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:
2026-07-20 10:46:21 +02:00
parent 8db9232dea
commit badf17bebd
13 changed files with 663 additions and 14 deletions
+67
View File
@@ -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")
}