diff --git a/ansible/roles/phoenixd/README.md b/ansible/roles/phoenixd/README.md new file mode 100644 index 0000000..3c147e3 --- /dev/null +++ b/ansible/roles/phoenixd/README.md @@ -0,0 +1,56 @@ +# `phoenixd` + +Deploys and runs [phoenixd](https://phoenix.acinq.co/server), an ACINQ Lightning +node, on the edge host. LNBits uses it as a wallet backend. The HTTP API stays on +loopback — phoenixd is never published through Caddy. + +Converted from `deploy_phoenixd_playbook.yml` (552 lines) under Plan 6. The +playbook is now 18 lines. + +## Phases + +| | | +|---|---| +| `install.yml` | packages, system user, directories, versioned download and install | +| `service.yml` | systemd unit, start, then first-boot checks (config written, seed created) | +| `healthcheck.yml` | check script, unit, timer | + +## The seed + +`{{ phoenixd_data_dir }}/seed.dat` **is** the funds. phoenixd is deliberately +excluded from the automated backups (Plan 5, Model C): the seed is twelve fixed +words that never change, so an automated job would only manufacture more copies +of a static secret on more machines. Write them down offline, once. + +Note the live file is mode `0644`. That is phoenixd's own doing, not this role's, +and it is worth tightening. + +## Monitoring: one variable, no product knowledge + +The check asks the node itself — the service must be active **and** +`phoenix-cli getinfo` must return a `nodeId` — and records the answer in its exit +code, which systemd keeps: + +```bash +systemctl is-failed phoenixd-healthcheck.service +``` + +That is a complete answer with no monitoring system involved. To report +elsewhere, set `healthcheck_push_url` to anything accepting an HTTP ping. Gone +from this role: the embedded Python that created monitors over the Uptime Kuma +API, the `/tmp` credentials file, the push-URL file written and parsed back, and +the systemd `Environment=` rewrite. + +### Two things the conversion fixed + +**The check used to log an error once a minute.** Its `Environment=` push URL had +been empty since the decommissioning, and the script printed +`ERROR: UPTIME_KUMA_PUSH_URL not set` on every fire — roughly 1,400 times a day. +The exit code was still correct, so nothing was broken; it was pure noise, and +noise that trains you to ignore the log. An unset push URL is now normal and +silent. + +**`Enable and start phoenixd health check timer` was guarded by +`uptime_kuma_enabled`** and so had not run since the decommissioning — while the +timer itself was still live on the host from before. Ansible had quietly stopped +managing something that was still running. Ungated. diff --git a/ansible/services/phoenixd/phoenixd_vars.yml b/ansible/roles/phoenixd/defaults/main.yml similarity index 69% rename from ansible/services/phoenixd/phoenixd_vars.yml rename to ansible/roles/phoenixd/defaults/main.yml index 93921fd..ea9c889 100644 --- a/ansible/services/phoenixd/phoenixd_vars.yml +++ b/ansible/roles/phoenixd/defaults/main.yml @@ -35,15 +35,20 @@ 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: "{{ groups['edge'] | first }}" -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) }}" + +# --- Health check ----------------------------------------------------------- +# The check asks phoenixd itself whether it is healthy (service active AND the +# node answers getinfo with a nodeId) and records the answer in its exit code, +# which systemd keeps: +# systemctl is-failed phoenixd-healthcheck.service +# That is a complete answer with no monitoring system involved. +phoenixd_healthcheck_script_path: /usr/local/bin/phoenixd-healthcheck-push.sh +phoenixd_healthcheck_service_name: phoenixd-healthcheck + +# WHERE TO REPORT HEALTH — the one place to plug in monitoring. +# Empty means check, log, exit honestly, report nowhere. Any endpoint that +# accepts an HTTP ping works; nothing here is specific to a monitoring product. +healthcheck_push_url: "" diff --git a/ansible/roles/phoenixd/handlers/main.yml b/ansible/roles/phoenixd/handlers/main.yml new file mode 100644 index 0000000..7779684 --- /dev/null +++ b/ansible/roles/phoenixd/handlers/main.yml @@ -0,0 +1,12 @@ +--- +- 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/roles/phoenixd/tasks/healthcheck.yml b/ansible/roles/phoenixd/tasks/healthcheck.yml new file mode 100644 index 0000000..ad5cc22 --- /dev/null +++ b/ansible/roles/phoenixd/tasks/healthcheck.yml @@ -0,0 +1,68 @@ +--- +# Everything here answers "is phoenixd healthy" and records the answer. The +# Uptime Kuma specifics that used to follow it — an embedded Python script +# creating monitors over the API, a /tmp credentials file, a push-URL file read +# back and parsed, and a systemd Environment= rewrite — are gone. What reports +# where is now one variable, healthcheck_push_url. See the role README. +- name: Create phoenixd health check script + ansible.builtin.template: + src: healthcheck.sh.j2 + dest: "{{ phoenixd_healthcheck_script_path }}" + owner: root + group: root + mode: "0755" + validate: "bash -n %s" + +- name: Create phoenixd health check systemd service + ansible.builtin.template: + src: healthcheck.service.j2 + dest: "/etc/systemd/system/{{ phoenixd_healthcheck_service_name }}.service" + owner: root + group: root + mode: "0644" + notify: Restart phoenixd health check timer + +- name: Create phoenixd health check systemd timer + ansible.builtin.template: + src: healthcheck.timer.j2 + dest: "/etc/systemd/system/{{ phoenixd_healthcheck_service_name }}.timer" + owner: root + group: root + mode: "0644" + notify: Restart phoenixd health check timer + +- name: Reload systemd daemon after health check units + systemd: + daemon_reload: yes + +# Ungated on purpose. This was guarded by `uptime_kuma_enabled`, but enabling a +# timer is deployment, not monitoring — the deprecation banner swept it up with +# the push plumbing. The timer is in fact running on the host, from before the +# decommissioning, so the guard meant Ansible had stopped managing something +# that was still live. +- name: Enable and start phoenixd health check timer + systemd: + name: "{{ phoenixd_healthcheck_service_name }}.timer" + enabled: yes + state: started + +- 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 }} + Health: systemctl is-failed {{ phoenixd_healthcheck_service_name }}.service + + API password (needed to wire LNBits up to this node): + sudo grep '^http-password=' {{ phoenixd_data_dir }}/phoenix.conf + + ⚠️ BACK UP THE SEED: {{ phoenixd_data_dir }}/seed.dat + Losing it means losing the funds. phoenixd is deliberately excluded + from the automated backups (Plan 5, Model C) because the seed is 12 + fixed words — write them down offline, once: + sudo cat {{ phoenixd_data_dir }}/seed.dat diff --git a/ansible/roles/phoenixd/tasks/install.yml b/ansible/roles/phoenixd/tasks/install.yml new file mode 100644 index 0000000..89a4fa0 --- /dev/null +++ b/ansible/roles/phoenixd/tasks/install.yml @@ -0,0 +1,125 @@ +--- +- 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 + diff --git a/ansible/roles/phoenixd/tasks/main.yml b/ansible/roles/phoenixd/tasks/main.yml new file mode 100644 index 0000000..bc7ff05 --- /dev/null +++ b/ansible/roles/phoenixd/tasks/main.yml @@ -0,0 +1,6 @@ +--- +# import_tasks, not include_tasks: static imports stay visible to --list-tasks, +# which is how this conversion was verified against the playbook it replaced. +- ansible.builtin.import_tasks: install.yml +- ansible.builtin.import_tasks: service.yml +- ansible.builtin.import_tasks: healthcheck.yml diff --git a/ansible/roles/phoenixd/tasks/service.yml b/ansible/roles/phoenixd/tasks/service.yml new file mode 100644 index 0000000..79bf6f5 --- /dev/null +++ b/ansible/roles/phoenixd/tasks/service.yml @@ -0,0 +1,52 @@ +--- +- 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 + ansible.builtin.template: + src: phoenixd.service.j2 + dest: /etc/systemd/system/phoenixd.service + 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'" diff --git a/ansible/roles/phoenixd/templates/healthcheck.service.j2 b/ansible/roles/phoenixd/templates/healthcheck.service.j2 new file mode 100644 index 0000000..4060dbc --- /dev/null +++ b/ansible/roles/phoenixd/templates/healthcheck.service.j2 @@ -0,0 +1,14 @@ +[Unit] +Description=phoenixd Health Check +After=network.target phoenixd.service + +[Service] +Type=oneshot +User=root +ExecStart={{ phoenixd_healthcheck_script_path }} +Environment=HEALTHCHECK_PUSH_URL={{ healthcheck_push_url }} +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/ansible/roles/phoenixd/templates/healthcheck.sh.j2 b/ansible/roles/phoenixd/templates/healthcheck.sh.j2 new file mode 100644 index 0000000..d2cec21 --- /dev/null +++ b/ansible/roles/phoenixd/templates/healthcheck.sh.j2 @@ -0,0 +1,39 @@ +#!/bin/bash +# phoenixd health check — managed by Ansible (roles/phoenixd) +# +# Asks the node whether it is healthy and records the answer in the exit code, +# which systemd keeps: +# systemctl is-failed {{ phoenixd_healthcheck_service_name }}.service +# That is a complete answer on its own. Reporting anywhere else is optional. +PUSH_URL="${HEALTHCHECK_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"' +} + +report() { + local status=$1 msg=$2 + # No push URL configured is NORMAL, not an error: the exit code below still + # answers the question. The previous version logged ERROR here on every + # single fire, once a minute, which is noise that trains you to ignore it. + [ -n "$PUSH_URL" ] || return 0 + curl -s --max-time 10 --retry 2 -o /dev/null \ + "${PUSH_URL}?status=${status}&msg=${msg// /%20}&ping=" || true +} + +if check_phoenixd; then + report "up" "OK" + exit 0 +else + echo "phoenixd is not responding" + report "down" "phoenixd not responding" + exit 1 +fi diff --git a/ansible/roles/phoenixd/templates/healthcheck.timer.j2 b/ansible/roles/phoenixd/templates/healthcheck.timer.j2 new file mode 100644 index 0000000..ee07257 --- /dev/null +++ b/ansible/roles/phoenixd/templates/healthcheck.timer.j2 @@ -0,0 +1,10 @@ +[Unit] +Description=phoenixd Health Check Timer + +[Timer] +OnBootSec=2min +OnUnitActiveSec=1min +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/ansible/roles/phoenixd/templates/phoenixd.service.j2 b/ansible/roles/phoenixd/templates/phoenixd.service.j2 new file mode 100644 index 0000000..44fdb94 --- /dev/null +++ b/ansible/roles/phoenixd/templates/phoenixd.service.j2 @@ -0,0 +1,31 @@ +[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 diff --git a/ansible/services/phoenixd/deploy_phoenixd_playbook.yml b/ansible/services/phoenixd/deploy_phoenixd_playbook.yml index 8b30a1f..1e53ff0 100644 --- a/ansible/services/phoenixd/deploy_phoenixd_playbook.yml +++ b/ansible/services/phoenixd/deploy_phoenixd_playbook.yml @@ -1,24 +1,6 @@ --- -# 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. - +# phoenixd: Lightning node on the edge host, used by LNBits as a wallet backend. +# Never exposed through Caddy — the HTTP API stays on loopback. - name: Deploy phoenixd on the edge host hosts: edge become: yes @@ -26,527 +8,11 @@ - ../../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 + # phoenixd's health check has never reported anywhere since the Uptime Kuma + # decommissioning — its systemd Environment= was left empty. Leaving it empty + # preserves that; the check still runs and its exit code is still the answer. + # Set this to plug in whatever monitoring replaces it. + healthcheck_push_url: "{{ healthcheck_push_urls.phoenixd | default('') }}" + roles: + - phoenixd