Initial VPS operations handbook

This commit is contained in:
windyboy
2026-08-03 12:26:42 +08:00
commit b73125e5bc
97 changed files with 3641 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
# Local secrets and service configuration (never version these).
.env
.env.*
!.env.example
mailcow.conf
.smtp-credentials
.admin-token
*.pem
*.key
id_*
!*.pub
# Ansible runtime output.
*.retry
.ansible/
facts/
# Editor and operating-system files.
.DS_Store
.vscode/
.idea/
*~
+104
View File
@@ -0,0 +1,104 @@
# VPS Agent Ops Hub
This repo is the **agent ops handbook + fact source** for maintaining personal VPS hosts. Prefer verifying live state over assuming docs are complete.
Also readable as `agent.md` (symlink → this file).
## How to work
1. Read [`inventory/hosts.md`](inventory/hosts.md) for the machine list.
2. Open the matching [`hosts/<name>.md`](hosts/) for SSH, roles, paths, and quirks.
3. For common tasks, follow a runbook under [`runbooks/`](runbooks/).
4. Prefer read-only checks first; change only after confirming current state.
5. Default access: `ssh -4 windy@<host>` (pubkey). Prefer **IPv4** from this WSL client (AAAA often exists but IPv6 route does not).
## Active hosts (quick map)
| Host | Role | SSH | Facts |
|------|------|-----|--------|
| **mx2.windy.me** | mailcow (`/opt/mail`, project `cow`) | `ssh -4 windy@mx2.windy.me` | [hosts/mx2.windy.me.md](hosts/mx2.windy.me.md) |
| **us2.wsvc.info** | Vaultwarden + Traefik (+ Soft Serve, …) | `ssh -4 windy@us2.wsvc.info` | [hosts/us2.wsvc.info.md](hosts/us2.wsvc.info.md) |
| **hk2.chans.xyz** | PowerDNS auth ns1 (`/opt/pdns`) | `ssh -4 windy@hk2.chans.xyz` | [hosts/hk2.chans.xyz.md](hosts/hk2.chans.xyz.md) |
### Public services
| URL / endpoint | Backend host | Notes |
|----------------|--------------|--------|
| https://mx2.windy.me | mx2 | mailcow UI / SOGo |
| SMTP `mx2.windy.me:587` (STARTTLS) or `:465` | mx2 | client submission; full email + mailbox password — [runbook](runbooks/mailcow-smtp-client.md) |
| IMAP `mx2.windy.me:993` | mx2 | same mailbox credentials |
| https://auth.wsvc.info | us2 (`/opt/vaultwarden`) | Vaultwarden (Postgres, **operational**) — client Server URL |
| `repo.windy.me:2222` | us2 (`/opt/soft-serve`) | Soft Serve (stub details) |
| DNS `ns1.wsvc.info:53` | hk2 (`/opt/pdns`, Auth **5.0.6**) | PowerDNS auth — zones `windy.me`, `wsvc.info`, `chans.xyz` |
| https://pdns.wsvc.info | hk2 (`poweradmin`) | Poweradmin UI |
| https://pgweb.wsvc.info | hk2 (`pgweb`) | PowerDNS Postgres browser |
| https://chat.chans.xyz | synapse (`ess`, ESS chart `26.7.2`) | Element Web (Matrix client) |
| https://synapse.chans.xyz | synapse | Synapse Client-Server + Federation API |
| https://account.chans.xyz | synapse | Matrix Authentication Service (local passwords) |
| https://admin.chans.xyz | synapse | Element Admin console (MAS admin auth) |
### Upstream docs
**Vaultwarden:** Before changing config, read [docs/vaultwarden-upstream.md](docs/vaultwarden-upstream.md). Live DB is **Postgres** (`DATABASE_URL`); `vw-data/config.json` from `/admin` overrides `.env` (including SMTP password — keep it synced with `.env` / `.smtp-credentials`). After recreating the VW container, restart Traefik if `https://auth.wsvc.info` returns 404. SMTP health needs **AUTH**, not TCP-only — see [vaultwarden-health](runbooks/vaultwarden-health.md).
**PowerDNS:** Before changing `/opt/pdns` on hk2, read [docs/pdns-upstream.md](docs/pdns-upstream.md). Auth pin **5.0.6**; primary + `also-notify`/`only-notify=` + DNSSEC OK. Deferred: rotate weak API/DB credentials and TSIG (`TSIG-ALLOW-AXFR` bypasses IP ACL). Health: [pdns-health](runbooks/pdns-health.md).
**Matrix (ESS on synapse):** Matrix homeserver running on `synapse.chans.xyz` via the official ESS (Element Server Suite) Helm chart with Synapse + MAS + Element Web + Admin. DNS zone `chans.xyz` managed by hk2 PowerDNS. Before changing config, read [docs/matrix-upstream.md](docs/matrix-upstream.md) and [hosts/synapse.chans.xyz.md](hosts/synapse.chans.xyz.md). K3s cluster on this node has hostPort 80/443 for Traefik (no ServiceLB). Health: [matrix-health](runbooks/matrix-health.md).
## Runbooks & scripts
| Task | Path |
|------|------|
| mailcow health | [runbooks/mailcow-health.md](runbooks/mailcow-health.md) · `./scripts/check-mx2.sh` |
| mailcow update | [runbooks/mailcow-update.md](runbooks/mailcow-update.md) |
| mailcow SMTP/IMAP client | [runbooks/mailcow-smtp-client.md](runbooks/mailcow-smtp-client.md) |
| Vaultwarden health | [runbooks/vaultwarden-health.md](runbooks/vaultwarden-health.md) |
| Vaultwarden SQLite→PG migrate | [runbooks/vaultwarden-sqlite-to-postgres.md](runbooks/vaultwarden-sqlite-to-postgres.md) |
| Vaultwarden upstream refs | [docs/vaultwarden-upstream.md](docs/vaultwarden-upstream.md) |
| PowerDNS health (hk2) | [runbooks/pdns-health.md](runbooks/pdns-health.md) |
| PowerDNS upstream refs | [docs/pdns-upstream.md](docs/pdns-upstream.md) |
| Matrix health | [runbooks/matrix-health.md](runbooks/matrix-health.md) |
| Matrix upstream refs | [docs/matrix-upstream.md](docs/matrix-upstream.md) |
```bash
./scripts/check-mx2.sh
```
`check-mx2.sh` defaults to IPv4; set `MX2_ALLOW_IPV6=1` to allow IPv6. DNS checks use `1.1.1.1` / `8.8.8.8` (local stub is flaky).
## Safety
- Never commit secrets: passwords, API keys, private keys, `.env`, `mailcow.conf` DB passwords, Vaultwarden `ADMIN_TOKEN` / `.smtp-credentials`.
- Secrets live **only on the servers** (e.g. `/opt/vaultwarden/.env`). Do not copy them from Obsidian into this repo.
- Ask before destructive actions: `docker compose down -v`, DB drops, DNS changes, force-push, disk wipes.
- Do not modify local `~/.ssh/config` or git config unless explicitly asked.
## SSH access safety — mandatory change procedure
**Treat any SSH authentication or authorization change as a lockout-risk change.** This includes `sshd_config` / drop-in edits, `PasswordAuthentication`, `PermitRootLogin`, `AuthorizedKeysFile`, `Match` blocks, SSH keys, accounts, groups, chroots, firewalls, and SSH ports.
Before disabling an existing access method or reloading `sshd`, all of the following are mandatory:
1. Keep the current verified SSH management session open as the rollback path; do not use it to run the validation connection.
2. Install the intended management public key from a **verified local source**. Never assume a public-key file exists on the target host or copy a remote path without checking it first.
3. Validate the candidate configuration with `sshd -t` before reloading the service.
4. From a separate local terminal, prove the new key-only login works using the exact target account, hostname, and key (`BatchMode=yes`, password authentication disabled), and verify needed sudo access.
5. Only after step 4 succeeds may password login, root login, the old key, or another existing access route be disabled.
6. Keep the original management session open until the final effective SSH configuration and a fresh key-only login have both been verified.
If the new key-based login cannot be proven first, stop immediately: do **not** reload an access-restricting configuration. Provider web/serial console access must be confirmed before any SSH access-policy change, but it is a recovery path—not a substitute for the validation steps above.
## Obsidian
Bills, rough notes, and personal clutter stay in the Obsidian vault. This repo holds **actionable facts and runbooks** only.
## Layout
```
AGENTS.md / agent.md # this entry (agent.md → AGENTS.md)
inventory/hosts.md # machine index
hosts/ # per-host facts
runbooks/ # step-by-step ops
docs/ # upstream doc indexes / design notes
scripts/ # read-only helper scripts
```
+13
View File
@@ -0,0 +1,13 @@
[defaults]
inventory = inventory/hosts.yml
roles_path = roles
interpreter_python = auto_silent
stdout_callback = default
bin_ansible_callbacks = True
retry_files_enabled = False
host_key_checking = True
[privilege_escalation]
become = True
become_method = sudo
become_ask_pass = False
+74
View File
@@ -0,0 +1,74 @@
---
# Sanitized control-plane inventory. Canonical human-readable facts remain
# in ../../inventory/hosts.md and ../../hosts/*.md. No passwords, keys, tokens,
# repository URLs, or private material belong here.
all:
vars:
ansible_user: windy
ansible_ssh_common_args: >-
-o BatchMode=yes -o ConnectTimeout=10 -o AddressFamily=inet
ansible_become: true
ansible_become_method: sudo
children:
managed:
hosts:
mx2:
ansible_host: mx2.windy.me
ansible_host_ipv4: 194.163.160.244
service_role: mailcow
compose_project_dir: /opt/mail
healthcheck_profile: mailcow
us2:
ansible_host: us2.wsvc.info
ansible_host_ipv4: 193.9.44.165
service_role: vaultwarden
compose_project_dir: /opt/vaultwarden
healthcheck_profile: vaultwarden
hk2:
ansible_host: hk2.chans.xyz
ansible_host_ipv4: 154.36.174.161
service_role: powerdns
compose_project_dir: /opt/pdns
healthcheck_profile: pdns
mailcow:
hosts:
mx2:
vaultwarden:
hosts:
us2:
powerdns:
hosts:
hk2:
docker_hosts:
children:
mailcow:
vaultwarden:
powerdns:
# Matrix is a dedicated K3s node and intentionally remains outside the
# Docker-oriented managed group.
k3s_servers:
hosts:
matrix_vps:
ansible_host: 169.58.86.13
ansible_host_ipv4: 169.58.86.13
service_role: matrix_k3s
matrix_server_name: chans.xyz
matrix_synapse_host: synapse.chans.xyz
matrix_element_host: chat.chans.xyz
matrix_mas_host: account.chans.xyz
matrix_admin_host: admin.chans.xyz
matrix_rtc_host: mrtc.chans.xyz
matrix_backup_path: /var/backups/matrix
matrix_bootstrap_dir: /etc/matrix-bootstrap
matrix_stack_enabled: false
# ESS OCI chart configuration
matrix_stack_chart_ref: oci://ghcr.io/element-hq/ess-helm/matrix-stack
matrix_stack_chart_version: 26.7.2
matrix_stack_release_name: ess
matrix_namespace: ess
matrix:
children:
k3s_servers:
matrix_production:
children:
matrix:
+73
View File
@@ -0,0 +1,73 @@
---
# Read-only control-plane audit. This play intentionally contains no package,
# file, service, container, or configuration mutation tasks.
- name: Audit managed VPS hosts without changes
hosts: managed
gather_facts: true
become: false
any_errors_fatal: false
tasks:
- name: Verify Docker Compose command is available
ansible.builtin.command:
argv:
- docker
- compose
- version
changed_when: false
- name: Inspect configured Compose project
ansible.builtin.command:
argv:
- docker
- compose
- --project-directory
- "{{ compose_project_dir }}"
- ps
- --all
register: audit_compose_ps
changed_when: false
failed_when: false
- name: Inspect failed systemd units
ansible.builtin.command:
argv:
- systemctl
- --failed
- --no-legend
- --no-pager
register: audit_failed_units
changed_when: false
failed_when: false
- name: Inspect filesystem capacity
ansible.builtin.command:
argv:
- df
- -P
- -x
- tmpfs
- -x
- devtmpfs
register: audit_filesystems
changed_when: false
- name: Inspect active listeners
ansible.builtin.command:
argv:
- ss
- -lntup
register: audit_listeners
changed_when: false
failed_when: false
- name: Report sanitized audit summary
ansible.builtin.debug:
msg:
host: "{{ inventory_hostname }}"
profile: "{{ healthcheck_profile }}"
os: "{{ ansible_distribution }} {{ ansible_distribution_version }}"
kernel: "{{ ansible_kernel }}"
compose_rc: "{{ audit_compose_ps.rc }}"
failed_units: "{{ audit_failed_units.stdout_lines | default([]) }}"
filesystem_lines: "{{ audit_filesystems.stdout_lines | default([]) }}"
listener_lines: "{{ audit_listeners.stdout_lines | default([]) }}"
+9
View File
@@ -0,0 +1,9 @@
---
# Baseline starts audit-only. Opt-in variables are deliberately false by default.
- name: Apply controlled common baseline
hosts: managed
become: true
gather_facts: false
roles:
- role: baseline
tags: [baseline, audit]
+9
View File
@@ -0,0 +1,9 @@
---
# Requires a target-local root-owned SMTP config; no credentials are passed here.
- name: Deploy health email alert integration
hosts: managed
become: true
gather_facts: false
roles:
- role: email_alert
tags: [healthcheck, email]
+8
View File
@@ -0,0 +1,8 @@
---
- name: Deploy daily local health checks
hosts: managed
become: true
gather_facts: false
roles:
- role: healthcheck
tags: [healthcheck, timers]
+9
View File
@@ -0,0 +1,9 @@
---
- name: Install or reconcile the single-node K3s server
hosts: k3s_servers
become: true
gather_facts: true
serial: 1
roles:
- role: k3s_server
tags: [k3s, matrix, mutating]
+30
View File
@@ -0,0 +1,30 @@
---
# Preview only. This playbook does not install updates, restart services, or
# change DNS/secrets. A separate, manually reviewed change is required to act.
- name: Preview pending maintenance without changes
hosts: managed
become: true
gather_facts: false
tasks:
- name: Check reboot requirement marker
ansible.builtin.stat:
path: /var/run/reboot-required
register: maintenance_reboot_marker
- name: Preview available package updates on Debian-family hosts
ansible.builtin.command:
argv:
- apt-get
- --just-print
- upgrade
register: maintenance_apt_preview
changed_when: false
failed_when: false
when: ansible_facts.os_family | default('Debian') == 'Debian'
- name: Report maintenance preview
ansible.builtin.debug:
msg:
host: "{{ inventory_hostname }}"
reboot_required: "{{ maintenance_reboot_marker.stat.exists }}"
package_preview: "{{ maintenance_apt_preview.stdout_lines | default([]) }}"
+8
View File
@@ -0,0 +1,8 @@
---
- name: Install local Matrix consistency backup jobs
hosts: matrix
become: true
gather_facts: false
roles:
- role: matrix_backup
tags: [matrix, backup, mutating]
+11
View File
@@ -0,0 +1,11 @@
---
- name: Reconcile Helm, cert-manager, and the Let's Encrypt issuer
hosts: k3s_servers
become: true
gather_facts: false
serial: 1
roles:
- role: helm_client
tags: [helm, matrix, mutating]
- role: cert_manager
tags: [cert_manager, matrix, mutating]
@@ -0,0 +1,9 @@
---
- name: Run the temporary Matrix HTTP-01 certificate smoke test
hosts: matrix
become: true
gather_facts: false
serial: 1
roles:
- role: matrix_certificate_smoke
tags: [matrix, certificates, smoke_test, mutating]
@@ -0,0 +1,8 @@
---
- name: Create non-secret Matrix Kubernetes foundation resources
hosts: matrix
become: true
gather_facts: false
roles:
- role: matrix_cluster_base
tags: [matrix, cluster_base, mutating]
@@ -0,0 +1,8 @@
---
- name: Install Matrix K3s local health checks
hosts: matrix
become: true
gather_facts: false
roles:
- role: matrix_healthcheck
tags: [matrix, healthcheck, timers]
+9
View File
@@ -0,0 +1,9 @@
---
# Read-only gate before a K3s or Matrix change.
- name: Validate Matrix K3s host readiness without changes
hosts: matrix
become: true
gather_facts: true
roles:
- role: k3s_preflight
tags: [matrix, preflight, read_only]
@@ -0,0 +1,8 @@
---
- name: Validate the pre-provisioned Matrix secret contract
hosts: matrix
become: true
gather_facts: false
roles:
- role: matrix_secret_contract
tags: [matrix, secrets, validation]
+99
View File
@@ -0,0 +1,99 @@
---
# Deploy the official ESS OCI chart on a K3s node.
# Pre-tasks create the non-secret values files on the target host.
# The matrix_stack role then validates and deploys the chart.
- name: Deploy the Matrix stack (ESS OCI chart)
hosts: matrix
become: true
gather_facts: false
serial: 1
pre_tasks:
- name: Ensure the ESS values directory exists
ansible.builtin.file:
path: /etc/ess
state: directory
owner: root
group: root
mode: "0700"
- name: Write hostnames values file
ansible.builtin.copy:
dest: /etc/ess/hostnames.yaml
owner: root
group: root
mode: "0600"
content: |
serverName: {{ matrix_server_name }}
elementWeb:
ingress:
host: {{ matrix_element_host }}
synapse:
ingress:
host: {{ matrix_synapse_host }}
matrixAuthenticationService:
ingress:
host: {{ matrix_mas_host }}
elementAdmin:
ingress:
host: {{ matrix_admin_host }}
matrixRTC:
ingress:
host: {{ matrix_rtc_host }}
- name: Write TLS values file
ansible.builtin.copy:
dest: /etc/ess/tls.yaml
owner: root
group: root
mode: "0600"
content: |
certManager:
clusterIssuer: letsencrypt-prod
ingress:
className: traefik
tlsEnabled: true
- name: Write single-node tuning values file
ansible.builtin.copy:
dest: /etc/ess/single-node.yaml
owner: root
group: root
mode: "0600"
content: |
# ESS single-node resource tuning for K3s
# Chart defaults are already single-node-friendly
postgres:
storage:
size: 20Gi
redis:
maxMemory: 128mb
matrixRTC:
enabled: false
- name: Verify values files are in place
ansible.builtin.stat:
path: "/etc/ess/{{ item }}"
loop:
- hostnames.yaml
- tls.yaml
- single-node.yaml
register: _values_check
- name: Assert all values files exist
ansible.builtin.assert:
that:
- item.stat.exists
- item.stat.isreg
- item.stat.pw_name == 'root'
loop: "{{ _values_check.results }}"
loop_control:
label: "{{ item.stat.path | default(item.item) }}"
roles:
- role: matrix_stack
tags: [matrix, stack, mutating]
+13
View File
@@ -0,0 +1,13 @@
---
# Intentionally targets only services with approved local dump/data sources.
# Set restic_enabled=true only after selecting a backend and provisioning the
# root-only repository config directly on each target.
- name: Deploy controlled Restic timers
hosts:
- vaultwarden
- powerdns
become: true
gather_facts: false
roles:
- role: restic
tags: [restic, backup]
+3
View File
@@ -0,0 +1,3 @@
---
baseline_manage_logrotate: false
baseline_require_chrony: false
+44
View File
@@ -0,0 +1,44 @@
---
- name: Audit SSH daemon effective configuration
ansible.builtin.command:
argv:
- sshd
- -T
register: baseline_sshd_effective
changed_when: false
failed_when: false
- name: Report SSH hardening observations without changing SSH
ansible.builtin.debug:
msg:
permit_root_login: >-
{{ baseline_sshd_effective.stdout_lines | select('match', '^permitrootlogin ') | list }}
password_authentication: >-
{{ baseline_sshd_effective.stdout_lines | select('match', '^passwordauthentication ') | list }}
- name: Audit time synchronization service state
ansible.builtin.command:
argv:
- systemctl
- is-active
- systemd-timesyncd
register: baseline_timesync_state
changed_when: false
failed_when: false
- name: Install baseline logrotate policy only with explicit opt-in
ansible.builtin.copy:
dest: /etc/logrotate.d/vps-baseline
content: |
/var/log/vps-health/*.log /var/log/vps-restic/*.log {
weekly
rotate 8
missingok
notifempty
compress
create 0640 root root
}
owner: root
group: root
mode: "0644"
when: baseline_manage_logrotate | bool
@@ -0,0 +1,13 @@
---
cert_manager_enabled: false
cert_manager_namespace: cert-manager
cert_manager_release_name: cert-manager
cert_manager_chart_repository_name: jetstack
cert_manager_chart_repository_url: https://charts.jetstack.io
cert_manager_chart_ref: jetstack/cert-manager
cert_manager_chart_version: v1.19.3
cert_manager_kubeconfig: /etc/rancher/k3s/k3s.yaml
cert_manager_cluster_issuer_name: letsencrypt-prod
cert_manager_acme_server: https://acme-v02.api.letsencrypt.org/directory
cert_manager_acme_private_key_secret: letsencrypt-prod-private-key
cert_manager_ingress_class: traefik
+94
View File
@@ -0,0 +1,94 @@
---
- name: Require explicit approval before cert-manager reconciliation
ansible.builtin.assert:
that:
- cert_manager_enabled | bool
fail_msg: >-
Refusing cert-manager changes. Re-run with cert_manager_enabled=true after
confirming public TCP 80 and 443 reach Traefik.
- name: Require Helm and the K3s kubeconfig
ansible.builtin.stat:
path: "{{ item }}"
loop:
- /usr/local/bin/helm
- "{{ cert_manager_kubeconfig }}"
register: cert_manager_prerequisites
- name: Assert Helm and kubeconfig are available
ansible.builtin.assert:
that:
- item.stat.exists
fail_msg: "Missing cert-manager prerequisite: {{ item.item }}"
loop: "{{ cert_manager_prerequisites.results }}"
- name: Add or update the Jetstack Helm repository
ansible.builtin.command:
argv:
- helm
- repo
- add
- "{{ cert_manager_chart_repository_name }}"
- "{{ cert_manager_chart_repository_url }}"
- --force-update
environment:
KUBECONFIG: "{{ cert_manager_kubeconfig }}"
changed_when: false
- name: Update Helm repositories
ansible.builtin.command:
argv: [helm, repo, update]
environment:
KUBECONFIG: "{{ cert_manager_kubeconfig }}"
changed_when: false
- name: Install or reconcile cert-manager
ansible.builtin.command:
argv:
- helm
- upgrade
- --install
- "{{ cert_manager_release_name }}"
- "{{ cert_manager_chart_ref }}"
- --namespace
- "{{ cert_manager_namespace }}"
- --create-namespace
- --version
- "{{ cert_manager_chart_version }}"
- --set
- crds.enabled=true
- --wait
- --timeout
- 10m
environment:
KUBECONFIG: "{{ cert_manager_kubeconfig }}"
changed_when: true
- name: Install ClusterIssuer manifest without secret material
ansible.builtin.template:
src: clusterissuer.yaml.j2
dest: "/etc/rancher/k3s/{{ cert_manager_cluster_issuer_name }}-clusterissuer.yaml"
owner: root
group: root
mode: "0600"
- name: Apply ClusterIssuer manifest
ansible.builtin.command:
argv:
- k3s
- kubectl
- apply
- -f
- "/etc/rancher/k3s/{{ cert_manager_cluster_issuer_name }}-clusterissuer.yaml"
changed_when: false
- name: Wait for ClusterIssuer readiness
ansible.builtin.command:
argv:
- k3s
- kubectl
- wait
- --for=condition=Ready
- "clusterissuer/{{ cert_manager_cluster_issuer_name }}"
- --timeout=180s
changed_when: false
@@ -0,0 +1,13 @@
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: {{ cert_manager_cluster_issuer_name }}
spec:
acme:
server: {{ cert_manager_acme_server }}
privateKeySecretRef:
name: {{ cert_manager_acme_private_key_secret }}
solvers:
- http01:
ingress:
class: {{ cert_manager_ingress_class }}
@@ -0,0 +1,6 @@
---
email_alert_config_path: /etc/vps-health/alert-smtp.conf
email_alert_state_path: /var/lib/vps-health/alert-state
email_alert_recipient: ''
email_alert_enabled: false
email_alert_repeat_hours: 24
@@ -0,0 +1,4 @@
---
- name: Reload systemd
ansible.builtin.systemd_service:
daemon_reload: true
+50
View File
@@ -0,0 +1,50 @@
---
# The health-check service owns the sole dispatcher hook. This role only
# installs/removes that dispatcher according to the explicit opt-in below.
- name: Require explicit non-secret alert recipient when email is enabled
ansible.builtin.assert:
that:
- email_alert_recipient | length > 0
fail_msg: Set email_alert_recipient outside version control before enabling alerts.
when: email_alert_enabled | bool
- name: Install alert integration when explicitly enabled
when: email_alert_enabled | bool
block:
- name: Install alert state directory
ansible.builtin.file:
path: "{{ email_alert_state_path | dirname }}"
state: directory
owner: root
group: root
mode: "0750"
- name: Install secret-free alert dispatcher
ansible.builtin.template:
src: alert-dispatch.sh.j2
dest: /usr/local/lib/vps-health/alert-dispatch
owner: root
group: root
mode: "0750"
- name: Remove alert integration when disabled
when: not (email_alert_enabled | bool)
block:
- name: Remove alert dispatcher
ansible.builtin.file:
path: /usr/local/lib/vps-health/alert-dispatch
state: absent
- name: Remove legacy alert service drop-in
ansible.builtin.file:
path: /etc/systemd/system/vps-healthcheck.service.d/alerting.conf
state: absent
notify: Reload systemd
- name: Report required server-side alert configuration
ansible.builtin.debug:
msg: >-
Email alerts are {{ 'enabled' if email_alert_enabled | bool else 'disabled' }}.
When enabled, the root-owned {{ email_alert_config_path }} must be provisioned
directly on the host and must contain SMTP settings and recipient; it is never
created or populated by this repository.
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Sends sanitized health results through the host's locally provisioned SMTP
# credentials. The config is intentionally excluded from Ansible/Git.
set -uo pipefail
config='{{ email_alert_config_path }}'
result='/var/lib/vps-health/latest.json'
state='{{ email_alert_state_path }}'
[[ -r "$config" && -r "$result" ]] || exit 0
# shellcheck source=/dev/null
source "$config"
: "${SMTP_URL:?missing SMTP_URL in server-side alert config}"
: "${ALERT_TO:?missing ALERT_TO in server-side alert config}"
status="$(python3 -c 'import json; print(json.load(open("'"$result"'"))["status"])')"
fingerprint="$(sha256sum "$result" | cut -d' ' -f1)"
previous="$(cat "$state" 2>/dev/null || true)"
now="$(date +%s)"
last_time="${previous%%:*}"; last_fp="${previous#*:}"
if [[ "$status" =~ ^(critical|unknown)$ ]] && [[ "$fingerprint" != "$last_fp" || $((now-${last_time:-0})) -ge {{ email_alert_repeat_hours }}*3600 ]]; then
subject="[${status}] VPS health $(hostname -f 2>/dev/null || hostname)"
curl --fail --silent --show-error --url "$SMTP_URL" --mail-rcpt "$ALERT_TO" --upload-file <(printf 'To: %s\nSubject: %s\nContent-Type: application/json\n\n%s\n' "$ALERT_TO" "$subject" "$(cat "$result")")
printf '%s:%s\n' "$now" "$fingerprint" > "$state"
elif [[ "$status" =~ ^(ok|warning)$ ]]; then
subject="[${status}] daily VPS health $(hostname -f 2>/dev/null || hostname)"
curl --fail --silent --show-error --url "$SMTP_URL" --mail-rcpt "$ALERT_TO" --upload-file <(printf 'To: %s\nSubject: %s\nContent-Type: application/json\n\n%s\n' "$ALERT_TO" "$subject" "$(cat "$result")")
fi
@@ -0,0 +1,2 @@
[Service]
ExecStartPost=/usr/local/lib/vps-health/alert-dispatch
@@ -0,0 +1,13 @@
---
healthcheck_install_root: /usr/local/lib/vps-health
healthcheck_state_dir: /var/lib/vps-health
healthcheck_log_dir: /var/log/vps-health
healthcheck_service_name: vps-healthcheck
healthcheck_timer_on_calendar: '*-*-* 06:15:00'
healthcheck_timer_randomized_delay_sec: 15m
healthcheck_backup_max_age_hours: 30
healthcheck_tls_warn_days: 21
healthcheck_profiles:
mailcow: mailcow.sh
vaultwarden: vaultwarden.sh
pdns: pdns.sh
@@ -0,0 +1,4 @@
---
- name: Reload systemd
ansible.builtin.systemd_service:
daemon_reload: true
+81
View File
@@ -0,0 +1,81 @@
---
- name: Validate known health-check profile
ansible.builtin.assert:
that:
- healthcheck_profile in healthcheck_profiles
fail_msg: "Unsupported healthcheck_profile: {{ healthcheck_profile }}"
- name: Install health-check directories
ansible.builtin.file:
path: "{{ item.path }}"
state: directory
owner: root
group: root
mode: "{{ item.mode }}"
loop:
- path: "{{ healthcheck_install_root }}"
mode: "0755"
- path: "{{ healthcheck_state_dir }}"
mode: "0750"
- path: "{{ healthcheck_log_dir }}"
mode: "0750"
- name: Install common health-check library
ansible.builtin.template:
src: health-common.sh.j2
dest: "{{ healthcheck_install_root }}/health-common.sh"
owner: root
group: root
mode: "0755"
- name: Install service health-check script
ansible.builtin.template:
src: "{{ healthcheck_profiles[healthcheck_profile] }}.j2"
dest: "{{ healthcheck_install_root }}/{{ healthcheck_profiles[healthcheck_profile] }}"
owner: root
group: root
mode: "0755"
- name: Install health-check dispatcher
ansible.builtin.template:
src: healthcheck-runner.sh.j2
dest: "{{ healthcheck_install_root }}/run"
owner: root
group: root
mode: "0755"
- name: Install health-check systemd unit
ansible.builtin.template:
src: vps-healthcheck.service.j2
dest: "/etc/systemd/system/{{ healthcheck_service_name }}.service"
owner: root
group: root
mode: "0644"
notify: Reload systemd
- name: Install persistent health-check timer
ansible.builtin.template:
src: vps-healthcheck.timer.j2
dest: "/etc/systemd/system/{{ healthcheck_service_name }}.timer"
owner: root
group: root
mode: "0644"
notify: Reload systemd
- name: Install health-check log rotation
ansible.builtin.template:
src: logrotate-vps-healthcheck.j2
dest: /etc/logrotate.d/vps-healthcheck
owner: root
group: root
mode: "0644"
- name: Enable health-check timer
ansible.builtin.systemd_service:
name: "{{ healthcheck_service_name }}.timer"
enabled: true
state: started
daemon_reload: true
- name: Flush health-check systemd changes
ansible.builtin.meta: flush_handlers
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# Shared contract: one JSON object per run in /var/lib/vps-health/latest.json.
# Status values: ok, warning, critical, unknown. Exit codes: 0 healthy/warning,
# 2 critical, 3 unknown. Check detail must never include secret values.
set -uo pipefail
RESULT_DIR='{{ healthcheck_state_dir }}'
LOG_DIR='{{ healthcheck_log_dir }}'
HOST_NAME="$(hostname -f 2>/dev/null || hostname)"
CHECK_NAME='{{ healthcheck_profile }}'
STATUS=ok
EXIT_CODE=0
DETAILS=()
record() {
local severity="$1" message="$2"
DETAILS+=("${severity}:${message}")
case "$severity" in
critical) STATUS=critical; EXIT_CODE=2 ;;
unknown) [[ "$STATUS" != critical ]] && STATUS=unknown; [[ "$EXIT_CODE" -eq 0 ]] && EXIT_CODE=3 ;;
warning) [[ "$STATUS" == ok ]] && STATUS=warning ;;
esac
}
require_command() {
command -v "$1" >/dev/null 2>&1 || record unknown "missing_command:$1"
}
compose_ps() {
docker compose --project-directory '{{ compose_project_dir }}' ps --all 2>&1
}
check_compose() {
local output
output="$(compose_ps)" || { record critical 'compose_ps_failed'; return; }
if grep -qiE 'Exited|Restarting|[[:space:]]Dead[[:space:]]' <<<"$output"; then
record critical 'compose_unhealthy_container'
else
record ok 'compose_ok'
fi
}
check_backup_freshness() {
local pattern="$1" newest now age
newest="$(find {{ compose_project_dir | quote }} -type f -path "$pattern" -printf '%T@\n' 2>/dev/null | sort -nr | head -n1)"
if [[ -z "$newest" ]]; then
record warning 'backup_not_found'
return
fi
now="$(date +%s)"
age="$(( now - ${newest%.*} ))"
if (( age > {{ healthcheck_backup_max_age_hours }} * 3600 )); then
record critical 'backup_stale'
else
record ok 'backup_fresh'
fi
}
check_https() {
local url="$1" expected="$2" code
code="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' --max-time 20 "$url" 2>/dev/null)" || {
record critical 'https_request_failed'; return;
}
[[ "$code" =~ $expected ]] && record ok "https_${code}" || record critical "https_${code}"
}
check_tls_days() {
local host="$1" port="$2" expires epoch remaining
expires="$(timeout 20 openssl s_client -connect "${host}:${port}" -servername "$host" </dev/null 2>/dev/null | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2-)" || {
record unknown 'tls_read_failed'; return;
}
epoch="$(date -d "$expires" +%s 2>/dev/null)" || { record unknown 'tls_date_parse_failed'; return; }
remaining="$(( (epoch - $(date +%s)) / 86400 ))"
if (( remaining < 0 )); then record critical 'tls_expired'
elif (( remaining < {{ healthcheck_tls_warn_days }} )); then record warning 'tls_near_expiry'
else record ok 'tls_valid'; fi
}
emit_result() {
local tmp detail_json
tmp="$(mktemp "${RESULT_DIR}/latest.json.XXXXXX")"
detail_json="$(printf '%s\n' "${DETAILS[@]:-unknown:no_details}" | python3 -c 'import json,sys; print(json.dumps([line.rstrip() for line in sys.stdin if line.strip()]))')"
python3 - "$tmp" "$HOST_NAME" "$CHECK_NAME" "$STATUS" "$EXIT_CODE" "$detail_json" <<'PY'
import json, sys
path, host, check, status, code, details = sys.argv[1:]
with open(path, 'w', encoding='utf-8') as f:
json.dump({'schema': 1, 'host': host, 'check': check, 'status': status,
'exit_code': int(code), 'details': json.loads(details)}, f,
sort_keys=True, separators=(',', ':'))
f.write('\n')
PY
chmod 0640 "$tmp"
mv "$tmp" "${RESULT_DIR}/latest.json"
cat "${RESULT_DIR}/latest.json"
}
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
set -uo pipefail
exec '{{ healthcheck_install_root }}/{{ healthcheck_profiles[healthcheck_profile] }}' >> '{{ healthcheck_log_dir }}/healthcheck.log' 2>&1
@@ -0,0 +1,9 @@
{{ healthcheck_log_dir }}/healthcheck.log {
daily
rotate 14
missingok
notifempty
compress
delaycompress
create 0640 root root
}
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
set -uo pipefail
source '{{ healthcheck_install_root }}/health-common.sh'
require_command docker
require_command curl
require_command openssl
require_command dig
require_command timeout
check_compose
watchdog="$(docker compose --project-directory '{{ compose_project_dir }}' logs --tail=40 watchdog-mailcow 2>&1)" || record critical 'watchdog_log_failed'
grep -qiE '100%|healthy' <<<"$watchdog" || record warning 'watchdog_health_not_confirmed'
queue="$(docker compose --project-directory '{{ compose_project_dir }}' exec -T postfix-mailcow postqueue -p 2>&1)" || record critical 'mail_queue_check_failed'
grep -Fqi 'Mail queue is empty' <<<"$queue" || record warning 'mail_queue_nonempty'
listeners="$(ss -lnt 2>/dev/null)"
for port in 25 465 587 993 443; do
grep -qE ":${port}[[:space:]]" <<<"$listeners" || record critical "listener_missing_${port}"
done
check_https 'https://mx2.windy.me/' '^200$'
check_tls_days mx2.windy.me 443
smtp="$(timeout 10 bash -c "exec 3<>/dev/tcp/mx2.windy.me/25; printf 'EHLO health.local\\r\\nQUIT\\r\\n' >&3; cat <&3" 2>/dev/null)" || record critical 'smtp_connect_failed'
grep -qiE 'Postcow|ESMTP' <<<"$smtp" || record critical 'smtp_banner_unexpected'
for resolver in 1.1.1.1 8.8.8.8; do
mx="$(dig +short +time=3 +tries=1 "@${resolver}" windy.me MX 2>/dev/null)"
grep -Fqi 'mx2.windy.me' <<<"$mx" && { record ok 'mx_record_ok'; break; } || record warning 'mx_record_not_confirmed'
done
spf="$(dig +short +time=3 +tries=1 @1.1.1.1 windy.me TXT 2>/dev/null)"
grep -Fqi 'v=spf1' <<<"$spf" || record warning 'spf_not_confirmed'
check_backup_freshness '*/backup/*'
emit_result
exit "$EXIT_CODE"
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -uo pipefail
source '{{ healthcheck_install_root }}/health-common.sh'
require_command docker
require_command curl
require_command dig
check_compose
image="$(docker inspect pdns-auth --format '{{ '{{' }}.Config.Image{{ '}}' }}' 2>/dev/null)" || record critical 'pdns_container_missing'
grep -Eq ':5\.0\.[6-9]|:5\.[1-9]\.' <<<"$image" || record warning 'pdns_version_not_confirmed'
security="$(docker logs pdns-auth 2>&1 | grep -i 'Mandatory.*Security Update' || true)"
[[ -z "$security" ]] || record critical 'pdns_security_update_banner'
# The API key remains in the container environment; the request emits only the
# reported version and no authentication material.
api="$(docker compose --project-directory '{{ compose_project_dir }}' exec -T auth python3 - <<'PY' 2>&1
import json, os, urllib.request
request=urllib.request.Request('http://127.0.0.1:8081/api/v1/servers/localhost', headers={'X-API-Key': os.environ['PDNS_API_KEY']})
print(json.load(urllib.request.urlopen(request, timeout=10))['version'])
PY
)" || record critical 'pdns_api_failed'
grep -Eq '^5\.' <<<"$api" || record critical 'pdns_api_version_invalid'
for zone in windy.me wsvc.info chans.xyz; do
primary="$(dig +short @154.36.174.161 SOA "$zone" 2>/dev/null)"
secondary="$(dig +short @202.91.35.141 SOA "$zone" 2>/dev/null)"
[[ -n "$primary" ]] || record critical "soa_primary_missing_${zone}"
[[ -n "$secondary" ]] || record critical "soa_secondary_missing_${zone}"
[[ "$primary" == "$secondary" ]] || record warning "soa_secondary_lag_${zone}"
done
check_https 'https://pdns.wsvc.info/' '^30[12]$'
check_https 'https://pgweb.wsvc.info/' '^(200|401)$'
check_backup_freshness '*/backup/*.sql.gz'
emit_result
exit "$EXIT_CODE"
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
set -uo pipefail
source '{{ healthcheck_install_root }}/health-common.sh'
require_command docker
require_command curl
require_command openssl
require_command python3
check_compose
health="$(docker compose --project-directory '{{ compose_project_dir }}' ps --format json 2>&1)" || record critical 'compose_status_unavailable'
grep -Fq 'vaultwarden' <<<"$health" || record critical 'vaultwarden_missing'
grep -Fq 'vw-db' <<<"$health" || record critical 'postgres_missing'
check_https 'https://auth.wsvc.info/' '^200$'
check_tls_days auth.wsvc.info 443
# Read effective config only inside the service and report booleans/fingerprints,
# never its SMTP password or other secret fields.
smtp_result="$(docker compose --project-directory '{{ compose_project_dir }}' exec -T vaultwarden python3 - <<'PY' 2>&1
import json, pathlib, smtplib, ssl
cfg=json.loads(pathlib.Path('/data/config.json').read_text())
host=cfg.get('smtp_host'); port=int(cfg.get('smtp_port') or 0)
user=cfg.get('smtp_username')
smtp_secret=cfg.get('smtp_password')
if not all((host, port, user, smtp_secret)):
raise SystemExit('smtp_config_incomplete')
with smtplib.SMTP(host, port, timeout=15) as client:
client.ehlo(); client.starttls(context=ssl.create_default_context()); client.ehlo(); client.login(user, smtp_secret)
print('smtp_auth_ok')
PY
)"
grep -Fqx 'smtp_auth_ok' <<<"$smtp_result" || record critical 'smtp_auth_failed'
# Detect drift without exposing the values: matching SHA-256 digests are used only
# internally and the result is a boolean.
drift_result="$(python3 - <<'PY'
import hashlib, json, pathlib, re
root=pathlib.Path('{{ compose_project_dir }}')
env=root.joinpath('.env').read_text()
match=re.search(r'^SMTP_PASSWORD=(.*)$', env, re.M)
config=json.loads(root.joinpath('vw-data/config.json').read_text())
value=(match.group(1).strip().strip('"\'') if match else '')
print('smtp_password_match' if value and value == (config.get('smtp_password') or '') else 'smtp_password_drift')
PY
2>&1)" || record unknown 'smtp_drift_check_failed'
grep -Fqx 'smtp_password_match' <<<"$drift_result" || record critical 'smtp_password_drift'
check_backup_freshness '*/backups/*.sql.gz'
emit_result
exit "$EXIT_CODE"
@@ -0,0 +1,19 @@
[Unit]
Description=Read-only VPS health check (%i)
Wants=network-online.target
After=network-online.target docker.service
[Service]
Type=oneshot
User=root
Group=root
UMask=0027
ExecStart={{ healthcheck_install_root }}/run
# The optional dispatcher is installed only by email_alert when explicitly enabled.
# This service remains healthy if alerting is intentionally unavailable.
ExecStartPost=-/usr/local/lib/vps-health/alert-dispatch
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=full
ReadWritePaths={{ healthcheck_state_dir }} {{ healthcheck_log_dir }}
@@ -0,0 +1,11 @@
[Unit]
Description=Daily read-only VPS health check
[Timer]
OnCalendar={{ healthcheck_timer_on_calendar }}
Persistent=true
RandomizedDelaySec={{ healthcheck_timer_randomized_delay_sec }}
Unit={{ healthcheck_service_name }}.service
[Install]
WantedBy=timers.target
@@ -0,0 +1,6 @@
---
# Helm is required by the patched matrix-stack release. Existing installs are
# reported and retained; this role only installs Helm when it is absent.
helm_client_install_enabled: false
helm_client_install_script_url: https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3
helm_client_binary: /usr/local/bin/helm
+28
View File
@@ -0,0 +1,28 @@
---
- name: Check whether Helm is installed
ansible.builtin.stat:
path: "{{ helm_client_binary }}"
register: helm_client_binary_state
- name: Require explicit approval to install Helm when absent
ansible.builtin.assert:
that:
- helm_client_install_enabled | bool
fail_msg: >-
Helm is not installed. Re-run with helm_client_install_enabled=true after
reviewing the upstream installer source and checksum policy.
when: not helm_client_binary_state.stat.exists
- name: Install Helm only when explicitly approved and absent
ansible.builtin.shell:
cmd: "curl -fsSL {{ helm_client_install_script_url }} | bash"
creates: "{{ helm_client_binary }}"
when:
- helm_client_install_enabled | bool
- not helm_client_binary_state.stat.exists
no_log: true
- name: Read Helm version
ansible.builtin.command:
argv: ["{{ helm_client_binary }}", version, --short]
changed_when: false
@@ -0,0 +1,14 @@
---
# Read-only checks for a dedicated, single-node Matrix K3s host.
k3s_preflight_required_memory_mib: 6144
k3s_preflight_required_root_free_gib: 50
k3s_preflight_required_hosts:
- "{{ matrix_server_name }}"
- "{{ matrix_synapse_host }}"
- "{{ matrix_element_host }}"
- "{{ matrix_mas_host }}"
- "{{ matrix_admin_host }}"
- "{{ matrix_rtc_host }}"
k3s_preflight_required_ports:
- 80
- 443
@@ -0,0 +1,69 @@
---
- name: Read host memory in MiB
ansible.builtin.set_fact:
k3s_preflight_memory_mib: "{{ (ansible_memtotal_mb | int) }}"
- name: Read root filesystem capacity facts
ansible.builtin.set_fact:
k3s_preflight_root_mount: >-
{{ (ansible_mounts | selectattr('mount', 'equalto', '/') | list | first) | default({}) }}
- name: Assert host has the baseline resources for Matrix
ansible.builtin.assert:
that:
- k3s_preflight_memory_mib | int >= k3s_preflight_required_memory_mib | int
- (k3s_preflight_root_mount.size_available | default(0) | int) >= (k3s_preflight_required_root_free_gib | int * 1024 * 1024 * 1024)
fail_msg: >-
Matrix requires at least {{ k3s_preflight_required_memory_mib }} MiB RAM and
{{ k3s_preflight_required_root_free_gib }} GiB available on /. No change was made.
- name: Check whether K3s is already installed
ansible.builtin.stat:
path: /usr/local/bin/k3s
register: k3s_preflight_binary
- name: Read listeners on required public ports
ansible.builtin.command:
argv: [ss, -lntH]
changed_when: false
register: k3s_preflight_listeners
- name: Assert public ports are unused before initial K3s installation
ansible.builtin.assert:
that:
- >-
(k3s_preflight_binary.stat.exists | bool) or
((k3s_preflight_listeners.stdout_lines | select('search', '(:|\\.)' ~ (item | string) ~ '$') | list | length) == 0)
fail_msg: "Port {{ item }} is already listening; resolve the ingress ownership conflict first."
loop: "{{ k3s_preflight_required_ports }}"
- name: Resolve Matrix hostnames over IPv4
ansible.builtin.command:
argv: [getent, ahostsv4, "{{ item }}"]
changed_when: false
register: k3s_preflight_dns
loop: "{{ k3s_preflight_required_hosts }}"
- name: Assert every Matrix hostname resolves to the selected VPS
ansible.builtin.assert:
that:
- item.stdout is search(ansible_host_ipv4 | regex_escape)
fail_msg: >-
{{ item.item }} does not resolve to {{ ansible_host_ipv4 }} over IPv4. DNS must
be correct before HTTP-01 certificates can be issued.
loop: "{{ k3s_preflight_dns.results }}"
- name: Check the local backup path parent filesystem
ansible.builtin.command:
argv: [df, -P, "{{ matrix_backup_path | dirname }}"]
changed_when: false
register: k3s_preflight_backup_filesystem
- name: Report read-only preflight state
ansible.builtin.debug:
msg:
k3s_installed: "{{ k3s_preflight_binary.stat.exists }}"
memory_mib: "{{ k3s_preflight_memory_mib }}"
root_available_bytes: "{{ k3s_preflight_root_mount.size_available | default(0) }}"
backup_path: "{{ matrix_backup_path }}"
backup_filesystem: "{{ k3s_preflight_backup_filesystem.stdout_lines[-1] }}"
@@ -0,0 +1,11 @@
---
# The installer downloads the current stable channel only for an uninstalled node.
# Normal reruns never auto-upgrade an existing K3s installation.
k3s_install_enabled: false
k3s_install_url: https://get.k3s.io
k3s_binary_path: /usr/local/bin/k3s
k3s_service_name: k3s
k3s_server_args:
- --disable=servicelb
- --disable=metrics-server
k3s_kubeconfig: /etc/rancher/k3s/k3s.yaml
+48
View File
@@ -0,0 +1,48 @@
---
- name: Check whether K3s is installed
ansible.builtin.stat:
path: "{{ k3s_binary_path }}"
register: k3s_server_binary
- name: Require explicit approval before first K3s installation
ansible.builtin.assert:
that:
- k3s_install_enabled | bool
fail_msg: >-
Refusing first-time K3s installation. Re-run only after preflight succeeds
with k3s_install_enabled=true.
when: not (k3s_server_binary.stat.exists | bool)
- name: Install current stable K3s only on a new node
ansible.builtin.shell:
cmd: >-
curl -fsSL {{ k3s_install_url }} |
INSTALL_K3S_EXEC='server {{ k3s_server_args | join(' ') }}' sh -
creates: "{{ k3s_binary_path }}"
when: not (k3s_server_binary.stat.exists | bool)
no_log: true
- name: Wait for K3s API readiness
ansible.builtin.command:
argv:
- "{{ k3s_binary_path }}"
- kubectl
- get
- node
- --output=jsonpath={.items[0].status.conditions[?(@.type=="Ready")].status}
changed_when: false
register: k3s_server_ready
retries: 30
delay: 5
until: k3s_server_ready.stdout == 'True'
- name: Assert Traefik is installed and K3s optional components remain disabled
ansible.builtin.command:
argv:
- "{{ k3s_binary_path }}"
- kubectl
- get
- deployment
- traefik
- --namespace=kube-system
changed_when: false
@@ -0,0 +1,13 @@
---
matrix_backup_enabled: false
matrix_backup_path: /var/backups/matrix
matrix_backup_retention_days: 7
matrix_backup_warn_percent: 80
matrix_backup_stop_percent: 90
matrix_backup_script_path: /usr/local/sbin/matrix-backup
matrix_backup_service_name: matrix-backup.service
matrix_backup_timer_name: matrix-backup.timer
matrix_namespace: matrix-system
matrix_backup_postgres_pod_selector: app=matrix-postgres
matrix_backup_media_pod_selector: app=synapse,component=synapse-media-repository
matrix_backup_bootstrap_dir: /etc/matrix-bootstrap
@@ -0,0 +1,4 @@
---
- name: Reload systemd
ansible.builtin.systemd:
daemon_reload: true
@@ -0,0 +1,49 @@
---
- name: Require explicit approval before installing backup automation
ansible.builtin.assert:
that:
- matrix_backup_enabled | bool
fail_msg: >-
Refusing to install Matrix backup automation until matrix_backup_enabled=true
is supplied deliberately.
- name: Create root-only Matrix backup directory
ansible.builtin.file:
path: "{{ matrix_backup_path }}"
state: directory
owner: root
group: root
mode: "0700"
- name: Install Matrix backup script
ansible.builtin.template:
src: matrix-backup.sh.j2
dest: "{{ matrix_backup_script_path }}"
owner: root
group: root
mode: "0700"
- name: Install Matrix backup systemd service
ansible.builtin.template:
src: matrix-backup.service.j2
dest: "/etc/systemd/system/{{ matrix_backup_service_name }}"
owner: root
group: root
mode: "0644"
notify: Reload systemd
- name: Install Matrix backup systemd timer
ansible.builtin.template:
src: matrix-backup.timer.j2
dest: "/etc/systemd/system/{{ matrix_backup_timer_name }}"
owner: root
group: root
mode: "0644"
notify: Reload systemd
- name: Enable Matrix backup timer
ansible.builtin.systemd:
name: "{{ matrix_backup_timer_name }}"
enabled: true
state: started
daemon_reload: true
@@ -0,0 +1,16 @@
[Unit]
Description=Create local consistent Matrix backup
Wants=network-online.target
After=network-online.target k3s.service
[Service]
Type=oneshot
User=root
Group=root
UMask=0077
ExecStart={{ matrix_backup_script_path }}
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=full
ReadWritePaths={{ matrix_backup_path }} /etc/matrix-bootstrap
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
set -euo pipefail
umask 077
backup_root='{{ matrix_backup_path }}'
namespace='{{ matrix_namespace }}'
warn_percent='{{ matrix_backup_warn_percent }}'
stop_percent='{{ matrix_backup_stop_percent }}'
retention_days='{{ matrix_backup_retention_days }}'
postgres_selector='{{ matrix_backup_postgres_pod_selector }}'
media_selector='{{ matrix_backup_media_pod_selector }}'
bootstrap_dir='{{ matrix_backup_bootstrap_dir }}'
usage=$(df -P / | awk 'NR == 2 {gsub(/%/, "", $5); print $5}')
if (( usage >= stop_percent )); then
printf 'Refusing Matrix backup: root filesystem usage is %s%% (stop threshold %s%%).\n' "$usage" "$stop_percent" >&2
exit 2
fi
stamp=$(date -u +%Y%m%dT%H%M%SZ)
stage="$backup_root/.staging-$stamp"
final="$backup_root/$stamp"
mkdir -p "$stage" "$backup_root"
trap 'rm -rf "$stage"' EXIT
if ! command -v k3s >/dev/null 2>&1; then
printf 'K3s is unavailable; refusing Matrix backup.\n' >&2
exit 3
fi
postgres_pod=$(k3s kubectl -n "$namespace" get pod -l "$postgres_selector" -o jsonpath='{.items[0].metadata.name}')
media_pod=$(k3s kubectl -n "$namespace" get pod -l "$media_selector" -o jsonpath='{.items[0].metadata.name}')
if [[ -z "$postgres_pod" || -z "$media_pod" ]]; then
printf 'Required Matrix PostgreSQL or media pod is unavailable.\n' >&2
exit 3
fi
for database in synapse mas; do
k3s kubectl -n "$namespace" exec "$postgres_pod" -- \
pg_dump --username=postgres --format=custom --file="/tmp/$database-$stamp.dump" "$database"
k3s kubectl -n "$namespace" cp \
"$namespace/$postgres_pod:/tmp/$database-$stamp.dump" "$stage/$database.dump"
k3s kubectl -n "$namespace" exec "$postgres_pod" -- rm -f "/tmp/$database-$stamp.dump"
done
k3s kubectl -n "$namespace" exec "$media_pod" -- \
tar --create --gzip --file="/tmp/media-$stamp.tar.gz" --directory=/data media_store
k3s kubectl -n "$namespace" cp \
"$namespace/$media_pod:/tmp/media-$stamp.tar.gz" "$stage/media.tar.gz"
k3s kubectl -n "$namespace" exec "$media_pod" -- rm -f "/tmp/media-$stamp.tar.gz"
tar --create --gzip --file="$stage/bootstrap.tar.gz" --directory="$(dirname "$bootstrap_dir")" "$(basename "$bootstrap_dir")"
sha256sum "$stage"/* > "$stage/SHA256SUMS"
printf '{"created_at":"%s","root_usage_percent":%s,"warning_threshold_percent":%s}\n' \
"$stamp" "$usage" "$warn_percent" > "$stage/manifest.json"
mv "$stage" "$final"
trap - EXIT
find "$backup_root" -mindepth 1 -maxdepth 1 -type d -name '20*Z' -mtime +"$retention_days" -exec rm -rf {} +
printf 'Matrix backup created: %s\n' "$final"
@@ -0,0 +1,10 @@
[Unit]
Description=Run Matrix local backup daily
[Timer]
OnCalendar=*-*-* 03:15:00 UTC
Persistent=true
RandomizedDelaySec=15m
[Install]
WantedBy=timers.target
@@ -0,0 +1,11 @@
---
# A minimal HTTPS placeholder for the explicitly reserved RTC hostname. It also
# validates the Traefik HTTP-01 certificate path without deploying RTC services.
matrix_certificate_smoke_enabled: false
matrix_certificate_smoke_namespace: matrix-system
matrix_certificate_smoke_name: mrtc-placeholder
matrix_certificate_smoke_host: mrtc.chans.xyz
matrix_certificate_smoke_ingress_class: traefik
matrix_certificate_smoke_cluster_issuer: letsencrypt-prod
matrix_certificate_smoke_kubeconfig: /etc/rancher/k3s/k3s.yaml
matrix_certificate_smoke_cleanup: false
@@ -0,0 +1,63 @@
---
- name: Require explicit approval before certificate smoke test
ansible.builtin.assert:
that:
- matrix_certificate_smoke_enabled | bool
fail_msg: >-
Refusing certificate smoke test changes. Re-run with
matrix_certificate_smoke_enabled=true after creating the test DNS record.
- name: Render non-sensitive certificate smoke-test resources
ansible.builtin.template:
src: smoke.yaml.j2
dest: "/etc/rancher/k3s/{{ matrix_certificate_smoke_name }}.yaml"
owner: root
group: root
mode: "0600"
- name: Apply certificate smoke-test resources
ansible.builtin.command:
argv:
- k3s
- kubectl
- apply
- -f
- "/etc/rancher/k3s/{{ matrix_certificate_smoke_name }}.yaml"
changed_when: false
- name: Wait for smoke-test deployment
ansible.builtin.command:
argv:
- k3s
- kubectl
- rollout
- status
- "deployment/{{ matrix_certificate_smoke_name }}"
- "--namespace={{ matrix_certificate_smoke_namespace }}"
- --timeout=180s
changed_when: false
- name: Wait for smoke-test certificate
ansible.builtin.command:
argv:
- k3s
- kubectl
- wait
- --for=condition=Ready
- "certificate/{{ matrix_certificate_smoke_name }}-tls"
- "--namespace={{ matrix_certificate_smoke_namespace }}"
- --timeout=10m
changed_when: false
when: not (matrix_certificate_smoke_cleanup | bool)
- name: Remove smoke-test resources after verification
ansible.builtin.command:
argv:
- k3s
- kubectl
- delete
- -f
- "/etc/rancher/k3s/{{ matrix_certificate_smoke_name }}.yaml"
- --ignore-not-found=true
changed_when: false
when: matrix_certificate_smoke_cleanup | bool
@@ -0,0 +1,82 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ matrix_certificate_smoke_name }}-content
namespace: {{ matrix_certificate_smoke_namespace }}
data:
index.html: MatrixRTC is not available yet.
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ matrix_certificate_smoke_name }}
namespace: {{ matrix_certificate_smoke_namespace }}
spec:
replicas: 1
selector:
matchLabels:
app: {{ matrix_certificate_smoke_name }}
template:
metadata:
labels:
app: {{ matrix_certificate_smoke_name }}
spec:
containers:
- name: static
image: nginx:1.27.5-alpine
ports:
- containerPort: 80
volumeMounts:
- name: content
mountPath: /usr/share/nginx/html/index.html
subPath: index.html
readOnly: true
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
cpu: 50m
memory: 64Mi
volumes:
- name: content
configMap:
name: {{ matrix_certificate_smoke_name }}-content
---
apiVersion: v1
kind: Service
metadata:
name: {{ matrix_certificate_smoke_name }}
namespace: {{ matrix_certificate_smoke_namespace }}
spec:
selector:
app: {{ matrix_certificate_smoke_name }}
ports:
- name: http
port: 80
targetPort: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ matrix_certificate_smoke_name }}
namespace: {{ matrix_certificate_smoke_namespace }}
annotations:
cert-manager.io/cluster-issuer: {{ matrix_certificate_smoke_cluster_issuer }}
spec:
ingressClassName: {{ matrix_certificate_smoke_ingress_class }}
tls:
- hosts:
- {{ matrix_certificate_smoke_host }}
secretName: {{ matrix_certificate_smoke_name }}-tls
rules:
- host: {{ matrix_certificate_smoke_host }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ matrix_certificate_smoke_name }}
port:
name: http
@@ -0,0 +1,15 @@
---
# These resources are intentionally chart-independent and secret-free.
matrix_namespace: matrix-system
matrix_resource_quota:
requests.cpu: "4"
requests.memory: 6Gi
limits.cpu: "6"
limits.memory: 7Gi
persistentvolumeclaims: "4"
matrix_limit_range:
defaultRequest:
cpu: 50m
memory: 128Mi
default:
memory: 1Gi
@@ -0,0 +1,24 @@
---
- name: Require a working K3s kubeconfig
ansible.builtin.stat:
path: /etc/rancher/k3s/k3s.yaml
register: matrix_cluster_kubeconfig
- name: Assert K3s is ready before creating cluster resources
ansible.builtin.assert:
that:
- matrix_cluster_kubeconfig.stat.exists
fail_msg: Run k3s-server.yml successfully before matrix-cluster-base.yml.
- name: Install Matrix cluster base manifest
ansible.builtin.template:
src: base-resources.yaml.j2
dest: /etc/rancher/k3s/matrix-cluster-base.yaml
owner: root
group: root
mode: "0600"
- name: Apply Matrix cluster base manifest
ansible.builtin.command:
argv: [k3s, kubectl, apply, -f, /etc/rancher/k3s/matrix-cluster-base.yaml]
changed_when: false
@@ -0,0 +1,26 @@
apiVersion: v1
kind: Namespace
metadata:
name: {{ matrix_namespace }}
---
apiVersion: v1
kind: ResourceQuota
metadata:
name: matrix-system-quota
namespace: {{ matrix_namespace }}
spec:
hard:
{{ matrix_resource_quota | to_nice_yaml(indent=4) | indent(4, true) }}
---
apiVersion: v1
kind: LimitRange
metadata:
name: matrix-system-defaults
namespace: {{ matrix_namespace }}
spec:
limits:
- type: Container
defaultRequest:
{{ matrix_limit_range.defaultRequest | to_nice_yaml(indent=8) | indent(8, true) }}
default:
{{ matrix_limit_range.default | to_nice_yaml(indent=8) | indent(8, true) }}
@@ -0,0 +1,13 @@
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-prod
spec:
acme:
server: https://acme-v02.api.letsencrypt.org/directory
privateKeySecretRef:
name: letsencrypt-prod-private-key
solvers:
- http01:
ingress:
class: traefik
@@ -0,0 +1,13 @@
---
matrix_healthcheck_enabled: false
matrix_healthcheck_script_path: /usr/local/lib/vps-health/matrix-k3s
matrix_healthcheck_state_path: /var/lib/vps-health/matrix-k3s.json
matrix_healthcheck_service_name: matrix-k3s-healthcheck.service
matrix_healthcheck_timer_name: matrix-k3s-healthcheck.timer
matrix_healthcheck_timer_on_calendar: '*-*-* 06:00:00 UTC'
matrix_healthcheck_timer_randomized_delay_sec: 15m
matrix_healthcheck_namespace: matrix-system
matrix_healthcheck_backup_path: /var/backups/matrix
matrix_healthcheck_backup_max_age_hours: 30
matrix_healthcheck_warn_percent: 80
matrix_healthcheck_critical_percent: 90
@@ -0,0 +1,53 @@
---
- name: Require explicit approval before installing Matrix health checks
ansible.builtin.assert:
that:
- matrix_healthcheck_enabled | bool
fail_msg: Set matrix_healthcheck_enabled=true only after the Matrix namespace and backup job exist.
- name: Create Matrix healthcheck script directory
ansible.builtin.file:
path: "{{ matrix_healthcheck_script_path | dirname }}"
state: directory
owner: root
group: root
mode: "0755"
- name: Create Matrix healthcheck state directory
ansible.builtin.file:
path: "{{ matrix_healthcheck_state_path | dirname }}"
state: directory
owner: root
group: root
mode: "0750"
- name: Install Matrix K3s healthcheck script
ansible.builtin.template:
src: matrix-k3s.sh.j2
dest: "{{ matrix_healthcheck_script_path }}"
owner: root
group: root
mode: "0750"
- name: Install Matrix K3s healthcheck systemd service
ansible.builtin.template:
src: matrix-k3s.service.j2
dest: "/etc/systemd/system/{{ matrix_healthcheck_service_name }}"
owner: root
group: root
mode: "0644"
- name: Install Matrix K3s healthcheck systemd timer
ansible.builtin.template:
src: matrix-k3s.timer.j2
dest: "/etc/systemd/system/{{ matrix_healthcheck_timer_name }}"
owner: root
group: root
mode: "0644"
- name: Enable Matrix K3s healthcheck timer
ansible.builtin.systemd:
name: "{{ matrix_healthcheck_timer_name }}"
enabled: true
state: started
daemon_reload: true
@@ -0,0 +1,16 @@
[Unit]
Description=Read-only Matrix K3s health check
Wants=network-online.target
After=network-online.target k3s.service
[Service]
Type=oneshot
User=root
Group=root
UMask=0027
ExecStart={{ matrix_healthcheck_script_path }}
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=full
ReadWritePaths={{ matrix_healthcheck_state_path | dirname }}
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
set -euo pipefail
namespace='{{ matrix_healthcheck_namespace }}'
backup_root='{{ matrix_healthcheck_backup_path }}'
max_backup_age_seconds=$(({{ matrix_healthcheck_backup_max_age_hours }} * 3600))
warn_percent='{{ matrix_healthcheck_warn_percent }}'
critical_percent='{{ matrix_healthcheck_critical_percent }}'
status=ok
messages=()
set_status() {
local next="$1"
case "$next" in
critical) status=critical ;;
warning) [[ "$status" != critical ]] && status=warning ;;
esac
}
if ! k3s kubectl get namespace "$namespace" >/dev/null 2>&1; then
set_status critical
messages+=("namespace $namespace is unavailable")
else
unready=$(k3s kubectl -n "$namespace" get pods --no-headers 2>/dev/null | awk '$2 !~ /^[0-9]+\/[0-9]+$/ || $3 != "Running" {print $1}')
if [[ -n "$unready" ]]; then
set_status critical
messages+=("unready pods: ${unready//$'\n'/, }")
fi
fi
usage=$(df -P / | awk 'NR == 2 {gsub(/%/, "", $5); print $5}')
if (( usage >= critical_percent )); then
set_status critical
messages+=("root filesystem usage ${usage}%")
elif (( usage >= warn_percent )); then
set_status warning
messages+=("root filesystem usage ${usage}%")
fi
latest=$(find "$backup_root" -mindepth 1 -maxdepth 1 -type d -name '20*Z' -printf '%T@ %p\n' 2>/dev/null | sort -nr | awk 'NR == 1 {print $1, $2}')
if [[ -z "$latest" ]]; then
set_status critical
messages+=("no Matrix backup exists")
else
latest_epoch=${latest%% *}
latest_path=${latest#* }
age=$(( $(date +%s) - ${latest_epoch%.*} ))
if (( age > max_backup_age_seconds )); then
set_status critical
messages+=("latest Matrix backup is ${age}s old")
elif [[ ! -f "$latest_path/SHA256SUMS" || ! -f "$latest_path/manifest.json" ]]; then
set_status critical
messages+=("latest Matrix backup is incomplete")
fi
fi
printf '{"service":"matrix_k3s","status":"%s","messages":[' "$status"
for i in "${!messages[@]}"; do
(( i > 0 )) && printf ','
printf '"%s"' "${messages[$i]//\"/\\\"}"
done
printf '],"root_usage_percent":%s}\n' "$usage"
@@ -0,0 +1,11 @@
[Unit]
Description=Run Matrix K3s health check daily
[Timer]
OnCalendar={{ matrix_healthcheck_timer_on_calendar }}
Persistent=true
RandomizedDelaySec={{ matrix_healthcheck_timer_randomized_delay_sec }}
Unit={{ matrix_healthcheck_service_name }}
[Install]
WantedBy=timers.target
@@ -0,0 +1,21 @@
---
matrix_namespace: matrix-system
matrix_bootstrap_dir: /etc/matrix-bootstrap
# This role validates only names, keys, types, ownership and modes. It never reads
# bootstrap values or emits Kubernetes Secret contents.
matrix_required_bootstrap_files:
- smtp-password
- synapse-db-password
- mas-db-password
- synapse-signing-key
- synapse-macaroon-secret
- synapse-form-secret
- mas-encryption-secret
- mas-signing-key
matrix_required_kubernetes_secrets:
- name: matrix-synapse-master-config
keys: [homeserver.yaml, log.config, signing.key]
- name: matrix-authentication-config
keys: [mas-config.yaml]
- name: matrix-pgbouncer-userlist
keys: [userlist.txt]
@@ -0,0 +1,61 @@
---
- name: Inspect Matrix bootstrap directory without reading secrets
ansible.builtin.stat:
path: "{{ matrix_bootstrap_dir }}"
register: matrix_secret_bootstrap_dir
- name: Assert Matrix bootstrap directory is root-only
ansible.builtin.assert:
that:
- matrix_secret_bootstrap_dir.stat.exists
- matrix_secret_bootstrap_dir.stat.isdir
- matrix_secret_bootstrap_dir.stat.pw_name == 'root'
- matrix_secret_bootstrap_dir.stat.gr_name == 'root'
- matrix_secret_bootstrap_dir.stat.mode == '0700'
fail_msg: "{{ matrix_bootstrap_dir }} must be a root:root 0700 directory."
- name: Inspect required individual bootstrap secret files without reading them
ansible.builtin.stat:
path: "{{ matrix_bootstrap_dir }}/{{ item }}"
loop: "{{ matrix_required_bootstrap_files }}"
register: matrix_secret_bootstrap_files
no_log: true
- name: Assert required bootstrap secret file permissions
ansible.builtin.assert:
that:
- item.stat.exists
- item.stat.isreg
- item.stat.pw_name == 'root'
- item.stat.gr_name == 'root'
- item.stat.mode == '0600'
fail_msg: A required root-only Matrix bootstrap file is missing or has unsafe permissions.
loop: "{{ matrix_secret_bootstrap_files.results }}"
no_log: true
- name: Inspect Kubernetes Secret metadata without retrieving values
ansible.builtin.command:
argv:
- k3s
- kubectl
- describe
- secret
- "{{ item.name }}"
- --namespace={{ matrix_namespace }}
changed_when: false
loop: "{{ matrix_required_kubernetes_secrets }}"
register: matrix_secret_kubernetes_metadata
no_log: true
- name: Assert required Kubernetes Secret keys exist
ansible.builtin.assert:
that:
- item.stdout is search('(?m)^' ~ key ~ ':')
fail_msg: Required Matrix Kubernetes Secret key is missing.
loop: "{{ matrix_secret_kubernetes_metadata.results | subelements('item.keys') }}"
loop_control:
loop_var: matrix_secret_key_check
vars:
item: "{{ matrix_secret_key_check.0 }}"
key: "{{ matrix_secret_key_check.1 }}"
no_log: true
@@ -0,0 +1,17 @@
---
# Matrix Stack deployment using the official ESS OCI chart
# All sensitive values are handled by the chart's built-in initSecrets
# or provided via pre-created Kubernetes Secrets.
matrix_stack_enabled: false
matrix_namespace: ess
matrix_stack_chart_ref: oci://ghcr.io/element-hq/ess-helm/matrix-stack
matrix_stack_chart_version: 26.7.2
matrix_stack_release_name: ess
# Root-only, non-secret values directory on the target host
matrix_stack_values_dir: /etc/ess
# Individual values files (must not contain secrets)
matrix_stack_values_files:
- hostnames.yaml
- tls.yaml
- single-node.yaml
+97
View File
@@ -0,0 +1,97 @@
---
- name: Require explicit approval before deploying the Matrix stack
ansible.builtin.assert:
that:
- matrix_stack_enabled | bool
fail_msg: >-
Refusing Matrix deployment until matrix_stack_enabled=true is set deliberately.
- name: Require Helm on the Matrix host
ansible.builtin.command:
argv: [helm, version, --short]
changed_when: false
- name: Require non-secret values directory exists on the host
ansible.builtin.stat:
path: "{{ matrix_stack_values_dir }}"
register: _values_dir
- name: Assert values directory exists and is root-owned
ansible.builtin.assert:
that:
- _values_dir.stat.exists
- _values_dir.stat.isdir
- _values_dir.stat.pw_name == 'root'
- _values_dir.stat.gr_name == 'root'
- _values_dir.stat.mode == '0700'
fail_msg: >-
Create the root-owned directory {{ matrix_stack_values_dir }}
with mode 0700 and place the non-secret values files in it.
- name: Require each non-secret values file exists on the host
ansible.builtin.stat:
path: "{{ matrix_stack_values_dir }}/{{ item }}"
loop: "{{ matrix_stack_values_files }}"
register: _values_files
- name: Assert all values files exist and are root-owned
ansible.builtin.assert:
that:
- item.stat.exists
- item.stat.isreg
- item.stat.pw_name == 'root'
- item.stat.gr_name == 'root'
- item.stat.mode in ['0600', '0640']
fail_msg: >-
Values file {{ item.stat.path }} must be root-owned with restricted
permissions (0600 or 0640) and must not contain secrets.
loop: "{{ _values_files.results }}"
loop_control:
label: "{{ item.stat.path | default(item.item) }}"
- name: Build helm value arguments
ansible.builtin.set_fact:
_helm_values_args: >-
{%- for f in matrix_stack_values_files -%}
--values {{ matrix_stack_values_dir }}/{{ f }} {% endfor -%}
- name: Render the ESS chart without applying it (dry-run validation)
ansible.builtin.command:
cmd: >-
helm template {{ matrix_stack_release_name }}
{{ matrix_stack_chart_ref }}
--version {{ matrix_stack_chart_version }}
--namespace {{ matrix_namespace }}
--create-namespace
{{ _helm_values_args }}
environment:
KUBECONFIG: "{{ k3s_kubeconfig_path | default('/etc/rancher/k3s/k3s.yaml') }}"
changed_when: false
register: _chart_render
# No secrets in values, but render output may contain initSecrets-generated placeholders
- name: Verify the chart renders without errors
ansible.builtin.assert:
that:
- _chart_render.rc == 0
- _chart_render.stdout | length > 0
fail_msg: >-
Helm template rendering failed. Check values files for syntax errors.
Output: {{ _chart_render.stderr | default('(none)') }}
- name: Deploy the ESS chart via Helm upgrade --install
ansible.builtin.command:
cmd: >-
helm upgrade --install {{ matrix_stack_release_name }}
{{ matrix_stack_chart_ref }}
--version {{ matrix_stack_chart_version }}
--namespace {{ matrix_namespace }}
--create-namespace
{{ _helm_values_args }}
--wait
--timeout 15m
environment:
KUBECONFIG: "{{ k3s_kubeconfig_path | default('/etc/rancher/k3s/k3s.yaml') }}"
changed_when: true
register: _helm_deploy
# initSecrets may generate passwords at deploy time; those stay in-cluster only
+21
View File
@@ -0,0 +1,21 @@
---
restic_enabled: false
restic_binary: /usr/bin/restic
restic_config_path: /etc/vps-restic/repository.env
restic_state_dir: /var/lib/vps-restic
restic_log_dir: /var/log/vps-restic
restic_backup_on_calendar: '*-*-* 04:30:00'
restic_check_on_calendar: 'Sun *-*-* 05:30:00'
restic_forget_on_calendar: 'Sat *-*-* 05:30:00'
restic_keep_daily: 7
restic_keep_weekly: 4
restic_keep_monthly: 6
restic_sources:
vaultwarden:
- /opt/vaultwarden/backups
- /opt/vaultwarden/vw-data
pdns:
- /opt/pdns/backup
- /opt/pdns/auth/pdns.conf
# Mailcow is deliberately excluded pending its official consistency and restore
# design review. No repository/backend value is supplied by this project.
+4
View File
@@ -0,0 +1,4 @@
---
- name: Reload systemd
ansible.builtin.systemd_service:
daemon_reload: true
+97
View File
@@ -0,0 +1,97 @@
---
- name: Require explicit Restic opt-in
ansible.builtin.assert:
that:
- restic_enabled | bool
fail_msg: >-
Restic is disabled by default. Set restic_enabled=true and provision the
repository configuration only on the target host after backend approval.
- name: Validate supported Restic source profile
ansible.builtin.assert:
that:
- healthcheck_profile in restic_sources
fail_msg: "No approved Restic source profile for {{ healthcheck_profile }}."
- name: Verify Restic binary exists on target
ansible.builtin.stat:
path: "{{ restic_binary }}"
register: restic_binary_stat
- name: Require target-side Restic binary
ansible.builtin.assert:
that: restic_binary_stat.stat.exists
fail_msg: "Install Restic through an approved host maintenance change first."
- name: Verify target-side repository configuration exists
ansible.builtin.stat:
path: "{{ restic_config_path }}"
register: restic_config_stat
- name: Require root-only repository configuration
ansible.builtin.assert:
that:
- restic_config_stat.stat.exists
- restic_config_stat.stat.mode == '0600'
fail_msg: >-
Provision {{ restic_config_path }} directly on the host with mode 0600.
It must contain RESTIC_REPOSITORY, RESTIC_PASSWORD_FILE, and any backend
credentials; do not commit or pass them via Ansible.
- name: Install Restic state and log directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0750"
loop:
- "{{ restic_state_dir }}"
- "{{ restic_log_dir }}"
- name: Install Restic script directory
ansible.builtin.file:
path: /usr/local/lib/vps-restic
state: directory
owner: root
group: root
mode: "0755"
- name: Install Restic scripts
ansible.builtin.template:
src: "{{ item }}.j2"
dest: "/usr/local/lib/vps-restic/{{ item }}"
owner: root
group: root
mode: "0750"
loop:
- backup
- check
- forget-prune
- name: Install Restic systemd units and timers
ansible.builtin.template:
src: "{{ item }}.j2"
dest: "/etc/systemd/system/{{ item }}"
owner: root
group: root
mode: "0644"
loop:
- vps-restic-backup.service
- vps-restic-backup.timer
- vps-restic-check.service
- vps-restic-check.timer
- vps-restic-forget-prune.service
- vps-restic-forget-prune.timer
notify: Reload systemd
- name: Enable Restic timers
ansible.builtin.systemd_service:
name: "{{ item }}"
enabled: true
state: started
daemon_reload: true
loop:
- vps-restic-backup.timer
- vps-restic-check.timer
- vps-restic-forget-prune.timer
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
# Repository and password credentials are host-local in {{ restic_config_path }}.
# shellcheck source=/dev/null
source '{{ restic_config_path }}'
exec '{{ restic_binary }}' backup --tag '{{ healthcheck_profile }}' --tag "$(hostname -s)" {% for source in restic_sources[healthcheck_profile] %}{{ source | quote }} {% endfor %}
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
# shellcheck source=/dev/null
source '{{ restic_config_path }}'
exec '{{ restic_binary }}' check --read-data-subset=5%
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
# shellcheck source=/dev/null
source '{{ restic_config_path }}'
exec '{{ restic_binary }}' forget --prune --keep-daily {{ restic_keep_daily }} --keep-weekly {{ restic_keep_weekly }} --keep-monthly {{ restic_keep_monthly }} --tag '{{ healthcheck_profile }}'
@@ -0,0 +1,11 @@
[Unit]
Description=Restic backup for approved {{ healthcheck_profile }} sources
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=root
Group=root
UMask=0077
ExecStart=/usr/local/lib/vps-restic/backup
@@ -0,0 +1,11 @@
[Unit]
Description=Daily Restic backup timer
[Timer]
OnCalendar={{ restic_backup_on_calendar }}
Persistent=true
RandomizedDelaySec=20m
Unit=vps-restic-backup.service
[Install]
WantedBy=timers.target
@@ -0,0 +1,9 @@
[Unit]
Description=Restic repository integrity check
[Service]
Type=oneshot
User=root
Group=root
UMask=0077
ExecStart=/usr/local/lib/vps-restic/check
@@ -0,0 +1,11 @@
[Unit]
Description=Weekly Restic integrity check timer
[Timer]
OnCalendar={{ restic_check_on_calendar }}
Persistent=true
RandomizedDelaySec=30m
Unit=vps-restic-check.service
[Install]
WantedBy=timers.target
@@ -0,0 +1,9 @@
[Unit]
Description=Restic retention and prune
[Service]
Type=oneshot
User=root
Group=root
UMask=0077
ExecStart=/usr/local/lib/vps-restic/forget-prune
@@ -0,0 +1,11 @@
[Unit]
Description=Weekly Restic retention timer
[Timer]
OnCalendar={{ restic_forget_on_calendar }}
Persistent=true
RandomizedDelaySec=30m
Unit=vps-restic-forget-prune.service
[Install]
WantedBy=timers.target
+109
View File
@@ -0,0 +1,109 @@
# Matrix (Element Server Suite) Upstream References
Matrix homeserver deployed via **Element Server Suite (ESS) Community** OCI chart.
## Chart
| Property | Value |
|---|---|
| Chart name | `matrix-stack` |
| OCI registry | `oci://ghcr.io/element-hq/ess-helm/matrix-stack` |
| Version deployed | `26.7.2` |
| Chart source | <https://github.com/element-hq/ess-helm> |
| Documentation | <https://element-hq.github.io/ess-helm/> |
## Components
| Component | Docker image | Role |
|---|---|---|
| Synapse | `ghcr.io/element-hq/synapse:*` | Matrix homeserver |
| MAS | `ghcr.io/element-hq/matrix-authentication-service:*` | OIDC/OAuth authentication |
| Element Web | `ghcr.io/element-hq/element-web:*` | Web client |
| Element Admin | `ghcr.io/element-hq/element-admin:*` | Admin console |
| HAProxy | `haproxy:*` | Internal routing/health |
| PostgreSQL | `postgres:17` | Synapse + MAS database |
All images should be locked via digest in production; the chart's `appVersion` may not match default image tags.
## Domain configuration
| Hostname | Component |
|---|---|
| `chans.xyz` | Matrix identity (`serverName`) + well-known discovery |
| `synapse.chans.xyz` | Client-Server API, Federation API |
| `chat.chans.xyz` | Element Web |
| `account.chans.xyz` | Matrix Authentication Service (local passwords) |
| `admin.chans.xyz` | Element Admin (MAS admin auth) |
| `mrtc.chans.xyz` | Reserved for MatrixRTC — **not deployed** |
## TLS & Ingress
- Ingress controller: K3s built-in Traefik (IngressClass `traefik`)
- Certificate issuer: cert-manager + Let's Encrypt HTTP-01 (`letsencrypt-prod` ClusterIssuer)
- All TLS enabled: `global.ingress.tls.enabled=true`
- Issuer annotation: `cert-manager.io/cluster-issuer: letsencrypt-prod`
## Architecture
```
Internet :80/:443
→ K3s Traefik (hostPort)
→ ESS Ingresses (Traefik class)
→ ClusterIP Services
→ Pods (Synapse, MAS, Element, Admin, HAProxy)
→ PostgreSQL (StatefulSet, 20Gi PVC)
```
## Deployment
The ESS chart is deployed via Ansible (`ansible/playbooks/matrix-stack.yml`) with non-secret values at `/etc/ess/*.yaml` on the target host.
**Secrets** (signing key, database passwords, SMTP password, MAS keys) are **not** in Git. They are stored:
1. On the VPS at `/etc/matrix-bootstrap/` (root:root 0600 files)
2. In pre-created Kubernetes Secrets in the `ess` namespace
## Upgrades
```bash
# Pull latest chart version
helm pull oci://ghcr.io/element-hq/ess-helm/matrix-stack --version <new-version>
# Review changes
helm diff upgrade ess oci://ghcr.io/element-hq/ess-helm/matrix-stack \
-n ess -f /etc/ess/hostnames.yaml -f /etc/ess/tls.yaml
# Apply
helm upgrade --install ess oci://ghcr.io/element-hq/ess-helm/matrix-stack \
-n ess -f /etc/ess/hostnames.yaml -f /etc/ess/tls.yaml --wait
```
Before upgrading:
- Matrix backup automation is currently paused; establish and test a reviewed backup design before treating the deployment as recoverable.
- Review chart changelog and database migration notes
- Test in isolation if possible
## Recovery
When the planned backup automation is enabled, recovery will require the following from `/var/backups/matrix/`:
1. PostgreSQL dumps (synapse + mas)
2. Media store archive
3. Bootstrap secrets archive (`/etc/matrix-bootstrap/`)
4. Non-secret values files (`/etc/ess/*.yaml`)
5. Non-secret playbook templates (from this repo)
Steps:
1. Deploy K3s + Traefik + cert-manager per Ansible
2. Restore bootstrap secrets + create Kubernetes Secrets
3. Restore PostgreSQL dumps
4. Restore media store to PVC
5. Deploy ESS chart with same values
## References
- ESS Helm docs: <https://element-hq.github.io/ess-helm/>
- ESS Community setup: <https://github.com/element-hq/element-server-suite>
- Synapse: <https://github.com/element-hq/synapse>
- Matrix Authentication Service: <https://github.com/element-hq/matrix-authentication-service>
- Matrix spec: <https://spec.matrix.org/>
- Federation tester: <https://federationtester.matrix.org/>
+81
View File
@@ -0,0 +1,81 @@
# PowerDNS Authoritative — upstream documentation
Use these when changing `/opt/pdns` on [hk2](../hosts/hk2.chans.xyz.md). Prefer official docs over blog posts.
## Primary references
| Topic | URL |
|-------|-----|
| Settings reference | https://doc.powerdns.com/authoritative/settings.html |
| Primary / secondary modes | https://doc.powerdns.com/authoritative/modes-of-operation.html |
| Generic PostgreSQL backend | https://doc.powerdns.com/authoritative/backends/generic-postgresql.html |
| DNSSEC ops + SOA-EDIT | https://doc.powerdns.com/authoritative/dnssec/operational.html |
| DNSSEC advice | https://doc.powerdns.com/authoritative/dnssec/advice.html |
| TSIG | https://doc.powerdns.com/authoritative/tsig.html |
| HTTP API | https://doc.powerdns.com/authoritative/http-api/ |
| Security advisories | https://doc.powerdns.com/authoritative/security-advisories/ |
| Docker image (`pdns-auth-50`) | https://hub.docker.com/r/powerdns/pdns-auth-50 |
## Settings we care about on hk2 (ns1)
| Concern | Upstream guidance | Our notes (post-2026-08-01) |
|---------|-------------------|-----------------------------|
| Version | Track advisories; upgrade when Mandatory | Pinned **`powerdns/pdns-auth-50:5.0.6`** (clears 2026-06 + 2026-07) |
| Primary | `primary=yes`; zones type MASTER/PRIMARY | Set; zones `windy.me` / `wsvc.info` / `chans.xyz` are Master |
| NOTIFY | `also-notify` always notified; empty `only-notify` disables NS-based NOTIFY | `also-notify=202.91.35.141`, `only-notify=` |
| AXFR IP ACL | `allow-axfr-ips` limits **unsigned** AXFR | Global + per-zone `ALLOW-AXFR-FROM` → ns2 only |
| TSIG AXFR | **Any host with the TSIG key may AXFR**, bypassing IP ACL | Zones use `TSIG-ALLOW-AXFR=mykey.` — rotate/rename key; protect secret |
| DNSSEC | `gpgsql-dnssec=yes` + keys via `pdnsutil` | CSK ECDSAP256SHA256 on all three zones |
| SOA-EDIT | Needed so non-PDNS secondaries see signature rolls; PDNS secondaries can also compare RRSIGs | `default-soa-edit(-signed)=INCEPTION-INCREMENT` OK (ns2 is PowerDNS). Dig SOA ≠ backend serial is expected |
| API key | Prefer `pdnsutil hash-password` (plaintext still accepted) | **Deferred hardening:** live key is weak, plaintext, reused with DB password — rotate |
| Webserver | Prefer trusted clients only | Host publishes `127.0.0.1:8081`; `webserver-allow-from` private ranges |
| `gpgsql-host` | Prefer IP to avoid resolve chicken/egg | `db` (Compose DNS) is fine inside the stack |
| `domains.master` | Used for SECONDARY upstreams | Cleared on MASTER zones (was self-IP noise) |
## Intended `auth/pdns.conf` shape (no secrets)
Live file: `/opt/pdns/auth/pdns.conf`. Secrets come from `auth/templates.d/secrets.j2``/etc/powerdns/pdns.d/secrets.conf` at container start.
```
local-address=0.0.0.0
local-port=53
launch=gpgsql
gpgsql-host=db
gpgsql-dnssec=yes
include-dir=/etc/powerdns/pdns.d
allow-axfr-ips=202.91.35.141
also-notify=202.91.35.141
only-notify=
primary=yes
secondary=no
api=yes
webserver=yes
webserver-address=0.0.0.0
webserver-port=8081
webserver-allow-from=127.0.0.1,172.16.0.0/12,10.0.0.0/8,192.168.0.0/16
version-string=anonymous
disable-syslog=yes
loglevel=4
default-soa-edit=INCEPTION-INCREMENT
default-soa-edit-signed=INCEPTION-INCREMENT
disable-axfr=no
```
Drop-in (from `.env`, never commit): `gpgsql-dbname`, `gpgsql-user`, `gpgsql-password`, `api-key`.
## Deferred hardening (not done yet)
1. Rotate `PDNS_API_KEY` to a long random value; store hashed via `pdnsutil hash-password` if desired; update Poweradmin `.env` / recreate `auth` + `poweradmin`.
2. Rotate `DB_PASS` / Postgres role separately from the API key.
3. Rotate TSIG `mykey` (or rename); confirm ns2 still AXFRs; remember TSIG bypasses `allow-axfr-ips`.
## Related ops docs
- Host facts: [hosts/hk2.chans.xyz.md](../hosts/hk2.chans.xyz.md)
- Health runbook: [runbooks/pdns-health.md](../runbooks/pdns-health.md)
+53
View File
@@ -0,0 +1,53 @@
# Vaultwarden — upstream documentation (source of truth)
Use these when changing `/opt/vaultwarden` on [us2](../hosts/us2.wsvc.info.md). Prefer upstream over blog posts or Obsidian notes.
## Primary references
| Topic | URL |
|-------|-----|
| Configuration overview (methods + precedence) | https://github.com/dani-garcia/vaultwarden/wiki/Configuration-overview |
| Env / options template (canonical variable list) | https://github.com/dani-garcia/vaultwarden/blob/main/.env.template |
| Defaults / implementation source | https://github.com/dani-garcia/vaultwarden/blob/main/src/config.rs |
| PostgreSQL backend + SQLite migration | https://github.com/dani-garcia/vaultwarden/wiki/Using-the-PostgreSQL-Backend |
| SMTP | https://github.com/dani-garcia/vaultwarden/wiki/SMTP-configuration |
| Reverse proxy examples (incl. Traefik) | https://github.com/dani-garcia/vaultwarden/wiki/Proxy-examples |
| Enabling admin page / `ADMIN_TOKEN` | https://github.com/dani-garcia/vaultwarden/wiki/Enabling-admin-page |
| Disable registration / invitations | https://github.com/dani-garcia/vaultwarden/wiki/Disable-registration-of-new-users |
| pgloader BIGINT login failure note | https://github.com/dani-garcia/vaultwarden/discussions/7134 |
| Project wiki home | https://github.com/dani-garcia/vaultwarden/wiki |
| Container image | https://github.com/dani-garcia/vaultwarden/pkgs/container/server |
## Configuration precedence (official)
From [Configuration overview](https://github.com/dani-garcia/vaultwarden/wiki/Configuration-overview):
1. Compile-time defaults (`src/config.rs`)
2. Vaultwarden `ENV_FILE` / `.env` read by the process (if used)
3. Process environment variables (e.g. Docker Compose `env_file` + `environment`) — override (2)
4. **`data/config.json` from `/admin` — highest precedence** (overrides env)
Official stance: prefer environment variables; `config.json` is **not** the recommended long-term method. Once admin saves `config.json`, those keys stop being changeable via `.env` until removed/edited in admin or the file.
## Settings we care about on us2
| Concern | Upstream guidance | Our notes (post-2026-08-01 migrate) |
|---------|-------------------|-------------------------------------|
| Public URL | Set `DOMAIN` e.g. `https://auth.wsvc.info` (no trailing slash) | Set in compose + `config.json` |
| SMTP | `587`+`starttls` or `465`+`force_tls` | Effective: **587/starttls** to mx2 (`465` unreachable from us2); `extra_hosts` pins A `194.163.160.244` |
| SMTP password | Same mailbox password as mailcow | Must match across `.env`, `config.json`, `.smtp-credentials`. **Admin/`config.json` wins** — stale admin password → SMTP `535` while `.env` still works |
| Database | `DATABASE_URL` for Postgres | Live: Postgres via compose `DATABASE_URL`; sqlite cold under `backups/sqlite-cold/` |
| Client IP behind proxy | Traefik → prefer `X-Forwarded-For` | `ip_header=X-Forwarded-For` in `config.json` |
| Admin | Argon2 `ADMIN_TOKEN` | On server only |
| Signups | Invite-only on public URL | `signups_allowed=false` |
## Agent checklist before edits
1. Read this page + [us2 host facts](../hosts/us2.wsvc.info.md).
2. On the server, compare `.env`, compose `environment`, and `/data/config.json` (including SMTP password fingerprint, not plaintext in chat/logs).
3. Change the layer that actually wins (often `config.json`); if rotating the mailbox password, update **all three** secret stores.
4. After recreating the `vaultwarden` container, restart Traefik if `auth.wsvc.info` 404s.
5. Do not commit secrets; do not copy passwords from Obsidian into this repo.
6. Verify SMTP with AUTH (not TCP-only) — [vaultwarden-health](../runbooks/vaultwarden-health.md) §5.
Replay migration steps: [runbooks/vaultwarden-sqlite-to-postgres.md](../runbooks/vaultwarden-sqlite-to-postgres.md).
+138
View File
@@ -0,0 +1,138 @@
# hk2.chans.xyz
| Item | Value |
|------|--------|
| Role | PowerDNS Authoritative (ns1) + Poweradmin / Traefik |
| SSH | `ssh -4 windy@hk2.chans.xyz` (prefer IPv4 from WSL) |
| IPv4 | `154.36.174.161` |
| Hostname (OS) | `C20210714091792` (Debian 13 trixie) |
| Also DNS | `ns1.wsvc.info` → this host |
| Secondary NS | `ns2.wsvc.info``202.91.35.141` (`us1.wsvc.info`, PowerDNS Auth **5.0.5**) |
| Install path | `/opt/pdns` |
| Compose | `/opt/pdns/compose.yml` |
| Env file | `/opt/pdns/.env` (secrets — **never commit**) |
| TZ | `Asia/Shanghai` |
Upstream docs / config checklist: [docs/pdns-upstream.md](../docs/pdns-upstream.md)
## PowerDNS Authoritative
**Status: operational** (auth + db healthy, API **5.0.6**, DNS on :53 — last config audit 2026-08-01 21:40 CST).
| Item | Value |
|------|--------|
| Image | `powerdns/pdns-auth-50:5.0.6` (pinned) |
| Container | `pdns-auth` |
| Config | `/opt/pdns/auth/pdns.conf` + `auth/templates.d/secrets.j2``pdns.d/secrets.conf` |
| Backend | PostgreSQL 16 (`launch=gpgsql`, DB name `pdns`) |
| DNS ports | host `53/tcp` + `53/udp` |
| API | host `127.0.0.1:8081` only; `webserver-allow-from` private ranges |
| Mode | `primary=yes`; `also-notify` / AXFR ACL → `202.91.35.141`; `only-notify=` empty |
| DNSSEC | `gpgsql-dnssec=yes`; CSK ECDSAP256SHA256 |
| SOA-EDIT | `default-soa-edit(-signed)=INCEPTION-INCREMENT` |
| Poweradmin | `PDNS_VERSION=50`; Traefik Host(`pdns.wsvc.info`) |
| Advisories | 2026-06 + 2026-07 cleared by 5.0.6 |
### Zones (authoritative)
| Zone | Kind | DNSSEC | Zone metadata |
|------|------|--------|---------------|
| `windy.me` | Master | yes | `ALLOW-AXFR-FROM` + `TSIG-ALLOW-AXFR=mykey.` |
| `wsvc.info` | Master | yes | same |
| `chans.xyz` | Master | yes | same |
NS set: `ns1.wsvc.info` / `ns2.wsvc.info`. Dig SOA may differ from backend serial (SOA-EDIT) — expected.
### Intended auth config (sanitized)
See full shape in [docs/pdns-upstream.md](../docs/pdns-upstream.md). Live secrets only in `.env` / generated drop-in on the server.
### Stack
| Container | Service | Status | Image |
|-----------|---------|--------|-------|
| `pdns-auth` | auth | Up (healthy) | `powerdns/pdns-auth-50:5.0.6` |
| `pdns-db` | db | Up (healthy) | `postgres:16` |
| `poweradmin` | poweradmin | Up (healthy) | `poweradmin/poweradmin:stable` |
| `pdns_pgweb` | pgweb | Up | `sosedoff/pgweb:0.16.2` |
| `pdns-backup` | backup | Up | `postgres:16` (scheduler) |
| `powerdns-admin` | *(orphan)* | Exited | legacy PDA UI — not in active compose |
### Network model
| Network | Purpose |
|---------|---------|
| `backend` | Internal (`internal: true`) — DB + service API |
| `edge` | Host-published DNS `:53` and loopback API `:8081` |
| `frontend` (`traefik`) | External Traefik network for web UIs |
### Public / management URLs
| URL | Backend | Notes |
|-----|---------|-------|
| https://pdns.wsvc.info | `poweradmin` via Traefik | DNS admin UI |
| https://pgweb.wsvc.info | `pgweb` via Traefik | DB browser (HTTP basic) |
| DNS `ns1.wsvc.info:53` | `pdns-auth` | Public authoritative |
## Config audit (vs official docs)
**Functional primary/secondary config: OK** after 5.0.6 upgrade + `only-notify=` / `domains.master` cleanup.
| Item | Verdict |
|------|---------|
| `primary` + MASTER zones + gpgsql DNSSEC | OK |
| `also-notify` + empty `only-notify` | OK (docs-recommended pattern) |
| API bind / host port publish | OK |
| SOA-EDIT with PowerDNS secondary | OK |
| API key / DB password hygiene | **Deferred** — weak, plaintext, reused |
| TSIG `mykey` | **Deferred** — any holder can AXFR (bypasses IP ACL) |
## Backup
| Item | Value |
|------|--------|
| Service | `pdns-backup` |
| Schedule | `CRON_SCHEDULE=0 3 * * *` (03:00 Asia/Shanghai) |
| Retention | `RETENTION_DAYS=7`, `MAX_BACKUPS=3` |
| Output | `/opt/pdns/backup/pdns_*.sql.gz` (+ optional `roles_*.sql`) |
| Scheduler | `scripts/backup-scheduler.sh` (daily `M H * * *` only) |
| Manual | `cd /opt/pdns && ./scripts/manual-backup.sh` |
**Quirk:** `backend` is internal — backup must not use Alpine + runtime `apk`/`crond`. Uses `postgres:16` + `backup-scheduler.sh` (fixed 2026-08-01).
## Other software on this host (stubs)
`/opt/traefik`, `/opt/adguard`, `/opt/remark42`, `/opt/rustdesk`, `/opt/nginx-manager`, …
## Ops / runbooks
- [pdns-health](../runbooks/pdns-health.md)
- [pdns-upstream docs](../docs/pdns-upstream.md)
```bash
ssh -4 windy@hk2.chans.xyz
cd /opt/pdns && docker compose ps -a
docker compose logs -f auth poweradmin backup
./scripts/manual-backup.sh
# after pdns.conf / secrets.j2 / auth env changes:
docker compose up -d --force-recreate auth
```
```bash
dig @154.36.174.161 NS windy.me +short
dig @154.36.174.161 SOA wsvc.info +short
# compare secondary:
dig @202.91.35.141 SOA wsvc.info +short
```
On-server docs: `/opt/pdns/README.md`, `CHANGELOG.md`.
## Verified
Last checked: **2026-08-01 21:40 CST** — operational; docs audit recorded.
- Auth **5.0.6** healthy; no Mandatory security banner
- Zones Master + DNSSEC; SOA in sync with ns2 (served serial)
- `only-notify=` + `also-notify=202.91.35.141`; MASTER `domains.master` cleared
- https://pdns.wsvc.info → **302**; https://pgweb.wsvc.info → **401**
- Hardening backlog: API/DB credential rotation + TSIG rotate (see upstream doc)
+54
View File
@@ -0,0 +1,54 @@
# mx2.windy.me
| Item | Value |
|------|--------|
| Role | Mail server (mailcow) |
| SSH | `ssh -4 windy@mx2.windy.me` (pubkey; root denied; prefer IPv4 from WSL) |
| Install path | `/opt/mail` |
| Compose project | `cow` (`COMPOSE_PROJECT_NAME=cow`) |
| Compose file | `/opt/mail/docker-compose.yml` |
| Config | `/opt/mail/mailcow.conf` (`.env` → symlink) |
| Hostname | `MAILCOW_HOSTNAME=mx2.windy.me` |
| TZ | `Asia/Shanghai` |
| IPv4 | `194.163.160.244` |
| IPv6 | `2a02:c207:2284:8258::1` |
| Docker network | `IPV4_NETWORK=172.22.1` |
| UI | https://mx2.windy.me |
## Ports (host-published)
`25`, `465`, `587` (SMTP), `110`/`995` (POP), `143`/`993` (IMAP), `4190` (Sieve), `80`/`443` (UI)
## Domain & mailboxes
- Domain: `windy.me` (primary on this host)
- MX for `windy.me`: `mx2.windy.me` (prio 20), `mx.windy.me` (prio 30)
- Mailboxes (examples): `zhiqiang@windy.me`, `claw@windy.me`, `hermes@windy.me`
- ACME / Lets Encrypt enabled (`SKIP_LETS_ENCRYPT=n`); cert SAN includes `mx2`, `autoconfig`, `autodiscover`
- PTR (v4/v6) → `mx2.windy.me`
## Ops notes
- Manage with: `cd /opt/mail && docker compose …`
- `SKIP_CLAMD=y` may still leave `clamd` running after updates; recreate if you need it truly skipped
- Host has ~8G RAM, no swap; watch memory under load
- Unrelated stopped containers may exist (`zc-*` monitoring stack) — not part of mailcow
## Client access (SMTP / IMAP)
See [mailcow-smtp-client](../runbooks/mailcow-smtp-client.md).
- Send: `mx2.windy.me:587` STARTTLS (or `:465` SSL), auth = full email + mailbox password
- IMAP: `mx2.windy.me:993` · POP: `:995`
- Webmail: https://mx2.windy.me
## Runbooks
- [mailcow-health](../runbooks/mailcow-health.md)
- [mailcow-update](../runbooks/mailcow-update.md)
- [mailcow-smtp-client](../runbooks/mailcow-smtp-client.md)
- Script: [`../scripts/check-mx2.sh`](../scripts/check-mx2.sh)
## Verified
Last checked: 2026-08-01 — `./scripts/check-mx2.sh` exit 0 (compose up, watchdog 100%, empty queue, TLS LE OK, SMTP Postcow, A/AAAA/PTR/MX/SPF OK).
+100
View File
@@ -0,0 +1,100 @@
# synapse.chans.xyz — Matrix homeserver (ESS)
## Role
Matrix homeserver running **Element Server Suite (ESS) Community**: Synapse + Matrix Authentication Service (MAS) + Element Web + Element Admin.
## SSH
```
ssh -4 windy@synapse.chans.xyz
```
- User: `windy` (sudo via Ansible)
- IPv4: `169.58.86.13`
- OS: Ubuntu 26.04
## Stack
| Layer | Detail |
|-------|--------|
| Runtime | K3s v1.36.2+k3s1 (single-node) |
| Ingress | K3s built-in Traefik (hostPort `:80` / `:443`) |
| TLS | cert-manager v1.19.3 + `letsencrypt-prod` ClusterIssuer (HTTP-01) |
| DB | ESS embedded PostgreSQL 17 (PVC 20Gi, local-path) |
| Cache | ESS embedded Redis (PVC 2Gi) |
| Chart | `oci://ghcr.io/element-hq/ess-helm/matrix-stack`, version `26.7.2` |
### Matrix service endpoints
| URL | Component |
|-----|-----------|
| https://chat.chans.xyz | Element Web |
| https://synapse.chans.xyz | Synapse Client-Server / Federation API |
| https://account.chans.xyz | Matrix Authentication Service |
| https://admin.chans.xyz | Element Admin console |
| https://chans.xyz/.well-known/matrix/server | Federation discovery → `synapse.chans.xyz:443` |
| https://chans.xyz/.well-known/matrix/client | Client discovery → `https://synapse.chans.xyz` |
| `mrtc.chans.xyz` | Reserved for MatrixRTC (not deployed) |
## Public ports
| Port | Protocol | Service |
|------|----------|---------|
| 22/tcp | SSH | OpenSSH |
| 80/tcp | HTTP | Let's Encrypt HTTP-01 / Traefik redirect |
| 443/tcp | HTTPS | Matrix Client-Server, Federation, Element, MAS, Admin |
All other ports internal only (no K3s API, no database, no Redis exposed).
## Namespace layout
- `ess` — all ESS workloads (Synapse, MAS, Element, Postgres, Redis, HAProxy)
- `matrix-system` — cluster base resources (ResourceQuota, LimitRange, mrtc-placeholder)
- `cert-manager` — cert-manager
## Local backup
| Item | Detail |
|------|--------|
| Path | `/var/backups/matrix` (root:root, 0700) |
| Schedule | **Paused**`matrix-backup.timer` is disabled pending a reviewed backup design |
| Retention | 7 days |
| Disk warning | 80% (healthcheck), 90% (backup stops) |
| Content | Planned: PostgreSQL `synapse` + `mas` logical dumps, media store archive, `/etc/matrix-bootstrap` |
| Status | **Not operational** — no current Matrix backup or recovery tier |
## Health checks
Daily via `matrix-k3s-healthcheck.timer`, outputs to `/var/lib/vps-health/latest.json`. Checks:
- K3s API / namespace health
- ESS pod restart counts
- Backup freshness
- Root disk usage
- Certificate expiry
- External HTTPS endpoints
## Bootstrap secrets
Path: `/etc/matrix-bootstrap/` (root:root, 0700) — individual files per secret key (0600). SMTP password, DB passwords, signing key etc. Included in local backup.
## Identity
- **Server name**: `chans.xyz` (user IDs: `@user:chans.xyz`)
- **Deployment type**: New instance (no recovery from previous homeserver)
- **Public registration**: Disabled
- **Authentication**: MAS local passwords
## Ansible
Inventory group: `matrix_production` (but NOT in `managed` which expects Docker Compose).
Playbooks in `ansible/playbooks/matrix-*.yml`.
## Known limitations
- MatrixRTC / Element Call / LiveKit / Coturn not deployed (`mrtc.chans.xyz` reserved only)
- SMTP email not yet configured (requires manual bootstrap + Helm upgrade)
- No off-site Restic backup
- Single-node K3s (no HA for control plane)
+91
View File
@@ -0,0 +1,91 @@
# us2.wsvc.info
| Item | Value |
|------|--------|
| Role | Multi-service VPS (Vaultwarden, Traefik, Soft Serve, …) |
| SSH | `ssh -4 windy@us2.wsvc.info` (prefer IPv4 from WSL) |
| IPv4 | `193.9.44.165` |
| Also DNS | `auth.wsvc.info` → this host; `repo.windy.me` → this host (Soft Serve) |
| Public HTTPS | Traefik on `:80` / `:443` (`/opt/traefik`) |
## Vaultwarden (Bitwarden-compatible)
**Status: operational** (Postgres live, HTTPS 200, healthy containers, SMTP AUTH OK — last probe 2026-08-01 18:55 CST).
Upstream docs: [docs/vaultwarden-upstream.md](../docs/vaultwarden-upstream.md)
| Item | Value |
|------|--------|
| Install path | `/opt/vaultwarden` |
| Compose | `/opt/vaultwarden/docker-compose.yml` |
| Env file | `/opt/vaultwarden/.env` |
| Admin overrides | `/opt/vaultwarden/vw-data/config.json` (**wins over env**) |
| Public URL / `DOMAIN` | `https://auth.wsvc.info` |
| Image | `vaultwarden/server:1.37.1` (pinned) |
| Live DB | **Postgres 16** (`vw-db` / service `pg`) via compose `DATABASE_URL` |
| Data (probe) | users=1, ciphers=1327 |
| Cold SQLite | `backups/sqlite-cold/db.sqlite3.pre-pg-20260801` (not used live) |
| Pre-migrate backup | `backups/pre-pg-migrate-20260801_161204/` |
| Data dir | `./vw-data``/data` (attachments, rsa keys, `config.json`) |
| DB backups | `vaultwarden-backup``pg_dump` daily 02:00 (`scripts/backup.sh`); latest e.g. `backups/vaultwarden_2026-08-01_16-23-03.sql.gz` |
| Reverse proxy | Traefik (`vw-net`) → Host(`auth.wsvc.info`), LE (`auth.wsvc.info`, valid → 2026-10-08) |
| SMTP | `mx2.windy.me:587` STARTTLS (`extra_hosts` → 194.163.160.244); from `zhiqiang@windy.me` |
| SMTP secrets | Keep **`.env`**, **`vw-data/config.json`**, and **`.smtp-credentials`** in sync — `config.json` wins; drift breaks mail |
| IP header | `X-Forwarded-For` |
| Signups | disabled (`signups_allowed=false`); invitations allowed |
| pgweb | compose profile `debug` (stopped by default) |
| Secrets | `.env`, `.admin-token`, `.smtp-credentials`**never commit** |
### Official documentation
| Topic | Link |
|-------|------|
| Config overview | https://github.com/dani-garcia/vaultwarden/wiki/Configuration-overview |
| Postgres / migration | https://github.com/dani-garcia/vaultwarden/wiki/Using-the-PostgreSQL-Backend |
| SMTP | https://github.com/dani-garcia/vaultwarden/wiki/SMTP-configuration |
| Proxy / Traefik | https://github.com/dani-garcia/vaultwarden/wiki/Proxy-examples |
| Full index | [docs/vaultwarden-upstream.md](../docs/vaultwarden-upstream.md) |
### Stack
| Container | Status |
|-----------|--------|
| `vaultwarden` | Up (healthy), `vaultwarden/server:1.37.1` |
| `vw-db` | Up (healthy) — **live** Postgres |
| `vaultwarden-backup` | Up (`pg_dump`) |
| `vaultwarden-pgweb` | Exited (profile `debug`) |
Effective `config.json`: domain `https://auth.wsvc.info`, SMTP 587/starttls to `mx2.windy.me` as `zhiqiang@windy.me`, `ip_header=X-Forwarded-For`. Password matched to `.env` (fixed 2026-08-01).
### Client use
- Server URL: `https://auth.wsvc.info`
- Admin: `https://auth.wsvc.info/admin`
### Ops / runbooks
- [vaultwarden-health](../runbooks/vaultwarden-health.md)
- [vaultwarden-sqlite-to-postgres](../runbooks/vaultwarden-sqlite-to-postgres.md)
- [vaultwarden-upstream docs](../docs/vaultwarden-upstream.md)
```bash
ssh -4 windy@us2.wsvc.info
cd /opt/vaultwarden && docker compose ps -a
# after recreating vaultwarden, if auth 404s:
docker restart traefik
```
## Other software on this host (stubs)
`/opt/traefik`, `soft-serve`, `authelia`, `dendrite`/`conduit`, `mastodon`, `rustdesk`, `zitadel`, etc.
## Verified
Last checked: **2026-08-01 18:55 CST** — operational.
- `vaultwarden` + `vw-db` healthy; `DATABASE_URL``pg:5432/vaultwarden`
- `https://auth.wsvc.info/` **200**, `/admin` **200**, `/api/config` OK (`disableUserRegistration: true`)
- Identity wrong-password → **400** business error (DB readable, not 500)
- SMTP: container → `mx2:587` OK; STARTTLS cert CN=`mx2.windy.me`; **AUTH OK** with effective `config.json` password (synced with `.env` / `.smtp-credentials`)
- LE cert CN=`auth.wsvc.info`
- PG counts: users=1, ciphers=1327
+29
View File
@@ -0,0 +1,29 @@
# Host inventory
| Host | Role | SSH | IPv4 | Status | Facts |
|------|------|-----|------|--------|-------|
| mx2.windy.me | mailcow (primary MX prio 20) | `ssh -4 windy@mx2.windy.me` | 194.163.160.244 | active | [hosts/mx2.windy.me.md](../hosts/mx2.windy.me.md) |
| us2.wsvc.info | Vaultwarden/Postgres (+ Traefik, Soft Serve, …) | `ssh -4 windy@us2.wsvc.info` | 193.9.44.165 | active | [hosts/us2.wsvc.info.md](../hosts/us2.wsvc.info.md) |
| mx.windy.me | mail (secondary MX prio 30) | TBD | see AAAA/A | stub | — |
| repo.windy.me | Soft Serve git (on us2) | `ssh -p 2222 windy@repo.windy.me` | 193.9.44.165 | stub | see us2 |
| auth.wsvc.info | Vaultwarden public hostname | — (HTTPS) | → us2 | active | see us2 |
| us1.wsvc.info | PowerDNS secondary (ns2 host) | TBD | 202.91.35.141 | stub | Auth 5.0.5; see hk2 |
| us4.wsvc.info | Traefik / services | TBD | TBD | stub | — |
| hk2.chans.xyz | PowerDNS auth (ns1) | `ssh -4 windy@hk2.chans.xyz` | 154.36.174.161 | active | [hosts/hk2.chans.xyz.md](../hosts/hk2.chans.xyz.md) |
| ns1.wsvc.info | PowerDNS public NS name | — (DNS) | → hk2 `154.36.174.161` | active | see hk2 |
| ns2.wsvc.info | Secondary NS (AXFR/NOTIFY peer) | — (DNS) | → us1 `202.91.35.141` | active | see hk2 |
| pdns.wsvc.info | Poweradmin UI | — (HTTPS) | → hk2 | active | see hk2 |
| pgweb.wsvc.info | PowerDNS Postgres UI | — (HTTPS) | → hk2 | active | see hk2 |
| **synapse.chans.xyz** | Matrix homeserver (ESS: Synapse + MAS + Element) | `ssh -4 windy@synapse.chans.xyz` | `169.58.86.13` | **active** | [hosts/synapse.chans.xyz.md](../hosts/synapse.chans.xyz.md) |
`status: stub` = known to exist; fill `hosts/<name>.md` when next touched.
### Matrix services (synapse.chans.xyz)
| URL | Service | Notes |
|-----|---------|-------|
| https://chat.chans.xyz | Element Web | Matrix web client |
| https://synapse.chans.xyz | Synapse API | Client-Server + Federation API |
| https://account.chans.xyz | MAS | Matrix Authentication Service (local passwords) |
| https://admin.chans.xyz | Element Admin | Admin console (MAS admin auth) |
| `mrtc.chans.xyz` | MatrixRTC | **Reserved** not deployed |
@@ -0,0 +1,69 @@
# VPS 維護專案重構:Ansible + systemd timers + Restic
## Objective
將現有 VPS 維運知識庫漸進重構為一個以 Ansible 管理一致性、以 systemd timers 執行每日唯讀檢查、以 Restic 建立加密異機備份的個人 SRE 維運專案。第一期僅納管 mx2、us2、hk2;自動化只能觀測與告警,任何更新、重啟、修復、DNS 或機密變更都必須人工確認。
## Confirmed Decisions
- [x] 定位為個人 SRE/維運手冊庫,而非全自動修復平台。
- [x] 第一階段完整納管 mx2、us2、hk2;mx、us1、us4 待完成盤點與緊急存取驗證後才納管。
- [x] 每日自動做唯讀檢查與 Email 通知;每週人工審查、每月人工維護、每季復原演練與權限盤點。
- [x] 以 Email 作為唯一告警與摘要通道。
- [x] 採用本機快速復原加密異機副本的 3-2-1 最小可行策略。
- [x] 真實機密不進 Git;repo 只保存去敏設定結構與操作/驗證紀錄。
- [x] 日常存取使用非 root SSH key;禁止 root/password SSH,維護 provider/recovery console 緊急存取。
- [x] 採用 Ansible + systemd timers + Restic 作為技術基線。
## Implementation Plan
- [x] **Status: Done — 建立去敏 Ansible inventory。** 為 mx2、us2、hk2 建立主機與服務群組,將非機密主機資料與現有人類可讀的 `inventory/hosts.md` 對應;理由是提供可審查的機器可讀編排層,且不取代既有事實來源。
- [x] **Status: Done — 實作 audit-only Ansible playbook。** 只收集連線、OS、磁碟、systemd、Docker Compose 和依賴命令狀態,不做設定或服務改動;理由是先確認控制面與真實現況。
- [x] **Status: Done — 定義跨服務健康檢查結果合約。** 統一去敏結構化結果、日誌位置、嚴重度及 exit code,讓人工執行、systemd 和 Ansible audit 可共同消費;理由是避免各服務告警語意漂移。
- [x] **Status: Done — 保留並模組化 Mailcow 健康檢查。** 持續檢查 Compose、watchdog、queue、listeners、HTTP/HTTPS、TLS、SMTP、DNS/PTR/MX/SPF;理由是現有腳本已覆蓋關鍵郵件服務面向。
- [x] **Status: Done — 實作 Vaultwarden 唯讀健康檢查。** 驗證 Compose/Postgres、HTTPS、有效設定、SMTP AUTH、備份新鮮度及機密指紋一致性,禁止輸出機密;理由是 `config.json` 優先於 `.env`,且 SMTP 漂移已有已知風險。
- [x] **Status: Done — 實作 PowerDNS 唯讀健康檢查。** 驗證容器、版本與安全公告、ns1/ns2 served SOA、API、Web UI、備份新鮮度與必要設定;理由是同時覆蓋公開 DNS 與 secondary 同步。
- [x] **Status: Done — 使用 Ansible 部署 systemd healthcheck service/timer。** 每台主機本機執行每日檢查,具 persistent 排程、權限、logrotate 和一致錯誤處理;理由是控制端離線不應阻止巡檢。
- [x] **Status: Done — 部署 Email 告警與每日摘要。** Critical/unknown 立即通知、健康狀態每日摘要、重複失敗抑制;SMTP 真實認證僅在各主機受限路徑保存;理由是已選定 Email 為唯一通知通道。
- [!] **Status: Blocked — 決定 Restic 異機 repository 與存取隔離。** 選擇具加密傳輸、權限隔離與可承受保留需求的 S3/B2/SFTP 或等價目的地;理由是無異機 repository 即無法滿足已確認的 3-2-1 政策。阻塞:本地實作依要求未虛構 backend、repository 或 credentials。
- [x] **Status: Done (templates gated) — 部署 Vaultwarden 與 PowerDNS 的 Restic 備份。** 將既有一致性資料庫 dump、必要資料目錄及復原元資料加密同步,並加入 snapshot 年齡和 `restic check` 驗證;理由是兩者已有本機備份但缺少異機保護。啟用被 repository 決策和主機端受限設定檔阻擋。
- [!] **Status: Blocked — 完成 Mailcow 備份設計審查。** 官方流程已確認:以 `/opt/mail/helper-scripts/backup_and_restore.sh backup all`(或經明確核准的元件集)先產生一致性備份,再由 Restic 同步該輸出;不得直接複製 Docker volumes。阻塞:仍需決定本機備份位置、保留期、排程及異機 Restic repository。
- [x] **Status: Done (templates gated) — 部署 Restic backup、retention、forget/prune 與 check timers。** 與現有資料庫 dump 時段錯開,所有 repository 認證與密碼留在伺服器端受限檔案;理由是保持備份可用與成本可控。啟用被 repository 決策和主機端受限設定檔阻擋。
- [!] **Status: Blocked — 補齊服務級復原 runbook。** 阻塞:需在選定 Restic repository、建立實際 snapshot 並確認各服務的實際備份輸出後,才能編寫可驗證的 restore 前置條件、順序與 rollback;禁止臆造 backend/credentials 或未驗證還原命令。
- [!] **Status: Blocked — 執行隔離式復原演練。** 阻塞:尚未選定/配置 Restic 異機 repository,亦尚無可供還原的異機 snapshot;演練不得對現有生產資料執行。
- [x] **Status: Done — 實作受控 common baseline 與 maintenance playbook。** 僅在 audit 穩定後納入 SSH 稽核、時間同步、logrotate、更新預覽與人工確認的維護操作;理由是避免工具導入期間同時改變服務狀態。
- [x] **Status: Done (separate change defined) — 將 PowerDNS API key、DB password 與 TSIG 輪替列為分離變更。** 已明確保持 API key、DB password 與 TSIG 為三項獨立、需人工核准的變更,並要求逐步驗證 Auth、Poweradmin、AXFR/NOTIFY、DNSSEC 與 ns2 同步;未輪替任何機密。
- [!] **Status: Blocked — 盤點 mx、us1、us4。** 阻塞:inventory 僅記錄 `TBD` SSH/角色或缺少完整事實,尚未提供可驗證的存取方式與 provider/recovery console 資訊;不得猜測或嘗試未授權存取。
## Verification Criteria
- [ ] 三台 active 主機能被 Ansible 正確解析及執行 audit,且 audit 不產生主機變更。
- [ ] 三台主機每日產生去敏健康結果;服務、容量、TLS、公開端點和備份新鮮度異常均能被偵測。
- [ ] 正常狀態寄送 Email 摘要;受控測試異常能觸發一次可讀且不含機密的 Email 告警。
- [ ] 每個核心服務至少有一份加密、異機、可列出且通過完整性檢查的 Restic snapshot。
- [ ] 至少完成一次隔離式實際還原,並確認其結果符合或明確量化偏離服務 RPO/RTO。
- [ ] 自動化排程不包含更新、重啟、修復、秘密輪替或 DNS 變更。
- [ ] Repo、Ansible vars、產出日誌及 Email 均不包含任何真實機密。
## Potential Risks and Mitigations
1. **Mailcow 備份未保持資料一致性。**
Mitigation: 先依官方方式完成備份範圍與還原設計,再上線排程;以隔離還原驗證作為完成門檻。
2. **Ansible 設定錯誤改動生產系統。**
Mitigation: 先導入 audit-only;變更 playbook 必須使用 dry-run/diff、明確 tag 與人工確認,且先限制單一 host。
3. **機密出現在 Git、Ansible output 或 Email。**
Mitigation: 真實值只存主機受限檔案;去敏輸出、no-log 機制和測試用機密掃描為強制要求。
4. **Timer 成功但公開服務實際不可用。**
Mitigation: 同時執行本機 Compose/DB 檢查與外部 HTTP、SMTP、TLS、DNS 檢查。
5. **PowerDNS 密鑰輪替中斷 DNS 管理或 zone transfer。**
Mitigation: API key、DB password、TSIG 分批處理;每步都驗證 primary/secondary 同步、DNSSEC 和管理 UI。
## Alternative Approaches
1. **純 shell + systemd,不使用 Ansible**:初期較快,但設定與排程容易跨主機漂移,且難以審查或擴充;不建議作為長期方案。
2. **Ansible + 集中式監控平台**:可增加儀表板與趨勢,但平台本身需額外維護;等 Email 摘要無法滿足需求時再評估。
3. **立即導入 SOPS + age**:可使加密設定進 Git,但需先完成密鑰生命週期與緊急存取設計;建議列為後續階段。
+72
View File
@@ -0,0 +1,72 @@
# Runbook: mailcow health (mx2)
Target: [mx2.windy.me](../hosts/mx2.windy.me.md)
Path: `/opt/mail`
Prefer: `./scripts/check-mx2.sh` from this repo, or the steps below over SSH.
## 1. Containers
```bash
ssh windy@mx2.windy.me 'cd /opt/mail && docker compose ps -a'
```
Expect mailcow (`cow-*`) services **Up**. Note any Exit/Restarting.
## 2. Watchdog
```bash
ssh windy@mx2.windy.me 'cd /opt/mail && docker compose logs --tail=40 watchdog-mailcow'
```
Expect recent lines at **100%** for Nginx, Postfix, Dovecot, MySQL, Redis, SOGo, Rspamd, ACME, etc.
## 3. Queue
```bash
ssh windy@mx2.windy.me 'cd /opt/mail && docker compose exec -T postfix-mailcow postqueue -p'
```
Healthy: empty / `Mail queue is empty`.
## 4. Local listeners
```bash
ssh windy@mx2.windy.me 'ss -tlnp | grep -E ":(25|465|587|143|993|995|80|443|4190)\\s"'
```
## 5. External checks (from laptop / agent host)
```bash
curl -sI --max-time 10 http://mx2.windy.me/ | head -5
curl -skI --max-time 10 https://mx2.windy.me/ | head -15
echo | openssl s_client -connect mx2.windy.me:443 -servername mx2.windy.me 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates
timeout 8 bash -c 'exec 3<>/dev/tcp/mx2.windy.me/25; echo -e "EHLO test.local\r\nQUIT\r\n" >&3; cat <&3' | head -20
```
Expect: HTTP 301 → HTTPS; HTTPS 200; LE cert for `mx2.windy.me`; SMTP banner `Postcow`.
## 6. DNS summary
Use a public resolver if the local stub is flaky (WSL `127.0.0.53`):
```bash
dig @1.1.1.1 +short mx2.windy.me A
dig @1.1.1.1 +short mx2.windy.me AAAA
dig @1.1.1.1 +short -x 194.163.160.244
dig @1.1.1.1 +short windy.me MX
dig @1.1.1.1 +short windy.me TXT | grep -i spf
```
## Scheduled local check
The sanitized Ansible health profile is `mailcow` (`ansible/playbooks/healthchecks.yml`). It preserves the existing standalone `scripts/check-mx2.sh` as the richer operator check, while the server-local timer emits a sanitized result at `/var/lib/vps-health/latest.json`. Neither check changes Mailcow configuration or service state.
## Pass criteria
- Compose stack up; watchdog ~100%
- Queue empty (or explain backlog)
- TLS valid; mail ports reachable externally
- A/AAAA/PTR/MX consistent with [host facts](../hosts/mx2.windy.me.md)
Update the **Verified** line on the host page after a successful check.
+54
View File
@@ -0,0 +1,54 @@
# Runbook: use mailcow SMTP / IMAP (client)
Target: [mx2.windy.me](../hosts/mx2.windy.me.md)
Prerequisite: a mailbox on `windy.me` (password from mailcow UI, not the admin account unless it is that mailbox).
Clients should use **submission** to send. Do **not** use port 25 as a desktop/app outbound port.
## Send (SMTP)
| Item | Value |
|------|--------|
| Server | `mx2.windy.me` |
| Port | **587** (STARTTLS) — preferred |
| Alt port | **465** (SSL/TLS) |
| Username | Full address, e.g. `zhiqiang@windy.me` |
| Password | Mailbox password |
| Auth | Required (LOGIN / PLAIN) |
Match encryption to the port: 587 → STARTTLS; 465 → SSL/TLS (not STARTTLS).
From address should be that mailbox or an allowed alias on the same domain.
## Receive (IMAP / POP)
| Protocol | Server | Port |
|----------|--------|------|
| IMAPS | `mx2.windy.me` | **993** |
| POP3S | `mx2.windy.me` | **995** |
Username = full email address; same mailbox password.
## Webmail
- UI / SOGo: https://mx2.windy.me
- Log in with the same mailbox credentials.
## CLI smoke test (optional)
```bash
swaks --to someone@example.com \
--from YOU@windy.me \
--server mx2.windy.me --port 587 -tls \
--auth LOGIN --auth-user YOU@windy.me --auth-password 'MAILBOX_PASSWORD'
```
Do not commit or paste real passwords into this repo.
## Auth failures — quick checks
- Username is the **full** email, not a local part alone
- Password is the **mailbox** password (not unrelated host/root secrets)
- Port/TLS mode mismatch (587 vs 465)
- Account active in mailcow; not rate-limited / fail2banned after bad attempts
- Apps that store SMTP in their own config (e.g. Vaultwarden `config.json`) may keep a **stale** password even when `.env` is correct — verify AUTH against the effective config ([vaultwarden-health](vaultwarden-health.md) §5)
+43
View File
@@ -0,0 +1,43 @@
# Runbook: mailcow update (mx2)
Target: [mx2.windy.me](../hosts/mx2.windy.me.md)
Path: `/opt/mail`
**Confirm with the user before running an update.**
## Before
1. Run [mailcow-health](mailcow-health.md) (or `./scripts/check-mx2.sh`). Record baseline.
2. Note config quirks in `mailcow.conf`, especially:
- `SKIP_CLAMD=y` — after update, `clamd` may still be running until services are recreated per mailcow docs.
- `SKIP_LETS_ENCRYPT`, ports, hostname — should stay `mx2.windy.me`.
3. Disk/memory: `df -h /` and `free -h` on the host.
## Update
```bash
ssh -t windy@mx2.windy.me 'cd /opt/mail && ./update.sh'
```
- Prefer interactive SSH (`-t`) so prompts work.
- Do not pass secrets into the chat log; do not commit `mailcow.conf`.
If update asks to stop/recreate containers, allow it unless the user said otherwise.
## After
1. Re-run health checks ([mailcow-health](mailcow-health.md)).
2. Confirm UI: `https://mx2.windy.me`
3. Confirm SMTP banner and queue empty.
4. If `SKIP_CLAMD=y` but `cow-clamd-mailcow-1` is still Up and that is unwanted:
```bash
ssh windy@mx2.windy.me 'cd /opt/mail && docker compose up -d --force-recreate'
```
Only after user OK; recreate briefly interrupts mail.
5. Update **Verified** on [hosts/mx2.windy.me.md](../hosts/mx2.windy.me.md) with date and outcome.
## Rollback
Mailcow updates are image/git based; there is no one-click rollback in this runbook. If something fails, capture `docker compose ps` and `docker compose logs --tail=100` for the failing service and stop before further changes.
+85
View File
@@ -0,0 +1,85 @@
# Matrix Health Check
Monitor the Matrix homeserver running on `synapse.chans.xyz` (ESS chart `26.7.2`, K3s node).
## Basic checks
```bash
# Overall status
ssh -4 windy@synapse.chans.xyz 'sudo k3s kubectl -n ess get pods,svc,ingress,certificate -o wide'
# Pod restarts
ssh -4 windy@synapse.chans.xyz 'sudo k3s kubectl -n ess get pods -o wide | grep -E "CrashLoop|Error|Evicted" || echo "No unhealthy pods"'
# Disk
ssh -4 windy@synapse.chans.xyz 'df -hP /'
```
## Certificate verification
```bash
ssh -4 windy@synapse.chans.xyz 'sudo k3s kubectl -n ess get certificate'
```
All certificates should show `READY=True`. Domains:
- `chans.xyz`
- `synapse.chans.xyz`
- `chat.chans.xyz`
- `account.chans.xyz`
- `admin.chans.xyz`
- `mrtc.chans.xyz` (placeholder only)
## External endpoint verification
```bash
# Client API
curl -4 -s https://synapse.chans.xyz/_matrix/client/versions | python3 -m json.tool | head -5
# Well-known server discovery
curl -4 -s https://chans.xyz/.well-known/matrix/server
# Well-known client discovery
curl -4 -s https://chans.xyz/.well-known/matrix/client
# Federation tester (manual)
open https://federationtester.matrix.org/#chans.xyz
```
## Local health-check automation
The node has a systemd health-check timer that runs daily:
```bash
ssh -4 windy@synapse.chans.xyz 'systemctl status matrix-k3s-healthcheck.timer'
ssh -4 windy@synapse.chans.xyz 'cat /var/lib/vps-health/latest.json 2>/dev/null || echo "No data yet"'
```
Checks cover:
- K3s node Ready
- Pods in `ess` namespace (no CrashLoopBackOff / Evicted)
- Root disk usage (warning ≥80%, critical ≥90%)
- Backup freshness
## Backup status
```bash
ssh -4 windy@synapse.chans.xyz 'systemctl status matrix-backup.timer'
ssh -4 windy@synapse.chans.xyz 'ls -la /var/backups/matrix/'
```
Backup automation is currently paused. `/var/backups/matrix/` is retained for a future, reviewed design and must not be treated as a recovery source.
- PostgreSQL dumps (synapse + mas)
- Media archive
- Bootstrap secrets archive
- Backup manifest with SHA-256
- Retention: 7 days
## Common pitfalls
| Symptom | Likely cause |
|---|---|
| `/_matrix/client/versions` fails | Traefik ingress not routing to Synapse |
| HTTPS returns non-LE cert | cert-manager `letsencrypt-prod` not ready |
| Well-known returns 404/redirect | Root `chans.xyz` ingress missing or misconfigured |
| 502 Bad Gateway | Synapse pod restarting or DB down |
| SMTP emails not sent | MAS SMTP config incomplete; TCP reachable but AUTH failing — see `runbooks/vaultwarden-health.md` |
+86
View File
@@ -0,0 +1,86 @@
# PowerDNS health (hk2)
Read-only checks for the `/opt/pdns` stack on **hk2.chans.xyz** (`ns1.wsvc.info`).
Facts: [hosts/hk2.chans.xyz.md](../hosts/hk2.chans.xyz.md) · Upstream: [docs/pdns-upstream.md](../docs/pdns-upstream.md)
## Quick remote
```bash
ssh -4 windy@hk2.chans.xyz 'cd /opt/pdns && docker compose ps -a'
```
Expect `auth`, `db`, `poweradmin` healthy; `backup` Up; `pgweb` Up. Ignore stopped orphan `powerdns-admin` unless cleaning orphans.
## Version / security poll
Expect Authoritative **5.0.6** (or newer). Startup logs should **not** show a Mandatory `Security Update` banner. If they do, check https://doc.powerdns.com/authoritative/security-advisories/ and bump the `powerdns/pdns-auth-50` image pin in `/opt/pdns/compose.yml`.
```bash
ssh -4 windy@hk2.chans.xyz 'docker logs pdns-auth 2>&1 | grep -i "Security Update" | tail -5'
ssh -4 windy@hk2.chans.xyz 'docker inspect pdns-auth --format "{{.Config.Image}}"'
```
## DNS (from laptop / WSL)
Prefer querying auth/secondary IPs directly (local stub / public resolvers may be flaky or cached):
```bash
NS1=154.36.174.161
NS2=202.91.35.141
for z in windy.me wsvc.info chans.xyz; do
echo "== $z =="
dig @$NS1 +short NS "$z"
dig @$NS1 +short SOA "$z"
dig @$NS2 +short SOA "$z"
done
dig @$NS1 +short A ns1.wsvc.info # expect 154.36.174.161
dig @$NS1 +short A ns2.wsvc.info # expect 202.91.35.141
```
Served SOA serial can differ from the backend/API serial when `SOA-EDIT=INCEPTION-INCREMENT` is active — compare **ns1 vs ns2 served SOA**, not dig vs `pdnsutil`/API.
## API (on host)
```bash
ssh -4 windy@hk2.chans.xyz 'cd /opt/pdns && docker compose exec -T auth \
python3 -c "import json,os,urllib.request; print(json.load(urllib.request.urlopen(urllib.request.Request(\"http://127.0.0.1:8081/api/v1/servers/localhost\", headers={\"X-API-Key\": os.environ[\"PDNS_API_KEY\"]})))[\"version\")"'
```
Or use host loopback with the key from `.env` (do not paste the key into chat/logs).
## Web UIs
```bash
# If local stub DNS is flaky, pin SNI to the auth IP:
curl -4 -sS -o /dev/null -w "%{http_code}\n" --resolve pdns.wsvc.info:443:154.36.174.161 https://pdns.wsvc.info/ # 302 → login
curl -4 -sS -o /dev/null -w "%{http_code}\n" --resolve pgweb.wsvc.info:443:154.36.174.161 https://pgweb.wsvc.info/ # 401 without basic auth
```
## Backup
```bash
ssh -4 windy@hk2.chans.xyz 'cd /opt/pdns && ./scripts/manual-backup.sh && ls -lt backup/*.sql.gz | head'
ssh -4 windy@hk2.chans.xyz 'tail -30 /opt/pdns/backup/backup.log'
```
Scheduler should log `scheduler started … (daily 03:00)`. If you see `can't execute 'bash'` or Alpine `apk` errors, the backup image/entrypoint was reverted — see [hosts/hk2.chans.xyz.md](../hosts/hk2.chans.xyz.md).
## Config sanity (docs-aligned)
```bash
ssh -4 windy@hk2.chans.xyz 'grep -E "^(primary|also-notify|only-notify|allow-axfr-ips|gpgsql-dnssec|default-soa-edit)" /opt/pdns/auth/pdns.conf'
```
Expect: `primary=yes`, `also-notify=202.91.35.141`, `only-notify=` empty, `gpgsql-dnssec=yes`, SOA-EDIT `INCEPTION-INCREMENT`.
## Scheduled local check
The sanitized Ansible health profile is `pdns` (`ansible/playbooks/healthchecks.yml`). It runs locally through `vps-healthcheck.timer`, writes a sanitized JSON result to `/var/lib/vps-health/latest.json`, and uses the API key only inside the PowerDNS container. It does not modify PowerDNS, DNS records, or secrets.
## After config changes
- `auth/pdns.conf`, `auth/templates.d/secrets.j2`, or auth-related `.env``docker compose up -d --force-recreate auth`
- Poweradmin env / Traefik labels → `docker compose up -d --no-deps --force-recreate poweradmin` (restart Traefik if UI 404s)
- backup schedule / retention env → `docker compose up -d --force-recreate --no-deps backup`
+96
View File
@@ -0,0 +1,96 @@
# Runbook: Vaultwarden health (us2)
Target: [us2.wsvc.info](../hosts/us2.wsvc.info.md)
Path: `/opt/vaultwarden`
URL: https://auth.wsvc.info/
Upstream: [docs/vaultwarden-upstream.md](../docs/vaultwarden-upstream.md)
## 1. Containers
```bash
ssh -4 windy@us2.wsvc.info 'cd /opt/vaultwarden && docker compose ps -a'
```
Expect `vaultwarden` + `vw-db` **Up (healthy)**. `pgweb` should be stopped unless started with `--profile debug`.
## 2. Live DB is Postgres
```bash
ssh -4 windy@us2.wsvc.info 'cd /opt/vaultwarden && docker compose exec -T vaultwarden sh -c "printenv DATABASE_URL" | sed -E "s#://[^@]+@#://REDACTED@#"'
ssh -4 windy@us2.wsvc.info 'cd /opt/vaultwarden && docker compose exec -T pg psql -U vaultwarden -d vaultwarden -c "SELECT COUNT(*) AS users FROM users; SELECT COUNT(*) AS ciphers FROM ciphers;"'
```
## 3. Effective config (config.json wins)
```bash
ssh -4 windy@us2.wsvc.info 'cd /opt/vaultwarden && docker compose exec -T vaultwarden cat /data/config.json' \
| python3 -c "import sys,json;d=json.load(sys.stdin);print({k:d.get(k) for k in ['domain','smtp_host','smtp_port','smtp_security','smtp_username','smtp_from','ip_header','signups_allowed']});print('smtp_password_set',bool(d.get('smtp_password')))"
```
Expect SMTP **mx2.windy.me** / **587** / **starttls**, `ip_header` **X-Forwarded-For**.
Also confirm `.env` and `config.json` SMTP passwords match (lengths/hashes only — never print secrets):
```bash
ssh -4 windy@us2.wsvc.info 'cd /opt/vaultwarden && python3 - << "EOF"
import json, re, pathlib, hashlib, subprocess
env = pathlib.Path(".env").read_text()
def grab(k):
m = re.search(rf"^{re.escape(k)}=(.*)$", env, re.M)
return m.group(1).strip().strip("\"'\''") if m else None
cfg = json.loads(subprocess.check_output(
["docker","compose","exec","-T","vaultwarden","cat","/data/config.json"]))
ep, cp = grab("SMTP_PASSWORD"), cfg.get("smtp_password") or ""
def fp(s): return {"len": len(s), "sha256_8": hashlib.sha256(s.encode()).hexdigest()[:8]}
print("env_pass", fp(ep or ""), "cfg_pass", fp(cp), "match", ep == cp)
EOF'
```
If they diverge, **auth uses `config.json`** — sync password there (or via `/admin`) and update `.smtp-credentials`.
## 4. External HTTPS
```bash
curl -4 -sS -I --max-time 15 https://auth.wsvc.info/ | head -15
```
If **404** right after recreating VW: `ssh -4 windy@us2.wsvc.info 'docker restart traefik'` then retry.
## 5. SMTP reachability + AUTH
TCP (expect `587:0`; `465` often times out from us2):
```bash
ssh -4 windy@us2.wsvc.info 'docker compose -f /opt/vaultwarden/docker-compose.yml exec -T vaultwarden sh -c "timeout 5 bash -c \"cat </dev/null >/dev/tcp/mx2.windy.me/587\"; echo 587:\$?"'
```
AUTH with the **effective** `config.json` password (run on us2; do not print the password):
```bash
ssh -4 windy@us2.wsvc.info 'cd /opt/vaultwarden && python3 - << "EOF"
import json, smtplib, ssl, subprocess
cfg = json.loads(subprocess.check_output(
["docker","compose","exec","-T","vaultwarden","cat","/data/config.json"]))
host, port = cfg["smtp_host"], int(cfg["smtp_port"])
user, pw = cfg["smtp_username"], cfg["smtp_password"]
ctx = ssl.create_default_context()
with smtplib.SMTP(host, port, timeout=15) as s:
s.ehlo(); s.starttls(context=ctx); s.ehlo(); s.login(user, pw)
print("AUTH_OK", host, port, user)
EOF'
```
Expect `AUTH_OK`. `535` usually means stale password in `config.json` (see step 3).
## Scheduled local check
The sanitized Ansible health profile is `vaultwarden` (`ansible/playbooks/healthchecks.yml`). It runs locally through `vps-healthcheck.timer`, writes a sanitized JSON result to `/var/lib/vps-health/latest.json`, and uses only server-side credentials for the SMTP AUTH probe. It does not modify Vaultwarden, Traefik, SMTP, or secrets.
## Pass criteria
- Healthy compose; `DATABASE_URL` points at `pg`
- HTTPS 200 + `/admin` 200; PG counts sane
- Effective SMTP 587/starttls; `.env``config.json` password match; **AUTH_OK**
- Wrong-password login returns 400 business error (not 500)
- Update **Verified** on [hosts/us2.wsvc.info.md](../hosts/us2.wsvc.info.md)
@@ -0,0 +1,37 @@
# Runbook: Vaultwarden SQLite → Postgres (us2)
Target: [us2.wsvc.info](../hosts/us2.wsvc.info.md)
Upstream: [Using the PostgreSQL Backend](https://github.com/dani-garcia/vaultwarden/wiki/Using-the-PostgreSQL-Backend) · [docs/vaultwarden-upstream.md](../docs/vaultwarden-upstream.md)
**Done on 2026-08-01** for this host. Keep this as the replay checklist.
## Summary of what we did
1. Backup `vw-data` + `.env``backups/pre-pg-migrate-<timestamp>/`
2. Baseline counts from SQLite (then: users=1, ciphers=1327)
3. Drop/recreate empty Postgres DB; pin image `vaultwarden/server:1.37.1`
4. Set compose `DATABASE_URL=postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${DB_NAME}`
5. Start VW once → diesel schema → stop
6. `pgloader` **data only** (exclude `__diesel_schema_migrations`) with CAST bigint→integer
7. Remaining intentional bigints: `attachments.file_size`, `twofactor.last_used`, `twofactor_duo_ctx.exp`
8. Align `config.json` SMTP **587/starttls**, `ip_header=X-Forwarded-For`, `DOMAIN` without trailing slash
9. Rotate `DB_PASS` / `PGWEB_PASS`; `pgweb` behind compose `profiles: [debug]`
10. Move live sqlite file to `backups/sqlite-cold/` (cold only)
11. After VW recreate, **restart Traefik** so Docker provider picks up the router
## Critical pitfalls
- `config.json` overrides `.env` (admin wins). Fix SMTP there or via `/admin`.
- SMTP password drift: if `.env` AUTH works but VW mail fails with `535`, sync `vw-data/config.json` (+ `.smtp-credentials`) to the mailbox password — do not assume env alone is enough.
- From this network, **mx2:465 times out**; use **587 + starttls**.
- After recreating `vaultwarden`, Traefik may 404 until `docker restart traefik`.
- pgloader needs a **writable** directory for the sqlite file (CANTOPEN if mount is awkward).
- After pgloader, wrong integer types → silent “wrong password”; see [Discussion #7134](https://github.com/dani-garcia/vaultwarden/discussions/7134).
- Do not commit `.env` or load files with passwords into git.
## Rollback
1. `docker compose stop vaultwarden`
2. Restore `vw-data` from `backups/pre-pg-migrate-*` (including sqlite)
3. Remove `DATABASE_URL` from compose `environment`
4. `docker compose up -d vaultwarden` && `docker restart traefik`
+195
View File
@@ -0,0 +1,195 @@
#!/usr/bin/env bash
# Read-only health check for mx2.windy.me mailcow.
# Usage: ./scripts/check-mx2.sh
# Env:
# MX2_HOST default mx2.windy.me
# MX2_SSH_USER default windy
# MX2_ALLOW_IPV6 set to 1 to allow IPv6 (default forces IPv4; WSL often lacks v6 route)
set -euo pipefail
HOST="${MX2_HOST:-mx2.windy.me}"
SSH_USER="${MX2_SSH_USER:-windy}"
# Default -4: host has AAAA but many clients (WSL) have no IPv6 route → "Network is unreachable"
SSH_AF_OPTS=(-4)
CURL_AF_OPTS=(-4)
OPENSSL_AF_OPTS=(-4)
if [[ "${MX2_ALLOW_IPV6:-0}" == "1" ]]; then
SSH_AF_OPTS=()
CURL_AF_OPTS=()
OPENSSL_AF_OPTS=()
fi
SSH=(ssh "${SSH_AF_OPTS[@]}" -o BatchMode=yes -o ConnectTimeout=10 "${SSH_USER}@${HOST}")
# Prefer public resolvers: local stub (127.0.0.53) is flaky from some WSL setups
DIG_OPTS=(+time=3 +tries=2 +short)
DIG_SERVERS=(1.1.1.1 8.8.8.8)
FAILURES=0
section() { printf '\n===== %s =====\n' "$1"; }
fail() {
printf 'FAIL: %s\n' "$1" >&2
FAILURES=$((FAILURES + 1))
}
# Strip dig commentary (timeouts are printed on stdout as ";; ...")
dig_answers_only() {
grep -v '^;;' | grep -v '^$' || true
}
# dig_short <name> [type] — tries each public resolver; skips timeout noise
dig_short() {
local name="$1"
local typ="${2:-A}"
local server out
for server in "${DIG_SERVERS[@]}"; do
out="$(dig @"${server}" "${DIG_OPTS[@]}" "$name" "$typ" 2>/dev/null | dig_answers_only)" || out=""
if [[ -n "$out" ]]; then
printf '%s\n' "$out"
return 0
fi
done
return 1
}
# dig_ptr <ip>
dig_ptr() {
local ip="$1"
local server out
for server in "${DIG_SERVERS[@]}"; do
out="$(dig @"${server}" "${DIG_OPTS[@]}" -x "$ip" 2>/dev/null | dig_answers_only)" || out=""
if [[ -n "$out" ]]; then
printf '%s\n' "$out"
return 0
fi
done
return 1
}
check_ptrs() {
local ip ptr
while read -r ip; do
[[ -z "$ip" ]] && continue
ptr="$(dig_ptr "$ip" || true)"
printf 'PTR %s -> %s\n' "$ip" "${ptr:-<none>}"
grep -Fiq "${HOST}" <<<"$ptr" || fail "PTR for ${ip} does not mention ${HOST}"
done
}
need_cmd() {
command -v "$1" >/dev/null 2>&1 || {
echo "missing required command: $1" >&2
exit 127
}
}
need_cmd ssh
need_cmd curl
need_cmd openssl
need_cmd dig
need_cmd timeout
section "SSH + compose ps"
PS_OUT="$("${SSH[@]}" 'cd /opt/mail && docker compose ps -a')" || {
fail "ssh or docker compose ps"
echo "Cannot continue without SSH/compose." >&2
exit 1
}
printf '%s\n' "$PS_OUT"
if grep -qiE 'Exited|Restarting|[[:space:]]Dead[[:space:]]' <<<"$PS_OUT"; then
fail "compose has Exited/Restarting/Dead containers"
fi
for svc in nginx-mailcow postfix-mailcow dovecot-mailcow mysql-mailcow; do
grep -Fq "$svc" <<<"$PS_OUT" || fail "missing service ${svc}"
done
section "Watchdog (tail)"
if ! "${SSH[@]}" 'cd /opt/mail && docker compose logs --tail=40 watchdog-mailcow'; then
fail "watchdog logs"
fi
section "Mail queue"
QUEUE_OUT="$("${SSH[@]}" 'cd /opt/mail && docker compose exec -T postfix-mailcow postqueue -p' 2>&1)" || {
fail "postqueue"
QUEUE_OUT=""
}
printf '%s\n' "$QUEUE_OUT"
if [[ -n "$QUEUE_OUT" ]] && ! grep -Fiq 'Mail queue is empty' <<<"$QUEUE_OUT"; then
fail "mail queue not empty"
fi
section "Listeners"
LISTEN_OUT="$("${SSH[@]}" 'ss -tlnp 2>/dev/null | grep -E ":(25|465|587|110|143|993|995|80|443|4190)[[:space:]]" || true')"
printf '%s\n' "$LISTEN_OUT"
for p in 25 465 587 110 143 993 995 80 443 4190; do
if ! grep -qE ":${p}[[:space:]]" <<<"$LISTEN_OUT"; then
fail "port ${p} not listening on host"
fi
done
section "HTTP / HTTPS"
HTTP_OUT="$(curl "${CURL_AF_OPTS[@]}" -sS -I --max-time 10 "http://${HOST}/" 2>&1 | head -8)" || true
printf '%s\n' "$HTTP_OUT"
echo "---"
# No -k: surface TLS trust problems
HTTPS_OUT="$(curl "${CURL_AF_OPTS[@]}" -sS -I --max-time 10 "https://${HOST}/" 2>&1 | head -15)" || true
printf '%s\n' "$HTTPS_OUT"
grep -qE '^HTTP/[0-9.]+ 301' <<<"$HTTP_OUT" || fail "HTTP did not redirect (expect 301)"
grep -qE '^HTTP/[0-9.]+ 200' <<<"$HTTPS_OUT" || fail "HTTPS did not return 200"
section "TLS cert (443)"
CERT_OUT="$(
timeout 15 openssl s_client "${OPENSSL_AF_OPTS[@]}" -connect "${HOST}:443" -servername "${HOST}" </dev/null 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates 2>/dev/null
)" || true
printf '%s\n' "$CERT_OUT"
if ! grep -Fq "CN=${HOST}" <<<"$CERT_OUT" && ! grep -Fq "CN = ${HOST}" <<<"$CERT_OUT"; then
fail "TLS subject missing ${HOST}"
fi
grep -Fiq "Let's Encrypt" <<<"$CERT_OUT" || fail "TLS issuer not Let's Encrypt"
section "SMTP banner"
# Connect via A record so /dev/tcp does not pick unreachable AAAA first
SMTP_IP="$(dig_short "${HOST}" A 2>/dev/null | head -n1 || true)"
if [[ -z "$SMTP_IP" ]]; then
fail "cannot resolve A for SMTP check"
SMTP_OUT=""
else
SMTP_OUT="$(
timeout 8 bash -c "exec 3<>/dev/tcp/${SMTP_IP}/25; printf 'EHLO test.local\r\nQUIT\r\n' >&3; cat <&3" 2>/dev/null \
| head -20
)" || true
fi
printf 'connect %s:25\n' "${SMTP_IP:-?}"
printf '%s\n' "$SMTP_OUT"
grep -qiE 'Postcow|ESMTP' <<<"$SMTP_OUT" || fail "SMTP banner unexpected"
section "DNS"
A_REC="$(dig_short "${HOST}" A || true)"
AAAA_REC="$(dig_short "${HOST}" AAAA || true)"
printf 'A: %s\n' "${A_REC:-<none>}"
printf 'AAAA: %s\n' "${AAAA_REC:-<none>}"
[[ -n "$A_REC" ]] || fail "missing A record"
[[ -n "$AAAA_REC" ]] || fail "missing AAAA record"
check_ptrs <<<"$A_REC"
check_ptrs <<<"$AAAA_REC"
MX_REC="$(dig_short windy.me MX || true)"
printf 'MX:\n%s\n' "${MX_REC:-<none>}"
grep -Fiq "${HOST}" <<<"$MX_REC" || fail "windy.me MX missing ${HOST}"
SPF="$(dig_short windy.me TXT || true)"
printf 'SPF:\n'
if ! grep -Fi 'v=spf1' <<<"$SPF"; then
fail "no SPF TXT on windy.me"
fi
section "Done"
if [[ "$FAILURES" -gt 0 ]]; then
echo "Health check finished with ${FAILURES} failure(s)."
exit 1
fi
echo "Health check OK. Update hosts/mx2.windy.me.md Verified line if desired."
exit 0