Files
felhom-controller/controller/internal/web/netstorage_job.go
T
admin 7013a5fd2e 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
2026-07-22 13:49:57 +02:00

374 lines
14 KiB
Go

package web
import (
"context"
"errors"
"fmt"
"net"
"strings"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/logx"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// NAS add orchestration (verify-before-commit). POST /api/storage/netstorage/add no longer
// registers blind: it validates sync, then a DETACHED job (migrate.go shape — sync-validate-then-
// goroutine, single-flight, deep-copied status) drives:
//
// agent_add → the agent installs units + starts ITS detached verify (auto-rollback on fail)
// verifying → poll the agent's verify slot every 2 s; "none" after install = agent restarted
// mid-verify ⇒ WE roll back (Scenario F)
// probing → in-guest uid-1000 write probe (the squash trap — a mountable-but-unwritable
// export must never register)
// registering→ AddStoragePath — the LAST step, so the worst crash outcome is an agent-side
// orphan (surfaced by the list), never a registered-but-broken path
// done | failed
//
// The job runs on context.Background() with an overall deadline — a closed browser tab can never
// abort a half-done add mid-rollback. Rollback (agent RemoveNetStorage) is idempotent; a
// double-remove after the agent's own auto-rollback is harmless.
// netAgent is the agent surface the orchestrator needs (seam — tests inject a fake; production is
// the shared *agentapi.Client).
type netAgent interface {
AddNetStorage(ctx context.Context, req agentapi.AddNetStorageRequest) (agentapi.NetStorageAddResult, error)
NetVerifyStatus(ctx context.Context) (agentapi.NetVerifyStatus, error)
RemoveNetStorage(ctx context.Context, name string) error
}
// netAddJob is the poll-visible orchestration state (GET /api/storage/netstorage/add/status).
type netAddJob struct {
Name string `json:"name"`
Phase string `json:"phase"` // agent_add | verifying | probing | registering | done | failed
Category string `json:"category,omitempty"` // failure category (agent classifier vocabulary + not_writable/probe_io)
Message string `json:"message,omitempty"` // the category's Hungarian customer message (§3.2)
Detail string `json:"detail,omitempty"` // raw English/journal detail (the collapsible)
Warn string `json:"warn,omitempty"` // non-fatal note on a successful add (e.g. probe cleanup)
Path string `json:"path,omitempty"` // the registered in-guest path (done only)
StartedAt time.Time `json:"started_at"`
UpdatedAt time.Time `json:"updated_at"`
}
const (
netAddPhaseAgentAdd = "agent_add"
netAddPhaseVerifying = "verifying"
netAddPhaseProbing = "probing"
netAddPhaseRegistering = "registering"
netAddPhaseDone = "done"
netAddPhaseFailed = "failed"
)
const (
// netAddDeadline bounds the WHOLE orchestration (§8: detached ctx + ~150 s). The dominant term
// is the agent verify's own 95 s worst case (black-holed-but-routed server).
netAddDeadline = 150 * time.Second
// netVerifyPollEvery is the agent verify-slot poll cadence.
netVerifyPollEvery = 2 * time.Second
// netRollbackBudget bounds a rollback call on a possibly-expired job context.
netRollbackBudget = 30 * time.Second
)
// netAddState is the single-flight slot (migrate.go's acquire/release + deep-copy status shape).
type netAddState struct {
mu sync.Mutex
running bool
cur *netAddJob
}
func (s *netAddState) acquire(job *netAddJob) bool {
s.mu.Lock()
defer s.mu.Unlock()
if s.running {
return false
}
s.running = true
cp := *job
s.cur = &cp
return true
}
func (s *netAddState) release() {
s.mu.Lock()
s.running = false
s.mu.Unlock()
}
func (s *netAddState) set(job *netAddJob) {
s.mu.Lock()
cp := *job
s.cur = &cp
s.mu.Unlock()
}
// snapshot returns a copy of the last / in-flight job (nil = never ran).
func (s *netAddState) snapshot() *netAddJob {
s.mu.Lock()
defer s.mu.Unlock()
if s.cur == nil {
return nil
}
cp := *s.cur
return &cp
}
// netAgentForAdd resolves the net-storage agent surface (test seam first, then the shared
// client). Despite the name it serves the whole share lifecycle — the remove handler resolves
// through it too (v0.130.0).
func (s *Server) netAgentForAdd() (netAgent, error) {
if s.netAgentFn != nil {
return s.netAgentFn()
}
c, err := s.agentClient()
if err != nil {
return nil, err
}
return c, nil
}
// netProbe runs the in-guest uid-1000 write probe (test seam first, then the re-exec runner).
func (s *Server) netProbe(ctx context.Context, dir string) probeOutcome {
if s.netProbeFn != nil {
return s.netProbeFn(ctx, dir)
}
logx.Debugf(s.logger, "[web] netprobe exec start (uid-1000 re-exec) dir=%s", dir)
o := runNetProbe(ctx, dir)
logx.Debugf(s.logger, "[web] netprobe result: ok=%v category=%s detail=%s",
o.OK, o.Category, firstLine(o.Detail))
return o
}
// firstLine bounds a raw detail to its first line for a log field.
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
// startNetAdd claims the single-flight slot and launches the detached orchestration. false = an add
// is already in flight (Scenario G).
func (s *Server) startNetAdd(agent netAgent, req agentapi.AddNetStorageRequest, label string) bool {
job := &netAddJob{
Name: req.Name,
Phase: netAddPhaseAgentAdd,
StartedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
}
if !s.netAdd.acquire(job) {
return false
}
go s.runNetAdd(agent, req, label, job)
return true
}
// runNetAdd drives the phase machine on a DETACHED context (a closed tab can't abort a rollback).
func (s *Server) runNetAdd(agent netAgent, req agentapi.AddNetStorageRequest, label string, job *netAddJob) {
defer s.netAdd.release()
ctx, cancel := context.WithTimeout(context.Background(), netAddDeadline)
defer cancel()
start := time.Now()
logx.Infof(s.logger, "[web] netstorage add %q started (%s %s:%s, mapped_uid=%d)",
req.Name, req.Protocol, req.Server, req.Export, req.MappedUID)
setPhase := func(p string) {
logx.Debugf(s.logger, "[web] netstorage add %q phase %s -> %s (%dms elapsed)",
req.Name, job.Phase, p, time.Since(start).Milliseconds())
job.Phase = p
job.UpdatedAt = time.Now().UTC()
s.netAdd.set(job)
}
fail := func(category, detail string) {
job.Phase = netAddPhaseFailed
job.Category = category
job.Message = netAddMessage(category, req.Server, req.MappedUID)
job.Detail = detail
job.UpdatedAt = time.Now().UTC()
s.netAdd.set(job)
logx.Warnf(s.logger, "[web] netstorage add %q failed: category=%s detail=%s (%dms)",
req.Name, category, detail, time.Since(start).Milliseconds())
}
rollback := func(why string) {
logx.Debugf(s.logger, "[web] netstorage add %q rollback started (%s)", req.Name, why)
rctx, rcancel := context.WithTimeout(context.Background(), netRollbackBudget)
defer rcancel()
if err := agent.RemoveNetStorage(rctx, req.Name); err != nil {
// Best-effort by design: the agent may have auto-rolled-back already (double-remove is
// harmless) — but log it, a REAL leftover shows up as an orphan row in the list.
logx.Warnf(s.logger, "[web] netstorage add %q rollback (%s): remove: %v", req.Name, why, err)
} else {
logx.Infof(s.logger, "[web] netstorage add %q rolled back (%s)", req.Name, why)
}
}
// Phase 1 — agent add (install units + start the agent-side verify).
res, err := agent.AddNetStorage(ctx, req)
if err != nil {
var refused *agentapi.NetAddRefusedError
if errors.As(err, &refused) {
fail(refused.Code, refused.Msg) // categorized sync refusal — NOTHING was installed
return
}
fail("agent_error", err.Error())
return
}
// Phase 2 — poll the agent's verify slot. On a pre-verify agent (no job started) skip straight
// to the probe: the mount-trigger check then happens implicitly through the probe's write.
logx.Debugf(s.logger, "[web] netstorage add %q agent add accepted (verify=%s job_id=%s guest_path=%s)",
req.Name, res.Verify, res.JobID, res.GuestPath)
if res.Verify == "started" {
setPhase(netAddPhaseVerifying)
verdict, verr := s.pollAgentVerify(ctx, agent, res.JobID)
logx.Debugf(s.logger, "[web] netstorage add %q agent verify verdict: phase=%s code=%s (err=%v)",
req.Name, verdict.Phase, verdict.Code, verr)
switch {
case verr != nil:
rollback("verify poll failed")
fail("agent_error", "verify status unavailable: "+verr.Error())
return
case verdict.Phase == "failed":
// The agent has ALREADY auto-rolled-back (units + creds) — no controller rollback needed.
fail(verdict.Code, verdict.Detail)
return
case verdict.Phase == "done":
// verified — proceed
default:
// "none" (agent restarted mid-verify — Scenario F) or an alien job id: the verify is
// LOST, the units may linger — roll back and fail loud.
rollback("verify lost (agent restart?)")
fail("mount_failed", fmt.Sprintf("agent verify job lost after install (phase=%q) — rolled back", verdict.Phase))
return
}
}
// Phase 3 — the in-guest uid-1000 write probe (the squash trap).
setPhase(netAddPhaseProbing)
outcome := s.netProbe(ctx, res.GuestPath)
logx.Debugf(s.logger, "[web] netstorage add %q probe verdict: ok=%v category=%s warn=%q",
req.Name, outcome.OK, outcome.Category, outcome.Warn)
if !outcome.OK {
rollback("probe failed")
fail(outcome.Category, outcome.Detail)
return
}
// Phase 4 — register. THE LAST STEP (worst crash outcome = an agent-side orphan, never a
// registered-but-broken path).
setPhase(netAddPhaseRegistering)
sp := settings.StoragePath{
Path: res.GuestPath,
Label: label,
Schedulable: true,
AddedAt: time.Now().UTC().Format(time.RFC3339),
Kind: settings.StorageKindNetwork,
Protocol: req.Protocol,
Server: req.Server,
Export: req.Export,
MappedUID: req.MappedUID,
MappedGID: req.MappedGID,
}
if err := s.settings.AddStoragePath(sp); err != nil {
rollback("register failed")
fail("register_failed", err.Error())
return
}
job.Phase = netAddPhaseDone
job.Path = res.GuestPath
job.Warn = outcome.Warn
job.UpdatedAt = time.Now().UTC()
s.netAdd.set(job)
logx.Infof(s.logger, "[web] network storage added + verified: %s (%s %s:%s) → %s (warn=%q) in %dms",
req.Name, req.Protocol, req.Server, req.Export, res.GuestPath, outcome.Warn, time.Since(start).Milliseconds())
}
// pollAgentVerify polls the agent's verify slot until it leaves `running` (or ctx expires).
// Transient poll errors are tolerated up to a small budget — the agent may be busy mounting.
func (s *Server) pollAgentVerify(ctx context.Context, agent netAgent, jobID string) (agentapi.NetVerifyStatus, error) {
var last agentapi.NetVerifyStatus
consecutiveErrs := 0
t := time.NewTicker(netVerifyPollEvery)
defer t.Stop()
for {
st, err := agent.NetVerifyStatus(ctx)
if err != nil {
consecutiveErrs++
if consecutiveErrs >= 5 {
return last, err
}
} else {
consecutiveErrs = 0
last = st
if st.Phase != "running" {
// A different job id in the slot means OUR job is gone (restart + a newer add) —
// report it as the lost-verify shape, not a false done/failed.
if st.Phase != "none" && jobID != "" && st.JobID != jobID {
last.Phase = "none"
}
return last, nil
}
}
select {
case <-ctx.Done():
return last, ctx.Err()
case <-t.C:
}
}
}
// netAddMessage maps a failure category to the EXACT Hungarian customer message (§3.2). server and
// mappedUID parameterize the unreachable/not_writable texts (host id = mapped uid + 100000).
func netAddMessage(category, server string, mappedUID int) string {
switch category {
case "unreachable":
msg := "A szerver nem érhető el (" + server + "). Ellenőrizze az IP-címet, és hogy a NAS be van-e kapcsolva."
// R-66 Leg C: a single-label non-IP server (»FELHOM«) is almost always a Windows/NetBIOS
// network name, which this box generally cannot resolve — name the trap instead of letting
// the generic text teach nothing. Purely lexical on the submitted value: NO NetBIOS/mDNS
// resolution is ever attempted, and an IP or dotted DNS name never gets nagged about this.
if looksLikeFlatNetworkName(server) {
msg += " Tipp: a(z) »" + server + "« Windows-hálózati névnek tűnik — használja az eszköz IP-címét."
}
return msg
case "nfs_export":
return "A megosztás nem található, vagy a NAS nem engedélyezi ennek a gépnek a hozzáférését. Ellenőrizze az export útvonalát, és hogy a NAS engedélyezi-e a Felhom gép IP-címét."
case "smb_auth":
return "Hibás SMB felhasználónév vagy jelszó."
case "smb_share":
return "A megadott SMB-megosztás nem található a szerveren. Ellenőrizze a megosztás nevét."
case "timeout":
return "Időtúllépés a csatolás közben — a szerver elérhető a hálózaton, de a megosztás nem csatolható. Ellenőrizze a NAS NFS/SMB szolgáltatását."
case "not_writable":
return "A megosztás csatolható, de az alkalmazások nem tudnak rá írni. NFS esetén kapcsolja be a NAS-on a „minden felhasználó leképezése” (map all users / all squash) beállítást a megosztáson — vagy állítsa a fájlok tulajdonosát a(z) " + fmt.Sprint(mappedUID+100000) + " azonosítóra. SMB esetén ellenőrizze, hogy a felhasználónak írási joga van a megosztáson."
case "not_network_fs":
return "A hálózati tárhely csatolása a rendszeren belül nem jött létre megfelelően. Próbálja újra a csatlakoztatást; ha a hiba ismétlődik, jelezze az üzemeltetőnek."
case "probe_io":
return "Írási hiba a megosztáson (az adat nem olvasható vissza hibátlanul). Ellenőrizze a megosztást és a hálózatot."
case "busy":
return "Már folyamatban van egy csatlakoztatás. Várja meg, amíg befejeződik."
default: // mount_failed + agent_error + register_failed + any future agent code
return "A csatolás sikertelen. Részletek alább."
}
}
// looksLikeFlatNetworkName reports whether a submitted server value is a single-label non-IP name
// (no dot, not an IP literal) — the NetBIOS-name shape. `nas.local` (dotted) and any parseable IP
// (v4 or v6 — colons carry the v6 case through ParseIP) are NOT flagged: the hint must never nag
// someone who typed a resolvable form (R-66 C2/C3).
func looksLikeFlatNetworkName(server string) bool {
s := strings.TrimSpace(server)
return s != "" && !strings.Contains(s, ".") && net.ParseIP(s) == nil
}
// trimNetDetail bounds the raw detail shown in the UI collapsible.
func trimNetDetail(d string) string {
d = strings.TrimSpace(d)
if len(d) > 800 {
return d[:800] + "…"
}
return d
}