feat: add gated Compose deploy and make inventory the host source of truth

Keep sanitized Compose sources in-repo with a confirmation-gated Ansible
playbook, add repo-wide validation, tighten runbook ownership/STOP/review
metadata, and archive stale research docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
windyboy
2026-08-17 17:36:39 +08:00
co-authored by Cursor
parent 885d977531
commit 343c5db415
35 changed files with 912 additions and 276 deletions
+10 -13
View File
@@ -32,19 +32,13 @@ only — do not mutate production state. When live state conflicts with a
runbook's assumptions, `STOP` and report; never invent missing parameters or runbook's assumptions, `STOP` and report; never invent missing parameters or
bypass failed checks. The spec is [`RUNBOOKS.md`](RUNBOOKS.md). bypass failed checks. The spec is [`RUNBOOKS.md`](RUNBOOKS.md).
## Active hosts (quick map) ## Active hosts
| Host | Role | SSH | Facts | The canonical machine list (roles, SSH endpoints, Ansible coverage, status) is
|------|------|-----|--------| [`inventory/hosts.md`](inventory/hosts.md) — the single human-readable source
| **mx2.windy.me** | mailcow (`/opt/mail`, project `cow`) | `ssh -4 windy@mx2.windy.me` | [hosts/mx2.windy.me.md](hosts/mx2.windy.me.md) | of truth. Per-host facts live in [`hosts/`](hosts/). The Ansible execution
| **us2.wsvc.info** | Vaultwarden + Traefik (+ Soft Serve, …) | `ssh -4 windy@us2.wsvc.info` | [hosts/us2.wsvc.info.md](hosts/us2.wsvc.info.md) | inventory is [`ansible/inventory/hosts.yml`](ansible/inventory/hosts.yml). Do
| **hk2.chans.xyz** | PowerDNS auth ns1 (`/opt/pdns`) | `ssh -4 windy@hk2.chans.xyz` | [hosts/hk2.chans.xyz.md](hosts/hk2.chans.xyz.md) | not maintain a second copy of the machine table here.
| **synapse.chans.xyz** | Matrix ESS (Synapse + MAS + Element) on K3s | `ssh -4 windy@synapse.chans.xyz` | [hosts/synapse.chans.xyz.md](hosts/synapse.chans.xyz.md) |
| **dns.windy.lan** | AdGuard Home LAN DNS + Mihomo explicit proxy | `ssh -4 windy@192.168.66.36` | [hosts/dns.windy.lan.md](hosts/dns.windy.lan.md) |
| **gfw.windy.lan** | OpenWrt LAN gateway / OpenClash | `ssh -4 root@192.168.66.1` | [hosts/gfw.windy.lan.md](hosts/gfw.windy.lan.md) |
| **gw** | EdgeRouter X primary LAN gateway | `ssh -4 zhiqiang@192.168.66.254` | [hosts/gw.md](hosts/gw.md) |
| **ubnt** | UniFi Network Controller | `ssh -4 windy@192.168.66.46` | [hosts/ubnt.md](hosts/ubnt.md) |
| **hass.windy.lan** | Home Assistant (HAOS, LAN55) | `ssh hassio@hass.windy.lan` | [hosts/hass.windy.lan.md](hosts/hass.windy.lan.md) |
### Public services ### Public services
@@ -152,8 +146,11 @@ Bills, rough notes, and personal clutter stay in the Obsidian vault. This repo h
``` ```
AGENTS.md / agent.md # this entry (agent.md → AGENTS.md) AGENTS.md / agent.md # this entry (agent.md → AGENTS.md)
RUNBOOKS.md # runbook spec (six-field model, naming, review rules) RUNBOOKS.md # runbook spec (six-field model, naming, review rules)
inventory/hosts.md # machine index inventory/hosts.md # machine index (human-readable source of truth)
ansible/ # playbooks, roles, sanitized control-plane inventory
compose/ # repo-owned non-secret Compose sources (+ .env.example)
hosts/ # per-host facts hosts/ # per-host facts
runbooks/ # step-by-step ops (README.md = index, _template.md = template) runbooks/ # step-by-step ops (README.md = index, _template.md = template)
docs/ # upstream doc indexes / design notes docs/ # upstream doc indexes / design notes
scripts/validate-repo.sh # repo-wide validation (run before merging)
``` ```
+20
View File
@@ -27,6 +27,15 @@ runbooks/
> 只读类 runbook 不产生副作用,可省略 Rollback;但必须保留 Stop condition(状态与预期冲突即 `STOP` 并记录证据)。 > 只读类 runbook 不产生副作用,可省略 Rollback;但必须保留 Stop condition(状态与预期冲突即 `STOP` 并记录证据)。
**只读类变体(read-only variant**:只读 runbookhealth 类、参考类)不强制
六字段模型,但必须包含以下最小结构,否则不视为达标:
- `## Purpose`12 行)+ `## Scope`(适用/不适用)
- `## Safety` 或等效章节,其中**必须**含显式 Stop condition(状态与预期冲突即
`STOP` 并记录证据;不得在执行中自行"顺手修复")
- 只读健康类另含可观察的 `## Pass criteria`(或等效的 Expected 信号)
- 每份 runbook 顶部/元信息区必须标注 `Last reviewed: <YYYY-MM-DD>`
## 命名与拆分规则 ## 命名与拆分规则
- 文件名采用小写连字符,反映**操作意图**而非目标主机,例如 `mailcow-health.md``release.md` - 文件名采用小写连字符,反映**操作意图**而非目标主机,例如 `mailcow-health.md``release.md`
@@ -37,6 +46,17 @@ runbooks/
- 每份 runbook 顶部含 `## Purpose`12 行)与 `## Scope`(适用/不适用情形)。 - 每份 runbook 顶部含 `## Purpose`12 行)与 `## Scope`(适用/不适用情形)。
- 变更型 runbook 额外含 `## Approval gates` 表;破坏性/不可逆操作必须获得明确批准。 - 变更型 runbook 额外含 `## Approval gates` 表;破坏性/不可逆操作必须获得明确批准。
- 语言约定:**runbook 正文统一使用英文**(由 agent 逐字执行,降低二义性);
元规范文件(AGENTS.md / RUNBOOKS.md / 模板注释)可保留中文。
- 变更型 runbook 的两种形态:
- **流程式(Procedure 型)**:使用六字段模型,适用多分支/多步骤变更
(现有:`fix-ci.md``issue-to-merge.md``network-change.md`
`network-recovery.md``release.md``rollback.md`)。
- **门控命令式(gated command reference**:已稳定、低歧义、可验证的
操作以命令集 + 门控呈现(现有:`mailcow-update.md`
`ansible-operations.md``home-assistant-maintenance.md`
`vaultwarden-sqlite-to-postgres.md`),必须含 Approval gates 或确认变量
要求 + 显式 STOP,不替代流程式形态。新写的变更 runbook 默认用流程式。
- 统一在 `## Safety` 或正文中复用以下通用安全规则(更严格要求优先)。 - 统一在 `## Safety` 或正文中复用以下通用安全规则(更严格要求优先)。
```markdown ```markdown
+5
View File
@@ -11,3 +11,8 @@ host_key_checking = True
become = True become = True
become_method = sudo become_method = sudo
become_ask_pass = False become_ask_pass = False
[ssh_connection]
# Keep SSH control sockets inside the repo (gitignored .ansible/) so playbook
# runs work in sandboxed/CI environments without touching ~/.ansible.
ssh_args = -C -o ControlMaster=auto -o ControlPersist=60s -o ControlPath=.ansible/ssh-control/%h-%p-%r
+11
View File
@@ -15,6 +15,7 @@ all:
mx2: mx2:
ansible_host: mx2.windy.me ansible_host: mx2.windy.me
ansible_host_ipv4: 194.163.160.244 ansible_host_ipv4: 194.163.160.244
display_name: mx2.windy.me
service_role: mailcow service_role: mailcow
compose_project_dir: /opt/mail compose_project_dir: /opt/mail
healthcheck_profiles: [mailcow] healthcheck_profiles: [mailcow]
@@ -24,8 +25,11 @@ all:
us2: us2:
ansible_host: us2.wsvc.info ansible_host: us2.wsvc.info
ansible_host_ipv4: 193.9.44.165 ansible_host_ipv4: 193.9.44.165
display_name: us2.wsvc.info
service_role: vaultwarden service_role: vaultwarden
compose_project_dir: /opt/vaultwarden compose_project_dir: /opt/vaultwarden
compose_repo_project: vaultwarden
compose_remote_file: docker-compose.yml
healthcheck_profiles: [vaultwarden] healthcheck_profiles: [vaultwarden]
restic_backup_profile: vaultwarden restic_backup_profile: vaultwarden
service_reconcile_services: service_reconcile_services:
@@ -35,8 +39,11 @@ all:
hk2: hk2:
ansible_host: hk2.chans.xyz ansible_host: hk2.chans.xyz
ansible_host_ipv4: 154.36.174.161 ansible_host_ipv4: 154.36.174.161
display_name: hk2.chans.xyz
service_role: powerdns service_role: powerdns
compose_project_dir: /opt/pdns compose_project_dir: /opt/pdns
compose_repo_project: pdns
compose_remote_file: compose.yml
healthcheck_profiles: [pdns, rustdesk, hk2aux] healthcheck_profiles: [pdns, rustdesk, hk2aux]
restic_backup_profile: pdns restic_backup_profile: pdns
service_reconcile_services: service_reconcile_services:
@@ -54,6 +61,7 @@ all:
us4: us4:
ansible_host: us4.wsvc.info ansible_host: us4.wsvc.info
ansible_host_ipv4: 185.201.226.122 ansible_host_ipv4: 185.201.226.122
display_name: us4.wsvc.info
service_role: wireguard service_role: wireguard
compose_project_dir: /opt/wireguard compose_project_dir: /opt/wireguard
healthcheck_profiles: [wireguard] healthcheck_profiles: [wireguard]
@@ -65,6 +73,7 @@ all:
dns_windy_lan: dns_windy_lan:
ansible_host: 192.168.66.36 ansible_host: 192.168.66.36
ansible_host_ipv4: 192.168.66.36 ansible_host_ipv4: 192.168.66.36
display_name: dns.windy.lan
service_role: adguardhome service_role: adguardhome
compose_project_dir: /opt/adguardhome compose_project_dir: /opt/adguardhome
healthcheck_profiles: [adguardhome] healthcheck_profiles: [adguardhome]
@@ -94,6 +103,7 @@ all:
ubnt: ubnt:
ansible_host: 192.168.66.46 ansible_host: 192.168.66.46
ansible_host_ipv4: 192.168.66.46 ansible_host_ipv4: 192.168.66.46
display_name: ubnt
vars: vars:
service_role: unifi service_role: unifi
compose_project_dir: /home/windy/unifi-9 compose_project_dir: /home/windy/unifi-9
@@ -114,6 +124,7 @@ all:
matrix_vps: matrix_vps:
ansible_host: 169.58.86.13 ansible_host: 169.58.86.13
ansible_host_ipv4: 169.58.86.13 ansible_host_ipv4: 169.58.86.13
display_name: synapse.chans.xyz
service_role: matrix_k3s service_role: matrix_k3s
matrix_server_name: chans.xyz matrix_server_name: chans.xyz
matrix_synapse_host: synapse.chans.xyz matrix_synapse_host: synapse.chans.xyz
+13
View File
@@ -0,0 +1,13 @@
---
# Deploy repo-owned Compose declarations (compose/<project>/compose.yml) to
# inventory hosts. Non-secret source; server-local .env provides the values.
# Gated: apply requires compose_deploy_confirm=true; --check is a read-only
# diff + validation. See runbooks/ansible-operations.md.
- name: Deploy repo-owned Compose declarations
hosts: docker_hosts
become: true
gather_facts: false
serial: 1
roles:
- role: compose_deploy
tags: [compose, deploy, mutating]
@@ -0,0 +1,8 @@
---
# Allowlist of compose/ projects this playbook may deploy. A host may only
# reference a project listed here (see tasks: "Require a repo compose project").
compose_repo_projects:
- vaultwarden
- pdns
- adguardhome
- unifi
@@ -0,0 +1,84 @@
---
# Deploy the repo-owned, sanitized Compose declaration to the host.
#
# Safety model:
# - Only hosts with an inventory `compose_repo_project` (allowlisted) are valid.
# - The repo file is staged to `<file>.dsh-new` and validated with
# `docker compose config --quiet` against the server-local .env BEFORE it
# replaces anything. A failed validation never touches the live file.
# - The current file is kept as `*.bak-<timestamp>` before promotion.
# - Apply mode requires `compose_deploy_confirm=true`; `--check` gives a
# read-only diff + validation without writes.
# - The playbook never writes, reads, or transfers the server .env.
- name: Require an allowlisted repo compose project for this host
ansible.builtin.assert:
that:
- compose_repo_project is defined
- compose_repo_project in compose_repo_projects
fail_msg: >-
No allowlisted compose_repo_project for {{ inventory_hostname }}.
Supported: {{ compose_repo_projects | join(', ') }}.
- name: Require explicit confirmation for apply mode
ansible.builtin.assert:
that:
- ansible_check_mode or (compose_deploy_confirm | bool)
fail_msg: >-
This playbook replaces the server compose file and may recreate
containers. Run with --check for a read-only diff, or supply
compose_deploy_confirm=true to apply.
- name: Stage the repo compose file next to the live one
ansible.builtin.copy:
src: "{{ playbook_dir }}/../../compose/{{ compose_repo_project }}/compose.yml"
dest: "{{ compose_project_dir }}/{{ compose_remote_file | default('compose.yml') }}.dsh-new"
mode: "0644"
diff: true
register: compose_stage
- name: Validate staged compose against the server .env (read-only)
ansible.builtin.command:
argv:
- docker
- compose
- -f
- "{{ compose_project_dir }}/{{ compose_remote_file | default('compose.yml') }}.dsh-new"
- --project-directory
- "{{ compose_project_dir }}"
- config
- --quiet
register: compose_validate
changed_when: false
failed_when: compose_validate.rc != 0
- name: Show staged-vs-live difference
ansible.builtin.debug:
msg: "{{ compose_stage.diff | default('(no change)') }}"
when: ansible_check_mode
- name: Back up the current compose file (apply mode)
ansible.builtin.shell:
cmd: >-
cp -a '{{ compose_project_dir }}/{{ compose_remote_file | default('compose.yml') }}'
'{{ compose_project_dir }}/{{ compose_remote_file | default('compose.yml') }}.bak-$(date +%Y%m%d-%H%M%S)'
when: not ansible_check_mode
- name: Promote the validated compose file (apply mode)
ansible.builtin.command:
argv:
- mv
- "{{ compose_project_dir }}/{{ compose_remote_file | default('compose.yml') }}.dsh-new"
- "{{ compose_project_dir }}/{{ compose_remote_file | default('compose.yml') }}"
when: not ansible_check_mode
- name: Apply the compose declaration (apply mode)
ansible.builtin.command:
argv:
- docker
- compose
- --project-directory
- "{{ compose_project_dir }}"
- up
- -d
when: not ansible_check_mode
+48
View File
@@ -0,0 +1,48 @@
# compose/ — repo-owned Compose declarations
Non-secret Compose sources for the Docker hosts. Secrets are **never** in these
files: every secret is a `${VAR}` reference resolved from the **server-local
`.env`** (docker compose reads `.env` from the project directory automatically).
## Source-of-truth matrix
| Project | Host | Compose source | Mechanism |
|---------|------|----------------|-----------|
| `vaultwarden` | us2 (`/opt/vaultwarden`) | `compose/vaultwarden/compose.yml` | static file + `compose-deploy.yml` |
| `pdns` | hk2 (`/opt/pdns`) | `compose/pdns/compose.yml` | static file + `compose-deploy.yml` |
| `adguardhome` | dns.windy.lan (`/opt/adguardhome`) | — (待从 LAN 提取) | static file (pending) |
| `unifi` | ubnt (`/home/windy/unifi-9`) | — (待从 LAN 提取) | static file (pending) |
| `wireguard` | us4 (`/opt/wireguard`) | `ansible/templates/wireguard-compose.yml.j2` | role-rendered (inventory vars) |
| `rustdesk` | hk2 (`/opt/rustdesk`) | `ansible/roles/rustdesk/templates/compose.yml.j2` | role-rendered (inventory vars) |
| `mailcow` | mx2 (`/opt/mail`) | — (mailcow update generator owns it) | excluded by design |
Mechanism rule: **static** `compose/<project>/compose.yml` for declarations that
do not vary per host; **role-rendered j2** for declarations driven by inventory
vars (image pins, relay host). One mechanism per project; do not duplicate a
project in both.
## Deploying a static project
```bash
cd ansible
# Read-only diff + validation against the server .env (no writes)
ansible-playbook playbooks/compose-deploy.yml --limit vaultwarden --check --diff
# Apply: stage repo file → validate `docker compose config -q` → backup current
# file → promote → `docker compose up -d` (gated)
ansible-playbook playbooks/compose-deploy.yml --limit vaultwarden \
-e '{"compose_deploy_confirm": true}'
```
See [`../runbooks/ansible-operations.md`](../runbooks/ansible-operations.md).
## Adding a project
1. Sanitize the live compose so every secret is `${VAR}` from `.env`
(prefer `${VAR:?missing VAR}` for required keys).
2. Commit `compose/<project>/compose.yml` + `.env.example` (key names only).
3. Add `compose_repo_project` (+ `compose_remote_file` if not `compose.yml`) to
the host in `ansible/inventory/hosts.yml`, and allowlist the project in
`ansible/roles/compose_deploy/defaults/main.yml`.
4. Verify with `--check --diff` (zero diff) then a gated apply.
+39
View File
@@ -0,0 +1,39 @@
# .env.example — PowerDNS stack (hk2.chans.xyz, /opt/pdns)
#
# Non-secret key reference ONLY. Real values live in the server-local .env
# (never commit them). Compose requires the `:?`-marked keys to be present.
# Runtime
TZ=Asia/Shanghai
# Postgres superuser (db + backup + pgweb)
PGUSER=
PGPASSWORD=
DB_HOST=db
DB_PORT=5432
# Application database (auth / poweradmin / backup)
DB_NAME=pdns
DB_USER=pdns
DB_PASS=
ADMIN_DB=pdnsadmin
# Backups
CRON_SCHEDULE=0 3 * * *
RETENTION_DAYS=7
MAX_BACKUPS=7
DUMP_ROLES=true
# PowerDNS auth API
PDNS_API_KEY=
# Poweradmin (first-run admin + session)
PA_SESSION_KEY=
PA_ADMIN_USERNAME=
PA_ADMIN_PASSWORD=
PA_ADMIN_EMAIL=
PA_ADMIN_FULLNAME=
# pgweb debug profile
PGWEB_USER=
PGWEB_PASS=
+159
View File
@@ -0,0 +1,159 @@
networks:
frontend:
name: traefik
external: true
backend:
internal: true
edge:
services:
db:
image: postgres:16
container_name: pdns-db
environment:
POSTGRES_DB: postgres
POSTGRES_USER: ${PGUSER:?missing PGUSER}
POSTGRES_PASSWORD: ${PGPASSWORD:?missing PGPASSWORD}
TZ: ${TZ:-Asia/Shanghai}
PGTZ: ${TZ:-Asia/Shanghai}
volumes:
# Keep the existing mount path to avoid moving the current data directory.
- dbdata:/var/lib/postgresql
- ./db-init-generated:/docker-entrypoint-initdb.d:ro
- ./backup:/backup:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
interval: 10s
timeout: 5s
retries: 10
restart: unless-stopped
networks: [backend, edge]
auth:
image: powerdns/pdns-auth-50:5.0.6
container_name: pdns-auth
depends_on:
db:
condition: service_healthy
ports:
- "53:53/udp"
- "53:53/tcp"
- "127.0.0.1:8081:8081"
environment:
PDNS_API_KEY: ${PDNS_API_KEY:?missing PDNS_API_KEY}
DB_NAME: ${DB_NAME:?missing DB_NAME}
DB_USER: ${DB_USER:?missing DB_USER}
DB_PASS: ${DB_PASS:?missing DB_PASS}
TEMPLATE_FILES: secrets
volumes:
- ./auth/pdns.conf:/etc/powerdns/pdns.conf:ro
- ./auth/templates.d:/etc/powerdns/templates.d:ro
- ./auth/keys:/var/lib/powerdns
- ./auth/import:/import
- ./auth/export:/export
- ./auth/logs:/var/log/pdns
healthcheck:
test:
[
"CMD-SHELL",
"python3 -c \"import json, os, urllib.request; req = urllib.request.Request('http://127.0.0.1:8081/api/v1/servers/localhost', headers={'X-API-Key': os.environ['PDNS_API_KEY']}); data = json.load(urllib.request.urlopen(req, timeout=3)); assert data['daemon_type'] == 'authoritative'\""
]
interval: 10s
timeout: 5s
retries: 12
restart: unless-stopped
networks: [backend, edge]
poweradmin:
image: poweradmin/poweradmin:stable
container_name: poweradmin
depends_on:
db:
condition: service_healthy
auth:
condition: service_healthy
environment:
DB_TYPE: pgsql
DB_HOST: ${DB_HOST:-db}
DB_PORT: ${DB_PORT:-5432}
DB_NAME: ${DB_NAME:?missing DB_NAME}
DB_USER: ${DB_USER:?missing DB_USER}
DB_PASS: ${DB_PASS:?missing DB_PASS}
PA_PDNS_API_URL: http://auth:8081
PA_PDNS_API_KEY: ${PDNS_API_KEY:?missing PDNS_API_KEY}
PA_DNS_BACKEND: sql
PDNS_VERSION: ${PDNS_VERSION:-50}
DNS_NS1: ${DNS_NS1:-ns1.wsvc.info}
DNS_NS2: ${DNS_NS2:-ns2.wsvc.info}
DNS_HOSTMASTER: ${DNS_HOSTMASTER:-hostmaster.wsvc.info}
PA_APP_TITLE: ${PA_APP_TITLE:-Poweradmin}
PA_TIMEZONE: ${TZ:-Asia/Shanghai}
PA_SESSION_KEY: ${PA_SESSION_KEY:?missing PA_SESSION_KEY}
PA_CREATE_ADMIN: ${PA_CREATE_ADMIN:-1}
PA_ADMIN_USERNAME: ${PA_ADMIN_USERNAME:?missing PA_ADMIN_USERNAME}
PA_ADMIN_PASSWORD: ${PA_ADMIN_PASSWORD:?missing PA_ADMIN_PASSWORD}
PA_ADMIN_EMAIL: ${PA_ADMIN_EMAIL:?missing PA_ADMIN_EMAIL}
PA_ADMIN_FULLNAME: ${PA_ADMIN_FULLNAME:?missing PA_ADMIN_FULLNAME}
TRUSTED_PROXIES: private_ranges
DEBUG: "false"
restart: unless-stopped
networks: [backend, frontend]
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik"
- "traefik.http.routers.poweradmin.rule=Host(`pdns.wsvc.info`)"
- "traefik.http.routers.poweradmin.entrypoints=websecure"
- "traefik.http.routers.poweradmin.tls.certresolver=letsencrypt"
- "traefik.http.services.poweradmin.loadbalancer.server.port=80"
backup:
# Use postgres:16 so bash/pg_dump/flock exist without runtime package installs.
# backend is internal:true — Alpine apk at start cannot reach mirrors.
image: postgres:16
container_name: pdns-backup
depends_on:
db:
condition: service_healthy
environment:
TZ: ${TZ:-Asia/Shanghai}
DB_HOST: ${DB_HOST:-db}
DB_PORT: ${DB_PORT:-5432}
DB_USER: ${PGUSER:?missing PGUSER}
DB_PASS: ${PGPASSWORD:?missing PGPASSWORD}
DB_NAME: ${DB_NAME:?missing DB_NAME}
RETENTION_DAYS: ${RETENTION_DAYS:-7}
MAX_BACKUPS: ${MAX_BACKUPS:-7}
DUMP_ROLES: ${DUMP_ROLES:-true}
CRON_SCHEDULE: ${CRON_SCHEDULE:?missing CRON_SCHEDULE}
volumes:
- ./backup:/backup
- ./scripts:/scripts:ro
entrypoint: ["/bin/bash", "/scripts/backup-scheduler.sh"]
restart: unless-stopped
networks: [backend]
pgweb:
image: sosedoff/pgweb:0.16.2
container_name: pdns_pgweb
restart: unless-stopped
environment:
PGWEB_DATABASE_URL: "postgres://${PGUSER:?missing PGUSER}:${PGPASSWORD:?missing PGPASSWORD}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:?missing DB_NAME}?sslmode=disable"
PGWEB_AUTH_USER: ${PGWEB_USER:?missing PGWEB_USER}
PGWEB_AUTH_PASS: ${PGWEB_PASS:?missing PGWEB_PASS}
TZ: ${TZ:-Asia/Shanghai}
depends_on:
db:
condition: service_healthy
networks: [backend, frontend]
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik"
- "traefik.http.routers.pgweb.rule=Host(`pgweb.wsvc.info`)"
- "traefik.http.routers.pgweb.entrypoints=websecure"
- "traefik.http.routers.pgweb.tls.certresolver=letsencrypt"
- "traefik.http.services.pgweb.loadbalancer.server.port=8081"
volumes:
dbdata: {}
+38
View File
@@ -0,0 +1,38 @@
# .env.example — Vaultwarden (us2.wsvc.info, /opt/vaultwarden)
#
# Non-secret key reference ONLY. Real values live in the server-local .env
# (never commit them). Copy the keys below into the server .env if a key is
# missing; the compose file requires them via ${VAR} / env_file.
# Service identity
DOMAIN=https://auth.wsvc.info
TEMPLATES_FOLDER=
# Postgres (compose services vaultwarden / backup / pg / pgweb)
DB_HOST=pg
DB_PORT=5432
DB_NAME=vaultwarden
DB_USER=vaultwarden
DB_PASS=
# pgweb debug profile
PGWEB_USER=
PGWEB_PASS=
PGWEB_DATABASE_URL=
# SMTP (mailcow mx2.windy.me:587 starttls)
SMTP_HOST=mx2.windy.me
SMTP_PORT=587
SMTP_SECURITY=starttls
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM=
HELO_NAME=
# Admin console
ADMIN_TOKEN=
# Runtime
UID=1000
GID=1000
IP_HEADER=X-Forwarded-For
+107
View File
@@ -0,0 +1,107 @@
services:
vaultwarden:
image: vaultwarden/server:1.37.1
container_name: vaultwarden
restart: unless-stopped
env_file: ".env"
environment:
DOMAIN: "https://auth.wsvc.info"
DATABASE_URL: "postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${DB_NAME}"
volumes:
- ./vw-data:/data
extra_hosts:
- "mx2.windy.me:194.163.160.244"
networks:
- net
depends_on:
pg:
condition: service_healthy
labels:
- "traefik.enable=true"
- "traefik.docker.network=vw-net"
- "traefik.http.routers.vaultwarden.rule=Host(`auth.wsvc.info`)"
- "traefik.http.routers.vaultwarden.entrypoints=websecure"
- "traefik.http.routers.vaultwarden.tls=true"
- "traefik.http.routers.vaultwarden.tls.certresolver=letsencrypt"
- "traefik.http.services.vaultwarden.loadbalancer.server.port=80"
backup:
build:
context: .
dockerfile: Dockerfile.backup
container_name: vaultwarden-backup
restart: unless-stopped
volumes:
- ./backups:/backup
- ./scripts:/scripts
#user: "${UID:-1000}:${GID:-1000}"
environment:
DB_HOST: ${DB_HOST}
DB_PORT: ${DB_PORT}
DB_USER: ${DB_USER}
DB_NAME: ${DB_NAME}
DB_PASS: ${DB_PASS}
BACKUP_UID: ${UID:-0}
BACKUP_GID: ${GID:-0}
TZ: Asia/Shanghai
entrypoint: >
/bin/sh -ec "
umask 077 &&
printf '%s:%s:*:%s:%s\n' \"$$DB_HOST\" \"$$DB_PORT\" \"$$DB_USER\" \"$$DB_PASS\" > /root/.pgpass &&
chmod 600 /root/.pgpass &&
touch /backup/backup.log &&
crontab /scripts/crontab.txt &&
echo '[INFO] Backup cron installed' &&
echo '[INFO] Starting crond...' &&
crond -f -l 8
"
networks: [net]
pg:
image: postgres:16
container_name: vw-db
restart: unless-stopped
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASS}
TZ: Asia/Shanghai
PGTZ: Asia/Shanghai
volumes:
- vwdata:/var/lib/postgresql/data
- ./backups:/backup # to import existing dump
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 10s
timeout: 5s
retries: 10
networks: [net]
pgweb:
profiles: ["debug"]
image: sosedoff/pgweb:0.16.2
container_name: vaultwarden-pgweb
restart: unless-stopped
environment:
# 用 Vaultwarden 的数据库参数拼接连接串
#DATABASE_URL: "postgres://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=disable"
PGWEB_AUTH_USER: ${PGWEB_USER}
PGWEB_AUTH_PASS: ${PGWEB_PASS}
TZ: Asia/Shanghai
#ports:
# - "8082:8081" # 本地访问 http://localhost:8082
depends_on:
pg:
condition: service_healthy
networks: [net]
networks:
net:
name: vw-net
external: true
volumes:
vwdata: {}
+13
View File
@@ -0,0 +1,13 @@
# docs/archive — 归档文档
归档 = 单次调研、已过期,或与当前运维无行动指向的内容。恢复使用前先确认
内容仍与线上状态一致(本仓库原则:先证据后变更,live state 优先)。
## 归档清单
| 文件 | 归档日期 | 原位置 | 说明 |
|------|---------|--------|------|
| `lan-dns-alternatives.md` | 2026-08-17 | `docs/` | DNS 技术选型调研,零引用,无在途决策 |
> 购物类文档(打印机购买指南、交换机选型调研)已按整改计划移入 Obsidian
> vault`~/Documents/vault/my-vault/02_Areas/House/`),不在本目录。
@@ -3,7 +3,7 @@
**Status: research only. No configuration was changed.** This page evaluates **Status: research only. No configuration was changed.** This page evaluates
resolvers/splitters that are genuinely better than — or meaningfully different resolvers/splitters that are genuinely better than — or meaningfully different
from — the current "AdGuard Home (AGH) + mosdns" setup on from — the current "AdGuard Home (AGH) + mosdns" setup on
[`dns.windy.lan`](../hosts/dns.windy.lan.md) (`.36`), for a GFW-constrained [`dns.windy.lan`](../../hosts/dns.windy.lan.md) (`.36`), for a GFW-constrained
China home LAN. Claims are cited to primary sources (official repos, official China home LAN. Claims are cited to primary sources (official repos, official
docs, upstream READMEs); anything not verified is flagged as such. docs, upstream READMEs); anything not verified is flagged as such.
@@ -11,11 +11,11 @@ docs, upstream READMEs); anything not verified is flagged as such.
> verification — mosdns on `.1` is **not idle**, it is clash's > verification — mosdns on `.1` is **not idle**, it is clash's
> `nameserver`/`default-nameserver` (DIRECT-rule real-IP resolution); the > `nameserver`/`default-nameserver` (DIRECT-rule real-IP resolution); the
> canonical decision record is > canonical decision record is
> [`lan-dns-architecture.md`](lan-dns-architecture.md) (final verdict aligned, > [`lan-dns-architecture.md`](../lan-dns-architecture.md) (final verdict aligned,
> Phase 0 kill-test evidence incl. a measured upstream-blackhole degradation > Phase 0 kill-test evidence incl. a measured upstream-blackhole degradation
> gap). > gap).
Scope recap (from [`lan-overview.md`](lan-overview.md), verified 2026-08-06): Scope recap (from [`lan-overview.md`](../lan-overview.md), verified 2026-08-06):
- Clients get DNS via EdgeRouter DHCP option 6 → AGH `192.168.66.36:53`. - Clients get DNS via EdgeRouter DHCP option 6 → AGH `192.168.66.36:53`.
- AGH upstreams: `dns.alidns.com` + `doh.pub` DoH (load-balanced), fallback - AGH upstreams: `dns.alidns.com` + `doh.pub` DoH (load-balanced), fallback
@@ -58,7 +58,7 @@ The genuinely worthwhile changes, in order of value:
3. **mosdns on `.1` is resolved, not idle** — it is clash's 3. **mosdns on `.1` is resolved, not idle** — it is clash's
`nameserver`/`default-nameserver` (DIRECT-rule real-IP resolution, verified `nameserver`/`default-nameserver` (DIRECT-rule real-IP resolution, verified
2026-08-12), so "delete it" is off the table; its role is documented in 2026-08-12), so "delete it" is off the table; its role is documented in
[`lan-dns-architecture.md`](lan-dns-architecture.md) §1. If a future change [`lan-dns-architecture.md`](../lan-dns-architecture.md) §1. If a future change
moves this role to an AGH-side companion, keep in mind mosdns's cache strips moves this role to an AGH-side companion, keep in mind mosdns's cache strips
EDNS0 and it performs no DNSSEC validation EDNS0 and it performs no DNSSEC validation
([mosdns v5 executable plugins](https://irine-sistiana.gitbook.io/mosdns-wiki/mosdns-v5/ru-he-pei-zhi-mosdns/ke-zhi-xing-cha-jian.md)). ([mosdns v5 executable plugins](https://irine-sistiana.gitbook.io/mosdns-wiki/mosdns-v5/ru-he-pei-zhi-mosdns/ke-zhi-xing-cha-jian.md)).
@@ -517,18 +517,18 @@ AGH .36 (filtering, rewrites, query log, per-client upstreams)
TPROXY/fake-ip/DNS-hijack rules; co-locating LAN DNS there couples DNS to the TPROXY/fake-ip/DNS-hijack rules; co-locating LAN DNS there couples DNS to the
proxy and its restart/update lifecycle. proxy and its restart/update lifecycle.
- A standalone resolver VM adds nothing: both current VMs already sit on the - A standalone resolver VM adds nothing: both current VMs already sit on the
same PVE hypervisor ([lan-overview.md](lan-overview.md) §Positioning facts), same PVE hypervisor ([lan-overview.md](../lan-overview.md) §Positioning facts),
so a hypervisor outage takes out either placement equally; a second physical so a hypervisor outage takes out either placement equally; a second physical
host for HA is out of scope for a home LAN. host for HA is out of scope for a home LAN.
- If you ever run a validating resolver + AGH on `.36`, verify outbound from - If you ever run a validating resolver + AGH on `.36`, verify outbound from
`.36` to the foreign upstreams is not re-hijacked by OpenClash (loop check `.36` to the foreign upstreams is not re-hijacked by OpenClash (loop check
already mandated in the [AGH review](adguard-home-official-review-2026-08.md)). already mandated in the [AGH review](../adguard-home-official-review-2026-08.md)).
### 4.4 Fail-open, DNSSEC, private names — by candidate ### 4.4 Fail-open, DNSSEC, private names — by candidate
| Concern | How the recommended stack behaves | | Concern | How the recommended stack behaves |
|---|---| |---|---|
| Fail-open when proxy/subscription down | AGH forwards directly to DoH upstreams; `.36`'s outbound is not forced through the proxy in normal ops (no TUN policy routing on `.36` — [dns host facts](../hosts/dns.windy.lan.md)). With unbound/blocky behind, foreign resolution recurses/validates directly, independent of OpenClash. Avoid mihomo-DNS-as-resolver, which is proxy-coupled. | | Fail-open when proxy/subscription down | AGH forwards directly to DoH upstreams; `.36`'s outbound is not forced through the proxy in normal ops (no TUN policy routing on `.36` — [dns host facts](../../hosts/dns.windy.lan.md)). With unbound/blocky behind, foreign resolution recurses/validates directly, independent of OpenClash. Avoid mihomo-DNS-as-resolver, which is proxy-coupled. |
| DNSSEC validation | Only unbound, blocky, knot-resolver, Technitium validate in-process. AGH sets DO only; mosdns/smartdns/chinadns-ng/mihomo/sing-box do not. Plan: validate behind AGH, or accept "validating public upstream" (confirm with `dig +dnssec`/known-bad test). | | DNSSEC validation | Only unbound, blocky, knot-resolver, Technitium validate in-process. AGH sets DO only; mosdns/smartdns/chinadns-ng/mihomo/sing-box do not. Plan: validate behind AGH, or accept "validating public upstream" (confirm with `dig +dnssec`/known-bad test). |
| Private names / rewrites | AGH `rewrites` (already in use for `hass.windy.lan`) + `local_ptr_upstreams` once a local PTR source exists. blocky: `customDNS` mapping/rewrite + hosts. unbound: `local-zone`. All adequate. | | Private names / rewrites | AGH `rewrites` (already in use for `hass.windy.lan`) + `local_ptr_upstreams` once a local PTR source exists. blocky: `customDNS` mapping/rewrite + hosts. unbound: `local-zone`. All adequate. |
| Query log / visibility | AGH is the best at this of everything evaluated (14-day anonymized log already configured). | | Query log / visibility | AGH is the best at this of everything evaluated (14-day anonymized log already configured). |
@@ -551,7 +551,7 @@ Concrete, in increasing effort:
the known-bad-signature check, then enable AGH DNSSEC. Without this, AGH's the known-bad-signature check, then enable AGH DNSSEC. Without this, AGH's
`enable_dnssec` is only a DO-flag — the exact reason it is currently off `enable_dnssec` is only a DO-flag — the exact reason it is currently off
([AGH DNSSEC semantics](https://adguard-dns.io/kb/adguard-home/configuration/), ([AGH DNSSEC semantics](https://adguard-dns.io/kb/adguard-home/configuration/),
[host facts](../hosts/dns.windy.lan.md)). [host facts](../../hosts/dns.windy.lan.md)).
3. **Either fully configure mosdns (systemd service, sequence, lists) or remove 3. **Either fully configure mosdns (systemd service, sequence, lists) or remove
it.** Leaving an idle `127.0.0.1:6052` listener documented as "not the active it.** Leaving an idle `127.0.0.1:6052` listener documented as "not the active
path" is drift. If kept, plan around no-EDNS0 cache + no validation; if path" is drift. If kept, plan around no-EDNS0 cache + no validation; if
@@ -559,9 +559,9 @@ Concrete, in increasing effort:
4. **Close the private-PTR gap**: once a local authoritative source exists (e.g. 4. **Close the private-PTR gap**: once a local authoritative source exists (e.g.
dnsmasq on `gw`, or a tiny authoritative zone), point AGH dnsmasq on `gw`, or a tiny authoritative zone), point AGH
`local_ptr_upstreams` at it as the AGH review recommends `local_ptr_upstreams` at it as the AGH review recommends
([AGH review](adguard-home-official-review-2026-08.md)); ([AGH review](../adguard-home-official-review-2026-08.md));
don't set it before that source exists don't set it before that source exists
([dns host facts](../hosts/dns.windy.lan.md)). ([dns host facts](../../hosts/dns.windy.lan.md)).
5. **Optional: ECS** for CDN geo-accuracy — AGH `edns_client_subnet.use_custom` 5. **Optional: ECS** for CDN geo-accuracy — AGH `edns_client_subnet.use_custom`
with a coarse fixed prefix (or blocky `ecs.forward`) if measurements show a with a coarse fixed prefix (or blocky `ecs.forward`) if measurements show a
benefit; note many CN resolvers ignore ECS benefit; note many CN resolvers ignore ECS
@@ -579,7 +579,7 @@ here beats it on that axis for this LAN.
- **Live behavior not tested**: all capability claims are from primary docs - **Live behavior not tested**: all capability claims are from primary docs
reviewed 2026-08-12; DNSSEC behavior of `dns.alidns.com`/`doh.pub`/the reviewed 2026-08-12; DNSSEC behavior of `dns.alidns.com`/`doh.pub`/the
`adg.chans.xyz` path and mosdns's actual version on `.36` need on-box `adg.chans.xyz` path and mosdns's actual version on `.36` need on-box
`dig +dnssec` verification (per [adguard-home-health](../runbooks/adguard-home-health.md)). `dig +dnssec` verification (per [adguard-home-health](../../runbooks/adguard-home-health.md)).
- **smartdns DNSSEC**: the official config reference lists no DNSSEC option; - **smartdns DNSSEC**: the official config reference lists no DNSSEC option;
if a newer version added one, it is not reflected here if a newer version added one, it is not reflected here
([config options](https://pymumu.github.io/smartdns/configuration/)). ([config options](https://pymumu.github.io/smartdns/configuration/)).
@@ -598,8 +598,8 @@ here beats it on that axis for this LAN.
## Related docs ## Related docs
- [lan-overview.md](lan-overview.md) — full topology (verified 2026-08-06) - [lan-overview.md](../lan-overview.md) — full topology (verified 2026-08-06)
- [hosts/dns.windy.lan.md](../hosts/dns.windy.lan.md) — AGH host facts - [hosts/dns.windy.lan.md](../../hosts/dns.windy.lan.md) — AGH host facts
- [hosts/gfw.windy.lan.md](../hosts/gfw.windy.lan.md) — OpenClash facts - [hosts/gfw.windy.lan.md](../../hosts/gfw.windy.lan.md) — OpenClash facts
- [adguard-home-official-review-2026-08.md](adguard-home-official-review-2026-08.md) — prior AGH config review - [adguard-home-official-review-2026-08.md](../adguard-home-official-review-2026-08.md) — prior AGH config review
- [runbooks/adguard-home-health.md](../runbooks/adguard-home-health.md) - [runbooks/adguard-home-health.md](../../runbooks/adguard-home-health.md)
@@ -1,67 +0,0 @@
# 低打印量黑白激光一体机:采购决策树(2026-08)
## 结论
对于“打印量很小、20 页/分钟足够、不需要 ADF”的需求,不应为 34 页/分钟、250 页纸盒或双面 ADF 付费。它们解决的是连续文档处理,而不是偶尔打印。优先级应为:**不被耗材/账号绑定、稳定的局域网打印、平板扫描、紧凑尺寸和可获得的原装耗材**。
首选是 **Brother DCP-L1638W 或 DCP-L1848W**:两者都是传统鼓粉分离路线,20 ppm、150 页纸盒、百兆有线网口及 2.4/5 GHz Wi-Fi;官方资料不能证实 1848 相比 1638 有实质功能升级,因此按正规渠道的含税到手价和保修选较便宜、在售的一台即可。[L1638W 官方参数表](https://www.brother.cn/-/media/ap/cn/products/pdf-file/prt/ESL-done/DCP-L1628L1638W.ashx)[L1848W 官方参数表](https://www.brother.cn/-/media/ap/cn/products/pdf-file/prt/ESL_DCP-L1848W.ashx)
不把 Brother DCP-11W 作为默认首选。它是 Brother 当前标为新品的同级机器,却是“云充”按页模式:激活后含 700 页,页数用完要通过绑定的微信账户购买套餐才能继续打印;粉仓和硒鼓由官方免费提供。这适合愿意换取三年保修和明确按页预算的人,不适合希望长期离线、自主选择耗材的人。[新品发布](https://www.brother.cn/info/news/20250613)[云充规则](https://www.brother.cn/minisite/sppackage/esl/)
## 先按需求分流,而不是按品牌或 ppm
```text
需要批量扫描/复印多页原稿?
├─ 是 → 本指南不适用;选择带 ADF 的 L2648DW 等级,双面原稿高频则看双 CIS 机型。
└─ 否
└─ 需要自动双面打印?
├─ 是 → 选择 B7628DW 等带 duplex 的型号;这是功能升级,不是速度升级。
└─ 否
└─ 必须有有线网口或 5 GHz Wi-Fi
├─ 是 → Brother L1638W/L1848W 为基准选择。
└─ 否 / 接受仅 2.4 GHz Wi-Fi
└─ 是否接受按页充值并绑定微信?
├─ 是 → Brother DCP-11W(先比较套餐与传统耗材总价)。
└─ 否 → L1638W/L1848W;或比较下列 Canon/HP/Pantum。
```
### 采购前的一票否决项
- 需要 macOS、iPhone/iPad:确认 AirPrint;不要假定“Wi-Fi”就等于无需驱动。
- 打印机准备接交换机:确认具体 SKU 有 **Ethernet**,不能把 Wi-Fi Direct 当作局域网网口。HP MFP 1188w 的中国规格仅列 USB 和 2.4 GHz Wi-Fi,不列有线网口。
- 偶尔使用也建议选激光,但纸张要长期放在干燥、封闭处;低使用量时,受潮纸和旧粉盒造成的底灰、掉粉或卡纸比 20/22 ppm 差异更常见。
- 需要自动双面打印、ADF、双面扫描时,直接跳级;入门平板机硬凑这些功能没有性价比。
## 候选机型:仅保留与需求相符者
| 机型 | 联网 / 移动打印 | 核心纸路与扫描 | 耗材结构 | 面向本需求的判断 |
|---|---|---|---|---|
| **Brother DCP-L1638W / L1848W** | USB、100M Ethernet、2.4/5 GHz Wi-FiAirPrint、Mopria、Wireless Direct、Brother Mobile Connect | 20 ppm、150 页进/50 页出、平板 CIS;无 ADF、无自动双面 | TN118 约 1,500 页 + DR118 约 10,000 页,鼓粉分离 | **首选**:功能刚好够用,网络规格最好,自主耗材路径最清晰。两台按价格/现货择一。 |
| **Brother DCP-11W** | USB、100M Ethernet、2.4/5 GHz Wi-Fi / Wi-Fi Direct | 20 ppm、150 页纸盒、平板扫描;未列 ADF 或自动双面 | 云充按页;耗材由官方供给 | 只有明确接受微信充值、看重三年保修时才选;不是“便宜传统激光机”。 |
| **Pantum M6509NW** | USB、100M Ethernet、2.4 GHz 802.11b/g/n Wi-Fi;自带热点 | 22 ppm、150 页进/100 页出、平板扫描;手动双面 | 鼓粉一体 PD-219,官方标称 1,600 页 | **价格明显更低时的可比替代**。接口齐全,支持扫 PC/邮件/FTP/移动端,但机身较宽、仅 2.4 GHz,且鼓粉一体意味着每次换耗材同时更换感光组件。 |
| **HP Laser MFP 1188w** | USB、2.4 GHz 802.11b/g/n、Wi-Fi Direct、AirPrint/Mopria/HP 应用;**无 Ethernet** | 22 ppm、150 页进/100 页出、平板扫描;手动双面 | 一体式黑色硒鼓;随机约 1,500 页 | 仅无线且 2.4 GHz 能满足时再比价。优点是 AirPrint/Mopria 明确、首张页快;不符合“网口或双频”优先条件。 |
| **Canon iC MF232w** | Ethernet、2.4 GHz Wi-Fi、AirPrint、网络/移动扫描 | 23 ppm、250 页纸盒、平板扫描;无自动双面 | CRG337 一体式硒鼓 2,400 页 | 纸盒需求确实较大才考虑。官方建议价较高,且产品规格呈现的是较老的 IPv4 / 2.4 GHz 组合,不是本需求下的优先解。 |
| **Canon iC MF272dw** | Ethernet、2.4 GHz Wi-Fi、AirPrint/Mopria | 29 ppm、150 页纸盒、平板扫描、自动双面打印 | CRG071700 页随机、1,200/2,500 页商品硒鼓 | 功能不错但属于为自动双面打印升级;官方建议价 ¥3,838,不适合低量、单面为主时以性价比为目标的采购。 |
Brother 规格与耗材页数以官方参数表为准;Pantum 的接口、PD-219 和建议月印量 2502,000 页见[官方产品页](https://www.pantum.cn/product-center/1487019260672548865.html)。HP 的 22 ppm、150 页、仅 Wi-Fi/USB、手动双面和一年保修见[中国官方规格](https://support.hp.com/cn-zh/product/product-specs/hp/2101513893)。Canon MF232w 的 23 ppm、250 页、CRG337、IPv4 和接口见[官方规格](https://www.canon.com.cn/product/icmf232w/spec.html)MF272dw 的 29 ppm、自动双面、接口及 CRG071 页数见[官方规格](https://www.canon.com.cn/product/icmf272dw/spec.html)。
## 耗材与锁定:应怎样理解
**不要只用“每页成本”决定低量用户。** 一年只打印几十到几百页时,机器差价、过期/存放不当的耗材风险和购买便利性,通常超过高容量粉盒带来的单位页优势。页产量也是 ISO 覆盖率下的额定值,不等于实际能稳定打印的页数。
- **传统耗材(Brother L1638W/L1848W**:粉盒和硒鼓分开,硒鼓寿命远高于单盒粉量;这是长期低量使用中最可预测的结构。原装 TN118 / DR118 的料号和页数已由 Brother 公布。第三方粉盒或灌粉可以降低成本,但不属于厂商性能/保修承诺;低量用户省下的钱很有限,反而更容易把故障归因变复杂。建议首个生命周期使用原装或可靠授权渠道耗材。
- **云充(DCP-11W)**:这里的锁定不是“第三方粉盒风险”,而是服务依赖:打印资格、套餐和耗材供给都依赖绑定的微信/官方流程。购买前应把预计三年页数代入套餐,确认账号更换、迁移、停服或转让场景的处理规则;并接受双面一张按两页计。
- **一体式硒鼓(Pantum、HP、Canon)**:换粉即换鼓,维护动作简单;缺点是无法像鼓粉分离机那样只更换粉盒。不要据此推断“第三方一定不能用”或“必然会被固件锁死”——厂商公开资料通常只承诺原装耗材效果/保修,兼容耗材的芯片兼容性、质量和售后由销售方承担,应按批次验证。
## 最终推荐与购买动作
1. **默认买 Brother DCP-L1638W 或 DCP-L1848W**:选到手价更低、可开票、有本地退换/保修的那个;功能层面无需为 1848 付溢价。
2. 若二者断货或溢价过大,**Pantum M6509NW** 是功能不降级的对照品;要求 5 GHz Wi-Fi 时排除它。
3. 若只用手机/2.4 GHz Wi-Fi,且 HP 的即时价格有明显优势,才纳入 **HP 1188w**;它没有网口,不能接入现有有线网络。
4. **不要因为“最新”买 DCP-11W**,除非云充模式本身是主动选择。对低量家庭用户,耗材自主权通常比三年保修更重要。
到货后先完成一次有线或基础 Wi-Fi 配网、AirPrint/Windows/macOS 实测、扫描为 PDF、睡眠唤醒和一张双面手动测试;保留试机页与发票。将设备放在受信任 LAN;如果启用 Wi-Fi Direct,设置强口令,平时不需要则关闭。
## 调研边界
本表只比较中国市场仍可由厂商官方页面/支持页核实的代表 SKU,价格、实际库存和促销会实时变化,未把电商标价写入结论。所谓“最新”以 Brother 中国目录/公告为准,而非“功能最强”或“最适合”。资料核查日期:2026-08-11。
@@ -1,158 +0,0 @@
# 希力威视 SR-S25G3218F 调查(2026-08-08
**结论:** 若需求是大量 2.5G 终端、少量 10G 光上联,`SR-S25G3218F` 的端口密度
更合适;厂商已公开该型号的固件页,但仍缺少完整规格书、管理手册与兼容矩阵。若需求是 8 条全部可协商
1/2.5/5/10G 的铜缆链路,且希望有可查的 L3 能力和固件入口,兮克
`SKS8300-8T` 是资料更完整、风险更低的选择;它的代价是主动风扇、外置 12 V 电源、
无 SFP+ 光口,且仍不应把消费级/SMB 设备当作安全边界或唯一核心。两者都应在
到货可退换期内完成实机验收。
本页为采购前资料调查,不代表已接入本地网络;检索日期为 2026-08-08。
## 已能核实的事项
| 项目 | 结论与证据强度 |
|---|---|
| 型号/端口 | 京东的希力威视商品标题称该 SKU 为 `SR-S25G3218F`,有 16 个 2.5G 电口和 2 个万兆光口,并宣传 VLAN、端口隔离与 LACP。该店铺被厂商官网列为可购买的「京东旗舰店」,因此可作为销售规格,非技术手册。[京东商品页](https://item.jd.com/100165071727.html)[厂商购买渠道说明](https://en.sirivision.com/contactus/) |
| 厂商身份 | 厂商官网为 Shenzhen/Guangdong Sirivision Communication;英文官网说明其自 2016 年起提供接入、汇聚和核心交换机方案。[厂商首页](https://en.sirivision.com/) |
| 公开的二手厂家资料 | 同一制造商名义的 Alibaba 出口页将精确型号写成 `16*2.5G+2*10G``120Gbps`,并列出 QoS、VLAN、SNMP、L3 与 stackable。这是制造商发布在平台上的销售资料,**不是**官网数据表;其中后五项不能据此视为已验收的功能承诺。[制造商平台页](https://www.alibaba.com/pla/SR-S25G3218F-QoS-Managed-SFP-Switch-1625G210G_1601494946214.html) |
| 固件入口 | 厂商已发布此精确型号的[固件页](https://www.sirivision.com/sr-s25g3218f%E5%9B%BA%E4%BB%B6/)。公开变更记录提到“光口自适应”和“增加 DAC 配置”;这证明厂商维护过该路径,**不**代表任意 SFP+/DAC/铜模块均兼容。 |
| 本机可计算的带宽 | 端口线速相加为单向 60 Gb/s16 × 2.5 + 2 × 10);若厂商所谓 `120Gbps` 是全双工交换容量,则数学上吻合。它**不**证明缓冲、PPS、表项规模或实际无阻塞性能。 |
## 网管/L2/L3 能力边界
京东标题足以支持把 VLAN、端口隔离、LACP 作为「卖家声称提供」的功能;不得由此推导出
ACL、IPv4/IPv6 静态路由、SVI 数量、DHCP relay、OSPF/RIP、VRRP、IGMP、ERPS、
802.1X、RADIUS/TACACS+、SSH/HTTPS 管理、SNMP 版本、日志/审计、配置备份或固件
安全维护一定存在。
尤其要注意:厂商官网把真正列出的 2.5G L3 产品标为
`SR-S25G3412F (8 × 2.5G + 4 × 10G SFP+)`;其 2.5G 类目只显示 7 个型号,
不含 `SR-S25G3218F`。官网也把 L2+、Web Smart、L3 分成不同产品类别。这个目录
差异**不是**证明 3218F 没有 L3,而是说明「三层」无法通过官网的精确型号文档确认。
[2.5G 产品目录](https://en.sirivision.com/product-category/products/2-5g-switches/)
[官网的 10G L3 目录](https://en.sirivision.com/product-category/products/10g-switches/10g-layer3-managed-switches/)
[官网的 L2+ 分类示例](https://en.sirivision.com/product-category/products/gigabit-switches/gigabit-layer2-managed-switches/)。
采购前请向京东/厂商索取**与机身 SKU、硬件 revision 和固件版本对应**的 PDF
数据表、管理手册和 release notes,并要求书面回答至少以下问题:
1. L3 是只有 VLAN Interface/IPv4 静态路由,还是另有 IPv6、ACL、动态路由、DHCP relay
等;每项的最大 VLAN、MAC、ARP、路由、ACL、LAG 数量分别是多少?
2. LACP 是否符合 802.3ad、一个 LAG 最多多少成员、能否跨两台设备(若销售页的
`stackable` 属实,堆叠的线缆/模块、最大成员、控制面和软件版本为何)?
3. 管理面是否支持 HTTPS/SSH、禁用 HTTP/Telnet、独立管理 VLAN、SNMPv3、syslog、NTP、
配置导出/回滚和已签名或可校验的固件;默认凭据首次登录是否强制修改?
## 供电、散热和光口:当前不能确认
针对该精确 SKU,厂商官网目录与公开搜索未找到说明书/数据表,所以以下均为**待确认,
不能猜测**
- 是否为内置 AC 电源、额定输入范围/最大功耗、是否带电源开关和接地端子;是否完全
不提供 PoE(本型号名和京东标题均未写 PoE,但这不足以替代规格书)。
- 风扇数量、常态/满载噪声、风向、环境温湿度、机架深度与安装耳;不要将「金属壳」
或产品照片等同于无风扇/静音。
- 两个槽是否均为 **10G SFP+**,是否可协商 1G SFP;支持的 SR/LR/BiDi 波长距离、
DAC/AOC 长度、第三方模块/EERPOM 兼容策略、10GBASE-T SFP+ 模块的功耗/温度限制,
以及是否支持 GPON/XPON ONU「猫棒」。
厂商确实单列「SFP Optical Modules」产品分类,但这不构成 3218F 的兼容清单。
[厂商产品导航](https://en.sirivision.com/)。购买光模块/直连线时,应要求厂商按这台
设备的硬件/固件 revision 出具兼容型号清单;没有书面清单时,先在可退换期实测两端的
链路、重启恢复、热插拔与长时间满载错误计数。
## 风险与建议验收
- **文档/生命周期风险(中到高):** 精确型号不在厂商当前官网 2.5G 目录,虽有固件下载页,
但未公开完整型号手册、明确 release notes 或兼容矩阵。官网的售后条款也要求按具体产品查询保修期,配件(含光纤头)
的保修条款与主机不同;不要把平台页的「3 年」当作中国零售 SKU 的已确认保修。
[厂商售后条款](https://en.sirivision.com/after-sale-protection/)
- **功能表述风险(高):** 页面将 L2 特性和「三层网管」并列;在命令/网页菜单、
手册和测试证明之前,将其当作 L2 VLAN/LACP 设备部署,跨 VLAN 路由仍由现有网关承担。
- **双 10G 上联约束(中):** 两个 SFP+ 可作双上联或一个二成员 LAG,但 LAG 增加的是
多流量总吞吐,单一 TCP/UDP 流通常仍受一条 10G 链路限制;上级设备也必须匹配 LACP
配置。
- **管理面风险(中到高):** 家用/低价网管设备常见明文管理、弱默认口令或不透明的固件
更新周期;采购后先置于受限管理 VLAN,改口令、升级已验证固件,且不将管理界面暴露
到 WAN/访客网。
最低验收应包括:逐口协商 100M/1G/2.5G、两只不同厂家 SFP+/DAC(仅在卖家承诺支持的
范围内)、VLAN trunk/access/PVID、STP/环路保护、LACP 故障切换、端口隔离、满载
双向 iperf3 与错误计数、冷启动后的配置保留,以及管理面的 HTTPS/SSH/SNMPv3/配置备份。
如无法提供与型号匹配的正式资料或其中任一关键项失败,应在退换期内退货,并选择公开
数据表、固件与兼容矩阵更完整的型号。
## 备选:兮克 SKS8300-8T 对比
### 已核实的厂商规格
兮克官网的精确型号页明确将 `SKS8300-8T` 定位为三层管理型 10G 全电口交换机,并列出:
- 8 × 1/2.5/5/10GBASE-T RJ45160 Gb/s 交换容量、119.05 Mpps、12 Mbit 缓存、
16K MAC、12 KB 巨帧、512 MB DRAM、32 MB Flash,尺寸 207 × 136 × 35 mm
- QoS、ACL、IP+MAC+端口绑定、流分类/优先级标记、多端口镜像、静态/灵活 QinQ、
sFlow,以及「基于策略的 IPv4/IPv6 单播路由」。
这些是厂商能力声明,并非对每一种路由协议或表项上限的承诺;但相对 3218F 的仅有
销售标题,它给出了精确型号、转发性能和 L3 范围。[兮克 SKS8300-8T
产品页](https://seekswan.com/user/custom-pages/SKS8300-8T.html)
独立的 OpenWrt 设备资料将其识别为 Realtek RTL9303、512 MB RAM,记录了原厂固件
下载入口和串口/TFTP 恢复路径;其硬件数据页列为 12 V / 4 A。这支持「可恢复、可替换
系统」的可操作性,但**不是**兮克对原厂功能的支持承诺。
[OpenWrt 设备页](https://openwrt.org/toh/xikestor/sks8300-8t)
[OpenWrt 硬件数据](https://openwrt.org/toh/hwdata/xikestor/xikestor_sks8300-8t)。
### 能力、物理与运维比较
| 维度 | 希力威视 SR-S25G3218F | 兮克 SKS8300-8T |
|---|---|---|
| 接口/典型用途 | 16 × 2.5G 电口 + 2 × 10G SFP+(销售规格);适合很多 2.5G 终端/NAS,以 10G 光或 DAC 上联。 | 8 × 1/2.5/5/10GBASE-T;适合 10G 铜缆设备、2.5/5G 多速率 NAS/主机。没有 SFP+,光纤上联必须经媒体转换或选另一型号。 |
| 可确认的三层范围 | 仅销售/平台资料称 L3;没有精确型号官方手册,不能确认静态路由以外的功能。 | 官网明确写策略型 IPv4/IPv6 单播路由、ACL/QoS/sFlow/QinQ;动态路由、VRRP、IPv6 ACL/SNMP/认证等仍须按当前固件手册确认。 |
| 冗余/二层 | 卖家声称 VLAN、端口隔离、LACP;STP/环网的实现与规格未知。 | 官网声明 L3 和多项转发特性,但未在产品页给出 STP/LACP/ERPS 的精确限制;购买前仍索取手册。 |
| 散热/噪声 | 无可核实的精确型号风扇、噪声、功耗或风向数据。 | 独立手册镜像和产品图均称智能温控风扇,但厂商产品页未给 dBA;应按「有风扇、可能听得见」规划,不能承诺静音。 |
| 供电 | 未找到精确型号官方输入/功耗资料。 | OpenWrt 硬件数据记录 12 V / 4 A;确认随附电源适配器的插头、余量和地区认证。官方产品页未给满载功耗。 |
| 固件/恢复 | 有精确型号官方固件页;公开记录包含光口自适应与 DAC 配置改动,但未找到完整 release notes、恢复步骤或兼容矩阵。 | 厂商产品页提供「相关下载」区,OpenWrt 还记录原厂固件入口、RJ45 串口和 U-Boot/TFTP 恢复;原厂镜像是否签名、漏洞修复 SLA、配置回退仍未知。 |
关于 8T 的风扇、满载功耗(常见转述为 ≤36 W)、温度范围、芯片型号等,本次未找到
相应的**厂商原始数据表**;不将第三方手册转录当作已核实规格。若噪声、UPS 容量或
机柜散热是购买约束,请先让卖家提供产品铭牌照片、适配器铭牌照片、额定/实测功耗和
dBA 测试条件。
### 选择与验收建议
-**3218F**:必须有 ≥12 个 2.5G 接入端、10G 光/DAC 上联、且 L3 留给现有路由器。
下单前先取得精确型号手册和 SFP+/DAC 兼容承诺;否则端口数量优势不足以抵消资料风险。
-**8T**:最多 8 个设备但需要多速率 10G RJ45、明确的 IPv4/IPv6 静态/策略路由和
以后自行维护/恢复的余地。不要把其 160 Gb/s 标称交换容量误解为 8 端口同时 10G
全双工的性能保证——该标称与端口总线速数学相等,但仍须以实测和厂商 PPS/缓冲说明为准。
- 两台都不应单独承担防火墙、访客/IoT 安全隔离或 WAN 暴露;VLAN 的跨网段策略和公网
边界留在受支持的网关/防火墙上。先为管理面创建专用 VLAN,仅从管理主机访问,禁用
未使用的远程管理协议,备份配置和原厂固件后再接入生产网络。
## 低功耗核心备选(8 × 2.5G + 2 × SFP+
如果核心只需接最多 8 台铜缆终端、上联/连接 NAS 使用 DAC 或光纤 10G,优先考虑没有
PoE 的以下两款。它们都满足 VLAN trunk、LACP 和至少两个 10G SFP+ 的需求;不要为
AP 选 PoE 版来承担核心,因为 PoE 预算、风扇和待机损耗都会明显增加。
| 型号 | 端口与管理能力(厂商声明) | 厂商功耗 / 噪声资料 | 对当前 LAN 的判断 |
|---|---|---|---|
| **TP-Link Omada SG3210X-M2** | 8 × 100M/1G/2.5G RJ45、2 × 10G SFP+,并有 RJ45 和 Micro-USB console。厂商规格列出 802.1Q VLAN、STP/RSTP/MSTP、静态 LAG 和 802.3ad LACP(最多 8 个聚合组、每组最多 8 端口);L3 是 32 个 IPv4/IPv6 接口、48 条静态路由。 | **无风扇**100240 V AC 内置电源。`UN 1.20` 数据表:待机最高 **6.0 W**220 V/50 Hz、25 °C),最高 **15.3 W**220 V)或 **15.0 W**110 V)。 | **首选低功耗方案。** 足以做 LAN66 核心、给 PVE/gfw 与 U6 Lite 做 VLAN 10 trunk,并以 SFP+ DAC/光口连接 10G NAS/主机;它不提供 5G/10G RJ4510G 铜缆需外置转换或 SFP+ 10GBASE-T 模块。 |
| **MikroTik CRS310-8G+2S+IN** | 8 × 2.5G RJ45、2 × 10G SFP+SFP+ 笼支持 1G/2.5G/10G。RouterOS v7(也可选 SwOS)支持 VLAN、链路聚合与 ACL。 | 18–57 V DC 外置供电;官方给出“无附件”最高 **21 W**、总体最高 **34 W**,且机内 **1 个风扇**。厂商没有在该页给出 dBA。 | 可用且软件/文档/恢复路径成熟,但不是本题的静音低功耗优先项:官方最大功耗显著高于 TP-Link,且有风扇。适合明确偏好 RouterOS/SwOS 与其可维护性时选。 |
功耗数字是各厂商的**上限/待机测试条件**,不是你实际墙插读数;SFP+ 光模块、DAC/AOC,尤其
10GBASE-T SFP+ 模块,会另增功耗和热量。对于本网络,用被动 DAC 或短距光模块连接 10G
设备,通常比全 RJ45 10G 核心更容易保持低温、低噪。
`SG3210X-M2` 的上表数据对应 TP-Link 的 `UN 1.20` 数据表;不同地区/硬件版本的包装、
认证和功耗标注可能不同,购买中国零售版本前应让卖家确认**准确硬件版本、保修渠道和固件地区**。
本次未找到 TP-Link 中国官网的该精确型号页,因此不能把海外官方页面当作大陆现货/售后承诺。
MikroTik 同样应通过其官方零售商查询渠道确认本地库存和保修。两台购买前还应确认所选
SFP+/DAC 的兼容清单。
来源:[TP-Link 产品规格](https://www.tp-link.com/uk/business-networking/omada-switch-access-pro/sg3210x-m2/)
[TP-Link `UN 1.20` 数据表](https://static.tp-link.com/upload/product-overview/2025/202512/20251224/SG3210X-M2%28UN%29%201.20_datasheet.pdf)
[MikroTik 产品页](https://mikrotik.com/product/crs310_8g_2s_in)
[MikroTik 用户手册](https://help.mikrotik.com/docs/spaces/UM/pages/214630429/CRS310-8G%2B2S%2BIN)。
+28 -20
View File
@@ -8,29 +8,37 @@ diagnosis and procedures that are deliberately interactive or destructive; see
For a live-verified map of the **internal LAN** (gw, gfw, dns, ubnt, APs) and For a live-verified map of the **internal LAN** (gw, gfw, dns, ubnt, APs) and
the software deployed there, see [the LAN overview](../docs/lan-overview.md). the software deployed there, see [the LAN overview](../docs/lan-overview.md).
| Host | Role | SSH | IPv4 | Status | Facts | **Ansible 列**`✓` = 该主机在 [`ansible/inventory/hosts.yml`](../ansible/inventory/hosts.yml)
|------|------|-----|------|--------|-------| (执行真相),用其 inventory key(见括号注)跑 playbook`—` = 不由 Ansible 管理,
| 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) | 原因是该平台无 ansible 覆盖或仅是公网别名/服务端点。
| 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 | | | Host | Role | SSH | IPv4 | Ansible | Status | Facts |
| 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 | | mx2.windy.me | mailcow (primary MX prio 20) | `ssh -4 windy@mx2.windy.me` | 194.163.160.244 | ✓ (mx2) | active | [hosts/mx2.windy.me.md](../hosts/mx2.windy.me.md) |
| us1.wsvc.info | PowerDNS secondary (ns2 host) | TBD | 202.91.35.141 | stub | Auth 5.0.5; see hk2 | | us2.wsvc.info | Vaultwarden/Postgres (+ Traefik, Soft Serve, …) | `ssh -4 windy@us2.wsvc.info` | 193.9.44.165 | ✓ (us2) | active | [hosts/us2.wsvc.info.md](../hosts/us2.wsvc.info.md) |
| us4.wsvc.info | WireGuard VPN | `ssh -4 windy@us4.wsvc.info` | 185.201.226.122 | active | [hosts/us4.wsvc.info.md](../hosts/us4.wsvc.info.md) | | mx.windy.me | mail (secondary MX prio 30) | TBD | see AAAA/A | — (stub) | 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) | | repo.windy.me | Soft Serve git (on us2) | `ssh -p 2222 windy@repo.windy.me` | 193.9.44.165 | — (service on us2) | stub | see us2 |
| ns1.wsvc.info | PowerDNS public NS name | — (DNS) | → hk2 `154.36.174.161` | active | see hk2 | | auth.wsvc.info | Vaultwarden public hostname | — (HTTPS) | → us2 | — (alias) | active | see us2 |
| ns2.wsvc.info | Secondary NS (AXFR/NOTIFY peer) | — (DNS) | → us1 `202.91.35.141` | active | see hk2 | | us1.wsvc.info | PowerDNS secondary (ns2 host) | TBD | 202.91.35.141 | — (stub) | stub | Auth 5.0.5; see hk2 |
| pdns.wsvc.info | Poweradmin UI | — (HTTPS) | → hk2 | active | see hk2 | | us4.wsvc.info | WireGuard VPN | `ssh -4 windy@us4.wsvc.info` | 185.201.226.122 | ✓ (us4) | active | [hosts/us4.wsvc.info.md](../hosts/us4.wsvc.info.md) |
| pgweb.wsvc.info | PowerDNS Postgres UI | — (HTTPS) | → hk2 | active | see hk2 | | hk2.chans.xyz | PowerDNS auth (ns1) | `ssh -4 windy@hk2.chans.xyz` | 154.36.174.161 | ✓ (hk2) | active | [hosts/hk2.chans.xyz.md](../hosts/hk2.chans.xyz.md) |
| **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) | | ns1.wsvc.info | PowerDNS public NS name | — (DNS) | → hk2 `154.36.174.161` | — (alias) | active | see hk2 |
| **gfw.windy.lan** | OpenWrt (ImmortalWrt) LAN gateway / OpenClash (PVE VM 140) | `ssh -4 root@192.168.66.1` | `192.168.66.1` | **active** | [hosts/gfw.windy.lan.md](../hosts/gfw.windy.lan.md) | | ns2.wsvc.info | Secondary NS (AXFR/NOTIFY peer) | — (DNS) | → us1 `202.91.35.141` | — (alias) | active | see hk2 |
| **dns.windy.lan** | AdGuard Home LAN DNS + Mihomo explicit proxy (PVE VM 120) | `ssh -4 windy@192.168.66.36` | `192.168.66.36` | **active** | [hosts/dns.windy.lan.md](../hosts/dns.windy.lan.md) | | pdns.wsvc.info | Poweradmin UI | — (HTTPS) | → hk2 | — (alias) | active | see hk2 |
| **gw** | EdgeRouter X primary LAN gateway | `ssh -4 zhiqiang@192.168.66.254` | `192.168.66.254` | **active** | [hosts/gw.md](../hosts/gw.md) | | pgweb.wsvc.info | PowerDNS Postgres UI | — (HTTPS) | → hk2 | — (alias) | active | see hk2 |
| **ubnt** | UniFi Network Controller (PVE VM 160) | `ssh -4 windy@192.168.66.46` | `192.168.66.46` | **active** | [hosts/ubnt.md](../hosts/ubnt.md) | | **synapse.chans.xyz** | Matrix homeserver (ESS: Synapse + MAS + Element) | `ssh -4 windy@synapse.chans.xyz` | `169.58.86.13` | ✓ (matrix_vps) | **active** | [hosts/synapse.chans.xyz.md](../hosts/synapse.chans.xyz.md) |
| **hass.windy.lan** | Home Assistant (HAOS, PVE VM 180, LAN55) | `ssh hassio@hass.windy.lan` | `192.168.55.11` | **active** | [hosts/hass.windy.lan.md](../hosts/hass.windy.lan.md) | | **gfw.windy.lan** | OpenWrt (ImmortalWrt) LAN gateway / OpenClash (PVE VM 140) | `ssh -4 root@192.168.66.1` | `192.168.66.1` | — (OpenWrt, no ansible) | **active** | [hosts/gfw.windy.lan.md](../hosts/gfw.windy.lan.md) |
| **dns.windy.lan** | AdGuard Home LAN DNS + Mihomo explicit proxy (PVE VM 120) | `ssh -4 windy@192.168.66.36` | `192.168.66.36` | ✓ (dns_windy_lan) | **active** | [hosts/dns.windy.lan.md](../hosts/dns.windy.lan.md) |
| **gw** | EdgeRouter X primary LAN gateway | `ssh -4 zhiqiang@192.168.66.254` | `192.168.66.254` | — (EdgeOS, no ansible) | **active** | [hosts/gw.md](../hosts/gw.md) |
| **ubnt** | UniFi Network Controller (PVE VM 160) | `ssh -4 windy@192.168.66.46` | `192.168.66.46` | ✓ (ubnt) | **active** | [hosts/ubnt.md](../hosts/ubnt.md) |
| **hass.windy.lan** | Home Assistant (HAOS, PVE VM 180, LAN55) | `ssh hassio@hass.windy.lan` | `192.168.55.11` | — (HAOS, no ansible) | **active** | [hosts/hass.windy.lan.md](../hosts/hass.windy.lan.md) |
`status: stub` = known to exist; fill `hosts/<name>.md` when next touched. `status: stub` = known to exist; fill `hosts/<name>.md` when next touched.
**命名映射**ansible inventory key ↔ 本表主机名 —— `matrix_vps``synapse.chans.xyz`
`dns_windy_lan``dns.windy.lan`。inventory key 不随主机名改(防止破坏 `--limit` 用法),
通过 inventory 内的 `display_name` 变量与文档交叉引用。
### Matrix services (synapse.chans.xyz) ### Matrix services (synapse.chans.xyz)
| URL | Service | Notes | | URL | Service | Notes |
+6
View File
@@ -9,6 +9,12 @@ Read-only health check of the AdGuard Home LAN DNS service.
- Applicable: [dns.windy.lan](../hosts/dns.windy.lan.md) (`192.168.66.36`). - Applicable: [dns.windy.lan](../hosts/dns.windy.lan.md) (`192.168.66.36`).
- Read-only: does not expose query-log contents or secrets; does not change configuration. - Read-only: does not expose query-log contents or secrets; does not change configuration.
## Ownership
- Owner: personal ops (Windy)
- Last reviewed: 2026-08-17
- Related systems: dns.windy.lan (`/opt/adguardhome`)
This runbook is read-only. It does not expose query-log contents or secrets. This runbook is read-only. It does not expose query-log contents or secrets.
Routine checks run through Ansible on demand: Routine checks run through Ansible on demand:
+27
View File
@@ -18,6 +18,12 @@ account with sudo. Do a read-only health pass before any reconciliation.
- Mutating playbooks require explicit confirmation variables; do not bypass them. - Mutating playbooks require explicit confirmation variables; do not bypass them.
- If a reconcile target or service name is not allowlisted, `STOP` — do not invent one. - If a reconcile target or service name is not allowlisted, `STOP` — do not invent one.
## Ownership
- Owner: personal ops (Windy)
- Last reviewed: 2026-08-17
- Related systems: Ansible control-plane + all inventory hosts
## Health report (read-only) ## Health report (read-only)
```bash ```bash
@@ -63,6 +69,27 @@ Do not use this playbook for a Mailcow update, database migration, DNS record
change, or secret rotation. Those operations require their dedicated reviewed change, or secret rotation. Those operations require their dedicated reviewed
and, where appropriate, interactive procedures. and, where appropriate, interactive procedures.
## Deploy repo-owned Compose (static projects)
Repo source: `compose/<project>/compose.yml` (non-secret; secrets come from the
server-local `.env` via `${VAR}`). Mechanism and per-project status:
[`compose/README.md`](../compose/README.md).
```bash
# Read-only: staged-file diff + allowlist/confirmation asserts, no writes
ansible-playbook playbooks/compose-deploy.yml --limit vaultwarden --check --diff
ansible-playbook playbooks/compose-deploy.yml --limit powerdns --check --diff
# Apply: stage repo file → validate `docker compose config -q` against the
# server .env → backup current file (*.bak-<ts>) → promote → `up -d` (gated)
ansible-playbook playbooks/compose-deploy.yml --limit vaultwarden \
-e '{"compose_deploy_confirm": true}'
```
The playbook never writes, reads, or transfers the server `.env`. A failed
validation never touches the live compose file. Hosts without an allowlisted
`compose_repo_project` fail the assert — do not invent targets.
## Host-level maintenance ## Host-level maintenance
These playbooks cover every inventory host, including the Matrix K3s node: These playbooks cover every inventory host, including the Matrix K3s node:
+6
View File
@@ -20,6 +20,12 @@ skipping checks or changing unrelated code.
- Do not change unrelated hosts or services. - Do not change unrelated hosts or services.
- Prefer read-only diagnosis before mutation; destructive fixes require approval. - Prefer read-only diagnosis before mutation; destructive fixes require approval.
## Ownership
- Owner: personal ops (Windy)
- Last reviewed: 2026-08-17
- Related systems: Ansible health report / gated playbooks
## Procedure ## Procedure
### Step 1 — Reproduce and read ### Step 1 — Reproduce and read
+8
View File
@@ -21,6 +21,12 @@ read-only checks and gated mutating operations; the "Command families
intentionally NOT scripted" table below lists what is deliberately out of intentionally NOT scripted" table below lists what is deliberately out of
scope. scope.
## Ownership
- Owner: personal ops (Windy)
- Last reviewed: 2026-08-17
- Related systems: hass.windy.lan (HAOS, `machine: green`)
## Safety ## Safety
- Prefer read-only checks first; the health snapshot mutates nothing. - Prefer read-only checks first; the health snapshot mutates nothing.
@@ -33,6 +39,8 @@ scope.
state via Supervisor (`SUPERVISOR_TOKEN` after `sudo -n -i`). state via Supervisor (`SUPERVISOR_TOKEN` after `sudo -n -i`).
- The `--restart-core` wrapper exits 1 silently on ssh failure — treat an - The `--restart-core` wrapper exits 1 silently on ssh failure — treat an
empty/exit-1 result as failure and confirm with `ha core info`. empty/exit-1 result as failure and confirm with `ha core info`.
- If live state conflicts with a documented expectation, `STOP` and report;
do not improvise command families outside this script.
## Access pattern ## Access pattern
+6 -2
View File
@@ -51,9 +51,13 @@ and ask for clarification (add a `needs-info` label if applicable). Otherwise go
### Step 3 — Verify ### Step 3 — Verify
**Action** — run the applicable verification (link check for docs; `ansible-playbook --check` / `--syntax-check` for playbooks). **Action** — run `scripts/validate-repo.sh` from the repo root (covers secret
scan, inventory cross-check, markdown link check, runbook-spec check, and
Ansible `--syntax-check`); for changes that alter playbook behavior, also run
a read-only `ansible-playbook --check` where possible.
**Verification**see `RUNBOOKS.md` §校验; the concrete checks must match the change type. **Verification**`scripts/validate-repo.sh` exits 0; see `RUNBOOKS.md`
§校验; the concrete checks must match the change type.
**Decision** — verification passed → Step 4; failed → Troubleshooting A. **Decision** — verification passed → Step 4; failed → Troubleshooting A.
+6
View File
@@ -9,6 +9,12 @@ Read-only health check of the mailcow stack on mx2.
- Applicable: [mx2.windy.me](../hosts/mx2.windy.me.md), `/opt/mail`. - Applicable: [mx2.windy.me](../hosts/mx2.windy.me.md), `/opt/mail`.
- Read-only: does not change mailcow configuration or service state. - Read-only: does not change mailcow configuration or service state.
## Ownership
- Owner: personal ops (Windy)
- Last reviewed: 2026-08-17
- Related systems: mx2.windy.me (`/opt/mail`)
Target: [mx2.windy.me](../hosts/mx2.windy.me.md) Target: [mx2.windy.me](../hosts/mx2.windy.me.md)
Path: `/opt/mail` Path: `/opt/mail`
Prefer: the Ansible health report (`ansible/playbooks/health-report.yml --limit mailcow`), Prefer: the Ansible health report (`ansible/playbooks/health-report.yml --limit mailcow`),
+7
View File
@@ -9,6 +9,12 @@ Reference for configuring mail clients against the mailcow SMTP/IMAP endpoints.
- Applicable: [mx2.windy.me](../hosts/mx2.windy.me.md) client submission (587/465) and IMAP/POP (993/995). - Applicable: [mx2.windy.me](../hosts/mx2.windy.me.md) client submission (587/465) and IMAP/POP (993/995).
- Not applicable: server-side mailcow configuration or administration. - Not applicable: server-side mailcow configuration or administration.
## Ownership
- Owner: personal ops (Windy)
- Last reviewed: 2026-08-17
- Related systems: mx2.windy.me (SMTP/IMAP client endpoints)
Target: [mx2.windy.me](../hosts/mx2.windy.me.md) 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). Prerequisite: a mailbox on `windy.me` (password from mailcow UI, not the admin account unless it is that mailbox).
@@ -66,3 +72,4 @@ Do not commit or paste real passwords into this repo.
- Do not commit or paste real passwords into this repo or chat. - Do not commit or paste real passwords into this repo or chat.
- Use submission (587/465) for client sending; never use port 25 as a desktop/app outbound port. - Use submission (587/465) for client sending; never use port 25 as a desktop/app outbound port.
- If live state conflicts with the endpoint values above, `STOP` and report; do not change server-side settings during this reference check.
+7
View File
@@ -9,6 +9,12 @@ Update the mailcow stack on mx2 to the latest supported release.
- Applicable: [mx2.windy.me](../hosts/mx2.windy.me.md), `/opt/mail`. - Applicable: [mx2.windy.me](../hosts/mx2.windy.me.md), `/opt/mail`.
- Not applicable: config changes beyond the update, DB migration, secret rotation. - Not applicable: config changes beyond the update, DB migration, secret rotation.
## Ownership
- Owner: personal ops (Windy)
- Last reviewed: 2026-08-17
- Related systems: mx2.windy.me (`/opt/mail`)
## Approval gates ## Approval gates
| Action | Risk | Explicit approval | | Action | Risk | Explicit approval |
@@ -24,6 +30,7 @@ Path: `/opt/mail`
- Never run the update without explicit user confirmation. - Never run the update without explicit user confirmation.
- Never pass secrets into the chat log; do not commit `mailcow.conf`. - Never pass secrets into the chat log; do not commit `mailcow.conf`.
- If a step fails, capture `docker compose ps` and logs and stop before further changes. - If a step fails, capture `docker compose ps` and logs and stop before further changes.
- If live state conflicts with this runbook's assumptions (e.g. unexpected `mailcow.conf` values), `STOP` and report.
## Before ## Before
+6
View File
@@ -9,6 +9,12 @@ Read-only health check of the Matrix homeserver (ESS on K3s).
- Applicable: [synapse.chans.xyz](../hosts/synapse.chans.xyz.md), namespace `ess`. - Applicable: [synapse.chans.xyz](../hosts/synapse.chans.xyz.md), namespace `ess`.
- Read-only: does not change pods, ingress, certificates, or configuration. - Read-only: does not change pods, ingress, certificates, or configuration.
## Ownership
- Owner: personal ops (Windy)
- Last reviewed: 2026-08-17
- Related systems: synapse.chans.xyz (ESS chart `26.7.2`, K3s)
Monitor the Matrix homeserver running on `synapse.chans.xyz` (ESS chart `26.7.2`, K3s node). Monitor the Matrix homeserver running on `synapse.chans.xyz` (ESS chart `26.7.2`, K3s node).
Prefer `cd ansible && ansible-playbook playbooks/health-report.yml --limit matrix` Prefer `cd ansible && ansible-playbook playbooks/health-report.yml --limit matrix`
+6
View File
@@ -10,6 +10,12 @@ gateway, VLAN) with impact assessment, approval, and a rollback path.
- Applicable: PowerDNS zone records, `us4` firewalld allowlist, LAN gateway/VLAN/DNS changes, WireGuard. - Applicable: PowerDNS zone records, `us4` firewalld allowlist, LAN gateway/VLAN/DNS changes, WireGuard.
- Not applicable: SSH access-policy changes (see `AGENTS.md` §SSH access safety — mandatory lockout-risk procedure). - Not applicable: SSH access-policy changes (see `AGENTS.md` §SSH access safety — mandatory lockout-risk procedure).
## Ownership
- Owner: personal ops (Windy)
- Last reviewed: 2026-08-17
- Related systems: PowerDNS / us4 firewalld / LAN gateway / WireGuard
## Preconditions ## Preconditions
- A change record (Linear `vps` issue) describes the change, its reason, and rollback. - A change record (Linear `vps` issue) describes the change, its reason, and rollback.
+6
View File
@@ -10,6 +10,12 @@ diagnosis and mutating only when the root cause is confirmed.
- Applicable: unreachable VPS services, LAN gateway/DNS failures, DNS resolution failures. - Applicable: unreachable VPS services, LAN gateway/DNS failures, DNS resolution failures.
- Not applicable: planned changes (→ `network-change.md`), SSH access recovery (→ `AGENTS.md` §SSH access safety). - Not applicable: planned changes (→ `network-change.md`), SSH access recovery (→ `AGENTS.md` §SSH access safety).
## Ownership
- Owner: personal ops (Windy)
- Last reviewed: 2026-08-17
- Related systems: VPS services / LAN gateway / DNS
## Safety ## Safety
- Read-only diagnosis first; do not mutate while the root cause is unknown. - Read-only diagnosis first; do not mutate while the root cause is unknown.
+6
View File
@@ -9,6 +9,12 @@ Read-only health check of the `/opt/pdns` PowerDNS stack.
- Applicable: [hk2.chans.xyz](../hosts/hk2.chans.xyz.md), `/opt/pdns`. - Applicable: [hk2.chans.xyz](../hosts/hk2.chans.xyz.md), `/opt/pdns`.
- Read-only: does not change PowerDNS, DNS records, or secrets. - Read-only: does not change PowerDNS, DNS records, or secrets.
## Ownership
- Owner: personal ops (Windy)
- Last reviewed: 2026-08-17
- Related systems: hk2.chans.xyz (`/opt/pdns`)
Read-only checks for the `/opt/pdns` stack on **hk2.chans.xyz** (`ns1.wsvc.info`). 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) Facts: [hosts/hk2.chans.xyz.md](../hosts/hk2.chans.xyz.md) · Upstream: [docs/pdns-upstream.md](../docs/pdns-upstream.md)
+6
View File
@@ -10,6 +10,12 @@ verifiable, rollback-safe way.
- Applicable: reviewed changes to Compose services, DNS, firewall, or Ansible-managed config on production hosts. - Applicable: reviewed changes to Compose services, DNS, firewall, or Ansible-managed config on production hosts.
- Not applicable: destructive migrations (`vaultwarden-sqlite-to-postgres.md`), SSH access-policy changes (`AGENTS.md` §SSH access safety), or network changes needing a change ticket (`network-change.md`). - Not applicable: destructive migrations (`vaultwarden-sqlite-to-postgres.md`), SSH access-policy changes (`AGENTS.md` §SSH access safety), or network changes needing a change ticket (`network-change.md`).
## Ownership
- Owner: personal ops (Windy)
- Last reviewed: 2026-08-17
- Related systems: all production hosts
## Preconditions ## Preconditions
- The change is reviewed and its intent matches a Linear issue / change record. - The change is reviewed and its intent matches a Linear issue / change record.
+6
View File
@@ -10,6 +10,12 @@ regression.
- Applicable: Compose services, DNS records, firewall rules, and Ansible-managed config that have a rollback point. - Applicable: Compose services, DNS records, firewall rules, and Ansible-managed config that have a rollback point.
- Not applicable: destructive migrations without a backup (route to the owning runbook, e.g. `vaultwarden-sqlite-to-postgres.md` §Rollback). - Not applicable: destructive migrations without a backup (route to the owning runbook, e.g. `vaultwarden-sqlite-to-postgres.md` §Rollback).
## Ownership
- Owner: personal ops (Windy)
- Last reviewed: 2026-08-17
- Related systems: all production hosts
## Preconditions ## Preconditions
- A rollback point exists: backup, prior config, or known-good commit/playbook state. - A rollback point exists: backup, prior config, or known-good commit/playbook state.
+6
View File
@@ -9,6 +9,12 @@ Read-only health check of the `/opt/rustdesk` server stack.
- Applicable: [hk2.chans.xyz](../hosts/hk2.chans.xyz.md), `/opt/rustdesk`. - Applicable: [hk2.chans.xyz](../hosts/hk2.chans.xyz.md), `/opt/rustdesk`.
- Read-only: does not change RustDesk configuration or service state. - Read-only: does not change RustDesk configuration or service state.
## Ownership
- Owner: personal ops (Windy)
- Last reviewed: 2026-08-17
- Related systems: hk2.chans.xyz (`/opt/rustdesk`)
Read-only checks for the `/opt/rustdesk` stack on **hk2.chans.xyz**. Read-only checks for the `/opt/rustdesk` stack on **hk2.chans.xyz**.
Facts: [hosts/hk2.chans.xyz.md](../hosts/hk2.chans.xyz.md) Facts: [hosts/hk2.chans.xyz.md](../hosts/hk2.chans.xyz.md)
+6
View File
@@ -9,6 +9,12 @@ Read-only health check of the Vaultwarden stack (Postgres backend) on us2.
- Applicable: [us2.wsvc.info](../hosts/us2.wsvc.info.md), `/opt/vaultwarden`. - Applicable: [us2.wsvc.info](../hosts/us2.wsvc.info.md), `/opt/vaultwarden`.
- Read-only: does not change Vaultwarden, Traefik, SMTP, or secrets. - Read-only: does not change Vaultwarden, Traefik, SMTP, or secrets.
## Ownership
- Owner: personal ops (Windy)
- Last reviewed: 2026-08-17
- Related systems: us2.wsvc.info (`/opt/vaultwarden`)
Target: [us2.wsvc.info](../hosts/us2.wsvc.info.md) Target: [us2.wsvc.info](../hosts/us2.wsvc.info.md)
Path: `/opt/vaultwarden` Path: `/opt/vaultwarden`
URL: https://auth.wsvc.info/ URL: https://auth.wsvc.info/
@@ -9,6 +9,12 @@ Replay checklist for the exceptional SQLite→Postgres migration on us2.
- Applicable: [us2.wsvc.info](../hosts/us2.wsvc.info.md), `/opt/vaultwarden`. - Applicable: [us2.wsvc.info](../hosts/us2.wsvc.info.md), `/opt/vaultwarden`.
- Not applicable: routine reconcile — this is destructive and intentionally not automated. - Not applicable: routine reconcile — this is destructive and intentionally not automated.
## Ownership
- Owner: personal ops (Windy)
- Last reviewed: 2026-08-17
- Related systems: us2.wsvc.info (`/opt/vaultwarden`)
## Safety ## Safety
- Destructive: requires explicit approval; confirm backups exist before starting. - Destructive: requires explicit approval; confirm backups exist before starting.
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env bash
# validate-repo.sh — repo-wide validation for the VPS ops hub.
#
# Run from anywhere; must be executed from a git worktree of this repo.
# Exit 0 = pass (warnings allowed), non-zero = violations found.
#
# Checks:
# 1. Secret scan — tracked/working-tree files must not look like secrets.
# 2. Inventory — ansible inventory (display_name) ↔ inventory/hosts.md
# ↔ hosts/<name>.md cross-check (SKIP if no ansible CLI).
# 3. Links — relative markdown links must resolve.
# 4. Runbook spec — RUNBOOKS.md: Last reviewed + STOP on every runbook;
# six-field markers on procedure-type change runbooks;
# gate markers on gated command references.
# 5. Ansible — inventory parse + per-playbook --syntax-check
# (SKIP if no ansible CLI).
#
# NOTE: ansible temp/home are pinned inside the repo (./.ansible) so the
# script works in sandboxed/CI-like environments without touching ~/.ansible.
set -uo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
FAIL=0
WARN=0
say() { printf '%s\n' "$*"; }
ok() { printf ' [ok] %s\n' "$*"; }
skip() { printf ' [skip] %s\n' "$*"; }
warn() { printf ' [warn] %s\n' "$*"; WARN=$((WARN+1)); }
fail() { printf ' [FAIL] %s\n' "$*"; FAIL=$((FAIL+1)); }
# ---------------------------------------------------------------------------
say "== 1. Secret scan (tracked + untracked non-ignored files) =="
SECRET_PATH_RE='(^|/)(\.env$|\.env\.[^.].*|mailcow\.conf|\.smtp-credentials|\.admin-token)$|\.(pem|key)$|(^|/)id_(rsa|ed25519|ecdsa)$'
while IFS= read -r f; do
# .env.example is the sanctioned non-secret placeholder (see .gitignore).
if printf '%s' "$f" | grep -qE "$SECRET_PATH_RE" && ! printf '%s' "$f" | grep -qE '(^|/)\.env\.example$'; then
fail "secret-like path present: $f"
fi
done < <(git ls-files -co --exclude-standard)
if git grep -qIl '-----BEGIN [A-Z ]*PRIVATE KEY-----' 2>/dev/null; then
fail "private key material found in tracked files"
else
ok "no secret-like paths / private-key material"
fi
# ---------------------------------------------------------------------------
say "== 2. Inventory cross-check (ansible display_name ↔ hosts.md ↔ hosts/) =="
if command -v ansible-inventory >/dev/null 2>&1; then
export ANSIBLE_LOCAL_TEMP="$REPO_ROOT/.ansible/tmp"
export ANSIBLE_HOME="$REPO_ROOT/.ansible"
mkdir -p "$REPO_ROOT/.ansible/tmp"
INV_JSON="$(mktemp)"
if (cd ansible && ansible-inventory --list > "$INV_JSON" 2>/dev/null); then
if python3 - "$INV_JSON" <<'PYEOF'
import json, sys, re, pathlib
inv = json.load(open(sys.argv[1]))
hostvars = inv.get('_meta', {}).get('hostvars', {})
root = pathlib.Path('.')
hosts_md = (root / 'inventory' / 'hosts.md').read_text(encoding='utf-8')
errors = []
for key, v in sorted(hostvars.items()):
dn = v.get('display_name')
if not dn:
errors.append(f"inventory host {key!r} has no display_name")
continue
if not (root / 'hosts' / f'{dn}.md').exists():
errors.append(f"hosts/{dn}.md missing for inventory host {key}")
if dn not in hosts_md:
errors.append(f"display_name {dn!r} (inventory key {key}) not found in inventory/hosts.md")
display_names = {v.get('display_name') for v in hostvars.values() if v.get('display_name')}
# Reverse: rows marked '✓ (key)' in hosts.md must exist in the ansible inventory.
for line in hosts_md.splitlines():
m = re.match(r'^\|\s*\*{0,2}([^*|]+?)\*{0,2}\s*\|\s*[^|]*?\s*\|\s*[^|]*?\s*\|\s*[^|]*?\s*\|\s*✓\s*\(([^)]+)\)', line)
if m:
host, invkey = m.group(1).strip(), m.group(2).strip()
if invkey not in hostvars:
errors.append(f"hosts.md row {host!r} marked Ansible '✓' but inventory key {invkey!r} missing")
for e in errors:
print(e)
sys.exit(1 if errors else 0)
PYEOF
then ok "inventory cross-check passed"
else fail "inventory cross-check violations (see above)"
fi
rm -f "$INV_JSON"
else
fail "ansible-inventory --list failed"
fi
else
skip "ansible-inventory not available"
fi
# ---------------------------------------------------------------------------
say "== 3. Relative markdown link check =="
if python3 - <<'PYEOF'
import pathlib, re, subprocess, sys
root = pathlib.Path('.')
# Only repo content: tracked + untracked non-ignored files (excludes
# gitignored tooling dirs like .agents/ and .claude/).
tracked = subprocess.check_output(['git', 'ls-files', '--', '*.md'], text=True).split()
untracked = subprocess.check_output(
['git', 'ls-files', '-o', '--exclude-standard', '--', '*.md'], text=True).split()
files = sorted(set(pathlib.Path(f) for f in tracked + untracked))
link_re = re.compile(r'\[[^\]]*\]\(([^)]+)\)')
errors = []
for f in files:
try:
text = f.read_text(encoding='utf-8')
except UnicodeDecodeError:
continue
for m in link_re.finditer(text):
target = m.group(1).strip()
if not target or target.startswith(('#', 'http://', 'https://', 'mailto:', 'tel:', '{{')):
continue
# strip optional anchor and surrounding quotes
target = target.split('#', 1)[0].strip().strip('"\'')
if not target:
continue
resolved = (f.parent / target)
if not resolved.exists():
errors.append(f"{f}: broken link -> {target}")
for e in errors:
print(e)
sys.exit(1 if errors else 0)
PYEOF
then ok "all relative markdown links resolve"
else fail "broken markdown links (see above)"
fi
# ---------------------------------------------------------------------------
say "== 4. Runbook spec compliance (RUNBOOKS.md) =="
PROCEDURE_RUNBOOKS="fix-ci.md issue-to-merge.md network-change.md network-recovery.md release.md rollback.md"
GATED_REF_RUNBOOKS="mailcow-update.md ansible-operations.md home-assistant-maintenance.md vaultwarden-sqlite-to-postgres.md"
for f in runbooks/*.md; do
base="$(basename "$f")"
[ "$base" = "README.md" ] && continue
if ! grep -q "Last reviewed" "$f"; then fail "$f: missing 'Last reviewed'"; fi
if ! grep -qE "STOP" "$f"; then fail "$f: missing explicit STOP condition"; fi
done
for base in $PROCEDURE_RUNBOOKS; do
f="runbooks/$base"
[ -f "$f" ] || { fail "$f: expected procedure-type runbook missing"; continue; }
for marker in '**Action**' '**Expected**' '**Decision**' '**Verification**'; do
if ! grep -qF "$marker" "$f"; then fail "$f: procedure-type runbook missing $marker"; fi
done
done
for base in $GATED_REF_RUNBOOKS; do
f="runbooks/$base"
[ -f "$f" ] || { fail "$f: expected gated-command runbook missing"; continue; }
if ! grep -qE "(Approval gates|confirm|--yes|explicit approval|confirmation)" "$f"; then
fail "$f: gated-command runbook missing approval/confirmation gate"
fi
done
if [ "$FAIL" -gt 0 ]; then :; else ok "runbook spec checks passed"; fi
# ---------------------------------------------------------------------------
say "== 5. Ansible inventory + playbook syntax =="
if command -v ansible-playbook >/dev/null 2>&1; then
export ANSIBLE_LOCAL_TEMP="$REPO_ROOT/.ansible/tmp"
export ANSIBLE_HOME="$REPO_ROOT/.ansible"
mkdir -p "$REPO_ROOT/.ansible/tmp"
syntax_fail=0
for p in ansible/playbooks/*.yml; do
if ! (cd ansible && ansible-playbook --syntax-check "playbooks/$(basename "$p")" >/dev/null 2>&1); then
fail "syntax-check failed: $p"
syntax_fail=1
fi
done
[ "$syntax_fail" -eq 0 ] && ok "all playbooks passed --syntax-check"
else
skip "ansible not available (syntax-check skipped)"
fi
# ---------------------------------------------------------------------------
say ""
if [ "$FAIL" -gt 0 ]; then
say "RESULT: FAIL ($FAIL violations, $WARN warnings)"
exit 1
else
say "RESULT: PASS ($WARN warnings)"
exit 0
fi