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:
@@ -214,6 +214,42 @@ func (s *Server) debugDump(w http.ResponseWriter, r *http.Request) {
|
||||
dump["backup"] = map[string]interface{}{"enabled": false}
|
||||
}
|
||||
|
||||
// Network (R-66) — the guest's netns view, read through the samba-container door
|
||||
// (stacks/guestnet.go: the controller's OWN netns is the docker bridge — the S-2 wrong
|
||||
// answer). Best-effort per item, the dump's tolerance style: a failed read yields that
|
||||
// item's error string in place and never aborts the dump. lan_address is the SAME live
|
||||
// value the Hálózat card shows, so a support session can cross-check the two.
|
||||
netSnap := s.guestNetSnapshot()
|
||||
network := map[string]interface{}{}
|
||||
if e := netSnap.Errors["interfaces"]; e != "" {
|
||||
network["interfaces"] = "error: " + e
|
||||
} else {
|
||||
ifaces := make([]map[string]interface{}, 0, len(netSnap.Interfaces))
|
||||
for _, gi := range netSnap.Interfaces {
|
||||
ifaces = append(ifaces, map[string]interface{}{
|
||||
"name": gi.Name,
|
||||
"up": gi.Up,
|
||||
"addresses": gi.Addresses,
|
||||
})
|
||||
}
|
||||
network["interfaces"] = ifaces
|
||||
}
|
||||
if e := netSnap.Errors["route"]; e != "" {
|
||||
network["default_route"] = "error: " + e
|
||||
} else {
|
||||
network["default_route"] = map[string]interface{}{
|
||||
"gateway": netSnap.Gateway,
|
||||
"interface": netSnap.RouteInterface,
|
||||
}
|
||||
}
|
||||
if e := netSnap.Errors["dns"]; e != "" {
|
||||
network["dns_servers"] = "error: " + e
|
||||
} else {
|
||||
network["dns_servers"] = netSnap.DNSServers
|
||||
}
|
||||
network["lan_address"] = netSnap.LANAddress
|
||||
dump["network"] = network
|
||||
|
||||
// Hub
|
||||
hubInfo := map[string]interface{}{
|
||||
"url": s.cfg.Hub.URL,
|
||||
|
||||
@@ -1252,6 +1252,17 @@ func (s *Server) systemPageData() map[string]interface{} {
|
||||
}
|
||||
// Guest RAM resize card (v0.143.0, R-24): current allocation + bounds + capability/reachability.
|
||||
s.memoryCardData(data)
|
||||
|
||||
// „Hálózat" card (R-66): where the box IS, live-computed per render and stored NOWHERE — the
|
||||
// guest holds its address by DHCP, so a stored copy eventually misdirects people (S-5); an
|
||||
// address-less row („—") beats a wrong address. Hálózati név renders ONLY while Megosztás is
|
||||
// enabled: the NetBIOS name exists only while samba runs — showing \\FELHOM otherwise would be
|
||||
// a wrong promise.
|
||||
data["NetLANAddress"] = s.sambaLANAddress()
|
||||
data["NetGateway"] = s.guestGateway()
|
||||
if smb := s.settings.GetSMBSettings(); smb.Enabled {
|
||||
data["NetSMBName"] = smb.EffectiveServerName()
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -323,7 +324,15 @@ func (s *Server) pollAgentVerify(ctx context.Context, agent netAgent, jobID stri
|
||||
func netAddMessage(category, server string, mappedUID int) string {
|
||||
switch category {
|
||||
case "unreachable":
|
||||
return "A szerver nem érhető el (" + server + "). Ellenőrizze az IP-címet, és hogy a NAS be van-e kapcsolva."
|
||||
msg := "A szerver nem érhető el (" + server + "). Ellenőrizze az IP-címet, és hogy a NAS be van-e kapcsolva."
|
||||
// R-66 Leg C: a single-label non-IP server (»FELHOM«) is almost always a Windows/NetBIOS
|
||||
// network name, which this box generally cannot resolve — name the trap instead of letting
|
||||
// the generic text teach nothing. Purely lexical on the submitted value: NO NetBIOS/mDNS
|
||||
// resolution is ever attempted, and an IP or dotted DNS name never gets nagged about this.
|
||||
if looksLikeFlatNetworkName(server) {
|
||||
msg += " Tipp: a(z) »" + server + "« Windows-hálózati névnek tűnik — használja az eszköz IP-címét."
|
||||
}
|
||||
return msg
|
||||
case "nfs_export":
|
||||
return "A megosztás nem található, vagy a NAS nem engedélyezi ennek a gépnek a hozzáférését. Ellenőrizze az export útvonalát, és hogy a NAS engedélyezi-e a Felhom gép IP-címét."
|
||||
case "smb_auth":
|
||||
@@ -345,6 +354,15 @@ func netAddMessage(category, server string, mappedUID int) string {
|
||||
}
|
||||
}
|
||||
|
||||
// looksLikeFlatNetworkName reports whether a submitted server value is a single-label non-IP name
|
||||
// (no dot, not an IP literal) — the NetBIOS-name shape. `nas.local` (dotted) and any parseable IP
|
||||
// (v4 or v6 — colons carry the v6 case through ParseIP) are NOT flagged: the hint must never nag
|
||||
// someone who typed a resolvable form (R-66 C2/C3).
|
||||
func looksLikeFlatNetworkName(server string) bool {
|
||||
s := strings.TrimSpace(server)
|
||||
return s != "" && !strings.Contains(s, ".") && net.ParseIP(s) == nil
|
||||
}
|
||||
|
||||
// trimNetDetail bounds the raw detail shown in the UI collapsible.
|
||||
func trimNetDetail(d string) string {
|
||||
d = strings.TrimSpace(d)
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// R-66 Leg A — the „Hálózat" card on Beállítások → Rendszer.
|
||||
|
||||
// A1: sharing enabled → the card renders address + network-name + gateway rows with live values.
|
||||
func TestNetworkCardRendersAllRows(t *testing.T) {
|
||||
s := testPageServer(t)
|
||||
if err := s.settings.SetSMBEnabled(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.sambaAddrFn = func() string { return "192.168.0.104" }
|
||||
s.guestGatewayFn = func() string { return "192.168.0.1" }
|
||||
|
||||
body := getPage(t, s, "/settings").Body.String()
|
||||
for _, m := range []string{"Hálózat", "Helyi cím (LAN)", "192.168.0.104", "Hálózati név", `\\FELHOM`, "Átjáró", "192.168.0.1"} {
|
||||
if !strings.Contains(body, m) {
|
||||
t.Errorf("card missing %q", m)
|
||||
}
|
||||
}
|
||||
// The footer sentence (remote-troubleshooting read-aloud hint) belongs to the card.
|
||||
if !strings.Contains(body, "helyi hálózattól függenek") {
|
||||
t.Error("card missing the footer sentence")
|
||||
}
|
||||
}
|
||||
|
||||
// A2: sharing DISABLED → the Hálózati név row is ABSENT. The WRONG case this pins: rendering
|
||||
// \\FELHOM while samba is down would promise a name that does not exist on the network.
|
||||
// Red-proof: drop the smb.Enabled gate in systemPageData → this fails with \\FELHOM present.
|
||||
func TestNetworkCardNoSMBNameWhenSharingOff(t *testing.T) {
|
||||
s := testPageServer(t)
|
||||
if err := s.settings.SetSMBEnabled(false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.sambaAddrFn = func() string { return "" }
|
||||
s.guestGatewayFn = func() string { return "" }
|
||||
|
||||
body := getPage(t, s, "/settings").Body.String()
|
||||
if !strings.Contains(body, "Hálózat") {
|
||||
t.Fatal("card absent entirely")
|
||||
}
|
||||
if strings.Contains(body, "Hálózati név") || strings.Contains(body, `\\FELHOM`) {
|
||||
t.Error("Hálózati név row rendered while Megosztás is disabled — a wrong promise")
|
||||
}
|
||||
}
|
||||
|
||||
// A3: the address helper returns "" → the row renders „—" + the muted note, no crash, and no
|
||||
// fallback value appears from anywhere (nothing is stored to fall back TO — S-5).
|
||||
func TestNetworkCardDashOnUnavailable(t *testing.T) {
|
||||
s := testPageServer(t)
|
||||
s.sambaAddrFn = func() string { return "" }
|
||||
s.guestGatewayFn = func() string { return "" }
|
||||
|
||||
rec := getPage(t, s, "/settings")
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("GET /settings = %d, want 200", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "nem állapítható meg") {
|
||||
t.Error("missing the muted unavailable note")
|
||||
}
|
||||
// No plausible-but-stale address may surface: the only IPs on the page must be the ones other
|
||||
// cards legitimately carry — assert the card region itself carries the dash.
|
||||
cardStart := strings.Index(body, "Helyi cím (LAN)")
|
||||
if cardStart < 0 {
|
||||
t.Fatal("card row missing")
|
||||
}
|
||||
region := body[cardStart:]
|
||||
if end := strings.Index(region, "Átjáró"); end > 0 {
|
||||
region = region[:end]
|
||||
}
|
||||
if !strings.Contains(region, "—") {
|
||||
t.Error("Helyi cím row lacks the em-dash placeholder")
|
||||
}
|
||||
}
|
||||
|
||||
// R-66 Leg A seam freshness: the card must re-derive per render (never memoize a DHCP lease) —
|
||||
// the same counted-fn assertion that guards the Megosztás connect card.
|
||||
func TestNetworkCardFreshPerRender(t *testing.T) {
|
||||
s := testPageServer(t)
|
||||
addrCalls, gwCalls := 0, 0
|
||||
s.sambaAddrFn = func() string { addrCalls++; return "192.168.0.104" }
|
||||
s.guestGatewayFn = func() string { gwCalls++; return "192.168.0.1" }
|
||||
|
||||
getPage(t, s, "/settings")
|
||||
getPage(t, s, "/settings")
|
||||
if addrCalls != 2 || gwCalls != 2 {
|
||||
t.Errorf("stale-value risk: addr resolved %d×, gateway %d× over 2 renders (want 2/2)", addrCalls, gwCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// R-66 Leg B (web half) — the Debug dump's network section.
|
||||
// B1: the dump contains `network` with the four sub-keys; a fabricated resolv.conf failure yields
|
||||
// an error string IN PLACE while the dump stays complete.
|
||||
func TestDebugDumpNetworkSection(t *testing.T) {
|
||||
s := testPageServer(t)
|
||||
s.guestNetFn = func() stacks.GuestNetSnapshot {
|
||||
return stacks.GuestNetSnapshot{
|
||||
Interfaces: []stacks.GuestInterface{{Name: "eth0", Up: true, Addresses: []string{"192.168.0.104/24"}}},
|
||||
Gateway: "192.168.0.1",
|
||||
RouteInterface: "eth0",
|
||||
LANAddress: "192.168.0.104",
|
||||
Errors: map[string]string{"dns": "scripted resolv.conf failure"},
|
||||
}
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
s.debugDump(rec, httptest.NewRequest("GET", "/api/debug/dump", nil))
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("dump = %d, want 200", rec.Code)
|
||||
}
|
||||
var dump map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &dump); err != nil {
|
||||
t.Fatalf("dump not JSON: %v", err)
|
||||
}
|
||||
network, ok := dump["network"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("dump lacks a network object: %T", dump["network"])
|
||||
}
|
||||
for _, key := range []string{"interfaces", "default_route", "dns_servers", "lan_address"} {
|
||||
if _, present := network[key]; !present {
|
||||
t.Errorf("network section missing %q", key)
|
||||
}
|
||||
}
|
||||
// The failed item reports in place…
|
||||
if e, _ := network["dns_servers"].(string); !strings.Contains(e, "scripted resolv.conf failure") {
|
||||
t.Errorf("dns_servers = %v, want the in-place error string", network["dns_servers"])
|
||||
}
|
||||
// …and the healthy items are real values, not casualties.
|
||||
if la, _ := network["lan_address"].(string); la != "192.168.0.104" {
|
||||
t.Errorf("lan_address = %v (must equal the Hálózat card's value)", network["lan_address"])
|
||||
}
|
||||
route, _ := network["default_route"].(map[string]interface{})
|
||||
if route["gateway"] != "192.168.0.1" || route["interface"] != "eth0" {
|
||||
t.Errorf("default_route = %v", network["default_route"])
|
||||
}
|
||||
// The dump as a whole stayed complete (existing sections intact).
|
||||
for _, key := range []string{"controller", "storage", "stacks"} {
|
||||
if _, present := dump[key]; !present {
|
||||
t.Errorf("dump lost its %q section", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// R-66 Leg C — the NetBIOS trap named on an unreachable-class add failure.
|
||||
func TestNetAddMessageNetBIOSHint(t *testing.T) {
|
||||
const hintMark = "Windows-hálózati névnek tűnik"
|
||||
|
||||
// C1: single-label non-IP name + unreachable → hint present, naming the submitted value.
|
||||
msg := netAddMessage("unreachable", "FELHOM", 1000)
|
||||
if !strings.Contains(msg, hintMark) || !strings.Contains(msg, "»FELHOM«") {
|
||||
t.Errorf("C1: hint missing from %q", msg)
|
||||
}
|
||||
|
||||
// C2: an IP + unreachable → hint ABSENT (the wrong case: nagging an IP user about NetBIOS).
|
||||
// Red-proof: invert the lexical check in looksLikeFlatNetworkName → this fails.
|
||||
if msg := netAddMessage("unreachable", "192.168.0.50", 1000); strings.Contains(msg, hintMark) {
|
||||
t.Errorf("C2: hint wrongly present for an IP: %q", msg)
|
||||
}
|
||||
|
||||
// C3: dotted name → hint absent.
|
||||
if msg := netAddMessage("unreachable", "nas.local", 1000); strings.Contains(msg, hintMark) {
|
||||
t.Errorf("C3: hint wrongly present for a dotted name: %q", msg)
|
||||
}
|
||||
|
||||
// The hint stays out of every OTHER category — it explains unreachability only.
|
||||
if msg := netAddMessage("smb_auth", "FELHOM", 1000); strings.Contains(msg, hintMark) {
|
||||
t.Errorf("hint leaked into smb_auth: %q", msg)
|
||||
}
|
||||
|
||||
// Lexical edges: IPv6 literal (no dots, but an IP) and empty stay quiet.
|
||||
if looksLikeFlatNetworkName("fe80::1") {
|
||||
t.Error("IPv6 literal flagged as a NetBIOS name")
|
||||
}
|
||||
if looksLikeFlatNetworkName("") || looksLikeFlatNetworkName(" ") {
|
||||
t.Error("empty value flagged as a NetBIOS name")
|
||||
}
|
||||
if !looksLikeFlatNetworkName("FELHOM") {
|
||||
t.Error("FELHOM not flagged")
|
||||
}
|
||||
}
|
||||
@@ -100,6 +100,11 @@ type Server struct {
|
||||
// „Csatlakozás a megosztáshoz" card, or "" when it cannot be read. nil → stackMgr.SambaLANAddress.
|
||||
// Called PER RENDER and stored nowhere — the address is a DHCP lease (see sambaLANAddress).
|
||||
sambaAddrFn func() string
|
||||
// guestGatewayFn / guestNetFn are the R-66 guest-network seams: the Hálózat card's gateway row
|
||||
// and the Debug dump's network section. nil → stackMgr.GuestGateway / stackMgr.GuestNetSnapshot.
|
||||
// Same S-5 law as sambaAddrFn: live-computed per render/dump, stored nowhere.
|
||||
guestGatewayFn func() string
|
||||
guestNetFn func() stacks.GuestNetSnapshot
|
||||
netAgentFn func() (netAgent, error)
|
||||
// fabUpload is the chunked browser .fab upload single-flight slot (v0.128.0).
|
||||
fabUpload uploadState
|
||||
|
||||
@@ -199,6 +199,35 @@ func (s *Server) sambaLANAddress() string {
|
||||
return s.stackMgr.SambaLANAddress()
|
||||
}
|
||||
|
||||
// guestGateway resolves the guest's default gateway for the Hálózat card (R-66) — the sibling of
|
||||
// sambaLANAddress with the identical contract: live per render, never stored, "" = the row shows
|
||||
// „—". The read goes through the samba-container netns door (stacks/guestnet.go) because the
|
||||
// controller's OWN /proc/net/route answers for the docker bridge (172.x) — the S-2 wrong answer.
|
||||
func (s *Server) guestGateway() string {
|
||||
if s.guestGatewayFn != nil {
|
||||
return s.guestGatewayFn()
|
||||
}
|
||||
if s.stackMgr == nil {
|
||||
return ""
|
||||
}
|
||||
return s.stackMgr.GuestGateway()
|
||||
}
|
||||
|
||||
// guestNetSnapshot resolves the Debug dump's network section (R-66); same seam shape.
|
||||
func (s *Server) guestNetSnapshot() stacks.GuestNetSnapshot {
|
||||
if s.guestNetFn != nil {
|
||||
return s.guestNetFn()
|
||||
}
|
||||
if s.stackMgr == nil {
|
||||
return stacks.GuestNetSnapshot{Errors: map[string]string{
|
||||
"interfaces": "stack manager unavailable",
|
||||
"route": "stack manager unavailable",
|
||||
"dns": "stack manager unavailable",
|
||||
}}
|
||||
}
|
||||
return s.stackMgr.GuestNetSnapshot()
|
||||
}
|
||||
|
||||
func (s *Server) sharingPageHandler(w http.ResponseWriter, r *http.Request) {
|
||||
data := s.sharingPageData()
|
||||
if f := strings.TrimSpace(r.URL.Query().Get("flash")); f != "" {
|
||||
|
||||
@@ -143,6 +143,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section: Network (R-66) — the box's own address. Every value is live-computed per render and
|
||||
stored NOWHERE (S-5: a DHCP lease persisted anywhere eventually misdirects people); a missing
|
||||
value renders „—" because an address-less row beats a wrong address. -->
|
||||
<div class="settings-card">
|
||||
<h3>Hálózat</h3>
|
||||
<div class="settings-grid">
|
||||
<div class="settings-row">
|
||||
<span class="settings-label">Helyi cím (LAN)</span>
|
||||
<span class="settings-value mono">{{if .NetLANAddress}}{{.NetLANAddress}}{{else}}— <span style="color:var(--text-3);">nem állapítható meg</span>{{end}}</span>
|
||||
</div>
|
||||
{{if .NetSMBName}}
|
||||
<div class="settings-row">
|
||||
<span class="settings-label">Hálózati név</span>
|
||||
<span class="settings-value mono">\\{{.NetSMBName}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="settings-row">
|
||||
<span class="settings-label">Átjáró</span>
|
||||
<span class="settings-value mono">{{if .NetGateway}}{{.NetGateway}}{{else}}— <span style="color:var(--text-3);">nem állapítható meg</span>{{end}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="settings-card-desc" style="margin-top:0.5em;">Ezek az értékek a helyi hálózattól függenek és változhatnak. Távoli hibaelhárításnál ezt az oldalt kérjük felolvasni.</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3>Szerver memória (RAM)</h3>
|
||||
{{if .MemorySupported}}
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
<div class="form-group">
|
||||
<label for="ns-server">Szerver (IP vagy hosztnév) <span class="required">*</span></label>
|
||||
<input id="ns-server" type="text" class="form-control" placeholder="pl. 192.168.0.10" required style="max-width:220px">
|
||||
<span class="form-hint">IP-cím vagy DNS-név. A Windows-hálózati név (pl. FELHOM) itt általában nem oldható fel — a Megosztás oldal »közvetlen cím« sora adja meg a másik eszköz IP-címét.</span>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="ns-export" id="ns-export-label">Megosztás neve</label>
|
||||
|
||||
Reference in New Issue
Block a user