Author SHA1 Message Date
windyboy ff1a92110c docs(us2): Soft Serve → Gitea 迁移事实与参考镜像 (Plane VPS-94)
- hosts/us2: Gitea 1.27.3-rootless 部署实况 (repo.windy.me SSH:2222/Web), 16 仓迁移核对, 备份/回滚; soft-serve 停用保留作回滚
- compose/gitea: 参考镜像 (rootless compose + 备份 sidecar + 一次性迁移脚本留档)
- AGENTS/inventory/compose README: 服务表与索引同步
2026-09-18 17:24:35 +08:00
windyboy cc3fb99c14 docs(us2): 根盘清理 71%→23% 事实记录 (Plane VPS-93) 2026-09-18 11:46:17 +08:00
windyboy 6879d79cc6 docs(hass,pgdb): scribe 4.4.0 升级核对 + stats_io_interval 300 + sensor_minute 体积诊断
- hass: Scribe 3.8.0 → 4.4.0(HACS jonathan-gtd/scribe,2026-09-13 随 Core
  2026.9.1 / HAOS 18.2 升级)。记录 4.0 两个 breaking change 在本机均无需动作
  (3.x 结构 DB + PK 在、TimescaleDB 2.29.2 已装)、配置优先级
  YAML > options > entry data > 默认值、以及「YAML 改动必须重启 Core」。
- hass: 新增 stats_io_interval: 300(备份 scribe.yaml.bak-20260913-191558)。
  变更前 24h scribe 自写 11 019/87 461 行(12.6%);实测发布间隔 300s。
  retention_states/retention_events 可用但刻意不设;flush_interval 仍被 entry
  data 钉在 5s(采用新默认 30s 需显式写 YAML)。
- pgdb: sensor_minute 2.26 GB 是压缩窗口内的正常暂存(compress_after 按 chunk
  结束时间判断,09-17 才合格),稳态 4–5 GB 平台期,暂不处理;复查点 09-17
  之后。顺带修正 hass/scribe 库体积事实。

校验:scripts/validate-repo.sh PASS (0 warnings)。
2026-09-13 19:34:04 +08:00
windyboy c445c5f512 Merge remote-tracking branch 'origin/main' into main
AGENTS.md 冲突(两侧都改了 Linear→Plane 记录源):取本地更完整的表述
(self-hosted plane.chans.xyz + mcp__plane__* + 2026-09-03 停用日期 +
issue-tracker.md 已过时),并吸收远端的 `plane-workflow` skill 指引。
2026-09-13 19:13:06 +08:00
windyboy 3c83246f24 docs(pgdb,gfw): pgdb VM 重启根因 (W1N-263) + gfw Quad9 上游移除记录 2026-09-13 19:12:32 +08:00
windyboy c0c975584a docs(plane): 自托管 Plane 落地事实入仓库 + plane-health runbook + hardening 草稿
记录源 Linear→Plane (2026-09-03 起, Plane MCP) + plane.chans.xyz 服务行/upstream 段;
inventory + hosts/synapse.chans.xyz.md 补 Plane 部署事实 (Helm plane-ce-1.8.0 / app v1.4.1,
ns plane, IngressRoute/自有证书 issuer/PVC 5+5Gi local-path/无备份层);
新增 runbooks/plane-health.md (只读健康检查) 与 docs/plane-hardening/ 草稿
(values.hardened.yaml、secrets.yaml.example 占位、backup/ CronJob), 均为未应用设计稿;
.gitignore 增加 .tmp-* agent 临时文件。
2026-09-13 19:12:32 +08:00
windyboy 0034cec925 Merge remote-tracking branch 'origin/main' into HEAD 2026-09-13 19:11:13 +08:00
windyboy c9dcde1274 docs(hass): Quick 面板时间范围扩容 — 功率/环境/人体感应加 3d-30d 档,用电量加 3mo (VPS-92)
卡片 JS 只接受 <n>m|<n>h|<n>d 与命名档(today/week/month/3mo/6mo/year/custom);
记录 scribe sensor_minute 数据下界 2026-08-29 对 >15d 档的影响。
2026-09-13 19:09:54 +08:00
windyboy 037c4ccaa5 docs(hass): 马桶换气电源 Matter 插座接入 + 功率积分补电量 (VPS-92)
Matter Smart Plug (SIXWGH model 3596) 的 cluster 0x0091 声明 IMPE+CUME+PERE 但
CumulativeEnergyImported 恒为 null -> HA 能量实体永久 unknown。功率计量正常
(实测 24.7 W),故用 Integration (Riemann sum) 辅助元素补电量实体
sensor.wei_sheng_jian_ma_tong_huan_qi_dian_yuan_energy:
能源仪表盘 grid 源 [8] 与 Quick「用电量(按插座)」图换到该实体,Quick 另加
span-2「开关」区块。记录 config-flow 经 supervisor 代理走 REST 的 Agent 方法、
备份路径与回滚方式;energy/validate 已全绿。
2026-09-13 18:39:22 +08:00
windyboy 17bb171578 docs(hass): CSG 面板重构入维护 runbook — avg price 传感器 + 季节提醒 automation + WS 改法 (VPS-90) 2026-09-04 21:55:40 +08:00
windyboy cecf7e6331 docs(hass): CSG 电力监控面板重构 — avg price 传感器 + 去重/改名 + 季节切换提醒 (VPS-90) 2026-09-04 21:44:43 +08:00
windyboy e957bc2bb1 docs: add host-disk-cleanup runbook + hk2 disk facts; record source -> Plane vps (VPS-81) 2026-09-02 18:02:22 +08:00
windyboy de52cb8b57 docs(hass): 地图卡 CARTO 水印修复 — custom:map-card v1.16.0 + keyed tiles (W1N-261) 2026-08-30 13:39:42 +08:00
windyboy b15e19bce9 docs(pgdb): compose 开机竞态故障修复 + 自愈 unit (W1N-260)
- 根因:开机时 docker 恢复容器绑定 192.168.55.15:5432/8081 失败(EADDRNOTAVAIL,IP 尚未可绑)→ timescaledb/pgweb 启动失败且不重试,停摆 3h15m;pg-backup 开机备份失败 → unhealthy
- 处置:docker compose up -d --force-recreate(三容器回 database_default、端口发布、备份恢复、pgweb 恢复);用户重启 HA Core 后写入管道恢复
- 防复发:新增开机自愈 systemd oneshot pgdb-compose.service(enabled),源码 compose/pgdb/pgdb-compose.service
2026-08-30 13:19:05 +08:00
windyboy bee54a6858 docs(hass): CSG 长期归档 csg_history + recorder 365d 补录 (W1N-243) 2026-08-30 13:19:05 +08:00
windyboy d6747028b4 docs(soft-serve): 镜像固定 v0.12.2 + 备份 sidecar + 非 root 运行 (W1N-244..248)
- 镜像 pinned charmcli/soft-serve:v0.12.2(GHCR 为 dev/nightly 源,无 v0.12.x tag)
- soft-serve-backup sidecar:每日 02:00 sqlite .backup + repos-config 打包,03:00 prune 保留 14 份
- 非 root 运行(user 1000:1000),data chown;ssh.public_url 修复
- compose 源码参考:compose/soft-serve/(服务器文件为准)
2026-08-30 13:19:05 +08:00
windyboy f174aa1219 docs(hass): CSG 复核遗留修复 — 模板 days[-1] 补排序 + 本月日均/预测进度上屏 (W1N-242) 2026-08-29 21:08:13 +08:00
windyboy 5b5f6042e6 docs(hass): CSG 模板/面板修复记录 — availability 硬化 (W1N-239)、off-by-one + 新传感器 (W1N-241)、gauge 阶梯对齐 + 年度统计 (W1N-240)
- hosts/hass.windy.lan.md: csg_sensors 三次变更记录 + 季节性 gauge 切换已知事项 (11-01/5-01)
- runbooks/home-assistant-maintenance.md: float(0) fake-zero follow-up 标记已解决
2026-08-29 21:02:33 +08:00
windyboy 50136b2ffd docs(hass): W1N-238 — scribe config split to scribe.yaml + templates/ merge include
- Scribe 3.8.0 block moved verbatim from configuration.yaml to
  /homeassistant/scribe.yaml (scribe: !include scribe.yaml); YAML stays
  authoritative, import semantics unchanged.
- template: switched to !include_dir_merge_list templates; new
  quick_sensors.yaml scaffold (top-level list, quick_ prefix, unique_id
  required; pure sums stay min_max per W1N-233).
- Verified post-restart 2026-08-29 20:19 CST: core check ok, scribe
  connection on, states_written 18581→19426, template entities = 12,
  no scribe/template log errors. Backup configuration.yaml.bak-20260829-201724-w1n238.
2026-08-29 20:25:53 +08:00
windyboy 6707cebc88 chore: gitignore .agent-work/ agent scratch dir
Untracked agent scratch made validate-repo.sh link scan fail; same
category as the already-ignored .agents/ and .claude/ dirs.
2026-08-29 20:25:53 +08:00
windyboy 908ff5412a docs(hass): Quick dashboard round-2 state — badges, total-power helper, span-2 power pair, fill/colors (W1N-231)
Sync Quick dashboard section with live config: 2x2 mushroom light grid,
kong_diao AC entity fix, heading badges (env temps / AC / PC / total power),
min_max sum helper sensor.dang_qian_zong_gong_lu (fail-closed), selective
tozeroy fill on base-load chart, power charts paired as span-2 sections with
card titles removed, motion per-entity colors.
2026-08-29 19:54:35 +08:00
windyboy e58283210a chore(vaultwarden,healthcheck): upgrade 1.37.2 (Bitwarden 2026.8+); fix vps-health checks
- vaultwarden/server:1.37.1 -> 1.37.2 (required for Bitwarden clients 2026.8.0+)
- compose probe: flag only active services (config --services) so debug-profile
  pgweb 'Exited' no longer false-positives
- runner: build aggregate args line-by-line (robust vs Jinja trim_blocks)
- SMTP AUTH probe moved host-side (vaultwarden image has no python3); never
  prints the SMTP password
- us2 facts: probe refresh 2026-08-29, image/version, vps-health install
2026-08-29 19:54:32 +08:00
windyboy 8c73d1f894 docs(hass,pgdb): timescale-plotly-card chart stack — reader+card install, sensor_minute pipeline, Quick dashboard
- reader timescale_database_reader v1.1.0 (bb8776a) + card timescale-plotly-card
  2.2.0 (217961d), manual installs; config entry, Lovelace resource id recorded
- pgdb scribe: sensor_minute_aggregate cagg + sensor_minute hypertable + jobs
  1005/1006/1007; states_raw 3-month retention/compression statements deliberately
  skipped (permanent archive per host doc)
- sensor_minute_refresh local patch ELSE 0 → ELSE NULL (unavailable-minute zeros
  poison diff-mode energy charts) + one-time cleanup (505 head rows, 26 impossible
  zeros); re-apply after re-running upstream 02 SQL
- Quick dashboard: 5 chart cards via WS lovelace/config/save; documented section
  column_span (absent → span 1) vs card grid_options sizing rules
2026-08-29 15:54:40 +08:00
windyboy bc0a86245d docs: Scribe retention v4.x status (user declined RCs, 2026-08-29); HA Core 2026.8.3 verified 2026-08-29 14:56:05 +08:00
windyboy 13032fd0bb Merge origin/main (production Makefile) into pgdb runbooks delivery 2026-08-29 14:52:10 +08:00
windyboy d2063e7496 Merge pgdb ops runbooks — health/restore/update (W1N-228) 2026-08-29 14:51:31 +08:00
windyboy 2d95f87897 docs(runbooks): pgdb ops runbooks — health / restore / update + facts refresh (W1N-228)
- runbooks/pgdb-health.md: read-only health check (8 diagnostics) — containers,
  PG core + HA clients, write activity, TimescaleDB hypertables/compression,
  pgweb auth/bookmarks, daily custom-format backups, disk/fstab, logs
- runbooks/pgdb-restore.md: procedure-type restore (pg_restore -Fc, temp-DB swap,
  approval gates, rollback) — precondition command verified live
- runbooks/pgdb-update.md: gated command reference (pull -> config -q -> up -> verify;
  rollback = /opt/database/run + old volumes)
- index + validate-repo.sh classification updated; hosts/pgdb.md refreshed
  (SSH key auth works, scribe events hypertable, runbook cross-refs)
2026-08-29 14:51:20 +08:00
windyboy b61513c93e chore(pgdb): land W1N-227 compose-化 leftovers (compose source, host facts, inventory, scribe notes) 2026-08-29 14:51:20 +08:00
windyboy ab808088b8 Add production Makefile for routine VPS ops
Wrap validate-repo.sh and routine Ansible playbooks with safe-by-default
targets: read-only health/audit flows, CONFIRM=1 gates for mutating work,
and LIMIT/TARGETS guards. Document entry point in AGENTS.md.
2026-08-26 11:27:02 +08:00
windyboy c7dc4fd25c chore: remove duplicate hook setup
Keep pre-commit configuration as the single validation path and tolerate deleted tracked Markdown during link validation.
2026-08-23 17:58:19 +08:00
windyboy 7bc7d3f99b docs: align runbooks and validation structure 2026-08-23 17:42:31 +08:00
windyboy fea9a6560f chore: gitignore .opencode/ and .zcode/ local tool caches 2026-08-23 17:10:07 +08:00
windyboy a95b626636 docs: Matter bulbs failure mode C — both bulbs announce mDNS but refuse TCP 5540 (08-23 read-only verification); record 08-22 add/loop saga, working bulb MAC change, 3-fabric map, PD rotation to 238:4812; refresh stale DHCP-reservation note on gw (W1N-207) 2026-08-23 11:06:35 +08:00
windyboy 27fe9c078e docs: archive historical planning documents and fix references
Move self-described historical/upstream docs to docs/archive/:
- agent-runbook-guide.md
- lan-core-switch-upgrade-plan.md
- lan-rb5009-upgrade.md
- se5420-review-claim-verification-2026-08.md

Update archive/README.md manifest and fix relative links in active docs
and archived docs. Update AGENTS.md docs/ layout description.
2026-08-22 19:34:05 +08:00
windyboy aaa4ee312e docs: verify gw switch0 as limited capture point — SE5420 single-uplink (eth1 up, eth2/3 down), LAN55 wired hosts behind SE5420; record EdgeOS 3 CLI/access quirks (W1N-207) 2026-08-22 10:44:20 +08:00
windyboy 6b298491a8 docs: Matter packet-capture runbook v2 — hass end0 commissioner capture point, corrected AP-point scope (no wired↔wired unicast), UAP-AC-Lite model fix, SE5420 live status; fix hass interface end1→end0 (W1N-207) 2026-08-22 10:14:29 +08:00
windyboy 32631e2996 docs: add Matter pairing troubleshooting handbook; record bulb state + DHCP reservation mismatch (W1N-207) 2026-08-22 08:18:23 +08:00
windyboy 1426b4ecfe docs: matrix_e2ee v0.3.12/v0.3.9 notes; gw/ubnt IPv6 re-verification; agent sandbox SSH quirk (2026-08-20) 2026-08-21 08:57:53 +08:00
windyboy 1f5e58bf17 docs: record Matter/IPv6 findings — stale matter-server mDNS address, SSID cleanup, ER-X ULA infeasibility (W1N-207) 2026-08-21 08:56:26 +08:00
windyboy e5819eeba3 docs: correct hass hardware to x88 Pro physical box; sync CSG v1.3.2
- hass.windy.lan is a physical x88 Pro box (HAOS bare-metal, machine: green,
  CPE x88pro20, virtualization empty) — not PVE VM 180 (verified live 2026-08-18)
- Record CSG v1.3.2 (934f58c, W1N-118) deploy in maintenance runbook verify
  section and hosts live-tree section (backups now include w1n118)
2026-08-18 13:32:33 +08:00
windyboy 079332e082 docs: record matrix_e2ee v0.3.0 deploy; fix and rename matrix-e2ee update runbook
- Deploy v0.3.0 (main 216cc99, W1N-180 bot-initiated device verification
  wizard) on hass.windy.lan; backup matrix_e2ee.bak-20260818-v0.2.10
- Fix runbook: tag-only prerequisite (v0.3.0 was untagged), working-tree
  HEAD check, rsync exit-23 note, actual setup log line, post-deploy record
  step, ssh_config.d -F /dev/null gotcha
- Rename runbook matrix-e2e-update.md -> matrix-e2ee-update.md and update
  AGENTS.md/hosts references (domain is matrix_e2ee, double-e)
- Unify matrix_e2ee naming and update version history in
  docs/home-assistant-matrix.md
2026-08-18 13:31:15 +08:00
windyboy 7cedba7f51 docs: rename integration name from matrix_e2ee to matrix_e2e
- Rename runbook: matrix-e2ee-update.md -> matrix-e2e-update.md
- Update all references in AGENTS.md, hass.windy.lan.md,
  home-assistant-matrix.md to use the short name matrix_e2e
- The code domain stays matrix_e2ee (E2EE) in source; all
  doc prose and command references now use matrix_e2e
2026-08-18 13:31:15 +08:00
windyboyandCursor 343c5db415 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>
2026-08-17 17:36:39 +08:00
windyboy 885d977531 docs(runbooks): light-enhance home-assistant-maintenance and index it
Rebase onto origin/main surfaced runbooks/home-assistant-maintenance.md
(W1N-69) which predated the runbook reorg. Add Purpose/Scope/Safety
headers and add it to the README routing index (now 17/17 consistent).
2026-08-17 16:02:33 +08:00
windyboy b0c01b2551 docs(runbooks): add runbook spec, template, index and 6 first-batch runbooks; light-enhance existing 10
- RUNBOOKS.md: repo-level spec (six-field model, naming, safety, maturity path)
- runbooks/_template.md + README.md: standard template and 16-entry routing index
- new: issue-to-merge, fix-ci, release, rollback, network-change, network-recovery
- light-enhance 10 existing runbooks with Purpose/Scope/Safety headers
- AGENTS.md: point step 3 at index/spec, add runbook execution rules
- docs/agent-runbook-guide.md: archive of Manus AI guide
2026-08-17 15:59:46 +08:00
windyboy 047ac03346 chore(skills): remove vendored encrypted-dns-skill (installed globally via skills CLI) 2026-08-15 12:35:55 +08:00
windyboy 1ec9246156 docs: treat ha backups list as ignored arg, not a subcommand
ha backups --help has no list; extra positional args still print
the default backup list with exit 0. Keep the ha host update fix.
2026-08-14 22:44:02 +08:00
windyboy 1936b8f5fe docs: correct ha backups list CLI note in HA runbook
ha backups list exists on this host; only ha host update is missing.
2026-08-14 22:42:44 +08:00
windyboy eda6536ddb docs: record CSG v1.3.1 zip install and correct ha-maintenance restart failure
ha-maintenance.sh --restart-core --yes exited 1 in <1s without restarting
Core. Empty output is ssh failure hidden by 2>/dev/null + pipefail, not a
MOTD-strip after a successful restart. Direct `ha core restart` is the
working path.
2026-08-14 22:31:31 +08:00
windyboy 1dc880362d docs: add HA Matrix integration notes and record .local rewrite removals 2026-08-14 18:17:26 +08:00
windyboy 70aea6cd72 feat(ednsdiag): add DoQ/DoH3/DNSCrypt transports, proxy support, probe & compare 2026-08-14 18:17:26 +08:00
windyboy 8303d78caf Record W1N-105 CSG network step, P1, and fork master→main rename on hass.windy.lan. 2026-08-14 18:15:28 +08:00
windyboy ebfe7b8488 docs(gw): document EdgeOS PPPoE redial procedure 2026-08-14 16:26:23 +08:00
windyboy 88eaefda33 Record W1N-104 CSG auto dual-stack deploy on hass.windy.lan.
end1 IPv6 is on, wlan0 stays off, and the live custom component is de01914
with ip_family=auto after the IPv4 blackhole.
2026-08-14 16:11:03 +08:00
windyboy fcb76d3d5a Record W1N-102 CSG deploy and IPv4 blackhole on hass.windy.lan.
The host note now states the aiohttp IPv4 client is live, Core loaded it,
and home PPPoE IPv4 to 95598.csg.cn is currently blackholed while IPv6
works on gw. HA still has IPv6 disabled (W1N-85).
2026-08-14 15:47:08 +08:00
windyboy 6ae835037b hass.windy.lan: resolve health snapshot issues (W1N-70..76) and document findings
- Add tianqi weather recorder patch notes (W1N-75: _unrecorded_attributes)
- Document Bluetooth hci0 RTL8821CS instability (W1N-74) and eMMC lifetime
  10% (W1N-76) as known issues
- Rewrite runbook known-issues section: all snapshot items resolved; link
  hosts doc for the two remaining known issues
2026-08-13 19:48:18 +08:00
windyboy b5617fd3a9 docs(ha): add Home Assistant maintenance runbook + ha CLI script (W1N-69)
- runbooks/home-assistant-maintenance.md: access pattern (sudo -n -i ha),
  command reference verified on host, recovery ops, families not scripted,
  docs-vs-CLI discrepancies
- runbooks/scripts/ha-maintenance.sh: read-only health/logs + --yes-gated
  update/restart/rebuild/rollback/reboot/backup/restore/app modes
- AGENTS.md: register runbook in table; hosts/hass: access pattern + link
2026-08-13 17:54:33 +08:00
windyboy f5842568b9 docs: add Home Assistant API access notes 2026-08-13 17:20:49 +08:00
windyboy 6f8a4918f0 feat(ednsdiag): support custom DoH endpoint via --url
Allow overriding the provider preset with an explicit HTTPS DoH URL,
including validation that custom endpoints apply only to DoH queries.
2026-08-13 15:34:54 +08:00
windyboy 5b0f7950e6 docs: add hass.windy.lan Home Assistant host documentation
Document HAOS on PVE VM 180 (LAN55) with verified SSH access,
network details, and cross-links from lan-overview, inventory,
and AGENTS quick map.
2026-08-13 14:10:39 +08:00
windyboy c0cf82d4af tools(skills): add encrypted-dns-skill (ednsdiag CLI + agent skill) 2026-08-13 12:52:20 +08:00
windyboy 95ec2350af docs(dns): record mosdns foreign DoH multi-upstream redundancy (W1N-62) 2026-08-13 10:56:40 +08:00
windyboy 3de4beb028 docs: add low-volume mono laser MFP buying guide (2026-08)
Decision tree for occasional B&W laser MFP purchases: Brother L1638W/L1848W
as default, cloud-subscription models as opt-in only, and one-veto checks
for AirPrint, Ethernet, and duplex/ADF needs.
2026-08-13 10:54:29 +08:00
windyboy d54ec71aea docs(dns): record gfw foreign branch DoH change (W1N-62)
Document encrypted DoH upstream for mosdns foreign queries and note that
DoH traffic goes direct to hk2, not via OpenClash proxy.
2026-08-13 10:50:16 +08:00
windyboy 2ffd9f9f9c docs(se5420): align review claim verification with current guide (W1N-63)
Add historical snapshot header (baseline 35577d0), rewrite outdated
"current guide" assertions for post-ffb37a9 revisions, and add a
12-row status table mapping review claims to current §4.3/§11 sections.
2026-08-13 10:50:16 +08:00
windyboy f255785b72 docs(se5420): add review claim verification record (2026-08-10)
Documents which deployment-guide review claims are confirmed by specs,
field read-only checks on gfw, and remaining pre-change evidence needs.
2026-08-13 10:12:06 +08:00
windyboy 2fd354c2a9 docs(dns): record mosdns fallback hardening for AGH outage (W1N-56) 2026-08-12 22:24:54 +08:00
windyboy 82203038f0 docs(dns): record mosdns sequence misconfig found+fixed (W1N-56) 2026-08-12 22:19:20 +08:00
windyboy 096e1ce8b6 docs(dns): correct idle-mosdns premise in alternatives research (W1N-56) 2026-08-12 22:12:32 +08:00
windyboy 8550053287 docs(dns): record Phase 0 verification evidence + final decision alignment (W1N-56) 2026-08-12 22:11:53 +08:00
windyboy e501b93d65 docs(dns): correct gfw/.1 facts (W1N-56)
- hosts/gfw.windy.lan.md: 3 NICs (eth2/VLAN10 ubunt_upg live), mosdns is
  now OpenClash's nameserver (not idle), rewrite VLAN10 Wi-Fi section to
  live-verified state
- docs/lan-dns-architecture.md: mosdns on gfw no longer 闲置; note the
  recommended AGH+.36 companion architecture is still pending review
2026-08-12 22:11:53 +08:00
windyboy 62b8fbb8b7 docs(dns): add LAN DNS architecture research + recommendation (W1N-56) 2026-08-12 22:11:53 +08:00
windyboy efa6cf0899 docs(dns): record agh_ui_access LAN55 allow for Home Assistant (2026-08-12) 2026-08-12 22:11:53 +08:00
windyboy 086740b16e docs: record pdns PDA removal, us4 firewalld ops, LAN DNS alternatives
- runbooks/pdns-health.md: note the legacy powerdns-admin (PDA) orphan was
  removed 2026-08-12 (W1N-59).
- runbooks/ansible-operations.md: document the us4 firewalld reconciliation
  playbook scope (audited public zone only, fail-closed, no reload).
- docs/agents/domain.md: single-context repo layout for domain docs.
- docs/lan-dns-alternatives.md: notes on LAN DNS alternatives.
- .gitignore: exclude local agent-harness config (.agents/ .claude/ .omp/
  .mcp.json WATCHDOG.yml skills-lock.json) from the repo.
2026-08-12 21:16:31 +08:00
windyboy 1f6d028ab5 feat(us4): firewall audit + safe reconciliation playbook, host doc
Add playbooks/us4-firewalld.yml, a narrow reconciliation of the audited us4
public zone: fails closed on drift or unknown allowances, never reloads or
restarts firewalld, and does not manage Docker rules. Requires explicit
apply + provider-console confirmations, backs up the firewalld config and
ruleset, schedules an automatic 15-minute rollback via at, and verifies SSH,
HTTPS routes, containers, Fail2ban jails, and the WireGuard health check before
cancelling rollback. Pins ansible.posix 2.2.2 in requirements.yml.

Also expand hosts/us4.wsvc.info.md with deployment config and a live audit
snapshot (2026-08-12).
2026-08-12 21:16:31 +08:00
windyboy 035587e3bf feat(rustdesk): onboard self-hosted RustDesk server on hk2
Deploy hbbs + hbbr via a safe-by-default Ansible role (playbooks/rustdesk.yml):
with rustdesk_confirm=false it only reports whether compose.yml matches live
state and refuses to recreate the stack; with rustdesk_confirm=true it deploys
and recreates. The relay assert rejects the known-bad hk2.wsvc.info hostname.

Add health profiles rustdesk (hbbs/hbbr health, relay DNS) and hk2aux (co-located
traefik/adguard/remark42 on hk2), plus the rustdesk-health runbook and AGENTS.md
entry. Server image pinned rustdesk/rustdesk-server:1.1.14.
2026-08-12 21:16:30 +08:00
windyboy e7296e664a feat(ansible): move hosts to healthcheck_profiles list; decouple audit/restic
Inventory now declares the plural healthcheck_profiles list per host (hk2 runs
pdns, rustdesk, hk2aux) instead of a single healthcheck_profile, and carries
the rustdesk server vars (rustdesk_compose_dir/relay/image) plus the rustdesk
host group.

The restic role relied on the removed singular healthcheck_profile var; it now
uses its own restic_backup_profile (set per host to vaultwarden on us2 and pdns
on hk2), so the health-check rename no longer breaks it. audit.yml's summary
labels the host's profile list instead of the singular var.
2026-08-12 21:16:30 +08:00
windyboy d0d5e5a704 refactor(healthcheck): support multiple profiles per host with aggregate result
Replace the single healthcheck_profile with a healthcheck_profiles list so a
host can run several checks (e.g. hk2: pdns, rustdesk, hk2aux). Profiles emit
per-check JSON to latest-<check>.json; the dispatcher clears stale per-check
files, runs every profile, and merges them into latest.json.

Contract: a single complete check keeps the historical verbatim latest.json
shape; several checks produce a worst-status aggregate retaining every check's
detail. A profile that crashes before reporting is aggregated as unknown so
latest.json can never go stale while the dispatcher fails. The dispatcher exits
with the worst (max) profile exit code.
2026-08-12 21:16:30 +08:00
windyboy baca89be83 docs(hk2): document Traefik dashboard auth and password rotation 2026-08-12 21:16:30 +08:00
windyboy 562de68127 docs: record VLAN10 live verification (DHCP lease, NAT counters, isolation) 2026-08-11 19:36:20 +08:00
windyboy 86bfb5a953 docs: record SE5420 VLAN10 deployment pitfalls (trunk PVID, fw4 dest=lan, minimal gfw migration) 2026-08-11 19:22:22 +08:00
windyboy 3ec51299cc docs: note VLAN1 undeletable on SE5420; disable unused ports first 2026-08-11 13:13:33 +08:00
windyboy 7a54e8d9ec docs: align SE5420 guide with actual Chinese GUI menus and port layout 2026-08-11 12:59:46 +08:00
windyboy 7b4c48eb93 docs: refine SE5420 upgrade guide into granular step-by-step ops 2026-08-11 11:07:25 +08:00
windyboy ffb37a916e docs: finalize SE5420 deployment guide per review
- Reorder §3.3: create VLAN66/55 before SVI; keep VLAN1 until management path verified
- Fix §3.4 verification port wording (enabled VLAN66 access port, e.g. port 6)
- Harden VLAN10 phase: PVE boundary, gfw NAT/IPv6 policy, rollback order, verification chain
2026-08-10 11:00:28 +08:00
windyboy 35577d0c5c docs: add SE5420 core-switch deployment plans; note PVE VM topology 2026-08-09 21:22:02 +08:00
windyboy e90eef9647 docs: record UniFi SSO login change and Ansible management (W1N-51) 2026-08-08 09:17:55 +08:00
windyboy 14a758a28a feat: manage UniFi SSO login setting via Ansible (W1N-51) 2026-08-08 09:15:49 +08:00
windyboy c386fe8136 docs: record gfw OpenClash core v1.19.29 upgrade (W1N-50) 2026-08-06 16:45:11 +08:00
windyboy e2884069a8 docs: add live-verified LAN overview; refresh gfw facts 2026-08-06 16:22:20 +08:00
windyboy b03d7019c4 docs: onboard AdGuard Home to Ansible; retire check-mx2; migrate plan to Linear
- Add dns.windy.lan to Ansible inventory (adguardhome group/profile) with a
  read-only healthcheck template, systemd timer, and on-demand report parity
  with the other active hosts; verified green on the host.
- Add synapse.chans.xyz to the AGENTS.md active-host quick map.
- Remove plans/; migrate decisions/verification to a Linear vps project doc and
  file the remaining gaps as W1N-46..49 with blocking edges.
- Retire scripts/check-mx2.sh; point mailcow health/update runbooks and the
  mx2 host file at the Ansible health report.
- Trim us2 stub software to verified running services; note dormant dirs.
- Normalize repo docs to English; drop the non-existent CONTEXT.md/ADR claim
  and remove docs/agents/domain.md.
2026-08-06 15:48:48 +08:00
windyboy 29bdf06383 Document network operations and WireGuard maintenance 2026-08-06 10:20:43 +08:00
128 changed files with 12202 additions and 366 deletions
+13
View File
@@ -15,8 +15,21 @@ id_*
.ansible/
facts/
# Local agent-harness / tooling config (not repo content).
.agents/
.claude/
.omp/
.opencode/
.zcode/
.mcp.json
WATCHDOG.yml
skills-lock.json
# Editor and operating-system files.
.DS_Store
.vscode/
.idea/
*~
# Agent working scratch (not repo content).
.agent-work/
.tmp-*
+8
View File
@@ -0,0 +1,8 @@
repos:
- repo: local
hooks:
- id: validate-repo
name: validate repository
entry: scripts/validate-repo.sh
language: system
pass_filenames: false
+91 -25
View File
@@ -2,33 +2,58 @@
This repo is the **agent ops handbook + fact source** for maintaining personal VPS hosts. Prefer verifying live state over assuming docs are complete.
Also readable as `agent.md` (symlink → this file).
## How to work
1. Read [`inventory/hosts.md`](inventory/hosts.md) for the machine list.
2. Open the matching [`hosts/<name>.md`](hosts/) for SSH, roles, paths, and quirks.
3. For common tasks, follow a runbook under [`runbooks/`](runbooks/).
3. For common tasks, follow a runbook under [`runbooks/`](runbooks/). Pick the
most specific applicable one from [`runbooks/README.md`](runbooks/README.md);
the spec is [`RUNBOOKS.md`](RUNBOOKS.md) and new runbooks start from
[`runbooks/_template.md`](runbooks/_template.md).
4. Prefer read-only checks first; change only after confirming current state.
5. For routine checks and approved service reconciliation, run the matching
Ansible playbook from `ansible/`; see [routine Ansible operations](runbooks/ansible-operations.md).
6. Default SSH access (`ssh -4 windy@<host>`) is for focused diagnostics,
imperative upstream procedures, and incident work. Prefer **IPv4** from this
WSL client (AAAA often exists but IPv6 route does not).
> **Agent sandbox SSH quirk (verified 2026-08-20):** the agent shell runs in
> a sandboxed user namespace — system files such as
> `/etc/ssh/ssh_config.d/20-systemd-ssh-proxy.conf` appear owned by `nobody`,
> so plain `ssh` aborts with `Bad owner or permissions on ...`. Always use
> `ssh -F /dev/null` from the agent shell and pass options explicitly
> (`~/.ssh/config` is skipped; e.g. `ssh -F /dev/null -p 2222
> -i ~/.ssh/id_ed25519 windy@repo.windy.me`). `sudo` never works in the
> sandbox (`NoNewPrivs`, no capabilities, `/` read-only). The host itself is
> healthy — to inspect or act on the real host from the sandbox use
> `/mnt/c/WINDOWS/system32/wsl.exe -u root -- <cmd>` (real root: keep
> read-only unless a change is approved).
7. Record each material VPS operation, incident, configuration change, or
verification outcome in the corresponding **Linear `vps` project**. Include
scope, action, verification, and remaining follow-up; never put passwords,
tokens, private keys, recovery keys, or private room IDs in Linear.
verification outcome in the corresponding **Plane `vps` project**
(self-hosted `plane.chans.xyz`, Plane MCP `mcp__plane__*`, following the
`plane-workflow` skill). **Linear is retired as a record source (2026-09-03)
— do not create Linear issues;** existing W1N-* entries are read-only
history. Include scope, action, verification, and remaining follow-up; never
put passwords, tokens, private keys, recovery keys, or private room IDs in
Plane or Linear.
## Active hosts (quick map)
### Runbook execution rules
| 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) |
| **gfw.windy.lan** | OpenWrt LAN gateway / OpenClash | `ssh -4 root@192.168.66.1` | [hosts/gfw.windy.lan.md](hosts/gfw.windy.lan.md) |
| **ubnt** | UniFi Network Controller | `ssh -4 windy@192.168.66.46` | [hosts/ubnt.md](hosts/ubnt.md) |
Before operational work: inspect `runbooks/`, select the most specific
applicable runbook, follow its steps in order, do not skip verification steps,
and respect its STOP and approval conditions. If no runbook applies, diagnose
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
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
@@ -38,7 +63,7 @@ Also readable as `agent.md` (symlink → this file).
| SMTP `mx2.windy.me:587` (STARTTLS) or `:465` | mx2 | client submission; full email + mailbox password — [runbook](runbooks/mailcow-smtp-client.md) |
| IMAP `mx2.windy.me:993` | mx2 | same mailbox credentials |
| https://auth.wsvc.info | us2 (`/opt/vaultwarden`) | Vaultwarden (Postgres, **operational**) — client Server URL |
| `repo.windy.me:2222` | us2 (`/opt/soft-serve`) | Soft Serve (stub details) |
| `repo.windy.me` (git SSH `:2222` / web HTTPS) | us2 (`/opt/gitea`) | Gitea — 1.27.3-rootless pinned, backup sidecar; details in [hosts/us2.wsvc.info.md](hosts/us2.wsvc.info.md) |
| DNS `ns1.wsvc.info:53` | hk2 (`/opt/pdns`, Auth **5.0.6**) | PowerDNS auth — zones `windy.me`, `wsvc.info`, `chans.xyz` |
| https://pdns.wsvc.info | hk2 (`poweradmin`) | Poweradmin UI |
| https://pgweb.wsvc.info | hk2 (`pgweb`) | PowerDNS Postgres browser |
@@ -46,6 +71,7 @@ Also readable as `agent.md` (symlink → this file).
| https://synapse.chans.xyz | synapse | Synapse Client-Server + Federation API |
| https://account.chans.xyz | synapse | Matrix Authentication Service (local passwords) |
| https://admin.chans.xyz | synapse | Element Admin console (MAS admin auth) |
| https://plane.chans.xyz | synapse (`plane`, Helm `plane-ce` 1.8.0 / v1.4.1) | Plane project management (self-hosted, K3s) |
### Upstream docs
@@ -55,11 +81,15 @@ Also readable as `agent.md` (symlink → this file).
**Matrix (ESS on synapse):** Matrix homeserver running on `synapse.chans.xyz` via the official ESS (Element Server Suite) Helm chart with Synapse + MAS + Element Web + Admin. DNS zone `chans.xyz` managed by hk2 PowerDNS. Before changing config, read [docs/matrix-upstream.md](docs/matrix-upstream.md) and [hosts/synapse.chans.xyz.md](hosts/synapse.chans.xyz.md). K3s cluster on this node has hostPort 80/443 for Traefik (no ServiceLB). Health: [matrix-health](runbooks/matrix-health.md).
**Plane (on synapse):** Self-hosted Plane project management at `plane.chans.xyz`, Helm release `plane-app` (chart `plane-ce-1.8.0`, app `v1.4.1`) in ns `plane` on the same K3s node as Matrix. Config from `/home/windy/plane-k3s/values.yaml`; workload/cert/ingress details in [hosts/synapse.chans.xyz.md](hosts/synapse.chans.xyz.md). Its Postgres/MinIO PVCs are **not** backed up.
**RustDesk:** Self-hosted RustDesk server on `hk2.chans.xyz` (`/opt/rustdesk`, containers `hbbs`/`hbbr`, image pinned `1.1.14`). The `hbbs -r` relay hostname must resolve to the host's public IP `154.36.174.161` — use `hk2.chans.xyz` (never `hk2.wsvc.info`, which has no DNS record). Health: [rustdesk-health](runbooks/rustdesk-health.md).
## Runbooks & scripts
| Task | Path |
|------|------|
| mailcow health | [runbooks/mailcow-health.md](runbooks/mailcow-health.md) · `./scripts/check-mx2.sh` |
| mailcow health | [runbooks/mailcow-health.md](runbooks/mailcow-health.md) |
| mailcow update | [runbooks/mailcow-update.md](runbooks/mailcow-update.md) |
| mailcow SMTP/IMAP client | [runbooks/mailcow-smtp-client.md](runbooks/mailcow-smtp-client.md) |
| Vaultwarden health | [runbooks/vaultwarden-health.md](runbooks/vaultwarden-health.md) |
@@ -68,16 +98,48 @@ Also readable as `agent.md` (symlink → this file).
| PowerDNS health (hk2) | [runbooks/pdns-health.md](runbooks/pdns-health.md) |
| PowerDNS upstream refs | [docs/pdns-upstream.md](docs/pdns-upstream.md) |
| Matrix health | [runbooks/matrix-health.md](runbooks/matrix-health.md) |
| Plane health | [runbooks/plane-health.md](runbooks/plane-health.md) |
| RustDesk health (hk2) | [runbooks/rustdesk-health.md](runbooks/rustdesk-health.md) |
| AdGuard Home health | [runbooks/adguard-home-health.md](runbooks/adguard-home-health.md) |
| Host disk cleanup | [runbooks/host-disk-cleanup.md](runbooks/host-disk-cleanup.md) |
| Home Assistant maintenance | [runbooks/home-assistant-maintenance.md](runbooks/home-assistant-maintenance.md) + [scripts/ha-maintenance.sh](runbooks/scripts/ha-maintenance.sh) |
| matrix_e2ee update (hass.windy.lan) | [runbooks/matrix-e2ee-update.md](runbooks/matrix-e2ee-update.md) |
| Matrix upstream refs | [docs/matrix-upstream.md](docs/matrix-upstream.md) |
| Hermes Agent Matrix channel | [docs/hermes-matrix.md](docs/hermes-matrix.md) |
| UniFi local-service proxy bypass | [docs/unifi-openclash-localhost.md](docs/unifi-openclash-localhost.md) |
| UniFi SSO login setting (Ansible) | `cd ansible && ansible-playbook playbooks/unifi-sso.yml --limit unifi` |
| Routine Ansible operations | [runbooks/ansible-operations.md](runbooks/ansible-operations.md) |
| Routine make commands | `make help` (wraps `ansible-operations.md` read-only + gated flows) |
| Issue → mergeable change | [runbooks/issue-to-merge.md](runbooks/issue-to-merge.md) |
| Fix failing health/playbook run | [runbooks/fix-ci.md](runbooks/fix-ci.md) |
| Release a reviewed change | [runbooks/release.md](runbooks/release.md) |
| Roll back a change | [runbooks/rollback.md](runbooks/rollback.md) |
| Controlled network change | [runbooks/network-change.md](runbooks/network-change.md) |
| Network outage recovery | [runbooks/network-recovery.md](runbooks/network-recovery.md) |
```bash
./scripts/check-mx2.sh
```
Full index: [runbooks/README.md](runbooks/README.md). Spec: [RUNBOOKS.md](RUNBOOKS.md).
`check-mx2.sh` defaults to IPv4; set `MX2_ALLOW_IPV6=1` to allow IPv6. DNS checks use `1.1.1.1` / `8.8.8.8` (local stub is flaky).
Routine mailcow health: `cd ansible && ansible-playbook playbooks/health-report.yml --limit mailcow`. The local stub resolver is flaky; DNS probes use `1.1.1.1` / `8.8.8.8`.
## Agent skills
### Issue tracker
Issues are tracked in **Plane** — self-hosted at `plane.chans.xyz`, project
`vps` — and created/updated via the Plane MCP (`mcp__plane__*`), following the
`plane-workflow` skill. **Linear is retired as a record source (2026-09-03); do
not create Linear issues.** Existing W1N-* entries are read-only history.
`docs/agents/issue-tracker.md` documents the retired Linear workflow and is
stale; treat this section as authoritative.
### Triage labels
Default triage labels: needs-triage, needs-info, ready-for-agent, ready-for-human, wontfix. See `docs/agents/triage-labels.md`.
### Domain docs
Domain-documentation conventions, including lazily created `CONTEXT.md` and
`docs/adr/` entries when needed, are described in [`docs/agents/domain.md`](docs/agents/domain.md).
## Safety
@@ -110,10 +172,14 @@ Bills, rough notes, and personal clutter stay in the Obsidian vault. This repo h
## Layout
```
AGENTS.md / agent.md # this entry (agent.md → AGENTS.md)
inventory/hosts.md # machine index
AGENTS.md # this entry
RUNBOOKS.md # runbook spec (six-field model, naming, review rules)
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
docs/ # upstream doc indexes / design notes
scripts/ # read-only helper scripts
runbooks/ # step-by-step ops (README.md = index, _template.md = template)
docs/ # upstream refs / design notes / research records (active + archive/)
scripts/validate-repo.sh # repo-wide validation (run before merging)
Makefile # routine validate / health / gated ansible wrappers
```
+183
View File
@@ -0,0 +1,183 @@
# VPS ops hub — routine validate / health / gated Ansible wrappers.
# See runbooks/ansible-operations.md for playbook semantics.
SHELL := /usr/bin/env bash
.SHELLFLAGS := -eu -o pipefail -c
.DEFAULT_GOAL := help
REPO_ROOT := $(CURDIR)
ANSIBLE_DIR := $(REPO_ROOT)/ansible
export ANSIBLE_LOCAL_TEMP := $(REPO_ROOT)/.ansible/tmp
export ANSIBLE_HOME := $(REPO_ROOT)/.ansible
LIMIT ?=
EXTRA ?=
VERBOSE ?= 0
CONFIRM ?= 0
TARGETS ?=
TRAEFIK ?= 0
LIMIT_FLAG := $(if $(LIMIT),--limit $(LIMIT),)
VERBOSE_FLAG := $(if $(filter 1,$(VERBOSE)),-v,$(if $(filter 2,$(VERBOSE)),-vvv,))
.PHONY: help validate check deps galaxy syntax ansible-prep \
ping inventory audit health health-mailcow health-matrix \
maint-preview baseline compose-check \
install-healthchecks install-matrix-healthchecks compose-deploy reconcile
help:
@printf '%s\n' \
'VPS ops hub — make targets (run from repo root)' \
'' \
'Variables: LIMIT=<group|host> CONFIRM=1 TARGETS=<svc[,svc]> TRAEFIK=1 VERBOSE=0|1|2 EXTRA=...' \
'' \
'Local / repo:' \
' validate, check scripts/validate-repo.sh (pre-merge gate)' \
' deps, galaxy ansible-galaxy collection install' \
' syntax ansible-playbook --syntax-check all playbooks' \
'' \
'Read-only remote (ansible):' \
' ping ansible managed -m ping' \
' inventory ansible-inventory --graph' \
' audit playbooks/audit.yml' \
' health [LIMIT=…] playbooks/health-report.yml' \
' health-mailcow health --limit mailcow' \
' health-matrix health --limit matrix' \
' maint-preview playbooks/maintenance-preview.yml' \
' baseline playbooks/baseline.yml' \
' compose-check compose-deploy --check --diff (requires LIMIT=)' \
'' \
'Mutating (require CONFIRM=1; host-scoped targets require LIMIT=):' \
' install-healthchecks playbooks/healthchecks.yml' \
' install-matrix-healthchecks playbooks/matrix-healthchecks.yml' \
' compose-deploy playbooks/compose-deploy.yml' \
' reconcile playbooks/compose-reconcile.yml (requires TARGETS=)' \
'' \
'Examples:' \
' make validate' \
' make health LIMIT=mailcow' \
' make compose-check LIMIT=vaultwarden' \
' make compose-deploy LIMIT=vaultwarden CONFIRM=1' \
' make reconcile LIMIT=powerdns TARGETS=auth CONFIRM=1' \
' make reconcile LIMIT=vaultwarden TARGETS=vaultwarden TRAEFIK=1 CONFIRM=1' \
'' \
'Advanced (not wrapped — use ansible-playbook directly):' \
' us4-firewalld, unifi-sso, k3s-server, matrix-stack, wireguard-harden,' \
' restic, rustdesk, email-alerts, mailcow update runbook'
validate check:
@bash "$(REPO_ROOT)/scripts/validate-repo.sh"
deps galaxy: ansible-prep
@command -v ansible-galaxy >/dev/null 2>&1 || { echo "ansible-galaxy not found; install Ansible first." >&2; exit 1; }
@cd "$(ANSIBLE_DIR)" && ansible-galaxy collection install -r requirements.yml
syntax: ansible-prep
@if ! command -v ansible-playbook >/dev/null 2>&1; then \
echo "ansible-playbook not found; syntax check skipped." >&2; \
exit 0; \
fi
@fail=0; \
for p in "$(ANSIBLE_DIR)"/playbooks/*.yml; do \
if ! (cd "$(ANSIBLE_DIR)" && ansible-playbook --syntax-check "playbooks/$$(basename "$$p")" >/dev/null 2>&1); then \
echo "syntax-check failed: $$p" >&2; \
fail=1; \
fi; \
done; \
exit $$fail
ansible-prep:
@mkdir -p "$(ANSIBLE_HOME)/tmp" "$(ANSIBLE_HOME)/ssh-control"
define require_ansible
@command -v ansible-playbook >/dev/null 2>&1 || { echo "ansible-playbook not found; install Ansible first." >&2; exit 1; }
endef
define require_limit
@if [ -z "$(LIMIT)" ]; then \
echo "LIMIT is required (e.g. LIMIT=mailcow, LIMIT=vaultwarden, LIMIT=powerdns)." >&2; \
exit 1; \
fi
endef
define require_confirm
@if [ "$(CONFIRM)" != "1" ]; then \
echo "Mutating operation blocked. Re-run with CONFIRM=1" >&2; \
exit 1; \
fi
endef
define require_targets
@if [ -z "$(TARGETS)" ]; then \
echo "TARGETS is required (comma-separated service names, e.g. TARGETS=auth or TARGETS=vaultwarden)." >&2; \
exit 1; \
fi
endef
ping: ansible-prep
$(require_ansible)
@cd "$(ANSIBLE_DIR)" && ansible managed -m ping $(LIMIT_FLAG) $(VERBOSE_FLAG) $(EXTRA)
inventory: ansible-prep
$(require_ansible)
@cd "$(ANSIBLE_DIR)" && ansible-inventory --graph $(EXTRA)
audit: ansible-prep
$(require_ansible)
@cd "$(ANSIBLE_DIR)" && ansible-playbook playbooks/audit.yml $(LIMIT_FLAG) $(VERBOSE_FLAG) $(EXTRA)
health: ansible-prep
$(require_ansible)
@cd "$(ANSIBLE_DIR)" && ansible-playbook playbooks/health-report.yml $(LIMIT_FLAG) $(VERBOSE_FLAG) $(EXTRA)
health-mailcow: ansible-prep
$(require_ansible)
@cd "$(ANSIBLE_DIR)" && ansible-playbook playbooks/health-report.yml --limit mailcow $(VERBOSE_FLAG) $(EXTRA)
health-matrix: ansible-prep
$(require_ansible)
@cd "$(ANSIBLE_DIR)" && ansible-playbook playbooks/health-report.yml --limit matrix $(VERBOSE_FLAG) $(EXTRA)
maint-preview: ansible-prep
$(require_ansible)
@cd "$(ANSIBLE_DIR)" && ansible-playbook playbooks/maintenance-preview.yml $(LIMIT_FLAG) $(VERBOSE_FLAG) $(EXTRA)
baseline: ansible-prep
$(require_ansible)
@cd "$(ANSIBLE_DIR)" && ansible-playbook playbooks/baseline.yml $(LIMIT_FLAG) $(VERBOSE_FLAG) $(EXTRA)
compose-check: ansible-prep
$(require_ansible)
$(require_limit)
@cd "$(ANSIBLE_DIR)" && ansible-playbook playbooks/compose-deploy.yml --check --diff --limit $(LIMIT) $(VERBOSE_FLAG) $(EXTRA)
install-healthchecks: ansible-prep
$(require_ansible)
$(require_confirm)
@cd "$(ANSIBLE_DIR)" && ansible-playbook playbooks/healthchecks.yml $(LIMIT_FLAG) $(VERBOSE_FLAG) $(EXTRA)
install-matrix-healthchecks: ansible-prep
$(require_ansible)
$(require_confirm)
@cd "$(ANSIBLE_DIR)" && ansible-playbook playbooks/matrix-healthchecks.yml $(LIMIT_FLAG) $(VERBOSE_FLAG) $(EXTRA)
compose-deploy: ansible-prep
$(require_ansible)
$(require_limit)
$(require_confirm)
@cd "$(ANSIBLE_DIR)" && ansible-playbook playbooks/compose-deploy.yml --limit $(LIMIT) \
-e '{"compose_deploy_confirm": true}' $(VERBOSE_FLAG) $(EXTRA)
reconcile: ansible-prep
$(require_ansible)
$(require_limit)
$(require_targets)
$(require_confirm)
@json=$$(python3 -c 'import json,sys; t=[x.strip() for x in sys.argv[1].split(",") if x.strip()]; \
(not t) and sys.exit("TARGETS must contain at least one non-empty service name"); \
d={"service_reconcile_confirm": True, "service_reconcile_targets": t}; \
(sys.argv[2]=="1") and d.update({"service_reconcile_restart_traefik": True}); \
print(json.dumps(d))' "$(TARGETS)" "$(TRAEFIK)"); \
cd "$(ANSIBLE_DIR)" && ansible-playbook playbooks/compose-reconcile.yml --limit $(LIMIT) \
-e "$$json" $(VERBOSE_FLAG) $(EXTRA)
+89
View File
@@ -0,0 +1,89 @@
# RUNBOOKS — 仓库级规范
本文件统一所有 Runbook 的字段、命名、评审与变更规则。上游参考:[docs/archive/agent-runbook-guide.md](docs/archive/agent-runbook-guide.md)。
## 目录结构
```text
runbooks/
├── README.md # 意图 → 文件 路由索引(本目录的入口)
├── _template.md # 新建 runbook 的标准模板(复制后填写)
├── <intent>.md # 每份 runbook 只描述一种可识别的操作意图
└── ...
```
## 最小字段模型
每份 runbook 必须显式包含以下控制信息,否则盲目执行或错误恢复的风险会升高:
| 字段 | 作用 | 写作要求 |
|---|---|---|
| **Action** | 定义当前要执行的动作 | 可观察、可执行的动词;避免“检查一下”“适当调整” |
| **Expected** | 描述正常状态或预期输出 | 具体信号、阈值、状态码、测试结果或页面表现 |
| **Decision** | 定义分支与下一跳 | “条件 → 下一步”;无法判断时指向 `STOP` |
| **Verification** | 确认变更真正生效 | 每个有副作用的步骤后执行,不可跳过 |
| **Stop condition** | 规定何时不得继续 | 列出信息缺失、状态冲突、权限不足、验证失败等 |
| **Rollback** | 如何恢复到变更前状态 | 触发条件、前提、撤销步骤、回滚后验证 |
> 只读类 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`
- 一份文件只描述一种意图。流程出现明显分叉时拆分为独立文件,不堆叠“万能流程”。
- 只读诊断与变更操作应分离:health 类 runbook 保持只读,变更走 `ansible-operations.md``release.md``rollback.md` 或对应 gated playbook。
## 章节约定
- 每份 runbook 顶部含 `## Purpose`12 行)与 `## Scope`(适用/不适用情形)。
- 变更型 runbook 必须记录明确的审批门:门控命令式使用 `## Approval gates` 表;
流程式在步骤中记录审批动作、证据位置和未批准时的 `STOP`。破坏性/不可逆操作必须获得明确批准。
- 语言约定:**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`
`matrix-e2ee-update.md``vaultwarden-sqlite-to-postgres.md`),必须含 Approval gates 或确认变量
要求 + 显式 STOP,不替代流程式形态。新写的变更 runbook 默认用流程式。
- 统一在 `## Safety` 或正文中复用以下通用安全规则(更严格要求优先)。
```markdown
## Safety Rules
- Never delete an existing configuration as the first recovery action.
- Prefer read-only diagnosis before mutation.
- After every mutation, verify the expected state.
- If actual state conflicts with this runbook, STOP.
- Do not invent missing parameters.
- Do not bypass failed tests.
- Destructive actions require explicit approval.
```
## 评审与变更规则
- 新建/修改 runbook 与代码同仓评审,随系统演进更新。
- 每份 runbook 标注 `Last reviewed`;流程执行过程中发现的偏差记入对应的 Linear `vps` 项目 issue。
- 破坏性流程(迁移、删除、DNS 变更、网络变更)保持人工审批,不自动下沉。
## 成熟路径
1. **人工处理** → 现场处置与复盘,记录证据。
2. **Markdown runbook** → 固化步骤与证据要求,Agent 可辅助诊断。
3. **Agent + runbook** → 严格按流程执行,受 Stop/Approval 约束。
4. **Script / Ansible / Skill** → 把已稳定、低歧义、可验证的操作程序化(本仓库的执行层是 Ansible playbook)。
5. **人工审批 + 自动执行** → 审批门控下的自动变更(如 gated playbook + 确认变量)。
原则:先证据后变更,先小范围后扩大,先验证后结束,不确定则停止。
+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
+62 -3
View File
@@ -15,18 +15,23 @@ 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_profile: mailcow
healthcheck_profiles: [mailcow]
service_reconcile_services:
all:
compose_args: [--force-recreate]
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
healthcheck_profile: vaultwarden
compose_repo_project: vaultwarden
compose_remote_file: docker-compose.yml
healthcheck_profiles: [vaultwarden]
restic_backup_profile: vaultwarden
service_reconcile_services:
vaultwarden:
compose_args: [--force-recreate]
@@ -34,9 +39,13 @@ 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
healthcheck_profile: pdns
compose_repo_project: pdns
compose_remote_file: compose.yml
healthcheck_profiles: [pdns, rustdesk, hk2aux]
restic_backup_profile: pdns
service_reconcile_services:
auth:
compose_args: [--force-recreate]
@@ -45,6 +54,32 @@ all:
backup:
compose_args: [--no-deps, --force-recreate]
service_reconcile_traefik_restart_targets: [poweradmin]
# RustDesk server (same host, separate compose project)
rustdesk_compose_dir: /opt/rustdesk
rustdesk_relay: hk2.chans.xyz:21117
rustdesk_image: rustdesk/rustdesk-server:1.1.14
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]
wireguard_image: >-
lscr.io/linuxserver/wireguard@sha256:ac43e1226878d2611315172d6ea357a95cb326ee73124b91108118efc8666889
service_reconcile_services:
wireguard:
compose_args: [--no-deps, --force-recreate]
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]
service_reconcile_services:
adguardhome:
compose_args: [--no-deps, --force-recreate]
mailcow:
hosts:
mx2:
@@ -54,11 +89,34 @@ all:
powerdns:
hosts:
hk2:
rustdesk:
hosts:
hk2:
wireguard:
hosts:
us4:
adguardhome:
hosts:
dns_windy_lan:
unifi:
hosts:
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
unifi_container: unifi-controller
unifi_mongo_port: 27117
docker_hosts:
children:
mailcow:
vaultwarden:
powerdns:
wireguard:
adguardhome:
unifi:
# Matrix is a dedicated K3s node and intentionally remains outside the
# Docker-oriented managed group.
k3s_servers:
@@ -66,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
+1 -1
View File
@@ -64,7 +64,7 @@
ansible.builtin.debug:
msg:
host: "{{ inventory_hostname }}"
profile: "{{ healthcheck_profile }}"
profiles: "{{ healthcheck_profiles | default([]) | join(', ') }}"
os: "{{ ansible_distribution }} {{ ansible_distribution_version }}"
kernel: "{{ ansible_kernel }}"
compose_rc: "{{ audit_compose_ps.rc }}"
+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]
+20
View File
@@ -0,0 +1,20 @@
---
# Deploy/reconcile the self-hosted RustDesk server (hbbs + hbbr) on hk2.
#
# Safe by default: run with --check for a read-only report, or supply
# rustdesk_confirm=true to deploy the compose file and recreate the stack.
#
# # Read-only report
# ansible-playbook playbooks/rustdesk.yml --limit rustdesk --check
#
# # Apply (deploy compose + recreate hbbs/hbbr)
# ansible-playbook playbooks/rustdesk.yml --limit rustdesk \
# -e '{"rustdesk_confirm": true}'
- name: Deploy and reconcile RustDesk server
hosts: rustdesk
become: true
gather_facts: false
serial: 1
roles:
- role: rustdesk
tags: [rustdesk, mutating]
+25
View File
@@ -0,0 +1,25 @@
---
# Reconcile the UniFi controller SSO login setting
# (super_sdn.sso_login_enabled = "Sync Local Admin with Ubiquiti SSO").
#
# Safe by default: run with --check for a read-only status report, or supply
# unifi_sso_confirm=true to apply the declared target value.
#
# # Read-only status
# ansible-playbook playbooks/unifi-sso.yml --limit ubnt --check
#
# # Apply (disable SSO login; local accounts use local passwords, no MFA)
# ansible-playbook playbooks/unifi-sso.yml --limit ubnt \
# -e '{"unifi_sso_confirm": true, "unifi_sso_target_value": false}'
#
# # Rollback (re-enable SSO login)
# ansible-playbook playbooks/unifi-sso.yml --limit ubnt \
# -e '{"unifi_sso_confirm": true, "unifi_sso_target_value": true}'
- name: Reconcile UniFi SSO login setting
hosts: unifi
become: true
gather_facts: false
serial: 1
roles:
- role: unifi_sso
tags: [unifi, sso, mutating]
+456
View File
@@ -0,0 +1,456 @@
---
# Narrow reconciliation for the audited us4 public zone. This playbook never
# reloads or restarts firewalld and deliberately does not manage Docker rules.
- name: Safely remove audited stale firewalld allowances from us4
hosts: wireguard
become: true
gather_facts: false
serial: 1
any_errors_fatal: true
vars:
us4_firewalld_confirm: false
us4_console_confirm: false
us4_firewalld_zone: public
us4_firewalld_keep_services:
- dhcpv6-client
- http
- https
- smtp
- ssh
us4_firewalld_stale_services:
- imap
- imaps
- smtp-submission
- smtps
us4_firewalld_stale_ports:
- 24/tcp
- 6443/tcp
- 8443/tcp
us4_firewalld_expected_containers:
- nghttpx-proxy
- semaphoreui-postgres-1
- semaphoreui-semaphore-1
- squid-backend
- traefik
- trlm-server-trilium-1
- wireguard
us4_firewalld_backup_root: /var/backups/us4-firewall
tasks:
- name: Require the audited host and explicit apply confirmations
ansible.builtin.assert:
that:
- inventory_hostname == 'us4'
- ansible_host == 'us4.wsvc.info'
- ansible_host_ipv4 == '185.201.226.122'
- ansible_check_mode or (us4_firewalld_confirm | bool)
- ansible_check_mode or (us4_console_confirm | bool)
fail_msg: >-
Apply is allowed only for audited host us4 after the provider console
has been tested. Set both us4_firewalld_confirm=true and
us4_console_confirm=true. Check mode does not require confirmation.
- name: Verify the remote host identity
ansible.builtin.command:
argv: [hostname, -f]
check_mode: false
changed_when: false
register: us4_firewalld_hostname
- name: Reject an unexpected remote host
ansible.builtin.assert:
that:
- us4_firewalld_hostname.stdout == 'us4.wsvc.info'
- name: Verify required services are active
ansible.builtin.command:
argv: [systemctl, is-active, --quiet, "{{ item }}"]
check_mode: false
changed_when: false
loop:
- atd
- firewalld
- name: Verify firewalld Python bindings used by ansible.posix
ansible.builtin.command:
argv: [python3, -c, "import dbus, firewall, firewall.client"]
check_mode: false
changed_when: false
- name: Verify the default firewalld zone
ansible.builtin.command:
argv: [firewall-cmd, --get-default-zone]
check_mode: false
changed_when: false
register: us4_firewalld_default_zone
- name: Read runtime public-zone services
ansible.builtin.command:
argv: [firewall-cmd, --zone=public, --list-services]
check_mode: false
changed_when: false
register: us4_firewalld_runtime_services
- name: Read permanent public-zone services
ansible.builtin.command:
argv: [firewall-cmd, --permanent, --zone=public, --list-services]
check_mode: false
changed_when: false
register: us4_firewalld_permanent_services
- name: Read runtime public-zone ports
ansible.builtin.command:
argv: [firewall-cmd, --zone=public, --list-ports]
check_mode: false
changed_when: false
register: us4_firewalld_runtime_ports
- name: Read permanent public-zone ports
ansible.builtin.command:
argv: [firewall-cmd, --permanent, --zone=public, --list-ports]
check_mode: false
changed_when: false
register: us4_firewalld_permanent_ports
- name: Normalize the audited public-zone state
ansible.builtin.set_fact:
us4_firewalld_pre_services: "{{ us4_firewalld_runtime_services.stdout.split() | sort }}"
us4_firewalld_pre_permanent_services: "{{ us4_firewalld_permanent_services.stdout.split() | sort }}"
us4_firewalld_pre_ports: "{{ us4_firewalld_runtime_ports.stdout.split() | sort }}"
us4_firewalld_pre_permanent_ports: "{{ us4_firewalld_permanent_ports.stdout.split() | sort }}"
- name: Fail closed on public-zone drift or unknown allowances
ansible.builtin.assert:
that:
- us4_firewalld_default_zone.stdout == us4_firewalld_zone
- us4_firewalld_pre_services == us4_firewalld_pre_permanent_services
- us4_firewalld_pre_ports == us4_firewalld_pre_permanent_ports
- us4_firewalld_keep_services | difference(us4_firewalld_pre_services) | length == 0
- us4_firewalld_pre_services | difference(us4_firewalld_keep_services + us4_firewalld_stale_services) | length == 0
- us4_firewalld_pre_ports | difference(us4_firewalld_stale_ports) | length == 0
fail_msg: >-
The public zone differs from the audited baseline. Stop and review it;
this playbook will not infer whether an unknown allowance is required.
- name: Select only audited stale entries that currently exist
ansible.builtin.set_fact:
us4_firewalld_cleanup_services: >-
{{ us4_firewalld_stale_services | intersect(us4_firewalld_pre_services) | sort }}
us4_firewalld_cleanup_ports: >-
{{ us4_firewalld_stale_ports | intersect(us4_firewalld_pre_ports) | sort }}
- name: Report the proposed reconciliation
ansible.builtin.debug:
msg:
keep_services: "{{ us4_firewalld_keep_services }}"
remove_services: "{{ us4_firewalld_cleanup_services }}"
remove_ports: "{{ us4_firewalld_cleanup_ports }}"
reload_or_restart: false
- name: Create rollback material when cleanup is required
when:
- not ansible_check_mode
- us4_firewalld_cleanup_services | length > 0 or us4_firewalld_cleanup_ports | length > 0
block:
- name: Create the protected firewall backup root
ansible.builtin.file:
path: "{{ us4_firewalld_backup_root }}"
state: directory
owner: root
group: root
mode: "0700"
- name: Create a backup timestamp
ansible.builtin.command:
argv: [date, +%Y%m%dT%H%M%S%z]
changed_when: false
register: us4_firewalld_backup_timestamp
- name: Set the protected backup directory
ansible.builtin.set_fact:
us4_firewalld_backup_dir: >-
{{ us4_firewalld_backup_root }}/{{ us4_firewalld_backup_timestamp.stdout }}
us4_firewalld_rollback_command: >-
{{ us4_firewalld_backup_root }}/{{ us4_firewalld_backup_timestamp.stdout }}/rollback-phase1.sh
- name: Create the protected backup directory
ansible.builtin.file:
path: "{{ us4_firewalld_backup_dir }}"
state: directory
owner: root
group: root
mode: "0700"
- name: Back up the complete firewalld configuration
ansible.builtin.command:
argv:
- tar
- --create
- --gzip
- "--file={{ us4_firewalld_backup_dir }}/firewalld.tgz"
- --directory=/etc
- firewalld
changed_when: true
- name: Capture the pre-change runtime ruleset
ansible.builtin.shell:
cmd: >-
umask 077 && nft list ruleset >
{{ us4_firewalld_backup_dir | quote }}/nft-ruleset.txt
executable: /bin/bash
changed_when: true
- name: Install the exact pre-change rollback script
ansible.builtin.copy:
dest: "{{ us4_firewalld_rollback_command }}"
owner: root
group: root
mode: "0700"
content: |
#!/bin/sh
set -eu
exec >>/var/log/us4-firewalld-phase1-rollback.log 2>&1
printf '%s rollback start\n' "$(date -Is)"
add_service() {
service=$1
/usr/bin/firewall-cmd --permanent --zone=public \
--query-service="$service" >/dev/null 2>&1 ||
/usr/bin/firewall-cmd --permanent --zone=public \
--add-service="$service"
/usr/bin/firewall-cmd --zone=public \
--query-service="$service" >/dev/null 2>&1 ||
/usr/bin/firewall-cmd --zone=public --add-service="$service"
}
add_port() {
port=$1
/usr/bin/firewall-cmd --permanent --zone=public \
--query-port="$port" >/dev/null 2>&1 ||
/usr/bin/firewall-cmd --permanent --zone=public \
--add-port="$port"
/usr/bin/firewall-cmd --zone=public \
--query-port="$port" >/dev/null 2>&1 ||
/usr/bin/firewall-cmd --zone=public --add-port="$port"
}
{% for service in us4_firewalld_cleanup_services %}
add_service {{ service }}
{% endfor %}
{% for port in us4_firewalld_cleanup_ports %}
add_port {{ port }}
{% endfor %}
/usr/bin/firewall-cmd --check-config
printf '%s rollback complete\n' "$(date -Is)"
- name: Schedule the 15-minute automatic rollback
ansible.builtin.shell:
cmd: |
set -euo pipefail
output=$(printf '%s\n' {{ us4_firewalld_rollback_command | quote }} | at now + 15 minutes 2>&1)
job_id=$(printf '%s\n' "$output" | sed -n 's/^job \([0-9][0-9]*\).*/\1/p')
test -n "$job_id"
printf '%s\n' "$job_id"
executable: /bin/bash
changed_when: true
register: us4_firewalld_rollback_job
- name: Record the automatic rollback job
ansible.builtin.set_fact:
us4_firewalld_rollback_job_id: "{{ us4_firewalld_rollback_job.stdout }}"
us4_firewalld_rollback_cancelled: false
- name: Persist the rollback job ID beside the backup
ansible.builtin.copy:
dest: "{{ us4_firewalld_backup_dir }}/phase1-at-job-id"
owner: root
group: root
mode: "0600"
content: "{{ us4_firewalld_rollback_job_id }}\n"
- name: Reconcile and verify the audited public zone
block:
- name: Remove audited stale firewalld services
ansible.posix.firewalld:
zone: "{{ us4_firewalld_zone }}"
service: "{{ item }}"
state: disabled
permanent: true
immediate: true
loop: "{{ us4_firewalld_stale_services }}"
- name: Remove audited stale firewalld ports
ansible.posix.firewalld:
zone: "{{ us4_firewalld_zone }}"
port: "{{ item }}"
state: disabled
permanent: true
immediate: true
loop: "{{ us4_firewalld_stale_ports }}"
- name: Verify the permanent firewalld configuration
ansible.builtin.command:
argv: [firewall-cmd, --check-config]
when: not ansible_check_mode
changed_when: false
- name: Read reconciled runtime services
ansible.builtin.command:
argv: [firewall-cmd, --zone=public, --list-services]
changed_when: false
when: not ansible_check_mode
register: us4_firewalld_after_runtime_services
- name: Read reconciled permanent services
ansible.builtin.command:
argv: [firewall-cmd, --permanent, --zone=public, --list-services]
changed_when: false
when: not ansible_check_mode
register: us4_firewalld_after_permanent_services
- name: Read reconciled runtime ports
ansible.builtin.command:
argv: [firewall-cmd, --zone=public, --list-ports]
changed_when: false
when: not ansible_check_mode
register: us4_firewalld_after_runtime_ports
- name: Read reconciled permanent ports
ansible.builtin.command:
argv: [firewall-cmd, --permanent, --zone=public, --list-ports]
changed_when: false
when: not ansible_check_mode
register: us4_firewalld_after_permanent_ports
- name: Require the exact audited post-change public zone
ansible.builtin.assert:
that:
- us4_firewalld_after_runtime_services.stdout.split() | sort == us4_firewalld_keep_services | sort
- us4_firewalld_after_permanent_services.stdout.split() | sort == us4_firewalld_keep_services | sort
- us4_firewalld_after_runtime_ports.stdout.split() | length == 0
- us4_firewalld_after_permanent_ports.stdout.split() | length == 0
when: not ansible_check_mode
- name: Verify a fresh independent SSH and sudo path
ansible.builtin.command:
argv:
- ssh
- -4
- -o
- BatchMode=yes
- -o
- ConnectTimeout=10
- -o
- ControlMaster=no
- -o
- ControlPath=none
- windy@us4.wsvc.info
- sudo -n true
delegate_to: localhost
become: false
changed_when: false
when: not ansible_check_mode
vars:
ansible_become: false
- name: Verify public HTTPS routes
ansible.builtin.uri:
url: "{{ item.url }}"
follow_redirects: all
status_code: "{{ item.status }}"
validate_certs: true
use_proxy: false
loop:
- {url: https://update.wsvc.info/, status: 200}
- {url: https://us4-gate.wsvc.info/, status: 401}
- {url: https://trlm.wsvc.info/, status: 200}
delegate_to: localhost
become: false
when: not ansible_check_mode
vars:
ansible_become: false
- name: Verify the secondary MX TCP listener externally
ansible.builtin.wait_for:
host: "{{ ansible_host_ipv4 }}"
port: 25
state: started
connect_timeout: 5
timeout: 10
delegate_to: localhost
become: false
when: not ansible_check_mode
vars:
ansible_become: false
- name: Verify all expected containers are running
ansible.builtin.command:
argv: [docker, ps, --format, "{{ '{{.Names}}' }}"]
changed_when: false
when: not ansible_check_mode
register: us4_firewalld_running_containers
- name: Reject missing application containers
ansible.builtin.assert:
that:
- us4_firewalld_expected_containers | difference(us4_firewalld_running_containers.stdout_lines) | length == 0
when: not ansible_check_mode
- name: Verify Fail2ban remains active
ansible.builtin.command:
argv: [fail2ban-client, status]
changed_when: false
when: not ansible_check_mode
register: us4_firewalld_fail2ban
- name: Require all audited Fail2ban jails
ansible.builtin.assert:
that:
- item in us4_firewalld_fail2ban.stdout
loop:
- postfix-postscreen
- postfix-sasl
- recidive
- sshd
when: not ansible_check_mode
- name: Verify the deployed WireGuard health check
ansible.builtin.command:
argv: [/usr/local/lib/vps-health/run]
changed_when: false
when: not ansible_check_mode
register: us4_firewalld_wireguard_health
- name: Cancel automatic rollback only after all checks pass
ansible.builtin.command:
argv: [at, -r, "{{ us4_firewalld_rollback_job_id }}"]
changed_when: true
when:
- not ansible_check_mode
- us4_firewalld_cleanup_services | length > 0 or us4_firewalld_cleanup_ports | length > 0
- name: Mark the automatic rollback as cancelled
ansible.builtin.set_fact:
us4_firewalld_rollback_cancelled: true
when:
- not ansible_check_mode
- us4_firewalld_cleanup_services | length > 0 or us4_firewalld_cleanup_ports | length > 0
rescue:
- name: Preserve the automatic rollback and stop
ansible.builtin.fail:
msg: >-
A reconciliation or verification task failed. No reload was
attempted. If cleanup was required, its automatic rollback remains
scheduled; do not remove it manually.
always:
- name: Report backup and rollback disposition
ansible.builtin.debug:
msg:
backup: >-
{{ us4_firewalld_backup_dir |
default('not-created-in-check-mode' if ansible_check_mode else 'not-required') }}
automatic_rollback: >-
{{ 'not-created-in-check-mode' if ansible_check_mode else
('cancelled-after-success' if (us4_firewalld_rollback_cancelled | default(false)) else
'scheduled-or-executed') if
(us4_firewalld_cleanup_services | length > 0 or us4_firewalld_cleanup_ports | length > 0)
else 'not-required' }}
+62
View File
@@ -0,0 +1,62 @@
---
# This owns the non-secret Compose declaration only. WireGuard key material
# remains under the server-local /opt/wireguard/config bind mount.
- name: Apply the reviewed WireGuard Compose hardening
hosts: wireguard
become: true
gather_facts: false
serial: 1
vars:
wireguard_harden_confirm: false
wireguard_compose_candidate: "{{ compose_project_dir }}/compose.yml.candidate"
tasks:
- name: Require explicit WireGuard hardening confirmation
ansible.builtin.assert:
that:
- wireguard_harden_confirm | bool
fail_msg: >-
This operation replaces the non-secret Compose declaration. Run only
during the approved maintenance window after a rollback archive exists.
- name: Verify that a protected pre-change archive exists
ansible.builtin.find:
paths: "{{ compose_project_dir }}/backups"
patterns: 'pre-hardening-*.tar.gz'
file_type: file
register: wireguard_pre_hardening_archives
- name: Require a pre-change rollback archive
ansible.builtin.assert:
that:
- wireguard_pre_hardening_archives.matched | int > 0
fail_msg: >-
Create a protected pre-hardening archive before replacing compose.yml.
- name: Render the candidate non-secret Compose declaration
ansible.builtin.template:
src: ../templates/wireguard-compose.yml.j2
dest: "{{ wireguard_compose_candidate }}"
owner: root
group: root
mode: "0644"
- name: Validate the candidate Compose declaration
ansible.builtin.command:
argv:
- docker
- compose
- --project-directory
- "{{ compose_project_dir }}"
- --file
- "{{ wireguard_compose_candidate }}"
- config
- --quiet
changed_when: false
- name: Atomically activate the validated Compose declaration
ansible.builtin.command:
argv:
- mv
- "{{ wireguard_compose_candidate }}"
- "{{ compose_project_dir }}/compose.yml"
changed_when: true
+114
View File
@@ -0,0 +1,114 @@
---
# Deliberately narrow maintenance path for the us4 WireGuard service. The
# Compose file and all private key material remain on the server; this playbook
# only pulls its declared immutable image and recreates the named service.
- name: Update and recreate the approved WireGuard service
hosts: wireguard
become: true
gather_facts: false
serial: 1
vars:
wireguard_maintenance_confirm: false
tasks:
- name: Require explicit WireGuard maintenance confirmation
ansible.builtin.assert:
that:
- wireguard_maintenance_confirm | bool
fail_msg: >-
This operation pulls the image declared in /opt/wireguard/compose.yml
and briefly recreates WireGuard. Set wireguard_maintenance_confirm=true
only during the approved maintenance window.
- name: Create the local WireGuard rollback directory
ansible.builtin.file:
path: "{{ compose_project_dir }}/backups"
state: directory
owner: root
group: root
mode: "0700"
- name: Create a timestamp for the rollback archive
ansible.builtin.command:
argv: [date, +%Y%m%dT%H%M%SZ]
changed_when: false
register: wireguard_backup_timestamp
- name: Create a protected local rollback archive of WireGuard configuration
ansible.builtin.shell:
cmd: >-
umask 077 && tar --create --gzip
--file={{ compose_project_dir }}/backups/config-{{ wireguard_backup_timestamp.stdout }}.tar.gz
--directory={{ compose_project_dir }} config
executable: /bin/bash
changed_when: true
- name: Verify the compose declaration uses an immutable image digest
ansible.builtin.command:
argv:
- docker
- compose
- --project-directory
- "{{ compose_project_dir }}"
- config
changed_when: false
register: wireguard_compose_config
- name: Reject mutable WireGuard image declarations
ansible.builtin.assert:
that:
- "'@sha256:' in wireguard_compose_config.stdout"
fail_msg: >-
Pin the WireGuard image to an approved immutable digest before using
this maintenance playbook.
- name: Pull the declared WireGuard image
ansible.builtin.command:
argv:
- docker
- compose
- --project-directory
- "{{ compose_project_dir }}"
- pull
- wireguard
changed_when: true
- name: Recreate the WireGuard container
ansible.builtin.command:
argv:
- docker
- compose
- --project-directory
- "{{ compose_project_dir }}"
- up
- -d
- --no-deps
- --force-recreate
- wireguard
changed_when: true
- name: Verify WireGuard container state
ansible.builtin.command:
argv:
- docker
- compose
- --project-directory
- "{{ compose_project_dir }}"
- ps
- --all
changed_when: false
register: wireguard_compose_ps
- name: Display WireGuard container state
ansible.builtin.debug:
var: wireguard_compose_ps.stdout_lines
- name: Run the deployed WireGuard health check
ansible.builtin.command:
argv: [/usr/local/lib/vps-health/run]
changed_when: false
register: wireguard_health_report
failed_when: wireguard_health_report.rc not in [0, 1]
- name: Display sanitized WireGuard health result
ansible.builtin.debug:
msg: "{{ wireguard_health_report.stdout | default(wireguard_health_report.stderr, true) }}"
+5
View File
@@ -0,0 +1,5 @@
---
collections:
# us4-firewalld.yml was source-reviewed and exercised with this version.
- name: ansible.posix
version: 2.2.2
@@ -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
+7 -1
View File
@@ -7,7 +7,13 @@ healthcheck_timer_on_calendar: '*-*-* 06:15:00'
healthcheck_timer_randomized_delay_sec: 15m
healthcheck_backup_max_age_hours: 30
healthcheck_tls_warn_days: 21
healthcheck_profiles:
# Map of profile name -> installed script filename. A host selects which
# profiles it runs via the `healthcheck_profiles` list (inventory).
healthcheck_profile_scripts:
mailcow: mailcow.sh
vaultwarden: vaultwarden.sh
pdns: pdns.sh
wireguard: wireguard.sh
adguardhome: adguardhome.sh
rustdesk: rustdesk.sh
hk2aux: hk2aux.sh
+8 -6
View File
@@ -1,9 +1,10 @@
---
- name: Validate known health-check profile
- name: Validate known health-check profiles
ansible.builtin.assert:
that:
- healthcheck_profile in healthcheck_profiles
fail_msg: "Unsupported healthcheck_profile: {{ healthcheck_profile }}"
- item in healthcheck_profile_scripts
fail_msg: "Unsupported healthcheck_profile: {{ item }}"
loop: "{{ healthcheck_profiles }}"
- name: Install health-check directories
ansible.builtin.file:
@@ -28,13 +29,14 @@
group: root
mode: "0755"
- name: Install service health-check script
- name: Install service health-check scripts
ansible.builtin.template:
src: "{{ healthcheck_profiles[healthcheck_profile] }}.j2"
dest: "{{ healthcheck_install_root }}/{{ healthcheck_profiles[healthcheck_profile] }}"
src: "{{ healthcheck_profile_scripts[item] }}.j2"
dest: "{{ healthcheck_install_root }}/{{ healthcheck_profile_scripts[item] }}"
owner: root
group: root
mode: "0755"
loop: "{{ healthcheck_profiles }}"
- name: Install health-check dispatcher
ansible.builtin.template:
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
set -uo pipefail
source '{{ healthcheck_install_root }}/health-common.sh'
require_command docker
require_command curl
require_command dig
require_command ss
require_command df
check_compose
container='adguardhome'
if ! docker inspect "$container" >/dev/null 2>&1; then
record critical 'adguardhome_container_missing'
else
running="$(docker inspect "$container" --format '{{ '{{' }}.State.Running{{ '}}' }}' 2>/dev/null)"
[[ "$running" == true ]] || record critical 'adguardhome_container_not_running'
fi
config_check="$(docker exec "$container" /opt/adguardhome/AdGuardHome --check-config \
-c /opt/adguardhome/conf/AdGuardHome.yaml 2>&1 || true)"
if grep -qiE 'error|invalid|fatal' <<<"$config_check"; then
record critical 'adguardhome_config_invalid'
else
record ok 'adguardhome_config_valid'
fi
if ss -H -ltn 2>/dev/null | awk '{print $4}' | grep -Eq '(^|:)53$'; then
record ok 'dns_tcp_53_listening'
else
record critical 'dns_tcp_53_missing'
fi
if ss -H -uln 2>/dev/null | awk '{print $4}' | grep -Eq '(^|:)53$'; then
record ok 'dns_udp_53_listening'
else
record critical 'dns_udp_53_missing'
fi
if ss -H -ltn 2>/dev/null | awk '{print $4}' | grep -Eq '(^|:)80$'; then
record ok 'ui_http_80_listening'
else
record critical 'ui_http_80_missing'
fi
dns_a="$(dig +time=5 +tries=1 @192.168.66.36 example.com A +noall +answer 2>/dev/null)"
[[ -n "$dns_a" ]] && record ok 'dns_example_a' || record critical 'dns_example_a_failed'
dns_aaaa="$(dig +time=5 +tries=1 @192.168.66.36 example.com AAAA +noall +answer 2>/dev/null)"
[[ -n "$dns_aaaa" ]] && record ok 'dns_example_aaaa' || record warning 'dns_example_aaaa_none'
dns_local="$(dig +time=5 +tries=1 @192.168.66.36 hass.windy.lan A +noall +answer 2>/dev/null)"
[[ -n "$dns_local" ]] && record ok 'dns_local_name' || record warning 'dns_local_name_unresolved'
ui_code="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' --max-time 10 http://192.168.66.36/ 2>/dev/null || true)"
case "$ui_code" in
200|301|302) record ok "ui_http_${ui_code}" ;;
'') record critical 'ui_unreachable' ;;
*) record critical "ui_http_${ui_code}" ;;
esac
usage="$(df -P /opt/adguardhome/work 2>/dev/null | awk 'NR==2 {print $5}' | tr -d '%')"
if [[ "$usage" =~ ^[0-9]+$ ]]; then
if (( usage >= 90 )); then record critical 'disk_usage_high'
elif (( usage >= 80 )); then record warning 'disk_usage_elevated'
else record ok 'disk_usage_ok'; fi
else
record unknown 'disk_usage_unknown'
fi
logs="$(docker logs --since 30m "$container" 2>&1 || true)"
if grep -qiE '\[(error|warn|fatal)\]|level=(error|warn|fatal)' <<<"$logs"; then
record warning 'adguardhome_log_errors'
else
record ok 'adguardhome_logs_clean'
fi
emit_result
exit "$EXIT_CODE"
@@ -7,7 +7,7 @@ set -uo pipefail
RESULT_DIR='{{ healthcheck_state_dir }}'
LOG_DIR='{{ healthcheck_log_dir }}'
HOST_NAME="$(hostname -f 2>/dev/null || hostname)"
CHECK_NAME='{{ healthcheck_profile }}'
CHECK_NAME="$(basename "$0" .sh)"
STATUS=ok
EXIT_CODE=0
DETAILS=()
@@ -31,9 +31,27 @@ compose_ps() {
}
check_compose() {
local output
output="$(compose_ps)" || { record critical 'compose_ps_failed'; return; }
if grep -qiE 'Exited|Restarting|[[:space:]]Dead[[:space:]]' <<<"$output"; then
local output services bad
# Only flag containers of *active* services (config --services excludes
# debug/profile-gated services such as vaultwarden's pgweb, which is
# intentionally stopped unless started with --profile debug).
services="$(docker compose --project-directory '{{ compose_project_dir }}' config --services 2>/dev/null)" || { record critical 'compose_ps_failed'; return; }
output="$(docker compose --project-directory '{{ compose_project_dir }}' ps --all --format json 2>&1)" || { record critical 'compose_ps_failed'; return; }
bad="$(printf '%s\n' "$output" | python3 -c '
import json, sys
services = set(sys.argv[1].split())
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
c = json.loads(line)
except Exception:
continue
if c.get("Service") in services and c.get("State") in ("exited", "restarting", "dead"):
print(c.get("Service"))
' "$services")"
if [[ -n "$bad" ]]; then
record critical 'compose_unhealthy_container'
else
record ok 'compose_ok'
@@ -77,8 +95,11 @@ check_tls_days() {
}
emit_result() {
local tmp detail_json
tmp="$(mktemp "${RESULT_DIR}/latest.json.XXXXXX")"
# Per-check JSON at latest-<check>.json. The dispatcher merges these into
# latest.json so multiple profiles on one host do not overwrite each other.
local tmp path detail_json
path="${RESULT_DIR}/latest-${CHECK_NAME}.json"
tmp="$(mktemp "${RESULT_DIR}/.latest-${CHECK_NAME}.XXXXXX")"
detail_json="$(printf '%s\n' "${DETAILS[@]:-unknown:no_details}" | python3 -c 'import json,sys; print(json.dumps([line.rstrip() for line in sys.stdin if line.strip()]))')"
python3 - "$tmp" "$HOST_NAME" "$CHECK_NAME" "$STATUS" "$EXIT_CODE" "$detail_json" <<'PY'
import json, sys
@@ -90,6 +111,75 @@ with open(path, 'w', encoding='utf-8') as f:
f.write('\n')
PY
chmod 0640 "$tmp"
mv "$tmp" "${RESULT_DIR}/latest.json"
mv "$tmp" "$path"
cat "$path"
}
aggregate_result() {
# Merge the just-run per-check files into latest.json. With a single complete
# check this is a verbatim copy, preserving the historical one-object shape.
# With several checks it emits one object whose status is the worst of all
# checks; each check's own status/details are retained under `checks`. An
# expected check with no fresh result file (profile crashed before writing)
# is aggregated as `unknown`, so latest.json can never go stale while the
# dispatcher reports a failure.
case "$#" in
0) return 0 ;;
1) if [[ -f "${RESULT_DIR}/latest-$1.json" ]]; then
cp -f "${RESULT_DIR}/latest-$1.json" "${RESULT_DIR}/latest.json"
else
python3 - "$RESULT_DIR" "$HOST_NAME" "$@" <<'PY'
import json, os, sys
rdir, host = sys.argv[1], sys.argv[2]
checks = sys.argv[3:]
levels = {'ok': 0, 'warning': 1, 'unknown': 2, 'critical': 3}
worst, worst_code = 'ok', 0
items = []
for c in checks:
p = os.path.join(rdir, 'latest-%s.json' % c)
if os.path.exists(p):
d = json.load(open(p))
st, code = d['status'], d['exit_code']
items.append({'check': d['check'], 'status': st,
'exit_code': code, 'details': d['details']})
else:
st, code = 'unknown', 3
items.append({'check': c, 'status': st, 'exit_code': code,
'details': ['unknown:check_did_not_complete']})
if levels[st] > levels[worst]:
worst, worst_code = st, code
out = {'schema': 1, 'host': host, 'check': 'aggregate', 'status': worst,
'exit_code': worst_code, 'checks': items}
open(os.path.join(rdir, 'latest.json'), 'w').write(
json.dumps(out, sort_keys=True, separators=(',', ':')) + '\n')
PY
fi ;;
*) python3 - "$RESULT_DIR" "$HOST_NAME" "$@" <<'PY'
import json, os, sys
rdir, host = sys.argv[1], sys.argv[2]
checks = sys.argv[3:]
levels = {'ok': 0, 'warning': 1, 'unknown': 2, 'critical': 3}
worst, worst_code = 'ok', 0
items = []
for c in checks:
p = os.path.join(rdir, 'latest-%s.json' % c)
if os.path.exists(p):
d = json.load(open(p))
st, code = d['status'], d['exit_code']
items.append({'check': d['check'], 'status': st,
'exit_code': code, 'details': d['details']})
else:
st, code = 'unknown', 3
items.append({'check': c, 'status': st, 'exit_code': code,
'details': ['unknown:check_did_not_complete']})
if levels[st] > levels[worst]:
worst, worst_code = st, code
out = {'schema': 1, 'host': host, 'check': 'aggregate', 'status': worst,
'exit_code': worst_code, 'checks': items}
open(os.path.join(rdir, 'latest.json'), 'w').write(
json.dumps(out, sort_keys=True, separators=(',', ':')) + '\n')
PY
esac
chmod 0640 "${RESULT_DIR}/latest.json"
cat "${RESULT_DIR}/latest.json"
}
@@ -1,3 +1,24 @@
#!/usr/bin/env bash
set -uo pipefail
exec '{{ healthcheck_install_root }}/{{ healthcheck_profiles[healthcheck_profile] }}' >> '{{ healthcheck_log_dir }}/healthcheck.log' 2>&1
set -o pipefail
source '{{ healthcheck_install_root }}/health-common.sh'
# Run every enabled health-check profile, exit with the worst (max) code, and
# merge the per-check results into /var/lib/vps-health/latest.json.
rc=0
# Drop per-check results from any prior run so a profile that crashes before
# reporting cannot leak a stale healthy result into the aggregate.
{% for profile in healthcheck_profiles %}
rm -f '{{ healthcheck_state_dir }}/latest-{{ healthcheck_profile_scripts[profile] | replace('.sh', '') }}.json'
{% endfor %}
{% for profile in healthcheck_profiles %}
'{{ healthcheck_install_root }}/{{ healthcheck_profile_scripts[profile] }}' 2>&1 | tee -a '{{ healthcheck_log_dir }}/healthcheck.log'
this_rc="${PIPESTATUS[0]}"
[ "$this_rc" -gt "$rc" ] && rc="$this_rc"
{% endfor %}
# Collect profile check names line-by-line (robust against Jinja trim_blocks
# whitespace control, which would otherwise merge this into one line).
aggregate_args=""
{% for profile in healthcheck_profiles %}
aggregate_args="$aggregate_args {{ healthcheck_profile_scripts[profile] | replace('.sh', '') }}"
{% endfor %}
aggregate_result $aggregate_args
exit "$rc"
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -uo pipefail
source '{{ healthcheck_install_root }}/health-common.sh'
require_command docker
require_command ss
# Auxiliary services co-located on hk2.chans.xyz (separate compose projects
# under /opt, fronted by Traefik). Verified live 2026-08-12.
# traefik
if docker inspect traefik >/dev/null 2>&1; then
[[ "$(docker inspect traefik --format '{{ '{{' }}.State.Running{{ '}}' }}' 2>/dev/null)" == true ]] \
&& record ok 'traefik_running' || record critical 'traefik_not_running'
else
record critical 'traefik_container_missing'
fi
# adguardhome (hk2 variant: DoH 5443, DoT 853)
if docker inspect adguardhome >/dev/null 2>&1; then
[[ "$(docker inspect adguardhome --format '{{ '{{' }}.State.Running{{ '}}' }}' 2>/dev/null)" == true ]] \
&& record ok 'adguard_running' || record critical 'adguard_not_running'
else
record critical 'adguard_container_missing'
fi
# remark42
if docker inspect remark42 >/dev/null 2>&1; then
[[ "$(docker inspect remark42 --format '{{ '{{' }}.State.Running{{ '}}' }}' 2>/dev/null)" == true ]] \
&& record ok 'remark42_running' || record critical 'remark42_not_running'
else
record critical 'remark42_container_missing'
fi
# nginx-manager was removed 2026-08-12 (leftover config, never running).
# Warn if a container by that name ever reappears.
if docker inspect nginx-manager >/dev/null 2>&1; then
record warning 'nginx_manager_unexpectedly_running'
else
record ok 'nginx_manager_not_running'
fi
# Listening ports (Traefik 80/443/8080, AdGuard DoH 5443 / DoT 853).
ss -H -ltn 2>/dev/null | awk '{print $4}' | grep -Eq '(^|:)80$' \
&& record ok 'traefik_http_80' || record critical 'traefik_http_80_missing'
ss -H -ltn 2>/dev/null | awk '{print $4}' | grep -Eq '(^|:)443$' \
&& record ok 'traefik_https_443' || record critical 'traefik_https_443_missing'
ss -H -ltn 2>/dev/null | awk '{print $4}' | grep -Eq '(^|:)8080$' \
&& record ok 'traefik_dashboard_8080' || record warning 'traefik_dashboard_8080_missing'
ss -H -ltn 2>/dev/null | awk '{print $4}' | grep -Eq '(^|:)5443$' \
&& record ok 'adguard_doh_5443' || record critical 'adguard_doh_5443_missing'
ss -H -ltn 2>/dev/null | awk '{print $4}' | grep -Eq '(^|:)853$' \
&& record ok 'adguard_dot_853' || record critical 'adguard_dot_853_missing'
emit_result
exit "$EXIT_CODE"
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -uo pipefail
source '{{ healthcheck_install_root }}/health-common.sh'
require_command docker
require_command dig
# hbbs / hbbr must both be running (separate compose project at /opt/rustdesk).
output="$(docker compose --project-directory /opt/rustdesk ps --all 2>&1)"
if grep -qiE 'Exited|Restarting|[[:space:]]Dead[[:space:]]' <<<"$output"; then
record critical 'rustdesk_unhealthy_container'
else
record ok 'rustdesk_compose_ok'
fi
# hbbs must advertise the relay hostname that resolves to this host's public IP.
cmd="$(docker inspect hbbs --format '{{ '{{' }}json .Config.Cmd{{ '}}' }}' 2>/dev/null)" || record critical 'rustdesk_hbbs_missing'
grep -q 'hk2.chans.xyz:21117' <<<"$cmd" || record critical 'rustdesk_relay_misconfigured'
# The advertised relay hostname must resolve to this host's public IP.
resolved="$(dig +short hk2.chans.xyz A 2>/dev/null)"
grep -q '154.36.174.161' <<<"$resolved" || record critical 'rustdesk_relay_dns_missing'
emit_result
exit "$EXIT_CODE"
@@ -15,11 +15,12 @@ grep -Fq 'vw-db' <<<"$health" || record critical 'postgres_missing'
check_https 'https://auth.wsvc.info/' '^200$'
check_tls_days auth.wsvc.info 443
# Read effective config only inside the service and report booleans/fingerprints,
# never its SMTP password or other secret fields.
smtp_result="$(docker compose --project-directory '{{ compose_project_dir }}' exec -T vaultwarden python3 - <<'PY' 2>&1
# Read effective config from the mounted vw-data dir on the host and run the
# SMTP AUTH probe from the host (the vaultwarden image has no python3; the
# host does). Never print the SMTP password.
smtp_result="$(python3 - <<'PY' 2>&1
import json, pathlib, smtplib, ssl
cfg=json.loads(pathlib.Path('/data/config.json').read_text())
cfg=json.loads(pathlib.Path('{{ compose_project_dir }}/vw-data/config.json').read_text())
host=cfg.get('smtp_host'); port=int(cfg.get('smtp_port') or 0)
user=cfg.get('smtp_username')
smtp_secret=cfg.get('smtp_password')
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
set -uo pipefail
source '{{ healthcheck_install_root }}/health-common.sh'
require_command docker
require_command ss
check_compose
container='wireguard'
if ! docker inspect "$container" >/dev/null 2>&1; then
record critical 'wireguard_container_missing'
else
running="$(docker inspect "$container" --format '{{ '{{' }}.State.Running{{ '}}' }}' 2>/dev/null)"
[[ "$running" == true ]] || record critical 'wireguard_container_not_running'
interface="$(docker exec "$container" wg show interfaces 2>/dev/null || true)"
grep -qw 'wg0' <<<"$interface" || record critical 'wireguard_wg0_missing'
configured_peer_count="$(docker exec "$container" sh -c 'find /config -mindepth 1 -maxdepth 1 -type d -name "peer_*" | wc -l' 2>/dev/null || true)"
runtime_peer_count="$(docker exec "$container" wg show wg0 dump 2>/dev/null | awk 'NR > 1 { count++ } END { print count + 0 }')"
[[ "$configured_peer_count" =~ ^[0-9]+$ ]] || record unknown 'wireguard_configured_peer_count_unknown'
[[ "$runtime_peer_count" =~ ^[0-9]+$ ]] || record unknown 'wireguard_runtime_peer_count_unknown'
if [[ "$configured_peer_count" =~ ^[0-9]+$ && "$runtime_peer_count" =~ ^[0-9]+$ ]]; then
[[ "$configured_peer_count" -eq "$runtime_peer_count" ]] || record warning "wireguard_peer_count_mismatch_${configured_peer_count}_${runtime_peer_count}"
fi
fi
if ss -H -uln 2>/dev/null | awk '{print $4}' | grep -Eq '(^|:)51820$'; then
record ok 'wireguard_udp_51820_listening'
else
record critical 'wireguard_udp_51820_missing'
fi
emit_result
exit "$EXIT_CODE"
+3
View File
@@ -1,5 +1,8 @@
---
restic_enabled: false
# Approved Restic source profile (a key of restic_sources) for the host. Set
# per-host in inventory; the role fails if it is not an approved source.
restic_backup_profile: ""
restic_binary: /usr/bin/restic
restic_config_path: /etc/vps-restic/repository.env
restic_state_dir: /var/lib/vps-restic
+2 -2
View File
@@ -10,8 +10,8 @@
- name: Validate supported Restic source profile
ansible.builtin.assert:
that:
- healthcheck_profile in restic_sources
fail_msg: "No approved Restic source profile for {{ healthcheck_profile }}."
- restic_backup_profile in restic_sources
fail_msg: "No approved Restic source profile for {{ restic_backup_profile }}."
- name: Verify Restic binary exists on target
ansible.builtin.stat:
+1 -1
View File
@@ -3,4 +3,4 @@ set -euo pipefail
# Repository and password credentials are host-local in {{ restic_config_path }}.
# shellcheck source=/dev/null
source '{{ restic_config_path }}'
exec '{{ restic_binary }}' backup --tag '{{ healthcheck_profile }}' --tag "$(hostname -s)" {% for source in restic_sources[healthcheck_profile] %}{{ source | quote }} {% endfor %}
exec '{{ restic_binary }}' backup --tag '{{ restic_backup_profile }}' --tag "$(hostname -s)" {% for source in restic_sources[restic_backup_profile] %}{{ source | quote }} {% endfor %}
@@ -2,4 +2,4 @@
set -euo pipefail
# shellcheck source=/dev/null
source '{{ restic_config_path }}'
exec '{{ restic_binary }}' forget --prune --keep-daily {{ restic_keep_daily }} --keep-weekly {{ restic_keep_weekly }} --keep-monthly {{ restic_keep_monthly }} --tag '{{ healthcheck_profile }}'
exec '{{ restic_binary }}' forget --prune --keep-daily {{ restic_keep_daily }} --keep-weekly {{ restic_keep_weekly }} --keep-monthly {{ restic_keep_monthly }} --tag '{{ restic_backup_profile }}'
@@ -1,5 +1,5 @@
[Unit]
Description=Restic backup for approved {{ healthcheck_profile }} sources
Description=Restic backup for approved {{ restic_backup_profile }} sources
After=network-online.target
Wants=network-online.target
+13
View File
@@ -0,0 +1,13 @@
---
# RustDesk server deployment (hbbs + hbbr) on hk2.
# Safe by default: without rustdesk_confirm=true the role only reports whether
# the declared compose file matches live state and refuses to recreate the stack.
rustdesk_confirm: false
# Compose project directory.
rustdesk_compose_dir: /opt/rustdesk
# Relay (hbbr) hostname:port advertised to every client via `hbbs -r`.
# MUST resolve to this host's public IP (154.36.174.161). The known-bad value
# 'hk2.wsvc.info' has no DNS record and must never be used.
rustdesk_relay: hk2.chans.xyz:21117
# Pinned server image (used for both hbbs and hbbr).
rustdesk_image: rustdesk/rustdesk-server:1.1.14
+85
View File
@@ -0,0 +1,85 @@
---
# Deploy/reconcile the self-hosted RustDesk server (hbbs + hbbr).
# Idempotent: deploys the declared compose file; only recreates the stack with
# explicit confirmation.
- name: Validate relay address is set and not the known-bad value
ansible.builtin.assert:
that:
- rustdesk_relay | length > 0
- "'hk2.wsvc.info' not in rustdesk_relay"
fail_msg: >-
rustdesk_relay must be a resolvable relay address. The known-bad
'hk2.wsvc.info' has no DNS record and must not be used.
- name: Ensure compose project directory exists
ansible.builtin.file:
path: "{{ rustdesk_compose_dir }}"
state: directory
owner: windy
group: root
mode: "0755"
- name: Deploy compose file
ansible.builtin.template:
src: compose.yml.j2
dest: "{{ rustdesk_compose_dir }}/compose.yml"
owner: windy
group: windy
mode: "0644"
register: rustdesk_compose_deployed
- name: Report no change needed
ansible.builtin.debug:
msg: "compose.yml already matches declared state; no change needed."
when: not rustdesk_compose_deployed.changed
- name: Refuse to recreate without explicit confirmation
ansible.builtin.fail:
msg: >-
compose.yml differs from declared state but rustdesk_confirm is not true.
Supply rustdesk_confirm=true to deploy the file and recreate the stack.
when:
- rustdesk_compose_deployed.changed
- not (rustdesk_confirm | bool)
- not ansible_check_mode
- name: Apply compose stack
ansible.builtin.command:
argv:
- docker
- compose
- --project-directory
- "{{ rustdesk_compose_dir }}"
- up
- -d
when:
- rustdesk_compose_deployed.changed
- rustdesk_confirm | bool
changed_when: true
register: rustdesk_apply
- name: Verify hbbs relay command
ansible.builtin.command:
argv:
- docker
- inspect
- hbbs
- --format
- '{{ "{{" }}json .Config.Cmd{{ "}}" }}'
register: rustdesk_hbbs_cmd
changed_when: false
when:
- rustdesk_compose_deployed.changed
- rustdesk_confirm | bool
- not ansible_check_mode
- name: Assert hbbs advertises the declared relay
ansible.builtin.assert:
that:
- "'{{ rustdesk_relay }}' in rustdesk_hbbs_cmd.stdout"
fail_msg: "hbbs is not advertising the declared relay {{ rustdesk_relay }}."
when:
- rustdesk_compose_deployed.changed
- rustdesk_confirm | bool
- not ansible_check_mode
@@ -0,0 +1,34 @@
networks:
rustdesk-net:
external: false
services:
hbbs:
container_name: hbbs
ports:
- 21115:21115
- 21116:21116
- 21116:21116/udp
- 21118:21118
image: {{ rustdesk_image }}
command: "hbbs -r {{ rustdesk_relay }}"
volumes:
- ./hbbs:/root
networks:
- rustdesk-net
depends_on:
- hbbr
restart: unless-stopped
hbbr:
container_name: hbbr
ports:
- 21117:21117
- 21119:21119
image: {{ rustdesk_image }}
command: hbbr
volumes:
- ./hbbr:/root
networks:
- rustdesk-net
restart: unless-stopped
+11
View File
@@ -0,0 +1,11 @@
---
# Reconcile super_sdn.sso_login_enabled on the UniFi Network Controller DB.
# Safe by default: without unifi_sso_confirm=true the role only reports the
# current state and refuses to change anything.
unifi_sso_confirm: false
# Declared desired state (true = Sync Local Admin with Ubiquiti SSO ON).
unifi_sso_target_value: false
# Restart the controller container after applying a change. Only needed when
# the setting was changed directly in the DB while the controller was running
# (the UI path applies it immediately without a restart).
unifi_sso_restart_controller: false
+110
View File
@@ -0,0 +1,110 @@
---
# Reconcile super_sdn.sso_login_enabled on the UniFi controller DB.
# Idempotent: reads the current value, reports compliance when it already
# matches the target, and only mutates with explicit confirmation.
- name: Require a boolean target value
ansible.builtin.assert:
that:
- unifi_sso_target_value | type_debug == 'bool'
fail_msg: "unifi_sso_target_value must be a boolean (true or false)."
- name: Read current SSO login setting
ansible.builtin.command:
argv:
- docker
- exec
- "{{ unifi_container }}"
- mongo
- --port
- "{{ unifi_mongo_port }}"
- --quiet
- ace
- --eval
- 'db.setting.findOne({key:"super_sdn"}).sso_login_enabled'
register: unifi_sso_current
changed_when: false
failed_when: unifi_sso_current.rc != 0
check_mode: false
- name: Report current setting
ansible.builtin.debug:
msg: >-
sso_login_enabled={{ unifi_sso_current.stdout | trim }}
(target={{ unifi_sso_target_value | string | lower }})
- name: Report already compliant
ansible.builtin.debug:
msg: "Already compliant; no change needed."
when: unifi_sso_current.stdout | trim | lower == unifi_sso_target_value | string | lower
- name: Refuse to change without explicit confirmation
ansible.builtin.fail:
msg: >-
Change required (sso_login_enabled={{ unifi_sso_current.stdout | trim }}
!= target {{ unifi_sso_target_value | string | lower }}) but
unifi_sso_confirm is not true. Supply unifi_sso_confirm=true to apply.
when:
- unifi_sso_current.stdout | trim | lower != unifi_sso_target_value | string | lower
- not (unifi_sso_confirm | bool)
- name: Apply SSO login setting change
ansible.builtin.command:
argv:
- docker
- exec
- "{{ unifi_container }}"
- mongo
- --port
- "{{ unifi_mongo_port }}"
- --quiet
- ace
- --eval
- >-
db.setting.updateOne({key:"super_sdn"},
{$set:{sso_login_enabled: {{ unifi_sso_target_value | string | lower }}}})
when:
- unifi_sso_confirm | bool
- unifi_sso_current.stdout | trim | lower != unifi_sso_target_value | string | lower
changed_when: true
register: unifi_sso_update
- name: Restart controller to apply setting when requested
ansible.builtin.command:
argv: [docker, restart, "{{ unifi_container }}"]
when:
- unifi_sso_restart_controller | bool
- unifi_sso_current.stdout | trim | lower != unifi_sso_target_value | string | lower
changed_when: true
- name: Verify setting after change
ansible.builtin.command:
argv:
- docker
- exec
- "{{ unifi_container }}"
- mongo
- --port
- "{{ unifi_mongo_port }}"
- --quiet
- ace
- --eval
- 'db.setting({key:"super_sdn"}).sso_login_enabled'
register: unifi_sso_verify
changed_when: false
check_mode: false
when:
- unifi_sso_current.stdout | trim | lower != unifi_sso_target_value | string | lower
- not ansible_check_mode
- name: Assert verified value matches target
ansible.builtin.assert:
that:
- unifi_sso_verify.stdout | trim | lower == unifi_sso_target_value | string | lower
fail_msg: >-
Verification failed: sso_login_enabled is
{{ unifi_sso_verify.stdout | trim }} but expected
{{ unifi_sso_target_value | string | lower }}.
when:
- unifi_sso_current.stdout | trim | lower != unifi_sso_target_value | string | lower
- not ansible_check_mode
@@ -0,0 +1,24 @@
services:
wireguard:
image: {{ wireguard_image }}
container_name: wireguard
cap_add:
- NET_ADMIN
environment:
- PUID=1000
- PGID=1000
- TZ=Asia/Shanghai
- SERVERURL=us4.wsvc.info
- SERVERPORT=51820
- PEERS=ha,phone,mbp
- PEERDNS=1.1.1.1
# Intentional: client IPv6 traffic is not tunneled.
- ALLOWEDIPS=0.0.0.0/0
ports:
- 51820:51820/udp
volumes:
- ./config:/config
read_only: true
tmpfs:
- /run:exec
restart: unless-stopped
+51
View File
@@ -0,0 +1,51 @@
# 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` |
| `pgdb` | pgdb (`/opt/database`, 无 ansible) | `compose/pgdb/compose.yml` | static file(手动部署:scp → `docker compose config -q``up -d`;服务器文件名 `docker-compose.yml` |
| `soft-serve` | us2 (`/opt/soft-serve`, 已退役停用) | `compose/soft-serve/compose.yml` (+ `Dockerfile.backup`, `scripts/`) | static file(参考镜像; 2026-09-18 被 gitea 替换 VPS-94, 数据保留作回滚) |
| `gitea` | us2 (`/opt/gitea`) | `compose/gitea/compose.yml` (+ `Dockerfile.backup`, `scripts/`) | static file(参考镜像, 未接入 compose-deploy; 服务器文件为准; 2026-09-18 替换 soft-serve, VPS-94 |
| `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.
+4
View File
@@ -0,0 +1,4 @@
# compose/gitea — 秘密一律走服务器本地 .env, 不入库
# 迁移期一次性: Gitea 管理员生成的 token (mirror-migrate.sh 读取, 用后撤销)
GITEA_MIGRATE_USER=
GITEA_MIGRATE_TOKEN=
+3
View File
@@ -0,0 +1,3 @@
FROM alpine:3.20
RUN apk add --no-cache sqlite rsync tzdata
WORKDIR /scripts
+59
View File
@@ -0,0 +1,59 @@
# Gitea on us2 — reference compose (Plane VPS-94, 迁移完成 2026-09-18)
# 参考镜像, 服务器 /opt/gitea 文件为准 (同 soft-serve 约定, 未接入 compose-deploy)
# rootless 镜像: uid 1000 原生非 root; 数据 /var/lib/gitea (宿主 ./data), 配置 /etc/gitea (宿主 ./config)
# SSH: 容器内监听 2322 (非特权, SSH_LISTEN_PORT), 对外 repo.windy.me:2222 经 Traefik TCP entrypoint `ssh`
services:
gitea:
image: gitea/gitea@sha256:1c17ecaead42eb3b5391553d8708103a4beb0e86edf5b9ebc1eb269c318845f2 # 1.27.3-rootless
container_name: gitea
restart: unless-stopped
user: "1000:1000"
environment:
TZ: Asia/Shanghai
volumes:
- ./data:/var/lib/gitea
- ./config:/etc/gitea
- ./secrets:/secrets:ro # 复用的 soft-serve host key (SSH_SERVER_HOST_KEYS)
networks:
- traefik
labels:
- traefik.enable=true
# Web UI: repo.windy.me (2026-09-18 操作者决定复用现有域名, 免 DNS 变更)
- traefik.http.routers.gitea-web.rule=Host(`repo.windy.me`)
- traefik.http.routers.gitea-web.entrypoints=websecure
- traefik.http.routers.gitea-web.tls.certresolver=letsencrypt
- traefik.http.services.gitea-web.loadbalancer.server.port=3000
# SSH: 接管 :2222 (entrypoint 已存在, router 动态生效, 无需重启 Traefik)
- traefik.tcp.routers.gitea-ssh.entrypoints=ssh
- traefik.tcp.routers.gitea-ssh.rule=HostSNI(`*`)
- traefik.tcp.routers.gitea-ssh.tls=false
- traefik.tcp.services.gitea-ssh.loadbalancer.server.port=2322
gitea-backup:
build:
context: .
dockerfile: Dockerfile.backup
container_name: gitea-backup
restart: unless-stopped
volumes:
- ./data:/data:ro
- ./config:/config:ro
- ./backups:/backup
- ./scripts:/scripts
environment:
TZ: Asia/Shanghai
BACKUP_UID: 1000
BACKUP_GID: 1000
entrypoint: >
/bin/sh -ec "
umask 077 &&
touch /backup/backup.log &&
crontab /scripts/crontab.txt &&
echo '[INFO] gitea backup cron installed' &&
crond -f -l 8
"
networks:
traefik:
external: true
name: vw-net
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
set -eu
umask 077
D() { date "+%Y-%m-%d %H:%M:%S"; }
TS=$(date +%Y%m%d_%H%M%S)
OUT="/backup/gitea_${TS}"
mkdir -p "$OUT"
echo "[$(D)] Starting gitea backup -> $OUT"
# rootless 布局: app.ini=/etc/gitea(宿主 ./config), db+repos=/var/lib/gitea/data(宿主 ./data/data)
# app.ini 含 SECRET_KEY/INTERNAL_TOKEN — 恢复 2FA/session/mirror 凭据必需
tar czf "$OUT/app.ini.tar.gz" -C /config app.ini
sqlite3 /data/data/gitea.db ".backup '$OUT/gitea.db'"
rsync -a /data/data/git/repositories/ "$OUT/repos/"
tar czf "$OUT/repos.tar.gz" -C "$OUT" repos
rm -rf "$OUT/repos"
chmod 600 "$OUT"/*
if [ -n "${BACKUP_UID:-}" ] && [ -n "${BACKUP_GID:-}" ]; then
chown -R "$BACKUP_UID:$BACKUP_GID" "$OUT" /backup/backup.log
fi
echo "[$(D)] Backup OK: $(du -sh "$OUT" | cut -f1)"
+4
View File
@@ -0,0 +1,4 @@
# Run gitea backup daily at 02:00
0 2 * * * /bin/sh /scripts/backup.sh >> /backup/backup.log 2>&1
# Prune backups older than 14 days daily at 03:00
0 3 * * * /bin/sh /scripts/prune.sh >> /backup/backup.log 2>&1
+41
View File
@@ -0,0 +1,41 @@
#!/bin/sh
# 一次性迁移辅助 (Plane VPS-94 Phase 2): 在 gitea 容器内执行。
# 已于 2026-09-18 执行完成 (16 仓), 留档备查; 复用时按 VPS-94 流程重生成一次性 token。
# 用法:
# GITEA_MIGRATE_USER=<user> GITEA_MIGRATE_TOKEN=<token> \
# docker exec -e GITEA_MIGRATE_USER -e GITEA_MIGRATE_TOKEN gitea \
# /scripts/mirror-migrate.sh [public_repo ...]
# 每仓: API 建仓 (默认 private, 参数中列出的为 public) -> push --mirror。
# default_branch 按源仓 symbolic-ref HEAD 设置, 避免非 main 源仓在 Gitea 显示为空。
# 结束后按 VPS-94 Phase 3 逐仓核对 git ls-remote ref 全集。
set -eu
MUSER="${GITEA_MIGRATE_USER:?need GITEA_MIGRATE_USER}"
TOKEN="${GITEA_MIGRATE_TOKEN:?need GITEA_MIGRATE_TOKEN}"
SRC="/migration-src"
API="http://localhost:3000/api/v1"
PUBLIC_REPOS=" $* "
migrate_one() {
dir="$1"
git -C "$dir" rev-parse --git-dir >/dev/null 2>&1 || { echo "[SKIP] $dir (not a git repo)"; return 0; }
name=$(basename "$dir"); name=${name%.git}
def_branch=$(git -C "$dir" symbolic-ref --short HEAD)
case "$PUBLIC_REPOS" in *" $name "*) private=false ;; *) private=true ;; esac
echo "[MIGRATE] $name (default=$def_branch private=$private)"
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$API/user/repos" \
-H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d "{\"name\":\"$name\",\"private\":$private,\"default_branch\":\"$def_branch\",\"auto_init\":false}")
case "$code" in
201) : ;;
409) echo " [WARN] $name 已存在, 直接补推" ;;
*) echo " [FAIL] create HTTP $code"; return 1 ;;
esac
git -C "$dir" push --mirror "http://$MUSER:$TOKEN@localhost:3000/$MUSER/$name.git"
echo " [OK] $name pushed"
}
for dir in "$SRC"/*.git "$SRC"/cdia; do
[ -d "$dir" ] || continue
migrate_one "$dir"
done
echo "[DONE] 全部处理完毕; 迁移后记得撤销一次性 token"
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
set -eu
D() { date "+%Y-%m-%d %H:%M:%S"; }
ls -dt /backup/gitea_* 2>/dev/null | tail -n +15 | xargs -r rm -rf
echo "[$(D)] Pruned. Kept $(ls -d /backup/gitea_* 2>/dev/null | wc -l) backups (max 14)"
+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: {}
+5
View File
@@ -0,0 +1,5 @@
# pgdb compose secrets — copy to /opt/database/.env on the host, chmod 600.
# NEVER commit the real values. Generate: openssl rand -hex 24
POSTGRES_PASSWORD=change-me-strong-hex
PGWEB_AUTH_USER=pgweb
PGWEB_AUTH_PASS=change-me-strong-hex
+65
View File
@@ -0,0 +1,65 @@
# pgdb (192.168.55.15) — TimescaleDB + pgweb GUI + nightly backup
#
# Deploy: copy this file to /opt/database/docker-compose.yml on pgdb,
# create /opt/database/.env (chmod 600) from .env.example, plus
# /opt/database/pgweb-bookmarks/{hass,scribe}.toml (chmod 600, contains DB password).
# Then: docker compose config --quiet && docker compose up -d
#
# Rollback: previous launch command is kept at /opt/database/run
# (container is stateless; data lives on /srv/pgdata).
services:
timescaledb:
image: timescale/timescaledb:latest-pg18
container_name: timescaledb
restart: unless-stopped
ports:
- "192.168.55.15:5432:5432" # bind VM IP only (no IPv6 wildcard)
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- /srv/pgdata:/var/lib/postgresql # data disk (ext4 /dev/sdb1)
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 30s
timeout: 5s
retries: 5
start_period: 10s
pgweb:
image: sosedoff/pgweb:latest
container_name: pgweb
restart: unless-stopped
# bind/listen/readonly/sessions/bookmarks-only/bookmarks-dir are CLI flags (no env equivalent in v0.17.0)
command: ["pgweb", "--bind", "0.0.0.0", "--listen", "8081", "--readonly", "--sessions", "--bookmarks-only", "--bookmarks-dir", "/bookmarks"]
ports:
- "192.168.55.15:8081:8081" # LAN only + basic auth (see .env)
environment:
PGWEB_AUTH_USER: ${PGWEB_AUTH_USER}
PGWEB_AUTH_PASS: ${PGWEB_AUTH_PASS}
PGWEB_BOOKMARKS_DIR: /bookmarks
volumes:
- ./pgweb-bookmarks:/bookmarks:ro # bookmark .toml files (contain DB password, keep 0600)
depends_on:
timescaledb:
condition: service_healthy
pg-backup:
image: prodrigestivill/postgres-backup-local:latest # latest = postgres 18 base (pg_dump 18.x)
container_name: pg-backup
restart: unless-stopped
environment:
POSTGRES_HOST: timescaledb
POSTGRES_DB: "hass scribe postgres"
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_EXTRA_OPTS: "-Fc" # custom-format dumps (pg_restore)
SCHEDULE: "0 2 * * *" # nightly 02:00 (TZ=Asia/Shanghai -> local 02:00)
BACKUP_ON_START: "TRUE" # immediate backup on first start
BACKUP_SUFFIX: ".dump"
HEALTHCHECK_PORT: "80" # go-cron health endpoint for the image healthcheck
TZ: "Asia/Shanghai" # match original host-cron 02:00 local (container default is UTC)
volumes:
- /opt/database/backups:/backups # POSIX fs required; root disk, separate from data disk
depends_on:
timescaledb:
condition: service_healthy
+24
View File
@@ -0,0 +1,24 @@
[Unit]
Description=Reconcile pgdb compose stack (timescaledb + pgweb + pg-backup) at boot
Documentation=file:///opt/database/docker-compose.yml
After=network-online.target docker.service
Wants=network-online.target
Requires=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/database
# Idempotent boot-time reconcile. docker's own restore can fail to bind the
# published ports (192.168.55.15:5432/8081) when the VM IP is not yet usable
# right after boot (EADDRNOTAVAIL, observed 2026-08-30): timescaledb/pgweb
# then stay stopped until a manual `docker compose up`. This unit retries
# `docker compose up -d` (a no-op when the stack is healthy) until the port
# listens, and force-recreates as a last resort to recover a network-detached
# container. Data lives on bind mounts (/srv/pgdata, /opt/database/backups),
# so recreation is safe.
ExecStart=/bin/bash -c 'for i in $(seq 1 12); do docker compose up -d --remove-orphans; sleep 2; if ss -tln | grep -q "192.168.55.15:5432"; then exit 0; fi; sleep 3; done; echo "pgdb-compose: retries exhausted, force-recreating"; docker compose up -d --force-recreate; sleep 10; ss -tln | grep -q "192.168.55.15:5432"'
TimeoutStartSec=180
[Install]
WantedBy=multi-user.target
+2
View File
@@ -0,0 +1,2 @@
# Soft Serve initial admin public key (used only on first boot)
SOFT_SERVE_INITIAL_ADMIN_KEYS=ssh-ed25519 AAAA... # replace with admin public key
+3
View File
@@ -0,0 +1,3 @@
FROM alpine:3.20
RUN apk add --no-cache sqlite tzdata
WORKDIR /scripts
+59
View File
@@ -0,0 +1,59 @@
services:
soft-serve:
image: charmcli/soft-serve:v0.12.2
container_name: soft-serve
restart: unless-stopped
# non-root (uid 1000 = windy; 与 backup sidecar BACKUP_UID 一致)
user: "1000:1000"
environment:
SOFT_SERVE_DATA_PATH: /var/lib/soft-serve
SOFT_SERVE_INITIAL_ADMIN: windy
SOFT_SERVE_INITIAL_ADMIN_KEYS: ${SOFT_SERVE_INITIAL_ADMIN_KEYS}
volumes:
- ./data:/var/lib/soft-serve
- soft-serve-app:/soft-serve
networks:
- traefik
labels:
- traefik.enable=true
# SSH over TCP via Traefik (entryPoint ssh -> container port 23231)
- traefik.tcp.routers.softserve-ssh.entrypoints=ssh
- traefik.tcp.routers.softserve-ssh.rule=HostSNI(`*`)
- traefik.tcp.routers.softserve-ssh.tls=false
- traefik.tcp.services.softserve-ssh.loadbalancer.server.port=23231
soft-serve-backup:
build:
context: .
dockerfile: Dockerfile.backup
container_name: soft-serve-backup
restart: unless-stopped
volumes:
- ./data:/data:ro
- ./backups:/backup
- ./scripts:/scripts
environment:
TZ: Asia/Shanghai
BACKUP_UID: 1000
BACKUP_GID: 1000
entrypoint: >
/bin/sh -ec "
umask 077 &&
touch /backup/backup.log &&
crontab /scripts/crontab.txt &&
echo '[INFO] soft-serve backup cron installed' &&
crond -f -l 8
"
volumes:
soft-serve-app:
networks:
traefik:
external: true
name: vw-net
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
set -eu
umask 077
D() { date "+%Y-%m-%d %H:%M:%S"; }
TS=$(date +%Y%m%d_%H%M%S)
OUT="/backup/soft-serve_${TS}"
mkdir -p "$OUT"
echo "[$(D)] Starting soft-serve backup -> $OUT"
tar czf "$OUT/repos-config.tar.gz" -C /data repos hooks config.yaml ssh
sqlite3 /data/soft-serve.db ".backup '$OUT/soft-serve.db'"
chmod 600 "$OUT/repos-config.tar.gz" "$OUT/soft-serve.db"
if [ -n "${BACKUP_UID:-}" ] && [ -n "${BACKUP_GID:-}" ]; then
chown -R "$BACKUP_UID:$BACKUP_GID" "$OUT" /backup/backup.log
fi
echo "[$(D)] Backup OK: $(du -sh "$OUT" | cut -f1)"
+4
View File
@@ -0,0 +1,4 @@
# Run soft-serve backup daily at 02:00
0 2 * * * /bin/sh /scripts/backup.sh >> /backup/backup.log 2>&1
# Prune backups older than 14 days daily at 03:00
0 3 * * * /bin/sh /scripts/prune.sh >> /backup/backup.log 2>&1
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
set -eu
D() { date "+%Y-%m-%d %H:%M:%S"; }
ls -dt /backup/soft-serve_* 2>/dev/null | tail -n +15 | xargs -r rm -rf
echo "[$(D)] Pruned. Kept $(ls -d /backup/soft-serve_* 2>/dev/null | wc -l) backups (max 14)"
+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.2
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: {}
@@ -0,0 +1,164 @@
# AdGuard Home official configuration review basis (2026-08)
This page is the **official source basis and recommendation baseline** used
during the on-site review of AdGuard Home (AGH) on `192.168.66.36`. It does not
claim to have read that host's configuration and contains no credentials.
On-site conclusions must be based on the live `AdGuardHome.yaml`, OpenWrt
`dnsmasq`/DHCP, firewall, routing, and actual client leases.
Official sources (AdGuard only):
- [Configuration](https://adguard-dns.io/kb/adguard-home/configuration/)
- [Setting up AdGuard Home securely](https://adguard-dns.io/kb/adguard-home/running-securely/)
- [Getting started](https://adguard-dns.io/kb/adguard-home/getting-started/)
- [DHCP](https://adguard-dns.io/kb/adguard-home/dhcp/)
- [DNS encryption](https://adguard-dns.io/kb/adguard-home/encryption/)
## Recommended role split (for the existing OpenWrt gateway)
Preferred: keep **OpenWrt/dnsmasq as the single DHCP and local-domain
authority, with AGH as the LAN DNS filter/forwarder**: DHCP option 6 hands
clients `.36`; AGH sets the router's local DNS (usually the gateway LAN IP) as
`local_ptr_upstreams` and, where needed, uses domain-specific upstreams to route
the LAN search domain / local names back to dnsmasq. Clients are filtered while
still keeping DHCP lease names, static hosts, local reverse resolution, and
gateway-internal service discovery.
Rationale: AGH supports dnsmasq-style domain-specific upstreams for internal
private name servers; private PTR should be configured in **Private reverse DNS
servers** / `local_ptr_upstreams` rather than regular upstreams; when not
explicitly configured, it falls back to the OS default resolver, which shifts as
host networking changes. [Official config: domain-specific upstreams](https://adguard-dns.io/kb/adguard-home/configuration/#configuring-upstreams);
[private rDNS](https://adguard-dns.io/kb/adguard-home/configuration/#private-addresses).
Do not have OpenWrt dnsmasq and AGH both listen on `:53` at the same LAN IP;
port conflicts, or pointing dnsmasq's upstream at AGH while AGH points back at
dnsmasq, cause failures/forwarding loops. When AGH runs on a separate `.36`
host, the simplest topology is clients → AGH `.36:53` → public/encrypted
upstreams, forwarding only local resolution (PTR, LAN domain) → gateway dnsmasq.
This is an operational inference based on the official capabilities above and
must be validated against the live `ss -lntup`, dnsmasq config, and query path.
AGH also has built-in DHCP, but it should only be enabled after **disabling the
existing gateway DHCP, explicitly migrating leases/reservations and verifying
options 3/6**. The official docs describe it as an alternative when the router
cannot hand out custom DNS, not as a dual-DHCP setup; the DHCP docs also state
leases/static leases are managed by AGH. [Getting started: router DHCP/DNS
handout and alternative DHCP](https://adguard-dns.io/kb/adguard-home/getting-started/#router);
[DHCP configuration](https://adguard-dns.io/kb/adguard-home/dhcp/).
## Exposure and access control
1. `dns.bind_hosts` should bind only loopback and the actual LAN address (here
prefer `.36`'s LAN address); do not use `0.0.0.0`/public interfaces for a
home recursive resolver. The official docs note full-interface binding
exposes DDoS risk; a small isolated network should pick local service
interfaces and may add loopback alongside. [Secure deployment: server
addresses](https://adguard-dns.io/kb/adguard-home/running-securely/#choosing-server-addresses);
[`bind_hosts` definition](https://adguard-dns.io/kb/adguard-home/configuration/#dns).
2. In the firewall, allow only managed LAN/VLAN → `.36` TCP/UDP 53; bind the web
admin UI to the management network/loopback and avoid WAN forwarding. At the
app layer, further restrict with `allowed_clients` to the local subnets; when
non-empty, only the CIDR/IP/ClientIDs in it are accepted, and it overrides
the effect of `disallowed_clients`. [Official access
control](https://adguard-dns.io/kb/adguard-home/configuration/#dns).
3. Keep `refuse_any: true` and a non-zero `ratelimit` (default 20 qps), unless
on-site evidence shows the service can never be reached from the internet and
LAN high-concurrency needs are data-backed; the official docs list both as
protection against reflection/DDoS. [Configuration
notes](https://adguard-dns.io/kb/adguard-home/configuration/#dns).
4. The web UI must have a user with login-failure lockout enabled;
`users: []` disables authentication, and `auth_attempts` or `block_auth_min`
of 0 disables the corresponding lockout logic. [Web/auth
parameters](https://adguard-dns.io/kb/adguard-home/configuration/#users).
## Upstreams, encryption, and DNSSEC
- Use two trusted, differently-networked DoH/DoT/DoQ upstreams for normal public
resolution; AGH supports these protocols and DNS Stamps. If an upstream
URL/hostname needs resolving, `bootstrap_dns` is its initial resolver; do not
point bootstrap at a name that requires AGH itself to resolve, and use
directly reachable, stable IPs. A `fallback_dns` can be set separately; it is
used only when primary upstreams do not respond. [Upstream protocols and
bootstrap/fallback](https://adguard-dns.io/kb/adguard-home/configuration/#dns).
- If local IPv6's actual egress path is unstable, do not enable
`bootstrap_prefer_ipv6`; the official docs note it makes DoH/DoQ/DoT bootstrap
prefer IPv6. Decide on-site based on each upstream's IPv4/IPv6 reachability,
latency, and failure rate. [Same reference](https://adguard-dns.io/kb/adguard-home/configuration/#dns).
- Consider enabling `enable_dnssec`, choosing recursive upstreams that actually
handle DNSSEC; but understand precisely: officially AGH sets the DO flag on
requests to upstreams; it does not perform recursive validation itself. On
site, query a known-bad-signature domain and observe AD/failure responses to
confirm upstream validation behavior. [DNSSEC parameter
definition](https://adguard-dns.io/kb/adguard-home/configuration/#dns).
- Prefer `upstream_mode: load_balance` unless measurement shows `parallel` first
answer latency is clearly worth the privacy/traffic cost of querying all
upstreams. The former picks by failure rate and average latency weighting; the
latter queries all upstreams concurrently; this is an operational judgment
based on documented behavior. [Upstream
modes](https://adguard-dns.io/kb/adguard-home/configuration/#dns).
## Cache, ECS, and privacy
- Keep DNS cache enabled; use `cache_ttl_min`/`cache_ttl_max` overrides
cautiously to avoid stale or unnecessarily short TTLs. `cache_optimistic`
answers with a 10-second TTL after expiry and attempts refresh — good for
low-latency scenarios that tolerate briefly stale results, not to be enabled
blindly on internal domains sensitive to immediate changes. [Cache
parameters](https://adguard-dns.io/kb/adguard-home/configuration/#dns).
- ECS is off by default: when enabled, AGH adds EDNS Client Subnet to upstream
requests; if CDN geo-scheduling is truly needed, use `use_custom: true` with a
coarse, non-real-client-subnet `custom_ip`, then compare hit rate vs. privacy
impact. [ECS parameters](https://adguard-dns.io/kb/adguard-home/configuration/#dns).
- Review log retention and who can read it. If device identity is not needed,
consider `anonymize_client_ip: true`; this anonymizes client addresses in logs
and stats. [Anonymization
parameter](https://adguard-dns.io/kb/adguard-home/configuration/#dns).
## Proxy / OpenClash coexistence
If OpenClash/transparent proxy redirects DNS, validate hop by hop to ensure:
client DNS requests reach AGH uniquely; AGH's outbound traffic to public
encrypted upstreams is not hijacked back to AGH; and the router's local dnsmasq
PTR/local-domain requests return to dnsmasq. Create explicit proxy/hijack
exceptions for `.36` and its upstreams, or explicitly design the proxy to take
over AGH's outbound resolution; do not rely on "it seems to resolve" to conclude
there is no loop.
This is not an AGH-official OpenClash instruction (the official docs do not
maintain that integration), but an on-site verification requirement derived from
AGH's independent listening, upstream forwarding, and local-PTR-split
capabilities. The officially supported mechanisms are referenced at
[`bind_hosts`/upstreams/PTR](https://adguard-dns.io/kb/adguard-home/configuration/#dns).
If serving DoH via a reverse proxy, `trusted_proxies` must list only the actual
proxy IP/CIDR; AGH will only trust `X-Real-IP`, `X-Forwarded-For`, etc. from
those sources. Do not write the whole LAN/WAN for convenience;
`http.doh.insecure_enabled` should only be enabled when a reverse proxy really
terminates TLS in front. [Proxy and
DoH](https://adguard-dns.io/kb/adguard-home/configuration/#dns); [encrypted DNS
docs](https://adguard-dns.io/kb/adguard-home/encryption/). For a pure-LAN
plaintext-53 service, publishing DoH/DoT/DoQ outward is usually unnecessary.
## On-site review checklist (read-only)
1. Record the version, service method, and the full effective configuration
(secrets redacted); run the AGH config check. Officially provided via
`--check-config`. [Command-line
arguments](https://adguard-dns.io/kb/adguard-home/configuration/#command-line-arguments).
2. On `.36`, the gateway, and a wired/wireless client, record `ss` listeners,
DHCP option 6, default route, `/etc/config/dhcp`, and OpenClash
DNS/firewall-redirect rules.
3. From a client test: public A/AAAA, ad-rule hits, DHCP hostnames, forward LAN
domain, private PTR, DNSSEC bad-signature domain, and the expected failure
mode when AGH stops. Then check whether client IPs in AGH query logs are
still genuinely identifiable.
4. From the AGH host, test each upstream and bootstrap over the actual protocol
for IPv4 and IPv6 reachability; compare with transparent proxy on/off to rule
out loops and leaks.
5. Verify the web UI/DNS ports have no WAN exposure; confirm allowed clients and
firewall match actual LAN/VLAN, guest network, and VPN subnets.
Before changing anything, export the current configuration and DHCP/firewall
state; adjust one layer at a time (DHCP handout, AGH listening, upstreams, or
DNS hijack) and keep a known-good management and DNS fallback path.
+51
View File
@@ -0,0 +1,51 @@
# Domain Docs
How the engineering skills should consume this repo's domain documentation when exploring the codebase.
## Before exploring, read these
- **`CONTEXT.md`** at the repo root, or
- **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic.
- **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src/<context>/docs/adr/` for context-scoped decisions.
If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved.
## File structure
Single-context repo (most repos):
```
/
├── CONTEXT.md
├── docs/adr/
│ ├── 0001-event-sourced-orders.md
│ └── 0002-postgres-for-write-model.md
└── src/
```
Multi-context repo (presence of `CONTEXT-MAP.md` at the root):
```
/
├── CONTEXT-MAP.md
├── docs/adr/ ← system-wide decisions
└── src/
├── ordering/
│ ├── CONTEXT.md
│ └── docs/adr/ ← context-specific decisions
└── billing/
├── CONTEXT.md
└── docs/adr/
```
## Use the glossary's vocabulary
When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`).
## Flag ADR conflicts
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_
+47
View File
@@ -0,0 +1,47 @@
# Issue tracker: Linear
Issues and PRDs for this repo are tracked in Linear, driven entirely through the
Linear MCP configured in the current runtime. VPS operation records use the
Linear **`vps`** project (team `W1ndy`).
## Conventions
- **Create**: `linear_save_issue` (requires `title` + `team`); description as
real-newline Markdown.
- **Read**: `linear_get_issue <identifier>` (e.g. LIN-123), optionally with
`includeRelations`.
- **List**: `linear_list_issues`, filtered by `query` / `state` / `assignee` /
`team` / `project`.
- **Comment**: `linear_save_comment` (pass `issueId` + `body`).
- **Labels**: `linear_save_issue` `labels` field (replaces the whole label set).
- **Status**: `linear_save_issue` `state` field (type / name / ID).
Resolve team, project, and cycle names through the Linear MCP rather than
hard-coding them.
## Cross-issue constraint baseline
The `plans/` directory is retired (removed 2026-08-06). Plans and baselines now
live in the vps project document "VPS 运维重构:已确认决策与验收标准". Open work
items are tracked as individual issues with dependencies expressed via
`blockedBy` / `blocks`. Read that document before adding new tasks.
## When a skill says "publish to the issue tracker"
Create a Linear issue with `linear_save_issue` (`project: vps`).
## When a skill says "fetch the relevant ticket"
Use `linear_get_issue <identifier>`.
## Wayfinding operations
- **Map**: one issue as the map; child tickets link via `parentId`.
- **Child ticket**: `linear_save_issue` with `parentId` set to the map; labels
`wayfinder:<type>` (research / prototype / grilling / task).
- **Blocking**: `linear_save_issue` `blockedBy` / `blocks` (append-only).
- **Frontier query**: list the map's open children, excluding those blocked or
already assigned; take the first in map order.
- **Claim**: `linear_save_issue` `assignee: me`.
- **Resolve**: `linear_save_comment` the answer → update `state` → append a
pointer under the map's decisions-so-far.
+15
View File
@@ -0,0 +1,15 @@
# Triage Labels
The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.
| Label in mattpocock/skills | Label in our tracker | Meaning |
| -------------------------- | -------------------- | ---------------------------------------- |
| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
| `needs-info` | `needs-info` | Waiting on reporter for more information |
| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
| `ready-for-human` | `ready-for-human` | Requires human implementation |
| `wontfix` | `wontfix` | Will not be actioned |
When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.
Edit the right-hand column to match whatever vocabulary you actually use.
+17
View File
@@ -0,0 +1,17 @@
# docs/archive — 归档文档
归档 = 单次调研、已过期,或与当前运维无行动指向的内容。恢复使用前先确认
内容仍与线上状态一致(本仓库原则:先证据后变更,live state 优先)。
## 归档清单
| 文件 | 归档日期 | 原位置 | 说明 |
|------|---------|--------|------|
| `lan-dns-alternatives.md` | 2026-08-17 | `docs/` | DNS 技术选型调研,零引用,无在途决策 |
| `agent-runbook-guide.md` | 2026-08-22 | `docs/` | 上游参考存档;仓库落地规范为 `RUNBOOKS.md` |
| `lan-core-switch-upgrade-plan.md` | 2026-08-22 | `docs/` | SE5420 历史规划参考;执行以 `docs/lan-se5420-deployment-guide.md` 为准 |
| `lan-rb5009-upgrade.md` | 2026-08-22 | `docs/` | 未采购的 ER-X→RB5009 休眠备选方案;其中 PVE 透传与 VLAN10 调研仍可参考 |
| `se5420-review-claim-verification-2026-08.md` | 2026-08-22 | `docs/` | 一次性评审复核调研(现场只读复核结论) |
> 购物类文档(打印机购买指南、交换机选型调研)已按整改计划移入 Obsidian
> vault`~/Documents/vault/my-vault/02_Areas/House/`),不在本目录。
+326
View File
@@ -0,0 +1,326 @@
# Agent Runbook 实用指南(v1
> **定位**:本指南用于把团队的重复性运维、交付与故障处理经验写成可由 Agent 安全执行的流程。它适用于以 Git 仓库为中心的工程协作模式,优先采用 **Markdown + Git 版本控制 + 明确的 Agent 路由规则**,而不是一开始引入复杂的自动化平台。
> 本文件为上游参考存档。仓库内落地规范见 [`RUNBOOKS.md`](../../RUNBOOKS.md),标准模板见 [`runbooks/_template.md`](../../runbooks/_template.md),索引见 [`runbooks/README.md`](../../runbooks/README.md)。
## 1. 什么是 Agent Runbook
Runbook 是预先设计的、可重复执行的操作流程,用于处理部署、告警、故障、配置变更、CI 修复等标准化工作。传统 Runbook 的主要读者是人;**Agent Runbook 则必须把人的隐性判断显式化**,使 Agent 能知道做什么、看到什么才算正常、下一步去哪里、何时停止以及如何撤销。
Google SRE 强调在事故发生前设计响应流程、系统化排障,并逐步将重复性运维工作自动化。[1] [2] AWS Systems Manager Automation 则把可执行 Runbook 建模为顺序步骤:每个步骤调用一个动作,前一步输出可以传递给后续步骤。[3] 这两种思路共同构成了 Agent Runbook 的实用基础。
| 层次 | 核心问题 | 应承担的职责 |
|---|---|---|
| `AGENTS.md` | **何时使用哪份流程?** | 工作路由、通用操作约束、无匹配流程时的默认行为 |
| `runbooks/*.md` | **这件事按什么流程做?** | 前置条件、分步操作、决策分支、验证、停止条件与回滚 |
| Skill / MCP / Tool | **有哪些可调用能力?** | 具体能力、参数、权限边界和使用说明 |
| Shell / GitHub / Linear / SSH 等 | **实际如何执行?** | 对系统、代码库或外部服务执行操作 |
## 2. 设计目标与适用边界
Agent Runbook 的目标不是让 Agent 在所有异常下“想办法修好”,而是在一个**已知、受控、可验证、可回退**的边界中提高执行一致性。它应当优先覆盖高频、后果明确、流程稳定的操作,例如 CI 失败定位、Issue 到合并请求、发布前检查、标准部署、回滚及网络变更。
| 适合纳入 Runbook | 暂不适合直接自动执行 |
|---|---|
| 明确输入、固定步骤、可观察结果的操作 | 目标或验收标准尚不清楚的探索性任务 |
| 可在每次修改后验证状态的变更 | 缺失关键参数、权限或上下文的任务 |
| 具有安全回滚路径的发布与配置调整 | 高破坏性、不可逆或影响面未知的操作 |
| 可由权限与审批规则约束的运维流程 | 与既有流程事实冲突、无法判断根因的异常场景 |
> **基本原则**:当实际状态与 Runbook 的假设冲突,Agent 应停止并呈报,而不是补全未知信息、绕过检查或继续试错。
## 3. Agent Runbook 的最小字段
与普通人工 Runbook 相比,Agent Runbook 必须显式包含以下六类控制信息。缺少其中任一项,都会增加盲目执行或错误恢复的风险。
| 字段 | 作用 | 写作要求 |
|---|---|---|
| **Action** | 定义当前要执行的动作 | 使用可观察、可执行的动词;避免“检查一下”“适当调整”等模糊表述 |
| **Expected** | 描述正常状态或预期输出 | 给出具体信号、阈值、状态码、测试结果或页面表现 |
| **Decision** | 定义分支与下一跳 | 用“条件 → 下一步”的形式;无法判断时指向 `STOP` |
| **Verification** | 确认变更真正生效 | 在每个有副作用的步骤后执行,不能被跳过 |
| **Stop condition** | 规定何时不得继续 | 明确列出信息缺失、状态冲突、权限不足、验证失败等条件 |
| **Rollback** | 描述如何恢复到变更前状态 | 标明触发条件、前提、撤销步骤及回滚后的验证方式 |
## 4. 推荐目录与路由机制
建议把流程与代码一起保存在 Git 仓库中。这样 Runbook 可以评审、版本化、随系统演进更新,也能与相关 Issue、PR 和配置建立可追溯关系。
```text
repo/
├── AGENTS.md
├── RUNBOOKS.md
├── runbooks/
│ ├── README.md
│ ├── issue-to-merge.md
│ ├── fix-ci.md
│ ├── release.md
│ ├── rollback.md
│ ├── network-change.md
│ └── network-recovery.md
└── ...
```
### `AGENTS.md`:只做路由与通用约束
`AGENTS.md` 不应重复流程细节。它只需要规定 Agent 在进行操作类工作前,先查找最具体且适用的 Runbook,并严格遵守其中的步骤、验证、停止和审批要求。
```markdown
# Operational Rules
Before performing operational work:
1. Inspect `runbooks/`.
2. Select the most specific applicable runbook.
3. Follow its steps in order.
4. Do not skip verification steps.
5. Respect STOP and approval conditions.
6. If no runbook applies, diagnose only; do not mutate production state.
## Routing
- CI failure → `runbooks/fix-ci.md`
- GitHub issue implementation → `runbooks/issue-to-merge.md`
- Deployment → `runbooks/release.md`
- Rollback → `runbooks/rollback.md`
- Network configuration → `runbooks/network-change.md`
- Network outage → `runbooks/network-recovery.md`
```
### `RUNBOOKS.md`:仓库级规范
`RUNBOOKS.md` 用于统一所有 Runbook 的字段、命名、评审要求和变更规则。每份 Runbook 只描述一种可识别的操作意图;如果流程已有明显分叉,应拆分为独立文件,而不是堆叠成长篇“万能流程”。
## 5. 规范模板
以下模板可直接保存为 `runbooks/_template.md` 使用。
```markdown
# Runbook: <名称>
## Purpose
说明本 Runbook 要解决的问题及成功结果。
## Scope
- 适用环境:<如 development / staging / production>
- 适用对象:<服务、仓库、组件或告警类型>
- 不适用情形:<需要改用其他 Runbook 或转人工的场景>
## Ownership
- Owner<团队或角色>
- Last reviewed<YYYY-MM-DD>
- Related systems<系统名称>
## Preconditions
- <执行前必须满足的权限、备份、窗口、健康状态或已知信息>
## Inputs
| 输入 | 来源 | 是否必需 | 校验方法 |
|---|---|---:|---|
| <参数> | <来源> | 是/否 | <如何确认有效> |
## Safety
### Non-negotiable rules
- 先只读诊断,后执行变更。
- 不得把删除现有配置作为首次恢复动作。
- 不得猜测或编造缺失参数。
- 不得绕过失败的测试、检查或审批。
- 每次变更后必须完成对应验证。
- 破坏性操作必须获得明确批准。
### Stop conditions
- 实际状态与本文档的前提或预期结果冲突。
- 缺少必要输入、权限、审批或回滚能力。
- 验证失败且本文档没有明确的下一步。
- 影响范围超出 Scope。
### Approval gates
| 动作 | 风险级别 | 是否需要明确批准 | 批准记录位置 |
|---|---|---:|---|
| <动作> | 低/中/高 | 是/否 | <Issue / PR / 变更单> |
## Procedure
### Step 1 — Diagnose
**Action**
<执行只读诊断动作。>
**Expected**
<列出预期输出、状态或证据。>
**Decision**
- 若 <条件 A>,进入 Step 2。
- 若 <条件 B>,进入 Troubleshooting A。
- 若无法判断或状态冲突,`STOP` 并记录证据。
### Step 2 — Change
**Action**
<描述单一、可审计的变更动作。>
**Expected**
<变更后应出现的状态。>
**Verification**
<给出可重复执行的验证命令、测试、监控指标或检查清单。>
**Rollback**
- 触发条件:<什么情况需要回滚>
- 回滚动作:<如何撤销>
- 回滚验证:<如何确认恢复成功>
## Troubleshooting
### Troubleshooting A — <异常名称>
- 证据收集:<日志、指标、命令输出、链接>
- 允许动作:<仅限已验证且低风险的动作>
- 下一步:<回到某步 / 转入另一 Runbook / STOP 并升级>
## Final Verification
只有同时满足以下标准,流程才算成功:
- <功能或服务状态>
- <自动化测试或健康检查>
- <监控指标或告警状态>
- <变更记录、PR 或 Issue 已更新>
## Failure Handling
若未能完成:
1. 停止进一步变更。
2. 收集 <命令输出、时间范围、请求 ID、日志链接、截图或复现步骤>。
3. 记录已完成步骤、实际结果、未满足的预期和是否执行过回滚。
4. 按 <升级渠道> 交接,不继续猜测。
## References
- <关联 Issue、PR、架构文档、仪表盘、配置仓库或外部文档>
```
## 6. 编写步骤的标准写法
每个步骤应只承担一个清晰目的,并使用“动作—预期—决策”的闭环表达。如下表所示,前者会导致 Agent 自主扩大操作范围,后者则为其提供安全边界。
| 不推荐写法 | 推荐写法 |
|---|---|
| “检查部署是否正常,不正常就修复。” | “读取部署状态与最近一次发布记录。若所有副本 `Ready` 且版本等于目标版本,进入 Final Verification;若副本未就绪,收集事件与日志并进入 Troubleshooting A;若版本不匹配且原因未知,`STOP`。” |
| “必要时修改配置。” | “仅当配置差异与变更单 `CHG-123` 完全一致且审批已记录时,应用指定键的值;应用后运行健康检查;失败则按 Rollback 回退。” |
| “测试失败时可先跳过。” | “任何必需测试失败均不得继续部署。记录失败测试、日志和提交版本;仅按 Troubleshooting B 处理。” |
## 7. 通用安全规则
以下规则适合在每份 Runbook 的 `Safety` 章节中复用。若某流程存在更严格要求,应以更严格要求为准。
```markdown
## Safety Rules
- Never delete an existing configuration as the first recovery action.
- Prefer read-only diagnosis before mutation.
- After every mutation, verify the expected state.
- If actual state conflicts with this runbook, STOP.
- Do not invent missing parameters.
- Do not bypass failed tests.
- Destructive actions require explicit approval.
```
这些约束体现了一个关键顺序:**先证据,后变更;先小范围,后扩大;先验证,后结束;不确定则停止。** 特别是停止条件必须可操作,例如“权限不足”“缺少变更单”“生产状态与前提不一致”“错误率超过 1%”等,而不应写成“情况复杂时停止”。
## 8. 运行与审计流程
Agent 执行 Runbook 时,应按照固定运行模型工作。每一步的输入、动作、输出和下一跳都应可追踪,这与 AWS 自动化 Runbook 的顺序步骤和输出传递思想一致。[3]
```text
输入与前置条件
只读诊断
确认预期状态或决策分支
获取审批(如需要)
执行最小变更
立即验证
成功收尾 / 回滚 / 停止并升级
```
| 阶段 | Agent 必须产出的证据 | 禁止行为 |
|---|---|---|
| 输入确认 | 参数来源、环境、目标资源、权限与审批状态 | 用猜测值补全必需参数 |
| 诊断 | 命令输出、日志、指标或页面状态 | 在未诊断前直接修改生产状态 |
| 变更 | 实际执行内容、变更范围、时间 | 将多个无关变更混在一起执行 |
| 验证 | 测试、健康检查、监控状态与预期对比 | 以“命令执行成功”代替业务验证 |
| 失败处理 | 已做步骤、异常证据、回滚状态和升级对象 | 无限制重试或绕过失败检查 |
## 9. 从人工操作到自动化的成熟路径
不建议在流程尚未稳定时先构建复杂 DSL 或全自动编排。应先积累真实案例,把可重复部分固化为 Markdown Runbook,再把已稳定、低歧义、可验证的操作迁移到脚本、CI、Skill 或自动化系统。Google SRE 将能够由机器替代的重复性人工工作视为应逐步消除的 toil。[4]
| 阶段 | 主要形式 | 人的角色 | 自动化边界 |
|---|---|---|---|
| 1. 人工处理 | 现场处置与复盘 | 执行、判断、记录 | 不自动化 |
| 2. Markdown Runbook | 固化步骤与证据要求 | 审核流程与异常判断 | Agent 可辅助诊断 |
| 3. Agent + Runbook | 严格按流程执行 | 审批高风险动作、处理例外 | 受停止条件约束的执行 |
| 4. Script / Skill / CI / Automation | 把稳定步骤程序化 | 处理异常和维护自动化 | 自动完成重复性操作 |
| 5. 人工审批 + 自动执行 | 常规流程端到端运行 | 决策、审计与治理 | 审批门控下的自动变更 |
## 10. 上线前检查清单
在将一份新 Runbook 交给 Agent 使用前,建议由流程所有者按以下清单审核。
| 检查项 | 合格标准 |
|---|---|
| 问题边界 | Purpose 与 Scope 清楚描述适用和不适用情形 |
| 输入 | 所有必需输入都有来源、格式和校验方法 |
| 步骤 | 每一步均有 Action、Expected 与明确的下一跳 |
| 变更控制 | 所有修改动作都有 Verification;关键动作有 Rollback |
| 安全控制 | Stop conditions、审批门槛和禁止行为已列明 |
| 异常处理 | 失败时知道收集什么证据、交给谁,而非继续猜测 |
| 可维护性 | 有 Owner、最近复审日期与关联文档;已在版本控制中评审 |
| 可演练性 | 已在安全环境或历史案例上走通至少一次 |
## 11. 建议的首批 Runbook
首次落地时,应优先选择频率较高、输入相对明确、变更可回退的场景。以下集合通常能覆盖大部分工程协作的基础需求。
| Runbook | 目的 | 关键安全控制 |
|---|---|---|
| `issue-to-merge.md` | 从已明确 Issue 到可评审变更 | Scope 锁定、测试门槛、PR 证据 |
| `fix-ci.md` | 诊断并修复 CI 失败 | 不跳过测试、不修改无关代码 |
| `release.md` | 执行标准发布 | 发布窗口、审批、健康检查、回滚点 |
| `rollback.md` | 恢复到已知稳定版本 | 明确触发条件、版本选择、回滚后验证 |
| `network-change.md` | 实施受控网络配置变更 | 影响评估、变更单、回退配置 |
| `network-recovery.md` | 处理网络异常与服务恢复 | 只读诊断优先、状态冲突即停止 |
## 12. 结论
Agent Runbook 的价值不在于把每一项运维工作立即自动化,而在于将团队的工程判断编码为**可路由、可验证、可停止、可回滚**的操作系统。对于多数团队,从仓库中的 `AGENTS.md``RUNBOOKS.md` 和一组 Markdown Runbook 起步,已经足够实用。
当某个流程经过多次执行、输入稳定、异常分支收敛且验证可靠后,再将其下沉为脚本、CI 或其他自动化能力。这样既能逐步降低重复性 toil,也能始终保留人类对高风险和例外情形的决策权。[4]
## References
[1]: https://sre.google/sre-book/managing-incidents/ "Google SRE Book — Managing Incidents"
[2]: https://sre.google/sre-book/effective-troubleshooting/ "Google SRE Book — Effective Troubleshooting"
[3]: https://docs.aws.amazon.com/systems-manager/latest/userguide/automation-documents.html "AWS Systems Manager — Creating your own runbooks"
[4]: https://sre.google/sre-book/eliminating-toil/ "Google SRE Book — Eliminating Toil"
[5]: https://docs.aws.amazon.com/systems-manager/latest/userguide/systems-manager-automation.html "AWS Systems Manager Automation"
[6]: https://docs.aws.amazon.com/systems-manager-automation-runbooks/latest/userguide/automation-runbook-reference.html "AWS Systems Manager Automation Runbook Reference"
[7]: https://learn.microsoft.com/en-us/azure/automation/manage-runbooks "Microsoft Learn — Manage runbooks in Azure Automation"
---
**来源**Manus AI《Agent Runbook 实用指南(v1.0)》,本仓库存档为规范参考。
@@ -0,0 +1,156 @@
# LAN 核心交换机升级计划(保留 ER-X)
**状态:** SE5420 **已采购**2026-08-09)。**实施与验证以 [lan-se5420-deployment-guide.md](../lan-se5420-deployment-guide.md) 为准**
本文为历史规划参考,**不得作为现场执行步骤**;所有实际操作均以部署指南为准。
**锁定硬件:** TP-Link **`TL-SE5420`**16 × 2.5GbE RJ45 + 4 × 10GbE SFP+)。
**目标:** SE5420 承接全部 LAN 物理接入与二层转发;ER-X 继续承担公网、NAT、防火墙、
LAN66/LAN55 网关与 DHCP。
**拓扑与流量的详细说明**(职责、逻辑网、流量路径、Wi-Fi 分工、验收边界)见:
[lan-erx-se5420-network.md](../lan-erx-se5420-network.md)。
SE5420 官方资料:静态功耗 8 W、最大功耗 32 WVLAN、LACP、STP/RSTP/MSTP、ACL、
CLI/SNMP、配置导入导出与固件下载。无 PoE——AP 使用本地取电 + 普通网线。
## 已确认边界(摘要)
| 项 | 结论 |
|---|---|
| 硬目标 | 同 VLAN 2.5GSFP+ 先空槽 |
| 核心角色 | 纯 L2;不开 L3 / DHCP Server/Relay / NAT |
| 网关 | 默认一律 ER-X `.254`;升级专用 SSID(后续)才走 `gfw` `.1` |
| 本次 Done | 阶段 03VLAN10 / 客人 SSID / 升级 SSID 另立项目 |
| 切换 | 30–60 分钟维护窗;旧交换迁完后闲置 |
| 首批 2.5G | NAS + 主力 PC(或 PVE |
| 管理 | 先本地 HTTPS/SSH;云以后再说 |
### 保留的 ER-X 职责
| 项目 | 迁移后职责 |
|---|---|
| PPPoE / WAN、NAT、端口转发、WAN 防火墙 | ER-X,不变 |
| LAN66 (`192.168.66.0/24`) 默认网关与 DHCP | ER-X `eth0`,不变 |
| LAN55 (`192.168.55.0/24`) 默认网关与 DHCP | ER-X `switch0`,不变 |
| LAN66 ↔ LAN55 三层转发 | ER-X,不变 |
| 升级专用 Wi-Fi 网关(后续) | `gfw` VM,不是 ER-X 或核心交换机 |
ER-X 与核心之间使用两条**独立无标签 access**LAN66 + LAN55),不向 ER-X 送
VLAN tag。
### 核心交换机职责
- 所有有线设备、AP 与 PVE 的物理接入;
- VLAN66、VLAN55 的二层转发;
- 为日后 U6 Lite 与 PVE 预留 VLAN10 trunk(本次不启用业务);
- 同 VLAN 的 1G/2.5G 转发;
- SFP+ 预留未来 10GDAC/AOC/光;不用 10GBASE-T 作默认)。
## 目标拓扑(简图)
```text
Internet
|
ER-X / PPPoE
+-------+--------+
| |
eth0, LAN66 switch0 port, LAN55
access access
| |
+-------+--------+
|
TL-SE5420 核心(纯 L2
| | |
PVE / gfw U6 Lite LAN66/LAN55 access
(日后 trunk (日后 trunk NAS, PC, dns, ubnt, AC-Lite …
```
完整端口表、流量路径与 Wi-Fi 分工见
[lan-erx-se5420-network.md](../lan-erx-se5420-network.md)。
## VLAN 与端口设计
| VLAN / 逻辑网络 | 核心配置 | 网关 / DHCP | 用途 |
|---|---|---|---|
| 66 | accessPVE/U6 日后 trunk 的 native | ER-X `eth0` / `.254` | 主 LAN、管理 |
| 55 | access | ER-X `switch0` / `.254` | LAN55、AC-Lite |
| 10(后续) | 仅 PVE 与 U6 trunk 上 tagged | `gfw` | 升级专用 SSID |
| 管理 | LAN66 管理地址;不新建 VLAN | reservation / 静态 | SE5420 管理面 |
管理地址分配前先查 ER-X DHCP/reservation;禁用不需要的 HTTP/Telnet;云管延后;
保留 HTTPS/SSH 与离线配置备份(备份不进本仓库)。
### 初始端口分配
| 预留 | 对端 | 模式 |
|---|---|---|
| 铜口 1 | ER-X `eth0` | access VLAN66 |
| 铜口 2 | ER-X `switch0` 成员口 | access VLAN55 |
| 铜口 3 | PVE`gfw` | 现 access 66;日后 trunk native66+tag10 |
| 铜口 4 | U6 Lite | 同上;本地取电 |
| 铜口 5 | UAP-AC-Lite | access VLAN55;本地取电 |
| 其余铜口 | NAS、PC、`dns``ubnt`… | 默认 access VLAN66 |
| SFP+ 14 | 未来 | 空槽 |
## 分阶段实施与验收
### 阶段 0:采购前核验
1. 确认 SE5420 revision、保修、手册、固件页。
2. 确认 VLAN trunk、RSTP/MSTP、LACP、镜像、配置导出、错误计数。
3. SFP+ 模块本次不买;有对端后再选 DAC/AOC/光。
4. 盘点线缆与对端(LAN66/55、PVE、两台 AP);确认关键 2.5G 链路可协商。
### 阶段 1:离线初始化核心
1. 仅电源 + 隔离管理本:管理 IP、强密码、时区、NTP、HTTPS/SSH**关闭 L3/DHCP**。
2. 导出初始配置(离线保存,不进仓库)。
3. 建立 VLAN66/55/10 与端口模板;VLAN10 不接生产。
4. 开启 RSTP/MSTP;不制造物理环路。
### 阶段 2:维护窗迁移 LAN66
1. 使用约定维护窗(约 30–60 分钟);保留原接线作回滚。
2. 先接铜口 1 ↔ ER-X `eth0`;验证管理地址、`.254`、DNS `.36`、互联网。
3. 逐台迁到 VLAN66 access;每台确认地址/DNS/路由/业务。
4. 最后迁 `ubnt``dns`、NAS/PC;禁止无意双上行。
### 阶段 3:迁移 LAN55
1. 接铜口 2 ↔ ER-X `switch0` 成员口。
2. 测试设备获 `192.168.55.x`、网关 `.254`、可达 DNS。
3. AC-Lite → 铜口 5;确认 `.55.5` 与 Inform Connected。
4. 旧交换下电闲置。
### 阶段 4VLAN10 升级专用 Wi-Fi(独立项目)
不与本次 Done 捆绑。仅 U6;网关 `gfw`;详见
[lan-erx-se5420-network.md](../lan-erx-se5420-network.md) 第 6.5 / 8 节与
[unifi-network.md](../unifi-network.md)。
## 性能预期与不变瓶颈
- 同 VLAN、两端在核心上的 2.5G:可测 2.5G 级二层。
- 上网与 LAN66↔LAN55:仍经 ER-X 1G 路径。
- 空 SFP+ 不使网络「变成 10G」。
## 验收清单
- 管理面、口令、离线备份;
- 端口速率与错误计数;
- LAN66/55 的 DHCP、网关、DNS、互联网与关键本地服务;
- 两台 AP ConnectedInform 仍为 `http://192.168.66.46:9080/inform`
- 无无意环路;旧交换已闲置;
- (建议)两台 2.5G 终端同 VLAN `iperf3`
## 回滚
验证失败则停迁、恢复原接线;不改 ER-X WAN/DHCP/SSH。VLAN10 失败只撤新 SSID。
## 参考
- [ER-X + SE5420 网络与拓扑说明](../lan-erx-se5420-network.md)
- [LAN 概览](../lan-overview.md)
- [ER-X 配置记录](../edgerouter-x-configuration.md)
- [UniFi 网络](../unifi-network.md)
- [`gfw`](../../hosts/gfw.windy.lan.md)
- [TL-SE5420 官方规格](https://www.tp-link.com.cn/product_2899.html?v=specification)
+605
View File
@@ -0,0 +1,605 @@
# Home-LAN DNS alternatives for the windy LAN (research, 2026-08)
**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
China home LAN. Claims are cited to primary sources (official repos, official
docs, upstream READMEs); anything not verified is flagged as such.
> 2026-08-12: facts in this page's scope recap were refreshed by W1N-56 live
> 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,
> 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):
- 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
`https://adg.chans.xyz/dns-query`. **DNSSEC disabled** (known-bad-signature
check failed on the selected path). Rewrites: `hass.local` / `hass.windy.lan`.
- `gfw` OpenWrt (`.1`) runs OpenClash fake-ip + TPROXY; dnsmasq → clash DNS
`127.0.0.1#7874`. `mosdns` on `127.0.0.1:6052` is clash's
`nameserver`/`default-nameserver` (DIRECT-rule real-IP resolution: domestic →
AGH `.36:53`, foreign → `223.5.5.5`/`119.29.29.29`); it is **not** in the LAN
client query path.
- No local authoritative PTR source yet; private reverse DNS is a known gap.
---
## 1. TL;DR / recommendation
**The current stack is already 80% of the answer.** AGH is a strong LAN DNS
front-end (filtering, rewrites, per-client upstreams, query log, web UI) and its
upstream layer — **per-domain upstreams** plus a **per-domain list loaded from a
file** (`upstream_dns_file`) — is exactly the mechanism the official docs
recommend for accelerating China CDN domains while keeping everything else on a
trusted path. [AGH configuration: upstreams](https://adguard-dns.io/kb/adguard-home/configuration/).
The genuinely worthwhile changes, in order of value:
1. **Add geo-split inside AGH** via `upstream_dns_file` fed by a converted
`accelerated-domains.china.conf` ([felixonmars/dnsmasq-china-list](https://github.com/felixonmars/dnsmasq-china-list)):
domestic CDN domains → `dns.alidns.com` / `doh.pub`; everything else →
the trusted foreign path (currently `adg.chans.xyz`). This is a documented
AGH use case, requires **no new daemon**, and removes the need for mosdns.
This is the top recommendation.
2. **Re-enable real DNSSEC** by putting validation behind AGH: AGH's
`enable_dnssec` only sets the DO bit — it does not validate
([AGH config: DNSSEC](https://adguard-dns.io/kb/adguard-home/configuration/)).
The two realistic ways are (a) point the foreign/trusted default upstream at
a validating resolver ([unbound](https://unbound.docs.nlnetlabs.nl/en/latest/),
[blocky](https://0xerr0r.github.io/blocky/latest/configuration/#dnssec-validation))
and re-test a known-bad-signature domain; or (b) insert a validating
resolver (blocky is the lightest) between AGH and the upstreams.
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
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)).
Top-3 alternatives worth pursuing (see §3 for detail):
| Rank | Option | Why |
|------|--------|-----|
| 1 | **AGH with China-list geo-split (`upstream_dns_file`)** | Documented AGH pattern; single box; no new service; keeps filtering/rewrites/UI. |
| 2 | **Blocky as validating backend behind AGH** | The only "new software" option that adds real in-process DNSSEC validation + conditional per-domain upstreams + ECS in one static binary ([blocky README](https://github.com/0xERR0R/blocky), [config](https://0xerr0r.github.io/blocky/latest/configuration/)). |
| 3 | **Unbound as validating recursive resolver** (replaces forwarders for the foreign path, or whole path) | True validation, full recursion (fail-open by nature), private `local-zone`s; heavier ops than AGH's file-driven split. |
Explicitly **not** recommended as replacements here: smartdns and chinadns-ng
(both excellent *splitters*, but neither validates DNSSEC and both lack AGH's
filtering/UI/query-log layer, so they add a daemon without closing the DNSSEC
gap); mihomo/sing-box DNS as the primary path (couples DNS to the proxy and is
fail-closed; keep for proxy-side concerns only); knot-resolver/dnsdist (overkill
for a single-operator home LAN).
---
## 2. Requirement matrix
Legend: **●** native/built-in · **◐** possible with config/lists · **○** absent/
not applicable. "Geo-split" = route domestic vs foreign names to different
upstreams. "Anti-pollution" = a mechanism to avoid/adjudicate poisoned answers
(IP-verdict or trusted-upstream routing). "DNSSEC" = performs validation
in-process (not just forwards DO).
| Candidate | Geo-split | Anti-pollution | DNSSEC (validate) | Cache | Private names / rewrites | Ops simplicity | License |
|---|---|---|---|---|---|---|---|
| AGH (current) | ◐ per-domain upstreams + list file | ◐ via trusted foreign upstream | ○ (DO bit only) | ● | ● rewrites, per-client, private-PTR | ● Docker + UI | GPL-3.0 |
| mosdns v5 | ● domain/ip list matchers | ◐ forward foreign→trusted | ○ | ● (strips EDNS0) | ● hosts/redirect/reverse_lookup | ◐ single binary, YAML, no UI | GPL-3.0 |
| smartdns | ● nameserver groups + domain lists | ● bogus-nxdomain / blacklist-ip / trusted groups | ○ (no option in config ref) | ● serve-expired | ● address / local-domain / lease file | ◐ single binary, optional WebUI plugin | GPL-3.0 |
| chinadns-ng | ● chnlist/gfwlist + tag:none IP-test | ● IP verdict via chnroute ipset/nftset | ○ | ● cache/stale/verdict | ◐ hosts / dns-rr-ip | ◐ single static binary, config file | AGPL-3.0 |
| dnsmasq-china-list | ◐ (data only) | ◐ (via host resolver) | ◐ via host | ◐ via host | ◐ via host | ◐ feed lists | WTFPL |
| unbound | ◐ forward-zones / RPZ / views | ◐ forward-zones + bogus-nxdomain | ● | ● serve-expired | ● local-zone / local-data | ◐ config daemon, no UI | BSD-style (NLnet) |
| blocky | ◐ conditional per-domain + client groups | ◐ blocking lists + conditional routing | ● | ● prefetch | ● customDNS / rewrite / hosts | ◐ single binary, YAML, REST (no full web UI) | Apache-2.0 |
| Technitium | ◐ conditional-forwarder zones / apps | ◐ blocked lists + forwarding | ● | ● persistent | ● zones, stub, split-horizon | ● .NET + web console | GPL-3.0 |
| sing-box | ● DNS rules (geoip/geosite) | ● rule-based servers + (proxy) sniffing | ○ | ● LRU + optimistic | ● hosts / local server | ◐ single binary, JSON | GPLv3-family (metadata "other") |
| mihomo | ● nameserver-policy + fallback-filter | ● geoip verdict + geosite | ○ | ● (cache-algorithm) | ● hosts; fake-ip-filter for `.lan` | ◐ single binary, YAML | not cleanly verifiable (repo obfuscated) |
| knot-resolver | ◐ policy modules | ◐ policy + RPZ | ● | ● persistent | ◐ hints / local data | ◐ systemd, Lua config | open source (CZ-NIC) |
| dnsdist | ◐ Lua rules (custom) | ◐ custom policies | ○ (balancer, not validator) | ○ (no cache of its own) | ○ | ○ power tool | GPL (PowerDNS) |
Notes:
- "Geo-split" for AGH/blocky/unbound/Technitium is real but requires feeding a
China domain list; chinadns-ng/mihomo additionally offer the **IP-verdict**
path for domains not in any list (query both, adopt CN result only if the
answer IP is mainland).
- mosdns v5's `cache` plugin ignores request EDNS0 and strips response EDNS0
([cache plugin](https://irine-sistiana.gitbook.io/mosdns-wiki/mosdns-v5/ru-he-pei-zhi-mosdns/ke-zhi-xing-cha-jian.md)) —
relevant because AGH in front of it relies on the DO bit for DNSSEC-capable
upstreams.
- License for mihomo/sing-box/knot-resolver marked conservative: GitHub
metadata is "other"/custom or deliberately obfuscated; see §3 caveats.
---
## 3. Per-candidate evaluation
### 3.1 AdGuard Home — advanced upstream routing / built-ins
What it is: Go DNS proxy + adblock + DHCP, LAN DNS front-end
([official](https://adguard-dns.io/kb/adguard-home/overview/)).
Capabilities relevant here (all from the official configuration page):
- **Per-domain upstreams** dnsmasq-style: `[/domain/]upstream`, wildcards,
`#` = "default upstreams", empty `//` = unqualified names
([upstreams for domains](https://adguard-dns.io/kb/adguard-home/configuration/#upstreams-for-domains)).
- **List from file** `upstream_dns_file` — the docs *explicitly* call out China
CDN acceleration via dnsmasq lists, with the `server=/0-100.com/114.114.114.114`
`[/0-100.com/]114.114.114.114` conversion
([loading upstreams from file](https://adguard-dns.io/kb/adguard-home/configuration/#upstreams-from-file)).
- Upstream modes: `load_balance`, `parallel`, `fastest_addr`; plus `fallback_dns`
used only when primary upstreams fail
([config file: dns](https://adguard-dns.io/kb/adguard-home/configuration/)).
- Per-client upstreams (`clients.persistent[].upstreams`), rewrites
(`filtering.rewrites`, incl. wildcard), `local_ptr_upstreams` for private PTR,
ECS (`edns_client_subnet` with `use_custom` coarse prefix), optimistic cache
([same page](https://adguard-dns.io/kb/adguard-home/configuration/)).
- **DNSSEC is DO-bit only**: `enable_dnssec` "defines whether the proxy should
set the DO flag in the upstream requests" — validation must happen upstream
([same page](https://adguard-dns.io/kb/adguard-home/configuration/)).
- DoH/DoT/DoQ/DoH3 serving, `bind_hosts`/ACL guidance
([running securely](https://adguard-dns.io/kb/adguard-home/running-securely/)).
Verdict: **Already installed and capable of the geo-split itself.** The current
setup under-uses it: only a load-balanced CN pair + fallback, no per-domain
routing and no validating upstream. This is the cheapest "better" state — see §5.
### 3.2 mosdns v5 — installed, active as clash nameserver (gateway-side)
What it is: "一个 DNS 转发器" (a DNS forwarder) — plugin-based, sequence-driven
([README](https://github.com/IrineSistiana/mosdns), GPL-3.0, ~3.7k★).
What it does (verified from the v5 wiki and source tree):
- Servers: `udp_server`, `tcp_server` (TLS→DoT), `quic_server`, `http_server`
(DoH); upstreams in `forward` support `udp`, `tcp`, `tls`, `https`, `quic`,
HTTP/3, concurrent racing (`concurrent: n` picks the fastest) and socks5
([server plugins](https://irine-sistiana.gitbook.io/mosdns-wiki/mosdns-v5/ru-he-pei-zhi-mosdns/fu-wu-qi-cha-jian.md),
[executable plugins](https://irine-sistiana.gitbook.io/mosdns-wiki/mosdns-v5/ru-he-pei-zhi-mosdns/ke-zhi-xing-cha-jian.md)).
- Geo-split: v5 data providers are **`domain_set` / `ip_set` (text list files)**
plus `qname`/`resp_ip` matchers — verified from the current source tree
([plugin/data_provider](https://github.com/IrineSistiana/mosdns/tree/main/plugin/data_provider))
— and an `ipset`/`nftset` exec plugin to push answer IPs to kernel sets. The
old v4-style `geosite`/`geoip` `.dat` plugins are **not present** in the v5
tree; the v5 wiki's own matcher page currently states there are no matcher
plugins to document
([matcher page](https://irine-sistiana.gitbook.io/mosdns-wiki/mosdns-v5/ru-he-pei-zhi-mosdns/pi-pei-qi-cha-jian.md)).
Plan on chnlist/gfwlist-style text lists, not `geosite.dat`.
- Cache: yes, incl. optional lazy cache and disk dump; **request EDNS0 is
ignored and response EDNS0 stripped** by the cache plugin
([cache](https://irine-sistiana.gitbook.io/mosdns-wiki/mosdns-v5/ru-he-pei-zhi-mosdns/ke-zhi-xing-cha-jian.md)).
- Private names: `hosts` (domain-rules style, not OS /etc/hosts syntax),
`redirect`, `arbitrary` (zone records), `reverse_lookup` (PTR/HTTP lookup).
- Ops: single binary + YAML; `mosdns service install` ships a systemd/launchd
helper ([v5 overview](https://irine-sistiana.gitbook.io/mosdns-wiki/mosdns-v5.md));
Docker image exists. No web UI of its own.
Verdict: capable splitter/forwarder, but **adds no DNSSEC and no filtering
layer**, and its cache interferes with EDNS0/DO handling. As a *back-end* splitter
behind AGH it is a legitimate choice only if DNSSEC stays off. Given AGH can do
the same per-domain split natively (3.1), mosdns's marginal value here is
concurrent upstream racing and ipset/nftset integration — neither is needed at
this LAN's scale. Either wire it up properly or remove it.
### 3.3 smartdns
What it is: local DNS server that queries multiple upstreams, **speed-tests the
answer IPs and returns the fastest**; DoH/DoT/DoQ/DoH3; GPL-3.0, ~11.2k★
([README](https://github.com/pymumu/smartdns)).
Capabilities (from the official config reference and FAQ):
- Multi upstream + "returns the fastest IP", unlike dnsmasq all-servers
([README](https://github.com/pymumu/smartdns)).
- Domain groups: `server ... -group <name>` + `nameserver /domain/group` routing,
per-`bind` port flags (`-group`, `-no-speed-check`…), client rules/MAC/IP
([config options](https://pymumu.github.io/smartdns/configuration/)).
- Anti-pollution tooling: `bogus-nxdomain` (return NXDOMAIN for poisoned IPs),
`blacklist-ip`, `whitelist-ip`, `ignore-ip`, `ipset`/`nftset` export
([same page](https://pymumu.github.io/smartdns/configuration/)).
- ECS: global `edns-client-subnet` and per-server `-subnet`
([same page](https://pymumu.github.io/smartdns/configuration/)).
- Cache: `cache-size`, `serve-expired` (RFC-like stale), `prefetch-domain`,
persistent cache file ([same page](https://pymumu.github.io/smartdns/configuration/)).
- Private names: `address`, `cname`, `local-domain`, `dnsmasq-lease-file`
([same page](https://pymumu.github.io/smartdns/configuration/)).
- **DNSSEC: no validation option appears anywhere in the official config
reference or FAQ** — its pollution model is blacklist/whitelist + trusted
groups + speed selection, not DNSSEC ([config options](https://pymumu.github.io/smartdns/configuration/),
[FAQ](https://pymumu.github.io/smartdns/faq/)). Flagged: verify on the version
you deploy before relying on it.
Verdict: the classic China-home "best-IP" resolver; good splitter, no DNSSEC,
speed-test model optimizes for latency rather than anti-pollution correctness.
Not better than AGH+China-list for this LAN; at most a back-end splitter behind
AGH, with the same DNSSEC caveat as mosdns.
### 3.4 chinadns-ng / chinadns2 / dnsmasq-china-list
**chinadns-ng** (the requested "china-dns-ng"; actual repo `zfl9/chinadns-ng`,
AGPL-3.0, Zig, ~1.4k★) is the maintained rewrite of shadowsocks/ChinaDNS:
- Two upstream groups (china / trust) + `chnlist.txt` / `gfwlist.txt` domain
lists; domains are tagged `chn`/`gfw`/`none`
([README](https://github.com/zfl9/chinadns-ng)).
- `tag:none` names are queried on **both** upstreams and the china answer is
adopted only if its A/AAAA is a mainland IP (tested against a `chnroute`
ipset/nftset loaded into the kernel); verdict caching avoids re-testing and
leaks ([README: 原理/verdict-cache](https://github.com/zfl9/chinadns-ng)).
- Cache with stale + pre-refresh + optional persistence; DoT upstream
(wolfssl build); `hosts` + `dns-rr-ip` local records; `nftset` add for
chn/gfw IPs; **no DoH by design** and **no DNSSEC** — the author's stated
philosophy is "one job, done well"
([README](https://github.com/zfl9/chinadns-ng)).
- Resource footprint is tiny: ~140 KB baseline, ~2.4 MB with 73k+ chnlist +
5.7k gfwlist entries ([README](https://github.com/zfl9/chinadns-ng)).
**chinadns2** (`zfl9/chinadns2`) is the older C predecessor; effectively
superseded by chinadns-ng for new deployments (README not directly fetched —
treat as legacy line).
**dnsmasq-china-list** (felixonmars, ~6.1k★) is data, not a daemon:
`accelerated-domains.china.conf`, `bogus-nxdomain.china.conf`,
`apple.china.conf`, `google.china.conf`, with generators for **dnsmasq,
unbound, bind, dnscrypt-proxy**
([README](https://github.com/felixonmars/dnsmasq-china-list), WTFPL per repo).
Verdict: chinadns-ng is the strongest *pure splitter* for GFW networks (IP
verdict beats pure list-based routing for unknown domains), but it cannot
validate DNSSEC and brings no filtering UI. As AGH's backend it duplicates what
AGH's per-domain upstreams already do; its IP-test mode requires shipping
`chnroute` ipset/nftset into the host. dnsmasq-china-list is best used as the
**data feed** for the AGH `upstream_dns_file` recommendation in §5.
### 3.5 unbound
What it is: validating, recursive, caching resolver from NLnet Labs
([docs](https://unbound.docs.nlnetlabs.nl/en/latest/)).
- **Real DNSSEC validation by default** (trust anchor, chain of trust); the
official home-network guide turns it on explicitly
([home resolver guide](https://unbound.docs.nlnetlabs.nl/en/latest/use-cases/home-resolver.html)).
- Full recursion → does not hard-depend on any upstream or proxy; serve-expired
(RFC 8767), aggressive NSEC, DoH/DoT/DoQ serving and TLS upstreams,
forward-zone/stub-zone/authority-zone, RPZ filtering, views, ECS module
([docs index](https://unbound.docs.nlnetlabs.nl/en/latest/)).
- Private names: `local-zone`/`local-data` for `*.windy.lan`-style names
([unbound.conf(5)](https://unbound.docs.nlnetlabs.nl/en/latest/manpages/unbound.conf.html)).
- No built-in China split: you assemble it with forward-zones fed by
dnsmasq-china-list (`make unbound` generator) + `bogus-nxdomain`; no UI, no
per-client grouping comparable to AGH.
Verdict: the gold standard for the **validation** half. Best used as (a) the
validating upstream behind AGH for the foreign/trusted path, or (b) a full
recursive resolver replacing the forwarders if you accept losing AGH-style
filtering/UI on top — keep AGH in front for that. System-package based, heavier
to operate than blocky but battle-tested.
### 3.6 blocky
What it is: Go DNS proxy + ad-blocker, "fast and lightweight", single static
binary, stateless, Apache-2.0, ~6.9k★
([README](https://github.com/0xERR0R/blocky)).
- **In-process DNSSEC validation**: `dnssec.validate` with DO bit, RRSIG
verification, chain-of-trust, NSEC/NSEC3, custom trust anchors, SERVFAIL on
bogus ([DNSSEC validation docs](https://0xerr0r.github.io/blocky/latest/configuration/#dnssec-validation)).
- Upstreams: `parallel_best` (2 random resolvers, fastest answer), `strict`,
`random`; per-client/per-subnet upstream **groups**; UDP/TCP/DoT/DoH/DoQ/DoH3;
DNS stamps; bootstrap DNS
([upstreams](https://0xerr0r.github.io/blocky/latest/configuration/#upstreams-configuration)).
- Conditional forwarding + `customDNS` mapping/rewrite (the AGH-rewrite
equivalent), hosts files, per-domain upstream routing
([custom DNS / conditional](https://0xerr0r.github.io/blocky/latest/configuration/#custom-dns)).
- ECS: `ecs.useAsClient` / `ecs.forward`
([ECS](https://0xerr0r.github.io/blocky/latest/configuration/#edns-client-subnet-options)).
- Cache with min/max TTL + **prefetching**; optional **Redis** cache/state sync
between instances; query log to SQLite/Postgres/CSV; Prometheus metrics; REST
API ([README](https://github.com/0xERR0R/blocky),
[config](https://0xerr0r.github.io/blocky/latest/configuration/)).
- No full web admin UI (metrics/REST/logs only) — an ops trade-off vs AGH's UI.
Verdict: the most attractive *new software* option for this LAN **as a backend
behind AGH**: it adds real DNSSEC validation + conditional upstream routing +
ECS with a single binary and YAML. It has no China-IP-verdict split built in —
feed it the China domain list via `conditional.mapping`/upstream groups, which
is fine at this scale. One caveat: no GUI means AGH stays the human-facing
front, so AGH→blocky is strictly additive.
### 3.7 Technitium DNS Server
What it is: self-hosted authoritative **and** recursive DNS server, .NET,
web console, GPL-3.0, ~9.5k★
([README](https://github.com/TechnitiumSoftware/DnsServer)).
- **DNSSEC validation** for recursive resolution, forwarders, and conditional
forwarders (RSA/ECDSA/EdDSA, NSEC/NSEC3); can also *serve* signed zones
([README](https://github.com/TechnitiumSoftware/DnsServer)).
- Conditional forwarder zones + bulk conditional forwarding app; blocked-domain
lists with regex support and per-client variants; split-horizon/geolocation
via DNS Apps; ECS; QNAME minimization
([README](https://github.com/TechnitiumSoftware/DnsServer)).
- Serving side: DoH/DoT/DoQ/DoH3 server, built-in DHCP, persistent cache,
caching with serve-stale/prefetch, clustering, HTTP/SOCKS5 proxy for DNS
(e.g. over Tor) ([README](https://github.com/TechnitiumSoftware/DnsServer)).
- Heavier footprint (needs .NET; Docker image available) and a full web console
with many features this LAN won't use.
Verdict: capable and genuinely feature-rich (a real AGH alternative in the
"everything in one box" sense — filtering, private zones, validation, DHCP), but
it's more moving parts than this LAN needs, and its geo-split still requires
manual conditional-forwarder lists. Not chosen over the lighter AGH+backend
approach.
### 3.8 sing-box / mihomo built-in DNS as the split resolver (fake-ip)
The "third option": let the proxy engine's DNS own resolution, AGH on top.
**sing-box** DNS object: multiple server types (local, udp, tcp, tls, https,
http3, quic, fakeip, hosts, dhcp, mdns…), rule-based server selection by
geoip/geosite, LRU cache + optimistic serving, per-query timeout, `client_subnet`
(ECS), `reverse_mapping`
([sing-box DNS docs](https://sing-box.sagernet.org/configuration/dns/)).
**mihomo** (Clash.Meta lineage; docs at
[wiki.metacubex.one](https://wiki.metacubex.one/en/config/dns/)):
`nameserver-policy` (geosite/rule-set/domain keys) routes specific domains to
specific resolvers; `fallback` + `fallback-filter` (geoip=CN, geosite=gfw,
ipcidr, domain) adjudicate pollution — a CN resolver's answer is adopted only if
the IP is mainland, otherwise the overseas fallback's answer is used;
`fake-ip`/`redir-host` enhanced mode, `fake-ip-filter` with e.g. `'*.lan'` to
keep local names on real-IP; per-DNS-server ECS; cache-algorithm
([mihomo DNS config](https://wiki.metacubex.one/en/config/dns/)).
Assessment for THIS LAN:
- The **pollution adjudication is strong** (geoip-verdict fallback, geosite
lists), and mihomo already runs on the gateway — so "clash DNS as splitter" is
tempting.
- But the DNS service is **coupled to the proxy**: foreign resolution rides the
proxy path, so when OpenClash/subscription is down, fake-ip mapping and
foreign lookups break (partial fail-open only if `direct-nameserver`/fallback
are carefully set). The LAN requirement says **must not hard-depend on the
proxy (fail-open)**.
- fake-ip adds an indirection layer for anything in front of it (AGH on top
resolves client IPs against fake-ip ranges; leaks/loops need careful rules).
- Neither engine **validates DNSSEC** (no RRSIG verification).
- sing-box repo license shows "other" in GitHub metadata (not cleanly
verifiable); mihomo's repo currently carries **deliberately obfuscated content**
("Void Terminal" parody) — treat `wiki.metacubex.one` as the authoritative
docs and expect the GitHub surface to change.
Verdict: keep clash/mihomo DNS exactly where it is (proxy-side, TPROXY/fake-ip),
do **not** make it the LAN resolver of record. If you ever want its IP-verdict
quality outside the proxy, chinadns-ng gives the same idea with zero proxy
dependency.
### 3.9 knot-resolver / dnsdist — power-resolver options
**knot-resolver** (CZ-NIC): minimal caching validating resolver, modular/Lua,
full DNSSEC validation, forwarding over TLS, query policies, RPZ, views/ACLs,
DNS64, persistent cache, serve-stale, even XDP fast-path
([docs](https://knot-resolver.readthedocs.io/en/stable/)). As powerful as
unbound but with more configuration surface (Lua); overkill for a one-operator
home LAN, though it would do the validating-resolver role well.
**dnsdist** (PowerDNS): "highly DNS-, DoS- and abuse-aware loadbalancer" —
routes traffic to backend servers, Lua/YAML config, runtime console, metrics
([overview](https://dnsdist.org/)). It is a **balancer, not a validator/cache**
— it fronts other resolvers. Overkill; only relevant if you wanted a
multi-backend DNS LB, which this LAN does not.
### 3.10 Emerging / also-considered options
- **AdGuard Home + dnsmasq-china-list** — covered in §3.1/§5; this is the
"emerging best practice" for China CDN splits on AGH and is officially
documented.
- **pi-hole** — adblock/dashboard equivalent of AGH but no per-domain upstream
routing worth choosing it over AGH here (not deeply verified for this write-up;
AGH already satisfies the role).
- **dnscrypt-proxy** — encrypted forwarder with stamp support; a transport
option, not a splitter/validator (not deeply verified for this write-up).
- **coredns** — plugin-based; geo-split is DIY via plugins; no DNSSEC
validation by default (not deeply verified for this write-up).
### 3.11 Other popular options (survey supplement, 2026-08-12)
Follow-up survey of additional popular solutions not covered above, evaluated
against this LAN's constraints (fail-open, keep DNS on `.36`, DNSSEC goal).
None of these change the §4/§5 recommendation.
**Encrypted-forwarder micro-tools (AGH downstream options, not replacements):**
- **dnscrypt-proxy** — the classic OpenWrt encrypted forwarder with
China-list support and DNS-stamp routing. No in-process DNSSEC validation and
no filtering UI; overlaps with AGH's own DoH upstream layer, so its marginal
value here is low.
- **dnsproxy** (AdGuardTeam) — lightweight DoH/DoT/DoQ forwarder/server.
Functionally a subset of AGH's upstream layer; only useful if forwarding logic
is deliberately split out of AGH.
- **Stubby** — dnsmasq→stubby→DoT (privacy-community pattern). Pure
forwarding, no split/filter/validation; adopting it alone would be a
downgrade from AGH.
**Managed / cloud DNS (zero-ops, not self-hosted):**
- **NextDNS / ControlD / AdGuard DNS / Cloudflare** — hosted filtering, logs,
per-device policies. This LAN already self-hosts AGH + a private
`adg.chans.xyz` fallback, so a cloud service would be a downgrade in control
(data leaves the LAN). Only realistic use: add one as an extra foreign-path
upstream inside AGH's `upstream_dns_file`.
**Heavier all-in-one resolvers:**
- **PowerDNS Recursor** — real DNSSEC validation + Lua policy, authoritative
and recursive in one. Capable but overlaps unbound; over-provisioned here.
- **BIND9** — classic authoritative/recursive; can validate DNSSEC and, more
interestingly, serve as a local **authoritative zone** that would close the
private-PTR gap. As a LAN resolver it lacks AGH's filtering/UI and is heavier
to operate; a small dnsmasq authoritative zone is a lighter way to achieve the
PTR goal (still deferred until a local authoritative source exists).
- **hickory-dns / trust-dns** (Rust) — emerging recursive resolver, DNSSEC
friendly, smaller ecosystem/ops track record than unbound/blocky; not yet
worth switching for this LAN.
**Popular stack patterns (structure, not new software):**
- **Pi-hole + unbound** — the most common global self-hosted combo
(filtering front-end + validating backend). AGH already occupies the
Pi-hole role here (and does more), so the equivalent is **AGH + unbound/
blocky** — exactly the report's recommendation #2.
- **dnsmasq + china-list + smartdns** (classic OpenWrt trio) — routes the
China list on the gateway itself. Equivalent to co-locating DNS with the
proxy host (`.1`), which violates the fail-open requirement; not recommended
for this LAN.
Verdict: the survey adds no better candidate. dnsproxy/dnscrypt-proxy duplicate
AGH's upstream layer, cloud DNS is a control downgrade, and the only genuinely
new capability (a local authoritative source for PTR) is better served by a
small dnsmasq authoritative zone than by replacing the resolver.
---
## 4. Architecture recommendation for this LAN
### 4.1 Preferred architecture (change is config-only)
```
clients (DHCP option 6 = .36)
│ UDP/TCP :53
AGH .36 (filtering, rewrites, query log, per-client upstreams)
│ upstream_dns_file:
│ [/cn-domain-list/] dns.alidns.com doh.pub ← CN CDN domains (China list)
│ default: https://adg.chans.xyz/dns-query … ← trusted/foreign path
└→ validating resolver (unbound OR blocky) for the foreign path (optional phase 2)
```
- Front = AGH stays the single LAN DNS box (filtering/rewrites/UI/query log
are its strong suit and are already operating).
- Split = AGH per-domain upstreams fed by a converted dnsmasq-china-list; no
new daemon. This is the documented AGH pattern
([upstreams from file](https://adguard-dns.io/kb/adguard-home/configuration/#upstreams-from-file)).
- Validation = add a validating resolver behind AGH for the trusted path
(blocky simplest; unbound most battle-tested) and re-run the known-bad-signature
check that failed before; then flip `enable_dnssec`.
### 4.2 Why not the alternatives as front-ends
- **smartdns / chinadns-ng as the LAN resolver**: they are pure splitters —
no adblock layer, no query log/UI, no DNSSEC. Replacing AGH with either is a
capability downgrade; behind AGH they duplicate AGH's built-in split while
adding a daemon and losing validation. Only chinadns-ng's IP-verdict mode is
genuinely beyond AGH, and it needs kernel ipset/nftset plumbing.
- **mosdns as the AGH backend**: viable splitter, but no validation and its
cache strips EDNS0/DO ([cache plugin](https://irine-sistiana.gitbook.io/mosdns-wiki/mosdns-v5/ru-he-pei-zhi-mosdns/ke-zhi-xing-cha-jian.md)),
which fights the DNSSEC goal. It is already idle on the box — configure it
deliberately or remove it.
- **mihomo/sing-box DNS as the resolver of record**: fail-closed + proxy-coupled
+ no validation. Keep as proxy-side concern (§3.8).
- **knot-resolver / dnsdist / Technitium**: capable but over-provisioned;
Technitium is the only one that would *replace* AGH wholesale, and there's no
benefit worth the migration here.
### 4.3 Deployment location
- **Keep DNS on `dns.windy.lan` (.36)**. It is already the DHCP-advertised
resolver; it is a separate VM from the proxy host; DNS therefore stays
independent of OpenClash state (fail-open), which is an explicit requirement.
- **Do not move it to `gfw` (.1)**: the gateway is where OpenClash injects
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),
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)).
### 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. |
| 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). |
---
## 5. What would make the current AGH + mosdns setup genuinely better
Concrete, in increasing effort:
1. **Implement the China-list geo-split in AGH itself**
(`upstream_dns_file` + converted `accelerated-domains.china.conf`, default
upstreams = trusted foreign path, `fallback_dns` kept). Official AGH docs
describe exactly this pattern
([loading upstreams from file](https://adguard-dns.io/kb/adguard-home/configuration/#upstreams-from-file));
list source: [dnsmasq-china-list](https://github.com/felixonmars/dnsmasq-china-list).
Wire a refresh path (cron/ansible) so the list stays current. Re-test CDN
resolution and the DNSSEC known-bad domain after.
2. **Put a validating resolver on the trusted path** (unbound or blocky), re-run
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)).
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
removed, drop the listener and its config to reduce surface.
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));
don't set it before that source exists
([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
([AGH ECS](https://adguard-dns.io/kb/adguard-home/configuration/)).
If the DNS engineering budget is one afternoon, do #1 + #3. If the goal is
"real DNSSEC or nothing", do #1 + #2 + #3. Replacing the stack is only
justified if you want to abandon AGH's UI/filtering entirely — nothing evaluated
here beats it on that axis for this LAN.
---
## Caveats / not verified
- **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)).
- **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/)).
- **mosdns geosite/geoip**: v5 source tree (fetched 2026-08-12) contains only
`domain_set`/`ip_set` data providers; if a `geosite.dat` plugin exists in a
release branch, it is not in `main`
([plugin/data_provider](https://github.com/IrineSistiana/mosdns/tree/main/plugin/data_provider)).
- **mihomo**: the GitHub repo currently shows deliberately obfuscated metadata
(see §3.8); capabilities cited from
[wiki.metacubex.one](https://wiki.metacubex.one/en/config/dns/).
sing-box/knot-resolver/dnsdist license identifiers via GitHub metadata are
"other"/custom — treat the specific SPDX ids with caution.
- **chinadns2** README was not retrieved (404 on the raw URL); treated as the
legacy predecessor of chinadns-ng and not evaluated in depth.
- Obsidian/personal notes were not consulted; this is upstream-docs-only.
## 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)
+397
View File
@@ -0,0 +1,397 @@
# ER-X → RB5009 网关升级:网络形态与实施计划
**状态:** 规划文档(未采购、未接线、未改生产配置)。
**重要变更(2026-08-09):** **SE5420 已采购**,网络升级改为「保留 ER-X + SE5420 核心」路径——
实施与验证以 [lan-se5420-deployment-guide.md](../lan-se5420-deployment-guide.md) 为准。
本文保留为「ER-X 网关未来替换为 RB5009」的备选方案;其中 PVE 透传调研与 VLAN10 实现方法仍适用。
---
## 1. 一句话架构
```text
公网 ← RB5009(主网关:PPPoE / NAT / 防火墙 / DHCP / IPv6 / VLAN 66+55 三层)
↑ 全部终端 / AP / PVE 直连其 8×1G 端口
(SFP+ 空槽,为未来 10G 核心预留)
```
- **RB5009** 兼任路由与 LAN 二层交换:所有接入设备直插 RB5009 端口。
- 客户端默认网关仍是 **`.254`**RB5009 沿用 ER-X 地址,客户端零感知)。
- **`gfw`** 仍为旁路由(默认网关 `.254`);OpenClash 行为不变。
- **ER-X 升级后下电闲置,作为已配置的备件保存**(回滚路径)。
- 2.5G/10G 同 VLAN 交换目标**放弃**(无 SE5420);LAN 二层为 1G。
---
## 2. 已确认的设计决策
| 决策 | 结论 |
|---|---|
| 角色 | RB5009 单机:路由 + LAN 交换 + 双 VLAN 三层 |
| WAN | **PPPoE**,走 **`ether1`2.5G 铜口接 ONT/光猫)**MTU 1492 |
| LAN 模型 | **单 bridge + VLAN 过滤**VLAN 66(多数端口)+ VLAN 55VLAN 10 预留 |
| 网关地址 | **沿用 `192.168.66.254` / `192.168.55.254`**;重编址另立项目 |
| IPv6 | **保留对等**PPPoE 上 DHCPv6-PD `/60` + 双 LAN SLAAC `/64` + IPv6 防火墙 |
| 端口转发 | 4 条 **原样迁入**hass / transmission / ssh / openvpn+ hairpin NAT |
| LAN55 成员 | **保持现状**AC-Lite、Aqara 及原 LAN55 设备)不迁到 VLAN66 |
| 管理面 | SSH **仅密钥**`zhiqiang`);禁用默认 `admin`**禁 Winbox/API/WebFig** 或仅限 LAN |
| 迁移方式 | 台面预配置(临时 `.253`)→ 维护窗(30–60 分钟)→ 验证 → ER-X 留作备件 |
| 固件/备份 | 当前稳定 RouterOS 7.x;上线前 `.backup` + 文本导出存离线 |
| 时区/NTP | `Asia/Shanghai` + 现有 NTP 服务器 |
| 安全 | PPPoE 口令、SSH 私钥、`zhiqiang` 口令**只存设备上**,不落本仓库 |
---
## 3. 升级后的网络形态(拓扑图)
### 3.1 目标物理拓扑(升级完成后)
```text
Internet
│ PPPoE (ether1 2.5G, MTU 1492, IPv6 PD /60)
┌──────────────────────────────────────────────┐
│ RB5009 (gw) RouterOS 7.x │
│ ether1 = WAN (PPPoE) │
│ bridge + vlan-filtering=yes │
│ vlan66 = 192.168.66.254/24 (pvid 66) │
│ vlan55 = 192.168.55.254/24 (pvid 55) │
│ SFP+ = 空槽(未来 10G
│ SSH-only 管理(仅 LAN
└─────┬─────────┬──────────┬──────────┬────────┘
│ pvid66 │ pvid66 │ pvid66 │ pvid55
│ │ │ │
gfw (.1) dns (.36) ubnt (.46) UAP-AC-Lite (.55.5)
PVE (.26) NAS/PC… U6 Lite Aqara (.55.248)
(.66.6)
```
端口角色以面板为准;下表是建议预留:
| RB5009 端口 | 对端 | 模式 |
|---|---|---|
| `ether1` | ONT/光猫 | WANPPPoE |
| `ether2``ether8` | PVE/gfw、dns、ubnt、U6 Lite、NAS、PC、杂项 | accesspvid 66 |
| `ether9` | 原 LAN55 成员(AC-Lite、Aqara…) | accesspvid 55 |
| SFP+ `sfp-sfpplus1` | 未来 10G 核心 | 空槽 |
> **禁止双上行:** 任一设备不得同时接旧 ER-X 与 RB5009(环路 / 双默认网关)。
### 3.2 逻辑网络
| VLAN | 子网 | 网关 / DHCP | 形态 | 成员 |
|---|---|---|---|---|
| **66** | `192.168.66.0/24` | RB5009 `vlan66` `.254` | 多数端口 access | gfw、dns、ubnt、U6、PVE、NAS、PC… |
| **55** | `192.168.55.0/24` | RB5009 `vlan55` `.254` | `ether9` access | AC-Lite、Aqara…(原 LAN55 全量) |
| 10(预留) | `192.168.10.0/24` | 未来(`gfw`) | 暂不配业务 | 升级专用 SSID(后续项目) |
- 客户端 DNSDHCP 仍下发 **`192.168.66.36`**AdGuard Home)。
- UniFi Inform 仍为 `http://192.168.66.46:9080/inform`
- LAN66 ↔ LAN55 互通由 RB5009 三层转发(沿用现网默认可达语义,防火墙不设隔离边界)。
### 3.3 流量怎么走
```text
同 VLANNAS ↔ PC): 客户端 → RB5009 端口 → bridge(二层交换,1G)
跨 VLAN(66 ↔ 55): 客户端 → RB5009 路由(vlan66 ↔ vlan551G
上网: 客户端 → RB5009 → PPPoE → Internet(限速 = 线路;RB5009 fasttrack 去掉 ER-X ~900M 天花板)
旁路由: 默认网关仍 `.254`;显式代理走 gfw(不变)
未来 VLAN10 U6 trunk → RB5009 →(转发 VLAN10)→ gfw(后续项目)
```
### 3.4 性能预期(诚实边界)
| 场景 | 预期 |
|---|---|
| WAN / PPPoE | 线路速率以内;若线路 >1GRB5009 fasttrack 可跑满(ER-X 受 900M 限制) |
| 同 VLAN 交换 | **1G**(无 SE5420RB5009 8×1G |
| 跨 VLAN | 1G 级三层 |
| SFP+ 空槽 | 不等于已是 10G |
---
## 4. 分阶段实施计划
### 阶段 0:采购前核验
1. 确认 RB5009 revision、保修、零售渠道、RouterOS 当前稳定版本号(建议 7.x 最新 stable)。
2. 确认硬件:1× 2.5G PoE-inether1)、8× 1G、1× SFP+、1GB RAM。SFP+ 模块本次不买。
3. 盘点线缆与对端:ONT ↔ ether1;原 LAN55 设备清单(AC-Lite、Aqara…)及原 ER-X `switch0` 接线。
### 阶段 1:离线初始化(台面)
1. 仅电源 + 隔离管理本。设置身份、时区 `Asia/Shanghai`、NTP。
2.`zhiqiang` 管理用户(**密钥登录,禁密码**);**删除/禁用默认 `admin`**。
3. 建 bridge + VLAN 66/55 + 端口模板(`ether28` pvid66`ether9` pvid55);**临时地址 `.253`**(不与 ER-X `.254` 冲突)。
4. 配 WAN PPPoEether1)、默认路由、NAT masquerade、4 条端口转发 + hairpin、防火墙(input/forward)、MSS clamp、IPv6 PD + SLAAC + IPv6 防火墙。
5. 配 DHCP 池 `.38.243`、24h 租约、DNS `.36`、UniFi Inform 选项、**静态映射全量照搬**。
6. 管理面收口:**禁 Winbox8291/ API8728/ WebFig80/443**SSH 仅限 LAN66/55。
7. `system backup save` + `/export`,导出到离线存储(**不落仓库**)。
**参考 RouterOS 配置骨架**(口令/密钥用占位符;落地前逐一核对):
```text
# 身份 / 时区 / NTP
/system identity set name=gw
/system clock set time-zone-name=Asia/Shanghai
# WAN
/interface ethernet set ether1 name=wan
/interface pppoe-client add name=pppoe0 interface=wan user=<PPPoE_USER> \
password=<PPPoE_PASS> add-default-route=yes use-peer-dns=no \
mtu=1492 mru=1492
# bridge + VLAN
/interface bridge add name=bridge66 vlan-filtering=yes
/interface bridge port add bridge=bridge66 interface=ether2 pvid=66
... # ether3..ether8 同 pvid 66
/interface bridge port add bridge=bridge66 interface=ether9 pvid=55
/interface bridge vlan add bridge=bridge66 vlan-ids=66 tagged=bridge66
/interface bridge vlan add bridge=bridge66 vlan-ids=55 tagged=bridge66
/interface vlan add name=vlan66 interface=bridge66 vlan-id=66
/interface vlan add name=vlan55 interface=bridge66 vlan-id=55
# 地址(台面先用 .253,维护窗切 .254)
/ip address add address=192.168.66.254/24 interface=vlan66
/ip address add address=192.168.55.254/24 interface=vlan55
# NAT masquerade + 端口转发 + hairpin
/ip firewall nat add chain=srcnat out-interface=pppoe0 action=masquerade
/ip firewall nat add chain=dstnat in-interface=pppoe0 protocol=tcp dst-port=8123 \
action=dst-nat to-addresses=192.168.55.11 to-ports=8123
/ip firewall nat add chain=dstnat in-interface=pppoe0 protocol=tcp dst-port=51413 \
action=dst-nat to-addresses=192.168.66.51 to-ports=51413
/ip firewall nat add chain=dstnat in-interface=pppoe0 protocol=udp dst-port=51413 \
action=dst-nat to-addresses=192.168.66.51 to-ports=51413
/ip firewall nat add chain=dstnat in-interface=pppoe0 protocol=tcp dst-port=5822 \
action=dst-nat to-addresses=192.168.66.36 to-ports=22
/ip firewall nat add chain=dstnat in-interface=pppoe0 protocol=udp dst-port=5822 \
action=dst-nat to-addresses=192.168.66.36 to-ports=22
/ip firewall nat add chain=dstnat in-interface=pppoe0 protocol=tcp dst-port=1194 \
action=dst-nat to-addresses=192.168.66.32 to-ports=1194
/ip firewall nat add chain=dstnat in-interface=pppoe0 protocol=udp dst-port=1194 \
action=dst-nat to-addresses=192.168.66.32 to-ports=1194
/ip firewall nat add chain=srcnat connection-nat-state=dstnat action=masquerade # hairpin
# 防火墙(input / forward),顺序很重要:SSH 放行必须早于默认 drop
/ip firewall filter add chain=input connection-state=established,related action=accept
/ip firewall filter add chain=input connection-state=invalid action=drop
/ip firewall filter add chain=input protocol=icmp action=accept
/ip firewall filter add chain=input in-interface=pppoe0 action=drop comment="drop WAN in"
/ip firewall filter add chain=input protocol=tcp dst-port=22 \
src-address=192.168.66.0/24,192.168.55.0/24 action=accept comment="mgmt SSH"
/ip firewall filter add chain=input action=drop comment="drop other input"
/ip firewall filter add chain=forward connection-state=established,related action=fasttrack-connection \
hw-offload=yes
/ip firewall filter add chain=forward connection-state=established,related action=accept
/ip firewall filter add chain=forward connection-state=invalid action=drop
/ip firewall filter add chain=forward in-interface=pppoe0 action=drop comment="drop WAN fwd"
/ip firewall filter add chain=forward action=accept comment="accept LAN fwd"
# MSS clampPPPoE MTU 1492 → 1452ER-X 旧值 1412 偏小,验证后按标准值收敛)
/ip firewall mangle add chain=forward protocol=tcp tcp-flags=syn \
tcp-mss=1400-65535 action=change-mss new-mss=1452 passthrough=yes
# IPv6PD /60 + SLAAC /64
/ipv6 dhcp-client add interface=pppoe0 request=prefix pool-name=pd6 \
pool-prefix-length=60 add-default-route=yes
/ipv6 address add from-pool=pd6 interface=vlan66 address=::1 adverts=yes
/ipv6 address add from-pool=pd6 interface=vlan55 address=::1 adverts=yes
# /ipv6 firewall filter 参照 IPv4established/related accept、invalid drop、
# icmpv6 accept、pppoe0 in drop、默认 dropforward 同构
# DHCP(池 / DNS / Inform / 静态映射全量照搬 ER-X
/ip pool add name=pool66 ranges=192.168.66.38-192.168.66.243
/ip pool add name=pool55 ranges=192.168.55.38-192.168.55.243
/ip dhcp-server add name=dhcp66 interface=vlan66 address-pool=pool66 lease-time=1d
/ip dhcp-server add name=dhcp55 interface=vlan55 address-pool=pool55 lease-time=1d
/ip dhcp-server network add address=192.168.66.0/24 gateway=192.168.66.254 \
dns-server=192.168.66.36
/ip dhcp-server network add address=192.168.55.0/24 gateway=192.168.55.254 \
dns-server=192.168.66.36
# 静态映射:从 ER-X 导出后逐条 /ip dhcp-server lease add ...
# UniFi InformDHCP option 43hex 编码为 http://192.168.66.46:9080/inform),
# 或依赖已 adopt AP 的 set-inform;与现网 ER-X 行为保持一致。
```
### 阶段 2:上台预验证(ER-X 仍在线)
1. 将 RB5009 `ether2` 接入现有 LAN66 网段(管理本同网段),SSH 登录 `.253`
2. 全配置复查:VLAN 表、防火墙规则顺序(SSH 在 drop 前)、NAT、路由、IPv6。
3. **暂不启用 RA/DHCP**(避免与 ER-X 冲突)。预验证 `.253` 可达、SSH 密钥生效、`zhiqiang` 无密码。
4. 确认 ER-X 当前配置**已备份并保存**(作为回滚依据;`show configuration commands` 过滤敏感行)。
### 阶段 3:维护窗切换(约 30–60 分钟)
1. 变更 RB5009 `vlan66`/`vlan55` 地址 `.253 → .254`
2. **下电 ER-X**(保留原接线与配置,不作任何改动)。
3. ONT 线从 ER-X `eth4` 移到 RB5009 `ether1`
4. 观察 PPPoE 拨号:`/interface pppoe-client monitor pppoe0` 直至 `status=established`;确认 WAN IP 与默认路由。
5. 验证清单(见第 5 节),全绿才算完成。
### 阶段 4:上线收口
1. 再确认管理面:禁 Winbox/API/WebFig、SSH 仅密钥、默认 `admin` 已除。
2. 更新固件/补丁至已核定的稳定版;重新导出备份(`.backup` + 文本)存离线。
3. ER-X 下电收纳为**已配置备件**,把其接线与角色记录到 `hosts/gw.md` 备份节。
### 阶段 5(后续,另立项目)
**时序确认:** VLAN10 升级专用 Wi-Fi 是 RB5009 上线**稳定之后**的独立项目,不并入本次维护窗。
- 4 条端口转发是否仍需的审计。
- 网关重编址(`.254 → 其它`)决策。
- **VLAN10 升级专用 SSID → `gfw`(可行性审查,2026-08-09**。
- 迁移后同步本仓库事实文档:`hosts/gw.md``docs/lan-overview.md``inventory/hosts.md``AGENTS.md` 快速地图。
### 阶段 5 附:VLAN10 可行性审查
**结论:** 换 RB5009 后**物理上可行**,但 RB5009 只解决「交换机侧」路径;PVE 宿主机到 `gfw` 虚拟机这一段(此前判定为不可行的关键缺口)仍必须单独打通。
**为什么当前不能在 AP 上启用 VLAN10:**
U6 接的是 ER-X `eth0` 的普通 untagged LAN66 路径,尚无已验证的 VLAN10 端到端二层通道。给 SSID 选择 VLAN10 后,客户端不会获得可由 gfw 服务的预期 VLAN10 网络;帧究竟被丢弃、被设备错误处理,还是 SSID 实际没有打 tag,必须以 AP/交换机/gfw 抓包和配置核验判断。**不得**将“tagged 帧必然自动去标签并泄漏到 LAN66”作为实施前提。
**RB5009 下实现需要 3 个前提:**
1. **RB5009 bridge 增加 VLAN10 转发:**
`/interface bridge vlan add bridge=bridge66 vlan-ids=10 tagged=<U6口>,<PVE口>`
只在这两个口 tagged。**不要**在 RB5009 上给 VLAN10 配 IP / DHCPgfw 才是网关与唯一 DHCP)。
2. **PVE 宿主机路径(必须做,RB5009 解决不了这一段):**
PVE 物理上联口透传 tagged VLAN10`gfw` 虚拟机有一块 VLAN10 可达的网卡
(如 vmbr0 开 `vlan_filtering` + 给 gfw 加第二块 pvid 10 的 NIC,或 VM 内 `eth0.10`)。
这是此前「无法实现」的同一处缺口,需在 PVE/vSwitch 层单独验证。
3. **`gfw` 侧:** VLAN10 网卡 `192.168.10.1/24` + DHCP`192.168.10.0/24`+ 出 66 口
masquerade。RB5009 无需到 `192.168.10.0/24` 的路由(gfw SNAT 后源地址即 66 网段)。
**两个注意点:**
- **单 DHCP 原则:** VLAN10 上唯一 DHCP 是 gfwRB5009 不得在 VLAN10 提供 DHCP。
- **设计确认:** 升级 SSID 客户端走 gfw 网关后,DNS 为 gfw 的 dnsmasq→clash7874),
**不是** AdGuard `.36`——这是「被代理网络」的预期行为,需接受。
### 阶段 5 附:PVE 上 VLAN10 透传实现(调研 2026-08-09
官方 wiki + 多个社区案例支持两种模型,核心原则是 **「一层只拥有一个 tag」**——要么 PVE 拥有
access VLAN,要么 OpenWrt 拥有 trunk**不能在同一张 NIC 上两层都做**。
**方案 A(推荐):PVE 拥有 access VLANgfw 加第二块 virtio 网卡**
1. `vmbr0` 开 vlan-aware`/etc/network/interfaces`):
```text
auto vmbr0
iface vmbr0 inet static
address 192.168.66.26/24
gateway 192.168.66.254
bridge-ports eno1
bridge-stp off
bridge-fd 0
bridge-vlan-aware yes
bridge-vids 10 # 至少含 10;常见默认 2-4094
```
2. gfw VM 添加 `net1: virtio,bridge=vmbr0,tag=10`。VM 内该网卡是**无标记**接口
(已落在 VLAN10 广播域),**不要再建同名 8021q 子接口**。
3. 现有 `eth0`native/untagged = VLAN66)不动,gfw 原有角色不变。
4. PVE 物理上联口(PVE→RB5009)改为 **trunknative 66 + tagged 10**。
5. OpenWrt 内:新网卡 `192.168.10.1/24` + DHCP`192.168.10.0/24`+ 独立 firewall zone→wan masq。
**方案 BOpenWrt 拥有 trunk(单 NIC 多 VLAN**
- `vmbr0` vlan-awareVM NIC **不加 tag**trunk 原样进 VMOpenWrt 内建 `8021q` 设备
`eth0.10`x86/virtio 用 `option type '8021q'`,不要套用 DSA 教程)。物理上联 trunk。
- 更灵活(一块网卡多 VLAN),是 router VM 的常见做法,但需要 OpenWrt 8021q 配置,
且方案 A 对现有单网卡 gfw 改动更小。
**RB5009 侧配套(U6 口 + PVE 口都做成 trunk):**
```text
/interface bridge port add bridge=bridge66 interface=<U6口> pvid=66
/interface bridge port add bridge=bridge66 interface=<PVE口> pvid=66
/interface bridge vlan add bridge=bridge66 vlan-ids=66 tagged=bridge66 untagged=<U6口>,<PVE口>,<其余66口>
/interface bridge vlan add bridge=bridge66 vlan-ids=55 tagged=bridge66 untagged=<ether9>
/interface bridge vlan add bridge=bridge66 vlan-ids=10 tagged=<U6口>,<PVE口>
```
VLAN10 在 RB5009 上**纯二层桥接**U6 ↔ PVE),三层由 gfw 承担;RB5009 无 VLAN10 IP/DHCP。
**常见坑(社区高复发):**
- **双标签:** PVE 设了 `tag=10` 又在 OpenWrt 里建 `eth1.10` → 一帧被两层改两次。
- **native VLAN 不一致:** trunk 上无标记帧被两端当成不同 VLAN → DHCP 消失 / 拿到错网段
(正是你之前在 AP 上打 VLAN10 坏 66 网的同类故障)。
- **bridge 未 vlan-aware** tagged 帧进 host 后在 bridge 过滤层消失。
**诊断命令:**
```bash
# PVE host
bridge vlan show
ip -br link
tcpdump -eni <上联口> # 帧是否到物理口
tcpdump -eni vmtapXXXXXX # 帧是否到 VM tap
# OpenWrt guest
ip -d link show
logread -e netifd
```
**参考案例:**
- PVE 官方 wiki — Network Configuration / VLAN 802.1Q(三种模式 + vlan-aware bridge):
<https://pve.proxmox.com/wiki/Network_Configuration>
- 「OpenWrt VM on Proxmox」设计(trunk vs access 谁拥有 tag、双标签坑):
<https://phb-crystal-ball.org/run-openwrt-in-proxmox/>
- PVE 论坛「Tagged and Untagged VLAN」(`bridge-vlan-aware yes` + `bridge-vids` 解法):
<https://forum.proxmox.com/threads/tagged-and-untagged-vlan-configuration.144421/>
- OpenWrt 论坛 guest WiFi tagged VLAN 案例(`vmbr0.3`→VM 第三网卡;guest 拿到错误网段的
同型故障,最终归因在 PVE/host 侧):<https://forum.openwrt.org/t/continued-x86-openwrt-proxmox-vlan-issues/182623>
> **PVE host 改动风险:** 给 `vmbr0` 开 vlan-aware 是对宿主机网络栈的修改,有管理面断连风险;
> 需在维护窗内用控制台/独立带外通道进行,先 `ifreload -a`PVE7+ 的 ifupdown2 支持热应用),
> 保留原配置作回滚。
---
## 5. 验收清单(「网络算正常」的样子)
1. 管理面:`zhiqiang` 密钥 SSH 可从 LAN66/55 登录;默认 `admin` 禁用;Winbox/API 不可达;`.254` 管理可达。
2. VLAN:客户端取得正确网段(`.66.x` / `.55.x`),默认网关 `.254`DNS `.36` 可用。
3. 互联网:PPPoE 已建立;IPv4 外网通;端口转发逐条从公网验证(hass 8123、transmission 51413、ssh 5822→.36:22、openvpn 1194)。
4. IPv6:两 VLAN 拿到 SLAAC `/64`,默认路由存在,外部 IPv6 可达;IPv6 防火墙未阻断必要 ICMPv6/DHCPv6。
5. 本地服务:跨 VLAN`.55.x` ↔ `.66.x`)互通;AdGuard Home、UniFi 控制器、`gfw` 旁路由行为与升级前一致。
6. UniFiU6 Lite`.66.6`)与 UAP-AC-Lite`.55.5`)在控制器显示 **Connected**Inform 未变 `:9080`。
7. 无环路、无双默认网关;端口协商与错误计数正常。
8. 备份(`.backup` + 文本导出)已离线保存;ER-X 已下电收纳。
---
## 6. 回滚语义
任一阶段失败:**停手**。
- **维护窗内失败(PPPoE 未起 / 客户端不通 / 防火墙锁死):**
1. 下电 RB5009。
2. ONT 线插回 ER-X `eth4`。
3. 给 ER-X 上电 → 服务在数分钟内恢复。
4. 不要在故障中改 ER-X 的 WAN、DHCP、网关地址或 SSH 策略。
- **维护窗成功后** ER-X 只是备件;后续 VLAN10 失败只撤 SSID/VLAN 绑定,主 SSID 与 `.254` 路径不动。
---
## 7. 安全与记录
- **本仓库永不记录**:PPPoE 口令、`zhiqiang` 口令、SSH 私钥、RouterOS 备份(含口令/密钥)。
- 每次实质性变更(切换、回滚、加固)完成后,在 Linear **`vps` 项目**记录 scope / action / verification / 遗留 follow-up(本次先不建 issue,待执行时补)。
- SSH 与访问策略变更遵循仓库「SSH access safety」流程:ER-X 会话保持为回滚路径,新密钥登录验证成功前不关闭旧通道。
---
## 8. 参考
- 现网地图:[lan-overview.md](../lan-overview.md)
- ER-X 现状:[edgerouter-x-configuration.md](../edgerouter-x-configuration.md)、[hosts/gw.md](../../hosts/gw.md)
- UniFi[unifi-network.md](../unifi-network.md)、[hosts/ubnt.md](../../hosts/ubnt.md)
- `gfw`[hosts/gfw.windy.lan.md](../../hosts/gfw.windy.lan.md)
- 作废方案(**不再实施**):[lan-erx-se5420-network.md](../lan-erx-se5420-network.md)、[lan-core-switch-upgrade-plan.md](lan-core-switch-upgrade-plan.md)
- MikroTik RB5009 官方:<https://mikrotik.com/product/rb5009ug_s_in>、RouterOS v7 手册
@@ -0,0 +1,74 @@
# SE5420 实施评审主张核实(2026-08-10
> **核对基准(历史快照):** 本文于 2026-08-10 针对 [lan-se5420-deployment-guide.md](../lan-se5420-deployment-guide.md) 的**评审前版本**`35577d0`)撰写。该指南自 `ffb37a9`"finalize SE5420 deployment guide per review")起已按本评审修订,当前 `origin/main` 章节已重组:旧 §3.3 → §4.3、旧 §6(gfw)→ §11、旧 §7(SSID)→ §12、旧 §9(验收/IPv6)→ §13 + §11.4。文末「当前指南处理情况」列出各主张的现行状态;实施以部署指南现行为准。
**范围。** 本文核对对 `lan-se5420-deployment-guide.md` 的评审意见。结论分为
“已证实”(规范/一手资料直接支持)、“基本证实”(架构推论成立但仍须读取现场配置)和
“需现场核实”(不能仅由文档或产品手册断言)。这不是实施变更,也不替代维护窗前的
`uci show firewall`、交换机当前 VLAN 表和 PVE bridge 配置检查。
## 核实结论
| 评审主张 | 结论 | 依据与限定 |
| --- | --- | --- |
| `firewall.ubunt_upg.masq=1` 是错误方向,应在实际出站的 `wan` zone 做 IPv4 NAT | **已证实** | OpenWrt 明确规定 masquerade 是**按出站 zone/interface**控制;`masq` 通常在 `wan`。因此,对 `ubunt_upg → wan` 流量把 `masq` 放在源 zone 不是该需求的正确 zone 语义。若 `wan` 已 masq,不应重复开启;也可用 `masq_src` 只限 `192.168.10.0/24`。见 [OpenWrt firewall configuration](https://openwrt.org/docs/guide-user/firewall/firewall_configuration) 和 [fw4 masq 测试](https://lxr.openwrt.org/source/firewall4/tests/02_zones/02_masq)。 |
| `ubunt_upg → wan` 允许所有经 gfw `wan` 可路由的目的地,不等于只上互联网 | **已证实** | `forwarding``src`/`dest` 是 zone-to-zone 单向许可,未按“Internet”语义区分目标 IP;规则可用 `dest_ip` 限制。故若 gfw 的 `wan` 接在 LAN66 且 ER-X 可路由 LAN55,评审所列 LAN66/LAN55 风险成立。最终可达网段仍须以 gfw 路由表、ER-X 路由/防火墙现场检查为准。见 [OpenWrt forwarding/rule 参考](https://openwrt.org/docs/guide-user/firewall/firewall_configuration)。 |
| 不应把 `ubunt_upg.forward` 改为 `ACCEPT``forward_policy` 不是必要的标准 zone 选项;匿名 `uci add` 不可重复执行 | **基本证实** | OpenWrt zone 的标准项是 `forward`forwarding 是独立 section,参考页未定义 `forward_policy`。单独的具名 forwarding 足以允许跨 zone 路径,因此保持 zone 内 `forward=REJECT` 是较小权限配置。匿名 section 每执行一次都会新增一节,这是 UCI 的操作语义;实施应先读现场配置并使用具名 section。 |
| VLAN10 必须有显式 IPv6 策略,否则可能绕过仅 IPv4 的 NAT/隔离 | **已证实** | fw4 将 `masq`IPv4)和 `masq6`IPv6)分开;forwarding 默认 family 是 `any`。仅写 IPv4 DHCP/NAT/地址规则不能表达 VLAN10 的 IPv6 RA、DHCPv6、路由和过滤策略。是否已经存在可用 IPv6 前缀、以及 OpenClash 是否接管 IPv6,必须现场验证。见 [OpenWrt firewall configuration](https://openwrt.org/docs/guide-user/firewall/firewall_configuration)。 |
| 管理 SVI + 默认路由与“不开 SVI/静态路由/一切 L3”矛盾 | **已证实** | 指南(历史版)§3.3 同时要求 VLAN66 `192.168.66.253/24` 与默认路由,又要求不开 SVI/静态路由。TL-SE5420 官方称其为三层交换机,支持静态路由、RIP、DHCP server/relay。准确目标应是:仅保留 VLAN66 管理 L3 interface/默认网关,不给 VLAN55/10 建 L3 interface,且禁用不需要的 L3 服务和跨 VLAN routing。见 [TL-SE5420 官方页](https://www.tp-link.com.cn/product_2899.html?v=specification) 与 [官方安装手册](https://service.tp-link.com.cn/download/202310/TL-SE5420%20V1.0%E5%AE%89%E8%A3%85%E6%89%8B%E5%86%8C%201.0.2.pdf)。 |
| 必须明确移除 VLAN1 成员,PVID 变更本身不等于 access-port VLAN membership | **已证实** | PVID/native VLAN 只处理进入端口的未标记帧;access/trunk 的允许 VLAN 列表是独立概念。指南(历史版)§3.3 只列 VLAN66/55 member 和 PVID,未写移除 VLAN1 或 ingress filtering。验收应检查 VLAN1 member、VLAN1 管理 IP、端口允许 VLAN 和 tagged-frame ingress policy。见 [Ubiquiti 对 native/tagged/access/trunk 的定义](https://help.ui.com/hc/en-us/articles/26136855808919-Switch-Port-VLAN-Assignment-Trunk-Access-Ports)(术语与 802.1Q 语义)以及 [Linux bridge VLAN 配置示例](https://www.kernel.org/doc/html/v5.19/networking/dsa/b53.html)(显式 `bridge vlan del ... vid 1`)。TL-SE5420 具体 GUI/CLI 行为仍以其固件手册核验。 |
| NAS 不应在未先完成双端 LACP 时同时接两口;LACP 不使单 TCP 流自动达到 5G | **基本证实** | 这是标准二层环路/聚合变更控制结论:没有已协商的 LAG 时,两条同 VLAN 并行链路会构成潜在环路;STP 只能作为保护而非实施方法。官方产品页列出 LACP 相关资料,但本次未取得 TL-SE5420/TrueNAS 对端的精确配置与当前 NAS 连接状态,故“必然环路/双 IP”不能在桌面审阅中断言。单连接吞吐受链路散列限制是 802.3ad 的常见实现特性,应以 NAS 与交换机的 hash policy 和 `iperf3` 实测验收。 |
| 非 VLAN-aware 的 PVE `vmbr0` 不提供 VLAN10 的端口级隔离 | **已证实** | PVE 将 bridge 描述为虚拟交换机;VLAN-aware mode 才能给 guest NIC 赋 VLAN tag,或显式 trunk。Linux 内核说明:`vlan_filtering=0` 时 bridge 不考虑 VLAN tag,且默认关闭;开启后才按 MAC **和 VLAN tag**转发及进行严格 VID 检查。因此“共享非 VLAN-aware bridge 可让可控 guest 主动消费 VLAN10,不能作为严格隔离边界”成立。不能仅凭该结论断言每个 guest 必定收到每个单播帧:未知单播/广播会泛洪,已学习的单播会按 FDB 转发。见 [PVE 网络配置](https://pve.proxmox.com/wiki/Network_Configuration) 和 [Linux bridge 文档](https://docs.kernel.org/networking/switchdev.html)。 |
| AP VLAN10 tagged frame 在一个普通 untagged LAN66 access path 上会“自动去 tag 并泄漏到 LAN66” | **不成立/需改写** | 802.1Q 的 native VLAN 是对**未标记**流量的 VLANtagged VLAN 需被显式允许于 trunk。因此通常的正确表述是:若上游不允许 VLAN10 tagAP 到 VLAN10 网关/DHCP 的路径不存在,SSID 会成为不可用入口。实际设备的端口模式(包括是否错误地配置为 all/trunk、是否接受 tagged ingress)须现场查看,不能泛称必然去标签。见 [Ubiquiti VLAN 端口定义](https://help.ui.com/hc/en-us/articles/26136855808919-Switch-Port-VLAN-Assignment-Trunk-Access-Ports) 和 [Ubiquiti VLAN troubleshooting](https://help.ui.com/hc/en-us/articles/9592924981911-Virtual-Network-VLAN-Troubleshooting)。 |
| 仅保留 SSH 会话不是移动 PVE/ER-X 物理上联时的真正带外回滚路径;应全程保持 SE5420 Console | **已证实** | 这是直接的操作依赖判断:TCP SSH 的承载链路被拔除时会断,不能证明回滚可达。TL-SE5420 官方安装手册确认该机有 Type-C Console,且本仓库指南本身也把恢复出厂流程建立在 Console 上。故应在迁移前接通 Console、标注旧/新端口、逐根迁移并用 MAC 表与链路/错误计数验证。见 [官方安装手册](https://service.tp-link.com.cn/download/202310/TL-SE5420%20V1.0%E5%AE%89%E8%A3%85%E6%89%8B%E5%86%8C%201.0.2.pdf)。 |
| “所有设备均不得直连 ER-X”不是避免环路的必要条件 | **已证实** | 环路取决于同一 L2 广播域存在多条并行二层路径,不取决于是否还有一个独立终端直接接 ER-X。应禁止的是一个下级交换机/桥接主机同时形成平行路径。此项仍需以 ER-X switch0 VLAN/bridge 现场配置和实际接线图确认。 |
| 性能不应承诺全面 2.5G;同 VLAN 才可能在 SE5420 本地交换超 1G,跨 55/66 与 Internet 受 ER-X/宽带限制 | **已证实** | TL-SE5420 的 2.5G 端口仅提高经其本地二层转发的链路上限;跨子网必须由网关路由,Internet 另受 WAN/PPPoE 约束。产品页确认 16×2.5G + 4×10G SFP+,但 ER-X、NAS、PC、AP 的实际协商速率和 NIC/布线能力必须由 `ethtool`/端口状态及 `iperf3` 验证。见 [TL-SE5420 官方规格](https://www.tp-link.com.cn/product_2899.html?v=specification)。 |
## 已核对的文档内事实
现行指南的**历史版本**`35577d0`)确实包含评审指出的关键文字:旧 §3.3 的管理 IP/默认路由与“不开 SVI/静态路由”;旧 §6 的 `ubunt_upg.masq``forward=ACCEPT``forward_policy`、匿名 forwarding;旧 §7 只验“不可达 LAN66”;旧 §9 对新增 VLAN10 写“IPv6 行为与升级前一致”。因此上述评审不是对未出现内容的假设。
但这些内容在 `ffb37a9` 起的修订中已被修正或重组:`ubunt_upg.masq``forward_policy` 已删除,gfw 防火墙改为 §11(§11.3 第 2 步明确“不要给 `ubunt_upg` zone 加 masq”);VLAN10 IPv6 在 §11.4 显式写为“本阶段不提供”;SVI/L3 边界在 §4.3 第 16 步单列“L3 明确边界检查”。本文按历史快照保留评审结论,读者应以现行部署指南为准。
本仓库的 `hosts/gfw.windy.lan.md` 还记录 gfw 的 `eth0` 在 LAN66、`eth1` 在 LAN55,故 `ubunt_upg → wan` 的隔离结论应在执行前以当前 `ip route``uci show firewall``nft list ruleset` 复核,而不能从方案文字直接把规则写死。
## gfw 现场只读复核(2026-08-10
已通过 `ssh -4 root@192.168.66.1` 仅读取配置和运行规则,未修改设备。该结果会改变
评审中两项“当前状态”的表述:
| 现场事实 | 对评审的影响 |
| --- | --- |
| `wan` zone 已有 `masq='1'`;现有配置另有具名 `ubunt_upg_nat`,运行时渲染为 `oifname "eth0"` 且只匹配 `ip saddr 192.168.10.0/24 masquerade`。 | “必须在 wan 开 masq”的**方向原则**正确,但“当前无 masq”不正确。现有显式 SNAT 已在实际出 `eth0` 时执行;计划中再将 `masq` 加到 `ubunt_upg` 仍是多余且方向错误。 |
| 当前放行是具名 `ubunt_upg_to_lan`,不是 `ubunt_upg→wan`;其运行链先拒绝 `192.168.66.0/24`,再允许到 `lan`。gfw 的 IPv4 default route 是 `192.168.66.254`。 | 计划新增 `ubunt_upg→wan` 会是与当前设计不同、过宽的改动。现有 LAN66 阻断规则在该链中先匹配;但对经 ER-X 可达的 LAN55/其他内网仍没有显式拒绝,故隔离评审的**剩余风险成立**。应以明确内网前缀 deny + 所需外网 allow 重写,而不是加 WAN forwarding。 |
| `ubunt_upg` DHCPv6 和 RA 都是 `disabled`;运行路由表仅有各接口的 IPv6 link-local route,没有 IPv6 default route;全局 IPv6 forwarding 是 `1`。 | 评审“VLAN10 未明确 IPv6 策略”的表述对计划文本仍成立,但“IPv6 可能立即绕过”的事实判断在当前状态**未获证实**:现有 RA/DHCPv6 已关闭且无 IPv6 默认路由。实施文档仍应把这项显式写为“IPv6 不提供”,并在启用前复查。 |
| 系统是 ImmortalWrt **25.12.0**`/usr/bin/apk` 存在(apk-tools 3.0.5)。 | 评审中“ImmortalWrt 21.02.5 应使用 opkg”的版本判断错误/过时;在本机上 `apk add tcpdump` 是可用包管理器。仍应先检查软件包可用性,避免在维护文档中把两种命令并列为未经验证的替代方案。 |
这些命令输出未含凭据、令牌或私钥,故仅记录了安全相关的摘要;不将完整防火墙快照提交至仓库。
## 当前指南处理情况(2026-08-13 核对)
`origin/main``2fd354c`)逐项核对评审主张:
| 评审主张 | 现行状态 | 现行位置 |
| --- | --- | --- |
| `ubunt_upg.masq=1` 方向错误 | ✅ 已修复 | §11.3 第 2 步「不要给 `ubunt_upg` zone 加 masq」 |
| `ubunt_upg→wan` 不等于只上互联网 | ⚠️ 原则成立,指南已禁止新增宽泛 forwardingLAN55/RFC1918 显式 deny 仍为待办 | §11.1b「待补缺口」、§11.3 |
| 勿改 `forward=ACCEPT`;无 `forward_policy`;匿名 uci 不可重复 | ✅ 已修复 | §11.3 第 1 步保持 REJECT;指南已无 `forward_policy` |
| VLAN10 须显式 IPv6 策略 | ✅ 已修复 | §11.4「本阶段不提供 VLAN10 IPv6」 |
| 管理 SVI + 默认路由 vs「不开一切 L3」矛盾 | ✅ 已消解 | §4.3 第 16 步「L3 明确边界检查」 |
| 须移除 VLAN1 成员;PVID≠membership | ✅ 指南已加强;现网仍偏离(W1N-54) | §4.3 第 912 步;VLAN1 不可删说明 |
| NAS 双口未 LACP 前勿并行 | ✅ 已体现 | §7 第 7 步「仅口 8,口 12 断开」 |
| 非 VLAN-aware PVE bridge 不能作隔离边界 | ✅ 已体现 | §9.2 要求 VLAN-aware + `bridge-vids` |
| AP tagged 帧在 access 口自动去 tag | ✅ 本文已纠正(不成立) | — |
| SSH 非真正带外;须 Console | ✅ 已体现 | 开头第 2 条、§4.1、§16 |
| 「所有设备不得直连 ER-X」非必要 | ✅ 已体现 | 全程三条第 1 条、§7 |
| 不应承诺全面 2.5G | ✅ 已体现 | §8 第 7 条、§14 |
## 实施前的最低限度现场证据
1. gfw:保存并审阅 `uci show firewall``ip route``ip -6 route``nft list ruleset`;确认 wan 的 masq 与所有 WAN→内网、VLAN10→内网匹配次序。
2. SE5420 Console:导出/截图 VLAN1、55、66 member 和 PVID/ingress-filter 状态;确认唯一管理 L3 interface 和路由/relay/DHCP 状态。
3. PVE:记录 `/etc/network/interfaces`、VM NIC VLAN tags 和 `bridge vlan show`,再决定是否把 VLAN-aware 改造另开窗口。
4. AP:从实际设备 `info` 或控制器记录确认 Inform URL(本仓库目前记录 `http://192.168.66.46:9080/inform`),并验证 VLAN10 tag 只经 U6/PVE trunk。
5. NAS:单网口稳定后,另窗配置并验证两端 LACP,第二根线最后插入;用多流及单流 `iperf3` 分开验收。
+120
View File
@@ -0,0 +1,120 @@
# EdgeRouter X configuration record
This is a read-only, sanitized configuration record for
[gw](../hosts/gw.md), taken on 2026-08-05. It intentionally excludes PPPoE
credentials, user/password configuration, key material, and full DHCP client
or reservation inventories.
## Platform and topology
| Item | Current configuration |
|---|---|
| Appliance / OS | EdgeRouter X 5-Port; EdgeOS `v3.0.1` build `5862409` |
| WAN physical interface | `eth4`, described as `Internet (PPPoE)` |
| WAN routed interface | `pppoe0`; dynamic IPv4 default route; MTU `1492` |
| LAN66 | `eth0`, `192.168.66.254/24` |
| LAN55 | `switch0`, `192.168.55.254/24`; member ports `eth1``eth3` |
| IPv4 routing | Both LAN prefixes are directly connected; default route is through `pppoe0` |
Despite its name, LAN55 is not currently an 802.1Q VLAN configuration:
`switch0` has `vlan-aware` disabled. It is a separate Layer-3 subnet carried
by the built-in switch ports, while LAN66 is connected through `eth0`.
## IPv6
The PPPoE uplink requests a delegated `/60`, enables rapid commit and assigns
SLAAC-capable `/64` networks to the LANs:
| Interface | Delegated prefix slot | Router address suffix |
|---|---|---|
| `eth0` (LAN66) | `:1` | `::1` |
| `switch0` (LAN55) | `:2` | `::1` |
IPv6 has a default route through `pppoe0`. The currently delegated prefixes
are dynamic and are deliberately not treated as persistent configuration
facts.
## Effective firewall policy
| Ruleset | Direction / attachment | Effective policy |
|---|---|---|
| `WAN_IN` | `pppoe0`, IPv4 `in` | Active; default drop; permits established/related, drops invalid |
| `WAN_LOCAL` | `pppoe0`, IPv4 `local` | Active; default drop; permits established/related, drops invalid |
| `WANv6_IN` | `pppoe0`, IPv6 `in` | Active; default drop; permits established/related and ICMPv6; drops invalid |
| `WANv6_LOCAL` | `pppoe0`, IPv6 `local` | Active; default drop; permits established/related, ICMPv6, and DHCPv6 replies; drops invalid |
| `LAN_IN` | None | Inactive; configured default drop with explicit LAN55 ↔ LAN66 allows |
| `LAN_OUT` | None | Inactive; configured default drop with an Internet allow |
Therefore, LAN55 and LAN66 currently have ordinary bidirectional routed
access. The configured `LAN_IN`/`LAN_OUT` rules do **not** enforce an access
boundary because neither is bound to an interface. From source
`192.168.55.254`, the controller at `192.168.66.46` replied to 3/3 ICMP probes
on 2026-08-05. The online LAN55 AP also confirms the operational Inform path
to `192.168.66.46:9080`.
Global firewall settings enable normal ICMP echo replies, disable broadcast
ping and IPv4/IPv6 source routing, enable SYN cookies and martian logging, set
an MSS clamp of `1412`, and leave source validation disabled. Receive redirects
are disabled; IPv4 send redirects remain enabled.
The [official EdgeOS User Guide](https://dl.ubnt.com/guides/edgemax/EdgeOS_UG.pdf)
describes the distinction: a firewall policy is a ruleset that must be applied
to its target interface and direction before it affects traffic.
## NAT and inbound exposure
IPv4 traffic leaving `pppoe0` is covered by NAT masquerade rule `5010`.
Port-forward auto-firewall and hairpin NAT are enabled. The configured WAN
forwards are below; their external reachability was not tested in this review.
| WAN protocol / port | Internal destination | Purpose |
|---|---|---|
| TCP/UDP `8123` | `192.168.55.11:8123` | Home Assistant |
| TCP/UDP `51413` | `192.168.66.51:51413` | Transmission |
| TCP/UDP `5822` | `192.168.66.36:22` | SSH forward |
| TCP/UDP `1194` | `192.168.66.32:1194` | OpenVPN |
Treat every forward as an intentional public attack surface. Changes require
the normal firewall/change review and external verification from a trusted
network.
## LAN services
| Service | LAN66 | LAN55 |
|---|---|---|
| DHCP pool | `.38``.243` | `.38``.243` |
| Default gateway | `.254` | `.254` |
| DNS server handed out | `192.168.66.36` | `192.168.66.36` |
| Lease duration | 24 hours | 24 hours |
| UniFi Controller DHCP option | `192.168.66.46` | `192.168.66.46` |
DNS forwarding listens on `eth0` and `switch0` with a cache size of `512`.
Management services are SSH/TCP `22`, HTTP/TCP `80`, and HTTPS/TCP `443`.
Since no active LAN-local firewall is documented, treat access to this gateway
management plane from either LAN as permitted unless verified otherwise.
Infrastructure devices, including the two APs and the UniFi controller, have
DHCP static mappings. Keep the authoritative full mapping list only in the
gateway configuration; do not copy unrelated household device inventories into
this operations repository.
## Acceleration and maintenance implications
At verification time, `show ubnt offload` reported that both IPSec and HWNAT
offload modules were not loaded. No `system offload` enablement was present in
the inspected configuration. This may constrain routing/NAT throughput under
load; it is an observation, not authorization to alter the router.
Use these safe effective-state checks after any planned change:
```bash
show interfaces
show ip route
show ipv6 route
show firewall
show nat rules
show ubnt offload
```
Avoid recording raw `show configuration commands` output: it includes PPPoE
credentials and may include other secrets.
+345
View File
@@ -0,0 +1,345 @@
# Home Assistant × Matrix integration
Reference for wiring the Home Assistant [Matrix integration](https://www.home-assistant.io/integrations/matrix)
to the self-hosted Matrix homeserver at [`synapse.chans.xyz`](../hosts/synapse.chans.xyz.md).
Deliberately contains no Matrix passwords, access tokens, or room encryption material.
> **Status (2026-08-15, W1N-139):** the built-in `matrix` integration has been
> **retired** on `hass.windy.lan` and replaced by the custom **`matrix_e2ee`**
> integration. The sections below on the built-in integration are kept for
> reference only. See [matrix_e2ee](#matrix-e2ee-custom-e2e-integration) for the
> active setup and [Device verification (SAS) model](#device-verification-sas-model)
> for how device trust works.
## Purpose
The integration lets Home Assistant send messages to Matrix rooms and react to
messages/reactions in Matrix rooms. "Reacting" is done by firing a
`matrix_command` event when one of the configured commands matches; automations
then trigger on that event. Sending is done through the `notify.matrix` platform
and the `matrix.send_message` / `matrix.react` actions.
## Environment mapping
| Integration setting | This deployment |
|---|---|
| `homeserver` | `https://synapse.chans.xyz` (client-server base URL) |
| `username` | full Matrix ID, e.g. `@ha_bot:chans.xyz` |
| `password` | MAS local-password account password (see below) |
| Room IDs / aliases | full forms with the identity domain, e.g. `!cUrbafjkfsMDVwdRDQ:chans.xyz` or `#room:chans.xyz` |
- Identity domain is `chans.xyz` (not `synapse.chans.xyz`); user IDs and room
aliases carry the `:chans.xyz` suffix.
- Authentication on this homeserver is MAS (Matrix Authentication Service) with
local-password accounts. The integration logs in with `m.login.password`
(username + password), so the bot account must be a local-password account —
same as the Hermes account documented in [`hermes-matrix.md`](hermes-matrix.md).
If MAS is later switched to OAuth2/OIDC-only (no legacy password login), the
integration's password login will stop working; keep that in mind before such
a change.
- Public registration is disabled. Create/reset the dedicated bot account via
MAS / Element Admin.
## Use a separate bot account (mandatory)
The docs are explicit: to prevent infinite loops when reacting to commands,
the integration **must** use a separate account from any account whose messages
it reacts to. Use a dedicated account such as `@ha_bot:chans.xyz`, not a human
account.
## configuration.yaml (example)
```yaml
# The Matrix integration
matrix:
homeserver: https://synapse.chans.xyz
username: "@ha_bot:chans.xyz"
password: supersecurepassword
rooms:
- "#hasstest:chans.xyz"
commands:
- word: my_command
name: my_command
```
After changing `configuration.yaml`, restart Home Assistant to apply the
changes. The integration then shows under **Settings → Devices & services**;
its entities are on the integration card and the Entities tab.
### Configuration variables
| Variable | Meaning |
|---|---|
| `username` | Full Matrix ID the bot logs in as, e.g. `@ha_bot:chans.xyz`. The `@` has a special YAML meaning, so always quote it. |
| `password` | The bot account's password (MAS local password). |
| `homeserver` | Full client-server URL of the homeserver. |
| `rooms` | Rooms the bot should join and listen in. List **all** rooms commands are to be received in, even if a command scopes itself to fewer rooms. Accepts internal room ID (`!…:chans.xyz`) or alias (`#room:chans.xyz`). |
| `commands` | Commands to listen for. Each fires a `matrix_command` event when triggered. |
### Command types
| Key | Triggers when |
|---|---|
| `word` | A message starts with `!<word>`. Arguments after the word are captured as a list in the event's `data`. |
| `expression` | A message matches the Python regexp. The regexp group dictionary is captured in the event's `data`. |
| `reaction` | A message is reacted to with the given emoji. |
| `name` | The command name, exposed as an attribute of the fired event. |
A command can be scoped to specific rooms with a per-command `rooms` list (the
room must still be listed under the top-level `rooms`).
## Event data
When a command triggers, a `matrix_command` event fires with:
- `name` — the command name.
- `data` — for `word` commands, a list of arguments (everything after the word,
split on spaces); for `expression` commands, the group dictionary of the
matching regexp.
- `event_id` — the received message's identifier.
- `thread_parent` — the root message ID of the thread; equals `event_id` when
the message is not inside a thread.
## Notifications (notify.matrix)
Deliver notifications from Home Assistant to a Matrix room (direct or group):
```yaml
notify:
- name: matrix_notify
platform: matrix
default_room: "#hasstest:chans.xyz"
```
- The target room must already exist; get its canonical ID from the room
settings dialog (`!<randomid>:chans.xyz`) or an alias (`#roomname:chans.xyz`).
Quote the room ID/alias in YAML to escape the `!` / `#` characters.
- The notifying account may need to be invited to the room, depending on room
policy.
Message formats (`data.format`): `text` (default) and `html`. Images can be
attached via `data.images` (list of file paths); files from outside allowed
folders require `homeassistant.allowlist_external_dirs` to list the source
folder.
Reply inside a thread by passing the root message ID into `data.thread_id`:
```yaml
action: notify.matrix_notify
data:
message: "Reply message goes here"
data:
thread_id: "{{ trigger.event.data.thread_parent }}"
```
## Actions
- `matrix.react` — send a reaction to a message in a Matrix room
(`reaction`, `room`, `message_id`).
- `matrix.send_message` — send a message to one or more Matrix rooms.
## Comprehensive example (adapted)
```yaml
matrix:
homeserver: https://synapse.chans.xyz
username: "@ha_bot:chans.xyz"
password: supersecurepassword
rooms:
- "#hasstest:chans.xyz"
- "#someothertest:chans.xyz"
commands:
- word: testword
name: testword
rooms:
- "#someothertest:chans.xyz"
- expression: "My name is (?P<name>.*)"
name: introduction
- reaction: 👍
name: thumbsup
notify:
- name: matrix_notify
platform: matrix
default_room: "#hasstest:chans.xyz"
automation:
- alias: "Respond to !testword"
triggers:
- trigger: event
event_type: matrix_command
event_data:
command: testword
actions:
- action: notify.matrix_notify
data:
message: "It looks like you wrote !testword"
```
## matrix_e2ee (custom E2E integration)
Custom integration [`windyboy/ha-matrix-e2ee`](https://github.com/windyboy/ha-matrix-e2ee),
release **v0.3.12** (Matrix activity events + push diagnostics), deployed on
`hass.windy.lan` 2026-08-20 (upgraded from v0.3.2, W1N-182/#34 emoji-wait
wizard fix; v0.3.9 brought the Connection health binary sensor, SAS/command
allowlist split, URL normalization and single-entry enforcement, W1N-156/W1N-190).
Runs a dedicated bot with a **persistent E2EE device identity**.
- Domain `matrix_e2ee`; Config Flow (UI) with YAML import migration, not in HACS. Does **not**
override the built-in `matrix` integration.
- Dependencies are declared **explicitly** in `manifest.json` to work around Home
Assistant's `is_installed` dropping the `[e2e]` extra (W1N-140):
`matrix-nio[e2e]==0.26.0` + `vodozemac` + `peewee` + `cachetools` + `atomicwrites`.
- **v0.2.0 migration:** YAML `matrix_e2ee:` block was auto-imported into a Config Entry
(`source: import`) on first startup, then removed. All settings now managed via
**Settings → Devices & Services → Matrix E2EE → Configure**.
See [hosts/hass.windy.lan.md](../hosts/hass.windy.lan.md) for the deployed state.
### Services & events
- Services (all admin-only since v0.1.4):
- `send_message` (`message`, `room_id`)
- `start_verification` (`user_id`, `device_id`)
- `confirm_verification` (`transaction_id`)
- `cancel_verification` (`transaction_id`)
- `reauthenticate` (`password`) — soft-logout only
- `get_fingerprint` (no fields; returns bot's own `ed25519`/`curve25519` keys; added v0.1.3)
- `verify_device_by_fingerprint` (`user_id`, `device_id`, `ed25519`; added v0.1.3,
renamed from `verify_device` in v0.1.4; requires exact `ed25519` match)
- Events:
- `matrix_e2ee_command` (`room_id`, `sender`, `command`, `args` only —
never the raw body)
- `matrix_e2ee_error` (codes, no secrets)
- `matrix_e2ee_verification` (`stage`, `transaction_id`, `user_id`, `device_id`,
optional `emojis`, optional `expires_at`; `expires_at` added v0.1.3)
- `matrix_e2ee_fingerprint` (`user_id`, `device_id`, `ed25519`, `curve25519`
public keys only; added v0.1.3)
- `matrix_e2ee_message_received` (`room_id`, `sender`, `event_id`; added v0.3.12
activity events)
- `matrix_e2ee_verification_done` (`transaction_id`, `user_id`, `device_id`;
added v0.3.12)
- v0.3.12 also adds an `event.` platform entity (`Bot activity`,
`event_types: ["message", "command", "verification_done"]`) and a diagnostic
Connection binary sensor (`binary_sensor.*_connection`, CONNECTIVITY class).
- `notify.matrix_e2ee` is **not implemented** (upstream deferred) — notifications
must call `matrix_e2ee.send_message` (message + room_id).
- Commands fire Home Assistant events only; the integration never calls
`domain.service` itself. Map commands in automations.
- Encrypted rooms fail-closed on unverified devices.
- Since v0.1.4: `start_verification`, `confirm_verification`, `cancel_verification`,
`verify_device_by_fingerprint`, and `reauthenticate` are enforced as HA admin-only
via `async_register_admin_service`; non-admin users cannot call them.
### Storage & recovery
- `.storage/matrix_e2ee_session.json` (`user_id`, `device_id`, `access_token`,
`pickle_key`) and `.storage/matrix_e2ee_store/` (Olm/Megolm, device trust,
sync token). Both stay on the HA persistent volume and are in HA backups.
- Soft logout → `matrix_e2ee.reauthenticate` (keeps `device_id` + crypto store;
rejected outside soft-logout state since v0.1.3).
- Hard logout / store loss → delete session + store, restart with password, re-SAS
(a **new device**; old history not decryptable).
## Device verification (SAS + fingerprint) model
Researched 2026-08-15 (W1N-139 stage-6 pre-study), updated for v0.1.3/v0.1.4.
Sources: matrix.org
[cross-signing guide](https://matrix.org/docs/guides/implementing-more-advanced-e-2-ee-features-such-as-cross-signing/),
matrix-nio [examples](https://matrix-nio.readthedocs.io/en/latest/examples.html),
[element-android#6832](https://github.com/vector-im/element-android/issues/6832),
Element [device-verification](https://element.io/features/device-verification).
`matrix_e2ee` supports three verification paths (the wizard — v0.3.0
bot-initiated, reworked in v0.3.1/v0.3.2 to wait for a peer-initiated inbound
SAS from the user's Matrix client with emoji comparison — automates the SAS
flow):
### 1. SAS (mutual, manual confirmation since v0.1.4)
- SAS is device-to-device: exchange ephemeral keys → derive emojis → **a human on
each side compares and confirms** (`m.key.verification.mac`).
- Matrix distinguishes two cases (spec uses *should*, not *must*):
- **same user, two devices** → to-device messages (SAS);
- **two different users** → **in-room (DM) messages**, verifying the *user*
(cross-signing master key), not a specific device.
- Cross-signing: each user has master / self-signing / user-signing keys. A device
looks "verified" to another user via the chain
`my master → my user-signing → their master → their self-signing → their device`.
- Element's "Verify" button only starts **in-DM user verification**; it has no
"verify a specific device of another user via to-device" flow (matrix.org
recommends hiding per-device verification for other users).
- `matrix_e2ee` implements **raw to-device device SAS** (`start_verification`/
`confirm_verification`), **no cross-signing / in-room**. This is a non-standard
cross-user path: works with matrix-nio + Element Web/Desktop (reported in
element-android#6832), **not** on Element Android/X.
- **v0.1.3**: inbound SAS auto-complete was added; SAS events include `expires_at`.
- **v0.1.4 (breaking)**: auto-confirm was removed. **Every** device — including
another device of the bot's own account — requires explicit `confirm_verification`
after emoji comparison. Only the bot's own account or users in `allowed_users`
may initiate SAS (`verification_peer_denied` otherwise).
- **v0.2.1**: storage I/O moved off the event loop (`asyncio.to_thread`,
W1N-167); own-keys query on startup so inbound SAS can build a session (W1N-166).
- **v0.2.2** (not deployed): intermediate version.
- **v0.2.3**: sync loop runs as a background task (fixes bootstrap setup timeout,
W1N-168); SAS double-send of key and MAC fixed (W1N-169).
- **v0.2.6**: `_log_verification_state()` tracks SAS state transitions with
`async_write_ha_state` for diagnosis (W1N-174);
`_bridge_verification_request()` handles inbound
`m.key.verification.request``m.key.verification.ready` since nio lacks a
`request` framework (W1N-173).
- **v0.2.5**: bridge `m.key.verification.request``ready` (nio lacks
request framework, W1N-173).
- **v0.2.4**: `_patch_nio_sas_timeout()` works around nio 0.26.0
`_last_event_time` bug (SAS timed out at 60s regardless of activity — now uses
`_max_age` 5 min); `_repair_dropped_start()` recovers SAS `start` events nio
dropped when the peer device was unknown (W1N-170/W1N-172);
`VERIFICATION_TIMEOUT_SECONDS` 600→240 (fires before nio's `_max_age`).
- **v0.2.11**: `receive_mac_event` no longer overrides canceled state (W1N-179/#31).
- **v0.3.12**: Matrix activity events (`matrix_e2ee_message_received`,
`matrix_e2ee_verification_done`) + `event.` Bot activity entity + Connection
diagnostic binary sensor.
- **v0.3.9**: SAS driver gate split from the command allowlist — new
`verification_peer_users` option (W1N-156/#41); SAS/sync logs demoted
warning→info/debug (W1N-188/#38); Connection health binary sensor
(W1N-185/#40); URL normalization + single-entry enforcement (W1N-190/#42).
- **v0.3.8**: `m.key.verification.done` handshake completion for
request-based SAS (W1N-183/#35).
- **v0.3.2**: wizard waits for the inbound SAS to show emojis before moving
to the compare step (`_wait_for_inbound` requires `latest_sas_snapshot()` to
return `emojis`) — W1N-182/#34.
- **v0.3.1**: verification wizard now waits for a peer-initiated inbound SAS
(options flow no longer starts verification from the bot; `latest_sas_snapshot()`
skips verified/canceled transactions) — GitHub #33.
- **v0.3.0**: bot-initiated device verification wizard (W1N-180/#32).
- Inbound SAS is gated to `allowed_users` (v0.1.3); **since v0.3.9 (W1N-156)
the gate is the separate `verification_peer_users` allowlist**, which is
unset on hass.windy.lan — only the bot's own account may drive SAS until
`@zhiqiang:chans.xyz` is added there.
### 2. One-sided fingerprint (added v0.1.3, hardened v0.1.4)
- Call `matrix_e2ee.get_fingerprint` to get the bot's own `ed25519` device key
(read it from the `matrix_e2ee_fingerprint` event).
- In Element, open the bot user's sessions and use "Manually verify by text".
Compare the session key with the fingerprint.
- To trust another device from the bot's side, call
`matrix_e2ee.verify_device_by_fingerprint` with the peer's `user_id`, `device_id`,
and `ed25519` key. The match is exact (since v0.1.4's rename from `verify_device`).
Feed the **peer** key, not the bot's own key.
- This trusts from one side only; the peer still trusts the bot independently.
- Both `get_fingerprint` and `verify_device_by_fingerprint` are HA admin-only.
### Consequence
Whether `@zhiqiang`'s device can be verified depends on which Element client
they use. Open options recorded in W1N-139 (A: Web SAS test; B: upstream
in-room/cross-signing; C: unencrypted-room downgrade).
## References
- Home Assistant Matrix integration: <https://www.home-assistant.io/integrations/matrix>
- Matrix host facts: [`hosts/synapse.chans.xyz.md`](../hosts/synapse.chans.xyz.md)
- Matrix deployment and upstream index: [`matrix-upstream.md`](matrix-upstream.md)
- Hermes Agent Matrix channel (MAS local-password + access-token pattern): [`hermes-matrix.md`](hermes-matrix.md)
- HA host facts: [`hosts/hass.windy.lan.md`](../hosts/hass.windy.lan.md)
- HA maintenance runbook: [`runbooks/home-assistant-maintenance.md`](../runbooks/home-assistant-maintenance.md)
+246
View File
@@ -0,0 +1,246 @@
# 内网 DNS 架构调研与优化建议
> 状态:2026-08-12 调研定稿,Linear **W1N-56**。**Phase 0 验证(2026-08-12)全部完成,最终裁决(用户定稿)已对齐**;本期未改动任何生产 DNS 路径。
> 2026-08-12 现场核查修正:gfw 上 mosdns **不是闲置**——它是 OpenClash clash 的
> `nameserver`/`default-nameserver`(DIRECT 规则真实 IP 解析),处于活动链路,裁决第 5 条
> 的"删除闲置 mosdns"前提不成立,处置改为"正式纳管并文档化"。
> 相关:`docs/lan-overview.md`、`hosts/dns.windy.lan.md`、`hosts/gfw.windy.lan.md`、W1N-40。
> 2026-08-13 (W1N-62):gfw mosdns 国外分支已从"明文国内公网 DNS"改为**加密 DoH**
> (自建 `https://adg.chans.xyz/dns-query`,hk2),新增 `foreign_upstream`/`foreign_fallback`
> (primary=DoH, secondary=明文国内 DNS, threshold 1000ms),`bootstrap` 用现有国内公网 IP
> (防自举循环)。实测:mosdns 平面国外域名 A/AAAA 恢复(google AAAA `2607:f8b0…`)、
> `dup.baidustatic.com`→`0.0.0.0`(AGH 拦截保留)、clash 7874 fake-ip 平面不变。
> **重要修正(定稿)**:DoH 流量实测为 **gfw→hk2 直连,未经 clash 代理**——nft output 链
> (mangle mark/tcp redirect)计数为 0、`/proc/net/tcp` 存在到 hk2:443 的 established
> 连接,路由自身 TCP 输出当前并未被 OpenClash 重定向,故"经代理访问加密 DNS"的假设不成立。
> **最终决策:接受直连,不强行走代理**——`foreign_upstream` 以自建解析器
> `adg.chans.xyz`(hk2)为主力,自有 VPS 直连即可达、无被墙/污染问题、DoH/TLS 已加密、应答干净,
> 走代理毫无增益反而把 DNS 平面耦合进 clash;实测 kill clash 期间国外查询 0.01s 正常应答,
> watchdog 自动拉起,直连使 DNS 平面独立于代理(优于过代理)。
> **多上游冗余(同日)**:`concurrent: 3`,新增 `https://dns.quad9.net/dns-query` 与
> `https://dns.cloudflare.com/dns-query`(2026-08-13 本网络实测可达;`dns.quad101.net`
> TLS 握手失败已排除)——hk2 故障时仍由干净的国外 DoH 应答,最后才退化明文国内兜底。
> 验证:google AAAA 由 hk2 的 `2607:f8b0…` 变为新上游的 `2404:6800…`,国内外/拦截/代理平面无回归。
> 备份:`config.yaml.bak-foreign-doh-20260813-103746` / `config.yaml.bak-foreign-doh-20260813-103813` /
> `config.yaml.bak-multi-doh-20260813-105421`。
## 1. 现状(实测 2026-08-12)
| 角色 | 部署 | 职责 | 是否在活动路径 |
|------|------|------|----------------|
| **AdGuard Home** | `192.168.66.36`(PVE VM 120,Docker host 网络) | EdgeRouter DHCP 通告给 LAN55/66 客户端的唯一 DNS;广告/过滤、查询统计、Web 面板 | ✅ **是(LAN 客户端唯一入口)** |
| **mosdns** | `192.168.66.1`(gfw OpenWrt)监听 `127.0.0.1:6052`(仅本机) | clash 的 `nameserver`/`default-nameserver`:DIRECT 规则真实 IP 分流(国内 → AGH `.36:53`,国外 → `223.5.5.5`/`119.29.29.29`) | ✅ 网关侧(clash 消费,不面向客户端) |
| **OpenClash / clash(meta)** | `192.168.66.1`(gfw) | gateway 自身/被劫持流量的 fake-ip + 代理;DNS 走 dnsmasq→clash `#7874` | 仅网关侧与 VLAN10 |
**关键事实(全部实测):**
- LAN 客户端 DNS 直连 `66.36`,**不经过** gfw(EdgeRouter `service dns forwarding` cache 512,通告 `.36`)。
- AGH 上游:DoH `dns.alidns.com`(→`223.5.5.5`/`223.6.6.6`)+ `doh.pub`(→`120.53.53.53`/`1.12.12.12`),
`upstream_mode: load_balance``fastest_timeout: 1s``upstream_timeout: 10s`;bootstrap
`223.5.5.5`/`223.6.6.6`(公网 IP,无自举循环);兜底 DoH `adg.chans.xyz`(→`hk2.chans.xyz``154.36.174.161`)。
DNS 监听 UDP/TCP **v4 only(`0.0.0.0:53`)**,无 v6 监听;`enable_dnssec: false`;cache 4 MB;ratelimit 20。
- AGH 宿主出网:默认路由 `via 192.168.66.254`(EdgeRouter),**直连,不经 gfw**;DoH 实测可达
(223.5.5.5:443 → HTTP 400/0.05s,120.53.53.53 → 502/0.06s,adg.chans.xyz 冷连接 ~3.5s)。
- gfw 自身出网:OpenClash `openclash_mangle_output` 对非本地区域/国内 IP 流量统一
`mark 0x162 → tproxy 127.0.0.1:7895`(**gfw 自身流量默认走代理**,含 clash 的
nameserver-policy DoH);mosdns 的上游(AGH `.36` 本地区域、`223.5.5.5` 国内 IP)均被 bypass,保持直连。
- gfw DNS 链:`server=127.0.0.1#7874`(dnsmasq)→ clash:`nameserver: [127.0.0.1:6052]`(mosdns)、
`default-nameserver: [127.0.0.1:6052]``nameserver-policy` 国外域名 → DoH `https://1.1.1.1/dns-query`
`enhanced-mode: fake-ip`(198.18.0.1/16)、`ipv6: false`;nft 有 UDP/53 hijack → dnsmasq。
- **mosdns 配置缺陷(2026-08-12 发现并修复)**:`main` sequence 的国内分支
(`matches: qname $domestic_domains → exec: $domestic_upstream`)之后**缺少
`matches: has_resp → accept` 守卫**。mosdns v5 的 `sequence``forward` 成功后不会停止,
只有 `accept`/`reject`/`return` 或错误会终止——因此命中 `geosite_cn` 的查询会被转发**两次**
(AGH 与 223.5.5.5/119.29.29.29),最终应答来自最后一个 forward(国内公网 DNS),**AGH 的
拦截/rewrite 对 DIRECT 国内域名静默失效**。实测证据:`dup.baidustatic.com`(在 `geosite_cn`
且在 AGH 拦截表)经 mosdns 返回真实 IP `183.60.227.49` 而非 `0.0.0.0`。已修复(备份
`/etc/mosdns/config.yaml.bak-20260812`),修复后同一域名返回 `0.0.0.0`,taobao/google 解析
与 clash 链均无回归。**§6 的二期示例同款缺陷已一并修正。**
同日追加加固:`domestic_fallback`(fallback 插件:`primary: domestic_upstream`(AGH)、
`secondary: default_upstream`(223.5.5.5/119.29.29.29)、`threshold: 500ms`)使 AGH 宕机时
DIRECT 国内真实 IP 查询回退国内公网 DNS,不再直接报错;实测:AGH 停止时缓存未命中查询由
fallback 应答(NXDOMAIN/真实 IP),AGH 恢复后主路径即时应答且拦截(`0.0.0.0`)恢复。
备份:`/etc/mosdns/config.yaml.bak-fallback-20260812`
- AGH rewrites(实测):`hass.windy.lan`/`hass.local``192.168.55.11`;`dns.windy.lan``.36`;
`ubnt.windy.lan``.46`;`gfw.windy.lan``.1`;`nas.windy.local``.32`
- 拦截:仅启用 **AdGuard DNS filter**(filter_1);实测 `doubleclick.net`/`googleadservices.com``0.0.0.0`
**现状缺口(实测确认):**
1. AGH 上游是固定 DoH,**无"国内/国外分流"能力**;国外域名解析质量依赖唯一兜底路径。
2. **兜底失效**(kill-test 证实):主上游黑洞时,兜底 `adg.chans.xyz` 在客户端 15s 窗口内不生效
(`upstream_timeout: 10s` + TCP 重试行为),缓存未命中查询无有界降级——见 §8。
3. 国外域名 AAAA 经国内路径全部置空(见 §8),v6 解析缺位。
## 2. 最终裁决对齐(用户定稿 2026-08-12)
| # | 裁决 | 本issue处理 |
|---|------|------------|
| 1 | **保留 AGH `.36` 为唯一 LAN DNS 入口**(现有方案增强版),不改 EdgeRouter DHCP 通告 | ✅ 现状保持;本期零改动 |
| 2 | **否决"AGH 全局转发到 Clash fake-IP"**——DNS 平面必须与流量转发平面一致 | ✅ 分层方案(§4)明确 AGH 上游为**真实 IP** 解析路径,不与 fake-ip 混用 |
| 3 | **AGH → mosdns 仅为二期可选项**(经实测确有需求后启用) | ✅ §4 为二期方案;§8 kill-test 已给出"实测需求"证据(降级缺口) |
| 4 | 代理 VLAN10 将来用独立 OpenClash DNS 平面(fake-ip + TPROXY),不污染普通 LAN | ✅ 现状即此(dnsmasq→clash,非面向 LAN 客户端);文档记录 |
| 5 | **删除或明确禁用** `.1` 上未使用的 mosdns | ⚠️ 前提修正:mosdns 是 clash 的 nameserver,处于活动链路(§1)。处置改为**正式纳管并文档化**(本文件 + `hosts/gfw.windy.lan.md`),不删除 |
| 6 | 先完成验证再改动生产路径 | ✅ 本期完成全部 Phase 0 验证(§8),**未改任何生产 DNS 路径** |
| 7 | 建立 `home.arpa` 内部域(替代 `.local`) | ⏳ 后续任务:当前命名空间为 `.lan`(AGH rewrites + EdgeRouter DHCP domain),`hass.local` 兼容保留至迁移完成;home.arpa 需联动 AGH rewrites、DHCP domain、客户端,另行排期 |
| 8 | 高可用时增加第二个等价 AGH(独立物理故障域) | ⏳ 备用方案,记录不实施 |
## 3. 两个候选方案评估
### 方案 A:AGH 单独作为统一入口(现状演进)
- 优点:单解析点、面板/拦截/日志集中、维护简单。
- 缺点:AGH 对 geo 分流 + 防污染支持弱(官方定位是"过滤/家长控制")。固定 DoH 上游无法按域名
region 选路;且 §8 kill-test 显示主上游全挂时缓存未命中查询无有界降级。→ **不足以解决防污染/分流/降级问题。**
### 方案 B:mosdns 作为智能上游分流器
mosdns(v5)用 `sequence` 编排:`geosite/geoip` 匹配器 → 国内域名转发国内 DoH、国外域名转发加密 DoH(防污染),可加 `cache``reject`
- 优点:真正解决"国内快 / 国外不被污染"的分流;性能高。
- 缺点:纯转发器,无 Web 面板、无每客户端统计、拦截靠域名表。→ 单独当入口会退回原始体验。
**结论:两个方案互补,不是二选一。** 单用 A 无法分流防污染且降级无界;单用 B 失去 AGH 管理体验。
## 4. 二期可选项:分层架构(AGH 前端 + mosdns 后端,经实测需求后启用)
```
局域网客户端(DHCP DNS = 192.168.66.36)
AdGuard Home (66.36) ── 前端:广告/过滤、拦截表、每客户端统计、Web 面板
│ 上游 = mosdns
mosdns(66.36 伴生容器) ── 后端:智能分流 + 防污染
│ - geosite:cn → 国内 DoH/UDP(aliDNS / 腾讯 DNSPod)
│ - 其他 → 加密 DoH(自建 adg.chans.xyz 等)
上游 DoH
```
职责分离:
- **AGH = 策略/拦截/可观测**(拦截表、每客户端日志、面板)。
- **mosdns = 智能转发**(geo 分流 + 加密防污染 + 内置 cache,可显著缩短降级窗口)。
- **OpenClash(gfw)= 代理选路**(fake-ip + 规则)。与 DNS 解析选上游是两个独立决策,分开放最干净。
**部署位置:mosdns 与 AGH 同机(66.36 伴生容器),而非 gfw(.1)**:单点即 AGH 所在;不受网关重启/
OpenClash churn 影响;可纳入现有 compose/ansible 管理;不占用 OpenWrt 资源。放 gfw 会与 clash 的
DNS 处理互相干扰、耦合,且网关重启即断全 LAN DNS。**不推荐放 .1。**
> 启用条件(kill-test 实测需求,§8):主上游全挂时,当前 AGH 单入口对缓存未命中查询无有界降级。
> 分层方案(或下调 `upstream_timeout` + 改 failover 模式)可修;启用与否由用户在二期决定。
## 5. 更优替代方案(一并考虑)
1. **分层(推荐二期,见 §4)**:AGH(66.36)→ mosdns(66.36 伴生)→ 上游。体验最好、职责最清。
2. **纯 mosdns + 前端面板**:损失拦截/统计管理体验。**不推荐**用于替换。
3. **AGH 只挂一个带分流的上游(第三方 DoH 聚合)**:失去可控性且不可信。不推荐做主路径。
4. **全部交给 OpenClash fake-ip,关闭 AGH**:让"代理网关"成为全 LAN DNS 单点;且 AGH 拦截/日志也没了。**不推荐。**
## 6. mosdns 配置要点(mosdns v5,二期实施预留)
核心是 `sequence` + 上游拆分 + 缓存 + 屏蔽:
```yaml
plugins:
- tag: main
type: sequence
args:
- exec: cache 1024 # 缓存加速
- matches: has_resp
exec: accept
# 国内分流:命中 geosite:cn → 国内 DoH
- matches: [ qname &geosite:cn ]
exec: forward https://dns.alidns.com/dns-query
# 广告域名屏蔽交 AGH 前置,不重复维护
# 其余(国外)→ 加密 DoH 防污染
- exec: forward https://adg.chans.xyz/dns-query
- type: udp_server
args: { entry: main, listen: "127.0.0.1:5353" }
- type: tcp_server
args: { entry: main, listen: "127.0.0.1:5353" }
```
> **注意**:`sequence` 中每个 `forward` 分支之后必须跟 `matches: has_resp → accept`
> (或改用 `goto`/`jump` + `return` 结构),否则查询会继续执行后续规则被二次转发,
> 最终应答来自最后一个 forward——gfw 上 mosdns 的同类缺陷(2026-08-12)已实测并修复(见 §1)。
要点:
- 上游可加 `upstream``concurrent > 1` 与多地址故障切换;mosdns 自带 cache,能保证上游故障时
缓存命中仍即时应答(对应 §8 认定的降级缺口)。
- `geosite:cn` / `geoip:cn` 数据自动更新;国内 aliDNS/腾讯,国外可用自建 `adg.chans.xyz`(实测
唯一能返回国外 AAAA 的路径,§8)。
- 屏蔽交 AGH 前置,AGH 与 mosdns 不各自维护拦截表。
## 7. 迁移 / 实施顺序(二期,待用户确认启用)
1. 在 66.36 起 mosdns 伴生容器(`/opt/mosdns` + compose,**按 digest 固定镜像**,纳入 ansible)。
2. AGH「上游 DNS 服务器」改为指向 mosdns(`127.0.0.1:5353`,bootstrap 仍用公网 IP,避免
AGH → mosdns → AGH 死循环);只保留**一条语义一致的上游路径**,保持可回滚(备份 yaml + `--check-config`)。
3. 验证:国内域名、国外域名、被拦截域名、每客户端日志、AAA A 解析(§8 基线)。
4. 回归:EdgeRouter 通告不变(仍 `.36`),LAN 客户端无感;重启 AGH/mosdns 单点验证(§8 kill-test 模板)。
5. 上线后重跑 §8 kill-test,确认降级窗口有界。
## 8. Phase 0 验证证据(2026-08-12 全部实测)
### 8.1 基线与功能
| 项 | 结果 |
|----|------|
| rewrites:`hass.windy.lan` / `hass.local` | → `192.168.55.11` ✅(兼容保留) |
| rewrites:`dns.windy.lan` / `gfw.windy.lan` / `ubnt.windy.lan` / `nas.windy.local` | → `.36` / `.1` / `.46` / `.32` ✅ |
| 国内解析 `taobao.com`(经 AGH) | 真实 CN IP(59.82.x 等)✅ |
| 国外解析 `google.com` / `github.com`(经 AGH) | 真实 IP(142.250.x / 20.205.x),**无 fake-IP 泄漏** ✅ |
| 广告拦截 `doubleclick.net` / `googleadservices.com` | → `0.0.0.0` ✅ |
| clash 7874 `google.com` | `198.18.1.101`(fake-ip,仅网关/VLAN10 平面)✅ |
| clash 7874 `taobao.com` | 真实 IP(经 mosdns→AGH)✅ |
| dnsmasq :53(.1)`google.com` / `taobao.com` | fake-ip / 真实 IP ✅ |
| mosdns 6052 直连 `taobao.com` / `google.com` | 真实 IP(59.82.x / 142.250.73.78)✅ |
| DNSSEC:`dnssec-failed.org`(经 AGH) | 返回正常应答 `96.99.227.255`(非 SERVFAIL)→ 当前路径不校验,W1N-40 结论复现,**维持关闭** |
### 8.2 出口路径与 fake-IP 泄漏
- AGH 宿主默认路由 `via 192.168.66.254`(EdgeRouter),**直连出网,不经 gfw**;DoH 端点实测可达
(见 §1)。LAN 客户端经 AGH 的解析结果全部为真实 IP,无 `198.18/16` 泄漏。
- gfw 自身流量默认进代理(`openclash_mangle_output` mark 0x162 → tproxy :7895),
clash nameserver-policy 的 `1.1.1.1` DoH 实测 35ms 可达(走代理链路,不依赖直连)。
- mosdns 上游(AGH `.36``223.5.5.5`)命中本地/国内 bypass 规则,保持直连——设计意图达成。
### 8.3 IPv6 / RDNSS / AAAA
- LAN 有 IPv6 SLAAC(EdgeRouter dhcpv6-pd /60 → eth0 host-address + switch0,**仅 `service slaac`**,
**无 RDNSS/dns-server 通告**);`.36` 有全局 v6 地址 + RA 默认路由。
- **RDNSS 未通告** → v6 客户端无 v6 DNS,回退 v4 DNS(`.36`);AGH 仅监听 `0.0.0.0:53`(v4 only),无 v6 DNS 服务。
- **AAAA 解析实测**:`baidu.com` 公网本就无 AAAA(dns.google NOERROR/0,权威 NS 而已);
`taobao.com` AAAA 经国内路径正常(`2408:4001:f10::6f` 等);**国外域名(`google.com`)经
`223.5.5.5` UDP、alidns DoH、`8.8.8.8` UDP 全部返回空**,而 dns.google 与 `adg.chans.xyz`
DoH 均能返回 `2404:6800:4005:81a::200e` → **国内路径对国外域 AAAA 置空;`adg.chans.xyz`
兜底是当前唯一能返回国外 AAAA 的路径**。clash `ipv6: false` 亦不返回 AAAA。
### 8.4 自举(bootstrap)循环
- AGH `bootstrap_dns: [223.5.5.5, 223.6.6.6]`(公网 IP,非 AGH 自身)→ 无自举循环;AGH 解析
DoH 主机名不经过自身。二期方案要求 AGH→mosdns 时 bootstrap 仍用公网 IP(§7)。
### 8.5 Kill-test 矩阵(2026-08-12,全部实测)
| # | 场景 | 结果 |
|---|------|------|
| 1 | 重启 `.1` mosdns(init.d) | ✅ 直连 6052 与 clash 链恢复 |
| 2 | 重启 `.1` dnsmasq | ✅ 真实 IP 与 fake-ip 双路径恢复 |
| 3 | kill `.1` clash 核心 | ✅ LAN DNS(AGH)不受影响;gfw dnsmasq→clash **有界 3s 失败**(无卡死);OpenClash watchdog ~15s 自动拉起 |
| 4 | stop/start `.1` OpenClash | ✅ DNS 平面独立于代理;clash 与 tproxy 规则恢复 |
| 5 | 重启 `.36` AGH 容器 | ✅ 全量恢复:rewrites/拦截/国内外解析/DNSSEC 行为不变 |
| 6 | 黑洞 alidns DoH(223.5.5.5/223.6.6.6:443) | ✅ ~0.5s 内经 `doh.pub` 应答(load_balance 生效) |
| 7 | 黑洞全部主上游,兜底存活(adg.chans.xyz) | ⚠️ **客户端 15s 内无应答**——兜底未在窗口内生效 |
| 8 | 黑洞全部上游(含兜底),缓存未命中 | ⚠️ **25s 内无应答、无 SERVFAIL**——解析器对缓存未命中查询"卡死" |
| 9 | 黑洞全部上游,缓存命中 | ✅ 瞬时 NOERROR(cache 兜底) |
**结论(Phase 0 门禁):** 国内解析在代理停止/上游单点故障/组件重启下均维持可用;
但**"主上游全挂"时缓存未命中查询无有界降级**——`upstream_timeout: 10s` 与 TCP 重试行为使
兜底 `adg.chans.xyz` 在实践中无法在客户端期望窗口内生效。这是 §4 二期分层方案(或下调
`upstream_timeout` + failover 模式)的**实测需求依据**;按最终裁决,本期不改生产路径。
## 9. 风险与备注
- mosdns 仅监听 `127.0.0.1`(不对外),由 clash 消费;二期若启用,保持同样的边界,避免 LAN 出现两套入口。
- AGH 上游指向本机 mosdns 时务必配公网 bootstrap,否则自举死循环。
- 本方案不改 EdgeRouter DHCP/通告、不改 gfw OpenClash 代理规则,只动 66.36 上的 DNS 链路,风险可控。
- 已知降级缺口(§8.5 #7/#8):主上游全挂时缓存未命中查询无有界降级;启用二期前,LAN 客户端会感知
超时(约 10s+)。缓解:AGH cache 已覆盖高频域;根治需二期。
- 与 W1N-40「审查并修正 AdGuard Home」联动:该 issue 侧重 AGH 本身,本 issue 侧重整体 DNS 分层。
+315
View File
@@ -0,0 +1,315 @@
# ER-X + TL-SE5420 网络与拓扑说明
**状态:** SE5420 **已采购**2026-08-09)。**实施与验证以 [lan-se5420-deployment-guide.md](lan-se5420-deployment-guide.md) 为准**
本文保留为拓扑/流量路径/职责边界的设计说明。
**锁定硬件:** EdgeRouter X(保留)+ TP-Link `TL-SE5420`(新建核心)。
**本文目的:** 说明升级后物理拓扑、逻辑网络、流量路径与职责边界;唯一实施步骤见
[lan-se5420-deployment-guide.md](lan-se5420-deployment-guide.md)。
---
## 1. 一句话架构
```text
公网 ← ER-X(网关 / NAT / DHCP / 防火墙)
↑ 两条独立 access 上联(LAN66 + LAN55,无 VLAN tag
TL-SE5420(纯 L2 核心:所有接入、同 VLAN 转发、未来 trunk)
有线终端 / PVE(gfw) / 两台 UniFi AP
```
- **ER-X** 继续当唯一主网关:PPPoE、NAT、端口转发、LAN66/LAN55 的 `.254` 与 DHCP。
- **SE5420** 只做二层:接线、VLAN 隔离转发、端口统计;**不开** L3、静态路由、DHCP
Server/Relay、NAT。
- **`gfw`PVE 上的 OpenWrt)** 仍是旁路由;默认客户端网关仍是 `.254`。以后若启用
「设备升级」专用 SSID,该 SSID 的客户端才把网关指向 `.1`
---
## 2. 已确认的设计决策
| 决策 | 结论 |
|---|---|
| 硬目标 | 同 VLAN **2.5G** 二层转发;10G 仅 SFP+ 预留 |
| 核心型号 | **`TL-SE5420`**16×2.5G RJ45 + 4×10G SFP+ |
| 终局形态 | ER-X 边缘网关 + 核心纯 L2;可分阶段实施 |
| 本次升级 Done | 迁完 LAN66/LAN55 接入与验收(计划阶段 03);不含 VLAN10 |
| 默认网关 | **`192.168.66.254` / `192.168.55.254`ER-X** |
| 专用升级 SSID | 仅 **U6 Lite**;客户端网关 **`gfw` `.1`**(后续项目) |
| 客人网 | 仅 **UAP-AC-Lite**UniFi Guest 隔离,仍在 **LAN55**(后续) |
| 切换方式 | **3060 分钟维护窗** 集中切;旧交换迁完后闲置 |
| 首批 2.5G | NAS + 主力 PC(或 PVE);其余可先 1G |
| SFP+ | 先空槽;有明确 10G 对端再买 DAC/光模块 |
| AP 供电 | **本地取电 + 普通网线**SE5420 **无 PoE** |
| 管理面 | 先本地 HTTPS/SSH;云以后再说;L3/DHCP 严格关闭 |
---
## 3. 设备职责
### 3.1 EdgeRouter X`gw``192.168.66.254`
| 职责 | 说明 |
|---|---|
| WAN | `eth4` → PPPoE`pppoe0`),NAT、WAN 防火墙、端口转发 |
| LAN66 网关/DHCP | `eth0` = `192.168.66.254/24`DHCP 池与静态映射不变 |
| LAN55 网关/DHCP | `switch0``eth1``eth3`= `192.168.55.254/24` |
| 跨网段路由 | LAN66 ↔ LAN55 仍由 ER-X 三层转发 |
| 与核心的连接 | **两条无标签 access**,不是 802.1Q trunk |
ER-X 当前不是 trunk 设计:不要向它发送 VLAN66/55 的 tag。核心上 VLAN66 的
untagged 帧进 `eth0`VLAN55 的 untagged 帧进 `switch0` 的某个成员口。
### 3.2 TL-SE5420(新建核心)
| 职责 | 说明 |
|---|---|
| 物理接入汇聚 | 有线设备、PVE、两台 AP 都接到 SE5420 |
| VLAN66 / VLAN55 | 二层转发(access 或 trunk 的 native/allowed |
| 同 VLAN 高速交换 | 两端都是 2.5G 时可跑满协商速率(受 NIC/线材限制) |
| 未来 trunk | PVE、U6native 66 + tagged 10(仅后续阶段启用) |
| 不做 | 不当默认网关;不开 DHCP;不做 NAT;第一阶段不开 SVI/路由 |
管理地址:落在 **LAN66** 的一个未占用地址(先查 ER-X DHCP reservation,再分配);
不新建管理 VLAN。
### 3.3 旁路由 `gfw``192.168.66.1`
| 现状 | 升级后(阶段 0–3) | 更后续 |
|---|---|---|
| PVE 上的 ImmortalWrt VM | 仍挂在 **untagged LAN66** | 可增 VLAN10 接口 |
| OpenClash 旁路/透明代理 | 路径不变:客户端默认仍走 `.254` | 升级专用 SSID → 网关 `.1` |
| 不能单独「做出」VLAN10 | 需 PVE vSwitch + 核心 trunk + U6 trunk | 见部署指南第二步 |
### 3.4 其它现网角色(地址与角色不变)
| 主机 | 地址 | 角色 |
|---|---|---|
| `dns.windy.lan` | `192.168.66.36` | AdGuard Home + Mihomo |
| `ubnt` | `192.168.66.46` | UniFi ControllerInform `http://192.168.66.46:9080/inform` |
| U6 Lite | `192.168.66.6` | LAN66 AP;后续承载升级专用 SSID |
| UAP-AC-Lite | `192.168.55.5` | LAN55 AP;后续承载客人 SSID |
| PVE | `192.168.66.26`DHCP 静态) | 宿主机;上挂 `gfw` |
| NAS / 主力 PC 等 | 既有 reservation | 首批 2.5G 候选 |
---
## 4. 物理拓扑
### 4.1 目标连线(阶段 3 完成后)
```text
Internet
PPPoE / WAN
┌──────────┴──────────┐
│ EdgeRouter X │
│ (gw / 网关) │
│ │
│ eth0 switch0 成员口
│ LAN66 LAN55
│ untagged untagged
└───┬───────────────┬─┘
│ │
铜口1│ │铜口2
│ │
┌─────────┴───────────────┴─────────┐
│ TL-SE5420 核心 │
│ 16×2.5G RJ45 + 4×SFP+(先空) │
│ 纯 L2 · VLAN66 / VLAN55 │
└───┬─────┬─────┬─────┬─────┬───────┘
│ │ │ │ │
铜口3 铜口4 铜口5 其余铜口 SFP+
│ │ │ │ (预留)
│ │ │ │
PVE U6 AC-Lite NAS/PC/
(gfw) Lite (LAN55) dns/ubnt…
LAN66 LAN66 默认 LAN66
(日后可 (日后可
trunk) trunk)
```
要点:
1. **星型**:终端与 AP 只上联到 SE5420;旧交换机迁完后**闲置**,避免双上行环路。
2. **ER-X 双上联**LAN66、LAN55 各一条独立铜缆;两条都是 access,**不打 tag**。
3. **AP 供电**SE5420 无 PoEU6 / AC-Lite 在安装点**本地取电**,网线只传数据。
4. **SFP+**:四个槽位先空着;有 10G NIC/对端与兼容模块后再上 DAC/AOC/光纤。
### 4.2 与「现在」的差异(概念上)
```text
现在(简化) 目标
───────────── ────
终端/AP ──► ER-X 内置交换/旧交换 终端/AP ──► SE5420 ──► ER-X(仅网关)
同网段 1G 交换受 ER-X/旧设备限制 同 VLAN 2.5G 在 SE5420 上本地交换
跨网段 / 上网 仍经 ER-X 跨网段 / 上网 仍经 ER-X(不变)
```
ER-X **不会被换成**「只接一条 trunk 的路由」;WAN 与双 LAN 接口模型保持不变。
---
## 5. 逻辑网络(VLAN / 子网)
### 5.1 本次升级范围内(阶段 0–3)
| 名称 | 子网 | 核心上的形态 | 网关 | DHCP | 典型成员 |
|---|---|---|---|---|---|
| **LAN66** | `192.168.66.0/24` | VLAN 66access,或 trunk 的 native | ER-X `eth0` `.254` | ER-X | PVE、gfw、dns、ubnt、U6、NAS、PC… |
| **LAN55** | `192.168.55.0/24` | VLAN 55access | ER-X `switch0` `.254` | ER-X | UAP-AC-Lite、原 LAN55 设备 |
客户端 DNS 仍按现网:DHCP 下发 `192.168.66.36`AdGuard)。
LAN55 ↔ LAN66 互通仍走 ER-X(现网 `LAN_IN`/`LAN_OUT` 未挂到接口时,两边默认可达;不要把未生效的防火墙当隔离边界)。
### 5.2 后续能力(不进本次 Done)
| 名称 | 打算 | 网关 | 落点 | 依赖 |
|---|---|---|---|---|
| **升级专用 Wi-Fi** | 独立 SSID → 独立 VLAN(如 VLAN10 | **`gfw` `.1`**(该 VLAN 内) | **仅 U6 Lite** | 核心 trunk + PVE vSwitch + `gfw` DHCP/NAT |
| **客人网** | 客人 SSID + UniFi Guest 隔离 | 仍 **ER-X `.254`LAN55** | **仅 UAP-AC-Lite** | UniFi 策略;**不**新建 VLAN |
客人网与升级 SSID **分工到两台 AP**,避免一上来就做「两台 AP 同一客人 VLAN + ER-X trunk」的改造。
### 5.3 VLAN10 的时序与边界
第一阶段**不**在 SE5420 创建 VLAN10,也**不**把 PVE/U6 改为 trunk。VLAN10 必须在 LAN55/LAN66 稳定后,以独立维护窗完成 PVE VLAN-aware 隔离、SE5420 trunk、gfw IPv4 NAT/内网拒绝策略和 IPv6 策略;仅在这些验收通过后才启用专用 SSID。详见部署指南第二步。
---
## 6. 流量怎么走
### 6.1 同 VLAN、两端都在核心上(例如 NAS ↔ PC,皆 LAN66
```text
NAS ──(2.5G)── SE5420 ──(2.5G)── PC
(二层交换,不经过 ER-X)
```
这是本次升级的**主要收益**。吞吐受 NIC、线材、磁盘限制;与 ER-X 的 1G 无关。
### 6.2 访问互联网(任意 LAN66/LAN55 客户端)
```text
客户端 → SE5420 →(对应 access 上联)→ ER-X → PPPoE → Internet
默认网关 = .254
```
上网路径仍受 **ER-X 上联 1G** 与 PPPoE 限制;换核心**不会**把公网变成 2.5G。
### 6.3 LAN66 ↔ LAN55
```text
LAN66 设备 → SE5420(VLAN66) → ER-X eth0
↕ 三层转发
LAN55 设备 → SE5420(VLAN55) → ER-X switch0
```
跨网段必须过 ER-X;两条上联各 1G,且 ER-X 是否硬件加速需实测,**不要**按 2.5G 承诺跨 VLAN 吞吐。
### 6.4 旁路由 `gfw`(默认主网行为)
默认:DHCP 仍发网关 `.254`。客户端若把代理/策略指到 `gfw`,或由上游策略引流,则
`gfw` 以旁路由身份处理;**主网关身份仍是 ER-X**。
### 6.5 未来:升级专用 SSID(仅说明,非本次范围)
```text
手机 --SSID(升级)--> U6 Lite --tag VLAN10--> SE5420 --tag VLAN10--> PVE --> gfw
网关 .1
gfw 上游仍回 LAN66 → SE5420 → ER-X → Internet
```
主 SSID(如现有 `ubnt-windy`)继续 untagged/LAN66,网关仍是 `.254`,互不影响。
---
## 7. 端口预留(SE5420
端口号以面板为准;下表是角色预留。
| 预留 | 对端 | 模式 | 备注 |
|---|---|---|---|
| 铜口 1 | ER-X `eth0` | access VLAN66 | 主网上联 |
| 铜口 2 | ER-X `switch0` 成员口 | access VLAN55 | LAN55 上联 |
| 铜口 3 | PVE`gfw` 所在宿主机) | 阶段 03access VLAN66;日后 trunk native66+tag10 | 只允许需要的 VLAN |
| 铜口 4 | U6 Lite | 同上 | 本地取电;日后升级 SSID |
| 铜口 5 | UAP-AC-Lite | access VLAN55 | 本地取电;日后客人 SSID |
| 其余铜口 | NAS、PC、`dns``ubnt`… | 默认 access VLAN66 | 首批 2.5GNAS + 主力 PC/PVE |
| SFP+ 14 | 未来 10G | 空槽 | 优先 DAC/AOC/光;不用 10GBASE-T 模块作默认 |
禁止:同一设备同时接旧路径与新核心形成**无意双上行**(环路/环回保护误杀/不稳定)。
---
## 8. Wi-Fi 与 UniFi(目标语义)
| SSID 类型 | AP | 二层落点 | 客户端网关 | 本次升级是否必须完成 |
|---|---|---|---|---|
| 现有主 SSID(如 `ubnt-windy` | 按现网 | LAN66 等 | `.254` | 迁线后保持 Connected 即可 |
| 客人 SSID | **仅 AC-Lite** | LAN55 + UniFi Guest 隔离 | `.254` | **否**(后续) |
| 升级专用 SSID | **仅 U6** | 未来 VLAN10 | **`.1`gfw** | **否**(后续) |
Inform 仍为:`http://192.168.66.46:9080/inform`
AC-Lite 在 LAN55、Controller 在 LAN66:跨网段由 ER-X 转发(现网已验证可达)。
---
## 9. 管理与安全面
| 项 | 做法 |
|---|---|
| SE5420 管理 IP | LAN66 未占用地址;DHCP reservation 或静态;先查 ER-X 再写 |
| 管理协议 | 本地 HTTPS / SSH;禁用不需要的 HTTP/Telnet |
| 云管 | 先本地管稳;商云/App **以后再说** |
| L3 功能 | **全部关闭**(无 SVI、无静态路由、无 DHCP Server/Relay |
| STP | 开启 RSTP/MSTP;合理根桥优先级;迁完后不留生产环路 |
| 配置备份 | 离线导出;**不得**把含口令/SNMP 的备份放进本仓库 |
| AP | 本地电源 + 数据网线;核心不供电 |
---
## 10. 性能预期(诚实边界)
| 场景 | 预期 |
|---|---|
| 同 VLAN、两端 2.5G 接在 SE5420 | 可达约 2.5G 级二层(测 `iperf3` 验收) |
| 上网 / PPPoE | 仍受 ER-X WAN 与 1G 上联限制 |
| LAN66 ↔ LAN55 | 必过 ER-X;按 1G 级规划,实测为准 |
| SFP+ 空槽 | **不等于**网络已是 10G |
| 未来 VLAN10 + `gfw` | 受 PVE NIC/vSwitch 与 `gfw` 处理能力限制,勿预先承诺 2.5G 路由 |
---
## 11. 验收时「网络算正常」的样子
阶段 3 完成后,应同时满足:
1. 有线设备从 SE5420 获取正确网段地址;默认路由为对应 `.254`DNS `.36` 可用。
2. 互联网、端口转发、现有本地服务行为与迁线前一致(除同 VLAN 内网吞吐变快)。
3. U6`.6`)与 AC-Lite`.55.5`)在 UniFi 为 ConnectedInform 未改。
4. SE5420 管理面仅可信 LAN66 可达;默认口令已改;配置已离线备份。
5. 端口协商速率、CRC/错误计数正常;旧交换已下电闲置。
6. (可选强化)两台 2.5G 终端同 VLAN `iperf3` 结果入库/记笔记。
VLAN10 / 升级 SSID / 客人 SSID **失败或未做,不否决**本次核心升级。
---
## 12. 回滚语义
任一阶段失败:停止继续搬家;把刚迁的设备插回原 ER-X/旧交换端口。
**不要**在故障中改 ER-X 的 WAN、DHCP 池、网关地址或 SSH 策略。
后续 VLAN10 失败:只撤 SSID/VLAN 绑定,主 SSID 与 `.254` 路径保持不动。
---
## 13. 参考
- 实施阶段与清单:[lan-core-switch-upgrade-plan.md](archive/lan-core-switch-upgrade-plan.md)
- 现网地图:[lan-overview.md](lan-overview.md)
- ER-X[edgerouter-x-configuration.md](edgerouter-x-configuration.md)、[hosts/gw.md](../hosts/gw.md)
- UniFi / VLAN10 前置:[unifi-network.md](unifi-network.md)
- `gfw`[hosts/gfw.windy.lan.md](../hosts/gfw.windy.lan.md)
- SE5420 规格:<https://www.tp-link.com.cn/product_2899.html?v=specification>
+248
View File
@@ -0,0 +1,248 @@
# LAN network overview
A standalone, live-verified map of the **internal LAN** hosts and the software
deployed on them. Written for a future self / another agent to get oriented fast.
Focused per-host facts and runbooks live in [`../hosts/`](../hosts/) and are linked
from each section below.
> **Scope:** this document covers the real LAN only. It deliberately excludes the public VPS
> hosts (`mx2`, `us2`, `us4`, `hk2`, `synapse`) and any dormant/stub software. The
> WireGuard VPN server is external (`us4.wsvc.info`); there is **no WireGuard interface on
> the LAN side**.
> **Verified live on 2026-08-06** by read-only SSH from the WSL client. No
> changes were made. `gfw.windy.lan` root SSH was re-verified the same day after
> the key was installed; its facts below are from the fresh probe.
>
> **IPv6 re-verified 2026-08-20** (read-only): UniFi controller `Default`
> network IPv6 enabled (SLAAC/RA), both APs hold global SLAAC addresses, and
> `zhiqiangf` key-only AP SSH re-confirmed. See
> [unifi-network.md](unifi-network.md).
---
## Topology
```
Internet
│ PPPoE (pppoe0, MTU 1492, IPv6 PD /60)
┌───────────────────────────────────────────────┐
│ gw — EdgeRouter X (192.168.66.254) │
│ eth0 = LAN66 · switch0(eth1-3) = LAN55 │
│ WAN_IN active on pppoe0 (default-drop) │
│ port-forwards: hass/transmission/ssh/openvpn │
LAN66 └───────────────┬──────────────────────────────┘
(192.168.66.0/24) │
│ gfw.windy.lan — OpenWrt / OpenClash (192.168.66.1)
│ dns.windy.lan — AdGuard Home + Mihomo (192.168.66.36)
│ ubnt — UniFi Network Controller (192.168.66.46)
```
> **SE5420 live (2026-08-22):** TP-Link `TL-SE5420` (purchased 2026-08-09) is
> online — management `192.168.66.253` reachable, web UI on :80/:443; LAN55
> 上联为 ER-X `switch0` **单口**`eth1` up、`eth2`/`eth3` down2026-08-22
> 只读核实)→ `switch0` 不再是 LAN55 全量抓包点(同段有线单播在 SE5420 本地
> 交换),全量点只能靠 SE5420 port mirroring。迁移状态见部署计划
> [lan-se5420-deployment-guide.md](lan-se5420-deployment-guide.md). Design/planning refs:
> [lan-erx-se5420-network.md](lan-erx-se5420-network.md),
> [lan-core-switch-upgrade-plan.md](archive/lan-core-switch-upgrade-plan.md).
---
## Hosts
| Host | Role | SSH | IPv4 | Facts |
|------|------|-----|------|-------|
| **gw** | EdgeRouter X primary LAN gateway | `ssh -4 zhiqiang@192.168.66.254` | `192.168.66.254` | [hosts/gw.md](../hosts/gw.md) |
| **PVE** | Proxmox host (`.66.26`/vmbr0 · `.55.26`/vmbr1) — hosts gfw/dns/ubnt VMs | `ssh -4 root@192.168.66.26` | `192.168.66.26` | — |
| **gfw.windy.lan** | OpenWrt LAN gateway / OpenClash — **PVE VM 140** | `ssh -4 root@192.168.66.1` | `192.168.66.1` | [hosts/gfw.windy.lan.md](../hosts/gfw.windy.lan.md) |
| **dns.windy.lan** | AdGuard Home LAN DNS + Mihomo explicit proxy — **PVE VM 120** (`pihole`) | `ssh -4 windy@192.168.66.36` | `192.168.66.36` | [hosts/dns.windy.lan.md](../hosts/dns.windy.lan.md) |
| **ubnt** | UniFi Network Controller — **PVE VM 160** | `ssh -4 windy@192.168.66.46` | `192.168.66.46` | [hosts/ubnt.md](../hosts/ubnt.md) |
| **hass.windy.lan** | Home Assistant (HAOS) — **x88 Pro physical box** (LAN55) | `ssh hassio@hass.windy.lan` | `192.168.55.11` | [hosts/hass.windy.lan.md](../hosts/hass.windy.lan.md) |
| **pgdb** | TimescaleDB PG18 (Docker) — HA recorder 后端 — **PVE VM** (LAN55) | `ssh -4 windy@192.168.55.15` | `192.168.55.15` | [hosts/pgdb.md](../hosts/pgdb.md) |
| **NAS/FreeNAS** | NAS; `transmission` jail runs here (`.51`) | — | — | — |
| **U6 Lite** | UniFi AP (LAN66) | `ssh -4 zhiqiangf@192.168.66.6` | `192.168.66.6` | [docs/unifi-network.md](../docs/unifi-network.md) |
| **UAP-AC-Lite** | UniFi AP (LAN55) | `ssh -4 zhiqiangf@192.168.55.5` | `192.168.55.5` | [docs/unifi-network.md](../docs/unifi-network.md) |
> **Positioning facts:** `dns`/`ubnt`/`gfw`/`pgdb` are VMs on PVE; `haos` is a **physical x88 Pro
> box** (HAOS bare-metal, `machine: green`), not a PVE VM (corrected 2026-08-15).
> `transmission` is a FreeNAS/NAS jail. Physical SE5420 ports: gw, PVE, haos, NAS,
> U6, UAP-AC-Lite, and wired PCs. See [lan-se5420-deployment-guide.md](lan-se5420-deployment-guide.md) §1.
---
## gw — EdgeRouter X (LAN gateway)
| Item | Value |
|------|-------|
| Platform | EdgeOS v3.0.1 (Build 5862409), kernel 4.14.54-UBNT |
| Interfaces | `eth0`=192.168.66.254/24 (LAN66) · `switch0`=192.168.55.254/24 (LAN55) · `eth4`=WAN PPPoE · `pppoe0`=WAN default route (MTU 1492, IPv6 PD /60) |
| WAN | `pppoe0`=113.68.54.159 (PPPoE) · IPv6 SLAAC /64 on both LANs |
| Firewall | `WAN_IN` **active** on pppoe0 (established/related accept, invalid drop, default drop) · `LAN_IN`/ `LAN_OUT` defined but **inactive** (not attached) |
| DHCP | LAN1 (66) + LAN2 (55), 24h leases, client DNS = `192.168.66.36`, pools .38.243, static mappings for infra hosts |
| DNS | `service dns forwarding` cache 512, listen on eth0 + switch0; DHCP advertises `192.168.66.36` as client DNS |
| Port-forwards | `hass`→192.168.55.11:8123 · `transmission`→192.168.66.51:51413 · `ssh`→192.168.66.36:22 (orig 5822) · `openvpn`→192.168.66.32:1194 · WAN iface pppoe0 |
| Management | SSH TCP 22 · EdgeOS GUI HTTP 80 / HTTPS 443 |
**Static DHCP mappings (LAN66):** `OnePlus-12`=.37, `gfw`=.1, `hp-nas`=.32, `pihole`=.36, `pve`=.26, `transmission`=.51, `ubnt-6`=.6, `ubnt-app`=.46, `windy-pc`=.99. LAN55: `Aqara-Hub-M3-10CB`=.248, `SmartThings-Station`=.48, `espressif`=.47,
`hass`=.11, `hass-wifi`=.250, `ihost`=.12, `midea_ac_0418`=.10,
`midea_e3_0198`=.42, `roborock-wm-a141`=.43, `samsung-hub`=.251,
`matter`=.41 (added 2026-08-20).
> **Note:** `LAN_IN`/`LAN_OUT` are defined but not applied to an interface, so LAN55
> and LAN66 are bidirectionally reachable by default. Do not rely on those rules as
> an access boundary. See [hosts/gw.md](../hosts/gw.md).
---
## gfw.windy.lan — OpenWrt / OpenClash gateway
| Item | Value |
|------|-------|
| IPv4 | `192.168.66.1` |
| OS | **ImmortalWrt 25.12.0** (r37854), Linux 6.12.87, x86/64 |
| SSH | `ssh -4 root@192.168.66.1` (key-only, verified 2026-08-06) |
| OpenClash | `/etc/openclash/clash` (clash_meta core) + config `/etc/openclash/pass-cat.yaml` |
| Mode | **fake-ip + TPROXY transparent proxy** (`operation_mode=fake-ip`, `en_mode=fake-ip`, `proxy_mode=rule`) |
| DNS | dnsmasq → clash DNS `127.0.0.1#7874`; clash `nameserver` = mosdns `127.0.0.1:6052` (DIRECT 规则真实 IP 解析,非客户端路径) |
| nft | `table inet fw4` with OpenClash TPROXY/redirect + DNS-hijack rules; residual `table inet passwall` (0 packets, unused) |
**OpenClash listeners:** HTTP `7890` · SOCKS `7891` · Redirect `7892` · Mixed `7893` · TPROXY `7895` · DNS `7874` · dashboard `9090`. `8443` is **not** an OpenClash listener (only in its TLS-sniffing port list).
> **Operational note:** a LAN client request logged as `<client> --> localhost:8443` + refusal is normally an explicit-proxy client sending its own loopback to the gateway. Fix the
> originating host's `NO_PROXY`/`no_proxy` config; do not create a listener on this router to mask it. See [docs/unifi-openclash-localhost.md](../docs/unifi-openclash-localhost.md).
---
## dns.windy.lan — AdGuard Home + Mihomo (DNS & proxy host)
| Item | Value |
|------|-------|
| IPv4 | `192.168.66.36` |
| OS | Debian 12 (kernel 6.1.0-43) |
| SSH | `ssh -4 windy@192.168.66.36` |
| AdGuard Home | Docker container `adguardhome` (host network, pinned digest), `/opt/adguardhome` |
| DNS | TCP/UDP `53` (no source ACL — serves both LAN55 and LAN66) |
| UI | `192.168.66.36:80` (LAN66 only via nft `agh_ui_access`) |
| Upstreams | `https://dns.alidns.com/dns-query` + `https://doh.pub/dns-query` (load-balanced) · bootstrap `223.5.5.5`/`223.6.6.6` · fallback `https://adg.chans.xyz/dns-query` |
| DNSSEC | **disabled** (upstream path failed known-bad-signature check) |
| Rewrites | `hass.local` + `hass.windy.lan` → same Home Assistant address |
| Query log | 14 days, anonymized client IPs |
| Mihomo | **systemd service** (NOT Docker) — `/usr/local/bin/mihomo -d /etc/mihomo`, config `/etc/mihomo/config.yaml` |
| Mihomo listeners | HTTP `7890` · SOCKS `7891` · Mixed `7892` · DNS `1053` · external-controller `9090` · `allow-lan: true` · mode `rule` |
| nft | `table inet agh_ui_access` permits only `192.168.66.0/24` to TCP/80, drops other TCP/80 input |
**Routing boundary:** read-only checks found no host TUN policy routing or nftables/iptables transparent-redirection on this host. A Mihomo listener alone does not mean it intercepts AdGuard traffic; the default gateway (`gfw.windy.lan`) can still affect outbound, so inspect its OpenClash rules separately before changing upstream routing.
---
## ubnt — UniFi controller host
| Item | Value |
|------|-------|
| IPv4 | `192.168.66.46` |
| Compose | `/home/windy/unifi-9/compose.yml` · service `unifi` · container `unifi-controller` |
| Image | `jacobalberty/unifi:v9.5.21` (host network) |
| HTTPS UI | `https://192.168.66.46:8443` |
| Inform | `http://192.168.66.46:9080/inform` (not 8080) |
| Health | `docker inspect --format '{{.State.Health.Status}}' unifi-controller``healthy` (2026-08-06) |
| Also running | **dockge** (`louislam/dockge:1`, healthy) — compose UI, `/opt/stacks` |
| Dormant | `semaphoreui` (semaphore + postgres, exited 9 months) · `homarr` dir (no container) |
**Proxy contract:** the container keeps `NO_PROXY`/`no_proxy` enabled with
`localhost,127.0.0.1,::1,192.168.0.0/16,10.0.0.0/8,172.16.0.0/12` so a controller
request to `localhost:8443` stays local instead of going to the OpenClash gateway.
See [docs/unifi-openclash-localhost.md](../docs/unifi-openclash-localhost.md).
---
## hass.windy.lan — Home Assistant (HAOS)
| Item | Value |
|------|-------|
| IPv4 | `192.168.55.11` (LAN55) |
| DNS | `hass.windy.lan` (AdGuard rewrite; legacy `hass.local` alias) |
| SSH | `ssh hassio@hass.windy.lan` (key-only, verified 2026-08-13) |
| Web UI | `http://hass.windy.lan:8123` |
| WAN | gw port-forward `hass``192.168.55.11:8123` |
| Platform | HAOS on physical x88 Pro box; kernel `6.1.115-haos` (aarch64), `machine: green` |
---
## Managed access points
| Name | Model | Mgmt IP | Firmware | Network | Inform |
|------|-------|---------|----------|---------|--------|
| U6 Lite | U6 Lite (UAL6) | `192.168.66.6` | 6.7.54.15663 | LAN66 | `:9080` |
| UAP-AC-Lite | U7LT | `192.168.55.5` | 6.8.2.15592 | LAN55 | `:9080` |
Both reported **Connected** to `http://192.168.66.46:9080/inform` on 2026-08-06.
AP SSH account is `zhiqiangf` (key-only, verified). See [docs/unifi-network.md](../docs/unifi-network.md).
**IPv6 (verified 2026-08-20):** both APs hold global SLAAC IPv6 addresses on
`br0` — U6 Lite `240e:3bd:235:1fb1::/64` (LAN66), UAP-AC-Lite
`240e:3bd:235:1fb2::/64` (LAN55) — with RA default routes via `gw`; the
controller's `Default` network has IPv6 enabled (SLAAC). Prefixes are dynamic
(PPPoE PD), so they rotate on redial. Details:
[docs/unifi-network.md](../docs/unifi-network.md).
**SSID cleanup (2026-08-21, W1N-207):** the SmartThings Element/vWire provisioning
SSIDs (`element-8a0d5133c9438f12`, `vwire-8b2d67469e455785`, `vport-F09FC22004E9`)
were removed/disabled in the controller (`element_adopt` setting off, element wlanconf
deleted, connectivity `x_mesh_essid`/`x_mesh_psk` cleared, device `x_vwirekey` removed,
`vwire_enabled`/`mesh_sta_vap_enabled=false`) and cleared from both APs; all
vwire/vport/element flags on the remaining SSIDs are now `disabled`.
**Stable ULA on gw: not feasible (2026-08-21, W1N-207):** EdgeOS v3.0.1
`interfaces switch switch0` rejects a static `ipv6 address`, and an explicit
`router-advert` node *replaces* the DHCPv6-PD-slaac RA (drops the delegated GUA
prefix from radvd → LAN55 loses IPv6 egress after RA expiry). Attempted and rolled
back cleanly (no `save`; gw config unchanged). Consequence: after a PD rotation,
restart HA's matter-server (see [hosts/hass.windy.lan.md](../hosts/hass.windy.lan.md))
to clear stale IPv6 mDNS caches.
**LAN55 RA environment (observed 2026-08-21):** besides `gw`, the SmartThings
Station (.48) and Aqara M3 (.248) act as Thread border routers and advertise ULA
prefixes (`fd00:5a7:6415:1::/64`, `fd97:d580:16fe:1::/64`); several LAN55 hosts
(HA, PVE, UAP-AC-Lite) have IPv6 forwarding enabled and mark themselves as
routers in NDP. This is normal Thread-BDR behaviour and was not the Matter
failure cause.
**Matter 灯泡(2026-08-21 实测,W1N-207):** 两盏 ESP32-C2 Matter 灯泡
VP `0x4891/0x4100`OUI `34:98:7a`)——工作盏 MAC `34:98:7a:25:a1:f0`;故障盏
MAC `34:98:7a:27:7f:08`hostname `matter`,动态 .145)。故障盏已在 Aqara fabric
`4DF2B1455D19402D` 内、宣告 `CM=0`(不在配对模式)且缺 GUA → 找回需**恢复出厂**
后扫它自己的二维码。DHCP 保留 `matter`.45 → MAC `34:98:7a:27:10:bc`)与故障盏
MAC 不符,保留从未租出(待修,见 [hosts/gw.md](../hosts/gw.md))。完整排障知识:
[docs/matter-pairing-troubleshoot.md](matter-pairing-troubleshoot.md)。
---
## Quick orientation (who runs what)
| Service | Host | How to reach |
|---------|------|--------------|
| LAN gateway / routing / DHCP | gw | `ssh -4 zhiqiang@192.168.66.254` |
| Transparent/explicit proxy (OpenClash) | gfw.windy.lan | `ssh -4 root@192.168.66.1` |
| LAN DNS (AdGuard Home) + Mihomo proxy | dns.windy.lan | `ssh -4 windy@192.168.66.36` |
| UniFi controller + dockge | ubnt | `ssh -4 windy@192.168.66.46` |
| Home Assistant | hass.windy.lan | `ssh hassio@hass.windy.lan` · UI `:8123` |
| Wi-Fi APs | U6 Lite / UAP-AC-Lite | via controller |
---
## Related docs
- [hosts/gw.md](../hosts/gw.md) — EdgeRouter X detail, firewall, verification record
- [hosts/gfw.windy.lan.md](../hosts/gfw.windy.lan.md) — OpenClash listeners
- [hosts/dns.windy.lan.md](../hosts/dns.windy.lan.md) — AdGuard Home + Mihomo detail
- [hosts/ubnt.md](../hosts/ubnt.md) — UniFi controller + proxy contract
- [hosts/hass.windy.lan.md](../hosts/hass.windy.lan.md) — Home Assistant (HAOS) SSH + LAN access
- [docs/unifi-network.md](../docs/unifi-network.md) — APs, inform endpoint, recovery
- [docs/unifi-third-party-vlan10-dhcp.md](unifi-third-party-vlan10-dhcp.md) — VLAN Wi-Fi feasibility and DHCP boundary
- [docs/unifi-openwrt-vlan10-implementation-examples.md](unifi-openwrt-vlan10-implementation-examples.md) — supported topology and examples
- [docs/edgerouter-x-configuration.md](../docs/edgerouter-x-configuration.md) — effective gw config
- [docs/unifi-openclash-localhost.md](../docs/unifi-openclash-localhost.md) — proxy bypass
- [runbooks/adguard-home-health.md](../runbooks/adguard-home-health.md) — AGH health
+495
View File
@@ -0,0 +1,495 @@
# SE5420 核心交换机升级操作指导(细粒度版)
**目标:** 把 LAN66/LAN55 有线接入从 ER-X 内置交换迁到 TL-SE5420;稳定后再启用 VLAN10 升级专用 SSID。
**全程三条(不再重复):**
1. 先断旧、后接新,一次一根线;同一设备不得同时接旧路径与新核心。
2. 全程保持 SE5420 Type-C Console38400 8N1)连接——SSH 会话移动其承载链路即断,不是回滚保障。
3. 任一步「预期」不符 → 停止,执行 §16 对应回滚;故障中不动 ER-X 的 WAN/DHCP/SSH。
**关于命令的真实性:** SE5420 的 GUI 菜单路径来自官方 Omada VLAN/管理指南;CLI 语法仅采用官方手册出现过的(`enable``telnet disable``copy running-config startup-config``show vlan`)。PVE/gfw 的每步先有只读快照命令,改动均以你实际看到的输出为准,不套用写死的模板。
---
# 第一阶段:主网 55/66 迁移
## 1. 开工前准备
### 1.1 备份(4 个动作,全部留档到 `/tmp/opencode/baseline-YYYYMMDD/`,不进仓库)
1. ER-X 配置快照(滤敏感行):
```bash
mkdir -p /tmp/opencode/baseline-$(date +%Y%m%d)
ssh -4 zhiqiang@192.168.66.254 'show configuration commands' \
| grep -Ev 'pppoe|password|secret|key|pre-shared' \
> /tmp/opencode/baseline-$(date +%Y%m%d)/gw-sanitized.txt
```
- 预期:文件非空,`grep -i 'password\|pppoe' gw-sanitized.txt` 无输出。
2. PVE 配置:
```bash
ssh -4 root@192.168.66.26 'cat /etc/network/interfaces; echo ---; qm config 140' \
> /tmp/opencode/baseline-$(date +%Y%m%d)/pve-interfaces.txt
```
- 预期:能看到 `vmbr0`/`vmbr1` 定义与 VM140 的 `net0`/`net1`
3. gfw 配置:
```bash
ssh -4 root@192.168.66.1 'cat /etc/config/network /etc/config/dhcp /etc/config/firewall' \
> /tmp/opencode/baseline-$(date +%Y%m%d)/gfw-uci.txt
```
- 预期:三个文件内容都在,且能看到 `ubunt_upg` zone、`ubunt_upg_nat``ubunt_upg_to_lan`
4. 检查备份文件存在且可读:`ls -la /tmp/opencode/baseline-$(date +%Y%m%d)/`
### 1.2 记录现网状态(迁移后比对用)
1. ER-X 状态:
```bash
ssh -4 zhiqiang@192.168.66.254 'show interfaces; show ip route; show dhcp server leases'
```
- 预期:eth0/switch0 up;两条直连路由 + pppoe0 默认路由;DHCP lease 正常。
2. PVE 地址:
```bash
ssh -4 root@192.168.66.26 'ip -br addr'
```
- 预期:`vmbr0`=192.168.66.26、`vmbr1`=192.168.55.26。
3. gfw 关键状态:
```bash
ssh -4 root@192.168.66.1 'uci show firewall | grep -E "ubunt_upg|wan.*masq"; ip -4 route'
```
- 预期:`wan.masq=1``ubunt_upg_nat``ubunt_upg_to_lan` 在位;默认路由 `default via 192.168.66.254`
4. 两台 AP:打开 UniFi 控制台 → Devices,记录 U6 Lite 与 UAP-AC-Lite 的 IP、Connected 状态、Inform URL。
### 1.3 SSID 与标签
1. 禁用不可用 SSIDUniFi → WiFi → 找到 `ubnt-upg` → 关闭 Enabled 开关。
- 预期:开关变灰/显示 Disabled。
- 记录原设置(第二步回滚要用)。
2. 给现网每根线缆贴标签:`旧设备端接口名` + `目标 SE5420 口`,如 `NAS→SE5420口4``ERX-eth0→SE5420口1`
3. 打印 §2 端口表放在操作位。
### 1.4 物料与带外
1. 确认有:SE5420(核对 V1.0 型号)、原装电源线、Type-C 串口线、Cat5e 以上网线数根、标签贴。
2. 管理本装「TP-LINK USB 串口驱动」(官网下载或随机光盘)。
3. 终端仿真软件准备(PuTTY/串口助手/Windows Terminal),预填参数 **38400 / 8 / 无校验 / 1 / 无流控**
## 2. 端口表(SE5420
> 面板端口为**上下交错**排列:下排 = 1、3、5、7、9、11、13、15;上排 = 2、4、6、8、10、12、14、16(口 1 最左下,口 2 在其正上方)。
> **分组原则:上排全 = LAN66,下排全 = LAN55**(55 在 66 下一排);ER-X 两条上联在最左相邻(口 2=66、口 1=55)。
### 上排 = VLAN66(口 2、4、6、8、10、12、14、16
| SE5420 口 | 对端 | 模式 | PVID | 成员 VLAN |
|---|---|---|---|---|
| **2** | ER-X `eth0`66 上联) | access | 66 | untagged 66 |
| **4** | PVE `enp1s0` | access | 66 | untagged 66 |
| **6** | U6 Lite | access | 66 | untagged 66 |
| **8** | NAS/FreeNAS 第 1 口 | access | 66 | untagged 66 |
| **10** | windy-pc`.99`)或 hp-nas`.32`)二选一 | access | 66 | untagged 66 |
| **12** | NAS 第 2 口(后续 LACP 窗用) | 物理断开 | — | — |
| **14** | LAN66 预留 | admin down | — | — |
| **16** | LAN66 预留 | admin down | — | — |
### 下排 = VLAN55(口 1、3、5、7、9、11、13、15
| SE5420 口 | 对端 | 模式 | PVID | 成员 VLAN |
|---|---|---|---|---|
| **1** | ER-X `switch0` 成员口(55 上联) | access | 55 | untagged 55 |
| **3** | PVE `enp2s0` | access | 55 | untagged 55 |
| **5** | UAP-AC-Lite | access | 55 | untagged 55 |
| **7** | 隔壁房间上联(IoT | access | 55 | untagged 55 |
| **9** | 打印机(未来) | admin down | — | — |
| **11** | LAN55 预留 | admin down | — | — |
| **13** | LAN55 预留 | admin down | — | — |
| **15** | LAN55 预留 | admin down | — | — |
| SFP+ 14 | 最右侧 4 个 | 空槽 | — | — |
- 第一步不建 VLAN10、不向 ER-X 送任何 tag、口 4/6PVE、U6)不做 trunk。
- NAS 只接口 8,口 12 断开(LACP 是独立维护窗)。
- 不占口的 VMdns(.36=VM120)、ubnt(.46=VM160)、gfw(.1=VM140)haos(.55.11) 是物理 x88 Pro 盒子(非 VM);transmission(.51) 是 NAS jail。
## 3. 开箱与固件升级
1. 核对包装型号 `TL-SE5420`;看设备底部标签硬件版本 `V1.0`
2. 浏览器打开 `https://www.tp-link.com.cn/product_2899.html?v=download`
3. 页面「升级软件」分类下下载最新固件(已知 `20230908_1.0.0`,以页面为准);解压得到 `.bin` 文件。
4. 管理本网线接 SE5420 任意 RJ45 口;管理本设静态 IP `10.18.18.10/24`(网关留空)。
5. 浏览器 `http://10.18.18.251`,首次登录创建用户名 + 强密码。
6. WEB 里 `系统管理 → 系统工具 → 软件升级 → 选择文件` 选固件 → 升级 → 等待重启,**期间勿断电**。
7. **验证:** 升级后 `系统管理 → 系统配置 → 系统信息` 版本号正确;面板 SYS 灯绿色闪烁、Link/Act 闪一下后熄灭。
## 4. 台面初始化 SE5420(不接生产)
### 4.1 登录通道确认
1. Console 线接 SE5420 Type-C Console 口 → 管理本 USB。
2. 打开串口终端(38400/8/无/1/无流控),连按回车。
3. **预期:** 出现 `TL-SE5420>` 提示符。
4. 输入 `enable` 回车,进特权模式(提示符变 `#` 或类似)。
5. 若 WEB 也通(第 3 步已创建账号),记录:WEB + Console 双通道可用。
### 4.2 基础安全
1. WEB 登录 → 改强口令:`系统管理 → 用户管理 → 用户配置` → 修改用户名/密码 → 保存。
2. 设时区与 NTP`系统管理 → 系统配置 → 系统时间` → 时区 `Asia/Shanghai` → NTP 服务器填 `ntp.aliyun.com`(或 `pool.ntp.org`)→ 保存。
3. 关 Telnet`系统管理 → 安全管理 → Telnet配置` → 关闭 Enable。
- CLI 替代:`telnet disable`
4. 关 HTTP`系统管理 → 安全管理 → HTTP配置` → 关闭 HTTP 服务(保留 HTTPS)。
5. 开 SSH`系统管理 → 安全管理 → SSH配置` → 启用 SSH(端口 22)。
6. 关云管理:`系统管理 → 云管理 → 全局配置` → 全局开关保持关闭(不绑定 `smbcloud.tp-link.com.cn`)。
7. **保存:** 顶部/右上角保存按钮,或 `配置保存 → 保存配置`
8. **验证:** CLI `show running-config` 中 Telnet/HTTP 为 disable;管理本 `https://10.18.18.251` 能开、SSH 22 能连(此时管理 IP 仍是默认 10.18.18.25166.253 的 HTTPS 验证在 §4.3 第 7 步做)。
### 4.3 VLAN 与管理地址(本步只建 66/55)
**步骤顺序依据:先建二层 VLAN(勾选端口成员)→ 再建 SVI → 验证管理路径 → 才清理 VLAN1。**
> **端口成员在 VLAN 配置界面的 Untagged/Tagged 选择器里直接勾选**,无独立端口配置页。Untagged = 该 VLAN 的 access 口;PVID 随之对应(以界面为准)。
1. 建 VLAN66`VLAN → 802.1Q VLAN → VLAN配置` → 新建 → VLAN ID 填 `66`**Untagged 勾选:口 2、4、6、8、10**Tagged 不选)→ 保存。
2. 建 VLAN55:新建 → VLAN ID 填 `55`**Untagged 勾选:口 1、3、5、7**Tagged 不选)→ 保存。
3. **验证:** VLAN 列表出现 66、55;VLAN1 仍在(暂不动)。
4. 建管理 SVI`路由功能 → 接口 → 接口设置` → 创建接口 → 接口 ID `VLAN 66` → IP 地址模式 `Static` → IP `192.168.66.253`、掩码 `255.255.255.0` → 管理状态开启 → 保存。默认网关:`路由功能 → 静态路由 → IPv4静态路由条目` → 新建:目的 `0.0.0.0`、掩码 `0.0.0.0`、下一跳 `192.168.66.254`、管理距离 `1`
5. 管理本改静态 IP `192.168.66.10/24`,网线接 SE5420 **口 6**(口 6 已在 VLAN66)。
6. **验证管理路径:**
- `ping 192.168.66.253` → 通;
- 浏览器 `https://192.168.66.253` → 登录成功;
- (可选)SSH`ssh -o KexAlgorithms=+diffie-hellman-group1-sha1 -o HostKeyAlgorithms=+ssh-rsa admin@192.168.66.253` → 成功。
- 全通后再进行下一步;否则停手排查(查 Untagged 勾选/PVID/接线),保持 Console 兜底。
7. **保存配置**(右上角保存或 `配置保存 → 保存配置`)。
8. **验证:** `show vlan`:口 2/4/6/8/10 属 VLAN66,口 1/3/5/7 属 VLAN55,口 9/11/12/13/14/15/16 无成员;VLAN1 暂时还有全口成员(下一步清理)。
9. 清理 VLAN1`VLAN → 802.1Q VLAN → VLAN配置` → 编辑 VLAN1 → 把口 116 从 **Untagged 勾选**中移除(保留 66 或 55 的勾选不变)→ 保存。**改 PVID 不等于移除成员。**
> **VLAN1 不可删除属正常**TP-Link 默认 VLAN),且 **access 口必须属于某 untagged VLANGUI 会拒绝把口从 VLAN1 剔除**——不必强求。做法改为:**先把未用口 Disabled(第 13 步)再清 VLAN1 IP(第 10 步)**disabled 口不转发流量,VLAN1 剩下无管理 IP + 全 disabled 成员 = 无管理面、无转发路径,目标达成。
10. 删 VLAN1 管理 IP`路由功能 → 接口 → 接口设置` → VLAN1 → 清空 IPv4。
11. **保存配置**
12. **验证:** `show vlan` VLAN1 无任何成员、无管理 IPVLAN66 管理路径仍通(SSH/HTTPS 不断)。
13. 关未用端口:`二层交换 → 端口管理 → 端口配置` → 口 9、11、12、13、14、15、16 **及 SFP+ 1720** → 端口状态 `Disabled`;并确认这些口不在任何 VLAN 的 Untagged/Tagged 勾选中。**口 12 是 NAS LACP 预留口,未来 LACP 维护窗需在此重新启用。**
14. 配管理面源限制:`系统管理 → 安全管理 → 安全配置`(管理主机/管理访问,以界面为准)→ 只允许源 `192.168.66.0/24` 访问 HTTPS/SSH(启用 SNMP 则一并限制)→ 其余拒绝 → 保存。
> ⚠️ **不要**在 `访问控制` 菜单配——那是数据面 ACL(MAC/IP ACL),管不到管理面;管理面限制在 `安全管理 → 安全配置`
15. 配生成树:
- 全局配置(`生成树 → 基本配置`):功能`启用`、模式`RSTP`、**CIST优先级 `4096`**(让本机当根桥);联络时间 2 / 老化 20 / 时延 15 / 流量限制 5 / 最大跳数 20 保持默认。
- 端口配置(`生成树 → 端口配置`):**终端口 5、6、8、10 的「边缘端口」设`启用`**= PortFast);上联口 1、2、3、4、7 保持`禁用`。状态列显示`禁用`是链路未接/未启用,属正常。
- 端口保护(`生成树 → 安全配置 → 端口保护`):**终端口 5、6、8、10 的「BPDU保护」设`启用`**;上联口 1、2、3、4、7 保持`禁用`。环路保护/根桥保护/TC保护/BPDU过滤全部保持`禁用`
- 保存。
16. **L3 明确边界检查:** `路由功能` 确认只有 VLAN66 有接口(`路由功能 → 接口 → 接口设置` 仅 VLAN66 有 IPv4);`路由功能 → 静态路由 → IPv4静态路由条目` 只有默认路由一条(0.0.0.0→.254);`路由功能 → DHCP服务器``DHCP中继` 未启用;`路由功能 → RIP` 未启用。
17. **保存 + 导出备份:** `配置保存 → 保存配置``系统管理 → 系统工具 → 配置导出` → 导出配置文件到管理本(离线保存,不进仓库)。
18. **断电重启验证:** 拔电源 10 秒 → 插回 → 重启完成后 `ping 192.168.66.253` 通、HTTPS/SSH 可登录 → 证明保存生效。
### 4.4 台面完成判定
- `show vlan` 结果与 §2 端口表一致;VLAN1 空;管理仅 LAN66 可达;配置已导出。
## 5. 建立上联(维护窗开始)
1. 找到 ER-X `eth0` 当前所连的旧设备/旧交换线,**先拔掉旧线**(避免 eth0 同时连两条路径形成环路)。
2. 新线一端插 SE5420 **口 2**,另一端插 ER-X `eth0`
3. **验证:**
- 管理本 `ping 192.168.66.253` → 通(SE5420);
- `ping 192.168.66.254` → 通(ER-X);
- `ping 223.5.5.5` → 通(外网);
- SE5420 `show interface` 口 2:Link up、速率协商正常、错误计数 0。
4. 找到 ER-X `switch0` 某成员口(eth1–eth3 任一口)的旧接线,先拔。
5. 新线一端插 SE5420 **口 1**,另一端插该 switch0 成员口。
6. **验证:**
- 从 55 网段设备(或临时给管理本加 `.55.x` 地址)`ping 192.168.55.254` → 通;
- `ping 192.168.66.46`(控制器,跨网段)→ 通;
- SE5420 `show interface` 口 1:Link up、错误计数 0。
## 6. 迁移 PVE
1. 拔 PVE `enp1s0` 旧线 → 插 SE5420 **口 4**
2. **验证:**
```bash
ssh -4 root@192.168.66.26 'ip -br addr'
```
- 预期:`enp1s0`/`vmbr0` = `192.168.66.26/24`
- `ssh -4 root@192.168.66.26 'ping -c3 192.168.66.254'` → 通。
3. 拔 PVE `enp2s0` 旧线 → 插 SE5420 **口 3**
4. **验证:**
- `ip -br addr``vmbr1` = `192.168.55.26/24`
- `ping -c3 192.168.55.254` → 通。
5. 逐台验证(顺序:gfw → dns → ubnt → haos;前三个是 VM,haos 是物理盒子):
```bash
ssh -4 root@192.168.66.26 'qm list'
```
- gfw`ping -c3 192.168.66.1` → 通;
- dns`ping -c3 192.168.66.36` → 通;
- ubnt`ping -c3 192.168.66.46` → 通;
- haos`ping -c3 192.168.55.11` → 通(注意是 55 网段)。
6. 每台再验业务:gfw 的 OpenClash 面板/DNS 正常、dns 的 AdGuard UI 能开、ubnt 控制器 Connected、haos 界面能开。不以"宿主开机"代替。
## 7. 迁移 AP 与接入设备
1. U6 Lite:先拔旧线 → 插 SE5420 **口 6**(本地 PoE 注入器不动,网线只走数据)。
2. **验证:** U6 重连后拿回 `192.168.66.6`UniFi 控制台显示 U6 ConnectedInform 与 §1.2 记录一致。
3. UAP-AC-Lite:拔旧线 → 插 SE5420 **口 5**
4. **验证:** `192.168.55.5`UniFi 显示 Connected。
5. 隔壁房间上联:拔旧线 → 插 SE5420 **口 7**
- 插之前确认对端:若对端是小交换机,须满足——只有一条上联、只承载 untagged LAN55、无 VLAN trunk、无第二条路径回 ER-X/SE5420。任一项不满足则不迁移。
6. **验证:** 该网段设备拿到 `.55.x`、网关 `192.168.55.254`、DNS `192.168.66.36` 可达。
7. NAS:拔旧线 → 插 SE5420 **仅口 8**(口 12 保持断开)。
8. **验证:** NAS 拿到 `.66.x`(应为原静态 `.51` 或 reservation),`ping 192.168.66.36` 通,transmission jail 可访问。
9. 口 10 只接一台明确设备(windy-pc `.99` 或 hp-nas `.32`):拔旧线 → 插口 10。
10. **验证:** 该设备拿 `.66.x`、上网正常。
11. 确认没有并行二层路径后,旧交换/旧接线下电、收线。
## 8. 第一阶段验收(全绿才进第二阶段)
1. SE5420`ping 192.168.66.253` 通,且从 55 网段**不通**(依赖 §4.3 第 14 步管理面源限制已配;若 ACL 未生效,从 55 网段经 ER-X 路由仍可达 .253——此条不通过则回查 ACL);默认口令已改;配置已离线备份。
2. LAN66 客户端:`ip addr``.66.x`、路由 `default via 192.168.66.254`、DNS `192.168.66.36``ping 223.5.5.5` 通。
3. LAN55 客户端:`.55.x``default via 192.168.55.254`LAN55↔LAN66 互通(如 `ping 192.168.66.46`)。
4. 端口转发:从公网(手机流量)逐条验——hass `:8123`、transmission `:51413`、ssh `:5822→.36:22`、openvpn `:1194`
5. UniFiU6、AC-Lite 均 ConnectedInform 与基线一致。
6. SE5420 `show interface`:所有在用口无 CRC/FCS/错误计数增长;ER-X 无广播风暴/ MAC 漂移日志;旧交换已断电。
7. gfw:主网默认网关仍 `.254``ip route` 里没有默认走 `.1`),OpenClash 行为与升级前一致。
> 可选:两台 2.5G 终端同 VLAN 跑 `iperf3 -s` / `iperf3 -c <对端>`,记结果。同 VLAN 才可能超 1G;跨 55/66 与 Internet 受 ER-X/宽带限制。
---
# 第二阶段:VLAN10 升级专用 SSID(第一步稳定 ≥1 天后再做)
> 放行门槛:§8 全绿、稳定 ≥1 天、每步先只读核实现状。SSID 最后才启用。任一步失败只回滚该步。
## 9. PVE VLAN10 边界
### 9.1 快照与备份(在 PVE 上执行:SSH `ssh -4 root@192.168.66.26` 进入或本地终端)
1. 取只读快照(逐条执行并保存输出):
```sh
cat /etc/network/interfaces
qm config 140
bridge vlan show
bridge link
ip -d link show vmbr0
pveversion -v
```
2. 备份(同 §1.1 目录):PVE `/etc/network/interfaces``/etc/pve/qemu-server/140.conf`gfw `/etc/config/{network,dhcp,firewall}`
3. 确认本地 Console/带外路径可用;**无完整备份和回滚能力则停止。**
### 9.2 改造 vmbr0 为 VLAN-aware(以 §9.1 快照为准)
1. 编辑 `/etc/network/interfaces`PVE 本机 `nano /etc/network/interfaces`)。
2. 在 **vmbr0 定义段**加两行:
```text
bridge-vlan-aware yes
bridge-vids 10 66
```
> `bridge-vids` 只放行需要的 tagged VLAN`10`gfw VLAN10 帧)+ `66`(显式声明 native)。不要写 `2-4094` 全放行;将来加 VLAN 再扩展此列表。
> ⚠️ **`bridge-vids` 必须加在 vmbr0 段**,不是 `enp1s0` 段——enp1s0 只是 `bridge-ports` 成员;加错位置 VLAN10 无法上 bridge。
3. **不要**改动 `bridge-ports enp1s0`、现有 tap 口或 `vmbr1`;现有 VM 的无标签语义(untagged/PVID)保持不变。
4. 应用:**优先 `ifreload -a`**PVE 用 ifupdown2,只应用变更);不要用 `systemctl restart networking`(会重载全部接口)。
> ⚠️ `ifreload` 会断当前 SSH 连接(管理本重连即可),`ip -br addr` 重新验证。
5. **验证(关键):** `ssh -4 root@192.168.66.26 'ip -br addr'` 显示 `.66.26` 仍在;`ping 192.168.66.254` 通;dns(.36)/ubnt(.46)/gfw(.1) 仍通。任一断 → 立即恢复备份的 interfaces 文件并 `ifreload -a`
6. `bridge vlan show` 确认现有 tap 口、物理口的 PVID/untagged 与迁移前一致,只是多出 tag 10 的允许。
### 9.3 为 gfw 加 tag=10 专用 vNIC
1. PVE 上给 VM140 加网卡(从管理本执行,bus 用下一个空闲值,记下 MAC):
```sh
ssh -4 root@192.168.66.26 'qm set 140 -net2 virtio,bridge=vmbr0,tag=10'
```
2. **验证:** `qm config 140` 出现 `net2 ... tag=10`
3. gfw 内确认新接口名:
```sh
ssh -4 root@192.168.66.1 'ip -br link'
```
- 预期出现新接口(如 `eth2`),状态 DOWN 正常。
### 9.4 迁移 gfw 的 ubunt_upg 到新接口
1. gfw 先只读确认当前绑定与 DHCP 段名:
```sh
ssh -4 root@192.168.66.1 'uci show network; uci show dhcp'
```
预期:`network.ubunt_upg.device='eth0.10'``dhcp.ubunt_upg.interface='ubunt_upg'`(按接口名绑定)。
2. **最小迁移——只改 device 一行**gfw 上执行):
```sh
ifdown ubunt_upg
uci set network.ubunt_upg.device='eth2'
uci commit network
ifup ubunt_upg
```
> `eth0.10` 不再被引用会自动消失,**无需手动删段**;DHCP 池 `dhcp.ubunt_upg.interface='ubunt_upg'` 按接口名绑定,自动跟着走。
3. **验证:** gfw 内 `ip -br addr``eth2``192.168.10.1/24` UP、`eth0.10` 消失、`eth0``.66.1`
4. 确认 firewall `ubunt_upg` zone 按 `network='ubunt_upg'` 绑定(改 device 后 zone/DHCP/NAT/规则自动跟随)。
5. **验证:**
- PVE 管理 IP 仍在 LAN66
- `bridge vlan show` 确认其他 VM 无 VLAN10 可达性(`tap140i2` 显示 `10 PVID Egress Untagged`dns/ubnt 等 tap 仍只有 PVID 1)。
6. 未通过 → 按 §16 回滚顺序停止 VLAN10 项目。
## 10. SE5420 增加 VLAN10
> **关键:口 4/6 是 access 口时,VLAN10 的 Tagged 选择器里会显示不可选**——必须先在本步第 1 步把口 4/6 改成 trunk,才能勾 Tagged。改 trunk 后口 4/6 会从 VLAN66 untagged 成员中被移除,需在第 3 步恢复。
1. 改口 4/6 为 trunk`VLAN → 802.1Q VLAN → 端口配置` → 口 4、口 6 → 端口类型 `ACCESS``TRUNK`**PVID 改 `66`**(改 TRUNK 后默认 PVID 变 1,必须改回 66)→ 保存。
2. **验证:** 端口配置页口 4/6 显示 `TRUNK` + PVID `66`
3. **恢复 VLAN66 成员:** 改 trunk 后口 4/6 从 VLAN66 untagged 被移除 → `VLAN → 802.1Q VLAN → VLAN配置` → 编辑 VLAN66 → **Untagged 重新勾选口 4、口 6** → 保存。确认 VLAN66 = 口 2、4、6、8、10。
4. 建 VLAN10`VLAN → 802.1Q VLAN → VLAN配置` → 新建 VLAN ID `10`**Tagged 勾选:口 4、口 6**(Untagged 不勾)→ 保存。口 4/6 即 trunkuntagged 66 + tagged 10。
5. 确认其余口不允许 VLAN10(口 4/6 之外不勾选)。
6. 确认 VLAN10 不建 SVI/DHCP/路由:`路由功能 → 接口 → 接口设置` 无 VLAN10 接口;`路由功能 → DHCP服务器/DHCP中继` 无 10 段。
7. **保存配置:** `配置保存 → 保存配置`
8. **验证(静态链路):**
- SE5420 `show vlan`VLAN10 仅在口 4/6VLAN66 仍是口 2、4、6、8、10。
- PVE `bridge vlan show``enp1s0` 允许 10 + 66`tap140i2` 显示 `10 PVID Egress Untagged`
- `ping 192.168.66.253``ssh` 均通(VLAN66 管理流量不受影响)。
- > 注:此时抓不到 VLAN10 帧是正常的——还没有客户端(SSID 未启用);真正流量验证在 §12 启用 SSID 后。
## 11. gfw 防火墙、NAT 与 IPv6
### 11.1 先只读确认现场
```sh
ssh -4 root@192.168.66.1 'uci show network; uci show firewall; uci show dhcp; ip -4 route; ip -6 addr; ip -6 route; fw4 print; nft list ruleset'
```
- 记录 `wan` zone 的 masq、`ubunt_upg` zone 段名、`ubunt_upg_nat``ubunt_upg_to_lan`、DHCP 段名。以下命令全部用具名段,段名以本次输出为准。
### 11.1b 现场已核实规则(2026-08 实施时确认存在,无需重建)
| 段名 | 类型 | 作用 |
|---|---|---|
| `firewall.ubunt_upg` | zone | `network='ubunt_upg'`input/forward=`REJECT`、output=`ACCEPT` |
| `ubunt_upg_dhcp` | rule | VLAN10 → UDP 67 允许(DHCP |
| `ubunt_upg_dns` | rule | VLAN10 → TCP/UDP 53 允许(DNS |
| `ubunt_upg_block_lan66` | rule | VLAN10 → `192.168.66.0/24` REJECT |
| `ubunt_upg_to_lan` | forwarding | `src=ubunt_upg → dest=lan` |
| `ubunt_upg_nat` | nat | `src=lan``src_ip=192.168.10.0/24`、target `MASQUERADE`(出口走 lan 即 eth0/LAN66 |
> **待补缺口:** 只有 `192.168.66.0/24` 被拒,**LAN55 和其余 RFC1918 无显式拒绝**——VLAN10 经 ER-X 仍可达 `192.168.55.0/24`haos/IoT/AP)等内网,必须在 §11.3 补齐。
### 11.2 IPv4 策略(目标)
| 流量 | 动作 |
|---|---|
| VLAN10 → gfw DHCP、DNS | 允许 |
| VLAN10 → gfw SSH/LuCI/SNMP/其他管理服务 | 拒绝 |
| VLAN10 → LAN66、LAN55、其他 RFC1918 内网 | 拒绝 |
| VLAN10 → Internet | 允许 |
| established/related 回包 | 允许 |
### 11.3 执行(段名替换为实际值)
1. 确认 `ubunt_upg` zone 的 `forward` 保持 `REJECT`(不改 ACCEPT)。
2. **NAT 已存在,勿动:** 现场已有 `wan` zone `masq=1` + `ubunt_upg_nat``src=lan``src_ip=192.168.10.0/24` masquerade)。**不要**给 `ubunt_upg` zone 加 masq,也不要新增 NAT 规则。核对 `nft list ruleset` 该规则在列。
3. 不新增宽泛的 `ubunt_upg→wan` zone forwarding(会经 ER-X 放行 LAN55/其它内网)。
4. **补内网前缀拒绝**LAN55、10/8、172.16/12`192.168.0.0/16` 已被 66/55/10 三段覆盖,无需再加)。⚠️ **每条必须写 `dest='lan'`**——否则 fw4 会把规则放进 `input_ubunt_upg`(进 gfw 本机方向)而非 `forward_ubunt_upg`VLAN10→其他网段转发),`dest_ip` 永远不命中、等于没配:
```sh
# LAN55
uci add firewall rule
uci set firewall.@rule[-1].name='v10_deny_lan55'
uci set firewall.@rule[-1].src='ubunt_upg'
uci set firewall.@rule[-1].dest='lan'
uci set firewall.@rule[-1].dest_ip='192.168.55.0/24'
uci set firewall.@rule[-1].target='REJECT'
uci set firewall.@rule[-1].proto='all'
uci set firewall.@rule[-1].family='ipv4'
# 10/8
uci add firewall rule
uci set firewall.@rule[-1].name='v10_deny_10net'
uci set firewall.@rule[-1].src='ubunt_upg'
uci set firewall.@rule[-1].dest='lan'
uci set firewall.@rule[-1].dest_ip='10.0.0.0/8'
uci set firewall.@rule[-1].target='REJECT'
uci set firewall.@rule[-1].proto='all'
uci set firewall.@rule[-1].family='ipv4'
# 172.16/12
uci add firewall rule
uci set firewall.@rule[-1].name='v10_deny_172net'
uci set firewall.@rule[-1].src='ubunt_upg'
uci set firewall.@rule[-1].dest='lan'
uci set firewall.@rule[-1].dest_ip='172.16.0.0/12'
uci set firewall.@rule[-1].target='REJECT'
uci set firewall.@rule[-1].proto='all'
uci set firewall.@rule[-1].family='ipv4'
uci commit firewall
fw4 reload
```
5. **验证(看 nft 而非 UCI):** `nft list ruleset | grep -A6 "chain forward_ubunt_upg"` 应显示拒绝规则**全部在 `forward_ubunt_upg` 链**且先于 `accept_to_lan``wan` masq 仍在。若拒绝规则出现在 `input_ubunt_upg` 链 → 说明漏了 `dest='lan'`,回去补。
6. **IPv6 核验:** `ip -6 addr show eth2` 只有 `fe80::``dhcp.ubunt_upg``ra='disabled'``dhcpv6='disabled'` → VLAN10 无 GUA/ULA、无 IPv6 DNS/路由/出口。
### 11.4 IPv6:本阶段不提供 VLAN10 IPv6
1. 显式关闭并核验(不能靠"没配"):RA、DHCPv6、NDP relay/delegation、IPv6 forwarding 对 `ubunt_upg` 相关接口、前缀委派。
2. 现场已知:DHCPv6/RA disabled、无 IPv6 默认路由,但全局 `IPv6 forwarding=1`——启用 VLAN10 前复查 `ip -6 addr``sysctl net.ipv6.conf.all.forwarding`
3. 客户端可留 link-local;不得获得 GUA/ULA、IPv6 DNS 或 IPv6 默认路由。
### 11.5 部署后验证
1. **链归属(已实测):** `nft list ruleset | grep -A6 "chain forward_ubunt_upg"` 确认 66/55/10/172 四条拒绝在 forward 链、先于 `accept_to_lan``192.168.10.0/24 masquerade` 仍在(§11.3 第 5 步已验)。
2. **IPv6(已实测):** `ip -6 addr show eth2``fe80::``dhcp.ubunt_upg.ra='disabled'` + `dhcpv6='disabled'`odhcpd 不服务 eth2(§11.4 已验)。
3. **DHCP 租约(已实测):** `cat /tmp/dhcp.leases` 出现 VLAN10 客户端(实测 OnePlus-12 → `192.168.10.168`)。
4. **数据面(已实测):** `nft list ruleset``192.168.10.0/24 masquerade` counter 增长(实测 215 pkts / 42KB)、`accept_to_ubunt_upg` 回包增长、四条 deny 保持 0 → VLAN10 上网走 gfw NAT、隔离生效。
5. **拒绝规则命中(SSID 启用后验):** 从测试客户端 `ping 192.168.66.1/.26/.36/.46/.254``192.168.55.254`、LAN55 主机 → 全不通;访问 gfw 的 22/80/443 管理口 → 拒绝。
6. **客户端上网(SSID 启用后验):**`192.168.10.x`、网关 `.10.1`、DNS 正常;`curl -4` 国内/海外均通、`curl -6` 失败。
7. 抓包工具:`apk add tcpdump`ImmortalWrt 25.12.0;勿用 opkg)。
## 12. UniFi 改网络 + 启用 SSID(最后做)
1. 在 AP 上核实 InformSSH 进 U6`ssh -4 zhiqiangf@192.168.66.6`)→ `info` → 记录 `inform_url`。不凭文档假定。
2. UniFi → Networks → 编辑 `ubnt-upg`
- Router/网关设置 → 选 **Third-party Gateway**
- VLAN ID`10`
- 子网:`192.168.10.0/24`;网关:`192.168.10.1`
- DHCP:关闭(dhcp 由 gfw 提供)。
3. **验证:** 保存后网络列表 `ubnt-upg` 显示 Third-party Gateway、VLAN 10。
4. UniFi → WiFi → `ubnt-upg` SSID → 分配设备只勾 **U6 Lite**(确保 UAP-AC-Lite 不被勾选)→ 保存。
5. 启用 `ubnt-upg` SSID(最后一步才开)。
6. 单台测试客户端连上后逐项验证:
- `ip addr``192.168.10.100~250``ip route` 默认网关 `192.168.10.1`DNS 为预期 gfw 地址;
- `nslookup` 普通/国内/海外域名均按预期解析(DNS 走 gfw/OpenClash 路径,不能直连 LAN66 `.36` DNS,除非书面允许);
- gfw 出现该客户端 DHCP 租约与 VLAN10 帧;
- `curl -4 https://www.baidu.com``curl -4 https://www.google.com` 均成功;`curl -6` 失败;
- `ping 192.168.66.1/.26/.36/.46/.254``192.168.55.254`、LAN55 主机 → 全不通;gfw 管理口不通;
- 代理路径:在 gfw/OpenClash 日志或按客户端源地址/五元组确认流量走代理,**不以全局 `7892` 计数增长为证明**。
7. 任一验证失败 → 先关 `ubnt-upg` SSID;主 SSID、`.254` 路径、LAN55/LAN66 不受影响。
> **实测记录(2026-08-11):** OnePlus-12 连 `ubnt-upg` 拿到 `192.168.10.168`DHCP 由 gfw 下发,租约见 `/tmp/dhcp.leases`);gfw `192.168.10.0/24 masquerade` counter 增长(215 pkts / 42KB)、`accept_to_ubunt_upg` 回包 5 pkts、四条内网 deny 保持 0 → 上网走 gfw NAT、VLAN10→内网隔离生效。手机 Wi-Fi 详情不显示网关,以 DHCP 租约 + NAT counter 为证。
## 13. 第二阶段验收
1. `ubnt-upg` 客户端满足 §12 第 6 步全部项。
2. gfw 侧 DHCP、拒绝规则、NAT、DNS、代理路径均关联到该测试客户端。
3. LAN55/LAN66 的 IPv6 行为与迁移前基线一致;VLAN10 无 GUA/ULA、IPv6 DNS、默认路由或 IPv6 出口(除 link-local)。
> **状态(2026-08-11):** 第 2 条 DHCP+NAT+隔离已实测通过(见 §12 实测记录);第 1、3 条待补(浏览器 curl 国内/海外、拒绝规则从客户端实测命中、IPv6 `curl -6` 失败)。
## 14. 性能实测(可选但建议)
1. 两台 2.5G 终端(如 NAS 与 windy-pc,均接 SE5420):一端 `iperf3 -s`,另一端 `iperf3 -c <对端IP>`
2. 记录:单流、4 流结果;预期单流约 2.3–2.5Gbps(受 NIC/线材限制)。
3. 跨网段(66↔55)与 Internet 结果如实记录——预期被 ER-X 上联/宽带限制,不要按 2.5G 承诺。
## 15. 记录
- 每次实质变更后在 Linear `vps` 项目记录 scope / action / verification / 遗留 follow-up。
- 本仓库不记录 SE5420 口令、ER-X 配置快照(含 PPPoE/口令)、gfw 凭据。
- 实施前先读 `archive/se5420-review-claim-verification-2026-08.md` 的现场只读复核结论。
## 16. 回滚
| 失败位置 | 回滚动作 |
|---|---|
| §4 台面 | 还原台面配置或恢复出厂(见下) |
| §5–7 上联/迁移 | 停手 → 刚迁的线逐根插回 ER-X 原口 → 逐根验证(IP/服务恢复、AP Connected、MAC 表回旧拓扑) |
| §9 PVE 边界 | 关 `ubnt-upg` SSID → gfw `ubunt_upg` 恢复绑 `eth0.10` → 恢复 gfw network/DHCP/firewall → 移除 VM140 新增 vNIC`qm set 140 -delete net2`)→ 恢复 VM140 配置 → **最后**恢复 PVE `/etc/network/interfaces``ifreload -a` → 分别验证 PVE/dns/ubnt/gfw 的 LAN66 |
| §10 SE5420 VLAN10 | 删 VLAN10`VLAN → 802.1Q VLAN → VLAN配置` 删除 VLAN10),口 4/6 恢复 Untagged 仅 VLAN66、Tagged 清空 |
| §11 gfw | 恢复已备份命名规则/NAT;不触碰 LAN55/LAN66 主网规则 |
| §12 SSID | 只关 `ubnt-upg` SSID;主 SSID、`.254`、gfw 66 路径不受影响 |
**SE5420 恢复出厂(忘口令/重配):**
- **WEB 方式:** `系统管理 → 系统工具 → 软件复位`(若还能登录 GUI)。
- **Console 方式(WEB 不可用/忘口令):**
1. 管理本连 Type-C Console38400 8N1)。
2. 交换机断电重启;串口出现 **`Press CTRL-B to enter the bootUtil`** 时按 `Ctrl+B`
3. 输入 **`2`**(软件复位)→ 恢复出厂;此后首次 WEB 登录创建新用户名密码(初始管理 IP 恢复 `10.18.18.251`)。
4. 恢复后按 §4 重配并保存。
## 参考
- 设计说明:[lan-erx-se5420-network.md](lan-erx-se5420-network.md)
- 评审核实:[se5420-review-claim-verification-2026-08.md](archive/se5420-review-claim-verification-2026-08.md)
- 现网地图:[lan-overview.md](lan-overview.md)
- 官方安装手册(Markdown 版):[se5420-official-manuals/tl-se5420-install-manual.md](se5420-official-manuals/tl-se5420-install-manual.md)
- 官方 PDF<https://service.tp-link.com.cn/download/202310/TL-SE5420%20V1.0安装手册%201.0.2.pdf>
- 规格 / 固件:<https://www.tp-link.com.cn/product_2899.html?v=specification> · <https://www.tp-link.com.cn/product_2899.html?v=download>
- Omada VLAN 指南:<https://support.omadanetworks.com/en/document/12981/> · <https://support.omadanetworks.com/en/document/13135/>
- ER-X[edgerouter-x-configuration.md](edgerouter-x-configuration.md)gfw[hosts/gfw.windy.lan.md](../hosts/gfw.windy.lan.md)PVE VLAN10[lan-rb5009-upgrade.md#阶段-5-附pve-上-vlan10-透传实现-调研-2026-08-09](archive/lan-rb5009-upgrade.md#阶段-5-附pve-上-vlan10-透传实现-调研-2026-08-09)
+1
View File
@@ -108,3 +108,4 @@ Steps:
- Matrix Authentication Service: <https://github.com/element-hq/matrix-authentication-service>
- Matrix spec: <https://spec.matrix.org/>
- Federation tester: <https://federationtester.matrix.org/>
- Home Assistant Matrix integration: [home-assistant-matrix.md](home-assistant-matrix.md)
+215
View File
@@ -0,0 +1,215 @@
# Matter 配网排障手册
> 基于 Matter 1.5.1 Core Spec §4.3.1 与本环境(EdgeRouter X + UniFi AP + Aqara M3 +
> Home Assistant2026-08-21 实测整理。配套 Linear W1N-207。
## 1. Matter 配网协议要点(发现即一切)
- **发现走 mDNSDNS-SD**UDP **5353**,组播 `224.0.0.251` / `ff02::fb`
**不经过单播 DNS(如 AdGuard .36)、不需要反向 DNS、不需要 DHCPv6**SLAAC 即满足 Matter
的 IPv6 要求)。
- 服务类型:
- `_matterc._udp` — 可配网设备(Commissionable),**配对模式才有效**
- `_matter._tcp` — 已配设备(Operational),TXT 里含 fabric 信息
- 子类型(配对方按此过滤):
- `_L<全12位 discriminator>`(如 `_L3266`)— 按二维码里的完整 discriminator 精确匹配
- `_S<高4位>`(如 `_S12`
- `_V<vendorId>``_T<deviceType>`(可选)
- `_CM`(仅真正处于配对模式时发布)
- TXT 关键键:`D=`discriminator,规范 **SHALL** 必填)、`VP=`vendor+product)、
**`CM=`**、`RI=`rotating id)、`PH=`/`PI=`(配对提示)。
- 配对端口:**TCP 5540**PASE/CASE)。部分生态(Aqara M3)为 Thread 中继节点用 **5552**
- 实例名:64 位随机 hex;**进入配对模式时更换**(可用作"是否重新进过配对"的信号)。
- 规范参考:[Matter 1.5.1 Core Spec §4.3.1](https://csa-iot.org/wp-content/uploads/2026/03/23-27349-010_Matter-1.5.1-Core-Specification.pdf)、
[Google Home: Commissionable and Operational Discovery](https://developers.home.google.com/matter/primer/commissionable-and-operational-discovery)、
[Matter Handbook: Discovery](https://handbook.buildwithmatter.com/how-it-works/discovery/)、
[connectedhomeip: IP commissioning](https://pigweed.googlesource.com/third_party/github/project-chip/connectedhomeip/+show/59edd2ff8506b1e3dabb7040d716f0e75a2312d1/docs/guides/ip_commissioning.md)。
## 2. 关键判据:CM=0 = 不在配对模式
规范 §4.3.1.2 / §4.3.1.7
- 设备可以长期宣告 `_matterc`**Extended Discovery**),但 **`CM=0` 表示"当前不接受配网"**。
- **已在 fabric 里的设备**(宣告里同时有 `_matter._tcp` + `_I<fabric>._sub` 运营记录)重配时
通常报 `CM=0` —— 它已配好,不是新设备。
- **配对方不能把已配设备当新设备加** → 重加/找回必须先**恢复出厂**(清 fabric,重启后以
`CM=1` 全新配对模式宣告),再用**它自己的二维码**添加。
- 常见误判:抓包看到 `_matterc` 宣告就以为"在配对模式"——**必须看 `CM=`**。
## 3. 本环境实测事实(2026-08-21W1N-207
| 事实 | 状态 |
|---|---|
| LAN55 IPv6/mDNS 链路 | ✅ 全正常(RA→交换机→AP→客户端;mDNS 双向通;igmp snooping off、mdns on、无客户端隔离、无组播增强、PMF off、WPA2、仅 2.4G |
| Matter 不依赖单播 DNS/.36、反向 DNS、DHCPv6 | ✅ 已排除(.36 健康且不在路径上) |
| HA matter-server 曾宣告两代前的旧 GUA | ✅ 已修复(重启 `core_matter_server`;宣告恢复当前前缀) |
| ISP PD /60 随重拨轮换 → Matter IPv6 缓存反复失效 | ⚠️ 环境性根因;对策 = 重拨后重启 matter-server + 重启 M3 |
| EdgeOS 上静态 ULA 不可行 | ✅ 已尝试并回滚(switch0 不支持静态 `ipv6 address`;显式 router-advert 会替换 PD-slaac RA |
| 在用的两盏 ESP32-C2 Matter 灯泡(VP `0x4891/0x4100`2026-08-23 复核) | 工作盏 MAC 已变为 `fc:e8:c0:25:a1:f0``.146`hostname `espressif`;原 `34:98:7a:25:a1:f0` 全网消失,疑固件更新后换 MAC——末 3 字节相同);新盏 `34:98:7a:27:10:bc``.148`hostname `matter`)。两盏各宣告 **3 个 fabric** 运营实例:Aqara `4DF2B1455D19402D``2F6E56020E1996E7`、HA `DCE86145C137AF0E`(见 §8 |
| 故障盏 `34:98:7a:27:7f:08`(曾 .145Aqara fabric`CM=0` 缺 GUA | 2026-08-23 复核:无租约、ARP incomplete、AP 无日志 = **已离网**(退役/退换) |
| **失败模式 C(2026-08-23 实测,两盏同时)**mDNS 活、5540 死 | 灯泡 ping 通(v4/v6)、DHCP 正常续租、mDNS 应答并宣告 `_matter._tcp`SRV :5540、TXT `T=1`、当前前缀 GUA),但 **TCP 5540 在 IPv4 与 IPv6fe80+GUA)均 RST 拒绝** → 配对方无法建立 CASEApp 显示离线;hass matter-server 侧无任何 established :5540 会话(详见 §8 |
| ISP PD 前缀再次轮换(2026-08-23 → `240e:3bd:238:4812::/64`08-22 为 `235:1fb2` | hass 与 `.148` 均持当前前缀 GUA;hass 残留 `.146` 旧前缀 GUA 的 **FAILED** 邻居项(旧地址缓存仍被某端尝试) |
| DHCP 保留 `matter`.45 → MAC `…10:bc` | ⚠️ 保留仍未生效:新灯泡(`…10:bc`)实际拿到动态 `.148` 而非保留的 `.45`(待修,见 hosts/gw.md |
| **新灯泡(2026-08-22 添加成功)**MAC `34:98:7a:27:10:bc`=DHCP 保留目标 MAC),hostname `matter`IP `.148`VP `4891/4100`D=`3377` | ✅ 已入 **Aqara fabric `4DF2B1455D19402D`****经 BLE 配网**Aqara Home App)——线上**无 TCP 5540** 属正常(BLE 会话对 AP/hass 抓包不可见) |
| ESP32-C2 灯泡 firmware 挂死模式(2026-08-22 实测) | 入网后宣告 `_matterc`CM=1、D=3377)约 **3 秒后网络栈完全静默**:STA 收发计数冻结、不掉线不重启、配对方(手机/M3 `_L3377` 查询)无应答 → 加不上。**对策=断电 10 秒重启**重新进配网模式(实例名更换:`3F4E2C66F2DA85CD``E5BA8E28E4DE23A0`),随即 App 添加即成功 |
| 遗留 SSIDelement/vwire/vport | ✅ 已清理 |
## 4. 抓包方法(BusyBox 兼容)
> 完整指令集(实时 / 落盘轮转 / 定向抓取 / Wireshark 解密)见
> [runbooks/matter-packet-capture.md](../runbooks/matter-packet-capture.md)。
> 下面是最常用的两条。
**视角必须在 LAN55**。**HA matter-server 作配对方时推荐直接在 hass `end0` 抓**——配对方
必然参与配对流程的每一条通讯(mDNS 本段组播 + 自己的 TCP 5540 全程),覆盖最全;AP `br0`
能看到全部 mDNS 组播 + 无线客户端单播,但**看不到有线↔有线单播**(如 Thread 设备经有线 M3
配对时 HA↔M3 的 5540 在 AP 侧不可见)。66 网段电脑看不到 55 的组播。BusyBox 注意点仅适用
AP**不要用 `--line-buffered`**;引号外层双引号、内层单引号);hass 是 HAOS 全量 tcpdump。
完整抓取(跑配对时保持窗口开着,`Ctrl+C` 结束):
```bash
ssh zhiqiangf@192.168.55.5 "tcpdump -ni br0 -s 0 -vvv -tt 'udp port 5353 or tcp port 5540 or tcp port 5552'"
```
hass 侧(HA matter-server 作配对方,推荐;非交互 ssh 需显式 `sudo -n -i`):
```bash
ssh hassio@hass.windy.lan "sudo -n -i tcpdump -ni end0 -s 0 -vvv -tt 'udp port 5353 or tcp port 5540 or tcp port 5552'"
```
精简过滤(只看 Matter 信号):
```bash
ssh zhiqiangf@192.168.55.5 "tcpdump -ni br0 -s 0 -vvv -tt 'udp port 5353 or tcp port 5540 or tcp port 5552' | grep -E '_matterc|_matter|_L[0-9]+|_S[0-9]+|_CM|_V[0-9]+|_T[0-9]+|\.5540|\.5552'"
```
存 pcap 供 Wireshark:把上面 `-w /tmp/matter.pcap` 追加到 tcpdump 参数(去掉 `-vvv`),
`scp zhiqiangf@192.168.55.5:/tmp/matter.pcap .` 拉回本地分析。
> **落盘务必轮转**AP `/tmp` 只有约 60MB。用
> `-C 5 -W 12 -w /tmp/matter.pcap`(每 5MB 轮转、最多 12 个文件)防止写满,
> 详见 runbook Step 3(落盘轮转)。
> **Matter 载荷是加密的**mDNS5353)明文可读;5540 上的 Matter 报文要看明文
> 需要 Wireshark matter-dissector + 会话密钥,详见 runbook Step 5(解密)。
### 阶段对照表
| 阶段 | 应该看到 | 对应问题 |
|---|---|---|
| 发现(设备侧) | `_matterc._udp` + `_L3266._sub` + `_S12._sub` + TXT `D=3266 CM=1` + SRV `:5540` + AAAA | **无宣告**=设备没入网/没进配对模式;**`CM=0`**=不在配对模式(已配设备);**无 `_L3266`**=固件子类型缺失 |
| 发现(配对方侧) | M3/手机查询 `_L3266._sub._matterc._udp` | 查询有、无应答 = 码/discriminator 不匹配或设备不在线 |
| 配对握手 | 到设备 IP **TCP 5540 SYN/SYN-ACK** 双向 | **SYN 无 ACK**=设备不可达/防火墙;**完全无 5540**=发现阶段没完成 |
| 配完后 | 设备宣告 `_matter._tcp` + `_I<fabric>._sub` | 出现 = 已入网成功 |
| BLE 配网(手机 App 直连设备 BLE,如 Aqara Home | 线上**无 TCP 5540**BLE 会话对 AP/hass 抓包不可见);设备入网后仍先 mDNS 宣告 `_matterc` | 成功判据=最终宣告 `_matter._tcp` + `_I<fabric>._sub`;无 5540 **不代表**失败 |
## 5. 排障决策树(按顺序)
1. 抓包看**有没有 `_matterc` 宣告**:没有 → 设备不通电 / 没连上 Wi-Fi / 没进配对模式
(先解决"设备在线",网络侧已反复验证正常)。
2. 有宣告但 **`CM=0`** → 设备已配 / 不在配对模式 → **恢复出厂**后重试(用它自己的二维码)。
3. 有宣告 `CM=1` 但**无 `_L<disc>` 子类型** → 固件 mDNS 缺陷 → 升固件或换通用发现配对方。
4. `CM=1` + 子类型齐全但**无 TCP 5540** → 配对方没匹配上(查码/discriminator)或设备不可达。
5. 有 5540 但配对中断 → 查 `CM` 源(码是否正确)、设备电源、fabric 状态(是否需先清)。
## 6. 相关文档
- [runbooks/matter-packet-capture.md](../runbooks/matter-packet-capture.md) — Matter 抓包指令集(实时/落盘轮转/定向/解密)
- [docs/lan-overview.md](lan-overview.md) — LAN 拓扑、SSID 清理、ULA 不可行
- [hosts/hass.windy.lan.md](../hosts/hass.windy.lan.md) — matter-server 重拨运维规范
- [docs/unifi-network.md](unifi-network.md) — UniFi 网络/IPv6/SSID 记录
- [hosts/gw.md](../hosts/gw.md) — DHCP 保留 `matter` MAC 错位(待修)
## 7. 2026-08-22 实测记录:添加新 ESP32-C2 Matter 灯泡(成功 + 失败路径全记录)
> 场景:手机 AppAqara Home)添加一盏**新的** ESP32-C2 Matter 灯泡
> `34:98:7a:27:10:bc`hostname `matter`,最终 IP `.148`)。中途换了灯泡并断电重启,
> 共经历 **2 种失败模式****1 条成功路径**,全部抓包实证。
>
> 抓包点:UAP-AC-Lite `192.168.55.5` `br0`(轮转 `udp 5353 or tcp 5540 or tcp 5552`
> + 定向全量 `ether host 34:98:7a:27:10:bc`+ hostapd/stahtd 日志 + gw DHCP/ARP 交叉验证。
> 本地用 tshark 4.7.2 分析。
### 时间线(CST2026-08-22
| 时间 | 事件 | 判据 / 说明 |
|---|---|---|
| 10:50:28 | 启动轮转抓包 | — |
| 10:51:1219 | 手机 `.143`OnePlus,连 wifi0ap0=`ubnt-windy-2`)重新关联;查询 `_matter._tcp` | 运营查询(浏览已配设备),**不是**配网(配网应查 `_matterc._udp` |
| ~10:56 | 用户报「配置 wifi 后挂起,不能加入 wifi」 | 首次失败 |
| 11:00:15 | M3 `.248` 查询 `_L3266._sub._matterc` | 无应答(是另一台设备的 discriminator,无关) |
| 11:01:2442 | **失败模式 A**:故障盏 `…7f:08` 尝试关联 `wifi0ap1`(`ubnt-haas`):发 1 次 open-auth 帧(algorithm 0)→ AP 回 `status_code=0`**客户端不再发 assoc 请求** → 18s 后 `auth_failures=1` + disassociated | auth 阶段卡死(client 侧);非密码错——密码错会先 assoc 再 4-way 失败 |
| 11:04:0105 | **新灯泡 `…10:bc` 关联 `wifi0ap1` 成功**WPA2 4-way 完成,DHCP 拿 `.148`tracker `soft failure`: ip_delta 3.76savg_rssi -68);随即宣告 `_matterc`:实例 `3F4E2C66F2DA85CD`TXT `VP=4891+4100 D=3377 CM=1`SRV :5540**有 GUA** | 发现阶段判据全过 |
| 11:04:05 之后 | **失败模式 B**:灯泡网络栈完全静默——STA 收发计数冻结(rx=89/tx=5 持续 12s+ 不变)、**不掉线不重启** | firmware 挂死 |
| 11:04:5911:07:27 | 手机查 `_matterc` ×4、`.60` 解析实例、M3 查 `_L3377._sub._matterc` ×5discriminator 3377 正是新盏)——**全部无应答**TCP 5540/5552 全程 0 | 配对方找不到设备 → App 报「加不上」 |
| 11:09:3548 | **断电 10 秒重启**:灯泡重新关联 `wifi0ap1` ×2 | 对策生效 |
| 11:10:26 | DHCP 重新拿 `.148`;STA 计数恢复持续增长(活跃) | — |
| 11:1011:15 | 重新宣告 `_matterc`**新实例 `E5BA8E28E4DE23A0`**——入配对模式实例名更换,符合规范);App 走 **BLE 配网** | 线上无 TCP 5540BLE 对 AP 不可见,属正常) |
| 11:15:23 | 灯泡宣告 **`_matter._tcp`**`4DF2B1455D19402D-02EF2FF12DFAF10E`Aqara fabric+ SRV :5540 + GUA + A | ✅ **添加成功**(已入 Aqara fabric `4DF2B1455D19402D` |
### 结论与经验
1. **同族灯泡(VP 4891/4100OUI 34:98:7a)存在两种不同失败模式**
- 故障盏 `…7f:08`:auth 阶段卡死(auth 帧后不发 assoc);此前(08-21 21:27)成功关联后伴随
`ip_failures=1`(拿不到 IP)+ 缺 GUA —— 属更深层故障,需恢复出厂,本次未处理,仍离线。
- 新盏 `…10:bc`:入网 + 宣告 `_matterc`(CM=1)成功后约 3 秒固件挂死(全静默)。
**断电 10 秒重启即恢复**,是最简单有效的对策。
2. **AP 抓包看不到 BLE 配网**Aqara Home App 对 WiFi Matter 设备走 BLE 配网时,线上只有
mDNS/DHCP**无 TCP 5540 不代表失败**;成功判据 = 设备最终宣告 `_matter._tcp` + `_I<fabric>._sub`
3. **发现判据回顾**`_matterc` + TXT`CM=1``D=``VP=`+ SRV :5540 + AAAA(GUA) + A 全齐才算
设备真的在配对模式;配对方按 `_L<disc>._sub._matterc` 精确匹配 discriminator(本例 D=3377)。
4. 新盏 RSSI -68、DHCP 3.76s,射频偏弱,可能加剧 firmware 不稳定(待观察)。
5. DHCP 保留 `matter`.45→`…10:bc`**仍未生效**:新盏实际拿动态 `.148`(待修,见 hosts/gw.md)。
6. 识别「配对方在找但设备不答」的快速方法:抓包里配对方持续查 `_matterc`/`_L<disc>` 而目标 MAC
零应答 + STA 收发计数冻结 = 设备侧挂死;此时**先断电重启设备**,不要怀疑网络/AP。
### 后续:新盏 11:24 起离线循环(同一盏 `…10:bc`2026-08-22
配网成功后约 10 分钟(11:15–11:24 可控制),灯泡进入**持续性故障循环**:
| 时间 | 事件 | 模式 |
|---|---|---|
| 11:24:57 | `EVENT_STA_LEAVE`(真掉线) | 掉线 |
| 11:25:12 | 重连 `auth_failures=2` | **auth 卡死**(同故障盏 `…7f:08` 11:01 的模式) |
| 11:25:2325 | 重连成功,WPA2 完成,重新拿 `.148` | — |
| 11:25:38 | `soft failure`ip_delta 2.65s**avg_rssi -73**-68→-73 持续变差) | 射频偏弱 |
| 11:25 之后 | STA 计数冻结(rx=119/tx=64 不动);M3 持续查询其运营实例 `4DF2B1455D19402D-02EF2FF12DFAF10E._matter._tcp` **无应答** → App 显示「离线」 | 静默挂死 |
**结论**:三盏 ESP32-C2 灯泡中两盏(`…7f:08``…10:bc`)故障,表现覆盖 auth 卡死 / 静默挂死 /
随机掉线三种形态;工作盏 `…25:a1:f0` 正常。网络侧(AP、M3、DHCP、mDNS)均验证正常。
**疑似根因(按可能性)**:① ESP32-C2 Matter 灯泡 firmware 缺陷(同批次)② 射频偏弱
(RSSI -73,天线/距离/遮挡)加剧不稳定 ③ 供电不稳(brownout 造成 Wi-Fi 栈崩溃重启)。
**待办**:移近 AP 或改善供电后观察;App 内查固件更新;仍复发则考虑退换。
## 8. 2026-08-23 状态核查:两盏「半在线」——mDNS 宣告正常但 TCP 5540 无监听(失败模式 C)
> 全程**只读**核查(gw DHCP/ARP、AP hostapd 日志、hass matter-server 状态 + mDNS 抓包、
> 对灯泡 v4/v6 的 TCP 5540 探测,09:0x CST)。结论:**网络侧全部健康;两盏灯泡网络栈活着、
> mDNS 运营宣告正常,但 Matter 会话端点(TCP 5540)无监听**——配对方无法建立 CASE,
> App 内应显示离线/不可达。
| 对象 | 状态(2026-08-23 |
|---|---|
| 新盏 `34:98:7a:27:10:bc``.148`hostname `matter` | DHCP 04:40 续租;gw ARP 完整;ping 通(93122msESP32 省电时延);08-22 16:40 起稳定关联 `wifi0ap1`,关联时 `avg_rssi -70`。mDNS 宣告 3 实例:`4DF2B1455D19402D-02EF079EEB480D07`**新 node ID——08-22 之后被重新配网过**)、`2F6E56020E1996E7-137147AF27BE4EB6``DCE86145C137AF0E-0000000000000011`HA fabric);host 记录 A `.148` + fe80 + **当前前缀** GUA `240e:3bd:238:4812:*`。支持单播 legacy mDNS 查询(`dig -p 5353 @.148 _matter._tcp.local PTR` 可用) |
| 工作盏(MAC 已变)`fc:e8:c0:25:a1:f0``.146`hostname `espressif` | DHCP 07:17 续租;ping 通 v4/v6v6 fe80 3861ms)。mDNS 宣告 3 实例:`4DF2B1455D19402D-02EF4CA3F856B615``2F6E56020E1996E7-EE8F2E4F1A77BF05``DCE86145C137AF0E-000000000000000B`。原 MAC `34:98:7a:25:a1:f0` 全网消失(无租约/ARP/AP 日志)而新 MAC 末 3 字节相同 → 疑固件更新后改 MAC。**拒绝单播 5353**ICMP port unreachable),只应答组播查询——同族固件行为差异。hass 残留其旧前缀 GUA `240e:3bd:235:1fb2:fee8:c0ff:fe25:a1f0`**FAILED** 邻居项 |
| 故障盏 `34:98:7a:27:7f:08`(曾 `.145` | 无租约、ARP incomplete、AP 日志零事件 = 已离网 |
| **TCP 5540 探测(两盏)** | IPv4LAN66 与 hass 本段)、IPv6fe80%end0 + 当前 GUA)全部 **RSTConnection refused** —— SRV 宣告 :5540 且 TXT `T=1`,但实际无监听 |
| hass matter-server | `started`,v9.0.4,无更新;宣告自身运营实例 `DCE86145C137AF0E-…1B669`v4+v6,当前 GUA);**无任何 established :5540 会话**core/add-on 日志无 matter 错误 |
| 其他 Matter 控制器 | Aqara M3 `.248` 在线(有线 0.8ms),宣告含自身 fabric 节点 `4DF2B1455D19402D-11E158E46D24A000`SmartThings `.48` 在线并周期查询 `_matter._tcp.local`;手机(当前前缀 GUA)也在浏览。LAN55 共见 **5 个 fabric**`4DF2B1455D19402D`M3)、`DCE86145C137AF0E`HA)、`2F6E56020E1996E7``03BCFAEDD6153944``6A6FF80C2DB84DEE` |
**判定**:失败模式 C = TCP/IP 栈与 mDNS 守护进程活着(主动 RST、DHCP 续租、ping 通),
但 Matter 应用层监听不存在。与模式 A(auth 卡死)、模式 B(全静默挂死)同族不同形态;
**两盏同时处于同一状态**更指向共同诱因(固件缺陷,或 PD 轮换等共同事件后未恢复)。
**对策(推荐,未执行)**:逐盏断电 10 秒重启(模式 B 的已验证对策),重启后复测
TCP 5540 恢复监听即可确认。
**核查方法备忘**(只读,可复用):
- gw`show dhcp leases` / `show arp`(经 `/opt/vyatta/bin/vyatta-op-cmd-wrapper`)。
- AP`grep -i <mac> /var/log/messages`hostapd 关联事件 + stahtd RSSI/soft failure)。
- hass`sudo -n -i ha apps info core_matter_server``ip -6 neigh show dev end0`
(看灯泡 fe80/旧新前缀 GUA 与 FAILED 项);被动抓包
`sudo -n -i timeout 65 tcpdump -ni end0 -s 0 -tt 'udp port 5353'`——配对方周期查询
会自然引出灯泡宣告,无需主动发包。
- 5540 探测:hass 上 python3 对 v4 / fe80%end0 / GUA 各 connect 一次;RST=无监听,
超时=不可达(两者含义不同)。
+45
View File
@@ -0,0 +1,45 @@
# Plane CE 加固草稿(docs/plane-hardening/
> **状态:草稿,未应用、未提交。** 对应追踪:Plane vps 项目条目(2026-09-03**记录源**Linear W1N-277 已取消,Linear 自 2026-09-03 起不再作为记录源)。
> 线上实例:`plane.chans.xyz`synapse K3sns `plane`release `plane-app` = chart `plane-ce-1.8.0` / app `v1.4.1`)。
> 依据:2026-09-03 只读核查(13 条审查意见中 11 条属实、#3 基本属实、#9 指标归属错误)+ 上游 chart 模板逐条核对。
## 文件
| 文件 | 内容 |
|------|------|
| `values.hardened.yaml` | 可选硬化 valuesexternal secrets 引用、requireExplicitSecrets、minio pin、上传限额对齐);含 HTTP→HTTPS `extraObjects` 示例 |
| `secrets.yaml.example` | 6 组外部 Secret 结构占位(只含 key 名,真实值仅存宿主机) |
| `backup/plane-backup.yaml` | **PostgreSQL 备份 CronJob**pg_dump `-Fc`hostPath `/var/backups/plane`MinIO 已按实际用量剔除) |
| `backup/README.md` | 备份方案说明(排程/容量/保留/还原/阻塞) |
## 应用顺序(每步先 diff 后执行,全部需用户逐项确认)
### 现在就值得做:DB 备份(P0,见 backup/
`plane-backup.yaml` 部署 + 手动触发验证一次即可;88 MB 库每日快照几乎零成本。
### 可选(顺手做一次,不是必须)
- **Phase A 密钥外部化**(零行为变化、无停机,约 15 分钟):按 `secrets.yaml.example`
在宿主机建 6 个 Secret(值先复制当前集群),用 `values.hardened.yaml`
`helm diff upgrade``helm upgrade`;验证后删除 chart 生成的旧 Secret。
价值:默认密钥不再落在 chart 公开常量上,作为保险。
- **MCP API Key 轮换**:若审查对话出过你的环境,Plane 后台重生成 + 更新
`/home/windy/plane-k3s/mcp/mcp.env`0600+ 重启 Cursor MCP。
- **/god-mode IP 白名单**:若在意管理后台被公网爆破。chart 1.8.0 的 IngressRoute
不支持给单条路由追加 middleware → 需 post-renderer 或 upgrade 后 `kubectl patch`
(升级会覆盖,需固化);源 IP 清单待提供。
### 明确暂缓/跳过(个人单节点,等出现症状再处理)
- SECRET_KEY 等轮换(Phase B):等真要配 SMTP/OAuth 前再做(避免旧密文不可解)。
- NetworkPolicy、有状态组件 resources limitschart 无 values 开关,需 post-render/patch)、
HTTP→HTTPS(草稿已给 `extraObjects` 示例)、metrics-server/Sentry。
## 关键限制(chart 1.8.0 模板已核对)
- `external_secrets.*_existingSecret` 设置后,对应 Secret **必须**包含模板所需全部 key
(缺失不自动补),见 `secrets.yaml.example` 注释。
- `app_keys_existingSecret` 的 envFrom 在所有 workload 上**最后注入**(后置生效),
保证 app/live 共享密钥一致——不要在其后再放同名 key 的 Secret。
- `DATABASE_URL`/`AMQP_URL`/`REDIS_URL` 是 chart 生成的派生 URL,内嵌明文密码;
外部化后轮换 DB/队列密码时必须同步更新 `plane-app-env`
- minio 的 `MINIO_ROOT_*``AWS_*` 同源于一个 Secret;升级时 bucket Job 会重跑
(需 admin 权限凭据)——换 svcacct 前先确认权限覆盖该 Job。
+48
View File
@@ -0,0 +1,48 @@
# Plane CE 备份方案(DB-only)— DRAFT (2026-09-03), 未应用
> 关联:`plane-backup.yaml`CronJob);追踪:Plane vps 项目条目(记录源,2026-09-03 起不用 Linear)。
> 现状(实测):pg 全库 **88 MB**310 issues / 1 user);MinIO uploads **264 KB**(几乎空)。
## 范围决策(2026-09-03,实际角度)
- **做:PostgreSQL 逻辑备份** —— 覆盖现实故障(误删、升级失败、磁盘坏、重装),成本≈0。
- **不做:MinIO/附件备份** —— 桶仅 264 KB,个人实例附件可接受丢失;不为它付日常维护。
日后附件明显变多再按原完整版思路加 `mc mirror`(历史版本见本目录 git 历史/Plane 条目评论)。
- 异机同步暂不启用(见下"局限/阻塞")。
## 方案
集群内 CronJobns `plane`,每天 **01:30 UTC = 03:30 本地**,控制器按 UTC 跑):
1. 单容器 `postgres:15.7-alpine``pg_dump -Fc`(自定义压缩格式)打 `plane`
`/var/backups/plane/pg/plane-<UTC时间戳>.dump`hostPath `DirectoryOrCreate`
2. 保留 7 天(`find -mtime +7 -delete`),成功/失败历史各留 3/2
3. 凭据:现 chart Secret `plane-app-pgdb-secrets`Phase A 外部化后改 `plane-pgdb-credentials`
## 容量
- 库 88 MB → `-Fc` 快照约 10–40 MB/天 × 7 天 ≈ **<300 MB**,对 83 G 可用盘可忽略。
## 还原(未演练;应用前先做一次隔离测试)
```bash
# 目标 PG15 实例(临时起一个 postgres:15.7-alpine 容器或另一台机):
# 先建空库: createdb plane (user=plane)
pg_restore -h <target> -U plane -d plane --clean --if-exists /var/backups/plane/pg/plane-<TS>.dump
# 还原后确认 310 issues 量级一致;附件为空属预期(未备份 MinIO)
```
## 验收(应用前逐项过)
- [ ] CronJob 建立后手动触发一次:`kubectl -n plane create job --from=cronjob/plane-backup plane-backup-manual-1`Job `Completed`
- [ ] `/var/backups/plane/pg/plane-*.dump` 可被 `pg_restore -l` 列出
- [ ] 备份 Job 只依赖 pgdb 服务,不依赖 Plane 应用 Pod(应用故障期间也能出备份)
- [ ] 保留清理 dry-run`find ... -print`)正确;`df -h /` 前后对比记录
## 局限 / 阻塞
- **本地方案不是离机备份**:单节点磁盘/整机故障即丢。如日后要离机,纳入
[Restic 异机 repository 决策与存取隔离](https://plane.chans.xyz/space/projects/56874283-7e1d-43a8-afa4-631cf1c4ad5b/issues/7825d564-ae15-446b-bced-be26b648346b/)
(与 Matrix 备份同一决策);恢复演练纪律见
[服务级 restore runbook 与隔离复元演练](https://plane.chans.xyz/space/projects/56874283-7e1d-43a8-afa4-631cf1c4ad5b/issues/a9bea3ba-c958-4a74-b2f1-6bbb653f21d3/)。
- 提醒:同一节点 **Matrix 数据价值远高于 Plane 且同样无备份** —— 若投入备份精力,顺序上 Matrix 优先。
@@ -0,0 +1,63 @@
# Plane CE PostgreSQL backup CronJob — DRAFT (2026-09-03), NOT applied.
# ns: plane (synapse K3s single node). Output: hostPath /var/backups/plane (root disk, auto-created).
#
# Scope decision (2026-09-03, practical): DB-only. MinIO dropped — uploads bucket
# measured at 264 KB / 444 KB total; attachments are acceptable loss for this
# personal 1-user instance (310 issues / 88 MB DB). Revisit only if usage grows.
#
# Credentials: read from the CURRENT chart-generated Secret (works today). After the
# optional external-secrets migration (docs/plane-hardening/README.md Phase A) switch
# the secretKeyRef name to plane-pgdb-credentials.
#
# Apply:
# ssh windy@synapse.chans.xyz 'sudo k3s kubectl apply -n plane -f -' < plane-backup.yaml
# Manual run + verify:
# sudo k3s kubectl -n plane create job --from=cronjob/plane-backup plane-backup-manual-1
# sudo k3s kubectl -n plane get cronjob,job,pods | grep plane-backup
# sudo ls -lh /var/backups/plane/pg
# Restore steps + tuning: see backup/README.md
apiVersion: batch/v1
kind: CronJob
metadata:
name: plane-backup
namespace: plane
spec:
# 01:30 UTC daily = 03:30 local (CEST). CronJob controller runs in UTC.
schedule: "30 1 * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 2
jobTemplate:
spec:
backoffLimit: 2
template:
spec:
restartPolicy: OnFailure
volumes:
- name: backup
hostPath:
path: /var/backups/plane
type: DirectoryOrCreate
containers:
- name: pg-dump
image: postgres:15.7-alpine
env:
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: plane-app-pgdb-secrets # -> plane-pgdb-credentials after Phase A
key: POSTGRES_PASSWORD
command: ["/bin/sh", "-c"]
args:
- |
set -euo pipefail
TS=$(date -u +%Y%m%dT%H%M%SZ)
mkdir -p /backup/pg
pg_dump -h plane-app-pgdb.plane.svc.cluster.local -U plane -d plane \
-Fc -f "/backup/pg/plane-${TS}.dump"
find /backup/pg -type f -name 'plane-*.dump' -mtime +7 -delete
echo "pg_dump done: /backup/pg/plane-${TS}.dump ($(du -h /backup/pg/plane-${TS}.dump | cut -f1))"
volumeMounts:
- name: backup
mountPath: /backup
+91
View File
@@ -0,0 +1,91 @@
# External Secret structure for Plane CE hardening — EXAMPLE ONLY.
# No real values here; this file is safe to commit. Real values live only on the
# host (/home/windy/plane-k3s, 0600/0700) and in the cluster.
#
# Phase A — create each Secret with the CURRENT cluster values first (zero change):
# # current source Secrets (chart-generated):
# kubectl -n plane get secret plane-app-app-secrets -o jsonpath='{.data.SECRET_KEY}' | base64 -d
# kubectl -n plane get secret plane-app-live-secrets -o jsonpath='{.data.REDIS_URL}' | base64 -d
# kubectl -n plane get secret plane-app-pgdb-secrets -o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d
# kubectl -n plane get secret plane-app-rabbitmq-secrets -o jsonpath='{.data.RABBITMQ_DEFAULT_PASS}' | base64 -d
# kubectl -n plane get secret plane-app-doc-store-secrets -o jsonpath='{.data}' | base64 -d
#
# e.g. kubectl -n plane create secret generic plane-app-keys \
# --from-literal=SECRET_KEY="$(<copy from above>)" \
# --from-literal=LIVE_SERVER_SECRET_KEY="$(<copy from above>)"
#
# All keys below are REQUIRED by chart templates/plane-ce-1.8.0 (verified 2026-09-03):
# missing keys are NOT auto-filled once an existingSecret is referenced.
---
apiVersion: v1
kind: Secret
metadata:
name: plane-app-keys # external_secrets.app_keys_existingSecret
namespace: plane
type: Opaque
stringData:
SECRET_KEY: "" # current: copy from plane-app-app-secrets; rotate only in Phase B
LIVE_SERVER_SECRET_KEY: "" # current: same value as above / plane-app-live-secrets
---
apiVersion: v1
kind: Secret
metadata:
name: plane-app-env # external_secrets.app_env_existingSecret
namespace: plane
type: Opaque
stringData:
REDIS_URL: "" # redis://plane-app-redis.plane.svc.cluster.local:6379/
DATABASE_URL: "" # postgresql://plane:plane@plane-app-pgdb.plane.svc.cluster.local/plane
AMQP_URL: "" # amqp://plane:plane@plane-app-rabbitmq.plane.svc.cluster.local/
---
apiVersion: v1
kind: Secret
metadata:
name: plane-live-env # external_secrets.live_env_existingSecret
namespace: plane
type: Opaque
stringData:
REDIS_URL: "" # redis://plane-app-redis.plane.svc.cluster.local:6379/
---
apiVersion: v1
kind: Secret
metadata:
name: plane-pgdb-credentials # external_secrets.pgdb_existingSecret
namespace: plane
type: Opaque
stringData:
POSTGRES_PASSWORD: "" # Phase A: keep current ('plane'); Phase B: ALTER USER first, then sync
POSTGRES_DB: "plane"
POSTGRES_USER: "plane"
---
apiVersion: v1
kind: Secret
metadata:
name: plane-rabbitmq-credentials # external_secrets.rabbitmq_existingSecret
namespace: plane
type: Opaque
stringData:
RABBITMQ_DEFAULT_USER: "plane"
RABBITMQ_DEFAULT_PASS: "" # Phase A: keep current; Phase B: rabbitmqctl change_password first
---
apiVersion: v1
kind: Secret
metadata:
name: plane-minio-credentials # external_secrets.doc_store_existingSecret
namespace: plane
type: Opaque
stringData:
FILE_SIZE_LIMIT: "20971520" # must match env.doc_upload_size_limit
AWS_S3_BUCKET_NAME: "uploads"
USE_MINIO: "1"
MINIO_ROOT_USER: "admin"
MINIO_ROOT_PASSWORD: "" # root creds take effect on first init only
AWS_ACCESS_KEY_ID: "admin"
AWS_SECRET_ACCESS_KEY: "" # == MINIO_ROOT_PASSWORD while minio.local_setup
AWS_S3_ENDPOINT_URL: "http://plane-app-minio:9000"
+109
View File
@@ -0,0 +1,109 @@
# Plane CE hardened values — DRAFT (2026-09-03), NOT applied.
# Target file on host: /home/windy/plane-k3s/values.yaml (synapse.chans.xyz)
# Reference release: plane-app, chart plane-ce-1.8.0 (values.yaml L1-362 + templates verified 2026-09-03).
# No secrets in this file. Secret *values* live only in k8s Secrets (see secrets.yaml.example).
#
# Two phases:
# Phase A: externalize secrets (reference names below) with CURRENT values copied -> zero change.
# Phase B: rotate credentials one by one (see README.md). SECRET_KEY rotation is cheap only while
# SMTP/OAuth are unconfigured (no encrypted config rows yet).
planeVersion: v1.4.1
ingress:
enabled: true
appHost: plane.chans.xyz
ingressClass: traefik
traefik:
# 20 MiB (chart default). Keep aligned with env.doc_upload_size_limit below.
maxRequestBodyBytes: 20971520
ssl:
createIssuer: true
issuer: http # HTTP-01; ssl_token_existingSecret not needed
email: admin@chans.xyz
generateCerts: true
postgres:
storageClass: local-path
volumeSize: 5Gi
# NOTE: chart 1.8.0 exposes NO resources knob for the bundled datastores
# (stateful templates render no resources block). Add limits via
# --post-renderer/kustomize or `kubectl -n plane patch sts ...` re-applied on
# every upgrade (P2 task; see README.md).
redis:
storageClass: local-path
# image: valkey/valkey:7.2.11-alpine # already pinned by chart default; uncomment to make explicit
minio:
# P2: pin. Digest of the currently running :latest (2026-09-03, pod plane-app-minio-wl-0).
image: minio/minio@sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e
# image_mc: minio/mc@sha256:... # optional: pin one-shot bucket-init client the same way
storageClass: local-path
volumeSize: 5Gi
rabbitmq:
storageClass: local-path
env:
# Fail the render instead of ever falling back to the chart's PUBLIC constants
# (values.yaml L340-341 in chart 1.8.0). Requires external_secrets below.
requireExplicitSecrets: true
# SECRET_KEY / LIVE_SERVER_SECRET_KEY are deliberately OMITTED here.
# They live in k8s Secret `plane-app-keys` (referenced below). With
# requireExplicitSecrets=true and app_keys_existingSecret set, the chart renders
# neither key itself and app+live workloads both envFrom `plane-app-keys` LAST
# (later envFrom wins), which keeps the shared signing key consistent.
pgdb_name: plane
docstore_bucket: uploads
# Align app-side upload cap with the Traefik body limit (was 5242880/5MiB).
# Keep both at 20MiB, or lower both together.
doc_upload_size_limit: "20971520"
external_secrets:
# Shared signing keys (used by app + live). REQUIRED keys: SECRET_KEY, LIVE_SERVER_SECRET_KEY.
app_keys_existingSecret: plane-app-keys
# REQUIRED keys: REDIS_URL, DATABASE_URL, AMQP_URL (chart-derived URLs; update on DB/queue rotation).
app_env_existingSecret: plane-app-env
# REQUIRED keys: REDIS_URL.
live_env_existingSecret: plane-live-env
# REQUIRED keys: POSTGRES_PASSWORD, POSTGRES_DB, POSTGRES_USER.
pgdb_existingSecret: plane-pgdb-credentials
# REQUIRED keys: RABBITMQ_DEFAULT_USER, RABBITMQ_DEFAULT_PASS.
rabbitmq_existingSecret: plane-rabbitmq-credentials
# REQUIRED keys: FILE_SIZE_LIMIT, AWS_S3_BUCKET_NAME, USE_MINIO, MINIO_ROOT_USER,
# MINIO_ROOT_PASSWORD, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_S3_ENDPOINT_URL.
doc_store_existingSecret: plane-minio-credentials
# ssl_token_existingSecret: '' # DNS-01 only (cloudflare/digitalocean); unused with HTTP-01
# Optional, P2: HTTP -> HTTPS 301. The chart's own IngressRoute binds only
# 'websecure' (http:// currently 404s). extraObjects is rendered verbatim (toYaml).
# Uncomment and `helm upgrade` once reviewed:
# extraObjects:
# - apiVersion: traefik.io/v1alpha1
# kind: Middleware
# metadata:
# name: plane-https-redirect
# namespace: plane
# spec:
# redirectScheme:
# scheme: https
# permanent: true
# - apiVersion: traefik.io/v1alpha1
# kind: IngressRoute
# metadata:
# name: plane-http-to-https
# namespace: plane
# spec:
# entryPoints: [web]
# routes:
# - match: Host(`plane.chans.xyz`)
# kind: Rule
# middlewares:
# - name: plane-https-redirect
# services:
# - name: plane-app-web
# port: 3000
@@ -0,0 +1,262 @@
# TL-SE5420 官方安装手册(整理版)
> **来源:** TP-LINK《TL-SE5420 V1.0 安装手册 1.0.2》(PDF),已整理为 Markdown。
> 官方 PDF<https://service.tp-link.com.cn/download/202310/TL-SE5420%20V1.0安装手册%201.0.2.pdf>
> 配套实施文档:[../lan-se5420-deployment-guide.md](../lan-se5420-deployment-guide.md)
> 说明:PDF 中示意图已省略(仅保留文字);正文以官方 PDF 为准。
---
## 声明
- Copyright © 2023 普联技术有限公司,版权所有,保留所有权利。
- 产品规格和资讯仅供参考,如有内容更新,恕不另行通知。本手册仅作为使用指导,所作陈述均不构成任何形式的担保。
## 相关文档
| 文档名称 | 用途 |
|---|---|
| 《安装手册》 | 交换机的安装过程、启动以及常见故障处理 |
| 《用户手册》 | 交换机的各个功能应用以及常见功能使用问题处理 |
| 《命令行手册》 | 交换机 CLICommand Line Interface,命令行界面)参考信息 |
| 《防雷安装手册》 | 安装中如何做好防雷措施以避免设备雷击损坏 |
技术支持:服务热线 `400-8863-400` · 邮箱 `fae@tp-link.com.cn` · 微信公众号「TP-LINK商用网络」/「TP-LINK商云APP」
## 阅读对象与约定
- 适合阅读对象:网络工程师、网络管理员。
- 本手册如无特别说明,均以 **TL-SE5420** 机型为例介绍,所指交换机为 2.5G 三层网管交换机。
- 产品规格标贴位于设备底部,扫描二维码可查看安规符号释义。
---
## 第 1 章 产品介绍
### 1.1 产品简介
2.5G 三层网管交换机,支持静态路由和动态路由 RIP 协议,提供完备的安全防护机制、完善的 QoS 策略和丰富实用的 VLAN 功能。适用于中小型企业网、校园网络、酒店、网吧等使用场景。
### 1.2 产品外观
**前面板组成:** 1 模式切换按键 · 2 指示灯 · 3 1 个 Type-C Console 端口 · 4 16 个 2.5Gbps RJ45 端口 · 5 4 个 SFP+ 端口 · 6 1 个 USB 端口
**模式切换按键:** 用于切换指示灯的指示状态,支持「端口模式」和「堆叠模式」切换。任意模式下超过 45s 不按动按钮,自动恢复为端口模式。(模式切换功能软件后续升级支持,以实际软件功能为准。)
**指示灯状态:**
| 指示灯 | 名称 | 工作状态 | 工作说明 |
|---|---|---|---|
| PWR | 电源指示灯 | 常亮 / 熄灭 / 闪烁 | 供电正常 / 未通电或供电异常 / 供电异常 |
| SYS | 系统指示灯 | 绿色闪烁 / 红色闪烁 | 系统正常 / 系统异常 |
| Master | 堆叠指示灯 | 绿色常亮 / 黄色常亮 / 熄灭 | 堆叠主设备 / 堆叠错误 / 非主设备 |
| Stack | 模式指示灯 | 绿色常亮 / 熄灭 | 端口指示灯工作在堆叠模式 / 其它模式 |
| 1-16(端口模式) | 端口指示灯 | 绿色常亮 / 黄色常亮 / 闪烁 / 熄灭 | 2.5Gbps / 10/100/1000Mbps / 传输中 / 未连接 |
| 17-20(端口模式) | 端口指示灯 | 绿色常亮 / 黄色常亮 / 闪烁 / 熄灭 | 10Gbps / 2.5Gbps 或 1000Mbps / 传输中 / 未连接 |
| USB | 端口指示灯 | 绿色常亮 / 熄灭 | USB 连接正常 / 未连接或异常 |
**Console 端口:** 用于和计算机或其他终端的串口相连以管理或配置交换机。TL-SE5420 提供 **1 个 Type-C Console 端口**
**2.5Gbps RJ45 端口:** 16 个,支持 10M/100M/1000M/2.5Gbps 速率,每个端口对应一个端口指示灯。
**10Gbps SFP+ 端口:** 支持接入 1Gbps/2.5Gbps SFP 光/电模块或 10Gbps SFP+ 光/电模块,每个端口对应 1 个 Link/Act 指示灯。
**USB 端口:** 标准 USB2.0480Mbps。可和交换机 Flash 文件系统交互(上传/下载应用程序、配置文件)。支持**交换机 U 盘开局**(详见《用户手册》)。
> 注意:不同厂商 USB 设备的兼容性和驱动存在差异,TP-LINK 不保证所有 USB 设备都能正常使用;如不能使用不属于交换机故障。
**后面板组成:** 1 防雷接线柱 · 2 电源插座(三相,220V~50Hz 交流电源)。
> 注意:请使用原装电源线;请通过电源线插头连接到有接地连接的输出插座;请远离水、火、潮湿或炎热环境;不要自行拆卸、修理、修改设备或更换电池(有爆炸危险)。
---
## 第 2 章 产品安装
### 2.1 物品清单
- 交换机 ×1 · 电源线 ×1 · 机架 L 型支架 ×2 · 安装手册 ×1 · 接地线 ×1 · 其他配件
### 2.2 安装注意事项
**安装安全:**
- 安装过程中电源保持关闭,佩戴防静电手环并保证接地良好;
- 确认供电电压与交换机标示电压相符;
- 通电前确认不会引起电源电路超负荷;
- 工作时不要打开外壳;
- 清洁前先拔电源插头,勿用湿润面料擦拭或液体清洗;
- 保持交换机底部朝下水平放置。
**环境要求:**
| 环境描述 | 温度 | 相对湿度 |
|---|---|---|
| 工作环境 | 0℃ ~ 40℃ | 10% ~ 90%RH 无凝结 |
| 存储环境 | -40℃ ~ 70℃ | 5% ~ 90%RH 无凝结 |
- 海拔:仅适用于 2000m 以下地区。
- 室内防尘、防电磁干扰、防雷:确认设备与大地接地良好;远离高频大功率设备;室外布线建议使用信号防雷器。
- 安装台:机架或工作台需承重至少 **5.5Kg**;供电插座与交换机距离不超过 1.5 米。
### 2.3 安装工具准备
十字螺丝刀 · 防静电手环 · 网线、光纤
### 2.4 产品安装
**桌面安装:** 底部朝上放置于稳定桌面 → 粘贴 4 个脚垫到机壳底部四角凹槽 → 翻转放平。机壳周围预留 5~10cm 空隙确保散热。
**19 英寸机架安装:** 检查机架接地与稳定性 → 两侧安装 L 型角铁并固定 → 安放到机架内由托架支撑 → 螺钉固定 L 型角铁到机架导槽。机架内一般由下至上安装。
---
## 第 3 章 硬件连接
### 3.1 连接 RJ45 端口
通过 RJ45 端口连接交换机和对端设备。
### 3.2 连接 SFP+ 端口
连接 SFP/SFP+ 光/电模块或 SFP+ 电缆。**SFP+ 端口默认支持 10Gbps;若使用千兆 SFP 光模块,需先将 SFP+ 端口速率设置为 1000Mbps。** 使用 SFP+ 电缆时确保最小弯曲半径符合要求。
### 3.3 连接 Console 端口
Type-C Console 端口,连接后可通过命令行管理交换机(登录方法见 4.2)。
### 3.4 连接电源线
接入 220V~50Hz 交流电源;确认供电电源与大地良好接触,确认电源开关位置以便及时断电。
### 3.5 设备初始化
接通电源后自动初始化:
- PWR 指示灯亮 = 电源连接正确,灭 = 供电异常;
- SYS 指示灯闪烁 = 系统初始化成功,灭或常亮 = 初始化失败;
- Link/Act 指示灯会闪烁一下后熄灭,指示系统已复位。
### 3.6 安装后检查
- 周围散热空间与空气流通;
- 电源插座供电符合规格;
- 电源、交换机、机架正确接地;
- 与其它网络设备连接正常。
---
## 第 4 章 配置指南
### 4.1 WEB 登录
> **TL-SE5420 业务口固定 IP 为 `10.18.18.251`**;电脑连接业务口时需设 IP `10.18.18.x`(x 为 2-250 任意值),子网掩码 `255.255.255.0`
1. 普通 PC 用网线连接交换机任意一个 RJ45 业务端口;
2. 浏览器输入 `http://10.18.18.251` 回车;
3. **设置用户名和密码**,点击<确认>登录 WEB 管理界面;
4. 首页左上角商标可访问 TP-LINK 官网;左侧主菜单进行功能配置。
### 4.2 本地登录(Console
1. 连接 Type-C Console 口到计算机;
2. 安装「**TP-LINK USB 串口驱动程序**」(官网下载或随机光盘);
3. 终端仿真程序参数:
- 波特率 **38400bps** · 数据位 **8** · 奇偶校验 **无** · 停止位 **1** · 数据流控制 **无**
4. 输入回车,出现 **`TL-SE5420>`** 提示符即登录成功。
### 4.3 远程登录(Telnet
1. cmd 输入 `telnet 10.18.18.251`
2. 输入登录用户名和密码;
3. 输入 `enable` 回车进入特权模式。
### 4.4 云管理
1. Web 界面 →<云管理> → 开启<全局开关>
2. 访问 `https://smbcloud.tp-link.com.cn/login.html`,登录 TP-LINK ID
3. 项目集中管理 → 设备列表 → 添加设备(输入 MAC、名称、用户名、密码、分组;MAC 可扫设备底部二维码获取);
4. 设备信息页找到对应条目 →<远程配置> → 云平台远程管理。
---
## 附录 A 常见故障处理
**问题1:忘记用户名和密码(恢复出厂设置)**
1. 串口连 Console 口,打开终端;
2. 交换机断电重启,终端出现 **`Press CTRL-B to enter the bootUtil`** 时按 `Ctrl+B` 进入 bootUtil 菜单;
3. 输入 **`2`** 进行软件复位,恢复到出厂默认设置,登录用户名和密码均为 **admin**
**问题2:电源指示灯显示不正常**
- 电源线连接是否正确;供电电源是否匹配(100-240V~ 50/60Hz)。
**问题3:无法登录 WEB 管理界面**
- 是否达到 HTTP 管理人数上限;
- 端口线缆连接与端口状态;
- 电脑 IP 与交换机 IP 是否同网段;
- `ping 127.0.0.1`(本机 TCP/IP)与 `ping 10.18.18.251`(交换机)。
**问题4:断电后配置消失**
- 配置实时生效,但**断电前必须点保存配置**。
---
## 附录 B 技术参数规格
| 项 | 值 |
|---|---|
| 产品型号 | TL-SE5420 |
| 技术标准 | IEEE 802.3 / 802.3i / 802.3u / 802.3ab / 802.3z / 802.3bz(2.5G/5G) / 802.3ae(10G) / 802.3ad(链路聚合) / 802.3x(流控) / 802.1p(QoS) / 802.1q(VLAN) / 802.1x / 802.1d(STP) / 802.1s(MSTP) / 802.1w(RSTP) / 802.1ab(LLDP) |
| 端口 | 16× 10/100/1000/2.5G RJ45 + 4× 10G SFP+ |
| 传输方式 | 存储转发 |
| 数据传输速率 | 10M半双工/20M全双工 · 100M半/200M全 · 千兆2000M全 · 2.5G 5000M全 · 10G 20000M全 |
| 包转发速率 | 10BASE-T:14881pps · 100BASE-TX:148810pps · 1000BASE:1488095pps · 2.5GBASE-T:3720238pps · 10GBASE:14880952pps(每端口) |
| 电源输入 | 220V~50Hz |
| 工作温度 | 0℃~40℃ |
| 存储温度 | -40℃~70℃ |
| 工作湿度 | 10%~90%RH 无凝结 |
| 存储湿度 | 5%~90%RH 无凝结 |
网络介质(部分):10BASE-T 3类线≤100m · 100BASE-TX 5类线≤100m · 1000BASE-T 超5类≤100m · 2.5GBASE-T 超5类≤100m · 10GBASE-SR OM1/OM2/OM3 MMF 2m~300m · 10GBASE-LR SMF 2m~10000m。
---
## 附录 C 连接 SFP+ 端口补充说明
- **光纤**:单模(芯径 9/10μm,模间色散小,适合远程);多模(芯径 50/62.5μm,传输距离较近,一般几公里)。
- **光模块**:按性质分单模/多模;按速率分百兆/千兆/万兆。SFP/SFP+ 端口兼容单模、多模光模块,推荐使用 TP-LINK 光模块。
**TP-LINK 光模块参考表:**
| 模块类型 | 产品型号 | 速率 | 距离 | 传输介质 |
|---|---|---|---|---|
| 光模块 | TL-SM512LS-10KM | 10Gbps | 10km | SMF 双纤 LC |
| 光模块 | TL-SM512LM-300m | 10Gbps | 300m | OM3 MMF 双纤 LC |
| 光模块 | TL-SM411LSA/B-5KM | 2.5Gbps | 5km | SMF 单纤 LC |
| 光模块 | TL-SM411LSA/B-500m | 2.5Gbps | 500m | SMF 单纤 LC |
| 光模块 | TL-SM411SSA/B-500m | 2.5Gbps | 500m | SMF 单纤 SC |
| 光模块 | TL-SM412LS-5KM | 2.5Gbps | 5km | SMF 双纤 LC |
| 光模块 | TL-SM311LSA/B-20KM | 1Gbps | 20km | SMF 单纤 LC |
| 光模块 | TL-SM311LSA/B-2KM | 1Gbps | 2km | SMF 单纤 LC |
| 光模块 | TL-SM311SSA/B-2KM | 1Gbps | 2km | SMF 单纤 SC |
| 光模块 | TL-SM312LS-40KM | 1Gbps | 40km | SMF 双纤 LC |
| 光模块 | TL-SM312LS-20KM | 1Gbps | 20km | SMF 双纤 LC |
| 光模块 | TL-SM312LS-20KM工业级 | 1Gbps | 20km | SMF 双纤 LC |
| 光模块 | TL-SM312LM-550m | 1Gbps | 550m | OM2 MMF 双纤 LC |
| 电口模块 | TL-SM510U | 10Gbps | 30m | 超六类网线 |
| 电口模块 | TL-SM410U | 2.5Gbps | 100m | 超五类网线 |
| 电口模块 | TL-SM310U | 1Gbps | 100m | 超五类网线 |
---
## 附录 D 产品保修卡(要点)
- 免费保修范围外的情况:超出保换/保修期限、未按要求安装使用、擅自改动设置或拆机、意外或人为损坏、运输损坏、不可抗力等。
- 技术支持热线:`400-8863-400` · 邮箱 `fae@tp-link.com.cn` · 官网 `www.tp-link.com.cn`
- **售后服务承诺:三年保修。**
- 外置电源保换 1 年,电池 6 个月,鼠标 3 个月;无线外置天线及光纤头 3 个月。
- 购机 15 天内出现性能问题且外观无划伤可换新。
---
## 附录 E 有毒有害物质含量声明
(详见官方 PDF 附表。)
---
公司地址:深圳市南山区深南路科技园工业厂房 24 栋南段 1 层、3-5 层、28 栋北段 1-4 层
公司网址:<http://www.tp-link.com.cn> · 技术支持 E-mail`smb@tp-link.com.cn` · 技术支持热线:`400-8863-400`
文档版本:REV1.0.2
+272
View File
@@ -0,0 +1,272 @@
# UniFi network configuration
## Controller
| Item | Value |
|---|---|
| Host | [ubnt](../hosts/ubnt.md) |
| Address | `192.168.66.46` |
| Deployment | Docker, `jacobalberty/unifi:v9.5.21`, host networking |
| Inform endpoint | `http://192.168.66.46:9080/inform` |
| MongoDB database / port | `ace` / `27117` |
Although `8080` is a common UniFi inform port, this controller uses `9080`.
All AP adoption and recovery procedures must use the endpoint above.
## Login and MFA
On 2026-08-08, **"Sync Local Admin with Ubiquiti SSO"** was disabled
(`super_sdn.sso_login_enabled: false`). Previously every login was forced
through Ubiquiti SSO (sso.ui.com) and required MFA, which blocked programmatic
access: the `unifi-network-mcp` write operations failed with
`api.err.Ubic2faTokenRequired`.
Now local accounts (`windy`, `zhiqiangf`) sign in with their local passwords
and no MFA. The `unifi-network-mcp` tool uses the `zhiqiangf` local account and
works for both read and write operations (verified 2026-08-08 with a reversible
client rename).
Impact: unifi.ui.com remote access (Site Manager) no longer works.
Manage the setting idempotently via Ansible:
```bash
cd ansible
ansible-playbook playbooks/unifi-sso.yml --limit unifi --check # read-only
ansible-playbook playbooks/unifi-sso.yml --limit unifi \
-e '{"unifi_sso_confirm": true, "unifi_sso_target_value": false}' # apply
```
See [runbooks/ansible-operations.md](../runbooks/ansible-operations.md).
## Inter-VLAN reachability
The AP management networks are routed by [gw](../hosts/gw.md),
`192.168.66.254`. On 2026-08-05, a probe sourced from the LAN55 gateway
(`192.168.55.254`) reached the controller (`192.168.66.46`) with 3/3 ICMP
replies. The UAP-AC-Lite is online through the `:9080/inform` endpoint, which
also confirms the required application path in practice.
`LAN_IN` contains explicit allow rules for `192.168.55.0/24 ↔
192.168.66.0/24`, but the firewall set is currently inactive (not attached to
an interface). Treat the networks as bidirectionally reachable by default;
do not rely on those inactive rules as an access boundary. Recheck this after
attaching any LAN firewall policy or changing VLAN routing.
To inspect device records from the controller host:
```bash
docker exec -it unifi-controller bash
mongo --port 27117
```
```javascript
use ace
db.device.find(
{ type: "uap" },
{ name: 1, ip: 1, model: 1, inform_url: 1 }
).pretty()
```
## IPv6 status (verified 2026-08-20)
IPv6 is **enabled and live** on the main Wi-Fi networks. Read-only
verification, no changes made.
**Controller (`networkconf` in the `ace` DB):** the `Default` LAN network has
`ipv6_enabled: true`, `ipv6_client_address_assignment: slaac`,
`ipv6_ra_enabled: true`, `ipv6_ra_priority: high`, and
`dhcpdv6_allow_slaac: true`. `ipv6_interface_type: "none"` is expected: the
network's gateway is the third-party EdgeRouter (`gw`), so the controller does
not manage WAN-side IPv6 — RA/SLAAC is served by the router.
All active SSIDs map to the `Default` network: `ubnt-windy` (5G),
`ubnt-windy-2` (2.4G), `ubnt-haas` (2.4G) — clients on them receive SLAAC IPv6.
Exception: the dormant `ubnt-upg` VLAN 10 network (and its `ubnt-upg` SSID) has
no IPv6 configuration (default off). See
[Dedicated Wi-Fi through a third-party gateway](#dedicated-wi-fi-through-a-third-party-gateway).
**APs (live):** both managed APs hold global SLAAC addresses on `br0` with a
default route learned via RA from `gw`:
| AP | Global IPv6 on `br0` (at check time) | Default route |
|---|---|---|
| U6 Lite (`192.168.66.6`) | `240e:3bd:235:1fb1:...`/64 | `default via fe80::... dev br0 proto ra` |
| UAP-AC-Lite (`192.168.55.5`) | `240e:3bd:235:1fb2:...`/64 | `default via fe80::... dev br0 proto ra` |
The delegated prefixes are dynamic ISP allocations (PPPoE PD `/60`) and rotate
on redial; only the structure is stable.
**Gateway (`gw`):** the IPv6 routing table shows connected `/64`s on `eth0`
(LAN66) and `switch0` (LAN55) plus `::/0` via `pppoe0`.
Re-verify:
```bash
ssh -4 -o BatchMode=yes zhiqiangf@192.168.66.6 'ip -6 addr show br0; ip -6 route show'
ssh -4 -o BatchMode=yes zhiqiangf@192.168.55.5 'ip -6 addr show br0; ip -6 route show'
```
> **2026-08-21 (W1N-207):** SmartThings Element/vWire provisioning SSIDs
> (`element-8a0d5133c9438f12`, `vwire-8b2d67469e455785`, `vport-F09FC22004E9`) were
> removed (element_adopt setting disabled + element wlanconf deleted + device vwire
> fields cleared) and confirmed off on both APs (normal SSIDs unchanged: `ubnt-windy`,
> `ubnt-windy-2`, `ubnt-haas`, `ubnt-upg`). Root cause of Matter onboarding failure that
> day: HA's matter-server advertised a stale IPv6 GUA (two prefix generations old) in
> mDNS; fixed by restarting the add-on — see [hosts/hass.windy.lan.md](../hosts/hass.windy.lan.md).
>
> **SSID ↔ subnet split (Matter-relevant):** `ubnt-windy` (5G) is served only by the
> U6 Lite on LAN66; `ubnt-haas` / `ubnt-windy-2` (2.4G) only by the UAP-AC-Lite on
> LAN55. mDNS is link-local multicast and does **not** cross the routed 55/66
> boundary (no mDNS reflector). Matter commissioning therefore requires phone and
> device on the **same subnet (LAN55)**; a phone on 5G (LAN66) cannot discover a
> LAN55 Matter device.
>
> **Cleanup side-effects (left as-is, harmless):** after the direct-DB cleanup,
> `db.device.cfgversion` holds placeholder values (`0000000000000000` /
> `1111111111111111`) and UAP-AC-Lite has `mesh_sta_vap_enabled=false`; the
> controller has not reverted them and no functional impact was observed.
## Dedicated Wi-Fi through a third-party gateway
### Architecture boundary discovered on 2026-08-08
`gfw` is a virtual machine connected only to the native LAN 66 network; the
physical Wi-Fi uplink is also an ER-X LAN 66 path. The ER-X provides DHCP for
that LAN. There is no verified 802.1Q-capable path from the U6 Lite to `gfw`.
Consequently, the proposed `ubnt-upg` design is **not implementable on the
current topology without an additional VLAN-capable L2 path**. Giving the VM
an `eth0.10` interface alone does not create VLAN 10 on the physical switch or
deliver tagged frames to it.
UniFi APs associate clients and bridge an SSID into a selected 802.1Q VLAN;
they do not allocate DHCP addresses. The controller configures that AP
behavior, but it is not a DHCP service for a third-party-gateway VLAN. The
gateway on the corresponding VLAN provides DHCP. In the existing topology, the
ER-X DHCP service legitimately answers untagged LAN 66 requests.
The observed `192.168.66.91` lease on `ubnt-upg` is therefore expected when
traffic reaches the existing untagged LAN 66. It is not a failure that can be
fixed by changing the controller or by adding another DHCP server to the same
broadcast domain.
### Conditions for a future implementation
The design becomes viable only after adding and proving one of these:
1. A VLAN-capable switch/trunk from the U6 Lite to a VLAN-10 interface on the
actual gateway, with native LAN 66 retained for AP management; or
2. A hypervisor/vSwitch and physical uplink configured to pass VLAN 10 end to
end to the `gfw` VM, plus the corresponding tagged port towards the U6.
Then configure one UniFi virtual network with **Router: Third-party Gateway**
and VLAN ID `10`, map only `ubnt-upg` to it, and have the VLAN 10 gateway be
the sole DHCP server for `192.168.10.0/24`. A test client must obtain
`192.168.10.x`, with traffic and its lease visible on that gateway, before the
SSID is considered working.
### Protecting existing Wi-Fi
`ubnt-windy` is the existing 5 GHz SSID and has been restored on the U6 Lite.
Keep its network and broadcast/device assignment unchanged. If the dedicated
SSID is ever retried, use the controller's normal **Specific -> U6 Lite**
broadcast selection for `ubnt-upg` only; do not reuse, move, or manually edit
an existing SSID's AP group. An AP configuration push can still briefly
reassociate clients, so zero-impact work on the same AP cannot be guaranteed.
### References
- [Feasibility and DHCP responsibility](unifi-third-party-vlan10-dhcp.md)
- [Supported topology and implementation examples](unifi-openwrt-vlan10-implementation-examples.md)
- Ubiquiti: [Creating Virtual Networks (VLANs)](https://help.ui.com/hc/en-us/articles/9761080275607-Creating-Virtual-Networks-VLANs),
[Creating WiFi SSIDs](https://help.ui.com/hc/en-us/articles/26136823938583-Creating-UniFi-WiFi-SSIDs), and
[Switch Port VLAN Assignment](https://help.ui.com/hc/en-us/articles/26136855808919-Switch-Port-VLAN-Assignment-Trunk-Access-Ports).
## Managed access points
| Name | Model | Management IP | Firmware | Management network | Inform port |
|---|---|---|---|---|---|
| U6 Lite | U6 Lite (`UAL6`) | `192.168.66.6` | `6.7.54.15663` | LAN66 (`192.168.66.0/24`, gateway `192.168.66.254`) | `9080` |
| UAP-AC-Lite | `U7LT` | `192.168.55.5` | `6.8.2.15592` | LAN55 (`192.168.55.0/24`, gateway `192.168.55.254`) | `9080` |
AP MAC addresses are recorded only where operationally needed: U6 Lite
`78:45:58:4d:cc:30`; UAP-AC-Lite `f0:9f:c2:20:04:e9`.
## SSH administration
Adopted APs use the SSH account configured in the controller, rather than the
factory `ubnt/ubnt` credential. The password is stored in the password manager
and must not be added to this repository.
In UniFi Network `9.5.21`, configure it at:
```text
Devices → Device Updates and Settings → Device SSH Settings
→ Device SSH Authentication
```
Use `ssh zhiqiangf@AP_IP` for the adopted-device account. Do not query or copy
the controller's `mgmt` database setting into logs or documentation: it can
contain the managed SSH password.
Key-only IPv4 SSH was verified for both managed APs using the `zhiqiangf`
account on 2026-08-06 and re-verified 2026-08-20 (BatchMode with password and
keyboard-interactive disabled; both APs still log in key-only). Verify future
access without permitting password or keyboard-interactive fallback:
```bash
ssh -4 -o BatchMode=yes -o PasswordAuthentication=no \
-o KbdInteractiveAuthentication=no -o StrictHostKeyChecking=yes \
zhiqiangf@192.168.66.6 'mca-cli-op info'
ssh -4 -o BatchMode=yes -o PasswordAuthentication=no \
-o KbdInteractiveAuthentication=no -o StrictHostKeyChecking=yes \
zhiqiangf@192.168.55.5 'mca-cli-op info'
```
Both devices reported `Connected` to
`http://192.168.66.46:9080/inform` during that verification. The AP shell maps
this management account to UID 0, so treat it as administrative access.
## Offline AP recovery
An AP can be reachable via ICMP and SSH but offline in the controller when its
saved inform URL still points to port `8080`. This occurred for UAP-AC-Lite
after its inform configuration had diverged from the controller endpoint.
From the AP, inspect the active endpoint and test controller reachability:
```bash
info
nc 192.168.66.46 9080
```
The AP's BusyBox `nc` may not support `-zv`; use the plain `nc` invocation
above when testing the TCP path.
If `info` reports `http://192.168.66.46:8080/inform` (or another obsolete
endpoint), correct it. UniFi devices commonly require the command twice while
adoption is completed:
```bash
set-inform http://192.168.66.46:9080/inform
set-inform http://192.168.66.46:9080/inform
```
Verify that the AP changes to connected/online in the controller.
## Controller migration check
After a controller migration or restore, inspect stored device records for a
stale default-port endpoint before treating an AP as faulty:
```javascript
use ace
db.device.find(
{ inform_url: /8080/ },
{ name: 1, ip: 1, inform_url: 1 }
)
```
Do not include credentials or controller database dumps in incident notes.
@@ -0,0 +1,120 @@
# UniFi AP + OpenWrt VLAN-backed Wi-Fi: implementation evidence
**Research date:** 2026-08-08
**Scope:** research only. This file does not assert that the local physical
path currently carries VLAN 10, and no controller, AP, ERX, or OpenWrt setting
was changed while preparing it.
## Conclusion
Yes. This is a normal, supported deployment pattern: UniFi APs and Network
Application manage the SSID-to-VLAN mapping, while a non-UniFi gateway such as
OpenWrt owns the tagged VLAN interface, its subnet, DHCP, routing, and
firewall/policy routing. The existing untagged LAN DHCP server remains in
place; it serves only the native LAN, while OpenWrt serves the tagged VLAN.
Ubiquiti's current third-party-gateway recipe specifically says to configure
the subnet, VLAN ID, and DHCP **on the third-party gateway first**, then create
a UniFi virtual network with **Router = Third-party Gateway** and the same VLAN
ID. [Ubiquiti: Creating Virtual Networks (VLANs)](https://help.ui.com/hc/en-us/articles/9761080275607-Creating-Virtual-Networks-VLANs)
That supported pattern requires an end-to-end tagged path. It is not feasible
on the current local topology: `gfw` is a VM connected only to untagged LAN 66
and there is no verified physical or virtual switch trunk carrying VLAN 10 from
the U6 Lite to that VM. Creating `eth0.10` inside the VM cannot supply the
missing L2 transport.
## The reusable topology
```text
main SSID ── untagged/native LAN66 ── ERX DHCP ── 192.168.66.0/24
U6 Lite ── AP uplink trunk (native LAN66 + tagged VLAN 10) ── gfw/OpenWrt
└── ubnt-upg SSID ── VLAN 10 ── gfw.10: 192.168.10.1/24 + DHCP + OpenClash
```
This is not two DHCP servers answering one broadcast domain. They serve two
separate L2 broadcast domains. An AP bridges a station's DHCP request into the
VLAN selected for its SSID; it does not allocate the address itself. Ubiquiti
states that SSID clients become members of the selected VLAN and that every
port between AP and gateway must allow that VLAN. [Ubiquiti: Creating UniFi
WiFi SSIDs](https://help.ui.com/hc/en-us/articles/26136823938583-Creating-UniFi-WiFi-SSIDs)
OpenWrt's own VLAN documentation shows the same mixed-link model: an untagged
LAN plus tagged VLAN interfaces on a common link, and explicitly notes that a
separate DHCP server can serve each segment. [OpenWrt: VLAN / switch
configuration](https://openwrt.org/docs/guide-user/network/vlan/switch_configuration)
## Evidence of existing deployments
### Protocol and product documentation (authoritative)
* Ubiquiti's third-party-gateway workflow is an exact product-supported recipe
for the UniFi half: gateway first, then matching VLAN in UniFi. It does not
require a UniFi gateway or switch. [Creating Virtual Networks
(VLANs)](https://help.ui.com/hc/en-us/articles/9761080275607-Creating-Virtual-Networks-VLANs)
* Ubiquiti specifies the exact Wi-Fi half: choose the network in the SSID's
**Network** dropdown, select the APs that should broadcast it, and permit the
VLAN on all intervening ports. [Creating UniFi WiFi
SSIDs](https://help.ui.com/hc/en-us/articles/26136823938583-Creating-UniFi-WiFi-SSIDs)
* Ubiquiti's troubleshooting topology specifically calls an SSID VLAN made
native on the AP port, or absent as a tagged VLAN upstream, a broken setup;
it directs operators to trace every AP-to-gateway port. [Virtual Network
(VLAN) Troubleshooting](https://help.ui.com/hc/en-us/articles/9592924981911-Virtual-Network-VLAN-Troubleshooting)
* OpenWrt documents Linux 802.1Q interfaces and bridge VLAN membership. Its
example calls out a trunk carrying untagged traffic and tagged VLANs, which
is the link gfw needs. [OpenWrt: VLAN / switch
configuration](https://openwrt.org/docs/guide-user/network/vlan/switch_configuration)
### User-reported working deployments (official Ubiquiti Community; anecdotal)
These are not Ubiquiti support guarantees, but they demonstrate that the exact
vendor split is in real use:
* A user reports a UniFi site whose upstream router/DHCP server is **OpenWrt**;
they create a Third-Party Gateway network, assign its VLAN tag, then bind it
to the Wi-Fi SSID. The report says it works without a UniFi gateway or switch.
[UniFi Network Application 7.4.156 discussion](https://community.ui.com/releases/UniFi-Network-Application-7-4-156/15ac6260-9cd1-4ac3-a91c-4880c1c87882?page=36)
* A second user reports two SSID VLANs over an **OpenWrt Linksys WRT32X 802.1Q
trunk**, with no USG or UniFi switch; the controller network is selected in
the SSID's Network dropdown. [Assigning VLANs to SSIDs on Controller
6](https://community.ui.com/questions/Assigning-VLANs-to-SSIDs-on-Controller-6/3e656047-685f-470e-954c-703077e89aaa)
* A Ubiquiti Community post describes an existing multi-VLAN UniFi deployment
with an OpenWrt NanoPi R6S gateway and all UniFi networks configured as
Third-party Gateway. [OpenWrt third-party-gateway
deployment](https://community.ui.com/questions/Replacing-an-existing-third-party-router-firewall-with-a-UXG-Lite-on-an-existing-UniFi-network/5de2504a-fac2-437a-b893-495a6be2f3c0)
## Exact mapping for this network
| Layer | Required configuration |
|---|---|
| `gfw` / OpenWrt | Only after a tagged path reaches the VM: create/retain VLAN-10 interface, address `192.168.10.1/24`, run the **only** DHCP server for that segment, and apply the intended OpenClash/policy routing. |
| U6-to-gfw path | Native/untagged LAN66 for U6 management; tagged VLAN 10 allowed at every switch/link. Do not replace the native LAN with VLAN 10. |
| UniFi network | One VLAN 10 virtual network with **Router: Third-party Gateway**. This is controller metadata that makes the AP tag the selected SSID; it is not a DHCP service. |
| `ubnt-upg` Wi-Fi | Select that VLAN 10 network. Do not enable UniFi Hotspot/guest portal unless deliberately required. |
| Existing Wi-Fi | Leave `ubnt-windy`, `ubnt-windy-2`, and `ubnt-haas` network/VLAN choices untouched; they remain on their existing LANs and DHCP servers. |
The acceptance test is therefore unambiguous: after reconnecting, a device on
`ubnt-upg` must receive `192.168.10.x` from the VLAN 10 gateway, while a device
on existing Wi-Fi continues to receive its established LAN address. A
`192.168.66.x` lease on the dedicated SSID means VLAN 10 was not selected or
did not pass the L2 path; it is not evidence that the U6 should run DHCP.
## Protecting the existing Wi-Fi during a retry
The current UniFi UI supports **All**, **Specific**, and **Groups** independently
for each SSID's Broadcasting APs selection. [UniFi WiFi SSID and AP Settings
Overview](https://help.ui.com/hc/en-us/articles/32065480092951-UniFi-WiFi-SSID-and-AP-Settings-Overview)
Given the earlier incident where changing a virtual/AP group removed
`ubnt-windy` from the U6 Lite, the low-risk control is to leave every existing
SSID's Broadcasting APs selection unchanged and set **only `ubnt-upg`** to
**Specific → U6 Lite**. Do not reuse or move the default SSID into a temporary
group. This does not eliminate the short re-provision/reassociation caused by
an AP config push, but it prevents the configuration-level loss of the default
SSID that was observed.
Before enabling the dedicated SSID, inspect the saved settings and AP's
effective configuration, then test with one client. Only the dedicated SSID
should be affected by a failure; LAN66 DHCP and the existing SSIDs require no
change.
+104
View File
@@ -0,0 +1,104 @@
# UniFi Wi-Fi VLAN 10 with a third-party gateway
**Research date:** 2026-08-08
**Scope:** evidence-backed design only; no controller, AP, switch, EdgeRouter,
or OpenWrt settings were changed by this research.
## Answer: who assigns client addresses?
Neither a UniFi Network controller nor a UniFi AP is the DHCP server for a
normal third-party-gateway deployment. The controller configures the AP; the
AP maps a selected SSID to an 802.1Q VLAN and bridges the client's DHCP
broadcast into that VLAN. A UniFi **Gateway** can provide DHCP, but Ubiquiti's
DHCP documentation expressly describes the DHCP server as a service of UniFi
Gateways, not APs or the Network Application. [UniFi DHCP Server](https://help.ui.com/hc/en-us/articles/360012097513-UniFi-DHCP-Server)
Accordingly, an existing DHCP server on the native/untagged LAN (here, the
EdgeRouter X for `192.168.66.0/24`) is expected to answer an SSID that is not
actually VLAN-tagged. It must **not** provide the lease for a genuinely
separate dedicated VLAN. That VLAN's gateway would provide its DHCP service.
## Required architecture
```text
client → SSID ubnt-upg → U6 Lite
│ VLAN 10 (802.1Q tagged)
every intervening L2 link permits VLAN 10
gateway VLAN-10 interface: 192.168.10.1/24 + DHCP + routing/OpenClash
```
The AP's management traffic may remain untagged on LAN66. VLAN 10 is only
the client traffic emitted by `ubnt-upg`; it must be carried, tagged, from the
U6 Lite through every intervening switch/link to `gfw`. Ubiquiti explicitly
states that selecting a network for an SSID makes connected clients members of
that VLAN and that all ports between AP and gateway must permit its VLAN ID;
it recommends AP uplinks be trunked for the relevant VLANs. [Creating UniFi
WiFi SSIDs](https://help.ui.com/hc/en-us/articles/26136823938583-Creating-UniFi-WiFi-SSIDs)
On a managed UniFi switch, this means the AP uplink and every upstream port in
the path have native LAN66 (for AP management) plus tagged VLAN 10. Ubiquiti
defines that as a trunk, and warns that an AP uplink must not restrict VLANs
needed downstream. [Switch Port VLAN Assignment](https://help.ui.com/hc/en-us/articles/26136855808919-Switch-Port-VLAN-Assignment-Trunk-Access-Ports)
If a non-UniFi switch is in the path, its 802.1Q settings must be configured
equivalently (or it must be verified to transparently forward VLAN 10); UniFi
does not manage that switch. This is a physical/L2 requirement, so merely
creating a controller network cannot make a VLAN reach `gfw`.
## Local feasibility result
The local `gfw` instance is a VM attached only to untagged LAN 66, and the U6
Lite's physical path is the same ER-X LAN 66 network. No VLAN-capable physical
trunk or verified hypervisor/vSwitch VLAN handoff exists between them. The
diagram above is therefore a valid design pattern but **not a description of
the current deployable topology**. An `eth0.10` interface inside the VM cannot
create that missing L2 path.
To make this architecture feasible, add a VLAN-capable switch/trunk from AP to
gateway, or configure an end-to-end tagged VLAN handoff through the hypervisor
and its physical uplink. Until then, leave the ER-X LAN 66 DHCP design in place
and do not expect `gfw` to serve an SSID-only subnet.
## Correct controller model (after the L2 prerequisite exists)
Use one UniFi virtual network for VLAN 10 whose **Router** is **Third-party
Gateway** and whose VLAN ID is `10`; assign that network to Wi-Fi
`ubnt-upg`. Configure the subnet, VLAN and DHCP on the VLAN gateway first.
This follows Ubiquiti's documented third-party-gateway workflow: create the
VLAN on the gateway, then add a UniFi virtual network with Router =
Third-party Gateway and the matching VLAN ID. [Creating Virtual Networks
(VLANs)](https://help.ui.com/hc/en-us/articles/9761080275607-Creating-Virtual-Networks-VLANs)
Do not enable a second DHCP service in UniFi for this VLAN. The controller
entry is the AP/switch VLAN definition; the gateway on VLAN 10 remains the
sole DHCP server and default gateway. The EdgeRouter X's LAN66 DHCP service
can remain unchanged because it is for untagged LAN66, not VLAN 10.
## Safe verification sequence
1. Read-only confirm on `gfw` that its VLAN-10 interface has
`192.168.10.1/24`, DHCP is enabled for `192.168.10.0/24`, and no other
DHCP daemon serves that VLAN.
2. In the controller, verify `ubnt-upg` selects the Third-party Gateway VLAN
10 network, not `Default`/LAN66 and not an unassigned VLAN-only placeholder.
3. Trace the U6 Lite-to-`gfw` L2 path and permit tagged VLAN 10 on every link
while retaining LAN66 as the AP's native/management network.
4. Reconnect a test client (forget/rejoin or renew lease). The expected lease
is `192.168.10.x`, gateway/DNS `192.168.10.1` (or the DNS option configured
by `gfw`), never `192.168.66.x`.
5. Confirm a lease appears on `gfw` and that the VLAN-10 interface's RX/TX
counters advance. Ubiquiti's VLAN troubleshooting guide identifies
upstream ports not allowing the VLAN as the common cause when Wi-Fi clients
cannot receive a correct IP address. [Virtual Network (VLAN)
Troubleshooting](https://help.ui.com/hc/en-us/articles/9592924981911-Virtual-Network-VLAN-Troubleshooting)
## What the observed `192.168.66.91` proves
It is evidence that the client's DHCP discover reached the LAN66 broadcast
domain and was answered by the EdgeRouter X. It does not show a DHCP failure
on `gfw`; it shows that the client was bridged untagged (or VLAN 10 was not
carried end-to-end) at that attempt. The corrective work is therefore to make
the SSID-to-VLAN association and L2 trunk path real, then test the lease—not to
make the AP/controller compete with the LAN DHCP server.
+85
View File
@@ -0,0 +1,85 @@
# dns.windy.lan — AdGuard Home LAN DNS
## Role and access
| Item | Value |
|---|---|
| IPv4 | `192.168.66.36` |
| SSH | `ssh -4 windy@192.168.66.36` |
| OS | Debian 12 |
| **Host** | **PVE VM 120 (`pihole`)** — not a separate physical host (verified 2026-08-09) |
| Roles | AdGuard Home LAN DNS; Mihomo explicit-proxy service |
Use `windy` for routine inspection. Do not store credentials, AdGuard Home
user hashes, proxy subscriptions, or query-log contents in this repository.
## AdGuard Home
AdGuard Home runs as the root-owned `adguardhome` Docker container in host
network mode. The persistent deployment is `/opt/adguardhome`:
| Item | Path / setting |
|---|---|
| Compose file | `/opt/adguardhome/docker-compose.yml` |
| Configuration | `/opt/adguardhome/conf/AdGuardHome.yaml` |
| Mutable data and query logs | `/opt/adguardhome/work` |
| DNS | TCP/UDP `53`; intentionally no source ACL so both LAN55 and LAN66 work |
| HTTP UI | `192.168.66.36:80`; LAN66 only |
| Image policy | Pin the reviewed image digest; do not use `latest` for a rebuild |
The EdgeRouter DHCP service advertises this host as DNS for LAN55 and LAN66.
Do not enable AdGuard Home DHCP unless the existing EdgeRouter DHCP service is
explicitly migrated and disabled first.
`agh-ui-access.service` loads `/etc/nftables-agh-ui-access.nft`. It permits
`192.168.66.0/24` (LAN66) and `192.168.55.0/24` (LAN55, for Home Assistant
integration) to TCP/80 and drops other TCP/80 input. It deliberately
does **not** restrict DNS, SSH, Docker, or Mihomo ports. Keep it isolated from
Docker-managed nftables tables.
> 2026-08-12: added `192.168.55.0/24` allow so HAOS (`192.168.55.11`) can reach
the HTTP API on `:80` for the Home Assistant AdGuard Home integration; applied
via `sudo systemctl restart agh-ui-access.service` (file edited first, then
reloaded; syntax verified with `nft -c`). Other firewalls (EdgeRouter LAN_IN/
LAN_OUT inactive, PVE zero rules) were already open for LAN55->LAN66.
Current query-log policy is 14 days with anonymized client IPs. Check free
space before increasing retention. DNSSEC is disabled because the selected
upstream path did not pass the known-bad-signature validation check; do not
enable it without re-testing validated upstreams.
`hass.windy.lan` points to Home Assistant via this rewrite. The legacy
`hass.local` rewrite was removed on 2026-08-14; `hass.local` now resolves only
via HAOS mDNS/LLMNR (`hostname: hass`), not via AdGuard Home. The
`nas.windy.local` rewrite was likewise removed on 2026-08-14; `.local` names
are now left to mDNS only. Remaining rewrites all use `.windy.lan`.
## Mihomo and routing boundary
Mihomo listens for explicit proxy traffic on this host. Read-only checks found
no host TUN policy routing or local nftables/iptables transparent-redirection
rules. A Mihomo listener alone does not mean it intercepts AdGuard Home
traffic. The default gateway (`gfw.windy.lan`) can still affect outbound
traffic, so inspect its OpenClash rules separately before changing upstream
routing.
## Safety
- Back up `/opt/adguardhome/conf/AdGuardHome.yaml` and `docker-compose.yml`
before changing either, then run the official `--check-config` command.
- Recreate only the AdGuard Home container and verify DNS A/AAAA, local names,
the UI from LAN66, and the UI denial from LAN55 afterwards.
- Private PTR currently has no local authoritative source: direct EdgeRouter
PTR queries return NXDOMAIN. Do not point `local_ptr_upstreams` at the
router until that source exists.
See [the health runbook](../runbooks/adguard-home-health.md) and
[the official configuration review](../docs/adguard-home-official-review-2026-08.md).
Routine health through Ansible (`--limit adguardhome`), with a daily timer +
email alerting like the other active hosts:
```bash
cd ansible
ansible-playbook playbooks/health-report.yml --limit adguardhome
```
+77 -6
View File
@@ -6,17 +6,71 @@
|---|---|
| Role | LAN gateway and OpenClash transparent/explicit-proxy host |
| IPv4 | `192.168.66.1` |
| SSH | `ssh -4 root@192.168.66.1` |
| OS | OpenWrt, Linux `6.12.87` |
| SSH | `ssh -4 root@192.168.66.1` (key-only, verified 2026-08-06) |
| OS | ImmortalWrt 25.12.0 (r37854), Linux `6.12.87`, x86/64 |
| **Host** | **PVE VM 140 (`gfw`)** — 3 NICs: `net0`→vmbr0(LAN66/eth0), `net1`→vmbr1(LAN55/eth1, up but unaddressed), `net2`→VLAN10/`ubunt_upg`(eth2, `192.168.10.1/24`) (topology 2026-08-09; eth2/VLAN10 live verified 2026-08-11) |
Do not store the root password in this repository. Prefer a verified SSH key for
future management access.
Do not store the root password in this repository.
## OpenClash
OpenClash runs `/etc/openclash/clash` with configuration
OpenClash runs `/etc/openclash/clash` (clash_meta core) with configuration
`/etc/openclash/pass-cat.yaml`.
- Core: **Mihomo Meta v1.19.29** (linux-amd64, with_gvisor; upgraded from
`alpha-g9ae1cc2` on 2026-08-06, backup `clash_meta.bak.alpha-g9ae1cc2`) —
tracked in Linear W1N-50
- OpenClash app: **luci-app-openclash 0.47.133** (GitHub latest)
- Mode: **fake-ip + TPROXY transparent proxy** (`operation_mode=fake-ip`,
`en_mode=fake-ip`, `proxy_mode=rule`); fake-ip network `198.18.0.0/16`
- DNS path: dnsmasq → clash DNS `127.0.0.1#7874` (`server=127.0.0.1#7874` in
dnsmasq config); OpenClash custom DNS uses `mosdns` on `127.0.0.1:6052` as its
`nameserver`/`default-nameserver` for DIRECT-rule real-IP resolution
(`/etc/mosdns/config.yaml`): domestic domains → AGH `.36:53`, foreign →
`223.5.5.5`/`119.29.29.29` (Chinese public DNS). mosdns is **not** in the
client query path — LAN/VLAN10 clients receive fake-ip from clash :7874.
> 2026-08-12: fixed missing `has_resp → accept` guard after the domestic
> branch in `/etc/mosdns/config.yaml` (domestic queries were double-forwarded,
> final answer came from CN public DNS, bypassing AGH blocking/rewrites;
> verified via `dup.baidustatic.com` before/after); added `domestic_fallback`
> (fallback plugin: primary=AGH, secondary=CN public DNS, 500ms) so domestic
> DIRECT lookups survive an AGH outage. Backups:
> `config.yaml.bak-20260812` / `config.yaml.bak-fallback-20260812`. See
> [docs/lan-dns-architecture.md](../docs/lan-dns-architecture.md) §1.
> 2026-08-13 (W1N-62): foreign branch now uses encrypted DoH
> `https://adg.chans.xyz/dns-query` (self-hosted, hk2) via new
> `foreign_upstream` / `foreign_fallback` plugins; non-CN queries → DoH,
> falls back to CN public DNS after 1000ms. `bootstrap` = existing CN public
> DNS IPs (no self-loop). Live-verified: google/youtube real IP + AAAA
> restored (2607:f8b0…), `dup.baidustatic.com``0.0.0.0` (AGH intercept
> kept), clash 7874 fake-ip plane unchanged. **Final decision (2026-08-13):
> DoH goes DIRECT to hk2, not via clash proxy** — `foreign_upstream` points
> only at the self-hosted resolver `adg.chans.xyz` (hk2), which is directly
> reachable and already encrypted (DoH/TLS) with clean answers, so forcing
> the proxy adds nothing and would couple the DNS plane to clash (nft output
> chains also show OpenClash does not currently redirect router-own TCP).
> Kill-test: foreign queries answered during clash outage, watchdog
> auto-restarted. Backups: `config.yaml.bak-foreign-doh-20260813-103746` /
> `config.yaml.bak-foreign-doh-20260813-103813`.
> 2026-08-13 (W1N-62): added redundancy to `foreign_upstream`
> `concurrent: 3`, upstreams = `adg.chans.xyz` (hk2) + `dns.quad9.net` +
> `dns.cloudflare.com` (both direct-reachable from CN, live-tested 2026-08-13;
> `dns.quad101.net` excluded — TLS handshake fails). Verified: google.com
> AAAA now `2404:6800…` (new upstream answering, was `2607:f8b0…` via hk2),
> taobao/intercept/clash-fake-ip all unchanged. Backup:
> `config.yaml.bak-multi-doh-20260813-105421`.
> 2026-09-01: removed `dns.quad9.net` from `foreign_upstream` — recurring
> `WARN foreign_upstream … unexpected EOF` bursts (481 log entries) against
> Quad9 DoH; endpoint answers on probe but gets intermittently
> connection-reset from this network (same failure class as the excluded
> `dns.quad101.net`). Remaining upstreams `adg.chans.xyz` (hk2) +
> `dns.cloudflare.com` both verified live; google.com A + youtube.com AAAA
> resolve through mosdns :6052 after restart. Backup:
> `config.yaml.bak-quad9-remove-20260901-201801`.
- nft: OpenClash injects TPROXY/redirect + DNS-hijack rules into
`table inet fw4`; a residual `table inet passwall` exists with 0 packets (unused)
| Listener | Port |
|---|---:|
| HTTP | `7890` |
@@ -25,10 +79,28 @@ OpenClash runs `/etc/openclash/clash` with configuration
| Mixed | `7893` |
| TPROXY | `7895` |
| DNS | `7874` |
| Dashboard | `9090` |
`8443` is not an OpenClash listener and has no runtime nftables forwarding rule.
It is included only in OpenClash's common TLS-sniffing port list.
## VLAN 10 Wi-Fi
`gfw`'s third NIC `eth2` hosts the `ubunt_upg` interface at `192.168.10.1/24`,
serving the dedicated `ubnt-upg` SSID VLAN 10 (untagged access path from a
VLAN-capable switch/trunk; AP management stays untagged on LAN66). The
`ubunt_upg` zone runs the **only** DHCP server for `192.168.10.0/24` (UDP/67),
allows DNS (53), and applies `192.168.10.0/24 → eth0 masquerade` (NAT) for
Internet egress. `forward_ubunt_upg` isolates VLAN10 from LAN66/55 and RFC1918
(deny counters 0, `accept_to_lan` passes).
Live-verified 2026-08-11: an `ubnt-upg` client received `192.168.10.168` (lease
in `/tmp/dhcp.leases`), the `192.168.10.0/24 masquerade` counter climbed
(215 pkts/42KB), and the LAN55/LAN66 deny counters stayed 0 → VLAN10→LAN
isolation holds. See
[docs/lan-se5420-deployment-guide.md](../docs/lan-se5420-deployment-guide.md),
[docs/unifi-openwrt-vlan10-implementation-examples.md](../docs/unifi-openwrt-vlan10-implementation-examples.md)
## Operational note
When OpenClash logs a LAN client request such as
@@ -37,4 +109,3 @@ an explicit-proxy client sending its own loopback request to the gateway. Fix
the originating host's `NO_PROXY` / `no_proxy` configuration; do not create a
listener on this router to mask the problem. See
[the UniFi localhost proxy note](../docs/unifi-openclash-localhost.md).
+163
View File
@@ -0,0 +1,163 @@
# gw — EdgeRouter X primary LAN gateway
## Role and access
| Item | Value |
|---|---|
| Role | Primary router for the `192.168.66.0/24` and `192.168.55.0/24` LANs |
| IPv4 | `192.168.66.254` |
| Verified hostname | `gw` |
| SSH | `ssh -4 zhiqiang@192.168.66.254` |
| Platform | Ubiquiti EdgeRouter X 5-Port, EdgeOS `v3.0.1` (Build `5862409`) |
| Kernel | `4.14.54-UBNT` |
| Time zone | `Asia/Shanghai` |
Use `zhiqiang` as the default account for all routine gateway operations. Its
key-only SSH login and passwordless `sudo` were verified on 2026-08-04; use
`BatchMode=yes` for unattended read-only checks. `ubnt` remains an existing
administrative account, but is not the routine operations entry point.
Do not store account passwords, PPPoE credentials, SSH keys, configuration
password hashes, or UISP/UNMS connection strings in this repository. Both
administrative accounts have EdgeOS configuration and sudo privileges. Verify a
new SSH host key out of band before accepting it.
## Network topology
| Interface | Address / role |
|---|---|
| `eth0` | `192.168.66.254/24`; LAN 66 |
| `switch0` (`eth1``eth3`) | `192.168.55.254/24`; LAN 55 |
| `eth4` | WAN physical port; PPPoE uplink |
| `pppoe0` | WAN default route; MTU `1492`; IPv6 prefix delegation `/60` |
IPv6 prefix delegation assigns SLAAC-capable `/64` networks to both LANs.
`eth4` applies the WAN IPv4 and IPv6 firewall policies.
**SE5420 single-uplink topology (verified 2026-08-22):** the TP-Link `TL-SE5420`
core switch is deployed — management `192.168.66.253` (TP-Link OUI `f8:c9:03`,
web UI on :80/:443). The LAN55 uplink into `switch0` is a **single member
port**: `eth1` link up, `eth2`/`eth3` down. All LAN55 wired devices (hass
`.11`, Aqara M3 `.248`, SmartThings `.48`, UAP-AC-Lite `.5`) are reached via
`switch0` behind that one uplink, so same-segment wired↔wired unicast is
switched locally on the SE5420 and never reaches the ER-X. The switch FDB is
hardware-offloaded and not readable from the ER-X (`brctl showmacs switch0`
"Operation not supported"; `show mac-address-table` / `show ethernet-switch`
are not available on this EdgeOS build) — port link state (`show interfaces
ethernet`) plus ARP are the reliable topology checks.
Detailed effective configuration, including firewall binding and WAN exposure,
is recorded in [the EdgeRouter X configuration record](../docs/edgerouter-x-configuration.md).
## Services and policy
- DHCP serves both LANs with 24-hour leases. Client DNS is `192.168.66.36`.
Pools are `.38``.243` on both networks; selected infrastructure and client
addresses have static mappings. The UniFi controller is advertised as
`192.168.66.46`.
- DNS forwarding listens on `eth0` and `switch0` with a cache size of 512.
- WAN NAT masquerades all IPv4 traffic leaving `pppoe0`.
- `WAN_IN` and `WAN_LOCAL` default to drop, permitting established/related
sessions and rejecting invalid state. Equivalent IPv6 policies permit
necessary ICMPv6 and DHCPv6 traffic.
- `LAN_IN` defines a default drop plus explicit `55 → 66` and `66 → 55`
allows, but it is **not currently applied to an interface**. The active
inter-VLAN posture is therefore ordinary routed access: LAN55 and LAN66 can
communicate bidirectionally unless a different active policy intervenes.
`LAN_OUT` is likewise defined but inactive; WAN policies are active on
`pppoe0`.
- Management services: SSH on TCP 22; EdgeOS GUI on HTTP 80 and HTTPS 443.
## Safe inspection
Use the EdgeOS operational CLI for routine inspection. `show configuration
commands` can expose credentials and remote-management tokens, so either avoid
copying it or filter sensitive lines before recording output.
```bash
ssh -4 -o BatchMode=yes zhiqiang@192.168.66.254
show version
show interfaces
show ip route
show system uptime
show firewall
```
The [official EdgeOS User Guide](https://dl.ubnt.com/guides/edgemax/EdgeOS_UG.pdf)
explains that a firewall policy is a ruleset and must be applied to the
relevant interface/direction to take effect. Use the operational `show
firewall` output—not merely the configured rule definitions—to determine the
effective policy.
## PPPoE redial
To force the `pppoe0` session to reconnect (e.g. to obtain a fresh WAN IP), use
the operational `disconnect` / `connect` commands — **not** `renew dhcp
interface`, which applies only to DHCP interfaces:
```bash
ssh -4 zhiqiang@192.168.66.254
/opt/vyatta/bin/vyatta-op-cmd-wrapper disconnect interface pppoe0
/opt/vyatta/bin/vyatta-op-cmd-wrapper connect interface pppoe0
```
`disconnect` tears down the PPP session; `connect` re-dials immediately. A
short pause between them (a few seconds, or minutes for cautious ISPs) lets the
old session finish teardown before redialing. This briefly drops the whole WAN
uplink and may change the public IPv4 and delegated IPv6 `/60`; in-flight
sessions and port-forwarded services are interrupted until the new session is
up.
The `zhiqiang` account logs into `vbash`, not the EdgeOS CLI, so operational
commands must be invoked through `/opt/vyatta/bin/vyatta-op-cmd-wrapper` and
depend on its passwordless `sudo`. The `ubnt` account lands directly in the
operational CLI, where the same commands are entered without the wrapper.
`show`/`configure` are interactive-only aliases (from
`/etc/bash_completion.d/vyatta-{op,cfg}`, loaded via `~/.bashrc`), so a
non-interactive `ssh ubnt@… 'show …'` also fails — from a script use the op
wrapper above, or `_vyatta_op_run` after sourcing `vyatta-op` with
`vyatta_op_templates=/opt/vyatta/share/vyatta-op/templates`.
## Maintenance notes
- EdgeOS writes persistent changes through its configuration tree: enter
`configure`, make the intended change, then run `commit` and `save`.
- The current SSH service reported a non-post-quantum key-exchange warning.
Treat any cryptography or SSH-policy maintenance as a planned, lockout-safe
change following the repository SSH safety procedure.
- This device is distinct from `gfw.windy.lan` (`192.168.66.1`), the OpenWrt
OpenClash gateway. Do not apply OpenWrt procedures to this router.
## Verification record
Configuration and reachability were checked by read-only SSH on 2026-08-04.
No network policy changes were made during that inspection. The `zhiqiang`
account password was subsequently rotated using the EdgeOS configuration tree
and verified by a separate SSH login; the password is intentionally not
recorded here.
Inter-VLAN access was rechecked by read-only SSH on 2026-08-05. A probe sourced
from `192.168.55.254` reached the UniFi controller at `192.168.66.46` with
3/3 ICMP replies. This supports the AP Inform path to
`192.168.66.46:9080`; the controller listener and an online LAN55 AP provide
the corresponding application-level evidence. No firewall changes were made.
IPv6 was re-verified by read-only SSH on 2026-08-20 during the UniFi AP/AC
check: the IPv6 routing table shows connected `/64`s on `eth0` (LAN66) and
`switch0` (LAN55) plus `::/0` via `pppoe0`; both UniFi APs obtained SLAAC
addresses from the router's RAs. No configuration changes were made.
**DHCP 保留 `matter` 失效(2026-08-21 发现,2026-08-23 复核仍未生效,W1N-207):**
静态映射 `matter` → .45 / MAC `34:98:7a:27:10:bc`,但该灯泡一直以**动态租约**拿
`.148`hostname `matter`2026-08-23 09:02 时租约当日 04:40 已续租)。保留 .45 从未
被租出。2026-08-23 复核补充:另一盏工作灯泡的 MAC 已变为 `fc:e8:c0:25:a1:f0`
(动态 `.146`hostname `espressif`),原「把 MAC 改为 `34:98:7a:27:7f:08`」的修正
建议已过时(该灯泡已离网)。处置:删除该保留,或按现用 MAC(`.148`
`34:98:7a:27:10:bc` / `.146``fc:e8:c0:25:a1:f0`)重建,**未执行**。
**SE5420 部署 + switch0 单上联(2026-08-22 只读核实):** `switch0` 成员口
`eth1` link up、`eth2`/`eth3` down(单上联);SE5420 管理面 `192.168.66.253`
在线(TP-Link OUI `f8:c9:03`:80/:443);ARP 显示 LAN55 主机(hass `.11`
M3 `.248`、SmartThings `.48`、UAP-AC-Lite `.5`)全部经 switch0 可达。含义:
`switch0` 不再是 LAN55 的全量抓包点(同段有线单播在 SE5420 本地交换),详见
[runbooks/matter-packet-capture.md](../runbooks/matter-packet-capture.md)。
+751
View File
@@ -0,0 +1,751 @@
[hosts/hass.windy.lan.md#8DF6]
# hass.windy.lan — Home Assistant (HAOS)
## Role and access
| Item | Value |
|---|---|
| Role | Home Assistant automation hub |
| IPv4 | `192.168.55.11` (LAN55) |
| DNS | `hass.windy.lan` (AdGuard rewrite on `dns.windy.lan`; legacy `hass.local` alias) |
| SSH | `ssh hassio@hass.windy.lan` |
| **Host** | **x88 Pro physical box** (HAOS bare-metal, `machine: green`; verified 2026-08-18) |
| Platform | Home Assistant OS; kernel `6.1.115-haos` (aarch64) |
| Web UI | `http://hass.windy.lan:8123` (LAN); WAN port-forward `hass` on gw → `:8123` |
Use `hassio` for routine SSH inspection. Key-only login was verified on
2026-08-13 from the WSL client (`BatchMode=yes`).
The `ha` supervisor CLI (`/usr/bin/ha`) authenticates with `SUPERVISOR_TOKEN`.
Interactive login works because `~hassio/.zprofile` runs `exec sudo -i`, which
loads a root environment carrying the supervisor API token. Non-interactive
`ssh hassio 'command'` does not source `.zprofile` and fails with
`unauthorized: missing or invalid API token`. Run `ha` non-interactively via:
```bash
ssh -o BatchMode=yes hassio@hass.windy.lan 'sudo -n -i ha core info'
```
Verified 2026-08-13 that `sudo -n -i ha core info` works from the WSL client.
Never copy the supervisor token into this repository.
The current SSH ED25519 host-key fingerprint is
`SHA256:DMcMOgDzFsFTon1fndXowEP7jlyOK3/AX3PVK8BATvk` (verified 2026-08-13).
Verify a changed key out of band before accepting it.
Do not store Home Assistant long-lived tokens, integration credentials, or
recovery codes in this repository.
## Network
| Interface | Address / role |
|---|---|
| `end0` | IPv4 static `192.168.55.11/24` (gw `.254`, DNS `192.168.66.36`); IPv6 SLAAC `auto` with GUA on the current PD-derived /64 (`240e:3bd:235:1fb2:*` at 2026-08-22; rotates on PPPoE redial); primary LAN55 NIC (interface name verified live 2026-08-22 — `end1` does not exist) |
| `wlan0` | Supervisor **disabled** (verified 2026-08-14, W1N-104); IPv6 remains off on this RTL8821CS radio |
| `wg0` | `10.13.13.2/32`; WireGuard (add-on / integration tunnel) |
| `hassio` / `docker0` | internal HAOS Docker bridges (`172.30.32.0/23`, `172.30.232.0/23`) |
LAN55 clients reach the HTTP API on `dns.windy.lan:80` for the AdGuard Home
integration; see [hosts/dns.windy.lan.md](dns.windy.lan.md).
## API access
Home Assistant exposes a REST API at `http://hass.windy.lan:8123/api/` (same
as `http://192.168.55.11:8123/api/`). Authenticate with a **long-lived access
token** created under **Profile → Security → Long-lived access tokens**.
```bash
HA_URL="http://hass.windy.lan:8123"
HA_TOKEN="<long-lived-access-token>"
# Health check — expect {"message":"API running."} and HTTP:200
curl -sS -w "\nHTTP:%{http_code}\n" \
-H "Authorization: Bearer $HA_TOKEN" "$HA_URL/api/"
# Read one entity state
curl -sS -H "Authorization: Bearer $HA_TOKEN" \
"$HA_URL/api/states/sensor.csg_30d_max"
# List entities / recent errors
curl -sS -H "Authorization: Bearer $HA_TOKEN" "$HA_URL/api/states"
curl -sS -H "Authorization: Bearer $HA_TOKEN" "$HA_URL/api/error_log"
```
- `401` → token invalid or expired; create a new one.
- `404` on `/api/states/<id>` → entity does not exist.
- The token is a secret: never commit it here; keep it in the shell
environment or a secrets file outside the repo.
### HTTP proxy gotcha (verified 2026-08-13)
The WSL client had `http_proxy` set to Mihomo (`192.168.66.99:7890`). LAN
hostnames sent **through that proxy** returned empty `502`, even though DNS
resolved and the HA UI was up. Direct `192.168.55.11:8123` worked, and
`hass.windy.lan:8123` worked only after clearing the HTTP proxy.
Before debugging a "502" on a LAN URL, check `env | grep -i proxy` and bypass
the proxy:
```bash
unset http_proxy HTTP_PROXY all_proxy ALL_PROXY
curl -sS -w "\nHTTP:%{http_code}\n" \
-H "Authorization: Bearer $HA_TOKEN" "$HA_URL/api/"
```
For a persistent fix, add `.windy.lan` (leading dot) and the LAN ranges to
`NO_PROXY`, or add `*.windy.lan` to the proxy's own bypass/skip-proxy list.
See `~/.config/zsh/env/local/environment.env` for the client-side setting.
## Safe verification
```bash
ssh -o BatchMode=yes hassio@hass.windy.lan 'hostname; ip -4 addr show end0'
```
From a LAN client, confirm DNS and UI reachability:
```bash
getent hosts hass.windy.lan
# expect 192.168.55.11
```
## Local patches (custom components)
### Manual custom-component install (this host)
Home Assistant loads custom integrations from
`<config>/custom_components/<domain>/` (HAOS: `/config``/homeassistant`).
A folder named after the integration domain, containing at least
`manifest.json` and `__init__.py`, is enough; Core must be restarted after
copying files. Official HA lookup order:
`<config>/custom_components/<domain>` then built-in
`homeassistant/components/<domain>`.
See [Integration file structure](https://developers.home-assistant.io/docs/creating_integration_file_structure).
This host **does not git-clone** custom components. The live tree is a file
copy. Do not `git pull` on HA.
**Official plugin path** (from
[windyboy/china_southern_power_grid_stat README](https://github.com/windyboy/china_southern_power_grid_stat)):
HACS **or** [手动下载安装](https://github.com/windyboy/china_southern_power_grid_stat/releases).
This host uses the latter. Releases here have no uploaded zip assets; use
GitHub's **Source code (zip)** / zipball of the tag.
**UI (Samba / File editor / Studio Code Server):**
1. Download Source code (zip) from the GitHub Release.
2. Extract. Copy only the inner
`custom_components/china_southern_power_grid_stat/` tree — not the repo
root, not a nested extra folder.
3. Place it at `/config/custom_components/china_southern_power_grid_stat/`.
4. Restart Core (**Settings → System → Restart**).
5. First install only: **Settings → Devices & services → Add integration**.
**SSH from the workstation** (verified 2026-08-14, W1N-107). Replace `v1.3.1`
with the tag being installed:
```bash
TAG=v1.3.1
STAGE=/tmp/csg-${TAG}-deploy
mkdir -p "$STAGE"
gh api "repos/windyboy/china_southern_power_grid_stat/zipball/${TAG}" \
> "$STAGE/src.zip"
unzip -q "$STAGE/src.zip" -d "$STAGE"
SRC=$(find "$STAGE" -type d -path '*/custom_components/china_southern_power_grid_stat' | head -1)
# expect .../custom_components/china_southern_power_grid_stat
ssh -o BatchMode=yes hassio@hass.windy.lan \
'sudo -n -i mkdir -p /homeassistant/.csg-backups &&
sudo -n -i cp -a /homeassistant/custom_components/china_southern_power_grid_stat \
/homeassistant/.csg-backups/china_southern_power_grid_stat.bak-$(date +%Y%m%d)-manual'
rsync -a --delete \
-e 'ssh -o BatchMode=yes' \
"$SRC/" \
hassio@hass.windy.lan:/homeassistant/custom_components/china_southern_power_grid_stat/
# --delete cannot remove Core-owned __pycache__; wipe as root, then restart
ssh -o BatchMode=yes hassio@hass.windy.lan \
'sudo -n -i rm -rf /homeassistant/custom_components/china_southern_power_grid_stat/__pycache__ \
/homeassistant/custom_components/china_southern_power_grid_stat/*/__pycache__ &&
sudo -n -i ha core restart'
```
Wait until Core is up (`ha core info` returns, typically 12 min; this CLI
build does not print a `state:` field).
Then:
```bash
ssh -o BatchMode=yes hassio@hass.windy.lan \
'sudo -n -i cat /homeassistant/custom_components/china_southern_power_grid_stat/manifest.json'
# version must match the tag
ssh -o BatchMode=yes hassio@hass.windy.lan \
'sudo -n -i ha core logs -n 2500' | grep -E 'china_southern_power_grid_stat|cannot pickle' || true
```
**Host constraints (do not skip):**
- Backups **must** live in `/homeassistant/.csg-backups/`. A `*.bak-*`
directory next to the live folder is scanned as the same domain and Core
fails with `No module named '...bak-YYYYMMDD-...'`.
- Do not install this fork via HACS on this host. HACS still tracks
`CubicPill/china_southern_power_grid_stat` `v1.2.0`; a HACS update would
overwrite the live copy.
- First poll after restart can time out to CSG over IPv4; if this-month
sensors stay `unknown` while last-month filled, reload the config entry
(UI: integration → Reload, or supervisor
`POST /core/api/config/config_entries/entry/<id>/reload`).
- `runbooks/scripts/ha-maintenance.sh --restart-core --yes` can print
nothing and exit 1 in under a second **without restarting Core**. The
wrapper's ssh line discards stderr (`2>/dev/null`); with `pipefail`,
an ssh failure yields empty stdout + exit 1 before any remote command
runs. Do not treat that as a completed restart. Confirm with elapsed
time (~2 min for a real restart) and `ha core info`. Prefer
`ssh -o BatchMode=yes hassio@hass.windy.lan 'sudo -n -i ha core restart'`.
Full command family: [runbooks/home-assistant-maintenance.md](../runbooks/home-assistant-maintenance.md).
### `china_southern_power_grid_stat` live tree
**v1.3.2** (`934f58c`, verified 2026-08-15, W1N-118): manual zipball of
GitHub release
[v1.3.2](https://github.com/windyboy/china_southern_power_grid_stat/releases/tag/v1.3.2)
copied to `/config/custom_components/china_southern_power_grid_stat`.
Earlier trees: v1.3.1/`55a293fc` (W1N-107), v1.3.0/`69f13c90` (W1N-106),
`a433e8c` (W1N-105), `de01914` (W1N-103), `eb8b174` (W1N-102). Backups:
`/homeassistant/.csg-backups/` (w1n102/104/105/106/107/118).
v1.3.0 crashed the coordinator on first refresh
(`TypeError: cannot pickle 'mappingproxy' object` in
`copy.deepcopy(self._config)` under Python 3.14 / HA 2026.8.1). v1.3.1
wraps those `deepcopy` calls with `dict(...)`. Post-restart 22:13 CST:
entry `loaded`, no pickle traceback. Native this-month sensors filled after
reloading entry `01KGCQDSZCF523A9X6SV3BZ1B9` (`ip_family: ipv4`). Native
cost/ladder sensors can stay `unknown` because CSG
`get_month_daily_cost_detail` returns a marketing-system SQL error; the
dashboard uses template ladder/cost entities instead. Do not change
`templates/csg_sensors.yaml` or the 电力监控 dashboard for an install.
**`templates/csg_sensors.yaml` hardened 2026-08-29 (W1N-239):** added
`availability` templates to all 12 `csg_*` sensors (numeric sensors can't
render `unknown`/`unavailable` in `state`; availability suppresses
rendering instead — native CSG down ⇒ derived sensors show `unavailable`,
no more fake zeros / "一档" / `0%`). `csg_yesterday_kwh` now falls back to
`last_month_by_day`'s last entry when `this_month_by_day` is empty (month
start); ladder constants (`t1/t2/p1/p2/p3`) deduped into per-block
`variables:` (Block B + Block D); `csg_mom_change` parses `date`
defensively. Backup:
`/homeassistant/.csg-backups/csg_sensors.yaml.bak-20260829-w1n239`.
**Verified:** `ha core check` OK; Core restart required (trigger-based
template blocks don't settle on `template.reload` — W1N-114 precedent);
post-restart all 12 entities numeric & consistent (302.47 kWh→180.28 元,
324.03 kWh→194.06 元, mom_change -3.6%, yesterday 7.66 kWh/2026-08-28),
no template errors in Core logs.
**`csg_sensors.yaml` off-by-one fixed 2026-08-29 (W1N-241):** CSG data
lags 1 day (`sum(this_month_by_day)` == `this_month_total_usage`, data
stops at yesterday), but templates used `now().day` as "days elapsed" →
`csg_predicted_usage` underestimated ~1 daily avg (~3%) and
`csg_mom_change` compared this-month 28 days vs last-month 29 days
(-3.6% vs true -0.3%). Both now derive the day number from
`this_month_by_day[-1].date` (fallback `now().day` when empty). Added
`sensor.csg_this_month_daily_avg` (month-to-date avg, 302.47/28=10.8) and
`sensor.csg_prediction_progress` (usage/predicted %, 90.3) in Block C
(trigger adds `csg_predicted_usage`). Backup:
`/homeassistant/.csg-backups/csg_sensors.yaml.bak-20260829-w1n241`.
**Verified (8/29):** predicted 324.03→334.81, mom_change -3.6→-0.3,
daily_avg 10.8, progress 90.3, predicted_cost 194.06→200.94 (334.81 kWh
ladder), ladder cost 180.28 unchanged, `ha core check` OK after restart,
no template errors; 14 csg_* entities total.
**电力监控面板(`lovelace.dashboard_unknown` / view `power-monitor`
updated 2026-08-29 (W1N-240 + W1N-242):** 「本月累计」gauge 对齐夏季阶梯:
`max:650`、segments `0/260/600`(绿/橙/红 = 一/二/三档;冬季 11-01 需切
`max:450``0/200/400`**seasonal switch point**,见下文)。「📊 统计
数据」卡新增本年/去年 4 行(原生传感器,口径标注「电费(账单)」、本年
「(至今)」)+ 本月日均/预测进度 2 行(`csg_this_month_daily_avg` /
`csg_prediction_progress`W1N-242);面板共引用 **20** 个实体。改前备份:
`/homeassistant/.lovelace-backups/dashboard-unknown-power-monitor-20260829-204845.json`
W1N-240)、`-20260829-210708.json`W1N-242
(改法:WS `lovelace/config/save`,参数 `url_path: dashboard-unknown` +
`config`;勿直改 `.storage/`)。验证:WS 读回 18→20 实体 diff ✓、gauge
配置一致 ✓、URL `http://hass.windy.lan:8123/dashboard-unknown/power-monitor`
**`csg_sensors.yaml` W1N-242:** `csg_predicted_usage` /
`csg_mom_change` / `csg_this_month_daily_avg` 三处取 `days[-1]` 前补
`sort(attribute='date')`(与 `csg_yesterday_kwh` 一致,防上游乱序取错
数据日)。备份 `csg_sensors.yaml.bak-20260829-w1n242`。验证:Core
restart 后回归值不变(334.81 / -0.3 / 10.8 / 90.3 / 200.94 / 180.28)。
**CSG 面板重构 2026-09-04VPS-90,先核对计价后展示层改动):** 核对
`power-monitor` 计价与 8 月账单一致(198.65 vs 账单 198.64,差 ≤0.01 元,
因模板用公众圆整价 0.589/0.639/0.889、账单用 6 位精确价),不改阶梯常量。
改动:① `csg_sensors.yaml` Block B 新增
`sensor.csg_this_month_avg_price`(本月阶梯电费÷本月用电,`元/kWh`
availability 照 W1N-239 惯例;**csg_* 实体 14→15**);② 面板改名「环比上月」
→「环比上月同期」;glance「本月/上月」grid 去重为单卡「上月」(本月用电/电费
行归 💰核心数据卡);⚡阶梯电价卡加「本月实际均价」行(当前档位/当前电价/
本月实际均价/档位剩余;面板唯一实体引用 20→21);③ `automations.yaml`
2 条提醒:`automation.csg_mian_ban_qie_dong_ji_dang_ti_xing`10-25 09:00
`automation.csg_mian_ban_qie_xia_ji_dang_ti_xing`4-25 09:00)经
`matrix_e2ee.send_message` 提醒切 gauge。④ 金额单位混排(原生 CNY vs 模板
元)**维持**`config/entity_registry/update` 拒绝自定义文本单位
`extra keys not allowed … Got '元'`),用户确认接受。备份:
`.lovelace-backups/dashboard-unknown-power-monitor-20260904-204757-pre-refactor.json`
`.csg-backups/csg_sensors.yaml.bak-20260904-204757-pre-refactor`(及
`-205301-pre-avgprice`)、`.automations-backups/automations.yaml.bak-*`
**WS 改法(2026.8,本机实测)**core/主机 python 无 ws 库、core 容器内经
supervisor 代理 WS 被拒(loop prevention),用
`docker run --rm --network host -e SUPERVISOR_TOKEN`supervisor 镜像
`aarch64-hassio-supervisor:2026.08.0`)连 `ws://172.30.32.2/core/websocket`
命令名 `lovelace/config`(读)+ `lovelace/config/save`(写),
`lovelace/config/get` 已不存在(unknown_command)。验证:新实体
0.589 元/kWh、15 个 csg_* 数值齐全、回归值不变(14.09/198.65/331.22/
304.99/181.89)、automations on、`ha core check` OK、日志无 template 错误。
**CSG 长期归档(W1N-243, 2026-08-29:** scribe 库新增 `csg_history`
表(逐日 usage/cost/ladder/balance + 逐月累计;2026-07-01 起回填,永久),
由 TimescaleDB 每日任务 **1008** `csg_daily_snapshot()`22:30
Asia/Shanghai**TS job 非 pg_cron**upsert 维护。日费用在原生
`latest_day_cost` 缺失时回退 = 昨日用电 × 当前档费率(模板
`csg_current_ladder_tariff` 0.639);月费用回退模板
`csg_this_month_ladder_cost`。**语义**day 行 usage/cost 为该日值,
ladder/balance 为 22:30 快照值。详见 [hosts/pgdb.md](../hosts/pgdb.md)。
> **Seasonal gauge switch (W1N-240 已知事项):** 每年 **11-01**
> `power-monitor` 视图「本月累计」gauge 切到冬季 `max:450` /
> `0/200/400`**5-01** 切回夏季 `max:650` / `0/260/600`(与模板
> `now().month` 季节逻辑对齐;模板常量在 Block B/D `variables`)。
> **提醒 automation2026-09-04 起,VPS-90:**
> `automation.csg_mian_ban_qie_dong_ji_dang_ti_xing`10-25)与
> `automation.csg_mian_ban_qie_xia_ji_dang_ti_xing`4-2509:00 经
> `matrix_e2ee.send_message` 发操作步骤提醒;gauge 的 max/segments 无法
> 模板化,仍需人工改卡配置。
Home PPPoE IPv4 to CSG is still blackholed (`curl -4` to `218.19.148.218:443`
times out). `end0` IPv6 is enabled (`ipv6.method: auto`); from HA,
`curl -6 https://95598.csg.cn` returns HTTP 200 via `240e:f9:8060::1:16`.
**`tianqi` weather recorder patch (verified 2026-08-13, W1N-75):**
`/config/custom_components/tianqi/weather.py` has a local patch adding
`_unrecorded_attributes = frozenset({"hourly_temperature", "hourly_skycon",
"hourly_cloudrate", "hourly_precipitation"})` to the `WeatherEntity` class.
Without it, weather.guangzhou's state attributes (~19 KB, dominated by the 4
hourly_* arrays of up to 48 entries) exceed the recorder 16384-byte limit, so
the recorder drops **all** attributes for the entity and logs
`Recorder.db_schema: State attributes for weather.guangzhou exceed maximum
size of 16384 bytes`. The patch excludes only the 4 arrays from recording
(live state unchanged; other attributes still stored; ~6.3 KB payload). Backup
at `weather.py.bak-w1n75`. **Re-apply after any `tianqi` component update.**
The `_unrecorded_attributes` mechanism exists in Core 2026.8.1
(`Entity.__init_subclass__``state_info["unrecorded_attributes"]`, consumed
by recorder `shared_attrs_bytes_from_event`).
### `matrix_e2ee` live tree (E2E Matrix bot, verified 2026-08-20)
**v0.3.12** (tag `v0.3.12`; feat — Matrix activity events
`matrix_e2ee_message_received` / `matrix_e2ee_verification_done` + push
diagnostics; v0.3.9 added Connection health binary sensor, SAS/command
allowlist split, URL normalization, single-entry enforcement):
source copy from `/home/windy/project/ha-matrix-e2ee` `ea421ed` (tag
`v0.3.12`) deployed 2026-08-20 via SSH rsync from workstation (upgraded
from v0.3.2, backup `matrix_e2ee.bak-20260820-v0.3.2`).
Custom **`matrix_e2ee`** integration — **Config Flow** (UI). See
[docs/home-assistant-matrix.md](../docs/home-assistant-matrix.md).
**Update runbook:** [runbooks/matrix-e2ee-update.md](../runbooks/matrix-e2ee-update.md).
Earlier: v0.3.2 (tag `v0.3.2`, W1N-182/#34: wizard waits for inbound SAS
emojis) deployed 2026-08-18 from `d35c484` (backup
`matrix_e2ee.bak-20260818-v0.3.1`); v0.3.1 (GitHub #33: peer-initiated
verification wizard fix) deployed 2026-08-18 from `d22e935` (backup
`matrix_e2ee.bak-20260818-v0.3.0`); v0.3.0 (W1N-180/#32: bot-initiated
verification wizard; W1N-179/#31 `receive_mac_event` cancel-state fix)
deployed 2026-08-18 from `216cc99` (backup
`matrix_e2ee.bak-20260818-v0.2.10`).
- Bot `@hass:chans.xyz` reused (E2EE device `rO1R915ncu`). Config Entry
`01M04D7C1M4T2GX5VPG7NVQ7GV` (`source: import`, `state: loaded`). All
settings via **Settings → Devices & Services → Matrix E2EE → Configure**.
- Config Entry options: `allowed_rooms` `["!gidvAzpDzwtzfEDrqu:chans.xyz", "!boxfylDSzOvrWkcsyY:chans.xyz"]`,
`allowed_users` `["@zhiqiang:chans.xyz"]`, `command_prefix` `"!"`.
**`verification_peer_users` not set** (v0.3.9+ SAS allowlist split from
`allowed_users`, W1N-156): defaults to empty → only the bot's own account
may drive SAS; `@zhiqiang` is denied until the option is added via
Settings → Devices & Services → Matrix E2EE → Configure.
- Storage: `/config/.storage/matrix_e2ee_session.json` +
`/config/.storage/matrix_e2ee_store/`. Backups:
`/homeassistant/.matrix-e2ee-backups/` (incl. `matrix_e2ee.bak-20260820-v0.3.2`,
`matrix_e2ee.bak-20260818-v0.3.1`,
`matrix_e2ee.bak-20260818-v0.3.0`,
`matrix_e2ee.bak-20260818-v0.2.10`,
`matrix_e2ee.bak-20260816-v0.2.9`, `matrix_e2ee.bak-20260816-v0.2.8`);
full HA backup slugs `3d9d36db` (pre-v0.1.4) + `9f223f35` (pre-v0.2.0).
- v0.3.12: Matrix activity events + push diagnostics
(`matrix_e2ee_message_received` / `matrix_e2ee_verification_done`).
v0.3.9: Connection health binary sensor (W1N-185/#40), config-entry
diagnostics (W1N-184/#39), SAS/command allowlist split
`verification_peer_users` (W1N-156/#41), SAS/sync logs demoted
warning→info/debug (W1N-188/#38), URL normalization + single-entry
enforcement (W1N-190/#42).
v0.3.8: `m.key.verification.done` handshake for request-based SAS
(W1N-183/#35).
v0.3.2: wizard waits for inbound SAS emojis before the compare step
(W1N-182/#34).
v0.3.1: verification wizard waits for a peer-initiated inbound SAS instead
of the bot starting SAS (GitHub #33).
v0.3.0: bot-initiated device verification wizard (W1N-180/#32).
v0.2.11: `receive_mac_event` no longer overrides canceled state (W1N-179/#31).
- v0.2.9: restore SAS emoji rendering after vodozemac migration (W1N-175/#29).
v0.2.8: SAS commitment unpadded base64 for Element interop (W1N-174/#28).
v0.2.7: SAS cancel code/reason logging. v0.2.6: verification state logging +
request→ready bridge. v0.2.4: `_patch_nio_sas_timeout()` +
`_repair_dropped_start()`; `VERIFICATION_TIMEOUT_SECONDS` 600→240.
- Automation `1761188403590`「Matrix 聊天关卫生间灯」: trigger
`matrix_e2ee_command` (command `关卫生间灯`), actions `light.turn_off` +
`matrix_e2ee.send_message` (room `!gidvAzpDzwtzfEDrqu`).
- **SAS not yet completed:** every device requires explicit `confirm_verification`.
Encrypted-room commands stay fail-closed until `@zhiqiang`'s device is verified.
Since v0.3.9 the SAS driver gate uses `verification_peer_users` (empty on
this host) instead of `allowed_users` — add `@zhiqiang:chans.xyz` there
before retrying the wizard. Three paths available: SAS manual confirm,
fingerprint, or the device verification wizard (v0.3.0 bot-initiated,
reworked in v0.3.1/v0.3.2 to wait for a peer-initiated inbound SAS from
Element with emoji comparison), see
[docs/home-assistant-matrix.md § Device verification](../docs/home-assistant-matrix.md).
### Scribe long-term history (3.8.0 setup 2026-08-29; 4.4.0 verified 2026-09-13)
- **Scribe 4.4.0** (`/homeassistant/custom_components/scribe/`, HACS repo
`jonathan-gtd/scribe`, = latest stable 2026-09-12; upgraded 2026-09-13 together
with Core 2026.9.1 / HAOS 18.2), configured from
`/homeassistant/scribe.yaml` — W1N-238 moved the block out of
`configuration.yaml` on 2026-08-29 (main config now carries
`scribe: !include scribe.yaml`; content moved verbatim; backup
`configuration.yaml.bak-20260829-201724-w1n238`). Config entry
`01KC2VFJWEQ3XDHY6TQKHPDVRB`, `source: import` — UI "Configure → Advanced"
edits are overridden by the YAML on restart; treat YAML as authoritative.
- TimescaleDB at `192.168.55.15:5432/scribe` (DB user `hass`; host in inventory,
see [hosts/pgdb.md](../hosts/pgdb.md)). Database re-initialized 2026-08-29 14:06 CST
(user-handled; earlier `relation "entities" does not exist` errors resolved).
Health: `binary_sensor.scribe_database_connection`.
- 2026-08-29 config applied (backup `/homeassistant/configuration.yaml.bak-20260829-scribe`):
- `record_events: true` with `include_events` whitelist: `automation_triggered`,
`matrix_e2ee_command`, `matrix_e2ee_message_received`,
`matrix_e2ee_verification_done`, `script_started`, `tag_scanned`,
`mobile_app_notification_action`, `homeassistant_start`, `homeassistant_stop`.
- State noise trimmed: `exclude_domains` update/button; glob
`sensor.zigbee2mqtt_bridge_*`; 4 hassio cpu/mem-percent entities.
- Global `exclude_attributes` drops tianqi `hourly_*` arrays (~19 KB/state —
the recorder-side `_unrecorded_attributes` patch does not apply to Scribe).
- `enable_stats_io` + `enable_stats_size` on → 14 `sensor.scribe_*` stats
entities (`scribe_states_written`, `scribe_events_written`, rates, sizes).
- Verified post-restart 14:23 CST: writer started, `scribe_events_written=1`
(homeassistant_start), states ~110/min, buffer 3, no scribe log errors.
- **4.x upgrade核对 2026-09-13(只读 + 一处配置变更)**live `manifest.json` =
4.4.0。两个 4.0 breaking change 在本机都不需要动作——数据库是 3.x 结构
`states_raw` PK `(metadata_id, time)` 在,4.2.0 的启动态去重因此可用),
TimescaleDB 2.29.2 已装。4.1.0 修了 `db_url` 优先级,YAML 里的
`!secret scribe_url` 现在是权威。`scribe.yaml` 现有键在 4.4.0 全部仍然合法
(未知键被忽略,`extra=vol.ALLOW_EXTRA`)。**配置优先级 YAML > entry
`options` > entry `data` > 默认值**,而 `_resolve_settings` 读的是
`hass.data[DOMAIN]["yaml_config"]`(只有 `async_setup` 会写),所以
**YAML 改动必须重启 Corereload config entry 不重读 YAML。**
- **`stats_io_interval: 300`2026-09-13 添加**,备份
`/homeassistant/scribe.yaml.bak-20260913-191558`)。4.4.0 不再让 HA 每 30s
轮询 I/O 统计传感器,改由集成自己每 60s 发布,间隔成为配置项。scribe 自己的
传感器此前是本机自写历史的主要来源(变更前 24h:11 019 / 87 461 行状态 =
12.6%),60s → 300s 把这部分降约 5 倍(每个 I/O 传感器约 1440 → 288 行/天)。
验证:`ha core check` OK;重启 88s`ScribeWriter started successfully`
无 scribe error/warningscribe Repairs 问题 0 条;传感器发布间隔实测正好
300s11:19:26 → 11:24:26 UTC)。
- **Retention 现在可用但刻意不设**`retention_states` / `retention_events`
4.0.0)按间隔丢 chunk,留空 = 永久保留,符合本机定位(Scribe 是永久归档,
recorder 保留 365 天)。注意 retention 是**绕过** entry `data` 副本读取的
`from_entry_data=False`),所以删掉 YAML 行即撤销策略。`db_schema`
`enable_rollups``scribe.purge` 同样未用:图表走 `sensor_minute` +
`timescale_database_reader`(见 [hosts/pgdb.md](pgdb.md)),不吃 scribe 自己的
视图,配置里也没有任何 `scribe.query` 调用。`flush_interval` 仍是 entry
`data` 钉住的 5s——上游下一个版本把默认改成 30s,但 entry 值优先,要采用只能
在 YAML 显式写 `flush_interval: 30`
- Recorder stays external-Postgres with `purge_keep_days: 365` (W1N-243,
2026-08-29, raised from 30 — ~300 MB/yr, 1% of the 30G pgdb disk) for
native UI per-change history; Scribe is the permanent archive. Long-term
statistics stay permanent (not purged by `purge_keep_days`). Note:
extending retention does **not** recover pre-2026-08-29 raw history
(already purged); only `csg_history` day/month values cover that period.
### Config layout: scribe.yaml + templates/ merge (W1N-238, verified 2026-08-29)
- `configuration.yaml` line 29: `scribe: !include scribe.yaml`; line 9:
`template: !include_dir_merge_list templates`. No `packages/`.
- `scribe.yaml` (config root): the Scribe block, content identical to the
former inline one; import semantics unchanged.
- `templates/`: `csg_sensors.yaml` (12 template sensors, top-level **list**)
+ `quick_sensors.yaml` (scaffold for Quick-derived `quick_*` sensors, empty
list with convention header). **`!include_dir_merge_list` merges per-file
lists; non-list files are silently skipped** — every file in `templates/`
must be a top-level list (`- sensor:` blocks). Directory include only picks
up `*.yaml`, so the `.bak` / `.pre-*` backups in the dir are ignored. After
adding sensors, verify template-platform entity count = 12 + N (entity
registry `platform: template`).
- Convention (per review + W1N-233): pure sums/averages stay min_max helpers
(e.g. `sensor.dang_qian_zong_gong_lu`); only template-logic derivations
(ladder pricing, cross-entity conditions) go into `quick_sensors.yaml`.
- Post-change verification 20:19 CST: `ha core check` ok, 92 s restart
(2026.8.3), `binary_sensor.scribe_database_connection` on,
`scribe_states_written` 18581→19426 growing, template entities still 12,
csg sensors numeric, no scribe/template log errors.
### Timescale Plotly card + database reader (verified 2026-08-29)
Chart stack over the Scribe TimescaleDB archive. Upstream pair (no HACS;
manual copies): reader `remmob/timescale_database_reader` **v1.1.0** (main
`bb8776a`) + card `remmob/timescale-plotly-card` **2.2.0** (main `217961d`).
- **Reader integration**: `/homeassistant/custom_components/timescale_database_reader/`.
Config entry `01M165P77QT1FQEAVPNZHDT82W` ("Scribe", `source: user`): connects
`hass@192.168.55.15:5432/scribe` (credentials = `secrets.yaml` `scribe_url`),
`table: sensor_minute`. Exposes no entities/services — it serves WS command
`timescale/query` (window ≤ 365 d, ≤ 50 000 rows, `downsample` bucket seconds).
Benign startup warning `Error executing test query: column "time" does not
exist`: the self-test SQL assumes the LTSS column name; the scribe table uses
`minute` — real queries work (verified: 70 rows for a live power sensor).
- **Card**: `/homeassistant/www/community/timescale-plotly-card/timescale-plotly-card.js`
(root-owned, same convention as HACS dirs). Lovelace resource (storage)
id `2e360d17b5aa4ce59c2fd13c43b51215`
`/hacsfiles/timescale-plotly-card/timescale-plotly-card.js`, type `module`.
Card config matches the entry by `database: scribe` (name from the reader
entry). Updates: replace the file, resource URL unchanged — browsers need a
hard refresh or a bumped `?v=` query on the resource URL.
- **pgdb side** (`sensor_minute_aggregate` cagg + `sensor_minute` hypertable +
every-minute refresh job): see [hosts/pgdb.md](pgdb.md) § Databases.
- **Agent-side HA WebSocket without a long-lived token** (verified 2026-08-29):
connect `ws://supervisor/core/websocket` with header
`Authorization: Bearer $SUPERVISOR_TOKEN`, then send
`{"type":"auth","access_token":"$SUPERVISOR_TOKEN"}` — the Supervisor proxy
swaps it for a core token (works as the internal Supervisor admin user). Note
`lovelace/resources/create` in HA 2026.8 takes `res_type` (NOT
`resource_type`).
- Scribe stores numeric sensor values in `states_raw.value` with `state` NULL,
so `sensor_minute.state` shows `'0'` for numeric sensors; the card plots
`avg_state` (from `value`) — expected, not a bug.
- **Quick 仪表盘(`dashboard-quick`)图表套件**2026-08-29 创建,经 WS
`lovelace/config/save` 写入;W1N-230 修复 + W1N-231 round-2 改进):
5 张 timescale 卡——大功率电器/常驻负载功率(按量级拆图,避免尖峰压扁
<70 W 基线)、按插座用电量(`energy_mode` + cumulative/diff,数据质量前提
见 pgdb 的 refresh 过程补丁)、室内外温湿度(温度左轴/湿度右轴,4 位置同色
配对)、人体感应活动状态(3 个 `motion_state`banded `state_map`
none/small/medium/large → 0-11per-entity `line_color` 红/蓝/绿)。
空调实体引用为 `kong_diao_*``kong_tiao` 是笔误,W1N-230 修复;`grep -c
kong_tiao` 应为 0)。灯区:2×2 嵌套 grid(`grid_options: {columns: "full"}`
内层 `columns: 2`+ 4 卡统一 `mushroom-light-card`(显式 name、
`use_light_color: false`、内联亮度/色温控制),heading icon
`mdi:lightbulb-group`。heading badges:环境 4 温度(迷你/mini数显/数显/广州)、
大功率电器 空调/电脑当前功率、常驻负载 总功率
`sensor.dang_qian_zong_gong_lu`min_max **sum** helper`round_digits: 0`
任一源掉线 fail-closed → unknown)。常驻负载图卡级 `fill: 'tozeroy'` +
冰箱/主网络 per-entity `fill_color`(线色 20% 透明)+ 其余 5 条 `fill: false`
per-entity fill 逐系列退出,卡 JS `seriesConfig.fill !== false`)。
布局:视图 `type: sections` + `max_columns: 4`;灯/用电/环境/人体感应
`column_span: 4`,功率两图拆两个 `column_span: 2` 分区**并排**(等高 280px
桌面并排、手机回落堆叠;去卡内 title 省半宽图垂直空间)。
**分区/卡片是两套尺寸键,不可混用**:分区宽 = `column_span`
`hui-sections-view.ts` 缺省按 1 列渲染,绝不省略);卡片宽 =
`grid_options: {columns: <n|"full">}``hui-card.ts` 只读 `config.grid_options`
写在卡片上的 `column_span` 被静默忽略;缺省 12 列,分区内格 = 12 × 分区
span,故 span-4 分区里缺省卡片只有 1/4 宽)。
修改前备份:`/homeassistant/.lovelace-backups/dashboard-quick-*.json`
W1N-230 修复: `20260829-190256`round-2 改进: `20260829-194040`)。
- **Quick 时间范围扩容 (2026-09-13, VPS-92)**: 用户反馈「48 小时不够」。
各 timescale 卡可选档上调——大功率电器/常驻负载 `…,24h``+3d,7d`
环境 `6h,12h,24h,48h``+7d,14d,30d`;人体感应 `…,24h``+3d,7d`
用电量(按插座) `energy_time_ranges` `today,week,month,custom``+3mo`
**默认档未改**6h / 6h / today / 24h / 12h)。卡片 JS 只接受
`<n>m|<n>h|<n>d``parseDurationToMs` 正则 `/^(\d+)(m|h|d)$/`
仅 m/h/d,无 w)与命名档 `today|week|month|3mo|6mo|year|years|custom`
`energy_mode` 卡必须用后者。**数据下界注意**:scribe `sensor_minute`
目前最早只到 **2026-08-29**,所以 >15d 的档(14d 边缘、30d 明显)前半段
会是空白,等归档继续累积才好看。备份
`.lovelace-backups/dashboard-quick-20260913-190912-pre-timerange.json`
### 地图仪表盘:CARTO keyed tiles via `custom:map-card` (verified 2026-08-30, W1N-261)
- **背景:** CARTO 自 2026-08-26 起对无 key 栅格瓦片打 "API KEY REQUIRED"
水印,内置地图卡/zone 编辑器全部受影响。Core 2026.8.3 的 `MapCardConfig`
**没有任何瓦片配置项**frontend 20260729.7 源码核对:
`setup-leaflet-map.ts` 硬编码 CARTO voyager URL)。上游修复是 2026.9.0b1
起改用 OSMF 矢量瓦片(frontend PR #53816),stable 预计 2026-09-02 前后。
- **变更:** 「地图」仪表盘(url_path `map`storage)唯一 map 卡替换为
`custom:map-card`[nathan-gs/ha-map-card](https://github.com/nathan-gs/ha-map-card)
**v1.16.0**,手动安装非 HACS):`tile_layer_url` =
`https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png?key=<CARTO_KEY>`
(配 `tile_layer_options: {subdomains: abcd, maxZoom: 20}` + OSM/CARTO
attribution)。实体不变:2 person + 4 zonezone 用 `display: icon` +
`circle: auto`circle 读实体 `radius` 属性画半径圈)。
- **CARTO key 是 secret**: 只存在于服务端 lovelace 存储(dashboard `map`
的卡片配置)和用户本人处;勿写入本仓库或 Linear。
- **文件/资源:** `/homeassistant/www/community/ha-map-card/map-card.js`
root:root 644678554 Bsha256
`f30dfb606e858d2216d5198d8cf758ce956d127006ebd7d66d4329153a247ec2`);
Lovelace resourcestorageid `9d2b50b52c60420d89ebd041f722cf60`
`/hacsfiles/ha-map-card/map-card.js`type moduleWS
`lovelace/resources/create`2026.8 参数名 `res_type`)。升级 = 手动替换
该文件(不在 HACS 管理下,浏览器需强刷)。
- **备份:** `/homeassistant/.lovelace-backups/dashboard-map-map-20260830-133714.json`
(还原 = 把备份里的 `views[0].cards[0]` 写回后再 WS `lovelace/config/save`
url_path `map`)。
- **验证 8/30:** 同瓦片无 key=水印 / 带 key=干净(256×256 PNG 视觉对比);
resource HTTP 200 text/javascriptWS 读回卡片配置(type/entities/key/
attribution/options)全部符合;HA 主机 `curl -4` 带 key 瓦片 200。
- **Follow-up:** Core 升 2026.9.0 stable 后内置地图/zone 编辑器自动切
OSMF 矢量瓦片;届时可保留 custom 卡(继续 keyed CARTO)或用备份还原
内置卡。zone 编辑器等其余内置地图的水印在 2026.9 前无解。
## Known issues
**Bluetooth hci0 instability — RTL8821CS (verified 2026-08-13, W1N-74):**
The local Bluetooth controller hci0 is an **RTL8821CS** combo chip on the
x88 Pro board. Kernel logs show recurring `hci0: hardware error 0x00`,
`Opcode 0x200c tx timeout` (HCI_LE_Set_Scan_Parameters), `Unable to disable
scanning: -110`, `Peer device has reset` — the chip hardware-stalls during
active scanning. HA's `bluetooth_auto_recovery` power-cycle then times out
after 5 s and retries every ~2 min:
`bluetooth_auto_recovery.recover: Could not reset the power state of the
Bluetooth adapter hci0 ... due to timeout after 5 seconds`. The HAOS image
already ships custom systemd units to cope (`x88-bt-hci-recovery.service` and
a "Patch HA Bluetooth scanner mode for x88 RTL8821CS" service, visible in host
journal). **No user impact:** there are **no BLE entities** in HA
(xiaomi_ble / bthome / led_ble / bluetooth / esphome domains are all empty;
platforms merely load from stray advertisements). Real IoT devices are Zigbee
(via Zigbee2MQTT) or WiFi/MQTT/cloud. An ESPHome Bluetooth-proxy ESP32
(`/config/esphome/bluetooth.yaml`, bluetooth_proxy: active, WiFi `ubnt-haas`)
is configured but currently offline (ESPHome add-on stopped, port 6053
unreachable) and produced no entities. Follow-up (optional): disable the
local adapter and rely on the ESPHome proxy, or stop the bluetooth
integration entirely.
**eMMC disk lifetime 10% (verified 2026-08-13, W1N-76):** `ha host info`
reports `disk_life_time: 10` — the boot eMMC (`/dev/mmcblk2`, CJTD4R
`0xacacc064`, 64 GB) has ~10% life left. `disk_free: 40.2/56.4 GB`. Full
backup `pre-maintenance-20260813` (slug `411a4ba5`, 144.26 MB) taken
2026-08-13 covers current config; monitor `disk_life_time` on each health
snapshot and plan a disk replacement / data-disk migration before the eMMC
fails.
## Matter Server (verified 2026-08-21)
- Add-on `core_matter_server` (`homeassistant/aarch64-addon-matter-server`) runs the Matter
commissioner on this host (host networking; add-on container `app_core_matter_server`).
- **After the ISP PD prefix rotates (PPPoE redial), the add-on can cache a stale IPv6 GUA
in its mDNS advertisement** — clients trying that dead address make Matter
commissioning/connection fail. Fix: restart the add-on so it re-enumerates addresses:
`ssh hassio@hass.windy.lan 'sudo -n -i ha apps restart core_matter_server'`
(`ha addons restart ...` also works; "addons" is deprecated in favor of "apps").
- Verified 2026-08-21 (W1N-207): stale `240e:3bd:234:2f22:*` AAAA in mDNS removed by
restart; advertisement now carries only current GUA `240e:3bd:235:1fb2:*` + link-local;
CASE sessions with Aqara M3 / SmartThings hubs resumed over IPv6 link-local.
> **Open items (2026-08-21, W1N-207):** a phone on LAN55 was querying five known
> `_matter._tcp` instances of which only HA answered — the other Matter nodes are
> offline / not announcing (device-side; user to confirm power/Wi-Fi). HA's IPv6
> default route via NetworkManager was observed missing once (curl -6 intermittent,
> while ping6 and `curl -6 --noproxy` work) — not the Matter root cause; re-check
> on the next health snapshot.
Verified 2026-08-23 (read-only, W1N-207): add-on `started`, version `9.0.4`, no
update pending; current GUA `240e:3bd:238:4812:*` (PD rotated again since 08-22)
advertised correctly over v4+v6. Both ESP32-C2 bulbs now announce `_matter._tcp`
(multi-fabric, including this host's fabric `DCE86145C137AF0E`) — but they
**refuse TCP 5540 on IPv4 and IPv6**, so matter-server holds **zero established
:5540 sessions** (device-side failure mode C; no errors logged — see
[docs/matter-pairing-troubleshoot.md §8](../docs/matter-pairing-troubleshoot.md)).
## 马桶换气电源(Matter 插座,半计量)+ 电量估算 (2026-09-13)
**设备**Matter `Smart Plug`SIXWGH`model_id 3596`hw 1.0 / sw 1.3.0),node 18
(0x12)`device_id 5ef1850953466d6e7a9c6b901fbebe1c`config entry
`01JF51VQ48PGJGXX3RNAG6MVAA`,区域**卫生间** (`wei_sheng_jian`)label `power`
2026-09-13 17:58 CST 配对。实体:
`switch.wei_sheng_jian_ma_tong_huan_qi_dian_yuan`(插座)、
`sensor.…_dian_yuan`(电源 W)、`sensor.…_dian_ya`(电压 V)、
`sensor.…_you_gong_dian_liu`(有功电流 A)、`sensor.…_dian_li`(电力 kWh
**永久 unknown**)。
**根因(实测 Matter 属性,node 18**:电量簇 0x0091 `FeatureMap = 13`
(IMPE|CUME|PERE,即**声明**支持导入/累计/周期电量),但
`CumulativeEnergyImported (0x0001)` 恒为 `null``PeriodicEnergyImported
(0x0003)` 带载也恒为 `{Energy: 0}``CumulativeEnergyExported (0x0002)`
不存在(EXPE 未声明,自洽)。HA 只用 `CumulativeEnergyImported` 建能量实体
`components/matter/sensor.py:1083``allow_none_value=True`)→ 该实体
**永远不会出数**。**功率计量本身正常**:0x0090 `FeatureMap = 2` (ALTC)
Voltage / ActiveCurrent / ActivePower 都随负载变化(实测 220.3 V / 118 mA /
24.7 WHA `电源` 0.0→24.9 W 有历史)。厂商 `update` 实体报无新固件。
**处理(方案 A:功率积分补电量)**:
- 新建 **Integration (Riemann sum) 辅助元素**config entry
`01M2D53T188FW8WEC547ENHSVH`domain `integration`state `loaded`),
source `sensor.wei_sheng_jian_ma_tong_huan_qi_dian_yuan_dian_yuan`
`method: trapezoidal``unit_prefix: k``unit_time: h``round: 3`
`max_sub_interval: 60s`
- 实体 `sensor.wei_sheng_jian_ma_tong_huan_qi_dian_yuan_energy`(创建时 HA
自动生成 `…_dian_yuan_ma_tong_huan_qi_dian_yuan_dian_liang`,随后立即
`config/entity_registry/update` 改名为 `<插座>_energy` 以对齐约定;
该实体新建、无引用,改名安全),friendly name「马桶换气电源 电力」,
unit kWh、`device_class: energy`、**`state_class: total`**——能源仪表盘
允许 `TOTAL``TOTAL_INCREASING``components/energy/validate.py:279`)。
- **能源仪表盘** (`/energy`)grid 源 `[8]``…_dian_li` 改为 `…_energy`
其余 8 条插座源未动。注意这 9 条「插座」全部以 `type: grid` 注册,被当作
全屋用电代理;`switch` 卡片所在的 Grid 卡片此前第 9 行是空的,即本次修复点。
- **Quick 仪表盘**:「用电量(按插座)」图第 11 项由 `…_dian_li` 改为
`…_energy`;新增 `column_span: 2` 的「开关」区块(heading + tile
`switch.…` + `toggle` feature + 功率徽标)→ 视图 6→7 分区。
**口径警告**`…_energy` 是**估算值**Riemann 积分,只在 HA 运行期间累计、
非账单级),与另外 8 个原生计量插座的累计电量口径不同;功率传感器更新
间隔约 510 s(实测 24.9/24.8/25.0 W 抖动),加 `max_sub_interval: 60s`
保证静默时也继续累计。
**Agent 侧建辅助元素的方法(2026-09-13 实测)**HA 的 config flow 走
**REST**WS 只有 `config_entries/flow/progress|subscribe`,没有 start)。
经 supervisor 代理即可,无需 HA 长连接/长寿命 token:
```bash
# SUPERVISOR_TOKEN 由 sudo -n -i 提供
curl -s -X POST -H "Authorization: Bearer $SUPERVISOR_TOKEN" \
-H "Content-Type: application/json" -d '{"handler":"integration"}' \
http://supervisor/core/api/config/config_entries/flow # → {flow_id, step_id:"user", data_schema}
curl -s -X POST -H "Authorization: Bearer $SUPERVISOR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"…","source":"sensor.x","method":"trapezoidal","round":3,
"unit_prefix":"k","unit_time":"h","max_sub_interval":{"minutes":1}}' \
http://supervisor/core/api/config/config_entries/flow/<flow_id> # → create_entry
```
`auth/long_lived_access_token` 在 supervisor 代理身份下**失败**
`unknown_error`),故无法用长寿命 token 开浏览器会话;`DurationSelector`
的值是 `{"minutes":1}` 形式(`cv.time_period`)。
**备份/回滚**`.lovelace-backups/dashboard-quick-20260913-181251-pre-ma-tong-plug.json`
(改动前原件)、`…-20260913-183210-pre-repoint.json`(改名/换源前);
`.ha-backups/energy-20260913-183135-pre-ma-tong-repoint.json`(能源 prefs)。
回滚 = 把能源 prefs 的源 [8] 指回 `…_dian_li` + 还原 Quick 面板 JSON
如需彻底放弃估算电量 = 删除 config entry `01M2D53T188FW8WEC547ENHSVH`
**验证 (2026-09-13 18:3x)**`…_energy` 0.002→0.003 kWh 且随 24.6 W 负载
增长(换气扇关掉后回落 0.0 W,累计值保留);`recorder/list_statistic_ids`
已含该实体;Quick 面板 WS 读回 7 分区、用电量图 11 项指向新实体、旧
`_dian_li` 引用 0 处;能源 prefs 读回 9 源、第 9 条为新实体。
**`energy/validate` 已全绿**9 源 0 issue):创建后 ~5 min 内曾报
`statistics_not_defined`(recorder 的统计任务周期是 5 min,`statistics_meta`
行由该任务建立),18:39 复核时已自动消失——建辅助元素后**不要**把这条
瞬时告警当作失败。
## Related docs
- [runbooks/home-assistant-maintenance.md](../runbooks/home-assistant-maintenance.md) — `ha` CLI maintenance runbook + [script](../runbooks/scripts/ha-maintenance.sh); custom-component zip install is §7
- [docs/lan-overview.md](../docs/lan-overview.md) — LAN map and gw port-forward
- [hosts/dns.windy.lan.md](dns.windy.lan.md) — `hass.windy.lan` / `hass.local` rewrites
+73 -3
View File
@@ -56,7 +56,8 @@ See full shape in [docs/pdns-upstream.md](../docs/pdns-upstream.md). Live secret
| `poweradmin` | poweradmin | Up (healthy) | `poweradmin/poweradmin:stable` |
| `pdns_pgweb` | pgweb | Up | `sosedoff/pgweb:0.16.2` |
| `pdns-backup` | backup | Up | `postgres:16` (scheduler) |
| `powerdns-admin` | *(orphan)* | Exited | legacy PDA UI — not in active compose |
> Legacy PDA UI container `powerdns-admin` (orphan, Exited) was removed 2026-08-12 (W1N-59).
### Network model
@@ -100,9 +101,60 @@ See full shape in [docs/pdns-upstream.md](../docs/pdns-upstream.md). Live secret
**Quirk:** `backend` is internal — backup must not use Alpine + runtime `apk`/`crond`. Uses `postgres:16` + `backup-scheduler.sh` (fixed 2026-08-01).
## Other software on this host (stubs)
## RustDesk Server
`/opt/traefik`, `/opt/adguard`, `/opt/remark42`, `/opt/rustdesk`, `/opt/nginx-manager`, …
**Status: operational** (hbbs + hbbr Up; image pinned `1.1.14`; relay address fixed 2026-08-12, W1N-59).
| Item | Value |
|------|--------|
| Install path | `/opt/rustdesk` |
| Compose | `/opt/rustdesk/compose.yml` |
| Containers | `hbbs` (rendezvous), `hbbr` (relay) |
| Image | `rustdesk/rustdesk-server:1.1.14` (pinned) |
| Relay (hbbr) | `hk2.chans.xyz:21117` — advertised to clients via `hbbs -r` |
| Rendezvous (hbbs) | `21115/tcp` (NAT test), `21116/tcp+udp`, `21118/tcp` (ws) |
| Relay (hbbr) | `21117/tcp`, `21119/tcp` (ws) |
| Public IP | `154.36.174.161` |
| Health | [runbooks/rustdesk-health.md](../runbooks/rustdesk-health.md) |
**Note:** the relay hostname in `hbbs -r` must resolve to this host's public IP
(`154.36.174.161`). `hk2.chans.xyz` resolves correctly; the previously used
`hk2.wsvc.info` had **no DNS record** and broke relay connectivity for clients
(fixed 2026-08-12, W1N-59).
## Other software on this host (confirmed 2026-08-12)
Verified live via `docker ps` / port scan. Each runs as a separate compose
project under `/opt/<name>` and is fronted by Traefik where noted.
| Service | Path | Container(s) | Image | Ports / notes |
|---------|------|--------------|-------|---------------|
| Traefik | `/opt/traefik` | `traefik` | `traefik:v3.6.2` | `80`, `443` (TLS entry), `8080` (dashboard) |
| AdGuard Home | `/opt/adguard` | `adguardhome` | `adguard/adguardhome:latest` | DoH `5443`, DoT `853` (bridge; no LAN `:53`) |
| Remark42 | `/opt/remark42` | `remark42` | `ghcr.io/umputun/remark42:latest` | no host ports; via Traefik (in-container `8080`) |
### Traefik dashboard auth
| Item | Value |
|------|-------|
| Dashboard URL | `https://npm.chans.xyz` (Traefik `api@internal` router), also host `:8080` |
| Auth | HTTP Basic via Traefik `basicauth` middleware (label `dashboard-auth`) |
| User | `windy` — stored as a **bcrypt** hash (plaintext never stored) |
| Hash generator | `/opt/traefik/generate-dashboard-auth.sh` (bcrypt; auto `$``$$` compose escaping) |
| Config | `/opt/traefik/compose.yml` (label `traefik.http.middlewares.dashboard-auth.basicauth.users`) |
**Password rotated 2026-08-12** from apr1/MD5 to bcrypt via the generator script; the
plaintext lives only in the operator's password manager, never in this repo.
To rotate again: `cd /opt/traefik && ./generate-dashboard-auth.sh windy`, paste the
printed label into `compose.yml`, then `docker compose up -d --force-recreate traefik`.
`/opt/nginx-manager` was a leftover (compose + `data/` + `letsencrypt/`, no running
container) and was **removed 2026-08-12**; pre-deletion backup:
`/opt/backups/nginx-manager-20260812.tar.gz`.
Health coverage: these auxiliary services are checked by the `hk2aux`
health-check profile (`ansible/roles/healthcheck`). Run:
`cd ansible && ansible-playbook playbooks/health-report.yml --limit powerdns`.
## Ops / runbooks
@@ -133,6 +185,23 @@ dig @202.91.35.141 SOA wsvc.info +short
On-server docs: `/opt/pdns/README.md`, `CHANGELOG.md`.
## Disk / logging (VPS-81, 2026-09-02)
Root disk cleanup performed (runbook: [host-disk-cleanup](../runbooks/host-disk-cleanup.md)):
- Root `/` (20G vda1): 76% used → **38% used** (15G → 7.1G; free 4.7G → 12G).
- **AGH log flood root cause fixed**: `/opt/adguard/conf/AdGuardHome.yaml`
`log.verbose: true → false` (backup `AdGuardHome.yaml.bak-20260902-vps81`).
Verbose debug was streaming to stderr → container `json.log` (~120MB/day);
`log.file: ""` makes AGH's own rotation keys inert. Restart only (no recreate).
- Journald capped: `/etc/systemd/journald.conf.d/00-vps81.conf`
`SystemMaxUse=200M`; journal vacuumed to ~96M.
- Docker: engine **29.7.2**; 14 unused images removed (kept `pdns-auth-50:5.0.5`
rollback pin); 12 orphan anonymous volumes + build cache pruned. In-use
volumes intact (`pdns_dbdata`, `b594d738…` PG data, `e855d078…` backup).
- Follow-up: re-check AGH `json.log` growth **2026-09-09** (one-week checkpoint);
global docker log rotation only if still needed.
## Verified
Last checked: **2026-08-01 21:40 CST** — operational; docs audit recorded.
@@ -142,3 +211,4 @@ Last checked: **2026-08-01 21:40 CST** — operational; docs audit recorded.
- `only-notify=` + `also-notify=202.91.35.141`; MASTER `domains.master` cleared
- https://pdns.wsvc.info → **302**; https://pgweb.wsvc.info → **401**
- Hardening backlog: API/DB credential rotation + TSIG rotate (see upstream doc)
- 2026-09-02 (VPS-81): post-cleanup verified — 10 containers Up (adguardhome healthy), DNS SOA/NS + web endpoints OK; see Disk/logging section above.
+3 -2
View File
@@ -50,8 +50,9 @@ See [mailcow-smtp-client](../runbooks/mailcow-smtp-client.md).
- [mailcow-health](../runbooks/mailcow-health.md)
- [mailcow-update](../runbooks/mailcow-update.md)
- [mailcow-smtp-client](../runbooks/mailcow-smtp-client.md)
- Script: [`../scripts/check-mx2.sh`](../scripts/check-mx2.sh)
Routine health: `cd ansible && ansible-playbook playbooks/health-report.yml --limit mailcow`.
## Verified
Last checked: 2026-08-01 — `./scripts/check-mx2.sh` exit 0 (compose up, watchdog 100%, empty queue, TLS LE OK, SMTP Postcow, A/AAAA/PTR/MX/SPF OK).
Last checked: 2026-08-01 — Ansible mailcow health profile OK (compose up, watchdog 100%, empty queue, TLS LE OK, SMTP Postcow, A/AAAA/PTR/MX/SPF OK).
+59
View File
@@ -0,0 +1,59 @@
# pgdb — TimescaleDB (PG18, Docker)
## Role and access
| Item | Value |
|---|---|
| Role | TimescaleDB PostgreSQL 18 (Docker) — Home Assistant recorder 后端(`hass`/`scribe` 库) |
| IPv4 | `192.168.55.15` (LAN55) |
| DNS | (none) |
| SSH | `ssh -4 windy@192.168.55.15`key auth 已验证可用 2026-08-29agent 沙箱用 `ssh -F /dev/null -o BatchMode=yes`password auth 亦可) |
| Host | PVE 管理的 QEMU VMi440FX**VMID 100**),Debian 13 (trixie),内核 6.12.105;宿主机 **pve2 `192.168.55.25`**Proxmox 9.2.2SSH `root@192.168.55.25``onboot: 1`QEMU guest agent 已装;2026-08-31 补记) |
| Resources | 3 GB RAM08-30 13:58 由 2G 上调、删除 balloon/ksm/shares 后重启生效)/ 30 GB disk26 G 空闲) |
| Docker | 29.7.2;容器 `timescaledb` = `timescale/timescaledb:latest-pg18`PG **18.6** + TimescaleDB **2.29.2**Apache-2.0 版) |
| Ports | `192.168.55.15:5432`PGIPv4 only);`192.168.55.15:8081`pgweb GUIbasic auth |
## Databases
| DB | Owner | Size | 用途 |
|---|---|---|---|
| `hass` | hass | ~406 MB2026-09-13 | HA recorderstates/events/statistics),客户端 HAOS `192.168.55.11` |
| `scribe` | postgres | ~2.6 GB2026-09-13 | HA scribe 集成(entities/areas/devices 注册表同步 + `states_raw`/`events` hypertable + `csg_history` 长期归档表);体积由 `sensor_minute` 图表管道主导(2.26 GB),见 Known issues |
| `postgres` | postgres | ~9 MB | 默认库 |
## Ops notes
- **Docker compose 管理**2026-08-29 改造):`/opt/database/docker-compose.yml`(源码在仓库 `compose/pgdb/`+ `/opt/database/.env`0600,密钥)+ `/opt/database/pgweb-bookmarks/`0600bookmark 含 DB 密码)。三个服务:
| 服务 | 镜像 | 端口 | 说明 |
|---|---|---|---|
| `timescaledb` | `timescale/timescaledb:latest-pg18` | `192.168.55.15:5432`IPv4 only | PG 18.6 + TS 2.29.2healthcheck pg_isready`restart: unless-stopped` |
| `pgweb` | `sosedoff/pgweb:latest`v0.17.0 | `192.168.55.15:8081` | Web GUIhttp://192.168.55.15:8081basic auth(用户名/密码见 .env `PGWEB_AUTH_USER/PASS`);`--readonly --sessions --bookmarks-only --bookmarks-dir /bookmarks`v0.17.0 不读 PGWEB_BOOKMARKS_DIR env,必须用 flag);bookmarks = hass/scribe |
| `pg-backup` | `prodrigestivill/postgres-backup-local:latest`(=PG18 客户端) | — | 每日 02:00(`TZ=Asia/Shanghai`,本地时区)`pg_dump -Fc` 三库 → `/opt/database/backups/{daily,weekly,monthly}`;保留 7 天/4 周/6 月;`BACKUP_ON_START` |
- **数据盘**`/dev/sdb1`32G ext4label `pgdata`)挂载 `/srv/pgdata`fstab 按 `UUID=c9e12e79-1f66-404c-ab7f-b8809be81d86`defaults,noatime)持久化(2026-08-29 迁移)。容器 bind mount `/srv/pgdata:/var/lib/postgresql`
- 容器内 postgres 用户 uid/gid = **70**(Debian 系,非 999);迁移数据后需 `chown -R 70:70`
- **密码**:postgres 超级用户已换强密码(hex,存 `/opt/database/.env` 06002026-08-29)。HA 用 `hass` 角色不受影响。
- **备份**:由 `pg-backup` 容器接管(2026-08-29),宿主机 cron 与 `/opt/database/pg-backup.sh` 已退役。恢复用 `pg_restore`custom format)——2026-08-29 已实测还原 hass 库 dumpstates 10014 行)成功。
- **认证**:外部连接 scram-sha-256(密码必填,改密码有效);容器内 loopback 为 trust(官方镜像默认)。
- **回滚**:旧启动命令保留在 `/opt/database/run`(容器无状态,数据在 /srv/pgdata);旧匿名卷 `9375195843b950f4e04c34872409ca095e1136520dd019a8e86e2794be06c236`(根盘 ~82M)保留作兜底,确认稳定后可 `docker volume rm`
- **开机自愈**2026-08-30):新增 systemd oneshot `pgdb-compose.service`enabled,源码在仓库 `compose/pgdb/pgdb-compose.service`):`After=network-online.target docker.service`,开机后幂等执行 `docker compose up -d`,重试直到 `192.168.55.15:5432` 监听,重试耗尽 `--force-recreate` 兜底(数据在 bind mount,无损)。原因:2026-08-30 开机竞态——docker 恢复容器时 VM IP 尚未可绑(EADDRNOTAVAIL),timescaledb/pgweb 启动失败且 docker 不重试。手动重跑:`sudo systemctl restart pgdb-compose.service`
- 本机无防火墙(ufw/nft/iptables 均未装)——待办:如要彻底隔离可加 ufw 白名单 192.168.55.11。
- `/opt/database/backups/` 根下残留 `*-2026-08-29_1359.dump`(compose 化之前旧备份机制产物)与 `backup.log`——健康检查只看 `daily/`,残留可清理。
- **Runbooks**[pgdb-health](../runbooks/pgdb-health.md)(只读健康检查)、[pgdb-restore](../runbooks/pgdb-restore.md)pg_restore 还原)、[pgdb-update](../runbooks/pgdb-update.md)(镜像/compose 升级)。
- **CSG 长期归档(2026-08-29, W1N-243**`csg_history` 表(`period date / kind('day'|'month') / usage_kwh / cost / ladder / balance / updated_at`PK(period,kind)`GRANT SELECT TO hass`)保存南方电网有价值数据:day = 逐日(昨日用电/费用/阶梯/余额,2026-07-01 起),month = 当月累计(用电/费用,2025-01 起)。由 TimescaleDB 每日任务 **1008** `csg_daily_snapshot()`22:30 Asia/Shanghai**TS job 非 pg_cron**,本库未装 pg_cronupsert 维护:取「最新有值行」防瞬态 unknown 竞态;日费用缺原生 `latest_day_cost` 时回退 = 昨日用电 × 当前档费率(模板 `csg_current_ladder_tariff` 0.639);月费用回退模板 `csg_this_month_ladder_cost`。验证:day 08-28 = 7.66 / 4.89474 / 二档 / 0month 08 = 302.47 / 180.28。回填来源:集成 attributes `history_data`59 天)+ `by_month`(19 月)——08-29 前唯一残存历史。回滚:`DROP TABLE csg_history` + `SELECT delete_job(1008)`
## Known issues
- 2026-09-13**`sensor_minute` 体积构成与压缩窗口(只读诊断,暂不处理)**。`scribe` 库 2.6 GB = `sensor_minute` **2.26 GB**850 万行 / 16 天,约 5659 万行/天 = 331 实体 × 1440 分钟 LOCF+ `states_raw` 290 MB + `events` 1.5 MB`hass` 库另 406 MB。2.26 GB 中 1.50 GB 是 chunk `[09-03,09-10]`、0.76 GB 是 `[09-10,09-17]`,**都还没到压缩窗口**——TimescaleDB 的 `compress_after`**chunk 结束时间**判断(09-10 结束 + 7 天 = **09-17** 才合格),所以「7 天 chunk + 7 天 compress_after」的设计下限就是盘上常驻近 14 天原始数据;已压缩的 `[08-27,09-03]` 从 1.04 GB → **1.5 MB**LOCF 重复度极高,~700:1)。任务 1005 健康(30 成功 / 0 失败,最近 09-13 04:18 跑过但无合格 chunk);1002/1003/1006/1007 亦全 Success。稳态估算 ≈ 2 个未压缩 chunk(3–4.5 GB+ 已压缩归档(约 1.5 MB/周 ≈ 80 MB/年)≈ **45 GB 平台期**pgdata 卷 32 G 当前用 3.2 G,可用 27 G,**无需处理**。复查点 **2026-09-17 之后**`_hyper_4_6_chunk` 应转为 `compressed=true` 且库体积回落;若仍为 false 才需动手(手动 `compress_chunk()` 或调小 `compress_after`)。可选调优:chunk 间隔 7 天 → 1 天 + `compress_after` → 2 天,把常驻未压缩量压到 <1 GB(`set_chunk_time_interval` 只对新 chunk 生效,旧 chunk 不重切)。诊断命令:`select chunk_name, is_compressed from timescaledb_information.chunks where hypertable_name='sensor_minute';` + `pg_database_size('scribe')`。**注意:这是 pgdb 侧对象,HA/scribe 的 `retention_states` 管不到它;HA 侧唯一杠杆是少记/少画(等于砍图)。**
- 2026-08-29HA 侧 HACS 集成 `custom_components.scribe`YAML `scribe: db_url:`,连 `scribe` 库)建表被拒(`permission denied for schema public`hass 无 CREATE 权限),之后持续报 `relation "entities" does not exist`。**已解决**:① `GRANT CREATE ON SCHEMA public TO hass;`scribe 库)② 重启 HA Core 触发重跑建表。重启后自动创建 `entities`1591 行)/`users`/`areas`/`devices`/`integrations`/`states_raw` 表并启用 TimescaleDB 时间序列能力。报错已停止(最后一条 06:06 UTC),`states_raw` 持续写入。2026-08-29 复查:scribe 现有**两个** hypertable——`states_raw`segmentby `metadata_id`、orderby `time`)与 `events`segmentby `event_type`、orderby `time`),均 1 维 `time`;压缩已配置(`timescaledb_information.compression_settings` 可见对应行;2.29.x 该视图无 `compression_enabled` 列)。
- 2026-08-29**timescale reader 图表对象**(配套 hass 的 `timescale_database_reader` 集成 + `timescale-plotly-card`,上游 SQL `remmob/timescale_database_reader` `SQL/scribe/01+02` @ `bb8776a`,以 postgres 执行):`sensor_minute_aggregate` 连续聚合(1 分钟桶,last(state)/last(value),实时聚合开启)+ `sensor_minute_aggregate_entity` 视图(join `entities`+ `sensor_minute` hypertable`minute`/`entity_id`/`state`/`value`,LOCF 前向填充)。任务:1005 `sensor_minute` 压缩(7 天)、1006 `sensor_minute` 保留(10 年)、1007 `every_minute_refresh` 每分钟增量刷新(含 5 分钟回溯窗口修正)。授权:`GRANT SELECT ON sensor_minute_aggregate, sensor_minute_aggregate_entity, sensor_minute, entities TO hass`。种子 19529 行(331 实体,自首个数据点起)。**刻意跳过**了上游脚本对 `states_raw` 的 3 个月保留 + 压缩策略语句——与"`states_raw` 永久归档"定位冲突,如需磁盘回收属用户决策(scribe 自己的压缩任务 1000/1001 未动)。
- 2026-08-29**`sensor_minute_refresh` 本地补丁(类比 tianqi 补丁,重跑上游 02 SQL 后需重打)**:值 CASE 的 `ELSE 0``ELSE NULL`。原因:scribe 对 unavailable 分钟 value 为 NULL,上游刷新过程兜底写 0;对差分模式的用电图,0→计数器回升会把插座的**生命周期累计值**(最高 1588 kWh)算进掉线那一小时。同日一次性清理既有脏 0:头部占位行 DELETE 505 行(各实体首次非零分钟之前的 value=0);`sensor.%_energy` 与温湿度实体的 value=0 → NULL(10+16 行,物理上不可能的真 0,图表渲染为断点)。功率实体的中途 0 是真实待机读数,保留。
- `hass` 库的 recorder 表仍为普通表(无 hypertable);`scribe` 集成负责时间序列历史(`states_raw` + `events` hypertable)。
## Verification history
- 2026-08-31**13:58 重启根因确认,非停电**W1N-263):pve2`192.168.55.25`)任务日志显示 08-30 **13:58:00 `root@pam` 在 PVE Web UI 修改 VM 100 配置**`-delete allow-ksm,balloon,shares -memory 3072`),**13:58:06 点 Reboot**`qmreboot` → 客机 13:58:08 干净 ACPI 关机 → 13:58:13 自动重启)。宿主机全程在线(08-30 09:00 开机至今连续运行 1d12h+),`.66.26` PVE 及各 VM 均无重启——排除停电。HA recorder 在窗口(13:58:4647)报 2 次 `Connection refused`,DB 恢复后自动重连,**无数据丢失**(`hass.states`/`scribe.states_raw` 13:5514:02 逐分钟无缺口,recorder 内存队列吸收回写)。13:58:47 三容器已起,13:58:56 自愈单元 `pgdb-compose.service` 执行成功——本次自愈按设计工作。同日下午 12:54–12:55 另有一次**客机内自重启**(无 PVE 任务,工作站 SSH 会话相邻)。08-29 22:19→08-30 09:00 宿主机停机 10h41m 为**干净关机**(systemd 有序关闭,非停电)。
- 2026-08-30**开机竞态故障 + 修复**W1N-260):09:01 开机后 docker 恢复容器时绑定 `192.168.55.15:5432/8081` 失败(EADDRNOTAVAIL)→ timescaledb/pgweb 停摆至 12:16pg-backup 开机备份失败(解析不到 timescaledb)→ unhealthy。12:22 `docker compose up -d --force-recreate` 修复(三容器回 `database_default`、端口发布、今日备份、pgweb 恢复);用户重启 HA Core 后写入管道恢复。12:43 新增开机自愈 unit `pgdb-compose.service`enabled,已实测幂等 reconcile)。pgdb-health 8 项全绿。
- 2026-08-29:首次检查(只读)+ 修复 scribe 权限 + 安装夜间备份。见 Linear vps 项目登记。
- 2026-08-29**compose 改造完成**W1N-227,用户已验收):裸 `docker run``/opt/database/docker-compose.yml` 三服务(timescaledb + pgweb + pg-backup);superuser 换强密码;端口收紧 IPv4;备份容器化(TZ=Asia/Shanghaicron 02:00 本地);`pg_restore` 还原实测通过;pgweb UI 用户确认可查 hass/scribe 数据。源码在仓库 `compose/pgdb/`
- 2026-08-29**运维 runbook 落地**W1N-228,已验收):新增 `runbooks/pgdb-health.md`(只读,8 项诊断全绿)、`pgdb-restore.md`(流程式,temp-DB 安全还原 + 审批门)、`pgdb-update.md`(门控命令式,回滚=/opt/database/run + 旧卷);README 索引与 validate-repo.sh 分类同步更新;runbook 命令已对活主机逐条实测(含 `pg_restore -l` 校验当日 dump)。同日修正:SSH key auth 可用(facts 原记"密钥未安装"已过时);scribe 新增 `events` hypertable。
- 2026-08-29**CSG 长期归档 + recorder 365d**W1N-243):建 `csg_history` 表 + attributes 回填(逐日 59 + 逐月 19)+ 每日任务 1008(函数 v2:最新有值行读取、日费用阶梯回退);hass `purge_keep_days` 30→365(备份 `configuration.yaml.bak-20260829-purge365`)。见 Linear vps W1N-243。
+45 -1
View File
@@ -24,6 +24,7 @@ ssh -4 windy@synapse.chans.xyz
| DB | ESS embedded PostgreSQL 17 (PVC 20Gi, local-path) |
| Cache | ESS embedded Redis (PVC 2Gi) |
| Chart | `oci://ghcr.io/element-hq/ess-helm/matrix-stack`, version `26.7.2` |
| Plane | Helm `plane-ce-1.8.0` (app `v1.4.1`), namespace `plane` — self-hosted Plane project management |
### Matrix service endpoints
@@ -51,8 +52,50 @@ All other ports internal only (no K3s API, no database, no Redis exposed).
- `ess` — all ESS workloads (Synapse, MAS, Element, Postgres, Redis, HAProxy)
- `matrix-system` — cluster base resources (ResourceQuota, LimitRange, mrtc-placeholder)
- `plane` — Plane project management (Helm release `plane-app`)
- `cert-manager` — cert-manager
## Plane (project management)
Self-hosted [Plane](https://github.com/makeplane/plane) on the same K3s node, deployed via the official `plane-ce` Helm chart.
| Item | Detail |
|------|--------|
| Release | `plane-app` (ns `plane`), chart `plane-ce-1.8.0`, app `v1.4.1`, revision 1 |
| URL | https://plane.chans.xyz |
| Install date | 2026-09-01 |
| Values source | `/home/windy/plane-k3s/values.yaml` (plain file, not a git repo) |
| Images | `artifacts.plane.so/makeplane/*` (`plane-frontend`, `plane-backend`, `plane-admin`, `plane-live`), pullPolicy `Always` |
| Ingress | Traefik `IngressRoute` `plane-app-ingress``/`→web, `/api` `/auth`→api, `/spaces`→space, `/god-mode`→admin, `/live`→live, `/uploads`→minio; `maxRequestBodyBytes` 20Mi |
| TLS | Own namespace `Issuer` `plane-app-cert-issuer` (HTTP-01, LE prod, `admin@chans.xyz`); cert `plane-app-ssl-cert` (CN `plane.chans.xyz`) |
| DB | Bundled Postgres `15.7-alpine` (PVC 5Gi, local-path) |
| Cache/queue | Bundled Redis (PVC 100Mi), RabbitMQ `3.13.6-management-alpine` (PVC 100Mi) |
| Storage | Bundled MinIO (`minio/minio:latest`, root user `admin`, PVC 5Gi) — S3 for uploads/docs |
| Resources | Every workload: cpu 50m/500m, mem 50Mi/1000Mi, replicas 1 |
| SMTP | Not configured (no `smtp` values) — Plane invites/password resets won't email yet |
Workloads (all 1/1 Running): 7 Deployments (`plane-app-{admin,api,beat-worker,live,space,web,worker}-wl`) + 4 StatefulSets (`plane-app-{minio,pgdb,rabbitmq,redis}-wl`); init Jobs `api-migrate-1` / `minio-bucket-1` Completed. All PVCs Bound on `local-path` (root disk).
### Plane configuration notes
- **`planeVersion: v1.4.1`** pinned in values.yaml; chart tracks Plane's own tags.
- **Secrets**: Helm-generated Opaque secrets (`plane-app-app-secrets`, `-doc-store-secrets`, `-pgdb-secrets`, `-rabbitmq-secrets`, `-live-secrets`); `requireExplicitSecrets: false`. Values live in `$SECRET_KEY`, `DATABASE_URL`, `AMQP_URL`, `REDIS_URL` etc.
- **Sentry / CORS**: `sentry_dsn` and `cors_allowed_origins` empty (defaults fine for single-host).
- **MinIO is `latest` tag** — pin a version for reproducibility.
- **Backup**: NOT covered by `/var/backups/matrix` (which is paused anyway) — Plane Postgres/MinIO PVCs have no backup tier yet.
### Plane verification
```bash
# Release + workloads
sudo helm list -A
sudo k3s kubectl -n plane get deploy,sts,pods -o wide
# Cert + ingress
sudo k3s kubectl -n plane get certificate,ingressroute
# Endpoint
curl -4 -s -o /dev/null -w '%{http_code}\n' https://plane.chans.xyz/
```
## Local backup
| Item | Detail |
@@ -62,7 +105,7 @@ All other ports internal only (no K3s API, no database, no Redis exposed).
| Retention | 7 days |
| Disk warning | 80% (healthcheck), 90% (backup stops) |
| Content | Planned: PostgreSQL `synapse` + `mas` logical dumps, media store archive, `/etc/matrix-bootstrap` |
| Status | **Not operational** — no current Matrix backup or recovery tier |
| Status | **Not operational** — no current Matrix backup or recovery tier. **Plane data (its own Postgres + MinIO PVCs in ns `plane`) is also not covered by any backup.** |
## Health checks
@@ -101,5 +144,6 @@ diagnosis and imperative recovery work.
- MatrixRTC / Element Call / LiveKit / Coturn not deployed (`mrtc.chans.xyz` reserved only)
- SMTP email not yet configured (requires manual secret bootstrap followed by a
reviewed Ansible stack deployment)
- Plane `minio` image uses `latest` tag (pin a version)
- No off-site Restic backup
- Single-node K3s (no HA for control plane)
+27
View File
@@ -8,6 +8,7 @@
| IPv4 | `192.168.66.46` |
| SSH | `ssh -4 windy@192.168.66.46` |
| Hostname | `ubnt` |
| **Host** | **PVE VM 160 (`ubnt`)** — not a separate physical host (verified 2026-08-09) |
| Privilege | `windy` has passwordless sudo and is in the `docker` group |
The current SSH ED25519 host-key fingerprint is
@@ -25,6 +26,7 @@ Verify a changed key out of band before accepting it.
| Image | `jacobalberty/unifi:v9.5.21` |
| Network mode | host |
| HTTPS UI | `https://192.168.66.46:8443` |
| Device inform endpoint | `http://192.168.66.46:9080/inform` |
The controller binds `*:8443`. A local HTTPS request to
`https://127.0.0.1:8443/` is the primary quick health check.
@@ -43,6 +45,19 @@ Without this bypass, a controller request to `localhost:8443` can be sent to
the OpenClash gateway instead of remaining local. Details and verification are
in [the UniFi localhost proxy note](../docs/unifi-openclash-localhost.md).
## Managed access points
The controller manages the APs listed in [the UniFi network record](../docs/unifi-network.md).
The inform endpoint is deliberately on port `9080`, not UniFi's common default
of `8080`. During adoption or recovery, use the documented `:9080/inform` URL;
an AP left on `:8080` can remain reachable by ping and SSH while showing
offline in the controller.
IPv6 is enabled on the controller's `Default` network (`ipv6_enabled: true`,
client assignment SLAAC; RA is served by `gw`, so `ipv6_interface_type` is
`none`); both managed APs hold global SLAAC addresses — verified 2026-08-20.
See [docs/unifi-network.md](../docs/unifi-network.md).
## Safe reconciliation and verification
```bash
@@ -56,3 +71,15 @@ curl -k -sS -o /dev/null -w '%{http_code}\n' https://127.0.0.1:8443/
Recreating the service briefly interrupts the controller. Do not use `docker
compose down -v`; it can remove persistent data.
## Ansible management
The host is in the Ansible `unifi` group (inventory `ansible/inventory/hosts.yml`),
deliberately outside `managed` so the generic health report does not run here.
- SSO login setting (idempotent, gated): `cd ansible && ansible-playbook
playbooks/unifi-sso.yml --limit unifi` — see
[runbooks/ansible-operations.md](../runbooks/ansible-operations.md).
- Login/MFA state: SSO login disabled 2026-08-08; local accounts
(`windy`, `zhiqiangf`) use local passwords, no MFA. `unifi-network-mcp`
works for read and write operations. See
[docs/unifi-network.md](../docs/unifi-network.md).
+32 -12
View File
@@ -5,12 +5,12 @@
| Role | Multi-service VPS (Vaultwarden, Traefik, Soft Serve, …) |
| SSH | `ssh -4 windy@us2.wsvc.info` (prefer IPv4 from WSL) |
| IPv4 | `193.9.44.165` |
| Also DNS | `auth.wsvc.info` → this host; `repo.windy.me` → this host (Soft Serve) |
| Also DNS | `auth.wsvc.info` → this host; `repo.windy.me` → this host (Gitea) |
| Public HTTPS | Traefik on `:80` / `:443` (`/opt/traefik`) |
## Vaultwarden (Bitwarden-compatible)
**Status: operational** (Postgres live, HTTPS 200, healthy containers, SMTP AUTH OK — last probe 2026-08-01 18:55 CST).
**Status: operational** (Postgres live, HTTPS 200, healthy containers, SMTP AUTH OK — last probe 2026-08-29).
Upstream docs: [docs/vaultwarden-upstream.md](../docs/vaultwarden-upstream.md)
@@ -21,9 +21,9 @@ Upstream docs: [docs/vaultwarden-upstream.md](../docs/vaultwarden-upstream.md)
| Env file | `/opt/vaultwarden/.env` |
| Admin overrides | `/opt/vaultwarden/vw-data/config.json` (**wins over env**) |
| Public URL / `DOMAIN` | `https://auth.wsvc.info` |
| Image | `vaultwarden/server:1.37.1` (pinned) |
| Image | `vaultwarden/server:1.37.2` (pinned) |
| Live DB | **Postgres 16** (`vw-db` / service `pg`) via compose `DATABASE_URL` |
| Data (probe) | users=1, ciphers=1327 |
| Data (probe) | users=1, ciphers=1360 |
| Cold SQLite | `backups/sqlite-cold/db.sqlite3.pre-pg-20260801` (not used live) |
| Pre-migrate backup | `backups/pre-pg-migrate-20260801_161204/` |
| Data dir | `./vw-data``/data` (attachments, rsa keys, `config.json`) |
@@ -50,7 +50,7 @@ Upstream docs: [docs/vaultwarden-upstream.md](../docs/vaultwarden-upstream.md)
| Container | Status |
|-----------|--------|
| `vaultwarden` | Up (healthy), `vaultwarden/server:1.37.1` |
| `vaultwarden` | Up (healthy), `vaultwarden/server:1.37.2` |
| `vw-db` | Up (healthy) — **live** Postgres |
| `vaultwarden-backup` | Up (`pg_dump`) |
| `vaultwarden-pgweb` | Exited (profile `debug`) |
@@ -76,17 +76,37 @@ ansible-playbook playbooks/compose-reconcile.yml --limit vaultwarden \
-e '{"service_reconcile_confirm": true, "service_reconcile_targets": ["vaultwarden"], "service_reconcile_restart_traefik": true}'
```
## Other software on this host (stubs)
## Other running services on this host
`/opt/traefik`, `soft-serve`, `authelia`, `dendrite`/`conduit`, `mastodon`, `rustdesk`, `zitadel`, etc.
| Container | Status | Image / notes |
|-----------|--------|---------------|
| `gitea` | Up | `gitea/gitea@sha256:1c17ecaead42e…` (1.27.3-rootless) — SSH `repo.windy.me:2222`, web `https://repo.windy.me` |
| `gitea-backup` | Up | alpine + sqlite3/rsync sidecar (daily backup 02:00 / prune 03:00, crond) |
### Gitea (replaced Soft Serve 2026-09-18; [Plane VPS-94](https://plane.chans.xyz))
- `/opt/gitea/compose.yml` (+ `Dockerfile.backup`, `scripts/`, `config/app.ini`, `data/`, `secrets/`, `backups/`); 镜像: `compose/gitea/`(参考, 服务器文件为准)
- **rootless 镜像** uid 1000:1000; SQLite `/opt/gitea/data/data/gitea.db`; repos `/opt/gitea/data/data/git/repositories/`; app.ini `/opt/gitea/config/app.ini`(600, 含 SECRET_KEY)
- SSH: 内置 server 容器内 `:2322`(`SSH_LISTEN_PORT` 非特权), Traefik TCP entrypoint `ssh`(`:2222``gitea:2322`, `HostSNI(*)`, `tls=false`) on `vw-net`; clone URL `ssh://git@repo.windy.me:2222/windy/<repo>.git`(owner 段 `windy`)
- **host key 复用 soft-serve**(`SSH_SERVER_HOST_KEYS=/secrets/soft_serve_host_ed25519`, ed25519, 指纹 `SHA256:PdxZRe74…`): 客户端 known_hosts 零变更; 仅公钥认证(密码认证未启用)
- Web: `https://repo.windy.me`(Traefik websecure + letsencrypt); `DISABLE_REGISTRATION=true`, Actions 关闭; 管理员 `windy`(凭据仅存服务器 `/opt/gitea/.admin-credentials`, 勿入库/入 Plane)
- 仓库: 16 个(顶层 11 + `cdia/` 4 + `windyboy/go-caatsm`), 2026-09-18 自 soft-serve `push --mirror` 迁移, 逐仓 `ls-remote` ref 全集 + HEAD symref 两端一致; 可见性仅 `dotfiles-personal` private, 其余 public(与 soft-serve 现状一致)
- 备份: sidecar 每日 02:00 → `backups/gitea_<TS>/{app.ini.tar.gz, gitea.db, repos.tar.gz}`(app.ini 含恢复必需 SECRET_KEY), 03:00 prune 保留 14 份; 已验证手动备份产物 109.9M
- 回滚: `/opt/soft-serve` 未删(compose stop + sidecar 停, 数据与旧备份冻结保留), 回滚 = Traefik `:2222` 指回 `soft-serve:23231` + 客户端 remote 回改旧无 owner 段路径; 观察 24 周后清理(历史: W1N-244~248)
| `traefik` | Up | `traefik:v3.6.2` (`/opt/traefik`, public `:80`/`:443`) |
| `nghttpx-proxy` + `squid-backend` | Up | HTTP forward-proxy stack (`/opt/nghttpx`), network `nghttpx_internal-net`; details TBD |
**Disk cleanup 2026-09-18** ([Plane vps VPS-93](https://plane.chans.xyz)): root 71% → **23%** (~33G freed) keeping soft-serve / vaultwarden / traefik (nghttpx kept running per operator choice). Removed: unused Docker images + orphan volumes (incl. `zitadel_data` 801M), dormant `/opt` dirs (dendrite + its disabled `dendrite.service` unit, mastodon, dailysync, keycloak, media-repo, authelia, conduit, npm, manager, fusion, zitadel, rustdesk), rootless podman storage (6.4G stale goauthentik), home dev caches, apt cache, journal 3.8G→162M (+`SystemMaxUse=200M` drop-in, active next boot), truncated container logs (nghttpx 550M / traefik / squid). Follow-up: nghttpx-proxy logs grow ~25M/day (INFO per-connection); root-cause log-level/rotation fix still open (needs container restart approval).
Remaining running services on this host: `gitea`, `vaultwarden` stack, `traefik`, `nghttpx-proxy` + `squid-backend` (undocumented forward proxy, `/opt/nghttpx`). `/opt/soft-serve` kept stopped as rollback (24 weeks, data intact). `/home/windy/authelia` (76M) left in place — outside approved cleanup scope.
## Verified
Last checked: **2026-08-01 18:55 CST** — operational.
Last checked: **2026-09-18** — operational; disk cleanup done (see note above, Plane vps VPS-93). Prior full probe: 2026-08-29.
- `vaultwarden` + `vw-db` healthy; `DATABASE_URL``pg:5432/vaultwarden`
- `https://auth.wsvc.info/` **200**, `/admin` **200**, `/api/config` OK (`disableUserRegistration: true`)
- Identity wrong-password → **400** business error (DB readable, not 500)
- SMTP: container → `mx2:587` OK; STARTTLS cert CN=`mx2.windy.me`; **AUTH OK** with effective `config.json` password (synced with `.env` / `.smtp-credentials`)
- LE cert CN=`auth.wsvc.info`
- PG counts: users=1, ciphers=1327
- SMTP: container → `mx2:587` OK; **AUTH OK** with effective `config.json` password (synced with `.env` / `.smtp-credentials`, fingerprint match)
- PG counts: users=1, ciphers=1360
- Image `vaultwarden/server:1.37.2` (**upgraded 2026-08-29** from 1.37.1; required for Bitwarden clients 2026.8.0+); post-upgrade 404 fixed by Traefik restart, then 200
- vps-health local check **installed 2026-08-29** (`vps-healthcheck.timer` daily 06:15 + `/usr/local/lib/vps-health/run`); `health-report.yml --limit vaultwarden` now passes (**ok**, was failing due to missing check infra + script bugs fixed: trim_blocks render, pgweb debug-profile false positive, SMTP probe moved host-side since image lacks python3)
+193
View File
@@ -0,0 +1,193 @@
# us4.wsvc.info
| Item | Value |
|------|-------|
| Role | WireGuard VPN server (LinuxServer Docker image) |
| SSH | `ssh -4 windy@us4.wsvc.info` (prefer IPv4 from WSL) |
| IPv4 | `185.201.226.122` |
| Compose project | `/opt/wireguard` |
| Compose file | `/opt/wireguard/compose.yml` |
| Container | `wireguard` |
| Image policy | Immutable digest, updated only in an approved maintenance window |
| Public endpoint | `us4.wsvc.info:51820/udp`; DNS publishes only A `185.201.226.122` (no native AAAA) |
| Tunnel subnet | `10.13.13.0/24` |
| Routing policy | IPv4-only full tunnel (`ALLOWEDIPS=0.0.0.0/0`); IPv6 traffic is not guaranteed to use the VPN |
Upstream image documentation:
[LinuxServer.io WireGuard](https://docs.linuxserver.io/images/docker-wireguard/).
## Deployment configuration
The repository-owned, non-secret Compose declaration is rendered from
`ansible/templates/wireguard-compose.yml.j2`. The live declaration was verified
on 2026-08-12 with these core settings:
| Setting | Live value / intent |
|---------|---------------------|
| Image | `lscr.io/linuxserver/wireguard@sha256:ac43e1226878d2611315172d6ea357a95cb326ee73124b91108118efc8666889` |
| Image version | `1.0.20260223-r0-ls119` (build 2026-07-30) |
| Required capability | `NET_ADMIN` only; host kernel already supplies WireGuard/iptables, so `SYS_MODULE` and `/lib/modules` are not granted |
| Filesystem | Read-only container root; executable tmpfs at `/run`; writable bind mount `/opt/wireguard/config:/config` |
| Restart | `unless-stopped` |
| Server mode | Named peers `ha`, `phone`, `mbp`; runtime and configured peer counts both `3` |
| Client DNS | `1.1.1.1` |
| Tunnel routing | IPv4 full tunnel, `0.0.0.0/0`; no client IPv6 tunnel |
| Runtime interface | `wg0`, server address `10.13.13.1/32`, listen port `51820` |
| Forwarding/NAT | IPv4 forwarding enabled in the container namespace; `wg0` forwarding allowed and egress masqueraded on `eth+`; IPv6 forwarding disabled |
Docker binds UDP `51820` on both host socket families, but the public hostname
has no AAAA record. Clients using `us4.wsvc.info` therefore reach the server over
IPv4.
## Other host services and firewall (2026-08-12)
This host also carries the `windy.me` secondary MX and several web applications;
do not build its firewall allowlist from the WireGuard role alone.
| Port | Owner / purpose | Effective public state |
|------|-----------------|------------------------|
| TCP `22` | SSH management | Open |
| TCP `25` | Postfix, `mx.windy.me` (MX priority 30) | Open; retain until the secondary-MX role is explicitly retired |
| TCP `80`, `443` | Traefik for `update.wsvc.info`, `us4-gate.wsvc.info`, and `trlm.wsvc.info` | Open |
| TCP `3000` | Semaphore UI direct Docker publish | Open; redundant with the Traefik route and should be removed or bound to loopback |
| TCP `8080` | Traefik direct Docker publish | Open; redundant with the authenticated dashboard route and should be removed or bound to loopback |
| UDP `51820` | WireGuard | Required public endpoint |
| TCP `9443` | Host nghttpx-to-Squid proxy | Listening but blocked by the current firewall |
| UDP `123` | ntpsec | Listening but blocked by the current firewall |
PostgreSQL (`5433`/`5434`/`5435`), MariaDB (`3306`), and the host Squid TCP
listener (`3128`) are loopback-only. Squid also owns wildcard UDP sockets, which
are not allowed by the current public zone.
UFW is not installed. Firewalld `2.3.1` is active with nftables. On 2026-08-12,
the reviewed `ansible/playbooks/us4-firewalld.yml` reconciliation removed the
stale `imap`, `imaps`, `smtp-submission`, and `smtps` services plus TCP `24`,
`6443`, and `8443` without reloading or restarting firewalld. Runtime and
permanent public-zone state now match exactly: services `dhcpv6-client`, `http`,
`https`, `smtp`, and `ssh`, with no explicit ports.
Docker-published ports are accepted through Docker's DNAT/FORWARD chains, so
the public-zone cleanup does not close `3000` or `8080`. Their Compose bindings
remain a separate, staged follow-up after the required observation window.
Firewalld logged Docker chain/policy conflicts during the 2026-08-10 boots;
treat any firewall reload or service restart as a maintenance-window operation
and reverify Docker routing. Tracking: Linear `W1N-60`.
`mx.windy.me` also publishes AAAA `2602:f9f3:0:2::878`, while the host currently
has no global IPv6 address or IPv6 default route. Treat that as a separate
secondary-MX reachability issue.
## Safety
- Private keys, preshared keys, peer configuration files, and QR codes remain
only in `/opt/wireguard/config`; do not copy them to this repository or
Linear.
- Local rollback archives are stored in `/opt/wireguard/backups` (directory
mode `0700`, archives mode `0600`). They contain private keys, are not an
off-host disaster-recovery backup, and must never leave the server.
- Live private keys, preshared keys, generated peer configs, QR images, and
`wg0.conf` are mode `0600`. Template-only `peer.conf` and `server.conf` files
are mode `0644` and do not contain generated key material.
- `/opt/wireguard/config` is mode `0755`, but its sensitive files are `0600`.
The current files are owned by the image's numeric UID/GID rather than the
declared `PUID=1000` / `PGID=1000`; the root-run WireGuard processes can use
them, but reconcile ownership only after a protected backup and maintenance
review.
- `LOG_CONFS` is currently unset and the inspected container log contained no
QR-code/config banners. Do not enable config logging; generated QR images are
credentials.
- Do not delete, move, or regenerate `/opt/wireguard/config` during
maintenance.
- Before a container recreation, validate `docker compose config` and retain a
verified SSH session for rollback. Do not run `docker compose down -v`.
## Routine operations
Run read-only checks through Ansible:
```bash
cd ansible
ansible-playbook playbooks/health-report.yml --limit wireguard
```
Preview the narrow, fail-closed public-zone reconciliation:
```bash
ansible-galaxy collection install -r requirements.yml
ansible-playbook playbooks/us4-firewalld.yml --limit us4 --check --diff
```
Apply it only after testing the provider console and keeping an independent SSH
rollback session open. The playbook creates a protected server-local backup and
a 15-minute automatic rollback before changing rules; it cancels that rollback
only after SSH, HTTPS, SMTP, Docker, Fail2ban, and WireGuard checks pass:
```bash
ansible-playbook playbooks/us4-firewalld.yml --limit us4 \
-e '{"us4_firewalld_confirm": true, "us4_console_confirm": true}'
```
The image update and recreate procedure is deliberately separate and requires
an immutable image digest in the server-side Compose file plus an explicit
maintenance-window confirmation:
```bash
cd ansible
ansible-playbook playbooks/wireguard-maintenance.yml --limit wireguard \
-e '{"wireguard_maintenance_confirm": true}'
```
Before that recreate, apply the reviewed Compose hardening from the repository:
```bash
ansible-playbook playbooks/wireguard-harden.yml --limit wireguard \
-e '{"wireguard_harden_confirm": true}'
```
## Verification
- Container `wireguard` is running and interface `wg0` exists.
- UDP `51820` is listening on the host.
- Validate a known client can handshake and sends IPv4 traffic through the VPN.
- Do not treat inactive mobile peers as a failure solely because their latest
handshake is old.
## Live audit snapshot (2026-08-12)
The WireGuard service itself is healthy and its installation is broadly
reasonable:
- The sanitized Ansible health report returned `status=ok`; Compose is valid,
the container is running with zero restarts, `wg0` exists, and UDP `51820` is
listening.
- One of three peers had a current handshake during the audit. Two peers had
not handshaken since the current container/interface start; confirm those
clients only if they are expected to be active.
- The image is immutable-digest pinned, key-bearing files are protected, the
container root is read-only, and the container has `NET_ADMIN` without the
broader `SYS_MODULE` capability.
- Debian `13.6`, kernel `6.12.101+deb13-amd64`, Docker Engine `29.7.2`, and
Docker Compose `v5.4.0` were observed. No Debian package updates or reboot
requirement were pending.
Open host-level follow-up (do not conflate these with a WireGuard outage):
1. **Disk capacity:** `/` was 90% used with about 3.4 GiB free. Docker reported
about 2.48 GB of reclaimable images and the system journal used about 1.9
GB, but do not prune or vacuum without reviewing retention and rollback
needs first.
2. **Docker exposure:** the firewalld public-zone cleanup is complete, but
Docker still publishes `3000` and `8080` outside the ordinary host INPUT
path. Remove those redundant Compose bindings in separate maintenance units
after the observation window, and confirm provider firewall rules first.
3. **Image maintenance:** the upstream `latest` amd64 image had advanced to
`1.0.20260223-r0-ls120` (build 2026-08-06). Review and pin its immutable
digest in a maintenance window rather than updating unattended.
4. **Host hygiene:** `apache2.service`, `certbot.service`, and
`postgresql@9.6-main.service` were in a failed state while unrelated Docker
workloads remained active. Establish ownership and remove or repair stale
units separately.
5. **Resource/log limits:** the WireGuard container has no memory, CPU, or PID
limit and uses Docker's `json-file` log driver without a per-container
rotation setting. Current log size was small, but limits/rotation should be
considered during a reviewed Compose update.
+33 -17
View File
@@ -5,26 +5,41 @@ run through Ansible. SSH endpoints below remain the access path for focused
diagnosis and procedures that are deliberately interactive or destructive; see
[the Ansible operations runbook](../runbooks/ansible-operations.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 | Traefik / services | TBD | TBD | stub | — |
| hk2.chans.xyz | PowerDNS auth (ns1) | `ssh -4 windy@hk2.chans.xyz` | 154.36.174.161 | active | [hosts/hk2.chans.xyz.md](../hosts/hk2.chans.xyz.md) |
| ns1.wsvc.info | PowerDNS public NS name | — (DNS) | → hk2 `154.36.174.161` | active | see hk2 |
| ns2.wsvc.info | Secondary NS (AXFR/NOTIFY peer) | — (DNS) | → us1 `202.91.35.141` | active | see hk2 |
| pdns.wsvc.info | Poweradmin UI | — (HTTPS) | → hk2 | active | see hk2 |
| pgweb.wsvc.info | PowerDNS Postgres UI | — (HTTPS) | → hk2 | active | see hk2 |
| **synapse.chans.xyz** | Matrix homeserver (ESS: Synapse + MAS + Element) | `ssh -4 windy@synapse.chans.xyz` | `169.58.86.13` | **active** | [hosts/synapse.chans.xyz.md](../hosts/synapse.chans.xyz.md) |
| **gfw.windy.lan** | OpenWrt LAN gateway / OpenClash | `ssh -4 root@192.168.66.1` | `192.168.66.1` | **active** | [hosts/gfw.windy.lan.md](../hosts/gfw.windy.lan.md) |
| **ubnt** | UniFi Network Controller | `ssh -4 windy@192.168.66.46` | `192.168.66.46` | **active** | [hosts/ubnt.md](../hosts/ubnt.md) |
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).
**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 | Gitea 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, x88 Pro physical box, LAN55) | `ssh hassio@hass.windy.lan` | `192.168.55.11` | — (HAOS, no ansible) | **active** | [hosts/hass.windy.lan.md](../hosts/hass.windy.lan.md) |
| **pgdb** | TimescaleDB PG18 (Docker) — HA recorder backend (PVE VM, LAN55) | `ssh -4 windy@192.168.55.15` | `192.168.55.15` | — (no ansible) | **active** | [hosts/pgdb.md](../hosts/pgdb.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 |
@@ -33,4 +48,5 @@ diagnosis and procedures that are deliberately interactive or destructive; see
| https://synapse.chans.xyz | Synapse API | Client-Server + Federation API |
| https://account.chans.xyz | MAS | Matrix Authentication Service (local passwords) |
| https://admin.chans.xyz | Element Admin | Admin console (MAS admin auth) |
| https://plane.chans.xyz | Plane | Project management (Helm `plane-ce` v1.4.1, ns `plane`) |
| `mrtc.chans.xyz` | MatrixRTC | **Reserved** not deployed |
+2
View File
@@ -0,0 +1,2 @@
version: 1
profile: minimal
@@ -1,70 +0,0 @@
# VPS 維護專案重構:Ansible + systemd timers + Restic
## Objective
將現有 VPS 維運知識庫漸進重構為一個以 Ansible 管理一致性、以 systemd timers 執行每日唯讀檢查、以 Restic 建立加密異機備份的個人 SRE 維運專案。現納管 mx2、us2、hk2 與 Matrix K3s 主機。自動化預設只能觀測與告警;受 allowlist 和顯式確認保護的 Compose reconciliation 可處理已審查的服務重建,DNS、機密與破壞性資料操作仍需人工確認。
## Confirmed Decisions
- [x] 定位為個人 SRE/維運手冊庫,而非全自動修復平台。
- [x] 已納管 mx2、us2、hk2 與 Matrix K3smx、us1、us4 待完成盤點與緊急存取驗證後才納管。
- [x] 每日自動做唯讀檢查與 Email 通知;每週人工審查、每月人工維護、每季復原演練與權限盤點。
- [x] 以 Email 作為唯一告警與摘要通道。
- [x] 採用本機快速復原加密異機副本的 3-2-1 最小可行策略。
- [x] 真實機密不進 Git;repo 只保存去敏設定結構與操作/驗證紀錄。
- [x] 日常存取使用非 root SSH key;禁止 root/password SSH,維護 provider/recovery console 緊急存取。
- [x] 採用 Ansible + systemd timers + Restic 作為技術基線。
## Implementation Plan
- [x] **Status: Done — 建立去敏 Ansible inventory。** 為 mx2、us2、hk2 與 Matrix 建立主機與服務群組,將非機密主機資料與現有人類可讀的 `inventory/hosts.md` 對應;理由是提供可審查的機器可讀編排層,且不取代既有事實來源。
- [x] **Status: Done — 實作 audit-only Ansible playbook。** 只收集連線、OS、磁碟、systemd、Docker Compose 和依賴命令狀態,不做設定或服務改動;理由是先確認控制面與真實現況。
- [x] **Status: Done — 定義跨服務健康檢查結果合約。** 統一去敏結構化結果、日誌位置、嚴重度及 exit code,讓人工執行、systemd 和 Ansible audit 可共同消費;理由是避免各服務告警語意漂移。
- [x] **Status: Done — 保留並模組化 Mailcow 健康檢查。** 持續檢查 Compose、watchdog、queue、listeners、HTTP/HTTPS、TLS、SMTP、DNS/PTR/MX/SPF;理由是現有腳本已覆蓋關鍵郵件服務面向。
- [x] **Status: Done — 實作 Vaultwarden 唯讀健康檢查。** 驗證 Compose/Postgres、HTTPS、有效設定、SMTP AUTH、備份新鮮度及機密指紋一致性,禁止輸出機密;理由是 `config.json` 優先於 `.env`,且 SMTP 漂移已有已知風險。
- [x] **Status: Done — 實作 PowerDNS 唯讀健康檢查。** 驗證容器、版本與安全公告、ns1/ns2 served SOA、API、Web UI、備份新鮮度與必要設定;理由是同時覆蓋公開 DNS 與 secondary 同步。
- [x] **Status: Done — 使用 Ansible 部署 systemd healthcheck service/timer。** 每台主機本機執行每日檢查,具 persistent 排程、權限、logrotate 和一致錯誤處理;理由是控制端離線不應阻止巡檢。
- [x] **Status: Done — 部署 Email 告警與每日摘要。** Critical/unknown 立即通知、健康狀態每日摘要、重複失敗抑制;SMTP 真實認證僅在各主機受限路徑保存;理由是已選定 Email 為唯一通知通道。
- [!] **Status: Blocked — 決定 Restic 異機 repository 與存取隔離。** 選擇具加密傳輸、權限隔離與可承受保留需求的 S3/B2/SFTP 或等價目的地;理由是無異機 repository 即無法滿足已確認的 3-2-1 政策。阻塞:本地實作依要求未虛構 backend、repository 或 credentials。
- [x] **Status: Done (templates gated) — 部署 Vaultwarden 與 PowerDNS 的 Restic 備份。** 將既有一致性資料庫 dump、必要資料目錄及復原元資料加密同步,並加入 snapshot 年齡和 `restic check` 驗證;理由是兩者已有本機備份但缺少異機保護。啟用被 repository 決策和主機端受限設定檔阻擋。
- [!] **Status: Blocked — 完成 Mailcow 備份設計審查。** 官方流程已確認:以 `/opt/mail/helper-scripts/backup_and_restore.sh backup all`(或經明確核准的元件集)先產生一致性備份,再由 Restic 同步該輸出;不得直接複製 Docker volumes。阻塞:仍需決定本機備份位置、保留期、排程及異機 Restic repository。
- [x] **Status: Done (templates gated) — 部署 Restic backup、retention、forget/prune 與 check timers。** 與現有資料庫 dump 時段錯開,所有 repository 認證與密碼留在伺服器端受限檔案;理由是保持備份可用與成本可控。啟用被 repository 決策和主機端受限設定檔阻擋。
- [!] **Status: Blocked — 補齊服務級復原 runbook。** 阻塞:需在選定 Restic repository、建立實際 snapshot 並確認各服務的實際備份輸出後,才能編寫可驗證的 restore 前置條件、順序與 rollback;禁止臆造 backend/credentials 或未驗證還原命令。
- [!] **Status: Blocked — 執行隔離式復原演練。** 阻塞:尚未選定/配置 Restic 異機 repository,亦尚無可供還原的異機 snapshot;演練不得對現有生產資料執行。
- [x] **Status: Done — 實作受控 common baseline 與 maintenance playbook。** 僅在 audit 穩定後納入 SSH 稽核、時間同步、logrotate、更新預覽與人工確認的維護操作;理由是避免工具導入期間同時改變服務狀態。
- [x] **Status: Done (2026-08-03) — 將例行操作收斂為 Ansible 入口。** 新增 on-demand health report 與 allowlisted Compose reconciliationhealth、maintenance preview、baseline 現可涵蓋 Matrix。互動式 Mailcow 更新、資料遷移、DNS 與機密操作保留人工程序。
- [x] **Status: Done (separate change defined) — 將 PowerDNS API key、DB password 與 TSIG 輪替列為分離變更。** 已明確保持 API key、DB password 與 TSIG 為三項獨立、需人工核准的變更,並要求逐步驗證 Auth、Poweradmin、AXFR/NOTIFY、DNSSEC 與 ns2 同步;未輪替任何機密。
- [!] **Status: Blocked — 盤點 mx、us1、us4。** 阻塞:inventory 僅記錄 `TBD` SSH/角色或缺少完整事實,尚未提供可驗證的存取方式與 provider/recovery console 資訊;不得猜測或嘗試未授權存取。
## Verification Criteria
- [ ] 四台 active 主機能被 Ansible 正確解析;Compose 主機可執行 audit,且 audit 不產生主機變更。
- [ ] 四台主機每日產生去敏健康結果;服務、容量、TLS、公開端點和備份新鮮度異常均能被偵測。
- [ ] 正常狀態寄送 Email 摘要;受控測試異常能觸發一次可讀且不含機密的 Email 告警。
- [ ] 每個核心服務至少有一份加密、異機、可列出且通過完整性檢查的 Restic snapshot。
- [ ] 至少完成一次隔離式實際還原,並確認其結果符合或明確量化偏離服務 RPO/RTO。
- [ ] 自動化排程不包含更新、重啟、修復、秘密輪替或 DNS 變更。
- [ ] Repo、Ansible vars、產出日誌及 Email 均不包含任何真實機密。
## Potential Risks and Mitigations
1. **Mailcow 備份未保持資料一致性。**
Mitigation: 先依官方方式完成備份範圍與還原設計,再上線排程;以隔離還原驗證作為完成門檻。
2. **Ansible 設定錯誤改動生產系統。**
Mitigation: 先導入 audit-only;變更 playbook 必須使用 dry-run/diff、明確 tag 與人工確認,且先限制單一 host。
3. **機密出現在 Git、Ansible output 或 Email。**
Mitigation: 真實值只存主機受限檔案;去敏輸出、no-log 機制和測試用機密掃描為強制要求。
4. **Timer 成功但公開服務實際不可用。**
Mitigation: 同時執行本機 Compose/DB 檢查與外部 HTTP、SMTP、TLS、DNS 檢查。
5. **PowerDNS 密鑰輪替中斷 DNS 管理或 zone transfer。**
Mitigation: API key、DB password、TSIG 分批處理;每步都驗證 primary/secondary 同步、DNSSEC 和管理 UI。
## Alternative Approaches
1. **純 shell + systemd,不使用 Ansible**:初期較快,但設定與排程容易跨主機漂移,且難以審查或擴充;不建議作為長期方案。
2. **Ansible + 集中式監控平台**:可增加儀表板與趨勢,但平台本身需額外維護;等 Email 摘要無法滿足需求時再評估。
3. **立即導入 SOPS + age**:可使加密設定進 Git,但需先完成密鑰生命週期與緊急存取設計;建議列為後續階段。
+42
View File
@@ -0,0 +1,42 @@
# Runbook index
Entry point for all runbooks. Before operational work, read the repo entry
[`AGENTS.md`](../AGENTS.md) and the spec [`RUNBOOKS.md`](../RUNBOOKS.md). New
runbooks start from [`_template.md`](_template.md).
## Route by intent
| Intent | Runbook | Type |
|---|---|---|
| mailcow health check | [mailcow-health.md](mailcow-health.md) | read-only |
| mailcow update | [mailcow-update.md](mailcow-update.md) | change (gated) |
| mailcow SMTP/IMAP client | [mailcow-smtp-client.md](mailcow-smtp-client.md) | reference |
| Vaultwarden health check | [vaultwarden-health.md](vaultwarden-health.md) | read-only |
| Vaultwarden SQLite→PG migrate | [vaultwarden-sqlite-to-postgres.md](vaultwarden-sqlite-to-postgres.md) | change (destructive) |
| PowerDNS health check | [pdns-health.md](pdns-health.md) | read-only |
| RustDesk health check | [rustdesk-health.md](rustdesk-health.md) | read-only |
| Matrix health check | [matrix-health.md](matrix-health.md) | read-only |
| Plane health check | [plane-health.md](plane-health.md) | read-only |
| pgdb health check | [pgdb-health.md](pgdb-health.md) | read-only |
| pgdb DB restore (pg_restore) | [pgdb-restore.md](pgdb-restore.md) | change (procedure) |
| pgdb image/compose update | [pgdb-update.md](pgdb-update.md) | change (gated) |
| AdGuard Home health check | [adguard-home-health.md](adguard-home-health.md) | read-only |
| Host disk cleanup (logs/apt/docker) | [host-disk-cleanup.md](host-disk-cleanup.md) | change (gated) |
| Matter packet capture | [matter-packet-capture.md](matter-packet-capture.md) | read-only |
| Home Assistant maintenance | [home-assistant-maintenance.md](home-assistant-maintenance.md) | change (gated) |
| matrix_e2ee integration update | [matrix-e2ee-update.md](matrix-e2ee-update.md) | change (gated) |
| Routine Ansible operations | [ansible-operations.md](ansible-operations.md) | change (allowlisted) |
| Linear issue → mergeable change | [issue-to-merge.md](issue-to-merge.md) | delivery |
| Failing health/playbook run | [fix-ci.md](fix-ci.md) | change |
| Release a reviewed change to production | [release.md](release.md) | change (gated) |
| Roll back a change | [rollback.md](rollback.md) | change (gated) |
| Controlled network configuration | [network-change.md](network-change.md) | change (gated) |
| Network outage / service recovery | [network-recovery.md](network-recovery.md) | recovery |
## Notes
- `fix-ci.md`, `release.md`, `rollback.md`, `network-change.md`, `network-recovery.md`
are adapted from the upstream guide to this repo's VPS-ops context (execution
layer is Ansible + SSH + Linear, not a software CI/CD pipeline).
- Health runbooks are read-only; they stop (`STOP`) when live state conflicts
with the expected state instead of mutating production.

Some files were not shown because too many files have changed in this diff Show More