diff --git a/AGENTS.md b/AGENTS.md index 8ae3dd8..25a5e43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,6 +66,8 @@ Ansible-driven personal infrastructure repo for Fedora and Void desktops, Fedora `ansible-playbook ansible/site.yml --limit atlas --tags gitea --check --diff` - Atlas canonical Gitea domain (restarts only Gitea on a real configuration change): `ansible-playbook ansible/site.yml --limit atlas --tags gitea_public_domain --check --diff` + - Atlas Nextcloud/ONLYOFFICE steady state: + `ansible-playbook ansible/site.yml --limit atlas --tags nextcloud --check --diff` - Atlas iCloudPD storage and boot-started Quadlet: `ansible-playbook ansible/site.yml --limit atlas --tags icloudpd --check --diff` - Ongoing Gitea proxy configuration: @@ -363,10 +365,21 @@ successfully. The first monthly scrub remains a runtime check. - [x] Validate Gitea login and write via HTTPS. On 2026-10-03 the operator confirmed authenticated web login and Git clone/pull/push through the public HTTPS endpoint. Do not restart the stale source Gitea after Atlas has accepted writes. -- [ ] Design and deploy Nextcloud as another explicitly temporary Atlas service before Uranus. Give it - separate persistent application, database, and cache storage; keep credentials in Vault; publish it only - through NPM over the Prometheus--Aegis gateway; and define backup, upgrade, and eventual Uranus-migration - procedures before exposing user data. Do not deploy Nextcloud before the data-protection checklist is complete. +- [x] Design and deploy the empty temporary Atlas Nextcloud/ONLYOFFICE stack on 2026-10-03. + The operator explicitly authorized empty internal service startup before the first scrub; + this does not close the scrub or protection checks. Four rootless Quadlets, separate + component datasets, pinned images/apps, Vault secrets, standard fabio/chiara users, a + separate application admin and the Famiglia folder are deployed. Cron and internal Office + connection checks succeeded; repeat deployment changed nothing. See `docs/atlas-nextcloud.md`. +- [x] Complete the authorized empty-stack public cutover on 2026-10-03 after operator + DNS/NPM configuration. Both hostnames passed TLS and HTTPS redirects; authenticated + web login, WebDAV, private-file isolation, Famiglia cross-user create/read/update/delete and + CalDAV/CardDAV discovery passed. The Office connector and public health/API asset passed. + Temporary test files were removed; no iCloud data was imported. +- [ ] Complete Nextcloud desktop/mobile editing and synchronization acceptance, and + application-consistent backup/restore validation. Close the first actual scrub and + protection checks before importing family data. + iCloud migration and future Uranus transfer remain separate operations, not playbook flags. - [x] Move Gitea canonical HTTPS and SSH hostname to `git.fscotto.co` on 2026-10-03 through Ansible. Only Gitea restarted; second run changed nothing. HTTPS and authenticated SSH reads returned the same repository HEAD. diff --git a/ansible/inventory/host_vars/atlas.yml b/ansible/inventory/host_vars/atlas.yml index 39b8694..6d60e54 100644 --- a/ansible/inventory/host_vars/atlas.yml +++ b/ansible/inventory/host_vars/atlas.yml @@ -49,6 +49,38 @@ atlas_zfs_backup_reservation: 500G atlas_zfs_dataset_photobook: media/photobook atlas_mount_root: /zpool atlas_manage_storage: true +atlas_manage_nextcloud: true +atlas_nextcloud_domain: cloud.fscotto.co +atlas_onlyoffice_domain: office.fscotto.co +# Resolved official amd64 images on 2026-10-03; updates are deliberate. +atlas_nextcloud_image: docker.io/library/nextcloud:33.0.9-apache@sha256:a97666d6ae931bde78a80cfba8abdf46d436d7b540f31895803f6fb0a012d689 +atlas_nextcloud_postgres_image: docker.io/library/postgres:17-bookworm@sha256:639ab7ceb90e13123085b741fb31ef493fba25463002f6da665352e7b534b652 +atlas_nextcloud_redis_image: docker.io/library/redis:7.4-bookworm@sha256:c6eabf748fc7a61dbb5a705c78bcf3d6377b1127a97d0ce965c11c44ba46896f +atlas_onlyoffice_image: docker.io/onlyoffice/documentserver:9.4.0.1@sha256:3ab6ebc7c605e5a32b7ae3ff19daed4925090245acc8100ce2230bd766c88212 +atlas_nextcloud_users: + - username: fabio + display_name: Fabio + password: "{{ vault_nextcloud_fabio_password }}" + - username: chiara + display_name: Chiara + password: "{{ vault_nextcloud_chiara_password }}" +atlas_nextcloud_apps: + - id: groupfolders + version: 21.0.9 + url: https://github.com/nextcloud-releases/groupfolders/releases/download/v21.0.9/groupfolders-v21.0.9.tar.gz + checksum: sha256:d8b95f0778425f646f2311ba5b42d8e2fcfdf37dc2fd35fcac8d3f01bde38a21 + - id: onlyoffice + version: 10.2.1 + url: https://github.com/ONLYOFFICE/onlyoffice-nextcloud/releases/download/v10.2.1/onlyoffice.tar.gz + checksum: sha256:144998af0610ccd17ee8d7025e2f8001472da03f6dab90ff38039247825e3a9b + - id: contacts + version: 8.9.1 + url: https://github.com/nextcloud-releases/contacts/releases/download/v8.9.1/contacts-v8.9.1.tar.gz + checksum: sha256:a25cdf448b192631b8e8eb7addc31b40382b33871b10521f4308ac5a6e0457bf + - id: calendar + version: 6.6.2 + url: https://github.com/nextcloud-releases/calendar/releases/download/v6.6.2/calendar-v6.6.2.tar.gz + checksum: sha256:7e83632d4436d3037a34d1c73cbc06d5ccb6e8fc10f096a86a43a0515588529c # Rootless Gitea was restored from the stopped-source export before production activation. atlas_manage_gitea: true atlas_gitea_production_enabled: true diff --git a/ansible/roles/profile_atlas/defaults/main.yml b/ansible/roles/profile_atlas/defaults/main.yml index d786a58..69193d8 100644 --- a/ansible/roles/profile_atlas/defaults/main.yml +++ b/ansible/roles/profile_atlas/defaults/main.yml @@ -1,5 +1,29 @@ --- atlas_manage_storage: false +atlas_manage_nextcloud: false +atlas_nextcloud_root: "{{ atlas_app_data_mountpoint }}/nextcloud" +atlas_nextcloud_dataset: "{{ atlas_zfs_pool }}/services/data/nextcloud" +atlas_nextcloud_domain: "" +atlas_onlyoffice_domain: "" +atlas_nextcloud_http_port: 8080 +atlas_onlyoffice_http_port: 8081 +atlas_nextcloud_network_subnet: 10.90.10.0/24 +atlas_nextcloud_network_gateway: 10.90.10.1 +atlas_nextcloud_quadlet_dir: "{{ atlas_admin_home }}/.config/containers/systemd" +atlas_nextcloud_private_dir: "{{ atlas_admin_home }}/.config/atlas-nextcloud" +atlas_nextcloud_app_cache: "{{ atlas_admin_home }}/.cache/atlas-nextcloud-apps" +atlas_nextcloud_image: "" +atlas_nextcloud_postgres_image: "" +atlas_nextcloud_redis_image: "" +atlas_onlyoffice_image: "" +atlas_nextcloud_admin: admin +atlas_nextcloud_users: [] +atlas_nextcloud_apps: [] +atlas_nextcloud_services: + - atlas-nextcloud-db.service + - atlas-nextcloud-redis.service + - atlas-nextcloud.service + - atlas-onlyoffice.service atlas_manage_sharing: false # Destructive first-boot action; normally false once the pool exists. atlas_create_pool: false diff --git a/ansible/roles/profile_atlas/tasks/main.yml b/ansible/roles/profile_atlas/tasks/main.yml index 97bb3a9..6e2810e 100644 --- a/ansible/roles/profile_atlas/tasks/main.yml +++ b/ansible/roles/profile_atlas/tasks/main.yml @@ -20,6 +20,9 @@ - name: Import the declared Atlas Gitea public domain ansible.builtin.import_tasks: gitea_public_domain.yml +- name: Import Atlas Nextcloud steady-state stack + ansible.builtin.import_tasks: nextcloud.yml + - name: Import Atlas iCloudPD storage and boot-started Quadlet tasks ansible.builtin.import_tasks: icloudpd.yml diff --git a/ansible/roles/profile_atlas/tasks/nextcloud.yml b/ansible/roles/profile_atlas/tasks/nextcloud.yml new file mode 100644 index 0000000..73dcacf --- /dev/null +++ b/ansible/roles/profile_atlas/tasks/nextcloud.yml @@ -0,0 +1,309 @@ +--- +- name: Manage the empty Atlas Nextcloud and ONLYOFFICE stack + tags: [atlas, nextcloud] + when: atlas_manage_nextcloud | bool + block: + - name: Validate dedicated paths, domains and pinned images + ansible.builtin.assert: + that: + - atlas_manage_storage | bool + - atlas_manage_firewall | bool + - atlas_nextcloud_root == atlas_app_data_mountpoint ~ '/nextcloud' + - atlas_nextcloud_dataset == atlas_zfs_pool ~ '/services/data/nextcloud' + - atlas_nextcloud_domain is match('^[a-z0-9.-]+$') + - atlas_onlyoffice_domain is match('^[a-z0-9.-]+$') + - atlas_nextcloud_domain != atlas_onlyoffice_domain + - atlas_nextcloud_http_port | int > 1024 + - atlas_onlyoffice_http_port | int > 1024 + - atlas_nextcloud_http_port != atlas_onlyoffice_http_port + - "['calendar', 'contacts', 'onlyoffice', 'groupfolders'] | difference(atlas_nextcloud_apps | map(attribute='id') | list) | length == 0" + - atlas_nextcloud_users | length > 0 + - atlas_nextcloud_admin not in (atlas_nextcloud_users | map(attribute='username') | list) + - atlas_nextcloud_users | map(attribute='username') | unique | list | length == atlas_nextcloud_users | length + - item is search('@sha256:[0-9a-f]{64}$') + loop: + - "{{ atlas_nextcloud_image }}" + - "{{ atlas_nextcloud_postgres_image }}" + - "{{ atlas_nextcloud_redis_image }}" + - "{{ atlas_onlyoffice_image }}" + + - name: Require dedicated Vault secrets without exposing them + ansible.builtin.assert: + that: + - item | default('') is match('^[a-zA-Z0-9]{32,}$') + loop: >- + {{ [vault_nextcloud_database_password | default(''), + vault_nextcloud_redis_password | default(''), + vault_nextcloud_admin_password | default(''), + vault_nextcloud_onlyoffice_jwt | default('')] + + (atlas_nextcloud_users | map(attribute='password') | list) }} + no_log: true + + - name: Verify the existing application-data parent is mounted + community.general.zfs_facts: + name: "{{ atlas_zfs_pool }}/{{ atlas_zfs_dataset_app_data }}" + properties: name,mounted,mountpoint + register: atlas_nextcloud_parent + + - name: Require the verified application-data parent + ansible.builtin.assert: + that: + - atlas_nextcloud_parent.ansible_facts.ansible_zfs_datasets | length == 1 + - atlas_nextcloud_parent.ansible_facts.ansible_zfs_datasets[0].mounted == 'yes' + - atlas_nextcloud_parent.ansible_facts.ansible_zfs_datasets[0].mountpoint == atlas_app_data_mountpoint + + - name: Create the dedicated Nextcloud namespace and component datasets + community.general.zfs: + name: "{{ atlas_nextcloud_dataset }}{{ item }}" + state: present + extra_zfs_properties: + compression: zstd + mountpoint: "{{ atlas_nextcloud_root }}{{ item }}" + loop: ['', /app, /files, /database, /cache, /office] + + - name: Inspect component directories before seeding ownership + ansible.builtin.stat: + path: "{{ atlas_nextcloud_root }}{{ item }}" + follow: false + get_checksum: false + loop: [/app, /files, /database, /cache, /office] + register: atlas_nextcloud_component_paths + + - name: Seed only root-owned new dataset roots without recursive ownership changes + ansible.builtin.file: + path: "{{ item.stat.path }}" + state: directory + owner: "{{ atlas_admin_username }}" + group: "{{ atlas_admin_group }}" + mode: "0700" + loop: "{{ atlas_nextcloud_component_paths.results }}" + loop_control: + label: "{{ item.item }}" + when: + - item.stat.exists + - item.stat.uid | default(-1) | int == 0 + + - name: Ensure private rootless stack configuration directories exist + ansible.builtin.file: + path: "{{ item.path }}" + state: directory + owner: "{{ atlas_admin_username }}" + group: "{{ atlas_admin_group }}" + mode: "{{ item.mode }}" + loop: + - {path: "{{ atlas_nextcloud_private_dir }}", mode: "0700"} + - {path: "{{ atlas_nextcloud_app_cache }}", mode: "0755"} + - {path: "{{ atlas_nextcloud_quadlet_dir }}", mode: "0700"} + - {path: "{{ atlas_admin_home }}/.config/systemd/user", mode: "0700"} + + - name: Inspect the dedicated ONLYOFFICE bind directories + ansible.builtin.stat: + path: "{{ atlas_nextcloud_root }}/office/{{ item }}" + follow: false + get_checksum: false + loop: [data, lib, logs, database] + register: atlas_onlyoffice_bind_paths + + - name: Create ONLYOFFICE bind directories only when absent + ansible.builtin.file: + path: "{{ item.invocation.module_args.path }}" + state: directory + owner: "{{ atlas_admin_username }}" + group: "{{ atlas_admin_group }}" + mode: "0700" + loop: "{{ atlas_onlyoffice_bind_paths.results }}" + loop_control: + label: "{{ item.item }}" + when: not item.stat.exists + + - name: Store private mounted password files inside a restricted host directory + ansible.builtin.copy: + content: "{{ item.value }}\n" + dest: "{{ atlas_nextcloud_private_dir }}/{{ item.name }}" + owner: "{{ atlas_admin_username }}" + group: "{{ atlas_admin_group }}" + mode: "0644" + loop: + - {name: postgres-password, value: "{{ vault_nextcloud_database_password }}"} + - {name: redis-password, value: "{{ vault_nextcloud_redis_password }}"} + - {name: admin-password, value: "{{ vault_nextcloud_admin_password }}"} + - {name: onlyoffice-jwt, value: "{{ vault_nextcloud_onlyoffice_jwt }}"} + no_log: true + diff: false + register: atlas_nextcloud_secret_files + + - name: Render private Redis and ONLYOFFICE configuration + ansible.builtin.template: + src: "{{ item.src }}" + dest: "{{ atlas_nextcloud_private_dir }}/{{ item.dest }}" + owner: "{{ atlas_admin_username }}" + group: "{{ atlas_admin_group }}" + mode: "{{ item.mode }}" + loop: + - {src: atlas-nextcloud-redis.conf.j2, dest: redis.conf, mode: "0644"} + - {src: atlas-onlyoffice.env.j2, dest: onlyoffice.env, mode: "0600"} + no_log: true + diff: false + register: atlas_nextcloud_private_configuration + + - name: Download checksum-pinned compatible application releases + ansible.builtin.get_url: + url: "{{ item.url }}" + dest: "{{ atlas_nextcloud_app_cache }}/{{ item.id }}-{{ item.version }}.tar.gz" + checksum: "{{ item.checksum }}" + owner: "{{ atlas_admin_username }}" + group: "{{ atlas_admin_group }}" + mode: "0644" + loop: "{{ atlas_nextcloud_apps }}" + loop_control: + label: "{{ item.id }} {{ item.version }}" + when: not ansible_check_mode + + - name: Admit only the Aegis gateway to the Nextcloud and Office HTTP listeners + ansible.posix.firewalld: + rich_rule: >- + rule family="ipv4" source address="{{ atlas_aegis_ip }}" + port port="{{ item }}" protocol="tcp" accept + zone: "{{ atlas_firewalld_zone }}" + state: enabled + permanent: true + immediate: true + loop: ["{{ atlas_nextcloud_http_port }}", "{{ atlas_onlyoffice_http_port }}"] + + - name: Enable lingering for the declared rootless owner + ansible.builtin.command: + argv: [loginctl, enable-linger, "{{ atlas_admin_username }}"] + creates: "/var/lib/systemd/linger/{{ atlas_admin_username }}" + + - name: Render Nextcloud component and network Quadlets + ansible.builtin.template: + src: "{{ item }}.j2" + dest: "{{ atlas_nextcloud_quadlet_dir }}/{{ item }}" + owner: "{{ atlas_admin_username }}" + group: "{{ atlas_admin_group }}" + mode: "0644" + loop: + - atlas-nextcloud.network + - atlas-nextcloud-db.container + - atlas-nextcloud-redis.container + - atlas-nextcloud.container + - atlas-onlyoffice.container + register: atlas_nextcloud_quadlets + + - name: Render recurring Nextcloud cron user units + ansible.builtin.template: + src: "{{ item }}.j2" + dest: "{{ atlas_admin_home }}/.config/systemd/user/{{ item }}" + owner: "{{ atlas_admin_username }}" + group: "{{ atlas_admin_group }}" + mode: "0644" + loop: [atlas-nextcloud-cron.service, atlas-nextcloud-cron.timer] + register: atlas_nextcloud_cron_units + + - name: Manage and verify rootless Nextcloud services + become_user: "{{ atlas_admin_username }}" + environment: + XDG_RUNTIME_DIR: "/run/user/{{ atlas_admin_uid }}" + DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ atlas_admin_uid }}/bus" + when: not ansible_check_mode + block: + - name: Pull the pinned images before starting services + containers.podman.podman_image: + name: "{{ item }}" + state: present + loop: + - "{{ atlas_nextcloud_image }}" + - "{{ atlas_nextcloud_postgres_image }}" + - "{{ atlas_nextcloud_redis_image }}" + - "{{ atlas_onlyoffice_image }}" + + - name: Reload the user manager to generate component units + ansible.builtin.systemd: + scope: user + daemon_reload: true + + - name: Start the declared Nextcloud and ONLYOFFICE services + ansible.builtin.systemd: + scope: user + name: "{{ item }}" + state: >- + {{ 'restarted' if (atlas_nextcloud_quadlets is changed or + atlas_nextcloud_private_configuration is changed or + atlas_nextcloud_secret_files is changed) else 'started' }} + loop: "{{ atlas_nextcloud_services }}" + + - name: Wait for the application configuration directory to be initialized + become: true + become_user: root + ansible.builtin.wait_for: + path: "{{ atlas_nextcloud_root }}/app/config/config.php" + timeout: 600 + + - name: Derive container web-user host IDs from the actual rootless maps + ansible.builtin.command: + argv: + - podman + - unshare + - python3 + - -c + - >- + import json; + print(json.dumps({k: next(int(b)+33-int(a) for a,b,n in + (l.split() for l in open('/proc/self/'+k+'_map')) + if int(a)<=33- + atlas_nextcloud_status.rc == 0 and + atlas_nextcloud_status.stdout.startswith('{') and + (atlas_nextcloud_status.stdout | from_json).installed | default(false) + + - name: Import declared ongoing application and account configuration + ansible.builtin.include_tasks: nextcloud_application.yml + + - name: Enable and start the recurring Nextcloud cron timer + ansible.builtin.systemd: + scope: user + name: atlas-nextcloud-cron.timer + state: "{{ 'restarted' if atlas_nextcloud_cron_units is changed else 'started' }}" + enabled: true + + - name: Verify ONLYOFFICE local health without publishing the domain + ansible.builtin.uri: + url: "http://127.0.0.1:{{ atlas_onlyoffice_http_port }}/healthcheck" + return_content: true + register: atlas_onlyoffice_health + retries: 60 + delay: 10 + until: atlas_onlyoffice_health.status | default(0) == 200 and atlas_onlyoffice_health.content | default('') | trim == 'true' diff --git a/ansible/roles/profile_atlas/tasks/nextcloud_application.yml b/ansible/roles/profile_atlas/tasks/nextcloud_application.yml new file mode 100644 index 0000000..9fc41ea --- /dev/null +++ b/ansible/roles/profile_atlas/tasks/nextcloud_application.yml @@ -0,0 +1,171 @@ +--- +- name: Inspect installed application state + ansible.builtin.command: + argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, app:list, --output=json] + register: atlas_nextcloud_current_apps + changed_when: false + +- name: Record enabled and disabled application versions + ansible.builtin.set_fact: + atlas_nextcloud_installed_apps: >- + {{ (atlas_nextcloud_current_apps.stdout | from_json).enabled | + combine((atlas_nextcloud_current_apps.stdout | from_json).disabled) }} + +- name: Refuse implicit application upgrades or downgrades + ansible.builtin.assert: + that: + - item.id not in atlas_nextcloud_installed_apps or atlas_nextcloud_installed_apps[item.id] == item.version + fail_msg: Application versions must be changed in a deliberate upgrade window. + loop: "{{ atlas_nextcloud_apps }}" + loop_control: + label: "{{ item.id }}" + +- name: Install only absent checksum-verified application archives + ansible.builtin.command: + argv: + - podman + - exec + - --user + - '33' + - atlas-nextcloud + - tar + - -xzf + - "/mnt/atlas-apps/{{ item.id }}-{{ item.version }}.tar.gz" + - -C + - /var/www/html/custom_apps + loop: "{{ atlas_nextcloud_apps }}" + loop_control: + label: "{{ item.id }}" + when: item.id not in atlas_nextcloud_installed_apps + changed_when: true + +- name: Enable the declared applications + ansible.builtin.command: + argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, app:enable, "{{ item.id }}"] + loop: "{{ atlas_nextcloud_apps }}" + loop_control: + label: "{{ item.id }}" + when: item.id not in (atlas_nextcloud_current_apps.stdout | from_json).enabled + changed_when: true + +- name: Inspect existing application users without exposing passwords + ansible.builtin.command: + argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, user:list, --output=json] + register: atlas_nextcloud_current_users + changed_when: false + +- name: Ensure the two standard users exist without resetting existing passwords + ansible.builtin.command: + argv: + - podman + - exec + - --user + - '33' + - --env + - OC_PASS + - atlas-nextcloud + - php + - occ + - user:add + - --password-from-env + - --display-name + - "{{ item.display_name }}" + - "{{ item.username }}" + environment: + OC_PASS: "{{ item.password }}" + loop: "{{ atlas_nextcloud_users }}" + when: item.username not in (atlas_nextcloud_current_users.stdout | from_json) + changed_when: true + no_log: true + diff: false + +- name: Inspect standard-user group membership and quota + ansible.builtin.command: + argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, user:info, --output=json, "{{ item.username }}"] + loop: "{{ atlas_nextcloud_users }}" + loop_control: + label: "{{ item.username }}" + register: atlas_nextcloud_user_info + changed_when: false + no_log: true + +- name: Require that family users are not administrators + ansible.builtin.assert: + that: + - "'admin' not in (item.stdout | from_json).groups" + loop: "{{ atlas_nextcloud_user_info.results }}" + no_log: true + +- name: Maintain unlimited initial standard-user quotas + ansible.builtin.command: + argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, user:setting, "{{ item.item.username }}", files, quota, none] + loop: "{{ atlas_nextcloud_user_info.results }}" + when: (item.stdout | from_json).quota != 'none' + changed_when: true + no_log: true + +- name: Inspect the family group + ansible.builtin.command: + argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, group:list, --output=json] + register: atlas_nextcloud_groups + changed_when: false + +- name: Ensure the family group exists + ansible.builtin.command: + argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, group:add, famiglia] + when: "'famiglia' not in (atlas_nextcloud_groups.stdout | from_json)" + changed_when: true + +- name: Ensure both standard users belong to the family group + ansible.builtin.command: + argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, group:adduser, famiglia, "{{ item.username }}"] + loop: "{{ atlas_nextcloud_users }}" + when: item.username not in ((atlas_nextcloud_groups.stdout | from_json).get('famiglia', [])) + changed_when: true + no_log: true + +- name: Inspect configured family folders + ansible.builtin.command: + argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, groupfolders:list, --output=json] + register: atlas_nextcloud_folders_before + changed_when: false + +- name: Ensure a shared Famiglia folder exists + ansible.builtin.command: + argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, groupfolders:create, Famiglia] + when: >- + (atlas_nextcloud_folders_before.stdout | from_json | + selectattr('mountPoint', 'equalto', 'Famiglia') | list | length) == 0 + changed_when: true + +- name: Inspect the resulting family folder + ansible.builtin.command: + argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, groupfolders:list, --output=json] + register: atlas_nextcloud_folders_after + changed_when: false + +- name: Select the existing family folder without changing unrelated folders + ansible.builtin.set_fact: + atlas_nextcloud_family_folder: >- + {{ atlas_nextcloud_folders_after.stdout | from_json | + selectattr('mountPoint', 'equalto', 'Famiglia') | first }} + +- name: Maintain family read, create, write and delete permissions + ansible.builtin.command: + argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, groupfolders:group, + "{{ atlas_nextcloud_family_folder.id }}", famiglia, write, delete] + when: (atlas_nextcloud_family_folder.groups_list | default({}, true)).get('famiglia', 0) | int != 15 + changed_when: true + +- name: Inspect the background job mode + ansible.builtin.command: + argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, config:app:get, core, backgroundjobs_mode] + register: atlas_nextcloud_background_mode + changed_when: false + failed_when: atlas_nextcloud_background_mode.rc not in [0, 1] + +- name: Maintain cron background processing + ansible.builtin.command: + argv: [podman, exec, --user, '33', atlas-nextcloud, php, occ, background:cron] + when: atlas_nextcloud_background_mode.stdout | trim != 'cron' + changed_when: true diff --git a/ansible/roles/profile_atlas/templates/atlas-nextcloud-cron.service.j2 b/ansible/roles/profile_atlas/templates/atlas-nextcloud-cron.service.j2 new file mode 100644 index 0000000..f1b0453 --- /dev/null +++ b/ansible/roles/profile_atlas/templates/atlas-nextcloud-cron.service.j2 @@ -0,0 +1,10 @@ +[Unit] +Description=Atlas recurring Nextcloud background jobs +Requires=atlas-nextcloud.service +After=atlas-nextcloud.service + +[Service] +Type=oneshot +ExecStart=/usr/bin/podman exec --user 33 atlas-nextcloud php -f /var/www/html/cron.php +TimeoutStartSec=15min +NoNewPrivileges=true diff --git a/ansible/roles/profile_atlas/templates/atlas-nextcloud-cron.timer.j2 b/ansible/roles/profile_atlas/templates/atlas-nextcloud-cron.timer.j2 new file mode 100644 index 0000000..50130d2 --- /dev/null +++ b/ansible/roles/profile_atlas/templates/atlas-nextcloud-cron.timer.j2 @@ -0,0 +1,10 @@ +[Unit] +Description=Run Nextcloud background jobs every five minutes + +[Timer] +OnBootSec=5min +OnUnitActiveSec=5min +Unit=atlas-nextcloud-cron.service + +[Install] +WantedBy=timers.target diff --git a/ansible/roles/profile_atlas/templates/atlas-nextcloud-db.container.j2 b/ansible/roles/profile_atlas/templates/atlas-nextcloud-db.container.j2 new file mode 100644 index 0000000..e0d5307 --- /dev/null +++ b/ansible/roles/profile_atlas/templates/atlas-nextcloud-db.container.j2 @@ -0,0 +1,27 @@ +[Unit] +Description=Atlas Nextcloud PostgreSQL +RequiresMountsFor={{ atlas_nextcloud_root }}/database + +[Container] +ContainerName=atlas-nextcloud-db +Image={{ atlas_nextcloud_postgres_image }} +Network=atlas-nextcloud.network +NetworkAlias=atlas-nextcloud-db +Environment=POSTGRES_DB=nextcloud +Environment=POSTGRES_USER=nextcloud +Environment=POSTGRES_PASSWORD_FILE=/run/secrets/postgres-password +Volume={{ atlas_nextcloud_private_dir }}/postgres-password:/run/secrets/postgres-password:ro,z +Volume={{ atlas_nextcloud_root }}/database:/var/lib/postgresql/data:Z +PodmanArgs=--memory=1g +HealthCmd=pg_isready -U nextcloud -d nextcloud +HealthInterval=30s +HealthStartPeriod=60s +NoNewPrivileges=true + +[Service] +Restart=on-failure +RestartSec=10 +TimeoutStartSec=900 + +[Install] +WantedBy=default.target diff --git a/ansible/roles/profile_atlas/templates/atlas-nextcloud-redis.conf.j2 b/ansible/roles/profile_atlas/templates/atlas-nextcloud-redis.conf.j2 new file mode 100644 index 0000000..0d75ad4 --- /dev/null +++ b/ansible/roles/profile_atlas/templates/atlas-nextcloud-redis.conf.j2 @@ -0,0 +1,8 @@ +bind 0.0.0.0 +protected-mode yes +port 6379 +requirepass {{ vault_nextcloud_redis_password }} +maxmemory 128mb +maxmemory-policy noeviction +save "" +appendonly no diff --git a/ansible/roles/profile_atlas/templates/atlas-nextcloud-redis.container.j2 b/ansible/roles/profile_atlas/templates/atlas-nextcloud-redis.container.j2 new file mode 100644 index 0000000..fa11fe8 --- /dev/null +++ b/ansible/roles/profile_atlas/templates/atlas-nextcloud-redis.container.j2 @@ -0,0 +1,22 @@ +[Unit] +Description=Atlas Nextcloud private Redis +RequiresMountsFor={{ atlas_nextcloud_root }}/cache + +[Container] +ContainerName=atlas-nextcloud-redis +Image={{ atlas_nextcloud_redis_image }} +Network=atlas-nextcloud.network +NetworkAlias=atlas-nextcloud-redis +Volume={{ atlas_nextcloud_private_dir }}/redis.conf:/usr/local/etc/redis/atlas.conf:ro,z +Volume={{ atlas_nextcloud_root }}/cache:/data:Z +Exec=redis-server /usr/local/etc/redis/atlas.conf +PodmanArgs=--memory=256m +NoNewPrivileges=true + +[Service] +Restart=on-failure +RestartSec=10 +TimeoutStartSec=900 + +[Install] +WantedBy=default.target diff --git a/ansible/roles/profile_atlas/templates/atlas-nextcloud.config.php.j2 b/ansible/roles/profile_atlas/templates/atlas-nextcloud.config.php.j2 new file mode 100644 index 0000000..eda1d61 --- /dev/null +++ b/ansible/roles/profile_atlas/templates/atlas-nextcloud.config.php.j2 @@ -0,0 +1,23 @@ + ['{{ atlas_nextcloud_domain }}', 'atlas-nextcloud'], + 'trusted_proxies' => ['{{ atlas_aegis_ip }}', '{{ atlas_nextcloud_network_gateway }}'], + 'overwrite.cli.url' => 'https://{{ atlas_nextcloud_domain }}', + 'overwritehost' => '{{ atlas_nextcloud_domain }}', + 'overwriteprotocol' => 'https', + 'allow_local_remote_servers' => true, + 'default_quota' => 'none', + 'skeletondirectory' => '', + 'maintenance_window_start' => 1, + 'default_phone_region' => 'IT', + 'twofactor_enforced' => false, + 'onlyoffice' => [ + 'DocumentServerUrl' => 'https://{{ atlas_onlyoffice_domain }}/', + 'DocumentServerInternalUrl' => 'http://atlas-onlyoffice/', + 'StorageUrl' => 'http://atlas-nextcloud/', + 'jwt_secret' => trim(file_get_contents('/run/secrets/onlyoffice-jwt')), + 'jwt_header' => 'AuthorizationJwt', + 'allow_local_address' => true, + ], +]; diff --git a/ansible/roles/profile_atlas/templates/atlas-nextcloud.container.j2 b/ansible/roles/profile_atlas/templates/atlas-nextcloud.container.j2 new file mode 100644 index 0000000..af031ea --- /dev/null +++ b/ansible/roles/profile_atlas/templates/atlas-nextcloud.container.j2 @@ -0,0 +1,42 @@ +[Unit] +Description=Atlas Nextcloud +Requires=atlas-nextcloud-db.service atlas-nextcloud-redis.service +After=atlas-nextcloud-db.service atlas-nextcloud-redis.service +RequiresMountsFor={{ atlas_nextcloud_root }}/app {{ atlas_nextcloud_root }}/files + +[Container] +ContainerName=atlas-nextcloud +Image={{ atlas_nextcloud_image }} +Network=atlas-nextcloud.network +NetworkAlias=atlas-nextcloud +PublishPort={{ ansible_host }}:{{ atlas_nextcloud_http_port }}:80 +PublishPort=127.0.0.1:{{ atlas_nextcloud_http_port }}:80 +Environment=POSTGRES_HOST=atlas-nextcloud-db +Environment=POSTGRES_DB=nextcloud +Environment=POSTGRES_USER=nextcloud +Environment=POSTGRES_PASSWORD_FILE=/run/secrets/postgres-password +Environment=NEXTCLOUD_ADMIN_USER={{ atlas_nextcloud_admin }} +Environment=NEXTCLOUD_ADMIN_PASSWORD_FILE=/run/secrets/admin-password +Environment="NEXTCLOUD_TRUSTED_DOMAINS={{ atlas_nextcloud_domain }} atlas-nextcloud" +Environment=REDIS_HOST=atlas-nextcloud-redis +Environment=REDIS_HOST_PASSWORD_FILE=/run/secrets/redis-password +Environment=APACHE_DISABLE_REWRITE_IP=1 +Environment=PHP_MEMORY_LIMIT=512M +Environment=PHP_UPLOAD_LIMIT=2G +Volume={{ atlas_nextcloud_root }}/app:/var/www/html:Z +Volume={{ atlas_nextcloud_root }}/files:/var/www/html/data:Z +Volume={{ atlas_nextcloud_app_cache }}:/mnt/atlas-apps:ro,z +Volume={{ atlas_nextcloud_private_dir }}/postgres-password:/run/secrets/postgres-password:ro,z +Volume={{ atlas_nextcloud_private_dir }}/admin-password:/run/secrets/admin-password:ro,z +Volume={{ atlas_nextcloud_private_dir }}/redis-password:/run/secrets/redis-password:ro,z +Volume={{ atlas_nextcloud_private_dir }}/onlyoffice-jwt:/run/secrets/onlyoffice-jwt:ro,z +PodmanArgs=--memory=2g +NoNewPrivileges=true + +[Service] +Restart=on-failure +RestartSec=10 +TimeoutStartSec=900 + +[Install] +WantedBy=default.target diff --git a/ansible/roles/profile_atlas/templates/atlas-nextcloud.network.j2 b/ansible/roles/profile_atlas/templates/atlas-nextcloud.network.j2 new file mode 100644 index 0000000..a9b925b --- /dev/null +++ b/ansible/roles/profile_atlas/templates/atlas-nextcloud.network.j2 @@ -0,0 +1,5 @@ +# Managed by Ansible: private rootless application network, no host services. +[Network] +NetworkName=atlas-nextcloud +Subnet={{ atlas_nextcloud_network_subnet }} +Gateway={{ atlas_nextcloud_network_gateway }} diff --git a/ansible/roles/profile_atlas/templates/atlas-onlyoffice.container.j2 b/ansible/roles/profile_atlas/templates/atlas-onlyoffice.container.j2 new file mode 100644 index 0000000..fb90d17 --- /dev/null +++ b/ansible/roles/profile_atlas/templates/atlas-onlyoffice.container.j2 @@ -0,0 +1,26 @@ +[Unit] +Description=Atlas ONLYOFFICE Docs Community +RequiresMountsFor={{ atlas_nextcloud_root }}/office + +[Container] +ContainerName=atlas-onlyoffice +Image={{ atlas_onlyoffice_image }} +Network=atlas-nextcloud.network +NetworkAlias=atlas-onlyoffice +PublishPort={{ ansible_host }}:{{ atlas_onlyoffice_http_port }}:80 +PublishPort=127.0.0.1:{{ atlas_onlyoffice_http_port }}:80 +EnvironmentFile={{ atlas_nextcloud_private_dir }}/onlyoffice.env +Volume={{ atlas_nextcloud_root }}/office/data:/var/www/onlyoffice/Data:Z +Volume={{ atlas_nextcloud_root }}/office/lib:/var/lib/onlyoffice:Z +Volume={{ atlas_nextcloud_root }}/office/logs:/var/log/onlyoffice:Z +Volume={{ atlas_nextcloud_root }}/office/database:/var/lib/postgresql:Z +PodmanArgs=--memory=4g --shm-size=256m +NoNewPrivileges=true + +[Service] +Restart=on-failure +RestartSec=10 +TimeoutStartSec=1200 + +[Install] +WantedBy=default.target diff --git a/ansible/roles/profile_atlas/templates/atlas-onlyoffice.env.j2 b/ansible/roles/profile_atlas/templates/atlas-onlyoffice.env.j2 new file mode 100644 index 0000000..11cb187 --- /dev/null +++ b/ansible/roles/profile_atlas/templates/atlas-onlyoffice.env.j2 @@ -0,0 +1,7 @@ +JWT_ENABLED=true +JWT_SECRET={{ vault_nextcloud_onlyoffice_jwt }} +JWT_HEADER=AuthorizationJwt +ALLOW_PRIVATE_IP_ADDRESS=true +ALLOW_META_IP_ADDRESS=false +USE_UNAUTHORIZED_STORAGE=false +WOPI_ENABLED=false diff --git a/docs/atlas-nextcloud-design.md b/docs/atlas-nextcloud-design.md new file mode 100644 index 0000000..3f68eca --- /dev/null +++ b/docs/atlas-nextcloud-design.md @@ -0,0 +1,105 @@ +# Nextcloud on Atlas — design draft + +Status: the empty stack was deployed on 2026-10-03, explicitly before the first +scrub. The operator configured DNS/NPM and authorized public cutover; public TLS, +DAV and cross-user file checks passed. Client editing/sync acceptance and consistent +backup/restore validation remain open before family data. iCloud import remains a +separate operation. See `docs/atlas-nextcloud.md` for observed runtime state. + +## Confirmed requirements + +- Three family members are the eventual scope; provision only two standard user + accounts initially, `fabio` and `chiara`, with the third family user deferred. + Each initial user has a private file space and no administrator privileges. +- Add a separate Nextcloud application administrator account named `admin`, for + administration rather than daily document use. This is distinct from Atlas' + host account of the same name; credentials must not be reused. +- 2FA is optional, not enforced for the accounts. Offer enrollment and recovery + codes; encourage it for the administrator without silently imposing it. +- The three application accounts have been created in the empty deployment. +- No SMTP service is available. Initial deployment will not configure outbound + email or provision a mail server. Email notifications and email-based password + recovery are unavailable until SMTP is explicitly added. Document administrator- + assisted recovery for standard users and a private host-side admin recovery + procedure; do not expose a recovery endpoint or store plaintext passwords. +- Both initial users may add, edit and delete files in the shared `Famiglia` + folder. This does not imply sharing personal calendars or contacts. +- No initial per-user Nextcloud storage quota for `fabio` or `chiara`. Available + space is still bounded by the physical pool and any separately approved dataset + limits; monitor capacity and do not describe this as unlimited physical storage. +- Files, calendars, contacts and Office document editing in the browser. +- iPhone/iPad, Windows and Linux clients. +- Migrate iCloud Drive files, calendars and contacts. The operator estimates + approximately 50 GB of iCloud Drive files, excluding iCloudPD photos; this is + an estimate, not a measured inventory. The files include a mix of Fabio's and + Chiara's data. Migration is explicitly deferred to a separate later operation; + initial deployment must not import iCloud files, calendars or contacts. + Per-account mapping will be decided at migration time. Do not assume ongoing + two-way synchronization with iCloud or extend this scope to iCloud Photos. +- ONLYOFFICE is the chosen editor: browser editing on desktop and the existing + ONLYOFFICE app on iPhone/iPad. Mobile browser editing is not required. +- Temporary Atlas hosting, with eventual migration to Uranus. +- Completed one-time imports/migrations stay outside the steady-state playbook. + +## Implemented architecture — public acceptance pending + +- Nextcloud application with Files, Calendar, Contacts and an Office connector. +- PostgreSQL database and Redis for locking/cache; deployed versions and pinned + image digests are declared in Atlas host vars and documented in the runbook. +- Dedicated ONLYOFFICE Docs service and its Nextcloud connector. Test real + DOCX/XLSX/PPTX files in desktop browsers and opening/editing/saving through + the mobile ONLYOFFICE app before acceptance. Community Edition is deployed; + internal connector checks passed, but browser/mobile acceptance is still pending. +- Explicit Podman Quadlets managed by Ansible, preferably rootless like existing + Atlas services, subject to image/user namespace/SELinux validation. +- Separate persistent application/configuration, user files, database and cache + storage in the service namespace. Do not expose the managed Nextcloud data + directory as a writable SMB share or let Syncthing modify it directly. +- Approved names: `cloud.fscotto.co` for Nextcloud and `office.fscotto.co` for + ONLYOFFICE Docs. The operator configured DNS, certificates and NPM hosts; + public endpoint and routing checks passed on 2026-10-03. +- Public HTTPS through Prometheus NPM and the existing Aegis gateway only. + No public database/cache ports or directly exposed administrative interfaces. +- Office/Nextcloud callback routing, WebSockets, trusted proxies, JWT authentication + and upload limits must be tested end to end before publication. +- Credentials remain in Vault; never enter passwords or private keys in chat. + +## Office decision + +The operator already uses ONLYOFFICE on mobile and desktop and selected it for +this project. Desktop browser editing will use ONLYOFFICE Docs integrated with +Nextcloud; mobile editing will use the existing ONLYOFFICE app. The limitation +on Community mobile web editors does not conflict with that requirement. +App integration, permissions, document fidelity and reliable saves still require +acceptance tests; the app is not treated as proof of server-side compatibility. + +## Data protection and rollout gates + +- The operator explicitly authorized this empty deployment before the first scrub. + Close the data-protection checks before accepting live family data; this limited + exception does not mark the scrub or recovery checks complete. +- Re-check free RAM/CPU/storage and existing workload before choosing limits or quotas. +- Design consistent backups covering configuration, custom apps/themes, user files + and the database. ZFS snapshots alone do not establish application consistency. +- Define a coordinated maintenance/background-job pause and database dump/snapshot + procedure for recurring backups, with failure cleanup and monitoring. +- Confirm ZFS/Borg/USB coverage and independently restore into an isolated environment + before importing family data. +- Define deliberate upgrades and rollback boundaries; do not roll back a database + independently of its matching application/data backup. +- Start with a test account and representative documents; migrate iCloud content + explicitly only after client, sharing, Office and recovery tests pass. +- Plan Uranus transfer separately; do not add permanent one-time migration flags. + +## Next decisions, one at a time + +1. Validate desktop Office editing/saving, calendar/contact synchronization and + mobile ONLYOFFICE app integration; public empty-stack cutover is verified. +2. Complete protection gates and application-consistent backup/recovery tests. +3. Plan the deferred iCloud migration when explicitly requested. + +## Primary references + +- [Nextcloud Office installation](https://docs.nextcloud.com/server/stable/admin_manual/office/installation.html) +- [ONLYOFFICE mobile web editor restrictions](https://helpcenter.onlyoffice.com/mobile/android/mobile-web-editors/overview.aspx) +- [Nextcloud backup requirements](https://docs.nextcloud.com/server/stable/admin_manual/maintenance/backup.html) diff --git a/docs/atlas-nextcloud.md b/docs/atlas-nextcloud.md new file mode 100644 index 0000000..6f2ece4 --- /dev/null +++ b/docs/atlas-nextcloud.md @@ -0,0 +1,127 @@ +# Atlas Nextcloud — public empty-stack cutover + +## Observed state, 2026-10-03 + +The operator explicitly approved an empty deployment before the first monthly +scrub, and subsequently authorized public cutover. No iCloud files, calendars +or contacts have been imported. Public empty-stack validation is not acceptance +of production data before the outstanding protection and recovery checks. + +Ansible manages the steady state through `profile_atlas` and the host-local +`atlas_manage_nextcloud: true` declaration. No migration/import flags or helpers +were added. An actual repeat run returned `changed=0`, with no failures. + +- Rootless `admin` Quadlets: Nextcloud 33.0.9, PostgreSQL 17.11, Redis 7.4.11 and + ONLYOFFICE Docs Community 9.4.0.129 (image tag 9.4.0.1), on a dedicated network. +- Images are pinned by digest; Calendar 6.6.2, Contacts 8.9.1, ONLYOFFICE connector + 10.2.1 and Team Folders 21.0.9 archives are pinned by version and SHA-256. +- Dedicated ZFS namespace: `zpool/services/data/nextcloud`, with separate `app`, + `files`, `database`, `cache` and `office` datasets. No writable SMB/Syncthing + access to the Nextcloud-managed file namespace is provided. +- The `admin` Nextcloud account is an application administrator, distinct from + the host account. `fabio` and `chiara` are standard users in `famiglia`, each + with no initial quota. Team folder `Famiglia` has unlimited quota and group + permission mask 15 (read/create/update/delete, not additional re-sharing). +- Optional TOTP is available; 2FA is not enforced. SMTP is not configured. +- The five-minute user cron timer is active; a manual service run succeeded. + Its `Type=oneshot` means a recurring short-lived job, not a one-time migration. +- Component memory ceilings are Nextcloud 2 GiB, ONLYOFFICE 4 GiB, PostgreSQL + 1 GiB and Redis 256 MiB; these are ceilings, not reserved memory or load-test results. + +Nextcloud reported installed, no maintenance mode and no pending DB upgrade. +PostgreSQL was healthy; ONLYOFFICE `/healthcheck` returned `true`. The connector's +`onlyoffice:documentserver --check` succeeded using internal routing. JWT is +enabled and matches the dedicated secret; neither privileged containers nor +container-engine socket mounts are used. + +NPM on Prometheus reached both upstreams through the Aegis gateway. Direct LAN +connections from Ikaros to 8080/8081 were blocked, and PostgreSQL/Redis had no +published host ports. Existing Git, Music and Syncthing HTTPS returned 200 with +valid TLS. NPM and its backup export timer stayed active; the pool remained healthy. + +After operator DNS/NPM configuration, both public hostnames resolved to the VPS. +HTTPS and HTTP-to-HTTPS redirects passed with valid certificates. Both Proxy Hosts +were enabled with Force SSL and WebSocket support. Public Office health and its +browser API asset returned 200; the connector check also passed. Actual browser +editing/saving and native mobile client use remain operator acceptance tests. + +Public session-based web login and authenticated WebDAV succeeded for admin, +fabio and chiara. CalDAV/CardDAV +discovery redirected to the DAV endpoint; Fabio's calendar/address-book collections +answered PROPFIND. A uniquely named private test file was inaccessible to Chiara. +Fabio created a test file in Famiglia; Chiara read, edited and deleted it, and Fabio +read the updated contents. All temporary test files were removed. These are HTTP +protocol checks, not device synchronization or large-upload acceptance evidence. + +## Operator DNS and NPM configuration + +Namecheap: add CNAMEs `cloud` and `office` to `fscotto.co`. Do not change the blog, +mail records or apex IP. + +| NPM hostname | Scheme | Upstream | Port | +| --- | --- | --- | --- | +| cloud.fscotto.co | http | 192.168.178.55 | 8080 | +| office.fscotto.co | http | 192.168.178.55 | 8081 | + +For each host, obtain a certificate for its hostname, enable Force SSL and +WebSocket support. Keep NPM administration loopback-only; do not expose port 81. +Nextcloud's declared upload ceiling is 2 GiB; align the proxy request-size and +timeout settings rather than claiming large uploads work before testing them. +Verify CalDAV/CardDAV `.well-known` redirects to `/remote.php/dav/` through NPM. +Never disable certificate verification to make Office work. + +The browser-facing Office URL is `https://office.fscotto.co/`; server-side routes +use `http://atlas-onlyoffice/` and `http://atlas-nextcloud/` on the private network. +These internal routes require explicit local-address permission in the connector +and ONLYOFFICE. Metadata-address access remains disabled. Nextcloud trusts only +the declared Aegis address and rootless network gateway, not arbitrary proxies. + +## Secrets and administration + +Six unique secrets were generated into the existing encrypted `secrets/vault.yml`: +database, Redis, Office JWT and initial passwords for `admin`, `fabio`, `chiara`. +Use the local Vault editor to retrieve them; do not paste them in chat. +Account provisioning never resets an existing user's password. After a user +changes it, the initial Vault password is not necessarily their current password. +Database secret rotation needs a coordinated role-password update, not just an +edited initialization file. Image/app upgrades likewise require a deliberate window. + +Host configuration lives below `/home/admin/.config/atlas-nextcloud` with a 0700 +parent. Mounted individual secret files are readable by their container consumers, +but a different host user was verified unable to read them through the parent. +Nextcloud's managed PHP include inherits the live container SELinux category; +neither global relabeling nor disabling SELinux is used. + +```bash +ansible-playbook ansible/site.yml --limit atlas --tags nextcloud --check --diff +ansible-playbook ansible/site.yml --limit atlas --tags nextcloud +``` + +Dry-run skips initial downloads, image pulls and runtime account/app commands; +it is not proof of an installed or healthy stack. The deployed repeat run is +the current idempotence evidence. + +## Gates before family data and full client acceptance + +- Verify the first actual scrub and the outstanding protection checks. +- Public TLS, redirects, web login and WebDAV passed. Complete calendar/contact + synchronization and Office editing/saving from a desktop. +- Test opening, editing and saving from the iPhone/iPad ONLYOFFICE app; mobile + browser editing is not a requirement. No such client test is claimed yet. +- Private-space isolation and cross-user shared writes/deletes passed the public + smoke test above; complete normal client acceptance as well. +- Integrate and test application-consistent database/files backups before import. + The new datasets fall beneath existing recursive snapshot/backup scope, but + that alone does not verify a new Borg/USB version or a consistent Nextcloud restore. +- For a consistent backup, coordinate pending Office saves, pause cron and writes, + take a verified PostgreSQL dump and matching application/files snapshot, and + resume services promptly even on failure. Extend recurring backup procedures, + not the steady-state playbook with one-time migration tasks. Restore into an + isolated environment using matching image/app versions, config, files and DB. +- Confirm encrypted Vault/recovery material is available offline. Without SMTP, + recovery for standard accounts is administrator-assisted; a forgotten admin + password can be reset through the private host-side `occ` CLI. +- Select versions deliberately for upgrades. Do not downgrade the application + against an upgraded database; use matching tested backups for recovery. +- Future Uranus migration and iCloud import are separate, explicitly authorized + operations. No source data deletion or automatic cross-system cutover is provided. diff --git a/secrets/vault.yml b/secrets/vault.yml index b829d93..6889c79 100644 --- a/secrets/vault.yml +++ b/secrets/vault.yml @@ -1,71 +1,101 @@ $ANSIBLE_VAULT;1.1;AES256 -37646664613266633436346262613633613830623366383138613432366365373765353230333134 -3332333764313337396637323133623937343738373133370a333930356365653034323235643230 -36633864343161653833356636373931383761663864663334336236373733326266386639366335 -3131313661313637320a313937633361646333333962303335333233346166343831373039663964 -33366532386135663463643965363766643063616436316463666232666138323236346231303537 -34303535333866376430363063623934623761373865656231656661383935393866353566346430 -64333434613432376436343438343561383235366631623730653533633535326237666265653439 -34366264653665643063663361313339663034323932326233366636326336323432303434373765 -36316532316265343434383438623239666232373633626330333464303361643630303635643834 -39313136623830303762313462343637633763626333393033346637663931663238653734626131 -38393963646563333732353531653239643330326539643538323164343934356166343034316565 -33346431333735636537613930383331393265313962626234363237373562313231393061326439 -64363765323935316661353531366165343139633963336139313737306332613364643031666161 -30386362643930316265616564306336633133303166363665333462316265313364393939306162 -31303639313933356337386134623934663461643161306666633261653538633232343036653833 -66316466636233343136393765636333353230353738313833333265663238303730313936326664 -38373239353162363438323964333030666563346161643437326335666162356264396135393532 -63363862373136346532653734336335616132386237303031363433663132343861633937386130 -30633938616364303462303030303966303939633066393264303462393730363233373937356439 -36663533376232663737613734653532313136343939663539373866333638396266666163383864 -63613532393334373539346338616163383637633237666234613437663966653733616361353830 -61656666376133363330633863346637376266343134633037313132313361366638616261363839 -39393062396237666333303937363536346561343763663133323236393037383532396465336138 -35613463356532376534386433626337613030343266353332306462306463336336343830666138 -33656138363837633337393865643633623261613335366263643162663637623636666162653632 -30626238616266323332616234393838343330663662393433366630393566316336636530303165 -38343665623437356636643236393734396264356632326133623264633862633333626330336663 -61376263656665653731636133316161653635323138303866623862303065366232633736623336 -39656666386435343062656138313061616661313966326432663236626631316162623961616636 -35343939613262303066626537396164616666316265643065373638663436643961336138313862 -39666163646538356338356631346534633139643636393866646462646533363265663234633761 -65363661336138353239656165393836386134666331663036653132306433343764643666306333 -33623661626565633333306337303263633335386632386330353730316436313931326164363862 -37616265653161633632353865346639653961653836353962303762336535666266386535363165 -39653138646663376634323131613463333035326639313266613830616431316131383464353533 -62656634346637636164626461613137303461633761336232373133653532323566303136663030 -30633337346534636566343934306662356238396365306563336666623435353731613136333036 -36343436373932323265306639363761353364383635333136366231373166613861633032343233 -61376338616630343639333964356162613332323835333730333135356665383431626138643534 -66393966666465303763316230386538393863303063386564303165303962346139373338303436 -39373032663538323532323766353864643338326561313564373562616430326264386362666532 -36613132306462336631363035343732636465343562643430343035373961366566383130656165 -64613938393265343037633161653937323933646637653036306532366237313838346361333932 -34663565653264626137323239336532643262356166633665313761336162303635346666383863 -61383930383033626337383366353766393536653135383062656639323361353539356232613736 -63646235663363333333623463313961326533653236363938383765663439613832653039386436 -38393734633536313731323437336332353564363564333736663037386530333639326338656561 -66336637353238383231613666313261383234336531666132396230373931623363323832633064 -39613036346166393936613939363865616135653830366435643538336365353333613831353962 -36623537363434373137633063373934383439333462646361613737303239643834303535366138 -34656465316431656461373737643537303936636539383934373831616438343965373765373535 -63653164363731653030303466646539636361383664343763646163663238383435653035653666 -63383165626365653261303834333234626534396333353231303261396361616233363334383336 -33316462636133336132656364613439396131613565646565396365316238323962353462653736 -64386139313266663963643962363133386133393166306163626632646463333363323830306164 -35353936653137383761326132373739306163613764386531613032313235373331303530383633 -64646533313434653734366233633535323564386431306538633666383661303038613330653832 -34643463396137643034353439653334653836333161396130363637326339383363303037306330 -61353635633334343432646461396439393439383639336139316161373737333961653731393333 -61636164343838346365373736356161386430356533303331333838333732363233613931613863 -66633662383466306332366563373865323861323833353238356563363635313463366333653432 -65303839653963376566383737346231343663363363313332383365646363373737323839613564 -34613362303335316363363661653639386538326337386537333765643161613961316531613563 -38386564636637643762643830666138383361396233303339643665343261356462393830376662 -32656334346536636536343263336565333234353831616565366538393661353561376538346334 -61396135623230366433303932396130636331333263316333643861626564343330386636613063 -32383061616435643736653264313839363232346332343565336464353138396339623533393237 -38353632646565323735643462626239663736643033643231613464663866663262366632353434 -37363866343239363131633464316133396462353336613962306332343563333962333934616330 -3536356634376131633039373834376533633065303533653333 +36616436366637373963326235323736623235633666353235383933663230616532613131636466 +3132633562663861353835633633653764376634636638620a636662316234316164626635646539 +63343233373531373833626437656630363330363932353136653834313830646431343961386237 +3166323135376665620a383334616634356361326134313930613266333136393238366566343233 +33366335353639316164346239336539636335393130663261333065363733323163613437396332 +35663339623338363737383332396238346430353730356632623964323134663434336363613564 +63306464623331643738666234343162643630353061363231313933633733626165333763653461 +39663066376637623939383964333663306137663433313334313132323465623534666133393533 +36633036376538623764363165663861383135663437343230366165636530663165643538376161 +62653335666463653538356635333339353165336333306462373233316438386539613361383039 +34663336636565343035626238633139356638636535373239386463663738633036383861633062 +35313735333530306666313966333061326338393533333936633634633136353237643464376563 +35626266363237613037663934666538356639366637643037386336316131343965616137336330 +64623432663033653066353661613366313065366264663138643965346363626562366433326461 +65316661346631343330633033326630306536633831366231363066323861366662363861666364 +38623438633235646430613935363932386237303132343236303439633939373862366565313864 +35306332636562636466333739343663343762343163343738646234353638303134643763636639 +36613662633135303733376333613235333637646661326235373732306139363363623632666262 +38366436613733316465623438343334333861313161363131376132613232376663623230623533 +38303061386639616631383636303966666338353865626464363434353661393665613862303130 +62303664653362336433356239626661353864363537346234613331376331313038633138363565 +31616232653265343430646537373835643163396530353832366337663363386635306665643432 +66393838363266383230363633313235376130356436633137636637666562383165643862313931 +63346633343334333662363334373865653232623938363162363362646361383961376532626339 +30643537356436346161353161626232303962396463323037653235343633643261396134373061 +61323463653962363639373531366130326431353635346463396434393336313730373431316334 +37313032666231383536363535326239383363346137363037653930373261326338303936663234 +36326635346465316233363266383337343335653239393830356262346530363734383532303936 +38633065633135666438333832333336636365326430656534313332356662356165616563333035 +35363833363636346430356461306337396561366536326139623131303638333733616663653336 +65623062626366386364343036386633626236363638393565323163623936663930363864656264 +61323566376464356532316366633663623031613439653635323339363730366231326531303163 +62313638393962653064303934663436376335663763333965366230323466646463653665656466 +62643164616331636464613934376335353437653662363433363533613633633536346662656339 +62353565303464373438383234353237636239313062643036383161303735386539613533383334 +63383065613236316633623936383130353466383865376336393733663434663636333463336334 +37356233306333366463303839643363393463636630306632326339646661643162323334633331 +66613731313733646362396534356236363361363330383230303731356261333336653930303161 +35353336326438376563616534616361353233373232303034623465656261326664393962326632 +36373936393261616338396630383034323462646664623566663064316438363065646330353362 +32633566666263333863383264363762323964356430336539623633643537336538353037396566 +63396537626465363531393161653939633461366231326234663161646364616338636236313332 +35646664333763623532306637383961623538643164633939303561316262316463646665353633 +66313164646134646132653338356531303435623130343864326236353939356433396164336236 +36623066396435323532356663326163636637346463626235616132353932326438303233393830 +64326563303365646664376337303539643032363537633139623665346130636631386662373762 +66336565303334303561386134343730306566303036313933613134366238303636316238363165 +66373531633430363730376236353939626137323862623538356233616363376330366433633032 +37363538393331376665623230623233343065653139313431323966326238636663633030393734 +32643635326266646636663539353537623062633130386532373638396366353038663861333033 +63343031383238306139653932366433346564643233323937316134306666623030623137313736 +39623537346564623236353131656465326632303038366261626661333931323265396262636661 +35313739306432323034643832623831373831666231643862613736393135383561386365323835 +35623863396339653038316262303263313262616361666666343331393666663530363764643639 +31353564633835323031303835636261613839353031373334366335323465326536323762626633 +37343633376666323963336436623533346261396438343336663630366434383961393738383263 +65383036333031643336393835303835363733663634653463313639313939636539386634663464 +32643034383835333533343434656234343134313934323462643631653337383536363165613835 +63393437653261653966313237633939626330316631633335386235346465663332336337613865 +30626332353130326430316266363062353636356663663439346662313461393835663864656561 +62633131323937656239383531373863393865386265663038346535616463326630646565663463 +64393861366635656130386434633431393661656438333832633366333730643639333036613935 +31363764396466333964343136363630386530343662656362316137306634383032363962616530 +38393731346236353530626263336366376466343430316235653363396565656435323531393438 +61356464333539636637363632626661663634333331643734316230663736333134383664376231 +37613339613266663831613030633466326439323635626638343430383230333639333561646431 +66313634373365373137613134373635333535333164353134623937633066613330393430633438 +35366261633739623963623331373262313865326264386334633630653263343637343633313366 +33303036623333633365373830333465333931633761636366323939363463303239363461333139 +33396663376335646137393436323463383461336233646236306331636361653964346536666637 +36376133326431333234613435613535316263313364396362386537343565393533356564633338 +64363261323838343531326234343138303133626636653732313234383662326131313431323332 +32636539323033376434323939666437373936383763323762636439323836656432303833363362 +35646235653839303838363936613166643662393131373438633265316136663264316332663638 +32613535643565303566376530373065646333356136613462666465396566313933323261663736 +66396565396261306139396364393962393361326363666439333566376561366466626335363461 +34356232353664346234316330363962656332396236383136353461333234313662633234346565 +34376365356133396239383333643163386133316461633032373035323131663139336339346633 +32373633663231373361393762396632383738616330333038646439336532303461663430613133 +37633833393762303035353566633736353130663136626666613061383732326233303831386466 +38313162623836373533326361313031303636393564656634393263306262376336303663363933 +30643266626632366333323434383063356363646264363133306566316533356438633135336130 +62663335386335636364313234633965373961353135373339316337626665323761336133653364 +33393733383330656432356231313236646163666565373666633637373765346636336235316534 +64613536333433626261646333373539383862366334376137373862323232653362346431386164 +36643536613133396162653132616134663538393566323363353038383464663638303865376336 +30333833313764643130366533646234343339356562663036373137356565643762306261316632 +33323134633562303263383931623565383766653536353565303266353862643234346637653132 +63646130646339663035333963323366373331616462613236623133646239363134333165646133 +64643433373134656161653130323537613361643731653938623036383331633861666332376361 +39373962653630326561323662303664636161386461383833363865663935303132353637386633 +37346566626439323863393064643765636337616231363066636539306439356632633032663065 +66363839616430666233373033376362623862383066396565633632306534623036626335393039 +32343132616465383961373432336233376339393863663136663435303266333038333566313665 +61303561376366306331633730616265343662333833633533643465373663663634636632666234 +31373332323234376430306538386138316431623133626636633034333735303337663335646461 +61653439653663653930666332313334623264323539613037323534666137616165373865306531 +36306137663164316534373738383865363333316363323538356139646139363064383666626536 +66653363393433316335623063633436353761313065636631623366646633353735326362366162 +64613736343363333834 diff --git a/secrets/vault.yml.example b/secrets/vault.yml.example index 9a8e43e..f1da114 100644 --- a/secrets/vault.yml.example +++ b/secrets/vault.yml.example @@ -11,4 +11,10 @@ vault_atlas_icloudpd_apple_id: "REPLACE_ME" vault_atlas_admin_password_hash: "REPLACE_WITH_A_SHADOW_COMPATIBLE_HASH" vault_atlas_samba_password: "REPLACE_ME" vault_atlas_immich_db_password: "REPLACE_ME" +vault_nextcloud_database_password: "REPLACE_WITH_A_UNIQUE_RANDOM_ALPHANUMERIC_SECRET" +vault_nextcloud_redis_password: "REPLACE_WITH_A_UNIQUE_RANDOM_ALPHANUMERIC_SECRET" +vault_nextcloud_onlyoffice_jwt: "REPLACE_WITH_A_UNIQUE_RANDOM_ALPHANUMERIC_SECRET" +vault_nextcloud_admin_password: "REPLACE_WITH_A_UNIQUE_RANDOM_ALPHANUMERIC_SECRET" +vault_nextcloud_fabio_password: "REPLACE_WITH_A_UNIQUE_RANDOM_ALPHANUMERIC_SECRET" +vault_nextcloud_chiara_password: "REPLACE_WITH_A_UNIQUE_RANDOM_ALPHANUMERIC_SECRET" vault_atlas_borg_passphrase: "REPLACE_WITH_A_STRONG_UNIQUE_PASSPHRASE"