63aa63d0d6
Host-side NFS/SMB automount of a bulk-media NAS share under /mnt/felhom-drives/<name> (propagates into the guest via the existing shared bind), the +100000 uid recipe, per-share liveness, and add/list/remove local-API endpoints. A NAS is a distinct class that bypasses the drive enroll/eject/decommission/SMART/watchdog machinery. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HxLA1mZurFq9kt8hneFeCs
205 lines
8.6 KiB
Go
205 lines
8.6 KiB
Go
package localapi
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"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/<name> (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 mounts a NAS share host-side. It role-gates the mount to the user-data namespace,
|
|
// writes the SMB creds file out-of-band (0600), then calls EnsureNetworkMount (automount idle-unmount).
|
|
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,
|
|
}
|
|
|
|
// SMB: stage the credentials out-of-band (0600) BEFORE the mount. NFS needs none (server squash).
|
|
if spec.Protocol == storage.ProtocolSMB {
|
|
credsPath, err := s.writeSMBCreds(spec.Name, req.Username, req.Password)
|
|
if err != nil {
|
|
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
|
|
}
|
|
|
|
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)
|
|
}
|
|
writeErr(w, http.StatusBadGateway, "network mount failed: "+err.Error())
|
|
return
|
|
}
|
|
s.logger.Info("local-api: network mount added", "name", spec.Name, "proto", spec.Protocol,
|
|
"server", spec.Server, "export", spec.Export, "where", spec.Where())
|
|
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
|
|
})
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
dir := s.smbCredsDir
|
|
if dir == "" {
|
|
dir = defaultSmbCredsDir
|
|
}
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
return "", fmt.Errorf("creds dir: %w", err)
|
|
}
|
|
path := filepath.Join(dir, name+".cred")
|
|
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) {
|
|
dir := s.smbCredsDir
|
|
if dir == "" {
|
|
dir = defaultSmbCredsDir
|
|
}
|
|
_ = os.Remove(filepath.Join(dir, name+".cred"))
|
|
}
|