72602
Scope
This section is the single source of truth for 72602 cluster operations.
Topology
- Public ECS:
47.110.67.161 (2C4G, cn-hangzhou, zone cn-hangzhou-i) - Active ingress domain:
72602.space; legacy .72602.online routes are retired - ArgoCD host:
argocd.72602.space - k3s node:
72602-minipc (192.168.0.25, MiniPC N100 28G+1TB NVMe) - SSH reverse tunnel:
:10021 (main), :10022 (backup and Mailu loopback) - Web tunnel: WireGuard UDP
51820 between ECS and minipc - Ingress NodePort:
32080 (HTTP), 32443 (HTTPS) - Ingress class:
nginx - Ingress namespace:
basic-components - cert-manager issuer:
lets-encrypt - Storage class:
local-path (default, RWO) - OS: Ubuntu 26.04 LTS (minipc)
- k3s version: v1.34.6+k3s1 (installed via install.sh)
Traffic Path
Web:
Internet -> ECS HAProxy TCP passthrough -> WireGuard -> 72602-minipc ingress-nginx NodePort
Mail:
Internet -> ECS HAProxy TCP passthrough -> ECS loopback sshd forwards -> 72602-minipc Mailu front
ECS Port Forwarding
Web traffic crosses WireGuard; SSH and mail continue to use independent SSH
reverse tunnels. ECS side ports:
10021 -> minipc:22 (72602 main SSH)10022 -> minipc:22 (72602 backup SSH and Mailu loopback forwards)80 -> 10.77.0.2:32080 (HAProxy TCP passthrough over WireGuard)443 -> 10.77.0.2:32443 (HAProxy TCP passthrough over WireGuard)127.0.0.1:10225 -> minipc:25 (SMTP via SSH reverse tunnel on 10022)127.0.0.1:10465 -> minipc:465 (SMTPS via SSH reverse tunnel on 10022)127.0.0.1:10587 -> minipc:587 (submission via SSH reverse tunnel on 10022)127.0.0.1:10993 -> minipc:993 (IMAPS via SSH reverse tunnel on 10022)
ZJLAB NewAPI public route
The isolated ZJLAB NewAPI-compatible service is published at
https://newapi.zjlab.72602.space. Its relay, credentials, tunnel, and
rollback procedure are maintained in the private operations repository. The
existing MaaS route at https://llm.72602.space remains an independent
service.
ECS HAProxy listens on public IPv4 and IPv6 25, 465, 587, and 993, and
on public IPv4 80 and 443. Web is plain TCP passthrough without PROXY
protocol or TLS termination. Mail passes TCP with PROXY v2 to the four ECS
loopback backends. TLS remains terminated by ingress-nginx for Web and Mailu
for the implicit-TLS mail protocols.
DNS Setup
Active service records use 72602.space and point to 47.110.67.161:
| Host | Type | Value | Service |
|---|
argocd.72602.space | A | 47.110.67.161 | ArgoCD UI |
ops.docs.72602.space | A | 47.110.67.161 | Hugo Docs |
token.72602.space | A | 47.110.67.161 | AI API proxy |
port.72602.space | A | 47.110.67.161 | Homepage dashboard |
n8n.72602.space | A | 47.110.67.161 | N8N workflow |
webhook.n8n.72602.space | A | 47.110.67.161 | N8N webhook receiver |
ops.agent.72602.space | A | 47.110.67.161 | OpenCode operations agent |
grafana.72602.space | A | 47.110.67.161 | Grafana observability UI |
otlp.72602.space | A | 47.110.67.161 | OTLP ingest endpoint |
prometheus-write.72602.space | A | 47.110.67.161 | Prometheus remote-write endpoint |
uptime.72602.space | A | 47.110.67.161 | Uptime Kuma |
clash.72602.space | A | 47.110.67.161 | Clash/mihomo panel |
api.minio.72602.space | A | 47.110.67.161 | MinIO S3 API |
console.minio.72602.space | A | 47.110.67.161 | MinIO Console |
ACME CAA policy
The AliDNS zone 72602.space is delegated to dns15.hichina.com and
dns16.hichina.com. AliDNS reports DNSSEC OFF; the parent delegation has no
DS record, and both authoritative servers match the delegated nameservers. The
zone has one enabled apex CAA record:
| RecordId | RR | Type | Value | TTL | Status |
|---|
2084917630338801664 | @ | CAA | 0 issue "letsencrypt.org" | 600 | ENABLE |
This record was created after confirming that no enabled equivalent or
conflicting CAA existed. It is scoped to ACME issuance and did not change any
address, alias, delegation, DNSSEC, or unrelated record. Verify the record with
the AliDNS API, both authoritative servers, and public resolvers 1.1.1.1 and
8.8.8.8; each must return NOERROR and the exact value. Roll back only this
change with the official AliDNS SDK DeleteDomainRecord call for
2084917630338801664, then repeat the same resolver checks for an empty CAA
answer. Do not delete other records.
The Clash route is basic-components/clash-ui-ingress using class nginx,
ClusterIssuer lets-encrypt, and Secret clash.72602.space-tls. After the
CAA addition, cert-manager retained the failed Order and automatically retried
at its scheduled backoff time. The new Order became valid and the Certificate
became Ready. Keep failed Orders and Challenges until normal cert-manager
cleanup; do not repeatedly delete them.
The existing Prometheus receiver also accepts ZJLAB Kubernetes metrics through
a dedicated HTTPS write-only Ingress at prometheus-write.72602.space. The
Ingress exposes only the exact /api/v1/write path and requires a runtime
Basic Auth Secret; it does not expose Prometheus query, status, or admin APIs.
The TLS certificate is issued by lets-encrypt. ZJLAB sends metrics with the
external label cluster=zjlab, so the existing Grafana Prometheus datasource
can query ZJLAB without a second Grafana datasource. Credentials remain in
runtime/private secret stores and must not be added to public manifests.
The Git-provisioned Grafana dashboard Kubernetes Resources (UID
kubernetes-resources-multicluster) is in the Kubernetes folder and is
defined by manifests/grafana-kubernetes-dashboard.yaml. It uses the existing
Prometheus datasource UID prometheus; its Cluster selector maps 72602 to
the empty-label matcher (^$), ZJLAB to cluster="zjlab", and All to both.
Node and namespace selectors are dependent query variables. Node resource
panels use the kubernetes-service-endpoints scrape job to avoid counting the
ZJLAB node-exporter series twice. Verification returned one, two, and three
unique nodes for 72602, ZJLAB, and All respectively; Pod CPU, Deployment, and
scrape-target queries were non-empty. The previous runtime dashboard is not
part of the operating path.
On 2026-08-14, the user-authorized reset deleted the 30Gi Prometheus TSDB PVCs
monitor/prometheus-server in 72602 and
monitoring/zjlab-prometheus-server in ZJLAB. ArgoCD recreated both PVCs and
the existing scrape and remote-write configuration was retained. No backup was
made; all prior metrics history is intentionally unrecoverable. Loki, Tempo,
Grafana users, and Grafana datasource configuration were not cleared.
Mail records are managed in the 72602.space zone with TTL 600:
| RR | Type | Value | Priority |
|---|
mail | A | 47.110.67.161 | |
@ | MX | mail.72602.space. | 10 |
@ | TXT | v=spf1 mx ip4:<current-72602-egress-ip> -all (maintained dynamically) | |
_dmarc | TXT | v=DMARC1; p=none; rua=mailto:admin@72602.space | |
dkim._domainkey | TXT | v=DKIM1; k=rsa; p=<derived-public-key> | |
The mail records were verified through AliDNS, both authoritative nameservers, and
public resolvers 1.1.1.1 and 8.8.8.8. Mailu is configured with DKIM selector
dkim for 72602.space, using /dkim/{domain}.{selector}.key. The current
admin /dkim file is 72602.space.dkim.key; the front /dkim directory is not
used for this admin-owned key.
Mailu DKIM and PTR readiness
The domain key was generated idempotently in the admin Pod with the official
update import path. The input contained only the domain name and
dkim_key: -generate-; -u -q was used and the default replace mode was not
used:
kubectl -n mailu exec deploy/mailu-admin -- sh -c \
'printf "%s\\n" "domain:" " - name: 72602.space" \
" dkim_key: -generate-" | flask mailu config-import -u -q -'
The admin image does not contain openssl. A temporary mailu-dkim-openssl
Pod using the already deployed Mailu front image mounted only the
mailu-storage PVC dkim subPath and ran container openssl to derive RSA
public DER base64. It mounted no Secret, was deleted after derivation, and
emitted no private-key content. The derived public value is intentionally not
stored in this handbook.
AliDNS was queried first from aaron@72602-minipc with the official SDK
v4.6.0 in /home/aaron/.local/venvs/alidns, sourcing only the mode-0600
/home/aaron/.aliyun-keys credential file inside that SDK process. The exact
dkim._domainkey / TXT record was absent, so one record was created with
TTL 600 and value shape v=DKIM1; k=rsa; p=<derived-public-key>. Its
RecordId is 2082130099188750336.
The authoritative servers dns15.hichina.com and dns16.hichina.com, and
public resolvers 1.1.1.1 and 8.8.8.8, all returned the complete matching
TXT value with TTL 600 (410 characters). SPF, DMARC, and MX retained their
existing values. Mailu admin/front were Ready, all Mailu Pod restart counts
were zero, and no Warning events were present after the operation.
Roll back this newly created DNS record only with the official AliDNS SDK
DeleteDomainRecord call for RecordId 2082130099188750336. Do not delete
or regenerate the Mailu key during DNS rollback. If a future operation updates
an existing record instead of creating one, restore that same RecordId’s
pre-change value and TTL rather than deleting it.
Reverse DNS for 47.110.67.161 currently returns NXDOMAIN. A fresh read-only
ECS API check found the address as the primary public IP of running instance
i-bp1caavgd1twh7wb3n63 in cn-hangzhou (zone cn-hangzhou-i); its
EipAddress.AllocationId and EipAddress.IpAddress are empty. The installed
official aliyun-python-sdk-ecs is 4.24.83: DescribeEipAddresses found
no EIP allocation for this address in the 32 discovered regions. The
installed DescribeNatGateways model returned zero gateways in 31 regions;
sa-east-1 returned 503 ServiceUnavailable twice and remains unconfirmed.
The SDK contains ModifyEipAddressAttributeRequest, but its request model
exposes AllocationId and Bandwidth (plus common owner parameters), not
ResourceId, RegionId, or ReverseDnsName; it contains no
ModifyReverseDns, ReverseDnsName, DescribeNatGatewayEipAddresses, or
DescribeNatGatewayAttribute model. RegionId is supplied to AcsClient,
not as a PTR parameter in that request. Do not treat
ModifyEipAddressAttribute as a PTR operation or change PTR automatically;
identify the owning Alibaba product/resource and use its documented console
reverse-DNS action or Alibaba support path to request mail.72602.space if
that product permits it. This verification made no PTR or DNS change.
Safe checks:
kubectl -n mailu exec deploy/mailu-admin -- sh -c 'ls -1 /dkim'
kubectl -n mailu exec deploy/mailu-front -- sh -c 'ls -1 /dkim'
dig @dns15.hichina.com dkim._domainkey.72602.space TXT +noall +answer +authority
dig @dns16.hichina.com dkim._domainkey.72602.space TXT +noall +answer +authority
dig -x 47.110.67.161 +noall +answer +authority
The AliDNS credential file must be sourced only inside the official SDK process;
never print, log, copy, or commit its values. Never print Mailu Secret data,
passwords, or DKIM private-key contents during these checks.
txt2img.agent.72602.online is retired. Its DNS record, certificate, TLS Secret, and unreferenced ai data claims have been removed.
DNS for 72602.space is managed in AliDNS. Active service records should point
to the ECS public address only when the corresponding Ingress and certificate
are Ready; do not describe this zone as dual-managed by Cloudflare.
Deployed ArgoCD Apps
| App | Namespace | Type | Source | Ingress |
|---|
| argocd | argocd | Helm (argo-cd) | argo-cd 9.5.4 | argocd.72602.space |
| cert-manager | basic-components | Helm (Jetstack) | cert-manager 1.20.2 | internal |
| ingress-nginx | basic-components | Helm | ingress-nginx 4.15.1 | shared ingress controller |
| ops-docs | application | manifests (Git) | docs.git/main | ops.docs.72602.space |
| ops-agent | application | manifests (Git) | docs.git/main | ops.agent.72602.space |
| mailu | mailu | Helm (Mailu) | mailu 2.7.3 | mail.72602.space |
| prometheus | monitor | Helm (Prometheus Community) | prometheus 29.18.0 | prometheus-write.72602.space |
| grafana | monitor | Helm (Grafana) | grafana 10.5.15 | grafana.72602.space |
| loki | monitor | Helm (Grafana) | loki 6.55.0 | internal |
| tempo | monitor | Helm (Grafana) | tempo 1.24.4 | otlp.72602.space |
| alloy | monitor | Helm (Grafana) | alloy 1.10.1 | otlp.72602.space |
| homepage | monitor | manifests (Git) | docs.git/main | port.72602.space |
| uptime-kuma | monitor | manifests (Git) | docs.git/main | uptime.72602.space |
| sub2api | application | ArgoCD (Git → OCI Helm) | sub2api 0.1.6 / ghcr.io/wei-shaw/sub2api:0.1.168 | token.72602.space |
| postgresql | database | Helm (Bitnami) | postgresql 18.1.8 | internal |
| redis-shared | storage | Helm (Bitnami) | redis 18.16.0 | internal |
| minio | storage | Helm | minio 16.0.10 | console.minio.72602.space, api.minio.72602.space |
| n8n | n8n | Helm (community) | n8n 1.16.36 | n8n.72602.space, webhook.n8n.72602.space |
filing-site is uninstalled. Commit
0c250db869ae45c6c6a5a850876728783f1b08dd removed its manifest from the
ops-docs source. Detailed filing-site deployment checks are intentionally
omitted from this current-state page; use Git history when an older incident
record is required.
argocd/ops-docs reconciles the repository’s manifests path and owns the
application workloads and their child Applications, including sub2api and
mailu. The same source also defines the observability Applications listed
above. Sub2API uses the application namespace, nginx Ingress, a Ready TLS
certificate, a 10Gi local-path RWO application PVC, and an 8Gi
local-path RWO Redis PVC with AOF enabled.
The alloy Application is also live in monitor (Grafana Alloy chart
1.10.1) and receives OTLP traffic at otlp.72602.space; it forwards traces,
metrics, and logs to Tempo, Prometheus, and Loki. Confirm the Application and
its endpoints before changing the observability pipeline.
Ops Docs Publishing
argocd/ops-docs compares only the repository’s manifests path. A commit that
changes only content can advance .status.sync.revision while remaining
Synced; it does not create a sync operation, so the ops-docs-build Sync hook
does not run.
Publish a reviewed content commit by setting its full SHA in
manifests/configmap.yaml as PUBLISH_REVISION, then commit and push that
single manifest change. Automatic sync configures ops-docs-config and runs the
hook. The hook fetches and verifies that exact SHA, builds Hugo into
hugo-docs-pvc, and writes the SHA to /usr/share/nginx/html/.ops-docs-revision.
Its fixed Job name is safe because the delete policy is
BeforeHookCreation,HookSucceeded; the successful Job is normally absent after
the operation.
Verify the source, operation, build marker, rollout, and public route:
git -C /home/aaron/Ops/docs ls-remote origin refs/heads/main
kubectl -n application exec deployment/ops-agent -c ops-agent -- \
argocd app get ops-docs --hard-refresh --insecure --grpc-web
kubectl -n application exec deployment/ops-agent -c ops-agent -- \
argocd app history ops-docs --insecure --grpc-web
kubectl -n application rollout status deployment/ops-docs --timeout=300s
kubectl -n application exec deployment/ops-docs -- sh -c \
'tr -d "\n" < /usr/share/nginx/html/.ops-docs-revision; printf "\n"'
curl -fsS -o /dev/null -w '%{http_code}\n' https://ops.docs.72602.space/
The Application revision and published content revision can differ by the
manifest-only trigger commit; both must match their reviewed Git commits. To
roll back the generated site, set PUBLISH_REVISION to the previous reviewed
content SHA in Git and push a new trigger commit. Let automatic sync rebuild the
PVC. Do not copy HTML directly or patch the Deployment, ConfigMap, or PVC.
Non-ArgoCD (手动部署)
| Deployment | Namespace | Image | Ingress |
|---|
| ops-agent | application | ay-dev/ops-agent:0.2.3 | ops.agent.72602.space |
2026-08-14: ops-agent 0.2.3 deployed
- Live checks confirmed
hostname=72602-minipc, kubeconfig context default,
and node 72602-minipc Ready at 192.168.0.25 on v1.34.6+k3s1. - The live owner is
argocd/ops-agent, sourced from
https://github.com/AaronYang0628/docs.git at manifests/ops-agent, target
main. - The local
0.2.3 image built successfully with OpenCode 1.18.16 and
image ID 2e8de824615d5cfc8b3cb887bff21bad974985fe88ad24beda1c9082760c0f7c.
The OCI manifest digest was
sha256:f1e5e79d570cdec0ce375e264f3c83291912180591f43308d7fe2b66dc326bff.
The first attempt hit a transient direct dl.k8s.io curl timeout; retrying
completed the build. - The image was pushed to the Aliyun personal registry and imported into local
k3s containerd under tag
0.2.3. Registry credentials were used through a
temporary authfile and were not written to Git or the host’s persistent
container configuration. - The GitOps manifest was committed and pushed to
main; ArgoCD reconciled the
updated Deployment. The replacement Pod became Ready with the 0.2.3 image,
and the rollout completed successfully. - Authenticated
/global/health returned healthy, DNS resolved
ops.agent.72602.space to 47.110.67.161, its nginx Ingress and Ready TLS
remained healthy, and the anonymous public health request returned the
expected 401. - Roll back by restoring the prior
0.2.2 source revision and reconciling; do
not delete Secrets or PVCs.
Network Proxy
For host-level command loading and the standard read-only preflight, use the
shared Clash/Mihomo runbook. In particular, clashctl is a
shell function and must be sourced explicitly in non-interactive agent shells;
do not probe guessed ports when clashctl status and the runtime configuration
provide the answer directly.
Egress Proxy Architecture
k8s Pod (10.42.x.x) --HTTP_PROXY--> 192.168.0.25:17890 (socat) --forward--> 127.0.0.1:7890 (mihomo/clash) --tunnel--> upstream proxies
- mihomo (clash): listens on
127.0.0.1:7890 (HTTP), 127.0.0.1:7891 (SOCKS5)- Config:
/home/aaron/clashctl/resources/runtime.yaml - Key setting:
allow-lan: false (只监听 localhost)
- socat bridge:
0.0.0.0:17890 → 127.0.0.1:7890 (桥接使 k8s Pod 可达)- 进程:
socat -d -d TCP-LISTEN:17890,fork,reuseaddr,bind=0.0.0.0 TCP:127.0.0.1:7890
- k8s Service:
argocd-egress-proxy.argocd.svc.cluster.local:17890 → Host 192.168.0.25:17890; use the Service DNS name rather than a mutable ClusterIP. - App proxy env: 应统一使用
http://192.168.0.25:17890(不是 192.168.0.25:7890,因为 mihomo 仅绑定 127.0.0.1)
关键约束
- mihomo
allow-lan: false 意味着 不能 直接用 192.168.0.25:7890 作为代理地址 - 必须通过 socat 桥接 (
192.168.0.25:17890) 或 argocd-egress-proxy Service 访问 - GitHub acceleration:
ghfast.top URL rewrite + NO_PROXY bypass - Image mirror:
m.daocloud.io/docker.io, m.daocloud.io/ghcr.io
Known Incident Pattern
Historical pattern (legacy .72602.online hostname): HTTPS handshake fails
with tls alert internal error.
Root cause: ECS Docker/derper occupies public 443, traffic never reaches k3s ingress.
Fix baseline: derper must expose 8443:443, keep public 443 for ingress NodePort 32443.
Symptom: n8n 所有 workflow 报 connect ECONNREFUSED 192.168.0.25:7890。
Root cause: HTTP_PROXY 指向 192.168.0.25:7890,但 mihomo 只监听 127.0.0.1:7890(allow-lan: false)。Pod 无法直连 mihomo 的 LAN IP。
Fix baseline: HTTP_PROXY/HTTPS_PROXY 必须使用 socat 桥接端口
192.168.0.25:17890(或 Service DNS
argocd-egress-proxy.argocd.svc.cluster.local:17890),该端口由 socat
转发至 127.0.0.1:7890。
Host-Level Services
| Service | Port | Bind | Description |
|---|
| mihomo (clash) HTTP proxy | 7890 | 127.0.0.1 | Egress proxy, allow-lan: false |
| mihomo (clash) SOCKS5 | 7891 | 127.0.0.1 | SOCKS5 proxy |
| mihomo external controller | 9090 | 0.0.0.0 | Clash API/UI, exposed via clash.72602.space |
| socat bridge | 17890 | 0.0.0.0 | Forwards to 127.0.0.1:7890, k8s pod accessible |
| autossh tunnel (main) | 10021→ECS | - | Reverse tunnel to ECS |
| autossh tunnel (backup) | 10022→ECS | - | Reverse SSH + Mailu loopback forwarding |
| WireGuard | 51820/udp | ECS 0.0.0.0, minipc dynamic UDP | Encrypted Web path between ECS and minipc |
| ECS HAProxy Web | 80,443 | 0.0.0.0 | TCP passthrough to minipc WireGuard IP |
| ECS HAProxy Mail | 25,465,587,993 | 0.0.0.0 and [::] | TCP passthrough with PROXY v2 |
| ECS mail tunnel backends | 10225,10465,10587,10993 | 127.0.0.1 | sshd reverse forwards to minipc Mailu front |
| Mailu front host ports | 25,465,587,993 | minipc hostPort | SMTP, SMTPS, submission, IMAPS |
| k3s ingress HTTP | 32080 | 0.0.0.0 | NodePort for ingress HTTP |
| k3s ingress HTTPS | 32443 | 0.0.0.0 | NodePort for ingress HTTPS |
Notes
- Keep
derper away from public 443 (use 8443). - Keep app ingress aligned with ArgoCD ingress pattern:
ingressClassName: nginxcert-manager.io/cluster-issuer: lets-encrypt- TLS secret per host.
argocd-egress-proxy 由 ops-docs ArgoCD Application 管理,并为 repo-server 提供 Git/Helm 出站代理。- mihomo
allow-lan: false 意味着 Pod 代理地址必须是 socat 桥接端口 17890,不能用 7890。 - 72602 的 SSH 隧道当前由两个独立的用户级 systemd 服务维护,并依赖
loginctl enable-linger 在登出和重启后继续运行;ZJLAB 的对应 loopback
隧道采用独立的系统级服务,不能把两套服务模型混写。 - ECS must allow inbound TCP
25,465,587,993 from 0.0.0.0/0 and UFW must
allow the same ports before testing public mail delivery. Preserve the existing
default firewall policies and unrelated rules.
Mailu Public Mail Path
- Inbound mail remains
Internet -> ECS 47.110.67.161:25/465/587/993 -> HAProxy TCP passthrough with PROXY v2 -> ECS loopback backends 127.0.0.1:10225/10465/10587/10993 -> independent 10022 reverse tunnel -> 72602-minipc Mailu front hostPort. - ECS public mail ports are owned by HAProxy; the four loopback backends are
owned by
sshd. The 10021 SSH service is independent and must not be
restarted during mail changes. - The current Mailu source trusts the k3s CNI gateway
10.42.0.1/32 for PROXY
traffic and exposes PROXY ports 993, 25, 465, and 587. Read-only
verification on 2026-08-13 confirmed the Mailu workloads were Ready and the
465/993 TLS and 587 STARTTLS handshakes completed. The old
127.0.0.1/32 trust mismatch and EOF observations are historical incident
records, not the current baseline.
Mailu outbound delivery and SPF
- Mailu outbound delivery is direct from Postfix through the 72602 home
egress. It does not traverse ECS
10022 or ECS HAProxy; those are inbound
mail paths. The egress address is dynamic, so update-mailu-spf.timer
refreshes the AliDNS SPF record hourly. Query the current record and current
egress IP during an incident rather than treating an observed historical IP
as a permanent value. - The 2026-08-13 audit observed home egress and authoritative/public SPF as
36.24.58.213; the single SPF record is maintained by
update-mailu-spf.timer. Treat that address, and the 36.24.59.216 and
125.121.102.50 values in the dated 2026-07-31 delivery record, as dated
observations rather than permanent allowlist values. A successful SMTP queue
response still does not prove final inbox placement or reputation. - Rspamd logged
DKIM_SIGNED for 72602.space with selector dkim. The
corresponding DNS record is 2082130099188750336; DMARC is record
2082063800085560320 with p=none. PTR lookups for both the dynamic
egress IP and ECS 47.110.67.161 returned NXDOMAIN. A fixed outbound SMTP
relay remains the reliable solution; no relay was configured. - SPF automation still has a one-hour polling interval and cannot guarantee
delivery immediately after a home IP change. Preserve the timer and inspect
its last run before making any manual DNS change; do not hard-code a transient
egress address in this current-state section.
Useful checks:
sudo ss -lntp | grep -E ':(25|465|587|993|10022)$'
kubectl -n mailu get deploy,pod,svc,certificate,order -o wide
kubectl -n mailu get endpoints mailu-front -o wide
Rollback for this mail proxy change is: systemctl stop haproxy; restore the
saved reverse-tunnel-ecs-10022.service backup; run
systemctl --user daemon-reload; restart only
reverse-tunnel-ecs-10022.service. If reverting the required ECS sshd binding
change, restore /var/backups/sshd_config.20260728T142924Z.before-haproxy, run
sshd -t, reload sshd, then restore/restart the 10022 tunnel as needed. Do not
restore an old HAProxy configuration or uninstall the package as part of this
rollback, and do not delete Mailu Secrets or PVCs.
Recent Operations
2026-08-02: application namespace prune incident
- Trigger and time: commit
0c250db869ae45c6c6a5a850876728783f1b08dd deleted
manifests/filing-site.yaml. The file’s first object was the shared
Namespace/application. At 2026-08-02 08:25:53 +08, the ops-docs
Application’s automated prune deleted that Namespace along with the intended
filing-site resources. - Root cause: the lifecycle of a shared Namespace was coupled to one
removable workload manifest while automated prune was enabled. Once the
Namespace disappeared from Git, Argo CD treated it as stale. Kubernetes then
cascade-deleted namespaced resources; sync options on an individual PVC or
Secret cannot protect it from deletion through its parent Namespace.
- Impact: runtime Secrets and the
application local-path PVCs were
deleted, so Ops Agent, Sub2API, and its Redis could not start. Argo CD
recreated declarative resources, but not runtime Secret values or deleted
volume contents. PostgreSQL remained intact because it runs in the separate
database namespace. - Unrecovered data: the pre-incident local-path directories for
opencode-data, Sub2API Redis AOF, sub2api-data, and filing-site photos were
deleted and had no snapshot. Those contents were not recovered; replacement
PVCs do not contain the former data. - Secret recovery: the
2026-08-02 00:00 +08 etcd snapshot was restored
only into an isolated same-version temporary k3s. The approved whitelist was
the aliyun-registry Secret, five opencode-* Secrets, and three
sub2api-* Secrets. All nine were still absent immediately before create-only
restoration. No Secret value was printed, logged, committed, or allowed to
overwrite a newer object.
The production server never ran cluster-reset; the procedure is documented
in Runtime Secret Recovery. - Result: kubelet recovered the existing post-prune Pods without manual
deletion or restart. Ops Agent reached
2/2 Ready; Sub2API and its Redis
reached 1/1; application health checks passed; and ops-docs, ops-agent,
and sub2api were Synced/Healthy. filing-site-upload-auth,
72602.space-tls, filing-site, and deleted local-path data were not restored. - Immediate guard: the live
Namespace/application was merge-patched only
with argocd.argoproj.io/sync-options=Prune=false,Delete=false. This protects
an Argo CD-tracked Namespace from prune and Application deletion, but it is
not an admission policy and cannot block direct deletion by another actor. - Durable guard: during the incident response,
manifests/application-namespace.yaml was prepared as a dedicated Namespace
manifest but was not included in a commit or pushed at that response
checkpoint; remote main and ops-docs were still at 0c250db8. This is a
historical checkpoint, not a claim about the current repository state.
Before relying on the guard, verify that the reviewed file is in remote Git,
Argo CD tracks the Namespace with both sync options, no removable workload
manifest defines the shared Namespace, and recoverable off-volume backups
exist for required local-path data. Do not delete or recreate the Namespace
as rollback.
2026-07-30: fix MinIO S3 upload HTTP 413
Symptom: Sub2API backup uploads to api.minio.72602.space failed with S3
PutObject HTTP 413 for an approximately 2.45 MB request. The default
ingress-nginx client_max_body_size 1m rejected the request before MinIO.
Fix: commit 2338bc6 added this annotation under the MinIO
apiIngress.annotations in Git:
nginx.ingress.kubernetes.io/proxy-body-size: "0"
This change is scoped only to the MinIO API Ingress; it does not change the
MinIO console or the shared/global ingress configuration. ArgoCD and MinIO
became Synced/Healthy; generated nginx reported client_max_body_size 0.
An authenticated S3 PutObject/Stat/Delete smoke test passed and its
temporary object was removed. Sub2API backup upload was then manually
confirmed successful.
Roll back by reverting 2338bc6 in Git and allowing ArgoCD to reconcile. If
an emergency live reversal is required first, remove only the annotation
from storage/minio-api, then verify ArgoCD convergence. Do not delete
MinIO Secrets or PVCs. Do not globally disable request body limits without
explicit scope and security review.
2026-07-29: rotate Ops Agent provider credentials
- Confirmed the operation on
72602-minipc with context default; the only
node was Ready at 192.168.0.25 on v1.34.6+k3s1. Before the change,
application/opencode-model contained only the api-key key and the live
Deployment injected only OPENAI_API_KEY. No Secret value was read or
printed. - Commit
ddb69f1 on main routes both the OpenAI and Grok providers to the
then-current endpoint https://sub2api.72602.space/v1. The current public
endpoint is https://token.72602.space/v1; this dated record is retained as
history, not as a current endpoint instruction. The Secret was merge-patched
through stdin to update api-key and add grok-api-key, preserving its other fields, and
manifests/ops-agent/deployment.yaml was applied. No credential was written
to Git or a temporary file, and no sub2api or unrelated resource was
changed. - The live Deployment now injects
OPENAI_API_KEY from
opencode-model/api-key and GROK_API_KEY from
opencode-model/grok-api-key. Rollout completed with Deployment generation
and observed generation 16; Pod ops-agent-68556dc7f5-6jd84 was 2/2
Ready with zero restarts, and the Service endpoint was 10.42.0.207:8080. - The authenticated internal
/global/health check returned healthy=true.
Separate, read-only /v1/models requests from the Pod returned HTTP 200
with the OpenAI credential and HTTP 200 with the Grok credential. Filtered
live merged configuration at that time showed both provider base URLs as
https://sub2api.72602.space/v1; new-Pod logs contained no
Invalid API key message. - DNS for
ops.agent.72602.space resolved to 47.110.67.161; Ingress
ops-agent used class nginx and that host, and Certificate
ops.agent.72602.space-tls was Ready. The unauthenticated public health URL
returned the expected HTTP 401. An Argo CD hard refresh from the Ops Agent
Pod with --insecure --grpc-web reported ops-agent Synced to ddb69f1 and
Healthy. - Roll back by restoring the prior approved model credential with the same
non-output stdin merge-patch method. Remove
grok-api-key only when reverting
to the previous single-provider state, and restore/sync source revision
a7e434b for the prior Deployment. Do not delete the Secret, expose its
values, or modify sub2api during rollback.
2026-07-28: read-only Mailu verification after c5d1e0a
- Checks ran from the Ops Agent Pod (
hostname=ops-agent-5d6878f6c-xwdb).
kubectl config current-context was unset, but in-cluster credentials reached
the only node 72602-minipc, Ready at 192.168.0.25 on v1.34.6+k3s1. - Automatic sync completed at
2026-07-28T12:50:54Z. Read-only ArgoCD checks
reported ops-docs Synced/Healthy at full revision
c5d1e0adddaace9ad0ba2d3c57c3ef27eb0e6802 (c5d1e0a, history ID 24), and
child Application mailu targeting chart 2.7.3 as Synced/Healthy.
No manual apply, sync, delete, rollback, or restart was run. - Read-only observations from
12:53:05Z through approximately 13:00Z kept
all eight Mailu Deployments and both StatefulSets at 1/1 Ready. The
mailu-front rollout status completed. mailu-front (10.43.76.102) and
mailu-front-ext (10.43.108.81, ClusterIP) both had endpoint
10.42.0.198. The mailu-envvars ConfigMap reported
PORTS=80,443,4190,995,993,25,465,587,4190. - Certificate
mail.72602.space-tls was Ready=True; its Order was valid,
with validity 2026-07-28T10:22:57Z through 2026-10-26T10:22:56Z, and no
active Challenge. Public DNS returned mail.72602.space A 47.110.67.161,
72602.space MX 10 mail.72602.space., SPF v=spf1 mx -all, and DMARC
v=DMARC1; p=none; rua=mailto:admin@72602.space, all with TTL 600.
The three PVCs were Bound on local-path with RWO: 2Gi ClamAV,
100Gi Mailu storage, and 8Gi Redis. - Following the HTTPS redirect,
https://mail.72602.space/ returned final
HTTP 200 at /sso/login?url=/webmail/?homepage. Port 25 returned
220 mail.72602.space ESMTP ready; ports 465 and 993 completed verified
TLS 1.3 handshakes for mail.72602.space. - Port
587 did not provide STARTTLS: openssl s_client reported no
STARTTLS and an unexpected EOF. Connections to port 587 on both front
ClusterIP Services and the postfix ClusterIP were refused, and the front
container listener list had no 587. The ConfigMap update was not followed
by a new front Pod template rollout: the running Pod started at
2026-07-28T12:32:36Z, before the automatic sync, while the Deployment
remained 1/1 with generation and observed generation 3. Fix the rollout
through Git and automatic ArgoCD reconciliation before treating submission
as ready. - A single non-authenticated relay probe sent only
EHLO, MAIL FROM,
RCPT TO:<nobody@example.net>, and QUIT; it sent no DATA, credentials,
or message. The external RCPT received 250 2.1.5 Ok instead of a rejection.
This does not prove actual delivery or an open relay, but it fails the
required black-box rejection check and requires immediate relay-policy review. - No Secret data, password, or DKIM private key was read. Remaining manual
actions are to fix and reverify the front submission rollout, review relay
policy, obtain the initial admin password through an approved secure process,
publish the generated DKIM public record, and confirm ECS/UFW/cloud policy
for outbound TCP 25 before any real delivery test. Do not delete Mailu
Secrets or PVCs during correction or rollback.
2026-07-28: read-only Mailu deployment verification
- Read-only checks ran from the Ops Agent Pod (
hostname=ops-agent-5d6878f6c-xwdb).
kubectl config current-context was unset, but the in-cluster credentials reached
the only node 72602-minipc, which was Ready at 192.168.0.25 on
v1.34.6+k3s1. - Ran
argocd app get ops-docs --hard-refresh --insecure --grpc-web: revision
2773ef5, Synced, Healthy. Ran argocd app get mailu --refresh --insecure --grpc-web: target 2.7.3, Synced, Degraded; only the
mailu-front Deployment was Degraded. - Over approximately eight minutes, 17 read-only observations at 30-second
intervals showed
mailu-admin, mailu-dovecot, mailu-oletools,
mailu-postfix, mailu-rspamd, mailu-tika, and mailu-webmail at 1/1
Ready, with mailu-clamav and mailu-redis-master at 1/1; all three PVCs
were Bound on local-path (2Gi, 100Gi, and 8Gi). mailu-front remained
0/1 Ready with ProgressDeadlineExceeded, and mailu-front had no
Endpoints. The old Pod mailu-front-85d9b6d7d4-6bd9s used
m.daocloud.io/ghcr.io/mailu/nginx:2024.06.57 and remained in
ImagePullBackOff after the mirror returned 403 Forbidden. The replacement
Pod mailu-front-5cbbf9bc99-2qc7v used the desired
ghcr.nju.edu.cn/mailu/nginx:2024.06.57 but remained Pending because the
single node had no free requested host ports. Both ReplicaSets requested
host ports 110,995,143,993,25,465,587. - Ingress
mailu uses class nginx for mail.72602.space. Certificate
mail.72602.space-tls is Ready=True, its Order is valid, and the
certificate is valid from 2026-07-28T10:22:57Z through
2026-10-26T10:22:56Z; there is no active Challenge. getent ahostsv4 mail.72602.space resolved the host to 47.110.67.161.
Direct https://mail.72602.space/ returned 503. ECS TCP ports 25,
465, 587, and 993 accepted connections, but SMTP ports closed before
returning a banner and SMTPS/IMAPS TLS handshakes were reset. No authentication
or real mail delivery was attempted.- The exact blocker is the
mailu-front image-pull 403 Forbidden in the old
ReplicaSet combined with host-port contention during the single-node rolling
update. DNS, the issued certificate, and public ECS port reachability are not
the blocker. Webmail/Admin and the mail protocols are therefore not usable yet.
No delete, rollback, manual apply, or ArgoCD sync was run, and no Secret
values were read. A future fix must correct the image/rollout through the Git
source, then verify the front Endpoints and protocol handshakes; preserve all
Mailu Secrets and PVCs. Any rollback should restore the reviewed source
revision through ArgoCD and must not delete Mailu Secrets or PVCs.
2026-07-28: diagnose MinIO Console slowness and port TLS fix
- Read-only identity checks from the Ops Agent Pod reported
hostname=ops-agent-5d6878f6c-xwdb; the kubeconfig context was unset, but in-cluster credentials reached the only node 72602-minipc, Ready at 192.168.0.25 on v1.34.6+k3s1. No Secret values, credentials, object names, or object data were read. - The pending
manifests/ingress-port.yaml change has ClusterIssuer lets-encrypt, nginx.ingress.kubernetes.io/ssl-redirect: "true", and TLS host port.72602.space, but no matching port.72602.space rule. It belongs to the application/ops-docs source, while the live owner is monitor/homepage from manifests/homepage/ingress.yaml. Therefore it was invalid for the requested route and was not committed, pushed, applied, synced, or rolled back. Before correction, http://port.72602.space/ returned 200 without redirect; HTTPS returned 200 only with the Kubernetes Ingress Controller fake certificate. The correct future fix must update the owning Homepage ingress source, then sync ops-docs, wait for Certificate Ready, and verify strict HTTPS. storage/minio was 1/1 Ready (minio-7f776484df-sslgs), with four restarts 58 days ago, 2m CPU and 662Mi memory observed, requests 250m/512Mi, limits 512m/1Gi, Service endpoints 10.42.0.21:9000,9001, and an 8Gi Bound local-path PVC. The mounted filesystem reported 937G total, 269G used, and 621G free. Node MemoryPressure, DiskPressure, and PIDPressure were False; node usage was 389m CPU (2%) and 11737Mi memory (40%).- From 72602, public Console root/API timings were
0.125s/0.241s; from ecs-99, 0.772s/0.783s. Direct Service/Pod representative requests were approximately 0.0003–0.0006s; direct ingress was approximately 0.107–0.179s. The 3,259,800-byte main JavaScript asset took 16.802s locally and 13.893s from ECS; the 3,835,591-byte login video took 21.337s and 17.413s. The 663,820-byte background SVG took 2.144s and 5.437s. API responses remained fast. - Ingress-nginx was at
3m CPU and 253Mi memory with five restarts 58 days ago, 16 workers, and 16384 worker connections. In the last 24 hours, 165 Console access requests had no observed 499/502/504 or upstream errors; MinIO logs had no warning/error lines. /ws/objectManager returned HTTP 101 and several websocket connections ended at 60.000–60.002s, matching the generated proxy-read-timeout 60s; this is a long-lived websocket timeout/reconnect concern, not a slow initial API response. - Root cause assessment: initial Console slowness is dominated by large static/media transfers through the ECS reverse tunnel and its network variability; MinIO, ingress CPU/concurrency, storage capacity, readiness, and API/backend response time were not limiting. No performance mutation was justified, so no resource, timeout, keepalive, scaling, DNS, or broad configuration change was made. No rollback is needed. A future websocket timeout change must be committed through
manifests/minio-argocd.yaml and synced through ArgoCD; any port TLS correction must first update the owning Homepage ingress source.
2026-07-28: observe automatic sync for d639a46
- Ran the read-only checks from the Ops Agent Pod (
hostname=ops-agent-5d6878f6c-xwdb). kubectl config current-context was unset, but in-cluster credentials reached the only node 72602-minipc, which was Ready at 192.168.0.25 on v1.34.6+k3s1. The expected Git manifest route is mail.72602.space, with Ingress class nginx, issuer lets-encrypt, local-path storage, Mailu chart 2.7.3, and global.security.allowInsecureImages: true. - Ran
argocd app get ops-docs --hard-refresh --insecure --grpc-web. The first observation was OutOfSync from main (d639a46) with Healthy; automatic sync then completed without a manual sync, and the final state was Synced to main (d639a46) and Healthy. The output showed child argocd/mailu configured. No argocd app sync, permission bypass, Kubernetes apply, create, patch, or delete was run. - Ran
argocd app get mailu --refresh --insecure --grpc-web. Automatic sync removed the previous ComparisonError; Mailu was Synced to 2.7.3 but Progressing. Twenty read-only observations over approximately five minutes (10:05:17–10:10:07 UTC) remained Synced / Progressing with no ComparisonError condition. - The required
kubectl -n mailu get deploy,statefulset,pod,pvc,svc,ingress,certificate,order,challenge -o wide check found all eight Mailu Deployments at 0/1; mailu-clamav StatefulSet at 0/1 and mailu-redis-master at 1/1. The Redis Pod and ACME solver Pod were Running and ready; admin, dovecot, and postfix were ImagePullBackOff; oletools, rspamd, and webmail were ErrImagePull; front, tika, and clamav were ContainerCreating. The three PVCs were Bound (2Gi, 100Gi, and 8Gi, all local-path/RWO). Ingress mailu used nginx for mail.72602.space at 10.43.13.156 on ports 80,443. Certificate mail.72602.space-tls was not Ready, its Order was pending, and its Challenge was pending. - The redacted ACME error was:
Waiting for HTTP-01 challenge propagation: failed to perform self check GET request 'http://mail.72602.space/.well-known/acme-challenge/...': Get "http://mail.72602.space/.well-known/acme-challenge/...": dial tcp: lookup mail.72602.space on 10.43.0.10:53: no such host. Kubelet also reported, for Mailu components including admin, webmail, oletools, rspamd, postfix, and dovecot: failed to pull and unpack image "m.daocloud.io/ghcr.io/mailu/<component>:2024.06.57": failed to resolve reference ...: unexpected status from HEAD request to https://m.daocloud.io/v2/ghcr.io/mailu/<component>/manifests/2024.06.57: 403 Forbidden. The front Pod additionally reported MountVolume.SetUp failed for volume "certs" : secret "mail.72602.space-tls" not found. No Secret data was read. - The Helm comparison blocker was cleared by the committed configuration, and ArgoCD automatically created the Mailu resources. The rollout remained blocked by the image-mirror
403 Forbidden responses and missing DNS record, which prevented cert-manager HTTP-01 validation and TLS Secret issuance. No resource deletion or rollback was performed. Do not delete the Mailu Secret or PVCs; any Git revert, ArgoCD sync, DNS change, or resource mutation requires explicit authorization and should first correct the image source and DNS.
2026-07-28: Mailu deployment blocked before resource sync
- Ran the checks from the Ops Agent Pod (
hostname=ops-agent-5d6878f6c-xwdb). kubectl config current-context was unset, but in-cluster credentials reached 72602-minipc, which is Ready at 192.168.0.25 on v1.34.6+k3s1. The existing mailu namespace and Secret metadata were left unchanged; Secret values were not read. - GitHub
main contains 91986de with manifests/mailu-argocd.yaml. The manifest declares the Mailu route mail.72602.space, using the active 72602.space domain, nginx, lets-encrypt, and local-path. - Ran
argocd app get ops-docs --hard-refresh --insecure --grpc-web; argocd/ops-docs reported Synced, Healthy, and revision 91986de, and its output confirmed that argocd/mailu was created. The required argocd app sync ops-docs --revision main --assumeYes --insecure --grpc-web then failed before applying changes with PermissionDenied: applications, sync, default/ops-docs, sub: readonly. - No
mailu sync was attempted, and no Helm install, deletion, or automatic rollback was performed. argocd app get mailu --insecure --grpc-web reported Sync Unknown, Health Healthy, and a ComparisonError: Helm rejected the substituted Daocloud Bitnami Redis images because global.security.allowInsecureImages=true is not enabled. - Read-only checks found no Mailu Deployment, Pod, PVC, Service, Ingress, Certificate, CertificateRequest, Order, Challenge, or namespace events.
mail.72602.space had no DNS result, and the HTTPS probe timed out because no Mailu endpoint was deployed. - Rollback boundary: stop further sync; after explicit authorization, restore or revert the Git source to the pre-change known-good revision and sync through ArgoCD. Do not delete the
mailu Secret or any PVC, and do not auto-rollback. - Next actions: obtain an ArgoCD identity permitted to sync
ops-docs, review and update the Git manifest with the chart-supported insecure-image setting if the image mirror is retained, then sync ops-docs and mailu through ArgoCD. Add mail.72602.space DNS A record to 47.110.67.161 before validating the public endpoint.
2026-07-28: create Mailu namespace and bootstrap Secret
- Ran the checks from the Ops Agent Pod (
hostname=ops-agent-5d6878f6c-xwdb). kubectl config current-context was unset, but the in-cluster Kubernetes credentials reached the live node 72602-minipc, which is Ready at 192.168.0.25 on v1.34.6+k3s1. - The
mailu namespace did not exist, so kubectl create namespace mailu created it. A second check confirmed that mailu/mailu-secrets did not exist before creation. - Generated
secret-key with openssl rand -hex 32 and initial-account-password with openssl rand -base64 24 in shell memory, then ran kubectl create secret generic mailu-secrets -n mailu --from-literal=secret-key="$secret_key" --from-literal=initial-account-password="$initial_account_password". No credential value was written to a file, command output, logs, Git, or this documentation. - Safe verification confirmed namespace
mailu is Active; Secret metadata is name=mailu-secrets, namespace=mailu, type=Opaque, and keys initial-account-password and secret-key. Secret data values were not read or output. - No ArgoCD sync was run, and no DNS, security-group, or reverse-tunnel changes were made. Rollback, only with explicit authorization:
kubectl -n mailu delete secret mailu-secrets. The Secret was not deleted.
2026-07-28: black-box verification of filing-site upload policy
- Ran the checks with
curl on 72602-minipc (hostname=72602-minipc, context default). The live route is https://72602.space/; GET http://72602.space/ returned 308 with Location: https://72602.space, and the HTTPS home returned 200 text/html (19,881 bytes) containing data-upload="aaron", data-upload="licorice", and data-upload="yakult". - Anonymous
GET https://72602.space/photos/{aaron,licorice,yakult}/ each returned 200 application/json with []; the corresponding HEAD requests each returned 200 application/json. - The one temporary test object was
https://72602.space/photos/aaron/verification-1785228022571821377.png, a valid 1x1 PNG of 68 bytes. Anonymous PUT and wrong-credential PUT each returned 401 text/html; PUT with the temporary uploader credential held in shell memory since Secret creation returned 201. The follow-up Aaron listing returned 200 application/json and showed the file as type=file, size=68; the image GET returned 200 image/png with 68 bytes. - Authenticated
DELETE on the temporary object and authenticated MKCOL, MOVE, COPY, and POST on the Aaron directory were all rejected with 403 text/html; no authenticated DELETE succeeded. Cleanup used kubectl -n application exec filing-site-55cff975bf-z67xw -- rm -f -- /data/files/aaron/verification-1785228022571821377.png. The post-cleanup Aaron listing returned 200 application/json with [], and the exact path was absent in the Pod. - Live resource checks passed: Deployment
filing-site is 1/1 available, Pod filing-site-55cff975bf-z67xw is Running and ready with zero restarts, Ingress filing-site uses class nginx for 72602.space, and 72602.space-tls is Ready. PVC filing-site-photos is Bound to a 5Gi local-path PV. - In the nginx container (
uid=101, gid=101), /data/files/aaron, /data/files/licorice, and /data/files/yakult exist and are writable with mode 775; /data/.tmp exists and is writable with mode 770. No Kubernetes Secret value was read, no manifest was changed, no ArgoCD sync was run, and no test object was retained. Recheck with the same curl method matrix and kubectl exec path test; rollback is limited to deleting the exact temporary path if a test object remains. Do not delete the PVC or Secret.
2026-07-28: sync filing-site photo albums from ops-docs
- Confirmed
72602-minipc, context default, node 72602-minipc Ready at 192.168.0.25 (v1.34.6+k3s1); live route is https://72602.space/ through the nginx ingress class. - Hard-refreshed
argocd/ops-docs with argocd app get ops-docs --hard-refresh --insecure --grpc-web (the installed CLI is v3.3.8 and does not support argocd app refresh --hard), then ran argocd app sync ops-docs --revision main --assumeYes --insecure --grpc-web. - The requested baseline was
98dbe94, but origin/main advanced during the operation to 07f0e515feb7379ca79516a6c31f0e41be5a04b4 (fix: increase sub2api ingress body timeout); the final sync used that remote main revision. ArgoCD finished Succeeded, Synced, and Healthy from 16:30:08 to 16:30:47 (+0800), with message successfully synced (no more tasks). ops-docs-build briefly remained in Init:0/1 while clone-repo fetched the repository. Both clone-repo and hugo exited 0; the hook Job reached the expected succeeded pods. No manifest fix was necessary.application/filing-site rollout completed. Pod filing-site-55cff975bf-z67xw is 1/1 Running; init-albums completed with exit 0, and nginx is ready with zero restarts. Deployment conditions Available=True and Progressing=True are present.- PVC
filing-site-photos is Bound to a 5Gi local-path PV. Ingress annotations remain issuer lets-encrypt, SSL redirect enabled, body size 25m, request buffering off, and read/send timeouts 120; TLS Secret is 72602.space-tls. - Read-only verification:
nginx -t reported syntax ok and test successful inside the Pod; HTTPS GET https://72602.space/ returned HTTP 200 with text/html from 47.110.67.161. Recent events show successful local-path provisioning, old ReplicaSet scale-down/new ReplicaSet scale-up, and Ingress scheduled for sync. - No Kubernetes Secret value was read, and no PUT/upload request or resource deletion was performed. Rollback requires explicit authorization and review of the current
main: syncing the captured pre-sync revision 98dbe94 would also roll back later commits such as 07f0e51; do not delete the PVC or Secret.
2026-07-28: create filing-site upload authentication Secret
- Confirmed
72602-minipc as the active node and found no existing application/filing-site-upload-auth Secret. - Generated the
uploader password in shell memory with openssl rand -hex 18, generated an nginx-compatible apr1 hash with openssl passwd -apr1, and applied application/filing-site-upload-auth with key htpasswd. No credential material was written to disk or Git. manifests/filing-site.yaml references this Secret for the filing-site Deployment, but does not define the Secret; no ArgoCD sync was required.- Safe verification confirmed metadata
name=filing-site-upload-auth, namespace=application, type=Opaque, and key htpasswd without reading its value. The Deployment rollout succeeded with its Pod 1/1 Running. - Ingress remains
nginx at https://72602.space/; certificate 72602.space-tls is Ready under lets-encrypt, and the public HTTPS check returned HTTP 200. - Rollback, only with explicit authorization:
kubectl -n application delete secret filing-site-upload-auth.
2026-07-16: reset csst and update N8N webhook host
- Deleted and recreated the
csst namespace. Only the namespace default ServiceAccount and kube-root-ca.crt ConfigMap remain. - Changed N8N
WEBHOOK_URL, webhook worker URL, Ingress rule, and TLS DNS name from webhook.72602.online to webhook.n8n.72602.online. - Synced ArgoCD application
argocd/n8n; main, webhook, MCP webhook, and worker rollouts completed. - cert-manager completed HTTP-01 validation and issued the updated certificate.
2026-07-16: migrate OpenCode web to k3s
- Replaced the host systemd process and static EndpointSlice with the
application/ops-agent workload. - The Pod mounts
/home/aaron/Ops/docs at /workspace, loads the project .opencode/opencode.json, and persists sessions in the opencode-data PVC. - Image
ay-dev/ops-agent:0.2.3 uses glibc and contains OpenCode 1.18.16, kubectl 1.34.6, Argo CD CLI 3.3.8, VibeGuard, DCP, and Goal Mode. - OpenCode native Basic Auth protects both Ingress and cluster-internal access. Anonymous HTTPS returns
401; authenticated HTTPS returns 200. - An Nginx sidecar publishes the
Ops Agent browser title and proxies Terminal WebSocket and event streams.
2026-07-16: remove Langfuse and refresh Homepage
- Permanently removed the seven unused Langfuse PVCs (56 GiB) and six residual Secrets from
monitor. - Removed Langfuse and pgAdmin from Homepage and added the OpenCode operations agent.
- Restored the ArgoCD Homepage widget by binding the
readonly API account to role:readonly.
2026-07-17: align Ops resource names
- Renamed the Hugo workload and its Service, ConfigMap, build Job, and Ingress resources to
ops-docs; retained hugo-docs-pvc to preserve generated content. - Renamed the OpenCode-based workload, Service, Ingress, proxy ConfigMap, manifest directory, and Dockerfile to
ops-agent; retained existing opencode-* PVC and Secrets to preserve sessions and credentials. - Replaced the manual
local-proxy-bridge with the GitOps-managed argocd-egress-proxy; ArgoCD repo-server uses its cluster Service while applications can continue using host port 17890.
Default Verification Commands
# 公网入口
curl -vI http://argocd.72602.space
curl -vkI https://argocd.72602.space
# k8s 资源
kubectl get ingress -A -o wide
kubectl get svc -A -o wide
kubectl get pods -A -o wide
kubectl get certificate,certificaterequest,order,challenge -A
# 主机端口
sudo ss -lntp | grep -E ':80|:443|:8443|:32080|:32443|:7890|:17890|:9090'
# ECS 转发
sudo iptables -t nat -L PREROUTING -n -v --line-numbers
sudo iptables -t nat -L DOCKER -n -v --line-numbers
# Egress proxy 完整性:先读实际端口,再做一次已知 204 检查
CLASH_HOME=/home/aaron/clashctl
. "$CLASH_HOME/scripts/cmd/clashctl.sh"
clashctl status
proxy_port="$("$CLASH_HOME/bin/yq" '."mixed-port" // .port // 7890' \
"$CLASH_HOME/resources/runtime.yaml")"
curl --proxy "http://127.0.0.1:${proxy_port}" \
--connect-timeout 5 --max-time 12 --silent --show-error \
--output /dev/null --write-out 'proxy_http_code=%{http_code}\n' \
https://www.gstatic.com/generate_204
kubectl exec -n n8n deploy/n8n -- sh -c \
'test -n "$HTTP_PROXY" && test -n "$HTTPS_PROXY"'
# SSH 隧道
journalctl --user -u reverse-tunnel-ecs-10021.service --since "1 hour ago" --no-pager
journalctl --user -u reverse-tunnel-ecs-10022.service --since "1 hour ago" --no-pager
Subsections of 72602
Runtime Secret Recovery
This runbook documents the narrowly scoped 2026-08-02 recovery of a small,
approved whitelist of missing runtime Secrets from a pre-incident k3s etcd
snapshot. It does not restore the production cluster, databases, PVC contents,
or application data. Do not reuse the old snapshot or whitelist as a generic
current recovery recipe: later deployments added dependencies such as
sub2api-mcp, and any future recovery must use a newly approved snapshot and
an explicitly reviewed object list.
Safety boundary
Never reset production
Never run k3s server --cluster-reset with the production data directory,
service, ports, network namespace, or kubeconfig. Restore the snapshot only in
an isolated, same-version temporary k3s environment with no physical network
interface or external route.
- Obtain explicit approval to read the snapshot and create the named production
Secrets.
- Stop before production writes if the snapshot, version, token, isolation, or
target object state is uncertain.
- Disable shell tracing. Do not print Secret JSON/YAML, base64 data, hashes,
tokens, passwords, private keys, or connection strings.
- Use a root-owned mode-
0700 temporary directory and mode-0600 files. Do not
use the repository or an ordinary shared /tmp directory. - Use
kubectl create, not apply, replace, or patch. A concurrent object
creation must fail instead of being overwritten. - Do not delete Pods to accelerate recovery. Let kubelet and controllers retry
the existing Pods after the required Secrets exist.
- Do not modify PVCs, restore PostgreSQL, copy Redis data, or include filing-site
resources in this procedure.
- Run the production write steps only from
72602-minipc with kubeconfig
context default. Recheck both immediately before creation; do not rely on
an earlier terminal prompt or context check.
The approved 2026-08-02 whitelist was:
aliyun-registry
opencode-model
opencode-basic-auth
opencode-argocd
opencode-ssh
opencode-git-credentials
sub2api-auth
sub2api-external-postgresql
sub2api-redis
filing-site-upload-auth and 72602.space-tls were explicitly excluded.
The list is historical. In particular, do not silently append newly created
Secrets such as sub2api-mcp to this old snapshot procedure; validate each
dependency against the selected snapshot and obtain approval for a new list.
Preflight
Confirm the live identity and exact k3s version before reading the snapshot:
hostname
kubectl config current-context
kubectl get nodes -o wide
kubectl version
sudo k3s etcd-snapshot ls --output json
sudo k3s secrets-encrypt status
Set the approved snapshot and whitelist without adding credential values to the
shell history:
set +x
set -o pipefail
SNAPSHOT="/var/lib/rancher/k3s/server/db/snapshots/<approved-snapshot>"
NAMESPACE=application
NAMES=(
aliyun-registry
opencode-model
opencode-basic-auth
opencode-argocd
opencode-ssh
opencode-git-credentials
sub2api-auth
sub2api-external-postgresql
sub2api-redis
)
sudo test -r "$SNAPSHOT"
Perform the first production absence check. If any object is present, stop the
batch before extraction or writing and determine whether a newer value has
already been created:
for name in "${NAMES[@]}"; do
found="$(kubectl -n "$NAMESPACE" get secret "$name" \
--ignore-not-found -o name)"
test -z "$found" || {
printf '%s already exists; stop before write\n' "$name" >&2
exit 1
}
done
Record the affected workloads, storage, route, and certificate state without
reading Secret values:
kubectl -n application get deploy,statefulset,pod,pvc -o wide
kubectl -n application get ingress,certificate -o wide
kubectl -n application get events --sort-by=.lastTimestamp
Use the installed k3s binary so the temporary server exactly matches the live
version. The temporary network has only loopback and a dummy sink interface.
The dummy default route satisfies k3s node-address discovery, but cannot send a
packet through a host or physical interface.
Create the protected operation directory:
RECOVERY_ROOT="$(sudo mktemp -d -p /var/lib \
k3s-secret-recovery.XXXXXXXX)"
sudo chmod 0700 "$RECOVERY_ROOT"
printf 'recovery directory created with mode 0700\n'
Run the reset and export inside one private mount, network, and PID namespace.
The production k3s and configuration directories are remounted read-only only
inside that private mount namespace. The production server token is required to
decrypt the snapshot bootstrap data; copy it only to
${RECOVERY_ROOT}/data/server/token with mode 0600 and never print it.
sudo tee "$RECOVERY_ROOT/extract.sh" >/dev/null <<'RECOVERY_SCRIPT'
#!/usr/bin/env bash
set -euo pipefail
set +x
umask 077
REC="$RECOVERY_ROOT"
PROD=/var/lib/rancher/k3s
case "$REC" in
/var/lib/k3s-secret-recovery.*) ;;
*) exit 20 ;;
esac
test -r "$SNAPSHOT"
mount --make-rprivate /
mount --bind "$PROD" "$PROD"
mount -o remount,bind,ro "$PROD"
if test -d /etc/rancher/k3s; then
mount --bind /etc/rancher/k3s /etc/rancher/k3s
mount -o remount,bind,ro /etc/rancher/k3s
fi
ip link set lo up
ip link add recovery0 type dummy
ip addr add 198.18.0.1/32 dev recovery0
ip link set recovery0 up
ip route add default dev recovery0
test "$(ip -o link show | wc -l)" -eq 2
test "$(ip route show default dev recovery0 | wc -l)" -eq 1
install -d -m 0700 "$REC/data/server" "$REC/export"
install -m 0600 "$PROD/server/token" "$REC/data/server/token"
common=(
server
--config /dev/null
--data-dir "$REC/data"
--token-file "$REC/data/server/token"
--node-name secret-recovery
--node-ip 198.18.0.1
--bind-address 127.0.0.1
--advertise-address 127.0.0.1
--https-listen-port 16443
--lb-server-port 16444
--write-kubeconfig "$REC/recovery.kubeconfig"
--write-kubeconfig-mode 0600
--disable-agent
--flannel-backend none
--egress-selector-mode disabled
--disable coredns
--disable servicelb
--disable traefik
--disable local-storage
--disable metrics-server
--disable-scheduler
--disable-cloud-controller
--disable-kube-proxy
--disable-network-policy
--disable-helm-controller
--etcd-disable-snapshots
)
/usr/local/bin/k3s "${common[@]}" \
--cluster-reset \
--cluster-reset-restore-path "$SNAPSHOT" \
>"$REC/restore.log" 2>&1
/usr/local/bin/k3s "${common[@]}" >"$REC/server.log" 2>&1 &
server_pid=$!
cleanup_server() {
kill -TERM "$server_pid" >/dev/null 2>&1 || true
wait "$server_pid" >/dev/null 2>&1 || true
}
trap cleanup_server EXIT
ready=0
for _ in $(seq 1 180); do
kill -0 "$server_pid" >/dev/null 2>&1 || exit 21
if /usr/local/bin/k3s kubectl \
--kubeconfig "$REC/recovery.kubeconfig" \
get --raw=/readyz >/dev/null 2>&1; then
ready=1
break
fi
sleep 1
done
test "$ready" -eq 1
names=(
aliyun-registry
opencode-model
opencode-basic-auth
opencode-argocd
opencode-ssh
opencode-git-credentials
sub2api-auth
sub2api-external-postgresql
sub2api-redis
)
for name in "${names[@]}"; do
/usr/local/bin/k3s kubectl \
--kubeconfig "$REC/recovery.kubeconfig" \
-n application get secret "$name" -o json | \
jq -ce --arg name "$name" '
select(
.apiVersion == "v1" and
.kind == "Secret" and
.metadata.namespace == "application" and
.metadata.name == $name and
(.data | type == "object") and
(.data | length > 0)
) |
{
apiVersion: "v1",
kind: "Secret",
metadata: {
name: .metadata.name,
namespace: "application"
},
type: .type,
data: .data
} +
(if has("immutable") then {immutable: .immutable} else {} end)
' >"$REC/export/$name.json"
chmod 0600 "$REC/export/$name.json"
done
test "$(printf "%s\n" "$REC"/export/*.json | wc -l)" -eq 9
printf 'isolated whitelist export complete: 9 objects\n'
RECOVERY_SCRIPT
sudo chmod 0700 "$RECOVERY_ROOT/extract.sh"
sudo env -i \
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \
HOME=/root \
RECOVERY_ROOT="$RECOVERY_ROOT" \
SNAPSHOT="$SNAPSHOT" \
unshare --mount --net --pid --fork --mount-proc \
"$RECOVERY_ROOT/extract.sh"
Do not continue if the reset or temporary API fails. Inspect only redacted error
lines from the root-owned logs; do not dump those logs or exported objects to a
terminal. A missing default route must be solved only with the isolated dummy
sink above. A restore requires the token at the temporary data-directory path;
never satisfy that requirement by changing the production data directory.
Create production objects
Before the first mutation, record this rollback: delete only the objects that
this operation successfully creates and that were proven absent immediately
before creation. Never include a PVC, database, TLS Secret, or filing-site
resource in that rollback.
Repeat the absence check immediately before creation:
test "$(hostname)" = 72602-minipc || {
printf 'wrong host; abort before production write\n' >&2
exit 1
}
test "$(kubectl config current-context)" = default || {
printf 'wrong kubeconfig context; abort before production write\n' >&2
exit 1
}
test "$NAMESPACE" = application || {
printf 'wrong target namespace; abort before production write\n' >&2
exit 1
}
kubectl get node 72602-minipc -o name | grep -qx 'node/72602-minipc'
for name in "${NAMES[@]}"; do
found="$(kubectl -n "$NAMESPACE" get secret "$name" \
--ignore-not-found -o name)"
test -z "$found" || {
printf '%s appeared after preflight; abort the batch\n' "$name" >&2
exit 1
}
done
Create each object through a protected pipe. kubectl create provides the
required no-overwrite behavior:
CREATED=()
CREATE_FAILED=
for name in "${NAMES[@]}"; do
if sudo dd if="$RECOVERY_ROOT/export/$name.json" status=none | \
kubectl create -f - >/dev/null; then
CREATED+=("$name")
printf '%s created\n' "$name"
else
printf '%s failed; stop and assess\n' "$name" >&2
CREATE_FAILED="$name"
break
fi
done
if test -n "$CREATE_FAILED"; then
printf 'created before failure:' >&2
printf ' %s' "${CREATED[@]}" >&2
printf '\n' >&2
false
fi
Do not automatically roll back a healthy recovery. If rollback is explicitly
approved, use the same shell and delete only the names captured in CREATED.
If that shell state is unavailable, stop and reconstruct the list from the
recorded create results and Secret metadata before requesting new approval:
if test "${#CREATED[@]}" -eq 0; then
printf 'CREATED is empty; refuse rollback\n' >&2
false
else
kubectl -n "$NAMESPACE" delete secret "${CREATED[@]}"
fi
Verification
Verify object metadata without requesting .data or .stringData:
for name in "${NAMES[@]}"; do
kubectl -n application get secret "$name" \
-o custom-columns='NAME:.metadata.name,TYPE:.type,CREATED:.metadata.creationTimestamp' \
--no-headers
done
Wait for the existing workloads and endpoints. A Deployment can briefly retain
the incident’s old ProgressDeadlineExceeded condition before its Pod becomes
Ready; verify the current replicas and repeat rollout status after the
controller updates the condition.
kubectl -n application rollout status \
statefulset/sub2api-redis-master --timeout=300s
kubectl -n application rollout status deployment/sub2api --timeout=300s
kubectl -n application rollout status deployment/ops-agent --timeout=300s
kubectl -n application get deploy,statefulset,pod -o wide
kubectl -n application get endpointslice \
-l kubernetes.io/service-name=sub2api -o wide
kubectl -n application get endpointslice \
-l kubernetes.io/service-name=ops-agent -o wide
Inspect logs only after applying a credential and connection-string redactor.
Treat any unrecognized output format as unsafe and stop before printing it.
Verify that restart counts remain unchanged during an observation window.
Use the credential already injected into the Ops Agent Pod to verify health
without reading it into terminal output or a command argument:
kubectl -n application exec deployment/ops-agent -c ops-agent -- sh -ceu '
set +x
{
printf "user = \"%s:%s\"\n" \
"$OPENCODE_SERVER_USERNAME" "$OPENCODE_SERVER_PASSWORD"
printf "url = \"http://127.0.0.1:4000/global/health\"\n"
} | curl --silent --show-error --fail --config - | \
jq -e ".healthy == true" >/dev/null
printf "ops-agent health is healthy\n"
'
Verify public routing, DNS, and TLS:
getent ahostsv4 ops.agent.72602.space
getent ahostsv4 token.72602.space
kubectl -n application get ingress ops-agent sub2api-token -o wide
kubectl -n application get certificate \
ops.agent.72602.space-tls token.72602.space-tls
curl -fsS -o /dev/null -w '%{http_code}\n' \
https://token.72602.space/health
curl -fsS -o /dev/null -w '%{http_code}\n' \
https://token.72602.space/api/v1/settings/public
curl -sS -o /dev/null -w '%{http_code}\n' \
https://ops.agent.72602.space/
Run ArgoCD read-only checks from the Ops Agent Pod, where ARGOCD_SERVER and
the readonly ARGOCD_AUTH_TOKEN are injected:
kubectl -n application exec -i deployment/ops-agent -c ops-agent -- \
sh -seu <<'ARGOCD_CHECK'
for app in ops-docs ops-agent sub2api; do
argocd app get "$app" --hard-refresh --insecure --grpc-web -o json | \
jq -r '[
.metadata.name,
.status.sync.status,
.status.health.status,
(.status.sync.revision // "")
] | @tsv'
done
ARGOCD_CHECK
Cleanup
After the production create has completed or the operation has stopped before
write, remove the entire temporary directory. First prove that no process or
listener still uses it, then validate the path before deletion:
RECOVERY_CANON="$(sudo realpath -e -- "$RECOVERY_ROOT")" || {
printf 'cannot resolve recovery path; refuse cleanup\n' >&2
exit 1
}
test "$RECOVERY_CANON" = "$RECOVERY_ROOT" || {
printf 'recovery path is not canonical; refuse cleanup\n' >&2
exit 1
}
test "$(dirname -- "$RECOVERY_CANON")" = /var/lib || {
printf 'unexpected recovery parent; refuse cleanup\n' >&2
exit 1
}
case "$(basename -- "$RECOVERY_CANON")" in
k3s-secret-recovery.????????) ;;
*) printf 'unexpected recovery path; refuse cleanup\n' >&2; exit 1 ;;
esac
test "$(sudo stat -c '%u:%a' -- "$RECOVERY_CANON")" = 0:700 || {
printf 'unexpected recovery owner or mode; refuse cleanup\n' >&2
exit 1
}
if ps -eo args= | grep -F "$RECOVERY_ROOT" | grep -v grep >/dev/null; then
printf 'temporary process still present; stop before cleanup\n' >&2
exit 1
fi
TEMP_LISTENERS="$(sudo ss -H -lntp \
'( sport = :16443 or sport = :16444 )')" || exit 1
test -z "$TEMP_LISTENERS" || {
printf 'temporary listener still present; stop before cleanup\n' >&2
exit 1
}
if ip link show recovery0 >/dev/null 2>&1; then
printf 'temporary link still present; stop before cleanup\n' >&2
exit 1
fi
sudo rm -r -- "$RECOVERY_CANON"
sudo test ! -e "$RECOVERY_CANON"
test "$(kubectl get --raw=/readyz)" = ok
The listener and link gates must both pass before deletion. The private
namespace exit normally removes them automatically. Keep the approved etcd
snapshot in the normal k3s snapshot directory; cleanup removes only the
isolated restore environment and exported Secret material.
2026-08-02 recovery record
- Live identity was
72602-minipc, context default, node
72602-minipc Ready at 192.168.0.25, k3s v1.34.6+k3s1. - Snapshot
etcd-snapshot-72602-minipc-1785600004 was
readyToUse=true, size 20164640, created at
2026-08-01T16:00:04Z. Secret encryption at rest was disabled. - Both production absence checks passed. All nine whitelist objects were
created with create-only semantics; no object was overwritten and no
credential was written to Git.
- Kubelet recovered the existing Pods without deletion or a manual restart.
Ops Agent reached
2/2 Ready, Sub2API and its Redis reached 1/1, and a
90-second stability observation showed no additional restart. - PostgreSQL remained
1/1 on its existing 95-day Pod in the separate
database namespace. The post-prune replacement Ops Agent, Sub2API, and
Redis PVCs remained Bound during Secret restoration; the pre-incident
opencode-data, Redis AOF, sub2api-data, and filing-site photos local-path
contents had no snapshot and were not recovered. Filing-site and its excluded
Secrets were not restored. - Ops Agent health was healthy; OpenAI and Grok model checks returned HTTP
200. Sub2API cluster and public health returned 200; its public settings
endpoint returned 200. Ops Agent public access returned authenticated
200 and expected anonymous 401. - Both active hostnames resolved to
47.110.67.161. Their nginx Ingresses and
lets-encrypt certificates were Ready. ArgoCD reported ops-docs,
ops-agent, and sub2api as Synced and Healthy. - The only remaining application warning was a missing pricing record for
gemma4:31b; it did not block model requests or service health. - Cleanup removed the temporary data, token copy, exports, and logs. No
temporary process, listener, or dummy link remained, and production k3s was
still active and ready.
ECS Security Group
安全组 IP 自动更新
背景
72602-minipc 的 ISP 不定期更换公网 IP,而阿里云 ECS (ecs-99) 安全组限制了 SSH 端口只能从特定 IP 访问。
当公网 IP 变化时:
- SSH 反向隧道断开
- 无法通过
ssh aaron@47.110.67.161 -p 10022 访问 - 无法直接
ssh root@47.110.67.161
解决方案
定时检测公网 IP,变化时统一协调两处 consumer:阿里云 ECS 安全组的 TCP
22 / 10021 / 10022 / 3128 / 56396 与 UDP 51820 规则,以及 ECS
本机 UFW 的 51820/udp(comment wg 72602-minipc)与 3128/tcp
(comment squid 72602-minipc)规则。所有云端写操作统一从 72602-minipc
上的同一个 5 分钟 systemd timer 发起;ECS 上只放一个最小化、root-only 的
forced-command 助手负责 UFW 这一侧。
ECS UFW 助手只固定管理两条规则:51820/udp(WireGuard)和 3128/tcp
(Squid 公共前向代理),分别通过 UFW comment wg 72602-minipc 和
squid 72602-minipc 识别。云端 56396/tcp(mihomo/clash 外网面板)
当前只纳入 update-sg-ip-72602-minipc 的云安全组维护范围;ECS UFW 仍为
broad allow,暂未对 56396 做 IP 收窄。后续如要把 56396 真正转为 IP 白名单,
需要单独变更并评估与现网 0.0.0.0/0 行为之间的差异,本页不主张在
update-sg-ip 内一并实现。
工作原理
统一的动态 IP 协调器只跑在 72602-minipc 上的一个 5 分钟 systemd timer
(update-sg-ip.timer / update-sg-ip.service)。每次执行都用 flock
串行化,保证同一时刻只有一个进程推进状态:
每 5 分钟 ──> flock 串行化
│
└── 获取公网 IPv4 (curl -4,按固定顺序逐个 fallback)
│
├── 全部失败 ──> 写入 journald,不推进状态
│
└── 获取成功 ──> 校验返回内容是合法 IPv4
│
├── 校验失败 ──> 写入 journald,不推进状态
│
└── 通过校验 ──> 仅与已记录的「两端都已核实」结果对比
│
├── 未变化 ──> 退出,不动 SG / UFW / 状态
└── 已变化 ──> 进入「先建新、再验证、后清理」
│
1. 通过 Aliyun SDK 在安全组内写入新 /32
(TCP 22 / 10021 / 10022 / 3128 / 56396 与 UDP 51820)
2. 通过专用受限 SSH key(路径仅运行时存在)
以 root 身份调用 ECS 端的 forced-command 助手
/usr/local/sbin/72602-wireguard-ufw-reconcile
仅调整 `51820/udp`(comment `wg 72602-minipc`)
与 `3128/tcp`(comment `squid 72602-minipc`)
的 UFW 规则
3. 两个 consumer(Aliyun SG 与 ECS UFW)都验证生效后
才写入持久状态并清理旧的 updater-owned 规则
│
└── 任意一步失败 ──> 新规则保留,
旧 managed 规则不删除;
下一次重试幂等
公网 IP 探测使用 curl -4、有限重试和五个固定 endpoint:保留的
ifconfig.me、ip.sb、icanhazip.com,以及现场验证通过的
ifconfig.co/ip、ipinfo.io/ip。脚本会收集全部结果;至少两个 endpoint 返回
相同的合法 IPv4 才会被采纳。单个结果或全失败都只写入 journald,不修改云
安全组、不推进缓存,并记录各 endpoint 的阶段性错误或结果。
探测失败使用持久的连续失败计数:达到三次才尝试一次
[ZJLAB] public IPv4 detection failed 通知,失败期间不重复刷屏;quorum 恢复
后只尝试一次恢复通知。通知失败不改变任务退出判断。成功 heartbeat 仍保持三天
一次,失败计数与告警状态文件位于现有持久状态目录且权限为 0600。
受影响的端口
| 端口 | 协调器写入的位置 | 由谁验证 |
|---|
TCP 22 | Aliyun 安全组 /32 规则 | update-sg-ip.service 通过 Aliyun SDK 描述与对比验证 |
TCP 10021 | Aliyun 安全组 /32 规则 | update-sg-ip.service 通过 Aliyun SDK 描述与对比验证 |
TCP 10022 | Aliyun 安全组 /32 规则 | update-sg-ip.service 通过 Aliyun SDK 描述与对比验证 |
TCP 3128 | Aliyun 安全组 /32 规则 + ECS UFW /32 规则(comment squid 72602-minipc) | Aliyun SDK 与 ECS UFW helper 两侧都需要「先建新 + 验证生效」才算落地 |
TCP 56396 | Aliyun 安全组 /32 规则 | update-sg-ip.service 通过 Aliyun SDK 描述与对比验证(仅云端;ECS UFW 当前仍 broad allow,暂未收窄) |
UDP 51820 | Aliyun 安全组 /32 规则 + ECS UFW /32 规则(comment wg 72602-minipc) | Aliyun SDK 与 ECS UFW helper 两侧都需要「先建新 + 验证生效」才算落地 |
51820/udp 与 3128/tcp 是「双 consumer」:Aliyun 安全组由
update-sg-ip.service 写入;ECS 本机的 UFW 规则由 update-sg-ip.service
通过专用受限 SSH key 调用 ECS 上 root-only 的 forced-command 助手
/usr/local/sbin/72602-wireguard-ufw-reconcile 调整。两个 consumer 都验证
生效后,协调器才清理旧的 updater-owned 规则并落盘持久状态。任意一侧失败都会
让新规则保留、旧 managed 规则保留到下一次重试,重试本身幂等。
56396/tcp 是「单 consumer」:只写 Aliyun 安全组;ECS UFW 仍维持当前
broad allow。协调器不会去收窄 UFW 这一侧;任何把 56396 真正变成 IP 白名单
的变更都需要单独评审,并不会通过 update-sg-ip 顺带实现。
双 consumer 协调与安全的部分失败
51820/udp(WireGuard)和 3128/tcp(Squid 公共前向代理)在公网路径上各
有两层入口:阿里云安全组的 /32 规则(云端边界)和 ECS 本机 UFW 的 /32
规则(实例边界)。只更新其中一层,目标端口的流量仍可能在另一层被丢包,
因此协调器把两者视作一个事务来推进:
- 先建新规则:协调器先按 Description
update-sg-ip-72602-minipc 在
Aliyun 安全组中写入新 /32,并通过 SSH 调用 ECS 上的
72602-wireguard-ufw-reconcile 在 UFW 中加入新 /32:51820/udp
使用 comment wg 72602-minipc,3128/tcp 使用 comment
squid 72602-minipc。 - 两边都验证:协调器再次描述安全组、再次触发 UFW helper 的 status
输出,确认所有新规则都已经落地并匹配当前探测到的公网 IP。
- 再清理旧规则:两侧验证都通过后才删除旧 updater-owned 规则,再把
「最近已知 IP + 上一次双 consumer 已核实时间戳」写入持久状态目录。
- 失败回退:只要任意一侧验证失败,协调器就立刻退出,不删除旧规则、
不推进持久状态。新规则保留,下一个 5 分钟周期由协调器幂等重试。
协调器在「IP 未变化」的周期也会跑一次只读的 3128/tcp 与 51820/udp
UFW 一致性检查:发现 UFW 与持久状态不符(例如上一次 IP 变化期间 ECS 侧
helper 调用被中断,或 UFW 被人手改回旧 IP)时,会把当前持久 IP 重新作为
新规则写回 UFW、验证后再清理旧的 managed 规则;幂等性保证重复执行不会
产生重复条目。这条「无变化补齐」逻辑正是 2026-08-27 那次旧 UFW 3128 白名
单未及时刷新故障的直接修复。
这一顺序保证了三件事:
- 新 IP 在协调器认为「完成」之前已经可以同时被云端与本机接受,新规则一旦
落地就不会再被回滚。
- 旧 IP 的访问能力在协调失败时仍然保留,下一次重试会先再次验证新规则、
再尝试清理旧规则,不会出现「只删了旧规则、新规则又没建好」的窗口。
- 整个流程不依赖任何单一调用方的成功响应;任意一次调用失败都不会破坏
协调器与两个 consumer 之间的一致性。
协调器本身是幂等的:Aliyun SDK 写入同 Description / 同 /32 是修改语义,
不会复制规则;UFW 助手按 comment 识别自己负责的规则,重复调用也是修改或
去重,不会复制条目。因此 systemctl start update-sg-ip.service 在五分钟
周期之外被手动触发不会引入脏状态。
文件位置
协调器的所有持久组件都落在 72602-minipc 的 aaron 用户下;ECS 上只放一个
最小化、root-only 的 forced-command 助手。本页不复述任何运行时密钥或
AccessKey 的实际路径。
| 文件 / 资源 | 说明 |
|---|
/home/aaron/bin/update-sg-ip.sh | 协调器主脚本(0755,仅属主可写);使用 flock 串行化并调用 /home/aaron/.local/venvs/alidns/bin/python 跑官方 Aliyun ECS / VPC SDK |
/home/aaron/.local/venvs/alidns/bin/python | 官方 Aliyun ECS / VPC SDK 的 approved virtualenv;当前脚本通过它发请求,不再使用系统 Python |
/home/aaron/.aliyun-keys | 阿里云 AccessKey(0600,仅属主可读写),在 SDK 进程内被 source;当前同时具备 ECS 安全组和 AliDNS 权限,后续应拆分为最小权限 RAM 身份 |
/etc/systemd/system/update-sg-ip.service | 72602 系统级 systemd service(User=aaron) |
/etc/systemd/system/update-sg-ip.timer | 72602 系统级 systemd timer(OnBootSec=30、OnUnitActiveSec=5min、Persistent=true);唯一调度源 |
/home/aaron/.config/systemd/user/update-sg-ip.service | ZJLAB 用户级 systemd service |
/home/aaron/.config/systemd/user/update-sg-ip.timer | ZJLAB 用户级 systemd timer(OnUnitActiveSec=5min、Persistent=true,链接位于 timers.target.wants) |
/home/aaron/.local/state/update-sg-ip/ | 持久状态目录:最近已知 IP、上一次「两端都已核实」的时间戳、连续探测失败计数与告警状态;状态文件 0600,仅属主可读写 |
update-sg-ip.service / update-sg-ip.timer 的 journald | 失败原因、API 退出码、是否推进状态等副作用日志 |
/usr/local/sbin/72602-wireguard-ufw-reconcile | ECS 上 root-only forced-command 助手;只接受来自专用受限 SSH key 的连接,仅调整 51820/udp(comment wg 72602-minipc)和 3128/tcp(comment squid 72602-minipc)两条 UFW 规则,不开放 shell / port forwarding;源 IP 取自 ECS 上看到的实际 SSH_CONNECTION |
| ECS 端的专用受限 SSH key | 路径与权限仅在运行时存在;本页面与版本控制都不复述绝对路径 |
/tmp 下不再保留持久状态;断电或重启后历史只在持久目录里。安全组的旧
Description 归属(auto-updated-ip)早已下线,新的 updater-owned 规则一律
按 update-sg-ip-72602-minipc / update-sg-ip-zjlab 描述字段识别。
Live 部署快照(已核实事实;2026-08-13 审计,动态状态需 live verify)
72602 live(2026-08-16 21:29 +08 观察点):
update-sg-ip.service 与 update-sg-ip.timer 已部署在
/etc/systemd/system/;User=aaron。- timer 配置:
OnBootSec=30、OnUnitActiveSec=5min、Persistent=true,
当前唯一调度源。 - 已执行
daemon-reload;systemctl status update-sg-ip.timer 处于
active (waiting);手动运行 update-sg-ip.service 一次成功。 - 脚本
/home/aaron/bin/update-sg-ip.sh 权限 0755,使用 flock 串行化;
当前通过 approved virtualenv /home/aaron/.local/venvs/alidns/bin/python
调起官方 Aliyun ECS / VPC SDK,不再走系统 Python。 - 持久状态目录
/home/aaron/.local/state/update-sg-ip/,文件 0600。 - ECS 上的 root-only forced-command 助手
/usr/local/sbin/72602-wireguard-ufw-reconcile 已部署,固定管理两条 UFW
规则:51820/udp(comment wg 72602-minipc)和 3128/tcp(comment
squid 72602-minipc);仅接受来自专用受限 SSH key 的 forced-command
调用,不开放 shell / port forwarding / Agent forwarding;该 SSH key 的私钥
路径与权限仅运行时存在,文档不公开。 - 当前观察到的 72602-minipc 公网 IPv4 为
122.231.144.126,但这只是某一次
观察点,不应作为永久期望值;任何「当前公网 IP 应为 X」的判断都要重新探测
后再写。 - WireGuard handshake fresh,HAProxy 正常服务 minipc_wg,SSH Web 备份路径
健康;公网
port.72602.space 与 ops.docs.72602.space 均为 HTTP 200、
TLS 校验通过。
ZJLAB 与仓库模板:ZJLAB 上的同名用户级 updater,以及本仓库私有模板,当前
只维护 TCP 22、10021、10022,不维护 UDP 51820。两套脚本虽然文件名
相同,但端口集合、部署层级和运行时凭据来源不同;同步或恢复前必须按目标主机
逐项审阅,不能互相替换。
ZJLAB live:
- 用户级
~/.config/systemd/user/update-sg-ip.service 与 .timer
已写入;timers.target.wants/ 下存在 enabled 链接。 - timer 调度与 72602 相同(
OnUnitActiveSec=5min、Persistent=true)。 - 脚本
/home/aaron/bin/update-sg-ip.sh 权限 0755;持久状态目录
/home/aaron/.local/state/update-sg-ip/。 - 脚本通过
bash -n;两个 user unit 通过 systemd-analyze verify。本批次变更
未修改 user unit 文件,也未对其执行 daemon-reload。 - 当前 user timer 为
enabled、active (waiting),仍按五分钟调度;非交互 SSH
需要设置用户运行时目录后才能连接 user manager。
安全组规则 Description 归属(已核实)
本次仅按 ModifySecurityGroupRule 在 ECS 控制台原地改写 Description 字段;协议、端口、CIDR、优先级、Policy、Direction 均未变更,未使用 RevokeSecurityGroup + AuthorizeSecurityGroup 组合。本节不出现 RuleId、真实 IP 或备份绝对路径。
当前自动更新器在 ECS 上以如下 Description 识别自己负责的规则:
| 脚本 | Description | 负责的协议/端口 |
|---|
update-sg-ip-72602-minipc | update-sg-ip-72602-minipc | 72602-minipc 当前自动更新的 TCP 22 / 10021 / 10022 / 3128 / 56396 与 UDP 51820(其中 3128 与 51820 在 ECS UFW 也有对端规则,56396 仅云端维护) |
update-sg-ip-zjlab | update-sg-ip-zjlab | ZJLAB 出口来源的 TCP 22 / 10021 / 10022 白名单;ZJLAB 的 10023 / 10024 仍为 ECS loopback-only listener,不应有公网规则 |
迁移与兼容要点(已核实):
- 旧的
zjlab-ubuntu-SSH 目标规则当前已迁移为新 Description(update-sg-ip-zjlab),新写入按新 Description 归属。 - 此前遗留的 8 条
auto-updated-ip 规则已经逐条审计并清理:无法证明仍在使用的历史来源已删除;仍有连接证据的 72602/ZJLAB 来源规则改为对应的新 Description;仅绑定 ECS loopback 的 10023 / 10024 规则也已删除。当前 live 安全组中 auto-updated-ip 为 0 条。当前 10023 / 10024 不属于公网访问面。 - 72602 live 脚本后续新增规则统一使用新 Description 写入,再按 Description 归属做替换与去重。
排障/审计提示:
- 描述归属是审计依据,但不是访问控制字段;脚本仍只按来源 IP 维度做替换与去重,不依赖 Description 进行授权判定。
- 调整 Description 不会改变
ModifySecurityGroupRule 调用语义;若要恢复旧描述或回退历史规则,请先在 ECS 控制台人工确认目标 RuleId 当前的协议/端口/CIDR/优先级/Policy/Direction,再按原值 ModifySecurityGroupRule 回写 Description。 - 后续如再次出现未知来源或旧 Description 规则,不应仅凭名称自动删除;必须先核对协议、端口、CIDR、优先级、Policy、Direction,以及 ECS 监听和连接日志,再按 RuleId 原地迁移或删除。
可恢复备份
脱敏后的可恢复备份位于私有仓库 ops-private 的隐私目录,包含 scripts/update-sg-ip.sh、两套 systemd unit 的样例、env.example 及对应的 Markdown runbook;提交不包含 AccessKey、钉钉 token 或真实安全组 ID。绝对路径仅保留在 ops-private 内部,本页不复述。
备份操作不会触碰现网脚本、systemd units 或 timer;只有在显式授权下才把备份还原回现网路径。
AliDNS 环境
- 官方 AliDNS SDK 使用独立虚拟环境
/home/aaron/.local/venvs/alidns。 - SDK 或依赖需要下载时,使用 HTTP 代理
http://192.168.0.25:17890。 - 当前凭证具备
72602.space 区域的 AliDNS 记录管理能力,也具备 ECS 安全组变更能力。 - DNS 变更前应限定目标区域和记录,并在变更后分别执行权威 DNS 与公共 DNS 验证。
常用命令
72602 系统级(从 72602-minipc 执行):
# 查看定时器状态
systemctl status update-sg-ip.timer
# 手动触发一次更新
sudo systemctl start update-sg-ip.service
# 查看执行日志
journalctl -u update-sg-ip.service -f
# 手动运行脚本
~/bin/update-sg-ip.sh
# 查看持久状态(最近已知 IP、上一次「两端都已核实」的时间戳等)
ls -l /home/aaron/.local/state/update-sg-ip/
# 检查 approved virtualenv
ls -l /home/aaron/.local/venvs/alidns/bin/python
# 验证 ECS 端的 UFW 规则与 helper 一致(只读)
ssh root@47.110.67.161 'sudo ufw status | grep -E "51820/udp|3128/tcp|wg 72602-minipc|squid 72602-minipc"'
ZJLAB 用户级(从非交互通道;enable-linger 状态需另行确认):
# 查看用户级定时器
systemctl --user status update-sg-ip.timer
# 手动触发一次更新
systemctl --user start update-sg-ip.service
# 查看执行日志
journalctl --user -u update-sg-ip.service -f
钉钉通知
脚本支持钉钉通知。ops-private 恢复模板要求从 mode 0600 的运行时凭据
文件或环境占位符读取通知配置,变量值不应出现在版本控制、日志或本页面里。
2026-08-13 审计发现 72602 live /home/aaron/bin/update-sg-ip.sh 与仓库模板
不一致并包含内嵌的钉钉运行时配置/凭据;本次未输出、复制或修改这些值。后续
应在维护窗口迁移到受权限保护的凭据文件并轮换旧凭据,不能把 live 脚本直接
复制回仓库。
通知端到端送达(钉钉服务器 → 群)无法从主机单独证明。已核实的层面仅是「脚本进入了成功发送路径」:
- 72602:HTTP 层返回成功。
- ZJLAB:当前仅能确认请求进入了成功发送路径,无法断言到达对端。
只有在「两端都已核实」之后,通知才会被发出;单边成功(SG 写入成功但 UFW 助手失败,或反之)不构成成功完成。
凭证安全
阿里云 AccessKey 存储在 /home/aaron/.aliyun-keys,权限 0600,仅在官方
Aliyun SDK 进程内被 source,绝不打字、复制或写入 Git。当前同一 AccessKey
同时具备 ECS 安全组和 AliDNS 变更权限;后续应拆分为两个最小权限 RAM
身份。AccessKey 获取:阿里云控制台 → 头像 → AccessKey 管理。建议定期轮换。
脚本 /home/aaron/bin/update-sg-ip.sh 权限已为 0755,仅属主可写。当前
通过 approved virtualenv /home/aaron/.local/venvs/alidns/bin/python 调起
官方 SDK(ECS / VPC / AliDNS),不再走系统 Python;依赖下载时仍可走
http://192.168.0.25:17890 代理。
ECS 上用于调用 UFW 助手的 SSH key 只授权单一 forced-command
(/usr/local/sbin/72602-wireguard-ufw-reconcile),不携带 shell、不支持
端口转发 / Agent forwarding;其私钥路径与权限仅在运行时存在,本页面不复述。
排障与验证
依次用下面命令定位问题,每一步都不会改动远端:
# 1. systemd 单元是否加载、timer 是否 active/waiting
systemctl status update-sg-ip.timer
# 2. 最近一次运行的输出与退出码
sudo journalctl -u update-sg-ip.service -n 200 --no-pager
# 3. 公网 IPv4 获取是否仍正常(独立于 systemd)
curl -4 --max-time 5 -s https://ifconfig.me; echo
curl -4 --max-time 5 -s https://ip.sb; echo
curl -4 --max-time 5 -s https://icanhazip.com; echo
# 4. 当前持久状态与上次「两端都已核实」的时间戳
stat -c '%n %y' /home/aaron/.local/state/update-sg-ip/*
# 5. Aliyun 安全组中 updater-owned 规则的实际状态(只读)
# 仅按 Description 过滤,不打印真实 IP/RuleId
ssh root@47.110.67.161 'sudo /usr/local/sbin/72602-wireguard-ufw-reconcile status || true'
# 6. ECS UFW 中 helper 管理的两条规则的实际状态(只读):
# `51820/udp` 走 comment `wg 72602-minipc`,`3128/tcp` 走 comment `squid 72602-minipc`
ssh root@47.110.67.161 'sudo ufw status | grep -E "wg 72602-minipc|squid 72602-minipc"'
# 7. Squid 自身监听(只读),确认 `:3128` 仍在 ECS 上服务
ssh root@47.110.67.161 'sudo ss -ltnp | grep ":3128 "'
# 8. 走 ECS 公网 `47.110.67.161:3128` 的代理连通性(明确不经过集群内任何代理)
ssh root@47.110.67.161 'curl --noproxy "*" -sS -o /dev/null -w "%{http_code}\n" \
--max-time 8 -x http://127.0.0.1:3128 https://ifconfig.me/ip'
补充说明:
- TCP
56396 当前只纳入 72602 云安全组 update-sg-ip-72602-minipc 的
updater 范围,ECS UFW 仍维持 broad allow;协调器不会去收窄 UFW 这一
侧,因此排障时不要把它和 wg 72602-minipc / squid 72602-minipc 放在同
一条 UFW 命令里期待出现匹配条目。如要把 56396 真正变成 IP 白名单,需要
在 helper 与协调器之间单独追加变更,不在本页说明。 - 步骤 8 的
curl --noproxy '*' 是为了直接验证 ECS 自己的 Squid 是否真的在
接受流量,不等同于独立公网观测点。本环境目前没有独立公网探测机,不能声
称独立公网访问已验证;任何对该结论的主张都需要外部观测数据。
判定要点:
- 如果定时器未处于
active (waiting),先看 journalctl 里是否含单元语法/路径错误;不要直接重写 unit,先核对 systemd-analyze verify。 - 如果 IPv4 全部失败,证实问题在出网路径而非本脚本;优先检查 ISP 与
192.168.0.25:17890 代理。 - 如果 journald 显示「安全组写入成功但 UFW 助手失败」或反之,说明只完成了一半 consumer;此时新规则已经生效、旧 managed 规则不会删除,等待下一个 5 分钟周期由协调器重试,不需要立刻手工调整。
- 如果发现
3128/tcp 或 51820/udp 的 ECS UFW 中存在两条 updater-owned
/32(旧的 IP 与当前 IP),说明上一次 IP 变化期间 UFW 这一侧未完成「先
建新 + 验证 + 清理旧」;当前协调器在「IP 未变化」的周期也会做幂等补齐,
请等待一个 5 分钟周期并复查;如果持续多条 managed /32 共存,请先按
helper 的 status 输出确认实际状态再决定是否人工介入,不要直接删除。 - 不要在「两端都已核实」之前手工删除旧 managed 规则;否则下一次 IP 变化会同时出现旧规则缺失 + 新规则被建,造成双 consumer 一致性窗口被绕过。
- 协调器本身是幂等的:每 5 分钟周期都会重新评估,新规则重复写入会被 Aliyun SDK / UFW 助手去重。
回滚原则
回滚的目标只是把协调器本身(unit、timer、脚本、approved virtualenv、持久
状态目录、ECS UFW 助手)恢复到上一份已审核版本,不应回滚阿里云安全
组或 ECS UFW 远端规则,也不应暴露任何备份的绝对路径或 SSH key 路径。
在确认新的协调路径能跑通之前,不要主动删除当前 ECS 22/10021/10022、
3128/tcp 或 51820/udp 的允许 IP,否则可能把自己从 ECS 端断掉。
一般顺序:
systemctl stop update-sg-ip.timer(必要时连同 .service),冻结调度。- 从
ops-private 隐私目录取出对应文件原样覆盖到现网路径,并恢复属主 aaron 与原权限(脚本 0755,虚拟环境与持久状态目录 0600 / 0700)。 systemctl daemon-reload(系统级)或 systemctl --user daemon-reload(用户级)。- 重启 timer 并通过
systemctl status 与 journalctl 复核 unit 已被识别。 - 在 ECS 上复核
/usr/local/sbin/72602-wireguard-ufw-reconcile 的版本与权限(root-only、0700),确认其 authorized_keys 条目仍指向专用受限 key 而非 root 登录 key。 - 安全组 / UFW 规则若需手动恢复到旧的允许 IP,应通过
ops-private 内的官方 AliDNS / 阿里云 SDK 流程,不在本页复述参数。
如果回滚过程中发现协调逻辑本身可疑,先保留旧的 systemd unit 与旧脚本,把
问题记到新 issue 而不是就地修改 live 协调路径。
紧急临时访问设计(尚未部署)
当前安全组只允许已登记的 72602 与 ZJLAB 出口来源访问 ECS 的 TCP 22、
10021、10022;端口用途仍分别由 72602 主/备入口和 ECS sshd 决定。ZJLAB
的 10023 / 10024 监听器仅在 ECS loopback 上提供 ProxyJump 目标,不应通过
安全组公开。紧急访问不建议使用无认证的传统端口敲门序列;序列可被监听、重放
或扫描。推荐使用一个独立的、仅密钥认证的 SSH gate:
- ECS 单独监听一个 gate 端口,例如 TCP
2222;该端口只允许专用用户 sg-gate,不提供 shell、PTY、端口转发或 Agent forwarding。 sg-gate 只接受一把独立的、带密码短语的 emergency key。认证成功后由 forced command 读取 SSH_CONNECTION 的实际来源 IP,不接受用户自行传入任意 IP。- 默认只为该来源 IP 添加 TCP
22 的 /32 临时规则,Description 使用 emergency-ssh-<request-id>;如确实要访问 72602 反向入口,必须显式选择只包含 10021 / 10022 的 tunnel profile,不默认开放,也不得选择 10023 / 10024。 - 临时授权最大有效期固定为 3600 秒。授权器保存 RuleId、来源、端口和 UTC 到期时间;root-only 的过期任务每分钟扫描并按 RuleId 删除,重启后先执行一次过期清理。删除失败必须重试并告警,不能只依赖启动授权的 SSH 会话。
- 授权、续期和删除都要记录审计日志;重复请求不得创建重复规则。用户 IP 发生变化时,必须从新 IP 重新执行 gate。
用户侧操作流程(部署后):
# 1. 用独立 emergency key 认证 gate;源 IP 由 ECS 自动识别
ssh -p 2222 -i ~/.ssh/ecs-emergency-gate sg-gate@47.110.67.161 grant
# 2. 使用原来的 ECS 管理 key 连接真正的 SSH 服务
ssh -i ~/.ssh/ecs-admin root@47.110.67.161
这个流程需要一个独立的云端控制路径。若授权器放在 ECS 上,必须使用只允许目标安全组读取、添加和删除规则的独立 RAM 身份,凭据仅由 root 读取,不能复用当前同时拥有 AliDNS 权限的主密钥。若坚持所有云变更只从 72602-minipc 发起,则 gate 可以通过现有反向隧道请求 minipc 执行,但 72602 与 ECS 的桥梁同时中断时紧急入口也会失效,不能满足真正的灾备目标。
传统 knockd 端口序列可以作为低成本触发器,但不应作为唯一认证。若不开放独立 SSH gate,可改用带时间戳、随机数和 MAC 的 SPA(例如 fwknop)触发同一个授权器;无论采用哪种触发方式,云端规则都必须由持久过期任务按 RuleId 删除。
该设计目前仅记录方案,尚未开放 gate 端口、创建 emergency key、创建 RAM 身份或部署授权器。
Recent Operations
2026-08-27: 72602 出口 IP 变化导致 ECS UFW 3128 白名单失同步
- 现象:72602-minipc 出口公网 IP 变化后,Aliyun ECS 安全组的 TCP
22 /
10021 / 10022 / 3128 / 56396 与 UDP 51820 已由
update-sg-ip-72602-minipc 按 Description 全部刷新到新 IP;但 ECS 本机
UFW 中 3128/tcp(旧 squid 72602-minipc 注释的 /32)未同步刷新,
新 IP 通过 ECS 公共 Squid 47.110.67.161:3128 出网时出现连接阶段超时。
旧 51820/udp UFW 规则在本次 IP 变化中由协调器按时刷新,未受影响。 - 修复:协调器在「IP 未变化」的周期补做一次
51820/udp + 3128/tcp UFW
一致性检查:以持久状态目录记录的最近已知 IP 与 ECS UFW 中
wg 72602-minipc / squid 72602-minipc 注释的 /32 做比对,发现差异时
把当前持久 IP 重新作为新规则写回 UFW、验证后再清理旧 managed 规则;调
用逻辑仍然走 ECS 上 root-only 的 forced-command 助手
/usr/local/sbin/72602-wireguard-ufw-reconcile,保留「先建新 + 验证 + 清理
旧」顺序,并依赖 Aliyun SDK / UFW 助手自身去重保证幂等。 - 已核实(2026-08-27):
- Aliyun ECS 安全组按 Description
update-sg-ip-72602-minipc 描述验证:
新 IP /32 已落地,旧 IP /32 已清理; - ECS UFW 中
51820/udp(comment wg 72602-minipc)与 3128/tcp
(comment squid 72602-minipc)的 /32 均与持久状态目录中的最近
已知 IP 一致; - Squid 在 ECS
:3128 上仍正常监听(root-only ss 验证),与故障前
行为相同; - 通过 ECS 上
curl --noproxy '*' -x http://127.0.0.1:3128 https://ifconfig.me/ip 确认 Squid 仍能向出口出网并取得与持久状态一
致的公网 IPv4。
- 未核实:本环境当前没有独立公网探测机,不能把上述验证等同于独立公网访
问已通过;任何对外部独立访问性的主张都需要外部观测数据。
- 未触碰:Kubernetes、安全组旧
auto-updated-ip 历史规则、SSH 反向隧道、
HAProxy、Mailu、wg-quick@wg0、凭据、AccessKey 或 token。回滚保持通用
顺序,不要先于新协调路径验证就主动删除 ECS 22/10021/10022、
3128/tcp 或 51820/udp 的允许 IP。
2026-08-27: ZJLAB IPv4 探测多 endpoint curl 28 风暴后自愈
- 当前 ZJLAB 用户级
update-sg-ip.service 在一个观测窗口内出现三个或多个
endpoint 同时返回连接阶段 curl 28 超时,随后自动恢复;该窗口内 ZJLAB
出口公网 IP 未发生实际变化,未触发任何云端写操作,未推进持久状态。 - 已部署行为:
- 5 个 endpoint 参与探测(保留
ifconfig.me / ip.sb / icanhazip.com,
新增现场验证通过的 ifconfig.co/ip / ipinfo.io/ip); - 至少两个 endpoint 返回同一合法 IPv4 才会被采纳;
- 连续三次探测失败才发送一次
[ZJLAB] public IPv4 detection failed
通知,恢复后只发送一次恢复通知; - 失败 / 恢复通知在 DingTalk 上若有 pending 状态,会在下一个健康周期
重试,不会在日志里重复刷屏。
- 当前事实:ZJLAB 出口 IP 未变化,云端 AliDNS / 安全组 / UFW / WireGuard
/ SSH 反向隧道均未触发更新;通知凭据与 endpoint 凭据不在本页面复述。
后续若窗口再次扩大或某个 endpoint 持续不可达,先看 journald 与 dingtalk
通知侧的实际状态,再决定是否调整 endpoint 列表或告警阈值。
2026-08-20: ZJLAB public IPv4 detection quorum and alert debounce
- 通过批准的
zjlab-ubuntu-local 路径修复了 ZJLAB 用户级 updater 的诊断和告警质量。
旧逻辑按 endpoint 顺序采纳首个成功结果;本次观测到既有 endpoint 在一段时间内
同时出现连接阶段 curl 28 超时,恢复后无需云端动作。 - 保留
ifconfig.me、ip.sb、icanhazip.com,新增并现场验证
ifconfig.co/ip、ipinfo.io/ip。现在至少两个 endpoint 返回同一合法 IPv4
才会推进后续协调;单个或全失败不修改安全组、不推进缓存,并记录 endpoint
阶段性结果。 - 持久失败计数在连续第三次失败时才尝试一次失败通知,恢复后只尝试一次恢复
通知;通知失败不改变任务退出判断。成功 heartbeat 仍为三天一次。
- 未修改 Kubernetes、安全组、SSH 隧道、凭据、端口列表或 Description 逻辑。
脚本保持
0755;脚本与两个 user unit 的 0600 回滚备份保存在 ZJLAB 用户
的持久状态目录下,实际备份位置不在本页复述。 bash -n、两个 user unit 的 systemd-analyze verify 以及不触发云写 API 的
受控测试均通过,覆盖 quorum、单结果不足 quorum、全失败、三次失败单告警和
恢复单通知。
2026-08-16: unified 72602 dynamic-IP reconciliation deployed
- 单一 5 分钟 systemd timer(
update-sg-ip.timer / update-sg-ip.service)
现在统一协调两处 consumer:Aliyun ECS 安全组的 TCP 22 / 10021 /
10022 与 UDP 51820 规则,以及 ECS 本机 UFW 的 51820/udp 规则
(comment wg 72602-minipc)。ZJLAB 上的同名 user timer 仍是各自
environment 内的独立调度源。 - 协调器使用
flock 串行化;通过 approved virtualenv
/home/aaron/.local/venvs/alidns/bin/python 调起官方 Aliyun ECS /
VPC SDK,不再走系统 Python。脚本本身仍位于 /home/aaron/bin/update-sg-ip.sh
(0755)。 - ECS UFW 这一侧由一个 root-only forced-command 助手
/usr/local/sbin/72602-wireguard-ufw-reconcile 负责;该助手仅调整
51820/udp UFW 规则,仅接受来自专用受限 SSH key 的调用,不开放 shell
/ port forwarding / Agent forwarding,源 IP 取自 ECS 上看到的实际
SSH_CONNECTION。专用 SSH key 的私钥路径与权限仅运行时存在,本页面与
版本控制都不公开其绝对路径。 - 协调器采用「先建新规则 → 两边分别验证生效 → 再清理旧 updater-owned
规则并落盘持久状态」的顺序。任意一侧验证失败都会让新规则保留、旧 managed
规则保留到下一次重试,重试本身幂等;持久状态目录
/home/aaron/.local/state/update-sg-ip/ 只在「两端都已核实」之后才推进。 - 验证点(2026-08-16 21:29 +08):
update-sg-ip.timer 仍处 active (waiting);- 公网 IPv4 探测从
ifconfig.me / ip.sb / icanhazip.com 都返回
122.231.144.126(仅作为该观察点的快照,不作为永久期望值); - ECS UFW 中
51820/udp 规则带有 comment wg 72602-minipc; - WireGuard handshake fresh;HAProxy 正常服务
minipc_wg;SSH Web
backup 路径健康; - 公网
https://port.72602.space/ 与
https://ops.docs.72602.space/ 均 HTTP 200 且 TLS 校验通过。
- 回滚保持通用顺序(停 timer / 从
ops-private 取备份覆盖 / daemon-reload
/ 重启 timer),不要先于新路径验证就主动删除 ECS 22/10021/10022 或
51820/udp 的允许 IP。
2026-08-11: approved ECS security-group cleanup
- The operation ran through the approved
72602-minipc SSH path. Both ECS
reverse-tunnel entry points (10021 and 10022) authenticated successfully
and returned 72602-minipc. The official ECS SDK was run on that host in
cn-hangzhou; no credential value was printed. A host-local redacted
rollback record was created with mode 0600 before mutation. ModifySecurityGroupRule changed only the Description of the three
39.170.58.206/32 TCP rules for 22, 10021, and 10022 from
auto-updated-ip to update-sg-ip-zjlab. Protocol, port, source, policy,
priority, and direction were unchanged.- By exact RuleId, with a fresh Describe verification after each deletion, the
following were removed: the
36.24.59.216/32 TCP 22/10021/10022
rules; the 39.170.58.206/32 TCP 10023/10024 rules; the
47.110.67.161/32 TCP 10021/10022 rules owned by
update-sg-ip-zjlab; and the 0.0.0.0/0 TCP 22 system-created rule.
The live Description of the final rule included a trailing period and was
matched by its RuleId after Describe. No unrelated port rule was changed. - Final Describe confirmed that TCP
22, 10021, and 10022 have only
36.24.58.213/32 (update-sg-ip-72602-minipc) and
39.170.58.206/32 (update-sg-ip-zjlab). TCP 10023 and 10024 have no
public security-group rule. The final ingress rule count was 17. - IPv4 checks through
ifconfig.me, ip.sb, and icanhazip.com all returned
36.24.58.213. The 72602 update-sg-ip.timer remained enabled and
active (waiting). The rollback is to review the current fields and restore
only the recorded deleted rules or revert the three descriptions through the
official ECS SDK; do not restore public 10023/10024 rules.
Network Tunnels
72602 使用两类独立隧道;ZJLAB 的 SSH 入口是另一组 ECS loopback-only
监听器,不能与本页的公网 72602 入口混用:
- SSH 主入口:
10021 - SSH 备入口:
10022 - Web 数据通道:WireGuard UDP
51820 - 目标 ECS:
47.110.67.161 (ecs-99)
ZJLAB 使用 ECS ProxyJump 访问 10023(primary)和 10024(backup)。这
两个端口只绑定 ECS loopback,由独立监控进程检查并通过 DingTalk 告警;它们
没有公网安全组规则。
公网 Web 80/443 固定由 ECS HAProxy 监听,经 WireGuard 转发到
72602-minipc 的 ingress NodePort。SSH 不再承载 Web;10022 仍保留 Mailu
loopback forwards。
快速连接命令:
以下命令仅适用于当前登记在 ECS 安全组白名单中的来源客户端,并使用 SSH
密钥认证;10021/10022 不是面向任意公网客户端的开放入口。
ssh -p 10021 aaron@47.110.67.161
ssh -p 10022 aaron@47.110.67.161
上线顺序建议:
- 先在 72602-minipc 创建并启动
10022(备入口) - 验证 ECS 已监听
10022 - 再创建并启动
10021(主入口) - 最后做外网双端口连通性验证
完整步骤、故障恢复与运维命令见子页面。
Subsections of Network Tunnels
72602-minipc → ecs-99
SSH 反向隧道:72602-minipc → ecs-99(双入口)
本文档是 72602-minipc 的当前参考方案;执行前应核对 live unit、ECS 安全组、
SSH banner 和监控状态,目标是避免单端口掉线导致完全失联。
- 主入口:
10021 - 备入口:
10022 - 两个端口由两个独立 service 维护
一、架构
已登记且获安全组授权的来源客户端 ecs-99 (47.110.67.161) 72602-minipc (192.168.0.25)
ssh -p 10021 aaron@47.110.67.161 -> 0.0.0.0:10021 (sshd) --SSH reverse--> localhost:22
ssh -p 10022 aaron@47.110.67.161 -> 0.0.0.0:10022 (sshd) --SSH reverse--> localhost:22
ECS HAProxy :25/:465/:587/:993 -> 127.0.0.1:10225/:10465/:10587/:10993 (sshd)
--SSH reverse--> minipc hostPort :25/:465/:587/:993
说明:反向隧道必须由 72602-minipc 主动发起。0.0.0.0 是 ECS 上
sshd 的 reverse-bind,并不等于对任意公网来源开放;安全组来源限制、SSH
key 认证、已建立的 SSH child/session、banner 和监控恢复都必须分别验证。
二、上线前检查
2.1 在 72602-minipc 检查基础条件
# 1) 本机 SSH 服务
sudo systemctl is-active ssh
# 2) autossh 是否安装
autossh -V
# 3) 本机到 ECS 网络与认证
ssh -o ConnectTimeout=5 root@47.110.67.161 hostname
# 期望输出: ecs-99
2.2 在 ECS 检查前置配置
/etc/ssh/sshd_config 至少包含:
GatewayPorts clientspecified
重载:
sudo systemctl reload sshd
安全组仅应按当前 72602 出口地址来源受限地放行 10021/tcp、10022/tcp。
不要为 ZJLAB 的 10023/tcp、10024/tcp 添加公网规则;那两条 listener
属于 ECS loopback-only 的 ProxyJump 路径。
同时确认 ECS 本机防火墙(UFW)放行这两个端口:
sudo ufw status numbered
# 应包含 10021/tcp 和 10022/tcp 的 ALLOW 规则;公网来源边界由云安全组控制
三、创建双 service(72602-minipc 上执行)
下面步骤全部在 72602-minipc 上执行。
3.1 统一 SSH 客户端配置(可选但推荐)
编辑 ~/.ssh/config:
Host ecs-99
HostName 47.110.67.161
User root
ServerAliveInterval 60
ServerAliveCountMax 3
ExitOnForwardFailure yes
TCPKeepAlive yes
ConnectTimeout 10
3.2 创建 systemd 用户服务目录
mkdir -p ~/.config/systemd/user
3.3 新建 service(10021 主)
文件:~/.config/systemd/user/reverse-tunnel-ecs-10021.service
[Unit]
Description=Reverse SSH tunnel to ecs-99 (port 10021 -> local SSH)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
Environment="AUTOSSH_GATETIME=0"
Environment="AUTOSSH_POLL=60"
Environment="AUTOSSH_FIRST_POLL=30"
ExecStart=/usr/bin/autossh -M 0 -N -R 0.0.0.0:10021:localhost:22 ecs-99
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=default.target
3.4 10022 备入口(含 Mailu)
文件:~/.config/systemd/user/reverse-tunnel-ecs-10022.service
[Unit]
Description=Reverse SSH tunnel to ecs-99 (port 10022 -> local SSH)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
Environment="AUTOSSH_GATETIME=0"
Environment="AUTOSSH_POLL=60"
Environment="AUTOSSH_FIRST_POLL=30"
ExecStart=/usr/bin/autossh -M 0 -N \
-R 0.0.0.0:10022:localhost:22 \
-R 127.0.0.1:10225:127.0.0.1:25 \
-R 127.0.0.1:10465:127.0.0.1:465 \
-R 127.0.0.1:10587:127.0.0.1:587 \
-R 127.0.0.1:10993:127.0.0.1:993 \
ecs-99
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=default.target
说明:10022 只承载备用 SSH 和 Mailu 四个 hostPort。Web 80/443 已迁移
到 HAProxy + WireGuard,不得重新加入该 service,除非按 WireGuard 回滚步骤
临时恢复旧路径。ECS 上的四个 Mailu入口必须是 loopback-only;公网绑定由
HAProxy 完成。
3.5 启用并启动
export XDG_RUNTIME_DIR=/run/user/$(id -u)
systemctl --user daemon-reload
systemctl --user enable --now reverse-tunnel-ecs-10021.service
systemctl --user enable --now reverse-tunnel-ecs-10022.service
systemctl --user status reverse-tunnel-ecs-10021.service --no-pager
systemctl --user status reverse-tunnel-ecs-10022.service --no-pager
3.6 防登出失效(强烈建议)
sudo loginctl enable-linger aaron
loginctl show-user aaron | grep Linger
# 期望: Linger=yes
四、连通性验证
4.1 在 ECS 上看监听
ssh root@47.110.67.161 "ss -tlnp | grep -E '10021|10022'"
期望看到(监听归属示例;地址、来源限制和健康状态需按 2026-08-13 之后的
live 状态复核):
0.0.0.0:100210.0.0.0:10022127.0.0.1:10225, 127.0.0.1:10465, 127.0.0.1:10587, 127.0.0.1:10993 (sshd)0.0.0.0:80, 0.0.0.0:443 (HAProxy)0.0.0.0:51820/udp (WireGuard)
4.2 在外网验证
nc -zv 47.110.67.161 10021
nc -zv 47.110.67.161 10022
ssh -p 10021 aaron@47.110.67.161
ssh -p 10022 aaron@47.110.67.161
若 10021/10022 外网超时,但 ECS 上已监听,优先检查:
- 云安全组来源网段是否覆盖当前出口 IP
- ECS UFW 是否放行对应端口
4.3 Mailu 入口验证
在 ECS 上确认公网 Web/Mail 端口归 HAProxy;10021/10022 SSH listener 和
10225/10465/10587/10993 Mailu loopback backend 归 sshd:
sudo systemctl is-active haproxy
haproxy -c -f /etc/haproxy/haproxy.cfg
sudo ss -ltnp
从不发送邮件数据的测试客户端验证 banner、TLS 和 STARTTLS。relay 检查最多
发送 EHLO、MAIL FROM、RCPT TO、QUIT,不要发送 DATA,不要认证。
同时检查 ArgoCD 与 front rollout:
argocd app get ops-docs --refresh --insecure --grpc-web
argocd app get mailu --refresh --insecure --grpc-web
kubectl -n mailu rollout status deployment/mailu-front
kubectl -n mailu get endpoints mailu-front mailu-front-ext
若 Dovecot 报 Client not trusted,先检查 Mailu realIpFrom 与 hostPort/CNI
实际传输源是否一致。修正必须提交到 Git 并等待 ArgoCD 自动同步,不能手动
patch、apply、sync 或重启 Mailu。
五、故障恢复(按顺序)
场景 A:ECS 本机 ssh localhost -p 10022 失败
这说明 ECS 上没有监听 10022,问题几乎总在源机器(72602-minipc)侧。
在 72602-minipc 执行:
export XDG_RUNTIME_DIR=/run/user/$(id -u)
# 1) 看 service
systemctl --user status reverse-tunnel-ecs-10021.service --no-pager
systemctl --user status reverse-tunnel-ecs-10022.service --no-pager
# 2) 一次只处理一条;先处理 backup,验证 listener/banner/监控恢复后再决定是否处理 primary
TUNNEL_SERVICE=reverse-tunnel-ecs-10022.service
systemctl --user restart "$TUNNEL_SERVICE"
# 3) 看日志
journalctl --user -u "$TUNNEL_SERVICE" --since "10 min ago" --no-pager
# 4) 验证到 ECS 的基础连通
ssh -o ConnectTimeout=5 root@47.110.67.161 echo ok
场景 B:外网超时但 ECS 本机可通
问题在安全组或 ECS 防火墙,不在隧道本身。
ssh root@47.110.67.161 "ss -tlnp | grep -E '10021|10022'"
ssh root@47.110.67.161 "iptables -L INPUT -n | grep -E '10021|10022' || true"
场景 C:重启后隧道没起来
export XDG_RUNTIME_DIR=/run/user/$(id -u)
systemctl --user is-enabled reverse-tunnel-ecs-10021.service
systemctl --user is-enabled reverse-tunnel-ecs-10022.service
loginctl show-user aaron | grep Linger
六、常用运维命令
export XDG_RUNTIME_DIR=/run/user/$(id -u)
# 重载;重启时一次只选择一个入口,验证 listener/banner/监控后再处理另一个
systemctl --user daemon-reload
TUNNEL_SERVICE=reverse-tunnel-ecs-10021.service
systemctl --user restart "$TUNNEL_SERVICE"
# 停止(仅在明确授权的维护窗口内;一次只停止一个入口)
TUNNEL_SERVICE=reverse-tunnel-ecs-10021.service
systemctl --user stop "$TUNNEL_SERVICE"
# 日志实时跟踪
journalctl --user -u "$TUNNEL_SERVICE" -f
七、和现网监控的关系(72602;2026-08-13 审计快照,动态状态需 live verify)
ECS 上原有巡检继续只监控 72602-minipc 的两个公开入口;其端口列表、脚本、
unit、timer 和状态文件保持不变。
当 72602-minipc 双入口上线后,只需确认:
- 安全组已放行
10021/10022 /etc/tunnel-healthcheck-ports.conf 包含 10021、10022/etc/tunnel-healthcheck.env 企业应用参数可用(DINGTALK_CLIENT_ID/SECRET/AGENT_ID/USER_IDS)
按该审计快照记录的 72602 线上实现:
- 告警通道为钉钉企业应用 API(非 webhook)
- 告警消息为 Markdown 格式(标题:
🚨 ECS Tunnel Alert) - 连续失败 3 次才发送告警(防抖)
ZJLAB 的两个私有 loopback listener 不加入上述端口列表,而由独立的
check-only 巡检按 primary、backup 标签检查。该巡检要求每个标签恰有一个
loopback-only listener、唯一 owner 为 sshd、两个 owner 相互独立、短超时
SSH banner 正常且 owner 签名稳定;连续失败 3 次才告警。日志和告警不包含
endpoint、端口、账户、内网拓扑或签名值。巡检不会 restart/kill 隧道,也不会
修改 sshd、防火墙、安全组、DNS 或 endpoint。真实配置和恢复步骤只保存在
私有 SOPS inventory 与 root-only host 配置中。
八、Mailu 代理回滚
按以下顺序回滚,不恢复旧 HAProxy 配置,也不卸载 HAProxy:
# ECS
sudo systemctl stop haproxy
# 72602-minipc(使用实际保存的备份路径)
cp /home/aaron/Ops/ops-private/backups/reverse-tunnel-ecs-10022.service.<UTC>.before-haproxy \
~/.config/systemd/user/reverse-tunnel-ecs-10022.service
export XDG_RUNTIME_DIR=/run/user/$(id -u)
systemctl --user daemon-reload
systemctl --user restart reverse-tunnel-ecs-10022.service
若还原 ECS 的 GatewayPorts 修正,恢复对应的
/var/backups/sshd_config.<UTC>.before-haproxy,执行 sshd -t 后 reload
sshd,再按上面步骤恢复并重启 10022。10021 不在本次回滚范围内。
WireGuard Web Tunnel
WireGuard Web Tunnel
公网 Web 入口固定由 ECS HAProxy 持有,HAProxy 通过 WireGuard 访问
72602-minipc 的 ingress-nginx NodePort。TLS 仍由 ingress-nginx 和
cert-manager 管理;ECS 不复制证书,不启用 PROXY protocol,也不终止 TLS。
Internet TCP 80/443
-> ECS HAProxy
-> primary: WireGuard 10.77.0.1 <-> 10.77.0.2 over UDP 51820
-> minipc TCP 32080/32443 -> ingress-nginx
-> backup: ECS-loopback SSH Web path 127.0.0.1:18080/18443
-> minipc TCP 32080/32443 -> ingress-nginx
The WireGuard and SSH Web paths are alternative HAProxy backends, not a
serial chain. The SSH Web fallback is an independent ECS-loopback service;
its host-local unit and credentials are kept in private host state/
ops-private, not reproduced in this repository.
Reference Configuration (verify live state; audit snapshot 2026-08-13)
| Item | ECS | 72602-minipc |
|---|
| WireGuard address | 10.77.0.1/30 | 10.77.0.2/30 |
| Service | wg-quick@wg0 | wg-quick@wg0 |
| Config | /etc/wireguard/wg0.conf | /etc/wireguard/wg0.conf |
| Public UDP | listens on 51820 | initiates to ECS with keepalive |
| Web role | HAProxy 80/443 | ingress NodePort 32080/32443 |
| SSH Web backup | ECS loopback 18080/18443 | independent fallback service (private host state) |
Both WireGuard configs and private keys are root-only mode 0600. Never print,
copy, commit, or place private keys in a ticket. Public keys are identifiers but
do not need to be published in this handbook.
Firewall
- UDP
51820 在公网路径上有两层入口:阿里云安全组的 /32 规则(云端边界)
和 ECS 本机 UFW 的 /32 规则(实例边界)。两者都由
72602-minipc 上同一个 5 分钟 systemd timer
(update-sg-ip.timer / update-sg-ip.service)协调:安全组由协调器
通过官方 Aliyun ECS / VPC SDK 写入;ECS UFW 规则(comment
wg 72602-minipc)由协调器通过专用受限 SSH key 调用 ECS 上
root-only 的 forced-command 助手 /usr/local/sbin/72602-wireguard-ufw-reconcile
调整。两条规则在协调器「两端都已核实」之前都会被保留。 - 协调器只在 Aliyun 安全组与 ECS UFW 这两个 consumer 都验证生效后才清理旧
的 updater-owned 规则并落盘持久状态;任意一侧失败都会让新规则保留、旧
managed 规则保留到下一次重试,重试本身幂等。协调器在
/home/aaron/.local/state/update-sg-ip/ 写入的最近已知 IP 与上一次
「两端都已核实」时间戳只描述成功的协调结果,不描述 IP 探测成功本身。 - minipc UFW 允许 WireGuard 子网访问 TCP
32080 和 32443。 - Do not expose
32080/32443 through the Aliyun security group.
Health Checks
Run these checks without displaying key material:
# Both hosts
systemctl is-active wg-quick@wg0
systemctl is-enabled wg-quick@wg0
sudo wg show wg0
# ECS listener ownership
sudo ss -ltnup | grep -E ':(80|443|51820) '
sudo haproxy -c -f /etc/haproxy/haproxy.cfg
# ECS -> ingress over WireGuard
curl --resolve port.72602.space:32443:10.77.0.2 \
https://port.72602.space:32443/
# Public strict TLS
curl -fsS -o /dev/null https://port.72602.space/
curl -fsS -o /dev/null https://ops.docs.72602.space/
Expected listener ownership on ECS:
80/443: HAProxy51820/udp: WireGuard kernel interface10021/10022: sshd reverse listeners
The WireGuard handshake alone is not sufficient. A valid handshake with a
failed NodePort or HAProxy backend still breaks Web traffic, so monitor both the
public HTTPS URL and the direct ECS-to-NodePort path.
The ECS host-local Web/tunnel monitor (private runtime configuration; verify it
live before relying on its path or unit name) checks the HAProxy frontend,
WireGuard primary, SSH Web backup, 72602 SSH listeners, and Mail
public/loopback ports. DingTalk receives a message only when the state changes.
The ZJLAB 10023/10024 check-only monitor is independent and is not managed by
this Web monitor:
PRIMARY: WireGuard serves Web and the SSH backup is ready.BACKUP: WireGuard failed and HAProxy automatically uses SSH.PRIMARY_BACKUP_FAILED: Web remains healthy through WireGuard but redundancy
is unavailable.DOWN: no usable Web backend remains or the HAProxy frontend failed._SSH_PORT_FAILURE / _MAIL_FAILURE: the corresponding critical listener
checks failed.
Notifications include the diagnosed layer, active path, whether automatic
service recovery succeeded, timestamp, and host. They use DingTalk plain-text
messages with real line breaks, not escaped \n text.
Runtime Recovery
ECS uses wg-quick@wg0 and /etc/wireguard/wg0.conf as the sole owner of the
WireGuard interface address and peer configuration. Do not add a competing
/etc/systemd/network/10-wg0.network file.
The ECS runtime repair timer 72602-wireguard-runtime-repair.timer runs every
30 seconds after boot. Its root-owned check restarts wg-quick@wg0 only when
the service is inactive, wg0 lacks 10.77.0.1/30, or the route to
10.77.0.2 does not use wg0. A healthy interface is not restarted. Verify it
with:
systemctl list-timers 72602-wireguard-runtime-repair.timer --all
sudo journalctl -t 72602-wireguard-repair --since "-15 min"
systemd-networkd also retries failures with a five-second delay and a
60-second, 12-start limit. The previous outage was not automatically repaired
because networkd’s watchdog restart loop hit its default five-start limit after
repeated 203/EXEC failures, while wg-quick@wg0 is a successful
Type=oneshot unit with RemainAfterExit=yes and no restart policy. That unit
therefore remained active (exited) after its address was lost and had no
runtime address/route check.
Failure Handling
If public Web fails, inspect in this order:
- HAProxy owns
80/443 and its configuration validates. - Both
wg-quick@wg0 services are active and have a recent handshake. - ECS can reach
10.77.0.2:32080 and 10.77.0.2:32443. - ingress-nginx Service, Pod, Endpoint, Host routing, and certificate are healthy.
- The Aliyun security group and ECS UFW still allow UDP
51820 from the current home public IP.
Do not restart reverse-tunnel-ecs-10021.service during Web troubleshooting;
it is the independent 72602 SSH access/rescue path. Keep the 72602 primary and
backup SSH services operationally separate, and do not touch the ZJLAB
system-level 10023/10024 path from this runbook. Do not enable PROXY protocol
on HAProxy unless ingress-nginx is changed in the same reviewed operation.
Automatic Failover
HAProxy marks WireGuard as the primary backend and the independent SSH loopback
path as backup. Checks run every two seconds with fall 2 and rise 2.
The dated controlled test record measured automatic failover in approximately
8-9 seconds and automatic return to WireGuard after recovery. Treat that as a
test observation, not an SLO; existing connections may fail and must reconnect,
while new connections use the healthy path.
The approved SSH Web backup service is referenced as
reverse-tunnel-ecs-web-backup.service in private host state. It must remain
independent from 10021 (72602 SSH) and 10022 (SSH + Mailu). Never bind its
18080/18443 listeners publicly; they are ECS loopback-only.
Emergency Web Rollback (historical pre-migration path)
Use this only when WireGuard cannot be restored promptly and the independent
10021 72602 SSH access path has been authenticated first. This is not the
ZJLAB 10023/10024 ProxyJump path.
- Keep
10021 authenticated and do not stop Mail HAProxy frontends. - Restore the saved HAProxy configuration or the independent SSH Web backup
service from
ops-private; validate with haproxy -c before reload. - If HAProxy itself cannot be restored, only then remove the Web frontends and
restore the pre-migration
10022 unit containing -R 80 and -R 443. - Confirm listener ownership, both 72602 SSH entries, Mail loopbacks, and
strict public TLS.
Do not delete WireGuard keys, uninstall packages, change DNS, or alter
ingress/cert-manager during an emergency Web rollback. The host-local migration
backup is root/private state and must not be copied into Git.
Shared Clash/Mihomo Operations
zjlab-ubuntu and 72602-minipc both use
nelvko/clash-for-linux-install
from /home/aaron/clashctl. This page is the shared host-level proxy runbook;
cluster-specific bridges and application proxy variables remain documented on
their respective cluster pages.
Command Loading
clashctl is a Bash function loaded by /home/aaron/.bashrc, not an executable
installed in PATH. Non-interactive SSH and agent shells do not automatically
load it, so command -v clashctl or a direct clashctl status can incorrectly
report that the command is missing.
Prefer explicit loading in automation:
CLASH_HOME=/home/aaron/clashctl
. "$CLASH_HOME/scripts/cmd/clashctl.sh"
clashctl --help
"$CLASH_HOME/bin/mihomo" -v
clashctl status
bash -ic 'clashctl status' is acceptable for a quick remote check, but it can
emit harmless job-control warnings when SSH has no TTY. Explicit sourcing gives
cleaner, more predictable automation output.
Read-Only Preflight
Run this once on the target host before trying alternate ports, editing proxy
variables, or restarting anything:
CLASH_HOME=/home/aaron/clashctl
. "$CLASH_HOME/scripts/cmd/clashctl.sh"
hostname
"$CLASH_HOME/bin/mihomo" -v
clashctl status
proxy_port="$("$CLASH_HOME/bin/yq" \
'."mixed-port" // .port // 7890' \
"$CLASH_HOME/resources/runtime.yaml")"
printf 'proxy_port=%s\n' "$proxy_port"
curl --proxy "http://127.0.0.1:${proxy_port}" \
--connect-timeout 5 --max-time 12 \
--silent --show-error --output /dev/null \
--write-out 'proxy_http_code=%{http_code}\n' \
https://www.gstatic.com/generate_204
The expected result is one active mihomo process and HTTP 204. This test uses
the configured local port and proves an end-to-end HTTPS request through the
proxy; it is more useful than probing a sequence of guessed ports.
If it fails, inspect the existing log without opening an interactive pager:
tail -n 100 /home/aaron/clashctl/resources/mihomo.log
Do not use clashctl log in unattended work because it invokes less.
Three Independent States
Do not collapse these into one “proxy is enabled” judgment:
clashctl status checks whether the mihomo core process exists.._custom.system-proxy.enable is only the saved proxy preference in
mixin.yaml.HTTP_PROXY, HTTPS_PROXY, ALL_PROXY, and NO_PROXY are environment
variables inherited by the current shell or process.
Inspect the latter two without printing proxy URLs or authentication:
CLASH_HOME=/home/aaron/clashctl
"$CLASH_HOME/bin/yq" -r \
'"saved_proxy_flag=" + ((._custom.system-proxy.enable // false) | tostring)' \
"$CLASH_HOME/resources/mixin.yaml"
env | awk -F= \
'BEGIN { IGNORECASE=1 } /^(http|https|all|no)_proxy=/{ print $1 }' | \
sort -fu
It is valid for the core and explicit curl --proxy request to be healthy while
a fresh non-interactive shell has no proxy variables. Do not call
clashctl proxy on merely to make a diagnostic shell look enabled; that command
changes saved state. Use an explicit per-command proxy unless the requested
operation requires persistent shell proxy configuration.
Systemd Boundary
clashctl has no version subcommand. Query the installed core directly:
/home/aaron/clashctl/bin/mihomo -v
Do not use a systemd unit state as a substitute for clashctl status and the
end-to-end request. ZJLAB currently has no clashctl-on.service. On 72602,
clashctl-on.service is an enabled user-level Type=oneshot bootstrap;
active (exited) means only that the bootstrap command completed. It does not
supervise or prove the health of the current mihomo process.
Path Boundaries
- Host-local tools on both machines use
127.0.0.1:<runtime-port>. - On 72602, Pods use
http://192.168.0.25:17890 or
http://argocd-egress-proxy.argocd.svc.cluster.local:17890. The socat bridge
forwards to host-local mihomo. - Do not configure a 72602 Pod with
192.168.0.25:7890 while allow-lan is
false. - The 72602 bridge is not evidence that the same Pod path exists in ZJLAB.
Verify the target cluster’s live proxy path separately.
Command Safety
| Command | Behavior | Agent policy |
|---|
clashctl --help | Shows locally installed command set | Read-only |
/home/aaron/clashctl/bin/mihomo -v | Shows the installed core version | Read-only |
clashctl status | Checks the mihomo process | Read-only; first diagnostic |
clashctl log | Opens less on the core log | Avoid in automation; use tail |
clashctl proxy | Shows proxy environment state | Do not capture raw output; it may include authentication |
clashctl on / off | Starts or force-stops the shared core and changes shell proxy state | Requires explicit authorization |
clashctl ui | Shows the panel and starts the core if it is down | Not read-only; requires authorization |
clashctl proxy on / off | Changes saved proxy state and current shell variables | Requires authorization |
clashctl tun | Reads or changes host routing/TUN state | Changes require explicit authorization |
clashctl mixin | Reads or edits merged configuration and can restart the core | Do not print full config; changes require authorization |
clashctl sub | Manages subscription sources and updates | URLs are sensitive; changes require authorization |
clashctl upgrade | Upgrades the running core | Requires reviewed maintenance window |
clashctl secret | Reads or changes the controller secret | Never print; changes require secure rotation approval |
Do not stop or restart the proxy merely because an unrelated external endpoint
is slow. Confirm core status, the configured port, one known generate_204
request, and the recent log first.
2026-08-14 Audit Snapshot
Read-only checks on both zjlab-ubuntu and 72602-minipc found:
- the installed mihomo process active;
- Mihomo Meta
v1.19.17 on linux amd64; - HTTP/mixed port
7890, SOCKS port 7891, and controller port 9090; allow-lan: false and TUN disabled;- the dynamic local-port preflight above returned HTTP
204 on each host.
ZJLAB had no clashctl-on.service. The 72602 user unit was enabled and
active (exited), but was a Type=oneshot bootstrap rather than the supervisor
of the live core. Fresh non-interactive shells did not inherit proxy environment
variables, which did not prevent the explicit local proxy checks from passing.
Treat these as dated health observations. The runtime configuration remains
authoritative for current ports and modes.
Zhejianglab
Scope
ZJLAB operational inventory and network details are private. Public pages contain reusable application guidance only; verify dynamic state against the live cluster before applying a runbook.
Access
ssh zjlab-ubuntu-local hostname # when running on ZJLAB
ssh zjlab-ubuntu-local 'kubectl get nodes'
ssh zjlab-ubuntu-proxy hostname # when running on 72602
Use zjlab-ubuntu-local for direct access from ZJLAB and
zjlab-ubuntu-proxy for the forwarded path from 72602. These are SSH config
aliases, not DNS names: validate configuration with ssh -G and reachability
with SSH, not with getent hosts. The aliases are provisioned from private
inventory and use an ECS ProxyJump to loopback-only reverse SSH listeners. Do
not publish their resolved endpoints, ports, users, internal topology, or
service names.
Detailed inventory and tunnel recovery procedures are maintained in the private ops-private repository with SOPS-encrypted values.
Tunnel Health
ECS runs an independent check-and-alert-only monitor for the approved
primary and backup loopback listeners. For each label it requires exactly
one listener, loopback-only binding, sole sshd ownership, an independent
owner, a short-timeout SSH banner, and a stable hashed owner signature. Alerts
start only after ten consecutive failures.
The monitor stores root-only state and emits labels and fixed reason codes only.
It never restarts or kills a tunnel and never changes sshd, firewall, cloud
network policy, DNS, keys, or endpoints. Real configuration and rollback details
remain in the private SOPS inventory; public pages must not reproduce them.
The ZJLAB tunnel initiators use enabled system-level services so they recover
after a host reboot. User-level legacy tunnel services must remain stopped and
disabled; Linger=yes starts the user manager but does not start disabled
services. If both labels fail together, inspect host boot and service-manager
state first, then recover backup before primary. Verify each alias and its
loopback listener before proceeding to the next label.
For external availability history, report the existing primary and backup
check results to separate Uptime Kuma Push monitors. Do not expose the relay’s
loopback listeners just to make them reachable by Kuma. The Push URLs and the
ECS monitor configuration belong in the private SOPS inventory; see the
Uptime Kuma setup
for the public setup requirements.
The ECS-side integration is active: ECS listener 10023 reports the primary
check and listener 10024 reports the backup check. This is separate from
the 72602 public tunnel listeners on 10021 and 10022, which are not Kuma
Push sources for this monitor.
The stable access map is:
10023 is the ZJLAB primary SSH reverse listener.10024 is the backup listener.10025 is a restricted break-glass maintenance listener.
All three are loopback-only on the ECS host and are reached through the ECS
ProxyJump path. They must never be added to the ECS security group or exposed
publicly. The maintenance listener is provided by
zjlab-loopback-maintenance.service and uses a separate restricted SSH
account and key; use the local zjlab-maintenance alias only for recovery.
Client configurations should use HostName 127.0.0.1 for these ECS loopback
targets rather than relying on localhost address-family resolution.
ECS applies ClientAliveInterval 30 and ClientAliveCountMax 10 to the
zjlab-tunnel and zjlab-maintenance accounts. This releases stale remote
forward listeners quickly after a broken connection without changing the
global SSH policy.
The ZJLAB tunnel initiators are the system-level services located at
/etc/systemd/system/zjlab-loopback-reverse-primary.service and
/etc/systemd/system/zjlab-loopback-reverse-backup.service. These units invoke
/usr/bin/ssh directly (not autossh) with Restart=on-failure,
RestartSec=30s for the primary, RestartSec=45s for the backup, and
KillMode=control-group. Legacy user-level services
reverse-tunnel-ecs.service and reverse-tunnel-ecs-10024.service must remain
stopped and disabled; running them alongside the system units causes listener
ownership conflicts and reconnect loops.
When both monitors report ssh_banner_failed, first verify the current ZJLAB
egress IPv4 and the ECS security-group allowlist for TCP 22, then check ECS
sshd and the established SSH child/session. A systemd active state alone only
proves that the supervisor is running; it does not prove that the reverse
listener has been established.
The private checker keeps the Push request as a best-effort reporting path.
Healthy checks send up; failed checks send down with a fixed reason. Push
HTTP failures do not change listener judgment, failure counters, DingTalk
debounce, or tunnel lifecycle. The checker merges existing query parameters
when constructing the request and does not log the Push value. A normal tunnel
reconnect changes the sshd PID and socket identity; those changes are not
treated as failures when the listener, owner, bind address, and SSH banner are
healthy. The checker completes an SSH key exchange with ssh-keyscan before
closing its probe connection; do not replace it with a raw TCP banner read,
which can leave CLOSE-WAIT channels on the reverse listener.
DingTalk sends one failure notification after ten consecutive failures for a
label. The message includes a fixed, redacted recovery action. After a notified
failure becomes healthy, the checker sends one recovery notification; if that
send fails, the alert state is retained and the next healthy check retries it.
Short failures that never cross the alert threshold do not generate a recovery
message.
Deployment verification on 2026-09-05 confirmed the primary, backup, and
maintenance aliases returned zjlab-ubuntu; the ECS listeners were unique,
loopback-only, and returned valid SSH banners. The checker dry-run and live
service run were healthy for both labels across more than two complete
60-second timer cycles.
Rollback restores the root-only ECS backup and encrypted private inventory
backup, then restarts only zjlab-tunnel-healthcheck.service; tunnel units are
not restarted as part of monitoring rollback.
Preflight
For host-level proxy checks, follow the
shared Clash/Mihomo runbook before trying ports or changing
proxy variables. The installed clashctl is a shell function, so
non-interactive sessions must source it explicitly.
# On ZJLAB, use zjlab-ubuntu-local; on 72602, use zjlab-ubuntu-proxy.
ZJLAB_SSH_ALIAS=zjlab-ubuntu-local
ssh "$ZJLAB_SSH_ALIAS" 'kubectl config current-context'
ssh "$ZJLAB_SSH_ALIAS" 'kubectl get nodes'
ssh "$ZJLAB_SSH_ALIAS" 'kubectl get namespace'
ssh "$ZJLAB_SSH_ALIAS" 'kubectl get applications.argoproj.io -A'
ssh "$ZJLAB_SSH_ALIAS" 'kubectl get ingress,certificate -A'
Independent Prometheus Deployment Attempt
An independent Prometheus deployment was prepared for ZJLAB with the public
Prometheus Community prometheus chart version 29.18.0, ArgoCD Application
zjlab-prometheus, and destination namespace monitoring. Helm rendering and
Kubernetes server-side dry-run succeeded, including the password-file based
remote-write mount and explicit kube-state-metrics and node-exporter scrape
jobs.
The Application reached Synced but remained Healthy: Progressing. The
30Gi local-path PVC stayed Pending: the local-path provisioner timed out
while creating its helper Pod and did not bind the volume. Consequently the
Prometheus server, kube-state-metrics, and node-exporter workloads did not
reach the verification gate, so readiness, targets, remote-write delivery,
and the receiving-cluster queries were not claimed as successful.
The attempt was rolled back by deleting only the newly created
zjlab-prometheus Application, its monitoring/zjlab-prometheus-server PVC,
the runtime remote-write Secret, and the empty monitoring namespace. Existing
applications, tunnels, network policies, and metrics-server were not changed.
Before retrying, verify local-path provisioning on the selected node and
repeat the full readiness, target, remote-write, and receiving-cluster query
checks.
Prometheus Retry (2026-08-05)
The retry used a private node selector for the previously diagnosed healthy
local-path node. The 30Gi PVC bound immediately, and the Prometheus server was
scheduled on the same node. Kube-state-metrics and both node-exporter Pods
reached Running.
The Prometheus server then exited with code 2 and entered CrashLoopBackOff.
This is a stop condition, so readiness, targets, remote-write queue health,
and receiving-cluster queries were not claimed as successful. The Application,
PVC, runtime Secret, and monitoring namespace created for this retry were
rolled back. Existing applications, tunnels, network policies, and
metrics-server were not changed. The next retry must capture the Prometheus
startup error before cleanup and correct the rendered configuration without
publishing credentials.
Prometheus Deployment (2026-08-05)
The deployment was recreated as ArgoCD Application zjlab-prometheus using
Prometheus Community chart 29.18.0. The rendered server arguments contain the
chart-default web.enable-lifecycle flag exactly once; no custom
server.extraArgs was configured. The runtime remote-write Secret contains
only the password file and is mounted read-only; credentials are not part of
the Application values.
ArgoCD is Synced and Healthy at revision 29.18.0. The 30Gi local-path
PVC is Bound, the Prometheus server is co-located with that volume on the
private selected node, the Prometheus and kube-state-metrics Deployments are
1/1 Ready, and both node-exporter DaemonSet Pods are Ready with zero restarts.
The server Service and the KSM/node-exporter Services are ClusterIP; no
Ingress, NodePort, or admin API was enabled.
Prometheus /-/ready returned HTTP 200. The targets API showed the Prometheus,
kube-state-metrics, node-exporter, Kubernetes API server, nodes, cadvisor, and
Kubernetes pod/service discovery jobs up with no scrape errors. The rendered
configuration contains external_labels.cluster=zjlab, the remote-write
password-file path, and no password value. Across two complete 30-second
cycles, remote-write samples increased while failed and retried samples stayed
at zero.
The receiving 72602 Prometheus queries were completed after deployment:
up{cluster="zjlab"} returned 18 samples,
kube_node_status_condition{cluster="zjlab"} returned 54 samples, and
kube_pod_info{cluster="zjlab"} returned 72 samples. The newest ZJLAB sample
was approximately five seconds old at verification time, and all three
queries were served by the existing 72602 Prometheus datasource used by
Grafana. No Grafana datasource change was required.
The receiving Prometheus reported zero failed and retried remote-write samples.
After the user-authorized 2026-08-14 metrics reset, the local
monitoring/zjlab-prometheus-server TSDB PVC was recreated while this
remote-write configuration remained unchanged. The old local history was not
backed up and is intentionally unrecoverable. The sender queue drained and the
72602 receiver again exposed fresh cluster="zjlab" samples; continue watching
the pending queue if its delay grows.
Prometheus Storage Diagnosis (2026-08-05)
The read-only follow-up found two Ready nodes with no taints and with
MemoryPressure, DiskPressure, and PIDPressure all false. The default
local-path StorageClass uses rancher.io/local-path, WaitForFirstConsumer,
and Delete reclaim policy. Its Rancher v0.0.35 provisioner is 1/1
Ready. The provisioner configuration has only the default root
/var/lib/rancher/k3s/storage; its helper image is BusyBox 1.37.0, and the
configured setup creates the volume directory and restricts its parent.
Existing Bound local-path volumes cover both nodes, and the node filesystem
and inode checks showed substantial headroom. Existing local-path mounts were
also writable from their consuming workloads. This does not support a root
capacity or permission failure as the cause of the Prometheus incident.
The retained helper events use the
helper-pod-create-pvc-<PVC-UID> naming pattern. The image was already
available, but helper container creation/startup and retries exceeded the
provisioner’s 120-second create-process timeout. Later retries included
ContainerCreating and failed log-stream reads before the Prometheus
resources were removed. No helper Pod, PVC, PV, or Prometheus Application is
currently left behind. The evidence points first to helper scheduling/runtime
latency or a local-path/K3s compatibility issue on the selected worker; the
retained evidence does not prove a filesystem fault.
nfs-data is currently 1/1 Ready with the v4.0.2 provisioner (Helm chart
4.0.18), an active leader endpoint, Immediate binding, Retain reclaim
policy, and archiveOnDelete=true. Multiple existing nfs-data PVCs are
Bound, and the NFS mount is currently read-write with healthy capacity and
inode headroom. It remains a single-backend/single-node dependency, so NFS is
an acceptable fallback for Prometheus only after confirming its latency and
failure policy; it is not automatic high availability.
The minimum reliable retry is to repair and verify helper startup on a
deliberately selected, healthy local-path node, and to make the Prometheus
workload’s node selection explicit so the local volume and workload remain
co-located. Rollback is to remove only the new ArgoCD Application and its new
resources; do not remove existing local-path data. NFS is the secondary
option, and ephemeral storage is not recommended for Prometheus. Direct
worker-host journal and host-permission inspection remains a verification gap
because this diagnostic session was restricted to the approved canonical ZJLAB
SSH entry point and did not create a debug Pod.
Prometheus Storage Follow-up (2026-08-05)
The approved node proxy exposed host system.journal and container-log
directories for both nodes. The historical helper had already been removed,
and no matching kubelet, containerd, CRI, mount, image, or sandbox error was
retained for the helper timeout window. The control-plane journal did contain
repeated PartialObjectMetadata watch errors, but no evidence connected them
to the local-path helper failure. The Kubernetes node image inventory no
longer advertises the helper image; the historical kubelet event is the
available evidence that it was already cached during the incident.
The strongest retry candidate is the control-plane node that already hosts the
local-path provisioner and several successfully Bound local-path volumes. It
has the standard Linux and control-plane/etcd labels, no taint, and healthy
Ready/pressure conditions. The trade-off is that Prometheus would share the
node with control-plane workloads, and loss of that node makes its local data
unavailable. Use the private value of its kubernetes.io/hostname label in
the deployment; do not publish that value.
For a retry, the server values should include the following shape, with
<selected-local-path-node> replaced only in the private manifest:
server:
nodeSelector:
kubernetes.io/hostname: <selected-local-path-node>
persistentVolume:
enabled: true
storageClass: local-path
accessModes:
- ReadWriteOnce
size: 30Gi
The retry gate is: the PVC must reach Bound and receive node affinity for
the selected node before 120 seconds; the helper must reach container
started/completed without ContainerCreating, mount, image, sandbox, or CRI
errors; and the Prometheus server must schedule on the same node and become
Ready. Any repeated runtime error, a helper still in ContainerCreating at
60 seconds, or another ProvisioningFailed at 120 seconds is a stop
condition. Roll back the new Application and its new resources, then use
nfs-data only after accepting its single-backend failure risk. No runtime or
storage change was made during this follow-up.