diff --git a/apps/control-plane/cmd/control-plane/app.go b/apps/control-plane/cmd/control-plane/app.go index 2863e8b..949d7f5 100644 --- a/apps/control-plane/cmd/control-plane/app.go +++ b/apps/control-plane/cmd/control-plane/app.go @@ -7,27 +7,28 @@ import ( ) type appConfig struct { - version string - nextcloudBaseURL string - statePath string - clientToken string - nodeBootstrapToken string - davAuthSecret string - davCredentialTTL time.Duration + 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 diff --git a/apps/control-plane/cmd/control-plane/auth_test.go b/apps/control-plane/cmd/control-plane/auth_test.go new file mode 100644 index 0000000..0fa9262 --- /dev/null +++ b/apps/control-plane/cmd/control-plane/auth_test.go @@ -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) +} diff --git a/apps/control-plane/cmd/control-plane/main.go b/apps/control-plane/cmd/control-plane/main.go index 7b109f5..82e0f0b 100644 --- a/apps/control-plane/cmd/control-plane/main.go +++ b/apps/control-plane/cmd/control-plane/main.go @@ -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, - nodeBootstrapToken: nodeBootstrapToken, - davAuthSecret: davAuthSecret, - davCredentialTTL: davCredentialTTL, + version: env("BETTERNAS_VERSION", "0.1.0-dev"), + nextcloudBaseURL: env("NEXTCLOUD_BASE_URL", ""), + statePath: env("BETTERNAS_CONTROL_PLANE_STATE_PATH", ".state/control-plane/state.json"), + dbPath: env("BETTERNAS_CONTROL_PLANE_DB_PATH", ""), + clientToken: env("BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN", ""), + nodeBootstrapToken: nodeBootstrapToken, + davAuthSecret: davAuthSecret, + davCredentialTTL: davCredentialTTL, + sessionTTL: sessionTTL, + registrationEnabled: env("BETTERNAS_REGISTRATION_ENABLED", "true") == "true", + corsOrigin: env("BETTERNAS_CORS_ORIGIN", ""), }, startedAt, ) diff --git a/apps/control-plane/cmd/control-plane/server.go b/apps/control-plane/cmd/control-plane/server.go index 0fbd2e4..e54f0be 100644 --- a/apps/control-plane/cmd/control-plane/server.go +++ b/apps/control-plane/cmd/control-plane/server.go @@ -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 } - 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 { diff --git a/apps/control-plane/cmd/control-plane/sqlite_store.go b/apps/control-plane/cmd/control-plane/sqlite_store.go new file mode 100644 index 0000000..e565e13 --- /dev/null +++ b/apps/control-plane/cmd/control-plane/sqlite_store.go @@ -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 +} diff --git a/apps/control-plane/cmd/control-plane/sqlite_store_test.go b/apps/control-plane/cmd/control-plane/sqlite_store_test.go new file mode 100644 index 0000000..47cfc3d --- /dev/null +++ b/apps/control-plane/cmd/control-plane/sqlite_store_test.go @@ -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) +} diff --git a/apps/control-plane/cmd/control-plane/store.go b/apps/control-plane/cmd/control-plane/store.go index 5e0861b..e72219e 100644 --- a/apps/control-plane/cmd/control-plane/store.go +++ b/apps/control-plane/cmd/control-plane/store.go @@ -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 { diff --git a/apps/control-plane/cmd/control-plane/store_iface.go b/apps/control-plane/cmd/control-plane/store_iface.go new file mode 100644 index 0000000..25c7821 --- /dev/null +++ b/apps/control-plane/cmd/control-plane/store_iface.go @@ -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 +} diff --git a/apps/control-plane/go.mod b/apps/control-plane/go.mod index f5728d1..92653a7 100644 --- a/apps/control-plane/go.mod +++ b/apps/control-plane/go.mod @@ -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 +) diff --git a/apps/control-plane/go.sum b/apps/control-plane/go.sum new file mode 100644 index 0000000..6620310 --- /dev/null +++ b/apps/control-plane/go.sum @@ -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= diff --git a/go.work.sum b/go.work.sum index c526ba6..677e637 100644 --- a/go.work.sum +++ b/go.work.sum @@ -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= diff --git a/thoughts/shared/plans/2026-04-01-production-deployment.md b/thoughts/shared/plans/2026-04-01-production-deployment.md new file mode 100644 index 0000000..b76c7c6 --- /dev/null +++ b/thoughts/shared/plans/2026-04-01-production-deployment.md @@ -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 { + const res = await fetch(`${apiUrl}/api/v1/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + if (!res.ok) throw new Error("Login failed"); + const data = await res.json(); + setSessionToken(data.token); + return data.token; +} +``` + +Update `lib/control-plane.ts`: +```typescript +// Replace the current getControlPlaneConfig with: +export function getControlPlaneConfig(): ControlPlaneConfig { + const baseUrl = process.env.NEXT_PUBLIC_BETTERNAS_API_URL || null; + const clientToken = getSessionToken(); + return { baseUrl, clientToken }; +} +``` + +#### 3. Auth-gated layout +**File**: `apps/web/app/layout.tsx` or a middleware + +Redirect to `/login` if no valid session. The `/login` and `/register` pages are public. + +### Success Criteria + +#### Automated Verification: +- [ ] CORS preflight (OPTIONS) returns correct headers +- [ ] Frontend builds: `cd apps/web && pnpm build` +- [ ] No TypeScript errors + +#### Manual Verification: +- [ ] Open `betternas.com` (or localhost:3000) - redirected to login +- [ ] Register a new account, login, see exports dashboard +- [ ] Click an export, get mount credentials +- [ ] Logout, confirm redirected to login +- [ ] API calls from frontend include correct CORS headers + +--- + +## Phase 4: NixOS Deployment (netty) + +### Overview +Deploy the control-plane as a NixOS-managed systemd service on netty, behind NGINX with ACME TLS at `api.betternas.com`. Stop the Docker Compose stack. + +### Changes Required + +#### 1. DNS: Point `api.betternas.com` to netty +Run from local machine (Vercel CLI): +```bash +vercel dns add betternas.com api A 152.53.195.59 +``` + +#### 2. Build the Go binary for Linux +**File**: `apps/control-plane/Dockerfile` (or local cross-compile) + +For NixOS, we can either: +- (a) Cross-compile locally: `GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o control-plane ./cmd/control-plane` +- (b) Build a Nix package (cleaner, but more work) +- (c) Build on netty directly from the git repo + +Recommendation: **(c) Build on netty** from the cloned repo. Simple, works now. Add a Nix package later if desired. + +#### 3. NixOS configuration changes +**File**: `/home/rathi/Documents/GitHub/nix/hosts/netty/configuration.nix` + +Add these blocks (following the existing forgejo/vaultwarden pattern): + +```nix + # --- betterNAS control-plane --- + betternasDomain = "api.betternas.com"; + + # In services.nginx.virtualHosts: + virtualHosts.${betternasDomain} = { + enableACME = true; + forceSSL = true; + locations."/".proxyPass = "http://127.0.0.1:3100"; + locations."/".extraConfig = '' + proxy_set_header X-Forwarded-Proto $scheme; + ''; + }; + + # Systemd service: + systemd.services.betternas-control-plane = { + description = "betterNAS Control Plane"; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "simple"; + User = username; + Group = "users"; + WorkingDirectory = "/var/lib/betternas/control-plane"; + ExecStart = "/home/${username}/Documents/GitHub/betterNAS/betterNAS/apps/control-plane/dist/control-plane"; + EnvironmentFile = "/var/lib/betternas/control-plane/control-plane.env"; + Restart = "on-failure"; + RestartSec = 5; + StateDirectory = "betternas/control-plane"; + }; + }; +``` + +#### 4. Environment file on netty +**File**: `/var/lib/betternas/control-plane/control-plane.env` + +```bash +PORT=3100 +BETTERNAS_VERSION=0.1.0 +BETTERNAS_CONTROL_PLANE_DB_PATH=/var/lib/betternas/control-plane/betternas.db +BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN= +BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN= +BETTERNAS_DAV_AUTH_SECRET= +BETTERNAS_DAV_CREDENTIAL_TTL=24h +BETTERNAS_SESSION_TTL=720h +BETTERNAS_REGISTRATION_ENABLED=true +BETTERNAS_CORS_ORIGIN=https://betternas.com +BETTERNAS_NODE_DIRECT_ADDRESS=https://api.betternas.com +``` + +#### 5. Build and deploy script +**File**: `apps/control-plane/scripts/deploy-netty.sh` + +```bash +#!/usr/bin/env bash +set -euo pipefail + +REMOTE="netty" +REPO="/home/rathi/Documents/GitHub/betterNAS/betterNAS" +DIST="$REPO/apps/control-plane/dist" + +ssh "$REMOTE" "cd $REPO && git pull && \ + mkdir -p $DIST && \ + cd apps/control-plane && \ + CGO_ENABLED=0 go build -o $DIST/control-plane ./cmd/control-plane && \ + sudo systemctl restart betternas-control-plane && \ + sleep 2 && \ + sudo systemctl status betternas-control-plane --no-pager" +``` + +#### 6. Stop Docker Compose stack +After the systemd service is running and verified: +```bash +ssh netty 'bash -c "cd /home/rathi/Documents/GitHub/betterNAS/betterNAS && source scripts/lib/runtime-env.sh && compose down"' +``` + +### Success Criteria + +#### Automated Verification: +- [ ] `curl https://api.betternas.com/health` returns `ok` +- [ ] `curl https://api.betternas.com/version` returns version JSON +- [ ] TLS certificate is valid (Let's Encrypt) +- [ ] `systemctl status betternas-control-plane` shows active + +#### Manual Verification: +- [ ] Node-agent can register against `https://api.betternas.com` +- [ ] Mount credentials issued via the API work in Finder +- [ ] Service survives restart: `sudo systemctl restart betternas-control-plane` +- [ ] State persists in SQLite across restarts + +--- + +## Phase 5: Vercel Deployment + +### Overview +Deploy the Next.js web UI to Vercel at `betternas.com`. + +### Changes Required + +#### 1. Create Vercel project +```bash +cd apps/web +vercel link # or vercel --yes +``` + +#### 2. Configure environment variables on Vercel +```bash +vercel env add NEXT_PUBLIC_BETTERNAS_API_URL production +# Value: https://api.betternas.com +``` + +#### 3. Configure domain +```bash +vercel domains add betternas.com +# Already have wildcard ALIAS to vercel-dns, so this should work +``` + +#### 4. Deploy +```bash +cd apps/web +vercel --prod +``` + +#### 5. Verify CORS +The backend at `api.betternas.com` must have `BETTERNAS_CORS_ORIGIN=https://betternas.com` set (done in Phase 4). + +### Success Criteria + +#### Automated Verification: +- [ ] `curl -I https://betternas.com` returns 200 +- [ ] CORS preflight from `betternas.com` to `api.betternas.com` succeeds + +#### Manual Verification: +- [ ] Visit `betternas.com` - see login page +- [ ] Register, login, see exports, issue mount credentials +- [ ] Mount from Finder using issued credentials + +--- + +## Node-Agent Deployment (post-phases) + +After the control-plane is running at `api.betternas.com`, update the node-agent on netty to connect to it: + +1. Build node-agent: `cd apps/node-agent && CGO_ENABLED=0 go build -o dist/node-agent ./cmd/node-agent` +2. Create systemd service similar to control-plane +3. Environment: `BETTERNAS_CONTROL_PLANE_URL=https://api.betternas.com` +4. NGINX vhost for WebDAV if needed (or direct port exposure) + +This is a follow-up task, not part of the initial deployment. + +--- + +## Testing Strategy + +### Unit Tests (Go): +- SQLite store: CRUD operations, transactions, concurrent access +- Auth: registration, login, session validation, expiry, logout +- Migration: schema creates cleanly on empty DB + +### Integration Tests: +- Full API flow: register user -> login -> list exports -> issue mount profile +- Node registration + export sync against SQLite store +- Session expiry and cleanup + +### Manual Testing: +1. Fresh deploy: start control-plane with empty DB +2. Register first user via API +3. Login from web UI +4. Connect node-agent, verify exports appear +5. Issue mount credentials, mount in Finder +6. Restart control-plane, verify all state persisted + +## Performance Considerations + +- SQLite WAL mode for concurrent reads during writes +- Session cleanup: delete expired sessions on a timer (every 10 minutes), not on every request +- Connection pool: single writer, multiple readers (SQLite default with WAL) +- For a single-NAS deployment, SQLite performance is more than sufficient + +## Go Dependencies to Add + +``` +modernc.org/sqlite # Pure Go SQLite driver +golang.org/x/crypto/bcrypt # Password hashing +``` + +Both are well-maintained, widely used, and have no CGo requirement. + +## References + +- NixOS config: `/home/rathi/Documents/GitHub/nix/hosts/netty/configuration.nix` +- Control-plane server: `apps/control-plane/cmd/control-plane/server.go` +- Control-plane store: `apps/control-plane/cmd/control-plane/store.go` +- Web frontend API client: `apps/web/lib/control-plane.ts` +- Docker compose (current dev): `infra/docker/compose.dev.yml`