Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5617fd3a9 | ||
|
|
f5842568b9 | ||
|
|
6f8a4918f0 | ||
|
|
5b0f7950e6 | ||
|
|
c0cf82d4af | ||
|
|
95ec2350af | ||
|
|
3de4beb028 | ||
|
|
d54ec71aea | ||
|
|
2ffd9f9f9c | ||
|
|
f255785b72 | ||
|
|
2fd354c2a9 | ||
|
|
82203038f0 | ||
|
|
096e1ce8b6 | ||
|
|
8550053287 | ||
|
|
e501b93d65 | ||
|
|
62b8fbb8b7 | ||
|
|
efa6cf0899 | ||
|
|
086740b16e | ||
|
|
1f6d028ab5 | ||
|
|
035587e3bf | ||
|
|
e7296e664a | ||
|
|
d0d5e5a704 | ||
|
|
baca89be83 |
@@ -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
|
||||
@@ -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
|
||||
|
||||
[](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
|
||||
}
|
||||
@@ -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/
|
||||
|
||||
@@ -32,6 +32,7 @@ Also readable as `agent.md` (symlink → this file).
|
||||
| **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
|
||||
|
||||
@@ -58,6 +59,8 @@ 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 |
|
||||
@@ -71,7 +74,9 @@ 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) |
|
||||
@@ -90,6 +95,10 @@ Issues are tracked in Linear and created/updated via the Linear MCP (`vps` proje
|
||||
|
||||
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
|
||||
|
||||
- Never commit secrets: passwords, API keys, private keys, `.env`, `mailcow.conf` DB passwords, Vaultwarden `ADMIN_TOKEN` / `.smtp-credentials`.
|
||||
|
||||
@@ -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,12 +47,16 @@ 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_profile: wireguard
|
||||
healthcheck_profiles: [wireguard]
|
||||
wireguard_image: >-
|
||||
lscr.io/linuxserver/wireguard@sha256:ac43e1226878d2611315172d6ea357a95cb326ee73124b91108118efc8666889
|
||||
service_reconcile_services:
|
||||
@@ -61,7 +67,7 @@ all:
|
||||
ansible_host_ipv4: 192.168.66.36
|
||||
service_role: adguardhome
|
||||
compose_project_dir: /opt/adguardhome
|
||||
healthcheck_profile: adguardhome
|
||||
healthcheck_profiles: [adguardhome]
|
||||
service_reconcile_services:
|
||||
adguardhome:
|
||||
compose_args: [--no-deps, --force-recreate]
|
||||
@@ -74,6 +80,9 @@ all:
|
||||
powerdns:
|
||||
hosts:
|
||||
hk2:
|
||||
rustdesk:
|
||||
hosts:
|
||||
hk2:
|
||||
wireguard:
|
||||
hosts:
|
||||
us4:
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
ansible.builtin.debug:
|
||||
msg:
|
||||
host: "{{ inventory_hostname }}"
|
||||
profile: "{{ healthcheck_profile }}"
|
||||
profiles: "{{ healthcheck_profiles | default([]) | join(', ') }}"
|
||||
os: "{{ ansible_distribution }} {{ ansible_distribution_version }}"
|
||||
kernel: "{{ ansible_kernel }}"
|
||||
compose_rc: "{{ audit_compose_ps.rc }}"
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
# Deploy/reconcile the self-hosted RustDesk server (hbbs + hbbr) on hk2.
|
||||
#
|
||||
# Safe by default: run with --check for a read-only report, or supply
|
||||
# rustdesk_confirm=true to deploy the compose file and recreate the stack.
|
||||
#
|
||||
# # Read-only report
|
||||
# ansible-playbook playbooks/rustdesk.yml --limit rustdesk --check
|
||||
#
|
||||
# # Apply (deploy compose + recreate hbbs/hbbr)
|
||||
# ansible-playbook playbooks/rustdesk.yml --limit rustdesk \
|
||||
# -e '{"rustdesk_confirm": true}'
|
||||
- name: Deploy and reconcile RustDesk server
|
||||
hosts: rustdesk
|
||||
become: true
|
||||
gather_facts: false
|
||||
serial: 1
|
||||
roles:
|
||||
- role: rustdesk
|
||||
tags: [rustdesk, mutating]
|
||||
@@ -0,0 +1,456 @@
|
||||
---
|
||||
# Narrow reconciliation for the audited us4 public zone. This playbook never
|
||||
# reloads or restarts firewalld and deliberately does not manage Docker rules.
|
||||
- name: Safely remove audited stale firewalld allowances from us4
|
||||
hosts: wireguard
|
||||
become: true
|
||||
gather_facts: false
|
||||
serial: 1
|
||||
any_errors_fatal: true
|
||||
vars:
|
||||
us4_firewalld_confirm: false
|
||||
us4_console_confirm: false
|
||||
us4_firewalld_zone: public
|
||||
us4_firewalld_keep_services:
|
||||
- dhcpv6-client
|
||||
- http
|
||||
- https
|
||||
- smtp
|
||||
- ssh
|
||||
us4_firewalld_stale_services:
|
||||
- imap
|
||||
- imaps
|
||||
- smtp-submission
|
||||
- smtps
|
||||
us4_firewalld_stale_ports:
|
||||
- 24/tcp
|
||||
- 6443/tcp
|
||||
- 8443/tcp
|
||||
us4_firewalld_expected_containers:
|
||||
- nghttpx-proxy
|
||||
- semaphoreui-postgres-1
|
||||
- semaphoreui-semaphore-1
|
||||
- squid-backend
|
||||
- traefik
|
||||
- trlm-server-trilium-1
|
||||
- wireguard
|
||||
us4_firewalld_backup_root: /var/backups/us4-firewall
|
||||
|
||||
tasks:
|
||||
- name: Require the audited host and explicit apply confirmations
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- inventory_hostname == 'us4'
|
||||
- ansible_host == 'us4.wsvc.info'
|
||||
- ansible_host_ipv4 == '185.201.226.122'
|
||||
- ansible_check_mode or (us4_firewalld_confirm | bool)
|
||||
- ansible_check_mode or (us4_console_confirm | bool)
|
||||
fail_msg: >-
|
||||
Apply is allowed only for audited host us4 after the provider console
|
||||
has been tested. Set both us4_firewalld_confirm=true and
|
||||
us4_console_confirm=true. Check mode does not require confirmation.
|
||||
|
||||
- name: Verify the remote host identity
|
||||
ansible.builtin.command:
|
||||
argv: [hostname, -f]
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: us4_firewalld_hostname
|
||||
|
||||
- name: Reject an unexpected remote host
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- us4_firewalld_hostname.stdout == 'us4.wsvc.info'
|
||||
|
||||
- name: Verify required services are active
|
||||
ansible.builtin.command:
|
||||
argv: [systemctl, is-active, --quiet, "{{ item }}"]
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
loop:
|
||||
- atd
|
||||
- firewalld
|
||||
|
||||
- name: Verify firewalld Python bindings used by ansible.posix
|
||||
ansible.builtin.command:
|
||||
argv: [python3, -c, "import dbus, firewall, firewall.client"]
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
|
||||
- name: Verify the default firewalld zone
|
||||
ansible.builtin.command:
|
||||
argv: [firewall-cmd, --get-default-zone]
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: us4_firewalld_default_zone
|
||||
|
||||
- name: Read runtime public-zone services
|
||||
ansible.builtin.command:
|
||||
argv: [firewall-cmd, --zone=public, --list-services]
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: us4_firewalld_runtime_services
|
||||
|
||||
- name: Read permanent public-zone services
|
||||
ansible.builtin.command:
|
||||
argv: [firewall-cmd, --permanent, --zone=public, --list-services]
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: us4_firewalld_permanent_services
|
||||
|
||||
- name: Read runtime public-zone ports
|
||||
ansible.builtin.command:
|
||||
argv: [firewall-cmd, --zone=public, --list-ports]
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: us4_firewalld_runtime_ports
|
||||
|
||||
- name: Read permanent public-zone ports
|
||||
ansible.builtin.command:
|
||||
argv: [firewall-cmd, --permanent, --zone=public, --list-ports]
|
||||
check_mode: false
|
||||
changed_when: false
|
||||
register: us4_firewalld_permanent_ports
|
||||
|
||||
- name: Normalize the audited public-zone state
|
||||
ansible.builtin.set_fact:
|
||||
us4_firewalld_pre_services: "{{ us4_firewalld_runtime_services.stdout.split() | sort }}"
|
||||
us4_firewalld_pre_permanent_services: "{{ us4_firewalld_permanent_services.stdout.split() | sort }}"
|
||||
us4_firewalld_pre_ports: "{{ us4_firewalld_runtime_ports.stdout.split() | sort }}"
|
||||
us4_firewalld_pre_permanent_ports: "{{ us4_firewalld_permanent_ports.stdout.split() | sort }}"
|
||||
|
||||
- name: Fail closed on public-zone drift or unknown allowances
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- us4_firewalld_default_zone.stdout == us4_firewalld_zone
|
||||
- us4_firewalld_pre_services == us4_firewalld_pre_permanent_services
|
||||
- us4_firewalld_pre_ports == us4_firewalld_pre_permanent_ports
|
||||
- us4_firewalld_keep_services | difference(us4_firewalld_pre_services) | length == 0
|
||||
- us4_firewalld_pre_services | difference(us4_firewalld_keep_services + us4_firewalld_stale_services) | length == 0
|
||||
- us4_firewalld_pre_ports | difference(us4_firewalld_stale_ports) | length == 0
|
||||
fail_msg: >-
|
||||
The public zone differs from the audited baseline. Stop and review it;
|
||||
this playbook will not infer whether an unknown allowance is required.
|
||||
|
||||
- name: Select only audited stale entries that currently exist
|
||||
ansible.builtin.set_fact:
|
||||
us4_firewalld_cleanup_services: >-
|
||||
{{ us4_firewalld_stale_services | intersect(us4_firewalld_pre_services) | sort }}
|
||||
us4_firewalld_cleanup_ports: >-
|
||||
{{ us4_firewalld_stale_ports | intersect(us4_firewalld_pre_ports) | sort }}
|
||||
|
||||
- name: Report the proposed reconciliation
|
||||
ansible.builtin.debug:
|
||||
msg:
|
||||
keep_services: "{{ us4_firewalld_keep_services }}"
|
||||
remove_services: "{{ us4_firewalld_cleanup_services }}"
|
||||
remove_ports: "{{ us4_firewalld_cleanup_ports }}"
|
||||
reload_or_restart: false
|
||||
|
||||
- name: Create rollback material when cleanup is required
|
||||
when:
|
||||
- not ansible_check_mode
|
||||
- us4_firewalld_cleanup_services | length > 0 or us4_firewalld_cleanup_ports | length > 0
|
||||
block:
|
||||
- name: Create the protected firewall backup root
|
||||
ansible.builtin.file:
|
||||
path: "{{ us4_firewalld_backup_root }}"
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0700"
|
||||
|
||||
- name: Create a backup timestamp
|
||||
ansible.builtin.command:
|
||||
argv: [date, +%Y%m%dT%H%M%S%z]
|
||||
changed_when: false
|
||||
register: us4_firewalld_backup_timestamp
|
||||
|
||||
- name: Set the protected backup directory
|
||||
ansible.builtin.set_fact:
|
||||
us4_firewalld_backup_dir: >-
|
||||
{{ us4_firewalld_backup_root }}/{{ us4_firewalld_backup_timestamp.stdout }}
|
||||
us4_firewalld_rollback_command: >-
|
||||
{{ us4_firewalld_backup_root }}/{{ us4_firewalld_backup_timestamp.stdout }}/rollback-phase1.sh
|
||||
|
||||
- name: Create the protected backup directory
|
||||
ansible.builtin.file:
|
||||
path: "{{ us4_firewalld_backup_dir }}"
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0700"
|
||||
|
||||
- name: Back up the complete firewalld configuration
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- tar
|
||||
- --create
|
||||
- --gzip
|
||||
- "--file={{ us4_firewalld_backup_dir }}/firewalld.tgz"
|
||||
- --directory=/etc
|
||||
- firewalld
|
||||
changed_when: true
|
||||
|
||||
- name: Capture the pre-change runtime ruleset
|
||||
ansible.builtin.shell:
|
||||
cmd: >-
|
||||
umask 077 && nft list ruleset >
|
||||
{{ us4_firewalld_backup_dir | quote }}/nft-ruleset.txt
|
||||
executable: /bin/bash
|
||||
changed_when: true
|
||||
|
||||
- name: Install the exact pre-change rollback script
|
||||
ansible.builtin.copy:
|
||||
dest: "{{ us4_firewalld_rollback_command }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0700"
|
||||
content: |
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
exec >>/var/log/us4-firewalld-phase1-rollback.log 2>&1
|
||||
printf '%s rollback start\n' "$(date -Is)"
|
||||
add_service() {
|
||||
service=$1
|
||||
/usr/bin/firewall-cmd --permanent --zone=public \
|
||||
--query-service="$service" >/dev/null 2>&1 ||
|
||||
/usr/bin/firewall-cmd --permanent --zone=public \
|
||||
--add-service="$service"
|
||||
/usr/bin/firewall-cmd --zone=public \
|
||||
--query-service="$service" >/dev/null 2>&1 ||
|
||||
/usr/bin/firewall-cmd --zone=public --add-service="$service"
|
||||
}
|
||||
add_port() {
|
||||
port=$1
|
||||
/usr/bin/firewall-cmd --permanent --zone=public \
|
||||
--query-port="$port" >/dev/null 2>&1 ||
|
||||
/usr/bin/firewall-cmd --permanent --zone=public \
|
||||
--add-port="$port"
|
||||
/usr/bin/firewall-cmd --zone=public \
|
||||
--query-port="$port" >/dev/null 2>&1 ||
|
||||
/usr/bin/firewall-cmd --zone=public --add-port="$port"
|
||||
}
|
||||
{% for service in us4_firewalld_cleanup_services %}
|
||||
add_service {{ service }}
|
||||
{% endfor %}
|
||||
{% for port in us4_firewalld_cleanup_ports %}
|
||||
add_port {{ port }}
|
||||
{% endfor %}
|
||||
/usr/bin/firewall-cmd --check-config
|
||||
printf '%s rollback complete\n' "$(date -Is)"
|
||||
|
||||
- name: Schedule the 15-minute automatic rollback
|
||||
ansible.builtin.shell:
|
||||
cmd: |
|
||||
set -euo pipefail
|
||||
output=$(printf '%s\n' {{ us4_firewalld_rollback_command | quote }} | at now + 15 minutes 2>&1)
|
||||
job_id=$(printf '%s\n' "$output" | sed -n 's/^job \([0-9][0-9]*\).*/\1/p')
|
||||
test -n "$job_id"
|
||||
printf '%s\n' "$job_id"
|
||||
executable: /bin/bash
|
||||
changed_when: true
|
||||
register: us4_firewalld_rollback_job
|
||||
|
||||
- name: Record the automatic rollback job
|
||||
ansible.builtin.set_fact:
|
||||
us4_firewalld_rollback_job_id: "{{ us4_firewalld_rollback_job.stdout }}"
|
||||
us4_firewalld_rollback_cancelled: false
|
||||
|
||||
- name: Persist the rollback job ID beside the backup
|
||||
ansible.builtin.copy:
|
||||
dest: "{{ us4_firewalld_backup_dir }}/phase1-at-job-id"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0600"
|
||||
content: "{{ us4_firewalld_rollback_job_id }}\n"
|
||||
|
||||
- name: Reconcile and verify the audited public zone
|
||||
block:
|
||||
- name: Remove audited stale firewalld services
|
||||
ansible.posix.firewalld:
|
||||
zone: "{{ us4_firewalld_zone }}"
|
||||
service: "{{ item }}"
|
||||
state: disabled
|
||||
permanent: true
|
||||
immediate: true
|
||||
loop: "{{ us4_firewalld_stale_services }}"
|
||||
|
||||
- name: Remove audited stale firewalld ports
|
||||
ansible.posix.firewalld:
|
||||
zone: "{{ us4_firewalld_zone }}"
|
||||
port: "{{ item }}"
|
||||
state: disabled
|
||||
permanent: true
|
||||
immediate: true
|
||||
loop: "{{ us4_firewalld_stale_ports }}"
|
||||
|
||||
- name: Verify the permanent firewalld configuration
|
||||
ansible.builtin.command:
|
||||
argv: [firewall-cmd, --check-config]
|
||||
when: not ansible_check_mode
|
||||
changed_when: false
|
||||
|
||||
- name: Read reconciled runtime services
|
||||
ansible.builtin.command:
|
||||
argv: [firewall-cmd, --zone=public, --list-services]
|
||||
changed_when: false
|
||||
when: not ansible_check_mode
|
||||
register: us4_firewalld_after_runtime_services
|
||||
|
||||
- name: Read reconciled permanent services
|
||||
ansible.builtin.command:
|
||||
argv: [firewall-cmd, --permanent, --zone=public, --list-services]
|
||||
changed_when: false
|
||||
when: not ansible_check_mode
|
||||
register: us4_firewalld_after_permanent_services
|
||||
|
||||
- name: Read reconciled runtime ports
|
||||
ansible.builtin.command:
|
||||
argv: [firewall-cmd, --zone=public, --list-ports]
|
||||
changed_when: false
|
||||
when: not ansible_check_mode
|
||||
register: us4_firewalld_after_runtime_ports
|
||||
|
||||
- name: Read reconciled permanent ports
|
||||
ansible.builtin.command:
|
||||
argv: [firewall-cmd, --permanent, --zone=public, --list-ports]
|
||||
changed_when: false
|
||||
when: not ansible_check_mode
|
||||
register: us4_firewalld_after_permanent_ports
|
||||
|
||||
- name: Require the exact audited post-change public zone
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- us4_firewalld_after_runtime_services.stdout.split() | sort == us4_firewalld_keep_services | sort
|
||||
- us4_firewalld_after_permanent_services.stdout.split() | sort == us4_firewalld_keep_services | sort
|
||||
- us4_firewalld_after_runtime_ports.stdout.split() | length == 0
|
||||
- us4_firewalld_after_permanent_ports.stdout.split() | length == 0
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Verify a fresh independent SSH and sudo path
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- ssh
|
||||
- -4
|
||||
- -o
|
||||
- BatchMode=yes
|
||||
- -o
|
||||
- ConnectTimeout=10
|
||||
- -o
|
||||
- ControlMaster=no
|
||||
- -o
|
||||
- ControlPath=none
|
||||
- windy@us4.wsvc.info
|
||||
- sudo -n true
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
changed_when: false
|
||||
when: not ansible_check_mode
|
||||
vars:
|
||||
ansible_become: false
|
||||
|
||||
- name: Verify public HTTPS routes
|
||||
ansible.builtin.uri:
|
||||
url: "{{ item.url }}"
|
||||
follow_redirects: all
|
||||
status_code: "{{ item.status }}"
|
||||
validate_certs: true
|
||||
use_proxy: false
|
||||
loop:
|
||||
- {url: https://update.wsvc.info/, status: 200}
|
||||
- {url: https://us4-gate.wsvc.info/, status: 401}
|
||||
- {url: https://trlm.wsvc.info/, status: 200}
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
when: not ansible_check_mode
|
||||
vars:
|
||||
ansible_become: false
|
||||
|
||||
- name: Verify the secondary MX TCP listener externally
|
||||
ansible.builtin.wait_for:
|
||||
host: "{{ ansible_host_ipv4 }}"
|
||||
port: 25
|
||||
state: started
|
||||
connect_timeout: 5
|
||||
timeout: 10
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
when: not ansible_check_mode
|
||||
vars:
|
||||
ansible_become: false
|
||||
|
||||
- name: Verify all expected containers are running
|
||||
ansible.builtin.command:
|
||||
argv: [docker, ps, --format, "{{ '{{.Names}}' }}"]
|
||||
changed_when: false
|
||||
when: not ansible_check_mode
|
||||
register: us4_firewalld_running_containers
|
||||
|
||||
- name: Reject missing application containers
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- us4_firewalld_expected_containers | difference(us4_firewalld_running_containers.stdout_lines) | length == 0
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Verify Fail2ban remains active
|
||||
ansible.builtin.command:
|
||||
argv: [fail2ban-client, status]
|
||||
changed_when: false
|
||||
when: not ansible_check_mode
|
||||
register: us4_firewalld_fail2ban
|
||||
|
||||
- name: Require all audited Fail2ban jails
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- item in us4_firewalld_fail2ban.stdout
|
||||
loop:
|
||||
- postfix-postscreen
|
||||
- postfix-sasl
|
||||
- recidive
|
||||
- sshd
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Verify the deployed WireGuard health check
|
||||
ansible.builtin.command:
|
||||
argv: [/usr/local/lib/vps-health/run]
|
||||
changed_when: false
|
||||
when: not ansible_check_mode
|
||||
register: us4_firewalld_wireguard_health
|
||||
|
||||
- name: Cancel automatic rollback only after all checks pass
|
||||
ansible.builtin.command:
|
||||
argv: [at, -r, "{{ us4_firewalld_rollback_job_id }}"]
|
||||
changed_when: true
|
||||
when:
|
||||
- not ansible_check_mode
|
||||
- us4_firewalld_cleanup_services | length > 0 or us4_firewalld_cleanup_ports | length > 0
|
||||
|
||||
- name: Mark the automatic rollback as cancelled
|
||||
ansible.builtin.set_fact:
|
||||
us4_firewalld_rollback_cancelled: true
|
||||
when:
|
||||
- not ansible_check_mode
|
||||
- us4_firewalld_cleanup_services | length > 0 or us4_firewalld_cleanup_ports | length > 0
|
||||
|
||||
rescue:
|
||||
- name: Preserve the automatic rollback and stop
|
||||
ansible.builtin.fail:
|
||||
msg: >-
|
||||
A reconciliation or verification task failed. No reload was
|
||||
attempted. If cleanup was required, its automatic rollback remains
|
||||
scheduled; do not remove it manually.
|
||||
|
||||
always:
|
||||
- name: Report backup and rollback disposition
|
||||
ansible.builtin.debug:
|
||||
msg:
|
||||
backup: >-
|
||||
{{ us4_firewalld_backup_dir |
|
||||
default('not-created-in-check-mode' if ansible_check_mode else 'not-required') }}
|
||||
automatic_rollback: >-
|
||||
{{ 'not-created-in-check-mode' if ansible_check_mode else
|
||||
('cancelled-after-success' if (us4_firewalld_rollback_cancelled | default(false)) else
|
||||
'scheduled-or-executed') if
|
||||
(us4_firewalld_cleanup_services | length > 0 or us4_firewalld_cleanup_ports | length > 0)
|
||||
else 'not-required' }}
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
collections:
|
||||
# us4-firewalld.yml was source-reviewed and exercised with this version.
|
||||
- name: ansible.posix
|
||||
version: 2.2.2
|
||||
@@ -7,9 +7,13 @@ healthcheck_timer_on_calendar: '*-*-* 06:15:00'
|
||||
healthcheck_timer_randomized_delay_sec: 15m
|
||||
healthcheck_backup_max_age_hours: 30
|
||||
healthcheck_tls_warn_days: 21
|
||||
healthcheck_profiles:
|
||||
# Map of profile name -> installed script filename. A host selects which
|
||||
# profiles it runs via the `healthcheck_profiles` list (inventory).
|
||||
healthcheck_profile_scripts:
|
||||
mailcow: mailcow.sh
|
||||
vaultwarden: vaultwarden.sh
|
||||
pdns: pdns.sh
|
||||
wireguard: wireguard.sh
|
||||
adguardhome: adguardhome.sh
|
||||
rustdesk: rustdesk.sh
|
||||
hk2aux: hk2aux.sh
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
---
|
||||
- name: Validate known health-check profile
|
||||
- name: Validate known health-check profiles
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- healthcheck_profile in healthcheck_profiles
|
||||
fail_msg: "Unsupported healthcheck_profile: {{ healthcheck_profile }}"
|
||||
- item in healthcheck_profile_scripts
|
||||
fail_msg: "Unsupported healthcheck_profile: {{ item }}"
|
||||
loop: "{{ healthcheck_profiles }}"
|
||||
|
||||
- name: Install health-check directories
|
||||
ansible.builtin.file:
|
||||
@@ -28,13 +29,14 @@
|
||||
group: root
|
||||
mode: "0755"
|
||||
|
||||
- name: Install service health-check script
|
||||
- name: Install service health-check scripts
|
||||
ansible.builtin.template:
|
||||
src: "{{ healthcheck_profiles[healthcheck_profile] }}.j2"
|
||||
dest: "{{ healthcheck_install_root }}/{{ healthcheck_profiles[healthcheck_profile] }}"
|
||||
src: "{{ healthcheck_profile_scripts[item] }}.j2"
|
||||
dest: "{{ healthcheck_install_root }}/{{ healthcheck_profile_scripts[item] }}"
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
loop: "{{ healthcheck_profiles }}"
|
||||
|
||||
- name: Install health-check dispatcher
|
||||
ansible.builtin.template:
|
||||
|
||||
@@ -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,4 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
set -o pipefail
|
||||
'{{ healthcheck_install_root }}/{{ healthcheck_profiles[healthcheck_profile] }}' 2>&1 | tee -a '{{ healthcheck_log_dir }}/healthcheck.log'
|
||||
exit "${PIPESTATUS[0]}"
|
||||
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"
|
||||
@@ -1,5 +1,8 @@
|
||||
---
|
||||
restic_enabled: false
|
||||
# Approved Restic source profile (a key of restic_sources) for the host. Set
|
||||
# per-host in inventory; the role fails if it is not an approved source.
|
||||
restic_backup_profile: ""
|
||||
restic_binary: /usr/bin/restic
|
||||
restic_config_path: /etc/vps-restic/repository.env
|
||||
restic_state_dir: /var/lib/vps-restic
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
- name: Validate supported Restic source profile
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- healthcheck_profile in restic_sources
|
||||
fail_msg: "No approved Restic source profile for {{ healthcheck_profile }}."
|
||||
- restic_backup_profile in restic_sources
|
||||
fail_msg: "No approved Restic source profile for {{ restic_backup_profile }}."
|
||||
|
||||
- name: Verify Restic binary exists on target
|
||||
ansible.builtin.stat:
|
||||
|
||||
@@ -3,4 +3,4 @@ set -euo pipefail
|
||||
# Repository and password credentials are host-local in {{ restic_config_path }}.
|
||||
# shellcheck source=/dev/null
|
||||
source '{{ restic_config_path }}'
|
||||
exec '{{ restic_binary }}' backup --tag '{{ healthcheck_profile }}' --tag "$(hostname -s)" {% for source in restic_sources[healthcheck_profile] %}{{ source | quote }} {% endfor %}
|
||||
exec '{{ restic_binary }}' backup --tag '{{ restic_backup_profile }}' --tag "$(hostname -s)" {% for source in restic_sources[restic_backup_profile] %}{{ source | quote }} {% endfor %}
|
||||
|
||||
@@ -2,4 +2,4 @@
|
||||
set -euo pipefail
|
||||
# shellcheck source=/dev/null
|
||||
source '{{ restic_config_path }}'
|
||||
exec '{{ restic_binary }}' forget --prune --keep-daily {{ restic_keep_daily }} --keep-weekly {{ restic_keep_weekly }} --keep-monthly {{ restic_keep_monthly }} --tag '{{ healthcheck_profile }}'
|
||||
exec '{{ restic_binary }}' forget --prune --keep-daily {{ restic_keep_daily }} --keep-weekly {{ restic_keep_weekly }} --keep-monthly {{ restic_keep_monthly }} --tag '{{ restic_backup_profile }}'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[Unit]
|
||||
Description=Restic backup for approved {{ healthcheck_profile }} sources
|
||||
Description=Restic backup for approved {{ restic_backup_profile }} sources
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
# RustDesk server deployment (hbbs + hbbr) on hk2.
|
||||
# Safe by default: without rustdesk_confirm=true the role only reports whether
|
||||
# the declared compose file matches live state and refuses to recreate the stack.
|
||||
rustdesk_confirm: false
|
||||
# Compose project directory.
|
||||
rustdesk_compose_dir: /opt/rustdesk
|
||||
# Relay (hbbr) hostname:port advertised to every client via `hbbs -r`.
|
||||
# MUST resolve to this host's public IP (154.36.174.161). The known-bad value
|
||||
# 'hk2.wsvc.info' has no DNS record and must never be used.
|
||||
rustdesk_relay: hk2.chans.xyz:21117
|
||||
# Pinned server image (used for both hbbs and hbbr).
|
||||
rustdesk_image: rustdesk/rustdesk-server:1.1.14
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
# Deploy/reconcile the self-hosted RustDesk server (hbbs + hbbr).
|
||||
# Idempotent: deploys the declared compose file; only recreates the stack with
|
||||
# explicit confirmation.
|
||||
|
||||
- name: Validate relay address is set and not the known-bad value
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- rustdesk_relay | length > 0
|
||||
- "'hk2.wsvc.info' not in rustdesk_relay"
|
||||
fail_msg: >-
|
||||
rustdesk_relay must be a resolvable relay address. The known-bad
|
||||
'hk2.wsvc.info' has no DNS record and must not be used.
|
||||
|
||||
- name: Ensure compose project directory exists
|
||||
ansible.builtin.file:
|
||||
path: "{{ rustdesk_compose_dir }}"
|
||||
state: directory
|
||||
owner: windy
|
||||
group: root
|
||||
mode: "0755"
|
||||
|
||||
- name: Deploy compose file
|
||||
ansible.builtin.template:
|
||||
src: compose.yml.j2
|
||||
dest: "{{ rustdesk_compose_dir }}/compose.yml"
|
||||
owner: windy
|
||||
group: windy
|
||||
mode: "0644"
|
||||
register: rustdesk_compose_deployed
|
||||
|
||||
- name: Report no change needed
|
||||
ansible.builtin.debug:
|
||||
msg: "compose.yml already matches declared state; no change needed."
|
||||
when: not rustdesk_compose_deployed.changed
|
||||
|
||||
- name: Refuse to recreate without explicit confirmation
|
||||
ansible.builtin.fail:
|
||||
msg: >-
|
||||
compose.yml differs from declared state but rustdesk_confirm is not true.
|
||||
Supply rustdesk_confirm=true to deploy the file and recreate the stack.
|
||||
when:
|
||||
- rustdesk_compose_deployed.changed
|
||||
- not (rustdesk_confirm | bool)
|
||||
- not ansible_check_mode
|
||||
|
||||
- name: Apply compose stack
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- docker
|
||||
- compose
|
||||
- --project-directory
|
||||
- "{{ rustdesk_compose_dir }}"
|
||||
- up
|
||||
- -d
|
||||
when:
|
||||
- rustdesk_compose_deployed.changed
|
||||
- rustdesk_confirm | bool
|
||||
changed_when: true
|
||||
register: rustdesk_apply
|
||||
|
||||
- name: Verify hbbs relay command
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- docker
|
||||
- inspect
|
||||
- hbbs
|
||||
- --format
|
||||
- '{{ "{{" }}json .Config.Cmd{{ "}}" }}'
|
||||
register: rustdesk_hbbs_cmd
|
||||
changed_when: false
|
||||
when:
|
||||
- rustdesk_compose_deployed.changed
|
||||
- rustdesk_confirm | bool
|
||||
- not ansible_check_mode
|
||||
|
||||
- name: Assert hbbs advertises the declared relay
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "'{{ rustdesk_relay }}' in rustdesk_hbbs_cmd.stdout"
|
||||
fail_msg: "hbbs is not advertising the declared relay {{ rustdesk_relay }}."
|
||||
when:
|
||||
- rustdesk_compose_deployed.changed
|
||||
- rustdesk_confirm | bool
|
||||
- not ansible_check_mode
|
||||
@@ -0,0 +1,34 @@
|
||||
networks:
|
||||
rustdesk-net:
|
||||
external: false
|
||||
|
||||
services:
|
||||
hbbs:
|
||||
container_name: hbbs
|
||||
ports:
|
||||
- 21115:21115
|
||||
- 21116:21116
|
||||
- 21116:21116/udp
|
||||
- 21118:21118
|
||||
image: {{ rustdesk_image }}
|
||||
command: "hbbs -r {{ rustdesk_relay }}"
|
||||
volumes:
|
||||
- ./hbbs:/root
|
||||
networks:
|
||||
- rustdesk-net
|
||||
depends_on:
|
||||
- hbbr
|
||||
restart: unless-stopped
|
||||
|
||||
hbbr:
|
||||
container_name: hbbr
|
||||
ports:
|
||||
- 21117:21117
|
||||
- 21119:21119
|
||||
image: {{ rustdesk_image }}
|
||||
command: hbbr
|
||||
volumes:
|
||||
- ./hbbr:/root
|
||||
networks:
|
||||
- rustdesk-net
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,51 @@
|
||||
# Domain Docs
|
||||
|
||||
How the engineering skills should consume this repo's domain documentation when exploring the codebase.
|
||||
|
||||
## Before exploring, read these
|
||||
|
||||
- **`CONTEXT.md`** at the repo root, or
|
||||
- **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic.
|
||||
- **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src/<context>/docs/adr/` for context-scoped decisions.
|
||||
|
||||
If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved.
|
||||
|
||||
## File structure
|
||||
|
||||
Single-context repo (most repos):
|
||||
|
||||
```
|
||||
/
|
||||
├── CONTEXT.md
|
||||
├── docs/adr/
|
||||
│ ├── 0001-event-sourced-orders.md
|
||||
│ └── 0002-postgres-for-write-model.md
|
||||
└── src/
|
||||
```
|
||||
|
||||
Multi-context repo (presence of `CONTEXT-MAP.md` at the root):
|
||||
|
||||
```
|
||||
/
|
||||
├── CONTEXT-MAP.md
|
||||
├── docs/adr/ ← system-wide decisions
|
||||
└── src/
|
||||
├── ordering/
|
||||
│ ├── CONTEXT.md
|
||||
│ └── docs/adr/ ← context-specific decisions
|
||||
└── billing/
|
||||
├── CONTEXT.md
|
||||
└── docs/adr/
|
||||
```
|
||||
|
||||
## Use the glossary's vocabulary
|
||||
|
||||
When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
|
||||
|
||||
If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`).
|
||||
|
||||
## Flag ADR conflicts
|
||||
|
||||
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
|
||||
|
||||
> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_
|
||||
@@ -0,0 +1,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)
|
||||
+188
-60
@@ -1,39 +1,103 @@
|
||||
# 内网 DNS 架构调研与优化建议
|
||||
|
||||
> 状态:2026-08-12 调研,Linear **W1N-56**。基于网络工程师视角,方案待实施评审。
|
||||
> 2026-08-12 现场核查修正:gfw 上 mosdns 已并入 OpenClash DNS 链(作为 clash 的
|
||||
> `nameserver`,DIRECT 规则真实 IP 解析用),**不再闲置**;推荐方案(AGH 前端 +
|
||||
> `.36` 伴生 mosdns 后端)仍待评审落地。
|
||||
> 相关:`docs/lan-overview.md`、`hosts/dns.windy.lan.md`、`hosts/gfw.windy.lan.md`。
|
||||
> 状态: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. 现状(实测)
|
||||
## 1. 现状(实测 2026-08-12)
|
||||
|
||||
| 角色 | 部署 | 职责 | 是否在活动路径 |
|
||||
|------|------|------|----------------|
|
||||
| **AdGuard Home** | `192.168.66.36`(PVE VM 120,Docker host 网络) | EdgeRouter DHCP 通告给 LAN55/66 客户端的 DNS;广告/过滤、查询统计、Web 面板 | ✅ **是** |
|
||||
| **mosdns** | `192.168.66.1`(gfw OpenWrt)监听 `127.0.0.1:6052` | OpenClash custom DNS 的 `nameserver`(DIRECT 规则真实 IP 分流:国内→AGH `.36`,国外→国内公网 DNS `223.5.5.5`/`119.29.29.29`) | ✅ 网关侧(clash 消费,不面向客户端) |
|
||||
| **OpenClash / clash(meta)** | `192.168.66.1`(gfw) | gateway 自身/被劫持流量的 fake-ip + 代理,DNS 走 dnsmasq→clash `#7874` | 仅网关侧 |
|
||||
| **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`)。所以 gfw/clash 的 fake-ip 分流对"直连 AGH 的客户端"不起作用;gfw 上 mosdns 作为 clash 的 `nameserver` 供 DIRECT 规则连接的真实 IP 解析用(国内→AGH,国外→国内公网 DNS),不面向 LAN 客户端。
|
||||
**关键事实(全部实测):**
|
||||
- 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**(alidns/doh.pub,兜底 adg.chans.xyz),**没有"国内/国外分流"能力** → 国外域名解析易受 DNS 污染/时延差,也无法为不同 region 选最优上游。
|
||||
2. 国内/国外分流逻辑(geo)与代理分流逻辑(clash fake-ip)混在网关上,职责不清。
|
||||
**现状缺口(实测确认):**
|
||||
1. AGH 上游是固定 DoH,**无"国内/国外分流"能力**;国外域名解析质量依赖唯一兜底路径。
|
||||
2. **兜底失效**(kill-test 证实):主上游黑洞时,兜底 `adg.chans.xyz` 在客户端 15s 窗口内不生效
|
||||
(`upstream_timeout: 10s` + TCP 重试行为),缓存未命中查询无有界降级——见 §8。
|
||||
3. 国外域名 AAAA 经国内路径全部置空(见 §8),v6 解析缺位。
|
||||
|
||||
## 2. 两个候选方案评估
|
||||
## 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 分流 + 防污染支持弱(官方定位是"过滤/家长控制",见 adguard README "Encrypted DNS upstream... requires additional software")。固定 DoH 上游无法按域名 region 选路。→ **不足以解决防污染/分流问题。**
|
||||
- 缺点:AGH 对 geo 分流 + 防污染支持弱(官方定位是"过滤/家长控制")。固定 DoH 上游无法按域名
|
||||
region 选路;且 §8 kill-test 显示主上游全挂时缓存未命中查询无有界降级。→ **不足以解决防污染/分流/降级问题。**
|
||||
|
||||
### 方案 B:mosdns 作为智能上游分流器
|
||||
mosdns(v5)用 `sequence` 编排:`geosite/geoip` 匹配器 → 国内域名转发国内 DoH、国外域名转发加密 DoH(防污染),可加 `cache`、`reject`(屏蔽)。
|
||||
- 优点:真正解决"国内快 / 国外不被污染"的分流;性能高(百万域名表也不卡)。
|
||||
- 缺点:纯转发器,无 Web 面板、无每客户端统计、拦截要靠域名表(不如 AGH 体验)。→ 单独当入口会退回原始体验。
|
||||
mosdns(v5)用 `sequence` 编排:`geosite/geoip` 匹配器 → 国内域名转发国内 DoH、国外域名转发加密 DoH(防污染),可加 `cache`、`reject`。
|
||||
- 优点:真正解决"国内快 / 国外不被污染"的分流;性能高。
|
||||
- 缺点:纯转发器,无 Web 面板、无每客户端统计、拦截靠域名表。→ 单独当入口会退回原始体验。
|
||||
|
||||
**结论:两个方案是互补的,不是二选一。** 单用 A 无法分流防污染,单用 B 失去 AGH 的管理体验。
|
||||
**结论:两个方案互补,不是二选一。** 单用 A 无法分流防污染且降级无界;单用 B 失去 AGH 管理体验。
|
||||
|
||||
## 3. 推荐:分层架构(AGH 前端 + mosdns 后端)
|
||||
## 4. 二期可选项:分层架构(AGH 前端 + mosdns 后端,经实测需求后启用)
|
||||
|
||||
```
|
||||
局域网客户端(DHCP DNS = 192.168.66.36)
|
||||
@@ -44,33 +108,31 @@ AdGuard Home (66.36) ── 前端:广告/过滤、拦截表、每客户端
|
||||
▼
|
||||
mosdns(66.36 伴生容器) ── 后端:智能分流 + 防污染
|
||||
│ - geosite:cn → 国内 DoH/UDP(aliDNS / 腾讯 DNSPod)
|
||||
│ - 其他 → 加密 DoH(Cloudflare/Google/自建 adg.chans.xyz)
|
||||
│ - 其他 → 加密 DoH(自建 adg.chans.xyz 等)
|
||||
▼
|
||||
上游 DoH
|
||||
```
|
||||
|
||||
职责分离,每个工具只做自己最擅长的事:
|
||||
- **AGH = 策略/拦截/可观测**(拦截表、每客户端日志、面板)。AGH 原生干不了"按域名选路",所以不做分流。
|
||||
- **mosdns = 智能转发**(geo 分流 + 加密防污染)。不用它当入口,所以保持 AGH 的 UX。
|
||||
- **OpenClash(gfw)= 代理选路**(fake-ip + 规则决定"哪些流量走代理")。与"DNS 解析选上游"是**两个独立决策**,分开放在不同工具最干净——DNS 解析在 66.36 做,代理路由在网关做,互不耦合。
|
||||
职责分离:
|
||||
- **AGH = 策略/拦截/可观测**(拦截表、每客户端日志、面板)。
|
||||
- **mosdns = 智能转发**(geo 分流 + 加密防污染 + 内置 cache,可显著缩短降级窗口)。
|
||||
- **OpenClash(gfw)= 代理选路**(fake-ip + 规则)。与 DNS 解析选上游是两个独立决策,分开放最干净。
|
||||
|
||||
### 推荐部署位置:mosdns 与 AGH 同机(66.36),而非 gf(.1)
|
||||
**部署位置:mosdns 与 AGH 同机(66.36 伴生容器),而非 gfw(.1)**:单点即 AGH 所在;不受网关重启/
|
||||
OpenClash churn 影响;可纳入现有 compose/ansible 管理;不占用 OpenWrt 资源。放 gfw 会与 clash 的
|
||||
DNS 处理互相干扰、耦合,且网关重启即断全 LAN DNS。**不推荐放 .1。**
|
||||
|
||||
| 位置 | 评价 |
|
||||
|------|------|
|
||||
| **66.36 伴生容器(推荐)** | 单点即 AGH 所在;AGH→mosdns 走本机/近端一跳;不受网关重启/OpenClash churn 影响;可纳入现有 ansible compose 管理;不占用 OpenWrt 资源 |
|
||||
| gf(.1) | 虽近网络边缘,但该网关已有 clash fake-ip + 多种劫持规则,再叠 mosdns 会与 clash 的 DNS 处理互相干扰、耦合;且网关重启即断 DNS(影响整个 LAN)。**不推荐** |
|
||||
> 启用条件(kill-test 实测需求,§8):主上游全挂时,当前 AGH 单入口对缓存未命中查询无有界降级。
|
||||
> 分层方案(或下调 `upstream_timeout` + 改 failover 模式)可修;启用与否由用户在二期决定。
|
||||
|
||||
> 注意:若把 mosdns 放 gfw,必须先理清与 OpenClash `dnsmasq→clash #7874` + `nft fw4 DNS-hijack` 的先后/覆盖关系,否则会出现"部分设备解析走了 clash、部分走了 mosdns"的混乱。放 66.36 则完全避开这个冲突。
|
||||
## 5. 更优替代方案(一并考虑)
|
||||
|
||||
## 4. 更优替代方案(也一并考虑)
|
||||
1. **分层(推荐二期,见 §4)**:AGH(66.36)→ mosdns(66.36 伴生)→ 上游。体验最好、职责最清。
|
||||
2. **纯 mosdns + 前端面板**:损失拦截/统计管理体验。**不推荐**用于替换。
|
||||
3. **AGH 只挂一个带分流的上游(第三方 DoH 聚合)**:失去可控性且不可信。不推荐做主路径。
|
||||
4. **全部交给 OpenClash fake-ip,关闭 AGH**:让"代理网关"成为全 LAN DNS 单点;且 AGH 拦截/日志也没了。**不推荐。**
|
||||
|
||||
1. **分层(推荐,见上)**:AGH(66.36)→ mosdns(66.36 伴生)→ 上游。体验最好、职责最清。
|
||||
2. **纯 mosdns + 前端面板**:不用 AGH,用 mosdns + 其他统计面板。→ 会明显损失拦截/统计管理体验,除非你讨厌 AGH 的 Docker 部署。**不推荐**用于替换。
|
||||
3. **AGH 只挂一个带分流的上游(第三方 DoH 聚合)**:例如接一个已做分流的公共 DoH。→ 失去可控性,且不可信。不推荐做主路径。
|
||||
4. **全部交给 OpenClash fake-ip,关闭 AGH**:把 LAN 客户端 DNS 指到 gfw。→ 让"代理网关"成为全 LAN DNS 单点,网关重启/代理抖动整个内网断网;且 AGH 的拦截/日志也没了。**不推荐。**
|
||||
|
||||
## 5. mosdns 配置要点(mosdns v5,预留实施)
|
||||
## 6. mosdns 配置要点(mosdns v5,二期实施预留)
|
||||
|
||||
核心是 `sequence` + 上游拆分 + 缓存 + 屏蔽:
|
||||
|
||||
@@ -80,39 +142,105 @@ plugins:
|
||||
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 前置拦截,二选一)
|
||||
# - matches: [ qname &./blocklist.txt ]
|
||||
# exec: reject 3
|
||||
# 广告域名屏蔽交 AGH 前置,不重复维护
|
||||
# 其余(国外)→ 加密 DoH 防污染
|
||||
- exec: forward https://1.1.1.1/dns-query
|
||||
# 备选国外上游/兜底
|
||||
- matches: [ has_resp ]
|
||||
exec: accept
|
||||
- exec: forward_addr https://208.67.222.222:443/dns-query
|
||||
- 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` 与 `addr` 做多/故障切换。
|
||||
- `geosite:cn` / `geoip:cn` 数据插件自动从 repo 更新;国内用 aliDNS/腾讯,国外用 DoH(Cloudflare/Google/自建 adg.chans.xyz)。
|
||||
- 屏蔽交由 AGH 前置(推荐),不要 AGH 和 mosdns 都自己维护一套拦截表(重复)。
|
||||
- 上游可加 `upstream` 的 `concurrent > 1` 与多地址故障切换;mosdns 自带 cache,能保证上游故障时
|
||||
缓存命中仍即时应答(对应 §8 认定的降级缺口)。
|
||||
- `geosite:cn` / `geoip:cn` 数据自动更新;国内 aliDNS/腾讯,国外可用自建 `adg.chans.xyz`(实测
|
||||
唯一能返回国外 AAAA 的路径,§8)。
|
||||
- 屏蔽交 AGH 前置,AGH 与 mosdns 不各自维护拦截表。
|
||||
|
||||
## 6. 迁移 / 实施顺序(待评审)
|
||||
## 7. 迁移 / 实施顺序(二期,待用户确认启用)
|
||||
|
||||
1. 在 66.36 起 mosdns 伴生容器(`/opt/mosdns` + compose,固定 digest,纳入 ansible)。
|
||||
2. AGH「上游 DNS 服务器」改为指向 mosdns(`http://127.0.0.1:5353/dns-query` 或 `127.0.0.1:5353`)。AGH 的 `bootstrap` 仍用公网 IP(避免 AGH → mosdns → AGH 死循环)。
|
||||
3. 验证:国内域名(如 `taobao.com`)、国外域名(如 `google.com`)、被拦截域名、每客户端日志。
|
||||
4. 确认后,`disable`/移除 gfw 上闲置的 mosdns(6052)以免混淆。
|
||||
5. 回归:EdgeRouter 通告不变(仍 `.36`),因此 LAN 客户端无感;重启 AGH/mosdns 单点验证。
|
||||
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,确认降级窗口有界。
|
||||
|
||||
## 7. 风险与备注
|
||||
- mosdns 仅监听 `127.0.0.1`(不对外),由 AGH 消费;避免 LAN 直连 mosdns 造成两套入口。
|
||||
- AGH 上游指向本机 mosdns 时,务必配 bootstrap 公网 IP,否则自举死循环。
|
||||
- 本方案不改 EdgeRouter DHCP/通告,不改 gfw OpenClash 代理规则,只动 66.36 上的 DNS 链路,风险可控。
|
||||
- 与 W1N-40「审查并修正 AdGuard Home」联动:该 issue 侧重 AGH 本身,本 issue 侧重整体 DNS 分层。
|
||||
## 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 分层。
|
||||
+18
-3
@@ -51,7 +51,7 @@ from each section below.
|
||||
| **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) |
|
||||
| **haos** | Home Assistant (HAOS) — **PVE VM 180** (LAN55) | — | `192.168.55.11` | — |
|
||||
| **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) |
|
||||
@@ -93,7 +93,7 @@ from each section below.
|
||||
| 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`; `mosdns` also listens on `127.0.0.1:6052` (not the active path) |
|
||||
| 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).
|
||||
@@ -145,6 +145,19 @@ 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 |
|
||||
@@ -165,6 +178,7 @@ AP SSH account is `zhiqiangf` (key-only, verified). See [docs/unifi-network.md](
|
||||
| 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 |
|
||||
|
||||
---
|
||||
@@ -175,9 +189,10 @@ AP SSH account is `zhiqiangf` (key-only, verified). See [docs/unifi-network.md](
|
||||
- [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
|
||||
- [runbooks/adguard-home-health.md](../runbooks/adguard-home-health.md) — AGH health
|
||||
@@ -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-Fi;AirPrint、Mopria、Wireless Direct、Brother Mobile Connect | 20 ppm、150 页进/50 页出、平板 CIS;无 ADF、无自动双面 | TN118 约 1,500 页 + DR118 约 10,000 页,鼓粉分离 | **首选**:功能刚好够用,网络规格最好,自主耗材路径最清晰。两台按价格/现货择一。 |
|
||||
| **Brother DCP-11W** | USB、100M Ethernet、2.4/5 GHz Wi-Fi / Wi-Fi Direct | 20 ppm、150 页纸盒、平板扫描;未列 ADF 或自动双面 | 云充按页;耗材由官方供给 | 只有明确接受微信充值、看重三年保修时才选;不是“便宜传统激光机”。 |
|
||||
| **Pantum M6509NW** | USB、100M Ethernet、2.4 GHz 802.11b/g/n Wi-Fi;自带热点 | 22 ppm、150 页进/100 页出、平板扫描;手动双面 | 鼓粉一体 PD-219,官方标称 1,600 页 | **价格明显更低时的可比替代**。接口齐全,支持扫 PC/邮件/FTP/移动端,但机身较宽、仅 2.4 GHz,且鼓粉一体意味着每次换耗材同时更换感光组件。 |
|
||||
| **HP Laser MFP 1188w** | USB、2.4 GHz 802.11b/g/n、Wi-Fi Direct、AirPrint/Mopria/HP 应用;**无 Ethernet** | 22 ppm、150 页进/100 页出、平板扫描;手动双面 | 一体式黑色硒鼓;随机约 1,500 页 | 仅无线且 2.4 GHz 能满足时再比价。优点是 AirPrint/Mopria 明确、首张页快;不符合“网口或双频”优先条件。 |
|
||||
| **Canon iC MF232w** | Ethernet、2.4 GHz Wi-Fi、AirPrint、网络/移动扫描 | 23 ppm、250 页纸盒、平板扫描;无自动双面 | CRG337 一体式硒鼓 2,400 页 | 纸盒需求确实较大才考虑。官方建议价较高,且产品规格呈现的是较老的 IPv4 / 2.4 GHz 组合,不是本需求下的优先解。 |
|
||||
| **Canon iC MF272dw** | Ethernet、2.4 GHz Wi-Fi、AirPrint/Mopria | 29 ppm、150 页纸盒、平板扫描、自动双面打印 | CRG071:700 页随机、1,200/2,500 页商品硒鼓 | 功能不错但属于为自动双面打印升级;官方建议价 ¥3,838,不适合低量、单面为主时以性价比为目标的采购。 |
|
||||
|
||||
Brother 规格与耗材页数以官方参数表为准;Pantum 的接口、PD-219 和建议月印量 250–2,000 页见[官方产品页](https://www.pantum.cn/product-center/1487019260672548865.html)。HP 的 22 ppm、150 页、仅 Wi-Fi/USB、手动双面和一年保修见[中国官方规格](https://support.hp.com/cn-zh/product/product-specs/hp/2101513893)。Canon MF232w 的 23 ppm、250 页、CRG337、IPv4 和接口见[官方规格](https://www.canon.com.cn/product/icmf232w/spec.html);MF272dw 的 29 ppm、自动双面、接口及 CRG071 页数见[官方规格](https://www.canon.com.cn/product/icmf272dw/spec.html)。
|
||||
|
||||
## 耗材与锁定:应怎样理解
|
||||
|
||||
**不要只用“每页成本”决定低量用户。** 一年只打印几十到几百页时,机器差价、过期/存放不当的耗材风险和购买便利性,通常超过高容量粉盒带来的单位页优势。页产量也是 ISO 覆盖率下的额定值,不等于实际能稳定打印的页数。
|
||||
|
||||
- **传统耗材(Brother L1638W/L1848W)**:粉盒和硒鼓分开,硒鼓寿命远高于单盒粉量;这是长期低量使用中最可预测的结构。原装 TN118 / DR118 的料号和页数已由 Brother 公布。第三方粉盒或灌粉可以降低成本,但不属于厂商性能/保修承诺;低量用户省下的钱很有限,反而更容易把故障归因变复杂。建议首个生命周期使用原装或可靠授权渠道耗材。
|
||||
- **云充(DCP-11W)**:这里的锁定不是“第三方粉盒风险”,而是服务依赖:打印资格、套餐和耗材供给都依赖绑定的微信/官方流程。购买前应把预计三年页数代入套餐,确认账号更换、迁移、停服或转让场景的处理规则;并接受双面一张按两页计。
|
||||
- **一体式硒鼓(Pantum、HP、Canon)**:换粉即换鼓,维护动作简单;缺点是无法像鼓粉分离机那样只更换粉盒。不要据此推断“第三方一定不能用”或“必然会被固件锁死”——厂商公开资料通常只承诺原装耗材效果/保修,兼容耗材的芯片兼容性、质量和售后由销售方承担,应按批次验证。
|
||||
|
||||
## 最终推荐与购买动作
|
||||
|
||||
1. **默认买 Brother DCP-L1638W 或 DCP-L1848W**:选到手价更低、可开票、有本地退换/保修的那个;功能层面无需为 1848 付溢价。
|
||||
2. 若二者断货或溢价过大,**Pantum M6509NW** 是功能不降级的对照品;要求 5 GHz Wi-Fi 时排除它。
|
||||
3. 若只用手机/2.4 GHz Wi-Fi,且 HP 的即时价格有明显优势,才纳入 **HP 1188w**;它没有网口,不能接入现有有线网络。
|
||||
4. **不要因为“最新”买 DCP-11W**,除非云充模式本身是主动选择。对低量家庭用户,耗材自主权通常比三年保修更重要。
|
||||
|
||||
到货后先完成一次有线或基础 Wi-Fi 配网、AirPrint/Windows/macOS 实测、扫描为 PDF、睡眠唤醒和一张双面手动测试;保留试机页与发票。将设备放在受信任 LAN;如果启用 Wi-Fi Direct,设置强口令,平时不需要则关闭。
|
||||
|
||||
## 调研边界
|
||||
|
||||
本表只比较中国市场仍可由厂商官方页面/支持页核实的代表 SKU,价格、实际库存和促销会实时变化,未把电商标价写入结论。所谓“最新”以 Brother 中国目录/公告为准,而非“功能最强”或“最适合”。资料核查日期:2026-08-11。
|
||||
@@ -0,0 +1,74 @@
|
||||
# SE5420 实施评审主张核实(2026-08-10)
|
||||
|
||||
> **核对基准(历史快照):** 本文于 2026-08-10 针对 [lan-se5420-deployment-guide.md](lan-se5420-deployment-guide.md) 的**评审前版本**(`35577d0`)撰写。该指南自 `ffb37a9`("finalize SE5420 deployment guide per review")起已按本评审修订,当前 `origin/main` 章节已重组:旧 §3.3 → §4.3、旧 §6(gfw)→ §11、旧 §7(SSID)→ §12、旧 §9(验收/IPv6)→ §13 + §11.4。文末「当前指南处理情况」列出各主张的现行状态;实施以部署指南现行为准。
|
||||
|
||||
**范围。** 本文核对对 `lan-se5420-deployment-guide.md` 的评审意见。结论分为
|
||||
“已证实”(规范/一手资料直接支持)、“基本证实”(架构推论成立但仍须读取现场配置)和
|
||||
“需现场核实”(不能仅由文档或产品手册断言)。这不是实施变更,也不替代维护窗前的
|
||||
`uci show firewall`、交换机当前 VLAN 表和 PVE bridge 配置检查。
|
||||
|
||||
## 核实结论
|
||||
|
||||
| 评审主张 | 结论 | 依据与限定 |
|
||||
| --- | --- | --- |
|
||||
| `firewall.ubunt_upg.masq=1` 是错误方向,应在实际出站的 `wan` zone 做 IPv4 NAT | **已证实** | OpenWrt 明确规定 masquerade 是**按出站 zone/interface**控制;`masq` 通常在 `wan`。因此,对 `ubunt_upg → wan` 流量把 `masq` 放在源 zone 不是该需求的正确 zone 语义。若 `wan` 已 masq,不应重复开启;也可用 `masq_src` 只限 `192.168.10.0/24`。见 [OpenWrt firewall configuration](https://openwrt.org/docs/guide-user/firewall/firewall_configuration) 和 [fw4 masq 测试](https://lxr.openwrt.org/source/firewall4/tests/02_zones/02_masq)。 |
|
||||
| `ubunt_upg → wan` 允许所有经 gfw `wan` 可路由的目的地,不等于只上互联网 | **已证实** | `forwarding` 的 `src`/`dest` 是 zone-to-zone 单向许可,未按“Internet”语义区分目标 IP;规则可用 `dest_ip` 限制。故若 gfw 的 `wan` 接在 LAN66 且 ER-X 可路由 LAN55,评审所列 LAN66/LAN55 风险成立。最终可达网段仍须以 gfw 路由表、ER-X 路由/防火墙现场检查为准。见 [OpenWrt forwarding/rule 参考](https://openwrt.org/docs/guide-user/firewall/firewall_configuration)。 |
|
||||
| 不应把 `ubunt_upg.forward` 改为 `ACCEPT`;`forward_policy` 不是必要的标准 zone 选项;匿名 `uci add` 不可重复执行 | **基本证实** | OpenWrt zone 的标准项是 `forward`,forwarding 是独立 section,参考页未定义 `forward_policy`。单独的具名 forwarding 足以允许跨 zone 路径,因此保持 zone 内 `forward=REJECT` 是较小权限配置。匿名 section 每执行一次都会新增一节,这是 UCI 的操作语义;实施应先读现场配置并使用具名 section。 |
|
||||
| VLAN10 必须有显式 IPv6 策略,否则可能绕过仅 IPv4 的 NAT/隔离 | **已证实** | fw4 将 `masq`(IPv4)和 `masq6`(IPv6)分开;forwarding 默认 family 是 `any`。仅写 IPv4 DHCP/NAT/地址规则不能表达 VLAN10 的 IPv6 RA、DHCPv6、路由和过滤策略。是否已经存在可用 IPv6 前缀、以及 OpenClash 是否接管 IPv6,必须现场验证。见 [OpenWrt firewall configuration](https://openwrt.org/docs/guide-user/firewall/firewall_configuration)。 |
|
||||
| 管理 SVI + 默认路由与“不开 SVI/静态路由/一切 L3”矛盾 | **已证实** | 指南(历史版)§3.3 同时要求 VLAN66 `192.168.66.253/24` 与默认路由,又要求不开 SVI/静态路由。TL-SE5420 官方称其为三层交换机,支持静态路由、RIP、DHCP server/relay。准确目标应是:仅保留 VLAN66 管理 L3 interface/默认网关,不给 VLAN55/10 建 L3 interface,且禁用不需要的 L3 服务和跨 VLAN routing。见 [TL-SE5420 官方页](https://www.tp-link.com.cn/product_2899.html?v=specification) 与 [官方安装手册](https://service.tp-link.com.cn/download/202310/TL-SE5420%20V1.0%E5%AE%89%E8%A3%85%E6%89%8B%E5%86%8C%201.0.2.pdf)。 |
|
||||
| 必须明确移除 VLAN1 成员,PVID 变更本身不等于 access-port VLAN membership | **已证实** | PVID/native VLAN 只处理进入端口的未标记帧;access/trunk 的允许 VLAN 列表是独立概念。指南(历史版)§3.3 只列 VLAN66/55 member 和 PVID,未写移除 VLAN1 或 ingress filtering。验收应检查 VLAN1 member、VLAN1 管理 IP、端口允许 VLAN 和 tagged-frame ingress policy。见 [Ubiquiti 对 native/tagged/access/trunk 的定义](https://help.ui.com/hc/en-us/articles/26136855808919-Switch-Port-VLAN-Assignment-Trunk-Access-Ports)(术语与 802.1Q 语义)以及 [Linux bridge VLAN 配置示例](https://www.kernel.org/doc/html/v5.19/networking/dsa/b53.html)(显式 `bridge vlan del ... vid 1`)。TL-SE5420 具体 GUI/CLI 行为仍以其固件手册核验。 |
|
||||
| NAS 不应在未先完成双端 LACP 时同时接两口;LACP 不使单 TCP 流自动达到 5G | **基本证实** | 这是标准二层环路/聚合变更控制结论:没有已协商的 LAG 时,两条同 VLAN 并行链路会构成潜在环路;STP 只能作为保护而非实施方法。官方产品页列出 LACP 相关资料,但本次未取得 TL-SE5420/TrueNAS 对端的精确配置与当前 NAS 连接状态,故“必然环路/双 IP”不能在桌面审阅中断言。单连接吞吐受链路散列限制是 802.3ad 的常见实现特性,应以 NAS 与交换机的 hash policy 和 `iperf3` 实测验收。 |
|
||||
| 非 VLAN-aware 的 PVE `vmbr0` 不提供 VLAN10 的端口级隔离 | **已证实** | PVE 将 bridge 描述为虚拟交换机;VLAN-aware mode 才能给 guest NIC 赋 VLAN tag,或显式 trunk。Linux 内核说明:`vlan_filtering=0` 时 bridge 不考虑 VLAN tag,且默认关闭;开启后才按 MAC **和 VLAN tag**转发及进行严格 VID 检查。因此“共享非 VLAN-aware bridge 可让可控 guest 主动消费 VLAN10,不能作为严格隔离边界”成立。不能仅凭该结论断言每个 guest 必定收到每个单播帧:未知单播/广播会泛洪,已学习的单播会按 FDB 转发。见 [PVE 网络配置](https://pve.proxmox.com/wiki/Network_Configuration) 和 [Linux bridge 文档](https://docs.kernel.org/networking/switchdev.html)。 |
|
||||
| AP VLAN10 tagged frame 在一个普通 untagged LAN66 access path 上会“自动去 tag 并泄漏到 LAN66” | **不成立/需改写** | 802.1Q 的 native VLAN 是对**未标记**流量的 VLAN;tagged VLAN 需被显式允许于 trunk。因此通常的正确表述是:若上游不允许 VLAN10 tag,AP 到 VLAN10 网关/DHCP 的路径不存在,SSID 会成为不可用入口。实际设备的端口模式(包括是否错误地配置为 all/trunk、是否接受 tagged ingress)须现场查看,不能泛称必然去标签。见 [Ubiquiti VLAN 端口定义](https://help.ui.com/hc/en-us/articles/26136855808919-Switch-Port-VLAN-Assignment-Trunk-Access-Ports) 和 [Ubiquiti VLAN troubleshooting](https://help.ui.com/hc/en-us/articles/9592924981911-Virtual-Network-VLAN-Troubleshooting)。 |
|
||||
| 仅保留 SSH 会话不是移动 PVE/ER-X 物理上联时的真正带外回滚路径;应全程保持 SE5420 Console | **已证实** | 这是直接的操作依赖判断:TCP SSH 的承载链路被拔除时会断,不能证明回滚可达。TL-SE5420 官方安装手册确认该机有 Type-C Console,且本仓库指南本身也把恢复出厂流程建立在 Console 上。故应在迁移前接通 Console、标注旧/新端口、逐根迁移并用 MAC 表与链路/错误计数验证。见 [官方安装手册](https://service.tp-link.com.cn/download/202310/TL-SE5420%20V1.0%E5%AE%89%E8%A3%85%E6%89%8B%E5%86%8C%201.0.2.pdf)。 |
|
||||
| “所有设备均不得直连 ER-X”不是避免环路的必要条件 | **已证实** | 环路取决于同一 L2 广播域存在多条并行二层路径,不取决于是否还有一个独立终端直接接 ER-X。应禁止的是一个下级交换机/桥接主机同时形成平行路径。此项仍需以 ER-X switch0 VLAN/bridge 现场配置和实际接线图确认。 |
|
||||
| 性能不应承诺全面 2.5G;同 VLAN 才可能在 SE5420 本地交换超 1G,跨 55/66 与 Internet 受 ER-X/宽带限制 | **已证实** | TL-SE5420 的 2.5G 端口仅提高经其本地二层转发的链路上限;跨子网必须由网关路由,Internet 另受 WAN/PPPoE 约束。产品页确认 16×2.5G + 4×10G SFP+,但 ER-X、NAS、PC、AP 的实际协商速率和 NIC/布线能力必须由 `ethtool`/端口状态及 `iperf3` 验证。见 [TL-SE5420 官方规格](https://www.tp-link.com.cn/product_2899.html?v=specification)。 |
|
||||
|
||||
## 已核对的文档内事实
|
||||
|
||||
现行指南的**历史版本**(`35577d0`)确实包含评审指出的关键文字:旧 §3.3 的管理 IP/默认路由与“不开 SVI/静态路由”;旧 §6 的 `ubunt_upg.masq`、`forward=ACCEPT`、`forward_policy`、匿名 forwarding;旧 §7 只验“不可达 LAN66”;旧 §9 对新增 VLAN10 写“IPv6 行为与升级前一致”。因此上述评审不是对未出现内容的假设。
|
||||
|
||||
但这些内容在 `ffb37a9` 起的修订中已被修正或重组:`ubunt_upg.masq` 与 `forward_policy` 已删除,gfw 防火墙改为 §11(§11.3 第 2 步明确“不要给 `ubunt_upg` zone 加 masq”);VLAN10 IPv6 在 §11.4 显式写为“本阶段不提供”;SVI/L3 边界在 §4.3 第 16 步单列“L3 明确边界检查”。本文按历史快照保留评审结论,读者应以现行部署指南为准。
|
||||
|
||||
本仓库的 `hosts/gfw.windy.lan.md` 还记录 gfw 的 `eth0` 在 LAN66、`eth1` 在 LAN55,故 `ubunt_upg → wan` 的隔离结论应在执行前以当前 `ip route`、`uci show firewall`、`nft list ruleset` 复核,而不能从方案文字直接把规则写死。
|
||||
|
||||
## gfw 现场只读复核(2026-08-10)
|
||||
|
||||
已通过 `ssh -4 root@192.168.66.1` 仅读取配置和运行规则,未修改设备。该结果会改变
|
||||
评审中两项“当前状态”的表述:
|
||||
|
||||
| 现场事实 | 对评审的影响 |
|
||||
| --- | --- |
|
||||
| `wan` zone 已有 `masq='1'`;现有配置另有具名 `ubunt_upg_nat`,运行时渲染为 `oifname "eth0"` 且只匹配 `ip saddr 192.168.10.0/24 masquerade`。 | “必须在 wan 开 masq”的**方向原则**正确,但“当前无 masq”不正确。现有显式 SNAT 已在实际出 `eth0` 时执行;计划中再将 `masq` 加到 `ubunt_upg` 仍是多余且方向错误。 |
|
||||
| 当前放行是具名 `ubunt_upg_to_lan`,不是 `ubunt_upg→wan`;其运行链先拒绝 `192.168.66.0/24`,再允许到 `lan`。gfw 的 IPv4 default route 是 `192.168.66.254`。 | 计划新增 `ubunt_upg→wan` 会是与当前设计不同、过宽的改动。现有 LAN66 阻断规则在该链中先匹配;但对经 ER-X 可达的 LAN55/其他内网仍没有显式拒绝,故隔离评审的**剩余风险成立**。应以明确内网前缀 deny + 所需外网 allow 重写,而不是加 WAN forwarding。 |
|
||||
| `ubunt_upg` DHCPv6 和 RA 都是 `disabled`;运行路由表仅有各接口的 IPv6 link-local route,没有 IPv6 default route;全局 IPv6 forwarding 是 `1`。 | 评审“VLAN10 未明确 IPv6 策略”的表述对计划文本仍成立,但“IPv6 可能立即绕过”的事实判断在当前状态**未获证实**:现有 RA/DHCPv6 已关闭且无 IPv6 默认路由。实施文档仍应把这项显式写为“IPv6 不提供”,并在启用前复查。 |
|
||||
| 系统是 ImmortalWrt **25.12.0**,`/usr/bin/apk` 存在(apk-tools 3.0.5)。 | 评审中“ImmortalWrt 21.02.5 应使用 opkg”的版本判断错误/过时;在本机上 `apk add tcpdump` 是可用包管理器。仍应先检查软件包可用性,避免在维护文档中把两种命令并列为未经验证的替代方案。 |
|
||||
|
||||
这些命令输出未含凭据、令牌或私钥,故仅记录了安全相关的摘要;不将完整防火墙快照提交至仓库。
|
||||
|
||||
## 当前指南处理情况(2026-08-13 核对)
|
||||
|
||||
对 `origin/main`(`2fd354c`)逐项核对评审主张:
|
||||
|
||||
| 评审主张 | 现行状态 | 现行位置 |
|
||||
| --- | --- | --- |
|
||||
| `ubunt_upg.masq=1` 方向错误 | ✅ 已修复 | §11.3 第 2 步「不要给 `ubunt_upg` zone 加 masq」 |
|
||||
| `ubunt_upg→wan` 不等于只上互联网 | ⚠️ 原则成立,指南已禁止新增宽泛 forwarding;LAN55/RFC1918 显式 deny 仍为待办 | §11.1b「待补缺口」、§11.3 |
|
||||
| 勿改 `forward=ACCEPT`;无 `forward_policy`;匿名 uci 不可重复 | ✅ 已修复 | §11.3 第 1 步保持 REJECT;指南已无 `forward_policy` |
|
||||
| VLAN10 须显式 IPv6 策略 | ✅ 已修复 | §11.4「本阶段不提供 VLAN10 IPv6」 |
|
||||
| 管理 SVI + 默认路由 vs「不开一切 L3」矛盾 | ✅ 已消解 | §4.3 第 16 步「L3 明确边界检查」 |
|
||||
| 须移除 VLAN1 成员;PVID≠membership | ✅ 指南已加强;现网仍偏离(W1N-54) | §4.3 第 9–12 步;VLAN1 不可删说明 |
|
||||
| NAS 双口未 LACP 前勿并行 | ✅ 已体现 | §7 第 7 步「仅口 8,口 12 断开」 |
|
||||
| 非 VLAN-aware PVE bridge 不能作隔离边界 | ✅ 已体现 | §9.2 要求 VLAN-aware + `bridge-vids` |
|
||||
| AP tagged 帧在 access 口自动去 tag | ✅ 本文已纠正(不成立) | — |
|
||||
| SSH 非真正带外;须 Console | ✅ 已体现 | 开头第 2 条、§4.1、§16 |
|
||||
| 「所有设备不得直连 ER-X」非必要 | ✅ 已体现 | 全程三条第 1 条、§7 |
|
||||
| 不应承诺全面 2.5G | ✅ 已体现 | §8 第 7 条、§14 |
|
||||
|
||||
## 实施前的最低限度现场证据
|
||||
|
||||
1. gfw:保存并审阅 `uci show firewall`、`ip route`、`ip -6 route`、`nft list ruleset`;确认 wan 的 masq 与所有 WAN→内网、VLAN10→内网匹配次序。
|
||||
2. SE5420 Console:导出/截图 VLAN1、55、66 member 和 PVID/ingress-filter 状态;确认唯一管理 L3 interface 和路由/relay/DHCP 状态。
|
||||
3. PVE:记录 `/etc/network/interfaces`、VM NIC VLAN tags 和 `bridge vlan show`,再决定是否把 VLAN-aware 改造另开窗口。
|
||||
4. AP:从实际设备 `info` 或控制器记录确认 Inform URL(本仓库目前记录 `http://192.168.66.46:9080/inform`),并验证 VLAN10 tag 只经 U6/PVE trunk。
|
||||
5. NAS:单网口稳定后,另窗配置并验证两端 LACP,第二根线最后插入;用多流及单流 `iperf3` 分开验收。
|
||||
@@ -30,6 +30,36 @@ OpenClash runs `/etc/openclash/clash` (clash_meta core) with configuration
|
||||
(`/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)
|
||||
|
||||
|
||||
@@ -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
@@ -56,7 +56,8 @@ See full shape in [docs/pdns-upstream.md](../docs/pdns-upstream.md). Live secret
|
||||
| `poweradmin` | poweradmin | Up (healthy) | `poweradmin/poweradmin:stable` |
|
||||
| `pdns_pgweb` | pgweb | Up | `sosedoff/pgweb:0.16.2` |
|
||||
| `pdns-backup` | backup | Up | `postgres:16` (scheduler) |
|
||||
| `powerdns-admin` | *(orphan)* | Exited | legacy PDA UI — not in active compose |
|
||||
|
||||
> Legacy PDA UI container `powerdns-admin` (orphan, Exited) was removed 2026-08-12 (W1N-59).
|
||||
|
||||
### Network model
|
||||
|
||||
@@ -100,9 +101,60 @@ See full shape in [docs/pdns-upstream.md](../docs/pdns-upstream.md). Live secret
|
||||
|
||||
**Quirk:** `backend` is internal — backup must not use Alpine + runtime `apk`/`crond`. Uses `postgres:16` + `backup-scheduler.sh` (fixed 2026-08-01).
|
||||
|
||||
## Other software on this host (stubs)
|
||||
## RustDesk Server
|
||||
|
||||
`/opt/traefik`, `/opt/adguard`, `/opt/remark42`, `/opt/rustdesk`, `/opt/nginx-manager`, …
|
||||
**Status: operational** (hbbs + hbbr Up; image pinned `1.1.14`; relay address fixed 2026-08-12, W1N-59).
|
||||
|
||||
| Item | Value |
|
||||
|------|--------|
|
||||
| Install path | `/opt/rustdesk` |
|
||||
| Compose | `/opt/rustdesk/compose.yml` |
|
||||
| Containers | `hbbs` (rendezvous), `hbbr` (relay) |
|
||||
| Image | `rustdesk/rustdesk-server:1.1.14` (pinned) |
|
||||
| Relay (hbbr) | `hk2.chans.xyz:21117` — advertised to clients via `hbbs -r` |
|
||||
| Rendezvous (hbbs) | `21115/tcp` (NAT test), `21116/tcp+udp`, `21118/tcp` (ws) |
|
||||
| Relay (hbbr) | `21117/tcp`, `21119/tcp` (ws) |
|
||||
| Public IP | `154.36.174.161` |
|
||||
| Health | [runbooks/rustdesk-health.md](../runbooks/rustdesk-health.md) |
|
||||
|
||||
**Note:** the relay hostname in `hbbs -r` must resolve to this host's public IP
|
||||
(`154.36.174.161`). `hk2.chans.xyz` resolves correctly; the previously used
|
||||
`hk2.wsvc.info` had **no DNS record** and broke relay connectivity for clients
|
||||
(fixed 2026-08-12, W1N-59).
|
||||
|
||||
## Other software on this host (confirmed 2026-08-12)
|
||||
|
||||
Verified live via `docker ps` / port scan. Each runs as a separate compose
|
||||
project under `/opt/<name>` and is fronted by Traefik where noted.
|
||||
|
||||
| Service | Path | Container(s) | Image | Ports / notes |
|
||||
|---------|------|--------------|-------|---------------|
|
||||
| Traefik | `/opt/traefik` | `traefik` | `traefik:v3.6.2` | `80`, `443` (TLS entry), `8080` (dashboard) |
|
||||
| AdGuard Home | `/opt/adguard` | `adguardhome` | `adguard/adguardhome:latest` | DoH `5443`, DoT `853` (bridge; no LAN `:53`) |
|
||||
| Remark42 | `/opt/remark42` | `remark42` | `ghcr.io/umputun/remark42:latest` | no host ports; via Traefik (in-container `8080`) |
|
||||
|
||||
### Traefik dashboard auth
|
||||
|
||||
| Item | Value |
|
||||
|------|-------|
|
||||
| Dashboard URL | `https://npm.chans.xyz` (Traefik `api@internal` router), also host `:8080` |
|
||||
| Auth | HTTP Basic via Traefik `basicauth` middleware (label `dashboard-auth`) |
|
||||
| User | `windy` — stored as a **bcrypt** hash (plaintext never stored) |
|
||||
| Hash generator | `/opt/traefik/generate-dashboard-auth.sh` (bcrypt; auto `$`→`$$` compose escaping) |
|
||||
| Config | `/opt/traefik/compose.yml` (label `traefik.http.middlewares.dashboard-auth.basicauth.users`) |
|
||||
|
||||
**Password rotated 2026-08-12** from apr1/MD5 to bcrypt via the generator script; the
|
||||
plaintext lives only in the operator's password manager, never in this repo.
|
||||
To rotate again: `cd /opt/traefik && ./generate-dashboard-auth.sh windy`, paste the
|
||||
printed label into `compose.yml`, then `docker compose up -d --force-recreate traefik`.
|
||||
|
||||
`/opt/nginx-manager` was a leftover (compose + `data/` + `letsencrypt/`, no running
|
||||
container) and was **removed 2026-08-12**; pre-deletion backup:
|
||||
`/opt/backups/nginx-manager-20260812.tar.gz`.
|
||||
|
||||
Health coverage: these auxiliary services are checked by the `hk2aux`
|
||||
health-check profile (`ansible/roles/healthcheck`). Run:
|
||||
`cd ansible && ansible-playbook playbooks/health-report.yml --limit powerdns`.
|
||||
|
||||
## Ops / runbooks
|
||||
|
||||
|
||||
+133
-1
@@ -9,10 +9,74 @@
|
||||
| Compose file | `/opt/wireguard/compose.yml` |
|
||||
| Container | `wireguard` |
|
||||
| Image policy | Immutable digest, updated only in an approved maintenance window |
|
||||
| Public port | UDP `51820` on IPv4 and IPv6 |
|
||||
| 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
|
||||
@@ -21,6 +85,17 @@
|
||||
- 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
|
||||
@@ -35,6 +110,23 @@ 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:
|
||||
@@ -59,3 +151,43 @@ ansible-playbook playbooks/wireguard-harden.yml --limit wireguard \
|
||||
- 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.
|
||||
|
||||
@@ -27,6 +27,7 @@ the software deployed there, see [the LAN overview](../docs/lan-overview.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.
|
||||
|
||||
|
||||
@@ -60,6 +60,30 @@ ansible-playbook playbooks/maintenance-preview.yml
|
||||
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`,
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
Executable
+180
@@ -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'
|
||||
Reference in New Issue
Block a user