Author SHA1 Message Date
windyboy b5617fd3a9 docs(ha): add Home Assistant maintenance runbook + ha CLI script (W1N-69)
- runbooks/home-assistant-maintenance.md: access pattern (sudo -n -i ha),
  command reference verified on host, recovery ops, families not scripted,
  docs-vs-CLI discrepancies
- runbooks/scripts/ha-maintenance.sh: read-only health/logs + --yes-gated
  update/restart/rebuild/rollback/reboot/backup/restore/app modes
- AGENTS.md: register runbook in table; hosts/hass: access pattern + link
2026-08-13 17:54:33 +08:00
windyboy f5842568b9 docs: add Home Assistant API access notes 2026-08-13 17:20:49 +08:00
windyboy 6f8a4918f0 feat(ednsdiag): support custom DoH endpoint via --url
Allow overriding the provider preset with an explicit HTTPS DoH URL,
including validation that custom endpoints apply only to DoH queries.
2026-08-13 15:34:54 +08:00
windyboy 5b0f7950e6 docs: add hass.windy.lan Home Assistant host documentation
Document HAOS on PVE VM 180 (LAN55) with verified SSH access,
network details, and cross-links from lan-overview, inventory,
and AGENTS quick map.
2026-08-13 14:10:39 +08:00
windyboy c0cf82d4af tools(skills): add encrypted-dns-skill (ednsdiag CLI + agent skill) 2026-08-13 12:52:20 +08:00
windyboy 95ec2350af docs(dns): record mosdns foreign DoH multi-upstream redundancy (W1N-62) 2026-08-13 10:56:40 +08:00
windyboy 3de4beb028 docs: add low-volume mono laser MFP buying guide (2026-08)
Decision tree for occasional B&W laser MFP purchases: Brother L1638W/L1848W
as default, cloud-subscription models as opt-in only, and one-veto checks
for AirPrint, Ethernet, and duplex/ADF needs.
2026-08-13 10:54:29 +08:00
windyboy d54ec71aea docs(dns): record gfw foreign branch DoH change (W1N-62)
Document encrypted DoH upstream for mosdns foreign queries and note that
DoH traffic goes direct to hk2, not via OpenClash proxy.
2026-08-13 10:50:16 +08:00
windyboy 2ffd9f9f9c docs(se5420): align review claim verification with current guide (W1N-63)
Add historical snapshot header (baseline 35577d0), rewrite outdated
"current guide" assertions for post-ffb37a9 revisions, and add a
12-row status table mapping review claims to current §4.3/§11 sections.
2026-08-13 10:50:16 +08:00
windyboy f255785b72 docs(se5420): add review claim verification record (2026-08-10)
Documents which deployment-guide review claims are confirmed by specs,
field read-only checks on gfw, and remaining pre-change evidence needs.
2026-08-13 10:12:06 +08:00
windyboy 2fd354c2a9 docs(dns): record mosdns fallback hardening for AGH outage (W1N-56) 2026-08-12 22:24:54 +08:00
windyboy 82203038f0 docs(dns): record mosdns sequence misconfig found+fixed (W1N-56) 2026-08-12 22:19:20 +08:00
windyboy 096e1ce8b6 docs(dns): correct idle-mosdns premise in alternatives research (W1N-56) 2026-08-12 22:12:32 +08:00
windyboy 8550053287 docs(dns): record Phase 0 verification evidence + final decision alignment (W1N-56) 2026-08-12 22:11:53 +08:00
windyboy e501b93d65 docs(dns): correct gfw/.1 facts (W1N-56)
- hosts/gfw.windy.lan.md: 3 NICs (eth2/VLAN10 ubunt_upg live), mosdns is
  now OpenClash's nameserver (not idle), rewrite VLAN10 Wi-Fi section to
  live-verified state
- docs/lan-dns-architecture.md: mosdns on gfw no longer 闲置; note the
  recommended AGH+.36 companion architecture is still pending review
2026-08-12 22:11:53 +08:00
windyboy 62b8fbb8b7 docs(dns): add LAN DNS architecture research + recommendation (W1N-56) 2026-08-12 22:11:53 +08:00
windyboy efa6cf0899 docs(dns): record agh_ui_access LAN55 allow for Home Assistant (2026-08-12) 2026-08-12 22:11:53 +08:00
windyboy 086740b16e docs: record pdns PDA removal, us4 firewalld ops, LAN DNS alternatives
- runbooks/pdns-health.md: note the legacy powerdns-admin (PDA) orphan was
  removed 2026-08-12 (W1N-59).
- runbooks/ansible-operations.md: document the us4 firewalld reconciliation
  playbook scope (audited public zone only, fail-closed, no reload).
- docs/agents/domain.md: single-context repo layout for domain docs.
- docs/lan-dns-alternatives.md: notes on LAN DNS alternatives.
- .gitignore: exclude local agent-harness config (.agents/ .claude/ .omp/
  .mcp.json WATCHDOG.yml skills-lock.json) from the repo.
2026-08-12 21:16:31 +08:00
windyboy 1f6d028ab5 feat(us4): firewall audit + safe reconciliation playbook, host doc
Add playbooks/us4-firewalld.yml, a narrow reconciliation of the audited us4
public zone: fails closed on drift or unknown allowances, never reloads or
restarts firewalld, and does not manage Docker rules. Requires explicit
apply + provider-console confirmations, backs up the firewalld config and
ruleset, schedules an automatic 15-minute rollback via at, and verifies SSH,
HTTPS routes, containers, Fail2ban jails, and the WireGuard health check before
cancelling rollback. Pins ansible.posix 2.2.2 in requirements.yml.

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

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

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

Contract: a single complete check keeps the historical verbatim latest.json
shape; several checks produce a worst-status aggregate retaining every check's
detail. A profile that crashes before reporting is aggregated as unknown so
latest.json can never go stale while the dispatcher fails. The dispatcher exits
with the worst (max) profile exit code.
2026-08-12 21:16:30 +08:00
windyboy baca89be83 docs(hk2): document Traefik dashboard auth and password rotation 2026-08-12 21:16:30 +08:00
windyboy 562de68127 docs: record VLAN10 live verification (DHCP lease, NAT counters, isolation) 2026-08-11 19:36:20 +08:00
windyboy 86bfb5a953 docs: record SE5420 VLAN10 deployment pitfalls (trunk PVID, fw4 dest=lan, minimal gfw migration) 2026-08-11 19:22:22 +08:00
windyboy 3ec51299cc docs: note VLAN1 undeletable on SE5420; disable unused ports first 2026-08-11 13:13:33 +08:00
windyboy 7a54e8d9ec docs: align SE5420 guide with actual Chinese GUI menus and port layout 2026-08-11 12:59:46 +08:00
windyboy 7b4c48eb93 docs: refine SE5420 upgrade guide into granular step-by-step ops 2026-08-11 11:07:25 +08:00
windyboy ffb37a916e docs: finalize SE5420 deployment guide per review
- Reorder §3.3: create VLAN66/55 before SVI; keep VLAN1 until management path verified
- Fix §3.4 verification port wording (enabled VLAN66 access port, e.g. port 6)
- Harden VLAN10 phase: PVE boundary, gfw NAT/IPv6 policy, rollback order, verification chain
2026-08-10 11:00:28 +08:00
windyboy 35577d0c5c docs: add SE5420 core-switch deployment plans; note PVE VM topology 2026-08-09 21:22:02 +08:00
windyboy e90eef9647 docs: record UniFi SSO login change and Ansible management (W1N-51) 2026-08-08 09:17:55 +08:00
windyboy 14a758a28a feat: manage UniFi SSO login setting via Ansible (W1N-51) 2026-08-08 09:15:49 +08:00
windyboy c386fe8136 docs: record gfw OpenClash core v1.19.29 upgrade (W1N-50) 2026-08-06 16:45:11 +08:00
windyboy e2884069a8 docs: add live-verified LAN overview; refresh gfw facts 2026-08-06 16:22:20 +08:00
windyboy b03d7019c4 docs: onboard AdGuard Home to Ansible; retire check-mx2; migrate plan to Linear
- Add dns.windy.lan to Ansible inventory (adguardhome group/profile) with a
  read-only healthcheck template, systemd timer, and on-demand report parity
  with the other active hosts; verified green on the host.
- Add synapse.chans.xyz to the AGENTS.md active-host quick map.
- Remove plans/; migrate decisions/verification to a Linear vps project doc and
  file the remaining gaps as W1N-46..49 with blocking edges.
- Retire scripts/check-mx2.sh; point mailcow health/update runbooks and the
  mx2 host file at the Ansible health report.
- Trim us2 stub software to verified running services; note dormant dirs.
- Normalize repo docs to English; drop the non-existent CONTEXT.md/ADR claim
  and remove docs/agents/domain.md.
2026-08-06 15:48:48 +08:00
windyboy 29bdf06383 Document network operations and WireGuard maintenance 2026-08-06 10:20:43 +08:00
93 changed files with 8504 additions and 315 deletions
@@ -0,0 +1,52 @@
name: CI
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
quality:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Check formatting
run: |
files="$(gofmt -l .)"
if [ -n "$files" ]; then
echo "Unformatted Go files:"
echo "$files"
exit 1
fi
- name: Check module files
run: go mod tidy && git diff --exit-code
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Resolve modules
run: go mod download
- name: Test
run: go test ./...
- name: Vet
run: go vet ./...
@@ -0,0 +1,10 @@
# Go build outputs
/bin/
/dist/
*.exe
*.test
*.out
# Local development
.env
.DS_Store
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@@ -0,0 +1,159 @@
# Encrypted DNS Skill
[![CI](https://github.com/windyboy/encrypted-dns-skill/actions/workflows/ci.yml/badge.svg)](https://github.com/windyboy/encrypted-dns-skill/actions/workflows/ci.yml)
An [Agent Skill](https://agentskills.io/specification) and deterministic Go CLI
for querying and diagnosing encrypted DNS resolvers.
The skill tells an agent when and how to perform encrypted DNS diagnostics;
`ednsdiag` performs the protocol exchange. Agents do not need to construct DoH
URLs, TLS sessions, or DNS wire messages themselves.
## Status
| Protocol | Status | Standard |
| --- | --- | --- |
| DNS over HTTPS (DoH) | Available (GET and POST) | [RFC 8484](https://www.rfc-editor.org/rfc/rfc8484.html) |
| DNS over TLS (DoT) | Available (strict authentication) | [RFC 7858](https://www.rfc-editor.org/rfc/rfc7858.html), [RFC 8310](https://www.rfc-editor.org/rfc/rfc8310.html) |
| DNS over QUIC (DoQ) | Planned | [RFC 9250](https://www.rfc-editor.org/rfc/rfc9250.html) |
| DoH over HTTP/3 (DoH3) | Planned | RFC 8484 over HTTP/3 |
| DNSCrypt | Planned | [DNSCrypt protocol specification](https://github.com/DNSCrypt/dnscrypt-protocol) |
| Oblivious DoH (ODoH) | Research | [RFC 9230](https://www.rfc-editor.org/rfc/rfc9230.html) |
| Anonymized DNSCrypt | Research | [Anonymized DNSCrypt specification](https://github.com/DNSCrypt/dnscrypt-protocol/blob/master/ANONYMIZED-DNSCRYPT.txt) |
Run `ednsdiag capabilities` instead of assuming a protocol is implemented.
## Why a Skill and a CLI?
- `SKILL.md` provides compact instructions, safety boundaries, and result
interpretation for an AI agent.
- `ednsdiag` provides repeatable wire-format DNS, HTTP, TLS, input validation,
and structured JSON output.
- Reference files keep protocol, provider, and security details grounded in
authoritative sources without bloating the agent's active context.
The CLI never silently downgrades to plaintext DNS, and it does not connect to
addresses returned in DNS answers.
## Requirements
- Go 1.26 or later when running or building from source
- Network access to the selected encrypted DNS resolver
- A host that supports the [Agent Skills package format](https://agentskills.io/specification) when using the repository as a Skill
## Install the Skill
Clone or copy this repository into a skill discovery directory supported by
your agent host. Keep the repository layout intact so `SKILL.md`, `references/`,
`schemas/`, and the Go source remain together.
For example, in a host that discovers project-local skills from `.agents/skills`:
```bash
git clone https://github.com/windyboy/encrypted-dns-skill.git \
.agents/skills/encrypted-dns-skill
```
Discovery paths differ between hosts. Follow the host's documentation rather
than moving only `SKILL.md`.
## Run from Source
No precompiled executable is required. Go can compile and run the command from
the repository root:
```bash
go run ./cmd/ednsdiag capabilities
go run ./cmd/ednsdiag query example.com A --protocol doh --provider cloudflare
go run ./cmd/ednsdiag query gmail.com MX --protocol dot --provider google --timeout 5s
```
The first run may download the modules pinned in `go.mod` and `go.sum`.
To build a reusable local executable:
```bash
go build -o ./bin/ednsdiag ./cmd/ednsdiag
./bin/ednsdiag capabilities
```
Do not download or execute an unverified third-party binary. This repository
does not currently publish release binaries.
## Usage
```text
ednsdiag capabilities
ednsdiag version
ednsdiag query <domain> [type] \
[--protocol doh|dot] \
[--provider cloudflare|google|quad9|adguard] \
[--method post|get] \
[--timeout 5s]
```
Defaults are `A`, `doh`, `cloudflare`, `post`, and `5s`. `--method` applies
only to DoH. The timeout must be between `250ms` and `30s`.
Built-in resolver profiles:
| Provider | Profile |
| --- | --- |
| Cloudflare | Unfiltered |
| Google | Unfiltered |
| Quad9 | Security-filtered |
| AdGuard | Ad- and security-filtered |
Filtering policies can affect DNS answers. Results always identify the
provider and profile used.
## Result Semantics
Every query returns structured JSON compatible with
[`schemas/result-v1.schema.json`](schemas/result-v1.schema.json).
- `completed: true` means the encrypted protocol exchange completed; it does
not mean the DNS response was `NOERROR`.
- `dns.rcode` is the DNS result. `NXDOMAIN`, `SERVFAIL`, and `REFUSED` are DNS
outcomes, not transport failures.
- Empty `dns.answers` with `NOERROR` means NODATA.
- `transport.server_authenticated` reports resolver endpoint authentication.
- `dns.resolver_reports_dnssec_authenticated` reflects the resolver's AD bit;
it is not local DNSSEC validation.
- `transport.bootstrap: system_resolver` means the operating system resolver
was used to locate the encrypted resolver endpoint.
## Security Model
- DoH uses standard `application/dns-message` wire messages.
- DoT verifies the PKIX certificate chain and configured authentication domain.
- Plaintext fallback is prohibited.
- DNS errors are not retried through another protocol as transport failures.
- Provider and protocol results remain separate.
- DNS answers are data only; the tool does not make application connections to
returned addresses.
See [`references/security.md`](references/security.md) for the complete threat
model and privacy boundaries.
## Development
```bash
go test ./...
go vet ./...
```
Protocol behavior must remain aligned with
[`references/standards.md`](references/standards.md), provider changes with
[`references/providers.md`](references/providers.md), and output with the v1
JSON schema.
## Scope
This project targets client-to-recursive encrypted DNS diagnostics. It is not a
system stub resolver, an authoritative DNS server, a hosted DNS record manager,
or a zone-transfer tool.
## License
Licensed under the [Apache License 2.0](LICENSE).
@@ -0,0 +1,90 @@
---
name: encrypted-dns-skill
description: Query, probe, and compare DNS resolution through supported encrypted transports. Use for encrypted DNS record lookups, resolver connectivity tests, TLS and QUIC diagnostics, protocol comparisons, DNSSEC status inspection, and troubleshooting DoH, DoT, DoQ, DoH3, or DNSCrypt resolver endpoints.
---
# Encrypted DNS Diagnostics
Use `ednsdiag` for encrypted DNS work. Do not assemble protocol requests with
`curl`, `openssl`, or ad-hoc scripts when `ednsdiag` supports the operation.
The executable requires network access.
Prefer an installed `ednsdiag` executable. When it is unavailable and Go 1.26+
is installed, run the source from the skill root with:
```bash
go run ./cmd/ednsdiag <command> [arguments]
```
Do not download or execute an unverified binary automatically. Building from
source may require permission to download pinned Go modules.
## Check capabilities
Before attempting an operation, run:
```bash
ednsdiag capabilities
```
Only use a protocol when its reported status is `available`. Never describe a
`planned` or `experimental` capability as implemented.
## Commands
```bash
ednsdiag query example.com A --protocol doh --provider cloudflare
ednsdiag query gmail.com MX --protocol dot --provider google --timeout 5s
ednsdiag capabilities
ednsdiag version
```
Use `--method get` or `--method post` only with DoH. The default is POST.
Built-in providers are `cloudflare`, `google`, `quad9`, and `adguard`. Provider
filtering policies differ and are included in the result. `probe` and `compare`
remain reserved until their capabilities are implemented.
## Required behavior
- Use standard DNS wire messages for DoH, not provider-specific JSON APIs.
- Apply strict certificate and authentication-domain validation.
- Never silently downgrade to plaintext DNS.
- Do not retry `NXDOMAIN`, `NODATA`, `SERVFAIL`, or `REFUSED` through another
protocol as though they were transport failures.
- Keep results from different providers and protocols separate.
- Report every fallback attempt and its reason.
- Treat the DNS `AD` bit as validation reported by the selected resolver, not
as local DNSSEC validation.
- Do not connect to addresses returned in DNS answers.
## Result interpretation
- `completed: true` means a protocol exchange completed. It does not imply
`NOERROR`.
- Read `dns.rcode` for the DNS outcome.
- Read `transport.server_authenticated` separately from DNSSEC fields.
- Read `transport.bootstrap`; `system_resolver` means resolving the encrypted
resolver endpoint itself used the operating system resolver.
- Empty answers with `NOERROR` represent NODATA.
- A filtering resolver may synthesize `NXDOMAIN`; disclose the provider.
## References
- Read [references/standards.md](references/standards.md) before changing
protocol behavior.
- Read [references/security.md](references/security.md) before changing TLS,
bootstrap, fallback, endpoint, or privacy behavior.
- Read [references/providers.md](references/providers.md) before adding or
modifying a built-in provider.
- Keep output compatible with
[schemas/result-v1.schema.json](schemas/result-v1.schema.json).
## Scope
The target scope is widely deployed client-to-recursive encrypted DNS:
DoH, DoT, DoQ, DoH3, and DNSCrypt. ODoH and Anonymized DNSCrypt remain
research capabilities until explicitly marked available.
Do not use this skill for DNS-over-DTLS, zone transfers, authoritative-server
operation, changing hosted DNS records, or replacing the operating system's
stub resolver.
@@ -0,0 +1,184 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"time"
"github.com/windyboy/encrypted-dns-skill/internal/edns"
)
const version = "0.1.0-dev"
type capability struct {
Protocol string `json:"protocol"`
Status string `json:"status"`
Standard string `json:"standard,omitempty"`
Note string `json:"note,omitempty"`
}
type capabilitiesResult struct {
SchemaVersion int `json:"schema_version"`
Command string `json:"command"`
Version string `json:"version"`
Capabilities []capability `json:"capabilities"`
}
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
}
func run(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
writeUsage(stderr)
return 2
}
switch args[0] {
case "capabilities":
if len(args) != 1 {
fmt.Fprintln(stderr, "capabilities does not accept arguments")
return 2
}
result := capabilitiesResult{
SchemaVersion: 1,
Command: "capabilities",
Version: version,
Capabilities: []capability{
{Protocol: "doh", Status: "available", Standard: "RFC 8484", Note: "RFC wire format over HTTP GET or POST"},
{Protocol: "dot", Status: "available", Standard: "RFC 7858 and RFC 8310", Note: "strict PKIX and authentication-domain validation"},
{Protocol: "doq", Status: "planned", Standard: "RFC 9250"},
{Protocol: "doh3", Status: "planned", Standard: "RFC 8484 over HTTP/3"},
{Protocol: "dnscrypt", Status: "planned", Standard: "DNSCrypt protocol specification"},
{Protocol: "odoh", Status: "research", Standard: "RFC 9230", Note: "No maintained Go dependency has been selected."},
{Protocol: "anonymized-dnscrypt", Status: "research", Standard: "Anonymized DNSCrypt specification"},
},
}
return writeJSON(stdout, stderr, result)
case "version":
if len(args) != 1 {
fmt.Fprintln(stderr, "version does not accept arguments")
return 2
}
fmt.Fprintln(stdout, version)
return 0
case "query":
options, timeout, err := parseQueryArgs(args[1:])
if err != nil {
fmt.Fprintln(stderr, err)
writeQueryUsage(stderr)
return 2
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
result := edns.Query(ctx, options)
if code := writeJSON(stdout, stderr, result); code != 0 {
return code
}
if result.Completed {
return 0
}
if result.Error != nil && result.Error.Class == "input" {
return 2
}
return 3
case "probe", "compare":
fmt.Fprintf(stderr, "%s is not implemented in %s; run ednsdiag capabilities\n", args[0], version)
return 4
default:
fmt.Fprintf(stderr, "unknown command %q\n", args[0])
writeUsage(stderr)
return 2
}
}
func parseQueryArgs(args []string) (edns.QueryOptions, time.Duration, error) {
options := edns.QueryOptions{
RecordType: "A",
Protocol: "doh",
Provider: "cloudflare",
Method: "post",
}
timeout := 5 * time.Second
positionals := make([]string, 0, 2)
for index := 0; index < len(args); index++ {
argument := args[index]
if !strings.HasPrefix(argument, "--") {
positionals = append(positionals, argument)
continue
}
key, value, found := strings.Cut(strings.TrimPrefix(argument, "--"), "=")
if !found {
index++
if index >= len(args) {
return options, 0, fmt.Errorf("--%s requires a value", key)
}
value = args[index]
}
switch key {
case "protocol":
options.Protocol = strings.ToLower(value)
case "provider":
options.Provider = strings.ToLower(value)
case "url":
options.EndpointURL = value
case "method":
options.Method = strings.ToLower(value)
case "timeout":
parsed, err := time.ParseDuration(value)
if err != nil {
return options, 0, fmt.Errorf("invalid timeout %q: %w", value, err)
}
timeout = parsed
default:
return options, 0, fmt.Errorf("unknown query option --%s", key)
}
}
if len(positionals) < 1 || len(positionals) > 2 {
return options, 0, fmt.Errorf("query requires a domain and optional record type")
}
if timeout < 250*time.Millisecond || timeout > 30*time.Second {
return options, 0, fmt.Errorf("timeout must be between 250ms and 30s")
}
options.Name = positionals[0]
if len(positionals) == 2 {
options.RecordType = strings.ToUpper(positionals[1])
}
if options.Protocol != "doh" && options.Protocol != "dot" {
return options, 0, fmt.Errorf("protocol %q is not available", options.Protocol)
}
if options.Method != "get" && options.Method != "post" {
return options, 0, fmt.Errorf("DoH method must be get or post")
}
if options.Protocol == "dot" && options.Method != "post" {
return options, 0, fmt.Errorf("--method applies only to DoH")
}
return options, timeout, nil
}
func writeJSON(stdout, stderr io.Writer, value any) int {
encoder := json.NewEncoder(stdout)
encoder.SetIndent("", " ")
if err := encoder.Encode(value); err != nil {
fmt.Fprintf(stderr, "encode JSON result: %v\n", err)
return 1
}
return 0
}
func writeUsage(writer io.Writer) {
fmt.Fprintln(writer, "usage: ednsdiag <capabilities|version|query|probe|compare>")
}
func writeQueryUsage(writer io.Writer) {
fmt.Fprintln(writer, "usage: ednsdiag query <domain> [type] [--protocol doh|dot] [--provider cloudflare|google|quad9|adguard] [--url https://host/dns-query] [--method post|get] [--timeout 5s]")
}
@@ -0,0 +1,82 @@
package main
import (
"bytes"
"encoding/json"
"strings"
"testing"
"time"
)
func TestCapabilities(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := run([]string{"capabilities"}, &stdout, &stderr)
if code != 0 {
t.Fatalf("run capabilities returned %d; stderr=%q", code, stderr.String())
}
var result capabilitiesResult
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
t.Fatalf("decode capabilities: %v", err)
}
if result.SchemaVersion != 1 {
t.Fatalf("schema version = %d, want 1", result.SchemaVersion)
}
if result.Command != "capabilities" {
t.Fatalf("command = %q, want capabilities", result.Command)
}
if len(result.Capabilities) == 0 {
t.Fatal("capabilities list is empty")
}
available := map[string]bool{}
for _, item := range result.Capabilities {
available[item.Protocol] = item.Status == "available"
}
if !available["doh"] || !available["dot"] {
t.Fatalf("DoH and DoT must be available: %#v", available)
}
if available["doq"] || available["doh3"] || available["dnscrypt"] {
t.Fatalf("planned transports must not be available: %#v", available)
}
}
func TestReservedCommandIsNotImplemented(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := run([]string{"probe"}, &stdout, &stderr)
if code != 4 {
t.Fatalf("run probe returned %d, want 4", code)
}
if !strings.Contains(stderr.String(), "not implemented") {
t.Fatalf("stderr = %q, want not implemented message", stderr.String())
}
}
func TestParseQueryArgsAllowsInterspersedOptions(t *testing.T) {
options, timeout, err := parseQueryArgs([]string{"example.com", "MX", "--protocol", "dot", "--provider=quad9", "--timeout", "3s"})
if err != nil {
t.Fatalf("parse query args: %v", err)
}
if options.Name != "example.com" || options.RecordType != "MX" || options.Protocol != "dot" || options.Provider != "quad9" {
t.Fatalf("unexpected options: %#v", options)
}
if timeout != 3*time.Second {
t.Fatalf("timeout = %v, want 3s", timeout)
}
}
func TestUnknownCommand(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
code := run([]string{"unknown"}, &stdout, &stderr)
if code != 2 {
t.Fatalf("run unknown returned %d, want 2", code)
}
if !strings.Contains(stderr.String(), "unknown command") {
t.Fatalf("stderr = %q, want unknown command message", stderr.String())
}
}
@@ -0,0 +1,7 @@
module github.com/windyboy/encrypted-dns-skill
go 1.26.0
require golang.org/x/net v0.58.0
require golang.org/x/text v0.41.0 // indirect
@@ -0,0 +1,4 @@
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
@@ -0,0 +1,229 @@
package edns
import (
"crypto/rand"
"encoding/base64"
"encoding/binary"
"fmt"
"net"
"strings"
"golang.org/x/net/dns/dnsmessage"
"golang.org/x/net/idna"
)
var recordTypes = map[string]dnsmessage.Type{
"A": dnsmessage.TypeA,
"AAAA": dnsmessage.TypeAAAA,
"CNAME": dnsmessage.TypeCNAME,
"MX": dnsmessage.TypeMX,
"TXT": dnsmessage.TypeTXT,
"NS": dnsmessage.TypeNS,
"SOA": dnsmessage.TypeSOA,
"CAA": dnsmessage.Type(257),
"SRV": dnsmessage.TypeSRV,
"SVCB": dnsmessage.TypeSVCB,
"HTTPS": dnsmessage.TypeHTTPS,
}
func BuildQuery(name, recordType string) ([]byte, QueryInfo, uint16, error) {
canonical, err := canonicalName(name)
if err != nil {
return nil, QueryInfo{}, 0, err
}
typeName := strings.ToUpper(recordType)
qtype, ok := recordTypes[typeName]
if !ok {
return nil, QueryInfo{}, 0, fmt.Errorf("unsupported record type %q", recordType)
}
dnsName, err := dnsmessage.NewName(canonical + ".")
if err != nil {
return nil, QueryInfo{}, 0, fmt.Errorf("encode domain name: %w", err)
}
var randomID [2]byte
if _, err := rand.Read(randomID[:]); err != nil {
return nil, QueryInfo{}, 0, fmt.Errorf("generate DNS transaction ID: %w", err)
}
id := binary.BigEndian.Uint16(randomID[:])
message := dnsmessage.Message{
Header: dnsmessage.Header{ID: id, RecursionDesired: true},
Questions: []dnsmessage.Question{{
Name: dnsName,
Type: qtype,
Class: dnsmessage.ClassINET,
}},
}
wire, err := message.Pack()
if err != nil {
return nil, QueryInfo{}, 0, fmt.Errorf("pack DNS query: %w", err)
}
return wire, QueryInfo{Name: canonical, Type: typeName}, id, nil
}
func ParseResponse(wire []byte, expectedID uint16, query QueryInfo) (DNSInfo, error) {
var message dnsmessage.Message
if err := message.Unpack(wire); err != nil {
return DNSInfo{}, fmt.Errorf("unpack DNS response: %w", err)
}
if !message.Header.Response {
return DNSInfo{}, fmt.Errorf("received a DNS query instead of a response")
}
if message.Header.ID != expectedID {
return DNSInfo{}, fmt.Errorf("DNS transaction ID mismatch")
}
if len(message.Questions) != 1 {
return DNSInfo{}, fmt.Errorf("DNS response contains %d questions, want 1", len(message.Questions))
}
wantType := recordTypes[query.Type]
question := message.Questions[0]
if trimRoot(question.Name.String()) != query.Name || question.Type != wantType {
return DNSInfo{}, fmt.Errorf("DNS response question does not match request")
}
answers := make([]AnswerRecord, 0, len(message.Answers))
for _, resource := range message.Answers {
answers = append(answers, normalizeAnswer(resource))
}
return DNSInfo{
RCode: rcodeName(message.Header.RCode),
RCodeValue: int(message.Header.RCode),
ResolverReportsDNSSECAuthenticated: message.Header.AuthenticData,
ClientValidatedDNSSEC: false,
Answers: answers,
}, nil
}
func canonicalName(input string) (string, error) {
name := strings.TrimSuffix(strings.TrimSpace(input), ".")
if name == "" {
return "", fmt.Errorf("domain name is empty")
}
if net.ParseIP(name) != nil {
return "", fmt.Errorf("IP literals are not accepted as domain names")
}
ascii, err := idna.Lookup.ToASCII(name)
if err != nil {
return "", fmt.Errorf("convert domain name to IDNA ASCII: %w", err)
}
ascii = strings.ToLower(ascii)
if len(ascii) > 253 {
return "", fmt.Errorf("domain name exceeds 253 bytes")
}
for _, label := range strings.Split(ascii, ".") {
if label == "" || len(label) > 63 {
return "", fmt.Errorf("domain name contains an invalid label")
}
}
blocked := []string{"localhost", ".local", ".internal", ".lan", ".arpa"}
for _, suffix := range blocked {
if ascii == strings.TrimPrefix(suffix, ".") || strings.HasSuffix(ascii, suffix) {
return "", fmt.Errorf("domain name is blocked by the local-name policy")
}
}
return ascii, nil
}
func normalizeAnswer(resource dnsmessage.Resource) AnswerRecord {
record := AnswerRecord{
"name": trimRoot(resource.Header.Name.String()),
"type": typeName(resource.Header.Type),
"ttl": resource.Header.TTL,
}
switch body := resource.Body.(type) {
case *dnsmessage.AResource:
record["address"] = net.IP(body.A[:]).String()
case *dnsmessage.AAAAResource:
record["address"] = net.IP(body.AAAA[:]).String()
case *dnsmessage.CNAMEResource:
record["target"] = trimRoot(body.CNAME.String())
case *dnsmessage.MXResource:
record["priority"] = body.Pref
record["exchange"] = trimRoot(body.MX.String())
case *dnsmessage.TXTResource:
record["strings"] = body.TXT
case *dnsmessage.NSResource:
record["host"] = trimRoot(body.NS.String())
case *dnsmessage.PTRResource:
record["target"] = trimRoot(body.PTR.String())
case *dnsmessage.SOAResource:
record["primary_ns"] = trimRoot(body.NS.String())
record["responsible_mailbox"] = trimRoot(body.MBox.String())
record["serial"] = body.Serial
record["refresh"] = body.Refresh
record["retry"] = body.Retry
record["expire"] = body.Expire
record["minimum_ttl"] = body.MinTTL
case *dnsmessage.SRVResource:
record["priority"] = body.Priority
record["weight"] = body.Weight
record["port"] = body.Port
record["target"] = trimRoot(body.Target.String())
case *dnsmessage.SVCBResource:
addSVCBFields(record, body.Priority, body.Target, body.Params)
case *dnsmessage.HTTPSResource:
addSVCBFields(record, body.Priority, body.Target, body.Params)
case *dnsmessage.UnknownResource:
if resource.Header.Type == dnsmessage.Type(257) && len(body.Data) >= 2 {
record["flags"] = body.Data[0]
tagLength := int(body.Data[1])
if 2+tagLength <= len(body.Data) {
record["tag"] = string(body.Data[2 : 2+tagLength])
record["value"] = string(body.Data[2+tagLength:])
} else {
record["rdata_base64"] = base64.StdEncoding.EncodeToString(body.Data)
}
} else {
record["rdata_base64"] = base64.StdEncoding.EncodeToString(body.Data)
}
}
return record
}
func addSVCBFields(record AnswerRecord, priority uint16, target dnsmessage.Name, params []dnsmessage.SVCParam) {
record["priority"] = priority
record["target"] = trimRoot(target.String())
values := make([]map[string]any, 0, len(params))
for _, param := range params {
values = append(values, map[string]any{
"key": param.Key.String(),
"key_value": uint16(param.Key),
"value_base64": base64.StdEncoding.EncodeToString(param.Value),
})
}
record["params"] = values
}
func trimRoot(name string) string {
return strings.TrimSuffix(strings.ToLower(name), ".")
}
func typeName(recordType dnsmessage.Type) string {
for name, value := range recordTypes {
if value == recordType {
return name
}
}
return fmt.Sprintf("TYPE%d", recordType)
}
func rcodeName(rcode dnsmessage.RCode) string {
names := map[dnsmessage.RCode]string{
dnsmessage.RCodeSuccess: "NOERROR",
dnsmessage.RCodeFormatError: "FORMERR",
dnsmessage.RCodeServerFailure: "SERVFAIL",
dnsmessage.RCodeNameError: "NXDOMAIN",
dnsmessage.RCodeNotImplemented: "NOTIMP",
dnsmessage.RCodeRefused: "REFUSED",
}
if name, ok := names[rcode]; ok {
return name
}
return fmt.Sprintf("RCODE%d", rcode)
}
@@ -0,0 +1,100 @@
package edns
import (
"encoding/binary"
"testing"
"golang.org/x/net/dns/dnsmessage"
)
func TestBuildAndParseResponse(t *testing.T) {
queryWire, query, transactionID, err := BuildQuery("Example.COM.", "A")
if err != nil {
t.Fatalf("build query: %v", err)
}
if query.Name != "example.com" || query.Type != "A" {
t.Fatalf("canonical query = %#v", query)
}
var request dnsmessage.Message
if err := request.Unpack(queryWire); err != nil {
t.Fatalf("unpack query: %v", err)
}
response := dnsmessage.Message{
Header: dnsmessage.Header{
ID: transactionID,
Response: true,
RecursionDesired: true,
RecursionAvailable: true,
AuthenticData: true,
},
Questions: request.Questions,
Answers: []dnsmessage.Resource{{
Header: dnsmessage.ResourceHeader{Name: request.Questions[0].Name, Class: dnsmessage.ClassINET, TTL: 60},
Body: &dnsmessage.AResource{A: [4]byte{192, 0, 2, 1}},
}},
}
responseWire, err := response.Pack()
if err != nil {
t.Fatalf("pack response: %v", err)
}
dnsResult, err := ParseResponse(responseWire, transactionID, query)
if err != nil {
t.Fatalf("parse response: %v", err)
}
if dnsResult.RCode != "NOERROR" || !dnsResult.ResolverReportsDNSSECAuthenticated {
t.Fatalf("unexpected DNS result: %#v", dnsResult)
}
if got := dnsResult.Answers[0]["address"]; got != "192.0.2.1" {
t.Fatalf("address = %v, want 192.0.2.1", got)
}
}
func TestBuildQueryIDNAAndBlockedNames(t *testing.T) {
_, query, _, err := BuildQuery("bücher.example", "AAAA")
if err != nil {
t.Fatalf("build IDNA query: %v", err)
}
if query.Name != "xn--bcher-kva.example" {
t.Fatalf("IDNA name = %q", query.Name)
}
blocked := []string{"localhost", "router.local", "service.internal", "host.lan", "1.0.0.127.in-addr.arpa", "127.0.0.1"}
for _, name := range blocked {
if _, _, _, err := BuildQuery(name, "A"); err == nil {
t.Errorf("BuildQuery(%q) succeeded, want policy error", name)
}
}
}
func TestNormalizeCAA(t *testing.T) {
name := dnsmessage.MustNewName("example.com.")
data := append([]byte{0, 5}, []byte("issueletsencrypt.org")...)
record := normalizeAnswer(dnsmessage.Resource{
Header: dnsmessage.ResourceHeader{Name: name, Type: dnsmessage.Type(257), Class: dnsmessage.ClassINET, TTL: 300},
Body: &dnsmessage.UnknownResource{Type: dnsmessage.Type(257), Data: data},
})
if record["tag"] != "issue" || record["value"] != "letsencrypt.org" {
t.Fatalf("unexpected CAA normalization: %#v", record)
}
}
func TestParseResponseRejectsTransactionMismatch(t *testing.T) {
name := dnsmessage.MustNewName("example.com.")
message := dnsmessage.Message{
Header: dnsmessage.Header{ID: 2, Response: true},
Questions: []dnsmessage.Question{{Name: name, Type: dnsmessage.TypeA, Class: dnsmessage.ClassINET}},
}
wire, err := message.Pack()
if err != nil {
t.Fatalf("pack response: %v", err)
}
if _, err := ParseResponse(wire, 1, QueryInfo{Name: "example.com", Type: "A"}); err == nil {
t.Fatal("transaction mismatch was accepted")
}
if binary.BigEndian.Uint16(wire[:2]) != 2 {
t.Fatal("test response ID was not encoded")
}
}
@@ -0,0 +1,131 @@
package edns
import (
"bytes"
"context"
"crypto/tls"
"encoding/base64"
"fmt"
"io"
"mime"
"net"
"net/http"
"net/url"
"strings"
"time"
)
const maxDNSMessageSize = 65535
func exchangeDoH(ctx context.Context, provider Provider, wire []byte, method string) ([]byte, TransportInfo, error) {
client := newDoHClient(provider.DoHURL)
return exchangeDoHWithClient(ctx, client, provider.DoHURL, wire, method)
}
func newDoHClient(endpoint string) *http.Client {
origin, _ := url.Parse(endpoint)
transport := &http.Transport{
ForceAttemptHTTP2: true,
DialContext: (&net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
},
TLSHandshakeTimeout: 5 * time.Second,
}
return &http.Client{
Transport: transport,
CheckRedirect: func(request *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return fmt.Errorf("too many DoH redirects")
}
if request.URL.Scheme != "https" {
return fmt.Errorf("DoH redirect changed to a non-HTTPS scheme")
}
if !strings.EqualFold(request.URL.Hostname(), origin.Hostname()) {
return fmt.Errorf("DoH redirect changed authentication domain")
}
return nil
},
}
}
func exchangeDoHWithClient(ctx context.Context, client *http.Client, endpoint string, wire []byte, method string) ([]byte, TransportInfo, error) {
started := time.Now()
info := TransportInfo{
Protocol: "doh",
Encrypted: true,
Bootstrap: "system_resolver",
}
requestURL := endpoint
var body io.Reader
switch strings.ToLower(method) {
case "get":
parsed, err := url.Parse(endpoint)
if err != nil {
return nil, info, fmt.Errorf("parse DoH endpoint: %w", err)
}
query := parsed.Query()
query.Set("dns", base64.RawURLEncoding.EncodeToString(wire))
parsed.RawQuery = query.Encode()
requestURL = parsed.String()
case "post", "":
method = "post"
body = bytes.NewReader(wire)
default:
return nil, info, fmt.Errorf("unsupported DoH method %q", method)
}
request, err := http.NewRequestWithContext(ctx, strings.ToUpper(method), requestURL, body)
if err != nil {
return nil, info, fmt.Errorf("create DoH request: %w", err)
}
request.Header.Set("Accept", "application/dns-message")
if strings.EqualFold(method, "post") {
request.Header.Set("Content-Type", "application/dns-message")
}
request.Header.Set("User-Agent", "ednsdiag/0.1.0-dev")
response, err := client.Do(request)
info.ElapsedMS = time.Since(started).Milliseconds()
if err != nil {
return nil, info, fmt.Errorf("perform DoH exchange: %w", err)
}
defer response.Body.Close()
info.HTTPVersion = response.Proto
if response.TLS == nil || len(response.TLS.VerifiedChains) == 0 {
return nil, info, fmt.Errorf("DoH server TLS identity was not verified")
}
info.ServerAuthenticated = true
info.TLSVersion = tlsVersionName(response.TLS.Version)
info.ALPN = response.TLS.NegotiatedProtocol
if response.StatusCode < 200 || response.StatusCode > 299 {
return nil, info, fmt.Errorf("DoH server returned HTTP status %d", response.StatusCode)
}
mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type"))
if err != nil || !strings.EqualFold(mediaType, "application/dns-message") {
return nil, info, fmt.Errorf("DoH server returned unsupported content type %q", response.Header.Get("Content-Type"))
}
payload, err := io.ReadAll(io.LimitReader(response.Body, maxDNSMessageSize+1))
if err != nil {
return nil, info, fmt.Errorf("read DoH response: %w", err)
}
if len(payload) > maxDNSMessageSize {
return nil, info, fmt.Errorf("DoH response exceeds %d bytes", maxDNSMessageSize)
}
return payload, info, nil
}
func tlsVersionName(version uint16) string {
switch version {
case tls.VersionTLS13:
return "TLS1.3"
case tls.VersionTLS12:
return "TLS1.2"
default:
return fmt.Sprintf("0x%04x", version)
}
}
@@ -0,0 +1,75 @@
package edns
import (
"io"
"net/http"
"net/http/httptest"
"testing"
"golang.org/x/net/dns/dnsmessage"
)
func TestExchangeDoHGETAndPOST(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
var payload []byte
var err error
if request.Method == http.MethodGet {
payload, err = decodeGETQuery(request.URL.Query().Get("dns"))
} else {
payload, err = io.ReadAll(request.Body)
}
if err != nil {
http.Error(writer, err.Error(), http.StatusBadRequest)
return
}
if request.Header.Get("Accept") != "application/dns-message" {
http.Error(writer, "missing accept", http.StatusNotAcceptable)
return
}
var query dnsmessage.Message
if err := query.Unpack(payload); err != nil {
http.Error(writer, err.Error(), http.StatusBadRequest)
return
}
response := dnsmessage.Message{
Header: dnsmessage.Header{ID: query.Header.ID, Response: true, RecursionAvailable: true},
Questions: query.Questions,
}
responseWire, err := response.Pack()
if err != nil {
http.Error(writer, err.Error(), http.StatusInternalServerError)
return
}
writer.Header().Set("Content-Type", "application/dns-message")
_, _ = writer.Write(responseWire)
}))
defer server.Close()
wire, _, _, err := BuildQuery("example.com", "A")
if err != nil {
t.Fatalf("build query: %v", err)
}
for _, method := range []string{"get", "post"} {
t.Run(method, func(t *testing.T) {
response, info, err := exchangeDoHWithClient(t.Context(), server.Client(), server.URL, wire, method)
if err != nil {
t.Fatalf("exchange DoH: %v", err)
}
if len(response) == 0 || !info.Encrypted || !info.ServerAuthenticated {
t.Fatalf("unexpected result: response=%d info=%#v", len(response), info)
}
})
}
}
func TestExchangeDoHRejectsHTTPError(t *testing.T) {
server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
http.Error(writer, "unavailable", http.StatusServiceUnavailable)
}))
defer server.Close()
if _, _, err := exchangeDoHWithClient(t.Context(), server.Client(), server.URL, []byte{1}, "post"); err == nil {
t.Fatal("HTTP error was accepted")
}
}
@@ -0,0 +1,106 @@
package edns
import (
"context"
"crypto/tls"
"encoding/binary"
"fmt"
"io"
"net"
"time"
)
func exchangeDoT(ctx context.Context, provider Provider, wire []byte) ([]byte, TransportInfo, error) {
return exchangeDoTWithTLSConfig(ctx, provider, wire, &tls.Config{
ServerName: provider.DoTName,
MinVersion: tls.VersionTLS12,
NextProtos: []string{"dot"},
})
}
func exchangeDoTWithTLSConfig(ctx context.Context, provider Provider, wire []byte, tlsConfig *tls.Config) ([]byte, TransportInfo, error) {
started := time.Now()
info := TransportInfo{
Protocol: "dot",
Encrypted: true,
Bootstrap: "system_resolver",
}
rawConnection, err := (&net.Dialer{}).DialContext(ctx, "tcp", provider.DoTAddr)
if err != nil {
info.ElapsedMS = time.Since(started).Milliseconds()
return nil, info, fmt.Errorf("connect to DoT server: %w", err)
}
defer rawConnection.Close()
if deadline, ok := ctx.Deadline(); ok {
if err := rawConnection.SetDeadline(deadline); err != nil {
return nil, info, fmt.Errorf("set DoT deadline: %w", err)
}
}
tlsConfig = tlsConfig.Clone()
tlsConfig.ServerName = provider.DoTName
tlsConnection := tls.Client(rawConnection, tlsConfig)
if err := tlsConnection.HandshakeContext(ctx); err != nil {
info.ElapsedMS = time.Since(started).Milliseconds()
return nil, info, fmt.Errorf("authenticate DoT server: %w", err)
}
state := tlsConnection.ConnectionState()
if len(state.VerifiedChains) == 0 {
return nil, info, fmt.Errorf("DoT server TLS identity was not verified")
}
info.ServerAuthenticated = true
info.TLSVersion = tlsVersionName(state.Version)
info.ALPN = state.NegotiatedProtocol
if info.ALPN != "" && info.ALPN != "dot" {
info.ElapsedMS = time.Since(started).Milliseconds()
return nil, info, fmt.Errorf("DoT server negotiated unexpected ALPN protocol %q", info.ALPN)
}
response, err := exchangeTCPFrame(tlsConnection, wire)
info.ElapsedMS = time.Since(started).Milliseconds()
if err != nil {
return nil, info, fmt.Errorf("perform DoT exchange: %w", err)
}
return response, info, nil
}
func exchangeTCPFrame(connection io.ReadWriter, wire []byte) ([]byte, error) {
if len(wire) == 0 || len(wire) > maxDNSMessageSize {
return nil, fmt.Errorf("invalid DNS message length %d", len(wire))
}
frame := make([]byte, 2+len(wire))
binary.BigEndian.PutUint16(frame[:2], uint16(len(wire)))
copy(frame[2:], wire)
if err := writeAll(connection, frame); err != nil {
return nil, fmt.Errorf("write framed DNS query: %w", err)
}
var lengthBytes [2]byte
if _, err := io.ReadFull(connection, lengthBytes[:]); err != nil {
return nil, fmt.Errorf("read DNS response length: %w", err)
}
length := int(binary.BigEndian.Uint16(lengthBytes[:]))
if length == 0 {
return nil, fmt.Errorf("DoT server returned an empty DNS message")
}
response := make([]byte, length)
if _, err := io.ReadFull(connection, response); err != nil {
return nil, fmt.Errorf("read DNS response: %w", err)
}
return response, nil
}
func writeAll(writer io.Writer, payload []byte) error {
for len(payload) > 0 {
written, err := writer.Write(payload)
if err != nil {
return err
}
if written == 0 {
return io.ErrShortWrite
}
payload = payload[written:]
}
return nil
}
@@ -0,0 +1,254 @@
package edns
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"encoding/binary"
"io"
"math/big"
"net"
"strings"
"testing"
"time"
"golang.org/x/net/dns/dnsmessage"
)
type scriptedReadWriter struct {
read *bytes.Reader
written bytes.Buffer
}
func (stream *scriptedReadWriter) Read(payload []byte) (int, error) {
return stream.read.Read(payload)
}
func (stream *scriptedReadWriter) Write(payload []byte) (int, error) {
return stream.written.Write(payload)
}
func TestExchangeTCPFrame(t *testing.T) {
responsePayload := []byte{9, 8, 7}
framedResponse := make([]byte, 2+len(responsePayload))
binary.BigEndian.PutUint16(framedResponse[:2], uint16(len(responsePayload)))
copy(framedResponse[2:], responsePayload)
stream := &scriptedReadWriter{read: bytes.NewReader(framedResponse)}
query := []byte{1, 2, 3, 4}
response, err := exchangeTCPFrame(stream, query)
if err != nil {
t.Fatalf("exchange TCP frame: %v", err)
}
if !bytes.Equal(response, responsePayload) {
t.Fatalf("response = %v, want %v", response, responsePayload)
}
written := stream.written.Bytes()
if int(binary.BigEndian.Uint16(written[:2])) != len(query) || !bytes.Equal(written[2:], query) {
t.Fatalf("invalid query frame: %v", written)
}
}
func TestExchangeDoTAuthenticatesServer(t *testing.T) {
certificate, roots := newTestCertificate(t, "resolver.test")
listener, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{
Certificates: []tls.Certificate{certificate},
MinVersion: tls.VersionTLS12,
NextProtos: []string{"dot"},
})
if err != nil {
t.Fatalf("listen for DoT: %v", err)
}
defer listener.Close()
serverError := make(chan error, 1)
go func() {
connection, err := listener.Accept()
if err != nil {
serverError <- err
return
}
defer connection.Close()
response, err := serveOneDoTQuery(connection)
if err == nil {
err = writeAll(connection, response)
}
serverError <- err
}()
queryWire, query, transactionID, err := BuildQuery("example.com", "A")
if err != nil {
t.Fatalf("build query: %v", err)
}
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
response, info, err := exchangeDoTWithTLSConfig(ctx, Provider{DoTAddr: listener.Addr().String(), DoTName: "resolver.test"}, queryWire, &tls.Config{
RootCAs: roots,
MinVersion: tls.VersionTLS12,
NextProtos: []string{"dot"},
})
if err != nil {
t.Fatalf("exchange DoT: %v", err)
}
if err := <-serverError; err != nil {
t.Fatalf("serve DoT: %v", err)
}
if !info.ServerAuthenticated || info.ALPN != "dot" {
t.Fatalf("unexpected transport info: %#v", info)
}
if _, err := ParseResponse(response, transactionID, query); err != nil {
t.Fatalf("parse response: %v", err)
}
}
func TestExchangeDoTAllowsMissingALPN(t *testing.T) {
certificate, roots := newTestCertificate(t, "resolver.test")
listener, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{
Certificates: []tls.Certificate{certificate},
MinVersion: tls.VersionTLS12,
})
if err != nil {
t.Fatalf("listen for DoT: %v", err)
}
defer listener.Close()
serverError := make(chan error, 1)
go func() {
connection, err := listener.Accept()
if err != nil {
serverError <- err
return
}
defer connection.Close()
response, err := serveOneDoTQuery(connection)
if err == nil {
err = writeAll(connection, response)
}
serverError <- err
}()
queryWire, _, _, err := BuildQuery("example.com", "A")
if err != nil {
t.Fatalf("build query: %v", err)
}
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
_, info, err := exchangeDoTWithTLSConfig(ctx, Provider{DoTAddr: listener.Addr().String(), DoTName: "resolver.test"}, queryWire, &tls.Config{
RootCAs: roots,
MinVersion: tls.VersionTLS12,
NextProtos: []string{"dot"},
})
if err != nil {
t.Fatalf("exchange DoT without server ALPN: %v", err)
}
if err := <-serverError; err != nil {
t.Fatalf("serve DoT: %v", err)
}
if !info.ServerAuthenticated || info.ALPN != "" {
t.Fatalf("unexpected transport info: %#v", info)
}
}
func TestExchangeDoTRejectsUnexpectedALPN(t *testing.T) {
certificate, roots := newTestCertificate(t, "resolver.test")
listener, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{
Certificates: []tls.Certificate{certificate},
MinVersion: tls.VersionTLS12,
NextProtos: []string{"http/1.1"},
})
if err != nil {
t.Fatalf("listen for TLS: %v", err)
}
defer listener.Close()
serverError := make(chan error, 1)
go func() {
connection, err := listener.Accept()
if err != nil {
serverError <- err
return
}
defer connection.Close()
serverError <- connection.(*tls.Conn).Handshake()
}()
queryWire, _, _, err := BuildQuery("example.com", "A")
if err != nil {
t.Fatalf("build query: %v", err)
}
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
_, info, err := exchangeDoTWithTLSConfig(ctx, Provider{DoTAddr: listener.Addr().String(), DoTName: "resolver.test"}, queryWire, &tls.Config{
RootCAs: roots,
MinVersion: tls.VersionTLS12,
NextProtos: []string{"http/1.1"},
})
if err == nil || !strings.Contains(err.Error(), "unexpected ALPN protocol") {
t.Fatalf("exchange DoT error = %v, want unexpected ALPN error", err)
}
if err := <-serverError; err != nil {
t.Fatalf("complete TLS handshake: %v", err)
}
if !info.ServerAuthenticated || info.ALPN != "http/1.1" {
t.Fatalf("unexpected transport info: %#v", info)
}
}
func newTestCertificate(t *testing.T, name string) (tls.Certificate, *x509.CertPool) {
t.Helper()
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
template := &x509.Certificate{
SerialNumber: big.NewInt(1),
DNSNames: []string{name},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
IsCA: true,
BasicConstraintsValid: true,
}
der, err := x509.CreateCertificate(rand.Reader, template, template, publicKey, privateKey)
if err != nil {
t.Fatalf("create certificate: %v", err)
}
parsed, err := x509.ParseCertificate(der)
if err != nil {
t.Fatalf("parse certificate: %v", err)
}
roots := x509.NewCertPool()
roots.AddCert(parsed)
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: privateKey}, roots
}
func serveOneDoTQuery(connection net.Conn) ([]byte, error) {
var lengthBytes [2]byte
if _, err := io.ReadFull(connection, lengthBytes[:]); err != nil {
return nil, err
}
wire := make([]byte, int(binary.BigEndian.Uint16(lengthBytes[:])))
if _, err := io.ReadFull(connection, wire); err != nil {
return nil, err
}
var query dnsmessage.Message
if err := query.Unpack(wire); err != nil {
return nil, err
}
response := dnsmessage.Message{
Header: dnsmessage.Header{ID: query.Header.ID, Response: true, RecursionAvailable: true},
Questions: query.Questions,
}
responseWire, err := response.Pack()
if err != nil {
return nil, err
}
framed := make([]byte, 2+len(responseWire))
binary.BigEndian.PutUint16(framed[:2], uint16(len(responseWire)))
copy(framed[2:], responseWire)
return framed, nil
}
@@ -0,0 +1,59 @@
package edns
type QueryOptions struct {
Name string
RecordType string
Protocol string
Provider string
Method string
EndpointURL string
}
type Result struct {
SchemaVersion int `json:"schema_version"`
Operation string `json:"operation"`
Completed bool `json:"completed"`
Query QueryInfo `json:"query"`
Resolver ResolverInfo `json:"resolver"`
Transport TransportInfo `json:"transport"`
DNS DNSInfo `json:"dns"`
Warnings []string `json:"warnings,omitempty"`
Error *ErrorInfo `json:"error,omitempty"`
}
type QueryInfo struct {
Name string `json:"name"`
Type string `json:"type"`
}
type ResolverInfo struct {
Provider string `json:"provider"`
Endpoint string `json:"endpoint"`
Profile string `json:"profile"`
}
type TransportInfo struct {
Protocol string `json:"protocol"`
Encrypted bool `json:"encrypted"`
ServerAuthenticated bool `json:"server_authenticated"`
ElapsedMS int64 `json:"elapsed_ms"`
Bootstrap string `json:"bootstrap"`
TLSVersion string `json:"tls_version,omitempty"`
ALPN string `json:"alpn,omitempty"`
HTTPVersion string `json:"http_version,omitempty"`
}
type DNSInfo struct {
RCode string `json:"rcode"`
RCodeValue int `json:"rcode_value"`
ResolverReportsDNSSECAuthenticated bool `json:"resolver_reports_dnssec_authenticated"`
ClientValidatedDNSSEC bool `json:"client_validated_dnssec"`
Answers []AnswerRecord `json:"answers"`
}
type AnswerRecord map[string]any
type ErrorInfo struct {
Class string `json:"class"`
Message string `json:"message"`
}
@@ -0,0 +1,53 @@
package edns
import (
"fmt"
"strings"
)
type Provider struct {
ID string
Profile string
DoHURL string
DoTAddr string
DoTName string
}
var providers = map[string]Provider{
"cloudflare": {
ID: "cloudflare",
Profile: "unfiltered",
DoHURL: "https://cloudflare-dns.com/dns-query",
DoTAddr: "one.one.one.one:853",
DoTName: "one.one.one.one",
},
"google": {
ID: "google",
Profile: "unfiltered",
DoHURL: "https://dns.google/dns-query",
DoTAddr: "dns.google:853",
DoTName: "dns.google",
},
"quad9": {
ID: "quad9",
Profile: "security-filtered",
DoHURL: "https://dns.quad9.net/dns-query",
DoTAddr: "dns.quad9.net:853",
DoTName: "dns.quad9.net",
},
"adguard": {
ID: "adguard",
Profile: "ad-and-security-filtered",
DoHURL: "https://dns.adguard-dns.com/dns-query",
DoTAddr: "dns.adguard-dns.com:853",
DoTName: "dns.adguard-dns.com",
},
}
func FindProvider(name string) (Provider, error) {
provider, ok := providers[strings.ToLower(name)]
if !ok {
return Provider{}, fmt.Errorf("unknown provider %q", name)
}
return provider, nil
}
@@ -0,0 +1,18 @@
package edns
import "testing"
func TestBuiltInProvidersHaveStrictEndpoints(t *testing.T) {
for _, name := range []string{"cloudflare", "google", "quad9", "adguard"} {
provider, err := FindProvider(name)
if err != nil {
t.Fatalf("find provider %s: %v", name, err)
}
if provider.DoHURL == "" || provider.DoTAddr == "" || provider.DoTName == "" {
t.Fatalf("provider %s is incomplete: %#v", name, provider)
}
}
if _, err := FindProvider("custom"); err == nil {
t.Fatal("unapproved custom provider was accepted")
}
}
@@ -0,0 +1,70 @@
package edns
import (
"context"
"fmt"
"net/url"
)
func Query(ctx context.Context, options QueryOptions) Result {
wire, query, transactionID, err := BuildQuery(options.Name, options.RecordType)
result := Result{
SchemaVersion: 1,
Operation: "query",
Query: query,
Transport: TransportInfo{
Protocol: options.Protocol,
Encrypted: true,
Bootstrap: "system_resolver",
},
DNS: DNSInfo{Answers: []AnswerRecord{}},
}
if err != nil {
result.Query = QueryInfo{Name: options.Name, Type: options.RecordType}
result.Error = &ErrorInfo{Class: "input", Message: err.Error()}
return result
}
provider, err := FindProvider(options.Provider)
if err != nil {
result.Error = &ErrorInfo{Class: "input", Message: err.Error()}
return result
}
if options.EndpointURL != "" {
if options.Protocol != "doh" {
result.Error = &ErrorInfo{Class: "input", Message: "custom --url applies only to DoH"}
return result
}
parsed, err := url.Parse(options.EndpointURL)
if err != nil || parsed.Scheme != "https" || parsed.Hostname() == "" {
result.Error = &ErrorInfo{Class: "input", Message: fmt.Sprintf("invalid DoH endpoint URL %q", options.EndpointURL)}
return result
}
provider = Provider{ID: "custom", Profile: "custom", DoHURL: options.EndpointURL}
}
result.Resolver = ResolverInfo{Provider: provider.ID, Profile: provider.Profile}
var response []byte
switch options.Protocol {
case "doh":
result.Resolver.Endpoint = provider.DoHURL
response, result.Transport, err = exchangeDoH(ctx, provider, wire, options.Method)
case "dot":
result.Resolver.Endpoint = provider.DoTAddr
response, result.Transport, err = exchangeDoT(ctx, provider, wire)
default:
err = fmt.Errorf("protocol %q is not available; run ednsdiag capabilities", options.Protocol)
}
if err != nil {
result.Error = &ErrorInfo{Class: "transport", Message: err.Error()}
return result
}
result.DNS, err = ParseResponse(response, transactionID, query)
if err != nil {
result.Error = &ErrorInfo{Class: "protocol", Message: err.Error()}
return result
}
result.Completed = true
return result
}
@@ -0,0 +1,7 @@
package edns
import "encoding/base64"
func decodeGETQuery(value string) ([]byte, error) {
return base64.RawURLEncoding.DecodeString(value)
}
@@ -0,0 +1,30 @@
# Built-in provider policy
Provider endpoints and capabilities must be verified against the provider's
official documentation before they are added or changed. The built-in entries
below were verified on 2026-08-13.
## Candidate providers
| Provider | DoH endpoint | DoT endpoint / authentication name | Official documentation | Profile |
| --- | --- | --- | --- | --- |
| Cloudflare | `https://cloudflare-dns.com/dns-query` | `one.one.one.one:853` | [DoH](https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https/make-api-requests/) / [DoT](https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-tls/) | Unfiltered |
| Google | `https://dns.google/dns-query` | `dns.google:853` | [DoH](https://developers.google.com/speed/public-dns/docs/doh) / [DoT](https://developers.google.com/speed/public-dns/docs/dns-over-tls) | Unfiltered |
| Quad9 | `https://dns.quad9.net/dns-query` | `dns.quad9.net:853` | [Quad9 services](https://docs.quad9.net/services/) | Security filtered; HTTP/2 required |
| AdGuard | `https://dns.adguard-dns.com/dns-query` | `dns.adguard-dns.com:853` | [AdGuard providers](https://adguard-dns.io/kb/general/dns-providers/) | Ads, tracking, and security filtered |
## Registry requirements
Each built-in provider entry must include:
- stable provider identifier;
- protocol and endpoint;
- authentication domain name;
- bootstrap addresses only when officially published;
- filtering/ECS profile;
- official source URL;
- last verification date.
Do not infer one protocol endpoint from another. Do not treat filtering and
non-filtering services as interchangeable. Provider comparison results must
remain separate.
@@ -0,0 +1,58 @@
# Security and privacy requirements
Read this file before changing transports, bootstrap behavior, endpoint
validation, fallback, or result claims.
## Non-negotiable rules
1. Never silently downgrade to plaintext DNS.
2. Validate certificates and authentication domain names. DoT follows the
strict privacy profile in [RFC 8310](https://www.rfc-editor.org/rfc/rfc8310.html).
3. Treat certificate, hostname, SNI, and negotiated ALPN mismatches as hard
failures, not fallback opportunities.
4. Bound response sizes, per-attempt timeouts, total time, redirects, and the
number of attempts.
5. Do not expose an unrestricted endpoint parameter to an Agent. Built-in
providers are allowlisted; private or custom endpoints require explicit
user intent and policy approval.
6. Do not connect to addresses returned in DNS answers.
7. Do not enable AXFR, IXFR, or ANY queries.
8. Do not persist full query names or client identifiers by default.
## DoT ALPN policy
The client advertises the `dot` ALPN identifier. RFC 7858 and RFC 8310 do not
require a DoT server on its dedicated port to select an ALPN protocol, so an
empty negotiated ALPN is permitted and reported as empty. If a server selects
a non-empty protocol other than `dot`, abort before sending the DNS query.
## Bootstrap transparency
Connecting to a resolver hostname may require an initial DNS lookup. Report
whether the endpoint was reached using a configured bootstrap address, the
system resolver, or an already-known IP. Do not claim that a query avoided the
system resolver when bootstrap used it.
## DNS status and fallback
An HTTP, TLS, or QUIC exchange can succeed while DNS returns `NXDOMAIN`,
`SERVFAIL`, or `REFUSED`. Those are DNS outcomes and must not be converted into
transport errors. Cross-provider or cross-protocol fallback is permitted only
for explicitly classified transport failures and must be disclosed.
## DNSSEC language
The AD bit means the selected recursive resolver reports authenticated data.
It is not proof that this client validated the DNSSEC chain. Use separate
fields for resolver-reported and locally validated DNSSEC state.
## Privacy language
Encrypted transport protects the path between the client and the selected
resolver. The resolver can still observe the query. Provider policy, logging,
filtering, ECS behavior, and jurisdiction remain relevant. See
[RFC 8932](https://www.rfc-editor.org/rfc/rfc8932.html).
ODoH and Anonymized DNSCrypt add relay models but do not justify claims of
absolute anonymity. Their proxy, relay, and target roles must be reported
separately.
@@ -0,0 +1,40 @@
# Standards and authoritative sources
Verified on 2026-08-13. Protocol behavior must be based on the published
standard, not on summaries or provider-specific JSON APIs.
| Capability | Authority | Project scope |
| --- | --- | --- |
| Agent Skills package | [Agent Skills specification](https://agentskills.io/specification) | Required package format |
| OMP discovery | [OMP Skills documentation](https://github.com/can1357/oh-my-pi/blob/main/docs/skills.md) | Supported host |
| DoH | [RFC 8484](https://www.rfc-editor.org/rfc/rfc8484.html) | Planned |
| DoT | [RFC 7858](https://www.rfc-editor.org/rfc/rfc7858.html) | Planned |
| DoT authentication profiles | [RFC 8310](https://www.rfc-editor.org/rfc/rfc8310.html) | Strict privacy only |
| DoQ | [RFC 9250](https://www.rfc-editor.org/rfc/rfc9250.html) | Planned |
| ODoH | [RFC 9230](https://www.rfc-editor.org/rfc/rfc9230.html) | Research until a maintained implementation is selected |
| DNS privacy operations | [RFC 8932](https://www.rfc-editor.org/rfc/rfc8932.html) | Security and privacy guidance |
| EDNS(0) padding | [RFC 7830](https://www.rfc-editor.org/rfc/rfc7830.html) and [RFC 8467](https://www.rfc-editor.org/rfc/rfc8467.html) | Evaluate per transport |
| DNSCrypt | [DNSCrypt protocol specification](https://github.com/DNSCrypt/dnscrypt-protocol) | Planned, non-IETF |
| Anonymized DNSCrypt | [Anonymized DNSCrypt specification](https://github.com/DNSCrypt/dnscrypt-protocol/blob/master/ANONYMIZED-DNSCRYPT.txt) | Research |
| Go DNS wire and IDNA support | [Go x/net module](https://pkg.go.dev/golang.org/x/net) | Pinned to v0.58.0; use `dnsmessage` and `idna` |
## Deliberate exclusions
- DNS-over-DTLS ([RFC 8094](https://www.rfc-editor.org/rfc/rfc8094.html))
is experimental and is not a target transport.
- DNS zone transfer over TLS
([RFC 9103](https://www.rfc-editor.org/rfc/rfc9103.html)) is outside the
client-to-recursive diagnostic scope.
- Recursive-to-authoritative encryption and resolver/server operation are
outside the initial scope.
## Terminology
DoH3 means RFC 8484 semantics carried over HTTP/3. It is not a separate DNS
message format. DNSCrypt is an encrypted DNS protocol with its own
specification; do not label it as an IETF RFC.
For DoH, accept and send `application/dns-message`. Keep HTTP status separate
from the DNS RCODE: a valid NXDOMAIN or SERVFAIL response still uses HTTP 2xx.
For DoT, use the strict privacy profile and verify both the PKIX chain and the
configured authentication domain name.
@@ -0,0 +1,82 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/windyboy/encrypted-dns-skill/schemas/result-v1.schema.json",
"title": "Encrypted DNS diagnostic result",
"type": "object",
"required": ["schema_version", "operation", "completed", "query", "transport", "dns"],
"properties": {
"schema_version": { "const": 1 },
"operation": { "enum": ["query", "probe", "compare"] },
"completed": { "type": "boolean" },
"query": {
"type": "object",
"required": ["name", "type"],
"properties": {
"name": { "type": "string" },
"type": { "type": "string" }
},
"additionalProperties": false
},
"resolver": {
"type": "object",
"properties": {
"provider": { "type": "string" },
"endpoint": { "type": "string" },
"profile": { "type": "string" }
},
"additionalProperties": false
},
"transport": {
"type": "object",
"required": ["protocol", "encrypted", "server_authenticated"],
"properties": {
"protocol": { "enum": ["doh", "dot", "doq", "doh3", "dnscrypt", "odoh", "anonymized-dnscrypt"] },
"encrypted": { "type": "boolean" },
"server_authenticated": { "type": "boolean" },
"elapsed_ms": { "type": "integer", "minimum": 0 },
"bootstrap": { "type": "string" },
"tls_version": { "type": "string" },
"alpn": { "type": "string" },
"http_version": { "type": "string" }
},
"additionalProperties": true
},
"dns": {
"type": "object",
"required": ["rcode", "rcode_value", "answers"],
"properties": {
"rcode": { "type": "string" },
"rcode_value": { "type": "integer", "minimum": 0 },
"resolver_reports_dnssec_authenticated": { "type": "boolean" },
"client_validated_dnssec": { "type": "boolean" },
"answers": {
"type": "array",
"items": {
"type": "object",
"required": ["name", "type", "ttl"],
"properties": {
"name": { "type": "string" },
"type": { "type": "string" },
"ttl": { "type": "integer", "minimum": 0 }
},
"additionalProperties": true
}
}
},
"additionalProperties": true
},
"fallback_used": { "type": "boolean" },
"attempts": { "type": "array", "items": { "type": "object" } },
"warnings": { "type": "array", "items": { "type": "string" } },
"error": {
"type": "object",
"required": ["class", "message"],
"properties": {
"class": { "enum": ["input", "transport", "protocol", "internal"] },
"message": { "type": "string" }
},
"additionalProperties": false
}
},
"additionalProperties": true
}
+8
View File
@@ -15,6 +15,14 @@ id_*
.ansible/
facts/
# Local agent-harness / tooling config (not repo content).
.agents/
.claude/
.omp/
.mcp.json
WATCHDOG.yml
skills-lock.json
# Editor and operating-system files.
.DS_Store
.vscode/
+25 -6
View File
@@ -27,8 +27,12 @@ Also readable as `agent.md` (symlink → this file).
| **mx2.windy.me** | mailcow (`/opt/mail`, project `cow`) | `ssh -4 windy@mx2.windy.me` | [hosts/mx2.windy.me.md](hosts/mx2.windy.me.md) |
| **us2.wsvc.info** | Vaultwarden + Traefik (+ Soft Serve, …) | `ssh -4 windy@us2.wsvc.info` | [hosts/us2.wsvc.info.md](hosts/us2.wsvc.info.md) |
| **hk2.chans.xyz** | PowerDNS auth ns1 (`/opt/pdns`) | `ssh -4 windy@hk2.chans.xyz` | [hosts/hk2.chans.xyz.md](hosts/hk2.chans.xyz.md) |
| **synapse.chans.xyz** | Matrix ESS (Synapse + MAS + Element) on K3s | `ssh -4 windy@synapse.chans.xyz` | [hosts/synapse.chans.xyz.md](hosts/synapse.chans.xyz.md) |
| **dns.windy.lan** | AdGuard Home LAN DNS + Mihomo explicit proxy | `ssh -4 windy@192.168.66.36` | [hosts/dns.windy.lan.md](hosts/dns.windy.lan.md) |
| **gfw.windy.lan** | OpenWrt LAN gateway / OpenClash | `ssh -4 root@192.168.66.1` | [hosts/gfw.windy.lan.md](hosts/gfw.windy.lan.md) |
| **gw** | EdgeRouter X primary LAN gateway | `ssh -4 zhiqiang@192.168.66.254` | [hosts/gw.md](hosts/gw.md) |
| **ubnt** | UniFi Network Controller | `ssh -4 windy@192.168.66.46` | [hosts/ubnt.md](hosts/ubnt.md) |
| **hass.windy.lan** | Home Assistant (HAOS, LAN55) | `ssh hassio@hass.windy.lan` | [hosts/hass.windy.lan.md](hosts/hass.windy.lan.md) |
### Public services
@@ -55,11 +59,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 +74,30 @@ 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 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) |
```bash
./scripts/check-mx2.sh
```
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`.
`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).
## 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
Single-context layout: `CONTEXT.md` + `docs/adr/` at the repo root. See `docs/agents/domain.md`.
## Safety
@@ -115,5 +135,4 @@ inventory/hosts.md # machine index
hosts/ # per-host facts
runbooks/ # step-by-step ops
docs/ # upstream doc indexes / design notes
scripts/ # read-only helper scripts
```
+51 -3
View File
@@ -17,7 +17,7 @@ all:
ansible_host_ipv4: 194.163.160.244
service_role: mailcow
compose_project_dir: /opt/mail
healthcheck_profile: mailcow
healthcheck_profiles: [mailcow]
service_reconcile_services:
all:
compose_args: [--force-recreate]
@@ -26,7 +26,8 @@ all:
ansible_host_ipv4: 193.9.44.165
service_role: vaultwarden
compose_project_dir: /opt/vaultwarden
healthcheck_profile: vaultwarden
healthcheck_profiles: [vaultwarden]
restic_backup_profile: vaultwarden
service_reconcile_services:
vaultwarden:
compose_args: [--force-recreate]
@@ -36,7 +37,8 @@ all:
ansible_host_ipv4: 154.36.174.161
service_role: powerdns
compose_project_dir: /opt/pdns
healthcheck_profile: pdns
healthcheck_profiles: [pdns, rustdesk, hk2aux]
restic_backup_profile: pdns
service_reconcile_services:
auth:
compose_args: [--force-recreate]
@@ -45,6 +47,30 @@ 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
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
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 +80,33 @@ 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
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:
+1 -1
View File
@@ -64,7 +64,7 @@
ansible.builtin.debug:
msg:
host: "{{ inventory_hostname }}"
profile: "{{ healthcheck_profile }}"
profiles: "{{ healthcheck_profiles | default([]) | join(', ') }}"
os: "{{ ansible_distribution }} {{ ansible_distribution_version }}"
kernel: "{{ ansible_kernel }}"
compose_rc: "{{ audit_compose_ps.rc }}"
+20
View File
@@ -0,0 +1,20 @@
---
# Deploy/reconcile the self-hosted RustDesk server (hbbs + hbbr) on hk2.
#
# Safe by default: run with --check for a read-only report, or supply
# rustdesk_confirm=true to deploy the compose file and recreate the stack.
#
# # Read-only report
# ansible-playbook playbooks/rustdesk.yml --limit rustdesk --check
#
# # Apply (deploy compose + recreate hbbs/hbbr)
# ansible-playbook playbooks/rustdesk.yml --limit rustdesk \
# -e '{"rustdesk_confirm": true}'
- name: Deploy and reconcile RustDesk server
hosts: rustdesk
become: true
gather_facts: false
serial: 1
roles:
- role: rustdesk
tags: [rustdesk, mutating]
+25
View File
@@ -0,0 +1,25 @@
---
# Reconcile the UniFi controller SSO login setting
# (super_sdn.sso_login_enabled = "Sync Local Admin with Ubiquiti SSO").
#
# Safe by default: run with --check for a read-only status report, or supply
# unifi_sso_confirm=true to apply the declared target value.
#
# # Read-only status
# ansible-playbook playbooks/unifi-sso.yml --limit ubnt --check
#
# # Apply (disable SSO login; local accounts use local passwords, no MFA)
# ansible-playbook playbooks/unifi-sso.yml --limit ubnt \
# -e '{"unifi_sso_confirm": true, "unifi_sso_target_value": false}'
#
# # Rollback (re-enable SSO login)
# ansible-playbook playbooks/unifi-sso.yml --limit ubnt \
# -e '{"unifi_sso_confirm": true, "unifi_sso_target_value": true}'
- name: Reconcile UniFi SSO login setting
hosts: unifi
become: true
gather_facts: false
serial: 1
roles:
- role: unifi_sso
tags: [unifi, sso, mutating]
+456
View File
@@ -0,0 +1,456 @@
---
# Narrow reconciliation for the audited us4 public zone. This playbook never
# reloads or restarts firewalld and deliberately does not manage Docker rules.
- name: Safely remove audited stale firewalld allowances from us4
hosts: wireguard
become: true
gather_facts: false
serial: 1
any_errors_fatal: true
vars:
us4_firewalld_confirm: false
us4_console_confirm: false
us4_firewalld_zone: public
us4_firewalld_keep_services:
- dhcpv6-client
- http
- https
- smtp
- ssh
us4_firewalld_stale_services:
- imap
- imaps
- smtp-submission
- smtps
us4_firewalld_stale_ports:
- 24/tcp
- 6443/tcp
- 8443/tcp
us4_firewalld_expected_containers:
- nghttpx-proxy
- semaphoreui-postgres-1
- semaphoreui-semaphore-1
- squid-backend
- traefik
- trlm-server-trilium-1
- wireguard
us4_firewalld_backup_root: /var/backups/us4-firewall
tasks:
- name: Require the audited host and explicit apply confirmations
ansible.builtin.assert:
that:
- inventory_hostname == 'us4'
- ansible_host == 'us4.wsvc.info'
- ansible_host_ipv4 == '185.201.226.122'
- ansible_check_mode or (us4_firewalld_confirm | bool)
- ansible_check_mode or (us4_console_confirm | bool)
fail_msg: >-
Apply is allowed only for audited host us4 after the provider console
has been tested. Set both us4_firewalld_confirm=true and
us4_console_confirm=true. Check mode does not require confirmation.
- name: Verify the remote host identity
ansible.builtin.command:
argv: [hostname, -f]
check_mode: false
changed_when: false
register: us4_firewalld_hostname
- name: Reject an unexpected remote host
ansible.builtin.assert:
that:
- us4_firewalld_hostname.stdout == 'us4.wsvc.info'
- name: Verify required services are active
ansible.builtin.command:
argv: [systemctl, is-active, --quiet, "{{ item }}"]
check_mode: false
changed_when: false
loop:
- atd
- firewalld
- name: Verify firewalld Python bindings used by ansible.posix
ansible.builtin.command:
argv: [python3, -c, "import dbus, firewall, firewall.client"]
check_mode: false
changed_when: false
- name: Verify the default firewalld zone
ansible.builtin.command:
argv: [firewall-cmd, --get-default-zone]
check_mode: false
changed_when: false
register: us4_firewalld_default_zone
- name: Read runtime public-zone services
ansible.builtin.command:
argv: [firewall-cmd, --zone=public, --list-services]
check_mode: false
changed_when: false
register: us4_firewalld_runtime_services
- name: Read permanent public-zone services
ansible.builtin.command:
argv: [firewall-cmd, --permanent, --zone=public, --list-services]
check_mode: false
changed_when: false
register: us4_firewalld_permanent_services
- name: Read runtime public-zone ports
ansible.builtin.command:
argv: [firewall-cmd, --zone=public, --list-ports]
check_mode: false
changed_when: false
register: us4_firewalld_runtime_ports
- name: Read permanent public-zone ports
ansible.builtin.command:
argv: [firewall-cmd, --permanent, --zone=public, --list-ports]
check_mode: false
changed_when: false
register: us4_firewalld_permanent_ports
- name: Normalize the audited public-zone state
ansible.builtin.set_fact:
us4_firewalld_pre_services: "{{ us4_firewalld_runtime_services.stdout.split() | sort }}"
us4_firewalld_pre_permanent_services: "{{ us4_firewalld_permanent_services.stdout.split() | sort }}"
us4_firewalld_pre_ports: "{{ us4_firewalld_runtime_ports.stdout.split() | sort }}"
us4_firewalld_pre_permanent_ports: "{{ us4_firewalld_permanent_ports.stdout.split() | sort }}"
- name: Fail closed on public-zone drift or unknown allowances
ansible.builtin.assert:
that:
- us4_firewalld_default_zone.stdout == us4_firewalld_zone
- us4_firewalld_pre_services == us4_firewalld_pre_permanent_services
- us4_firewalld_pre_ports == us4_firewalld_pre_permanent_ports
- us4_firewalld_keep_services | difference(us4_firewalld_pre_services) | length == 0
- us4_firewalld_pre_services | difference(us4_firewalld_keep_services + us4_firewalld_stale_services) | length == 0
- us4_firewalld_pre_ports | difference(us4_firewalld_stale_ports) | length == 0
fail_msg: >-
The public zone differs from the audited baseline. Stop and review it;
this playbook will not infer whether an unknown allowance is required.
- name: Select only audited stale entries that currently exist
ansible.builtin.set_fact:
us4_firewalld_cleanup_services: >-
{{ us4_firewalld_stale_services | intersect(us4_firewalld_pre_services) | sort }}
us4_firewalld_cleanup_ports: >-
{{ us4_firewalld_stale_ports | intersect(us4_firewalld_pre_ports) | sort }}
- name: Report the proposed reconciliation
ansible.builtin.debug:
msg:
keep_services: "{{ us4_firewalld_keep_services }}"
remove_services: "{{ us4_firewalld_cleanup_services }}"
remove_ports: "{{ us4_firewalld_cleanup_ports }}"
reload_or_restart: false
- name: Create rollback material when cleanup is required
when:
- not ansible_check_mode
- us4_firewalld_cleanup_services | length > 0 or us4_firewalld_cleanup_ports | length > 0
block:
- name: Create the protected firewall backup root
ansible.builtin.file:
path: "{{ us4_firewalld_backup_root }}"
state: directory
owner: root
group: root
mode: "0700"
- name: Create a backup timestamp
ansible.builtin.command:
argv: [date, +%Y%m%dT%H%M%S%z]
changed_when: false
register: us4_firewalld_backup_timestamp
- name: Set the protected backup directory
ansible.builtin.set_fact:
us4_firewalld_backup_dir: >-
{{ us4_firewalld_backup_root }}/{{ us4_firewalld_backup_timestamp.stdout }}
us4_firewalld_rollback_command: >-
{{ us4_firewalld_backup_root }}/{{ us4_firewalld_backup_timestamp.stdout }}/rollback-phase1.sh
- name: Create the protected backup directory
ansible.builtin.file:
path: "{{ us4_firewalld_backup_dir }}"
state: directory
owner: root
group: root
mode: "0700"
- name: Back up the complete firewalld configuration
ansible.builtin.command:
argv:
- tar
- --create
- --gzip
- "--file={{ us4_firewalld_backup_dir }}/firewalld.tgz"
- --directory=/etc
- firewalld
changed_when: true
- name: Capture the pre-change runtime ruleset
ansible.builtin.shell:
cmd: >-
umask 077 && nft list ruleset >
{{ us4_firewalld_backup_dir | quote }}/nft-ruleset.txt
executable: /bin/bash
changed_when: true
- name: Install the exact pre-change rollback script
ansible.builtin.copy:
dest: "{{ us4_firewalld_rollback_command }}"
owner: root
group: root
mode: "0700"
content: |
#!/bin/sh
set -eu
exec >>/var/log/us4-firewalld-phase1-rollback.log 2>&1
printf '%s rollback start\n' "$(date -Is)"
add_service() {
service=$1
/usr/bin/firewall-cmd --permanent --zone=public \
--query-service="$service" >/dev/null 2>&1 ||
/usr/bin/firewall-cmd --permanent --zone=public \
--add-service="$service"
/usr/bin/firewall-cmd --zone=public \
--query-service="$service" >/dev/null 2>&1 ||
/usr/bin/firewall-cmd --zone=public --add-service="$service"
}
add_port() {
port=$1
/usr/bin/firewall-cmd --permanent --zone=public \
--query-port="$port" >/dev/null 2>&1 ||
/usr/bin/firewall-cmd --permanent --zone=public \
--add-port="$port"
/usr/bin/firewall-cmd --zone=public \
--query-port="$port" >/dev/null 2>&1 ||
/usr/bin/firewall-cmd --zone=public --add-port="$port"
}
{% for service in us4_firewalld_cleanup_services %}
add_service {{ service }}
{% endfor %}
{% for port in us4_firewalld_cleanup_ports %}
add_port {{ port }}
{% endfor %}
/usr/bin/firewall-cmd --check-config
printf '%s rollback complete\n' "$(date -Is)"
- name: Schedule the 15-minute automatic rollback
ansible.builtin.shell:
cmd: |
set -euo pipefail
output=$(printf '%s\n' {{ us4_firewalld_rollback_command | quote }} | at now + 15 minutes 2>&1)
job_id=$(printf '%s\n' "$output" | sed -n 's/^job \([0-9][0-9]*\).*/\1/p')
test -n "$job_id"
printf '%s\n' "$job_id"
executable: /bin/bash
changed_when: true
register: us4_firewalld_rollback_job
- name: Record the automatic rollback job
ansible.builtin.set_fact:
us4_firewalld_rollback_job_id: "{{ us4_firewalld_rollback_job.stdout }}"
us4_firewalld_rollback_cancelled: false
- name: Persist the rollback job ID beside the backup
ansible.builtin.copy:
dest: "{{ us4_firewalld_backup_dir }}/phase1-at-job-id"
owner: root
group: root
mode: "0600"
content: "{{ us4_firewalld_rollback_job_id }}\n"
- name: Reconcile and verify the audited public zone
block:
- name: Remove audited stale firewalld services
ansible.posix.firewalld:
zone: "{{ us4_firewalld_zone }}"
service: "{{ item }}"
state: disabled
permanent: true
immediate: true
loop: "{{ us4_firewalld_stale_services }}"
- name: Remove audited stale firewalld ports
ansible.posix.firewalld:
zone: "{{ us4_firewalld_zone }}"
port: "{{ item }}"
state: disabled
permanent: true
immediate: true
loop: "{{ us4_firewalld_stale_ports }}"
- name: Verify the permanent firewalld configuration
ansible.builtin.command:
argv: [firewall-cmd, --check-config]
when: not ansible_check_mode
changed_when: false
- name: Read reconciled runtime services
ansible.builtin.command:
argv: [firewall-cmd, --zone=public, --list-services]
changed_when: false
when: not ansible_check_mode
register: us4_firewalld_after_runtime_services
- name: Read reconciled permanent services
ansible.builtin.command:
argv: [firewall-cmd, --permanent, --zone=public, --list-services]
changed_when: false
when: not ansible_check_mode
register: us4_firewalld_after_permanent_services
- name: Read reconciled runtime ports
ansible.builtin.command:
argv: [firewall-cmd, --zone=public, --list-ports]
changed_when: false
when: not ansible_check_mode
register: us4_firewalld_after_runtime_ports
- name: Read reconciled permanent ports
ansible.builtin.command:
argv: [firewall-cmd, --permanent, --zone=public, --list-ports]
changed_when: false
when: not ansible_check_mode
register: us4_firewalld_after_permanent_ports
- name: Require the exact audited post-change public zone
ansible.builtin.assert:
that:
- us4_firewalld_after_runtime_services.stdout.split() | sort == us4_firewalld_keep_services | sort
- us4_firewalld_after_permanent_services.stdout.split() | sort == us4_firewalld_keep_services | sort
- us4_firewalld_after_runtime_ports.stdout.split() | length == 0
- us4_firewalld_after_permanent_ports.stdout.split() | length == 0
when: not ansible_check_mode
- name: Verify a fresh independent SSH and sudo path
ansible.builtin.command:
argv:
- ssh
- -4
- -o
- BatchMode=yes
- -o
- ConnectTimeout=10
- -o
- ControlMaster=no
- -o
- ControlPath=none
- windy@us4.wsvc.info
- sudo -n true
delegate_to: localhost
become: false
changed_when: false
when: not ansible_check_mode
vars:
ansible_become: false
- name: Verify public HTTPS routes
ansible.builtin.uri:
url: "{{ item.url }}"
follow_redirects: all
status_code: "{{ item.status }}"
validate_certs: true
use_proxy: false
loop:
- {url: https://update.wsvc.info/, status: 200}
- {url: https://us4-gate.wsvc.info/, status: 401}
- {url: https://trlm.wsvc.info/, status: 200}
delegate_to: localhost
become: false
when: not ansible_check_mode
vars:
ansible_become: false
- name: Verify the secondary MX TCP listener externally
ansible.builtin.wait_for:
host: "{{ ansible_host_ipv4 }}"
port: 25
state: started
connect_timeout: 5
timeout: 10
delegate_to: localhost
become: false
when: not ansible_check_mode
vars:
ansible_become: false
- name: Verify all expected containers are running
ansible.builtin.command:
argv: [docker, ps, --format, "{{ '{{.Names}}' }}"]
changed_when: false
when: not ansible_check_mode
register: us4_firewalld_running_containers
- name: Reject missing application containers
ansible.builtin.assert:
that:
- us4_firewalld_expected_containers | difference(us4_firewalld_running_containers.stdout_lines) | length == 0
when: not ansible_check_mode
- name: Verify Fail2ban remains active
ansible.builtin.command:
argv: [fail2ban-client, status]
changed_when: false
when: not ansible_check_mode
register: us4_firewalld_fail2ban
- name: Require all audited Fail2ban jails
ansible.builtin.assert:
that:
- item in us4_firewalld_fail2ban.stdout
loop:
- postfix-postscreen
- postfix-sasl
- recidive
- sshd
when: not ansible_check_mode
- name: Verify the deployed WireGuard health check
ansible.builtin.command:
argv: [/usr/local/lib/vps-health/run]
changed_when: false
when: not ansible_check_mode
register: us4_firewalld_wireguard_health
- name: Cancel automatic rollback only after all checks pass
ansible.builtin.command:
argv: [at, -r, "{{ us4_firewalld_rollback_job_id }}"]
changed_when: true
when:
- not ansible_check_mode
- us4_firewalld_cleanup_services | length > 0 or us4_firewalld_cleanup_ports | length > 0
- name: Mark the automatic rollback as cancelled
ansible.builtin.set_fact:
us4_firewalld_rollback_cancelled: true
when:
- not ansible_check_mode
- us4_firewalld_cleanup_services | length > 0 or us4_firewalld_cleanup_ports | length > 0
rescue:
- name: Preserve the automatic rollback and stop
ansible.builtin.fail:
msg: >-
A reconciliation or verification task failed. No reload was
attempted. If cleanup was required, its automatic rollback remains
scheduled; do not remove it manually.
always:
- name: Report backup and rollback disposition
ansible.builtin.debug:
msg:
backup: >-
{{ us4_firewalld_backup_dir |
default('not-created-in-check-mode' if ansible_check_mode else 'not-required') }}
automatic_rollback: >-
{{ 'not-created-in-check-mode' if ansible_check_mode else
('cancelled-after-success' if (us4_firewalld_rollback_cancelled | default(false)) else
'scheduled-or-executed') if
(us4_firewalld_cleanup_services | length > 0 or us4_firewalld_cleanup_ports | length > 0)
else 'not-required' }}
+62
View File
@@ -0,0 +1,62 @@
---
# This owns the non-secret Compose declaration only. WireGuard key material
# remains under the server-local /opt/wireguard/config bind mount.
- name: Apply the reviewed WireGuard Compose hardening
hosts: wireguard
become: true
gather_facts: false
serial: 1
vars:
wireguard_harden_confirm: false
wireguard_compose_candidate: "{{ compose_project_dir }}/compose.yml.candidate"
tasks:
- name: Require explicit WireGuard hardening confirmation
ansible.builtin.assert:
that:
- wireguard_harden_confirm | bool
fail_msg: >-
This operation replaces the non-secret Compose declaration. Run only
during the approved maintenance window after a rollback archive exists.
- name: Verify that a protected pre-change archive exists
ansible.builtin.find:
paths: "{{ compose_project_dir }}/backups"
patterns: 'pre-hardening-*.tar.gz'
file_type: file
register: wireguard_pre_hardening_archives
- name: Require a pre-change rollback archive
ansible.builtin.assert:
that:
- wireguard_pre_hardening_archives.matched | int > 0
fail_msg: >-
Create a protected pre-hardening archive before replacing compose.yml.
- name: Render the candidate non-secret Compose declaration
ansible.builtin.template:
src: ../templates/wireguard-compose.yml.j2
dest: "{{ wireguard_compose_candidate }}"
owner: root
group: root
mode: "0644"
- name: Validate the candidate Compose declaration
ansible.builtin.command:
argv:
- docker
- compose
- --project-directory
- "{{ compose_project_dir }}"
- --file
- "{{ wireguard_compose_candidate }}"
- config
- --quiet
changed_when: false
- name: Atomically activate the validated Compose declaration
ansible.builtin.command:
argv:
- mv
- "{{ wireguard_compose_candidate }}"
- "{{ compose_project_dir }}/compose.yml"
changed_when: true
+114
View File
@@ -0,0 +1,114 @@
---
# Deliberately narrow maintenance path for the us4 WireGuard service. The
# Compose file and all private key material remain on the server; this playbook
# only pulls its declared immutable image and recreates the named service.
- name: Update and recreate the approved WireGuard service
hosts: wireguard
become: true
gather_facts: false
serial: 1
vars:
wireguard_maintenance_confirm: false
tasks:
- name: Require explicit WireGuard maintenance confirmation
ansible.builtin.assert:
that:
- wireguard_maintenance_confirm | bool
fail_msg: >-
This operation pulls the image declared in /opt/wireguard/compose.yml
and briefly recreates WireGuard. Set wireguard_maintenance_confirm=true
only during the approved maintenance window.
- name: Create the local WireGuard rollback directory
ansible.builtin.file:
path: "{{ compose_project_dir }}/backups"
state: directory
owner: root
group: root
mode: "0700"
- name: Create a timestamp for the rollback archive
ansible.builtin.command:
argv: [date, +%Y%m%dT%H%M%SZ]
changed_when: false
register: wireguard_backup_timestamp
- name: Create a protected local rollback archive of WireGuard configuration
ansible.builtin.shell:
cmd: >-
umask 077 && tar --create --gzip
--file={{ compose_project_dir }}/backups/config-{{ wireguard_backup_timestamp.stdout }}.tar.gz
--directory={{ compose_project_dir }} config
executable: /bin/bash
changed_when: true
- name: Verify the compose declaration uses an immutable image digest
ansible.builtin.command:
argv:
- docker
- compose
- --project-directory
- "{{ compose_project_dir }}"
- config
changed_when: false
register: wireguard_compose_config
- name: Reject mutable WireGuard image declarations
ansible.builtin.assert:
that:
- "'@sha256:' in wireguard_compose_config.stdout"
fail_msg: >-
Pin the WireGuard image to an approved immutable digest before using
this maintenance playbook.
- name: Pull the declared WireGuard image
ansible.builtin.command:
argv:
- docker
- compose
- --project-directory
- "{{ compose_project_dir }}"
- pull
- wireguard
changed_when: true
- name: Recreate the WireGuard container
ansible.builtin.command:
argv:
- docker
- compose
- --project-directory
- "{{ compose_project_dir }}"
- up
- -d
- --no-deps
- --force-recreate
- wireguard
changed_when: true
- name: Verify WireGuard container state
ansible.builtin.command:
argv:
- docker
- compose
- --project-directory
- "{{ compose_project_dir }}"
- ps
- --all
changed_when: false
register: wireguard_compose_ps
- name: Display WireGuard container state
ansible.builtin.debug:
var: wireguard_compose_ps.stdout_lines
- name: Run the deployed WireGuard health check
ansible.builtin.command:
argv: [/usr/local/lib/vps-health/run]
changed_when: false
register: wireguard_health_report
failed_when: wireguard_health_report.rc not in [0, 1]
- name: Display sanitized WireGuard health result
ansible.builtin.debug:
msg: "{{ wireguard_health_report.stdout | default(wireguard_health_report.stderr, true) }}"
+5
View File
@@ -0,0 +1,5 @@
---
collections:
# us4-firewalld.yml was source-reviewed and exercised with this version.
- name: ansible.posix
version: 2.2.2
+7 -1
View File
@@ -7,7 +7,13 @@ healthcheck_timer_on_calendar: '*-*-* 06:15:00'
healthcheck_timer_randomized_delay_sec: 15m
healthcheck_backup_max_age_hours: 30
healthcheck_tls_warn_days: 21
healthcheck_profiles:
# Map of profile name -> installed script filename. A host selects which
# profiles it runs via the `healthcheck_profiles` list (inventory).
healthcheck_profile_scripts:
mailcow: mailcow.sh
vaultwarden: vaultwarden.sh
pdns: pdns.sh
wireguard: wireguard.sh
adguardhome: adguardhome.sh
rustdesk: rustdesk.sh
hk2aux: hk2aux.sh
+8 -6
View File
@@ -1,9 +1,10 @@
---
- name: Validate known health-check profile
- name: Validate known health-check profiles
ansible.builtin.assert:
that:
- healthcheck_profile in healthcheck_profiles
fail_msg: "Unsupported healthcheck_profile: {{ healthcheck_profile }}"
- item in healthcheck_profile_scripts
fail_msg: "Unsupported healthcheck_profile: {{ item }}"
loop: "{{ healthcheck_profiles }}"
- name: Install health-check directories
ansible.builtin.file:
@@ -28,13 +29,14 @@
group: root
mode: "0755"
- name: Install service health-check script
- name: Install service health-check scripts
ansible.builtin.template:
src: "{{ healthcheck_profiles[healthcheck_profile] }}.j2"
dest: "{{ healthcheck_install_root }}/{{ healthcheck_profiles[healthcheck_profile] }}"
src: "{{ healthcheck_profile_scripts[item] }}.j2"
dest: "{{ healthcheck_install_root }}/{{ healthcheck_profile_scripts[item] }}"
owner: root
group: root
mode: "0755"
loop: "{{ healthcheck_profiles }}"
- name: Install health-check dispatcher
ansible.builtin.template:
@@ -0,0 +1,75 @@
#!/usr/bin/env bash
set -uo pipefail
source '{{ healthcheck_install_root }}/health-common.sh'
require_command docker
require_command curl
require_command dig
require_command ss
require_command df
check_compose
container='adguardhome'
if ! docker inspect "$container" >/dev/null 2>&1; then
record critical 'adguardhome_container_missing'
else
running="$(docker inspect "$container" --format '{{ '{{' }}.State.Running{{ '}}' }}' 2>/dev/null)"
[[ "$running" == true ]] || record critical 'adguardhome_container_not_running'
fi
config_check="$(docker exec "$container" /opt/adguardhome/AdGuardHome --check-config \
-c /opt/adguardhome/conf/AdGuardHome.yaml 2>&1 || true)"
if grep -qiE 'error|invalid|fatal' <<<"$config_check"; then
record critical 'adguardhome_config_invalid'
else
record ok 'adguardhome_config_valid'
fi
if ss -H -ltn 2>/dev/null | awk '{print $4}' | grep -Eq '(^|:)53$'; then
record ok 'dns_tcp_53_listening'
else
record critical 'dns_tcp_53_missing'
fi
if ss -H -uln 2>/dev/null | awk '{print $4}' | grep -Eq '(^|:)53$'; then
record ok 'dns_udp_53_listening'
else
record critical 'dns_udp_53_missing'
fi
if ss -H -ltn 2>/dev/null | awk '{print $4}' | grep -Eq '(^|:)80$'; then
record ok 'ui_http_80_listening'
else
record critical 'ui_http_80_missing'
fi
dns_a="$(dig +time=5 +tries=1 @192.168.66.36 example.com A +noall +answer 2>/dev/null)"
[[ -n "$dns_a" ]] && record ok 'dns_example_a' || record critical 'dns_example_a_failed'
dns_aaaa="$(dig +time=5 +tries=1 @192.168.66.36 example.com AAAA +noall +answer 2>/dev/null)"
[[ -n "$dns_aaaa" ]] && record ok 'dns_example_aaaa' || record warning 'dns_example_aaaa_none'
dns_local="$(dig +time=5 +tries=1 @192.168.66.36 hass.windy.lan A +noall +answer 2>/dev/null)"
[[ -n "$dns_local" ]] && record ok 'dns_local_name' || record warning 'dns_local_name_unresolved'
ui_code="$(curl --silent --show-error --output /dev/null --write-out '%{http_code}' --max-time 10 http://192.168.66.36/ 2>/dev/null || true)"
case "$ui_code" in
200|301|302) record ok "ui_http_${ui_code}" ;;
'') record critical 'ui_unreachable' ;;
*) record critical "ui_http_${ui_code}" ;;
esac
usage="$(df -P /opt/adguardhome/work 2>/dev/null | awk 'NR==2 {print $5}' | tr -d '%')"
if [[ "$usage" =~ ^[0-9]+$ ]]; then
if (( usage >= 90 )); then record critical 'disk_usage_high'
elif (( usage >= 80 )); then record warning 'disk_usage_elevated'
else record ok 'disk_usage_ok'; fi
else
record unknown 'disk_usage_unknown'
fi
logs="$(docker logs --since 30m "$container" 2>&1 || true)"
if grep -qiE '\[(error|warn|fatal)\]|level=(error|warn|fatal)' <<<"$logs"; then
record warning 'adguardhome_log_errors'
else
record ok 'adguardhome_logs_clean'
fi
emit_result
exit "$EXIT_CODE"
@@ -7,7 +7,7 @@ set -uo pipefail
RESULT_DIR='{{ healthcheck_state_dir }}'
LOG_DIR='{{ healthcheck_log_dir }}'
HOST_NAME="$(hostname -f 2>/dev/null || hostname)"
CHECK_NAME='{{ healthcheck_profile }}'
CHECK_NAME="$(basename "$0" .sh)"
STATUS=ok
EXIT_CODE=0
DETAILS=()
@@ -77,8 +77,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 +93,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,18 @@
#!/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 %}
aggregate_result{% for profile in healthcheck_profiles %} {{ healthcheck_profile_scripts[profile] | replace('.sh', '') }}{% endfor %}
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"
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
set -uo pipefail
source '{{ healthcheck_install_root }}/health-common.sh'
require_command docker
require_command ss
check_compose
container='wireguard'
if ! docker inspect "$container" >/dev/null 2>&1; then
record critical 'wireguard_container_missing'
else
running="$(docker inspect "$container" --format '{{ '{{' }}.State.Running{{ '}}' }}' 2>/dev/null)"
[[ "$running" == true ]] || record critical 'wireguard_container_not_running'
interface="$(docker exec "$container" wg show interfaces 2>/dev/null || true)"
grep -qw 'wg0' <<<"$interface" || record critical 'wireguard_wg0_missing'
configured_peer_count="$(docker exec "$container" sh -c 'find /config -mindepth 1 -maxdepth 1 -type d -name "peer_*" | wc -l' 2>/dev/null || true)"
runtime_peer_count="$(docker exec "$container" wg show wg0 dump 2>/dev/null | awk 'NR > 1 { count++ } END { print count + 0 }')"
[[ "$configured_peer_count" =~ ^[0-9]+$ ]] || record unknown 'wireguard_configured_peer_count_unknown'
[[ "$runtime_peer_count" =~ ^[0-9]+$ ]] || record unknown 'wireguard_runtime_peer_count_unknown'
if [[ "$configured_peer_count" =~ ^[0-9]+$ && "$runtime_peer_count" =~ ^[0-9]+$ ]]; then
[[ "$configured_peer_count" -eq "$runtime_peer_count" ]] || record warning "wireguard_peer_count_mismatch_${configured_peer_count}_${runtime_peer_count}"
fi
fi
if ss -H -uln 2>/dev/null | awk '{print $4}' | grep -Eq '(^|:)51820$'; then
record ok 'wireguard_udp_51820_listening'
else
record critical 'wireguard_udp_51820_missing'
fi
emit_result
exit "$EXIT_CODE"
+3
View File
@@ -1,5 +1,8 @@
---
restic_enabled: false
# Approved Restic source profile (a key of restic_sources) for the host. Set
# per-host in inventory; the role fails if it is not an approved source.
restic_backup_profile: ""
restic_binary: /usr/bin/restic
restic_config_path: /etc/vps-restic/repository.env
restic_state_dir: /var/lib/vps-restic
+2 -2
View File
@@ -10,8 +10,8 @@
- name: Validate supported Restic source profile
ansible.builtin.assert:
that:
- healthcheck_profile in restic_sources
fail_msg: "No approved Restic source profile for {{ healthcheck_profile }}."
- restic_backup_profile in restic_sources
fail_msg: "No approved Restic source profile for {{ restic_backup_profile }}."
- name: Verify Restic binary exists on target
ansible.builtin.stat:
+1 -1
View File
@@ -3,4 +3,4 @@ set -euo pipefail
# Repository and password credentials are host-local in {{ restic_config_path }}.
# shellcheck source=/dev/null
source '{{ restic_config_path }}'
exec '{{ restic_binary }}' backup --tag '{{ healthcheck_profile }}' --tag "$(hostname -s)" {% for source in restic_sources[healthcheck_profile] %}{{ source | quote }} {% endfor %}
exec '{{ restic_binary }}' backup --tag '{{ restic_backup_profile }}' --tag "$(hostname -s)" {% for source in restic_sources[restic_backup_profile] %}{{ source | quote }} {% endfor %}
@@ -2,4 +2,4 @@
set -euo pipefail
# shellcheck source=/dev/null
source '{{ restic_config_path }}'
exec '{{ restic_binary }}' forget --prune --keep-daily {{ restic_keep_daily }} --keep-weekly {{ restic_keep_weekly }} --keep-monthly {{ restic_keep_monthly }} --tag '{{ healthcheck_profile }}'
exec '{{ restic_binary }}' forget --prune --keep-daily {{ restic_keep_daily }} --keep-weekly {{ restic_keep_weekly }} --keep-monthly {{ restic_keep_monthly }} --tag '{{ restic_backup_profile }}'
@@ -1,5 +1,5 @@
[Unit]
Description=Restic backup for approved {{ healthcheck_profile }} sources
Description=Restic backup for approved {{ restic_backup_profile }} sources
After=network-online.target
Wants=network-online.target
+13
View File
@@ -0,0 +1,13 @@
---
# RustDesk server deployment (hbbs + hbbr) on hk2.
# Safe by default: without rustdesk_confirm=true the role only reports whether
# the declared compose file matches live state and refuses to recreate the stack.
rustdesk_confirm: false
# Compose project directory.
rustdesk_compose_dir: /opt/rustdesk
# Relay (hbbr) hostname:port advertised to every client via `hbbs -r`.
# MUST resolve to this host's public IP (154.36.174.161). The known-bad value
# 'hk2.wsvc.info' has no DNS record and must never be used.
rustdesk_relay: hk2.chans.xyz:21117
# Pinned server image (used for both hbbs and hbbr).
rustdesk_image: rustdesk/rustdesk-server:1.1.14
+85
View File
@@ -0,0 +1,85 @@
---
# Deploy/reconcile the self-hosted RustDesk server (hbbs + hbbr).
# Idempotent: deploys the declared compose file; only recreates the stack with
# explicit confirmation.
- name: Validate relay address is set and not the known-bad value
ansible.builtin.assert:
that:
- rustdesk_relay | length > 0
- "'hk2.wsvc.info' not in rustdesk_relay"
fail_msg: >-
rustdesk_relay must be a resolvable relay address. The known-bad
'hk2.wsvc.info' has no DNS record and must not be used.
- name: Ensure compose project directory exists
ansible.builtin.file:
path: "{{ rustdesk_compose_dir }}"
state: directory
owner: windy
group: root
mode: "0755"
- name: Deploy compose file
ansible.builtin.template:
src: compose.yml.j2
dest: "{{ rustdesk_compose_dir }}/compose.yml"
owner: windy
group: windy
mode: "0644"
register: rustdesk_compose_deployed
- name: Report no change needed
ansible.builtin.debug:
msg: "compose.yml already matches declared state; no change needed."
when: not rustdesk_compose_deployed.changed
- name: Refuse to recreate without explicit confirmation
ansible.builtin.fail:
msg: >-
compose.yml differs from declared state but rustdesk_confirm is not true.
Supply rustdesk_confirm=true to deploy the file and recreate the stack.
when:
- rustdesk_compose_deployed.changed
- not (rustdesk_confirm | bool)
- not ansible_check_mode
- name: Apply compose stack
ansible.builtin.command:
argv:
- docker
- compose
- --project-directory
- "{{ rustdesk_compose_dir }}"
- up
- -d
when:
- rustdesk_compose_deployed.changed
- rustdesk_confirm | bool
changed_when: true
register: rustdesk_apply
- name: Verify hbbs relay command
ansible.builtin.command:
argv:
- docker
- inspect
- hbbs
- --format
- '{{ "{{" }}json .Config.Cmd{{ "}}" }}'
register: rustdesk_hbbs_cmd
changed_when: false
when:
- rustdesk_compose_deployed.changed
- rustdesk_confirm | bool
- not ansible_check_mode
- name: Assert hbbs advertises the declared relay
ansible.builtin.assert:
that:
- "'{{ rustdesk_relay }}' in rustdesk_hbbs_cmd.stdout"
fail_msg: "hbbs is not advertising the declared relay {{ rustdesk_relay }}."
when:
- rustdesk_compose_deployed.changed
- rustdesk_confirm | bool
- not ansible_check_mode
@@ -0,0 +1,34 @@
networks:
rustdesk-net:
external: false
services:
hbbs:
container_name: hbbs
ports:
- 21115:21115
- 21116:21116
- 21116:21116/udp
- 21118:21118
image: {{ rustdesk_image }}
command: "hbbs -r {{ rustdesk_relay }}"
volumes:
- ./hbbs:/root
networks:
- rustdesk-net
depends_on:
- hbbr
restart: unless-stopped
hbbr:
container_name: hbbr
ports:
- 21117:21117
- 21119:21119
image: {{ rustdesk_image }}
command: hbbr
volumes:
- ./hbbr:/root
networks:
- rustdesk-net
restart: unless-stopped
+11
View File
@@ -0,0 +1,11 @@
---
# Reconcile super_sdn.sso_login_enabled on the UniFi Network Controller DB.
# Safe by default: without unifi_sso_confirm=true the role only reports the
# current state and refuses to change anything.
unifi_sso_confirm: false
# Declared desired state (true = Sync Local Admin with Ubiquiti SSO ON).
unifi_sso_target_value: false
# Restart the controller container after applying a change. Only needed when
# the setting was changed directly in the DB while the controller was running
# (the UI path applies it immediately without a restart).
unifi_sso_restart_controller: false
+110
View File
@@ -0,0 +1,110 @@
---
# Reconcile super_sdn.sso_login_enabled on the UniFi controller DB.
# Idempotent: reads the current value, reports compliance when it already
# matches the target, and only mutates with explicit confirmation.
- name: Require a boolean target value
ansible.builtin.assert:
that:
- unifi_sso_target_value | type_debug == 'bool'
fail_msg: "unifi_sso_target_value must be a boolean (true or false)."
- name: Read current SSO login setting
ansible.builtin.command:
argv:
- docker
- exec
- "{{ unifi_container }}"
- mongo
- --port
- "{{ unifi_mongo_port }}"
- --quiet
- ace
- --eval
- 'db.setting.findOne({key:"super_sdn"}).sso_login_enabled'
register: unifi_sso_current
changed_when: false
failed_when: unifi_sso_current.rc != 0
check_mode: false
- name: Report current setting
ansible.builtin.debug:
msg: >-
sso_login_enabled={{ unifi_sso_current.stdout | trim }}
(target={{ unifi_sso_target_value | string | lower }})
- name: Report already compliant
ansible.builtin.debug:
msg: "Already compliant; no change needed."
when: unifi_sso_current.stdout | trim | lower == unifi_sso_target_value | string | lower
- name: Refuse to change without explicit confirmation
ansible.builtin.fail:
msg: >-
Change required (sso_login_enabled={{ unifi_sso_current.stdout | trim }}
!= target {{ unifi_sso_target_value | string | lower }}) but
unifi_sso_confirm is not true. Supply unifi_sso_confirm=true to apply.
when:
- unifi_sso_current.stdout | trim | lower != unifi_sso_target_value | string | lower
- not (unifi_sso_confirm | bool)
- name: Apply SSO login setting change
ansible.builtin.command:
argv:
- docker
- exec
- "{{ unifi_container }}"
- mongo
- --port
- "{{ unifi_mongo_port }}"
- --quiet
- ace
- --eval
- >-
db.setting.updateOne({key:"super_sdn"},
{$set:{sso_login_enabled: {{ unifi_sso_target_value | string | lower }}}})
when:
- unifi_sso_confirm | bool
- unifi_sso_current.stdout | trim | lower != unifi_sso_target_value | string | lower
changed_when: true
register: unifi_sso_update
- name: Restart controller to apply setting when requested
ansible.builtin.command:
argv: [docker, restart, "{{ unifi_container }}"]
when:
- unifi_sso_restart_controller | bool
- unifi_sso_current.stdout | trim | lower != unifi_sso_target_value | string | lower
changed_when: true
- name: Verify setting after change
ansible.builtin.command:
argv:
- docker
- exec
- "{{ unifi_container }}"
- mongo
- --port
- "{{ unifi_mongo_port }}"
- --quiet
- ace
- --eval
- 'db.setting({key:"super_sdn"}).sso_login_enabled'
register: unifi_sso_verify
changed_when: false
check_mode: false
when:
- unifi_sso_current.stdout | trim | lower != unifi_sso_target_value | string | lower
- not ansible_check_mode
- name: Assert verified value matches target
ansible.builtin.assert:
that:
- unifi_sso_verify.stdout | trim | lower == unifi_sso_target_value | string | lower
fail_msg: >-
Verification failed: sso_login_enabled is
{{ unifi_sso_verify.stdout | trim }} but expected
{{ unifi_sso_target_value | string | lower }}.
when:
- unifi_sso_current.stdout | trim | lower != unifi_sso_target_value | string | lower
- not ansible_check_mode
@@ -0,0 +1,24 @@
services:
wireguard:
image: {{ wireguard_image }}
container_name: wireguard
cap_add:
- NET_ADMIN
environment:
- PUID=1000
- PGID=1000
- TZ=Asia/Shanghai
- SERVERURL=us4.wsvc.info
- SERVERPORT=51820
- PEERS=ha,phone,mbp
- PEERDNS=1.1.1.1
# Intentional: client IPv6 traffic is not tunneled.
- ALLOWEDIPS=0.0.0.0/0
ports:
- 51820:51820/udp
volumes:
- ./config:/config
read_only: true
tmpfs:
- /run:exec
restart: unless-stopped
@@ -0,0 +1,164 @@
# AdGuard Home official configuration review basis (2026-08)
This page is the **official source basis and recommendation baseline** used
during the on-site review of AdGuard Home (AGH) on `192.168.66.36`. It does not
claim to have read that host's configuration and contains no credentials.
On-site conclusions must be based on the live `AdGuardHome.yaml`, OpenWrt
`dnsmasq`/DHCP, firewall, routing, and actual client leases.
Official sources (AdGuard only):
- [Configuration](https://adguard-dns.io/kb/adguard-home/configuration/)
- [Setting up AdGuard Home securely](https://adguard-dns.io/kb/adguard-home/running-securely/)
- [Getting started](https://adguard-dns.io/kb/adguard-home/getting-started/)
- [DHCP](https://adguard-dns.io/kb/adguard-home/dhcp/)
- [DNS encryption](https://adguard-dns.io/kb/adguard-home/encryption/)
## Recommended role split (for the existing OpenWrt gateway)
Preferred: keep **OpenWrt/dnsmasq as the single DHCP and local-domain
authority, with AGH as the LAN DNS filter/forwarder**: DHCP option 6 hands
clients `.36`; AGH sets the router's local DNS (usually the gateway LAN IP) as
`local_ptr_upstreams` and, where needed, uses domain-specific upstreams to route
the LAN search domain / local names back to dnsmasq. Clients are filtered while
still keeping DHCP lease names, static hosts, local reverse resolution, and
gateway-internal service discovery.
Rationale: AGH supports dnsmasq-style domain-specific upstreams for internal
private name servers; private PTR should be configured in **Private reverse DNS
servers** / `local_ptr_upstreams` rather than regular upstreams; when not
explicitly configured, it falls back to the OS default resolver, which shifts as
host networking changes. [Official config: domain-specific upstreams](https://adguard-dns.io/kb/adguard-home/configuration/#configuring-upstreams);
[private rDNS](https://adguard-dns.io/kb/adguard-home/configuration/#private-addresses).
Do not have OpenWrt dnsmasq and AGH both listen on `:53` at the same LAN IP;
port conflicts, or pointing dnsmasq's upstream at AGH while AGH points back at
dnsmasq, cause failures/forwarding loops. When AGH runs on a separate `.36`
host, the simplest topology is clients → AGH `.36:53` → public/encrypted
upstreams, forwarding only local resolution (PTR, LAN domain) → gateway dnsmasq.
This is an operational inference based on the official capabilities above and
must be validated against the live `ss -lntup`, dnsmasq config, and query path.
AGH also has built-in DHCP, but it should only be enabled after **disabling the
existing gateway DHCP, explicitly migrating leases/reservations and verifying
options 3/6**. The official docs describe it as an alternative when the router
cannot hand out custom DNS, not as a dual-DHCP setup; the DHCP docs also state
leases/static leases are managed by AGH. [Getting started: router DHCP/DNS
handout and alternative DHCP](https://adguard-dns.io/kb/adguard-home/getting-started/#router);
[DHCP configuration](https://adguard-dns.io/kb/adguard-home/dhcp/).
## Exposure and access control
1. `dns.bind_hosts` should bind only loopback and the actual LAN address (here
prefer `.36`'s LAN address); do not use `0.0.0.0`/public interfaces for a
home recursive resolver. The official docs note full-interface binding
exposes DDoS risk; a small isolated network should pick local service
interfaces and may add loopback alongside. [Secure deployment: server
addresses](https://adguard-dns.io/kb/adguard-home/running-securely/#choosing-server-addresses);
[`bind_hosts` definition](https://adguard-dns.io/kb/adguard-home/configuration/#dns).
2. In the firewall, allow only managed LAN/VLAN → `.36` TCP/UDP 53; bind the web
admin UI to the management network/loopback and avoid WAN forwarding. At the
app layer, further restrict with `allowed_clients` to the local subnets; when
non-empty, only the CIDR/IP/ClientIDs in it are accepted, and it overrides
the effect of `disallowed_clients`. [Official access
control](https://adguard-dns.io/kb/adguard-home/configuration/#dns).
3. Keep `refuse_any: true` and a non-zero `ratelimit` (default 20 qps), unless
on-site evidence shows the service can never be reached from the internet and
LAN high-concurrency needs are data-backed; the official docs list both as
protection against reflection/DDoS. [Configuration
notes](https://adguard-dns.io/kb/adguard-home/configuration/#dns).
4. The web UI must have a user with login-failure lockout enabled;
`users: []` disables authentication, and `auth_attempts` or `block_auth_min`
of 0 disables the corresponding lockout logic. [Web/auth
parameters](https://adguard-dns.io/kb/adguard-home/configuration/#users).
## Upstreams, encryption, and DNSSEC
- Use two trusted, differently-networked DoH/DoT/DoQ upstreams for normal public
resolution; AGH supports these protocols and DNS Stamps. If an upstream
URL/hostname needs resolving, `bootstrap_dns` is its initial resolver; do not
point bootstrap at a name that requires AGH itself to resolve, and use
directly reachable, stable IPs. A `fallback_dns` can be set separately; it is
used only when primary upstreams do not respond. [Upstream protocols and
bootstrap/fallback](https://adguard-dns.io/kb/adguard-home/configuration/#dns).
- If local IPv6's actual egress path is unstable, do not enable
`bootstrap_prefer_ipv6`; the official docs note it makes DoH/DoQ/DoT bootstrap
prefer IPv6. Decide on-site based on each upstream's IPv4/IPv6 reachability,
latency, and failure rate. [Same reference](https://adguard-dns.io/kb/adguard-home/configuration/#dns).
- Consider enabling `enable_dnssec`, choosing recursive upstreams that actually
handle DNSSEC; but understand precisely: officially AGH sets the DO flag on
requests to upstreams; it does not perform recursive validation itself. On
site, query a known-bad-signature domain and observe AD/failure responses to
confirm upstream validation behavior. [DNSSEC parameter
definition](https://adguard-dns.io/kb/adguard-home/configuration/#dns).
- Prefer `upstream_mode: load_balance` unless measurement shows `parallel` first
answer latency is clearly worth the privacy/traffic cost of querying all
upstreams. The former picks by failure rate and average latency weighting; the
latter queries all upstreams concurrently; this is an operational judgment
based on documented behavior. [Upstream
modes](https://adguard-dns.io/kb/adguard-home/configuration/#dns).
## Cache, ECS, and privacy
- Keep DNS cache enabled; use `cache_ttl_min`/`cache_ttl_max` overrides
cautiously to avoid stale or unnecessarily short TTLs. `cache_optimistic`
answers with a 10-second TTL after expiry and attempts refresh — good for
low-latency scenarios that tolerate briefly stale results, not to be enabled
blindly on internal domains sensitive to immediate changes. [Cache
parameters](https://adguard-dns.io/kb/adguard-home/configuration/#dns).
- ECS is off by default: when enabled, AGH adds EDNS Client Subnet to upstream
requests; if CDN geo-scheduling is truly needed, use `use_custom: true` with a
coarse, non-real-client-subnet `custom_ip`, then compare hit rate vs. privacy
impact. [ECS parameters](https://adguard-dns.io/kb/adguard-home/configuration/#dns).
- Review log retention and who can read it. If device identity is not needed,
consider `anonymize_client_ip: true`; this anonymizes client addresses in logs
and stats. [Anonymization
parameter](https://adguard-dns.io/kb/adguard-home/configuration/#dns).
## Proxy / OpenClash coexistence
If OpenClash/transparent proxy redirects DNS, validate hop by hop to ensure:
client DNS requests reach AGH uniquely; AGH's outbound traffic to public
encrypted upstreams is not hijacked back to AGH; and the router's local dnsmasq
PTR/local-domain requests return to dnsmasq. Create explicit proxy/hijack
exceptions for `.36` and its upstreams, or explicitly design the proxy to take
over AGH's outbound resolution; do not rely on "it seems to resolve" to conclude
there is no loop.
This is not an AGH-official OpenClash instruction (the official docs do not
maintain that integration), but an on-site verification requirement derived from
AGH's independent listening, upstream forwarding, and local-PTR-split
capabilities. The officially supported mechanisms are referenced at
[`bind_hosts`/upstreams/PTR](https://adguard-dns.io/kb/adguard-home/configuration/#dns).
If serving DoH via a reverse proxy, `trusted_proxies` must list only the actual
proxy IP/CIDR; AGH will only trust `X-Real-IP`, `X-Forwarded-For`, etc. from
those sources. Do not write the whole LAN/WAN for convenience;
`http.doh.insecure_enabled` should only be enabled when a reverse proxy really
terminates TLS in front. [Proxy and
DoH](https://adguard-dns.io/kb/adguard-home/configuration/#dns); [encrypted DNS
docs](https://adguard-dns.io/kb/adguard-home/encryption/). For a pure-LAN
plaintext-53 service, publishing DoH/DoT/DoQ outward is usually unnecessary.
## On-site review checklist (read-only)
1. Record the version, service method, and the full effective configuration
(secrets redacted); run the AGH config check. Officially provided via
`--check-config`. [Command-line
arguments](https://adguard-dns.io/kb/adguard-home/configuration/#command-line-arguments).
2. On `.36`, the gateway, and a wired/wireless client, record `ss` listeners,
DHCP option 6, default route, `/etc/config/dhcp`, and OpenClash
DNS/firewall-redirect rules.
3. From a client test: public A/AAAA, ad-rule hits, DHCP hostnames, forward LAN
domain, private PTR, DNSSEC bad-signature domain, and the expected failure
mode when AGH stops. Then check whether client IPs in AGH query logs are
still genuinely identifiable.
4. From the AGH host, test each upstream and bootstrap over the actual protocol
for IPv4 and IPv6 reachability; compare with transparent proxy on/off to rule
out loops and leaks.
5. Verify the web UI/DNS ports have no WAN exposure; confirm allowed clients and
firewall match actual LAN/VLAN, guest network, and VPN subnets.
Before changing anything, export the current configuration and DHCP/firewall
state; adjust one layer at a time (DHCP handout, AGH listening, upstreams, or
DNS hijack) and keep a known-good management and DNS fallback path.
+51
View File
@@ -0,0 +1,51 @@
# Domain Docs
How the engineering skills should consume this repo's domain documentation when exploring the codebase.
## Before exploring, read these
- **`CONTEXT.md`** at the repo root, or
- **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic.
- **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src/<context>/docs/adr/` for context-scoped decisions.
If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved.
## File structure
Single-context repo (most repos):
```
/
├── CONTEXT.md
├── docs/adr/
│ ├── 0001-event-sourced-orders.md
│ └── 0002-postgres-for-write-model.md
└── src/
```
Multi-context repo (presence of `CONTEXT-MAP.md` at the root):
```
/
├── CONTEXT-MAP.md
├── docs/adr/ ← system-wide decisions
└── src/
├── ordering/
│ ├── CONTEXT.md
│ └── docs/adr/ ← context-specific decisions
└── billing/
├── CONTEXT.md
└── docs/adr/
```
## Use the glossary's vocabulary
When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`).
## Flag ADR conflicts
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_
+47
View File
@@ -0,0 +1,47 @@
# Issue tracker: Linear
Issues and PRDs for this repo are tracked in Linear, driven entirely through the
Linear MCP configured in the current runtime. VPS operation records use the
Linear **`vps`** project (team `W1ndy`).
## Conventions
- **Create**: `linear_save_issue` (requires `title` + `team`); description as
real-newline Markdown.
- **Read**: `linear_get_issue <identifier>` (e.g. LIN-123), optionally with
`includeRelations`.
- **List**: `linear_list_issues`, filtered by `query` / `state` / `assignee` /
`team` / `project`.
- **Comment**: `linear_save_comment` (pass `issueId` + `body`).
- **Labels**: `linear_save_issue` `labels` field (replaces the whole label set).
- **Status**: `linear_save_issue` `state` field (type / name / ID).
Resolve team, project, and cycle names through the Linear MCP rather than
hard-coding them.
## Cross-issue constraint baseline
The `plans/` directory is retired (removed 2026-08-06). Plans and baselines now
live in the vps project document "VPS 运维重构:已确认决策与验收标准". Open work
items are tracked as individual issues with dependencies expressed via
`blockedBy` / `blocks`. Read that document before adding new tasks.
## When a skill says "publish to the issue tracker"
Create a Linear issue with `linear_save_issue` (`project: vps`).
## When a skill says "fetch the relevant ticket"
Use `linear_get_issue <identifier>`.
## Wayfinding operations
- **Map**: one issue as the map; child tickets link via `parentId`.
- **Child ticket**: `linear_save_issue` with `parentId` set to the map; labels
`wayfinder:<type>` (research / prototype / grilling / task).
- **Blocking**: `linear_save_issue` `blockedBy` / `blocks` (append-only).
- **Frontier query**: list the map's open children, excluding those blocked or
already assigned; take the first in map order.
- **Claim**: `linear_save_issue` `assignee: me`.
- **Resolve**: `linear_save_comment` the answer → update `state` → append a
pointer under the map's decisions-so-far.
+15
View File
@@ -0,0 +1,15 @@
# Triage Labels
The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.
| Label in mattpocock/skills | Label in our tracker | Meaning |
| -------------------------- | -------------------- | ---------------------------------------- |
| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
| `needs-info` | `needs-info` | Waiting on reporter for more information |
| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
| `ready-for-human` | `ready-for-human` | Requires human implementation |
| `wontfix` | `wontfix` | Will not be actioned |
When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.
Edit the right-hand column to match whatever vocabulary you actually use.
+120
View File
@@ -0,0 +1,120 @@
# EdgeRouter X configuration record
This is a read-only, sanitized configuration record for
[gw](../hosts/gw.md), taken on 2026-08-05. It intentionally excludes PPPoE
credentials, user/password configuration, key material, and full DHCP client
or reservation inventories.
## Platform and topology
| Item | Current configuration |
|---|---|
| Appliance / OS | EdgeRouter X 5-Port; EdgeOS `v3.0.1` build `5862409` |
| WAN physical interface | `eth4`, described as `Internet (PPPoE)` |
| WAN routed interface | `pppoe0`; dynamic IPv4 default route; MTU `1492` |
| LAN66 | `eth0`, `192.168.66.254/24` |
| LAN55 | `switch0`, `192.168.55.254/24`; member ports `eth1``eth3` |
| IPv4 routing | Both LAN prefixes are directly connected; default route is through `pppoe0` |
Despite its name, LAN55 is not currently an 802.1Q VLAN configuration:
`switch0` has `vlan-aware` disabled. It is a separate Layer-3 subnet carried
by the built-in switch ports, while LAN66 is connected through `eth0`.
## IPv6
The PPPoE uplink requests a delegated `/60`, enables rapid commit and assigns
SLAAC-capable `/64` networks to the LANs:
| Interface | Delegated prefix slot | Router address suffix |
|---|---|---|
| `eth0` (LAN66) | `:1` | `::1` |
| `switch0` (LAN55) | `:2` | `::1` |
IPv6 has a default route through `pppoe0`. The currently delegated prefixes
are dynamic and are deliberately not treated as persistent configuration
facts.
## Effective firewall policy
| Ruleset | Direction / attachment | Effective policy |
|---|---|---|
| `WAN_IN` | `pppoe0`, IPv4 `in` | Active; default drop; permits established/related, drops invalid |
| `WAN_LOCAL` | `pppoe0`, IPv4 `local` | Active; default drop; permits established/related, drops invalid |
| `WANv6_IN` | `pppoe0`, IPv6 `in` | Active; default drop; permits established/related and ICMPv6; drops invalid |
| `WANv6_LOCAL` | `pppoe0`, IPv6 `local` | Active; default drop; permits established/related, ICMPv6, and DHCPv6 replies; drops invalid |
| `LAN_IN` | None | Inactive; configured default drop with explicit LAN55 ↔ LAN66 allows |
| `LAN_OUT` | None | Inactive; configured default drop with an Internet allow |
Therefore, LAN55 and LAN66 currently have ordinary bidirectional routed
access. The configured `LAN_IN`/`LAN_OUT` rules do **not** enforce an access
boundary because neither is bound to an interface. From source
`192.168.55.254`, the controller at `192.168.66.46` replied to 3/3 ICMP probes
on 2026-08-05. The online LAN55 AP also confirms the operational Inform path
to `192.168.66.46:9080`.
Global firewall settings enable normal ICMP echo replies, disable broadcast
ping and IPv4/IPv6 source routing, enable SYN cookies and martian logging, set
an MSS clamp of `1412`, and leave source validation disabled. Receive redirects
are disabled; IPv4 send redirects remain enabled.
The [official EdgeOS User Guide](https://dl.ubnt.com/guides/edgemax/EdgeOS_UG.pdf)
describes the distinction: a firewall policy is a ruleset that must be applied
to its target interface and direction before it affects traffic.
## NAT and inbound exposure
IPv4 traffic leaving `pppoe0` is covered by NAT masquerade rule `5010`.
Port-forward auto-firewall and hairpin NAT are enabled. The configured WAN
forwards are below; their external reachability was not tested in this review.
| WAN protocol / port | Internal destination | Purpose |
|---|---|---|
| TCP/UDP `8123` | `192.168.55.11:8123` | Home Assistant |
| TCP/UDP `51413` | `192.168.66.51:51413` | Transmission |
| TCP/UDP `5822` | `192.168.66.36:22` | SSH forward |
| TCP/UDP `1194` | `192.168.66.32:1194` | OpenVPN |
Treat every forward as an intentional public attack surface. Changes require
the normal firewall/change review and external verification from a trusted
network.
## LAN services
| Service | LAN66 | LAN55 |
|---|---|---|
| DHCP pool | `.38``.243` | `.38``.243` |
| Default gateway | `.254` | `.254` |
| DNS server handed out | `192.168.66.36` | `192.168.66.36` |
| Lease duration | 24 hours | 24 hours |
| UniFi Controller DHCP option | `192.168.66.46` | `192.168.66.46` |
DNS forwarding listens on `eth0` and `switch0` with a cache size of `512`.
Management services are SSH/TCP `22`, HTTP/TCP `80`, and HTTPS/TCP `443`.
Since no active LAN-local firewall is documented, treat access to this gateway
management plane from either LAN as permitted unless verified otherwise.
Infrastructure devices, including the two APs and the UniFi controller, have
DHCP static mappings. Keep the authoritative full mapping list only in the
gateway configuration; do not copy unrelated household device inventories into
this operations repository.
## Acceleration and maintenance implications
At verification time, `show ubnt offload` reported that both IPSec and HWNAT
offload modules were not loaded. No `system offload` enablement was present in
the inspected configuration. This may constrain routing/NAT throughput under
load; it is an observation, not authorization to alter the router.
Use these safe effective-state checks after any planned change:
```bash
show interfaces
show ip route
show ipv6 route
show firewall
show nat rules
show ubnt offload
```
Avoid recording raw `show configuration commands` output: it includes PPPoE
credentials and may include other secrets.
+156
View File
@@ -0,0 +1,156 @@
# LAN 核心交换机升级计划(保留 ER-X)
**状态:** SE5420 **已采购**2026-08-09)。**实施与验证以 [lan-se5420-deployment-guide.md](lan-se5420-deployment-guide.md) 为准**
本文为历史规划参考,**不得作为现场执行步骤**;所有实际操作均以部署指南为准。
**锁定硬件:** TP-Link **`TL-SE5420`**16 × 2.5GbE RJ45 + 4 × 10GbE SFP+)。
**目标:** SE5420 承接全部 LAN 物理接入与二层转发;ER-X 继续承担公网、NAT、防火墙、
LAN66/LAN55 网关与 DHCP。
**拓扑与流量的详细说明**(职责、逻辑网、流量路径、Wi-Fi 分工、验收边界)见:
[lan-erx-se5420-network.md](lan-erx-se5420-network.md)。
SE5420 官方资料:静态功耗 8 W、最大功耗 32 WVLAN、LACP、STP/RSTP/MSTP、ACL、
CLI/SNMP、配置导入导出与固件下载。无 PoE——AP 使用本地取电 + 普通网线。
## 已确认边界(摘要)
| 项 | 结论 |
|---|---|
| 硬目标 | 同 VLAN 2.5GSFP+ 先空槽 |
| 核心角色 | 纯 L2;不开 L3 / DHCP Server/Relay / NAT |
| 网关 | 默认一律 ER-X `.254`;升级专用 SSID(后续)才走 `gfw` `.1` |
| 本次 Done | 阶段 03VLAN10 / 客人 SSID / 升级 SSID 另立项目 |
| 切换 | 30–60 分钟维护窗;旧交换迁完后闲置 |
| 首批 2.5G | NAS + 主力 PC(或 PVE |
| 管理 | 先本地 HTTPS/SSH;云以后再说 |
### 保留的 ER-X 职责
| 项目 | 迁移后职责 |
|---|---|
| PPPoE / WAN、NAT、端口转发、WAN 防火墙 | ER-X,不变 |
| LAN66 (`192.168.66.0/24`) 默认网关与 DHCP | ER-X `eth0`,不变 |
| LAN55 (`192.168.55.0/24`) 默认网关与 DHCP | ER-X `switch0`,不变 |
| LAN66 ↔ LAN55 三层转发 | ER-X,不变 |
| 升级专用 Wi-Fi 网关(后续) | `gfw` VM,不是 ER-X 或核心交换机 |
ER-X 与核心之间使用两条**独立无标签 access**LAN66 + LAN55),不向 ER-X 送
VLAN tag。
### 核心交换机职责
- 所有有线设备、AP 与 PVE 的物理接入;
- VLAN66、VLAN55 的二层转发;
- 为日后 U6 Lite 与 PVE 预留 VLAN10 trunk(本次不启用业务);
- 同 VLAN 的 1G/2.5G 转发;
- SFP+ 预留未来 10GDAC/AOC/光;不用 10GBASE-T 作默认)。
## 目标拓扑(简图)
```text
Internet
|
ER-X / PPPoE
+-------+--------+
| |
eth0, LAN66 switch0 port, LAN55
access access
| |
+-------+--------+
|
TL-SE5420 核心(纯 L2
| | |
PVE / gfw U6 Lite LAN66/LAN55 access
(日后 trunk (日后 trunk NAS, PC, dns, ubnt, AC-Lite …
```
完整端口表、流量路径与 Wi-Fi 分工见
[lan-erx-se5420-network.md](lan-erx-se5420-network.md)。
## VLAN 与端口设计
| VLAN / 逻辑网络 | 核心配置 | 网关 / DHCP | 用途 |
|---|---|---|---|
| 66 | accessPVE/U6 日后 trunk 的 native | ER-X `eth0` / `.254` | 主 LAN、管理 |
| 55 | access | ER-X `switch0` / `.254` | LAN55、AC-Lite |
| 10(后续) | 仅 PVE 与 U6 trunk 上 tagged | `gfw` | 升级专用 SSID |
| 管理 | LAN66 管理地址;不新建 VLAN | reservation / 静态 | SE5420 管理面 |
管理地址分配前先查 ER-X DHCP/reservation;禁用不需要的 HTTP/Telnet;云管延后;
保留 HTTPS/SSH 与离线配置备份(备份不进本仓库)。
### 初始端口分配
| 预留 | 对端 | 模式 |
|---|---|---|
| 铜口 1 | ER-X `eth0` | access VLAN66 |
| 铜口 2 | ER-X `switch0` 成员口 | access VLAN55 |
| 铜口 3 | PVE`gfw` | 现 access 66;日后 trunk native66+tag10 |
| 铜口 4 | U6 Lite | 同上;本地取电 |
| 铜口 5 | UAP-AC-Lite | access VLAN55;本地取电 |
| 其余铜口 | NAS、PC、`dns``ubnt`… | 默认 access VLAN66 |
| SFP+ 14 | 未来 | 空槽 |
## 分阶段实施与验收
### 阶段 0:采购前核验
1. 确认 SE5420 revision、保修、手册、固件页。
2. 确认 VLAN trunk、RSTP/MSTP、LACP、镜像、配置导出、错误计数。
3. SFP+ 模块本次不买;有对端后再选 DAC/AOC/光。
4. 盘点线缆与对端(LAN66/55、PVE、两台 AP);确认关键 2.5G 链路可协商。
### 阶段 1:离线初始化核心
1. 仅电源 + 隔离管理本:管理 IP、强密码、时区、NTP、HTTPS/SSH**关闭 L3/DHCP**。
2. 导出初始配置(离线保存,不进仓库)。
3. 建立 VLAN66/55/10 与端口模板;VLAN10 不接生产。
4. 开启 RSTP/MSTP;不制造物理环路。
### 阶段 2:维护窗迁移 LAN66
1. 使用约定维护窗(约 30–60 分钟);保留原接线作回滚。
2. 先接铜口 1 ↔ ER-X `eth0`;验证管理地址、`.254`、DNS `.36`、互联网。
3. 逐台迁到 VLAN66 access;每台确认地址/DNS/路由/业务。
4. 最后迁 `ubnt``dns`、NAS/PC;禁止无意双上行。
### 阶段 3:迁移 LAN55
1. 接铜口 2 ↔ ER-X `switch0` 成员口。
2. 测试设备获 `192.168.55.x`、网关 `.254`、可达 DNS。
3. AC-Lite → 铜口 5;确认 `.55.5` 与 Inform Connected。
4. 旧交换下电闲置。
### 阶段 4VLAN10 升级专用 Wi-Fi(独立项目)
不与本次 Done 捆绑。仅 U6;网关 `gfw`;详见
[lan-erx-se5420-network.md](lan-erx-se5420-network.md) 第 6.5 / 8 节与
[unifi-network.md](unifi-network.md)。
## 性能预期与不变瓶颈
- 同 VLAN、两端在核心上的 2.5G:可测 2.5G 级二层。
- 上网与 LAN66↔LAN55:仍经 ER-X 1G 路径。
- 空 SFP+ 不使网络「变成 10G」。
## 验收清单
- 管理面、口令、离线备份;
- 端口速率与错误计数;
- LAN66/55 的 DHCP、网关、DNS、互联网与关键本地服务;
- 两台 AP ConnectedInform 仍为 `http://192.168.66.46:9080/inform`
- 无无意环路;旧交换已闲置;
- (建议)两台 2.5G 终端同 VLAN `iperf3`
## 回滚
验证失败则停迁、恢复原接线;不改 ER-X WAN/DHCP/SSH。VLAN10 失败只撤新 SSID。
## 参考
- [ER-X + SE5420 网络与拓扑说明](lan-erx-se5420-network.md)
- [LAN 概览](lan-overview.md)
- [ER-X 配置记录](edgerouter-x-configuration.md)
- [UniFi 网络](unifi-network.md)
- [`gfw`](../hosts/gfw.windy.lan.md)
- [TL-SE5420 官方规格](https://www.tp-link.com.cn/product_2899.html?v=specification)
+605
View File
@@ -0,0 +1,605 @@
# Home-LAN DNS alternatives for the windy LAN (research, 2026-08)
**Status: research only. No configuration was changed.** This page evaluates
resolvers/splitters that are genuinely better than — or meaningfully different
from — the current "AdGuard Home (AGH) + mosdns" setup on
[`dns.windy.lan`](../hosts/dns.windy.lan.md) (`.36`), for a GFW-constrained
China home LAN. Claims are cited to primary sources (official repos, official
docs, upstream READMEs); anything not verified is flagged as such.
> 2026-08-12: facts in this page's scope recap were refreshed by W1N-56 live
> verification — mosdns on `.1` is **not idle**, it is clash's
> `nameserver`/`default-nameserver` (DIRECT-rule real-IP resolution); the
> canonical decision record is
> [`lan-dns-architecture.md`](lan-dns-architecture.md) (final verdict aligned,
> Phase 0 kill-test evidence incl. a measured upstream-blackhole degradation
> gap).
Scope recap (from [`lan-overview.md`](lan-overview.md), verified 2026-08-06):
- Clients get DNS via EdgeRouter DHCP option 6 → AGH `192.168.66.36:53`.
- AGH upstreams: `dns.alidns.com` + `doh.pub` DoH (load-balanced), fallback
`https://adg.chans.xyz/dns-query`. **DNSSEC disabled** (known-bad-signature
check failed on the selected path). Rewrites: `hass.local` / `hass.windy.lan`.
- `gfw` OpenWrt (`.1`) runs OpenClash fake-ip + TPROXY; dnsmasq → clash DNS
`127.0.0.1#7874`. `mosdns` on `127.0.0.1:6052` is clash's
`nameserver`/`default-nameserver` (DIRECT-rule real-IP resolution: domestic →
AGH `.36:53`, foreign → `223.5.5.5`/`119.29.29.29`); it is **not** in the LAN
client query path.
- No local authoritative PTR source yet; private reverse DNS is a known gap.
---
## 1. TL;DR / recommendation
**The current stack is already 80% of the answer.** AGH is a strong LAN DNS
front-end (filtering, rewrites, per-client upstreams, query log, web UI) and its
upstream layer — **per-domain upstreams** plus a **per-domain list loaded from a
file** (`upstream_dns_file`) — is exactly the mechanism the official docs
recommend for accelerating China CDN domains while keeping everything else on a
trusted path. [AGH configuration: upstreams](https://adguard-dns.io/kb/adguard-home/configuration/).
The genuinely worthwhile changes, in order of value:
1. **Add geo-split inside AGH** via `upstream_dns_file` fed by a converted
`accelerated-domains.china.conf` ([felixonmars/dnsmasq-china-list](https://github.com/felixonmars/dnsmasq-china-list)):
domestic CDN domains → `dns.alidns.com` / `doh.pub`; everything else →
the trusted foreign path (currently `adg.chans.xyz`). This is a documented
AGH use case, requires **no new daemon**, and removes the need for mosdns.
This is the top recommendation.
2. **Re-enable real DNSSEC** by putting validation behind AGH: AGH's
`enable_dnssec` only sets the DO bit — it does not validate
([AGH config: DNSSEC](https://adguard-dns.io/kb/adguard-home/configuration/)).
The two realistic ways are (a) point the foreign/trusted default upstream at
a validating resolver ([unbound](https://unbound.docs.nlnetlabs.nl/en/latest/),
[blocky](https://0xerr0r.github.io/blocky/latest/configuration/#dnssec-validation))
and re-test a known-bad-signature domain; or (b) insert a validating
resolver (blocky is the lightest) between AGH and the upstreams.
3. **mosdns on `.1` is resolved, not idle** — it is clash's
`nameserver`/`default-nameserver` (DIRECT-rule real-IP resolution, verified
2026-08-12), so "delete it" is off the table; its role is documented in
[`lan-dns-architecture.md`](lan-dns-architecture.md) §1. If a future change
moves this role to an AGH-side companion, keep in mind mosdns's cache strips
EDNS0 and it performs no DNSSEC validation
([mosdns v5 executable plugins](https://irine-sistiana.gitbook.io/mosdns-wiki/mosdns-v5/ru-he-pei-zhi-mosdns/ke-zhi-xing-cha-jian.md)).
Top-3 alternatives worth pursuing (see §3 for detail):
| Rank | Option | Why |
|------|--------|-----|
| 1 | **AGH with China-list geo-split (`upstream_dns_file`)** | Documented AGH pattern; single box; no new service; keeps filtering/rewrites/UI. |
| 2 | **Blocky as validating backend behind AGH** | The only "new software" option that adds real in-process DNSSEC validation + conditional per-domain upstreams + ECS in one static binary ([blocky README](https://github.com/0xERR0R/blocky), [config](https://0xerr0r.github.io/blocky/latest/configuration/)). |
| 3 | **Unbound as validating recursive resolver** (replaces forwarders for the foreign path, or whole path) | True validation, full recursion (fail-open by nature), private `local-zone`s; heavier ops than AGH's file-driven split. |
Explicitly **not** recommended as replacements here: smartdns and chinadns-ng
(both excellent *splitters*, but neither validates DNSSEC and both lack AGH's
filtering/UI/query-log layer, so they add a daemon without closing the DNSSEC
gap); mihomo/sing-box DNS as the primary path (couples DNS to the proxy and is
fail-closed; keep for proxy-side concerns only); knot-resolver/dnsdist (overkill
for a single-operator home LAN).
---
## 2. Requirement matrix
Legend: **●** native/built-in · **◐** possible with config/lists · **○** absent/
not applicable. "Geo-split" = route domestic vs foreign names to different
upstreams. "Anti-pollution" = a mechanism to avoid/adjudicate poisoned answers
(IP-verdict or trusted-upstream routing). "DNSSEC" = performs validation
in-process (not just forwards DO).
| Candidate | Geo-split | Anti-pollution | DNSSEC (validate) | Cache | Private names / rewrites | Ops simplicity | License |
|---|---|---|---|---|---|---|---|
| AGH (current) | ◐ per-domain upstreams + list file | ◐ via trusted foreign upstream | ○ (DO bit only) | ● | ● rewrites, per-client, private-PTR | ● Docker + UI | GPL-3.0 |
| mosdns v5 | ● domain/ip list matchers | ◐ forward foreign→trusted | ○ | ● (strips EDNS0) | ● hosts/redirect/reverse_lookup | ◐ single binary, YAML, no UI | GPL-3.0 |
| smartdns | ● nameserver groups + domain lists | ● bogus-nxdomain / blacklist-ip / trusted groups | ○ (no option in config ref) | ● serve-expired | ● address / local-domain / lease file | ◐ single binary, optional WebUI plugin | GPL-3.0 |
| chinadns-ng | ● chnlist/gfwlist + tag:none IP-test | ● IP verdict via chnroute ipset/nftset | ○ | ● cache/stale/verdict | ◐ hosts / dns-rr-ip | ◐ single static binary, config file | AGPL-3.0 |
| dnsmasq-china-list | ◐ (data only) | ◐ (via host resolver) | ◐ via host | ◐ via host | ◐ via host | ◐ feed lists | WTFPL |
| unbound | ◐ forward-zones / RPZ / views | ◐ forward-zones + bogus-nxdomain | ● | ● serve-expired | ● local-zone / local-data | ◐ config daemon, no UI | BSD-style (NLnet) |
| blocky | ◐ conditional per-domain + client groups | ◐ blocking lists + conditional routing | ● | ● prefetch | ● customDNS / rewrite / hosts | ◐ single binary, YAML, REST (no full web UI) | Apache-2.0 |
| Technitium | ◐ conditional-forwarder zones / apps | ◐ blocked lists + forwarding | ● | ● persistent | ● zones, stub, split-horizon | ● .NET + web console | GPL-3.0 |
| sing-box | ● DNS rules (geoip/geosite) | ● rule-based servers + (proxy) sniffing | ○ | ● LRU + optimistic | ● hosts / local server | ◐ single binary, JSON | GPLv3-family (metadata "other") |
| mihomo | ● nameserver-policy + fallback-filter | ● geoip verdict + geosite | ○ | ● (cache-algorithm) | ● hosts; fake-ip-filter for `.lan` | ◐ single binary, YAML | not cleanly verifiable (repo obfuscated) |
| knot-resolver | ◐ policy modules | ◐ policy + RPZ | ● | ● persistent | ◐ hints / local data | ◐ systemd, Lua config | open source (CZ-NIC) |
| dnsdist | ◐ Lua rules (custom) | ◐ custom policies | ○ (balancer, not validator) | ○ (no cache of its own) | ○ | ○ power tool | GPL (PowerDNS) |
Notes:
- "Geo-split" for AGH/blocky/unbound/Technitium is real but requires feeding a
China domain list; chinadns-ng/mihomo additionally offer the **IP-verdict**
path for domains not in any list (query both, adopt CN result only if the
answer IP is mainland).
- mosdns v5's `cache` plugin ignores request EDNS0 and strips response EDNS0
([cache plugin](https://irine-sistiana.gitbook.io/mosdns-wiki/mosdns-v5/ru-he-pei-zhi-mosdns/ke-zhi-xing-cha-jian.md)) —
relevant because AGH in front of it relies on the DO bit for DNSSEC-capable
upstreams.
- License for mihomo/sing-box/knot-resolver marked conservative: GitHub
metadata is "other"/custom or deliberately obfuscated; see §3 caveats.
---
## 3. Per-candidate evaluation
### 3.1 AdGuard Home — advanced upstream routing / built-ins
What it is: Go DNS proxy + adblock + DHCP, LAN DNS front-end
([official](https://adguard-dns.io/kb/adguard-home/overview/)).
Capabilities relevant here (all from the official configuration page):
- **Per-domain upstreams** dnsmasq-style: `[/domain/]upstream`, wildcards,
`#` = "default upstreams", empty `//` = unqualified names
([upstreams for domains](https://adguard-dns.io/kb/adguard-home/configuration/#upstreams-for-domains)).
- **List from file** `upstream_dns_file` — the docs *explicitly* call out China
CDN acceleration via dnsmasq lists, with the `server=/0-100.com/114.114.114.114`
`[/0-100.com/]114.114.114.114` conversion
([loading upstreams from file](https://adguard-dns.io/kb/adguard-home/configuration/#upstreams-from-file)).
- Upstream modes: `load_balance`, `parallel`, `fastest_addr`; plus `fallback_dns`
used only when primary upstreams fail
([config file: dns](https://adguard-dns.io/kb/adguard-home/configuration/)).
- Per-client upstreams (`clients.persistent[].upstreams`), rewrites
(`filtering.rewrites`, incl. wildcard), `local_ptr_upstreams` for private PTR,
ECS (`edns_client_subnet` with `use_custom` coarse prefix), optimistic cache
([same page](https://adguard-dns.io/kb/adguard-home/configuration/)).
- **DNSSEC is DO-bit only**: `enable_dnssec` "defines whether the proxy should
set the DO flag in the upstream requests" — validation must happen upstream
([same page](https://adguard-dns.io/kb/adguard-home/configuration/)).
- DoH/DoT/DoQ/DoH3 serving, `bind_hosts`/ACL guidance
([running securely](https://adguard-dns.io/kb/adguard-home/running-securely/)).
Verdict: **Already installed and capable of the geo-split itself.** The current
setup under-uses it: only a load-balanced CN pair + fallback, no per-domain
routing and no validating upstream. This is the cheapest "better" state — see §5.
### 3.2 mosdns v5 — installed, active as clash nameserver (gateway-side)
What it is: "一个 DNS 转发器" (a DNS forwarder) — plugin-based, sequence-driven
([README](https://github.com/IrineSistiana/mosdns), GPL-3.0, ~3.7k★).
What it does (verified from the v5 wiki and source tree):
- Servers: `udp_server`, `tcp_server` (TLS→DoT), `quic_server`, `http_server`
(DoH); upstreams in `forward` support `udp`, `tcp`, `tls`, `https`, `quic`,
HTTP/3, concurrent racing (`concurrent: n` picks the fastest) and socks5
([server plugins](https://irine-sistiana.gitbook.io/mosdns-wiki/mosdns-v5/ru-he-pei-zhi-mosdns/fu-wu-qi-cha-jian.md),
[executable plugins](https://irine-sistiana.gitbook.io/mosdns-wiki/mosdns-v5/ru-he-pei-zhi-mosdns/ke-zhi-xing-cha-jian.md)).
- Geo-split: v5 data providers are **`domain_set` / `ip_set` (text list files)**
plus `qname`/`resp_ip` matchers — verified from the current source tree
([plugin/data_provider](https://github.com/IrineSistiana/mosdns/tree/main/plugin/data_provider))
— and an `ipset`/`nftset` exec plugin to push answer IPs to kernel sets. The
old v4-style `geosite`/`geoip` `.dat` plugins are **not present** in the v5
tree; the v5 wiki's own matcher page currently states there are no matcher
plugins to document
([matcher page](https://irine-sistiana.gitbook.io/mosdns-wiki/mosdns-v5/ru-he-pei-zhi-mosdns/pi-pei-qi-cha-jian.md)).
Plan on chnlist/gfwlist-style text lists, not `geosite.dat`.
- Cache: yes, incl. optional lazy cache and disk dump; **request EDNS0 is
ignored and response EDNS0 stripped** by the cache plugin
([cache](https://irine-sistiana.gitbook.io/mosdns-wiki/mosdns-v5/ru-he-pei-zhi-mosdns/ke-zhi-xing-cha-jian.md)).
- Private names: `hosts` (domain-rules style, not OS /etc/hosts syntax),
`redirect`, `arbitrary` (zone records), `reverse_lookup` (PTR/HTTP lookup).
- Ops: single binary + YAML; `mosdns service install` ships a systemd/launchd
helper ([v5 overview](https://irine-sistiana.gitbook.io/mosdns-wiki/mosdns-v5.md));
Docker image exists. No web UI of its own.
Verdict: capable splitter/forwarder, but **adds no DNSSEC and no filtering
layer**, and its cache interferes with EDNS0/DO handling. As a *back-end* splitter
behind AGH it is a legitimate choice only if DNSSEC stays off. Given AGH can do
the same per-domain split natively (3.1), mosdns's marginal value here is
concurrent upstream racing and ipset/nftset integration — neither is needed at
this LAN's scale. Either wire it up properly or remove it.
### 3.3 smartdns
What it is: local DNS server that queries multiple upstreams, **speed-tests the
answer IPs and returns the fastest**; DoH/DoT/DoQ/DoH3; GPL-3.0, ~11.2k★
([README](https://github.com/pymumu/smartdns)).
Capabilities (from the official config reference and FAQ):
- Multi upstream + "returns the fastest IP", unlike dnsmasq all-servers
([README](https://github.com/pymumu/smartdns)).
- Domain groups: `server ... -group <name>` + `nameserver /domain/group` routing,
per-`bind` port flags (`-group`, `-no-speed-check`…), client rules/MAC/IP
([config options](https://pymumu.github.io/smartdns/configuration/)).
- Anti-pollution tooling: `bogus-nxdomain` (return NXDOMAIN for poisoned IPs),
`blacklist-ip`, `whitelist-ip`, `ignore-ip`, `ipset`/`nftset` export
([same page](https://pymumu.github.io/smartdns/configuration/)).
- ECS: global `edns-client-subnet` and per-server `-subnet`
([same page](https://pymumu.github.io/smartdns/configuration/)).
- Cache: `cache-size`, `serve-expired` (RFC-like stale), `prefetch-domain`,
persistent cache file ([same page](https://pymumu.github.io/smartdns/configuration/)).
- Private names: `address`, `cname`, `local-domain`, `dnsmasq-lease-file`
([same page](https://pymumu.github.io/smartdns/configuration/)).
- **DNSSEC: no validation option appears anywhere in the official config
reference or FAQ** — its pollution model is blacklist/whitelist + trusted
groups + speed selection, not DNSSEC ([config options](https://pymumu.github.io/smartdns/configuration/),
[FAQ](https://pymumu.github.io/smartdns/faq/)). Flagged: verify on the version
you deploy before relying on it.
Verdict: the classic China-home "best-IP" resolver; good splitter, no DNSSEC,
speed-test model optimizes for latency rather than anti-pollution correctness.
Not better than AGH+China-list for this LAN; at most a back-end splitter behind
AGH, with the same DNSSEC caveat as mosdns.
### 3.4 chinadns-ng / chinadns2 / dnsmasq-china-list
**chinadns-ng** (the requested "china-dns-ng"; actual repo `zfl9/chinadns-ng`,
AGPL-3.0, Zig, ~1.4k★) is the maintained rewrite of shadowsocks/ChinaDNS:
- Two upstream groups (china / trust) + `chnlist.txt` / `gfwlist.txt` domain
lists; domains are tagged `chn`/`gfw`/`none`
([README](https://github.com/zfl9/chinadns-ng)).
- `tag:none` names are queried on **both** upstreams and the china answer is
adopted only if its A/AAAA is a mainland IP (tested against a `chnroute`
ipset/nftset loaded into the kernel); verdict caching avoids re-testing and
leaks ([README: 原理/verdict-cache](https://github.com/zfl9/chinadns-ng)).
- Cache with stale + pre-refresh + optional persistence; DoT upstream
(wolfssl build); `hosts` + `dns-rr-ip` local records; `nftset` add for
chn/gfw IPs; **no DoH by design** and **no DNSSEC** — the author's stated
philosophy is "one job, done well"
([README](https://github.com/zfl9/chinadns-ng)).
- Resource footprint is tiny: ~140 KB baseline, ~2.4 MB with 73k+ chnlist +
5.7k gfwlist entries ([README](https://github.com/zfl9/chinadns-ng)).
**chinadns2** (`zfl9/chinadns2`) is the older C predecessor; effectively
superseded by chinadns-ng for new deployments (README not directly fetched —
treat as legacy line).
**dnsmasq-china-list** (felixonmars, ~6.1k★) is data, not a daemon:
`accelerated-domains.china.conf`, `bogus-nxdomain.china.conf`,
`apple.china.conf`, `google.china.conf`, with generators for **dnsmasq,
unbound, bind, dnscrypt-proxy**
([README](https://github.com/felixonmars/dnsmasq-china-list), WTFPL per repo).
Verdict: chinadns-ng is the strongest *pure splitter* for GFW networks (IP
verdict beats pure list-based routing for unknown domains), but it cannot
validate DNSSEC and brings no filtering UI. As AGH's backend it duplicates what
AGH's per-domain upstreams already do; its IP-test mode requires shipping
`chnroute` ipset/nftset into the host. dnsmasq-china-list is best used as the
**data feed** for the AGH `upstream_dns_file` recommendation in §5.
### 3.5 unbound
What it is: validating, recursive, caching resolver from NLnet Labs
([docs](https://unbound.docs.nlnetlabs.nl/en/latest/)).
- **Real DNSSEC validation by default** (trust anchor, chain of trust); the
official home-network guide turns it on explicitly
([home resolver guide](https://unbound.docs.nlnetlabs.nl/en/latest/use-cases/home-resolver.html)).
- Full recursion → does not hard-depend on any upstream or proxy; serve-expired
(RFC 8767), aggressive NSEC, DoH/DoT/DoQ serving and TLS upstreams,
forward-zone/stub-zone/authority-zone, RPZ filtering, views, ECS module
([docs index](https://unbound.docs.nlnetlabs.nl/en/latest/)).
- Private names: `local-zone`/`local-data` for `*.windy.lan`-style names
([unbound.conf(5)](https://unbound.docs.nlnetlabs.nl/en/latest/manpages/unbound.conf.html)).
- No built-in China split: you assemble it with forward-zones fed by
dnsmasq-china-list (`make unbound` generator) + `bogus-nxdomain`; no UI, no
per-client grouping comparable to AGH.
Verdict: the gold standard for the **validation** half. Best used as (a) the
validating upstream behind AGH for the foreign/trusted path, or (b) a full
recursive resolver replacing the forwarders if you accept losing AGH-style
filtering/UI on top — keep AGH in front for that. System-package based, heavier
to operate than blocky but battle-tested.
### 3.6 blocky
What it is: Go DNS proxy + ad-blocker, "fast and lightweight", single static
binary, stateless, Apache-2.0, ~6.9k★
([README](https://github.com/0xERR0R/blocky)).
- **In-process DNSSEC validation**: `dnssec.validate` with DO bit, RRSIG
verification, chain-of-trust, NSEC/NSEC3, custom trust anchors, SERVFAIL on
bogus ([DNSSEC validation docs](https://0xerr0r.github.io/blocky/latest/configuration/#dnssec-validation)).
- Upstreams: `parallel_best` (2 random resolvers, fastest answer), `strict`,
`random`; per-client/per-subnet upstream **groups**; UDP/TCP/DoT/DoH/DoQ/DoH3;
DNS stamps; bootstrap DNS
([upstreams](https://0xerr0r.github.io/blocky/latest/configuration/#upstreams-configuration)).
- Conditional forwarding + `customDNS` mapping/rewrite (the AGH-rewrite
equivalent), hosts files, per-domain upstream routing
([custom DNS / conditional](https://0xerr0r.github.io/blocky/latest/configuration/#custom-dns)).
- ECS: `ecs.useAsClient` / `ecs.forward`
([ECS](https://0xerr0r.github.io/blocky/latest/configuration/#edns-client-subnet-options)).
- Cache with min/max TTL + **prefetching**; optional **Redis** cache/state sync
between instances; query log to SQLite/Postgres/CSV; Prometheus metrics; REST
API ([README](https://github.com/0xERR0R/blocky),
[config](https://0xerr0r.github.io/blocky/latest/configuration/)).
- No full web admin UI (metrics/REST/logs only) — an ops trade-off vs AGH's UI.
Verdict: the most attractive *new software* option for this LAN **as a backend
behind AGH**: it adds real DNSSEC validation + conditional upstream routing +
ECS with a single binary and YAML. It has no China-IP-verdict split built in —
feed it the China domain list via `conditional.mapping`/upstream groups, which
is fine at this scale. One caveat: no GUI means AGH stays the human-facing
front, so AGH→blocky is strictly additive.
### 3.7 Technitium DNS Server
What it is: self-hosted authoritative **and** recursive DNS server, .NET,
web console, GPL-3.0, ~9.5k★
([README](https://github.com/TechnitiumSoftware/DnsServer)).
- **DNSSEC validation** for recursive resolution, forwarders, and conditional
forwarders (RSA/ECDSA/EdDSA, NSEC/NSEC3); can also *serve* signed zones
([README](https://github.com/TechnitiumSoftware/DnsServer)).
- Conditional forwarder zones + bulk conditional forwarding app; blocked-domain
lists with regex support and per-client variants; split-horizon/geolocation
via DNS Apps; ECS; QNAME minimization
([README](https://github.com/TechnitiumSoftware/DnsServer)).
- Serving side: DoH/DoT/DoQ/DoH3 server, built-in DHCP, persistent cache,
caching with serve-stale/prefetch, clustering, HTTP/SOCKS5 proxy for DNS
(e.g. over Tor) ([README](https://github.com/TechnitiumSoftware/DnsServer)).
- Heavier footprint (needs .NET; Docker image available) and a full web console
with many features this LAN won't use.
Verdict: capable and genuinely feature-rich (a real AGH alternative in the
"everything in one box" sense — filtering, private zones, validation, DHCP), but
it's more moving parts than this LAN needs, and its geo-split still requires
manual conditional-forwarder lists. Not chosen over the lighter AGH+backend
approach.
### 3.8 sing-box / mihomo built-in DNS as the split resolver (fake-ip)
The "third option": let the proxy engine's DNS own resolution, AGH on top.
**sing-box** DNS object: multiple server types (local, udp, tcp, tls, https,
http3, quic, fakeip, hosts, dhcp, mdns…), rule-based server selection by
geoip/geosite, LRU cache + optimistic serving, per-query timeout, `client_subnet`
(ECS), `reverse_mapping`
([sing-box DNS docs](https://sing-box.sagernet.org/configuration/dns/)).
**mihomo** (Clash.Meta lineage; docs at
[wiki.metacubex.one](https://wiki.metacubex.one/en/config/dns/)):
`nameserver-policy` (geosite/rule-set/domain keys) routes specific domains to
specific resolvers; `fallback` + `fallback-filter` (geoip=CN, geosite=gfw,
ipcidr, domain) adjudicate pollution — a CN resolver's answer is adopted only if
the IP is mainland, otherwise the overseas fallback's answer is used;
`fake-ip`/`redir-host` enhanced mode, `fake-ip-filter` with e.g. `'*.lan'` to
keep local names on real-IP; per-DNS-server ECS; cache-algorithm
([mihomo DNS config](https://wiki.metacubex.one/en/config/dns/)).
Assessment for THIS LAN:
- The **pollution adjudication is strong** (geoip-verdict fallback, geosite
lists), and mihomo already runs on the gateway — so "clash DNS as splitter" is
tempting.
- But the DNS service is **coupled to the proxy**: foreign resolution rides the
proxy path, so when OpenClash/subscription is down, fake-ip mapping and
foreign lookups break (partial fail-open only if `direct-nameserver`/fallback
are carefully set). The LAN requirement says **must not hard-depend on the
proxy (fail-open)**.
- fake-ip adds an indirection layer for anything in front of it (AGH on top
resolves client IPs against fake-ip ranges; leaks/loops need careful rules).
- Neither engine **validates DNSSEC** (no RRSIG verification).
- sing-box repo license shows "other" in GitHub metadata (not cleanly
verifiable); mihomo's repo currently carries **deliberately obfuscated content**
("Void Terminal" parody) — treat `wiki.metacubex.one` as the authoritative
docs and expect the GitHub surface to change.
Verdict: keep clash/mihomo DNS exactly where it is (proxy-side, TPROXY/fake-ip),
do **not** make it the LAN resolver of record. If you ever want its IP-verdict
quality outside the proxy, chinadns-ng gives the same idea with zero proxy
dependency.
### 3.9 knot-resolver / dnsdist — power-resolver options
**knot-resolver** (CZ-NIC): minimal caching validating resolver, modular/Lua,
full DNSSEC validation, forwarding over TLS, query policies, RPZ, views/ACLs,
DNS64, persistent cache, serve-stale, even XDP fast-path
([docs](https://knot-resolver.readthedocs.io/en/stable/)). As powerful as
unbound but with more configuration surface (Lua); overkill for a one-operator
home LAN, though it would do the validating-resolver role well.
**dnsdist** (PowerDNS): "highly DNS-, DoS- and abuse-aware loadbalancer" —
routes traffic to backend servers, Lua/YAML config, runtime console, metrics
([overview](https://dnsdist.org/)). It is a **balancer, not a validator/cache**
— it fronts other resolvers. Overkill; only relevant if you wanted a
multi-backend DNS LB, which this LAN does not.
### 3.10 Emerging / also-considered options
- **AdGuard Home + dnsmasq-china-list** — covered in §3.1/§5; this is the
"emerging best practice" for China CDN splits on AGH and is officially
documented.
- **pi-hole** — adblock/dashboard equivalent of AGH but no per-domain upstream
routing worth choosing it over AGH here (not deeply verified for this write-up;
AGH already satisfies the role).
- **dnscrypt-proxy** — encrypted forwarder with stamp support; a transport
option, not a splitter/validator (not deeply verified for this write-up).
- **coredns** — plugin-based; geo-split is DIY via plugins; no DNSSEC
validation by default (not deeply verified for this write-up).
### 3.11 Other popular options (survey supplement, 2026-08-12)
Follow-up survey of additional popular solutions not covered above, evaluated
against this LAN's constraints (fail-open, keep DNS on `.36`, DNSSEC goal).
None of these change the §4/§5 recommendation.
**Encrypted-forwarder micro-tools (AGH downstream options, not replacements):**
- **dnscrypt-proxy** — the classic OpenWrt encrypted forwarder with
China-list support and DNS-stamp routing. No in-process DNSSEC validation and
no filtering UI; overlaps with AGH's own DoH upstream layer, so its marginal
value here is low.
- **dnsproxy** (AdGuardTeam) — lightweight DoH/DoT/DoQ forwarder/server.
Functionally a subset of AGH's upstream layer; only useful if forwarding logic
is deliberately split out of AGH.
- **Stubby** — dnsmasq→stubby→DoT (privacy-community pattern). Pure
forwarding, no split/filter/validation; adopting it alone would be a
downgrade from AGH.
**Managed / cloud DNS (zero-ops, not self-hosted):**
- **NextDNS / ControlD / AdGuard DNS / Cloudflare** — hosted filtering, logs,
per-device policies. This LAN already self-hosts AGH + a private
`adg.chans.xyz` fallback, so a cloud service would be a downgrade in control
(data leaves the LAN). Only realistic use: add one as an extra foreign-path
upstream inside AGH's `upstream_dns_file`.
**Heavier all-in-one resolvers:**
- **PowerDNS Recursor** — real DNSSEC validation + Lua policy, authoritative
and recursive in one. Capable but overlaps unbound; over-provisioned here.
- **BIND9** — classic authoritative/recursive; can validate DNSSEC and, more
interestingly, serve as a local **authoritative zone** that would close the
private-PTR gap. As a LAN resolver it lacks AGH's filtering/UI and is heavier
to operate; a small dnsmasq authoritative zone is a lighter way to achieve the
PTR goal (still deferred until a local authoritative source exists).
- **hickory-dns / trust-dns** (Rust) — emerging recursive resolver, DNSSEC
friendly, smaller ecosystem/ops track record than unbound/blocky; not yet
worth switching for this LAN.
**Popular stack patterns (structure, not new software):**
- **Pi-hole + unbound** — the most common global self-hosted combo
(filtering front-end + validating backend). AGH already occupies the
Pi-hole role here (and does more), so the equivalent is **AGH + unbound/
blocky** — exactly the report's recommendation #2.
- **dnsmasq + china-list + smartdns** (classic OpenWrt trio) — routes the
China list on the gateway itself. Equivalent to co-locating DNS with the
proxy host (`.1`), which violates the fail-open requirement; not recommended
for this LAN.
Verdict: the survey adds no better candidate. dnsproxy/dnscrypt-proxy duplicate
AGH's upstream layer, cloud DNS is a control downgrade, and the only genuinely
new capability (a local authoritative source for PTR) is better served by a
small dnsmasq authoritative zone than by replacing the resolver.
---
## 4. Architecture recommendation for this LAN
### 4.1 Preferred architecture (change is config-only)
```
clients (DHCP option 6 = .36)
│ UDP/TCP :53
AGH .36 (filtering, rewrites, query log, per-client upstreams)
│ upstream_dns_file:
│ [/cn-domain-list/] dns.alidns.com doh.pub ← CN CDN domains (China list)
│ default: https://adg.chans.xyz/dns-query … ← trusted/foreign path
└→ validating resolver (unbound OR blocky) for the foreign path (optional phase 2)
```
- Front = AGH stays the single LAN DNS box (filtering/rewrites/UI/query log
are its strong suit and are already operating).
- Split = AGH per-domain upstreams fed by a converted dnsmasq-china-list; no
new daemon. This is the documented AGH pattern
([upstreams from file](https://adguard-dns.io/kb/adguard-home/configuration/#upstreams-from-file)).
- Validation = add a validating resolver behind AGH for the trusted path
(blocky simplest; unbound most battle-tested) and re-run the known-bad-signature
check that failed before; then flip `enable_dnssec`.
### 4.2 Why not the alternatives as front-ends
- **smartdns / chinadns-ng as the LAN resolver**: they are pure splitters —
no adblock layer, no query log/UI, no DNSSEC. Replacing AGH with either is a
capability downgrade; behind AGH they duplicate AGH's built-in split while
adding a daemon and losing validation. Only chinadns-ng's IP-verdict mode is
genuinely beyond AGH, and it needs kernel ipset/nftset plumbing.
- **mosdns as the AGH backend**: viable splitter, but no validation and its
cache strips EDNS0/DO ([cache plugin](https://irine-sistiana.gitbook.io/mosdns-wiki/mosdns-v5/ru-he-pei-zhi-mosdns/ke-zhi-xing-cha-jian.md)),
which fights the DNSSEC goal. It is already idle on the box — configure it
deliberately or remove it.
- **mihomo/sing-box DNS as the resolver of record**: fail-closed + proxy-coupled
+ no validation. Keep as proxy-side concern (§3.8).
- **knot-resolver / dnsdist / Technitium**: capable but over-provisioned;
Technitium is the only one that would *replace* AGH wholesale, and there's no
benefit worth the migration here.
### 4.3 Deployment location
- **Keep DNS on `dns.windy.lan` (.36)**. It is already the DHCP-advertised
resolver; it is a separate VM from the proxy host; DNS therefore stays
independent of OpenClash state (fail-open), which is an explicit requirement.
- **Do not move it to `gfw` (.1)**: the gateway is where OpenClash injects
TPROXY/fake-ip/DNS-hijack rules; co-locating LAN DNS there couples DNS to the
proxy and its restart/update lifecycle.
- A standalone resolver VM adds nothing: both current VMs already sit on the
same PVE hypervisor ([lan-overview.md](lan-overview.md) §Positioning facts),
so a hypervisor outage takes out either placement equally; a second physical
host for HA is out of scope for a home LAN.
- If you ever run a validating resolver + AGH on `.36`, verify outbound from
`.36` to the foreign upstreams is not re-hijacked by OpenClash (loop check
already mandated in the [AGH review](adguard-home-official-review-2026-08.md)).
### 4.4 Fail-open, DNSSEC, private names — by candidate
| Concern | How the recommended stack behaves |
|---|---|
| Fail-open when proxy/subscription down | AGH forwards directly to DoH upstreams; `.36`'s outbound is not forced through the proxy in normal ops (no TUN policy routing on `.36` — [dns host facts](../hosts/dns.windy.lan.md)). With unbound/blocky behind, foreign resolution recurses/validates directly, independent of OpenClash. Avoid mihomo-DNS-as-resolver, which is proxy-coupled. |
| DNSSEC validation | Only unbound, blocky, knot-resolver, Technitium validate in-process. AGH sets DO only; mosdns/smartdns/chinadns-ng/mihomo/sing-box do not. Plan: validate behind AGH, or accept "validating public upstream" (confirm with `dig +dnssec`/known-bad test). |
| Private names / rewrites | AGH `rewrites` (already in use for `hass.windy.lan`) + `local_ptr_upstreams` once a local PTR source exists. blocky: `customDNS` mapping/rewrite + hosts. unbound: `local-zone`. All adequate. |
| Query log / visibility | AGH is the best at this of everything evaluated (14-day anonymized log already configured). |
---
## 5. What would make the current AGH + mosdns setup genuinely better
Concrete, in increasing effort:
1. **Implement the China-list geo-split in AGH itself**
(`upstream_dns_file` + converted `accelerated-domains.china.conf`, default
upstreams = trusted foreign path, `fallback_dns` kept). Official AGH docs
describe exactly this pattern
([loading upstreams from file](https://adguard-dns.io/kb/adguard-home/configuration/#upstreams-from-file));
list source: [dnsmasq-china-list](https://github.com/felixonmars/dnsmasq-china-list).
Wire a refresh path (cron/ansible) so the list stays current. Re-test CDN
resolution and the DNSSEC known-bad domain after.
2. **Put a validating resolver on the trusted path** (unbound or blocky), re-run
the known-bad-signature check, then enable AGH DNSSEC. Without this, AGH's
`enable_dnssec` is only a DO-flag — the exact reason it is currently off
([AGH DNSSEC semantics](https://adguard-dns.io/kb/adguard-home/configuration/),
[host facts](../hosts/dns.windy.lan.md)).
3. **Either fully configure mosdns (systemd service, sequence, lists) or remove
it.** Leaving an idle `127.0.0.1:6052` listener documented as "not the active
path" is drift. If kept, plan around no-EDNS0 cache + no validation; if
removed, drop the listener and its config to reduce surface.
4. **Close the private-PTR gap**: once a local authoritative source exists (e.g.
dnsmasq on `gw`, or a tiny authoritative zone), point AGH
`local_ptr_upstreams` at it as the AGH review recommends
([AGH review](adguard-home-official-review-2026-08.md));
don't set it before that source exists
([dns host facts](../hosts/dns.windy.lan.md)).
5. **Optional: ECS** for CDN geo-accuracy — AGH `edns_client_subnet.use_custom`
with a coarse fixed prefix (or blocky `ecs.forward`) if measurements show a
benefit; note many CN resolvers ignore ECS
([AGH ECS](https://adguard-dns.io/kb/adguard-home/configuration/)).
If the DNS engineering budget is one afternoon, do #1 + #3. If the goal is
"real DNSSEC or nothing", do #1 + #2 + #3. Replacing the stack is only
justified if you want to abandon AGH's UI/filtering entirely — nothing evaluated
here beats it on that axis for this LAN.
---
## Caveats / not verified
- **Live behavior not tested**: all capability claims are from primary docs
reviewed 2026-08-12; DNSSEC behavior of `dns.alidns.com`/`doh.pub`/the
`adg.chans.xyz` path and mosdns's actual version on `.36` need on-box
`dig +dnssec` verification (per [adguard-home-health](../runbooks/adguard-home-health.md)).
- **smartdns DNSSEC**: the official config reference lists no DNSSEC option;
if a newer version added one, it is not reflected here
([config options](https://pymumu.github.io/smartdns/configuration/)).
- **mosdns geosite/geoip**: v5 source tree (fetched 2026-08-12) contains only
`domain_set`/`ip_set` data providers; if a `geosite.dat` plugin exists in a
release branch, it is not in `main`
([plugin/data_provider](https://github.com/IrineSistiana/mosdns/tree/main/plugin/data_provider)).
- **mihomo**: the GitHub repo currently shows deliberately obfuscated metadata
(see §3.8); capabilities cited from
[wiki.metacubex.one](https://wiki.metacubex.one/en/config/dns/).
sing-box/knot-resolver/dnsdist license identifiers via GitHub metadata are
"other"/custom — treat the specific SPDX ids with caution.
- **chinadns2** README was not retrieved (404 on the raw URL); treated as the
legacy predecessor of chinadns-ng and not evaluated in depth.
- Obsidian/personal notes were not consulted; this is upstream-docs-only.
## Related docs
- [lan-overview.md](lan-overview.md) — full topology (verified 2026-08-06)
- [hosts/dns.windy.lan.md](../hosts/dns.windy.lan.md) — AGH host facts
- [hosts/gfw.windy.lan.md](../hosts/gfw.windy.lan.md) — OpenClash facts
- [adguard-home-official-review-2026-08.md](adguard-home-official-review-2026-08.md) — prior AGH config review
- [runbooks/adguard-home-health.md](../runbooks/adguard-home-health.md)
+246
View File
@@ -0,0 +1,246 @@
# 内网 DNS 架构调研与优化建议
> 状态:2026-08-12 调研定稿,Linear **W1N-56**。**Phase 0 验证(2026-08-12)全部完成,最终裁决(用户定稿)已对齐**;本期未改动任何生产 DNS 路径。
> 2026-08-12 现场核查修正:gfw 上 mosdns **不是闲置**——它是 OpenClash clash 的
> `nameserver`/`default-nameserver`(DIRECT 规则真实 IP 解析),处于活动链路,裁决第 5 条
> 的"删除闲置 mosdns"前提不成立,处置改为"正式纳管并文档化"。
> 相关:`docs/lan-overview.md``hosts/dns.windy.lan.md``hosts/gfw.windy.lan.md`、W1N-40。
> 2026-08-13 (W1N-62):gfw mosdns 国外分支已从"明文国内公网 DNS"改为**加密 DoH**
> (自建 `https://adg.chans.xyz/dns-query`,hk2),新增 `foreign_upstream`/`foreign_fallback`
> (primary=DoH, secondary=明文国内 DNS, threshold 1000ms),`bootstrap` 用现有国内公网 IP
> (防自举循环)。实测:mosdns 平面国外域名 A/AAAA 恢复(google AAAA `2607:f8b0…`)、
> `dup.baidustatic.com``0.0.0.0`(AGH 拦截保留)、clash 7874 fake-ip 平面不变。
> **重要修正(定稿)**:DoH 流量实测为 **gfw→hk2 直连,未经 clash 代理**——nft output 链
> (mangle mark/tcp redirect)计数为 0、`/proc/net/tcp` 存在到 hk2:443 的 established
> 连接,路由自身 TCP 输出当前并未被 OpenClash 重定向,故"经代理访问加密 DNS"的假设不成立。
> **最终决策:接受直连,不强行走代理**——`foreign_upstream` 以自建解析器
> `adg.chans.xyz`(hk2)为主力,自有 VPS 直连即可达、无被墙/污染问题、DoH/TLS 已加密、应答干净,
> 走代理毫无增益反而把 DNS 平面耦合进 clash;实测 kill clash 期间国外查询 0.01s 正常应答,
> watchdog 自动拉起,直连使 DNS 平面独立于代理(优于过代理)。
> **多上游冗余(同日)**:`concurrent: 3`,新增 `https://dns.quad9.net/dns-query`
> `https://dns.cloudflare.com/dns-query`(2026-08-13 本网络实测可达;`dns.quad101.net`
> TLS 握手失败已排除)——hk2 故障时仍由干净的国外 DoH 应答,最后才退化明文国内兜底。
> 验证:google AAAA 由 hk2 的 `2607:f8b0…` 变为新上游的 `2404:6800…`,国内外/拦截/代理平面无回归。
> 备份:`config.yaml.bak-foreign-doh-20260813-103746` / `config.yaml.bak-foreign-doh-20260813-103813` /
> `config.yaml.bak-multi-doh-20260813-105421`
## 1. 现状(实测 2026-08-12)
| 角色 | 部署 | 职责 | 是否在活动路径 |
|------|------|------|----------------|
| **AdGuard Home** | `192.168.66.36`(PVE VM 120,Docker host 网络) | EdgeRouter DHCP 通告给 LAN55/66 客户端的唯一 DNS;广告/过滤、查询统计、Web 面板 | ✅ **是(LAN 客户端唯一入口)** |
| **mosdns** | `192.168.66.1`(gfw OpenWrt)监听 `127.0.0.1:6052`(仅本机) | clash 的 `nameserver`/`default-nameserver`:DIRECT 规则真实 IP 分流(国内 → AGH `.36:53`,国外 → `223.5.5.5`/`119.29.29.29`) | ✅ 网关侧(clash 消费,不面向客户端) |
| **OpenClash / clash(meta)** | `192.168.66.1`(gfw) | gateway 自身/被劫持流量的 fake-ip + 代理;DNS 走 dnsmasq→clash `#7874` | 仅网关侧与 VLAN10 |
**关键事实(全部实测):**
- LAN 客户端 DNS 直连 `66.36`,**不经过** gfw(EdgeRouter `service dns forwarding` cache 512,通告 `.36`)。
- AGH 上游:DoH `dns.alidns.com`(→`223.5.5.5`/`223.6.6.6`)+ `doh.pub`(→`120.53.53.53`/`1.12.12.12`),
`upstream_mode: load_balance``fastest_timeout: 1s``upstream_timeout: 10s`;bootstrap
`223.5.5.5`/`223.6.6.6`(公网 IP,无自举循环);兜底 DoH `adg.chans.xyz`(→`hk2.chans.xyz``154.36.174.161`)。
DNS 监听 UDP/TCP **v4 only(`0.0.0.0:53`)**,无 v6 监听;`enable_dnssec: false`;cache 4 MB;ratelimit 20。
- AGH 宿主出网:默认路由 `via 192.168.66.254`(EdgeRouter),**直连,不经 gfw**;DoH 实测可达
(223.5.5.5:443 → HTTP 400/0.05s,120.53.53.53 → 502/0.06s,adg.chans.xyz 冷连接 ~3.5s)。
- gfw 自身出网:OpenClash `openclash_mangle_output` 对非本地区域/国内 IP 流量统一
`mark 0x162 → tproxy 127.0.0.1:7895`(**gfw 自身流量默认走代理**,含 clash 的
nameserver-policy DoH);mosdns 的上游(AGH `.36` 本地区域、`223.5.5.5` 国内 IP)均被 bypass,保持直连。
- gfw DNS 链:`server=127.0.0.1#7874`(dnsmasq)→ clash:`nameserver: [127.0.0.1:6052]`(mosdns)、
`default-nameserver: [127.0.0.1:6052]``nameserver-policy` 国外域名 → DoH `https://1.1.1.1/dns-query`
`enhanced-mode: fake-ip`(198.18.0.1/16)、`ipv6: false`;nft 有 UDP/53 hijack → dnsmasq。
- **mosdns 配置缺陷(2026-08-12 发现并修复)**:`main` sequence 的国内分支
(`matches: qname $domestic_domains → exec: $domestic_upstream`)之后**缺少
`matches: has_resp → accept` 守卫**。mosdns v5 的 `sequence``forward` 成功后不会停止,
只有 `accept`/`reject`/`return` 或错误会终止——因此命中 `geosite_cn` 的查询会被转发**两次**
(AGH 与 223.5.5.5/119.29.29.29),最终应答来自最后一个 forward(国内公网 DNS),**AGH 的
拦截/rewrite 对 DIRECT 国内域名静默失效**。实测证据:`dup.baidustatic.com`(在 `geosite_cn`
且在 AGH 拦截表)经 mosdns 返回真实 IP `183.60.227.49` 而非 `0.0.0.0`。已修复(备份
`/etc/mosdns/config.yaml.bak-20260812`),修复后同一域名返回 `0.0.0.0`,taobao/google 解析
与 clash 链均无回归。**§6 的二期示例同款缺陷已一并修正。**
同日追加加固:`domestic_fallback`(fallback 插件:`primary: domestic_upstream`(AGH)、
`secondary: default_upstream`(223.5.5.5/119.29.29.29)、`threshold: 500ms`)使 AGH 宕机时
DIRECT 国内真实 IP 查询回退国内公网 DNS,不再直接报错;实测:AGH 停止时缓存未命中查询由
fallback 应答(NXDOMAIN/真实 IP),AGH 恢复后主路径即时应答且拦截(`0.0.0.0`)恢复。
备份:`/etc/mosdns/config.yaml.bak-fallback-20260812`
- AGH rewrites(实测):`hass.windy.lan`/`hass.local``192.168.55.11`;`dns.windy.lan``.36`;
`ubnt.windy.lan``.46`;`gfw.windy.lan``.1`;`nas.windy.local``.32`
- 拦截:仅启用 **AdGuard DNS filter**(filter_1);实测 `doubleclick.net`/`googleadservices.com``0.0.0.0`
**现状缺口(实测确认):**
1. AGH 上游是固定 DoH,**无"国内/国外分流"能力**;国外域名解析质量依赖唯一兜底路径。
2. **兜底失效**(kill-test 证实):主上游黑洞时,兜底 `adg.chans.xyz` 在客户端 15s 窗口内不生效
(`upstream_timeout: 10s` + TCP 重试行为),缓存未命中查询无有界降级——见 §8。
3. 国外域名 AAAA 经国内路径全部置空(见 §8),v6 解析缺位。
## 2. 最终裁决对齐(用户定稿 2026-08-12)
| # | 裁决 | 本issue处理 |
|---|------|------------|
| 1 | **保留 AGH `.36` 为唯一 LAN DNS 入口**(现有方案增强版),不改 EdgeRouter DHCP 通告 | ✅ 现状保持;本期零改动 |
| 2 | **否决"AGH 全局转发到 Clash fake-IP"**——DNS 平面必须与流量转发平面一致 | ✅ 分层方案(§4)明确 AGH 上游为**真实 IP** 解析路径,不与 fake-ip 混用 |
| 3 | **AGH → mosdns 仅为二期可选项**(经实测确有需求后启用) | ✅ §4 为二期方案;§8 kill-test 已给出"实测需求"证据(降级缺口) |
| 4 | 代理 VLAN10 将来用独立 OpenClash DNS 平面(fake-ip + TPROXY),不污染普通 LAN | ✅ 现状即此(dnsmasq→clash,非面向 LAN 客户端);文档记录 |
| 5 | **删除或明确禁用** `.1` 上未使用的 mosdns | ⚠️ 前提修正:mosdns 是 clash 的 nameserver,处于活动链路(§1)。处置改为**正式纳管并文档化**(本文件 + `hosts/gfw.windy.lan.md`),不删除 |
| 6 | 先完成验证再改动生产路径 | ✅ 本期完成全部 Phase 0 验证(§8),**未改任何生产 DNS 路径** |
| 7 | 建立 `home.arpa` 内部域(替代 `.local`) | ⏳ 后续任务:当前命名空间为 `.lan`(AGH rewrites + EdgeRouter DHCP domain),`hass.local` 兼容保留至迁移完成;home.arpa 需联动 AGH rewrites、DHCP domain、客户端,另行排期 |
| 8 | 高可用时增加第二个等价 AGH(独立物理故障域) | ⏳ 备用方案,记录不实施 |
## 3. 两个候选方案评估
### 方案 A:AGH 单独作为统一入口(现状演进)
- 优点:单解析点、面板/拦截/日志集中、维护简单。
- 缺点:AGH 对 geo 分流 + 防污染支持弱(官方定位是"过滤/家长控制")。固定 DoH 上游无法按域名
region 选路;且 §8 kill-test 显示主上游全挂时缓存未命中查询无有界降级。→ **不足以解决防污染/分流/降级问题。**
### 方案 B:mosdns 作为智能上游分流器
mosdns(v5)用 `sequence` 编排:`geosite/geoip` 匹配器 → 国内域名转发国内 DoH、国外域名转发加密 DoH(防污染),可加 `cache``reject`
- 优点:真正解决"国内快 / 国外不被污染"的分流;性能高。
- 缺点:纯转发器,无 Web 面板、无每客户端统计、拦截靠域名表。→ 单独当入口会退回原始体验。
**结论:两个方案互补,不是二选一。** 单用 A 无法分流防污染且降级无界;单用 B 失去 AGH 管理体验。
## 4. 二期可选项:分层架构(AGH 前端 + mosdns 后端,经实测需求后启用)
```
局域网客户端(DHCP DNS = 192.168.66.36)
AdGuard Home (66.36) ── 前端:广告/过滤、拦截表、每客户端统计、Web 面板
│ 上游 = mosdns
mosdns(66.36 伴生容器) ── 后端:智能分流 + 防污染
│ - geosite:cn → 国内 DoH/UDP(aliDNS / 腾讯 DNSPod)
│ - 其他 → 加密 DoH(自建 adg.chans.xyz 等)
上游 DoH
```
职责分离:
- **AGH = 策略/拦截/可观测**(拦截表、每客户端日志、面板)。
- **mosdns = 智能转发**(geo 分流 + 加密防污染 + 内置 cache,可显著缩短降级窗口)。
- **OpenClash(gfw)= 代理选路**(fake-ip + 规则)。与 DNS 解析选上游是两个独立决策,分开放最干净。
**部署位置:mosdns 与 AGH 同机(66.36 伴生容器),而非 gfw(.1)**:单点即 AGH 所在;不受网关重启/
OpenClash churn 影响;可纳入现有 compose/ansible 管理;不占用 OpenWrt 资源。放 gfw 会与 clash 的
DNS 处理互相干扰、耦合,且网关重启即断全 LAN DNS。**不推荐放 .1。**
> 启用条件(kill-test 实测需求,§8):主上游全挂时,当前 AGH 单入口对缓存未命中查询无有界降级。
> 分层方案(或下调 `upstream_timeout` + 改 failover 模式)可修;启用与否由用户在二期决定。
## 5. 更优替代方案(一并考虑)
1. **分层(推荐二期,见 §4)**:AGH(66.36)→ mosdns(66.36 伴生)→ 上游。体验最好、职责最清。
2. **纯 mosdns + 前端面板**:损失拦截/统计管理体验。**不推荐**用于替换。
3. **AGH 只挂一个带分流的上游(第三方 DoH 聚合)**:失去可控性且不可信。不推荐做主路径。
4. **全部交给 OpenClash fake-ip,关闭 AGH**:让"代理网关"成为全 LAN DNS 单点;且 AGH 拦截/日志也没了。**不推荐。**
## 6. mosdns 配置要点(mosdns v5,二期实施预留)
核心是 `sequence` + 上游拆分 + 缓存 + 屏蔽:
```yaml
plugins:
- tag: main
type: sequence
args:
- exec: cache 1024 # 缓存加速
- matches: has_resp
exec: accept
# 国内分流:命中 geosite:cn → 国内 DoH
- matches: [ qname &geosite:cn ]
exec: forward https://dns.alidns.com/dns-query
# 广告域名屏蔽交 AGH 前置,不重复维护
# 其余(国外)→ 加密 DoH 防污染
- exec: forward https://adg.chans.xyz/dns-query
- type: udp_server
args: { entry: main, listen: "127.0.0.1:5353" }
- type: tcp_server
args: { entry: main, listen: "127.0.0.1:5353" }
```
> **注意**:`sequence` 中每个 `forward` 分支之后必须跟 `matches: has_resp → accept`
> (或改用 `goto`/`jump` + `return` 结构),否则查询会继续执行后续规则被二次转发,
> 最终应答来自最后一个 forward——gfw 上 mosdns 的同类缺陷(2026-08-12)已实测并修复(见 §1)。
要点:
- 上游可加 `upstream``concurrent > 1` 与多地址故障切换;mosdns 自带 cache,能保证上游故障时
缓存命中仍即时应答(对应 §8 认定的降级缺口)。
- `geosite:cn` / `geoip:cn` 数据自动更新;国内 aliDNS/腾讯,国外可用自建 `adg.chans.xyz`(实测
唯一能返回国外 AAAA 的路径,§8)。
- 屏蔽交 AGH 前置,AGH 与 mosdns 不各自维护拦截表。
## 7. 迁移 / 实施顺序(二期,待用户确认启用)
1. 在 66.36 起 mosdns 伴生容器(`/opt/mosdns` + compose,**按 digest 固定镜像**,纳入 ansible)。
2. AGH「上游 DNS 服务器」改为指向 mosdns(`127.0.0.1:5353`,bootstrap 仍用公网 IP,避免
AGH → mosdns → AGH 死循环);只保留**一条语义一致的上游路径**,保持可回滚(备份 yaml + `--check-config`)。
3. 验证:国内域名、国外域名、被拦截域名、每客户端日志、AAA A 解析(§8 基线)。
4. 回归:EdgeRouter 通告不变(仍 `.36`),LAN 客户端无感;重启 AGH/mosdns 单点验证(§8 kill-test 模板)。
5. 上线后重跑 §8 kill-test,确认降级窗口有界。
## 8. Phase 0 验证证据(2026-08-12 全部实测)
### 8.1 基线与功能
| 项 | 结果 |
|----|------|
| rewrites:`hass.windy.lan` / `hass.local` | → `192.168.55.11` ✅(兼容保留) |
| rewrites:`dns.windy.lan` / `gfw.windy.lan` / `ubnt.windy.lan` / `nas.windy.local` | → `.36` / `.1` / `.46` / `.32` ✅ |
| 国内解析 `taobao.com`(经 AGH) | 真实 CN IP(59.82.x 等)✅ |
| 国外解析 `google.com` / `github.com`(经 AGH) | 真实 IP(142.250.x / 20.205.x),**无 fake-IP 泄漏** ✅ |
| 广告拦截 `doubleclick.net` / `googleadservices.com` | → `0.0.0.0` ✅ |
| clash 7874 `google.com` | `198.18.1.101`(fake-ip,仅网关/VLAN10 平面)✅ |
| clash 7874 `taobao.com` | 真实 IP(经 mosdns→AGH)✅ |
| dnsmasq :53(.1)`google.com` / `taobao.com` | fake-ip / 真实 IP ✅ |
| mosdns 6052 直连 `taobao.com` / `google.com` | 真实 IP(59.82.x / 142.250.73.78)✅ |
| DNSSEC:`dnssec-failed.org`(经 AGH) | 返回正常应答 `96.99.227.255`(非 SERVFAIL)→ 当前路径不校验,W1N-40 结论复现,**维持关闭** |
### 8.2 出口路径与 fake-IP 泄漏
- AGH 宿主默认路由 `via 192.168.66.254`(EdgeRouter),**直连出网,不经 gfw**;DoH 端点实测可达
(见 §1)。LAN 客户端经 AGH 的解析结果全部为真实 IP,无 `198.18/16` 泄漏。
- gfw 自身流量默认进代理(`openclash_mangle_output` mark 0x162 → tproxy :7895),
clash nameserver-policy 的 `1.1.1.1` DoH 实测 35ms 可达(走代理链路,不依赖直连)。
- mosdns 上游(AGH `.36``223.5.5.5`)命中本地/国内 bypass 规则,保持直连——设计意图达成。
### 8.3 IPv6 / RDNSS / AAAA
- LAN 有 IPv6 SLAAC(EdgeRouter dhcpv6-pd /60 → eth0 host-address + switch0,**仅 `service slaac`**,
**无 RDNSS/dns-server 通告**);`.36` 有全局 v6 地址 + RA 默认路由。
- **RDNSS 未通告** → v6 客户端无 v6 DNS,回退 v4 DNS(`.36`);AGH 仅监听 `0.0.0.0:53`(v4 only),无 v6 DNS 服务。
- **AAAA 解析实测**:`baidu.com` 公网本就无 AAAA(dns.google NOERROR/0,权威 NS 而已);
`taobao.com` AAAA 经国内路径正常(`2408:4001:f10::6f` 等);**国外域名(`google.com`)经
`223.5.5.5` UDP、alidns DoH、`8.8.8.8` UDP 全部返回空**,而 dns.google 与 `adg.chans.xyz`
DoH 均能返回 `2404:6800:4005:81a::200e` → **国内路径对国外域 AAAA 置空;`adg.chans.xyz`
兜底是当前唯一能返回国外 AAAA 的路径**。clash `ipv6: false` 亦不返回 AAAA。
### 8.4 自举(bootstrap)循环
- AGH `bootstrap_dns: [223.5.5.5, 223.6.6.6]`(公网 IP,非 AGH 自身)→ 无自举循环;AGH 解析
DoH 主机名不经过自身。二期方案要求 AGH→mosdns 时 bootstrap 仍用公网 IP(§7)。
### 8.5 Kill-test 矩阵(2026-08-12,全部实测)
| # | 场景 | 结果 |
|---|------|------|
| 1 | 重启 `.1` mosdns(init.d) | ✅ 直连 6052 与 clash 链恢复 |
| 2 | 重启 `.1` dnsmasq | ✅ 真实 IP 与 fake-ip 双路径恢复 |
| 3 | kill `.1` clash 核心 | ✅ LAN DNS(AGH)不受影响;gfw dnsmasq→clash **有界 3s 失败**(无卡死);OpenClash watchdog ~15s 自动拉起 |
| 4 | stop/start `.1` OpenClash | ✅ DNS 平面独立于代理;clash 与 tproxy 规则恢复 |
| 5 | 重启 `.36` AGH 容器 | ✅ 全量恢复:rewrites/拦截/国内外解析/DNSSEC 行为不变 |
| 6 | 黑洞 alidns DoH(223.5.5.5/223.6.6.6:443) | ✅ ~0.5s 内经 `doh.pub` 应答(load_balance 生效) |
| 7 | 黑洞全部主上游,兜底存活(adg.chans.xyz) | ⚠️ **客户端 15s 内无应答**——兜底未在窗口内生效 |
| 8 | 黑洞全部上游(含兜底),缓存未命中 | ⚠️ **25s 内无应答、无 SERVFAIL**——解析器对缓存未命中查询"卡死" |
| 9 | 黑洞全部上游,缓存命中 | ✅ 瞬时 NOERROR(cache 兜底) |
**结论(Phase 0 门禁):** 国内解析在代理停止/上游单点故障/组件重启下均维持可用;
但**"主上游全挂"时缓存未命中查询无有界降级**——`upstream_timeout: 10s` 与 TCP 重试行为使
兜底 `adg.chans.xyz` 在实践中无法在客户端期望窗口内生效。这是 §4 二期分层方案(或下调
`upstream_timeout` + failover 模式)的**实测需求依据**;按最终裁决,本期不改生产路径。
## 9. 风险与备注
- mosdns 仅监听 `127.0.0.1`(不对外),由 clash 消费;二期若启用,保持同样的边界,避免 LAN 出现两套入口。
- AGH 上游指向本机 mosdns 时务必配公网 bootstrap,否则自举死循环。
- 本方案不改 EdgeRouter DHCP/通告、不改 gfw OpenClash 代理规则,只动 66.36 上的 DNS 链路,风险可控。
- 已知降级缺口(§8.5 #7/#8):主上游全挂时缓存未命中查询无有界降级;启用二期前,LAN 客户端会感知
超时(约 10s+)。缓解:AGH cache 已覆盖高频域;根治需二期。
- 与 W1N-40「审查并修正 AdGuard Home」联动:该 issue 侧重 AGH 本身,本 issue 侧重整体 DNS 分层。
+315
View File
@@ -0,0 +1,315 @@
# ER-X + TL-SE5420 网络与拓扑说明
**状态:** SE5420 **已采购**2026-08-09)。**实施与验证以 [lan-se5420-deployment-guide.md](lan-se5420-deployment-guide.md) 为准**
本文保留为拓扑/流量路径/职责边界的设计说明。
**锁定硬件:** EdgeRouter X(保留)+ TP-Link `TL-SE5420`(新建核心)。
**本文目的:** 说明升级后物理拓扑、逻辑网络、流量路径与职责边界;唯一实施步骤见
[lan-se5420-deployment-guide.md](lan-se5420-deployment-guide.md)。
---
## 1. 一句话架构
```text
公网 ← ER-X(网关 / NAT / DHCP / 防火墙)
↑ 两条独立 access 上联(LAN66 + LAN55,无 VLAN tag
TL-SE5420(纯 L2 核心:所有接入、同 VLAN 转发、未来 trunk)
有线终端 / PVE(gfw) / 两台 UniFi AP
```
- **ER-X** 继续当唯一主网关:PPPoE、NAT、端口转发、LAN66/LAN55 的 `.254` 与 DHCP。
- **SE5420** 只做二层:接线、VLAN 隔离转发、端口统计;**不开** L3、静态路由、DHCP
Server/Relay、NAT。
- **`gfw`PVE 上的 OpenWrt)** 仍是旁路由;默认客户端网关仍是 `.254`。以后若启用
「设备升级」专用 SSID,该 SSID 的客户端才把网关指向 `.1`
---
## 2. 已确认的设计决策
| 决策 | 结论 |
|---|---|
| 硬目标 | 同 VLAN **2.5G** 二层转发;10G 仅 SFP+ 预留 |
| 核心型号 | **`TL-SE5420`**16×2.5G RJ45 + 4×10G SFP+ |
| 终局形态 | ER-X 边缘网关 + 核心纯 L2;可分阶段实施 |
| 本次升级 Done | 迁完 LAN66/LAN55 接入与验收(计划阶段 03);不含 VLAN10 |
| 默认网关 | **`192.168.66.254` / `192.168.55.254`ER-X** |
| 专用升级 SSID | 仅 **U6 Lite**;客户端网关 **`gfw` `.1`**(后续项目) |
| 客人网 | 仅 **UAP-AC-Lite**UniFi Guest 隔离,仍在 **LAN55**(后续) |
| 切换方式 | **3060 分钟维护窗** 集中切;旧交换迁完后闲置 |
| 首批 2.5G | NAS + 主力 PC(或 PVE);其余可先 1G |
| SFP+ | 先空槽;有明确 10G 对端再买 DAC/光模块 |
| AP 供电 | **本地取电 + 普通网线**SE5420 **无 PoE** |
| 管理面 | 先本地 HTTPS/SSH;云以后再说;L3/DHCP 严格关闭 |
---
## 3. 设备职责
### 3.1 EdgeRouter X`gw``192.168.66.254`
| 职责 | 说明 |
|---|---|
| WAN | `eth4` → PPPoE`pppoe0`),NAT、WAN 防火墙、端口转发 |
| LAN66 网关/DHCP | `eth0` = `192.168.66.254/24`DHCP 池与静态映射不变 |
| LAN55 网关/DHCP | `switch0``eth1``eth3`= `192.168.55.254/24` |
| 跨网段路由 | LAN66 ↔ LAN55 仍由 ER-X 三层转发 |
| 与核心的连接 | **两条无标签 access**,不是 802.1Q trunk |
ER-X 当前不是 trunk 设计:不要向它发送 VLAN66/55 的 tag。核心上 VLAN66 的
untagged 帧进 `eth0`VLAN55 的 untagged 帧进 `switch0` 的某个成员口。
### 3.2 TL-SE5420(新建核心)
| 职责 | 说明 |
|---|---|
| 物理接入汇聚 | 有线设备、PVE、两台 AP 都接到 SE5420 |
| VLAN66 / VLAN55 | 二层转发(access 或 trunk 的 native/allowed |
| 同 VLAN 高速交换 | 两端都是 2.5G 时可跑满协商速率(受 NIC/线材限制) |
| 未来 trunk | PVE、U6native 66 + tagged 10(仅后续阶段启用) |
| 不做 | 不当默认网关;不开 DHCP;不做 NAT;第一阶段不开 SVI/路由 |
管理地址:落在 **LAN66** 的一个未占用地址(先查 ER-X DHCP reservation,再分配);
不新建管理 VLAN。
### 3.3 旁路由 `gfw``192.168.66.1`
| 现状 | 升级后(阶段 0–3) | 更后续 |
|---|---|---|
| PVE 上的 ImmortalWrt VM | 仍挂在 **untagged LAN66** | 可增 VLAN10 接口 |
| OpenClash 旁路/透明代理 | 路径不变:客户端默认仍走 `.254` | 升级专用 SSID → 网关 `.1` |
| 不能单独「做出」VLAN10 | 需 PVE vSwitch + 核心 trunk + U6 trunk | 见部署指南第二步 |
### 3.4 其它现网角色(地址与角色不变)
| 主机 | 地址 | 角色 |
|---|---|---|
| `dns.windy.lan` | `192.168.66.36` | AdGuard Home + Mihomo |
| `ubnt` | `192.168.66.46` | UniFi ControllerInform `http://192.168.66.46:9080/inform` |
| U6 Lite | `192.168.66.6` | LAN66 AP;后续承载升级专用 SSID |
| UAP-AC-Lite | `192.168.55.5` | LAN55 AP;后续承载客人 SSID |
| PVE | `192.168.66.26`DHCP 静态) | 宿主机;上挂 `gfw` |
| NAS / 主力 PC 等 | 既有 reservation | 首批 2.5G 候选 |
---
## 4. 物理拓扑
### 4.1 目标连线(阶段 3 完成后)
```text
Internet
PPPoE / WAN
┌──────────┴──────────┐
│ EdgeRouter X │
│ (gw / 网关) │
│ │
│ eth0 switch0 成员口
│ LAN66 LAN55
│ untagged untagged
└───┬───────────────┬─┘
│ │
铜口1│ │铜口2
│ │
┌─────────┴───────────────┴─────────┐
│ TL-SE5420 核心 │
│ 16×2.5G RJ45 + 4×SFP+(先空) │
│ 纯 L2 · VLAN66 / VLAN55 │
└───┬─────┬─────┬─────┬─────┬───────┘
│ │ │ │ │
铜口3 铜口4 铜口5 其余铜口 SFP+
│ │ │ │ (预留)
│ │ │ │
PVE U6 AC-Lite NAS/PC/
(gfw) Lite (LAN55) dns/ubnt…
LAN66 LAN66 默认 LAN66
(日后可 (日后可
trunk) trunk)
```
要点:
1. **星型**:终端与 AP 只上联到 SE5420;旧交换机迁完后**闲置**,避免双上行环路。
2. **ER-X 双上联**LAN66、LAN55 各一条独立铜缆;两条都是 access,**不打 tag**。
3. **AP 供电**SE5420 无 PoEU6 / AC-Lite 在安装点**本地取电**,网线只传数据。
4. **SFP+**:四个槽位先空着;有 10G NIC/对端与兼容模块后再上 DAC/AOC/光纤。
### 4.2 与「现在」的差异(概念上)
```text
现在(简化) 目标
───────────── ────
终端/AP ──► ER-X 内置交换/旧交换 终端/AP ──► SE5420 ──► ER-X(仅网关)
同网段 1G 交换受 ER-X/旧设备限制 同 VLAN 2.5G 在 SE5420 上本地交换
跨网段 / 上网 仍经 ER-X 跨网段 / 上网 仍经 ER-X(不变)
```
ER-X **不会被换成**「只接一条 trunk 的路由」;WAN 与双 LAN 接口模型保持不变。
---
## 5. 逻辑网络(VLAN / 子网)
### 5.1 本次升级范围内(阶段 0–3)
| 名称 | 子网 | 核心上的形态 | 网关 | DHCP | 典型成员 |
|---|---|---|---|---|---|
| **LAN66** | `192.168.66.0/24` | VLAN 66access,或 trunk 的 native | ER-X `eth0` `.254` | ER-X | PVE、gfw、dns、ubnt、U6、NAS、PC… |
| **LAN55** | `192.168.55.0/24` | VLAN 55access | ER-X `switch0` `.254` | ER-X | UAP-AC-Lite、原 LAN55 设备 |
客户端 DNS 仍按现网:DHCP 下发 `192.168.66.36`AdGuard)。
LAN55 ↔ LAN66 互通仍走 ER-X(现网 `LAN_IN`/`LAN_OUT` 未挂到接口时,两边默认可达;不要把未生效的防火墙当隔离边界)。
### 5.2 后续能力(不进本次 Done)
| 名称 | 打算 | 网关 | 落点 | 依赖 |
|---|---|---|---|---|
| **升级专用 Wi-Fi** | 独立 SSID → 独立 VLAN(如 VLAN10 | **`gfw` `.1`**(该 VLAN 内) | **仅 U6 Lite** | 核心 trunk + PVE vSwitch + `gfw` DHCP/NAT |
| **客人网** | 客人 SSID + UniFi Guest 隔离 | 仍 **ER-X `.254`LAN55** | **仅 UAP-AC-Lite** | UniFi 策略;**不**新建 VLAN |
客人网与升级 SSID **分工到两台 AP**,避免一上来就做「两台 AP 同一客人 VLAN + ER-X trunk」的改造。
### 5.3 VLAN10 的时序与边界
第一阶段**不**在 SE5420 创建 VLAN10,也**不**把 PVE/U6 改为 trunk。VLAN10 必须在 LAN55/LAN66 稳定后,以独立维护窗完成 PVE VLAN-aware 隔离、SE5420 trunk、gfw IPv4 NAT/内网拒绝策略和 IPv6 策略;仅在这些验收通过后才启用专用 SSID。详见部署指南第二步。
---
## 6. 流量怎么走
### 6.1 同 VLAN、两端都在核心上(例如 NAS ↔ PC,皆 LAN66
```text
NAS ──(2.5G)── SE5420 ──(2.5G)── PC
(二层交换,不经过 ER-X)
```
这是本次升级的**主要收益**。吞吐受 NIC、线材、磁盘限制;与 ER-X 的 1G 无关。
### 6.2 访问互联网(任意 LAN66/LAN55 客户端)
```text
客户端 → SE5420 →(对应 access 上联)→ ER-X → PPPoE → Internet
默认网关 = .254
```
上网路径仍受 **ER-X 上联 1G** 与 PPPoE 限制;换核心**不会**把公网变成 2.5G。
### 6.3 LAN66 ↔ LAN55
```text
LAN66 设备 → SE5420(VLAN66) → ER-X eth0
↕ 三层转发
LAN55 设备 → SE5420(VLAN55) → ER-X switch0
```
跨网段必须过 ER-X;两条上联各 1G,且 ER-X 是否硬件加速需实测,**不要**按 2.5G 承诺跨 VLAN 吞吐。
### 6.4 旁路由 `gfw`(默认主网行为)
默认:DHCP 仍发网关 `.254`。客户端若把代理/策略指到 `gfw`,或由上游策略引流,则
`gfw` 以旁路由身份处理;**主网关身份仍是 ER-X**。
### 6.5 未来:升级专用 SSID(仅说明,非本次范围)
```text
手机 --SSID(升级)--> U6 Lite --tag VLAN10--> SE5420 --tag VLAN10--> PVE --> gfw
网关 .1
gfw 上游仍回 LAN66 → SE5420 → ER-X → Internet
```
主 SSID(如现有 `ubnt-windy`)继续 untagged/LAN66,网关仍是 `.254`,互不影响。
---
## 7. 端口预留(SE5420
端口号以面板为准;下表是角色预留。
| 预留 | 对端 | 模式 | 备注 |
|---|---|---|---|
| 铜口 1 | ER-X `eth0` | access VLAN66 | 主网上联 |
| 铜口 2 | ER-X `switch0` 成员口 | access VLAN55 | LAN55 上联 |
| 铜口 3 | PVE`gfw` 所在宿主机) | 阶段 03access VLAN66;日后 trunk native66+tag10 | 只允许需要的 VLAN |
| 铜口 4 | U6 Lite | 同上 | 本地取电;日后升级 SSID |
| 铜口 5 | UAP-AC-Lite | access VLAN55 | 本地取电;日后客人 SSID |
| 其余铜口 | NAS、PC、`dns``ubnt`… | 默认 access VLAN66 | 首批 2.5GNAS + 主力 PC/PVE |
| SFP+ 14 | 未来 10G | 空槽 | 优先 DAC/AOC/光;不用 10GBASE-T 模块作默认 |
禁止:同一设备同时接旧路径与新核心形成**无意双上行**(环路/环回保护误杀/不稳定)。
---
## 8. Wi-Fi 与 UniFi(目标语义)
| SSID 类型 | AP | 二层落点 | 客户端网关 | 本次升级是否必须完成 |
|---|---|---|---|---|
| 现有主 SSID(如 `ubnt-windy` | 按现网 | LAN66 等 | `.254` | 迁线后保持 Connected 即可 |
| 客人 SSID | **仅 AC-Lite** | LAN55 + UniFi Guest 隔离 | `.254` | **否**(后续) |
| 升级专用 SSID | **仅 U6** | 未来 VLAN10 | **`.1`gfw** | **否**(后续) |
Inform 仍为:`http://192.168.66.46:9080/inform`
AC-Lite 在 LAN55、Controller 在 LAN66:跨网段由 ER-X 转发(现网已验证可达)。
---
## 9. 管理与安全面
| 项 | 做法 |
|---|---|
| SE5420 管理 IP | LAN66 未占用地址;DHCP reservation 或静态;先查 ER-X 再写 |
| 管理协议 | 本地 HTTPS / SSH;禁用不需要的 HTTP/Telnet |
| 云管 | 先本地管稳;商云/App **以后再说** |
| L3 功能 | **全部关闭**(无 SVI、无静态路由、无 DHCP Server/Relay |
| STP | 开启 RSTP/MSTP;合理根桥优先级;迁完后不留生产环路 |
| 配置备份 | 离线导出;**不得**把含口令/SNMP 的备份放进本仓库 |
| AP | 本地电源 + 数据网线;核心不供电 |
---
## 10. 性能预期(诚实边界)
| 场景 | 预期 |
|---|---|
| 同 VLAN、两端 2.5G 接在 SE5420 | 可达约 2.5G 级二层(测 `iperf3` 验收) |
| 上网 / PPPoE | 仍受 ER-X WAN 与 1G 上联限制 |
| LAN66 ↔ LAN55 | 必过 ER-X;按 1G 级规划,实测为准 |
| SFP+ 空槽 | **不等于**网络已是 10G |
| 未来 VLAN10 + `gfw` | 受 PVE NIC/vSwitch 与 `gfw` 处理能力限制,勿预先承诺 2.5G 路由 |
---
## 11. 验收时「网络算正常」的样子
阶段 3 完成后,应同时满足:
1. 有线设备从 SE5420 获取正确网段地址;默认路由为对应 `.254`DNS `.36` 可用。
2. 互联网、端口转发、现有本地服务行为与迁线前一致(除同 VLAN 内网吞吐变快)。
3. U6`.6`)与 AC-Lite`.55.5`)在 UniFi 为 ConnectedInform 未改。
4. SE5420 管理面仅可信 LAN66 可达;默认口令已改;配置已离线备份。
5. 端口协商速率、CRC/错误计数正常;旧交换已下电闲置。
6. (可选强化)两台 2.5G 终端同 VLAN `iperf3` 结果入库/记笔记。
VLAN10 / 升级 SSID / 客人 SSID **失败或未做,不否决**本次核心升级。
---
## 12. 回滚语义
任一阶段失败:停止继续搬家;把刚迁的设备插回原 ER-X/旧交换端口。
**不要**在故障中改 ER-X 的 WAN、DHCP 池、网关地址或 SSH 策略。
后续 VLAN10 失败:只撤 SSID/VLAN 绑定,主 SSID 与 `.254` 路径保持不动。
---
## 13. 参考
- 实施阶段与清单:[lan-core-switch-upgrade-plan.md](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>
+198
View File
@@ -0,0 +1,198 @@
# 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.
---
## 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 purchased (2026-08-09):** TP-Link `TL-SE5420` acquired; deployment plan is
> [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](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/haos 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) — **PVE VM 180** (LAN55) | `ssh hassio@hass.windy.lan` | `192.168.55.11` | [hosts/hass.windy.lan.md](../hosts/hass.windy.lan.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 (verified 2026-08-09):** `dns`/`ubnt`/`gfw`/`haos` are all VMs on PVE
> (no separate physical hosts); `transmission` is a FreeNAS/NAS jail. Only gw, PVE,
> NAS, U6, UAP-AC-Lite, and wired PCs/NAS are physical SE5420 ports. 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.
> **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; kernel `6.1.115-haos` (aarch64) |
---
## 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).
---
## 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
+397
View File
@@ -0,0 +1,397 @@
# ER-X → RB5009 网关升级:网络形态与实施计划
**状态:** 规划文档(未采购、未接线、未改生产配置)。
**重要变更(2026-08-09):** **SE5420 已采购**,网络升级改为「保留 ER-X + SE5420 核心」路径——
实施与验证以 [lan-se5420-deployment-guide.md](lan-se5420-deployment-guide.md) 为准。
本文保留为「ER-X 网关未来替换为 RB5009」的备选方案;其中 PVE 透传调研与 VLAN10 实现方法仍适用。
---
## 1. 一句话架构
```text
公网 ← RB5009(主网关:PPPoE / NAT / 防火墙 / DHCP / IPv6 / VLAN 66+55 三层)
↑ 全部终端 / AP / PVE 直连其 8×1G 端口
(SFP+ 空槽,为未来 10G 核心预留)
```
- **RB5009** 兼任路由与 LAN 二层交换:所有接入设备直插 RB5009 端口。
- 客户端默认网关仍是 **`.254`**RB5009 沿用 ER-X 地址,客户端零感知)。
- **`gfw`** 仍为旁路由(默认网关 `.254`);OpenClash 行为不变。
- **ER-X 升级后下电闲置,作为已配置的备件保存**(回滚路径)。
- 2.5G/10G 同 VLAN 交换目标**放弃**(无 SE5420);LAN 二层为 1G。
---
## 2. 已确认的设计决策
| 决策 | 结论 |
|---|---|
| 角色 | RB5009 单机:路由 + LAN 交换 + 双 VLAN 三层 |
| WAN | **PPPoE**,走 **`ether1`2.5G 铜口接 ONT/光猫)**MTU 1492 |
| LAN 模型 | **单 bridge + VLAN 过滤**VLAN 66(多数端口)+ VLAN 55VLAN 10 预留 |
| 网关地址 | **沿用 `192.168.66.254` / `192.168.55.254`**;重编址另立项目 |
| IPv6 | **保留对等**PPPoE 上 DHCPv6-PD `/60` + 双 LAN SLAAC `/64` + IPv6 防火墙 |
| 端口转发 | 4 条 **原样迁入**hass / transmission / ssh / openvpn+ hairpin NAT |
| LAN55 成员 | **保持现状**AC-Lite、Aqara 及原 LAN55 设备)不迁到 VLAN66 |
| 管理面 | SSH **仅密钥**`zhiqiang`);禁用默认 `admin`**禁 Winbox/API/WebFig** 或仅限 LAN |
| 迁移方式 | 台面预配置(临时 `.253`)→ 维护窗(30–60 分钟)→ 验证 → ER-X 留作备件 |
| 固件/备份 | 当前稳定 RouterOS 7.x;上线前 `.backup` + 文本导出存离线 |
| 时区/NTP | `Asia/Shanghai` + 现有 NTP 服务器 |
| 安全 | PPPoE 口令、SSH 私钥、`zhiqiang` 口令**只存设备上**,不落本仓库 |
---
## 3. 升级后的网络形态(拓扑图)
### 3.1 目标物理拓扑(升级完成后)
```text
Internet
│ PPPoE (ether1 2.5G, MTU 1492, IPv6 PD /60)
┌──────────────────────────────────────────────┐
│ RB5009 (gw) RouterOS 7.x │
│ ether1 = WAN (PPPoE) │
│ bridge + vlan-filtering=yes │
│ vlan66 = 192.168.66.254/24 (pvid 66) │
│ vlan55 = 192.168.55.254/24 (pvid 55) │
│ SFP+ = 空槽(未来 10G
│ SSH-only 管理(仅 LAN
└─────┬─────────┬──────────┬──────────┬────────┘
│ pvid66 │ pvid66 │ pvid66 │ pvid55
│ │ │ │
gfw (.1) dns (.36) ubnt (.46) UAP-AC-Lite (.55.5)
PVE (.26) NAS/PC… U6 Lite Aqara (.55.248)
(.66.6)
```
端口角色以面板为准;下表是建议预留:
| RB5009 端口 | 对端 | 模式 |
|---|---|---|
| `ether1` | ONT/光猫 | WANPPPoE |
| `ether2``ether8` | PVE/gfw、dns、ubnt、U6 Lite、NAS、PC、杂项 | accesspvid 66 |
| `ether9` | 原 LAN55 成员(AC-Lite、Aqara…) | accesspvid 55 |
| SFP+ `sfp-sfpplus1` | 未来 10G 核心 | 空槽 |
> **禁止双上行:** 任一设备不得同时接旧 ER-X 与 RB5009(环路 / 双默认网关)。
### 3.2 逻辑网络
| VLAN | 子网 | 网关 / DHCP | 形态 | 成员 |
|---|---|---|---|---|
| **66** | `192.168.66.0/24` | RB5009 `vlan66` `.254` | 多数端口 access | gfw、dns、ubnt、U6、PVE、NAS、PC… |
| **55** | `192.168.55.0/24` | RB5009 `vlan55` `.254` | `ether9` access | AC-Lite、Aqara…(原 LAN55 全量) |
| 10(预留) | `192.168.10.0/24` | 未来(`gfw`) | 暂不配业务 | 升级专用 SSID(后续项目) |
- 客户端 DNSDHCP 仍下发 **`192.168.66.36`**AdGuard Home)。
- UniFi Inform 仍为 `http://192.168.66.46:9080/inform`
- LAN66 ↔ LAN55 互通由 RB5009 三层转发(沿用现网默认可达语义,防火墙不设隔离边界)。
### 3.3 流量怎么走
```text
同 VLANNAS ↔ PC): 客户端 → RB5009 端口 → bridge(二层交换,1G)
跨 VLAN(66 ↔ 55): 客户端 → RB5009 路由(vlan66 ↔ vlan551G
上网: 客户端 → RB5009 → PPPoE → Internet(限速 = 线路;RB5009 fasttrack 去掉 ER-X ~900M 天花板)
旁路由: 默认网关仍 `.254`;显式代理走 gfw(不变)
未来 VLAN10 U6 trunk → RB5009 →(转发 VLAN10)→ gfw(后续项目)
```
### 3.4 性能预期(诚实边界)
| 场景 | 预期 |
|---|---|
| WAN / PPPoE | 线路速率以内;若线路 >1GRB5009 fasttrack 可跑满(ER-X 受 900M 限制) |
| 同 VLAN 交换 | **1G**(无 SE5420RB5009 8×1G |
| 跨 VLAN | 1G 级三层 |
| SFP+ 空槽 | 不等于已是 10G |
---
## 4. 分阶段实施计划
### 阶段 0:采购前核验
1. 确认 RB5009 revision、保修、零售渠道、RouterOS 当前稳定版本号(建议 7.x 最新 stable)。
2. 确认硬件:1× 2.5G PoE-inether1)、8× 1G、1× SFP+、1GB RAM。SFP+ 模块本次不买。
3. 盘点线缆与对端:ONT ↔ ether1;原 LAN55 设备清单(AC-Lite、Aqara…)及原 ER-X `switch0` 接线。
### 阶段 1:离线初始化(台面)
1. 仅电源 + 隔离管理本。设置身份、时区 `Asia/Shanghai`、NTP。
2. 建 `zhiqiang` 管理用户(**密钥登录,禁密码**);**删除/禁用默认 `admin`**。
3. 建 bridge + VLAN 66/55 + 端口模板(`ether28` pvid66`ether9` pvid55);**临时地址 `.253`**(不与 ER-X `.254` 冲突)。
4. 配 WAN PPPoEether1)、默认路由、NAT masquerade、4 条端口转发 + hairpin、防火墙(input/forward)、MSS clamp、IPv6 PD + SLAAC + IPv6 防火墙。
5. 配 DHCP 池 `.38.243`、24h 租约、DNS `.36`、UniFi Inform 选项、**静态映射全量照搬**。
6. 管理面收口:**禁 Winbox8291/ API8728/ WebFig80/443**SSH 仅限 LAN66/55。
7. `system backup save` + `/export`,导出到离线存储(**不落仓库**)。
**参考 RouterOS 配置骨架**(口令/密钥用占位符;落地前逐一核对):
```text
# 身份 / 时区 / NTP
/system identity set name=gw
/system clock set time-zone-name=Asia/Shanghai
# WAN
/interface ethernet set ether1 name=wan
/interface pppoe-client add name=pppoe0 interface=wan user=<PPPoE_USER> \
password=<PPPoE_PASS> add-default-route=yes use-peer-dns=no \
mtu=1492 mru=1492
# bridge + VLAN
/interface bridge add name=bridge66 vlan-filtering=yes
/interface bridge port add bridge=bridge66 interface=ether2 pvid=66
... # ether3..ether8 同 pvid 66
/interface bridge port add bridge=bridge66 interface=ether9 pvid=55
/interface bridge vlan add bridge=bridge66 vlan-ids=66 tagged=bridge66
/interface bridge vlan add bridge=bridge66 vlan-ids=55 tagged=bridge66
/interface vlan add name=vlan66 interface=bridge66 vlan-id=66
/interface vlan add name=vlan55 interface=bridge66 vlan-id=55
# 地址(台面先用 .253,维护窗切 .254)
/ip address add address=192.168.66.254/24 interface=vlan66
/ip address add address=192.168.55.254/24 interface=vlan55
# NAT masquerade + 端口转发 + hairpin
/ip firewall nat add chain=srcnat out-interface=pppoe0 action=masquerade
/ip firewall nat add chain=dstnat in-interface=pppoe0 protocol=tcp dst-port=8123 \
action=dst-nat to-addresses=192.168.55.11 to-ports=8123
/ip firewall nat add chain=dstnat in-interface=pppoe0 protocol=tcp dst-port=51413 \
action=dst-nat to-addresses=192.168.66.51 to-ports=51413
/ip firewall nat add chain=dstnat in-interface=pppoe0 protocol=udp dst-port=51413 \
action=dst-nat to-addresses=192.168.66.51 to-ports=51413
/ip firewall nat add chain=dstnat in-interface=pppoe0 protocol=tcp dst-port=5822 \
action=dst-nat to-addresses=192.168.66.36 to-ports=22
/ip firewall nat add chain=dstnat in-interface=pppoe0 protocol=udp dst-port=5822 \
action=dst-nat to-addresses=192.168.66.36 to-ports=22
/ip firewall nat add chain=dstnat in-interface=pppoe0 protocol=tcp dst-port=1194 \
action=dst-nat to-addresses=192.168.66.32 to-ports=1194
/ip firewall nat add chain=dstnat in-interface=pppoe0 protocol=udp dst-port=1194 \
action=dst-nat to-addresses=192.168.66.32 to-ports=1194
/ip firewall nat add chain=srcnat connection-nat-state=dstnat action=masquerade # hairpin
# 防火墙(input / forward),顺序很重要:SSH 放行必须早于默认 drop
/ip firewall filter add chain=input connection-state=established,related action=accept
/ip firewall filter add chain=input connection-state=invalid action=drop
/ip firewall filter add chain=input protocol=icmp action=accept
/ip firewall filter add chain=input in-interface=pppoe0 action=drop comment="drop WAN in"
/ip firewall filter add chain=input protocol=tcp dst-port=22 \
src-address=192.168.66.0/24,192.168.55.0/24 action=accept comment="mgmt SSH"
/ip firewall filter add chain=input action=drop comment="drop other input"
/ip firewall filter add chain=forward connection-state=established,related action=fasttrack-connection \
hw-offload=yes
/ip firewall filter add chain=forward connection-state=established,related action=accept
/ip firewall filter add chain=forward connection-state=invalid action=drop
/ip firewall filter add chain=forward in-interface=pppoe0 action=drop comment="drop WAN fwd"
/ip firewall filter add chain=forward action=accept comment="accept LAN fwd"
# MSS clampPPPoE MTU 1492 → 1452ER-X 旧值 1412 偏小,验证后按标准值收敛)
/ip firewall mangle add chain=forward protocol=tcp tcp-flags=syn \
tcp-mss=1400-65535 action=change-mss new-mss=1452 passthrough=yes
# IPv6PD /60 + SLAAC /64
/ipv6 dhcp-client add interface=pppoe0 request=prefix pool-name=pd6 \
pool-prefix-length=60 add-default-route=yes
/ipv6 address add from-pool=pd6 interface=vlan66 address=::1 adverts=yes
/ipv6 address add from-pool=pd6 interface=vlan55 address=::1 adverts=yes
# /ipv6 firewall filter 参照 IPv4established/related accept、invalid drop、
# icmpv6 accept、pppoe0 in drop、默认 dropforward 同构
# DHCP(池 / DNS / Inform / 静态映射全量照搬 ER-X)
/ip pool add name=pool66 ranges=192.168.66.38-192.168.66.243
/ip pool add name=pool55 ranges=192.168.55.38-192.168.55.243
/ip dhcp-server add name=dhcp66 interface=vlan66 address-pool=pool66 lease-time=1d
/ip dhcp-server add name=dhcp55 interface=vlan55 address-pool=pool55 lease-time=1d
/ip dhcp-server network add address=192.168.66.0/24 gateway=192.168.66.254 \
dns-server=192.168.66.36
/ip dhcp-server network add address=192.168.55.0/24 gateway=192.168.55.254 \
dns-server=192.168.66.36
# 静态映射:从 ER-X 导出后逐条 /ip dhcp-server lease add ...
# UniFi InformDHCP option 43hex 编码为 http://192.168.66.46:9080/inform),
# 或依赖已 adopt AP 的 set-inform;与现网 ER-X 行为保持一致。
```
### 阶段 2:上台预验证(ER-X 仍在线)
1. 将 RB5009 `ether2` 接入现有 LAN66 网段(管理本同网段),SSH 登录 `.253`
2. 全配置复查:VLAN 表、防火墙规则顺序(SSH 在 drop 前)、NAT、路由、IPv6。
3. **暂不启用 RA/DHCP**(避免与 ER-X 冲突)。预验证 `.253` 可达、SSH 密钥生效、`zhiqiang` 无密码。
4. 确认 ER-X 当前配置**已备份并保存**(作为回滚依据;`show configuration commands` 过滤敏感行)。
### 阶段 3:维护窗切换(约 30–60 分钟)
1. 变更 RB5009 `vlan66`/`vlan55` 地址 `.253 → .254`
2. **下电 ER-X**(保留原接线与配置,不作任何改动)。
3. ONT 线从 ER-X `eth4` 移到 RB5009 `ether1`
4. 观察 PPPoE 拨号:`/interface pppoe-client monitor pppoe0` 直至 `status=established`;确认 WAN IP 与默认路由。
5. 验证清单(见第 5 节),全绿才算完成。
### 阶段 4:上线收口
1. 再确认管理面:禁 Winbox/API/WebFig、SSH 仅密钥、默认 `admin` 已除。
2. 更新固件/补丁至已核定的稳定版;重新导出备份(`.backup` + 文本)存离线。
3. ER-X 下电收纳为**已配置备件**,把其接线与角色记录到 `hosts/gw.md` 备份节。
### 阶段 5(后续,另立项目)
**时序确认:** VLAN10 升级专用 Wi-Fi 是 RB5009 上线**稳定之后**的独立项目,不并入本次维护窗。
- 4 条端口转发是否仍需的审计。
- 网关重编址(`.254 → 其它`)决策。
- **VLAN10 升级专用 SSID → `gfw`(可行性审查,2026-08-09**
- 迁移后同步本仓库事实文档:`hosts/gw.md``docs/lan-overview.md``inventory/hosts.md``AGENTS.md` 快速地图。
### 阶段 5 附:VLAN10 可行性审查
**结论:** 换 RB5009 后**物理上可行**,但 RB5009 只解决「交换机侧」路径;PVE 宿主机到 `gfw` 虚拟机这一段(此前判定为不可行的关键缺口)仍必须单独打通。
**为什么当前不能在 AP 上启用 VLAN10:**
U6 接的是 ER-X `eth0` 的普通 untagged LAN66 路径,尚无已验证的 VLAN10 端到端二层通道。给 SSID 选择 VLAN10 后,客户端不会获得可由 gfw 服务的预期 VLAN10 网络;帧究竟被丢弃、被设备错误处理,还是 SSID 实际没有打 tag,必须以 AP/交换机/gfw 抓包和配置核验判断。**不得**将“tagged 帧必然自动去标签并泄漏到 LAN66”作为实施前提。
**RB5009 下实现需要 3 个前提:**
1. **RB5009 bridge 增加 VLAN10 转发:**
`/interface bridge vlan add bridge=bridge66 vlan-ids=10 tagged=<U6口>,<PVE口>`
只在这两个口 tagged。**不要**在 RB5009 上给 VLAN10 配 IP / DHCPgfw 才是网关与唯一 DHCP)。
2. **PVE 宿主机路径(必须做,RB5009 解决不了这一段):**
PVE 物理上联口透传 tagged VLAN10`gfw` 虚拟机有一块 VLAN10 可达的网卡
(如 vmbr0 开 `vlan_filtering` + 给 gfw 加第二块 pvid 10 的 NIC,或 VM 内 `eth0.10`)。
这是此前「无法实现」的同一处缺口,需在 PVE/vSwitch 层单独验证。
3. **`gfw` 侧:** VLAN10 网卡 `192.168.10.1/24` + DHCP`192.168.10.0/24`+ 出 66 口
masquerade。RB5009 无需到 `192.168.10.0/24` 的路由(gfw SNAT 后源地址即 66 网段)。
**两个注意点:**
- **单 DHCP 原则:** VLAN10 上唯一 DHCP 是 gfwRB5009 不得在 VLAN10 提供 DHCP。
- **设计确认:** 升级 SSID 客户端走 gfw 网关后,DNS 为 gfw 的 dnsmasq→clash7874),
**不是** AdGuard `.36`——这是「被代理网络」的预期行为,需接受。
### 阶段 5 附:PVE 上 VLAN10 透传实现(调研 2026-08-09
官方 wiki + 多个社区案例支持两种模型,核心原则是 **「一层只拥有一个 tag」**——要么 PVE 拥有
access VLAN,要么 OpenWrt 拥有 trunk**不能在同一张 NIC 上两层都做**。
**方案 A(推荐):PVE 拥有 access VLANgfw 加第二块 virtio 网卡**
1. `vmbr0` 开 vlan-aware`/etc/network/interfaces`):
```text
auto vmbr0
iface vmbr0 inet static
address 192.168.66.26/24
gateway 192.168.66.254
bridge-ports eno1
bridge-stp off
bridge-fd 0
bridge-vlan-aware yes
bridge-vids 10 # 至少含 10;常见默认 2-4094
```
2. gfw VM 添加 `net1: virtio,bridge=vmbr0,tag=10`。VM 内该网卡是**无标记**接口
(已落在 VLAN10 广播域),**不要再建同名 8021q 子接口**。
3. 现有 `eth0`native/untagged = VLAN66)不动,gfw 原有角色不变。
4. PVE 物理上联口(PVE→RB5009)改为 **trunknative 66 + tagged 10**
5. OpenWrt 内:新网卡 `192.168.10.1/24` + DHCP`192.168.10.0/24`+ 独立 firewall zone→wan masq。
**方案 BOpenWrt 拥有 trunk(单 NIC 多 VLAN**
- `vmbr0` vlan-awareVM NIC **不加 tag**trunk 原样进 VMOpenWrt 内建 `8021q` 设备
`eth0.10`x86/virtio 用 `option type '8021q'`,不要套用 DSA 教程)。物理上联 trunk。
- 更灵活(一块网卡多 VLAN),是 router VM 的常见做法,但需要 OpenWrt 8021q 配置,
且方案 A 对现有单网卡 gfw 改动更小。
**RB5009 侧配套(U6 口 + PVE 口都做成 trunk):**
```text
/interface bridge port add bridge=bridge66 interface=<U6口> pvid=66
/interface bridge port add bridge=bridge66 interface=<PVE口> pvid=66
/interface bridge vlan add bridge=bridge66 vlan-ids=66 tagged=bridge66 untagged=<U6口>,<PVE口>,<其余66口>
/interface bridge vlan add bridge=bridge66 vlan-ids=55 tagged=bridge66 untagged=<ether9>
/interface bridge vlan add bridge=bridge66 vlan-ids=10 tagged=<U6口>,<PVE口>
```
VLAN10 在 RB5009 上**纯二层桥接**U6 ↔ PVE),三层由 gfw 承担;RB5009 无 VLAN10 IP/DHCP。
**常见坑(社区高复发):**
- **双标签:** PVE 设了 `tag=10` 又在 OpenWrt 里建 `eth1.10` → 一帧被两层改两次。
- **native VLAN 不一致:** trunk 上无标记帧被两端当成不同 VLAN → DHCP 消失 / 拿到错网段
(正是你之前在 AP 上打 VLAN10 坏 66 网的同类故障)。
- **bridge 未 vlan-aware** tagged 帧进 host 后在 bridge 过滤层消失。
**诊断命令:**
```bash
# PVE host
bridge vlan show
ip -br link
tcpdump -eni <上联口> # 帧是否到物理口
tcpdump -eni vmtapXXXXXX # 帧是否到 VM tap
# OpenWrt guest
ip -d link show
logread -e netifd
```
**参考案例:**
- PVE 官方 wiki — Network Configuration / VLAN 802.1Q(三种模式 + vlan-aware bridge):
<https://pve.proxmox.com/wiki/Network_Configuration>
- 「OpenWrt VM on Proxmox」设计(trunk vs access 谁拥有 tag、双标签坑):
<https://phb-crystal-ball.org/run-openwrt-in-proxmox/>
- PVE 论坛「Tagged and Untagged VLAN」(`bridge-vlan-aware yes` + `bridge-vids` 解法):
<https://forum.proxmox.com/threads/tagged-and-untagged-vlan-configuration.144421/>
- OpenWrt 论坛 guest WiFi tagged VLAN 案例(`vmbr0.3`→VM 第三网卡;guest 拿到错误网段的
同型故障,最终归因在 PVE/host 侧):<https://forum.openwrt.org/t/continued-x86-openwrt-proxmox-vlan-issues/182623>
> **PVE host 改动风险:**`vmbr0` 开 vlan-aware 是对宿主机网络栈的修改,有管理面断连风险;
> 需在维护窗内用控制台/独立带外通道进行,先 `ifreload -a`PVE7+ 的 ifupdown2 支持热应用),
> 保留原配置作回滚。
---
## 5. 验收清单(「网络算正常」的样子)
1. 管理面:`zhiqiang` 密钥 SSH 可从 LAN66/55 登录;默认 `admin` 禁用;Winbox/API 不可达;`.254` 管理可达。
2. VLAN:客户端取得正确网段(`.66.x` / `.55.x`),默认网关 `.254`DNS `.36` 可用。
3. 互联网:PPPoE 已建立;IPv4 外网通;端口转发逐条从公网验证(hass 8123、transmission 51413、ssh 5822→.36:22、openvpn 1194)。
4. IPv6:两 VLAN 拿到 SLAAC `/64`,默认路由存在,外部 IPv6 可达;IPv6 防火墙未阻断必要 ICMPv6/DHCPv6。
5. 本地服务:跨 VLAN`.55.x``.66.x`)互通;AdGuard Home、UniFi 控制器、`gfw` 旁路由行为与升级前一致。
6. UniFiU6 Lite`.66.6`)与 UAP-AC-Lite`.55.5`)在控制器显示 **Connected**Inform 未变 `:9080`
7. 无环路、无双默认网关;端口协商与错误计数正常。
8. 备份(`.backup` + 文本导出)已离线保存;ER-X 已下电收纳。
---
## 6. 回滚语义
任一阶段失败:**停手**。
- **维护窗内失败(PPPoE 未起 / 客户端不通 / 防火墙锁死):**
1. 下电 RB5009。
2. ONT 线插回 ER-X `eth4`
3. 给 ER-X 上电 → 服务在数分钟内恢复。
4. 不要在故障中改 ER-X 的 WAN、DHCP、网关地址或 SSH 策略。
- **维护窗成功后** ER-X 只是备件;后续 VLAN10 失败只撤 SSID/VLAN 绑定,主 SSID 与 `.254` 路径不动。
---
## 7. 安全与记录
- **本仓库永不记录**PPPoE 口令、`zhiqiang` 口令、SSH 私钥、RouterOS 备份(含口令/密钥)。
- 每次实质性变更(切换、回滚、加固)完成后,在 Linear **`vps` 项目**记录 scope / action / verification / 遗留 follow-up(本次先不建 issue,待执行时补)。
- SSH 与访问策略变更遵循仓库「SSH access safety」流程:ER-X 会话保持为回滚路径,新密钥登录验证成功前不关闭旧通道。
---
## 8. 参考
- 现网地图:[lan-overview.md](lan-overview.md)
- ER-X 现状:[edgerouter-x-configuration.md](edgerouter-x-configuration.md)、[hosts/gw.md](../hosts/gw.md)
- UniFi[unifi-network.md](unifi-network.md)、[hosts/ubnt.md](../hosts/ubnt.md)
- `gfw`[hosts/gfw.windy.lan.md](../hosts/gfw.windy.lan.md)
- 作废方案(**不再实施**):[lan-erx-se5420-network.md](lan-erx-se5420-network.md)、[lan-core-switch-upgrade-plan.md](lan-core-switch-upgrade-plan.md)
- MikroTik RB5009 官方:<https://mikrotik.com/product/rb5009ug_s_in>、RouterOS v7 手册
+495
View File
@@ -0,0 +1,495 @@
# SE5420 核心交换机升级操作指导(细粒度版)
**目标:** 把 LAN66/LAN55 有线接入从 ER-X 内置交换迁到 TL-SE5420;稳定后再启用 VLAN10 升级专用 SSID。
**全程三条(不再重复):**
1. 先断旧、后接新,一次一根线;同一设备不得同时接旧路径与新核心。
2. 全程保持 SE5420 Type-C Console38400 8N1)连接——SSH 会话移动其承载链路即断,不是回滚保障。
3. 任一步「预期」不符 → 停止,执行 §16 对应回滚;故障中不动 ER-X 的 WAN/DHCP/SSH。
**关于命令的真实性:** SE5420 的 GUI 菜单路径来自官方 Omada VLAN/管理指南;CLI 语法仅采用官方手册出现过的(`enable``telnet disable``copy running-config startup-config``show vlan`)。PVE/gfw 的每步先有只读快照命令,改动均以你实际看到的输出为准,不套用写死的模板。
---
# 第一阶段:主网 55/66 迁移
## 1. 开工前准备
### 1.1 备份(4 个动作,全部留档到 `/tmp/opencode/baseline-YYYYMMDD/`,不进仓库)
1. ER-X 配置快照(滤敏感行):
```bash
mkdir -p /tmp/opencode/baseline-$(date +%Y%m%d)
ssh -4 zhiqiang@192.168.66.254 'show configuration commands' \
| grep -Ev 'pppoe|password|secret|key|pre-shared' \
> /tmp/opencode/baseline-$(date +%Y%m%d)/gw-sanitized.txt
```
- 预期:文件非空,`grep -i 'password\|pppoe' gw-sanitized.txt` 无输出。
2. PVE 配置:
```bash
ssh -4 root@192.168.66.26 'cat /etc/network/interfaces; echo ---; qm config 140' \
> /tmp/opencode/baseline-$(date +%Y%m%d)/pve-interfaces.txt
```
- 预期:能看到 `vmbr0`/`vmbr1` 定义与 VM140 的 `net0`/`net1`
3. gfw 配置:
```bash
ssh -4 root@192.168.66.1 'cat /etc/config/network /etc/config/dhcp /etc/config/firewall' \
> /tmp/opencode/baseline-$(date +%Y%m%d)/gfw-uci.txt
```
- 预期:三个文件内容都在,且能看到 `ubunt_upg` zone、`ubunt_upg_nat``ubunt_upg_to_lan`
4. 检查备份文件存在且可读:`ls -la /tmp/opencode/baseline-$(date +%Y%m%d)/`
### 1.2 记录现网状态(迁移后比对用)
1. ER-X 状态:
```bash
ssh -4 zhiqiang@192.168.66.254 'show interfaces; show ip route; show dhcp server leases'
```
- 预期:eth0/switch0 up;两条直连路由 + pppoe0 默认路由;DHCP lease 正常。
2. PVE 地址:
```bash
ssh -4 root@192.168.66.26 'ip -br addr'
```
- 预期:`vmbr0`=192.168.66.26、`vmbr1`=192.168.55.26。
3. gfw 关键状态:
```bash
ssh -4 root@192.168.66.1 'uci show firewall | grep -E "ubunt_upg|wan.*masq"; ip -4 route'
```
- 预期:`wan.masq=1``ubunt_upg_nat``ubunt_upg_to_lan` 在位;默认路由 `default via 192.168.66.254`
4. 两台 AP:打开 UniFi 控制台 → Devices,记录 U6 Lite 与 UAP-AC-Lite 的 IP、Connected 状态、Inform URL。
### 1.3 SSID 与标签
1. 禁用不可用 SSIDUniFi → WiFi → 找到 `ubnt-upg` → 关闭 Enabled 开关。
- 预期:开关变灰/显示 Disabled。
- 记录原设置(第二步回滚要用)。
2. 给现网每根线缆贴标签:`旧设备端接口名` + `目标 SE5420 口`,如 `NAS→SE5420口4``ERX-eth0→SE5420口1`
3. 打印 §2 端口表放在操作位。
### 1.4 物料与带外
1. 确认有:SE5420(核对 V1.0 型号)、原装电源线、Type-C 串口线、Cat5e 以上网线数根、标签贴。
2. 管理本装「TP-LINK USB 串口驱动」(官网下载或随机光盘)。
3. 终端仿真软件准备(PuTTY/串口助手/Windows Terminal),预填参数 **38400 / 8 / 无校验 / 1 / 无流控**
## 2. 端口表(SE5420
> 面板端口为**上下交错**排列:下排 = 1、3、5、7、9、11、13、15;上排 = 2、4、6、8、10、12、14、16(口 1 最左下,口 2 在其正上方)。
> **分组原则:上排全 = LAN66,下排全 = LAN55**(55 在 66 下一排);ER-X 两条上联在最左相邻(口 2=66、口 1=55)。
### 上排 = VLAN66(口 2、4、6、8、10、12、14、16
| SE5420 口 | 对端 | 模式 | PVID | 成员 VLAN |
|---|---|---|---|---|
| **2** | ER-X `eth0`66 上联) | access | 66 | untagged 66 |
| **4** | PVE `enp1s0` | access | 66 | untagged 66 |
| **6** | U6 Lite | access | 66 | untagged 66 |
| **8** | NAS/FreeNAS 第 1 口 | access | 66 | untagged 66 |
| **10** | windy-pc`.99`)或 hp-nas`.32`)二选一 | access | 66 | untagged 66 |
| **12** | NAS 第 2 口(后续 LACP 窗用) | 物理断开 | — | — |
| **14** | LAN66 预留 | admin down | — | — |
| **16** | LAN66 预留 | admin down | — | — |
### 下排 = VLAN55(口 1、3、5、7、9、11、13、15
| SE5420 口 | 对端 | 模式 | PVID | 成员 VLAN |
|---|---|---|---|---|
| **1** | ER-X `switch0` 成员口(55 上联) | access | 55 | untagged 55 |
| **3** | PVE `enp2s0` | access | 55 | untagged 55 |
| **5** | UAP-AC-Lite | access | 55 | untagged 55 |
| **7** | 隔壁房间上联(IoT | access | 55 | untagged 55 |
| **9** | 打印机(未来) | admin down | — | — |
| **11** | LAN55 预留 | admin down | — | — |
| **13** | LAN55 预留 | admin down | — | — |
| **15** | LAN55 预留 | admin down | — | — |
| SFP+ 14 | 最右侧 4 个 | 空槽 | — | — |
- 第一步不建 VLAN10、不向 ER-X 送任何 tag、口 4/6PVE、U6)不做 trunk。
- NAS 只接口 8,口 12 断开(LACP 是独立维护窗)。
- 不占口的 VMdns(.36=VM120)、ubnt(.46=VM160)、gfw(.1=VM140)、haos(.55.11=VM180)transmission(.51) 是 NAS jail。
## 3. 开箱与固件升级
1. 核对包装型号 `TL-SE5420`;看设备底部标签硬件版本 `V1.0`
2. 浏览器打开 `https://www.tp-link.com.cn/product_2899.html?v=download`
3. 页面「升级软件」分类下下载最新固件(已知 `20230908_1.0.0`,以页面为准);解压得到 `.bin` 文件。
4. 管理本网线接 SE5420 任意 RJ45 口;管理本设静态 IP `10.18.18.10/24`(网关留空)。
5. 浏览器 `http://10.18.18.251`,首次登录创建用户名 + 强密码。
6. WEB 里 `系统管理 → 系统工具 → 软件升级 → 选择文件` 选固件 → 升级 → 等待重启,**期间勿断电**。
7. **验证:** 升级后 `系统管理 → 系统配置 → 系统信息` 版本号正确;面板 SYS 灯绿色闪烁、Link/Act 闪一下后熄灭。
## 4. 台面初始化 SE5420(不接生产)
### 4.1 登录通道确认
1. Console 线接 SE5420 Type-C Console 口 → 管理本 USB。
2. 打开串口终端(38400/8/无/1/无流控),连按回车。
3. **预期:** 出现 `TL-SE5420>` 提示符。
4. 输入 `enable` 回车,进特权模式(提示符变 `#` 或类似)。
5. 若 WEB 也通(第 3 步已创建账号),记录:WEB + Console 双通道可用。
### 4.2 基础安全
1. WEB 登录 → 改强口令:`系统管理 → 用户管理 → 用户配置` → 修改用户名/密码 → 保存。
2. 设时区与 NTP`系统管理 → 系统配置 → 系统时间` → 时区 `Asia/Shanghai` → NTP 服务器填 `ntp.aliyun.com`(或 `pool.ntp.org`)→ 保存。
3. 关 Telnet`系统管理 → 安全管理 → Telnet配置` → 关闭 Enable。
- CLI 替代:`telnet disable`
4. 关 HTTP`系统管理 → 安全管理 → HTTP配置` → 关闭 HTTP 服务(保留 HTTPS)。
5. 开 SSH`系统管理 → 安全管理 → SSH配置` → 启用 SSH(端口 22)。
6. 关云管理:`系统管理 → 云管理 → 全局配置` → 全局开关保持关闭(不绑定 `smbcloud.tp-link.com.cn`)。
7. **保存:** 顶部/右上角保存按钮,或 `配置保存 → 保存配置`
8. **验证:** CLI `show running-config` 中 Telnet/HTTP 为 disable;管理本 `https://10.18.18.251` 能开、SSH 22 能连(此时管理 IP 仍是默认 10.18.18.25166.253 的 HTTPS 验证在 §4.3 第 7 步做)。
### 4.3 VLAN 与管理地址(本步只建 66/55)
**步骤顺序依据:先建二层 VLAN(勾选端口成员)→ 再建 SVI → 验证管理路径 → 才清理 VLAN1。**
> **端口成员在 VLAN 配置界面的 Untagged/Tagged 选择器里直接勾选**,无独立端口配置页。Untagged = 该 VLAN 的 access 口;PVID 随之对应(以界面为准)。
1. 建 VLAN66`VLAN → 802.1Q VLAN → VLAN配置` → 新建 → VLAN ID 填 `66`**Untagged 勾选:口 2、4、6、8、10**Tagged 不选)→ 保存。
2. 建 VLAN55:新建 → VLAN ID 填 `55`**Untagged 勾选:口 1、3、5、7**Tagged 不选)→ 保存。
3. **验证:** VLAN 列表出现 66、55;VLAN1 仍在(暂不动)。
4. 建管理 SVI`路由功能 → 接口 → 接口设置` → 创建接口 → 接口 ID `VLAN 66` → IP 地址模式 `Static` → IP `192.168.66.253`、掩码 `255.255.255.0` → 管理状态开启 → 保存。默认网关:`路由功能 → 静态路由 → IPv4静态路由条目` → 新建:目的 `0.0.0.0`、掩码 `0.0.0.0`、下一跳 `192.168.66.254`、管理距离 `1`
5. 管理本改静态 IP `192.168.66.10/24`,网线接 SE5420 **口 6**(口 6 已在 VLAN66)。
6. **验证管理路径:**
- `ping 192.168.66.253` → 通;
- 浏览器 `https://192.168.66.253` → 登录成功;
- (可选)SSH`ssh -o KexAlgorithms=+diffie-hellman-group1-sha1 -o HostKeyAlgorithms=+ssh-rsa admin@192.168.66.253` → 成功。
- 全通后再进行下一步;否则停手排查(查 Untagged 勾选/PVID/接线),保持 Console 兜底。
7. **保存配置**(右上角保存或 `配置保存 → 保存配置`)。
8. **验证:** `show vlan`:口 2/4/6/8/10 属 VLAN66,口 1/3/5/7 属 VLAN55,口 9/11/12/13/14/15/16 无成员;VLAN1 暂时还有全口成员(下一步清理)。
9. 清理 VLAN1`VLAN → 802.1Q VLAN → VLAN配置` → 编辑 VLAN1 → 把口 116 从 **Untagged 勾选**中移除(保留 66 或 55 的勾选不变)→ 保存。**改 PVID 不等于移除成员。**
> **VLAN1 不可删除属正常**TP-Link 默认 VLAN),且 **access 口必须属于某 untagged VLANGUI 会拒绝把口从 VLAN1 剔除**——不必强求。做法改为:**先把未用口 Disabled(第 13 步)再清 VLAN1 IP(第 10 步)**disabled 口不转发流量,VLAN1 剩下无管理 IP + 全 disabled 成员 = 无管理面、无转发路径,目标达成。
10. 删 VLAN1 管理 IP`路由功能 → 接口 → 接口设置` → VLAN1 → 清空 IPv4。
11. **保存配置**
12. **验证:** `show vlan` VLAN1 无任何成员、无管理 IPVLAN66 管理路径仍通(SSH/HTTPS 不断)。
13. 关未用端口:`二层交换 → 端口管理 → 端口配置` → 口 9、11、12、13、14、15、16 **及 SFP+ 1720** → 端口状态 `Disabled`;并确认这些口不在任何 VLAN 的 Untagged/Tagged 勾选中。**口 12 是 NAS LACP 预留口,未来 LACP 维护窗需在此重新启用。**
14. 配管理面源限制:`系统管理 → 安全管理 → 安全配置`(管理主机/管理访问,以界面为准)→ 只允许源 `192.168.66.0/24` 访问 HTTPS/SSH(启用 SNMP 则一并限制)→ 其余拒绝 → 保存。
> ⚠️ **不要**在 `访问控制` 菜单配——那是数据面 ACL(MAC/IP ACL),管不到管理面;管理面限制在 `安全管理 → 安全配置`
15. 配生成树:
- 全局配置(`生成树 → 基本配置`):功能`启用`、模式`RSTP`、**CIST优先级 `4096`**(让本机当根桥);联络时间 2 / 老化 20 / 时延 15 / 流量限制 5 / 最大跳数 20 保持默认。
- 端口配置(`生成树 → 端口配置`):**终端口 5、6、8、10 的「边缘端口」设`启用`**= PortFast);上联口 1、2、3、4、7 保持`禁用`。状态列显示`禁用`是链路未接/未启用,属正常。
- 端口保护(`生成树 → 安全配置 → 端口保护`):**终端口 5、6、8、10 的「BPDU保护」设`启用`**;上联口 1、2、3、4、7 保持`禁用`。环路保护/根桥保护/TC保护/BPDU过滤全部保持`禁用`
- 保存。
16. **L3 明确边界检查:** `路由功能` 确认只有 VLAN66 有接口(`路由功能 → 接口 → 接口设置` 仅 VLAN66 有 IPv4);`路由功能 → 静态路由 → IPv4静态路由条目` 只有默认路由一条(0.0.0.0→.254);`路由功能 → DHCP服务器``DHCP中继` 未启用;`路由功能 → RIP` 未启用。
17. **保存 + 导出备份:** `配置保存 → 保存配置``系统管理 → 系统工具 → 配置导出` → 导出配置文件到管理本(离线保存,不进仓库)。
18. **断电重启验证:** 拔电源 10 秒 → 插回 → 重启完成后 `ping 192.168.66.253` 通、HTTPS/SSH 可登录 → 证明保存生效。
### 4.4 台面完成判定
- `show vlan` 结果与 §2 端口表一致;VLAN1 空;管理仅 LAN66 可达;配置已导出。
## 5. 建立上联(维护窗开始)
1. 找到 ER-X `eth0` 当前所连的旧设备/旧交换线,**先拔掉旧线**(避免 eth0 同时连两条路径形成环路)。
2. 新线一端插 SE5420 **口 2**,另一端插 ER-X `eth0`
3. **验证:**
- 管理本 `ping 192.168.66.253` → 通(SE5420);
- `ping 192.168.66.254` → 通(ER-X);
- `ping 223.5.5.5` → 通(外网);
- SE5420 `show interface` 口 2:Link up、速率协商正常、错误计数 0。
4. 找到 ER-X `switch0` 某成员口(eth1–eth3 任一口)的旧接线,先拔。
5. 新线一端插 SE5420 **口 1**,另一端插该 switch0 成员口。
6. **验证:**
- 从 55 网段设备(或临时给管理本加 `.55.x` 地址)`ping 192.168.55.254` → 通;
- `ping 192.168.66.46`(控制器,跨网段)→ 通;
- SE5420 `show interface` 口 1:Link up、错误计数 0。
## 6. 迁移 PVE
1. 拔 PVE `enp1s0` 旧线 → 插 SE5420 **口 4**
2. **验证:**
```bash
ssh -4 root@192.168.66.26 'ip -br addr'
```
- 预期:`enp1s0`/`vmbr0` = `192.168.66.26/24`
- `ssh -4 root@192.168.66.26 'ping -c3 192.168.66.254'` → 通。
3. 拔 PVE `enp2s0` 旧线 → 插 SE5420 **口 3**
4. **验证:**
- `ip -br addr``vmbr1` = `192.168.55.26/24`
- `ping -c3 192.168.55.254` → 通。
5. 逐台验证 VM(顺序:gfw → dns → ubnt → 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. 每个 VM 再验业务:gfw 的 OpenClash 面板/DNS 正常、dns 的 AdGuard UI 能开、ubnt 控制器 Connected、haos 界面能开。不以"宿主开机"代替。
## 7. 迁移 AP 与接入设备
1. U6 Lite:先拔旧线 → 插 SE5420 **口 6**(本地 PoE 注入器不动,网线只走数据)。
2. **验证:** U6 重连后拿回 `192.168.66.6`UniFi 控制台显示 U6 ConnectedInform 与 §1.2 记录一致。
3. UAP-AC-Lite:拔旧线 → 插 SE5420 **口 5**
4. **验证:** `192.168.55.5`UniFi 显示 Connected。
5. 隔壁房间上联:拔旧线 → 插 SE5420 **口 7**
- 插之前确认对端:若对端是小交换机,须满足——只有一条上联、只承载 untagged LAN55、无 VLAN trunk、无第二条路径回 ER-X/SE5420。任一项不满足则不迁移。
6. **验证:** 该网段设备拿到 `.55.x`、网关 `192.168.55.254`、DNS `192.168.66.36` 可达。
7. NAS:拔旧线 → 插 SE5420 **仅口 8**(口 12 保持断开)。
8. **验证:** NAS 拿到 `.66.x`(应为原静态 `.51` 或 reservation),`ping 192.168.66.36` 通,transmission jail 可访问。
9. 口 10 只接一台明确设备(windy-pc `.99` 或 hp-nas `.32`):拔旧线 → 插口 10。
10. **验证:** 该设备拿 `.66.x`、上网正常。
11. 确认没有并行二层路径后,旧交换/旧接线下电、收线。
## 8. 第一阶段验收(全绿才进第二阶段)
1. SE5420`ping 192.168.66.253` 通,且从 55 网段**不通**(依赖 §4.3 第 14 步管理面源限制已配;若 ACL 未生效,从 55 网段经 ER-X 路由仍可达 .253——此条不通过则回查 ACL);默认口令已改;配置已离线备份。
2. LAN66 客户端:`ip addr``.66.x`、路由 `default via 192.168.66.254`、DNS `192.168.66.36``ping 223.5.5.5` 通。
3. LAN55 客户端:`.55.x``default via 192.168.55.254`LAN55↔LAN66 互通(如 `ping 192.168.66.46`)。
4. 端口转发:从公网(手机流量)逐条验——hass `:8123`、transmission `:51413`、ssh `:5822→.36:22`、openvpn `:1194`
5. UniFiU6、AC-Lite 均 ConnectedInform 与基线一致。
6. SE5420 `show interface`:所有在用口无 CRC/FCS/错误计数增长;ER-X 无广播风暴/ MAC 漂移日志;旧交换已断电。
7. gfw:主网默认网关仍 `.254``ip route` 里没有默认走 `.1`),OpenClash 行为与升级前一致。
> 可选:两台 2.5G 终端同 VLAN 跑 `iperf3 -s` / `iperf3 -c <对端>`,记结果。同 VLAN 才可能超 1G;跨 55/66 与 Internet 受 ER-X/宽带限制。
---
# 第二阶段:VLAN10 升级专用 SSID(第一步稳定 ≥1 天后再做)
> 放行门槛:§8 全绿、稳定 ≥1 天、每步先只读核实现状。SSID 最后才启用。任一步失败只回滚该步。
## 9. PVE VLAN10 边界
### 9.1 快照与备份(在 PVE 上执行:SSH `ssh -4 root@192.168.66.26` 进入或本地终端)
1. 取只读快照(逐条执行并保存输出):
```sh
cat /etc/network/interfaces
qm config 140
bridge vlan show
bridge link
ip -d link show vmbr0
pveversion -v
```
2. 备份(同 §1.1 目录):PVE `/etc/network/interfaces``/etc/pve/qemu-server/140.conf`gfw `/etc/config/{network,dhcp,firewall}`
3. 确认本地 Console/带外路径可用;**无完整备份和回滚能力则停止。**
### 9.2 改造 vmbr0 为 VLAN-aware(以 §9.1 快照为准)
1. 编辑 `/etc/network/interfaces`PVE 本机 `nano /etc/network/interfaces`)。
2. 在 **vmbr0 定义段**加两行:
```text
bridge-vlan-aware yes
bridge-vids 10 66
```
> `bridge-vids` 只放行需要的 tagged VLAN`10`gfw VLAN10 帧)+ `66`(显式声明 native)。不要写 `2-4094` 全放行;将来加 VLAN 再扩展此列表。
> ⚠️ **`bridge-vids` 必须加在 vmbr0 段**,不是 `enp1s0` 段——enp1s0 只是 `bridge-ports` 成员;加错位置 VLAN10 无法上 bridge。
3. **不要**改动 `bridge-ports enp1s0`、现有 tap 口或 `vmbr1`;现有 VM 的无标签语义(untagged/PVID)保持不变。
4. 应用:**优先 `ifreload -a`**PVE 用 ifupdown2,只应用变更);不要用 `systemctl restart networking`(会重载全部接口)。
> ⚠️ `ifreload` 会断当前 SSH 连接(管理本重连即可),`ip -br addr` 重新验证。
5. **验证(关键):** `ssh -4 root@192.168.66.26 'ip -br addr'` 显示 `.66.26` 仍在;`ping 192.168.66.254` 通;dns(.36)/ubnt(.46)/gfw(.1) 仍通。任一断 → 立即恢复备份的 interfaces 文件并 `ifreload -a`
6. `bridge vlan show` 确认现有 tap 口、物理口的 PVID/untagged 与迁移前一致,只是多出 tag 10 的允许。
### 9.3 为 gfw 加 tag=10 专用 vNIC
1. PVE 上给 VM140 加网卡(从管理本执行,bus 用下一个空闲值,记下 MAC):
```sh
ssh -4 root@192.168.66.26 'qm set 140 -net2 virtio,bridge=vmbr0,tag=10'
```
2. **验证:** `qm config 140` 出现 `net2 ... tag=10`
3. gfw 内确认新接口名:
```sh
ssh -4 root@192.168.66.1 'ip -br link'
```
- 预期出现新接口(如 `eth2`),状态 DOWN 正常。
### 9.4 迁移 gfw 的 ubunt_upg 到新接口
1. gfw 先只读确认当前绑定与 DHCP 段名:
```sh
ssh -4 root@192.168.66.1 'uci show network; uci show dhcp'
```
预期:`network.ubunt_upg.device='eth0.10'``dhcp.ubunt_upg.interface='ubunt_upg'`(按接口名绑定)。
2. **最小迁移——只改 device 一行**gfw 上执行):
```sh
ifdown ubunt_upg
uci set network.ubunt_upg.device='eth2'
uci commit network
ifup ubunt_upg
```
> `eth0.10` 不再被引用会自动消失,**无需手动删段**;DHCP 池 `dhcp.ubunt_upg.interface='ubunt_upg'` 按接口名绑定,自动跟着走。
3. **验证:** gfw 内 `ip -br addr``eth2``192.168.10.1/24` UP、`eth0.10` 消失、`eth0``.66.1`
4. 确认 firewall `ubunt_upg` zone 按 `network='ubunt_upg'` 绑定(改 device 后 zone/DHCP/NAT/规则自动跟随)。
5. **验证:**
- PVE 管理 IP 仍在 LAN66
- `bridge vlan show` 确认其他 VM 无 VLAN10 可达性(`tap140i2` 显示 `10 PVID Egress Untagged`dns/ubnt 等 tap 仍只有 PVID 1)。
6. 未通过 → 按 §16 回滚顺序停止 VLAN10 项目。
## 10. SE5420 增加 VLAN10
> **关键:口 4/6 是 access 口时,VLAN10 的 Tagged 选择器里会显示不可选**——必须先在本步第 1 步把口 4/6 改成 trunk,才能勾 Tagged。改 trunk 后口 4/6 会从 VLAN66 untagged 成员中被移除,需在第 3 步恢复。
1. 改口 4/6 为 trunk`VLAN → 802.1Q VLAN → 端口配置` → 口 4、口 6 → 端口类型 `ACCESS``TRUNK`**PVID 改 `66`**(改 TRUNK 后默认 PVID 变 1,必须改回 66)→ 保存。
2. **验证:** 端口配置页口 4/6 显示 `TRUNK` + PVID `66`
3. **恢复 VLAN66 成员:** 改 trunk 后口 4/6 从 VLAN66 untagged 被移除 → `VLAN → 802.1Q VLAN → VLAN配置` → 编辑 VLAN66 → **Untagged 重新勾选口 4、口 6** → 保存。确认 VLAN66 = 口 2、4、6、8、10。
4. 建 VLAN10`VLAN → 802.1Q VLAN → VLAN配置` → 新建 VLAN ID `10`**Tagged 勾选:口 4、口 6**(Untagged 不勾)→ 保存。口 4/6 即 trunkuntagged 66 + tagged 10。
5. 确认其余口不允许 VLAN10(口 4/6 之外不勾选)。
6. 确认 VLAN10 不建 SVI/DHCP/路由:`路由功能 → 接口 → 接口设置` 无 VLAN10 接口;`路由功能 → DHCP服务器/DHCP中继` 无 10 段。
7. **保存配置:** `配置保存 → 保存配置`
8. **验证(静态链路):**
- SE5420 `show vlan`VLAN10 仅在口 4/6VLAN66 仍是口 2、4、6、8、10。
- PVE `bridge vlan show``enp1s0` 允许 10 + 66`tap140i2` 显示 `10 PVID Egress Untagged`
- `ping 192.168.66.253``ssh` 均通(VLAN66 管理流量不受影响)。
- > 注:此时抓不到 VLAN10 帧是正常的——还没有客户端(SSID 未启用);真正流量验证在 §12 启用 SSID 后。
## 11. gfw 防火墙、NAT 与 IPv6
### 11.1 先只读确认现场
```sh
ssh -4 root@192.168.66.1 'uci show network; uci show firewall; uci show dhcp; ip -4 route; ip -6 addr; ip -6 route; fw4 print; nft list ruleset'
```
- 记录 `wan` zone 的 masq、`ubunt_upg` zone 段名、`ubunt_upg_nat``ubunt_upg_to_lan`、DHCP 段名。以下命令全部用具名段,段名以本次输出为准。
### 11.1b 现场已核实规则(2026-08 实施时确认存在,无需重建)
| 段名 | 类型 | 作用 |
|---|---|---|
| `firewall.ubunt_upg` | zone | `network='ubunt_upg'`input/forward=`REJECT`、output=`ACCEPT` |
| `ubunt_upg_dhcp` | rule | VLAN10 → UDP 67 允许(DHCP |
| `ubunt_upg_dns` | rule | VLAN10 → TCP/UDP 53 允许(DNS |
| `ubunt_upg_block_lan66` | rule | VLAN10 → `192.168.66.0/24` REJECT |
| `ubunt_upg_to_lan` | forwarding | `src=ubunt_upg → dest=lan` |
| `ubunt_upg_nat` | nat | `src=lan``src_ip=192.168.10.0/24`、target `MASQUERADE`(出口走 lan 即 eth0/LAN66 |
> **待补缺口:** 只有 `192.168.66.0/24` 被拒,**LAN55 和其余 RFC1918 无显式拒绝**——VLAN10 经 ER-X 仍可达 `192.168.55.0/24`haos/IoT/AP)等内网,必须在 §11.3 补齐。
### 11.2 IPv4 策略(目标)
| 流量 | 动作 |
|---|---|
| VLAN10 → gfw DHCP、DNS | 允许 |
| VLAN10 → gfw SSH/LuCI/SNMP/其他管理服务 | 拒绝 |
| VLAN10 → LAN66、LAN55、其他 RFC1918 内网 | 拒绝 |
| VLAN10 → Internet | 允许 |
| established/related 回包 | 允许 |
### 11.3 执行(段名替换为实际值)
1. 确认 `ubunt_upg` zone 的 `forward` 保持 `REJECT`(不改 ACCEPT)。
2. **NAT 已存在,勿动:** 现场已有 `wan` zone `masq=1` + `ubunt_upg_nat``src=lan``src_ip=192.168.10.0/24` masquerade)。**不要**给 `ubunt_upg` zone 加 masq,也不要新增 NAT 规则。核对 `nft list ruleset` 该规则在列。
3. 不新增宽泛的 `ubunt_upg→wan` zone forwarding(会经 ER-X 放行 LAN55/其它内网)。
4. **补内网前缀拒绝**LAN55、10/8、172.16/12`192.168.0.0/16` 已被 66/55/10 三段覆盖,无需再加)。⚠️ **每条必须写 `dest='lan'`**——否则 fw4 会把规则放进 `input_ubunt_upg`(进 gfw 本机方向)而非 `forward_ubunt_upg`VLAN10→其他网段转发),`dest_ip` 永远不命中、等于没配:
```sh
# LAN55
uci add firewall rule
uci set firewall.@rule[-1].name='v10_deny_lan55'
uci set firewall.@rule[-1].src='ubunt_upg'
uci set firewall.@rule[-1].dest='lan'
uci set firewall.@rule[-1].dest_ip='192.168.55.0/24'
uci set firewall.@rule[-1].target='REJECT'
uci set firewall.@rule[-1].proto='all'
uci set firewall.@rule[-1].family='ipv4'
# 10/8
uci add firewall rule
uci set firewall.@rule[-1].name='v10_deny_10net'
uci set firewall.@rule[-1].src='ubunt_upg'
uci set firewall.@rule[-1].dest='lan'
uci set firewall.@rule[-1].dest_ip='10.0.0.0/8'
uci set firewall.@rule[-1].target='REJECT'
uci set firewall.@rule[-1].proto='all'
uci set firewall.@rule[-1].family='ipv4'
# 172.16/12
uci add firewall rule
uci set firewall.@rule[-1].name='v10_deny_172net'
uci set firewall.@rule[-1].src='ubunt_upg'
uci set firewall.@rule[-1].dest='lan'
uci set firewall.@rule[-1].dest_ip='172.16.0.0/12'
uci set firewall.@rule[-1].target='REJECT'
uci set firewall.@rule[-1].proto='all'
uci set firewall.@rule[-1].family='ipv4'
uci commit firewall
fw4 reload
```
5. **验证(看 nft 而非 UCI):** `nft list ruleset | grep -A6 "chain forward_ubunt_upg"` 应显示拒绝规则**全部在 `forward_ubunt_upg` 链**且先于 `accept_to_lan``wan` masq 仍在。若拒绝规则出现在 `input_ubunt_upg` 链 → 说明漏了 `dest='lan'`,回去补。
6. **IPv6 核验:** `ip -6 addr show eth2` 只有 `fe80::``dhcp.ubunt_upg``ra='disabled'``dhcpv6='disabled'` → VLAN10 无 GUA/ULA、无 IPv6 DNS/路由/出口。
### 11.4 IPv6:本阶段不提供 VLAN10 IPv6
1. 显式关闭并核验(不能靠"没配"):RA、DHCPv6、NDP relay/delegation、IPv6 forwarding 对 `ubunt_upg` 相关接口、前缀委派。
2. 现场已知:DHCPv6/RA disabled、无 IPv6 默认路由,但全局 `IPv6 forwarding=1`——启用 VLAN10 前复查 `ip -6 addr``sysctl net.ipv6.conf.all.forwarding`
3. 客户端可留 link-local;不得获得 GUA/ULA、IPv6 DNS 或 IPv6 默认路由。
### 11.5 部署后验证
1. **链归属(已实测):** `nft list ruleset | grep -A6 "chain forward_ubunt_upg"` 确认 66/55/10/172 四条拒绝在 forward 链、先于 `accept_to_lan``192.168.10.0/24 masquerade` 仍在(§11.3 第 5 步已验)。
2. **IPv6(已实测):** `ip -6 addr show eth2``fe80::``dhcp.ubunt_upg.ra='disabled'` + `dhcpv6='disabled'`odhcpd 不服务 eth2(§11.4 已验)。
3. **DHCP 租约(已实测):** `cat /tmp/dhcp.leases` 出现 VLAN10 客户端(实测 OnePlus-12 → `192.168.10.168`)。
4. **数据面(已实测):** `nft list ruleset``192.168.10.0/24 masquerade` counter 增长(实测 215 pkts / 42KB)、`accept_to_ubunt_upg` 回包增长、四条 deny 保持 0 → VLAN10 上网走 gfw NAT、隔离生效。
5. **拒绝规则命中(SSID 启用后验):** 从测试客户端 `ping 192.168.66.1/.26/.36/.46/.254``192.168.55.254`、LAN55 主机 → 全不通;访问 gfw 的 22/80/443 管理口 → 拒绝。
6. **客户端上网(SSID 启用后验):**`192.168.10.x`、网关 `.10.1`、DNS 正常;`curl -4` 国内/海外均通、`curl -6` 失败。
7. 抓包工具:`apk add tcpdump`ImmortalWrt 25.12.0;勿用 opkg)。
## 12. UniFi 改网络 + 启用 SSID(最后做)
1. 在 AP 上核实 InformSSH 进 U6`ssh -4 zhiqiangf@192.168.66.6`)→ `info` → 记录 `inform_url`。不凭文档假定。
2. UniFi → Networks → 编辑 `ubnt-upg`
- Router/网关设置 → 选 **Third-party Gateway**
- VLAN ID`10`
- 子网:`192.168.10.0/24`;网关:`192.168.10.1`
- DHCP:关闭(dhcp 由 gfw 提供)。
3. **验证:** 保存后网络列表 `ubnt-upg` 显示 Third-party Gateway、VLAN 10。
4. UniFi → WiFi → `ubnt-upg` SSID → 分配设备只勾 **U6 Lite**(确保 UAP-AC-Lite 不被勾选)→ 保存。
5. 启用 `ubnt-upg` SSID(最后一步才开)。
6. 单台测试客户端连上后逐项验证:
- `ip addr``192.168.10.100~250``ip route` 默认网关 `192.168.10.1`DNS 为预期 gfw 地址;
- `nslookup` 普通/国内/海外域名均按预期解析(DNS 走 gfw/OpenClash 路径,不能直连 LAN66 `.36` DNS,除非书面允许);
- gfw 出现该客户端 DHCP 租约与 VLAN10 帧;
- `curl -4 https://www.baidu.com``curl -4 https://www.google.com` 均成功;`curl -6` 失败;
- `ping 192.168.66.1/.26/.36/.46/.254``192.168.55.254`、LAN55 主机 → 全不通;gfw 管理口不通;
- 代理路径:在 gfw/OpenClash 日志或按客户端源地址/五元组确认流量走代理,**不以全局 `7892` 计数增长为证明**。
7. 任一验证失败 → 先关 `ubnt-upg` SSID;主 SSID、`.254` 路径、LAN55/LAN66 不受影响。
> **实测记录(2026-08-11):** OnePlus-12 连 `ubnt-upg` 拿到 `192.168.10.168`DHCP 由 gfw 下发,租约见 `/tmp/dhcp.leases`);gfw `192.168.10.0/24 masquerade` counter 增长(215 pkts / 42KB)、`accept_to_ubunt_upg` 回包 5 pkts、四条内网 deny 保持 0 → 上网走 gfw NAT、VLAN10→内网隔离生效。手机 Wi-Fi 详情不显示网关,以 DHCP 租约 + NAT counter 为证。
## 13. 第二阶段验收
1. `ubnt-upg` 客户端满足 §12 第 6 步全部项。
2. gfw 侧 DHCP、拒绝规则、NAT、DNS、代理路径均关联到该测试客户端。
3. LAN55/LAN66 的 IPv6 行为与迁移前基线一致;VLAN10 无 GUA/ULA、IPv6 DNS、默认路由或 IPv6 出口(除 link-local)。
> **状态(2026-08-11):** 第 2 条 DHCP+NAT+隔离已实测通过(见 §12 实测记录);第 1、3 条待补(浏览器 curl 国内/海外、拒绝规则从客户端实测命中、IPv6 `curl -6` 失败)。
## 14. 性能实测(可选但建议)
1. 两台 2.5G 终端(如 NAS 与 windy-pc,均接 SE5420):一端 `iperf3 -s`,另一端 `iperf3 -c <对端IP>`
2. 记录:单流、4 流结果;预期单流约 2.3–2.5Gbps(受 NIC/线材限制)。
3. 跨网段(66↔55)与 Internet 结果如实记录——预期被 ER-X 上联/宽带限制,不要按 2.5G 承诺。
## 15. 记录
- 每次实质变更后在 Linear `vps` 项目记录 scope / action / verification / 遗留 follow-up。
- 本仓库不记录 SE5420 口令、ER-X 配置快照(含 PPPoE/口令)、gfw 凭据。
- 实施前先读 `se5420-review-claim-verification-2026-08.md` 的现场只读复核结论。
## 16. 回滚
| 失败位置 | 回滚动作 |
|---|---|
| §4 台面 | 还原台面配置或恢复出厂(见下) |
| §5–7 上联/迁移 | 停手 → 刚迁的线逐根插回 ER-X 原口 → 逐根验证(IP/服务恢复、AP Connected、MAC 表回旧拓扑) |
| §9 PVE 边界 | 关 `ubnt-upg` SSID → gfw `ubunt_upg` 恢复绑 `eth0.10` → 恢复 gfw network/DHCP/firewall → 移除 VM140 新增 vNIC`qm set 140 -delete net2`)→ 恢复 VM140 配置 → **最后**恢复 PVE `/etc/network/interfaces``ifreload -a` → 分别验证 PVE/dns/ubnt/gfw 的 LAN66 |
| §10 SE5420 VLAN10 | 删 VLAN10`VLAN → 802.1Q VLAN → VLAN配置` 删除 VLAN10),口 4/6 恢复 Untagged 仅 VLAN66、Tagged 清空 |
| §11 gfw | 恢复已备份命名规则/NAT;不触碰 LAN55/LAN66 主网规则 |
| §12 SSID | 只关 `ubnt-upg` SSID;主 SSID、`.254`、gfw 66 路径不受影响 |
**SE5420 恢复出厂(忘口令/重配):**
- **WEB 方式:** `系统管理 → 系统工具 → 软件复位`(若还能登录 GUI)。
- **Console 方式(WEB 不可用/忘口令):**
1. 管理本连 Type-C Console38400 8N1)。
2. 交换机断电重启;串口出现 **`Press CTRL-B to enter the bootUtil`** 时按 `Ctrl+B`
3. 输入 **`2`**(软件复位)→ 恢复出厂;此后首次 WEB 登录创建新用户名密码(初始管理 IP 恢复 `10.18.18.251`)。
4. 恢复后按 §4 重配并保存。
## 参考
- 设计说明:[lan-erx-se5420-network.md](lan-erx-se5420-network.md)
- 评审核实:[se5420-review-claim-verification-2026-08.md](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](lan-rb5009-upgrade.md#阶段-5-附pve-上-vlan10-透传实现-调研-2026-08-09)
@@ -0,0 +1,67 @@
# 低打印量黑白激光一体机:采购决策树(2026-08)
## 结论
对于“打印量很小、20 页/分钟足够、不需要 ADF”的需求,不应为 34 页/分钟、250 页纸盒或双面 ADF 付费。它们解决的是连续文档处理,而不是偶尔打印。优先级应为:**不被耗材/账号绑定、稳定的局域网打印、平板扫描、紧凑尺寸和可获得的原装耗材**。
首选是 **Brother DCP-L1638W 或 DCP-L1848W**:两者都是传统鼓粉分离路线,20 ppm、150 页纸盒、百兆有线网口及 2.4/5 GHz Wi-Fi;官方资料不能证实 1848 相比 1638 有实质功能升级,因此按正规渠道的含税到手价和保修选较便宜、在售的一台即可。[L1638W 官方参数表](https://www.brother.cn/-/media/ap/cn/products/pdf-file/prt/ESL-done/DCP-L1628L1638W.ashx)[L1848W 官方参数表](https://www.brother.cn/-/media/ap/cn/products/pdf-file/prt/ESL_DCP-L1848W.ashx)
不把 Brother DCP-11W 作为默认首选。它是 Brother 当前标为新品的同级机器,却是“云充”按页模式:激活后含 700 页,页数用完要通过绑定的微信账户购买套餐才能继续打印;粉仓和硒鼓由官方免费提供。这适合愿意换取三年保修和明确按页预算的人,不适合希望长期离线、自主选择耗材的人。[新品发布](https://www.brother.cn/info/news/20250613)[云充规则](https://www.brother.cn/minisite/sppackage/esl/)
## 先按需求分流,而不是按品牌或 ppm
```text
需要批量扫描/复印多页原稿?
├─ 是 → 本指南不适用;选择带 ADF 的 L2648DW 等级,双面原稿高频则看双 CIS 机型。
└─ 否
└─ 需要自动双面打印?
├─ 是 → 选择 B7628DW 等带 duplex 的型号;这是功能升级,不是速度升级。
└─ 否
└─ 必须有有线网口或 5 GHz Wi-Fi?
├─ 是 → Brother L1638W/L1848W 为基准选择。
└─ 否 / 接受仅 2.4 GHz Wi-Fi
└─ 是否接受按页充值并绑定微信?
├─ 是 → Brother DCP-11W(先比较套餐与传统耗材总价)。
└─ 否 → L1638W/L1848W;或比较下列 Canon/HP/Pantum。
```
### 采购前的一票否决项
- 需要 macOS、iPhone/iPad:确认 AirPrint;不要假定“Wi-Fi”就等于无需驱动。
- 打印机准备接交换机:确认具体 SKU 有 **Ethernet**,不能把 Wi-Fi Direct 当作局域网网口。HP MFP 1188w 的中国规格仅列 USB 和 2.4 GHz Wi-Fi,不列有线网口。
- 偶尔使用也建议选激光,但纸张要长期放在干燥、封闭处;低使用量时,受潮纸和旧粉盒造成的底灰、掉粉或卡纸比 20/22 ppm 差异更常见。
- 需要自动双面打印、ADF、双面扫描时,直接跳级;入门平板机硬凑这些功能没有性价比。
## 候选机型:仅保留与需求相符者
| 机型 | 联网 / 移动打印 | 核心纸路与扫描 | 耗材结构 | 面向本需求的判断 |
|---|---|---|---|---|
| **Brother DCP-L1638W / L1848W** | USB、100M Ethernet、2.4/5 GHz Wi-FiAirPrint、Mopria、Wireless Direct、Brother Mobile Connect | 20 ppm、150 页进/50 页出、平板 CIS;无 ADF、无自动双面 | TN118 约 1,500 页 + DR118 约 10,000 页,鼓粉分离 | **首选**:功能刚好够用,网络规格最好,自主耗材路径最清晰。两台按价格/现货择一。 |
| **Brother DCP-11W** | USB、100M Ethernet、2.4/5 GHz Wi-Fi / Wi-Fi Direct | 20 ppm、150 页纸盒、平板扫描;未列 ADF 或自动双面 | 云充按页;耗材由官方供给 | 只有明确接受微信充值、看重三年保修时才选;不是“便宜传统激光机”。 |
| **Pantum M6509NW** | USB、100M Ethernet、2.4 GHz 802.11b/g/n Wi-Fi;自带热点 | 22 ppm、150 页进/100 页出、平板扫描;手动双面 | 鼓粉一体 PD-219,官方标称 1,600 页 | **价格明显更低时的可比替代**。接口齐全,支持扫 PC/邮件/FTP/移动端,但机身较宽、仅 2.4 GHz,且鼓粉一体意味着每次换耗材同时更换感光组件。 |
| **HP Laser MFP 1188w** | USB、2.4 GHz 802.11b/g/n、Wi-Fi Direct、AirPrint/Mopria/HP 应用;**无 Ethernet** | 22 ppm、150 页进/100 页出、平板扫描;手动双面 | 一体式黑色硒鼓;随机约 1,500 页 | 仅无线且 2.4 GHz 能满足时再比价。优点是 AirPrint/Mopria 明确、首张页快;不符合“网口或双频”优先条件。 |
| **Canon iC MF232w** | Ethernet、2.4 GHz Wi-Fi、AirPrint、网络/移动扫描 | 23 ppm、250 页纸盒、平板扫描;无自动双面 | CRG337 一体式硒鼓 2,400 页 | 纸盒需求确实较大才考虑。官方建议价较高,且产品规格呈现的是较老的 IPv4 / 2.4 GHz 组合,不是本需求下的优先解。 |
| **Canon iC MF272dw** | Ethernet、2.4 GHz Wi-Fi、AirPrint/Mopria | 29 ppm、150 页纸盒、平板扫描、自动双面打印 | CRG071700 页随机、1,200/2,500 页商品硒鼓 | 功能不错但属于为自动双面打印升级;官方建议价 ¥3,838,不适合低量、单面为主时以性价比为目标的采购。 |
Brother 规格与耗材页数以官方参数表为准;Pantum 的接口、PD-219 和建议月印量 2502,000 页见[官方产品页](https://www.pantum.cn/product-center/1487019260672548865.html)。HP 的 22 ppm、150 页、仅 Wi-Fi/USB、手动双面和一年保修见[中国官方规格](https://support.hp.com/cn-zh/product/product-specs/hp/2101513893)。Canon MF232w 的 23 ppm、250 页、CRG337、IPv4 和接口见[官方规格](https://www.canon.com.cn/product/icmf232w/spec.html)MF272dw 的 29 ppm、自动双面、接口及 CRG071 页数见[官方规格](https://www.canon.com.cn/product/icmf272dw/spec.html)。
## 耗材与锁定:应怎样理解
**不要只用“每页成本”决定低量用户。** 一年只打印几十到几百页时,机器差价、过期/存放不当的耗材风险和购买便利性,通常超过高容量粉盒带来的单位页优势。页产量也是 ISO 覆盖率下的额定值,不等于实际能稳定打印的页数。
- **传统耗材(Brother L1638W/L1848W**:粉盒和硒鼓分开,硒鼓寿命远高于单盒粉量;这是长期低量使用中最可预测的结构。原装 TN118 / DR118 的料号和页数已由 Brother 公布。第三方粉盒或灌粉可以降低成本,但不属于厂商性能/保修承诺;低量用户省下的钱很有限,反而更容易把故障归因变复杂。建议首个生命周期使用原装或可靠授权渠道耗材。
- **云充(DCP-11W**:这里的锁定不是“第三方粉盒风险”,而是服务依赖:打印资格、套餐和耗材供给都依赖绑定的微信/官方流程。购买前应把预计三年页数代入套餐,确认账号更换、迁移、停服或转让场景的处理规则;并接受双面一张按两页计。
- **一体式硒鼓(Pantum、HP、Canon**:换粉即换鼓,维护动作简单;缺点是无法像鼓粉分离机那样只更换粉盒。不要据此推断“第三方一定不能用”或“必然会被固件锁死”——厂商公开资料通常只承诺原装耗材效果/保修,兼容耗材的芯片兼容性、质量和售后由销售方承担,应按批次验证。
## 最终推荐与购买动作
1. **默认买 Brother DCP-L1638W 或 DCP-L1848W**:选到手价更低、可开票、有本地退换/保修的那个;功能层面无需为 1848 付溢价。
2. 若二者断货或溢价过大,**Pantum M6509NW** 是功能不降级的对照品;要求 5 GHz Wi-Fi 时排除它。
3. 若只用手机/2.4 GHz Wi-Fi,且 HP 的即时价格有明显优势,才纳入 **HP 1188w**;它没有网口,不能接入现有有线网络。
4. **不要因为“最新”买 DCP-11W**,除非云充模式本身是主动选择。对低量家庭用户,耗材自主权通常比三年保修更重要。
到货后先完成一次有线或基础 Wi-Fi 配网、AirPrint/Windows/macOS 实测、扫描为 PDF、睡眠唤醒和一张双面手动测试;保留试机页与发票。将设备放在受信任 LAN;如果启用 Wi-Fi Direct,设置强口令,平时不需要则关闭。
## 调研边界
本表只比较中国市场仍可由厂商官方页面/支持页核实的代表 SKU,价格、实际库存和促销会实时变化,未把电商标价写入结论。所谓“最新”以 Brother 中国目录/公告为准,而非“功能最强”或“最适合”。资料核查日期:2026-08-11。
@@ -0,0 +1,262 @@
# TL-SE5420 官方安装手册(整理版)
> **来源:** TP-LINK《TL-SE5420 V1.0 安装手册 1.0.2》(PDF),已整理为 Markdown。
> 官方 PDF<https://service.tp-link.com.cn/download/202310/TL-SE5420%20V1.0安装手册%201.0.2.pdf>
> 配套实施文档:[../lan-se5420-deployment-guide.md](../lan-se5420-deployment-guide.md)
> 说明:PDF 中示意图已省略(仅保留文字);正文以官方 PDF 为准。
---
## 声明
- Copyright © 2023 普联技术有限公司,版权所有,保留所有权利。
- 产品规格和资讯仅供参考,如有内容更新,恕不另行通知。本手册仅作为使用指导,所作陈述均不构成任何形式的担保。
## 相关文档
| 文档名称 | 用途 |
|---|---|
| 《安装手册》 | 交换机的安装过程、启动以及常见故障处理 |
| 《用户手册》 | 交换机的各个功能应用以及常见功能使用问题处理 |
| 《命令行手册》 | 交换机 CLICommand Line Interface,命令行界面)参考信息 |
| 《防雷安装手册》 | 安装中如何做好防雷措施以避免设备雷击损坏 |
技术支持:服务热线 `400-8863-400` · 邮箱 `fae@tp-link.com.cn` · 微信公众号「TP-LINK商用网络」/「TP-LINK商云APP」
## 阅读对象与约定
- 适合阅读对象:网络工程师、网络管理员。
- 本手册如无特别说明,均以 **TL-SE5420** 机型为例介绍,所指交换机为 2.5G 三层网管交换机。
- 产品规格标贴位于设备底部,扫描二维码可查看安规符号释义。
---
## 第 1 章 产品介绍
### 1.1 产品简介
2.5G 三层网管交换机,支持静态路由和动态路由 RIP 协议,提供完备的安全防护机制、完善的 QoS 策略和丰富实用的 VLAN 功能。适用于中小型企业网、校园网络、酒店、网吧等使用场景。
### 1.2 产品外观
**前面板组成:** 1 模式切换按键 · 2 指示灯 · 3 1 个 Type-C Console 端口 · 4 16 个 2.5Gbps RJ45 端口 · 5 4 个 SFP+ 端口 · 6 1 个 USB 端口
**模式切换按键:** 用于切换指示灯的指示状态,支持「端口模式」和「堆叠模式」切换。任意模式下超过 45s 不按动按钮,自动恢复为端口模式。(模式切换功能软件后续升级支持,以实际软件功能为准。)
**指示灯状态:**
| 指示灯 | 名称 | 工作状态 | 工作说明 |
|---|---|---|---|
| PWR | 电源指示灯 | 常亮 / 熄灭 / 闪烁 | 供电正常 / 未通电或供电异常 / 供电异常 |
| SYS | 系统指示灯 | 绿色闪烁 / 红色闪烁 | 系统正常 / 系统异常 |
| Master | 堆叠指示灯 | 绿色常亮 / 黄色常亮 / 熄灭 | 堆叠主设备 / 堆叠错误 / 非主设备 |
| Stack | 模式指示灯 | 绿色常亮 / 熄灭 | 端口指示灯工作在堆叠模式 / 其它模式 |
| 1-16(端口模式) | 端口指示灯 | 绿色常亮 / 黄色常亮 / 闪烁 / 熄灭 | 2.5Gbps / 10/100/1000Mbps / 传输中 / 未连接 |
| 17-20(端口模式) | 端口指示灯 | 绿色常亮 / 黄色常亮 / 闪烁 / 熄灭 | 10Gbps / 2.5Gbps 或 1000Mbps / 传输中 / 未连接 |
| USB | 端口指示灯 | 绿色常亮 / 熄灭 | USB 连接正常 / 未连接或异常 |
**Console 端口:** 用于和计算机或其他终端的串口相连以管理或配置交换机。TL-SE5420 提供 **1 个 Type-C Console 端口**
**2.5Gbps RJ45 端口:** 16 个,支持 10M/100M/1000M/2.5Gbps 速率,每个端口对应一个端口指示灯。
**10Gbps SFP+ 端口:** 支持接入 1Gbps/2.5Gbps SFP 光/电模块或 10Gbps SFP+ 光/电模块,每个端口对应 1 个 Link/Act 指示灯。
**USB 端口:** 标准 USB2.0480Mbps。可和交换机 Flash 文件系统交互(上传/下载应用程序、配置文件)。支持**交换机 U 盘开局**(详见《用户手册》)。
> 注意:不同厂商 USB 设备的兼容性和驱动存在差异,TP-LINK 不保证所有 USB 设备都能正常使用;如不能使用不属于交换机故障。
**后面板组成:** 1 防雷接线柱 · 2 电源插座(三相,220V~50Hz 交流电源)。
> 注意:请使用原装电源线;请通过电源线插头连接到有接地连接的输出插座;请远离水、火、潮湿或炎热环境;不要自行拆卸、修理、修改设备或更换电池(有爆炸危险)。
---
## 第 2 章 产品安装
### 2.1 物品清单
- 交换机 ×1 · 电源线 ×1 · 机架 L 型支架 ×2 · 安装手册 ×1 · 接地线 ×1 · 其他配件
### 2.2 安装注意事项
**安装安全:**
- 安装过程中电源保持关闭,佩戴防静电手环并保证接地良好;
- 确认供电电压与交换机标示电压相符;
- 通电前确认不会引起电源电路超负荷;
- 工作时不要打开外壳;
- 清洁前先拔电源插头,勿用湿润面料擦拭或液体清洗;
- 保持交换机底部朝下水平放置。
**环境要求:**
| 环境描述 | 温度 | 相对湿度 |
|---|---|---|
| 工作环境 | 0℃ ~ 40℃ | 10% ~ 90%RH 无凝结 |
| 存储环境 | -40℃ ~ 70℃ | 5% ~ 90%RH 无凝结 |
- 海拔:仅适用于 2000m 以下地区。
- 室内防尘、防电磁干扰、防雷:确认设备与大地接地良好;远离高频大功率设备;室外布线建议使用信号防雷器。
- 安装台:机架或工作台需承重至少 **5.5Kg**;供电插座与交换机距离不超过 1.5 米。
### 2.3 安装工具准备
十字螺丝刀 · 防静电手环 · 网线、光纤
### 2.4 产品安装
**桌面安装:** 底部朝上放置于稳定桌面 → 粘贴 4 个脚垫到机壳底部四角凹槽 → 翻转放平。机壳周围预留 5~10cm 空隙确保散热。
**19 英寸机架安装:** 检查机架接地与稳定性 → 两侧安装 L 型角铁并固定 → 安放到机架内由托架支撑 → 螺钉固定 L 型角铁到机架导槽。机架内一般由下至上安装。
---
## 第 3 章 硬件连接
### 3.1 连接 RJ45 端口
通过 RJ45 端口连接交换机和对端设备。
### 3.2 连接 SFP+ 端口
连接 SFP/SFP+ 光/电模块或 SFP+ 电缆。**SFP+ 端口默认支持 10Gbps;若使用千兆 SFP 光模块,需先将 SFP+ 端口速率设置为 1000Mbps。** 使用 SFP+ 电缆时确保最小弯曲半径符合要求。
### 3.3 连接 Console 端口
Type-C Console 端口,连接后可通过命令行管理交换机(登录方法见 4.2)。
### 3.4 连接电源线
接入 220V~50Hz 交流电源;确认供电电源与大地良好接触,确认电源开关位置以便及时断电。
### 3.5 设备初始化
接通电源后自动初始化:
- PWR 指示灯亮 = 电源连接正确,灭 = 供电异常;
- SYS 指示灯闪烁 = 系统初始化成功,灭或常亮 = 初始化失败;
- Link/Act 指示灯会闪烁一下后熄灭,指示系统已复位。
### 3.6 安装后检查
- 周围散热空间与空气流通;
- 电源插座供电符合规格;
- 电源、交换机、机架正确接地;
- 与其它网络设备连接正常。
---
## 第 4 章 配置指南
### 4.1 WEB 登录
> **TL-SE5420 业务口固定 IP 为 `10.18.18.251`**;电脑连接业务口时需设 IP `10.18.18.x`(x 为 2-250 任意值),子网掩码 `255.255.255.0`
1. 普通 PC 用网线连接交换机任意一个 RJ45 业务端口;
2. 浏览器输入 `http://10.18.18.251` 回车;
3. **设置用户名和密码**,点击<确认>登录 WEB 管理界面;
4. 首页左上角商标可访问 TP-LINK 官网;左侧主菜单进行功能配置。
### 4.2 本地登录(Console
1. 连接 Type-C Console 口到计算机;
2. 安装「**TP-LINK USB 串口驱动程序**」(官网下载或随机光盘);
3. 终端仿真程序参数:
- 波特率 **38400bps** · 数据位 **8** · 奇偶校验 **无** · 停止位 **1** · 数据流控制 **无**
4. 输入回车,出现 **`TL-SE5420>`** 提示符即登录成功。
### 4.3 远程登录(Telnet
1. cmd 输入 `telnet 10.18.18.251`
2. 输入登录用户名和密码;
3. 输入 `enable` 回车进入特权模式。
### 4.4 云管理
1. Web 界面 →<云管理> → 开启<全局开关>
2. 访问 `https://smbcloud.tp-link.com.cn/login.html`,登录 TP-LINK ID
3. 项目集中管理 → 设备列表 → 添加设备(输入 MAC、名称、用户名、密码、分组;MAC 可扫设备底部二维码获取);
4. 设备信息页找到对应条目 →<远程配置> → 云平台远程管理。
---
## 附录 A 常见故障处理
**问题1:忘记用户名和密码(恢复出厂设置)**
1. 串口连 Console 口,打开终端;
2. 交换机断电重启,终端出现 **`Press CTRL-B to enter the bootUtil`** 时按 `Ctrl+B` 进入 bootUtil 菜单;
3. 输入 **`2`** 进行软件复位,恢复到出厂默认设置,登录用户名和密码均为 **admin**
**问题2:电源指示灯显示不正常**
- 电源线连接是否正确;供电电源是否匹配(100-240V~ 50/60Hz)。
**问题3:无法登录 WEB 管理界面**
- 是否达到 HTTP 管理人数上限;
- 端口线缆连接与端口状态;
- 电脑 IP 与交换机 IP 是否同网段;
- `ping 127.0.0.1`(本机 TCP/IP)与 `ping 10.18.18.251`(交换机)。
**问题4:断电后配置消失**
- 配置实时生效,但**断电前必须点保存配置**。
---
## 附录 B 技术参数规格
| 项 | 值 |
|---|---|
| 产品型号 | TL-SE5420 |
| 技术标准 | IEEE 802.3 / 802.3i / 802.3u / 802.3ab / 802.3z / 802.3bz(2.5G/5G) / 802.3ae(10G) / 802.3ad(链路聚合) / 802.3x(流控) / 802.1p(QoS) / 802.1q(VLAN) / 802.1x / 802.1d(STP) / 802.1s(MSTP) / 802.1w(RSTP) / 802.1ab(LLDP) |
| 端口 | 16× 10/100/1000/2.5G RJ45 + 4× 10G SFP+ |
| 传输方式 | 存储转发 |
| 数据传输速率 | 10M半双工/20M全双工 · 100M半/200M全 · 千兆2000M全 · 2.5G 5000M全 · 10G 20000M全 |
| 包转发速率 | 10BASE-T:14881pps · 100BASE-TX:148810pps · 1000BASE:1488095pps · 2.5GBASE-T:3720238pps · 10GBASE:14880952pps(每端口) |
| 电源输入 | 220V~50Hz |
| 工作温度 | 0℃~40℃ |
| 存储温度 | -40℃~70℃ |
| 工作湿度 | 10%~90%RH 无凝结 |
| 存储湿度 | 5%~90%RH 无凝结 |
网络介质(部分):10BASE-T 3类线≤100m · 100BASE-TX 5类线≤100m · 1000BASE-T 超5类≤100m · 2.5GBASE-T 超5类≤100m · 10GBASE-SR OM1/OM2/OM3 MMF 2m~300m · 10GBASE-LR SMF 2m~10000m。
---
## 附录 C 连接 SFP+ 端口补充说明
- **光纤**:单模(芯径 9/10μm,模间色散小,适合远程);多模(芯径 50/62.5μm,传输距离较近,一般几公里)。
- **光模块**:按性质分单模/多模;按速率分百兆/千兆/万兆。SFP/SFP+ 端口兼容单模、多模光模块,推荐使用 TP-LINK 光模块。
**TP-LINK 光模块参考表:**
| 模块类型 | 产品型号 | 速率 | 距离 | 传输介质 |
|---|---|---|---|---|
| 光模块 | TL-SM512LS-10KM | 10Gbps | 10km | SMF 双纤 LC |
| 光模块 | TL-SM512LM-300m | 10Gbps | 300m | OM3 MMF 双纤 LC |
| 光模块 | TL-SM411LSA/B-5KM | 2.5Gbps | 5km | SMF 单纤 LC |
| 光模块 | TL-SM411LSA/B-500m | 2.5Gbps | 500m | SMF 单纤 LC |
| 光模块 | TL-SM411SSA/B-500m | 2.5Gbps | 500m | SMF 单纤 SC |
| 光模块 | TL-SM412LS-5KM | 2.5Gbps | 5km | SMF 双纤 LC |
| 光模块 | TL-SM311LSA/B-20KM | 1Gbps | 20km | SMF 单纤 LC |
| 光模块 | TL-SM311LSA/B-2KM | 1Gbps | 2km | SMF 单纤 LC |
| 光模块 | TL-SM311SSA/B-2KM | 1Gbps | 2km | SMF 单纤 SC |
| 光模块 | TL-SM312LS-40KM | 1Gbps | 40km | SMF 双纤 LC |
| 光模块 | TL-SM312LS-20KM | 1Gbps | 20km | SMF 双纤 LC |
| 光模块 | TL-SM312LS-20KM工业级 | 1Gbps | 20km | SMF 双纤 LC |
| 光模块 | TL-SM312LM-550m | 1Gbps | 550m | OM2 MMF 双纤 LC |
| 电口模块 | TL-SM510U | 10Gbps | 30m | 超六类网线 |
| 电口模块 | TL-SM410U | 2.5Gbps | 100m | 超五类网线 |
| 电口模块 | TL-SM310U | 1Gbps | 100m | 超五类网线 |
---
## 附录 D 产品保修卡(要点)
- 免费保修范围外的情况:超出保换/保修期限、未按要求安装使用、擅自改动设置或拆机、意外或人为损坏、运输损坏、不可抗力等。
- 技术支持热线:`400-8863-400` · 邮箱 `fae@tp-link.com.cn` · 官网 `www.tp-link.com.cn`
- **售后服务承诺:三年保修。**
- 外置电源保换 1 年,电池 6 个月,鼠标 3 个月;无线外置天线及光纤头 3 个月。
- 购机 15 天内出现性能问题且外观无划伤可换新。
---
## 附录 E 有毒有害物质含量声明
(详见官方 PDF 附表。)
---
公司地址:深圳市南山区深南路科技园工业厂房 24 栋南段 1 层、3-5 层、28 栋北段 1-4 层
公司网址:<http://www.tp-link.com.cn> · 技术支持 E-mail`smb@tp-link.com.cn` · 技术支持热线:`400-8863-400`
文档版本:REV1.0.2
@@ -0,0 +1,74 @@
# SE5420 实施评审主张核实(2026-08-10)
> **核对基准(历史快照):** 本文于 2026-08-10 针对 [lan-se5420-deployment-guide.md](lan-se5420-deployment-guide.md) 的**评审前版本**`35577d0`)撰写。该指南自 `ffb37a9`"finalize SE5420 deployment guide per review")起已按本评审修订,当前 `origin/main` 章节已重组:旧 §3.3 → §4.3、旧 §6(gfw)→ §11、旧 §7(SSID)→ §12、旧 §9(验收/IPv6)→ §13 + §11.4。文末「当前指南处理情况」列出各主张的现行状态;实施以部署指南现行为准。
**范围。** 本文核对对 `lan-se5420-deployment-guide.md` 的评审意见。结论分为
“已证实”(规范/一手资料直接支持)、“基本证实”(架构推论成立但仍须读取现场配置)和
“需现场核实”(不能仅由文档或产品手册断言)。这不是实施变更,也不替代维护窗前的
`uci show firewall`、交换机当前 VLAN 表和 PVE bridge 配置检查。
## 核实结论
| 评审主张 | 结论 | 依据与限定 |
| --- | --- | --- |
| `firewall.ubunt_upg.masq=1` 是错误方向,应在实际出站的 `wan` zone 做 IPv4 NAT | **已证实** | OpenWrt 明确规定 masquerade 是**按出站 zone/interface**控制;`masq` 通常在 `wan`。因此,对 `ubunt_upg → wan` 流量把 `masq` 放在源 zone 不是该需求的正确 zone 语义。若 `wan` 已 masq,不应重复开启;也可用 `masq_src` 只限 `192.168.10.0/24`。见 [OpenWrt firewall configuration](https://openwrt.org/docs/guide-user/firewall/firewall_configuration) 和 [fw4 masq 测试](https://lxr.openwrt.org/source/firewall4/tests/02_zones/02_masq)。 |
| `ubunt_upg → wan` 允许所有经 gfw `wan` 可路由的目的地,不等于只上互联网 | **已证实** | `forwarding``src`/`dest` 是 zone-to-zone 单向许可,未按“Internet”语义区分目标 IP;规则可用 `dest_ip` 限制。故若 gfw 的 `wan` 接在 LAN66 且 ER-X 可路由 LAN55,评审所列 LAN66/LAN55 风险成立。最终可达网段仍须以 gfw 路由表、ER-X 路由/防火墙现场检查为准。见 [OpenWrt forwarding/rule 参考](https://openwrt.org/docs/guide-user/firewall/firewall_configuration)。 |
| 不应把 `ubunt_upg.forward` 改为 `ACCEPT``forward_policy` 不是必要的标准 zone 选项;匿名 `uci add` 不可重复执行 | **基本证实** | OpenWrt zone 的标准项是 `forward`forwarding 是独立 section,参考页未定义 `forward_policy`。单独的具名 forwarding 足以允许跨 zone 路径,因此保持 zone 内 `forward=REJECT` 是较小权限配置。匿名 section 每执行一次都会新增一节,这是 UCI 的操作语义;实施应先读现场配置并使用具名 section。 |
| VLAN10 必须有显式 IPv6 策略,否则可能绕过仅 IPv4 的 NAT/隔离 | **已证实** | fw4 将 `masq`IPv4)和 `masq6`IPv6)分开;forwarding 默认 family 是 `any`。仅写 IPv4 DHCP/NAT/地址规则不能表达 VLAN10 的 IPv6 RA、DHCPv6、路由和过滤策略。是否已经存在可用 IPv6 前缀、以及 OpenClash 是否接管 IPv6,必须现场验证。见 [OpenWrt firewall configuration](https://openwrt.org/docs/guide-user/firewall/firewall_configuration)。 |
| 管理 SVI + 默认路由与“不开 SVI/静态路由/一切 L3”矛盾 | **已证实** | 指南(历史版)§3.3 同时要求 VLAN66 `192.168.66.253/24` 与默认路由,又要求不开 SVI/静态路由。TL-SE5420 官方称其为三层交换机,支持静态路由、RIP、DHCP server/relay。准确目标应是:仅保留 VLAN66 管理 L3 interface/默认网关,不给 VLAN55/10 建 L3 interface,且禁用不需要的 L3 服务和跨 VLAN routing。见 [TL-SE5420 官方页](https://www.tp-link.com.cn/product_2899.html?v=specification) 与 [官方安装手册](https://service.tp-link.com.cn/download/202310/TL-SE5420%20V1.0%E5%AE%89%E8%A3%85%E6%89%8B%E5%86%8C%201.0.2.pdf)。 |
| 必须明确移除 VLAN1 成员,PVID 变更本身不等于 access-port VLAN membership | **已证实** | PVID/native VLAN 只处理进入端口的未标记帧;access/trunk 的允许 VLAN 列表是独立概念。指南(历史版)§3.3 只列 VLAN66/55 member 和 PVID,未写移除 VLAN1 或 ingress filtering。验收应检查 VLAN1 member、VLAN1 管理 IP、端口允许 VLAN 和 tagged-frame ingress policy。见 [Ubiquiti 对 native/tagged/access/trunk 的定义](https://help.ui.com/hc/en-us/articles/26136855808919-Switch-Port-VLAN-Assignment-Trunk-Access-Ports)(术语与 802.1Q 语义)以及 [Linux bridge VLAN 配置示例](https://www.kernel.org/doc/html/v5.19/networking/dsa/b53.html)(显式 `bridge vlan del ... vid 1`)。TL-SE5420 具体 GUI/CLI 行为仍以其固件手册核验。 |
| NAS 不应在未先完成双端 LACP 时同时接两口;LACP 不使单 TCP 流自动达到 5G | **基本证实** | 这是标准二层环路/聚合变更控制结论:没有已协商的 LAG 时,两条同 VLAN 并行链路会构成潜在环路;STP 只能作为保护而非实施方法。官方产品页列出 LACP 相关资料,但本次未取得 TL-SE5420/TrueNAS 对端的精确配置与当前 NAS 连接状态,故“必然环路/双 IP”不能在桌面审阅中断言。单连接吞吐受链路散列限制是 802.3ad 的常见实现特性,应以 NAS 与交换机的 hash policy 和 `iperf3` 实测验收。 |
| 非 VLAN-aware 的 PVE `vmbr0` 不提供 VLAN10 的端口级隔离 | **已证实** | PVE 将 bridge 描述为虚拟交换机;VLAN-aware mode 才能给 guest NIC 赋 VLAN tag,或显式 trunk。Linux 内核说明:`vlan_filtering=0` 时 bridge 不考虑 VLAN tag,且默认关闭;开启后才按 MAC **和 VLAN tag**转发及进行严格 VID 检查。因此“共享非 VLAN-aware bridge 可让可控 guest 主动消费 VLAN10,不能作为严格隔离边界”成立。不能仅凭该结论断言每个 guest 必定收到每个单播帧:未知单播/广播会泛洪,已学习的单播会按 FDB 转发。见 [PVE 网络配置](https://pve.proxmox.com/wiki/Network_Configuration) 和 [Linux bridge 文档](https://docs.kernel.org/networking/switchdev.html)。 |
| AP VLAN10 tagged frame 在一个普通 untagged LAN66 access path 上会“自动去 tag 并泄漏到 LAN66” | **不成立/需改写** | 802.1Q 的 native VLAN 是对**未标记**流量的 VLANtagged VLAN 需被显式允许于 trunk。因此通常的正确表述是:若上游不允许 VLAN10 tagAP 到 VLAN10 网关/DHCP 的路径不存在,SSID 会成为不可用入口。实际设备的端口模式(包括是否错误地配置为 all/trunk、是否接受 tagged ingress)须现场查看,不能泛称必然去标签。见 [Ubiquiti VLAN 端口定义](https://help.ui.com/hc/en-us/articles/26136855808919-Switch-Port-VLAN-Assignment-Trunk-Access-Ports) 和 [Ubiquiti VLAN troubleshooting](https://help.ui.com/hc/en-us/articles/9592924981911-Virtual-Network-VLAN-Troubleshooting)。 |
| 仅保留 SSH 会话不是移动 PVE/ER-X 物理上联时的真正带外回滚路径;应全程保持 SE5420 Console | **已证实** | 这是直接的操作依赖判断:TCP SSH 的承载链路被拔除时会断,不能证明回滚可达。TL-SE5420 官方安装手册确认该机有 Type-C Console,且本仓库指南本身也把恢复出厂流程建立在 Console 上。故应在迁移前接通 Console、标注旧/新端口、逐根迁移并用 MAC 表与链路/错误计数验证。见 [官方安装手册](https://service.tp-link.com.cn/download/202310/TL-SE5420%20V1.0%E5%AE%89%E8%A3%85%E6%89%8B%E5%86%8C%201.0.2.pdf)。 |
| “所有设备均不得直连 ER-X”不是避免环路的必要条件 | **已证实** | 环路取决于同一 L2 广播域存在多条并行二层路径,不取决于是否还有一个独立终端直接接 ER-X。应禁止的是一个下级交换机/桥接主机同时形成平行路径。此项仍需以 ER-X switch0 VLAN/bridge 现场配置和实际接线图确认。 |
| 性能不应承诺全面 2.5G;同 VLAN 才可能在 SE5420 本地交换超 1G,跨 55/66 与 Internet 受 ER-X/宽带限制 | **已证实** | TL-SE5420 的 2.5G 端口仅提高经其本地二层转发的链路上限;跨子网必须由网关路由,Internet 另受 WAN/PPPoE 约束。产品页确认 16×2.5G + 4×10G SFP+,但 ER-X、NAS、PC、AP 的实际协商速率和 NIC/布线能力必须由 `ethtool`/端口状态及 `iperf3` 验证。见 [TL-SE5420 官方规格](https://www.tp-link.com.cn/product_2899.html?v=specification)。 |
## 已核对的文档内事实
现行指南的**历史版本**`35577d0`)确实包含评审指出的关键文字:旧 §3.3 的管理 IP/默认路由与“不开 SVI/静态路由”;旧 §6 的 `ubunt_upg.masq``forward=ACCEPT``forward_policy`、匿名 forwarding;旧 §7 只验“不可达 LAN66”;旧 §9 对新增 VLAN10 写“IPv6 行为与升级前一致”。因此上述评审不是对未出现内容的假设。
但这些内容在 `ffb37a9` 起的修订中已被修正或重组:`ubunt_upg.masq``forward_policy` 已删除,gfw 防火墙改为 §11(§11.3 第 2 步明确“不要给 `ubunt_upg` zone 加 masq”);VLAN10 IPv6 在 §11.4 显式写为“本阶段不提供”;SVI/L3 边界在 §4.3 第 16 步单列“L3 明确边界检查”。本文按历史快照保留评审结论,读者应以现行部署指南为准。
本仓库的 `hosts/gfw.windy.lan.md` 还记录 gfw 的 `eth0` 在 LAN66、`eth1` 在 LAN55,故 `ubunt_upg → wan` 的隔离结论应在执行前以当前 `ip route``uci show firewall``nft list ruleset` 复核,而不能从方案文字直接把规则写死。
## gfw 现场只读复核(2026-08-10
已通过 `ssh -4 root@192.168.66.1` 仅读取配置和运行规则,未修改设备。该结果会改变
评审中两项“当前状态”的表述:
| 现场事实 | 对评审的影响 |
| --- | --- |
| `wan` zone 已有 `masq='1'`;现有配置另有具名 `ubunt_upg_nat`,运行时渲染为 `oifname "eth0"` 且只匹配 `ip saddr 192.168.10.0/24 masquerade`。 | “必须在 wan 开 masq”的**方向原则**正确,但“当前无 masq”不正确。现有显式 SNAT 已在实际出 `eth0` 时执行;计划中再将 `masq` 加到 `ubunt_upg` 仍是多余且方向错误。 |
| 当前放行是具名 `ubunt_upg_to_lan`,不是 `ubunt_upg→wan`;其运行链先拒绝 `192.168.66.0/24`,再允许到 `lan`。gfw 的 IPv4 default route 是 `192.168.66.254`。 | 计划新增 `ubunt_upg→wan` 会是与当前设计不同、过宽的改动。现有 LAN66 阻断规则在该链中先匹配;但对经 ER-X 可达的 LAN55/其他内网仍没有显式拒绝,故隔离评审的**剩余风险成立**。应以明确内网前缀 deny + 所需外网 allow 重写,而不是加 WAN forwarding。 |
| `ubunt_upg` DHCPv6 和 RA 都是 `disabled`;运行路由表仅有各接口的 IPv6 link-local route,没有 IPv6 default route;全局 IPv6 forwarding 是 `1`。 | 评审“VLAN10 未明确 IPv6 策略”的表述对计划文本仍成立,但“IPv6 可能立即绕过”的事实判断在当前状态**未获证实**:现有 RA/DHCPv6 已关闭且无 IPv6 默认路由。实施文档仍应把这项显式写为“IPv6 不提供”,并在启用前复查。 |
| 系统是 ImmortalWrt **25.12.0**`/usr/bin/apk` 存在(apk-tools 3.0.5)。 | 评审中“ImmortalWrt 21.02.5 应使用 opkg”的版本判断错误/过时;在本机上 `apk add tcpdump` 是可用包管理器。仍应先检查软件包可用性,避免在维护文档中把两种命令并列为未经验证的替代方案。 |
这些命令输出未含凭据、令牌或私钥,故仅记录了安全相关的摘要;不将完整防火墙快照提交至仓库。
## 当前指南处理情况(2026-08-13 核对)
`origin/main``2fd354c`)逐项核对评审主张:
| 评审主张 | 现行状态 | 现行位置 |
| --- | --- | --- |
| `ubunt_upg.masq=1` 方向错误 | ✅ 已修复 | §11.3 第 2 步「不要给 `ubunt_upg` zone 加 masq」 |
| `ubunt_upg→wan` 不等于只上互联网 | ⚠️ 原则成立,指南已禁止新增宽泛 forwardingLAN55/RFC1918 显式 deny 仍为待办 | §11.1b「待补缺口」、§11.3 |
| 勿改 `forward=ACCEPT`;无 `forward_policy`;匿名 uci 不可重复 | ✅ 已修复 | §11.3 第 1 步保持 REJECT;指南已无 `forward_policy` |
| VLAN10 须显式 IPv6 策略 | ✅ 已修复 | §11.4「本阶段不提供 VLAN10 IPv6」 |
| 管理 SVI + 默认路由 vs「不开一切 L3」矛盾 | ✅ 已消解 | §4.3 第 16 步「L3 明确边界检查」 |
| 须移除 VLAN1 成员;PVID≠membership | ✅ 指南已加强;现网仍偏离(W1N-54) | §4.3 第 912 步;VLAN1 不可删说明 |
| NAS 双口未 LACP 前勿并行 | ✅ 已体现 | §7 第 7 步「仅口 8,口 12 断开」 |
| 非 VLAN-aware PVE bridge 不能作隔离边界 | ✅ 已体现 | §9.2 要求 VLAN-aware + `bridge-vids` |
| AP tagged 帧在 access 口自动去 tag | ✅ 本文已纠正(不成立) | — |
| SSH 非真正带外;须 Console | ✅ 已体现 | 开头第 2 条、§4.1、§16 |
| 「所有设备不得直连 ER-X」非必要 | ✅ 已体现 | 全程三条第 1 条、§7 |
| 不应承诺全面 2.5G | ✅ 已体现 | §8 第 7 条、§14 |
## 实施前的最低限度现场证据
1. gfw:保存并审阅 `uci show firewall``ip route``ip -6 route``nft list ruleset`;确认 wan 的 masq 与所有 WAN→内网、VLAN10→内网匹配次序。
2. SE5420 Console:导出/截图 VLAN1、55、66 member 和 PVID/ingress-filter 状态;确认唯一管理 L3 interface 和路由/relay/DHCP 状态。
3. PVE:记录 `/etc/network/interfaces`、VM NIC VLAN tags 和 `bridge vlan show`,再决定是否把 VLAN-aware 改造另开窗口。
4. AP:从实际设备 `info` 或控制器记录确认 Inform URL(本仓库目前记录 `http://192.168.66.46:9080/inform`),并验证 VLAN10 tag 只经 U6/PVE trunk。
5. NAS:单网口稳定后,另窗配置并验证两端 LACP,第二根线最后插入;用多流及单流 `iperf3` 分开验收。
@@ -0,0 +1,158 @@
# 希力威视 SR-S25G3218F 调查(2026-08-08
**结论:** 若需求是大量 2.5G 终端、少量 10G 光上联,`SR-S25G3218F` 的端口密度
更合适;厂商已公开该型号的固件页,但仍缺少完整规格书、管理手册与兼容矩阵。若需求是 8 条全部可协商
1/2.5/5/10G 的铜缆链路,且希望有可查的 L3 能力和固件入口,兮克
`SKS8300-8T` 是资料更完整、风险更低的选择;它的代价是主动风扇、外置 12 V 电源、
无 SFP+ 光口,且仍不应把消费级/SMB 设备当作安全边界或唯一核心。两者都应在
到货可退换期内完成实机验收。
本页为采购前资料调查,不代表已接入本地网络;检索日期为 2026-08-08。
## 已能核实的事项
| 项目 | 结论与证据强度 |
|---|---|
| 型号/端口 | 京东的希力威视商品标题称该 SKU 为 `SR-S25G3218F`,有 16 个 2.5G 电口和 2 个万兆光口,并宣传 VLAN、端口隔离与 LACP。该店铺被厂商官网列为可购买的「京东旗舰店」,因此可作为销售规格,非技术手册。[京东商品页](https://item.jd.com/100165071727.html)[厂商购买渠道说明](https://en.sirivision.com/contactus/) |
| 厂商身份 | 厂商官网为 Shenzhen/Guangdong Sirivision Communication;英文官网说明其自 2016 年起提供接入、汇聚和核心交换机方案。[厂商首页](https://en.sirivision.com/) |
| 公开的二手厂家资料 | 同一制造商名义的 Alibaba 出口页将精确型号写成 `16*2.5G+2*10G``120Gbps`,并列出 QoS、VLAN、SNMP、L3 与 stackable。这是制造商发布在平台上的销售资料,**不是**官网数据表;其中后五项不能据此视为已验收的功能承诺。[制造商平台页](https://www.alibaba.com/pla/SR-S25G3218F-QoS-Managed-SFP-Switch-1625G210G_1601494946214.html) |
| 固件入口 | 厂商已发布此精确型号的[固件页](https://www.sirivision.com/sr-s25g3218f%E5%9B%BA%E4%BB%B6/)。公开变更记录提到“光口自适应”和“增加 DAC 配置”;这证明厂商维护过该路径,**不**代表任意 SFP+/DAC/铜模块均兼容。 |
| 本机可计算的带宽 | 端口线速相加为单向 60 Gb/s16 × 2.5 + 2 × 10);若厂商所谓 `120Gbps` 是全双工交换容量,则数学上吻合。它**不**证明缓冲、PPS、表项规模或实际无阻塞性能。 |
## 网管/L2/L3 能力边界
京东标题足以支持把 VLAN、端口隔离、LACP 作为「卖家声称提供」的功能;不得由此推导出
ACL、IPv4/IPv6 静态路由、SVI 数量、DHCP relay、OSPF/RIP、VRRP、IGMP、ERPS、
802.1X、RADIUS/TACACS+、SSH/HTTPS 管理、SNMP 版本、日志/审计、配置备份或固件
安全维护一定存在。
尤其要注意:厂商官网把真正列出的 2.5G L3 产品标为
`SR-S25G3412F (8 × 2.5G + 4 × 10G SFP+)`;其 2.5G 类目只显示 7 个型号,
不含 `SR-S25G3218F`。官网也把 L2+、Web Smart、L3 分成不同产品类别。这个目录
差异**不是**证明 3218F 没有 L3,而是说明「三层」无法通过官网的精确型号文档确认。
[2.5G 产品目录](https://en.sirivision.com/product-category/products/2-5g-switches/)
[官网的 10G L3 目录](https://en.sirivision.com/product-category/products/10g-switches/10g-layer3-managed-switches/)
[官网的 L2+ 分类示例](https://en.sirivision.com/product-category/products/gigabit-switches/gigabit-layer2-managed-switches/)。
采购前请向京东/厂商索取**与机身 SKU、硬件 revision 和固件版本对应**的 PDF
数据表、管理手册和 release notes,并要求书面回答至少以下问题:
1. L3 是只有 VLAN Interface/IPv4 静态路由,还是另有 IPv6、ACL、动态路由、DHCP relay
等;每项的最大 VLAN、MAC、ARP、路由、ACL、LAG 数量分别是多少?
2. LACP 是否符合 802.3ad、一个 LAG 最多多少成员、能否跨两台设备(若销售页的
`stackable` 属实,堆叠的线缆/模块、最大成员、控制面和软件版本为何)?
3. 管理面是否支持 HTTPS/SSH、禁用 HTTP/Telnet、独立管理 VLAN、SNMPv3、syslog、NTP、
配置导出/回滚和已签名或可校验的固件;默认凭据首次登录是否强制修改?
## 供电、散热和光口:当前不能确认
针对该精确 SKU,厂商官网目录与公开搜索未找到说明书/数据表,所以以下均为**待确认,
不能猜测**
- 是否为内置 AC 电源、额定输入范围/最大功耗、是否带电源开关和接地端子;是否完全
不提供 PoE(本型号名和京东标题均未写 PoE,但这不足以替代规格书)。
- 风扇数量、常态/满载噪声、风向、环境温湿度、机架深度与安装耳;不要将「金属壳」
或产品照片等同于无风扇/静音。
- 两个槽是否均为 **10G SFP+**,是否可协商 1G SFP;支持的 SR/LR/BiDi 波长距离、
DAC/AOC 长度、第三方模块/EERPOM 兼容策略、10GBASE-T SFP+ 模块的功耗/温度限制,
以及是否支持 GPON/XPON ONU「猫棒」。
厂商确实单列「SFP Optical Modules」产品分类,但这不构成 3218F 的兼容清单。
[厂商产品导航](https://en.sirivision.com/)。购买光模块/直连线时,应要求厂商按这台
设备的硬件/固件 revision 出具兼容型号清单;没有书面清单时,先在可退换期实测两端的
链路、重启恢复、热插拔与长时间满载错误计数。
## 风险与建议验收
- **文档/生命周期风险(中到高):** 精确型号不在厂商当前官网 2.5G 目录,虽有固件下载页,
但未公开完整型号手册、明确 release notes 或兼容矩阵。官网的售后条款也要求按具体产品查询保修期,配件(含光纤头)
的保修条款与主机不同;不要把平台页的「3 年」当作中国零售 SKU 的已确认保修。
[厂商售后条款](https://en.sirivision.com/after-sale-protection/)
- **功能表述风险(高):** 页面将 L2 特性和「三层网管」并列;在命令/网页菜单、
手册和测试证明之前,将其当作 L2 VLAN/LACP 设备部署,跨 VLAN 路由仍由现有网关承担。
- **双 10G 上联约束(中):** 两个 SFP+ 可作双上联或一个二成员 LAG,但 LAG 增加的是
多流量总吞吐,单一 TCP/UDP 流通常仍受一条 10G 链路限制;上级设备也必须匹配 LACP
配置。
- **管理面风险(中到高):** 家用/低价网管设备常见明文管理、弱默认口令或不透明的固件
更新周期;采购后先置于受限管理 VLAN,改口令、升级已验证固件,且不将管理界面暴露
到 WAN/访客网。
最低验收应包括:逐口协商 100M/1G/2.5G、两只不同厂家 SFP+/DAC(仅在卖家承诺支持的
范围内)、VLAN trunk/access/PVID、STP/环路保护、LACP 故障切换、端口隔离、满载
双向 iperf3 与错误计数、冷启动后的配置保留,以及管理面的 HTTPS/SSH/SNMPv3/配置备份。
如无法提供与型号匹配的正式资料或其中任一关键项失败,应在退换期内退货,并选择公开
数据表、固件与兼容矩阵更完整的型号。
## 备选:兮克 SKS8300-8T 对比
### 已核实的厂商规格
兮克官网的精确型号页明确将 `SKS8300-8T` 定位为三层管理型 10G 全电口交换机,并列出:
- 8 × 1/2.5/5/10GBASE-T RJ45160 Gb/s 交换容量、119.05 Mpps、12 Mbit 缓存、
16K MAC、12 KB 巨帧、512 MB DRAM、32 MB Flash,尺寸 207 × 136 × 35 mm
- QoS、ACL、IP+MAC+端口绑定、流分类/优先级标记、多端口镜像、静态/灵活 QinQ、
sFlow,以及「基于策略的 IPv4/IPv6 单播路由」。
这些是厂商能力声明,并非对每一种路由协议或表项上限的承诺;但相对 3218F 的仅有
销售标题,它给出了精确型号、转发性能和 L3 范围。[兮克 SKS8300-8T
产品页](https://seekswan.com/user/custom-pages/SKS8300-8T.html)
独立的 OpenWrt 设备资料将其识别为 Realtek RTL9303、512 MB RAM,记录了原厂固件
下载入口和串口/TFTP 恢复路径;其硬件数据页列为 12 V / 4 A。这支持「可恢复、可替换
系统」的可操作性,但**不是**兮克对原厂功能的支持承诺。
[OpenWrt 设备页](https://openwrt.org/toh/xikestor/sks8300-8t)
[OpenWrt 硬件数据](https://openwrt.org/toh/hwdata/xikestor/xikestor_sks8300-8t)。
### 能力、物理与运维比较
| 维度 | 希力威视 SR-S25G3218F | 兮克 SKS8300-8T |
|---|---|---|
| 接口/典型用途 | 16 × 2.5G 电口 + 2 × 10G SFP+(销售规格);适合很多 2.5G 终端/NAS,以 10G 光或 DAC 上联。 | 8 × 1/2.5/5/10GBASE-T;适合 10G 铜缆设备、2.5/5G 多速率 NAS/主机。没有 SFP+,光纤上联必须经媒体转换或选另一型号。 |
| 可确认的三层范围 | 仅销售/平台资料称 L3;没有精确型号官方手册,不能确认静态路由以外的功能。 | 官网明确写策略型 IPv4/IPv6 单播路由、ACL/QoS/sFlow/QinQ;动态路由、VRRP、IPv6 ACL/SNMP/认证等仍须按当前固件手册确认。 |
| 冗余/二层 | 卖家声称 VLAN、端口隔离、LACP;STP/环网的实现与规格未知。 | 官网声明 L3 和多项转发特性,但未在产品页给出 STP/LACP/ERPS 的精确限制;购买前仍索取手册。 |
| 散热/噪声 | 无可核实的精确型号风扇、噪声、功耗或风向数据。 | 独立手册镜像和产品图均称智能温控风扇,但厂商产品页未给 dBA;应按「有风扇、可能听得见」规划,不能承诺静音。 |
| 供电 | 未找到精确型号官方输入/功耗资料。 | OpenWrt 硬件数据记录 12 V / 4 A;确认随附电源适配器的插头、余量和地区认证。官方产品页未给满载功耗。 |
| 固件/恢复 | 有精确型号官方固件页;公开记录包含光口自适应与 DAC 配置改动,但未找到完整 release notes、恢复步骤或兼容矩阵。 | 厂商产品页提供「相关下载」区,OpenWrt 还记录原厂固件入口、RJ45 串口和 U-Boot/TFTP 恢复;原厂镜像是否签名、漏洞修复 SLA、配置回退仍未知。 |
关于 8T 的风扇、满载功耗(常见转述为 ≤36 W)、温度范围、芯片型号等,本次未找到
相应的**厂商原始数据表**;不将第三方手册转录当作已核实规格。若噪声、UPS 容量或
机柜散热是购买约束,请先让卖家提供产品铭牌照片、适配器铭牌照片、额定/实测功耗和
dBA 测试条件。
### 选择与验收建议
- 选 **3218F**:必须有 ≥12 个 2.5G 接入端、10G 光/DAC 上联、且 L3 留给现有路由器。
下单前先取得精确型号手册和 SFP+/DAC 兼容承诺;否则端口数量优势不足以抵消资料风险。
- 选 **8T**:最多 8 个设备但需要多速率 10G RJ45、明确的 IPv4/IPv6 静态/策略路由和
以后自行维护/恢复的余地。不要把其 160 Gb/s 标称交换容量误解为 8 端口同时 10G
全双工的性能保证——该标称与端口总线速数学相等,但仍须以实测和厂商 PPS/缓冲说明为准。
- 两台都不应单独承担防火墙、访客/IoT 安全隔离或 WAN 暴露;VLAN 的跨网段策略和公网
边界留在受支持的网关/防火墙上。先为管理面创建专用 VLAN,仅从管理主机访问,禁用
未使用的远程管理协议,备份配置和原厂固件后再接入生产网络。
## 低功耗核心备选(8 × 2.5G + 2 × SFP+
如果核心只需接最多 8 台铜缆终端、上联/连接 NAS 使用 DAC 或光纤 10G,优先考虑没有
PoE 的以下两款。它们都满足 VLAN trunk、LACP 和至少两个 10G SFP+ 的需求;不要为
AP 选 PoE 版来承担核心,因为 PoE 预算、风扇和待机损耗都会明显增加。
| 型号 | 端口与管理能力(厂商声明) | 厂商功耗 / 噪声资料 | 对当前 LAN 的判断 |
|---|---|---|---|
| **TP-Link Omada SG3210X-M2** | 8 × 100M/1G/2.5G RJ45、2 × 10G SFP+,并有 RJ45 和 Micro-USB console。厂商规格列出 802.1Q VLAN、STP/RSTP/MSTP、静态 LAG 和 802.3ad LACP(最多 8 个聚合组、每组最多 8 端口);L3 是 32 个 IPv4/IPv6 接口、48 条静态路由。 | **无风扇**100240 V AC 内置电源。`UN 1.20` 数据表:待机最高 **6.0 W**220 V/50 Hz、25 °C),最高 **15.3 W**220 V)或 **15.0 W**110 V)。 | **首选低功耗方案。** 足以做 LAN66 核心、给 PVE/gfw 与 U6 Lite 做 VLAN 10 trunk,并以 SFP+ DAC/光口连接 10G NAS/主机;它不提供 5G/10G RJ4510G 铜缆需外置转换或 SFP+ 10GBASE-T 模块。 |
| **MikroTik CRS310-8G+2S+IN** | 8 × 2.5G RJ45、2 × 10G SFP+SFP+ 笼支持 1G/2.5G/10G。RouterOS v7(也可选 SwOS)支持 VLAN、链路聚合与 ACL。 | 18–57 V DC 外置供电;官方给出“无附件”最高 **21 W**、总体最高 **34 W**,且机内 **1 个风扇**。厂商没有在该页给出 dBA。 | 可用且软件/文档/恢复路径成熟,但不是本题的静音低功耗优先项:官方最大功耗显著高于 TP-Link,且有风扇。适合明确偏好 RouterOS/SwOS 与其可维护性时选。 |
功耗数字是各厂商的**上限/待机测试条件**,不是你实际墙插读数;SFP+ 光模块、DAC/AOC,尤其
10GBASE-T SFP+ 模块,会另增功耗和热量。对于本网络,用被动 DAC 或短距光模块连接 10G
设备,通常比全 RJ45 10G 核心更容易保持低温、低噪。
`SG3210X-M2` 的上表数据对应 TP-Link 的 `UN 1.20` 数据表;不同地区/硬件版本的包装、
认证和功耗标注可能不同,购买中国零售版本前应让卖家确认**准确硬件版本、保修渠道和固件地区**。
本次未找到 TP-Link 中国官网的该精确型号页,因此不能把海外官方页面当作大陆现货/售后承诺。
MikroTik 同样应通过其官方零售商查询渠道确认本地库存和保修。两台购买前还应确认所选
SFP+/DAC 的兼容清单。
来源:[TP-Link 产品规格](https://www.tp-link.com/uk/business-networking/omada-switch-access-pro/sg3210x-m2/)
[TP-Link `UN 1.20` 数据表](https://static.tp-link.com/upload/product-overview/2025/202512/20251224/SG3210X-M2%28UN%29%201.20_datasheet.pdf)
[MikroTik 产品页](https://mikrotik.com/product/crs310_8g_2s_in)
[MikroTik 用户手册](https://help.mikrotik.com/docs/spaces/UM/pages/214630429/CRS310-8G%2B2S%2BIN)。
+211
View File
@@ -0,0 +1,211 @@
# 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()
```
## 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.
On 2026-08-06, key-only IPv4 SSH was verified for both managed APs using the
`zhiqiangf` account. Verify future access without permitting password or
keyboard-interactive fallback:
```bash
ssh -4 -o BatchMode=yes -o PasswordAuthentication=no \
-o KbdInteractiveAuthentication=no -o StrictHostKeyChecking=yes \
zhiqiangf@192.168.66.6 'mca-cli-op info'
ssh -4 -o BatchMode=yes -o PasswordAuthentication=no \
-o KbdInteractiveAuthentication=no -o StrictHostKeyChecking=yes \
zhiqiangf@192.168.55.5 'mca-cli-op info'
```
Both devices reported `Connected` to
`http://192.168.66.46:9080/inform` during that verification. The AP shell maps
this management account to UID 0, so treat it as administrative access.
## Offline AP recovery
An AP can be reachable via ICMP and SSH but offline in the controller when its
saved inform URL still points to port `8080`. This occurred for UAP-AC-Lite
after its inform configuration had diverged from the controller endpoint.
From the AP, inspect the active endpoint and test controller reachability:
```bash
info
nc 192.168.66.46 9080
```
The AP's BusyBox `nc` may not support `-zv`; use the plain `nc` invocation
above when testing the TCP path.
If `info` reports `http://192.168.66.46:8080/inform` (or another obsolete
endpoint), correct it. UniFi devices commonly require the command twice while
adoption is completed:
```bash
set-inform http://192.168.66.46:9080/inform
set-inform http://192.168.66.46:9080/inform
```
Verify that the AP changes to connected/online in the controller.
## Controller migration check
After a controller migration or restore, inspect stored device records for a
stale default-port endpoint before treating an AP as faulty:
```javascript
use ace
db.device.find(
{ inform_url: /8080/ },
{ name: 1, ip: 1, inform_url: 1 }
)
```
Do not include credentials or controller database dumps in incident notes.
@@ -0,0 +1,120 @@
# UniFi AP + OpenWrt VLAN-backed Wi-Fi: implementation evidence
**Research date:** 2026-08-08
**Scope:** research only. This file does not assert that the local physical
path currently carries VLAN 10, and no controller, AP, ERX, or OpenWrt setting
was changed while preparing it.
## Conclusion
Yes. This is a normal, supported deployment pattern: UniFi APs and Network
Application manage the SSID-to-VLAN mapping, while a non-UniFi gateway such as
OpenWrt owns the tagged VLAN interface, its subnet, DHCP, routing, and
firewall/policy routing. The existing untagged LAN DHCP server remains in
place; it serves only the native LAN, while OpenWrt serves the tagged VLAN.
Ubiquiti's current third-party-gateway recipe specifically says to configure
the subnet, VLAN ID, and DHCP **on the third-party gateway first**, then create
a UniFi virtual network with **Router = Third-party Gateway** and the same VLAN
ID. [Ubiquiti: Creating Virtual Networks (VLANs)](https://help.ui.com/hc/en-us/articles/9761080275607-Creating-Virtual-Networks-VLANs)
That supported pattern requires an end-to-end tagged path. It is not feasible
on the current local topology: `gfw` is a VM connected only to untagged LAN 66
and there is no verified physical or virtual switch trunk carrying VLAN 10 from
the U6 Lite to that VM. Creating `eth0.10` inside the VM cannot supply the
missing L2 transport.
## The reusable topology
```text
main SSID ── untagged/native LAN66 ── ERX DHCP ── 192.168.66.0/24
U6 Lite ── AP uplink trunk (native LAN66 + tagged VLAN 10) ── gfw/OpenWrt
└── ubnt-upg SSID ── VLAN 10 ── gfw.10: 192.168.10.1/24 + DHCP + OpenClash
```
This is not two DHCP servers answering one broadcast domain. They serve two
separate L2 broadcast domains. An AP bridges a station's DHCP request into the
VLAN selected for its SSID; it does not allocate the address itself. Ubiquiti
states that SSID clients become members of the selected VLAN and that every
port between AP and gateway must allow that VLAN. [Ubiquiti: Creating UniFi
WiFi SSIDs](https://help.ui.com/hc/en-us/articles/26136823938583-Creating-UniFi-WiFi-SSIDs)
OpenWrt's own VLAN documentation shows the same mixed-link model: an untagged
LAN plus tagged VLAN interfaces on a common link, and explicitly notes that a
separate DHCP server can serve each segment. [OpenWrt: VLAN / switch
configuration](https://openwrt.org/docs/guide-user/network/vlan/switch_configuration)
## Evidence of existing deployments
### Protocol and product documentation (authoritative)
* Ubiquiti's third-party-gateway workflow is an exact product-supported recipe
for the UniFi half: gateway first, then matching VLAN in UniFi. It does not
require a UniFi gateway or switch. [Creating Virtual Networks
(VLANs)](https://help.ui.com/hc/en-us/articles/9761080275607-Creating-Virtual-Networks-VLANs)
* Ubiquiti specifies the exact Wi-Fi half: choose the network in the SSID's
**Network** dropdown, select the APs that should broadcast it, and permit the
VLAN on all intervening ports. [Creating UniFi WiFi
SSIDs](https://help.ui.com/hc/en-us/articles/26136823938583-Creating-UniFi-WiFi-SSIDs)
* Ubiquiti's troubleshooting topology specifically calls an SSID VLAN made
native on the AP port, or absent as a tagged VLAN upstream, a broken setup;
it directs operators to trace every AP-to-gateway port. [Virtual Network
(VLAN) Troubleshooting](https://help.ui.com/hc/en-us/articles/9592924981911-Virtual-Network-VLAN-Troubleshooting)
* OpenWrt documents Linux 802.1Q interfaces and bridge VLAN membership. Its
example calls out a trunk carrying untagged traffic and tagged VLANs, which
is the link gfw needs. [OpenWrt: VLAN / switch
configuration](https://openwrt.org/docs/guide-user/network/vlan/switch_configuration)
### User-reported working deployments (official Ubiquiti Community; anecdotal)
These are not Ubiquiti support guarantees, but they demonstrate that the exact
vendor split is in real use:
* A user reports a UniFi site whose upstream router/DHCP server is **OpenWrt**;
they create a Third-Party Gateway network, assign its VLAN tag, then bind it
to the Wi-Fi SSID. The report says it works without a UniFi gateway or switch.
[UniFi Network Application 7.4.156 discussion](https://community.ui.com/releases/UniFi-Network-Application-7-4-156/15ac6260-9cd1-4ac3-a91c-4880c1c87882?page=36)
* A second user reports two SSID VLANs over an **OpenWrt Linksys WRT32X 802.1Q
trunk**, with no USG or UniFi switch; the controller network is selected in
the SSID's Network dropdown. [Assigning VLANs to SSIDs on Controller
6](https://community.ui.com/questions/Assigning-VLANs-to-SSIDs-on-Controller-6/3e656047-685f-470e-954c-703077e89aaa)
* A Ubiquiti Community post describes an existing multi-VLAN UniFi deployment
with an OpenWrt NanoPi R6S gateway and all UniFi networks configured as
Third-party Gateway. [OpenWrt third-party-gateway
deployment](https://community.ui.com/questions/Replacing-an-existing-third-party-router-firewall-with-a-UXG-Lite-on-an-existing-UniFi-network/5de2504a-fac2-437a-b893-495a6be2f3c0)
## Exact mapping for this network
| Layer | Required configuration |
|---|---|
| `gfw` / OpenWrt | Only after a tagged path reaches the VM: create/retain VLAN-10 interface, address `192.168.10.1/24`, run the **only** DHCP server for that segment, and apply the intended OpenClash/policy routing. |
| U6-to-gfw path | Native/untagged LAN66 for U6 management; tagged VLAN 10 allowed at every switch/link. Do not replace the native LAN with VLAN 10. |
| UniFi network | One VLAN 10 virtual network with **Router: Third-party Gateway**. This is controller metadata that makes the AP tag the selected SSID; it is not a DHCP service. |
| `ubnt-upg` Wi-Fi | Select that VLAN 10 network. Do not enable UniFi Hotspot/guest portal unless deliberately required. |
| Existing Wi-Fi | Leave `ubnt-windy`, `ubnt-windy-2`, and `ubnt-haas` network/VLAN choices untouched; they remain on their existing LANs and DHCP servers. |
The acceptance test is therefore unambiguous: after reconnecting, a device on
`ubnt-upg` must receive `192.168.10.x` from the VLAN 10 gateway, while a device
on existing Wi-Fi continues to receive its established LAN address. A
`192.168.66.x` lease on the dedicated SSID means VLAN 10 was not selected or
did not pass the L2 path; it is not evidence that the U6 should run DHCP.
## Protecting the existing Wi-Fi during a retry
The current UniFi UI supports **All**, **Specific**, and **Groups** independently
for each SSID's Broadcasting APs selection. [UniFi WiFi SSID and AP Settings
Overview](https://help.ui.com/hc/en-us/articles/32065480092951-UniFi-WiFi-SSID-and-AP-Settings-Overview)
Given the earlier incident where changing a virtual/AP group removed
`ubnt-windy` from the U6 Lite, the low-risk control is to leave every existing
SSID's Broadcasting APs selection unchanged and set **only `ubnt-upg`** to
**Specific → U6 Lite**. Do not reuse or move the default SSID into a temporary
group. This does not eliminate the short re-provision/reassociation caused by
an AP config push, but it prevents the configuration-level loss of the default
SSID that was observed.
Before enabling the dedicated SSID, inspect the saved settings and AP's
effective configuration, then test with one client. Only the dedicated SSID
should be affected by a failure; LAN66 DHCP and the existing SSIDs require no
change.
+104
View File
@@ -0,0 +1,104 @@
# UniFi Wi-Fi VLAN 10 with a third-party gateway
**Research date:** 2026-08-08
**Scope:** evidence-backed design only; no controller, AP, switch, EdgeRouter,
or OpenWrt settings were changed by this research.
## Answer: who assigns client addresses?
Neither a UniFi Network controller nor a UniFi AP is the DHCP server for a
normal third-party-gateway deployment. The controller configures the AP; the
AP maps a selected SSID to an 802.1Q VLAN and bridges the client's DHCP
broadcast into that VLAN. A UniFi **Gateway** can provide DHCP, but Ubiquiti's
DHCP documentation expressly describes the DHCP server as a service of UniFi
Gateways, not APs or the Network Application. [UniFi DHCP Server](https://help.ui.com/hc/en-us/articles/360012097513-UniFi-DHCP-Server)
Accordingly, an existing DHCP server on the native/untagged LAN (here, the
EdgeRouter X for `192.168.66.0/24`) is expected to answer an SSID that is not
actually VLAN-tagged. It must **not** provide the lease for a genuinely
separate dedicated VLAN. That VLAN's gateway would provide its DHCP service.
## Required architecture
```text
client → SSID ubnt-upg → U6 Lite
│ VLAN 10 (802.1Q tagged)
every intervening L2 link permits VLAN 10
gateway VLAN-10 interface: 192.168.10.1/24 + DHCP + routing/OpenClash
```
The AP's management traffic may remain untagged on LAN66. VLAN 10 is only
the client traffic emitted by `ubnt-upg`; it must be carried, tagged, from the
U6 Lite through every intervening switch/link to `gfw`. Ubiquiti explicitly
states that selecting a network for an SSID makes connected clients members of
that VLAN and that all ports between AP and gateway must permit its VLAN ID;
it recommends AP uplinks be trunked for the relevant VLANs. [Creating UniFi
WiFi SSIDs](https://help.ui.com/hc/en-us/articles/26136823938583-Creating-UniFi-WiFi-SSIDs)
On a managed UniFi switch, this means the AP uplink and every upstream port in
the path have native LAN66 (for AP management) plus tagged VLAN 10. Ubiquiti
defines that as a trunk, and warns that an AP uplink must not restrict VLANs
needed downstream. [Switch Port VLAN Assignment](https://help.ui.com/hc/en-us/articles/26136855808919-Switch-Port-VLAN-Assignment-Trunk-Access-Ports)
If a non-UniFi switch is in the path, its 802.1Q settings must be configured
equivalently (or it must be verified to transparently forward VLAN 10); UniFi
does not manage that switch. This is a physical/L2 requirement, so merely
creating a controller network cannot make a VLAN reach `gfw`.
## Local feasibility result
The local `gfw` instance is a VM attached only to untagged LAN 66, and the U6
Lite's physical path is the same ER-X LAN 66 network. No VLAN-capable physical
trunk or verified hypervisor/vSwitch VLAN handoff exists between them. The
diagram above is therefore a valid design pattern but **not a description of
the current deployable topology**. An `eth0.10` interface inside the VM cannot
create that missing L2 path.
To make this architecture feasible, add a VLAN-capable switch/trunk from AP to
gateway, or configure an end-to-end tagged VLAN handoff through the hypervisor
and its physical uplink. Until then, leave the ER-X LAN 66 DHCP design in place
and do not expect `gfw` to serve an SSID-only subnet.
## Correct controller model (after the L2 prerequisite exists)
Use one UniFi virtual network for VLAN 10 whose **Router** is **Third-party
Gateway** and whose VLAN ID is `10`; assign that network to Wi-Fi
`ubnt-upg`. Configure the subnet, VLAN and DHCP on the VLAN gateway first.
This follows Ubiquiti's documented third-party-gateway workflow: create the
VLAN on the gateway, then add a UniFi virtual network with Router =
Third-party Gateway and the matching VLAN ID. [Creating Virtual Networks
(VLANs)](https://help.ui.com/hc/en-us/articles/9761080275607-Creating-Virtual-Networks-VLANs)
Do not enable a second DHCP service in UniFi for this VLAN. The controller
entry is the AP/switch VLAN definition; the gateway on VLAN 10 remains the
sole DHCP server and default gateway. The EdgeRouter X's LAN66 DHCP service
can remain unchanged because it is for untagged LAN66, not VLAN 10.
## Safe verification sequence
1. Read-only confirm on `gfw` that its VLAN-10 interface has
`192.168.10.1/24`, DHCP is enabled for `192.168.10.0/24`, and no other
DHCP daemon serves that VLAN.
2. In the controller, verify `ubnt-upg` selects the Third-party Gateway VLAN
10 network, not `Default`/LAN66 and not an unassigned VLAN-only placeholder.
3. Trace the U6 Lite-to-`gfw` L2 path and permit tagged VLAN 10 on every link
while retaining LAN66 as the AP's native/management network.
4. Reconnect a test client (forget/rejoin or renew lease). The expected lease
is `192.168.10.x`, gateway/DNS `192.168.10.1` (or the DNS option configured
by `gfw`), never `192.168.66.x`.
5. Confirm a lease appears on `gfw` and that the VLAN-10 interface's RX/TX
counters advance. Ubiquiti's VLAN troubleshooting guide identifies
upstream ports not allowing the VLAN as the common cause when Wi-Fi clients
cannot receive a correct IP address. [Virtual Network (VLAN)
Troubleshooting](https://help.ui.com/hc/en-us/articles/9592924981911-Virtual-Network-VLAN-Troubleshooting)
## What the observed `192.168.66.91` proves
It is evidence that the client's DHCP discover reached the LAN66 broadcast
domain and was answered by the EdgeRouter X. It does not show a DHCP failure
on `gfw`; it shows that the client was bridged untagged (or VLAN 10 was not
carried end-to-end) at that attempt. The corrective work is therefore to make
the SSID-to-VLAN association and L2 trunk path real, then test the lease—not to
make the AP/controller compete with the LAN DHCP server.
+83
View File
@@ -0,0 +1,83 @@
# 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.
The compatible names `hass.windy.lan` and legacy `hass.local` currently point
to the same Home Assistant address. Migrate clients to `hass.windy.lan`; keep
the legacy rewrite until its planned retirement.
## 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
View File
@@ -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).
+102
View File
@@ -0,0 +1,102 @@
# 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.
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.
## 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.
+114
View File
@@ -0,0 +1,114 @@
# 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** | **PVE VM 180 (`haos`)** — not a separate physical host (verified 2026-08-09) |
| 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 |
|---|---|
| `end1` | `192.168.55.11/24`; primary LAN55 address |
| `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 end1'
```
From a LAN client, confirm DNS and UI reachability:
```bash
getent hosts hass.windy.lan
# expect 192.168.55.11
```
## Related docs
- [runbooks/home-assistant-maintenance.md](../runbooks/home-assistant-maintenance.md) — `ha` CLI maintenance runbook + [script](../runbooks/scripts/ha-maintenance.sh)
- [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
View File
@@ -56,7 +56,8 @@ See full shape in [docs/pdns-upstream.md](../docs/pdns-upstream.md). Live secret
| `poweradmin` | poweradmin | Up (healthy) | `poweradmin/poweradmin:stable` |
| `pdns_pgweb` | pgweb | Up | `sosedoff/pgweb:0.16.2` |
| `pdns-backup` | backup | Up | `postgres:16` (scheduler) |
| `powerdns-admin` | *(orphan)* | Exited | legacy PDA UI — not in active compose |
> Legacy PDA UI container `powerdns-admin` (orphan, Exited) was removed 2026-08-12 (W1N-59).
### Network model
@@ -100,9 +101,60 @@ See full shape in [docs/pdns-upstream.md](../docs/pdns-upstream.md). Live secret
**Quirk:** `backend` is internal — backup must not use Alpine + runtime `apk`/`crond`. Uses `postgres:16` + `backup-scheduler.sh` (fixed 2026-08-01).
## Other software on this host (stubs)
## RustDesk Server
`/opt/traefik`, `/opt/adguard`, `/opt/remark42`, `/opt/rustdesk`, `/opt/nginx-manager`, …
**Status: operational** (hbbs + hbbr Up; image pinned `1.1.14`; relay address fixed 2026-08-12, W1N-59).
| Item | Value |
|------|--------|
| Install path | `/opt/rustdesk` |
| Compose | `/opt/rustdesk/compose.yml` |
| Containers | `hbbs` (rendezvous), `hbbr` (relay) |
| Image | `rustdesk/rustdesk-server:1.1.14` (pinned) |
| Relay (hbbr) | `hk2.chans.xyz:21117` — advertised to clients via `hbbs -r` |
| Rendezvous (hbbs) | `21115/tcp` (NAT test), `21116/tcp+udp`, `21118/tcp` (ws) |
| Relay (hbbr) | `21117/tcp`, `21119/tcp` (ws) |
| Public IP | `154.36.174.161` |
| Health | [runbooks/rustdesk-health.md](../runbooks/rustdesk-health.md) |
**Note:** the relay hostname in `hbbs -r` must resolve to this host's public IP
(`154.36.174.161`). `hk2.chans.xyz` resolves correctly; the previously used
`hk2.wsvc.info` had **no DNS record** and broke relay connectivity for clients
(fixed 2026-08-12, W1N-59).
## Other software on this host (confirmed 2026-08-12)
Verified live via `docker ps` / port scan. Each runs as a separate compose
project under `/opt/<name>` and is fronted by Traefik where noted.
| Service | Path | Container(s) | Image | Ports / notes |
|---------|------|--------------|-------|---------------|
| Traefik | `/opt/traefik` | `traefik` | `traefik:v3.6.2` | `80`, `443` (TLS entry), `8080` (dashboard) |
| AdGuard Home | `/opt/adguard` | `adguardhome` | `adguard/adguardhome:latest` | DoH `5443`, DoT `853` (bridge; no LAN `:53`) |
| Remark42 | `/opt/remark42` | `remark42` | `ghcr.io/umputun/remark42:latest` | no host ports; via Traefik (in-container `8080`) |
### Traefik dashboard auth
| Item | Value |
|------|-------|
| Dashboard URL | `https://npm.chans.xyz` (Traefik `api@internal` router), also host `:8080` |
| Auth | HTTP Basic via Traefik `basicauth` middleware (label `dashboard-auth`) |
| User | `windy` — stored as a **bcrypt** hash (plaintext never stored) |
| Hash generator | `/opt/traefik/generate-dashboard-auth.sh` (bcrypt; auto `$``$$` compose escaping) |
| Config | `/opt/traefik/compose.yml` (label `traefik.http.middlewares.dashboard-auth.basicauth.users`) |
**Password rotated 2026-08-12** from apr1/MD5 to bcrypt via the generator script; the
plaintext lives only in the operator's password manager, never in this repo.
To rotate again: `cd /opt/traefik && ./generate-dashboard-auth.sh windy`, paste the
printed label into `compose.yml`, then `docker compose up -d --force-recreate traefik`.
`/opt/nginx-manager` was a leftover (compose + `data/` + `letsencrypt/`, no running
container) and was **removed 2026-08-12**; pre-deletion backup:
`/opt/backups/nginx-manager-20260812.tar.gz`.
Health coverage: these auxiliary services are checked by the `hk2aux`
health-check profile (`ansible/roles/healthcheck`). Run:
`cd ansible && ansible-playbook playbooks/health-report.yml --limit powerdns`.
## Ops / runbooks
+3 -2
View File
@@ -50,8 +50,9 @@ See [mailcow-smtp-client](../runbooks/mailcow-smtp-client.md).
- [mailcow-health](../runbooks/mailcow-health.md)
- [mailcow-update](../runbooks/mailcow-update.md)
- [mailcow-smtp-client](../runbooks/mailcow-smtp-client.md)
- Script: [`../scripts/check-mx2.sh`](../scripts/check-mx2.sh)
Routine health: `cd ansible && ansible-playbook playbooks/health-report.yml --limit mailcow`.
## Verified
Last checked: 2026-08-01 — `./scripts/check-mx2.sh` exit 0 (compose up, watchdog 100%, empty queue, TLS LE OK, SMTP Postcow, A/AAAA/PTR/MX/SPF OK).
Last checked: 2026-08-01 — Ansible mailcow health profile OK (compose up, watchdog 100%, empty queue, TLS LE OK, SMTP Postcow, A/AAAA/PTR/MX/SPF OK).
+22
View File
@@ -8,6 +8,7 @@
| IPv4 | `192.168.66.46` |
| SSH | `ssh -4 windy@192.168.66.46` |
| Hostname | `ubnt` |
| **Host** | **PVE VM 160 (`ubnt`)** — not a separate physical host (verified 2026-08-09) |
| Privilege | `windy` has passwordless sudo and is in the `docker` group |
The current SSH ED25519 host-key fingerprint is
@@ -25,6 +26,7 @@ Verify a changed key out of band before accepting it.
| Image | `jacobalberty/unifi:v9.5.21` |
| Network mode | host |
| HTTPS UI | `https://192.168.66.46:8443` |
| Device inform endpoint | `http://192.168.66.46:9080/inform` |
The controller binds `*:8443`. A local HTTPS request to
`https://127.0.0.1:8443/` is the primary quick health check.
@@ -43,6 +45,14 @@ 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.
## Safe reconciliation and verification
```bash
@@ -56,3 +66,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).
+8 -2
View File
@@ -76,9 +76,15 @@ 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 | `ghcr.io/charmbracelet/soft-serve:latest` (`repo.windy.me:2222`) |
| `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
+193
View File
@@ -0,0 +1,193 @@
# us4.wsvc.info
| Item | Value |
|------|-------|
| Role | WireGuard VPN server (LinuxServer Docker image) |
| SSH | `ssh -4 windy@us4.wsvc.info` (prefer IPv4 from WSL) |
| IPv4 | `185.201.226.122` |
| Compose project | `/opt/wireguard` |
| Compose file | `/opt/wireguard/compose.yml` |
| Container | `wireguard` |
| Image policy | Immutable digest, updated only in an approved maintenance window |
| Public endpoint | `us4.wsvc.info:51820/udp`; DNS publishes only A `185.201.226.122` (no native AAAA) |
| Tunnel subnet | `10.13.13.0/24` |
| Routing policy | IPv4-only full tunnel (`ALLOWEDIPS=0.0.0.0/0`); IPv6 traffic is not guaranteed to use the VPN |
Upstream image documentation:
[LinuxServer.io WireGuard](https://docs.linuxserver.io/images/docker-wireguard/).
## Deployment configuration
The repository-owned, non-secret Compose declaration is rendered from
`ansible/templates/wireguard-compose.yml.j2`. The live declaration was verified
on 2026-08-12 with these core settings:
| Setting | Live value / intent |
|---------|---------------------|
| Image | `lscr.io/linuxserver/wireguard@sha256:ac43e1226878d2611315172d6ea357a95cb326ee73124b91108118efc8666889` |
| Image version | `1.0.20260223-r0-ls119` (build 2026-07-30) |
| Required capability | `NET_ADMIN` only; host kernel already supplies WireGuard/iptables, so `SYS_MODULE` and `/lib/modules` are not granted |
| Filesystem | Read-only container root; executable tmpfs at `/run`; writable bind mount `/opt/wireguard/config:/config` |
| Restart | `unless-stopped` |
| Server mode | Named peers `ha`, `phone`, `mbp`; runtime and configured peer counts both `3` |
| Client DNS | `1.1.1.1` |
| Tunnel routing | IPv4 full tunnel, `0.0.0.0/0`; no client IPv6 tunnel |
| Runtime interface | `wg0`, server address `10.13.13.1/32`, listen port `51820` |
| Forwarding/NAT | IPv4 forwarding enabled in the container namespace; `wg0` forwarding allowed and egress masqueraded on `eth+`; IPv6 forwarding disabled |
Docker binds UDP `51820` on both host socket families, but the public hostname
has no AAAA record. Clients using `us4.wsvc.info` therefore reach the server over
IPv4.
## Other host services and firewall (2026-08-12)
This host also carries the `windy.me` secondary MX and several web applications;
do not build its firewall allowlist from the WireGuard role alone.
| Port | Owner / purpose | Effective public state |
|------|-----------------|------------------------|
| TCP `22` | SSH management | Open |
| TCP `25` | Postfix, `mx.windy.me` (MX priority 30) | Open; retain until the secondary-MX role is explicitly retired |
| TCP `80`, `443` | Traefik for `update.wsvc.info`, `us4-gate.wsvc.info`, and `trlm.wsvc.info` | Open |
| TCP `3000` | Semaphore UI direct Docker publish | Open; redundant with the Traefik route and should be removed or bound to loopback |
| TCP `8080` | Traefik direct Docker publish | Open; redundant with the authenticated dashboard route and should be removed or bound to loopback |
| UDP `51820` | WireGuard | Required public endpoint |
| TCP `9443` | Host nghttpx-to-Squid proxy | Listening but blocked by the current firewall |
| UDP `123` | ntpsec | Listening but blocked by the current firewall |
PostgreSQL (`5433`/`5434`/`5435`), MariaDB (`3306`), and the host Squid TCP
listener (`3128`) are loopback-only. Squid also owns wildcard UDP sockets, which
are not allowed by the current public zone.
UFW is not installed. Firewalld `2.3.1` is active with nftables. On 2026-08-12,
the reviewed `ansible/playbooks/us4-firewalld.yml` reconciliation removed the
stale `imap`, `imaps`, `smtp-submission`, and `smtps` services plus TCP `24`,
`6443`, and `8443` without reloading or restarting firewalld. Runtime and
permanent public-zone state now match exactly: services `dhcpv6-client`, `http`,
`https`, `smtp`, and `ssh`, with no explicit ports.
Docker-published ports are accepted through Docker's DNAT/FORWARD chains, so
the public-zone cleanup does not close `3000` or `8080`. Their Compose bindings
remain a separate, staged follow-up after the required observation window.
Firewalld logged Docker chain/policy conflicts during the 2026-08-10 boots;
treat any firewall reload or service restart as a maintenance-window operation
and reverify Docker routing. Tracking: Linear `W1N-60`.
`mx.windy.me` also publishes AAAA `2602:f9f3:0:2::878`, while the host currently
has no global IPv6 address or IPv6 default route. Treat that as a separate
secondary-MX reachability issue.
## Safety
- Private keys, preshared keys, peer configuration files, and QR codes remain
only in `/opt/wireguard/config`; do not copy them to this repository or
Linear.
- Local rollback archives are stored in `/opt/wireguard/backups` (directory
mode `0700`, archives mode `0600`). They contain private keys, are not an
off-host disaster-recovery backup, and must never leave the server.
- Live private keys, preshared keys, generated peer configs, QR images, and
`wg0.conf` are mode `0600`. Template-only `peer.conf` and `server.conf` files
are mode `0644` and do not contain generated key material.
- `/opt/wireguard/config` is mode `0755`, but its sensitive files are `0600`.
The current files are owned by the image's numeric UID/GID rather than the
declared `PUID=1000` / `PGID=1000`; the root-run WireGuard processes can use
them, but reconcile ownership only after a protected backup and maintenance
review.
- `LOG_CONFS` is currently unset and the inspected container log contained no
QR-code/config banners. Do not enable config logging; generated QR images are
credentials.
- Do not delete, move, or regenerate `/opt/wireguard/config` during
maintenance.
- Before a container recreation, validate `docker compose config` and retain a
verified SSH session for rollback. Do not run `docker compose down -v`.
## Routine operations
Run read-only checks through Ansible:
```bash
cd ansible
ansible-playbook playbooks/health-report.yml --limit wireguard
```
Preview the narrow, fail-closed public-zone reconciliation:
```bash
ansible-galaxy collection install -r requirements.yml
ansible-playbook playbooks/us4-firewalld.yml --limit us4 --check --diff
```
Apply it only after testing the provider console and keeping an independent SSH
rollback session open. The playbook creates a protected server-local backup and
a 15-minute automatic rollback before changing rules; it cancels that rollback
only after SSH, HTTPS, SMTP, Docker, Fail2ban, and WireGuard checks pass:
```bash
ansible-playbook playbooks/us4-firewalld.yml --limit us4 \
-e '{"us4_firewalld_confirm": true, "us4_console_confirm": true}'
```
The image update and recreate procedure is deliberately separate and requires
an immutable image digest in the server-side Compose file plus an explicit
maintenance-window confirmation:
```bash
cd ansible
ansible-playbook playbooks/wireguard-maintenance.yml --limit wireguard \
-e '{"wireguard_maintenance_confirm": true}'
```
Before that recreate, apply the reviewed Compose hardening from the repository:
```bash
ansible-playbook playbooks/wireguard-harden.yml --limit wireguard \
-e '{"wireguard_harden_confirm": true}'
```
## Verification
- Container `wireguard` is running and interface `wg0` exists.
- UDP `51820` is listening on the host.
- Validate a known client can handshake and sends IPv4 traffic through the VPN.
- Do not treat inactive mobile peers as a failure solely because their latest
handshake is old.
## Live audit snapshot (2026-08-12)
The WireGuard service itself is healthy and its installation is broadly
reasonable:
- The sanitized Ansible health report returned `status=ok`; Compose is valid,
the container is running with zero restarts, `wg0` exists, and UDP `51820` is
listening.
- One of three peers had a current handshake during the audit. Two peers had
not handshaken since the current container/interface start; confirm those
clients only if they are expected to be active.
- The image is immutable-digest pinned, key-bearing files are protected, the
container root is read-only, and the container has `NET_ADMIN` without the
broader `SYS_MODULE` capability.
- Debian `13.6`, kernel `6.12.101+deb13-amd64`, Docker Engine `29.7.2`, and
Docker Compose `v5.4.0` were observed. No Debian package updates or reboot
requirement were pending.
Open host-level follow-up (do not conflate these with a WireGuard outage):
1. **Disk capacity:** `/` was 90% used with about 3.4 GiB free. Docker reported
about 2.48 GB of reclaimable images and the system journal used about 1.9
GB, but do not prune or vacuum without reviewing retention and rollback
needs first.
2. **Docker exposure:** the firewalld public-zone cleanup is complete, but
Docker still publishes `3000` and `8080` outside the ordinary host INPUT
path. Remove those redundant Compose bindings in separate maintenance units
after the observation window, and confirm provider firewall rules first.
3. **Image maintenance:** the upstream `latest` amd64 image had advanced to
`1.0.20260223-r0-ls120` (build 2026-08-06). Review and pin its immutable
digest in a maintenance window rather than updating unattended.
4. **Host hygiene:** `apache2.service`, `certbot.service`, and
`postgresql@9.6-main.service` were in a failed state while unrelated Docker
workloads remained active. Establish ownership and remove or repair stale
units separately.
5. **Resource/log limits:** the WireGuard container has no memory, CPU, or PID
limit and uses Docker's `json-file` log driver without a per-container
rotation setting. Current log size was small, but limits/rotation should be
considered during a reviewed Compose update.
+9 -3
View File
@@ -5,6 +5,9 @@ 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).
For a live-verified map of the **internal LAN** (gw, gfw, dns, ubnt, APs) and
the software deployed there, see [the LAN overview](../docs/lan-overview.md).
| Host | Role | SSH | IPv4 | Status | Facts |
|------|------|-----|------|--------|-------|
| mx2.windy.me | mailcow (primary MX prio 20) | `ssh -4 windy@mx2.windy.me` | 194.163.160.244 | active | [hosts/mx2.windy.me.md](../hosts/mx2.windy.me.md) |
@@ -13,15 +16,18 @@ diagnosis and procedures that are deliberately interactive or destructive; see
| 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 | — |
| us4.wsvc.info | WireGuard VPN | `ssh -4 windy@us4.wsvc.info` | 185.201.226.122 | active | [hosts/us4.wsvc.info.md](../hosts/us4.wsvc.info.md) |
| hk2.chans.xyz | PowerDNS auth (ns1) | `ssh -4 windy@hk2.chans.xyz` | 154.36.174.161 | active | [hosts/hk2.chans.xyz.md](../hosts/hk2.chans.xyz.md) |
| ns1.wsvc.info | PowerDNS public NS name | — (DNS) | → hk2 `154.36.174.161` | active | see hk2 |
| ns2.wsvc.info | Secondary NS (AXFR/NOTIFY peer) | — (DNS) | → us1 `202.91.35.141` | active | see hk2 |
| pdns.wsvc.info | Poweradmin UI | — (HTTPS) | → hk2 | active | see hk2 |
| pgweb.wsvc.info | PowerDNS Postgres UI | — (HTTPS) | → hk2 | active | see hk2 |
| **synapse.chans.xyz** | Matrix homeserver (ESS: Synapse + MAS + Element) | `ssh -4 windy@synapse.chans.xyz` | `169.58.86.13` | **active** | [hosts/synapse.chans.xyz.md](../hosts/synapse.chans.xyz.md) |
| **gfw.windy.lan** | OpenWrt 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) |
| **gfw.windy.lan** | OpenWrt (ImmortalWrt) LAN gateway / OpenClash (PVE VM 140) | `ssh -4 root@192.168.66.1` | `192.168.66.1` | **active** | [hosts/gfw.windy.lan.md](../hosts/gfw.windy.lan.md) |
| **dns.windy.lan** | AdGuard Home LAN DNS + Mihomo explicit proxy (PVE VM 120) | `ssh -4 windy@192.168.66.36` | `192.168.66.36` | **active** | [hosts/dns.windy.lan.md](../hosts/dns.windy.lan.md) |
| **gw** | EdgeRouter X primary LAN gateway | `ssh -4 zhiqiang@192.168.66.254` | `192.168.66.254` | **active** | [hosts/gw.md](../hosts/gw.md) |
| **ubnt** | UniFi Network Controller (PVE VM 160) | `ssh -4 windy@192.168.66.46` | `192.168.66.46` | **active** | [hosts/ubnt.md](../hosts/ubnt.md) |
| **hass.windy.lan** | Home Assistant (HAOS, PVE VM 180, LAN55) | `ssh hassio@hass.windy.lan` | `192.168.55.11` | **active** | [hosts/hass.windy.lan.md](../hosts/hass.windy.lan.md) |
`status: stub` = known to exist; fill `hosts/<name>.md` when next touched.
+2
View File
@@ -0,0 +1,2 @@
version: 1
profile: minimal
@@ -1,70 +0,0 @@
# VPS 維護專案重構:Ansible + systemd timers + Restic
## Objective
將現有 VPS 維運知識庫漸進重構為一個以 Ansible 管理一致性、以 systemd timers 執行每日唯讀檢查、以 Restic 建立加密異機備份的個人 SRE 維運專案。現納管 mx2、us2、hk2 與 Matrix K3s 主機。自動化預設只能觀測與告警;受 allowlist 和顯式確認保護的 Compose reconciliation 可處理已審查的服務重建,DNS、機密與破壞性資料操作仍需人工確認。
## Confirmed Decisions
- [x] 定位為個人 SRE/維運手冊庫,而非全自動修復平台。
- [x] 已納管 mx2、us2、hk2 與 Matrix K3smx、us1、us4 待完成盤點與緊急存取驗證後才納管。
- [x] 每日自動做唯讀檢查與 Email 通知;每週人工審查、每月人工維護、每季復原演練與權限盤點。
- [x] 以 Email 作為唯一告警與摘要通道。
- [x] 採用本機快速復原加密異機副本的 3-2-1 最小可行策略。
- [x] 真實機密不進 Git;repo 只保存去敏設定結構與操作/驗證紀錄。
- [x] 日常存取使用非 root SSH key;禁止 root/password SSH,維護 provider/recovery console 緊急存取。
- [x] 採用 Ansible + systemd timers + Restic 作為技術基線。
## Implementation Plan
- [x] **Status: Done — 建立去敏 Ansible inventory。** 為 mx2、us2、hk2 與 Matrix 建立主機與服務群組,將非機密主機資料與現有人類可讀的 `inventory/hosts.md` 對應;理由是提供可審查的機器可讀編排層,且不取代既有事實來源。
- [x] **Status: Done — 實作 audit-only Ansible playbook。** 只收集連線、OS、磁碟、systemd、Docker Compose 和依賴命令狀態,不做設定或服務改動;理由是先確認控制面與真實現況。
- [x] **Status: Done — 定義跨服務健康檢查結果合約。** 統一去敏結構化結果、日誌位置、嚴重度及 exit code,讓人工執行、systemd 和 Ansible audit 可共同消費;理由是避免各服務告警語意漂移。
- [x] **Status: Done — 保留並模組化 Mailcow 健康檢查。** 持續檢查 Compose、watchdog、queue、listeners、HTTP/HTTPS、TLS、SMTP、DNS/PTR/MX/SPF;理由是現有腳本已覆蓋關鍵郵件服務面向。
- [x] **Status: Done — 實作 Vaultwarden 唯讀健康檢查。** 驗證 Compose/Postgres、HTTPS、有效設定、SMTP AUTH、備份新鮮度及機密指紋一致性,禁止輸出機密;理由是 `config.json` 優先於 `.env`,且 SMTP 漂移已有已知風險。
- [x] **Status: Done — 實作 PowerDNS 唯讀健康檢查。** 驗證容器、版本與安全公告、ns1/ns2 served SOA、API、Web UI、備份新鮮度與必要設定;理由是同時覆蓋公開 DNS 與 secondary 同步。
- [x] **Status: Done — 使用 Ansible 部署 systemd healthcheck service/timer。** 每台主機本機執行每日檢查,具 persistent 排程、權限、logrotate 和一致錯誤處理;理由是控制端離線不應阻止巡檢。
- [x] **Status: Done — 部署 Email 告警與每日摘要。** Critical/unknown 立即通知、健康狀態每日摘要、重複失敗抑制;SMTP 真實認證僅在各主機受限路徑保存;理由是已選定 Email 為唯一通知通道。
- [!] **Status: Blocked — 決定 Restic 異機 repository 與存取隔離。** 選擇具加密傳輸、權限隔離與可承受保留需求的 S3/B2/SFTP 或等價目的地;理由是無異機 repository 即無法滿足已確認的 3-2-1 政策。阻塞:本地實作依要求未虛構 backend、repository 或 credentials。
- [x] **Status: Done (templates gated) — 部署 Vaultwarden 與 PowerDNS 的 Restic 備份。** 將既有一致性資料庫 dump、必要資料目錄及復原元資料加密同步,並加入 snapshot 年齡和 `restic check` 驗證;理由是兩者已有本機備份但缺少異機保護。啟用被 repository 決策和主機端受限設定檔阻擋。
- [!] **Status: Blocked — 完成 Mailcow 備份設計審查。** 官方流程已確認:以 `/opt/mail/helper-scripts/backup_and_restore.sh backup all`(或經明確核准的元件集)先產生一致性備份,再由 Restic 同步該輸出;不得直接複製 Docker volumes。阻塞:仍需決定本機備份位置、保留期、排程及異機 Restic repository。
- [x] **Status: Done (templates gated) — 部署 Restic backup、retention、forget/prune 與 check timers。** 與現有資料庫 dump 時段錯開,所有 repository 認證與密碼留在伺服器端受限檔案;理由是保持備份可用與成本可控。啟用被 repository 決策和主機端受限設定檔阻擋。
- [!] **Status: Blocked — 補齊服務級復原 runbook。** 阻塞:需在選定 Restic repository、建立實際 snapshot 並確認各服務的實際備份輸出後,才能編寫可驗證的 restore 前置條件、順序與 rollback;禁止臆造 backend/credentials 或未驗證還原命令。
- [!] **Status: Blocked — 執行隔離式復原演練。** 阻塞:尚未選定/配置 Restic 異機 repository,亦尚無可供還原的異機 snapshot;演練不得對現有生產資料執行。
- [x] **Status: Done — 實作受控 common baseline 與 maintenance playbook。** 僅在 audit 穩定後納入 SSH 稽核、時間同步、logrotate、更新預覽與人工確認的維護操作;理由是避免工具導入期間同時改變服務狀態。
- [x] **Status: Done (2026-08-03) — 將例行操作收斂為 Ansible 入口。** 新增 on-demand health report 與 allowlisted Compose reconciliationhealth、maintenance preview、baseline 現可涵蓋 Matrix。互動式 Mailcow 更新、資料遷移、DNS 與機密操作保留人工程序。
- [x] **Status: Done (separate change defined) — 將 PowerDNS API key、DB password 與 TSIG 輪替列為分離變更。** 已明確保持 API key、DB password 與 TSIG 為三項獨立、需人工核准的變更,並要求逐步驗證 Auth、Poweradmin、AXFR/NOTIFY、DNSSEC 與 ns2 同步;未輪替任何機密。
- [!] **Status: Blocked — 盤點 mx、us1、us4。** 阻塞:inventory 僅記錄 `TBD` SSH/角色或缺少完整事實,尚未提供可驗證的存取方式與 provider/recovery console 資訊;不得猜測或嘗試未授權存取。
## Verification Criteria
- [ ] 四台 active 主機能被 Ansible 正確解析;Compose 主機可執行 audit,且 audit 不產生主機變更。
- [ ] 四台主機每日產生去敏健康結果;服務、容量、TLS、公開端點和備份新鮮度異常均能被偵測。
- [ ] 正常狀態寄送 Email 摘要;受控測試異常能觸發一次可讀且不含機密的 Email 告警。
- [ ] 每個核心服務至少有一份加密、異機、可列出且通過完整性檢查的 Restic snapshot。
- [ ] 至少完成一次隔離式實際還原,並確認其結果符合或明確量化偏離服務 RPO/RTO。
- [ ] 自動化排程不包含更新、重啟、修復、秘密輪替或 DNS 變更。
- [ ] Repo、Ansible vars、產出日誌及 Email 均不包含任何真實機密。
## Potential Risks and Mitigations
1. **Mailcow 備份未保持資料一致性。**
Mitigation: 先依官方方式完成備份範圍與還原設計,再上線排程;以隔離還原驗證作為完成門檻。
2. **Ansible 設定錯誤改動生產系統。**
Mitigation: 先導入 audit-only;變更 playbook 必須使用 dry-run/diff、明確 tag 與人工確認,且先限制單一 host。
3. **機密出現在 Git、Ansible output 或 Email。**
Mitigation: 真實值只存主機受限檔案;去敏輸出、no-log 機制和測試用機密掃描為強制要求。
4. **Timer 成功但公開服務實際不可用。**
Mitigation: 同時執行本機 Compose/DB 檢查與外部 HTTP、SMTP、TLS、DNS 檢查。
5. **PowerDNS 密鑰輪替中斷 DNS 管理或 zone transfer。**
Mitigation: API key、DB password、TSIG 分批處理;每步都驗證 primary/secondary 同步、DNSSEC 和管理 UI。
## Alternative Approaches
1. **純 shell + systemd,不使用 Ansible**:初期較快,但設定與排程容易跨主機漂移,且難以審查或擴充;不建議作為長期方案。
2. **Ansible + 集中式監控平台**:可增加儀表板與趨勢,但平台本身需額外維護;等 Email 摘要無法滿足需求時再評估。
3. **立即導入 SOPS + age**:可使加密設定進 Git,但需先完成密鑰生命週期與緊急存取設計;建議列為後續階段。
+60
View File
@@ -0,0 +1,60 @@
# AdGuard Home health — dns.windy.lan
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.
+47
View File
@@ -59,3 +59,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.
+210
View File
@@ -0,0 +1,210 @@
# Runbook: Home Assistant maintenance (hass.windy.lan)
Target: [hass.windy.lan](../hosts/hass.windy.lan.md) (HAOS, `machine: green`)
Upstream: HAOS 18.1 / Core 2026.8.x / Supervisor 2026.07.5 (verified 2026-08-13)
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.
## 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.
## 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 200 # tail core logs
./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-13)
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` |
| 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) |
| 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.
### 3. Inspect logs
```bash
./ha-maintenance.sh --logs core 200
./ha-maintenance.sh --logs supervisor
./ha-maintenance.sh --logs apps:core_mosquitto
```
### 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
```
## 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/)
reference `ha backups list` and `ha host update`. **Neither exists in the
installed CLI** (2026-08-13): backups are inspected with `ha backups info
<slug>` (a slug is required) and `host` has no `update` subcommand. Trust the
server CLI (`ha <cmd> --help`) over the docs.
## Known issues on hass.windy.lan (observed 2026-08-13)
From the health snapshot — follow-ups are optional, no action taken:
- **2 add-ons in `state: error`**: `core_openthread_border_router`,
`a0d7b954_ssh` (duplicate Advanced SSH & Web Terminal install).
- **`ha resolution info` issues**: `systemd_unit_failed`
(`systemd-vconsole-setup.service`), `no_current_backup`, 2×
`corrupt_repository` (store `d5369777`, `a0d7b954`).
- `host info` reports `disk_life_time: 10` (disk lifetime warning threshold).
## 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`
- Update the **Verified** line on [hosts/hass.windy.lan.md](../hosts/hass.windy.lan.md)
+7 -4
View File
@@ -2,9 +2,10 @@
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 +67,9 @@ 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.
## Pass criteria
+1 -1
View File
@@ -6,7 +6,7 @@ Path: `/opt/mail`
## 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`.
+1 -1
View File
@@ -18,7 +18,7 @@ Use these only after the Ansible health report needs investigation.
ssh -4 windy@hk2.chans.xyz 'cd /opt/pdns && docker compose ps -a'
```
Expect `auth`, `db`, `poweradmin` healthy; `backup` Up; `pgweb` Up. Ignore stopped orphan `powerdns-admin` unless cleaning orphans.
Expect `auth`, `db`, `poweradmin` healthy; `backup` Up; `pgweb` Up. The legacy PDA orphan `powerdns-admin` was removed 2026-08-12 (W1N-59).
### Version / security poll
+80
View File
@@ -0,0 +1,80 @@
# RustDesk server health (hk2)
Read-only checks for the `/opt/rustdesk` stack on **hk2.chans.xyz**.
Facts: [hosts/hk2.chans.xyz.md](../hosts/hk2.chans.xyz.md)
Prefer the Ansible health report for routine checks and the
[rustdesk playbook](../ansible/playbooks/rustdesk.yml) for reviewed config
changes. Keep the SSH commands below for focused diagnosis.
## Focused SSH diagnostics
Use these only after the Ansible health report needs investigation.
### Quick remote
```bash
ssh -4 windy@hk2.chans.xyz 'cd /opt/rustdesk && docker compose ps -a'
```
Expect `hbbs` and `hbbr` both `Up`. Ignore any stopped orphans unless cleaning up.
### Version / image pin
Expect `rustdesk/rustdesk-server:1.1.14` (or the documented pin). Startup logs
should show the relay list and no errors.
```bash
ssh -4 windy@hk2.chans.xyz 'docker inspect hbbs --format "{{.Config.Image}}"; docker inspect hbbr --format "{{.Config.Image}}"'
```
### Relay address (the critical check)
The `hbbs -r <host>:21117` value is advertised to every client as the relay
(hbbr) address. It **must resolve to this host's public IP** `154.36.174.161`.
`hk2.chans.xyz` is the correct value; `hk2.wsvc.info` has no DNS record and must
never be used.
```bash
# The value hbbs is actually serving (definitive):
ssh -4 windy@hk2.chans.xyz 'docker inspect hbbs --format "{{json .Config.Cmd}}"'
# expect ["hbbs","-r","hk2.chans.xyz:21117"]
# Confirm the relay hostname resolves to the public IP:
dig +short hk2.chans.xyz A @1.1.1.1 # expect 154.36.174.161
# Confirm the relay hostname is what hbbs logs as its relay list:
ssh -4 windy@hk2.chans.xyz 'docker logs hbbs 2>&1 | grep "relay-servers" | tail -1'
# expect relay-servers=["hk2.chans.xyz:21117"]
```
### Ports (from laptop / WSL)
```bash
IP=154.36.174.161
nc -vz -w 5 $IP 21117 # hbbr relay TCP
nc -vz -w 5 $IP 21116 # hbbs rendezvous TCP
nc -vz -u -w 5 $IP 21116 # hbbs rendezvous UDP
nc -vz -w 5 $IP 21115 # hbbs NAT type test
```
## Scheduled local check
The sanitized Ansible health profile is `rustdesk` (deployed via
`ansible/playbooks/healthchecks.yml` on hosts whose inventory lists it). It runs
locally through `vps-healthcheck.timer`, writes a sanitized JSON result to
`/var/lib/vps-health/latest.json`, and never exposes secrets.
## After config changes
Reviewed changes to `/opt/rustdesk/compose.yml` (relay address, image pin, port
mapping) are applied through the gated rustdesk playbook:
```bash
cd ansible
ansible-playbook playbooks/rustdesk.yml --limit rustdesk \
-e '{"rustdesk_confirm": true}'
```
Re-run the health report afterwards.
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env bash
#
# ha-maintenance.sh — Home Assistant maintenance via the `ha` supervisor CLI.
#
# Target : hass.windy.lan (HAOS / Core), SSH user `hassio`.
# Access : non-interactive `sudo -n -i ha <cmd>`; the root login environment
# carries the supervisor API token (interactive login loads
# ~hassio/.zprofile which runs `exec sudo -i`). The whole remote
# procedure runs in ONE `sudo -n -i bash -s` login so the SSH & Web
# Terminal MOTD is printed only once, then stripped locally.
# See hosts/hass.windy.lan.md and runbooks/home-assistant-maintenance.md.
#
# Usage:
# ./ha-maintenance.sh read-only health snapshot
# ./ha-maintenance.sh --check-config validate core configuration
# ./ha-maintenance.sh --logs core|supervisor|host|apps:<slug> [N]
# ./ha-maintenance.sh --update --yes refresh + update core(--backup)/supervisor/os
# ./ha-maintenance.sh --restart-core [--safe-mode] --yes
# ./ha-maintenance.sh --rebuild-core --yes rebuild core image (after options change)
# ./ha-maintenance.sh --rollback-os --yes boot the previous OS slot (A/B rollback)
# ./ha-maintenance.sh --backup [NAME] --yes full backup (optionally named)
# ./ha-maintenance.sh --restore <slug> --yes restore a backup (DESTRUCTIVE)
# ./ha-maintenance.sh --app start|stop|restart|update <slug> --yes
#
# Environment: HA_HOST (default hass.windy.lan), HA_SSH_USER (default hassio).
# Mutating actions always require --yes; this script never prompts.
set -uo pipefail
HOST="${HA_HOST:-hass.windy.lan}"
SSH_USER="${HA_SSH_USER:-hassio}"
MODE="health"
TARGET="core"
LINES=100
SAFE_MODE=0
CONFIRM=0
ARG1=""
ARG2=""
while [ $# -gt 0 ]; do
case "$1" in
--check-config) MODE="check-config" ;;
--logs)
MODE="logs"
if [ $# -gt 1 ]; then TARGET="$2"; shift; fi
if [ $# -gt 1 ] && [[ "$2" =~ ^[0-9]+$ ]]; then LINES="$2"; shift; fi
;;
--update) MODE="update" ;;
--restart-core) MODE="restart-core" ;;
--safe-mode) SAFE_MODE=1 ;;
--rebuild-core) MODE="rebuild-core" ;;
--rollback-os) MODE="rollback-os" ;;
--reboot) MODE="reboot" ;;
--backup)
MODE="backup"
if [ $# -gt 1 ] && [[ "$2" != --* ]]; then ARG1="$2"; shift; fi
;;
--restore)
MODE="restore"
if [ $# -gt 1 ] && [[ "$2" != --* ]]; then ARG1="$2"; shift
else echo "ha-maintenance: --restore needs a backup slug" >&2; exit 2; fi
;;
--app)
MODE="app"
if [ $# -gt 1 ] && [[ "$2" != --* ]]; then ARG1="$2"; shift; fi
if [ $# -gt 1 ] && [[ "$2" != --* ]]; then ARG2="$2"; shift; fi
;;
--yes) CONFIRM=1 ;;
*)
echo "ha-maintenance: unknown option '$1'" >&2
echo "usage: $0 [--check-config|--logs TARGET [N]|--update|--restart-core [--safe-mode]|--rebuild-core|--rollback-os|--reboot|--backup [NAME]|--restore SLUG|--app ACTION SLUG] [--yes]" >&2
exit 2
;;
esac
shift
done
if [ "$MODE" = "app" ]; then
case "$ARG1" in
start|stop|restart|update) ;;
*) echo "ha-maintenance: --app action must be start|stop|restart|update (got '$ARG1')" >&2; exit 2 ;;
esac
if [ -z "$ARG2" ]; then
echo "ha-maintenance: --app needs an app slug, e.g. --app restart core_mosquitto" >&2
exit 2
fi
fi
case "$MODE" in
update|restart-core|rebuild-core|rollback-os|reboot|backup|restore|app)
if [ "$CONFIRM" -ne 1 ]; then
echo "ha-maintenance: refusing '$MODE' without --yes (mutating action)" >&2
exit 1
fi
;;
esac
REMOTE_SCRIPT=$(cat <<'EOF'
set -u
ha() { command ha --no-progress "$@"; }
case "$1" in
health)
printf '\n=== CORE ===\n'
ha core info
printf '\n=== SUPERVISOR ===\n'
ha supervisor info | grep -E '^(healthy|supported|version|version_latest|update_available|auto_update|timezone|logging):'
printf '\n=== ADDON STATES ===\n'
ha supervisor info | grep -E '^ (name|state):'
printf '\n=== OS ===\n'
ha os info
printf '\n=== HOST ===\n'
ha host info
printf '\n=== NETWORK ===\n'
ha network info | grep -E '^(address|ip_address|supervisor_internet|vlan|wifi):'
printf '\n=== AVAILABLE UPDATES ===\n'
ha available-updates
printf '\n=== JOBS ===\n'
ha jobs info
printf '\n=== RESOLUTION ===\n'
ha resolution info
printf '\n=== CORE STATS ===\n'
ha core stats
;;
check-config)
ha core check
;;
logs)
case "$2" in
core) ha core logs -n "$3" ;;
supervisor) ha supervisor logs -n "$3" ;;
host) ha host logs -n "$3" ;;
apps:*) ha apps logs -n "$3" "${2#apps:}" ;;
*) echo "unknown log target: $2" >&2; exit 2 ;;
esac
;;
update)
ha refresh-updates
printf '\n=== UPDATE CORE (with backup) ===\n'; ha core update --backup
printf '\n=== UPDATE SUPERVISOR ===\n'; ha supervisor update
printf '\n=== UPDATE OS ===\n'; ha os update
printf '\n=== PENDING AFTER ===\n'; ha available-updates
;;
restart-core)
if [ "$4" = "1" ]; then ha core restart --safe-mode; else ha core restart; fi
;;
rebuild-core)
ha core rebuild
;;
rollback-os)
ha os boot-slot other
;;
reboot)
ha host reboot
;;
backup)
if [ -n "${2:-}" ]; then ha backups new --name "$2"; else ha backups new; fi
;;
restore)
echo "WARNING: restoring backup $2"
ha backups restore "$2"
;;
app)
ha apps "$2" "$3"
;;
esac
EOF
)
if [ "$MODE" = "logs" ]; then
ARG1="$TARGET"
ARG2="$LINES"
fi
# One remote login shell (MOTD printed once), then drop the MOTD locally.
printf '%s\n' "$REMOTE_SCRIPT" \
| ssh -o BatchMode=yes -o ConnectTimeout=10 "${SSH_USER}@${HOST}" \
'sudo -n -i bash -s' "$MODE" "$ARG1" "$ARG2" "$SAFE_MODE" 2>/dev/null \
| awk 'BEGIN{on=0} /^System is ready/{on=1; next} on'
-195
View File
@@ -1,195 +0,0 @@
#!/usr/bin/env bash
# Read-only health check for mx2.windy.me mailcow.
# Usage: ./scripts/check-mx2.sh
# Env:
# MX2_HOST default mx2.windy.me
# MX2_SSH_USER default windy
# MX2_ALLOW_IPV6 set to 1 to allow IPv6 (default forces IPv4; WSL often lacks v6 route)
set -euo pipefail
HOST="${MX2_HOST:-mx2.windy.me}"
SSH_USER="${MX2_SSH_USER:-windy}"
# Default -4: host has AAAA but many clients (WSL) have no IPv6 route → "Network is unreachable"
SSH_AF_OPTS=(-4)
CURL_AF_OPTS=(-4)
OPENSSL_AF_OPTS=(-4)
if [[ "${MX2_ALLOW_IPV6:-0}" == "1" ]]; then
SSH_AF_OPTS=()
CURL_AF_OPTS=()
OPENSSL_AF_OPTS=()
fi
SSH=(ssh "${SSH_AF_OPTS[@]}" -o BatchMode=yes -o ConnectTimeout=10 "${SSH_USER}@${HOST}")
# Prefer public resolvers: local stub (127.0.0.53) is flaky from some WSL setups
DIG_OPTS=(+time=3 +tries=2 +short)
DIG_SERVERS=(1.1.1.1 8.8.8.8)
FAILURES=0
section() { printf '\n===== %s =====\n' "$1"; }
fail() {
printf 'FAIL: %s\n' "$1" >&2
FAILURES=$((FAILURES + 1))
}
# Strip dig commentary (timeouts are printed on stdout as ";; ...")
dig_answers_only() {
grep -v '^;;' | grep -v '^$' || true
}
# dig_short <name> [type] — tries each public resolver; skips timeout noise
dig_short() {
local name="$1"
local typ="${2:-A}"
local server out
for server in "${DIG_SERVERS[@]}"; do
out="$(dig @"${server}" "${DIG_OPTS[@]}" "$name" "$typ" 2>/dev/null | dig_answers_only)" || out=""
if [[ -n "$out" ]]; then
printf '%s\n' "$out"
return 0
fi
done
return 1
}
# dig_ptr <ip>
dig_ptr() {
local ip="$1"
local server out
for server in "${DIG_SERVERS[@]}"; do
out="$(dig @"${server}" "${DIG_OPTS[@]}" -x "$ip" 2>/dev/null | dig_answers_only)" || out=""
if [[ -n "$out" ]]; then
printf '%s\n' "$out"
return 0
fi
done
return 1
}
check_ptrs() {
local ip ptr
while read -r ip; do
[[ -z "$ip" ]] && continue
ptr="$(dig_ptr "$ip" || true)"
printf 'PTR %s -> %s\n' "$ip" "${ptr:-<none>}"
grep -Fiq "${HOST}" <<<"$ptr" || fail "PTR for ${ip} does not mention ${HOST}"
done
}
need_cmd() {
command -v "$1" >/dev/null 2>&1 || {
echo "missing required command: $1" >&2
exit 127
}
}
need_cmd ssh
need_cmd curl
need_cmd openssl
need_cmd dig
need_cmd timeout
section "SSH + compose ps"
PS_OUT="$("${SSH[@]}" 'cd /opt/mail && docker compose ps -a')" || {
fail "ssh or docker compose ps"
echo "Cannot continue without SSH/compose." >&2
exit 1
}
printf '%s\n' "$PS_OUT"
if grep -qiE 'Exited|Restarting|[[:space:]]Dead[[:space:]]' <<<"$PS_OUT"; then
fail "compose has Exited/Restarting/Dead containers"
fi
for svc in nginx-mailcow postfix-mailcow dovecot-mailcow mysql-mailcow; do
grep -Fq "$svc" <<<"$PS_OUT" || fail "missing service ${svc}"
done
section "Watchdog (tail)"
if ! "${SSH[@]}" 'cd /opt/mail && docker compose logs --tail=40 watchdog-mailcow'; then
fail "watchdog logs"
fi
section "Mail queue"
QUEUE_OUT="$("${SSH[@]}" 'cd /opt/mail && docker compose exec -T postfix-mailcow postqueue -p' 2>&1)" || {
fail "postqueue"
QUEUE_OUT=""
}
printf '%s\n' "$QUEUE_OUT"
if [[ -n "$QUEUE_OUT" ]] && ! grep -Fiq 'Mail queue is empty' <<<"$QUEUE_OUT"; then
fail "mail queue not empty"
fi
section "Listeners"
LISTEN_OUT="$("${SSH[@]}" 'ss -tlnp 2>/dev/null | grep -E ":(25|465|587|110|143|993|995|80|443|4190)[[:space:]]" || true')"
printf '%s\n' "$LISTEN_OUT"
for p in 25 465 587 110 143 993 995 80 443 4190; do
if ! grep -qE ":${p}[[:space:]]" <<<"$LISTEN_OUT"; then
fail "port ${p} not listening on host"
fi
done
section "HTTP / HTTPS"
HTTP_OUT="$(curl "${CURL_AF_OPTS[@]}" -sS -I --max-time 10 "http://${HOST}/" 2>&1 | head -8)" || true
printf '%s\n' "$HTTP_OUT"
echo "---"
# No -k: surface TLS trust problems
HTTPS_OUT="$(curl "${CURL_AF_OPTS[@]}" -sS -I --max-time 10 "https://${HOST}/" 2>&1 | head -15)" || true
printf '%s\n' "$HTTPS_OUT"
grep -qE '^HTTP/[0-9.]+ 301' <<<"$HTTP_OUT" || fail "HTTP did not redirect (expect 301)"
grep -qE '^HTTP/[0-9.]+ 200' <<<"$HTTPS_OUT" || fail "HTTPS did not return 200"
section "TLS cert (443)"
CERT_OUT="$(
timeout 15 openssl s_client "${OPENSSL_AF_OPTS[@]}" -connect "${HOST}:443" -servername "${HOST}" </dev/null 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates 2>/dev/null
)" || true
printf '%s\n' "$CERT_OUT"
if ! grep -Fq "CN=${HOST}" <<<"$CERT_OUT" && ! grep -Fq "CN = ${HOST}" <<<"$CERT_OUT"; then
fail "TLS subject missing ${HOST}"
fi
grep -Fiq "Let's Encrypt" <<<"$CERT_OUT" || fail "TLS issuer not Let's Encrypt"
section "SMTP banner"
# Connect via A record so /dev/tcp does not pick unreachable AAAA first
SMTP_IP="$(dig_short "${HOST}" A 2>/dev/null | head -n1 || true)"
if [[ -z "$SMTP_IP" ]]; then
fail "cannot resolve A for SMTP check"
SMTP_OUT=""
else
SMTP_OUT="$(
timeout 8 bash -c "exec 3<>/dev/tcp/${SMTP_IP}/25; printf 'EHLO test.local\r\nQUIT\r\n' >&3; cat <&3" 2>/dev/null \
| head -20
)" || true
fi
printf 'connect %s:25\n' "${SMTP_IP:-?}"
printf '%s\n' "$SMTP_OUT"
grep -qiE 'Postcow|ESMTP' <<<"$SMTP_OUT" || fail "SMTP banner unexpected"
section "DNS"
A_REC="$(dig_short "${HOST}" A || true)"
AAAA_REC="$(dig_short "${HOST}" AAAA || true)"
printf 'A: %s\n' "${A_REC:-<none>}"
printf 'AAAA: %s\n' "${AAAA_REC:-<none>}"
[[ -n "$A_REC" ]] || fail "missing A record"
[[ -n "$AAAA_REC" ]] || fail "missing AAAA record"
check_ptrs <<<"$A_REC"
check_ptrs <<<"$AAAA_REC"
MX_REC="$(dig_short windy.me MX || true)"
printf 'MX:\n%s\n' "${MX_REC:-<none>}"
grep -Fiq "${HOST}" <<<"$MX_REC" || fail "windy.me MX missing ${HOST}"
SPF="$(dig_short windy.me TXT || true)"
printf 'SPF:\n'
if ! grep -Fi 'v=spf1' <<<"$SPF"; then
fail "no SPF TXT on windy.me"
fi
section "Done"
if [[ "$FAILURES" -gt 0 ]]; then
echo "Health check finished with ${FAILURES} failure(s)."
exit 1
fi
echo "Health check OK. Update hosts/mx2.windy.me.md Verified line if desired."
exit 0