7013a5fd2e
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
185 lines
8.2 KiB
Go
185 lines
8.2 KiB
Go
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)
|
|
}
|
|
}
|