diff --git a/bitwarden/files/nginx.conf.j2 b/bitwarden/files/nginx.conf.j2 deleted file mode 100644 index f56f10e..0000000 --- a/bitwarden/files/nginx.conf.j2 +++ /dev/null @@ -1,42 +0,0 @@ -# The `upstream` directives ensure that you have a http/1.1 connection -# This enables the keepalive option and better performance -# -# Define the server IP and ports here. -upstream vaultwarden-default { - zone vaultwarden-default 64k; - server localhost:8080; - keepalive 2; -} - -# Needed to support websocket connections -# See: https://nginx.org/en/docs/http/websocket.html -# Instead of "close" as stated in the above link we send an empty value. -# Else all keepalive connections will not work. -map $http_upgrade $connection_upgrade { - default upgrade; - '' ""; -} - -server { - - listen 80; - server_name {{ http_host }}; - - # Allow large attachments - client_max_body_size 525M; - - location / { - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $connection_upgrade; - - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - proxy_pass http://vaultwarden-default; - } - -} - diff --git a/bitwarden/playbook.yml b/bitwarden/playbook.yml index ebe4a88..34ed25c 100755 --- a/bitwarden/playbook.yml +++ b/bitwarden/playbook.yml @@ -1,13 +1,53 @@ -################################################# -# Bitwarden Playbook -################################################# --- -- hosts: monoco - become: true +# Vaultwarden (the Bitwarden-compatible server) as a rootless Podman Quadlet +# unit, behind the shared Caddy unit (caddy/playbook.yml) and with its data in +# the shared Postgres unit (postgres/playbook.yml). +# +# Connects as the container user and runs everything as that user: the Quadlet +# units belong to it, so the connection's PAM session is what makes +# `systemctl --user` work without any environment juggling. +# +# gather_facts is required: podman_user and the home directory come from the +# user_id / user_dir facts. +# +# The rootless-Podman prerequisites (installing podman, enabling lingering, the +# low-port sysctl, creating the Quadlet directory) are deliberately NOT applied +# here. They are the shared task file at caddy/roles/podman/tasks/main.yaml, and +# a host that has already run caddy/playbook.yml has them: +# +# - name: Rootless Podman prerequisites +# ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/podman/tasks/main.yaml" +# +# The variables this play needs are duplicated at the top of vars/default.yml, so +# it stands on its own. +# +# Run with: ansible-playbook -u tanshu bitwarden/playbook.yml +- name: Deploy Vaultwarden as a rootless Podman Quadlet + hosts: emily + gather_facts: true vars_files: - vars/default.yml + pre_tasks: + # This must happen before the app unit is started, not after: the generated + # unit references {{ network_name }}.network, and Quadlet fails *unit + # generation* (so `systemctl --user start` reports "unit not found") when a + # referenced .network file does not exist yet. The same include writes the + # Caddy-side drop-in that attaches Caddy to this network, and the Postgres + # attach below needs the network unit to exist as well. + # + # network_name is a play var, so the include can see it directly. + - name: Register Bitwarden's network with Caddy + ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml" + roles: - - network + # Attaches this app's network to the shared Postgres unit, which is what + # makes DATABASE_URL's host resolve, and owns the "Restart Postgres" handler + # that postgres/roles/attach notifies. Runs first so the database is + # reachable before the app starts. + - postgres + # The app: data directory, the 0600 environment file, the Quadlet unit, + # start + verify. - bitwarden + # Appends this site's block to the shared Caddyfile and reloads Caddy. - caddy diff --git a/bitwarden/readme.md b/bitwarden/readme.md index f6d7ecc..f37e4fe 100644 --- a/bitwarden/readme.md +++ b/bitwarden/readme.md @@ -1,46 +1,374 @@ -# Docker on Ubuntu 18.04 +# Vaultwarden / Bitwarden (rootless Podman + Quadlet) -This playbook will install Docker an Ubuntu 18.04 machine, as explained in the guide on -[How to Use Ansible to Install and Set Up Docker on Ubuntu 18.04](https://www.digitalocean.com/community/tutorials/how-to-use-ansible-to-install-and-set-up-docker-on-ubuntu-18-04). -A number of containers will be created with the options specified in the `vars/default.yml` variable file. +Runs [Vaultwarden](https://github.com/dani-garcia/vaultwarden) — the +Bitwarden-compatible server this repo has always called `bitwarden` — on `emily` +as a **rootless Podman** container managed by a **systemd user unit** (Quadlet), +behind the shared [rootless Caddy](../caddy/readme.md), with the vault in the +shared [rootless Postgres](../postgres/readme.md). Replaces the +`docker_container` deployment on `monoco`. + +The name is split on purpose: the directory, container, unit, Podman network and +Postgres database are all `bitwarden`, as they have been since the Docker-era +playbook, and only the image is `vaultwarden/server`. Renaming it would touch the +Caddyfile upstream, the database and every client's saved server URL, for no +gain. + +**Postgres is authoritative for the vault.** `/data` holds everything else — +attachments, Sends, the `rsa_key*` signing pair, the admin config, the icon +cache — and the `db.sqlite3` that the image would use by default is **stale on +the old host and deliberately not migrated** +([docs/backup-plan.md §3.3](../docs/backup-plan.md)). The playbook warns if it +finds that file in the data directory. + +## Ordering + +Both shared services are already migrated and running on `emily`: + +1. [caddy/playbook.yml](../caddy/playbook.yml) — the reverse proxy, which owns + the Caddyfile this site appends to, supplies the `caddy.service` handlers, and + is the one play that applies the rootless-Podman prerequisites. +2. [postgres/playbook.yml](../postgres/playbook.yml) — deployed, and its cluster + brought across from monoco. **The `bitwarden` database is already restored** + (10 MB, one of the 12 databases in + [postgres/migrate-from-monoco.md](../postgres/migrate-from-monoco.md)). +3. This playbook — **run it after the file copy below**, not before: + the app is the thing that writes to `~/podman-data/bitwarden`, and its first + start generates `rsa_key*` if the copy has not landed yet. + +Vaultwarden's environment file points `DATABASE_URL` at `postgres`. Until this +playbook has attached `bitwarden_net` to Postgres, that name does not resolve on +this network, so running this playbook is what makes the app reachable — there is +no separate step. + +## Layout + +``` +bitwarden/ +├── playbook.yml +├── vars/default.yml +└── roles/ + ├── postgres/ # attaches this app's network to Postgres (+ its handler) + ├── bitwarden/ # data dir, the 0600 env file, the Quadlet unit, start + verify + └── caddy/ # appends this site's block to the shared Caddyfile +``` + +Quadlet units and state: + +| On the host | What | +|---|---| +| `~/.config/containers/systemd/bitwarden.container` | the generated unit (Ansible-templated) | +| `~/.config/containers/systemd/bitwarden.network` | the app network, written by `caddy/roles/register` | +| `~/.config/containers/systemd/caddy.container.d/50-bitwarden.conf` | attaches Caddy to that network | +| `~/.config/containers/systemd/postgres.container.d/50-bitwarden.conf` | attaches Postgres to it | +| `~/.config/containers/systemd/postgres.container.d/10-network-aliases.conf` | the shared `db` alias | +| `~/podman-data/bitwarden/data/` | the image's `/data`: `attachments/`, `sends/`, `rsa_key*`, `config.json`, `icon_cache/` | +| `~/podman-data/bitwarden/bitwarden.env` | `DATABASE_URL` + `DOMAIN`, mode `0600`, read by systemd | + +## What is state, and what is not + +Per the upstream wiki's +[Backing up your data](https://github.com/dani-garcia/vaultwarden/wiki/Backing-up-your-vault), +and with Postgres as the database, `data/` holds: + +| Path | Keep? | Why | +|---|---|---| +| `attachments/` | **required** | the only important data not in database tables | +| `sends/` | copy it | Send attachments; ephemeral by design, but copying keeps existing Sends working | +| `rsa_key.pem`, `rsa_key.der`, `rsa_key.pub.*` | copy it | signs the JWTs of logged-in users. Regenerating the pair logs everyone out and invalidates open invitation tokens | +| `config.json` | copy it | admin-page configuration; only exists if the admin page was enabled | +| `icon_cache/` | optional | favicon cache, refetched on demand | +| `db.sqlite3`, `-wal`, `-shm` | **no** | stale. Postgres has been authoritative for years; restoring this would silently lose months of vault data | + +## How it registers with Caddy + +Caddy is the base unit and knows nothing about its upstreams. This playbook calls +the shared include in +[caddy/roles/register](../caddy/roles/register/tasks/main.yaml), which writes the +network unit **and** the drop-in that pulls it into Caddy: + +```yaml +- name: Register Bitwarden's network with Caddy + ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml" +``` + +It runs in `pre_tasks`, **before** the app unit is started, because +`bitwarden.container` references `bitwarden.network`: Quadlet fails *unit +generation* — so `systemctl --user start` reports "unit not found" — when a +referenced `.network` file does not exist yet. The Postgres attach below depends +on it for the same reason. + +`network_name` is `bitwarden`, so the *file* is `bitwarden.network` and the +*Podman network* is `bitwarden_net` (`NetworkName=` in the network unit pins it, +keeping the Docker-era name). + +The Docker-era `roles/network` is **deleted rather than ported**. It existed to +attach an already-created container to a network — `docker_network`'s +`connected:`/`appends:` — which `podman_network` has no equivalent for +([docs/podman-compat-audit.md §3.3](../docs/podman-compat-audit.md)). Nothing has +to be connected after the fact now: the app's own unit names its `Network=`, and +Caddy and Postgres learn about the network from the drop-ins written in the same +run. + +The site block itself is appended to the shared Caddyfile by +[roles/caddy](roles/caddy/tasks/main.yaml) with `blockinfile`, exactly as the +other services do — only `caddyfile_path` changed, from +`/var/lib/caddy/conf/Caddyfile` to `~/podman-data/caddy/conf/Caddyfile`. Caddy +handles the WebSocket upgrade the Docker-era nginx front end had to configure by +hand, and it imposes no request-body limit, so the `client_max_body_size 525M` in +the deleted `files/nginx.conf.j2` has no equivalent and needs none. + +## How it attaches to Postgres + +Same idea at the other end: Postgres knows nothing about its clients, and this +playbook uses the shared include in +[postgres/roles/attach](../postgres/roles/attach/tasks/main.yaml) rather than +writing those drop-ins itself. That writes two files, both mergeable into +`postgres.container` by Quadlet: + +| File | Content | +|---|---| +| `postgres.container.d/50-bitwarden.conf` | `Network=bitwarden.network` | +| `postgres.container.d/10-network-aliases.conf` | `NetworkAlias=db` | + +`DATABASE_URL` uses the host `postgres`, which is what the Docker-era `db_url` +used and what `ContainerName=postgres` provides; the shared `db` alias resolves +too, and the play checks both. + +[roles/postgres](roles/postgres/tasks/main.yaml) wraps that include for two +reasons: + +- it defines the `Restart Postgres` handler the include notifies; and +- it **flushes handlers** immediately afterwards. On a first attach the drop-in + is new, so Postgres has to be *recreated* to join `bitwarden_net` — and that + restart is a handler, which by default runs at the end of the play, after the + `bitwarden` role has already started the app. The app would then come up + before `postgres` resolves, fail its own health check, and fail the play on a + machine where nothing is wrong. Flushing applies the drop-in first; on later + runs nothing is pending and it is a no-op. + +Note the cost stated in [postgres/readme.md](../postgres/readme.md): a new +drop-in recreates Postgres, which is a few seconds of downtime for **every** +attached app, not just this one. ## Settings -- `create_containers`: number of containers to create. -- `default_container_name`: default name for new containers. -- `default_container_image`: default image for new containers. -- `default_container_command`: default command to run on new containers. +| Variable | Value / purpose | +|---|---| +| `podman_user` | **Derived** from the connection (`ansible_facts['user_id']`), not set by hand | +| `podman_data_dir` | `~/podman-data` | +| `quadlet_dir` | `~/.config/containers/systemd` | +| `host` | `vault.tanshu.com` — the site block Caddy serves | +| `container_name` | `bitwarden`, and the Quadlet unit / container name | +| `image` | `docker.io/vaultwarden/server:latest` — fully qualified on purpose | +| `network_name` | `bitwarden` → `bitwarden.network` / `bitwarden_net` | +| `data_dir` | `~/podman-data/bitwarden/data` → the image's `/data` | +| `container_port` | `80`, the port inside the container that Caddy proxies to (`ROCKET_PORT`) | +| `env_file` | `~/podman-data/bitwarden/bitwarden.env`, mode `0600` | +| `postgres_container` / `postgres_unit` | The shared database unit this app attaches to | +| `pg_user` / `pg_password` / `pg_database` | Credentials, and the database name — `bitwarden` | +| `database_url` / `domain` | What the environment file contains | +| `caddy_name` / `caddyfile_path` | The shared Caddy unit and its Caddyfile | +The three `podman_*` / `quadlet_dir` vars are **duplicated** from +[caddy/vars/default.yml](../caddy/vars/default.yml), as in every service +playbook. That is a deliberate trade: a play cannot include the Caddy vars file +wholesale, because it also sets `host_directory` (to the *Caddy* data directory) +and every service defines that for itself. Keep them in sync. Caddy's fourth, +`podman_unprivileged_port_start`, belongs to the podman tasks this play does not +run — Vaultwarden publishes nothing host-side. -## Running this Playbook +Like Caddy, `podman_user` is derived from the connection and the play carries no +`become`. Because the podman tasks are not run here, there is **no guard against +connecting as `root`**; the prerequisites, including that check, live in +`caddy/playbook.yml`. -Quick Steps: +`UserNS=keep-id:uid=0,gid=0` — Vaultwarden's image runs as container-root and +writes `/data` as root, with no entrypoint step that chowns anything +([docs/podman-compat-audit.md §3.2](../docs/podman-compat-audit.md)). Mapping +container root onto `podman_user` keeps `~/podman-data/bitwarden` owned by +`tanshu` and leaves no subuid-owned files to trip over backups. -### 1. Obtain the playbook -```shell -git clone https://github.com/do-community/ansible-playbooks.git -cd ansible-playbooks/docker_ubuntu1804 -``` - -### 2. Customize Options - -```shell -nano vars/default.yml -``` - -```yml -#vars/default.yml ---- -create_containers: 4 -default_container_name: docker -default_container_image: ubuntu -default_container_command: sleep 1d -``` - -### 3. Run the Playbook +## Running ```command -ansible-playbook -l [target] -i [inventory file] -u [remote user] playbook.yml +ansible-playbook -u tanshu bitwarden/playbook.yml ``` -For more information on how to run this Ansible setup, please check this guide: [How to Use Ansible to Install and Set Up Docker on Ubuntu 18.04](https://www.digitalocean.com/community/tutorials/how-to-use-ansible-to-install-and-set-up-docker-on-ubuntu-18-04). \ No newline at end of file +The play targets `emily` directly, so the inventory must contain it — the one in +`prepare-server/` does, and its `ansible.cfg` names it. + +This play applies **only** this app's units and data. Its `pre_tasks` run Caddy's +`register` include and nothing else. The rootless-Podman prerequisites — +installing podman, `loginctl enable-linger`, the low-port sysctl and the Quadlet +directory — are the shared task file at +[caddy/roles/podman/tasks/main.yaml](../caddy/roles/podman/tasks/main.yaml), and +are **not** run here. A host that has already run `caddy/playbook.yml` has them. + +`systemctl --user enable bitwarden` is neither needed nor possible: the unit is +*generated*, and Quadlet applies `[Install] WantedBy=default.target` itself. + +## Migrating the file data from monoco + +The database already moved — `bitwarden` is one of the 12 databases restored in +[postgres/migrate-from-monoco.md](../postgres/migrate-from-monoco.md). **This is +the app's own files only**, and the playbook does not move them because they are +on the old host while the playbook runs on the new one. Run this from the machine +that holds the `monoco` and `emily` SSH aliases. + +The whole tree is about **4 MB** ([docs/backup-plan.md §2.1](../docs/backup-plan.md)), +so this is quick — the state that matters is the 10 MB Postgres database, and that +is done. Copy it anyway: `attachments/` is the part of the vault that is not in +any database. + +```bash +# 1. Look before you copy: size, contents, and whether it is readable without +# sudo. `ls -ln` shows numeric owners, which is what matters here (the old +# container ran as root, so these files are uid 0). +ssh monoco 'du -sh /var/lib/bitwarden/data; ls -ln /var/lib/bitwarden/data' +ssh monoco 'find /var/lib/bitwarden/data -type f ! -readable' # empty = no sudo needed + +# 2. Stop the old container so nothing writes while you copy. Leave it stopped: +# its database is on emily now, and two live copies would diverge. +ssh monoco 'docker stop bitwarden' + +# 3. Create the destination (this is the mount target of the image's /data). +ssh emily 'mkdir -p ~/podman-data/bitwarden/data' + +# 4. Copy. tar streams it across the workstation; rsync cannot copy +# remote-to-remote in one command. +# +# `./db.sqlite3*` is excluded — main file, -wal and -shm. It is stale on +# monoco (see the table above), and with Postgres configured it would be an +# inert file that looks exactly like a restore source. +# +# `set -o pipefail` matters here: without it the pipeline reports only the +# *receiving* tar's status, so a read error or a dropped stream on the source +# side exits 0 and leaves a silently short copy. +set -o pipefail +ssh monoco 'tar --exclude="./db.sqlite3*" -C /var/lib/bitwarden/data -cf - .' \ + | ssh emily 'tar --no-same-owner -C ~/podman-data/bitwarden/data -xf -' + +# If step 1 listed unreadable files, read them as root instead: +# ssh monoco 'sudo tar --exclude="./db.sqlite3*" -C /var/lib/bitwarden/data -cf - .' \ +# | ssh emily 'tar --no-same-owner -C ~/podman-data/bitwarden/data -xf -' + +# 5. Verify by content, over the whole tree, with the same exclusion on the +# source side. The paths are relative on both sides, so the digests are +# comparable. +ssh monoco 'cd /var/lib/bitwarden/data && find . -type f ! -name "db.sqlite3*" -print0 | sort -z | xargs -0 -r sha256sum | sha256sum' +ssh emily 'cd ~/podman-data/bitwarden/data && find . -type f -print0 | sort -z | xargs -0 -r sha256sum | sha256sum' +# identical digests = a byte-faithful copy + +# 6. Nothing may end up owned by root or by a subuid. +ssh emily 'find ~/podman-data/bitwarden ! -user tanshu' # empty = correct + +# 7. Deploy — after the copy, so Vaultwarden finds the real data on first start +# rather than generating a fresh rsa_key pair. +cd prepare-server && ansible-playbook -u tanshu ../bitwarden/playbook.yml +``` + +Ownership needs no `chown` pass: the container runs as uid 0 in-container, which +`keep-id` maps to `tanshu`, so files extracted as `tanshu` are already right. +That is also why `--no-same-owner` is explicit on the receiving side — the source +is root-owned, and restoring that ownership would be wrong here. + +If the link is slow or drops, do it in two hops instead: resumable, and it shows +progress. + +```bash +rsync -aP --exclude='db.sqlite3*' monoco:/var/lib/bitwarden/data/ ~/bitwarden-copy/ +rsync -aP ~/bitwarden-copy/ emily:~/podman-data/bitwarden/data/ +``` + +Rolling back means rolling back both halves: `ssh monoco 'docker start bitwarden'` +is only meaningful while monoco's own Postgres 17 cluster is still intact, and it +would then be writing to a database that emily's copy has already moved past. + +## Verification + +```bash +systemctl --user status bitwarden +journalctl --user -u bitwarden -f +podman logs bitwarden + +# What the playbook checks for you: +podman healthcheck run bitwarden # the image's own probe +podman exec bitwarden getent hosts postgres +podman exec bitwarden getent hosts db + +# The database really is the migrated one. Run the same query on both hosts and +# compare — equal counts mean the vault came across. +ssh emily 'podman exec -e PGPASSWORD=123456 postgres psql -U postgres -d bitwarden -tAc "select count(*) from users"' +ssh monoco 'docker exec -e PGPASSWORD=123456 postgres psql -U postgres -d bitwarden -tAc "select count(*) from users"' + +# The data directory is the copied one, and has no SQLite file in it. +ssh emily 'ls -ln ~/podman-data/bitwarden/data' +ssh emily 'test ! -e ~/podman-data/bitwarden/data/db.sqlite3 && echo "no stale SQLite"' + +# The site, end to end. +curl -sSI https://vault.tanshu.com +``` + +Then log in **in a browser** and confirm an existing item decrypts — that is the +check that the Postgres database and the client-facing crypto are both intact, +and a `curl` cannot make it. The play waits on the image's own health check +(`/healthcheck.sh` → `/alive`), which is stronger than `is-active` but still only +says the process answers. + +Also exercise a **write** through the app — upload a file attachment — and confirm +on the host that the new file under +`~/podman-data/bitwarden/data/attachments` is owned by `tanshu`. That is the check +that catches a wrong `keep-id`; a read-only smoke test will not. + +Then reboot the host and confirm the unit returns unattended — without +`loginctl enable-linger tanshu` it will not, and the failure is silent. + +## Notes and gotchas + +- **The stale SQLite file.** If `~/podman-data/bitwarden/data/db.sqlite3` exists, + the play prints a warning on every run rather than failing, because the file is + inert while `DATABASE_URL` is set. It is not a backup. Delete it once the + Postgres database has been verified. +- **Signups are open.** The Docker-era container set only `DATABASE_URL` and + `DOMAIN`, so `SIGNUPS_ALLOWED` is still the image default (`true`), the admin + panel is off (no `ADMIN_TOKEN`), and SMTP is unconfigured. This playbook keeps + that behaviour deliberately — tightening it is a decision, not a migration + step. +- **Client IPs.** Vaultwarden uses the client address for its login + brute-force protection, and whether the original address survives the rootless + port forward depends on the forwarder. See + [caddy/readme.md](../caddy/readme.md), "Client IPs", before relying on it. +- **`latest` floats.** The old playbook used the unqualified + `vaultwarden/server:latest`; this one fully qualifies the same tag, because + rootless Podman has no TTY to answer a short-name prompt when the unit starts. + Neither pins a digest or a version, so an image bump is a normal `podman pull` + away and may run new Diesel migrations on first start. +- **Registering restarts all of Caddy**, and attaching restarts Postgres for + everyone — a brief restart of every hosted site, and a few seconds of downtime + for every attached app. See the trade-offs in + [caddy/readme.md](../caddy/readme.md) and + [postgres/readme.md](../postgres/readme.md). +- **If the Caddyfile does not exist yet**, this playbook creates it containing + only the site block. That is a valid Caddyfile; the Caddy playbook's seeded + global-options block is then not added, because its seed only fires on a + missing or zero-length file. Deploy Caddy first on a fresh host. +- **DNS.** `vault.tanshu.com` must resolve to `emily` before Caddy can obtain a + certificate for it. +- **The backup scripts still assume Docker.** `~/bin/bitwarden-backup.sh` uses + `--link`, which Podman does not have; it needs the `podman exec` form from + [postgres/readme.md](../postgres/readme.md), "Backups". `bitwarden-upload.sh` + is worse than broken — it uploads a `production.sqlite3` that does not exist, + and the file it would upload is not the authoritative store anyway. Also, + root's crontab cannot `podman exec` into another user's rootless containers, so + anything scheduled belongs in a **systemd user timer** next to the Quadlet + units. +- **Do not restart the container on monoco.** It would write to monoco's old + cluster, which is being kept intact purely as a rollback. + +## Not done here + +- Rotating the secrets that are committed in plaintext — `pg_password` is the + fleet's shared `123456`, and it lives in `bitwarden.env` at mode `0600` rather + than in the world-readable unit file. +- Closing signups, enabling the admin page, or configuring SMTP (see above). +- Replacing the two broken backup scripts with a systemd user timer. diff --git a/bitwarden/roles/bitwarden/handlers/main.yaml b/bitwarden/roles/bitwarden/handlers/main.yaml new file mode 100644 index 0000000..439c095 --- /dev/null +++ b/bitwarden/roles/bitwarden/handlers/main.yaml @@ -0,0 +1,20 @@ +--- +# All handlers run as the connection user ({{ podman_user }}), so systemctl +# --user works without setting XDG_RUNTIME_DIR. + +- name: Reload systemd user units + ansible.builtin.command: systemctl --user daemon-reload + changed_when: true + listen: Reload systemd user units + +# A changed environment file or unit file changes the generated unit, so +# Vaultwarden is recreated. The vault itself is in Postgres and the attachments +# are on a bind mount, so a recreate loses nothing — but it is a few seconds of +# downtime for the one site. +- name: Restart Bitwarden + ansible.builtin.systemd_service: + name: "{{ unit_name }}" + scope: user + state: restarted + daemon_reload: true + listen: Restart Bitwarden diff --git a/bitwarden/roles/bitwarden/tasks/main.yaml b/bitwarden/roles/bitwarden/tasks/main.yaml index 3c32547..070e95a 100644 --- a/bitwarden/roles/bitwarden/tasks/main.yaml +++ b/bitwarden/roles/bitwarden/tasks/main.yaml @@ -1,27 +1,138 @@ --- -# Tasks for Bitwarden role -- name: Pull Bitwarden Vault Warden image - docker_image: - name: "{{ image_name }}" - source: pull - force_source: yes +# Vaultwarden (the Bitwarden-compatible server) as a rootless Podman Quadlet +# unit. +# +# Everything here runs as {{ podman_user }} — the connection user. No become, no +# XDG_RUNTIME_DIR: the SSH connection gave us a real PAM session, so systemd's +# user manager is reachable through the standard environment. -- name: Ensure Bitwarden Directory exists - file: - path: "/var/lib/{{ host_directory }}" - state: directory - mode: 0755 +# /data is the image's VOLUME, and it holds everything Vaultwarden owns that is +# not in Postgres: attachments/, sends/, the rsa_key signing pair, the admin +# config and the icon cache. Postgres is authoritative for the vault itself +# (docs/backup-plan.md §3.3). +- name: Ensure the Vaultwarden data directory exists + ansible.builtin.file: + path: "{{ data_dir }}" + state: directory + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0755' -- name: Create bitwarden container - docker_container: - name: "{{ docker_container }}" - image: "{{ image_name }}" - state: started - restart_policy: "unless-stopped" - env: - DATABASE_URL: "{{ db_url }}" - DOMAIN: "https://{{ host }}" - volumes: - - "/var/lib/{{ host_directory }}/data:/data/" - networks: - - name: "{{ docker_network }}" +# The app's secrets and database coordinates are consumed by systemd +# (EnvironmentFile= in the Quadlet unit), not by Ansible, so this has to be +# written before the unit starts. Mode 0600: DATABASE_URL carries the Postgres +# password. +- name: Write the environment file + ansible.builtin.template: + src: bitwarden.env.j2 + dest: "{{ env_file }}" + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0600' + notify: Restart Bitwarden + +- name: Install the Vaultwarden Quadlet unit + ansible.builtin.template: + src: bitwarden.container.j2 + dest: "{{ quadlet_dir }}/{{ container_name }}.container" + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0644' + notify: + - Reload systemd user units + - Restart Bitwarden + +- name: Start Vaultwarden + block: + - name: Start Vaultwarden + ansible.builtin.systemd_service: + name: "{{ unit_name }}" + scope: user + state: started + daemon_reload: true + rescue: + # systemd reports "control process exited with error code" and nothing + # else, which is useless for the usual failures (an unattached network, an + # unreachable database, a bad volume). Pull the real reason out. + - name: Collect Vaultwarden diagnostics + ansible.builtin.command: "{{ item }}" + loop: + - "systemctl --user status {{ unit_name }} --no-pager" + - "journalctl --user -u {{ unit_name }} --no-pager -n 40" + - "podman logs --tail 40 {{ container_name }}" + register: bitwarden_diag + changed_when: false + failed_when: false + + - name: Show Vaultwarden diagnostics + ansible.builtin.debug: + msg: "{{ bitwarden_diag.results | map(attribute='stdout') | list }}" + + - name: Fail + ansible.builtin.fail: + msg: "Vaultwarden did not start — see the diagnostics above." + +# Quadlet honours [Install] WantedBy= when it generates the unit, so +# `systemctl --user enable` is neither needed nor possible on a generated unit. +- name: Verify Vaultwarden is running + ansible.builtin.command: "systemctl --user is-active {{ unit_name }}" + register: bitwarden_active + changed_when: false + failed_when: bitwarden_active.stdout != 'active' + +# `is-active` says the container is running; it does not say the app can serve. +# Vaultwarden runs its Diesel migrations against Postgres before Rocket starts +# answering, and `Restart=always` means even a crash-looping container is +# reported active. +# +# `podman healthcheck run` executes the image's *own* HEALTHCHECK — which is +# /healthcheck.sh curling http://localhost:80/alive — so this depends on nothing +# the play has to know about: no curl, wget or python3 inside the image, and no +# hardcoded path that a later image tag could move. +- name: Wait for Vaultwarden to pass its own health check + ansible.builtin.command: "podman healthcheck run {{ container_name }}" + register: bitwarden_health + until: bitwarden_health.rc == 0 + retries: 24 + delay: 5 + changed_when: false + +# The database is the whole vault, and it arrives over the network that +# roles/postgres attached Postgres to. Check the name the environment file +# actually asks for, plus the shared `db` alias. +- name: Confirm Vaultwarden can resolve its database + ansible.builtin.command: >- + podman exec {{ container_name }} getent hosts {{ item }} + loop: + - "{{ postgres_container }}" + - db + register: bitwarden_db_lookup + changed_when: false + failed_when: false + +- name: Report database resolution + ansible.builtin.debug: + msg: >- + {{ item.item }} resolves as + {{ item.stdout | default('(no answer)') }} + loop: "{{ bitwarden_db_lookup.results }}" + +- name: Check for a stale SQLite database in the data directory + ansible.builtin.stat: + path: "{{ data_dir }}/db.sqlite3" + register: bitwarden_stale_sqlite + +# SQLite in /data is the image's own default, and the Docker-era deployment left +# a stale db.sqlite3 (with -wal/-shm sidecars) behind long after DATABASE_URL had +# been pointed at Postgres. This deployment ignores the file entirely, which is +# exactly why it is dangerous: it looks like a restore source, and restoring it +# would silently lose months of vault data (docs/backup-plan.md §3.3). Warn +# rather than fail, because the file is inert while DATABASE_URL is set. +- name: Warn about the stale SQLite database + ansible.builtin.debug: + msg: >- + {{ data_dir }}/db.sqlite3 exists, but Postgres is the authoritative store + for this deployment and Vaultwarden is not reading it. It is not a backup + and must not be restored. Delete it once the Postgres database has been + verified — see readme.md, "Migrating the file data". + when: bitwarden_stale_sqlite.stat.exists diff --git a/bitwarden/roles/bitwarden/templates/bitwarden.container.j2 b/bitwarden/roles/bitwarden/templates/bitwarden.container.j2 new file mode 100644 index 0000000..2c9f767 --- /dev/null +++ b/bitwarden/roles/bitwarden/templates/bitwarden.container.j2 @@ -0,0 +1,45 @@ +# Vaultwarden (Bitwarden-compatible server) — rootless Podman Quadlet unit. +# Managed by Ansible (bitwarden/roles/bitwarden). Do not edit by hand. +# +# This unit joins {{ network_name }}.network, which is created by +# caddy/roles/register (the same include that attaches Caddy to it). The +# postgres container is joined to that network by postgres/roles/attach, which +# is what makes the host in DATABASE_URL resolve. + +[Unit] +Description=Vaultwarden (Bitwarden-compatible server) +Documentation=https://github.com/dani-garcia/vaultwarden/wiki +After=network-online.target +Wants=network-online.target + +[Container] +ContainerName={{ container_name }} +Image={{ image }} + +# Vaultwarden's image runs as container-root and writes /data as root, with no +# entrypoint step that chowns it (see docs/podman-compat-audit.md §3.2). +# Mapping container root onto {{ podman_user }} keeps {{ data_dir }} owned by +# {{ podman_user }} on the host, so plain tar/rsync backups keep working and no +# subuid-owned files appear. +UserNS=keep-id:uid=0,gid=0 + +# DATABASE_URL and DOMAIN live here rather than as Environment= lines above: +# this unit is world-readable in ~/.config, and DATABASE_URL carries the +# Postgres password. Read by systemd, not by Ansible, so the file has to exist +# before this unit starts. +EnvironmentFile={{ env_file }} + +Network={{ network_name }}.network + +# The image's VOLUME. Everything Vaultwarden owns outside Postgres — attachments, +# sends, the rsa_key signing pair, the admin config — lives here. +Volume={{ data_dir }}:/data + +[Service] +Restart=always +# The first start runs Diesel migrations against Postgres before Rocket begins +# answering on port {{ container_port }}. +TimeoutStartSec=120 + +[Install] +WantedBy=default.target diff --git a/bitwarden/roles/bitwarden/templates/bitwarden.env.j2 b/bitwarden/roles/bitwarden/templates/bitwarden.env.j2 new file mode 100644 index 0000000..aa36a62 --- /dev/null +++ b/bitwarden/roles/bitwarden/templates/bitwarden.env.j2 @@ -0,0 +1,19 @@ +# Environment for {{ container_name }}.container. +# Managed by Ansible (bitwarden/roles/bitwarden). Do not edit by hand. +# +# Mode 0600 on purpose: the Quadlet unit that reads this file is world-readable, +# and DATABASE_URL carries the Postgres password. Note that `podman inspect` +# still prints the resulting environment to anyone who can run podman as this +# user. +# +# Postgres is authoritative for the vault. The SQLite file the image would use +# by default ({{ data_dir }}/db.sqlite3) is stale on the old host and is +# deliberately not migrated — see readme.md, "Migrating the file data". +DATABASE_URL={{ database_url }} +DOMAIN={{ domain }} + +# Deliberately not set, so this deployment behaves exactly as the Docker-era +# container did: SIGNUPS_ALLOWED is still the image default (true), the admin +# panel is off (no ADMIN_TOKEN), and SMTP is unconfigured. Tightening any of +# those is a behaviour change, not a migration step — see readme.md, +# "Notes and gotchas". diff --git a/bitwarden/roles/caddy/handlers/main.yaml b/bitwarden/roles/caddy/handlers/main.yaml index 294bf14..5fe87df 100644 --- a/bitwarden/roles/caddy/handlers/main.yaml +++ b/bitwarden/roles/caddy/handlers/main.yaml @@ -1,5 +1,31 @@ --- -# Handlers for Caddy role +# Caddy is its own playbook (caddy/playbook.yml) and a rootless Quadlet unit +# there. These handlers run as the connection user, so `systemctl --user` and +# `podman exec` work without any XDG_RUNTIME_DIR juggling. + +# Notified by this role when the Caddyfile changes. A reload keeps existing +# connections; a restart would briefly drop every hosted site. +# +# Note the config path: /etc/caddy/Caddyfile is the mount inside the container, +# not {{ caddyfile_path }}, which is the host side of it. - name: Reload Caddy configuration - ansible.builtin.command: "docker exec -w /etc/caddy {{ caddy_container }} caddy reload" - listen: "Reload Caddy" \ No newline at end of file + ansible.builtin.command: >- + podman exec {{ caddy_name }} caddy reload --config /etc/caddy/Caddyfile + changed_when: true + listen: "Reload Caddy" + +# Notified by caddy/roles/register when this service's network unit, or the +# drop-in that attaches Caddy to it, is written. Quadlet has no live equivalent +# of `docker network connect`, so the container has to be recreated — a brief +# restart of every hosted site, not just this one. See caddy/readme.md, +# "Trade-off worth knowing". +# +# Defined after the reload above so that when both are notified the cheap +# config reload happens first. +- name: Restart Caddy + ansible.builtin.systemd_service: + name: "{{ caddy_name }}.service" + scope: user + state: restarted + daemon_reload: true + listen: Restart Caddy diff --git a/bitwarden/roles/caddy/tasks/main.yaml b/bitwarden/roles/caddy/tasks/main.yaml index 9616151..ffbe0d8 100644 --- a/bitwarden/roles/caddy/tasks/main.yaml +++ b/bitwarden/roles/caddy/tasks/main.yaml @@ -1,22 +1,22 @@ --- -- name: Read current Caddyfile - ansible.builtin.slurp: - path: "{{ caddyfile_path }}" - register: caddyfile_raw +# Append this site's block to the shared Caddyfile. +# +# The Caddyfile is a bind mount into the Caddy container, so Caddy sees the +# change as soon as the file is written; the handler only asks Caddy to re-read +# it. That is a reload, not a restart, so the other hosted sites are untouched. +# +# blockinfile is idempotent on its own via the marker, so there is no need to +# slurp the file and compare first — which also matters because slurp *fails* +# when the Caddyfile does not exist yet, on a host where the Caddy playbook has +# not run. -- name: Decode Caddyfile content - set_fact: - caddyfile_content: "{{ caddyfile_raw['content'] | b64decode }}" - -- name: Check if snippet already exists - set_fact: - snippet_present: "{{ snippet_block in caddyfile_content }}" - -- name: Add snippet if missing +- name: "Add the {{ host }} site block to the Caddyfile" ansible.builtin.blockinfile: path: "{{ caddyfile_path }}" marker: "# {mark} Ansible managed Caddy snippet for {{ host }}" block: "{{ snippet_block }}" create: yes - when: not snippet_present - notify: "Reload Caddy" \ No newline at end of file + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0644' + notify: Reload Caddy diff --git a/bitwarden/roles/network/tasks/main.yaml b/bitwarden/roles/network/tasks/main.yaml deleted file mode 100644 index 376c80e..0000000 --- a/bitwarden/roles/network/tasks/main.yaml +++ /dev/null @@ -1,9 +0,0 @@ ---- -- name: "Ensure {{ docker_container }} Docker network exists" - docker_network: - name: "{{ docker_network }}" - state: present - connected: - - "{{ caddy_container }}" - - "{{ postgres_container }}" - appends: yes diff --git a/bitwarden/roles/postgres/handlers/main.yaml b/bitwarden/roles/postgres/handlers/main.yaml new file mode 100644 index 0000000..31a5d44 --- /dev/null +++ b/bitwarden/roles/postgres/handlers/main.yaml @@ -0,0 +1,17 @@ +--- +# Postgres is its own playbook (postgres/playbook.yml) and a rootless Quadlet +# unit there. This handler runs as the connection user, so `systemctl --user` +# works without setting XDG_RUNTIME_DIR. +# +# Required by postgres/roles/attach, which notifies it whenever this app's +# drop-in changes: a new drop-in changes the generated unit, so Postgres must be +# recreated to join the network — Quadlet has no live equivalent of +# `podman network connect`. That is a few seconds of downtime for every attached +# app, not just this one. See postgres/readme.md, "The trade-off". +- name: Restart Postgres + ansible.builtin.systemd_service: + name: "{{ postgres_unit }}" + scope: user + state: restarted + daemon_reload: true + listen: Restart Postgres diff --git a/bitwarden/roles/postgres/tasks/main.yaml b/bitwarden/roles/postgres/tasks/main.yaml new file mode 100644 index 0000000..0043938 --- /dev/null +++ b/bitwarden/roles/postgres/tasks/main.yaml @@ -0,0 +1,26 @@ +--- +# Attach this app's network to the shared Postgres unit. +# +# Postgres knows nothing about its clients, exactly like Caddy: a service joins +# by dropping 50-bitwarden.conf and the shared 10-network-aliases.conf next to +# postgres.container. The canonical way to do that is the shared include below — +# it writes the same bytes every service writes, asserts that bitwarden.network +# already exists, and notifies the "Restart Postgres" handler defined in this +# role. +# +# This runs in the roles phase, which is after the register include in +# playbook.yml's pre_tasks has created bitwarden.network. + +- name: Attach Bitwarden to Postgres + ansible.builtin.include_tasks: "{{ playbook_dir }}/../postgres/roles/attach/tasks/main.yaml" + +# On a first attach both drop-ins are new, so Postgres has to be recreated to +# join bitwarden_net — and that restart is a *handler*, which by default runs at +# the end of the play, after the bitwarden role has already started the app. The +# app would then come up before DATABASE_URL's host resolves, fail its own +# health check, and fail the play on a machine where nothing is actually wrong. +# +# Flushing applies the new drop-in before the app starts. On later runs the +# drop-ins are unchanged, nothing is pending, and this is a no-op. +- name: Apply the Postgres network change before starting Vaultwarden + ansible.builtin.meta: flush_handlers diff --git a/bitwarden/vars/default.yml b/bitwarden/vars/default.yml index 01d69d2..bd142c7 100644 --- a/bitwarden/vars/default.yml +++ b/bitwarden/vars/default.yml @@ -1,21 +1,87 @@ --- +# --------------------------------------------------------------------------- +# Rootless Podman / Quadlet +# --------------------------------------------------------------------------- +# Duplicated on purpose from caddy/vars/default.yml: a play cannot include that +# file wholesale, because it also sets `host_directory` — to the Caddy data +# directory — and every playbook defines that for itself. Keep these three in +# sync with caddy/vars/default.yml. (Its fourth, podman_unprivileged_port_start, +# belongs to the podman tasks, which this play does not run: Vaultwarden +# publishes nothing host-side.) +# +# podman_user is derived from the connection: this play carries no become, so +# the Quadlet units belong to whoever Ansible connects as. Requires gather_facts +# (set in playbook.yml). +podman_user: "{{ ansible_facts['user_id'] }}" +podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data" +quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd" + +# --------------------------------------------------------------------------- +# Vaultwarden (the Bitwarden-compatible server) +# --------------------------------------------------------------------------- +# The service keeps the Docker-era name everywhere — directory, container, unit, +# Podman network and Postgres database are all `bitwarden`, and the Caddyfile +# upstream is `bitwarden:80`. Only the image is `vaultwarden/server`. Renaming +# it would touch the Caddyfile, the Postgres database and every client's saved +# server URL, for no gain. host: vault.tanshu.com -docker_container: bitwarden -image_name: vaultwarden/server:latest +container_name: bitwarden +unit_name: "{{ container_name }}.service" -host_directory: bitwarden +# Fully qualified on purpose: rootless Podman resolves short names against +# unqualified-search-registries, and there is no TTY to answer a prompt when +# the systemd unit starts. +image: docker.io/vaultwarden/server:latest -docker_network: "{{ docker_container }}_net" +# The Quadlet file is .network. caddy/roles/register pins the +# Podman network name to _net (so: bitwarden.network, +# bitwarden_net). +network_name: "{{ container_name }}" +host_directory: "{{ podman_data_dir }}/{{ container_name }}" + +# The image's VOLUME is /data and ROCKET_PORT is 80, so this is both the bind +# mount target and the port Caddy proxies to. +data_dir: "{{ host_directory }}/data" +container_port: 80 + +# DATABASE_URL carries the Postgres password, and the Quadlet unit is +# world-readable in ~/.config, so it goes in this 0600 file instead and is read +# by systemd through EnvironmentFile=. +env_file: "{{ host_directory }}/bitwarden.env" + +# The shared Postgres unit this app attaches itself to (postgres/playbook.yml). +# roles/postgres owns this app's drop-ins there and the "Restart Postgres" +# handler that applies them. postgres_container: postgres -caddy_container: caddy +postgres_unit: "{{ postgres_container }}.service" -caddyfile_path: /var/lib/caddy/conf/Caddyfile +# The credentials the Docker-era playbook embedded in its db_url, kept as-is so +# the already-restored `bitwarden` database and the app's own connection string +# agree. Postgres is authoritative for this service; the SQLite file the image +# would use by default is stale and deliberately not migrated (see readme.md). +pg_user: postgres +pg_password: '123456' +pg_database: bitwarden + +# Hostname, not an IP, and the same `postgres` name the old db_url used: +# postgres/roles/attach joins the postgres container to bitwarden_net, which is +# what makes this resolve. (`db`, the shared NetworkAlias, resolves too.) +database_url: "postgresql://{{ pg_user }}:{{ pg_password }}@{{ postgres_container }}:5432/{{ pg_database }}" + +# Vaultwarden validates that DOMAIN carries a scheme and serves its web vault +# and API links from it. The old container set exactly this. +domain: "https://{{ host }}" + +# The shared Caddy unit and its Caddyfile, both owned by caddy/playbook.yml. +caddy_name: caddy +caddyfile_path: "{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile" + +# No request-body limit: Caddy streams request bodies and imposes none by +# default, so the 525M `client_max_body_size` the dead nginx front end carried +# has no equivalent — and needs none. snippet_block: | {{ host }} { - reverse_proxy {{ docker_container }}:80 + reverse_proxy {{ container_name }}:{{ container_port }} } - -db_url: postgresql://postgres:123456@{{ postgres_container }}:5432/bitwarden -