diff --git a/docs/.agents/skills/shadcn/SKILL.md b/.agents/skills/shadcn/SKILL.md similarity index 100% rename from docs/.agents/skills/shadcn/SKILL.md rename to .agents/skills/shadcn/SKILL.md diff --git a/docs/.agents/skills/shadcn/agents/openai.yml b/.agents/skills/shadcn/agents/openai.yml similarity index 100% rename from docs/.agents/skills/shadcn/agents/openai.yml rename to .agents/skills/shadcn/agents/openai.yml diff --git a/docs/.agents/skills/shadcn/assets/shadcn-small.png b/.agents/skills/shadcn/assets/shadcn-small.png similarity index 100% rename from docs/.agents/skills/shadcn/assets/shadcn-small.png rename to .agents/skills/shadcn/assets/shadcn-small.png diff --git a/docs/.agents/skills/shadcn/assets/shadcn.png b/.agents/skills/shadcn/assets/shadcn.png similarity index 100% rename from docs/.agents/skills/shadcn/assets/shadcn.png rename to .agents/skills/shadcn/assets/shadcn.png diff --git a/docs/.agents/skills/shadcn/cli.md b/.agents/skills/shadcn/cli.md similarity index 100% rename from docs/.agents/skills/shadcn/cli.md rename to .agents/skills/shadcn/cli.md diff --git a/docs/.agents/skills/shadcn/customization.md b/.agents/skills/shadcn/customization.md similarity index 100% rename from docs/.agents/skills/shadcn/customization.md rename to .agents/skills/shadcn/customization.md diff --git a/docs/.agents/skills/shadcn/evals/evals.json b/.agents/skills/shadcn/evals/evals.json similarity index 100% rename from docs/.agents/skills/shadcn/evals/evals.json rename to .agents/skills/shadcn/evals/evals.json diff --git a/docs/.agents/skills/shadcn/mcp.md b/.agents/skills/shadcn/mcp.md similarity index 100% rename from docs/.agents/skills/shadcn/mcp.md rename to .agents/skills/shadcn/mcp.md diff --git a/docs/.agents/skills/shadcn/rules/base-vs-radix.md b/.agents/skills/shadcn/rules/base-vs-radix.md similarity index 100% rename from docs/.agents/skills/shadcn/rules/base-vs-radix.md rename to .agents/skills/shadcn/rules/base-vs-radix.md diff --git a/docs/.agents/skills/shadcn/rules/composition.md b/.agents/skills/shadcn/rules/composition.md similarity index 100% rename from docs/.agents/skills/shadcn/rules/composition.md rename to .agents/skills/shadcn/rules/composition.md diff --git a/docs/.agents/skills/shadcn/rules/forms.md b/.agents/skills/shadcn/rules/forms.md similarity index 100% rename from docs/.agents/skills/shadcn/rules/forms.md rename to .agents/skills/shadcn/rules/forms.md diff --git a/docs/.agents/skills/shadcn/rules/icons.md b/.agents/skills/shadcn/rules/icons.md similarity index 100% rename from docs/.agents/skills/shadcn/rules/icons.md rename to .agents/skills/shadcn/rules/icons.md diff --git a/docs/.agents/skills/shadcn/rules/styling.md b/.agents/skills/shadcn/rules/styling.md similarity index 100% rename from docs/.agents/skills/shadcn/rules/styling.md rename to .agents/skills/shadcn/rules/styling.md diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a6a50b7..9846b53 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -7,7 +7,7 @@ on: jobs: test-control-plane: - runs-on: [self-hosted, netty] + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 @@ -20,7 +20,7 @@ jobs: working-directory: apps/control-plane test-node-agent: - runs-on: [self-hosted, netty] + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 @@ -31,3 +31,17 @@ jobs: working-directory: apps/node-agent - run: go test -count=1 ./... working-directory: apps/node-agent + + build-web: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm --filter @betternas/web build + env: + NEXT_PUBLIC_BETTERNAS_API_URL: https://api.betternas.com diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..951a376 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,63 @@ +# Project Constraints + +## Delivery sequencing + +- Start with `apps/control-plane` first. +- Deliver the core backend in 2 steps, not 3: + 1. `control-server` plus `node-service` contract and runtime loop + 2. web control plane on top of that stable backend seam +- Do not start web UI work until the `control-server` and `node-service` contract is stable. + +## Architecture + +- `control-server` is the clean backend contract that other parts consume. +- `apps/node-agent` reports into `apps/control-plane`. +- `apps/web` reads from `apps/control-plane`. +- Local mount UX is issued by `apps/control-plane`. + +## Backend contract priorities + +- The first backend seam must cover: + - node enrollment + - node heartbeats + - node export reporting + - control-server persistence of nodes and exports + - mount profile issuance for one export +- `control-server` should own: + - node auth + - user auth + - mount issuance + +## Mount profile shape + +- Prefer standard WebDAV username and password semantics for Finder compatibility. +- The consumer-facing mount profile should behave like: + - export id + - display name + - mount URL + - username + - password + - readonly + - expires at + +## Service boundary + +- Keep `node-service` limited to the WebDAV mount surface. +- Route admin and control actions through `control-server`, not directly from browsers to `node-service`. + +## 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/README.md b/README.md index aa33745..3c7c647 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,114 @@ -# BetterNAS +# betterNAS -https://github.com/user-attachments/assets/7909f957-b6d9-4cc1-aaec-369d5d94a7f5 +betterNAS is a self-hostable WebDAV stack for mounting NAS exports in Finder. +The default product shape is: -betterNAS lets you mount remote machines as native Finder volumes on your Mac. -Install a small agent on any box with files you care about, and it shows up in Finder like a local drive. -No sync clients, no special apps - just your files, where you expect them. +- `node-service` serves the real files from the NAS over WebDAV +- `control-server` owns auth, nodes, exports, grants, and mount profile issuance +- `web control plane` lets the user manage the NAS and get mount instructions +- `macOS client` starts as native Finder WebDAV mounting, with a thin helper later -The plan is bigger: phone, laptop, agents, all seeing the same filesystem. -A modular backup layer you actually use day-to-day, and a way to run agents on your own hardware without handing over the keys. +For now, the whole stack should be able to run on the user's NAS device. -image +## Current repo shape + +- `apps/node-agent` + - NAS-side Go runtime and WebDAV server +- `apps/control-plane` + - Go backend for auth, registry, and mount profile issuance +- `apps/web` + - Next.js web control plane +- `apps/nextcloud-app` + - optional Nextcloud adapter, not the product center +- `packages/contracts` + - canonical shared contracts +- `infra/docker` + - self-hosted local stack + +The main planning docs are: + +- [docs/architecture.md](./docs/architecture.md) +- [skeleton.md](./skeleton.md) +- [docs/05-build-plan.md](./docs/05-build-plan.md) + +## Default runtime model + +```text + self-hosted betterNAS on the user's NAS + + +------------------------------+ + | web control plane | + | Next.js UI | + +--------------+---------------+ + | + v + +------------------------------+ + | control-server | + | auth / nodes / exports | + | grants / mount profiles | + +--------------+---------------+ + | + v + +------------------------------+ + | node-service | + | WebDAV + export runtime | + | real NAS bytes | + +------------------------------+ + + user Mac + | + +--> browser -> web control plane + | + +--> Finder -> WebDAV mount URL from control-server +``` + +## Verify + +Static verification: + +```bash +pnpm verify +``` + +Bootstrap clone-local runtime settings: + +```bash +pnpm agent:bootstrap +``` + +Bring the self-hosted stack up, verify it, and tear it down: + +```bash +pnpm stack:up +pnpm stack:verify +pnpm stack:down --volumes +``` + +Run the full loop: + +```bash +pnpm agent:verify +``` + +## Current end-to-end slice + +The first proven slice is: + +1. boot the stack with `pnpm stack:up` +2. verify it with `pnpm stack:verify` +3. get the WebDAV mount profile from the control plane +4. mount it in Finder with the issued credentials + +If the stack is running on a remote machine, tunnel the WebDAV port first, then +use Finder `Connect to Server` with the tunneled URL. + +## Product boundary + +The default betterNAS product is self-hosted and WebDAV-first. + +Nextcloud remains optional and secondary: + +- useful later for browser/mobile/share surfaces +- not required for the core mount flow +- not the system of record diff --git a/docs/repository-root/TODO.md b/TODO.md similarity index 100% rename from docs/repository-root/TODO.md rename to TODO.md diff --git a/apps/control-plane/cmd/control-plane/app.go b/apps/control-plane/cmd/control-plane/app.go index 4fb54b8..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 ( - "sort" - "strings" "time" ) @@ -12,7 +10,6 @@ type appConfig struct { statePath string dbPath string sessionTTL time.Duration - nodeOfflineThreshold time.Duration registrationEnabled bool corsOrigin string } @@ -24,13 +21,7 @@ type app struct { store store } -const defaultNodeOfflineThreshold = 2 * time.Minute - func newApp(config appConfig, startedAt time.Time) (*app, error) { - if config.nodeOfflineThreshold <= 0 { - config.nodeOfflineThreshold = defaultNodeOfflineThreshold - } - var s store var err error if config.dbPath != "" { @@ -50,68 +41,6 @@ func newApp(config appConfig, startedAt time.Time) (*app, error) { }, nil } -func (a *app) presentedNode(node nasNode) nasNode { - presented := copyNasNode(node) - if !nodeHeartbeatIsFresh(presented.LastSeenAt, a.now().UTC(), a.config.nodeOfflineThreshold) { - presented.Status = "offline" - } - return presented -} - -func (a *app) listNodes(ownerID string) []nasNode { - nodes := a.store.listNodes(ownerID) - presented := make([]nasNode, 0, len(nodes)) - for _, node := range nodes { - presented = append(presented, a.presentedNode(node)) - } - - sort.Slice(presented, func(i, j int) bool { - return presented[i].ID < presented[j].ID - }) - - return presented -} - -func (a *app) listConnectedExports(ownerID string) []storageExport { - exports := a.store.listExports(ownerID) - connected := make([]storageExport, 0, len(exports)) - for _, export := range exports { - context, ok := a.store.exportContext(export.ID, ownerID) - if !ok { - continue - } - if !nodeIsConnected(a.presentedNode(context.node)) { - continue - } - connected = append(connected, export) - } - - return connected -} - -func nodeHeartbeatIsFresh(lastSeenAt string, referenceTime time.Time, threshold time.Duration) bool { - lastSeenAt = strings.TrimSpace(lastSeenAt) - if threshold <= 0 || lastSeenAt == "" { - return false - } - - parsedLastSeenAt, err := time.Parse(time.RFC3339, lastSeenAt) - if err != nil { - return false - } - - referenceTime = referenceTime.UTC() - if parsedLastSeenAt.After(referenceTime) { - return true - } - - return referenceTime.Sub(parsedLastSeenAt) <= threshold -} - -func nodeIsConnected(node nasNode) bool { - return node.Status == "online" || node.Status == "degraded" -} - type nextcloudBackendStatus struct { Configured bool `json:"configured"` BaseURL string `json:"baseUrl"` diff --git a/apps/control-plane/cmd/control-plane/main.go b/apps/control-plane/cmd/control-plane/main.go index 298e55f..d89196e 100644 --- a/apps/control-plane/cmd/control-plane/main.go +++ b/apps/control-plane/cmd/control-plane/main.go @@ -36,16 +36,6 @@ func newAppFromEnv(startedAt time.Time) (*app, error) { sessionTTL = parsedSessionTTL } - nodeOfflineThreshold := defaultNodeOfflineThreshold - rawNodeOfflineThreshold := strings.TrimSpace(env("BETTERNAS_NODE_OFFLINE_THRESHOLD", "2m")) - if rawNodeOfflineThreshold != "" { - parsedNodeOfflineThreshold, err := time.ParseDuration(rawNodeOfflineThreshold) - if err != nil { - return nil, err - } - nodeOfflineThreshold = parsedNodeOfflineThreshold - } - app, err := newApp( appConfig{ version: env("BETTERNAS_VERSION", "0.1.0-dev"), @@ -53,7 +43,6 @@ func newAppFromEnv(startedAt time.Time) (*app, error) { statePath: env("BETTERNAS_CONTROL_PLANE_STATE_PATH", ".state/control-plane/state.json"), dbPath: env("BETTERNAS_CONTROL_PLANE_DB_PATH", ".state/control-plane/betternas.db"), sessionTTL: sessionTTL, - nodeOfflineThreshold: nodeOfflineThreshold, registrationEnabled: env("BETTERNAS_REGISTRATION_ENABLED", "true") == "true", corsOrigin: env("BETTERNAS_CORS_ORIGIN", ""), }, 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 2e93f6e..0fb13ac 100644 --- a/apps/control-plane/cmd/control-plane/runtime_integration_test.go +++ b/apps/control-plane/cmd/control-plane/runtime_integration_test.go @@ -56,9 +56,8 @@ func TestControlPlaneBinaryMountLoopIntegration(t *testing.T) { mount := postJSONAuth[mountProfile](t, client, controlPlane.sessionToken, controlPlane.baseURL+"/api/v1/mount-profiles/issue", mountProfileRequest{ ExportID: export.ID, }) - expectedMountURL := nodeAgent.baseURL + defaultWebDAVPath + runtimeUsername + "/" - if mount.MountURL != expectedMountURL { - t.Fatalf("expected runtime mount URL %q, got %q", expectedMountURL, mount.MountURL) + if mount.MountURL != nodeAgent.baseURL+defaultWebDAVPath { + t.Fatalf("expected runtime mount URL %q, got %q", nodeAgent.baseURL+defaultWebDAVPath, mount.MountURL) } if mount.Credential.Mode != mountCredentialModeBasicAuth { t.Fatalf("expected mount credential mode %q, got %q", mountCredentialModeBasicAuth, mount.Credential.Mode) @@ -104,13 +103,11 @@ func TestControlPlaneBinaryMultiExportProfilesStayDistinct(t *testing.T) { if firstMount.MountURL == secondMount.MountURL { t.Fatalf("expected distinct runtime mount URLs, got %q", firstMount.MountURL) } - expectedFirstMountURL := nodeAgent.baseURL + firstMountPath + runtimeUsername + "/" - expectedSecondMountURL := nodeAgent.baseURL + secondMountPath + runtimeUsername + "/" - if firstMount.MountURL != expectedFirstMountURL { - t.Fatalf("expected first runtime mount URL %q, got %q", expectedFirstMountURL, firstMount.MountURL) + if firstMount.MountURL != nodeAgent.baseURL+firstMountPath { + t.Fatalf("expected first runtime mount URL %q, got %q", nodeAgent.baseURL+firstMountPath, firstMount.MountURL) } - if secondMount.MountURL != expectedSecondMountURL { - t.Fatalf("expected second runtime mount URL %q, got %q", expectedSecondMountURL, secondMount.MountURL) + if secondMount.MountURL != nodeAgent.baseURL+secondMountPath { + t.Fatalf("expected second runtime mount URL %q, got %q", nodeAgent.baseURL+secondMountPath, secondMount.MountURL) } assertHTTPStatusWithBasicAuth(t, client, "PROPFIND", firstMount.MountURL, controlPlane.username, controlPlane.password, http.StatusMultiStatus) diff --git a/apps/control-plane/cmd/control-plane/server.go b/apps/control-plane/cmd/control-plane/server.go index 092bedc..810dbb0 100644 --- a/apps/control-plane/cmd/control-plane/server.go +++ b/apps/control-plane/cmd/control-plane/server.go @@ -36,7 +36,6 @@ func (a *app) handler() http.Handler { mux.HandleFunc("POST /api/v1/auth/login", a.handleAuthLogin) mux.HandleFunc("POST /api/v1/auth/logout", a.handleAuthLogout) mux.HandleFunc("GET /api/v1/auth/me", a.handleAuthMe) - mux.HandleFunc("GET /api/v1/nodes", a.handleNodesList) mux.HandleFunc("POST /api/v1/nodes/register", a.handleNodeRegister) mux.HandleFunc("POST /api/v1/nodes/{nodeId}/heartbeat", a.handleNodeHeartbeat) mux.HandleFunc("PUT /api/v1/nodes/{nodeId}/exports", a.handleNodeExports) @@ -75,15 +74,6 @@ func (a *app) handleVersion(w http.ResponseWriter, _ *http.Request) { }) } -func (a *app) handleNodesList(w http.ResponseWriter, r *http.Request) { - currentUser, ok := a.requireSessionUser(w, r) - if !ok { - return - } - - writeJSON(w, http.StatusOK, a.listNodes(currentUser.ID)) -} - func (a *app) handleNodeRegister(w http.ResponseWriter, r *http.Request) { currentUser, ok := a.requireSessionUser(w, r) if !ok { @@ -187,7 +177,7 @@ func (a *app) handleExportsList(w http.ResponseWriter, r *http.Request) { return } - writeJSON(w, http.StatusOK, a.listConnectedExports(currentUser.ID)) + writeJSON(w, http.StatusOK, a.store.listExports(currentUser.ID)) } func (a *app) handleMountProfileIssue(w http.ResponseWriter, r *http.Request) { @@ -212,13 +202,8 @@ func (a *app) handleMountProfileIssue(w http.ResponseWriter, r *http.Request) { http.Error(w, errExportNotFound.Error(), http.StatusNotFound) return } - context.node = a.presentedNode(context.node) - if !nodeIsConnected(context.node) { - http.Error(w, errMountTargetUnavailable.Error(), http.StatusServiceUnavailable) - return - } - mountURL, err := buildMountURL(context, currentUser.Username) + mountURL, err := buildMountURL(context) if err != nil { http.Error(w, err.Error(), http.StatusServiceUnavailable) return @@ -720,17 +705,13 @@ func hasConfiguredNextcloudBaseURL(baseURL string) bool { return err == nil } -func buildMountURL(context exportContext, username string) (string, error) { +func buildMountURL(context exportContext) (string, error) { address, ok := firstAddress(context.node.DirectAddress, context.node.RelayAddress) if !ok { return "", errMountTargetUnavailable } - basePath := mountProfilePathForExport(context.export.MountPath) - // Append the username so Finder uses it as the volume name in the sidebar. - userScopedPath := path.Join(basePath, username) + "/" - - mountURL, err := buildAbsoluteHTTPURLWithPath(address, userScopedPath) + mountURL, err := buildAbsoluteHTTPURLWithPath(address, mountProfilePathForExport(context.export.MountPath)) if err != nil { return "", errMountTargetUnavailable } diff --git a/apps/control-plane/cmd/control-plane/server_test.go b/apps/control-plane/cmd/control-plane/server_test.go index c714a89..adf5741 100644 --- a/apps/control-plane/cmd/control-plane/server_test.go +++ b/apps/control-plane/cmd/control-plane/server_test.go @@ -162,8 +162,8 @@ func TestControlPlaneRegistrationProfilesAndHeartbeat(t *testing.T) { if mount.DisplayName != "Photos" { t.Fatalf("expected mount display name Photos, got %q", mount.DisplayName) } - if mount.MountURL != "http://nas.local:8090/dav/fixture/" { - t.Fatalf("expected mount URL %q, got %q", "http://nas.local:8090/dav/fixture/", mount.MountURL) + if mount.MountURL != "http://nas.local:8090/dav/" { + t.Fatalf("expected mount URL %q, got %q", "http://nas.local:8090/dav/", mount.MountURL) } if mount.Readonly { t.Fatal("expected mount profile to be read-write") @@ -415,11 +415,11 @@ func TestControlPlaneProfilesRemainExportSpecificForConfiguredMountPaths(t *test if docsMount.MountURL == mediaMount.MountURL { t.Fatalf("expected distinct mount URLs for configured export paths, got %q", docsMount.MountURL) } - if docsMount.MountURL != "http://nas.local:8090/dav/exports/docs/fixture/" { - t.Fatalf("expected docs mount URL %q, got %q", "http://nas.local:8090/dav/exports/docs/fixture/", docsMount.MountURL) + if docsMount.MountURL != "http://nas.local:8090/dav/exports/docs/" { + t.Fatalf("expected docs mount URL %q, got %q", "http://nas.local:8090/dav/exports/docs/", docsMount.MountURL) } - if mediaMount.MountURL != "http://nas.local:8090/dav/exports/media/fixture/" { - t.Fatalf("expected media mount URL %q, got %q", "http://nas.local:8090/dav/exports/media/fixture/", mediaMount.MountURL) + if mediaMount.MountURL != "http://nas.local:8090/dav/exports/media/" { + t.Fatalf("expected media mount URL %q, got %q", "http://nas.local:8090/dav/exports/media/", mediaMount.MountURL) } docsCloud := postJSONAuth[cloudProfile](t, server.Client(), testClientToken, server.URL+"/api/v1/cloud-profiles/issue", cloudProfileRequest{ @@ -469,8 +469,8 @@ func TestControlPlaneMountProfilesUseRelayAndPreserveBasePath(t *testing.T) { }) mount := postJSONAuth[mountProfile](t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: "dev-export"}) - if mount.MountURL != "https://nas.example.test/control/dav/relay/fixture/" { - t.Fatalf("expected relay mount URL %q, got %q", "https://nas.example.test/control/dav/relay/fixture/", mount.MountURL) + if mount.MountURL != "https://nas.example.test/control/dav/relay/" { + t.Fatalf("expected relay mount URL %q, got %q", "https://nas.example.test/control/dav/relay/", mount.MountURL) } registration = registerNode(t, server.Client(), server.URL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{ @@ -494,69 +494,6 @@ func TestControlPlaneMountProfilesUseRelayAndPreserveBasePath(t *testing.T) { postJSONAuthStatus(t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: "dev-export-2"}, http.StatusServiceUnavailable) } -func TestControlPlaneOfflineNodesAreListedButHiddenFromMountableExports(t *testing.T) { - t.Parallel() - - app, server := newTestControlPlaneServer(t, appConfig{ - version: "test-version", - nodeOfflineThreshold: time.Minute, - }) - defer server.Close() - - directAddress := "http://nas.local:8090" - registration := registerNode(t, server.Client(), server.URL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{ - MachineID: "machine-offline-filter", - DisplayName: "Offline Filter NAS", - AgentVersion: "1.2.3", - DirectAddress: &directAddress, - RelayAddress: nil, - }) - syncNodeExports(t, server.Client(), registration.NodeToken, server.URL+"/api/v1/nodes/"+registration.Node.ID+"/exports", nodeExportsRequest{ - Exports: []storageExportInput{{ - Label: "Docs", - Path: "/srv/docs", - MountPath: "/dav/", - Protocols: []string{"webdav"}, - CapacityBytes: nil, - Tags: []string{}, - }}, - }) - - initialNodes := getJSONAuth[[]nasNode](t, server.Client(), testClientToken, server.URL+"/api/v1/nodes") - if len(initialNodes) != 1 { - t.Fatalf("expected 1 node before staleness, got %d", len(initialNodes)) - } - if initialNodes[0].Status != "online" { - t.Fatalf("expected node to start online, got %q", initialNodes[0].Status) - } - - initialExports := getJSONAuth[[]storageExport](t, server.Client(), testClientToken, server.URL+"/api/v1/exports") - if len(initialExports) != 1 { - t.Fatalf("expected 1 connected export before staleness, got %d", len(initialExports)) - } - - app.now = func() time.Time { - return testControlPlaneNow.Add(2 * time.Minute) - } - - nodes := getJSONAuth[[]nasNode](t, server.Client(), testClientToken, server.URL+"/api/v1/nodes") - if len(nodes) != 1 { - t.Fatalf("expected 1 node after staleness, got %d", len(nodes)) - } - if nodes[0].Status != "offline" { - t.Fatalf("expected stale node to be offline, got %q", nodes[0].Status) - } - - exports := getJSONAuth[[]storageExport](t, server.Client(), testClientToken, server.URL+"/api/v1/exports") - if len(exports) != 0 { - t.Fatalf("expected stale node exports to be hidden, got %d", len(exports)) - } - - postJSONAuthStatus(t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{ - ExportID: "dev-export", - }, http.StatusServiceUnavailable) -} - func TestControlPlaneCloudProfilesRequireConfiguredBaseURLAndExistingExport(t *testing.T) { t.Parallel() @@ -648,8 +585,8 @@ func TestControlPlanePersistsRegistryAcrossAppRestart(t *testing.T) { } mount := postJSONAuth[mountProfile](t, secondServer.Client(), testClientToken, secondServer.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: exports[0].ID}) - if mount.MountURL != "http://nas.local:8090/dav/persisted/fixture/" { - t.Fatalf("expected persisted mount URL %q, got %q", "http://nas.local:8090/dav/persisted/fixture/", mount.MountURL) + if mount.MountURL != "http://nas.local:8090/dav/persisted/" { + t.Fatalf("expected persisted mount URL %q, got %q", "http://nas.local:8090/dav/persisted/", mount.MountURL) } reRegistration := registerNode(t, secondServer.Client(), secondServer.URL+"/api/v1/nodes/register", registration.NodeToken, nodeRegistrationRequest{ @@ -808,8 +745,6 @@ func TestControlPlaneRejectsInvalidRequestsAndEnforcesAuth(t *testing.T) { LastSeenAt: "2025-01-02T03:04:05Z", }, http.StatusNotFound) - getStatusWithAuth(t, server.Client(), "", server.URL+"/api/v1/nodes", http.StatusUnauthorized) - getStatusWithAuth(t, server.Client(), "wrong-client-token", server.URL+"/api/v1/nodes", http.StatusUnauthorized) getStatusWithAuth(t, server.Client(), "", server.URL+"/api/v1/exports", http.StatusUnauthorized) getStatusWithAuth(t, server.Client(), "wrong-client-token", server.URL+"/api/v1/exports", http.StatusUnauthorized) diff --git a/apps/control-plane/cmd/control-plane/sqlite_store.go b/apps/control-plane/cmd/control-plane/sqlite_store.go index 0ae277e..a0050ed 100644 --- a/apps/control-plane/cmd/control-plane/sqlite_store.go +++ b/apps/control-plane/cmd/control-plane/sqlite_store.go @@ -346,27 +346,6 @@ func (s *sqliteStore) listExports(ownerID string) []storageExport { return exports } -func (s *sqliteStore) listNodes(ownerID string) []nasNode { - rows, err := s.db.Query("SELECT id, machine_id, owner_id, display_name, agent_version, status, last_seen_at, direct_address, relay_address FROM nodes WHERE owner_id = ? ORDER BY id", ownerID) - if err != nil { - return nil - } - defer rows.Close() - - var nodes []nasNode - for rows.Next() { - node := s.scanNode(rows) - if node.ID != "" { - nodes = append(nodes, node) - } - } - if nodes == nil { - nodes = []nasNode{} - } - - return nodes -} - func (s *sqliteStore) listExportsForNode(nodeID string) []storageExport { 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 { @@ -422,29 +401,15 @@ func (s *sqliteStore) exportContext(exportID string, ownerID string) (exportCont } func (s *sqliteStore) nodeByID(nodeID string) (nasNode, bool) { - row := s.db.QueryRow( - "SELECT id, machine_id, owner_id, display_name, agent_version, status, last_seen_at, direct_address, relay_address FROM nodes WHERE id = ?", - nodeID) - n := s.scanNode(row) - if n.ID == "" { - return nasNode{}, false - } - - return n, true -} - -type sqliteNodeScanner interface { - Scan(dest ...any) error -} - -func (s *sqliteStore) scanNode(scanner sqliteNodeScanner) nasNode { var n nasNode var directAddr, relayAddr sql.NullString var lastSeenAt sql.NullString var ownerID sql.NullString - err := scanner.Scan(&n.ID, &n.MachineID, &ownerID, &n.DisplayName, &n.AgentVersion, &n.Status, &lastSeenAt, &directAddr, &relayAddr) + err := s.db.QueryRow( + "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{} + return nasNode{}, false } if ownerID.Valid { n.OwnerID = ownerID.String @@ -458,7 +423,7 @@ func (s *sqliteStore) scanNode(scanner sqliteNodeScanner) nasNode { if relayAddr.Valid { n.RelayAddress = &relayAddr.String } - return n + return n, true } func (s *sqliteStore) nodeAuthByMachineID(machineID string) (nodeAuthState, bool) { 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 0be0d53..1dda815 100644 --- a/apps/control-plane/cmd/control-plane/sqlite_store_test.go +++ b/apps/control-plane/cmd/control-plane/sqlite_store_test.go @@ -102,8 +102,8 @@ func TestSQLiteRegistrationAndExports(t *testing.T) { } mount := postJSONAuth[mountProfile](t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: "dev-export"}) - if mount.MountURL != "http://nas.local:8090/dav/docs/fixture/" { - t.Fatalf("expected mount URL %q, got %q", "http://nas.local:8090/dav/docs/fixture/", mount.MountURL) + if mount.MountURL != "http://nas.local:8090/dav/docs/" { + t.Fatalf("expected mount URL %q, got %q", "http://nas.local:8090/dav/docs/", mount.MountURL) } } diff --git a/apps/control-plane/cmd/control-plane/store.go b/apps/control-plane/cmd/control-plane/store.go index 23eb7e6..6ebb65a 100644 --- a/apps/control-plane/cmd/control-plane/store.go +++ b/apps/control-plane/cmd/control-plane/store.go @@ -320,25 +320,6 @@ func (s *memoryStore) listExports(ownerID string) []storageExport { return exports } -func (s *memoryStore) listNodes(ownerID string) []nasNode { - s.mu.RLock() - defer s.mu.RUnlock() - - nodes := make([]nasNode, 0, len(s.state.NodesByID)) - for _, node := range s.state.NodesByID { - if node.OwnerID != ownerID { - continue - } - nodes = append(nodes, copyNasNode(node)) - } - - sort.Slice(nodes, func(i, j int) bool { - return nodes[i].ID < nodes[j].ID - }) - - return nodes -} - func (s *memoryStore) exportContext(exportID string, ownerID string) (exportContext, bool) { s.mu.RLock() defer s.mu.RUnlock() diff --git a/apps/control-plane/cmd/control-plane/store_iface.go b/apps/control-plane/cmd/control-plane/store_iface.go index 2449b96..409e894 100644 --- a/apps/control-plane/cmd/control-plane/store_iface.go +++ b/apps/control-plane/cmd/control-plane/store_iface.go @@ -9,7 +9,6 @@ type store interface { upsertExports(nodeID string, ownerID string, request nodeExportsRequest) ([]storageExport, error) recordHeartbeat(nodeID string, ownerID string, request nodeHeartbeatRequest) error listExports(ownerID string) []storageExport - listNodes(ownerID string) []nasNode exportContext(exportID string, ownerID string) (exportContext, bool) nodeByID(nodeID string) (nasNode, bool) diff --git a/apps/node-agent/cmd/node-agent/app.go b/apps/node-agent/cmd/node-agent/app.go index 1a87468..679ea94 100644 --- a/apps/node-agent/cmd/node-agent/app.go +++ b/apps/node-agent/cmd/node-agent/app.go @@ -1,16 +1,13 @@ package main import ( - "context" "crypto/subtle" "encoding/json" "errors" "fmt" - "log" "net/http" "os" "strings" - "time" "golang.org/x/net/webdav" ) @@ -30,7 +27,6 @@ type app struct { authUsername string authPassword string exportMounts []exportMount - controlPlane *controlPlaneSession } type exportMount struct { @@ -73,92 +69,17 @@ func newAppFromEnv() (*app, error) { if err != nil { return nil, err } - var controlPlane *controlPlaneSession if strings.TrimSpace(env("BETTERNAS_CONTROL_PLANE_URL", "")) != "" { - session, err := bootstrapNodeAgentFromEnv(exportPaths) - if err != nil { + if _, err := bootstrapNodeAgentFromEnv(exportPaths); err != nil { return nil, err } - controlPlane = &session } - app, err := newApp(appConfig{ + return newApp(appConfig{ exportPaths: exportPaths, authUsername: authUsername, authPassword: authPassword, }) - if err != nil { - return nil, err - } - app.controlPlane = controlPlane - return app, nil -} - -func (a *app) startControlPlaneLoop(ctx context.Context) { - if a.controlPlane == nil { - return - } - - go runNodeHeartbeatLoop( - ctx, - &http.Client{Timeout: 5 * time.Second}, - a.controlPlane.controlPlaneURL, - a.controlPlane.sessionToken, - a.controlPlane.nodeID, - a.controlPlane.heartbeatInterval, - time.Now, - log.Default(), - ) -} - -func (a *app) controlPlaneEnabled() bool { - return a.controlPlane != nil -} - -func defaultNodeHeartbeatInterval() time.Duration { - return 30 * time.Second -} - -func heartbeatIntervalFromEnv() (time.Duration, error) { - rawInterval := strings.TrimSpace(env("BETTERNAS_NODE_HEARTBEAT_INTERVAL", "30s")) - if rawInterval == "" { - return defaultNodeHeartbeatInterval(), nil - } - - interval, err := time.ParseDuration(rawInterval) - if err != nil { - return 0, err - } - if interval <= 0 { - return 0, errors.New("BETTERNAS_NODE_HEARTBEAT_INTERVAL must be greater than zero") - } - - return interval, nil -} - -func runNodeHeartbeatLoop( - ctx context.Context, - client *http.Client, - baseURL string, - sessionToken string, - nodeID string, - interval time.Duration, - now func() time.Time, - logger *log.Logger, -) { - ticker := time.NewTicker(interval) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - if err := sendNodeHeartbeatAt(client, baseURL, sessionToken, nodeID, now().UTC()); err != nil && logger != nil { - logger.Printf("betternas node heartbeat failed: %v", err) - } - } - } } func exportPathsFromEnv() ([]string, error) { @@ -234,24 +155,12 @@ func (a *app) handler() http.Handler { for _, mount := range a.exportMounts { mountPathPrefix := strings.TrimSuffix(mount.mountPath, "/") fs := webdav.Dir(mount.exportPath) - lockSystem := webdav.NewMemLS() dav := &webdav.Handler{ Prefix: mountPathPrefix, FileSystem: fs, - LockSystem: lockSystem, + LockSystem: webdav.NewMemLS(), } mux.Handle(mount.mountPath, a.requireDAVAuth(mount, finderCompatible(dav, fs, mountPathPrefix))) - - // Register a username-scoped handler at {mountPath}{username}/ so - // Finder shows the username as the volume name in the sidebar. - userScopedPath := mount.mountPath + a.authUsername + "/" - userScopedPrefix := strings.TrimSuffix(userScopedPath, "/") - userDav := &webdav.Handler{ - Prefix: userScopedPrefix, - FileSystem: fs, - LockSystem: lockSystem, - } - mux.Handle(userScopedPath, a.requireDAVAuth(mount, finderCompatible(userDav, fs, userScopedPrefix))) } return mux diff --git a/apps/node-agent/cmd/node-agent/control_plane.go b/apps/node-agent/cmd/node-agent/control_plane.go index dca7dda..570bbec 100644 --- a/apps/node-agent/cmd/node-agent/control_plane.go +++ b/apps/node-agent/cmd/node-agent/control_plane.go @@ -14,11 +14,8 @@ import ( "time" ) -type controlPlaneSession struct { - nodeID string - controlPlaneURL string - sessionToken string - heartbeatInterval time.Duration +type bootstrapResult struct { + nodeID string } type nodeRegistrationRequest struct { @@ -61,23 +58,19 @@ type nodeHeartbeatRequest struct { LastSeenAt string `json:"lastSeenAt"` } -func bootstrapNodeAgentFromEnv(exportPaths []string) (controlPlaneSession, error) { +func bootstrapNodeAgentFromEnv(exportPaths []string) (bootstrapResult, error) { controlPlaneURL := strings.TrimSpace(env("BETTERNAS_CONTROL_PLANE_URL", "https://api.betternas.com")) if controlPlaneURL == "" { - return controlPlaneSession{}, fmt.Errorf("BETTERNAS_CONTROL_PLANE_URL is required") + return bootstrapResult{}, fmt.Errorf("BETTERNAS_CONTROL_PLANE_URL is required") } username, err := requiredEnv("BETTERNAS_USERNAME") if err != nil { - return controlPlaneSession{}, err + return bootstrapResult{}, err } password, err := requiredEnv("BETTERNAS_PASSWORD") if err != nil { - return controlPlaneSession{}, err - } - heartbeatInterval, err := heartbeatIntervalFromEnv() - if err != nil { - return controlPlaneSession{}, err + return bootstrapResult{}, err } machineID := strings.TrimSpace(env("BETTERNAS_NODE_MACHINE_ID", defaultNodeMachineID(username))) @@ -89,7 +82,7 @@ func bootstrapNodeAgentFromEnv(exportPaths []string) (controlPlaneSession, error client := &http.Client{Timeout: 5 * time.Second} sessionToken, err := loginWithControlPlane(client, controlPlaneURL, username, password) if err != nil { - return controlPlaneSession{}, err + return bootstrapResult{}, err } registration, err := registerNodeWithControlPlane(client, controlPlaneURL, sessionToken, nodeRegistrationRequest{ @@ -100,22 +93,17 @@ func bootstrapNodeAgentFromEnv(exportPaths []string) (controlPlaneSession, error RelayAddress: optionalEnvPointer("BETTERNAS_NODE_RELAY_ADDRESS"), }) if err != nil { - return controlPlaneSession{}, err + return bootstrapResult{}, err } if err := syncNodeExportsWithControlPlane(client, controlPlaneURL, sessionToken, registration.ID, buildStorageExportInputs(exportPaths)); err != nil { - return controlPlaneSession{}, err + return bootstrapResult{}, err } - if err := sendNodeHeartbeatAt(client, controlPlaneURL, sessionToken, registration.ID, time.Now().UTC()); err != nil { - return controlPlaneSession{}, err + if err := sendNodeHeartbeat(client, controlPlaneURL, sessionToken, registration.ID); err != nil { + return bootstrapResult{}, err } - return controlPlaneSession{ - nodeID: registration.ID, - controlPlaneURL: controlPlaneURL, - sessionToken: sessionToken, - heartbeatInterval: heartbeatInterval, - }, nil + return bootstrapResult{nodeID: registration.ID}, nil } func loginWithControlPlane(client *http.Client, baseURL string, username string, password string) (string, error) { @@ -180,14 +168,10 @@ func syncNodeExportsWithControlPlane(client *http.Client, baseURL string, token } func sendNodeHeartbeat(client *http.Client, baseURL string, token string, nodeID string) error { - return sendNodeHeartbeatAt(client, baseURL, token, nodeID, time.Now().UTC()) -} - -func sendNodeHeartbeatAt(client *http.Client, baseURL string, token string, nodeID string, at time.Time) error { response, err := doControlPlaneJSONRequest(client, http.MethodPost, controlPlaneEndpoint(baseURL, "/api/v1/nodes/"+nodeID+"/heartbeat"), token, nodeHeartbeatRequest{ NodeID: nodeID, Status: "online", - LastSeenAt: at.UTC().Format(time.RFC3339), + LastSeenAt: time.Now().UTC().Format(time.RFC3339), }) if err != nil { return err diff --git a/apps/node-agent/cmd/node-agent/control_plane_test.go b/apps/node-agent/cmd/node-agent/control_plane_test.go deleted file mode 100644 index 884ec56..0000000 --- a/apps/node-agent/cmd/node-agent/control_plane_test.go +++ /dev/null @@ -1,97 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "io" - "log" - "net/http" - "net/http/httptest" - "sync" - "testing" - "time" -) - -func TestRunNodeHeartbeatLoopSendsRecurringHeartbeats(t *testing.T) { - t.Parallel() - - var mu sync.Mutex - var heartbeats []nodeHeartbeatRequest - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost || r.URL.Path != "/api/v1/nodes/dev-node/heartbeat" { - http.NotFound(w, r) - return - } - - body, err := io.ReadAll(r.Body) - if err != nil { - t.Fatalf("read heartbeat body: %v", err) - } - _ = r.Body.Close() - - var heartbeat nodeHeartbeatRequest - if err := json.Unmarshal(body, &heartbeat); err != nil { - t.Fatalf("decode heartbeat body: %v", err) - } - - mu.Lock() - heartbeats = append(heartbeats, heartbeat) - mu.Unlock() - w.WriteHeader(http.StatusNoContent) - })) - defer server.Close() - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - done := make(chan struct{}) - go func() { - runNodeHeartbeatLoop( - ctx, - server.Client(), - server.URL, - "session-token", - "dev-node", - 10*time.Millisecond, - func() time.Time { return time.Date(2025, time.January, 1, 12, 0, 0, 0, time.UTC) }, - log.New(io.Discard, "", 0), - ) - close(done) - }() - - deadline := time.Now().Add(500 * time.Millisecond) - for { - mu.Lock() - count := len(heartbeats) - mu.Unlock() - if count >= 2 { - break - } - if time.Now().After(deadline) { - t.Fatalf("expected recurring heartbeats, got %d", count) - } - time.Sleep(10 * time.Millisecond) - } - - cancel() - - select { - case <-done: - case <-time.After(time.Second): - t.Fatal("heartbeat loop did not stop after context cancellation") - } - - mu.Lock() - defer mu.Unlock() - for _, heartbeat := range heartbeats { - if heartbeat.NodeID != "dev-node" { - t.Fatalf("expected node ID dev-node, got %q", heartbeat.NodeID) - } - if heartbeat.Status != "online" { - t.Fatalf("expected status online, got %q", heartbeat.Status) - } - if heartbeat.LastSeenAt != "2025-01-01T12:00:00Z" { - t.Fatalf("expected fixed lastSeenAt, got %q", heartbeat.LastSeenAt) - } - } -} diff --git a/apps/node-agent/cmd/node-agent/main.go b/apps/node-agent/cmd/node-agent/main.go index 1842e33..1b7e84c 100644 --- a/apps/node-agent/cmd/node-agent/main.go +++ b/apps/node-agent/cmd/node-agent/main.go @@ -1,12 +1,9 @@ package main import ( - "context" "log" "net/http" "os" - "os/signal" - "syscall" "time" ) @@ -17,19 +14,6 @@ func main() { log.Fatal(err) } - controlPlaneCtx, stopControlPlane := context.WithCancel(context.Background()) - defer stopControlPlane() - if app.controlPlaneEnabled() { - app.startControlPlaneLoop(controlPlaneCtx) - } - - signalContext, stopSignals := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stopSignals() - go func() { - <-signalContext.Done() - stopControlPlane() - }() - server := &http.Server{ Addr: ":" + port, Handler: app.handler(), diff --git a/apps/web/app/app/page.tsx b/apps/web/app/app/page.tsx deleted file mode 100644 index 2e3dc8a..0000000 --- a/apps/web/app/app/page.tsx +++ /dev/null @@ -1,283 +0,0 @@ -"use client"; - -import { useEffect, useState } from "react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { SignOut } from "@phosphor-icons/react"; -import { - isAuthenticated, - listExports, - listNodes, - issueMountProfile, - logout, - getMe, - type StorageExport, - type MountProfile, - type NasNode, - type User, - ApiError, -} from "@/lib/api"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; -import { CopyField } from "../copy-field"; - -export default function Home() { - const router = useRouter(); - const [user, setUser] = useState(null); - const [nodes, setNodes] = useState([]); - const [exports, setExports] = useState([]); - const [selectedExportId, setSelectedExportId] = useState(null); - const [mountProfile, setMountProfile] = useState(null); - const [feedback, setFeedback] = useState(null); - const [loading, setLoading] = useState(true); - - useEffect(() => { - if (!isAuthenticated()) { - router.replace("/login"); - return; - } - - async function load() { - try { - const [me, registeredNodes, exps] = await Promise.all([ - getMe(), - listNodes(), - listExports(), - ]); - setUser(me); - setNodes(registeredNodes); - setExports(exps); - } catch (err) { - if (err instanceof ApiError && err.status === 401) { - router.replace("/login"); - return; - } - setFeedback(err instanceof Error ? err.message : "Failed to load"); - } finally { - setLoading(false); - } - } - - load(); - }, [router]); - - async function handleSelectExport(exportId: string) { - setSelectedExportId(exportId); - setMountProfile(null); - setFeedback(null); - - try { - const profile = await issueMountProfile(exportId); - setMountProfile(profile); - } catch (err) { - setFeedback( - err instanceof Error ? err.message : "Failed to issue mount profile", - ); - } - } - - async function handleLogout() { - await logout(); - router.replace("/login"); - } - - if (loading) { - return ( -
-

Loading...

-
- ); - } - - const selectedExport = selectedExportId - ? (exports.find((e) => e.id === selectedExportId) ?? null) - : null; - - return ( -
-
- {/* header */} -
-
- - betterNAS - -

- Control Plane -

-
- {user && ( -
- - Docs - - - {user.username} - - -
- )} -
- - {feedback !== null && ( -
- {feedback} -
- )} - - {/* nodes */} -
-
-

Nodes

- - {nodes.filter((n) => n.status === "online").length} online - {nodes.filter((n) => n.status === "offline").length > 0 && - `, ${nodes.filter((n) => n.status === "offline").length} offline`} - -
- - {nodes.length === 0 ? ( -

- No nodes registered yet. Install and start the node agent on the - machine that owns your files. -

- ) : ( -
- {nodes.map((node) => ( -
-
- - {node.displayName} - - - {node.directAddress ?? node.relayAddress ?? node.machineId} - - - Last seen {formatTimestamp(node.lastSeenAt)} - -
- - {node.status} - -
- ))} -
- )} -
- - {/* exports + mount */} -
- {/* exports list */} -
-

Exports

- - {exports.length === 0 ? ( -

- {nodes.length === 0 - ? "No exports yet. Start the node agent to register one." - : "No connected exports. Start the node agent or wait for reconnection."} -

- ) : ( -
- {exports.map((exp) => { - const isSelected = exp.id === selectedExportId; - - return ( - - ); - })} -
- )} -
- - {/* mount profile */} -
-

- {selectedExport ? `Mount ${selectedExport.label}` : "Mount"} -

- - {mountProfile === null ? ( -

- Select an export to see the mount URL and credentials. -

- ) : ( -
- - - -

- Use your betterNAS account password when Finder prompts. v1 - does not issue a separate WebDAV password. -

- -
-

Finder steps

-
    -
  1. 1. Go > Connect to Server in Finder.
  2. -
  3. 2. Paste the mount URL.
  4. -
  5. 3. Enter your betterNAS username and password.
  6. -
  7. 4. Optionally save to Keychain.
  8. -
-
-
- )} -
-
-
-
- ); -} - -function formatTimestamp(value: string): string { - const trimmedValue = value.trim(); - if (trimmedValue === "") return "Never"; - - const parsed = new Date(trimmedValue); - if (Number.isNaN(parsed.getTime())) return trimmedValue; - - return parsed.toLocaleString(); -} diff --git a/apps/web/app/docs/page.tsx b/apps/web/app/docs/page.tsx deleted file mode 100644 index 5d97e1b..0000000 --- a/apps/web/app/docs/page.tsx +++ /dev/null @@ -1,196 +0,0 @@ -"use client"; - -import { useState } from "react"; -import Link from "next/link"; -import { Check, Copy } from "@phosphor-icons/react"; - -function CodeBlock({ children, label }: { children: string; label?: string }) { - const [copied, setCopied] = useState(false); - - return ( -
- {label && ( - - {label} - - )} -
-        {children}
-      
- -
- ); -} - -export default function DocsPage() { - return ( -
-
- {/* header */} -
-
- - - - - - - Sign in - -
-
-

- betterNAS -

-

- Mount remote machines as native Finder volumes on your Mac. - Install a small agent on any box with files you care about, and - it shows up in Finder like a local drive. No sync clients, no - special apps - just your files, where you expect them. -

-

- The plan is bigger: phone, laptop, agents, all seeing the same - filesystem. A modular backup layer you actually use day-to-day, - and a way to run agents on your own hardware without handing over - the keys. -

-

- Getting started -

-

- One account works everywhere: the web app, the node agent, and - Finder. Set up the node, confirm it is online, then mount your - export. -

-
-
- - {/* prerequisites */} -
-

Prerequisites

-
    -
  • - A betterNAS account
  • -
  • - A machine with the files you want to expose
  • -
  • - An export folder on that machine
  • -
  • - - A public HTTPS URL that reaches your node directly (for Finder - mounting) -
  • -
-
- - {/* step 1 */} -
-

1. Install the node binary

-

- Run this on the machine that owns the files. -

- - {`curl -fsSL https://raw.githubusercontent.com/harivansh-afk/betterNAS/main/scripts/install-betternas-node.sh | sh`} - -
- - {/* step 2 */} -
-

2. Start the node

-

- Replace the placeholders with your account, export path, and public - node URL. -

- - {`BETTERNAS_CONTROL_PLANE_URL=https://api.betternas.com \\ -BETTERNAS_USERNAME=your-username \\ -BETTERNAS_PASSWORD='your-password' \\ -BETTERNAS_EXPORT_PATH=/absolute/path/to/export \\ -BETTERNAS_NODE_DIRECT_ADDRESS=https://your-public-node-url \\ -betternas-node`} - -
-

- Export path{" "} - - the directory you want to expose through betterNAS. -

-

- - Direct address - {" "} - - the real public HTTPS base URL that reaches your node directly. -

-
-
- - {/* step 3 */} -
-

3. Confirm the node is online

-

- Open the control plane after the node starts. You should see: -

-
    -
  • - Your node appears as online
  • -
  • - Your export appears in the exports list
  • -
  • - - Issuing a mount profile gives you a WebDAV URL, not an HTML - login page -
  • -
-
- - {/* step 4 */} -
-

4. Mount in Finder

-
    -
  1. 1. Open Finder, then Go > Connect to Server.
  2. -
  3. - 2. Copy the mount URL from the control plane and paste it in. -
  4. -
  5. - 3. Sign in with the same username and password you used for the - web app and node agent. -
  6. -
  7. - 4. Save to Keychain only if you want Finder to remember the - password. -
  8. -
-
- - {/* note about public urls */} -
-

A note on public URLs

-

- Finder mounting only works when the node URL is directly reachable - over HTTPS. Avoid gateways that show their own login page before - forwarding traffic. A good check: load{" "} - /dav/{" "} - on your node URL. A working node responds with WebDAV headers, not - HTML. -

-
-
-
- ); -} diff --git a/apps/web/app/landing/page.tsx b/apps/web/app/landing/page.tsx deleted file mode 100644 index 21fd92a..0000000 --- a/apps/web/app/landing/page.tsx +++ /dev/null @@ -1,446 +0,0 @@ -"use client"; - -import { useState } from "react"; -import Link from "next/link"; - -/* ------------------------------------------------------------------ */ -/* README content (rendered as simple markdown-ish HTML) */ -/* ------------------------------------------------------------------ */ - -const README_LINES = [ - { tag: "h1", text: "betterNAS" }, - { - tag: "p", - text: "betterNAS lets you mount remote machines as native Finder volumes on your Mac. Install a small agent on any box with files you care about, and it shows up in Finder like a local drive.", - }, - { - tag: "p", - text: "The goal is to build a modular filesystem you actually use natively", - }, -] as const; - -/* ------------------------------------------------------------------ */ -/* Icons */ -/* ------------------------------------------------------------------ */ - -function GithubIcon({ className }: { className?: string }) { - return ( - - - - ); -} - -function ClockIcon() { - return ( - - - - - ); -} - -function SharedIcon() { - return ( - - - - - - ); -} - -function LibraryIcon() { - return ( - - - - ); -} - -function AppIcon() { - return ( - - - - ); -} - -function DesktopIcon() { - return ( - - - - - ); -} - -function DownloadIcon() { - return ( - - - - ); -} - -function DocumentsIcon() { - return ( - - - - - ); -} - -function FolderIcon({ className }: { className?: string }) { - return ( - - - - ); -} - -function CloudIcon() { - return ( - - - - ); -} - -function HomeIcon() { - return ( - - - - ); -} - -function NetworkIcon() { - return ( - - - - - ); -} - -function AirdropIcon() { - return ( - - - - - ); -} - -/* ------------------------------------------------------------------ */ -/* README modal (Quick Look style) */ -/* ------------------------------------------------------------------ */ - -function ReadmeModal({ onClose }: { onClose: () => void }) { - return ( -
-
e.stopPropagation()} - > - {/* titlebar */} -
-
-
- - README.md - - -
- - {/* body */} -
-
- {README_LINES.map((block, i) => { - if (block.tag === "h1") - return ( -

- {block.text} -

- ); - return ( -

- {block.text} -

- ); - })} -
-
-
-
- ); -} - -/* ------------------------------------------------------------------ */ -/* Finder sidebar item */ -/* ------------------------------------------------------------------ */ - -function SidebarItem({ - icon, - label, - active, - accent, - onClick, -}: { - icon: React.ReactNode; - label: string; - active?: boolean; - accent?: string; - onClick?: () => void; -}) { - return ( - - ); -} - -/* ------------------------------------------------------------------ */ -/* Finder file grid item (folder) */ -/* ------------------------------------------------------------------ */ - -function GridFolder({ - name, - itemCount, - onClick, -}: { - name: string; - itemCount?: number; - onClick?: () => void; -}) { - return ( - - ); -} - -/* ------------------------------------------------------------------ */ -/* Finder file grid item (file) */ -/* ------------------------------------------------------------------ */ - -function GridFile({ - name, - meta, - onClick, -}: { - name: string; - meta?: string; - onClick?: () => void; -}) { - return ( - - ); -} - -/* ------------------------------------------------------------------ */ -/* Main page */ -/* ------------------------------------------------------------------ */ - -export default function LandingPage() { - const [readmeOpen, setReadmeOpen] = useState(false); - const [selectedSidebar, setSelectedSidebar] = useState("DAV"); - - return ( -
- {/* ---- header ---- */} -
-
- - Docs - - - Sign in - - - - -
-
- - {/* ---- finder ---- */} -
-
- {/* titlebar */} -
-
- - - -
- -
- DAV -
- - {/* forward/back placeholders */} -
- - - - - - -
-
- - {/* content area */} -
- {/* ---- sidebar ---- */} -
- {/* Favorites */} -

- Favorites -

- } label="Recents" /> - } label="Shared" /> - } label="Library" /> - } label="Applications" /> - } label="Desktop" /> - } label="Downloads" /> - } label="Documents" /> - } label="GitHub" /> - - {/* Locations */} -

- Locations -

- } label="rathi" /> - } label="hari-macbook-pro" /> - } - label="DAV" - active={selectedSidebar === "DAV"} - accent="text-[#65a2f8]" - onClick={() => setSelectedSidebar("DAV")} - /> - } label="AirDrop" /> -
- - {/* ---- file grid ---- */} -
- {/* toolbar */} -
-
- - DAV - / - exports -
-
- - - - - - - - - -
-
- - {/* files */} -
-
- - - - - - setReadmeOpen(true)} - /> -
-
- - {/* statusbar */} -
- 5 folders, 1 file - 847 GB available -
-
-
-
-
- - {/* ---- readme modal ---- */} - {readmeOpen && setReadmeOpen(false)} />} -
- ); -} diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx index 503c7c6..73eb23d 100644 --- a/apps/web/app/login/page.tsx +++ b/apps/web/app/login/page.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; -import Link from "next/link"; import { login, register, ApiError } from "@/lib/api"; import { Button } from "@/components/ui/button"; import { @@ -32,7 +31,7 @@ export default function LoginPage() { } else { await register(username, password); } - router.push("/app"); + router.push("/"); } catch (err) { if (err instanceof ApiError) { setError(err.message); @@ -46,116 +45,108 @@ export default function LoginPage() { return (
-
- - - - - + + +

+ betterNAS +

+ + {mode === "login" ? "Sign in" : "Create account"} + + + {mode === "login" + ? "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."} + +
+ +
+
+ + setUsername(e.target.value)} + className="rounded-lg border border-input bg-background px-3 py-2 text-sm outline-none ring-ring focus:ring-2" + placeholder="admin" + /> +
- - - - {mode === "login" ? "Sign in" : "Create account"} - - - {mode === "login" - ? "Use the same credentials as your node agent and Finder." - : "This account works across the web UI, node agent, and Finder."} - - - - -
- - setUsername(e.target.value)} - className="rounded-lg border border-input bg-background px-3 py-2 text-sm outline-none ring-ring focus:ring-2" - placeholder="admin" - /> -
+
+ + setPassword(e.target.value)} + className="rounded-lg border border-input bg-background px-3 py-2 text-sm outline-none ring-ring focus:ring-2" + /> +
-
- - setPassword(e.target.value)} - className="rounded-lg border border-input bg-background px-3 py-2 text-sm outline-none ring-ring focus:ring-2" - /> -
+ {error &&

{error}

} - {error &&

{error}

} + - - -

- {mode === "login" ? ( - <> - No account?{" "} - - - ) : ( - <> - Already have an account?{" "} - - - )} -

- -
-
-
+

+ {mode === "login" ? ( + <> + No account?{" "} + + + ) : ( + <> + Already have an account?{" "} + + + )} +

+ + +
); } diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index d6fc8bf..abe1a38 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1 +1,373 @@ -export { default } from "./landing/page"; +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { + Globe, + HardDrives, + LinkSimple, + SignOut, + Warning, +} from "@phosphor-icons/react"; +import { + isAuthenticated, + listExports, + issueMountProfile, + logout, + getMe, + type StorageExport, + type MountProfile, + type User, + ApiError, +} from "@/lib/api"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Separator } from "@/components/ui/separator"; +import { cn } from "@/lib/utils"; +import { CopyField } from "./copy-field"; + +export default function Home() { + const router = useRouter(); + const [user, setUser] = useState(null); + const [exports, setExports] = useState([]); + const [selectedExportId, setSelectedExportId] = useState(null); + const [mountProfile, setMountProfile] = useState(null); + const [feedback, setFeedback] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!isAuthenticated()) { + router.replace("/login"); + return; + } + + async function load() { + try { + const [me, exps] = await Promise.all([getMe(), listExports()]); + setUser(me); + setExports(exps); + } catch (err) { + if (err instanceof ApiError && err.status === 401) { + router.replace("/login"); + return; + } + setFeedback(err instanceof Error ? err.message : "Failed to load"); + } finally { + setLoading(false); + } + } + + load(); + }, [router]); + + async function handleSelectExport(exportId: string) { + setSelectedExportId(exportId); + setMountProfile(null); + setFeedback(null); + + try { + const profile = await issueMountProfile(exportId); + setMountProfile(profile); + } catch (err) { + setFeedback( + err instanceof Error ? err.message : "Failed to issue mount profile", + ); + } + } + + async function handleLogout() { + await logout(); + router.replace("/login"); + } + + if (loading) { + return ( +
+

Loading...

+
+ ); + } + + const selectedExport = selectedExportId + ? (exports.find((e) => e.id === selectedExportId) ?? null) + : null; + + return ( +
+
+
+
+
+

+ betterNAS +

+

+ Control Plane +

+
+ {user && ( +
+ + {user.username} + + +
+ )} +
+ +
+ + + {process.env.NEXT_PUBLIC_BETTERNAS_API_URL || "local"} + + + {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. + + + +
+
+                    
+                      {`curl -fsSL https://raw.githubusercontent.com/harivansh-afk/betterNAS/main/scripts/install-betternas-node.sh | sh`}
+                    
+                  
+
+                    
+                      {`BETTERNAS_USERNAME=${user.username} BETTERNAS_PASSWORD=... BETTERNAS_EXPORT_PATH=/path/to/export BETTERNAS_NODE_DIRECT_ADDRESS=https://your-public-node-url betternas-node`}
+                    
+                  
+
+
+
+ )} +
+ + {feedback !== null && ( + + + Error + {feedback} + + )} + +
+ + + Exports + + Storage exports registered with this control plane. + + + + {exports.length === 1 + ? "1 export" + : `${exports.length} exports`} + + + + + {exports.length === 0 ? ( +
+ +

+ No exports registered yet. Start the node agent and connect + it to this control plane. +

+
+ ) : ( +
+ {exports.map((storageExport) => { + const isSelected = storageExport.id === selectedExportId; + + return ( + + ); + })} +
+ )} +
+
+ + + + + {selectedExport !== null + ? `Mount ${selectedExport.label}` + : "Mount instructions"} + + + {selectedExport !== null + ? "WebDAV mount details for Finder." + : "Select an export to see the mount URL and account login details."} + + + + {mountProfile === null ? ( +
+ +

+ Pick an export to see the Finder mount URL and the username + to use with your betterNAS account password. +

+
+ ) : ( +
+
+ + Issued profile + + + {mountProfile.readonly ? "Read-only" : "Read-write"} + +
+ + + +
+ + + + + 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. + + +
+ + + +
+
+
+ Mode +
+
+ {mountProfile.credential.mode} +
+
+
+
+ Password source +
+
+ Your betterNAS account password +
+
+
+ + + +
+

Finder steps

+
    + {[ + "Open Finder and choose Go, then Connect to Server.", + "Paste the mount URL into the server address field.", + "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) => ( +
  1. + + {index + 1} + + {step} +
  2. + ))} +
+
+
+ )} +
+
+
+
+
+ ); +} diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 6b20980..448527a 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -1,16 +1,5 @@ const API_URL = process.env.NEXT_PUBLIC_BETTERNAS_API_URL || ""; -export interface NasNode { - id: string; - machineId: string; - displayName: string; - agentVersion: string; - status: "online" | "offline" | "degraded"; - lastSeenAt: string; - directAddress: string | null; - relayAddress: string | null; -} - export interface StorageExport { id: string; nasNodeId: string; @@ -157,10 +146,6 @@ export async function getMe(): Promise { return apiFetch("/api/v1/auth/me"); } -export async function listNodes(): Promise { - return apiFetch("/api/v1/nodes"); -} - export async function listExports(): Promise { return apiFetch("/api/v1/exports"); } diff --git a/docs/repository-root/control.md b/control.md similarity index 100% rename from docs/repository-root/control.md rename to control.md diff --git a/packages/contracts/openapi/betternas.v1.yaml b/packages/contracts/openapi/betternas.v1.yaml index b9b5381..1902c06 100644 --- a/packages/contracts/openapi/betternas.v1.yaml +++ b/packages/contracts/openapi/betternas.v1.yaml @@ -18,22 +18,6 @@ paths: responses: "200": description: Control-plane version - /api/v1/nodes: - get: - operationId: listNodes - security: - - UserSession: [] - responses: - "200": - description: Node list - content: - application/json: - schema: - type: array - items: - $ref: "#/components/schemas/NasNode" - "401": - description: Unauthorized /api/v1/nodes/register: post: operationId: registerNode diff --git a/packages/contracts/src/foundation.ts b/packages/contracts/src/foundation.ts index 21514e4..04e513b 100644 --- a/packages/contracts/src/foundation.ts +++ b/packages/contracts/src/foundation.ts @@ -1,5 +1,4 @@ export const FOUNDATION_API_ROUTES = { - listNodes: "/api/v1/nodes", registerNode: "/api/v1/nodes/register", nodeHeartbeat: "/api/v1/nodes/:nodeId/heartbeat", nodeExports: "/api/v1/nodes/:nodeId/exports", diff --git a/scripts/install-betternas-node.sh b/scripts/install-betternas-node.sh index 4d15ea8..280d536 100755 --- a/scripts/install-betternas-node.sh +++ b/scripts/install-betternas-node.sh @@ -37,8 +37,7 @@ case "$arch_name" in ;; esac -version_stripped="${version#v}" -archive_name="${binary_name}_${version_stripped}_${os}_${arch}.tar.gz" +archive_name="${binary_name}_${version}_${os}_${arch}.tar.gz" download_url="${download_base_url}/${version}/${archive_name}" tmp_dir="$(mktemp -d)" diff --git a/docs/repository-root/skeleton.md b/skeleton.md similarity index 100% rename from docs/repository-root/skeleton.md rename to skeleton.md diff --git a/thoughts/shared/plans/2026-04-01-production-deployment.md b/thoughts/shared/plans/2026-04-01-production-deployment.md new file mode 100644 index 0000000..40315f4 --- /dev/null +++ b/thoughts/shared/plans/2026-04-01-production-deployment.md @@ -0,0 +1,698 @@ +# betterNAS Production Deployment Plan + +## Overview + +Deploy the betterNAS control-plane as a production service on netty (Netcup VPS) with SQLite-backed user auth, NGINX reverse proxy at `api.betternas.com`, and the web frontend on Vercel at `betternas.com`. Replaces the current dev Docker Compose setup with a NixOS-native systemd service matching the existing deployment pattern (forgejo, vaultwarden, sandbox-agent). + +## Current State + +- Control-plane is a Go binary running in Docker on netty (port 3001->3000) +- State is an in-memory store backed by a JSON file +- Auth is static tokens from environment variables (no user accounts) +- Web frontend reads env vars to find the control-plane URL and client token +- Node-agent runs in Docker, connects to control-plane over Docker network +- NGINX on netty already reverse-proxies 3 domains with ACME/Let's Encrypt +- NixOS config is at `/home/rathi/Documents/GitHub/nix/hosts/netty/configuration.nix` +- `betternas.com` is registered on Vercel with nameservers pointed to Vercel DNS + +## Desired End State + +- `api.betternas.com` serves the control-plane Go binary behind NGINX with TLS +- `betternas.com` serves the Next.js web UI from Vercel +- All state (users, sessions, nodes, exports) lives in SQLite at `/var/lib/betternas/control-plane/betternas.db` +- Users log in with username/password on the web UI, get a session cookie +- One-click mount: logged-in user clicks an export, backend issues WebDAV credentials using the user's session +- Node-agent connects to `api.betternas.com` over HTTPS +- 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 +4. Node-agent on netty registers and syncs exports to `api.betternas.com` +5. WebDAV mount from Finder works with issued credentials + +## What We're NOT Doing + +- Multi-tenant / multi-user RBAC (just simple username/password accounts) +- OAuth / SSO / social login +- Email verification or password reset flows +- Migrating existing JSON state (fresh SQLite DB) +- Nextcloud integration (can add later) +- CI/CD pipeline (manual deploy via `nixos-rebuild switch`) +- Rate limiting or request throttling + +## Implementation Approach + +Five phases, each independently deployable and testable: + +1. **SQLite store** - Replace memoryStore with sqliteStore for all existing state +2. **User auth** - Add users/sessions tables, login/register endpoints, session middleware +3. **CORS + frontend auth** - Wire the web UI to use session-based auth against `api.betternas.com` +4. **NixOS deployment** - Systemd service, NGINX vhost, ACME cert, DNS +5. **Vercel deployment** - Deploy web UI, configure domain and env vars + +--- + +## 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 + +```sql +-- Ordinal counters (replaces NextNodeOrdinal / NextExportOrdinal) +CREATE TABLE ordinals ( + name TEXT PRIMARY KEY, + value INTEGER NOT NULL DEFAULT 0 +); +INSERT INTO ordinals (name, value) VALUES ('node', 0), ('export', 0); + +-- Nodes +CREATE TABLE nodes ( + id TEXT PRIMARY KEY, + machine_id TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL DEFAULT '', + agent_version TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'online', + last_seen_at TEXT, + direct_address TEXT, + relay_address TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) +); + +-- Node auth tokens (hashed) +CREATE TABLE node_tokens ( + node_id TEXT PRIMARY KEY REFERENCES nodes(id), + token_hash TEXT NOT NULL +); + +-- Storage exports +CREATE TABLE exports ( + id TEXT PRIMARY KEY, + node_id TEXT NOT NULL REFERENCES nodes(id), + label TEXT NOT NULL DEFAULT '', + path TEXT NOT NULL, + mount_path TEXT NOT NULL DEFAULT '', + capacity_bytes INTEGER, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + UNIQUE(node_id, path) +); + +-- Export protocols (normalized from JSON array) +CREATE TABLE export_protocols ( + export_id TEXT NOT NULL REFERENCES exports(id) ON DELETE CASCADE, + protocol TEXT NOT NULL, + PRIMARY KEY (export_id, protocol) +); + +-- Export tags (normalized from JSON array) +CREATE TABLE export_tags ( + export_id TEXT NOT NULL REFERENCES exports(id) ON DELETE CASCADE, + tag TEXT NOT NULL, + PRIMARY KEY (export_id, tag) +); +``` + +### 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 +- `recordHeartbeat(...)` - UPDATE node status/lastSeenAt +- `listExports()` - SELECT all exports with protocols/tags joined +- `exportContext(exportID)` - SELECT export + its node +- `nodeAuthByMachineID(machineID)` - SELECT node_id + token_hash by machine_id +- `nodeAuthByID(nodeID)` - SELECT token_hash by node_id +- `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` +- Schema migrations run on startup (embed SQL with `//go:embed`) +- All multi-table mutations wrapped in transactions +- 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) +// With: +// store, err := newSQLiteStore(dbPath) +``` + +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`. + +### 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 + +--- + +## 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 + +```sql +-- Users +CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) +); + +-- Sessions +CREATE TABLE sessions ( + token TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + expires_at TEXT NOT NULL +); +CREATE INDEX idx_sessions_expires ON sessions(expires_at); +``` + +### New API Endpoints + +``` +POST /api/v1/auth/register - Create account (username, password) +POST /api/v1/auth/login - Login, returns session token + sets cookie +POST /api/v1/auth/logout - Invalidate session +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 +// Dependencies: golang.org/x/crypto/bcrypt, crypto/rand + +func (s *sqliteStore) createUser(username, password string) (user, error) +// - Validate username (3-64 chars, alphanumeric + underscore/hyphen) +// - bcrypt hash the password (cost 10) +// - INSERT into users with generated ID +// - Return user struct + +func (s *sqliteStore) authenticateUser(username, password string) (user, error) +// - SELECT user by username +// - bcrypt.CompareHashAndPassword +// - Return user or error + +func (s *sqliteStore) createSession(userID string, ttl time.Duration) (string, error) +// - Generate 32-byte random token, hex-encode +// - INSERT into sessions with expires_at = now + ttl +// - Return token + +func (s *sqliteStore) validateSession(token string) (user, error) +// - SELECT session JOIN users WHERE token = ? AND expires_at > now +// - Return user or error + +func (s *sqliteStore) deleteSession(token string) error +// - DELETE FROM sessions WHERE token = ? + +func (s *sqliteStore) cleanExpiredSessions() error +// - DELETE FROM sessions WHERE expires_at < now +// - Run periodically (e.g., on each request or via goroutine) +``` + +#### 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) +mux.HandleFunc("POST /api/v1/auth/logout", s.handleLogout) +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 +// This preserves backwards compatibility during migration +func (s *server) requireClientAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token := extractBearerToken(r) + + // Try session-based auth first + user, err := s.store.validateSession(token) + if err == nil { + ctx := context.WithValue(r.Context(), userContextKey, user) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + + // Fall back to static client token (for backwards compat / scripts) + if secureStringEquals(token, s.config.clientToken) { + next.ServeHTTP(w, r) + return + } + + writeUnauthorized(w) + }) +} +``` + +### 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 +- [ ] Registration can be disabled via env var + +--- + +## 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 +// New env var: BETTERNAS_CORS_ORIGIN (e.g., "https://betternas.com") + +func corsMiddleware(allowedOrigin string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", allowedOrigin) + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") + w.Header().Set("Access-Control-Allow-Credentials", "true") + w.Header().Set("Access-Control-Max-Age", "86400") + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + next.ServeHTTP(w, r) + }) +} +``` + +#### 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 +- Add login/register/logout API calls + +```typescript +// lib/auth.ts +const TOKEN_KEY = "betternas_session"; + +export function getSessionToken(): string | null { + if (typeof window === "undefined") return null; + return localStorage.getItem(TOKEN_KEY); +} + +export function setSessionToken(token: string): void { + localStorage.setItem(TOKEN_KEY, token); +} + +export function clearSessionToken(): void { + localStorage.removeItem(TOKEN_KEY); +} + +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" }, + body: JSON.stringify({ username, password }), + }); + if (!res.ok) throw new Error("Login failed"); + const data = await res.json(); + setSessionToken(data.token); + return data.token; +} +``` + +Update `lib/control-plane.ts`: + +```typescript +// Replace the current getControlPlaneConfig with: +export function getControlPlaneConfig(): ControlPlaneConfig { + const baseUrl = process.env.NEXT_PUBLIC_BETTERNAS_API_URL || null; + const clientToken = getSessionToken(); + return { baseUrl, clientToken }; +} +``` + +#### 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. + +### 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 +- [ ] Logout, confirm redirected to login +- [ ] API calls from frontend include correct CORS headers + +--- + +## 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 + +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): + +```nix + # --- betterNAS control-plane --- + betternasDomain = "api.betternas.com"; + + # In services.nginx.virtualHosts: + virtualHosts.${betternasDomain} = { + enableACME = true; + forceSSL = true; + locations."/".proxyPass = "http://127.0.0.1:3100"; + locations."/".extraConfig = '' + proxy_set_header X-Forwarded-Proto $scheme; + ''; + }; + + # Systemd service: + systemd.services.betternas-control-plane = { + description = "betterNAS Control Plane"; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "simple"; + User = username; + Group = "users"; + WorkingDirectory = "/var/lib/betternas/control-plane"; + ExecStart = "/home/${username}/Documents/GitHub/betterNAS/betterNAS/apps/control-plane/dist/control-plane"; + EnvironmentFile = "/var/lib/betternas/control-plane/control-plane.env"; + Restart = "on-failure"; + RestartSec = 5; + StateDirectory = "betternas/control-plane"; + }; + }; +``` + +#### 4. Environment file on netty + +**File**: `/var/lib/betternas/control-plane/control-plane.env` + +```bash +PORT=3100 +BETTERNAS_VERSION=0.1.0 +BETTERNAS_CONTROL_PLANE_DB_PATH=/var/lib/betternas/control-plane/betternas.db +BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN= +BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN= +BETTERNAS_DAV_AUTH_SECRET= +BETTERNAS_DAV_CREDENTIAL_TTL=24h +BETTERNAS_SESSION_TTL=720h +BETTERNAS_REGISTRATION_ENABLED=true +BETTERNAS_CORS_ORIGIN=https://betternas.com +BETTERNAS_NODE_DIRECT_ADDRESS=https://api.betternas.com +``` + +#### 5. Build and deploy script + +**File**: `apps/control-plane/scripts/deploy-netty.sh` + +```bash +#!/usr/bin/env bash +set -euo pipefail + +REMOTE="netty" +REPO="/home/rathi/Documents/GitHub/betterNAS/betterNAS" +DIST="$REPO/apps/control-plane/dist" + +ssh "$REMOTE" "cd $REPO && git pull && \ + mkdir -p $DIST && \ + cd apps/control-plane && \ + CGO_ENABLED=0 go build -o $DIST/control-plane ./cmd/control-plane && \ + sudo systemctl restart betternas-control-plane && \ + sleep 2 && \ + sudo systemctl status betternas-control-plane --no-pager" +``` + +#### 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"' +``` + +### 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` +- [ ] State persists in SQLite across restarts + +--- + +## 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 + +--- + +## Node-Agent Deployment (post-phases) + +After the control-plane is running at `api.betternas.com`, update the node-agent on netty to connect to it: + +1. Build node-agent: `cd apps/node-agent && CGO_ENABLED=0 go build -o dist/node-agent ./cmd/node-agent` +2. Create systemd service similar to control-plane +3. Environment: `BETTERNAS_CONTROL_PLANE_URL=https://api.betternas.com` +4. NGINX vhost for WebDAV if needed (or direct port exposure) + +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 +4. Connect node-agent, verify exports appear +5. Issue mount credentials, mount in Finder +6. Restart control-plane, verify all state persisted + +## Performance Considerations + +- SQLite WAL mode for concurrent reads during writes +- Session cleanup: delete expired sessions on a timer (every 10 minutes), not on every request +- Connection pool: single writer, multiple readers (SQLite default with WAL) +- For a single-NAS deployment, SQLite performance is more than sufficient + +## Go Dependencies to Add + +``` +modernc.org/sqlite # Pure Go SQLite driver +golang.org/x/crypto/bcrypt # Password hashing +``` + +Both are well-maintained, widely used, and have no CGo requirement. + +## References + +- NixOS config: `/home/rathi/Documents/GitHub/nix/hosts/netty/configuration.nix` +- Control-plane server: `apps/control-plane/cmd/control-plane/server.go` +- Control-plane store: `apps/control-plane/cmd/control-plane/store.go` +- Web frontend API client: `apps/web/lib/control-plane.ts` +- Docker compose (current dev): `infra/docker/compose.dev.yml`