diff --git a/.env.agent.example b/.env.agent.example index aa4ca07..a87fca2 100644 --- a/.env.agent.example +++ b/.env.agent.example @@ -7,6 +7,10 @@ BETTERNAS_NODE_AGENT_PORT= BETTERNAS_NEXTCLOUD_PORT= BETTERNAS_EXPORT_PATH= BETTERNAS_VERSION=local-dev +BETTERNAS_DAV_AUTH_SECRET= +BETTERNAS_DAV_CREDENTIAL_TTL= +BETTERNAS_NODE_MACHINE_ID= +BETTERNAS_NODE_DISPLAY_NAME= BETTERNAS_NODE_DIRECT_ADDRESS= BETTERNAS_EXAMPLE_MOUNT_URL= NEXTCLOUD_BASE_URL= diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a36de20 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,46 @@ +# Project Constraints + +## Delivery sequencing + +- Start with `apps/control-plane` first. +- Deliver the core backend in 2 steps, not 3: + 1. `control-server` plus `node-service` contract and runtime loop + 2. web control plane on top of that stable backend seam +- Do not start web UI work until the `control-server` and `node-service` contract is stable. + +## Architecture + +- `control-server` is the clean backend contract that other parts consume. +- `apps/node-agent` reports into `apps/control-plane`. +- `apps/web` reads from `apps/control-plane`. +- Local mount UX is issued by `apps/control-plane`. + +## Backend contract priorities + +- The first backend seam must cover: + - node enrollment + - node heartbeats + - node export reporting + - control-server persistence of nodes and exports + - mount profile issuance for one export +- `control-server` should own: + - node auth + - user auth + - mount issuance + +## Mount profile shape + +- Prefer standard WebDAV username and password semantics for Finder compatibility. +- The consumer-facing mount profile should behave like: + - export id + - display name + - mount URL + - username + - password + - readonly + - expires at + +## Service boundary + +- Keep `node-service` limited to the WebDAV mount surface. +- Route admin and control actions through `control-server`, not directly from browsers to `node-service`. diff --git a/README.md b/README.md index e7ec4c7..3c7c647 100644 --- a/README.md +++ b/README.md @@ -97,8 +97,8 @@ The first proven slice is: 1. boot the stack with `pnpm stack:up` 2. verify it with `pnpm stack:verify` -3. get the WebDAV mount URL -4. mount it in Finder +3. get the WebDAV mount profile from the control plane +4. mount it in Finder with the issued credentials If the stack is running on a remote machine, tunnel the WebDAV port first, then use Finder `Connect to Server` with the tunneled URL. diff --git a/apps/control-plane/README.md b/apps/control-plane/README.md index 8bec0f2..9bfe221 100644 --- a/apps/control-plane/README.md +++ b/apps/control-plane/README.md @@ -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. diff --git a/apps/control-plane/cmd/control-plane/app.go b/apps/control-plane/cmd/control-plane/app.go index 9d6ef11..2863e8b 100644 --- a/apps/control-plane/cmd/control-plane/app.go +++ b/apps/control-plane/cmd/control-plane/app.go @@ -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"` } diff --git a/apps/control-plane/cmd/control-plane/main.go b/apps/control-plane/cmd/control-plane/main.go index 9159161..7b109f5 100644 --- a/apps/control-plane/cmd/control-plane/main.go +++ b/apps/control-plane/cmd/control-plane/main.go @@ -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, ) diff --git a/apps/control-plane/cmd/control-plane/mount_credentials.go b/apps/control-plane/cmd/control-plane/mount_credentials.go new file mode 100644 index 0000000..0ad9a28 --- /dev/null +++ b/apps/control-plane/cmd/control-plane/mount_credentials.go @@ -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)) +} diff --git a/apps/control-plane/cmd/control-plane/runtime_integration_test.go b/apps/control-plane/cmd/control-plane/runtime_integration_test.go index d59fb2a..4aab408 100644 --- a/apps/control-plane/cmd/control-plane/runtime_integration_test.go +++ b/apps/control-plane/cmd/control-plane/runtime_integration_test.go @@ -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 { diff --git a/apps/control-plane/cmd/control-plane/server.go b/apps/control-plane/cmd/control-plane/server.go index d83f3ce..0fbd2e4 100644 --- a/apps/control-plane/cmd/control-plane/server.go +++ b/apps/control-plane/cmd/control-plane/server.go @@ -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() diff --git a/apps/control-plane/cmd/control-plane/server_test.go b/apps/control-plane/cmd/control-plane/server_test.go index b7f96c5..ec8c600 100644 --- a/apps/control-plane/cmd/control-plane/server_test.go +++ b/apps/control-plane/cmd/control-plane/server_test.go @@ -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) { diff --git a/apps/control-plane/cmd/control-plane/store.go b/apps/control-plane/cmd/control-plane/store.go index 9d8a5be..5e0861b 100644 --- a/apps/control-plane/cmd/control-plane/store.go +++ b/apps/control-plane/cmd/control-plane/store.go @@ -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 { diff --git a/apps/node-agent/README.md b/apps/node-agent/README.md index 9e096ac..3ff861e 100644 --- a/apps/node-agent/README.md +++ b/apps/node-agent/README.md @@ -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//` 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. diff --git a/apps/node-agent/cmd/node-agent/app.go b/apps/node-agent/cmd/node-agent/app.go index 0dbe4d5..0ff0d6a 100644 --- a/apps/node-agent/cmd/node-agent/app.go +++ b/apps/node-agent/cmd/node-agent/app.go @@ -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) } diff --git a/apps/node-agent/cmd/node-agent/app_test.go b/apps/node-agent/cmd/node-agent/app_test.go index d85a550..99db614 100644 --- a/apps/node-agent/cmd/node-agent/app_test.go +++ b/apps/node-agent/cmd/node-agent/app_test.go @@ -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() diff --git a/apps/node-agent/cmd/node-agent/control_plane.go b/apps/node-agent/cmd/node-agent/control_plane.go new file mode 100644 index 0000000..28326fa --- /dev/null +++ b/apps/node-agent/cmd/node-agent/control_plane.go @@ -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(®istration); 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 +} diff --git a/apps/node-agent/cmd/node-agent/dav_auth.go b/apps/node-agent/cmd/node-agent/dav_auth.go new file mode 100644 index 0000000..e483be8 --- /dev/null +++ b/apps/node-agent/cmd/node-agent/dav_auth.go @@ -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 + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 6d1d37e..18b2b11 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -111,7 +111,7 @@ self-hosted mount flow. - node registration - node heartbeat -- export inventory +- export inventory via a dedicated sync endpoint ### Web control plane -> control-server @@ -123,7 +123,20 @@ self-hosted mount flow. ### Local device -> control-server - fetch mount instructions -- receive issued WebDAV URL and credentials or token material +- receive issued WebDAV URL and standard WebDAV credentials + - username + - password + - expiresAt + +## Initial backend route sketch + +The first backend contract should stay narrow: + +- `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` ### Control-server internal diff --git a/infra/docker/compose.dev.yml b/infra/docker/compose.dev.yml index 0f47344..3d742db 100644 --- a/infra/docker/compose.dev.yml +++ b/infra/docker/compose.dev.yml @@ -35,6 +35,8 @@ services: BETTERNAS_CONTROL_PLANE_STATE_PATH: /var/lib/betternas/control-plane/state.json BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN: ${BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN} BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN: ${BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN} + BETTERNAS_DAV_AUTH_SECRET: ${BETTERNAS_DAV_AUTH_SECRET} + BETTERNAS_DAV_CREDENTIAL_TTL: ${BETTERNAS_DAV_CREDENTIAL_TTL} BETTERNAS_EXAMPLE_MOUNT_URL: ${BETTERNAS_EXAMPLE_MOUNT_URL} BETTERNAS_NODE_DIRECT_ADDRESS: ${BETTERNAS_NODE_DIRECT_ADDRESS} ports: @@ -54,6 +56,15 @@ services: environment: PORT: 8090 BETTERNAS_EXPORT_PATH: /data/export + BETTERNAS_EXPORT_PATHS_JSON: '["/data/export"]' + BETTERNAS_CONTROL_PLANE_URL: http://control-plane:3000 + BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN: ${BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN} + BETTERNAS_NODE_TOKEN_PATH: /var/lib/betternas/node-agent/node-token + BETTERNAS_NODE_MACHINE_ID: ${BETTERNAS_NODE_MACHINE_ID} + BETTERNAS_NODE_DISPLAY_NAME: ${BETTERNAS_NODE_DISPLAY_NAME} + BETTERNAS_NODE_DIRECT_ADDRESS: ${BETTERNAS_NODE_DIRECT_ADDRESS} + BETTERNAS_DAV_AUTH_SECRET: ${BETTERNAS_DAV_AUTH_SECRET} + BETTERNAS_VERSION: ${BETTERNAS_VERSION} ports: - "${BETTERNAS_NODE_AGENT_PORT}:8090" healthcheck: @@ -63,6 +74,7 @@ services: retries: 12 volumes: - ${BETTERNAS_EXPORT_PATH}:/data/export + - node-agent-data:/var/lib/betternas/node-agent nextcloud: image: nextcloud:31-apache @@ -91,6 +103,7 @@ services: volumes: control-plane-data: + node-agent-data: nextcloud-data: nextcloud-custom-apps: postgres-data: diff --git a/packages/contracts/openapi/betternas.v1.yaml b/packages/contracts/openapi/betternas.v1.yaml index 7981e4d..92fe7b1 100644 --- a/packages/contracts/openapi/betternas.v1.yaml +++ b/packages/contracts/openapi/betternas.v1.yaml @@ -66,6 +66,34 @@ paths: description: Heartbeat accepted "401": description: Unauthorized + /api/v1/nodes/{nodeId}/exports: + put: + operationId: syncNodeExports + security: + - NodeToken: [] + parameters: + - in: path + name: nodeId + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/NodeExportsRequest" + responses: + "200": + description: Export inventory accepted + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/StorageExport" + "401": + description: Unauthorized /api/v1/exports: get: operationId: listExports @@ -213,7 +241,7 @@ components: - displayName - mountUrl - readonly - - credentialMode + - credential properties: id: type: string @@ -228,9 +256,25 @@ components: type: string readonly: type: boolean - credentialMode: + credential: + $ref: "#/components/schemas/MountCredential" + MountCredential: + type: object + required: + - mode + - username + - password + - expiresAt + properties: + mode: + type: string + enum: [basic-auth] + username: + type: string + password: + type: string + expiresAt: type: string - enum: [session-token, app-password] CloudProfile: type: object required: @@ -287,7 +331,6 @@ components: - agentVersion - directAddress - relayAddress - - exports properties: machineId: type: string @@ -303,6 +346,11 @@ components: type: - string - "null" + NodeExportsRequest: + type: object + required: + - exports + properties: exports: type: array items: @@ -324,14 +372,8 @@ components: MountProfileRequest: type: object required: - - userId - - deviceId - exportId properties: - userId: - type: string - deviceId: - type: string exportId: type: string CloudProfileRequest: diff --git a/packages/contracts/schemas/mount-credential.schema.json b/packages/contracts/schemas/mount-credential.schema.json new file mode 100644 index 0000000..2606df2 --- /dev/null +++ b/packages/contracts/schemas/mount-credential.schema.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://betternas.local/schemas/mount-credential.schema.json", + "type": "object", + "additionalProperties": false, + "required": ["mode", "username", "password", "expiresAt"], + "properties": { + "mode": { + "const": "basic-auth" + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "expiresAt": { + "type": "string" + } + } +} diff --git a/packages/contracts/schemas/mount-profile.schema.json b/packages/contracts/schemas/mount-profile.schema.json index c0027c0..a67cfbb 100644 --- a/packages/contracts/schemas/mount-profile.schema.json +++ b/packages/contracts/schemas/mount-profile.schema.json @@ -10,7 +10,7 @@ "displayName", "mountUrl", "readonly", - "credentialMode" + "credential" ], "properties": { "id": { @@ -31,8 +31,8 @@ "readonly": { "type": "boolean" }, - "credentialMode": { - "enum": ["session-token", "app-password"] + "credential": { + "$ref": "./mount-credential.schema.json" } } } diff --git a/packages/contracts/src/foundation.ts b/packages/contracts/src/foundation.ts index c60324c..1825d7b 100644 --- a/packages/contracts/src/foundation.ts +++ b/packages/contracts/src/foundation.ts @@ -1,6 +1,7 @@ export const FOUNDATION_API_ROUTES = { registerNode: "/api/v1/nodes/register", nodeHeartbeat: "/api/v1/nodes/:nodeId/heartbeat", + nodeExports: "/api/v1/nodes/:nodeId/exports", listExports: "/api/v1/exports", issueMountProfile: "/api/v1/mount-profiles/issue", issueCloudProfile: "/api/v1/cloud-profiles/issue", @@ -15,7 +16,7 @@ export type NasNodeStatus = "online" | "offline" | "degraded"; export type StorageAccessProtocol = "webdav"; export type AccessMode = "mount" | "cloud"; export type AccessPrincipalType = "user" | "device"; -export type MountCredentialMode = "session-token" | "app-password"; +export type MountCredentialMode = "basic-auth"; export type CloudProvider = "nextcloud"; export interface NasNode { @@ -56,7 +57,14 @@ export interface MountProfile { displayName: string; mountUrl: string; readonly: boolean; - credentialMode: MountCredentialMode; + credential: MountCredential; +} + +export interface MountCredential { + mode: MountCredentialMode; + username: string; + password: string; + expiresAt: string; } export interface CloudProfile { @@ -82,6 +90,9 @@ export interface NodeRegistrationRequest { agentVersion: string; directAddress: string | null; relayAddress: string | null; +} + +export interface NodeExportsRequest { exports: StorageExportInput[]; } @@ -92,8 +103,6 @@ export interface NodeHeartbeatRequest { } export interface MountProfileRequest { - userId: string; - deviceId: string; exportId: string; } diff --git a/scripts/agent-bootstrap b/scripts/agent-bootstrap index 4e24251..949ffb7 100755 --- a/scripts/agent-bootstrap +++ b/scripts/agent-bootstrap @@ -35,7 +35,8 @@ Control plane: http://localhost:${BETTERNAS_CONTROL_PLANE_PORT} Node agent: http://localhost:${BETTERNAS_NODE_AGENT_PORT} Nextcloud: ${NEXTCLOUD_BASE_URL} Export path: ${BETTERNAS_EXPORT_PATH} -Mount URL: ${BETTERNAS_EXAMPLE_MOUNT_URL} +Example mount URL: ${BETTERNAS_EXAMPLE_MOUNT_URL} +Mount credentials are issued by the control plane. Next: pnpm verify diff --git a/scripts/dev-up b/scripts/dev-up index 1a86050..a678e6b 100755 --- a/scripts/dev-up +++ b/scripts/dev-up @@ -67,4 +67,5 @@ echo "Clone: $BETTERNAS_CLONE_NAME" echo "Nextcloud: $NEXTCLOUD_BASE_URL" echo "betterNAS control plane: http://localhost:$BETTERNAS_CONTROL_PLANE_PORT" echo "betterNAS node agent: http://localhost:$BETTERNAS_NODE_AGENT_PORT" -echo "WebDAV mount URL: $BETTERNAS_EXAMPLE_MOUNT_URL" +echo "Example WebDAV mount URL: $BETTERNAS_EXAMPLE_MOUNT_URL" +echo "Issue a mount profile from the control plane to get WebDAV credentials." diff --git a/scripts/integration/verify-stack b/scripts/integration/verify-stack index 7a3ece3..8360e45 100755 --- a/scripts/integration/verify-stack +++ b/scripts/integration/verify-stack @@ -6,87 +6,64 @@ set -euo pipefail source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../lib/runtime-env.sh" "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/wait-stack" -"$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/verify-webdav" control_health="$(curl -fsS "http://localhost:${BETTERNAS_CONTROL_PLANE_PORT}/health")" -verify_run_id="$(date +%s)-$$" -node_machine_id="${BETTERNAS_CLONE_NAME}-machine-${verify_run_id}" - echo "$control_health" | jq -e '.service == "control-plane" and .status == "ok"' >/dev/null -register_headers="$(mktemp)" -register_body="$(mktemp)" -trap 'rm -f "$register_headers" "$register_body"' EXIT +export_id="" +for _ in {1..30}; do + exports_response="$(curl -fsS -H "Authorization: Bearer ${BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN}" "http://localhost:${BETTERNAS_CONTROL_PLANE_PORT}/api/v1/exports")" + export_id="$({ + echo "$exports_response" | jq -er \ + 'map(select(.mountPath == "/dav/")) | .[0].id? // empty' + } 2>/dev/null || true)" + if [[ -n "$export_id" ]]; then + break + fi + sleep 1 +done -curl -fsS \ - -D "$register_headers" \ - -o "$register_body" \ - -X POST \ - -H 'Content-Type: application/json' \ - -H "Authorization: Bearer ${BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN}" \ - -d @- \ - "http://localhost:${BETTERNAS_CONTROL_PLANE_PORT}/api/v1/nodes/register" </dev/null -node_id="$(echo "$register_response" | jq -er '.id')" -node_token="$(tr -d '\r' < "$register_headers" | awk -F': ' 'tolower($1) == tolower("X-BetterNAS-Node-Token") { print $2 }' | tail -n 1 | tr -d '\n')" -if [[ -z "$node_token" ]]; then - echo "Node registration did not return X-BetterNAS-Node-Token" >&2 +if [[ -z "$export_id" ]]; then + echo "Node agent export did not appear in the control plane." >&2 exit 1 fi -heartbeat_status="$(curl -sS -o /dev/null -w '%{http_code}' \ - -X POST \ - -H 'Content-Type: application/json' \ - -H "Authorization: Bearer ${node_token}" \ - -d "{\"nodeId\":\"${node_id}\",\"status\":\"online\",\"lastSeenAt\":\"2026-01-01T00:00:00Z\"}" \ - "http://localhost:${BETTERNAS_CONTROL_PLANE_PORT}/api/v1/nodes/${node_id}/heartbeat")" -if [[ "$heartbeat_status" != "204" ]]; then - echo "Heartbeat did not return 204" >&2 - exit 1 -fi - -exports_response="$(curl -fsS -H "Authorization: Bearer ${BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN}" "http://localhost:${BETTERNAS_CONTROL_PLANE_PORT}/api/v1/exports")" -export_id="$( - echo "$exports_response" | jq -er \ - --arg node_id "$node_id" \ - --arg export_path "$BETTERNAS_EXPORT_PATH" \ - 'map(select(.nasNodeId == $node_id and .path == $export_path)) | .[0].id' -)" - -mount_profile="$( +mount_profile="$({ curl -fsS \ -X POST \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer ${BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN}" \ - -d "{\"userId\":\"integration-user\",\"deviceId\":\"integration-device\",\"exportId\":\"${export_id}\"}" \ + -d "{\"exportId\":\"${export_id}\"}" \ "http://localhost:${BETTERNAS_CONTROL_PLANE_PORT}/api/v1/mount-profiles/issue" -)" -echo "$mount_profile" | jq -e --arg expected "$BETTERNAS_EXAMPLE_MOUNT_URL" '.protocol == "webdav" and .mountUrl == $expected' >/dev/null +})" +echo "$mount_profile" | jq -e --arg expected "$BETTERNAS_EXAMPLE_MOUNT_URL" '.protocol == "webdav" and .mountUrl == $expected and .credential.mode == "basic-auth"' >/dev/null -cloud_profile="$( +BETTERNAS_EXAMPLE_MOUNT_USERNAME="$(echo "$mount_profile" | jq -er '.credential.username')" +BETTERNAS_EXAMPLE_MOUNT_PASSWORD="$(echo "$mount_profile" | jq -er '.credential.password')" +export BETTERNAS_EXAMPLE_MOUNT_USERNAME +export BETTERNAS_EXAMPLE_MOUNT_PASSWORD +"$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/verify-webdav" + +cloud_profile="$({ curl -fsS \ -X POST \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer ${BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN}" \ -d "{\"userId\":\"integration-user\",\"exportId\":\"${export_id}\",\"provider\":\"nextcloud\"}" \ "http://localhost:${BETTERNAS_CONTROL_PLANE_PORT}/api/v1/cloud-profiles/issue" -)" +})" echo "$cloud_profile" | jq -e --arg expected "$NEXTCLOUD_BASE_URL" '.provider == "nextcloud" and .baseUrl == $expected' >/dev/null echo "$cloud_profile" | jq -e --arg expected "/apps/betternascontrolplane/exports/${export_id}" '.path == $expected' >/dev/null nextcloud_status="$(curl -fsS "${NEXTCLOUD_BASE_URL}/status.php")" echo "$nextcloud_status" | jq -e '.installed == true' >/dev/null -nextcloud_app_status="$( +nextcloud_app_status="$({ curl -fsS \ -u "${NEXTCLOUD_ADMIN_USER}:${NEXTCLOUD_ADMIN_PASSWORD}" \ -H 'OCS-APIRequest: true' \ "${NEXTCLOUD_BASE_URL}/ocs/v2.php/apps/betternascontrolplane/api/status?format=json" -)" +})" echo "$nextcloud_app_status" | jq -e '.ocs.meta.statuscode == 200' >/dev/null echo "Stack verified for ${BETTERNAS_CLONE_NAME}." diff --git a/scripts/integration/verify-webdav b/scripts/integration/verify-webdav index 70b8355..4927857 100755 --- a/scripts/integration/verify-webdav +++ b/scripts/integration/verify-webdav @@ -8,7 +8,19 @@ source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../lib/runtime-env.sh" headers="$(mktemp)" trap 'rm -f "$headers"' EXIT -curl -fsS -D "$headers" -o /dev/null -X PROPFIND -H 'Depth: 0' "$BETTERNAS_EXAMPLE_MOUNT_URL" +curl_args=( + -fsS + -D "$headers" + -o /dev/null + -X PROPFIND + -H 'Depth: 0' +) + +if [[ -n "${BETTERNAS_EXAMPLE_MOUNT_USERNAME:-}" ]] || [[ -n "${BETTERNAS_EXAMPLE_MOUNT_PASSWORD:-}" ]]; then + curl_args+=(-u "${BETTERNAS_EXAMPLE_MOUNT_USERNAME:-}:${BETTERNAS_EXAMPLE_MOUNT_PASSWORD:-}") +fi + +curl "${curl_args[@]}" "$BETTERNAS_EXAMPLE_MOUNT_URL" if ! grep -Eq '^HTTP/[0-9.]+ 207' "$headers"; then echo "WebDAV PROPFIND did not return 207 for $BETTERNAS_EXAMPLE_MOUNT_URL" >&2 diff --git a/scripts/lib/agent-env.sh b/scripts/lib/agent-env.sh index ab43cb6..3ad36ce 100644 --- a/scripts/lib/agent-env.sh +++ b/scripts/lib/agent-env.sh @@ -132,6 +132,10 @@ betternas_write_agent_env_file() { betternas_write_env_assignment "BETTERNAS_VERSION" "local-dev" betternas_write_env_assignment "BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN" "${clone_name}-local-client-token" betternas_write_env_assignment "BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN" "${clone_name}-local-node-bootstrap-token" + betternas_write_env_assignment "BETTERNAS_DAV_AUTH_SECRET" "${clone_name}-local-dav-auth-secret" + betternas_write_env_assignment "BETTERNAS_DAV_CREDENTIAL_TTL" "1h" + betternas_write_env_assignment "BETTERNAS_NODE_MACHINE_ID" "${clone_name}-node" + betternas_write_env_assignment "BETTERNAS_NODE_DISPLAY_NAME" "${clone_name} node" betternas_write_env_assignment "BETTERNAS_NODE_DIRECT_ADDRESS" "http://localhost:${node_agent_port}" betternas_write_env_assignment "BETTERNAS_EXAMPLE_MOUNT_URL" "http://localhost:${node_agent_port}/dav/" betternas_write_env_assignment "NEXTCLOUD_BASE_URL" "http://localhost:${nextcloud_port}" diff --git a/scripts/lib/runtime-env.sh b/scripts/lib/runtime-env.sh index 0bbe9a1..8f31766 100755 --- a/scripts/lib/runtime-env.sh +++ b/scripts/lib/runtime-env.sh @@ -33,6 +33,10 @@ read -r default_nextcloud_port default_node_agent_port default_control_plane_por : "${BETTERNAS_VERSION:=local-dev}" : "${BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN:=${BETTERNAS_CLONE_NAME}-local-client-token}" : "${BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN:=${BETTERNAS_CLONE_NAME}-local-node-bootstrap-token}" +: "${BETTERNAS_DAV_AUTH_SECRET:=${BETTERNAS_CLONE_NAME}-local-dav-auth-secret}" +: "${BETTERNAS_DAV_CREDENTIAL_TTL:=1h}" +: "${BETTERNAS_NODE_MACHINE_ID:=${BETTERNAS_CLONE_NAME}-node}" +: "${BETTERNAS_NODE_DISPLAY_NAME:=${BETTERNAS_CLONE_NAME} node}" : "${NEXTCLOUD_ADMIN_USER:=admin}" : "${NEXTCLOUD_ADMIN_PASSWORD:=admin}" @@ -60,6 +64,10 @@ export BETTERNAS_EXPORT_PATH export BETTERNAS_VERSION export BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN export BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN +export BETTERNAS_DAV_AUTH_SECRET +export BETTERNAS_DAV_CREDENTIAL_TTL +export BETTERNAS_NODE_MACHINE_ID +export BETTERNAS_NODE_DISPLAY_NAME export NEXTCLOUD_ADMIN_USER export NEXTCLOUD_ADMIN_PASSWORD export BETTERNAS_NODE_DIRECT_ADDRESS