Make control-plane the real mount authority

Split node enrollment from export sync and issue Finder-compatible DAV
credentials so the stack proves the real backend seam before any web UI
consumes it.
This commit is contained in:
Harivansh Rathi 2026-04-01 17:46:50 +00:00
parent 5bc24fa99d
commit b5f8ea9c52
28 changed files with 1345 additions and 423 deletions

View file

@ -8,6 +8,7 @@ It is intentionally small for now:
- `GET /version`
- `POST /api/v1/nodes/register`
- `POST /api/v1/nodes/{nodeId}/heartbeat`
- `PUT /api/v1/nodes/{nodeId}/exports`
- `GET /api/v1/exports`
- `POST /api/v1/mount-profiles/issue`
- `POST /api/v1/cloud-profiles/issue`
@ -19,4 +20,7 @@ The request and response shapes must follow the contracts in
`BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN`, client flows use
`BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN`, and node registration returns an
`X-BetterNAS-Node-Token` header for subsequent node-scoped register and
heartbeat calls. Multi-export registrations should also send an explicit `mountPath` per export so mount profiles can stay stable across runtimes.
heartbeat and export sync calls. Mount profiles now return standard WebDAV
username and password credentials, and multi-export sync should send an
explicit `mountPath` per export so mount profiles can stay stable across
runtimes.

View file

@ -12,6 +12,8 @@ type appConfig struct {
statePath string
clientToken string
nodeBootstrapToken string
davAuthSecret string
davCredentialTTL time.Duration
}
type app struct {
@ -32,6 +34,14 @@ func newApp(config appConfig, startedAt time.Time) (*app, error) {
return nil, errors.New("node bootstrap token is required")
}
config.davAuthSecret = strings.TrimSpace(config.davAuthSecret)
if config.davAuthSecret == "" {
return nil, errors.New("dav auth secret is required")
}
if config.davCredentialTTL <= 0 {
return nil, errors.New("dav credential ttl must be greater than 0")
}
store, err := newMemoryStore(config.statePath)
if err != nil {
return nil, err
@ -88,13 +98,20 @@ type storageExport struct {
}
type mountProfile struct {
ID string `json:"id"`
ExportID string `json:"exportId"`
Protocol string `json:"protocol"`
DisplayName string `json:"displayName"`
MountURL string `json:"mountUrl"`
Readonly bool `json:"readonly"`
CredentialMode string `json:"credentialMode"`
ID string `json:"id"`
ExportID string `json:"exportId"`
Protocol string `json:"protocol"`
DisplayName string `json:"displayName"`
MountURL string `json:"mountUrl"`
Readonly bool `json:"readonly"`
Credential mountCredential `json:"credential"`
}
type mountCredential struct {
Mode string `json:"mode"`
Username string `json:"username"`
Password string `json:"password"`
ExpiresAt string `json:"expiresAt"`
}
type cloudProfile struct {
@ -115,12 +132,15 @@ type storageExportInput struct {
}
type nodeRegistrationRequest struct {
MachineID string `json:"machineId"`
DisplayName string `json:"displayName"`
AgentVersion string `json:"agentVersion"`
DirectAddress *string `json:"directAddress"`
RelayAddress *string `json:"relayAddress"`
Exports []storageExportInput `json:"exports"`
MachineID string `json:"machineId"`
DisplayName string `json:"displayName"`
AgentVersion string `json:"agentVersion"`
DirectAddress *string `json:"directAddress"`
RelayAddress *string `json:"relayAddress"`
}
type nodeExportsRequest struct {
Exports []storageExportInput `json:"exports"`
}
type nodeHeartbeatRequest struct {
@ -130,8 +150,6 @@ type nodeHeartbeatRequest struct {
}
type mountProfileRequest struct {
UserID string `json:"userId"`
DeviceID string `json:"deviceId"`
ExportID string `json:"exportId"`
}

View file

@ -34,6 +34,16 @@ func newAppFromEnv(startedAt time.Time) (*app, error) {
return nil, err
}
davAuthSecret, err := requiredEnv("BETTERNAS_DAV_AUTH_SECRET")
if err != nil {
return nil, err
}
davCredentialTTL, err := parseRequiredDurationEnv("BETTERNAS_DAV_CREDENTIAL_TTL")
if err != nil {
return nil, err
}
return newApp(
appConfig{
version: env("BETTERNAS_VERSION", "0.1.0-dev"),
@ -41,6 +51,8 @@ func newAppFromEnv(startedAt time.Time) (*app, error) {
statePath: env("BETTERNAS_CONTROL_PLANE_STATE_PATH", ".state/control-plane/state.json"),
clientToken: clientToken,
nodeBootstrapToken: nodeBootstrapToken,
davAuthSecret: davAuthSecret,
davCredentialTTL: davCredentialTTL,
},
startedAt,
)

View file

@ -0,0 +1,71 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"time"
)
const mountCredentialModeBasicAuth = "basic-auth"
type signedMountCredentialClaims struct {
Version int `json:"v"`
NodeID string `json:"nodeId"`
MountPath string `json:"mountPath"`
Username string `json:"username"`
Readonly bool `json:"readonly"`
ExpiresAt string `json:"expiresAt"`
}
func issueMountCredential(secret string, nodeID string, mountPath string, readonly bool, issuedAt time.Time, ttl time.Duration) (string, mountCredential, error) {
credentialID, err := newOpaqueToken()
if err != nil {
return "", mountCredential{}, err
}
usernameToken, err := newOpaqueToken()
if err != nil {
return "", mountCredential{}, err
}
claims := signedMountCredentialClaims{
Version: 1,
NodeID: nodeID,
MountPath: mountPath,
Username: "mount-" + usernameToken,
Readonly: readonly,
ExpiresAt: issuedAt.UTC().Add(ttl).Format(time.RFC3339),
}
password, err := signMountCredentialClaims(secret, claims)
if err != nil {
return "", mountCredential{}, err
}
return "mount-" + credentialID, mountCredential{
Mode: mountCredentialModeBasicAuth,
Username: claims.Username,
Password: password,
ExpiresAt: claims.ExpiresAt,
}, nil
}
func signMountCredentialClaims(secret string, claims signedMountCredentialClaims) (string, error) {
payload, err := json.Marshal(claims)
if err != nil {
return "", fmt.Errorf("encode mount credential claims: %w", err)
}
encodedPayload := base64.RawURLEncoding.EncodeToString(payload)
signature := signMountCredentialPayload(secret, encodedPayload)
return encodedPayload + "." + signature, nil
}
func signMountCredentialPayload(secret string, encodedPayload string) string {
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(encodedPayload))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}

View file

@ -31,6 +31,11 @@ var (
nodeAgentBinaryErr error
)
const (
runtimeDAVAuthSecret = "runtime-dav-auth-secret"
runtimeDAVCredentialTTL = "1h"
)
func TestControlPlaneBinaryMountLoopIntegration(t *testing.T) {
exportDir := t.TempDir()
writeExportFile(t, exportDir, "README.txt", "betterNAS export\n")
@ -38,166 +43,42 @@ func TestControlPlaneBinaryMountLoopIntegration(t *testing.T) {
nextcloud := httptest.NewServer(http.NotFoundHandler())
defer nextcloud.Close()
nodeAgent := startNodeAgentBinary(t, exportDir)
controlPlane := startControlPlaneBinary(t, "runtime-test-version", nextcloud.URL)
nodeAgent := startNodeAgentBinaryWithExports(t, controlPlane.baseURL, []string{exportDir}, "machine-runtime-1")
client := &http.Client{Timeout: 2 * time.Second}
directAddress := nodeAgent.baseURL
registration := registerNode(t, client, controlPlane.baseURL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{
MachineID: "machine-runtime-1",
DisplayName: "Runtime NAS",
AgentVersion: "1.2.3",
DirectAddress: &directAddress,
RelayAddress: nil,
Exports: []storageExportInput{{
Label: "Photos",
Path: exportDir,
MountPath: defaultWebDAVPath,
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{"runtime"},
}},
})
if registration.Node.ID != "dev-node" {
t.Fatalf("expected node ID %q, got %q", "dev-node", registration.Node.ID)
exports := waitForExportsByPath(t, client, controlPlane.baseURL+"/api/v1/exports", []string{exportDir})
export := exports[exportDir]
if export.ID != "dev-export" {
t.Fatalf("expected export ID %q, got %q", "dev-export", export.ID)
}
if registration.NodeToken == "" {
t.Fatal("expected runtime registration to return a node token")
}
exports := getJSONAuth[[]storageExport](t, client, testClientToken, controlPlane.baseURL+"/api/v1/exports")
if len(exports) != 1 {
t.Fatalf("expected 1 export, got %d", len(exports))
}
if exports[0].ID != "dev-export" {
t.Fatalf("expected export ID %q, got %q", "dev-export", exports[0].ID)
}
if exports[0].Path != exportDir {
t.Fatalf("expected exported path %q, got %q", exportDir, exports[0].Path)
}
if exports[0].MountPath != defaultWebDAVPath {
t.Fatalf("expected mountPath %q, got %q", defaultWebDAVPath, exports[0].MountPath)
if export.MountPath != defaultWebDAVPath {
t.Fatalf("expected mountPath %q, got %q", defaultWebDAVPath, export.MountPath)
}
mount := postJSONAuth[mountProfile](t, client, testClientToken, controlPlane.baseURL+"/api/v1/mount-profiles/issue", mountProfileRequest{
UserID: "runtime-user",
DeviceID: "runtime-device",
ExportID: exports[0].ID,
ExportID: export.ID,
})
if mount.MountURL != nodeAgent.baseURL+defaultWebDAVPath {
t.Fatalf("expected runtime mount URL %q, got %q", nodeAgent.baseURL+defaultWebDAVPath, mount.MountURL)
}
if mount.Credential.Mode != mountCredentialModeBasicAuth {
t.Fatalf("expected mount credential mode %q, got %q", mountCredentialModeBasicAuth, mount.Credential.Mode)
}
assertHTTPStatus(t, client, "PROPFIND", mount.MountURL, http.StatusMultiStatus)
assertMountedFileContents(t, client, mount.MountURL+"README.txt", "betterNAS export\n")
assertHTTPStatusWithBasicAuth(t, client, "PROPFIND", mount.MountURL, mount.Credential.Username, mount.Credential.Password, http.StatusMultiStatus)
assertMountedFileContentsWithBasicAuth(t, client, mount.MountURL+"README.txt", mount.Credential.Username, mount.Credential.Password, "betterNAS export\n")
cloud := postJSONAuth[cloudProfile](t, client, testClientToken, controlPlane.baseURL+"/api/v1/cloud-profiles/issue", cloudProfileRequest{
UserID: "runtime-user",
ExportID: exports[0].ID,
ExportID: export.ID,
Provider: "nextcloud",
})
if cloud.BaseURL != nextcloud.URL {
t.Fatalf("expected runtime cloud baseUrl %q, got %q", nextcloud.URL, cloud.BaseURL)
}
expectedCloudPath := cloudProfilePathForExport(exports[0].ID)
if cloud.Path != expectedCloudPath {
t.Fatalf("expected runtime cloud path %q, got %q", expectedCloudPath, cloud.Path)
}
postJSONAuthStatus(t, client, registration.NodeToken, controlPlane.baseURL+"/api/v1/nodes/"+registration.Node.ID+"/heartbeat", nodeHeartbeatRequest{
NodeID: registration.Node.ID,
Status: "online",
LastSeenAt: "2025-01-02T03:04:05Z",
}, http.StatusNoContent)
}
func TestControlPlaneBinaryReRegistrationReconcilesExports(t *testing.T) {
nextcloud := httptest.NewServer(http.NotFoundHandler())
defer nextcloud.Close()
controlPlane := startControlPlaneBinary(t, "runtime-test-version", nextcloud.URL)
client := &http.Client{Timeout: 2 * time.Second}
directAddress := "http://nas.local:8090"
firstRegistration := registerNode(t, client, controlPlane.baseURL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{
MachineID: "machine-runtime-2",
DisplayName: "Runtime NAS",
AgentVersion: "1.2.3",
DirectAddress: &directAddress,
RelayAddress: nil,
Exports: []storageExportInput{
{
Label: "Docs",
Path: "/srv/docs",
MountPath: "/dav/exports/docs/",
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{"runtime"},
},
{
Label: "Media",
Path: "/srv/media",
MountPath: "/dav/exports/media/",
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{"runtime"},
},
},
})
initialExports := exportsByPath(getJSONAuth[[]storageExport](t, client, testClientToken, controlPlane.baseURL+"/api/v1/exports"))
docsExport := initialExports["/srv/docs"]
if _, ok := initialExports["/srv/media"]; !ok {
t.Fatal("expected media export to be registered")
}
secondRegistration := registerNode(t, client, controlPlane.baseURL+"/api/v1/nodes/register", firstRegistration.NodeToken, nodeRegistrationRequest{
MachineID: "machine-runtime-2",
DisplayName: "Runtime NAS Updated",
AgentVersion: "1.2.4",
DirectAddress: &directAddress,
RelayAddress: nil,
Exports: []storageExportInput{
{
Label: "Docs v2",
Path: "/srv/docs",
MountPath: "/dav/exports/docs-v2/",
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{"runtime", "updated"},
},
{
Label: "Backups",
Path: "/srv/backups",
MountPath: "/dav/exports/backups/",
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{"runtime"},
},
},
})
if secondRegistration.Node.ID != firstRegistration.Node.ID {
t.Fatalf("expected node ID %q after re-registration, got %q", firstRegistration.Node.ID, secondRegistration.Node.ID)
}
updatedExports := exportsByPath(getJSONAuth[[]storageExport](t, client, testClientToken, controlPlane.baseURL+"/api/v1/exports"))
if len(updatedExports) != 2 {
t.Fatalf("expected 2 exports after re-registration, got %d", len(updatedExports))
}
if updatedExports["/srv/docs"].ID != docsExport.ID {
t.Fatalf("expected docs export to keep ID %q, got %q", docsExport.ID, updatedExports["/srv/docs"].ID)
}
if updatedExports["/srv/docs"].Label != "Docs v2" {
t.Fatalf("expected docs export label to update, got %q", updatedExports["/srv/docs"].Label)
}
if updatedExports["/srv/docs"].MountPath != "/dav/exports/docs-v2/" {
t.Fatalf("expected docs export mountPath to update, got %q", updatedExports["/srv/docs"].MountPath)
}
if _, ok := updatedExports["/srv/media"]; ok {
t.Fatal("expected stale media export to be removed")
}
if _, ok := updatedExports["/srv/backups"]; !ok {
t.Fatal("expected backups export to be present")
if cloud.Path != cloudProfilePathForExport(export.ID) {
t.Fatalf("expected runtime cloud path %q, got %q", cloudProfilePathForExport(export.ID), cloud.Path)
}
}
@ -210,53 +91,18 @@ func TestControlPlaneBinaryMultiExportProfilesStayDistinct(t *testing.T) {
nextcloud := httptest.NewServer(http.NotFoundHandler())
defer nextcloud.Close()
nodeAgent := startNodeAgentBinaryWithExports(t, []string{firstExportDir, secondExportDir})
controlPlane := startControlPlaneBinary(t, "runtime-test-version", nextcloud.URL)
nodeAgent := startNodeAgentBinaryWithExports(t, controlPlane.baseURL, []string{firstExportDir, secondExportDir}, "machine-runtime-multi")
client := &http.Client{Timeout: 2 * time.Second}
firstMountPath := nodeAgentMountPathForExport(firstExportDir, 2)
secondMountPath := nodeAgentMountPathForExport(secondExportDir, 2)
directAddress := nodeAgent.baseURL
registerNode(t, client, controlPlane.baseURL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{
MachineID: "machine-runtime-multi",
DisplayName: "Runtime Multi NAS",
AgentVersion: "1.2.3",
DirectAddress: &directAddress,
RelayAddress: nil,
Exports: []storageExportInput{
{
Label: "Docs",
Path: firstExportDir,
MountPath: firstMountPath,
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{"runtime"},
},
{
Label: "Media",
Path: secondExportDir,
MountPath: secondMountPath,
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{"runtime"},
},
},
})
exports := exportsByPath(getJSONAuth[[]storageExport](t, client, testClientToken, controlPlane.baseURL+"/api/v1/exports"))
exports := waitForExportsByPath(t, client, controlPlane.baseURL+"/api/v1/exports", []string{firstExportDir, secondExportDir})
firstExport := exports[firstExportDir]
secondExport := exports[secondExportDir]
firstMount := postJSONAuth[mountProfile](t, client, testClientToken, controlPlane.baseURL+"/api/v1/mount-profiles/issue", mountProfileRequest{
UserID: "runtime-user",
DeviceID: "runtime-device",
ExportID: firstExport.ID,
})
secondMount := postJSONAuth[mountProfile](t, client, testClientToken, controlPlane.baseURL+"/api/v1/mount-profiles/issue", mountProfileRequest{
UserID: "runtime-user",
DeviceID: "runtime-device",
ExportID: secondExport.ID,
})
firstMount := postJSONAuth[mountProfile](t, client, testClientToken, controlPlane.baseURL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: firstExport.ID})
secondMount := postJSONAuth[mountProfile](t, client, testClientToken, controlPlane.baseURL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: secondExport.ID})
if firstMount.MountURL == secondMount.MountURL {
t.Fatalf("expected distinct runtime mount URLs, got %q", firstMount.MountURL)
}
@ -267,10 +113,10 @@ func TestControlPlaneBinaryMultiExportProfilesStayDistinct(t *testing.T) {
t.Fatalf("expected second runtime mount URL %q, got %q", nodeAgent.baseURL+secondMountPath, secondMount.MountURL)
}
assertHTTPStatus(t, client, "PROPFIND", firstMount.MountURL, http.StatusMultiStatus)
assertHTTPStatus(t, client, "PROPFIND", secondMount.MountURL, http.StatusMultiStatus)
assertMountedFileContents(t, client, firstMount.MountURL+"README.txt", "first runtime export\n")
assertMountedFileContents(t, client, secondMount.MountURL+"README.txt", "second runtime export\n")
assertHTTPStatusWithBasicAuth(t, client, "PROPFIND", firstMount.MountURL, firstMount.Credential.Username, firstMount.Credential.Password, http.StatusMultiStatus)
assertHTTPStatusWithBasicAuth(t, client, "PROPFIND", secondMount.MountURL, secondMount.Credential.Username, secondMount.Credential.Password, http.StatusMultiStatus)
assertMountedFileContentsWithBasicAuth(t, client, firstMount.MountURL+"README.txt", firstMount.Credential.Username, firstMount.Credential.Password, "first runtime export\n")
assertMountedFileContentsWithBasicAuth(t, client, secondMount.MountURL+"README.txt", secondMount.Credential.Username, secondMount.Credential.Password, "second runtime export\n")
firstCloud := postJSONAuth[cloudProfile](t, client, testClientToken, controlPlane.baseURL+"/api/v1/cloud-profiles/issue", cloudProfileRequest{
UserID: "runtime-user",
@ -319,6 +165,8 @@ func startControlPlaneBinary(t *testing.T, version string, nextcloudBaseURL stri
"BETTERNAS_CONTROL_PLANE_STATE_PATH="+statePath,
"BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN="+testClientToken,
"BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN="+testNodeBootstrapToken,
"BETTERNAS_DAV_AUTH_SECRET="+runtimeDAVAuthSecret,
"BETTERNAS_DAV_CREDENTIAL_TTL="+runtimeDAVCredentialTTL,
)
cmd.Stdout = logFile
cmd.Stderr = logFile
@ -343,15 +191,13 @@ func startControlPlaneBinary(t *testing.T, version string, nextcloudBaseURL stri
}
}
func startNodeAgentBinary(t *testing.T, exportPath string) runningBinary {
return startNodeAgentBinaryWithExports(t, []string{exportPath})
}
func startNodeAgentBinaryWithExports(t *testing.T, exportPaths []string) runningBinary {
func startNodeAgentBinaryWithExports(t *testing.T, controlPlaneBaseURL string, exportPaths []string, machineID string) runningBinary {
t.Helper()
port := reserveTCPPort(t)
baseURL := fmt.Sprintf("http://127.0.0.1:%s", port)
logPath := filepath.Join(t.TempDir(), "node-agent.log")
nodeTokenPath := filepath.Join(t.TempDir(), "node-token")
logFile, err := os.Create(logPath)
if err != nil {
t.Fatalf("create node-agent log file: %v", err)
@ -368,6 +214,14 @@ func startNodeAgentBinaryWithExports(t *testing.T, exportPaths []string) running
os.Environ(),
"PORT="+port,
"BETTERNAS_EXPORT_PATHS_JSON="+string(rawExportPaths),
"BETTERNAS_CONTROL_PLANE_URL="+controlPlaneBaseURL,
"BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN="+testNodeBootstrapToken,
"BETTERNAS_NODE_TOKEN_PATH="+nodeTokenPath,
"BETTERNAS_NODE_MACHINE_ID="+machineID,
"BETTERNAS_NODE_DISPLAY_NAME="+machineID,
"BETTERNAS_NODE_DIRECT_ADDRESS="+baseURL,
"BETTERNAS_DAV_AUTH_SECRET="+runtimeDAVAuthSecret,
"BETTERNAS_VERSION=runtime-test-version",
)
cmd.Stdout = logFile
cmd.Stderr = logFile
@ -382,7 +236,6 @@ func startNodeAgentBinaryWithExports(t *testing.T, exportPaths []string) running
waitDone <- cmd.Wait()
}()
baseURL := fmt.Sprintf("http://127.0.0.1:%s", port)
waitForHTTPStatus(t, baseURL+"/health", waitDone, logPath, http.StatusOK)
registerProcessCleanup(t, ctx, cancel, cmd, waitDone, logFile, logPath, "node-agent")
@ -392,6 +245,30 @@ func startNodeAgentBinaryWithExports(t *testing.T, exportPaths []string) running
}
}
func waitForExportsByPath(t *testing.T, client *http.Client, endpoint string, expectedPaths []string) map[string]storageExport {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
exports := getJSONAuth[[]storageExport](t, client, testClientToken, endpoint)
exportsByPath := exportsByPath(exports)
allPresent := true
for _, expectedPath := range expectedPaths {
if _, ok := exportsByPath[expectedPath]; !ok {
allPresent = false
break
}
}
if allPresent {
return exportsByPath
}
time.Sleep(100 * time.Millisecond)
}
t.Fatalf("exports for %v did not appear in time", expectedPaths)
return nil
}
func buildControlPlaneBinary(t *testing.T) string {
t.Helper()
@ -411,6 +288,7 @@ func buildControlPlaneBinary(t *testing.T) string {
controlPlaneBinaryPath = filepath.Join(tempDir, "control-plane")
cmd := exec.Command("go", "build", "-o", controlPlaneBinaryPath, ".")
cmd.Dir = filepath.Dir(filename)
cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
output, err := cmd.CombinedOutput()
if err != nil {
controlPlaneBinaryErr = fmt.Errorf("build control-plane binary: %w\n%s", err, output)
@ -443,6 +321,7 @@ func buildNodeAgentBinary(t *testing.T) string {
nodeAgentBinaryPath = filepath.Join(tempDir, "node-agent")
cmd := exec.Command("go", "build", "-o", nodeAgentBinaryPath, "./cmd/node-agent")
cmd.Dir = filepath.Clean(filepath.Join(filepath.Dir(filename), "../../../node-agent"))
cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
output, err := cmd.CombinedOutput()
if err != nil {
nodeAgentBinaryErr = fmt.Errorf("build node-agent binary: %w\n%s", err, output)
@ -532,10 +411,16 @@ func registerProcessCleanup(t *testing.T, ctx context.Context, cancel context.Ca
})
}
func assertMountedFileContents(t *testing.T, client *http.Client, endpoint string, expected string) {
func assertMountedFileContentsWithBasicAuth(t *testing.T, client *http.Client, endpoint string, username string, password string, expected string) {
t.Helper()
response, err := client.Get(endpoint)
request, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
t.Fatalf("build GET request for %s: %v", endpoint, err)
}
request.SetBasicAuth(username, password)
response, err := client.Do(request)
if err != nil {
t.Fatalf("get %s: %v", endpoint, err)
}
@ -554,13 +439,14 @@ func assertMountedFileContents(t *testing.T, client *http.Client, endpoint strin
}
}
func assertHTTPStatus(t *testing.T, client *http.Client, method string, endpoint string, expectedStatus int) {
func assertHTTPStatusWithBasicAuth(t *testing.T, client *http.Client, method string, endpoint string, username string, password string, expectedStatus int) {
t.Helper()
request, err := http.NewRequest(method, endpoint, nil)
if err != nil {
t.Fatalf("build %s request for %s: %v", method, endpoint, err)
}
request.SetBasicAuth(username, password)
response, err := client.Do(request)
if err != nil {

View file

@ -35,6 +35,7 @@ func (a *app) handler() http.Handler {
mux.HandleFunc("GET /version", a.handleVersion)
mux.HandleFunc("POST /api/v1/nodes/register", a.handleNodeRegister)
mux.HandleFunc("POST /api/v1/nodes/{nodeId}/heartbeat", a.handleNodeHeartbeat)
mux.HandleFunc("PUT /api/v1/nodes/{nodeId}/exports", a.handleNodeExports)
mux.HandleFunc("GET /api/v1/exports", a.handleExportsList)
mux.HandleFunc("POST /api/v1/mount-profiles/issue", a.handleMountProfileIssue)
mux.HandleFunc("POST /api/v1/cloud-profiles/issue", a.handleCloudProfileIssue)
@ -127,6 +128,37 @@ func (a *app) handleNodeHeartbeat(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
func (a *app) handleNodeExports(w http.ResponseWriter, r *http.Request) {
nodeID := r.PathValue("nodeId")
request, err := decodeNodeExportsRequest(w, r)
if err != nil {
writeDecodeError(w, err)
return
}
if err := validateNodeExportsRequest(request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if !a.authorizeNode(w, r, nodeID) {
return
}
exports, err := a.store.upsertExports(nodeID, request)
if err != nil {
statusCode := http.StatusInternalServerError
if errors.Is(err, errNodeNotFound) {
statusCode = http.StatusNotFound
}
http.Error(w, err.Error(), statusCode)
return
}
writeJSON(w, http.StatusOK, exports)
}
func (a *app) handleExportsList(w http.ResponseWriter, r *http.Request) {
if !a.requireClientAuth(w, r) {
return
@ -163,14 +195,27 @@ func (a *app) handleMountProfileIssue(w http.ResponseWriter, r *http.Request) {
return
}
credentialID, credential, err := issueMountCredential(
a.config.davAuthSecret,
context.node.ID,
mountProfilePathForExport(context.export.MountPath),
false,
a.now(),
a.config.davCredentialTTL,
)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, mountProfile{
ID: fmt.Sprintf("mount-%s-%s", request.DeviceID, context.export.ID),
ExportID: context.export.ID,
Protocol: "webdav",
DisplayName: context.export.Label,
MountURL: mountURL,
Readonly: false,
CredentialMode: "session-token",
ID: credentialID,
ExportID: context.export.ID,
Protocol: "webdav",
DisplayName: context.export.Label,
MountURL: mountURL,
Readonly: false,
Credential: credential,
})
}
@ -226,7 +271,6 @@ func decodeNodeRegistrationRequest(w http.ResponseWriter, r *http.Request) (node
"agentVersion",
"directAddress",
"relayAddress",
"exports",
); err != nil {
return nodeRegistrationRequest{}, err
}
@ -258,9 +302,22 @@ func decodeNodeRegistrationRequest(w http.ResponseWriter, r *http.Request) (node
return nodeRegistrationRequest{}, err
}
return request, nil
}
func decodeNodeExportsRequest(w http.ResponseWriter, r *http.Request) (nodeExportsRequest, error) {
object, err := decodeRawObjectRequest(w, r)
if err != nil {
return nodeExportsRequest{}, err
}
if err := object.validateRequiredKeys("exports"); err != nil {
return nodeExportsRequest{}, err
}
request := nodeExportsRequest{}
request.Exports, err = object.storageExportInputsField("exports")
if err != nil {
return nodeRegistrationRequest{}, err
return nodeExportsRequest{}, err
}
return request, nil
@ -495,10 +552,18 @@ func validateNodeRegistrationRequest(request *nodeRegistrationRequest) error {
return err
}
seenPaths := make(map[string]struct{}, len(request.Exports))
seenMountPaths := make(map[string]struct{}, len(request.Exports))
for index := range request.Exports {
export := &request.Exports[index]
return nil
}
func validateNodeExportsRequest(request nodeExportsRequest) error {
return validateStorageExportInputs(request.Exports)
}
func validateStorageExportInputs(exports []storageExportInput) error {
seenPaths := make(map[string]struct{}, len(exports))
seenMountPaths := make(map[string]struct{}, len(exports))
for index := range exports {
export := &exports[index]
export.Label = strings.TrimSpace(export.Label)
if export.Label == "" {
return fmt.Errorf("exports[%d].label is required", index)
@ -514,7 +579,7 @@ func validateNodeRegistrationRequest(request *nodeRegistrationRequest) error {
seenPaths[export.Path] = struct{}{}
export.MountPath = strings.TrimSpace(export.MountPath)
if len(request.Exports) > 1 && export.MountPath == "" {
if len(exports) > 1 && export.MountPath == "" {
return fmt.Errorf("exports[%d].mountPath is required when registering multiple exports", index)
}
if export.MountPath != "" {
@ -567,12 +632,6 @@ func validateNodeHeartbeatRequest(nodeID string, request nodeHeartbeatRequest) e
}
func validateMountProfileRequest(request mountProfileRequest) error {
if strings.TrimSpace(request.UserID) == "" {
return errors.New("userId is required")
}
if strings.TrimSpace(request.DeviceID) == "" {
return errors.New("deviceId is required")
}
if strings.TrimSpace(request.ExportID) == "" {
return errors.New("exportId is required")
}
@ -773,6 +832,23 @@ func requiredEnv(key string) (string, error) {
return value, nil
}
func parseRequiredDurationEnv(key string) (time.Duration, error) {
value, err := requiredEnv(key)
if err != nil {
return 0, err
}
duration, err := time.ParseDuration(value)
if err != nil {
return 0, fmt.Errorf("%s must be a valid duration: %w", key, err)
}
if duration <= 0 {
return 0, fmt.Errorf("%s must be greater than 0", key)
}
return duration, nil
}
func decodeJSON(w http.ResponseWriter, r *http.Request, destination any) error {
defer r.Body.Close()

View file

@ -92,16 +92,23 @@ func TestControlPlaneRegistrationProfilesAndHeartbeat(t *testing.T) {
AgentVersion: "1.2.3",
DirectAddress: &directAddress,
RelayAddress: &relayAddress,
})
if registration.NodeToken == "" {
t.Fatal("expected node registration to return a node token")
}
syncedExports := syncNodeExports(t, server.Client(), registration.NodeToken, server.URL+"/api/v1/nodes/"+registration.Node.ID+"/exports", nodeExportsRequest{
Exports: []storageExportInput{{
Label: "Photos",
Path: "/srv/photos",
MountPath: "/dav/",
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{"family"},
}},
})
if registration.NodeToken == "" {
t.Fatal("expected node registration to return a node token")
if len(syncedExports) != 1 {
t.Fatalf("expected sync to return 1 export, got %d", len(syncedExports))
}
node := registration.Node
@ -137,13 +144,11 @@ func TestControlPlaneRegistrationProfilesAndHeartbeat(t *testing.T) {
if exports[0].Path != "/srv/photos" {
t.Fatalf("expected export path %q, got %q", "/srv/photos", exports[0].Path)
}
if exports[0].MountPath != "" {
t.Fatalf("expected empty mountPath for default export, got %q", exports[0].MountPath)
if exports[0].MountPath != "/dav/" {
t.Fatalf("expected mountPath %q, got %q", "/dav/", exports[0].MountPath)
}
mount := postJSONAuth[mountProfile](t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{
UserID: "user-1",
DeviceID: "device-1",
ExportID: exports[0].ID,
})
if mount.ExportID != exports[0].ID {
@ -161,8 +166,17 @@ func TestControlPlaneRegistrationProfilesAndHeartbeat(t *testing.T) {
if mount.Readonly {
t.Fatal("expected mount profile to be read-write")
}
if mount.CredentialMode != "session-token" {
t.Fatalf("expected credentialMode session-token, got %q", mount.CredentialMode)
if mount.Credential.Mode != mountCredentialModeBasicAuth {
t.Fatalf("expected credential mode %q, got %q", mountCredentialModeBasicAuth, mount.Credential.Mode)
}
if mount.Credential.Username == "" {
t.Fatal("expected mount credential username to be set")
}
if mount.Credential.Password == "" {
t.Fatal("expected mount credential password to be set")
}
if mount.Credential.ExpiresAt == "" {
t.Fatal("expected mount credential expiry to be set")
}
cloud := postJSONAuth[cloudProfile](t, server.Client(), testClientToken, server.URL+"/api/v1/cloud-profiles/issue", cloudProfileRequest{
@ -202,7 +216,7 @@ func TestControlPlaneRegistrationProfilesAndHeartbeat(t *testing.T) {
}
}
func TestControlPlaneReRegistrationReconcilesExportsAndKeepsStableIDs(t *testing.T) {
func TestControlPlaneExportSyncReconcilesExportsAndKeepsStableIDs(t *testing.T) {
t.Parallel()
app, server := newTestControlPlaneServer(t, appConfig{version: "test-version"})
@ -215,6 +229,30 @@ func TestControlPlaneReRegistrationReconcilesExportsAndKeepsStableIDs(t *testing
AgentVersion: "1.2.3",
DirectAddress: &directAddress,
RelayAddress: nil,
})
putJSONAuthStatus(t, server.Client(), testNodeBootstrapToken, server.URL+"/api/v1/nodes/"+firstRegistration.Node.ID+"/exports", nodeExportsRequest{
Exports: []storageExportInput{
{
Label: "Docs",
Path: "/srv/docs",
MountPath: "/dav/exports/docs/",
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{"work"},
},
{
Label: "Media",
Path: "/srv/media",
MountPath: "/dav/exports/media/",
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{"personal"},
},
},
}, http.StatusUnauthorized)
syncNodeExports(t, server.Client(), firstRegistration.NodeToken, server.URL+"/api/v1/nodes/"+firstRegistration.Node.ID+"/exports", nodeExportsRequest{
Exports: []storageExportInput{
{
Label: "Docs",
@ -235,22 +273,6 @@ func TestControlPlaneReRegistrationReconcilesExportsAndKeepsStableIDs(t *testing
},
})
postJSONAuthStatus(t, server.Client(), testNodeBootstrapToken, server.URL+"/api/v1/nodes/register", nodeRegistrationRequest{
MachineID: "machine-1",
DisplayName: "Unauthorized Re-register",
AgentVersion: "1.2.3",
DirectAddress: &directAddress,
RelayAddress: nil,
Exports: []storageExportInput{{
Label: "Docs",
Path: "/srv/docs",
MountPath: "/dav/exports/docs/",
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{"work"},
}},
}, http.StatusUnauthorized)
initialExports := exportsByPath(getJSONAuth[[]storageExport](t, server.Client(), testClientToken, server.URL+"/api/v1/exports"))
docsExport := initialExports["/srv/docs"]
mediaExport := initialExports["/srv/media"]
@ -261,6 +283,30 @@ func TestControlPlaneReRegistrationReconcilesExportsAndKeepsStableIDs(t *testing
AgentVersion: "1.2.4",
DirectAddress: &directAddress,
RelayAddress: nil,
})
putJSONAuthStatus(t, server.Client(), testClientToken, server.URL+"/api/v1/nodes/"+firstRegistration.Node.ID+"/exports", nodeExportsRequest{
Exports: []storageExportInput{
{
Label: "Docs v2",
Path: "/srv/docs",
MountPath: "/dav/exports/docs-v2/",
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{"work", "updated"},
},
{
Label: "Backups",
Path: "/srv/backups",
MountPath: "/dav/exports/backups/",
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{"system"},
},
},
}, http.StatusUnauthorized)
syncNodeExports(t, server.Client(), firstRegistration.NodeToken, server.URL+"/api/v1/nodes/"+firstRegistration.Node.ID+"/exports", nodeExportsRequest{
Exports: []storageExportInput{
{
Label: "Docs v2",
@ -330,12 +376,14 @@ func TestControlPlaneProfilesRemainExportSpecificForConfiguredMountPaths(t *test
defer server.Close()
directAddress := "http://nas.local:8090"
registerNode(t, server.Client(), server.URL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{
registration := registerNode(t, server.Client(), server.URL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{
MachineID: "machine-multi",
DisplayName: "Multi Export NAS",
AgentVersion: "1.2.3",
DirectAddress: &directAddress,
RelayAddress: nil,
})
syncNodeExports(t, server.Client(), registration.NodeToken, server.URL+"/api/v1/nodes/"+registration.Node.ID+"/exports", nodeExportsRequest{
Exports: []storageExportInput{
{
Label: "Docs",
@ -360,16 +408,8 @@ func TestControlPlaneProfilesRemainExportSpecificForConfiguredMountPaths(t *test
docsExport := exports["/srv/docs"]
mediaExport := exports["/srv/media"]
docsMount := postJSONAuth[mountProfile](t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{
UserID: "user-1",
DeviceID: "device-1",
ExportID: docsExport.ID,
})
mediaMount := postJSONAuth[mountProfile](t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{
UserID: "user-1",
DeviceID: "device-1",
ExportID: mediaExport.ID,
})
docsMount := postJSONAuth[mountProfile](t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: docsExport.ID})
mediaMount := postJSONAuth[mountProfile](t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: mediaExport.ID})
if docsMount.MountURL == mediaMount.MountURL {
t.Fatalf("expected distinct mount URLs for configured export paths, got %q", docsMount.MountURL)
}
@ -408,12 +448,14 @@ func TestControlPlaneMountProfilesUseRelayAndPreserveBasePath(t *testing.T) {
defer server.Close()
relayAddress := "https://nas.example.test/control"
registerNode(t, server.Client(), server.URL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{
registration := registerNode(t, server.Client(), server.URL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{
MachineID: "machine-relay",
DisplayName: "Relay NAS",
AgentVersion: "1.2.3",
DirectAddress: nil,
RelayAddress: &relayAddress,
})
syncNodeExports(t, server.Client(), registration.NodeToken, server.URL+"/api/v1/nodes/"+registration.Node.ID+"/exports", nodeExportsRequest{
Exports: []storageExportInput{{
Label: "Relay",
Path: "/srv/relay",
@ -424,35 +466,30 @@ func TestControlPlaneMountProfilesUseRelayAndPreserveBasePath(t *testing.T) {
}},
})
mount := postJSONAuth[mountProfile](t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{
UserID: "user-1",
DeviceID: "device-1",
ExportID: "dev-export",
})
mount := postJSONAuth[mountProfile](t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: "dev-export"})
if mount.MountURL != "https://nas.example.test/control/dav/relay/" {
t.Fatalf("expected relay mount URL %q, got %q", "https://nas.example.test/control/dav/relay/", mount.MountURL)
}
registerNode(t, server.Client(), server.URL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{
registration = registerNode(t, server.Client(), server.URL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{
MachineID: "machine-no-target",
DisplayName: "No Target NAS",
AgentVersion: "1.2.3",
DirectAddress: nil,
RelayAddress: nil,
})
syncNodeExports(t, server.Client(), registration.NodeToken, server.URL+"/api/v1/nodes/"+registration.Node.ID+"/exports", nodeExportsRequest{
Exports: []storageExportInput{{
Label: "Offline",
Path: "/srv/offline",
MountPath: "/dav/",
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{},
}},
})
postJSONAuthStatus(t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{
UserID: "user-1",
DeviceID: "device-2",
ExportID: "dev-export-2",
}, http.StatusServiceUnavailable)
postJSONAuthStatus(t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: "dev-export-2"}, http.StatusServiceUnavailable)
}
func TestControlPlaneCloudProfilesRequireConfiguredBaseURLAndExistingExport(t *testing.T) {
@ -462,15 +499,18 @@ func TestControlPlaneCloudProfilesRequireConfiguredBaseURLAndExistingExport(t *t
defer server.Close()
directAddress := "http://nas.local:8090"
registerNode(t, server.Client(), server.URL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{
registration := registerNode(t, server.Client(), server.URL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{
MachineID: "machine-cloud",
DisplayName: "Cloud NAS",
AgentVersion: "1.2.3",
DirectAddress: &directAddress,
RelayAddress: nil,
})
syncNodeExports(t, server.Client(), registration.NodeToken, server.URL+"/api/v1/nodes/"+registration.Node.ID+"/exports", nodeExportsRequest{
Exports: []storageExportInput{{
Label: "Photos",
Path: "/srv/photos",
MountPath: "/dav/",
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{},
@ -512,6 +552,8 @@ func TestControlPlanePersistsRegistryAcrossAppRestart(t *testing.T) {
AgentVersion: "1.2.3",
DirectAddress: &directAddress,
RelayAddress: nil,
})
syncNodeExports(t, firstServer.Client(), registration.NodeToken, firstServer.URL+"/api/v1/nodes/"+registration.Node.ID+"/exports", nodeExportsRequest{
Exports: []storageExportInput{{
Label: "Docs",
Path: "/srv/docs",
@ -540,11 +582,7 @@ func TestControlPlanePersistsRegistryAcrossAppRestart(t *testing.T) {
t.Fatalf("expected persisted mountPath %q, got %q", "/dav/persisted/", exports[0].MountPath)
}
mount := postJSONAuth[mountProfile](t, secondServer.Client(), testClientToken, secondServer.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{
UserID: "user-1",
DeviceID: "device-1",
ExportID: exports[0].ID,
})
mount := postJSONAuth[mountProfile](t, secondServer.Client(), testClientToken, secondServer.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: exports[0].ID})
if mount.MountURL != "http://nas.local:8090/dav/persisted/" {
t.Fatalf("expected persisted mount URL %q, got %q", "http://nas.local:8090/dav/persisted/", mount.MountURL)
}
@ -555,6 +593,8 @@ func TestControlPlanePersistsRegistryAcrossAppRestart(t *testing.T) {
AgentVersion: "1.2.4",
DirectAddress: &directAddress,
RelayAddress: nil,
})
syncNodeExports(t, secondServer.Client(), registration.NodeToken, secondServer.URL+"/api/v1/nodes/"+registration.Node.ID+"/exports", nodeExportsRequest{
Exports: []storageExportInput{{
Label: "Docs Updated",
Path: "/srv/docs",
@ -580,16 +620,14 @@ func TestControlPlaneRejectsInvalidRequestsAndEnforcesAuth(t *testing.T) {
"displayName":"Primary NAS",
"agentVersion":"1.2.3",
"directAddress":"http://nas.local:8090",
"relayAddress":null,
"exports":[{"label":"Docs","path":"/srv/docs","protocols":["webdav"],"capacityBytes":null,"tags":[]}]
"relayAddress":null
}`, http.StatusUnauthorized)
postRawJSONAuthStatus(t, server.Client(), testNodeBootstrapToken, server.URL+"/api/v1/nodes/register", `{
"machineId":"machine-1",
"displayName":"Primary NAS",
"agentVersion":"1.2.3",
"relayAddress":null,
"exports":[{"label":"Docs","path":"/srv/docs","protocols":["webdav"],"capacityBytes":null,"tags":[]}]
"relayAddress":null
}`, http.StatusBadRequest)
postRawJSONAuthStatus(t, server.Client(), testNodeBootstrapToken, server.URL+"/api/v1/nodes/register", `{
@ -597,32 +635,6 @@ func TestControlPlaneRejectsInvalidRequestsAndEnforcesAuth(t *testing.T) {
"displayName":"Primary NAS",
"agentVersion":"1.2.3",
"directAddress":"nas.local:8090",
"relayAddress":null,
"exports":[{"label":"Docs","path":"/srv/docs","protocols":["webdav"],"capacityBytes":null,"tags":[]}]
}`, http.StatusBadRequest)
postRawJSONAuthStatus(t, server.Client(), testNodeBootstrapToken, server.URL+"/api/v1/nodes/register", `{
"machineId":"machine-1",
"displayName":"Primary NAS",
"agentVersion":"1.2.3",
"directAddress":"http://nas.local:8090",
"relayAddress":null,
"exports":[
{"label":"Docs","path":"/srv/docs","mountPath":"/dav/docs/","protocols":["webdav"],"capacityBytes":null,"tags":[]},
{"label":"Docs Duplicate","path":"/srv/docs-2","mountPath":"/dav/docs/","protocols":["webdav"],"capacityBytes":null,"tags":[]}
]
}`, http.StatusBadRequest)
postRawJSONAuthStatus(t, server.Client(), testNodeBootstrapToken, server.URL+"/api/v1/nodes/register", `{
"machineId":"machine-1",
"displayName":"Primary NAS",
"agentVersion":"1.2.3",
"directAddress":"http://nas.local:8090",
"relayAddress":null,
"exports":[
{"label":"Docs","path":"/srv/docs","mountPath":"/dav/docs/","protocols":["webdav"],"capacityBytes":null,"tags":[]},
{"label":"Media","path":"/srv/media","protocols":["webdav"],"capacityBytes":null,"tags":[]}
]
}`, http.StatusBadRequest)
response := postRawJSONAuth(t, server.Client(), testNodeBootstrapToken, server.URL+"/api/v1/nodes/register", `{
@ -631,8 +643,7 @@ func TestControlPlaneRejectsInvalidRequestsAndEnforcesAuth(t *testing.T) {
"agentVersion":"1.2.3",
"directAddress":"http://nas.local:8090",
"relayAddress":null,
"ignoredTopLevel":"ok",
"exports":[{"label":"Docs","path":"/srv/docs","mountPath":"/dav/docs/","protocols":["webdav"],"capacityBytes":null,"tags":[],"ignoredNested":"ok"}]
"ignoredTopLevel":"ok"
}`)
defer response.Body.Close()
@ -653,6 +664,58 @@ func TestControlPlaneRejectsInvalidRequestsAndEnforcesAuth(t *testing.T) {
t.Fatalf("expected node ID %q, got %q", "dev-node", node.ID)
}
putJSONAuthStatus(t, server.Client(), testClientToken, server.URL+"/api/v1/nodes/"+node.ID+"/exports", nodeExportsRequest{
Exports: []storageExportInput{{
Label: "Docs",
Path: "/srv/docs",
MountPath: "/dav/docs/",
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{},
}},
}, http.StatusUnauthorized)
putJSONAuthStatus(t, server.Client(), nodeToken, server.URL+"/api/v1/nodes/"+node.ID+"/exports", nodeExportsRequest{
Exports: []storageExportInput{
{
Label: "Docs",
Path: "/srv/docs",
MountPath: "/dav/docs/",
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{},
},
{
Label: "Media",
Path: "/srv/media",
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{},
},
},
}, http.StatusBadRequest)
putJSONAuthStatus(t, server.Client(), nodeToken, server.URL+"/api/v1/nodes/"+node.ID+"/exports", nodeExportsRequest{
Exports: []storageExportInput{
{
Label: "Docs",
Path: "/srv/docs",
MountPath: "/dav/docs/",
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{},
},
{
Label: "Docs Duplicate",
Path: "/srv/docs-2",
MountPath: "/dav/docs/",
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{},
},
},
}, http.StatusBadRequest)
postJSONAuthStatus(t, server.Client(), testClientToken, server.URL+"/api/v1/nodes/"+node.ID+"/heartbeat", nodeHeartbeatRequest{
NodeID: node.ID,
Status: "online",
@ -686,9 +749,9 @@ func TestControlPlaneRejectsInvalidRequestsAndEnforcesAuth(t *testing.T) {
getStatusWithAuth(t, server.Client(), "", server.URL+"/api/v1/exports", http.StatusUnauthorized)
getStatusWithAuth(t, server.Client(), "wrong-client-token", server.URL+"/api/v1/exports", http.StatusUnauthorized)
postRawJSONAuthStatus(t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", `{}`, http.StatusBadRequest)
postJSONAuthStatus(t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{
UserID: "user-1",
DeviceID: "device-1",
ExportID: "missing-export",
}, http.StatusNotFound)
@ -711,6 +774,12 @@ func newTestControlPlaneServer(t *testing.T, config appConfig) (*app, *httptest.
if config.nodeBootstrapToken == "" {
config.nodeBootstrapToken = testNodeBootstrapToken
}
if config.davAuthSecret == "" {
config.davAuthSecret = "test-dav-auth-secret"
}
if config.davCredentialTTL == 0 {
config.davCredentialTTL = time.Hour
}
app, err := newApp(config, testControlPlaneNow)
if err != nil {
@ -755,6 +824,25 @@ func registerNode(t *testing.T, client *http.Client, endpoint string, token stri
}
}
func syncNodeExports(t *testing.T, client *http.Client, token string, endpoint string, payload nodeExportsRequest) []storageExport {
t.Helper()
response := putJSONAuthResponse(t, client, token, endpoint, payload)
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
responseBody, _ := io.ReadAll(response.Body)
t.Fatalf("put %s: expected status 200, got %d: %s", endpoint, response.StatusCode, responseBody)
}
var exports []storageExport
if err := json.NewDecoder(response.Body).Decode(&exports); err != nil {
t.Fatalf("decode %s response: %v", endpoint, err)
}
return exports
}
func getJSON[T any](t *testing.T, client *http.Client, endpoint string) T {
t.Helper()
@ -836,15 +924,39 @@ func postJSONAuthStatus(t *testing.T, client *http.Client, token string, endpoin
}
}
func putJSONAuthStatus(t *testing.T, client *http.Client, token string, endpoint string, payload any, expectedStatus int) {
t.Helper()
response := putJSONAuthResponse(t, client, token, endpoint, payload)
defer response.Body.Close()
if response.StatusCode != expectedStatus {
body, _ := io.ReadAll(response.Body)
t.Fatalf("put %s: expected status %d, got %d: %s", endpoint, expectedStatus, response.StatusCode, body)
}
}
func postJSONAuthResponse(t *testing.T, client *http.Client, token string, endpoint string, payload any) *http.Response {
t.Helper()
return jsonAuthResponse(t, client, http.MethodPost, token, endpoint, payload)
}
func putJSONAuthResponse(t *testing.T, client *http.Client, token string, endpoint string, payload any) *http.Response {
t.Helper()
return jsonAuthResponse(t, client, http.MethodPut, token, endpoint, payload)
}
func jsonAuthResponse(t *testing.T, client *http.Client, method string, token string, endpoint string, payload any) *http.Response {
t.Helper()
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload for %s: %v", endpoint, err)
}
return doRequest(t, client, http.MethodPost, endpoint, bytes.NewReader(body), authHeaders(token))
return doRequest(t, client, method, endpoint, bytes.NewReader(body), authHeaders(token))
}
func postRawJSONAuthStatus(t *testing.T, client *http.Client, token string, endpoint string, raw string, expectedStatus int) {

View file

@ -197,14 +197,43 @@ func registerNodeInState(state *storeState, request nodeRegistrationRequest, reg
RelayAddress: copyStringPointer(request.RelayAddress),
}
state.NodesByID[nodeID] = node
return nodeRegistrationResult{
Node: node,
IssuedNodeToken: issuedNodeToken,
}, nil
}
func (s *memoryStore) upsertExports(nodeID string, request nodeExportsRequest) ([]storageExport, error) {
s.mu.Lock()
defer s.mu.Unlock()
nextState := cloneStoreState(s.state)
exports, err := upsertExportsInState(&nextState, nodeID, request.Exports)
if err != nil {
return nil, err
}
if err := s.persistLocked(nextState); err != nil {
return nil, err
}
s.state = nextState
return exports, nil
}
func upsertExportsInState(state *storeState, nodeID string, exports []storageExportInput) ([]storageExport, error) {
if _, ok := state.NodesByID[nodeID]; !ok {
return nil, errNodeNotFound
}
exportIDsByPath, ok := state.ExportIDsByNodePath[nodeID]
if !ok {
exportIDsByPath = make(map[string]string)
state.ExportIDsByNodePath[nodeID] = exportIDsByPath
}
keepPaths := make(map[string]struct{}, len(request.Exports))
for _, export := range request.Exports {
keepPaths := make(map[string]struct{}, len(exports))
for _, export := range exports {
exportID, ok := exportIDsByPath[export.Path]
if !ok {
exportID = nextExportID(state)
@ -233,11 +262,19 @@ func registerNodeInState(state *storeState, request nodeRegistrationRequest, reg
delete(state.ExportsByID, exportID)
}
state.NodesByID[nodeID] = node
return nodeRegistrationResult{
Node: node,
IssuedNodeToken: issuedNodeToken,
}, nil
nodeExports := make([]storageExport, 0, len(exportIDsByPath))
for exportPath, exportID := range exportIDsByPath {
if _, ok := keepPaths[exportPath]; !ok {
continue
}
nodeExports = append(nodeExports, copyStorageExport(state.ExportsByID[exportID]))
}
sort.Slice(nodeExports, func(i, j int) bool {
return nodeExports[i].ID < nodeExports[j].ID
})
return nodeExports, nil
}
func (s *memoryStore) recordHeartbeat(nodeID string, request nodeHeartbeatRequest) error {

View file

@ -7,5 +7,8 @@ For the scaffold it does two things:
- serves `GET /health`
- serves a WebDAV export at `/dav/`
- optionally serves multiple configured exports at deterministic `/dav/exports/<slug>/` paths via `BETTERNAS_EXPORT_PATHS_JSON`
- registers itself with the control plane and syncs its exports when
`BETTERNAS_CONTROL_PLANE_URL` is configured
- enforces issued WebDAV basic-auth mount credentials
This is the first real storage-facing surface in the monorepo.

View file

@ -1,8 +1,6 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@ -19,11 +17,15 @@ const (
)
type appConfig struct {
exportPaths []string
exportPaths []string
nodeID string
davAuthSecret string
}
type app struct {
exportMounts []exportMount
nodeID string
davAuthSecret string
exportMounts []exportMount
}
type exportMount struct {
@ -32,12 +34,26 @@ type exportMount struct {
}
func newApp(config appConfig) (*app, error) {
config.nodeID = strings.TrimSpace(config.nodeID)
if config.nodeID == "" {
return nil, errors.New("nodeID is required")
}
config.davAuthSecret = strings.TrimSpace(config.davAuthSecret)
if config.davAuthSecret == "" {
return nil, errors.New("davAuthSecret is required")
}
exportMounts, err := buildExportMounts(config.exportPaths)
if err != nil {
return nil, err
}
return &app{exportMounts: exportMounts}, nil
return &app{
nodeID: config.nodeID,
davAuthSecret: config.davAuthSecret,
exportMounts: exportMounts,
}, nil
}
func newAppFromEnv() (*app, error) {
@ -46,7 +62,25 @@ func newAppFromEnv() (*app, error) {
return nil, err
}
return newApp(appConfig{exportPaths: exportPaths})
davAuthSecret, err := requiredEnv("BETTERNAS_DAV_AUTH_SECRET")
if err != nil {
return nil, err
}
nodeID := strings.TrimSpace(env("BETTERNAS_NODE_ID", ""))
if strings.TrimSpace(env("BETTERNAS_CONTROL_PLANE_URL", "")) != "" {
bootstrapResult, err := bootstrapNodeAgentFromEnv(exportPaths)
if err != nil {
return nil, err
}
nodeID = bootstrapResult.nodeID
}
return newApp(appConfig{
exportPaths: exportPaths,
nodeID: nodeID,
davAuthSecret: davAuthSecret,
})
}
func exportPathsFromEnv() ([]string, error) {
@ -126,12 +160,38 @@ func (a *app) handler() http.Handler {
FileSystem: webdav.Dir(mount.exportPath),
LockSystem: webdav.NewMemLS(),
}
mux.Handle(mount.mountPath, dav)
mux.Handle(mount.mountPath, a.requireDAVAuth(mount, dav))
}
return mux
}
func (a *app) requireDAVAuth(mount exportMount, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
username, password, ok := r.BasicAuth()
if !ok {
writeDAVUnauthorized(w)
return
}
claims, err := verifyMountCredential(a.davAuthSecret, password)
if err != nil {
writeDAVUnauthorized(w)
return
}
if claims.NodeID != a.nodeID || claims.MountPath != mount.mountPath || claims.Username != username {
writeDAVUnauthorized(w)
return
}
if claims.Readonly && !isDAVReadMethod(r.Method) {
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func mountProfilePathForExport(exportPath string, exportCount int) string {
// Keep /dav/ stable for the common single-export case while exposing distinct
// scoped roots when a node serves more than one export.
@ -147,6 +207,5 @@ func scopedMountPathForExport(exportPath string) string {
}
func exportRouteSlug(exportPath string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(exportPath)))
return hex.EncodeToString(sum[:])
return stableExportRouteSlug(exportPath)
}

View file

@ -1,21 +1,31 @@
package main
import (
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestSingleExportServesDefaultAndScopedMountPaths(t *testing.T) {
const testDAVAuthSecret = "test-dav-auth-secret"
func TestSingleExportServesDefaultAndScopedMountPathsWithValidCredentials(t *testing.T) {
t.Parallel()
exportDir := t.TempDir()
writeExportFile(t, exportDir, "README.txt", "single export\n")
app, err := newApp(appConfig{exportPaths: []string{exportDir}})
app, err := newApp(appConfig{
exportPaths: []string{exportDir},
nodeID: "node-1",
davAuthSecret: testDAVAuthSecret,
})
if err != nil {
t.Fatalf("new app: %v", err)
}
@ -23,13 +33,17 @@ func TestSingleExportServesDefaultAndScopedMountPaths(t *testing.T) {
server := httptest.NewServer(app.handler())
defer server.Close()
assertHTTPStatus(t, server.Client(), "PROPFIND", server.URL+defaultWebDAVPath, http.StatusMultiStatus)
assertHTTPStatus(t, server.Client(), "PROPFIND", server.URL+scopedMountPathForExport(exportDir), http.StatusMultiStatus)
assertMountedFileContents(t, server.Client(), server.URL+defaultWebDAVPath+"README.txt", "single export\n")
assertMountedFileContents(t, server.Client(), server.URL+scopedMountPathForExport(exportDir)+"README.txt", "single export\n")
defaultUsername, defaultPassword := issueTestMountCredential(t, "node-1", defaultWebDAVPath, false)
scopedMountPath := scopedMountPathForExport(exportDir)
scopedUsername, scopedPassword := issueTestMountCredential(t, "node-1", scopedMountPath, false)
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+defaultWebDAVPath, defaultUsername, defaultPassword, http.StatusMultiStatus)
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+scopedMountPath, scopedUsername, scopedPassword, http.StatusMultiStatus)
assertMountedFileContentsWithBasicAuth(t, server.Client(), server.URL+defaultWebDAVPath+"README.txt", defaultUsername, defaultPassword, "single export\n")
assertMountedFileContentsWithBasicAuth(t, server.Client(), server.URL+scopedMountPath+"README.txt", scopedUsername, scopedPassword, "single export\n")
}
func TestMultipleExportsServeDistinctScopedMountPaths(t *testing.T) {
func TestMultipleExportsServeDistinctScopedMountPathsWithValidCredentials(t *testing.T) {
t.Parallel()
firstExportDir := t.TempDir()
@ -37,7 +51,11 @@ func TestMultipleExportsServeDistinctScopedMountPaths(t *testing.T) {
writeExportFile(t, firstExportDir, "README.txt", "first export\n")
writeExportFile(t, secondExportDir, "README.txt", "second export\n")
app, err := newApp(appConfig{exportPaths: []string{firstExportDir, secondExportDir}})
app, err := newApp(appConfig{
exportPaths: []string{firstExportDir, secondExportDir},
nodeID: "node-1",
davAuthSecret: testDAVAuthSecret,
})
if err != nil {
t.Fatalf("new app: %v", err)
}
@ -51,10 +69,13 @@ func TestMultipleExportsServeDistinctScopedMountPaths(t *testing.T) {
t.Fatal("expected distinct mount paths for multiple exports")
}
assertHTTPStatus(t, server.Client(), "PROPFIND", server.URL+firstMountPath, http.StatusMultiStatus)
assertHTTPStatus(t, server.Client(), "PROPFIND", server.URL+secondMountPath, http.StatusMultiStatus)
assertMountedFileContents(t, server.Client(), server.URL+firstMountPath+"README.txt", "first export\n")
assertMountedFileContents(t, server.Client(), server.URL+secondMountPath+"README.txt", "second export\n")
firstUsername, firstPassword := issueTestMountCredential(t, "node-1", firstMountPath, false)
secondUsername, secondPassword := issueTestMountCredential(t, "node-1", secondMountPath, false)
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+firstMountPath, firstUsername, firstPassword, http.StatusMultiStatus)
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+secondMountPath, secondUsername, secondPassword, http.StatusMultiStatus)
assertMountedFileContentsWithBasicAuth(t, server.Client(), server.URL+firstMountPath+"README.txt", firstUsername, firstPassword, "first export\n")
assertMountedFileContentsWithBasicAuth(t, server.Client(), server.URL+secondMountPath+"README.txt", secondUsername, secondPassword, "second export\n")
response, err := server.Client().Get(server.URL + defaultWebDAVPath)
if err != nil {
@ -66,6 +87,48 @@ func TestMultipleExportsServeDistinctScopedMountPaths(t *testing.T) {
}
}
func TestDAVAuthRejectsMissingInvalidAndReadonlyCredentials(t *testing.T) {
t.Parallel()
exportDir := t.TempDir()
writeExportFile(t, exportDir, "README.txt", "readonly export\n")
app, err := newApp(appConfig{
exportPaths: []string{exportDir},
nodeID: "node-1",
davAuthSecret: testDAVAuthSecret,
})
if err != nil {
t.Fatalf("new app: %v", err)
}
server := httptest.NewServer(app.handler())
defer server.Close()
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+defaultWebDAVPath, "", "", http.StatusUnauthorized)
wrongMountUsername, wrongMountPassword := issueTestMountCredential(t, "node-1", "/dav/wrong/", false)
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+defaultWebDAVPath, wrongMountUsername, wrongMountPassword, http.StatusUnauthorized)
expiredUsername, expiredPassword := issueExpiredTestMountCredential(t, "node-1", defaultWebDAVPath, false)
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+defaultWebDAVPath, expiredUsername, expiredPassword, http.StatusUnauthorized)
readonlyUsername, readonlyPassword := issueTestMountCredential(t, "node-1", defaultWebDAVPath, true)
request, err := http.NewRequest(http.MethodPut, server.URL+defaultWebDAVPath+"README.txt", strings.NewReader("updated\n"))
if err != nil {
t.Fatalf("build PUT request: %v", err)
}
request.SetBasicAuth(readonlyUsername, readonlyPassword)
response, err := server.Client().Do(request)
if err != nil {
t.Fatalf("PUT %s: %v", request.URL.String(), err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusForbidden {
t.Fatalf("expected readonly credential to return 403, got %d", response.StatusCode)
}
}
func TestBuildExportMountsRejectsInvalidConfigs(t *testing.T) {
t.Parallel()
@ -80,13 +143,16 @@ func TestBuildExportMountsRejectsInvalidConfigs(t *testing.T) {
}
}
func assertHTTPStatus(t *testing.T, client *http.Client, method string, endpoint string, expectedStatus int) {
func assertHTTPStatusWithBasicAuth(t *testing.T, client *http.Client, method string, endpoint string, username string, password string, expectedStatus int) {
t.Helper()
request, err := http.NewRequest(method, endpoint, nil)
if err != nil {
t.Fatalf("build %s request for %s: %v", method, endpoint, err)
}
if username != "" || password != "" {
request.SetBasicAuth(username, password)
}
response, err := client.Do(request)
if err != nil {
@ -99,10 +165,16 @@ func assertHTTPStatus(t *testing.T, client *http.Client, method string, endpoint
}
}
func assertMountedFileContents(t *testing.T, client *http.Client, endpoint string, expected string) {
func assertMountedFileContentsWithBasicAuth(t *testing.T, client *http.Client, endpoint string, username string, password string, expected string) {
t.Helper()
response, err := client.Get(endpoint)
request, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
t.Fatalf("build GET request for %s: %v", endpoint, err)
}
request.SetBasicAuth(username, password)
response, err := client.Do(request)
if err != nil {
t.Fatalf("get %s: %v", endpoint, err)
}
@ -121,6 +193,54 @@ func assertMountedFileContents(t *testing.T, client *http.Client, endpoint strin
}
}
func issueTestMountCredential(t *testing.T, nodeID string, mountPath string, readonly bool) (string, string) {
t.Helper()
claims := signedMountCredentialClaims{
Version: 1,
NodeID: nodeID,
MountPath: mountPath,
Username: "mount-test-user",
Readonly: readonly,
ExpiresAt: time.Now().UTC().Add(time.Hour).Format(time.RFC3339),
}
password, err := encodeTestMountCredential(claims)
if err != nil {
t.Fatalf("issue test mount credential: %v", err)
}
return claims.Username, password
}
func issueExpiredTestMountCredential(t *testing.T, nodeID string, mountPath string, readonly bool) (string, string) {
t.Helper()
claims := signedMountCredentialClaims{
Version: 1,
NodeID: nodeID,
MountPath: mountPath,
Username: "mount-expired-user",
Readonly: readonly,
ExpiresAt: time.Now().UTC().Add(-time.Minute).Format(time.RFC3339),
}
password, err := encodeTestMountCredential(claims)
if err != nil {
t.Fatalf("issue expired test mount credential: %v", err)
}
return claims.Username, password
}
func encodeTestMountCredential(claims signedMountCredentialClaims) (string, error) {
payload, err := json.Marshal(claims)
if err != nil {
return "", err
}
encodedPayload := base64.RawURLEncoding.EncodeToString(payload)
return encodedPayload + "." + signMountCredentialPayload(testDAVAuthSecret, encodedPayload), nil
}
func writeExportFile(t *testing.T, directory string, name string, contents string) {
t.Helper()

View file

@ -0,0 +1,299 @@
package main
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
const controlPlaneNodeTokenHeader = "X-BetterNAS-Node-Token"
type bootstrapResult struct {
nodeID string
}
type nodeRegistrationRequest struct {
MachineID string `json:"machineId"`
DisplayName string `json:"displayName"`
AgentVersion string `json:"agentVersion"`
DirectAddress *string `json:"directAddress"`
RelayAddress *string `json:"relayAddress"`
}
type nodeRegistrationResponse struct {
ID string `json:"id"`
}
type nodeExportsRequest struct {
Exports []storageExportInput `json:"exports"`
}
type storageExportInput struct {
Label string `json:"label"`
Path string `json:"path"`
MountPath string `json:"mountPath"`
Protocols []string `json:"protocols"`
CapacityBytes *int64 `json:"capacityBytes"`
Tags []string `json:"tags"`
}
type nodeHeartbeatRequest struct {
NodeID string `json:"nodeId"`
Status string `json:"status"`
LastSeenAt string `json:"lastSeenAt"`
}
func bootstrapNodeAgentFromEnv(exportPaths []string) (bootstrapResult, error) {
controlPlaneURL, err := requiredEnv("BETTERNAS_CONTROL_PLANE_URL")
if err != nil {
return bootstrapResult{}, err
}
bootstrapToken, err := requiredEnv("BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN")
if err != nil {
return bootstrapResult{}, err
}
nodeTokenPath, err := requiredEnv("BETTERNAS_NODE_TOKEN_PATH")
if err != nil {
return bootstrapResult{}, err
}
machineID, err := requiredEnv("BETTERNAS_NODE_MACHINE_ID")
if err != nil {
return bootstrapResult{}, err
}
displayName := strings.TrimSpace(env("BETTERNAS_NODE_DISPLAY_NAME", machineID))
if displayName == "" {
displayName = machineID
}
client := &http.Client{Timeout: 5 * time.Second}
nodeToken, err := readNodeToken(nodeTokenPath)
if err != nil {
return bootstrapResult{}, err
}
authToken := nodeToken
if authToken == "" {
authToken = bootstrapToken
}
registration, issuedNodeToken, err := registerNodeWithControlPlane(client, controlPlaneURL, authToken, nodeRegistrationRequest{
MachineID: machineID,
DisplayName: displayName,
AgentVersion: env("BETTERNAS_VERSION", "0.1.0-dev"),
DirectAddress: optionalEnvPointer("BETTERNAS_NODE_DIRECT_ADDRESS"),
RelayAddress: optionalEnvPointer("BETTERNAS_NODE_RELAY_ADDRESS"),
})
if err != nil {
return bootstrapResult{}, err
}
if strings.TrimSpace(issuedNodeToken) != "" {
if err := writeNodeToken(nodeTokenPath, issuedNodeToken); err != nil {
return bootstrapResult{}, err
}
authToken = issuedNodeToken
}
if err := syncNodeExportsWithControlPlane(client, controlPlaneURL, authToken, registration.ID, buildStorageExportInputs(exportPaths)); err != nil {
return bootstrapResult{}, err
}
if err := sendNodeHeartbeat(client, controlPlaneURL, authToken, registration.ID); err != nil {
return bootstrapResult{}, err
}
return bootstrapResult{nodeID: registration.ID}, nil
}
func registerNodeWithControlPlane(client *http.Client, baseURL string, token string, payload nodeRegistrationRequest) (nodeRegistrationResponse, string, error) {
response, err := doControlPlaneJSONRequest(client, http.MethodPost, controlPlaneEndpoint(baseURL, "/api/v1/nodes/register"), token, payload)
if err != nil {
return nodeRegistrationResponse{}, "", err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return nodeRegistrationResponse{}, "", controlPlaneResponseError("register node", response)
}
var registration nodeRegistrationResponse
if err := json.NewDecoder(response.Body).Decode(&registration); err != nil {
return nodeRegistrationResponse{}, "", fmt.Errorf("decode register node response: %w", err)
}
return registration, strings.TrimSpace(response.Header.Get(controlPlaneNodeTokenHeader)), nil
}
func syncNodeExportsWithControlPlane(client *http.Client, baseURL string, token string, nodeID string, exports []storageExportInput) error {
response, err := doControlPlaneJSONRequest(client, http.MethodPut, controlPlaneEndpoint(baseURL, "/api/v1/nodes/"+nodeID+"/exports"), token, nodeExportsRequest{
Exports: exports,
})
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return controlPlaneResponseError("sync node exports", response)
}
_, _ = io.Copy(io.Discard, response.Body)
return nil
}
func sendNodeHeartbeat(client *http.Client, baseURL string, token string, nodeID string) error {
response, err := doControlPlaneJSONRequest(client, http.MethodPost, controlPlaneEndpoint(baseURL, "/api/v1/nodes/"+nodeID+"/heartbeat"), token, nodeHeartbeatRequest{
NodeID: nodeID,
Status: "online",
LastSeenAt: time.Now().UTC().Format(time.RFC3339),
})
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusNoContent {
return controlPlaneResponseError("send node heartbeat", response)
}
return nil
}
func doControlPlaneJSONRequest(client *http.Client, method string, endpoint string, token string, payload any) (*http.Response, error) {
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal %s %s payload: %w", method, endpoint, err)
}
request, err := http.NewRequest(method, endpoint, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("build %s %s request: %w", method, endpoint, err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "Bearer "+token)
response, err := client.Do(request)
if err != nil {
return nil, fmt.Errorf("%s %s: %w", method, endpoint, err)
}
return response, nil
}
func controlPlaneEndpoint(baseURL string, suffix string) string {
return strings.TrimRight(strings.TrimSpace(baseURL), "/") + suffix
}
func controlPlaneResponseError(action string, response *http.Response) error {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("%s: unexpected status %d: %s", action, response.StatusCode, strings.TrimSpace(string(body)))
}
func buildStorageExportInputs(exportPaths []string) []storageExportInput {
inputs := make([]storageExportInput, len(exportPaths))
for index, exportPath := range exportPaths {
inputs[index] = storageExportInput{
Label: exportLabel(exportPath),
Path: strings.TrimSpace(exportPath),
MountPath: mountProfilePathForExport(exportPath, len(exportPaths)),
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{},
}
}
return inputs
}
func exportLabel(exportPath string) string {
base := filepath.Base(filepath.Clean(strings.TrimSpace(exportPath)))
if base == "" || base == "." || base == string(filepath.Separator) {
return "export"
}
return base
}
func stableExportRouteSlug(exportPath string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(exportPath)))
return hex.EncodeToString(sum[:])
}
func requiredEnv(key string) (string, error) {
value, ok := os.LookupEnv(key)
if !ok || strings.TrimSpace(value) == "" {
return "", fmt.Errorf("%s is required", key)
}
return strings.TrimSpace(value), nil
}
func optionalEnvPointer(key string) *string {
value := strings.TrimSpace(env(key, ""))
if value == "" {
return nil
}
return &value
}
func readNodeToken(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", fmt.Errorf("read node token %s: %w", path, err)
}
return strings.TrimSpace(string(data)), nil
}
func writeNodeToken(path string, token string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return fmt.Errorf("create node token directory %s: %w", filepath.Dir(path), err)
}
tempFile, err := os.CreateTemp(filepath.Dir(path), ".node-token-*.tmp")
if err != nil {
return fmt.Errorf("create node token temp file in %s: %w", filepath.Dir(path), err)
}
tempFilePath := tempFile.Name()
cleanupTempFile := true
defer func() {
if cleanupTempFile {
_ = os.Remove(tempFilePath)
}
}()
if err := tempFile.Chmod(0o600); err != nil {
_ = tempFile.Close()
return fmt.Errorf("chmod node token temp file %s: %w", tempFilePath, err)
}
if _, err := tempFile.WriteString(strings.TrimSpace(token) + "\n"); err != nil {
_ = tempFile.Close()
return fmt.Errorf("write node token temp file %s: %w", tempFilePath, err)
}
if err := tempFile.Close(); err != nil {
return fmt.Errorf("close node token temp file %s: %w", tempFilePath, err)
}
if err := os.Rename(tempFilePath, path); err != nil {
return fmt.Errorf("replace node token %s: %w", path, err)
}
cleanupTempFile = false
return nil
}

View file

@ -0,0 +1,74 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
)
type signedMountCredentialClaims struct {
Version int `json:"v"`
NodeID string `json:"nodeId"`
MountPath string `json:"mountPath"`
Username string `json:"username"`
Readonly bool `json:"readonly"`
ExpiresAt string `json:"expiresAt"`
}
func verifyMountCredential(secret string, token string) (signedMountCredentialClaims, error) {
encodedPayload, signature, ok := strings.Cut(strings.TrimSpace(token), ".")
if !ok || encodedPayload == "" || signature == "" {
return signedMountCredentialClaims{}, errors.New("invalid mount credential")
}
expectedSignature := signMountCredentialPayload(secret, encodedPayload)
if subtle.ConstantTimeCompare([]byte(expectedSignature), []byte(signature)) != 1 {
return signedMountCredentialClaims{}, errors.New("invalid mount credential")
}
payload, err := base64.RawURLEncoding.DecodeString(encodedPayload)
if err != nil {
return signedMountCredentialClaims{}, errors.New("invalid mount credential")
}
var claims signedMountCredentialClaims
if err := json.Unmarshal(payload, &claims); err != nil {
return signedMountCredentialClaims{}, errors.New("invalid mount credential")
}
if claims.Version != 1 || claims.NodeID == "" || claims.MountPath == "" || claims.Username == "" || claims.ExpiresAt == "" {
return signedMountCredentialClaims{}, errors.New("invalid mount credential")
}
expiresAt, err := time.Parse(time.RFC3339, claims.ExpiresAt)
if err != nil || time.Now().UTC().After(expiresAt) {
return signedMountCredentialClaims{}, errors.New("invalid mount credential")
}
return claims, nil
}
func signMountCredentialPayload(secret string, encodedPayload string) string {
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(encodedPayload))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func writeDAVUnauthorized(w http.ResponseWriter) {
w.Header().Set("WWW-Authenticate", `Basic realm="betterNAS"`)
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
}
func isDAVReadMethod(method string) bool {
switch method {
case http.MethodGet, http.MethodHead, http.MethodOptions, "PROPFIND":
return true
default:
return false
}
}