mirror of
https://github.com/harivansh-afk/betterNAS.git
synced 2026-04-19 00:05:23 +00:00
Compare commits
No commits in common. "main" and "v0.1.0" have entirely different histories.
42 changed files with 1402 additions and 1565 deletions
|
Before Width: | Height: | Size: 1 KiB After Width: | Height: | Size: 1 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
18
.github/workflows/ci.yaml
vendored
18
.github/workflows/ci.yaml
vendored
|
|
@ -7,7 +7,7 @@ on:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test-control-plane:
|
test-control-plane:
|
||||||
runs-on: [self-hosted, netty]
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v5
|
||||||
|
|
@ -20,7 +20,7 @@ jobs:
|
||||||
working-directory: apps/control-plane
|
working-directory: apps/control-plane
|
||||||
|
|
||||||
test-node-agent:
|
test-node-agent:
|
||||||
runs-on: [self-hosted, netty]
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v5
|
||||||
|
|
@ -31,3 +31,17 @@ jobs:
|
||||||
working-directory: apps/node-agent
|
working-directory: apps/node-agent
|
||||||
- run: go test -count=1 ./...
|
- run: go test -count=1 ./...
|
||||||
working-directory: apps/node-agent
|
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
|
||||||
|
|
|
||||||
63
CLAUDE.md
Normal file
63
CLAUDE.md
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
# 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.
|
||||||
117
README.md
117
README.md
|
|
@ -1,13 +1,114 @@
|
||||||
# BetterNAS
|
# betterNAS
|
||||||
|
|
||||||
https://github.com/user-attachments/assets/7909f957-b6d9-4cc1-aaec-369d5d94a7f5
|
betterNAS is a self-hostable WebDAV stack for mounting NAS exports in Finder.
|
||||||
|
|
||||||
|
The default product shape is:
|
||||||
|
|
||||||
betterNAS lets you mount remote machines as native Finder volumes on your Mac.
|
- `node-service` serves the real files from the NAS over WebDAV
|
||||||
Install a small agent on any box with files you care about, and it shows up in Finder like a local drive.
|
- `control-server` owns auth, nodes, exports, grants, and mount profile issuance
|
||||||
No sync clients, no special apps - just your files, where you expect them.
|
- `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
|
||||||
|
|
||||||
The plan is bigger: phone, laptop, agents, all seeing the same filesystem.
|
For now, the whole stack should be able to run on the user's NAS device.
|
||||||
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.
|
|
||||||
|
|
||||||
<img width="1330" height="614" alt="image" src="https://github.com/user-attachments/assets/f4cfe135-505f-4ce1-bbb3-1f1d47821f8f" />
|
## 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
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -12,7 +10,6 @@ type appConfig struct {
|
||||||
statePath string
|
statePath string
|
||||||
dbPath string
|
dbPath string
|
||||||
sessionTTL time.Duration
|
sessionTTL time.Duration
|
||||||
nodeOfflineThreshold time.Duration
|
|
||||||
registrationEnabled bool
|
registrationEnabled bool
|
||||||
corsOrigin string
|
corsOrigin string
|
||||||
}
|
}
|
||||||
|
|
@ -24,13 +21,7 @@ type app struct {
|
||||||
store store
|
store store
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultNodeOfflineThreshold = 2 * time.Minute
|
|
||||||
|
|
||||||
func newApp(config appConfig, startedAt time.Time) (*app, error) {
|
func newApp(config appConfig, startedAt time.Time) (*app, error) {
|
||||||
if config.nodeOfflineThreshold <= 0 {
|
|
||||||
config.nodeOfflineThreshold = defaultNodeOfflineThreshold
|
|
||||||
}
|
|
||||||
|
|
||||||
var s store
|
var s store
|
||||||
var err error
|
var err error
|
||||||
if config.dbPath != "" {
|
if config.dbPath != "" {
|
||||||
|
|
@ -50,68 +41,6 @@ func newApp(config appConfig, startedAt time.Time) (*app, error) {
|
||||||
}, nil
|
}, 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 {
|
type nextcloudBackendStatus struct {
|
||||||
Configured bool `json:"configured"`
|
Configured bool `json:"configured"`
|
||||||
BaseURL string `json:"baseUrl"`
|
BaseURL string `json:"baseUrl"`
|
||||||
|
|
|
||||||
|
|
@ -36,16 +36,6 @@ func newAppFromEnv(startedAt time.Time) (*app, error) {
|
||||||
sessionTTL = parsedSessionTTL
|
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(
|
app, err := newApp(
|
||||||
appConfig{
|
appConfig{
|
||||||
version: env("BETTERNAS_VERSION", "0.1.0-dev"),
|
version: env("BETTERNAS_VERSION", "0.1.0-dev"),
|
||||||
|
|
@ -53,7 +43,6 @@ func newAppFromEnv(startedAt time.Time) (*app, error) {
|
||||||
statePath: env("BETTERNAS_CONTROL_PLANE_STATE_PATH", ".state/control-plane/state.json"),
|
statePath: env("BETTERNAS_CONTROL_PLANE_STATE_PATH", ".state/control-plane/state.json"),
|
||||||
dbPath: env("BETTERNAS_CONTROL_PLANE_DB_PATH", ".state/control-plane/betternas.db"),
|
dbPath: env("BETTERNAS_CONTROL_PLANE_DB_PATH", ".state/control-plane/betternas.db"),
|
||||||
sessionTTL: sessionTTL,
|
sessionTTL: sessionTTL,
|
||||||
nodeOfflineThreshold: nodeOfflineThreshold,
|
|
||||||
registrationEnabled: env("BETTERNAS_REGISTRATION_ENABLED", "true") == "true",
|
registrationEnabled: env("BETTERNAS_REGISTRATION_ENABLED", "true") == "true",
|
||||||
corsOrigin: env("BETTERNAS_CORS_ORIGIN", ""),
|
corsOrigin: env("BETTERNAS_CORS_ORIGIN", ""),
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -56,9 +56,8 @@ func TestControlPlaneBinaryMountLoopIntegration(t *testing.T) {
|
||||||
mount := postJSONAuth[mountProfile](t, client, controlPlane.sessionToken, controlPlane.baseURL+"/api/v1/mount-profiles/issue", mountProfileRequest{
|
mount := postJSONAuth[mountProfile](t, client, controlPlane.sessionToken, controlPlane.baseURL+"/api/v1/mount-profiles/issue", mountProfileRequest{
|
||||||
ExportID: export.ID,
|
ExportID: export.ID,
|
||||||
})
|
})
|
||||||
expectedMountURL := nodeAgent.baseURL + defaultWebDAVPath + runtimeUsername + "/"
|
if mount.MountURL != nodeAgent.baseURL+defaultWebDAVPath {
|
||||||
if mount.MountURL != expectedMountURL {
|
t.Fatalf("expected runtime mount URL %q, got %q", nodeAgent.baseURL+defaultWebDAVPath, mount.MountURL)
|
||||||
t.Fatalf("expected runtime mount URL %q, got %q", expectedMountURL, mount.MountURL)
|
|
||||||
}
|
}
|
||||||
if mount.Credential.Mode != mountCredentialModeBasicAuth {
|
if mount.Credential.Mode != mountCredentialModeBasicAuth {
|
||||||
t.Fatalf("expected mount credential mode %q, got %q", mountCredentialModeBasicAuth, mount.Credential.Mode)
|
t.Fatalf("expected mount credential mode %q, got %q", mountCredentialModeBasicAuth, mount.Credential.Mode)
|
||||||
|
|
@ -104,13 +103,11 @@ func TestControlPlaneBinaryMultiExportProfilesStayDistinct(t *testing.T) {
|
||||||
if firstMount.MountURL == secondMount.MountURL {
|
if firstMount.MountURL == secondMount.MountURL {
|
||||||
t.Fatalf("expected distinct runtime mount URLs, got %q", firstMount.MountURL)
|
t.Fatalf("expected distinct runtime mount URLs, got %q", firstMount.MountURL)
|
||||||
}
|
}
|
||||||
expectedFirstMountURL := nodeAgent.baseURL + firstMountPath + runtimeUsername + "/"
|
if firstMount.MountURL != nodeAgent.baseURL+firstMountPath {
|
||||||
expectedSecondMountURL := nodeAgent.baseURL + secondMountPath + runtimeUsername + "/"
|
t.Fatalf("expected first runtime mount URL %q, got %q", nodeAgent.baseURL+firstMountPath, firstMount.MountURL)
|
||||||
if firstMount.MountURL != expectedFirstMountURL {
|
|
||||||
t.Fatalf("expected first runtime mount URL %q, got %q", expectedFirstMountURL, firstMount.MountURL)
|
|
||||||
}
|
}
|
||||||
if secondMount.MountURL != expectedSecondMountURL {
|
if secondMount.MountURL != nodeAgent.baseURL+secondMountPath {
|
||||||
t.Fatalf("expected second runtime mount URL %q, got %q", expectedSecondMountURL, secondMount.MountURL)
|
t.Fatalf("expected second runtime mount URL %q, got %q", nodeAgent.baseURL+secondMountPath, secondMount.MountURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
assertHTTPStatusWithBasicAuth(t, client, "PROPFIND", firstMount.MountURL, controlPlane.username, controlPlane.password, http.StatusMultiStatus)
|
assertHTTPStatusWithBasicAuth(t, client, "PROPFIND", firstMount.MountURL, controlPlane.username, controlPlane.password, http.StatusMultiStatus)
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,6 @@ func (a *app) handler() http.Handler {
|
||||||
mux.HandleFunc("POST /api/v1/auth/login", a.handleAuthLogin)
|
mux.HandleFunc("POST /api/v1/auth/login", a.handleAuthLogin)
|
||||||
mux.HandleFunc("POST /api/v1/auth/logout", a.handleAuthLogout)
|
mux.HandleFunc("POST /api/v1/auth/logout", a.handleAuthLogout)
|
||||||
mux.HandleFunc("GET /api/v1/auth/me", a.handleAuthMe)
|
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/register", a.handleNodeRegister)
|
||||||
mux.HandleFunc("POST /api/v1/nodes/{nodeId}/heartbeat", a.handleNodeHeartbeat)
|
mux.HandleFunc("POST /api/v1/nodes/{nodeId}/heartbeat", a.handleNodeHeartbeat)
|
||||||
mux.HandleFunc("PUT /api/v1/nodes/{nodeId}/exports", a.handleNodeExports)
|
mux.HandleFunc("PUT /api/v1/nodes/{nodeId}/exports", a.handleNodeExports)
|
||||||
|
|
@ -75,15 +74,6 @@ 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) {
|
func (a *app) handleNodeRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
currentUser, ok := a.requireSessionUser(w, r)
|
currentUser, ok := a.requireSessionUser(w, r)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -187,7 +177,7 @@ func (a *app) handleExportsList(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, a.listConnectedExports(currentUser.ID))
|
writeJSON(w, http.StatusOK, a.store.listExports(currentUser.ID))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) handleMountProfileIssue(w http.ResponseWriter, r *http.Request) {
|
func (a *app) handleMountProfileIssue(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -212,13 +202,8 @@ func (a *app) handleMountProfileIssue(w http.ResponseWriter, r *http.Request) {
|
||||||
http.Error(w, errExportNotFound.Error(), http.StatusNotFound)
|
http.Error(w, errExportNotFound.Error(), http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
context.node = a.presentedNode(context.node)
|
|
||||||
if !nodeIsConnected(context.node) {
|
|
||||||
http.Error(w, errMountTargetUnavailable.Error(), http.StatusServiceUnavailable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
mountURL, err := buildMountURL(context, currentUser.Username)
|
mountURL, err := buildMountURL(context)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||||
return
|
return
|
||||||
|
|
@ -720,17 +705,13 @@ func hasConfiguredNextcloudBaseURL(baseURL string) bool {
|
||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildMountURL(context exportContext, username string) (string, error) {
|
func buildMountURL(context exportContext) (string, error) {
|
||||||
address, ok := firstAddress(context.node.DirectAddress, context.node.RelayAddress)
|
address, ok := firstAddress(context.node.DirectAddress, context.node.RelayAddress)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", errMountTargetUnavailable
|
return "", errMountTargetUnavailable
|
||||||
}
|
}
|
||||||
|
|
||||||
basePath := mountProfilePathForExport(context.export.MountPath)
|
mountURL, err := buildAbsoluteHTTPURLWithPath(address, 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 {
|
if err != nil {
|
||||||
return "", errMountTargetUnavailable
|
return "", errMountTargetUnavailable
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -162,8 +162,8 @@ func TestControlPlaneRegistrationProfilesAndHeartbeat(t *testing.T) {
|
||||||
if mount.DisplayName != "Photos" {
|
if mount.DisplayName != "Photos" {
|
||||||
t.Fatalf("expected mount display name Photos, got %q", mount.DisplayName)
|
t.Fatalf("expected mount display name Photos, got %q", mount.DisplayName)
|
||||||
}
|
}
|
||||||
if mount.MountURL != "http://nas.local:8090/dav/fixture/" {
|
if mount.MountURL != "http://nas.local:8090/dav/" {
|
||||||
t.Fatalf("expected mount URL %q, got %q", "http://nas.local:8090/dav/fixture/", mount.MountURL)
|
t.Fatalf("expected mount URL %q, got %q", "http://nas.local:8090/dav/", mount.MountURL)
|
||||||
}
|
}
|
||||||
if mount.Readonly {
|
if mount.Readonly {
|
||||||
t.Fatal("expected mount profile to be read-write")
|
t.Fatal("expected mount profile to be read-write")
|
||||||
|
|
@ -415,11 +415,11 @@ func TestControlPlaneProfilesRemainExportSpecificForConfiguredMountPaths(t *test
|
||||||
if docsMount.MountURL == mediaMount.MountURL {
|
if docsMount.MountURL == mediaMount.MountURL {
|
||||||
t.Fatalf("expected distinct mount URLs for configured export paths, got %q", docsMount.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/fixture/" {
|
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/fixture/", docsMount.MountURL)
|
t.Fatalf("expected docs mount URL %q, got %q", "http://nas.local:8090/dav/exports/docs/", docsMount.MountURL)
|
||||||
}
|
}
|
||||||
if mediaMount.MountURL != "http://nas.local:8090/dav/exports/media/fixture/" {
|
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/fixture/", mediaMount.MountURL)
|
t.Fatalf("expected media mount URL %q, got %q", "http://nas.local:8090/dav/exports/media/", mediaMount.MountURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
docsCloud := postJSONAuth[cloudProfile](t, server.Client(), testClientToken, server.URL+"/api/v1/cloud-profiles/issue", cloudProfileRequest{
|
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"})
|
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/fixture/" {
|
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/fixture/", mount.MountURL)
|
t.Fatalf("expected relay mount URL %q, got %q", "https://nas.example.test/control/dav/relay/", mount.MountURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
registration = registerNode(t, server.Client(), server.URL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{
|
registration = registerNode(t, server.Client(), server.URL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{
|
||||||
|
|
@ -494,69 +494,6 @@ func TestControlPlaneMountProfilesUseRelayAndPreserveBasePath(t *testing.T) {
|
||||||
postJSONAuthStatus(t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: "dev-export-2"}, http.StatusServiceUnavailable)
|
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) {
|
func TestControlPlaneCloudProfilesRequireConfiguredBaseURLAndExistingExport(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|
@ -648,8 +585,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})
|
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/fixture/" {
|
if mount.MountURL != "http://nas.local:8090/dav/persisted/" {
|
||||||
t.Fatalf("expected persisted mount URL %q, got %q", "http://nas.local:8090/dav/persisted/fixture/", mount.MountURL)
|
t.Fatalf("expected persisted mount URL %q, got %q", "http://nas.local:8090/dav/persisted/", mount.MountURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
reRegistration := registerNode(t, secondServer.Client(), secondServer.URL+"/api/v1/nodes/register", registration.NodeToken, nodeRegistrationRequest{
|
reRegistration := registerNode(t, secondServer.Client(), secondServer.URL+"/api/v1/nodes/register", registration.NodeToken, nodeRegistrationRequest{
|
||||||
|
|
@ -808,8 +745,6 @@ func TestControlPlaneRejectsInvalidRequestsAndEnforcesAuth(t *testing.T) {
|
||||||
LastSeenAt: "2025-01-02T03:04:05Z",
|
LastSeenAt: "2025-01-02T03:04:05Z",
|
||||||
}, http.StatusNotFound)
|
}, 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(), "", server.URL+"/api/v1/exports", http.StatusUnauthorized)
|
||||||
getStatusWithAuth(t, server.Client(), "wrong-client-token", server.URL+"/api/v1/exports", http.StatusUnauthorized)
|
getStatusWithAuth(t, server.Client(), "wrong-client-token", server.URL+"/api/v1/exports", http.StatusUnauthorized)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -346,27 +346,6 @@ func (s *sqliteStore) listExports(ownerID string) []storageExport {
|
||||||
return exports
|
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 {
|
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)
|
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 {
|
if err != nil {
|
||||||
|
|
@ -422,29 +401,15 @@ func (s *sqliteStore) exportContext(exportID string, ownerID string) (exportCont
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *sqliteStore) nodeByID(nodeID string) (nasNode, bool) {
|
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 n nasNode
|
||||||
var directAddr, relayAddr sql.NullString
|
var directAddr, relayAddr sql.NullString
|
||||||
var lastSeenAt sql.NullString
|
var lastSeenAt sql.NullString
|
||||||
var ownerID sql.NullString
|
var ownerID sql.NullString
|
||||||
err := scanner.Scan(&n.ID, &n.MachineID, &ownerID, &n.DisplayName, &n.AgentVersion, &n.Status, &lastSeenAt, &directAddr, &relayAddr)
|
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)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nasNode{}
|
return nasNode{}, false
|
||||||
}
|
}
|
||||||
if ownerID.Valid {
|
if ownerID.Valid {
|
||||||
n.OwnerID = ownerID.String
|
n.OwnerID = ownerID.String
|
||||||
|
|
@ -458,7 +423,7 @@ func (s *sqliteStore) scanNode(scanner sqliteNodeScanner) nasNode {
|
||||||
if relayAddr.Valid {
|
if relayAddr.Valid {
|
||||||
n.RelayAddress = &relayAddr.String
|
n.RelayAddress = &relayAddr.String
|
||||||
}
|
}
|
||||||
return n
|
return n, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *sqliteStore) nodeAuthByMachineID(machineID string) (nodeAuthState, bool) {
|
func (s *sqliteStore) nodeAuthByMachineID(machineID string) (nodeAuthState, bool) {
|
||||||
|
|
|
||||||
|
|
@ -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"})
|
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/fixture/" {
|
if mount.MountURL != "http://nas.local:8090/dav/docs/" {
|
||||||
t.Fatalf("expected mount URL %q, got %q", "http://nas.local:8090/dav/docs/fixture/", mount.MountURL)
|
t.Fatalf("expected mount URL %q, got %q", "http://nas.local:8090/dav/docs/", mount.MountURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -320,25 +320,6 @@ func (s *memoryStore) listExports(ownerID string) []storageExport {
|
||||||
return exports
|
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) {
|
func (s *memoryStore) exportContext(exportID string, ownerID string) (exportContext, bool) {
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ type store interface {
|
||||||
upsertExports(nodeID string, ownerID string, request nodeExportsRequest) ([]storageExport, error)
|
upsertExports(nodeID string, ownerID string, request nodeExportsRequest) ([]storageExport, error)
|
||||||
recordHeartbeat(nodeID string, ownerID string, request nodeHeartbeatRequest) error
|
recordHeartbeat(nodeID string, ownerID string, request nodeHeartbeatRequest) error
|
||||||
listExports(ownerID string) []storageExport
|
listExports(ownerID string) []storageExport
|
||||||
listNodes(ownerID string) []nasNode
|
|
||||||
exportContext(exportID string, ownerID string) (exportContext, bool)
|
exportContext(exportID string, ownerID string) (exportContext, bool)
|
||||||
nodeByID(nodeID string) (nasNode, bool)
|
nodeByID(nodeID string) (nasNode, bool)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,13 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"golang.org/x/net/webdav"
|
"golang.org/x/net/webdav"
|
||||||
)
|
)
|
||||||
|
|
@ -30,7 +27,6 @@ type app struct {
|
||||||
authUsername string
|
authUsername string
|
||||||
authPassword string
|
authPassword string
|
||||||
exportMounts []exportMount
|
exportMounts []exportMount
|
||||||
controlPlane *controlPlaneSession
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type exportMount struct {
|
type exportMount struct {
|
||||||
|
|
@ -73,92 +69,17 @@ func newAppFromEnv() (*app, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var controlPlane *controlPlaneSession
|
|
||||||
if strings.TrimSpace(env("BETTERNAS_CONTROL_PLANE_URL", "")) != "" {
|
if strings.TrimSpace(env("BETTERNAS_CONTROL_PLANE_URL", "")) != "" {
|
||||||
session, err := bootstrapNodeAgentFromEnv(exportPaths)
|
if _, err := bootstrapNodeAgentFromEnv(exportPaths); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
controlPlane = &session
|
|
||||||
}
|
}
|
||||||
|
|
||||||
app, err := newApp(appConfig{
|
return newApp(appConfig{
|
||||||
exportPaths: exportPaths,
|
exportPaths: exportPaths,
|
||||||
authUsername: authUsername,
|
authUsername: authUsername,
|
||||||
authPassword: authPassword,
|
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) {
|
func exportPathsFromEnv() ([]string, error) {
|
||||||
|
|
@ -234,24 +155,12 @@ func (a *app) handler() http.Handler {
|
||||||
for _, mount := range a.exportMounts {
|
for _, mount := range a.exportMounts {
|
||||||
mountPathPrefix := strings.TrimSuffix(mount.mountPath, "/")
|
mountPathPrefix := strings.TrimSuffix(mount.mountPath, "/")
|
||||||
fs := webdav.Dir(mount.exportPath)
|
fs := webdav.Dir(mount.exportPath)
|
||||||
lockSystem := webdav.NewMemLS()
|
|
||||||
dav := &webdav.Handler{
|
dav := &webdav.Handler{
|
||||||
Prefix: mountPathPrefix,
|
Prefix: mountPathPrefix,
|
||||||
FileSystem: fs,
|
FileSystem: fs,
|
||||||
LockSystem: lockSystem,
|
LockSystem: webdav.NewMemLS(),
|
||||||
}
|
}
|
||||||
mux.Handle(mount.mountPath, a.requireDAVAuth(mount, finderCompatible(dav, fs, mountPathPrefix)))
|
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
|
return mux
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,8 @@ import (
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type controlPlaneSession struct {
|
type bootstrapResult struct {
|
||||||
nodeID string
|
nodeID string
|
||||||
controlPlaneURL string
|
|
||||||
sessionToken string
|
|
||||||
heartbeatInterval time.Duration
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type nodeRegistrationRequest struct {
|
type nodeRegistrationRequest struct {
|
||||||
|
|
@ -61,23 +58,19 @@ type nodeHeartbeatRequest struct {
|
||||||
LastSeenAt string `json:"lastSeenAt"`
|
LastSeenAt string `json:"lastSeenAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func bootstrapNodeAgentFromEnv(exportPaths []string) (controlPlaneSession, error) {
|
func bootstrapNodeAgentFromEnv(exportPaths []string) (bootstrapResult, error) {
|
||||||
controlPlaneURL := strings.TrimSpace(env("BETTERNAS_CONTROL_PLANE_URL", "https://api.betternas.com"))
|
controlPlaneURL := strings.TrimSpace(env("BETTERNAS_CONTROL_PLANE_URL", "https://api.betternas.com"))
|
||||||
if controlPlaneURL == "" {
|
if controlPlaneURL == "" {
|
||||||
return controlPlaneSession{}, fmt.Errorf("BETTERNAS_CONTROL_PLANE_URL is required")
|
return bootstrapResult{}, fmt.Errorf("BETTERNAS_CONTROL_PLANE_URL is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
username, err := requiredEnv("BETTERNAS_USERNAME")
|
username, err := requiredEnv("BETTERNAS_USERNAME")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return controlPlaneSession{}, err
|
return bootstrapResult{}, err
|
||||||
}
|
}
|
||||||
password, err := requiredEnv("BETTERNAS_PASSWORD")
|
password, err := requiredEnv("BETTERNAS_PASSWORD")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return controlPlaneSession{}, err
|
return bootstrapResult{}, err
|
||||||
}
|
|
||||||
heartbeatInterval, err := heartbeatIntervalFromEnv()
|
|
||||||
if err != nil {
|
|
||||||
return controlPlaneSession{}, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
machineID := strings.TrimSpace(env("BETTERNAS_NODE_MACHINE_ID", defaultNodeMachineID(username)))
|
machineID := strings.TrimSpace(env("BETTERNAS_NODE_MACHINE_ID", defaultNodeMachineID(username)))
|
||||||
|
|
@ -89,7 +82,7 @@ func bootstrapNodeAgentFromEnv(exportPaths []string) (controlPlaneSession, error
|
||||||
client := &http.Client{Timeout: 5 * time.Second}
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
sessionToken, err := loginWithControlPlane(client, controlPlaneURL, username, password)
|
sessionToken, err := loginWithControlPlane(client, controlPlaneURL, username, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return controlPlaneSession{}, err
|
return bootstrapResult{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
registration, err := registerNodeWithControlPlane(client, controlPlaneURL, sessionToken, nodeRegistrationRequest{
|
registration, err := registerNodeWithControlPlane(client, controlPlaneURL, sessionToken, nodeRegistrationRequest{
|
||||||
|
|
@ -100,22 +93,17 @@ func bootstrapNodeAgentFromEnv(exportPaths []string) (controlPlaneSession, error
|
||||||
RelayAddress: optionalEnvPointer("BETTERNAS_NODE_RELAY_ADDRESS"),
|
RelayAddress: optionalEnvPointer("BETTERNAS_NODE_RELAY_ADDRESS"),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return controlPlaneSession{}, err
|
return bootstrapResult{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := syncNodeExportsWithControlPlane(client, controlPlaneURL, sessionToken, registration.ID, buildStorageExportInputs(exportPaths)); err != nil {
|
if err := syncNodeExportsWithControlPlane(client, controlPlaneURL, sessionToken, registration.ID, buildStorageExportInputs(exportPaths)); err != nil {
|
||||||
return controlPlaneSession{}, err
|
return bootstrapResult{}, err
|
||||||
}
|
}
|
||||||
if err := sendNodeHeartbeatAt(client, controlPlaneURL, sessionToken, registration.ID, time.Now().UTC()); err != nil {
|
if err := sendNodeHeartbeat(client, controlPlaneURL, sessionToken, registration.ID); err != nil {
|
||||||
return controlPlaneSession{}, err
|
return bootstrapResult{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return controlPlaneSession{
|
return bootstrapResult{nodeID: registration.ID}, nil
|
||||||
nodeID: registration.ID,
|
|
||||||
controlPlaneURL: controlPlaneURL,
|
|
||||||
sessionToken: sessionToken,
|
|
||||||
heartbeatInterval: heartbeatInterval,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func loginWithControlPlane(client *http.Client, baseURL string, username string, password string) (string, error) {
|
func loginWithControlPlane(client *http.Client, baseURL string, username string, password string) (string, error) {
|
||||||
|
|
@ -180,14 +168,10 @@ func syncNodeExportsWithControlPlane(client *http.Client, baseURL string, token
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendNodeHeartbeat(client *http.Client, baseURL string, token string, nodeID string) error {
|
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{
|
response, err := doControlPlaneJSONRequest(client, http.MethodPost, controlPlaneEndpoint(baseURL, "/api/v1/nodes/"+nodeID+"/heartbeat"), token, nodeHeartbeatRequest{
|
||||||
NodeID: nodeID,
|
NodeID: nodeID,
|
||||||
Status: "online",
|
Status: "online",
|
||||||
LastSeenAt: at.UTC().Format(time.RFC3339),
|
LastSeenAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +1,9 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -17,19 +14,6 @@ func main() {
|
||||||
log.Fatal(err)
|
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{
|
server := &http.Server{
|
||||||
Addr: ":" + port,
|
Addr: ":" + port,
|
||||||
Handler: app.handler(),
|
Handler: app.handler(),
|
||||||
|
|
|
||||||
|
|
@ -1,283 +0,0 @@
|
||||||
"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<User | null>(null);
|
|
||||||
const [nodes, setNodes] = useState<NasNode[]>([]);
|
|
||||||
const [exports, setExports] = useState<StorageExport[]>([]);
|
|
||||||
const [selectedExportId, setSelectedExportId] = useState<string | null>(null);
|
|
||||||
const [mountProfile, setMountProfile] = useState<MountProfile | null>(null);
|
|
||||||
const [feedback, setFeedback] = useState<string | null>(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 (
|
|
||||||
<main className="flex min-h-screen items-center justify-center bg-background">
|
|
||||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedExport = selectedExportId
|
|
||||||
? (exports.find((e) => e.id === selectedExportId) ?? null)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-background">
|
|
||||||
<div className="mx-auto flex max-w-5xl flex-col gap-8 px-4 py-8 sm:px-6">
|
|
||||||
{/* header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
|
|
||||||
>
|
|
||||||
betterNAS
|
|
||||||
</Link>
|
|
||||||
<h1 className="text-xl font-semibold tracking-tight">
|
|
||||||
Control Plane
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
{user && (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Link
|
|
||||||
href="/docs"
|
|
||||||
className="text-sm text-muted-foreground transition-colors hover:text-foreground"
|
|
||||||
>
|
|
||||||
Docs
|
|
||||||
</Link>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{user.username}
|
|
||||||
</span>
|
|
||||||
<Button variant="ghost" size="sm" onClick={handleLogout}>
|
|
||||||
<SignOut className="mr-1 size-4" />
|
|
||||||
Sign out
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{feedback !== null && (
|
|
||||||
<div className="rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
|
|
||||||
{feedback}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* nodes */}
|
|
||||||
<section className="flex flex-col gap-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h2 className="text-sm font-medium">Nodes</h2>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{nodes.filter((n) => n.status === "online").length} online
|
|
||||||
{nodes.filter((n) => n.status === "offline").length > 0 &&
|
|
||||||
`, ${nodes.filter((n) => n.status === "offline").length} offline`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{nodes.length === 0 ? (
|
|
||||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
|
||||||
No nodes registered yet. Install and start the node agent on the
|
|
||||||
machine that owns your files.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
{nodes.map((node) => (
|
|
||||||
<div
|
|
||||||
key={node.id}
|
|
||||||
className="flex items-start justify-between gap-4 rounded-lg border p-4"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-1 overflow-hidden">
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
{node.displayName}
|
|
||||||
</span>
|
|
||||||
<span className="truncate text-xs text-muted-foreground">
|
|
||||||
{node.directAddress ?? node.relayAddress ?? node.machineId}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Last seen {formatTimestamp(node.lastSeenAt)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
variant={node.status === "offline" ? "outline" : "secondary"}
|
|
||||||
className={cn(
|
|
||||||
"shrink-0",
|
|
||||||
node.status === "online" &&
|
|
||||||
"bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
|
|
||||||
node.status === "degraded" &&
|
|
||||||
"bg-amber-500/15 text-amber-700 dark:text-amber-300",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{node.status}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* exports + mount */}
|
|
||||||
<div className="grid gap-8 lg:grid-cols-[1fr_380px]">
|
|
||||||
{/* exports list */}
|
|
||||||
<section className="flex flex-col gap-4">
|
|
||||||
<h2 className="text-sm font-medium">Exports</h2>
|
|
||||||
|
|
||||||
{exports.length === 0 ? (
|
|
||||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
|
||||||
{nodes.length === 0
|
|
||||||
? "No exports yet. Start the node agent to register one."
|
|
||||||
: "No connected exports. Start the node agent or wait for reconnection."}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{exports.map((exp) => {
|
|
||||||
const isSelected = exp.id === selectedExportId;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={exp.id}
|
|
||||||
onClick={() => handleSelectExport(exp.id)}
|
|
||||||
className={cn(
|
|
||||||
"flex items-start justify-between gap-4 rounded-lg border p-4 text-left text-sm transition-colors",
|
|
||||||
isSelected
|
|
||||||
? "border-foreground/20 bg-muted/50"
|
|
||||||
: "hover:bg-muted/30",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-1 overflow-hidden">
|
|
||||||
<span className="font-medium">{exp.label}</span>
|
|
||||||
<span className="truncate text-xs text-muted-foreground">
|
|
||||||
{exp.path}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">
|
|
||||||
{exp.protocols.join(", ")}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* mount profile */}
|
|
||||||
<section className="flex flex-col gap-4">
|
|
||||||
<h2 className="text-sm font-medium">
|
|
||||||
{selectedExport ? `Mount ${selectedExport.label}` : "Mount"}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
{mountProfile === null ? (
|
|
||||||
<p className="py-6 text-center text-sm text-muted-foreground">
|
|
||||||
Select an export to see the mount URL and credentials.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-5">
|
|
||||||
<CopyField label="Mount URL" value={mountProfile.mountUrl} />
|
|
||||||
<CopyField
|
|
||||||
label="Username"
|
|
||||||
value={mountProfile.credential.username}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<p className="rounded-lg border bg-muted/20 px-3 py-2.5 text-xs leading-relaxed text-muted-foreground">
|
|
||||||
Use your betterNAS account password when Finder prompts. v1
|
|
||||||
does not issue a separate WebDAV password.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<h3 className="text-xs font-medium">Finder steps</h3>
|
|
||||||
<ol className="flex flex-col gap-1 text-xs text-muted-foreground">
|
|
||||||
<li>1. Go > Connect to Server in Finder.</li>
|
|
||||||
<li>2. Paste the mount URL.</li>
|
|
||||||
<li>3. Enter your betterNAS username and password.</li>
|
|
||||||
<li>4. Optionally save to Keychain.</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
@ -1,196 +0,0 @@
|
||||||
"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 (
|
|
||||||
<div className="group relative">
|
|
||||||
{label && (
|
|
||||||
<span className="mb-1.5 block text-xs text-muted-foreground">
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<pre className="overflow-x-auto rounded-lg border bg-muted/40 p-4 pr-12 font-mono text-xs leading-relaxed text-foreground">
|
|
||||||
<code>{children}</code>
|
|
||||||
</pre>
|
|
||||||
<button
|
|
||||||
onClick={async () => {
|
|
||||||
await navigator.clipboard.writeText(children);
|
|
||||||
setCopied(true);
|
|
||||||
window.setTimeout(() => setCopied(false), 1500);
|
|
||||||
}}
|
|
||||||
className="absolute right-2 top-2 flex items-center gap-1 rounded-md border bg-background/80 px-2 py-1 text-xs text-muted-foreground opacity-0 backdrop-blur transition-opacity hover:text-foreground group-hover:opacity-100"
|
|
||||||
aria-label="Copy to clipboard"
|
|
||||||
>
|
|
||||||
{copied ? (
|
|
||||||
<>
|
|
||||||
<Check size={12} weight="bold" /> Copied
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Copy size={12} /> Copy
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DocsPage() {
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-background">
|
|
||||||
<div className="mx-auto flex max-w-2xl flex-col gap-10 px-4 py-12 sm:px-6">
|
|
||||||
{/* header */}
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
|
|
||||||
>
|
|
||||||
<svg className="size-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
||||||
<path d="M10 3L5 8l5 5" />
|
|
||||||
</svg>
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/login"
|
|
||||||
className="text-sm text-muted-foreground transition-colors hover:text-foreground"
|
|
||||||
>
|
|
||||||
Sign in
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">
|
|
||||||
betterNAS
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-4 text-lg font-semibold tracking-tight">
|
|
||||||
Getting started
|
|
||||||
</h2>
|
|
||||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
|
||||||
One account works everywhere: the web app, the node agent, and
|
|
||||||
Finder. Set up the node, confirm it is online, then mount your
|
|
||||||
export.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* prerequisites */}
|
|
||||||
<section className="flex flex-col gap-3">
|
|
||||||
<h2 className="text-sm font-medium">Prerequisites</h2>
|
|
||||||
<ul className="flex flex-col gap-1.5 text-sm text-muted-foreground">
|
|
||||||
<li>- A betterNAS account</li>
|
|
||||||
<li>- A machine with the files you want to expose</li>
|
|
||||||
<li>- An export folder on that machine</li>
|
|
||||||
<li>
|
|
||||||
- A public HTTPS URL that reaches your node directly (for Finder
|
|
||||||
mounting)
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* step 1 */}
|
|
||||||
<section className="flex flex-col gap-3">
|
|
||||||
<h2 className="text-sm font-medium">1. Install the node binary</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Run this on the machine that owns the files.
|
|
||||||
</p>
|
|
||||||
<CodeBlock>
|
|
||||||
{`curl -fsSL https://raw.githubusercontent.com/harivansh-afk/betterNAS/main/scripts/install-betternas-node.sh | sh`}
|
|
||||||
</CodeBlock>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* step 2 */}
|
|
||||||
<section className="flex flex-col gap-3">
|
|
||||||
<h2 className="text-sm font-medium">2. Start the node</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Replace the placeholders with your account, export path, and public
|
|
||||||
node URL.
|
|
||||||
</p>
|
|
||||||
<CodeBlock>
|
|
||||||
{`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`}
|
|
||||||
</CodeBlock>
|
|
||||||
<div className="flex flex-col gap-1 text-sm text-muted-foreground">
|
|
||||||
<p>
|
|
||||||
<span className="font-medium text-foreground">Export path</span>{" "}
|
|
||||||
- the directory you want to expose through betterNAS.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<span className="font-medium text-foreground">
|
|
||||||
Direct address
|
|
||||||
</span>{" "}
|
|
||||||
- the real public HTTPS base URL that reaches your node directly.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* step 3 */}
|
|
||||||
<section className="flex flex-col gap-3">
|
|
||||||
<h2 className="text-sm font-medium">3. Confirm the node is online</h2>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Open the control plane after the node starts. You should see:
|
|
||||||
</p>
|
|
||||||
<ul className="flex flex-col gap-1.5 text-sm text-muted-foreground">
|
|
||||||
<li>- Your node appears as online</li>
|
|
||||||
<li>- Your export appears in the exports list</li>
|
|
||||||
<li>
|
|
||||||
- Issuing a mount profile gives you a WebDAV URL, not an HTML
|
|
||||||
login page
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* step 4 */}
|
|
||||||
<section className="flex flex-col gap-3">
|
|
||||||
<h2 className="text-sm font-medium">4. Mount in Finder</h2>
|
|
||||||
<ol className="flex flex-col gap-1.5 text-sm text-muted-foreground">
|
|
||||||
<li>1. Open Finder, then Go > Connect to Server.</li>
|
|
||||||
<li>
|
|
||||||
2. Copy the mount URL from the control plane and paste it in.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
3. Sign in with the same username and password you used for the
|
|
||||||
web app and node agent.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
4. Save to Keychain only if you want Finder to remember the
|
|
||||||
password.
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* note about public urls */}
|
|
||||||
<section className="flex flex-col gap-2 rounded-lg border border-border/60 bg-muted/20 px-4 py-3">
|
|
||||||
<h2 className="text-sm font-medium">A note on public URLs</h2>
|
|
||||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
|
||||||
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{" "}
|
|
||||||
<code className="rounded bg-muted px-1 py-0.5 text-xs">/dav/</code>{" "}
|
|
||||||
on your node URL. A working node responds with WebDAV headers, not
|
|
||||||
HTML.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,446 +0,0 @@
|
||||||
"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 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: "The goal is to build a modular filesystem you actually use natively",
|
|
||||||
},
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Icons */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
function GithubIcon({ className }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<svg viewBox="0 0 16 16" fill="currentColor" className={className}>
|
|
||||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ClockIcon() {
|
|
||||||
return (
|
|
||||||
<svg className="size-[18px] text-[#65a2f8]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<path d="M12 6v6l4 2" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SharedIcon() {
|
|
||||||
return (
|
|
||||||
<svg className="size-[18px] text-[#65a2f8]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
||||||
<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2" />
|
|
||||||
<circle cx="9" cy="7" r="4" />
|
|
||||||
<path d="M23 21v-2a4 4 0 00-3-3.87M16 3.13a4 4 0 010 7.75" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LibraryIcon() {
|
|
||||||
return (
|
|
||||||
<svg className="size-[18px] text-[#65a2f8]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
||||||
<path d="M3 21h18M3 7v14M21 7v14M6 7V3h12v4M9 21V11M15 21V11" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AppIcon() {
|
|
||||||
return (
|
|
||||||
<svg className="size-[18px] text-[#65a2f8]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
||||||
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DesktopIcon() {
|
|
||||||
return (
|
|
||||||
<svg className="size-[18px] text-[#65a2f8]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
||||||
<rect x="2" y="3" width="20" height="14" rx="2" />
|
|
||||||
<path d="M8 21h8M12 17v4" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DownloadIcon() {
|
|
||||||
return (
|
|
||||||
<svg className="size-[18px] text-[#65a2f8]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
||||||
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function DocumentsIcon() {
|
|
||||||
return (
|
|
||||||
<svg className="size-[18px] text-[#65a2f8]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
||||||
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
|
||||||
<polyline points="14,2 14,8 20,8" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FolderIcon({ className }: { className?: string }) {
|
|
||||||
return (
|
|
||||||
<svg className={className ?? "size-[18px] text-[#65a2f8]"} viewBox="0 0 24 24" fill="currentColor">
|
|
||||||
<path d="M2 6a2 2 0 012-2h5l2 2h9a2 2 0 012 2v10a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CloudIcon() {
|
|
||||||
return (
|
|
||||||
<svg className="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
||||||
<path d="M18 10h-1.26A8 8 0 109 20h9a5 5 0 000-10z" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function HomeIcon() {
|
|
||||||
return (
|
|
||||||
<svg className="size-[18px] text-[#65a2f8]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
||||||
<path d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0a1 1 0 01-1-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 01-1 1" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function NetworkIcon() {
|
|
||||||
return (
|
|
||||||
<svg className="size-[18px] text-[#65a2f8]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
||||||
<rect x="2" y="3" width="20" height="14" rx="2" />
|
|
||||||
<path d="M8 21h8M12 17v4" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AirdropIcon() {
|
|
||||||
return (
|
|
||||||
<svg className="size-[18px] text-[#65a2f8]" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
||||||
<circle cx="12" cy="12" r="3" />
|
|
||||||
<path d="M16.24 7.76a6 6 0 010 8.49M7.76 16.24a6 6 0 010-8.49M19.07 4.93a10 10 0 010 14.14M4.93 19.07a10 10 0 010-14.14" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* README modal (Quick Look style) */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
function ReadmeModal({ onClose }: { onClose: () => void }) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="relative mx-4 flex max-h-[80vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl border border-border bg-card shadow-2xl"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* titlebar */}
|
|
||||||
<div className="flex shrink-0 items-center justify-between border-b border-border px-4 py-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="flex size-3 items-center justify-center rounded-full bg-[#ff5f57] transition-opacity hover:opacity-80"
|
|
||||||
aria-label="Close"
|
|
||||||
/>
|
|
||||||
<span className="size-3 rounded-full bg-[#febc2e]" />
|
|
||||||
<span className="size-3 rounded-full bg-[#28c840]" />
|
|
||||||
</div>
|
|
||||||
<span className="text-xs font-medium text-muted-foreground">
|
|
||||||
README.md
|
|
||||||
</span>
|
|
||||||
<span className="w-[52px]" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* body */}
|
|
||||||
<div className="overflow-y-auto p-6">
|
|
||||||
<div className="prose-sm max-w-none space-y-4 text-foreground">
|
|
||||||
{README_LINES.map((block, i) => {
|
|
||||||
if (block.tag === "h1")
|
|
||||||
return (
|
|
||||||
<h1
|
|
||||||
key={i}
|
|
||||||
className="text-2xl font-bold tracking-tight text-foreground"
|
|
||||||
>
|
|
||||||
{block.text}
|
|
||||||
</h1>
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<p key={i} className="text-sm leading-relaxed text-muted-foreground">
|
|
||||||
{block.text}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Finder sidebar item */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
function SidebarItem({
|
|
||||||
icon,
|
|
||||||
label,
|
|
||||||
active,
|
|
||||||
accent,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
icon: React.ReactNode;
|
|
||||||
label: string;
|
|
||||||
active?: boolean;
|
|
||||||
accent?: string;
|
|
||||||
onClick?: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
className={`flex w-full items-center gap-2.5 rounded-md px-2 py-[5px] text-left text-[13px] transition-colors ${
|
|
||||||
active
|
|
||||||
? "bg-primary/15 text-foreground"
|
|
||||||
: "text-muted-foreground hover:bg-muted/50"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className={accent ?? ""}>{icon}</span>
|
|
||||||
<span className="truncate">{label}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Finder file grid item (folder) */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
function GridFolder({
|
|
||||||
name,
|
|
||||||
itemCount,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
name: string;
|
|
||||||
itemCount?: number;
|
|
||||||
onClick?: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
className="group flex flex-col items-center gap-1 rounded-lg p-3 transition-colors hover:bg-muted/50"
|
|
||||||
>
|
|
||||||
<svg className="size-16 text-[#3b9dff] drop-shadow-sm" viewBox="0 0 64 56" fill="currentColor">
|
|
||||||
<path d="M2 8a6 6 0 016-2h14l4 4h30a6 6 0 016 6v32a6 6 0 01-6 6H8a6 6 0 01-6-6V8z" opacity="0.85" />
|
|
||||||
<path d="M2 16h60v32a6 6 0 01-6 6H8a6 6 0 01-6-6V16z" opacity="0.95" />
|
|
||||||
</svg>
|
|
||||||
<span className="max-w-[100px] truncate text-xs text-foreground">
|
|
||||||
{name}
|
|
||||||
</span>
|
|
||||||
{itemCount !== undefined && (
|
|
||||||
<span className="text-[10px] text-muted-foreground">
|
|
||||||
{itemCount} {itemCount === 1 ? "item" : "items"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Finder file grid item (file) */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
function GridFile({
|
|
||||||
name,
|
|
||||||
meta,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
name: string;
|
|
||||||
meta?: string;
|
|
||||||
onClick?: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
className="group flex flex-col items-center gap-1 rounded-lg p-3 transition-colors hover:bg-muted/50"
|
|
||||||
>
|
|
||||||
<div className="relative flex size-16 items-center justify-center">
|
|
||||||
<svg className="size-14 text-muted-foreground/30" viewBox="0 0 48 56" fill="currentColor">
|
|
||||||
<path d="M4 0h28l12 12v40a4 4 0 01-4 4H4a4 4 0 01-4-4V4a4 4 0 014-4z" />
|
|
||||||
<path d="M32 0l12 12H36a4 4 0 01-4-4V0z" opacity="0.5" />
|
|
||||||
</svg>
|
|
||||||
<span className="absolute bottom-2 text-[9px] font-semibold uppercase tracking-wide text-foreground/60">
|
|
||||||
MD
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="max-w-[100px] truncate text-xs text-foreground">
|
|
||||||
{name}
|
|
||||||
</span>
|
|
||||||
{meta && (
|
|
||||||
<span className="text-[10px] text-muted-foreground">{meta}</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* Main page */
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
|
|
||||||
export default function LandingPage() {
|
|
||||||
const [readmeOpen, setReadmeOpen] = useState(false);
|
|
||||||
const [selectedSidebar, setSelectedSidebar] = useState("DAV");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen flex-col bg-background text-foreground">
|
|
||||||
{/* ---- header ---- */}
|
|
||||||
<header className="flex shrink-0 items-center justify-end px-5 py-3.5">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Link
|
|
||||||
href="/docs"
|
|
||||||
className="rounded-xl border border-border bg-muted/30 px-4 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
||||||
>
|
|
||||||
Docs
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/login"
|
|
||||||
className="rounded-xl border border-border bg-muted/30 px-4 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
||||||
>
|
|
||||||
Sign in
|
|
||||||
</Link>
|
|
||||||
<a
|
|
||||||
href="https://github.com/harivansh-afk/betterNAS"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="flex size-8 items-center justify-center rounded-xl border border-border bg-muted/30 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
||||||
aria-label="GitHub"
|
|
||||||
>
|
|
||||||
<GithubIcon className="size-4" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* ---- finder ---- */}
|
|
||||||
<main className="flex flex-1 items-center justify-center p-4 sm:p-8">
|
|
||||||
<div className="w-full max-w-4xl overflow-hidden rounded-xl border border-border bg-card shadow-xl">
|
|
||||||
{/* titlebar */}
|
|
||||||
<div className="flex items-center border-b border-border bg-muted/30 px-4 py-2.5">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="size-3 rounded-full bg-[#ff5f57]" />
|
|
||||||
<span className="size-3 rounded-full bg-[#febc2e]" />
|
|
||||||
<span className="size-3 rounded-full bg-[#28c840]" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mx-auto flex items-center gap-2">
|
|
||||||
<span className="text-sm font-medium text-foreground">DAV</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* forward/back placeholders */}
|
|
||||||
<div className="flex items-center gap-1 text-muted-foreground/40">
|
|
||||||
<svg className="size-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
||||||
<path d="M10 3L5 8l5 5" />
|
|
||||||
</svg>
|
|
||||||
<svg className="size-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
||||||
<path d="M6 3l5 5-5 5" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* content area */}
|
|
||||||
<div className="flex min-h-[480px]">
|
|
||||||
{/* ---- sidebar ---- */}
|
|
||||||
<div className="hidden w-[180px] shrink-0 flex-col gap-0.5 border-r border-border bg-muted/20 p-3 sm:flex">
|
|
||||||
{/* Favorites */}
|
|
||||||
<p className="mb-1 mt-1 px-2 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/50">
|
|
||||||
Favorites
|
|
||||||
</p>
|
|
||||||
<SidebarItem icon={<ClockIcon />} label="Recents" />
|
|
||||||
<SidebarItem icon={<SharedIcon />} label="Shared" />
|
|
||||||
<SidebarItem icon={<LibraryIcon />} label="Library" />
|
|
||||||
<SidebarItem icon={<AppIcon />} label="Applications" />
|
|
||||||
<SidebarItem icon={<DesktopIcon />} label="Desktop" />
|
|
||||||
<SidebarItem icon={<DownloadIcon />} label="Downloads" />
|
|
||||||
<SidebarItem icon={<DocumentsIcon />} label="Documents" />
|
|
||||||
<SidebarItem icon={<FolderIcon className="size-[18px] text-[#65a2f8]" />} label="GitHub" />
|
|
||||||
|
|
||||||
{/* Locations */}
|
|
||||||
<p className="mb-1 mt-4 px-2 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground/50">
|
|
||||||
Locations
|
|
||||||
</p>
|
|
||||||
<SidebarItem icon={<HomeIcon />} label="rathi" />
|
|
||||||
<SidebarItem icon={<NetworkIcon />} label="hari-macbook-pro" />
|
|
||||||
<SidebarItem
|
|
||||||
icon={<CloudIcon />}
|
|
||||||
label="DAV"
|
|
||||||
active={selectedSidebar === "DAV"}
|
|
||||||
accent="text-[#65a2f8]"
|
|
||||||
onClick={() => setSelectedSidebar("DAV")}
|
|
||||||
/>
|
|
||||||
<SidebarItem icon={<AirdropIcon />} label="AirDrop" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ---- file grid ---- */}
|
|
||||||
<div className="flex flex-1 flex-col">
|
|
||||||
{/* toolbar */}
|
|
||||||
<div className="flex items-center justify-between border-b border-border px-4 py-2">
|
|
||||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
||||||
<CloudIcon />
|
|
||||||
<span className="font-medium text-foreground">DAV</span>
|
|
||||||
<span className="text-muted-foreground/50">/</span>
|
|
||||||
<span>exports</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-muted-foreground/50">
|
|
||||||
<svg className="size-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
||||||
<rect x="1" y="1" width="6" height="6" rx="1" />
|
|
||||||
<rect x="9" y="1" width="6" height="6" rx="1" />
|
|
||||||
<rect x="1" y="9" width="6" height="6" rx="1" />
|
|
||||||
<rect x="9" y="9" width="6" height="6" rx="1" />
|
|
||||||
</svg>
|
|
||||||
<svg className="size-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
||||||
<path d="M2 4h12M2 8h12M2 12h12" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* files */}
|
|
||||||
<div className="flex-1 p-4">
|
|
||||||
<div className="grid grid-cols-2 gap-1 sm:grid-cols-4 md:grid-cols-5">
|
|
||||||
<GridFolder name="Movies" itemCount={12} />
|
|
||||||
<GridFolder name="Music" itemCount={847} />
|
|
||||||
<GridFolder name="Photos" itemCount={3241} />
|
|
||||||
<GridFolder name="Documents" itemCount={56} />
|
|
||||||
<GridFolder name="Backups" itemCount={4} />
|
|
||||||
<GridFile
|
|
||||||
name="README.md"
|
|
||||||
meta="4 KB"
|
|
||||||
onClick={() => setReadmeOpen(true)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* statusbar */}
|
|
||||||
<div className="flex items-center justify-between border-t border-border px-4 py-1.5 text-[11px] text-muted-foreground/50">
|
|
||||||
<span>5 folders, 1 file</span>
|
|
||||||
<span>847 GB available</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* ---- readme modal ---- */}
|
|
||||||
{readmeOpen && <ReadmeModal onClose={() => setReadmeOpen(false)} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
|
||||||
import { login, register, ApiError } from "@/lib/api";
|
import { login, register, ApiError } from "@/lib/api";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
|
@ -32,7 +31,7 @@ export default function LoginPage() {
|
||||||
} else {
|
} else {
|
||||||
await register(username, password);
|
await register(username, password);
|
||||||
}
|
}
|
||||||
router.push("/app");
|
router.push("/");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
|
|
@ -46,25 +45,18 @@ export default function LoginPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-screen items-center justify-center bg-background px-4">
|
<main className="flex min-h-screen items-center justify-center bg-background px-4">
|
||||||
<div className="flex w-full max-w-sm flex-col gap-4">
|
<Card className="w-full max-w-sm">
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="inline-flex items-center gap-1.5 self-start text-sm text-muted-foreground transition-colors hover:text-foreground"
|
|
||||||
>
|
|
||||||
<svg className="size-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
|
||||||
<path d="M10 3L5 8l5 5" />
|
|
||||||
</svg>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader className="text-center">
|
<CardHeader className="text-center">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-widest text-muted-foreground">
|
||||||
|
betterNAS
|
||||||
|
</p>
|
||||||
<CardTitle className="text-xl">
|
<CardTitle className="text-xl">
|
||||||
{mode === "login" ? "Sign in" : "Create account"}
|
{mode === "login" ? "Sign in" : "Create account"}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{mode === "login"
|
{mode === "login"
|
||||||
? "Use the same credentials as your node agent and Finder."
|
? "Sign in to your betterNAS control plane with the same credentials you use for the node agent and Finder."
|
||||||
: "This account works across the web UI, node agent, and Finder."}
|
: "Create your betterNAS account. You will use the same username and password for the web app, node agent, and Finder."}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|
@ -155,7 +147,6 @@ export default function LoginPage() {
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +1,373 @@
|
||||||
export { default } from "./landing/page";
|
"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,
|
||||||
|
issueMountProfile,
|
||||||
|
logout,
|
||||||
|
getMe,
|
||||||
|
type StorageExport,
|
||||||
|
type MountProfile,
|
||||||
|
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<User | null>(null);
|
||||||
|
const [exports, setExports] = useState<StorageExport[]>([]);
|
||||||
|
const [selectedExportId, setSelectedExportId] = useState<string | null>(null);
|
||||||
|
const [mountProfile, setMountProfile] = useState<MountProfile | null>(null);
|
||||||
|
const [feedback, setFeedback] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated()) {
|
||||||
|
router.replace("/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const [me, exps] = await Promise.all([getMe(), listExports()]);
|
||||||
|
setUser(me);
|
||||||
|
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 (
|
||||||
|
<main className="flex min-h-screen items-center justify-center bg-background">
|
||||||
|
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedExport = selectedExportId
|
||||||
|
? (exports.find((e) => e.id === selectedExportId) ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-background">
|
||||||
|
<div className="mx-auto flex max-w-6xl flex-col gap-8 px-4 py-8 sm:px-6">
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<p className="text-xs font-medium uppercase tracking-widest text-muted-foreground">
|
||||||
|
betterNAS
|
||||||
|
</p>
|
||||||
|
<h1 className="font-heading text-2xl font-semibold tracking-tight">
|
||||||
|
Control Plane
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
{user && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{user.username}
|
||||||
|
</span>
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleLogout}>
|
||||||
|
<SignOut className="mr-1 size-4" />
|
||||||
|
Sign out
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="outline">
|
||||||
|
<Globe data-icon="inline-start" />
|
||||||
|
{process.env.NEXT_PUBLIC_BETTERNAS_API_URL || "local"}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{exports.length === 1 ? "1 export" : `${exports.length} exports`}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Node agent setup</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Run the node binary on the machine that owns the files with
|
||||||
|
the same account credentials you use here and in Finder.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<pre className="overflow-x-auto rounded-xl border bg-muted/40 p-4 text-xs text-foreground">
|
||||||
|
<code>
|
||||||
|
{`curl -fsSL https://raw.githubusercontent.com/harivansh-afk/betterNAS/main/scripts/install-betternas-node.sh | sh`}
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
<pre className="overflow-x-auto rounded-xl border bg-muted/40 p-4 text-xs text-foreground">
|
||||||
|
<code>
|
||||||
|
{`BETTERNAS_USERNAME=${user.username} BETTERNAS_PASSWORD=... BETTERNAS_EXPORT_PATH=/path/to/export BETTERNAS_NODE_DIRECT_ADDRESS=https://your-public-node-url betternas-node`}
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{feedback !== null && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<Warning />
|
||||||
|
<AlertTitle>Error</AlertTitle>
|
||||||
|
<AlertDescription>{feedback}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_400px]">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Exports</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Storage exports registered with this control plane.
|
||||||
|
</CardDescription>
|
||||||
|
<CardAction>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{exports.length === 1
|
||||||
|
? "1 export"
|
||||||
|
: `${exports.length} exports`}
|
||||||
|
</Badge>
|
||||||
|
</CardAction>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{exports.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-10 text-center">
|
||||||
|
<HardDrives size={32} className="text-muted-foreground/40" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No exports registered yet. Start the node agent and connect
|
||||||
|
it to this control plane.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{exports.map((storageExport) => {
|
||||||
|
const isSelected = storageExport.id === selectedExportId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={storageExport.id}
|
||||||
|
onClick={() => handleSelectExport(storageExport.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col gap-3 rounded-2xl border p-4 text-left text-sm transition-colors",
|
||||||
|
isSelected
|
||||||
|
? "border-primary/20 bg-primary/5"
|
||||||
|
: "border-border hover:bg-muted/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{storageExport.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{storageExport.id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary" className="shrink-0">
|
||||||
|
{storageExport.protocols.join(", ")}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||||
|
<div>
|
||||||
|
<dt className="mb-0.5 text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Node
|
||||||
|
</dt>
|
||||||
|
<dd className="truncate text-xs text-foreground">
|
||||||
|
{storageExport.nasNodeId}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="mb-0.5 text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Mount path
|
||||||
|
</dt>
|
||||||
|
<dd className="text-xs text-foreground">
|
||||||
|
{storageExport.mountPath ?? "/dav/"}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<dt className="mb-0.5 text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Export path
|
||||||
|
</dt>
|
||||||
|
<dd className="truncate text-xs text-foreground">
|
||||||
|
{storageExport.path}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>
|
||||||
|
{selectedExport !== null
|
||||||
|
? `Mount ${selectedExport.label}`
|
||||||
|
: "Mount instructions"}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{selectedExport !== null
|
||||||
|
? "WebDAV mount details for Finder."
|
||||||
|
: "Select an export to see the mount URL and account login details."}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{mountProfile === null ? (
|
||||||
|
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-10 text-center">
|
||||||
|
<LinkSimple size={32} className="text-muted-foreground/40" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Pick an export to see the Finder mount URL and the username
|
||||||
|
to use with your betterNAS account password.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
Issued profile
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant={mountProfile.readonly ? "secondary" : "default"}
|
||||||
|
>
|
||||||
|
{mountProfile.readonly ? "Read-only" : "Read-write"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<CopyField
|
||||||
|
label="Mount URL"
|
||||||
|
value={mountProfile.mountUrl}
|
||||||
|
/>
|
||||||
|
<CopyField
|
||||||
|
label="Username"
|
||||||
|
value={mountProfile.credential.username}
|
||||||
|
/>
|
||||||
|
<Alert>
|
||||||
|
<AlertTitle>
|
||||||
|
Use your betterNAS account password
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Enter the same password you use to sign in to betterNAS
|
||||||
|
and run the node agent. v1 does not issue a separate
|
||||||
|
WebDAV password.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<dl className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||||
|
<div>
|
||||||
|
<dt className="mb-0.5 text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Mode
|
||||||
|
</dt>
|
||||||
|
<dd className="text-xs text-foreground">
|
||||||
|
{mountProfile.credential.mode}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt className="mb-0.5 text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
|
Password source
|
||||||
|
</dt>
|
||||||
|
<dd className="text-xs text-foreground">
|
||||||
|
Your betterNAS account password
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<h3 className="text-sm font-medium">Finder steps</h3>
|
||||||
|
<ol className="flex flex-col gap-2">
|
||||||
|
{[
|
||||||
|
"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) => (
|
||||||
|
<li
|
||||||
|
key={index}
|
||||||
|
className="flex gap-2.5 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
<span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium text-foreground">
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
{step}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,5 @@
|
||||||
const API_URL = process.env.NEXT_PUBLIC_BETTERNAS_API_URL || "";
|
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 {
|
export interface StorageExport {
|
||||||
id: string;
|
id: string;
|
||||||
nasNodeId: string;
|
nasNodeId: string;
|
||||||
|
|
@ -157,10 +146,6 @@ export async function getMe(): Promise<User> {
|
||||||
return apiFetch<User>("/api/v1/auth/me");
|
return apiFetch<User>("/api/v1/auth/me");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listNodes(): Promise<NasNode[]> {
|
|
||||||
return apiFetch<NasNode[]>("/api/v1/nodes");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listExports(): Promise<StorageExport[]> {
|
export async function listExports(): Promise<StorageExport[]> {
|
||||||
return apiFetch<StorageExport[]>("/api/v1/exports");
|
return apiFetch<StorageExport[]>("/api/v1/exports");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,22 +18,6 @@ paths:
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Control-plane version
|
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:
|
/api/v1/nodes/register:
|
||||||
post:
|
post:
|
||||||
operationId: registerNode
|
operationId: registerNode
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
export const FOUNDATION_API_ROUTES = {
|
export const FOUNDATION_API_ROUTES = {
|
||||||
listNodes: "/api/v1/nodes",
|
|
||||||
registerNode: "/api/v1/nodes/register",
|
registerNode: "/api/v1/nodes/register",
|
||||||
nodeHeartbeat: "/api/v1/nodes/:nodeId/heartbeat",
|
nodeHeartbeat: "/api/v1/nodes/:nodeId/heartbeat",
|
||||||
nodeExports: "/api/v1/nodes/:nodeId/exports",
|
nodeExports: "/api/v1/nodes/:nodeId/exports",
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,7 @@ case "$arch_name" in
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
version_stripped="${version#v}"
|
archive_name="${binary_name}_${version}_${os}_${arch}.tar.gz"
|
||||||
archive_name="${binary_name}_${version_stripped}_${os}_${arch}.tar.gz"
|
|
||||||
download_url="${download_base_url}/${version}/${archive_name}"
|
download_url="${download_base_url}/${version}/${archive_name}"
|
||||||
|
|
||||||
tmp_dir="$(mktemp -d)"
|
tmp_dir="$(mktemp -d)"
|
||||||
|
|
|
||||||
698
thoughts/shared/plans/2026-04-01-production-deployment.md
Normal file
698
thoughts/shared/plans/2026-04-01-production-deployment.md
Normal file
|
|
@ -0,0 +1,698 @@
|
||||||
|
# betterNAS Production Deployment Plan
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Deploy the betterNAS control-plane as a production service on netty (Netcup VPS) with SQLite-backed user auth, NGINX reverse proxy at `api.betternas.com`, and the web frontend on Vercel at `betternas.com`. Replaces the current dev Docker Compose setup with a NixOS-native systemd service matching the existing deployment pattern (forgejo, vaultwarden, sandbox-agent).
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
- Control-plane is a Go binary running in Docker on netty (port 3001->3000)
|
||||||
|
- State is an in-memory store backed by a JSON file
|
||||||
|
- Auth is static tokens from environment variables (no user accounts)
|
||||||
|
- Web frontend reads env vars to find the control-plane URL and client token
|
||||||
|
- Node-agent runs in Docker, connects to control-plane over Docker network
|
||||||
|
- NGINX on netty already reverse-proxies 3 domains with ACME/Let's Encrypt
|
||||||
|
- NixOS config is at `/home/rathi/Documents/GitHub/nix/hosts/netty/configuration.nix`
|
||||||
|
- `betternas.com` is registered on Vercel with nameservers pointed to Vercel DNS
|
||||||
|
|
||||||
|
## Desired End State
|
||||||
|
|
||||||
|
- `api.betternas.com` serves the control-plane Go binary behind NGINX with TLS
|
||||||
|
- `betternas.com` serves the Next.js web UI from Vercel
|
||||||
|
- All state (users, sessions, nodes, exports) lives in SQLite at `/var/lib/betternas/control-plane/betternas.db`
|
||||||
|
- Users log in with username/password on the web UI, get a session cookie
|
||||||
|
- One-click mount: logged-in user clicks an export, backend issues WebDAV credentials using the user's session
|
||||||
|
- Node-agent connects to `api.betternas.com` over HTTPS
|
||||||
|
- Deployment is declarative via NixOS configuration.nix
|
||||||
|
|
||||||
|
### Verification:
|
||||||
|
|
||||||
|
1. `curl https://api.betternas.com/health` returns `ok`
|
||||||
|
2. Web UI at `betternas.com` loads, shows login page
|
||||||
|
3. User can register, log in, see exports, one-click mount
|
||||||
|
4. Node-agent on netty registers and syncs exports to `api.betternas.com`
|
||||||
|
5. WebDAV mount from Finder works with issued credentials
|
||||||
|
|
||||||
|
## What We're NOT Doing
|
||||||
|
|
||||||
|
- Multi-tenant / multi-user RBAC (just simple username/password accounts)
|
||||||
|
- OAuth / SSO / social login
|
||||||
|
- Email verification or password reset flows
|
||||||
|
- Migrating existing JSON state (fresh SQLite DB)
|
||||||
|
- Nextcloud integration (can add later)
|
||||||
|
- CI/CD pipeline (manual deploy via `nixos-rebuild switch`)
|
||||||
|
- Rate limiting or request throttling
|
||||||
|
|
||||||
|
## Implementation Approach
|
||||||
|
|
||||||
|
Five phases, each independently deployable and testable:
|
||||||
|
|
||||||
|
1. **SQLite store** - Replace memoryStore with sqliteStore for all existing state
|
||||||
|
2. **User auth** - Add users/sessions tables, login/register endpoints, session middleware
|
||||||
|
3. **CORS + frontend auth** - Wire the web UI to use session-based auth against `api.betternas.com`
|
||||||
|
4. **NixOS deployment** - Systemd service, NGINX vhost, ACME cert, DNS
|
||||||
|
5. **Vercel deployment** - Deploy web UI, configure domain and env vars
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: SQLite Store
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
Replace `memoryStore` (in-memory + JSON file) with a `sqliteStore` using `modernc.org/sqlite` (pure Go, no CGo, `database/sql` compatible). This keeps all existing API behavior identical while switching the persistence layer.
|
||||||
|
|
||||||
|
### Schema
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Ordinal counters (replaces NextNodeOrdinal / NextExportOrdinal)
|
||||||
|
CREATE TABLE ordinals (
|
||||||
|
name TEXT PRIMARY KEY,
|
||||||
|
value INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
INSERT INTO ordinals (name, value) VALUES ('node', 0), ('export', 0);
|
||||||
|
|
||||||
|
-- Nodes
|
||||||
|
CREATE TABLE nodes (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
machine_id TEXT NOT NULL UNIQUE,
|
||||||
|
display_name TEXT NOT NULL DEFAULT '',
|
||||||
|
agent_version TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT 'online',
|
||||||
|
last_seen_at TEXT,
|
||||||
|
direct_address TEXT,
|
||||||
|
relay_address TEXT,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Node auth tokens (hashed)
|
||||||
|
CREATE TABLE node_tokens (
|
||||||
|
node_id TEXT PRIMARY KEY REFERENCES nodes(id),
|
||||||
|
token_hash TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Storage exports
|
||||||
|
CREATE TABLE exports (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
node_id TEXT NOT NULL REFERENCES nodes(id),
|
||||||
|
label TEXT NOT NULL DEFAULT '',
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
mount_path TEXT NOT NULL DEFAULT '',
|
||||||
|
capacity_bytes INTEGER,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||||
|
UNIQUE(node_id, path)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Export protocols (normalized from JSON array)
|
||||||
|
CREATE TABLE export_protocols (
|
||||||
|
export_id TEXT NOT NULL REFERENCES exports(id) ON DELETE CASCADE,
|
||||||
|
protocol TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (export_id, protocol)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Export tags (normalized from JSON array)
|
||||||
|
CREATE TABLE export_tags (
|
||||||
|
export_id TEXT NOT NULL REFERENCES exports(id) ON DELETE CASCADE,
|
||||||
|
tag TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (export_id, tag)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Changes Required
|
||||||
|
|
||||||
|
#### 1. Add SQLite dependency
|
||||||
|
|
||||||
|
**File**: `apps/control-plane/go.mod`
|
||||||
|
|
||||||
|
```
|
||||||
|
go get modernc.org/sqlite
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. New file: `sqlite_store.go`
|
||||||
|
|
||||||
|
**File**: `apps/control-plane/cmd/control-plane/sqlite_store.go`
|
||||||
|
|
||||||
|
Implements the same operations as `memoryStore` but backed by SQLite:
|
||||||
|
|
||||||
|
- `newSQLiteStore(dbPath string) (*sqliteStore, error)` - opens DB, runs migrations
|
||||||
|
- `registerNode(...)` - INSERT/UPDATE node + token hash in a transaction
|
||||||
|
- `upsertExports(...)` - DELETE removed exports, UPSERT current ones in a transaction
|
||||||
|
- `recordHeartbeat(...)` - UPDATE node status/lastSeenAt
|
||||||
|
- `listExports()` - SELECT all exports with protocols/tags joined
|
||||||
|
- `exportContext(exportID)` - SELECT export + its node
|
||||||
|
- `nodeAuthByMachineID(machineID)` - SELECT node_id + token_hash by machine_id
|
||||||
|
- `nodeAuthByID(nodeID)` - SELECT token_hash by node_id
|
||||||
|
- `nextOrdinal(name)` - UPDATE ordinals SET value = value + 1 RETURNING value
|
||||||
|
|
||||||
|
Key design decisions:
|
||||||
|
|
||||||
|
- Use `database/sql` with `modernc.org/sqlite` driver
|
||||||
|
- WAL mode enabled at connection: `PRAGMA journal_mode=WAL`
|
||||||
|
- Foreign keys enabled: `PRAGMA foreign_keys=ON`
|
||||||
|
- Schema migrations run on startup (embed SQL with `//go:embed`)
|
||||||
|
- All multi-table mutations wrapped in transactions
|
||||||
|
- No ORM - raw SQL with prepared statements
|
||||||
|
|
||||||
|
#### 3. Update `app.go` to use SQLite store
|
||||||
|
|
||||||
|
**File**: `apps/control-plane/cmd/control-plane/app.go`
|
||||||
|
|
||||||
|
Replace `memoryStore` initialization with `sqliteStore`:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Replace:
|
||||||
|
// store, err := newMemoryStore(statePath)
|
||||||
|
// With:
|
||||||
|
// store, err := newSQLiteStore(dbPath)
|
||||||
|
```
|
||||||
|
|
||||||
|
New env var: `BETTERNAS_CONTROL_PLANE_DB_PATH` (default: `/var/lib/betternas/control-plane/betternas.db`)
|
||||||
|
|
||||||
|
#### 4. Update `server.go` to use new store interface
|
||||||
|
|
||||||
|
**File**: `apps/control-plane/cmd/control-plane/server.go`
|
||||||
|
|
||||||
|
The server handlers currently call methods directly on `*memoryStore`. These need to call the equivalent methods on the new store. If the method signatures match, this is a straight swap. If not, introduce a `store` interface that both implement during migration, then delete `memoryStore`.
|
||||||
|
|
||||||
|
### Success Criteria
|
||||||
|
|
||||||
|
#### Automated Verification:
|
||||||
|
|
||||||
|
- [ ] `go build ./apps/control-plane/cmd/control-plane/` compiles with `CGO_ENABLED=0`
|
||||||
|
- [ ] `go test ./apps/control-plane/cmd/control-plane/ -v` passes all existing tests
|
||||||
|
- [ ] New SQLite store tests pass (register node, upsert exports, list exports, auth lookup)
|
||||||
|
- [ ] `curl` against a local instance: register node, sync exports, issue mount profile - all return expected responses
|
||||||
|
|
||||||
|
#### Manual Verification:
|
||||||
|
|
||||||
|
- [ ] Start control-plane locally, SQLite file is created at configured path
|
||||||
|
- [ ] Restart control-plane - state persists across restarts
|
||||||
|
- [ ] Node-agent can register and sync exports against the SQLite-backed control-plane
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: User Auth
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
Add user accounts with username/password (bcrypt) and session tokens stored in SQLite. The session token replaces the static `BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN` for web UI access. Node-agent auth (bootstrap token + node token) is unchanged.
|
||||||
|
|
||||||
|
### Additional Schema
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Users
|
||||||
|
CREATE TABLE users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Sessions
|
||||||
|
CREATE TABLE sessions (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||||
|
expires_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_sessions_expires ON sessions(expires_at);
|
||||||
|
```
|
||||||
|
|
||||||
|
### New API Endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /api/v1/auth/register - Create account (username, password)
|
||||||
|
POST /api/v1/auth/login - Login, returns session token + sets cookie
|
||||||
|
POST /api/v1/auth/logout - Invalidate session
|
||||||
|
GET /api/v1/auth/me - Return current user info (session validation)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Changes Required
|
||||||
|
|
||||||
|
#### 1. New file: `auth.go`
|
||||||
|
|
||||||
|
**File**: `apps/control-plane/cmd/control-plane/auth.go`
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Dependencies: golang.org/x/crypto/bcrypt, crypto/rand
|
||||||
|
|
||||||
|
func (s *sqliteStore) createUser(username, password string) (user, error)
|
||||||
|
// - Validate username (3-64 chars, alphanumeric + underscore/hyphen)
|
||||||
|
// - bcrypt hash the password (cost 10)
|
||||||
|
// - INSERT into users with generated ID
|
||||||
|
// - Return user struct
|
||||||
|
|
||||||
|
func (s *sqliteStore) authenticateUser(username, password string) (user, error)
|
||||||
|
// - SELECT user by username
|
||||||
|
// - bcrypt.CompareHashAndPassword
|
||||||
|
// - Return user or error
|
||||||
|
|
||||||
|
func (s *sqliteStore) createSession(userID string, ttl time.Duration) (string, error)
|
||||||
|
// - Generate 32-byte random token, hex-encode
|
||||||
|
// - INSERT into sessions with expires_at = now + ttl
|
||||||
|
// - Return token
|
||||||
|
|
||||||
|
func (s *sqliteStore) validateSession(token string) (user, error)
|
||||||
|
// - SELECT session JOIN users WHERE token = ? AND expires_at > now
|
||||||
|
// - Return user or error
|
||||||
|
|
||||||
|
func (s *sqliteStore) deleteSession(token string) error
|
||||||
|
// - DELETE FROM sessions WHERE token = ?
|
||||||
|
|
||||||
|
func (s *sqliteStore) cleanExpiredSessions() error
|
||||||
|
// - DELETE FROM sessions WHERE expires_at < now
|
||||||
|
// - Run periodically (e.g., on each request or via goroutine)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. New env vars
|
||||||
|
|
||||||
|
```
|
||||||
|
BETTERNAS_SESSION_TTL # Session duration (default: "720h" = 30 days)
|
||||||
|
BETTERNAS_REGISTRATION_ENABLED # Allow new registrations (default: "true")
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Update `server.go` - auth middleware and routes
|
||||||
|
|
||||||
|
**File**: `apps/control-plane/cmd/control-plane/server.go`
|
||||||
|
|
||||||
|
Add auth routes:
|
||||||
|
|
||||||
|
```go
|
||||||
|
mux.HandleFunc("POST /api/v1/auth/register", s.handleRegister)
|
||||||
|
mux.HandleFunc("POST /api/v1/auth/login", s.handleLogin)
|
||||||
|
mux.HandleFunc("POST /api/v1/auth/logout", s.handleLogout)
|
||||||
|
mux.HandleFunc("GET /api/v1/auth/me", s.handleMe)
|
||||||
|
```
|
||||||
|
|
||||||
|
Update client-auth middleware:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Currently: checks Bearer token against static BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN
|
||||||
|
// New: checks Bearer token against sessions table first, falls back to static token
|
||||||
|
// This preserves backwards compatibility during migration
|
||||||
|
func (s *server) requireClientAuth(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := extractBearerToken(r)
|
||||||
|
|
||||||
|
// Try session-based auth first
|
||||||
|
user, err := s.store.validateSession(token)
|
||||||
|
if err == nil {
|
||||||
|
ctx := context.WithValue(r.Context(), userContextKey, user)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to static client token (for backwards compat / scripts)
|
||||||
|
if secureStringEquals(token, s.config.clientToken) {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeUnauthorized(w)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Success Criteria
|
||||||
|
|
||||||
|
#### Automated Verification:
|
||||||
|
|
||||||
|
- [ ] `go test` passes for auth endpoints (register, login, logout, me)
|
||||||
|
- [ ] `go test` passes for session middleware (valid token, expired token, invalid token)
|
||||||
|
- [ ] Existing client token auth still works (backwards compat)
|
||||||
|
- [ ] Existing node auth unchanged
|
||||||
|
|
||||||
|
#### Manual Verification:
|
||||||
|
|
||||||
|
- [ ] Register a user via curl, login, use session token to list exports
|
||||||
|
- [ ] Session expires after TTL
|
||||||
|
- [ ] Logout invalidates session immediately
|
||||||
|
- [ ] Registration can be disabled via env var
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: CORS + Frontend Auth Integration
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
Add CORS headers to the control-plane so the Vercel-hosted frontend can make API calls. Update the web frontend to use session-based auth (login page, session cookie/token management).
|
||||||
|
|
||||||
|
### Changes Required
|
||||||
|
|
||||||
|
#### 1. CORS middleware in control-plane
|
||||||
|
|
||||||
|
**File**: `apps/control-plane/cmd/control-plane/server.go`
|
||||||
|
|
||||||
|
```go
|
||||||
|
// New env var: BETTERNAS_CORS_ORIGIN (e.g., "https://betternas.com")
|
||||||
|
|
||||||
|
func corsMiddleware(allowedOrigin string, next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", allowedOrigin)
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||||
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||||
|
w.Header().Set("Access-Control-Max-Age", "86400")
|
||||||
|
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Frontend auth flow
|
||||||
|
|
||||||
|
**Files**: `apps/web/`
|
||||||
|
|
||||||
|
New pages/components:
|
||||||
|
|
||||||
|
- `app/login/page.tsx` - Login form (username + password)
|
||||||
|
- `app/register/page.tsx` - Registration form (if enabled)
|
||||||
|
- `lib/auth.ts` - Client-side auth helpers (store token, attach to requests)
|
||||||
|
|
||||||
|
Update `lib/control-plane.ts`:
|
||||||
|
|
||||||
|
- Remove `.env.agent` file reading (production doesn't need it)
|
||||||
|
- Read `NEXT_PUBLIC_BETTERNAS_API_URL` env var for the backend URL
|
||||||
|
- Use session token from localStorage/cookie instead of static client token
|
||||||
|
- Add login/register/logout API calls
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// lib/auth.ts
|
||||||
|
const TOKEN_KEY = "betternas_session";
|
||||||
|
|
||||||
|
export function getSessionToken(): string | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
return localStorage.getItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSessionToken(token: string): void {
|
||||||
|
localStorage.setItem(TOKEN_KEY, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSessionToken(): void {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(
|
||||||
|
apiUrl: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const res = await fetch(`${apiUrl}/api/v1/auth/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Login failed");
|
||||||
|
const data = await res.json();
|
||||||
|
setSessionToken(data.token);
|
||||||
|
return data.token;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `lib/control-plane.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Replace the current getControlPlaneConfig with:
|
||||||
|
export function getControlPlaneConfig(): ControlPlaneConfig {
|
||||||
|
const baseUrl = process.env.NEXT_PUBLIC_BETTERNAS_API_URL || null;
|
||||||
|
const clientToken = getSessionToken();
|
||||||
|
return { baseUrl, clientToken };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Auth-gated layout
|
||||||
|
|
||||||
|
**File**: `apps/web/app/layout.tsx` or a middleware
|
||||||
|
|
||||||
|
Redirect to `/login` if no valid session. The `/login` and `/register` pages are public.
|
||||||
|
|
||||||
|
### Success Criteria
|
||||||
|
|
||||||
|
#### Automated Verification:
|
||||||
|
|
||||||
|
- [ ] CORS preflight (OPTIONS) returns correct headers
|
||||||
|
- [ ] Frontend builds: `cd apps/web && pnpm build`
|
||||||
|
- [ ] No TypeScript errors
|
||||||
|
|
||||||
|
#### Manual Verification:
|
||||||
|
|
||||||
|
- [ ] Open `betternas.com` (or localhost:3000) - redirected to login
|
||||||
|
- [ ] Register a new account, login, see exports dashboard
|
||||||
|
- [ ] Click an export, get mount credentials
|
||||||
|
- [ ] Logout, confirm redirected to login
|
||||||
|
- [ ] API calls from frontend include correct CORS headers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: NixOS Deployment (netty)
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
Deploy the control-plane as a NixOS-managed systemd service on netty, behind NGINX with ACME TLS at `api.betternas.com`. Stop the Docker Compose stack.
|
||||||
|
|
||||||
|
### Changes Required
|
||||||
|
|
||||||
|
#### 1. DNS: Point `api.betternas.com` to netty
|
||||||
|
|
||||||
|
Run from local machine (Vercel CLI):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vercel dns add betternas.com api A 152.53.195.59
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Build the Go binary for Linux
|
||||||
|
|
||||||
|
**File**: `apps/control-plane/Dockerfile` (or local cross-compile)
|
||||||
|
|
||||||
|
For NixOS, we can either:
|
||||||
|
|
||||||
|
- (a) Cross-compile locally: `GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o control-plane ./cmd/control-plane`
|
||||||
|
- (b) Build a Nix package (cleaner, but more work)
|
||||||
|
- (c) Build on netty directly from the git repo
|
||||||
|
|
||||||
|
Recommendation: **(c) Build on netty** from the cloned repo. Simple, works now. Add a Nix package later if desired.
|
||||||
|
|
||||||
|
#### 3. NixOS configuration changes
|
||||||
|
|
||||||
|
**File**: `/home/rathi/Documents/GitHub/nix/hosts/netty/configuration.nix`
|
||||||
|
|
||||||
|
Add these blocks (following the existing forgejo/vaultwarden pattern):
|
||||||
|
|
||||||
|
```nix
|
||||||
|
# --- betterNAS control-plane ---
|
||||||
|
betternasDomain = "api.betternas.com";
|
||||||
|
|
||||||
|
# In services.nginx.virtualHosts:
|
||||||
|
virtualHosts.${betternasDomain} = {
|
||||||
|
enableACME = true;
|
||||||
|
forceSSL = true;
|
||||||
|
locations."/".proxyPass = "http://127.0.0.1:3100";
|
||||||
|
locations."/".extraConfig = ''
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
|
||||||
|
# Systemd service:
|
||||||
|
systemd.services.betternas-control-plane = {
|
||||||
|
description = "betterNAS Control Plane";
|
||||||
|
after = [ "network-online.target" ];
|
||||||
|
wants = [ "network-online.target" ];
|
||||||
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
serviceConfig = {
|
||||||
|
Type = "simple";
|
||||||
|
User = username;
|
||||||
|
Group = "users";
|
||||||
|
WorkingDirectory = "/var/lib/betternas/control-plane";
|
||||||
|
ExecStart = "/home/${username}/Documents/GitHub/betterNAS/betterNAS/apps/control-plane/dist/control-plane";
|
||||||
|
EnvironmentFile = "/var/lib/betternas/control-plane/control-plane.env";
|
||||||
|
Restart = "on-failure";
|
||||||
|
RestartSec = 5;
|
||||||
|
StateDirectory = "betternas/control-plane";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Environment file on netty
|
||||||
|
|
||||||
|
**File**: `/var/lib/betternas/control-plane/control-plane.env`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PORT=3100
|
||||||
|
BETTERNAS_VERSION=0.1.0
|
||||||
|
BETTERNAS_CONTROL_PLANE_DB_PATH=/var/lib/betternas/control-plane/betternas.db
|
||||||
|
BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN=<generate-strong-token>
|
||||||
|
BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN=<generate-strong-token>
|
||||||
|
BETTERNAS_DAV_AUTH_SECRET=<generate-strong-secret>
|
||||||
|
BETTERNAS_DAV_CREDENTIAL_TTL=24h
|
||||||
|
BETTERNAS_SESSION_TTL=720h
|
||||||
|
BETTERNAS_REGISTRATION_ENABLED=true
|
||||||
|
BETTERNAS_CORS_ORIGIN=https://betternas.com
|
||||||
|
BETTERNAS_NODE_DIRECT_ADDRESS=https://api.betternas.com
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. Build and deploy script
|
||||||
|
|
||||||
|
**File**: `apps/control-plane/scripts/deploy-netty.sh`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REMOTE="netty"
|
||||||
|
REPO="/home/rathi/Documents/GitHub/betterNAS/betterNAS"
|
||||||
|
DIST="$REPO/apps/control-plane/dist"
|
||||||
|
|
||||||
|
ssh "$REMOTE" "cd $REPO && git pull && \
|
||||||
|
mkdir -p $DIST && \
|
||||||
|
cd apps/control-plane && \
|
||||||
|
CGO_ENABLED=0 go build -o $DIST/control-plane ./cmd/control-plane && \
|
||||||
|
sudo systemctl restart betternas-control-plane && \
|
||||||
|
sleep 2 && \
|
||||||
|
sudo systemctl status betternas-control-plane --no-pager"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6. Stop Docker Compose stack
|
||||||
|
|
||||||
|
After the systemd service is running and verified:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh netty 'bash -c "cd /home/rathi/Documents/GitHub/betterNAS/betterNAS && source scripts/lib/runtime-env.sh && compose down"'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Success Criteria
|
||||||
|
|
||||||
|
#### Automated Verification:
|
||||||
|
|
||||||
|
- [ ] `curl https://api.betternas.com/health` returns `ok`
|
||||||
|
- [ ] `curl https://api.betternas.com/version` returns version JSON
|
||||||
|
- [ ] TLS certificate is valid (Let's Encrypt)
|
||||||
|
- [ ] `systemctl status betternas-control-plane` shows active
|
||||||
|
|
||||||
|
#### Manual Verification:
|
||||||
|
|
||||||
|
- [ ] Node-agent can register against `https://api.betternas.com`
|
||||||
|
- [ ] Mount credentials issued via the API work in Finder
|
||||||
|
- [ ] Service survives restart: `sudo systemctl restart betternas-control-plane`
|
||||||
|
- [ ] State persists in SQLite across restarts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: Vercel Deployment
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
Deploy the Next.js web UI to Vercel at `betternas.com`.
|
||||||
|
|
||||||
|
### Changes Required
|
||||||
|
|
||||||
|
#### 1. Create Vercel project
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/web
|
||||||
|
vercel link # or vercel --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Configure environment variables on Vercel
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vercel env add NEXT_PUBLIC_BETTERNAS_API_URL production
|
||||||
|
# Value: https://api.betternas.com
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Configure domain
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vercel domains add betternas.com
|
||||||
|
# Already have wildcard ALIAS to vercel-dns, so this should work
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/web
|
||||||
|
vercel --prod
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5. Verify CORS
|
||||||
|
|
||||||
|
The backend at `api.betternas.com` must have `BETTERNAS_CORS_ORIGIN=https://betternas.com` set (done in Phase 4).
|
||||||
|
|
||||||
|
### Success Criteria
|
||||||
|
|
||||||
|
#### Automated Verification:
|
||||||
|
|
||||||
|
- [ ] `curl -I https://betternas.com` returns 200
|
||||||
|
- [ ] CORS preflight from `betternas.com` to `api.betternas.com` succeeds
|
||||||
|
|
||||||
|
#### Manual Verification:
|
||||||
|
|
||||||
|
- [ ] Visit `betternas.com` - see login page
|
||||||
|
- [ ] Register, login, see exports, issue mount credentials
|
||||||
|
- [ ] Mount from Finder using issued credentials
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Node-Agent Deployment (post-phases)
|
||||||
|
|
||||||
|
After the control-plane is running at `api.betternas.com`, update the node-agent on netty to connect to it:
|
||||||
|
|
||||||
|
1. Build node-agent: `cd apps/node-agent && CGO_ENABLED=0 go build -o dist/node-agent ./cmd/node-agent`
|
||||||
|
2. Create systemd service similar to control-plane
|
||||||
|
3. Environment: `BETTERNAS_CONTROL_PLANE_URL=https://api.betternas.com`
|
||||||
|
4. NGINX vhost for WebDAV if needed (or direct port exposure)
|
||||||
|
|
||||||
|
This is a follow-up task, not part of the initial deployment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Unit Tests (Go):
|
||||||
|
|
||||||
|
- SQLite store: CRUD operations, transactions, concurrent access
|
||||||
|
- Auth: registration, login, session validation, expiry, logout
|
||||||
|
- Migration: schema creates cleanly on empty DB
|
||||||
|
|
||||||
|
### Integration Tests:
|
||||||
|
|
||||||
|
- Full API flow: register user -> login -> list exports -> issue mount profile
|
||||||
|
- Node registration + export sync against SQLite store
|
||||||
|
- Session expiry and cleanup
|
||||||
|
|
||||||
|
### Manual Testing:
|
||||||
|
|
||||||
|
1. Fresh deploy: start control-plane with empty DB
|
||||||
|
2. Register first user via API
|
||||||
|
3. Login from web UI
|
||||||
|
4. Connect node-agent, verify exports appear
|
||||||
|
5. Issue mount credentials, mount in Finder
|
||||||
|
6. Restart control-plane, verify all state persisted
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
- SQLite WAL mode for concurrent reads during writes
|
||||||
|
- Session cleanup: delete expired sessions on a timer (every 10 minutes), not on every request
|
||||||
|
- Connection pool: single writer, multiple readers (SQLite default with WAL)
|
||||||
|
- For a single-NAS deployment, SQLite performance is more than sufficient
|
||||||
|
|
||||||
|
## Go Dependencies to Add
|
||||||
|
|
||||||
|
```
|
||||||
|
modernc.org/sqlite # Pure Go SQLite driver
|
||||||
|
golang.org/x/crypto/bcrypt # Password hashing
|
||||||
|
```
|
||||||
|
|
||||||
|
Both are well-maintained, widely used, and have no CGo requirement.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- NixOS config: `/home/rathi/Documents/GitHub/nix/hosts/netty/configuration.nix`
|
||||||
|
- Control-plane server: `apps/control-plane/cmd/control-plane/server.go`
|
||||||
|
- Control-plane store: `apps/control-plane/cmd/control-plane/store.go`
|
||||||
|
- Web frontend API client: `apps/web/lib/control-plane.ts`
|
||||||
|
- Docker compose (current dev): `infra/docker/compose.dev.yml`
|
||||||
Loading…
Add table
Add a link
Reference in a new issue