R-66: the box's own address becomes visible (v0.159.0)

Leg A: „Hálózat" card on Beállítások → Rendszer — Helyi cím (LAN),
Hálózati név (only while Megosztás is enabled), Átjáró; live per render,
stored nowhere (S-5), „—" on unavailable.
Leg B: network section in the Debug system dump (interfaces/route/DNS/
lan_address), best-effort per item via the samba-netns door.
Leg C: NetBIOS trap named — Szerver field helper text + a purely lexical
hint on unreachable failures for single-label non-IP names.

Design note: all guest-net reads go through docker exec into the
host-networked felhom-samba container (stacks/guestnet.go, one seam) —
the controller's own netns is the docker bridge, so /proc/net/route etc.
would answer 172.x (the S-2 trap). Red-proofs: A2 gate-drop and C2
lexical-invert both failed as required.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UuFPHmHNrCJj1VhY6QdDMU
This commit is contained in:
2026-07-22 13:49:57 +02:00
parent 4aa2ce4b61
commit 7013a5fd2e
14 changed files with 779 additions and 2 deletions
+219
View File
@@ -0,0 +1,219 @@
package stacks
import (
"fmt"
"net"
"os/exec"
"strings"
)
// Guest-network reads (R-66) — the „Hálózat" card's gateway row and the Debug dump's network
// section.
//
// THE DOOR: the controller runs on a docker BRIDGE, so every in-process answer — net.Interfaces(),
// /proc/net/route, /etc/resolv.conf — describes the CONTAINER's netns: a 172.x address, the docker
// bridge gateway, docker's 127.0.0.11 embedded resolver. All of them are the wrong-kind-of-true
// trap SambaLANAddress already documents (S-2). The ONLY guest-netns view this process has is a
// docker-exec into the felhom-samba container (`network_mode: host`) — so every read here goes
// through that door. Accepted consequence: while Megosztás is off the door is closed, these reads
// fail, and the surfaces show „—" / an error string — an address-less row beats a wrong address
// (S-5). NEVER cached, NEVER persisted: the guest holds its addressing by DHCP.
// GuestInterface is one guest-netns interface for the Debug dump (veth*/docker*/br-* plumbing
// skipped — the dump is about where the BOX is, not about container wiring).
type GuestInterface struct {
Name string `json:"name"`
Up bool `json:"up"`
Addresses []string `json:"addresses"` // IPv4 with prefix (e.g. 192.168.0.104/24); empty = none
}
// GuestNetSnapshot is the best-effort guest-network view for the Debug system-diagnostics dump.
// Collection never aborts: a failed read records its error string under Errors (keys: "interfaces",
// "route", "dns") and the other items still fill in.
type GuestNetSnapshot struct {
Interfaces []GuestInterface
Gateway string // default-route gateway ("" when unreadable)
RouteInterface string // default-route source interface ("" when unreadable)
DNSServers []string
LANAddress string // the SAME value SambaLANAddress serves — the cross-check anchor
Errors map[string]string
}
// guestNetExec runs a read-only command inside the samba container's (= the guest's) netns.
// Seam-first so no unit test touches docker; nil → the real docker exec.
func (m *Manager) guestNetExec(args ...string) (string, error) {
if m.guestNetExecFn != nil {
return m.guestNetExecFn(args...)
}
out, err := exec.Command("docker", append([]string{"exec", sambaContainer}, args...)...).Output()
if err != nil {
return "", fmt.Errorf("docker exec %s: %w", strings.Join(args, " "), err)
}
return string(out), nil
}
// GuestGateway returns the guest's default-route gateway, or "" when it cannot be read (sharing
// off → the netns door is closed; a route-less guest; parse failure). Never an error to the
// caller — same contract as SambaLANAddress: the card omits the value rather than failing, and
// "" must never be substituted with the controller container's OWN gateway (172.x — S-2).
func (m *Manager) GuestGateway() string {
out, err := m.guestNetExec("ip", "-4", "route", "show", "default")
if err != nil {
m.logger.Printf("[DEBUG] [guestnet] gateway unavailable: %v", err)
return ""
}
gw, _, perr := parseDefaultRoute(out)
if perr != nil {
m.logger.Printf("[DEBUG] [guestnet] gateway parse: %v", perr)
return ""
}
return gw
}
// GuestNetSnapshot collects the Debug dump's network section. Best-effort per item — a failed
// read yields its error string in Errors and never aborts the rest (the dump's tolerance style).
func (m *Manager) GuestNetSnapshot() GuestNetSnapshot {
snap := GuestNetSnapshot{Errors: map[string]string{}}
// Interfaces: link state + IPv4 addresses, container plumbing skipped.
linkOut, lerr := m.guestNetExec("ip", "-o", "link", "show")
addrOut, aerr := m.guestNetExec("ip", "-4", "-o", "addr", "show")
switch {
case lerr != nil:
snap.Errors["interfaces"] = lerr.Error()
case aerr != nil:
snap.Errors["interfaces"] = aerr.Error()
default:
snap.Interfaces = parseGuestInterfaces(linkOut, addrOut)
}
// Default route: gateway + source interface.
if routeOut, err := m.guestNetExec("ip", "-4", "route", "show", "default"); err != nil {
snap.Errors["route"] = err.Error()
} else if gw, dev, perr := parseDefaultRoute(routeOut); perr != nil {
snap.Errors["route"] = perr.Error()
} else {
snap.Gateway = gw
snap.RouteInterface = dev
}
// DNS: the GUEST's resolv.conf. Docker gives a host-network container a copy of the host's
// file (no 127.0.0.11 embedded resolver on network_mode: host) — reading it through the door
// answers for the box, where the controller's own /etc/resolv.conf would answer for docker.
if dnsOut, err := m.guestNetExec("cat", "/etc/resolv.conf"); err != nil {
snap.Errors["dns"] = err.Error()
} else {
snap.DNSServers = parseResolvConf(dnsOut)
}
// The same live value the Hálózat card shows, so a support session can cross-check the two.
snap.LANAddress = m.SambaLANAddress()
return snap
}
// parseDefaultRoute pulls gateway + device out of `ip -4 route show default`, whose one-line form:
//
// default via 192.168.0.1 dev eth0 proto dhcp src 192.168.0.104 metric 100
//
// Pure and separately tested (the parseIPv4FromIPAddrOutput discipline): the parse is the only
// part that can silently produce a plausible wrong string.
func parseDefaultRoute(out string) (gw, dev string, err error) {
for _, line := range strings.Split(out, "\n") {
fields := strings.Fields(line)
if len(fields) == 0 || fields[0] != "default" {
continue
}
for i, f := range fields {
if f == "via" && i+1 < len(fields) {
ip := net.ParseIP(fields[i+1])
if ip == nil || ip.To4() == nil {
continue
}
gw = ip.String()
}
if f == "dev" && i+1 < len(fields) {
dev = fields[i+1]
}
}
if gw != "" {
return gw, dev, nil
}
}
return "", "", fmt.Errorf("no default route with a valid IPv4 gateway in ip-route output")
}
// skipGuestInterface filters container plumbing out of the dump: veth pairs, the docker0 bridge,
// and docker's per-network br-<id> bridges. Inside an LXC guest these are all docker's.
func skipGuestInterface(name string) bool {
return strings.HasPrefix(name, "veth") ||
strings.HasPrefix(name, "docker") ||
strings.HasPrefix(name, "br-")
}
// parseGuestInterfaces merges `ip -o link show` (name + flags) with `ip -4 -o addr show`
// (per-interface addresses). Link line form:
//
// 2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc ... state UP mode ...
//
// Up is taken from the <flags> UP (admin state) — `state` says UNKNOWN for lo and for many
// virtual devices even when they carry traffic.
func parseGuestInterfaces(linkOut, addrOut string) []GuestInterface {
// Addresses per interface name (prefix kept — diagnostic value).
addrs := map[string][]string{}
for _, line := range strings.Split(addrOut, "\n") {
fields := strings.Fields(line)
if len(fields) < 4 {
continue
}
name := trimIfaceName(fields[1])
for i, f := range fields {
if f == "inet" && i+1 < len(fields) {
addrs[name] = append(addrs[name], fields[i+1])
}
}
}
var out []GuestInterface
for _, line := range strings.Split(linkOut, "\n") {
fields := strings.Fields(line)
if len(fields) < 3 {
continue
}
name := trimIfaceName(fields[1])
if name == "" || skipGuestInterface(name) {
continue
}
flags := fields[2] // <BROADCAST,...,UP,...>
up := false
for _, fl := range strings.Split(strings.Trim(flags, "<>"), ",") {
if fl == "UP" {
up = true
break
}
}
out = append(out, GuestInterface{Name: name, Up: up, Addresses: addrs[name]})
}
return out
}
// trimIfaceName normalizes an `ip -o` interface field: trailing ':' and the veth `@if12` /
// `eth0@if34` peer suffix.
func trimIfaceName(f string) string {
f = strings.TrimSuffix(f, ":")
if at := strings.IndexByte(f, '@'); at >= 0 {
f = f[:at]
}
return f
}
// parseResolvConf pulls the nameserver entries out of a resolv.conf body.
func parseResolvConf(out string) []string {
var servers []string
for _, line := range strings.Split(out, "\n") {
fields := strings.Fields(line)
if len(fields) >= 2 && fields[0] == "nameserver" {
servers = append(servers, fields[1])
}
}
return servers
}
+184
View File
@@ -0,0 +1,184 @@
package stacks
import (
"errors"
"io"
"log"
"strings"
"testing"
)
// R-66: the default-route parse. Same discipline as parseIPv4FromIPAddrOutput — the parse is the
// one part that can silently produce a PLAUSIBLE WRONG gateway, and a wrong gateway on a
// troubleshooting card is worse than an omitted row.
func TestParseDefaultRoute(t *testing.T) {
ok := []struct{ name, in, wantGW, wantDev string }{
{"dhcp with src+metric", "default via 192.168.0.1 dev eth0 proto dhcp src 192.168.0.104 metric 100", "192.168.0.1", "eth0"},
{"bare static route", "default via 10.0.0.254 dev eth0", "10.0.0.254", "eth0"},
{"default line after other output", "unreachable 10.9.0.0/16\ndefault via 172.16.0.1 dev ens18 proto static", "172.16.0.1", "ens18"},
}
for _, tc := range ok {
gw, dev, err := parseDefaultRoute(tc.in)
if err != nil {
t.Errorf("%s: unexpected error %v", tc.name, err)
continue
}
if gw != tc.wantGW || dev != tc.wantDev {
t.Errorf("%s: got (%q,%q), want (%q,%q)", tc.name, gw, dev, tc.wantGW, tc.wantDev)
}
}
bad := []struct{ name, in string }{
{"empty (no default route)", ""},
{"non-default routes only", "192.168.0.0/24 dev eth0 proto kernel scope link src 192.168.0.104"},
{"garbage (docker error text)", "docker: Error response from daemon: No such container: felhom-samba"},
{"default with unparseable gateway", "default via not-an-ip dev eth0"},
{"default with IPv6 gateway in a -4 read", "default via fe80::1 dev eth0"},
}
for _, tc := range bad {
if gw, dev, err := parseDefaultRoute(tc.in); err == nil {
t.Errorf("%s: must be an error, got (%q,%q)", tc.name, gw, dev)
}
}
}
func TestParseGuestInterfaces(t *testing.T) {
// Shapes verbatim from a live LXC guest with docker running: lo, eth0, docker0, a br- network
// bridge and a veth pair member — only lo + eth0 belong in the dump.
linkOut := strings.Join([]string{
`1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000\ link/loopback 00:00:00:00:00:00`,
`2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000\ link/ether bc:24:11:de:1b:e7`,
`3: docker0: <NO-CARRIER,BROADCAST,MULTICAST,UP> mtu 1500 qdisc noqueue state DOWN mode DEFAULT group default\ link/ether 02:42:c0:a8:00:01`,
`4: br-1a2b3c4d5e6f: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default`,
`14: veth1234abc@if13: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master docker0 state UP mode DEFAULT group default`,
}, "\n")
addrOut := strings.Join([]string{
`1: lo inet 127.0.0.1/8 scope host lo\ valid_lft forever preferred_lft forever`,
`2: eth0 inet 192.168.0.104/24 brd 192.168.0.255 scope global dynamic eth0\ valid_lft 1486sec preferred_lft 1486sec`,
`3: docker0 inet 172.17.0.1/16 brd 172.17.255.255 scope global docker0`,
}, "\n")
got := parseGuestInterfaces(linkOut, addrOut)
if len(got) != 2 {
t.Fatalf("got %d interfaces (%+v), want 2 (lo + eth0; docker plumbing skipped)", len(got), got)
}
if got[0].Name != "lo" || !got[0].Up || len(got[0].Addresses) != 1 || got[0].Addresses[0] != "127.0.0.1/8" {
t.Errorf("lo: %+v", got[0])
}
if got[1].Name != "eth0" || !got[1].Up || len(got[1].Addresses) != 1 || got[1].Addresses[0] != "192.168.0.104/24" {
t.Errorf("eth0: %+v", got[1])
}
}
func TestParseResolvConf(t *testing.T) {
in := "# Generated\nsearch lan\nnameserver 192.168.0.2\nnameserver 1.1.1.1\noptions edns0\n"
got := parseResolvConf(in)
if len(got) != 2 || got[0] != "192.168.0.2" || got[1] != "1.1.1.1" {
t.Errorf("got %v, want [192.168.0.2 1.1.1.1]", got)
}
if got := parseResolvConf("docker: Error response from daemon"); got != nil {
t.Errorf("garbage input: got %v, want nil", got)
}
}
// GuestGateway has the SambaLANAddress contract: "" on any failure, never a substitute value —
// the caller renders „—", and the one wrong answer available in-process (the controller's OWN
// docker-bridge gateway) must never leak through.
func TestGuestGatewayFailsQuiet(t *testing.T) {
m := &Manager{logger: log.New(io.Discard, "", 0)}
m.guestNetExecFn = func(args ...string) (string, error) { return "", errors.New("no such container") }
if got := m.GuestGateway(); got != "" {
t.Errorf("exec-error path: got %q, want empty", got)
}
m.guestNetExecFn = func(args ...string) (string, error) { return "garbage", nil }
if got := m.GuestGateway(); got != "" {
t.Errorf("parse-error path: got %q, want empty", got)
}
m.guestNetExecFn = func(args ...string) (string, error) {
return "default via 192.168.0.1 dev eth0 proto dhcp src 192.168.0.104 metric 100", nil
}
if got := m.GuestGateway(); got != "192.168.0.1" {
t.Errorf("happy path: got %q, want 192.168.0.1", got)
}
}
// scriptedGuestNet answers each guestNetExec argv from a table; unmatched argv errors — so a test
// can fail exactly ONE read and prove the others still fill in (B1: best-effort per item).
func scriptedGuestNet(t *testing.T, script map[string]string, failKey string) func(args ...string) (string, error) {
t.Helper()
return func(args ...string) (string, error) {
key := strings.Join(args, " ")
if key == failKey {
return "", errors.New("scripted failure: " + key)
}
out, ok := script[key]
if !ok {
return "", errors.New("unscripted guestNetExec: " + key)
}
return out, nil
}
}
var guestNetScript = map[string]string{
"ip -o link show": `1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 state UNKNOWN
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 state UP`,
"ip -4 -o addr show": `2: eth0 inet 192.168.0.104/24 brd 192.168.0.255 scope global dynamic eth0`,
"ip -4 route show default": `default via 192.168.0.1 dev eth0 proto dhcp src 192.168.0.104 metric 100`,
"cat /etc/resolv.conf": "nameserver 192.168.0.2\nnameserver 1.1.1.1\n",
"ip -4 -o addr show eth0": `2: eth0 inet 192.168.0.104/24 brd 192.168.0.255 scope global dynamic eth0`,
}
// B1 (stacks half): the full snapshot fills every field; a fabricated resolv.conf failure yields
// an error string in place while every OTHER item still fills in — one dead read must never
// abort the dump's network section.
func TestGuestNetSnapshotBestEffort(t *testing.T) {
m := &Manager{logger: log.New(io.Discard, "", 0)}
m.sambaAddrFn = func() (string, error) { return "192.168.0.104", nil }
// All reads healthy.
m.guestNetExecFn = scriptedGuestNet(t, guestNetScript, "")
snap := m.GuestNetSnapshot()
if len(snap.Errors) != 0 {
t.Fatalf("healthy path: unexpected errors %v", snap.Errors)
}
if len(snap.Interfaces) != 2 || snap.Interfaces[1].Name != "eth0" {
t.Errorf("interfaces: %+v", snap.Interfaces)
}
if snap.Gateway != "192.168.0.1" || snap.RouteInterface != "eth0" {
t.Errorf("route: gw=%q dev=%q", snap.Gateway, snap.RouteInterface)
}
if len(snap.DNSServers) != 2 || snap.DNSServers[0] != "192.168.0.2" {
t.Errorf("dns: %v", snap.DNSServers)
}
if snap.LANAddress != "192.168.0.104" {
t.Errorf("lan address: %q (must be the SAME value the Hálózat card shows)", snap.LANAddress)
}
// Fabricated resolv.conf failure (B1's named case).
m.guestNetExecFn = scriptedGuestNet(t, guestNetScript, "cat /etc/resolv.conf")
snap = m.GuestNetSnapshot()
if snap.Errors["dns"] == "" || !strings.Contains(snap.Errors["dns"], "scripted failure") {
t.Errorf("dns failure not recorded in place: %v", snap.Errors)
}
if snap.DNSServers != nil {
t.Errorf("failed dns read must not fabricate servers: %v", snap.DNSServers)
}
if snap.Gateway != "192.168.0.1" || len(snap.Interfaces) != 2 || snap.LANAddress != "192.168.0.104" {
t.Errorf("one failed read poisoned the rest: gw=%q ifaces=%d lan=%q",
snap.Gateway, len(snap.Interfaces), snap.LANAddress)
}
// Door fully closed (sharing off — every exec fails): every keyed item reports, nothing panics.
m.guestNetExecFn = func(args ...string) (string, error) { return "", errors.New("no such container") }
m.sambaAddrFn = func() (string, error) { return "", errors.New("no such container") }
snap = m.GuestNetSnapshot()
for _, key := range []string{"interfaces", "route", "dns"} {
if snap.Errors[key] == "" {
t.Errorf("closed-door: missing error for %q", key)
}
}
if snap.LANAddress != "" || snap.Gateway != "" {
t.Errorf("closed-door: fabricated values lan=%q gw=%q", snap.LANAddress, snap.Gateway)
}
}
+4
View File
@@ -141,6 +141,10 @@ type Manager struct {
// sambaAddrFn replaces the docker-exec that reads the guest's LAN IPv4 out of the samba
// container's network namespace (v0.151.0, S-2/S-5 connect-address card).
sambaAddrFn func() (string, error)
// guestNetExecFn replaces guestnet.go's docker-exec into the samba netns (R-66 gateway row +
// Debug dump network section); nil in production. One seam for all guest-net reads — tests
// script canned `ip`/resolv.conf outputs per argv and never touch docker.
guestNetExecFn func(args ...string) (string, error)
}
// SetSambaRunProbe injects the samba liveness probe. Exported for the same reason