diff --git a/02_vps_core_services_setup.md b/02_vps_core_services_setup.md index 1423d19..19cd122 100644 --- a/02_vps_core_services_setup.md +++ b/02_vps_core_services_setup.md @@ -198,9 +198,58 @@ Checklist: - [ ] SSH cloning works after setting up your SSH pub key +## Phoenixd + +phoenixd is the server version of the Phoenix wallet: a self-custodial Lightning node that leans on the ACINQ node for liquidity and channel management. It is the Lightning backend that LNBits talks to, so deploy it first. + +It is not exposed to the internet. Its HTTP API is bound to `127.0.0.1` and only local consumers (LNBits, running on the same host) use it. There is no subdomain and no Caddy config for it. + +### Deploy + +* Review `ansible/services/phoenixd/phoenixd_vars.yml`. The things worth a look: + * `phoenixd_version`: pinned release tag. + * `phoenixd_auto_liquidity`: how much inbound liquidity phoenixd buys when it needs some (`off`, `2m`, `5m`, `10m`). This costs sats, it is not free. + * `phoenixd_http_bind_port`: the loopback API port LNBits will point at. +* No new secrets are needed: phoenixd generates its own API password on first start. +* Run the deployment playbook: `ansible-playbook -i inventory.ini services/phoenixd/deploy_phoenixd_playbook.yml`. +* The playbook will: + * Install `phoenixd` and `phoenix-cli` into `/usr/local/bin` + * Create the `phoenix` system user and a `0700` data dir at `/opt/phoenixd/.phoenix` + * Create and start the `phoenixd` systemd service + * Create a health check timer that runs `phoenix-cli getinfo` every minute + * Register a push monitor named `Phoenixd` in Uptime Kuma under the `services` group + +### Back up the seed + +**Do this immediately after the first deploy.** The seed is the only thing that recovers the funds. + +* Run the backup playbook: `ansible-playbook -i inventory.ini services/phoenixd/setup_backup_phoenixd_to_lapy.yml`. +* This gpg encrypts `seed.dat` and `phoenix.conf` to Lapy daily and keeps 14 days. It does not stop the node and it does not back up the channel database on purpose: phoenixd keeps channel state with its peer, and restoring a stale channel db to a live node can force close channels and cost you a penalty. +* Also write the 12 words down offline, once: `sudo cat /opt/phoenixd/.phoenix/seed.dat`. + +### Operate + +* Status: `sudo systemctl status phoenixd`, logs: `sudo journalctl -u phoenixd -f`. +* CLI: `sudo PHOENIX_DATADIR=/opt/phoenixd/.phoenix phoenix-cli getinfo` (also `getbalance`, `listchannels`, `createinvoice`). If you changed `phoenixd_http_bind_port`, add `--http-bind-port ` before the subcommand. +* API password, needed to wire anything up to the node: `sudo grep '^http-password=' /opt/phoenixd/.phoenix/phoenix.conf`. + +### Restoring on a fresh host + +* Deploy phoenixd but stop the service before it generates a new seed, or just drop the file in before the first run: + * `sudo mkdir -p /opt/phoenixd/.phoenix` + * `echo "your twelve words here" | sudo tee /opt/phoenixd/.phoenix/seed.dat` + * `sudo chown -R phoenix:phoenix /opt/phoenixd/.phoenix && sudo chmod 600 /opt/phoenixd/.phoenix/seed.dat` +* Then run the deployment playbook. Never run two nodes on the same seed at once. + +Checklist: +- [ ] `phoenix-cli getinfo` returns a node id +- [ ] The `Phoenixd` monitor in Uptime Kuma is green +- [ ] The seed is backed up to Lapy *and* written down offline + + ## LNBits -LNBits is a Lightning Network wallet and accounts system. +LNBits is a Lightning Network wallet and accounts system. It uses the phoenixd node deployed above as its Lightning backend. ### Deploy @@ -214,6 +263,7 @@ LNBits is a Lightning Network wallet and accounts system. * From that point on, you can configure through the Web UI. * Some advice around specifics of LNbits: * The default setup uses a FakeWallet backend for testing. Configure a real Lightning backend as needed by modifying the `.env` file located or using the superuser UI. + * To use the phoenixd node deployed above, set the backend to `PhoenixdWallet` with endpoint `http://127.0.0.1:9740` and the API password from `/opt/phoenixd/.phoenix/phoenix.conf`. The deployment playbook does not do this for you, since flipping the wallet backend on a live instance is not something to do behind your back. * For security, disable the new users registration. ### Set up backups to Lapy diff --git a/ansible/services/caddy_playbook.yml b/ansible/services/caddy_playbook.yml index 4935424..de98c8f 100644 --- a/ansible/services/caddy_playbook.yml +++ b/ansible/services/caddy_playbook.yml @@ -13,17 +13,34 @@ state: present update_cache: yes + # Caddy's signing subkey carries an expiry date, and Cloudsmith republishes + # the key with a refreshed binding signature before it lapses. Re-fetch on + # every run: pinning the keyring with `creates:` leaves the host stuck on a + # key that eventually expires and breaks `apt update` with an sqv error. + - name: Ensure apt keyrings directory exists + ansible.builtin.file: + path: /etc/apt/keyrings + state: directory + owner: root + group: root + mode: '0755' + - name: Download Caddy GPG armored key ansible.builtin.get_url: url: https://dl.cloudsmith.io/public/caddy/stable/gpg.key - dest: /tmp/caddy-stable-archive-keyring.asc + dest: /etc/apt/keyrings/caddy-stable-archive-keyring.asc mode: '0644' + register: caddy_key_download + + - name: Check for existing Caddy keyring + ansible.builtin.stat: + path: /usr/share/keyrings/caddy-stable-archive-keyring.gpg + register: caddy_keyring - name: Convert ASCII armored key to binary keyring ansible.builtin.command: - cmd: gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg /tmp/caddy-stable-archive-keyring.asc - args: - creates: /usr/share/keyrings/caddy-stable-archive-keyring.gpg + cmd: gpg --batch --yes --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg /etc/apt/keyrings/caddy-stable-archive-keyring.asc + when: caddy_key_download.changed or not caddy_keyring.stat.exists - name: Ensure permissions on keyring file ansible.builtin.file: diff --git a/ansible/services/phoenixd/deploy_phoenixd_playbook.yml b/ansible/services/phoenixd/deploy_phoenixd_playbook.yml new file mode 100644 index 0000000..65ad7c9 --- /dev/null +++ b/ansible/services/phoenixd/deploy_phoenixd_playbook.yml @@ -0,0 +1,535 @@ +--- +# phoenixd Deployment Playbook +# +# Deploys phoenixd (https://phoenix.acinq.co/server), the server version of the +# Phoenix Lightning wallet, on vipy so LNBits can use it as a wallet backend +# over loopback. +# +# What this does: +# 1. Downloads the pinned phoenixd release and installs phoenixd + phoenix-cli +# 2. Creates a dedicated system user and a 0700 data directory +# 3. Creates and enables a systemd service +# 4. Creates a push-monitor health check script + systemd timer +# 5. Registers a push monitor in Uptime Kuma +# +# The HTTP API stays bound to 127.0.0.1 and is NOT proxied by Caddy: phoenixd +# holds funds and its API is protected by a single password. Anything that needs +# it either runs on this host or reaches it over the Tailscale mesh. +# +# ⚠️ After the first run, back up {{ phoenixd_data_dir }}/seed.dat. Losing it +# means losing the funds. See setup_backup_phoenixd_to_lapy.yml. + +- name: Deploy phoenixd on vipy + hosts: vipy + become: yes + vars_files: + - ../../infra_vars.yml + - ../../services_config.yml + - ../../infra_secrets.yml + - ./phoenixd_vars.yml + vars: + uptime_kuma_api_url: "https://{{ subdomains.uptime_kuma }}.{{ root_domain }}" + + tasks: + # =========================================== + # Prerequisites + # =========================================== + - name: Install phoenixd runtime dependencies + apt: + name: + - unzip + - curl + state: present + update_cache: yes + + # =========================================== + # System User and Directories + # =========================================== + - name: Create phoenixd system group + group: + name: "{{ phoenixd_group }}" + system: yes + + - name: Create phoenixd system user + user: + name: "{{ phoenixd_user }}" + group: "{{ phoenixd_group }}" + system: yes + shell: /usr/sbin/nologin + home: "{{ phoenixd_home }}" + create_home: yes + comment: "phoenixd Lightning node" + + - name: Create phoenixd home directory + file: + path: "{{ phoenixd_home }}" + state: directory + owner: "{{ phoenixd_user }}" + group: "{{ phoenixd_group }}" + mode: "0750" + + - name: Create phoenixd data directory + file: + path: "{{ phoenixd_data_dir }}" + state: directory + owner: "{{ phoenixd_user }}" + group: "{{ phoenixd_group }}" + mode: "0700" + + # =========================================== + # Download and Install + # =========================================== + - name: Check if phoenixd is already installed + stat: + path: "{{ phoenixd_bin_dir }}/phoenixd" + register: phoenixd_binary + + - name: Check installed phoenixd version + command: "{{ phoenixd_bin_dir }}/phoenixd --version" + register: phoenixd_installed_version + changed_when: false + failed_when: false + when: phoenixd_binary.stat.exists + + - name: Decide whether phoenixd needs installing + set_fact: + phoenixd_needs_install: >- + {{ not phoenixd_binary.stat.exists + or phoenixd_version not in (phoenixd_installed_version.stdout | default('')) }} + + - name: Download phoenixd {{ phoenixd_version }} + get_url: + url: "{{ phoenixd_url }}" + dest: "/tmp/phoenixd-{{ phoenixd_version }}.zip" + mode: "0644" + when: phoenixd_needs_install | bool + + - name: Create temporary extraction directory + file: + path: /tmp/phoenixd-extract + state: directory + mode: "0755" + when: phoenixd_needs_install | bool + + - name: Extract phoenixd archive + unarchive: + src: "/tmp/phoenixd-{{ phoenixd_version }}.zip" + dest: /tmp/phoenixd-extract + remote_src: yes + when: phoenixd_needs_install | bool + + - name: Locate extracted binaries + find: + paths: /tmp/phoenixd-extract + patterns: "{{ item }}" + recurse: yes + file_type: file + register: phoenixd_extracted + loop: + - phoenixd + - phoenix-cli + when: phoenixd_needs_install | bool + + - name: Fail if the archive did not contain the expected binaries + assert: + that: + - item.files | length > 0 + fail_msg: "Could not find '{{ item.item }}' in the phoenixd {{ phoenixd_version }} archive" + loop: "{{ phoenixd_extracted.results }}" + loop_control: + label: "{{ item.item }}" + when: phoenixd_needs_install | bool + + - name: Install phoenixd and phoenix-cli binaries + copy: + src: "{{ item.files[0].path }}" + dest: "{{ phoenixd_bin_dir }}/{{ item.item }}" + remote_src: yes + owner: root + group: root + mode: "0755" + loop: "{{ phoenixd_extracted.results }}" + loop_control: + label: "{{ item.item }}" + when: phoenixd_needs_install | bool + notify: Restart phoenixd + + - name: Clean up phoenixd download artifacts + file: + path: "{{ item }}" + state: absent + loop: + - "/tmp/phoenixd-{{ phoenixd_version }}.zip" + - /tmp/phoenixd-extract + + # =========================================== + # Systemd Service + # =========================================== + - name: Build phoenixd command line arguments + set_fact: + phoenixd_args: >- + {{ (['--agree-to-terms-of-service'] if phoenixd_agree_tos else []) + + ['--chain', phoenixd_chain] + + ['--auto-liquidity', phoenixd_auto_liquidity] + + ['--http-bind-ip', phoenixd_http_bind_ip] + + ['--http-bind-port', phoenixd_http_bind_port | string] + + (['--max-mining-fee', phoenixd_max_mining_fee | string] if phoenixd_max_mining_fee else []) + + (['--webhook', phoenixd_webhook_url] if phoenixd_webhook_url else []) + + ['--silent'] }} + + - name: Create phoenixd systemd service + copy: + dest: /etc/systemd/system/phoenixd.service + content: | + [Unit] + Description=phoenixd - Lightning Network Node + Documentation=https://phoenix.acinq.co/server + After=network-online.target + Wants=network-online.target + + [Service] + Type=simple + User={{ phoenixd_user }} + Group={{ phoenixd_group }} + WorkingDirectory={{ phoenixd_home }} + Environment=PHOENIX_DATADIR={{ phoenixd_data_dir }} + ExecStart={{ phoenixd_bin_dir }}/phoenixd {{ phoenixd_args | join(' ') }} + Restart=always + RestartSec=30 + TimeoutStartSec=120 + TimeoutStopSec=120 + StandardOutput=journal + StandardError=journal + + # Hardening: the node only ever writes to its own data directory + NoNewPrivileges=true + PrivateTmp=true + ProtectSystem=strict + ProtectHome=read-only + ReadWritePaths={{ phoenixd_data_dir }} + + LimitNOFILE=65535 + + [Install] + WantedBy=multi-user.target + owner: root + group: root + mode: "0644" + notify: Restart phoenixd + + - name: Reload systemd daemon + systemd: + daemon_reload: yes + + - name: Enable and start phoenixd + systemd: + name: phoenixd + enabled: yes + state: started + + - name: Flush handlers so phoenixd is running before we inspect its data dir + meta: flush_handlers + + # =========================================== + # First Boot Checks + # =========================================== + - name: Wait for phoenixd to write its config file + wait_for: + path: "{{ phoenixd_data_dir }}/phoenix.conf" + state: present + timeout: 120 + + - name: Check that the seed file exists + stat: + path: "{{ phoenixd_data_dir }}/seed.dat" + register: phoenixd_seed_file + + - name: Fail if phoenixd did not create a seed + assert: + that: + - phoenixd_seed_file.stat.exists + fail_msg: "phoenixd started but {{ phoenixd_data_dir }}/seed.dat is missing - check 'journalctl -u phoenixd'" + + # =========================================== + # Health Check Script + Systemd Timer + # =========================================== + - name: Create phoenixd health check script + copy: + dest: "{{ phoenixd_healthcheck_script_path }}" + content: | + #!/bin/bash + # Checks phoenixd and pushes the result to Uptime Kuma. + UPTIME_KUMA_PUSH_URL="${UPTIME_KUMA_PUSH_URL}" + export PHOENIX_DATADIR="{{ phoenixd_data_dir }}" + + check_phoenixd() { + # Service must be active and the node must answer getinfo. + # phoenix-cli reads the api password from $PHOENIX_DATADIR/phoenix.conf, + # but not the bind address, so pass it explicitly. + systemctl is-active --quiet phoenixd && \ + {{ phoenixd_bin_dir }}/phoenix-cli \ + --http-bind-ip {{ phoenixd_http_bind_ip }} \ + --http-bind-port {{ phoenixd_http_bind_port }} \ + getinfo 2>/dev/null | grep -q '"nodeId"' + } + + push_to_uptime_kuma() { + local status=$1 + local msg=$2 + if [ -z "$UPTIME_KUMA_PUSH_URL" ]; then + echo "ERROR: UPTIME_KUMA_PUSH_URL not set" + return 1 + fi + curl -s --max-time 10 --retry 2 -o /dev/null \ + "${UPTIME_KUMA_PUSH_URL}?status=${status}&msg=${msg// /%20}&ping=" || true + } + + if check_phoenixd; then + push_to_uptime_kuma "up" "OK" + exit 0 + else + push_to_uptime_kuma "down" "phoenixd not responding" + exit 1 + fi + owner: root + group: root + mode: "0755" + + - name: Create phoenixd health check systemd service + copy: + dest: "/etc/systemd/system/{{ phoenixd_healthcheck_service_name }}.service" + content: | + [Unit] + Description=phoenixd Health Check + After=network.target phoenixd.service + + [Service] + Type=oneshot + User=root + ExecStart={{ phoenixd_healthcheck_script_path }} + Environment=UPTIME_KUMA_PUSH_URL= + StandardOutput=journal + StandardError=journal + + [Install] + WantedBy=multi-user.target + owner: root + group: root + mode: "0644" + + - name: Create phoenixd health check systemd timer + copy: + dest: "/etc/systemd/system/{{ phoenixd_healthcheck_service_name }}.timer" + content: | + [Unit] + Description=phoenixd Health Check Timer + + [Timer] + OnBootSec=2min + OnUnitActiveSec=1min + Persistent=true + + [Install] + WantedBy=timers.target + owner: root + group: root + mode: "0644" + + - name: Reload systemd daemon after health check units + systemd: + daemon_reload: yes + + - name: Enable and start phoenixd health check timer + systemd: + name: "{{ phoenixd_healthcheck_service_name }}.timer" + enabled: yes + state: started + + # =========================================== + # Uptime Kuma Push Monitor Setup + # =========================================== + - name: Create Uptime Kuma push monitor setup script for phoenixd + delegate_to: localhost + become: no + copy: + dest: /tmp/setup_phoenixd_monitor.py + content: | + #!/usr/bin/env python3 + import sys + import time + import traceback + import yaml + + from uptime_kuma_api import UptimeKumaApi, MonitorType + + try: + with open('/tmp/ansible_phoenixd_config.yml', 'r') as f: + config = yaml.safe_load(f) + + url = config['uptime_kuma_url'] + username = config['username'] + password = config['password'] + monitor_name = config['monitor_name'] + + api = UptimeKumaApi(url, timeout=30) + api.login(username, password) + + monitors = api.get_monitors() + + # Find or create "services" group + group = next((m for m in monitors if m.get('name') == 'services' and m.get('type') == 'group'), None) + if not group: + try: + api.add_monitor(type='group', name='services') + except Exception: + time.sleep(2) + monitors = api.get_monitors() + group = next((m for m in monitors if m.get('name') == 'services' and m.get('type') == 'group'), None) + + # Get ntfy notification ID + notifications = api.get_notifications() + ntfy_notification_id = None + for notif in notifications: + if notif.get('type') == 'ntfy': + ntfy_notification_id = notif.get('id') + break + + existing = next((m for m in monitors if m.get('name') == monitor_name), None) + + push_url = None + + if existing: + print(f"Monitor '{monitor_name}' already exists (ID: {existing['id']})") + push_token = existing.get('pushToken') or existing.get('push_token') + if push_token: + push_url = f"{url}/api/push/{push_token}" + else: + print(f"Creating push monitor '{monitor_name}'...") + try: + api.add_monitor( + type=MonitorType.PUSH, + name=monitor_name, + parent=group['id'], + interval=90, + maxretries=3, + retryInterval=60, + notificationIDList={ntfy_notification_id: True} if ntfy_notification_id else {} + ) + except Exception as e: + # socketio timeout: add_monitor may have succeeded server-side + print(f"add_monitor raised (possibly timeout): {e}", file=sys.stderr) + time.sleep(2) + + monitors = api.get_monitors() + new_monitor = next((m for m in monitors if m.get('name') == monitor_name), None) + if new_monitor: + push_token = new_monitor.get('pushToken') or new_monitor.get('push_token') + if push_token: + push_url = f"{url}/api/push/{push_token}" + + api.disconnect() + + if push_url: + print(f"PUSH_URL={push_url}") + with open('/tmp/phoenixd_push_url.txt', 'w') as f: + f.write(push_url) + + print("SUCCESS") + + except Exception as e: + print(f"ERROR: {str(e)}", file=sys.stderr) + traceback.print_exc(file=sys.stderr) + sys.exit(1) + mode: "0755" + + - name: Create temporary config for push monitor setup + delegate_to: localhost + become: no + copy: + dest: /tmp/ansible_phoenixd_config.yml + content: | + uptime_kuma_url: "{{ uptime_kuma_api_url }}" + username: "{{ uptime_kuma_username }}" + password: "{{ uptime_kuma_password }}" + monitor_name: "{{ phoenixd_monitor_name }}" + mode: "0644" + + - name: Run Uptime Kuma push monitor setup + command: python3 /tmp/setup_phoenixd_monitor.py + delegate_to: localhost + become: no + register: monitor_setup + changed_when: "'SUCCESS' in monitor_setup.stdout" + ignore_errors: yes + + - name: Display monitor setup output + debug: + msg: "{{ monitor_setup.stdout_lines }}" + when: monitor_setup.stdout is defined + + - name: Read push URL from file + slurp: + src: /tmp/phoenixd_push_url.txt + delegate_to: localhost + become: no + register: push_url_file + ignore_errors: yes + + - name: Parse push URL + set_fact: + phoenixd_push_url: "{{ push_url_file.content | b64decode | trim }}" + when: push_url_file.content is defined + + - name: Update health check service with push URL + lineinfile: + path: "/etc/systemd/system/{{ phoenixd_healthcheck_service_name }}.service" + regexp: "^Environment=UPTIME_KUMA_PUSH_URL=" + line: "Environment=UPTIME_KUMA_PUSH_URL={{ phoenixd_push_url }}" + when: phoenixd_push_url is defined + notify: Restart phoenixd health check timer + + - name: Clean up temporary files + delegate_to: localhost + become: no + file: + path: "{{ item }}" + state: absent + loop: + - /tmp/setup_phoenixd_monitor.py + - /tmp/ansible_phoenixd_config.yml + - /tmp/phoenixd_push_url.txt + + # =========================================== + # Post-install Notes + # =========================================== + - name: Display post-install information + debug: + msg: | + ✓ phoenixd {{ phoenixd_version }} deployed + + Status: systemctl status phoenixd + Logs: journalctl -u phoenixd -f + CLI: sudo PHOENIX_DATADIR={{ phoenixd_data_dir }} phoenix-cli --http-bind-port {{ phoenixd_http_bind_port }} getinfo + HTTP API: http://{{ phoenixd_http_bind_ip }}:{{ phoenixd_http_bind_port }} (loopback only) + Data dir: {{ phoenixd_data_dir }} + + API password (needed to wire LNBits up to this node): + sudo grep '^http-password=' {{ phoenixd_data_dir }}/phoenix.conf + + ⚠️ BACK UP THE SEED NOW: {{ phoenixd_data_dir }}/seed.dat + Losing it means losing the funds. Run + services/phoenixd/setup_backup_phoenixd_to_lapy.yml and also keep + the 12 words somewhere offline. + + handlers: + - name: Restart phoenixd + systemd: + name: phoenixd + state: restarted + daemon_reload: yes + + - name: Restart phoenixd health check timer + systemd: + name: "{{ phoenixd_healthcheck_service_name }}.timer" + state: restarted + daemon_reload: yes diff --git a/ansible/services/phoenixd/phoenixd_vars.yml b/ansible/services/phoenixd/phoenixd_vars.yml new file mode 100644 index 0000000..57218dc --- /dev/null +++ b/ansible/services/phoenixd/phoenixd_vars.yml @@ -0,0 +1,52 @@ +# phoenixd Configuration Variables +# https://phoenix.acinq.co/server + +# Version - pin to a specific release tag +phoenixd_version: "0.9.0" +phoenixd_arch: "linux-x64" +phoenixd_url: "https://github.com/ACINQ/phoenixd/releases/download/v{{ phoenixd_version }}/phoenixd-{{ phoenixd_version }}-{{ phoenixd_arch }}.zip" + +# Service user +phoenixd_user: phoenix +phoenixd_group: phoenix + +# Directories +phoenixd_home: /opt/phoenixd +# phoenixd resolves its data dir from PHOENIX_DATADIR; seed.dat, phoenix.conf +# and the channel db all live here. +phoenixd_data_dir: "{{ phoenixd_home }}/.phoenix" +phoenixd_bin_dir: /usr/local/bin + +# Lightning settings +phoenixd_chain: mainnet +# Amount automatically requested when inbound liquidity is needed: off, 2m, 5m, 10m +phoenixd_auto_liquidity: "2m" +# Max mining fee for on-chain operations, in sats. Empty means phoenixd's default. +phoenixd_max_mining_fee: "" +# Required for non-interactive deployment +phoenixd_agree_tos: true + +# HTTP API +# Stays on loopback: phoenixd is never exposed through Caddy, only local +# consumers (e.g. LNBits on the same host) talk to it. +phoenixd_http_bind_ip: "127.0.0.1" +phoenixd_http_bind_port: 9740 + +# Optional webhook for payment events. Leave empty to disable. +phoenixd_webhook_url: "" + +# Monitoring +phoenixd_healthcheck_script_path: /usr/local/bin/phoenixd-healthcheck-push.sh +phoenixd_healthcheck_service_name: phoenixd-healthcheck +phoenixd_monitor_name: "Phoenixd" + +# Remote access +remote_host_name: "vipy" +remote_host: "{{ hostvars.get(remote_host_name, {}).get('ansible_host', remote_host_name) }}" +remote_user: "{{ hostvars.get(remote_host_name, {}).get('ansible_user', 'counterweight') }}" +remote_key_file: "{{ hostvars.get(remote_host_name, {}).get('ansible_ssh_private_key_file', '') }}" +remote_port: "{{ hostvars.get(remote_host_name, {}).get('ansible_port', 22) }}" + +# Local backup +local_backup_dir: "{{ lookup('env', 'HOME') }}/phoenixd-backups" +backup_script_path: "{{ lookup('env', 'HOME') }}/.local/bin/phoenixd_backup.sh" diff --git a/ansible/services/phoenixd/setup_backup_phoenixd_to_lapy.yml b/ansible/services/phoenixd/setup_backup_phoenixd_to_lapy.yml new file mode 100644 index 0000000..cc008e6 --- /dev/null +++ b/ansible/services/phoenixd/setup_backup_phoenixd_to_lapy.yml @@ -0,0 +1,136 @@ +--- +# Backs up the phoenixd seed and config from vipy to Lapy, gpg encrypted. +# +# Only seed.dat and phoenix.conf are backed up, on purpose: +# - seed.dat is what actually recovers the funds. phoenixd keeps its channel +# state with the ACINQ peer, so a wallet is restored from the seed alone. +# - restoring a *stale* channel database to a running Lightning node is +# dangerous (it can trigger a force close and a penalty), so we do not keep +# copies of phoenix.db around to be tempted by. +# - phoenix.conf holds the http api password, which is what LNBits and any +# other local consumer authenticate with. +# +# Because both files are static, phoenixd does not need to be stopped. + +- name: Configure local backup for phoenixd from remote + hosts: lapy + gather_facts: no + vars_files: + - ../../infra_vars.yml + - ./phoenixd_vars.yml + vars: + remote_data_path: "{{ phoenixd_data_dir }}" + gpg_recipient: "{{ hostvars['localhost']['gpg_recipient'] | default('') }}" + gpg_key_id: "{{ hostvars['localhost']['gpg_key_id'] | default('') }}" + + tasks: + - name: Debug phoenixd backup vars + debug: + msg: + - "remote_host={{ remote_host }}" + - "remote_user={{ remote_user }}" + - "remote_data_path='{{ remote_data_path }}'" + - "local_backup_dir={{ local_backup_dir }}" + - "gpg_recipient={{ gpg_recipient }}" + - "gpg_key_id={{ gpg_key_id }}" + + - name: Ensure local backup directory exists + ansible.builtin.file: + path: "{{ local_backup_dir }}" + state: directory + mode: "0700" + + - name: Ensure ~/.local/bin exists + ansible.builtin.file: + path: "{{ lookup('env', 'HOME') }}/.local/bin" + state: directory + mode: "0755" + + - name: Create phoenixd backup script + ansible.builtin.copy: + dest: "{{ backup_script_path }}" + mode: "0750" + content: | + #!/bin/bash + set -euo pipefail + + if [ -z "{{ gpg_recipient }}" ]; then + echo "GPG recipient is not configured. Aborting." + exit 1 + fi + + TIMESTAMP=$(date +'%Y-%m-%d') + ENCRYPTED_BACKUP="{{ local_backup_dir }}/phoenixd-backup-$TIMESTAMP.tar.gz.gpg" + + {% if remote_key_file %} + SSH_CMD="ssh -i {{ remote_key_file }} -p {{ remote_port }}" + {% else %} + SSH_CMD="ssh -p {{ remote_port }}" + {% endif %} + + # seed.dat + phoenix.conf only, see the header of the playbook. + echo "Creating encrypted backup archive..." + $SSH_CMD {{ remote_user }}@{{ remote_host }} \ + "sudo tar -czf - -C {{ remote_data_path }} seed.dat phoenix.conf" | \ + gpg --batch --yes --encrypt --recipient "{{ gpg_recipient }}" --output "$ENCRYPTED_BACKUP" + + chmod 600 "$ENCRYPTED_BACKUP" + + # Rotate old backups (keep 14 days) + CUTOFF_DATE=$(date -d '14 days ago' +'%Y-%m-%d') + for backup_file in "{{ local_backup_dir }}"/phoenixd-backup-*.tar.gz.gpg; do + if [ -f "$backup_file" ]; then + # Extract date from filename: phoenixd-backup-YYYY-MM-DD.tar.gz.gpg + file_date=$(basename "$backup_file" | sed -n 's/phoenixd-backup-\([0-9]\{4\}-[0-9]\{2\}-[0-9]\{2\}\)\.tar\.gz\.gpg/\1/p') + if [ -n "$file_date" ] && [ "$file_date" != "$TIMESTAMP" ] && [ "$file_date" \< "$CUTOFF_DATE" ]; then + rm -f "$backup_file" + fi + fi + done + + echo "Backup completed successfully" + + - name: Ensure cronjob for phoenixd backup exists + ansible.builtin.cron: + name: "phoenixd backup" + user: "{{ lookup('env', 'USER') }}" + job: "{{ backup_script_path }}" + minute: 15 + hour: "9" + + - name: Run phoenixd backup script to create initial backup + ansible.builtin.command: "{{ backup_script_path }}" + + - name: Verify backup was created + block: + - name: Get today's date + command: date +'%Y-%m-%d' + register: today_date + changed_when: false + + - name: Check if backup file exists + stat: + path: "{{ local_backup_dir }}/phoenixd-backup-{{ today_date.stdout }}.tar.gz.gpg" + register: backup_file_stat + + - name: Verify backup file exists + assert: + that: + - backup_file_stat.stat.exists + - backup_file_stat.stat.isreg + fail_msg: "Backup file {{ local_backup_dir }}/phoenixd-backup-{{ today_date.stdout }}.tar.gz.gpg was not created" + success_msg: "Backup file {{ local_backup_dir }}/phoenixd-backup-{{ today_date.stdout }}.tar.gz.gpg exists" + + - name: Verify backup file is not empty + assert: + that: + - backup_file_stat.stat.size > 0 + fail_msg: "Backup file {{ local_backup_dir }}/phoenixd-backup-{{ today_date.stdout }}.tar.gz.gpg exists but is empty" + success_msg: "Backup file size is {{ backup_file_stat.stat.size }} bytes" + + - name: Remind about the offline seed copy + debug: + msg: | + These encrypted backups are only as safe as your GPG key. + Also write the 12 words down offline once: + ssh {{ remote_user }}@{{ remote_host }} "sudo cat {{ remote_data_path }}/seed.dat"