mirror of
https://github.com/harivansh-afk/betterNAS.git
synced 2026-04-15 09:01:13 +00:00
Compare commits
13 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90d7eda264 | ||
| be98500e09 | |||
| bebd8c3e4c | |||
| 93d610db29 | |||
| 23e0baa1ef | |||
| d939126ce0 | |||
| 79f52cac9a | |||
|
|
caa2e40dc1 | ||
| 43ef276976 | |||
| f6069a024a | |||
| 171a682f6a | |||
| 1d564b738d | |||
| 8002158a45 |
42 changed files with 1565 additions and 1402 deletions
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: ubuntu-latest
|
runs-on: [self-hosted, netty]
|
||||||
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: ubuntu-latest
|
runs-on: [self-hosted, netty]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v5
|
||||||
|
|
@ -31,17 +31,3 @@ 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
63
CLAUDE.md
|
|
@ -1,63 +0,0 @@
|
||||||
# Project Constraints
|
|
||||||
|
|
||||||
## Delivery sequencing
|
|
||||||
|
|
||||||
- Start with `apps/control-plane` first.
|
|
||||||
- Deliver the core backend in 2 steps, not 3:
|
|
||||||
1. `control-server` plus `node-service` contract and runtime loop
|
|
||||||
2. web control plane on top of that stable backend seam
|
|
||||||
- Do not start web UI work until the `control-server` and `node-service` contract is stable.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
- `control-server` is the clean backend contract that other parts consume.
|
|
||||||
- `apps/node-agent` reports into `apps/control-plane`.
|
|
||||||
- `apps/web` reads from `apps/control-plane`.
|
|
||||||
- Local mount UX is issued by `apps/control-plane`.
|
|
||||||
|
|
||||||
## Backend contract priorities
|
|
||||||
|
|
||||||
- The first backend seam must cover:
|
|
||||||
- node enrollment
|
|
||||||
- node heartbeats
|
|
||||||
- node export reporting
|
|
||||||
- control-server persistence of nodes and exports
|
|
||||||
- mount profile issuance for one export
|
|
||||||
- `control-server` should own:
|
|
||||||
- node auth
|
|
||||||
- user auth
|
|
||||||
- mount issuance
|
|
||||||
|
|
||||||
## Mount profile shape
|
|
||||||
|
|
||||||
- Prefer standard WebDAV username and password semantics for Finder compatibility.
|
|
||||||
- The consumer-facing mount profile should behave like:
|
|
||||||
- export id
|
|
||||||
- display name
|
|
||||||
- mount URL
|
|
||||||
- username
|
|
||||||
- password
|
|
||||||
- readonly
|
|
||||||
- expires at
|
|
||||||
|
|
||||||
## Service boundary
|
|
||||||
|
|
||||||
- Keep `node-service` limited to the WebDAV mount surface.
|
|
||||||
- Route admin and control actions through `control-server`, not directly from browsers to `node-service`.
|
|
||||||
|
|
||||||
## User-scoped auth requirements
|
|
||||||
|
|
||||||
- Remove the bootstrap token flow for v1.
|
|
||||||
- Use a single user-provided username and password across the entire stack:
|
|
||||||
- `apps/node-agent` authenticates with the user's username and password from environment variables
|
|
||||||
- web app sessions authenticate with the same username and password
|
|
||||||
- WebDAV and Finder authentication use the same username and password
|
|
||||||
- Do not generate separate WebDAV credentials for users.
|
|
||||||
- Nodes and exports must be owned by users and scoped so authenticated users can only view and mount their own resources.
|
|
||||||
- Package the node binary for user download and distribution.
|
|
||||||
|
|
||||||
## V1 simplicity
|
|
||||||
|
|
||||||
- Keep the implementation as simple as possible.
|
|
||||||
- Do not over-engineer the auth or distribution model for v1.
|
|
||||||
- Prefer the smallest change set that makes the product usable and distributable.
|
|
||||||
117
README.md
117
README.md
|
|
@ -1,114 +1,13 @@
|
||||||
# betterNAS
|
# BetterNAS
|
||||||
|
|
||||||
betterNAS is a self-hostable WebDAV stack for mounting NAS exports in Finder.
|
https://github.com/user-attachments/assets/7909f957-b6d9-4cc1-aaec-369d5d94a7f5
|
||||||
|
|
||||||
The default product shape is:
|
|
||||||
|
|
||||||
- `node-service` serves the real files from the NAS over WebDAV
|
betterNAS lets you mount remote machines as native Finder volumes on your Mac.
|
||||||
- `control-server` owns auth, nodes, exports, grants, and mount profile issuance
|
Install a small agent on any box with files you care about, and it shows up in Finder like a local drive.
|
||||||
- `web control plane` lets the user manage the NAS and get mount instructions
|
No sync clients, no special apps - just your files, where you expect them.
|
||||||
- `macOS client` starts as native Finder WebDAV mounting, with a thin helper later
|
|
||||||
|
|
||||||
For now, the whole stack should be able to run on the user's NAS device.
|
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.
|
||||||
|
|
||||||
## Current repo shape
|
<img width="1330" height="614" alt="image" src="https://github.com/user-attachments/assets/f4cfe135-505f-4ce1-bbb3-1f1d47821f8f" />
|
||||||
|
|
||||||
- `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,6 +1,8 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -10,6 +12,7 @@ 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
|
||||||
}
|
}
|
||||||
|
|
@ -21,7 +24,13 @@ 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 != "" {
|
||||||
|
|
@ -41,6 +50,68 @@ 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,6 +36,16 @@ 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"),
|
||||||
|
|
@ -43,6 +53,7 @@ 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,8 +56,9 @@ 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,
|
||||||
})
|
})
|
||||||
if mount.MountURL != nodeAgent.baseURL+defaultWebDAVPath {
|
expectedMountURL := nodeAgent.baseURL + defaultWebDAVPath + runtimeUsername + "/"
|
||||||
t.Fatalf("expected runtime mount URL %q, got %q", nodeAgent.baseURL+defaultWebDAVPath, mount.MountURL)
|
if mount.MountURL != expectedMountURL {
|
||||||
|
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)
|
||||||
|
|
@ -103,11 +104,13 @@ 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)
|
||||||
}
|
}
|
||||||
if firstMount.MountURL != nodeAgent.baseURL+firstMountPath {
|
expectedFirstMountURL := nodeAgent.baseURL + firstMountPath + runtimeUsername + "/"
|
||||||
t.Fatalf("expected first runtime mount URL %q, got %q", nodeAgent.baseURL+firstMountPath, firstMount.MountURL)
|
expectedSecondMountURL := nodeAgent.baseURL + secondMountPath + runtimeUsername + "/"
|
||||||
|
if firstMount.MountURL != expectedFirstMountURL {
|
||||||
|
t.Fatalf("expected first runtime mount URL %q, got %q", expectedFirstMountURL, firstMount.MountURL)
|
||||||
}
|
}
|
||||||
if secondMount.MountURL != nodeAgent.baseURL+secondMountPath {
|
if secondMount.MountURL != expectedSecondMountURL {
|
||||||
t.Fatalf("expected second runtime mount URL %q, got %q", nodeAgent.baseURL+secondMountPath, secondMount.MountURL)
|
t.Fatalf("expected second runtime mount URL %q, got %q", expectedSecondMountURL, secondMount.MountURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
assertHTTPStatusWithBasicAuth(t, client, "PROPFIND", firstMount.MountURL, controlPlane.username, controlPlane.password, http.StatusMultiStatus)
|
assertHTTPStatusWithBasicAuth(t, client, "PROPFIND", firstMount.MountURL, controlPlane.username, controlPlane.password, http.StatusMultiStatus)
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ func (a *app) handler() http.Handler {
|
||||||
mux.HandleFunc("POST /api/v1/auth/login", a.handleAuthLogin)
|
mux.HandleFunc("POST /api/v1/auth/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)
|
||||||
|
|
@ -74,6 +75,15 @@ func (a *app) handleVersion(w http.ResponseWriter, _ *http.Request) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *app) handleNodesList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
currentUser, ok := a.requireSessionUser(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, a.listNodes(currentUser.ID))
|
||||||
|
}
|
||||||
|
|
||||||
func (a *app) handleNodeRegister(w http.ResponseWriter, r *http.Request) {
|
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 {
|
||||||
|
|
@ -177,7 +187,7 @@ func (a *app) handleExportsList(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
writeJSON(w, http.StatusOK, a.store.listExports(currentUser.ID))
|
writeJSON(w, http.StatusOK, a.listConnectedExports(currentUser.ID))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *app) handleMountProfileIssue(w http.ResponseWriter, r *http.Request) {
|
func (a *app) handleMountProfileIssue(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -202,8 +212,13 @@ 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)
|
mountURL, err := buildMountURL(context, currentUser.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||||
return
|
return
|
||||||
|
|
@ -705,13 +720,17 @@ func hasConfiguredNextcloudBaseURL(baseURL string) bool {
|
||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildMountURL(context exportContext) (string, error) {
|
func buildMountURL(context exportContext, username string) (string, error) {
|
||||||
address, ok := firstAddress(context.node.DirectAddress, context.node.RelayAddress)
|
address, ok := firstAddress(context.node.DirectAddress, context.node.RelayAddress)
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", errMountTargetUnavailable
|
return "", errMountTargetUnavailable
|
||||||
}
|
}
|
||||||
|
|
||||||
mountURL, err := buildAbsoluteHTTPURLWithPath(address, mountProfilePathForExport(context.export.MountPath))
|
basePath := mountProfilePathForExport(context.export.MountPath)
|
||||||
|
// Append the username so Finder uses it as the volume name in the sidebar.
|
||||||
|
userScopedPath := path.Join(basePath, username) + "/"
|
||||||
|
|
||||||
|
mountURL, err := buildAbsoluteHTTPURLWithPath(address, userScopedPath)
|
||||||
if err != nil {
|
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/" {
|
if mount.MountURL != "http://nas.local:8090/dav/fixture/" {
|
||||||
t.Fatalf("expected mount URL %q, got %q", "http://nas.local:8090/dav/", mount.MountURL)
|
t.Fatalf("expected mount URL %q, got %q", "http://nas.local:8090/dav/fixture/", 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/" {
|
if docsMount.MountURL != "http://nas.local:8090/dav/exports/docs/fixture/" {
|
||||||
t.Fatalf("expected docs mount URL %q, got %q", "http://nas.local:8090/dav/exports/docs/", docsMount.MountURL)
|
t.Fatalf("expected docs mount URL %q, got %q", "http://nas.local:8090/dav/exports/docs/fixture/", docsMount.MountURL)
|
||||||
}
|
}
|
||||||
if mediaMount.MountURL != "http://nas.local:8090/dav/exports/media/" {
|
if mediaMount.MountURL != "http://nas.local:8090/dav/exports/media/fixture/" {
|
||||||
t.Fatalf("expected media mount URL %q, got %q", "http://nas.local:8090/dav/exports/media/", mediaMount.MountURL)
|
t.Fatalf("expected media mount URL %q, got %q", "http://nas.local:8090/dav/exports/media/fixture/", mediaMount.MountURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
docsCloud := postJSONAuth[cloudProfile](t, server.Client(), testClientToken, server.URL+"/api/v1/cloud-profiles/issue", cloudProfileRequest{
|
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/" {
|
if mount.MountURL != "https://nas.example.test/control/dav/relay/fixture/" {
|
||||||
t.Fatalf("expected relay mount URL %q, got %q", "https://nas.example.test/control/dav/relay/", mount.MountURL)
|
t.Fatalf("expected relay mount URL %q, got %q", "https://nas.example.test/control/dav/relay/fixture/", mount.MountURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
registration = registerNode(t, server.Client(), server.URL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{
|
registration = registerNode(t, server.Client(), server.URL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{
|
||||||
|
|
@ -494,6 +494,69 @@ func TestControlPlaneMountProfilesUseRelayAndPreserveBasePath(t *testing.T) {
|
||||||
postJSONAuthStatus(t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: "dev-export-2"}, http.StatusServiceUnavailable)
|
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()
|
||||||
|
|
||||||
|
|
@ -585,8 +648,8 @@ func TestControlPlanePersistsRegistryAcrossAppRestart(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
mount := postJSONAuth[mountProfile](t, secondServer.Client(), testClientToken, secondServer.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: exports[0].ID})
|
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/" {
|
if mount.MountURL != "http://nas.local:8090/dav/persisted/fixture/" {
|
||||||
t.Fatalf("expected persisted mount URL %q, got %q", "http://nas.local:8090/dav/persisted/", mount.MountURL)
|
t.Fatalf("expected persisted mount URL %q, got %q", "http://nas.local:8090/dav/persisted/fixture/", mount.MountURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
reRegistration := registerNode(t, secondServer.Client(), secondServer.URL+"/api/v1/nodes/register", registration.NodeToken, nodeRegistrationRequest{
|
reRegistration := registerNode(t, secondServer.Client(), secondServer.URL+"/api/v1/nodes/register", registration.NodeToken, nodeRegistrationRequest{
|
||||||
|
|
@ -745,6 +808,8 @@ 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,6 +346,27 @@ 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 {
|
||||||
|
|
@ -401,15 +422,29 @@ 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 := s.db.QueryRow(
|
err := scanner.Scan(&n.ID, &n.MachineID, &ownerID, &n.DisplayName, &n.AgentVersion, &n.Status, &lastSeenAt, &directAddr, &relayAddr)
|
||||||
"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{}, false
|
return nasNode{}
|
||||||
}
|
}
|
||||||
if ownerID.Valid {
|
if ownerID.Valid {
|
||||||
n.OwnerID = ownerID.String
|
n.OwnerID = ownerID.String
|
||||||
|
|
@ -423,7 +458,7 @@ func (s *sqliteStore) nodeByID(nodeID string) (nasNode, bool) {
|
||||||
if relayAddr.Valid {
|
if relayAddr.Valid {
|
||||||
n.RelayAddress = &relayAddr.String
|
n.RelayAddress = &relayAddr.String
|
||||||
}
|
}
|
||||||
return n, true
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
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/" {
|
if mount.MountURL != "http://nas.local:8090/dav/docs/fixture/" {
|
||||||
t.Fatalf("expected mount URL %q, got %q", "http://nas.local:8090/dav/docs/", mount.MountURL)
|
t.Fatalf("expected mount URL %q, got %q", "http://nas.local:8090/dav/docs/fixture/", mount.MountURL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -320,6 +320,25 @@ 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,6 +9,7 @@ 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,13 +1,16 @@
|
||||||
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"
|
||||||
)
|
)
|
||||||
|
|
@ -27,6 +30,7 @@ type app struct {
|
||||||
authUsername string
|
authUsername string
|
||||||
authPassword string
|
authPassword string
|
||||||
exportMounts []exportMount
|
exportMounts []exportMount
|
||||||
|
controlPlane *controlPlaneSession
|
||||||
}
|
}
|
||||||
|
|
||||||
type exportMount struct {
|
type exportMount struct {
|
||||||
|
|
@ -69,17 +73,92 @@ 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", "")) != "" {
|
||||||
if _, err := bootstrapNodeAgentFromEnv(exportPaths); err != nil {
|
session, err := bootstrapNodeAgentFromEnv(exportPaths)
|
||||||
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
controlPlane = &session
|
||||||
}
|
}
|
||||||
|
|
||||||
return newApp(appConfig{
|
app, err := 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) {
|
||||||
|
|
@ -155,12 +234,24 @@ 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: webdav.NewMemLS(),
|
LockSystem: lockSystem,
|
||||||
}
|
}
|
||||||
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,8 +14,11 @@ import (
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type bootstrapResult struct {
|
type controlPlaneSession struct {
|
||||||
nodeID string
|
nodeID string
|
||||||
|
controlPlaneURL string
|
||||||
|
sessionToken string
|
||||||
|
heartbeatInterval time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
type nodeRegistrationRequest struct {
|
type nodeRegistrationRequest struct {
|
||||||
|
|
@ -58,19 +61,23 @@ type nodeHeartbeatRequest struct {
|
||||||
LastSeenAt string `json:"lastSeenAt"`
|
LastSeenAt string `json:"lastSeenAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func bootstrapNodeAgentFromEnv(exportPaths []string) (bootstrapResult, error) {
|
func bootstrapNodeAgentFromEnv(exportPaths []string) (controlPlaneSession, error) {
|
||||||
controlPlaneURL := strings.TrimSpace(env("BETTERNAS_CONTROL_PLANE_URL", "https://api.betternas.com"))
|
controlPlaneURL := strings.TrimSpace(env("BETTERNAS_CONTROL_PLANE_URL", "https://api.betternas.com"))
|
||||||
if controlPlaneURL == "" {
|
if controlPlaneURL == "" {
|
||||||
return bootstrapResult{}, fmt.Errorf("BETTERNAS_CONTROL_PLANE_URL is required")
|
return controlPlaneSession{}, fmt.Errorf("BETTERNAS_CONTROL_PLANE_URL is required")
|
||||||
}
|
}
|
||||||
|
|
||||||
username, err := requiredEnv("BETTERNAS_USERNAME")
|
username, err := requiredEnv("BETTERNAS_USERNAME")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return bootstrapResult{}, err
|
return controlPlaneSession{}, err
|
||||||
}
|
}
|
||||||
password, err := requiredEnv("BETTERNAS_PASSWORD")
|
password, err := requiredEnv("BETTERNAS_PASSWORD")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return bootstrapResult{}, err
|
return controlPlaneSession{}, err
|
||||||
|
}
|
||||||
|
heartbeatInterval, err := heartbeatIntervalFromEnv()
|
||||||
|
if err != nil {
|
||||||
|
return controlPlaneSession{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
machineID := strings.TrimSpace(env("BETTERNAS_NODE_MACHINE_ID", defaultNodeMachineID(username)))
|
machineID := strings.TrimSpace(env("BETTERNAS_NODE_MACHINE_ID", defaultNodeMachineID(username)))
|
||||||
|
|
@ -82,7 +89,7 @@ func bootstrapNodeAgentFromEnv(exportPaths []string) (bootstrapResult, error) {
|
||||||
client := &http.Client{Timeout: 5 * time.Second}
|
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 bootstrapResult{}, err
|
return controlPlaneSession{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
registration, err := registerNodeWithControlPlane(client, controlPlaneURL, sessionToken, nodeRegistrationRequest{
|
registration, err := registerNodeWithControlPlane(client, controlPlaneURL, sessionToken, nodeRegistrationRequest{
|
||||||
|
|
@ -93,17 +100,22 @@ func bootstrapNodeAgentFromEnv(exportPaths []string) (bootstrapResult, error) {
|
||||||
RelayAddress: optionalEnvPointer("BETTERNAS_NODE_RELAY_ADDRESS"),
|
RelayAddress: optionalEnvPointer("BETTERNAS_NODE_RELAY_ADDRESS"),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return bootstrapResult{}, err
|
return controlPlaneSession{}, 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 bootstrapResult{}, err
|
return controlPlaneSession{}, err
|
||||||
}
|
}
|
||||||
if err := sendNodeHeartbeat(client, controlPlaneURL, sessionToken, registration.ID); err != nil {
|
if err := sendNodeHeartbeatAt(client, controlPlaneURL, sessionToken, registration.ID, time.Now().UTC()); err != nil {
|
||||||
return bootstrapResult{}, err
|
return controlPlaneSession{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return bootstrapResult{nodeID: registration.ID}, nil
|
return controlPlaneSession{
|
||||||
|
nodeID: registration.ID,
|
||||||
|
controlPlaneURL: controlPlaneURL,
|
||||||
|
sessionToken: sessionToken,
|
||||||
|
heartbeatInterval: heartbeatInterval,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func loginWithControlPlane(client *http.Client, baseURL string, username string, password string) (string, error) {
|
func loginWithControlPlane(client *http.Client, baseURL string, username string, password string) (string, error) {
|
||||||
|
|
@ -168,10 +180,14 @@ func syncNodeExportsWithControlPlane(client *http.Client, baseURL string, token
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendNodeHeartbeat(client *http.Client, baseURL string, token string, nodeID string) error {
|
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: time.Now().UTC().Format(time.RFC3339),
|
LastSeenAt: at.UTC().Format(time.RFC3339),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
|
||||||
97
apps/node-agent/cmd/node-agent/control_plane_test.go
Normal file
97
apps/node-agent/cmd/node-agent/control_plane_test.go
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunNodeHeartbeatLoopSendsRecurringHeartbeats(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
var heartbeats []nodeHeartbeatRequest
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/nodes/dev-node/heartbeat" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read heartbeat body: %v", err)
|
||||||
|
}
|
||||||
|
_ = r.Body.Close()
|
||||||
|
|
||||||
|
var heartbeat nodeHeartbeatRequest
|
||||||
|
if err := json.Unmarshal(body, &heartbeat); err != nil {
|
||||||
|
t.Fatalf("decode heartbeat body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
heartbeats = append(heartbeats, heartbeat)
|
||||||
|
mu.Unlock()
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
runNodeHeartbeatLoop(
|
||||||
|
ctx,
|
||||||
|
server.Client(),
|
||||||
|
server.URL,
|
||||||
|
"session-token",
|
||||||
|
"dev-node",
|
||||||
|
10*time.Millisecond,
|
||||||
|
func() time.Time { return time.Date(2025, time.January, 1, 12, 0, 0, 0, time.UTC) },
|
||||||
|
log.New(io.Discard, "", 0),
|
||||||
|
)
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(500 * time.Millisecond)
|
||||||
|
for {
|
||||||
|
mu.Lock()
|
||||||
|
count := len(heartbeats)
|
||||||
|
mu.Unlock()
|
||||||
|
if count >= 2 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("expected recurring heartbeats, got %d", count)
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("heartbeat loop did not stop after context cancellation")
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
for _, heartbeat := range heartbeats {
|
||||||
|
if heartbeat.NodeID != "dev-node" {
|
||||||
|
t.Fatalf("expected node ID dev-node, got %q", heartbeat.NodeID)
|
||||||
|
}
|
||||||
|
if heartbeat.Status != "online" {
|
||||||
|
t.Fatalf("expected status online, got %q", heartbeat.Status)
|
||||||
|
}
|
||||||
|
if heartbeat.LastSeenAt != "2025-01-01T12:00:00Z" {
|
||||||
|
t.Fatalf("expected fixed lastSeenAt, got %q", heartbeat.LastSeenAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -14,6 +17,19 @@ 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(),
|
||||||
|
|
|
||||||
283
apps/web/app/app/page.tsx
Normal file
283
apps/web/app/app/page.tsx
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { SignOut } from "@phosphor-icons/react";
|
||||||
|
import {
|
||||||
|
isAuthenticated,
|
||||||
|
listExports,
|
||||||
|
listNodes,
|
||||||
|
issueMountProfile,
|
||||||
|
logout,
|
||||||
|
getMe,
|
||||||
|
type StorageExport,
|
||||||
|
type MountProfile,
|
||||||
|
type NasNode,
|
||||||
|
type User,
|
||||||
|
ApiError,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { CopyField } from "../copy-field";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [user, setUser] = useState<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();
|
||||||
|
}
|
||||||
196
apps/web/app/docs/page.tsx
Normal file
196
apps/web/app/docs/page.tsx
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
446
apps/web/app/landing/page.tsx
Normal file
446
apps/web/app/landing/page.tsx
Normal file
|
|
@ -0,0 +1,446 @@
|
||||||
|
"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,6 +2,7 @@
|
||||||
|
|
||||||
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 {
|
||||||
|
|
@ -31,7 +32,7 @@ export default function LoginPage() {
|
||||||
} else {
|
} else {
|
||||||
await register(username, password);
|
await register(username, password);
|
||||||
}
|
}
|
||||||
router.push("/");
|
router.push("/app");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
|
|
@ -45,108 +46,116 @@ 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">
|
||||||
<Card className="w-full max-w-sm">
|
<div className="flex w-full max-w-sm flex-col gap-4">
|
||||||
<CardHeader className="text-center">
|
<Link
|
||||||
<p className="text-xs font-medium uppercase tracking-widest text-muted-foreground">
|
href="/"
|
||||||
betterNAS
|
className="inline-flex items-center gap-1.5 self-start text-sm text-muted-foreground transition-colors hover:text-foreground"
|
||||||
</p>
|
>
|
||||||
<CardTitle className="text-xl">
|
<svg className="size-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
|
||||||
{mode === "login" ? "Sign in" : "Create account"}
|
<path d="M10 3L5 8l5 5" />
|
||||||
</CardTitle>
|
</svg>
|
||||||
<CardDescription>
|
</Link>
|
||||||
{mode === "login"
|
|
||||||
? "Sign in to your betterNAS control plane with the same credentials you use for the node agent and Finder."
|
|
||||||
: "Create your betterNAS account. You will use the same username and password for the web app, node agent, and Finder."}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label
|
|
||||||
htmlFor="username"
|
|
||||||
className="text-sm font-medium text-foreground"
|
|
||||||
>
|
|
||||||
Username
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="username"
|
|
||||||
type="text"
|
|
||||||
autoComplete="username"
|
|
||||||
required
|
|
||||||
minLength={3}
|
|
||||||
maxLength={64}
|
|
||||||
value={username}
|
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
|
||||||
className="rounded-lg border border-input bg-background px-3 py-2 text-sm outline-none ring-ring focus:ring-2"
|
|
||||||
placeholder="admin"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<Card>
|
||||||
<label
|
<CardHeader className="text-center">
|
||||||
htmlFor="password"
|
<CardTitle className="text-xl">
|
||||||
className="text-sm font-medium text-foreground"
|
{mode === "login" ? "Sign in" : "Create account"}
|
||||||
>
|
</CardTitle>
|
||||||
Password
|
<CardDescription>
|
||||||
</label>
|
{mode === "login"
|
||||||
<input
|
? "Use the same credentials as your node agent and Finder."
|
||||||
id="password"
|
: "This account works across the web UI, node agent, and Finder."}
|
||||||
type="password"
|
</CardDescription>
|
||||||
autoComplete={
|
</CardHeader>
|
||||||
mode === "login" ? "current-password" : "new-password"
|
<CardContent>
|
||||||
}
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||||
required
|
<div className="flex flex-col gap-1.5">
|
||||||
minLength={8}
|
<label
|
||||||
value={password}
|
htmlFor="username"
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
className="text-sm font-medium text-foreground"
|
||||||
className="rounded-lg border border-input bg-background px-3 py-2 text-sm outline-none ring-ring focus:ring-2"
|
>
|
||||||
/>
|
Username
|
||||||
</div>
|
</label>
|
||||||
|
<input
|
||||||
|
id="username"
|
||||||
|
type="text"
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
minLength={3}
|
||||||
|
maxLength={64}
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
className="rounded-lg border border-input bg-background px-3 py-2 text-sm outline-none ring-ring focus:ring-2"
|
||||||
|
placeholder="admin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className="text-sm font-medium text-foreground"
|
||||||
|
>
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete={
|
||||||
|
mode === "login" ? "current-password" : "new-password"
|
||||||
|
}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="rounded-lg border border-input bg-background px-3 py-2 text-sm outline-none ring-ring focus:ring-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Button type="submit" disabled={loading} className="w-full">
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
{loading
|
|
||||||
? "..."
|
|
||||||
: mode === "login"
|
|
||||||
? "Sign in"
|
|
||||||
: "Create account"}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<p className="text-center text-sm text-muted-foreground">
|
<Button type="submit" disabled={loading} className="w-full">
|
||||||
{mode === "login" ? (
|
{loading
|
||||||
<>
|
? "..."
|
||||||
No account?{" "}
|
: mode === "login"
|
||||||
<button
|
? "Sign in"
|
||||||
type="button"
|
: "Create account"}
|
||||||
onClick={() => {
|
</Button>
|
||||||
setMode("register");
|
|
||||||
setError(null);
|
<p className="text-center text-sm text-muted-foreground">
|
||||||
}}
|
{mode === "login" ? (
|
||||||
className="text-foreground underline underline-offset-2"
|
<>
|
||||||
>
|
No account?{" "}
|
||||||
Create one
|
<button
|
||||||
</button>
|
type="button"
|
||||||
</>
|
onClick={() => {
|
||||||
) : (
|
setMode("register");
|
||||||
<>
|
setError(null);
|
||||||
Already have an account?{" "}
|
}}
|
||||||
<button
|
className="text-foreground underline underline-offset-2"
|
||||||
type="button"
|
>
|
||||||
onClick={() => {
|
Create one
|
||||||
setMode("login");
|
</button>
|
||||||
setError(null);
|
</>
|
||||||
}}
|
) : (
|
||||||
className="text-foreground underline underline-offset-2"
|
<>
|
||||||
>
|
Already have an account?{" "}
|
||||||
Sign in
|
<button
|
||||||
</button>
|
type="button"
|
||||||
</>
|
onClick={() => {
|
||||||
)}
|
setMode("login");
|
||||||
</p>
|
setError(null);
|
||||||
</form>
|
}}
|
||||||
</CardContent>
|
className="text-foreground underline underline-offset-2"
|
||||||
</Card>
|
>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,373 +1 @@
|
||||||
"use client";
|
export { default } from "./landing/page";
|
||||||
|
|
||||||
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,5 +1,16 @@
|
||||||
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;
|
||||||
|
|
@ -146,6 +157,10 @@ 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");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
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,6 +18,22 @@ 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,4 +1,5 @@
|
||||||
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,7 +37,8 @@ case "$arch_name" in
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
archive_name="${binary_name}_${version}_${os}_${arch}.tar.gz"
|
version_stripped="${version#v}"
|
||||||
|
archive_name="${binary_name}_${version_stripped}_${os}_${arch}.tar.gz"
|
||||||
download_url="${download_base_url}/${version}/${archive_name}"
|
download_url="${download_base_url}/${version}/${archive_name}"
|
||||||
|
|
||||||
tmp_dir="$(mktemp -d)"
|
tmp_dir="$(mktemp -d)"
|
||||||
|
|
|
||||||
|
|
@ -1,698 +0,0 @@
|
||||||
# betterNAS Production Deployment Plan
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Deploy the betterNAS control-plane as a production service on netty (Netcup VPS) with SQLite-backed user auth, NGINX reverse proxy at `api.betternas.com`, and the web frontend on Vercel at `betternas.com`. Replaces the current dev Docker Compose setup with a NixOS-native systemd service matching the existing deployment pattern (forgejo, vaultwarden, sandbox-agent).
|
|
||||||
|
|
||||||
## Current State
|
|
||||||
|
|
||||||
- Control-plane is a Go binary running in Docker on netty (port 3001->3000)
|
|
||||||
- State is an in-memory store backed by a JSON file
|
|
||||||
- Auth is static tokens from environment variables (no user accounts)
|
|
||||||
- Web frontend reads env vars to find the control-plane URL and client token
|
|
||||||
- Node-agent runs in Docker, connects to control-plane over Docker network
|
|
||||||
- NGINX on netty already reverse-proxies 3 domains with ACME/Let's Encrypt
|
|
||||||
- NixOS config is at `/home/rathi/Documents/GitHub/nix/hosts/netty/configuration.nix`
|
|
||||||
- `betternas.com` is registered on Vercel with nameservers pointed to Vercel DNS
|
|
||||||
|
|
||||||
## Desired End State
|
|
||||||
|
|
||||||
- `api.betternas.com` serves the control-plane Go binary behind NGINX with TLS
|
|
||||||
- `betternas.com` serves the Next.js web UI from Vercel
|
|
||||||
- All state (users, sessions, nodes, exports) lives in SQLite at `/var/lib/betternas/control-plane/betternas.db`
|
|
||||||
- Users log in with username/password on the web UI, get a session cookie
|
|
||||||
- One-click mount: logged-in user clicks an export, backend issues WebDAV credentials using the user's session
|
|
||||||
- Node-agent connects to `api.betternas.com` over HTTPS
|
|
||||||
- Deployment is declarative via NixOS configuration.nix
|
|
||||||
|
|
||||||
### Verification:
|
|
||||||
|
|
||||||
1. `curl https://api.betternas.com/health` returns `ok`
|
|
||||||
2. Web UI at `betternas.com` loads, shows login page
|
|
||||||
3. User can register, log in, see exports, one-click mount
|
|
||||||
4. Node-agent on netty registers and syncs exports to `api.betternas.com`
|
|
||||||
5. WebDAV mount from Finder works with issued credentials
|
|
||||||
|
|
||||||
## What We're NOT Doing
|
|
||||||
|
|
||||||
- Multi-tenant / multi-user RBAC (just simple username/password accounts)
|
|
||||||
- OAuth / SSO / social login
|
|
||||||
- Email verification or password reset flows
|
|
||||||
- Migrating existing JSON state (fresh SQLite DB)
|
|
||||||
- Nextcloud integration (can add later)
|
|
||||||
- CI/CD pipeline (manual deploy via `nixos-rebuild switch`)
|
|
||||||
- Rate limiting or request throttling
|
|
||||||
|
|
||||||
## Implementation Approach
|
|
||||||
|
|
||||||
Five phases, each independently deployable and testable:
|
|
||||||
|
|
||||||
1. **SQLite store** - Replace memoryStore with sqliteStore for all existing state
|
|
||||||
2. **User auth** - Add users/sessions tables, login/register endpoints, session middleware
|
|
||||||
3. **CORS + frontend auth** - Wire the web UI to use session-based auth against `api.betternas.com`
|
|
||||||
4. **NixOS deployment** - Systemd service, NGINX vhost, ACME cert, DNS
|
|
||||||
5. **Vercel deployment** - Deploy web UI, configure domain and env vars
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 1: SQLite Store
|
|
||||||
|
|
||||||
### Overview
|
|
||||||
|
|
||||||
Replace `memoryStore` (in-memory + JSON file) with a `sqliteStore` using `modernc.org/sqlite` (pure Go, no CGo, `database/sql` compatible). This keeps all existing API behavior identical while switching the persistence layer.
|
|
||||||
|
|
||||||
### Schema
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Ordinal counters (replaces NextNodeOrdinal / NextExportOrdinal)
|
|
||||||
CREATE TABLE ordinals (
|
|
||||||
name TEXT PRIMARY KEY,
|
|
||||||
value INTEGER NOT NULL DEFAULT 0
|
|
||||||
);
|
|
||||||
INSERT INTO ordinals (name, value) VALUES ('node', 0), ('export', 0);
|
|
||||||
|
|
||||||
-- Nodes
|
|
||||||
CREATE TABLE nodes (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
machine_id TEXT NOT NULL UNIQUE,
|
|
||||||
display_name TEXT NOT NULL DEFAULT '',
|
|
||||||
agent_version TEXT NOT NULL DEFAULT '',
|
|
||||||
status TEXT NOT NULL DEFAULT 'online',
|
|
||||||
last_seen_at TEXT,
|
|
||||||
direct_address TEXT,
|
|
||||||
relay_address TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Node auth tokens (hashed)
|
|
||||||
CREATE TABLE node_tokens (
|
|
||||||
node_id TEXT PRIMARY KEY REFERENCES nodes(id),
|
|
||||||
token_hash TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Storage exports
|
|
||||||
CREATE TABLE exports (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
node_id TEXT NOT NULL REFERENCES nodes(id),
|
|
||||||
label TEXT NOT NULL DEFAULT '',
|
|
||||||
path TEXT NOT NULL,
|
|
||||||
mount_path TEXT NOT NULL DEFAULT '',
|
|
||||||
capacity_bytes INTEGER,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
|
||||||
UNIQUE(node_id, path)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Export protocols (normalized from JSON array)
|
|
||||||
CREATE TABLE export_protocols (
|
|
||||||
export_id TEXT NOT NULL REFERENCES exports(id) ON DELETE CASCADE,
|
|
||||||
protocol TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (export_id, protocol)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Export tags (normalized from JSON array)
|
|
||||||
CREATE TABLE export_tags (
|
|
||||||
export_id TEXT NOT NULL REFERENCES exports(id) ON DELETE CASCADE,
|
|
||||||
tag TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (export_id, tag)
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Changes Required
|
|
||||||
|
|
||||||
#### 1. Add SQLite dependency
|
|
||||||
|
|
||||||
**File**: `apps/control-plane/go.mod`
|
|
||||||
|
|
||||||
```
|
|
||||||
go get modernc.org/sqlite
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. New file: `sqlite_store.go`
|
|
||||||
|
|
||||||
**File**: `apps/control-plane/cmd/control-plane/sqlite_store.go`
|
|
||||||
|
|
||||||
Implements the same operations as `memoryStore` but backed by SQLite:
|
|
||||||
|
|
||||||
- `newSQLiteStore(dbPath string) (*sqliteStore, error)` - opens DB, runs migrations
|
|
||||||
- `registerNode(...)` - INSERT/UPDATE node + token hash in a transaction
|
|
||||||
- `upsertExports(...)` - DELETE removed exports, UPSERT current ones in a transaction
|
|
||||||
- `recordHeartbeat(...)` - UPDATE node status/lastSeenAt
|
|
||||||
- `listExports()` - SELECT all exports with protocols/tags joined
|
|
||||||
- `exportContext(exportID)` - SELECT export + its node
|
|
||||||
- `nodeAuthByMachineID(machineID)` - SELECT node_id + token_hash by machine_id
|
|
||||||
- `nodeAuthByID(nodeID)` - SELECT token_hash by node_id
|
|
||||||
- `nextOrdinal(name)` - UPDATE ordinals SET value = value + 1 RETURNING value
|
|
||||||
|
|
||||||
Key design decisions:
|
|
||||||
|
|
||||||
- Use `database/sql` with `modernc.org/sqlite` driver
|
|
||||||
- WAL mode enabled at connection: `PRAGMA journal_mode=WAL`
|
|
||||||
- Foreign keys enabled: `PRAGMA foreign_keys=ON`
|
|
||||||
- Schema migrations run on startup (embed SQL with `//go:embed`)
|
|
||||||
- All multi-table mutations wrapped in transactions
|
|
||||||
- No ORM - raw SQL with prepared statements
|
|
||||||
|
|
||||||
#### 3. Update `app.go` to use SQLite store
|
|
||||||
|
|
||||||
**File**: `apps/control-plane/cmd/control-plane/app.go`
|
|
||||||
|
|
||||||
Replace `memoryStore` initialization with `sqliteStore`:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Replace:
|
|
||||||
// store, err := newMemoryStore(statePath)
|
|
||||||
// With:
|
|
||||||
// store, err := newSQLiteStore(dbPath)
|
|
||||||
```
|
|
||||||
|
|
||||||
New env var: `BETTERNAS_CONTROL_PLANE_DB_PATH` (default: `/var/lib/betternas/control-plane/betternas.db`)
|
|
||||||
|
|
||||||
#### 4. Update `server.go` to use new store interface
|
|
||||||
|
|
||||||
**File**: `apps/control-plane/cmd/control-plane/server.go`
|
|
||||||
|
|
||||||
The server handlers currently call methods directly on `*memoryStore`. These need to call the equivalent methods on the new store. If the method signatures match, this is a straight swap. If not, introduce a `store` interface that both implement during migration, then delete `memoryStore`.
|
|
||||||
|
|
||||||
### Success Criteria
|
|
||||||
|
|
||||||
#### Automated Verification:
|
|
||||||
|
|
||||||
- [ ] `go build ./apps/control-plane/cmd/control-plane/` compiles with `CGO_ENABLED=0`
|
|
||||||
- [ ] `go test ./apps/control-plane/cmd/control-plane/ -v` passes all existing tests
|
|
||||||
- [ ] New SQLite store tests pass (register node, upsert exports, list exports, auth lookup)
|
|
||||||
- [ ] `curl` against a local instance: register node, sync exports, issue mount profile - all return expected responses
|
|
||||||
|
|
||||||
#### Manual Verification:
|
|
||||||
|
|
||||||
- [ ] Start control-plane locally, SQLite file is created at configured path
|
|
||||||
- [ ] Restart control-plane - state persists across restarts
|
|
||||||
- [ ] Node-agent can register and sync exports against the SQLite-backed control-plane
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2: User Auth
|
|
||||||
|
|
||||||
### Overview
|
|
||||||
|
|
||||||
Add user accounts with username/password (bcrypt) and session tokens stored in SQLite. The session token replaces the static `BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN` for web UI access. Node-agent auth (bootstrap token + node token) is unchanged.
|
|
||||||
|
|
||||||
### Additional Schema
|
|
||||||
|
|
||||||
```sql
|
|
||||||
-- Users
|
|
||||||
CREATE TABLE users (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
|
||||||
password_hash TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Sessions
|
|
||||||
CREATE TABLE sessions (
|
|
||||||
token TEXT PRIMARY KEY,
|
|
||||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
|
||||||
expires_at TEXT NOT NULL
|
|
||||||
);
|
|
||||||
CREATE INDEX idx_sessions_expires ON sessions(expires_at);
|
|
||||||
```
|
|
||||||
|
|
||||||
### New API Endpoints
|
|
||||||
|
|
||||||
```
|
|
||||||
POST /api/v1/auth/register - Create account (username, password)
|
|
||||||
POST /api/v1/auth/login - Login, returns session token + sets cookie
|
|
||||||
POST /api/v1/auth/logout - Invalidate session
|
|
||||||
GET /api/v1/auth/me - Return current user info (session validation)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Changes Required
|
|
||||||
|
|
||||||
#### 1. New file: `auth.go`
|
|
||||||
|
|
||||||
**File**: `apps/control-plane/cmd/control-plane/auth.go`
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Dependencies: golang.org/x/crypto/bcrypt, crypto/rand
|
|
||||||
|
|
||||||
func (s *sqliteStore) createUser(username, password string) (user, error)
|
|
||||||
// - Validate username (3-64 chars, alphanumeric + underscore/hyphen)
|
|
||||||
// - bcrypt hash the password (cost 10)
|
|
||||||
// - INSERT into users with generated ID
|
|
||||||
// - Return user struct
|
|
||||||
|
|
||||||
func (s *sqliteStore) authenticateUser(username, password string) (user, error)
|
|
||||||
// - SELECT user by username
|
|
||||||
// - bcrypt.CompareHashAndPassword
|
|
||||||
// - Return user or error
|
|
||||||
|
|
||||||
func (s *sqliteStore) createSession(userID string, ttl time.Duration) (string, error)
|
|
||||||
// - Generate 32-byte random token, hex-encode
|
|
||||||
// - INSERT into sessions with expires_at = now + ttl
|
|
||||||
// - Return token
|
|
||||||
|
|
||||||
func (s *sqliteStore) validateSession(token string) (user, error)
|
|
||||||
// - SELECT session JOIN users WHERE token = ? AND expires_at > now
|
|
||||||
// - Return user or error
|
|
||||||
|
|
||||||
func (s *sqliteStore) deleteSession(token string) error
|
|
||||||
// - DELETE FROM sessions WHERE token = ?
|
|
||||||
|
|
||||||
func (s *sqliteStore) cleanExpiredSessions() error
|
|
||||||
// - DELETE FROM sessions WHERE expires_at < now
|
|
||||||
// - Run periodically (e.g., on each request or via goroutine)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. New env vars
|
|
||||||
|
|
||||||
```
|
|
||||||
BETTERNAS_SESSION_TTL # Session duration (default: "720h" = 30 days)
|
|
||||||
BETTERNAS_REGISTRATION_ENABLED # Allow new registrations (default: "true")
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. Update `server.go` - auth middleware and routes
|
|
||||||
|
|
||||||
**File**: `apps/control-plane/cmd/control-plane/server.go`
|
|
||||||
|
|
||||||
Add auth routes:
|
|
||||||
|
|
||||||
```go
|
|
||||||
mux.HandleFunc("POST /api/v1/auth/register", s.handleRegister)
|
|
||||||
mux.HandleFunc("POST /api/v1/auth/login", s.handleLogin)
|
|
||||||
mux.HandleFunc("POST /api/v1/auth/logout", s.handleLogout)
|
|
||||||
mux.HandleFunc("GET /api/v1/auth/me", s.handleMe)
|
|
||||||
```
|
|
||||||
|
|
||||||
Update client-auth middleware:
|
|
||||||
|
|
||||||
```go
|
|
||||||
// Currently: checks Bearer token against static BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN
|
|
||||||
// New: checks Bearer token against sessions table first, falls back to static token
|
|
||||||
// This preserves backwards compatibility during migration
|
|
||||||
func (s *server) requireClientAuth(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
token := extractBearerToken(r)
|
|
||||||
|
|
||||||
// Try session-based auth first
|
|
||||||
user, err := s.store.validateSession(token)
|
|
||||||
if err == nil {
|
|
||||||
ctx := context.WithValue(r.Context(), userContextKey, user)
|
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to static client token (for backwards compat / scripts)
|
|
||||||
if secureStringEquals(token, s.config.clientToken) {
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
writeUnauthorized(w)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Success Criteria
|
|
||||||
|
|
||||||
#### Automated Verification:
|
|
||||||
|
|
||||||
- [ ] `go test` passes for auth endpoints (register, login, logout, me)
|
|
||||||
- [ ] `go test` passes for session middleware (valid token, expired token, invalid token)
|
|
||||||
- [ ] Existing client token auth still works (backwards compat)
|
|
||||||
- [ ] Existing node auth unchanged
|
|
||||||
|
|
||||||
#### Manual Verification:
|
|
||||||
|
|
||||||
- [ ] Register a user via curl, login, use session token to list exports
|
|
||||||
- [ ] Session expires after TTL
|
|
||||||
- [ ] Logout invalidates session immediately
|
|
||||||
- [ ] Registration can be disabled via env var
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 3: CORS + Frontend Auth Integration
|
|
||||||
|
|
||||||
### Overview
|
|
||||||
|
|
||||||
Add CORS headers to the control-plane so the Vercel-hosted frontend can make API calls. Update the web frontend to use session-based auth (login page, session cookie/token management).
|
|
||||||
|
|
||||||
### Changes Required
|
|
||||||
|
|
||||||
#### 1. CORS middleware in control-plane
|
|
||||||
|
|
||||||
**File**: `apps/control-plane/cmd/control-plane/server.go`
|
|
||||||
|
|
||||||
```go
|
|
||||||
// New env var: BETTERNAS_CORS_ORIGIN (e.g., "https://betternas.com")
|
|
||||||
|
|
||||||
func corsMiddleware(allowedOrigin string, next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", allowedOrigin)
|
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
|
||||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
|
||||||
w.Header().Set("Access-Control-Max-Age", "86400")
|
|
||||||
|
|
||||||
if r.Method == http.MethodOptions {
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. Frontend auth flow
|
|
||||||
|
|
||||||
**Files**: `apps/web/`
|
|
||||||
|
|
||||||
New pages/components:
|
|
||||||
|
|
||||||
- `app/login/page.tsx` - Login form (username + password)
|
|
||||||
- `app/register/page.tsx` - Registration form (if enabled)
|
|
||||||
- `lib/auth.ts` - Client-side auth helpers (store token, attach to requests)
|
|
||||||
|
|
||||||
Update `lib/control-plane.ts`:
|
|
||||||
|
|
||||||
- Remove `.env.agent` file reading (production doesn't need it)
|
|
||||||
- Read `NEXT_PUBLIC_BETTERNAS_API_URL` env var for the backend URL
|
|
||||||
- Use session token from localStorage/cookie instead of static client token
|
|
||||||
- Add login/register/logout API calls
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// lib/auth.ts
|
|
||||||
const TOKEN_KEY = "betternas_session";
|
|
||||||
|
|
||||||
export function getSessionToken(): string | null {
|
|
||||||
if (typeof window === "undefined") return null;
|
|
||||||
return localStorage.getItem(TOKEN_KEY);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setSessionToken(token: string): void {
|
|
||||||
localStorage.setItem(TOKEN_KEY, token);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function clearSessionToken(): void {
|
|
||||||
localStorage.removeItem(TOKEN_KEY);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function login(
|
|
||||||
apiUrl: string,
|
|
||||||
username: string,
|
|
||||||
password: string,
|
|
||||||
): Promise<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