phase: 28_gateway_agent_proxy

**Phase 28 final pass — complete, 100 % green.** All five tasks were already `complete/`; this pass verified the criteria, re-ran the live half, and did the docs pass.

- Gate `bash .agents/validate.sh` **rc=0**: `1650 passed, 15 skipped`, coverage **98.97 %** (floor 94), `ruff` clean, `pyright` **0 errors**, `node --check` clean, hygiene clean (`/tmp/validate-done.log`).
- Live: `pytest -m live --live` **11 passed**; `./scripts/dev-server.sh status` nine sources `ok`; mTLS re-read of `gateway` → agent **1.2.0**, all three sections `available: true`, 6/6 frontends routed, 11 nginx servers + `$targetBackend`, **19** listeners, no core errors (`data/logs/28-finalpass-live.out`); installed agent file byte-identical to this tree (`b0e9ebf1b718…`).
- Criteria: 1 ✅ pytest/coverage · 2 ✅ three sections over a captured tree, `false`/`null` + one `errors` line each elsewhere with `cpu`/`mem`/`disk_io`/`system` intact · 3 ✅ `AGENT_VERSION` 1.2.0 + SCHEMA names all three · 4 ✅ six fixtures verbatim, capture refuses a bad read, 116 socket address columns hold no globally routable endpoint · 5 ✅ gateway runs 1.2.0 and answers over mTLS (transcript `data/logs/28-task05-rollout.md`, `28-finalpass-live.out`) · 6 ✅ validate · 7 ✅ additive: HEAD's 1.1.0 and this 1.2.0 `collect()`ed over one `--root` are key-for-key equal plus the three new sections, `--no-proxy` equal exactly (`data/logs/verify-28-final.py` → 41 PASS, `28-final-verify.out`).
- Docs fixed (README/AGENTS/Containerfile, all re-green): README's opening said "Four dashboards" above a five-row table and said the new sections are "stored in the snapshot" (they are in the **agent's** document — no `/api` payload, no `sample` row, nothing charts them until phase 29); `Containerfile`'s header still said "three dashboards"; AGENTS's coverage figure (98.94 → **98.97 %**), invariant 6 gained the captured-socket-table identifier rule, the agent bullet gained "phase 28 stops at the wire", and the fleet record names the stash-and-compare control plus the volatile listener count. Full record: `data/logs/NOTES-28-final.md`.
- Deviations: none functional; no git index/history touched, phase files untouched (`PLAN.md` and `complete/` unchanged). `uv pip list --outdated` still lists 4 packages (fastapi 0.153→0.161 etc.) — advisory only, dependencies untouched by this phase, left for a deliberate pass.
- Next pending phase: `.agents/phases/todo/29_gateway_dashboard_page/` (the page that consumes these three sections).
This commit is contained in:
2026-10-22 20:24:01 -04:00
parent 5d37444fb0
commit 44d61d3ab9
45 changed files with 4759 additions and 42 deletions
+57
View File
@@ -0,0 +1,57 @@
# Verbatim /etc/haproxy/haproxy.cfg from gateway-ops, read read-only over the ssh key by
# ./scripts/capture-gateway-config.sh (AGENTS.md invariant 7: verify against the
# deployed upstream, never guess its shape).
# Nothing here is rewritten: it is this fleet's ingress map, and the domains in it are
# the data — an inbound name and the target it routes to are what these config sections
# exist to report. Key material appears only as the file paths the proxy reads.
defaults
log stdout local0 info
mode tcp
option tcplog
timeout connect 10s
timeout client 30s
timeout server 30s
timeout tunnel 3600s
default-server init-addr last,libc,none
frontend haproxy-22
bind :::22 v4v6
default_backend gitea-2222
frontend haproxy-2000
bind :::2000 v4v6
default_backend sec-gitea-2000
frontend haproxy-5201
bind :::5201 v4v6
default_backend iperf3-5201
frontend haproxy-8080
bind :::8080 v4v6
default_backend unifi-external-8080
frontend haproxy-25565
bind :::25565 v4v6
default_backend testcraft-25565
frontend haproxy-25566
bind :::25566 v4v6
default_backend nimcraft-25566
backend gitea-2222
server gitea reeseapps-gitea.reeselink.com:2222 check inter 5s
backend sec-gitea-2000
server sec-gitea stackexpected-gitea.reeselink.com:2222 check inter 5s
backend iperf3-5201
server iperf3 web.reeselink.com:5201 check inter 5s
backend unifi-external-8080
server unifi unifi-external.reeselink.com:8080 check inter 5s
backend testcraft-25565
server mc1 minecraft.reeselink.com:25565 check inter 5s
backend nimcraft-25566
server mc2 minecraft.reeselink.com:25566 check inter 5s
+214
View File
@@ -0,0 +1,214 @@
# Verbatim /etc/nginx/conf.d/http.conf from gateway-ops, read read-only over the ssh key by
# ./scripts/capture-gateway-config.sh (AGENTS.md invariant 7: verify against the
# deployed upstream, never guess its shape).
# Nothing here is rewritten: it is this fleet's ingress map, and the domains in it are
# the data — an inbound name and the target it routes to are what these config sections
# exist to report. Key material appears only as the file paths the proxy reads.
server {
listen 4443 ssl proxy_protocol;
listen [::]:4443 ssl proxy_protocol;
server_name nextcloud.reeseapps.com;
client_max_body_size 0;
ssl_certificate /etc/letsencrypt/live/nextcloud.reeseapps.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/nextcloud.reeseapps.com/privkey.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location / {
proxy_pass http://nextcloud.reeselink.com:11000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
server {
listen 8443 ssl;
listen [::]:8443 ssl;
server_name nextcloud.reeseapps.com;
client_max_body_size 0;
ssl_certificate /etc/letsencrypt/live/nextcloud.reeseapps.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/nextcloud.reeseapps.com/privkey.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location / {
proxy_pass http://nextcloud.reeselink.com:11001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
server {
listen 4443 ssl proxy_protocol;
listen [::]:4443 ssl proxy_protocol;
server_name gitea.reeseapps.com;
client_max_body_size 0;
ssl_certificate /etc/letsencrypt/live/gitea.reeseapps.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/gitea.reeseapps.com/privkey.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location / {
proxy_pass http://gitea.reeselink.com:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
server {
listen 4443 ssl proxy_protocol;
listen [::]:4443 ssl proxy_protocol;
server_name jellyfin.reeseapps.com;
client_max_body_size 0;
ssl_certificate /etc/letsencrypt/live/jellyfin.reeseapps.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/jellyfin.reeseapps.com/privkey.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location / {
proxy_pass http://jellyfin.reeselink.com:8096;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
server {
listen 8443 ssl;
listen [::]:8443 ssl;
server_name alicia-ai-terminology.reeseapps.com;
ssl_certificate /etc/letsencrypt/live/alicia-ai-terminology.reeseapps.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/alicia-ai-terminology.reeseapps.com/privkey.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location / {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
server {
listen 4443 ssl proxy_protocol;
listen [::]:4443 ssl proxy_protocol;
server_name connors-legos.reeseapps.com;
ssl_certificate /etc/letsencrypt/live/connors-legos.reeseapps.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/connors-legos.reeseapps.com/privkey.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location / {
proxy_pass http://bricktracker.reeselink.com:3333;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
server {
listen 4443 ssl proxy_protocol;
listen [::]:4443 ssl proxy_protocol;
server_name keycloak.reeseapps.com;
client_max_body_size 0;
ssl_certificate /etc/letsencrypt/live/keycloak.reeseapps.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/keycloak.reeseapps.com/privkey.pem;
location / {
proxy_pass http://keycloak.reeselink.com:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
server {
listen 8443 ssl;
listen [::]:8443 ssl;
server_name admin.keycloak.reeseapps.com;
client_max_body_size 0;
ssl_certificate /etc/letsencrypt/live/admin.keycloak.reeseapps.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/admin.keycloak.reeseapps.com/privkey.pem;
location / {
proxy_pass http://keycloak.reeselink.com:8080;
proxy_set_header Host $host:$server_port;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header X-Forwarded-Host $host:$server_port;
}
}
server {
listen 4443 ssl proxy_protocol;
listen [::]:4443 ssl proxy_protocol;
server_name emmaleaf.reeseapps.com;
client_max_body_size 10m;
ssl_certificate /etc/letsencrypt/live/emmaleaf.reeseapps.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/emmaleaf.reeseapps.com/privkey.pem;
location / {
proxy_pass http://emmaleaf.reeselink.com:80;
proxy_ssl_verify off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
}
+63
View File
@@ -0,0 +1,63 @@
# Verbatim /etc/nginx/nginx.conf from gateway-ops, read read-only over the ssh key by
# ./scripts/capture-gateway-config.sh (AGENTS.md invariant 7: verify against the
# deployed upstream, never guess its shape).
# Nothing here is rewritten: it is this fleet's ingress map, and the domains in it are
# the data — an inbound name and the target it routes to are what these config sections
# exist to report. Key material appears only as the file paths the proxy reads.
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /run/nginx.pid;
events {
worker_connections 1024;
}
stream {
include /etc/nginx/stream.d/*.conf;
}
http {
real_ip_header proxy_protocol;
set_real_ip_from 127.0.0.1;
set_real_ip_from 10.0.0.0/8;
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format detailed '$remote_addr:$remote_port [$time_local] "$server_name" "$request" '
'$status "$body_bytes_sent" "$http_referer" "$http_user_agent" '
'$request_time "$upstream_addr" "$upstream_response_time" '
'$ssl_protocol/$ssl_cipher';
access_log /var/log/nginx/access.log detailed;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
gzip on;
server_tokens off;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
listen [::]:80;
server_name _;
return 301 https://$host$request_uri;
}
include /etc/nginx/conf.d/*.conf;
}
+40
View File
@@ -0,0 +1,40 @@
# Verbatim /etc/nginx/stream.d/stream.conf from gateway-ops, read read-only over the ssh key by
# ./scripts/capture-gateway-config.sh (AGENTS.md invariant 7: verify against the
# deployed upstream, never guess its shape).
# Nothing here is rewritten: it is this fleet's ingress map, and the domains in it are
# the data — an inbound name and the target it routes to are what these config sections
# exist to report. Key material appears only as the file paths the proxy reads.
map $ssl_preread_server_name $targetBackend {
default 127.0.0.1:4443;
homepage.reeseapps.com reeseapps-homepage.reeselink.com:443;
ntfy.reeseapps.com reeseapps-ntfy.reeselink.com:443;
gitea.reeseapps.com reeseapps-gitea.reeselink.com:443;
aipi.reeseapps.com reeseapps-litellm.reeselink.com:443;
chatreesept.reeseapps.com reeseapps-openwebui.reeselink.com:443;
keycloak.reeseapps.com reeseapps-keycloak.reeselink.com:443;
forgejo.reeseapps.com reeseapps-forgejo.reeselink.com:443;
brain.reeseapps.com reeseapps-brain-of-reese.reeselink.com:443;
brain-of-code.reeseapps.com reeseapps-brain-of-code.reeselink.com:443;
immich.reeseapps.com reeseapps-immich.reeselink.com:443;
stackexpected.com stackexpected.reeselink.com:443;
gitea.stackexpected.com stackexpected-gitea.reeselink.com:443;
keycloak.stackexpected.com stackexpected-keycloak.reeselink.com:443;
nextcloud.stackexpected.com stackexpected-nextcloud.reeselink.com:443;
hedgedoc.stackexpected.com stackexpected-hedgedoc.reeselink.com:443;
wellsfamilycounseling.reeseapps.com wfc.reeselink.com:443;
}
server {
listen 443;
listen [::]:443;
proxy_connect_timeout 1800s;
proxy_timeout 1800s;
proxy_protocol on;
proxy_pass $targetBackend;
ssl_preread on;
resolver 10.1.0.1;
}
+58
View File
@@ -0,0 +1,58 @@
# Verbatim /proc/net/tcp from gateway-ops, read read-only over the ssh key by
# ./scripts/capture-gateway-config.sh (AGENTS.md invariant 7: verify against the
# deployed upstream, never guess its shape).
# One line per TCP socket, in the kernel's own spelling. The only edit is the repo's
# identifier rule (invariant 6, the drive-serial scrub in capture-fixtures.sh): a
# globally routable address became a documentation one (RFC 5737 / RFC 3849), because a
# global IPv6 here is EUI-64 and a visitor's address is not ours to publish. Every port,
# state, queue, uid, inode and column width is verbatim, as are the loopback, private
# and wildcard addresses the gateway's own frontends use.
# Volatile by nature: a proxy in service has a different connection set every second, so
# a diff here on re-capture is expected. The tests assert shape, the two states the agent
# keeps, and the ports the captured configs claim to bind — never a particular row.
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
0: 00000000:20FB 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 9978 1 0000000000000000 100 0 0 10 0
1: 00000000:08AE 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 7797 1 0000000000000000 100 0 0 10 0
2: 00000000:0050 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 9974 1 0000000000000000 100 0 0 10 0
3: 00000000:01BB 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 9972 1 0000000000000000 100 0 0 10 0
4: 00000000:115B 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 9976 1 0000000000000000 100 0 0 10 0
5: 1B01010A:23A0 00000000:0000 0A 00000000:00000000 00:00000000 00000000 994 0 9479 1 0000000000000000 100 0 0 10 0
6: 1B01010A:BE2A B001010A:01BB 06 00000000:00000000 03:000013F5 00000000 0 0 0 3 0000000000000000
7: 0100007F:115B 0100007F:818C 01 00000000:00000000 00:00000000 00000000 101 0 62860 1 0000000000000000 20 4 30 10 -1
8: 1B01010A:BE2E B001010A:01BB 06 00000000:00000000 03:00001407 00000000 0 0 0 3 0000000000000000
9: 1B01010A:23A0 0204A8C0:C626 06 00000000:00000000 03:00000820 00000000 0 0 0 3 0000000000000000
10: 0100007F:115B 0100007F:C096 01 00000000:00000000 00:00000000 00000000 101 0 49558 1 0000000000000000 20 4 26 10 -1
11: 0100007F:115B 0100007F:B502 06 00000000:00000000 03:00000841 00000000 0 0 0 3 0000000000000000
12: 1B01010A:8290 A401010A:01BB 01 00000000:00000000 00:00000000 00000000 101 0 11850 1 0000000000000000 20 4 28 10 -1
13: 1B01010A:01BB 017100CB:90FA 01 00000000:00000000 00:00000000 00000000 101 0 21476 1 0000000000000000 35 4 31 10 -1
14: 1B01010A:23A0 0204A8C0:992E 06 00000000:00000000 03:00000C0A 00000000 0 0 0 3 0000000000000000
15: 1B01010A:CB4C B001010A:01BB 06 00000000:00000000 03:00000ECB 00000000 0 0 0 3 0000000000000000
16: 1B01010A:9C42 2700010A:01BB 06 00000000:00000000 03:00001259 00000000 0 0 0 3 0000000000000000
17: 1B01010A:01BB 0100010A:B2AC 01 00000000:00000000 00:00000000 00000000 101 0 77631 1 0000000000000000 20 4 31 10 -1
18: 1B01010A:01BB E600020A:BBE8 06 00000000:00000000 03:00000992 00000000 0 0 0 3 0000000000000000
19: 0100007F:C99E 0100007F:115B 06 00000000:00000000 03:000016BE 00000000 0 0 0 3 0000000000000000
20: 1B01010A:C1FE 2700010A:01BB 06 00000000:00000000 03:00000662 00000000 0 0 0 3 0000000000000000
21: 1B01010A:01BB E600020A:B5C4 06 00000000:00000000 03:000005EE 00000000 0 0 0 3 0000000000000000
22: 1B01010A:840E B001010A:01BB 01 00000000:00000000 00:00000000 00000000 101 0 21477 1 0000000000000000 20 4 30 10 -1
23: 1B01010A:D22A 4E01010A:01BB 01 00000000:00000000 00:00000000 00000000 101 0 77632 1 0000000000000000 21 4 30 10 -1
24: 1B01010A:01BB 017100CB:956A 01 00000000:00000000 00:00000000 00000000 101 0 62858 1 0000000000000000 29 4 30 10 -1
25: 0100007F:115B 0100007F:B4F6 06 00000000:00000000 03:000005ED 00000000 0 0 0 3 0000000000000000
26: 1B01010A:23A0 0204A8C0:8EE6 06 00000000:00000000 03:00001408 00000000 0 0 0 3 0000000000000000
27: 1B01010A:01BB EC01010A:94DE 01 00000000:00000000 00:00000000 00000000 101 0 11849 1 0000000000000000 20 4 31 10 -1
28: 0100007F:818C 0100007F:115B 01 00000000:00000000 00:00000000 00000000 101 0 62859 1 0000000000000000 21 4 24 10 -1
29: 1B01010A:23A0 0204A8C0:D5DE 06 00000000:00000000 03:0000020F 00000000 0 0 0 3 0000000000000000
30: 0100007F:115B 0100007F:B4F8 06 00000000:00000000 03:000005F6 00000000 0 0 0 3 0000000000000000
31: 1B01010A:A828 7600010A:63DD 06 00000000:00000000 03:00000F7F 00000000 0 0 0 3 0000000000000000
32: 0100007F:C096 0100007F:115B 01 00000000:00000000 00:00000000 00000000 101 0 49557 1 0000000000000000 20 4 24 10 -1
33: 1B01010A:D444 FE00010A:2AF8 01 00000000:00000000 00:00000000 00000000 101 0 49559 1 0000000000000000 20 4 30 10 -1
34: 1B01010A:9032 B001010A:01BB 01 00000000:00000000 00:00000000 00000000 101 0 11954 1 0000000000000000 20 4 30 10 -1
35: 1B01010A:977E 2700010A:01BB 06 00000000:00000000 03:00000C72 00000000 0 0 0 3 0000000000000000
36: 0100007F:115B 0100007F:B4EA 06 00000000:00000000 03:000005F0 00000000 0 0 0 3 0000000000000000
37: 1B01010A:08AE 0204A8C0:8290 01 00000048:00000000 01:00000017 00000000 0 0 86478 4 0000000000000000 24 8 15 10 -1
38: 1B01010A:9ED0 2700010A:01BB 06 00000000:00000000 03:0000170F 00000000 0 0 0 3 0000000000000000
39: 1B01010A:0050 027100CB:843E 01 0000018C:00000000 01:00000E57 00000007 101 0 84306 2 0000000000000000 4864 4 31 1 7
40: 1B01010A:CB5C B001010A:01BB 06 00000000:00000000 03:00000ED6 00000000 0 0 0 3 0000000000000000
41: 1B01010A:9776 2700010A:01BB 06 00000000:00000000 03:00000875 00000000 0 0 0 3 0000000000000000
42: 1B01010A:C59C FE00010A:2AF8 01 00000000:00000000 00:00000000 00000000 101 0 62861 1 0000000000000000 21 4 30 10 -1
43: 1B01010A:9154 2700010A:01BB 06 00000000:00000000 03:00001572 00000000 0 0 0 3 0000000000000000
44: 1B01010A:23A0 0204A8C0:9C42 06 00000000:00000000 03:00000FF3 00000000 0 0 0 3 0000000000000000
+26
View File
@@ -0,0 +1,26 @@
# Verbatim /proc/net/tcp6 from gateway-ops, read read-only over the ssh key by
# ./scripts/capture-gateway-config.sh (AGENTS.md invariant 7: verify against the
# deployed upstream, never guess its shape).
# One line per TCP socket, in the kernel's own spelling. The only edit is the repo's
# identifier rule (invariant 6, the drive-serial scrub in capture-fixtures.sh): a
# globally routable address became a documentation one (RFC 5737 / RFC 3849), because a
# global IPv6 here is EUI-64 and a visitor's address is not ours to publish. Every port,
# state, queue, uid, inode and column width is verbatim, as are the loopback, private
# and wildcard addresses the gateway's own frontends use.
# Volatile by nature: a proxy in service has a different connection set every second, so
# a diff here on re-capture is expected. The tests assert shape, the two states the agent
# keeps, and the ports the captured configs claim to bind — never a particular row.
sl local_address remote_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
0: 00000000000000000000000000000000:20FB 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 9979 1 0000000000000000 100 0 0 10 0
1: 00000000000000000000000000000000:08AE 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 7799 1 0000000000000000 100 0 0 10 0
2: 00000000000000000000000000000000:1451 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 99 0 9181 1 0000000000000000 100 0 0 10 0
3: 00000000000000000000000000000000:0050 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 9975 1 0000000000000000 100 0 0 10 0
4: 00000000000000000000000000000000:0016 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 99 0 9179 1 0000000000000000 100 0 0 10 0
5: 00000000000000000000000000000000:01BB 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 9973 1 0000000000000000 100 0 0 10 0
6: 00000000000000000000000000000000:115B 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 9977 1 0000000000000000 100 0 0 10 0
7: 00000000000000000000000000000000:63DE 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 99 0 9184 1 0000000000000000 100 0 0 10 0
8: 00000000000000000000000000000000:63DD 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 99 0 9183 1 0000000000000000 100 0 0 10 0
9: 00000000000000000000000000000000:07D0 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 99 0 9180 1 0000000000000000 100 0 0 10 0
10: 00000000000000000000000000000000:1F90 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 99 0 9182 1 0000000000000000 100 0 0 10 0
11: B80D0120000000000000000001000000:01BB 2377D3FDF349E0970000000003000000:C0AC 01 00000000:00000000 00:00000000 00000000 101 0 49556 1 0000000000000000 80 4 26 10 -1
12: B80D0120000000000000000001000000:01BB 2377D3FDF349E0970000000003000000:A120 01 00000000:00000000 00:00000000 00000000 101 0 11953 1 0000000000000000 59 4 30 7 7
+1662 -11
View File
File diff suppressed because it is too large Load Diff
+49
View File
@@ -249,6 +249,55 @@ async def test_real_degradations_still_show_when_smart_noise_is_alongside() -> N
assert result.payload["degraded"] == ["temperatures: no hwmon sensors"]
async def test_a_host_without_a_proxy_config_does_not_degrade_the_system_source() -> None:
"""The proxy add-on's absence lines, same rule and same reason as `smart:` above: two of this
fleet's three hosts run no haproxy and no nginx, so an agent upgrade must not put a
`degraded` payload (and therefore an overview alert, `alerts.py` reads this key) on a box that
never had either. The lines survive in `agent.errors` for the page that owns them — phase 29's
gateway source reads them the way `/disks` reads `smart_errors`.
`collectors.ADDON_ERROR_PREFIXES` is the one list of these prefixes; this asserts every
name by behaviour, so dropping any of them fails here, and `tests/test_agent.py` pins both
the wording of the lines the agent actually writes and the equality of the two lists.
"""
missing_haproxy = ("haproxy: /etc/haproxy/haproxy.cfg not found — haproxy is not installed "
"here, or its config lives elsewhere (--haproxy-config); --no-proxy "
"opts out")
missing_nginx = ("nginx: /etc/nginx/nginx.conf not found — nginx is not installed here, or "
"its config lives elsewhere (--nginx-config); --no-proxy opts out")
no_sockets = ("sockets: /proc/net/tcp not found, /proc/net/tcp6 not found — this host "
"publishes no kernel socket table; --no-proxy opts out")
body = agent_body()
body["errors"] = [missing_haproxy, missing_nginx, no_sockets,
"smart: smartctl 7.5 not found"]
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=body)
async with client_for(handler) as client:
result = await fetch_host_snapshot(client, Target("gw", "http://agent.test"),
HostCollector(RateEstimator()))
assert "degraded" not in result.payload, "unconfigured is not broken (invariant 3)"
for prefix in ("haproxy:", "nginx:", "sockets:"):
assert any(prefix in str(e) for e in result.payload["agent"]["errors"]), \
"the sentence survives for the page that explains it"
async def test_a_real_degradation_shows_alongside_the_proxy_addon_noise() -> None:
"""Filtering the add-ons must not swallow a genuine half-snapshot beside them."""
body = agent_body()
body["errors"] = ["nginx: /etc/nginx/nginx.conf is there but could not be read: boom",
"temperatures: no hwmon sensors"]
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=body)
async with client_for(handler) as client:
result = await fetch_host_snapshot(client, Target("gw", "http://agent.test"),
HostCollector(RateEstimator()))
assert result.payload["degraded"] == ["temperatures: no hwmon sensors"]
# --------------------------------------------------------------------------- #
# physical disks (the /disks source's fetch half)
# --------------------------------------------------------------------------- #
+386 -1
View File
@@ -1,4 +1,12 @@
"""Contract tests for the scripts an operator runs: `deploy/` and `scripts/dev-*.sh`/`.py`.
"""Contract tests for the scripts an operator runs: `deploy/`, `scripts/dev-*.sh`/`.py`, and
`scripts/capture-gateway-config.sh`.
The capture script gets the same treatment as the installer because it can do the same kind of
damage quietly: pointed at a gateway it cannot read, it is six empty files where six verbatim
captures were, and every parser test that reads them then passes against nothing. So it is run
here, against a stub `ssh` and a fake remote tree, and the assertions are about what lands on
disk — including the one edit it makes (a globally routable socket address becomes documentation
space) and the thing it never does (rewrite a config).
There is no shell test framework here — and adding one would buy nothing a subprocess does
not — but phase 10 rests on a sentence that lives in **two** files: the sudoers grant in
@@ -46,6 +54,7 @@ from dashboards.store import SampleStore
ROOT = Path(__file__).resolve().parent.parent
DEPLOY = ROOT / "deploy"
SCRIPTS = ROOT / "scripts"
INSTALLER = DEPLOY / "install-agent.sh"
SUDOERS = DEPLOY / "agent-sudoers"
BASH = shutil.which("bash") or "/bin/bash"
@@ -2334,3 +2343,379 @@ def test_a_local_bundle_still_validates_with_its_own_flag_name(tmp_path: Path) -
assert result.returncode == 2
assert result.stderr.startswith("--cert-bundle"), result.stderr
assert "--fetch-bundle" not in result.stderr
# --- the gateway capture (phase 28 task 04) ------------------------------------------------
#: The script that records `tests/fixtures/gateway_*` — the ground truth the agent's `haproxy`,
#: `nginx` and `sockets` parsers are pinned to. It has no Python-side coverage to speak of (the
#: project's coverage source is `src/dashboards`), so the pins here **run it**, against a fake
#: remote reached through a stubbed `ssh`, the way the installer sandbox stubs `install`.
CAPTURE = SCRIPTS / "capture-gateway-config.sh"
#: The remote documents, and the fixture each one becomes. A new include directory on the gateway
#: means a new entry here *and* a new `case` in the script — the script refuses silently-unknown
#: globs for exactly this reason.
CAPTURE_TARGETS = (
"/etc/haproxy/haproxy.cfg",
"/etc/nginx/nginx.conf",
"/etc/nginx/conf.d/*.conf",
"/etc/nginx/stream.d/*.conf",
"/proc/net/tcp",
"/proc/net/tcp6",
)
#: The stub `ssh`. It drops the options the script passes, records every call, and answers `cat`
#: and `ls` from `$FAKE_REMOTE` as if that directory were the remote filesystem's `/`. Nothing
#: here can reach a network: the point is to exercise the script's own branches (the include
#: globs, the refusal rule, the socket-table edit) with no gateway in sight.
CAPTURE_SSH_STUB = """#!/bin/sh
printf '%s\\n' "$*" >> "$STUB_LOG"
while [ $# -gt 0 ]; do
case "$1" in
-o) shift 2 ;;
-n) shift ;;
*) break ;;
esac
done
host="$1"; shift
command="$*"
if [ "${FAIL_SSH:-0}" = "1" ]; then
echo "ssh: connect to host $host port 22: Connection refused" >&2
exit 255
fi
case "$command" in
"cat "*)
path="${command#cat }"
file="$FAKE_REMOTE$path"
[ -f "$file" ] || { echo "cat: $path: No such file or directory" >&2; exit 1; }
cat "$file"
;;
"ls "*)
spec="${command#ls }"
dir="${spec%/*}"
glob="${spec##*/}"
found=0
for file in "$FAKE_REMOTE$dir"/$glob; do
[ -e "$file" ] || continue
found=1
printf '%s\\n' "${file#"$FAKE_REMOTE"}"
done
[ "$found" = 1 ] || { echo "ls: cannot access '$spec': No such file or directory" >&2; exit 2; }
;;
*)
echo "stub ssh: unsupported remote command: $command" >&2
exit 2
;;
esac
"""
#: A gateway tree small enough to assert on whole, with the two shapes the parsers exist for: a
#: `use_backend` whose target is a fetch expression, and a stream `proxy_pass` of a map variable.
CAPTURE_FAKE_GATEWAY: dict[str, str] = {
"/etc/haproxy/haproxy.cfg": (
"global\n maxconn 4096\n"
"defaults\n mode tcp\n"
"frontend haproxy-22\n bind :::22 v4v6\n default_backend gitea-2222\n"
"frontend haproxy-8443\n bind :::8443 v4v6\n"
" use_backend %[req.hdr(host),lower,map_dom(/etc/haproxy/maps,b0)\n"
"backend gitea-2222\n server gitea gitea.reeselink.com:2222 check inter 5s\n"
),
"/etc/nginx/nginx.conf": (
"user nginx;\n"
"stream {\n include /etc/nginx/stream.d/*.conf;\n}\n"
"http {\n"
" include /etc/nginx/mime.types;\n"
" server { listen 80; listen [::]:80; server_name _; return 301 https://$host; }\n"
" include /etc/nginx/conf.d/*.conf;\n}\n"
),
"/etc/nginx/conf.d/http.conf": (
"server {\n"
" listen 4443 ssl proxy_protocol;\n"
" server_name nextcloud.reeseapps.com;\n"
" ssl_certificate_key /etc/letsencrypt/live/nextcloud.reeseapps.com/privkey.pem;\n"
" location / { proxy_pass http://nextcloud.reeselink.com:11000; }\n}\n"
),
"/etc/nginx/conf.d/tls.conf": (
"server {\n listen 8443 ssl;\n server_name brain.reeseapps.com;\n"
" location / { proxy_pass http://reeseapps-brain-of-reese.reeselink.com:443; }\n}\n"
),
"/etc/nginx/stream.d/stream.conf": (
"map $ssl_preread_server_name $targetBackend {\n"
" default 127.0.0.1:4443;\n"
" brain.example reeseapps-brain-of-reese.reeselink.com:443;\n}\n"
"server {\n listen 443;\n proxy_pass $targetBackend;\n}\n"
),
# 8.8.8.8 and 2603:6013::1 are globally routable, `::ffff:8.8.8.8` is a visitor behind a v6
# socket, and 203.0.113.7 is already documentation space. The tx/rx queue pair on the last row
# is spelled like an address column and is not one.
"/proc/net/tcp": (
" sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid"
" timeout inode\n"
" 0: 00000000:1EFF 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0"
" 0 1 1 0000000000000000 100 0 0 10 0\n"
" 1: 1B01010A:01BB 08080808:911A 01 00000000:00000000 00:00000000 00000000 101"
" 0 2 1 00000000d22dedba 20 4 28 10 -1\n"
" 2: 0100007F:23A0 017100CB:C0A8 06 08080808:08080808 00:00000000 00000000 0"
" 0 3 3 0000000000000000 20 0 0 10 0\n"
),
"/proc/net/tcp6": (
" sl local_address remote_address"
" st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n"
" 0: 00000000000000000000000000000000:01BB 00000000000000000000000000000000:0000 0A"
" 00000000:00000000 00:00000000 00000000 0 0 4 1 0000000000000000 100 0 0 10 0\n"
" 1: 1360032601010031FF005450E0B284FE:01BB 0000000000000000FFFF000008080808:C0AC 01"
" 00000000:00000000 00:00000000 00000000 101 0 5 1 0000000000000000 63 4 26 10 -1\n"
),
}
def _capture_sandbox(
tmp_path: Path,
gateway: dict[str, str] | None = None,
*,
recorded: dict[str, str] | None = None,
fail_ssh: bool = False,
alias: str | None = None,
) -> tuple[Path, Path, Path, dict[str, str]]:
"""A copy of the capture script, a fake gateway, and a PATH whose `ssh` reaches only the fake.
Returns (script, fixtures dir, stub call log, env). The script is copied into
`<tmp>/scripts/` because it derives `tests/fixtures/` from its own location, which is what
keeps the repository's fixtures out of reach of a test.
"""
script = tmp_path / "scripts" / CAPTURE.name
script.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(CAPTURE, script)
fake = tmp_path / "gateway"
for path, body in {**CAPTURE_FAKE_GATEWAY, **(gateway or {})}.items():
target = fake / path.lstrip("/")
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(body, encoding="utf-8")
fixtures = tmp_path / "tests" / "fixtures"
fixtures.mkdir(parents=True, exist_ok=True)
for name, body in (recorded or {}).items():
(fixtures / name).write_text(body, encoding="utf-8")
stubs = tmp_path / "stubs"
stubs.mkdir()
stub = stubs / "ssh"
stub.write_text(CAPTURE_SSH_STUB, encoding="utf-8")
stub.chmod(0o755)
call_log = tmp_path / "ssh-calls.log"
call_log.write_text("", encoding="utf-8")
env = {**os.environ,
"PATH": f"{stubs}{os.pathsep}{os.environ['PATH']}",
"STUB_LOG": str(call_log),
"FAKE_REMOTE": str(fake),
"FAIL_SSH": "1" if fail_ssh else "0"}
if alias is not None:
env["GATEWAY_SSH"] = alias
return script, fixtures, call_log, env
def _capture(tmp_path: Path, **kwargs: object) -> subprocess.CompletedProcess[str]:
script, _fixtures, _log, env = _capture_sandbox(tmp_path, **kwargs) # type: ignore[arg-type]
return subprocess.run([BASH, str(script)], capture_output=True, text=True, env=env, check=False)
def _fixture_body(path: Path) -> str:
"""A fixture without its `#` provenance block — the bytes the host answered."""
return "".join(line for line in path.read_text().splitlines(keepends=True)
if not line.startswith("#"))
def test_the_gateway_capture_exists_and_asks_for_no_privilege() -> None:
"""Six world-readable reads over an ssh key: nothing here needs root or a rewrite tool."""
assert CAPTURE.is_file(), "scripts/capture-gateway-config.sh is missing (phase 28 task 04)"
assert os.access(CAPTURE, os.X_OK)
text = CAPTURE.read_text()
for path in CAPTURE_TARGETS:
assert path in text, f"{path} is no longer captured"
code = "\n".join(line for line in text.splitlines() if not line.lstrip().startswith("#"))
assert "sudo" not in code, (
"the capture must read exactly what dashboards-agent can read; a pass that needed root "
"would record more than the agent can see (the smartctl note in capture-fixtures.sh)"
)
for tool in ("sed ", "awk ", " tr ", "cut "):
assert tool not in text, f"{tool.strip()} would be a text filter between the read and a fixture"
def test_the_gateway_capture_dials_the_alias_and_nothing_else(tmp_path: Path) -> None:
"""No hardcoded address: every remote command goes to `$GATEWAY_SSH`, default `gateway-ops`."""
text = CAPTURE.read_text()
assert 'GATEWAY_SSH="${GATEWAY_SSH:-gateway-ops}"' in text
# Comments may name an address (`0.0.0.0` is a shape the parser has to survive); code may not.
code = "\n".join(line for line in text.splitlines() if not line.lstrip().startswith("#"))
stray = [a for a in re.findall(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", code)
if not a.startswith("203.0.113.")]
assert not stray, f"the script dials or embeds an address: {stray}"
_script, _fixtures, log, env = _capture_sandbox(tmp_path)
subprocess.run([BASH, str(_script)], capture_output=True, text=True, env=env, check=False)
assert _dialled_hosts(log) == {"gateway-ops"}
other = _capture_sandbox(tmp_path / "alias", alias="lab-gw")
subprocess.run([BASH, str(other[0])], capture_output=True, text=True, env=other[3], check=False)
assert _dialled_hosts(other[2]) == {"lab-gw"}
def _dialled_hosts(log: Path) -> set[str]:
"""The host argument of every ssh the stub was called for — options are not hosts."""
hosts = set()
for line in log.read_text().splitlines():
tokens = [token for token in line.split() if not token.startswith("-") and "=" not in token]
if tokens:
hosts.add(tokens[0])
return hosts
def test_the_gateway_capture_records_every_document_of_the_tree_it_read(tmp_path: Path) -> None:
"""The happy path, run: one fixture per remote document, include globs and all.
Two files in `conf.d` is deliberate. The script reads the include globs out of the captured
main config rather than a hardcoded pair of directories, so a gateway that adds a vhost file
gets it captured, and `files` in the payload stays the tree the config actually merges.
"""
result = _capture(tmp_path)
assert result.returncode == 0, result.stdout + result.stderr
fixtures = tmp_path / "tests" / "fixtures"
expected = {"gateway_haproxy.cfg", "gateway_nginx_main.conf",
"gateway_nginx_conf_d_http.conf", "gateway_nginx_conf_d_tls.conf",
"gateway_nginx_stream_d_stream.conf",
"gateway_proc_net_tcp.txt", "gateway_proc_net_tcp6.txt"}
assert {p.name for p in fixtures.iterdir()} == expected, result.stdout
assert _fixture_body(fixtures / "gateway_haproxy.cfg") == \
CAPTURE_FAKE_GATEWAY["/etc/haproxy/haproxy.cfg"]
assert _fixture_body(fixtures / "gateway_nginx_conf_d_tls.conf") == \
CAPTURE_FAKE_GATEWAY["/etc/nginx/conf.d/tls.conf"]
# `mime.types` is included by the config and absent on the host: no fixture, no complaint —
# the same absence-the-agent-reports rule, one level up.
assert not (fixtures / "gateway_nginx_mime.types").exists()
def test_the_gateway_capture_writes_the_domains_it_read_verbatim(tmp_path: Path) -> None:
"""Nothing is scrubbed from a config, and the domains are the proof.
The `server_name` and the `proxy_pass` target beside it are the ingress map, and the
`ssl_certificate_key` is a path rather than a key — so the file the fixture holds is the file
the gateway runs, byte for byte. The one exception in this script is the socket tables'
globally routable addresses, which `capture-fixtures.sh`'s identifier rule requires.
"""
_capture(tmp_path)
vhosts = (tmp_path / "tests" / "fixtures" / "gateway_nginx_conf_d_http.conf").read_text()
assert "server_name nextcloud.reeseapps.com;" in vhosts
assert "proxy_pass http://nextcloud.reeselink.com:11000;" in vhosts
assert "ssl_certificate_key /etc/letsencrypt/live/nextcloud.reeseapps.com/privkey.pem;" in vhosts
assert "SCRUB" not in vhosts and "REDACTED" not in vhosts
assert next((line for line in vhosts.splitlines()
if line.startswith("# Verbatim /etc/nginx/conf.d/http.conf from gateway-ops")),
None), "every fixture says which remote document it is"
def test_a_dead_gateway_leaves_the_recorded_fixtures_alone(tmp_path: Path) -> None:
"""The `slots_gufo.json` rule: a capture script must never turn six good fixtures into six
empty files, because then the parser tests pass against nothing (or fail for the wrong
reason) and the ground truth is gone."""
keep = {"gateway_haproxy.cfg": "# old\nfrontend kept\n bind :::22 v4v6\n",
"gateway_proc_net_tcp.txt": " 0: 00000000:1EFF 00000000:0000 0A x\n"}
result = _capture(tmp_path, recorded=keep, fail_ssh=True)
assert result.returncode == 1, result.stdout
for name, body in keep.items():
assert (tmp_path / "tests" / "fixtures" / name).read_text() == body, \
f"{name} was overwritten by a read that failed"
for name in keep:
assert f"✗ {name}" in result.stdout, result.stdout
assert "Connection refused" in result.stdout, "one line names the reason, per fixture"
assert sorted(p.name for p in (tmp_path / "tests" / "fixtures").iterdir()) == sorted(keep), \
"a failed capture leaves no temp files behind"
def test_an_empty_read_is_refused_the_way_a_failed_one_is(tmp_path: Path) -> None:
"""`cat` exiting 0 on an empty file is still not a capture."""
keep = {"gateway_proc_net_tcp.txt": " 0: 00000000:1EFF 00000000:0000 0A keep me\n"}
result = _capture(tmp_path, gateway={"/proc/net/tcp": ""}, recorded=keep)
assert result.returncode == 1, result.stdout
assert "gateway_proc_net_tcp.txt" in result.stdout
assert "empty" in result.stdout
assert (tmp_path / "tests" / "fixtures" / "gateway_proc_net_tcp.txt").read_text() == \
next(iter(keep.values()))
def test_an_include_dir_with_no_fixture_name_is_reported_and_fails(tmp_path: Path) -> None:
"""A config that moved its vhosts is a loud failure, not a fixture set that quietly stopped
being the tree the agent merges."""
moved = {"/etc/nginx/nginx.conf": (
"http {\n include /etc/nginx/sites-enabled/*.conf;\n}\n"
"stream {\n include /etc/nginx/stream.d/*.conf;\n}\n")}
result = _capture(tmp_path, gateway=moved)
assert result.returncode == 1, result.stdout
assert "/etc/nginx/sites-enabled/*.conf" in result.stdout
assert not (tmp_path / "tests" / "fixtures" / "gateway_nginx_sites_enabled_web.conf").exists()
def test_a_config_with_no_glob_include_says_the_vhost_fixtures_were_not_refreshed(
tmp_path: Path) -> None:
keep = {"gateway_nginx_conf_d_http.conf": "# keep\nserver {\n listen 4443;\n}\n"}
result = _capture(tmp_path, gateway={"/etc/nginx/nginx.conf": "user nginx;\nevents { }\n"},
recorded=keep)
assert result.returncode == 1, result.stdout
assert "no glob include" in result.stdout
assert (tmp_path / "tests" / "fixtures" / "gateway_nginx_conf_d_http.conf").read_text() == \
next(iter(keep.values()))
def test_the_socket_capture_publishes_no_globally_routable_endpoint(tmp_path: Path) -> None:
"""The one edit the capture makes, run rather than asserted in prose.
Two rules at once, which is what makes the socket fixtures both usable and publishable: a
globally routable address (including one behind `::ffff:`) becomes documentation space, and
everything else — every port, every private/loopback/wildcard word, every other column, and
the column widths themselves — is byte-for-byte what the kernel answered. A tx/rx queue pair
is spelled exactly like an address column and must survive untouched, which is why the edit
reads two columns and not the line.
"""
_capture(tmp_path)
fixtures = tmp_path / "tests" / "fixtures"
v4 = _fixture_body(fixtures / "gateway_proc_net_tcp.txt").splitlines()
v6 = _fixture_body(fixtures / "gateway_proc_net_tcp6.txt").splitlines()
raw_v4 = CAPTURE_FAKE_GATEWAY["/proc/net/tcp"].splitlines()
raw_v6 = CAPTURE_FAKE_GATEWAY["/proc/net/tcp6"].splitlines()
assert len(v4) == len(raw_v4) and len(v6) == len(raw_v6), "a row was added or lost"
assert v4[1] == raw_v4[1], "a wildcard listener must survive untouched"
assert "08080808:911A" not in "".join(v4), "8.8.8.8 is still in the fixture"
assert "017100CB:911A" in v4[2], v4[2] # 203.0.113.1, the port kept
assert len(v4[2]) == len(raw_v4[2]), "the replacement must be exactly as wide as the original"
assert v4[3] == raw_v4[3], (
"an already-documentation address and the tx/rx queue pair on that row both moved: only "
"the two address columns may be looked at")
assert "08080808:08080808" in v4[3], v4[3]
assert "1360032601010031FF005450E0B284FE" not in "".join(v6), "a global v6 is left in"
assert "B80D0120000000000000000001000000:01BB" in v6[2], v6[2] # 2001:db8::1, port kept
assert "FFFF0000017100CB:C0AC" in v6[2] or "FFFF0000" in v6[2], \
"a v4-mapped row must stay v4-mapped, or the parser's branch for it stops being exercised"
assert v6[1] == raw_v6[1], "the wildcard v6 listener moved"
assert len(v6[2]) == len(raw_v6[2])
def test_the_socket_capture_is_idempotent_so_a_re_run_does_not_churn(tmp_path: Path) -> None:
"""Re-recording the same moment must not diff, or every capture moves the fixture twice.
Documentation space is not globally routable, so the second pass leaves it alone — the same
property that makes the drive-serial scrub re-runnable.
"""
once = tmp_path / "once"
_capture(once)
again = _capture_sandbox(tmp_path / "twice",
gateway={"/proc/net/tcp": _fixture_body(
once / "tests" / "fixtures" / "gateway_proc_net_tcp.txt")})
result = subprocess.run([BASH, str(again[0])], capture_output=True, text=True,
env=again[3], check=False)
assert result.returncode == 0, result.stdout + result.stderr
assert _fixture_body(again[1] / "gateway_proc_net_tcp.txt") == _fixture_body(
once / "tests" / "fixtures" / "gateway_proc_net_tcp.txt")