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- 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: mtu 1500 qdisc ... state UP mode ... // // Up is taken from the 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] // 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 }