diff --git a/AGENTS.md b/AGENTS.md index 3a43cd7..34dc30f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) ``` diff --git a/RUNBOOKS.md b/RUNBOOKS.md index f39bb68..f8a4423 100644 --- a/RUNBOOKS.md +++ b/RUNBOOKS.md @@ -27,6 +27,15 @@ runbooks/ > 只读类 runbook 不产生副作用,可省略 Rollback;但必须保留 Stop condition(状态与预期冲突即 `STOP` 并记录证据)。 +**只读类变体(read-only variant)**:只读 runbook(health 类、参考类)不强制 +六字段模型,但必须包含以下最小结构,否则不视为达标: + +- `## Purpose`(1–2 行)+ `## Scope`(适用/不适用) +- `## Safety` 或等效章节,其中**必须**含显式 Stop condition(状态与预期冲突即 + `STOP` 并记录证据;不得在执行中自行"顺手修复") +- 只读健康类另含可观察的 `## Pass criteria`(或等效的 Expected 信号) +- 每份 runbook 顶部/元信息区必须标注 `Last reviewed: ` + ## 命名与拆分规则 - 文件名采用小写连字符,反映**操作意图**而非目标主机,例如 `mailcow-health.md`、`release.md`。 @@ -37,6 +46,17 @@ runbooks/ - 每份 runbook 顶部含 `## Purpose`(1–2 行)与 `## 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 diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg index 7487d63..cbd6b3e 100644 --- a/ansible/ansible.cfg +++ b/ansible/ansible.cfg @@ -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 diff --git a/ansible/inventory/hosts.yml b/ansible/inventory/hosts.yml index 72235a9..c466afe 100644 --- a/ansible/inventory/hosts.yml +++ b/ansible/inventory/hosts.yml @@ -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 diff --git a/ansible/playbooks/compose-deploy.yml b/ansible/playbooks/compose-deploy.yml new file mode 100644 index 0000000..70f70d4 --- /dev/null +++ b/ansible/playbooks/compose-deploy.yml @@ -0,0 +1,13 @@ +--- +# Deploy repo-owned Compose declarations (compose//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] diff --git a/ansible/roles/compose_deploy/defaults/main.yml b/ansible/roles/compose_deploy/defaults/main.yml new file mode 100644 index 0000000..0d136de --- /dev/null +++ b/ansible/roles/compose_deploy/defaults/main.yml @@ -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 diff --git a/ansible/roles/compose_deploy/tasks/main.yml b/ansible/roles/compose_deploy/tasks/main.yml new file mode 100644 index 0000000..c32ff9a --- /dev/null +++ b/ansible/roles/compose_deploy/tasks/main.yml @@ -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 `.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-` 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 diff --git a/compose/README.md b/compose/README.md new file mode 100644 index 0000000..2bedd4d --- /dev/null +++ b/compose/README.md @@ -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//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//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. diff --git a/compose/pdns/.env.example b/compose/pdns/.env.example new file mode 100644 index 0000000..d78030c --- /dev/null +++ b/compose/pdns/.env.example @@ -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= diff --git a/compose/pdns/compose.yml b/compose/pdns/compose.yml new file mode 100644 index 0000000..936bb64 --- /dev/null +++ b/compose/pdns/compose.yml @@ -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: {} diff --git a/compose/vaultwarden/.env.example b/compose/vaultwarden/.env.example new file mode 100644 index 0000000..f2dba50 --- /dev/null +++ b/compose/vaultwarden/.env.example @@ -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 diff --git a/compose/vaultwarden/compose.yml b/compose/vaultwarden/compose.yml new file mode 100644 index 0000000..09c6be2 --- /dev/null +++ b/compose/vaultwarden/compose.yml @@ -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: {} diff --git a/docs/archive/README.md b/docs/archive/README.md new file mode 100644 index 0000000..f4accc0 --- /dev/null +++ b/docs/archive/README.md @@ -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/`),不在本目录。 diff --git a/docs/lan-dns-alternatives.md b/docs/archive/lan-dns-alternatives.md similarity index 96% rename from docs/lan-dns-alternatives.md rename to docs/archive/lan-dns-alternatives.md index bbcf219..6e01b64 100644 --- a/docs/lan-dns-alternatives.md +++ b/docs/archive/lan-dns-alternatives.md @@ -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) diff --git a/docs/low-volume-mono-laser-mfp-buying-guide-2026-08.md b/docs/low-volume-mono-laser-mfp-buying-guide-2026-08.md deleted file mode 100644 index 2c9fa6a..0000000 --- a/docs/low-volume-mono-laser-mfp-buying-guide-2026-08.md +++ /dev/null @@ -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-Fi;AirPrint、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 页纸盒、平板扫描、自动双面打印 | CRG071:700 页随机、1,200/2,500 页商品硒鼓 | 功能不错但属于为自动双面打印升级;官方建议价 ¥3,838,不适合低量、单面为主时以性价比为目标的采购。 | - -Brother 规格与耗材页数以官方参数表为准;Pantum 的接口、PD-219 和建议月印量 250–2,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。 diff --git a/docs/sirivision-sr-s25g3218f-research-2026-08.md b/docs/sirivision-sr-s25g3218f-research-2026-08.md deleted file mode 100644 index 7714b21..0000000 --- a/docs/sirivision-sr-s25g3218f-research-2026-08.md +++ /dev/null @@ -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/s(16 × 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 RJ45;160 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 条静态路由。 | **无风扇**;100–240 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 RJ45,10G 铜缆需外置转换或 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)。 diff --git a/inventory/hosts.md b/inventory/hosts.md index a8b566a..3fbc1ca 100644 --- a/inventory/hosts.md +++ b/inventory/hosts.md @@ -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/.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 | diff --git a/runbooks/adguard-home-health.md b/runbooks/adguard-home-health.md index 1918e9d..e692e01 100644 --- a/runbooks/adguard-home-health.md +++ b/runbooks/adguard-home-health.md @@ -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: diff --git a/runbooks/ansible-operations.md b/runbooks/ansible-operations.md index 9b34a1c..e66a34f 100644 --- a/runbooks/ansible-operations.md +++ b/runbooks/ansible-operations.md @@ -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//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-) → 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: diff --git a/runbooks/fix-ci.md b/runbooks/fix-ci.md index 1bab897..6489fd3 100644 --- a/runbooks/fix-ci.md +++ b/runbooks/fix-ci.md @@ -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 diff --git a/runbooks/home-assistant-maintenance.md b/runbooks/home-assistant-maintenance.md index c1c9990..bb2fe02 100644 --- a/runbooks/home-assistant-maintenance.md +++ b/runbooks/home-assistant-maintenance.md @@ -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 diff --git a/runbooks/issue-to-merge.md b/runbooks/issue-to-merge.md index d1c3df0..e4b79de 100644 --- a/runbooks/issue-to-merge.md +++ b/runbooks/issue-to-merge.md @@ -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. diff --git a/runbooks/mailcow-health.md b/runbooks/mailcow-health.md index 464a27a..3f7a23a 100644 --- a/runbooks/mailcow-health.md +++ b/runbooks/mailcow-health.md @@ -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`), diff --git a/runbooks/mailcow-smtp-client.md b/runbooks/mailcow-smtp-client.md index dd46119..261c54d 100644 --- a/runbooks/mailcow-smtp-client.md +++ b/runbooks/mailcow-smtp-client.md @@ -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. diff --git a/runbooks/mailcow-update.md b/runbooks/mailcow-update.md index 86daf86..dbf78b4 100644 --- a/runbooks/mailcow-update.md +++ b/runbooks/mailcow-update.md @@ -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 diff --git a/runbooks/matrix-health.md b/runbooks/matrix-health.md index d21f120..fe608cb 100644 --- a/runbooks/matrix-health.md +++ b/runbooks/matrix-health.md @@ -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` diff --git a/runbooks/network-change.md b/runbooks/network-change.md index 54ac165..7871e83 100644 --- a/runbooks/network-change.md +++ b/runbooks/network-change.md @@ -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. diff --git a/runbooks/network-recovery.md b/runbooks/network-recovery.md index 6af6279..5160a24 100644 --- a/runbooks/network-recovery.md +++ b/runbooks/network-recovery.md @@ -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. diff --git a/runbooks/pdns-health.md b/runbooks/pdns-health.md index 2d0bf53..4f476ad 100644 --- a/runbooks/pdns-health.md +++ b/runbooks/pdns-health.md @@ -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) diff --git a/runbooks/release.md b/runbooks/release.md index 6046649..1c75764 100644 --- a/runbooks/release.md +++ b/runbooks/release.md @@ -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. diff --git a/runbooks/rollback.md b/runbooks/rollback.md index 5377aa6..64adeb3 100644 --- a/runbooks/rollback.md +++ b/runbooks/rollback.md @@ -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. diff --git a/runbooks/rustdesk-health.md b/runbooks/rustdesk-health.md index 6731e07..1f2cb2e 100644 --- a/runbooks/rustdesk-health.md +++ b/runbooks/rustdesk-health.md @@ -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) diff --git a/runbooks/vaultwarden-health.md b/runbooks/vaultwarden-health.md index 0a30c6e..0c82b5e 100644 --- a/runbooks/vaultwarden-health.md +++ b/runbooks/vaultwarden-health.md @@ -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/ diff --git a/runbooks/vaultwarden-sqlite-to-postgres.md b/runbooks/vaultwarden-sqlite-to-postgres.md index ab6a981..2c946e2 100644 --- a/runbooks/vaultwarden-sqlite-to-postgres.md +++ b/runbooks/vaultwarden-sqlite-to-postgres.md @@ -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. diff --git a/scripts/validate-repo.sh b/scripts/validate-repo.sh new file mode 100755 index 0000000..5e5cde0 --- /dev/null +++ b/scripts/validate-repo.sh @@ -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/.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