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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user