From d5292841410b45e2a3806b85f5fdb955d5ca9598 Mon Sep 17 00:00:00 2001 From: jerick Date: Tue, 30 Jun 2026 13:51:06 -0400 Subject: [PATCH] initial commit --- Jenkinsfile | 70 ++++++++++++++ README.md | 91 +++++++++++++++++++ inventories/inventory.proxmox.yml | 25 +++++ .../files/group.containers | 45 +++++++++ .../files/passwd.containers | 34 +++++++ .../tasks/jerick_access.yml | 45 +++++++++ roles/container_isolation/tasks/main.yml | 9 ++ .../container_isolation/tasks/sync_users.yml | 49 ++++++++++ roles/container_isolation/vars/main.yml | 58 ++++++++++++ site.yml | 13 +++ 10 files changed, 439 insertions(+) create mode 100644 Jenkinsfile create mode 100644 README.md create mode 100644 inventories/inventory.proxmox.yml create mode 100644 roles/container_isolation/files/group.containers create mode 100644 roles/container_isolation/files/passwd.containers create mode 100644 roles/container_isolation/tasks/jerick_access.yml create mode 100644 roles/container_isolation/tasks/main.yml create mode 100644 roles/container_isolation/tasks/sync_users.yml create mode 100644 roles/container_isolation/vars/main.yml create mode 100644 site.yml diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..df12b37 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,70 @@ +pipeline { + agent any + + parameters { + string( + name: 'LIMIT', + defaultValue: '', + description: 'Target host(s)/group(s), e.g. "vms", "lxcs", "docker_hosts", "kubernetes", "update_targets", or a hostname (no .lan required). Leave blank to run against all machines.' + ) + booleanParam( + name: 'DRY_RUN', + defaultValue: false, + description: 'Run in check mode (no changes made)' + ) + } + + environment { + PROXMOX_URL = 'https://192.168.0.166:8006' + PROXMOX_USER = 'dynamic-inventory@pve' + PROXMOX_TOKEN_ID = 'dynamic-inventory' + PROXMOX_TOKEN_SECRET = credentials('PROXMOX_TOKEN_SECRET') + ANSIBLE_HOST_KEY_CHECKING = 'False' + } + + stages { + stage('Checkout') { + steps { + checkout scm + } + } + + stage('Verify Inventory') { + steps { + sh ''' + echo "Testing dynamic inventory connection..." + ansible-inventory -i inventories/inventory.proxmox.yml --list | head -10 + ''' + } + } + + stage('Run Playbook') { + steps { + script { + def limitFlag = params.LIMIT ? "--limit '${params.LIMIT}'" : '' + def checkFlag = params.DRY_RUN ? '--check --diff' : '' + + echo "Limit: '${params.LIMIT ?: 'all'}'" + echo "Dry run: ${params.DRY_RUN}" + + sh """ + ansible-playbook \ + -i inventories/inventory.proxmox.yml \ + site.yml \ + ${limitFlag} \ + ${checkFlag} + """ + } + } + } + } + + post { + success { + echo "site.yml completed successfully (limit: ${params.LIMIT ?: 'all'})" + } + failure { + echo "site.yml failed (limit: ${params.LIMIT ?: 'all'})" + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..3ca75e9 --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# container_isolation Ansible role + +Creates per-app service accounts (UID/GID 2000+) on every host, so each +Docker container can run as its own dedicated UID instead of root or a +shared user. `jerick` is added as a secondary member of every app +group (and the shared `mediapipeline` group), so jerick retains access +everywhere regardless of whatever folder permissions you set up +separately on the NFS server. + +**Scope:** this role only manages accounts (groups + users). NFS +folder creation, ownership, and permission bits are handled outside +Ansible, by you, on the NFS server. + +## Layout + +``` +ansible/ +├── site.yml # top-level playbook, hosts: all +└── roles/container_isolation/ + ├── tasks/ + │ ├── main.yml # entry point, includes the others + │ ├── sync_users.yml # creates groups + users on every host + │ └── jerick_access.yml # adds jerick to every group, every host + ├── vars/main.yml # source of truth: app list, uids, shared groups + └── files/ + ├── passwd.containers # reference copy, human-readable + └── group.containers # reference copy, human-readable +``` + +`vars/main.yml` is what Ansible actually reads. The `files/*.containers` +files are a human-readable reference / diff-friendly changelog — not +consumed directly by any task, since the built-in `group`/`user` +modules create accounts idempotently rather than appending raw lines +to `/etc/passwd`. + +## Before running + +1. Point this at your dynamic inventory however you normally do, e.g.: + ```bash + ansible-playbook -i your_dynamic_inventory.py site.yml + ``` + No inventory group targeting is needed — `site.yml` runs against + `hosts: all`, and the role applies the same logic everywhere. +2. Confirm `jerick` already exists as a real user on every host. The + role does **not** create jerick's account (treated as a + pre-existing human user) — it only adds jerick to the app groups. + If jerick is missing on a host, the role prints a warning and skips + the group-membership tasks for that host rather than failing the + whole run. + +## Running + +```bash +ansible-playbook -i your_dynamic_inventory.py site.yml --check --diff # dry run first +ansible-playbook -i your_dynamic_inventory.py site.yml +``` + +Safe to re-run any time — every task uses Ansible's built-in +idempotent modules (`group`, `user`), so a second run produces no +changes. + +## Adding a new app later + +1. Add a line to `roles/container_isolation/vars/main.yml` under + `container_apps`, with the next free UID (currently up to 2026 — + use 2027 next). **Never reuse or renumber an existing UID** once + real folders have been chowned to it on the NFS server. +2. If it needs shared access to another app's data, add it to the + relevant `shared_groups[].members` list (or create a new shared + group, GID 3001+). +3. Update `files/passwd.containers` and `files/group.containers` to + match, for the human-readable record. +4. Re-run the playbook. + +## What you still need to do yourself + +- **NFS folder creation/ownership/permissions** on the NFS server — + `chown :` and `chmod` each app's folder using the UIDs/GIDs + from `vars/main.yml`. With jerick in every app's group, `770` + (owner+group rw, others none) gives jerick read-write access + everywhere while keeping apps isolated from each other; `750` would + make jerick read-only. +- **`/etc/exports`** on the NFS server — not touched by this role. +- **`docker-compose.yml` edits** — add `user: "UID:GID"` (and + `group_add:` for apps in `mediapipeline`) to each service, using the + UIDs from `vars/main.yml`. Happy to generate these once you share + your compose files. +- **NFS protocol version / idmapping verification** — confirm with + `nfsstat -m` on a client before relying on numeric UID/GID matching + across hosts; NFSv4 idmapping misconfiguration is the most common + cause of permissions silently not working as expected. diff --git a/inventories/inventory.proxmox.yml b/inventories/inventory.proxmox.yml new file mode 100644 index 0000000..f0ee66f --- /dev/null +++ b/inventories/inventory.proxmox.yml @@ -0,0 +1,25 @@ +# Proxmox Dynamic Inventory +# Requires PROXMOX_TOKEN_SECRET environment variable to be set +plugin: community.proxmox.proxmox +url: https://192.168.0.166:8006 +user: dynamic-inventory@pve +token_id: dynamic-inventory +validate_certs: false +want_facts: true + +# Filter to only running machines +filters: + - proxmox_status == 'running' + +# Group by Proxmox tags and type +groups: + vms: "'qemu' in proxmox_type" + lxcs: "'lxc' in proxmox_type" + # Tag-based groups - add tags in Proxmox to auto-group + update_targets: "'update' in (proxmox_tags | default([]))" + docker_hosts: "'docker' in (proxmox_tags | default([]))" + kubernetes: "'k8s' in (proxmox_tags | default([]))" + +compose: + ansible_host: name + '.lan' + ansible_user: 'jenkins' diff --git a/roles/container_isolation/files/group.containers b/roles/container_isolation/files/group.containers new file mode 100644 index 0000000..5e51489 --- /dev/null +++ b/roles/container_isolation/files/group.containers @@ -0,0 +1,45 @@ +# /etc/group.containers +# Source of truth for container service groups. Sync to all Docker VMs +# and the NFS server using sync-container-users.sh. Append-only — add new +# lines here when adding containers, never renumber existing ones. +# +# Primary per-app groups mirror passwd.containers UIDs (2000-2099). +# Shared groups start at 3000 for cross-container access. +# format: name:x:GID:secondary,members +2fauth:x:2000: +audiobookshelf:x:2001: +authelia:x:2002: +bazarr:x:2003: +cronjobs:x:2004: +dashy:x:2005: +dingus-archiver:x:2006: +finance-app:x:2007: +firefly3:x:2008: +gitea:x:2009: +gotify:x:2010: +immich:x:2011: +it-tools:x:2012: +jellyfin:x:2013: +joplin:x:2014: +kiwix:x:2015: +music-orchestrator:x:2016: +nginxproxy:x:2017: +ombi:x:2018: +picoshare:x:2019: +prowlarr:x:2020: +qbittorrent:x:2021: +radarr:x:2022: +romm:x:2023: +sonarr:x:2024: +tandoor_recipes:x:2025: +vault:x:2026: +# +# Shared groups (cross-container access, GID range 3000+) +# mediapipeline: *arr apps + qbittorrent + jellyfin share the media/download +# folders (e.g. /var/NFSFolder/AppData/media). Ombi is request-only (talks +# to APIs, doesn't touch the filesystem) so it's excluded. +mediapipeline:x:3000:bazarr,prowlarr,qbittorrent,radarr,sonarr,jellyfin +# +# finance-app and dingus-archiver are isolated custom apps with no shared +# access — their primary groups above (2007, 2006) already cover them. +# No secondary group needed unless that changes. diff --git a/roles/container_isolation/files/passwd.containers b/roles/container_isolation/files/passwd.containers new file mode 100644 index 0000000..88df899 --- /dev/null +++ b/roles/container_isolation/files/passwd.containers @@ -0,0 +1,34 @@ +# /etc/passwd.containers +# Source of truth for container service accounts. Sync to all Docker VMs +# and the NFS server using sync-container-users.sh. Append-only — add new +# lines here when adding containers, never renumber existing ones. +# +# UID range reserved: 2000-2099 (container service accounts) +# format: name:x:UID:GID::/nonexistent:/usr/sbin/nologin +2fauth:x:2000:2000::/nonexistent:/usr/sbin/nologin +audiobookshelf:x:2001:2001::/nonexistent:/usr/sbin/nologin +authelia:x:2002:2002::/nonexistent:/usr/sbin/nologin +bazarr:x:2003:2003::/nonexistent:/usr/sbin/nologin +cronjobs:x:2004:2004::/nonexistent:/usr/sbin/nologin +dashy:x:2005:2005::/nonexistent:/usr/sbin/nologin +dingus-archiver:x:2006:2006::/nonexistent:/usr/sbin/nologin +finance-app:x:2007:2007::/nonexistent:/usr/sbin/nologin +firefly3:x:2008:2008::/nonexistent:/usr/sbin/nologin +gitea:x:2009:2009::/nonexistent:/usr/sbin/nologin +gotify:x:2010:2010::/nonexistent:/usr/sbin/nologin +immich:x:2011:2011::/nonexistent:/usr/sbin/nologin +it-tools:x:2012:2012::/nonexistent:/usr/sbin/nologin +jellyfin:x:2013:2013::/nonexistent:/usr/sbin/nologin +joplin:x:2014:2014::/nonexistent:/usr/sbin/nologin +kiwix:x:2015:2015::/nonexistent:/usr/sbin/nologin +music-orchestrator:x:2016:2016::/nonexistent:/usr/sbin/nologin +nginxproxy:x:2017:2017::/nonexistent:/usr/sbin/nologin +ombi:x:2018:2018::/nonexistent:/usr/sbin/nologin +picoshare:x:2019:2019::/nonexistent:/usr/sbin/nologin +prowlarr:x:2020:2020::/nonexistent:/usr/sbin/nologin +qbittorrent:x:2021:2021::/nonexistent:/usr/sbin/nologin +radarr:x:2022:2022::/nonexistent:/usr/sbin/nologin +romm:x:2023:2023::/nonexistent:/usr/sbin/nologin +sonarr:x:2024:2024::/nonexistent:/usr/sbin/nologin +tandoor_recipes:x:2025:2025::/nonexistent:/usr/sbin/nologin +vault:x:2026:2026::/nonexistent:/usr/sbin/nologin diff --git a/roles/container_isolation/tasks/jerick_access.yml b/roles/container_isolation/tasks/jerick_access.yml new file mode 100644 index 0000000..992edb0 --- /dev/null +++ b/roles/container_isolation/tasks/jerick_access.yml @@ -0,0 +1,45 @@ +--- +# roles/container_isolation/tasks/jerick_access.yml +# +# jerick needs read-write access across every app folder for admin/ +# maintenance purposes, without weakening isolation between apps from +# each other. Achieved by making jerick a secondary member of every +# app's primary group, combined with 770 permissions on app folders +# (see nfs_folders.yml) — owner: full access, group (incl. jerick): +# full access, others: none. + +- name: Check whether jerick exists on this host (read-only, does not create the account) + ansible.builtin.getent: + database: passwd + key: "{{ jerick_user }}" + register: jerick_lookup + failed_when: false + +- name: Warn if jerick user does not exist on this host + ansible.builtin.debug: + msg: >- + WARNING: user '{{ jerick_user }}' was not found on this host. + This role does not create jerick's account (assumed to be a + pre-existing human user, not a service account). Create it first, + then re-run this role so jerick gets added to the app groups below. + when: jerick_lookup.failed | default(false) + +- name: Add jerick to every app's primary group + ansible.builtin.user: + name: "{{ jerick_user }}" + groups: "{{ item.name }}" + append: true + loop: "{{ container_apps }}" + loop_control: + label: "{{ jerick_user }} -> {{ item.name }}" + when: not (jerick_lookup.failed | default(false)) + +- name: Add jerick to every shared group + ansible.builtin.user: + name: "{{ jerick_user }}" + groups: "{{ item.name }}" + append: true + loop: "{{ shared_groups }}" + loop_control: + label: "{{ jerick_user }} -> {{ item.name }}" + when: not (jerick_lookup.failed | default(false)) diff --git a/roles/container_isolation/tasks/main.yml b/roles/container_isolation/tasks/main.yml new file mode 100644 index 0000000..8d94cfc --- /dev/null +++ b/roles/container_isolation/tasks/main.yml @@ -0,0 +1,9 @@ +--- +# roles/container_isolation/tasks/main.yml + +- name: Sync container service groups and users (all hosts) + import_tasks: sync_users.yml + +- name: Add jerick to every app and shared group (all hosts) + import_tasks: jerick_access.yml + diff --git a/roles/container_isolation/tasks/sync_users.yml b/roles/container_isolation/tasks/sync_users.yml new file mode 100644 index 0000000..5be42c1 --- /dev/null +++ b/roles/container_isolation/tasks/sync_users.yml @@ -0,0 +1,49 @@ +--- +# roles/container_isolation/tasks/sync_users.yml +# +# Creates each app's primary group + user, and each shared group, using +# Ansible's built-in group/user modules instead of hand-rolled file +# appends — this is idempotent and safe to re-run, and avoids the +# malformed-/etc/passwd risk of manual editing. + +- name: Create primary group for each container app + ansible.builtin.group: + name: "{{ item.name }}" + gid: "{{ item.uid }}" + state: present + loop: "{{ container_apps }}" + loop_control: + label: "{{ item.name }} (gid {{ item.uid }})" + +- name: Create shared (secondary) groups + ansible.builtin.group: + name: "{{ item.name }}" + gid: "{{ item.gid }}" + state: present + loop: "{{ shared_groups }}" + loop_control: + label: "{{ item.name }} (gid {{ item.gid }})" + +- name: Create service user for each container app + ansible.builtin.user: + name: "{{ item.name }}" + uid: "{{ item.uid }}" + group: "{{ item.name }}" + shell: /usr/sbin/nologin + home: /nonexistent + create_home: false + system: true + state: present + loop: "{{ container_apps }}" + loop_control: + label: "{{ item.name }} (uid {{ item.uid }})" + +- name: Add app users to their shared groups + ansible.builtin.user: + name: "{{ item.0.name }}" + groups: "{{ item.1.name }}" + append: true + loop: "{{ container_apps | product(shared_groups) | list }}" + loop_control: + label: "{{ item.0.name }} -> {{ item.1.name }}" + when: item.0.name in item.1.members diff --git a/roles/container_isolation/vars/main.yml b/roles/container_isolation/vars/main.yml new file mode 100644 index 0000000..6b975f9 --- /dev/null +++ b/roles/container_isolation/vars/main.yml @@ -0,0 +1,58 @@ +--- +# roles/container_isolation/vars/main.yml +# +# Single source of truth for container service accounts. Mirrors +# files/passwd.containers and files/group.containers — keep in sync if +# you edit one, edit the other. Append-only: never renumber existing +# UIDs/GIDs once folders have been chowned on the NFS server. + +jerick_user: jerick + +# Every container service account. uid == primary gid for each. +# jerick is added as a secondary member of every app's primary group +# in the group sync task, giving him rw access (770) without weakening +# isolation between apps. +container_apps: + - { name: "2fauth", uid: 2000 } + - { name: "audiobookshelf", uid: 2001 } + - { name: "authelia", uid: 2002 } + - { name: "bazarr", uid: 2003 } + - { name: "cronjobs", uid: 2004 } + - { name: "dashy", uid: 2005 } + - { name: "dingus-archiver", uid: 2006 } + - { name: "finance-app", uid: 2007 } + - { name: "firefly3", uid: 2008 } + - { name: "gitea", uid: 2009 } + - { name: "gotify", uid: 2010 } + - { name: "immich", uid: 2011 } + - { name: "it-tools", uid: 2012 } + - { name: "jellyfin", uid: 2013 } + - { name: "joplin", uid: 2014 } + - { name: "kiwix", uid: 2015 } + - { name: "music-orchestrator", uid: 2016 } + - { name: "nginxproxy", uid: 2017 } + - { name: "ombi", uid: 2018 } + - { name: "picoshare", uid: 2019 } + - { name: "prowlarr", uid: 2020 } + - { name: "qbittorrent", uid: 2021 } + - { name: "radarr", uid: 2022 } + - { name: "romm", uid: 2023 } + - { name: "sonarr", uid: 2024 } + - { name: "tandoor_recipes", uid: 2025 } + - { name: "vault", uid: 2026 } + +# Shared secondary groups for cross-container access. "members" lists +# app names from container_apps that need to be added to this group +# IN ADDITION to their own primary group. jerick is added automatically +# to every shared group as well as every per-app primary group — see +# tasks/sync_users.yml. +shared_groups: + - name: mediapipeline + gid: 3000 + members: + - bazarr + - prowlarr + - qbittorrent + - radarr + - sonarr + - jellyfin diff --git a/site.yml b/site.yml new file mode 100644 index 0000000..9cfe89b --- /dev/null +++ b/site.yml @@ -0,0 +1,13 @@ +--- +# site.yml +# +# Applies the container_isolation role to every host in inventory. +# Scope: service accounts only (groups + users + jerick's group +# memberships). Run against your dynamic inventory as-is — no group +# targeting needed since every host gets the same treatment. + +- name: Configure container service accounts + hosts: all + become: true + roles: + - container_isolation