Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d95f87897 | ||
|
|
b61513c93e | ||
|
|
c7dc4fd25c | ||
|
|
7bc7d3f99b | ||
|
|
fea9a6560f | ||
|
|
a95b626636 | ||
|
|
27fe9c078e | ||
|
|
aaa4ee312e | ||
|
|
6b298491a8 | ||
|
|
32631e2996 | ||
|
|
1426b4ecfe | ||
|
|
1f5e58bf17 | ||
|
|
e5819eeba3 | ||
|
|
079332e082 | ||
|
|
7cedba7f51 | ||
|
|
343c5db415 | ||
|
|
885d977531 | ||
|
|
b0c01b2551 | ||
|
|
047ac03346 | ||
|
|
1ec9246156 | ||
|
|
1936b8f5fe | ||
|
|
eda6536ddb | ||
|
|
1dc880362d | ||
|
|
70aea6cd72 | ||
|
|
8303d78caf | ||
|
|
ebfe7b8488 | ||
|
|
88eaefda33 | ||
|
|
fcb76d3d5a | ||
|
|
6ae835037b |
@@ -1,52 +0,0 @@
|
||||
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 ./...
|
||||
@@ -1,10 +0,0 @@
|
||||
# Go build outputs
|
||||
/bin/
|
||||
/dist/
|
||||
*.exe
|
||||
*.test
|
||||
*.out
|
||||
|
||||
# Local development
|
||||
.env
|
||||
.DS_Store
|
||||
@@ -1,202 +0,0 @@
|
||||
|
||||
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.
|
||||
@@ -1,159 +0,0 @@
|
||||
# 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).
|
||||
@@ -1,90 +0,0 @@
|
||||
---
|
||||
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.
|
||||
@@ -1,184 +0,0 @@
|
||||
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]")
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
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
|
||||
@@ -1,4 +0,0 @@
|
||||
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=
|
||||
@@ -1,229 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
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"`
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package edns
|
||||
|
||||
import "encoding/base64"
|
||||
|
||||
func decodeGETQuery(value string) ([]byte, error) {
|
||||
return base64.RawURLEncoding.DecodeString(value)
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,58 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,40 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,82 +0,0 @@
|
||||
{
|
||||
"$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
|
||||
}
|
||||
@@ -19,6 +19,8 @@ facts/
|
||||
.agents/
|
||||
.claude/
|
||||
.omp/
|
||||
.opencode/
|
||||
.zcode/
|
||||
.mcp.json
|
||||
WATCHDOG.yml
|
||||
skills-lock.json
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
repos:
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: validate-repo
|
||||
name: validate repository
|
||||
entry: scripts/validate-repo.sh
|
||||
language: system
|
||||
pass_filenames: false
|
||||
@@ -2,37 +2,54 @@
|
||||
|
||||
This repo is the **agent ops handbook + fact source** for maintaining personal VPS hosts. Prefer verifying live state over assuming docs are complete.
|
||||
|
||||
Also readable as `agent.md` (symlink → this file).
|
||||
|
||||
## How to work
|
||||
|
||||
1. Read [`inventory/hosts.md`](inventory/hosts.md) for the machine list.
|
||||
2. Open the matching [`hosts/<name>.md`](hosts/) for SSH, roles, paths, and quirks.
|
||||
3. For common tasks, follow a runbook under [`runbooks/`](runbooks/).
|
||||
3. For common tasks, follow a runbook under [`runbooks/`](runbooks/). Pick the
|
||||
most specific applicable one from [`runbooks/README.md`](runbooks/README.md);
|
||||
the spec is [`RUNBOOKS.md`](RUNBOOKS.md) and new runbooks start from
|
||||
[`runbooks/_template.md`](runbooks/_template.md).
|
||||
4. Prefer read-only checks first; change only after confirming current state.
|
||||
5. For routine checks and approved service reconciliation, run the matching
|
||||
Ansible playbook from `ansible/`; see [routine Ansible operations](runbooks/ansible-operations.md).
|
||||
6. Default SSH access (`ssh -4 windy@<host>`) is for focused diagnostics,
|
||||
imperative upstream procedures, and incident work. Prefer **IPv4** from this
|
||||
WSL client (AAAA often exists but IPv6 route does not).
|
||||
|
||||
> **Agent sandbox SSH quirk (verified 2026-08-20):** the agent shell runs in
|
||||
> a sandboxed user namespace — system files such as
|
||||
> `/etc/ssh/ssh_config.d/20-systemd-ssh-proxy.conf` appear owned by `nobody`,
|
||||
> so plain `ssh` aborts with `Bad owner or permissions on ...`. Always use
|
||||
> `ssh -F /dev/null` from the agent shell and pass options explicitly
|
||||
> (`~/.ssh/config` is skipped; e.g. `ssh -F /dev/null -p 2222
|
||||
> -i ~/.ssh/id_ed25519 windy@repo.windy.me`). `sudo` never works in the
|
||||
> sandbox (`NoNewPrivs`, no capabilities, `/` read-only). The host itself is
|
||||
> healthy — to inspect or act on the real host from the sandbox use
|
||||
> `/mnt/c/WINDOWS/system32/wsl.exe -u root -- <cmd>` (real root: keep
|
||||
> read-only unless a change is approved).
|
||||
|
||||
7. Record each material VPS operation, incident, configuration change, or
|
||||
verification outcome in the corresponding **Linear `vps` project**. Include
|
||||
scope, action, verification, and remaining follow-up; never put passwords,
|
||||
tokens, private keys, recovery keys, or private room IDs in Linear.
|
||||
|
||||
## Active hosts (quick map)
|
||||
### Runbook execution rules
|
||||
|
||||
| Host | Role | SSH | Facts |
|
||||
|------|------|-----|--------|
|
||||
| **mx2.windy.me** | mailcow (`/opt/mail`, project `cow`) | `ssh -4 windy@mx2.windy.me` | [hosts/mx2.windy.me.md](hosts/mx2.windy.me.md) |
|
||||
| **us2.wsvc.info** | Vaultwarden + Traefik (+ Soft Serve, …) | `ssh -4 windy@us2.wsvc.info` | [hosts/us2.wsvc.info.md](hosts/us2.wsvc.info.md) |
|
||||
| **hk2.chans.xyz** | PowerDNS auth ns1 (`/opt/pdns`) | `ssh -4 windy@hk2.chans.xyz` | [hosts/hk2.chans.xyz.md](hosts/hk2.chans.xyz.md) |
|
||||
| **synapse.chans.xyz** | Matrix ESS (Synapse + MAS + Element) on K3s | `ssh -4 windy@synapse.chans.xyz` | [hosts/synapse.chans.xyz.md](hosts/synapse.chans.xyz.md) |
|
||||
| **dns.windy.lan** | AdGuard Home LAN DNS + Mihomo explicit proxy | `ssh -4 windy@192.168.66.36` | [hosts/dns.windy.lan.md](hosts/dns.windy.lan.md) |
|
||||
| **gfw.windy.lan** | OpenWrt LAN gateway / OpenClash | `ssh -4 root@192.168.66.1` | [hosts/gfw.windy.lan.md](hosts/gfw.windy.lan.md) |
|
||||
| **gw** | EdgeRouter X primary LAN gateway | `ssh -4 zhiqiang@192.168.66.254` | [hosts/gw.md](hosts/gw.md) |
|
||||
| **ubnt** | UniFi Network Controller | `ssh -4 windy@192.168.66.46` | [hosts/ubnt.md](hosts/ubnt.md) |
|
||||
| **hass.windy.lan** | Home Assistant (HAOS, LAN55) | `ssh hassio@hass.windy.lan` | [hosts/hass.windy.lan.md](hosts/hass.windy.lan.md) |
|
||||
Before operational work: inspect `runbooks/`, select the most specific
|
||||
applicable runbook, follow its steps in order, do not skip verification steps,
|
||||
and respect its STOP and approval conditions. If no runbook applies, diagnose
|
||||
only — do not mutate production state. When live state conflicts with a
|
||||
runbook's assumptions, `STOP` and report; never invent missing parameters or
|
||||
bypass failed checks. The spec is [`RUNBOOKS.md`](RUNBOOKS.md).
|
||||
|
||||
## Active hosts
|
||||
|
||||
The canonical machine list (roles, SSH endpoints, Ansible coverage, status) is
|
||||
[`inventory/hosts.md`](inventory/hosts.md) — the single human-readable source
|
||||
of truth. Per-host facts live in [`hosts/`](hosts/). The Ansible execution
|
||||
inventory is [`ansible/inventory/hosts.yml`](ansible/inventory/hosts.yml). Do
|
||||
not maintain a second copy of the machine table here.
|
||||
|
||||
### Public services
|
||||
|
||||
@@ -77,11 +94,20 @@ Also readable as `agent.md` (symlink → this file).
|
||||
| RustDesk health (hk2) | [runbooks/rustdesk-health.md](runbooks/rustdesk-health.md) |
|
||||
| AdGuard Home health | [runbooks/adguard-home-health.md](runbooks/adguard-home-health.md) |
|
||||
| Home Assistant maintenance | [runbooks/home-assistant-maintenance.md](runbooks/home-assistant-maintenance.md) + [scripts/ha-maintenance.sh](runbooks/scripts/ha-maintenance.sh) |
|
||||
| matrix_e2ee update (hass.windy.lan) | [runbooks/matrix-e2ee-update.md](runbooks/matrix-e2ee-update.md) |
|
||||
| Matrix upstream refs | [docs/matrix-upstream.md](docs/matrix-upstream.md) |
|
||||
| Hermes Agent Matrix channel | [docs/hermes-matrix.md](docs/hermes-matrix.md) |
|
||||
| UniFi local-service proxy bypass | [docs/unifi-openclash-localhost.md](docs/unifi-openclash-localhost.md) |
|
||||
| UniFi SSO login setting (Ansible) | `cd ansible && ansible-playbook playbooks/unifi-sso.yml --limit unifi` |
|
||||
| Routine Ansible operations | [runbooks/ansible-operations.md](runbooks/ansible-operations.md) |
|
||||
| Issue → mergeable change | [runbooks/issue-to-merge.md](runbooks/issue-to-merge.md) |
|
||||
| Fix failing health/playbook run | [runbooks/fix-ci.md](runbooks/fix-ci.md) |
|
||||
| Release a reviewed change | [runbooks/release.md](runbooks/release.md) |
|
||||
| Roll back a change | [runbooks/rollback.md](runbooks/rollback.md) |
|
||||
| Controlled network change | [runbooks/network-change.md](runbooks/network-change.md) |
|
||||
| Network outage recovery | [runbooks/network-recovery.md](runbooks/network-recovery.md) |
|
||||
|
||||
Full index: [runbooks/README.md](runbooks/README.md). Spec: [RUNBOOKS.md](RUNBOOKS.md).
|
||||
|
||||
Routine mailcow health: `cd ansible && ansible-playbook playbooks/health-report.yml --limit mailcow`. The local stub resolver is flaky; DNS probes use `1.1.1.1` / `8.8.8.8`.
|
||||
|
||||
@@ -97,7 +123,8 @@ Default triage labels: needs-triage, needs-info, ready-for-agent, ready-for-huma
|
||||
|
||||
### Domain docs
|
||||
|
||||
Single-context layout: `CONTEXT.md` + `docs/adr/` at the repo root. See `docs/agents/domain.md`.
|
||||
Domain-documentation conventions, including lazily created `CONTEXT.md` and
|
||||
`docs/adr/` entries when needed, are described in [`docs/agents/domain.md`](docs/agents/domain.md).
|
||||
|
||||
## Safety
|
||||
|
||||
@@ -130,9 +157,13 @@ Bills, rough notes, and personal clutter stay in the Obsidian vault. This repo h
|
||||
## Layout
|
||||
|
||||
```
|
||||
AGENTS.md / agent.md # this entry (agent.md → AGENTS.md)
|
||||
inventory/hosts.md # machine index
|
||||
AGENTS.md # this entry
|
||||
RUNBOOKS.md # runbook spec (six-field model, naming, review rules)
|
||||
inventory/hosts.md # machine index (human-readable source of truth)
|
||||
ansible/ # playbooks, roles, sanitized control-plane inventory
|
||||
compose/ # repo-owned non-secret Compose sources (+ .env.example)
|
||||
hosts/ # per-host facts
|
||||
runbooks/ # step-by-step ops
|
||||
docs/ # upstream doc indexes / design notes
|
||||
runbooks/ # step-by-step ops (README.md = index, _template.md = template)
|
||||
docs/ # upstream refs / design notes / research records (active + archive/)
|
||||
scripts/validate-repo.sh # repo-wide validation (run before merging)
|
||||
```
|
||||
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
# RUNBOOKS — 仓库级规范
|
||||
|
||||
本文件统一所有 Runbook 的字段、命名、评审与变更规则。上游参考:[docs/archive/agent-runbook-guide.md](docs/archive/agent-runbook-guide.md)。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
runbooks/
|
||||
├── README.md # 意图 → 文件 路由索引(本目录的入口)
|
||||
├── _template.md # 新建 runbook 的标准模板(复制后填写)
|
||||
├── <intent>.md # 每份 runbook 只描述一种可识别的操作意图
|
||||
└── ...
|
||||
```
|
||||
|
||||
## 最小字段模型
|
||||
|
||||
每份 runbook 必须显式包含以下控制信息,否则盲目执行或错误恢复的风险会升高:
|
||||
|
||||
| 字段 | 作用 | 写作要求 |
|
||||
|---|---|---|
|
||||
| **Action** | 定义当前要执行的动作 | 可观察、可执行的动词;避免“检查一下”“适当调整” |
|
||||
| **Expected** | 描述正常状态或预期输出 | 具体信号、阈值、状态码、测试结果或页面表现 |
|
||||
| **Decision** | 定义分支与下一跳 | “条件 → 下一步”;无法判断时指向 `STOP` |
|
||||
| **Verification** | 确认变更真正生效 | 每个有副作用的步骤后执行,不可跳过 |
|
||||
| **Stop condition** | 规定何时不得继续 | 列出信息缺失、状态冲突、权限不足、验证失败等 |
|
||||
| **Rollback** | 如何恢复到变更前状态 | 触发条件、前提、撤销步骤、回滚后验证 |
|
||||
|
||||
> 只读类 runbook 不产生副作用,可省略 Rollback;但必须保留 Stop condition(状态与预期冲突即 `STOP` 并记录证据)。
|
||||
|
||||
**只读类变体(read-only variant)**:只读 runbook(health 类、参考类)不强制
|
||||
六字段模型,但必须包含以下最小结构,否则不视为达标:
|
||||
|
||||
- `## Purpose`(1–2 行)+ `## Scope`(适用/不适用)
|
||||
- `## Safety` 或等效章节,其中**必须**含显式 Stop condition(状态与预期冲突即
|
||||
`STOP` 并记录证据;不得在执行中自行"顺手修复")
|
||||
- 只读健康类另含可观察的 `## Pass criteria`(或等效的 Expected 信号)
|
||||
- 每份 runbook 顶部/元信息区必须标注 `Last reviewed: <YYYY-MM-DD>`
|
||||
|
||||
## 命名与拆分规则
|
||||
|
||||
- 文件名采用小写连字符,反映**操作意图**而非目标主机,例如 `mailcow-health.md`、`release.md`。
|
||||
- 一份文件只描述一种意图。流程出现明显分叉时拆分为独立文件,不堆叠“万能流程”。
|
||||
- 只读诊断与变更操作应分离:health 类 runbook 保持只读,变更走 `ansible-operations.md`、`release.md`、`rollback.md` 或对应 gated playbook。
|
||||
|
||||
## 章节约定
|
||||
|
||||
- 每份 runbook 顶部含 `## Purpose`(1–2 行)与 `## Scope`(适用/不适用情形)。
|
||||
- 变更型 runbook 必须记录明确的审批门:门控命令式使用 `## Approval gates` 表;
|
||||
流程式在步骤中记录审批动作、证据位置和未批准时的 `STOP`。破坏性/不可逆操作必须获得明确批准。
|
||||
- 语言约定:**runbook 正文统一使用英文**(由 agent 逐字执行,降低二义性);
|
||||
元规范文件(AGENTS.md / RUNBOOKS.md / 模板注释)可保留中文。
|
||||
- 变更型 runbook 的两种形态:
|
||||
- **流程式(Procedure 型)**:使用六字段模型,适用多分支/多步骤变更
|
||||
(现有:`fix-ci.md`、`issue-to-merge.md`、`network-change.md`、
|
||||
`network-recovery.md`、`release.md`、`rollback.md`)。
|
||||
- **门控命令式(gated command reference)**:已稳定、低歧义、可验证的
|
||||
操作以命令集 + 门控呈现(现有:`mailcow-update.md`、
|
||||
`ansible-operations.md`、`home-assistant-maintenance.md`、
|
||||
`matrix-e2ee-update.md`、`vaultwarden-sqlite-to-postgres.md`),必须含 Approval gates 或确认变量
|
||||
要求 + 显式 STOP,不替代流程式形态。新写的变更 runbook 默认用流程式。
|
||||
- 统一在 `## Safety` 或正文中复用以下通用安全规则(更严格要求优先)。
|
||||
|
||||
```markdown
|
||||
## Safety Rules
|
||||
|
||||
- Never delete an existing configuration as the first recovery action.
|
||||
- Prefer read-only diagnosis before mutation.
|
||||
- After every mutation, verify the expected state.
|
||||
- If actual state conflicts with this runbook, STOP.
|
||||
- Do not invent missing parameters.
|
||||
- Do not bypass failed tests.
|
||||
- Destructive actions require explicit approval.
|
||||
```
|
||||
|
||||
## 评审与变更规则
|
||||
|
||||
- 新建/修改 runbook 与代码同仓评审,随系统演进更新。
|
||||
- 每份 runbook 标注 `Last reviewed`;流程执行过程中发现的偏差记入对应的 Linear `vps` 项目 issue。
|
||||
- 破坏性流程(迁移、删除、DNS 变更、网络变更)保持人工审批,不自动下沉。
|
||||
|
||||
## 成熟路径
|
||||
|
||||
1. **人工处理** → 现场处置与复盘,记录证据。
|
||||
2. **Markdown runbook** → 固化步骤与证据要求,Agent 可辅助诊断。
|
||||
3. **Agent + runbook** → 严格按流程执行,受 Stop/Approval 约束。
|
||||
4. **Script / Ansible / Skill** → 把已稳定、低歧义、可验证的操作程序化(本仓库的执行层是 Ansible playbook)。
|
||||
5. **人工审批 + 自动执行** → 审批门控下的自动变更(如 gated playbook + 确认变量)。
|
||||
|
||||
原则:先证据后变更,先小范围后扩大,先验证后结束,不确定则停止。
|
||||
@@ -11,3 +11,8 @@ host_key_checking = True
|
||||
become = True
|
||||
become_method = sudo
|
||||
become_ask_pass = False
|
||||
|
||||
[ssh_connection]
|
||||
# Keep SSH control sockets inside the repo (gitignored .ansible/) so playbook
|
||||
# runs work in sandboxed/CI environments without touching ~/.ansible.
|
||||
ssh_args = -C -o ControlMaster=auto -o ControlPersist=60s -o ControlPath=.ansible/ssh-control/%h-%p-%r
|
||||
|
||||
@@ -15,6 +15,7 @@ all:
|
||||
mx2:
|
||||
ansible_host: mx2.windy.me
|
||||
ansible_host_ipv4: 194.163.160.244
|
||||
display_name: mx2.windy.me
|
||||
service_role: mailcow
|
||||
compose_project_dir: /opt/mail
|
||||
healthcheck_profiles: [mailcow]
|
||||
@@ -24,8 +25,11 @@ all:
|
||||
us2:
|
||||
ansible_host: us2.wsvc.info
|
||||
ansible_host_ipv4: 193.9.44.165
|
||||
display_name: us2.wsvc.info
|
||||
service_role: vaultwarden
|
||||
compose_project_dir: /opt/vaultwarden
|
||||
compose_repo_project: vaultwarden
|
||||
compose_remote_file: docker-compose.yml
|
||||
healthcheck_profiles: [vaultwarden]
|
||||
restic_backup_profile: vaultwarden
|
||||
service_reconcile_services:
|
||||
@@ -35,8 +39,11 @@ all:
|
||||
hk2:
|
||||
ansible_host: hk2.chans.xyz
|
||||
ansible_host_ipv4: 154.36.174.161
|
||||
display_name: hk2.chans.xyz
|
||||
service_role: powerdns
|
||||
compose_project_dir: /opt/pdns
|
||||
compose_repo_project: pdns
|
||||
compose_remote_file: compose.yml
|
||||
healthcheck_profiles: [pdns, rustdesk, hk2aux]
|
||||
restic_backup_profile: pdns
|
||||
service_reconcile_services:
|
||||
@@ -54,6 +61,7 @@ all:
|
||||
us4:
|
||||
ansible_host: us4.wsvc.info
|
||||
ansible_host_ipv4: 185.201.226.122
|
||||
display_name: us4.wsvc.info
|
||||
service_role: wireguard
|
||||
compose_project_dir: /opt/wireguard
|
||||
healthcheck_profiles: [wireguard]
|
||||
@@ -65,6 +73,7 @@ all:
|
||||
dns_windy_lan:
|
||||
ansible_host: 192.168.66.36
|
||||
ansible_host_ipv4: 192.168.66.36
|
||||
display_name: dns.windy.lan
|
||||
service_role: adguardhome
|
||||
compose_project_dir: /opt/adguardhome
|
||||
healthcheck_profiles: [adguardhome]
|
||||
@@ -94,6 +103,7 @@ all:
|
||||
ubnt:
|
||||
ansible_host: 192.168.66.46
|
||||
ansible_host_ipv4: 192.168.66.46
|
||||
display_name: ubnt
|
||||
vars:
|
||||
service_role: unifi
|
||||
compose_project_dir: /home/windy/unifi-9
|
||||
@@ -114,6 +124,7 @@ all:
|
||||
matrix_vps:
|
||||
ansible_host: 169.58.86.13
|
||||
ansible_host_ipv4: 169.58.86.13
|
||||
display_name: synapse.chans.xyz
|
||||
service_role: matrix_k3s
|
||||
matrix_server_name: chans.xyz
|
||||
matrix_synapse_host: synapse.chans.xyz
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
# Deploy repo-owned Compose declarations (compose/<project>/compose.yml) to
|
||||
# inventory hosts. Non-secret source; server-local .env provides the values.
|
||||
# Gated: apply requires compose_deploy_confirm=true; --check is a read-only
|
||||
# diff + validation. See runbooks/ansible-operations.md.
|
||||
- name: Deploy repo-owned Compose declarations
|
||||
hosts: docker_hosts
|
||||
become: true
|
||||
gather_facts: false
|
||||
serial: 1
|
||||
roles:
|
||||
- role: compose_deploy
|
||||
tags: [compose, deploy, mutating]
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
# Allowlist of compose/ projects this playbook may deploy. A host may only
|
||||
# reference a project listed here (see tasks: "Require a repo compose project").
|
||||
compose_repo_projects:
|
||||
- vaultwarden
|
||||
- pdns
|
||||
- adguardhome
|
||||
- unifi
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
# Deploy the repo-owned, sanitized Compose declaration to the host.
|
||||
#
|
||||
# Safety model:
|
||||
# - Only hosts with an inventory `compose_repo_project` (allowlisted) are valid.
|
||||
# - The repo file is staged to `<file>.dsh-new` and validated with
|
||||
# `docker compose config --quiet` against the server-local .env BEFORE it
|
||||
# replaces anything. A failed validation never touches the live file.
|
||||
# - The current file is kept as `*.bak-<timestamp>` before promotion.
|
||||
# - Apply mode requires `compose_deploy_confirm=true`; `--check` gives a
|
||||
# read-only diff + validation without writes.
|
||||
# - The playbook never writes, reads, or transfers the server .env.
|
||||
|
||||
- name: Require an allowlisted repo compose project for this host
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- compose_repo_project is defined
|
||||
- compose_repo_project in compose_repo_projects
|
||||
fail_msg: >-
|
||||
No allowlisted compose_repo_project for {{ inventory_hostname }}.
|
||||
Supported: {{ compose_repo_projects | join(', ') }}.
|
||||
|
||||
- name: Require explicit confirmation for apply mode
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- ansible_check_mode or (compose_deploy_confirm | bool)
|
||||
fail_msg: >-
|
||||
This playbook replaces the server compose file and may recreate
|
||||
containers. Run with --check for a read-only diff, or supply
|
||||
compose_deploy_confirm=true to apply.
|
||||
|
||||
- name: Stage the repo compose file next to the live one
|
||||
ansible.builtin.copy:
|
||||
src: "{{ playbook_dir }}/../../compose/{{ compose_repo_project }}/compose.yml"
|
||||
dest: "{{ compose_project_dir }}/{{ compose_remote_file | default('compose.yml') }}.dsh-new"
|
||||
mode: "0644"
|
||||
diff: true
|
||||
register: compose_stage
|
||||
|
||||
- name: Validate staged compose against the server .env (read-only)
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- docker
|
||||
- compose
|
||||
- -f
|
||||
- "{{ compose_project_dir }}/{{ compose_remote_file | default('compose.yml') }}.dsh-new"
|
||||
- --project-directory
|
||||
- "{{ compose_project_dir }}"
|
||||
- config
|
||||
- --quiet
|
||||
register: compose_validate
|
||||
changed_when: false
|
||||
failed_when: compose_validate.rc != 0
|
||||
|
||||
- name: Show staged-vs-live difference
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ compose_stage.diff | default('(no change)') }}"
|
||||
when: ansible_check_mode
|
||||
|
||||
- name: Back up the current compose file (apply mode)
|
||||
ansible.builtin.shell:
|
||||
cmd: >-
|
||||
cp -a '{{ compose_project_dir }}/{{ compose_remote_file | default('compose.yml') }}'
|
||||
'{{ compose_project_dir }}/{{ compose_remote_file | default('compose.yml') }}.bak-$(date +%Y%m%d-%H%M%S)'
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Promote the validated compose file (apply mode)
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- mv
|
||||
- "{{ compose_project_dir }}/{{ compose_remote_file | default('compose.yml') }}.dsh-new"
|
||||
- "{{ compose_project_dir }}/{{ compose_remote_file | default('compose.yml') }}"
|
||||
when: not ansible_check_mode
|
||||
|
||||
- name: Apply the compose declaration (apply mode)
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- docker
|
||||
- compose
|
||||
- --project-directory
|
||||
- "{{ compose_project_dir }}"
|
||||
- up
|
||||
- -d
|
||||
when: not ansible_check_mode
|
||||
@@ -0,0 +1,49 @@
|
||||
# compose/ — repo-owned Compose declarations
|
||||
|
||||
Non-secret Compose sources for the Docker hosts. Secrets are **never** in these
|
||||
files: every secret is a `${VAR}` reference resolved from the **server-local
|
||||
`.env`** (docker compose reads `.env` from the project directory automatically).
|
||||
|
||||
## Source-of-truth matrix
|
||||
|
||||
| Project | Host | Compose source | Mechanism |
|
||||
|---------|------|----------------|-----------|
|
||||
| `vaultwarden` | us2 (`/opt/vaultwarden`) | `compose/vaultwarden/compose.yml` | static file + `compose-deploy.yml` |
|
||||
| `pdns` | hk2 (`/opt/pdns`) | `compose/pdns/compose.yml` | static file + `compose-deploy.yml` |
|
||||
| `pgdb` | pgdb (`/opt/database`, 无 ansible) | `compose/pgdb/compose.yml` | static file(手动部署:scp → `docker compose config -q` → `up -d`;服务器文件名 `docker-compose.yml`) |
|
||||
| `adguardhome` | dns.windy.lan (`/opt/adguardhome`) | — (待从 LAN 提取) | static file (pending) |
|
||||
| `unifi` | ubnt (`/home/windy/unifi-9`) | — (待从 LAN 提取) | static file (pending) |
|
||||
| `wireguard` | us4 (`/opt/wireguard`) | `ansible/templates/wireguard-compose.yml.j2` | role-rendered (inventory vars) |
|
||||
| `rustdesk` | hk2 (`/opt/rustdesk`) | `ansible/roles/rustdesk/templates/compose.yml.j2` | role-rendered (inventory vars) |
|
||||
| `mailcow` | mx2 (`/opt/mail`) | — (mailcow update generator owns it) | excluded by design |
|
||||
|
||||
Mechanism rule: **static** `compose/<project>/compose.yml` for declarations that
|
||||
do not vary per host; **role-rendered j2** for declarations driven by inventory
|
||||
vars (image pins, relay host). One mechanism per project; do not duplicate a
|
||||
project in both.
|
||||
|
||||
## Deploying a static project
|
||||
|
||||
```bash
|
||||
cd ansible
|
||||
|
||||
# Read-only diff + validation against the server .env (no writes)
|
||||
ansible-playbook playbooks/compose-deploy.yml --limit vaultwarden --check --diff
|
||||
|
||||
# Apply: stage repo file → validate `docker compose config -q` → backup current
|
||||
# file → promote → `docker compose up -d` (gated)
|
||||
ansible-playbook playbooks/compose-deploy.yml --limit vaultwarden \
|
||||
-e '{"compose_deploy_confirm": true}'
|
||||
```
|
||||
|
||||
See [`../runbooks/ansible-operations.md`](../runbooks/ansible-operations.md).
|
||||
|
||||
## Adding a project
|
||||
|
||||
1. Sanitize the live compose so every secret is `${VAR}` from `.env`
|
||||
(prefer `${VAR:?missing VAR}` for required keys).
|
||||
2. Commit `compose/<project>/compose.yml` + `.env.example` (key names only).
|
||||
3. Add `compose_repo_project` (+ `compose_remote_file` if not `compose.yml`) to
|
||||
the host in `ansible/inventory/hosts.yml`, and allowlist the project in
|
||||
`ansible/roles/compose_deploy/defaults/main.yml`.
|
||||
4. Verify with `--check --diff` (zero diff) then a gated apply.
|
||||
@@ -0,0 +1,39 @@
|
||||
# .env.example — PowerDNS stack (hk2.chans.xyz, /opt/pdns)
|
||||
#
|
||||
# Non-secret key reference ONLY. Real values live in the server-local .env
|
||||
# (never commit them). Compose requires the `:?`-marked keys to be present.
|
||||
|
||||
# Runtime
|
||||
TZ=Asia/Shanghai
|
||||
|
||||
# Postgres superuser (db + backup + pgweb)
|
||||
PGUSER=
|
||||
PGPASSWORD=
|
||||
DB_HOST=db
|
||||
DB_PORT=5432
|
||||
|
||||
# Application database (auth / poweradmin / backup)
|
||||
DB_NAME=pdns
|
||||
DB_USER=pdns
|
||||
DB_PASS=
|
||||
ADMIN_DB=pdnsadmin
|
||||
|
||||
# Backups
|
||||
CRON_SCHEDULE=0 3 * * *
|
||||
RETENTION_DAYS=7
|
||||
MAX_BACKUPS=7
|
||||
DUMP_ROLES=true
|
||||
|
||||
# PowerDNS auth API
|
||||
PDNS_API_KEY=
|
||||
|
||||
# Poweradmin (first-run admin + session)
|
||||
PA_SESSION_KEY=
|
||||
PA_ADMIN_USERNAME=
|
||||
PA_ADMIN_PASSWORD=
|
||||
PA_ADMIN_EMAIL=
|
||||
PA_ADMIN_FULLNAME=
|
||||
|
||||
# pgweb debug profile
|
||||
PGWEB_USER=
|
||||
PGWEB_PASS=
|
||||
@@ -0,0 +1,159 @@
|
||||
networks:
|
||||
frontend:
|
||||
name: traefik
|
||||
external: true
|
||||
|
||||
backend:
|
||||
internal: true
|
||||
|
||||
edge:
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres:16
|
||||
container_name: pdns-db
|
||||
environment:
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_USER: ${PGUSER:?missing PGUSER}
|
||||
POSTGRES_PASSWORD: ${PGPASSWORD:?missing PGPASSWORD}
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
PGTZ: ${TZ:-Asia/Shanghai}
|
||||
volumes:
|
||||
# Keep the existing mount path to avoid moving the current data directory.
|
||||
- dbdata:/var/lib/postgresql
|
||||
- ./db-init-generated:/docker-entrypoint-initdb.d:ro
|
||||
- ./backup:/backup:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
networks: [backend, edge]
|
||||
|
||||
auth:
|
||||
image: powerdns/pdns-auth-50:5.0.6
|
||||
container_name: pdns-auth
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "53:53/udp"
|
||||
- "53:53/tcp"
|
||||
- "127.0.0.1:8081:8081"
|
||||
environment:
|
||||
PDNS_API_KEY: ${PDNS_API_KEY:?missing PDNS_API_KEY}
|
||||
DB_NAME: ${DB_NAME:?missing DB_NAME}
|
||||
DB_USER: ${DB_USER:?missing DB_USER}
|
||||
DB_PASS: ${DB_PASS:?missing DB_PASS}
|
||||
TEMPLATE_FILES: secrets
|
||||
volumes:
|
||||
- ./auth/pdns.conf:/etc/powerdns/pdns.conf:ro
|
||||
- ./auth/templates.d:/etc/powerdns/templates.d:ro
|
||||
- ./auth/keys:/var/lib/powerdns
|
||||
- ./auth/import:/import
|
||||
- ./auth/export:/export
|
||||
- ./auth/logs:/var/log/pdns
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"python3 -c \"import json, os, urllib.request; req = urllib.request.Request('http://127.0.0.1:8081/api/v1/servers/localhost', headers={'X-API-Key': os.environ['PDNS_API_KEY']}); data = json.load(urllib.request.urlopen(req, timeout=3)); assert data['daemon_type'] == 'authoritative'\""
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
restart: unless-stopped
|
||||
networks: [backend, edge]
|
||||
|
||||
poweradmin:
|
||||
image: poweradmin/poweradmin:stable
|
||||
container_name: poweradmin
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
auth:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DB_TYPE: pgsql
|
||||
DB_HOST: ${DB_HOST:-db}
|
||||
DB_PORT: ${DB_PORT:-5432}
|
||||
DB_NAME: ${DB_NAME:?missing DB_NAME}
|
||||
DB_USER: ${DB_USER:?missing DB_USER}
|
||||
DB_PASS: ${DB_PASS:?missing DB_PASS}
|
||||
PA_PDNS_API_URL: http://auth:8081
|
||||
PA_PDNS_API_KEY: ${PDNS_API_KEY:?missing PDNS_API_KEY}
|
||||
PA_DNS_BACKEND: sql
|
||||
PDNS_VERSION: ${PDNS_VERSION:-50}
|
||||
DNS_NS1: ${DNS_NS1:-ns1.wsvc.info}
|
||||
DNS_NS2: ${DNS_NS2:-ns2.wsvc.info}
|
||||
DNS_HOSTMASTER: ${DNS_HOSTMASTER:-hostmaster.wsvc.info}
|
||||
PA_APP_TITLE: ${PA_APP_TITLE:-Poweradmin}
|
||||
PA_TIMEZONE: ${TZ:-Asia/Shanghai}
|
||||
PA_SESSION_KEY: ${PA_SESSION_KEY:?missing PA_SESSION_KEY}
|
||||
PA_CREATE_ADMIN: ${PA_CREATE_ADMIN:-1}
|
||||
PA_ADMIN_USERNAME: ${PA_ADMIN_USERNAME:?missing PA_ADMIN_USERNAME}
|
||||
PA_ADMIN_PASSWORD: ${PA_ADMIN_PASSWORD:?missing PA_ADMIN_PASSWORD}
|
||||
PA_ADMIN_EMAIL: ${PA_ADMIN_EMAIL:?missing PA_ADMIN_EMAIL}
|
||||
PA_ADMIN_FULLNAME: ${PA_ADMIN_FULLNAME:?missing PA_ADMIN_FULLNAME}
|
||||
TRUSTED_PROXIES: private_ranges
|
||||
DEBUG: "false"
|
||||
restart: unless-stopped
|
||||
networks: [backend, frontend]
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik"
|
||||
- "traefik.http.routers.poweradmin.rule=Host(`pdns.wsvc.info`)"
|
||||
- "traefik.http.routers.poweradmin.entrypoints=websecure"
|
||||
- "traefik.http.routers.poweradmin.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.poweradmin.loadbalancer.server.port=80"
|
||||
|
||||
backup:
|
||||
# Use postgres:16 so bash/pg_dump/flock exist without runtime package installs.
|
||||
# backend is internal:true — Alpine apk at start cannot reach mirrors.
|
||||
image: postgres:16
|
||||
container_name: pdns-backup
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
DB_HOST: ${DB_HOST:-db}
|
||||
DB_PORT: ${DB_PORT:-5432}
|
||||
DB_USER: ${PGUSER:?missing PGUSER}
|
||||
DB_PASS: ${PGPASSWORD:?missing PGPASSWORD}
|
||||
DB_NAME: ${DB_NAME:?missing DB_NAME}
|
||||
RETENTION_DAYS: ${RETENTION_DAYS:-7}
|
||||
MAX_BACKUPS: ${MAX_BACKUPS:-7}
|
||||
DUMP_ROLES: ${DUMP_ROLES:-true}
|
||||
CRON_SCHEDULE: ${CRON_SCHEDULE:?missing CRON_SCHEDULE}
|
||||
volumes:
|
||||
- ./backup:/backup
|
||||
- ./scripts:/scripts:ro
|
||||
entrypoint: ["/bin/bash", "/scripts/backup-scheduler.sh"]
|
||||
restart: unless-stopped
|
||||
networks: [backend]
|
||||
|
||||
pgweb:
|
||||
image: sosedoff/pgweb:0.16.2
|
||||
container_name: pdns_pgweb
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
PGWEB_DATABASE_URL: "postgres://${PGUSER:?missing PGUSER}:${PGPASSWORD:?missing PGPASSWORD}@${DB_HOST:-db}:${DB_PORT:-5432}/${DB_NAME:?missing DB_NAME}?sslmode=disable"
|
||||
PGWEB_AUTH_USER: ${PGWEB_USER:?missing PGWEB_USER}
|
||||
PGWEB_AUTH_PASS: ${PGWEB_PASS:?missing PGWEB_PASS}
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
networks: [backend, frontend]
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=traefik"
|
||||
- "traefik.http.routers.pgweb.rule=Host(`pgweb.wsvc.info`)"
|
||||
- "traefik.http.routers.pgweb.entrypoints=websecure"
|
||||
- "traefik.http.routers.pgweb.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.pgweb.loadbalancer.server.port=8081"
|
||||
|
||||
volumes:
|
||||
dbdata: {}
|
||||
@@ -0,0 +1,5 @@
|
||||
# pgdb compose secrets — copy to /opt/database/.env on the host, chmod 600.
|
||||
# NEVER commit the real values. Generate: openssl rand -hex 24
|
||||
POSTGRES_PASSWORD=change-me-strong-hex
|
||||
PGWEB_AUTH_USER=pgweb
|
||||
PGWEB_AUTH_PASS=change-me-strong-hex
|
||||
@@ -0,0 +1,65 @@
|
||||
# pgdb (192.168.55.15) — TimescaleDB + pgweb GUI + nightly backup
|
||||
#
|
||||
# Deploy: copy this file to /opt/database/docker-compose.yml on pgdb,
|
||||
# create /opt/database/.env (chmod 600) from .env.example, plus
|
||||
# /opt/database/pgweb-bookmarks/{hass,scribe}.toml (chmod 600, contains DB password).
|
||||
# Then: docker compose config --quiet && docker compose up -d
|
||||
#
|
||||
# Rollback: previous launch command is kept at /opt/database/run
|
||||
# (container is stateless; data lives on /srv/pgdata).
|
||||
services:
|
||||
timescaledb:
|
||||
image: timescale/timescaledb:latest-pg18
|
||||
container_name: timescaledb
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "192.168.55.15:5432:5432" # bind VM IP only (no IPv6 wildcard)
|
||||
environment:
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- /srv/pgdata:/var/lib/postgresql # data disk (ext4 /dev/sdb1)
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
pgweb:
|
||||
image: sosedoff/pgweb:latest
|
||||
container_name: pgweb
|
||||
restart: unless-stopped
|
||||
# bind/listen/readonly/sessions/bookmarks-only/bookmarks-dir are CLI flags (no env equivalent in v0.17.0)
|
||||
command: ["pgweb", "--bind", "0.0.0.0", "--listen", "8081", "--readonly", "--sessions", "--bookmarks-only", "--bookmarks-dir", "/bookmarks"]
|
||||
ports:
|
||||
- "192.168.55.15:8081:8081" # LAN only + basic auth (see .env)
|
||||
environment:
|
||||
PGWEB_AUTH_USER: ${PGWEB_AUTH_USER}
|
||||
PGWEB_AUTH_PASS: ${PGWEB_AUTH_PASS}
|
||||
PGWEB_BOOKMARKS_DIR: /bookmarks
|
||||
volumes:
|
||||
- ./pgweb-bookmarks:/bookmarks:ro # bookmark .toml files (contain DB password, keep 0600)
|
||||
depends_on:
|
||||
timescaledb:
|
||||
condition: service_healthy
|
||||
|
||||
pg-backup:
|
||||
image: prodrigestivill/postgres-backup-local:latest # latest = postgres 18 base (pg_dump 18.x)
|
||||
container_name: pg-backup
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_HOST: timescaledb
|
||||
POSTGRES_DB: "hass scribe postgres"
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_EXTRA_OPTS: "-Fc" # custom-format dumps (pg_restore)
|
||||
SCHEDULE: "0 2 * * *" # nightly 02:00 (TZ=Asia/Shanghai -> local 02:00)
|
||||
BACKUP_ON_START: "TRUE" # immediate backup on first start
|
||||
BACKUP_SUFFIX: ".dump"
|
||||
HEALTHCHECK_PORT: "80" # go-cron health endpoint for the image healthcheck
|
||||
TZ: "Asia/Shanghai" # match original host-cron 02:00 local (container default is UTC)
|
||||
volumes:
|
||||
- /opt/database/backups:/backups # POSIX fs required; root disk, separate from data disk
|
||||
depends_on:
|
||||
timescaledb:
|
||||
condition: service_healthy
|
||||
@@ -0,0 +1,38 @@
|
||||
# .env.example — Vaultwarden (us2.wsvc.info, /opt/vaultwarden)
|
||||
#
|
||||
# Non-secret key reference ONLY. Real values live in the server-local .env
|
||||
# (never commit them). Copy the keys below into the server .env if a key is
|
||||
# missing; the compose file requires them via ${VAR} / env_file.
|
||||
|
||||
# Service identity
|
||||
DOMAIN=https://auth.wsvc.info
|
||||
TEMPLATES_FOLDER=
|
||||
|
||||
# Postgres (compose services vaultwarden / backup / pg / pgweb)
|
||||
DB_HOST=pg
|
||||
DB_PORT=5432
|
||||
DB_NAME=vaultwarden
|
||||
DB_USER=vaultwarden
|
||||
DB_PASS=
|
||||
|
||||
# pgweb debug profile
|
||||
PGWEB_USER=
|
||||
PGWEB_PASS=
|
||||
PGWEB_DATABASE_URL=
|
||||
|
||||
# SMTP (mailcow mx2.windy.me:587 starttls)
|
||||
SMTP_HOST=mx2.windy.me
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURITY=starttls
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=
|
||||
HELO_NAME=
|
||||
|
||||
# Admin console
|
||||
ADMIN_TOKEN=
|
||||
|
||||
# Runtime
|
||||
UID=1000
|
||||
GID=1000
|
||||
IP_HEADER=X-Forwarded-For
|
||||
@@ -0,0 +1,107 @@
|
||||
services:
|
||||
vaultwarden:
|
||||
image: vaultwarden/server:1.37.1
|
||||
container_name: vaultwarden
|
||||
restart: unless-stopped
|
||||
env_file: ".env"
|
||||
environment:
|
||||
DOMAIN: "https://auth.wsvc.info"
|
||||
DATABASE_URL: "postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${DB_NAME}"
|
||||
volumes:
|
||||
- ./vw-data:/data
|
||||
extra_hosts:
|
||||
- "mx2.windy.me:194.163.160.244"
|
||||
networks:
|
||||
- net
|
||||
depends_on:
|
||||
pg:
|
||||
condition: service_healthy
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=vw-net"
|
||||
|
||||
- "traefik.http.routers.vaultwarden.rule=Host(`auth.wsvc.info`)"
|
||||
- "traefik.http.routers.vaultwarden.entrypoints=websecure"
|
||||
- "traefik.http.routers.vaultwarden.tls=true"
|
||||
- "traefik.http.routers.vaultwarden.tls.certresolver=letsencrypt"
|
||||
|
||||
- "traefik.http.services.vaultwarden.loadbalancer.server.port=80"
|
||||
|
||||
backup:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.backup
|
||||
container_name: vaultwarden-backup
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./backups:/backup
|
||||
- ./scripts:/scripts
|
||||
#user: "${UID:-1000}:${GID:-1000}"
|
||||
|
||||
environment:
|
||||
DB_HOST: ${DB_HOST}
|
||||
DB_PORT: ${DB_PORT}
|
||||
DB_USER: ${DB_USER}
|
||||
DB_NAME: ${DB_NAME}
|
||||
DB_PASS: ${DB_PASS}
|
||||
BACKUP_UID: ${UID:-0}
|
||||
BACKUP_GID: ${GID:-0}
|
||||
TZ: Asia/Shanghai
|
||||
entrypoint: >
|
||||
/bin/sh -ec "
|
||||
umask 077 &&
|
||||
printf '%s:%s:*:%s:%s\n' \"$$DB_HOST\" \"$$DB_PORT\" \"$$DB_USER\" \"$$DB_PASS\" > /root/.pgpass &&
|
||||
chmod 600 /root/.pgpass &&
|
||||
touch /backup/backup.log &&
|
||||
crontab /scripts/crontab.txt &&
|
||||
echo '[INFO] Backup cron installed' &&
|
||||
echo '[INFO] Starting crond...' &&
|
||||
crond -f -l 8
|
||||
"
|
||||
networks: [net]
|
||||
|
||||
pg:
|
||||
image: postgres:16
|
||||
container_name: vw-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_NAME}
|
||||
POSTGRES_USER: ${DB_USER}
|
||||
POSTGRES_PASSWORD: ${DB_PASS}
|
||||
TZ: Asia/Shanghai
|
||||
PGTZ: Asia/Shanghai
|
||||
volumes:
|
||||
- vwdata:/var/lib/postgresql/data
|
||||
- ./backups:/backup # to import existing dump
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
networks: [net]
|
||||
|
||||
pgweb:
|
||||
profiles: ["debug"]
|
||||
image: sosedoff/pgweb:0.16.2
|
||||
container_name: vaultwarden-pgweb
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# 用 Vaultwarden 的数据库参数拼接连接串
|
||||
#DATABASE_URL: "postgres://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=disable"
|
||||
PGWEB_AUTH_USER: ${PGWEB_USER}
|
||||
PGWEB_AUTH_PASS: ${PGWEB_PASS}
|
||||
TZ: Asia/Shanghai
|
||||
#ports:
|
||||
# - "8082:8081" # 本地访问 http://localhost:8082
|
||||
depends_on:
|
||||
pg:
|
||||
condition: service_healthy
|
||||
networks: [net]
|
||||
|
||||
networks:
|
||||
net:
|
||||
name: vw-net
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
vwdata: {}
|
||||
@@ -0,0 +1,17 @@
|
||||
# docs/archive — 归档文档
|
||||
|
||||
归档 = 单次调研、已过期,或与当前运维无行动指向的内容。恢复使用前先确认
|
||||
内容仍与线上状态一致(本仓库原则:先证据后变更,live state 优先)。
|
||||
|
||||
## 归档清单
|
||||
|
||||
| 文件 | 归档日期 | 原位置 | 说明 |
|
||||
|------|---------|--------|------|
|
||||
| `lan-dns-alternatives.md` | 2026-08-17 | `docs/` | DNS 技术选型调研,零引用,无在途决策 |
|
||||
| `agent-runbook-guide.md` | 2026-08-22 | `docs/` | 上游参考存档;仓库落地规范为 `RUNBOOKS.md` |
|
||||
| `lan-core-switch-upgrade-plan.md` | 2026-08-22 | `docs/` | SE5420 历史规划参考;执行以 `docs/lan-se5420-deployment-guide.md` 为准 |
|
||||
| `lan-rb5009-upgrade.md` | 2026-08-22 | `docs/` | 未采购的 ER-X→RB5009 休眠备选方案;其中 PVE 透传与 VLAN10 调研仍可参考 |
|
||||
| `se5420-review-claim-verification-2026-08.md` | 2026-08-22 | `docs/` | 一次性评审复核调研(现场只读复核结论) |
|
||||
|
||||
> 购物类文档(打印机购买指南、交换机选型调研)已按整改计划移入 Obsidian
|
||||
> vault(`~/Documents/vault/my-vault/02_Areas/House/`),不在本目录。
|
||||
@@ -0,0 +1,326 @@
|
||||
# Agent Runbook 实用指南(v1)
|
||||
|
||||
> **定位**:本指南用于把团队的重复性运维、交付与故障处理经验写成可由 Agent 安全执行的流程。它适用于以 Git 仓库为中心的工程协作模式,优先采用 **Markdown + Git 版本控制 + 明确的 Agent 路由规则**,而不是一开始引入复杂的自动化平台。
|
||||
|
||||
> 本文件为上游参考存档。仓库内落地规范见 [`RUNBOOKS.md`](../../RUNBOOKS.md),标准模板见 [`runbooks/_template.md`](../../runbooks/_template.md),索引见 [`runbooks/README.md`](../../runbooks/README.md)。
|
||||
|
||||
## 1. 什么是 Agent Runbook
|
||||
|
||||
Runbook 是预先设计的、可重复执行的操作流程,用于处理部署、告警、故障、配置变更、CI 修复等标准化工作。传统 Runbook 的主要读者是人;**Agent Runbook 则必须把人的隐性判断显式化**,使 Agent 能知道做什么、看到什么才算正常、下一步去哪里、何时停止以及如何撤销。
|
||||
|
||||
Google SRE 强调在事故发生前设计响应流程、系统化排障,并逐步将重复性运维工作自动化。[1] [2] AWS Systems Manager Automation 则把可执行 Runbook 建模为顺序步骤:每个步骤调用一个动作,前一步输出可以传递给后续步骤。[3] 这两种思路共同构成了 Agent Runbook 的实用基础。
|
||||
|
||||
| 层次 | 核心问题 | 应承担的职责 |
|
||||
|---|---|---|
|
||||
| `AGENTS.md` | **何时使用哪份流程?** | 工作路由、通用操作约束、无匹配流程时的默认行为 |
|
||||
| `runbooks/*.md` | **这件事按什么流程做?** | 前置条件、分步操作、决策分支、验证、停止条件与回滚 |
|
||||
| Skill / MCP / Tool | **有哪些可调用能力?** | 具体能力、参数、权限边界和使用说明 |
|
||||
| Shell / GitHub / Linear / SSH 等 | **实际如何执行?** | 对系统、代码库或外部服务执行操作 |
|
||||
|
||||
## 2. 设计目标与适用边界
|
||||
|
||||
Agent Runbook 的目标不是让 Agent 在所有异常下“想办法修好”,而是在一个**已知、受控、可验证、可回退**的边界中提高执行一致性。它应当优先覆盖高频、后果明确、流程稳定的操作,例如 CI 失败定位、Issue 到合并请求、发布前检查、标准部署、回滚及网络变更。
|
||||
|
||||
| 适合纳入 Runbook | 暂不适合直接自动执行 |
|
||||
|---|---|
|
||||
| 明确输入、固定步骤、可观察结果的操作 | 目标或验收标准尚不清楚的探索性任务 |
|
||||
| 可在每次修改后验证状态的变更 | 缺失关键参数、权限或上下文的任务 |
|
||||
| 具有安全回滚路径的发布与配置调整 | 高破坏性、不可逆或影响面未知的操作 |
|
||||
| 可由权限与审批规则约束的运维流程 | 与既有流程事实冲突、无法判断根因的异常场景 |
|
||||
|
||||
> **基本原则**:当实际状态与 Runbook 的假设冲突,Agent 应停止并呈报,而不是补全未知信息、绕过检查或继续试错。
|
||||
|
||||
## 3. Agent Runbook 的最小字段
|
||||
|
||||
与普通人工 Runbook 相比,Agent Runbook 必须显式包含以下六类控制信息。缺少其中任一项,都会增加盲目执行或错误恢复的风险。
|
||||
|
||||
| 字段 | 作用 | 写作要求 |
|
||||
|---|---|---|
|
||||
| **Action** | 定义当前要执行的动作 | 使用可观察、可执行的动词;避免“检查一下”“适当调整”等模糊表述 |
|
||||
| **Expected** | 描述正常状态或预期输出 | 给出具体信号、阈值、状态码、测试结果或页面表现 |
|
||||
| **Decision** | 定义分支与下一跳 | 用“条件 → 下一步”的形式;无法判断时指向 `STOP` |
|
||||
| **Verification** | 确认变更真正生效 | 在每个有副作用的步骤后执行,不能被跳过 |
|
||||
| **Stop condition** | 规定何时不得继续 | 明确列出信息缺失、状态冲突、权限不足、验证失败等条件 |
|
||||
| **Rollback** | 描述如何恢复到变更前状态 | 标明触发条件、前提、撤销步骤及回滚后的验证方式 |
|
||||
|
||||
## 4. 推荐目录与路由机制
|
||||
|
||||
建议把流程与代码一起保存在 Git 仓库中。这样 Runbook 可以评审、版本化、随系统演进更新,也能与相关 Issue、PR 和配置建立可追溯关系。
|
||||
|
||||
```text
|
||||
repo/
|
||||
├── AGENTS.md
|
||||
├── RUNBOOKS.md
|
||||
├── runbooks/
|
||||
│ ├── README.md
|
||||
│ ├── issue-to-merge.md
|
||||
│ ├── fix-ci.md
|
||||
│ ├── release.md
|
||||
│ ├── rollback.md
|
||||
│ ├── network-change.md
|
||||
│ └── network-recovery.md
|
||||
└── ...
|
||||
```
|
||||
|
||||
### `AGENTS.md`:只做路由与通用约束
|
||||
|
||||
`AGENTS.md` 不应重复流程细节。它只需要规定 Agent 在进行操作类工作前,先查找最具体且适用的 Runbook,并严格遵守其中的步骤、验证、停止和审批要求。
|
||||
|
||||
```markdown
|
||||
# Operational Rules
|
||||
|
||||
Before performing operational work:
|
||||
|
||||
1. Inspect `runbooks/`.
|
||||
2. Select the most specific applicable runbook.
|
||||
3. Follow its steps in order.
|
||||
4. Do not skip verification steps.
|
||||
5. Respect STOP and approval conditions.
|
||||
6. If no runbook applies, diagnose only; do not mutate production state.
|
||||
|
||||
## Routing
|
||||
|
||||
- CI failure → `runbooks/fix-ci.md`
|
||||
- GitHub issue implementation → `runbooks/issue-to-merge.md`
|
||||
- Deployment → `runbooks/release.md`
|
||||
- Rollback → `runbooks/rollback.md`
|
||||
- Network configuration → `runbooks/network-change.md`
|
||||
- Network outage → `runbooks/network-recovery.md`
|
||||
```
|
||||
|
||||
### `RUNBOOKS.md`:仓库级规范
|
||||
|
||||
`RUNBOOKS.md` 用于统一所有 Runbook 的字段、命名、评审要求和变更规则。每份 Runbook 只描述一种可识别的操作意图;如果流程已有明显分叉,应拆分为独立文件,而不是堆叠成长篇“万能流程”。
|
||||
|
||||
## 5. 规范模板
|
||||
|
||||
以下模板可直接保存为 `runbooks/_template.md` 使用。
|
||||
|
||||
```markdown
|
||||
# Runbook: <名称>
|
||||
|
||||
## Purpose
|
||||
说明本 Runbook 要解决的问题及成功结果。
|
||||
|
||||
## Scope
|
||||
- 适用环境:<如 development / staging / production>
|
||||
- 适用对象:<服务、仓库、组件或告警类型>
|
||||
- 不适用情形:<需要改用其他 Runbook 或转人工的场景>
|
||||
|
||||
## Ownership
|
||||
- Owner:<团队或角色>
|
||||
- Last reviewed:<YYYY-MM-DD>
|
||||
- Related systems:<系统名称>
|
||||
|
||||
## Preconditions
|
||||
- <执行前必须满足的权限、备份、窗口、健康状态或已知信息>
|
||||
|
||||
## Inputs
|
||||
| 输入 | 来源 | 是否必需 | 校验方法 |
|
||||
|---|---|---:|---|
|
||||
| <参数> | <来源> | 是/否 | <如何确认有效> |
|
||||
|
||||
## Safety
|
||||
### Non-negotiable rules
|
||||
- 先只读诊断,后执行变更。
|
||||
- 不得把删除现有配置作为首次恢复动作。
|
||||
- 不得猜测或编造缺失参数。
|
||||
- 不得绕过失败的测试、检查或审批。
|
||||
- 每次变更后必须完成对应验证。
|
||||
- 破坏性操作必须获得明确批准。
|
||||
|
||||
### Stop conditions
|
||||
- 实际状态与本文档的前提或预期结果冲突。
|
||||
- 缺少必要输入、权限、审批或回滚能力。
|
||||
- 验证失败且本文档没有明确的下一步。
|
||||
- 影响范围超出 Scope。
|
||||
|
||||
### Approval gates
|
||||
| 动作 | 风险级别 | 是否需要明确批准 | 批准记录位置 |
|
||||
|---|---|---:|---|
|
||||
| <动作> | 低/中/高 | 是/否 | <Issue / PR / 变更单> |
|
||||
|
||||
## Procedure
|
||||
|
||||
### Step 1 — Diagnose
|
||||
|
||||
**Action**
|
||||
|
||||
<执行只读诊断动作。>
|
||||
|
||||
**Expected**
|
||||
|
||||
<列出预期输出、状态或证据。>
|
||||
|
||||
**Decision**
|
||||
|
||||
- 若 <条件 A>,进入 Step 2。
|
||||
- 若 <条件 B>,进入 Troubleshooting A。
|
||||
- 若无法判断或状态冲突,`STOP` 并记录证据。
|
||||
|
||||
### Step 2 — Change
|
||||
|
||||
**Action**
|
||||
|
||||
<描述单一、可审计的变更动作。>
|
||||
|
||||
**Expected**
|
||||
|
||||
<变更后应出现的状态。>
|
||||
|
||||
**Verification**
|
||||
|
||||
<给出可重复执行的验证命令、测试、监控指标或检查清单。>
|
||||
|
||||
**Rollback**
|
||||
|
||||
- 触发条件:<什么情况需要回滚>
|
||||
- 回滚动作:<如何撤销>
|
||||
- 回滚验证:<如何确认恢复成功>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Troubleshooting A — <异常名称>
|
||||
|
||||
- 证据收集:<日志、指标、命令输出、链接>
|
||||
- 允许动作:<仅限已验证且低风险的动作>
|
||||
- 下一步:<回到某步 / 转入另一 Runbook / STOP 并升级>
|
||||
|
||||
## Final Verification
|
||||
|
||||
只有同时满足以下标准,流程才算成功:
|
||||
|
||||
- <功能或服务状态>
|
||||
- <自动化测试或健康检查>
|
||||
- <监控指标或告警状态>
|
||||
- <变更记录、PR 或 Issue 已更新>
|
||||
|
||||
## Failure Handling
|
||||
|
||||
若未能完成:
|
||||
|
||||
1. 停止进一步变更。
|
||||
2. 收集 <命令输出、时间范围、请求 ID、日志链接、截图或复现步骤>。
|
||||
3. 记录已完成步骤、实际结果、未满足的预期和是否执行过回滚。
|
||||
4. 按 <升级渠道> 交接,不继续猜测。
|
||||
|
||||
## References
|
||||
|
||||
- <关联 Issue、PR、架构文档、仪表盘、配置仓库或外部文档>
|
||||
```
|
||||
|
||||
## 6. 编写步骤的标准写法
|
||||
|
||||
每个步骤应只承担一个清晰目的,并使用“动作—预期—决策”的闭环表达。如下表所示,前者会导致 Agent 自主扩大操作范围,后者则为其提供安全边界。
|
||||
|
||||
| 不推荐写法 | 推荐写法 |
|
||||
|---|---|
|
||||
| “检查部署是否正常,不正常就修复。” | “读取部署状态与最近一次发布记录。若所有副本 `Ready` 且版本等于目标版本,进入 Final Verification;若副本未就绪,收集事件与日志并进入 Troubleshooting A;若版本不匹配且原因未知,`STOP`。” |
|
||||
| “必要时修改配置。” | “仅当配置差异与变更单 `CHG-123` 完全一致且审批已记录时,应用指定键的值;应用后运行健康检查;失败则按 Rollback 回退。” |
|
||||
| “测试失败时可先跳过。” | “任何必需测试失败均不得继续部署。记录失败测试、日志和提交版本;仅按 Troubleshooting B 处理。” |
|
||||
|
||||
## 7. 通用安全规则
|
||||
|
||||
以下规则适合在每份 Runbook 的 `Safety` 章节中复用。若某流程存在更严格要求,应以更严格要求为准。
|
||||
|
||||
```markdown
|
||||
## Safety Rules
|
||||
|
||||
- Never delete an existing configuration as the first recovery action.
|
||||
- Prefer read-only diagnosis before mutation.
|
||||
- After every mutation, verify the expected state.
|
||||
- If actual state conflicts with this runbook, STOP.
|
||||
- Do not invent missing parameters.
|
||||
- Do not bypass failed tests.
|
||||
- Destructive actions require explicit approval.
|
||||
```
|
||||
|
||||
这些约束体现了一个关键顺序:**先证据,后变更;先小范围,后扩大;先验证,后结束;不确定则停止。** 特别是停止条件必须可操作,例如“权限不足”“缺少变更单”“生产状态与前提不一致”“错误率超过 1%”等,而不应写成“情况复杂时停止”。
|
||||
|
||||
## 8. 运行与审计流程
|
||||
|
||||
Agent 执行 Runbook 时,应按照固定运行模型工作。每一步的输入、动作、输出和下一跳都应可追踪,这与 AWS 自动化 Runbook 的顺序步骤和输出传递思想一致。[3]
|
||||
|
||||
```text
|
||||
输入与前置条件
|
||||
↓
|
||||
只读诊断
|
||||
↓
|
||||
确认预期状态或决策分支
|
||||
↓
|
||||
获取审批(如需要)
|
||||
↓
|
||||
执行最小变更
|
||||
↓
|
||||
立即验证
|
||||
↓
|
||||
成功收尾 / 回滚 / 停止并升级
|
||||
```
|
||||
|
||||
| 阶段 | Agent 必须产出的证据 | 禁止行为 |
|
||||
|---|---|---|
|
||||
| 输入确认 | 参数来源、环境、目标资源、权限与审批状态 | 用猜测值补全必需参数 |
|
||||
| 诊断 | 命令输出、日志、指标或页面状态 | 在未诊断前直接修改生产状态 |
|
||||
| 变更 | 实际执行内容、变更范围、时间 | 将多个无关变更混在一起执行 |
|
||||
| 验证 | 测试、健康检查、监控状态与预期对比 | 以“命令执行成功”代替业务验证 |
|
||||
| 失败处理 | 已做步骤、异常证据、回滚状态和升级对象 | 无限制重试或绕过失败检查 |
|
||||
|
||||
## 9. 从人工操作到自动化的成熟路径
|
||||
|
||||
不建议在流程尚未稳定时先构建复杂 DSL 或全自动编排。应先积累真实案例,把可重复部分固化为 Markdown Runbook,再把已稳定、低歧义、可验证的操作迁移到脚本、CI、Skill 或自动化系统。Google SRE 将能够由机器替代的重复性人工工作视为应逐步消除的 toil。[4]
|
||||
|
||||
| 阶段 | 主要形式 | 人的角色 | 自动化边界 |
|
||||
|---|---|---|---|
|
||||
| 1. 人工处理 | 现场处置与复盘 | 执行、判断、记录 | 不自动化 |
|
||||
| 2. Markdown Runbook | 固化步骤与证据要求 | 审核流程与异常判断 | Agent 可辅助诊断 |
|
||||
| 3. Agent + Runbook | 严格按流程执行 | 审批高风险动作、处理例外 | 受停止条件约束的执行 |
|
||||
| 4. Script / Skill / CI / Automation | 把稳定步骤程序化 | 处理异常和维护自动化 | 自动完成重复性操作 |
|
||||
| 5. 人工审批 + 自动执行 | 常规流程端到端运行 | 决策、审计与治理 | 审批门控下的自动变更 |
|
||||
|
||||
## 10. 上线前检查清单
|
||||
|
||||
在将一份新 Runbook 交给 Agent 使用前,建议由流程所有者按以下清单审核。
|
||||
|
||||
| 检查项 | 合格标准 |
|
||||
|---|---|
|
||||
| 问题边界 | Purpose 与 Scope 清楚描述适用和不适用情形 |
|
||||
| 输入 | 所有必需输入都有来源、格式和校验方法 |
|
||||
| 步骤 | 每一步均有 Action、Expected 与明确的下一跳 |
|
||||
| 变更控制 | 所有修改动作都有 Verification;关键动作有 Rollback |
|
||||
| 安全控制 | Stop conditions、审批门槛和禁止行为已列明 |
|
||||
| 异常处理 | 失败时知道收集什么证据、交给谁,而非继续猜测 |
|
||||
| 可维护性 | 有 Owner、最近复审日期与关联文档;已在版本控制中评审 |
|
||||
| 可演练性 | 已在安全环境或历史案例上走通至少一次 |
|
||||
|
||||
## 11. 建议的首批 Runbook
|
||||
|
||||
首次落地时,应优先选择频率较高、输入相对明确、变更可回退的场景。以下集合通常能覆盖大部分工程协作的基础需求。
|
||||
|
||||
| Runbook | 目的 | 关键安全控制 |
|
||||
|---|---|---|
|
||||
| `issue-to-merge.md` | 从已明确 Issue 到可评审变更 | Scope 锁定、测试门槛、PR 证据 |
|
||||
| `fix-ci.md` | 诊断并修复 CI 失败 | 不跳过测试、不修改无关代码 |
|
||||
| `release.md` | 执行标准发布 | 发布窗口、审批、健康检查、回滚点 |
|
||||
| `rollback.md` | 恢复到已知稳定版本 | 明确触发条件、版本选择、回滚后验证 |
|
||||
| `network-change.md` | 实施受控网络配置变更 | 影响评估、变更单、回退配置 |
|
||||
| `network-recovery.md` | 处理网络异常与服务恢复 | 只读诊断优先、状态冲突即停止 |
|
||||
|
||||
## 12. 结论
|
||||
|
||||
Agent Runbook 的价值不在于把每一项运维工作立即自动化,而在于将团队的工程判断编码为**可路由、可验证、可停止、可回滚**的操作系统。对于多数团队,从仓库中的 `AGENTS.md`、`RUNBOOKS.md` 和一组 Markdown Runbook 起步,已经足够实用。
|
||||
|
||||
当某个流程经过多次执行、输入稳定、异常分支收敛且验证可靠后,再将其下沉为脚本、CI 或其他自动化能力。这样既能逐步降低重复性 toil,也能始终保留人类对高风险和例外情形的决策权。[4]
|
||||
|
||||
## References
|
||||
|
||||
[1]: https://sre.google/sre-book/managing-incidents/ "Google SRE Book — Managing Incidents"
|
||||
[2]: https://sre.google/sre-book/effective-troubleshooting/ "Google SRE Book — Effective Troubleshooting"
|
||||
[3]: https://docs.aws.amazon.com/systems-manager/latest/userguide/automation-documents.html "AWS Systems Manager — Creating your own runbooks"
|
||||
[4]: https://sre.google/sre-book/eliminating-toil/ "Google SRE Book — Eliminating Toil"
|
||||
[5]: https://docs.aws.amazon.com/systems-manager/latest/userguide/systems-manager-automation.html "AWS Systems Manager Automation"
|
||||
[6]: https://docs.aws.amazon.com/systems-manager-automation-runbooks/latest/userguide/automation-runbook-reference.html "AWS Systems Manager Automation Runbook Reference"
|
||||
[7]: https://learn.microsoft.com/en-us/azure/automation/manage-runbooks "Microsoft Learn — Manage runbooks in Azure Automation"
|
||||
|
||||
---
|
||||
|
||||
**来源**:Manus AI《Agent Runbook 实用指南(v1.0)》,本仓库存档为规范参考。
|
||||
+10
-10
@@ -1,13 +1,13 @@
|
||||
# LAN 核心交换机升级计划(保留 ER-X)
|
||||
|
||||
**状态:** SE5420 **已采购**(2026-08-09)。**实施与验证以 [lan-se5420-deployment-guide.md](lan-se5420-deployment-guide.md) 为准**;
|
||||
**状态:** SE5420 **已采购**(2026-08-09)。**实施与验证以 [lan-se5420-deployment-guide.md](../lan-se5420-deployment-guide.md) 为准**;
|
||||
本文为历史规划参考,**不得作为现场执行步骤**;所有实际操作均以部署指南为准。
|
||||
**锁定硬件:** TP-Link **`TL-SE5420`**(16 × 2.5GbE RJ45 + 4 × 10GbE SFP+)。
|
||||
**目标:** SE5420 承接全部 LAN 物理接入与二层转发;ER-X 继续承担公网、NAT、防火墙、
|
||||
LAN66/LAN55 网关与 DHCP。
|
||||
|
||||
**拓扑与流量的详细说明**(职责、逻辑网、流量路径、Wi-Fi 分工、验收边界)见:
|
||||
[lan-erx-se5420-network.md](lan-erx-se5420-network.md)。
|
||||
[lan-erx-se5420-network.md](../lan-erx-se5420-network.md)。
|
||||
|
||||
SE5420 官方资料:静态功耗 8 W、最大功耗 32 W;VLAN、LACP、STP/RSTP/MSTP、ACL、
|
||||
CLI/SNMP、配置导入导出与固件下载。无 PoE——AP 使用本地取电 + 普通网线。
|
||||
@@ -65,7 +65,7 @@ VLAN tag。
|
||||
```
|
||||
|
||||
完整端口表、流量路径与 Wi-Fi 分工见
|
||||
[lan-erx-se5420-network.md](lan-erx-se5420-network.md)。
|
||||
[lan-erx-se5420-network.md](../lan-erx-se5420-network.md)。
|
||||
|
||||
## VLAN 与端口设计
|
||||
|
||||
@@ -124,8 +124,8 @@ VLAN tag。
|
||||
### 阶段 4:VLAN10 升级专用 Wi-Fi(独立项目)
|
||||
|
||||
不与本次 Done 捆绑。仅 U6;网关 `gfw`;详见
|
||||
[lan-erx-se5420-network.md](lan-erx-se5420-network.md) 第 6.5 / 8 节与
|
||||
[unifi-network.md](unifi-network.md)。
|
||||
[lan-erx-se5420-network.md](../lan-erx-se5420-network.md) 第 6.5 / 8 节与
|
||||
[unifi-network.md](../unifi-network.md)。
|
||||
|
||||
## 性能预期与不变瓶颈
|
||||
|
||||
@@ -148,9 +148,9 @@ VLAN tag。
|
||||
|
||||
## 参考
|
||||
|
||||
- [ER-X + SE5420 网络与拓扑说明](lan-erx-se5420-network.md)
|
||||
- [LAN 概览](lan-overview.md)
|
||||
- [ER-X 配置记录](edgerouter-x-configuration.md)
|
||||
- [UniFi 网络](unifi-network.md)
|
||||
- [`gfw`](../hosts/gfw.windy.lan.md)
|
||||
- [ER-X + SE5420 网络与拓扑说明](../lan-erx-se5420-network.md)
|
||||
- [LAN 概览](../lan-overview.md)
|
||||
- [ER-X 配置记录](../edgerouter-x-configuration.md)
|
||||
- [UniFi 网络](../unifi-network.md)
|
||||
- [`gfw`](../../hosts/gfw.windy.lan.md)
|
||||
- [TL-SE5420 官方规格](https://www.tp-link.com.cn/product_2899.html?v=specification)
|
||||
@@ -3,7 +3,7 @@
|
||||
**Status: research only. No configuration was changed.** This page evaluates
|
||||
resolvers/splitters that are genuinely better than — or meaningfully different
|
||||
from — the current "AdGuard Home (AGH) + mosdns" setup on
|
||||
[`dns.windy.lan`](../hosts/dns.windy.lan.md) (`.36`), for a GFW-constrained
|
||||
[`dns.windy.lan`](../../hosts/dns.windy.lan.md) (`.36`), for a GFW-constrained
|
||||
China home LAN. Claims are cited to primary sources (official repos, official
|
||||
docs, upstream READMEs); anything not verified is flagged as such.
|
||||
|
||||
@@ -11,11 +11,11 @@ docs, upstream READMEs); anything not verified is flagged as such.
|
||||
> verification — mosdns on `.1` is **not idle**, it is clash's
|
||||
> `nameserver`/`default-nameserver` (DIRECT-rule real-IP resolution); the
|
||||
> canonical decision record is
|
||||
> [`lan-dns-architecture.md`](lan-dns-architecture.md) (final verdict aligned,
|
||||
> [`lan-dns-architecture.md`](../lan-dns-architecture.md) (final verdict aligned,
|
||||
> Phase 0 kill-test evidence incl. a measured upstream-blackhole degradation
|
||||
> gap).
|
||||
|
||||
Scope recap (from [`lan-overview.md`](lan-overview.md), verified 2026-08-06):
|
||||
Scope recap (from [`lan-overview.md`](../lan-overview.md), verified 2026-08-06):
|
||||
|
||||
- Clients get DNS via EdgeRouter DHCP option 6 → AGH `192.168.66.36:53`.
|
||||
- AGH upstreams: `dns.alidns.com` + `doh.pub` DoH (load-balanced), fallback
|
||||
@@ -58,7 +58,7 @@ The genuinely worthwhile changes, in order of value:
|
||||
3. **mosdns on `.1` is resolved, not idle** — it is clash's
|
||||
`nameserver`/`default-nameserver` (DIRECT-rule real-IP resolution, verified
|
||||
2026-08-12), so "delete it" is off the table; its role is documented in
|
||||
[`lan-dns-architecture.md`](lan-dns-architecture.md) §1. If a future change
|
||||
[`lan-dns-architecture.md`](../lan-dns-architecture.md) §1. If a future change
|
||||
moves this role to an AGH-side companion, keep in mind mosdns's cache strips
|
||||
EDNS0 and it performs no DNSSEC validation
|
||||
([mosdns v5 executable plugins](https://irine-sistiana.gitbook.io/mosdns-wiki/mosdns-v5/ru-he-pei-zhi-mosdns/ke-zhi-xing-cha-jian.md)).
|
||||
@@ -517,18 +517,18 @@ AGH .36 (filtering, rewrites, query log, per-client upstreams)
|
||||
TPROXY/fake-ip/DNS-hijack rules; co-locating LAN DNS there couples DNS to the
|
||||
proxy and its restart/update lifecycle.
|
||||
- A standalone resolver VM adds nothing: both current VMs already sit on the
|
||||
same PVE hypervisor ([lan-overview.md](lan-overview.md) §Positioning facts),
|
||||
same PVE hypervisor ([lan-overview.md](../lan-overview.md) §Positioning facts),
|
||||
so a hypervisor outage takes out either placement equally; a second physical
|
||||
host for HA is out of scope for a home LAN.
|
||||
- If you ever run a validating resolver + AGH on `.36`, verify outbound from
|
||||
`.36` to the foreign upstreams is not re-hijacked by OpenClash (loop check
|
||||
already mandated in the [AGH review](adguard-home-official-review-2026-08.md)).
|
||||
already mandated in the [AGH review](../adguard-home-official-review-2026-08.md)).
|
||||
|
||||
### 4.4 Fail-open, DNSSEC, private names — by candidate
|
||||
|
||||
| Concern | How the recommended stack behaves |
|
||||
|---|---|
|
||||
| Fail-open when proxy/subscription down | AGH forwards directly to DoH upstreams; `.36`'s outbound is not forced through the proxy in normal ops (no TUN policy routing on `.36` — [dns host facts](../hosts/dns.windy.lan.md)). With unbound/blocky behind, foreign resolution recurses/validates directly, independent of OpenClash. Avoid mihomo-DNS-as-resolver, which is proxy-coupled. |
|
||||
| Fail-open when proxy/subscription down | AGH forwards directly to DoH upstreams; `.36`'s outbound is not forced through the proxy in normal ops (no TUN policy routing on `.36` — [dns host facts](../../hosts/dns.windy.lan.md)). With unbound/blocky behind, foreign resolution recurses/validates directly, independent of OpenClash. Avoid mihomo-DNS-as-resolver, which is proxy-coupled. |
|
||||
| DNSSEC validation | Only unbound, blocky, knot-resolver, Technitium validate in-process. AGH sets DO only; mosdns/smartdns/chinadns-ng/mihomo/sing-box do not. Plan: validate behind AGH, or accept "validating public upstream" (confirm with `dig +dnssec`/known-bad test). |
|
||||
| Private names / rewrites | AGH `rewrites` (already in use for `hass.windy.lan`) + `local_ptr_upstreams` once a local PTR source exists. blocky: `customDNS` mapping/rewrite + hosts. unbound: `local-zone`. All adequate. |
|
||||
| Query log / visibility | AGH is the best at this of everything evaluated (14-day anonymized log already configured). |
|
||||
@@ -551,7 +551,7 @@ Concrete, in increasing effort:
|
||||
the known-bad-signature check, then enable AGH DNSSEC. Without this, AGH's
|
||||
`enable_dnssec` is only a DO-flag — the exact reason it is currently off
|
||||
([AGH DNSSEC semantics](https://adguard-dns.io/kb/adguard-home/configuration/),
|
||||
[host facts](../hosts/dns.windy.lan.md)).
|
||||
[host facts](../../hosts/dns.windy.lan.md)).
|
||||
3. **Either fully configure mosdns (systemd service, sequence, lists) or remove
|
||||
it.** Leaving an idle `127.0.0.1:6052` listener documented as "not the active
|
||||
path" is drift. If kept, plan around no-EDNS0 cache + no validation; if
|
||||
@@ -559,9 +559,9 @@ Concrete, in increasing effort:
|
||||
4. **Close the private-PTR gap**: once a local authoritative source exists (e.g.
|
||||
dnsmasq on `gw`, or a tiny authoritative zone), point AGH
|
||||
`local_ptr_upstreams` at it as the AGH review recommends
|
||||
([AGH review](adguard-home-official-review-2026-08.md));
|
||||
([AGH review](../adguard-home-official-review-2026-08.md));
|
||||
don't set it before that source exists
|
||||
([dns host facts](../hosts/dns.windy.lan.md)).
|
||||
([dns host facts](../../hosts/dns.windy.lan.md)).
|
||||
5. **Optional: ECS** for CDN geo-accuracy — AGH `edns_client_subnet.use_custom`
|
||||
with a coarse fixed prefix (or blocky `ecs.forward`) if measurements show a
|
||||
benefit; note many CN resolvers ignore ECS
|
||||
@@ -579,7 +579,7 @@ here beats it on that axis for this LAN.
|
||||
- **Live behavior not tested**: all capability claims are from primary docs
|
||||
reviewed 2026-08-12; DNSSEC behavior of `dns.alidns.com`/`doh.pub`/the
|
||||
`adg.chans.xyz` path and mosdns's actual version on `.36` need on-box
|
||||
`dig +dnssec` verification (per [adguard-home-health](../runbooks/adguard-home-health.md)).
|
||||
`dig +dnssec` verification (per [adguard-home-health](../../runbooks/adguard-home-health.md)).
|
||||
- **smartdns DNSSEC**: the official config reference lists no DNSSEC option;
|
||||
if a newer version added one, it is not reflected here
|
||||
([config options](https://pymumu.github.io/smartdns/configuration/)).
|
||||
@@ -598,8 +598,8 @@ here beats it on that axis for this LAN.
|
||||
|
||||
## Related docs
|
||||
|
||||
- [lan-overview.md](lan-overview.md) — full topology (verified 2026-08-06)
|
||||
- [hosts/dns.windy.lan.md](../hosts/dns.windy.lan.md) — AGH host facts
|
||||
- [hosts/gfw.windy.lan.md](../hosts/gfw.windy.lan.md) — OpenClash facts
|
||||
- [adguard-home-official-review-2026-08.md](adguard-home-official-review-2026-08.md) — prior AGH config review
|
||||
- [runbooks/adguard-home-health.md](../runbooks/adguard-home-health.md)
|
||||
- [lan-overview.md](../lan-overview.md) — full topology (verified 2026-08-06)
|
||||
- [hosts/dns.windy.lan.md](../../hosts/dns.windy.lan.md) — AGH host facts
|
||||
- [hosts/gfw.windy.lan.md](../../hosts/gfw.windy.lan.md) — OpenClash facts
|
||||
- [adguard-home-official-review-2026-08.md](../adguard-home-official-review-2026-08.md) — prior AGH config review
|
||||
- [runbooks/adguard-home-health.md](../../runbooks/adguard-home-health.md)
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**状态:** 规划文档(未采购、未接线、未改生产配置)。
|
||||
**重要变更(2026-08-09):** **SE5420 已采购**,网络升级改为「保留 ER-X + SE5420 核心」路径——
|
||||
实施与验证以 [lan-se5420-deployment-guide.md](lan-se5420-deployment-guide.md) 为准。
|
||||
实施与验证以 [lan-se5420-deployment-guide.md](../lan-se5420-deployment-guide.md) 为准。
|
||||
本文保留为「ER-X 网关未来替换为 RB5009」的备选方案;其中 PVE 透传调研与 VLAN10 实现方法仍适用。
|
||||
|
||||
---
|
||||
@@ -389,9 +389,9 @@ logread -e netifd
|
||||
|
||||
## 8. 参考
|
||||
|
||||
- 现网地图:[lan-overview.md](lan-overview.md)
|
||||
- ER-X 现状:[edgerouter-x-configuration.md](edgerouter-x-configuration.md)、[hosts/gw.md](../hosts/gw.md)
|
||||
- UniFi:[unifi-network.md](unifi-network.md)、[hosts/ubnt.md](../hosts/ubnt.md)
|
||||
- `gfw`:[hosts/gfw.windy.lan.md](../hosts/gfw.windy.lan.md)
|
||||
- 作废方案(**不再实施**):[lan-erx-se5420-network.md](lan-erx-se5420-network.md)、[lan-core-switch-upgrade-plan.md](lan-core-switch-upgrade-plan.md)
|
||||
- 现网地图:[lan-overview.md](../lan-overview.md)
|
||||
- ER-X 现状:[edgerouter-x-configuration.md](../edgerouter-x-configuration.md)、[hosts/gw.md](../../hosts/gw.md)
|
||||
- UniFi:[unifi-network.md](../unifi-network.md)、[hosts/ubnt.md](../../hosts/ubnt.md)
|
||||
- `gfw`:[hosts/gfw.windy.lan.md](../../hosts/gfw.windy.lan.md)
|
||||
- 作废方案(**不再实施**):[lan-erx-se5420-network.md](../lan-erx-se5420-network.md)、[lan-core-switch-upgrade-plan.md](lan-core-switch-upgrade-plan.md)
|
||||
- MikroTik RB5009 官方:<https://mikrotik.com/product/rb5009ug_s_in>、RouterOS v7 手册
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# 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。文末「当前指南处理情况」列出各主张的现行状态;实施以部署指南现行为准。
|
||||
> **核对基准(历史快照):** 本文于 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` 的评审意见。结论分为
|
||||
“已证实”(规范/一手资料直接支持)、“基本证实”(架构推论成立但仍须读取现场配置)和
|
||||
@@ -0,0 +1,345 @@
|
||||
# Home Assistant × Matrix integration
|
||||
|
||||
Reference for wiring the Home Assistant [Matrix integration](https://www.home-assistant.io/integrations/matrix)
|
||||
to the self-hosted Matrix homeserver at [`synapse.chans.xyz`](../hosts/synapse.chans.xyz.md).
|
||||
Deliberately contains no Matrix passwords, access tokens, or room encryption material.
|
||||
|
||||
> **Status (2026-08-15, W1N-139):** the built-in `matrix` integration has been
|
||||
> **retired** on `hass.windy.lan` and replaced by the custom **`matrix_e2ee`**
|
||||
> integration. The sections below on the built-in integration are kept for
|
||||
> reference only. See [matrix_e2ee](#matrix-e2ee-custom-e2e-integration) for the
|
||||
> active setup and [Device verification (SAS) model](#device-verification-sas-model)
|
||||
> for how device trust works.
|
||||
|
||||
## Purpose
|
||||
|
||||
The integration lets Home Assistant send messages to Matrix rooms and react to
|
||||
messages/reactions in Matrix rooms. "Reacting" is done by firing a
|
||||
`matrix_command` event when one of the configured commands matches; automations
|
||||
then trigger on that event. Sending is done through the `notify.matrix` platform
|
||||
and the `matrix.send_message` / `matrix.react` actions.
|
||||
|
||||
## Environment mapping
|
||||
|
||||
| Integration setting | This deployment |
|
||||
|---|---|
|
||||
| `homeserver` | `https://synapse.chans.xyz` (client-server base URL) |
|
||||
| `username` | full Matrix ID, e.g. `@ha_bot:chans.xyz` |
|
||||
| `password` | MAS local-password account password (see below) |
|
||||
| Room IDs / aliases | full forms with the identity domain, e.g. `!cUrbafjkfsMDVwdRDQ:chans.xyz` or `#room:chans.xyz` |
|
||||
|
||||
- Identity domain is `chans.xyz` (not `synapse.chans.xyz`); user IDs and room
|
||||
aliases carry the `:chans.xyz` suffix.
|
||||
- Authentication on this homeserver is MAS (Matrix Authentication Service) with
|
||||
local-password accounts. The integration logs in with `m.login.password`
|
||||
(username + password), so the bot account must be a local-password account —
|
||||
same as the Hermes account documented in [`hermes-matrix.md`](hermes-matrix.md).
|
||||
If MAS is later switched to OAuth2/OIDC-only (no legacy password login), the
|
||||
integration's password login will stop working; keep that in mind before such
|
||||
a change.
|
||||
- Public registration is disabled. Create/reset the dedicated bot account via
|
||||
MAS / Element Admin.
|
||||
|
||||
## Use a separate bot account (mandatory)
|
||||
|
||||
The docs are explicit: to prevent infinite loops when reacting to commands,
|
||||
the integration **must** use a separate account from any account whose messages
|
||||
it reacts to. Use a dedicated account such as `@ha_bot:chans.xyz`, not a human
|
||||
account.
|
||||
|
||||
## configuration.yaml (example)
|
||||
|
||||
```yaml
|
||||
# The Matrix integration
|
||||
matrix:
|
||||
homeserver: https://synapse.chans.xyz
|
||||
username: "@ha_bot:chans.xyz"
|
||||
password: supersecurepassword
|
||||
rooms:
|
||||
- "#hasstest:chans.xyz"
|
||||
commands:
|
||||
- word: my_command
|
||||
name: my_command
|
||||
```
|
||||
|
||||
After changing `configuration.yaml`, restart Home Assistant to apply the
|
||||
changes. The integration then shows under **Settings → Devices & services**;
|
||||
its entities are on the integration card and the Entities tab.
|
||||
|
||||
### Configuration variables
|
||||
|
||||
| Variable | Meaning |
|
||||
|---|---|
|
||||
| `username` | Full Matrix ID the bot logs in as, e.g. `@ha_bot:chans.xyz`. The `@` has a special YAML meaning, so always quote it. |
|
||||
| `password` | The bot account's password (MAS local password). |
|
||||
| `homeserver` | Full client-server URL of the homeserver. |
|
||||
| `rooms` | Rooms the bot should join and listen in. List **all** rooms commands are to be received in, even if a command scopes itself to fewer rooms. Accepts internal room ID (`!…:chans.xyz`) or alias (`#room:chans.xyz`). |
|
||||
| `commands` | Commands to listen for. Each fires a `matrix_command` event when triggered. |
|
||||
|
||||
### Command types
|
||||
|
||||
| Key | Triggers when |
|
||||
|---|---|
|
||||
| `word` | A message starts with `!<word>`. Arguments after the word are captured as a list in the event's `data`. |
|
||||
| `expression` | A message matches the Python regexp. The regexp group dictionary is captured in the event's `data`. |
|
||||
| `reaction` | A message is reacted to with the given emoji. |
|
||||
| `name` | The command name, exposed as an attribute of the fired event. |
|
||||
|
||||
A command can be scoped to specific rooms with a per-command `rooms` list (the
|
||||
room must still be listed under the top-level `rooms`).
|
||||
|
||||
## Event data
|
||||
|
||||
When a command triggers, a `matrix_command` event fires with:
|
||||
|
||||
- `name` — the command name.
|
||||
- `data` — for `word` commands, a list of arguments (everything after the word,
|
||||
split on spaces); for `expression` commands, the group dictionary of the
|
||||
matching regexp.
|
||||
- `event_id` — the received message's identifier.
|
||||
- `thread_parent` — the root message ID of the thread; equals `event_id` when
|
||||
the message is not inside a thread.
|
||||
|
||||
## Notifications (notify.matrix)
|
||||
|
||||
Deliver notifications from Home Assistant to a Matrix room (direct or group):
|
||||
|
||||
```yaml
|
||||
notify:
|
||||
- name: matrix_notify
|
||||
platform: matrix
|
||||
default_room: "#hasstest:chans.xyz"
|
||||
```
|
||||
|
||||
- The target room must already exist; get its canonical ID from the room
|
||||
settings dialog (`!<randomid>:chans.xyz`) or an alias (`#roomname:chans.xyz`).
|
||||
Quote the room ID/alias in YAML to escape the `!` / `#` characters.
|
||||
- The notifying account may need to be invited to the room, depending on room
|
||||
policy.
|
||||
|
||||
Message formats (`data.format`): `text` (default) and `html`. Images can be
|
||||
attached via `data.images` (list of file paths); files from outside allowed
|
||||
folders require `homeassistant.allowlist_external_dirs` to list the source
|
||||
folder.
|
||||
|
||||
Reply inside a thread by passing the root message ID into `data.thread_id`:
|
||||
|
||||
```yaml
|
||||
action: notify.matrix_notify
|
||||
data:
|
||||
message: "Reply message goes here"
|
||||
data:
|
||||
thread_id: "{{ trigger.event.data.thread_parent }}"
|
||||
```
|
||||
|
||||
## Actions
|
||||
|
||||
- `matrix.react` — send a reaction to a message in a Matrix room
|
||||
(`reaction`, `room`, `message_id`).
|
||||
- `matrix.send_message` — send a message to one or more Matrix rooms.
|
||||
|
||||
## Comprehensive example (adapted)
|
||||
|
||||
```yaml
|
||||
matrix:
|
||||
homeserver: https://synapse.chans.xyz
|
||||
username: "@ha_bot:chans.xyz"
|
||||
password: supersecurepassword
|
||||
rooms:
|
||||
- "#hasstest:chans.xyz"
|
||||
- "#someothertest:chans.xyz"
|
||||
commands:
|
||||
- word: testword
|
||||
name: testword
|
||||
rooms:
|
||||
- "#someothertest:chans.xyz"
|
||||
- expression: "My name is (?P<name>.*)"
|
||||
name: introduction
|
||||
- reaction: 👍
|
||||
name: thumbsup
|
||||
|
||||
notify:
|
||||
- name: matrix_notify
|
||||
platform: matrix
|
||||
default_room: "#hasstest:chans.xyz"
|
||||
|
||||
automation:
|
||||
- alias: "Respond to !testword"
|
||||
triggers:
|
||||
- trigger: event
|
||||
event_type: matrix_command
|
||||
event_data:
|
||||
command: testword
|
||||
actions:
|
||||
- action: notify.matrix_notify
|
||||
data:
|
||||
message: "It looks like you wrote !testword"
|
||||
```
|
||||
|
||||
## matrix_e2ee (custom E2E integration)
|
||||
|
||||
Custom integration [`windyboy/ha-matrix-e2ee`](https://github.com/windyboy/ha-matrix-e2ee),
|
||||
release **v0.3.12** (Matrix activity events + push diagnostics), deployed on
|
||||
`hass.windy.lan` 2026-08-20 (upgraded from v0.3.2, W1N-182/#34 emoji-wait
|
||||
wizard fix; v0.3.9 brought the Connection health binary sensor, SAS/command
|
||||
allowlist split, URL normalization and single-entry enforcement, W1N-156/W1N-190).
|
||||
Runs a dedicated bot with a **persistent E2EE device identity**.
|
||||
|
||||
- Domain `matrix_e2ee`; Config Flow (UI) with YAML import migration, not in HACS. Does **not**
|
||||
override the built-in `matrix` integration.
|
||||
- Dependencies are declared **explicitly** in `manifest.json` to work around Home
|
||||
Assistant's `is_installed` dropping the `[e2e]` extra (W1N-140):
|
||||
`matrix-nio[e2e]==0.26.0` + `vodozemac` + `peewee` + `cachetools` + `atomicwrites`.
|
||||
- **v0.2.0 migration:** YAML `matrix_e2ee:` block was auto-imported into a Config Entry
|
||||
(`source: import`) on first startup, then removed. All settings now managed via
|
||||
**Settings → Devices & Services → Matrix E2EE → Configure**.
|
||||
See [hosts/hass.windy.lan.md](../hosts/hass.windy.lan.md) for the deployed state.
|
||||
|
||||
### Services & events
|
||||
|
||||
- Services (all admin-only since v0.1.4):
|
||||
- `send_message` (`message`, `room_id`)
|
||||
- `start_verification` (`user_id`, `device_id`)
|
||||
- `confirm_verification` (`transaction_id`)
|
||||
- `cancel_verification` (`transaction_id`)
|
||||
- `reauthenticate` (`password`) — soft-logout only
|
||||
- `get_fingerprint` (no fields; returns bot's own `ed25519`/`curve25519` keys; added v0.1.3)
|
||||
- `verify_device_by_fingerprint` (`user_id`, `device_id`, `ed25519`; added v0.1.3,
|
||||
renamed from `verify_device` in v0.1.4; requires exact `ed25519` match)
|
||||
- Events:
|
||||
- `matrix_e2ee_command` (`room_id`, `sender`, `command`, `args` only —
|
||||
never the raw body)
|
||||
- `matrix_e2ee_error` (codes, no secrets)
|
||||
- `matrix_e2ee_verification` (`stage`, `transaction_id`, `user_id`, `device_id`,
|
||||
optional `emojis`, optional `expires_at`; `expires_at` added v0.1.3)
|
||||
- `matrix_e2ee_fingerprint` (`user_id`, `device_id`, `ed25519`, `curve25519` —
|
||||
public keys only; added v0.1.3)
|
||||
- `matrix_e2ee_message_received` (`room_id`, `sender`, `event_id`; added v0.3.12
|
||||
activity events)
|
||||
- `matrix_e2ee_verification_done` (`transaction_id`, `user_id`, `device_id`;
|
||||
added v0.3.12)
|
||||
- v0.3.12 also adds an `event.` platform entity (`Bot activity`,
|
||||
`event_types: ["message", "command", "verification_done"]`) and a diagnostic
|
||||
Connection binary sensor (`binary_sensor.*_connection`, CONNECTIVITY class).
|
||||
- `notify.matrix_e2ee` is **not implemented** (upstream deferred) — notifications
|
||||
must call `matrix_e2ee.send_message` (message + room_id).
|
||||
- Commands fire Home Assistant events only; the integration never calls
|
||||
`domain.service` itself. Map commands in automations.
|
||||
- Encrypted rooms fail-closed on unverified devices.
|
||||
- Since v0.1.4: `start_verification`, `confirm_verification`, `cancel_verification`,
|
||||
`verify_device_by_fingerprint`, and `reauthenticate` are enforced as HA admin-only
|
||||
via `async_register_admin_service`; non-admin users cannot call them.
|
||||
|
||||
### Storage & recovery
|
||||
|
||||
- `.storage/matrix_e2ee_session.json` (`user_id`, `device_id`, `access_token`,
|
||||
`pickle_key`) and `.storage/matrix_e2ee_store/` (Olm/Megolm, device trust,
|
||||
sync token). Both stay on the HA persistent volume and are in HA backups.
|
||||
- Soft logout → `matrix_e2ee.reauthenticate` (keeps `device_id` + crypto store;
|
||||
rejected outside soft-logout state since v0.1.3).
|
||||
- Hard logout / store loss → delete session + store, restart with password, re-SAS
|
||||
(a **new device**; old history not decryptable).
|
||||
|
||||
## Device verification (SAS + fingerprint) model
|
||||
|
||||
Researched 2026-08-15 (W1N-139 stage-6 pre-study), updated for v0.1.3/v0.1.4.
|
||||
Sources: matrix.org
|
||||
[cross-signing guide](https://matrix.org/docs/guides/implementing-more-advanced-e-2-ee-features-such-as-cross-signing/),
|
||||
matrix-nio [examples](https://matrix-nio.readthedocs.io/en/latest/examples.html),
|
||||
[element-android#6832](https://github.com/vector-im/element-android/issues/6832),
|
||||
Element [device-verification](https://element.io/features/device-verification).
|
||||
|
||||
`matrix_e2ee` supports three verification paths (the wizard — v0.3.0
|
||||
bot-initiated, reworked in v0.3.1/v0.3.2 to wait for a peer-initiated inbound
|
||||
SAS from the user's Matrix client with emoji comparison — automates the SAS
|
||||
flow):
|
||||
|
||||
### 1. SAS (mutual, manual confirmation since v0.1.4)
|
||||
|
||||
- SAS is device-to-device: exchange ephemeral keys → derive emojis → **a human on
|
||||
each side compares and confirms** (`m.key.verification.mac`).
|
||||
- Matrix distinguishes two cases (spec uses *should*, not *must*):
|
||||
- **same user, two devices** → to-device messages (SAS);
|
||||
- **two different users** → **in-room (DM) messages**, verifying the *user*
|
||||
(cross-signing master key), not a specific device.
|
||||
- Cross-signing: each user has master / self-signing / user-signing keys. A device
|
||||
looks "verified" to another user via the chain
|
||||
`my master → my user-signing → their master → their self-signing → their device`.
|
||||
- Element's "Verify" button only starts **in-DM user verification**; it has no
|
||||
"verify a specific device of another user via to-device" flow (matrix.org
|
||||
recommends hiding per-device verification for other users).
|
||||
- `matrix_e2ee` implements **raw to-device device SAS** (`start_verification`/
|
||||
`confirm_verification`), **no cross-signing / in-room**. This is a non-standard
|
||||
cross-user path: works with matrix-nio + Element Web/Desktop (reported in
|
||||
element-android#6832), **not** on Element Android/X.
|
||||
- **v0.1.3**: inbound SAS auto-complete was added; SAS events include `expires_at`.
|
||||
- **v0.1.4 (breaking)**: auto-confirm was removed. **Every** device — including
|
||||
another device of the bot's own account — requires explicit `confirm_verification`
|
||||
after emoji comparison. Only the bot's own account or users in `allowed_users`
|
||||
may initiate SAS (`verification_peer_denied` otherwise).
|
||||
- **v0.2.1**: storage I/O moved off the event loop (`asyncio.to_thread`,
|
||||
W1N-167); own-keys query on startup so inbound SAS can build a session (W1N-166).
|
||||
- **v0.2.2** (not deployed): intermediate version.
|
||||
- **v0.2.3**: sync loop runs as a background task (fixes bootstrap setup timeout,
|
||||
W1N-168); SAS double-send of key and MAC fixed (W1N-169).
|
||||
- **v0.2.6**: `_log_verification_state()` tracks SAS state transitions with
|
||||
`async_write_ha_state` for diagnosis (W1N-174);
|
||||
`_bridge_verification_request()` handles inbound
|
||||
`m.key.verification.request` → `m.key.verification.ready` since nio lacks a
|
||||
`request` framework (W1N-173).
|
||||
- **v0.2.5**: bridge `m.key.verification.request` → `ready` (nio lacks
|
||||
request framework, W1N-173).
|
||||
- **v0.2.4**: `_patch_nio_sas_timeout()` works around nio 0.26.0
|
||||
`_last_event_time` bug (SAS timed out at 60s regardless of activity — now uses
|
||||
`_max_age` 5 min); `_repair_dropped_start()` recovers SAS `start` events nio
|
||||
dropped when the peer device was unknown (W1N-170/W1N-172);
|
||||
`VERIFICATION_TIMEOUT_SECONDS` 600→240 (fires before nio's `_max_age`).
|
||||
- **v0.2.11**: `receive_mac_event` no longer overrides canceled state (W1N-179/#31).
|
||||
- **v0.3.12**: Matrix activity events (`matrix_e2ee_message_received`,
|
||||
`matrix_e2ee_verification_done`) + `event.` Bot activity entity + Connection
|
||||
diagnostic binary sensor.
|
||||
- **v0.3.9**: SAS driver gate split from the command allowlist — new
|
||||
`verification_peer_users` option (W1N-156/#41); SAS/sync logs demoted
|
||||
warning→info/debug (W1N-188/#38); Connection health binary sensor
|
||||
(W1N-185/#40); URL normalization + single-entry enforcement (W1N-190/#42).
|
||||
- **v0.3.8**: `m.key.verification.done` handshake completion for
|
||||
request-based SAS (W1N-183/#35).
|
||||
- **v0.3.2**: wizard waits for the inbound SAS to show emojis before moving
|
||||
to the compare step (`_wait_for_inbound` requires `latest_sas_snapshot()` to
|
||||
return `emojis`) — W1N-182/#34.
|
||||
- **v0.3.1**: verification wizard now waits for a peer-initiated inbound SAS
|
||||
(options flow no longer starts verification from the bot; `latest_sas_snapshot()`
|
||||
skips verified/canceled transactions) — GitHub #33.
|
||||
- **v0.3.0**: bot-initiated device verification wizard (W1N-180/#32).
|
||||
- Inbound SAS is gated to `allowed_users` (v0.1.3); **since v0.3.9 (W1N-156)
|
||||
the gate is the separate `verification_peer_users` allowlist**, which is
|
||||
unset on hass.windy.lan — only the bot's own account may drive SAS until
|
||||
`@zhiqiang:chans.xyz` is added there.
|
||||
|
||||
### 2. One-sided fingerprint (added v0.1.3, hardened v0.1.4)
|
||||
|
||||
- Call `matrix_e2ee.get_fingerprint` to get the bot's own `ed25519` device key
|
||||
(read it from the `matrix_e2ee_fingerprint` event).
|
||||
- In Element, open the bot user's sessions and use "Manually verify by text".
|
||||
Compare the session key with the fingerprint.
|
||||
- To trust another device from the bot's side, call
|
||||
`matrix_e2ee.verify_device_by_fingerprint` with the peer's `user_id`, `device_id`,
|
||||
and `ed25519` key. The match is exact (since v0.1.4's rename from `verify_device`).
|
||||
Feed the **peer** key, not the bot's own key.
|
||||
- This trusts from one side only; the peer still trusts the bot independently.
|
||||
- Both `get_fingerprint` and `verify_device_by_fingerprint` are HA admin-only.
|
||||
|
||||
### Consequence
|
||||
|
||||
Whether `@zhiqiang`'s device can be verified depends on which Element client
|
||||
they use. Open options recorded in W1N-139 (A: Web SAS test; B: upstream
|
||||
in-room/cross-signing; C: unencrypted-room downgrade).
|
||||
|
||||
## References
|
||||
|
||||
- Home Assistant Matrix integration: <https://www.home-assistant.io/integrations/matrix>
|
||||
- Matrix host facts: [`hosts/synapse.chans.xyz.md`](../hosts/synapse.chans.xyz.md)
|
||||
- Matrix deployment and upstream index: [`matrix-upstream.md`](matrix-upstream.md)
|
||||
- Hermes Agent Matrix channel (MAS local-password + access-token pattern): [`hermes-matrix.md`](hermes-matrix.md)
|
||||
- HA host facts: [`hosts/hass.windy.lan.md`](../hosts/hass.windy.lan.md)
|
||||
- HA maintenance runbook: [`runbooks/home-assistant-maintenance.md`](../runbooks/home-assistant-maintenance.md)
|
||||
@@ -307,7 +307,7 @@ VLAN10 / 升级 SSID / 客人 SSID **失败或未做,不否决**本次核心
|
||||
|
||||
## 13. 参考
|
||||
|
||||
- 实施阶段与清单:[lan-core-switch-upgrade-plan.md](lan-core-switch-upgrade-plan.md)
|
||||
- 实施阶段与清单:[lan-core-switch-upgrade-plan.md](archive/lan-core-switch-upgrade-plan.md)
|
||||
- 现网地图:[lan-overview.md](lan-overview.md)
|
||||
- ER-X:[edgerouter-x-configuration.md](edgerouter-x-configuration.md)、[hosts/gw.md](../hosts/gw.md)
|
||||
- UniFi / VLAN10 前置:[unifi-network.md](unifi-network.md)
|
||||
|
||||
+60
-10
@@ -13,6 +13,11 @@ from each section below.
|
||||
> **Verified live on 2026-08-06** by read-only SSH from the WSL client. No
|
||||
> changes were made. `gfw.windy.lan` root SSH was re-verified the same day after
|
||||
> the key was installed; its facts below are from the fresh probe.
|
||||
>
|
||||
> **IPv6 re-verified 2026-08-20** (read-only): UniFi controller `Default`
|
||||
> network IPv6 enabled (SLAAC/RA), both APs hold global SLAAC addresses, and
|
||||
> `zhiqiangf` key-only AP SSH re-confirmed. See
|
||||
> [unifi-network.md](unifi-network.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -35,10 +40,14 @@ from each section below.
|
||||
│ ubnt — UniFi Network Controller (192.168.66.46)
|
||||
```
|
||||
|
||||
> **SE5420 purchased (2026-08-09):** TP-Link `TL-SE5420` acquired; deployment plan is
|
||||
> **SE5420 live (2026-08-22):** TP-Link `TL-SE5420` (purchased 2026-08-09) is
|
||||
> online — management `192.168.66.253` reachable, web UI on :80/:443; LAN55
|
||||
> 上联为 ER-X `switch0` **单口**(`eth1` up、`eth2`/`eth3` down,2026-08-22
|
||||
> 只读核实)→ `switch0` 不再是 LAN55 全量抓包点(同段有线单播在 SE5420 本地
|
||||
> 交换),全量点只能靠 SE5420 port mirroring。迁移状态见部署计划
|
||||
> [lan-se5420-deployment-guide.md](lan-se5420-deployment-guide.md). Design/planning refs:
|
||||
> [lan-erx-se5420-network.md](lan-erx-se5420-network.md),
|
||||
> [lan-core-switch-upgrade-plan.md](lan-core-switch-upgrade-plan.md).
|
||||
> [lan-core-switch-upgrade-plan.md](archive/lan-core-switch-upgrade-plan.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -47,19 +56,20 @@ from each section below.
|
||||
| Host | Role | SSH | IPv4 | Facts |
|
||||
|------|------|-----|------|-------|
|
||||
| **gw** | EdgeRouter X primary LAN gateway | `ssh -4 zhiqiang@192.168.66.254` | `192.168.66.254` | [hosts/gw.md](../hosts/gw.md) |
|
||||
| **PVE** | Proxmox host (`.66.26`/vmbr0 · `.55.26`/vmbr1) — hosts gfw/dns/ubnt/haos VMs | `ssh -4 root@192.168.66.26` | `192.168.66.26` | — |
|
||||
| **PVE** | Proxmox host (`.66.26`/vmbr0 · `.55.26`/vmbr1) — hosts gfw/dns/ubnt VMs | `ssh -4 root@192.168.66.26` | `192.168.66.26` | — |
|
||||
| **gfw.windy.lan** | OpenWrt LAN gateway / OpenClash — **PVE VM 140** | `ssh -4 root@192.168.66.1` | `192.168.66.1` | [hosts/gfw.windy.lan.md](../hosts/gfw.windy.lan.md) |
|
||||
| **dns.windy.lan** | AdGuard Home LAN DNS + Mihomo explicit proxy — **PVE VM 120** (`pihole`) | `ssh -4 windy@192.168.66.36` | `192.168.66.36` | [hosts/dns.windy.lan.md](../hosts/dns.windy.lan.md) |
|
||||
| **ubnt** | UniFi Network Controller — **PVE VM 160** | `ssh -4 windy@192.168.66.46` | `192.168.66.46` | [hosts/ubnt.md](../hosts/ubnt.md) |
|
||||
| **hass.windy.lan** | Home Assistant (HAOS) — **PVE VM 180** (LAN55) | `ssh hassio@hass.windy.lan` | `192.168.55.11` | [hosts/hass.windy.lan.md](../hosts/hass.windy.lan.md) |
|
||||
| **hass.windy.lan** | Home Assistant (HAOS) — **x88 Pro physical box** (LAN55) | `ssh hassio@hass.windy.lan` | `192.168.55.11` | [hosts/hass.windy.lan.md](../hosts/hass.windy.lan.md) |
|
||||
| **pgdb** | TimescaleDB PG18 (Docker) — HA recorder 后端 — **PVE VM** (LAN55) | `ssh -4 windy@192.168.55.15` | `192.168.55.15` | [hosts/pgdb.md](../hosts/pgdb.md) |
|
||||
| **NAS/FreeNAS** | NAS; `transmission` jail runs here (`.51`) | — | — | — |
|
||||
| **U6 Lite** | UniFi AP (LAN66) | `ssh -4 zhiqiangf@192.168.66.6` | `192.168.66.6` | [docs/unifi-network.md](../docs/unifi-network.md) |
|
||||
| **UAP-AC-Lite** | UniFi AP (LAN55) | `ssh -4 zhiqiangf@192.168.55.5` | `192.168.55.5` | [docs/unifi-network.md](../docs/unifi-network.md) |
|
||||
|
||||
> **Positioning facts (verified 2026-08-09):** `dns`/`ubnt`/`gfw`/`haos` are all VMs on PVE
|
||||
> (no separate physical hosts); `transmission` is a FreeNAS/NAS jail. Only gw, PVE,
|
||||
> NAS, U6, UAP-AC-Lite, and wired PCs/NAS are physical SE5420 ports. See
|
||||
> [lan-se5420-deployment-guide.md](lan-se5420-deployment-guide.md) §1.
|
||||
> **Positioning facts:** `dns`/`ubnt`/`gfw`/`pgdb` are VMs on PVE; `haos` is a **physical x88 Pro
|
||||
> box** (HAOS bare-metal, `machine: green`), not a PVE VM (corrected 2026-08-15).
|
||||
> `transmission` is a FreeNAS/NAS jail. Physical SE5420 ports: gw, PVE, haos, NAS,
|
||||
> U6, UAP-AC-Lite, and wired PCs. See [lan-se5420-deployment-guide.md](lan-se5420-deployment-guide.md) §1.
|
||||
|
||||
---
|
||||
|
||||
@@ -76,7 +86,10 @@ from each section below.
|
||||
| Port-forwards | `hass`→192.168.55.11:8123 · `transmission`→192.168.66.51:51413 · `ssh`→192.168.66.36:22 (orig 5822) · `openvpn`→192.168.66.32:1194 · WAN iface pppoe0 |
|
||||
| Management | SSH TCP 22 · EdgeOS GUI HTTP 80 / HTTPS 443 |
|
||||
|
||||
**Static DHCP mappings (LAN66):** `OnePlus-12`=.37, `gfw`=.1, `hp-nas`=.32, `pihole`=.36, `pve`=.26, `transmission`=.51, `ubnt-6`=.6, `ubnt-app`=.46, `windy-pc`=.99. LAN55: `Aqara-Hub-M3-10CB`=.248.
|
||||
**Static DHCP mappings (LAN66):** `OnePlus-12`=.37, `gfw`=.1, `hp-nas`=.32, `pihole`=.36, `pve`=.26, `transmission`=.51, `ubnt-6`=.6, `ubnt-app`=.46, `windy-pc`=.99. LAN55: `Aqara-Hub-M3-10CB`=.248, `SmartThings-Station`=.48, `espressif`=.47,
|
||||
`hass`=.11, `hass-wifi`=.250, `ihost`=.12, `midea_ac_0418`=.10,
|
||||
`midea_e3_0198`=.42, `roborock-wm-a141`=.43, `samsung-hub`=.251,
|
||||
`matter`=.41 (added 2026-08-20).
|
||||
|
||||
> **Note:** `LAN_IN`/`LAN_OUT` are defined but not applied to an interface, so LAN55
|
||||
> and LAN66 are bidirectionally reachable by default. Do not rely on those rules as
|
||||
@@ -154,7 +167,7 @@ See [docs/unifi-openclash-localhost.md](../docs/unifi-openclash-localhost.md).
|
||||
| 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) |
|
||||
| Platform | HAOS on physical x88 Pro box; kernel `6.1.115-haos` (aarch64), `machine: green` |
|
||||
|
||||
---
|
||||
|
||||
@@ -168,6 +181,43 @@ See [docs/unifi-openclash-localhost.md](../docs/unifi-openclash-localhost.md).
|
||||
Both reported **Connected** to `http://192.168.66.46:9080/inform` on 2026-08-06.
|
||||
AP SSH account is `zhiqiangf` (key-only, verified). See [docs/unifi-network.md](../docs/unifi-network.md).
|
||||
|
||||
**IPv6 (verified 2026-08-20):** both APs hold global SLAAC IPv6 addresses on
|
||||
`br0` — U6 Lite `240e:3bd:235:1fb1::/64` (LAN66), UAP-AC-Lite
|
||||
`240e:3bd:235:1fb2::/64` (LAN55) — with RA default routes via `gw`; the
|
||||
controller's `Default` network has IPv6 enabled (SLAAC). Prefixes are dynamic
|
||||
(PPPoE PD), so they rotate on redial. Details:
|
||||
[docs/unifi-network.md](../docs/unifi-network.md).
|
||||
|
||||
**SSID cleanup (2026-08-21, W1N-207):** the SmartThings Element/vWire provisioning
|
||||
SSIDs (`element-8a0d5133c9438f12`, `vwire-8b2d67469e455785`, `vport-F09FC22004E9`)
|
||||
were removed/disabled in the controller (`element_adopt` setting off, element wlanconf
|
||||
deleted, connectivity `x_mesh_essid`/`x_mesh_psk` cleared, device `x_vwirekey` removed,
|
||||
`vwire_enabled`/`mesh_sta_vap_enabled=false`) and cleared from both APs; all
|
||||
vwire/vport/element flags on the remaining SSIDs are now `disabled`.
|
||||
|
||||
**Stable ULA on gw: not feasible (2026-08-21, W1N-207):** EdgeOS v3.0.1
|
||||
`interfaces switch switch0` rejects a static `ipv6 address`, and an explicit
|
||||
`router-advert` node *replaces* the DHCPv6-PD-slaac RA (drops the delegated GUA
|
||||
prefix from radvd → LAN55 loses IPv6 egress after RA expiry). Attempted and rolled
|
||||
back cleanly (no `save`; gw config unchanged). Consequence: after a PD rotation,
|
||||
restart HA's matter-server (see [hosts/hass.windy.lan.md](../hosts/hass.windy.lan.md))
|
||||
to clear stale IPv6 mDNS caches.
|
||||
|
||||
**LAN55 RA environment (observed 2026-08-21):** besides `gw`, the SmartThings
|
||||
Station (.48) and Aqara M3 (.248) act as Thread border routers and advertise ULA
|
||||
prefixes (`fd00:5a7:6415:1::/64`, `fd97:d580:16fe:1::/64`); several LAN55 hosts
|
||||
(HA, PVE, UAP-AC-Lite) have IPv6 forwarding enabled and mark themselves as
|
||||
routers in NDP. This is normal Thread-BDR behaviour and was not the Matter
|
||||
failure cause.
|
||||
|
||||
**Matter 灯泡(2026-08-21 实测,W1N-207):** 两盏 ESP32-C2 Matter 灯泡
|
||||
(VP `0x4891/0x4100`,OUI `34:98:7a`)——工作盏 MAC `34:98:7a:25:a1:f0`;故障盏
|
||||
MAC `34:98:7a:27:7f:08`(hostname `matter`,动态 .145)。故障盏已在 Aqara fabric
|
||||
`4DF2B1455D19402D` 内、宣告 `CM=0`(不在配对模式)且缺 GUA → 找回需**恢复出厂**
|
||||
后扫它自己的二维码。DHCP 保留 `matter`(.45 → MAC `34:98:7a:27:10:bc`)与故障盏
|
||||
MAC 不符,保留从未租出(待修,见 [hosts/gw.md](../hosts/gw.md))。完整排障知识:
|
||||
[docs/matter-pairing-troubleshoot.md](matter-pairing-troubleshoot.md)。
|
||||
|
||||
---
|
||||
|
||||
## Quick orientation (who runs what)
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
|
||||
- 第一步不建 VLAN10、不向 ER-X 送任何 tag、口 4/6(PVE、U6)不做 trunk。
|
||||
- NAS 只接口 8,口 12 断开(LACP 是独立维护窗)。
|
||||
- 不占口的 VM:dns(.36=VM120)、ubnt(.46=VM160)、gfw(.1=VM140)、haos(.55.11=VM180);transmission(.51) 是 NAS jail。
|
||||
- 不占口的 VM:dns(.36=VM120)、ubnt(.46=VM160)、gfw(.1=VM140);haos(.55.11) 是物理 x88 Pro 盒子(非 VM);transmission(.51) 是 NAS jail。
|
||||
|
||||
## 3. 开箱与固件升级
|
||||
|
||||
@@ -207,7 +207,7 @@
|
||||
4. **验证:**
|
||||
- `ip -br addr`:`vmbr1` = `192.168.55.26/24`;
|
||||
- `ping -c3 192.168.55.254` → 通。
|
||||
5. 逐台验证 VM(顺序:gfw → dns → ubnt → haos):
|
||||
5. 逐台验证(顺序:gfw → dns → ubnt → haos;前三个是 VM,haos 是物理盒子):
|
||||
```bash
|
||||
ssh -4 root@192.168.66.26 'qm list'
|
||||
```
|
||||
@@ -215,7 +215,7 @@
|
||||
- dns:`ping -c3 192.168.66.36` → 通;
|
||||
- ubnt:`ping -c3 192.168.66.46` → 通;
|
||||
- haos:`ping -c3 192.168.55.11` → 通(注意是 55 网段)。
|
||||
6. 每个 VM 再验业务:gfw 的 OpenClash 面板/DNS 正常、dns 的 AdGuard UI 能开、ubnt 控制器 Connected、haos 界面能开。不以"宿主开机"代替。
|
||||
6. 每台再验业务:gfw 的 OpenClash 面板/DNS 正常、dns 的 AdGuard UI 能开、ubnt 控制器 Connected、haos 界面能开。不以"宿主开机"代替。
|
||||
|
||||
## 7. 迁移 AP 与接入设备
|
||||
|
||||
@@ -462,7 +462,7 @@ ssh -4 root@192.168.66.1 'uci show network; uci show firewall; uci show dhcp; ip
|
||||
|
||||
- 每次实质变更后在 Linear `vps` 项目记录 scope / action / verification / 遗留 follow-up。
|
||||
- 本仓库不记录 SE5420 口令、ER-X 配置快照(含 PPPoE/口令)、gfw 凭据。
|
||||
- 实施前先读 `se5420-review-claim-verification-2026-08.md` 的现场只读复核结论。
|
||||
- 实施前先读 `archive/se5420-review-claim-verification-2026-08.md` 的现场只读复核结论。
|
||||
|
||||
## 16. 回滚
|
||||
|
||||
@@ -486,10 +486,10 @@ ssh -4 root@192.168.66.1 'uci show network; uci show firewall; uci show dhcp; ip
|
||||
## 参考
|
||||
|
||||
- 设计说明:[lan-erx-se5420-network.md](lan-erx-se5420-network.md)
|
||||
- 评审核实:[se5420-review-claim-verification-2026-08.md](se5420-review-claim-verification-2026-08.md)
|
||||
- 评审核实:[se5420-review-claim-verification-2026-08.md](archive/se5420-review-claim-verification-2026-08.md)
|
||||
- 现网地图:[lan-overview.md](lan-overview.md)
|
||||
- 官方安装手册(Markdown 版):[se5420-official-manuals/tl-se5420-install-manual.md](se5420-official-manuals/tl-se5420-install-manual.md)
|
||||
- 官方 PDF:<https://service.tp-link.com.cn/download/202310/TL-SE5420%20V1.0安装手册%201.0.2.pdf>
|
||||
- 规格 / 固件:<https://www.tp-link.com.cn/product_2899.html?v=specification> · <https://www.tp-link.com.cn/product_2899.html?v=download>
|
||||
- Omada VLAN 指南:<https://support.omadanetworks.com/en/document/12981/> · <https://support.omadanetworks.com/en/document/13135/>
|
||||
- ER-X:[edgerouter-x-configuration.md](edgerouter-x-configuration.md);gfw:[hosts/gfw.windy.lan.md](../hosts/gfw.windy.lan.md);PVE VLAN10:[lan-rb5009-upgrade.md#阶段-5-附pve-上-vlan10-透传实现-调研-2026-08-09](lan-rb5009-upgrade.md#阶段-5-附pve-上-vlan10-透传实现-调研-2026-08-09)
|
||||
- ER-X:[edgerouter-x-configuration.md](edgerouter-x-configuration.md);gfw:[hosts/gfw.windy.lan.md](../hosts/gfw.windy.lan.md);PVE VLAN10:[lan-rb5009-upgrade.md#阶段-5-附pve-上-vlan10-透传实现-调研-2026-08-09](archive/lan-rb5009-upgrade.md#阶段-5-附pve-上-vlan10-透传实现-调研-2026-08-09)
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
# 低打印量黑白激光一体机:采购决策树(2026-08)
|
||||
|
||||
## 结论
|
||||
|
||||
对于“打印量很小、20 页/分钟足够、不需要 ADF”的需求,不应为 34 页/分钟、250 页纸盒或双面 ADF 付费。它们解决的是连续文档处理,而不是偶尔打印。优先级应为:**不被耗材/账号绑定、稳定的局域网打印、平板扫描、紧凑尺寸和可获得的原装耗材**。
|
||||
|
||||
首选是 **Brother DCP-L1638W 或 DCP-L1848W**:两者都是传统鼓粉分离路线,20 ppm、150 页纸盒、百兆有线网口及 2.4/5 GHz Wi-Fi;官方资料不能证实 1848 相比 1638 有实质功能升级,因此按正规渠道的含税到手价和保修选较便宜、在售的一台即可。[L1638W 官方参数表](https://www.brother.cn/-/media/ap/cn/products/pdf-file/prt/ESL-done/DCP-L1628L1638W.ashx);[L1848W 官方参数表](https://www.brother.cn/-/media/ap/cn/products/pdf-file/prt/ESL_DCP-L1848W.ashx)
|
||||
|
||||
不把 Brother DCP-11W 作为默认首选。它是 Brother 当前标为新品的同级机器,却是“云充”按页模式:激活后含 700 页,页数用完要通过绑定的微信账户购买套餐才能继续打印;粉仓和硒鼓由官方免费提供。这适合愿意换取三年保修和明确按页预算的人,不适合希望长期离线、自主选择耗材的人。[新品发布](https://www.brother.cn/info/news/20250613);[云充规则](https://www.brother.cn/minisite/sppackage/esl/)
|
||||
|
||||
## 先按需求分流,而不是按品牌或 ppm
|
||||
|
||||
```text
|
||||
需要批量扫描/复印多页原稿?
|
||||
├─ 是 → 本指南不适用;选择带 ADF 的 L2648DW 等级,双面原稿高频则看双 CIS 机型。
|
||||
└─ 否
|
||||
└─ 需要自动双面打印?
|
||||
├─ 是 → 选择 B7628DW 等带 duplex 的型号;这是功能升级,不是速度升级。
|
||||
└─ 否
|
||||
└─ 必须有有线网口或 5 GHz Wi-Fi?
|
||||
├─ 是 → Brother L1638W/L1848W 为基准选择。
|
||||
└─ 否 / 接受仅 2.4 GHz Wi-Fi
|
||||
└─ 是否接受按页充值并绑定微信?
|
||||
├─ 是 → Brother DCP-11W(先比较套餐与传统耗材总价)。
|
||||
└─ 否 → L1638W/L1848W;或比较下列 Canon/HP/Pantum。
|
||||
```
|
||||
|
||||
### 采购前的一票否决项
|
||||
|
||||
- 需要 macOS、iPhone/iPad:确认 AirPrint;不要假定“Wi-Fi”就等于无需驱动。
|
||||
- 打印机准备接交换机:确认具体 SKU 有 **Ethernet**,不能把 Wi-Fi Direct 当作局域网网口。HP MFP 1188w 的中国规格仅列 USB 和 2.4 GHz Wi-Fi,不列有线网口。
|
||||
- 偶尔使用也建议选激光,但纸张要长期放在干燥、封闭处;低使用量时,受潮纸和旧粉盒造成的底灰、掉粉或卡纸比 20/22 ppm 差异更常见。
|
||||
- 需要自动双面打印、ADF、双面扫描时,直接跳级;入门平板机硬凑这些功能没有性价比。
|
||||
|
||||
## 候选机型:仅保留与需求相符者
|
||||
|
||||
| 机型 | 联网 / 移动打印 | 核心纸路与扫描 | 耗材结构 | 面向本需求的判断 |
|
||||
|---|---|---|---|---|
|
||||
| **Brother DCP-L1638W / L1848W** | USB、100M Ethernet、2.4/5 GHz Wi-Fi;AirPrint、Mopria、Wireless Direct、Brother Mobile Connect | 20 ppm、150 页进/50 页出、平板 CIS;无 ADF、无自动双面 | TN118 约 1,500 页 + DR118 约 10,000 页,鼓粉分离 | **首选**:功能刚好够用,网络规格最好,自主耗材路径最清晰。两台按价格/现货择一。 |
|
||||
| **Brother DCP-11W** | USB、100M Ethernet、2.4/5 GHz Wi-Fi / Wi-Fi Direct | 20 ppm、150 页纸盒、平板扫描;未列 ADF 或自动双面 | 云充按页;耗材由官方供给 | 只有明确接受微信充值、看重三年保修时才选;不是“便宜传统激光机”。 |
|
||||
| **Pantum M6509NW** | USB、100M Ethernet、2.4 GHz 802.11b/g/n Wi-Fi;自带热点 | 22 ppm、150 页进/100 页出、平板扫描;手动双面 | 鼓粉一体 PD-219,官方标称 1,600 页 | **价格明显更低时的可比替代**。接口齐全,支持扫 PC/邮件/FTP/移动端,但机身较宽、仅 2.4 GHz,且鼓粉一体意味着每次换耗材同时更换感光组件。 |
|
||||
| **HP Laser MFP 1188w** | USB、2.4 GHz 802.11b/g/n、Wi-Fi Direct、AirPrint/Mopria/HP 应用;**无 Ethernet** | 22 ppm、150 页进/100 页出、平板扫描;手动双面 | 一体式黑色硒鼓;随机约 1,500 页 | 仅无线且 2.4 GHz 能满足时再比价。优点是 AirPrint/Mopria 明确、首张页快;不符合“网口或双频”优先条件。 |
|
||||
| **Canon iC MF232w** | Ethernet、2.4 GHz Wi-Fi、AirPrint、网络/移动扫描 | 23 ppm、250 页纸盒、平板扫描;无自动双面 | CRG337 一体式硒鼓 2,400 页 | 纸盒需求确实较大才考虑。官方建议价较高,且产品规格呈现的是较老的 IPv4 / 2.4 GHz 组合,不是本需求下的优先解。 |
|
||||
| **Canon iC MF272dw** | Ethernet、2.4 GHz Wi-Fi、AirPrint/Mopria | 29 ppm、150 页纸盒、平板扫描、自动双面打印 | CRG071:700 页随机、1,200/2,500 页商品硒鼓 | 功能不错但属于为自动双面打印升级;官方建议价 ¥3,838,不适合低量、单面为主时以性价比为目标的采购。 |
|
||||
|
||||
Brother 规格与耗材页数以官方参数表为准;Pantum 的接口、PD-219 和建议月印量 250–2,000 页见[官方产品页](https://www.pantum.cn/product-center/1487019260672548865.html)。HP 的 22 ppm、150 页、仅 Wi-Fi/USB、手动双面和一年保修见[中国官方规格](https://support.hp.com/cn-zh/product/product-specs/hp/2101513893)。Canon MF232w 的 23 ppm、250 页、CRG337、IPv4 和接口见[官方规格](https://www.canon.com.cn/product/icmf232w/spec.html);MF272dw 的 29 ppm、自动双面、接口及 CRG071 页数见[官方规格](https://www.canon.com.cn/product/icmf272dw/spec.html)。
|
||||
|
||||
## 耗材与锁定:应怎样理解
|
||||
|
||||
**不要只用“每页成本”决定低量用户。** 一年只打印几十到几百页时,机器差价、过期/存放不当的耗材风险和购买便利性,通常超过高容量粉盒带来的单位页优势。页产量也是 ISO 覆盖率下的额定值,不等于实际能稳定打印的页数。
|
||||
|
||||
- **传统耗材(Brother L1638W/L1848W)**:粉盒和硒鼓分开,硒鼓寿命远高于单盒粉量;这是长期低量使用中最可预测的结构。原装 TN118 / DR118 的料号和页数已由 Brother 公布。第三方粉盒或灌粉可以降低成本,但不属于厂商性能/保修承诺;低量用户省下的钱很有限,反而更容易把故障归因变复杂。建议首个生命周期使用原装或可靠授权渠道耗材。
|
||||
- **云充(DCP-11W)**:这里的锁定不是“第三方粉盒风险”,而是服务依赖:打印资格、套餐和耗材供给都依赖绑定的微信/官方流程。购买前应把预计三年页数代入套餐,确认账号更换、迁移、停服或转让场景的处理规则;并接受双面一张按两页计。
|
||||
- **一体式硒鼓(Pantum、HP、Canon)**:换粉即换鼓,维护动作简单;缺点是无法像鼓粉分离机那样只更换粉盒。不要据此推断“第三方一定不能用”或“必然会被固件锁死”——厂商公开资料通常只承诺原装耗材效果/保修,兼容耗材的芯片兼容性、质量和售后由销售方承担,应按批次验证。
|
||||
|
||||
## 最终推荐与购买动作
|
||||
|
||||
1. **默认买 Brother DCP-L1638W 或 DCP-L1848W**:选到手价更低、可开票、有本地退换/保修的那个;功能层面无需为 1848 付溢价。
|
||||
2. 若二者断货或溢价过大,**Pantum M6509NW** 是功能不降级的对照品;要求 5 GHz Wi-Fi 时排除它。
|
||||
3. 若只用手机/2.4 GHz Wi-Fi,且 HP 的即时价格有明显优势,才纳入 **HP 1188w**;它没有网口,不能接入现有有线网络。
|
||||
4. **不要因为“最新”买 DCP-11W**,除非云充模式本身是主动选择。对低量家庭用户,耗材自主权通常比三年保修更重要。
|
||||
|
||||
到货后先完成一次有线或基础 Wi-Fi 配网、AirPrint/Windows/macOS 实测、扫描为 PDF、睡眠唤醒和一张双面手动测试;保留试机页与发票。将设备放在受信任 LAN;如果启用 Wi-Fi Direct,设置强口令,平时不需要则关闭。
|
||||
|
||||
## 调研边界
|
||||
|
||||
本表只比较中国市场仍可由厂商官方页面/支持页核实的代表 SKU,价格、实际库存和促销会实时变化,未把电商标价写入结论。所谓“最新”以 Brother 中国目录/公告为准,而非“功能最强”或“最适合”。资料核查日期:2026-08-11。
|
||||
@@ -108,3 +108,4 @@ Steps:
|
||||
- Matrix Authentication Service: <https://github.com/element-hq/matrix-authentication-service>
|
||||
- Matrix spec: <https://spec.matrix.org/>
|
||||
- Federation tester: <https://federationtester.matrix.org/>
|
||||
- Home Assistant Matrix integration: [home-assistant-matrix.md](home-assistant-matrix.md)
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
# Matter 配网排障手册
|
||||
|
||||
> 基于 Matter 1.5.1 Core Spec §4.3.1 与本环境(EdgeRouter X + UniFi AP + Aqara M3 +
|
||||
> Home Assistant)2026-08-21 实测整理。配套 Linear W1N-207。
|
||||
|
||||
## 1. Matter 配网协议要点(发现即一切)
|
||||
|
||||
- **发现走 mDNS(DNS-SD)**,UDP **5353**,组播 `224.0.0.251` / `ff02::fb`。
|
||||
**不经过单播 DNS(如 AdGuard .36)、不需要反向 DNS、不需要 DHCPv6**(SLAAC 即满足 Matter
|
||||
的 IPv6 要求)。
|
||||
- 服务类型:
|
||||
- `_matterc._udp` — 可配网设备(Commissionable),**配对模式才有效**
|
||||
- `_matter._tcp` — 已配设备(Operational),TXT 里含 fabric 信息
|
||||
- 子类型(配对方按此过滤):
|
||||
- `_L<全12位 discriminator>`(如 `_L3266`)— 按二维码里的完整 discriminator 精确匹配
|
||||
- `_S<高4位>`(如 `_S12`)
|
||||
- `_V<vendorId>`、`_T<deviceType>`(可选)
|
||||
- `_CM`(仅真正处于配对模式时发布)
|
||||
- TXT 关键键:`D=`(discriminator,规范 **SHALL** 必填)、`VP=`(vendor+product)、
|
||||
**`CM=`**、`RI=`(rotating id)、`PH=`/`PI=`(配对提示)。
|
||||
- 配对端口:**TCP 5540**(PASE/CASE)。部分生态(Aqara M3)为 Thread 中继节点用 **5552**。
|
||||
- 实例名:64 位随机 hex;**进入配对模式时更换**(可用作"是否重新进过配对"的信号)。
|
||||
- 规范参考:[Matter 1.5.1 Core Spec §4.3.1](https://csa-iot.org/wp-content/uploads/2026/03/23-27349-010_Matter-1.5.1-Core-Specification.pdf)、
|
||||
[Google Home: Commissionable and Operational Discovery](https://developers.home.google.com/matter/primer/commissionable-and-operational-discovery)、
|
||||
[Matter Handbook: Discovery](https://handbook.buildwithmatter.com/how-it-works/discovery/)、
|
||||
[connectedhomeip: IP commissioning](https://pigweed.googlesource.com/third_party/github/project-chip/connectedhomeip/+show/59edd2ff8506b1e3dabb7040d716f0e75a2312d1/docs/guides/ip_commissioning.md)。
|
||||
|
||||
## 2. 关键判据:CM=0 = 不在配对模式
|
||||
|
||||
规范 §4.3.1.2 / §4.3.1.7:
|
||||
|
||||
- 设备可以长期宣告 `_matterc`(**Extended Discovery**),但 **`CM=0` 表示"当前不接受配网"**。
|
||||
- **已在 fabric 里的设备**(宣告里同时有 `_matter._tcp` + `_I<fabric>._sub` 运营记录)重配时
|
||||
通常报 `CM=0` —— 它已配好,不是新设备。
|
||||
- **配对方不能把已配设备当新设备加** → 重加/找回必须先**恢复出厂**(清 fabric,重启后以
|
||||
`CM=1` 全新配对模式宣告),再用**它自己的二维码**添加。
|
||||
- 常见误判:抓包看到 `_matterc` 宣告就以为"在配对模式"——**必须看 `CM=`**。
|
||||
|
||||
## 3. 本环境实测事实(2026-08-21,W1N-207)
|
||||
|
||||
| 事实 | 状态 |
|
||||
|---|---|
|
||||
| LAN55 IPv6/mDNS 链路 | ✅ 全正常(RA→交换机→AP→客户端;mDNS 双向通;igmp snooping off、mdns on、无客户端隔离、无组播增强、PMF off、WPA2、仅 2.4G) |
|
||||
| Matter 不依赖单播 DNS/.36、反向 DNS、DHCPv6 | ✅ 已排除(.36 健康且不在路径上) |
|
||||
| HA matter-server 曾宣告两代前的旧 GUA | ✅ 已修复(重启 `core_matter_server`;宣告恢复当前前缀) |
|
||||
| ISP PD /60 随重拨轮换 → Matter IPv6 缓存反复失效 | ⚠️ 环境性根因;对策 = 重拨后重启 matter-server + 重启 M3 |
|
||||
| EdgeOS 上静态 ULA 不可行 | ✅ 已尝试并回滚(switch0 不支持静态 `ipv6 address`;显式 router-advert 会替换 PD-slaac RA) |
|
||||
| 在用的两盏 ESP32-C2 Matter 灯泡(VP `0x4891/0x4100`;2026-08-23 复核) | 工作盏 MAC 已变为 `fc:e8:c0:25:a1:f0`(`.146`,hostname `espressif`;原 `34:98:7a:25:a1:f0` 全网消失,疑固件更新后换 MAC——末 3 字节相同);新盏 `34:98:7a:27:10:bc`(`.148`,hostname `matter`)。两盏各宣告 **3 个 fabric** 运营实例:Aqara `4DF2B1455D19402D`、`2F6E56020E1996E7`、HA `DCE86145C137AF0E`(见 §8) |
|
||||
| 故障盏 `34:98:7a:27:7f:08`(曾 .145,Aqara fabric,`CM=0` 缺 GUA) | 2026-08-23 复核:无租约、ARP incomplete、AP 无日志 = **已离网**(退役/退换) |
|
||||
| **失败模式 C(2026-08-23 实测,两盏同时)**:mDNS 活、5540 死 | 灯泡 ping 通(v4/v6)、DHCP 正常续租、mDNS 应答并宣告 `_matter._tcp`(SRV :5540、TXT `T=1`、当前前缀 GUA),但 **TCP 5540 在 IPv4 与 IPv6(fe80+GUA)均 RST 拒绝** → 配对方无法建立 CASE,App 显示离线;hass matter-server 侧无任何 established :5540 会话(详见 §8) |
|
||||
| ISP PD 前缀再次轮换(2026-08-23 → `240e:3bd:238:4812::/64`;08-22 为 `235:1fb2`) | hass 与 `.148` 均持当前前缀 GUA;hass 残留 `.146` 旧前缀 GUA 的 **FAILED** 邻居项(旧地址缓存仍被某端尝试) |
|
||||
| DHCP 保留 `matter`(.45 → MAC `…10:bc`) | ⚠️ 保留仍未生效:新灯泡(`…10:bc`)实际拿到动态 `.148` 而非保留的 `.45`(待修,见 hosts/gw.md) |
|
||||
| **新灯泡(2026-08-22 添加成功)**:MAC `34:98:7a:27:10:bc`(=DHCP 保留目标 MAC),hostname `matter`,IP `.148`,VP `4891/4100`,D=`3377` | ✅ 已入 **Aqara fabric `4DF2B1455D19402D`**;**经 BLE 配网**(Aqara Home App)——线上**无 TCP 5540** 属正常(BLE 会话对 AP/hass 抓包不可见) |
|
||||
| ESP32-C2 灯泡 firmware 挂死模式(2026-08-22 实测) | 入网后宣告 `_matterc`(CM=1、D=3377)约 **3 秒后网络栈完全静默**:STA 收发计数冻结、不掉线不重启、配对方(手机/M3 `_L3377` 查询)无应答 → 加不上。**对策=断电 10 秒重启**重新进配网模式(实例名更换:`3F4E2C66F2DA85CD`→`E5BA8E28E4DE23A0`),随即 App 添加即成功 |
|
||||
| 遗留 SSID(element/vwire/vport) | ✅ 已清理 |
|
||||
|
||||
## 4. 抓包方法(BusyBox 兼容)
|
||||
|
||||
> 完整指令集(实时 / 落盘轮转 / 定向抓取 / Wireshark 解密)见
|
||||
> [runbooks/matter-packet-capture.md](../runbooks/matter-packet-capture.md)。
|
||||
> 下面是最常用的两条。
|
||||
|
||||
**视角必须在 LAN55**。**HA matter-server 作配对方时推荐直接在 hass `end0` 抓**——配对方
|
||||
必然参与配对流程的每一条通讯(mDNS 本段组播 + 自己的 TCP 5540 全程),覆盖最全;AP `br0`
|
||||
能看到全部 mDNS 组播 + 无线客户端单播,但**看不到有线↔有线单播**(如 Thread 设备经有线 M3
|
||||
配对时 HA↔M3 的 5540 在 AP 侧不可见)。66 网段电脑看不到 55 的组播。BusyBox 注意点仅适用
|
||||
AP(**不要用 `--line-buffered`**;引号外层双引号、内层单引号);hass 是 HAOS 全量 tcpdump。
|
||||
|
||||
完整抓取(跑配对时保持窗口开着,`Ctrl+C` 结束):
|
||||
|
||||
```bash
|
||||
ssh zhiqiangf@192.168.55.5 "tcpdump -ni br0 -s 0 -vvv -tt 'udp port 5353 or tcp port 5540 or tcp port 5552'"
|
||||
```
|
||||
|
||||
hass 侧(HA matter-server 作配对方,推荐;非交互 ssh 需显式 `sudo -n -i`):
|
||||
|
||||
```bash
|
||||
ssh hassio@hass.windy.lan "sudo -n -i tcpdump -ni end0 -s 0 -vvv -tt 'udp port 5353 or tcp port 5540 or tcp port 5552'"
|
||||
```
|
||||
|
||||
精简过滤(只看 Matter 信号):
|
||||
|
||||
```bash
|
||||
ssh zhiqiangf@192.168.55.5 "tcpdump -ni br0 -s 0 -vvv -tt 'udp port 5353 or tcp port 5540 or tcp port 5552' | grep -E '_matterc|_matter|_L[0-9]+|_S[0-9]+|_CM|_V[0-9]+|_T[0-9]+|\.5540|\.5552'"
|
||||
```
|
||||
|
||||
存 pcap 供 Wireshark:把上面 `-w /tmp/matter.pcap` 追加到 tcpdump 参数(去掉 `-vvv`),
|
||||
`scp zhiqiangf@192.168.55.5:/tmp/matter.pcap .` 拉回本地分析。
|
||||
|
||||
> **落盘务必轮转**:AP `/tmp` 只有约 60MB。用
|
||||
> `-C 5 -W 12 -w /tmp/matter.pcap`(每 5MB 轮转、最多 12 个文件)防止写满,
|
||||
> 详见 runbook Step 3(落盘轮转)。
|
||||
> **Matter 载荷是加密的**:mDNS(5353)明文可读;5540 上的 Matter 报文要看明文
|
||||
> 需要 Wireshark matter-dissector + 会话密钥,详见 runbook Step 5(解密)。
|
||||
|
||||
### 阶段对照表
|
||||
|
||||
| 阶段 | 应该看到 | 对应问题 |
|
||||
|---|---|---|
|
||||
| 发现(设备侧) | `_matterc._udp` + `_L3266._sub` + `_S12._sub` + TXT `D=3266 CM=1` + SRV `:5540` + AAAA | **无宣告**=设备没入网/没进配对模式;**`CM=0`**=不在配对模式(已配设备);**无 `_L3266`**=固件子类型缺失 |
|
||||
| 发现(配对方侧) | M3/手机查询 `_L3266._sub._matterc._udp` | 查询有、无应答 = 码/discriminator 不匹配或设备不在线 |
|
||||
| 配对握手 | 到设备 IP **TCP 5540 SYN/SYN-ACK** 双向 | **SYN 无 ACK**=设备不可达/防火墙;**完全无 5540**=发现阶段没完成 |
|
||||
| 配完后 | 设备宣告 `_matter._tcp` + `_I<fabric>._sub` | 出现 = 已入网成功 |
|
||||
| BLE 配网(手机 App 直连设备 BLE,如 Aqara Home) | 线上**无 TCP 5540**(BLE 会话对 AP/hass 抓包不可见);设备入网后仍先 mDNS 宣告 `_matterc` | 成功判据=最终宣告 `_matter._tcp` + `_I<fabric>._sub`;无 5540 **不代表**失败 |
|
||||
|
||||
## 5. 排障决策树(按顺序)
|
||||
|
||||
1. 抓包看**有没有 `_matterc` 宣告**:没有 → 设备不通电 / 没连上 Wi-Fi / 没进配对模式
|
||||
(先解决"设备在线",网络侧已反复验证正常)。
|
||||
2. 有宣告但 **`CM=0`** → 设备已配 / 不在配对模式 → **恢复出厂**后重试(用它自己的二维码)。
|
||||
3. 有宣告 `CM=1` 但**无 `_L<disc>` 子类型** → 固件 mDNS 缺陷 → 升固件或换通用发现配对方。
|
||||
4. `CM=1` + 子类型齐全但**无 TCP 5540** → 配对方没匹配上(查码/discriminator)或设备不可达。
|
||||
5. 有 5540 但配对中断 → 查 `CM` 源(码是否正确)、设备电源、fabric 状态(是否需先清)。
|
||||
|
||||
## 6. 相关文档
|
||||
|
||||
- [runbooks/matter-packet-capture.md](../runbooks/matter-packet-capture.md) — Matter 抓包指令集(实时/落盘轮转/定向/解密)
|
||||
- [docs/lan-overview.md](lan-overview.md) — LAN 拓扑、SSID 清理、ULA 不可行
|
||||
- [hosts/hass.windy.lan.md](../hosts/hass.windy.lan.md) — matter-server 重拨运维规范
|
||||
- [docs/unifi-network.md](unifi-network.md) — UniFi 网络/IPv6/SSID 记录
|
||||
- [hosts/gw.md](../hosts/gw.md) — DHCP 保留 `matter` MAC 错位(待修)
|
||||
|
||||
## 7. 2026-08-22 实测记录:添加新 ESP32-C2 Matter 灯泡(成功 + 失败路径全记录)
|
||||
|
||||
> 场景:手机 App(Aqara Home)添加一盏**新的** ESP32-C2 Matter 灯泡
|
||||
> `34:98:7a:27:10:bc`(hostname `matter`,最终 IP `.148`)。中途换了灯泡并断电重启,
|
||||
> 共经历 **2 种失败模式** 和 **1 条成功路径**,全部抓包实证。
|
||||
>
|
||||
> 抓包点:UAP-AC-Lite `192.168.55.5` `br0`(轮转 `udp 5353 or tcp 5540 or tcp 5552`
|
||||
> + 定向全量 `ether host 34:98:7a:27:10:bc`)+ hostapd/stahtd 日志 + gw DHCP/ARP 交叉验证。
|
||||
> 本地用 tshark 4.7.2 分析。
|
||||
|
||||
### 时间线(CST,2026-08-22)
|
||||
|
||||
| 时间 | 事件 | 判据 / 说明 |
|
||||
|---|---|---|
|
||||
| 10:50:28 | 启动轮转抓包 | — |
|
||||
| 10:51:12–19 | 手机 `.143`(OnePlus,连 wifi0ap0=`ubnt-windy-2`)重新关联;查询 `_matter._tcp` | 运营查询(浏览已配设备),**不是**配网(配网应查 `_matterc._udp`) |
|
||||
| ~10:56 | 用户报「配置 wifi 后挂起,不能加入 wifi」 | 首次失败 |
|
||||
| 11:00:15 | M3 `.248` 查询 `_L3266._sub._matterc` | 无应答(是另一台设备的 discriminator,无关) |
|
||||
| 11:01:24–42 | **失败模式 A**:故障盏 `…7f:08` 尝试关联 `wifi0ap1`(`ubnt-haas`):发 1 次 open-auth 帧(algorithm 0)→ AP 回 `status_code=0` → **客户端不再发 assoc 请求** → 18s 后 `auth_failures=1` + disassociated | auth 阶段卡死(client 侧);非密码错——密码错会先 assoc 再 4-way 失败 |
|
||||
| 11:04:01–05 | **新灯泡 `…10:bc` 关联 `wifi0ap1` 成功**,WPA2 4-way 完成,DHCP 拿 `.148`(tracker `soft failure`: ip_delta 3.76s,avg_rssi -68);随即宣告 `_matterc`:实例 `3F4E2C66F2DA85CD`,TXT `VP=4891+4100 D=3377 CM=1`,SRV :5540,**有 GUA** | 发现阶段判据全过 |
|
||||
| 11:04:05 之后 | **失败模式 B**:灯泡网络栈完全静默——STA 收发计数冻结(rx=89/tx=5 持续 12s+ 不变)、**不掉线不重启** | firmware 挂死 |
|
||||
| 11:04:59–11:07:27 | 手机查 `_matterc` ×4、`.60` 解析实例、M3 查 `_L3377._sub._matterc` ×5(discriminator 3377 正是新盏)——**全部无应答**;TCP 5540/5552 全程 0 | 配对方找不到设备 → App 报「加不上」 |
|
||||
| 11:09:35–48 | **断电 10 秒重启**:灯泡重新关联 `wifi0ap1` ×2 | 对策生效 |
|
||||
| 11:10:26 | DHCP 重新拿 `.148`;STA 计数恢复持续增长(活跃) | — |
|
||||
| 11:10–11:15 | 重新宣告 `_matterc`(**新实例 `E5BA8E28E4DE23A0`**——入配对模式实例名更换,符合规范);App 走 **BLE 配网** | 线上无 TCP 5540(BLE 对 AP 不可见,属正常) |
|
||||
| 11:15:23 | 灯泡宣告 **`_matter._tcp`**:`4DF2B1455D19402D-02EF2FF12DFAF10E`(Aqara fabric)+ SRV :5540 + GUA + A | ✅ **添加成功**(已入 Aqara fabric `4DF2B1455D19402D`) |
|
||||
|
||||
### 结论与经验
|
||||
|
||||
1. **同族灯泡(VP 4891/4100,OUI 34:98:7a)存在两种不同失败模式**:
|
||||
- 故障盏 `…7f:08`:auth 阶段卡死(auth 帧后不发 assoc);此前(08-21 21:27)成功关联后伴随
|
||||
`ip_failures=1`(拿不到 IP)+ 缺 GUA —— 属更深层故障,需恢复出厂,本次未处理,仍离线。
|
||||
- 新盏 `…10:bc`:入网 + 宣告 `_matterc`(CM=1)成功后约 3 秒固件挂死(全静默)。
|
||||
**断电 10 秒重启即恢复**,是最简单有效的对策。
|
||||
2. **AP 抓包看不到 BLE 配网**:Aqara Home App 对 WiFi Matter 设备走 BLE 配网时,线上只有
|
||||
mDNS/DHCP,**无 TCP 5540 不代表失败**;成功判据 = 设备最终宣告 `_matter._tcp` + `_I<fabric>._sub`。
|
||||
3. **发现判据回顾**:`_matterc` + TXT(`CM=1`、`D=`、`VP=`)+ SRV :5540 + AAAA(GUA) + A 全齐才算
|
||||
设备真的在配对模式;配对方按 `_L<disc>._sub._matterc` 精确匹配 discriminator(本例 D=3377)。
|
||||
4. 新盏 RSSI -68、DHCP 3.76s,射频偏弱,可能加剧 firmware 不稳定(待观察)。
|
||||
5. DHCP 保留 `matter`(.45→`…10:bc`)**仍未生效**:新盏实际拿动态 `.148`(待修,见 hosts/gw.md)。
|
||||
6. 识别「配对方在找但设备不答」的快速方法:抓包里配对方持续查 `_matterc`/`_L<disc>` 而目标 MAC
|
||||
零应答 + STA 收发计数冻结 = 设备侧挂死;此时**先断电重启设备**,不要怀疑网络/AP。
|
||||
|
||||
### 后续:新盏 11:24 起离线循环(同一盏 `…10:bc`,2026-08-22)
|
||||
|
||||
配网成功后约 10 分钟(11:15–11:24 可控制),灯泡进入**持续性故障循环**:
|
||||
|
||||
| 时间 | 事件 | 模式 |
|
||||
|---|---|---|
|
||||
| 11:24:57 | `EVENT_STA_LEAVE`(真掉线) | 掉线 |
|
||||
| 11:25:12 | 重连 `auth_failures=2` | **auth 卡死**(同故障盏 `…7f:08` 11:01 的模式) |
|
||||
| 11:25:23–25 | 重连成功,WPA2 完成,重新拿 `.148` | — |
|
||||
| 11:25:38 | `soft failure`,ip_delta 2.65s,**avg_rssi -73**(-68→-73 持续变差) | 射频偏弱 |
|
||||
| 11:25 之后 | STA 计数冻结(rx=119/tx=64 不动);M3 持续查询其运营实例 `4DF2B1455D19402D-02EF2FF12DFAF10E._matter._tcp` **无应答** → App 显示「离线」 | 静默挂死 |
|
||||
|
||||
**结论**:三盏 ESP32-C2 灯泡中两盏(`…7f:08`、`…10:bc`)故障,表现覆盖 auth 卡死 / 静默挂死 /
|
||||
随机掉线三种形态;工作盏 `…25:a1:f0` 正常。网络侧(AP、M3、DHCP、mDNS)均验证正常。
|
||||
**疑似根因(按可能性)**:① ESP32-C2 Matter 灯泡 firmware 缺陷(同批次)② 射频偏弱
|
||||
(RSSI -73,天线/距离/遮挡)加剧不稳定 ③ 供电不稳(brownout 造成 Wi-Fi 栈崩溃重启)。
|
||||
**待办**:移近 AP 或改善供电后观察;App 内查固件更新;仍复发则考虑退换。
|
||||
|
||||
## 8. 2026-08-23 状态核查:两盏「半在线」——mDNS 宣告正常但 TCP 5540 无监听(失败模式 C)
|
||||
|
||||
> 全程**只读**核查(gw DHCP/ARP、AP hostapd 日志、hass matter-server 状态 + mDNS 抓包、
|
||||
> 对灯泡 v4/v6 的 TCP 5540 探测,09:0x CST)。结论:**网络侧全部健康;两盏灯泡网络栈活着、
|
||||
> mDNS 运营宣告正常,但 Matter 会话端点(TCP 5540)无监听**——配对方无法建立 CASE,
|
||||
> App 内应显示离线/不可达。
|
||||
|
||||
| 对象 | 状态(2026-08-23) |
|
||||
|---|---|
|
||||
| 新盏 `34:98:7a:27:10:bc`(`.148`,hostname `matter`) | DHCP 04:40 续租;gw ARP 完整;ping 通(93–122ms,ESP32 省电时延);08-22 16:40 起稳定关联 `wifi0ap1`,关联时 `avg_rssi -70`。mDNS 宣告 3 实例:`4DF2B1455D19402D-02EF079EEB480D07`(**新 node ID——08-22 之后被重新配网过**)、`2F6E56020E1996E7-137147AF27BE4EB6`、`DCE86145C137AF0E-0000000000000011`(HA fabric);host 记录 A `.148` + fe80 + **当前前缀** GUA `240e:3bd:238:4812:*`。支持单播 legacy mDNS 查询(`dig -p 5353 @.148 _matter._tcp.local PTR` 可用) |
|
||||
| 工作盏(MAC 已变)`fc:e8:c0:25:a1:f0`(`.146`,hostname `espressif`) | DHCP 07:17 续租;ping 通 v4/v6(v6 fe80 38–61ms)。mDNS 宣告 3 实例:`4DF2B1455D19402D-02EF4CA3F856B615`、`2F6E56020E1996E7-EE8F2E4F1A77BF05`、`DCE86145C137AF0E-000000000000000B`。原 MAC `34:98:7a:25:a1:f0` 全网消失(无租约/ARP/AP 日志)而新 MAC 末 3 字节相同 → 疑固件更新后改 MAC。**拒绝单播 5353**(ICMP port unreachable),只应答组播查询——同族固件行为差异。hass 残留其旧前缀 GUA `240e:3bd:235:1fb2:fee8:c0ff:fe25:a1f0` 的 **FAILED** 邻居项 |
|
||||
| 故障盏 `34:98:7a:27:7f:08`(曾 `.145`) | 无租约、ARP incomplete、AP 日志零事件 = 已离网 |
|
||||
| **TCP 5540 探测(两盏)** | IPv4(LAN66 与 hass 本段)、IPv6(fe80%end0 + 当前 GUA)全部 **RST(Connection refused)** —— SRV 宣告 :5540 且 TXT `T=1`,但实际无监听 |
|
||||
| hass matter-server | `started`,v9.0.4,无更新;宣告自身运营实例 `DCE86145C137AF0E-…1B669`(v4+v6,当前 GUA);**无任何 established :5540 会话**;core/add-on 日志无 matter 错误 |
|
||||
| 其他 Matter 控制器 | Aqara M3 `.248` 在线(有线 0.8ms),宣告含自身 fabric 节点 `4DF2B1455D19402D-11E158E46D24A000`;SmartThings `.48` 在线并周期查询 `_matter._tcp.local`;手机(当前前缀 GUA)也在浏览。LAN55 共见 **5 个 fabric**:`4DF2B1455D19402D`(M3)、`DCE86145C137AF0E`(HA)、`2F6E56020E1996E7`、`03BCFAEDD6153944`、`6A6FF80C2DB84DEE` |
|
||||
|
||||
**判定**:失败模式 C = TCP/IP 栈与 mDNS 守护进程活着(主动 RST、DHCP 续租、ping 通),
|
||||
但 Matter 应用层监听不存在。与模式 A(auth 卡死)、模式 B(全静默挂死)同族不同形态;
|
||||
**两盏同时处于同一状态**更指向共同诱因(固件缺陷,或 PD 轮换等共同事件后未恢复)。
|
||||
**对策(推荐,未执行)**:逐盏断电 10 秒重启(模式 B 的已验证对策),重启后复测
|
||||
TCP 5540 恢复监听即可确认。
|
||||
|
||||
**核查方法备忘**(只读,可复用):
|
||||
|
||||
- gw:`show dhcp leases` / `show arp`(经 `/opt/vyatta/bin/vyatta-op-cmd-wrapper`)。
|
||||
- AP:`grep -i <mac> /var/log/messages`(hostapd 关联事件 + stahtd RSSI/soft failure)。
|
||||
- hass:`sudo -n -i ha apps info core_matter_server`;`ip -6 neigh show dev end0`
|
||||
(看灯泡 fe80/旧新前缀 GUA 与 FAILED 项);被动抓包
|
||||
`sudo -n -i timeout 65 tcpdump -ni end0 -s 0 -tt 'udp port 5353'`——配对方周期查询
|
||||
会自然引出灯泡宣告,无需主动发包。
|
||||
- 5540 探测:hass 上 python3 对 v4 / fe80%end0 / GUA 各 connect 一次;RST=无监听,
|
||||
超时=不可达(两者含义不同)。
|
||||
@@ -1,158 +0,0 @@
|
||||
# 希力威视 SR-S25G3218F 调查(2026-08-08)
|
||||
|
||||
**结论:** 若需求是大量 2.5G 终端、少量 10G 光上联,`SR-S25G3218F` 的端口密度
|
||||
更合适;厂商已公开该型号的固件页,但仍缺少完整规格书、管理手册与兼容矩阵。若需求是 8 条全部可协商
|
||||
1/2.5/5/10G 的铜缆链路,且希望有可查的 L3 能力和固件入口,兮克
|
||||
`SKS8300-8T` 是资料更完整、风险更低的选择;它的代价是主动风扇、外置 12 V 电源、
|
||||
无 SFP+ 光口,且仍不应把消费级/SMB 设备当作安全边界或唯一核心。两者都应在
|
||||
到货可退换期内完成实机验收。
|
||||
|
||||
本页为采购前资料调查,不代表已接入本地网络;检索日期为 2026-08-08。
|
||||
|
||||
## 已能核实的事项
|
||||
|
||||
| 项目 | 结论与证据强度 |
|
||||
|---|---|
|
||||
| 型号/端口 | 京东的希力威视商品标题称该 SKU 为 `SR-S25G3218F`,有 16 个 2.5G 电口和 2 个万兆光口,并宣传 VLAN、端口隔离与 LACP。该店铺被厂商官网列为可购买的「京东旗舰店」,因此可作为销售规格,非技术手册。[京东商品页](https://item.jd.com/100165071727.html);[厂商购买渠道说明](https://en.sirivision.com/contactus/) |
|
||||
| 厂商身份 | 厂商官网为 Shenzhen/Guangdong Sirivision Communication;英文官网说明其自 2016 年起提供接入、汇聚和核心交换机方案。[厂商首页](https://en.sirivision.com/) |
|
||||
| 公开的二手厂家资料 | 同一制造商名义的 Alibaba 出口页将精确型号写成 `16*2.5G+2*10G`、`120Gbps`,并列出 QoS、VLAN、SNMP、L3 与 stackable。这是制造商发布在平台上的销售资料,**不是**官网数据表;其中后五项不能据此视为已验收的功能承诺。[制造商平台页](https://www.alibaba.com/pla/SR-S25G3218F-QoS-Managed-SFP-Switch-1625G210G_1601494946214.html) |
|
||||
| 固件入口 | 厂商已发布此精确型号的[固件页](https://www.sirivision.com/sr-s25g3218f%E5%9B%BA%E4%BB%B6/)。公开变更记录提到“光口自适应”和“增加 DAC 配置”;这证明厂商维护过该路径,**不**代表任意 SFP+/DAC/铜模块均兼容。 |
|
||||
| 本机可计算的带宽 | 端口线速相加为单向 60 Gb/s(16 × 2.5 + 2 × 10);若厂商所谓 `120Gbps` 是全双工交换容量,则数学上吻合。它**不**证明缓冲、PPS、表项规模或实际无阻塞性能。 |
|
||||
|
||||
## 网管/L2/L3 能力边界
|
||||
|
||||
京东标题足以支持把 VLAN、端口隔离、LACP 作为「卖家声称提供」的功能;不得由此推导出
|
||||
ACL、IPv4/IPv6 静态路由、SVI 数量、DHCP relay、OSPF/RIP、VRRP、IGMP、ERPS、
|
||||
802.1X、RADIUS/TACACS+、SSH/HTTPS 管理、SNMP 版本、日志/审计、配置备份或固件
|
||||
安全维护一定存在。
|
||||
|
||||
尤其要注意:厂商官网把真正列出的 2.5G L3 产品标为
|
||||
`SR-S25G3412F (8 × 2.5G + 4 × 10G SFP+)`;其 2.5G 类目只显示 7 个型号,
|
||||
不含 `SR-S25G3218F`。官网也把 L2+、Web Smart、L3 分成不同产品类别。这个目录
|
||||
差异**不是**证明 3218F 没有 L3,而是说明「三层」无法通过官网的精确型号文档确认。
|
||||
[2.5G 产品目录](https://en.sirivision.com/product-category/products/2-5g-switches/);
|
||||
[官网的 10G L3 目录](https://en.sirivision.com/product-category/products/10g-switches/10g-layer3-managed-switches/);
|
||||
[官网的 L2+ 分类示例](https://en.sirivision.com/product-category/products/gigabit-switches/gigabit-layer2-managed-switches/)。
|
||||
|
||||
采购前请向京东/厂商索取**与机身 SKU、硬件 revision 和固件版本对应**的 PDF
|
||||
数据表、管理手册和 release notes,并要求书面回答至少以下问题:
|
||||
|
||||
1. L3 是只有 VLAN Interface/IPv4 静态路由,还是另有 IPv6、ACL、动态路由、DHCP relay
|
||||
等;每项的最大 VLAN、MAC、ARP、路由、ACL、LAG 数量分别是多少?
|
||||
2. LACP 是否符合 802.3ad、一个 LAG 最多多少成员、能否跨两台设备(若销售页的
|
||||
`stackable` 属实,堆叠的线缆/模块、最大成员、控制面和软件版本为何)?
|
||||
3. 管理面是否支持 HTTPS/SSH、禁用 HTTP/Telnet、独立管理 VLAN、SNMPv3、syslog、NTP、
|
||||
配置导出/回滚和已签名或可校验的固件;默认凭据首次登录是否强制修改?
|
||||
|
||||
## 供电、散热和光口:当前不能确认
|
||||
|
||||
针对该精确 SKU,厂商官网目录与公开搜索未找到说明书/数据表,所以以下均为**待确认,
|
||||
不能猜测**:
|
||||
|
||||
- 是否为内置 AC 电源、额定输入范围/最大功耗、是否带电源开关和接地端子;是否完全
|
||||
不提供 PoE(本型号名和京东标题均未写 PoE,但这不足以替代规格书)。
|
||||
- 风扇数量、常态/满载噪声、风向、环境温湿度、机架深度与安装耳;不要将「金属壳」
|
||||
或产品照片等同于无风扇/静音。
|
||||
- 两个槽是否均为 **10G SFP+**,是否可协商 1G SFP;支持的 SR/LR/BiDi 波长距离、
|
||||
DAC/AOC 长度、第三方模块/EERPOM 兼容策略、10GBASE-T SFP+ 模块的功耗/温度限制,
|
||||
以及是否支持 GPON/XPON ONU「猫棒」。
|
||||
|
||||
厂商确实单列「SFP Optical Modules」产品分类,但这不构成 3218F 的兼容清单。
|
||||
[厂商产品导航](https://en.sirivision.com/)。购买光模块/直连线时,应要求厂商按这台
|
||||
设备的硬件/固件 revision 出具兼容型号清单;没有书面清单时,先在可退换期实测两端的
|
||||
链路、重启恢复、热插拔与长时间满载错误计数。
|
||||
|
||||
## 风险与建议验收
|
||||
|
||||
- **文档/生命周期风险(中到高):** 精确型号不在厂商当前官网 2.5G 目录,虽有固件下载页,
|
||||
但未公开完整型号手册、明确 release notes 或兼容矩阵。官网的售后条款也要求按具体产品查询保修期,配件(含光纤头)
|
||||
的保修条款与主机不同;不要把平台页的「3 年」当作中国零售 SKU 的已确认保修。
|
||||
[厂商售后条款](https://en.sirivision.com/after-sale-protection/)
|
||||
- **功能表述风险(高):** 页面将 L2 特性和「三层网管」并列;在命令/网页菜单、
|
||||
手册和测试证明之前,将其当作 L2 VLAN/LACP 设备部署,跨 VLAN 路由仍由现有网关承担。
|
||||
- **双 10G 上联约束(中):** 两个 SFP+ 可作双上联或一个二成员 LAG,但 LAG 增加的是
|
||||
多流量总吞吐,单一 TCP/UDP 流通常仍受一条 10G 链路限制;上级设备也必须匹配 LACP
|
||||
配置。
|
||||
- **管理面风险(中到高):** 家用/低价网管设备常见明文管理、弱默认口令或不透明的固件
|
||||
更新周期;采购后先置于受限管理 VLAN,改口令、升级已验证固件,且不将管理界面暴露
|
||||
到 WAN/访客网。
|
||||
|
||||
最低验收应包括:逐口协商 100M/1G/2.5G、两只不同厂家 SFP+/DAC(仅在卖家承诺支持的
|
||||
范围内)、VLAN trunk/access/PVID、STP/环路保护、LACP 故障切换、端口隔离、满载
|
||||
双向 iperf3 与错误计数、冷启动后的配置保留,以及管理面的 HTTPS/SSH/SNMPv3/配置备份。
|
||||
如无法提供与型号匹配的正式资料或其中任一关键项失败,应在退换期内退货,并选择公开
|
||||
数据表、固件与兼容矩阵更完整的型号。
|
||||
|
||||
## 备选:兮克 SKS8300-8T 对比
|
||||
|
||||
### 已核实的厂商规格
|
||||
|
||||
兮克官网的精确型号页明确将 `SKS8300-8T` 定位为三层管理型 10G 全电口交换机,并列出:
|
||||
|
||||
- 8 × 1/2.5/5/10GBASE-T RJ45;160 Gb/s 交换容量、119.05 Mpps、12 Mbit 缓存、
|
||||
16K MAC、12 KB 巨帧、512 MB DRAM、32 MB Flash,尺寸 207 × 136 × 35 mm;
|
||||
- QoS、ACL、IP+MAC+端口绑定、流分类/优先级标记、多端口镜像、静态/灵活 QinQ、
|
||||
sFlow,以及「基于策略的 IPv4/IPv6 单播路由」。
|
||||
|
||||
这些是厂商能力声明,并非对每一种路由协议或表项上限的承诺;但相对 3218F 的仅有
|
||||
销售标题,它给出了精确型号、转发性能和 L3 范围。[兮克 SKS8300-8T
|
||||
产品页](https://seekswan.com/user/custom-pages/SKS8300-8T.html)
|
||||
|
||||
独立的 OpenWrt 设备资料将其识别为 Realtek RTL9303、512 MB RAM,记录了原厂固件
|
||||
下载入口和串口/TFTP 恢复路径;其硬件数据页列为 12 V / 4 A。这支持「可恢复、可替换
|
||||
系统」的可操作性,但**不是**兮克对原厂功能的支持承诺。
|
||||
[OpenWrt 设备页](https://openwrt.org/toh/xikestor/sks8300-8t);
|
||||
[OpenWrt 硬件数据](https://openwrt.org/toh/hwdata/xikestor/xikestor_sks8300-8t)。
|
||||
|
||||
### 能力、物理与运维比较
|
||||
|
||||
| 维度 | 希力威视 SR-S25G3218F | 兮克 SKS8300-8T |
|
||||
|---|---|---|
|
||||
| 接口/典型用途 | 16 × 2.5G 电口 + 2 × 10G SFP+(销售规格);适合很多 2.5G 终端/NAS,以 10G 光或 DAC 上联。 | 8 × 1/2.5/5/10GBASE-T;适合 10G 铜缆设备、2.5/5G 多速率 NAS/主机。没有 SFP+,光纤上联必须经媒体转换或选另一型号。 |
|
||||
| 可确认的三层范围 | 仅销售/平台资料称 L3;没有精确型号官方手册,不能确认静态路由以外的功能。 | 官网明确写策略型 IPv4/IPv6 单播路由、ACL/QoS/sFlow/QinQ;动态路由、VRRP、IPv6 ACL/SNMP/认证等仍须按当前固件手册确认。 |
|
||||
| 冗余/二层 | 卖家声称 VLAN、端口隔离、LACP;STP/环网的实现与规格未知。 | 官网声明 L3 和多项转发特性,但未在产品页给出 STP/LACP/ERPS 的精确限制;购买前仍索取手册。 |
|
||||
| 散热/噪声 | 无可核实的精确型号风扇、噪声、功耗或风向数据。 | 独立手册镜像和产品图均称智能温控风扇,但厂商产品页未给 dBA;应按「有风扇、可能听得见」规划,不能承诺静音。 |
|
||||
| 供电 | 未找到精确型号官方输入/功耗资料。 | OpenWrt 硬件数据记录 12 V / 4 A;确认随附电源适配器的插头、余量和地区认证。官方产品页未给满载功耗。 |
|
||||
| 固件/恢复 | 有精确型号官方固件页;公开记录包含光口自适应与 DAC 配置改动,但未找到完整 release notes、恢复步骤或兼容矩阵。 | 厂商产品页提供「相关下载」区,OpenWrt 还记录原厂固件入口、RJ45 串口和 U-Boot/TFTP 恢复;原厂镜像是否签名、漏洞修复 SLA、配置回退仍未知。 |
|
||||
|
||||
关于 8T 的风扇、满载功耗(常见转述为 ≤36 W)、温度范围、芯片型号等,本次未找到
|
||||
相应的**厂商原始数据表**;不将第三方手册转录当作已核实规格。若噪声、UPS 容量或
|
||||
机柜散热是购买约束,请先让卖家提供产品铭牌照片、适配器铭牌照片、额定/实测功耗和
|
||||
dBA 测试条件。
|
||||
|
||||
### 选择与验收建议
|
||||
|
||||
- 选 **3218F**:必须有 ≥12 个 2.5G 接入端、10G 光/DAC 上联、且 L3 留给现有路由器。
|
||||
下单前先取得精确型号手册和 SFP+/DAC 兼容承诺;否则端口数量优势不足以抵消资料风险。
|
||||
- 选 **8T**:最多 8 个设备但需要多速率 10G RJ45、明确的 IPv4/IPv6 静态/策略路由和
|
||||
以后自行维护/恢复的余地。不要把其 160 Gb/s 标称交换容量误解为 8 端口同时 10G
|
||||
全双工的性能保证——该标称与端口总线速数学相等,但仍须以实测和厂商 PPS/缓冲说明为准。
|
||||
- 两台都不应单独承担防火墙、访客/IoT 安全隔离或 WAN 暴露;VLAN 的跨网段策略和公网
|
||||
边界留在受支持的网关/防火墙上。先为管理面创建专用 VLAN,仅从管理主机访问,禁用
|
||||
未使用的远程管理协议,备份配置和原厂固件后再接入生产网络。
|
||||
|
||||
## 低功耗核心备选(8 × 2.5G + 2 × SFP+)
|
||||
|
||||
如果核心只需接最多 8 台铜缆终端、上联/连接 NAS 使用 DAC 或光纤 10G,优先考虑没有
|
||||
PoE 的以下两款。它们都满足 VLAN trunk、LACP 和至少两个 10G SFP+ 的需求;不要为
|
||||
AP 选 PoE 版来承担核心,因为 PoE 预算、风扇和待机损耗都会明显增加。
|
||||
|
||||
| 型号 | 端口与管理能力(厂商声明) | 厂商功耗 / 噪声资料 | 对当前 LAN 的判断 |
|
||||
|---|---|---|---|
|
||||
| **TP-Link Omada SG3210X-M2** | 8 × 100M/1G/2.5G RJ45、2 × 10G SFP+,并有 RJ45 和 Micro-USB console。厂商规格列出 802.1Q VLAN、STP/RSTP/MSTP、静态 LAG 和 802.3ad LACP(最多 8 个聚合组、每组最多 8 端口);L3 是 32 个 IPv4/IPv6 接口、48 条静态路由。 | **无风扇**;100–240 V AC 内置电源。`UN 1.20` 数据表:待机最高 **6.0 W**(220 V/50 Hz、25 °C),最高 **15.3 W**(220 V)或 **15.0 W**(110 V)。 | **首选低功耗方案。** 足以做 LAN66 核心、给 PVE/gfw 与 U6 Lite 做 VLAN 10 trunk,并以 SFP+ DAC/光口连接 10G NAS/主机;它不提供 5G/10G RJ45,10G 铜缆需外置转换或 SFP+ 10GBASE-T 模块。 |
|
||||
| **MikroTik CRS310-8G+2S+IN** | 8 × 2.5G RJ45、2 × 10G SFP+;SFP+ 笼支持 1G/2.5G/10G。RouterOS v7(也可选 SwOS)支持 VLAN、链路聚合与 ACL。 | 18–57 V DC 外置供电;官方给出“无附件”最高 **21 W**、总体最高 **34 W**,且机内 **1 个风扇**。厂商没有在该页给出 dBA。 | 可用且软件/文档/恢复路径成熟,但不是本题的静音低功耗优先项:官方最大功耗显著高于 TP-Link,且有风扇。适合明确偏好 RouterOS/SwOS 与其可维护性时选。 |
|
||||
|
||||
功耗数字是各厂商的**上限/待机测试条件**,不是你实际墙插读数;SFP+ 光模块、DAC/AOC,尤其
|
||||
10GBASE-T SFP+ 模块,会另增功耗和热量。对于本网络,用被动 DAC 或短距光模块连接 10G
|
||||
设备,通常比全 RJ45 10G 核心更容易保持低温、低噪。
|
||||
|
||||
`SG3210X-M2` 的上表数据对应 TP-Link 的 `UN 1.20` 数据表;不同地区/硬件版本的包装、
|
||||
认证和功耗标注可能不同,购买中国零售版本前应让卖家确认**准确硬件版本、保修渠道和固件地区**。
|
||||
本次未找到 TP-Link 中国官网的该精确型号页,因此不能把海外官方页面当作大陆现货/售后承诺。
|
||||
MikroTik 同样应通过其官方零售商查询渠道确认本地库存和保修。两台购买前还应确认所选
|
||||
SFP+/DAC 的兼容清单。
|
||||
|
||||
来源:[TP-Link 产品规格](https://www.tp-link.com/uk/business-networking/omada-switch-access-pro/sg3210x-m2/);
|
||||
[TP-Link `UN 1.20` 数据表](https://static.tp-link.com/upload/product-overview/2025/202512/20251224/SG3210X-M2%28UN%29%201.20_datasheet.pdf);
|
||||
[MikroTik 产品页](https://mikrotik.com/product/crs310_8g_2s_in);
|
||||
[MikroTik 用户手册](https://help.mikrotik.com/docs/spaces/UM/pages/214630429/CRS310-8G%2B2S%2BIN)。
|
||||
+64
-3
@@ -68,6 +68,66 @@ db.device.find(
|
||||
).pretty()
|
||||
```
|
||||
|
||||
## IPv6 status (verified 2026-08-20)
|
||||
|
||||
IPv6 is **enabled and live** on the main Wi-Fi networks. Read-only
|
||||
verification, no changes made.
|
||||
|
||||
**Controller (`networkconf` in the `ace` DB):** the `Default` LAN network has
|
||||
`ipv6_enabled: true`, `ipv6_client_address_assignment: slaac`,
|
||||
`ipv6_ra_enabled: true`, `ipv6_ra_priority: high`, and
|
||||
`dhcpdv6_allow_slaac: true`. `ipv6_interface_type: "none"` is expected: the
|
||||
network's gateway is the third-party EdgeRouter (`gw`), so the controller does
|
||||
not manage WAN-side IPv6 — RA/SLAAC is served by the router.
|
||||
|
||||
All active SSIDs map to the `Default` network: `ubnt-windy` (5G),
|
||||
`ubnt-windy-2` (2.4G), `ubnt-haas` (2.4G) — clients on them receive SLAAC IPv6.
|
||||
|
||||
Exception: the dormant `ubnt-upg` VLAN 10 network (and its `ubnt-upg` SSID) has
|
||||
no IPv6 configuration (default off). See
|
||||
[Dedicated Wi-Fi through a third-party gateway](#dedicated-wi-fi-through-a-third-party-gateway).
|
||||
|
||||
**APs (live):** both managed APs hold global SLAAC addresses on `br0` with a
|
||||
default route learned via RA from `gw`:
|
||||
|
||||
| AP | Global IPv6 on `br0` (at check time) | Default route |
|
||||
|---|---|---|
|
||||
| U6 Lite (`192.168.66.6`) | `240e:3bd:235:1fb1:...`/64 | `default via fe80::... dev br0 proto ra` |
|
||||
| UAP-AC-Lite (`192.168.55.5`) | `240e:3bd:235:1fb2:...`/64 | `default via fe80::... dev br0 proto ra` |
|
||||
|
||||
The delegated prefixes are dynamic ISP allocations (PPPoE PD `/60`) and rotate
|
||||
on redial; only the structure is stable.
|
||||
|
||||
**Gateway (`gw`):** the IPv6 routing table shows connected `/64`s on `eth0`
|
||||
(LAN66) and `switch0` (LAN55) plus `::/0` via `pppoe0`.
|
||||
|
||||
Re-verify:
|
||||
|
||||
```bash
|
||||
ssh -4 -o BatchMode=yes zhiqiangf@192.168.66.6 'ip -6 addr show br0; ip -6 route show'
|
||||
ssh -4 -o BatchMode=yes zhiqiangf@192.168.55.5 'ip -6 addr show br0; ip -6 route show'
|
||||
```
|
||||
|
||||
> **2026-08-21 (W1N-207):** SmartThings Element/vWire provisioning SSIDs
|
||||
> (`element-8a0d5133c9438f12`, `vwire-8b2d67469e455785`, `vport-F09FC22004E9`) were
|
||||
> removed (element_adopt setting disabled + element wlanconf deleted + device vwire
|
||||
> fields cleared) and confirmed off on both APs (normal SSIDs unchanged: `ubnt-windy`,
|
||||
> `ubnt-windy-2`, `ubnt-haas`, `ubnt-upg`). Root cause of Matter onboarding failure that
|
||||
> day: HA's matter-server advertised a stale IPv6 GUA (two prefix generations old) in
|
||||
> mDNS; fixed by restarting the add-on — see [hosts/hass.windy.lan.md](../hosts/hass.windy.lan.md).
|
||||
>
|
||||
> **SSID ↔ subnet split (Matter-relevant):** `ubnt-windy` (5G) is served only by the
|
||||
> U6 Lite on LAN66; `ubnt-haas` / `ubnt-windy-2` (2.4G) only by the UAP-AC-Lite on
|
||||
> LAN55. mDNS is link-local multicast and does **not** cross the routed 55/66
|
||||
> boundary (no mDNS reflector). Matter commissioning therefore requires phone and
|
||||
> device on the **same subnet (LAN55)**; a phone on 5G (LAN66) cannot discover a
|
||||
> LAN55 Matter device.
|
||||
>
|
||||
> **Cleanup side-effects (left as-is, harmless):** after the direct-DB cleanup,
|
||||
> `db.device.cfgversion` holds placeholder values (`0000000000000000` /
|
||||
> `1111111111111111`) and UAP-AC-Lite has `mesh_sta_vap_enabled=false`; the
|
||||
> controller has not reverted them and no functional impact was observed.
|
||||
|
||||
## Dedicated Wi-Fi through a third-party gateway
|
||||
|
||||
### Architecture boundary discovered on 2026-08-08
|
||||
@@ -151,9 +211,10 @@ Use `ssh zhiqiangf@AP_IP` for the adopted-device account. Do not query or copy
|
||||
the controller's `mgmt` database setting into logs or documentation: it can
|
||||
contain the managed SSH password.
|
||||
|
||||
On 2026-08-06, key-only IPv4 SSH was verified for both managed APs using the
|
||||
`zhiqiangf` account. Verify future access without permitting password or
|
||||
keyboard-interactive fallback:
|
||||
Key-only IPv4 SSH was verified for both managed APs using the `zhiqiangf`
|
||||
account on 2026-08-06 and re-verified 2026-08-20 (BatchMode with password and
|
||||
keyboard-interactive disabled; both APs still log in key-only). Verify future
|
||||
access without permitting password or keyboard-interactive fallback:
|
||||
|
||||
```bash
|
||||
ssh -4 -o BatchMode=yes -o PasswordAuthentication=no \
|
||||
|
||||
@@ -48,9 +48,11 @@ space before increasing retention. DNSSEC is disabled because the selected
|
||||
upstream path did not pass the known-bad-signature validation check; do not
|
||||
enable it without re-testing validated upstreams.
|
||||
|
||||
The compatible names `hass.windy.lan` and legacy `hass.local` currently point
|
||||
to the same Home Assistant address. Migrate clients to `hass.windy.lan`; keep
|
||||
the legacy rewrite until its planned retirement.
|
||||
`hass.windy.lan` points to Home Assistant via this rewrite. The legacy
|
||||
`hass.local` rewrite was removed on 2026-08-14; `hass.local` now resolves only
|
||||
via HAOS mDNS/LLMNR (`hostname: hass`), not via AdGuard Home. The
|
||||
`nas.windy.local` rewrite was likewise removed on 2026-08-14; `.local` names
|
||||
are now left to mDNS only. Remaining rewrites all use `.windy.lan`.
|
||||
|
||||
## Mihomo and routing boundary
|
||||
|
||||
|
||||
+61
@@ -34,6 +34,18 @@ new SSH host key out of band before accepting it.
|
||||
IPv6 prefix delegation assigns SLAAC-capable `/64` networks to both LANs.
|
||||
`eth4` applies the WAN IPv4 and IPv6 firewall policies.
|
||||
|
||||
**SE5420 single-uplink topology (verified 2026-08-22):** the TP-Link `TL-SE5420`
|
||||
core switch is deployed — management `192.168.66.253` (TP-Link OUI `f8:c9:03`,
|
||||
web UI on :80/:443). The LAN55 uplink into `switch0` is a **single member
|
||||
port**: `eth1` link up, `eth2`/`eth3` down. All LAN55 wired devices (hass
|
||||
`.11`, Aqara M3 `.248`, SmartThings `.48`, UAP-AC-Lite `.5`) are reached via
|
||||
`switch0` behind that one uplink, so same-segment wired↔wired unicast is
|
||||
switched locally on the SE5420 and never reaches the ER-X. The switch FDB is
|
||||
hardware-offloaded and not readable from the ER-X (`brctl showmacs switch0` →
|
||||
"Operation not supported"; `show mac-address-table` / `show ethernet-switch`
|
||||
are not available on this EdgeOS build) — port link state (`show interfaces
|
||||
ethernet`) plus ARP are the reliable topology checks.
|
||||
|
||||
Detailed effective configuration, including firewall binding and WAN exposure,
|
||||
is recorded in [the EdgeRouter X configuration record](../docs/edgerouter-x-configuration.md).
|
||||
|
||||
@@ -77,6 +89,35 @@ relevant interface/direction to take effect. Use the operational `show
|
||||
firewall` output—not merely the configured rule definitions—to determine the
|
||||
effective policy.
|
||||
|
||||
## PPPoE redial
|
||||
|
||||
To force the `pppoe0` session to reconnect (e.g. to obtain a fresh WAN IP), use
|
||||
the operational `disconnect` / `connect` commands — **not** `renew dhcp
|
||||
interface`, which applies only to DHCP interfaces:
|
||||
|
||||
```bash
|
||||
ssh -4 zhiqiang@192.168.66.254
|
||||
/opt/vyatta/bin/vyatta-op-cmd-wrapper disconnect interface pppoe0
|
||||
/opt/vyatta/bin/vyatta-op-cmd-wrapper connect interface pppoe0
|
||||
```
|
||||
|
||||
`disconnect` tears down the PPP session; `connect` re-dials immediately. A
|
||||
short pause between them (a few seconds, or minutes for cautious ISPs) lets the
|
||||
old session finish teardown before redialing. This briefly drops the whole WAN
|
||||
uplink and may change the public IPv4 and delegated IPv6 `/60`; in-flight
|
||||
sessions and port-forwarded services are interrupted until the new session is
|
||||
up.
|
||||
|
||||
The `zhiqiang` account logs into `vbash`, not the EdgeOS CLI, so operational
|
||||
commands must be invoked through `/opt/vyatta/bin/vyatta-op-cmd-wrapper` and
|
||||
depend on its passwordless `sudo`. The `ubnt` account lands directly in the
|
||||
operational CLI, where the same commands are entered without the wrapper.
|
||||
`show`/`configure` are interactive-only aliases (from
|
||||
`/etc/bash_completion.d/vyatta-{op,cfg}`, loaded via `~/.bashrc`), so a
|
||||
non-interactive `ssh ubnt@… 'show …'` also fails — from a script use the op
|
||||
wrapper above, or `_vyatta_op_run` after sourcing `vyatta-op` with
|
||||
`vyatta_op_templates=/opt/vyatta/share/vyatta-op/templates`.
|
||||
|
||||
## Maintenance notes
|
||||
|
||||
- EdgeOS writes persistent changes through its configuration tree: enter
|
||||
@@ -100,3 +141,23 @@ from `192.168.55.254` reached the UniFi controller at `192.168.66.46` with
|
||||
3/3 ICMP replies. This supports the AP Inform path to
|
||||
`192.168.66.46:9080`; the controller listener and an online LAN55 AP provide
|
||||
the corresponding application-level evidence. No firewall changes were made.
|
||||
|
||||
IPv6 was re-verified by read-only SSH on 2026-08-20 during the UniFi AP/AC
|
||||
check: the IPv6 routing table shows connected `/64`s on `eth0` (LAN66) and
|
||||
`switch0` (LAN55) plus `::/0` via `pppoe0`; both UniFi APs obtained SLAAC
|
||||
addresses from the router's RAs. No configuration changes were made.
|
||||
|
||||
**DHCP 保留 `matter` 失效(2026-08-21 发现,2026-08-23 复核仍未生效,W1N-207):**
|
||||
静态映射 `matter` → .45 / MAC `34:98:7a:27:10:bc`,但该灯泡一直以**动态租约**拿
|
||||
`.148`(hostname `matter`;2026-08-23 09:02 时租约当日 04:40 已续租)。保留 .45 从未
|
||||
被租出。2026-08-23 复核补充:另一盏工作灯泡的 MAC 已变为 `fc:e8:c0:25:a1:f0`
|
||||
(动态 `.146`,hostname `espressif`),原「把 MAC 改为 `34:98:7a:27:7f:08`」的修正
|
||||
建议已过时(该灯泡已离网)。处置:删除该保留,或按现用 MAC(`.148` 的
|
||||
`34:98:7a:27:10:bc` / `.146` 的 `fc:e8:c0:25:a1:f0`)重建,**未执行**。
|
||||
|
||||
**SE5420 部署 + switch0 单上联(2026-08-22 只读核实):** `switch0` 成员口
|
||||
`eth1` link up、`eth2`/`eth3` down(单上联);SE5420 管理面 `192.168.66.253`
|
||||
在线(TP-Link OUI `f8:c9:03`,:80/:443);ARP 显示 LAN55 主机(hass `.11`、
|
||||
M3 `.248`、SmartThings `.48`、UAP-AC-Lite `.5`)全部经 switch0 可达。含义:
|
||||
`switch0` 不再是 LAN55 的全量抓包点(同段有线单播在 SE5420 本地交换),详见
|
||||
[runbooks/matter-packet-capture.md](../runbooks/matter-packet-capture.md)。
|
||||
|
||||
+305
-5
@@ -1,3 +1,4 @@
|
||||
[hosts/hass.windy.lan.md#8DF6]
|
||||
# hass.windy.lan — Home Assistant (HAOS)
|
||||
|
||||
## Role and access
|
||||
@@ -8,7 +9,7 @@
|
||||
| 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) |
|
||||
| **Host** | **x88 Pro physical box** (HAOS bare-metal, `machine: green`; verified 2026-08-18) |
|
||||
| Platform | Home Assistant OS; kernel `6.1.115-haos` (aarch64) |
|
||||
| Web UI | `http://hass.windy.lan:8123` (LAN); WAN port-forward `hass` on gw → `:8123` |
|
||||
|
||||
@@ -39,7 +40,8 @@ recovery codes in this repository.
|
||||
|
||||
| Interface | Address / role |
|
||||
|---|---|
|
||||
| `end1` | `192.168.55.11/24`; primary LAN55 address |
|
||||
| `end0` | IPv4 static `192.168.55.11/24` (gw `.254`, DNS `192.168.66.36`); IPv6 SLAAC `auto` with GUA on the current PD-derived /64 (`240e:3bd:235:1fb2:*` at 2026-08-22; rotates on PPPoE redial); primary LAN55 NIC (interface name verified live 2026-08-22 — `end1` does not exist) |
|
||||
| `wlan0` | Supervisor **disabled** (verified 2026-08-14, W1N-104); IPv6 remains off on this RTL8821CS radio |
|
||||
| `wg0` | `10.13.13.2/32`; WireGuard (add-on / integration tunnel) |
|
||||
| `hassio` / `docker0` | internal HAOS Docker bridges (`172.30.32.0/23`, `172.30.232.0/23`) |
|
||||
|
||||
@@ -97,7 +99,7 @@ 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'
|
||||
ssh -o BatchMode=yes hassio@hass.windy.lan 'hostname; ip -4 addr show end0'
|
||||
```
|
||||
|
||||
From a LAN client, confirm DNS and UI reachability:
|
||||
@@ -107,8 +109,306 @@ getent hosts hass.windy.lan
|
||||
# expect 192.168.55.11
|
||||
```
|
||||
|
||||
## Local patches (custom components)
|
||||
|
||||
|
||||
### Manual custom-component install (this host)
|
||||
|
||||
Home Assistant loads custom integrations from
|
||||
`<config>/custom_components/<domain>/` (HAOS: `/config` ≡ `/homeassistant`).
|
||||
A folder named after the integration domain, containing at least
|
||||
`manifest.json` and `__init__.py`, is enough; Core must be restarted after
|
||||
copying files. Official HA lookup order:
|
||||
`<config>/custom_components/<domain>` then built-in
|
||||
`homeassistant/components/<domain>`.
|
||||
See [Integration file structure](https://developers.home-assistant.io/docs/creating_integration_file_structure).
|
||||
|
||||
This host **does not git-clone** custom components. The live tree is a file
|
||||
copy. Do not `git pull` on HA.
|
||||
|
||||
**Official plugin path** (from
|
||||
[windyboy/china_southern_power_grid_stat README](https://github.com/windyboy/china_southern_power_grid_stat)):
|
||||
HACS **or** [手动下载安装](https://github.com/windyboy/china_southern_power_grid_stat/releases).
|
||||
This host uses the latter. Releases here have no uploaded zip assets; use
|
||||
GitHub's **Source code (zip)** / zipball of the tag.
|
||||
|
||||
**UI (Samba / File editor / Studio Code Server):**
|
||||
|
||||
1. Download Source code (zip) from the GitHub Release.
|
||||
2. Extract. Copy only the inner
|
||||
`custom_components/china_southern_power_grid_stat/` tree — not the repo
|
||||
root, not a nested extra folder.
|
||||
3. Place it at `/config/custom_components/china_southern_power_grid_stat/`.
|
||||
4. Restart Core (**Settings → System → Restart**).
|
||||
5. First install only: **Settings → Devices & services → Add integration**.
|
||||
|
||||
**SSH from the workstation** (verified 2026-08-14, W1N-107). Replace `v1.3.1`
|
||||
with the tag being installed:
|
||||
|
||||
```bash
|
||||
TAG=v1.3.1
|
||||
STAGE=/tmp/csg-${TAG}-deploy
|
||||
mkdir -p "$STAGE"
|
||||
gh api "repos/windyboy/china_southern_power_grid_stat/zipball/${TAG}" \
|
||||
> "$STAGE/src.zip"
|
||||
unzip -q "$STAGE/src.zip" -d "$STAGE"
|
||||
SRC=$(find "$STAGE" -type d -path '*/custom_components/china_southern_power_grid_stat' | head -1)
|
||||
# expect .../custom_components/china_southern_power_grid_stat
|
||||
|
||||
ssh -o BatchMode=yes hassio@hass.windy.lan \
|
||||
'sudo -n -i mkdir -p /homeassistant/.csg-backups &&
|
||||
sudo -n -i cp -a /homeassistant/custom_components/china_southern_power_grid_stat \
|
||||
/homeassistant/.csg-backups/china_southern_power_grid_stat.bak-$(date +%Y%m%d)-manual'
|
||||
|
||||
rsync -a --delete \
|
||||
-e 'ssh -o BatchMode=yes' \
|
||||
"$SRC/" \
|
||||
hassio@hass.windy.lan:/homeassistant/custom_components/china_southern_power_grid_stat/
|
||||
|
||||
# --delete cannot remove Core-owned __pycache__; wipe as root, then restart
|
||||
ssh -o BatchMode=yes hassio@hass.windy.lan \
|
||||
'sudo -n -i rm -rf /homeassistant/custom_components/china_southern_power_grid_stat/__pycache__ \
|
||||
/homeassistant/custom_components/china_southern_power_grid_stat/*/__pycache__ &&
|
||||
sudo -n -i ha core restart'
|
||||
```
|
||||
|
||||
Wait until Core is up (`ha core info` returns, typically 1–2 min; this CLI
|
||||
build does not print a `state:` field).
|
||||
Then:
|
||||
|
||||
```bash
|
||||
ssh -o BatchMode=yes hassio@hass.windy.lan \
|
||||
'sudo -n -i cat /homeassistant/custom_components/china_southern_power_grid_stat/manifest.json'
|
||||
# version must match the tag
|
||||
|
||||
ssh -o BatchMode=yes hassio@hass.windy.lan \
|
||||
'sudo -n -i ha core logs -n 2500' | grep -E 'china_southern_power_grid_stat|cannot pickle' || true
|
||||
```
|
||||
|
||||
**Host constraints (do not skip):**
|
||||
|
||||
- Backups **must** live in `/homeassistant/.csg-backups/`. A `*.bak-*`
|
||||
directory next to the live folder is scanned as the same domain and Core
|
||||
fails with `No module named '...bak-YYYYMMDD-...'`.
|
||||
- Do not install this fork via HACS on this host. HACS still tracks
|
||||
`CubicPill/china_southern_power_grid_stat` `v1.2.0`; a HACS update would
|
||||
overwrite the live copy.
|
||||
- First poll after restart can time out to CSG over IPv4; if this-month
|
||||
sensors stay `unknown` while last-month filled, reload the config entry
|
||||
(UI: integration → Reload, or supervisor
|
||||
`POST /core/api/config/config_entries/entry/<id>/reload`).
|
||||
- `runbooks/scripts/ha-maintenance.sh --restart-core --yes` can print
|
||||
nothing and exit 1 in under a second **without restarting Core**. The
|
||||
wrapper's ssh line discards stderr (`2>/dev/null`); with `pipefail`,
|
||||
an ssh failure yields empty stdout + exit 1 before any remote command
|
||||
runs. Do not treat that as a completed restart. Confirm with elapsed
|
||||
time (~2 min for a real restart) and `ha core info`. Prefer
|
||||
`ssh -o BatchMode=yes hassio@hass.windy.lan 'sudo -n -i ha core restart'`.
|
||||
|
||||
Full command family: [runbooks/home-assistant-maintenance.md](../runbooks/home-assistant-maintenance.md).
|
||||
|
||||
### `china_southern_power_grid_stat` live tree
|
||||
|
||||
**v1.3.2** (`934f58c`, verified 2026-08-15, W1N-118): manual zipball of
|
||||
GitHub release
|
||||
[v1.3.2](https://github.com/windyboy/china_southern_power_grid_stat/releases/tag/v1.3.2)
|
||||
copied to `/config/custom_components/china_southern_power_grid_stat`.
|
||||
Earlier trees: v1.3.1/`55a293fc` (W1N-107), v1.3.0/`69f13c90` (W1N-106),
|
||||
`a433e8c` (W1N-105), `de01914` (W1N-103), `eb8b174` (W1N-102). Backups:
|
||||
`/homeassistant/.csg-backups/` (w1n102/104/105/106/107/118).
|
||||
|
||||
v1.3.0 crashed the coordinator on first refresh
|
||||
(`TypeError: cannot pickle 'mappingproxy' object` in
|
||||
`copy.deepcopy(self._config)` under Python 3.14 / HA 2026.8.1). v1.3.1
|
||||
wraps those `deepcopy` calls with `dict(...)`. Post-restart 22:13 CST:
|
||||
entry `loaded`, no pickle traceback. Native this-month sensors filled after
|
||||
reloading entry `01KGCQDSZCF523A9X6SV3BZ1B9` (`ip_family: ipv4`). Native
|
||||
cost/ladder sensors can stay `unknown` because CSG
|
||||
`get_month_daily_cost_detail` returns a marketing-system SQL error; the
|
||||
dashboard uses template ladder/cost entities instead. Do not change
|
||||
`templates/csg_sensors.yaml` or the 电力监控 dashboard for an install.
|
||||
|
||||
Home PPPoE IPv4 to CSG is still blackholed (`curl -4` to `218.19.148.218:443`
|
||||
times out). `end0` IPv6 is enabled (`ipv6.method: auto`); from HA,
|
||||
`curl -6 https://95598.csg.cn` returns HTTP 200 via `240e:f9:8060::1:16`.
|
||||
|
||||
**`tianqi` weather recorder patch (verified 2026-08-13, W1N-75):**
|
||||
`/config/custom_components/tianqi/weather.py` has a local patch adding
|
||||
`_unrecorded_attributes = frozenset({"hourly_temperature", "hourly_skycon",
|
||||
"hourly_cloudrate", "hourly_precipitation"})` to the `WeatherEntity` class.
|
||||
Without it, weather.guangzhou's state attributes (~19 KB, dominated by the 4
|
||||
hourly_* arrays of up to 48 entries) exceed the recorder 16384-byte limit, so
|
||||
the recorder drops **all** attributes for the entity and logs
|
||||
`Recorder.db_schema: State attributes for weather.guangzhou exceed maximum
|
||||
size of 16384 bytes`. The patch excludes only the 4 arrays from recording
|
||||
(live state unchanged; other attributes still stored; ~6.3 KB payload). Backup
|
||||
at `weather.py.bak-w1n75`. **Re-apply after any `tianqi` component update.**
|
||||
The `_unrecorded_attributes` mechanism exists in Core 2026.8.1
|
||||
(`Entity.__init_subclass__` → `state_info["unrecorded_attributes"]`, consumed
|
||||
by recorder `shared_attrs_bytes_from_event`).
|
||||
|
||||
|
||||
### `matrix_e2ee` live tree (E2E Matrix bot, verified 2026-08-20)
|
||||
|
||||
**v0.3.12** (tag `v0.3.12`; feat — Matrix activity events
|
||||
`matrix_e2ee_message_received` / `matrix_e2ee_verification_done` + push
|
||||
diagnostics; v0.3.9 added Connection health binary sensor, SAS/command
|
||||
allowlist split, URL normalization, single-entry enforcement):
|
||||
source copy from `/home/windy/project/ha-matrix-e2ee` `ea421ed` (tag
|
||||
`v0.3.12`) deployed 2026-08-20 via SSH rsync from workstation (upgraded
|
||||
from v0.3.2, backup `matrix_e2ee.bak-20260820-v0.3.2`).
|
||||
Custom **`matrix_e2ee`** integration — **Config Flow** (UI). See
|
||||
[docs/home-assistant-matrix.md](../docs/home-assistant-matrix.md).
|
||||
**Update runbook:** [runbooks/matrix-e2ee-update.md](../runbooks/matrix-e2ee-update.md).
|
||||
|
||||
Earlier: v0.3.2 (tag `v0.3.2`, W1N-182/#34: wizard waits for inbound SAS
|
||||
emojis) deployed 2026-08-18 from `d35c484` (backup
|
||||
`matrix_e2ee.bak-20260818-v0.3.1`); v0.3.1 (GitHub #33: peer-initiated
|
||||
verification wizard fix) deployed 2026-08-18 from `d22e935` (backup
|
||||
`matrix_e2ee.bak-20260818-v0.3.0`); v0.3.0 (W1N-180/#32: bot-initiated
|
||||
verification wizard; W1N-179/#31 `receive_mac_event` cancel-state fix)
|
||||
deployed 2026-08-18 from `216cc99` (backup
|
||||
`matrix_e2ee.bak-20260818-v0.2.10`).
|
||||
|
||||
- Bot `@hass:chans.xyz` reused (E2EE device `rO1R915ncu`). Config Entry
|
||||
`01M04D7C1M4T2GX5VPG7NVQ7GV` (`source: import`, `state: loaded`). All
|
||||
settings via **Settings → Devices & Services → Matrix E2EE → Configure**.
|
||||
- Config Entry options: `allowed_rooms` `["!gidvAzpDzwtzfEDrqu:chans.xyz", "!boxfylDSzOvrWkcsyY:chans.xyz"]`,
|
||||
`allowed_users` `["@zhiqiang:chans.xyz"]`, `command_prefix` `"!"`.
|
||||
**`verification_peer_users` not set** (v0.3.9+ SAS allowlist split from
|
||||
`allowed_users`, W1N-156): defaults to empty → only the bot's own account
|
||||
may drive SAS; `@zhiqiang` is denied until the option is added via
|
||||
Settings → Devices & Services → Matrix E2EE → Configure.
|
||||
- Storage: `/config/.storage/matrix_e2ee_session.json` +
|
||||
`/config/.storage/matrix_e2ee_store/`. Backups:
|
||||
`/homeassistant/.matrix-e2ee-backups/` (incl. `matrix_e2ee.bak-20260820-v0.3.2`,
|
||||
`matrix_e2ee.bak-20260818-v0.3.1`,
|
||||
`matrix_e2ee.bak-20260818-v0.3.0`,
|
||||
`matrix_e2ee.bak-20260818-v0.2.10`,
|
||||
`matrix_e2ee.bak-20260816-v0.2.9`, `matrix_e2ee.bak-20260816-v0.2.8`);
|
||||
full HA backup slugs `3d9d36db` (pre-v0.1.4) + `9f223f35` (pre-v0.2.0).
|
||||
- v0.3.12: Matrix activity events + push diagnostics
|
||||
(`matrix_e2ee_message_received` / `matrix_e2ee_verification_done`).
|
||||
v0.3.9: Connection health binary sensor (W1N-185/#40), config-entry
|
||||
diagnostics (W1N-184/#39), SAS/command allowlist split
|
||||
`verification_peer_users` (W1N-156/#41), SAS/sync logs demoted
|
||||
warning→info/debug (W1N-188/#38), URL normalization + single-entry
|
||||
enforcement (W1N-190/#42).
|
||||
v0.3.8: `m.key.verification.done` handshake for request-based SAS
|
||||
(W1N-183/#35).
|
||||
v0.3.2: wizard waits for inbound SAS emojis before the compare step
|
||||
(W1N-182/#34).
|
||||
v0.3.1: verification wizard waits for a peer-initiated inbound SAS instead
|
||||
of the bot starting SAS (GitHub #33).
|
||||
v0.3.0: bot-initiated device verification wizard (W1N-180/#32).
|
||||
v0.2.11: `receive_mac_event` no longer overrides canceled state (W1N-179/#31).
|
||||
- v0.2.9: restore SAS emoji rendering after vodozemac migration (W1N-175/#29).
|
||||
v0.2.8: SAS commitment unpadded base64 for Element interop (W1N-174/#28).
|
||||
v0.2.7: SAS cancel code/reason logging. v0.2.6: verification state logging +
|
||||
request→ready bridge. v0.2.4: `_patch_nio_sas_timeout()` +
|
||||
`_repair_dropped_start()`; `VERIFICATION_TIMEOUT_SECONDS` 600→240.
|
||||
- Automation `1761188403590`「Matrix 聊天关卫生间灯」: trigger
|
||||
`matrix_e2ee_command` (command `关卫生间灯`), actions `light.turn_off` +
|
||||
`matrix_e2ee.send_message` (room `!gidvAzpDzwtzfEDrqu`).
|
||||
- **SAS not yet completed:** every device requires explicit `confirm_verification`.
|
||||
Encrypted-room commands stay fail-closed until `@zhiqiang`'s device is verified.
|
||||
Since v0.3.9 the SAS driver gate uses `verification_peer_users` (empty on
|
||||
this host) instead of `allowed_users` — add `@zhiqiang:chans.xyz` there
|
||||
before retrying the wizard. Three paths available: SAS manual confirm,
|
||||
fingerprint, or the device verification wizard (v0.3.0 bot-initiated,
|
||||
reworked in v0.3.1/v0.3.2 to wait for a peer-initiated inbound SAS from
|
||||
Element with emoji comparison), see
|
||||
[docs/home-assistant-matrix.md § Device verification](../docs/home-assistant-matrix.md).
|
||||
### Scribe long-term history (verified 2026-08-29)
|
||||
|
||||
- **Scribe 3.8.0** (`/homeassistant/custom_components/scribe/`), configured from
|
||||
`configuration.yaml` (`scribe:` block; config entry
|
||||
`01KC2VFJWEQ3XDHY6TQKHPDVRB`, `source: import` — UI "Configure → Advanced"
|
||||
edits are overridden by the YAML on restart; treat YAML as authoritative).
|
||||
- TimescaleDB at `192.168.55.15:5432/scribe` (DB user `hass`; host in inventory,
|
||||
see [hosts/pgdb.md](../hosts/pgdb.md)). Database re-initialized 2026-08-29 14:06 CST
|
||||
(user-handled; earlier `relation "entities" does not exist` errors resolved).
|
||||
Health: `binary_sensor.scribe_database_connection`.
|
||||
- 2026-08-29 config applied (backup `/homeassistant/configuration.yaml.bak-20260829-scribe`):
|
||||
- `record_events: true` with `include_events` whitelist: `automation_triggered`,
|
||||
`matrix_e2ee_command`, `matrix_e2ee_message_received`,
|
||||
`matrix_e2ee_verification_done`, `script_started`, `tag_scanned`,
|
||||
`mobile_app_notification_action`, `homeassistant_start`, `homeassistant_stop`.
|
||||
- State noise trimmed: `exclude_domains` update/button; glob
|
||||
`sensor.zigbee2mqtt_bridge_*`; 4 hassio cpu/mem-percent entities.
|
||||
- Global `exclude_attributes` drops tianqi `hourly_*` arrays (~19 KB/state —
|
||||
the recorder-side `_unrecorded_attributes` patch does not apply to Scribe).
|
||||
- `enable_stats_io` + `enable_stats_size` on → 14 `sensor.scribe_*` stats
|
||||
entities (`scribe_states_written`, `scribe_events_written`, rates, sizes).
|
||||
- Verified post-restart 14:23 CST: writer started, `scribe_events_written=1`
|
||||
(homeassistant_start), states ~110/min, buffer 3, no scribe log errors.
|
||||
- **Scribe 3.8.0 has no retention option** (upstream feature, newer versions
|
||||
only). Retention requires upgrading Scribe first; the DB is a fresh 3.x-schema
|
||||
so an upgrade is safe. Do not expect retention YAML keys to validate on 3.8.0.
|
||||
- Recorder stays external-Postgres with `purge_keep_days: 30` for daily UI
|
||||
history; Scribe is the permanent archive.
|
||||
|
||||
## Known issues
|
||||
|
||||
**Bluetooth hci0 instability — RTL8821CS (verified 2026-08-13, W1N-74):**
|
||||
The local Bluetooth controller hci0 is an **RTL8821CS** combo chip on the
|
||||
x88 Pro board. Kernel logs show recurring `hci0: hardware error 0x00`,
|
||||
`Opcode 0x200c tx timeout` (HCI_LE_Set_Scan_Parameters), `Unable to disable
|
||||
scanning: -110`, `Peer device has reset` — the chip hardware-stalls during
|
||||
active scanning. HA's `bluetooth_auto_recovery` power-cycle then times out
|
||||
after 5 s and retries every ~2 min:
|
||||
`bluetooth_auto_recovery.recover: Could not reset the power state of the
|
||||
Bluetooth adapter hci0 ... due to timeout after 5 seconds`. The HAOS image
|
||||
already ships custom systemd units to cope (`x88-bt-hci-recovery.service` and
|
||||
a "Patch HA Bluetooth scanner mode for x88 RTL8821CS" service, visible in host
|
||||
journal). **No user impact:** there are **no BLE entities** in HA
|
||||
(xiaomi_ble / bthome / led_ble / bluetooth / esphome domains are all empty;
|
||||
platforms merely load from stray advertisements). Real IoT devices are Zigbee
|
||||
(via Zigbee2MQTT) or WiFi/MQTT/cloud. An ESPHome Bluetooth-proxy ESP32
|
||||
(`/config/esphome/bluetooth.yaml`, bluetooth_proxy: active, WiFi `ubnt-haas`)
|
||||
is configured but currently offline (ESPHome add-on stopped, port 6053
|
||||
unreachable) and produced no entities. Follow-up (optional): disable the
|
||||
local adapter and rely on the ESPHome proxy, or stop the bluetooth
|
||||
integration entirely.
|
||||
|
||||
**eMMC disk lifetime 10% (verified 2026-08-13, W1N-76):** `ha host info`
|
||||
reports `disk_life_time: 10` — the boot eMMC (`/dev/mmcblk2`, CJTD4R
|
||||
`0xacacc064`, 64 GB) has ~10% life left. `disk_free: 40.2/56.4 GB`. Full
|
||||
backup `pre-maintenance-20260813` (slug `411a4ba5`, 144.26 MB) taken
|
||||
2026-08-13 covers current config; monitor `disk_life_time` on each health
|
||||
snapshot and plan a disk replacement / data-disk migration before the eMMC
|
||||
fails.
|
||||
|
||||
## Matter Server (verified 2026-08-21)
|
||||
|
||||
- Add-on `core_matter_server` (`homeassistant/aarch64-addon-matter-server`) runs the Matter
|
||||
commissioner on this host (host networking; add-on container `app_core_matter_server`).
|
||||
- **After the ISP PD prefix rotates (PPPoE redial), the add-on can cache a stale IPv6 GUA
|
||||
in its mDNS advertisement** — clients trying that dead address make Matter
|
||||
commissioning/connection fail. Fix: restart the add-on so it re-enumerates addresses:
|
||||
`ssh hassio@hass.windy.lan 'sudo -n -i ha apps restart core_matter_server'`
|
||||
(`ha addons restart ...` also works; "addons" is deprecated in favor of "apps").
|
||||
- Verified 2026-08-21 (W1N-207): stale `240e:3bd:234:2f22:*` AAAA in mDNS removed by
|
||||
restart; advertisement now carries only current GUA `240e:3bd:235:1fb2:*` + link-local;
|
||||
CASE sessions with Aqara M3 / SmartThings hubs resumed over IPv6 link-local.
|
||||
|
||||
> **Open items (2026-08-21, W1N-207):** a phone on LAN55 was querying five known
|
||||
> `_matter._tcp` instances of which only HA answered — the other Matter nodes are
|
||||
> offline / not announcing (device-side; user to confirm power/Wi-Fi). HA's IPv6
|
||||
> default route via NetworkManager was observed missing once (curl -6 intermittent,
|
||||
> while ping6 and `curl -6 --noproxy` work) — not the Matter root cause; re-check
|
||||
> on the next health snapshot.
|
||||
|
||||
Verified 2026-08-23 (read-only, W1N-207): add-on `started`, version `9.0.4`, no
|
||||
update pending; current GUA `240e:3bd:238:4812:*` (PD rotated again since 08-22)
|
||||
advertised correctly over v4+v6. Both ESP32-C2 bulbs now announce `_matter._tcp`
|
||||
(multi-fabric, including this host's fabric `DCE86145C137AF0E`) — but they
|
||||
**refuse TCP 5540 on IPv4 and IPv6**, so matter-server holds **zero established
|
||||
:5540 sessions** (device-side failure mode C; no errors logged — see
|
||||
[docs/matter-pairing-troubleshoot.md §8](../docs/matter-pairing-troubleshoot.md)).
|
||||
|
||||
## Related docs
|
||||
|
||||
- [runbooks/home-assistant-maintenance.md](../runbooks/home-assistant-maintenance.md) — `ha` CLI maintenance runbook + [script](../runbooks/scripts/ha-maintenance.sh)
|
||||
- [runbooks/home-assistant-maintenance.md](../runbooks/home-assistant-maintenance.md) — `ha` CLI maintenance runbook + [script](../runbooks/scripts/ha-maintenance.sh); custom-component zip install is §7
|
||||
- [docs/lan-overview.md](../docs/lan-overview.md) — LAN map and gw port-forward
|
||||
- [hosts/dns.windy.lan.md](dns.windy.lan.md) — `hass.windy.lan` / `hass.local` rewrites
|
||||
- [hosts/dns.windy.lan.md](dns.windy.lan.md) — `hass.windy.lan` / `hass.local` rewrites
|
||||
@@ -0,0 +1,51 @@
|
||||
# pgdb — TimescaleDB (PG18, Docker)
|
||||
|
||||
## Role and access
|
||||
|
||||
| Item | Value |
|
||||
|---|---|
|
||||
| Role | TimescaleDB PostgreSQL 18 (Docker) — Home Assistant recorder 后端(`hass`/`scribe` 库) |
|
||||
| IPv4 | `192.168.55.15` (LAN55) |
|
||||
| DNS | (none) |
|
||||
| SSH | `ssh -4 windy@192.168.55.15`(key auth 已验证可用 2026-08-29;agent 沙箱用 `ssh -F /dev/null -o BatchMode=yes`;password auth 亦可) |
|
||||
| Host | PVE 管理的 QEMU VM(i440FX),Debian 13 (trixie),内核 6.12.105 |
|
||||
| Resources | 2 GB RAM / 30 GB disk(26 G 空闲) |
|
||||
| Docker | 29.7.2;容器 `timescaledb` = `timescale/timescaledb:latest-pg18`(PG **18.6** + TimescaleDB **2.29.2**,Apache-2.0 版) |
|
||||
| Ports | `192.168.55.15:5432`(PG,IPv4 only);`192.168.55.15:8081`(pgweb GUI,basic auth) |
|
||||
|
||||
## Databases
|
||||
|
||||
| DB | Owner | Size | 用途 |
|
||||
|---|---|---|---|
|
||||
| `hass` | hass | ~14 MB | HA recorder(states/events/statistics),客户端 HAOS `192.168.55.11` |
|
||||
| `scribe` | postgres | ~11 MB | HA scribe 集成(entities/areas/devices 注册表同步 + `states_raw` hypertable) |
|
||||
| `postgres` | postgres | ~9 MB | 默认库 |
|
||||
|
||||
## Ops notes
|
||||
|
||||
- **Docker compose 管理**(2026-08-29 改造):`/opt/database/docker-compose.yml`(源码在仓库 `compose/pgdb/`)+ `/opt/database/.env`(0600,密钥)+ `/opt/database/pgweb-bookmarks/`(0600,bookmark 含 DB 密码)。三个服务:
|
||||
| 服务 | 镜像 | 端口 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `timescaledb` | `timescale/timescaledb:latest-pg18` | `192.168.55.15:5432`(IPv4 only) | PG 18.6 + TS 2.29.2;healthcheck pg_isready;`restart: unless-stopped` |
|
||||
| `pgweb` | `sosedoff/pgweb:latest`(v0.17.0) | `192.168.55.15:8081` | Web GUI:http://192.168.55.15:8081;basic auth(用户名/密码见 .env `PGWEB_AUTH_USER/PASS`);`--readonly --sessions --bookmarks-only --bookmarks-dir /bookmarks`(v0.17.0 不读 PGWEB_BOOKMARKS_DIR env,必须用 flag);bookmarks = hass/scribe |
|
||||
| `pg-backup` | `prodrigestivill/postgres-backup-local:latest`(=PG18 客户端) | — | 每日 02:00(`TZ=Asia/Shanghai`,本地时区)`pg_dump -Fc` 三库 → `/opt/database/backups/{daily,weekly,monthly}`;保留 7 天/4 周/6 月;`BACKUP_ON_START` |
|
||||
- **数据盘**:`/dev/sdb1`(32G ext4,label `pgdata`)挂载 `/srv/pgdata`,fstab 按 `UUID=c9e12e79-1f66-404c-ab7f-b8809be81d86`(defaults,noatime)持久化(2026-08-29 迁移)。容器 bind mount `/srv/pgdata:/var/lib/postgresql`。
|
||||
- 容器内 postgres 用户 uid/gid = **70**(Debian 系,非 999);迁移数据后需 `chown -R 70:70`。
|
||||
- **密码**:postgres 超级用户已换强密码(hex,存 `/opt/database/.env` 0600,2026-08-29)。HA 用 `hass` 角色不受影响。
|
||||
- **备份**:由 `pg-backup` 容器接管(2026-08-29),宿主机 cron 与 `/opt/database/pg-backup.sh` 已退役。恢复用 `pg_restore`(custom format)——2026-08-29 已实测还原 hass 库 dump(states 10014 行)成功。
|
||||
- **认证**:外部连接 scram-sha-256(密码必填,改密码有效);容器内 loopback 为 trust(官方镜像默认)。
|
||||
- **回滚**:旧启动命令保留在 `/opt/database/run`(容器无状态,数据在 /srv/pgdata);旧匿名卷 `9375195843b950f4e04c34872409ca095e1136520dd019a8e86e2794be06c236`(根盘 ~82M)保留作兜底,确认稳定后可 `docker volume rm`。
|
||||
- 本机无防火墙(ufw/nft/iptables 均未装)——待办:如要彻底隔离可加 ufw 白名单 192.168.55.11。
|
||||
- `/opt/database/backups/` 根下残留 `*-2026-08-29_1359.dump`(compose 化之前旧备份机制产物)与 `backup.log`——健康检查只看 `daily/`,残留可清理。
|
||||
- **Runbooks**:[pgdb-health](../runbooks/pgdb-health.md)(只读健康检查)、[pgdb-restore](../runbooks/pgdb-restore.md)(pg_restore 还原)、[pgdb-update](../runbooks/pgdb-update.md)(镜像/compose 升级)。
|
||||
|
||||
## Known issues
|
||||
|
||||
- 2026-08-29:HA 侧 HACS 集成 `custom_components.scribe`(YAML `scribe: db_url:`,连 `scribe` 库)建表被拒(`permission denied for schema public`,hass 无 CREATE 权限),之后持续报 `relation "entities" does not exist`。**已解决**:① `GRANT CREATE ON SCHEMA public TO hass;`(scribe 库)② 重启 HA Core 触发重跑建表。重启后自动创建 `entities`(1591 行)/`users`/`areas`/`devices`/`integrations`/`states_raw` 表并启用 TimescaleDB 时间序列能力。报错已停止(最后一条 06:06 UTC),`states_raw` 持续写入。2026-08-29 复查:scribe 现有**两个** hypertable——`states_raw`(segmentby `metadata_id`、orderby `time`)与 `events`(segmentby `event_type`、orderby `time`),均 1 维 `time`;压缩已配置(`timescaledb_information.compression_settings` 可见对应行;2.29.x 该视图无 `compression_enabled` 列)。
|
||||
- `hass` 库的 recorder 表仍为普通表(无 hypertable);`scribe` 集成负责时间序列历史(`states_raw` + `events` hypertable)。
|
||||
|
||||
## Verification history
|
||||
|
||||
- 2026-08-29:首次检查(只读)+ 修复 scribe 权限 + 安装夜间备份。见 Linear vps 项目登记。
|
||||
- 2026-08-29:**compose 改造完成**(W1N-227,用户已验收):裸 `docker run` → `/opt/database/docker-compose.yml` 三服务(timescaledb + pgweb + pg-backup);superuser 换强密码;端口收紧 IPv4;备份容器化(TZ=Asia/Shanghai,cron 02:00 本地);`pg_restore` 还原实测通过;pgweb UI 用户确认可查 hass/scribe 数据。源码在仓库 `compose/pgdb/`。
|
||||
- 2026-08-29:**运维 runbook 落地**(W1N-228,已验收):新增 `runbooks/pgdb-health.md`(只读,8 项诊断全绿)、`pgdb-restore.md`(流程式,temp-DB 安全还原 + 审批门)、`pgdb-update.md`(门控命令式,回滚=/opt/database/run + 旧卷);README 索引与 validate-repo.sh 分类同步更新;runbook 命令已对活主机逐条实测(含 `pg_restore -l` 校验当日 dump)。同日修正:SSH key auth 可用(facts 原记"密钥未安装"已过时);scribe 新增 `events` hypertable。
|
||||
@@ -53,6 +53,11 @@ of `8080`. During adoption or recovery, use the documented `:9080/inform` URL;
|
||||
an AP left on `:8080` can remain reachable by ping and SSH while showing
|
||||
offline in the controller.
|
||||
|
||||
IPv6 is enabled on the controller's `Default` network (`ipv6_enabled: true`,
|
||||
client assignment SLAAC; RA is served by `gw`, so `ipv6_interface_type` is
|
||||
`none`); both managed APs hold global SLAAC addresses — verified 2026-08-20.
|
||||
See [docs/unifi-network.md](../docs/unifi-network.md).
|
||||
|
||||
## Safe reconciliation and verification
|
||||
|
||||
```bash
|
||||
|
||||
+29
-20
@@ -8,29 +8,38 @@ diagnosis and procedures that are deliberately interactive or destructive; see
|
||||
For a live-verified map of the **internal LAN** (gw, gfw, dns, ubnt, APs) and
|
||||
the software deployed there, see [the LAN overview](../docs/lan-overview.md).
|
||||
|
||||
| Host | Role | SSH | IPv4 | Status | Facts |
|
||||
|------|------|-----|------|--------|-------|
|
||||
| mx2.windy.me | mailcow (primary MX prio 20) | `ssh -4 windy@mx2.windy.me` | 194.163.160.244 | active | [hosts/mx2.windy.me.md](../hosts/mx2.windy.me.md) |
|
||||
| us2.wsvc.info | Vaultwarden/Postgres (+ Traefik, Soft Serve, …) | `ssh -4 windy@us2.wsvc.info` | 193.9.44.165 | active | [hosts/us2.wsvc.info.md](../hosts/us2.wsvc.info.md) |
|
||||
| mx.windy.me | mail (secondary MX prio 30) | TBD | see AAAA/A | stub | — |
|
||||
| repo.windy.me | Soft Serve git (on us2) | `ssh -p 2222 windy@repo.windy.me` | 193.9.44.165 | stub | see us2 |
|
||||
| auth.wsvc.info | Vaultwarden public hostname | — (HTTPS) | → us2 | active | see us2 |
|
||||
| us1.wsvc.info | PowerDNS secondary (ns2 host) | TBD | 202.91.35.141 | stub | Auth 5.0.5; see hk2 |
|
||||
| us4.wsvc.info | WireGuard VPN | `ssh -4 windy@us4.wsvc.info` | 185.201.226.122 | active | [hosts/us4.wsvc.info.md](../hosts/us4.wsvc.info.md) |
|
||||
| hk2.chans.xyz | PowerDNS auth (ns1) | `ssh -4 windy@hk2.chans.xyz` | 154.36.174.161 | active | [hosts/hk2.chans.xyz.md](../hosts/hk2.chans.xyz.md) |
|
||||
| ns1.wsvc.info | PowerDNS public NS name | — (DNS) | → hk2 `154.36.174.161` | active | see hk2 |
|
||||
| ns2.wsvc.info | Secondary NS (AXFR/NOTIFY peer) | — (DNS) | → us1 `202.91.35.141` | active | see hk2 |
|
||||
| pdns.wsvc.info | Poweradmin UI | — (HTTPS) | → hk2 | active | see hk2 |
|
||||
| pgweb.wsvc.info | PowerDNS Postgres UI | — (HTTPS) | → hk2 | active | see hk2 |
|
||||
| **synapse.chans.xyz** | Matrix homeserver (ESS: Synapse + MAS + Element) | `ssh -4 windy@synapse.chans.xyz` | `169.58.86.13` | **active** | [hosts/synapse.chans.xyz.md](../hosts/synapse.chans.xyz.md) |
|
||||
| **gfw.windy.lan** | OpenWrt (ImmortalWrt) LAN gateway / OpenClash (PVE VM 140) | `ssh -4 root@192.168.66.1` | `192.168.66.1` | **active** | [hosts/gfw.windy.lan.md](../hosts/gfw.windy.lan.md) |
|
||||
| **dns.windy.lan** | AdGuard Home LAN DNS + Mihomo explicit proxy (PVE VM 120) | `ssh -4 windy@192.168.66.36` | `192.168.66.36` | **active** | [hosts/dns.windy.lan.md](../hosts/dns.windy.lan.md) |
|
||||
| **gw** | EdgeRouter X primary LAN gateway | `ssh -4 zhiqiang@192.168.66.254` | `192.168.66.254` | **active** | [hosts/gw.md](../hosts/gw.md) |
|
||||
| **ubnt** | UniFi Network Controller (PVE VM 160) | `ssh -4 windy@192.168.66.46` | `192.168.66.46` | **active** | [hosts/ubnt.md](../hosts/ubnt.md) |
|
||||
| **hass.windy.lan** | Home Assistant (HAOS, PVE VM 180, LAN55) | `ssh hassio@hass.windy.lan` | `192.168.55.11` | **active** | [hosts/hass.windy.lan.md](../hosts/hass.windy.lan.md) |
|
||||
**Ansible 列**:`✓` = 该主机在 [`ansible/inventory/hosts.yml`](../ansible/inventory/hosts.yml)
|
||||
(执行真相),用其 inventory key(见括号注)跑 playbook;`—` = 不由 Ansible 管理,
|
||||
原因是该平台无 ansible 覆盖或仅是公网别名/服务端点。
|
||||
|
||||
| Host | Role | SSH | IPv4 | Ansible | Status | Facts |
|
||||
|------|------|-----|------|---------|--------|-------|
|
||||
| mx2.windy.me | mailcow (primary MX prio 20) | `ssh -4 windy@mx2.windy.me` | 194.163.160.244 | ✓ (mx2) | active | [hosts/mx2.windy.me.md](../hosts/mx2.windy.me.md) |
|
||||
| us2.wsvc.info | Vaultwarden/Postgres (+ Traefik, Soft Serve, …) | `ssh -4 windy@us2.wsvc.info` | 193.9.44.165 | ✓ (us2) | active | [hosts/us2.wsvc.info.md](../hosts/us2.wsvc.info.md) |
|
||||
| mx.windy.me | mail (secondary MX prio 30) | TBD | see AAAA/A | — (stub) | stub | — |
|
||||
| repo.windy.me | Soft Serve git (on us2) | `ssh -p 2222 windy@repo.windy.me` | 193.9.44.165 | — (service on us2) | stub | see us2 |
|
||||
| auth.wsvc.info | Vaultwarden public hostname | — (HTTPS) | → us2 | — (alias) | active | see us2 |
|
||||
| us1.wsvc.info | PowerDNS secondary (ns2 host) | TBD | 202.91.35.141 | — (stub) | stub | Auth 5.0.5; see hk2 |
|
||||
| us4.wsvc.info | WireGuard VPN | `ssh -4 windy@us4.wsvc.info` | 185.201.226.122 | ✓ (us4) | active | [hosts/us4.wsvc.info.md](../hosts/us4.wsvc.info.md) |
|
||||
| hk2.chans.xyz | PowerDNS auth (ns1) | `ssh -4 windy@hk2.chans.xyz` | 154.36.174.161 | ✓ (hk2) | active | [hosts/hk2.chans.xyz.md](../hosts/hk2.chans.xyz.md) |
|
||||
| ns1.wsvc.info | PowerDNS public NS name | — (DNS) | → hk2 `154.36.174.161` | — (alias) | active | see hk2 |
|
||||
| ns2.wsvc.info | Secondary NS (AXFR/NOTIFY peer) | — (DNS) | → us1 `202.91.35.141` | — (alias) | active | see hk2 |
|
||||
| pdns.wsvc.info | Poweradmin UI | — (HTTPS) | → hk2 | — (alias) | active | see hk2 |
|
||||
| pgweb.wsvc.info | PowerDNS Postgres UI | — (HTTPS) | → hk2 | — (alias) | active | see hk2 |
|
||||
| **synapse.chans.xyz** | Matrix homeserver (ESS: Synapse + MAS + Element) | `ssh -4 windy@synapse.chans.xyz` | `169.58.86.13` | ✓ (matrix_vps) | **active** | [hosts/synapse.chans.xyz.md](../hosts/synapse.chans.xyz.md) |
|
||||
| **gfw.windy.lan** | OpenWrt (ImmortalWrt) LAN gateway / OpenClash (PVE VM 140) | `ssh -4 root@192.168.66.1` | `192.168.66.1` | — (OpenWrt, no ansible) | **active** | [hosts/gfw.windy.lan.md](../hosts/gfw.windy.lan.md) |
|
||||
| **dns.windy.lan** | AdGuard Home LAN DNS + Mihomo explicit proxy (PVE VM 120) | `ssh -4 windy@192.168.66.36` | `192.168.66.36` | ✓ (dns_windy_lan) | **active** | [hosts/dns.windy.lan.md](../hosts/dns.windy.lan.md) |
|
||||
| **gw** | EdgeRouter X primary LAN gateway | `ssh -4 zhiqiang@192.168.66.254` | `192.168.66.254` | — (EdgeOS, no ansible) | **active** | [hosts/gw.md](../hosts/gw.md) |
|
||||
| **ubnt** | UniFi Network Controller (PVE VM 160) | `ssh -4 windy@192.168.66.46` | `192.168.66.46` | ✓ (ubnt) | **active** | [hosts/ubnt.md](../hosts/ubnt.md) |
|
||||
| **hass.windy.lan** | Home Assistant (HAOS, x88 Pro physical box, LAN55) | `ssh hassio@hass.windy.lan` | `192.168.55.11` | — (HAOS, no ansible) | **active** | [hosts/hass.windy.lan.md](../hosts/hass.windy.lan.md) |
|
||||
| **pgdb** | TimescaleDB PG18 (Docker) — HA recorder backend (PVE VM, LAN55) | `ssh -4 windy@192.168.55.15` | `192.168.55.15` | — (no ansible) | **active** | [hosts/pgdb.md](../hosts/pgdb.md) |
|
||||
|
||||
`status: stub` = known to exist; fill `hosts/<name>.md` when next touched.
|
||||
|
||||
**命名映射**:ansible inventory key ↔ 本表主机名 —— `matrix_vps` ↔ `synapse.chans.xyz`、
|
||||
`dns_windy_lan` ↔ `dns.windy.lan`。inventory key 不随主机名改(防止破坏 `--limit` 用法),
|
||||
通过 inventory 内的 `display_name` 变量与文档交叉引用。
|
||||
|
||||
### Matrix services (synapse.chans.xyz)
|
||||
|
||||
| URL | Service | Notes |
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Runbook index
|
||||
|
||||
Entry point for all runbooks. Before operational work, read the repo entry
|
||||
[`AGENTS.md`](../AGENTS.md) and the spec [`RUNBOOKS.md`](../RUNBOOKS.md). New
|
||||
runbooks start from [`_template.md`](_template.md).
|
||||
|
||||
## Route by intent
|
||||
|
||||
| Intent | Runbook | Type |
|
||||
|---|---|---|
|
||||
| mailcow health check | [mailcow-health.md](mailcow-health.md) | read-only |
|
||||
| mailcow update | [mailcow-update.md](mailcow-update.md) | change (gated) |
|
||||
| mailcow SMTP/IMAP client | [mailcow-smtp-client.md](mailcow-smtp-client.md) | reference |
|
||||
| Vaultwarden health check | [vaultwarden-health.md](vaultwarden-health.md) | read-only |
|
||||
| Vaultwarden SQLite→PG migrate | [vaultwarden-sqlite-to-postgres.md](vaultwarden-sqlite-to-postgres.md) | change (destructive) |
|
||||
| PowerDNS health check | [pdns-health.md](pdns-health.md) | read-only |
|
||||
| RustDesk health check | [rustdesk-health.md](rustdesk-health.md) | read-only |
|
||||
| Matrix health check | [matrix-health.md](matrix-health.md) | read-only |
|
||||
| pgdb health check | [pgdb-health.md](pgdb-health.md) | read-only |
|
||||
| pgdb DB restore (pg_restore) | [pgdb-restore.md](pgdb-restore.md) | change (procedure) |
|
||||
| pgdb image/compose update | [pgdb-update.md](pgdb-update.md) | change (gated) |
|
||||
| AdGuard Home health check | [adguard-home-health.md](adguard-home-health.md) | read-only |
|
||||
| Matter packet capture | [matter-packet-capture.md](matter-packet-capture.md) | read-only |
|
||||
| Home Assistant maintenance | [home-assistant-maintenance.md](home-assistant-maintenance.md) | change (gated) |
|
||||
| matrix_e2ee integration update | [matrix-e2ee-update.md](matrix-e2ee-update.md) | change (gated) |
|
||||
| Routine Ansible operations | [ansible-operations.md](ansible-operations.md) | change (allowlisted) |
|
||||
| Linear issue → mergeable change | [issue-to-merge.md](issue-to-merge.md) | delivery |
|
||||
| Failing health/playbook run | [fix-ci.md](fix-ci.md) | change |
|
||||
| Release a reviewed change to production | [release.md](release.md) | change (gated) |
|
||||
| Roll back a change | [rollback.md](rollback.md) | change (gated) |
|
||||
| Controlled network configuration | [network-change.md](network-change.md) | change (gated) |
|
||||
| Network outage / service recovery | [network-recovery.md](network-recovery.md) | recovery |
|
||||
|
||||
## Notes
|
||||
|
||||
- `fix-ci.md`, `release.md`, `rollback.md`, `network-change.md`, `network-recovery.md`
|
||||
are adapted from the upstream guide to this repo's VPS-ops context (execution
|
||||
layer is Ansible + SSH + Linear, not a software CI/CD pipeline).
|
||||
- Health runbooks are read-only; they stop (`STOP`) when live state conflicts
|
||||
with the expected state instead of mutating production.
|
||||
@@ -0,0 +1,119 @@
|
||||
# Runbook: <名称>
|
||||
|
||||
## Purpose
|
||||
|
||||
<说明本 Runbook 要解决的问题及成功结果,1–2 行。>
|
||||
|
||||
## Scope
|
||||
|
||||
- 适用环境:<production / staging / LAN …>
|
||||
- 适用对象:<服务、主机、组件或告警类型>
|
||||
- 不适用情形:<需要改用其他 runbook 或转人工的场景>
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owner:<团队或角色>
|
||||
- Last reviewed:<YYYY-MM-DD>
|
||||
- Related systems:<主机名 / 服务名>
|
||||
|
||||
## Preconditions
|
||||
|
||||
- <执行前必须满足的权限、备份、窗口、健康状态或已知信息>
|
||||
|
||||
## Inputs
|
||||
|
||||
| 输入 | 来源 | 是否必需 | 校验方法 |
|
||||
|---|---|---:|---|
|
||||
| <参数> | <来源> | 是/否 | <如何确认有效> |
|
||||
|
||||
## Safety
|
||||
|
||||
### Non-negotiable rules
|
||||
|
||||
- 先只读诊断,后执行变更。
|
||||
- 不得把删除现有配置作为首次恢复动作。
|
||||
- 不得猜测或编造缺失参数。
|
||||
- 不得绕过失败的测试、检查或审批。
|
||||
- 每次变更后必须完成对应验证。
|
||||
- 破坏性操作必须获得明确批准。
|
||||
|
||||
### Stop conditions
|
||||
|
||||
- 实际状态与本文档的前提或预期结果冲突。
|
||||
- 缺少必要输入、权限、审批或回滚能力。
|
||||
- 验证失败且本文档没有明确的下一步。
|
||||
- 影响范围超出 Scope。
|
||||
|
||||
### Approval gates
|
||||
|
||||
| 动作 | 风险级别 | 是否需要明确批准 | 批准记录位置 |
|
||||
|---|---|---:|---|
|
||||
| <动作> | 低/中/高 | 是/否 | <Issue / PR / 变更单> |
|
||||
|
||||
## Procedure
|
||||
|
||||
### Step 1 — Diagnose
|
||||
|
||||
**Action**
|
||||
|
||||
<执行只读诊断动作。>
|
||||
|
||||
**Expected**
|
||||
|
||||
<列出预期输出、状态或证据。>
|
||||
|
||||
**Decision**
|
||||
|
||||
- 若 <条件 A>,进入 Step 2。
|
||||
- 若 <条件 B>,进入 Troubleshooting A。
|
||||
- 若无法判断或状态冲突,`STOP` 并记录证据。
|
||||
|
||||
### Step 2 — Change
|
||||
|
||||
**Action**
|
||||
|
||||
<描述单一、可审计的变更动作。>
|
||||
|
||||
**Expected**
|
||||
|
||||
<变更后应出现的状态。>
|
||||
|
||||
**Verification**
|
||||
|
||||
<给出可重复执行的验证命令、测试、监控指标或检查清单。>
|
||||
|
||||
**Rollback**
|
||||
|
||||
- 触发条件:<什么情况需要回滚>
|
||||
- 回滚动作:<如何撤销>
|
||||
- 回滚验证:<如何确认恢复成功>
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Troubleshooting A — <异常名称>
|
||||
|
||||
- 证据收集:<日志、指标、命令输出、链接>
|
||||
- 允许动作:<仅限已验证且低风险的动作>
|
||||
- 下一步:<回到某步 / 转入另一 runbook / STOP 并升级>
|
||||
|
||||
## Final Verification
|
||||
|
||||
只有同时满足以下标准,流程才算成功:
|
||||
|
||||
- <功能或服务状态>
|
||||
- <自动化测试或健康检查>
|
||||
- <监控指标或告警状态>
|
||||
- <变更记录、PR 或 Issue 已更新>
|
||||
|
||||
## Failure Handling
|
||||
|
||||
若未能完成:
|
||||
|
||||
1. 停止进一步变更。
|
||||
2. 收集 <命令输出、时间范围、请求 ID、日志链接、截图或复现步骤>。
|
||||
3. 记录已完成步骤、实际结果、未满足的预期和是否执行过回滚。
|
||||
4. 按 <升级渠道> 交接,不继续猜测。
|
||||
|
||||
## References
|
||||
|
||||
- <关联 Issue、PR、架构文档、仪表盘、配置仓库或外部文档>
|
||||
@@ -1,5 +1,20 @@
|
||||
# AdGuard Home health — dns.windy.lan
|
||||
|
||||
## Purpose
|
||||
|
||||
Read-only health check of the AdGuard Home LAN DNS service.
|
||||
|
||||
## Scope
|
||||
|
||||
- Applicable: [dns.windy.lan](../hosts/dns.windy.lan.md) (`192.168.66.36`).
|
||||
- Read-only: does not expose query-log contents or secrets; does not change configuration.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owner: personal ops (Windy)
|
||||
- Last reviewed: 2026-08-17
|
||||
- Related systems: dns.windy.lan (`/opt/adguardhome`)
|
||||
|
||||
This runbook is read-only. It does not expose query-log contents or secrets.
|
||||
|
||||
Routine checks run through Ansible on demand:
|
||||
@@ -58,3 +73,9 @@ a known-bad-signature test; an enabled DO bit alone is not validation.
|
||||
|
||||
Private PTR forwarding is intentionally absent because the EdgeRouter does
|
||||
not currently answer private PTR requests.
|
||||
|
||||
## Safety
|
||||
|
||||
- Read-only: never change the DNS policy or the `agh-ui-access.service` nftables rule during this check.
|
||||
- Do not infer a broken DNS policy from an empty `allowed_clients`.
|
||||
- If live state conflicts with an expected value, `STOP` and report.
|
||||
|
||||
@@ -1,8 +1,29 @@
|
||||
# Runbook: routine operations through Ansible
|
||||
|
||||
## Purpose
|
||||
|
||||
Routine operations (health, reconcile, maintenance) through the Ansible playbooks.
|
||||
|
||||
## Scope
|
||||
|
||||
- Applicable: every inventory host, run from `ansible/`.
|
||||
- Not applicable: arbitrary remote commands — the reconcile playbook is allowlisted and gated.
|
||||
|
||||
Run commands from `ansible/`. The inventory forces IPv4 and uses the `windy`
|
||||
account with sudo. Do a read-only health pass before any reconciliation.
|
||||
|
||||
## Safety
|
||||
|
||||
- Read-only health pass before any reconciliation.
|
||||
- Mutating playbooks require explicit confirmation variables; do not bypass them.
|
||||
- If a reconcile target or service name is not allowlisted, `STOP` — do not invent one.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owner: personal ops (Windy)
|
||||
- Last reviewed: 2026-08-17
|
||||
- Related systems: Ansible control-plane + all inventory hosts
|
||||
|
||||
## Health report (read-only)
|
||||
|
||||
```bash
|
||||
@@ -48,6 +69,27 @@ Do not use this playbook for a Mailcow update, database migration, DNS record
|
||||
change, or secret rotation. Those operations require their dedicated reviewed
|
||||
and, where appropriate, interactive procedures.
|
||||
|
||||
## Deploy repo-owned Compose (static projects)
|
||||
|
||||
Repo source: `compose/<project>/compose.yml` (non-secret; secrets come from the
|
||||
server-local `.env` via `${VAR}`). Mechanism and per-project status:
|
||||
[`compose/README.md`](../compose/README.md).
|
||||
|
||||
```bash
|
||||
# Read-only: staged-file diff + allowlist/confirmation asserts, no writes
|
||||
ansible-playbook playbooks/compose-deploy.yml --limit vaultwarden --check --diff
|
||||
ansible-playbook playbooks/compose-deploy.yml --limit powerdns --check --diff
|
||||
|
||||
# Apply: stage repo file → validate `docker compose config -q` against the
|
||||
# server .env → backup current file (*.bak-<ts>) → promote → `up -d` (gated)
|
||||
ansible-playbook playbooks/compose-deploy.yml --limit vaultwarden \
|
||||
-e '{"compose_deploy_confirm": true}'
|
||||
```
|
||||
|
||||
The playbook never writes, reads, or transfers the server `.env`. A failed
|
||||
validation never touches the live compose file. Hosts without an allowlisted
|
||||
`compose_repo_project` fail the assert — do not invent targets.
|
||||
|
||||
## Host-level maintenance
|
||||
|
||||
These playbooks cover every inventory host, including the Matrix K3s node:
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# Runbook: fix a failing health/playbook run
|
||||
|
||||
> Adapted from the upstream guide's `fix-ci`. This repo has no software CI; the
|
||||
> equivalent "pipeline" is the Ansible **health report** and the gated playbooks.
|
||||
> This runbook covers diagnosing and fixing a failed or warning/critical run.
|
||||
|
||||
## Purpose
|
||||
|
||||
Diagnose and fix a failing Ansible health-report or playbook run without
|
||||
skipping checks or changing unrelated code.
|
||||
|
||||
## Scope
|
||||
|
||||
- Applicable: `ansible-playbook playbooks/health-report.yml` and the gated playbooks under `ansible/playbooks/`.
|
||||
- Not applicable: production changes beyond fixing the run; network/DNS changes → `network-change.md`.
|
||||
|
||||
## Safety
|
||||
|
||||
- Do not skip or weaken a failing check to make it pass.
|
||||
- Do not change unrelated hosts or services.
|
||||
- Prefer read-only diagnosis before mutation; destructive fixes require approval.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owner: personal ops (Windy)
|
||||
- Last reviewed: 2026-08-17
|
||||
- Related systems: Ansible health report / gated playbooks
|
||||
|
||||
## Procedure
|
||||
|
||||
### Step 1 — Reproduce and read
|
||||
|
||||
**Action** — re-run the failing playbook with `--limit <host>` and capture the task that failed.
|
||||
|
||||
```bash
|
||||
cd ansible
|
||||
ansible-playbook playbooks/health-report.yml --limit <host> -v
|
||||
```
|
||||
|
||||
**Expected** — a specific failed task, host, and message (warning vs critical).
|
||||
|
||||
**Decision** — clear failure → Step 2; ambiguous → `STOP` and collect `-vvv` output + the relevant `latest.json`.
|
||||
|
||||
### Step 2 — Diagnose
|
||||
|
||||
**Action** — inspect the corresponding service on the host using the matching health runbook (`mailcow-health.md`, `vaultwarden-health.md`, `pdns-health.md`, etc.).
|
||||
|
||||
**Expected** — a root cause (container down, cert expired, queue backlog, drift).
|
||||
|
||||
**Decision** — root cause found → Step 3; live state conflicts with the runbook's assumptions → `STOP`.
|
||||
|
||||
### Step 3 — Fix within scope
|
||||
|
||||
**Action** — apply the minimal fix the service runbook prescribes (e.g. `compose-reconcile` for a config drift, or a documented update). Use only allowlisted/gated playbooks.
|
||||
|
||||
**Verification** — re-run the health report and confirm it passes.
|
||||
|
||||
**Rollback** — revert to the prior config/state and re-run; see `rollback.md` for the general procedure.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Troubleshooting A — Intermittent/flaky failure
|
||||
|
||||
- Evidence: timing, DNS stub flakiness (use `1.1.1.1`/`8.8.8.8` for probes).
|
||||
- Allowed: re-run once with the documented resolver workaround.
|
||||
- Next: still failing → `STOP` and escalate.
|
||||
|
||||
## Final Verification
|
||||
|
||||
- Health report passes for the affected host.
|
||||
- No checks were skipped or weakened; the fix is committed/documented.
|
||||
|
||||
## References
|
||||
|
||||
- [`ansible-operations.md`](ansible-operations.md)
|
||||
- Per-service health runbooks under [`runbooks/`](.)
|
||||
@@ -1,13 +1,47 @@
|
||||
# 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)
|
||||
Target: [hass.windy.lan](../hosts/hass.windy.lan.md) (physical x88 Pro box, HAOS `machine: green`)
|
||||
Upstream: HAOS 18.1 / Core 2026.8.1 / Supervisor 2026.07.5 (verified 2026-08-14)
|
||||
|
||||
This runbook covers routine Home Assistant maintenance through the **`ha`
|
||||
supervisor CLI**. All commands are wrapped by a single script
|
||||
[`scripts/ha-maintenance.sh`](scripts/ha-maintenance.sh); the sections below
|
||||
document the exact commands it runs, for manual/agent use.
|
||||
|
||||
## Purpose
|
||||
|
||||
Run routine Home Assistant maintenance on `hass.windy.lan` (health snapshot,
|
||||
config validation, log inspection, updates, and recovery) through the `ha`
|
||||
supervisor CLI.
|
||||
|
||||
## Scope
|
||||
|
||||
Applies to `hass.windy.lan` only (HAOS, `machine: green`). Covers both
|
||||
read-only checks and gated mutating operations; the "Command families
|
||||
intentionally NOT scripted" table below lists what is deliberately out of
|
||||
scope.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owner: personal ops (Windy)
|
||||
- Last reviewed: 2026-08-17
|
||||
- Related systems: hass.windy.lan (HAOS, `machine: green`)
|
||||
|
||||
## Safety
|
||||
|
||||
- Prefer read-only checks first; the health snapshot mutates nothing.
|
||||
- Every mutating mode (update / restart / rebuild / rollback / reboot /
|
||||
backup / restore / add-on lifecycle) refuses to run without `--yes`.
|
||||
- `--restore` overwrites the current installation; `--rollback-os`,
|
||||
`--reboot`, and `--rebuild-core` are disruptive. Run them only from a
|
||||
planned recovery with the backup verified.
|
||||
- Never commit `SUPERVISOR_TOKEN` or a long-lived `HA_TOKEN`; read entity
|
||||
state via Supervisor (`SUPERVISOR_TOKEN` after `sudo -n -i`).
|
||||
- The `--restart-core` wrapper exits 1 silently on ssh failure — treat an
|
||||
empty/exit-1 result as failure and confirm with `ha core info`.
|
||||
- If live state conflicts with a documented expectation, `STOP` and report;
|
||||
do not improvise command families outside this script.
|
||||
|
||||
## Access pattern
|
||||
|
||||
`ha` authenticates to the Supervisor with `SUPERVISOR_TOKEN`. Interactive SSH
|
||||
@@ -26,6 +60,17 @@ The script runs its whole procedure in one remote login (`sudo -n -i bash -s`)
|
||||
so the banner appears once, then strips it with `awk` up to the
|
||||
`System is ready! Use browser or app to configure.` line.
|
||||
|
||||
**Restart wrapper (verified 2026-08-14, W1N-107):**
|
||||
`./ha-maintenance.sh --restart-core --yes` exited 1 with no output in <1s
|
||||
and **did not restart Core**. The wrapper pipes a remote script through
|
||||
`ssh … 2>/dev/null | awk …`; with `set -uo pipefail`, an ssh failure is
|
||||
silent and the pipeline returns empty/exit 1 **before any remote command
|
||||
runs**. That is not a MOTD-strip artifact after a successful restart.
|
||||
The working restart was
|
||||
`ssh -o BatchMode=yes hassio@hass.windy.lan 'sudo -n -i ha core restart'`
|
||||
(~131s, `Command completed successfully.`). Treat empty/exit 1 as
|
||||
failure; confirm with elapsed time and `ha core info`.
|
||||
|
||||
## Script usage
|
||||
|
||||
```bash
|
||||
@@ -33,7 +78,7 @@ 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 core 2500 # tail core logs (use 2500 after a restart)
|
||||
./ha-maintenance.sh --logs supervisor # tail supervisor logs (default 100)
|
||||
./ha-maintenance.sh --logs host 50 # tail host journald logs
|
||||
./ha-maintenance.sh --logs apps:<slug> # tail an add-on log
|
||||
@@ -55,7 +100,7 @@ cd runbooks/scripts
|
||||
- `--restore` overwrites the current installation — run only from a planned
|
||||
recovery, with the backup verified.
|
||||
|
||||
## Command reference (verified 2026-08-13)
|
||||
## Command reference (verified 2026-08-14)
|
||||
|
||||
All verified against the live host. MOTD prepends each command's output; strip
|
||||
with the `awk` pattern above or read the last block.
|
||||
@@ -65,7 +110,7 @@ with the `awk` pattern above or read the last block.
|
||||
| Purpose | Command |
|
||||
|---|---|
|
||||
| General overview | `ha info` |
|
||||
| Core version/status | `ha core info` |
|
||||
| Core version/status | `ha core info` (this CLI build has no `state:` field; success is a normal info dump) |
|
||||
| Core config validation | `ha core check` |
|
||||
| Core stats | `ha core stats` |
|
||||
| Supervisor status | `ha supervisor info` (incl. add-on list) |
|
||||
@@ -78,7 +123,7 @@ with the `awk` pattern above or read the last block.
|
||||
| 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) |
|
||||
| Core logs | `ha core logs -n 100` (`-f` follow, `-b` boot id). Default 100 misses setup; use `-n 2500` after a custom-component restart. `/config/home-assistant.log` may be missing — `ha core logs` is the source of truth. |
|
||||
| Supervisor logs | `ha supervisor logs -n 100` |
|
||||
| Host journald logs | `ha host logs -n 100` |
|
||||
| Add-on logs | `ha apps logs <slug> -n 100` |
|
||||
@@ -120,14 +165,21 @@ add-on states (any `state: error`?); `resolution info` issues; disk free.
|
||||
|
||||
Expect `Command completed successfully.` before a Core restart.
|
||||
|
||||
`ha core check` / a YAML reload is **not** enough after copying Python
|
||||
custom-component files — restart Core.
|
||||
|
||||
### 3. Inspect logs
|
||||
|
||||
```bash
|
||||
./ha-maintenance.sh --logs core 200
|
||||
./ha-maintenance.sh --logs core 2500 # after a Core restart / custom-component copy
|
||||
./ha-maintenance.sh --logs supervisor
|
||||
./ha-maintenance.sh --logs apps:core_mosquitto
|
||||
```
|
||||
|
||||
Default `--logs core` (100 lines) is too short to catch coordinator pickle /
|
||||
setup errors. `/config/home-assistant.log` may be absent while
|
||||
`ha core logs` still has history.
|
||||
|
||||
### 4. Apply updates (mutating)
|
||||
|
||||
```bash
|
||||
@@ -155,6 +207,104 @@ booted. After a bad OS update, `ha os boot-slot other` boots the previous slot.
|
||||
./ha-maintenance.sh --backup pre-migration --yes # named backup
|
||||
```
|
||||
|
||||
### 7. Install or update a custom component (manual zip)
|
||||
|
||||
Home Assistant loads custom integrations from
|
||||
`/config/custom_components/<domain>/` (on this HAOS host `/config` ≡
|
||||
`/homeassistant`). Official lookup:
|
||||
`<config>/custom_components/<domain>` then built-in
|
||||
`homeassistant/components/<domain>`
|
||||
([Integration file structure](https://developers.home-assistant.io/docs/creating_integration_file_structure)).
|
||||
A folder named after the domain, with at least `manifest.json` and
|
||||
`__init__.py`, is enough. **Restart Core** after copying — `ha core check`
|
||||
and a YAML reload do not pick up new Python packages.
|
||||
|
||||
This host's live trees are **file copies**, not git clones. Do not
|
||||
`git pull` inside `custom_components/`.
|
||||
|
||||
#### Official plugin paths (CSG)
|
||||
|
||||
[windyboy/china_southern_power_grid_stat README](https://github.com/windyboy/china_southern_power_grid_stat):
|
||||
[HACS](https://hacs.xyz/) **or**
|
||||
[手动下载安装](https://github.com/windyboy/china_southern_power_grid_stat/releases).
|
||||
|
||||
This host uses the zip path. **Do not HACS-update this integration here.**
|
||||
HACS still tracks upstream `CubicPill/china_southern_power_grid_stat`
|
||||
`v1.2.0` and would overwrite the fork. Releases have no uploaded zip
|
||||
assets — use GitHub **Source code (zip)** / zipball of the tag.
|
||||
|
||||
Worked SSH example (tag, backup, `rsync`, `__pycache__`, restart):
|
||||
[hosts/hass.windy.lan.md](../hosts/hass.windy.lan.md) § Manual
|
||||
custom-component install.
|
||||
|
||||
#### Procedure
|
||||
|
||||
1. **Backup the live tree off `custom_components/`.** HA scans every
|
||||
directory under `custom_components/` whose `manifest.json` `domain`
|
||||
matches. A `*.bak-*` folder next to the live tree makes Core import
|
||||
the backup (`No module named '...bak-YYYYMMDD-...'`, W1N-106). CSG
|
||||
backups: `/homeassistant/.csg-backups/`.
|
||||
2. **Copy only the inner `custom_components/<domain>/` tree**, not the
|
||||
repo root and not an extra nested folder.
|
||||
3. **Wipe `__pycache__` as root.** `rsync --delete` as `hassio` cannot
|
||||
unlink Core-owned `.pyc` (permission denied, exit 23); stale
|
||||
`cpython-314` bytecode can keep the old coordinator in memory until
|
||||
restart. Then restart:
|
||||
|
||||
```bash
|
||||
ssh -o BatchMode=yes hassio@hass.windy.lan \
|
||||
'sudo -n -i rm -rf /homeassistant/custom_components/<domain>/__pycache__ \
|
||||
/homeassistant/custom_components/<domain>/*/__pycache__ &&
|
||||
sudo -n -i ha core restart'
|
||||
```
|
||||
|
||||
4. **Wait 1–2 min**, then `ha core info` (this CLI build has no `state:`
|
||||
field; success is a normal info dump). Confirm `manifest.json`
|
||||
`version` matches the tag.
|
||||
5. **Read enough Core logs.** Default `ha core logs` is too short to
|
||||
catch setup. Use `-n 2500` (or `--logs core 2500`) and look for
|
||||
`Setting up <domain>` plus the first coordinator errors.
|
||||
6. **First poll can time out.** If last-month sensors have numbers but
|
||||
this-month stay `unknown`/`unavailable`, reload the config entry
|
||||
(UI: integration → Reload). Supervisor:
|
||||
|
||||
```bash
|
||||
# entry id from .storage/core.config_entries (CSG: 01KGCQDSZCF523A9X6SV3BZ1B9)
|
||||
ssh -o BatchMode=yes hassio@hass.windy.lan \
|
||||
'sudo -n -i python3 -c "
|
||||
import os, urllib.request
|
||||
req = urllib.request.Request(
|
||||
\"http://supervisor/core/api/config/config_entries/entry/<ENTRY_ID>/reload\",
|
||||
method=\"POST\",
|
||||
headers={\"Authorization\": \"Bearer \" + os.environ[\"SUPERVISOR_TOKEN\"]},
|
||||
)
|
||||
print(urllib.request.urlopen(req, timeout=60).status)
|
||||
"'
|
||||
```
|
||||
|
||||
7. **Do not edit the dashboard or `templates/csg_sensors.yaml` for an
|
||||
install.** Entity IDs did not change across v1.3.0/v1.3.1/v1.3.2.
|
||||
Template `| float(0)` turns native `unavailable` into fake zeros; that
|
||||
is a follow-up, not part of the zip install.
|
||||
|
||||
#### Verify (CSG, after v1.3.2 / W1N-118)
|
||||
|
||||
| Check | Expect |
|
||||
|---|---|
|
||||
| `manifest.json` `version` | `1.3.2` |
|
||||
| `ha core logs` after this restart | `Setting up china_southern_power_grid_stat`; **no** `cannot pickle 'mappingproxy'` |
|
||||
| Config entry | `state: loaded` |
|
||||
| `sensor.0800041935246530_balance` | numeric (may be `0.0`) |
|
||||
| `sensor.0800041935246530_this_month_total_usage` | numeric after reload if first poll timed out |
|
||||
| Native `*_total_cost` / `current_ladder` | may stay `unknown` (CSG marketing calendar SQL error); dashboard uses W1N-114 `csg_*` ladder/cost templates |
|
||||
|
||||
`monetary` + `total_increasing` warnings on this-month/year cost sensors
|
||||
are a remaining plugin issue, not an install failure.
|
||||
|
||||
There is no long-lived `HA_TOKEN` in the agent environment. Read entity
|
||||
states via Supervisor (`SUPERVISOR_TOKEN` after `sudo -n -i`) at
|
||||
`http://supervisor/core/api/states/<entity_id>`.
|
||||
|
||||
## Command families intentionally NOT scripted
|
||||
|
||||
These exist in `ha` but are either rare, dangerous, or better done in the web
|
||||
@@ -185,21 +335,42 @@ host for exact syntax.
|
||||
## 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.
|
||||
also mention `ha host update`, which **does not exist** in this CLI
|
||||
(2026-08-14). Docs' `ha backups list` is not a subcommand either:
|
||||
`ha backups --help` lists freeze/info/new/options/reload/remove/restore/thaw;
|
||||
extra positional args (`list`, `nonsense`, ...) are ignored and the default
|
||||
list still prints with exit 0. The list command is plain `ha backups`.
|
||||
Per-backup: `ha backups info <slug>` (slug required). Trust the server CLI
|
||||
(`ha <cmd> --help`) over the docs.
|
||||
|
||||
## Known issues on hass.windy.lan (observed 2026-08-13)
|
||||
This CLI's `ha core info` also has no `state:` field (verified 2026-08-14).
|
||||
Wait for a successful info dump after restart, not a `state: running` line.
|
||||
|
||||
From the health snapshot — follow-ups are optional, no action taken:
|
||||
## Known issues on hass.windy.lan (2026-08-14)
|
||||
|
||||
- **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).
|
||||
2026-08-13 snapshot items were resolved same day (W1N-70/71/72/73/74/75/76):
|
||||
OTBR and the duplicate SSH add-on uninstalled, resolution-center empty,
|
||||
full backup `pre-maintenance-20260813` (slug `411a4ba5`). Remaining:
|
||||
|
||||
- **Bluetooth hci0 instability (RTL8821CS)**: `bluetooth_auto_recovery`
|
||||
power-reset times out every ~2 min; kernel `hci0 hardware error`. No BLE
|
||||
entities exist, so no user impact. HAOS image ships `x88-bt-hci-recovery`
|
||||
workaround units.
|
||||
- `host info` reports `disk_life_time: 10` (boot eMMC ~10% life left) —
|
||||
monitor on each snapshot; plan disk replacement / data-disk migration.
|
||||
- **Home PPPoE IPv4 to CSG is blackholed** (`curl -4` to
|
||||
`218.19.148.218:443` times out). `end0` IPv6 works (`curl -6
|
||||
https://95598.csg.cn` → HTTP 200). Entry `ip_family: ipv4` still
|
||||
matches the stored option; first post-restart poll can still time out
|
||||
— reload the config entry rather than reinstalling.
|
||||
- **WSL HTTP proxy**: LAN `hass.windy.lan:8123` through Mihomo returns
|
||||
empty `502`. Bypass proxy or add `.windy.lan` to `NO_PROXY` before
|
||||
debugging UI/API from the workstation
|
||||
([hosts/hass.windy.lan.md](../hosts/hass.windy.lan.md) § HTTP proxy
|
||||
gotcha).
|
||||
- **No long-lived HA token in the agent environment.** Read entity
|
||||
states via Supervisor (`SUPERVISOR_TOKEN` after `sudo -n -i`) at
|
||||
`http://supervisor/core/api/states/...`, not a committed `HA_TOKEN`.
|
||||
|
||||
## Pass criteria
|
||||
|
||||
@@ -207,4 +378,7 @@ From the health snapshot — follow-ups are optional, no action taken:
|
||||
- Mutating modes refuse to run without `--yes` (incl. `--restore`, `--app`)
|
||||
- `--check-config` returns success
|
||||
- Update / rollback / restore / reboot confirmed only after explicit `--yes`
|
||||
- Custom-component zip install: live `manifest.json` version matches the
|
||||
tag; backups not under `custom_components/`; Core restarted; logs show
|
||||
`Setting up <domain>` without import / pickle errors
|
||||
- Update the **Verified** line on [hosts/hass.windy.lan.md](../hosts/hass.windy.lan.md)
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# Runbook: issue → mergeable change
|
||||
|
||||
## Purpose
|
||||
|
||||
Turn an approved Linear `vps` issue into a reviewed, mergeable change in this
|
||||
repo (docs, runbooks, hosts facts, or Ansible playbooks).
|
||||
|
||||
## Scope
|
||||
|
||||
- Applicable: repo content under `docs/`, `runbooks/`, `hosts/`, `inventory/`, `ansible/`.
|
||||
- Not applicable: mutating production state directly — that goes through `release.md` / `ansible-operations.md`.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owner: personal ops (Windy)
|
||||
- Last reviewed: 2026-08-17
|
||||
- Related systems: Linear MCP (`vps` project), git
|
||||
|
||||
## Inputs
|
||||
|
||||
| Input | Source | Required | Validation |
|
||||
|---|---|---:|---|
|
||||
| Issue identifier | Linear (`vps` project) | Yes | `linear_get_issue <id>` returns a description |
|
||||
| Current repo state | `git status` / `git log` | Yes | Clean or intended worktree |
|
||||
|
||||
## Safety
|
||||
|
||||
- Scope is locked to the issue: do not bundle unrelated changes.
|
||||
- Never commit secrets (see `AGENTS.md` §Safety).
|
||||
- Verify every change; do not merge a change whose verification was skipped.
|
||||
|
||||
## Procedure
|
||||
|
||||
### Step 1 — Read the issue
|
||||
|
||||
**Action** — `linear_get_issue <id>`, read description and acceptance criteria.
|
||||
|
||||
**Expected** — clear scope, action, and verification for the change.
|
||||
|
||||
**Decision** — if the issue is ambiguous or lacks verification criteria, `STOP`
|
||||
and ask for clarification (add a `needs-info` label if applicable). Otherwise go to Step 2.
|
||||
|
||||
### Step 2 — Inspect and change
|
||||
|
||||
**Action** — read the relevant files, then make the minimal change the issue asks for.
|
||||
|
||||
**Expected** — diff is scoped to the issue.
|
||||
|
||||
**Decision** — if the change needs production mutation, `STOP` and route to
|
||||
`release.md`. Otherwise go to Step 3.
|
||||
|
||||
### Step 3 — Verify
|
||||
|
||||
**Action** — run `scripts/validate-repo.sh` from the repo root (covers secret
|
||||
scan, inventory cross-check, markdown link check, runbook-spec check, and
|
||||
Ansible `--syntax-check`); for changes that alter playbook behavior, also run
|
||||
a read-only `ansible-playbook --check` where possible.
|
||||
|
||||
**Verification** — `scripts/validate-repo.sh` exits 0; the concrete checks
|
||||
must match the change type.
|
||||
|
||||
**Decision** — verification passed → Step 4; failed → Troubleshooting A.
|
||||
|
||||
### Step 4 — Commit and link
|
||||
|
||||
**Action** — commit with a message containing the full issue ID (e.g. `W1N-123: …`); open a PR if the change is substantial; link the issue via `linear_save_comment`.
|
||||
|
||||
**Verification** — `git log -1` shows the issue ID; the issue has the commit/PR pointer.
|
||||
|
||||
**Rollback** — `git revert <sha>` or `git checkout <branch>` to drop the change; re-verify after.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Troubleshooting A — Verification failed
|
||||
|
||||
- Evidence: command output, failing check.
|
||||
- Allowed: fix the change within scope; re-run verification.
|
||||
- Next: still failing → `STOP` and report in the issue.
|
||||
|
||||
## Final Verification
|
||||
|
||||
- Change matches the issue scope.
|
||||
- Verification passed and the issue is updated with evidence.
|
||||
|
||||
## Failure Handling
|
||||
|
||||
If unfinished: stop, collect the failed check output, record completed steps, and
|
||||
hand back to the issue — do not guess.
|
||||
|
||||
## References
|
||||
|
||||
- [`docs/agents/issue-tracker.md`](../docs/agents/issue-tracker.md)
|
||||
- [`RUNBOOKS.md`](../RUNBOOKS.md)
|
||||
@@ -1,5 +1,20 @@
|
||||
# Runbook: mailcow health (mx2)
|
||||
|
||||
## Purpose
|
||||
|
||||
Read-only health check of the mailcow stack on mx2.
|
||||
|
||||
## Scope
|
||||
|
||||
- Applicable: [mx2.windy.me](../hosts/mx2.windy.me.md), `/opt/mail`.
|
||||
- Read-only: does not change mailcow configuration or service state.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owner: personal ops (Windy)
|
||||
- Last reviewed: 2026-08-17
|
||||
- Related systems: mx2.windy.me (`/opt/mail`)
|
||||
|
||||
Target: [mx2.windy.me](../hosts/mx2.windy.me.md)
|
||||
Path: `/opt/mail`
|
||||
Prefer: the Ansible health report (`ansible/playbooks/health-report.yml --limit mailcow`),
|
||||
@@ -71,6 +86,11 @@ The sanitized Ansible health profile is `mailcow` (`ansible/playbooks/healthchec
|
||||
The server-local timer emits a sanitized result at `/var/lib/vps-health/latest.json`.
|
||||
It does not change Mailcow configuration or service state.
|
||||
|
||||
## Safety
|
||||
|
||||
- Read-only: never mutate configuration or service state during this check.
|
||||
- If live state conflicts with an expected value below, `STOP` and report; do not "fix" on the fly.
|
||||
|
||||
## Pass criteria
|
||||
|
||||
- Compose stack up; watchdog ~100%
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# Runbook: use mailcow SMTP / IMAP (client)
|
||||
|
||||
## Purpose
|
||||
|
||||
Reference for configuring mail clients against the mailcow SMTP/IMAP endpoints.
|
||||
|
||||
## Scope
|
||||
|
||||
- Applicable: [mx2.windy.me](../hosts/mx2.windy.me.md) client submission (587/465) and IMAP/POP (993/995).
|
||||
- Not applicable: server-side mailcow configuration or administration.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owner: personal ops (Windy)
|
||||
- Last reviewed: 2026-08-17
|
||||
- Related systems: mx2.windy.me (SMTP/IMAP client endpoints)
|
||||
|
||||
Target: [mx2.windy.me](../hosts/mx2.windy.me.md)
|
||||
Prerequisite: a mailbox on `windy.me` (password from mailcow UI, not the admin account unless it is that mailbox).
|
||||
|
||||
@@ -52,3 +67,9 @@ Do not commit or paste real passwords into this repo.
|
||||
- Port/TLS mode mismatch (587 vs 465)
|
||||
- Account active in mailcow; not rate-limited / fail2banned after bad attempts
|
||||
- Apps that store SMTP in their own config (e.g. Vaultwarden `config.json`) may keep a **stale** password even when `.env` is correct — verify AUTH against the effective config ([vaultwarden-health](vaultwarden-health.md) §5)
|
||||
|
||||
## Safety
|
||||
|
||||
- Do not commit or paste real passwords into this repo or chat.
|
||||
- Use submission (587/465) for client sending; never use port 25 as a desktop/app outbound port.
|
||||
- If live state conflicts with the endpoint values above, `STOP` and report; do not change server-side settings during this reference check.
|
||||
|
||||
@@ -1,9 +1,37 @@
|
||||
# Runbook: mailcow update (mx2)
|
||||
|
||||
## Purpose
|
||||
|
||||
Update the mailcow stack on mx2 to the latest supported release.
|
||||
|
||||
## Scope
|
||||
|
||||
- Applicable: [mx2.windy.me](../hosts/mx2.windy.me.md), `/opt/mail`.
|
||||
- Not applicable: config changes beyond the update, DB migration, secret rotation.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owner: personal ops (Windy)
|
||||
- Last reviewed: 2026-08-17
|
||||
- Related systems: mx2.windy.me (`/opt/mail`)
|
||||
|
||||
## Approval gates
|
||||
|
||||
| Action | Risk | Explicit approval |
|
||||
|---|---|---|
|
||||
| Run `./update.sh` (recreates containers, brief mail interruption) | Medium | Yes — user confirmation required |
|
||||
|
||||
Target: [mx2.windy.me](../hosts/mx2.windy.me.md)
|
||||
Path: `/opt/mail`
|
||||
**Confirm with the user before running an update.**
|
||||
|
||||
## Safety
|
||||
|
||||
- Never run the update without explicit user confirmation.
|
||||
- Never pass secrets into the chat log; do not commit `mailcow.conf`.
|
||||
- If a step fails, capture `docker compose ps` and logs and stop before further changes.
|
||||
- If live state conflicts with this runbook's assumptions (e.g. unexpected `mailcow.conf` values), `STOP` and report.
|
||||
|
||||
## Before
|
||||
|
||||
1. Run [mailcow-health](mailcow-health.md) (Ansible health report). Record baseline.
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
# matrix_e2ee update (hass.windy.lan)
|
||||
|
||||
## Purpose
|
||||
|
||||
Update the custom **`matrix_e2ee`** integration on `hass.windy.lan` while
|
||||
preserving a verified rollback point and confirming that Home Assistant loads it.
|
||||
|
||||
## Scope
|
||||
|
||||
- Applicable: deploying a reviewed `matrix_e2ee` source revision to
|
||||
`hass.windy.lan`.
|
||||
- Not applicable: Home Assistant Core upgrades, integration configuration
|
||||
changes, or recovery without a usable live-tree backup.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owner: personal ops (Windy)
|
||||
- Last reviewed: 2026-08-23
|
||||
- Related systems: hass.windy.lan (HAOS, `machine: green`)
|
||||
|
||||
## Approval gates
|
||||
|
||||
| Action | Risk | Explicit approval |
|
||||
|---|---|---|
|
||||
| Replace the live integration tree and restart Home Assistant Core | Medium | Yes — user confirmation required |
|
||||
|
||||
## Safety
|
||||
|
||||
- Do not replace the live tree or restart Core without explicit user confirmation.
|
||||
- If any precondition or verification fails, `STOP` and record evidence before continuing.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- The source repo at `/home/windy/project/ha-matrix-e2ee` is on the **target
|
||||
state**: either a release tag (`git tag -l 'v*'`) or a commit whose
|
||||
`manifest.json` `version` is the target. Note v0.3.0 was deployed from an
|
||||
**untagged** `main` HEAD (`216cc99`), so the tag check alone is not enough —
|
||||
confirm the working-tree `custom_components/matrix_e2ee/manifest.json`.
|
||||
- The working tree matches HEAD: `git status --short` clean (only ignorables)
|
||||
and `git diff HEAD -- custom_components/` empty. Record
|
||||
`git rev-parse HEAD` for the docs/Linear record — HEAD can move during a
|
||||
session, so re-check right before rsync (verified 2026-08-18: HEAD moved
|
||||
from a `w1n-180` branch merge to `main` mid-deploy).
|
||||
- The remote host is reachable and `sudo -n -i ha core info` succeeds.
|
||||
- The workstation HTTP proxy does not interfere — LAN hosts must be reachable
|
||||
without proxying (unset `http_proxy` / `HTTP_PROXY` if needed).
|
||||
- If the agent sandbox hits `Bad owner or permissions on /etc/ssh/ssh_config.d/20-systemd-ssh-proxy.conf`, add `-F /dev/null` to the `ssh` / `rsync` commands below.
|
||||
- Domain is **`matrix_e2ee`** (double-e). Older notes may say `matrix_e2e`;
|
||||
paths, events, and services all use `matrix_e2ee`.
|
||||
|
||||
## Procedure
|
||||
|
||||
### 1. Backup the live tree
|
||||
|
||||
```bash
|
||||
ssh -o BatchMode=yes hassio@hass.windy.lan \
|
||||
'sudo -n -i mkdir -p /homeassistant/.matrix-e2ee-backups &&
|
||||
sudo -n -i cp -a /homeassistant/custom_components/matrix_e2ee \
|
||||
/homeassistant/.matrix-e2ee-backups/matrix_e2ee.bak-$(date +%Y%m%d)-v<OLD_VERSION>'
|
||||
```
|
||||
|
||||
The backup lives in `/homeassistant/.matrix-e2ee-backups/` — a directory
|
||||
separated from `custom_components/` to avoid HA scanning it as a custom
|
||||
component domain.
|
||||
|
||||
### 2. Rsync the new source
|
||||
|
||||
```bash
|
||||
rsync -a --delete -e 'ssh -o BatchMode=yes' \
|
||||
/home/windy/project/ha-matrix-e2ee/custom_components/matrix_e2ee/ \
|
||||
hassio@hass.windy.lan:/homeassistant/custom_components/matrix_e2ee/
|
||||
```
|
||||
|
||||
The `--delete` cannot remove Core-owned `__pycache__` — that is handled
|
||||
in the next step. Source `.py` files and `manifest.json` are transferred
|
||||
correctly even with the `__pycache__` errors, but **rsync exits with code 23
|
||||
(`some files/attrs were not transferred`)** — that is expected, not a failure.
|
||||
Confirm the transfer by checking the manifest on the host before restarting.
|
||||
|
||||
### 3. Wipe `__pycache__` (as root) and restart Core
|
||||
|
||||
Quote the nested `__pycache__` glob — remote login shell is zsh and will
|
||||
fail with `no matches found` if left unquoted.
|
||||
|
||||
```bash
|
||||
ssh -o BatchMode=yes hassio@hass.windy.lan \
|
||||
"sudo -n -i rm -rf /homeassistant/custom_components/matrix_e2ee/__pycache__ \
|
||||
'/homeassistant/custom_components/matrix_e2ee/*/__pycache__' &&
|
||||
sudo -n -i ha core restart"
|
||||
```
|
||||
|
||||
Stale `cpython-314` bytecode in Core-owned `__pycache__` keeps the old
|
||||
coordinator in memory until restart. Wipe before restart.
|
||||
|
||||
Wait for `Command completed successfully.` (typically 1–2 min).
|
||||
|
||||
### 4. Verify the deployment
|
||||
|
||||
#### 4a. Confirm manifest version
|
||||
|
||||
```bash
|
||||
ssh -o BatchMode=yes hassio@hass.windy.lan \
|
||||
'sudo -n -i cat /homeassistant/custom_components/matrix_e2ee/manifest.json'
|
||||
```
|
||||
|
||||
Expect `"version": "<NEW_VERSION>"`.
|
||||
|
||||
#### 4b. Check Core logs for matrix_e2ee
|
||||
|
||||
```bash
|
||||
ssh -o BatchMode=yes hassio@hass.windy.lan \
|
||||
'sudo -n -i ha core logs -n 2500' | grep -E 'matrix_e2ee|Setting up matrix' | head -20
|
||||
```
|
||||
|
||||
Expect:
|
||||
- `Setup of domain matrix_e2ee took ...` (older wording `Setting up matrix_e2ee` may appear)
|
||||
- `matrix_e2ee restored existing device; user=@hass:chans.xyz device=rO1R915ncu`
|
||||
- No `ERROR` level messages from `custom_components.matrix_e2ee`
|
||||
- Blocking-call WARNINGs from `_patch_nio_sas_timeout` / nio store I/O are expected
|
||||
|
||||
#### 4c. Verify the entry is loaded (optional, via Supervisor API)
|
||||
|
||||
No trailing slash on the entries URL (trailing `/` returns 404 on Core 2026.8.1).
|
||||
|
||||
```bash
|
||||
ssh -o BatchMode=yes hassio@hass.windy.lan \
|
||||
"sudo -n -i python3 - <<'PY'
|
||||
import os, json, urllib.request
|
||||
req = urllib.request.Request(
|
||||
'http://supervisor/core/api/config/config_entries/entry',
|
||||
headers={'Authorization': 'Bearer ' + os.environ['SUPERVISOR_TOKEN']},
|
||||
)
|
||||
entries = json.loads(urllib.request.urlopen(req, timeout=30).read())
|
||||
for e in entries:
|
||||
if e['domain'] == 'matrix_e2ee':
|
||||
print(f\"{e['domain']}: state={e['state']} source={e['source']}\")
|
||||
PY"
|
||||
```
|
||||
|
||||
Expect `state: loaded`.
|
||||
|
||||
### 5. Record the deployment
|
||||
|
||||
- Update the `matrix_e2ee` live-tree section in
|
||||
[hosts/hass.windy.lan.md](../hosts/hass.windy.lan.md): new version, source
|
||||
commit (`git rev-parse HEAD`), backup name, and any new feature notes.
|
||||
- Record the operation in the Linear `vps` project (scope, action,
|
||||
verification, follow-up); see [docs/agents/issue-tracker.md](../docs/agents/issue-tracker.md).
|
||||
|
||||
## Rollback
|
||||
|
||||
If Core fails to start after the update:
|
||||
|
||||
```bash
|
||||
# Restore the backup
|
||||
ssh -o BatchMode=yes hassio@hass.windy.lan \
|
||||
'sudo -n -i rm -rf /homeassistant/custom_components/matrix_e2ee &&
|
||||
sudo -n -i cp -a /homeassistant/.matrix-e2ee-backups/matrix_e2ee.bak-<DATE>-v<OLD_VERSION> \
|
||||
/homeassistant/custom_components/matrix_e2ee &&
|
||||
sudo -n -i rm -rf /homeassistant/custom_components/matrix_e2ee/__pycache__ &&
|
||||
sudo -n -i ha core restart'
|
||||
```
|
||||
|
||||
If a full HA backup exists (pre-update), restore via `ha backups restore <slug>`.
|
||||
|
||||
## References
|
||||
|
||||
- [hosts/hass.windy.lan.md](../hosts/hass.windy.lan.md) — current live version and config
|
||||
- [docs/home-assistant-matrix.md](../docs/home-assistant-matrix.md) — integration architecture and verification model
|
||||
- [home-assistant-maintenance.md](home-assistant-maintenance.md) — general HA maintenance procedures
|
||||
- [ha-matrix-e2ee source](https://github.com/windyboy/ha-matrix-e2ee) — GitHub repo
|
||||
@@ -1,5 +1,20 @@
|
||||
# Matrix Health Check
|
||||
|
||||
## Purpose
|
||||
|
||||
Read-only health check of the Matrix homeserver (ESS on K3s).
|
||||
|
||||
## Scope
|
||||
|
||||
- Applicable: [synapse.chans.xyz](../hosts/synapse.chans.xyz.md), namespace `ess`.
|
||||
- Read-only: does not change pods, ingress, certificates, or configuration.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owner: personal ops (Windy)
|
||||
- Last reviewed: 2026-08-17
|
||||
- Related systems: synapse.chans.xyz (ESS chart `26.7.2`, K3s)
|
||||
|
||||
Monitor the Matrix homeserver running on `synapse.chans.xyz` (ESS chart `26.7.2`, K3s node).
|
||||
|
||||
Prefer `cd ansible && ansible-playbook playbooks/health-report.yml --limit matrix`
|
||||
@@ -90,3 +105,9 @@ Backup automation is currently paused. `/var/backups/matrix/` is retained for a
|
||||
| Well-known returns 404/redirect | Root `chans.xyz` ingress missing or misconfigured |
|
||||
| 502 Bad Gateway | Synapse pod restarting or DB down |
|
||||
| SMTP emails not sent | MAS SMTP config incomplete; TCP reachable but AUTH failing — see `runbooks/vaultwarden-health.md` |
|
||||
|
||||
## Safety
|
||||
|
||||
- Read-only: never mutate pods, ingress, certificates, or configuration during this check.
|
||||
- Backup automation is paused; do not treat `/var/backups/matrix/` as a recovery source.
|
||||
- If live state conflicts with an expected value, `STOP` and report.
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
# Matter packet capture (read-only)
|
||||
|
||||
## Purpose
|
||||
|
||||
Capture Matter-related traffic on LAN55 (mDNS discovery + PASE/CASE commissioning +
|
||||
operational traffic) to determine whether a device is on the network, is in
|
||||
commissioning mode, and whether the commissioning handshake completes. Capture
|
||||
is read-only and changes no device or network state.
|
||||
|
||||
## Scope
|
||||
|
||||
- Environment: LAN55 (`hass.windy.lan`, Aqara M3, ESP32-C2 Matter bulbs,
|
||||
phone / HA matter-server all on the 55 subnet).
|
||||
- Subject: Matter over Wi-Fi and Thread relay nodes. The Thread 802.15.4 air
|
||||
side itself is not capturable — only IPv6 forwarding by a Thread relay such
|
||||
as the M3 is visible.
|
||||
- Not applicable: BLE commissioning, Thread 802.15.4 frames, cross-subnet
|
||||
multicast (66-subnet hosts cannot see the 55 subnet's mDNS — link-local
|
||||
multicast does not cross the routed 55/66 boundary, there is no reflector).
|
||||
- Read-only: no AP/device/network config is modified; state returns to normal
|
||||
when tcpdump exits.
|
||||
|
||||
### Capture-point selection
|
||||
|
||||
Matter commissioning is a two-party conversation and the commissioner
|
||||
participates in every message of it, so capturing on the commissioner host
|
||||
equals capturing the whole flow.
|
||||
|
||||
| Capture point | Sees | Blind spot | Notes |
|
||||
|---|---|---|---|
|
||||
| **hass `end0` — commissioner side (recommended)** | The full HA-driven commissioning conversation: all mDNS queries/announcements (segment multicast) + the complete TCP 5540 PASE/CASE session | Phone-as-commissioner flows (the phone's session to the device does not pass through hass) | `core_matter_server` uses **host networking**, so tcpdump on `end0` sees the add-on's traffic directly; `/` is overlay with ~42 GB free — no 60 MB tmpfs rotation needed |
|
||||
| **UAP-AC-Lite `br0` (192.168.55.5)** | All mDNS multicast (flooded; igmp snooping off) + all wireless-client unicast + unicast to/from the AP | Wired↔wired unicast — e.g. HA↔M3 TCP 5540 while a Thread device commissions via the M3 (wired, observed) — is switched locally and never traverses the AP | AP `/tmp` is a ~60 MB tmpfs → rotating capture is **mandatory** |
|
||||
|
||||
For the common "add device" case with HA matter-server as the commissioner,
|
||||
capture on hass `end0`. Use the AP `br0` point for wireless-device or
|
||||
phone-driven flows (a wireless client's unicast to/from its AP is only visible
|
||||
there).
|
||||
|
||||
A third point, `gw` `switch0`, is **verified as a limited capture point**
|
||||
(cross-subnet/gateway/mDNS flows only — not a full mirror of LAN55) — see
|
||||
[Capture point: gw switch0](#capture-point-gw-switch0).
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owner: personal ops (Windy)
|
||||
- Last reviewed: 2026-08-22
|
||||
- Related systems: UAP-AC-Lite AP `192.168.55.5` (br0), `core_matter_server` on
|
||||
`hass.windy.lan` (`end0`), Aqara M3, ESP32-C2 Matter bulbs
|
||||
|
||||
## Preconditions
|
||||
|
||||
- SSH to the capture point:
|
||||
- AP: `ssh zhiqiangf@192.168.55.5` (key-only, `BatchMode=yes` verified).
|
||||
- hass: `ssh hassio@hass.windy.lan`. Non-interactive SSH does **not** source
|
||||
`.zprofile`, so run tcpdump as `sudo -n -i tcpdump …` (verified 2026-08-22).
|
||||
- tcpdump available:
|
||||
- AP: full 4.9.2 / libpcap 1.8.1 (verified 2026-08-22).
|
||||
- hass: `/usr/bin/tcpdump` via `sudo -n -i` (verified 2026-08-22).
|
||||
- Trigger source ready: put the Matter device into commissioning mode, or have
|
||||
HA/phone perform discovery/commissioning — otherwise no relevant packets.
|
||||
- AP `/tmp` is a ~60 MB tmpfs (61.3 M total, 60.4 M free): rotating capture
|
||||
(`-C`/`-W`) is mandatory on the AP. hass `/` is overlay — rotation optional
|
||||
but keep the habit for long captures.
|
||||
|
||||
## Safety
|
||||
|
||||
### Non-negotiable rules
|
||||
|
||||
- Read-only diagnosis: no installs, config changes, or service restarts on the
|
||||
AP, hass, devices, or network.
|
||||
- pcap files are limited to `/tmp`; pull them off and delete them afterwards
|
||||
(mandatory on the AP; same hygiene on hass).
|
||||
- Never write captured content (including any plaintext key material) into this
|
||||
repository or Linear.
|
||||
|
||||
### Stop conditions
|
||||
|
||||
- Capture point unreachable (ssh fails) → `STOP`, fix the network first.
|
||||
- tcpdump reports "Permission denied" or cannot listen → `STOP` (admin needed;
|
||||
on hass verify `sudo -n -i` works).
|
||||
- Filter expression syntax error → `STOP`, use only expressions verified in
|
||||
this document.
|
||||
- AP `/tmp` nearly full (rotation file count × single-file size ≈ 60 MB) →
|
||||
`STOP` and clean old pcaps.
|
||||
|
||||
## Procedure
|
||||
|
||||
### Step 1 — Choose the capture point
|
||||
|
||||
**Action**
|
||||
|
||||
- HA matter-server is the commissioner (the "add device" case) → hass `end0`.
|
||||
- Wireless device or phone-driven flow → AP `br0`.
|
||||
|
||||
**Expected**
|
||||
|
||||
- The chosen point is reachable and tcpdump starts listening.
|
||||
|
||||
**Decision**
|
||||
|
||||
- Capture point chosen and reachable → Step 2.
|
||||
- Neither applies or the choice is unclear → `STOP` and record why.
|
||||
|
||||
### Step 2 — Realtime observation (quick confirm traffic appears)
|
||||
|
||||
**Action**
|
||||
|
||||
AP:
|
||||
|
||||
```bash
|
||||
ssh zhiqiangf@192.168.55.5 "tcpdump -ni br0 -s 0 -tt 'udp port 5353 or tcp port 5540 or tcp port 5552'"
|
||||
```
|
||||
|
||||
hass (commissioner side):
|
||||
|
||||
```bash
|
||||
ssh hassio@hass.windy.lan "sudo -n -i tcpdump -ni end0 -s 0 -tt 'udp port 5353 or tcp port 5540 or tcp port 5552'"
|
||||
```
|
||||
|
||||
Keep the window open, trigger the device behavior (enter commissioning mode /
|
||||
start commissioning / send a command), `Ctrl+C` to stop.
|
||||
|
||||
**Expected**
|
||||
|
||||
- `_matterc._udp` / `_matter._tcp` mDNS announcements (UDP 5353, multicast
|
||||
`224.0.0.251` / `ff02::fb`).
|
||||
- During commissioning: TCP **5540** (PASE/CASE) SYN/SYN-ACK between the device
|
||||
IP and HA/M3.
|
||||
- If the target device's MAC is known, add `and ether host <mac>` to keep only
|
||||
that device (see variants).
|
||||
- `5552` is not a standard Matter port; it is an observed port for the Aqara M3
|
||||
Thread-relay node (see `docs/matter-pairing-troubleshoot.md`).
|
||||
|
||||
**Decision**
|
||||
|
||||
- Expected packets present → Step 3 to save evidence, or judge directly against
|
||||
the stage table (`docs/matter-pairing-troubleshoot.md` §4).
|
||||
- No packets at all → `STOP`: fix device online / commissioning-mode first; the
|
||||
network side is repeatedly verified healthy (see troubleshooting doc).
|
||||
- mDNS present but no 5540 → see troubleshooting doc decision tree, item 4
|
||||
(§5).
|
||||
|
||||
### Step 3 — Rotating capture + pull to WSL
|
||||
|
||||
**Action** (`-C 5` = rotate every 5 MB, `-W 12` = max 12 files, ≈ 60 MB ≤ AP tmpfs)
|
||||
|
||||
AP:
|
||||
|
||||
```bash
|
||||
ssh zhiqiangf@192.168.55.5 "tcpdump -ni br0 -s 0 -C 5 -W 12 -w /tmp/matter.pcap 'udp port 5353 or tcp port 5540 or tcp port 5552'"
|
||||
```
|
||||
|
||||
hass (rotation optional — overlay disk):
|
||||
|
||||
```bash
|
||||
ssh hassio@hass.windy.lan "sudo -n -i tcpdump -ni end0 -s 0 -C 5 -W 12 -w /tmp/matter.pcap 'udp port 5353 or tcp port 5540 or tcp port 5552'"
|
||||
```
|
||||
|
||||
Trigger the traffic, then `Ctrl+C`. Files are `/tmp/matter.pcap`,
|
||||
`/tmp/matter.pcap1`, …
|
||||
|
||||
**Expected**
|
||||
|
||||
- tcpdump prints capture statistics (`N packets captured`).
|
||||
- `ls -la /tmp/matter.pcap*` shows the files; total stays < 60 MB on the AP.
|
||||
|
||||
**Verification**
|
||||
|
||||
```bash
|
||||
ssh zhiqiangf@192.168.55.5 "ls -la /tmp/matter.pcap*"
|
||||
# or
|
||||
ssh hassio@hass.windy.lan "ls -la /tmp/matter.pcap*"
|
||||
```
|
||||
|
||||
**Pull to WSL for analysis and clean up afterwards**
|
||||
|
||||
```bash
|
||||
scp zhiqiangf@192.168.55.5:/tmp/matter.pcap* .
|
||||
# or
|
||||
scp hassio@hass.windy.lan:/tmp/matter.pcap* .
|
||||
|
||||
# clean up on the capture point
|
||||
ssh zhiqiangf@192.168.55.5 "rm -f /tmp/matter.pcap*"
|
||||
ssh hassio@hass.windy.lan "sudo -n -i rm -f /tmp/matter.pcap*"
|
||||
```
|
||||
|
||||
### Step 4 — Wireshark analysis (optional)
|
||||
|
||||
**Action**
|
||||
|
||||
Open the pcap in Wireshark. mDNS (UDP 5353) is plaintext and directly
|
||||
readable; Matter payloads on TCP/UDP 5540 show only the handshake by default —
|
||||
plaintext needs the dissector plus session keys (Step 5).
|
||||
|
||||
**Expected**
|
||||
|
||||
- `mDNS` filter shows all discovery records; `tcp.port==5540` shows the
|
||||
commissioning handshake.
|
||||
|
||||
### Step 5 — Decrypt Matter plaintext (optional, needs session keys)
|
||||
|
||||
Matter payloads are encrypted (AES-CCM); mDNS plaintext contains no keys. To
|
||||
decrypt, one of:
|
||||
|
||||
1. **Capture-side key leak with a chip tool (most common)**: the commissioner
|
||||
(HA matter-server / chip-tool) prints or exports session keys during
|
||||
commissioning; enter them in Wireshark → Preferences → Protocols → Matter.
|
||||
See [matter-dissector README](https://github.com/project-chip/matter-dissector#security-features).
|
||||
2. **well-known CASE keys**: both sides compiled with
|
||||
`MATTER_CONFIG_SECURITY_TEST_MODE` / `CASEUseKnownECDHKey`; not enabled in
|
||||
this environment (ESP32-C2 + HA official matter-server).
|
||||
|
||||
**Expected**
|
||||
|
||||
- Matter dissector expands protocol headers, IM commands, and cluster content.
|
||||
|
||||
**Stop condition (decryption)**: with no session keys or test keys obtainable,
|
||||
do not fabricate keys to force a decrypt — plaintext mDNS + TCP handshake
|
||||
still resolves most troubleshooting; for plaintext payloads, upgrade to
|
||||
exporting keys on the commissioner side, then return to this runbook.
|
||||
|
||||
## Targeted capture variants
|
||||
|
||||
### One device only (known MAC)
|
||||
|
||||
```bash
|
||||
ssh zhiqiangf@192.168.55.5 "tcpdump -ni br0 -s 0 -tt 'ether host 34:98:7a:27:7f:08 and (udp port 5353 or tcp port 5540 or tcp port 5552)'"
|
||||
```
|
||||
|
||||
MACs from `docs/matter-pairing-troubleshoot.md` §3 (working bulb
|
||||
`34:98:7a:25:a1:f0`, broken bulb `34:98:7a:27:7f:08`).
|
||||
|
||||
### mDNS announcements only (no 5540 noise)
|
||||
|
||||
```bash
|
||||
ssh zhiqiangf@192.168.55.5 "tcpdump -ni br0 -s 0 -tt 'udp port 5353'"
|
||||
```
|
||||
|
||||
### Rotating capture with timestamped filename (multiple runs)
|
||||
|
||||
```bash
|
||||
ssh zhiqiangf@192.168.55.5 "tcpdump -ni br0 -s 0 -C 5 -W 12 -w /tmp/matter-\$(date +%H%M%S).pcap 'udp port 5353 or tcp port 5540 or tcp port 5552'"
|
||||
```
|
||||
|
||||
> The nested `$(date)` must be escaped as `\$(...)` inside the double-quoted ssh
|
||||
> command so the remote shell expands it.
|
||||
|
||||
For the hass point, prefix the same commands with
|
||||
`ssh hassio@hass.windy.lan "sudo -n -i tcpdump -ni end0 …"`.
|
||||
|
||||
## Capture point: gw switch0
|
||||
|
||||
**Status: verified 2026-08-22 — limited capture point; NOT a full mirror of
|
||||
LAN55.**
|
||||
|
||||
- `gw` `switch0` (`eth1`–`eth3`, `192.168.55.254/24`) is LAN55's L2 aggregation
|
||||
only while devices plug directly into the ER-X. EdgeOS ships tcpdump;
|
||||
`tcpdump -ni switch0` follows Linux bridge semantics.
|
||||
- **Live topology (verified 2026-08-22): the SE5420 core switch is deployed**
|
||||
(management `192.168.66.253` up — TP-Link OUI `f8:c9:03`, web UI on
|
||||
:80/:443) and the ER-X uplink is a **single switch0 member port**: `eth1`
|
||||
link up, `eth2`/`eth3` down. All LAN55 wired devices (hass `.11`, Aqara M3
|
||||
`.248`, SmartThings `.48`, UAP-AC-Lite `.5`) are reached via `switch0`
|
||||
behind that one uplink. Same-segment wired↔wired unicast switches locally on
|
||||
the SE5420 and never reaches `switch0`.
|
||||
- **What `switch0` still sees:** cross-subnet (66↔55) unicast, traffic to/from
|
||||
the gateway itself (DHCP, DNS forwarding, port-forwards), and LAN55 mDNS
|
||||
multicast (flooded up the uplink). Use it only for those flows; for a full
|
||||
commissioning conversation use the hass `end0` or AP `br0` point instead.
|
||||
- **Full mirror:** only via SE5420 port mirroring (the switch cannot run
|
||||
tcpdump). Not configured; out of scope here.
|
||||
- **Verification commands (EdgeOS v3.0.1 build 5862409):**
|
||||
- Interactive: `ssh ubnt@192.168.66.254` (or `zhiqiang`), then
|
||||
`show interfaces ethernet` — port link states are the decisive check
|
||||
(`eth1` up + `eth2`/`eth3` down = single uplink). `configure` (config
|
||||
mode) also accepts `show ...`.
|
||||
- Non-interactive (agent/script): `show`/`configure` are interactive-only
|
||||
aliases on this build; use the op wrapper:
|
||||
```bash
|
||||
ssh ubnt@192.168.66.254 '/opt/vyatta/bin/vyatta-op-cmd-wrapper show interfaces ethernet'
|
||||
```
|
||||
- `show ethernet-switch port all` and `show mac-address-table` are NOT
|
||||
available on this build; the switch FDB is hardware-offloaded
|
||||
(`brctl showmacs switch0` → "Operation not supported"). Port link state
|
||||
+ ARP (`show arp`) are the reliable checks.
|
||||
- SE5420 liveness: `ping 192.168.66.253` and `:80/:443`.
|
||||
- Sample capture at this point (cross-segment/gateway/mDNS flows only;
|
||||
tcpdump needs root — `zhiqiang` has passwordless sudo):
|
||||
```bash
|
||||
ssh zhiqiang@192.168.66.254 "sudo -n tcpdump -ni switch0 -s 0 'udp port 5353 or tcp port 5540 or tcp port 5552'"
|
||||
```
|
||||
|
||||
## Pass criteria
|
||||
|
||||
- Realtime capture consistently shows the target device's mDNS announcements
|
||||
(`_matterc` / `_matter._tcp`) on the chosen point.
|
||||
- Commissioning shows the TCP 5540 handshake (SYN/SYN-ACK/ACK); on the hass
|
||||
`end0` point this includes wired Thread-relay commissioning (HA↔M3), which
|
||||
the AP point cannot see.
|
||||
- Saved pcap opens in Wireshark and filters by `mDNS` / `tcp.port==5540`.
|
||||
|
||||
## References
|
||||
|
||||
- [docs/matter-pairing-troubleshoot.md](../docs/matter-pairing-troubleshoot.md) —
|
||||
troubleshooting decision tree, stage table, device MAC/fabric facts
|
||||
- [docs/unifi-network.md](../docs/unifi-network.md) — UniFi network/IPv6/SSID records
|
||||
- [hosts/hass.windy.lan.md](../hosts/hass.windy.lan.md) — matter-server host
|
||||
networking + `sudo -n -i` non-interactive note
|
||||
- [hosts/gw.md](../hosts/gw.md) — DHCP `matter` reservation MAC mismatch (pending, W1N-207)
|
||||
- [matter-dissector](https://github.com/project-chip/matter-dissector) —
|
||||
Wireshark Matter dissector (incl. decryption)
|
||||
- [Silabs: Using Wireshark to Capture Network Traffic in Matter](https://docs.silabs.com/matter/2.9.1/matter-references/matter-wireshark)
|
||||
@@ -0,0 +1,74 @@
|
||||
# Runbook: controlled network change
|
||||
|
||||
## Purpose
|
||||
|
||||
Apply a controlled network configuration change (DNS records, firewall, LAN
|
||||
gateway, VLAN) with impact assessment, approval, and a rollback path.
|
||||
|
||||
## Scope
|
||||
|
||||
- Applicable: PowerDNS zone records, `us4` firewalld allowlist, LAN gateway/VLAN/DNS changes, WireGuard.
|
||||
- Not applicable: SSH access-policy changes (see `AGENTS.md` §SSH access safety — mandatory lockout-risk procedure).
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owner: personal ops (Windy)
|
||||
- Last reviewed: 2026-08-17
|
||||
- Related systems: PowerDNS / us4 firewalld / LAN gateway / WireGuard
|
||||
|
||||
## Preconditions
|
||||
|
||||
- A change record (Linear `vps` issue) describes the change, its reason, and rollback.
|
||||
- Read-only impact assessment done (current config captured, blast radius known).
|
||||
|
||||
## Safety
|
||||
|
||||
- Never change DNS or network config without a change record and approval.
|
||||
- Capture the current config first; never delete existing config as the first action.
|
||||
- For DNS: record the current record values and TTL before editing.
|
||||
- For firewall: retain an independent SSH rollback session before applying (see `ansible-operations.md` §us4).
|
||||
|
||||
## Procedure
|
||||
|
||||
### Step 1 — Assess and capture
|
||||
|
||||
**Action** — capture the current state (e.g. `dig` for DNS, `--check --diff` for firewall, `show` for gateway).
|
||||
|
||||
**Expected** — a baseline of current config and an identified blast radius.
|
||||
|
||||
**Decision** — change fully specified with rollback → Step 2; missing → `STOP`.
|
||||
|
||||
### Step 2 — Approve
|
||||
|
||||
**Action** — confirm approval is recorded in the issue/change record.
|
||||
|
||||
**Decision** — approved → Step 3; not approved → `STOP`.
|
||||
|
||||
### Step 3 — Change
|
||||
|
||||
**Action** — apply the single change (edit the record, run the gated playbook, or change gateway config) and only that change.
|
||||
|
||||
**Expected** — the new value/state is in effect.
|
||||
|
||||
**Verification** — re-query/verify the new state and confirm dependent services still pass health.
|
||||
|
||||
**Rollback** — restore the captured prior config and re-verify.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Troubleshooting A — Change broke dependent service
|
||||
|
||||
- Evidence: health report / endpoint failure.
|
||||
- Allowed: roll back to the captured prior config.
|
||||
- Next: verify; if still broken, escalate.
|
||||
|
||||
## Final Verification
|
||||
|
||||
- New state verified; dependent services healthy.
|
||||
- Change and outcome recorded in the issue.
|
||||
|
||||
## References
|
||||
|
||||
- [`ansible-operations.md`](ansible-operations.md)
|
||||
- [`rollback.md`](rollback.md)
|
||||
- [`network-recovery.md`](network-recovery.md)
|
||||
@@ -0,0 +1,67 @@
|
||||
# Runbook: network outage / service recovery
|
||||
|
||||
## Purpose
|
||||
|
||||
Recover from a network outage or service failure, starting from read-only
|
||||
diagnosis and mutating only when the root cause is confirmed.
|
||||
|
||||
## Scope
|
||||
|
||||
- Applicable: unreachable VPS services, LAN gateway/DNS failures, DNS resolution failures.
|
||||
- Not applicable: planned changes (→ `network-change.md`), SSH access recovery (→ `AGENTS.md` §SSH access safety).
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owner: personal ops (Windy)
|
||||
- Last reviewed: 2026-08-17
|
||||
- Related systems: VPS services / LAN gateway / DNS
|
||||
|
||||
## Safety
|
||||
|
||||
- Read-only diagnosis first; do not mutate while the root cause is unknown.
|
||||
- If live state conflicts with a runbook's assumptions, `STOP` and report.
|
||||
- Keep the current verified management session open as the recovery path.
|
||||
|
||||
## Procedure
|
||||
|
||||
### Step 1 — Diagnose (read-only)
|
||||
|
||||
**Action** — gather evidence without changing anything:
|
||||
|
||||
```bash
|
||||
# From laptop, pin DNS to a public resolver if the stub is flaky
|
||||
dig @1.1.1.1 +short <host> A
|
||||
curl -4 -sS -I --max-time 10 https://<host>/
|
||||
# From a reachable host, inspect the service
|
||||
ssh -4 windy@<host> 'docker compose ps -a; df -h /; tail -n 50 /var/lib/vps-health/latest.json'
|
||||
```
|
||||
|
||||
**Expected** — a clear picture: is it DNS, connectivity, host, or service?
|
||||
|
||||
**Decision** — root cause localized → Step 2; ambiguous or conflicting → `STOP` and escalate (provider console if host is unreachable).
|
||||
|
||||
### Step 2 — Confirm and route
|
||||
|
||||
**Action** — match the failure to the owning runbook (`mailcow-health.md`, `pdns-health.md`, `matrix-health.md`, etc.) or `network-change.md` for a config fix.
|
||||
|
||||
**Expected** — an applicable runbook with a recovery action.
|
||||
|
||||
**Decision** — applicable → follow it; none → `STOP` (diagnose only, do not mutate).
|
||||
|
||||
### Step 3 — Recover (gated)
|
||||
|
||||
**Action** — apply only the runbook's documented recovery, with approval.
|
||||
|
||||
**Verification** — re-run the health report / endpoint check and confirm recovery.
|
||||
|
||||
**Rollback** — if recovery makes it worse, revert per `rollback.md`.
|
||||
|
||||
## Final Verification
|
||||
|
||||
- Service reachable and health report green.
|
||||
- Incident and recovery recorded in the Linear `vps` issue.
|
||||
|
||||
## References
|
||||
|
||||
- [`network-change.md`](network-change.md)
|
||||
- Per-service health runbooks under [`runbooks/`](.)
|
||||
@@ -1,5 +1,20 @@
|
||||
# PowerDNS health (hk2)
|
||||
|
||||
## Purpose
|
||||
|
||||
Read-only health check of the `/opt/pdns` PowerDNS stack.
|
||||
|
||||
## Scope
|
||||
|
||||
- Applicable: [hk2.chans.xyz](../hosts/hk2.chans.xyz.md), `/opt/pdns`.
|
||||
- Read-only: does not change PowerDNS, DNS records, or secrets.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owner: personal ops (Windy)
|
||||
- Last reviewed: 2026-08-17
|
||||
- Related systems: hk2.chans.xyz (`/opt/pdns`)
|
||||
|
||||
Read-only checks for the `/opt/pdns` stack on **hk2.chans.xyz** (`ns1.wsvc.info`).
|
||||
|
||||
Facts: [hosts/hk2.chans.xyz.md](../hosts/hk2.chans.xyz.md) · Upstream: [docs/pdns-upstream.md](../docs/pdns-upstream.md)
|
||||
@@ -87,6 +102,12 @@ Expect: `primary=yes`, `also-notify=202.91.35.141`, `only-notify=` empty, `gpgsq
|
||||
The sanitized Ansible health profile is `pdns` (`ansible/playbooks/healthchecks.yml`). It runs locally through `vps-healthcheck.timer`, writes a sanitized JSON result to `/var/lib/vps-health/latest.json`, and uses the API key only inside the PowerDNS container. It does not modify PowerDNS, DNS records, or secrets.
|
||||
|
||||
|
||||
## Safety
|
||||
|
||||
- Read-only: never mutate PowerDNS configuration or DNS records during this check.
|
||||
- Do not paste the API key into chat/logs.
|
||||
- If live state conflicts with an expected value, `STOP` and report.
|
||||
|
||||
## After config changes
|
||||
|
||||
- `auth/pdns.conf`, `auth/templates.d/secrets.j2`, or auth-related `.env` → use the Ansible Compose reconcile playbook with target `auth`
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
# Runbook: pgdb health (TimescaleDB + pgweb + pg-backup)
|
||||
|
||||
## Purpose
|
||||
|
||||
Read-only health check of the pgdb TimescaleDB compose stack (PG18 + pgweb GUI + nightly custom-format backups). Confirms the stack is serving Home Assistant (hass/scribe) and that backups are current and restorable.
|
||||
|
||||
## Scope
|
||||
|
||||
- Applicable: [pgdb](../hosts/pgdb.md) (`192.168.55.15`), `/opt/database` compose stack.
|
||||
- Read-only: never mutates containers, databases, backups, or secrets.
|
||||
- Not applicable: restoring data (use [pgdb-restore](pgdb-restore.md)), upgrading images (use [pgdb-update](pgdb-update.md)), HA-side changes (see `hosts/hass.windy.lan.md`).
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owner: personal ops (Windy)
|
||||
- Last reviewed: 2026-08-29
|
||||
- Related systems: pgdb (`/opt/database`, TimescaleDB 18.6 / TS 2.29.2), HA `192.168.55.11` (hass/scribe clients)
|
||||
|
||||
## Access
|
||||
|
||||
SSH to pgdb (key auth works from the WSL agent shell as of 2026-08-29):
|
||||
|
||||
```bash
|
||||
ssh -F /dev/null -o BatchMode=yes windy@192.168.55.15
|
||||
```
|
||||
|
||||
From the agent sandbox, always use `-F /dev/null` (system ssh config is unreadable there) and prefer IPv4. The compose project lives at `/opt/database` — prefix every `docker compose` call with `cd /opt/database`. Never print `.env` values or bookmarks (they contain DB passwords); compare or use them only inside commands that output non-secret signals (status codes, counts, names).
|
||||
|
||||
## Safety
|
||||
|
||||
### Non-negotiable rules
|
||||
|
||||
- Read-only diagnosis only; never "fix while checking".
|
||||
- Never print passwords or secrets — redact/consume them inside commands.
|
||||
- If live state conflicts with an expected value, `STOP` and record evidence; do not invent parameters or bypass a failed check.
|
||||
- Restore/update work belongs to the change runbooks, not this one.
|
||||
|
||||
### Stop conditions
|
||||
|
||||
- Any container `Exited`, `Restarting`, or not `healthy` where expected.
|
||||
- Expected database/table/hypertable missing or a key query errors.
|
||||
- Write-activity sample does not increase (scribe `states_raw` static).
|
||||
- Latest daily backup older than today, not custom format, or `pg_restore -l` fails.
|
||||
- Disk usage near full on `/srv/pgdata` or `/`.
|
||||
- New `ERROR`/`FATAL` lines in the timescaledb log or backup failures in the pg-backup log.
|
||||
|
||||
## Pass criteria
|
||||
|
||||
- `docker compose ps -a`: `timescaledb` + `pg-backup` **Up (healthy)**, `pgweb` **Up**; ports bound to `192.168.55.15:5432` and `:8081`.
|
||||
- PG 18.x; databases `hass`, `scribe`, `postgres` present; HA (`192.168.55.11`) connected as `hass` to both `hass` and `scribe`.
|
||||
- `hass.states` and `scribe.states_raw` row counts grow between two samples (scribe writes continuously).
|
||||
- TimescaleDB extension 2.29.x; scribe hypertables `states_raw` + `events` (1-dim, `time`); compression configured (segmentby/orderby rows in `timescaledb_information.compression_settings`); `entities` table exists.
|
||||
- pgweb: no credentials → HTTP 401; with credentials → HTTP 200; `/api/bookmarks` → `["hass","scribe"]`.
|
||||
- `daily/*-latest.dump` symlinks point to today's dumps; `file -L` reports `PostgreSQL custom database dump`.
|
||||
- `/srv/pgdata` (`/dev/sdb1`, 32G) and `/` not near full; fstab mounts `/srv/pgdata` by `UUID=c9e12e79-1f66-404c-ab7f-b8809be81d86` with `defaults,noatime`.
|
||||
- timescaledb log: no new `ERROR`/`FATAL`; pg-backup log: recent successful backup.
|
||||
|
||||
## Checks
|
||||
|
||||
### 1. Containers
|
||||
|
||||
```bash
|
||||
ssh -F /dev/null -o BatchMode=yes windy@192.168.55.15 'cd /opt/database && docker compose ps -a --format "table {{.Name}}\t{{.Status}}\t{{.Ports}}"'
|
||||
```
|
||||
|
||||
**Expected**
|
||||
|
||||
- `timescaledb` Up (healthy), `pg-backup` Up (healthy), `pgweb` Up.
|
||||
- Ports: `192.168.55.15:5432->5432/tcp` (timescaledb), `192.168.55.15:8081->8081/tcp` (pgweb).
|
||||
|
||||
**Stop** if any container is `Exited`/`Restarting`/`unhealthy`, or a port binding changed.
|
||||
|
||||
### 2. PG core and HA clients
|
||||
|
||||
```bash
|
||||
ssh -F /dev/null -o BatchMode=yes windy@192.168.55.15 'cd /opt/database && docker compose exec -T timescaledb psql -U postgres -Atc "select version();" | head -1'
|
||||
ssh -F /dev/null -o BatchMode=yes windy@192.168.55.15 'cd /opt/database && docker compose exec -T timescaledb psql -U postgres -Atc "select datname from pg_database where datistemplate=false order by 1;"'
|
||||
ssh -F /dev/null -o BatchMode=yes windy@192.168.55.15 'cd /opt/database && docker compose exec -T timescaledb psql -U postgres -Atc "select datname, usename, client_addr from pg_stat_activity where client_addr is not null group by 1,2,3 order by 1;"'
|
||||
```
|
||||
|
||||
**Expected**
|
||||
|
||||
- `PostgreSQL 18.x` (verified: 18.6).
|
||||
- Databases: `hass`, `postgres`, `scribe`.
|
||||
- HA sessions: `hass|hass|192.168.55.11` and `scribe|hass|192.168.55.11` (the HAOS recorder/scribe clients from `192.168.55.11`).
|
||||
|
||||
**Stop** if a database is missing, the version is not 18.x, or HA has no live sessions (scribe connectivity is part of the HA pipeline).
|
||||
|
||||
### 3. Write activity
|
||||
|
||||
```bash
|
||||
ssh -F /dev/null -o BatchMode=yes windy@192.168.55.15 'cd /opt/database
|
||||
A=$(docker compose exec -T timescaledb psql -U postgres -d scribe -Atc "select count(*) from states_raw;")
|
||||
sleep 30
|
||||
B=$(docker compose exec -T timescaledb psql -U postgres -d scribe -Atc "select count(*) from states_raw;")
|
||||
echo "states_raw $A -> $B"'
|
||||
```
|
||||
|
||||
Also sample `hass.states` once (recorder table, bulk-writes on HA restart): `docker compose exec -T timescaledb psql -U postgres -d hass -Atc "select count(*) from states;"`.
|
||||
|
||||
**Expected** — `states_raw` increases between samples (verified: 2665 → 2693 in 30 s). `states` count is sane (thousands).
|
||||
|
||||
**Stop** if `states_raw` is static across samples while HA is up — writes have stalled.
|
||||
|
||||
### 4. TimescaleDB (extension, hypertables, compression)
|
||||
|
||||
```bash
|
||||
ssh -F /dev/null -o BatchMode=yes windy@192.168.55.15 'cd /opt/database && docker compose exec -T timescaledb psql -U postgres -Atc "select extversion from pg_extension where extname='"'"'timescaledb'"'"';"'
|
||||
ssh -F /dev/null -o BatchMode=yes windy@192.168.55.15 'cd /opt/database && docker compose exec -T timescaledb psql -U postgres -d scribe -Atc "select hypertable_name, num_dimensions from timescaledb_information.hypertables order by 1;"'
|
||||
ssh -F /dev/null -o BatchMode=yes windy@192.168.55.15 'cd /opt/database && docker compose exec -T timescaledb psql -U postgres -d scribe -Atc "select hypertable_name, attname, segmentby_column_index, orderby_column_index from timescaledb_information.compression_settings order by 1,3,4;"'
|
||||
ssh -F /dev/null -o BatchMode=yes windy@192.168.55.15 'cd /opt/database && docker compose exec -T timescaledb psql -U postgres -d scribe -Atc "select to_regclass('"'"'public.entities'"'"');"'
|
||||
```
|
||||
|
||||
**Expected**
|
||||
|
||||
- Extension version `2.29.x` (verified: 2.29.2).
|
||||
- Scribe hypertables: `states_raw` and `events`, both `1` dimension.
|
||||
- Compression configured for `states_raw` (segmentby `metadata_id` idx 1, orderby `time` idx 1) and `events` (segmentby `event_type`, orderby `time`). Note: TimescaleDB 2.29.x has **no** `compression_enabled` column in this view — row presence is the enabled signal.
|
||||
- `entities` resolves (scribe registry table).
|
||||
|
||||
**Stop** if the extension version differs from the pinned 2.29.x line, a hypertable is missing, compression rows vanish, or `entities` is absent (scribe schema broke — see Known issues in [hosts/pgdb.md](../hosts/pgdb.md)).
|
||||
|
||||
### 5. pgweb GUI
|
||||
|
||||
```bash
|
||||
ssh -F /dev/null -o BatchMode=yes windy@192.168.55.15 'cd /opt/database
|
||||
curl -s -o /dev/null -w "no-auth:%{http_code}\n" --max-time 8 http://192.168.55.15:8081/
|
||||
U=$(grep -E "^PGWEB_AUTH_USER=" .env | cut -d= -f2-); P=$(grep -E "^PGWEB_AUTH_PASS=" .env | cut -d= -f2-)
|
||||
curl -s -o /dev/null -w "auth:%{http_code}\n" --max-time 8 -u "$U:$P" http://192.168.55.15:8081/
|
||||
echo -n "bookmarks:"; curl -s --max-time 8 -u "$U:$P" http://192.168.55.15:8081/api/bookmarks; echo'
|
||||
```
|
||||
|
||||
Use `192.168.55.15:8081` (pgweb binds the VM IP only — loopback is not bound). Credentials are read from `.env` on the host and never printed.
|
||||
|
||||
**Expected** — `no-auth:401`, `auth:200`, `bookmarks:["hass","scribe"]`.
|
||||
|
||||
**Stop** if pgweb is unreachable, unauthenticated access is not 401, or bookmarks diverge from `["hass","scribe"]`.
|
||||
|
||||
### 6. Backups
|
||||
|
||||
```bash
|
||||
ssh -F /dev/null -o BatchMode=yes windy@192.168.55.15 'ls -l --time-style=long-iso /opt/database/backups/daily/'
|
||||
ssh -F /dev/null -o BatchMode=yes windy@192.168.55.15 'file -L /opt/database/backups/daily/hass-latest.dump'
|
||||
ssh -F /dev/null -o BatchMode=yes windy@192.168.55.15 'cd /opt/database && docker compose exec -T pg-backup pg_restore -l /backups/daily/hass-latest.dump | head -4'
|
||||
```
|
||||
|
||||
**Expected**
|
||||
|
||||
- `daily/*-latest.dump` symlinks point to **today's** `*-YYYYMMDD.dump` (nightly 02:00 local `Asia/Shanghai`; a fresh container also fires `BACKUP_ON_START`).
|
||||
- `file -L` reports `PostgreSQL custom database dump` (pg_restore format; verified v1.16-0).
|
||||
- `pg_restore -l` from the **pg-backup** container lists the archive TOC without error (timescaledb does not mount `/backups`).
|
||||
|
||||
**Stop** if the latest dump is not from today, is not custom format, or `pg_restore -l` fails. Stray non-`daily/` dumps at the `/opt/database/backups/` root are pre-compose leftovers — ignore for health, flag for cleanup.
|
||||
|
||||
### 7. Disk and mount
|
||||
|
||||
```bash
|
||||
ssh -F /dev/null -o BatchMode=yes windy@192.168.55.15 'df -h /srv/pgdata /'
|
||||
ssh -F /dev/null -o BatchMode=yes windy@192.168.55.15 'grep -E "srv/pgdata" /etc/fstab'
|
||||
```
|
||||
|
||||
**Expected**
|
||||
|
||||
- `/srv/pgdata` = `/dev/sdb1` 32G (verified: 88M used / 30G avail) and `/` with comfortable headroom.
|
||||
- fstab: `UUID=c9e12e79-1f66-404c-ab7f-b8809be81d86 /srv/pgdata ext4 defaults,noatime 0 2`.
|
||||
|
||||
**Stop** if either filesystem is near full (define threshold before acting) or the fstab entry is missing/changed.
|
||||
|
||||
### 8. Logs
|
||||
|
||||
```bash
|
||||
ssh -F /dev/null -o BatchMode=yes windy@192.168.55.15 'cd /opt/database && docker compose logs --since 24h timescaledb 2>&1 | grep -E "ERROR|FATAL" | tail -10'
|
||||
ssh -F /dev/null -o BatchMode=yes windy@192.168.55.15 'cd /opt/database && docker compose logs --since 24h pg-backup 2>&1 | tail -5'
|
||||
```
|
||||
|
||||
**Expected**
|
||||
|
||||
- timescaledb: no new `ERROR`/`FATAL`. Known benign history: an old `relation "hass.states" does not exist` from a wrong-schema probe and `compression_enabled` column errors from an outdated query — neither recurs with the commands above.
|
||||
- pg-backup: recent successful run (`SQL backup created successfully` for each database, no restore/cleanup errors).
|
||||
|
||||
**Stop** if repeated `ERROR`/`FATAL` appear or a backup run failed.
|
||||
@@ -0,0 +1,181 @@
|
||||
# Runbook: pgdb database restore (pg_restore, custom format)
|
||||
|
||||
## Purpose
|
||||
|
||||
Restore one pgdb database (`hass`, `scribe`, or `postgres`) from a pg-backup custom-format dump via `pg_restore`. The dump → restore path was exercised on 2026-08-29 (hass restore test, `states` 10014 rows); this runbook makes it repeatable and safe.
|
||||
|
||||
## Scope
|
||||
|
||||
- Applicable: single-database restore on [pgdb](../hosts/pgdb.md) (`192.168.55.15`) from `/opt/database/backups/{daily,weekly,monthly}/*.dump`.
|
||||
- Not applicable: full-stack/VM recovery, HAOS-side changes, secret rotation, or restoring to a different host (adjust `-h` and auth first).
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owner: personal ops (Windy)
|
||||
- Last reviewed: 2026-08-29
|
||||
- Related systems: pgdb (`/opt/database`), HA `192.168.55.11` (hass/scribe clients), [pgdb-health](pgdb-health.md)
|
||||
|
||||
## Preconditions
|
||||
|
||||
- Health baseline recorded ([pgdb-health](pgdb-health.md)): stack healthy, current backup is from today and passes `pg_restore -l`.
|
||||
- A valid dump exists for the target database (see Step 1).
|
||||
- Explicit user approval obtained for the chosen restore mode (see `## Approval gates`) and recorded in the Linear `vps` project.
|
||||
- Disk headroom on `/srv/pgdata` for a second copy of the database (temp-DB mode).
|
||||
|
||||
## Inputs
|
||||
|
||||
| Input | Source | Required | Validation |
|
||||
|---|---:|---|---|
|
||||
| Target database (`hass`/`scribe`/`postgres`) | [hosts/pgdb.md](../hosts/pgdb.md) | yes | exists in `pg_database` |
|
||||
| Dump path (inside pg-backup, `/backups/...`) | `ls /opt/database/backups/daily/` | yes | `pg_restore -l` lists TOC |
|
||||
| Restore mode (temp-DB swap recommended / in-place `--clean`) | operator + user | yes | approval record |
|
||||
| Downtime window | user | yes (for in-place) | user-confirmed |
|
||||
|
||||
## Safety
|
||||
|
||||
### Non-negotiable rules
|
||||
|
||||
- Never delete the existing configuration or the last known-good dump as the first recovery action.
|
||||
- Prefer read-only diagnosis before any mutation; the restore itself is the only mutation.
|
||||
- Never print passwords — use `PGPASSWORD="$POSTGRES_PASSWORD"` inside the pg-backup container.
|
||||
- If actual state conflicts with this runbook (e.g. dump corrupt, DB missing), `STOP`.
|
||||
- Do not invent missing parameters; do not bypass failed checks or approval.
|
||||
|
||||
### Stop conditions
|
||||
|
||||
- Dump fails `pg_restore -l` (corrupt/truncated) — do not restore from it.
|
||||
- Target DB contains data newer than the dump and the operator did not accept the data-loss window.
|
||||
- Disk space insufficient for the temp copy.
|
||||
- Approval missing or stale (recorded in Linear).
|
||||
- Post-restore verification fails — do not declare success.
|
||||
|
||||
### Approval gates
|
||||
|
||||
| Action | Risk | Explicit approval | Recorded at |
|
||||
|---|---|---|---|
|
||||
| Create temp DB + `pg_restore` into it (no data loss) | Low | No (executor can run after plan review) | Linear vps issue |
|
||||
| Drop/rename the live target DB (temp-DB swap) | High | Yes — user confirmation | Linear vps issue |
|
||||
| In-place restore with `--clean --if-exists` | High | Yes — user confirmation | Linear vps issue |
|
||||
|
||||
## Procedure
|
||||
|
||||
### Step 1 — Diagnose
|
||||
|
||||
**Action**
|
||||
|
||||
```bash
|
||||
ssh -F /dev/null -o BatchMode=yes windy@192.168.55.15 'ls -l --time-style=long-iso /opt/database/backups/daily/'
|
||||
ssh -F /dev/null -o BatchMode=yes windy@192.168.55.15 'cd /opt/database && docker compose exec -T pg-backup sh -c "PGPASSWORD=\"\$POSTGRES_PASSWORD\" pg_restore -h timescaledb -U postgres -l /backups/daily/<db>-latest.dump" | head -6'
|
||||
ssh -F /dev/null -o BatchMode=yes windy@192.168.55.15 'cd /opt/database && docker compose exec -T timescaledb psql -U postgres -Atc "select datname, pg_size_pretty(pg_database_size(datname)) from pg_database where datistemplate=false order by 1;"'
|
||||
ssh -F /dev/null -o BatchMode=yes windy@192.168.55.15 'df -h /srv/pgdata'
|
||||
```
|
||||
|
||||
**Expected**
|
||||
|
||||
- Dump exists for today and is custom format (`file -L` → `PostgreSQL custom database dump`).
|
||||
- `pg_restore -l` lists the archive TOC (dbname matches the target) without error.
|
||||
- Target DB exists; disk has headroom for a second copy.
|
||||
|
||||
**Decision**
|
||||
|
||||
- Dump valid + DB present → Step 2.
|
||||
- Dump invalid or target missing → `STOP`; record evidence, do not restore.
|
||||
|
||||
### Step 2 — Approve
|
||||
|
||||
**Action** — Present the plan: target DB, dump (path + creation time), restore mode (temp-DB swap recommended), estimated write downtime (none for temp mode; brief for in-place), and rollback plan. Obtain explicit user approval and record it in the Linear `vps` project issue for this work.
|
||||
|
||||
**Expected** — User approval recorded with the chosen mode.
|
||||
|
||||
**Decision**
|
||||
|
||||
- Approved → Step 3.
|
||||
- Not approved / mode changed → `STOP` and report; re-plan if needed.
|
||||
|
||||
### Step 3 — Restore into a temp database (recommended, no data loss)
|
||||
|
||||
**Action**
|
||||
|
||||
```bash
|
||||
ssh -F /dev/null -o BatchMode=yes windy@192.168.55.15 'cd /opt/database
|
||||
docker compose exec -T timescaledb psql -U postgres -c "CREATE DATABASE <db>_restore OWNER <owner>;"
|
||||
docker compose exec -T pg-backup sh -c "PGPASSWORD=\"\$POSTGRES_PASSWORD\" pg_restore -h timescaledb -U postgres -d <db>_restore /backups/daily/<db>-latest.dump"
|
||||
docker compose exec -T timescaledb psql -U postgres -Atc "select count(*) from pg_database where datname='"'"'<db>_restore'"'"';"'
|
||||
```
|
||||
|
||||
`<owner>` is the database owner from [hosts/pgdb.md](../hosts/pgdb.md) (`hass` → `hass`, `scribe`/`postgres` → `postgres`). Run inside the pg-backup container so the password stays server-side; `-h timescaledb` routes over the compose network.
|
||||
|
||||
**Expected** — `CREATE DATABASE` succeeds; `pg_restore` exits 0 with no error lines; the temp DB exists.
|
||||
|
||||
**Decision**
|
||||
|
||||
- Restore OK → Step 4.
|
||||
- Restore errors → diagnose (dump integrity, permissions), fix, or `STOP`; the live DB is untouched in this mode.
|
||||
|
||||
**Verification**
|
||||
|
||||
```bash
|
||||
ssh -F /dev/null -o BatchMode=yes windy@192.168.55.15 'cd /opt/database && docker compose exec -T timescaledb psql -U postgres -d <db>_restore -Atc "select count(*) from <key_table>;"'
|
||||
```
|
||||
|
||||
Compare against the pre-restore baseline (e.g. `states` ≈ dump's row count). Row counts match → proceed to swap; mismatch → `STOP` and inspect.
|
||||
|
||||
### Step 4 — Swap temp DB into place (approval-gated)
|
||||
|
||||
**Action** — After user approval: drop the live DB and rename the temp (or `pg_dump` the live DB as a safety copy first).
|
||||
|
||||
```bash
|
||||
ssh -F /dev/null -o BatchMode=yes windy@192.168.55.15 'cd /opt/database
|
||||
docker compose exec -T timescaledb psql -U postgres -c "DROP DATABASE <db>;"
|
||||
docker compose exec -T timescaledb psql -U postgres -c "ALTER DATABASE <db>_restore RENAME TO <db>;"'
|
||||
```
|
||||
|
||||
**Expected** — Rename succeeds; `pg_database` shows `<db>` with the restored size.
|
||||
|
||||
**Decision**
|
||||
|
||||
- Swap OK → Step 5.
|
||||
- Swap fails → `STOP`; see Rollback.
|
||||
|
||||
**Verification**
|
||||
|
||||
- `select count(*)` on key tables matches the dump.
|
||||
- HA sessions re-establish: `pg_stat_activity` shows `hass|hass|192.168.55.11` on `<db>` (scribe writes resume).
|
||||
- pgweb bookmarks show restored data (read-only browse).
|
||||
|
||||
**Rollback**
|
||||
|
||||
- Trigger: verification fails or swap is wrong.
|
||||
- Action: restore again from the dump taken before the operation; if the live DB was dropped, recreate `<db>` and restore the pre-change dump (`--clean` not needed on an empty DB).
|
||||
- Verify: row counts + HA sessions match pre-change baseline; record in Linear.
|
||||
|
||||
### In-place variant (only with explicit approval)
|
||||
|
||||
```bash
|
||||
ssh -F /dev/null -o BatchMode=yes windy@192.168.55.15 'cd /opt/database && docker compose exec -T pg-backup sh -c "PGPASSWORD=\"\$POSTGRES_PASSWORD\" pg_restore -h timescaledb -U postgres --clean --if-exists -d <db> /backups/daily/<db>-latest.dump"'
|
||||
```
|
||||
|
||||
Destructive to current data — requires the High-risk approval gate and a fresh dump of the current state taken immediately before. Follow Steps 4–5 verification afterwards.
|
||||
|
||||
## Final Verification
|
||||
|
||||
Success requires all of:
|
||||
|
||||
- Target DB row counts match the dump (`states`, `states_raw`, `events` etc. sane).
|
||||
- HA (`192.168.55.11`) reconnects and writes resume (write-activity sample grows).
|
||||
- [pgdb-health](pgdb-health.md) passes (containers, backups still current, logs clean).
|
||||
- Outcome recorded in [hosts/pgdb.md](../hosts/pgdb.md) and the Linear `vps` project.
|
||||
|
||||
## Failure Handling
|
||||
|
||||
1. Stop further changes; do not guess.
|
||||
2. Collect: dump path + `pg_restore`/`psql` output, DB sizes, log tail.
|
||||
3. Record completed steps, actual vs expected, and whether rollback ran.
|
||||
4. Escalate to the user with evidence; continue only with explicit direction.
|
||||
|
||||
## References
|
||||
|
||||
- [hosts/pgdb.md](../hosts/pgdb.md) — roles, owners, backup layout, verification history
|
||||
- [pgdb-health](pgdb-health.md) — pre/post health baseline
|
||||
- [pgdb-update](pgdb-update.md) — image/compose upgrades
|
||||
- Linear `vps`: W1N-226 (first check), W1N-227 (compose-化 + restore test), W1N-228 (this runbook)
|
||||
@@ -0,0 +1,77 @@
|
||||
# Runbook: pgdb update (image / compose upgrade)
|
||||
|
||||
## Purpose
|
||||
|
||||
Upgrade the pgdb compose stack (`timescaledb` + `pgweb` + `pg-backup`) to newer images with gated commands and verification. Data lives on the `/srv/pgdata` volume and survives container recreation.
|
||||
|
||||
## Scope
|
||||
|
||||
- Applicable: [pgdb](../hosts/pgdb.md) (`192.168.55.15`), `/opt/database/docker-compose.yml` (source: `compose/pgdb/compose.yml` in this repo).
|
||||
- Not applicable: config/secret changes, `.env` rotation, DB migration, or pgweb bookmark changes.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owner: personal ops (Windy)
|
||||
- Last reviewed: 2026-08-29
|
||||
- Related systems: pgdb (`/opt/database`), HA `192.168.55.11`, [pgdb-health](pgdb-health.md)
|
||||
|
||||
## Approval gates
|
||||
|
||||
| Action | Risk | Explicit approval |
|
||||
|---|---|---|
|
||||
| `docker compose pull` + `up -d` (recreates changed containers; timescaledb recreate = brief write interruption for HA) | Medium | Yes — user confirmation required |
|
||||
|
||||
## Safety Rules
|
||||
|
||||
- Never delete an existing configuration as the first recovery action.
|
||||
- Prefer read-only diagnosis before mutation.
|
||||
- After every mutation, verify the expected state.
|
||||
- If actual state conflicts with this runbook, `STOP`.
|
||||
- Do not invent missing parameters (e.g. new env vars required by a major image bump).
|
||||
- Do not bypass failed tests.
|
||||
- Destructive actions require explicit approval.
|
||||
|
||||
## Before
|
||||
|
||||
1. Record a health baseline: run [pgdb-health](pgdb-health.md). Note current versions (`select version()` → PG 18.x, TimescaleDB 2.29.x) and image digests:
|
||||
```bash
|
||||
ssh -F /dev/null -o BatchMode=yes windy@192.168.55.15 'cd /opt/database && docker inspect --format "{{.Name}} {{.Config.Image}} {{.Image}}" timescaledb pgweb pg-backup'
|
||||
```
|
||||
2. Confirm parity between the repo source and the host compose file:
|
||||
```bash
|
||||
diff compose/pgdb/compose.yml <(ssh -F /dev/null -o BatchMode=yes windy@192.168.55.15 'cat /opt/database/docker-compose.yml')
|
||||
```
|
||||
3. Check disk: `df -h /srv/pgdata /` (new images need pull space on `/`).
|
||||
4. Confirm the intended change with the user (approval gate above).
|
||||
|
||||
## Update
|
||||
|
||||
```bash
|
||||
ssh -F /dev/null -o BatchMode=yes windy@192.168.55.15 'cd /opt/database && docker compose pull'
|
||||
ssh -F /dev/null -o BatchMode=yes windy@192.168.55.15 'cd /opt/database && docker compose config -q && echo CONFIG_OK'
|
||||
ssh -F /dev/null -o BatchMode=yes windy@192.168.55.15 'cd /opt/database && docker compose up -d'
|
||||
```
|
||||
|
||||
- `compose config -q` fails fast on invalid compose syntax — `STOP` there, do not `up`.
|
||||
- `up -d` recreates only containers whose image/config changed. Recreating `pg-backup` triggers an immediate `BACKUP_ON_START` dump (expected, keep it).
|
||||
- HA (`192.168.55.11`) recorder reconnects automatically after a brief write interruption; do not restart HA unless it fails to recover.
|
||||
|
||||
## After
|
||||
|
||||
1. Re-run [pgdb-health](pgdb-health.md). Confirm:
|
||||
- Containers `Up (healthy)`; ports still `192.168.55.15:5432` / `:8081`.
|
||||
- `select version()` still PG 18.x; TimescaleDB extension version still the pinned 2.29.x line (image `latest-pg18` should not jump major/minor unexpectedly — if it does, `STOP` and review before accepting).
|
||||
- Write activity resumes (`states_raw` grows); HA sessions from `192.168.55.11` present.
|
||||
- A fresh backup exists (today) and passes `pg_restore -l`.
|
||||
2. Update **Verified** on [hosts/pgdb.md](../hosts/pgdb.md) with date, new versions, and outcome.
|
||||
|
||||
## Rollback
|
||||
|
||||
- Trigger: health check fails after the update, or the new image misbehaves.
|
||||
- Action (data-safe — `/srv/pgdata` volume is untouched):
|
||||
1. Pin the previous image tags in `/opt/database/docker-compose.yml` (from the `docker inspect` baseline above) and sync the repo source if changed:
|
||||
```bash
|
||||
ssh -F /dev/null -o BatchMode=yes windy@192.168.55.15 'cd /opt/database && docker compose up -d --force-recreate'
|
||||
```
|
||||
2. Last resort: the pre-compose launcher `/opt/database/run` (legacy `docker run` command) with the same `/srv/pgdata` mount and the old anonymous volume as fallback — see [hosts/pgdb.md](../hosts/pgdb.md).
|
||||
- Verify: [pgdb-health](pgdb-health.md) passes and versions match the pre-update baseline.
|
||||
@@ -0,0 +1,79 @@
|
||||
# Runbook: release a reviewed change to production
|
||||
|
||||
## Purpose
|
||||
|
||||
Apply a reviewed configuration change to production hosts in a controlled,
|
||||
verifiable, rollback-safe way.
|
||||
|
||||
## Scope
|
||||
|
||||
- Applicable: reviewed changes to Compose services, DNS, firewall, or Ansible-managed config on production hosts.
|
||||
- Not applicable: destructive migrations (`vaultwarden-sqlite-to-postgres.md`), SSH access-policy changes (`AGENTS.md` §SSH access safety), or network changes needing a change ticket (`network-change.md`).
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owner: personal ops (Windy)
|
||||
- Last reviewed: 2026-08-17
|
||||
- Related systems: all production hosts
|
||||
|
||||
## Preconditions
|
||||
|
||||
- The change is reviewed and its intent matches a Linear issue / change record.
|
||||
- Read-only health pass (or `maintenance-preview`) has been run and is green.
|
||||
- A rollback point exists (backup, prior config, or known-good git/playbook state).
|
||||
|
||||
## Safety
|
||||
|
||||
- Never release without a verification and a rollback point.
|
||||
- Never bypass gated confirmation variables.
|
||||
- One host at a time; re-run health after each.
|
||||
|
||||
## Procedure
|
||||
|
||||
### Step 1 — Pre-flight
|
||||
|
||||
**Action** — run the read-only health report for the target host(s).
|
||||
|
||||
```bash
|
||||
cd ansible && ansible-playbook playbooks/health-report.yml --limit <host>
|
||||
```
|
||||
|
||||
**Expected** — green baseline.
|
||||
|
||||
**Decision** — green → Step 2; warning/critical → fix first (`fix-ci.md`), `STOP` the release.
|
||||
|
||||
### Step 2 — Approve
|
||||
|
||||
**Action** — confirm the change is approved in the issue/change record; confirm the window.
|
||||
|
||||
**Decision** — approval recorded → Step 3; missing → `STOP`.
|
||||
|
||||
### Step 3 — Apply
|
||||
|
||||
**Action** — run the gated playbook for the change, with its confirmation variable.
|
||||
|
||||
```bash
|
||||
# example: reviewed Compose reconcile
|
||||
cd ansible && ansible-playbook playbooks/compose-reconcile.yml --limit <host> \
|
||||
-e '{"service_reconcile_confirm": true, "service_reconcile_targets": ["<svc>"]}'
|
||||
```
|
||||
|
||||
**Expected** — playbook completes; service reports the new state.
|
||||
|
||||
**Verification** — re-run the health report and confirm green; confirm the specific change took effect (version, config, endpoint).
|
||||
|
||||
**Rollback** — on verification failure, revert the change and re-verify (see `rollback.md`).
|
||||
|
||||
## Final Verification
|
||||
|
||||
- Health report green post-release.
|
||||
- The change is visible and the issue/change record is updated.
|
||||
|
||||
## Failure Handling
|
||||
|
||||
On failure: stop further changes, collect the playbook output and health report, and route to `rollback.md` if the change was applied.
|
||||
|
||||
## References
|
||||
|
||||
- [`ansible-operations.md`](ansible-operations.md)
|
||||
- [`rollback.md`](rollback.md)
|
||||
@@ -0,0 +1,62 @@
|
||||
# Runbook: roll back a change
|
||||
|
||||
## Purpose
|
||||
|
||||
Restore a known-good state after a change failed verification or caused a
|
||||
regression.
|
||||
|
||||
## Scope
|
||||
|
||||
- Applicable: Compose services, DNS records, firewall rules, and Ansible-managed config that have a rollback point.
|
||||
- Not applicable: destructive migrations without a backup (route to the owning runbook, e.g. `vaultwarden-sqlite-to-postgres.md` §Rollback).
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owner: personal ops (Windy)
|
||||
- Last reviewed: 2026-08-17
|
||||
- Related systems: all production hosts
|
||||
|
||||
## Preconditions
|
||||
|
||||
- A rollback point exists: backup, prior config, or known-good commit/playbook state.
|
||||
- The failing change and its symptoms are recorded.
|
||||
|
||||
## Safety
|
||||
|
||||
- Confirm the rollback target before acting; do not roll back unrelated changes.
|
||||
- Verify after rollback; a rollback that is not verified is not complete.
|
||||
|
||||
## Procedure
|
||||
|
||||
### Step 1 — Confirm the target
|
||||
|
||||
**Action** — record the change to revert (playbook run, config edit, DNS change) and its intended prior state.
|
||||
|
||||
**Expected** — the exact prior state is known and reachable.
|
||||
|
||||
**Decision** — prior state known → Step 2; unknown → `STOP` and escalate.
|
||||
|
||||
### Step 2 — Revert
|
||||
|
||||
**Action** — apply the documented reversal for the change type:
|
||||
|
||||
- Compose config: re-apply the prior config and reconcile (or `git revert` the change then reconcile).
|
||||
- DNS: restore the previous record value on PowerDNS.
|
||||
- Firewall: re-apply the prior allowlist via the gated playbook.
|
||||
- General repo change: `git revert <sha>`.
|
||||
|
||||
**Expected** — the prior configuration is in place.
|
||||
|
||||
**Verification** — re-run the relevant health report / endpoint check and confirm green.
|
||||
|
||||
**Rollback** — if the rollback itself fails, `STOP`; keep the original management session open and escalate.
|
||||
|
||||
## Final Verification
|
||||
|
||||
- Health report green after rollback.
|
||||
- The incident and rollback are recorded in the Linear `vps` issue.
|
||||
|
||||
## References
|
||||
|
||||
- [`release.md`](release.md)
|
||||
- [`ansible-operations.md`](ansible-operations.md)
|
||||
@@ -1,5 +1,20 @@
|
||||
# RustDesk server health (hk2)
|
||||
|
||||
## Purpose
|
||||
|
||||
Read-only health check of the `/opt/rustdesk` server stack.
|
||||
|
||||
## Scope
|
||||
|
||||
- Applicable: [hk2.chans.xyz](../hosts/hk2.chans.xyz.md), `/opt/rustdesk`.
|
||||
- Read-only: does not change RustDesk configuration or service state.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owner: personal ops (Windy)
|
||||
- Last reviewed: 2026-08-17
|
||||
- Related systems: hk2.chans.xyz (`/opt/rustdesk`)
|
||||
|
||||
Read-only checks for the `/opt/rustdesk` stack on **hk2.chans.xyz**.
|
||||
|
||||
Facts: [hosts/hk2.chans.xyz.md](../hosts/hk2.chans.xyz.md)
|
||||
@@ -66,6 +81,11 @@ The sanitized Ansible health profile is `rustdesk` (deployed via
|
||||
locally through `vps-healthcheck.timer`, writes a sanitized JSON result to
|
||||
`/var/lib/vps-health/latest.json`, and never exposes secrets.
|
||||
|
||||
## Safety
|
||||
|
||||
- Read-only: never mutate configuration or service state during this check.
|
||||
- If live state conflicts with an expected value, `STOP` and report.
|
||||
|
||||
## After config changes
|
||||
|
||||
Reviewed changes to `/opt/rustdesk/compose.yml` (relay address, image pin, port
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# Runbook: Vaultwarden health (us2)
|
||||
|
||||
## Purpose
|
||||
|
||||
Read-only health check of the Vaultwarden stack (Postgres backend) on us2.
|
||||
|
||||
## Scope
|
||||
|
||||
- Applicable: [us2.wsvc.info](../hosts/us2.wsvc.info.md), `/opt/vaultwarden`.
|
||||
- Read-only: does not change Vaultwarden, Traefik, SMTP, or secrets.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owner: personal ops (Windy)
|
||||
- Last reviewed: 2026-08-17
|
||||
- Related systems: us2.wsvc.info (`/opt/vaultwarden`)
|
||||
|
||||
Target: [us2.wsvc.info](../hosts/us2.wsvc.info.md)
|
||||
Path: `/opt/vaultwarden`
|
||||
URL: https://auth.wsvc.info/
|
||||
@@ -96,6 +111,12 @@ Expect `AUTH_OK`. `535` usually means stale password in `config.json` (see step
|
||||
The sanitized Ansible health profile is `vaultwarden` (`ansible/playbooks/healthchecks.yml`). It runs locally through `vps-healthcheck.timer`, writes a sanitized JSON result to `/var/lib/vps-health/latest.json`, and uses only server-side credentials for the SMTP AUTH probe. It does not modify Vaultwarden, Traefik, SMTP, or secrets.
|
||||
|
||||
|
||||
## Safety
|
||||
|
||||
- Read-only: never mutate configuration, containers, or secrets during this check.
|
||||
- Never print passwords — compare lengths/hashes only.
|
||||
- If live state conflicts with an expected value below, `STOP` and report.
|
||||
|
||||
## Pass criteria
|
||||
|
||||
- Healthy compose; `DATABASE_URL` points at `pg`
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
# Runbook: Vaultwarden SQLite → Postgres (us2)
|
||||
|
||||
## Purpose
|
||||
|
||||
Replay checklist for the exceptional SQLite→Postgres migration on us2.
|
||||
|
||||
## Scope
|
||||
|
||||
- Applicable: [us2.wsvc.info](../hosts/us2.wsvc.info.md), `/opt/vaultwarden`.
|
||||
- Not applicable: routine reconcile — this is destructive and intentionally not automated.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Owner: personal ops (Windy)
|
||||
- Last reviewed: 2026-08-17
|
||||
- Related systems: us2.wsvc.info (`/opt/vaultwarden`)
|
||||
|
||||
## Safety
|
||||
|
||||
- Destructive: requires explicit approval; confirm backups exist before starting.
|
||||
- Do not commit `.env` or password-bearing files to git.
|
||||
- If any step diverges from this checklist, `STOP` and report.
|
||||
|
||||
Target: [us2.wsvc.info](../hosts/us2.wsvc.info.md)
|
||||
Upstream: [Using the PostgreSQL Backend](https://github.com/dani-garcia/vaultwarden/wiki/Using-the-PostgreSQL-Backend) · [docs/vaultwarden-upstream.md](../docs/vaultwarden-upstream.md)
|
||||
|
||||
|
||||
Executable
+206
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env bash
|
||||
# validate-repo.sh — repo-wide validation for the VPS ops hub.
|
||||
#
|
||||
# Run from anywhere; must be executed from a git worktree of this repo.
|
||||
# Exit 0 = pass (warnings allowed), non-zero = violations found.
|
||||
#
|
||||
# Checks:
|
||||
# 1. Secret scan — tracked/working-tree files must not look like secrets.
|
||||
# 2. Inventory — ansible inventory (display_name) ↔ inventory/hosts.md
|
||||
# ↔ hosts/<name>.md cross-check (SKIP if no ansible CLI).
|
||||
# 3. Links — relative markdown links must resolve.
|
||||
# 4. Runbook spec — RUNBOOKS.md: Last reviewed + STOP on every runbook;
|
||||
# six-field markers on procedure-type change runbooks;
|
||||
# gate markers on gated command references.
|
||||
# 5. Ansible — inventory parse + per-playbook --syntax-check
|
||||
# (SKIP if no ansible CLI).
|
||||
#
|
||||
# NOTE: ansible temp/home are pinned inside the repo (./.ansible) so the
|
||||
# script works in sandboxed/CI-like environments without touching ~/.ansible.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
FAIL=0
|
||||
WARN=0
|
||||
|
||||
say() { printf '%s\n' "$*"; }
|
||||
ok() { printf ' [ok] %s\n' "$*"; }
|
||||
skip() { printf ' [skip] %s\n' "$*"; }
|
||||
warn() { printf ' [warn] %s\n' "$*"; WARN=$((WARN+1)); }
|
||||
fail() { printf ' [FAIL] %s\n' "$*"; FAIL=$((FAIL+1)); }
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
say "== 1. Secret scan (tracked + untracked non-ignored files) =="
|
||||
SECRET_PATH_RE='(^|/)(\.env$|\.env\.[^.].*|mailcow\.conf|\.smtp-credentials|\.admin-token)$|\.(pem|key)$|(^|/)id_(rsa|ed25519|ecdsa)$'
|
||||
while IFS= read -r f; do
|
||||
# .env.example is the sanctioned non-secret placeholder (see .gitignore).
|
||||
if printf '%s' "$f" | grep -qE "$SECRET_PATH_RE" && ! printf '%s' "$f" | grep -qE '(^|/)\.env\.example$'; then
|
||||
fail "secret-like path present: $f"
|
||||
fi
|
||||
done < <(git ls-files -co --exclude-standard)
|
||||
if git grep -qIl '-----BEGIN [A-Z ]*PRIVATE KEY-----' 2>/dev/null; then
|
||||
fail "private key material found in tracked files"
|
||||
else
|
||||
ok "no secret-like paths / private-key material"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
say "== 2. Inventory cross-check (ansible display_name ↔ hosts.md ↔ hosts/) =="
|
||||
if command -v ansible-inventory >/dev/null 2>&1; then
|
||||
export ANSIBLE_LOCAL_TEMP="$REPO_ROOT/.ansible/tmp"
|
||||
export ANSIBLE_HOME="$REPO_ROOT/.ansible"
|
||||
mkdir -p "$REPO_ROOT/.ansible/tmp"
|
||||
INV_JSON="$(mktemp)"
|
||||
if (cd ansible && ansible-inventory --list > "$INV_JSON" 2>/dev/null); then
|
||||
if python3 - "$INV_JSON" <<'PYEOF'
|
||||
import json, sys, re, pathlib
|
||||
inv = json.load(open(sys.argv[1]))
|
||||
hostvars = inv.get('_meta', {}).get('hostvars', {})
|
||||
root = pathlib.Path('.')
|
||||
hosts_md = (root / 'inventory' / 'hosts.md').read_text(encoding='utf-8')
|
||||
errors = []
|
||||
for key, v in sorted(hostvars.items()):
|
||||
dn = v.get('display_name')
|
||||
if not dn:
|
||||
errors.append(f"inventory host {key!r} has no display_name")
|
||||
continue
|
||||
if not (root / 'hosts' / f'{dn}.md').exists():
|
||||
errors.append(f"hosts/{dn}.md missing for inventory host {key}")
|
||||
if dn not in hosts_md:
|
||||
errors.append(f"display_name {dn!r} (inventory key {key}) not found in inventory/hosts.md")
|
||||
display_names = {v.get('display_name') for v in hostvars.values() if v.get('display_name')}
|
||||
# Reverse: rows marked '✓ (key)' in hosts.md must exist in the ansible inventory.
|
||||
for line in hosts_md.splitlines():
|
||||
m = re.match(r'^\|\s*\*{0,2}([^*|]+?)\*{0,2}\s*\|\s*[^|]*?\s*\|\s*[^|]*?\s*\|\s*[^|]*?\s*\|\s*✓\s*\(([^)]+)\)', line)
|
||||
if m:
|
||||
host, invkey = m.group(1).strip(), m.group(2).strip()
|
||||
if invkey not in hostvars:
|
||||
errors.append(f"hosts.md row {host!r} marked Ansible '✓' but inventory key {invkey!r} missing")
|
||||
for e in errors:
|
||||
print(e)
|
||||
sys.exit(1 if errors else 0)
|
||||
PYEOF
|
||||
then ok "inventory cross-check passed"
|
||||
else fail "inventory cross-check violations (see above)"
|
||||
fi
|
||||
rm -f "$INV_JSON"
|
||||
else
|
||||
fail "ansible-inventory --list failed"
|
||||
fi
|
||||
else
|
||||
skip "ansible-inventory not available"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
say "== 3. Relative markdown link check =="
|
||||
if python3 - <<'PYEOF'
|
||||
import pathlib, re, subprocess, sys
|
||||
root = pathlib.Path('.')
|
||||
# Only repo content: tracked + untracked non-ignored files (excludes
|
||||
# gitignored tooling dirs like .agents/ and .claude/).
|
||||
tracked = subprocess.check_output(['git', 'ls-files', '--', '*.md'], text=True).split()
|
||||
untracked = subprocess.check_output(
|
||||
['git', 'ls-files', '-o', '--exclude-standard', '--', '*.md'], text=True).split()
|
||||
files = sorted(set(pathlib.Path(f) for f in tracked + untracked))
|
||||
link_re = re.compile(r'\[[^\]]*\]\(([^)]+)\)')
|
||||
errors = []
|
||||
for f in files:
|
||||
# `git ls-files` includes paths deleted in the working tree. They cannot
|
||||
# contain links and must not make validation fail before their deletion is
|
||||
# staged.
|
||||
if not f.exists():
|
||||
continue
|
||||
try:
|
||||
text = f.read_text(encoding='utf-8')
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
for m in link_re.finditer(text):
|
||||
target = m.group(1).strip()
|
||||
if not target or target.startswith(('#', 'http://', 'https://', 'mailto:', 'tel:', '{{')):
|
||||
continue
|
||||
# strip optional anchor and surrounding quotes
|
||||
target = target.split('#', 1)[0].strip().strip('"\'')
|
||||
if not target:
|
||||
continue
|
||||
resolved = (f.parent / target)
|
||||
if not resolved.exists():
|
||||
errors.append(f"{f}: broken link -> {target}")
|
||||
for e in errors:
|
||||
print(e)
|
||||
sys.exit(1 if errors else 0)
|
||||
PYEOF
|
||||
then ok "all relative markdown links resolve"
|
||||
else fail "broken markdown links (see above)"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
say "== 4. Runbook spec compliance (RUNBOOKS.md) =="
|
||||
PROCEDURE_RUNBOOKS="fix-ci.md issue-to-merge.md network-change.md network-recovery.md pgdb-restore.md release.md rollback.md"
|
||||
GATED_REF_RUNBOOKS="mailcow-update.md ansible-operations.md home-assistant-maintenance.md matrix-e2ee-update.md pgdb-update.md vaultwarden-sqlite-to-postgres.md"
|
||||
RUNBOOK_INDEX="runbooks/README.md"
|
||||
for f in runbooks/*.md; do
|
||||
base="$(basename "$f")"
|
||||
case "$base" in
|
||||
README.md|_template.md) continue ;;
|
||||
esac
|
||||
if ! grep -q "Last reviewed" "$f"; then fail "$f: missing 'Last reviewed'"; fi
|
||||
if ! grep -qE "STOP" "$f"; then fail "$f: missing explicit STOP condition"; fi
|
||||
if ! grep -qE '^## Purpose$' "$f"; then fail "$f: missing '## Purpose'"; fi
|
||||
if ! grep -qE '^## Scope$' "$f"; then fail "$f: missing '## Scope'"; fi
|
||||
index_matches="$(grep -cF "]($base)" "$RUNBOOK_INDEX" || true)"
|
||||
if [ "$index_matches" -ne 1 ]; then
|
||||
fail "$f: expected exactly one entry in $RUNBOOK_INDEX (found $index_matches)"
|
||||
fi
|
||||
if grep -F "]($base)" "$RUNBOOK_INDEX" | grep -qF "| change"; then
|
||||
case " $PROCEDURE_RUNBOOKS $GATED_REF_RUNBOOKS " in
|
||||
*" $base "*) ;;
|
||||
*) fail "$f: change runbook is not classified as procedure or gated-command" ;;
|
||||
esac
|
||||
fi
|
||||
done
|
||||
for base in $PROCEDURE_RUNBOOKS; do
|
||||
f="runbooks/$base"
|
||||
[ -f "$f" ] || { fail "$f: expected procedure-type runbook missing"; continue; }
|
||||
for marker in '**Action**' '**Expected**' '**Decision**' '**Verification**'; do
|
||||
if ! grep -qF "$marker" "$f"; then fail "$f: procedure-type runbook missing $marker"; fi
|
||||
done
|
||||
done
|
||||
for base in $GATED_REF_RUNBOOKS; do
|
||||
f="runbooks/$base"
|
||||
[ -f "$f" ] || { fail "$f: expected gated-command runbook missing"; continue; }
|
||||
if ! grep -qE "(Approval gates|confirm|--yes|explicit approval|confirmation)" "$f"; then
|
||||
fail "$f: gated-command runbook missing approval/confirmation gate"
|
||||
fi
|
||||
done
|
||||
if [ "$FAIL" -gt 0 ]; then :; else ok "runbook spec checks passed"; fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
say "== 5. Ansible inventory + playbook syntax =="
|
||||
if command -v ansible-playbook >/dev/null 2>&1; then
|
||||
export ANSIBLE_LOCAL_TEMP="$REPO_ROOT/.ansible/tmp"
|
||||
export ANSIBLE_HOME="$REPO_ROOT/.ansible"
|
||||
mkdir -p "$REPO_ROOT/.ansible/tmp"
|
||||
syntax_fail=0
|
||||
for p in ansible/playbooks/*.yml; do
|
||||
if ! (cd ansible && ansible-playbook --syntax-check "playbooks/$(basename "$p")" >/dev/null 2>&1); then
|
||||
fail "syntax-check failed: $p"
|
||||
syntax_fail=1
|
||||
fi
|
||||
done
|
||||
[ "$syntax_fail" -eq 0 ] && ok "all playbooks passed --syntax-check"
|
||||
else
|
||||
skip "ansible not available (syntax-check skipped)"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
say ""
|
||||
if [ "$FAIL" -gt 0 ]; then
|
||||
say "RESULT: FAIL ($FAIL violations, $WARN warnings)"
|
||||
exit 1
|
||||
else
|
||||
say "RESULT: PASS ($WARN warnings)"
|
||||
exit 0
|
||||
fi
|
||||
Reference in New Issue
Block a user