18 pages cut their per-endpoint shape, payload, port and window claims and
link [[frank-archon]] / [[frank-dragoon]] / [[frank-zealot]] instead. Several
of the cut claims had already drifted -- the stack page still described the
kingjones ROCmFP4 era on ROCmFPX, OpenWebUI carried the 27B/3-slot bench
narrative, Hermes named a retired max_model_len and the old service name, and
the OMP harness page's live-window block had drifted off frank-zealot's real
262,144 (it is now labelled a dated worked example whose authority is the
endpoint page).
Two facts gain a single owner instead of two: the up{job=~"frank-.*"} scope
lives in Infrastructure/Prometheus.md, and the blackbox/Anubis probe form in
Architecture/Stale Signal Audit.md, each cited from the other page.
Voice Packs and Frank TTS carried live-state claims that contradicted the
engine page's 2026-10-18 deploy record (nothing listening on 7772/8880, model
dir absent); they now point at the pages that own host state.
129 lines
5.9 KiB
Markdown
129 lines
5.9 KiB
Markdown
---
|
|
type: reference
|
|
title: Container Host Reachability
|
|
description: The two podman/netavark reachability quirks on the fleet — containers cannot dial unpublished host ports (LAN IP or loopback), and netns membership decides what "reachable" means. Probes that prove it, fixes, and the pasta port gate.
|
|
tags: [podman, netavark, networking, rootless, reachability, caddy]
|
|
timestamp: 2026-10-05T00:00:00-07:00
|
|
---
|
|
|
|
# Container Host Reachability
|
|
|
|
Two failure families on rootless podman + netavark hosts (Dankclaw), both
|
|
reading as "service down" when the service is up and the *path* is wrong.
|
|
Fleet source of truth: `serverAppConfigs-docs/Architecture/Container Network
|
|
Namespaces.md` (commit `2e179b0`).
|
|
|
|
## Quirk 1 — LAN-IP dialing of an unpublished host port refuses instantly
|
|
|
|
A container cannot reach a `Network=host` service at the host's LAN IP.
|
|
netavark DNATs **published** ports only: from a bridge container,
|
|
`192.168.0.143:<published>` answers while `192.168.0.143:<host bind>` and the
|
|
gateway `10.89.1.1` are both refused. This is what 502'd
|
|
`hermes.franksdank.cloud` until the Caddyfile upstream became
|
|
`host.containers.internal` on 2026-09-16 — its vhost comment had claimed the
|
|
LAN IP was open from that bridge; it never was.
|
|
|
|
Probe that proves it in one command:
|
|
|
|
```bash
|
|
ssh <host> "podman run --rm --network <net> --entrypoint sh \
|
|
docker.io/library/busybox:latest -c \
|
|
'for p in <target-port> <published-port>; do
|
|
nc -w2 -z -v <host-lan-ip> \$p >/dev/null 2>&1 && echo port-\$p-OK || echo port-\$p-BAD
|
|
done'"
|
|
podman exec <svc> bash -c 'exec 3<>/dev/tcp/host.containers.internal/<port> && echo ALIAS-OK'
|
|
```
|
|
|
|
Published OK + target BAD ⇒ quirk confirmed. Note busybox `nc -z` reports any
|
|
failure as failure — use it for bulk sweeps; use `/dev/tcp` (or `nc -v`) when
|
|
refusal-vs-timeout matters.
|
|
|
|
Fix: point the service's host-facing URL at
|
|
`http://host.containers.internal:<port>` in compose/quadlet env, redeploy via
|
|
git (`deploy.sh <target> --service <svc>`), verify
|
|
`podman exec <svc> printenv <URL_VAR>` and the connect log line (~40 s).
|
|
**Do not publish the daemon port** to make the LAN IP work — publishing is
|
|
exactly what makes other ports refuse.
|
|
|
|
## Quirk 2 — addressability is per netns, not per host
|
|
|
|
- `NetworkMode=host` → the container **is** the host netns: dials all of
|
|
`127.0.0.1`, binds any free port.
|
|
- bridge/named net → own netns; only published ports are reachable, via the
|
|
network's **gateway IP**. Unpublished host listeners refuse **instantly
|
|
(RST)** — no firewall rule, no stale DNAT, `nft`/`iptables -t nat` empty. Do
|
|
not hunt for REJECT rules; probe.
|
|
|
|
Probe sequence (read-only):
|
|
|
|
```bash
|
|
podman inspect <name> --format '{{.HostConfig.NetworkMode}}'
|
|
sudo ss -ltnp 'sport = :<port>' # host netns only — why 0.0.0.0 can still be unreachable
|
|
podman exec <name> cat /proc/net/route # default route = the gateway IP to dial
|
|
podman exec <name> sh -c 'timeout 2 bash -c "exec 3<>/dev/tcp/<gw-ip>/<port>"; echo rc=$?'
|
|
```
|
|
|
|
Interpretation without blurring:
|
|
|
|
- **instant refusal** = nothing listening in a reachable namespace (or
|
|
rootlessport rejected an unpublished port map).
|
|
- **timeout** = no route to that address at all. `host.containers.internal`
|
|
(`169.254.1.2`) is an alias on the **default `podman` net only**; containers
|
|
on a named network time out against it.
|
|
|
|
Tool gotchas (podman 5.x, verified live):
|
|
|
|
- `podman network inspect <net> --format '{{json .containers}}'` errors
|
|
(`can't evaluate field containers in type interface {}`) — list membership
|
|
from the container side: `podman ps -a --format '{{.Names}} {{.Networks}}'`.
|
|
- An **empty** `Networks` field = host network or shared namespace
|
|
(`--network=container:<other>`), not "no network".
|
|
- Quadlet `Network=<name>.network` yields the runtime name **`systemd-<name>`**
|
|
— probe with `podman network ls`, never the `.network` filename.
|
|
|
|
Fix options when a bridge-net container must reach a host-netns listener:
|
|
(a) move it to `Network=host`, or (b) publish the port — (b) makes the port
|
|
reachable from every namespace **and the LAN**, an exposure change needing
|
|
explicit operator approval, never a silent fix.
|
|
|
|
## Related trap — the pasta port gate (rootless published ports)
|
|
|
|
On hosts publishing ports through pasta, the port is bound in **userspace at
|
|
container CREATE**, not when the app binds. A predecessor stopped moments
|
|
earlier can still hold listener/TIME_WAIT state, so the replacement dies
|
|
`pasta: ... bind: Address already in use` (conmon exit 126) — then **self-heals
|
|
seconds later** under `Restart=on-failure`. The failure looks like it failed,
|
|
then looks fine, and the missing gate stays invisible because the retry masks
|
|
it. First seen on a `frank-dragoon` unit cutover; which supervision shape that
|
|
era was is in [[frank-dragoon]]'s era table.
|
|
|
|
Gate procedure: stop the old unit AND the new one (a redeploy's running
|
|
replacement is itself the port-holder — without that stop the gate deadlocks
|
|
against itself), then wait for the port to be empty across **all socket
|
|
states**:
|
|
|
|
```bash
|
|
wait_port_free() {
|
|
local port="$1" limit="${2:-90}" i
|
|
for ((i = 1; i <= limit; i++)); do
|
|
if [ -z "$(ss -Htan "sport = :$port" 2>/dev/null)" ]; then return 0; fi
|
|
sleep 1
|
|
done
|
|
echo "FAIL: port $port still held after ${limit}s:" >&2
|
|
ss -tan "sport = :$port" >&2 || true
|
|
return 1
|
|
}
|
|
```
|
|
|
|
`sport` catches accepted-connection server sockets and TIME_WAIT residue that
|
|
`ss -ltn` misses; fail loudly with the socket table attached. Verify the gate
|
|
by re-running the same deploy against a running service (idempotency) and
|
|
assert it in seam tests (`wait_port_free` present in the deploy handler).
|
|
|
|
## Related
|
|
|
|
- [[Quadlet Lifecycle]] — the unit layer these containers run under
|
|
- [[Container Network Namespaces]] — fleet network namespace source of truth
|
|
- [[Observability]] — the alerting paths that depend on reachability
|
|
- [[Deploy State Triage]] — the deploy flow that installs these configs
|