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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user