spaceship-earth: per-host Multica PAT, root LV growth, headless Obsidian (WDWL-22)
- provision-agent-host.yml: multica_pat now comes from vault_multica_pats[inventory_hostname_short], one daemon-<host> token per host. A pre_task asserts the key exists (never prints the value) so a missing entry fails by host name instead of as a no_log "censored". - provision-wong-host.yml: spaceship-earth only. Grows the root partition, PV and LV/filesystem with growpart + community.general.lvg/lvol; idempotent (second run changed=0). Then applies obsidian_headless. - roles/obsidian_headless: Obsidian's official headless Sync client (obsidian-headless 0.0.14) as system user obsync under a sandboxed systemd unit with UMask=0077. Vault at /srv/obsidian/vault: everpure/ and wip/ obsync-only, inbox/ granted to multica via access + default ACLs, vault root traverse-only. Verifies the isolation as multica. Sign-in to Obsidian Sync stays manual (see the role README). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
04a0937910
commit
15aeb8e222
14 files changed
+634
-4
No files matched your search
@@ -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
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
# Permission model
|
||||
#
|
||||
# <parent>/ obsync 0710 + user:<shared>:--x traverse, no listing
|
||||
# <vault>/ obsync 0710 + user:<shared>:--x traverse, no listing
|
||||
# <vault>/<private>/ obsync 0700 nobody else
|
||||
# <vault>/<shared>/ obsync 0770 + user:<u>: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 '' }}"
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
Reference in new issue
Block a user