Compare commits

...

13 commits
v0.1.0 ... main

Author SHA1 Message Date
Hari
90d7eda264
Update README with project title and URL
Some checks failed
CI / test-control-plane (push) Has been cancelled
CI / test-node-agent (push) Has been cancelled
Added project title and link to README.
2026-04-12 00:57:43 -04:00
be98500e09 ci: use self-hosted netty runners
Some checks failed
CI / test-control-plane (push) Has been cancelled
CI / test-node-agent (push) Has been cancelled
2026-04-05 11:41:11 -04:00
bebd8c3e4c move docs
Some checks failed
CI / test-control-plane (push) Has been cancelled
CI / test-node-agent (push) Has been cancelled
2026-04-01 23:43:09 -04:00
93d610db29 move 2026-04-01 23:39:43 -04:00
23e0baa1ef copy 2026-04-02 03:35:17 +00:00
d939126ce0 rm web build 2026-04-02 03:25:57 +00:00
79f52cac9a website 2026-04-02 03:24:41 +00:00
Hari
caa2e40dc1
Add image to README for better visualization
Some checks are pending
CI / test-control-plane (push) Waiting to run
CI / test-node-agent (push) Waiting to run
CI / build-web (push) Waiting to run
2026-04-01 22:39:58 -04:00
43ef276976 web repo 2026-04-01 22:10:39 -04:00
f6069a024a ui
Some checks are pending
CI / test-control-plane (push) Waiting to run
CI / test-node-agent (push) Waiting to run
CI / build-web (push) Waiting to run
2026-04-01 21:18:08 -04:00
171a682f6a tests 2026-04-01 21:10:13 -04:00
1d564b738d Fix install script: strip v prefix from version for archive name 2026-04-01 21:06:40 -04:00
8002158a45 docs
Some checks are pending
CI / test-control-plane (push) Waiting to run
CI / test-node-agent (push) Waiting to run
CI / build-web (push) Waiting to run
2026-04-01 20:55:17 -04:00
42 changed files with 1565 additions and 1402 deletions

View file

@ -7,7 +7,7 @@ on:
jobs:
test-control-plane:
runs-on: ubuntu-latest
runs-on: [self-hosted, netty]
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: ubuntu-latest
runs-on: [self-hosted, netty]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
@ -31,17 +31,3 @@ 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

View file

@ -1,63 +0,0 @@
# 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.

117
README.md
View file

@ -1,114 +1,13 @@
# betterNAS
# BetterNAS
betterNAS is a self-hostable WebDAV stack for mounting NAS exports in Finder.
https://github.com/user-attachments/assets/7909f957-b6d9-4cc1-aaec-369d5d94a7f5
The default product shape is:
- `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
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.
For now, the whole stack should be able to run on the user's NAS device.
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.
## 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
<img width="1330" height="614" alt="image" src="https://github.com/user-attachments/assets/f4cfe135-505f-4ce1-bbb3-1f1d47821f8f" />

View file

@ -1,6 +1,8 @@
package main
import (
"sort"
"strings"
"time"
)
@ -10,6 +12,7 @@ type appConfig struct {
statePath string
dbPath string
sessionTTL time.Duration
nodeOfflineThreshold time.Duration
registrationEnabled bool
corsOrigin string
}
@ -21,7 +24,13 @@ 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 != "" {
@ -41,6 +50,68 @@ 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"`

View file

@ -36,6 +36,16 @@ 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"),
@ -43,6 +53,7 @@ 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", ""),
},

View file

@ -56,8 +56,9 @@ func TestControlPlaneBinaryMountLoopIntegration(t *testing.T) {
mount := postJSONAuth[mountProfile](t, client, controlPlane.sessionToken, controlPlane.baseURL+"/api/v1/mount-profiles/issue", mountProfileRequest{
ExportID: export.ID,
})
if mount.MountURL != nodeAgent.baseURL+defaultWebDAVPath {
t.Fatalf("expected runtime mount URL %q, got %q", nodeAgent.baseURL+defaultWebDAVPath, mount.MountURL)
expectedMountURL := nodeAgent.baseURL + defaultWebDAVPath + runtimeUsername + "/"
if mount.MountURL != expectedMountURL {
t.Fatalf("expected runtime mount URL %q, got %q", expectedMountURL, mount.MountURL)
}
if mount.Credential.Mode != mountCredentialModeBasicAuth {
t.Fatalf("expected mount credential mode %q, got %q", mountCredentialModeBasicAuth, mount.Credential.Mode)
@ -103,11 +104,13 @@ func TestControlPlaneBinaryMultiExportProfilesStayDistinct(t *testing.T) {
if firstMount.MountURL == secondMount.MountURL {
t.Fatalf("expected distinct runtime mount URLs, got %q", firstMount.MountURL)
}
if firstMount.MountURL != nodeAgent.baseURL+firstMountPath {
t.Fatalf("expected first runtime mount URL %q, got %q", nodeAgent.baseURL+firstMountPath, 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 secondMount.MountURL != nodeAgent.baseURL+secondMountPath {
t.Fatalf("expected second runtime mount URL %q, got %q", nodeAgent.baseURL+secondMountPath, secondMount.MountURL)
if secondMount.MountURL != expectedSecondMountURL {
t.Fatalf("expected second runtime mount URL %q, got %q", expectedSecondMountURL, secondMount.MountURL)
}
assertHTTPStatusWithBasicAuth(t, client, "PROPFIND", firstMount.MountURL, controlPlane.username, controlPlane.password, http.StatusMultiStatus)

View file

@ -36,6 +36,7 @@ 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)
@ -74,6 +75,15 @@ 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 {
@ -177,7 +187,7 @@ func (a *app) handleExportsList(w http.ResponseWriter, r *http.Request) {
return
}
writeJSON(w, http.StatusOK, a.store.listExports(currentUser.ID))
writeJSON(w, http.StatusOK, a.listConnectedExports(currentUser.ID))
}
func (a *app) handleMountProfileIssue(w http.ResponseWriter, r *http.Request) {
@ -202,8 +212,13 @@ 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)
mountURL, err := buildMountURL(context, currentUser.Username)
if err != nil {
http.Error(w, err.Error(), http.StatusServiceUnavailable)
return
@ -705,13 +720,17 @@ func hasConfiguredNextcloudBaseURL(baseURL string) bool {
return err == nil
}
func buildMountURL(context exportContext) (string, error) {
func buildMountURL(context exportContext, username string) (string, error) {
address, ok := firstAddress(context.node.DirectAddress, context.node.RelayAddress)
if !ok {
return "", errMountTargetUnavailable
}
mountURL, err := buildAbsoluteHTTPURLWithPath(address, mountProfilePathForExport(context.export.MountPath))
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)
if err != nil {
return "", errMountTargetUnavailable
}

View file

@ -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/" {
t.Fatalf("expected mount URL %q, got %q", "http://nas.local:8090/dav/", mount.MountURL)
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.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/" {
t.Fatalf("expected docs mount URL %q, got %q", "http://nas.local:8090/dav/exports/docs/", 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 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)
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)
}
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/" {
t.Fatalf("expected relay mount URL %q, got %q", "https://nas.example.test/control/dav/relay/", mount.MountURL)
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)
}
registration = registerNode(t, server.Client(), server.URL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{
@ -494,6 +494,69 @@ 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()
@ -585,8 +648,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/" {
t.Fatalf("expected persisted mount URL %q, got %q", "http://nas.local:8090/dav/persisted/", mount.MountURL)
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)
}
reRegistration := registerNode(t, secondServer.Client(), secondServer.URL+"/api/v1/nodes/register", registration.NodeToken, nodeRegistrationRequest{
@ -745,6 +808,8 @@ 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)

View file

@ -346,6 +346,27 @@ 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 {
@ -401,15 +422,29 @@ 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 := 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)
err := scanner.Scan(&n.ID, &n.MachineID, &ownerID, &n.DisplayName, &n.AgentVersion, &n.Status, &lastSeenAt, &directAddr, &relayAddr)
if err != nil {
return nasNode{}, false
return nasNode{}
}
if ownerID.Valid {
n.OwnerID = ownerID.String
@ -423,7 +458,7 @@ func (s *sqliteStore) nodeByID(nodeID string) (nasNode, bool) {
if relayAddr.Valid {
n.RelayAddress = &relayAddr.String
}
return n, true
return n
}
func (s *sqliteStore) nodeAuthByMachineID(machineID string) (nodeAuthState, bool) {

View file

@ -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/" {
t.Fatalf("expected mount URL %q, got %q", "http://nas.local:8090/dav/docs/", mount.MountURL)
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)
}
}

View file

@ -320,6 +320,25 @@ 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()

View file

@ -9,6 +9,7 @@ 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)

View file

@ -1,13 +1,16 @@
package main
import (
"context"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
"golang.org/x/net/webdav"
)
@ -27,6 +30,7 @@ type app struct {
authUsername string
authPassword string
exportMounts []exportMount
controlPlane *controlPlaneSession
}
type exportMount struct {
@ -69,17 +73,92 @@ func newAppFromEnv() (*app, error) {
if err != nil {
return nil, err
}
var controlPlane *controlPlaneSession
if strings.TrimSpace(env("BETTERNAS_CONTROL_PLANE_URL", "")) != "" {
if _, err := bootstrapNodeAgentFromEnv(exportPaths); err != nil {
session, err := bootstrapNodeAgentFromEnv(exportPaths)
if err != nil {
return nil, err
}
controlPlane = &session
}
return newApp(appConfig{
app, err := 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) {
@ -155,12 +234,24 @@ 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: webdav.NewMemLS(),
LockSystem: lockSystem,
}
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

View file

@ -14,8 +14,11 @@ import (
"time"
)
type bootstrapResult struct {
nodeID string
type controlPlaneSession struct {
nodeID string
controlPlaneURL string
sessionToken string
heartbeatInterval time.Duration
}
type nodeRegistrationRequest struct {
@ -58,19 +61,23 @@ type nodeHeartbeatRequest struct {
LastSeenAt string `json:"lastSeenAt"`
}
func bootstrapNodeAgentFromEnv(exportPaths []string) (bootstrapResult, error) {
func bootstrapNodeAgentFromEnv(exportPaths []string) (controlPlaneSession, error) {
controlPlaneURL := strings.TrimSpace(env("BETTERNAS_CONTROL_PLANE_URL", "https://api.betternas.com"))
if controlPlaneURL == "" {
return bootstrapResult{}, fmt.Errorf("BETTERNAS_CONTROL_PLANE_URL is required")
return controlPlaneSession{}, fmt.Errorf("BETTERNAS_CONTROL_PLANE_URL is required")
}
username, err := requiredEnv("BETTERNAS_USERNAME")
if err != nil {
return bootstrapResult{}, err
return controlPlaneSession{}, err
}
password, err := requiredEnv("BETTERNAS_PASSWORD")
if err != nil {
return bootstrapResult{}, err
return controlPlaneSession{}, err
}
heartbeatInterval, err := heartbeatIntervalFromEnv()
if err != nil {
return controlPlaneSession{}, err
}
machineID := strings.TrimSpace(env("BETTERNAS_NODE_MACHINE_ID", defaultNodeMachineID(username)))
@ -82,7 +89,7 @@ func bootstrapNodeAgentFromEnv(exportPaths []string) (bootstrapResult, error) {
client := &http.Client{Timeout: 5 * time.Second}
sessionToken, err := loginWithControlPlane(client, controlPlaneURL, username, password)
if err != nil {
return bootstrapResult{}, err
return controlPlaneSession{}, err
}
registration, err := registerNodeWithControlPlane(client, controlPlaneURL, sessionToken, nodeRegistrationRequest{
@ -93,17 +100,22 @@ func bootstrapNodeAgentFromEnv(exportPaths []string) (bootstrapResult, error) {
RelayAddress: optionalEnvPointer("BETTERNAS_NODE_RELAY_ADDRESS"),
})
if err != nil {
return bootstrapResult{}, err
return controlPlaneSession{}, err
}
if err := syncNodeExportsWithControlPlane(client, controlPlaneURL, sessionToken, registration.ID, buildStorageExportInputs(exportPaths)); err != nil {
return bootstrapResult{}, err
return controlPlaneSession{}, err
}
if err := sendNodeHeartbeat(client, controlPlaneURL, sessionToken, registration.ID); err != nil {
return bootstrapResult{}, err
if err := sendNodeHeartbeatAt(client, controlPlaneURL, sessionToken, registration.ID, time.Now().UTC()); err != nil {
return controlPlaneSession{}, err
}
return bootstrapResult{nodeID: registration.ID}, nil
return controlPlaneSession{
nodeID: registration.ID,
controlPlaneURL: controlPlaneURL,
sessionToken: sessionToken,
heartbeatInterval: heartbeatInterval,
}, nil
}
func loginWithControlPlane(client *http.Client, baseURL string, username string, password string) (string, error) {
@ -168,10 +180,14 @@ 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: time.Now().UTC().Format(time.RFC3339),
LastSeenAt: at.UTC().Format(time.RFC3339),
})
if err != nil {
return err

View file

@ -0,0 +1,97 @@
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)
}
}
}

View file

@ -1,9 +1,12 @@
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
@ -14,6 +17,19 @@ 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(),

283
apps/web/app/app/page.tsx Normal file
View file

@ -0,0 +1,283 @@
"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<User | null>(null);
const [nodes, setNodes] = useState<NasNode[]>([]);
const [exports, setExports] = useState<StorageExport[]>([]);
const [selectedExportId, setSelectedExportId] = useState<string | null>(null);
const [mountProfile, setMountProfile] = useState<MountProfile | null>(null);
const [feedback, setFeedback] = useState<string | null>(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 (
<main className="flex min-h-screen items-center justify-center bg-background">
<p className="text-sm text-muted-foreground">Loading...</p>
</main>
);
}
const selectedExport = selectedExportId
? (exports.find((e) => e.id === selectedExportId) ?? null)
: null;
return (
<main className="min-h-screen bg-background">
<div className="mx-auto flex max-w-5xl flex-col gap-8 px-4 py-8 sm:px-6">
{/* header */}
<div className="flex items-center justify-between">
<div className="flex flex-col gap-0.5">
<Link
href="/"
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
>
betterNAS
</Link>
<h1 className="text-xl font-semibold tracking-tight">
Control Plane
</h1>
</div>
{user && (
<div className="flex items-center gap-3">
<Link
href="/docs"
className="text-sm text-muted-foreground transition-colors hover:text-foreground"
>
Docs
</Link>
<span className="text-sm text-muted-foreground">
{user.username}
</span>
<Button variant="ghost" size="sm" onClick={handleLogout}>
<SignOut className="mr-1 size-4" />
Sign out
</Button>
</div>
)}
</div>
{feedback !== null && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{feedback}
</div>
)}
{/* nodes */}
<section className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-sm font-medium">Nodes</h2>
<span className="text-xs text-muted-foreground">
{nodes.filter((n) => n.status === "online").length} online
{nodes.filter((n) => n.status === "offline").length > 0 &&
`, ${nodes.filter((n) => n.status === "offline").length} offline`}
</span>
</div>
{nodes.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
No nodes registered yet. Install and start the node agent on the
machine that owns your files.
</p>
) : (
<div className="grid gap-3 sm:grid-cols-2">
{nodes.map((node) => (
<div
key={node.id}
className="flex items-start justify-between gap-4 rounded-lg border p-4"
>
<div className="flex flex-col gap-1 overflow-hidden">
<span className="text-sm font-medium">
{node.displayName}
</span>
<span className="truncate text-xs text-muted-foreground">
{node.directAddress ?? node.relayAddress ?? node.machineId}
</span>
<span className="text-xs text-muted-foreground">
Last seen {formatTimestamp(node.lastSeenAt)}
</span>
</div>
<Badge
variant={node.status === "offline" ? "outline" : "secondary"}
className={cn(
"shrink-0",
node.status === "online" &&
"bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
node.status === "degraded" &&
"bg-amber-500/15 text-amber-700 dark:text-amber-300",
)}
>
{node.status}
</Badge>
</div>
))}
</div>
)}
</section>
{/* exports + mount */}
<div className="grid gap-8 lg:grid-cols-[1fr_380px]">
{/* exports list */}
<section className="flex flex-col gap-4">
<h2 className="text-sm font-medium">Exports</h2>
{exports.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
{nodes.length === 0
? "No exports yet. Start the node agent to register one."
: "No connected exports. Start the node agent or wait for reconnection."}
</p>
) : (
<div className="flex flex-col gap-2">
{exports.map((exp) => {
const isSelected = exp.id === selectedExportId;
return (
<button
key={exp.id}
onClick={() => handleSelectExport(exp.id)}
className={cn(
"flex items-start justify-between gap-4 rounded-lg border p-4 text-left text-sm transition-colors",
isSelected
? "border-foreground/20 bg-muted/50"
: "hover:bg-muted/30",
)}
>
<div className="flex flex-col gap-1 overflow-hidden">
<span className="font-medium">{exp.label}</span>
<span className="truncate text-xs text-muted-foreground">
{exp.path}
</span>
</div>
<span className="shrink-0 text-xs text-muted-foreground">
{exp.protocols.join(", ")}
</span>
</button>
);
})}
</div>
)}
</section>
{/* mount profile */}
<section className="flex flex-col gap-4">
<h2 className="text-sm font-medium">
{selectedExport ? `Mount ${selectedExport.label}` : "Mount"}
</h2>
{mountProfile === null ? (
<p className="py-6 text-center text-sm text-muted-foreground">
Select an export to see the mount URL and credentials.
</p>
) : (
<div className="flex flex-col gap-5">
<CopyField label="Mount URL" value={mountProfile.mountUrl} />
<CopyField
label="Username"
value={mountProfile.credential.username}
/>
<p className="rounded-lg border bg-muted/20 px-3 py-2.5 text-xs leading-relaxed text-muted-foreground">
Use your betterNAS account password when Finder prompts. v1
does not issue a separate WebDAV password.
</p>
<div className="flex flex-col gap-1.5">
<h3 className="text-xs font-medium">Finder steps</h3>
<ol className="flex flex-col gap-1 text-xs text-muted-foreground">
<li>1. Go &gt; Connect to Server in Finder.</li>
<li>2. Paste the mount URL.</li>
<li>3. Enter your betterNAS username and password.</li>
<li>4. Optionally save to Keychain.</li>
</ol>
</div>
</div>
)}
</section>
</div>
</div>
</main>
);
}
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();
}

196
apps/web/app/docs/page.tsx Normal file
View file

@ -0,0 +1,196 @@
"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 (
<div className="group relative">
{label && (
<span className="mb-1.5 block text-xs text-muted-foreground">
{label}
</span>
)}
<pre className="overflow-x-auto rounded-lg border bg-muted/40 p-4 pr-12 font-mono text-xs leading-relaxed text-foreground">
<code>{children}</code>
</pre>
<button
onClick={async () => {
await navigator.clipboard.writeText(children);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
}}
className="absolute right-2 top-2 flex items-center gap-1 rounded-md border bg-background/80 px-2 py-1 text-xs text-muted-foreground opacity-0 backdrop-blur transition-opacity hover:text-foreground group-hover:opacity-100"
aria-label="Copy to clipboard"
>
{copied ? (
<>
<Check size={12} weight="bold" /> Copied
</>
) : (
<>
<Copy size={12} /> Copy
</>
)}
</button>
</div>
);
}
export default function DocsPage() {
return (
<main className="min-h-screen bg-background">
<div className="mx-auto flex max-w-2xl flex-col gap-10 px-4 py-12 sm:px-6">
{/* header */}
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<Link
href="/"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<svg className="size-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M10 3L5 8l5 5" />
</svg>
</Link>
<Link
href="/login"
className="text-sm text-muted-foreground transition-colors hover:text-foreground"
>
Sign in
</Link>
</div>
<div className="flex flex-col gap-3">
<h1 className="text-2xl font-semibold tracking-tight">
betterNAS
</h1>
<p className="text-sm leading-relaxed text-muted-foreground">
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.
</p>
<p className="text-sm leading-relaxed text-muted-foreground">
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.
</p>
<h2 className="mt-4 text-lg font-semibold tracking-tight">
Getting started
</h2>
<p className="text-sm leading-relaxed text-muted-foreground">
One account works everywhere: the web app, the node agent, and
Finder. Set up the node, confirm it is online, then mount your
export.
</p>
</div>
</div>
{/* prerequisites */}
<section className="flex flex-col gap-3">
<h2 className="text-sm font-medium">Prerequisites</h2>
<ul className="flex flex-col gap-1.5 text-sm text-muted-foreground">
<li>- A betterNAS account</li>
<li>- A machine with the files you want to expose</li>
<li>- An export folder on that machine</li>
<li>
- A public HTTPS URL that reaches your node directly (for Finder
mounting)
</li>
</ul>
</section>
{/* step 1 */}
<section className="flex flex-col gap-3">
<h2 className="text-sm font-medium">1. Install the node binary</h2>
<p className="text-sm text-muted-foreground">
Run this on the machine that owns the files.
</p>
<CodeBlock>
{`curl -fsSL https://raw.githubusercontent.com/harivansh-afk/betterNAS/main/scripts/install-betternas-node.sh | sh`}
</CodeBlock>
</section>
{/* step 2 */}
<section className="flex flex-col gap-3">
<h2 className="text-sm font-medium">2. Start the node</h2>
<p className="text-sm text-muted-foreground">
Replace the placeholders with your account, export path, and public
node URL.
</p>
<CodeBlock>
{`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`}
</CodeBlock>
<div className="flex flex-col gap-1 text-sm text-muted-foreground">
<p>
<span className="font-medium text-foreground">Export path</span>{" "}
- the directory you want to expose through betterNAS.
</p>
<p>
<span className="font-medium text-foreground">
Direct address
</span>{" "}
- the real public HTTPS base URL that reaches your node directly.
</p>
</div>
</section>
{/* step 3 */}
<section className="flex flex-col gap-3">
<h2 className="text-sm font-medium">3. Confirm the node is online</h2>
<p className="text-sm text-muted-foreground">
Open the control plane after the node starts. You should see:
</p>
<ul className="flex flex-col gap-1.5 text-sm text-muted-foreground">
<li>- Your node appears as online</li>
<li>- Your export appears in the exports list</li>
<li>
- Issuing a mount profile gives you a WebDAV URL, not an HTML
login page
</li>
</ul>
</section>
{/* step 4 */}
<section className="flex flex-col gap-3">
<h2 className="text-sm font-medium">4. Mount in Finder</h2>
<ol className="flex flex-col gap-1.5 text-sm text-muted-foreground">
<li>1. Open Finder, then Go &gt; Connect to Server.</li>
<li>
2. Copy the mount URL from the control plane and paste it in.
</li>
<li>
3. Sign in with the same username and password you used for the
web app and node agent.
</li>
<li>
4. Save to Keychain only if you want Finder to remember the
password.
</li>
</ol>
</section>
{/* note about public urls */}
<section className="flex flex-col gap-2 rounded-lg border border-border/60 bg-muted/20 px-4 py-3">
<h2 className="text-sm font-medium">A note on public URLs</h2>
<p className="text-sm leading-relaxed text-muted-foreground">
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{" "}
<code className="rounded bg-muted px-1 py-0.5 text-xs">/dav/</code>{" "}
on your node URL. A working node responds with WebDAV headers, not
HTML.
</p>
</section>
</div>
</main>
);
}

View file

@ -0,0 +1,446 @@
"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 (
<svg viewBox="0 0 16 16" fill="currentColor" className={className}>
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
);
}
function ClockIcon() {
return (
<svg className="size-[18px] text-[#65a2f8]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" />
</svg>
);
}
function SharedIcon() {
return (
<svg className="size-[18px] text-[#65a2f8]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75" />
</svg>
);
}
function LibraryIcon() {
return (
<svg className="size-[18px] text-[#65a2f8]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M3 21h18M3 7v14M21 7v14M6 7V3h12v4M9 21V11M15 21V11" />
</svg>
);
}
function AppIcon() {
return (
<svg className="size-[18px] text-[#65a2f8]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
</svg>
);
}
function DesktopIcon() {
return (
<svg className="size-[18px] text-[#65a2f8]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="2" y="3" width="20" height="14" rx="2" />
<path d="M8 21h8M12 17v4" />
</svg>
);
}
function DownloadIcon() {
return (
<svg className="size-[18px] text-[#65a2f8]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
</svg>
);
}
function DocumentsIcon() {
return (
<svg className="size-[18px] text-[#65a2f8]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
<polyline points="14,2 14,8 20,8" />
</svg>
);
}
function FolderIcon({ className }: { className?: string }) {
return (
<svg className={className ?? "size-[18px] text-[#65a2f8]"} viewBox="0 0 24 24" fill="currentColor">
<path d="M2 6a2 2 0 012-2h5l2 2h9a2 2 0 012 2v10a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" />
</svg>
);
}
function CloudIcon() {
return (
<svg className="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M18 10h-1.26A8 8 0 109 20h9a5 5 0 000-10z" />
</svg>
);
}
function HomeIcon() {
return (
<svg className="size-[18px] text-[#65a2f8]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1" />
</svg>
);
}
function NetworkIcon() {
return (
<svg className="size-[18px] text-[#65a2f8]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="2" y="3" width="20" height="14" rx="2" />
<path d="M8 21h8M12 17v4" />
</svg>
);
}
function AirdropIcon() {
return (
<svg className="size-[18px] text-[#65a2f8]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="12" cy="12" r="3" />
<path d="M16.24 7.76a6 6 0 010 8.49M7.76 16.24a6 6 0 010-8.49M19.07 4.93a10 10 0 010 14.14M4.93 19.07a10 10 0 010-14.14" />
</svg>
);
}
/* ------------------------------------------------------------------ */
/* README modal (Quick Look style) */
/* ------------------------------------------------------------------ */
function ReadmeModal({ onClose }: { onClose: () => void }) {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onClick={onClose}
>
<div
className="relative mx-4 flex max-h-[80vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-border bg-card shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
{/* titlebar */}
<div className="flex shrink-0 items-center justify-between border-b border-border px-4 py-3">
<div className="flex items-center gap-2">
<button
onClick={onClose}
className="flex size-3 items-center justify-center rounded-full bg-[#ff5f57] transition-opacity hover:opacity-80"
aria-label="Close"
/>
<span className="size-3 rounded-full bg-[#febc2e]" />
<span className="size-3 rounded-full bg-[#28c840]" />
</div>
<span className="text-xs font-medium text-muted-foreground">
README.md
</span>
<span className="w-[52px]" />
</div>
{/* body */}
<div className="overflow-y-auto p-6">
<div className="prose-sm max-w-none space-y-4 text-foreground">
{README_LINES.map((block, i) => {
if (block.tag === "h1")
return (
<h1
key={i}
className="text-2xl font-bold tracking-tight text-foreground"
>
{block.text}
</h1>
);
return (
<p key={i} className="text-sm leading-relaxed text-muted-foreground">
{block.text}
</p>
);
})}
</div>
</div>
</div>
</div>
);
}
/* ------------------------------------------------------------------ */
/* Finder sidebar item */
/* ------------------------------------------------------------------ */
function SidebarItem({
icon,
label,
active,
accent,
onClick,
}: {
icon: React.ReactNode;
label: string;
active?: boolean;
accent?: string;
onClick?: () => void;
}) {
return (
<button
onClick={onClick}
className={`flex w-full items-center gap-2.5 rounded-md px-2 py-[5px] text-left text-[13px] transition-colors ${
active
? "bg-primary/15 text-foreground"
: "text-muted-foreground hover:bg-muted/50"
}`}
>
<span className={accent ?? ""}>{icon}</span>
<span className="truncate">{label}</span>
</button>
);
}
/* ------------------------------------------------------------------ */
/* Finder file grid item (folder) */
/* ------------------------------------------------------------------ */
function GridFolder({
name,
itemCount,
onClick,
}: {
name: string;
itemCount?: number;
onClick?: () => void;
}) {
return (
<button
onClick={onClick}
className="group flex flex-col items-center gap-1 rounded-lg p-3 transition-colors hover:bg-muted/50"
>
<svg className="size-16 text-[#3b9dff] drop-shadow-sm" viewBox="0 0 64 56" fill="currentColor">
<path d="M2 8a6 6 0 016-2h14l4 4h30a6 6 0 016 6v32a6 6 0 01-6 6H8a6 6 0 01-6-6V8z" opacity="0.85" />
<path d="M2 16h60v32a6 6 0 01-6 6H8a6 6 0 01-6-6V16z" opacity="0.95" />
</svg>
<span className="max-w-[100px] truncate text-xs text-foreground">
{name}
</span>
{itemCount !== undefined && (
<span className="text-[10px] text-muted-foreground">
{itemCount} {itemCount === 1 ? "item" : "items"}
</span>
)}
</button>
);
}
/* ------------------------------------------------------------------ */
/* Finder file grid item (file) */
/* ------------------------------------------------------------------ */
function GridFile({
name,
meta,
onClick,
}: {
name: string;
meta?: string;
onClick?: () => void;
}) {
return (
<button
onClick={onClick}
className="group flex flex-col items-center gap-1 rounded-lg p-3 transition-colors hover:bg-muted/50"
>
<div className="relative flex size-16 items-center justify-center">
<svg className="size-14 text-muted-foreground/30" viewBox="0 0 48 56" fill="currentColor">
<path d="M4 0h28l12 12v40a4 4 0 01-4 4H4a4 4 0 01-4-4V4a4 4 0 014-4z" />
<path d="M32 0l12 12H36a4 4 0 01-4-4V0z" opacity="0.5" />
</svg>
<span className="absolute bottom-2 text-[9px] font-semibold uppercase tracking-wide text-foreground/60">
MD
</span>
</div>
<span className="max-w-[100px] truncate text-xs text-foreground">
{name}
</span>
{meta && (
<span className="text-[10px] text-muted-foreground">{meta}</span>
)}
</button>
);
}
/* ------------------------------------------------------------------ */
/* Main page */
/* ------------------------------------------------------------------ */
export default function LandingPage() {
const [readmeOpen, setReadmeOpen] = useState(false);
const [selectedSidebar, setSelectedSidebar] = useState("DAV");
return (
<div className="flex min-h-screen flex-col bg-background text-foreground">
{/* ---- header ---- */}
<header className="flex shrink-0 items-center justify-end px-5 py-3.5">
<div className="flex items-center gap-2">
<Link
href="/docs"
className="rounded-xl border border-border bg-muted/30 px-4 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
Docs
</Link>
<Link
href="/login"
className="rounded-xl border border-border bg-muted/30 px-4 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
Sign in
</Link>
<a
href="https://github.com/harivansh-afk/betterNAS"
target="_blank"
rel="noopener noreferrer"
className="flex size-8 items-center justify-center rounded-xl border border-border bg-muted/30 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="GitHub"
>
<GithubIcon className="size-4" />
</a>
</div>
</header>
{/* ---- finder ---- */}
<main className="flex flex-1 items-center justify-center p-4 sm:p-8">
<div className="w-full max-w-4xl overflow-hidden rounded-xl border border-border bg-card shadow-xl">
{/* titlebar */}
<div className="flex items-center border-b border-border bg-muted/30 px-4 py-2.5">
<div className="flex items-center gap-2">
<span className="size-3 rounded-full bg-[#ff5f57]" />
<span className="size-3 rounded-full bg-[#febc2e]" />
<span className="size-3 rounded-full bg-[#28c840]" />
</div>
<div className="mx-auto flex items-center gap-2">
<span className="text-sm font-medium text-foreground">DAV</span>
</div>
{/* forward/back placeholders */}
<div className="flex items-center gap-1 text-muted-foreground/40">
<svg className="size-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M10 3L5 8l5 5" />
</svg>
<svg className="size-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M6 3l5 5-5 5" />
</svg>
</div>
</div>
{/* content area */}
<div className="flex min-h-[480px]">
{/* ---- sidebar ---- */}
<div className="hidden w-[180px] shrink-0 flex-col gap-0.5 border-r border-border bg-muted/20 p-3 sm:flex">
{/* Favorites */}
<p className="mb-1 mt-1 px-2 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/50">
Favorites
</p>
<SidebarItem icon={<ClockIcon />} label="Recents" />
<SidebarItem icon={<SharedIcon />} label="Shared" />
<SidebarItem icon={<LibraryIcon />} label="Library" />
<SidebarItem icon={<AppIcon />} label="Applications" />
<SidebarItem icon={<DesktopIcon />} label="Desktop" />
<SidebarItem icon={<DownloadIcon />} label="Downloads" />
<SidebarItem icon={<DocumentsIcon />} label="Documents" />
<SidebarItem icon={<FolderIcon className="size-[18px] text-[#65a2f8]" />} label="GitHub" />
{/* Locations */}
<p className="mb-1 mt-4 px-2 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/50">
Locations
</p>
<SidebarItem icon={<HomeIcon />} label="rathi" />
<SidebarItem icon={<NetworkIcon />} label="hari-macbook-pro" />
<SidebarItem
icon={<CloudIcon />}
label="DAV"
active={selectedSidebar === "DAV"}
accent="text-[#65a2f8]"
onClick={() => setSelectedSidebar("DAV")}
/>
<SidebarItem icon={<AirdropIcon />} label="AirDrop" />
</div>
{/* ---- file grid ---- */}
<div className="flex flex-1 flex-col">
{/* toolbar */}
<div className="flex items-center justify-between border-b border-border px-4 py-2">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<CloudIcon />
<span className="font-medium text-foreground">DAV</span>
<span className="text-muted-foreground/50">/</span>
<span>exports</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground/50">
<svg className="size-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="1" y="1" width="6" height="6" rx="1" />
<rect x="9" y="1" width="6" height="6" rx="1" />
<rect x="1" y="9" width="6" height="6" rx="1" />
<rect x="9" y="9" width="6" height="6" rx="1" />
</svg>
<svg className="size-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M2 4h12M2 8h12M2 12h12" />
</svg>
</div>
</div>
{/* files */}
<div className="flex-1 p-4">
<div className="grid grid-cols-2 gap-1 sm:grid-cols-4 md:grid-cols-5">
<GridFolder name="Movies" itemCount={12} />
<GridFolder name="Music" itemCount={847} />
<GridFolder name="Photos" itemCount={3241} />
<GridFolder name="Documents" itemCount={56} />
<GridFolder name="Backups" itemCount={4} />
<GridFile
name="README.md"
meta="4 KB"
onClick={() => setReadmeOpen(true)}
/>
</div>
</div>
{/* statusbar */}
<div className="flex items-center justify-between border-t border-border px-4 py-1.5 text-[11px] text-muted-foreground/50">
<span>5 folders, 1 file</span>
<span>847 GB available</span>
</div>
</div>
</div>
</div>
</main>
{/* ---- readme modal ---- */}
{readmeOpen && <ReadmeModal onClose={() => setReadmeOpen(false)} />}
</div>
);
}

View file

@ -2,6 +2,7 @@
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 {
@ -31,7 +32,7 @@ export default function LoginPage() {
} else {
await register(username, password);
}
router.push("/");
router.push("/app");
} catch (err) {
if (err instanceof ApiError) {
setError(err.message);
@ -45,108 +46,116 @@ export default function LoginPage() {
return (
<main className="flex min-h-screen items-center justify-center bg-background px-4">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<p className="text-xs font-medium uppercase tracking-widest text-muted-foreground">
betterNAS
</p>
<CardTitle className="text-xl">
{mode === "login" ? "Sign in" : "Create account"}
</CardTitle>
<CardDescription>
{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."}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label
htmlFor="username"
className="text-sm font-medium text-foreground"
>
Username
</label>
<input
id="username"
type="text"
autoComplete="username"
required
minLength={3}
maxLength={64}
value={username}
onChange={(e) => 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"
/>
</div>
<div className="flex w-full max-w-sm flex-col gap-4">
<Link
href="/"
className="inline-flex items-center gap-1.5 self-start text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<svg className="size-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M10 3L5 8l5 5" />
</svg>
</Link>
<div className="flex flex-col gap-1.5">
<label
htmlFor="password"
className="text-sm font-medium text-foreground"
>
Password
</label>
<input
id="password"
type="password"
autoComplete={
mode === "login" ? "current-password" : "new-password"
}
required
minLength={8}
value={password}
onChange={(e) => 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"
/>
</div>
<Card>
<CardHeader className="text-center">
<CardTitle className="text-xl">
{mode === "login" ? "Sign in" : "Create account"}
</CardTitle>
<CardDescription>
{mode === "login"
? "Use the same credentials as your node agent and Finder."
: "This account works across the web UI, node agent, and Finder."}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label
htmlFor="username"
className="text-sm font-medium text-foreground"
>
Username
</label>
<input
id="username"
type="text"
autoComplete="username"
required
minLength={3}
maxLength={64}
value={username}
onChange={(e) => 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"
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="flex flex-col gap-1.5">
<label
htmlFor="password"
className="text-sm font-medium text-foreground"
>
Password
</label>
<input
id="password"
type="password"
autoComplete={
mode === "login" ? "current-password" : "new-password"
}
required
minLength={8}
value={password}
onChange={(e) => 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"
/>
</div>
<Button type="submit" disabled={loading} className="w-full">
{loading
? "..."
: mode === "login"
? "Sign in"
: "Create account"}
</Button>
{error && <p className="text-sm text-destructive">{error}</p>}
<p className="text-center text-sm text-muted-foreground">
{mode === "login" ? (
<>
No account?{" "}
<button
type="button"
onClick={() => {
setMode("register");
setError(null);
}}
className="text-foreground underline underline-offset-2"
>
Create one
</button>
</>
) : (
<>
Already have an account?{" "}
<button
type="button"
onClick={() => {
setMode("login");
setError(null);
}}
className="text-foreground underline underline-offset-2"
>
Sign in
</button>
</>
)}
</p>
</form>
</CardContent>
</Card>
<Button type="submit" disabled={loading} className="w-full">
{loading
? "..."
: mode === "login"
? "Sign in"
: "Create account"}
</Button>
<p className="text-center text-sm text-muted-foreground">
{mode === "login" ? (
<>
No account?{" "}
<button
type="button"
onClick={() => {
setMode("register");
setError(null);
}}
className="text-foreground underline underline-offset-2"
>
Create one
</button>
</>
) : (
<>
Already have an account?{" "}
<button
type="button"
onClick={() => {
setMode("login");
setError(null);
}}
className="text-foreground underline underline-offset-2"
>
Sign in
</button>
</>
)}
</p>
</form>
</CardContent>
</Card>
</div>
</main>
);
}

View file

@ -1,373 +1 @@
"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<User | null>(null);
const [exports, setExports] = useState<StorageExport[]>([]);
const [selectedExportId, setSelectedExportId] = useState<string | null>(null);
const [mountProfile, setMountProfile] = useState<MountProfile | null>(null);
const [feedback, setFeedback] = useState<string | null>(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 (
<main className="flex min-h-screen items-center justify-center bg-background">
<p className="text-sm text-muted-foreground">Loading...</p>
</main>
);
}
const selectedExport = selectedExportId
? (exports.find((e) => e.id === selectedExportId) ?? null)
: null;
return (
<main className="min-h-screen bg-background">
<div className="mx-auto flex max-w-6xl flex-col gap-8 px-4 py-8 sm:px-6">
<div className="flex flex-col gap-4">
<div className="flex items-start justify-between">
<div className="flex flex-col gap-1">
<p className="text-xs font-medium uppercase tracking-widest text-muted-foreground">
betterNAS
</p>
<h1 className="font-heading text-2xl font-semibold tracking-tight">
Control Plane
</h1>
</div>
{user && (
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">
{user.username}
</span>
<Button variant="ghost" size="sm" onClick={handleLogout}>
<SignOut className="mr-1 size-4" />
Sign out
</Button>
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">
<Globe data-icon="inline-start" />
{process.env.NEXT_PUBLIC_BETTERNAS_API_URL || "local"}
</Badge>
<Badge variant="secondary">
{exports.length === 1 ? "1 export" : `${exports.length} exports`}
</Badge>
</div>
{user && (
<Card>
<CardHeader>
<CardTitle>Node agent setup</CardTitle>
<CardDescription>
Run the node binary on the machine that owns the files with
the same account credentials you use here and in Finder.
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-4">
<pre className="overflow-x-auto rounded-xl border bg-muted/40 p-4 text-xs text-foreground">
<code>
{`curl -fsSL https://raw.githubusercontent.com/harivansh-afk/betterNAS/main/scripts/install-betternas-node.sh | sh`}
</code>
</pre>
<pre className="overflow-x-auto rounded-xl border bg-muted/40 p-4 text-xs text-foreground">
<code>
{`BETTERNAS_USERNAME=${user.username} BETTERNAS_PASSWORD=... BETTERNAS_EXPORT_PATH=/path/to/export BETTERNAS_NODE_DIRECT_ADDRESS=https://your-public-node-url betternas-node`}
</code>
</pre>
</div>
</CardContent>
</Card>
)}
</div>
{feedback !== null && (
<Alert variant="destructive">
<Warning />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{feedback}</AlertDescription>
</Alert>
)}
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_400px]">
<Card>
<CardHeader>
<CardTitle>Exports</CardTitle>
<CardDescription>
Storage exports registered with this control plane.
</CardDescription>
<CardAction>
<Badge variant="secondary">
{exports.length === 1
? "1 export"
: `${exports.length} exports`}
</Badge>
</CardAction>
</CardHeader>
<CardContent>
{exports.length === 0 ? (
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-10 text-center">
<HardDrives size={32} className="text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">
No exports registered yet. Start the node agent and connect
it to this control plane.
</p>
</div>
) : (
<div className="flex flex-col gap-2">
{exports.map((storageExport) => {
const isSelected = storageExport.id === selectedExportId;
return (
<button
key={storageExport.id}
onClick={() => handleSelectExport(storageExport.id)}
className={cn(
"flex flex-col gap-3 rounded-2xl border p-4 text-left text-sm transition-colors",
isSelected
? "border-primary/20 bg-primary/5"
: "border-border hover:bg-muted/50",
)}
>
<div className="flex items-start justify-between gap-4">
<div className="flex flex-col gap-0.5">
<span className="font-medium text-foreground">
{storageExport.label}
</span>
<span className="text-xs text-muted-foreground">
{storageExport.id}
</span>
</div>
<Badge variant="secondary" className="shrink-0">
{storageExport.protocols.join(", ")}
</Badge>
</div>
<dl className="grid grid-cols-2 gap-x-4 gap-y-2">
<div>
<dt className="mb-0.5 text-xs uppercase tracking-wide text-muted-foreground">
Node
</dt>
<dd className="truncate text-xs text-foreground">
{storageExport.nasNodeId}
</dd>
</div>
<div>
<dt className="mb-0.5 text-xs uppercase tracking-wide text-muted-foreground">
Mount path
</dt>
<dd className="text-xs text-foreground">
{storageExport.mountPath ?? "/dav/"}
</dd>
</div>
<div className="col-span-2">
<dt className="mb-0.5 text-xs uppercase tracking-wide text-muted-foreground">
Export path
</dt>
<dd className="truncate text-xs text-foreground">
{storageExport.path}
</dd>
</div>
</dl>
</button>
);
})}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>
{selectedExport !== null
? `Mount ${selectedExport.label}`
: "Mount instructions"}
</CardTitle>
<CardDescription>
{selectedExport !== null
? "WebDAV mount details for Finder."
: "Select an export to see the mount URL and account login details."}
</CardDescription>
</CardHeader>
<CardContent>
{mountProfile === null ? (
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-10 text-center">
<LinkSimple size={32} className="text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">
Pick an export to see the Finder mount URL and the username
to use with your betterNAS account password.
</p>
</div>
) : (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">
Issued profile
</span>
<Badge
variant={mountProfile.readonly ? "secondary" : "default"}
>
{mountProfile.readonly ? "Read-only" : "Read-write"}
</Badge>
</div>
<Separator />
<div className="flex flex-col gap-4">
<CopyField
label="Mount URL"
value={mountProfile.mountUrl}
/>
<CopyField
label="Username"
value={mountProfile.credential.username}
/>
<Alert>
<AlertTitle>
Use your betterNAS account password
</AlertTitle>
<AlertDescription>
Enter the same password you use to sign in to betterNAS
and run the node agent. v1 does not issue a separate
WebDAV password.
</AlertDescription>
</Alert>
</div>
<Separator />
<dl className="grid grid-cols-2 gap-x-4 gap-y-3">
<div>
<dt className="mb-0.5 text-xs uppercase tracking-wide text-muted-foreground">
Mode
</dt>
<dd className="text-xs text-foreground">
{mountProfile.credential.mode}
</dd>
</div>
<div>
<dt className="mb-0.5 text-xs uppercase tracking-wide text-muted-foreground">
Password source
</dt>
<dd className="text-xs text-foreground">
Your betterNAS account password
</dd>
</div>
</dl>
<Separator />
<div className="flex flex-col gap-3">
<h3 className="text-sm font-medium">Finder steps</h3>
<ol className="flex flex-col gap-2">
{[
"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) => (
<li
key={index}
className="flex gap-2.5 text-sm text-muted-foreground"
>
<span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium text-foreground">
{index + 1}
</span>
{step}
</li>
))}
</ol>
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</main>
);
}
export { default } from "./landing/page";

View file

@ -1,5 +1,16 @@
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;
@ -146,6 +157,10 @@ export async function getMe(): Promise<User> {
return apiFetch<User>("/api/v1/auth/me");
}
export async function listNodes(): Promise<NasNode[]> {
return apiFetch<NasNode[]>("/api/v1/nodes");
}
export async function listExports(): Promise<StorageExport[]> {
return apiFetch<StorageExport[]>("/api/v1/exports");
}

View file

Before

Width:  |  Height:  |  Size: 1 KiB

After

Width:  |  Height:  |  Size: 1 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Before After
Before After

View file

@ -18,6 +18,22 @@ 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

View file

@ -1,4 +1,5 @@
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",

View file

@ -37,7 +37,8 @@ case "$arch_name" in
;;
esac
archive_name="${binary_name}_${version}_${os}_${arch}.tar.gz"
version_stripped="${version#v}"
archive_name="${binary_name}_${version_stripped}_${os}_${arch}.tar.gz"
download_url="${download_base_url}/${version}/${archive_name}"
tmp_dir="$(mktemp -d)"

View file

@ -1,698 +0,0 @@
# 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<string> {
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=<generate-strong-token>
BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN=<generate-strong-token>
BETTERNAS_DAV_AUTH_SECRET=<generate-strong-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`