Compare commits
80
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de52cb8b57 | ||
|
|
b15e19bce9 | ||
|
|
bee54a6858 | ||
|
|
d6747028b4 | ||
|
|
f174aa1219 | ||
|
|
5b5f6042e6 | ||
|
|
50136b2ffd | ||
|
|
6707cebc88 | ||
|
|
908ff5412a | ||
|
|
e58283210a | ||
|
|
8c73d1f894 | ||
|
|
bc0a86245d | ||
|
|
13032fd0bb | ||
|
|
d2063e7496 | ||
|
|
2d95f87897 | ||
|
|
b61513c93e | ||
|
|
ab808088b8 | ||
|
|
c7dc4fd25c | ||
|
|
7bc7d3f99b | ||
|
|
fea9a6560f | ||
|
|
a95b626636 | ||
|
|
27fe9c078e | ||
|
|
aaa4ee312e | ||
|
|
6b298491a8 | ||
|
|
32631e2996 | ||
|
|
1426b4ecfe | ||
|
|
1f5e58bf17 | ||
|
|
e5819eeba3 | ||
|
|
079332e082 | ||
|
|
7cedba7f51 | ||
|
|
343c5db415 | ||
|
|
885d977531 | ||
|
|
b0c01b2551 | ||
|
|
047ac03346 | ||
|
|
1ec9246156 | ||
|
|
1936b8f5fe | ||
|
|
eda6536ddb | ||
|
|
1dc880362d | ||
|
|
70aea6cd72 | ||
|
|
8303d78caf | ||
|
|
ebfe7b8488 | ||
|
|
88eaefda33 | ||
|
|
fcb76d3d5a | ||
|
|
6ae835037b | ||
|
|
b5617fd3a9 | ||
|
|
f5842568b9 | ||
|
|
6f8a4918f0 | ||
|
|
5b0f7950e6 | ||
|
|
c0cf82d4af | ||
|
|
95ec2350af | ||
|
|
3de4beb028 | ||
|
|
d54ec71aea | ||
|
|
2ffd9f9f9c | ||
|
|
f255785b72 | ||
|
|
2fd354c2a9 | ||
|
|
82203038f0 | ||
|
|
096e1ce8b6 | ||
|
|
8550053287 | ||
|
|
e501b93d65 | ||
|
|
62b8fbb8b7 | ||
|
|
efa6cf0899 | ||
|
|
086740b16e | ||
|
|
1f6d028ab5 | ||
|
|
035587e3bf | ||
|
|
e7296e664a | ||
|
|
d0d5e5a704 | ||
|
|
baca89be83 | ||
|
|
562de68127 | ||
|
|
86bfb5a953 | ||
|
|
3ec51299cc | ||
|
|
7a54e8d9ec | ||
|
|
7b4c48eb93 | ||
|
|
ffb37a916e | ||
|
|
35577d0c5c | ||
|
|
e90eef9647 | ||
|
|
14a758a28a | ||
|
|
c386fe8136 | ||
|
|
e2884069a8 | ||
|
|
b03d7019c4 | ||
|
|
29bdf06383 |
+12
@@ -15,8 +15,20 @@ 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/
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
repos:
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: validate-repo
|
||||
name: validate repository
|
||||
entry: scripts/validate-repo.sh
|
||||
language: system
|
||||
pass_filenames: false
|
||||
@@ -2,33 +2,54 @@
|
||||
|
||||
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.
|
||||
|
||||
## 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 +59,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:2222` | us2 (`/opt/soft-serve`) | Soft Serve git — v0.12.2 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 |
|
||||
@@ -55,11 +76,13 @@ 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).
|
||||
|
||||
**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 +91,41 @@ 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) |
|
||||
| RustDesk health (hk2) | [runbooks/rustdesk-health.md](runbooks/rustdesk-health.md) |
|
||||
| AdGuard Home health | [runbooks/adguard-home-health.md](runbooks/adguard-home-health.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 Linear and created/updated via the Linear MCP (`vps` project). See `docs/agents/issue-tracker.md`.
|
||||
|
||||
### 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 +158,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
|
||||
```
|
||||
|
||||
@@ -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
@@ -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)**:只读 runbook(health 类、参考类)不强制
|
||||
六字段模型,但必须包含以下最小结构,否则不视为达标:
|
||||
|
||||
- `## Purpose`(1–2 行)+ `## 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`(1–2 行)与 `## 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 + 确认变量)。
|
||||
|
||||
原则:先证据后变更,先小范围后扩大,先验证后结束,不确定则停止。
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
# Deploy repo-owned Compose declarations (compose/<project>/compose.yml) to
|
||||
# inventory hosts. Non-secret source; server-local .env provides the values.
|
||||
# Gated: apply requires compose_deploy_confirm=true; --check is a read-only
|
||||
# diff + validation. See runbooks/ansible-operations.md.
|
||||
- name: Deploy repo-owned Compose declarations
|
||||
hosts: docker_hosts
|
||||
become: true
|
||||
gather_facts: false
|
||||
serial: 1
|
||||
roles:
|
||||
- role: compose_deploy
|
||||
tags: [compose, deploy, mutating]
|
||||
@@ -0,0 +1,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]
|
||||
@@ -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]
|
||||
@@ -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' }}
|
||||
@@ -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
|
||||
@@ -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) }}"
|
||||
@@ -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,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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,50 @@
|
||||
# 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(参考镜像, 未接入 compose-deploy; 服务器文件为准) |
|
||||
| `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.
|
||||
@@ -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=
|
||||
@@ -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: {}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache sqlite tzdata
|
||||
WORKDIR /scripts
|
||||
@@ -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
|
||||
@@ -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)"
|
||||
@@ -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
|
||||
@@ -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)"
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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…_
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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/`),不在本目录。
|
||||
@@ -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 W;VLAN、LACP、STP/RSTP/MSTP、ACL、
|
||||
CLI/SNMP、配置导入导出与固件下载。无 PoE——AP 使用本地取电 + 普通网线。
|
||||
|
||||
## 已确认边界(摘要)
|
||||
|
||||
| 项 | 结论 |
|
||||
|---|---|
|
||||
| 硬目标 | 同 VLAN 2.5G;SFP+ 先空槽 |
|
||||
| 核心角色 | 纯 L2;不开 L3 / DHCP Server/Relay / NAT |
|
||||
| 网关 | 默认一律 ER-X `.254`;升级专用 SSID(后续)才走 `gfw` `.1` |
|
||||
| 本次 Done | 阶段 0–3;VLAN10 / 客人 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+ 预留未来 10G(DAC/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 | access;PVE/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+ 1–4 | 未来 | 空槽 |
|
||||
|
||||
## 分阶段实施与验收
|
||||
|
||||
### 阶段 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. 旧交换下电闲置。
|
||||
|
||||
### 阶段 4:VLAN10 升级专用 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 Connected;Inform 仍为 `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)
|
||||
@@ -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)
|
||||
@@ -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 55;VLAN 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/光猫 | WAN,PPPoE |
|
||||
| `ether2`–`ether8` | PVE/gfw、dns、ubnt、U6 Lite、NAS、PC、杂项 | access,pvid 66 |
|
||||
| `ether9` | 原 LAN55 成员(AC-Lite、Aqara…) | access,pvid 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(后续项目) |
|
||||
|
||||
- 客户端 DNS:DHCP 仍下发 **`192.168.66.36`**(AdGuard Home)。
|
||||
- UniFi Inform 仍为 `http://192.168.66.46:9080/inform`。
|
||||
- LAN66 ↔ LAN55 互通由 RB5009 三层转发(沿用现网默认可达语义,防火墙不设隔离边界)。
|
||||
|
||||
### 3.3 流量怎么走
|
||||
|
||||
```text
|
||||
同 VLAN(NAS ↔ PC): 客户端 → RB5009 端口 → bridge(二层交换,1G)
|
||||
跨 VLAN(66 ↔ 55): 客户端 → RB5009 路由(vlan66 ↔ vlan55,1G)
|
||||
上网: 客户端 → RB5009 → PPPoE → Internet(限速 = 线路;RB5009 fasttrack 去掉 ER-X ~900M 天花板)
|
||||
旁路由: 默认网关仍 `.254`;显式代理走 gfw(不变)
|
||||
未来 VLAN10: U6 trunk → RB5009 →(转发 VLAN10)→ gfw(后续项目)
|
||||
```
|
||||
|
||||
### 3.4 性能预期(诚实边界)
|
||||
|
||||
| 场景 | 预期 |
|
||||
|---|---|
|
||||
| WAN / PPPoE | 线路速率以内;若线路 >1G,RB5009 fasttrack 可跑满(ER-X 受 900M 限制) |
|
||||
| 同 VLAN 交换 | **1G**(无 SE5420;RB5009 8×1G) |
|
||||
| 跨 VLAN | 1G 级三层 |
|
||||
| SFP+ 空槽 | 不等于已是 10G |
|
||||
|
||||
---
|
||||
|
||||
## 4. 分阶段实施计划
|
||||
|
||||
### 阶段 0:采购前核验
|
||||
|
||||
1. 确认 RB5009 revision、保修、零售渠道、RouterOS 当前稳定版本号(建议 7.x 最新 stable)。
|
||||
2. 确认硬件:1× 2.5G PoE-in(ether1)、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 + 端口模板(`ether2–8` pvid66,`ether9` pvid55);**临时地址 `.253`**(不与 ER-X `.254` 冲突)。
|
||||
4. 配 WAN PPPoE(ether1)、默认路由、NAT masquerade、4 条端口转发 + hairpin、防火墙(input/forward)、MSS clamp、IPv6 PD + SLAAC + IPv6 防火墙。
|
||||
5. 配 DHCP 池 `.38–.243`、24h 租约、DNS `.36`、UniFi Inform 选项、**静态映射全量照搬**。
|
||||
6. 管理面收口:**禁 Winbox(8291)/ API(8728)/ WebFig(80/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 clamp(PPPoE MTU 1492 → 1452;ER-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
|
||||
|
||||
# IPv6(PD /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 参照 IPv4:established/related accept、invalid drop、
|
||||
# icmpv6 accept、pppoe0 in drop、默认 drop,forward 同构
|
||||
|
||||
# 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 Inform:DHCP option 43(hex 编码为 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 / DHCP(gfw 才是网关与唯一 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 是 gfw;RB5009 不得在 VLAN10 提供 DHCP。
|
||||
- **设计确认:** 升级 SSID 客户端走 gfw 网关后,DNS 为 gfw 的 dnsmasq→clash(7874),
|
||||
**不是** AdGuard `.36`——这是「被代理网络」的预期行为,需接受。
|
||||
|
||||
### 阶段 5 附:PVE 上 VLAN10 透传实现(调研 2026-08-09)
|
||||
|
||||
官方 wiki + 多个社区案例支持两种模型,核心原则是 **「一层只拥有一个 tag」**——要么 PVE 拥有
|
||||
access VLAN,要么 OpenWrt 拥有 trunk,**不能在同一张 NIC 上两层都做**。
|
||||
|
||||
**方案 A(推荐):PVE 拥有 access VLAN,gfw 加第二块 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)改为 **trunk:native 66 + tagged 10**。
|
||||
5. OpenWrt 内:新网卡 `192.168.10.1/24` + DHCP(`192.168.10.0/24`)+ 独立 firewall zone→wan masq。
|
||||
|
||||
**方案 B:OpenWrt 拥有 trunk(单 NIC 多 VLAN)**
|
||||
|
||||
- `vmbr0` vlan-aware;VM NIC **不加 tag**;trunk 原样进 VM;OpenWrt 内建 `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. UniFi:U6 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 是对**未标记**流量的 VLAN;tagged VLAN 需被显式允许于 trunk。因此通常的正确表述是:若上游不允许 VLAN10 tag,AP 到 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` 不等于只上互联网 | ⚠️ 原则成立,指南已禁止新增宽泛 forwarding;LAN55/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 第 9–12 步;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` 分开验收。
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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 分层。
|
||||
@@ -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 接入与验收(计划阶段 0–3);不含 VLAN10 |
|
||||
| 默认网关 | **`192.168.66.254` / `192.168.55.254`(ER-X)** |
|
||||
| 专用升级 SSID | 仅 **U6 Lite**;客户端网关 **`gfw` `.1`**(后续项目) |
|
||||
| 客人网 | 仅 **UAP-AC-Lite**;UniFi Guest 隔离,仍在 **LAN55**(后续) |
|
||||
| 切换方式 | **30–60 分钟维护窗** 集中切;旧交换迁完后闲置 |
|
||||
| 首批 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、U6:native 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 Controller;Inform `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 无 PoE;U6 / 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 66:access,或 trunk 的 native | ER-X `eth0` `.254` | ER-X | PVE、gfw、dns、ubnt、U6、NAS、PC… |
|
||||
| **LAN55** | `192.168.55.0/24` | VLAN 55:access | 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` 所在宿主机) | 阶段 0–3:access VLAN66;日后 trunk native66+tag10 | 只允许需要的 VLAN |
|
||||
| 铜口 4 | U6 Lite | 同上 | 本地取电;日后升级 SSID |
|
||||
| 铜口 5 | UAP-AC-Lite | access VLAN55 | 本地取电;日后客人 SSID |
|
||||
| 其余铜口 | NAS、PC、`dns`、`ubnt`… | 默认 access VLAN66 | 首批 2.5G:NAS + 主力 PC/PVE |
|
||||
| SFP+ 1–4 | 未来 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 为 Connected;Inform 未改。
|
||||
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>
|
||||
@@ -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` down,2026-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
|
||||
@@ -0,0 +1,495 @@
|
||||
# SE5420 核心交换机升级操作指导(细粒度版)
|
||||
|
||||
**目标:** 把 LAN66/LAN55 有线接入从 ER-X 内置交换迁到 TL-SE5420;稳定后再启用 VLAN10 升级专用 SSID。
|
||||
|
||||
**全程三条(不再重复):**
|
||||
1. 先断旧、后接新,一次一根线;同一设备不得同时接旧路径与新核心。
|
||||
2. 全程保持 SE5420 Type-C Console(38400 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. 禁用不可用 SSID:UniFi → 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+ 1–4 | 最右侧 4 个 | 空槽 | — | — |
|
||||
|
||||
- 第一步不建 VLAN10、不向 ER-X 送任何 tag、口 4/6(PVE、U6)不做 trunk。
|
||||
- NAS 只接口 8,口 12 断开(LACP 是独立维护窗)。
|
||||
- 不占口的 VM:dns(.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.251,66.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 → 把口 1–16 从 **Untagged 勾选**中移除(保留 66 或 55 的勾选不变)→ 保存。**改 PVID 不等于移除成员。**
|
||||
> **VLAN1 不可删除属正常**(TP-Link 默认 VLAN),且 **access 口必须属于某 untagged VLAN,GUI 会拒绝把口从 VLAN1 剔除**——不必强求。做法改为:**先把未用口 Disabled(第 13 步)再清 VLAN1 IP(第 10 步)**;disabled 口不转发流量,VLAN1 剩下无管理 IP + 全 disabled 成员 = 无管理面、无转发路径,目标达成。
|
||||
10. 删 VLAN1 管理 IP:`路由功能 → 接口 → 接口设置` → VLAN1 → 清空 IPv4。
|
||||
11. **保存配置**。
|
||||
12. **验证:** `show vlan` VLAN1 无任何成员、无管理 IP;VLAN66 管理路径仍通(SSH/HTTPS 不断)。
|
||||
13. 关未用端口:`二层交换 → 端口管理 → 端口配置` → 口 9、11、12、13、14、15、16 **及 SFP+ 17–20** → 端口状态 `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 Connected;Inform 与 §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. UniFi:U6、AC-Lite 均 Connected,Inform 与基线一致。
|
||||
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 即 trunk:untagged 66 + tagged 10。
|
||||
5. 确认其余口不允许 VLAN10(口 4/6 之外不勾选)。
|
||||
6. 确认 VLAN10 不建 SVI/DHCP/路由:`路由功能 → 接口 → 接口设置` 无 VLAN10 接口;`路由功能 → DHCP服务器/DHCP中继` 无 10 段。
|
||||
7. **保存配置:** `配置保存 → 保存配置`。
|
||||
8. **验证(静态链路):**
|
||||
- SE5420 `show vlan`:VLAN10 仅在口 4/6;VLAN66 仍是口 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 上核实 Inform:SSH 进 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 Console(38400 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
# Matter 配网排障手册
|
||||
|
||||
> 基于 Matter 1.5.1 Core Spec §4.3.1 与本环境(EdgeRouter X + UniFi AP + Aqara M3 +
|
||||
> Home Assistant)2026-08-21 实测整理。配套 Linear W1N-207。
|
||||
|
||||
## 1. Matter 配网协议要点(发现即一切)
|
||||
|
||||
- **发现走 mDNS(DNS-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-21,W1N-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`(曾 .145,Aqara 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 与 IPv6(fe80+GUA)均 RST 拒绝** → 配对方无法建立 CASE,App 显示离线;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 添加即成功 |
|
||||
| 遗留 SSID(element/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 载荷是加密的**:mDNS(5353)明文可读;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 灯泡(成功 + 失败路径全记录)
|
||||
|
||||
> 场景:手机 App(Aqara 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 分析。
|
||||
|
||||
### 时间线(CST,2026-08-22)
|
||||
|
||||
| 时间 | 事件 | 判据 / 说明 |
|
||||
|---|---|---|
|
||||
| 10:50:28 | 启动轮转抓包 | — |
|
||||
| 10:51:12–19 | 手机 `.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:24–42 | **失败模式 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:01–05 | **新灯泡 `…10:bc` 关联 `wifi0ap1` 成功**,WPA2 4-way 完成,DHCP 拿 `.148`(tracker `soft failure`: ip_delta 3.76s,avg_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:59–11:07:27 | 手机查 `_matterc` ×4、`.60` 解析实例、M3 查 `_L3377._sub._matterc` ×5(discriminator 3377 正是新盏)——**全部无应答**;TCP 5540/5552 全程 0 | 配对方找不到设备 → App 报「加不上」 |
|
||||
| 11:09:35–48 | **断电 10 秒重启**:灯泡重新关联 `wifi0ap1` ×2 | 对策生效 |
|
||||
| 11:10:26 | DHCP 重新拿 `.148`;STA 计数恢复持续增长(活跃) | — |
|
||||
| 11:10–11:15 | 重新宣告 `_matterc`(**新实例 `E5BA8E28E4DE23A0`**——入配对模式实例名更换,符合规范);App 走 **BLE 配网** | 线上无 TCP 5540(BLE 对 AP 不可见,属正常) |
|
||||
| 11:15:23 | 灯泡宣告 **`_matter._tcp`**:`4DF2B1455D19402D-02EF2FF12DFAF10E`(Aqara fabric)+ SRV :5540 + GUA + A | ✅ **添加成功**(已入 Aqara fabric `4DF2B1455D19402D`) |
|
||||
|
||||
### 结论与经验
|
||||
|
||||
1. **同族灯泡(VP 4891/4100,OUI 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:23–25 | 重连成功,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 通(93–122ms,ESP32 省电时延);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/v6(v6 fe80 38–61ms)。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 探测(两盏)** | IPv4(LAN66 与 hass 本段)、IPv6(fe80%end0 + 当前 GUA)全部 **RST(Connection 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=无监听,
|
||||
超时=不可达(两者含义不同)。
|
||||
@@ -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 普联技术有限公司,版权所有,保留所有权利。
|
||||
- 产品规格和资讯仅供参考,如有内容更新,恕不另行通知。本手册仅作为使用指导,所作陈述均不构成任何形式的担保。
|
||||
|
||||
## 相关文档
|
||||
|
||||
| 文档名称 | 用途 |
|
||||
|---|---|
|
||||
| 《安装手册》 | 交换机的安装过程、启动以及常见故障处理 |
|
||||
| 《用户手册》 | 交换机的各个功能应用以及常见功能使用问题处理 |
|
||||
| 《命令行手册》 | 交换机 CLI(Command 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.0,480Mbps。可和交换机 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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
+69
-6
@@ -6,17 +6,63 @@
|
||||
|---|---|
|
||||
| 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`.
|
||||
- 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 +71,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 +101,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
@@ -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)。
|
||||
@@ -0,0 +1,605 @@
|
||||
[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 1–2 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 长期归档(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`)。
|
||||
|
||||
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 (verified 2026-08-29)
|
||||
|
||||
- **Scribe 3.8.0** (`/homeassistant/custom_components/scribe/`), 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.
|
||||
- **Scribe 3.8.0 has no retention option.** Retention ships only in the v4.x line,
|
||||
which as of 2026-08-29 has no stable release (v4.0.0rc1/v4.1.0rc1 are
|
||||
prereleases; user declined RCs — data keeps growing until an upgrade). v4.x is
|
||||
a major rewrite (writer.py largely rewritten, migration.py removed, TimescaleDB
|
||||
extension required): re-read release notes before upgrading. Do not expect
|
||||
retention YAML keys to validate on 3.8.0.
|
||||
- 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-11,per-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`)。
|
||||
|
||||
### 地图仪表盘: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 zone(zone 用 `display: icon` +
|
||||
`circle: auto`,circle 读实体 `radius` 属性画半径圈)。
|
||||
- **CARTO key 是 secret**: 只存在于服务端 lovelace 存储(dashboard `map`
|
||||
的卡片配置)和用户本人处;勿写入本仓库或 Linear。
|
||||
- **文件/资源:** `/homeassistant/www/community/ha-map-card/map-card.js`
|
||||
(root:root 644,678554 B,sha256
|
||||
`f30dfb606e858d2216d5198d8cf758ce956d127006ebd7d66d4329153a247ec2`);
|
||||
Lovelace resource(storage)id `9d2b50b52c60420d89ebd041f722cf60` →
|
||||
`/hacsfiles/ha-map-card/map-card.js`,type module(WS
|
||||
`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/javascript;WS 读回卡片配置(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)).
|
||||
|
||||
## 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
|
||||
+55
-3
@@ -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
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# 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-29;agent 沙箱用 `ssh -F /dev/null -o BatchMode=yes`;password auth 亦可) |
|
||||
| Host | PVE 管理的 QEMU VM(i440FX),Debian 13 (trixie),内核 6.12.105 |
|
||||
| Resources | 2 GB RAM / 30 GB disk(26 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`(PG,IPv4 only);`192.168.55.15:8081`(pgweb GUI,basic auth) |
|
||||
|
||||
## Databases
|
||||
|
||||
| DB | Owner | Size | 用途 |
|
||||
|---|---|---|---|
|
||||
| `hass` | hass | ~14 MB | HA recorder(states/events/statistics),客户端 HAOS `192.168.55.11` |
|
||||
| `scribe` | postgres | ~73 MB | HA scribe 集成(entities/areas/devices 注册表同步 + `states_raw` hypertable + `csg_history` 长期归档表) |
|
||||
| `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/`(0600,bookmark 含 DB 密码)。三个服务:
|
||||
| 服务 | 镜像 | 端口 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `timescaledb` | `timescale/timescaledb:latest-pg18` | `192.168.55.15:5432`(IPv4 only) | PG 18.6 + TS 2.29.2;healthcheck pg_isready;`restart: unless-stopped` |
|
||||
| `pgweb` | `sosedoff/pgweb:latest`(v0.17.0) | `192.168.55.15:8081` | Web GUI:http://192.168.55.15:8081;basic 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 ext4,label `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` 0600,2026-08-29)。HA 用 `hass` 角色不受影响。
|
||||
- **备份**:由 `pg-backup` 容器接管(2026-08-29),宿主机 cron 与 `/opt/database/pg-backup.sh` 已退役。恢复用 `pg_restore`(custom format)——2026-08-29 已实测还原 hass 库 dump(states 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_cron)upsert 维护:取「最新有值行」防瞬态 unknown 竞态;日费用缺原生 `latest_day_cost` 时回退 = 昨日用电 × 当前档费率(模板 `csg_current_ladder_tariff` 0.639);月费用回退模板 `csg_this_month_ladder_cost`。验证:day 08-28 = 7.66 / 4.89474 / 二档 / 0,month 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-08-29:HA 侧 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-30:**开机竞态故障 + 修复**(W1N-260):09:01 开机后 docker 恢复容器时绑定 `192.168.55.15:5432/8081` 失败(EADDRNOTAVAIL)→ timescaledb/pgweb 停摆至 12:16,pg-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/Shanghai,cron 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。
|
||||
@@ -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).
|
||||
|
||||
+26
-11
@@ -10,7 +10,7 @@
|
||||
|
||||
## 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,32 @@ 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 |
|
||||
|-----------|--------|---------------|
|
||||
| `soft-serve` | Up | `charmcli/soft-serve:v0.12.2` (`repo.windy.me:2222`) |
|
||||
| `soft-serve-backup` | Up | alpine + sqlite3 sidecar (daily backup 02:00 / prune 03:00, crond) |
|
||||
|
||||
Soft Serve details (verified/updated 2026-08-30; 核查 [W1N-244](https://linear.app/w1ndy/issue/W1N-244), 修复 [W1N-245](https://linear.app/w1ndy/issue/W1N-245)/[W1N-246](https://linear.app/w1ndy/issue/W1N-246)/[W1N-247](https://linear.app/w1ndy/issue/W1N-247)):
|
||||
- `/opt/soft-serve/compose.yml` (+ `Dockerfile.backup`, `scripts/`, `backups/`); data `/opt/soft-serve/data` → `/var/lib/soft-serve` (sqlite `soft-serve.db`, 12 repos + `windyboy`); env `.env` (`SOFT_SERVE_INITIAL_ADMIN_KEYS` = admin pubkey, first-boot only). 仓库镜像: `compose/soft-serve/`(参考, 服务器文件为准)
|
||||
- No host ports published: Traefik TCP entrypoint `ssh` (`:2222` → `soft-serve:23231`, `HostSNI(*)`, `tls=false`) on `vw-net`. Container listens 23231 SSH / 23232 HTTP (git smart-http, no web UI) / 9418 git / 23233 stats (localhost only)
|
||||
- **镜像已固定** `charmcli/soft-serve:v0.12.2`(digest `sha256:554cc770…`, Docker Hub 稳定源; GHCR 为 dev/nightly 源且无 v0.12.x tag); `/soft-serve` 由 named volume `soft-serve_soft-serve-app` 承载, 旧匿名卷已清理(2026-08-30)
|
||||
- **非 root 运行**(2026-08-30, [W1N-248](https://linear.app/w1ndy/issue/W1N-248)): compose `user: "1000:1000"`(uid=windy, 镜像无内置用户); data 全量 `chown 1000:1000`, 容器内 `id` = uid 1000, 功能验证通过
|
||||
- **`ssh.public_url` 已修复**(2026-08-30): `config.yaml` → `ssh://git@repo.windy.me:2222`; 失效 env `SOFT_SERVE_SSH_PUBLIC_URL` 已删(v0.12 不读取); `http`/`git` public_url 保持 `localhost`(未对外暴露)
|
||||
- **备份**: sidecar `soft-serve-backup` 每日 02:00 → `backups/soft-serve_<TS>/{repos-config.tar.gz, soft-serve.db}`(db 用 `sqlite3 .backup` 在线快照), 03:00 prune 保留 14 份; 产物 chown windy:windy 600(含 `ssh/` host keys)。**恢复**: `docker compose stop soft-serve` → 解包 `repos-config.tar.gz` + 放回 `soft-serve.db` 到 `data/` → `docker compose up -d`。异地副本(hk2/WSL 每日拉取)= follow-up(见 W1N-247)
|
||||
| `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 |
|
||||
|
||||
Directories for `authelia`, `conduit`, `dendrite`, `mastodon`, `rustdesk`, `zitadel`, etc. exist under `/opt` but have no running containers; treat them as dormant, not documented services.
|
||||
|
||||
## Verified
|
||||
|
||||
Last checked: **2026-08-01 18:55 CST** — operational.
|
||||
Last checked: **2026-08-29** — operational.
|
||||
|
||||
- `vaultwarden` + `vw-db` healthy; `DATABASE_URL` → `pg:5432/vaultwarden`
|
||||
- `https://auth.wsvc.info/` **200**, `/admin` **200**, `/api/config` OK (`disableUserRegistration: true`)
|
||||
- Identity wrong-password → **400** business error (DB readable, not 500)
|
||||
- SMTP: container → `mx2:587` OK; STARTTLS cert CN=`mx2.windy.me`; **AUTH OK** with effective `config.json` password (synced with `.env` / `.smtp-credentials`)
|
||||
- LE cert CN=`auth.wsvc.info`
|
||||
- PG counts: users=1, ciphers=1327
|
||||
- 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)
|
||||
|
||||
@@ -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.
|
||||
+32
-17
@@ -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 | Soft Serve git (on us2) | `ssh -p 2222 windy@repo.windy.me` | 193.9.44.165 | — (service on us2) | stub | see us2 |
|
||||
| auth.wsvc.info | Vaultwarden public hostname | — (HTTPS) | → us2 | — (alias) | active | see us2 |
|
||||
| us1.wsvc.info | PowerDNS secondary (ns2 host) | TBD | 202.91.35.141 | — (stub) | stub | Auth 5.0.5; see hk2 |
|
||||
| us4.wsvc.info | WireGuard VPN | `ssh -4 windy@us4.wsvc.info` | 185.201.226.122 | ✓ (us4) | active | [hosts/us4.wsvc.info.md](../hosts/us4.wsvc.info.md) |
|
||||
| hk2.chans.xyz | PowerDNS auth (ns1) | `ssh -4 windy@hk2.chans.xyz` | 154.36.174.161 | ✓ (hk2) | active | [hosts/hk2.chans.xyz.md](../hosts/hk2.chans.xyz.md) |
|
||||
| ns1.wsvc.info | PowerDNS public NS name | — (DNS) | → hk2 `154.36.174.161` | — (alias) | active | see hk2 |
|
||||
| ns2.wsvc.info | Secondary NS (AXFR/NOTIFY peer) | — (DNS) | → us1 `202.91.35.141` | — (alias) | active | see hk2 |
|
||||
| pdns.wsvc.info | Poweradmin UI | — (HTTPS) | → hk2 | — (alias) | active | see hk2 |
|
||||
| pgweb.wsvc.info | PowerDNS Postgres UI | — (HTTPS) | → hk2 | — (alias) | active | see hk2 |
|
||||
| **synapse.chans.xyz** | Matrix homeserver (ESS: Synapse + MAS + Element) | `ssh -4 windy@synapse.chans.xyz` | `169.58.86.13` | ✓ (matrix_vps) | **active** | [hosts/synapse.chans.xyz.md](../hosts/synapse.chans.xyz.md) |
|
||||
| **gfw.windy.lan** | OpenWrt (ImmortalWrt) LAN gateway / OpenClash (PVE VM 140) | `ssh -4 root@192.168.66.1` | `192.168.66.1` | — (OpenWrt, no ansible) | **active** | [hosts/gfw.windy.lan.md](../hosts/gfw.windy.lan.md) |
|
||||
| **dns.windy.lan** | AdGuard Home LAN DNS + Mihomo explicit proxy (PVE VM 120) | `ssh -4 windy@192.168.66.36` | `192.168.66.36` | ✓ (dns_windy_lan) | **active** | [hosts/dns.windy.lan.md](../hosts/dns.windy.lan.md) |
|
||||
| **gw** | EdgeRouter X primary LAN gateway | `ssh -4 zhiqiang@192.168.66.254` | `192.168.66.254` | — (EdgeOS, no ansible) | **active** | [hosts/gw.md](../hosts/gw.md) |
|
||||
| **ubnt** | UniFi Network Controller (PVE VM 160) | `ssh -4 windy@192.168.66.46` | `192.168.66.46` | ✓ (ubnt) | **active** | [hosts/ubnt.md](../hosts/ubnt.md) |
|
||||
| **hass.windy.lan** | Home Assistant (HAOS, 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 |
|
||||
|
||||
@@ -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 K3s;mx、us1、us4 待完成盤點與緊急存取驗證後才納管。
|
||||
- [x] 每日自動做唯讀檢查與 Email 通知;每週人工審查、每月人工維護、每季復原演練與權限盤點。
|
||||
- [x] 以 Email 作為唯一告警與摘要通道。
|
||||
- [x] 採用本機快速復原加密異機副本的 3-2-1 最小可行策略。
|
||||
- [x] 真實機密不進 Git;repo 只保存去敏設定結構與操作/驗證紀錄。
|
||||
- [x] 日常存取使用非 root SSH key;禁止 root/password SSH,維護 provider/recovery console 緊急存取。
|
||||
- [x] 採用 Ansible + systemd timers + Restic 作為技術基線。
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
- [x] **Status: Done — 建立去敏 Ansible inventory。** 為 mx2、us2、hk2 與 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 reconciliation;health、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,但需先完成密鑰生命週期與緊急存取設計;建議列為後續階段。
|
||||
@@ -0,0 +1,40 @@
|
||||
# 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 |
|
||||
| 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 |
|
||||
| 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.
|
||||
@@ -0,0 +1,119 @@
|
||||
# Runbook: <名称>
|
||||
|
||||
## Purpose
|
||||
|
||||
<说明本 Runbook 要解决的问题及成功结果,1–2 行。>
|
||||
|
||||
## Scope
|
||||
|
||||
- 适用环境:<production / staging / LAN …>
|
||||
- 适用对象:<服务、主机、组件或告警类型>
|
||||
- 不适用情形:<需要改用其他 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、架构文档、仪表盘、配置仓库或外部文档>
|
||||
@@ -0,0 +1,81 @@
|
||||
# AdGuard Home health — dns.windy.lan
|
||||
|
||||
## Purpose
|
||||
|
||||
Read-only health check of the AdGuard Home LAN DNS service.
|
||||
|
||||
## Scope
|
||||
|
||||
- Applicable: [dns.windy.lan](../hosts/dns.windy.lan.md) (`192.168.66.36`).
|
||||
- Read-only: does not expose query-log contents or secrets; does not change configuration.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owner: personal ops (Windy)
|
||||
- Last reviewed: 2026-08-17
|
||||
- Related systems: dns.windy.lan (`/opt/adguardhome`)
|
||||
|
||||
This runbook is read-only. It does not expose query-log contents or secrets.
|
||||
|
||||
Routine checks run through Ansible on demand:
|
||||
|
||||
```bash
|
||||
cd ansible
|
||||
ansible-playbook playbooks/health-report.yml --limit adguardhome
|
||||
```
|
||||
|
||||
The same sanitized profile is installed as a daily server-local timer
|
||||
(`vps-healthcheck.timer`, output at `/var/lib/vps-health/latest.json`) via
|
||||
`playbooks/healthchecks.yml`; the manual checks below are for focused diagnosis.
|
||||
|
||||
## Verify service and configuration
|
||||
|
||||
```bash
|
||||
ssh -4 -o BatchMode=yes windy@192.168.66.36
|
||||
sudo docker ps --filter name=^/adguardhome$
|
||||
sudo docker exec adguardhome /opt/adguardhome/AdGuardHome --check-config \
|
||||
-c /opt/adguardhome/conf/AdGuardHome.yaml
|
||||
sudo ss -lntup | grep -E '(:53|:80)'
|
||||
```
|
||||
|
||||
Expected: DNS listens on TCP/UDP 53, and the HTTP UI listens only on
|
||||
`192.168.66.36:80`.
|
||||
|
||||
## Verify DNS and storage
|
||||
|
||||
```bash
|
||||
dig +time=5 +tries=1 @192.168.66.36 example.com A +noall +comments +answer
|
||||
dig +time=5 +tries=1 @192.168.66.36 example.com AAAA +noall +comments +answer
|
||||
dig +time=5 +tries=1 @192.168.66.36 hass.windy.lan A +noall +comments +answer
|
||||
sudo docker logs --since 30m adguardhome 2>&1 | grep -Ei '\[(error|warn|fatal)\]|level=(error|warn|fatal)' || true
|
||||
df -h /opt/adguardhome/work
|
||||
```
|
||||
|
||||
The host intentionally accepts DNS from LAN55 and LAN66. Do not infer a
|
||||
broken DNS policy merely because `allowed_clients` is empty.
|
||||
|
||||
## Verify management UI boundary
|
||||
|
||||
From LAN66, the UI should return a login redirect:
|
||||
|
||||
```bash
|
||||
curl -sS -o /dev/null -w '%{http_code}\n' http://192.168.66.36/
|
||||
```
|
||||
|
||||
From a LAN55 source, TCP/80 should time out or be refused by the dedicated
|
||||
`agh-ui-access.service` nftables rule. Do not alter this rule to restrict
|
||||
port 53 without an explicit network-access review.
|
||||
|
||||
## DNSSEC and PTR caveats
|
||||
|
||||
DNSSEC is currently disabled. Before enabling it, select upstreams that pass
|
||||
a known-bad-signature test; an enabled DO bit alone is not validation.
|
||||
|
||||
Private PTR forwarding is intentionally absent because the EdgeRouter does
|
||||
not currently answer private PTR requests.
|
||||
|
||||
## Safety
|
||||
|
||||
- Read-only: never change the DNS policy or the `agh-ui-access.service` nftables rule during this check.
|
||||
- Do not infer a broken DNS policy from an empty `allowed_clients`.
|
||||
- If live state conflicts with an expected value, `STOP` and report.
|
||||
@@ -1,8 +1,29 @@
|
||||
# Runbook: routine operations through Ansible
|
||||
|
||||
## Purpose
|
||||
|
||||
Routine operations (health, reconcile, maintenance) through the Ansible playbooks.
|
||||
|
||||
## Scope
|
||||
|
||||
- Applicable: every inventory host, run from `ansible/`.
|
||||
- Not applicable: arbitrary remote commands — the reconcile playbook is allowlisted and gated.
|
||||
|
||||
Run commands from `ansible/`. The inventory forces IPv4 and uses the `windy`
|
||||
account with sudo. Do a read-only health pass before any reconciliation.
|
||||
|
||||
## Safety
|
||||
|
||||
- Read-only health pass before any reconciliation.
|
||||
- Mutating playbooks require explicit confirmation variables; do not bypass them.
|
||||
- If a reconcile target or service name is not allowlisted, `STOP` — do not invent one.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owner: personal ops (Windy)
|
||||
- Last reviewed: 2026-08-17
|
||||
- Related systems: Ansible control-plane + all inventory hosts
|
||||
|
||||
## Health report (read-only)
|
||||
|
||||
```bash
|
||||
@@ -48,6 +69,27 @@ Do not use this playbook for a Mailcow update, database migration, DNS record
|
||||
change, or secret rotation. Those operations require their dedicated reviewed
|
||||
and, where appropriate, interactive procedures.
|
||||
|
||||
## Deploy repo-owned Compose (static projects)
|
||||
|
||||
Repo source: `compose/<project>/compose.yml` (non-secret; secrets come from the
|
||||
server-local `.env` via `${VAR}`). Mechanism and per-project status:
|
||||
[`compose/README.md`](../compose/README.md).
|
||||
|
||||
```bash
|
||||
# Read-only: staged-file diff + allowlist/confirmation asserts, no writes
|
||||
ansible-playbook playbooks/compose-deploy.yml --limit vaultwarden --check --diff
|
||||
ansible-playbook playbooks/compose-deploy.yml --limit powerdns --check --diff
|
||||
|
||||
# Apply: stage repo file → validate `docker compose config -q` against the
|
||||
# server .env → backup current file (*.bak-<ts>) → promote → `up -d` (gated)
|
||||
ansible-playbook playbooks/compose-deploy.yml --limit vaultwarden \
|
||||
-e '{"compose_deploy_confirm": true}'
|
||||
```
|
||||
|
||||
The playbook never writes, reads, or transfers the server `.env`. A failed
|
||||
validation never touches the live compose file. Hosts without an allowlisted
|
||||
`compose_repo_project` fail the assert — do not invent targets.
|
||||
|
||||
## Host-level maintenance
|
||||
|
||||
These playbooks cover every inventory host, including the Matrix K3s node:
|
||||
@@ -59,3 +101,50 @@ ansible-playbook playbooks/maintenance-preview.yml
|
||||
# Baseline observations; logrotate management remains opt-in
|
||||
ansible-playbook playbooks/baseline.yml
|
||||
```
|
||||
|
||||
## us4 firewalld reconciliation
|
||||
|
||||
The us4 playbook owns only the audited `public` zone allowlist. It fails closed
|
||||
on unknown services or ports, never reloads/restarts firewalld, and does not
|
||||
manage Docker-published ports.
|
||||
|
||||
```bash
|
||||
cd ansible
|
||||
ansible-galaxy collection install -r requirements.yml
|
||||
|
||||
# Read-only preview
|
||||
ansible-playbook playbooks/us4-firewalld.yml --limit us4 --check --diff
|
||||
|
||||
# Apply only after testing the provider console and retaining an independent
|
||||
# SSH rollback session.
|
||||
ansible-playbook playbooks/us4-firewalld.yml --limit us4 \
|
||||
-e '{"us4_firewalld_confirm": true, "us4_console_confirm": true}'
|
||||
```
|
||||
|
||||
Apply creates a protected server-local backup and schedules a 15-minute
|
||||
automatic rollback before changing rules. The rollback is cancelled only after
|
||||
the playbook verifies fresh SSH/sudo access, public HTTPS routes, SMTP, Docker,
|
||||
Fail2ban, and WireGuard. Do not bypass either confirmation variable.
|
||||
|
||||
## UniFi SSO login setting (mutating)
|
||||
|
||||
Reconciles `super_sdn.sso_login_enabled` on the UniFi controller (host `ubnt`,
|
||||
group `unifi`). Idempotent and gated: without `unifi_sso_confirm=true` the
|
||||
playbook only reports the current state and refuses to change anything.
|
||||
|
||||
```bash
|
||||
# Read-only status report
|
||||
ansible-playbook playbooks/unifi-sso.yml --limit unifi --check
|
||||
|
||||
# Apply (disable SSO login; local accounts use local passwords, no MFA)
|
||||
ansible-playbook playbooks/unifi-sso.yml --limit unifi \
|
||||
-e '{"unifi_sso_confirm": true, "unifi_sso_target_value": false}'
|
||||
|
||||
# Rollback (re-enable SSO login)
|
||||
ansible-playbook playbooks/unifi-sso.yml --limit unifi \
|
||||
-e '{"unifi_sso_confirm": true, "unifi_sso_target_value": true}'
|
||||
```
|
||||
|
||||
The controller container is not restarted by default; the UI path applies the
|
||||
setting immediately. Set `unifi_sso_restart_controller: true` only when the
|
||||
setting was changed directly in the DB while the controller was running.
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# Runbook: fix a failing health/playbook run
|
||||
|
||||
> Adapted from the upstream guide's `fix-ci`. This repo has no software CI; the
|
||||
> equivalent "pipeline" is the Ansible **health report** and the gated playbooks.
|
||||
> This runbook covers diagnosing and fixing a failed or warning/critical run.
|
||||
|
||||
## Purpose
|
||||
|
||||
Diagnose and fix a failing Ansible health-report or playbook run without
|
||||
skipping checks or changing unrelated code.
|
||||
|
||||
## Scope
|
||||
|
||||
- Applicable: `ansible-playbook playbooks/health-report.yml` and the gated playbooks under `ansible/playbooks/`.
|
||||
- Not applicable: production changes beyond fixing the run; network/DNS changes → `network-change.md`.
|
||||
|
||||
## Safety
|
||||
|
||||
- Do not skip or weaken a failing check to make it pass.
|
||||
- Do not change unrelated hosts or services.
|
||||
- Prefer read-only diagnosis before mutation; destructive fixes require approval.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owner: personal ops (Windy)
|
||||
- Last reviewed: 2026-08-17
|
||||
- Related systems: Ansible health report / gated playbooks
|
||||
|
||||
## Procedure
|
||||
|
||||
### Step 1 — Reproduce and read
|
||||
|
||||
**Action** — re-run the failing playbook with `--limit <host>` and capture the task that failed.
|
||||
|
||||
```bash
|
||||
cd ansible
|
||||
ansible-playbook playbooks/health-report.yml --limit <host> -v
|
||||
```
|
||||
|
||||
**Expected** — a specific failed task, host, and message (warning vs critical).
|
||||
|
||||
**Decision** — clear failure → Step 2; ambiguous → `STOP` and collect `-vvv` output + the relevant `latest.json`.
|
||||
|
||||
### Step 2 — Diagnose
|
||||
|
||||
**Action** — inspect the corresponding service on the host using the matching health runbook (`mailcow-health.md`, `vaultwarden-health.md`, `pdns-health.md`, etc.).
|
||||
|
||||
**Expected** — a root cause (container down, cert expired, queue backlog, drift).
|
||||
|
||||
**Decision** — root cause found → Step 3; live state conflicts with the runbook's assumptions → `STOP`.
|
||||
|
||||
### Step 3 — Fix within scope
|
||||
|
||||
**Action** — apply the minimal fix the service runbook prescribes (e.g. `compose-reconcile` for a config drift, or a documented update). Use only allowlisted/gated playbooks.
|
||||
|
||||
**Verification** — re-run the health report and confirm it passes.
|
||||
|
||||
**Rollback** — revert to the prior config/state and re-run; see `rollback.md` for the general procedure.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Troubleshooting A — Intermittent/flaky failure
|
||||
|
||||
- Evidence: timing, DNS stub flakiness (use `1.1.1.1`/`8.8.8.8` for probes).
|
||||
- Allowed: re-run once with the documented resolver workaround.
|
||||
- Next: still failing → `STOP` and escalate.
|
||||
|
||||
## Final Verification
|
||||
|
||||
- Health report passes for the affected host.
|
||||
- No checks were skipped or weakened; the fix is committed/documented.
|
||||
|
||||
## References
|
||||
|
||||
- [`ansible-operations.md`](ansible-operations.md)
|
||||
- Per-service health runbooks under [`runbooks/`](.)
|
||||
@@ -0,0 +1,386 @@
|
||||
# Runbook: Home Assistant maintenance (hass.windy.lan)
|
||||
|
||||
Target: [hass.windy.lan](../hosts/hass.windy.lan.md) (physical x88 Pro box, HAOS `machine: green`)
|
||||
Upstream: HAOS 18.1 / Supervisor 2026.07.5 (verified 2026-08-14); Core 2026.8.3 (verified 2026-08-29)
|
||||
|
||||
This runbook covers routine Home Assistant maintenance through the **`ha`
|
||||
supervisor CLI**. All commands are wrapped by a single script
|
||||
[`scripts/ha-maintenance.sh`](scripts/ha-maintenance.sh); the sections below
|
||||
document the exact commands it runs, for manual/agent use.
|
||||
|
||||
## Purpose
|
||||
|
||||
Run routine Home Assistant maintenance on `hass.windy.lan` (health snapshot,
|
||||
config validation, log inspection, updates, and recovery) through the `ha`
|
||||
supervisor CLI.
|
||||
|
||||
## Scope
|
||||
|
||||
Applies to `hass.windy.lan` only (HAOS, `machine: green`). Covers both
|
||||
read-only checks and gated mutating operations; the "Command families
|
||||
intentionally NOT scripted" table below lists what is deliberately out of
|
||||
scope.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owner: personal ops (Windy)
|
||||
- Last reviewed: 2026-08-17
|
||||
- Related systems: hass.windy.lan (HAOS, `machine: green`)
|
||||
|
||||
## Safety
|
||||
|
||||
- Prefer read-only checks first; the health snapshot mutates nothing.
|
||||
- Every mutating mode (update / restart / rebuild / rollback / reboot /
|
||||
backup / restore / add-on lifecycle) refuses to run without `--yes`.
|
||||
- `--restore` overwrites the current installation; `--rollback-os`,
|
||||
`--reboot`, and `--rebuild-core` are disruptive. Run them only from a
|
||||
planned recovery with the backup verified.
|
||||
- Never commit `SUPERVISOR_TOKEN` or a long-lived `HA_TOKEN`; read entity
|
||||
state via Supervisor (`SUPERVISOR_TOKEN` after `sudo -n -i`).
|
||||
- The `--restart-core` wrapper exits 1 silently on ssh failure — treat an
|
||||
empty/exit-1 result as failure and confirm with `ha core info`.
|
||||
- If live state conflicts with a documented expectation, `STOP` and report;
|
||||
do not improvise command families outside this script.
|
||||
|
||||
## Access pattern
|
||||
|
||||
`ha` authenticates to the Supervisor with `SUPERVISOR_TOKEN`. Interactive SSH
|
||||
login works because `~hassio/.zprofile` runs `exec sudo -i`; the root login
|
||||
environment carries the token. Non-interactive use must be:
|
||||
|
||||
```bash
|
||||
ssh -o BatchMode=yes hassio@hass.windy.lan 'sudo -n -i ha <cmd>'
|
||||
```
|
||||
|
||||
Running `ha` as `hassio` directly (or `sudo -n` without `-i`) returns
|
||||
`unauthorized: missing or invalid API token`.
|
||||
|
||||
**MOTD:** every `sudo -n -i` login prints the SSH & Web Terminal MOTD banner.
|
||||
The script runs its whole procedure in one remote login (`sudo -n -i bash -s`)
|
||||
so the banner appears once, then strips it with `awk` up to the
|
||||
`System is ready! Use browser or app to configure.` line.
|
||||
|
||||
**Restart wrapper (verified 2026-08-14, W1N-107):**
|
||||
`./ha-maintenance.sh --restart-core --yes` exited 1 with no output in <1s
|
||||
and **did not restart Core**. The wrapper pipes a remote script through
|
||||
`ssh … 2>/dev/null | awk …`; with `set -uo pipefail`, an ssh failure is
|
||||
silent and the pipeline returns empty/exit 1 **before any remote command
|
||||
runs**. That is not a MOTD-strip artifact after a successful restart.
|
||||
The working restart was
|
||||
`ssh -o BatchMode=yes hassio@hass.windy.lan 'sudo -n -i ha core restart'`
|
||||
(~131s, `Command completed successfully.`). Treat empty/exit 1 as
|
||||
failure; confirm with elapsed time and `ha core info`.
|
||||
|
||||
## Script usage
|
||||
|
||||
```bash
|
||||
cd runbooks/scripts
|
||||
|
||||
./ha-maintenance.sh # read-only health snapshot
|
||||
./ha-maintenance.sh --check-config # validate core configuration
|
||||
./ha-maintenance.sh --logs core 2500 # tail core logs (use 2500 after a restart)
|
||||
./ha-maintenance.sh --logs supervisor # tail supervisor logs (default 100)
|
||||
./ha-maintenance.sh --logs host 50 # tail host journald logs
|
||||
./ha-maintenance.sh --logs apps:<slug> # tail an add-on log
|
||||
|
||||
# Mutating — refuse to run without --yes:
|
||||
./ha-maintenance.sh --update --yes # refresh + update core(--backup)/supervisor/os
|
||||
./ha-maintenance.sh --restart-core --yes # restart Core
|
||||
./ha-maintenance.sh --restart-core --safe-mode --yes # restart Core in safe mode
|
||||
./ha-maintenance.sh --rebuild-core --yes # rebuild Core image (after options change)
|
||||
./ha-maintenance.sh --rollback-os --yes # boot previous OS slot (A/B rollback)
|
||||
./ha-maintenance.sh --reboot --yes # reboot the HAOS host
|
||||
./ha-maintenance.sh --backup [NAME] --yes # full backup (optionally named)
|
||||
./ha-maintenance.sh --restore <slug> --yes # restore a backup (DESTRUCTIVE)
|
||||
./ha-maintenance.sh --app restart core_mosquitto --yes # add-on lifecycle
|
||||
```
|
||||
|
||||
- `--app` action is one of `start|stop|restart|update`; needs an add-on slug.
|
||||
- `HA_HOST` / `HA_SSH_USER` override the defaults (`hass.windy.lan` / `hassio`).
|
||||
- `--restore` overwrites the current installation — run only from a planned
|
||||
recovery, with the backup verified.
|
||||
|
||||
## Command reference (verified 2026-08-14)
|
||||
|
||||
All verified against the live host. MOTD prepends each command's output; strip
|
||||
with the `awk` pattern above or read the last block.
|
||||
|
||||
### Routine / read-only
|
||||
|
||||
| Purpose | Command |
|
||||
|---|---|
|
||||
| General overview | `ha info` |
|
||||
| Core version/status | `ha core info` (this CLI build has no `state:` field; success is a normal info dump) |
|
||||
| Core config validation | `ha core check` |
|
||||
| Core stats | `ha core stats` |
|
||||
| Supervisor status | `ha supervisor info` (incl. add-on list) |
|
||||
| Supervisor stats | `ha supervisor stats` |
|
||||
| OS status | `ha os info` (boot slots A/B) |
|
||||
| Host status | `ha host info` (disk free/total, kernel) |
|
||||
| Network | `ha network info` (`supervisor_internet`) |
|
||||
| Hardware | `ha hardware info` |
|
||||
| Pending updates | `ha available-updates` |
|
||||
| Reload stores/versions | `ha refresh-updates` |
|
||||
| Job manager | `ha jobs info` |
|
||||
| Resolution center | `ha resolution info` |
|
||||
| Core logs | `ha core logs -n 100` (`-f` follow, `-b` boot id). Default 100 misses setup; use `-n 2500` after a custom-component restart. `/config/home-assistant.log` may be missing — `ha core logs` is the source of truth. |
|
||||
| Supervisor logs | `ha supervisor logs -n 100` |
|
||||
| Host journald logs | `ha host logs -n 100` |
|
||||
| Add-on logs | `ha apps logs <slug> -n 100` |
|
||||
| Add-on list | `ha supervisor info` → `addons:` (started/stopped/error) |
|
||||
| Security integrity | `ha security integrity` |
|
||||
|
||||
### Mutating (require --yes)
|
||||
|
||||
| Purpose | Command |
|
||||
|---|---|
|
||||
| Update core (with partial backup) | `ha core update --backup` |
|
||||
| Update supervisor | `ha supervisor update` |
|
||||
| Update OS | `ha os update` |
|
||||
| Update add-on | `ha apps update <slug>` |
|
||||
| Restart core | `ha core restart` / `ha core restart --safe-mode` |
|
||||
| Rebuild core | `ha core rebuild` |
|
||||
| OS rollback | `ha os boot-slot other` |
|
||||
| Reboot host | `ha host reboot` |
|
||||
| Full backup | `ha backups new [--name NAME]` |
|
||||
| Restore backup | `ha backups restore <slug>` |
|
||||
| Add-on start/stop/restart | `ha apps start\|stop\|restart <slug>` |
|
||||
|
||||
## Procedure
|
||||
|
||||
### 1. Health snapshot (read-only)
|
||||
|
||||
```bash
|
||||
./ha-maintenance.sh
|
||||
```
|
||||
|
||||
Review: supervisor `healthy: true`/`supported: true`; core/OS `update_available`;
|
||||
add-on states (any `state: error`?); `resolution info` issues; disk free.
|
||||
|
||||
### 2. Validate config after any `configuration.yaml` change
|
||||
|
||||
```bash
|
||||
./ha-maintenance.sh --check-config
|
||||
```
|
||||
|
||||
Expect `Command completed successfully.` before a Core restart.
|
||||
|
||||
`ha core check` / a YAML reload is **not** enough after copying Python
|
||||
custom-component files — restart Core.
|
||||
|
||||
### 3. Inspect logs
|
||||
|
||||
```bash
|
||||
./ha-maintenance.sh --logs core 2500 # after a Core restart / custom-component copy
|
||||
./ha-maintenance.sh --logs supervisor
|
||||
./ha-maintenance.sh --logs apps:core_mosquitto
|
||||
```
|
||||
|
||||
Default `--logs core` (100 lines) is too short to catch coordinator pickle /
|
||||
setup errors. `/config/home-assistant.log` may be absent while
|
||||
`ha core logs` still has history.
|
||||
|
||||
### 4. Apply updates (mutating)
|
||||
|
||||
```bash
|
||||
./ha-maintenance.sh --update --yes
|
||||
```
|
||||
|
||||
Runs `refresh-updates` → `core update --backup` (partial backup first) →
|
||||
`supervisor update` → `os update`, then re-prints pending updates. Prefer the
|
||||
web UI (**Settings → System → Updates**) for a human-supervised pass.
|
||||
|
||||
### 5. Recovery operations (mutating, only when needed)
|
||||
|
||||
```bash
|
||||
./ha-maintenance.sh --restart-core --safe-mode --yes # start Core without custom integrations
|
||||
./ha-maintenance.sh --rollback-os --yes # OS update broke boot? go back one slot
|
||||
./ha-maintenance.sh --restore <slug> --yes # full restore; overwrites current install
|
||||
```
|
||||
|
||||
OS update policy: HAOS uses two boot slots (A/B); `ha os info` shows which slot
|
||||
booted. After a bad OS update, `ha os boot-slot other` boots the previous slot.
|
||||
|
||||
### 6. Backup before major changes
|
||||
|
||||
```bash
|
||||
./ha-maintenance.sh --backup pre-migration --yes # named backup
|
||||
```
|
||||
|
||||
### 7. Install or update a custom component (manual zip)
|
||||
|
||||
Home Assistant loads custom integrations from
|
||||
`/config/custom_components/<domain>/` (on this HAOS host `/config` ≡
|
||||
`/homeassistant`). Official lookup:
|
||||
`<config>/custom_components/<domain>` then built-in
|
||||
`homeassistant/components/<domain>`
|
||||
([Integration file structure](https://developers.home-assistant.io/docs/creating_integration_file_structure)).
|
||||
A folder named after the domain, with at least `manifest.json` and
|
||||
`__init__.py`, is enough. **Restart Core** after copying — `ha core check`
|
||||
and a YAML reload do not pick up new Python packages.
|
||||
|
||||
This host's live trees are **file copies**, not git clones. Do not
|
||||
`git pull` inside `custom_components/`.
|
||||
|
||||
#### Official plugin paths (CSG)
|
||||
|
||||
[windyboy/china_southern_power_grid_stat README](https://github.com/windyboy/china_southern_power_grid_stat):
|
||||
[HACS](https://hacs.xyz/) **or**
|
||||
[手动下载安装](https://github.com/windyboy/china_southern_power_grid_stat/releases).
|
||||
|
||||
This host uses the zip path. **Do not HACS-update this integration here.**
|
||||
HACS still tracks upstream `CubicPill/china_southern_power_grid_stat`
|
||||
`v1.2.0` and would overwrite the fork. Releases have no uploaded zip
|
||||
assets — use GitHub **Source code (zip)** / zipball of the tag.
|
||||
|
||||
Worked SSH example (tag, backup, `rsync`, `__pycache__`, restart):
|
||||
[hosts/hass.windy.lan.md](../hosts/hass.windy.lan.md) § Manual
|
||||
custom-component install.
|
||||
|
||||
#### Procedure
|
||||
|
||||
1. **Backup the live tree off `custom_components/`.** HA scans every
|
||||
directory under `custom_components/` whose `manifest.json` `domain`
|
||||
matches. A `*.bak-*` folder next to the live tree makes Core import
|
||||
the backup (`No module named '...bak-YYYYMMDD-...'`, W1N-106). CSG
|
||||
backups: `/homeassistant/.csg-backups/`.
|
||||
2. **Copy only the inner `custom_components/<domain>/` tree**, not the
|
||||
repo root and not an extra nested folder.
|
||||
3. **Wipe `__pycache__` as root.** `rsync --delete` as `hassio` cannot
|
||||
unlink Core-owned `.pyc` (permission denied, exit 23); stale
|
||||
`cpython-314` bytecode can keep the old coordinator in memory until
|
||||
restart. Then restart:
|
||||
|
||||
```bash
|
||||
ssh -o BatchMode=yes hassio@hass.windy.lan \
|
||||
'sudo -n -i rm -rf /homeassistant/custom_components/<domain>/__pycache__ \
|
||||
/homeassistant/custom_components/<domain>/*/__pycache__ &&
|
||||
sudo -n -i ha core restart'
|
||||
```
|
||||
|
||||
4. **Wait 1–2 min**, then `ha core info` (this CLI build has no `state:`
|
||||
field; success is a normal info dump). Confirm `manifest.json`
|
||||
`version` matches the tag.
|
||||
5. **Read enough Core logs.** Default `ha core logs` is too short to
|
||||
catch setup. Use `-n 2500` (or `--logs core 2500`) and look for
|
||||
`Setting up <domain>` plus the first coordinator errors.
|
||||
6. **First poll can time out.** If last-month sensors have numbers but
|
||||
this-month stay `unknown`/`unavailable`, reload the config entry
|
||||
(UI: integration → Reload). Supervisor:
|
||||
|
||||
```bash
|
||||
# entry id from .storage/core.config_entries (CSG: 01KGCQDSZCF523A9X6SV3BZ1B9)
|
||||
ssh -o BatchMode=yes hassio@hass.windy.lan \
|
||||
'sudo -n -i python3 -c "
|
||||
import os, urllib.request
|
||||
req = urllib.request.Request(
|
||||
\"http://supervisor/core/api/config/config_entries/entry/<ENTRY_ID>/reload\",
|
||||
method=\"POST\",
|
||||
headers={\"Authorization\": \"Bearer \" + os.environ[\"SUPERVISOR_TOKEN\"]},
|
||||
)
|
||||
print(urllib.request.urlopen(req, timeout=60).status)
|
||||
"'
|
||||
```
|
||||
|
||||
7. **Do not edit the dashboard or `templates/csg_sensors.yaml` for an
|
||||
install.** Entity IDs did not change across v1.3.0/v1.3.1/v1.3.2.
|
||||
(The old `| float(0)` fake-zero follow-up was resolved 2026-08-29 by
|
||||
W1N-239: template sensors now carry `availability` templates and show
|
||||
`unavailable` instead of fake zeros when native CSG sensors are down.
|
||||
Template edits go through that issue, not the install path.)
|
||||
|
||||
#### Verify (CSG, after v1.3.2 / W1N-118)
|
||||
|
||||
| Check | Expect |
|
||||
|---|---|
|
||||
| `manifest.json` `version` | `1.3.2` |
|
||||
| `ha core logs` after this restart | `Setting up china_southern_power_grid_stat`; **no** `cannot pickle 'mappingproxy'` |
|
||||
| Config entry | `state: loaded` |
|
||||
| `sensor.0800041935246530_balance` | numeric (may be `0.0`) |
|
||||
| `sensor.0800041935246530_this_month_total_usage` | numeric after reload if first poll timed out |
|
||||
| Native `*_total_cost` / `current_ladder` | may stay `unknown` (CSG marketing calendar SQL error); dashboard uses W1N-114 `csg_*` ladder/cost templates |
|
||||
|
||||
`monetary` + `total_increasing` warnings on this-month/year cost sensors
|
||||
are a remaining plugin issue, not an install failure.
|
||||
|
||||
There is no long-lived `HA_TOKEN` in the agent environment. Read entity
|
||||
states via Supervisor (`SUPERVISOR_TOKEN` after `sudo -n -i`) at
|
||||
`http://supervisor/core/api/states/<entity_id>`.
|
||||
|
||||
## Command families intentionally NOT scripted
|
||||
|
||||
These exist in `ha` but are either rare, dangerous, or better done in the web
|
||||
UI; documented here so nothing is a surprise. Use `ha <family> --help` on the
|
||||
host for exact syntax.
|
||||
|
||||
| Family | Notes |
|
||||
|---|---|
|
||||
| `ha audio` | Audio device management; peripheral. |
|
||||
| `ha authentication` | `auth list/reset/cache`; user password ops — do in web UI. `auth list` is local-terminal only. |
|
||||
| `ha cli` | Internal CLI backend info/update; self-maintained. |
|
||||
| `ha dns` | Internal DNS server; only relevant if Supervisor DNS add-on in use. |
|
||||
| `ha docker` | Host Docker backend info/options/registries; HAOS-managed. |
|
||||
| `ha mounts` | Network storage (NFS/CIFS) mounts — configure in **Settings → System → Storage**. |
|
||||
| `ha multicast` / `ha observer` | Internal services; self-maintained. |
|
||||
| `ha network scan/update/vlan` | WiFi AP scan & interface config — prefer web UI networking. |
|
||||
| `ha host disks/options/shutdown/reload` | Disk ops / host options; `shutdown` is equivalent to `--reboot` but off. |
|
||||
| `ha os datadisk list/move/wipe` | Data-disk migration; `wipe` is **local-terminal only** and erases all data. |
|
||||
| `ha os import` | Import config from USB stick. |
|
||||
| `ha os boards` / `os config` | Board / OS settings. |
|
||||
| `ha core options` / `supervisor options` | Core/OS config options (e.g. `--duplicate-log-file`); changes need `ha core rebuild` + restart. |
|
||||
| `ha backups freeze/thaw/remove/options` | Freeze/thaw for external backup tools; removal is destructive. |
|
||||
| `ha jobs options/reset` | Job-manager tuning. |
|
||||
| `ha resolution check/healthcheck/issue/suggestion` | Resolution center management; `healthcheck` runs fixups. |
|
||||
| `ha store add/delete/repair` | Repository management — add repos in web UI app store. |
|
||||
| `ha security info/options` | Security backend options. |
|
||||
|
||||
## Docs vs actual CLI discrepancies
|
||||
|
||||
The [official HAOS common-tasks docs](https://www.home-assistant.io/common-tasks/os/)
|
||||
also mention `ha host update`, which **does not exist** in this CLI
|
||||
(2026-08-14). Docs' `ha backups list` is not a subcommand either:
|
||||
`ha backups --help` lists freeze/info/new/options/reload/remove/restore/thaw;
|
||||
extra positional args (`list`, `nonsense`, ...) are ignored and the default
|
||||
list still prints with exit 0. The list command is plain `ha backups`.
|
||||
Per-backup: `ha backups info <slug>` (slug required). Trust the server CLI
|
||||
(`ha <cmd> --help`) over the docs.
|
||||
|
||||
This CLI's `ha core info` also has no `state:` field (verified 2026-08-14).
|
||||
Wait for a successful info dump after restart, not a `state: running` line.
|
||||
|
||||
## Known issues on hass.windy.lan (2026-08-14)
|
||||
|
||||
2026-08-13 snapshot items were resolved same day (W1N-70/71/72/73/74/75/76):
|
||||
OTBR and the duplicate SSH add-on uninstalled, resolution-center empty,
|
||||
full backup `pre-maintenance-20260813` (slug `411a4ba5`). Remaining:
|
||||
|
||||
- **Bluetooth hci0 instability (RTL8821CS)**: `bluetooth_auto_recovery`
|
||||
power-reset times out every ~2 min; kernel `hci0 hardware error`. No BLE
|
||||
entities exist, so no user impact. HAOS image ships `x88-bt-hci-recovery`
|
||||
workaround units.
|
||||
- `host info` reports `disk_life_time: 10` (boot eMMC ~10% life left) —
|
||||
monitor on each snapshot; plan disk replacement / data-disk migration.
|
||||
- **Home PPPoE IPv4 to CSG is blackholed** (`curl -4` to
|
||||
`218.19.148.218:443` times out). `end0` IPv6 works (`curl -6
|
||||
https://95598.csg.cn` → HTTP 200). Entry `ip_family: ipv4` still
|
||||
matches the stored option; first post-restart poll can still time out
|
||||
— reload the config entry rather than reinstalling.
|
||||
- **WSL HTTP proxy**: LAN `hass.windy.lan:8123` through Mihomo returns
|
||||
empty `502`. Bypass proxy or add `.windy.lan` to `NO_PROXY` before
|
||||
debugging UI/API from the workstation
|
||||
([hosts/hass.windy.lan.md](../hosts/hass.windy.lan.md) § HTTP proxy
|
||||
gotcha).
|
||||
- **No long-lived HA token in the agent environment.** Read entity
|
||||
states via Supervisor (`SUPERVISOR_TOKEN` after `sudo -n -i`) at
|
||||
`http://supervisor/core/api/states/...`, not a committed `HA_TOKEN`.
|
||||
|
||||
## Pass criteria
|
||||
|
||||
- Health snapshot completes; supervisor `healthy`/`supported: true`
|
||||
- Mutating modes refuse to run without `--yes` (incl. `--restore`, `--app`)
|
||||
- `--check-config` returns success
|
||||
- Update / rollback / restore / reboot confirmed only after explicit `--yes`
|
||||
- Custom-component zip install: live `manifest.json` version matches the
|
||||
tag; backups not under `custom_components/`; Core restarted; logs show
|
||||
`Setting up <domain>` without import / pickle errors
|
||||
- Update the **Verified** line on [hosts/hass.windy.lan.md](../hosts/hass.windy.lan.md)
|
||||
@@ -0,0 +1,93 @@
|
||||
# Runbook: issue → mergeable change
|
||||
|
||||
## Purpose
|
||||
|
||||
Turn an approved Linear `vps` issue into a reviewed, mergeable change in this
|
||||
repo (docs, runbooks, hosts facts, or Ansible playbooks).
|
||||
|
||||
## Scope
|
||||
|
||||
- Applicable: repo content under `docs/`, `runbooks/`, `hosts/`, `inventory/`, `ansible/`.
|
||||
- Not applicable: mutating production state directly — that goes through `release.md` / `ansible-operations.md`.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owner: personal ops (Windy)
|
||||
- Last reviewed: 2026-08-17
|
||||
- Related systems: Linear MCP (`vps` project), git
|
||||
|
||||
## Inputs
|
||||
|
||||
| Input | Source | Required | Validation |
|
||||
|---|---|---:|---|
|
||||
| Issue identifier | Linear (`vps` project) | Yes | `linear_get_issue <id>` returns a description |
|
||||
| Current repo state | `git status` / `git log` | Yes | Clean or intended worktree |
|
||||
|
||||
## Safety
|
||||
|
||||
- Scope is locked to the issue: do not bundle unrelated changes.
|
||||
- Never commit secrets (see `AGENTS.md` §Safety).
|
||||
- Verify every change; do not merge a change whose verification was skipped.
|
||||
|
||||
## Procedure
|
||||
|
||||
### Step 1 — Read the issue
|
||||
|
||||
**Action** — `linear_get_issue <id>`, read description and acceptance criteria.
|
||||
|
||||
**Expected** — clear scope, action, and verification for the change.
|
||||
|
||||
**Decision** — if the issue is ambiguous or lacks verification criteria, `STOP`
|
||||
and ask for clarification (add a `needs-info` label if applicable). Otherwise go to Step 2.
|
||||
|
||||
### Step 2 — Inspect and change
|
||||
|
||||
**Action** — read the relevant files, then make the minimal change the issue asks for.
|
||||
|
||||
**Expected** — diff is scoped to the issue.
|
||||
|
||||
**Decision** — if the change needs production mutation, `STOP` and route to
|
||||
`release.md`. Otherwise go to Step 3.
|
||||
|
||||
### Step 3 — Verify
|
||||
|
||||
**Action** — run `scripts/validate-repo.sh` from the repo root (covers secret
|
||||
scan, inventory cross-check, markdown link check, runbook-spec check, and
|
||||
Ansible `--syntax-check`); for changes that alter playbook behavior, also run
|
||||
a read-only `ansible-playbook --check` where possible.
|
||||
|
||||
**Verification** — `scripts/validate-repo.sh` exits 0; the concrete checks
|
||||
must match the change type.
|
||||
|
||||
**Decision** — verification passed → Step 4; failed → Troubleshooting A.
|
||||
|
||||
### Step 4 — Commit and link
|
||||
|
||||
**Action** — commit with a message containing the full issue ID (e.g. `W1N-123: …`); open a PR if the change is substantial; link the issue via `linear_save_comment`.
|
||||
|
||||
**Verification** — `git log -1` shows the issue ID; the issue has the commit/PR pointer.
|
||||
|
||||
**Rollback** — `git revert <sha>` or `git checkout <branch>` to drop the change; re-verify after.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Troubleshooting A — Verification failed
|
||||
|
||||
- Evidence: command output, failing check.
|
||||
- Allowed: fix the change within scope; re-run verification.
|
||||
- Next: still failing → `STOP` and report in the issue.
|
||||
|
||||
## Final Verification
|
||||
|
||||
- Change matches the issue scope.
|
||||
- Verification passed and the issue is updated with evidence.
|
||||
|
||||
## Failure Handling
|
||||
|
||||
If unfinished: stop, collect the failed check output, record completed steps, and
|
||||
hand back to the issue — do not guess.
|
||||
|
||||
## References
|
||||
|
||||
- [`docs/agents/issue-tracker.md`](../docs/agents/issue-tracker.md)
|
||||
- [`RUNBOOKS.md`](../RUNBOOKS.md)
|
||||
@@ -1,10 +1,26 @@
|
||||
# Runbook: mailcow health (mx2)
|
||||
|
||||
## Purpose
|
||||
|
||||
Read-only health check of the mailcow stack on mx2.
|
||||
|
||||
## Scope
|
||||
|
||||
- Applicable: [mx2.windy.me](../hosts/mx2.windy.me.md), `/opt/mail`.
|
||||
- Read-only: does not change mailcow configuration or service state.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owner: personal ops (Windy)
|
||||
- Last reviewed: 2026-08-17
|
||||
- Related systems: mx2.windy.me (`/opt/mail`)
|
||||
|
||||
Target: [mx2.windy.me](../hosts/mx2.windy.me.md)
|
||||
Path: `/opt/mail`
|
||||
Prefer: the Ansible health report (`ansible/playbooks/health-report.yml`), or
|
||||
`./scripts/check-mx2.sh` from this repo. The SSH commands below remain useful
|
||||
for focused, interactive diagnosis after the report identifies a problem.
|
||||
Prefer: the Ansible health report (`ansible/playbooks/health-report.yml --limit mailcow`),
|
||||
or the server-local daily timer result at `/var/lib/vps-health/latest.json`. The
|
||||
SSH commands below remain useful for focused, interactive diagnosis after the
|
||||
report identifies a problem.
|
||||
|
||||
## Focused SSH diagnostics
|
||||
|
||||
@@ -66,7 +82,14 @@ dig @1.1.1.1 +short windy.me TXT | grep -i spf
|
||||
|
||||
## Scheduled local check
|
||||
|
||||
The sanitized Ansible health profile is `mailcow` (`ansible/playbooks/healthchecks.yml`). It preserves the existing standalone `scripts/check-mx2.sh` as the richer operator check, while the server-local timer emits a sanitized result at `/var/lib/vps-health/latest.json`. Neither check changes Mailcow configuration or service state.
|
||||
The sanitized Ansible health profile is `mailcow` (`ansible/playbooks/healthchecks.yml`).
|
||||
The server-local timer emits a sanitized result at `/var/lib/vps-health/latest.json`.
|
||||
It does not change Mailcow configuration or service state.
|
||||
|
||||
## Safety
|
||||
|
||||
- Read-only: never mutate configuration or service state during this check.
|
||||
- If live state conflicts with an expected value below, `STOP` and report; do not "fix" on the fly.
|
||||
|
||||
## Pass criteria
|
||||
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# Runbook: use mailcow SMTP / IMAP (client)
|
||||
|
||||
## Purpose
|
||||
|
||||
Reference for configuring mail clients against the mailcow SMTP/IMAP endpoints.
|
||||
|
||||
## Scope
|
||||
|
||||
- Applicable: [mx2.windy.me](../hosts/mx2.windy.me.md) client submission (587/465) and IMAP/POP (993/995).
|
||||
- Not applicable: server-side mailcow configuration or administration.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owner: personal ops (Windy)
|
||||
- Last reviewed: 2026-08-17
|
||||
- Related systems: mx2.windy.me (SMTP/IMAP client endpoints)
|
||||
|
||||
Target: [mx2.windy.me](../hosts/mx2.windy.me.md)
|
||||
Prerequisite: a mailbox on `windy.me` (password from mailcow UI, not the admin account unless it is that mailbox).
|
||||
|
||||
@@ -52,3 +67,9 @@ Do not commit or paste real passwords into this repo.
|
||||
- Port/TLS mode mismatch (587 vs 465)
|
||||
- Account active in mailcow; not rate-limited / fail2banned after bad attempts
|
||||
- Apps that store SMTP in their own config (e.g. Vaultwarden `config.json`) may keep a **stale** password even when `.env` is correct — verify AUTH against the effective config ([vaultwarden-health](vaultwarden-health.md) §5)
|
||||
|
||||
## Safety
|
||||
|
||||
- Do not commit or paste real passwords into this repo or chat.
|
||||
- Use submission (587/465) for client sending; never use port 25 as a desktop/app outbound port.
|
||||
- If live state conflicts with the endpoint values above, `STOP` and report; do not change server-side settings during this reference check.
|
||||
|
||||
@@ -1,12 +1,40 @@
|
||||
# Runbook: mailcow update (mx2)
|
||||
|
||||
## Purpose
|
||||
|
||||
Update the mailcow stack on mx2 to the latest supported release.
|
||||
|
||||
## Scope
|
||||
|
||||
- Applicable: [mx2.windy.me](../hosts/mx2.windy.me.md), `/opt/mail`.
|
||||
- Not applicable: config changes beyond the update, DB migration, secret rotation.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owner: personal ops (Windy)
|
||||
- Last reviewed: 2026-08-17
|
||||
- Related systems: mx2.windy.me (`/opt/mail`)
|
||||
|
||||
## Approval gates
|
||||
|
||||
| Action | Risk | Explicit approval |
|
||||
|---|---|---|
|
||||
| Run `./update.sh` (recreates containers, brief mail interruption) | Medium | Yes — user confirmation required |
|
||||
|
||||
Target: [mx2.windy.me](../hosts/mx2.windy.me.md)
|
||||
Path: `/opt/mail`
|
||||
**Confirm with the user before running an update.**
|
||||
|
||||
## Safety
|
||||
|
||||
- Never run the update without explicit user confirmation.
|
||||
- Never pass secrets into the chat log; do not commit `mailcow.conf`.
|
||||
- If a step fails, capture `docker compose ps` and logs and stop before further changes.
|
||||
- If live state conflicts with this runbook's assumptions (e.g. unexpected `mailcow.conf` values), `STOP` and report.
|
||||
|
||||
## Before
|
||||
|
||||
1. Run [mailcow-health](mailcow-health.md) (or `./scripts/check-mx2.sh`). Record baseline.
|
||||
1. Run [mailcow-health](mailcow-health.md) (Ansible health report). Record baseline.
|
||||
2. Note config quirks in `mailcow.conf`, especially:
|
||||
- `SKIP_CLAMD=y` — after update, `clamd` may still be running until services are recreated per mailcow docs.
|
||||
- `SKIP_LETS_ENCRYPT`, ports, hostname — should stay `mx2.windy.me`.
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
# matrix_e2ee update (hass.windy.lan)
|
||||
|
||||
## Purpose
|
||||
|
||||
Update the custom **`matrix_e2ee`** integration on `hass.windy.lan` while
|
||||
preserving a verified rollback point and confirming that Home Assistant loads it.
|
||||
|
||||
## Scope
|
||||
|
||||
- Applicable: deploying a reviewed `matrix_e2ee` source revision to
|
||||
`hass.windy.lan`.
|
||||
- Not applicable: Home Assistant Core upgrades, integration configuration
|
||||
changes, or recovery without a usable live-tree backup.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owner: personal ops (Windy)
|
||||
- Last reviewed: 2026-08-23
|
||||
- Related systems: hass.windy.lan (HAOS, `machine: green`)
|
||||
|
||||
## Approval gates
|
||||
|
||||
| Action | Risk | Explicit approval |
|
||||
|---|---|---|
|
||||
| Replace the live integration tree and restart Home Assistant Core | Medium | Yes — user confirmation required |
|
||||
|
||||
## Safety
|
||||
|
||||
- Do not replace the live tree or restart Core without explicit user confirmation.
|
||||
- If any precondition or verification fails, `STOP` and record evidence before continuing.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- The source repo at `/home/windy/project/ha-matrix-e2ee` is on the **target
|
||||
state**: either a release tag (`git tag -l 'v*'`) or a commit whose
|
||||
`manifest.json` `version` is the target. Note v0.3.0 was deployed from an
|
||||
**untagged** `main` HEAD (`216cc99`), so the tag check alone is not enough —
|
||||
confirm the working-tree `custom_components/matrix_e2ee/manifest.json`.
|
||||
- The working tree matches HEAD: `git status --short` clean (only ignorables)
|
||||
and `git diff HEAD -- custom_components/` empty. Record
|
||||
`git rev-parse HEAD` for the docs/Linear record — HEAD can move during a
|
||||
session, so re-check right before rsync (verified 2026-08-18: HEAD moved
|
||||
from a `w1n-180` branch merge to `main` mid-deploy).
|
||||
- The remote host is reachable and `sudo -n -i ha core info` succeeds.
|
||||
- The workstation HTTP proxy does not interfere — LAN hosts must be reachable
|
||||
without proxying (unset `http_proxy` / `HTTP_PROXY` if needed).
|
||||
- If the agent sandbox hits `Bad owner or permissions on /etc/ssh/ssh_config.d/20-systemd-ssh-proxy.conf`, add `-F /dev/null` to the `ssh` / `rsync` commands below.
|
||||
- Domain is **`matrix_e2ee`** (double-e). Older notes may say `matrix_e2e`;
|
||||
paths, events, and services all use `matrix_e2ee`.
|
||||
|
||||
## Procedure
|
||||
|
||||
### 1. Backup the live tree
|
||||
|
||||
```bash
|
||||
ssh -o BatchMode=yes hassio@hass.windy.lan \
|
||||
'sudo -n -i mkdir -p /homeassistant/.matrix-e2ee-backups &&
|
||||
sudo -n -i cp -a /homeassistant/custom_components/matrix_e2ee \
|
||||
/homeassistant/.matrix-e2ee-backups/matrix_e2ee.bak-$(date +%Y%m%d)-v<OLD_VERSION>'
|
||||
```
|
||||
|
||||
The backup lives in `/homeassistant/.matrix-e2ee-backups/` — a directory
|
||||
separated from `custom_components/` to avoid HA scanning it as a custom
|
||||
component domain.
|
||||
|
||||
### 2. Rsync the new source
|
||||
|
||||
```bash
|
||||
rsync -a --delete -e 'ssh -o BatchMode=yes' \
|
||||
/home/windy/project/ha-matrix-e2ee/custom_components/matrix_e2ee/ \
|
||||
hassio@hass.windy.lan:/homeassistant/custom_components/matrix_e2ee/
|
||||
```
|
||||
|
||||
The `--delete` cannot remove Core-owned `__pycache__` — that is handled
|
||||
in the next step. Source `.py` files and `manifest.json` are transferred
|
||||
correctly even with the `__pycache__` errors, but **rsync exits with code 23
|
||||
(`some files/attrs were not transferred`)** — that is expected, not a failure.
|
||||
Confirm the transfer by checking the manifest on the host before restarting.
|
||||
|
||||
### 3. Wipe `__pycache__` (as root) and restart Core
|
||||
|
||||
Quote the nested `__pycache__` glob — remote login shell is zsh and will
|
||||
fail with `no matches found` if left unquoted.
|
||||
|
||||
```bash
|
||||
ssh -o BatchMode=yes hassio@hass.windy.lan \
|
||||
"sudo -n -i rm -rf /homeassistant/custom_components/matrix_e2ee/__pycache__ \
|
||||
'/homeassistant/custom_components/matrix_e2ee/*/__pycache__' &&
|
||||
sudo -n -i ha core restart"
|
||||
```
|
||||
|
||||
Stale `cpython-314` bytecode in Core-owned `__pycache__` keeps the old
|
||||
coordinator in memory until restart. Wipe before restart.
|
||||
|
||||
Wait for `Command completed successfully.` (typically 1–2 min).
|
||||
|
||||
### 4. Verify the deployment
|
||||
|
||||
#### 4a. Confirm manifest version
|
||||
|
||||
```bash
|
||||
ssh -o BatchMode=yes hassio@hass.windy.lan \
|
||||
'sudo -n -i cat /homeassistant/custom_components/matrix_e2ee/manifest.json'
|
||||
```
|
||||
|
||||
Expect `"version": "<NEW_VERSION>"`.
|
||||
|
||||
#### 4b. Check Core logs for matrix_e2ee
|
||||
|
||||
```bash
|
||||
ssh -o BatchMode=yes hassio@hass.windy.lan \
|
||||
'sudo -n -i ha core logs -n 2500' | grep -E 'matrix_e2ee|Setting up matrix' | head -20
|
||||
```
|
||||
|
||||
Expect:
|
||||
- `Setup of domain matrix_e2ee took ...` (older wording `Setting up matrix_e2ee` may appear)
|
||||
- `matrix_e2ee restored existing device; user=@hass:chans.xyz device=rO1R915ncu`
|
||||
- No `ERROR` level messages from `custom_components.matrix_e2ee`
|
||||
- Blocking-call WARNINGs from `_patch_nio_sas_timeout` / nio store I/O are expected
|
||||
|
||||
#### 4c. Verify the entry is loaded (optional, via Supervisor API)
|
||||
|
||||
No trailing slash on the entries URL (trailing `/` returns 404 on Core 2026.8.1).
|
||||
|
||||
```bash
|
||||
ssh -o BatchMode=yes hassio@hass.windy.lan \
|
||||
"sudo -n -i python3 - <<'PY'
|
||||
import os, json, urllib.request
|
||||
req = urllib.request.Request(
|
||||
'http://supervisor/core/api/config/config_entries/entry',
|
||||
headers={'Authorization': 'Bearer ' + os.environ['SUPERVISOR_TOKEN']},
|
||||
)
|
||||
entries = json.loads(urllib.request.urlopen(req, timeout=30).read())
|
||||
for e in entries:
|
||||
if e['domain'] == 'matrix_e2ee':
|
||||
print(f\"{e['domain']}: state={e['state']} source={e['source']}\")
|
||||
PY"
|
||||
```
|
||||
|
||||
Expect `state: loaded`.
|
||||
|
||||
### 5. Record the deployment
|
||||
|
||||
- Update the `matrix_e2ee` live-tree section in
|
||||
[hosts/hass.windy.lan.md](../hosts/hass.windy.lan.md): new version, source
|
||||
commit (`git rev-parse HEAD`), backup name, and any new feature notes.
|
||||
- Record the operation in the Linear `vps` project (scope, action,
|
||||
verification, follow-up); see [docs/agents/issue-tracker.md](../docs/agents/issue-tracker.md).
|
||||
|
||||
## Rollback
|
||||
|
||||
If Core fails to start after the update:
|
||||
|
||||
```bash
|
||||
# Restore the backup
|
||||
ssh -o BatchMode=yes hassio@hass.windy.lan \
|
||||
'sudo -n -i rm -rf /homeassistant/custom_components/matrix_e2ee &&
|
||||
sudo -n -i cp -a /homeassistant/.matrix-e2ee-backups/matrix_e2ee.bak-<DATE>-v<OLD_VERSION> \
|
||||
/homeassistant/custom_components/matrix_e2ee &&
|
||||
sudo -n -i rm -rf /homeassistant/custom_components/matrix_e2ee/__pycache__ &&
|
||||
sudo -n -i ha core restart'
|
||||
```
|
||||
|
||||
If a full HA backup exists (pre-update), restore via `ha backups restore <slug>`.
|
||||
|
||||
## References
|
||||
|
||||
- [hosts/hass.windy.lan.md](../hosts/hass.windy.lan.md) — current live version and config
|
||||
- [docs/home-assistant-matrix.md](../docs/home-assistant-matrix.md) — integration architecture and verification model
|
||||
- [home-assistant-maintenance.md](home-assistant-maintenance.md) — general HA maintenance procedures
|
||||
- [ha-matrix-e2ee source](https://github.com/windyboy/ha-matrix-e2ee) — GitHub repo
|
||||
@@ -1,5 +1,20 @@
|
||||
# Matrix Health Check
|
||||
|
||||
## Purpose
|
||||
|
||||
Read-only health check of the Matrix homeserver (ESS on K3s).
|
||||
|
||||
## Scope
|
||||
|
||||
- Applicable: [synapse.chans.xyz](../hosts/synapse.chans.xyz.md), namespace `ess`.
|
||||
- Read-only: does not change pods, ingress, certificates, or configuration.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owner: personal ops (Windy)
|
||||
- Last reviewed: 2026-08-17
|
||||
- Related systems: synapse.chans.xyz (ESS chart `26.7.2`, K3s)
|
||||
|
||||
Monitor the Matrix homeserver running on `synapse.chans.xyz` (ESS chart `26.7.2`, K3s node).
|
||||
|
||||
Prefer `cd ansible && ansible-playbook playbooks/health-report.yml --limit matrix`
|
||||
@@ -90,3 +105,9 @@ Backup automation is currently paused. `/var/backups/matrix/` is retained for a
|
||||
| Well-known returns 404/redirect | Root `chans.xyz` ingress missing or misconfigured |
|
||||
| 502 Bad Gateway | Synapse pod restarting or DB down |
|
||||
| SMTP emails not sent | MAS SMTP config incomplete; TCP reachable but AUTH failing — see `runbooks/vaultwarden-health.md` |
|
||||
|
||||
## Safety
|
||||
|
||||
- Read-only: never mutate pods, ingress, certificates, or configuration during this check.
|
||||
- Backup automation is paused; do not treat `/var/backups/matrix/` as a recovery source.
|
||||
- If live state conflicts with an expected value, `STOP` and report.
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
# Matter packet capture (read-only)
|
||||
|
||||
## Purpose
|
||||
|
||||
Capture Matter-related traffic on LAN55 (mDNS discovery + PASE/CASE commissioning +
|
||||
operational traffic) to determine whether a device is on the network, is in
|
||||
commissioning mode, and whether the commissioning handshake completes. Capture
|
||||
is read-only and changes no device or network state.
|
||||
|
||||
## Scope
|
||||
|
||||
- Environment: LAN55 (`hass.windy.lan`, Aqara M3, ESP32-C2 Matter bulbs,
|
||||
phone / HA matter-server all on the 55 subnet).
|
||||
- Subject: Matter over Wi-Fi and Thread relay nodes. The Thread 802.15.4 air
|
||||
side itself is not capturable — only IPv6 forwarding by a Thread relay such
|
||||
as the M3 is visible.
|
||||
- Not applicable: BLE commissioning, Thread 802.15.4 frames, cross-subnet
|
||||
multicast (66-subnet hosts cannot see the 55 subnet's mDNS — link-local
|
||||
multicast does not cross the routed 55/66 boundary, there is no reflector).
|
||||
- Read-only: no AP/device/network config is modified; state returns to normal
|
||||
when tcpdump exits.
|
||||
|
||||
### Capture-point selection
|
||||
|
||||
Matter commissioning is a two-party conversation and the commissioner
|
||||
participates in every message of it, so capturing on the commissioner host
|
||||
equals capturing the whole flow.
|
||||
|
||||
| Capture point | Sees | Blind spot | Notes |
|
||||
|---|---|---|---|
|
||||
| **hass `end0` — commissioner side (recommended)** | The full HA-driven commissioning conversation: all mDNS queries/announcements (segment multicast) + the complete TCP 5540 PASE/CASE session | Phone-as-commissioner flows (the phone's session to the device does not pass through hass) | `core_matter_server` uses **host networking**, so tcpdump on `end0` sees the add-on's traffic directly; `/` is overlay with ~42 GB free — no 60 MB tmpfs rotation needed |
|
||||
| **UAP-AC-Lite `br0` (192.168.55.5)** | All mDNS multicast (flooded; igmp snooping off) + all wireless-client unicast + unicast to/from the AP | Wired↔wired unicast — e.g. HA↔M3 TCP 5540 while a Thread device commissions via the M3 (wired, observed) — is switched locally and never traverses the AP | AP `/tmp` is a ~60 MB tmpfs → rotating capture is **mandatory** |
|
||||
|
||||
For the common "add device" case with HA matter-server as the commissioner,
|
||||
capture on hass `end0`. Use the AP `br0` point for wireless-device or
|
||||
phone-driven flows (a wireless client's unicast to/from its AP is only visible
|
||||
there).
|
||||
|
||||
A third point, `gw` `switch0`, is **verified as a limited capture point**
|
||||
(cross-subnet/gateway/mDNS flows only — not a full mirror of LAN55) — see
|
||||
[Capture point: gw switch0](#capture-point-gw-switch0).
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owner: personal ops (Windy)
|
||||
- Last reviewed: 2026-08-22
|
||||
- Related systems: UAP-AC-Lite AP `192.168.55.5` (br0), `core_matter_server` on
|
||||
`hass.windy.lan` (`end0`), Aqara M3, ESP32-C2 Matter bulbs
|
||||
|
||||
## Preconditions
|
||||
|
||||
- SSH to the capture point:
|
||||
- AP: `ssh zhiqiangf@192.168.55.5` (key-only, `BatchMode=yes` verified).
|
||||
- hass: `ssh hassio@hass.windy.lan`. Non-interactive SSH does **not** source
|
||||
`.zprofile`, so run tcpdump as `sudo -n -i tcpdump …` (verified 2026-08-22).
|
||||
- tcpdump available:
|
||||
- AP: full 4.9.2 / libpcap 1.8.1 (verified 2026-08-22).
|
||||
- hass: `/usr/bin/tcpdump` via `sudo -n -i` (verified 2026-08-22).
|
||||
- Trigger source ready: put the Matter device into commissioning mode, or have
|
||||
HA/phone perform discovery/commissioning — otherwise no relevant packets.
|
||||
- AP `/tmp` is a ~60 MB tmpfs (61.3 M total, 60.4 M free): rotating capture
|
||||
(`-C`/`-W`) is mandatory on the AP. hass `/` is overlay — rotation optional
|
||||
but keep the habit for long captures.
|
||||
|
||||
## Safety
|
||||
|
||||
### Non-negotiable rules
|
||||
|
||||
- Read-only diagnosis: no installs, config changes, or service restarts on the
|
||||
AP, hass, devices, or network.
|
||||
- pcap files are limited to `/tmp`; pull them off and delete them afterwards
|
||||
(mandatory on the AP; same hygiene on hass).
|
||||
- Never write captured content (including any plaintext key material) into this
|
||||
repository or Linear.
|
||||
|
||||
### Stop conditions
|
||||
|
||||
- Capture point unreachable (ssh fails) → `STOP`, fix the network first.
|
||||
- tcpdump reports "Permission denied" or cannot listen → `STOP` (admin needed;
|
||||
on hass verify `sudo -n -i` works).
|
||||
- Filter expression syntax error → `STOP`, use only expressions verified in
|
||||
this document.
|
||||
- AP `/tmp` nearly full (rotation file count × single-file size ≈ 60 MB) →
|
||||
`STOP` and clean old pcaps.
|
||||
|
||||
## Procedure
|
||||
|
||||
### Step 1 — Choose the capture point
|
||||
|
||||
**Action**
|
||||
|
||||
- HA matter-server is the commissioner (the "add device" case) → hass `end0`.
|
||||
- Wireless device or phone-driven flow → AP `br0`.
|
||||
|
||||
**Expected**
|
||||
|
||||
- The chosen point is reachable and tcpdump starts listening.
|
||||
|
||||
**Decision**
|
||||
|
||||
- Capture point chosen and reachable → Step 2.
|
||||
- Neither applies or the choice is unclear → `STOP` and record why.
|
||||
|
||||
### Step 2 — Realtime observation (quick confirm traffic appears)
|
||||
|
||||
**Action**
|
||||
|
||||
AP:
|
||||
|
||||
```bash
|
||||
ssh zhiqiangf@192.168.55.5 "tcpdump -ni br0 -s 0 -tt 'udp port 5353 or tcp port 5540 or tcp port 5552'"
|
||||
```
|
||||
|
||||
hass (commissioner side):
|
||||
|
||||
```bash
|
||||
ssh hassio@hass.windy.lan "sudo -n -i tcpdump -ni end0 -s 0 -tt 'udp port 5353 or tcp port 5540 or tcp port 5552'"
|
||||
```
|
||||
|
||||
Keep the window open, trigger the device behavior (enter commissioning mode /
|
||||
start commissioning / send a command), `Ctrl+C` to stop.
|
||||
|
||||
**Expected**
|
||||
|
||||
- `_matterc._udp` / `_matter._tcp` mDNS announcements (UDP 5353, multicast
|
||||
`224.0.0.251` / `ff02::fb`).
|
||||
- During commissioning: TCP **5540** (PASE/CASE) SYN/SYN-ACK between the device
|
||||
IP and HA/M3.
|
||||
- If the target device's MAC is known, add `and ether host <mac>` to keep only
|
||||
that device (see variants).
|
||||
- `5552` is not a standard Matter port; it is an observed port for the Aqara M3
|
||||
Thread-relay node (see `docs/matter-pairing-troubleshoot.md`).
|
||||
|
||||
**Decision**
|
||||
|
||||
- Expected packets present → Step 3 to save evidence, or judge directly against
|
||||
the stage table (`docs/matter-pairing-troubleshoot.md` §4).
|
||||
- No packets at all → `STOP`: fix device online / commissioning-mode first; the
|
||||
network side is repeatedly verified healthy (see troubleshooting doc).
|
||||
- mDNS present but no 5540 → see troubleshooting doc decision tree, item 4
|
||||
(§5).
|
||||
|
||||
### Step 3 — Rotating capture + pull to WSL
|
||||
|
||||
**Action** (`-C 5` = rotate every 5 MB, `-W 12` = max 12 files, ≈ 60 MB ≤ AP tmpfs)
|
||||
|
||||
AP:
|
||||
|
||||
```bash
|
||||
ssh zhiqiangf@192.168.55.5 "tcpdump -ni br0 -s 0 -C 5 -W 12 -w /tmp/matter.pcap 'udp port 5353 or tcp port 5540 or tcp port 5552'"
|
||||
```
|
||||
|
||||
hass (rotation optional — overlay disk):
|
||||
|
||||
```bash
|
||||
ssh hassio@hass.windy.lan "sudo -n -i tcpdump -ni end0 -s 0 -C 5 -W 12 -w /tmp/matter.pcap 'udp port 5353 or tcp port 5540 or tcp port 5552'"
|
||||
```
|
||||
|
||||
Trigger the traffic, then `Ctrl+C`. Files are `/tmp/matter.pcap`,
|
||||
`/tmp/matter.pcap1`, …
|
||||
|
||||
**Expected**
|
||||
|
||||
- tcpdump prints capture statistics (`N packets captured`).
|
||||
- `ls -la /tmp/matter.pcap*` shows the files; total stays < 60 MB on the AP.
|
||||
|
||||
**Verification**
|
||||
|
||||
```bash
|
||||
ssh zhiqiangf@192.168.55.5 "ls -la /tmp/matter.pcap*"
|
||||
# or
|
||||
ssh hassio@hass.windy.lan "ls -la /tmp/matter.pcap*"
|
||||
```
|
||||
|
||||
**Pull to WSL for analysis and clean up afterwards**
|
||||
|
||||
```bash
|
||||
scp zhiqiangf@192.168.55.5:/tmp/matter.pcap* .
|
||||
# or
|
||||
scp hassio@hass.windy.lan:/tmp/matter.pcap* .
|
||||
|
||||
# clean up on the capture point
|
||||
ssh zhiqiangf@192.168.55.5 "rm -f /tmp/matter.pcap*"
|
||||
ssh hassio@hass.windy.lan "sudo -n -i rm -f /tmp/matter.pcap*"
|
||||
```
|
||||
|
||||
### Step 4 — Wireshark analysis (optional)
|
||||
|
||||
**Action**
|
||||
|
||||
Open the pcap in Wireshark. mDNS (UDP 5353) is plaintext and directly
|
||||
readable; Matter payloads on TCP/UDP 5540 show only the handshake by default —
|
||||
plaintext needs the dissector plus session keys (Step 5).
|
||||
|
||||
**Expected**
|
||||
|
||||
- `mDNS` filter shows all discovery records; `tcp.port==5540` shows the
|
||||
commissioning handshake.
|
||||
|
||||
### Step 5 — Decrypt Matter plaintext (optional, needs session keys)
|
||||
|
||||
Matter payloads are encrypted (AES-CCM); mDNS plaintext contains no keys. To
|
||||
decrypt, one of:
|
||||
|
||||
1. **Capture-side key leak with a chip tool (most common)**: the commissioner
|
||||
(HA matter-server / chip-tool) prints or exports session keys during
|
||||
commissioning; enter them in Wireshark → Preferences → Protocols → Matter.
|
||||
See [matter-dissector README](https://github.com/project-chip/matter-dissector#security-features).
|
||||
2. **well-known CASE keys**: both sides compiled with
|
||||
`MATTER_CONFIG_SECURITY_TEST_MODE` / `CASEUseKnownECDHKey`; not enabled in
|
||||
this environment (ESP32-C2 + HA official matter-server).
|
||||
|
||||
**Expected**
|
||||
|
||||
- Matter dissector expands protocol headers, IM commands, and cluster content.
|
||||
|
||||
**Stop condition (decryption)**: with no session keys or test keys obtainable,
|
||||
do not fabricate keys to force a decrypt — plaintext mDNS + TCP handshake
|
||||
still resolves most troubleshooting; for plaintext payloads, upgrade to
|
||||
exporting keys on the commissioner side, then return to this runbook.
|
||||
|
||||
## Targeted capture variants
|
||||
|
||||
### One device only (known MAC)
|
||||
|
||||
```bash
|
||||
ssh zhiqiangf@192.168.55.5 "tcpdump -ni br0 -s 0 -tt 'ether host 34:98:7a:27:7f:08 and (udp port 5353 or tcp port 5540 or tcp port 5552)'"
|
||||
```
|
||||
|
||||
MACs from `docs/matter-pairing-troubleshoot.md` §3 (working bulb
|
||||
`34:98:7a:25:a1:f0`, broken bulb `34:98:7a:27:7f:08`).
|
||||
|
||||
### mDNS announcements only (no 5540 noise)
|
||||
|
||||
```bash
|
||||
ssh zhiqiangf@192.168.55.5 "tcpdump -ni br0 -s 0 -tt 'udp port 5353'"
|
||||
```
|
||||
|
||||
### Rotating capture with timestamped filename (multiple runs)
|
||||
|
||||
```bash
|
||||
ssh zhiqiangf@192.168.55.5 "tcpdump -ni br0 -s 0 -C 5 -W 12 -w /tmp/matter-\$(date +%H%M%S).pcap 'udp port 5353 or tcp port 5540 or tcp port 5552'"
|
||||
```
|
||||
|
||||
> The nested `$(date)` must be escaped as `\$(...)` inside the double-quoted ssh
|
||||
> command so the remote shell expands it.
|
||||
|
||||
For the hass point, prefix the same commands with
|
||||
`ssh hassio@hass.windy.lan "sudo -n -i tcpdump -ni end0 …"`.
|
||||
|
||||
## Capture point: gw switch0
|
||||
|
||||
**Status: verified 2026-08-22 — limited capture point; NOT a full mirror of
|
||||
LAN55.**
|
||||
|
||||
- `gw` `switch0` (`eth1`–`eth3`, `192.168.55.254/24`) is LAN55's L2 aggregation
|
||||
only while devices plug directly into the ER-X. EdgeOS ships tcpdump;
|
||||
`tcpdump -ni switch0` follows Linux bridge semantics.
|
||||
- **Live topology (verified 2026-08-22): the SE5420 core switch is deployed**
|
||||
(management `192.168.66.253` up — TP-Link OUI `f8:c9:03`, web UI on
|
||||
:80/:443) and the ER-X uplink is a **single switch0 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. Same-segment wired↔wired unicast switches locally on
|
||||
the SE5420 and never reaches `switch0`.
|
||||
- **What `switch0` still sees:** cross-subnet (66↔55) unicast, traffic to/from
|
||||
the gateway itself (DHCP, DNS forwarding, port-forwards), and LAN55 mDNS
|
||||
multicast (flooded up the uplink). Use it only for those flows; for a full
|
||||
commissioning conversation use the hass `end0` or AP `br0` point instead.
|
||||
- **Full mirror:** only via SE5420 port mirroring (the switch cannot run
|
||||
tcpdump). Not configured; out of scope here.
|
||||
- **Verification commands (EdgeOS v3.0.1 build 5862409):**
|
||||
- Interactive: `ssh ubnt@192.168.66.254` (or `zhiqiang`), then
|
||||
`show interfaces ethernet` — port link states are the decisive check
|
||||
(`eth1` up + `eth2`/`eth3` down = single uplink). `configure` (config
|
||||
mode) also accepts `show ...`.
|
||||
- Non-interactive (agent/script): `show`/`configure` are interactive-only
|
||||
aliases on this build; use the op wrapper:
|
||||
```bash
|
||||
ssh ubnt@192.168.66.254 '/opt/vyatta/bin/vyatta-op-cmd-wrapper show interfaces ethernet'
|
||||
```
|
||||
- `show ethernet-switch port all` and `show mac-address-table` are NOT
|
||||
available on this build; the switch FDB is hardware-offloaded
|
||||
(`brctl showmacs switch0` → "Operation not supported"). Port link state
|
||||
+ ARP (`show arp`) are the reliable checks.
|
||||
- SE5420 liveness: `ping 192.168.66.253` and `:80/:443`.
|
||||
- Sample capture at this point (cross-segment/gateway/mDNS flows only;
|
||||
tcpdump needs root — `zhiqiang` has passwordless sudo):
|
||||
```bash
|
||||
ssh zhiqiang@192.168.66.254 "sudo -n tcpdump -ni switch0 -s 0 'udp port 5353 or tcp port 5540 or tcp port 5552'"
|
||||
```
|
||||
|
||||
## Pass criteria
|
||||
|
||||
- Realtime capture consistently shows the target device's mDNS announcements
|
||||
(`_matterc` / `_matter._tcp`) on the chosen point.
|
||||
- Commissioning shows the TCP 5540 handshake (SYN/SYN-ACK/ACK); on the hass
|
||||
`end0` point this includes wired Thread-relay commissioning (HA↔M3), which
|
||||
the AP point cannot see.
|
||||
- Saved pcap opens in Wireshark and filters by `mDNS` / `tcp.port==5540`.
|
||||
|
||||
## References
|
||||
|
||||
- [docs/matter-pairing-troubleshoot.md](../docs/matter-pairing-troubleshoot.md) —
|
||||
troubleshooting decision tree, stage table, device MAC/fabric facts
|
||||
- [docs/unifi-network.md](../docs/unifi-network.md) — UniFi network/IPv6/SSID records
|
||||
- [hosts/hass.windy.lan.md](../hosts/hass.windy.lan.md) — matter-server host
|
||||
networking + `sudo -n -i` non-interactive note
|
||||
- [hosts/gw.md](../hosts/gw.md) — DHCP `matter` reservation MAC mismatch (pending, W1N-207)
|
||||
- [matter-dissector](https://github.com/project-chip/matter-dissector) —
|
||||
Wireshark Matter dissector (incl. decryption)
|
||||
- [Silabs: Using Wireshark to Capture Network Traffic in Matter](https://docs.silabs.com/matter/2.9.1/matter-references/matter-wireshark)
|
||||
@@ -0,0 +1,74 @@
|
||||
# Runbook: controlled network change
|
||||
|
||||
## Purpose
|
||||
|
||||
Apply a controlled network configuration change (DNS records, firewall, LAN
|
||||
gateway, VLAN) with impact assessment, approval, and a rollback path.
|
||||
|
||||
## Scope
|
||||
|
||||
- Applicable: PowerDNS zone records, `us4` firewalld allowlist, LAN gateway/VLAN/DNS changes, WireGuard.
|
||||
- Not applicable: SSH access-policy changes (see `AGENTS.md` §SSH access safety — mandatory lockout-risk procedure).
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owner: personal ops (Windy)
|
||||
- Last reviewed: 2026-08-17
|
||||
- Related systems: PowerDNS / us4 firewalld / LAN gateway / WireGuard
|
||||
|
||||
## Preconditions
|
||||
|
||||
- A change record (Linear `vps` issue) describes the change, its reason, and rollback.
|
||||
- Read-only impact assessment done (current config captured, blast radius known).
|
||||
|
||||
## Safety
|
||||
|
||||
- Never change DNS or network config without a change record and approval.
|
||||
- Capture the current config first; never delete existing config as the first action.
|
||||
- For DNS: record the current record values and TTL before editing.
|
||||
- For firewall: retain an independent SSH rollback session before applying (see `ansible-operations.md` §us4).
|
||||
|
||||
## Procedure
|
||||
|
||||
### Step 1 — Assess and capture
|
||||
|
||||
**Action** — capture the current state (e.g. `dig` for DNS, `--check --diff` for firewall, `show` for gateway).
|
||||
|
||||
**Expected** — a baseline of current config and an identified blast radius.
|
||||
|
||||
**Decision** — change fully specified with rollback → Step 2; missing → `STOP`.
|
||||
|
||||
### Step 2 — Approve
|
||||
|
||||
**Action** — confirm approval is recorded in the issue/change record.
|
||||
|
||||
**Decision** — approved → Step 3; not approved → `STOP`.
|
||||
|
||||
### Step 3 — Change
|
||||
|
||||
**Action** — apply the single change (edit the record, run the gated playbook, or change gateway config) and only that change.
|
||||
|
||||
**Expected** — the new value/state is in effect.
|
||||
|
||||
**Verification** — re-query/verify the new state and confirm dependent services still pass health.
|
||||
|
||||
**Rollback** — restore the captured prior config and re-verify.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Troubleshooting A — Change broke dependent service
|
||||
|
||||
- Evidence: health report / endpoint failure.
|
||||
- Allowed: roll back to the captured prior config.
|
||||
- Next: verify; if still broken, escalate.
|
||||
|
||||
## Final Verification
|
||||
|
||||
- New state verified; dependent services healthy.
|
||||
- Change and outcome recorded in the issue.
|
||||
|
||||
## References
|
||||
|
||||
- [`ansible-operations.md`](ansible-operations.md)
|
||||
- [`rollback.md`](rollback.md)
|
||||
- [`network-recovery.md`](network-recovery.md)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user