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
203 lines
7.1 KiB
Go
203 lines
7.1 KiB
Go
package localapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-agent/internal/storage"
|
|
)
|
|
|
|
// fakeNetOps records the network-mount surface calls (never touches a real host).
|
|
type fakeNetOps struct {
|
|
ensured []storage.NetworkMountSpec
|
|
removed []string
|
|
list []storage.NetworkMountStatus
|
|
ensErr error
|
|
}
|
|
|
|
func (f *fakeNetOps) EnsureNetworkMount(_ context.Context, s storage.NetworkMountSpec) error {
|
|
f.ensured = append(f.ensured, s)
|
|
return f.ensErr
|
|
}
|
|
func (f *fakeNetOps) RemoveNetworkMount(_ context.Context, n string) error {
|
|
f.removed = append(f.removed, n)
|
|
return nil
|
|
}
|
|
func (f *fakeNetOps) ListNetworkMounts(_ context.Context) ([]storage.NetworkMountStatus, error) {
|
|
return f.list, nil
|
|
}
|
|
|
|
func newNetServer(t *testing.T, n NetworkStorageOps, credsDir string) *Server {
|
|
t.Helper()
|
|
srv, err := NewServer(Options{
|
|
ListenAddr: "127.0.0.1:0",
|
|
Guests: &fakeGuests{},
|
|
Backups: &fakeBackups{},
|
|
Store: &fakeStore{},
|
|
Storage: fakeStorage{},
|
|
Tokens: staticTokens{"A": 8200, "B": 9300},
|
|
NetStorage: n,
|
|
SmbCredsDir: credsDir,
|
|
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("new server: %v", err)
|
|
}
|
|
return srv
|
|
}
|
|
|
|
func TestNetStorage_AddNFS_HappyPath(t *testing.T) {
|
|
n := &fakeNetOps{}
|
|
h := newNetServer(t, n, t.TempDir()).Handler()
|
|
body := `{"name":"media","protocol":"nfs","server":"10.0.0.5","export":"/srv/media","mapped_uid":1000,"mapped_gid":1000}`
|
|
w := do(t, h, "POST", "/netstorage/add", "A", body)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("add NFS: got %d want 200 (%s)", w.Code, w.Body.String())
|
|
}
|
|
if len(n.ensured) != 1 {
|
|
t.Fatalf("EnsureNetworkMount not called once: %v", n.ensured)
|
|
}
|
|
got := n.ensured[0]
|
|
if got.Name != "media" || got.Protocol != storage.ProtocolNFS || got.Server != "10.0.0.5" || got.Export != "/srv/media" {
|
|
t.Fatalf("spec mismatch: %+v", got)
|
|
}
|
|
if got.HostUID() != 101000 {
|
|
t.Errorf("host uid = %d want 101000 (the +100000 recipe)", got.HostUID())
|
|
}
|
|
}
|
|
|
|
func TestNetStorage_AddSMB_WritesCreds0600(t *testing.T) {
|
|
credsDir := t.TempDir()
|
|
n := &fakeNetOps{}
|
|
h := newNetServer(t, n, credsDir).Handler()
|
|
body := `{"name":"vids","protocol":"smb","server":"nas","export":"vids","mapped_uid":1000,"mapped_gid":1000,"username":"u","password":"p"}`
|
|
w := do(t, h, "POST", "/netstorage/add", "A", body)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("add SMB: got %d want 200 (%s)", w.Code, w.Body.String())
|
|
}
|
|
if len(n.ensured) != 1 || n.ensured[0].CredsRef == "" {
|
|
t.Fatalf("SMB spec must carry a creds ref: %+v", n.ensured)
|
|
}
|
|
credsPath := filepath.Join(credsDir, "vids.cred")
|
|
info, err := os.Stat(credsPath)
|
|
if err != nil {
|
|
t.Fatalf("creds file not written: %v", err)
|
|
}
|
|
// 0600 is enforced on Linux (the production OS); Windows does not honor Unix perms, so the perm
|
|
// assertion runs on the Linux build server where it matters.
|
|
if runtime.GOOS != "windows" && info.Mode().Perm()&0o077 != 0 {
|
|
t.Errorf("creds file is group/other-readable (mode %v) — must be 0600", info.Mode().Perm())
|
|
}
|
|
data, _ := os.ReadFile(credsPath)
|
|
if !strings.Contains(string(data), "username=u") || !strings.Contains(string(data), "password=p") {
|
|
t.Errorf("creds file content wrong: %q", data)
|
|
}
|
|
// The secret must NOT appear in the response body.
|
|
if strings.Contains(w.Body.String(), "\"p\"") || strings.Contains(w.Body.String(), "password") {
|
|
t.Errorf("response leaked credentials: %s", w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestNetStorage_AddSMB_MissingCreds_Refused(t *testing.T) {
|
|
n := &fakeNetOps{}
|
|
h := newNetServer(t, n, t.TempDir()).Handler()
|
|
body := `{"name":"vids","protocol":"smb","server":"nas","export":"vids","mapped_uid":1000,"mapped_gid":1000}`
|
|
w := do(t, h, "POST", "/netstorage/add", "A", body)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("SMB without creds: got %d want 400", w.Code)
|
|
}
|
|
if len(n.ensured) != 0 {
|
|
t.Fatal("EnsureNetworkMount must not be called when creds are missing")
|
|
}
|
|
}
|
|
|
|
// TestNetStorage_RoleGate_NonUserDataRefused: a mount root outside the user-data namespace is refused
|
|
// (403) and the mount surface is never touched — network storage is bulk-userdata ONLY.
|
|
func TestNetStorage_RoleGate_NonUserDataRefused(t *testing.T) {
|
|
n := &fakeNetOps{}
|
|
srv := newNetServer(t, n, t.TempDir())
|
|
srv.netMountRoot = "/srv/system" // a non-user-data path
|
|
h := srv.Handler()
|
|
body := `{"name":"media","protocol":"nfs","server":"10.0.0.5","export":"/srv/media","mapped_uid":1000,"mapped_gid":1000}`
|
|
w := do(t, h, "POST", "/netstorage/add", "A", body)
|
|
if w.Code != http.StatusForbidden {
|
|
t.Fatalf("non-user-data path: got %d want 403 (%s)", w.Code, w.Body.String())
|
|
}
|
|
if len(n.ensured) != 0 {
|
|
t.Fatal("a role-gated request must not reach EnsureNetworkMount")
|
|
}
|
|
}
|
|
|
|
func TestNetStorage_List(t *testing.T) {
|
|
n := &fakeNetOps{list: []storage.NetworkMountStatus{
|
|
{Name: "media", Protocol: "nfs", Server: "10.0.0.5", Where: "/mnt/felhom-drives/media", Configured: true, Mounted: true, Reachable: true, Health: storage.NetHealthOK},
|
|
}}
|
|
h := newNetServer(t, n, t.TempDir()).Handler()
|
|
w := do(t, h, "GET", "/netstorage", "A", "")
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("list: got %d want 200 (%s)", w.Code, w.Body.String())
|
|
}
|
|
var resp struct {
|
|
Data struct {
|
|
NetworkMounts []storage.NetworkMountStatus `json:"network_mounts"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if len(resp.Data.NetworkMounts) != 1 || resp.Data.NetworkMounts[0].Health != storage.NetHealthOK {
|
|
t.Fatalf("list payload wrong: %+v", resp.Data.NetworkMounts)
|
|
}
|
|
}
|
|
|
|
func TestNetStorage_Remove(t *testing.T) {
|
|
credsDir := t.TempDir()
|
|
// Pre-stage a creds file to prove removal cleans it up.
|
|
credsPath := filepath.Join(credsDir, "media.cred")
|
|
if err := os.WriteFile(credsPath, []byte("username=u\npassword=p\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
n := &fakeNetOps{}
|
|
h := newNetServer(t, n, credsDir).Handler()
|
|
w := do(t, h, "POST", "/netstorage/remove", "A", `{"name":"media"}`)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("remove: got %d want 200 (%s)", w.Code, w.Body.String())
|
|
}
|
|
if len(n.removed) != 1 || n.removed[0] != "media" {
|
|
t.Fatalf("RemoveNetworkMount not called: %v", n.removed)
|
|
}
|
|
if _, err := os.Stat(credsPath); !os.IsNotExist(err) {
|
|
t.Errorf("creds file should be removed, stat err = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestNetStorage_NotConfigured(t *testing.T) {
|
|
h := newNetServer(t, nil, "").Handler() // NetStorage nil
|
|
for _, tc := range []struct{ method, path, body string }{
|
|
{"POST", "/netstorage/add", `{"name":"media","protocol":"nfs","server":"x","export":"/y","mapped_uid":1000,"mapped_gid":1000}`},
|
|
{"GET", "/netstorage", ""},
|
|
{"POST", "/netstorage/remove", `{"name":"media"}`},
|
|
} {
|
|
w := do(t, h, tc.method, tc.path, "A", tc.body)
|
|
if w.Code != http.StatusServiceUnavailable {
|
|
t.Errorf("%s %s with no NetStorage: got %d want 503", tc.method, tc.path, w.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNetStorage_RequiresAuth(t *testing.T) {
|
|
h := newNetServer(t, &fakeNetOps{}, t.TempDir()).Handler()
|
|
w := do(t, h, "GET", "/netstorage", "", "")
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("no token: got %d want 401", w.Code)
|
|
}
|
|
}
|