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:

HostTypeValueService
argocd.72602.spaceA47.110.67.161ArgoCD UI
ops.docs.72602.spaceA47.110.67.161Hugo Docs
token.72602.spaceA47.110.67.161AI API proxy
port.72602.spaceA47.110.67.161Homepage dashboard
n8n.72602.spaceA47.110.67.161N8N workflow
webhook.n8n.72602.spaceA47.110.67.161N8N webhook receiver
ops.agent.72602.spaceA47.110.67.161OpenCode operations agent
grafana.72602.spaceA47.110.67.161Grafana observability UI
otlp.72602.spaceA47.110.67.161OTLP ingest endpoint
prometheus-write.72602.spaceA47.110.67.161Prometheus remote-write endpoint
uptime.72602.spaceA47.110.67.161Uptime Kuma
clash.72602.spaceA47.110.67.161Clash/mihomo panel
api.minio.72602.spaceA47.110.67.161MinIO S3 API
console.minio.72602.spaceA47.110.67.161MinIO 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:

RecordIdRRTypeValueTTLStatus
2084917630338801664@CAA0 issue "letsencrypt.org"600ENABLE

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:

RRTypeValuePriority
mailA47.110.67.161
@MXmail.72602.space.10
@TXTv=spf1 mx ip4:<current-72602-egress-ip> -all (maintained dynamically)
_dmarcTXTv=DMARC1; p=none; rua=mailto:admin@72602.space
dkim._domainkeyTXTv=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

AppNamespaceTypeSourceIngress
argocdargocdHelm (argo-cd)argo-cd 9.5.4argocd.72602.space
cert-managerbasic-componentsHelm (Jetstack)cert-manager 1.20.2internal
ingress-nginxbasic-componentsHelmingress-nginx 4.15.1shared ingress controller
ops-docsapplicationmanifests (Git)docs.git/mainops.docs.72602.space
ops-agentapplicationmanifests (Git)docs.git/mainops.agent.72602.space
mailumailuHelm (Mailu)mailu 2.7.3mail.72602.space
prometheusmonitorHelm (Prometheus Community)prometheus 29.18.0prometheus-write.72602.space
grafanamonitorHelm (Grafana)grafana 10.5.15grafana.72602.space
lokimonitorHelm (Grafana)loki 6.55.0internal
tempomonitorHelm (Grafana)tempo 1.24.4otlp.72602.space
alloymonitorHelm (Grafana)alloy 1.10.1otlp.72602.space
homepagemonitormanifests (Git)docs.git/mainport.72602.space
uptime-kumamonitormanifests (Git)docs.git/mainuptime.72602.space
sub2apiapplicationArgoCD (Git → OCI Helm)sub2api 0.1.6 / ghcr.io/wei-shaw/sub2api:0.1.168token.72602.space
postgresqldatabaseHelm (Bitnami)postgresql 18.1.8internal
redis-sharedstorageHelm (Bitnami)redis 18.16.0internal
miniostorageHelmminio 16.0.10console.minio.72602.space, api.minio.72602.space
n8nn8nHelm (community)n8n 1.16.36n8n.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 (手动部署)

DeploymentNamespaceImageIngress
ops-agentapplicationay-dev/ops-agent:0.2.3ops.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:17890127.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:7890allow-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

ServicePortBindDescription
mihomo (clash) HTTP proxy7890127.0.0.1Egress proxy, allow-lan: false
mihomo (clash) SOCKS57891127.0.0.1SOCKS5 proxy
mihomo external controller90900.0.0.0Clash API/UI, exposed via clash.72602.space
socat bridge178900.0.0.0Forwards 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
WireGuard51820/udpECS 0.0.0.0, minipc dynamic UDPEncrypted Web path between ECS and minipc
ECS HAProxy Web80,4430.0.0.0TCP passthrough to minipc WireGuard IP
ECS HAProxy Mail25,465,587,9930.0.0.0 and [::]TCP passthrough with PROXY v2
ECS mail tunnel backends10225,10465,10587,10993127.0.0.1sshd reverse forwards to minipc Mailu front
Mailu front host ports25,465,587,993minipc hostPortSMTP, SMTPS, submission, IMAPS
k3s ingress HTTP320800.0.0.0NodePort for ingress HTTP
k3s ingress HTTPS324430.0.0.0NodePort for ingress HTTPS

Notes

  • Keep derper away from public 443 (use 8443).
  • Keep app ingress aligned with ArgoCD ingress pattern:
    • ingressClassName: nginx
    • cert-manager.io/cluster-issuer: lets-encrypt
    • TLS secret per host.
  • argocd-egress-proxyops-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.00030.0006s; direct ingress was approximately 0.1070.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.00060.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:1710: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
Mar 7, 2024

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

Isolated extraction

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-minipcsquid 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.meip.sbicanhazip.com,以及现场验证通过的 ifconfig.co/ipipinfo.io/ip。脚本会收集全部结果;至少两个 endpoint 返回 相同的合法 IPv4 才会被采纳。单个结果或全失败都只写入 journald,不修改云 安全组、不推进缓存,并记录各 endpoint 的阶段性错误或结果。

探测失败使用持久的连续失败计数:达到三次才尝试一次 [ZJLAB] public IPv4 detection failed 通知,失败期间不重复刷屏;quorum 恢复 后只尝试一次恢复通知。通知失败不改变任务退出判断。成功 heartbeat 仍保持三天 一次,失败计数与告警状态文件位于现有持久状态目录且权限为 0600

受影响的端口

端口协调器写入的位置由谁验证
TCP 22Aliyun 安全组 /32 规则update-sg-ip.service 通过 Aliyun SDK 描述与对比验证
TCP 10021Aliyun 安全组 /32 规则update-sg-ip.service 通过 Aliyun SDK 描述与对比验证
TCP 10022Aliyun 安全组 /32 规则update-sg-ip.service 通过 Aliyun SDK 描述与对比验证
TCP 3128Aliyun 安全组 /32 规则 + ECS UFW /32 规则(comment squid 72602-minipcAliyun SDK 与 ECS UFW helper 两侧都需要「先建新 + 验证生效」才算落地
TCP 56396Aliyun 安全组 /32 规则update-sg-ip.service 通过 Aliyun SDK 描述与对比验证(仅云端;ECS UFW 当前仍 broad allow,暂未收窄)
UDP 51820Aliyun 安全组 /32 规则 + ECS UFW /32 规则(comment wg 72602-minipcAliyun SDK 与 ECS UFW helper 两侧都需要「先建新 + 验证生效」才算落地

51820/udp3128/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 规则(实例边界)。只更新其中一层,目标端口的流量仍可能在另一层被丢包, 因此协调器把两者视作一个事务来推进:

  1. 先建新规则:协调器先按 Description update-sg-ip-72602-minipc 在 Aliyun 安全组中写入新 /32,并通过 SSH 调用 ECS 上的 72602-wireguard-ufw-reconcile 在 UFW 中加入新 /3251820/udp 使用 comment wg 72602-minipc3128/tcp 使用 comment squid 72602-minipc
  2. 两边都验证:协调器再次描述安全组、再次触发 UFW helper 的 status 输出,确认所有新规则都已经落地并匹配当前探测到的公网 IP。
  3. 再清理旧规则:两侧验证都通过后才删除旧 updater-owned 规则,再把 「最近已知 IP + 上一次双 consumer 已核实时间戳」写入持久状态目录。
  4. 失败回退:只要任意一侧验证失败,协调器就立刻退出,不删除旧规则、 不推进持久状态。新规则保留,下一个 5 分钟周期由协调器幂等重试。

协调器在「IP 未变化」的周期也会跑一次只读的 3128/tcp51820/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-minipcaaron 用户下;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.service72602 系统级 systemd service(User=aaron
/etc/systemd/system/update-sg-ip.timer72602 系统级 systemd timer(OnBootSec=30OnUnitActiveSec=5minPersistent=true);唯一调度源
/home/aaron/.config/systemd/user/update-sg-ip.serviceZJLAB 用户级 systemd service
/home/aaron/.config/systemd/user/update-sg-ip.timerZJLAB 用户级 systemd timer(OnUnitActiveSec=5minPersistent=true,链接位于 timers.target.wants
/home/aaron/.local/state/update-sg-ip/持久状态目录:最近已知 IP、上一次「两端都已核实」的时间戳、连续探测失败计数与告警状态;状态文件 0600,仅属主可读写
update-sg-ip.service / update-sg-ip.timerjournald失败原因、API 退出码、是否推进状态等副作用日志
/usr/local/sbin/72602-wireguard-ufw-reconcileECS 上 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.serviceupdate-sg-ip.timer 已部署在 /etc/systemd/system/User=aaron
  • timer 配置:OnBootSec=30OnUnitActiveSec=5minPersistent=true, 当前唯一调度源。
  • 已执行 daemon-reloadsystemctl 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.spaceops.docs.72602.space 均为 HTTP 200、 TLS 校验通过。

ZJLAB 与仓库模板:ZJLAB 上的同名用户级 updater,以及本仓库私有模板,当前 只维护 TCP 221002110022,不维护 UDP 51820。两套脚本虽然文件名 相同,但端口集合、部署层级和运行时凭据来源不同;同步或恢复前必须按目标主机 逐项审阅,不能互相替换。

ZJLAB live:

  • 用户级 ~/.config/systemd/user/update-sg-ip.service.timer 已写入;timers.target.wants/ 下存在 enabled 链接。
  • timer 调度与 72602 相同(OnUnitActiveSec=5minPersistent=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 为 enabledactive (waiting),仍按五分钟调度;非交互 SSH 需要设置用户运行时目录后才能连接 user manager。

安全组规则 Description 归属(已核实)

本次仅按 ModifySecurityGroupRule 在 ECS 控制台原地改写 Description 字段;协议、端口、CIDR、优先级、Policy、Direction 均未变更,未使用 RevokeSecurityGroup + AuthorizeSecurityGroup 组合。本节不出现 RuleId、真实 IP 或备份绝对路径。

当前自动更新器在 ECS 上以如下 Description 识别自己负责的规则:

脚本Description负责的协议/端口
update-sg-ip-72602-minipcupdate-sg-ip-72602-minipc72602-minipc 当前自动更新的 TCP 22 / 10021 / 10022 / 3128 / 56396 与 UDP 51820(其中 312851820 在 ECS UFW 也有对端规则,56396 仅云端维护)
update-sg-ip-zjlabupdate-sg-ip-zjlabZJLAB 出口来源的 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/tcp51820/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/100223128/tcp51820/udp 的允许 IP,否则可能把自己从 ECS 端断掉。

一般顺序:

  1. systemctl stop update-sg-ip.timer(必要时连同 .service),冻结调度。
  2. ops-private 隐私目录取出对应文件原样覆盖到现网路径,并恢复属主 aaron 与原权限(脚本 0755,虚拟环境与持久状态目录 0600 / 0700)。
  3. systemctl daemon-reload(系统级)或 systemctl --user daemon-reload(用户级)。
  4. 重启 timer 并通过 systemctl statusjournalctl 复核 unit 已被识别。
  5. 在 ECS 上复核 /usr/local/sbin/72602-wireguard-ufw-reconcile 的版本与权限(root-only、0700),确认其 authorized_keys 条目仍指向专用受限 key 而非 root 登录 key。
  6. 安全组 / UFW 规则若需手动恢复到旧的允许 IP,应通过 ops-private 内的官方 AliDNS / 阿里云 SDK 流程,不在本页复述参数。

如果回滚过程中发现协调逻辑本身可疑,先保留旧的 systemd unit 与旧脚本,把 问题记到新 issue 而不是就地修改 live 协调路径。

紧急临时访问设计(尚未部署)

当前安全组只允许已登记的 72602 与 ZJLAB 出口来源访问 ECS 的 TCP 221002110022;端口用途仍分别由 72602 主/备入口和 ECS sshd 决定。ZJLAB 的 10023 / 10024 监听器仅在 ECS loopback 上提供 ProxyJump 目标,不应通过 安全组公开。紧急访问不建议使用无认证的传统端口敲门序列;序列可被监听、重放 或扫描。推荐使用一个独立的、仅密钥认证的 SSH gate:

  1. ECS 单独监听一个 gate 端口,例如 TCP 2222;该端口只允许专用用户 sg-gate,不提供 shell、PTY、端口转发或 Agent forwarding。
  2. sg-gate 只接受一把独立的、带密码短语的 emergency key。认证成功后由 forced command 读取 SSH_CONNECTION 的实际来源 IP,不接受用户自行传入任意 IP。
  3. 默认只为该来源 IP 添加 TCP 22/32 临时规则,Description 使用 emergency-ssh-<request-id>;如确实要访问 72602 反向入口,必须显式选择只包含 10021 / 10022 的 tunnel profile,不默认开放,也不得选择 10023 / 10024
  4. 临时授权最大有效期固定为 3600 秒。授权器保存 RuleId、来源、端口和 UTC 到期时间;root-only 的过期任务每分钟扫描并按 RuleId 删除,重启后先执行一次过期清理。删除失败必须重试并告警,不能只依赖启动授权的 SSH 会话。
  5. 授权、续期和删除都要记录审计日志;重复请求不得创建重复规则。用户 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/100223128/tcp51820/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.meip.sbicanhazip.com,新增并现场验证 ifconfig.co/ipipinfo.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.sh0755)。
  • 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/1002251820/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

上线顺序建议:

  1. 先在 72602-minipc 创建并启动 10022(备入口)
  2. 验证 ECS 已监听 10022
  3. 再创建并启动 10021(主入口)
  4. 最后做外网双端口连通性验证

完整步骤、故障恢复与运维命令见子页面。

Mar 7, 2024

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/tcp10022/tcp。 不要为 ZJLAB 的 10023/tcp10024/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:10021
  • 0.0.0.0:10022
  • 127.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 检查最多 发送 EHLOMAIL FROMRCPT TOQUIT,不要发送 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 包含 1002110022
  • /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 巡检按 primarybackup 标签检查。该巡检要求每个标签恰有一个 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)

ItemECS72602-minipc
WireGuard address10.77.0.1/3010.77.0.2/30
Servicewg-quick@wg0wg-quick@wg0
Config/etc/wireguard/wg0.conf/etc/wireguard/wg0.conf
Public UDPlistens on 51820initiates to ECS with keepalive
Web roleHAProxy 80/443ingress NodePort 32080/32443
SSH Web backupECS loopback 18080/18443independent 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 3208032443
  • 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: HAProxy
  • 51820/udp: WireGuard kernel interface
  • 10021/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:

  1. HAProxy owns 80/443 and its configuration validates.
  2. Both wg-quick@wg0 services are active and have a recent handshake.
  3. ECS can reach 10.77.0.2:32080 and 10.77.0.2:32443.
  4. ingress-nginx Service, Pod, Endpoint, Host routing, and certificate are healthy.
  5. 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.

  1. Keep 10021 authenticated and do not stop Mail HAProxy frontends.
  2. Restore the saved HAProxy configuration or the independent SSH Web backup service from ops-private; validate with haproxy -c before reload.
  3. 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.
  4. 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.