v0.151.0 — the Megosztás page stops reloading, and says how to connect
S-1: /sharing/status coerced idle->running on the PHASE channel, so the first poll of every steady-state page load reported a terminal job that never ran and the client's repaint-reload fired ~1.2s apart, forever. The coercion's real duty (liveness must never be contradicted) belongs to the 'running' LEVEL field beside it, and is now pinned by its own regression test. S-4 core: a terminal 'running' is served exactly once, so a REAL bring-up cannot re-arm the reload on the page it just caused. failed/needs_password/in-flight are never consumed. Unified async-job feedback stays the ROADMAP item. S-2/S-5: new connect card with the Windows form, the Mac form and the direct smb://<IP>, read from the SAMBA container's netns (the controller is on a docker bridge and would answer 172.x). Derived per render, cached nowhere - the address is a DHCP lease. Underivable => the line is omitted. sharing.html's <script> block is byte-identical to v0.150.0. Red-proofed three ways. 23/23 packages green.
This commit is contained in:
@@ -30,6 +30,12 @@ type SambaData struct {
|
||||
// SambaHouseholdUser is the single household SMB account name (matches the entrypoint's unix user).
|
||||
const SambaHouseholdUser = "felhom"
|
||||
|
||||
// SambaHostInterface is the guest's LAN interface, and the single source of truth for it: smb.conf's
|
||||
// `interfaces =` line, the container's FELHOM_IFACE env, and the controller's LAN-address read
|
||||
// (stacks.SambaLANAddress, v0.151.0) must all name the SAME nic or the service and the address the
|
||||
// page prints drift apart.
|
||||
const SambaHostInterface = "eth0"
|
||||
|
||||
// SambaContainerName is the fixed container name the compose render pins. It is THE single source of
|
||||
// truth for it: the renderer below interpolates this constant, stacks.sambaContainer aliases it for
|
||||
// the exec paths, monitor's effective-protected set watches it (R-7b liveness), and the backup
|
||||
@@ -62,7 +68,7 @@ func RenderSambaConfig(d SambaData) string {
|
||||
b.WriteString(" server min protocol = SMB2\n")
|
||||
b.WriteString(" disable netbios = no\n")
|
||||
b.WriteString(" bind interfaces only = yes\n")
|
||||
b.WriteString(" interfaces = lo eth0\n")
|
||||
fmt.Fprintf(&b, " interfaces = lo %s\n", SambaHostInterface)
|
||||
b.WriteString(" smb ports = 445\n")
|
||||
b.WriteString(" load printers = no\n")
|
||||
b.WriteString(" printing = bsd\n")
|
||||
@@ -117,7 +123,7 @@ services:
|
||||
network_mode: host
|
||||
environment:
|
||||
- FELHOM_SERVER_NAME=%[3]s
|
||||
- FELHOM_IFACE=eth0
|
||||
- FELHOM_IFACE=%[8]s
|
||||
- FELHOM_UID=%[4]d
|
||||
- FELHOM_GID=%[4]d
|
||||
volumes:
|
||||
@@ -126,5 +132,5 @@ services:
|
||||
%[7]s
|
||||
volumes:
|
||||
%[5]s:
|
||||
`, SambaContainerName, SambaImage, d.ServerName, d.UID, SambaPassdbVolume, SambaPassdbMount, binds.String())
|
||||
`, SambaContainerName, SambaImage, d.ServerName, d.UID, SambaPassdbVolume, SambaPassdbMount, binds.String(), SambaHostInterface)
|
||||
}
|
||||
|
||||
@@ -119,8 +119,18 @@ type Manager struct {
|
||||
sambaPasswdFn func(password string) error
|
||||
sambaRunFn func() bool // replaces the docker-inspect liveness probe
|
||||
sambaImgFn func() bool // replaces the `docker image inspect` local-presence probe (4b card)
|
||||
// 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).
|
||||
sambaAddrFn func() (string, error)
|
||||
}
|
||||
|
||||
// SetSambaRunProbe injects the samba liveness probe. Exported for the same reason
|
||||
// SetMigrationDoneHook and SetOffboxStreamRunner are: the seam has to be reachable from ANOTHER
|
||||
// package's tests — here internal/web, which needs a live-container world to prove that a live
|
||||
// container no longer manufactures a job phase (S-1). Production never calls it; nil keeps the real
|
||||
// docker-inspect probe.
|
||||
func (m *Manager) SetSambaRunProbe(fn func() bool) { m.sambaRunFn = fn }
|
||||
|
||||
// NewManager creates a new stack manager.
|
||||
func NewManager(cfg *config.Config, logger *log.Logger) (*Manager, error) {
|
||||
composeCmd := cfg.Stacks.ComposeCommand
|
||||
|
||||
@@ -2,6 +2,7 @@ package stacks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -317,3 +318,69 @@ func (m *Manager) DisableSamba() error {
|
||||
|
||||
// SambaRunning reports whether the samba container is currently up (UI status line).
|
||||
func (m *Manager) SambaRunning() bool { return m.sambaIsRunning() }
|
||||
|
||||
// SambaLANAddress returns the guest's LAN IPv4 as the SMB service itself sees it, or "" when it
|
||||
// cannot be determined. Never an error to the caller: this feeds one optional hint line on the
|
||||
// Megosztás page (S-2), and a page that renders without it is strictly better than a page that
|
||||
// fails.
|
||||
//
|
||||
// WHY THE SAMBA CONTAINER AND NOT net.InterfaceAddrs(): the controller runs on a docker BRIDGE, so
|
||||
// its own addresses are 172.x — the classic wrong answer that already burned the setup wizard
|
||||
// (setup.DetectLocalIPs needs a HOST_IP env var for exactly this reason). felhom-samba is
|
||||
// `network_mode: host` inside the guest, so a docker-exec there reads the guest's real netns. That
|
||||
// also makes the answer the right KIND of true: it is the address smbd is bound to, not merely an
|
||||
// address the box happens to own.
|
||||
//
|
||||
// NEVER CACHED, NEVER PERSISTED (S-5): the guest holds this address by DHCP (`pct config 9201` →
|
||||
// `ip=dhcp`), so a value stored anywhere is a value that goes stale and starts misdirecting
|
||||
// customers. Callers re-derive per render.
|
||||
func (m *Manager) SambaLANAddress() string {
|
||||
addr, err := m.sambaLANAddr()
|
||||
if err != nil {
|
||||
// Debug, not warn: the overwhelmingly common cause is "sharing is off, so the container is
|
||||
// not there", which is not a fault worth an operator's attention.
|
||||
m.logger.Printf("[DEBUG] [samba] LAN address unavailable: %v", err)
|
||||
return ""
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
func (m *Manager) sambaLANAddr() (string, error) {
|
||||
if m.sambaAddrFn != nil {
|
||||
return m.sambaAddrFn()
|
||||
}
|
||||
out, err := exec.Command("docker", "exec", sambaContainer,
|
||||
"ip", "-4", "-o", "addr", "show", infra.SambaHostInterface).Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("docker exec ip addr: %w", err)
|
||||
}
|
||||
return parseIPv4FromIPAddrOutput(string(out))
|
||||
}
|
||||
|
||||
// parseIPv4FromIPAddrOutput pulls the address out of `ip -4 -o addr show <iface>`, whose one-line
|
||||
// form is:
|
||||
//
|
||||
// 2: eth0 inet 192.168.0.104/24 brd 192.168.0.255 scope global dynamic eth0\ valid_lft ...
|
||||
//
|
||||
// Pure and separately tested — the parsing is the only part that can silently produce a plausible
|
||||
// wrong string, and a wrong address on this page is worse than no address at all.
|
||||
func parseIPv4FromIPAddrOutput(out string) (string, error) {
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
fields := strings.Fields(line)
|
||||
for i, f := range fields {
|
||||
if f != "inet" || i+1 >= len(fields) {
|
||||
continue
|
||||
}
|
||||
addr := fields[i+1]
|
||||
if slash := strings.IndexByte(addr, '/'); slash >= 0 {
|
||||
addr = addr[:slash]
|
||||
}
|
||||
ip := net.ParseIP(addr)
|
||||
if ip == nil || ip.To4() == nil || ip.IsLoopback() || ip.IsUnspecified() || !ip.IsGlobalUnicast() {
|
||||
continue
|
||||
}
|
||||
return ip.String(), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no global-unicast IPv4 in ip-addr output")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// v0.151.0 (S-2): the `ip -4 -o addr show eth0` parse. Isolated because it is the one part of the
|
||||
// connect-address path that can silently produce a PLAUSIBLE WRONG string — and a wrong address on
|
||||
// that page is worse than no address, which is the whole reason the card exists.
|
||||
func TestParseIPv4FromIPAddrOutput(t *testing.T) {
|
||||
// Verbatim from the live demo guest (felhom-samba, host netns), backslash-continuation and all.
|
||||
const live = `2: eth0 inet 192.168.0.104/24 brd 192.168.0.255 scope global dynamic eth0\ valid_lft 1486sec preferred_lft 1486sec`
|
||||
|
||||
ok := []struct{ name, in, want string }{
|
||||
{"live demo output", live, "192.168.0.104"},
|
||||
{"no lifetime suffix", "2: eth0 inet 10.0.0.5/8 brd 10.255.255.255 scope global eth0", "10.0.0.5"},
|
||||
{"secondary address after loopback line", "1: lo inet 127.0.0.1/8 scope host lo\n2: eth0 inet 172.16.4.2/16 scope global eth0", "172.16.4.2"},
|
||||
}
|
||||
for _, tc := range ok {
|
||||
got, err := parseIPv4FromIPAddrOutput(tc.in)
|
||||
if err != nil {
|
||||
t.Errorf("%s: unexpected error %v", tc.name, err)
|
||||
continue
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("%s: got %q, want %q", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
// Every one of these would put a USELESS or MISLEADING address in front of a customer, so each
|
||||
// must be an error the caller turns into an omitted line — never a best-effort string.
|
||||
bad := []struct{ name, in string }{
|
||||
{"empty", ""},
|
||||
{"interface exists but has no address", "2: eth0 <NO-CARRIER>"},
|
||||
{"loopback only", "1: lo inet 127.0.0.1/8 scope host lo"},
|
||||
{"unspecified", "2: eth0 inet 0.0.0.0/0 scope global eth0"},
|
||||
{"link-local (DHCP never answered)", "2: eth0 inet 169.254.11.9/16 scope link eth0"},
|
||||
{"IPv6 only", "2: eth0 inet6 fe80::be24:11ff:fede:1be7/64 scope link"},
|
||||
{"garbage", "docker: Error response from daemon: No such container: felhom-samba"},
|
||||
{"inet with no value", "2: eth0 inet"},
|
||||
}
|
||||
for _, tc := range bad {
|
||||
if got, err := parseIPv4FromIPAddrOutput(tc.in); err == nil {
|
||||
t.Errorf("%s: must be an error, got %q", tc.name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SambaLANAddress swallows the error into "" — the page omits a line rather than failing — and
|
||||
// passes a good address straight through.
|
||||
func TestSambaLANAddressFailsQuiet(t *testing.T) {
|
||||
m := &Manager{logger: log.New(io.Discard, "", 0)}
|
||||
|
||||
m.sambaAddrFn = func() (string, error) { return "", errors.New("no such container") }
|
||||
if got := m.SambaLANAddress(); got != "" {
|
||||
t.Errorf("error path: got %q, want empty", got)
|
||||
}
|
||||
m.sambaAddrFn = func() (string, error) { return "192.0.2.10", nil }
|
||||
if got := m.SambaLANAddress(); got != "192.0.2.10" {
|
||||
t.Errorf("happy path: got %q, want 192.0.2.10", got)
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,41 @@ func (s *sambaEnsureState) snapshot() *sambaEnsureJob {
|
||||
return &cp
|
||||
}
|
||||
|
||||
// consumeIfRunning is snapshot() with SERVE-ONCE semantics for the one phase the client reads as an
|
||||
// edge rather than a level.
|
||||
//
|
||||
// `running` means "the bring-up you were watching has finished" — the client answers it by repainting
|
||||
// the page (a full reload, because the „Állapot" badge is server-rendered). A phase that stays
|
||||
// `running` in the snapshot therefore re-arms that reload on every subsequent page load: the loop
|
||||
// S-1 fixed by dropping the idle→running coercion would come straight back after the next REAL
|
||||
// bring-up, since the finished job outlives it in memory (S-4 core,
|
||||
// felhom.eu/documentation/audits/DIAG-sharing-2026-07-20.md). Reporting it exactly once is what
|
||||
// makes the edge an edge.
|
||||
//
|
||||
// Consumed: terminal `running`, and only while the single-flight slot is free — the job goroutine
|
||||
// sets the phase before its deferred release(), and eating it inside that window would lose the
|
||||
// success the customer is waiting for.
|
||||
//
|
||||
// NOT consumed: `failed` and `needs_password` (durable explanations — the client stops the timer and
|
||||
// shows a card, with no reload, so stickiness is informative and cannot loop) and every in-flight
|
||||
// phase (`pulling`/`starting`, which must survive being polled).
|
||||
//
|
||||
// Accepted cost: with two tabs open on /sharing during a bring-up, whichever polls first gets the
|
||||
// success banner and the other sees plain `idle`. Both then show the true state — `running` is still
|
||||
// on the level channel and the badge re-renders from the liveness probe either way.
|
||||
func (s *sambaEnsureState) consumeIfRunning() *sambaEnsureJob {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.cur == nil {
|
||||
return nil
|
||||
}
|
||||
cp := *s.cur
|
||||
if !s.running && cp.Phase == sambaPhaseRunning {
|
||||
s.cur = nil
|
||||
}
|
||||
return &cp
|
||||
}
|
||||
|
||||
// startSambaEnsure claims the single-flight slot and launches the detached reconcile. false = one is
|
||||
// already in flight (a double-submit must not start a second compose up on the same stack dir).
|
||||
//
|
||||
|
||||
@@ -96,6 +96,10 @@ type Server struct {
|
||||
storageInit storageInitState
|
||||
// sambaEnsure is the Megosztás bring-up progress slot (v0.147.0, 4b) — same single-flight shape.
|
||||
sambaEnsure sambaEnsureState
|
||||
// sambaAddrFn is the connect-address seam (v0.151.0, S-2/S-5): the guest's LAN IPv4 for the
|
||||
// „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
|
||||
netAgentFn func() (netAgent, error)
|
||||
// fabUpload is the chunked browser .fab upload single-flight slot (v0.128.0).
|
||||
fabUpload uploadState
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
)
|
||||
|
||||
// v0.151.0 — the „Csatlakozás a megosztáshoz" card (DIAG-sharing-2026-07-20.md, S-2 + S-5).
|
||||
//
|
||||
// The page used to show only the configured NetBIOS name. When a network fails to resolve it the
|
||||
// customer has no fallback and guesses an address; the guess that produced the diagnosis was the
|
||||
// Proxmox HOST's IP, which never ran smbd. So the card must name both ways in — and the direct
|
||||
// address must be re-derived on every render, because the guest holds it by DHCP.
|
||||
|
||||
// connectCardServer renders /sharing with sharing enabled, the given server name, and a counted
|
||||
// address seam. Returns the page body and a pointer to the derivation call count.
|
||||
func connectCardServer(t *testing.T, serverName, addr string) (*Server, *int) {
|
||||
t.Helper()
|
||||
s := testPageServer(t)
|
||||
if err := s.settings.SetSMBServerName(serverName); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.settings.SetSMBEnabled(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
calls := 0
|
||||
s.sambaAddrFn = func() string { calls++; return addr }
|
||||
return s, &calls
|
||||
}
|
||||
|
||||
// Scenario D — both name forms and the direct address are on the page, and the address came from a
|
||||
// derivation performed on THIS render.
|
||||
func TestSharingConnectCard_ShowsNameAndDirectAddress(t *testing.T) {
|
||||
s, calls := connectCardServer(t, "FELHOM", "192.0.2.10")
|
||||
|
||||
body := getPage(t, s, "/sharing").Body.String()
|
||||
for _, want := range []string{
|
||||
"Csatlakoz", // the card heading (ASCII-safe substring)
|
||||
`\\FELHOM`, // Windows form
|
||||
"smb://FELHOM", // Mac form
|
||||
"smb://192.0.2.10", // the fallback that did not exist before
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("page missing %q", want)
|
||||
}
|
||||
}
|
||||
if *calls == 0 {
|
||||
t.Error("address seam never called — the page cannot be showing a freshly derived address")
|
||||
}
|
||||
}
|
||||
|
||||
// S-5: derived per render, never memoized. A DHCP lease that is read once and reused is an address
|
||||
// that eventually sends customers to someone else's machine.
|
||||
func TestSharingConnectCard_AddressDerivedFreshEveryRender(t *testing.T) {
|
||||
s, calls := connectCardServer(t, "FELHOM", "192.0.2.10")
|
||||
|
||||
getPage(t, s, "/sharing")
|
||||
afterFirst := *calls
|
||||
if afterFirst != 1 {
|
||||
t.Fatalf("first render: %d derivations, want exactly 1", afterFirst)
|
||||
}
|
||||
getPage(t, s, "/sharing")
|
||||
if *calls != 2 {
|
||||
t.Errorf("second render: total derivations = %d, want 2 — the address is being cached", *calls)
|
||||
}
|
||||
|
||||
// And a CHANGED address must reach the page, which a cache would prevent.
|
||||
s.sambaAddrFn = func() string { *calls++; return "198.51.100.7" }
|
||||
body := getPage(t, s, "/sharing").Body.String()
|
||||
if !strings.Contains(body, "smb://198.51.100.7") {
|
||||
t.Error("the new address did not render — a stale value survived the re-derivation")
|
||||
}
|
||||
if strings.Contains(body, "192.0.2.10") {
|
||||
t.Error("the OLD address is still on the page")
|
||||
}
|
||||
}
|
||||
|
||||
// Fail-quiet: no derivable address omits the fallback lines but keeps the name lines. An
|
||||
// address-less page is strictly better than a page that fails, or one that prints a wrong address.
|
||||
func TestSharingConnectCard_NoAddressOmitsTheLine(t *testing.T) {
|
||||
s, _ := connectCardServer(t, "FELHOM", "")
|
||||
|
||||
body := getPage(t, s, "/sharing").Body.String()
|
||||
if !strings.Contains(body, "smb://FELHOM") {
|
||||
t.Error("name lines must remain when no address can be derived")
|
||||
}
|
||||
if strings.Contains(body, "smb://\n") || strings.Contains(body, "smb://<") || strings.Contains(body, "smb://</span>") {
|
||||
t.Error("an empty direct-address line rendered")
|
||||
}
|
||||
// The „változhat" caveat belongs to the address lines and must go with them.
|
||||
if strings.Contains(body, "zvetlen c") { // „közvetlen cím", ASCII-safe substring
|
||||
t.Error("the direct-address block rendered without an address")
|
||||
}
|
||||
}
|
||||
|
||||
// The name is CONFIGURABLE — nothing may hardcode FELHOM.
|
||||
func TestSharingConnectCard_UsesConfiguredName(t *testing.T) {
|
||||
s, _ := connectCardServer(t, "OTTHON", "192.0.2.10")
|
||||
|
||||
body := getPage(t, s, "/sharing").Body.String()
|
||||
if !strings.Contains(body, "smb://OTTHON") || !strings.Contains(body, `\\OTTHON`) {
|
||||
t.Error("the configured server name is not on the page")
|
||||
}
|
||||
if strings.Contains(body, "FELHOM") {
|
||||
t.Error("FELHOM rendered while the configured name is OTTHON — a hardcoded default leaked")
|
||||
}
|
||||
}
|
||||
|
||||
// The whole card is gated on the feature being on: a box with sharing off must not be told how to
|
||||
// connect to a service that is not running.
|
||||
func TestSharingConnectCard_AbsentWhenSharingDisabled(t *testing.T) {
|
||||
s := testPageServer(t)
|
||||
if err := s.settings.SetSMBServerName("FELHOM"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
calls := 0
|
||||
s.sambaAddrFn = func() string { calls++; return "192.0.2.10" }
|
||||
|
||||
body := getPage(t, s, "/sharing").Body.String()
|
||||
if strings.Contains(body, "Csatlakoz") {
|
||||
t.Error("the connect card rendered with sharing disabled")
|
||||
}
|
||||
if strings.Contains(body, "smb://192.0.2.10") {
|
||||
t.Error("a direct address rendered with sharing disabled")
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Errorf("address derived %d times with sharing off — a needless docker exec per render", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// Guard against the settings layer being bypassed: the page data reads the EFFECTIVE name, so an
|
||||
// unset name still renders the product default rather than an empty `smb://`.
|
||||
func TestSharingConnectCard_UnsetNameFallsBackToEffective(t *testing.T) {
|
||||
s := testPageServer(t)
|
||||
if err := s.settings.SetSMBEnabled(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.sambaAddrFn = func() string { return "192.0.2.10" }
|
||||
|
||||
def := settings.SMBSettings{}
|
||||
want := def.EffectiveServerName()
|
||||
if want == "" {
|
||||
t.Skip("no product default server name to assert")
|
||||
}
|
||||
if body := getPage(t, s, "/sharing").Body.String(); !strings.Contains(body, "smb://"+want) {
|
||||
t.Errorf("effective default name %q not rendered", want)
|
||||
}
|
||||
}
|
||||
@@ -122,7 +122,17 @@ func (s *Server) sharingPageData() map[string]interface{} {
|
||||
data["SMBEnabled"] = smb.Enabled
|
||||
data["SMBServerName"] = smb.EffectiveServerName()
|
||||
data["SMBUserSet"] = smb.UserSet
|
||||
data["SMBRunning"] = s.stackMgr.SambaRunning()
|
||||
data["SMBRunning"] = s.stackMgr != nil && s.stackMgr.SambaRunning()
|
||||
|
||||
// „Csatlakozás a megosztáshoz" (v0.151.0, S-2): the page has always shown the configured NAME and
|
||||
// never an address, so a customer whose network does not resolve the name had nothing to fall
|
||||
// back on and guessed — which is how this diagnosis started, with the Proxmox HOST's IP typed
|
||||
// into Finder (DIAG-sharing-2026-07-20.md). Derived FRESH on every render and cached nowhere:
|
||||
// the guest holds this address by DHCP, so a stored copy is a copy that eventually misdirects
|
||||
// people (S-5). "" simply omits the line — an address-less page beats a wrong address.
|
||||
if smb.Enabled {
|
||||
data["SMBDirectAddress"] = s.sambaLANAddress()
|
||||
}
|
||||
|
||||
type shareRow struct {
|
||||
Name string
|
||||
@@ -177,6 +187,18 @@ func (s *Server) sharingPageData() map[string]interface{} {
|
||||
return data
|
||||
}
|
||||
|
||||
// sambaLANAddress resolves the connect-address seam. Never cached at this level either — the seam
|
||||
// exists so tests can supply an address without docker, not so anyone can memoize one.
|
||||
func (s *Server) sambaLANAddress() string {
|
||||
if s.sambaAddrFn != nil {
|
||||
return s.sambaAddrFn()
|
||||
}
|
||||
if s.stackMgr == nil {
|
||||
return ""
|
||||
}
|
||||
return s.stackMgr.SambaLANAddress()
|
||||
}
|
||||
|
||||
func (s *Server) sharingPageHandler(w http.ResponseWriter, r *http.Request) {
|
||||
data := s.sharingPageData()
|
||||
if f := strings.TrimSpace(r.URL.Query().Get("flash")); f != "" {
|
||||
@@ -231,20 +253,28 @@ func (s *Server) sharingEnableHandler(w http.ResponseWriter, r *http.Request) {
|
||||
sharingRedirect(w, r, "Beállítás mentve. A megosztási szolgáltatás előkészítése folyamatban…")
|
||||
}
|
||||
|
||||
// sharingStatusHandler is the 4b poll target (GET /sharing/status). Reports the ensure job's phase
|
||||
// plus the live container state, so a page loaded AFTER the job finished (or after a restart, when
|
||||
// the in-memory job is gone) still shows the truth.
|
||||
// sharingStatusHandler is the 4b poll target (GET /sharing/status). It carries TWO independent
|
||||
// channels in one envelope, and keeping them apart is the whole point of this handler:
|
||||
//
|
||||
// phase — the ensure JOB. The client treats a terminal `running` as an EDGE ("the bring-up I was
|
||||
// watching just succeeded") and reloads once to repaint the server-rendered badge.
|
||||
// running — the service LEVEL, straight from the liveness probe. True whenever the container is up,
|
||||
// with or without a job, and it is what makes a page loaded after the job finished (or
|
||||
// after a controller restart, when the in-memory job is gone) still show the truth.
|
||||
//
|
||||
// v0.147.0 coerced `idle` → `running` here so that liveness could never be contradicted by a missing
|
||||
// job. That duty belongs to — and was already discharged by — the `running` field beside it; on the
|
||||
// phase channel the same value reads as a fresh terminal edge, so the client reloaded on the FIRST
|
||||
// poll of every steady-state page load and the page looped at ~1.2s forever
|
||||
// (felhom.eu/documentation/audits/DIAG-sharing-2026-07-20.md, S-1). The coercion is gone: no job,
|
||||
// no edge. See consumeIfRunning for the other half — a REAL bring-up must be reported exactly once.
|
||||
func (s *Server) sharingStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
phase := sambaPhaseIdle
|
||||
errMsg := ""
|
||||
if job := s.sambaEnsure.snapshot(); job != nil {
|
||||
if job := s.sambaEnsure.consumeIfRunning(); job != nil {
|
||||
phase, errMsg = job.Phase, job.Error
|
||||
}
|
||||
running := s.stackMgr != nil && s.stackMgr.SambaRunning()
|
||||
// A stale `idle`/`running` job must never contradict reality: liveness wins on a fresh page.
|
||||
if phase == sambaPhaseIdle && running {
|
||||
phase = sambaPhaseRunning
|
||||
}
|
||||
writeDiskJSON(w, http.StatusOK, true, "", map[string]any{
|
||||
"phase": phase, "error": errMsg, "running": running,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
)
|
||||
|
||||
// v0.151.0 — the /sharing/status CONTRACT (DIAG-sharing-2026-07-20.md, S-1 + S-4 core).
|
||||
//
|
||||
// The endpoint carries two channels that mean different things to the client:
|
||||
//
|
||||
// phase — the ensure JOB, and `running` on it is an EDGE the client answers with location.reload()
|
||||
// running — the service LEVEL, true whenever the container is up
|
||||
//
|
||||
// v0.147.0 coerced idle→running on the PHASE channel so liveness could never be contradicted. Every
|
||||
// steady-state page load then reported a terminal edge on its first poll and reloaded — forever,
|
||||
// ~1.2s apart, for every customer with sharing enabled. These tests pin the separation from both
|
||||
// sides: a live container must NOT manufacture a phase (A), and a real bring-up must be reported
|
||||
// exactly ONCE (B) while the non-reloading phases stay put (C).
|
||||
|
||||
// statusServer returns a Server whose liveness probe answers `running` and whose ensure slot starts
|
||||
// empty — the steady state of every box that has had sharing on for more than a moment.
|
||||
func statusServer(t *testing.T, running bool) *Server {
|
||||
t.Helper()
|
||||
s := testPageServer(t)
|
||||
s.stackMgr.SetSambaRunProbe(func() bool { return running })
|
||||
return s
|
||||
}
|
||||
|
||||
func getStatus(t *testing.T, s *Server) (phase string, isRunning bool) {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
s.sharingStatusHandler(rec, httptest.NewRequest(http.MethodGet, "/sharing/status", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status: got HTTP %d, want 200", rec.Code)
|
||||
}
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
Phase string `json:"phase"`
|
||||
Error string `json:"error"`
|
||||
Running bool `json:"running"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode %q: %v", rec.Body.String(), err)
|
||||
}
|
||||
if !env.OK {
|
||||
t.Fatalf("envelope not ok: %s", rec.Body.String())
|
||||
}
|
||||
return env.Data.Phase, env.Data.Running
|
||||
}
|
||||
|
||||
// Scenario A — a live container alone must never produce an edge. THE loop test: with the v0.147.0
|
||||
// coercion in place this fails on the very first call, which is exactly when the customer's page
|
||||
// reloaded.
|
||||
func TestSharingStatus_SteadyStateProducesNoEdge(t *testing.T) {
|
||||
s := statusServer(t, true)
|
||||
if job := s.sambaEnsure.snapshot(); job != nil {
|
||||
t.Fatalf("precondition: ensure slot must be empty, got phase %q", job.Phase)
|
||||
}
|
||||
for i := 1; i <= 2; i++ {
|
||||
phase, running := getStatus(t, s)
|
||||
if phase != sambaPhaseIdle {
|
||||
t.Errorf("call %d: phase = %q, want %q — a live container must not manufacture a job edge (this IS the reload loop)", i, phase, sambaPhaseIdle)
|
||||
}
|
||||
if !running {
|
||||
t.Errorf("call %d: running = false, want true", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The b5d78d1 REGRESSION test. The coercion was defensive, not a fix for a named repro: its comment
|
||||
// read „a stale idle/running job must never contradict reality: liveness wins on a fresh page". That
|
||||
// duty is real and it survives — discharged by the `running` field, which is where a LEVEL belongs.
|
||||
// This is the test that would catch someone deleting the coercion's intent along with its code.
|
||||
func TestSharingStatus_LivenessStillReportedWithoutAnyJob(t *testing.T) {
|
||||
for _, alive := range []bool{true, false} {
|
||||
s := statusServer(t, alive)
|
||||
phase, running := getStatus(t, s)
|
||||
if running != alive {
|
||||
t.Errorf("SambaRunning()=%v: running = %v, want %v — liveness must reach the client with no job in memory", alive, running, alive)
|
||||
}
|
||||
if phase != sambaPhaseIdle {
|
||||
t.Errorf("SambaRunning()=%v: phase = %q, want %q", alive, phase, sambaPhaseIdle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario B — a REAL bring-up: one edge, then silence. Without serve-once the loop returns after
|
||||
// every future image update, because the finished job outlives the reload it triggered.
|
||||
func TestSharingStatus_RealBringUpServedExactlyOnce(t *testing.T) {
|
||||
s := statusServer(t, true)
|
||||
s.sambaEnsure.set(&sambaEnsureJob{Phase: sambaPhaseRunning})
|
||||
|
||||
phase, running := getStatus(t, s)
|
||||
if phase != sambaPhaseRunning {
|
||||
t.Fatalf("first call: phase = %q, want %q — the genuine success edge must survive", phase, sambaPhaseRunning)
|
||||
}
|
||||
if !running {
|
||||
t.Error("first call: running = false, want true")
|
||||
}
|
||||
|
||||
// The page the first response caused to reload polls again. It must find nothing.
|
||||
phase, running = getStatus(t, s)
|
||||
if phase != sambaPhaseIdle {
|
||||
t.Errorf("second call: phase = %q, want %q — a re-served success re-arms the reload (post-bring-up loop)", phase, sambaPhaseIdle)
|
||||
}
|
||||
if !running {
|
||||
t.Error("second call: running = false, want true — the LEVEL must outlive the consumed edge")
|
||||
}
|
||||
}
|
||||
|
||||
// An in-flight job must never be consumed out from under itself: the goroutine sets the terminal
|
||||
// phase BEFORE its deferred release(), and eating it in that window loses the success the customer
|
||||
// is waiting on.
|
||||
func TestSharingStatus_RunningNotConsumedWhileInFlight(t *testing.T) {
|
||||
s := statusServer(t, true)
|
||||
if !s.sambaEnsure.acquire(&sambaEnsureJob{Phase: sambaPhaseRunning}) {
|
||||
t.Fatal("acquire refused")
|
||||
}
|
||||
if phase, _ := getStatus(t, s); phase != sambaPhaseRunning {
|
||||
t.Fatalf("in-flight: phase = %q, want %q", phase, sambaPhaseRunning)
|
||||
}
|
||||
if phase, _ := getStatus(t, s); phase != sambaPhaseRunning {
|
||||
t.Errorf("in-flight second call: phase = %q, want %q — consumed while the slot was still held", phase, sambaPhaseRunning)
|
||||
}
|
||||
s.sambaEnsure.release()
|
||||
if phase, _ := getStatus(t, s); phase != sambaPhaseRunning {
|
||||
t.Errorf("after release: phase = %q, want %q — the edge must be served once, now", phase, sambaPhaseRunning)
|
||||
}
|
||||
if phase, _ := getStatus(t, s); phase != sambaPhaseIdle {
|
||||
t.Errorf("after release, second call: phase = %q, want %q", phase, sambaPhaseIdle)
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario C — serve-once applies to `running` ONLY. `failed` and `needs_password` are durable
|
||||
// explanations whose client path stops the timer and shows a card with NO reload, so stickiness is
|
||||
// informative and cannot loop; in-flight phases must survive being polled.
|
||||
func TestSharingStatus_NonEdgePhasesStaySticky(t *testing.T) {
|
||||
for _, phase := range []string{sambaPhaseFailed, sambaPhaseNeedsPassword, sambaPhasePulling, sambaPhaseStarting} {
|
||||
s := statusServer(t, true)
|
||||
s.sambaEnsure.set(&sambaEnsureJob{Phase: phase, Error: "boom"})
|
||||
for i := 1; i <= 3; i++ {
|
||||
if got, _ := getStatus(t, s); got != phase {
|
||||
t.Errorf("%s call %d: phase = %q, want it to persist", phase, i, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The JSON contract itself: keys and shape are what sharing.html's untouched poll reads.
|
||||
func TestSharingStatus_EnvelopeShapeUnchanged(t *testing.T) {
|
||||
s := statusServer(t, true)
|
||||
rec := httptest.NewRecorder()
|
||||
s.sharingStatusHandler(rec, httptest.NewRequest(http.MethodGet, "/sharing/status", nil))
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, k := range []string{"phase", "error", "running"} {
|
||||
if _, ok := env.Data[k]; !ok {
|
||||
t.Errorf("data.%s missing — the client contract changed", k)
|
||||
}
|
||||
}
|
||||
if len(env.Data) != 3 {
|
||||
t.Errorf("data has %d keys, want exactly 3", len(env.Data))
|
||||
}
|
||||
}
|
||||
|
||||
// A nil stack manager must not panic the poll (the handler's own liveness guard).
|
||||
func TestSharingStatus_NilStackManagerIsQuiet(t *testing.T) {
|
||||
s := &Server{logger: log.New(io.Discard, "", 0), cfg: &config.Config{}}
|
||||
if phase, running := getStatus(t, s); phase != sambaPhaseIdle || running {
|
||||
t.Errorf("nil stackMgr: phase=%q running=%v, want idle/false", phase, running)
|
||||
}
|
||||
}
|
||||
@@ -62,6 +62,33 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .SMBEnabled}}
|
||||
<!-- v0.151.0 (S-2/S-5): the page names BOTH ways in. Until now it showed only the configured name,
|
||||
so a customer whose network fails to resolve it had nothing to fall back on but a guess — and
|
||||
the guess that started the diagnosis was the Proxmox HOST's address, which never ran smbd
|
||||
(DIAG-sharing-2026-07-20.md). The direct address is derived per render from the samba
|
||||
container's own netns and is absent whenever it cannot be read: no address beats a wrong one. -->
|
||||
<div class="settings-card">
|
||||
<h3>Csatlakozás a megosztáshoz</h3>
|
||||
<div class="form-hint">
|
||||
Windows: a Fájlkezelő címsorába: <span class="mono">\\{{.SMBServerName}}</span>
|
||||
</div>
|
||||
<div class="form-hint">
|
||||
Mac: Finder → Ugrás → Csatlakozás a szerverhez:
|
||||
<span class="mono">smb://{{.SMBServerName}}</span>
|
||||
</div>
|
||||
{{if .SMBDirectAddress}}
|
||||
<div class="form-hint">
|
||||
Ha a név nem működik, használd a közvetlen címet:
|
||||
<span class="mono">smb://{{.SMBDirectAddress}}</span>
|
||||
</div>
|
||||
<div class="form-hint">
|
||||
A közvetlen cím a hálózattól függően változhat — elsőként mindig a nevet próbáld.
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="settings-card">
|
||||
<h3>Megosztási jelszó beállítása</h3>
|
||||
<p class="settings-card-desc">
|
||||
|
||||
Reference in New Issue
Block a user