phoenixd upgrade

This commit is contained in:
counterweight 2026-08-08 15:19:11 +02:00
parent ba4ba504d0
commit d0bfc2650d
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
5 changed files with 795 additions and 5 deletions

View file

@ -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 <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

View file

@ -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:

View file

@ -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

View file

@ -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"

View file

@ -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"