Add SQLite store and user auth for production deployment

Replace the in-memory JSON-backed store with a SQLite option using
modernc.org/sqlite (pure Go, no CGo). Add user authentication with
bcrypt password hashing and random session tokens.

SQLite store:
- Schema covers nodes, exports, tokens, ordinals, users, sessions
- WAL mode and foreign keys enabled
- Set BETTERNAS_CONTROL_PLANE_DB_PATH to activate (falls back to
  memory store when empty)

User auth:
- POST /api/v1/auth/register, login, logout, GET /me
- bcrypt (cost 10) for passwords, 32-byte hex session tokens
- Sessions stored in SQLite with configurable TTL
- Client endpoints accept session tokens or static client token
- CORS middleware via BETTERNAS_CORS_ORIGIN env var

New env vars: BETTERNAS_CONTROL_PLANE_DB_PATH, BETTERNAS_SESSION_TTL,
BETTERNAS_REGISTRATION_ENABLED, BETTERNAS_CORS_ORIGIN

24 tests pass including 7 SQLite store tests and 7 auth tests.
Builds clean with CGO_ENABLED=0.
This commit is contained in:
Harivansh Rathi 2026-04-01 18:47:59 -04:00
parent 1bb065ade0
commit c499e46a4d
12 changed files with 2138 additions and 28 deletions

View file

@ -7,27 +7,28 @@ import (
) )
type appConfig struct { type appConfig struct {
version string version string
nextcloudBaseURL string nextcloudBaseURL string
statePath string statePath string
clientToken string dbPath string
nodeBootstrapToken string clientToken string
davAuthSecret string nodeBootstrapToken string
davCredentialTTL time.Duration davAuthSecret string
davCredentialTTL time.Duration
sessionTTL time.Duration
registrationEnabled bool
corsOrigin string
} }
type app struct { type app struct {
startedAt time.Time startedAt time.Time
now func() time.Time now func() time.Time
config appConfig config appConfig
store *memoryStore store store
} }
func newApp(config appConfig, startedAt time.Time) (*app, error) { func newApp(config appConfig, startedAt time.Time) (*app, error) {
config.clientToken = strings.TrimSpace(config.clientToken) config.clientToken = strings.TrimSpace(config.clientToken)
if config.clientToken == "" {
return nil, errors.New("client token is required")
}
config.nodeBootstrapToken = strings.TrimSpace(config.nodeBootstrapToken) config.nodeBootstrapToken = strings.TrimSpace(config.nodeBootstrapToken)
if config.nodeBootstrapToken == "" { if config.nodeBootstrapToken == "" {
@ -42,7 +43,13 @@ func newApp(config appConfig, startedAt time.Time) (*app, error) {
return nil, errors.New("dav credential ttl must be greater than 0") return nil, errors.New("dav credential ttl must be greater than 0")
} }
store, err := newMemoryStore(config.statePath) var s store
var err error
if config.dbPath != "" {
s, err = newSQLiteStore(config.dbPath)
} else {
s, err = newMemoryStore(config.statePath)
}
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -51,7 +58,7 @@ func newApp(config appConfig, startedAt time.Time) (*app, error) {
startedAt: startedAt, startedAt: startedAt,
now: time.Now, now: time.Now,
config: config, config: config,
store: store, store: s,
}, nil }, nil
} }
@ -164,6 +171,12 @@ type exportContext struct {
node nasNode node nasNode
} }
type user struct {
ID string `json:"id"`
Username string `json:"username"`
CreatedAt string `json:"createdAt"`
}
func copyStringPointer(value *string) *string { func copyStringPointer(value *string) *string {
if value == nil { if value == nil {
return nil return nil

View file

@ -0,0 +1,216 @@
package main
import (
"encoding/json"
"io"
"net/http"
"testing"
"time"
)
func postJSONAuthCreated[T any](t *testing.T, client *http.Client, token string, endpoint string, payload any) T {
t.Helper()
response := postJSONAuthResponse(t, client, token, endpoint, payload)
defer response.Body.Close()
if response.StatusCode != http.StatusCreated {
responseBody, _ := io.ReadAll(response.Body)
t.Fatalf("post %s: expected status 201, got %d: %s", endpoint, response.StatusCode, responseBody)
}
var decoded T
if err := json.NewDecoder(response.Body).Decode(&decoded); err != nil {
t.Fatalf("decode %s response: %v", endpoint, err)
}
return decoded
}
func TestAuthRegisterLoginLogoutMe(t *testing.T) {
t.Parallel()
_, server := newTestSQLiteApp(t, appConfig{
version: "test-version",
registrationEnabled: true,
sessionTTL: time.Hour,
})
defer server.Close()
// Register.
reg := postJSONAuthCreated[authLoginResponse](t, server.Client(), "", server.URL+"/api/v1/auth/register", authRegisterRequest{
Username: "testuser",
Password: "password123",
})
if reg.Token == "" {
t.Fatal("expected session token from registration")
}
if reg.User.Username != "testuser" {
t.Fatalf("expected username %q, got %q", "testuser", reg.User.Username)
}
if reg.User.ID == "" {
t.Fatal("expected user ID")
}
// /me with the registration token.
me := getJSONAuth[user](t, server.Client(), reg.Token, server.URL+"/api/v1/auth/me")
if me.Username != "testuser" {
t.Fatalf("expected username %q from /me, got %q", "testuser", me.Username)
}
// Use session to list exports (client auth).
exports := getJSONAuth[[]storageExport](t, server.Client(), reg.Token, server.URL+"/api/v1/exports")
if len(exports) != 0 {
t.Fatalf("expected 0 exports, got %d", len(exports))
}
// Login with same credentials.
login := postJSONAuth[authLoginResponse](t, server.Client(), "", server.URL+"/api/v1/auth/login", authLoginRequest{
Username: "testuser",
Password: "password123",
})
if login.Token == "" {
t.Fatal("expected session token from login")
}
if login.Token == reg.Token {
t.Fatal("expected login to issue a different token than registration")
}
// Logout the registration token.
postJSONAuthStatus(t, server.Client(), reg.Token, server.URL+"/api/v1/auth/logout", nil, http.StatusNoContent)
// Old token should be invalid now.
getStatusWithAuth(t, server.Client(), reg.Token, server.URL+"/api/v1/auth/me", http.StatusUnauthorized)
// Login token still works.
me = getJSONAuth[user](t, server.Client(), login.Token, server.URL+"/api/v1/auth/me")
if me.Username != "testuser" {
t.Fatalf("expected username %q, got %q", "testuser", me.Username)
}
}
func TestAuthDuplicateUsername(t *testing.T) {
t.Parallel()
_, server := newTestSQLiteApp(t, appConfig{
version: "test-version",
registrationEnabled: true,
})
defer server.Close()
postJSONAuthCreated[authLoginResponse](t, server.Client(), "", server.URL+"/api/v1/auth/register", authRegisterRequest{
Username: "taken",
Password: "password123",
})
postJSONAuthStatus(t, server.Client(), "", server.URL+"/api/v1/auth/register", authRegisterRequest{
Username: "taken",
Password: "different456",
}, http.StatusConflict)
}
func TestAuthBadCredentials(t *testing.T) {
t.Parallel()
_, server := newTestSQLiteApp(t, appConfig{
version: "test-version",
registrationEnabled: true,
})
defer server.Close()
postJSONAuthCreated[authLoginResponse](t, server.Client(), "", server.URL+"/api/v1/auth/register", authRegisterRequest{
Username: "realuser",
Password: "correctpassword",
})
postJSONAuthStatus(t, server.Client(), "", server.URL+"/api/v1/auth/login", authLoginRequest{
Username: "realuser",
Password: "wrongpassword",
}, http.StatusUnauthorized)
postJSONAuthStatus(t, server.Client(), "", server.URL+"/api/v1/auth/login", authLoginRequest{
Username: "nosuchuser",
Password: "anything",
}, http.StatusUnauthorized)
}
func TestAuthRegistrationDisabled(t *testing.T) {
t.Parallel()
_, server := newTestSQLiteApp(t, appConfig{
version: "test-version",
registrationEnabled: false,
})
defer server.Close()
postJSONAuthStatus(t, server.Client(), "", server.URL+"/api/v1/auth/register", authRegisterRequest{
Username: "blocked",
Password: "password123",
}, http.StatusForbidden)
}
func TestAuthValidation(t *testing.T) {
t.Parallel()
_, server := newTestSQLiteApp(t, appConfig{
version: "test-version",
registrationEnabled: true,
})
defer server.Close()
// Username too short.
postJSONAuthStatus(t, server.Client(), "", server.URL+"/api/v1/auth/register", authRegisterRequest{
Username: "ab",
Password: "password123",
}, http.StatusBadRequest)
// Password too short.
postJSONAuthStatus(t, server.Client(), "", server.URL+"/api/v1/auth/register", authRegisterRequest{
Username: "validuser",
Password: "short",
}, http.StatusBadRequest)
}
func TestAuthSessionUsedForClientEndpoints(t *testing.T) {
t.Parallel()
_, server := newTestSQLiteApp(t, appConfig{
version: "test-version",
registrationEnabled: true,
})
defer server.Close()
// Without auth, exports should fail.
getStatusWithAuth(t, server.Client(), "", server.URL+"/api/v1/exports", http.StatusUnauthorized)
// Register and get session.
reg := postJSONAuthCreated[authLoginResponse](t, server.Client(), "", server.URL+"/api/v1/auth/register", authRegisterRequest{
Username: "admin",
Password: "password123",
})
// Session should work for client endpoints.
exports := getJSONAuth[[]storageExport](t, server.Client(), reg.Token, server.URL+"/api/v1/exports")
if exports == nil {
t.Fatal("expected exports list, got nil")
}
}
func TestAuthStaticTokenFallback(t *testing.T) {
t.Parallel()
_, server := newTestSQLiteApp(t, appConfig{
version: "test-version",
clientToken: "static-fallback-token",
})
defer server.Close()
// Static token should work for client endpoints.
exports := getJSONAuth[[]storageExport](t, server.Client(), "static-fallback-token", server.URL+"/api/v1/exports")
if exports == nil {
t.Fatal("expected exports list, got nil")
}
// Wrong token should fail.
getStatusWithAuth(t, server.Client(), "wrong", server.URL+"/api/v1/exports", http.StatusUnauthorized)
}

View file

@ -3,6 +3,7 @@ package main
import ( import (
"log" "log"
"net/http" "net/http"
"strings"
"time" "time"
) )
@ -24,11 +25,6 @@ func main() {
} }
func newAppFromEnv(startedAt time.Time) (*app, error) { func newAppFromEnv(startedAt time.Time) (*app, error) {
clientToken, err := requiredEnv("BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN")
if err != nil {
return nil, err
}
nodeBootstrapToken, err := requiredEnv("BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN") nodeBootstrapToken, err := requiredEnv("BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN")
if err != nil { if err != nil {
return nil, err return nil, err
@ -44,15 +40,28 @@ func newAppFromEnv(startedAt time.Time) (*app, error) {
return nil, err return nil, err
} }
var sessionTTL time.Duration
rawSessionTTL := strings.TrimSpace(env("BETTERNAS_SESSION_TTL", "720h"))
if rawSessionTTL != "" {
sessionTTL, err = time.ParseDuration(rawSessionTTL)
if err != nil {
return nil, err
}
}
return newApp( return newApp(
appConfig{ appConfig{
version: env("BETTERNAS_VERSION", "0.1.0-dev"), version: env("BETTERNAS_VERSION", "0.1.0-dev"),
nextcloudBaseURL: env("NEXTCLOUD_BASE_URL", ""), nextcloudBaseURL: env("NEXTCLOUD_BASE_URL", ""),
statePath: env("BETTERNAS_CONTROL_PLANE_STATE_PATH", ".state/control-plane/state.json"), statePath: env("BETTERNAS_CONTROL_PLANE_STATE_PATH", ".state/control-plane/state.json"),
clientToken: clientToken, dbPath: env("BETTERNAS_CONTROL_PLANE_DB_PATH", ""),
nodeBootstrapToken: nodeBootstrapToken, clientToken: env("BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN", ""),
davAuthSecret: davAuthSecret, nodeBootstrapToken: nodeBootstrapToken,
davCredentialTTL: davCredentialTTL, davAuthSecret: davAuthSecret,
davCredentialTTL: davCredentialTTL,
sessionTTL: sessionTTL,
registrationEnabled: env("BETTERNAS_REGISTRATION_ENABLED", "true") == "true",
corsOrigin: env("BETTERNAS_CORS_ORIGIN", ""),
}, },
startedAt, startedAt,
) )

View file

@ -33,6 +33,10 @@ func (a *app) handler() http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /health", a.handleHealth) mux.HandleFunc("GET /health", a.handleHealth)
mux.HandleFunc("GET /version", a.handleVersion) mux.HandleFunc("GET /version", a.handleVersion)
mux.HandleFunc("POST /api/v1/auth/register", a.handleAuthRegister)
mux.HandleFunc("POST /api/v1/auth/login", a.handleAuthLogin)
mux.HandleFunc("POST /api/v1/auth/logout", a.handleAuthLogout)
mux.HandleFunc("GET /api/v1/auth/me", a.handleAuthMe)
mux.HandleFunc("POST /api/v1/nodes/register", a.handleNodeRegister) mux.HandleFunc("POST /api/v1/nodes/register", a.handleNodeRegister)
mux.HandleFunc("POST /api/v1/nodes/{nodeId}/heartbeat", a.handleNodeHeartbeat) mux.HandleFunc("POST /api/v1/nodes/{nodeId}/heartbeat", a.handleNodeHeartbeat)
mux.HandleFunc("PUT /api/v1/nodes/{nodeId}/exports", a.handleNodeExports) mux.HandleFunc("PUT /api/v1/nodes/{nodeId}/exports", a.handleNodeExports)
@ -40,7 +44,12 @@ func (a *app) handler() http.Handler {
mux.HandleFunc("POST /api/v1/mount-profiles/issue", a.handleMountProfileIssue) mux.HandleFunc("POST /api/v1/mount-profiles/issue", a.handleMountProfileIssue)
mux.HandleFunc("POST /api/v1/cloud-profiles/issue", a.handleCloudProfileIssue) mux.HandleFunc("POST /api/v1/cloud-profiles/issue", a.handleCloudProfileIssue)
return mux var handler http.Handler = mux
if a.config.corsOrigin != "" {
handler = corsMiddleware(a.config.corsOrigin, handler)
}
return handler
} }
func (a *app) handleHealth(w http.ResponseWriter, _ *http.Request) { func (a *app) handleHealth(w http.ResponseWriter, _ *http.Request) {
@ -891,14 +900,161 @@ func writeJSON(w http.ResponseWriter, statusCode int, payload any) {
} }
} }
// --- auth handlers ---
type authRegisterRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type authLoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type authLoginResponse struct {
Token string `json:"token"`
User user `json:"user"`
}
func (a *app) handleAuthRegister(w http.ResponseWriter, r *http.Request) {
if !a.config.registrationEnabled {
http.Error(w, "registration is disabled", http.StatusForbidden)
return
}
var request authRegisterRequest
if err := decodeJSON(w, r, &request); err != nil {
writeDecodeError(w, err)
return
}
username := strings.TrimSpace(request.Username)
if len(username) < 3 || len(username) > 64 {
http.Error(w, "username must be between 3 and 64 characters", http.StatusBadRequest)
return
}
if len(request.Password) < 8 {
http.Error(w, "password must be at least 8 characters", http.StatusBadRequest)
return
}
u, err := a.store.createUser(username, request.Password)
if err != nil {
if errors.Is(err, errUsernameTaken) {
http.Error(w, err.Error(), http.StatusConflict)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
sessionTTL := a.config.sessionTTL
if sessionTTL <= 0 {
sessionTTL = 720 * time.Hour
}
token, err := a.store.createSession(u.ID, sessionTTL)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusCreated, authLoginResponse{Token: token, User: u})
}
func (a *app) handleAuthLogin(w http.ResponseWriter, r *http.Request) {
var request authLoginRequest
if err := decodeJSON(w, r, &request); err != nil {
writeDecodeError(w, err)
return
}
u, err := a.store.authenticateUser(strings.TrimSpace(request.Username), request.Password)
if err != nil {
http.Error(w, "invalid username or password", http.StatusUnauthorized)
return
}
sessionTTL := a.config.sessionTTL
if sessionTTL <= 0 {
sessionTTL = 720 * time.Hour
}
token, err := a.store.createSession(u.ID, sessionTTL)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, authLoginResponse{Token: token, User: u})
}
func (a *app) handleAuthLogout(w http.ResponseWriter, r *http.Request) {
token, ok := bearerToken(r)
if !ok {
w.WriteHeader(http.StatusNoContent)
return
}
_ = a.store.deleteSession(token)
w.WriteHeader(http.StatusNoContent)
}
func (a *app) handleAuthMe(w http.ResponseWriter, r *http.Request) {
token, ok := bearerToken(r)
if !ok {
writeUnauthorized(w)
return
}
u, err := a.store.validateSession(token)
if err != nil {
writeUnauthorized(w)
return
}
writeJSON(w, http.StatusOK, u)
}
// --- CORS ---
func corsMiddleware(allowedOrigin string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", allowedOrigin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Max-Age", "86400")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
// --- client auth ---
func (a *app) requireClientAuth(w http.ResponseWriter, r *http.Request) bool { func (a *app) requireClientAuth(w http.ResponseWriter, r *http.Request) bool {
presentedToken, ok := bearerToken(r) presentedToken, ok := bearerToken(r)
if !ok || !secureStringEquals(a.config.clientToken, presentedToken) { if !ok {
writeUnauthorized(w) writeUnauthorized(w)
return false return false
} }
return true // 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 { func (a *app) authorizeNodeRegistration(w http.ResponseWriter, r *http.Request, machineID string) bool {

View file

@ -0,0 +1,598 @@
package main
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
_ "modernc.org/sqlite"
)
var (
errUsernameTaken = errors.New("username already taken")
errInvalidLogin = errors.New("invalid username or password")
errSessionExpired = errors.New("session expired or invalid")
)
const sqliteSchema = `
CREATE TABLE IF NOT EXISTS ordinals (
name TEXT PRIMARY KEY,
value INTEGER NOT NULL DEFAULT 0
);
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,
display_name TEXT NOT NULL DEFAULT '',
agent_version TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'online',
last_seen_at TEXT,
direct_address TEXT,
relay_address TEXT
);
CREATE TABLE IF NOT EXISTS node_tokens (
node_id TEXT PRIMARY KEY REFERENCES nodes(id),
token_hash TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS exports (
id TEXT PRIMARY KEY,
node_id TEXT NOT NULL REFERENCES nodes(id),
label TEXT NOT NULL DEFAULT '',
path TEXT NOT NULL,
mount_path TEXT NOT NULL DEFAULT '',
capacity_bytes INTEGER,
UNIQUE(node_id, path)
);
CREATE TABLE IF NOT EXISTS export_protocols (
export_id TEXT NOT NULL REFERENCES exports(id) ON DELETE CASCADE,
protocol TEXT NOT NULL,
PRIMARY KEY (export_id, protocol)
);
CREATE TABLE IF NOT EXISTS export_tags (
export_id TEXT NOT NULL REFERENCES exports(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
PRIMARY KEY (export_id, tag)
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
expires_at TEXT NOT NULL
);
`
type sqliteStore struct {
db *sql.DB
}
func newSQLiteStore(dbPath string) (*sqliteStore, error) {
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0o750); err != nil {
return nil, fmt.Errorf("create database directory %s: %w", dir, err)
}
db, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(wal)&_pragma=foreign_keys(1)")
if err != nil {
return nil, fmt.Errorf("open database %s: %w", dbPath, err)
}
if _, err := db.Exec(sqliteSchema); err != nil {
db.Close()
return nil, fmt.Errorf("initialize database schema: %w", err)
}
return &sqliteStore{db: db}, 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)
if err != nil {
return 0, fmt.Errorf("next ordinal %q: %w", name, err)
}
return value, nil
}
func ordinalToNodeID(ordinal int) string {
if ordinal == 1 {
return "dev-node"
}
return fmt.Sprintf("dev-node-%d", ordinal)
}
func ordinalToExportID(ordinal int) string {
if ordinal == 1 {
return "dev-export"
}
return fmt.Sprintf("dev-export-%d", ordinal)
}
func (s *sqliteStore) registerNode(request nodeRegistrationRequest, registeredAt time.Time) (nodeRegistrationResult, error) {
tx, err := s.db.Begin()
if err != nil {
return nodeRegistrationResult{}, fmt.Errorf("begin transaction: %w", err)
}
defer tx.Rollback()
// Check if machine already registered.
var nodeID string
err = tx.QueryRow("SELECT id FROM nodes WHERE machine_id = ?", request.MachineID).Scan(&nodeID)
if err == sql.ErrNoRows {
ordinal, err := s.nextOrdinal(tx, "node")
if err != nil {
return nodeRegistrationResult{}, err
}
nodeID = ordinalToNodeID(ordinal)
} else if err != nil {
return nodeRegistrationResult{}, fmt.Errorf("lookup node by machine_id: %w", err)
}
// 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', ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
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,
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,
}, nil
}
func (s *sqliteStore) upsertExports(nodeID string, request nodeExportsRequest) ([]storageExport, error) {
tx, err := s.db.Begin()
if err != nil {
return nil, fmt.Errorf("begin transaction: %w", err)
}
defer tx.Rollback()
// Verify node exists.
var exists bool
err = tx.QueryRow("SELECT 1 FROM nodes WHERE id = ?", nodeID).Scan(&exists)
if err != nil {
return nil, errNodeNotFound
}
// Collect current export IDs for this node (by path).
currentExports := make(map[string]string) // path -> exportID
rows, err := tx.Query("SELECT id, path FROM exports WHERE node_id = ?", nodeID)
if err != nil {
return nil, fmt.Errorf("query current exports: %w", err)
}
for rows.Next() {
var id, path string
if err := rows.Scan(&id, &path); err != nil {
rows.Close()
return nil, fmt.Errorf("scan current export: %w", err)
}
currentExports[path] = id
}
rows.Close()
keepPaths := make(map[string]struct{}, len(request.Exports))
for _, input := range request.Exports {
exportID, exists := currentExports[input.Path]
if !exists {
ordinal, err := s.nextOrdinal(tx, "export")
if err != nil {
return nil, err
}
exportID = ordinalToExportID(ordinal)
}
_, err = tx.Exec(`
INSERT INTO exports (id, node_id, label, path, mount_path, capacity_bytes)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
label = excluded.label,
mount_path = excluded.mount_path,
capacity_bytes = excluded.capacity_bytes
`, exportID, nodeID, input.Label, input.Path, input.MountPath, nullableInt64(input.CapacityBytes))
if err != nil {
return nil, fmt.Errorf("upsert export %q: %w", input.Path, err)
}
// Replace protocols.
if _, err := tx.Exec("DELETE FROM export_protocols WHERE export_id = ?", exportID); err != nil {
return nil, fmt.Errorf("clear export protocols: %w", err)
}
for _, protocol := range input.Protocols {
if _, err := tx.Exec("INSERT INTO export_protocols (export_id, protocol) VALUES (?, ?)", exportID, protocol); err != nil {
return nil, fmt.Errorf("insert export protocol: %w", err)
}
}
// Replace tags.
if _, err := tx.Exec("DELETE FROM export_tags WHERE export_id = ?", exportID); err != nil {
return nil, fmt.Errorf("clear export tags: %w", err)
}
for _, tag := range input.Tags {
if _, err := tx.Exec("INSERT INTO export_tags (export_id, tag) VALUES (?, ?)", exportID, tag); err != nil {
return nil, fmt.Errorf("insert export tag: %w", err)
}
}
keepPaths[input.Path] = struct{}{}
}
// Remove exports not in the input.
for path, exportID := range currentExports {
if _, keep := keepPaths[path]; !keep {
if _, err := tx.Exec("DELETE FROM exports WHERE id = ?", exportID); err != nil {
return nil, fmt.Errorf("delete stale export %q: %w", exportID, err)
}
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit exports: %w", err)
}
return s.listExportsForNode(nodeID), nil
}
func (s *sqliteStore) recordHeartbeat(nodeID string, request nodeHeartbeatRequest) error {
result, err := s.db.Exec(
"UPDATE nodes SET status = ?, last_seen_at = ? WHERE id = ?",
request.Status, request.LastSeenAt, nodeID)
if err != nil {
return fmt.Errorf("update heartbeat: %w", err)
}
affected, _ := result.RowsAffected()
if affected == 0 {
return errNodeNotFound
}
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")
if err != nil {
return nil
}
defer rows.Close()
var exports []storageExport
for rows.Next() {
e := s.scanExport(rows)
if e.ID != "" {
exports = append(exports, e)
}
}
if exports == nil {
exports = []storageExport{}
}
// Load protocols and tags for each export.
for i := range exports {
exports[i].Protocols = s.loadExportProtocols(exports[i].ID)
exports[i].Tags = s.loadExportTags(exports[i].ID)
}
return exports
}
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)
if err != nil {
return nil
}
defer rows.Close()
var exports []storageExport
for rows.Next() {
e := s.scanExport(rows)
if e.ID != "" {
exports = append(exports, e)
}
}
if exports == nil {
exports = []storageExport{}
}
for i := range exports {
exports[i].Protocols = s.loadExportProtocols(exports[i].ID)
exports[i].Tags = s.loadExportTags(exports[i].ID)
}
sort.Slice(exports, func(i, j int) bool { return exports[i].ID < exports[j].ID })
return exports
}
func (s *sqliteStore) exportContext(exportID string) (exportContext, bool) {
var e storageExport
var capacityBytes sql.NullInt64
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)
if err != nil {
return exportContext{}, false
}
if capacityBytes.Valid {
e.CapacityBytes = &capacityBytes.Int64
}
e.Protocols = s.loadExportProtocols(e.ID)
e.Tags = s.loadExportTags(e.ID)
node, ok := s.nodeByID(e.NasNodeID)
if !ok {
return exportContext{}, false
}
return exportContext{export: e, node: node}, true
}
func (s *sqliteStore) nodeByID(nodeID string) (nasNode, bool) {
var n nasNode
var directAddr, relayAddr sql.NullString
var lastSeenAt 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)
if err != nil {
return nasNode{}, false
}
if lastSeenAt.Valid {
n.LastSeenAt = lastSeenAt.String
}
if directAddr.Valid {
n.DirectAddress = &directAddr.String
}
if relayAddr.Valid {
n.RelayAddress = &relayAddr.String
}
return n, true
}
func (s *sqliteStore) nodeAuthByMachineID(machineID string) (nodeAuthState, bool) {
var state nodeAuthState
var tokenHash sql.NullString
err := s.db.QueryRow(`
SELECT n.id, nt.token_hash
FROM nodes n
LEFT JOIN node_tokens nt ON nt.node_id = n.id
WHERE n.machine_id = ?
`, machineID).Scan(&state.NodeID, &tokenHash)
if err != nil {
return nodeAuthState{}, false
}
if tokenHash.Valid {
state.TokenHash = tokenHash.String
}
return state, true
}
func (s *sqliteStore) nodeAuthByID(nodeID string) (nodeAuthState, bool) {
var state nodeAuthState
var tokenHash sql.NullString
err := s.db.QueryRow(`
SELECT n.id, nt.token_hash
FROM nodes n
LEFT JOIN node_tokens nt ON nt.node_id = n.id
WHERE n.id = ?
`, nodeID).Scan(&state.NodeID, &tokenHash)
if err != nil {
return nodeAuthState{}, false
}
if tokenHash.Valid {
state.TokenHash = tokenHash.String
}
return state, true
}
// --- helpers ---
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 {
return storageExport{}
}
if capacityBytes.Valid {
e.CapacityBytes = &capacityBytes.Int64
}
return e
}
func (s *sqliteStore) loadExportProtocols(exportID string) []string {
rows, err := s.db.Query("SELECT protocol FROM export_protocols WHERE export_id = ? ORDER BY protocol", exportID)
if err != nil {
return []string{}
}
defer rows.Close()
var protocols []string
for rows.Next() {
var p string
if err := rows.Scan(&p); err == nil {
protocols = append(protocols, p)
}
}
if protocols == nil {
return []string{}
}
return protocols
}
func (s *sqliteStore) loadExportTags(exportID string) []string {
rows, err := s.db.Query("SELECT tag FROM export_tags WHERE export_id = ? ORDER BY tag", exportID)
if err != nil {
return []string{}
}
defer rows.Close()
var tags []string
for rows.Next() {
var t string
if err := rows.Scan(&t); err == nil {
tags = append(tags, t)
}
}
if tags == nil {
return []string{}
}
return tags
}
func nullableString(p *string) sql.NullString {
if p == nil {
return sql.NullString{}
}
return sql.NullString{String: *p, Valid: true}
}
func nullableInt64(p *int64) sql.NullInt64 {
if p == nil {
return sql.NullInt64{}
}
return sql.NullInt64{Int64: *p, Valid: true}
}
// --- user auth ---
func (s *sqliteStore) createUser(username string, password string) (user, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return user{}, fmt.Errorf("hash password: %w", err)
}
id, err := newSessionToken()
if err != nil {
return user{}, err
}
var u user
err = s.db.QueryRow(`
INSERT INTO users (id, username, password_hash) VALUES (?, ?, ?)
RETURNING id, username, created_at
`, id, username, string(hash)).Scan(&u.ID, &u.Username, &u.CreatedAt)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint") {
return user{}, errUsernameTaken
}
return user{}, fmt.Errorf("create user: %w", err)
}
return u, nil
}
func (s *sqliteStore) authenticateUser(username string, password string) (user, error) {
var u user
var passwordHash string
err := s.db.QueryRow(
"SELECT id, username, password_hash, created_at FROM users WHERE username = ?",
username).Scan(&u.ID, &u.Username, &passwordHash, &u.CreatedAt)
if err != nil {
return user{}, errInvalidLogin
}
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password)); err != nil {
return user{}, errInvalidLogin
}
return u, nil
}
func (s *sqliteStore) createSession(userID string, ttl time.Duration) (string, error) {
token, err := newSessionToken()
if err != nil {
return "", err
}
expiresAt := time.Now().UTC().Add(ttl).Format(time.RFC3339)
_, err = s.db.Exec(
"INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)",
token, userID, expiresAt)
if err != nil {
return "", fmt.Errorf("create session: %w", err)
}
// Clean up expired sessions opportunistically.
_, _ = s.db.Exec("DELETE FROM sessions WHERE expires_at < ?", time.Now().UTC().Format(time.RFC3339))
return token, nil
}
func (s *sqliteStore) validateSession(token string) (user, error) {
var u user
err := s.db.QueryRow(`
SELECT u.id, u.username, u.created_at
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token = ? AND s.expires_at > ?
`, token, time.Now().UTC().Format(time.RFC3339)).Scan(&u.ID, &u.Username, &u.CreatedAt)
if err != nil {
return user{}, errSessionExpired
}
return u, nil
}
func (s *sqliteStore) deleteSession(token string) error {
_, err := s.db.Exec("DELETE FROM sessions WHERE token = ?", token)
return err
}
func newSessionToken() (string, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("generate session token: %w", err)
}
return hex.EncodeToString(raw), nil
}

View file

@ -0,0 +1,297 @@
package main
import (
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
)
func newTestSQLiteApp(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 {
t.Fatalf("new app: %v", err)
}
app.now = func() time.Time { return testControlPlaneNow }
server := httptest.NewServer(app.handler())
return app, server
}
func TestSQLiteHealthAndVersion(t *testing.T) {
t.Parallel()
_, server := newTestSQLiteApp(t, appConfig{
version: "test-version",
nextcloudBaseURL: "http://nextcloud.test",
})
defer server.Close()
health := getJSON[controlPlaneHealthResponse](t, server.Client(), server.URL+"/health")
if health.Status != "ok" {
t.Fatalf("expected status ok, got %q", health.Status)
}
exports := getJSONAuth[[]storageExport](t, server.Client(), testClientToken, server.URL+"/api/v1/exports")
if len(exports) != 0 {
t.Fatalf("expected no exports, got %d", len(exports))
}
}
func TestSQLiteRegistrationAndExports(t *testing.T) {
t.Parallel()
_, server := newTestSQLiteApp(t, appConfig{
version: "test-version",
nextcloudBaseURL: "http://nextcloud.test",
})
defer server.Close()
directAddress := "http://nas.local:8090"
registration := registerNode(t, server.Client(), server.URL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{
MachineID: "machine-1",
DisplayName: "Primary NAS",
AgentVersion: "1.2.3",
DirectAddress: &directAddress,
RelayAddress: nil,
})
if registration.NodeToken == "" {
t.Fatal("expected node registration to return a node token")
}
if registration.Node.ID != "dev-node" {
t.Fatalf("expected node ID %q, got %q", "dev-node", registration.Node.ID)
}
syncedExports := syncNodeExports(t, server.Client(), registration.NodeToken, server.URL+"/api/v1/nodes/"+registration.Node.ID+"/exports", nodeExportsRequest{
Exports: []storageExportInput{
{
Label: "Docs",
Path: "/srv/docs",
MountPath: "/dav/docs/",
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{"work"},
},
},
})
if len(syncedExports) != 1 {
t.Fatalf("expected 1 export, got %d", len(syncedExports))
}
if syncedExports[0].ID != "dev-export" {
t.Fatalf("expected export ID %q, got %q", "dev-export", syncedExports[0].ID)
}
if syncedExports[0].Label != "Docs" {
t.Fatalf("expected label %q, got %q", "Docs", syncedExports[0].Label)
}
allExports := getJSONAuth[[]storageExport](t, server.Client(), testClientToken, server.URL+"/api/v1/exports")
if len(allExports) != 1 {
t.Fatalf("expected 1 export in list, got %d", len(allExports))
}
mount := postJSONAuth[mountProfile](t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: "dev-export"})
if mount.MountURL != "http://nas.local:8090/dav/docs/" {
t.Fatalf("expected mount URL %q, got %q", "http://nas.local:8090/dav/docs/", mount.MountURL)
}
}
func TestSQLiteReRegistrationKeepsNodeID(t *testing.T) {
t.Parallel()
_, server := newTestSQLiteApp(t, appConfig{version: "test-version"})
defer server.Close()
directAddress := "http://nas.local:8090"
first := registerNode(t, server.Client(), server.URL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{
MachineID: "machine-1",
DisplayName: "NAS",
AgentVersion: "1.0.0",
DirectAddress: &directAddress,
})
second := registerNode(t, server.Client(), server.URL+"/api/v1/nodes/register", first.NodeToken, nodeRegistrationRequest{
MachineID: "machine-1",
DisplayName: "NAS Updated",
AgentVersion: "1.0.1",
DirectAddress: &directAddress,
})
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.Node.DisplayName != "NAS Updated" {
t.Fatalf("expected updated display name, got %q", second.Node.DisplayName)
}
}
func TestSQLiteExportSyncRemovesStaleExports(t *testing.T) {
t.Parallel()
_, server := newTestSQLiteApp(t, appConfig{version: "test-version"})
defer server.Close()
directAddress := "http://nas.local:8090"
reg := registerNode(t, server.Client(), server.URL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{
MachineID: "machine-stale",
DisplayName: "NAS",
AgentVersion: "1.0.0",
DirectAddress: &directAddress,
})
syncNodeExports(t, server.Client(), reg.NodeToken, server.URL+"/api/v1/nodes/"+reg.Node.ID+"/exports", nodeExportsRequest{
Exports: []storageExportInput{
{Label: "A", Path: "/a", MountPath: "/dav/a/", Protocols: []string{"webdav"}, Tags: []string{}},
{Label: "B", Path: "/b", MountPath: "/dav/b/", Protocols: []string{"webdav"}, Tags: []string{}},
},
})
exports := getJSONAuth[[]storageExport](t, server.Client(), testClientToken, server.URL+"/api/v1/exports")
if len(exports) != 2 {
t.Fatalf("expected 2 exports, got %d", len(exports))
}
// Sync with only A - B should be removed.
syncNodeExports(t, server.Client(), reg.NodeToken, server.URL+"/api/v1/nodes/"+reg.Node.ID+"/exports", nodeExportsRequest{
Exports: []storageExportInput{
{Label: "A Updated", Path: "/a", MountPath: "/dav/a/", Protocols: []string{"webdav"}, Tags: []string{}},
},
})
exports = getJSONAuth[[]storageExport](t, server.Client(), testClientToken, server.URL+"/api/v1/exports")
if len(exports) != 1 {
t.Fatalf("expected 1 export after stale removal, got %d", len(exports))
}
if exports[0].Label != "A Updated" {
t.Fatalf("expected updated label, got %q", exports[0].Label)
}
}
func TestSQLiteHeartbeat(t *testing.T) {
t.Parallel()
app, server := newTestSQLiteApp(t, appConfig{version: "test-version"})
defer server.Close()
_ = app
directAddress := "http://nas.local:8090"
reg := registerNode(t, server.Client(), server.URL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{
MachineID: "machine-hb",
DisplayName: "NAS",
AgentVersion: "1.0.0",
DirectAddress: &directAddress,
})
postJSONAuthStatus(t, server.Client(), reg.NodeToken, server.URL+"/api/v1/nodes/"+reg.Node.ID+"/heartbeat", nodeHeartbeatRequest{
NodeID: reg.Node.ID,
Status: "online",
LastSeenAt: "2025-06-01T12:00:00Z",
}, http.StatusNoContent)
node, ok := app.store.nodeByID(reg.Node.ID)
if !ok {
t.Fatal("expected node to exist after heartbeat")
}
if node.LastSeenAt != "2025-06-01T12:00:00Z" {
t.Fatalf("expected updated lastSeenAt, got %q", node.LastSeenAt)
}
}
func TestSQLitePersistsAcrossRestart(t *testing.T) {
t.Parallel()
dbPath := filepath.Join(t.TempDir(), "persist.db")
directAddress := "http://nas.local:8090"
_, firstServer := newTestSQLiteApp(t, appConfig{
version: "test-version",
dbPath: dbPath,
})
registration := registerNode(t, firstServer.Client(), firstServer.URL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{
MachineID: "machine-persist",
DisplayName: "Persisted NAS",
AgentVersion: "1.2.3",
DirectAddress: &directAddress,
})
syncNodeExports(t, firstServer.Client(), registration.NodeToken, firstServer.URL+"/api/v1/nodes/"+registration.Node.ID+"/exports", nodeExportsRequest{
Exports: []storageExportInput{{
Label: "Docs",
Path: "/srv/docs",
MountPath: "/dav/persisted/",
Protocols: []string{"webdav"},
Tags: []string{"work"},
}},
})
firstServer.Close()
// Restart with same DB path.
_, secondServer := newTestSQLiteApp(t, appConfig{
version: "test-version",
dbPath: dbPath,
})
defer secondServer.Close()
exports := getJSONAuth[[]storageExport](t, secondServer.Client(), testClientToken, secondServer.URL+"/api/v1/exports")
if len(exports) != 1 {
t.Fatalf("expected persisted export after restart, got %d", len(exports))
}
if exports[0].ID != "dev-export" {
t.Fatalf("expected persisted export ID %q, got %q", "dev-export", exports[0].ID)
}
if exports[0].MountPath != "/dav/persisted/" {
t.Fatalf("expected persisted mountPath %q, got %q", "/dav/persisted/", exports[0].MountPath)
}
if len(exports[0].Tags) != 1 || exports[0].Tags[0] != "work" {
t.Fatalf("expected persisted tags [work], got %v", exports[0].Tags)
}
// Re-register with the original node token.
reReg := registerNode(t, secondServer.Client(), secondServer.URL+"/api/v1/nodes/register", registration.NodeToken, nodeRegistrationRequest{
MachineID: "machine-persist",
DisplayName: "Persisted NAS Updated",
AgentVersion: "1.2.4",
DirectAddress: &directAddress,
})
if reReg.Node.ID != registration.Node.ID {
t.Fatalf("expected persisted node ID %q, got %q", registration.Node.ID, reReg.Node.ID)
}
}
func TestSQLiteAuthEnforcement(t *testing.T) {
t.Parallel()
_, server := newTestSQLiteApp(t, appConfig{version: "test-version"})
defer server.Close()
getStatusWithAuth(t, server.Client(), "", server.URL+"/api/v1/exports", http.StatusUnauthorized)
getStatusWithAuth(t, server.Client(), "wrong-token", server.URL+"/api/v1/exports", http.StatusUnauthorized)
postJSONAuthStatus(t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{
ExportID: "missing-export",
}, http.StatusNotFound)
}

View file

@ -5,6 +5,7 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@ -483,6 +484,30 @@ func copyStorageExport(export storageExport) storageExport {
} }
} }
// --- user auth stubs (memory store does not support user auth) ---
var errAuthNotSupported = errors.New("user auth requires SQLite database (set BETTERNAS_CONTROL_PLANE_DB_PATH)")
func (s *memoryStore) createUser(_ string, _ string) (user, error) {
return user{}, errAuthNotSupported
}
func (s *memoryStore) authenticateUser(_ string, _ string) (user, error) {
return user{}, errAuthNotSupported
}
func (s *memoryStore) createSession(_ string, _ time.Duration) (string, error) {
return "", errAuthNotSupported
}
func (s *memoryStore) validateSession(_ string) (user, error) {
return user{}, errAuthNotSupported
}
func (s *memoryStore) deleteSession(_ string) error {
return errAuthNotSupported
}
func newOpaqueToken() (string, error) { func newOpaqueToken() (string, error) {
raw := make([]byte, 32) raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil { if _, err := rand.Read(raw); err != nil {

View file

@ -0,0 +1,23 @@
package main
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)
nodeByID(nodeID string) (nasNode, bool)
nodeAuthByMachineID(machineID string) (nodeAuthState, bool)
nodeAuthByID(nodeID string) (nodeAuthState, bool)
// User auth
createUser(username string, password string) (user, error)
authenticateUser(username string, password string) (user, error)
createSession(userID string, ttl time.Duration) (string, error)
validateSession(token string) (user, error)
deleteSession(token string) error
}

View file

@ -1,3 +1,40 @@
module github.com/rathi/betternas/apps/control-plane module github.com/rathi/betternas/apps/control-plane
go 1.26.0 go 1.26.0
require (
github.com/apache/arrow-go/v18 v18.4.1 // indirect
github.com/duckdb/duckdb-go-bindings v0.1.21 // indirect
github.com/duckdb/duckdb-go-bindings/darwin-amd64 v0.1.21 // indirect
github.com/duckdb/duckdb-go-bindings/darwin-arm64 v0.1.21 // indirect
github.com/duckdb/duckdb-go-bindings/linux-amd64 v0.1.21 // indirect
github.com/duckdb/duckdb-go-bindings/linux-arm64 v0.1.21 // indirect
github.com/duckdb/duckdb-go-bindings/windows-amd64 v0.1.21 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/flatbuffers v25.2.10+incompatible // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/marcboeker/go-duckdb/arrowmapping v0.0.21 // indirect
github.com/marcboeker/go-duckdb/mapping v0.0.21 // indirect
github.com/marcboeker/go-duckdb/v2 v2.4.3 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 // indirect
golang.org/x/tools v0.42.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.48.0 // indirect
)

69
apps/control-plane/go.sum Normal file
View file

@ -0,0 +1,69 @@
github.com/apache/arrow-go/v18 v18.4.1 h1:q/jVkBWCJOB9reDgaIZIdruLQUb1kbkvOnOFezVH1C4=
github.com/apache/arrow-go/v18 v18.4.1/go.mod h1:tLyFubsAl17bvFdUAy24bsSvA/6ww95Iqi67fTpGu3E=
github.com/duckdb/duckdb-go-bindings v0.1.21 h1:bOb/MXNT4PN5JBZ7wpNg6hrj9+cuDjWDa4ee9UdbVyI=
github.com/duckdb/duckdb-go-bindings v0.1.21/go.mod h1:pBnfviMzANT/9hi4bg+zW4ykRZZPCXlVuvBWEcZofkc=
github.com/duckdb/duckdb-go-bindings/darwin-amd64 v0.1.21 h1:Sjjhf2F/zCjPF53c2VXOSKk0PzieMriSoyr5wfvr9d8=
github.com/duckdb/duckdb-go-bindings/darwin-amd64 v0.1.21/go.mod h1:Ezo7IbAfB8NP7CqPIN8XEHKUg5xdRRQhcPPlCXImXYA=
github.com/duckdb/duckdb-go-bindings/darwin-arm64 v0.1.21 h1:IUk0FFUB6dpWLhlN9hY1mmdPX7Hkn3QpyrAmn8pmS8g=
github.com/duckdb/duckdb-go-bindings/darwin-arm64 v0.1.21/go.mod h1:eS7m/mLnPQgVF4za1+xTyorKRBuK0/BA44Oy6DgrGXI=
github.com/duckdb/duckdb-go-bindings/linux-amd64 v0.1.21 h1:Qpc7ZE3n6Nwz30KTvaAwI6nGkXjXmMxBTdFpC8zDEYI=
github.com/duckdb/duckdb-go-bindings/linux-amd64 v0.1.21/go.mod h1:1GOuk1PixiESxLaCGFhag+oFi7aP+9W8byymRAvunBk=
github.com/duckdb/duckdb-go-bindings/linux-arm64 v0.1.21 h1:eX2DhobAZOgjXkh8lPnKAyrxj8gXd2nm+K71f6KV/mo=
github.com/duckdb/duckdb-go-bindings/linux-arm64 v0.1.21/go.mod h1:o7crKMpT2eOIi5/FY6HPqaXcvieeLSqdXXaXbruGX7w=
github.com/duckdb/duckdb-go-bindings/windows-amd64 v0.1.21 h1:hhziFnGV7mpA+v5J5G2JnYQ+UWCCP3NQ+OTvxFX10D8=
github.com/duckdb/duckdb-go-bindings/windows-amd64 v0.1.21/go.mod h1:IlOhJdVKUJCAPj3QsDszUo8DVdvp1nBFp4TUJVdw99s=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q=
github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/marcboeker/go-duckdb/arrowmapping v0.0.21 h1:geHnVjlsAJGczSWEqYigy/7ARuD+eBtjd0kLN80SPJQ=
github.com/marcboeker/go-duckdb/arrowmapping v0.0.21/go.mod h1:flFTc9MSqQCh2Xm62RYvG3Kyj29h7OtsTb6zUx1CdK8=
github.com/marcboeker/go-duckdb/mapping v0.0.21 h1:6woNXZn8EfYdc9Vbv0qR6acnt0TM1s1eFqnrJZVrqEs=
github.com/marcboeker/go-duckdb/mapping v0.0.21/go.mod h1:q3smhpLyv2yfgkQd7gGHMd+H/Z905y+WYIUjrl29vT4=
github.com/marcboeker/go-duckdb/v2 v2.4.3 h1:bHUkphPsAp2Bh/VFEdiprGpUekxBNZiWWtK+Bv/ljRk=
github.com/marcboeker/go-duckdb/v2 v2.4.3/go.mod h1:taim9Hktg2igHdNBmg5vgTfHAlV26z3gBI0QXQOcuyI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 h1:bTLqdHv7xrGlFbvf5/TXNxy/iUwwdkjhqQTJDjW7aj0=
golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.48.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4=
modernc.org/sqlite v1.48.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=

View file

@ -1,4 +1,29 @@
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View file

@ -0,0 +1,642 @@
# betterNAS Production Deployment Plan
## Overview
Deploy the betterNAS control-plane as a production service on netty (Netcup VPS) with SQLite-backed user auth, NGINX reverse proxy at `api.betternas.com`, and the web frontend on Vercel at `betternas.com`. Replaces the current dev Docker Compose setup with a NixOS-native systemd service matching the existing deployment pattern (forgejo, vaultwarden, sandbox-agent).
## Current State
- Control-plane is a Go binary running in Docker on netty (port 3001->3000)
- State is an in-memory store backed by a JSON file
- Auth is static tokens from environment variables (no user accounts)
- Web frontend reads env vars to find the control-plane URL and client token
- Node-agent runs in Docker, connects to control-plane over Docker network
- NGINX on netty already reverse-proxies 3 domains with ACME/Let's Encrypt
- NixOS config is at `/home/rathi/Documents/GitHub/nix/hosts/netty/configuration.nix`
- `betternas.com` is registered on Vercel with nameservers pointed to Vercel DNS
## Desired End State
- `api.betternas.com` serves the control-plane Go binary behind NGINX with TLS
- `betternas.com` serves the Next.js web UI from Vercel
- All state (users, sessions, nodes, exports) lives in SQLite at `/var/lib/betternas/control-plane/betternas.db`
- Users log in with username/password on the web UI, get a session cookie
- One-click mount: logged-in user clicks an export, backend issues WebDAV credentials using the user's session
- Node-agent connects to `api.betternas.com` over HTTPS
- Deployment is declarative via NixOS configuration.nix
### Verification:
1. `curl https://api.betternas.com/health` returns `ok`
2. Web UI at `betternas.com` loads, shows login page
3. User can register, log in, see exports, one-click mount
4. Node-agent on netty registers and syncs exports to `api.betternas.com`
5. WebDAV mount from Finder works with issued credentials
## What We're NOT Doing
- Multi-tenant / multi-user RBAC (just simple username/password accounts)
- OAuth / SSO / social login
- Email verification or password reset flows
- Migrating existing JSON state (fresh SQLite DB)
- Nextcloud integration (can add later)
- CI/CD pipeline (manual deploy via `nixos-rebuild switch`)
- Rate limiting or request throttling
## Implementation Approach
Five phases, each independently deployable and testable:
1. **SQLite store** - Replace memoryStore with sqliteStore for all existing state
2. **User auth** - Add users/sessions tables, login/register endpoints, session middleware
3. **CORS + frontend auth** - Wire the web UI to use session-based auth against `api.betternas.com`
4. **NixOS deployment** - Systemd service, NGINX vhost, ACME cert, DNS
5. **Vercel deployment** - Deploy web UI, configure domain and env vars
---
## Phase 1: SQLite Store
### Overview
Replace `memoryStore` (in-memory + JSON file) with a `sqliteStore` using `modernc.org/sqlite` (pure Go, no CGo, `database/sql` compatible). This keeps all existing API behavior identical while switching the persistence layer.
### Schema
```sql
-- Ordinal counters (replaces NextNodeOrdinal / NextExportOrdinal)
CREATE TABLE ordinals (
name TEXT PRIMARY KEY,
value INTEGER NOT NULL DEFAULT 0
);
INSERT INTO ordinals (name, value) VALUES ('node', 0), ('export', 0);
-- Nodes
CREATE TABLE nodes (
id TEXT PRIMARY KEY,
machine_id TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL DEFAULT '',
agent_version TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'online',
last_seen_at TEXT,
direct_address TEXT,
relay_address TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
-- Node auth tokens (hashed)
CREATE TABLE node_tokens (
node_id TEXT PRIMARY KEY REFERENCES nodes(id),
token_hash TEXT NOT NULL
);
-- Storage exports
CREATE TABLE exports (
id TEXT PRIMARY KEY,
node_id TEXT NOT NULL REFERENCES nodes(id),
label TEXT NOT NULL DEFAULT '',
path TEXT NOT NULL,
mount_path TEXT NOT NULL DEFAULT '',
capacity_bytes INTEGER,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
UNIQUE(node_id, path)
);
-- Export protocols (normalized from JSON array)
CREATE TABLE export_protocols (
export_id TEXT NOT NULL REFERENCES exports(id) ON DELETE CASCADE,
protocol TEXT NOT NULL,
PRIMARY KEY (export_id, protocol)
);
-- Export tags (normalized from JSON array)
CREATE TABLE export_tags (
export_id TEXT NOT NULL REFERENCES exports(id) ON DELETE CASCADE,
tag TEXT NOT NULL,
PRIMARY KEY (export_id, tag)
);
```
### Changes Required
#### 1. Add SQLite dependency
**File**: `apps/control-plane/go.mod`
```
go get modernc.org/sqlite
```
#### 2. New file: `sqlite_store.go`
**File**: `apps/control-plane/cmd/control-plane/sqlite_store.go`
Implements the same operations as `memoryStore` but backed by SQLite:
- `newSQLiteStore(dbPath string) (*sqliteStore, error)` - opens DB, runs migrations
- `registerNode(...)` - INSERT/UPDATE node + token hash in a transaction
- `upsertExports(...)` - DELETE removed exports, UPSERT current ones in a transaction
- `recordHeartbeat(...)` - UPDATE node status/lastSeenAt
- `listExports()` - SELECT all exports with protocols/tags joined
- `exportContext(exportID)` - SELECT export + its node
- `nodeAuthByMachineID(machineID)` - SELECT node_id + token_hash by machine_id
- `nodeAuthByID(nodeID)` - SELECT token_hash by node_id
- `nextOrdinal(name)` - UPDATE ordinals SET value = value + 1 RETURNING value
Key design decisions:
- Use `database/sql` with `modernc.org/sqlite` driver
- WAL mode enabled at connection: `PRAGMA journal_mode=WAL`
- Foreign keys enabled: `PRAGMA foreign_keys=ON`
- Schema migrations run on startup (embed SQL with `//go:embed`)
- All multi-table mutations wrapped in transactions
- No ORM - raw SQL with prepared statements
#### 3. Update `app.go` to use SQLite store
**File**: `apps/control-plane/cmd/control-plane/app.go`
Replace `memoryStore` initialization with `sqliteStore`:
```go
// Replace:
// store, err := newMemoryStore(statePath)
// With:
// store, err := newSQLiteStore(dbPath)
```
New env var: `BETTERNAS_CONTROL_PLANE_DB_PATH` (default: `/var/lib/betternas/control-plane/betternas.db`)
#### 4. Update `server.go` to use new store interface
**File**: `apps/control-plane/cmd/control-plane/server.go`
The server handlers currently call methods directly on `*memoryStore`. These need to call the equivalent methods on the new store. If the method signatures match, this is a straight swap. If not, introduce a `store` interface that both implement during migration, then delete `memoryStore`.
### Success Criteria
#### Automated Verification:
- [ ] `go build ./apps/control-plane/cmd/control-plane/` compiles with `CGO_ENABLED=0`
- [ ] `go test ./apps/control-plane/cmd/control-plane/ -v` passes all existing tests
- [ ] New SQLite store tests pass (register node, upsert exports, list exports, auth lookup)
- [ ] `curl` against a local instance: register node, sync exports, issue mount profile - all return expected responses
#### Manual Verification:
- [ ] Start control-plane locally, SQLite file is created at configured path
- [ ] Restart control-plane - state persists across restarts
- [ ] Node-agent can register and sync exports against the SQLite-backed control-plane
---
## Phase 2: User Auth
### Overview
Add user accounts with username/password (bcrypt) and session tokens stored in SQLite. The session token replaces the static `BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN` for web UI access. Node-agent auth (bootstrap token + node token) is unchanged.
### Additional Schema
```sql
-- Users
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
-- Sessions
CREATE TABLE sessions (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
expires_at TEXT NOT NULL
);
CREATE INDEX idx_sessions_expires ON sessions(expires_at);
```
### New API Endpoints
```
POST /api/v1/auth/register - Create account (username, password)
POST /api/v1/auth/login - Login, returns session token + sets cookie
POST /api/v1/auth/logout - Invalidate session
GET /api/v1/auth/me - Return current user info (session validation)
```
### Changes Required
#### 1. New file: `auth.go`
**File**: `apps/control-plane/cmd/control-plane/auth.go`
```go
// Dependencies: golang.org/x/crypto/bcrypt, crypto/rand
func (s *sqliteStore) createUser(username, password string) (user, error)
// - Validate username (3-64 chars, alphanumeric + underscore/hyphen)
// - bcrypt hash the password (cost 10)
// - INSERT into users with generated ID
// - Return user struct
func (s *sqliteStore) authenticateUser(username, password string) (user, error)
// - SELECT user by username
// - bcrypt.CompareHashAndPassword
// - Return user or error
func (s *sqliteStore) createSession(userID string, ttl time.Duration) (string, error)
// - Generate 32-byte random token, hex-encode
// - INSERT into sessions with expires_at = now + ttl
// - Return token
func (s *sqliteStore) validateSession(token string) (user, error)
// - SELECT session JOIN users WHERE token = ? AND expires_at > now
// - Return user or error
func (s *sqliteStore) deleteSession(token string) error
// - DELETE FROM sessions WHERE token = ?
func (s *sqliteStore) cleanExpiredSessions() error
// - DELETE FROM sessions WHERE expires_at < now
// - Run periodically (e.g., on each request or via goroutine)
```
#### 2. New env vars
```
BETTERNAS_SESSION_TTL # Session duration (default: "720h" = 30 days)
BETTERNAS_REGISTRATION_ENABLED # Allow new registrations (default: "true")
```
#### 3. Update `server.go` - auth middleware and routes
**File**: `apps/control-plane/cmd/control-plane/server.go`
Add auth routes:
```go
mux.HandleFunc("POST /api/v1/auth/register", s.handleRegister)
mux.HandleFunc("POST /api/v1/auth/login", s.handleLogin)
mux.HandleFunc("POST /api/v1/auth/logout", s.handleLogout)
mux.HandleFunc("GET /api/v1/auth/me", s.handleMe)
```
Update client-auth middleware:
```go
// Currently: checks Bearer token against static BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN
// New: checks Bearer token against sessions table first, falls back to static token
// This preserves backwards compatibility during migration
func (s *server) requireClientAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := extractBearerToken(r)
// Try session-based auth first
user, err := s.store.validateSession(token)
if err == nil {
ctx := context.WithValue(r.Context(), userContextKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
// Fall back to static client token (for backwards compat / scripts)
if secureStringEquals(token, s.config.clientToken) {
next.ServeHTTP(w, r)
return
}
writeUnauthorized(w)
})
}
```
### Success Criteria
#### Automated Verification:
- [ ] `go test` passes for auth endpoints (register, login, logout, me)
- [ ] `go test` passes for session middleware (valid token, expired token, invalid token)
- [ ] Existing client token auth still works (backwards compat)
- [ ] Existing node auth unchanged
#### Manual Verification:
- [ ] Register a user via curl, login, use session token to list exports
- [ ] Session expires after TTL
- [ ] Logout invalidates session immediately
- [ ] Registration can be disabled via env var
---
## Phase 3: CORS + Frontend Auth Integration
### Overview
Add CORS headers to the control-plane so the Vercel-hosted frontend can make API calls. Update the web frontend to use session-based auth (login page, session cookie/token management).
### Changes Required
#### 1. CORS middleware in control-plane
**File**: `apps/control-plane/cmd/control-plane/server.go`
```go
// New env var: BETTERNAS_CORS_ORIGIN (e.g., "https://betternas.com")
func corsMiddleware(allowedOrigin string, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", allowedOrigin)
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Max-Age", "86400")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
```
#### 2. Frontend auth flow
**Files**: `apps/web/`
New pages/components:
- `app/login/page.tsx` - Login form (username + password)
- `app/register/page.tsx` - Registration form (if enabled)
- `lib/auth.ts` - Client-side auth helpers (store token, attach to requests)
Update `lib/control-plane.ts`:
- Remove `.env.agent` file reading (production doesn't need it)
- Read `NEXT_PUBLIC_BETTERNAS_API_URL` env var for the backend URL
- Use session token from localStorage/cookie instead of static client token
- Add login/register/logout API calls
```typescript
// lib/auth.ts
const TOKEN_KEY = "betternas_session";
export function getSessionToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(TOKEN_KEY);
}
export function setSessionToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token);
}
export function clearSessionToken(): void {
localStorage.removeItem(TOKEN_KEY);
}
export async function login(apiUrl: string, username: string, password: string): Promise<string> {
const res = await fetch(`${apiUrl}/api/v1/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (!res.ok) throw new Error("Login failed");
const data = await res.json();
setSessionToken(data.token);
return data.token;
}
```
Update `lib/control-plane.ts`:
```typescript
// Replace the current getControlPlaneConfig with:
export function getControlPlaneConfig(): ControlPlaneConfig {
const baseUrl = process.env.NEXT_PUBLIC_BETTERNAS_API_URL || null;
const clientToken = getSessionToken();
return { baseUrl, clientToken };
}
```
#### 3. Auth-gated layout
**File**: `apps/web/app/layout.tsx` or a middleware
Redirect to `/login` if no valid session. The `/login` and `/register` pages are public.
### Success Criteria
#### Automated Verification:
- [ ] CORS preflight (OPTIONS) returns correct headers
- [ ] Frontend builds: `cd apps/web && pnpm build`
- [ ] No TypeScript errors
#### Manual Verification:
- [ ] Open `betternas.com` (or localhost:3000) - redirected to login
- [ ] Register a new account, login, see exports dashboard
- [ ] Click an export, get mount credentials
- [ ] Logout, confirm redirected to login
- [ ] API calls from frontend include correct CORS headers
---
## Phase 4: NixOS Deployment (netty)
### Overview
Deploy the control-plane as a NixOS-managed systemd service on netty, behind NGINX with ACME TLS at `api.betternas.com`. Stop the Docker Compose stack.
### Changes Required
#### 1. DNS: Point `api.betternas.com` to netty
Run from local machine (Vercel CLI):
```bash
vercel dns add betternas.com api A 152.53.195.59
```
#### 2. Build the Go binary for Linux
**File**: `apps/control-plane/Dockerfile` (or local cross-compile)
For NixOS, we can either:
- (a) Cross-compile locally: `GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o control-plane ./cmd/control-plane`
- (b) Build a Nix package (cleaner, but more work)
- (c) Build on netty directly from the git repo
Recommendation: **(c) Build on netty** from the cloned repo. Simple, works now. Add a Nix package later if desired.
#### 3. NixOS configuration changes
**File**: `/home/rathi/Documents/GitHub/nix/hosts/netty/configuration.nix`
Add these blocks (following the existing forgejo/vaultwarden pattern):
```nix
# --- betterNAS control-plane ---
betternasDomain = "api.betternas.com";
# In services.nginx.virtualHosts:
virtualHosts.${betternasDomain} = {
enableACME = true;
forceSSL = true;
locations."/".proxyPass = "http://127.0.0.1:3100";
locations."/".extraConfig = ''
proxy_set_header X-Forwarded-Proto $scheme;
'';
};
# Systemd service:
systemd.services.betternas-control-plane = {
description = "betterNAS Control Plane";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
Type = "simple";
User = username;
Group = "users";
WorkingDirectory = "/var/lib/betternas/control-plane";
ExecStart = "/home/${username}/Documents/GitHub/betterNAS/betterNAS/apps/control-plane/dist/control-plane";
EnvironmentFile = "/var/lib/betternas/control-plane/control-plane.env";
Restart = "on-failure";
RestartSec = 5;
StateDirectory = "betternas/control-plane";
};
};
```
#### 4. Environment file on netty
**File**: `/var/lib/betternas/control-plane/control-plane.env`
```bash
PORT=3100
BETTERNAS_VERSION=0.1.0
BETTERNAS_CONTROL_PLANE_DB_PATH=/var/lib/betternas/control-plane/betternas.db
BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN=<generate-strong-token>
BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN=<generate-strong-token>
BETTERNAS_DAV_AUTH_SECRET=<generate-strong-secret>
BETTERNAS_DAV_CREDENTIAL_TTL=24h
BETTERNAS_SESSION_TTL=720h
BETTERNAS_REGISTRATION_ENABLED=true
BETTERNAS_CORS_ORIGIN=https://betternas.com
BETTERNAS_NODE_DIRECT_ADDRESS=https://api.betternas.com
```
#### 5. Build and deploy script
**File**: `apps/control-plane/scripts/deploy-netty.sh`
```bash
#!/usr/bin/env bash
set -euo pipefail
REMOTE="netty"
REPO="/home/rathi/Documents/GitHub/betterNAS/betterNAS"
DIST="$REPO/apps/control-plane/dist"
ssh "$REMOTE" "cd $REPO && git pull && \
mkdir -p $DIST && \
cd apps/control-plane && \
CGO_ENABLED=0 go build -o $DIST/control-plane ./cmd/control-plane && \
sudo systemctl restart betternas-control-plane && \
sleep 2 && \
sudo systemctl status betternas-control-plane --no-pager"
```
#### 6. Stop Docker Compose stack
After the systemd service is running and verified:
```bash
ssh netty 'bash -c "cd /home/rathi/Documents/GitHub/betterNAS/betterNAS && source scripts/lib/runtime-env.sh && compose down"'
```
### Success Criteria
#### Automated Verification:
- [ ] `curl https://api.betternas.com/health` returns `ok`
- [ ] `curl https://api.betternas.com/version` returns version JSON
- [ ] TLS certificate is valid (Let's Encrypt)
- [ ] `systemctl status betternas-control-plane` shows active
#### Manual Verification:
- [ ] Node-agent can register against `https://api.betternas.com`
- [ ] Mount credentials issued via the API work in Finder
- [ ] Service survives restart: `sudo systemctl restart betternas-control-plane`
- [ ] State persists in SQLite across restarts
---
## Phase 5: Vercel Deployment
### Overview
Deploy the Next.js web UI to Vercel at `betternas.com`.
### Changes Required
#### 1. Create Vercel project
```bash
cd apps/web
vercel link # or vercel --yes
```
#### 2. Configure environment variables on Vercel
```bash
vercel env add NEXT_PUBLIC_BETTERNAS_API_URL production
# Value: https://api.betternas.com
```
#### 3. Configure domain
```bash
vercel domains add betternas.com
# Already have wildcard ALIAS to vercel-dns, so this should work
```
#### 4. Deploy
```bash
cd apps/web
vercel --prod
```
#### 5. Verify CORS
The backend at `api.betternas.com` must have `BETTERNAS_CORS_ORIGIN=https://betternas.com` set (done in Phase 4).
### Success Criteria
#### Automated Verification:
- [ ] `curl -I https://betternas.com` returns 200
- [ ] CORS preflight from `betternas.com` to `api.betternas.com` succeeds
#### Manual Verification:
- [ ] Visit `betternas.com` - see login page
- [ ] Register, login, see exports, issue mount credentials
- [ ] Mount from Finder using issued credentials
---
## Node-Agent Deployment (post-phases)
After the control-plane is running at `api.betternas.com`, update the node-agent on netty to connect to it:
1. Build node-agent: `cd apps/node-agent && CGO_ENABLED=0 go build -o dist/node-agent ./cmd/node-agent`
2. Create systemd service similar to control-plane
3. Environment: `BETTERNAS_CONTROL_PLANE_URL=https://api.betternas.com`
4. NGINX vhost for WebDAV if needed (or direct port exposure)
This is a follow-up task, not part of the initial deployment.
---
## Testing Strategy
### Unit Tests (Go):
- SQLite store: CRUD operations, transactions, concurrent access
- Auth: registration, login, session validation, expiry, logout
- Migration: schema creates cleanly on empty DB
### Integration Tests:
- Full API flow: register user -> login -> list exports -> issue mount profile
- Node registration + export sync against SQLite store
- Session expiry and cleanup
### Manual Testing:
1. Fresh deploy: start control-plane with empty DB
2. Register first user via API
3. Login from web UI
4. Connect node-agent, verify exports appear
5. Issue mount credentials, mount in Finder
6. Restart control-plane, verify all state persisted
## Performance Considerations
- SQLite WAL mode for concurrent reads during writes
- Session cleanup: delete expired sessions on a timer (every 10 minutes), not on every request
- Connection pool: single writer, multiple readers (SQLite default with WAL)
- For a single-NAS deployment, SQLite performance is more than sufficient
## Go Dependencies to Add
```
modernc.org/sqlite # Pure Go SQLite driver
golang.org/x/crypto/bcrypt # Password hashing
```
Both are well-maintained, widely used, and have no CGo requirement.
## References
- NixOS config: `/home/rathi/Documents/GitHub/nix/hosts/netty/configuration.nix`
- Control-plane server: `apps/control-plane/cmd/control-plane/server.go`
- Control-plane store: `apps/control-plane/cmd/control-plane/store.go`
- Web frontend API client: `apps/web/lib/control-plane.ts`
- Docker compose (current dev): `infra/docker/compose.dev.yml`