From 8002158a45b33a7500d5c6b3425b07b967ad8be3 Mon Sep 17 00:00:00 2001 From: Harivansh Rathi Date: Wed, 1 Apr 2026 20:55:17 -0400 Subject: [PATCH 01/13] docs --- CLAUDE.md | 4 + .../plans/2026-04-01-production-deployment.md | 698 ------------------ 2 files changed, 4 insertions(+), 698 deletions(-) delete mode 100644 thoughts/shared/plans/2026-04-01-production-deployment.md diff --git a/CLAUDE.md b/CLAUDE.md index 951a376..e9338c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,3 +61,7 @@ - Keep the implementation as simple as possible. - Do not over-engineer the auth or distribution model for v1. - Prefer the smallest change set that makes the product usable and distributable. + +## Live operations + +- If modifying the live Netcup deployment, only stop the `betternas` node process unless the user explicitly asks to modify the deployed backend service. diff --git a/thoughts/shared/plans/2026-04-01-production-deployment.md b/thoughts/shared/plans/2026-04-01-production-deployment.md deleted file mode 100644 index 40315f4..0000000 --- a/thoughts/shared/plans/2026-04-01-production-deployment.md +++ /dev/null @@ -1,698 +0,0 @@ -# betterNAS Production Deployment Plan - -## Overview - -Deploy the betterNAS control-plane as a production service on netty (Netcup VPS) with SQLite-backed user auth, NGINX reverse proxy at `api.betternas.com`, and the web frontend on Vercel at `betternas.com`. Replaces the current dev Docker Compose setup with a NixOS-native systemd service matching the existing deployment pattern (forgejo, vaultwarden, sandbox-agent). - -## Current State - -- Control-plane is a Go binary running in Docker on netty (port 3001->3000) -- State is an in-memory store backed by a JSON file -- Auth is static tokens from environment variables (no user accounts) -- Web frontend reads env vars to find the control-plane URL and client token -- Node-agent runs in Docker, connects to control-plane over Docker network -- NGINX on netty already reverse-proxies 3 domains with ACME/Let's Encrypt -- NixOS config is at `/home/rathi/Documents/GitHub/nix/hosts/netty/configuration.nix` -- `betternas.com` is registered on Vercel with nameservers pointed to Vercel DNS - -## Desired End State - -- `api.betternas.com` serves the control-plane Go binary behind NGINX with TLS -- `betternas.com` serves the Next.js web UI from Vercel -- All state (users, sessions, nodes, exports) lives in SQLite at `/var/lib/betternas/control-plane/betternas.db` -- Users log in with username/password on the web UI, get a session cookie -- One-click mount: logged-in user clicks an export, backend issues WebDAV credentials using the user's session -- Node-agent connects to `api.betternas.com` over HTTPS -- Deployment is declarative via NixOS configuration.nix - -### Verification: - -1. `curl https://api.betternas.com/health` returns `ok` -2. Web UI at `betternas.com` loads, shows login page -3. User can register, log in, see exports, one-click mount -4. Node-agent on netty registers and syncs exports to `api.betternas.com` -5. WebDAV mount from Finder works with issued credentials - -## What We're NOT Doing - -- Multi-tenant / multi-user RBAC (just simple username/password accounts) -- OAuth / SSO / social login -- Email verification or password reset flows -- Migrating existing JSON state (fresh SQLite DB) -- Nextcloud integration (can add later) -- CI/CD pipeline (manual deploy via `nixos-rebuild switch`) -- Rate limiting or request throttling - -## Implementation Approach - -Five phases, each independently deployable and testable: - -1. **SQLite store** - Replace memoryStore with sqliteStore for all existing state -2. **User auth** - Add users/sessions tables, login/register endpoints, session middleware -3. **CORS + frontend auth** - Wire the web UI to use session-based auth against `api.betternas.com` -4. **NixOS deployment** - Systemd service, NGINX vhost, ACME cert, DNS -5. **Vercel deployment** - Deploy web UI, configure domain and env vars - ---- - -## Phase 1: SQLite Store - -### Overview - -Replace `memoryStore` (in-memory + JSON file) with a `sqliteStore` using `modernc.org/sqlite` (pure Go, no CGo, `database/sql` compatible). This keeps all existing API behavior identical while switching the persistence layer. - -### Schema - -```sql --- Ordinal counters (replaces NextNodeOrdinal / NextExportOrdinal) -CREATE TABLE ordinals ( - name TEXT PRIMARY KEY, - value INTEGER NOT NULL DEFAULT 0 -); -INSERT INTO ordinals (name, value) VALUES ('node', 0), ('export', 0); - --- Nodes -CREATE TABLE nodes ( - id TEXT PRIMARY KEY, - machine_id TEXT NOT NULL UNIQUE, - display_name TEXT NOT NULL DEFAULT '', - agent_version TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'online', - last_seen_at TEXT, - direct_address TEXT, - relay_address TEXT, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) -); - --- Node auth tokens (hashed) -CREATE TABLE node_tokens ( - node_id TEXT PRIMARY KEY REFERENCES nodes(id), - token_hash TEXT NOT NULL -); - --- Storage exports -CREATE TABLE exports ( - id TEXT PRIMARY KEY, - node_id TEXT NOT NULL REFERENCES nodes(id), - label TEXT NOT NULL DEFAULT '', - path TEXT NOT NULL, - mount_path TEXT NOT NULL DEFAULT '', - capacity_bytes INTEGER, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), - UNIQUE(node_id, path) -); - --- Export protocols (normalized from JSON array) -CREATE TABLE export_protocols ( - export_id TEXT NOT NULL REFERENCES exports(id) ON DELETE CASCADE, - protocol TEXT NOT NULL, - PRIMARY KEY (export_id, protocol) -); - --- Export tags (normalized from JSON array) -CREATE TABLE export_tags ( - export_id TEXT NOT NULL REFERENCES exports(id) ON DELETE CASCADE, - tag TEXT NOT NULL, - PRIMARY KEY (export_id, tag) -); -``` - -### Changes Required - -#### 1. Add SQLite dependency - -**File**: `apps/control-plane/go.mod` - -``` -go get modernc.org/sqlite -``` - -#### 2. New file: `sqlite_store.go` - -**File**: `apps/control-plane/cmd/control-plane/sqlite_store.go` - -Implements the same operations as `memoryStore` but backed by SQLite: - -- `newSQLiteStore(dbPath string) (*sqliteStore, error)` - opens DB, runs migrations -- `registerNode(...)` - INSERT/UPDATE node + token hash in a transaction -- `upsertExports(...)` - DELETE removed exports, UPSERT current ones in a transaction -- `recordHeartbeat(...)` - UPDATE node status/lastSeenAt -- `listExports()` - SELECT all exports with protocols/tags joined -- `exportContext(exportID)` - SELECT export + its node -- `nodeAuthByMachineID(machineID)` - SELECT node_id + token_hash by machine_id -- `nodeAuthByID(nodeID)` - SELECT token_hash by node_id -- `nextOrdinal(name)` - UPDATE ordinals SET value = value + 1 RETURNING value - -Key design decisions: - -- Use `database/sql` with `modernc.org/sqlite` driver -- WAL mode enabled at connection: `PRAGMA journal_mode=WAL` -- Foreign keys enabled: `PRAGMA foreign_keys=ON` -- Schema migrations run on startup (embed SQL with `//go:embed`) -- All multi-table mutations wrapped in transactions -- No ORM - raw SQL with prepared statements - -#### 3. Update `app.go` to use SQLite store - -**File**: `apps/control-plane/cmd/control-plane/app.go` - -Replace `memoryStore` initialization with `sqliteStore`: - -```go -// Replace: -// store, err := newMemoryStore(statePath) -// With: -// store, err := newSQLiteStore(dbPath) -``` - -New env var: `BETTERNAS_CONTROL_PLANE_DB_PATH` (default: `/var/lib/betternas/control-plane/betternas.db`) - -#### 4. Update `server.go` to use new store interface - -**File**: `apps/control-plane/cmd/control-plane/server.go` - -The server handlers currently call methods directly on `*memoryStore`. These need to call the equivalent methods on the new store. If the method signatures match, this is a straight swap. If not, introduce a `store` interface that both implement during migration, then delete `memoryStore`. - -### Success Criteria - -#### Automated Verification: - -- [ ] `go build ./apps/control-plane/cmd/control-plane/` compiles with `CGO_ENABLED=0` -- [ ] `go test ./apps/control-plane/cmd/control-plane/ -v` passes all existing tests -- [ ] New SQLite store tests pass (register node, upsert exports, list exports, auth lookup) -- [ ] `curl` against a local instance: register node, sync exports, issue mount profile - all return expected responses - -#### Manual Verification: - -- [ ] Start control-plane locally, SQLite file is created at configured path -- [ ] Restart control-plane - state persists across restarts -- [ ] Node-agent can register and sync exports against the SQLite-backed control-plane - ---- - -## Phase 2: User Auth - -### Overview - -Add user accounts with username/password (bcrypt) and session tokens stored in SQLite. The session token replaces the static `BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN` for web UI access. Node-agent auth (bootstrap token + node token) is unchanged. - -### Additional Schema - -```sql --- Users -CREATE TABLE users ( - id TEXT PRIMARY KEY, - username TEXT NOT NULL UNIQUE COLLATE NOCASE, - password_hash TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) -); - --- Sessions -CREATE TABLE sessions ( - token TEXT PRIMARY KEY, - user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, - created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), - expires_at TEXT NOT NULL -); -CREATE INDEX idx_sessions_expires ON sessions(expires_at); -``` - -### New API Endpoints - -``` -POST /api/v1/auth/register - Create account (username, password) -POST /api/v1/auth/login - Login, returns session token + sets cookie -POST /api/v1/auth/logout - Invalidate session -GET /api/v1/auth/me - Return current user info (session validation) -``` - -### Changes Required - -#### 1. New file: `auth.go` - -**File**: `apps/control-plane/cmd/control-plane/auth.go` - -```go -// Dependencies: golang.org/x/crypto/bcrypt, crypto/rand - -func (s *sqliteStore) createUser(username, password string) (user, error) -// - Validate username (3-64 chars, alphanumeric + underscore/hyphen) -// - bcrypt hash the password (cost 10) -// - INSERT into users with generated ID -// - Return user struct - -func (s *sqliteStore) authenticateUser(username, password string) (user, error) -// - SELECT user by username -// - bcrypt.CompareHashAndPassword -// - Return user or error - -func (s *sqliteStore) createSession(userID string, ttl time.Duration) (string, error) -// - Generate 32-byte random token, hex-encode -// - INSERT into sessions with expires_at = now + ttl -// - Return token - -func (s *sqliteStore) validateSession(token string) (user, error) -// - SELECT session JOIN users WHERE token = ? AND expires_at > now -// - Return user or error - -func (s *sqliteStore) deleteSession(token string) error -// - DELETE FROM sessions WHERE token = ? - -func (s *sqliteStore) cleanExpiredSessions() error -// - DELETE FROM sessions WHERE expires_at < now -// - Run periodically (e.g., on each request or via goroutine) -``` - -#### 2. New env vars - -``` -BETTERNAS_SESSION_TTL # Session duration (default: "720h" = 30 days) -BETTERNAS_REGISTRATION_ENABLED # Allow new registrations (default: "true") -``` - -#### 3. Update `server.go` - auth middleware and routes - -**File**: `apps/control-plane/cmd/control-plane/server.go` - -Add auth routes: - -```go -mux.HandleFunc("POST /api/v1/auth/register", s.handleRegister) -mux.HandleFunc("POST /api/v1/auth/login", s.handleLogin) -mux.HandleFunc("POST /api/v1/auth/logout", s.handleLogout) -mux.HandleFunc("GET /api/v1/auth/me", s.handleMe) -``` - -Update client-auth middleware: - -```go -// Currently: checks Bearer token against static BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN -// New: checks Bearer token against sessions table first, falls back to static token -// This preserves backwards compatibility during migration -func (s *server) requireClientAuth(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - token := extractBearerToken(r) - - // Try session-based auth first - user, err := s.store.validateSession(token) - if err == nil { - ctx := context.WithValue(r.Context(), userContextKey, user) - next.ServeHTTP(w, r.WithContext(ctx)) - return - } - - // Fall back to static client token (for backwards compat / scripts) - if secureStringEquals(token, s.config.clientToken) { - next.ServeHTTP(w, r) - return - } - - writeUnauthorized(w) - }) -} -``` - -### Success Criteria - -#### Automated Verification: - -- [ ] `go test` passes for auth endpoints (register, login, logout, me) -- [ ] `go test` passes for session middleware (valid token, expired token, invalid token) -- [ ] Existing client token auth still works (backwards compat) -- [ ] Existing node auth unchanged - -#### Manual Verification: - -- [ ] Register a user via curl, login, use session token to list exports -- [ ] Session expires after TTL -- [ ] Logout invalidates session immediately -- [ ] Registration can be disabled via env var - ---- - -## Phase 3: CORS + Frontend Auth Integration - -### Overview - -Add CORS headers to the control-plane so the Vercel-hosted frontend can make API calls. Update the web frontend to use session-based auth (login page, session cookie/token management). - -### Changes Required - -#### 1. CORS middleware in control-plane - -**File**: `apps/control-plane/cmd/control-plane/server.go` - -```go -// New env var: BETTERNAS_CORS_ORIGIN (e.g., "https://betternas.com") - -func corsMiddleware(allowedOrigin string, next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", allowedOrigin) - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type") - w.Header().Set("Access-Control-Allow-Credentials", "true") - w.Header().Set("Access-Control-Max-Age", "86400") - - if r.Method == http.MethodOptions { - w.WriteHeader(http.StatusNoContent) - return - } - - next.ServeHTTP(w, r) - }) -} -``` - -#### 2. Frontend auth flow - -**Files**: `apps/web/` - -New pages/components: - -- `app/login/page.tsx` - Login form (username + password) -- `app/register/page.tsx` - Registration form (if enabled) -- `lib/auth.ts` - Client-side auth helpers (store token, attach to requests) - -Update `lib/control-plane.ts`: - -- Remove `.env.agent` file reading (production doesn't need it) -- Read `NEXT_PUBLIC_BETTERNAS_API_URL` env var for the backend URL -- Use session token from localStorage/cookie instead of static client token -- Add login/register/logout API calls - -```typescript -// lib/auth.ts -const TOKEN_KEY = "betternas_session"; - -export function getSessionToken(): string | null { - if (typeof window === "undefined") return null; - return localStorage.getItem(TOKEN_KEY); -} - -export function setSessionToken(token: string): void { - localStorage.setItem(TOKEN_KEY, token); -} - -export function clearSessionToken(): void { - localStorage.removeItem(TOKEN_KEY); -} - -export async function login( - apiUrl: string, - username: string, - password: string, -): Promise { - 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` From 1d564b738d4772350510e9d2e299e9e5e711bf7a Mon Sep 17 00:00:00 2001 From: Harivansh Rathi Date: Wed, 1 Apr 2026 21:06:40 -0400 Subject: [PATCH 02/13] Fix install script: strip v prefix from version for archive name --- CLAUDE.md | 5 + apps/control-plane/cmd/control-plane/app.go | 71 +++++++++ apps/control-plane/cmd/control-plane/main.go | 11 ++ .../control-plane/cmd/control-plane/server.go | 17 ++- .../cmd/control-plane/sqlite_store.go | 45 +++++- apps/control-plane/cmd/control-plane/store.go | 19 +++ .../cmd/control-plane/store_iface.go | 1 + apps/node-agent/cmd/node-agent/app.go | 83 ++++++++++- .../cmd/node-agent/control_plane.go | 42 ++++-- .../cmd/node-agent/control_plane_test.go | 97 +++++++++++++ apps/node-agent/cmd/node-agent/main.go | 16 +++ apps/web/app/page.tsx | 136 +++++++++++++++++- apps/web/lib/api.ts | 15 ++ packages/contracts/openapi/betternas.v1.yaml | 16 +++ packages/contracts/src/foundation.ts | 1 + scripts/install-betternas-node.sh | 3 +- 16 files changed, 552 insertions(+), 26 deletions(-) create mode 100644 apps/node-agent/cmd/node-agent/control_plane_test.go diff --git a/CLAUDE.md b/CLAUDE.md index e9338c1..67e34d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,3 +65,8 @@ ## Live operations - If modifying the live Netcup deployment, only stop the `betternas` node process unless the user explicitly asks to modify the deployed backend service. + +## Node availability UX + +- Prefer default UI behavior that does not present disconnected nodes as mountable. +- Surface connected and disconnected node state in the product when node availability is exposed. diff --git a/apps/control-plane/cmd/control-plane/app.go b/apps/control-plane/cmd/control-plane/app.go index 1aac5b0..4fb54b8 100644 --- a/apps/control-plane/cmd/control-plane/app.go +++ b/apps/control-plane/cmd/control-plane/app.go @@ -1,6 +1,8 @@ package main import ( + "sort" + "strings" "time" ) @@ -10,6 +12,7 @@ type appConfig struct { statePath string dbPath string sessionTTL time.Duration + nodeOfflineThreshold time.Duration registrationEnabled bool corsOrigin string } @@ -21,7 +24,13 @@ type app struct { store store } +const defaultNodeOfflineThreshold = 2 * time.Minute + func newApp(config appConfig, startedAt time.Time) (*app, error) { + if config.nodeOfflineThreshold <= 0 { + config.nodeOfflineThreshold = defaultNodeOfflineThreshold + } + var s store var err error if config.dbPath != "" { @@ -41,6 +50,68 @@ func newApp(config appConfig, startedAt time.Time) (*app, error) { }, nil } +func (a *app) presentedNode(node nasNode) nasNode { + presented := copyNasNode(node) + if !nodeHeartbeatIsFresh(presented.LastSeenAt, a.now().UTC(), a.config.nodeOfflineThreshold) { + presented.Status = "offline" + } + return presented +} + +func (a *app) listNodes(ownerID string) []nasNode { + nodes := a.store.listNodes(ownerID) + presented := make([]nasNode, 0, len(nodes)) + for _, node := range nodes { + presented = append(presented, a.presentedNode(node)) + } + + sort.Slice(presented, func(i, j int) bool { + return presented[i].ID < presented[j].ID + }) + + return presented +} + +func (a *app) listConnectedExports(ownerID string) []storageExport { + exports := a.store.listExports(ownerID) + connected := make([]storageExport, 0, len(exports)) + for _, export := range exports { + context, ok := a.store.exportContext(export.ID, ownerID) + if !ok { + continue + } + if !nodeIsConnected(a.presentedNode(context.node)) { + continue + } + connected = append(connected, export) + } + + return connected +} + +func nodeHeartbeatIsFresh(lastSeenAt string, referenceTime time.Time, threshold time.Duration) bool { + lastSeenAt = strings.TrimSpace(lastSeenAt) + if threshold <= 0 || lastSeenAt == "" { + return false + } + + parsedLastSeenAt, err := time.Parse(time.RFC3339, lastSeenAt) + if err != nil { + return false + } + + referenceTime = referenceTime.UTC() + if parsedLastSeenAt.After(referenceTime) { + return true + } + + return referenceTime.Sub(parsedLastSeenAt) <= threshold +} + +func nodeIsConnected(node nasNode) bool { + return node.Status == "online" || node.Status == "degraded" +} + type nextcloudBackendStatus struct { Configured bool `json:"configured"` BaseURL string `json:"baseUrl"` diff --git a/apps/control-plane/cmd/control-plane/main.go b/apps/control-plane/cmd/control-plane/main.go index d89196e..298e55f 100644 --- a/apps/control-plane/cmd/control-plane/main.go +++ b/apps/control-plane/cmd/control-plane/main.go @@ -36,6 +36,16 @@ func newAppFromEnv(startedAt time.Time) (*app, error) { sessionTTL = parsedSessionTTL } + nodeOfflineThreshold := defaultNodeOfflineThreshold + rawNodeOfflineThreshold := strings.TrimSpace(env("BETTERNAS_NODE_OFFLINE_THRESHOLD", "2m")) + if rawNodeOfflineThreshold != "" { + parsedNodeOfflineThreshold, err := time.ParseDuration(rawNodeOfflineThreshold) + if err != nil { + return nil, err + } + nodeOfflineThreshold = parsedNodeOfflineThreshold + } + app, err := newApp( appConfig{ version: env("BETTERNAS_VERSION", "0.1.0-dev"), @@ -43,6 +53,7 @@ func newAppFromEnv(startedAt time.Time) (*app, error) { statePath: env("BETTERNAS_CONTROL_PLANE_STATE_PATH", ".state/control-plane/state.json"), dbPath: env("BETTERNAS_CONTROL_PLANE_DB_PATH", ".state/control-plane/betternas.db"), sessionTTL: sessionTTL, + nodeOfflineThreshold: nodeOfflineThreshold, registrationEnabled: env("BETTERNAS_REGISTRATION_ENABLED", "true") == "true", corsOrigin: env("BETTERNAS_CORS_ORIGIN", ""), }, diff --git a/apps/control-plane/cmd/control-plane/server.go b/apps/control-plane/cmd/control-plane/server.go index 810dbb0..e4057f9 100644 --- a/apps/control-plane/cmd/control-plane/server.go +++ b/apps/control-plane/cmd/control-plane/server.go @@ -36,6 +36,7 @@ func (a *app) handler() http.Handler { mux.HandleFunc("POST /api/v1/auth/login", a.handleAuthLogin) mux.HandleFunc("POST /api/v1/auth/logout", a.handleAuthLogout) mux.HandleFunc("GET /api/v1/auth/me", a.handleAuthMe) + mux.HandleFunc("GET /api/v1/nodes", a.handleNodesList) mux.HandleFunc("POST /api/v1/nodes/register", a.handleNodeRegister) mux.HandleFunc("POST /api/v1/nodes/{nodeId}/heartbeat", a.handleNodeHeartbeat) mux.HandleFunc("PUT /api/v1/nodes/{nodeId}/exports", a.handleNodeExports) @@ -74,6 +75,15 @@ func (a *app) handleVersion(w http.ResponseWriter, _ *http.Request) { }) } +func (a *app) handleNodesList(w http.ResponseWriter, r *http.Request) { + currentUser, ok := a.requireSessionUser(w, r) + if !ok { + return + } + + writeJSON(w, http.StatusOK, a.listNodes(currentUser.ID)) +} + func (a *app) handleNodeRegister(w http.ResponseWriter, r *http.Request) { currentUser, ok := a.requireSessionUser(w, r) if !ok { @@ -177,7 +187,7 @@ func (a *app) handleExportsList(w http.ResponseWriter, r *http.Request) { return } - writeJSON(w, http.StatusOK, a.store.listExports(currentUser.ID)) + writeJSON(w, http.StatusOK, a.listConnectedExports(currentUser.ID)) } func (a *app) handleMountProfileIssue(w http.ResponseWriter, r *http.Request) { @@ -202,6 +212,11 @@ func (a *app) handleMountProfileIssue(w http.ResponseWriter, r *http.Request) { http.Error(w, errExportNotFound.Error(), http.StatusNotFound) return } + context.node = a.presentedNode(context.node) + if !nodeIsConnected(context.node) { + http.Error(w, errMountTargetUnavailable.Error(), http.StatusServiceUnavailable) + return + } mountURL, err := buildMountURL(context) if err != nil { diff --git a/apps/control-plane/cmd/control-plane/sqlite_store.go b/apps/control-plane/cmd/control-plane/sqlite_store.go index a0050ed..0ae277e 100644 --- a/apps/control-plane/cmd/control-plane/sqlite_store.go +++ b/apps/control-plane/cmd/control-plane/sqlite_store.go @@ -346,6 +346,27 @@ func (s *sqliteStore) listExports(ownerID string) []storageExport { return exports } +func (s *sqliteStore) listNodes(ownerID string) []nasNode { + rows, err := s.db.Query("SELECT id, machine_id, owner_id, display_name, agent_version, status, last_seen_at, direct_address, relay_address FROM nodes WHERE owner_id = ? ORDER BY id", ownerID) + if err != nil { + return nil + } + defer rows.Close() + + var nodes []nasNode + for rows.Next() { + node := s.scanNode(rows) + if node.ID != "" { + nodes = append(nodes, node) + } + } + if nodes == nil { + nodes = []nasNode{} + } + + return nodes +} + func (s *sqliteStore) listExportsForNode(nodeID string) []storageExport { rows, err := s.db.Query("SELECT id, node_id, owner_id, label, path, mount_path, capacity_bytes FROM exports WHERE node_id = ? ORDER BY id", nodeID) if err != nil { @@ -401,15 +422,29 @@ func (s *sqliteStore) exportContext(exportID string, ownerID string) (exportCont } func (s *sqliteStore) nodeByID(nodeID string) (nasNode, bool) { + row := s.db.QueryRow( + "SELECT id, machine_id, owner_id, display_name, agent_version, status, last_seen_at, direct_address, relay_address FROM nodes WHERE id = ?", + nodeID) + n := s.scanNode(row) + if n.ID == "" { + return nasNode{}, false + } + + return n, true +} + +type sqliteNodeScanner interface { + Scan(dest ...any) error +} + +func (s *sqliteStore) scanNode(scanner sqliteNodeScanner) nasNode { var n nasNode var directAddr, relayAddr sql.NullString var lastSeenAt sql.NullString var ownerID sql.NullString - err := s.db.QueryRow( - "SELECT id, machine_id, owner_id, display_name, agent_version, status, last_seen_at, direct_address, relay_address FROM nodes WHERE id = ?", - nodeID).Scan(&n.ID, &n.MachineID, &ownerID, &n.DisplayName, &n.AgentVersion, &n.Status, &lastSeenAt, &directAddr, &relayAddr) + err := scanner.Scan(&n.ID, &n.MachineID, &ownerID, &n.DisplayName, &n.AgentVersion, &n.Status, &lastSeenAt, &directAddr, &relayAddr) if err != nil { - return nasNode{}, false + return nasNode{} } if ownerID.Valid { n.OwnerID = ownerID.String @@ -423,7 +458,7 @@ func (s *sqliteStore) nodeByID(nodeID string) (nasNode, bool) { if relayAddr.Valid { n.RelayAddress = &relayAddr.String } - return n, true + return n } func (s *sqliteStore) nodeAuthByMachineID(machineID string) (nodeAuthState, bool) { diff --git a/apps/control-plane/cmd/control-plane/store.go b/apps/control-plane/cmd/control-plane/store.go index 6ebb65a..23eb7e6 100644 --- a/apps/control-plane/cmd/control-plane/store.go +++ b/apps/control-plane/cmd/control-plane/store.go @@ -320,6 +320,25 @@ func (s *memoryStore) listExports(ownerID string) []storageExport { return exports } +func (s *memoryStore) listNodes(ownerID string) []nasNode { + s.mu.RLock() + defer s.mu.RUnlock() + + nodes := make([]nasNode, 0, len(s.state.NodesByID)) + for _, node := range s.state.NodesByID { + if node.OwnerID != ownerID { + continue + } + nodes = append(nodes, copyNasNode(node)) + } + + sort.Slice(nodes, func(i, j int) bool { + return nodes[i].ID < nodes[j].ID + }) + + return nodes +} + func (s *memoryStore) exportContext(exportID string, ownerID string) (exportContext, bool) { s.mu.RLock() defer s.mu.RUnlock() diff --git a/apps/control-plane/cmd/control-plane/store_iface.go b/apps/control-plane/cmd/control-plane/store_iface.go index 409e894..2449b96 100644 --- a/apps/control-plane/cmd/control-plane/store_iface.go +++ b/apps/control-plane/cmd/control-plane/store_iface.go @@ -9,6 +9,7 @@ type store interface { upsertExports(nodeID string, ownerID string, request nodeExportsRequest) ([]storageExport, error) recordHeartbeat(nodeID string, ownerID string, request nodeHeartbeatRequest) error listExports(ownerID string) []storageExport + listNodes(ownerID string) []nasNode exportContext(exportID string, ownerID string) (exportContext, bool) nodeByID(nodeID string) (nasNode, bool) diff --git a/apps/node-agent/cmd/node-agent/app.go b/apps/node-agent/cmd/node-agent/app.go index 679ea94..469663d 100644 --- a/apps/node-agent/cmd/node-agent/app.go +++ b/apps/node-agent/cmd/node-agent/app.go @@ -1,13 +1,16 @@ package main import ( + "context" "crypto/subtle" "encoding/json" "errors" "fmt" + "log" "net/http" "os" "strings" + "time" "golang.org/x/net/webdav" ) @@ -27,6 +30,7 @@ type app struct { authUsername string authPassword string exportMounts []exportMount + controlPlane *controlPlaneSession } type exportMount struct { @@ -69,17 +73,92 @@ func newAppFromEnv() (*app, error) { if err != nil { return nil, err } + var controlPlane *controlPlaneSession if strings.TrimSpace(env("BETTERNAS_CONTROL_PLANE_URL", "")) != "" { - if _, err := bootstrapNodeAgentFromEnv(exportPaths); err != nil { + session, err := bootstrapNodeAgentFromEnv(exportPaths) + if err != nil { return nil, err } + controlPlane = &session } - return newApp(appConfig{ + app, err := newApp(appConfig{ exportPaths: exportPaths, authUsername: authUsername, authPassword: authPassword, }) + if err != nil { + return nil, err + } + app.controlPlane = controlPlane + return app, nil +} + +func (a *app) startControlPlaneLoop(ctx context.Context) { + if a.controlPlane == nil { + return + } + + go runNodeHeartbeatLoop( + ctx, + &http.Client{Timeout: 5 * time.Second}, + a.controlPlane.controlPlaneURL, + a.controlPlane.sessionToken, + a.controlPlane.nodeID, + a.controlPlane.heartbeatInterval, + time.Now, + log.Default(), + ) +} + +func (a *app) controlPlaneEnabled() bool { + return a.controlPlane != nil +} + +func defaultNodeHeartbeatInterval() time.Duration { + return 30 * time.Second +} + +func heartbeatIntervalFromEnv() (time.Duration, error) { + rawInterval := strings.TrimSpace(env("BETTERNAS_NODE_HEARTBEAT_INTERVAL", "30s")) + if rawInterval == "" { + return defaultNodeHeartbeatInterval(), nil + } + + interval, err := time.ParseDuration(rawInterval) + if err != nil { + return 0, err + } + if interval <= 0 { + return 0, errors.New("BETTERNAS_NODE_HEARTBEAT_INTERVAL must be greater than zero") + } + + return interval, nil +} + +func runNodeHeartbeatLoop( + ctx context.Context, + client *http.Client, + baseURL string, + sessionToken string, + nodeID string, + interval time.Duration, + now func() time.Time, + logger *log.Logger, +) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := sendNodeHeartbeatAt(client, baseURL, sessionToken, nodeID, now().UTC()); err != nil && logger != nil { + logger.Printf("betternas node heartbeat failed: %v", err) + } + } + } } func exportPathsFromEnv() ([]string, error) { diff --git a/apps/node-agent/cmd/node-agent/control_plane.go b/apps/node-agent/cmd/node-agent/control_plane.go index 570bbec..dca7dda 100644 --- a/apps/node-agent/cmd/node-agent/control_plane.go +++ b/apps/node-agent/cmd/node-agent/control_plane.go @@ -14,8 +14,11 @@ import ( "time" ) -type bootstrapResult struct { - nodeID string +type controlPlaneSession struct { + nodeID string + controlPlaneURL string + sessionToken string + heartbeatInterval time.Duration } type nodeRegistrationRequest struct { @@ -58,19 +61,23 @@ type nodeHeartbeatRequest struct { LastSeenAt string `json:"lastSeenAt"` } -func bootstrapNodeAgentFromEnv(exportPaths []string) (bootstrapResult, error) { +func bootstrapNodeAgentFromEnv(exportPaths []string) (controlPlaneSession, error) { controlPlaneURL := strings.TrimSpace(env("BETTERNAS_CONTROL_PLANE_URL", "https://api.betternas.com")) if controlPlaneURL == "" { - return bootstrapResult{}, fmt.Errorf("BETTERNAS_CONTROL_PLANE_URL is required") + return controlPlaneSession{}, fmt.Errorf("BETTERNAS_CONTROL_PLANE_URL is required") } username, err := requiredEnv("BETTERNAS_USERNAME") if err != nil { - return bootstrapResult{}, err + return controlPlaneSession{}, err } password, err := requiredEnv("BETTERNAS_PASSWORD") if err != nil { - return bootstrapResult{}, err + return controlPlaneSession{}, err + } + heartbeatInterval, err := heartbeatIntervalFromEnv() + if err != nil { + return controlPlaneSession{}, err } machineID := strings.TrimSpace(env("BETTERNAS_NODE_MACHINE_ID", defaultNodeMachineID(username))) @@ -82,7 +89,7 @@ func bootstrapNodeAgentFromEnv(exportPaths []string) (bootstrapResult, error) { client := &http.Client{Timeout: 5 * time.Second} sessionToken, err := loginWithControlPlane(client, controlPlaneURL, username, password) if err != nil { - return bootstrapResult{}, err + return controlPlaneSession{}, err } registration, err := registerNodeWithControlPlane(client, controlPlaneURL, sessionToken, nodeRegistrationRequest{ @@ -93,17 +100,22 @@ func bootstrapNodeAgentFromEnv(exportPaths []string) (bootstrapResult, error) { RelayAddress: optionalEnvPointer("BETTERNAS_NODE_RELAY_ADDRESS"), }) if err != nil { - return bootstrapResult{}, err + return controlPlaneSession{}, err } if err := syncNodeExportsWithControlPlane(client, controlPlaneURL, sessionToken, registration.ID, buildStorageExportInputs(exportPaths)); err != nil { - return bootstrapResult{}, err + return controlPlaneSession{}, err } - if err := sendNodeHeartbeat(client, controlPlaneURL, sessionToken, registration.ID); err != nil { - return bootstrapResult{}, err + if err := sendNodeHeartbeatAt(client, controlPlaneURL, sessionToken, registration.ID, time.Now().UTC()); err != nil { + return controlPlaneSession{}, err } - return bootstrapResult{nodeID: registration.ID}, nil + return controlPlaneSession{ + nodeID: registration.ID, + controlPlaneURL: controlPlaneURL, + sessionToken: sessionToken, + heartbeatInterval: heartbeatInterval, + }, nil } func loginWithControlPlane(client *http.Client, baseURL string, username string, password string) (string, error) { @@ -168,10 +180,14 @@ func syncNodeExportsWithControlPlane(client *http.Client, baseURL string, token } func sendNodeHeartbeat(client *http.Client, baseURL string, token string, nodeID string) error { + return sendNodeHeartbeatAt(client, baseURL, token, nodeID, time.Now().UTC()) +} + +func sendNodeHeartbeatAt(client *http.Client, baseURL string, token string, nodeID string, at time.Time) error { response, err := doControlPlaneJSONRequest(client, http.MethodPost, controlPlaneEndpoint(baseURL, "/api/v1/nodes/"+nodeID+"/heartbeat"), token, nodeHeartbeatRequest{ NodeID: nodeID, Status: "online", - LastSeenAt: time.Now().UTC().Format(time.RFC3339), + LastSeenAt: at.UTC().Format(time.RFC3339), }) if err != nil { return err diff --git a/apps/node-agent/cmd/node-agent/control_plane_test.go b/apps/node-agent/cmd/node-agent/control_plane_test.go new file mode 100644 index 0000000..884ec56 --- /dev/null +++ b/apps/node-agent/cmd/node-agent/control_plane_test.go @@ -0,0 +1,97 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "log" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" +) + +func TestRunNodeHeartbeatLoopSendsRecurringHeartbeats(t *testing.T) { + t.Parallel() + + var mu sync.Mutex + var heartbeats []nodeHeartbeatRequest + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/v1/nodes/dev-node/heartbeat" { + http.NotFound(w, r) + return + } + + body, err := io.ReadAll(r.Body) + if err != nil { + t.Fatalf("read heartbeat body: %v", err) + } + _ = r.Body.Close() + + var heartbeat nodeHeartbeatRequest + if err := json.Unmarshal(body, &heartbeat); err != nil { + t.Fatalf("decode heartbeat body: %v", err) + } + + mu.Lock() + heartbeats = append(heartbeats, heartbeat) + mu.Unlock() + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + done := make(chan struct{}) + go func() { + runNodeHeartbeatLoop( + ctx, + server.Client(), + server.URL, + "session-token", + "dev-node", + 10*time.Millisecond, + func() time.Time { return time.Date(2025, time.January, 1, 12, 0, 0, 0, time.UTC) }, + log.New(io.Discard, "", 0), + ) + close(done) + }() + + deadline := time.Now().Add(500 * time.Millisecond) + for { + mu.Lock() + count := len(heartbeats) + mu.Unlock() + if count >= 2 { + break + } + if time.Now().After(deadline) { + t.Fatalf("expected recurring heartbeats, got %d", count) + } + time.Sleep(10 * time.Millisecond) + } + + cancel() + + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("heartbeat loop did not stop after context cancellation") + } + + mu.Lock() + defer mu.Unlock() + for _, heartbeat := range heartbeats { + if heartbeat.NodeID != "dev-node" { + t.Fatalf("expected node ID dev-node, got %q", heartbeat.NodeID) + } + if heartbeat.Status != "online" { + t.Fatalf("expected status online, got %q", heartbeat.Status) + } + if heartbeat.LastSeenAt != "2025-01-01T12:00:00Z" { + t.Fatalf("expected fixed lastSeenAt, got %q", heartbeat.LastSeenAt) + } + } +} diff --git a/apps/node-agent/cmd/node-agent/main.go b/apps/node-agent/cmd/node-agent/main.go index 1b7e84c..1842e33 100644 --- a/apps/node-agent/cmd/node-agent/main.go +++ b/apps/node-agent/cmd/node-agent/main.go @@ -1,9 +1,12 @@ package main import ( + "context" "log" "net/http" "os" + "os/signal" + "syscall" "time" ) @@ -14,6 +17,19 @@ func main() { log.Fatal(err) } + controlPlaneCtx, stopControlPlane := context.WithCancel(context.Background()) + defer stopControlPlane() + if app.controlPlaneEnabled() { + app.startControlPlaneLoop(controlPlaneCtx) + } + + signalContext, stopSignals := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stopSignals() + go func() { + <-signalContext.Done() + stopControlPlane() + }() + server := &http.Server{ Addr: ":" + port, Handler: app.handler(), diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index abe1a38..44bff34 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -12,11 +12,13 @@ import { import { isAuthenticated, listExports, + listNodes, issueMountProfile, logout, getMe, type StorageExport, type MountProfile, + type NasNode, type User, ApiError, } from "@/lib/api"; @@ -38,6 +40,7 @@ import { CopyField } from "./copy-field"; export default function Home() { const router = useRouter(); const [user, setUser] = useState(null); + const [nodes, setNodes] = useState([]); const [exports, setExports] = useState([]); const [selectedExportId, setSelectedExportId] = useState(null); const [mountProfile, setMountProfile] = useState(null); @@ -52,8 +55,13 @@ export default function Home() { async function load() { try { - const [me, exps] = await Promise.all([getMe(), listExports()]); + const [me, registeredNodes, exps] = await Promise.all([ + getMe(), + listNodes(), + listExports(), + ]); setUser(me); + setNodes(registeredNodes); setExports(exps); } catch (err) { if (err instanceof ApiError && err.status === 401) { @@ -100,6 +108,13 @@ export default function Home() { const selectedExport = selectedExportId ? (exports.find((e) => e.id === selectedExportId) ?? null) : null; + const onlineNodeCount = nodes.filter((node) => node.status === "online").length; + const degradedNodeCount = nodes.filter( + (node) => node.status === "degraded", + ).length; + const offlineNodeCount = nodes.filter( + (node) => node.status === "offline", + ).length; return (
@@ -135,6 +150,9 @@ export default function Home() { {exports.length === 1 ? "1 export" : `${exports.length} exports`} + + {nodes.length === 1 ? "1 node" : `${nodes.length} nodes`} + {user && ( @@ -172,12 +190,107 @@ export default function Home() { )} + + + Nodes + + Machines registered to your account and their current connection + state. + + +
+ + {onlineNodeCount} online + + {degradedNodeCount > 0 && ( + + {degradedNodeCount} degraded + + )} + {offlineNodeCount > 0 && ( + {offlineNodeCount} offline + )} +
+
+
+ + {nodes.length === 0 ? ( +
+ +

+ No nodes registered yet. Install and start the node agent on + the machine that owns your files. +

+
+ ) : ( +
+ {nodes.map((node) => ( +
+
+
+ + {node.displayName} + + + {node.machineId} + +
+ + {node.status} + +
+ +
+
+
+ Node ID +
+
+ {node.id} +
+
+
+
+ Last seen +
+
+ {formatTimestamp(node.lastSeenAt)} +
+
+
+
+ Address +
+
+ {node.directAddress ?? node.relayAddress ?? "Unavailable"} +
+
+
+
+ ))} +
+ )} +
+
+
Exports - Storage exports registered with this control plane. + Connected storage exports that are currently mountable. @@ -192,8 +305,9 @@ export default function Home() {

- No exports registered yet. Start the node agent and connect - it to this control plane. + {nodes.length === 0 + ? "No exports registered yet. Start the node agent and connect it to this control plane." + : "No connected exports right now. Start the node agent or wait for a disconnected node to reconnect."}

) : ( @@ -371,3 +485,17 @@ export default function Home() {
); } + +function formatTimestamp(value: string): string { + const trimmedValue = value.trim(); + if (trimmedValue === "") { + return "Never"; + } + + const parsed = new Date(trimmedValue); + if (Number.isNaN(parsed.getTime())) { + return trimmedValue; + } + + return parsed.toLocaleString(); +} diff --git a/apps/web/lib/api.ts b/apps/web/lib/api.ts index 448527a..6b20980 100644 --- a/apps/web/lib/api.ts +++ b/apps/web/lib/api.ts @@ -1,5 +1,16 @@ const API_URL = process.env.NEXT_PUBLIC_BETTERNAS_API_URL || ""; +export interface NasNode { + id: string; + machineId: string; + displayName: string; + agentVersion: string; + status: "online" | "offline" | "degraded"; + lastSeenAt: string; + directAddress: string | null; + relayAddress: string | null; +} + export interface StorageExport { id: string; nasNodeId: string; @@ -146,6 +157,10 @@ export async function getMe(): Promise { return apiFetch("/api/v1/auth/me"); } +export async function listNodes(): Promise { + return apiFetch("/api/v1/nodes"); +} + export async function listExports(): Promise { return apiFetch("/api/v1/exports"); } diff --git a/packages/contracts/openapi/betternas.v1.yaml b/packages/contracts/openapi/betternas.v1.yaml index 1902c06..b9b5381 100644 --- a/packages/contracts/openapi/betternas.v1.yaml +++ b/packages/contracts/openapi/betternas.v1.yaml @@ -18,6 +18,22 @@ paths: responses: "200": description: Control-plane version + /api/v1/nodes: + get: + operationId: listNodes + security: + - UserSession: [] + responses: + "200": + description: Node list + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/NasNode" + "401": + description: Unauthorized /api/v1/nodes/register: post: operationId: registerNode diff --git a/packages/contracts/src/foundation.ts b/packages/contracts/src/foundation.ts index 04e513b..21514e4 100644 --- a/packages/contracts/src/foundation.ts +++ b/packages/contracts/src/foundation.ts @@ -1,4 +1,5 @@ export const FOUNDATION_API_ROUTES = { + listNodes: "/api/v1/nodes", registerNode: "/api/v1/nodes/register", nodeHeartbeat: "/api/v1/nodes/:nodeId/heartbeat", nodeExports: "/api/v1/nodes/:nodeId/exports", diff --git a/scripts/install-betternas-node.sh b/scripts/install-betternas-node.sh index 280d536..4d15ea8 100755 --- a/scripts/install-betternas-node.sh +++ b/scripts/install-betternas-node.sh @@ -37,7 +37,8 @@ case "$arch_name" in ;; esac -archive_name="${binary_name}_${version}_${os}_${arch}.tar.gz" +version_stripped="${version#v}" +archive_name="${binary_name}_${version_stripped}_${os}_${arch}.tar.gz" download_url="${download_base_url}/${version}/${archive_name}" tmp_dir="$(mktemp -d)" From 171a682f6ad51b3a815020536ce29ad7e802f007 Mon Sep 17 00:00:00 2001 From: Harivansh Rathi Date: Wed, 1 Apr 2026 21:10:13 -0400 Subject: [PATCH 03/13] tests --- .../cmd/control-plane/server_test.go | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/apps/control-plane/cmd/control-plane/server_test.go b/apps/control-plane/cmd/control-plane/server_test.go index adf5741..7456a8b 100644 --- a/apps/control-plane/cmd/control-plane/server_test.go +++ b/apps/control-plane/cmd/control-plane/server_test.go @@ -494,6 +494,69 @@ func TestControlPlaneMountProfilesUseRelayAndPreserveBasePath(t *testing.T) { postJSONAuthStatus(t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: "dev-export-2"}, http.StatusServiceUnavailable) } +func TestControlPlaneOfflineNodesAreListedButHiddenFromMountableExports(t *testing.T) { + t.Parallel() + + app, server := newTestControlPlaneServer(t, appConfig{ + version: "test-version", + nodeOfflineThreshold: time.Minute, + }) + defer server.Close() + + directAddress := "http://nas.local:8090" + registration := registerNode(t, server.Client(), server.URL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{ + MachineID: "machine-offline-filter", + DisplayName: "Offline Filter NAS", + AgentVersion: "1.2.3", + DirectAddress: &directAddress, + RelayAddress: nil, + }) + syncNodeExports(t, server.Client(), registration.NodeToken, server.URL+"/api/v1/nodes/"+registration.Node.ID+"/exports", nodeExportsRequest{ + Exports: []storageExportInput{{ + Label: "Docs", + Path: "/srv/docs", + MountPath: "/dav/", + Protocols: []string{"webdav"}, + CapacityBytes: nil, + Tags: []string{}, + }}, + }) + + initialNodes := getJSONAuth[[]nasNode](t, server.Client(), testClientToken, server.URL+"/api/v1/nodes") + if len(initialNodes) != 1 { + t.Fatalf("expected 1 node before staleness, got %d", len(initialNodes)) + } + if initialNodes[0].Status != "online" { + t.Fatalf("expected node to start online, got %q", initialNodes[0].Status) + } + + initialExports := getJSONAuth[[]storageExport](t, server.Client(), testClientToken, server.URL+"/api/v1/exports") + if len(initialExports) != 1 { + t.Fatalf("expected 1 connected export before staleness, got %d", len(initialExports)) + } + + app.now = func() time.Time { + return testControlPlaneNow.Add(2 * time.Minute) + } + + nodes := getJSONAuth[[]nasNode](t, server.Client(), testClientToken, server.URL+"/api/v1/nodes") + if len(nodes) != 1 { + t.Fatalf("expected 1 node after staleness, got %d", len(nodes)) + } + if nodes[0].Status != "offline" { + t.Fatalf("expected stale node to be offline, got %q", nodes[0].Status) + } + + exports := getJSONAuth[[]storageExport](t, server.Client(), testClientToken, server.URL+"/api/v1/exports") + if len(exports) != 0 { + t.Fatalf("expected stale node exports to be hidden, got %d", len(exports)) + } + + postJSONAuthStatus(t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{ + ExportID: "dev-export", + }, http.StatusServiceUnavailable) +} + func TestControlPlaneCloudProfilesRequireConfiguredBaseURLAndExistingExport(t *testing.T) { t.Parallel() @@ -745,6 +808,8 @@ func TestControlPlaneRejectsInvalidRequestsAndEnforcesAuth(t *testing.T) { LastSeenAt: "2025-01-02T03:04:05Z", }, http.StatusNotFound) + getStatusWithAuth(t, server.Client(), "", server.URL+"/api/v1/nodes", http.StatusUnauthorized) + getStatusWithAuth(t, server.Client(), "wrong-client-token", server.URL+"/api/v1/nodes", http.StatusUnauthorized) getStatusWithAuth(t, server.Client(), "", server.URL+"/api/v1/exports", http.StatusUnauthorized) getStatusWithAuth(t, server.Client(), "wrong-client-token", server.URL+"/api/v1/exports", http.StatusUnauthorized) From f6069a024ad209b0908995d39e3540a3cc6cbebc Mon Sep 17 00:00:00 2001 From: Harivansh Rathi Date: Wed, 1 Apr 2026 21:18:08 -0400 Subject: [PATCH 04/13] ui --- .../control-plane/runtime_integration_test.go | 15 +- .../control-plane/cmd/control-plane/server.go | 10 +- .../cmd/control-plane/server_test.go | 20 +- .../cmd/control-plane/sqlite_store_test.go | 4 +- apps/node-agent/cmd/node-agent/app.go | 14 +- apps/web/app/landing/page.tsx | 527 ++++++++++++++++++ apps/web/app/login/page.tsx | 205 +++---- 7 files changed, 675 insertions(+), 120 deletions(-) create mode 100644 apps/web/app/landing/page.tsx diff --git a/apps/control-plane/cmd/control-plane/runtime_integration_test.go b/apps/control-plane/cmd/control-plane/runtime_integration_test.go index 0fb13ac..2e93f6e 100644 --- a/apps/control-plane/cmd/control-plane/runtime_integration_test.go +++ b/apps/control-plane/cmd/control-plane/runtime_integration_test.go @@ -56,8 +56,9 @@ func TestControlPlaneBinaryMountLoopIntegration(t *testing.T) { mount := postJSONAuth[mountProfile](t, client, controlPlane.sessionToken, controlPlane.baseURL+"/api/v1/mount-profiles/issue", mountProfileRequest{ ExportID: export.ID, }) - if mount.MountURL != nodeAgent.baseURL+defaultWebDAVPath { - t.Fatalf("expected runtime mount URL %q, got %q", nodeAgent.baseURL+defaultWebDAVPath, mount.MountURL) + expectedMountURL := nodeAgent.baseURL + defaultWebDAVPath + runtimeUsername + "/" + if mount.MountURL != expectedMountURL { + t.Fatalf("expected runtime mount URL %q, got %q", expectedMountURL, mount.MountURL) } if mount.Credential.Mode != mountCredentialModeBasicAuth { t.Fatalf("expected mount credential mode %q, got %q", mountCredentialModeBasicAuth, mount.Credential.Mode) @@ -103,11 +104,13 @@ func TestControlPlaneBinaryMultiExportProfilesStayDistinct(t *testing.T) { if firstMount.MountURL == secondMount.MountURL { t.Fatalf("expected distinct runtime mount URLs, got %q", firstMount.MountURL) } - if firstMount.MountURL != nodeAgent.baseURL+firstMountPath { - t.Fatalf("expected first runtime mount URL %q, got %q", nodeAgent.baseURL+firstMountPath, firstMount.MountURL) + expectedFirstMountURL := nodeAgent.baseURL + firstMountPath + runtimeUsername + "/" + expectedSecondMountURL := nodeAgent.baseURL + secondMountPath + runtimeUsername + "/" + if firstMount.MountURL != expectedFirstMountURL { + t.Fatalf("expected first runtime mount URL %q, got %q", expectedFirstMountURL, firstMount.MountURL) } - if secondMount.MountURL != nodeAgent.baseURL+secondMountPath { - t.Fatalf("expected second runtime mount URL %q, got %q", nodeAgent.baseURL+secondMountPath, secondMount.MountURL) + if secondMount.MountURL != expectedSecondMountURL { + t.Fatalf("expected second runtime mount URL %q, got %q", expectedSecondMountURL, secondMount.MountURL) } assertHTTPStatusWithBasicAuth(t, client, "PROPFIND", firstMount.MountURL, controlPlane.username, controlPlane.password, http.StatusMultiStatus) diff --git a/apps/control-plane/cmd/control-plane/server.go b/apps/control-plane/cmd/control-plane/server.go index e4057f9..092bedc 100644 --- a/apps/control-plane/cmd/control-plane/server.go +++ b/apps/control-plane/cmd/control-plane/server.go @@ -218,7 +218,7 @@ func (a *app) handleMountProfileIssue(w http.ResponseWriter, r *http.Request) { return } - mountURL, err := buildMountURL(context) + mountURL, err := buildMountURL(context, currentUser.Username) if err != nil { http.Error(w, err.Error(), http.StatusServiceUnavailable) return @@ -720,13 +720,17 @@ func hasConfiguredNextcloudBaseURL(baseURL string) bool { return err == nil } -func buildMountURL(context exportContext) (string, error) { +func buildMountURL(context exportContext, username string) (string, error) { address, ok := firstAddress(context.node.DirectAddress, context.node.RelayAddress) if !ok { return "", errMountTargetUnavailable } - mountURL, err := buildAbsoluteHTTPURLWithPath(address, mountProfilePathForExport(context.export.MountPath)) + basePath := mountProfilePathForExport(context.export.MountPath) + // Append the username so Finder uses it as the volume name in the sidebar. + userScopedPath := path.Join(basePath, username) + "/" + + mountURL, err := buildAbsoluteHTTPURLWithPath(address, userScopedPath) if err != nil { return "", errMountTargetUnavailable } diff --git a/apps/control-plane/cmd/control-plane/server_test.go b/apps/control-plane/cmd/control-plane/server_test.go index 7456a8b..c714a89 100644 --- a/apps/control-plane/cmd/control-plane/server_test.go +++ b/apps/control-plane/cmd/control-plane/server_test.go @@ -162,8 +162,8 @@ func TestControlPlaneRegistrationProfilesAndHeartbeat(t *testing.T) { if mount.DisplayName != "Photos" { t.Fatalf("expected mount display name Photos, got %q", mount.DisplayName) } - if mount.MountURL != "http://nas.local:8090/dav/" { - t.Fatalf("expected mount URL %q, got %q", "http://nas.local:8090/dav/", mount.MountURL) + if mount.MountURL != "http://nas.local:8090/dav/fixture/" { + t.Fatalf("expected mount URL %q, got %q", "http://nas.local:8090/dav/fixture/", mount.MountURL) } if mount.Readonly { t.Fatal("expected mount profile to be read-write") @@ -415,11 +415,11 @@ func TestControlPlaneProfilesRemainExportSpecificForConfiguredMountPaths(t *test if docsMount.MountURL == mediaMount.MountURL { t.Fatalf("expected distinct mount URLs for configured export paths, got %q", docsMount.MountURL) } - if docsMount.MountURL != "http://nas.local:8090/dav/exports/docs/" { - t.Fatalf("expected docs mount URL %q, got %q", "http://nas.local:8090/dav/exports/docs/", docsMount.MountURL) + if docsMount.MountURL != "http://nas.local:8090/dav/exports/docs/fixture/" { + t.Fatalf("expected docs mount URL %q, got %q", "http://nas.local:8090/dav/exports/docs/fixture/", docsMount.MountURL) } - if mediaMount.MountURL != "http://nas.local:8090/dav/exports/media/" { - t.Fatalf("expected media mount URL %q, got %q", "http://nas.local:8090/dav/exports/media/", mediaMount.MountURL) + if mediaMount.MountURL != "http://nas.local:8090/dav/exports/media/fixture/" { + t.Fatalf("expected media mount URL %q, got %q", "http://nas.local:8090/dav/exports/media/fixture/", mediaMount.MountURL) } docsCloud := postJSONAuth[cloudProfile](t, server.Client(), testClientToken, server.URL+"/api/v1/cloud-profiles/issue", cloudProfileRequest{ @@ -469,8 +469,8 @@ func TestControlPlaneMountProfilesUseRelayAndPreserveBasePath(t *testing.T) { }) mount := postJSONAuth[mountProfile](t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: "dev-export"}) - if mount.MountURL != "https://nas.example.test/control/dav/relay/" { - t.Fatalf("expected relay mount URL %q, got %q", "https://nas.example.test/control/dav/relay/", mount.MountURL) + if mount.MountURL != "https://nas.example.test/control/dav/relay/fixture/" { + t.Fatalf("expected relay mount URL %q, got %q", "https://nas.example.test/control/dav/relay/fixture/", mount.MountURL) } registration = registerNode(t, server.Client(), server.URL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{ @@ -648,8 +648,8 @@ func TestControlPlanePersistsRegistryAcrossAppRestart(t *testing.T) { } mount := postJSONAuth[mountProfile](t, secondServer.Client(), testClientToken, secondServer.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: exports[0].ID}) - if mount.MountURL != "http://nas.local:8090/dav/persisted/" { - t.Fatalf("expected persisted mount URL %q, got %q", "http://nas.local:8090/dav/persisted/", mount.MountURL) + if mount.MountURL != "http://nas.local:8090/dav/persisted/fixture/" { + t.Fatalf("expected persisted mount URL %q, got %q", "http://nas.local:8090/dav/persisted/fixture/", mount.MountURL) } reRegistration := registerNode(t, secondServer.Client(), secondServer.URL+"/api/v1/nodes/register", registration.NodeToken, nodeRegistrationRequest{ diff --git a/apps/control-plane/cmd/control-plane/sqlite_store_test.go b/apps/control-plane/cmd/control-plane/sqlite_store_test.go index 1dda815..0be0d53 100644 --- a/apps/control-plane/cmd/control-plane/sqlite_store_test.go +++ b/apps/control-plane/cmd/control-plane/sqlite_store_test.go @@ -102,8 +102,8 @@ func TestSQLiteRegistrationAndExports(t *testing.T) { } mount := postJSONAuth[mountProfile](t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: "dev-export"}) - if mount.MountURL != "http://nas.local:8090/dav/docs/" { - t.Fatalf("expected mount URL %q, got %q", "http://nas.local:8090/dav/docs/", mount.MountURL) + if mount.MountURL != "http://nas.local:8090/dav/docs/fixture/" { + t.Fatalf("expected mount URL %q, got %q", "http://nas.local:8090/dav/docs/fixture/", mount.MountURL) } } diff --git a/apps/node-agent/cmd/node-agent/app.go b/apps/node-agent/cmd/node-agent/app.go index 469663d..1a87468 100644 --- a/apps/node-agent/cmd/node-agent/app.go +++ b/apps/node-agent/cmd/node-agent/app.go @@ -234,12 +234,24 @@ func (a *app) handler() http.Handler { for _, mount := range a.exportMounts { mountPathPrefix := strings.TrimSuffix(mount.mountPath, "/") fs := webdav.Dir(mount.exportPath) + lockSystem := webdav.NewMemLS() dav := &webdav.Handler{ Prefix: mountPathPrefix, FileSystem: fs, - LockSystem: webdav.NewMemLS(), + LockSystem: lockSystem, } mux.Handle(mount.mountPath, a.requireDAVAuth(mount, finderCompatible(dav, fs, mountPathPrefix))) + + // Register a username-scoped handler at {mountPath}{username}/ so + // Finder shows the username as the volume name in the sidebar. + userScopedPath := mount.mountPath + a.authUsername + "/" + userScopedPrefix := strings.TrimSuffix(userScopedPath, "/") + userDav := &webdav.Handler{ + Prefix: userScopedPrefix, + FileSystem: fs, + LockSystem: lockSystem, + } + mux.Handle(userScopedPath, a.requireDAVAuth(mount, finderCompatible(userDav, fs, userScopedPrefix))) } return mux diff --git a/apps/web/app/landing/page.tsx b/apps/web/app/landing/page.tsx new file mode 100644 index 0000000..3dba059 --- /dev/null +++ b/apps/web/app/landing/page.tsx @@ -0,0 +1,527 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; + +/* ------------------------------------------------------------------ */ +/* README content (rendered as simple markdown-ish HTML) */ +/* ------------------------------------------------------------------ */ + +const README_LINES = [ + { tag: "h1", text: "betterNAS" }, + { + tag: "p", + text: "betterNAS is a self-hostable WebDAV stack for mounting NAS exports in Finder.", + }, + { tag: "p", text: "The default product shape is:" }, + { + tag: "ul", + items: [ + "node-service serves the real files from the NAS over WebDAV", + "control-server owns auth, nodes, exports, grants, and mount profile issuance", + "web control plane lets the user manage the NAS and get mount instructions", + "macOS client starts as native Finder WebDAV mounting, with a thin helper later", + ], + }, + { + tag: "p", + text: "For now, the whole stack should be able to run on the user's NAS device.", + }, + { tag: "h2", text: "Current repo shape" }, + { + tag: "ul", + items: [ + "apps/node-agent - NAS-side Go runtime and WebDAV server", + "apps/control-plane - Go backend for auth, registry, and mount profile issuance", + "apps/web - Next.js web control plane", + "apps/nextcloud-app - optional Nextcloud adapter, not the product center", + "packages/contracts - canonical shared contracts", + "infra/docker - self-hosted local stack", + ], + }, + { tag: "h2", text: "Verify" }, + { tag: "code", text: "pnpm verify" }, + { tag: "h2", text: "Current end-to-end slice" }, + { + tag: "ol", + items: [ + "Boot the stack with pnpm stack:up", + "Verify it with pnpm stack:verify", + "Get the WebDAV mount profile from the control plane", + "Mount it in Finder with the issued credentials", + ], + }, + { tag: "h2", text: "Product boundary" }, + { + tag: "p", + text: "The default betterNAS product is self-hosted and WebDAV-first. Nextcloud remains optional and secondary.", + }, +] as const; + +/* ------------------------------------------------------------------ */ +/* Icons */ +/* ------------------------------------------------------------------ */ + +function GithubIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +function ClockIcon() { + return ( + + + + + ); +} + +function SharedIcon() { + return ( + + + + + + ); +} + +function LibraryIcon() { + return ( + + + + ); +} + +function AppIcon() { + return ( + + + + ); +} + +function DesktopIcon() { + return ( + + + + + ); +} + +function DownloadIcon() { + return ( + + + + ); +} + +function DocumentsIcon() { + return ( + + + + + ); +} + +function FolderIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +function CloudIcon() { + return ( + + + + ); +} + +function HomeIcon() { + return ( + + + + ); +} + +function NetworkIcon() { + return ( + + + + + ); +} + +function AirdropIcon() { + return ( + + + + + ); +} + +/* ------------------------------------------------------------------ */ +/* README modal (Quick Look style) */ +/* ------------------------------------------------------------------ */ + +function ReadmeModal({ onClose }: { onClose: () => void }) { + return ( +
+
e.stopPropagation()} + > + {/* titlebar */} +
+
+
+ + README.md + + +
+ + {/* body */} +
+
+ {README_LINES.map((block, i) => { + if (block.tag === "h1") + return ( +

+ {block.text} +

+ ); + if (block.tag === "h2") + return ( +

+ {block.text} +

+ ); + if (block.tag === "p") + return ( +

+ {block.text} +

+ ); + if (block.tag === "code") + return ( +
+                    {block.text}
+                  
+ ); + if (block.tag === "ul") + return ( +
    + {block.items.map((item, j) => ( +
  • + + {item.split(" - ")[0]} + + {item.includes(" - ") && ( + + {" "} + - {item.split(" - ").slice(1).join(" - ")} + + )} +
  • + ))} +
+ ); + if (block.tag === "ol") + return ( +
    + {block.items.map((item, j) => ( +
  1. + {item} +
  2. + ))} +
+ ); + return null; + })} +
+
+
+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Finder sidebar item */ +/* ------------------------------------------------------------------ */ + +function SidebarItem({ + icon, + label, + active, + accent, + onClick, +}: { + icon: React.ReactNode; + label: string; + active?: boolean; + accent?: string; + onClick?: () => void; +}) { + return ( + + ); +} + +/* ------------------------------------------------------------------ */ +/* Finder file grid item (folder) */ +/* ------------------------------------------------------------------ */ + +function GridFolder({ + name, + itemCount, + onClick, +}: { + name: string; + itemCount?: number; + onClick?: () => void; +}) { + return ( + + ); +} + +/* ------------------------------------------------------------------ */ +/* Finder file grid item (file) */ +/* ------------------------------------------------------------------ */ + +function GridFile({ + name, + meta, + onClick, +}: { + name: string; + meta?: string; + onClick?: () => void; +}) { + return ( + + ); +} + +/* ------------------------------------------------------------------ */ +/* Main page */ +/* ------------------------------------------------------------------ */ + +export default function LandingPage() { + const [readmeOpen, setReadmeOpen] = useState(false); + const [selectedSidebar, setSelectedSidebar] = useState("DAV"); + + return ( +
+ {/* ---- header ---- */} +
+
+ + Sign in + + + + +
+
+ + {/* ---- finder ---- */} +
+
+ {/* titlebar */} +
+
+ + + +
+ +
+ DAV +
+ + {/* forward/back placeholders */} +
+ + + + + + +
+
+ + {/* content area */} +
+ {/* ---- sidebar ---- */} +
+ {/* Favorites */} +

+ Favorites +

+ } label="Recents" /> + } label="Shared" /> + } label="Library" /> + } label="Applications" /> + } label="Desktop" /> + } label="Downloads" /> + } label="Documents" /> + } label="GitHub" /> + + {/* Locations */} +

+ Locations +

+ } label="rathi" /> + } label="hari-macbook-pro" /> + } + label="DAV" + active={selectedSidebar === "DAV"} + accent="text-[#65a2f8]" + onClick={() => setSelectedSidebar("DAV")} + /> + } label="AirDrop" /> +
+ + {/* ---- file grid ---- */} +
+ {/* toolbar */} +
+
+ + DAV + / + exports +
+
+ + + + + + + + + +
+
+ + {/* files */} +
+
+ + + + + + setReadmeOpen(true)} + /> +
+
+ + {/* statusbar */} +
+ 5 folders, 1 file + 847 GB available +
+
+
+
+
+ + {/* ---- readme modal ---- */} + {readmeOpen && setReadmeOpen(false)} />} +
+ ); +} diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx index 73eb23d..23d117e 100644 --- a/apps/web/app/login/page.tsx +++ b/apps/web/app/login/page.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; +import Link from "next/link"; import { login, register, ApiError } from "@/lib/api"; import { Button } from "@/components/ui/button"; import { @@ -45,108 +46,116 @@ export default function LoginPage() { return (
- - -

- betterNAS -

- - {mode === "login" ? "Sign in" : "Create account"} - - - {mode === "login" - ? "Sign in to your betterNAS control plane with the same credentials you use for the node agent and Finder." - : "Create your betterNAS account. You will use the same username and password for the web app, node agent, and Finder."} - -
- -
-
- - setUsername(e.target.value)} - className="rounded-lg border border-input bg-background px-3 py-2 text-sm outline-none ring-ring focus:ring-2" - placeholder="admin" - /> -
+
+ + + + + -
- - setPassword(e.target.value)} - className="rounded-lg border border-input bg-background px-3 py-2 text-sm outline-none ring-ring focus:ring-2" - /> -
+ + + + {mode === "login" ? "Sign in" : "Create account"} + + + {mode === "login" + ? "Use the same credentials as your node agent and Finder." + : "This account works across the web UI, node agent, and Finder."} + + + + +
+ + setUsername(e.target.value)} + className="rounded-lg border border-input bg-background px-3 py-2 text-sm outline-none ring-ring focus:ring-2" + placeholder="admin" + /> +
- {error &&

{error}

} +
+ + setPassword(e.target.value)} + className="rounded-lg border border-input bg-background px-3 py-2 text-sm outline-none ring-ring focus:ring-2" + /> +
- + {error &&

{error}

} -

- {mode === "login" ? ( - <> - No account?{" "} - - - ) : ( - <> - Already have an account?{" "} - - - )} -

- -
-
+ + +

+ {mode === "login" ? ( + <> + No account?{" "} + + + ) : ( + <> + Already have an account?{" "} + + + )} +

+ + + +
); } From 43ef276976ee8c75ee4ef1db0314962294e99275 Mon Sep 17 00:00:00 2001 From: Harivansh Rathi Date: Wed, 1 Apr 2026 22:10:39 -0400 Subject: [PATCH 05/13] web repo --- CLAUDE.md | 9 + README.md | 120 +------- apps/web/app/app/page.tsx | 283 +++++++++++++++++++ apps/web/app/docs/page.tsx | 181 ++++++++++++ apps/web/app/landing/page.tsx | 117 ++------ apps/web/app/login/page.tsx | 4 +- apps/web/app/page.tsx | 502 +--------------------------------- 7 files changed, 508 insertions(+), 708 deletions(-) create mode 100644 apps/web/app/app/page.tsx create mode 100644 apps/web/app/docs/page.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 67e34d8..fcce24d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,8 +65,17 @@ ## Live operations - If modifying the live Netcup deployment, only stop the `betternas` node process unless the user explicitly asks to modify the deployed backend service. +- When setting up a separate machine as a node in this session, access it through `computer ssh hiromi`. ## Node availability UX - Prefer default UI behavior that does not present disconnected nodes as mountable. - Surface connected and disconnected node state in the product when node availability is exposed. + +## Product docs UX + +- Remove operational setup instructions from the main control-plane page when they are better represented as dedicated end-to-end product docs. +- Prefer a separate clean docs page for simple end-to-end usage instructions. +- Keep the root `README.md` extremely minimal: no headings, just 5-6 plain lines that explain the architecture simply and cleanly. +- Make the default root route land on the main landing page instead of the auth-gated app. +- Add a plain `Docs` button to the left of the sign-in button on the main landing page. diff --git a/README.md b/README.md index 3c7c647..d5c9453 100644 --- a/README.md +++ b/README.md @@ -1,114 +1,6 @@ -# betterNAS - -betterNAS is a self-hostable WebDAV stack for mounting NAS exports in Finder. - -The default product shape is: - -- `node-service` serves the real files from the NAS over WebDAV -- `control-server` owns auth, nodes, exports, grants, and mount profile issuance -- `web control plane` lets the user manage the NAS and get mount instructions -- `macOS client` starts as native Finder WebDAV mounting, with a thin helper later - -For now, the whole stack should be able to run on the user's NAS device. - -## Current repo shape - -- `apps/node-agent` - - NAS-side Go runtime and WebDAV server -- `apps/control-plane` - - Go backend for auth, registry, and mount profile issuance -- `apps/web` - - Next.js web control plane -- `apps/nextcloud-app` - - optional Nextcloud adapter, not the product center -- `packages/contracts` - - canonical shared contracts -- `infra/docker` - - self-hosted local stack - -The main planning docs are: - -- [docs/architecture.md](./docs/architecture.md) -- [skeleton.md](./skeleton.md) -- [docs/05-build-plan.md](./docs/05-build-plan.md) - -## Default runtime model - -```text - self-hosted betterNAS on the user's NAS - - +------------------------------+ - | web control plane | - | Next.js UI | - +--------------+---------------+ - | - v - +------------------------------+ - | control-server | - | auth / nodes / exports | - | grants / mount profiles | - +--------------+---------------+ - | - v - +------------------------------+ - | node-service | - | WebDAV + export runtime | - | real NAS bytes | - +------------------------------+ - - user Mac - | - +--> browser -> web control plane - | - +--> Finder -> WebDAV mount URL from control-server -``` - -## Verify - -Static verification: - -```bash -pnpm verify -``` - -Bootstrap clone-local runtime settings: - -```bash -pnpm agent:bootstrap -``` - -Bring the self-hosted stack up, verify it, and tear it down: - -```bash -pnpm stack:up -pnpm stack:verify -pnpm stack:down --volumes -``` - -Run the full loop: - -```bash -pnpm agent:verify -``` - -## Current end-to-end slice - -The first proven slice is: - -1. boot the stack with `pnpm stack:up` -2. verify it with `pnpm stack:verify` -3. get the WebDAV mount profile from the control plane -4. mount it in Finder with the issued credentials - -If the stack is running on a remote machine, tunnel the WebDAV port first, then -use Finder `Connect to Server` with the tunneled URL. - -## Product boundary - -The default betterNAS product is self-hosted and WebDAV-first. - -Nextcloud remains optional and secondary: - -- useful later for browser/mobile/share surfaces -- not required for the core mount flow -- not the system of record +betterNAS is a hosted control plane with a user-run node agent. +The control plane owns user auth, node enrollment, heartbeats, export state, and mount issuance. +The node agent runs on the machine that owns the files and serves them over WebDAV. +The web app reads from the control plane and shows nodes, exports, and mount details. +Finder mounts the export from the node's public WebDAV URL using the same betterNAS username and password. +File traffic goes directly between the client and the node, not through the control plane. diff --git a/apps/web/app/app/page.tsx b/apps/web/app/app/page.tsx new file mode 100644 index 0000000..2e3dc8a --- /dev/null +++ b/apps/web/app/app/page.tsx @@ -0,0 +1,283 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { SignOut } from "@phosphor-icons/react"; +import { + isAuthenticated, + listExports, + listNodes, + issueMountProfile, + logout, + getMe, + type StorageExport, + type MountProfile, + type NasNode, + type User, + ApiError, +} from "@/lib/api"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { CopyField } from "../copy-field"; + +export default function Home() { + const router = useRouter(); + const [user, setUser] = useState(null); + const [nodes, setNodes] = useState([]); + const [exports, setExports] = useState([]); + const [selectedExportId, setSelectedExportId] = useState(null); + const [mountProfile, setMountProfile] = useState(null); + const [feedback, setFeedback] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!isAuthenticated()) { + router.replace("/login"); + return; + } + + async function load() { + try { + const [me, registeredNodes, exps] = await Promise.all([ + getMe(), + listNodes(), + listExports(), + ]); + setUser(me); + setNodes(registeredNodes); + setExports(exps); + } catch (err) { + if (err instanceof ApiError && err.status === 401) { + router.replace("/login"); + return; + } + setFeedback(err instanceof Error ? err.message : "Failed to load"); + } finally { + setLoading(false); + } + } + + load(); + }, [router]); + + async function handleSelectExport(exportId: string) { + setSelectedExportId(exportId); + setMountProfile(null); + setFeedback(null); + + try { + const profile = await issueMountProfile(exportId); + setMountProfile(profile); + } catch (err) { + setFeedback( + err instanceof Error ? err.message : "Failed to issue mount profile", + ); + } + } + + async function handleLogout() { + await logout(); + router.replace("/login"); + } + + if (loading) { + return ( +
+

Loading...

+
+ ); + } + + const selectedExport = selectedExportId + ? (exports.find((e) => e.id === selectedExportId) ?? null) + : null; + + return ( +
+
+ {/* header */} +
+
+ + betterNAS + +

+ Control Plane +

+
+ {user && ( +
+ + Docs + + + {user.username} + + +
+ )} +
+ + {feedback !== null && ( +
+ {feedback} +
+ )} + + {/* nodes */} +
+
+

Nodes

+ + {nodes.filter((n) => n.status === "online").length} online + {nodes.filter((n) => n.status === "offline").length > 0 && + `, ${nodes.filter((n) => n.status === "offline").length} offline`} + +
+ + {nodes.length === 0 ? ( +

+ No nodes registered yet. Install and start the node agent on the + machine that owns your files. +

+ ) : ( +
+ {nodes.map((node) => ( +
+
+ + {node.displayName} + + + {node.directAddress ?? node.relayAddress ?? node.machineId} + + + Last seen {formatTimestamp(node.lastSeenAt)} + +
+ + {node.status} + +
+ ))} +
+ )} +
+ + {/* exports + mount */} +
+ {/* exports list */} +
+

Exports

+ + {exports.length === 0 ? ( +

+ {nodes.length === 0 + ? "No exports yet. Start the node agent to register one." + : "No connected exports. Start the node agent or wait for reconnection."} +

+ ) : ( +
+ {exports.map((exp) => { + const isSelected = exp.id === selectedExportId; + + return ( + + ); + })} +
+ )} +
+ + {/* mount profile */} +
+

+ {selectedExport ? `Mount ${selectedExport.label}` : "Mount"} +

+ + {mountProfile === null ? ( +

+ Select an export to see the mount URL and credentials. +

+ ) : ( +
+ + + +

+ Use your betterNAS account password when Finder prompts. v1 + does not issue a separate WebDAV password. +

+ +
+

Finder steps

+
    +
  1. 1. Go > Connect to Server in Finder.
  2. +
  3. 2. Paste the mount URL.
  4. +
  5. 3. Enter your betterNAS username and password.
  6. +
  7. 4. Optionally save to Keychain.
  8. +
+
+
+ )} +
+
+
+
+ ); +} + +function formatTimestamp(value: string): string { + const trimmedValue = value.trim(); + if (trimmedValue === "") return "Never"; + + const parsed = new Date(trimmedValue); + if (Number.isNaN(parsed.getTime())) return trimmedValue; + + return parsed.toLocaleString(); +} diff --git a/apps/web/app/docs/page.tsx b/apps/web/app/docs/page.tsx new file mode 100644 index 0000000..60568a6 --- /dev/null +++ b/apps/web/app/docs/page.tsx @@ -0,0 +1,181 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { Check, Copy } from "@phosphor-icons/react"; + +function CodeBlock({ children, label }: { children: string; label?: string }) { + const [copied, setCopied] = useState(false); + + return ( +
+ {label && ( + + {label} + + )} +
+        {children}
+      
+ +
+ ); +} + +export default function DocsPage() { + return ( +
+
+ {/* header */} +
+
+ + + + + + + Sign in + +
+
+

+ Getting started +

+

+ One account works everywhere: the web app, the node agent, and + Finder. Set up the node, confirm it is online, then mount your + export. +

+
+
+ + {/* prerequisites */} +
+

Prerequisites

+
    +
  • - A betterNAS account
  • +
  • - A machine with the files you want to expose
  • +
  • - An export folder on that machine
  • +
  • + - A public HTTPS URL that reaches your node directly (for Finder + mounting) +
  • +
+
+ + {/* step 1 */} +
+

1. Install the node binary

+

+ Run this on the machine that owns the files. +

+ + {`curl -fsSL https://raw.githubusercontent.com/harivansh-afk/betterNAS/main/scripts/install-betternas-node.sh | sh`} + +
+ + {/* step 2 */} +
+

2. Start the node

+

+ Replace the placeholders with your account, export path, and public + node URL. +

+ + {`BETTERNAS_CONTROL_PLANE_URL=https://api.betternas.com \\ +BETTERNAS_USERNAME=your-username \\ +BETTERNAS_PASSWORD='your-password' \\ +BETTERNAS_EXPORT_PATH=/absolute/path/to/export \\ +BETTERNAS_NODE_DIRECT_ADDRESS=https://your-public-node-url \\ +betternas-node`} + +
+

+ Export path{" "} + - the directory you want to expose through betterNAS. +

+

+ + Direct address + {" "} + - the real public HTTPS base URL that reaches your node directly. +

+
+
+ + {/* step 3 */} +
+

3. Confirm the node is online

+

+ Open the control plane after the node starts. You should see: +

+
    +
  • - Your node appears as online
  • +
  • - Your export appears in the exports list
  • +
  • + - Issuing a mount profile gives you a WebDAV URL, not an HTML + login page +
  • +
+
+ + {/* step 4 */} +
+

4. Mount in Finder

+
    +
  1. 1. Open Finder, then Go > Connect to Server.
  2. +
  3. + 2. Copy the mount URL from the control plane and paste it in. +
  4. +
  5. + 3. Sign in with the same username and password you used for the + web app and node agent. +
  6. +
  7. + 4. Save to Keychain only if you want Finder to remember the + password. +
  8. +
+
+ + {/* note about public urls */} +
+

A note on public URLs

+

+ Finder mounting only works when the node URL is directly reachable + over HTTPS. Avoid gateways that show their own login page before + forwarding traffic. A good check: load{" "} + /dav/{" "} + on your node URL. A working node responds with WebDAV headers, not + HTML. +

+
+
+
+ ); +} diff --git a/apps/web/app/landing/page.tsx b/apps/web/app/landing/page.tsx index 3dba059..79ee795 100644 --- a/apps/web/app/landing/page.tsx +++ b/apps/web/app/landing/page.tsx @@ -11,50 +11,27 @@ const README_LINES = [ { tag: "h1", text: "betterNAS" }, { tag: "p", - text: "betterNAS is a self-hostable WebDAV stack for mounting NAS exports in Finder.", - }, - { tag: "p", text: "The default product shape is:" }, - { - tag: "ul", - items: [ - "node-service serves the real files from the NAS over WebDAV", - "control-server owns auth, nodes, exports, grants, and mount profile issuance", - "web control plane lets the user manage the NAS and get mount instructions", - "macOS client starts as native Finder WebDAV mounting, with a thin helper later", - ], + text: "betterNAS is a hosted control plane with a user-run node agent.", }, { tag: "p", - text: "For now, the whole stack should be able to run on the user's NAS device.", + text: "The control plane owns user auth, node enrollment, heartbeats, export state, and mount issuance.", }, - { tag: "h2", text: "Current repo shape" }, - { - tag: "ul", - items: [ - "apps/node-agent - NAS-side Go runtime and WebDAV server", - "apps/control-plane - Go backend for auth, registry, and mount profile issuance", - "apps/web - Next.js web control plane", - "apps/nextcloud-app - optional Nextcloud adapter, not the product center", - "packages/contracts - canonical shared contracts", - "infra/docker - self-hosted local stack", - ], - }, - { tag: "h2", text: "Verify" }, - { tag: "code", text: "pnpm verify" }, - { tag: "h2", text: "Current end-to-end slice" }, - { - tag: "ol", - items: [ - "Boot the stack with pnpm stack:up", - "Verify it with pnpm stack:verify", - "Get the WebDAV mount profile from the control plane", - "Mount it in Finder with the issued credentials", - ], - }, - { tag: "h2", text: "Product boundary" }, { tag: "p", - text: "The default betterNAS product is self-hosted and WebDAV-first. Nextcloud remains optional and secondary.", + text: "The node agent runs on the machine that owns the files and serves them over WebDAV.", + }, + { + tag: "p", + text: "The web app reads from the control plane and shows nodes, exports, and mount details.", + }, + { + tag: "p", + text: "Finder mounts the export from the node's public WebDAV URL using the same betterNAS username and password.", + }, + { + tag: "p", + text: "File traffic goes directly between the client and the node, not through the control plane.", }, ] as const; @@ -217,59 +194,11 @@ function ReadmeModal({ onClose }: { onClose: () => void }) { {block.text} ); - if (block.tag === "h2") - return ( -

- {block.text} -

- ); - if (block.tag === "p") - return ( -

- {block.text} -

- ); - if (block.tag === "code") - return ( -
-                    {block.text}
-                  
- ); - if (block.tag === "ul") - return ( -
    - {block.items.map((item, j) => ( -
  • - - {item.split(" - ")[0]} - - {item.includes(" - ") && ( - - {" "} - - {item.split(" - ").slice(1).join(" - ")} - - )} -
  • - ))} -
- ); - if (block.tag === "ol") - return ( -
    - {block.items.map((item, j) => ( -
  1. - {item} -
  2. - ))} -
- ); - return null; + return ( +

+ {block.text} +

+ ); })} @@ -394,6 +323,12 @@ export default function LandingPage() { {/* ---- header ---- */}
+ + Docs +
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 44bff34..d6fc8bf 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,501 +1 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; -import { - Globe, - HardDrives, - LinkSimple, - SignOut, - Warning, -} from "@phosphor-icons/react"; -import { - isAuthenticated, - listExports, - listNodes, - issueMountProfile, - logout, - getMe, - type StorageExport, - type MountProfile, - type NasNode, - type User, - ApiError, -} from "@/lib/api"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Badge } from "@/components/ui/badge"; -import { - Card, - CardAction, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { Separator } from "@/components/ui/separator"; -import { cn } from "@/lib/utils"; -import { CopyField } from "./copy-field"; - -export default function Home() { - const router = useRouter(); - const [user, setUser] = useState(null); - const [nodes, setNodes] = useState([]); - const [exports, setExports] = useState([]); - const [selectedExportId, setSelectedExportId] = useState(null); - const [mountProfile, setMountProfile] = useState(null); - const [feedback, setFeedback] = useState(null); - const [loading, setLoading] = useState(true); - - useEffect(() => { - if (!isAuthenticated()) { - router.replace("/login"); - return; - } - - async function load() { - try { - const [me, registeredNodes, exps] = await Promise.all([ - getMe(), - listNodes(), - listExports(), - ]); - setUser(me); - setNodes(registeredNodes); - setExports(exps); - } catch (err) { - if (err instanceof ApiError && err.status === 401) { - router.replace("/login"); - return; - } - setFeedback(err instanceof Error ? err.message : "Failed to load"); - } finally { - setLoading(false); - } - } - - load(); - }, [router]); - - async function handleSelectExport(exportId: string) { - setSelectedExportId(exportId); - setMountProfile(null); - setFeedback(null); - - try { - const profile = await issueMountProfile(exportId); - setMountProfile(profile); - } catch (err) { - setFeedback( - err instanceof Error ? err.message : "Failed to issue mount profile", - ); - } - } - - async function handleLogout() { - await logout(); - router.replace("/login"); - } - - if (loading) { - return ( -
-

Loading...

- - ); - } - - const selectedExport = selectedExportId - ? (exports.find((e) => e.id === selectedExportId) ?? null) - : null; - const onlineNodeCount = nodes.filter((node) => node.status === "online").length; - const degradedNodeCount = nodes.filter( - (node) => node.status === "degraded", - ).length; - const offlineNodeCount = nodes.filter( - (node) => node.status === "offline", - ).length; - - return ( -
-
-
-
-
-

- betterNAS -

-

- Control Plane -

-
- {user && ( -
- - {user.username} - - -
- )} -
- -
- - - {process.env.NEXT_PUBLIC_BETTERNAS_API_URL || "local"} - - - {exports.length === 1 ? "1 export" : `${exports.length} exports`} - - - {nodes.length === 1 ? "1 node" : `${nodes.length} nodes`} - -
- - {user && ( - - - Node agent setup - - Run the node binary on the machine that owns the files with - the same account credentials you use here and in Finder. - - - -
-
-                    
-                      {`curl -fsSL https://raw.githubusercontent.com/harivansh-afk/betterNAS/main/scripts/install-betternas-node.sh | sh`}
-                    
-                  
-
-                    
-                      {`BETTERNAS_USERNAME=${user.username} BETTERNAS_PASSWORD=... BETTERNAS_EXPORT_PATH=/path/to/export BETTERNAS_NODE_DIRECT_ADDRESS=https://your-public-node-url betternas-node`}
-                    
-                  
-
-
-
- )} -
- - {feedback !== null && ( - - - Error - {feedback} - - )} - - - - Nodes - - Machines registered to your account and their current connection - state. - - -
- - {onlineNodeCount} online - - {degradedNodeCount > 0 && ( - - {degradedNodeCount} degraded - - )} - {offlineNodeCount > 0 && ( - {offlineNodeCount} offline - )} -
-
-
- - {nodes.length === 0 ? ( -
- -

- No nodes registered yet. Install and start the node agent on - the machine that owns your files. -

-
- ) : ( -
- {nodes.map((node) => ( -
-
-
- - {node.displayName} - - - {node.machineId} - -
- - {node.status} - -
- -
-
-
- Node ID -
-
- {node.id} -
-
-
-
- Last seen -
-
- {formatTimestamp(node.lastSeenAt)} -
-
-
-
- Address -
-
- {node.directAddress ?? node.relayAddress ?? "Unavailable"} -
-
-
-
- ))} -
- )} -
-
- -
- - - Exports - - Connected storage exports that are currently mountable. - - - - {exports.length === 1 - ? "1 export" - : `${exports.length} exports`} - - - - - {exports.length === 0 ? ( -
- -

- {nodes.length === 0 - ? "No exports registered yet. Start the node agent and connect it to this control plane." - : "No connected exports right now. Start the node agent or wait for a disconnected node to reconnect."} -

-
- ) : ( -
- {exports.map((storageExport) => { - const isSelected = storageExport.id === selectedExportId; - - return ( - - ); - })} -
- )} -
-
- - - - - {selectedExport !== null - ? `Mount ${selectedExport.label}` - : "Mount instructions"} - - - {selectedExport !== null - ? "WebDAV mount details for Finder." - : "Select an export to see the mount URL and account login details."} - - - - {mountProfile === null ? ( -
- -

- Pick an export to see the Finder mount URL and the username - to use with your betterNAS account password. -

-
- ) : ( -
-
- - Issued profile - - - {mountProfile.readonly ? "Read-only" : "Read-write"} - -
- - - -
- - - - - Use your betterNAS account password - - - Enter the same password you use to sign in to betterNAS - and run the node agent. v1 does not issue a separate - WebDAV password. - - -
- - - -
-
-
- Mode -
-
- {mountProfile.credential.mode} -
-
-
-
- Password source -
-
- Your betterNAS account password -
-
-
- - - -
-

Finder steps

-
    - {[ - "Open Finder and choose Go, then Connect to Server.", - "Paste the mount URL into the server address field.", - "Enter your betterNAS username and account password when prompted.", - "Save to Keychain only if you want Finder to reuse that same account password.", - ].map((step, index) => ( -
  1. - - {index + 1} - - {step} -
  2. - ))} -
-
-
- )} -
-
-
-
-
- ); -} - -function formatTimestamp(value: string): string { - const trimmedValue = value.trim(); - if (trimmedValue === "") { - return "Never"; - } - - const parsed = new Date(trimmedValue); - if (Number.isNaN(parsed.getTime())) { - return trimmedValue; - } - - return parsed.toLocaleString(); -} +export { default } from "./landing/page"; From caa2e40dc195f327c9842f5b587d349d2964dcae Mon Sep 17 00:00:00 2001 From: Hari <73809867+harivansh-afk@users.noreply.github.com> Date: Wed, 1 Apr 2026 22:39:58 -0400 Subject: [PATCH 06/13] Add image to README for better visualization --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index d5c9453..0cf24df 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,5 @@ The node agent runs on the machine that owns the files and serves them over WebD The web app reads from the control plane and shows nodes, exports, and mount details. Finder mounts the export from the node's public WebDAV URL using the same betterNAS username and password. File traffic goes directly between the client and the node, not through the control plane. + +image From 79f52cac9a02ff854cd955762b76a5e57289d59c Mon Sep 17 00:00:00 2001 From: Harivansh Rathi Date: Thu, 2 Apr 2026 03:24:41 +0000 Subject: [PATCH 07/13] website --- apps/web/app/docs/page.tsx | 18 ++++++++++++++++-- apps/web/app/landing/page.tsx | 20 ++------------------ 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/apps/web/app/docs/page.tsx b/apps/web/app/docs/page.tsx index 60568a6..2bf6ca2 100644 --- a/apps/web/app/docs/page.tsx +++ b/apps/web/app/docs/page.tsx @@ -62,10 +62,24 @@ export default function DocsPage() { Sign in
-
+

- Getting started + betterNAS

+

+ Mount VMs and remote filesystems on your Mac as native Finder + volumes. No special client, no syncing - just your files, where + you expect them. +

+

+ Soon: a unified layer across your phone, computer, and AI agents. + A safe, modular backup of your filesystem that you can use + natively - and a way to deploy agents on your own infrastructure + without giving up control. +

+

+ Getting started +

One account works everywhere: the web app, the node agent, and Finder. Set up the node, confirm it is online, then mount your diff --git a/apps/web/app/landing/page.tsx b/apps/web/app/landing/page.tsx index 79ee795..67e3d29 100644 --- a/apps/web/app/landing/page.tsx +++ b/apps/web/app/landing/page.tsx @@ -11,27 +11,11 @@ const README_LINES = [ { tag: "h1", text: "betterNAS" }, { tag: "p", - text: "betterNAS is a hosted control plane with a user-run node agent.", + text: "Mount VMs and remote filesystems on your Mac as native Finder volumes. No special client, no syncing - just your files, where you expect them.", }, { tag: "p", - text: "The control plane owns user auth, node enrollment, heartbeats, export state, and mount issuance.", - }, - { - tag: "p", - text: "The node agent runs on the machine that owns the files and serves them over WebDAV.", - }, - { - tag: "p", - text: "The web app reads from the control plane and shows nodes, exports, and mount details.", - }, - { - tag: "p", - text: "Finder mounts the export from the node's public WebDAV URL using the same betterNAS username and password.", - }, - { - tag: "p", - text: "File traffic goes directly between the client and the node, not through the control plane.", + text: "Soon: a unified layer across your phone, computer, and AI agents. A safe, modular backup of your filesystem that you can use natively - and a way to deploy agents on your own infrastructure without giving up control.", }, ] as const; From d939126ce029d8ecc47dd5a0fbff699f2aa5ed83 Mon Sep 17 00:00:00 2001 From: Harivansh Rathi Date: Thu, 2 Apr 2026 03:25:57 +0000 Subject: [PATCH 08/13] rm web build --- .github/workflows/ci.yaml | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9846b53..0dd9785 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -31,17 +31,3 @@ jobs: working-directory: apps/node-agent - run: go test -count=1 ./... working-directory: apps/node-agent - - build-web: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: pnpm - - run: pnpm install --frozen-lockfile - - run: pnpm --filter @betternas/web build - env: - NEXT_PUBLIC_BETTERNAS_API_URL: https://api.betternas.com From 23e0baa1ef28198b434f1ab275ccaf3c0fba5066 Mon Sep 17 00:00:00 2001 From: Harivansh Rathi Date: Thu, 2 Apr 2026 03:35:17 +0000 Subject: [PATCH 09/13] copy --- README.md | 12 ++++++------ apps/web/app/docs/page.tsx | 15 ++++++++------- apps/web/app/landing/page.tsx | 4 ++-- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 0cf24df..3dcebee 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ -betterNAS is a hosted control plane with a user-run node agent. -The control plane owns user auth, node enrollment, heartbeats, export state, and mount issuance. -The node agent runs on the machine that owns the files and serves them over WebDAV. -The web app reads from the control plane and shows nodes, exports, and mount details. -Finder mounts the export from the node's public WebDAV URL using the same betterNAS username and password. -File traffic goes directly between the client and the node, not through the control plane. +betterNAS lets you mount remote machines as native Finder volumes on your Mac. +Install a small agent on any box with files you care about, and it shows up in Finder like a local drive. +No sync clients, no special apps - just your files, where you expect them. + +The plan is bigger: phone, laptop, agents, all seeing the same filesystem. +A modular backup layer you actually use day-to-day, and a way to run agents on your own hardware without handing over the keys. image diff --git a/apps/web/app/docs/page.tsx b/apps/web/app/docs/page.tsx index 2bf6ca2..5d97e1b 100644 --- a/apps/web/app/docs/page.tsx +++ b/apps/web/app/docs/page.tsx @@ -67,15 +67,16 @@ export default function DocsPage() { betterNAS

- Mount VMs and remote filesystems on your Mac as native Finder - volumes. No special client, no syncing - just your files, where - you expect them. + Mount remote machines as native Finder volumes on your Mac. + Install a small agent on any box with files you care about, and + it shows up in Finder like a local drive. No sync clients, no + special apps - just your files, where you expect them.

- Soon: a unified layer across your phone, computer, and AI agents. - A safe, modular backup of your filesystem that you can use - natively - and a way to deploy agents on your own infrastructure - without giving up control. + The plan is bigger: phone, laptop, agents, all seeing the same + filesystem. A modular backup layer you actually use day-to-day, + and a way to run agents on your own hardware without handing over + the keys.

Getting started diff --git a/apps/web/app/landing/page.tsx b/apps/web/app/landing/page.tsx index 67e3d29..21fd92a 100644 --- a/apps/web/app/landing/page.tsx +++ b/apps/web/app/landing/page.tsx @@ -11,11 +11,11 @@ const README_LINES = [ { tag: "h1", text: "betterNAS" }, { tag: "p", - text: "Mount VMs and remote filesystems on your Mac as native Finder volumes. No special client, no syncing - just your files, where you expect them.", + text: "betterNAS lets you mount remote machines as native Finder volumes on your Mac. Install a small agent on any box with files you care about, and it shows up in Finder like a local drive.", }, { tag: "p", - text: "Soon: a unified layer across your phone, computer, and AI agents. A safe, modular backup of your filesystem that you can use natively - and a way to deploy agents on your own infrastructure without giving up control.", + text: "The goal is to build a modular filesystem you actually use natively", }, ] as const; From 93d610db2934a324fa699e79c4ffb04b56c849bd Mon Sep 17 00:00:00 2001 From: Harivansh Rathi Date: Wed, 1 Apr 2026 23:39:43 -0400 Subject: [PATCH 10/13] move --- README.md => docs/repository-root/README.md | 0 TODO.md => docs/repository-root/TODO.md | 0 control.md => docs/repository-root/control.md | 0 skeleton.md => docs/repository-root/skeleton.md | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename README.md => docs/repository-root/README.md (100%) rename TODO.md => docs/repository-root/TODO.md (100%) rename control.md => docs/repository-root/control.md (100%) rename skeleton.md => docs/repository-root/skeleton.md (100%) diff --git a/README.md b/docs/repository-root/README.md similarity index 100% rename from README.md rename to docs/repository-root/README.md diff --git a/TODO.md b/docs/repository-root/TODO.md similarity index 100% rename from TODO.md rename to docs/repository-root/TODO.md diff --git a/control.md b/docs/repository-root/control.md similarity index 100% rename from control.md rename to docs/repository-root/control.md diff --git a/skeleton.md b/docs/repository-root/skeleton.md similarity index 100% rename from skeleton.md rename to docs/repository-root/skeleton.md From bebd8c3e4c30817b54d16a3202d2ed8f54d958d4 Mon Sep 17 00:00:00 2001 From: Harivansh Rathi Date: Wed, 1 Apr 2026 23:43:09 -0400 Subject: [PATCH 11/13] move docs --- CLAUDE.md | 81 ------------------ docs/repository-root/README.md => README.md | 0 .../.agents}/skills/shadcn/SKILL.md | 0 .../.agents}/skills/shadcn/agents/openai.yml | 0 .../skills/shadcn/assets/shadcn-small.png | Bin .../.agents}/skills/shadcn/assets/shadcn.png | Bin .../.agents}/skills/shadcn/cli.md | 0 .../.agents}/skills/shadcn/customization.md | 0 .../.agents}/skills/shadcn/evals/evals.json | 0 .../.agents}/skills/shadcn/mcp.md | 0 .../skills/shadcn/rules/base-vs-radix.md | 0 .../skills/shadcn/rules/composition.md | 0 .../.agents}/skills/shadcn/rules/forms.md | 0 .../.agents}/skills/shadcn/rules/icons.md | 0 .../.agents}/skills/shadcn/rules/styling.md | 0 15 files changed, 81 deletions(-) delete mode 100644 CLAUDE.md rename docs/repository-root/README.md => README.md (100%) rename {.agents => docs/.agents}/skills/shadcn/SKILL.md (100%) rename {.agents => docs/.agents}/skills/shadcn/agents/openai.yml (100%) rename {.agents => docs/.agents}/skills/shadcn/assets/shadcn-small.png (100%) rename {.agents => docs/.agents}/skills/shadcn/assets/shadcn.png (100%) rename {.agents => docs/.agents}/skills/shadcn/cli.md (100%) rename {.agents => docs/.agents}/skills/shadcn/customization.md (100%) rename {.agents => docs/.agents}/skills/shadcn/evals/evals.json (100%) rename {.agents => docs/.agents}/skills/shadcn/mcp.md (100%) rename {.agents => docs/.agents}/skills/shadcn/rules/base-vs-radix.md (100%) rename {.agents => docs/.agents}/skills/shadcn/rules/composition.md (100%) rename {.agents => docs/.agents}/skills/shadcn/rules/forms.md (100%) rename {.agents => docs/.agents}/skills/shadcn/rules/icons.md (100%) rename {.agents => docs/.agents}/skills/shadcn/rules/styling.md (100%) diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index fcce24d..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,81 +0,0 @@ -# Project Constraints - -## Delivery sequencing - -- Start with `apps/control-plane` first. -- Deliver the core backend in 2 steps, not 3: - 1. `control-server` plus `node-service` contract and runtime loop - 2. web control plane on top of that stable backend seam -- Do not start web UI work until the `control-server` and `node-service` contract is stable. - -## Architecture - -- `control-server` is the clean backend contract that other parts consume. -- `apps/node-agent` reports into `apps/control-plane`. -- `apps/web` reads from `apps/control-plane`. -- Local mount UX is issued by `apps/control-plane`. - -## Backend contract priorities - -- The first backend seam must cover: - - node enrollment - - node heartbeats - - node export reporting - - control-server persistence of nodes and exports - - mount profile issuance for one export -- `control-server` should own: - - node auth - - user auth - - mount issuance - -## Mount profile shape - -- Prefer standard WebDAV username and password semantics for Finder compatibility. -- The consumer-facing mount profile should behave like: - - export id - - display name - - mount URL - - username - - password - - readonly - - expires at - -## Service boundary - -- Keep `node-service` limited to the WebDAV mount surface. -- Route admin and control actions through `control-server`, not directly from browsers to `node-service`. - -## User-scoped auth requirements - -- Remove the bootstrap token flow for v1. -- Use a single user-provided username and password across the entire stack: - - `apps/node-agent` authenticates with the user's username and password from environment variables - - web app sessions authenticate with the same username and password - - WebDAV and Finder authentication use the same username and password -- Do not generate separate WebDAV credentials for users. -- Nodes and exports must be owned by users and scoped so authenticated users can only view and mount their own resources. -- Package the node binary for user download and distribution. - -## V1 simplicity - -- Keep the implementation as simple as possible. -- Do not over-engineer the auth or distribution model for v1. -- Prefer the smallest change set that makes the product usable and distributable. - -## Live operations - -- If modifying the live Netcup deployment, only stop the `betternas` node process unless the user explicitly asks to modify the deployed backend service. -- When setting up a separate machine as a node in this session, access it through `computer ssh hiromi`. - -## Node availability UX - -- Prefer default UI behavior that does not present disconnected nodes as mountable. -- Surface connected and disconnected node state in the product when node availability is exposed. - -## Product docs UX - -- Remove operational setup instructions from the main control-plane page when they are better represented as dedicated end-to-end product docs. -- Prefer a separate clean docs page for simple end-to-end usage instructions. -- Keep the root `README.md` extremely minimal: no headings, just 5-6 plain lines that explain the architecture simply and cleanly. -- Make the default root route land on the main landing page instead of the auth-gated app. -- Add a plain `Docs` button to the left of the sign-in button on the main landing page. diff --git a/docs/repository-root/README.md b/README.md similarity index 100% rename from docs/repository-root/README.md rename to README.md diff --git a/.agents/skills/shadcn/SKILL.md b/docs/.agents/skills/shadcn/SKILL.md similarity index 100% rename from .agents/skills/shadcn/SKILL.md rename to docs/.agents/skills/shadcn/SKILL.md diff --git a/.agents/skills/shadcn/agents/openai.yml b/docs/.agents/skills/shadcn/agents/openai.yml similarity index 100% rename from .agents/skills/shadcn/agents/openai.yml rename to docs/.agents/skills/shadcn/agents/openai.yml diff --git a/.agents/skills/shadcn/assets/shadcn-small.png b/docs/.agents/skills/shadcn/assets/shadcn-small.png similarity index 100% rename from .agents/skills/shadcn/assets/shadcn-small.png rename to docs/.agents/skills/shadcn/assets/shadcn-small.png diff --git a/.agents/skills/shadcn/assets/shadcn.png b/docs/.agents/skills/shadcn/assets/shadcn.png similarity index 100% rename from .agents/skills/shadcn/assets/shadcn.png rename to docs/.agents/skills/shadcn/assets/shadcn.png diff --git a/.agents/skills/shadcn/cli.md b/docs/.agents/skills/shadcn/cli.md similarity index 100% rename from .agents/skills/shadcn/cli.md rename to docs/.agents/skills/shadcn/cli.md diff --git a/.agents/skills/shadcn/customization.md b/docs/.agents/skills/shadcn/customization.md similarity index 100% rename from .agents/skills/shadcn/customization.md rename to docs/.agents/skills/shadcn/customization.md diff --git a/.agents/skills/shadcn/evals/evals.json b/docs/.agents/skills/shadcn/evals/evals.json similarity index 100% rename from .agents/skills/shadcn/evals/evals.json rename to docs/.agents/skills/shadcn/evals/evals.json diff --git a/.agents/skills/shadcn/mcp.md b/docs/.agents/skills/shadcn/mcp.md similarity index 100% rename from .agents/skills/shadcn/mcp.md rename to docs/.agents/skills/shadcn/mcp.md diff --git a/.agents/skills/shadcn/rules/base-vs-radix.md b/docs/.agents/skills/shadcn/rules/base-vs-radix.md similarity index 100% rename from .agents/skills/shadcn/rules/base-vs-radix.md rename to docs/.agents/skills/shadcn/rules/base-vs-radix.md diff --git a/.agents/skills/shadcn/rules/composition.md b/docs/.agents/skills/shadcn/rules/composition.md similarity index 100% rename from .agents/skills/shadcn/rules/composition.md rename to docs/.agents/skills/shadcn/rules/composition.md diff --git a/.agents/skills/shadcn/rules/forms.md b/docs/.agents/skills/shadcn/rules/forms.md similarity index 100% rename from .agents/skills/shadcn/rules/forms.md rename to docs/.agents/skills/shadcn/rules/forms.md diff --git a/.agents/skills/shadcn/rules/icons.md b/docs/.agents/skills/shadcn/rules/icons.md similarity index 100% rename from .agents/skills/shadcn/rules/icons.md rename to docs/.agents/skills/shadcn/rules/icons.md diff --git a/.agents/skills/shadcn/rules/styling.md b/docs/.agents/skills/shadcn/rules/styling.md similarity index 100% rename from .agents/skills/shadcn/rules/styling.md rename to docs/.agents/skills/shadcn/rules/styling.md From be98500e097bc4e6bdfaf89935e6f7e6dbbf8e91 Mon Sep 17 00:00:00 2001 From: Harivansh Rathi Date: Sun, 5 Apr 2026 11:41:11 -0400 Subject: [PATCH 12/13] ci: use self-hosted netty runners --- .github/workflows/ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0dd9785..a6a50b7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -7,7 +7,7 @@ on: jobs: test-control-plane: - runs-on: ubuntu-latest + runs-on: [self-hosted, netty] steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 @@ -20,7 +20,7 @@ jobs: working-directory: apps/control-plane test-node-agent: - runs-on: ubuntu-latest + runs-on: [self-hosted, netty] steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 From 90d7eda26444847b046116015caa86eef7b8093c Mon Sep 17 00:00:00 2001 From: Hari <73809867+harivansh-afk@users.noreply.github.com> Date: Sun, 12 Apr 2026 00:57:43 -0400 Subject: [PATCH 13/13] Update README with project title and URL Added project title and link to README. --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 3dcebee..aa33745 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,8 @@ +# BetterNAS + +https://github.com/user-attachments/assets/7909f957-b6d9-4cc1-aaec-369d5d94a7f5 + + betterNAS lets you mount remote machines as native Finder volumes on your Mac. Install a small agent on any box with files you care about, and it shows up in Finder like a local drive. No sync clients, no special apps - just your files, where you expect them.