package localapi import ( "context" "fmt" "net/http" "os" "path/filepath" "strings" "time" "gitea.dooplex.hu/admin/felhom-agent/internal/storage" ) // Network storage (NAS) — Part A1, doc 03 §6 + SPIKE-nas-storage-2026-06-29.md. The agent mounts a NAS // share HOST-SIDE under /mnt/felhom-drives/ (it propagates into the guest via the existing shared // bind); Docker then binds it into a media container. A NAS is a DISTINCT class from a physical drive: // it bypasses the disk enroll/eject/decommission/wipe machinery entirely (no durable-id, no SMART). The // controller registry/UI (A2) drives these endpoints; A1 is the agent foundation. // // Self-scoping is unchanged (withGuest): one customer per host, the token's guest authorizes the host's // network-mount surface. The mount is bulk-userdata role ONLY (role-gated below). // NetworkStorageOps is the privileged network-mount surface the endpoints need. Satisfied by // *storage.SudoHostOps. Optional — the endpoints report "not configured" when absent. type NetworkStorageOps interface { EnsureNetworkMount(ctx context.Context, spec storage.NetworkMountSpec) error RemoveNetworkMount(ctx context.Context, name string) error ListNetworkMounts(ctx context.Context) ([]storage.NetworkMountStatus, error) } // defaultSmbCredsDir is where the agent writes the 0600 SMB credentials files (out-of-band; never in // git, never in a plaintext registry, never logged — same posture as the rotated Resend key). const defaultSmbCredsDir = "/var/lib/felhom-agent/smb-creds" // netStorageAddRequest is POST /netstorage/add. For SMB, username+password are written by the agent to a // 0600 creds file and NEVER logged or echoed back; for NFS they are ignored (server-side squash handles // uid mapping). mapped_uid/mapped_gid are the CONTAINER ids (the agent applies the +100000 host offset). type netStorageAddRequest struct { VMID int `json:"vmid"` // optional; must match the token's guest if set Name string `json:"name"` Protocol string `json:"protocol"` // "nfs" | "smb" Server string `json:"server"` Export string `json:"export"` // NFS export path | SMB share name MappedUID int `json:"mapped_uid"` // container uid (e.g. 1000) MappedGID int `json:"mapped_gid"` // container gid IdleTimeoutSec int `json:"idle_timeout_sec"` // automount idle-unmount window; 0 → default Username string `json:"username,omitempty"` // SMB only (secret — written to creds file) Password string `json:"password,omitempty"` // SMB only (secret — written to creds file) } // handleNetStorageAdd installs a NAS share host-side — verify-before-commit (SPIKE-nas-verify). // Pipeline: decode + scope + role-gate (unchanged) → SYNC fast-fail (spec validation + a 2 s TCP // pre-probe; an unreachable server is refused with NOTHING installed — Scenario E) → stage SMB creds // (0600) → EnsureNetworkMount → start the DETACHED verify job → respond {verify:"started"}. The // caller (controller orchestrator) polls GET /netstorage/verify-status; a failed verify has already // auto-rolled-back agent-side. func (s *Server) handleNetStorageAdd(w http.ResponseWriter, r *http.Request, vmid int) { if s.netStorage == nil { writeErr(w, http.StatusServiceUnavailable, "network storage not configured on this host") return } var req netStorageAddRequest if !decodeBody(w, r, &req) { return } if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) { return } // Role gate (SPIKE Q7): network storage is bulk-userdata ONLY. The mount lands under the // network-mount root; refuse anything that would resolve outside the user-data namespace. where := s.netMountRoot + "/" + strings.TrimSpace(req.Name) if storage.NetworkMountRole(where) != storage.RoleUserData { s.logger.Warn("local-api: network mount refused — not a user-data path", "name", req.Name, "where", where, "role", storage.NetworkMountRole(where)) writeErr(w, http.StatusForbidden, "network storage is restricted to the user-data namespace") return } spec := storage.NetworkMountSpec{ Name: strings.TrimSpace(req.Name), Protocol: storage.NetworkProtocol(strings.ToLower(strings.TrimSpace(req.Protocol))), Server: strings.TrimSpace(req.Server), Export: strings.TrimSpace(req.Export), MappedUID: req.MappedUID, MappedGID: req.MappedGID, IdleTimeoutSec: req.IdleTimeoutSec, } // SYNC fast-fail 1/2 — full spec validation BEFORE anything is written or installed. For SMB the // spec is validated against the CANONICAL creds path (a fixed POSIX constant + the // charset-validated name), not the configured dir — the configured dir is agent-controlled, not // user input, and EnsureNetworkMount re-validates the real spec anyway. The file itself is // written only after every sync check has passed. if spec.Protocol == storage.ProtocolSMB { if req.Username == "" || req.Password == "" { writeErr(w, http.StatusBadRequest, "smb credentials (username + password) are required") return } spec.CredsRef = defaultSmbCredsDir + "/" + spec.Name + ".cred" } if err := storage.ValidateNetworkMountSpec(spec); err != nil { writeErr(w, http.StatusBadRequest, err.Error()) return } // Single-flight (Scenario G): claim the one verify slot before any side effect, so two racing // adds can never interleave creds/install/verify. job := &netVerifyJob{ JobID: s.nowFn().UTC().Format("20060102T150405.000Z"), Name: spec.Name, Where: spec.Where(), Protocol: string(spec.Protocol), Phase: netVerifyPhaseRunning, StartedAt: s.nowFn().UTC().Format(time.RFC3339), UpdatedAt: s.nowFn().UTC().Format(time.RFC3339), } if !s.tryStartNetVerify(job) { writeStatus(w, http.StatusConflict, false, map[string]any{"code": "busy"}, "a network storage add is already in progress") return } // SYNC fast-fail 2/2 (Scenario E): a dead endpoint is refused in ~2 s, BEFORE any unit or creds // file exists. The refusal carries the category code so the UI message is exact. if !s.netReachable(spec.Protocol, spec.Server) { s.releaseNetVerify(job) s.logger.Warn("local-api: network mount refused — endpoint not reachable", "name", spec.Name, "server", spec.Server, "proto", spec.Protocol, "code", storage.NetVerifyUnreachable) writeStatus(w, http.StatusBadGateway, false, map[string]any{"code": storage.NetVerifyUnreachable}, "NAS endpoint not reachable") return } s.logger.Debug("local-api: NAS endpoint pre-probe passed", "name", spec.Name, "server", spec.Server, "proto", spec.Protocol) // SMB: stage the credentials out-of-band (0600). NFS needs none (server squash). if spec.Protocol == storage.ProtocolSMB { credsPath, err := s.writeSMBCreds(spec.Name, req.Username, req.Password) if err != nil { s.releaseNetVerify(job) s.logger.Error("local-api: writing SMB credentials failed", "name", spec.Name, "err", err) writeErr(w, http.StatusBadRequest, "could not stage SMB credentials") return } spec.CredsRef = credsPath s.logger.Debug("local-api: SMB credentials staged", "name", spec.Name, "path", credsPath) // path only, never content } if err := s.netStorage.EnsureNetworkMount(r.Context(), spec); err != nil { s.logger.Error("local-api: network mount add failed", "name", spec.Name, "proto", spec.Protocol, "err", err) // Clean up a creds file we staged for a mount that then failed to apply. if spec.Protocol == storage.ProtocolSMB { s.removeSMBCreds(spec.Name) } s.releaseNetVerify(job) writeErr(w, http.StatusBadGateway, "network mount failed: "+err.Error()) return } // Units installed + automount enabled — hand off to the detached verify (it rolls back on fail). s.runNetVerify(spec, job) s.logger.Info("local-api: network mount installed — verify started", "name", spec.Name, "proto", spec.Protocol, "server", spec.Server, "export", spec.Export, "where", spec.Where(), "job_id", job.JobID) writeOK(w, map[string]any{ "name": spec.Name, "protocol": string(spec.Protocol), "where": spec.Where(), "host_uid": spec.HostUID(), "host_gid": spec.HostGID(), "guest_path": spec.Where(), // the in-guest path the controller (A2) repoints a media app's data dir to "verify": "started", "job_id": job.JobID, }) } // handleNetStorageList lists configured network mounts + per-share liveness (read-only/benign). func (s *Server) handleNetStorageList(w http.ResponseWriter, r *http.Request, vmid int) { if s.netStorage == nil { writeErr(w, http.StatusServiceUnavailable, "network storage not configured on this host") return } mounts, err := s.netStorage.ListNetworkMounts(r.Context()) if err != nil { s.logger.Error("local-api: network mount list failed", "err", err) writeErr(w, http.StatusBadGateway, "could not list network mounts") return } if mounts == nil { mounts = []storage.NetworkMountStatus{} } writeOK(w, map[string]any{"vmid": vmid, "network_mounts": mounts}) } type netStorageRemoveRequest struct { VMID int `json:"vmid"` Name string `json:"name"` } // handleNetStorageRemove unmounts + removes a network mount and its SMB creds file. It NEVER routes // through the drive eject/decommission path — a NAS has no device lifecycle to tear down. func (s *Server) handleNetStorageRemove(w http.ResponseWriter, r *http.Request, vmid int) { if s.netStorage == nil { writeErr(w, http.StatusServiceUnavailable, "network storage not configured on this host") return } var req netStorageRemoveRequest if !decodeBody(w, r, &req) { return } if !s.scopedFromBody(w, req.VMID, vmid, r.URL.Path) { return } name := strings.TrimSpace(req.Name) if name == "" { writeErr(w, http.StatusBadRequest, "name is required") return } if err := s.netStorage.RemoveNetworkMount(r.Context(), name); err != nil { s.logger.Error("local-api: network mount remove failed", "name", name, "err", err) writeErr(w, http.StatusBadGateway, "network mount removal failed: "+err.Error()) return } s.removeSMBCreds(name) // best-effort: a NFS mount has no creds file (no-op) s.logger.Info("local-api: network mount removed", "name", name) writeOK(w, map[string]any{"name": name, "removed": true}) } // ---- SMB credentials (out-of-band) --------------------------------------------------------------- // writeSMBCreds writes a 0600 mount.cifs credentials file for the share and returns its path. The file // is owned by the agent user; root (the systemd-triggered mount.cifs) still reads it. The secret is // NEVER logged. A missing username/password is an error (SMB needs credentials). func (s *Server) writeSMBCreds(name, username, password string) (string, error) { if username == "" || password == "" { return "", fmt.Errorf("smb credentials (username + password) are required") } if strings.ContainsAny(username, "\n\r") || strings.ContainsAny(password, "\n\r") { return "", fmt.Errorf("smb credentials must not contain newlines") } path := s.smbCredsPath(name) if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return "", fmt.Errorf("creds dir: %w", err) } body := "username=" + username + "\npassword=" + password + "\n" if err := os.WriteFile(path, []byte(body), 0o600); err != nil { return "", fmt.Errorf("writing creds: %w", err) } return path, nil } // removeSMBCreds deletes a share's creds file (best-effort; absent is fine). func (s *Server) removeSMBCreds(name string) { if err := os.Remove(s.smbCredsPath(name)); err == nil { s.logger.Debug("local-api: SMB credentials file removed", "name", name, "path", s.smbCredsPath(name)) } } // smbCredsPath computes a share's creds file path (pure — used by validation BEFORE the file exists). func (s *Server) smbCredsPath(name string) string { dir := s.smbCredsDir if dir == "" { dir = defaultSmbCredsDir } return filepath.Join(dir, name+".cred") }