diff --git a/ansible/playbooks/provision-agent-host.yml b/ansible/playbooks/provision-agent-host.yml index a51aab7..dd2342a 100644 --- a/ansible/playbooks/provision-agent-host.yml +++ b/ansible/playbooks/provision-agent-host.yml @@ -23,10 +23,13 @@ # --- Secret source ---------------------------------------------------- # This homelab keeps Ansible secrets in ansible-vault encrypted files, so - # that is the live form. vault_multica_pat is defined in - # group_vars/multica_agent_hosts/vault.yml (encrypted); the indirection - # keeps the name greppable and the file swappable. - multica_pat: "{{ vault_multica_pat }}" + # that is the live form. One PAT per host (named daemon-), held in + # the global vault (group_vars/all/vault) as a dictionary keyed by short + # hostname: + # vault_multica_pats: { haunted-mansion: mul_…, spaceship-earth: mul_… } + # A host with no entry fails the pre_task below, by name, before anything + # runs. The indirection keeps the name greppable and the file swappable. + multica_pat: "{{ vault_multica_pats[inventory_hostname_short] }}" # # Alternatives, if the backend ever consolidates. Each is a one-line # change here; the role is untouched by any of them. @@ -42,5 +45,22 @@ # Environment variable on the control node: # multica_pat: "{{ lookup('env', 'MULTICA_PAT') }}" + # The role's own PAT assert is no_log, so a missing dictionary entry would + # surface there only as "censored". This checks key presence, never the + # value, so its failure can name the host. + pre_tasks: + - name: Require a per-host Multica PAT in the vault + ansible.builtin.assert: + that: + - vault_multica_pats is defined + - vault_multica_pats is mapping + - inventory_hostname_short in vault_multica_pats + fail_msg: >- + No Multica PAT for {{ inventory_hostname_short }}: add + vault_multica_pats['{{ inventory_hostname_short }}'] to + group_vars/all/vault. + quiet: true + tags: [always, multica_pat_check] + roles: - role: multica_agent_host diff --git a/ansible/playbooks/provision-wong-host.yml b/ansible/playbooks/provision-wong-host.yml new file mode 100644 index 0000000..4ef4926 --- /dev/null +++ b/ansible/playbooks/provision-wong-host.yml @@ -0,0 +1,84 @@ +--- +# ============================================================================= +# provision-wong-host.yml — spaceship-earth (WDWL-22) +# +# spaceship-earth runs exactly ONE agent: wong, the personal vault's librarian +# and its only writer. No other agent may be pinned to its runtime. The +# isolation is enforced by the OS, not by agent instructions: Obsidian Sync +# runs as `obsync`, and Wong's daemon user (multica) can reach inbox/ only. +# +# Run order on a fresh host: +# 1. ansible-playbook playbooks/provision-agent-host.yml --limit spaceship-earth +# (multica daemon user, Node.js, multica CLI, Claude Code) +# 2. ansible-playbook playbooks/provision-wong-host.yml +# (root LV growth, headless Obsidian, vault folder isolation) +# +# Step 2 needs Node.js and the multica user from step 1, and asserts both. +# The Obsidian account sign-in is manual; see roles/obsidian_headless/README.md. +# ============================================================================= + +- name: Grow the root filesystem to fill the disk + hosts: spaceship-earth + become: true + tags: [wong_disk] + + vars: + root_vg: ubuntu-vg + root_lv: ubuntu-lv + + tasks: + - name: Disk | Find the PV backing the root VG + ansible.builtin.set_fact: + _root_pv: >- + {{ ansible_facts['lvm']['pvs'] | dict2items + | selectattr('value.vg', 'equalto', root_vg) + | map(attribute='key') | first }} + + - name: Disk | Split the PV into disk and partition number + ansible.builtin.set_fact: + _root_disk: "{{ _root_pv | regex_replace('p?[0-9]+$', '') }}" + _root_partno: "{{ _root_pv | regex_search('[0-9]+$') }}" + + - name: Disk | Install growpart + ansible.builtin.package: + name: cloud-guest-utils + state: present + + # growpart exits 1 with "NOCHANGE" when the partition already fills the disk. + - name: Disk | Grow the partition + ansible.builtin.command: + argv: [growpart, "{{ _root_disk }}", "{{ _root_partno }}"] + register: _growpart + changed_when: _growpart.rc == 0 and 'CHANGED' in _growpart.stdout + failed_when: _growpart.rc != 0 and 'NOCHANGE' not in _growpart.stdout + + - name: Disk | Grow the PV and VG + community.general.lvg: + vg: "{{ root_vg }}" + pvs: "{{ _root_pv }}" + pvresize: true + + - name: Disk | Extend the root LV and its filesystem into the free space + community.general.lvol: + vg: "{{ root_vg }}" + lv: "{{ root_lv }}" + size: +100%FREE + resizefs: true + +- name: Headless Obsidian Sync with vault folder isolation + hosts: spaceship-earth + become: true + tags: [obsidian] + + vars: + obsidian_headless_vault_path: /srv/obsidian/vault + obsidian_headless_device_name: spaceship-earth + obsidian_headless_private_folders: + - everpure + - wip + obsidian_headless_shared_folders: + - path: inbox + user: multica + + roles: + - role: obsidian_headless diff --git a/ansible/roles/obsidian_headless/README.md b/ansible/roles/obsidian_headless/README.md new file mode 100644 index 0000000..4aaabd1 --- /dev/null +++ b/ansible/roles/obsidian_headless/README.md @@ -0,0 +1,78 @@ +# obsidian_headless + +Runs Obsidian's official headless Sync client +([`obsidian-headless`](https://obsidian.md/help/sync/headless), command `ob`, +open beta) as a dedicated unprivileged user under a systemd unit. It lays out +the vault folder so that other local users reach only the folders granted to +them. + +Written for `spaceship-earth` (WDWL-22). Wong's daemon user (`multica`) may +read and write `inbox/` and nothing else. `everpure/` and `wip/` are `obsync` +only. Entry point: `playbooks/provision-wong-host.yml`. + +## What it does + +1. Creates the system user `obsync`, with a `0700` home in `/var/lib/obsync`. + That home holds the Obsidian account token and per-vault sync state. +2. Installs the pinned `obsidian-headless` into that user's npm prefix and + links `ob` into `/usr/local/bin`. Node.js is **not** installed here. The + role asserts that the runtime `multica_agent_host` installs is present and + is at least version 22. +3. Lays out the vault folder and its permissions: + + | Path | Mode | ACL | + |---|---|---| + | `/srv/obsidian/` | `0710 obsync` | `user:multica:--x` | + | `/srv/obsidian/vault/` | `0710 obsync` | `user:multica:--x`, so `multica` can traverse but not list | + | `vault/everpure/`, `vault/wip/` | `0700 obsync` | none | + | `vault/inbox/` | `0770 obsync` | `user:multica:rwx`, plus the same as a **default** entry | + + On a directory with an ACL, the mode's group bits are the ACL mask. A + `chmod 0700` would therefore void every named entry, which is why the + shared paths use `0710` and `0770`. The owning group is `obsync`'s private + group, so those bits grant nobody anything. +4. Installs `obsidian-sync.service` (`User=obsync`, `UMask=0077`, sandboxed) + and enables it. It is started only once a vault has been set up. + `Condition*=` lines keep it inert until then, including across reboots. +5. Verifies the isolation as the users themselves: + - `multica` writes, reads and deletes in `inbox/`. + - A folder and file created by `obsync` under umask `0077` (the way Sync + creates them) stay readable and writable by `multica`. + - `multica` is refused both listing the vault root and listing or writing + `everpure/` and `wip/`. + +## Do modes and ACLs survive Sync? + +Obsidian Sync carries no Unix modes or ACLs. Files are created locally by +`obsync`, so their permissions come from this host, not from the remote: + +- In `inbox/`, the default ACL applies and the umask is ignored, so new + content is granted to `multica`. +- Everywhere else, `UMask=0077` applies, so new content is private to + `obsync`. That covers any new top-level folder and `.obsidian/`. +- If Sync deletes and recreates `inbox/` itself, the recreated folder is + private and `multica` loses access. The failure is closed, not open, and + re-running the play restores the grant. + +## Manual step: sign in and set up the vault (once per host) + +The role never handles the Obsidian account. Run these as `obsync`; `-H` +makes `HOME` point to `/var/lib/obsync`: + +```bash +sudo -u obsync -H ob login +sudo -u obsync -H ob sync-list-remote +sudo -u obsync -H ob sync-setup --vault "" \ + --path /srv/obsidian/vault --device-name spaceship-earth +sudo systemctl start obsidian-sync +journalctl -u obsidian-sync -f +``` + +Re-running the play after this starts the unit as well. Do not also run +desktop Obsidian Sync on this host: Obsidian warns that two Sync clients on +one device conflict. + +## Tags + +`obsidian_user`, `obsidian_install`, `obsidian_layout`, `obsidian_service`, +`obsidian_verify`. The play itself is tagged `obsidian`. diff --git a/ansible/roles/obsidian_headless/defaults/main.yml b/ansible/roles/obsidian_headless/defaults/main.yml new file mode 100644 index 0000000..073cc05 --- /dev/null +++ b/ansible/roles/obsidian_headless/defaults/main.yml @@ -0,0 +1,64 @@ +--- +# ============================================================================= +# obsidian_headless — defaults +# +# Runs Obsidian's official headless Sync client (`ob`, npm obsidian-headless) +# as a dedicated unprivileged user under a systemd unit, and lays out the vault +# folder so that other local users reach only the folders granted to them. +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Service account +# ----------------------------------------------------------------------------- +# Holds the Obsidian account token (~/.config/obsidian-headless/auth_token) and +# the per-vault sync state, so its home is private. +obsidian_headless_user: obsync +obsidian_headless_group: obsync +obsidian_headless_home: "/var/lib/{{ obsidian_headless_user }}" + +# ----------------------------------------------------------------------------- +# Client +# ----------------------------------------------------------------------------- +# Pinned. The package is in open beta and versions move quickly. +obsidian_headless_version: "0.0.14" +obsidian_headless_npm_prefix: "{{ obsidian_headless_home }}/.npm-global" +# Node.js is NOT installed by this role. It expects the runtime that +# multica_agent_host already puts on this host. obsidian-headless needs >= 22. +obsidian_headless_node_bin_dir: /usr/local/bin +obsidian_headless_node_min_version: "22.0.0" +# Where `ob` is linked, so `sudo -u obsync -H ob ...` works without a PATH. +obsidian_headless_link_dir: /usr/local/bin + +# ----------------------------------------------------------------------------- +# Vault +# ----------------------------------------------------------------------------- +obsidian_headless_vault_path: /srv/obsidian/vault +# Shown in Sync version history. Applied by the manual `ob sync-setup`. +obsidian_headless_device_name: "{{ inventory_hostname_short }}" + +# Top-level folders only obsidian_headless_user may enter (0700, no ACL). +obsidian_headless_private_folders: [] + +# Top-level folders another local user may read and write. Granted with named +# ACL entries plus matching DEFAULT entries, so files and folders Sync creates +# later inherit the grant. Everything not listed here is private: the unit runs +# with UMask=0077, and the vault root grants listed users traverse (--x) only, +# never list. +# - path: inbox +# user: multica +obsidian_headless_shared_folders: [] + +# ----------------------------------------------------------------------------- +# Service +# ----------------------------------------------------------------------------- +obsidian_headless_service_name: obsidian-sync +# Enabled either way; started only once `ob login` and `ob sync-setup` have +# been done by hand. Its Condition*= lines keep it inert until then. +obsidian_headless_service_enabled: true + +# ----------------------------------------------------------------------------- +# Verification +# ----------------------------------------------------------------------------- +# Prove the isolation as the shared users themselves: read/write in their +# folders, denied in every private folder and on listing the vault root. +obsidian_headless_verify: true diff --git a/ansible/roles/obsidian_headless/handlers/main.yml b/ansible/roles/obsidian_headless/handlers/main.yml new file mode 100644 index 0000000..32f9d20 --- /dev/null +++ b/ansible/roles/obsidian_headless/handlers/main.yml @@ -0,0 +1,8 @@ +--- +# Restarts only a unit that is already running: before the manual sign-in the +# unit is inert, and starting it here would just trip its conditions. +- name: Restart obsidian sync + ansible.builtin.command: + argv: [systemctl, try-restart, "{{ obsidian_headless_service_name }}.service"] + changed_when: true + listen: restart obsidian sync diff --git a/ansible/roles/obsidian_headless/meta/main.yml b/ansible/roles/obsidian_headless/meta/main.yml new file mode 100644 index 0000000..4981351 --- /dev/null +++ b/ansible/roles/obsidian_headless/meta/main.yml @@ -0,0 +1,15 @@ +--- +galaxy_info: + role_name: obsidian_headless + author: JARVIS + description: >- + Obsidian's official headless Sync client as a dedicated user under a + systemd unit, with per-folder isolation for other local users. + license: MIT + min_ansible_version: "2.16" + platforms: + - name: Ubuntu + versions: + - noble + +dependencies: [] diff --git a/ansible/roles/obsidian_headless/tasks/install.yml b/ansible/roles/obsidian_headless/tasks/install.yml new file mode 100644 index 0000000..601085d --- /dev/null +++ b/ansible/roles/obsidian_headless/tasks/install.yml @@ -0,0 +1,39 @@ +--- +- name: Install | Read the installed client version + ansible.builtin.slurp: + src: "{{ obsidian_headless_npm_prefix }}/lib/node_modules/obsidian-headless/package.json" + register: _obh_pkg + failed_when: false + +- name: Install | Decide whether an install is needed + ansible.builtin.set_fact: + _obh_needs_install: >- + {{ _obh_pkg.content is not defined + or (_obh_pkg.content | b64decode | from_json).version != obsidian_headless_version }} + +- name: Install | Install the pinned client as the service user + ansible.builtin.command: + argv: + - "{{ obsidian_headless_node_bin_dir }}/npm" + - install + - --global + - --prefix + - "{{ obsidian_headless_npm_prefix }}" + - --no-fund + - --no-audit + - "obsidian-headless@{{ obsidian_headless_version }}" + become: true + become_user: "{{ obsidian_headless_user }}" + environment: + HOME: "{{ obsidian_headless_home }}" + PATH: "{{ obsidian_headless_node_bin_dir }}:/usr/bin:/bin" + when: _obh_needs_install | bool + changed_when: true + notify: restart obsidian sync + +- name: Install | Link ob onto the default PATH + ansible.builtin.file: + src: "{{ obsidian_headless_npm_prefix }}/bin/ob" + dest: "{{ obsidian_headless_link_dir }}/ob" + state: link + force: true diff --git a/ansible/roles/obsidian_headless/tasks/layout.yml b/ansible/roles/obsidian_headless/tasks/layout.yml new file mode 100644 index 0000000..f61ef7c --- /dev/null +++ b/ansible/roles/obsidian_headless/tasks/layout.yml @@ -0,0 +1,75 @@ +--- +# Permission model +# +# / obsync 0710 + user::--x traverse, no listing +# / obsync 0710 + user::--x traverse, no listing +# // obsync 0700 nobody else +# // obsync 0770 + user::rwx, and the same as a +# DEFAULT entry, so new children inherit +# +# The owning group is obsync's private group, so its bits grant nobody. On a +# directory that carries an ACL, the group bits of the mode ARE the ACL mask, +# which is why the modes here are 0710/0770 rather than 0700: a 0700 chmod +# would zero the mask and silently void every named entry. +# +# Anything else in the vault (folders Sync creates later, .obsidian/) is born +# private: the service runs with UMask=0077, and the umask is ignored only +# under a directory that has a default ACL — i.e. only in the shared folders. + +- name: Layout | Collect the users granted shared folders + ansible.builtin.set_fact: + _obh_shared_users: "{{ obsidian_headless_shared_folders | map(attribute='user') | unique | list }}" + +- name: Layout | Create the vault and its parent + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: "{{ obsidian_headless_user }}" + group: "{{ obsidian_headless_group }}" + mode: "{{ '0710' if _obh_shared_users | length > 0 else '0700' }}" + loop: + - "{{ obsidian_headless_vault_path | dirname }}" + - "{{ obsidian_headless_vault_path }}" + +- name: Layout | Let shared users traverse (not list) the vault and its parent + ansible.posix.acl: + path: "{{ item.0 }}" + entity: "{{ item.1 }}" + etype: user + permissions: x + state: present + loop: >- + {{ [obsidian_headless_vault_path | dirname, obsidian_headless_vault_path] + | product(_obh_shared_users) | list }} + +- name: Layout | Create the private folders + ansible.builtin.file: + path: "{{ obsidian_headless_vault_path }}/{{ item }}" + state: directory + owner: "{{ obsidian_headless_user }}" + group: "{{ obsidian_headless_group }}" + mode: "0700" + loop: "{{ obsidian_headless_private_folders }}" + +- name: Layout | Create the shared folders + ansible.builtin.file: + path: "{{ obsidian_headless_vault_path }}/{{ item.path }}" + state: directory + owner: "{{ obsidian_headless_user }}" + group: "{{ obsidian_headless_group }}" + mode: "0770" + loop: "{{ obsidian_headless_shared_folders }}" + loop_control: + label: "{{ item.path }}" + +- name: Layout | Grant shared folders (access and default entries) + ansible.posix.acl: + path: "{{ obsidian_headless_vault_path }}/{{ item.0.path }}" + entity: "{{ item.0.user }}" + etype: user + permissions: rwx + default: "{{ item.1 }}" + state: present + loop: "{{ obsidian_headless_shared_folders | product([false, true]) | list }}" + loop_control: + label: "{{ item.0.path }} -> {{ item.0.user }}{{ ' (default)' if item.1 else '' }}" diff --git a/ansible/roles/obsidian_headless/tasks/main.yml b/ansible/roles/obsidian_headless/tasks/main.yml new file mode 100644 index 0000000..f0427d4 --- /dev/null +++ b/ansible/roles/obsidian_headless/tasks/main.yml @@ -0,0 +1,27 @@ +--- +# Not `always`: that would also fire under unrelated tags of the calling +# playbook (e.g. --tags wong_disk). Every tag of this role carries it instead. +- name: Preflight + ansible.builtin.import_tasks: preflight.yml + tags: [obsidian, obsidian_user, obsidian_install, obsidian_layout, obsidian_service, obsidian_verify] + +- name: Service account + ansible.builtin.import_tasks: user.yml + tags: [obsidian_user] + +- name: Client + ansible.builtin.import_tasks: install.yml + tags: [obsidian_install] + +- name: Vault layout + ansible.builtin.import_tasks: layout.yml + tags: [obsidian_layout] + +- name: Service + ansible.builtin.import_tasks: service.yml + tags: [obsidian_service] + +- name: Verify + ansible.builtin.import_tasks: verify.yml + when: obsidian_headless_verify | bool + tags: [obsidian_verify] diff --git a/ansible/roles/obsidian_headless/tasks/preflight.yml b/ansible/roles/obsidian_headless/tasks/preflight.yml new file mode 100644 index 0000000..8bca537 --- /dev/null +++ b/ansible/roles/obsidian_headless/tasks/preflight.yml @@ -0,0 +1,33 @@ +--- +- name: Preflight | Read the Node.js version + ansible.builtin.command: + argv: ["{{ obsidian_headless_node_bin_dir }}/node", "--version"] + register: _obh_node + changed_when: false + failed_when: false + check_mode: false + +- name: Preflight | Require Node.js new enough for obsidian-headless + ansible.builtin.assert: + that: + - _obh_node.rc == 0 + - (_obh_node.stdout | trim | regex_replace('^v', '')) is version(obsidian_headless_node_min_version, '>=') + fail_msg: >- + {{ obsidian_headless_node_bin_dir }}/node is missing or older than + {{ obsidian_headless_node_min_version }} (found: + {{ _obh_node.stdout | default('nothing') | trim }}). This role uses the + runtime multica_agent_host installs; run provision-agent-host.yml first. + quiet: true + +- name: Preflight | Look up the users granted shared folders + ansible.builtin.getent: + database: passwd + key: "{{ item }}" + loop: "{{ obsidian_headless_shared_folders | map(attribute='user') | unique | list }}" + +- name: Preflight | Require shared folders to stay disjoint from private ones + ansible.builtin.assert: + that: + - obsidian_headless_shared_folders | map(attribute='path') | intersect(obsidian_headless_private_folders) | length == 0 + fail_msg: A folder cannot be both private and shared. + quiet: true diff --git a/ansible/roles/obsidian_headless/tasks/service.yml b/ansible/roles/obsidian_headless/tasks/service.yml new file mode 100644 index 0000000..d4a0107 --- /dev/null +++ b/ansible/roles/obsidian_headless/tasks/service.yml @@ -0,0 +1,31 @@ +--- +- name: Service | Install the unit + ansible.builtin.template: + src: obsidian-sync.service.j2 + dest: "/etc/systemd/system/{{ obsidian_headless_service_name }}.service" + owner: root + group: root + mode: "0644" + notify: restart obsidian sync + +- name: Service | Enable it at boot + ansible.builtin.systemd_service: + name: "{{ obsidian_headless_service_name }}" + enabled: "{{ obsidian_headless_service_enabled | bool }}" + daemon_reload: true + +# Starting it before the manual sign-in would be a no-op (its conditions fail) +# that reports "changed" on every run. Start it only once it can actually sync. +- name: Service | Check whether the manual sign-in and vault setup are done + ansible.builtin.find: + paths: "{{ obsidian_headless_home }}/.config/obsidian-headless/sync" + file_type: directory + register: _obh_sync_configs + +- name: Service | Start it once a vault is set up + ansible.builtin.systemd_service: + name: "{{ obsidian_headless_service_name }}" + state: started + when: + - obsidian_headless_service_enabled | bool + - _obh_sync_configs.matched | default(0) > 0 diff --git a/ansible/roles/obsidian_headless/tasks/user.yml b/ansible/roles/obsidian_headless/tasks/user.yml new file mode 100644 index 0000000..7c8a6c8 --- /dev/null +++ b/ansible/roles/obsidian_headless/tasks/user.yml @@ -0,0 +1,26 @@ +--- +- name: User | Create the group + ansible.builtin.group: + name: "{{ obsidian_headless_group }}" + system: true + state: present + +# nologin: nobody needs a shell as this user. `sudo -u obsync -H ob ...` runs +# the command directly and does not consult the login shell. +- name: User | Create the user + ansible.builtin.user: + name: "{{ obsidian_headless_user }}" + group: "{{ obsidian_headless_group }}" + home: "{{ obsidian_headless_home }}" + shell: /usr/sbin/nologin + system: true + create_home: true + state: present + +- name: User | Keep the home private (holds the Obsidian account token) + ansible.builtin.file: + path: "{{ obsidian_headless_home }}" + state: directory + owner: "{{ obsidian_headless_user }}" + group: "{{ obsidian_headless_group }}" + mode: "0700" diff --git a/ansible/roles/obsidian_headless/tasks/verify.yml b/ansible/roles/obsidian_headless/tasks/verify.yml new file mode 100644 index 0000000..aacaf72 --- /dev/null +++ b/ansible/roles/obsidian_headless/tasks/verify.yml @@ -0,0 +1,96 @@ +--- +# Each check runs AS the user it is about, because file modes and ACLs only +# mean something when exercised. Probes are cleaned up; nothing is left behind. + +- name: Verify | Shared users can write, read and delete in their folders + ansible.builtin.command: + argv: + - /bin/sh + - -c + - 'f="$1/.obh-verify.$$"; echo ok > "$f" && grep -q ok "$f" && rm -f "$f"' + - sh + - "{{ obsidian_headless_vault_path }}/{{ item.path }}" + become: true + become_user: "{{ item.user }}" + loop: "{{ obsidian_headless_shared_folders }}" + loop_control: + label: "{{ item.user }} -> {{ item.path }}" + changed_when: false + check_mode: false + +# Simulates the service: obsync, umask 0077, creating a folder and a file the +# way Sync does. The shared user must still reach both, via the default ACL. +- name: Verify | Content the service creates in a shared folder inherits the grant (create) + ansible.builtin.command: + argv: + - /bin/sh + - -c + - 'umask 0077; d="$1/.obh-verify-dir"; mkdir -p "$d" && echo ok > "$d/note.md"' + - sh + - "{{ obsidian_headless_vault_path }}/{{ item.path }}" + become: true + become_user: "{{ obsidian_headless_user }}" + loop: "{{ obsidian_headless_shared_folders }}" + loop_control: + label: "{{ item.path }}" + changed_when: false + check_mode: false + +- name: Verify | Content the service creates in a shared folder inherits the grant (use) + ansible.builtin.command: + argv: + - /bin/sh + - -c + - 'd="$1/.obh-verify-dir"; grep -q ok "$d/note.md" && echo ok > "$d/reply.md"' + - sh + - "{{ obsidian_headless_vault_path }}/{{ item.path }}" + become: true + become_user: "{{ item.user }}" + loop: "{{ obsidian_headless_shared_folders }}" + loop_control: + label: "{{ item.user }} -> {{ item.path }}" + register: _obh_inherit + changed_when: false + failed_when: false + check_mode: false + +- name: Verify | Remove the inheritance probes + ansible.builtin.file: + path: "{{ obsidian_headless_vault_path }}/{{ item.path }}/.obh-verify-dir" + state: absent + loop: "{{ obsidian_headless_shared_folders }}" + loop_control: + label: "{{ item.path }}" + changed_when: false + +- name: Verify | Inheritance held + ansible.builtin.assert: + that: + - _obh_inherit.results | rejectattr('rc', 'equalto', 0) | list | length == 0 + fail_msg: >- + A shared user could not read or write content created by + {{ obsidian_headless_user }} under umask 0077; the default ACL is not + being inherited. + quiet: true + +# Every shared user must be refused: listing the vault root, and listing, +# reading into or writing into each private folder. +- name: Verify | Shared users are denied the vault root listing and every private folder + ansible.builtin.command: + argv: >- + {{ ['/bin/sh', '-c', _obh_deny_script, 'sh', obsidian_headless_vault_path] + + obsidian_headless_private_folders }} + vars: + _obh_deny_script: | + v="$1"; shift + ls "$v" >/dev/null 2>&1 && { echo "can list $v"; exit 1; } + for p in "$@"; do + ls "$v/$p" >/dev/null 2>&1 && { echo "can list $p"; exit 1; } + touch "$v/$p/.obh-verify" 2>/dev/null && { rm -f "$v/$p/.obh-verify"; echo "can write $p"; exit 1; } + done + exit 0 + become: true + become_user: "{{ item }}" + loop: "{{ obsidian_headless_shared_folders | map(attribute='user') | unique | list }}" + changed_when: false + check_mode: false diff --git a/ansible/roles/obsidian_headless/templates/obsidian-sync.service.j2 b/ansible/roles/obsidian_headless/templates/obsidian-sync.service.j2 new file mode 100644 index 0000000..4d7e12f --- /dev/null +++ b/ansible/roles/obsidian_headless/templates/obsidian-sync.service.j2 @@ -0,0 +1,34 @@ +# {{ ansible_managed }} +[Unit] +Description=Obsidian Headless Sync ({{ obsidian_headless_vault_path }}) +Documentation=https://obsidian.md/help/sync/headless +Wants=network-online.target +After=network-online.target +# Inert until `ob login` and `ob sync-setup` have been run by hand. +ConditionPathExists={{ obsidian_headless_home }}/.config/obsidian-headless/auth_token +ConditionDirectoryNotEmpty={{ obsidian_headless_home }}/.config/obsidian-headless/sync + +[Service] +Type=simple +User={{ obsidian_headless_user }} +Group={{ obsidian_headless_group }} +# Everything Sync creates is private unless it lands under a folder with a +# default ACL (the shared folders), where the umask does not apply. +UMask=0077 +Environment=HOME={{ obsidian_headless_home }} +Environment=PATH={{ obsidian_headless_node_bin_dir }}:/usr/bin:/bin +WorkingDirectory={{ obsidian_headless_vault_path }} +ExecStart={{ obsidian_headless_link_dir }}/ob sync --continuous --path {{ obsidian_headless_vault_path }} +Restart=on-failure +RestartSec=30 +# 2: missing encryption key, 3: no sync configuration. Both need a human. +RestartPreventExitStatus=2 3 + +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=yes +ReadWritePaths={{ obsidian_headless_vault_path }} {{ obsidian_headless_home }} + +[Install] +WantedBy=multi-user.target