From 1bbfb6647df7aac473e583fc36beeca964b43478 Mon Sep 17 00:00:00 2001 From: Hari <73809867+harivansh-afk@users.noreply.github.com> Date: Wed, 1 Apr 2026 20:26:44 -0400 Subject: [PATCH] user-owned DAVs (#14) --- .env.agent.example | 4 +- .github/workflows/ci.yml | 36 ++++ .github/workflows/release.yml | 32 ++++ .goreleaser.yaml | 30 ++++ CLAUDE.md | 17 ++ apps/control-plane/README.md | 12 +- apps/control-plane/cmd/control-plane/app.go | 23 +-- .../cmd/control-plane/auth_test.go | 16 +- apps/control-plane/cmd/control-plane/main.go | 53 +++--- .../cmd/control-plane/mount_credentials.go | 87 +--------- .../control-plane/runtime_integration_test.go | 84 +++++----- .../control-plane/cmd/control-plane/server.go | 156 ++++++------------ .../cmd/control-plane/server_test.go | 106 ++++++++---- .../cmd/control-plane/sqlite_store.go | 119 ++++++++----- .../cmd/control-plane/sqlite_store_test.go | 19 +-- apps/control-plane/cmd/control-plane/store.go | 55 +++--- .../cmd/control-plane/store_iface.go | 12 +- apps/node-agent/README.md | 5 +- apps/node-agent/cmd/node-agent/app.go | 66 ++++---- apps/node-agent/cmd/node-agent/app_test.go | 116 ++++--------- .../cmd/node-agent/control_plane.go | 143 +++++++--------- apps/node-agent/cmd/node-agent/dav_auth.go | 68 +------- apps/node-agent/package.json | 2 +- apps/web/README.md | 12 +- apps/web/app/login/page.tsx | 8 +- apps/web/app/page.tsx | 60 +++++-- apps/web/lib/api.ts | 10 +- docs/architecture.md | 6 +- infra/docker/compose.dev.yml | 15 +- packages/contracts/openapi/betternas.v1.yaml | 30 +--- packages/contracts/src/foundation.ts | 1 - scripts/integration/verify-stack | 30 +++- scripts/lib/agent-env.sh | 6 +- scripts/lib/runtime-env.sh | 12 +- .../plans/2026-04-01-production-deployment.md | 58 ++++++- 35 files changed, 732 insertions(+), 777 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .goreleaser.yaml diff --git a/.env.agent.example b/.env.agent.example index a87fca2..8ed6f7f 100644 --- a/.env.agent.example +++ b/.env.agent.example @@ -7,8 +7,8 @@ BETTERNAS_NODE_AGENT_PORT= BETTERNAS_NEXTCLOUD_PORT= BETTERNAS_EXPORT_PATH= BETTERNAS_VERSION=local-dev -BETTERNAS_DAV_AUTH_SECRET= -BETTERNAS_DAV_CREDENTIAL_TTL= +BETTERNAS_USERNAME= +BETTERNAS_PASSWORD= BETTERNAS_NODE_MACHINE_ID= BETTERNAS_NODE_DISPLAY_NAME= BETTERNAS_NODE_DIRECT_ADDRESS= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ee5102a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: ci + +on: + pull_request: + push: + branches: + - main + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.33.0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.26.0" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Verify + run: pnpm verify diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d06bbba --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,32 @@ +name: release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.26.0" + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..365b90a --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,30 @@ +version: 2 + +project_name: betternas-node + +monorepo: + tag_prefix: v + +builds: + - id: betternas-node + main: ./apps/node-agent/cmd/node-agent + binary: betternas-node + env: + - CGO_ENABLED=0 + goos: + - darwin + - linux + goarch: + - amd64 + - arm64 + ldflags: + - -s -w + +archives: + - id: betternas-node + builds: + - betternas-node + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + +changelog: + use: git diff --git a/CLAUDE.md b/CLAUDE.md index a36de20..951a376 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,3 +44,20 @@ - Keep `node-service` limited to the WebDAV mount surface. - Route admin and control actions through `control-server`, not directly from browsers to `node-service`. + +## User-scoped auth requirements + +- Remove the bootstrap token flow for v1. +- Use a single user-provided username and password across the entire stack: + - `apps/node-agent` authenticates with the user's username and password from environment variables + - web app sessions authenticate with the same username and password + - WebDAV and Finder authentication use the same username and password +- Do not generate separate WebDAV credentials for users. +- Nodes and exports must be owned by users and scoped so authenticated users can only view and mount their own resources. +- Package the node binary for user download and distribution. + +## V1 simplicity + +- Keep the implementation as simple as possible. +- Do not over-engineer the auth or distribution model for v1. +- Prefer the smallest change set that makes the product usable and distributable. diff --git a/apps/control-plane/README.md b/apps/control-plane/README.md index 9bfe221..a0bc77c 100644 --- a/apps/control-plane/README.md +++ b/apps/control-plane/README.md @@ -17,10 +17,10 @@ The request and response shapes must follow the contracts in [`packages/contracts`](../../packages/contracts). `/api/v1/*` endpoints require bearer auth. New nodes register with -`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 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 +the same username and password session that users use in the web app. +`BETTERNAS_USERNAME` and `BETTERNAS_PASSWORD` may be provided to seed a default +account for local or self-hosted setups. Nodes and exports are owned by users, +and mount profiles return the account username plus the mount URL so Finder can +authenticate with that same betterNAS password. 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 949d7f5..1aac5b0 100644 --- a/apps/control-plane/cmd/control-plane/app.go +++ b/apps/control-plane/cmd/control-plane/app.go @@ -1,8 +1,6 @@ package main import ( - "errors" - "strings" "time" ) @@ -11,10 +9,6 @@ type appConfig struct { nextcloudBaseURL string statePath string dbPath string - clientToken string - nodeBootstrapToken string - davAuthSecret string - davCredentialTTL time.Duration sessionTTL time.Duration registrationEnabled bool corsOrigin string @@ -28,21 +22,6 @@ type app struct { } func newApp(config appConfig, startedAt time.Time) (*app, error) { - config.clientToken = strings.TrimSpace(config.clientToken) - - config.nodeBootstrapToken = strings.TrimSpace(config.nodeBootstrapToken) - if config.nodeBootstrapToken == "" { - 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") - } - var s store var err error if config.dbPath != "" { @@ -91,6 +70,7 @@ type nasNode struct { LastSeenAt string `json:"lastSeenAt"` DirectAddress *string `json:"directAddress"` RelayAddress *string `json:"relayAddress"` + OwnerID string `json:"-"` } type storageExport struct { @@ -102,6 +82,7 @@ type storageExport struct { Protocols []string `json:"protocols"` CapacityBytes *int64 `json:"capacityBytes"` Tags []string `json:"tags"` + OwnerID string `json:"-"` } type mountProfile struct { diff --git a/apps/control-plane/cmd/control-plane/auth_test.go b/apps/control-plane/cmd/control-plane/auth_test.go index 0fa9262..674fcce 100644 --- a/apps/control-plane/cmd/control-plane/auth_test.go +++ b/apps/control-plane/cmd/control-plane/auth_test.go @@ -196,21 +196,25 @@ func TestAuthSessionUsedForClientEndpoints(t *testing.T) { } } -func TestAuthStaticTokenFallback(t *testing.T) { +func TestAuthSessionIsTheOnlyClientAuthPath(t *testing.T) { t.Parallel() _, server := newTestSQLiteApp(t, appConfig{ - version: "test-version", - clientToken: "static-fallback-token", + version: "test-version", + registrationEnabled: true, }) defer server.Close() - // Static token should work for client endpoints. - exports := getJSONAuth[[]storageExport](t, server.Client(), "static-fallback-token", server.URL+"/api/v1/exports") + reg := postJSONAuthCreated[authLoginResponse](t, server.Client(), "", server.URL+"/api/v1/auth/register", authRegisterRequest{ + Username: "sessiononly", + Password: "password123", + }) + + exports := getJSONAuth[[]storageExport](t, server.Client(), reg.Token, server.URL+"/api/v1/exports") if exports == nil { t.Fatal("expected exports list, got nil") } - // Wrong token should fail. + getStatusWithAuth(t, server.Client(), "static-fallback-token", server.URL+"/api/v1/exports", http.StatusUnauthorized) getStatusWithAuth(t, server.Client(), "wrong", server.URL+"/api/v1/exports", http.StatusUnauthorized) } diff --git a/apps/control-plane/cmd/control-plane/main.go b/apps/control-plane/cmd/control-plane/main.go index 82e0f0b..d89196e 100644 --- a/apps/control-plane/cmd/control-plane/main.go +++ b/apps/control-plane/cmd/control-plane/main.go @@ -1,6 +1,7 @@ package main import ( + "errors" "log" "net/http" "strings" @@ -25,44 +26,52 @@ func main() { } func newAppFromEnv(startedAt time.Time) (*app, error) { - nodeBootstrapToken, err := requiredEnv("BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN") - if err != nil { - 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 - } - var sessionTTL time.Duration rawSessionTTL := strings.TrimSpace(env("BETTERNAS_SESSION_TTL", "720h")) if rawSessionTTL != "" { - sessionTTL, err = time.ParseDuration(rawSessionTTL) + parsedSessionTTL, err := time.ParseDuration(rawSessionTTL) if err != nil { return nil, err } + sessionTTL = parsedSessionTTL } - return newApp( + app, err := newApp( appConfig{ version: env("BETTERNAS_VERSION", "0.1.0-dev"), nextcloudBaseURL: env("NEXTCLOUD_BASE_URL", ""), statePath: env("BETTERNAS_CONTROL_PLANE_STATE_PATH", ".state/control-plane/state.json"), - dbPath: env("BETTERNAS_CONTROL_PLANE_DB_PATH", ""), - clientToken: env("BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN", ""), - nodeBootstrapToken: nodeBootstrapToken, - davAuthSecret: davAuthSecret, - davCredentialTTL: davCredentialTTL, + dbPath: env("BETTERNAS_CONTROL_PLANE_DB_PATH", ".state/control-plane/betternas.db"), sessionTTL: sessionTTL, registrationEnabled: env("BETTERNAS_REGISTRATION_ENABLED", "true") == "true", corsOrigin: env("BETTERNAS_CORS_ORIGIN", ""), }, startedAt, ) + if err != nil { + return nil, err + } + if err := seedDefaultUserFromEnv(app); err != nil { + return nil, err + } + + return app, nil +} + +func seedDefaultUserFromEnv(app *app) error { + username := strings.TrimSpace(env("BETTERNAS_USERNAME", "")) + password := env("BETTERNAS_PASSWORD", "") + if username == "" || password == "" { + return nil + } + + if _, err := app.store.createUser(username, password); err != nil { + if errors.Is(err, errUsernameTaken) { + _, authErr := app.store.authenticateUser(username, password) + return authErr + } + return err + } + + return nil } diff --git a/apps/control-plane/cmd/control-plane/mount_credentials.go b/apps/control-plane/cmd/control-plane/mount_credentials.go index c7b79ee..2e6b784 100644 --- a/apps/control-plane/cmd/control-plane/mount_credentials.go +++ b/apps/control-plane/cmd/control-plane/mount_credentials.go @@ -1,89 +1,12 @@ package main -import ( - "crypto/hmac" - "crypto/rand" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "fmt" - "time" -) - const mountCredentialModeBasicAuth = "basic-auth" -// mountCredentialUsernameTokenBytes controls the random token size in mount -// credential usernames (e.g. "mount-"). The username is also embedded -// inside the signed password payload, so longer tokens produce longer -// passwords. macOS WebDAVFS truncates Basic Auth passwords at 255 bytes, -// which corrupts the HMAC signature and causes auth failures. 24 bytes -// (32 base64url chars) keeps the total password under 250 characters with -// margin for longer node IDs and mount paths. -const mountCredentialUsernameTokenBytes = 24 - -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 := newMountCredentialUsernameToken() - 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{ +func buildAccountMountCredential(username string) mountCredential { + return mountCredential{ Mode: mountCredentialModeBasicAuth, - Username: claims.Username, - Password: password, - ExpiresAt: claims.ExpiresAt, - }, nil -} - -func newMountCredentialUsernameToken() (string, error) { - raw := make([]byte, mountCredentialUsernameTokenBytes) - if _, err := rand.Read(raw); err != nil { - return "", fmt.Errorf("generate mount credential username token: %w", err) + Username: username, + Password: "", + ExpiresAt: "", } - return base64.RawURLEncoding.EncodeToString(raw), 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 4aab408..0fb13ac 100644 --- a/apps/control-plane/cmd/control-plane/runtime_integration_test.go +++ b/apps/control-plane/cmd/control-plane/runtime_integration_test.go @@ -31,10 +31,7 @@ var ( nodeAgentBinaryErr error ) -const ( - runtimeDAVAuthSecret = "runtime-dav-auth-secret" - runtimeDAVCredentialTTL = "1h" -) +const runtimeUsername = "runtime-user" func TestControlPlaneBinaryMountLoopIntegration(t *testing.T) { exportDir := t.TempDir() @@ -47,7 +44,7 @@ func TestControlPlaneBinaryMountLoopIntegration(t *testing.T) { nodeAgent := startNodeAgentBinaryWithExports(t, controlPlane.baseURL, []string{exportDir}, "machine-runtime-1") client := &http.Client{Timeout: 2 * time.Second} - exports := waitForExportsByPath(t, client, controlPlane.baseURL+"/api/v1/exports", []string{exportDir}) + exports := waitForExportsByPath(t, client, controlPlane.sessionToken, 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) @@ -56,7 +53,7 @@ func TestControlPlaneBinaryMountLoopIntegration(t *testing.T) { t.Fatalf("expected mountPath %q, got %q", defaultWebDAVPath, export.MountPath) } - mount := postJSONAuth[mountProfile](t, client, testClientToken, controlPlane.baseURL+"/api/v1/mount-profiles/issue", mountProfileRequest{ + mount := postJSONAuth[mountProfile](t, client, controlPlane.sessionToken, controlPlane.baseURL+"/api/v1/mount-profiles/issue", mountProfileRequest{ ExportID: export.ID, }) if mount.MountURL != nodeAgent.baseURL+defaultWebDAVPath { @@ -66,11 +63,11 @@ func TestControlPlaneBinaryMountLoopIntegration(t *testing.T) { t.Fatalf("expected mount credential mode %q, got %q", mountCredentialModeBasicAuth, mount.Credential.Mode) } - 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") + assertHTTPStatusWithBasicAuth(t, client, "PROPFIND", mount.MountURL, controlPlane.username, controlPlane.password, http.StatusMultiStatus) + assertMountedFileContentsWithBasicAuth(t, client, mount.MountURL+"README.txt", controlPlane.username, controlPlane.password, "betterNAS export\n") - cloud := postJSONAuth[cloudProfile](t, client, testClientToken, controlPlane.baseURL+"/api/v1/cloud-profiles/issue", cloudProfileRequest{ - UserID: "runtime-user", + cloud := postJSONAuth[cloudProfile](t, client, controlPlane.sessionToken, controlPlane.baseURL+"/api/v1/cloud-profiles/issue", cloudProfileRequest{ + UserID: controlPlane.userID, ExportID: export.ID, Provider: "nextcloud", }) @@ -97,12 +94,12 @@ func TestControlPlaneBinaryMultiExportProfilesStayDistinct(t *testing.T) { firstMountPath := nodeAgentMountPathForExport(firstExportDir, 2) secondMountPath := nodeAgentMountPathForExport(secondExportDir, 2) - exports := waitForExportsByPath(t, client, controlPlane.baseURL+"/api/v1/exports", []string{firstExportDir, secondExportDir}) + exports := waitForExportsByPath(t, client, controlPlane.sessionToken, 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{ExportID: firstExport.ID}) - secondMount := postJSONAuth[mountProfile](t, client, testClientToken, controlPlane.baseURL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: secondExport.ID}) + firstMount := postJSONAuth[mountProfile](t, client, controlPlane.sessionToken, controlPlane.baseURL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: firstExport.ID}) + secondMount := postJSONAuth[mountProfile](t, client, controlPlane.sessionToken, 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) } @@ -113,18 +110,18 @@ func TestControlPlaneBinaryMultiExportProfilesStayDistinct(t *testing.T) { t.Fatalf("expected second runtime mount URL %q, got %q", nodeAgent.baseURL+secondMountPath, secondMount.MountURL) } - 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") + assertHTTPStatusWithBasicAuth(t, client, "PROPFIND", firstMount.MountURL, controlPlane.username, controlPlane.password, http.StatusMultiStatus) + assertHTTPStatusWithBasicAuth(t, client, "PROPFIND", secondMount.MountURL, controlPlane.username, controlPlane.password, http.StatusMultiStatus) + assertMountedFileContentsWithBasicAuth(t, client, firstMount.MountURL+"README.txt", controlPlane.username, controlPlane.password, "first runtime export\n") + assertMountedFileContentsWithBasicAuth(t, client, secondMount.MountURL+"README.txt", controlPlane.username, controlPlane.password, "second runtime export\n") - firstCloud := postJSONAuth[cloudProfile](t, client, testClientToken, controlPlane.baseURL+"/api/v1/cloud-profiles/issue", cloudProfileRequest{ - UserID: "runtime-user", + firstCloud := postJSONAuth[cloudProfile](t, client, controlPlane.sessionToken, controlPlane.baseURL+"/api/v1/cloud-profiles/issue", cloudProfileRequest{ + UserID: controlPlane.userID, ExportID: firstExport.ID, Provider: "nextcloud", }) - secondCloud := postJSONAuth[cloudProfile](t, client, testClientToken, controlPlane.baseURL+"/api/v1/cloud-profiles/issue", cloudProfileRequest{ - UserID: "runtime-user", + secondCloud := postJSONAuth[cloudProfile](t, client, controlPlane.sessionToken, controlPlane.baseURL+"/api/v1/cloud-profiles/issue", cloudProfileRequest{ + UserID: controlPlane.userID, ExportID: secondExport.ID, Provider: "nextcloud", }) @@ -140,8 +137,12 @@ func TestControlPlaneBinaryMultiExportProfilesStayDistinct(t *testing.T) { } type runningBinary struct { - baseURL string - logPath string + baseURL string + logPath string + sessionToken string + username string + password string + userID string } func startControlPlaneBinary(t *testing.T, version string, nextcloudBaseURL string) runningBinary { @@ -149,7 +150,7 @@ func startControlPlaneBinary(t *testing.T, version string, nextcloudBaseURL stri port := reserveTCPPort(t) logPath := filepath.Join(t.TempDir(), "control-plane.log") - statePath := filepath.Join(t.TempDir(), "control-plane-state.json") + dbPath := filepath.Join(t.TempDir(), "control-plane.db") logFile, err := os.Create(logPath) if err != nil { t.Fatalf("create control-plane log file: %v", err) @@ -162,11 +163,8 @@ func startControlPlaneBinary(t *testing.T, version string, nextcloudBaseURL stri "PORT="+port, "BETTERNAS_VERSION="+version, "NEXTCLOUD_BASE_URL="+nextcloudBaseURL, - "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, + "BETTERNAS_CONTROL_PLANE_DB_PATH="+dbPath, + "BETTERNAS_REGISTRATION_ENABLED=true", ) cmd.Stdout = logFile cmd.Stderr = logFile @@ -183,11 +181,16 @@ func startControlPlaneBinary(t *testing.T, version string, nextcloudBaseURL stri baseURL := fmt.Sprintf("http://127.0.0.1:%s", port) waitForHTTPStatus(t, baseURL+"/health", waitDone, logPath, http.StatusOK) + session := registerRuntimeUser(t, &http.Client{Timeout: 2 * time.Second}, baseURL) registerProcessCleanup(t, ctx, cancel, cmd, waitDone, logFile, logPath, "control-plane") return runningBinary{ - baseURL: baseURL, - logPath: logPath, + baseURL: baseURL, + logPath: logPath, + sessionToken: session.Token, + username: runtimeUsername, + password: testPassword, + userID: session.User.ID, } } @@ -197,7 +200,6 @@ func startNodeAgentBinaryWithExports(t *testing.T, controlPlaneBaseURL string, e 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) @@ -215,12 +217,11 @@ func startNodeAgentBinaryWithExports(t *testing.T, controlPlaneBaseURL string, e "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_USERNAME="+runtimeUsername, + "BETTERNAS_PASSWORD="+testPassword, "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 @@ -245,12 +246,12 @@ func startNodeAgentBinaryWithExports(t *testing.T, controlPlaneBaseURL string, e } } -func waitForExportsByPath(t *testing.T, client *http.Client, endpoint string, expectedPaths []string) map[string]storageExport { +func waitForExportsByPath(t *testing.T, client *http.Client, token string, 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) + exports := getJSONAuth[[]storageExport](t, client, token, endpoint) exportsByPath := exportsByPath(exports) allPresent := true for _, expectedPath := range expectedPaths { @@ -269,6 +270,15 @@ func waitForExportsByPath(t *testing.T, client *http.Client, endpoint string, ex return nil } +func registerRuntimeUser(t *testing.T, client *http.Client, baseURL string) authLoginResponse { + t.Helper() + + return postJSONAuthCreated[authLoginResponse](t, client, "", baseURL+"/api/v1/auth/register", authRegisterRequest{ + Username: runtimeUsername, + Password: testPassword, + }) +} + func buildControlPlaneBinary(t *testing.T) string { t.Helper() diff --git a/apps/control-plane/cmd/control-plane/server.go b/apps/control-plane/cmd/control-plane/server.go index e54f0be..810dbb0 100644 --- a/apps/control-plane/cmd/control-plane/server.go +++ b/apps/control-plane/cmd/control-plane/server.go @@ -2,7 +2,6 @@ package main import ( "bytes" - "crypto/subtle" "encoding/json" "errors" "fmt" @@ -21,12 +20,12 @@ var ( errMountTargetUnavailable = errors.New("mount target is not available") errNodeIDMismatch = errors.New("node id path and body must match") errNodeNotFound = errors.New("node not found") + errNodeOwnedByAnotherUser = errors.New("node is already owned by another user") ) const ( - authorizationHeader = "Authorization" - controlPlaneNodeTokenKey = "X-BetterNAS-Node-Token" - bearerScheme = "Bearer" + authorizationHeader = "Authorization" + bearerScheme = "Bearer" ) func (a *app) handler() http.Handler { @@ -76,6 +75,11 @@ func (a *app) handleVersion(w http.ResponseWriter, _ *http.Request) { } func (a *app) handleNodeRegister(w http.ResponseWriter, r *http.Request) { + currentUser, ok := a.requireSessionUser(w, r) + if !ok { + return + } + request, err := decodeNodeRegistrationRequest(w, r) if err != nil { writeDecodeError(w, err) @@ -87,23 +91,25 @@ func (a *app) handleNodeRegister(w http.ResponseWriter, r *http.Request) { return } - if !a.authorizeNodeRegistration(w, r, request.MachineID) { - return - } - - result, err := a.store.registerNode(request, a.now()) + result, err := a.store.registerNode(currentUser.ID, request, a.now()) if err != nil { + if errors.Is(err, errNodeOwnedByAnotherUser) { + http.Error(w, err.Error(), http.StatusConflict) + return + } http.Error(w, err.Error(), http.StatusInternalServerError) return } - if result.IssuedNodeToken != "" { - w.Header().Set(controlPlaneNodeTokenKey, result.IssuedNodeToken) - } writeJSON(w, http.StatusOK, result.Node) } func (a *app) handleNodeHeartbeat(w http.ResponseWriter, r *http.Request) { + currentUser, ok := a.requireSessionUser(w, r) + if !ok { + return + } + nodeID := r.PathValue("nodeId") var request nodeHeartbeatRequest @@ -121,11 +127,7 @@ func (a *app) handleNodeHeartbeat(w http.ResponseWriter, r *http.Request) { return } - if !a.authorizeNode(w, r, nodeID) { - return - } - - if err := a.store.recordHeartbeat(nodeID, request); err != nil { + if err := a.store.recordHeartbeat(nodeID, currentUser.ID, request); err != nil { statusCode := http.StatusInternalServerError if errors.Is(err, errNodeNotFound) { statusCode = http.StatusNotFound @@ -138,6 +140,11 @@ func (a *app) handleNodeHeartbeat(w http.ResponseWriter, r *http.Request) { } func (a *app) handleNodeExports(w http.ResponseWriter, r *http.Request) { + currentUser, ok := a.requireSessionUser(w, r) + if !ok { + return + } + nodeID := r.PathValue("nodeId") request, err := decodeNodeExportsRequest(w, r) @@ -151,11 +158,7 @@ func (a *app) handleNodeExports(w http.ResponseWriter, r *http.Request) { return } - if !a.authorizeNode(w, r, nodeID) { - return - } - - exports, err := a.store.upsertExports(nodeID, request) + exports, err := a.store.upsertExports(nodeID, currentUser.ID, request) if err != nil { statusCode := http.StatusInternalServerError if errors.Is(err, errNodeNotFound) { @@ -169,15 +172,17 @@ func (a *app) handleNodeExports(w http.ResponseWriter, r *http.Request) { } func (a *app) handleExportsList(w http.ResponseWriter, r *http.Request) { - if !a.requireClientAuth(w, r) { + currentUser, ok := a.requireSessionUser(w, r) + if !ok { return } - writeJSON(w, http.StatusOK, a.store.listExports()) + writeJSON(w, http.StatusOK, a.store.listExports(currentUser.ID)) } func (a *app) handleMountProfileIssue(w http.ResponseWriter, r *http.Request) { - if !a.requireClientAuth(w, r) { + currentUser, ok := a.requireSessionUser(w, r) + if !ok { return } @@ -192,8 +197,8 @@ func (a *app) handleMountProfileIssue(w http.ResponseWriter, r *http.Request) { return } - context, ok := a.store.exportContext(request.ExportID) - if !ok { + context, found := a.store.exportContext(request.ExportID, currentUser.ID) + if !found { http.Error(w, errExportNotFound.Error(), http.StatusNotFound) return } @@ -204,32 +209,20 @@ 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: credentialID, + ID: context.export.ID, ExportID: context.export.ID, Protocol: "webdav", DisplayName: context.export.Label, MountURL: mountURL, Readonly: false, - Credential: credential, + Credential: buildAccountMountCredential(currentUser.Username), }) } func (a *app) handleCloudProfileIssue(w http.ResponseWriter, r *http.Request) { - if !a.requireClientAuth(w, r) { + currentUser, ok := a.requireSessionUser(w, r) + if !ok { return } @@ -244,8 +237,8 @@ func (a *app) handleCloudProfileIssue(w http.ResponseWriter, r *http.Request) { return } - context, ok := a.store.exportContext(request.ExportID) - if !ok { + context, found := a.store.exportContext(request.ExportID, currentUser.ID) + if !found { http.Error(w, errExportNotFound.Error(), http.StatusNotFound) return } @@ -257,7 +250,7 @@ func (a *app) handleCloudProfileIssue(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, cloudProfile{ - ID: fmt.Sprintf("cloud-%s-%s", request.UserID, context.export.ID), + ID: fmt.Sprintf("cloud-%s-%s", currentUser.ID, context.export.ID), ExportID: context.export.ID, Provider: "nextcloud", BaseURL: baseURL, @@ -1034,71 +1027,22 @@ func corsMiddleware(allowedOrigin string, next http.Handler) http.Handler { }) } -// --- client auth --- +// --- session auth --- -func (a *app) requireClientAuth(w http.ResponseWriter, r *http.Request) bool { +func (a *app) requireSessionUser(w http.ResponseWriter, r *http.Request) (user, bool) { presentedToken, ok := bearerToken(r) if !ok { writeUnauthorized(w) - return false + return user{}, false } - // Session-based auth (SQLite). - if _, err := a.store.validateSession(presentedToken); err == nil { - return true - } - - // Fall back to static client token for backwards compatibility. - if a.config.clientToken != "" && secureStringEquals(a.config.clientToken, presentedToken) { - return true - } - - writeUnauthorized(w) - return false -} - -func (a *app) authorizeNodeRegistration(w http.ResponseWriter, r *http.Request, machineID string) bool { - presentedToken, ok := bearerToken(r) - if !ok { + currentUser, err := a.store.validateSession(presentedToken) + if err != nil { writeUnauthorized(w) - return false + return user{}, false } - authState, exists := a.store.nodeAuthByMachineID(machineID) - if !exists || strings.TrimSpace(authState.TokenHash) == "" { - if !secureStringEquals(a.config.nodeBootstrapToken, presentedToken) { - writeUnauthorized(w) - return false - } - return true - } - - if !tokenHashMatches(authState.TokenHash, presentedToken) { - writeUnauthorized(w) - return false - } - - return true -} - -func (a *app) authorizeNode(w http.ResponseWriter, r *http.Request, nodeID string) bool { - presentedToken, ok := bearerToken(r) - if !ok { - writeUnauthorized(w) - return false - } - - authState, exists := a.store.nodeAuthByID(nodeID) - if !exists { - http.Error(w, errNodeNotFound.Error(), http.StatusNotFound) - return false - } - if strings.TrimSpace(authState.TokenHash) == "" || !tokenHashMatches(authState.TokenHash, presentedToken) { - writeUnauthorized(w) - return false - } - - return true + return currentUser, true } func bearerToken(r *http.Request) (string, bool) { @@ -1124,11 +1068,3 @@ func writeUnauthorized(w http.ResponseWriter) { w.Header().Set("WWW-Authenticate", bearerScheme) http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) } - -func secureStringEquals(expected string, actual string) bool { - return subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) == 1 -} - -func tokenHashMatches(expectedHash string, token string) bool { - return secureStringEquals(expectedHash, hashOpaqueToken(token)) -} diff --git a/apps/control-plane/cmd/control-plane/server_test.go b/apps/control-plane/cmd/control-plane/server_test.go index ec8c600..adf5741 100644 --- a/apps/control-plane/cmd/control-plane/server_test.go +++ b/apps/control-plane/cmd/control-plane/server_test.go @@ -3,6 +3,7 @@ package main import ( "bytes" "encoding/json" + "errors" "io" "net/http" "net/http/httptest" @@ -15,8 +16,9 @@ import ( var testControlPlaneNow = time.Date(2025, time.January, 1, 12, 0, 0, 0, time.UTC) const ( + testPassword = "password123" testClientToken = "test-client-token" - testNodeBootstrapToken = "test-node-bootstrap-token" + testNodeBootstrapToken = "test-node-session-token" ) type registeredNode struct { @@ -94,7 +96,7 @@ func TestControlPlaneRegistrationProfilesAndHeartbeat(t *testing.T) { RelayAddress: &relayAddress, }) if registration.NodeToken == "" { - t.Fatal("expected node registration to return a node token") + t.Fatal("expected node registration to preserve the session token") } syncedExports := syncNodeExports(t, server.Client(), registration.NodeToken, server.URL+"/api/v1/nodes/"+registration.Node.ID+"/exports", nodeExportsRequest{ @@ -169,14 +171,14 @@ func TestControlPlaneRegistrationProfilesAndHeartbeat(t *testing.T) { 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.Username != "fixture" { + t.Fatalf("expected mount credential username %q, got %q", "fixture", mount.Credential.Username) } - if mount.Credential.Password == "" { - t.Fatal("expected mount credential password to be set") + if mount.Credential.Password != "" { + t.Fatalf("expected mount credential password to be blank, got %q", mount.Credential.Password) } - if mount.Credential.ExpiresAt == "" { - t.Fatal("expected mount credential expiry to be set") + if mount.Credential.ExpiresAt != "" { + t.Fatalf("expected mount credential expiry to be blank, got %q", mount.Credential.ExpiresAt) } cloud := postJSONAuth[cloudProfile](t, server.Client(), testClientToken, server.URL+"/api/v1/cloud-profiles/issue", cloudProfileRequest{ @@ -231,7 +233,7 @@ func TestControlPlaneExportSyncReconcilesExportsAndKeepsStableIDs(t *testing.T) RelayAddress: nil, }) - putJSONAuthStatus(t, server.Client(), testNodeBootstrapToken, server.URL+"/api/v1/nodes/"+firstRegistration.Node.ID+"/exports", nodeExportsRequest{ + putJSONAuthStatus(t, server.Client(), "wrong-session-token", server.URL+"/api/v1/nodes/"+firstRegistration.Node.ID+"/exports", nodeExportsRequest{ Exports: []storageExportInput{ { Label: "Docs", @@ -285,7 +287,7 @@ func TestControlPlaneExportSyncReconcilesExportsAndKeepsStableIDs(t *testing.T) RelayAddress: nil, }) - putJSONAuthStatus(t, server.Client(), testClientToken, server.URL+"/api/v1/nodes/"+firstRegistration.Node.ID+"/exports", nodeExportsRequest{ + putJSONAuthStatus(t, server.Client(), "wrong-session-token", server.URL+"/api/v1/nodes/"+firstRegistration.Node.ID+"/exports", nodeExportsRequest{ Exports: []storageExportInput{ { Label: "Docs v2", @@ -330,8 +332,8 @@ func TestControlPlaneExportSyncReconcilesExportsAndKeepsStableIDs(t *testing.T) if secondRegistration.Node.ID != firstRegistration.Node.ID { t.Fatalf("expected re-registration to keep node ID %q, got %q", firstRegistration.Node.ID, secondRegistration.Node.ID) } - if secondRegistration.NodeToken != "" { - t.Fatalf("expected re-registration to keep the existing node token, got %q", secondRegistration.NodeToken) + if secondRegistration.NodeToken != firstRegistration.NodeToken { + t.Fatalf("expected re-registration to keep the existing session token %q, got %q", firstRegistration.NodeToken, secondRegistration.NodeToken) } updatedExports := exportsByPath(getJSONAuth[[]storageExport](t, server.Client(), testClientToken, server.URL+"/api/v1/exports")) @@ -539,12 +541,12 @@ func TestControlPlaneCloudProfilesRequireConfiguredBaseURLAndExistingExport(t *t func TestControlPlanePersistsRegistryAcrossAppRestart(t *testing.T) { t.Parallel() - statePath := filepath.Join(t.TempDir(), "control-plane-state.json") + dbPath := filepath.Join(t.TempDir(), "control-plane.db") directAddress := "http://nas.local:8090" _, firstServer := newTestControlPlaneServer(t, appConfig{ - version: "test-version", - statePath: statePath, + version: "test-version", + dbPath: dbPath, }) registration := registerNode(t, firstServer.Client(), firstServer.URL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{ MachineID: "machine-persisted", @@ -566,8 +568,8 @@ func TestControlPlanePersistsRegistryAcrossAppRestart(t *testing.T) { firstServer.Close() _, secondServer := newTestControlPlaneServer(t, appConfig{ - version: "test-version", - statePath: statePath, + version: "test-version", + dbPath: dbPath, }) defer secondServer.Close() @@ -656,15 +658,12 @@ func TestControlPlaneRejectsInvalidRequestsAndEnforcesAuth(t *testing.T) { if err := json.NewDecoder(response.Body).Decode(&node); err != nil { t.Fatalf("decode registration response: %v", err) } - nodeToken := strings.TrimSpace(response.Header.Get(controlPlaneNodeTokenKey)) - if nodeToken == "" { - t.Fatal("expected node registration to return a node token") - } + nodeToken := testNodeBootstrapToken if node.ID != "dev-node" { 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{ + putJSONAuthStatus(t, server.Client(), "wrong-session-token", server.URL+"/api/v1/nodes/"+node.ID+"/exports", nodeExportsRequest{ Exports: []storageExportInput{{ Label: "Docs", Path: "/srv/docs", @@ -716,7 +715,7 @@ func TestControlPlaneRejectsInvalidRequestsAndEnforcesAuth(t *testing.T) { }, }, http.StatusBadRequest) - postJSONAuthStatus(t, server.Client(), testClientToken, server.URL+"/api/v1/nodes/"+node.ID+"/heartbeat", nodeHeartbeatRequest{ + postJSONAuthStatus(t, server.Client(), "wrong-session-token", server.URL+"/api/v1/nodes/"+node.ID+"/heartbeat", nodeHeartbeatRequest{ NodeID: node.ID, Status: "online", LastSeenAt: "2025-01-02T03:04:05Z", @@ -765,21 +764,12 @@ func TestControlPlaneRejectsInvalidRequestsAndEnforcesAuth(t *testing.T) { func newTestControlPlaneServer(t *testing.T, config appConfig) (*app, *httptest.Server) { t.Helper() + if config.dbPath == "" { + config.dbPath = filepath.Join(t.TempDir(), "test.db") + } if config.version == "" { config.version = "test-version" } - if config.clientToken == "" { - config.clientToken = testClientToken - } - 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 { @@ -788,11 +778,46 @@ func newTestControlPlaneServer(t *testing.T, config appConfig) (*app, *httptest. app.now = func() time.Time { return testControlPlaneNow } + seedDefaultSessionUser(t, app) server := httptest.NewServer(app.handler()) return app, server } +func seedDefaultSessionUser(t *testing.T, app *app) { + t.Helper() + + u, err := app.store.createUser("fixture", testPassword) + if err != nil && !errors.Is(err, errUsernameTaken) { + t.Fatalf("seed default test user: %v", err) + } + if errors.Is(err, errUsernameTaken) { + u, err = app.store.authenticateUser("fixture", testPassword) + if err != nil { + t.Fatalf("authenticate seeded test user: %v", err) + } + } + + sqliteStore, ok := app.store.(*sqliteStore) + if !ok { + return + } + + createdAt := time.Now().UTC().Format(time.RFC3339) + expiresAt := time.Now().UTC().Add(24 * time.Hour).Format(time.RFC3339) + for _, token := range []string{testClientToken, testNodeBootstrapToken} { + if _, err := sqliteStore.db.Exec( + "INSERT OR REPLACE INTO sessions (token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)", + token, + u.ID, + createdAt, + expiresAt, + ); err != nil { + t.Fatalf("seed session %s: %v", token, err) + } + } +} + func exportsByPath(exports []storageExport) map[string]storageExport { byPath := make(map[string]storageExport, len(exports)) for _, export := range exports { @@ -820,10 +845,19 @@ func registerNode(t *testing.T, client *http.Client, endpoint string, token stri return registeredNode{ Node: node, - NodeToken: strings.TrimSpace(response.Header.Get(controlPlaneNodeTokenKey)), + NodeToken: strings.TrimSpace(token), } } +func registerSessionUser(t *testing.T, client *http.Client, baseURL string, username string) authLoginResponse { + t.Helper() + + return postJSONAuthCreated[authLoginResponse](t, client, "", baseURL+"/api/v1/auth/register", authRegisterRequest{ + Username: username, + Password: testPassword, + }) +} + func syncNodeExports(t *testing.T, client *http.Client, token string, endpoint string, payload nodeExportsRequest) []storageExport { t.Helper() diff --git a/apps/control-plane/cmd/control-plane/sqlite_store.go b/apps/control-plane/cmd/control-plane/sqlite_store.go index e565e13..a0050ed 100644 --- a/apps/control-plane/cmd/control-plane/sqlite_store.go +++ b/apps/control-plane/cmd/control-plane/sqlite_store.go @@ -17,8 +17,8 @@ import ( ) var ( - errUsernameTaken = errors.New("username already taken") - errInvalidLogin = errors.New("invalid username or password") + errUsernameTaken = errors.New("username already taken") + errInvalidLogin = errors.New("invalid username or password") errSessionExpired = errors.New("session expired or invalid") ) @@ -32,6 +32,7 @@ INSERT OR IGNORE INTO ordinals (name, value) VALUES ('node', 0), ('export', 0); CREATE TABLE IF NOT EXISTS nodes ( id TEXT PRIMARY KEY, machine_id TEXT NOT NULL UNIQUE, + owner_id TEXT REFERENCES users(id), display_name TEXT NOT NULL DEFAULT '', agent_version TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'online', @@ -48,6 +49,7 @@ CREATE TABLE IF NOT EXISTS node_tokens ( CREATE TABLE IF NOT EXISTS exports ( id TEXT PRIMARY KEY, node_id TEXT NOT NULL REFERENCES nodes(id), + owner_id TEXT REFERENCES users(id), label TEXT NOT NULL DEFAULT '', path TEXT NOT NULL, mount_path TEXT NOT NULL DEFAULT '', @@ -101,10 +103,40 @@ func newSQLiteStore(dbPath string) (*sqliteStore, error) { db.Close() return nil, fmt.Errorf("initialize database schema: %w", err) } + if err := migrateSQLiteSchema(db); err != nil { + db.Close() + return nil, err + } return &sqliteStore{db: db}, nil } +func migrateSQLiteSchema(db *sql.DB) error { + migrations := []string{ + "ALTER TABLE nodes ADD COLUMN owner_id TEXT REFERENCES users(id)", + "ALTER TABLE exports ADD COLUMN owner_id TEXT REFERENCES users(id)", + } + for _, statement := range migrations { + if _, err := db.Exec(statement); err != nil && !strings.Contains(err.Error(), "duplicate column name") { + return fmt.Errorf("run sqlite migration %q: %w", statement, err) + } + } + + if _, err := db.Exec(` + UPDATE exports + SET owner_id = ( + SELECT owner_id + FROM nodes + WHERE nodes.id = exports.node_id + ) + WHERE owner_id IS NULL + `); err != nil { + return fmt.Errorf("backfill export owners: %w", err) + } + + return nil +} + func (s *sqliteStore) nextOrdinal(tx *sql.Tx, name string) (int, error) { var value int err := tx.QueryRow("UPDATE ordinals SET value = value + 1 WHERE name = ? RETURNING value", name).Scan(&value) @@ -128,7 +160,7 @@ func ordinalToExportID(ordinal int) string { return fmt.Sprintf("dev-export-%d", ordinal) } -func (s *sqliteStore) registerNode(request nodeRegistrationRequest, registeredAt time.Time) (nodeRegistrationResult, error) { +func (s *sqliteStore) registerNode(ownerID string, request nodeRegistrationRequest, registeredAt time.Time) (nodeRegistrationResult, error) { tx, err := s.db.Begin() if err != nil { return nodeRegistrationResult{}, fmt.Errorf("begin transaction: %w", err) @@ -137,7 +169,8 @@ func (s *sqliteStore) registerNode(request nodeRegistrationRequest, registeredAt // Check if machine already registered. var nodeID string - err = tx.QueryRow("SELECT id FROM nodes WHERE machine_id = ?", request.MachineID).Scan(&nodeID) + var existingOwnerID sql.NullString + err = tx.QueryRow("SELECT id, owner_id FROM nodes WHERE machine_id = ?", request.MachineID).Scan(&nodeID, &existingOwnerID) if err == sql.ErrNoRows { ordinal, err := s.nextOrdinal(tx, "node") if err != nil { @@ -146,57 +179,40 @@ func (s *sqliteStore) registerNode(request nodeRegistrationRequest, registeredAt nodeID = ordinalToNodeID(ordinal) } else if err != nil { return nodeRegistrationResult{}, fmt.Errorf("lookup node by machine_id: %w", err) + } else if existingOwnerID.Valid && strings.TrimSpace(existingOwnerID.String) != "" && existingOwnerID.String != ownerID { + return nodeRegistrationResult{}, errNodeOwnedByAnotherUser } // Upsert node. _, err = tx.Exec(` - INSERT INTO nodes (id, machine_id, display_name, agent_version, status, last_seen_at, direct_address, relay_address) - VALUES (?, ?, ?, ?, 'online', ?, ?, ?) + INSERT INTO nodes (id, machine_id, owner_id, display_name, agent_version, status, last_seen_at, direct_address, relay_address) + VALUES (?, ?, ?, ?, ?, 'online', ?, ?, ?) ON CONFLICT(id) DO UPDATE SET + owner_id = excluded.owner_id, display_name = excluded.display_name, agent_version = excluded.agent_version, status = 'online', last_seen_at = excluded.last_seen_at, direct_address = excluded.direct_address, relay_address = excluded.relay_address - `, nodeID, request.MachineID, request.DisplayName, request.AgentVersion, + `, nodeID, request.MachineID, ownerID, request.DisplayName, request.AgentVersion, registeredAt.UTC().Format(time.RFC3339), nullableString(request.DirectAddress), nullableString(request.RelayAddress)) if err != nil { return nodeRegistrationResult{}, fmt.Errorf("upsert node: %w", err) } - // Issue token if none exists. - var issuedNodeToken string - var existingHash sql.NullString - _ = tx.QueryRow("SELECT token_hash FROM node_tokens WHERE node_id = ?", nodeID).Scan(&existingHash) - - if !existingHash.Valid || strings.TrimSpace(existingHash.String) == "" { - nodeToken, err := newOpaqueToken() - if err != nil { - return nodeRegistrationResult{}, err - } - _, err = tx.Exec( - "INSERT OR REPLACE INTO node_tokens (node_id, token_hash) VALUES (?, ?)", - nodeID, hashOpaqueToken(nodeToken)) - if err != nil { - return nodeRegistrationResult{}, fmt.Errorf("store node token: %w", err) - } - issuedNodeToken = nodeToken - } - if err := tx.Commit(); err != nil { return nodeRegistrationResult{}, fmt.Errorf("commit registration: %w", err) } node, _ := s.nodeByID(nodeID) return nodeRegistrationResult{ - Node: node, - IssuedNodeToken: issuedNodeToken, + Node: node, }, nil } -func (s *sqliteStore) upsertExports(nodeID string, request nodeExportsRequest) ([]storageExport, error) { +func (s *sqliteStore) upsertExports(nodeID string, ownerID string, request nodeExportsRequest) ([]storageExport, error) { tx, err := s.db.Begin() if err != nil { return nil, fmt.Errorf("begin transaction: %w", err) @@ -205,7 +221,7 @@ func (s *sqliteStore) upsertExports(nodeID string, request nodeExportsRequest) ( // Verify node exists. var exists bool - err = tx.QueryRow("SELECT 1 FROM nodes WHERE id = ?", nodeID).Scan(&exists) + err = tx.QueryRow("SELECT 1 FROM nodes WHERE id = ? AND owner_id = ?", nodeID, ownerID).Scan(&exists) if err != nil { return nil, errNodeNotFound } @@ -238,13 +254,14 @@ func (s *sqliteStore) upsertExports(nodeID string, request nodeExportsRequest) ( } _, err = tx.Exec(` - INSERT INTO exports (id, node_id, label, path, mount_path, capacity_bytes) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO exports (id, node_id, owner_id, label, path, mount_path, capacity_bytes) + VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET + owner_id = excluded.owner_id, label = excluded.label, mount_path = excluded.mount_path, capacity_bytes = excluded.capacity_bytes - `, exportID, nodeID, input.Label, input.Path, input.MountPath, nullableInt64(input.CapacityBytes)) + `, exportID, nodeID, ownerID, input.Label, input.Path, input.MountPath, nullableInt64(input.CapacityBytes)) if err != nil { return nil, fmt.Errorf("upsert export %q: %w", input.Path, err) } @@ -288,10 +305,10 @@ func (s *sqliteStore) upsertExports(nodeID string, request nodeExportsRequest) ( return s.listExportsForNode(nodeID), nil } -func (s *sqliteStore) recordHeartbeat(nodeID string, request nodeHeartbeatRequest) error { +func (s *sqliteStore) recordHeartbeat(nodeID string, ownerID string, request nodeHeartbeatRequest) error { result, err := s.db.Exec( - "UPDATE nodes SET status = ?, last_seen_at = ? WHERE id = ?", - request.Status, request.LastSeenAt, nodeID) + "UPDATE nodes SET status = ?, last_seen_at = ? WHERE id = ? AND owner_id = ?", + request.Status, request.LastSeenAt, nodeID, ownerID) if err != nil { return fmt.Errorf("update heartbeat: %w", err) } @@ -302,8 +319,8 @@ func (s *sqliteStore) recordHeartbeat(nodeID string, request nodeHeartbeatReques return nil } -func (s *sqliteStore) listExports() []storageExport { - rows, err := s.db.Query("SELECT id, node_id, label, path, mount_path, capacity_bytes FROM exports ORDER BY id") +func (s *sqliteStore) listExports(ownerID string) []storageExport { + rows, err := s.db.Query("SELECT id, node_id, owner_id, label, path, mount_path, capacity_bytes FROM exports WHERE owner_id = ? ORDER BY id", ownerID) if err != nil { return nil } @@ -330,7 +347,7 @@ func (s *sqliteStore) listExports() []storageExport { } func (s *sqliteStore) listExportsForNode(nodeID string) []storageExport { - rows, err := s.db.Query("SELECT id, node_id, label, path, mount_path, capacity_bytes FROM exports WHERE node_id = ? ORDER BY id", nodeID) + rows, err := s.db.Query("SELECT id, node_id, owner_id, label, path, mount_path, capacity_bytes FROM exports WHERE node_id = ? ORDER BY id", nodeID) if err != nil { return nil } @@ -356,15 +373,19 @@ func (s *sqliteStore) listExportsForNode(nodeID string) []storageExport { return exports } -func (s *sqliteStore) exportContext(exportID string) (exportContext, bool) { +func (s *sqliteStore) exportContext(exportID string, ownerID string) (exportContext, bool) { var e storageExport var capacityBytes sql.NullInt64 + var exportOwnerID sql.NullString err := s.db.QueryRow( - "SELECT id, node_id, label, path, mount_path, capacity_bytes FROM exports WHERE id = ?", - exportID).Scan(&e.ID, &e.NasNodeID, &e.Label, &e.Path, &e.MountPath, &capacityBytes) + "SELECT id, node_id, owner_id, label, path, mount_path, capacity_bytes FROM exports WHERE id = ? AND owner_id = ?", + exportID, ownerID).Scan(&e.ID, &e.NasNodeID, &exportOwnerID, &e.Label, &e.Path, &e.MountPath, &capacityBytes) if err != nil { return exportContext{}, false } + if exportOwnerID.Valid { + e.OwnerID = exportOwnerID.String + } if capacityBytes.Valid { e.CapacityBytes = &capacityBytes.Int64 } @@ -383,12 +404,16 @@ func (s *sqliteStore) nodeByID(nodeID string) (nasNode, bool) { var n nasNode var directAddr, relayAddr sql.NullString var lastSeenAt sql.NullString + var ownerID sql.NullString err := s.db.QueryRow( - "SELECT id, machine_id, display_name, agent_version, status, last_seen_at, direct_address, relay_address FROM nodes WHERE id = ?", - nodeID).Scan(&n.ID, &n.MachineID, &n.DisplayName, &n.AgentVersion, &n.Status, &lastSeenAt, &directAddr, &relayAddr) + "SELECT id, machine_id, owner_id, display_name, agent_version, status, last_seen_at, direct_address, relay_address FROM nodes WHERE id = ?", + nodeID).Scan(&n.ID, &n.MachineID, &ownerID, &n.DisplayName, &n.AgentVersion, &n.Status, &lastSeenAt, &directAddr, &relayAddr) if err != nil { return nasNode{}, false } + if ownerID.Valid { + n.OwnerID = ownerID.String + } if lastSeenAt.Valid { n.LastSeenAt = lastSeenAt.String } @@ -442,9 +467,13 @@ func (s *sqliteStore) nodeAuthByID(nodeID string) (nodeAuthState, bool) { func (s *sqliteStore) scanExport(rows *sql.Rows) storageExport { var e storageExport var capacityBytes sql.NullInt64 - if err := rows.Scan(&e.ID, &e.NasNodeID, &e.Label, &e.Path, &e.MountPath, &capacityBytes); err != nil { + var ownerID sql.NullString + if err := rows.Scan(&e.ID, &e.NasNodeID, &ownerID, &e.Label, &e.Path, &e.MountPath, &capacityBytes); err != nil { return storageExport{} } + if ownerID.Valid { + e.OwnerID = ownerID.String + } if capacityBytes.Valid { e.CapacityBytes = &capacityBytes.Int64 } diff --git a/apps/control-plane/cmd/control-plane/sqlite_store_test.go b/apps/control-plane/cmd/control-plane/sqlite_store_test.go index 47cfc3d..1dda815 100644 --- a/apps/control-plane/cmd/control-plane/sqlite_store_test.go +++ b/apps/control-plane/cmd/control-plane/sqlite_store_test.go @@ -18,24 +18,13 @@ func newTestSQLiteApp(t *testing.T, config appConfig) (*app, *httptest.Server) { if config.version == "" { config.version = "test-version" } - if config.clientToken == "" { - config.clientToken = testClientToken - } - 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 { t.Fatalf("new app: %v", err) } app.now = func() time.Time { return testControlPlaneNow } + seedDefaultSessionUser(t, app) server := httptest.NewServer(app.handler()) return app, server @@ -79,7 +68,7 @@ func TestSQLiteRegistrationAndExports(t *testing.T) { RelayAddress: nil, }) if registration.NodeToken == "" { - t.Fatal("expected node registration to return a node token") + t.Fatal("expected node registration to preserve the session token") } if registration.Node.ID != "dev-node" { t.Fatalf("expected node ID %q, got %q", "dev-node", registration.Node.ID) @@ -142,8 +131,8 @@ func TestSQLiteReRegistrationKeepsNodeID(t *testing.T) { if second.Node.ID != first.Node.ID { t.Fatalf("expected re-registration to keep node ID %q, got %q", first.Node.ID, second.Node.ID) } - if second.NodeToken != "" { - t.Fatalf("expected re-registration to not issue new token, got %q", second.NodeToken) + if second.NodeToken != first.NodeToken { + t.Fatalf("expected re-registration to keep the existing session token %q, got %q", first.NodeToken, second.NodeToken) } if second.Node.DisplayName != "NAS Updated" { t.Fatalf("expected updated display name, got %q", second.Node.DisplayName) diff --git a/apps/control-plane/cmd/control-plane/store.go b/apps/control-plane/cmd/control-plane/store.go index e72219e..6ebb65a 100644 --- a/apps/control-plane/cmd/control-plane/store.go +++ b/apps/control-plane/cmd/control-plane/store.go @@ -31,8 +31,7 @@ type memoryStore struct { } type nodeRegistrationResult struct { - Node nasNode - IssuedNodeToken string + Node nasNode } type nodeAuthState struct { @@ -153,12 +152,12 @@ func cloneStoreState(state storeState) storeState { return cloned } -func (s *memoryStore) registerNode(request nodeRegistrationRequest, registeredAt time.Time) (nodeRegistrationResult, error) { +func (s *memoryStore) registerNode(ownerID string, request nodeRegistrationRequest, registeredAt time.Time) (nodeRegistrationResult, error) { s.mu.Lock() defer s.mu.Unlock() nextState := cloneStoreState(s.state) - result, err := registerNodeInState(&nextState, request, registeredAt) + result, err := registerNodeInState(&nextState, ownerID, request, registeredAt) if err != nil { return nodeRegistrationResult{}, err } @@ -170,21 +169,14 @@ func (s *memoryStore) registerNode(request nodeRegistrationRequest, registeredAt return result, nil } -func registerNodeInState(state *storeState, request nodeRegistrationRequest, registeredAt time.Time) (nodeRegistrationResult, error) { +func registerNodeInState(state *storeState, ownerID string, request nodeRegistrationRequest, registeredAt time.Time) (nodeRegistrationResult, error) { nodeID, ok := state.NodeIDByMachineID[request.MachineID] if !ok { nodeID = nextNodeID(state) state.NodeIDByMachineID[request.MachineID] = nodeID } - - issuedNodeToken := "" - if stringsTrimmedEmpty(state.NodeTokenHashByID[nodeID]) { - nodeToken, err := newOpaqueToken() - if err != nil { - return nodeRegistrationResult{}, err - } - state.NodeTokenHashByID[nodeID] = hashOpaqueToken(nodeToken) - issuedNodeToken = nodeToken + if existingNode, exists := state.NodesByID[nodeID]; exists && existingNode.OwnerID != "" && existingNode.OwnerID != ownerID { + return nodeRegistrationResult{}, errNodeOwnedByAnotherUser } node := nasNode{ @@ -196,21 +188,21 @@ func registerNodeInState(state *storeState, request nodeRegistrationRequest, reg LastSeenAt: registeredAt.UTC().Format(time.RFC3339), DirectAddress: copyStringPointer(request.DirectAddress), RelayAddress: copyStringPointer(request.RelayAddress), + OwnerID: ownerID, } state.NodesByID[nodeID] = node return nodeRegistrationResult{ - Node: node, - IssuedNodeToken: issuedNodeToken, + Node: node, }, nil } -func (s *memoryStore) upsertExports(nodeID string, request nodeExportsRequest) ([]storageExport, error) { +func (s *memoryStore) upsertExports(nodeID string, ownerID string, request nodeExportsRequest) ([]storageExport, error) { s.mu.Lock() defer s.mu.Unlock() nextState := cloneStoreState(s.state) - exports, err := upsertExportsInState(&nextState, nodeID, request.Exports) + exports, err := upsertExportsInState(&nextState, nodeID, ownerID, request.Exports) if err != nil { return nil, err } @@ -222,8 +214,9 @@ func (s *memoryStore) upsertExports(nodeID string, request nodeExportsRequest) ( return exports, nil } -func upsertExportsInState(state *storeState, nodeID string, exports []storageExportInput) ([]storageExport, error) { - if _, ok := state.NodesByID[nodeID]; !ok { +func upsertExportsInState(state *storeState, nodeID string, ownerID string, exports []storageExportInput) ([]storageExport, error) { + node, ok := state.NodesByID[nodeID] + if !ok || node.OwnerID != ownerID { return nil, errNodeNotFound } @@ -250,6 +243,7 @@ func upsertExportsInState(state *storeState, nodeID string, exports []storageExp Protocols: copyStringSlice(export.Protocols), CapacityBytes: copyInt64Pointer(export.CapacityBytes), Tags: copyStringSlice(export.Tags), + OwnerID: ownerID, } keepPaths[export.Path] = struct{}{} } @@ -278,12 +272,12 @@ func upsertExportsInState(state *storeState, nodeID string, exports []storageExp return nodeExports, nil } -func (s *memoryStore) recordHeartbeat(nodeID string, request nodeHeartbeatRequest) error { +func (s *memoryStore) recordHeartbeat(nodeID string, ownerID string, request nodeHeartbeatRequest) error { s.mu.Lock() defer s.mu.Unlock() nextState := cloneStoreState(s.state) - if err := recordHeartbeatInState(&nextState, nodeID, request); err != nil { + if err := recordHeartbeatInState(&nextState, nodeID, ownerID, request); err != nil { return err } if err := s.persistLocked(nextState); err != nil { @@ -294,9 +288,9 @@ func (s *memoryStore) recordHeartbeat(nodeID string, request nodeHeartbeatReques return nil } -func recordHeartbeatInState(state *storeState, nodeID string, request nodeHeartbeatRequest) error { +func recordHeartbeatInState(state *storeState, nodeID string, ownerID string, request nodeHeartbeatRequest) error { node, ok := state.NodesByID[nodeID] - if !ok { + if !ok || node.OwnerID != ownerID { return errNodeNotFound } @@ -307,12 +301,15 @@ func recordHeartbeatInState(state *storeState, nodeID string, request nodeHeartb return nil } -func (s *memoryStore) listExports() []storageExport { +func (s *memoryStore) listExports(ownerID string) []storageExport { s.mu.RLock() defer s.mu.RUnlock() exports := make([]storageExport, 0, len(s.state.ExportsByID)) for _, export := range s.state.ExportsByID { + if export.OwnerID != ownerID { + continue + } exports = append(exports, copyStorageExport(export)) } @@ -323,17 +320,17 @@ func (s *memoryStore) listExports() []storageExport { return exports } -func (s *memoryStore) exportContext(exportID string) (exportContext, bool) { +func (s *memoryStore) exportContext(exportID string, ownerID string) (exportContext, bool) { s.mu.RLock() defer s.mu.RUnlock() export, ok := s.state.ExportsByID[exportID] - if !ok { + if !ok || export.OwnerID != ownerID { return exportContext{}, false } node, ok := s.state.NodesByID[export.NasNodeID] - if !ok { + if !ok || node.OwnerID != ownerID { return exportContext{}, false } @@ -468,6 +465,7 @@ func copyNasNode(node nasNode) nasNode { LastSeenAt: node.LastSeenAt, DirectAddress: copyStringPointer(node.DirectAddress), RelayAddress: copyStringPointer(node.RelayAddress), + OwnerID: node.OwnerID, } } @@ -481,6 +479,7 @@ func copyStorageExport(export storageExport) storageExport { Protocols: copyStringSlice(export.Protocols), CapacityBytes: copyInt64Pointer(export.CapacityBytes), Tags: copyStringSlice(export.Tags), + OwnerID: export.OwnerID, } } diff --git a/apps/control-plane/cmd/control-plane/store_iface.go b/apps/control-plane/cmd/control-plane/store_iface.go index 25c7821..409e894 100644 --- a/apps/control-plane/cmd/control-plane/store_iface.go +++ b/apps/control-plane/cmd/control-plane/store_iface.go @@ -5,14 +5,12 @@ import "time" // store defines the persistence interface for the control-plane. type store interface { // Node management - registerNode(request nodeRegistrationRequest, registeredAt time.Time) (nodeRegistrationResult, error) - upsertExports(nodeID string, request nodeExportsRequest) ([]storageExport, error) - recordHeartbeat(nodeID string, request nodeHeartbeatRequest) error - listExports() []storageExport - exportContext(exportID string) (exportContext, bool) + registerNode(ownerID string, request nodeRegistrationRequest, registeredAt time.Time) (nodeRegistrationResult, error) + upsertExports(nodeID string, ownerID string, request nodeExportsRequest) ([]storageExport, error) + recordHeartbeat(nodeID string, ownerID string, request nodeHeartbeatRequest) error + listExports(ownerID string) []storageExport + exportContext(exportID string, ownerID string) (exportContext, bool) nodeByID(nodeID string) (nasNode, bool) - nodeAuthByMachineID(machineID string) (nodeAuthState, bool) - nodeAuthByID(nodeID string) (nodeAuthState, bool) // User auth createUser(username string, password string) (user, error) diff --git a/apps/node-agent/README.md b/apps/node-agent/README.md index 3ff861e..1505dc0 100644 --- a/apps/node-agent/README.md +++ b/apps/node-agent/README.md @@ -9,6 +9,9 @@ For the scaffold it does two things: - 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 +- uses `BETTERNAS_USERNAME` and `BETTERNAS_PASSWORD` both for control-plane login + and for local WebDAV basic auth This is the first real storage-facing surface in the monorepo. + +The user-facing binary should be distributed as `betternas-node`. diff --git a/apps/node-agent/cmd/node-agent/app.go b/apps/node-agent/cmd/node-agent/app.go index 2ed25c9..679ea94 100644 --- a/apps/node-agent/cmd/node-agent/app.go +++ b/apps/node-agent/cmd/node-agent/app.go @@ -1,6 +1,7 @@ package main import ( + "crypto/subtle" "encoding/json" "errors" "fmt" @@ -17,15 +18,15 @@ const ( ) type appConfig struct { - exportPaths []string - nodeID string - davAuthSecret string + exportPaths []string + authUsername string + authPassword string } type app struct { - nodeID string - davAuthSecret string - exportMounts []exportMount + authUsername string + authPassword string + exportMounts []exportMount } type exportMount struct { @@ -34,14 +35,12 @@ 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.authUsername = strings.TrimSpace(config.authUsername) + if config.authUsername == "" { + return nil, errors.New("authUsername is required") } - - config.davAuthSecret = strings.TrimSpace(config.davAuthSecret) - if config.davAuthSecret == "" { - return nil, errors.New("davAuthSecret is required") + if config.authPassword == "" { + return nil, errors.New("authPassword is required") } exportMounts, err := buildExportMounts(config.exportPaths) @@ -50,9 +49,9 @@ func newApp(config appConfig) (*app, error) { } return &app{ - nodeID: config.nodeID, - davAuthSecret: config.davAuthSecret, - exportMounts: exportMounts, + authUsername: config.authUsername, + authPassword: config.authPassword, + exportMounts: exportMounts, }, nil } @@ -62,24 +61,24 @@ func newAppFromEnv() (*app, error) { return nil, err } - davAuthSecret, err := requiredEnv("BETTERNAS_DAV_AUTH_SECRET") + authUsername, err := requiredEnv("BETTERNAS_USERNAME") + if err != nil { + return nil, err + } + authPassword, err := requiredEnv("BETTERNAS_PASSWORD") 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 { + if _, err := bootstrapNodeAgentFromEnv(exportPaths); err != nil { return nil, err } - nodeID = bootstrapResult.nodeID } return newApp(appConfig{ - exportPaths: exportPaths, - nodeID: nodeID, - davAuthSecret: davAuthSecret, + exportPaths: exportPaths, + authUsername: authUsername, + authPassword: authPassword, }) } @@ -182,25 +181,20 @@ func (a *app) requireDAVAuth(mount exportMount, next http.Handler) http.Handler writeDAVUnauthorized(w) return } - - claims, err := verifyMountCredential(a.davAuthSecret, password) - if err != nil { + if !a.matchesAccountCredential(username, password) { 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 (a *app) matchesAccountCredential(username string, password string) bool { + return subtle.ConstantTimeCompare([]byte(strings.TrimSpace(username)), []byte(a.authUsername)) == 1 && + subtle.ConstantTimeCompare([]byte(password), []byte(a.authPassword)) == 1 +} + 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. diff --git a/apps/node-agent/cmd/node-agent/app_test.go b/apps/node-agent/cmd/node-agent/app_test.go index 99db614..5df9179 100644 --- a/apps/node-agent/cmd/node-agent/app_test.go +++ b/apps/node-agent/cmd/node-agent/app_test.go @@ -1,8 +1,6 @@ package main import ( - "encoding/base64" - "encoding/json" "io" "net/http" "net/http/httptest" @@ -10,10 +8,12 @@ import ( "path/filepath" "strings" "testing" - "time" ) -const testDAVAuthSecret = "test-dav-auth-secret" +const ( + testUsername = "alice" + testPassword = "password123" +) func TestSingleExportServesDefaultAndScopedMountPathsWithValidCredentials(t *testing.T) { t.Parallel() @@ -22,9 +22,9 @@ func TestSingleExportServesDefaultAndScopedMountPathsWithValidCredentials(t *tes writeExportFile(t, exportDir, "README.txt", "single export\n") app, err := newApp(appConfig{ - exportPaths: []string{exportDir}, - nodeID: "node-1", - davAuthSecret: testDAVAuthSecret, + exportPaths: []string{exportDir}, + authUsername: testUsername, + authPassword: testPassword, }) if err != nil { t.Fatalf("new app: %v", err) @@ -33,14 +33,12 @@ func TestSingleExportServesDefaultAndScopedMountPathsWithValidCredentials(t *tes server := httptest.NewServer(app.handler()) defer server.Close() - 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") + assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+defaultWebDAVPath, testUsername, testPassword, http.StatusMultiStatus) + assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+scopedMountPath, testUsername, testPassword, http.StatusMultiStatus) + assertMountedFileContentsWithBasicAuth(t, server.Client(), server.URL+defaultWebDAVPath+"README.txt", testUsername, testPassword, "single export\n") + assertMountedFileContentsWithBasicAuth(t, server.Client(), server.URL+scopedMountPath+"README.txt", testUsername, testPassword, "single export\n") } func TestMultipleExportsServeDistinctScopedMountPathsWithValidCredentials(t *testing.T) { @@ -52,9 +50,9 @@ func TestMultipleExportsServeDistinctScopedMountPathsWithValidCredentials(t *tes writeExportFile(t, secondExportDir, "README.txt", "second export\n") app, err := newApp(appConfig{ - exportPaths: []string{firstExportDir, secondExportDir}, - nodeID: "node-1", - davAuthSecret: testDAVAuthSecret, + exportPaths: []string{firstExportDir, secondExportDir}, + authUsername: testUsername, + authPassword: testPassword, }) if err != nil { t.Fatalf("new app: %v", err) @@ -69,13 +67,10 @@ func TestMultipleExportsServeDistinctScopedMountPathsWithValidCredentials(t *tes t.Fatal("expected distinct mount paths for multiple exports") } - 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") + assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+firstMountPath, testUsername, testPassword, http.StatusMultiStatus) + assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+secondMountPath, testUsername, testPassword, http.StatusMultiStatus) + assertMountedFileContentsWithBasicAuth(t, server.Client(), server.URL+firstMountPath+"README.txt", testUsername, testPassword, "first export\n") + assertMountedFileContentsWithBasicAuth(t, server.Client(), server.URL+secondMountPath+"README.txt", testUsername, testPassword, "second export\n") response, err := server.Client().Get(server.URL + defaultWebDAVPath) if err != nil { @@ -87,16 +82,16 @@ func TestMultipleExportsServeDistinctScopedMountPathsWithValidCredentials(t *tes } } -func TestDAVAuthRejectsMissingInvalidAndReadonlyCredentials(t *testing.T) { +func TestDAVAuthRejectsMissingAndInvalidCredentials(t *testing.T) { t.Parallel() exportDir := t.TempDir() - writeExportFile(t, exportDir, "README.txt", "readonly export\n") + writeExportFile(t, exportDir, "README.txt", "mutable export\n") app, err := newApp(appConfig{ - exportPaths: []string{exportDir}, - nodeID: "node-1", - davAuthSecret: testDAVAuthSecret, + exportPaths: []string{exportDir}, + authUsername: testUsername, + authPassword: testPassword, }) if err != nil { t.Fatalf("new app: %v", err) @@ -106,27 +101,24 @@ func TestDAVAuthRejectsMissingInvalidAndReadonlyCredentials(t *testing.T) { defer server.Close() assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+defaultWebDAVPath, "", "", http.StatusUnauthorized) + assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+defaultWebDAVPath, "wrong-user", testPassword, http.StatusUnauthorized) + assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+defaultWebDAVPath, testUsername, "wrong-password", 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) + request.SetBasicAuth(testUsername, testPassword) 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) + if response.StatusCode != http.StatusCreated && response.StatusCode != http.StatusNoContent && response.StatusCode != http.StatusOK { + t.Fatalf("expected write with valid credentials to succeed, got %d", response.StatusCode) } + + assertMountedFileContentsWithBasicAuth(t, server.Client(), server.URL+defaultWebDAVPath+"README.txt", testUsername, testPassword, "updated\n") } func TestBuildExportMountsRejectsInvalidConfigs(t *testing.T) { @@ -193,54 +185,6 @@ func assertMountedFileContentsWithBasicAuth(t *testing.T, client *http.Client, e } } -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 index 28326fa..53b6531 100644 --- a/apps/node-agent/cmd/node-agent/control_plane.go +++ b/apps/node-agent/cmd/node-agent/control_plane.go @@ -14,8 +14,6 @@ import ( "time" ) -const controlPlaneNodeTokenHeader = "X-BetterNAS-Node-Token" - type bootstrapResult struct { nodeID string } @@ -32,6 +30,15 @@ type nodeRegistrationResponse struct { ID string `json:"id"` } +type authLoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type authLoginResponse struct { + Token string `json:"token"` +} + type nodeExportsRequest struct { Exports []storageExportInput `json:"exports"` } @@ -57,38 +64,28 @@ func bootstrapNodeAgentFromEnv(exportPaths []string) (bootstrapResult, error) { return bootstrapResult{}, err } - bootstrapToken, err := requiredEnv("BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN") + username, err := requiredEnv("BETTERNAS_USERNAME") + if err != nil { + return bootstrapResult{}, err + } + password, err := requiredEnv("BETTERNAS_PASSWORD") 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)) + machineID := strings.TrimSpace(env("BETTERNAS_NODE_MACHINE_ID", defaultNodeMachineID(username))) + displayName := strings.TrimSpace(env("BETTERNAS_NODE_DISPLAY_NAME", defaultNodeDisplayName(machineID))) if displayName == "" { displayName = machineID } client := &http.Client{Timeout: 5 * time.Second} - nodeToken, err := readNodeToken(nodeTokenPath) + sessionToken, err := loginWithControlPlane(client, controlPlaneURL, username, password) if err != nil { return bootstrapResult{}, err } - authToken := nodeToken - if authToken == "" { - authToken = bootstrapToken - } - - registration, issuedNodeToken, err := registerNodeWithControlPlane(client, controlPlaneURL, authToken, nodeRegistrationRequest{ + registration, err := registerNodeWithControlPlane(client, controlPlaneURL, sessionToken, nodeRegistrationRequest{ MachineID: machineID, DisplayName: displayName, AgentVersion: env("BETTERNAS_VERSION", "0.1.0-dev"), @@ -99,40 +96,58 @@ func bootstrapNodeAgentFromEnv(exportPaths []string) (bootstrapResult, error) { 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 { + if err := syncNodeExportsWithControlPlane(client, controlPlaneURL, sessionToken, registration.ID, buildStorageExportInputs(exportPaths)); err != nil { return bootstrapResult{}, err } - if err := sendNodeHeartbeat(client, controlPlaneURL, authToken, registration.ID); err != nil { + if err := sendNodeHeartbeat(client, controlPlaneURL, sessionToken, 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) +func loginWithControlPlane(client *http.Client, baseURL string, username string, password string) (string, error) { + response, err := doControlPlaneJSONRequest(client, http.MethodPost, controlPlaneEndpoint(baseURL, "/api/v1/auth/login"), "", authLoginRequest{ + Username: username, + Password: password, + }) if err != nil { - return nodeRegistrationResponse{}, "", err + return "", err } defer response.Body.Close() if response.StatusCode != http.StatusOK { - return nodeRegistrationResponse{}, "", controlPlaneResponseError("register node", response) + return "", controlPlaneResponseError("login", response) + } + + var auth authLoginResponse + if err := json.NewDecoder(response.Body).Decode(&auth); err != nil { + return "", fmt.Errorf("decode login response: %w", err) + } + if strings.TrimSpace(auth.Token) == "" { + return "", fmt.Errorf("login: missing session token") + } + + return strings.TrimSpace(auth.Token), nil +} + +func registerNodeWithControlPlane(client *http.Client, baseURL string, token string, payload nodeRegistrationRequest) (nodeRegistrationResponse, 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 nodeRegistrationResponse{}, fmt.Errorf("decode register node response: %w", err) } - return registration, strings.TrimSpace(response.Header.Get(controlPlaneNodeTokenHeader)), nil + return registration, nil } func syncNodeExportsWithControlPlane(client *http.Client, baseURL string, token string, nodeID string, exports []storageExportInput) error { @@ -181,7 +196,9 @@ func doControlPlaneJSONRequest(client *http.Client, method string, endpoint stri 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) + if strings.TrimSpace(token) != "" { + request.Header.Set("Authorization", "Bearer "+token) + } response, err := client.Do(request) if err != nil { @@ -248,52 +265,20 @@ func optionalEnvPointer(key string) *string { 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) +func defaultNodeMachineID(username string) string { + hostname, err := os.Hostname() + if err != nil || strings.TrimSpace(hostname) == "" { + return strings.TrimSpace(username) + "@node" } - return strings.TrimSpace(string(data)), nil + return strings.TrimSpace(username) + "@" + strings.TrimSpace(hostname) } -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) +func defaultNodeDisplayName(machineID string) string { + _, displayName, ok := strings.Cut(strings.TrimSpace(machineID), "@") + if ok && strings.TrimSpace(displayName) != "" { + return strings.TrimSpace(displayName) } - 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 + return strings.TrimSpace(machineID) } diff --git a/apps/node-agent/cmd/node-agent/dav_auth.go b/apps/node-agent/cmd/node-agent/dav_auth.go index e483be8..07e85c7 100644 --- a/apps/node-agent/cmd/node-agent/dav_auth.go +++ b/apps/node-agent/cmd/node-agent/dav_auth.go @@ -1,74 +1,8 @@ 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)) -} +import "net/http" 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/apps/node-agent/package.json b/apps/node-agent/package.json index d492987..8141ea0 100644 --- a/apps/node-agent/package.json +++ b/apps/node-agent/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "dev": "CGO_ENABLED=0 go run ./cmd/node-agent", - "build": "mkdir -p dist && CGO_ENABLED=0 go build -o dist/node-agent ./cmd/node-agent", + "build": "mkdir -p dist && CGO_ENABLED=0 go build -o dist/betternas-node ./cmd/node-agent", "format": "files=$(find . -name '*.go' -type f) && if [ -n \"$files\" ]; then gofmt -w $files; fi", "format:check": "files=$(find . -name '*.go' -type f) && if [ -n \"$files\" ]; then test -z \"$(gofmt -l $files)\"; fi", "lint": "CGO_ENABLED=0 go vet ./...", diff --git a/apps/web/README.md b/apps/web/README.md index b8acc89..796dc34 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -5,17 +5,9 @@ Next.js control-plane UI for betterNAS. Use this app for: - admin and operator workflows -- node and export visibility -- issuing mount profiles +- user-scoped node and export visibility +- issuing mount profiles that reuse the same betterNAS account credentials - later cloud-mode management Do not move the product system of record into this app. It should stay a UI and thin BFF layer over the Go control plane. - -The current page reads control-plane config from: - -- `BETTERNAS_CONTROL_PLANE_URL` and `BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN`, or -- the repo-local `.env.agent` file - -That keeps the page aligned with the running self-hosted stack during local -development. diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx index 2df62d4..73eb23d 100644 --- a/apps/web/app/login/page.tsx +++ b/apps/web/app/login/page.tsx @@ -55,8 +55,8 @@ export default function LoginPage() { {mode === "login" - ? "Sign in to your betterNAS control plane." - : "Create your betterNAS account."} + ? "Sign in to your betterNAS control plane with the same credentials you use for the node agent and Finder." + : "Create your betterNAS account. You will use the same username and password for the web app, node agent, and Finder."} @@ -103,9 +103,7 @@ export default function LoginPage() { /> - {error && ( -

{error}

- )} + {error &&

{error}

} @@ -138,6 +136,25 @@ export default function Home() { {exports.length === 1 ? "1 export" : `${exports.length} exports`} + + {user && ( + + + Node agent setup + + Run the node binary on the machine that owns the files with + the same account credentials you use here and in Finder. + + + +
+                  
+                    {`BETTERNAS_USERNAME=${user.username} BETTERNAS_PASSWORD=... BETTERNAS_EXPORT_PATH=/path/to/export betternas-node`}
+                  
+                
+
+
+ )} {feedback !== null && ( @@ -245,8 +262,8 @@ export default function Home() { {selectedExport !== null - ? "Issued WebDAV credentials for Finder." - : "Select an export to issue mount credentials."} + ? "WebDAV mount details for Finder." + : "Select an export to see the mount URL and account login details."} @@ -254,7 +271,8 @@ export default function Home() {

- Pick an export to issue WebDAV credentials for Finder. + Pick an export to see the Finder mount URL and the username + to use with your betterNAS account password.

) : ( @@ -281,10 +299,16 @@ export default function Home() { label="Username" value={mountProfile.credential.username} /> - + + + Use your betterNAS account password + + + Enter the same password you use to sign in to betterNAS + and run the node agent. v1 does not issue a separate + WebDAV password. + + @@ -300,10 +324,10 @@ export default function Home() {
- Expires + Password source
- {mountProfile.credential.expiresAt} + Your betterNAS account password
@@ -316,8 +340,8 @@ export default function Home() { {[ "Open Finder and choose Go, then Connect to Server.", "Paste the mount URL into the server address field.", - "Enter the issued username and password when prompted.", - "Save to Keychain only if the credential expiry suits your workflow.", + "Enter your betterNAS username and account password when prompted.", + "Save to Keychain only if you want Finder to reuse that same account password.", ].map((step, index) => (
  • ( - path: string, - options?: RequestInit, -): Promise { +async function apiFetch(path: string, options?: RequestInit): Promise { const headers: Record = {}; const token = getToken(); if (token) { @@ -79,7 +76,10 @@ async function apiFetch( const response = await fetch(`${API_URL}${path}`, { ...options, - headers: { ...headers, ...Object.fromEntries(new Headers(options?.headers).entries()) }, + headers: { + ...headers, + ...Object.fromEntries(new Headers(options?.headers).entries()), + }, }); if (!response.ok) { diff --git a/docs/architecture.md b/docs/architecture.md index 18b2b11..5921c19 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -120,12 +120,14 @@ self-hosted mount flow. - issue mount profile - issue share or cloud profile later +For v1, the same betterNAS username and password should be used across the web +UI, node enrollment, and Finder WebDAV login. + ### Local device -> control-server - fetch mount instructions -- receive issued WebDAV URL and standard WebDAV credentials +- receive issued WebDAV URL plus the betterNAS account username - username - - password - expiresAt ## Initial backend route sketch diff --git a/infra/docker/compose.dev.yml b/infra/docker/compose.dev.yml index 3d742db..1a0f055 100644 --- a/infra/docker/compose.dev.yml +++ b/infra/docker/compose.dev.yml @@ -32,11 +32,9 @@ services: PORT: 3000 BETTERNAS_VERSION: ${BETTERNAS_VERSION} NEXTCLOUD_BASE_URL: ${NEXTCLOUD_BASE_URL} - 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_CONTROL_PLANE_DB_PATH: /var/lib/betternas/control-plane/betternas.db + BETTERNAS_USERNAME: ${BETTERNAS_USERNAME} + BETTERNAS_PASSWORD: ${BETTERNAS_PASSWORD} BETTERNAS_EXAMPLE_MOUNT_URL: ${BETTERNAS_EXAMPLE_MOUNT_URL} BETTERNAS_NODE_DIRECT_ADDRESS: ${BETTERNAS_NODE_DIRECT_ADDRESS} ports: @@ -58,12 +56,11 @@ services: 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_USERNAME: ${BETTERNAS_USERNAME} + BETTERNAS_PASSWORD: ${BETTERNAS_PASSWORD} 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" @@ -94,7 +91,7 @@ services: POSTGRES_PASSWORD: nextcloud REDIS_HOST: redis BETTERNAS_CONTROL_PLANE_URL: http://control-plane:3000 - BETTERNAS_CONTROL_PLANE_API_TOKEN: ${BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN} + BETTERNAS_CONTROL_PLANE_API_TOKEN: "" ports: - "${BETTERNAS_NEXTCLOUD_PORT}:80" volumes: diff --git a/packages/contracts/openapi/betternas.v1.yaml b/packages/contracts/openapi/betternas.v1.yaml index 92fe7b1..1902c06 100644 --- a/packages/contracts/openapi/betternas.v1.yaml +++ b/packages/contracts/openapi/betternas.v1.yaml @@ -22,8 +22,7 @@ paths: post: operationId: registerNode security: - - NodeBootstrapToken: [] - - NodeToken: [] + - UserSession: [] requestBody: required: true content: @@ -33,11 +32,6 @@ paths: responses: "200": description: Registered node - headers: - X-BetterNAS-Node-Token: - description: Returned when a node is first registered or migrated to node-scoped auth. - schema: - type: string content: application/json: schema: @@ -48,7 +42,7 @@ paths: post: operationId: recordNodeHeartbeat security: - - NodeToken: [] + - UserSession: [] parameters: - in: path name: nodeId @@ -70,7 +64,7 @@ paths: put: operationId: syncNodeExports security: - - NodeToken: [] + - UserSession: [] parameters: - in: path name: nodeId @@ -98,7 +92,7 @@ paths: get: operationId: listExports security: - - ClientToken: [] + - UserSession: [] responses: "200": description: Export list @@ -114,7 +108,7 @@ paths: post: operationId: issueMountProfile security: - - ClientToken: [] + - UserSession: [] requestBody: required: true content: @@ -134,7 +128,7 @@ paths: post: operationId: issueCloudProfile security: - - ClientToken: [] + - UserSession: [] requestBody: required: true content: @@ -152,18 +146,10 @@ paths: description: Unauthorized components: securitySchemes: - ClientToken: + UserSession: type: http scheme: bearer - description: Bearer token required for export listing and profile issuance. - NodeBootstrapToken: - type: http - scheme: bearer - description: Bearer token required to register a new node before it receives a node-scoped token. - NodeToken: - type: http - scheme: bearer - description: Bearer token scoped to a previously registered node. + description: Bearer session token returned by the username and password auth endpoints. schemas: NasNode: type: object diff --git a/packages/contracts/src/foundation.ts b/packages/contracts/src/foundation.ts index 1825d7b..04e513b 100644 --- a/packages/contracts/src/foundation.ts +++ b/packages/contracts/src/foundation.ts @@ -9,7 +9,6 @@ export const FOUNDATION_API_ROUTES = { export const FOUNDATION_API_HEADERS = { authorization: "Authorization", - nodeToken: "X-BetterNAS-Node-Token", } as const; export type NasNodeStatus = "online" | "offline" | "degraded"; diff --git a/scripts/integration/verify-stack b/scripts/integration/verify-stack index 8360e45..5595c33 100755 --- a/scripts/integration/verify-stack +++ b/scripts/integration/verify-stack @@ -10,9 +10,29 @@ source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../lib/runtime-env.sh" control_health="$(curl -fsS "http://localhost:${BETTERNAS_CONTROL_PLANE_PORT}/health")" echo "$control_health" | jq -e '.service == "control-plane" and .status == "ok"' >/dev/null +auth_payload="$(jq -nc --arg username "$BETTERNAS_USERNAME" --arg password "$BETTERNAS_PASSWORD" '{username: $username, password: $password}')" +session_token="$({ + curl -fsS \ + -X POST \ + -H 'Content-Type: application/json' \ + -d "$auth_payload" \ + "http://localhost:${BETTERNAS_CONTROL_PLANE_PORT}/api/v1/auth/login" \ + | jq -er '.token' +} 2>/dev/null || true)" +if [[ -z "$session_token" ]]; then + session_token="$({ + curl -fsS \ + -X POST \ + -H 'Content-Type: application/json' \ + -d "$auth_payload" \ + "http://localhost:${BETTERNAS_CONTROL_PLANE_PORT}/api/v1/auth/register" \ + | jq -er '.token' + })" +fi + 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")" + exports_response="$(curl -fsS -H "Authorization: Bearer ${session_token}" "http://localhost:${BETTERNAS_CONTROL_PLANE_PORT}/api/v1/exports")" export_id="$({ echo "$exports_response" | jq -er \ 'map(select(.mountPath == "/dav/")) | .[0].id? // empty' @@ -32,14 +52,14 @@ mount_profile="$({ curl -fsS \ -X POST \ -H 'Content-Type: application/json' \ - -H "Authorization: Bearer ${BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN}" \ + -H "Authorization: Bearer ${session_token}" \ -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 and .credential.mode == "basic-auth"' >/dev/null -BETTERNAS_EXAMPLE_MOUNT_USERNAME="$(echo "$mount_profile" | jq -er '.credential.username')" -BETTERNAS_EXAMPLE_MOUNT_PASSWORD="$(echo "$mount_profile" | jq -er '.credential.password')" +BETTERNAS_EXAMPLE_MOUNT_USERNAME="${BETTERNAS_USERNAME}" +BETTERNAS_EXAMPLE_MOUNT_PASSWORD="${BETTERNAS_PASSWORD}" export BETTERNAS_EXAMPLE_MOUNT_USERNAME export BETTERNAS_EXAMPLE_MOUNT_PASSWORD "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/verify-webdav" @@ -48,7 +68,7 @@ cloud_profile="$({ curl -fsS \ -X POST \ -H 'Content-Type: application/json' \ - -H "Authorization: Bearer ${BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN}" \ + -H "Authorization: Bearer ${session_token}" \ -d "{\"userId\":\"integration-user\",\"exportId\":\"${export_id}\",\"provider\":\"nextcloud\"}" \ "http://localhost:${BETTERNAS_CONTROL_PLANE_PORT}/api/v1/cloud-profiles/issue" })" diff --git a/scripts/lib/agent-env.sh b/scripts/lib/agent-env.sh index 3ad36ce..b1b75cb 100644 --- a/scripts/lib/agent-env.sh +++ b/scripts/lib/agent-env.sh @@ -130,10 +130,8 @@ betternas_write_agent_env_file() { betternas_write_env_assignment "BETTERNAS_NEXTCLOUD_PORT" "$nextcloud_port" betternas_write_env_assignment "BETTERNAS_EXPORT_PATH" ".state/${clone_name}/export" 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_USERNAME" "${clone_name}-user" + betternas_write_env_assignment "BETTERNAS_PASSWORD" "${clone_name}-password123" 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}" diff --git a/scripts/lib/runtime-env.sh b/scripts/lib/runtime-env.sh index 8f31766..c518a05 100755 --- a/scripts/lib/runtime-env.sh +++ b/scripts/lib/runtime-env.sh @@ -31,10 +31,8 @@ read -r default_nextcloud_port default_node_agent_port default_control_plane_por : "${BETTERNAS_NODE_AGENT_PORT:=$default_node_agent_port}" : "${BETTERNAS_NEXTCLOUD_PORT:=$default_nextcloud_port}" : "${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_USERNAME:=${BETTERNAS_CLONE_NAME}-user}" +: "${BETTERNAS_PASSWORD:=${BETTERNAS_CLONE_NAME}-password123}" : "${BETTERNAS_NODE_MACHINE_ID:=${BETTERNAS_CLONE_NAME}-node}" : "${BETTERNAS_NODE_DISPLAY_NAME:=${BETTERNAS_CLONE_NAME} node}" : "${NEXTCLOUD_ADMIN_USER:=admin}" @@ -62,10 +60,8 @@ export BETTERNAS_NODE_AGENT_PORT export BETTERNAS_NEXTCLOUD_PORT 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_USERNAME +export BETTERNAS_PASSWORD export BETTERNAS_NODE_MACHINE_ID export BETTERNAS_NODE_DISPLAY_NAME export NEXTCLOUD_ADMIN_USER diff --git a/thoughts/shared/plans/2026-04-01-production-deployment.md b/thoughts/shared/plans/2026-04-01-production-deployment.md index b76c7c6..40315f4 100644 --- a/thoughts/shared/plans/2026-04-01-production-deployment.md +++ b/thoughts/shared/plans/2026-04-01-production-deployment.md @@ -26,6 +26,7 @@ Deploy the betterNAS control-plane as a production service on netty (Netcup VPS) - Deployment is declarative via NixOS configuration.nix ### Verification: + 1. `curl https://api.betternas.com/health` returns `ok` 2. Web UI at `betternas.com` loads, shows login page 3. User can register, log in, see exports, one-click mount @@ -57,6 +58,7 @@ Five phases, each independently deployable and testable: ## Phase 1: SQLite Store ### Overview + Replace `memoryStore` (in-memory + JSON file) with a `sqliteStore` using `modernc.org/sqlite` (pure Go, no CGo, `database/sql` compatible). This keeps all existing API behavior identical while switching the persistence layer. ### Schema @@ -118,15 +120,19 @@ CREATE TABLE export_tags ( ### Changes Required #### 1. Add SQLite dependency + **File**: `apps/control-plane/go.mod` + ``` go get modernc.org/sqlite ``` #### 2. New file: `sqlite_store.go` + **File**: `apps/control-plane/cmd/control-plane/sqlite_store.go` Implements the same operations as `memoryStore` but backed by SQLite: + - `newSQLiteStore(dbPath string) (*sqliteStore, error)` - opens DB, runs migrations - `registerNode(...)` - INSERT/UPDATE node + token hash in a transaction - `upsertExports(...)` - DELETE removed exports, UPSERT current ones in a transaction @@ -138,6 +144,7 @@ Implements the same operations as `memoryStore` but backed by SQLite: - `nextOrdinal(name)` - UPDATE ordinals SET value = value + 1 RETURNING value Key design decisions: + - Use `database/sql` with `modernc.org/sqlite` driver - WAL mode enabled at connection: `PRAGMA journal_mode=WAL` - Foreign keys enabled: `PRAGMA foreign_keys=ON` @@ -146,9 +153,11 @@ Key design decisions: - No ORM - raw SQL with prepared statements #### 3. Update `app.go` to use SQLite store + **File**: `apps/control-plane/cmd/control-plane/app.go` Replace `memoryStore` initialization with `sqliteStore`: + ```go // Replace: // store, err := newMemoryStore(statePath) @@ -159,6 +168,7 @@ Replace `memoryStore` initialization with `sqliteStore`: New env var: `BETTERNAS_CONTROL_PLANE_DB_PATH` (default: `/var/lib/betternas/control-plane/betternas.db`) #### 4. Update `server.go` to use new store interface + **File**: `apps/control-plane/cmd/control-plane/server.go` The server handlers currently call methods directly on `*memoryStore`. These need to call the equivalent methods on the new store. If the method signatures match, this is a straight swap. If not, introduce a `store` interface that both implement during migration, then delete `memoryStore`. @@ -166,12 +176,14 @@ The server handlers currently call methods directly on `*memoryStore`. These nee ### Success Criteria #### Automated Verification: + - [ ] `go build ./apps/control-plane/cmd/control-plane/` compiles with `CGO_ENABLED=0` - [ ] `go test ./apps/control-plane/cmd/control-plane/ -v` passes all existing tests - [ ] New SQLite store tests pass (register node, upsert exports, list exports, auth lookup) - [ ] `curl` against a local instance: register node, sync exports, issue mount profile - all return expected responses #### Manual Verification: + - [ ] Start control-plane locally, SQLite file is created at configured path - [ ] Restart control-plane - state persists across restarts - [ ] Node-agent can register and sync exports against the SQLite-backed control-plane @@ -181,6 +193,7 @@ The server handlers currently call methods directly on `*memoryStore`. These nee ## Phase 2: User Auth ### Overview + Add user accounts with username/password (bcrypt) and session tokens stored in SQLite. The session token replaces the static `BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN` for web UI access. Node-agent auth (bootstrap token + node token) is unchanged. ### Additional Schema @@ -216,6 +229,7 @@ GET /api/v1/auth/me - Return current user info (session validation) ### Changes Required #### 1. New file: `auth.go` + **File**: `apps/control-plane/cmd/control-plane/auth.go` ```go @@ -250,15 +264,18 @@ func (s *sqliteStore) cleanExpiredSessions() error ``` #### 2. New env vars + ``` BETTERNAS_SESSION_TTL # Session duration (default: "720h" = 30 days) BETTERNAS_REGISTRATION_ENABLED # Allow new registrations (default: "true") ``` #### 3. Update `server.go` - auth middleware and routes + **File**: `apps/control-plane/cmd/control-plane/server.go` Add auth routes: + ```go mux.HandleFunc("POST /api/v1/auth/register", s.handleRegister) mux.HandleFunc("POST /api/v1/auth/login", s.handleLogin) @@ -267,6 +284,7 @@ mux.HandleFunc("GET /api/v1/auth/me", s.handleMe) ``` Update client-auth middleware: + ```go // Currently: checks Bearer token against static BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN // New: checks Bearer token against sessions table first, falls back to static token @@ -297,12 +315,14 @@ func (s *server) requireClientAuth(next http.Handler) http.Handler { ### Success Criteria #### Automated Verification: + - [ ] `go test` passes for auth endpoints (register, login, logout, me) - [ ] `go test` passes for session middleware (valid token, expired token, invalid token) - [ ] Existing client token auth still works (backwards compat) - [ ] Existing node auth unchanged #### Manual Verification: + - [ ] Register a user via curl, login, use session token to list exports - [ ] Session expires after TTL - [ ] Logout invalidates session immediately @@ -313,11 +333,13 @@ func (s *server) requireClientAuth(next http.Handler) http.Handler { ## Phase 3: CORS + Frontend Auth Integration ### Overview + Add CORS headers to the control-plane so the Vercel-hosted frontend can make API calls. Update the web frontend to use session-based auth (login page, session cookie/token management). ### Changes Required #### 1. CORS middleware in control-plane + **File**: `apps/control-plane/cmd/control-plane/server.go` ```go @@ -342,14 +364,17 @@ func corsMiddleware(allowedOrigin string, next http.Handler) http.Handler { ``` #### 2. Frontend auth flow + **Files**: `apps/web/` New pages/components: + - `app/login/page.tsx` - Login form (username + password) - `app/register/page.tsx` - Registration form (if enabled) - `lib/auth.ts` - Client-side auth helpers (store token, attach to requests) Update `lib/control-plane.ts`: + - Remove `.env.agent` file reading (production doesn't need it) - Read `NEXT_PUBLIC_BETTERNAS_API_URL` env var for the backend URL - Use session token from localStorage/cookie instead of static client token @@ -372,7 +397,11 @@ export function clearSessionToken(): void { localStorage.removeItem(TOKEN_KEY); } -export async function login(apiUrl: string, username: string, password: string): Promise { +export async function login( + apiUrl: string, + username: string, + password: string, +): Promise { const res = await fetch(`${apiUrl}/api/v1/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -386,6 +415,7 @@ export async function login(apiUrl: string, username: string, password: string): ``` Update `lib/control-plane.ts`: + ```typescript // Replace the current getControlPlaneConfig with: export function getControlPlaneConfig(): ControlPlaneConfig { @@ -396,6 +426,7 @@ export function getControlPlaneConfig(): ControlPlaneConfig { ``` #### 3. Auth-gated layout + **File**: `apps/web/app/layout.tsx` or a middleware Redirect to `/login` if no valid session. The `/login` and `/register` pages are public. @@ -403,11 +434,13 @@ Redirect to `/login` if no valid session. The `/login` and `/register` pages are ### Success Criteria #### Automated Verification: + - [ ] CORS preflight (OPTIONS) returns correct headers - [ ] Frontend builds: `cd apps/web && pnpm build` - [ ] No TypeScript errors #### Manual Verification: + - [ ] Open `betternas.com` (or localhost:3000) - redirected to login - [ ] Register a new account, login, see exports dashboard - [ ] Click an export, get mount credentials @@ -419,20 +452,25 @@ Redirect to `/login` if no valid session. The `/login` and `/register` pages are ## Phase 4: NixOS Deployment (netty) ### Overview + Deploy the control-plane as a NixOS-managed systemd service on netty, behind NGINX with ACME TLS at `api.betternas.com`. Stop the Docker Compose stack. ### Changes Required #### 1. DNS: Point `api.betternas.com` to netty + Run from local machine (Vercel CLI): + ```bash vercel dns add betternas.com api A 152.53.195.59 ``` #### 2. Build the Go binary for Linux + **File**: `apps/control-plane/Dockerfile` (or local cross-compile) For NixOS, we can either: + - (a) Cross-compile locally: `GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o control-plane ./cmd/control-plane` - (b) Build a Nix package (cleaner, but more work) - (c) Build on netty directly from the git repo @@ -440,6 +478,7 @@ For NixOS, we can either: Recommendation: **(c) Build on netty** from the cloned repo. Simple, works now. Add a Nix package later if desired. #### 3. NixOS configuration changes + **File**: `/home/rathi/Documents/GitHub/nix/hosts/netty/configuration.nix` Add these blocks (following the existing forgejo/vaultwarden pattern): @@ -479,6 +518,7 @@ Add these blocks (following the existing forgejo/vaultwarden pattern): ``` #### 4. Environment file on netty + **File**: `/var/lib/betternas/control-plane/control-plane.env` ```bash @@ -496,6 +536,7 @@ BETTERNAS_NODE_DIRECT_ADDRESS=https://api.betternas.com ``` #### 5. Build and deploy script + **File**: `apps/control-plane/scripts/deploy-netty.sh` ```bash @@ -516,7 +557,9 @@ ssh "$REMOTE" "cd $REPO && git pull && \ ``` #### 6. Stop Docker Compose stack + After the systemd service is running and verified: + ```bash ssh netty 'bash -c "cd /home/rathi/Documents/GitHub/betterNAS/betterNAS && source scripts/lib/runtime-env.sh && compose down"' ``` @@ -524,12 +567,14 @@ ssh netty 'bash -c "cd /home/rathi/Documents/GitHub/betterNAS/betterNAS && sourc ### Success Criteria #### Automated Verification: + - [ ] `curl https://api.betternas.com/health` returns `ok` - [ ] `curl https://api.betternas.com/version` returns version JSON - [ ] TLS certificate is valid (Let's Encrypt) - [ ] `systemctl status betternas-control-plane` shows active #### Manual Verification: + - [ ] Node-agent can register against `https://api.betternas.com` - [ ] Mount credentials issued via the API work in Finder - [ ] Service survives restart: `sudo systemctl restart betternas-control-plane` @@ -540,44 +585,52 @@ ssh netty 'bash -c "cd /home/rathi/Documents/GitHub/betterNAS/betterNAS && sourc ## Phase 5: Vercel Deployment ### Overview + Deploy the Next.js web UI to Vercel at `betternas.com`. ### Changes Required #### 1. Create Vercel project + ```bash cd apps/web vercel link # or vercel --yes ``` #### 2. Configure environment variables on Vercel + ```bash vercel env add NEXT_PUBLIC_BETTERNAS_API_URL production # Value: https://api.betternas.com ``` #### 3. Configure domain + ```bash vercel domains add betternas.com # Already have wildcard ALIAS to vercel-dns, so this should work ``` #### 4. Deploy + ```bash cd apps/web vercel --prod ``` #### 5. Verify CORS + The backend at `api.betternas.com` must have `BETTERNAS_CORS_ORIGIN=https://betternas.com` set (done in Phase 4). ### Success Criteria #### Automated Verification: + - [ ] `curl -I https://betternas.com` returns 200 - [ ] CORS preflight from `betternas.com` to `api.betternas.com` succeeds #### Manual Verification: + - [ ] Visit `betternas.com` - see login page - [ ] Register, login, see exports, issue mount credentials - [ ] Mount from Finder using issued credentials @@ -600,16 +653,19 @@ This is a follow-up task, not part of the initial deployment. ## Testing Strategy ### Unit Tests (Go): + - SQLite store: CRUD operations, transactions, concurrent access - Auth: registration, login, session validation, expiry, logout - Migration: schema creates cleanly on empty DB ### Integration Tests: + - Full API flow: register user -> login -> list exports -> issue mount profile - Node registration + export sync against SQLite store - Session expiry and cleanup ### Manual Testing: + 1. Fresh deploy: start control-plane with empty DB 2. Register first user via API 3. Login from web UI