All five close-out greps return nothing: no sites-enabled handling outside roles/, no `systemctl reload caddy`, no caddy_sites_dir self-reference, no inline proxy unit writes. 37 playbooks syntax clean. The 14 Caddy site files and 6 proxy units on the hosts are byte-identical to the Stage 0 baseline. Seven play names still said "on vipy" while the play targeted a group. Renamed to "on the edge host" - the last place a play claimed a hostname after Plan 2. Documented the four vhosts in /etc/caddy/sites-enabled that no playbook writes (uptime-kuma, arbretstaging, bitcoininfra, scriberr) in the caddy_site README. None deleted. uptime-kuma.conf was going to be deleted as dead config. It is not dead: the louislam/uptime-kuma container is STILL RUNNING on watchtower - created 2026-02-07, restart=unless-stopped, healthy - and uptime.contrapeso.xyz returns 302, not the 502 a dead backend would give. The "decommissioning" retired the Ansible code and the vault credentials, not the service. PLAN_3 claimed "the tokens died with the server"; that is corrected there. The Caddyfile.* backups are kept: one per host, Nov-Dec 2025, not churning, and the only record of each Caddyfile before the import line was added. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
552 lines
19 KiB
YAML
552 lines
19 KiB
YAML
---
|
|
# 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 the edge host
|
|
hosts: edge
|
|
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
|
|
# ===========================================
|
|
# ═════════════════════════════════════════════════════════════════════════
|
|
# DEPRECATED — Uptime Kuma was decommissioned on 2026-09-11.
|
|
#
|
|
# Every task below is inert: uptime_kuma_enabled is false in
|
|
# group_vars/all/main.yml, so they all skip and the deployment above still
|
|
# runs normally. Kept because the health-check logic is the durable part —
|
|
# when a replacement exists, rewire the push transport and flip the flag.
|
|
#
|
|
# What was being monitored: archive/uptime_kuma/MONITORS.md
|
|
# ═════════════════════════════════════════════════════════════════════════
|
|
- name: Create phoenixd health check script
|
|
when: uptime_kuma_enabled | default(false)
|
|
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
|
|
when: uptime_kuma_enabled | default(false)
|
|
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
|
|
when: uptime_kuma_enabled | default(false)
|
|
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
|
|
when: uptime_kuma_enabled | default(false)
|
|
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
|
|
when: uptime_kuma_enabled | default(false)
|
|
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
|
|
when: uptime_kuma_enabled | default(false)
|
|
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
|
|
when: uptime_kuma_enabled | default(false)
|
|
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
|