user-owned DAVs (#14)

This commit is contained in:
Hari 2026-04-01 20:26:44 -04:00 committed by GitHub
parent ca5014750b
commit 1bbfb6647d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 732 additions and 777 deletions

View file

@ -7,8 +7,8 @@ BETTERNAS_NODE_AGENT_PORT=
BETTERNAS_NEXTCLOUD_PORT=
BETTERNAS_EXPORT_PATH=
BETTERNAS_VERSION=local-dev
BETTERNAS_DAV_AUTH_SECRET=
BETTERNAS_DAV_CREDENTIAL_TTL=
BETTERNAS_USERNAME=
BETTERNAS_PASSWORD=
BETTERNAS_NODE_MACHINE_ID=
BETTERNAS_NODE_DISPLAY_NAME=
BETTERNAS_NODE_DIRECT_ADDRESS=

36
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,36 @@
name: ci
on:
pull_request:
push:
branches:
- main
jobs:
verify:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Set up pnpm
uses: pnpm/action-setup@v4
with:
version: 10.33.0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.26.0"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Verify
run: pnpm verify

32
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,32 @@
name: release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.26.0"
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: "~> v2"
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

30
.goreleaser.yaml Normal file
View file

@ -0,0 +1,30 @@
version: 2
project_name: betternas-node
monorepo:
tag_prefix: v
builds:
- id: betternas-node
main: ./apps/node-agent/cmd/node-agent
binary: betternas-node
env:
- CGO_ENABLED=0
goos:
- darwin
- linux
goarch:
- amd64
- arm64
ldflags:
- -s -w
archives:
- id: betternas-node
builds:
- betternas-node
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
changelog:
use: git

View file

@ -44,3 +44,20 @@
- Keep `node-service` limited to the WebDAV mount surface.
- Route admin and control actions through `control-server`, not directly from browsers to `node-service`.
## User-scoped auth requirements
- Remove the bootstrap token flow for v1.
- Use a single user-provided username and password across the entire stack:
- `apps/node-agent` authenticates with the user's username and password from environment variables
- web app sessions authenticate with the same username and password
- WebDAV and Finder authentication use the same username and password
- Do not generate separate WebDAV credentials for users.
- Nodes and exports must be owned by users and scoped so authenticated users can only view and mount their own resources.
- Package the node binary for user download and distribution.
## V1 simplicity
- Keep the implementation as simple as possible.
- Do not over-engineer the auth or distribution model for v1.
- Prefer the smallest change set that makes the product usable and distributable.

View file

@ -17,10 +17,10 @@ The request and response shapes must follow the contracts in
[`packages/contracts`](../../packages/contracts).
`/api/v1/*` endpoints require bearer auth. New nodes register with
`BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN`, client flows use
`BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN`, and node registration returns an
`X-BetterNAS-Node-Token` header for subsequent node-scoped register and
heartbeat and export sync calls. Mount profiles now return standard WebDAV
username and password credentials, and multi-export sync should send an
explicit `mountPath` per export so mount profiles can stay stable across
the same username and password session that users use in the web app.
`BETTERNAS_USERNAME` and `BETTERNAS_PASSWORD` may be provided to seed a default
account for local or self-hosted setups. Nodes and exports are owned by users,
and mount profiles return the account username plus the mount URL so Finder can
authenticate with that same betterNAS password. Multi-export sync should send
an explicit `mountPath` per export so mount profiles can stay stable across
runtimes.

View file

@ -1,8 +1,6 @@
package main
import (
"errors"
"strings"
"time"
)
@ -11,10 +9,6 @@ type appConfig struct {
nextcloudBaseURL string
statePath string
dbPath string
clientToken string
nodeBootstrapToken string
davAuthSecret string
davCredentialTTL time.Duration
sessionTTL time.Duration
registrationEnabled bool
corsOrigin string
@ -28,21 +22,6 @@ type app struct {
}
func newApp(config appConfig, startedAt time.Time) (*app, error) {
config.clientToken = strings.TrimSpace(config.clientToken)
config.nodeBootstrapToken = strings.TrimSpace(config.nodeBootstrapToken)
if config.nodeBootstrapToken == "" {
return nil, errors.New("node bootstrap token is required")
}
config.davAuthSecret = strings.TrimSpace(config.davAuthSecret)
if config.davAuthSecret == "" {
return nil, errors.New("dav auth secret is required")
}
if config.davCredentialTTL <= 0 {
return nil, errors.New("dav credential ttl must be greater than 0")
}
var s store
var err error
if config.dbPath != "" {
@ -91,6 +70,7 @@ type nasNode struct {
LastSeenAt string `json:"lastSeenAt"`
DirectAddress *string `json:"directAddress"`
RelayAddress *string `json:"relayAddress"`
OwnerID string `json:"-"`
}
type storageExport struct {
@ -102,6 +82,7 @@ type storageExport struct {
Protocols []string `json:"protocols"`
CapacityBytes *int64 `json:"capacityBytes"`
Tags []string `json:"tags"`
OwnerID string `json:"-"`
}
type mountProfile struct {

View file

@ -196,21 +196,25 @@ func TestAuthSessionUsedForClientEndpoints(t *testing.T) {
}
}
func TestAuthStaticTokenFallback(t *testing.T) {
func TestAuthSessionIsTheOnlyClientAuthPath(t *testing.T) {
t.Parallel()
_, server := newTestSQLiteApp(t, appConfig{
version: "test-version",
clientToken: "static-fallback-token",
version: "test-version",
registrationEnabled: true,
})
defer server.Close()
// Static token should work for client endpoints.
exports := getJSONAuth[[]storageExport](t, server.Client(), "static-fallback-token", server.URL+"/api/v1/exports")
reg := postJSONAuthCreated[authLoginResponse](t, server.Client(), "", server.URL+"/api/v1/auth/register", authRegisterRequest{
Username: "sessiononly",
Password: "password123",
})
exports := getJSONAuth[[]storageExport](t, server.Client(), reg.Token, server.URL+"/api/v1/exports")
if exports == nil {
t.Fatal("expected exports list, got nil")
}
// Wrong token should fail.
getStatusWithAuth(t, server.Client(), "static-fallback-token", server.URL+"/api/v1/exports", http.StatusUnauthorized)
getStatusWithAuth(t, server.Client(), "wrong", server.URL+"/api/v1/exports", http.StatusUnauthorized)
}

View file

@ -1,6 +1,7 @@
package main
import (
"errors"
"log"
"net/http"
"strings"
@ -25,44 +26,52 @@ func main() {
}
func newAppFromEnv(startedAt time.Time) (*app, error) {
nodeBootstrapToken, err := requiredEnv("BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN")
if err != nil {
return nil, err
}
davAuthSecret, err := requiredEnv("BETTERNAS_DAV_AUTH_SECRET")
if err != nil {
return nil, err
}
davCredentialTTL, err := parseRequiredDurationEnv("BETTERNAS_DAV_CREDENTIAL_TTL")
if err != nil {
return nil, err
}
var sessionTTL time.Duration
rawSessionTTL := strings.TrimSpace(env("BETTERNAS_SESSION_TTL", "720h"))
if rawSessionTTL != "" {
sessionTTL, err = time.ParseDuration(rawSessionTTL)
parsedSessionTTL, err := time.ParseDuration(rawSessionTTL)
if err != nil {
return nil, err
}
sessionTTL = parsedSessionTTL
}
return newApp(
app, err := newApp(
appConfig{
version: env("BETTERNAS_VERSION", "0.1.0-dev"),
nextcloudBaseURL: env("NEXTCLOUD_BASE_URL", ""),
statePath: env("BETTERNAS_CONTROL_PLANE_STATE_PATH", ".state/control-plane/state.json"),
dbPath: env("BETTERNAS_CONTROL_PLANE_DB_PATH", ""),
clientToken: env("BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN", ""),
nodeBootstrapToken: nodeBootstrapToken,
davAuthSecret: davAuthSecret,
davCredentialTTL: davCredentialTTL,
dbPath: env("BETTERNAS_CONTROL_PLANE_DB_PATH", ".state/control-plane/betternas.db"),
sessionTTL: sessionTTL,
registrationEnabled: env("BETTERNAS_REGISTRATION_ENABLED", "true") == "true",
corsOrigin: env("BETTERNAS_CORS_ORIGIN", ""),
},
startedAt,
)
if err != nil {
return nil, err
}
if err := seedDefaultUserFromEnv(app); err != nil {
return nil, err
}
return app, nil
}
func seedDefaultUserFromEnv(app *app) error {
username := strings.TrimSpace(env("BETTERNAS_USERNAME", ""))
password := env("BETTERNAS_PASSWORD", "")
if username == "" || password == "" {
return nil
}
if _, err := app.store.createUser(username, password); err != nil {
if errors.Is(err, errUsernameTaken) {
_, authErr := app.store.authenticateUser(username, password)
return authErr
}
return err
}
return nil
}

View file

@ -1,89 +1,12 @@
package main
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"time"
)
const mountCredentialModeBasicAuth = "basic-auth"
// mountCredentialUsernameTokenBytes controls the random token size in mount
// credential usernames (e.g. "mount-<token>"). The username is also embedded
// inside the signed password payload, so longer tokens produce longer
// passwords. macOS WebDAVFS truncates Basic Auth passwords at 255 bytes,
// which corrupts the HMAC signature and causes auth failures. 24 bytes
// (32 base64url chars) keeps the total password under 250 characters with
// margin for longer node IDs and mount paths.
const mountCredentialUsernameTokenBytes = 24
type signedMountCredentialClaims struct {
Version int `json:"v"`
NodeID string `json:"nodeId"`
MountPath string `json:"mountPath"`
Username string `json:"username"`
Readonly bool `json:"readonly"`
ExpiresAt string `json:"expiresAt"`
}
func issueMountCredential(secret string, nodeID string, mountPath string, readonly bool, issuedAt time.Time, ttl time.Duration) (string, mountCredential, error) {
credentialID, err := newOpaqueToken()
if err != nil {
return "", mountCredential{}, err
}
usernameToken, err := newMountCredentialUsernameToken()
if err != nil {
return "", mountCredential{}, err
}
claims := signedMountCredentialClaims{
Version: 1,
NodeID: nodeID,
MountPath: mountPath,
Username: "mount-" + usernameToken,
Readonly: readonly,
ExpiresAt: issuedAt.UTC().Add(ttl).Format(time.RFC3339),
}
password, err := signMountCredentialClaims(secret, claims)
if err != nil {
return "", mountCredential{}, err
}
return "mount-" + credentialID, mountCredential{
func buildAccountMountCredential(username string) mountCredential {
return mountCredential{
Mode: mountCredentialModeBasicAuth,
Username: claims.Username,
Password: password,
ExpiresAt: claims.ExpiresAt,
}, nil
}
func newMountCredentialUsernameToken() (string, error) {
raw := make([]byte, mountCredentialUsernameTokenBytes)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("generate mount credential username token: %w", err)
Username: username,
Password: "",
ExpiresAt: "",
}
return base64.RawURLEncoding.EncodeToString(raw), nil
}
func signMountCredentialClaims(secret string, claims signedMountCredentialClaims) (string, error) {
payload, err := json.Marshal(claims)
if err != nil {
return "", fmt.Errorf("encode mount credential claims: %w", err)
}
encodedPayload := base64.RawURLEncoding.EncodeToString(payload)
signature := signMountCredentialPayload(secret, encodedPayload)
return encodedPayload + "." + signature, nil
}
func signMountCredentialPayload(secret string, encodedPayload string) string {
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(encodedPayload))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}

View file

@ -31,10 +31,7 @@ var (
nodeAgentBinaryErr error
)
const (
runtimeDAVAuthSecret = "runtime-dav-auth-secret"
runtimeDAVCredentialTTL = "1h"
)
const runtimeUsername = "runtime-user"
func TestControlPlaneBinaryMountLoopIntegration(t *testing.T) {
exportDir := t.TempDir()
@ -47,7 +44,7 @@ func TestControlPlaneBinaryMountLoopIntegration(t *testing.T) {
nodeAgent := startNodeAgentBinaryWithExports(t, controlPlane.baseURL, []string{exportDir}, "machine-runtime-1")
client := &http.Client{Timeout: 2 * time.Second}
exports := waitForExportsByPath(t, client, controlPlane.baseURL+"/api/v1/exports", []string{exportDir})
exports := waitForExportsByPath(t, client, controlPlane.sessionToken, controlPlane.baseURL+"/api/v1/exports", []string{exportDir})
export := exports[exportDir]
if export.ID != "dev-export" {
t.Fatalf("expected export ID %q, got %q", "dev-export", export.ID)
@ -56,7 +53,7 @@ func TestControlPlaneBinaryMountLoopIntegration(t *testing.T) {
t.Fatalf("expected mountPath %q, got %q", defaultWebDAVPath, export.MountPath)
}
mount := postJSONAuth[mountProfile](t, client, testClientToken, controlPlane.baseURL+"/api/v1/mount-profiles/issue", mountProfileRequest{
mount := postJSONAuth[mountProfile](t, client, controlPlane.sessionToken, controlPlane.baseURL+"/api/v1/mount-profiles/issue", mountProfileRequest{
ExportID: export.ID,
})
if mount.MountURL != nodeAgent.baseURL+defaultWebDAVPath {
@ -66,11 +63,11 @@ func TestControlPlaneBinaryMountLoopIntegration(t *testing.T) {
t.Fatalf("expected mount credential mode %q, got %q", mountCredentialModeBasicAuth, mount.Credential.Mode)
}
assertHTTPStatusWithBasicAuth(t, client, "PROPFIND", mount.MountURL, mount.Credential.Username, mount.Credential.Password, http.StatusMultiStatus)
assertMountedFileContentsWithBasicAuth(t, client, mount.MountURL+"README.txt", mount.Credential.Username, mount.Credential.Password, "betterNAS export\n")
assertHTTPStatusWithBasicAuth(t, client, "PROPFIND", mount.MountURL, controlPlane.username, controlPlane.password, http.StatusMultiStatus)
assertMountedFileContentsWithBasicAuth(t, client, mount.MountURL+"README.txt", controlPlane.username, controlPlane.password, "betterNAS export\n")
cloud := postJSONAuth[cloudProfile](t, client, testClientToken, controlPlane.baseURL+"/api/v1/cloud-profiles/issue", cloudProfileRequest{
UserID: "runtime-user",
cloud := postJSONAuth[cloudProfile](t, client, controlPlane.sessionToken, controlPlane.baseURL+"/api/v1/cloud-profiles/issue", cloudProfileRequest{
UserID: controlPlane.userID,
ExportID: export.ID,
Provider: "nextcloud",
})
@ -97,12 +94,12 @@ func TestControlPlaneBinaryMultiExportProfilesStayDistinct(t *testing.T) {
firstMountPath := nodeAgentMountPathForExport(firstExportDir, 2)
secondMountPath := nodeAgentMountPathForExport(secondExportDir, 2)
exports := waitForExportsByPath(t, client, controlPlane.baseURL+"/api/v1/exports", []string{firstExportDir, secondExportDir})
exports := waitForExportsByPath(t, client, controlPlane.sessionToken, controlPlane.baseURL+"/api/v1/exports", []string{firstExportDir, secondExportDir})
firstExport := exports[firstExportDir]
secondExport := exports[secondExportDir]
firstMount := postJSONAuth[mountProfile](t, client, testClientToken, controlPlane.baseURL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: firstExport.ID})
secondMount := postJSONAuth[mountProfile](t, client, testClientToken, controlPlane.baseURL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: secondExport.ID})
firstMount := postJSONAuth[mountProfile](t, client, controlPlane.sessionToken, controlPlane.baseURL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: firstExport.ID})
secondMount := postJSONAuth[mountProfile](t, client, controlPlane.sessionToken, controlPlane.baseURL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: secondExport.ID})
if firstMount.MountURL == secondMount.MountURL {
t.Fatalf("expected distinct runtime mount URLs, got %q", firstMount.MountURL)
}
@ -113,18 +110,18 @@ func TestControlPlaneBinaryMultiExportProfilesStayDistinct(t *testing.T) {
t.Fatalf("expected second runtime mount URL %q, got %q", nodeAgent.baseURL+secondMountPath, secondMount.MountURL)
}
assertHTTPStatusWithBasicAuth(t, client, "PROPFIND", firstMount.MountURL, firstMount.Credential.Username, firstMount.Credential.Password, http.StatusMultiStatus)
assertHTTPStatusWithBasicAuth(t, client, "PROPFIND", secondMount.MountURL, secondMount.Credential.Username, secondMount.Credential.Password, http.StatusMultiStatus)
assertMountedFileContentsWithBasicAuth(t, client, firstMount.MountURL+"README.txt", firstMount.Credential.Username, firstMount.Credential.Password, "first runtime export\n")
assertMountedFileContentsWithBasicAuth(t, client, secondMount.MountURL+"README.txt", secondMount.Credential.Username, secondMount.Credential.Password, "second runtime export\n")
assertHTTPStatusWithBasicAuth(t, client, "PROPFIND", firstMount.MountURL, controlPlane.username, controlPlane.password, http.StatusMultiStatus)
assertHTTPStatusWithBasicAuth(t, client, "PROPFIND", secondMount.MountURL, controlPlane.username, controlPlane.password, http.StatusMultiStatus)
assertMountedFileContentsWithBasicAuth(t, client, firstMount.MountURL+"README.txt", controlPlane.username, controlPlane.password, "first runtime export\n")
assertMountedFileContentsWithBasicAuth(t, client, secondMount.MountURL+"README.txt", controlPlane.username, controlPlane.password, "second runtime export\n")
firstCloud := postJSONAuth[cloudProfile](t, client, testClientToken, controlPlane.baseURL+"/api/v1/cloud-profiles/issue", cloudProfileRequest{
UserID: "runtime-user",
firstCloud := postJSONAuth[cloudProfile](t, client, controlPlane.sessionToken, controlPlane.baseURL+"/api/v1/cloud-profiles/issue", cloudProfileRequest{
UserID: controlPlane.userID,
ExportID: firstExport.ID,
Provider: "nextcloud",
})
secondCloud := postJSONAuth[cloudProfile](t, client, testClientToken, controlPlane.baseURL+"/api/v1/cloud-profiles/issue", cloudProfileRequest{
UserID: "runtime-user",
secondCloud := postJSONAuth[cloudProfile](t, client, controlPlane.sessionToken, controlPlane.baseURL+"/api/v1/cloud-profiles/issue", cloudProfileRequest{
UserID: controlPlane.userID,
ExportID: secondExport.ID,
Provider: "nextcloud",
})
@ -140,8 +137,12 @@ func TestControlPlaneBinaryMultiExportProfilesStayDistinct(t *testing.T) {
}
type runningBinary struct {
baseURL string
logPath string
baseURL string
logPath string
sessionToken string
username string
password string
userID string
}
func startControlPlaneBinary(t *testing.T, version string, nextcloudBaseURL string) runningBinary {
@ -149,7 +150,7 @@ func startControlPlaneBinary(t *testing.T, version string, nextcloudBaseURL stri
port := reserveTCPPort(t)
logPath := filepath.Join(t.TempDir(), "control-plane.log")
statePath := filepath.Join(t.TempDir(), "control-plane-state.json")
dbPath := filepath.Join(t.TempDir(), "control-plane.db")
logFile, err := os.Create(logPath)
if err != nil {
t.Fatalf("create control-plane log file: %v", err)
@ -162,11 +163,8 @@ func startControlPlaneBinary(t *testing.T, version string, nextcloudBaseURL stri
"PORT="+port,
"BETTERNAS_VERSION="+version,
"NEXTCLOUD_BASE_URL="+nextcloudBaseURL,
"BETTERNAS_CONTROL_PLANE_STATE_PATH="+statePath,
"BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN="+testClientToken,
"BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN="+testNodeBootstrapToken,
"BETTERNAS_DAV_AUTH_SECRET="+runtimeDAVAuthSecret,
"BETTERNAS_DAV_CREDENTIAL_TTL="+runtimeDAVCredentialTTL,
"BETTERNAS_CONTROL_PLANE_DB_PATH="+dbPath,
"BETTERNAS_REGISTRATION_ENABLED=true",
)
cmd.Stdout = logFile
cmd.Stderr = logFile
@ -183,11 +181,16 @@ func startControlPlaneBinary(t *testing.T, version string, nextcloudBaseURL stri
baseURL := fmt.Sprintf("http://127.0.0.1:%s", port)
waitForHTTPStatus(t, baseURL+"/health", waitDone, logPath, http.StatusOK)
session := registerRuntimeUser(t, &http.Client{Timeout: 2 * time.Second}, baseURL)
registerProcessCleanup(t, ctx, cancel, cmd, waitDone, logFile, logPath, "control-plane")
return runningBinary{
baseURL: baseURL,
logPath: logPath,
baseURL: baseURL,
logPath: logPath,
sessionToken: session.Token,
username: runtimeUsername,
password: testPassword,
userID: session.User.ID,
}
}
@ -197,7 +200,6 @@ func startNodeAgentBinaryWithExports(t *testing.T, controlPlaneBaseURL string, e
port := reserveTCPPort(t)
baseURL := fmt.Sprintf("http://127.0.0.1:%s", port)
logPath := filepath.Join(t.TempDir(), "node-agent.log")
nodeTokenPath := filepath.Join(t.TempDir(), "node-token")
logFile, err := os.Create(logPath)
if err != nil {
t.Fatalf("create node-agent log file: %v", err)
@ -215,12 +217,11 @@ func startNodeAgentBinaryWithExports(t *testing.T, controlPlaneBaseURL string, e
"PORT="+port,
"BETTERNAS_EXPORT_PATHS_JSON="+string(rawExportPaths),
"BETTERNAS_CONTROL_PLANE_URL="+controlPlaneBaseURL,
"BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN="+testNodeBootstrapToken,
"BETTERNAS_NODE_TOKEN_PATH="+nodeTokenPath,
"BETTERNAS_USERNAME="+runtimeUsername,
"BETTERNAS_PASSWORD="+testPassword,
"BETTERNAS_NODE_MACHINE_ID="+machineID,
"BETTERNAS_NODE_DISPLAY_NAME="+machineID,
"BETTERNAS_NODE_DIRECT_ADDRESS="+baseURL,
"BETTERNAS_DAV_AUTH_SECRET="+runtimeDAVAuthSecret,
"BETTERNAS_VERSION=runtime-test-version",
)
cmd.Stdout = logFile
@ -245,12 +246,12 @@ func startNodeAgentBinaryWithExports(t *testing.T, controlPlaneBaseURL string, e
}
}
func waitForExportsByPath(t *testing.T, client *http.Client, endpoint string, expectedPaths []string) map[string]storageExport {
func waitForExportsByPath(t *testing.T, client *http.Client, token string, endpoint string, expectedPaths []string) map[string]storageExport {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
exports := getJSONAuth[[]storageExport](t, client, testClientToken, endpoint)
exports := getJSONAuth[[]storageExport](t, client, token, endpoint)
exportsByPath := exportsByPath(exports)
allPresent := true
for _, expectedPath := range expectedPaths {
@ -269,6 +270,15 @@ func waitForExportsByPath(t *testing.T, client *http.Client, endpoint string, ex
return nil
}
func registerRuntimeUser(t *testing.T, client *http.Client, baseURL string) authLoginResponse {
t.Helper()
return postJSONAuthCreated[authLoginResponse](t, client, "", baseURL+"/api/v1/auth/register", authRegisterRequest{
Username: runtimeUsername,
Password: testPassword,
})
}
func buildControlPlaneBinary(t *testing.T) string {
t.Helper()

View file

@ -2,7 +2,6 @@ package main
import (
"bytes"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
@ -21,12 +20,12 @@ var (
errMountTargetUnavailable = errors.New("mount target is not available")
errNodeIDMismatch = errors.New("node id path and body must match")
errNodeNotFound = errors.New("node not found")
errNodeOwnedByAnotherUser = errors.New("node is already owned by another user")
)
const (
authorizationHeader = "Authorization"
controlPlaneNodeTokenKey = "X-BetterNAS-Node-Token"
bearerScheme = "Bearer"
authorizationHeader = "Authorization"
bearerScheme = "Bearer"
)
func (a *app) handler() http.Handler {
@ -76,6 +75,11 @@ func (a *app) handleVersion(w http.ResponseWriter, _ *http.Request) {
}
func (a *app) handleNodeRegister(w http.ResponseWriter, r *http.Request) {
currentUser, ok := a.requireSessionUser(w, r)
if !ok {
return
}
request, err := decodeNodeRegistrationRequest(w, r)
if err != nil {
writeDecodeError(w, err)
@ -87,23 +91,25 @@ func (a *app) handleNodeRegister(w http.ResponseWriter, r *http.Request) {
return
}
if !a.authorizeNodeRegistration(w, r, request.MachineID) {
return
}
result, err := a.store.registerNode(request, a.now())
result, err := a.store.registerNode(currentUser.ID, request, a.now())
if err != nil {
if errors.Is(err, errNodeOwnedByAnotherUser) {
http.Error(w, err.Error(), http.StatusConflict)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if result.IssuedNodeToken != "" {
w.Header().Set(controlPlaneNodeTokenKey, result.IssuedNodeToken)
}
writeJSON(w, http.StatusOK, result.Node)
}
func (a *app) handleNodeHeartbeat(w http.ResponseWriter, r *http.Request) {
currentUser, ok := a.requireSessionUser(w, r)
if !ok {
return
}
nodeID := r.PathValue("nodeId")
var request nodeHeartbeatRequest
@ -121,11 +127,7 @@ func (a *app) handleNodeHeartbeat(w http.ResponseWriter, r *http.Request) {
return
}
if !a.authorizeNode(w, r, nodeID) {
return
}
if err := a.store.recordHeartbeat(nodeID, request); err != nil {
if err := a.store.recordHeartbeat(nodeID, currentUser.ID, request); err != nil {
statusCode := http.StatusInternalServerError
if errors.Is(err, errNodeNotFound) {
statusCode = http.StatusNotFound
@ -138,6 +140,11 @@ func (a *app) handleNodeHeartbeat(w http.ResponseWriter, r *http.Request) {
}
func (a *app) handleNodeExports(w http.ResponseWriter, r *http.Request) {
currentUser, ok := a.requireSessionUser(w, r)
if !ok {
return
}
nodeID := r.PathValue("nodeId")
request, err := decodeNodeExportsRequest(w, r)
@ -151,11 +158,7 @@ func (a *app) handleNodeExports(w http.ResponseWriter, r *http.Request) {
return
}
if !a.authorizeNode(w, r, nodeID) {
return
}
exports, err := a.store.upsertExports(nodeID, request)
exports, err := a.store.upsertExports(nodeID, currentUser.ID, request)
if err != nil {
statusCode := http.StatusInternalServerError
if errors.Is(err, errNodeNotFound) {
@ -169,15 +172,17 @@ func (a *app) handleNodeExports(w http.ResponseWriter, r *http.Request) {
}
func (a *app) handleExportsList(w http.ResponseWriter, r *http.Request) {
if !a.requireClientAuth(w, r) {
currentUser, ok := a.requireSessionUser(w, r)
if !ok {
return
}
writeJSON(w, http.StatusOK, a.store.listExports())
writeJSON(w, http.StatusOK, a.store.listExports(currentUser.ID))
}
func (a *app) handleMountProfileIssue(w http.ResponseWriter, r *http.Request) {
if !a.requireClientAuth(w, r) {
currentUser, ok := a.requireSessionUser(w, r)
if !ok {
return
}
@ -192,8 +197,8 @@ func (a *app) handleMountProfileIssue(w http.ResponseWriter, r *http.Request) {
return
}
context, ok := a.store.exportContext(request.ExportID)
if !ok {
context, found := a.store.exportContext(request.ExportID, currentUser.ID)
if !found {
http.Error(w, errExportNotFound.Error(), http.StatusNotFound)
return
}
@ -204,32 +209,20 @@ func (a *app) handleMountProfileIssue(w http.ResponseWriter, r *http.Request) {
return
}
credentialID, credential, err := issueMountCredential(
a.config.davAuthSecret,
context.node.ID,
mountProfilePathForExport(context.export.MountPath),
false,
a.now(),
a.config.davCredentialTTL,
)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, mountProfile{
ID: credentialID,
ID: context.export.ID,
ExportID: context.export.ID,
Protocol: "webdav",
DisplayName: context.export.Label,
MountURL: mountURL,
Readonly: false,
Credential: credential,
Credential: buildAccountMountCredential(currentUser.Username),
})
}
func (a *app) handleCloudProfileIssue(w http.ResponseWriter, r *http.Request) {
if !a.requireClientAuth(w, r) {
currentUser, ok := a.requireSessionUser(w, r)
if !ok {
return
}
@ -244,8 +237,8 @@ func (a *app) handleCloudProfileIssue(w http.ResponseWriter, r *http.Request) {
return
}
context, ok := a.store.exportContext(request.ExportID)
if !ok {
context, found := a.store.exportContext(request.ExportID, currentUser.ID)
if !found {
http.Error(w, errExportNotFound.Error(), http.StatusNotFound)
return
}
@ -257,7 +250,7 @@ func (a *app) handleCloudProfileIssue(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, cloudProfile{
ID: fmt.Sprintf("cloud-%s-%s", request.UserID, context.export.ID),
ID: fmt.Sprintf("cloud-%s-%s", currentUser.ID, context.export.ID),
ExportID: context.export.ID,
Provider: "nextcloud",
BaseURL: baseURL,
@ -1034,71 +1027,22 @@ func corsMiddleware(allowedOrigin string, next http.Handler) http.Handler {
})
}
// --- client auth ---
// --- session auth ---
func (a *app) requireClientAuth(w http.ResponseWriter, r *http.Request) bool {
func (a *app) requireSessionUser(w http.ResponseWriter, r *http.Request) (user, bool) {
presentedToken, ok := bearerToken(r)
if !ok {
writeUnauthorized(w)
return false
return user{}, false
}
// Session-based auth (SQLite).
if _, err := a.store.validateSession(presentedToken); err == nil {
return true
}
// Fall back to static client token for backwards compatibility.
if a.config.clientToken != "" && secureStringEquals(a.config.clientToken, presentedToken) {
return true
}
writeUnauthorized(w)
return false
}
func (a *app) authorizeNodeRegistration(w http.ResponseWriter, r *http.Request, machineID string) bool {
presentedToken, ok := bearerToken(r)
if !ok {
currentUser, err := a.store.validateSession(presentedToken)
if err != nil {
writeUnauthorized(w)
return false
return user{}, false
}
authState, exists := a.store.nodeAuthByMachineID(machineID)
if !exists || strings.TrimSpace(authState.TokenHash) == "" {
if !secureStringEquals(a.config.nodeBootstrapToken, presentedToken) {
writeUnauthorized(w)
return false
}
return true
}
if !tokenHashMatches(authState.TokenHash, presentedToken) {
writeUnauthorized(w)
return false
}
return true
}
func (a *app) authorizeNode(w http.ResponseWriter, r *http.Request, nodeID string) bool {
presentedToken, ok := bearerToken(r)
if !ok {
writeUnauthorized(w)
return false
}
authState, exists := a.store.nodeAuthByID(nodeID)
if !exists {
http.Error(w, errNodeNotFound.Error(), http.StatusNotFound)
return false
}
if strings.TrimSpace(authState.TokenHash) == "" || !tokenHashMatches(authState.TokenHash, presentedToken) {
writeUnauthorized(w)
return false
}
return true
return currentUser, true
}
func bearerToken(r *http.Request) (string, bool) {
@ -1124,11 +1068,3 @@ func writeUnauthorized(w http.ResponseWriter) {
w.Header().Set("WWW-Authenticate", bearerScheme)
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
}
func secureStringEquals(expected string, actual string) bool {
return subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) == 1
}
func tokenHashMatches(expectedHash string, token string) bool {
return secureStringEquals(expectedHash, hashOpaqueToken(token))
}

View file

@ -3,6 +3,7 @@ package main
import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
@ -15,8 +16,9 @@ import (
var testControlPlaneNow = time.Date(2025, time.January, 1, 12, 0, 0, 0, time.UTC)
const (
testPassword = "password123"
testClientToken = "test-client-token"
testNodeBootstrapToken = "test-node-bootstrap-token"
testNodeBootstrapToken = "test-node-session-token"
)
type registeredNode struct {
@ -94,7 +96,7 @@ func TestControlPlaneRegistrationProfilesAndHeartbeat(t *testing.T) {
RelayAddress: &relayAddress,
})
if registration.NodeToken == "" {
t.Fatal("expected node registration to return a node token")
t.Fatal("expected node registration to preserve the session token")
}
syncedExports := syncNodeExports(t, server.Client(), registration.NodeToken, server.URL+"/api/v1/nodes/"+registration.Node.ID+"/exports", nodeExportsRequest{
@ -169,14 +171,14 @@ func TestControlPlaneRegistrationProfilesAndHeartbeat(t *testing.T) {
if mount.Credential.Mode != mountCredentialModeBasicAuth {
t.Fatalf("expected credential mode %q, got %q", mountCredentialModeBasicAuth, mount.Credential.Mode)
}
if mount.Credential.Username == "" {
t.Fatal("expected mount credential username to be set")
if mount.Credential.Username != "fixture" {
t.Fatalf("expected mount credential username %q, got %q", "fixture", mount.Credential.Username)
}
if mount.Credential.Password == "" {
t.Fatal("expected mount credential password to be set")
if mount.Credential.Password != "" {
t.Fatalf("expected mount credential password to be blank, got %q", mount.Credential.Password)
}
if mount.Credential.ExpiresAt == "" {
t.Fatal("expected mount credential expiry to be set")
if mount.Credential.ExpiresAt != "" {
t.Fatalf("expected mount credential expiry to be blank, got %q", mount.Credential.ExpiresAt)
}
cloud := postJSONAuth[cloudProfile](t, server.Client(), testClientToken, server.URL+"/api/v1/cloud-profiles/issue", cloudProfileRequest{
@ -231,7 +233,7 @@ func TestControlPlaneExportSyncReconcilesExportsAndKeepsStableIDs(t *testing.T)
RelayAddress: nil,
})
putJSONAuthStatus(t, server.Client(), testNodeBootstrapToken, server.URL+"/api/v1/nodes/"+firstRegistration.Node.ID+"/exports", nodeExportsRequest{
putJSONAuthStatus(t, server.Client(), "wrong-session-token", server.URL+"/api/v1/nodes/"+firstRegistration.Node.ID+"/exports", nodeExportsRequest{
Exports: []storageExportInput{
{
Label: "Docs",
@ -285,7 +287,7 @@ func TestControlPlaneExportSyncReconcilesExportsAndKeepsStableIDs(t *testing.T)
RelayAddress: nil,
})
putJSONAuthStatus(t, server.Client(), testClientToken, server.URL+"/api/v1/nodes/"+firstRegistration.Node.ID+"/exports", nodeExportsRequest{
putJSONAuthStatus(t, server.Client(), "wrong-session-token", server.URL+"/api/v1/nodes/"+firstRegistration.Node.ID+"/exports", nodeExportsRequest{
Exports: []storageExportInput{
{
Label: "Docs v2",
@ -330,8 +332,8 @@ func TestControlPlaneExportSyncReconcilesExportsAndKeepsStableIDs(t *testing.T)
if secondRegistration.Node.ID != firstRegistration.Node.ID {
t.Fatalf("expected re-registration to keep node ID %q, got %q", firstRegistration.Node.ID, secondRegistration.Node.ID)
}
if secondRegistration.NodeToken != "" {
t.Fatalf("expected re-registration to keep the existing node token, got %q", secondRegistration.NodeToken)
if secondRegistration.NodeToken != firstRegistration.NodeToken {
t.Fatalf("expected re-registration to keep the existing session token %q, got %q", firstRegistration.NodeToken, secondRegistration.NodeToken)
}
updatedExports := exportsByPath(getJSONAuth[[]storageExport](t, server.Client(), testClientToken, server.URL+"/api/v1/exports"))
@ -539,12 +541,12 @@ func TestControlPlaneCloudProfilesRequireConfiguredBaseURLAndExistingExport(t *t
func TestControlPlanePersistsRegistryAcrossAppRestart(t *testing.T) {
t.Parallel()
statePath := filepath.Join(t.TempDir(), "control-plane-state.json")
dbPath := filepath.Join(t.TempDir(), "control-plane.db")
directAddress := "http://nas.local:8090"
_, firstServer := newTestControlPlaneServer(t, appConfig{
version: "test-version",
statePath: statePath,
version: "test-version",
dbPath: dbPath,
})
registration := registerNode(t, firstServer.Client(), firstServer.URL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{
MachineID: "machine-persisted",
@ -566,8 +568,8 @@ func TestControlPlanePersistsRegistryAcrossAppRestart(t *testing.T) {
firstServer.Close()
_, secondServer := newTestControlPlaneServer(t, appConfig{
version: "test-version",
statePath: statePath,
version: "test-version",
dbPath: dbPath,
})
defer secondServer.Close()
@ -656,15 +658,12 @@ func TestControlPlaneRejectsInvalidRequestsAndEnforcesAuth(t *testing.T) {
if err := json.NewDecoder(response.Body).Decode(&node); err != nil {
t.Fatalf("decode registration response: %v", err)
}
nodeToken := strings.TrimSpace(response.Header.Get(controlPlaneNodeTokenKey))
if nodeToken == "" {
t.Fatal("expected node registration to return a node token")
}
nodeToken := testNodeBootstrapToken
if node.ID != "dev-node" {
t.Fatalf("expected node ID %q, got %q", "dev-node", node.ID)
}
putJSONAuthStatus(t, server.Client(), testClientToken, server.URL+"/api/v1/nodes/"+node.ID+"/exports", nodeExportsRequest{
putJSONAuthStatus(t, server.Client(), "wrong-session-token", server.URL+"/api/v1/nodes/"+node.ID+"/exports", nodeExportsRequest{
Exports: []storageExportInput{{
Label: "Docs",
Path: "/srv/docs",
@ -716,7 +715,7 @@ func TestControlPlaneRejectsInvalidRequestsAndEnforcesAuth(t *testing.T) {
},
}, http.StatusBadRequest)
postJSONAuthStatus(t, server.Client(), testClientToken, server.URL+"/api/v1/nodes/"+node.ID+"/heartbeat", nodeHeartbeatRequest{
postJSONAuthStatus(t, server.Client(), "wrong-session-token", server.URL+"/api/v1/nodes/"+node.ID+"/heartbeat", nodeHeartbeatRequest{
NodeID: node.ID,
Status: "online",
LastSeenAt: "2025-01-02T03:04:05Z",
@ -765,21 +764,12 @@ func TestControlPlaneRejectsInvalidRequestsAndEnforcesAuth(t *testing.T) {
func newTestControlPlaneServer(t *testing.T, config appConfig) (*app, *httptest.Server) {
t.Helper()
if config.dbPath == "" {
config.dbPath = filepath.Join(t.TempDir(), "test.db")
}
if config.version == "" {
config.version = "test-version"
}
if config.clientToken == "" {
config.clientToken = testClientToken
}
if config.nodeBootstrapToken == "" {
config.nodeBootstrapToken = testNodeBootstrapToken
}
if config.davAuthSecret == "" {
config.davAuthSecret = "test-dav-auth-secret"
}
if config.davCredentialTTL == 0 {
config.davCredentialTTL = time.Hour
}
app, err := newApp(config, testControlPlaneNow)
if err != nil {
@ -788,11 +778,46 @@ func newTestControlPlaneServer(t *testing.T, config appConfig) (*app, *httptest.
app.now = func() time.Time {
return testControlPlaneNow
}
seedDefaultSessionUser(t, app)
server := httptest.NewServer(app.handler())
return app, server
}
func seedDefaultSessionUser(t *testing.T, app *app) {
t.Helper()
u, err := app.store.createUser("fixture", testPassword)
if err != nil && !errors.Is(err, errUsernameTaken) {
t.Fatalf("seed default test user: %v", err)
}
if errors.Is(err, errUsernameTaken) {
u, err = app.store.authenticateUser("fixture", testPassword)
if err != nil {
t.Fatalf("authenticate seeded test user: %v", err)
}
}
sqliteStore, ok := app.store.(*sqliteStore)
if !ok {
return
}
createdAt := time.Now().UTC().Format(time.RFC3339)
expiresAt := time.Now().UTC().Add(24 * time.Hour).Format(time.RFC3339)
for _, token := range []string{testClientToken, testNodeBootstrapToken} {
if _, err := sqliteStore.db.Exec(
"INSERT OR REPLACE INTO sessions (token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
token,
u.ID,
createdAt,
expiresAt,
); err != nil {
t.Fatalf("seed session %s: %v", token, err)
}
}
}
func exportsByPath(exports []storageExport) map[string]storageExport {
byPath := make(map[string]storageExport, len(exports))
for _, export := range exports {
@ -820,10 +845,19 @@ func registerNode(t *testing.T, client *http.Client, endpoint string, token stri
return registeredNode{
Node: node,
NodeToken: strings.TrimSpace(response.Header.Get(controlPlaneNodeTokenKey)),
NodeToken: strings.TrimSpace(token),
}
}
func registerSessionUser(t *testing.T, client *http.Client, baseURL string, username string) authLoginResponse {
t.Helper()
return postJSONAuthCreated[authLoginResponse](t, client, "", baseURL+"/api/v1/auth/register", authRegisterRequest{
Username: username,
Password: testPassword,
})
}
func syncNodeExports(t *testing.T, client *http.Client, token string, endpoint string, payload nodeExportsRequest) []storageExport {
t.Helper()

View file

@ -17,8 +17,8 @@ import (
)
var (
errUsernameTaken = errors.New("username already taken")
errInvalidLogin = errors.New("invalid username or password")
errUsernameTaken = errors.New("username already taken")
errInvalidLogin = errors.New("invalid username or password")
errSessionExpired = errors.New("session expired or invalid")
)
@ -32,6 +32,7 @@ INSERT OR IGNORE INTO ordinals (name, value) VALUES ('node', 0), ('export', 0);
CREATE TABLE IF NOT EXISTS nodes (
id TEXT PRIMARY KEY,
machine_id TEXT NOT NULL UNIQUE,
owner_id TEXT REFERENCES users(id),
display_name TEXT NOT NULL DEFAULT '',
agent_version TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'online',
@ -48,6 +49,7 @@ CREATE TABLE IF NOT EXISTS node_tokens (
CREATE TABLE IF NOT EXISTS exports (
id TEXT PRIMARY KEY,
node_id TEXT NOT NULL REFERENCES nodes(id),
owner_id TEXT REFERENCES users(id),
label TEXT NOT NULL DEFAULT '',
path TEXT NOT NULL,
mount_path TEXT NOT NULL DEFAULT '',
@ -101,10 +103,40 @@ func newSQLiteStore(dbPath string) (*sqliteStore, error) {
db.Close()
return nil, fmt.Errorf("initialize database schema: %w", err)
}
if err := migrateSQLiteSchema(db); err != nil {
db.Close()
return nil, err
}
return &sqliteStore{db: db}, nil
}
func migrateSQLiteSchema(db *sql.DB) error {
migrations := []string{
"ALTER TABLE nodes ADD COLUMN owner_id TEXT REFERENCES users(id)",
"ALTER TABLE exports ADD COLUMN owner_id TEXT REFERENCES users(id)",
}
for _, statement := range migrations {
if _, err := db.Exec(statement); err != nil && !strings.Contains(err.Error(), "duplicate column name") {
return fmt.Errorf("run sqlite migration %q: %w", statement, err)
}
}
if _, err := db.Exec(`
UPDATE exports
SET owner_id = (
SELECT owner_id
FROM nodes
WHERE nodes.id = exports.node_id
)
WHERE owner_id IS NULL
`); err != nil {
return fmt.Errorf("backfill export owners: %w", err)
}
return nil
}
func (s *sqliteStore) nextOrdinal(tx *sql.Tx, name string) (int, error) {
var value int
err := tx.QueryRow("UPDATE ordinals SET value = value + 1 WHERE name = ? RETURNING value", name).Scan(&value)
@ -128,7 +160,7 @@ func ordinalToExportID(ordinal int) string {
return fmt.Sprintf("dev-export-%d", ordinal)
}
func (s *sqliteStore) registerNode(request nodeRegistrationRequest, registeredAt time.Time) (nodeRegistrationResult, error) {
func (s *sqliteStore) registerNode(ownerID string, request nodeRegistrationRequest, registeredAt time.Time) (nodeRegistrationResult, error) {
tx, err := s.db.Begin()
if err != nil {
return nodeRegistrationResult{}, fmt.Errorf("begin transaction: %w", err)
@ -137,7 +169,8 @@ func (s *sqliteStore) registerNode(request nodeRegistrationRequest, registeredAt
// Check if machine already registered.
var nodeID string
err = tx.QueryRow("SELECT id FROM nodes WHERE machine_id = ?", request.MachineID).Scan(&nodeID)
var existingOwnerID sql.NullString
err = tx.QueryRow("SELECT id, owner_id FROM nodes WHERE machine_id = ?", request.MachineID).Scan(&nodeID, &existingOwnerID)
if err == sql.ErrNoRows {
ordinal, err := s.nextOrdinal(tx, "node")
if err != nil {
@ -146,57 +179,40 @@ func (s *sqliteStore) registerNode(request nodeRegistrationRequest, registeredAt
nodeID = ordinalToNodeID(ordinal)
} else if err != nil {
return nodeRegistrationResult{}, fmt.Errorf("lookup node by machine_id: %w", err)
} else if existingOwnerID.Valid && strings.TrimSpace(existingOwnerID.String) != "" && existingOwnerID.String != ownerID {
return nodeRegistrationResult{}, errNodeOwnedByAnotherUser
}
// Upsert node.
_, err = tx.Exec(`
INSERT INTO nodes (id, machine_id, display_name, agent_version, status, last_seen_at, direct_address, relay_address)
VALUES (?, ?, ?, ?, 'online', ?, ?, ?)
INSERT INTO nodes (id, machine_id, owner_id, display_name, agent_version, status, last_seen_at, direct_address, relay_address)
VALUES (?, ?, ?, ?, ?, 'online', ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
owner_id = excluded.owner_id,
display_name = excluded.display_name,
agent_version = excluded.agent_version,
status = 'online',
last_seen_at = excluded.last_seen_at,
direct_address = excluded.direct_address,
relay_address = excluded.relay_address
`, nodeID, request.MachineID, request.DisplayName, request.AgentVersion,
`, nodeID, request.MachineID, ownerID, request.DisplayName, request.AgentVersion,
registeredAt.UTC().Format(time.RFC3339),
nullableString(request.DirectAddress), nullableString(request.RelayAddress))
if err != nil {
return nodeRegistrationResult{}, fmt.Errorf("upsert node: %w", err)
}
// Issue token if none exists.
var issuedNodeToken string
var existingHash sql.NullString
_ = tx.QueryRow("SELECT token_hash FROM node_tokens WHERE node_id = ?", nodeID).Scan(&existingHash)
if !existingHash.Valid || strings.TrimSpace(existingHash.String) == "" {
nodeToken, err := newOpaqueToken()
if err != nil {
return nodeRegistrationResult{}, err
}
_, err = tx.Exec(
"INSERT OR REPLACE INTO node_tokens (node_id, token_hash) VALUES (?, ?)",
nodeID, hashOpaqueToken(nodeToken))
if err != nil {
return nodeRegistrationResult{}, fmt.Errorf("store node token: %w", err)
}
issuedNodeToken = nodeToken
}
if err := tx.Commit(); err != nil {
return nodeRegistrationResult{}, fmt.Errorf("commit registration: %w", err)
}
node, _ := s.nodeByID(nodeID)
return nodeRegistrationResult{
Node: node,
IssuedNodeToken: issuedNodeToken,
Node: node,
}, nil
}
func (s *sqliteStore) upsertExports(nodeID string, request nodeExportsRequest) ([]storageExport, error) {
func (s *sqliteStore) upsertExports(nodeID string, ownerID string, request nodeExportsRequest) ([]storageExport, error) {
tx, err := s.db.Begin()
if err != nil {
return nil, fmt.Errorf("begin transaction: %w", err)
@ -205,7 +221,7 @@ func (s *sqliteStore) upsertExports(nodeID string, request nodeExportsRequest) (
// Verify node exists.
var exists bool
err = tx.QueryRow("SELECT 1 FROM nodes WHERE id = ?", nodeID).Scan(&exists)
err = tx.QueryRow("SELECT 1 FROM nodes WHERE id = ? AND owner_id = ?", nodeID, ownerID).Scan(&exists)
if err != nil {
return nil, errNodeNotFound
}
@ -238,13 +254,14 @@ func (s *sqliteStore) upsertExports(nodeID string, request nodeExportsRequest) (
}
_, err = tx.Exec(`
INSERT INTO exports (id, node_id, label, path, mount_path, capacity_bytes)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO exports (id, node_id, owner_id, label, path, mount_path, capacity_bytes)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
owner_id = excluded.owner_id,
label = excluded.label,
mount_path = excluded.mount_path,
capacity_bytes = excluded.capacity_bytes
`, exportID, nodeID, input.Label, input.Path, input.MountPath, nullableInt64(input.CapacityBytes))
`, exportID, nodeID, ownerID, input.Label, input.Path, input.MountPath, nullableInt64(input.CapacityBytes))
if err != nil {
return nil, fmt.Errorf("upsert export %q: %w", input.Path, err)
}
@ -288,10 +305,10 @@ func (s *sqliteStore) upsertExports(nodeID string, request nodeExportsRequest) (
return s.listExportsForNode(nodeID), nil
}
func (s *sqliteStore) recordHeartbeat(nodeID string, request nodeHeartbeatRequest) error {
func (s *sqliteStore) recordHeartbeat(nodeID string, ownerID string, request nodeHeartbeatRequest) error {
result, err := s.db.Exec(
"UPDATE nodes SET status = ?, last_seen_at = ? WHERE id = ?",
request.Status, request.LastSeenAt, nodeID)
"UPDATE nodes SET status = ?, last_seen_at = ? WHERE id = ? AND owner_id = ?",
request.Status, request.LastSeenAt, nodeID, ownerID)
if err != nil {
return fmt.Errorf("update heartbeat: %w", err)
}
@ -302,8 +319,8 @@ func (s *sqliteStore) recordHeartbeat(nodeID string, request nodeHeartbeatReques
return nil
}
func (s *sqliteStore) listExports() []storageExport {
rows, err := s.db.Query("SELECT id, node_id, label, path, mount_path, capacity_bytes FROM exports ORDER BY id")
func (s *sqliteStore) listExports(ownerID string) []storageExport {
rows, err := s.db.Query("SELECT id, node_id, owner_id, label, path, mount_path, capacity_bytes FROM exports WHERE owner_id = ? ORDER BY id", ownerID)
if err != nil {
return nil
}
@ -330,7 +347,7 @@ func (s *sqliteStore) listExports() []storageExport {
}
func (s *sqliteStore) listExportsForNode(nodeID string) []storageExport {
rows, err := s.db.Query("SELECT id, node_id, label, path, mount_path, capacity_bytes FROM exports WHERE node_id = ? ORDER BY id", nodeID)
rows, err := s.db.Query("SELECT id, node_id, owner_id, label, path, mount_path, capacity_bytes FROM exports WHERE node_id = ? ORDER BY id", nodeID)
if err != nil {
return nil
}
@ -356,15 +373,19 @@ func (s *sqliteStore) listExportsForNode(nodeID string) []storageExport {
return exports
}
func (s *sqliteStore) exportContext(exportID string) (exportContext, bool) {
func (s *sqliteStore) exportContext(exportID string, ownerID string) (exportContext, bool) {
var e storageExport
var capacityBytes sql.NullInt64
var exportOwnerID sql.NullString
err := s.db.QueryRow(
"SELECT id, node_id, label, path, mount_path, capacity_bytes FROM exports WHERE id = ?",
exportID).Scan(&e.ID, &e.NasNodeID, &e.Label, &e.Path, &e.MountPath, &capacityBytes)
"SELECT id, node_id, owner_id, label, path, mount_path, capacity_bytes FROM exports WHERE id = ? AND owner_id = ?",
exportID, ownerID).Scan(&e.ID, &e.NasNodeID, &exportOwnerID, &e.Label, &e.Path, &e.MountPath, &capacityBytes)
if err != nil {
return exportContext{}, false
}
if exportOwnerID.Valid {
e.OwnerID = exportOwnerID.String
}
if capacityBytes.Valid {
e.CapacityBytes = &capacityBytes.Int64
}
@ -383,12 +404,16 @@ func (s *sqliteStore) nodeByID(nodeID string) (nasNode, bool) {
var n nasNode
var directAddr, relayAddr sql.NullString
var lastSeenAt sql.NullString
var ownerID sql.NullString
err := s.db.QueryRow(
"SELECT id, machine_id, display_name, agent_version, status, last_seen_at, direct_address, relay_address FROM nodes WHERE id = ?",
nodeID).Scan(&n.ID, &n.MachineID, &n.DisplayName, &n.AgentVersion, &n.Status, &lastSeenAt, &directAddr, &relayAddr)
"SELECT id, machine_id, owner_id, display_name, agent_version, status, last_seen_at, direct_address, relay_address FROM nodes WHERE id = ?",
nodeID).Scan(&n.ID, &n.MachineID, &ownerID, &n.DisplayName, &n.AgentVersion, &n.Status, &lastSeenAt, &directAddr, &relayAddr)
if err != nil {
return nasNode{}, false
}
if ownerID.Valid {
n.OwnerID = ownerID.String
}
if lastSeenAt.Valid {
n.LastSeenAt = lastSeenAt.String
}
@ -442,9 +467,13 @@ func (s *sqliteStore) nodeAuthByID(nodeID string) (nodeAuthState, bool) {
func (s *sqliteStore) scanExport(rows *sql.Rows) storageExport {
var e storageExport
var capacityBytes sql.NullInt64
if err := rows.Scan(&e.ID, &e.NasNodeID, &e.Label, &e.Path, &e.MountPath, &capacityBytes); err != nil {
var ownerID sql.NullString
if err := rows.Scan(&e.ID, &e.NasNodeID, &ownerID, &e.Label, &e.Path, &e.MountPath, &capacityBytes); err != nil {
return storageExport{}
}
if ownerID.Valid {
e.OwnerID = ownerID.String
}
if capacityBytes.Valid {
e.CapacityBytes = &capacityBytes.Int64
}

View file

@ -18,24 +18,13 @@ func newTestSQLiteApp(t *testing.T, config appConfig) (*app, *httptest.Server) {
if config.version == "" {
config.version = "test-version"
}
if config.clientToken == "" {
config.clientToken = testClientToken
}
if config.nodeBootstrapToken == "" {
config.nodeBootstrapToken = testNodeBootstrapToken
}
if config.davAuthSecret == "" {
config.davAuthSecret = "test-dav-auth-secret"
}
if config.davCredentialTTL == 0 {
config.davCredentialTTL = time.Hour
}
app, err := newApp(config, testControlPlaneNow)
if err != nil {
t.Fatalf("new app: %v", err)
}
app.now = func() time.Time { return testControlPlaneNow }
seedDefaultSessionUser(t, app)
server := httptest.NewServer(app.handler())
return app, server
@ -79,7 +68,7 @@ func TestSQLiteRegistrationAndExports(t *testing.T) {
RelayAddress: nil,
})
if registration.NodeToken == "" {
t.Fatal("expected node registration to return a node token")
t.Fatal("expected node registration to preserve the session token")
}
if registration.Node.ID != "dev-node" {
t.Fatalf("expected node ID %q, got %q", "dev-node", registration.Node.ID)
@ -142,8 +131,8 @@ func TestSQLiteReRegistrationKeepsNodeID(t *testing.T) {
if second.Node.ID != first.Node.ID {
t.Fatalf("expected re-registration to keep node ID %q, got %q", first.Node.ID, second.Node.ID)
}
if second.NodeToken != "" {
t.Fatalf("expected re-registration to not issue new token, got %q", second.NodeToken)
if second.NodeToken != first.NodeToken {
t.Fatalf("expected re-registration to keep the existing session token %q, got %q", first.NodeToken, second.NodeToken)
}
if second.Node.DisplayName != "NAS Updated" {
t.Fatalf("expected updated display name, got %q", second.Node.DisplayName)

View file

@ -31,8 +31,7 @@ type memoryStore struct {
}
type nodeRegistrationResult struct {
Node nasNode
IssuedNodeToken string
Node nasNode
}
type nodeAuthState struct {
@ -153,12 +152,12 @@ func cloneStoreState(state storeState) storeState {
return cloned
}
func (s *memoryStore) registerNode(request nodeRegistrationRequest, registeredAt time.Time) (nodeRegistrationResult, error) {
func (s *memoryStore) registerNode(ownerID string, request nodeRegistrationRequest, registeredAt time.Time) (nodeRegistrationResult, error) {
s.mu.Lock()
defer s.mu.Unlock()
nextState := cloneStoreState(s.state)
result, err := registerNodeInState(&nextState, request, registeredAt)
result, err := registerNodeInState(&nextState, ownerID, request, registeredAt)
if err != nil {
return nodeRegistrationResult{}, err
}
@ -170,21 +169,14 @@ func (s *memoryStore) registerNode(request nodeRegistrationRequest, registeredAt
return result, nil
}
func registerNodeInState(state *storeState, request nodeRegistrationRequest, registeredAt time.Time) (nodeRegistrationResult, error) {
func registerNodeInState(state *storeState, ownerID string, request nodeRegistrationRequest, registeredAt time.Time) (nodeRegistrationResult, error) {
nodeID, ok := state.NodeIDByMachineID[request.MachineID]
if !ok {
nodeID = nextNodeID(state)
state.NodeIDByMachineID[request.MachineID] = nodeID
}
issuedNodeToken := ""
if stringsTrimmedEmpty(state.NodeTokenHashByID[nodeID]) {
nodeToken, err := newOpaqueToken()
if err != nil {
return nodeRegistrationResult{}, err
}
state.NodeTokenHashByID[nodeID] = hashOpaqueToken(nodeToken)
issuedNodeToken = nodeToken
if existingNode, exists := state.NodesByID[nodeID]; exists && existingNode.OwnerID != "" && existingNode.OwnerID != ownerID {
return nodeRegistrationResult{}, errNodeOwnedByAnotherUser
}
node := nasNode{
@ -196,21 +188,21 @@ func registerNodeInState(state *storeState, request nodeRegistrationRequest, reg
LastSeenAt: registeredAt.UTC().Format(time.RFC3339),
DirectAddress: copyStringPointer(request.DirectAddress),
RelayAddress: copyStringPointer(request.RelayAddress),
OwnerID: ownerID,
}
state.NodesByID[nodeID] = node
return nodeRegistrationResult{
Node: node,
IssuedNodeToken: issuedNodeToken,
Node: node,
}, nil
}
func (s *memoryStore) upsertExports(nodeID string, request nodeExportsRequest) ([]storageExport, error) {
func (s *memoryStore) upsertExports(nodeID string, ownerID string, request nodeExportsRequest) ([]storageExport, error) {
s.mu.Lock()
defer s.mu.Unlock()
nextState := cloneStoreState(s.state)
exports, err := upsertExportsInState(&nextState, nodeID, request.Exports)
exports, err := upsertExportsInState(&nextState, nodeID, ownerID, request.Exports)
if err != nil {
return nil, err
}
@ -222,8 +214,9 @@ func (s *memoryStore) upsertExports(nodeID string, request nodeExportsRequest) (
return exports, nil
}
func upsertExportsInState(state *storeState, nodeID string, exports []storageExportInput) ([]storageExport, error) {
if _, ok := state.NodesByID[nodeID]; !ok {
func upsertExportsInState(state *storeState, nodeID string, ownerID string, exports []storageExportInput) ([]storageExport, error) {
node, ok := state.NodesByID[nodeID]
if !ok || node.OwnerID != ownerID {
return nil, errNodeNotFound
}
@ -250,6 +243,7 @@ func upsertExportsInState(state *storeState, nodeID string, exports []storageExp
Protocols: copyStringSlice(export.Protocols),
CapacityBytes: copyInt64Pointer(export.CapacityBytes),
Tags: copyStringSlice(export.Tags),
OwnerID: ownerID,
}
keepPaths[export.Path] = struct{}{}
}
@ -278,12 +272,12 @@ func upsertExportsInState(state *storeState, nodeID string, exports []storageExp
return nodeExports, nil
}
func (s *memoryStore) recordHeartbeat(nodeID string, request nodeHeartbeatRequest) error {
func (s *memoryStore) recordHeartbeat(nodeID string, ownerID string, request nodeHeartbeatRequest) error {
s.mu.Lock()
defer s.mu.Unlock()
nextState := cloneStoreState(s.state)
if err := recordHeartbeatInState(&nextState, nodeID, request); err != nil {
if err := recordHeartbeatInState(&nextState, nodeID, ownerID, request); err != nil {
return err
}
if err := s.persistLocked(nextState); err != nil {
@ -294,9 +288,9 @@ func (s *memoryStore) recordHeartbeat(nodeID string, request nodeHeartbeatReques
return nil
}
func recordHeartbeatInState(state *storeState, nodeID string, request nodeHeartbeatRequest) error {
func recordHeartbeatInState(state *storeState, nodeID string, ownerID string, request nodeHeartbeatRequest) error {
node, ok := state.NodesByID[nodeID]
if !ok {
if !ok || node.OwnerID != ownerID {
return errNodeNotFound
}
@ -307,12 +301,15 @@ func recordHeartbeatInState(state *storeState, nodeID string, request nodeHeartb
return nil
}
func (s *memoryStore) listExports() []storageExport {
func (s *memoryStore) listExports(ownerID string) []storageExport {
s.mu.RLock()
defer s.mu.RUnlock()
exports := make([]storageExport, 0, len(s.state.ExportsByID))
for _, export := range s.state.ExportsByID {
if export.OwnerID != ownerID {
continue
}
exports = append(exports, copyStorageExport(export))
}
@ -323,17 +320,17 @@ func (s *memoryStore) listExports() []storageExport {
return exports
}
func (s *memoryStore) exportContext(exportID string) (exportContext, bool) {
func (s *memoryStore) exportContext(exportID string, ownerID string) (exportContext, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
export, ok := s.state.ExportsByID[exportID]
if !ok {
if !ok || export.OwnerID != ownerID {
return exportContext{}, false
}
node, ok := s.state.NodesByID[export.NasNodeID]
if !ok {
if !ok || node.OwnerID != ownerID {
return exportContext{}, false
}
@ -468,6 +465,7 @@ func copyNasNode(node nasNode) nasNode {
LastSeenAt: node.LastSeenAt,
DirectAddress: copyStringPointer(node.DirectAddress),
RelayAddress: copyStringPointer(node.RelayAddress),
OwnerID: node.OwnerID,
}
}
@ -481,6 +479,7 @@ func copyStorageExport(export storageExport) storageExport {
Protocols: copyStringSlice(export.Protocols),
CapacityBytes: copyInt64Pointer(export.CapacityBytes),
Tags: copyStringSlice(export.Tags),
OwnerID: export.OwnerID,
}
}

View file

@ -5,14 +5,12 @@ import "time"
// store defines the persistence interface for the control-plane.
type store interface {
// Node management
registerNode(request nodeRegistrationRequest, registeredAt time.Time) (nodeRegistrationResult, error)
upsertExports(nodeID string, request nodeExportsRequest) ([]storageExport, error)
recordHeartbeat(nodeID string, request nodeHeartbeatRequest) error
listExports() []storageExport
exportContext(exportID string) (exportContext, bool)
registerNode(ownerID string, request nodeRegistrationRequest, registeredAt time.Time) (nodeRegistrationResult, error)
upsertExports(nodeID string, ownerID string, request nodeExportsRequest) ([]storageExport, error)
recordHeartbeat(nodeID string, ownerID string, request nodeHeartbeatRequest) error
listExports(ownerID string) []storageExport
exportContext(exportID string, ownerID string) (exportContext, bool)
nodeByID(nodeID string) (nasNode, bool)
nodeAuthByMachineID(machineID string) (nodeAuthState, bool)
nodeAuthByID(nodeID string) (nodeAuthState, bool)
// User auth
createUser(username string, password string) (user, error)

View file

@ -9,6 +9,9 @@ For the scaffold it does two things:
- optionally serves multiple configured exports at deterministic `/dav/exports/<slug>/` paths via `BETTERNAS_EXPORT_PATHS_JSON`
- registers itself with the control plane and syncs its exports when
`BETTERNAS_CONTROL_PLANE_URL` is configured
- enforces issued WebDAV basic-auth mount credentials
- uses `BETTERNAS_USERNAME` and `BETTERNAS_PASSWORD` both for control-plane login
and for local WebDAV basic auth
This is the first real storage-facing surface in the monorepo.
The user-facing binary should be distributed as `betternas-node`.

View file

@ -1,6 +1,7 @@
package main
import (
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
@ -17,15 +18,15 @@ const (
)
type appConfig struct {
exportPaths []string
nodeID string
davAuthSecret string
exportPaths []string
authUsername string
authPassword string
}
type app struct {
nodeID string
davAuthSecret string
exportMounts []exportMount
authUsername string
authPassword string
exportMounts []exportMount
}
type exportMount struct {
@ -34,14 +35,12 @@ type exportMount struct {
}
func newApp(config appConfig) (*app, error) {
config.nodeID = strings.TrimSpace(config.nodeID)
if config.nodeID == "" {
return nil, errors.New("nodeID is required")
config.authUsername = strings.TrimSpace(config.authUsername)
if config.authUsername == "" {
return nil, errors.New("authUsername is required")
}
config.davAuthSecret = strings.TrimSpace(config.davAuthSecret)
if config.davAuthSecret == "" {
return nil, errors.New("davAuthSecret is required")
if config.authPassword == "" {
return nil, errors.New("authPassword is required")
}
exportMounts, err := buildExportMounts(config.exportPaths)
@ -50,9 +49,9 @@ func newApp(config appConfig) (*app, error) {
}
return &app{
nodeID: config.nodeID,
davAuthSecret: config.davAuthSecret,
exportMounts: exportMounts,
authUsername: config.authUsername,
authPassword: config.authPassword,
exportMounts: exportMounts,
}, nil
}
@ -62,24 +61,24 @@ func newAppFromEnv() (*app, error) {
return nil, err
}
davAuthSecret, err := requiredEnv("BETTERNAS_DAV_AUTH_SECRET")
authUsername, err := requiredEnv("BETTERNAS_USERNAME")
if err != nil {
return nil, err
}
authPassword, err := requiredEnv("BETTERNAS_PASSWORD")
if err != nil {
return nil, err
}
nodeID := strings.TrimSpace(env("BETTERNAS_NODE_ID", ""))
if strings.TrimSpace(env("BETTERNAS_CONTROL_PLANE_URL", "")) != "" {
bootstrapResult, err := bootstrapNodeAgentFromEnv(exportPaths)
if err != nil {
if _, err := bootstrapNodeAgentFromEnv(exportPaths); err != nil {
return nil, err
}
nodeID = bootstrapResult.nodeID
}
return newApp(appConfig{
exportPaths: exportPaths,
nodeID: nodeID,
davAuthSecret: davAuthSecret,
exportPaths: exportPaths,
authUsername: authUsername,
authPassword: authPassword,
})
}
@ -182,25 +181,20 @@ func (a *app) requireDAVAuth(mount exportMount, next http.Handler) http.Handler
writeDAVUnauthorized(w)
return
}
claims, err := verifyMountCredential(a.davAuthSecret, password)
if err != nil {
if !a.matchesAccountCredential(username, password) {
writeDAVUnauthorized(w)
return
}
if claims.NodeID != a.nodeID || claims.MountPath != mount.mountPath || claims.Username != username {
writeDAVUnauthorized(w)
return
}
if claims.Readonly && !isDAVReadMethod(r.Method) {
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func (a *app) matchesAccountCredential(username string, password string) bool {
return subtle.ConstantTimeCompare([]byte(strings.TrimSpace(username)), []byte(a.authUsername)) == 1 &&
subtle.ConstantTimeCompare([]byte(password), []byte(a.authPassword)) == 1
}
func mountProfilePathForExport(exportPath string, exportCount int) string {
// Keep /dav/ stable for the common single-export case while exposing distinct
// scoped roots when a node serves more than one export.

View file

@ -1,8 +1,6 @@
package main
import (
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
@ -10,10 +8,12 @@ import (
"path/filepath"
"strings"
"testing"
"time"
)
const testDAVAuthSecret = "test-dav-auth-secret"
const (
testUsername = "alice"
testPassword = "password123"
)
func TestSingleExportServesDefaultAndScopedMountPathsWithValidCredentials(t *testing.T) {
t.Parallel()
@ -22,9 +22,9 @@ func TestSingleExportServesDefaultAndScopedMountPathsWithValidCredentials(t *tes
writeExportFile(t, exportDir, "README.txt", "single export\n")
app, err := newApp(appConfig{
exportPaths: []string{exportDir},
nodeID: "node-1",
davAuthSecret: testDAVAuthSecret,
exportPaths: []string{exportDir},
authUsername: testUsername,
authPassword: testPassword,
})
if err != nil {
t.Fatalf("new app: %v", err)
@ -33,14 +33,12 @@ func TestSingleExportServesDefaultAndScopedMountPathsWithValidCredentials(t *tes
server := httptest.NewServer(app.handler())
defer server.Close()
defaultUsername, defaultPassword := issueTestMountCredential(t, "node-1", defaultWebDAVPath, false)
scopedMountPath := scopedMountPathForExport(exportDir)
scopedUsername, scopedPassword := issueTestMountCredential(t, "node-1", scopedMountPath, false)
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+defaultWebDAVPath, defaultUsername, defaultPassword, http.StatusMultiStatus)
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+scopedMountPath, scopedUsername, scopedPassword, http.StatusMultiStatus)
assertMountedFileContentsWithBasicAuth(t, server.Client(), server.URL+defaultWebDAVPath+"README.txt", defaultUsername, defaultPassword, "single export\n")
assertMountedFileContentsWithBasicAuth(t, server.Client(), server.URL+scopedMountPath+"README.txt", scopedUsername, scopedPassword, "single export\n")
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+defaultWebDAVPath, testUsername, testPassword, http.StatusMultiStatus)
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+scopedMountPath, testUsername, testPassword, http.StatusMultiStatus)
assertMountedFileContentsWithBasicAuth(t, server.Client(), server.URL+defaultWebDAVPath+"README.txt", testUsername, testPassword, "single export\n")
assertMountedFileContentsWithBasicAuth(t, server.Client(), server.URL+scopedMountPath+"README.txt", testUsername, testPassword, "single export\n")
}
func TestMultipleExportsServeDistinctScopedMountPathsWithValidCredentials(t *testing.T) {
@ -52,9 +50,9 @@ func TestMultipleExportsServeDistinctScopedMountPathsWithValidCredentials(t *tes
writeExportFile(t, secondExportDir, "README.txt", "second export\n")
app, err := newApp(appConfig{
exportPaths: []string{firstExportDir, secondExportDir},
nodeID: "node-1",
davAuthSecret: testDAVAuthSecret,
exportPaths: []string{firstExportDir, secondExportDir},
authUsername: testUsername,
authPassword: testPassword,
})
if err != nil {
t.Fatalf("new app: %v", err)
@ -69,13 +67,10 @@ func TestMultipleExportsServeDistinctScopedMountPathsWithValidCredentials(t *tes
t.Fatal("expected distinct mount paths for multiple exports")
}
firstUsername, firstPassword := issueTestMountCredential(t, "node-1", firstMountPath, false)
secondUsername, secondPassword := issueTestMountCredential(t, "node-1", secondMountPath, false)
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+firstMountPath, firstUsername, firstPassword, http.StatusMultiStatus)
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+secondMountPath, secondUsername, secondPassword, http.StatusMultiStatus)
assertMountedFileContentsWithBasicAuth(t, server.Client(), server.URL+firstMountPath+"README.txt", firstUsername, firstPassword, "first export\n")
assertMountedFileContentsWithBasicAuth(t, server.Client(), server.URL+secondMountPath+"README.txt", secondUsername, secondPassword, "second export\n")
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+firstMountPath, testUsername, testPassword, http.StatusMultiStatus)
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+secondMountPath, testUsername, testPassword, http.StatusMultiStatus)
assertMountedFileContentsWithBasicAuth(t, server.Client(), server.URL+firstMountPath+"README.txt", testUsername, testPassword, "first export\n")
assertMountedFileContentsWithBasicAuth(t, server.Client(), server.URL+secondMountPath+"README.txt", testUsername, testPassword, "second export\n")
response, err := server.Client().Get(server.URL + defaultWebDAVPath)
if err != nil {
@ -87,16 +82,16 @@ func TestMultipleExportsServeDistinctScopedMountPathsWithValidCredentials(t *tes
}
}
func TestDAVAuthRejectsMissingInvalidAndReadonlyCredentials(t *testing.T) {
func TestDAVAuthRejectsMissingAndInvalidCredentials(t *testing.T) {
t.Parallel()
exportDir := t.TempDir()
writeExportFile(t, exportDir, "README.txt", "readonly export\n")
writeExportFile(t, exportDir, "README.txt", "mutable export\n")
app, err := newApp(appConfig{
exportPaths: []string{exportDir},
nodeID: "node-1",
davAuthSecret: testDAVAuthSecret,
exportPaths: []string{exportDir},
authUsername: testUsername,
authPassword: testPassword,
})
if err != nil {
t.Fatalf("new app: %v", err)
@ -106,27 +101,24 @@ func TestDAVAuthRejectsMissingInvalidAndReadonlyCredentials(t *testing.T) {
defer server.Close()
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+defaultWebDAVPath, "", "", http.StatusUnauthorized)
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+defaultWebDAVPath, "wrong-user", testPassword, http.StatusUnauthorized)
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+defaultWebDAVPath, testUsername, "wrong-password", http.StatusUnauthorized)
wrongMountUsername, wrongMountPassword := issueTestMountCredential(t, "node-1", "/dav/wrong/", false)
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+defaultWebDAVPath, wrongMountUsername, wrongMountPassword, http.StatusUnauthorized)
expiredUsername, expiredPassword := issueExpiredTestMountCredential(t, "node-1", defaultWebDAVPath, false)
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+defaultWebDAVPath, expiredUsername, expiredPassword, http.StatusUnauthorized)
readonlyUsername, readonlyPassword := issueTestMountCredential(t, "node-1", defaultWebDAVPath, true)
request, err := http.NewRequest(http.MethodPut, server.URL+defaultWebDAVPath+"README.txt", strings.NewReader("updated\n"))
if err != nil {
t.Fatalf("build PUT request: %v", err)
}
request.SetBasicAuth(readonlyUsername, readonlyPassword)
request.SetBasicAuth(testUsername, testPassword)
response, err := server.Client().Do(request)
if err != nil {
t.Fatalf("PUT %s: %v", request.URL.String(), err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusForbidden {
t.Fatalf("expected readonly credential to return 403, got %d", response.StatusCode)
if response.StatusCode != http.StatusCreated && response.StatusCode != http.StatusNoContent && response.StatusCode != http.StatusOK {
t.Fatalf("expected write with valid credentials to succeed, got %d", response.StatusCode)
}
assertMountedFileContentsWithBasicAuth(t, server.Client(), server.URL+defaultWebDAVPath+"README.txt", testUsername, testPassword, "updated\n")
}
func TestBuildExportMountsRejectsInvalidConfigs(t *testing.T) {
@ -193,54 +185,6 @@ func assertMountedFileContentsWithBasicAuth(t *testing.T, client *http.Client, e
}
}
func issueTestMountCredential(t *testing.T, nodeID string, mountPath string, readonly bool) (string, string) {
t.Helper()
claims := signedMountCredentialClaims{
Version: 1,
NodeID: nodeID,
MountPath: mountPath,
Username: "mount-test-user",
Readonly: readonly,
ExpiresAt: time.Now().UTC().Add(time.Hour).Format(time.RFC3339),
}
password, err := encodeTestMountCredential(claims)
if err != nil {
t.Fatalf("issue test mount credential: %v", err)
}
return claims.Username, password
}
func issueExpiredTestMountCredential(t *testing.T, nodeID string, mountPath string, readonly bool) (string, string) {
t.Helper()
claims := signedMountCredentialClaims{
Version: 1,
NodeID: nodeID,
MountPath: mountPath,
Username: "mount-expired-user",
Readonly: readonly,
ExpiresAt: time.Now().UTC().Add(-time.Minute).Format(time.RFC3339),
}
password, err := encodeTestMountCredential(claims)
if err != nil {
t.Fatalf("issue expired test mount credential: %v", err)
}
return claims.Username, password
}
func encodeTestMountCredential(claims signedMountCredentialClaims) (string, error) {
payload, err := json.Marshal(claims)
if err != nil {
return "", err
}
encodedPayload := base64.RawURLEncoding.EncodeToString(payload)
return encodedPayload + "." + signMountCredentialPayload(testDAVAuthSecret, encodedPayload), nil
}
func writeExportFile(t *testing.T, directory string, name string, contents string) {
t.Helper()

View file

@ -14,8 +14,6 @@ import (
"time"
)
const controlPlaneNodeTokenHeader = "X-BetterNAS-Node-Token"
type bootstrapResult struct {
nodeID string
}
@ -32,6 +30,15 @@ type nodeRegistrationResponse struct {
ID string `json:"id"`
}
type authLoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type authLoginResponse struct {
Token string `json:"token"`
}
type nodeExportsRequest struct {
Exports []storageExportInput `json:"exports"`
}
@ -57,38 +64,28 @@ func bootstrapNodeAgentFromEnv(exportPaths []string) (bootstrapResult, error) {
return bootstrapResult{}, err
}
bootstrapToken, err := requiredEnv("BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN")
username, err := requiredEnv("BETTERNAS_USERNAME")
if err != nil {
return bootstrapResult{}, err
}
password, err := requiredEnv("BETTERNAS_PASSWORD")
if err != nil {
return bootstrapResult{}, err
}
nodeTokenPath, err := requiredEnv("BETTERNAS_NODE_TOKEN_PATH")
if err != nil {
return bootstrapResult{}, err
}
machineID, err := requiredEnv("BETTERNAS_NODE_MACHINE_ID")
if err != nil {
return bootstrapResult{}, err
}
displayName := strings.TrimSpace(env("BETTERNAS_NODE_DISPLAY_NAME", machineID))
machineID := strings.TrimSpace(env("BETTERNAS_NODE_MACHINE_ID", defaultNodeMachineID(username)))
displayName := strings.TrimSpace(env("BETTERNAS_NODE_DISPLAY_NAME", defaultNodeDisplayName(machineID)))
if displayName == "" {
displayName = machineID
}
client := &http.Client{Timeout: 5 * time.Second}
nodeToken, err := readNodeToken(nodeTokenPath)
sessionToken, err := loginWithControlPlane(client, controlPlaneURL, username, password)
if err != nil {
return bootstrapResult{}, err
}
authToken := nodeToken
if authToken == "" {
authToken = bootstrapToken
}
registration, issuedNodeToken, err := registerNodeWithControlPlane(client, controlPlaneURL, authToken, nodeRegistrationRequest{
registration, err := registerNodeWithControlPlane(client, controlPlaneURL, sessionToken, nodeRegistrationRequest{
MachineID: machineID,
DisplayName: displayName,
AgentVersion: env("BETTERNAS_VERSION", "0.1.0-dev"),
@ -99,40 +96,58 @@ func bootstrapNodeAgentFromEnv(exportPaths []string) (bootstrapResult, error) {
return bootstrapResult{}, err
}
if strings.TrimSpace(issuedNodeToken) != "" {
if err := writeNodeToken(nodeTokenPath, issuedNodeToken); err != nil {
return bootstrapResult{}, err
}
authToken = issuedNodeToken
}
if err := syncNodeExportsWithControlPlane(client, controlPlaneURL, authToken, registration.ID, buildStorageExportInputs(exportPaths)); err != nil {
if err := syncNodeExportsWithControlPlane(client, controlPlaneURL, sessionToken, registration.ID, buildStorageExportInputs(exportPaths)); err != nil {
return bootstrapResult{}, err
}
if err := sendNodeHeartbeat(client, controlPlaneURL, authToken, registration.ID); err != nil {
if err := sendNodeHeartbeat(client, controlPlaneURL, sessionToken, registration.ID); err != nil {
return bootstrapResult{}, err
}
return bootstrapResult{nodeID: registration.ID}, nil
}
func registerNodeWithControlPlane(client *http.Client, baseURL string, token string, payload nodeRegistrationRequest) (nodeRegistrationResponse, string, error) {
response, err := doControlPlaneJSONRequest(client, http.MethodPost, controlPlaneEndpoint(baseURL, "/api/v1/nodes/register"), token, payload)
func loginWithControlPlane(client *http.Client, baseURL string, username string, password string) (string, error) {
response, err := doControlPlaneJSONRequest(client, http.MethodPost, controlPlaneEndpoint(baseURL, "/api/v1/auth/login"), "", authLoginRequest{
Username: username,
Password: password,
})
if err != nil {
return nodeRegistrationResponse{}, "", err
return "", err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return nodeRegistrationResponse{}, "", controlPlaneResponseError("register node", response)
return "", controlPlaneResponseError("login", response)
}
var auth authLoginResponse
if err := json.NewDecoder(response.Body).Decode(&auth); err != nil {
return "", fmt.Errorf("decode login response: %w", err)
}
if strings.TrimSpace(auth.Token) == "" {
return "", fmt.Errorf("login: missing session token")
}
return strings.TrimSpace(auth.Token), nil
}
func registerNodeWithControlPlane(client *http.Client, baseURL string, token string, payload nodeRegistrationRequest) (nodeRegistrationResponse, error) {
response, err := doControlPlaneJSONRequest(client, http.MethodPost, controlPlaneEndpoint(baseURL, "/api/v1/nodes/register"), token, payload)
if err != nil {
return nodeRegistrationResponse{}, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return nodeRegistrationResponse{}, controlPlaneResponseError("register node", response)
}
var registration nodeRegistrationResponse
if err := json.NewDecoder(response.Body).Decode(&registration); err != nil {
return nodeRegistrationResponse{}, "", fmt.Errorf("decode register node response: %w", err)
return nodeRegistrationResponse{}, fmt.Errorf("decode register node response: %w", err)
}
return registration, strings.TrimSpace(response.Header.Get(controlPlaneNodeTokenHeader)), nil
return registration, nil
}
func syncNodeExportsWithControlPlane(client *http.Client, baseURL string, token string, nodeID string, exports []storageExportInput) error {
@ -181,7 +196,9 @@ func doControlPlaneJSONRequest(client *http.Client, method string, endpoint stri
return nil, fmt.Errorf("build %s %s request: %w", method, endpoint, err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "Bearer "+token)
if strings.TrimSpace(token) != "" {
request.Header.Set("Authorization", "Bearer "+token)
}
response, err := client.Do(request)
if err != nil {
@ -248,52 +265,20 @@ func optionalEnvPointer(key string) *string {
return &value
}
func readNodeToken(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", fmt.Errorf("read node token %s: %w", path, err)
func defaultNodeMachineID(username string) string {
hostname, err := os.Hostname()
if err != nil || strings.TrimSpace(hostname) == "" {
return strings.TrimSpace(username) + "@node"
}
return strings.TrimSpace(string(data)), nil
return strings.TrimSpace(username) + "@" + strings.TrimSpace(hostname)
}
func writeNodeToken(path string, token string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return fmt.Errorf("create node token directory %s: %w", filepath.Dir(path), err)
func defaultNodeDisplayName(machineID string) string {
_, displayName, ok := strings.Cut(strings.TrimSpace(machineID), "@")
if ok && strings.TrimSpace(displayName) != "" {
return strings.TrimSpace(displayName)
}
tempFile, err := os.CreateTemp(filepath.Dir(path), ".node-token-*.tmp")
if err != nil {
return fmt.Errorf("create node token temp file in %s: %w", filepath.Dir(path), err)
}
tempFilePath := tempFile.Name()
cleanupTempFile := true
defer func() {
if cleanupTempFile {
_ = os.Remove(tempFilePath)
}
}()
if err := tempFile.Chmod(0o600); err != nil {
_ = tempFile.Close()
return fmt.Errorf("chmod node token temp file %s: %w", tempFilePath, err)
}
if _, err := tempFile.WriteString(strings.TrimSpace(token) + "\n"); err != nil {
_ = tempFile.Close()
return fmt.Errorf("write node token temp file %s: %w", tempFilePath, err)
}
if err := tempFile.Close(); err != nil {
return fmt.Errorf("close node token temp file %s: %w", tempFilePath, err)
}
if err := os.Rename(tempFilePath, path); err != nil {
return fmt.Errorf("replace node token %s: %w", path, err)
}
cleanupTempFile = false
return nil
return strings.TrimSpace(machineID)
}

View file

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

View file

@ -4,7 +4,7 @@
"private": true,
"scripts": {
"dev": "CGO_ENABLED=0 go run ./cmd/node-agent",
"build": "mkdir -p dist && CGO_ENABLED=0 go build -o dist/node-agent ./cmd/node-agent",
"build": "mkdir -p dist && CGO_ENABLED=0 go build -o dist/betternas-node ./cmd/node-agent",
"format": "files=$(find . -name '*.go' -type f) && if [ -n \"$files\" ]; then gofmt -w $files; fi",
"format:check": "files=$(find . -name '*.go' -type f) && if [ -n \"$files\" ]; then test -z \"$(gofmt -l $files)\"; fi",
"lint": "CGO_ENABLED=0 go vet ./...",

View file

@ -5,17 +5,9 @@ Next.js control-plane UI for betterNAS.
Use this app for:
- admin and operator workflows
- node and export visibility
- issuing mount profiles
- user-scoped node and export visibility
- issuing mount profiles that reuse the same betterNAS account credentials
- later cloud-mode management
Do not move the product system of record into this app. It should stay a UI and
thin BFF layer over the Go control plane.
The current page reads control-plane config from:
- `BETTERNAS_CONTROL_PLANE_URL` and `BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN`, or
- the repo-local `.env.agent` file
That keeps the page aligned with the running self-hosted stack during local
development.

View file

@ -55,8 +55,8 @@ export default function LoginPage() {
</CardTitle>
<CardDescription>
{mode === "login"
? "Sign in to your betterNAS control plane."
: "Create your betterNAS account."}
? "Sign in to your betterNAS control plane with the same credentials you use for the node agent and Finder."
: "Create your betterNAS account. You will use the same username and password for the web app, node agent, and Finder."}
</CardDescription>
</CardHeader>
<CardContent>
@ -103,9 +103,7 @@ export default function LoginPage() {
/>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" disabled={loading} className="w-full">
{loading

View file

@ -78,7 +78,9 @@ export default function Home() {
const profile = await issueMountProfile(exportId);
setMountProfile(profile);
} catch (err) {
setFeedback(err instanceof Error ? err.message : "Failed to issue mount profile");
setFeedback(
err instanceof Error ? err.message : "Failed to issue mount profile",
);
}
}
@ -96,7 +98,7 @@ export default function Home() {
}
const selectedExport = selectedExportId
? exports.find((e) => e.id === selectedExportId) ?? null
? (exports.find((e) => e.id === selectedExportId) ?? null)
: null;
return (
@ -117,11 +119,7 @@ export default function Home() {
<span className="text-sm text-muted-foreground">
{user.username}
</span>
<Button
variant="ghost"
size="sm"
onClick={handleLogout}
>
<Button variant="ghost" size="sm" onClick={handleLogout}>
<SignOut className="mr-1 size-4" />
Sign out
</Button>
@ -138,6 +136,25 @@ export default function Home() {
{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>
<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`}
</code>
</pre>
</CardContent>
</Card>
)}
</div>
{feedback !== null && (
@ -245,8 +262,8 @@ export default function Home() {
</CardTitle>
<CardDescription>
{selectedExport !== null
? "Issued WebDAV credentials for Finder."
: "Select an export to issue mount credentials."}
? "WebDAV mount details for Finder."
: "Select an export to see the mount URL and account login details."}
</CardDescription>
</CardHeader>
<CardContent>
@ -254,7 +271,8 @@ export default function Home() {
<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 issue WebDAV credentials for Finder.
Pick an export to see the Finder mount URL and the username
to use with your betterNAS account password.
</p>
</div>
) : (
@ -281,10 +299,16 @@ export default function Home() {
label="Username"
value={mountProfile.credential.username}
/>
<CopyField
label="Password"
value={mountProfile.credential.password}
/>
<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 />
@ -300,10 +324,10 @@ export default function Home() {
</div>
<div>
<dt className="mb-0.5 text-xs uppercase tracking-wide text-muted-foreground">
Expires
Password source
</dt>
<dd className="text-xs text-foreground">
{mountProfile.credential.expiresAt}
Your betterNAS account password
</dd>
</div>
</dl>
@ -316,8 +340,8 @@ export default function Home() {
{[
"Open Finder and choose Go, then Connect to Server.",
"Paste the mount URL into the server address field.",
"Enter the issued username and password when prompted.",
"Save to Keychain only if the credential expiry suits your workflow.",
"Enter your betterNAS username and account password when prompted.",
"Save to Keychain only if you want Finder to reuse that same account password.",
].map((step, index) => (
<li
key={index}

View file

@ -64,10 +64,7 @@ export function isAuthenticated(): boolean {
return getToken() !== null;
}
async function apiFetch<T>(
path: string,
options?: RequestInit,
): Promise<T> {
async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
const headers: Record<string, string> = {};
const token = getToken();
if (token) {
@ -79,7 +76,10 @@ async function apiFetch<T>(
const response = await fetch(`${API_URL}${path}`, {
...options,
headers: { ...headers, ...Object.fromEntries(new Headers(options?.headers).entries()) },
headers: {
...headers,
...Object.fromEntries(new Headers(options?.headers).entries()),
},
});
if (!response.ok) {

View file

@ -120,12 +120,14 @@ self-hosted mount flow.
- issue mount profile
- issue share or cloud profile later
For v1, the same betterNAS username and password should be used across the web
UI, node enrollment, and Finder WebDAV login.
### Local device -> control-server
- fetch mount instructions
- receive issued WebDAV URL and standard WebDAV credentials
- receive issued WebDAV URL plus the betterNAS account username
- username
- password
- expiresAt
## Initial backend route sketch

View file

@ -32,11 +32,9 @@ services:
PORT: 3000
BETTERNAS_VERSION: ${BETTERNAS_VERSION}
NEXTCLOUD_BASE_URL: ${NEXTCLOUD_BASE_URL}
BETTERNAS_CONTROL_PLANE_STATE_PATH: /var/lib/betternas/control-plane/state.json
BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN: ${BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN}
BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN: ${BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN}
BETTERNAS_DAV_AUTH_SECRET: ${BETTERNAS_DAV_AUTH_SECRET}
BETTERNAS_DAV_CREDENTIAL_TTL: ${BETTERNAS_DAV_CREDENTIAL_TTL}
BETTERNAS_CONTROL_PLANE_DB_PATH: /var/lib/betternas/control-plane/betternas.db
BETTERNAS_USERNAME: ${BETTERNAS_USERNAME}
BETTERNAS_PASSWORD: ${BETTERNAS_PASSWORD}
BETTERNAS_EXAMPLE_MOUNT_URL: ${BETTERNAS_EXAMPLE_MOUNT_URL}
BETTERNAS_NODE_DIRECT_ADDRESS: ${BETTERNAS_NODE_DIRECT_ADDRESS}
ports:
@ -58,12 +56,11 @@ services:
BETTERNAS_EXPORT_PATH: /data/export
BETTERNAS_EXPORT_PATHS_JSON: '["/data/export"]'
BETTERNAS_CONTROL_PLANE_URL: http://control-plane:3000
BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN: ${BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN}
BETTERNAS_NODE_TOKEN_PATH: /var/lib/betternas/node-agent/node-token
BETTERNAS_USERNAME: ${BETTERNAS_USERNAME}
BETTERNAS_PASSWORD: ${BETTERNAS_PASSWORD}
BETTERNAS_NODE_MACHINE_ID: ${BETTERNAS_NODE_MACHINE_ID}
BETTERNAS_NODE_DISPLAY_NAME: ${BETTERNAS_NODE_DISPLAY_NAME}
BETTERNAS_NODE_DIRECT_ADDRESS: ${BETTERNAS_NODE_DIRECT_ADDRESS}
BETTERNAS_DAV_AUTH_SECRET: ${BETTERNAS_DAV_AUTH_SECRET}
BETTERNAS_VERSION: ${BETTERNAS_VERSION}
ports:
- "${BETTERNAS_NODE_AGENT_PORT}:8090"
@ -94,7 +91,7 @@ services:
POSTGRES_PASSWORD: nextcloud
REDIS_HOST: redis
BETTERNAS_CONTROL_PLANE_URL: http://control-plane:3000
BETTERNAS_CONTROL_PLANE_API_TOKEN: ${BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN}
BETTERNAS_CONTROL_PLANE_API_TOKEN: ""
ports:
- "${BETTERNAS_NEXTCLOUD_PORT}:80"
volumes:

View file

@ -22,8 +22,7 @@ paths:
post:
operationId: registerNode
security:
- NodeBootstrapToken: []
- NodeToken: []
- UserSession: []
requestBody:
required: true
content:
@ -33,11 +32,6 @@ paths:
responses:
"200":
description: Registered node
headers:
X-BetterNAS-Node-Token:
description: Returned when a node is first registered or migrated to node-scoped auth.
schema:
type: string
content:
application/json:
schema:
@ -48,7 +42,7 @@ paths:
post:
operationId: recordNodeHeartbeat
security:
- NodeToken: []
- UserSession: []
parameters:
- in: path
name: nodeId
@ -70,7 +64,7 @@ paths:
put:
operationId: syncNodeExports
security:
- NodeToken: []
- UserSession: []
parameters:
- in: path
name: nodeId
@ -98,7 +92,7 @@ paths:
get:
operationId: listExports
security:
- ClientToken: []
- UserSession: []
responses:
"200":
description: Export list
@ -114,7 +108,7 @@ paths:
post:
operationId: issueMountProfile
security:
- ClientToken: []
- UserSession: []
requestBody:
required: true
content:
@ -134,7 +128,7 @@ paths:
post:
operationId: issueCloudProfile
security:
- ClientToken: []
- UserSession: []
requestBody:
required: true
content:
@ -152,18 +146,10 @@ paths:
description: Unauthorized
components:
securitySchemes:
ClientToken:
UserSession:
type: http
scheme: bearer
description: Bearer token required for export listing and profile issuance.
NodeBootstrapToken:
type: http
scheme: bearer
description: Bearer token required to register a new node before it receives a node-scoped token.
NodeToken:
type: http
scheme: bearer
description: Bearer token scoped to a previously registered node.
description: Bearer session token returned by the username and password auth endpoints.
schemas:
NasNode:
type: object

View file

@ -9,7 +9,6 @@ export const FOUNDATION_API_ROUTES = {
export const FOUNDATION_API_HEADERS = {
authorization: "Authorization",
nodeToken: "X-BetterNAS-Node-Token",
} as const;
export type NasNodeStatus = "online" | "offline" | "degraded";

View file

@ -10,9 +10,29 @@ source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../lib/runtime-env.sh"
control_health="$(curl -fsS "http://localhost:${BETTERNAS_CONTROL_PLANE_PORT}/health")"
echo "$control_health" | jq -e '.service == "control-plane" and .status == "ok"' >/dev/null
auth_payload="$(jq -nc --arg username "$BETTERNAS_USERNAME" --arg password "$BETTERNAS_PASSWORD" '{username: $username, password: $password}')"
session_token="$({
curl -fsS \
-X POST \
-H 'Content-Type: application/json' \
-d "$auth_payload" \
"http://localhost:${BETTERNAS_CONTROL_PLANE_PORT}/api/v1/auth/login" \
| jq -er '.token'
} 2>/dev/null || true)"
if [[ -z "$session_token" ]]; then
session_token="$({
curl -fsS \
-X POST \
-H 'Content-Type: application/json' \
-d "$auth_payload" \
"http://localhost:${BETTERNAS_CONTROL_PLANE_PORT}/api/v1/auth/register" \
| jq -er '.token'
})"
fi
export_id=""
for _ in {1..30}; do
exports_response="$(curl -fsS -H "Authorization: Bearer ${BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN}" "http://localhost:${BETTERNAS_CONTROL_PLANE_PORT}/api/v1/exports")"
exports_response="$(curl -fsS -H "Authorization: Bearer ${session_token}" "http://localhost:${BETTERNAS_CONTROL_PLANE_PORT}/api/v1/exports")"
export_id="$({
echo "$exports_response" | jq -er \
'map(select(.mountPath == "/dav/")) | .[0].id? // empty'
@ -32,14 +52,14 @@ mount_profile="$({
curl -fsS \
-X POST \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer ${BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN}" \
-H "Authorization: Bearer ${session_token}" \
-d "{\"exportId\":\"${export_id}\"}" \
"http://localhost:${BETTERNAS_CONTROL_PLANE_PORT}/api/v1/mount-profiles/issue"
})"
echo "$mount_profile" | jq -e --arg expected "$BETTERNAS_EXAMPLE_MOUNT_URL" '.protocol == "webdav" and .mountUrl == $expected and .credential.mode == "basic-auth"' >/dev/null
BETTERNAS_EXAMPLE_MOUNT_USERNAME="$(echo "$mount_profile" | jq -er '.credential.username')"
BETTERNAS_EXAMPLE_MOUNT_PASSWORD="$(echo "$mount_profile" | jq -er '.credential.password')"
BETTERNAS_EXAMPLE_MOUNT_USERNAME="${BETTERNAS_USERNAME}"
BETTERNAS_EXAMPLE_MOUNT_PASSWORD="${BETTERNAS_PASSWORD}"
export BETTERNAS_EXAMPLE_MOUNT_USERNAME
export BETTERNAS_EXAMPLE_MOUNT_PASSWORD
"$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/verify-webdav"
@ -48,7 +68,7 @@ cloud_profile="$({
curl -fsS \
-X POST \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer ${BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN}" \
-H "Authorization: Bearer ${session_token}" \
-d "{\"userId\":\"integration-user\",\"exportId\":\"${export_id}\",\"provider\":\"nextcloud\"}" \
"http://localhost:${BETTERNAS_CONTROL_PLANE_PORT}/api/v1/cloud-profiles/issue"
})"

View file

@ -130,10 +130,8 @@ betternas_write_agent_env_file() {
betternas_write_env_assignment "BETTERNAS_NEXTCLOUD_PORT" "$nextcloud_port"
betternas_write_env_assignment "BETTERNAS_EXPORT_PATH" ".state/${clone_name}/export"
betternas_write_env_assignment "BETTERNAS_VERSION" "local-dev"
betternas_write_env_assignment "BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN" "${clone_name}-local-client-token"
betternas_write_env_assignment "BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN" "${clone_name}-local-node-bootstrap-token"
betternas_write_env_assignment "BETTERNAS_DAV_AUTH_SECRET" "${clone_name}-local-dav-auth-secret"
betternas_write_env_assignment "BETTERNAS_DAV_CREDENTIAL_TTL" "1h"
betternas_write_env_assignment "BETTERNAS_USERNAME" "${clone_name}-user"
betternas_write_env_assignment "BETTERNAS_PASSWORD" "${clone_name}-password123"
betternas_write_env_assignment "BETTERNAS_NODE_MACHINE_ID" "${clone_name}-node"
betternas_write_env_assignment "BETTERNAS_NODE_DISPLAY_NAME" "${clone_name} node"
betternas_write_env_assignment "BETTERNAS_NODE_DIRECT_ADDRESS" "http://localhost:${node_agent_port}"

View file

@ -31,10 +31,8 @@ read -r default_nextcloud_port default_node_agent_port default_control_plane_por
: "${BETTERNAS_NODE_AGENT_PORT:=$default_node_agent_port}"
: "${BETTERNAS_NEXTCLOUD_PORT:=$default_nextcloud_port}"
: "${BETTERNAS_VERSION:=local-dev}"
: "${BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN:=${BETTERNAS_CLONE_NAME}-local-client-token}"
: "${BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN:=${BETTERNAS_CLONE_NAME}-local-node-bootstrap-token}"
: "${BETTERNAS_DAV_AUTH_SECRET:=${BETTERNAS_CLONE_NAME}-local-dav-auth-secret}"
: "${BETTERNAS_DAV_CREDENTIAL_TTL:=1h}"
: "${BETTERNAS_USERNAME:=${BETTERNAS_CLONE_NAME}-user}"
: "${BETTERNAS_PASSWORD:=${BETTERNAS_CLONE_NAME}-password123}"
: "${BETTERNAS_NODE_MACHINE_ID:=${BETTERNAS_CLONE_NAME}-node}"
: "${BETTERNAS_NODE_DISPLAY_NAME:=${BETTERNAS_CLONE_NAME} node}"
: "${NEXTCLOUD_ADMIN_USER:=admin}"
@ -62,10 +60,8 @@ export BETTERNAS_NODE_AGENT_PORT
export BETTERNAS_NEXTCLOUD_PORT
export BETTERNAS_EXPORT_PATH
export BETTERNAS_VERSION
export BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN
export BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN
export BETTERNAS_DAV_AUTH_SECRET
export BETTERNAS_DAV_CREDENTIAL_TTL
export BETTERNAS_USERNAME
export BETTERNAS_PASSWORD
export BETTERNAS_NODE_MACHINE_ID
export BETTERNAS_NODE_DISPLAY_NAME
export NEXTCLOUD_ADMIN_USER

View file

@ -26,6 +26,7 @@ Deploy the betterNAS control-plane as a production service on netty (Netcup VPS)
- Deployment is declarative via NixOS configuration.nix
### Verification:
1. `curl https://api.betternas.com/health` returns `ok`
2. Web UI at `betternas.com` loads, shows login page
3. User can register, log in, see exports, one-click mount
@ -57,6 +58,7 @@ Five phases, each independently deployable and testable:
## Phase 1: SQLite Store
### Overview
Replace `memoryStore` (in-memory + JSON file) with a `sqliteStore` using `modernc.org/sqlite` (pure Go, no CGo, `database/sql` compatible). This keeps all existing API behavior identical while switching the persistence layer.
### Schema
@ -118,15 +120,19 @@ CREATE TABLE export_tags (
### Changes Required
#### 1. Add SQLite dependency
**File**: `apps/control-plane/go.mod`
```
go get modernc.org/sqlite
```
#### 2. New file: `sqlite_store.go`
**File**: `apps/control-plane/cmd/control-plane/sqlite_store.go`
Implements the same operations as `memoryStore` but backed by SQLite:
- `newSQLiteStore(dbPath string) (*sqliteStore, error)` - opens DB, runs migrations
- `registerNode(...)` - INSERT/UPDATE node + token hash in a transaction
- `upsertExports(...)` - DELETE removed exports, UPSERT current ones in a transaction
@ -138,6 +144,7 @@ Implements the same operations as `memoryStore` but backed by SQLite:
- `nextOrdinal(name)` - UPDATE ordinals SET value = value + 1 RETURNING value
Key design decisions:
- Use `database/sql` with `modernc.org/sqlite` driver
- WAL mode enabled at connection: `PRAGMA journal_mode=WAL`
- Foreign keys enabled: `PRAGMA foreign_keys=ON`
@ -146,9 +153,11 @@ Key design decisions:
- No ORM - raw SQL with prepared statements
#### 3. Update `app.go` to use SQLite store
**File**: `apps/control-plane/cmd/control-plane/app.go`
Replace `memoryStore` initialization with `sqliteStore`:
```go
// Replace:
// store, err := newMemoryStore(statePath)
@ -159,6 +168,7 @@ Replace `memoryStore` initialization with `sqliteStore`:
New env var: `BETTERNAS_CONTROL_PLANE_DB_PATH` (default: `/var/lib/betternas/control-plane/betternas.db`)
#### 4. Update `server.go` to use new store interface
**File**: `apps/control-plane/cmd/control-plane/server.go`
The server handlers currently call methods directly on `*memoryStore`. These need to call the equivalent methods on the new store. If the method signatures match, this is a straight swap. If not, introduce a `store` interface that both implement during migration, then delete `memoryStore`.
@ -166,12 +176,14 @@ The server handlers currently call methods directly on `*memoryStore`. These nee
### Success Criteria
#### Automated Verification:
- [ ] `go build ./apps/control-plane/cmd/control-plane/` compiles with `CGO_ENABLED=0`
- [ ] `go test ./apps/control-plane/cmd/control-plane/ -v` passes all existing tests
- [ ] New SQLite store tests pass (register node, upsert exports, list exports, auth lookup)
- [ ] `curl` against a local instance: register node, sync exports, issue mount profile - all return expected responses
#### Manual Verification:
- [ ] Start control-plane locally, SQLite file is created at configured path
- [ ] Restart control-plane - state persists across restarts
- [ ] Node-agent can register and sync exports against the SQLite-backed control-plane
@ -181,6 +193,7 @@ The server handlers currently call methods directly on `*memoryStore`. These nee
## Phase 2: User Auth
### Overview
Add user accounts with username/password (bcrypt) and session tokens stored in SQLite. The session token replaces the static `BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN` for web UI access. Node-agent auth (bootstrap token + node token) is unchanged.
### Additional Schema
@ -216,6 +229,7 @@ GET /api/v1/auth/me - Return current user info (session validation)
### Changes Required
#### 1. New file: `auth.go`
**File**: `apps/control-plane/cmd/control-plane/auth.go`
```go
@ -250,15 +264,18 @@ func (s *sqliteStore) cleanExpiredSessions() error
```
#### 2. New env vars
```
BETTERNAS_SESSION_TTL # Session duration (default: "720h" = 30 days)
BETTERNAS_REGISTRATION_ENABLED # Allow new registrations (default: "true")
```
#### 3. Update `server.go` - auth middleware and routes
**File**: `apps/control-plane/cmd/control-plane/server.go`
Add auth routes:
```go
mux.HandleFunc("POST /api/v1/auth/register", s.handleRegister)
mux.HandleFunc("POST /api/v1/auth/login", s.handleLogin)
@ -267,6 +284,7 @@ mux.HandleFunc("GET /api/v1/auth/me", s.handleMe)
```
Update client-auth middleware:
```go
// Currently: checks Bearer token against static BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN
// New: checks Bearer token against sessions table first, falls back to static token
@ -297,12 +315,14 @@ func (s *server) requireClientAuth(next http.Handler) http.Handler {
### Success Criteria
#### Automated Verification:
- [ ] `go test` passes for auth endpoints (register, login, logout, me)
- [ ] `go test` passes for session middleware (valid token, expired token, invalid token)
- [ ] Existing client token auth still works (backwards compat)
- [ ] Existing node auth unchanged
#### Manual Verification:
- [ ] Register a user via curl, login, use session token to list exports
- [ ] Session expires after TTL
- [ ] Logout invalidates session immediately
@ -313,11 +333,13 @@ func (s *server) requireClientAuth(next http.Handler) http.Handler {
## Phase 3: CORS + Frontend Auth Integration
### Overview
Add CORS headers to the control-plane so the Vercel-hosted frontend can make API calls. Update the web frontend to use session-based auth (login page, session cookie/token management).
### Changes Required
#### 1. CORS middleware in control-plane
**File**: `apps/control-plane/cmd/control-plane/server.go`
```go
@ -342,14 +364,17 @@ func corsMiddleware(allowedOrigin string, next http.Handler) http.Handler {
```
#### 2. Frontend auth flow
**Files**: `apps/web/`
New pages/components:
- `app/login/page.tsx` - Login form (username + password)
- `app/register/page.tsx` - Registration form (if enabled)
- `lib/auth.ts` - Client-side auth helpers (store token, attach to requests)
Update `lib/control-plane.ts`:
- Remove `.env.agent` file reading (production doesn't need it)
- Read `NEXT_PUBLIC_BETTERNAS_API_URL` env var for the backend URL
- Use session token from localStorage/cookie instead of static client token
@ -372,7 +397,11 @@ export function clearSessionToken(): void {
localStorage.removeItem(TOKEN_KEY);
}
export async function login(apiUrl: string, username: string, password: string): Promise<string> {
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" },
@ -386,6 +415,7 @@ export async function login(apiUrl: string, username: string, password: string):
```
Update `lib/control-plane.ts`:
```typescript
// Replace the current getControlPlaneConfig with:
export function getControlPlaneConfig(): ControlPlaneConfig {
@ -396,6 +426,7 @@ export function getControlPlaneConfig(): ControlPlaneConfig {
```
#### 3. Auth-gated layout
**File**: `apps/web/app/layout.tsx` or a middleware
Redirect to `/login` if no valid session. The `/login` and `/register` pages are public.
@ -403,11 +434,13 @@ Redirect to `/login` if no valid session. The `/login` and `/register` pages are
### Success Criteria
#### Automated Verification:
- [ ] CORS preflight (OPTIONS) returns correct headers
- [ ] Frontend builds: `cd apps/web && pnpm build`
- [ ] No TypeScript errors
#### Manual Verification:
- [ ] Open `betternas.com` (or localhost:3000) - redirected to login
- [ ] Register a new account, login, see exports dashboard
- [ ] Click an export, get mount credentials
@ -419,20 +452,25 @@ Redirect to `/login` if no valid session. The `/login` and `/register` pages are
## Phase 4: NixOS Deployment (netty)
### Overview
Deploy the control-plane as a NixOS-managed systemd service on netty, behind NGINX with ACME TLS at `api.betternas.com`. Stop the Docker Compose stack.
### Changes Required
#### 1. DNS: Point `api.betternas.com` to netty
Run from local machine (Vercel CLI):
```bash
vercel dns add betternas.com api A 152.53.195.59
```
#### 2. Build the Go binary for Linux
**File**: `apps/control-plane/Dockerfile` (or local cross-compile)
For NixOS, we can either:
- (a) Cross-compile locally: `GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o control-plane ./cmd/control-plane`
- (b) Build a Nix package (cleaner, but more work)
- (c) Build on netty directly from the git repo
@ -440,6 +478,7 @@ For NixOS, we can either:
Recommendation: **(c) Build on netty** from the cloned repo. Simple, works now. Add a Nix package later if desired.
#### 3. NixOS configuration changes
**File**: `/home/rathi/Documents/GitHub/nix/hosts/netty/configuration.nix`
Add these blocks (following the existing forgejo/vaultwarden pattern):
@ -479,6 +518,7 @@ Add these blocks (following the existing forgejo/vaultwarden pattern):
```
#### 4. Environment file on netty
**File**: `/var/lib/betternas/control-plane/control-plane.env`
```bash
@ -496,6 +536,7 @@ BETTERNAS_NODE_DIRECT_ADDRESS=https://api.betternas.com
```
#### 5. Build and deploy script
**File**: `apps/control-plane/scripts/deploy-netty.sh`
```bash
@ -516,7 +557,9 @@ ssh "$REMOTE" "cd $REPO && git pull && \
```
#### 6. Stop Docker Compose stack
After the systemd service is running and verified:
```bash
ssh netty 'bash -c "cd /home/rathi/Documents/GitHub/betterNAS/betterNAS && source scripts/lib/runtime-env.sh && compose down"'
```
@ -524,12 +567,14 @@ ssh netty 'bash -c "cd /home/rathi/Documents/GitHub/betterNAS/betterNAS && sourc
### Success Criteria
#### Automated Verification:
- [ ] `curl https://api.betternas.com/health` returns `ok`
- [ ] `curl https://api.betternas.com/version` returns version JSON
- [ ] TLS certificate is valid (Let's Encrypt)
- [ ] `systemctl status betternas-control-plane` shows active
#### Manual Verification:
- [ ] Node-agent can register against `https://api.betternas.com`
- [ ] Mount credentials issued via the API work in Finder
- [ ] Service survives restart: `sudo systemctl restart betternas-control-plane`
@ -540,44 +585,52 @@ ssh netty 'bash -c "cd /home/rathi/Documents/GitHub/betterNAS/betterNAS && sourc
## Phase 5: Vercel Deployment
### Overview
Deploy the Next.js web UI to Vercel at `betternas.com`.
### Changes Required
#### 1. Create Vercel project
```bash
cd apps/web
vercel link # or vercel --yes
```
#### 2. Configure environment variables on Vercel
```bash
vercel env add NEXT_PUBLIC_BETTERNAS_API_URL production
# Value: https://api.betternas.com
```
#### 3. Configure domain
```bash
vercel domains add betternas.com
# Already have wildcard ALIAS to vercel-dns, so this should work
```
#### 4. Deploy
```bash
cd apps/web
vercel --prod
```
#### 5. Verify CORS
The backend at `api.betternas.com` must have `BETTERNAS_CORS_ORIGIN=https://betternas.com` set (done in Phase 4).
### Success Criteria
#### Automated Verification:
- [ ] `curl -I https://betternas.com` returns 200
- [ ] CORS preflight from `betternas.com` to `api.betternas.com` succeeds
#### Manual Verification:
- [ ] Visit `betternas.com` - see login page
- [ ] Register, login, see exports, issue mount credentials
- [ ] Mount from Finder using issued credentials
@ -600,16 +653,19 @@ This is a follow-up task, not part of the initial deployment.
## Testing Strategy
### Unit Tests (Go):
- SQLite store: CRUD operations, transactions, concurrent access
- Auth: registration, login, session validation, expiry, logout
- Migration: schema creates cleanly on empty DB
### Integration Tests:
- Full API flow: register user -> login -> list exports -> issue mount profile
- Node registration + export sync against SQLite store
- Session expiry and cleanup
### Manual Testing:
1. Fresh deploy: start control-plane with empty DB
2. Register first user via API
3. Login from web UI