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:
@@ -1,5 +1,46 @@
|
|||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
|
### v0.159.0 — R-66: the box's own address becomes visible (2026-07-22)
|
||||||
|
|
||||||
|
No agent coupling; MinAgent unchanged. Controller-only, three XS legs with one theme: **the box
|
||||||
|
must be able to tell you where it is.** Origin: the Felhom↔Felhom NAS pairing drill — the serving
|
||||||
|
box's IP was findable only as a hint line buried on the OTHER box's Megosztás page, and the add
|
||||||
|
form's failure for a NetBIOS name („FELHOM") taught nothing.
|
||||||
|
|
||||||
|
**Leg A — „Hálózat" card** on Beállítások → Rendszer (between „Verzió és frissítés" and „Szerver
|
||||||
|
memória"): Helyi cím (LAN), Hálózati név (`\\<SMBServerName>`, rendered ONLY while Megosztás is
|
||||||
|
enabled — the NetBIOS name exists only while samba runs), Átjáró, and a muted footer asking the
|
||||||
|
customer to read the page aloud during remote troubleshooting. Everything is live-computed per
|
||||||
|
render and stored nowhere (S-5); an unavailable value renders „—" („nem állapítható meg").
|
||||||
|
|
||||||
|
**Leg B — `network` section in the Debug system dump** (`GET /api/debug/dump`): guest interfaces
|
||||||
|
(veth*/docker*/br-* plumbing skipped), default route + gateway + source interface, DNS servers
|
||||||
|
from the guest's resolv.conf, and the SAME `lan_address` value Leg A shows so a support session
|
||||||
|
can cross-check the two. Best-effort per item — a failed read yields that item's error string in
|
||||||
|
place, never aborts the dump.
|
||||||
|
|
||||||
|
**Leg C — the NetBIOS trap gets named**: helper text under the NAS add form's Szerver field, plus
|
||||||
|
one hint line appended to an `unreachable`-class add failure when the submitted server is a
|
||||||
|
single-label non-IP name („Tipp: a(z) »FELHOM« Windows-hálózati névnek tűnik…"). The detection is
|
||||||
|
purely lexical (`looksLikeFlatNetworkName`: non-empty, no dot, not `net.ParseIP`-able) — no
|
||||||
|
NetBIOS/mDNS resolution is attempted anywhere, and the agent's probe/taxonomy is untouched.
|
||||||
|
|
||||||
|
**The one design decision worth recording:** the spec sketched the gateway as a `/proc/net/route`
|
||||||
|
read, but the controller runs on a docker BRIDGE — every in-process answer (own routes, own
|
||||||
|
resolv.conf = 127.0.0.11, `net.Interfaces` = 172.x) is the S-2 wrong-kind-of-true trap that
|
||||||
|
already burned the setup wizard. All guest-net reads therefore go through the ONE guest-netns door
|
||||||
|
this process has: a docker-exec into the host-networked felhom-samba container
|
||||||
|
(`internal/stacks/guestnet.go`, single `guestNetExecFn` seam). Accepted consequence, by S-5's own
|
||||||
|
logic: with Megosztás off the door is closed and the card shows „—" rather than a plausible wrong
|
||||||
|
172.x answer.
|
||||||
|
|
||||||
|
Tests: `guestnet_test.go` (pure parsers pinned: default route, interface merge, resolv.conf;
|
||||||
|
fail-quiet contracts; B1 best-effort with a scripted per-argv exec fake) + `network_card_test.go`
|
||||||
|
(A1 all rows, A2 name-row absent when sharing off, A3 „—" fallback, per-render freshness counter,
|
||||||
|
B1 dump shape with in-place error, C1/C2/C3 hint lexicon). Red-proofs run and recorded in
|
||||||
|
REPORT.md: A2 (enabled-gate dropped → `\\FELHOM` rendered while sharing is off → FAIL) and C2
|
||||||
|
(lexical check inverted → the hint nags an IP user → FAIL).
|
||||||
|
|
||||||
### v0.158.1 — fix: the lifecycle methods broke every app detail page (2026-07-21)
|
### v0.158.1 — fix: the lifecycle methods broke every app detail page (2026-07-21)
|
||||||
|
|
||||||
**Defect shipped in v0.158.0 and caught live within the hour. `/apps/<slug>` returned HTTP 500 for
|
**Defect shipped in v0.158.0 and caught live within the hour. `/apps/<slug>` returned HTTP 500 for
|
||||||
|
|||||||
@@ -212,6 +212,8 @@
|
|||||||
| `Manager.sambaUpFn` / `sambaPasswdFn` / `sambaRunFn` / `sambaAddrFn` (func seams) | controller/internal/stacks/manager.go (fields) + samba.go | nil → `composeUp` / `docker exec smbpasswd` (STDIN) / `containerRunning("felhom-samba")` / `docker exec felhom-samba ip -4 -o addr show eth0` | injected in controller/internal/stacks/samba_test.go — the idempotency test asserts the up-seam is called **zero** times when config is unchanged; the passwd seam means no unit test ever handles a real secret or touches docker. **`sambaRunFn` has an EXPORTED setter (`SetSambaRunProbe`)** — internal/web's status-contract tests need a live-container world from another package. `sambaAddrFn` backs `SambaLANAddress()` (v0.151.0); its parse is separately pinned in samba_lanaddr_test.go and it returns "" on any failure — the page omits a line rather than printing a wrong address |
|
| `Manager.sambaUpFn` / `sambaPasswdFn` / `sambaRunFn` / `sambaAddrFn` (func seams) | controller/internal/stacks/manager.go (fields) + samba.go | nil → `composeUp` / `docker exec smbpasswd` (STDIN) / `containerRunning("felhom-samba")` / `docker exec felhom-samba ip -4 -o addr show eth0` | injected in controller/internal/stacks/samba_test.go — the idempotency test asserts the up-seam is called **zero** times when config is unchanged; the passwd seam means no unit test ever handles a real secret or touches docker. **`sambaRunFn` has an EXPORTED setter (`SetSambaRunProbe`)** — internal/web's status-contract tests need a live-container world from another package. `sambaAddrFn` backs `SambaLANAddress()` (v0.151.0); its parse is separately pinned in samba_lanaddr_test.go and it returns "" on any failure — the page omits a line rather than printing a wrong address |
|
||||||
| `Manager.SambaLANAddress()` | controller/internal/stacks/samba.go | `() string` — the guest's LAN IPv4 for the Megosztás connect card (v0.151.0, S-2) | Read from the SAMBA container's netns (`network_mode: host`), never `net.InterfaceAddrs()` — the controller is on a docker BRIDGE and would answer 172.x (the same trap `setup.DetectLocalIPs` needs `HOST_IP` for). **NEVER cache/persist it** — the guest holds it by DHCP (S-5); callers re-derive per render. `""` = omit the line |
|
| `Manager.SambaLANAddress()` | controller/internal/stacks/samba.go | `() string` — the guest's LAN IPv4 for the Megosztás connect card (v0.151.0, S-2) | Read from the SAMBA container's netns (`network_mode: host`), never `net.InterfaceAddrs()` — the controller is on a docker BRIDGE and would answer 172.x (the same trap `setup.DetectLocalIPs` needs `HOST_IP` for). **NEVER cache/persist it** — the guest holds it by DHCP (S-5); callers re-derive per render. `""` = omit the line |
|
||||||
| `Server.sambaAddrFn` (func seam) | controller/internal/web/server.go (field) + sharing_handlers.go `sambaLANAddress()` | nil → `stackMgr.SambaLANAddress()` | The web-side half of the connect card. Tests inject a COUNTED fn — the fresh-per-render assertion is what stops anyone memoizing a DHCP lease |
|
| `Server.sambaAddrFn` (func seam) | controller/internal/web/server.go (field) + sharing_handlers.go `sambaLANAddress()` | nil → `stackMgr.SambaLANAddress()` | The web-side half of the connect card. Tests inject a COUNTED fn — the fresh-per-render assertion is what stops anyone memoizing a DHCP lease |
|
||||||
|
| `Manager.guestNetExecFn` (func seam) + `GuestGateway()` / `GuestNetSnapshot()` | controller/internal/stacks/manager.go (field) + guestnet.go | nil → `docker exec felhom-samba <args>` — ONE seam for all R-66 guest-netns reads (route/link/addr/resolv.conf); tests script canned outputs per argv | guestnet_test.go. **The netns door rule:** the controller's OWN netns is the docker bridge, so any in-process read (`net.Interfaces`, `/proc/net/route`, its own `/etc/resolv.conf` = 127.0.0.11) is the S-2 wrong answer — guest-net reads MUST go through the samba (`network_mode: host`) exec door. Megosztás off ⇒ door closed ⇒ "" / per-item error strings; NEVER substitute an in-process value. Same S-5 law as SambaLANAddress: live per render, never cached/persisted. Parsers (`parseDefaultRoute`, `parseGuestInterfaces`, `parseResolvConf`) are pure + separately pinned |
|
||||||
|
| `Server.guestGatewayFn` / `guestNetFn` (func seams) | controller/internal/web/server.go (fields) + sharing_handlers.go accessors | nil → `stackMgr.GuestGateway` / `stackMgr.GuestNetSnapshot` | network_card_test.go — the counted-fn freshness test (2 renders ⇒ 2 resolves) is what stops anyone memoizing a DHCP lease; the Hálózati név row is gated on `smb.Enabled` (red-proven: gate dropped ⇒ \\FELHOM rendered while samba is down) |
|
||||||
| `sambaEnsureState.consumeIfRunning()` | controller/internal/web/samba_ensure_job.go | serve-once `snapshot()` for terminal `running` only | `/sharing/status` carries a job EDGE (`phase`) and a service LEVEL (`running`) in one envelope — never let a level reach the phase channel, and never re-serve a consumed edge: the client answers `phase=="running"` with `location.reload()`, so both mistakes produce an infinite page reload (S-1/S-4, DIAG-sharing-2026-07-20.md). `failed`/`needs_password`/in-flight are NOT consumed |
|
| `sambaEnsureState.consumeIfRunning()` | controller/internal/web/samba_ensure_job.go | serve-once `snapshot()` for terminal `running` only | `/sharing/status` carries a job EDGE (`phase`) and a service LEVEL (`running`) in one envelope — never let a level reach the phase channel, and never re-serve a consumed edge: the client answers `phase=="running"` with `location.reload()`, so both mistakes produce an infinite page reload (S-1/S-4, DIAG-sharing-2026-07-20.md). `failed`/`needs_password`/in-flight are NOT consumed |
|
||||||
| `infra.SambaHostInterface` | controller/internal/infra/samba.go | the guest LAN nic name (`eth0`) | Single source for smb.conf's `interfaces =`, the container's `FELHOM_IFACE`, and the LAN-address read — if they name different nics, the service and the address the page prints drift apart |
|
| `infra.SambaHostInterface` | controller/internal/infra/samba.go | the guest LAN nic name (`eth0`) | Single source for smb.conf's `interfaces =`, the container's `FELHOM_IFACE`, and the LAN-address read — if they name different nics, the service and the address the page prints drift apart |
|
||||||
| `Manager.sambaImgFn` (func seam) | controller/internal/stacks/manager.go (field) + samba.go | nil → `docker image inspect <infra.SambaImage>` | drives the 4b card's pulling-vs-starting decision, which MUST be taken before `compose up` (afterwards the image is always present) |
|
| `Manager.sambaImgFn` (func seam) | controller/internal/stacks/manager.go (field) + samba.go | nil → `docker image inspect <infra.SambaImage>` | drives the 4b card's pulling-vs-starting decision, which MUST be taken before `compose up` (afterwards the image is always present) |
|
||||||
|
|||||||
+15
-1
@@ -122,6 +122,20 @@ backups, monitoring and notifications. All Proxmox/disk operations are delegated
|
|||||||
`agentapi.ResizeMemory` → the code→Hungarian map. A JS confirm fires only on a shrink; an outdated agent
|
`agentapi.ResizeMemory` → the code→Hungarian map. A JS confirm fires only on a shrink; an outdated agent
|
||||||
hides the control; an unreachable agent falls back to the guest's own `/proc/meminfo`. Memory only
|
hides the control; an unreachable agent falls back to the guest's own `/proc/meminfo`. Memory only
|
||||||
(cores stay observation). The lxcfs ripple means the deploy-page memory math follows a resize for free.
|
(cores stay observation). The lxcfs ripple means the deploy-page memory math follows a resize for free.
|
||||||
|
- **„Hálózat" card (v0.159.0, R-66)** — Beállítások → Rendszer, between „Verzió és frissítés" and
|
||||||
|
„Szerver memória": **Helyi cím (LAN)**, **Hálózati név** (`\\<SMBServerName>` — rendered ONLY while
|
||||||
|
Megosztás is enabled; the NetBIOS name exists only while samba runs), **Átjáró**, plus a muted
|
||||||
|
footer ("read this page aloud during remote troubleshooting"). Every value is live-computed per
|
||||||
|
render and stored NOWHERE (S-5): the reads go through the samba-container netns door
|
||||||
|
(`internal/stacks/guestnet.go` — the controller's own netns is the docker bridge, so in-process
|
||||||
|
answers like `/proc/net/route` would report 172.x, the S-2 trap); with Megosztás off the door is
|
||||||
|
closed and rows render „—" (an address-less row beats a wrong address). Companions: the Debug
|
||||||
|
system dump gains a `network` section (interfaces without veth*/docker*/br-* plumbing, default
|
||||||
|
route + gateway + source interface, DNS from the guest's resolv.conf, and the SAME `lan_address`
|
||||||
|
the card shows for cross-checking), and the NAS add form names the NetBIOS trap — helper text
|
||||||
|
under Szerver, plus a purely lexical hint appended to an `unreachable` failure when the submitted
|
||||||
|
server is a single-label non-IP name (`looksLikeFlatNetworkName`; no NetBIOS/mDNS resolution is
|
||||||
|
ever attempted).
|
||||||
- **Page IA (v0.97.0, TASK-D1)** — the settings monolith is split into four pages, each with its own
|
- **Page IA (v0.97.0, TASK-D1)** — the settings monolith is split into four pages, each with its own
|
||||||
data builder (`systemPageData`/`storagePageData`/`notificationsPageData`/`securityPageData`, sharing
|
data builder (`systemPageData`/`storagePageData`/`notificationsPageData`/`securityPageData`, sharing
|
||||||
`settingsBaseData`) and template. Routes: `/settings` (Rendszer), `/settings/notifications`
|
`settingsBaseData`) and template. Routes: `/settings` (Rendszer), `/settings/notifications`
|
||||||
@@ -2190,7 +2204,7 @@ When `logging.level: "debug"` is set in `controller.yaml`, the controller expose
|
|||||||
|
|
||||||
| # | Section | Endpoints | Description |
|
| # | Section | Endpoints | Description |
|
||||||
|---|---------|-----------|-------------|
|
|---|---------|-----------|-------------|
|
||||||
| 1 | Rendszer diagnosztika | `GET /api/debug/dump` | Full state dump: controller info, storage, stacks, scheduler, health, alerts. JSON download. |
|
| 1 | Rendszer diagnosztika | `GET /api/debug/dump` | Full state dump: controller info, storage, stacks, network (guest-netns interfaces/route/DNS via the samba door, R-66; best-effort per item), scheduler, health, alerts. JSON download. |
|
||||||
| 2 | Értesítés teszt | `POST /api/debug/event/test`, `GET /api/debug/event/history` | Send test events with configurable type/severity, view event history ring buffer. |
|
| 2 | Értesítés teszt | `POST /api/debug/event/test`, `GET /api/debug/event/history` | Send test events with configurable type/severity, view event history ring buffer. |
|
||||||
| 3 | Mentés teszt | `POST /api/debug/backup/{dbdump,crossdrive,integrity,infra}` | Trigger individual backup phases independently. |
|
| 3 | Mentés teszt | `POST /api/debug/backup/{dbdump,crossdrive,integrity,infra}` | Trigger individual backup phases independently. |
|
||||||
| 4 | Tárhely teszt | `POST /api/debug/storage/simulate-{disconnect,reconnect}`, `GET /api/debug/storage/watchdog-status` | Simulate drive disconnect/reconnect without unmounting. Per-path probe state with 5s auto-refresh. |
|
| 4 | Tárhely teszt | `POST /api/debug/storage/simulate-{disconnect,reconnect}`, `GET /api/debug/storage/watchdog-status` | Simulate drive disconnect/reconnect without unmounting. Per-path probe state with 5s auto-refresh. |
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -141,6 +141,10 @@ type Manager struct {
|
|||||||
// sambaAddrFn replaces the docker-exec that reads the guest's LAN IPv4 out of the samba
|
// 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).
|
// container's network namespace (v0.151.0, S-2/S-5 connect-address card).
|
||||||
sambaAddrFn func() (string, error)
|
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
|
// SetSambaRunProbe injects the samba liveness probe. Exported for the same reason
|
||||||
|
|||||||
@@ -214,6 +214,42 @@ func (s *Server) debugDump(w http.ResponseWriter, r *http.Request) {
|
|||||||
dump["backup"] = map[string]interface{}{"enabled": false}
|
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
|
// Hub
|
||||||
hubInfo := map[string]interface{}{
|
hubInfo := map[string]interface{}{
|
||||||
"url": s.cfg.Hub.URL,
|
"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.
|
// Guest RAM resize card (v0.143.0, R-24): current allocation + bounds + capability/reachability.
|
||||||
s.memoryCardData(data)
|
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
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -323,7 +324,15 @@ func (s *Server) pollAgentVerify(ctx context.Context, agent netAgent, jobID stri
|
|||||||
func netAddMessage(category, server string, mappedUID int) string {
|
func netAddMessage(category, server string, mappedUID int) string {
|
||||||
switch category {
|
switch category {
|
||||||
case "unreachable":
|
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":
|
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."
|
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":
|
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.
|
// trimNetDetail bounds the raw detail shown in the UI collapsible.
|
||||||
func trimNetDetail(d string) string {
|
func trimNetDetail(d string) string {
|
||||||
d = strings.TrimSpace(d)
|
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.
|
// „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).
|
// Called PER RENDER and stored nowhere — the address is a DHCP lease (see sambaLANAddress).
|
||||||
sambaAddrFn func() string
|
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)
|
netAgentFn func() (netAgent, error)
|
||||||
// fabUpload is the chunked browser .fab upload single-flight slot (v0.128.0).
|
// fabUpload is the chunked browser .fab upload single-flight slot (v0.128.0).
|
||||||
fabUpload uploadState
|
fabUpload uploadState
|
||||||
|
|||||||
@@ -199,6 +199,35 @@ func (s *Server) sambaLANAddress() string {
|
|||||||
return s.stackMgr.SambaLANAddress()
|
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) {
|
func (s *Server) sharingPageHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
data := s.sharingPageData()
|
data := s.sharingPageData()
|
||||||
if f := strings.TrimSpace(r.URL.Query().Get("flash")); f != "" {
|
if f := strings.TrimSpace(r.URL.Query().Get("flash")); f != "" {
|
||||||
|
|||||||
@@ -143,6 +143,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="settings-card">
|
||||||
<h3>Szerver memória (RAM)</h3>
|
<h3>Szerver memória (RAM)</h3>
|
||||||
{{if .MemorySupported}}
|
{{if .MemorySupported}}
|
||||||
|
|||||||
@@ -75,6 +75,7 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="ns-server">Szerver (IP vagy hosztnév) <span class="required">*</span></label>
|
<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">
|
<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>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="ns-export" id="ns-export-label">Megosztás neve</label>
|
<label for="ns-export" id="ns-export-label">Megosztás neve</label>
|
||||||
|
|||||||
Reference in New Issue
Block a user