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
bypass failed checks. The spec is [`RUNBOOKS.md`](RUNBOOKS.md).
## Active hosts (quick map)
## Active hosts
| 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) |
| **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) |
The canonical machine list (roles, SSH endpoints, Ansible coverage, status) is
[`inventory/hosts.md`](inventory/hosts.md) — the single human-readable source
of truth. Per-host facts live in [`hosts/`](hosts/). The Ansible execution
inventory is [`ansible/inventory/hosts.yml`](ansible/inventory/hosts.yml). Do
not maintain a second copy of the machine table here.
### 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)
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
runbooks/ # step-by-step ops (README.md = index, _template.md = template)
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` 并记录证据)。
**只读类变体(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`
@@ -37,6 +46,17 @@ runbooks/
- 每份 runbook 顶部含 `## Purpose`12 行)与 `## Scope`(适用/不适用情形)。
- 变更型 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` 或正文中复用以下通用安全规则(更严格要求优先)。
```markdown
+5
View File
@@ -11,3 +11,8 @@ host_key_checking = True
become = True
become_method = sudo
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:
ansible_host: mx2.windy.me
ansible_host_ipv4: 194.163.160.244
display_name: mx2.windy.me
service_role: mailcow
compose_project_dir: /opt/mail
healthcheck_profiles: [mailcow]
@@ -24,8 +25,11 @@ all:
us2:
ansible_host: us2.wsvc.info
ansible_host_ipv4: 193.9.44.165
display_name: us2.wsvc.info
service_role: vaultwarden
compose_project_dir: /opt/vaultwarden
compose_repo_project: vaultwarden
compose_remote_file: docker-compose.yml
healthcheck_profiles: [vaultwarden]
restic_backup_profile: vaultwarden
service_reconcile_services:
@@ -35,8 +39,11 @@ all:
hk2:
ansible_host: hk2.chans.xyz
ansible_host_ipv4: 154.36.174.161
display_name: hk2.chans.xyz
service_role: powerdns
compose_project_dir: /opt/pdns
compose_repo_project: pdns
compose_remote_file: compose.yml
healthcheck_profiles: [pdns, rustdesk, hk2aux]
restic_backup_profile: pdns
service_reconcile_services:
@@ -54,6 +61,7 @@ all:
us4:
ansible_host: us4.wsvc.info
ansible_host_ipv4: 185.201.226.122
display_name: us4.wsvc.info
service_role: wireguard
compose_project_dir: /opt/wireguard
healthcheck_profiles: [wireguard]
@@ -65,6 +73,7 @@ all:
dns_windy_lan:
ansible_host: 192.168.66.36
ansible_host_ipv4: 192.168.66.36
display_name: dns.windy.lan
service_role: adguardhome
compose_project_dir: /opt/adguardhome
healthcheck_profiles: [adguardhome]
@@ -94,6 +103,7 @@ all:
ubnt:
ansible_host: 192.168.66.46
ansible_host_ipv4: 192.168.66.46
display_name: ubnt
vars:
service_role: unifi
compose_project_dir: /home/windy/unifi-9
@@ -114,6 +124,7 @@ all:
matrix_vps:
ansible_host: 169.58.86.13
ansible_host_ipv4: 169.58.86.13
display_name: synapse.chans.xyz
service_role: matrix_k3s
matrix_server_name: 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
resolvers/splitters that are genuinely better than — or meaningfully different
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
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
> `nameserver`/`default-nameserver` (DIRECT-rule real-IP resolution); the
> 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
> 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`.
- 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
`nameserver`/`default-nameserver` (DIRECT-rule real-IP resolution, verified
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
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)).
@@ -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
proxy and its restart/update lifecycle.
- 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
host for HA is out of scope for a home LAN.
- 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
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
| 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). |
| 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). |
@@ -551,7 +551,7 @@ Concrete, in increasing effort:
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
([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
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
@@ -559,9 +559,9 @@ Concrete, in increasing effort:
4. **Close the private-PTR gap**: once a local authoritative source exists (e.g.
dnsmasq on `gw`, or a tiny authoritative zone), point AGH
`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
([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`
with a coarse fixed prefix (or blocky `ecs.forward`) if measurements show a
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
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
`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;
if a newer version added one, it is not reflected here
([config options](https://pymumu.github.io/smartdns/configuration/)).
@@ -598,8 +598,8 @@ here beats it on that axis for this LAN.
## Related docs
- [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/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
- [runbooks/adguard-home-health.md](../runbooks/adguard-home-health.md)
- [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/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
- [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
the software deployed there, see [the LAN overview](../docs/lan-overview.md).
| 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 | WireGuard VPN | `ssh -4 windy@us4.wsvc.info` | 185.201.226.122 | active | [hosts/us4.wsvc.info.md](../hosts/us4.wsvc.info.md) |
| 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) |
| **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) |
| **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) |
| **gw** | EdgeRouter X primary LAN gateway | `ssh -4 zhiqiang@192.168.66.254` | `192.168.66.254` | **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` | **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` | **active** | [hosts/hass.windy.lan.md](../hosts/hass.windy.lan.md) |
**Ansible 列**`✓` = 该主机在 [`ansible/inventory/hosts.yml`](../ansible/inventory/hosts.yml)
(执行真相),用其 inventory key(见括号注)跑 playbook`—` = 不由 Ansible 管理,
原因是该平台无 ansible 覆盖或仅是公网别名/服务端点。
| Host | Role | SSH | IPv4 | Ansible | Status | Facts |
|------|------|-----|------|---------|--------|-------|
| 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) |
| 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) |
| mx.windy.me | mail (secondary MX prio 30) | TBD | see AAAA/A | — (stub) | stub | — |
| 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 |
| auth.wsvc.info | Vaultwarden public hostname | — (HTTPS) | → us2 | — (alias) | active | see us2 |
| us1.wsvc.info | PowerDNS secondary (ns2 host) | TBD | 202.91.35.141 | — (stub) | stub | Auth 5.0.5; 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) |
| 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) |
| ns1.wsvc.info | PowerDNS public NS name | — (DNS) | → hk2 `154.36.174.161` | — (alias) | active | see hk2 |
| ns2.wsvc.info | Secondary NS (AXFR/NOTIFY peer) | — (DNS) | → us1 `202.91.35.141` | — (alias) | active | see hk2 |
| pdns.wsvc.info | Poweradmin UI | — (HTTPS) | → hk2 | — (alias) | active | see hk2 |
| pgweb.wsvc.info | PowerDNS Postgres UI | — (HTTPS) | → hk2 | — (alias) | active | see hk2 |
| **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) |
| **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.
**命名映射**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)
| 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`).
- 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.
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.
- 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)
```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
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
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.
- 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
### 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
scope.
## Ownership
- Owner: personal ops (Windy)
- Last reviewed: 2026-08-17
- Related systems: hass.windy.lan (HAOS, `machine: green`)
## Safety
- Prefer read-only checks first; the health snapshot mutates nothing.
@@ -33,6 +39,8 @@ scope.
state via Supervisor (`SUPERVISOR_TOKEN` after `sudo -n -i`).
- The `--restart-core` wrapper exits 1 silently on ssh failure — treat an
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
+6 -2
View File
@@ -51,9 +51,13 @@ and ask for clarification (add a `needs-info` label if applicable). Otherwise go
### 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.
+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`.
- 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)
Path: `/opt/mail`
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).
- 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)
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.
- 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`.
- 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
| Action | Risk | Explicit approval |
@@ -24,6 +30,7 @@ Path: `/opt/mail`
- Never run the update without explicit user confirmation.
- 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 live state conflicts with this runbook's assumptions (e.g. unexpected `mailcow.conf` values), `STOP` and report.
## 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`.
- 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).
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.
- 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
- 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.
- 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
- 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`.
- 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`).
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.
- 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
- 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.
- 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
- 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`.
- 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**.
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`.
- 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)
Path: `/opt/vaultwarden`
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`.
- 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
- 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