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

@ -10,24 +10,25 @@ type appConfig struct {
version string
nextcloudBaseURL string
statePath string
dbPath string
clientToken string
nodeBootstrapToken string
davAuthSecret string
davCredentialTTL time.Duration
sessionTTL time.Duration
registrationEnabled bool
corsOrigin string
}
type app struct {
startedAt time.Time
now func() time.Time
config appConfig
store *memoryStore
store store
}
func newApp(config appConfig, startedAt time.Time) (*app, error) {
config.clientToken = strings.TrimSpace(config.clientToken)
if config.clientToken == "" {
return nil, errors.New("client token is required")
}
config.nodeBootstrapToken = strings.TrimSpace(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")
}
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 {
return nil, err
}
@ -51,7 +58,7 @@ func newApp(config appConfig, startedAt time.Time) (*app, error) {
startedAt: startedAt,
now: time.Now,
config: config,
store: store,
store: s,
}, nil
}
@ -164,6 +171,12 @@ type exportContext struct {
node nasNode
}
type user struct {
ID string `json:"id"`
Username string `json:"username"`
CreatedAt string `json:"createdAt"`
}
func copyStringPointer(value *string) *string {
if value == 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 (
"log"
"net/http"
"strings"
"time"
)
@ -24,11 +25,6 @@ func main() {
}
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")
if err != nil {
return nil, err
@ -44,15 +40,28 @@ func newAppFromEnv(startedAt time.Time) (*app, error) {
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(
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"),
clientToken: clientToken,
dbPath: env("BETTERNAS_CONTROL_PLANE_DB_PATH", ""),
clientToken: env("BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN", ""),
nodeBootstrapToken: nodeBootstrapToken,
davAuthSecret: davAuthSecret,
davCredentialTTL: davCredentialTTL,
sessionTTL: sessionTTL,
registrationEnabled: env("BETTERNAS_REGISTRATION_ENABLED", "true") == "true",
corsOrigin: env("BETTERNAS_CORS_ORIGIN", ""),
},
startedAt,
)

View file

@ -33,6 +33,10 @@ func (a *app) handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", a.handleHealth)
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/{nodeId}/heartbeat", a.handleNodeHeartbeat)
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/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) {
@ -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 {
presentedToken, ok := bearerToken(r)
if !ok || !secureStringEquals(a.config.clientToken, presentedToken) {
if !ok {
writeUnauthorized(w)
return 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 {

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"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"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) {
raw := make([]byte, 32)
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
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.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.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
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.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`