Merge pull request #11 from harivansh-afk/feat/backend-mount-contract

Make control-plane the real mount authority
This commit is contained in:
Hari 2026-04-01 14:03:27 -04:00 committed by GitHub
commit 8b4efa19fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 2118 additions and 474 deletions

View file

@ -7,6 +7,10 @@ BETTERNAS_NODE_AGENT_PORT=
BETTERNAS_NEXTCLOUD_PORT=
BETTERNAS_EXPORT_PATH=
BETTERNAS_VERSION=local-dev
BETTERNAS_DAV_AUTH_SECRET=
BETTERNAS_DAV_CREDENTIAL_TTL=
BETTERNAS_NODE_MACHINE_ID=
BETTERNAS_NODE_DISPLAY_NAME=
BETTERNAS_NODE_DIRECT_ADDRESS=
BETTERNAS_EXAMPLE_MOUNT_URL=
NEXTCLOUD_BASE_URL=

View file

@ -1,7 +1,9 @@
.git
.agents
.next
.turbo
coverage
dist
node_modules
apps/web/.next
skills-lock.json

46
CLAUDE.md Normal file
View file

@ -0,0 +1,46 @@
# 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`.

View file

@ -97,8 +97,8 @@ 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 URL
4. mount it in Finder
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.

View file

@ -8,6 +8,7 @@ It is intentionally small for now:
- `GET /version`
- `POST /api/v1/nodes/register`
- `POST /api/v1/nodes/{nodeId}/heartbeat`
- `PUT /api/v1/nodes/{nodeId}/exports`
- `GET /api/v1/exports`
- `POST /api/v1/mount-profiles/issue`
- `POST /api/v1/cloud-profiles/issue`
@ -19,4 +20,7 @@ The request and response shapes must follow the contracts in
`BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN`, client flows use
`BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN`, and node registration returns an
`X-BetterNAS-Node-Token` header for subsequent node-scoped register and
heartbeat calls. Multi-export registrations should also send an explicit `mountPath` per export so mount profiles can stay stable across runtimes.
heartbeat and export sync calls. Mount profiles now return standard WebDAV
username and password credentials, and multi-export sync should send an
explicit `mountPath` per export so mount profiles can stay stable across
runtimes.

View file

@ -12,6 +12,8 @@ type appConfig struct {
statePath string
clientToken string
nodeBootstrapToken string
davAuthSecret string
davCredentialTTL time.Duration
}
type app struct {
@ -32,6 +34,14 @@ func newApp(config appConfig, startedAt time.Time) (*app, error) {
return nil, errors.New("node bootstrap token is required")
}
config.davAuthSecret = strings.TrimSpace(config.davAuthSecret)
if config.davAuthSecret == "" {
return nil, errors.New("dav auth secret is required")
}
if config.davCredentialTTL <= 0 {
return nil, errors.New("dav credential ttl must be greater than 0")
}
store, err := newMemoryStore(config.statePath)
if err != nil {
return nil, err
@ -94,7 +104,14 @@ type mountProfile struct {
DisplayName string `json:"displayName"`
MountURL string `json:"mountUrl"`
Readonly bool `json:"readonly"`
CredentialMode string `json:"credentialMode"`
Credential mountCredential `json:"credential"`
}
type mountCredential struct {
Mode string `json:"mode"`
Username string `json:"username"`
Password string `json:"password"`
ExpiresAt string `json:"expiresAt"`
}
type cloudProfile struct {
@ -120,6 +137,9 @@ type nodeRegistrationRequest struct {
AgentVersion string `json:"agentVersion"`
DirectAddress *string `json:"directAddress"`
RelayAddress *string `json:"relayAddress"`
}
type nodeExportsRequest struct {
Exports []storageExportInput `json:"exports"`
}
@ -130,8 +150,6 @@ type nodeHeartbeatRequest struct {
}
type mountProfileRequest struct {
UserID string `json:"userId"`
DeviceID string `json:"deviceId"`
ExportID string `json:"exportId"`
}

View file

@ -34,6 +34,16 @@ func newAppFromEnv(startedAt time.Time) (*app, error) {
return nil, err
}
davAuthSecret, err := requiredEnv("BETTERNAS_DAV_AUTH_SECRET")
if err != nil {
return nil, err
}
davCredentialTTL, err := parseRequiredDurationEnv("BETTERNAS_DAV_CREDENTIAL_TTL")
if err != nil {
return nil, err
}
return newApp(
appConfig{
version: env("BETTERNAS_VERSION", "0.1.0-dev"),
@ -41,6 +51,8 @@ func newAppFromEnv(startedAt time.Time) (*app, error) {
statePath: env("BETTERNAS_CONTROL_PLANE_STATE_PATH", ".state/control-plane/state.json"),
clientToken: clientToken,
nodeBootstrapToken: nodeBootstrapToken,
davAuthSecret: davAuthSecret,
davCredentialTTL: davCredentialTTL,
},
startedAt,
)

View file

@ -0,0 +1,71 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"time"
)
const mountCredentialModeBasicAuth = "basic-auth"
type signedMountCredentialClaims struct {
Version int `json:"v"`
NodeID string `json:"nodeId"`
MountPath string `json:"mountPath"`
Username string `json:"username"`
Readonly bool `json:"readonly"`
ExpiresAt string `json:"expiresAt"`
}
func issueMountCredential(secret string, nodeID string, mountPath string, readonly bool, issuedAt time.Time, ttl time.Duration) (string, mountCredential, error) {
credentialID, err := newOpaqueToken()
if err != nil {
return "", mountCredential{}, err
}
usernameToken, err := newOpaqueToken()
if err != nil {
return "", mountCredential{}, err
}
claims := signedMountCredentialClaims{
Version: 1,
NodeID: nodeID,
MountPath: mountPath,
Username: "mount-" + usernameToken,
Readonly: readonly,
ExpiresAt: issuedAt.UTC().Add(ttl).Format(time.RFC3339),
}
password, err := signMountCredentialClaims(secret, claims)
if err != nil {
return "", mountCredential{}, err
}
return "mount-" + credentialID, mountCredential{
Mode: mountCredentialModeBasicAuth,
Username: claims.Username,
Password: password,
ExpiresAt: claims.ExpiresAt,
}, nil
}
func signMountCredentialClaims(secret string, claims signedMountCredentialClaims) (string, error) {
payload, err := json.Marshal(claims)
if err != nil {
return "", fmt.Errorf("encode mount credential claims: %w", err)
}
encodedPayload := base64.RawURLEncoding.EncodeToString(payload)
signature := signMountCredentialPayload(secret, encodedPayload)
return encodedPayload + "." + signature, nil
}
func signMountCredentialPayload(secret string, encodedPayload string) string {
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(encodedPayload))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}

View file

@ -31,6 +31,11 @@ var (
nodeAgentBinaryErr error
)
const (
runtimeDAVAuthSecret = "runtime-dav-auth-secret"
runtimeDAVCredentialTTL = "1h"
)
func TestControlPlaneBinaryMountLoopIntegration(t *testing.T) {
exportDir := t.TempDir()
writeExportFile(t, exportDir, "README.txt", "betterNAS export\n")
@ -38,166 +43,42 @@ func TestControlPlaneBinaryMountLoopIntegration(t *testing.T) {
nextcloud := httptest.NewServer(http.NotFoundHandler())
defer nextcloud.Close()
nodeAgent := startNodeAgentBinary(t, exportDir)
controlPlane := startControlPlaneBinary(t, "runtime-test-version", nextcloud.URL)
nodeAgent := startNodeAgentBinaryWithExports(t, controlPlane.baseURL, []string{exportDir}, "machine-runtime-1")
client := &http.Client{Timeout: 2 * time.Second}
directAddress := nodeAgent.baseURL
registration := registerNode(t, client, controlPlane.baseURL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{
MachineID: "machine-runtime-1",
DisplayName: "Runtime NAS",
AgentVersion: "1.2.3",
DirectAddress: &directAddress,
RelayAddress: nil,
Exports: []storageExportInput{{
Label: "Photos",
Path: exportDir,
MountPath: defaultWebDAVPath,
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{"runtime"},
}},
})
if registration.Node.ID != "dev-node" {
t.Fatalf("expected node ID %q, got %q", "dev-node", registration.Node.ID)
exports := waitForExportsByPath(t, client, controlPlane.baseURL+"/api/v1/exports", []string{exportDir})
export := exports[exportDir]
if export.ID != "dev-export" {
t.Fatalf("expected export ID %q, got %q", "dev-export", export.ID)
}
if registration.NodeToken == "" {
t.Fatal("expected runtime registration to return a node token")
}
exports := getJSONAuth[[]storageExport](t, client, testClientToken, controlPlane.baseURL+"/api/v1/exports")
if len(exports) != 1 {
t.Fatalf("expected 1 export, got %d", len(exports))
}
if exports[0].ID != "dev-export" {
t.Fatalf("expected export ID %q, got %q", "dev-export", exports[0].ID)
}
if exports[0].Path != exportDir {
t.Fatalf("expected exported path %q, got %q", exportDir, exports[0].Path)
}
if exports[0].MountPath != defaultWebDAVPath {
t.Fatalf("expected mountPath %q, got %q", defaultWebDAVPath, exports[0].MountPath)
if export.MountPath != defaultWebDAVPath {
t.Fatalf("expected mountPath %q, got %q", defaultWebDAVPath, export.MountPath)
}
mount := postJSONAuth[mountProfile](t, client, testClientToken, controlPlane.baseURL+"/api/v1/mount-profiles/issue", mountProfileRequest{
UserID: "runtime-user",
DeviceID: "runtime-device",
ExportID: exports[0].ID,
ExportID: export.ID,
})
if mount.MountURL != nodeAgent.baseURL+defaultWebDAVPath {
t.Fatalf("expected runtime mount URL %q, got %q", nodeAgent.baseURL+defaultWebDAVPath, mount.MountURL)
}
if mount.Credential.Mode != mountCredentialModeBasicAuth {
t.Fatalf("expected mount credential mode %q, got %q", mountCredentialModeBasicAuth, mount.Credential.Mode)
}
assertHTTPStatus(t, client, "PROPFIND", mount.MountURL, http.StatusMultiStatus)
assertMountedFileContents(t, client, mount.MountURL+"README.txt", "betterNAS export\n")
assertHTTPStatusWithBasicAuth(t, client, "PROPFIND", mount.MountURL, mount.Credential.Username, mount.Credential.Password, http.StatusMultiStatus)
assertMountedFileContentsWithBasicAuth(t, client, mount.MountURL+"README.txt", mount.Credential.Username, mount.Credential.Password, "betterNAS export\n")
cloud := postJSONAuth[cloudProfile](t, client, testClientToken, controlPlane.baseURL+"/api/v1/cloud-profiles/issue", cloudProfileRequest{
UserID: "runtime-user",
ExportID: exports[0].ID,
ExportID: export.ID,
Provider: "nextcloud",
})
if cloud.BaseURL != nextcloud.URL {
t.Fatalf("expected runtime cloud baseUrl %q, got %q", nextcloud.URL, cloud.BaseURL)
}
expectedCloudPath := cloudProfilePathForExport(exports[0].ID)
if cloud.Path != expectedCloudPath {
t.Fatalf("expected runtime cloud path %q, got %q", expectedCloudPath, cloud.Path)
}
postJSONAuthStatus(t, client, registration.NodeToken, controlPlane.baseURL+"/api/v1/nodes/"+registration.Node.ID+"/heartbeat", nodeHeartbeatRequest{
NodeID: registration.Node.ID,
Status: "online",
LastSeenAt: "2025-01-02T03:04:05Z",
}, http.StatusNoContent)
}
func TestControlPlaneBinaryReRegistrationReconcilesExports(t *testing.T) {
nextcloud := httptest.NewServer(http.NotFoundHandler())
defer nextcloud.Close()
controlPlane := startControlPlaneBinary(t, "runtime-test-version", nextcloud.URL)
client := &http.Client{Timeout: 2 * time.Second}
directAddress := "http://nas.local:8090"
firstRegistration := registerNode(t, client, controlPlane.baseURL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{
MachineID: "machine-runtime-2",
DisplayName: "Runtime NAS",
AgentVersion: "1.2.3",
DirectAddress: &directAddress,
RelayAddress: nil,
Exports: []storageExportInput{
{
Label: "Docs",
Path: "/srv/docs",
MountPath: "/dav/exports/docs/",
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{"runtime"},
},
{
Label: "Media",
Path: "/srv/media",
MountPath: "/dav/exports/media/",
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{"runtime"},
},
},
})
initialExports := exportsByPath(getJSONAuth[[]storageExport](t, client, testClientToken, controlPlane.baseURL+"/api/v1/exports"))
docsExport := initialExports["/srv/docs"]
if _, ok := initialExports["/srv/media"]; !ok {
t.Fatal("expected media export to be registered")
}
secondRegistration := registerNode(t, client, controlPlane.baseURL+"/api/v1/nodes/register", firstRegistration.NodeToken, nodeRegistrationRequest{
MachineID: "machine-runtime-2",
DisplayName: "Runtime NAS Updated",
AgentVersion: "1.2.4",
DirectAddress: &directAddress,
RelayAddress: nil,
Exports: []storageExportInput{
{
Label: "Docs v2",
Path: "/srv/docs",
MountPath: "/dav/exports/docs-v2/",
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{"runtime", "updated"},
},
{
Label: "Backups",
Path: "/srv/backups",
MountPath: "/dav/exports/backups/",
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{"runtime"},
},
},
})
if secondRegistration.Node.ID != firstRegistration.Node.ID {
t.Fatalf("expected node ID %q after re-registration, got %q", firstRegistration.Node.ID, secondRegistration.Node.ID)
}
updatedExports := exportsByPath(getJSONAuth[[]storageExport](t, client, testClientToken, controlPlane.baseURL+"/api/v1/exports"))
if len(updatedExports) != 2 {
t.Fatalf("expected 2 exports after re-registration, got %d", len(updatedExports))
}
if updatedExports["/srv/docs"].ID != docsExport.ID {
t.Fatalf("expected docs export to keep ID %q, got %q", docsExport.ID, updatedExports["/srv/docs"].ID)
}
if updatedExports["/srv/docs"].Label != "Docs v2" {
t.Fatalf("expected docs export label to update, got %q", updatedExports["/srv/docs"].Label)
}
if updatedExports["/srv/docs"].MountPath != "/dav/exports/docs-v2/" {
t.Fatalf("expected docs export mountPath to update, got %q", updatedExports["/srv/docs"].MountPath)
}
if _, ok := updatedExports["/srv/media"]; ok {
t.Fatal("expected stale media export to be removed")
}
if _, ok := updatedExports["/srv/backups"]; !ok {
t.Fatal("expected backups export to be present")
if cloud.Path != cloudProfilePathForExport(export.ID) {
t.Fatalf("expected runtime cloud path %q, got %q", cloudProfilePathForExport(export.ID), cloud.Path)
}
}
@ -210,53 +91,18 @@ func TestControlPlaneBinaryMultiExportProfilesStayDistinct(t *testing.T) {
nextcloud := httptest.NewServer(http.NotFoundHandler())
defer nextcloud.Close()
nodeAgent := startNodeAgentBinaryWithExports(t, []string{firstExportDir, secondExportDir})
controlPlane := startControlPlaneBinary(t, "runtime-test-version", nextcloud.URL)
nodeAgent := startNodeAgentBinaryWithExports(t, controlPlane.baseURL, []string{firstExportDir, secondExportDir}, "machine-runtime-multi")
client := &http.Client{Timeout: 2 * time.Second}
firstMountPath := nodeAgentMountPathForExport(firstExportDir, 2)
secondMountPath := nodeAgentMountPathForExport(secondExportDir, 2)
directAddress := nodeAgent.baseURL
registerNode(t, client, controlPlane.baseURL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{
MachineID: "machine-runtime-multi",
DisplayName: "Runtime Multi NAS",
AgentVersion: "1.2.3",
DirectAddress: &directAddress,
RelayAddress: nil,
Exports: []storageExportInput{
{
Label: "Docs",
Path: firstExportDir,
MountPath: firstMountPath,
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{"runtime"},
},
{
Label: "Media",
Path: secondExportDir,
MountPath: secondMountPath,
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{"runtime"},
},
},
})
exports := exportsByPath(getJSONAuth[[]storageExport](t, client, testClientToken, controlPlane.baseURL+"/api/v1/exports"))
exports := waitForExportsByPath(t, client, controlPlane.baseURL+"/api/v1/exports", []string{firstExportDir, secondExportDir})
firstExport := exports[firstExportDir]
secondExport := exports[secondExportDir]
firstMount := postJSONAuth[mountProfile](t, client, testClientToken, controlPlane.baseURL+"/api/v1/mount-profiles/issue", mountProfileRequest{
UserID: "runtime-user",
DeviceID: "runtime-device",
ExportID: firstExport.ID,
})
secondMount := postJSONAuth[mountProfile](t, client, testClientToken, controlPlane.baseURL+"/api/v1/mount-profiles/issue", mountProfileRequest{
UserID: "runtime-user",
DeviceID: "runtime-device",
ExportID: secondExport.ID,
})
firstMount := postJSONAuth[mountProfile](t, client, testClientToken, controlPlane.baseURL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: firstExport.ID})
secondMount := postJSONAuth[mountProfile](t, client, testClientToken, controlPlane.baseURL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: secondExport.ID})
if firstMount.MountURL == secondMount.MountURL {
t.Fatalf("expected distinct runtime mount URLs, got %q", firstMount.MountURL)
}
@ -267,10 +113,10 @@ func TestControlPlaneBinaryMultiExportProfilesStayDistinct(t *testing.T) {
t.Fatalf("expected second runtime mount URL %q, got %q", nodeAgent.baseURL+secondMountPath, secondMount.MountURL)
}
assertHTTPStatus(t, client, "PROPFIND", firstMount.MountURL, http.StatusMultiStatus)
assertHTTPStatus(t, client, "PROPFIND", secondMount.MountURL, http.StatusMultiStatus)
assertMountedFileContents(t, client, firstMount.MountURL+"README.txt", "first runtime export\n")
assertMountedFileContents(t, client, secondMount.MountURL+"README.txt", "second runtime export\n")
assertHTTPStatusWithBasicAuth(t, client, "PROPFIND", firstMount.MountURL, firstMount.Credential.Username, firstMount.Credential.Password, http.StatusMultiStatus)
assertHTTPStatusWithBasicAuth(t, client, "PROPFIND", secondMount.MountURL, secondMount.Credential.Username, secondMount.Credential.Password, http.StatusMultiStatus)
assertMountedFileContentsWithBasicAuth(t, client, firstMount.MountURL+"README.txt", firstMount.Credential.Username, firstMount.Credential.Password, "first runtime export\n")
assertMountedFileContentsWithBasicAuth(t, client, secondMount.MountURL+"README.txt", secondMount.Credential.Username, secondMount.Credential.Password, "second runtime export\n")
firstCloud := postJSONAuth[cloudProfile](t, client, testClientToken, controlPlane.baseURL+"/api/v1/cloud-profiles/issue", cloudProfileRequest{
UserID: "runtime-user",
@ -319,6 +165,8 @@ func startControlPlaneBinary(t *testing.T, version string, nextcloudBaseURL stri
"BETTERNAS_CONTROL_PLANE_STATE_PATH="+statePath,
"BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN="+testClientToken,
"BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN="+testNodeBootstrapToken,
"BETTERNAS_DAV_AUTH_SECRET="+runtimeDAVAuthSecret,
"BETTERNAS_DAV_CREDENTIAL_TTL="+runtimeDAVCredentialTTL,
)
cmd.Stdout = logFile
cmd.Stderr = logFile
@ -343,15 +191,13 @@ func startControlPlaneBinary(t *testing.T, version string, nextcloudBaseURL stri
}
}
func startNodeAgentBinary(t *testing.T, exportPath string) runningBinary {
return startNodeAgentBinaryWithExports(t, []string{exportPath})
}
func startNodeAgentBinaryWithExports(t *testing.T, exportPaths []string) runningBinary {
func startNodeAgentBinaryWithExports(t *testing.T, controlPlaneBaseURL string, exportPaths []string, machineID string) runningBinary {
t.Helper()
port := reserveTCPPort(t)
baseURL := fmt.Sprintf("http://127.0.0.1:%s", port)
logPath := filepath.Join(t.TempDir(), "node-agent.log")
nodeTokenPath := filepath.Join(t.TempDir(), "node-token")
logFile, err := os.Create(logPath)
if err != nil {
t.Fatalf("create node-agent log file: %v", err)
@ -368,6 +214,14 @@ func startNodeAgentBinaryWithExports(t *testing.T, exportPaths []string) running
os.Environ(),
"PORT="+port,
"BETTERNAS_EXPORT_PATHS_JSON="+string(rawExportPaths),
"BETTERNAS_CONTROL_PLANE_URL="+controlPlaneBaseURL,
"BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN="+testNodeBootstrapToken,
"BETTERNAS_NODE_TOKEN_PATH="+nodeTokenPath,
"BETTERNAS_NODE_MACHINE_ID="+machineID,
"BETTERNAS_NODE_DISPLAY_NAME="+machineID,
"BETTERNAS_NODE_DIRECT_ADDRESS="+baseURL,
"BETTERNAS_DAV_AUTH_SECRET="+runtimeDAVAuthSecret,
"BETTERNAS_VERSION=runtime-test-version",
)
cmd.Stdout = logFile
cmd.Stderr = logFile
@ -382,7 +236,6 @@ func startNodeAgentBinaryWithExports(t *testing.T, exportPaths []string) running
waitDone <- cmd.Wait()
}()
baseURL := fmt.Sprintf("http://127.0.0.1:%s", port)
waitForHTTPStatus(t, baseURL+"/health", waitDone, logPath, http.StatusOK)
registerProcessCleanup(t, ctx, cancel, cmd, waitDone, logFile, logPath, "node-agent")
@ -392,6 +245,30 @@ func startNodeAgentBinaryWithExports(t *testing.T, exportPaths []string) running
}
}
func waitForExportsByPath(t *testing.T, client *http.Client, endpoint string, expectedPaths []string) map[string]storageExport {
t.Helper()
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
exports := getJSONAuth[[]storageExport](t, client, testClientToken, endpoint)
exportsByPath := exportsByPath(exports)
allPresent := true
for _, expectedPath := range expectedPaths {
if _, ok := exportsByPath[expectedPath]; !ok {
allPresent = false
break
}
}
if allPresent {
return exportsByPath
}
time.Sleep(100 * time.Millisecond)
}
t.Fatalf("exports for %v did not appear in time", expectedPaths)
return nil
}
func buildControlPlaneBinary(t *testing.T) string {
t.Helper()
@ -411,6 +288,7 @@ func buildControlPlaneBinary(t *testing.T) string {
controlPlaneBinaryPath = filepath.Join(tempDir, "control-plane")
cmd := exec.Command("go", "build", "-o", controlPlaneBinaryPath, ".")
cmd.Dir = filepath.Dir(filename)
cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
output, err := cmd.CombinedOutput()
if err != nil {
controlPlaneBinaryErr = fmt.Errorf("build control-plane binary: %w\n%s", err, output)
@ -443,6 +321,7 @@ func buildNodeAgentBinary(t *testing.T) string {
nodeAgentBinaryPath = filepath.Join(tempDir, "node-agent")
cmd := exec.Command("go", "build", "-o", nodeAgentBinaryPath, "./cmd/node-agent")
cmd.Dir = filepath.Clean(filepath.Join(filepath.Dir(filename), "../../../node-agent"))
cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
output, err := cmd.CombinedOutput()
if err != nil {
nodeAgentBinaryErr = fmt.Errorf("build node-agent binary: %w\n%s", err, output)
@ -532,10 +411,16 @@ func registerProcessCleanup(t *testing.T, ctx context.Context, cancel context.Ca
})
}
func assertMountedFileContents(t *testing.T, client *http.Client, endpoint string, expected string) {
func assertMountedFileContentsWithBasicAuth(t *testing.T, client *http.Client, endpoint string, username string, password string, expected string) {
t.Helper()
response, err := client.Get(endpoint)
request, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
t.Fatalf("build GET request for %s: %v", endpoint, err)
}
request.SetBasicAuth(username, password)
response, err := client.Do(request)
if err != nil {
t.Fatalf("get %s: %v", endpoint, err)
}
@ -554,13 +439,14 @@ func assertMountedFileContents(t *testing.T, client *http.Client, endpoint strin
}
}
func assertHTTPStatus(t *testing.T, client *http.Client, method string, endpoint string, expectedStatus int) {
func assertHTTPStatusWithBasicAuth(t *testing.T, client *http.Client, method string, endpoint string, username string, password string, expectedStatus int) {
t.Helper()
request, err := http.NewRequest(method, endpoint, nil)
if err != nil {
t.Fatalf("build %s request for %s: %v", method, endpoint, err)
}
request.SetBasicAuth(username, password)
response, err := client.Do(request)
if err != nil {

View file

@ -35,6 +35,7 @@ func (a *app) handler() http.Handler {
mux.HandleFunc("GET /version", a.handleVersion)
mux.HandleFunc("POST /api/v1/nodes/register", a.handleNodeRegister)
mux.HandleFunc("POST /api/v1/nodes/{nodeId}/heartbeat", a.handleNodeHeartbeat)
mux.HandleFunc("PUT /api/v1/nodes/{nodeId}/exports", a.handleNodeExports)
mux.HandleFunc("GET /api/v1/exports", a.handleExportsList)
mux.HandleFunc("POST /api/v1/mount-profiles/issue", a.handleMountProfileIssue)
mux.HandleFunc("POST /api/v1/cloud-profiles/issue", a.handleCloudProfileIssue)
@ -127,6 +128,37 @@ func (a *app) handleNodeHeartbeat(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
func (a *app) handleNodeExports(w http.ResponseWriter, r *http.Request) {
nodeID := r.PathValue("nodeId")
request, err := decodeNodeExportsRequest(w, r)
if err != nil {
writeDecodeError(w, err)
return
}
if err := validateNodeExportsRequest(request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if !a.authorizeNode(w, r, nodeID) {
return
}
exports, err := a.store.upsertExports(nodeID, request)
if err != nil {
statusCode := http.StatusInternalServerError
if errors.Is(err, errNodeNotFound) {
statusCode = http.StatusNotFound
}
http.Error(w, err.Error(), statusCode)
return
}
writeJSON(w, http.StatusOK, exports)
}
func (a *app) handleExportsList(w http.ResponseWriter, r *http.Request) {
if !a.requireClientAuth(w, r) {
return
@ -163,14 +195,27 @@ func (a *app) handleMountProfileIssue(w http.ResponseWriter, r *http.Request) {
return
}
credentialID, credential, err := issueMountCredential(
a.config.davAuthSecret,
context.node.ID,
mountProfilePathForExport(context.export.MountPath),
false,
a.now(),
a.config.davCredentialTTL,
)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusOK, mountProfile{
ID: fmt.Sprintf("mount-%s-%s", request.DeviceID, context.export.ID),
ID: credentialID,
ExportID: context.export.ID,
Protocol: "webdav",
DisplayName: context.export.Label,
MountURL: mountURL,
Readonly: false,
CredentialMode: "session-token",
Credential: credential,
})
}
@ -226,7 +271,6 @@ func decodeNodeRegistrationRequest(w http.ResponseWriter, r *http.Request) (node
"agentVersion",
"directAddress",
"relayAddress",
"exports",
); err != nil {
return nodeRegistrationRequest{}, err
}
@ -258,9 +302,22 @@ func decodeNodeRegistrationRequest(w http.ResponseWriter, r *http.Request) (node
return nodeRegistrationRequest{}, err
}
return request, nil
}
func decodeNodeExportsRequest(w http.ResponseWriter, r *http.Request) (nodeExportsRequest, error) {
object, err := decodeRawObjectRequest(w, r)
if err != nil {
return nodeExportsRequest{}, err
}
if err := object.validateRequiredKeys("exports"); err != nil {
return nodeExportsRequest{}, err
}
request := nodeExportsRequest{}
request.Exports, err = object.storageExportInputsField("exports")
if err != nil {
return nodeRegistrationRequest{}, err
return nodeExportsRequest{}, err
}
return request, nil
@ -495,10 +552,18 @@ func validateNodeRegistrationRequest(request *nodeRegistrationRequest) error {
return err
}
seenPaths := make(map[string]struct{}, len(request.Exports))
seenMountPaths := make(map[string]struct{}, len(request.Exports))
for index := range request.Exports {
export := &request.Exports[index]
return nil
}
func validateNodeExportsRequest(request nodeExportsRequest) error {
return validateStorageExportInputs(request.Exports)
}
func validateStorageExportInputs(exports []storageExportInput) error {
seenPaths := make(map[string]struct{}, len(exports))
seenMountPaths := make(map[string]struct{}, len(exports))
for index := range exports {
export := &exports[index]
export.Label = strings.TrimSpace(export.Label)
if export.Label == "" {
return fmt.Errorf("exports[%d].label is required", index)
@ -514,7 +579,7 @@ func validateNodeRegistrationRequest(request *nodeRegistrationRequest) error {
seenPaths[export.Path] = struct{}{}
export.MountPath = strings.TrimSpace(export.MountPath)
if len(request.Exports) > 1 && export.MountPath == "" {
if len(exports) > 1 && export.MountPath == "" {
return fmt.Errorf("exports[%d].mountPath is required when registering multiple exports", index)
}
if export.MountPath != "" {
@ -567,12 +632,6 @@ func validateNodeHeartbeatRequest(nodeID string, request nodeHeartbeatRequest) e
}
func validateMountProfileRequest(request mountProfileRequest) error {
if strings.TrimSpace(request.UserID) == "" {
return errors.New("userId is required")
}
if strings.TrimSpace(request.DeviceID) == "" {
return errors.New("deviceId is required")
}
if strings.TrimSpace(request.ExportID) == "" {
return errors.New("exportId is required")
}
@ -773,6 +832,23 @@ func requiredEnv(key string) (string, error) {
return value, nil
}
func parseRequiredDurationEnv(key string) (time.Duration, error) {
value, err := requiredEnv(key)
if err != nil {
return 0, err
}
duration, err := time.ParseDuration(value)
if err != nil {
return 0, fmt.Errorf("%s must be a valid duration: %w", key, err)
}
if duration <= 0 {
return 0, fmt.Errorf("%s must be greater than 0", key)
}
return duration, nil
}
func decodeJSON(w http.ResponseWriter, r *http.Request, destination any) error {
defer r.Body.Close()

View file

@ -92,16 +92,23 @@ func TestControlPlaneRegistrationProfilesAndHeartbeat(t *testing.T) {
AgentVersion: "1.2.3",
DirectAddress: &directAddress,
RelayAddress: &relayAddress,
})
if registration.NodeToken == "" {
t.Fatal("expected node registration to return a node token")
}
syncedExports := syncNodeExports(t, server.Client(), registration.NodeToken, server.URL+"/api/v1/nodes/"+registration.Node.ID+"/exports", nodeExportsRequest{
Exports: []storageExportInput{{
Label: "Photos",
Path: "/srv/photos",
MountPath: "/dav/",
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{"family"},
}},
})
if registration.NodeToken == "" {
t.Fatal("expected node registration to return a node token")
if len(syncedExports) != 1 {
t.Fatalf("expected sync to return 1 export, got %d", len(syncedExports))
}
node := registration.Node
@ -137,13 +144,11 @@ func TestControlPlaneRegistrationProfilesAndHeartbeat(t *testing.T) {
if exports[0].Path != "/srv/photos" {
t.Fatalf("expected export path %q, got %q", "/srv/photos", exports[0].Path)
}
if exports[0].MountPath != "" {
t.Fatalf("expected empty mountPath for default export, got %q", exports[0].MountPath)
if exports[0].MountPath != "/dav/" {
t.Fatalf("expected mountPath %q, got %q", "/dav/", exports[0].MountPath)
}
mount := postJSONAuth[mountProfile](t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{
UserID: "user-1",
DeviceID: "device-1",
ExportID: exports[0].ID,
})
if mount.ExportID != exports[0].ID {
@ -161,8 +166,17 @@ func TestControlPlaneRegistrationProfilesAndHeartbeat(t *testing.T) {
if mount.Readonly {
t.Fatal("expected mount profile to be read-write")
}
if mount.CredentialMode != "session-token" {
t.Fatalf("expected credentialMode session-token, got %q", mount.CredentialMode)
if mount.Credential.Mode != mountCredentialModeBasicAuth {
t.Fatalf("expected credential mode %q, got %q", mountCredentialModeBasicAuth, mount.Credential.Mode)
}
if mount.Credential.Username == "" {
t.Fatal("expected mount credential username to be set")
}
if mount.Credential.Password == "" {
t.Fatal("expected mount credential password to be set")
}
if mount.Credential.ExpiresAt == "" {
t.Fatal("expected mount credential expiry to be set")
}
cloud := postJSONAuth[cloudProfile](t, server.Client(), testClientToken, server.URL+"/api/v1/cloud-profiles/issue", cloudProfileRequest{
@ -202,7 +216,7 @@ func TestControlPlaneRegistrationProfilesAndHeartbeat(t *testing.T) {
}
}
func TestControlPlaneReRegistrationReconcilesExportsAndKeepsStableIDs(t *testing.T) {
func TestControlPlaneExportSyncReconcilesExportsAndKeepsStableIDs(t *testing.T) {
t.Parallel()
app, server := newTestControlPlaneServer(t, appConfig{version: "test-version"})
@ -215,6 +229,30 @@ func TestControlPlaneReRegistrationReconcilesExportsAndKeepsStableIDs(t *testing
AgentVersion: "1.2.3",
DirectAddress: &directAddress,
RelayAddress: nil,
})
putJSONAuthStatus(t, server.Client(), testNodeBootstrapToken, server.URL+"/api/v1/nodes/"+firstRegistration.Node.ID+"/exports", nodeExportsRequest{
Exports: []storageExportInput{
{
Label: "Docs",
Path: "/srv/docs",
MountPath: "/dav/exports/docs/",
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{"work"},
},
{
Label: "Media",
Path: "/srv/media",
MountPath: "/dav/exports/media/",
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{"personal"},
},
},
}, http.StatusUnauthorized)
syncNodeExports(t, server.Client(), firstRegistration.NodeToken, server.URL+"/api/v1/nodes/"+firstRegistration.Node.ID+"/exports", nodeExportsRequest{
Exports: []storageExportInput{
{
Label: "Docs",
@ -235,22 +273,6 @@ func TestControlPlaneReRegistrationReconcilesExportsAndKeepsStableIDs(t *testing
},
})
postJSONAuthStatus(t, server.Client(), testNodeBootstrapToken, server.URL+"/api/v1/nodes/register", nodeRegistrationRequest{
MachineID: "machine-1",
DisplayName: "Unauthorized Re-register",
AgentVersion: "1.2.3",
DirectAddress: &directAddress,
RelayAddress: nil,
Exports: []storageExportInput{{
Label: "Docs",
Path: "/srv/docs",
MountPath: "/dav/exports/docs/",
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{"work"},
}},
}, http.StatusUnauthorized)
initialExports := exportsByPath(getJSONAuth[[]storageExport](t, server.Client(), testClientToken, server.URL+"/api/v1/exports"))
docsExport := initialExports["/srv/docs"]
mediaExport := initialExports["/srv/media"]
@ -261,6 +283,30 @@ func TestControlPlaneReRegistrationReconcilesExportsAndKeepsStableIDs(t *testing
AgentVersion: "1.2.4",
DirectAddress: &directAddress,
RelayAddress: nil,
})
putJSONAuthStatus(t, server.Client(), testClientToken, server.URL+"/api/v1/nodes/"+firstRegistration.Node.ID+"/exports", nodeExportsRequest{
Exports: []storageExportInput{
{
Label: "Docs v2",
Path: "/srv/docs",
MountPath: "/dav/exports/docs-v2/",
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{"work", "updated"},
},
{
Label: "Backups",
Path: "/srv/backups",
MountPath: "/dav/exports/backups/",
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{"system"},
},
},
}, http.StatusUnauthorized)
syncNodeExports(t, server.Client(), firstRegistration.NodeToken, server.URL+"/api/v1/nodes/"+firstRegistration.Node.ID+"/exports", nodeExportsRequest{
Exports: []storageExportInput{
{
Label: "Docs v2",
@ -330,12 +376,14 @@ func TestControlPlaneProfilesRemainExportSpecificForConfiguredMountPaths(t *test
defer server.Close()
directAddress := "http://nas.local:8090"
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{
MachineID: "machine-multi",
DisplayName: "Multi Export 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",
@ -360,16 +408,8 @@ func TestControlPlaneProfilesRemainExportSpecificForConfiguredMountPaths(t *test
docsExport := exports["/srv/docs"]
mediaExport := exports["/srv/media"]
docsMount := postJSONAuth[mountProfile](t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{
UserID: "user-1",
DeviceID: "device-1",
ExportID: docsExport.ID,
})
mediaMount := postJSONAuth[mountProfile](t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{
UserID: "user-1",
DeviceID: "device-1",
ExportID: mediaExport.ID,
})
docsMount := postJSONAuth[mountProfile](t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: docsExport.ID})
mediaMount := postJSONAuth[mountProfile](t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: mediaExport.ID})
if docsMount.MountURL == mediaMount.MountURL {
t.Fatalf("expected distinct mount URLs for configured export paths, got %q", docsMount.MountURL)
}
@ -408,12 +448,14 @@ func TestControlPlaneMountProfilesUseRelayAndPreserveBasePath(t *testing.T) {
defer server.Close()
relayAddress := "https://nas.example.test/control"
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{
MachineID: "machine-relay",
DisplayName: "Relay NAS",
AgentVersion: "1.2.3",
DirectAddress: nil,
RelayAddress: &relayAddress,
})
syncNodeExports(t, server.Client(), registration.NodeToken, server.URL+"/api/v1/nodes/"+registration.Node.ID+"/exports", nodeExportsRequest{
Exports: []storageExportInput{{
Label: "Relay",
Path: "/srv/relay",
@ -424,35 +466,30 @@ func TestControlPlaneMountProfilesUseRelayAndPreserveBasePath(t *testing.T) {
}},
})
mount := postJSONAuth[mountProfile](t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{
UserID: "user-1",
DeviceID: "device-1",
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/" {
t.Fatalf("expected relay mount URL %q, got %q", "https://nas.example.test/control/dav/relay/", mount.MountURL)
}
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{
MachineID: "machine-no-target",
DisplayName: "No Target NAS",
AgentVersion: "1.2.3",
DirectAddress: nil,
RelayAddress: nil,
})
syncNodeExports(t, server.Client(), registration.NodeToken, server.URL+"/api/v1/nodes/"+registration.Node.ID+"/exports", nodeExportsRequest{
Exports: []storageExportInput{{
Label: "Offline",
Path: "/srv/offline",
MountPath: "/dav/",
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{},
}},
})
postJSONAuthStatus(t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{
UserID: "user-1",
DeviceID: "device-2",
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 TestControlPlaneCloudProfilesRequireConfiguredBaseURLAndExistingExport(t *testing.T) {
@ -462,15 +499,18 @@ func TestControlPlaneCloudProfilesRequireConfiguredBaseURLAndExistingExport(t *t
defer server.Close()
directAddress := "http://nas.local:8090"
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{
MachineID: "machine-cloud",
DisplayName: "Cloud 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: "Photos",
Path: "/srv/photos",
MountPath: "/dav/",
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{},
@ -512,6 +552,8 @@ func TestControlPlanePersistsRegistryAcrossAppRestart(t *testing.T) {
AgentVersion: "1.2.3",
DirectAddress: &directAddress,
RelayAddress: nil,
})
syncNodeExports(t, firstServer.Client(), registration.NodeToken, firstServer.URL+"/api/v1/nodes/"+registration.Node.ID+"/exports", nodeExportsRequest{
Exports: []storageExportInput{{
Label: "Docs",
Path: "/srv/docs",
@ -540,11 +582,7 @@ func TestControlPlanePersistsRegistryAcrossAppRestart(t *testing.T) {
t.Fatalf("expected persisted mountPath %q, got %q", "/dav/persisted/", exports[0].MountPath)
}
mount := postJSONAuth[mountProfile](t, secondServer.Client(), testClientToken, secondServer.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{
UserID: "user-1",
DeviceID: "device-1",
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/" {
t.Fatalf("expected persisted mount URL %q, got %q", "http://nas.local:8090/dav/persisted/", mount.MountURL)
}
@ -555,6 +593,8 @@ func TestControlPlanePersistsRegistryAcrossAppRestart(t *testing.T) {
AgentVersion: "1.2.4",
DirectAddress: &directAddress,
RelayAddress: nil,
})
syncNodeExports(t, secondServer.Client(), registration.NodeToken, secondServer.URL+"/api/v1/nodes/"+registration.Node.ID+"/exports", nodeExportsRequest{
Exports: []storageExportInput{{
Label: "Docs Updated",
Path: "/srv/docs",
@ -580,16 +620,14 @@ func TestControlPlaneRejectsInvalidRequestsAndEnforcesAuth(t *testing.T) {
"displayName":"Primary NAS",
"agentVersion":"1.2.3",
"directAddress":"http://nas.local:8090",
"relayAddress":null,
"exports":[{"label":"Docs","path":"/srv/docs","protocols":["webdav"],"capacityBytes":null,"tags":[]}]
"relayAddress":null
}`, http.StatusUnauthorized)
postRawJSONAuthStatus(t, server.Client(), testNodeBootstrapToken, server.URL+"/api/v1/nodes/register", `{
"machineId":"machine-1",
"displayName":"Primary NAS",
"agentVersion":"1.2.3",
"relayAddress":null,
"exports":[{"label":"Docs","path":"/srv/docs","protocols":["webdav"],"capacityBytes":null,"tags":[]}]
"relayAddress":null
}`, http.StatusBadRequest)
postRawJSONAuthStatus(t, server.Client(), testNodeBootstrapToken, server.URL+"/api/v1/nodes/register", `{
@ -597,32 +635,6 @@ func TestControlPlaneRejectsInvalidRequestsAndEnforcesAuth(t *testing.T) {
"displayName":"Primary NAS",
"agentVersion":"1.2.3",
"directAddress":"nas.local:8090",
"relayAddress":null,
"exports":[{"label":"Docs","path":"/srv/docs","protocols":["webdav"],"capacityBytes":null,"tags":[]}]
}`, http.StatusBadRequest)
postRawJSONAuthStatus(t, server.Client(), testNodeBootstrapToken, server.URL+"/api/v1/nodes/register", `{
"machineId":"machine-1",
"displayName":"Primary NAS",
"agentVersion":"1.2.3",
"directAddress":"http://nas.local:8090",
"relayAddress":null,
"exports":[
{"label":"Docs","path":"/srv/docs","mountPath":"/dav/docs/","protocols":["webdav"],"capacityBytes":null,"tags":[]},
{"label":"Docs Duplicate","path":"/srv/docs-2","mountPath":"/dav/docs/","protocols":["webdav"],"capacityBytes":null,"tags":[]}
]
}`, http.StatusBadRequest)
postRawJSONAuthStatus(t, server.Client(), testNodeBootstrapToken, server.URL+"/api/v1/nodes/register", `{
"machineId":"machine-1",
"displayName":"Primary NAS",
"agentVersion":"1.2.3",
"directAddress":"http://nas.local:8090",
"relayAddress":null,
"exports":[
{"label":"Docs","path":"/srv/docs","mountPath":"/dav/docs/","protocols":["webdav"],"capacityBytes":null,"tags":[]},
{"label":"Media","path":"/srv/media","protocols":["webdav"],"capacityBytes":null,"tags":[]}
]
}`, http.StatusBadRequest)
response := postRawJSONAuth(t, server.Client(), testNodeBootstrapToken, server.URL+"/api/v1/nodes/register", `{
@ -631,8 +643,7 @@ func TestControlPlaneRejectsInvalidRequestsAndEnforcesAuth(t *testing.T) {
"agentVersion":"1.2.3",
"directAddress":"http://nas.local:8090",
"relayAddress":null,
"ignoredTopLevel":"ok",
"exports":[{"label":"Docs","path":"/srv/docs","mountPath":"/dav/docs/","protocols":["webdav"],"capacityBytes":null,"tags":[],"ignoredNested":"ok"}]
"ignoredTopLevel":"ok"
}`)
defer response.Body.Close()
@ -653,6 +664,58 @@ func TestControlPlaneRejectsInvalidRequestsAndEnforcesAuth(t *testing.T) {
t.Fatalf("expected node ID %q, got %q", "dev-node", node.ID)
}
putJSONAuthStatus(t, server.Client(), testClientToken, server.URL+"/api/v1/nodes/"+node.ID+"/exports", nodeExportsRequest{
Exports: []storageExportInput{{
Label: "Docs",
Path: "/srv/docs",
MountPath: "/dav/docs/",
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{},
}},
}, http.StatusUnauthorized)
putJSONAuthStatus(t, server.Client(), nodeToken, server.URL+"/api/v1/nodes/"+node.ID+"/exports", nodeExportsRequest{
Exports: []storageExportInput{
{
Label: "Docs",
Path: "/srv/docs",
MountPath: "/dav/docs/",
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{},
},
{
Label: "Media",
Path: "/srv/media",
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{},
},
},
}, http.StatusBadRequest)
putJSONAuthStatus(t, server.Client(), nodeToken, server.URL+"/api/v1/nodes/"+node.ID+"/exports", nodeExportsRequest{
Exports: []storageExportInput{
{
Label: "Docs",
Path: "/srv/docs",
MountPath: "/dav/docs/",
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{},
},
{
Label: "Docs Duplicate",
Path: "/srv/docs-2",
MountPath: "/dav/docs/",
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{},
},
},
}, http.StatusBadRequest)
postJSONAuthStatus(t, server.Client(), testClientToken, server.URL+"/api/v1/nodes/"+node.ID+"/heartbeat", nodeHeartbeatRequest{
NodeID: node.ID,
Status: "online",
@ -686,9 +749,9 @@ func TestControlPlaneRejectsInvalidRequestsAndEnforcesAuth(t *testing.T) {
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)
postRawJSONAuthStatus(t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", `{}`, http.StatusBadRequest)
postJSONAuthStatus(t, server.Client(), testClientToken, server.URL+"/api/v1/mount-profiles/issue", mountProfileRequest{
UserID: "user-1",
DeviceID: "device-1",
ExportID: "missing-export",
}, http.StatusNotFound)
@ -711,6 +774,12 @@ func newTestControlPlaneServer(t *testing.T, config appConfig) (*app, *httptest.
if config.nodeBootstrapToken == "" {
config.nodeBootstrapToken = testNodeBootstrapToken
}
if config.davAuthSecret == "" {
config.davAuthSecret = "test-dav-auth-secret"
}
if config.davCredentialTTL == 0 {
config.davCredentialTTL = time.Hour
}
app, err := newApp(config, testControlPlaneNow)
if err != nil {
@ -755,6 +824,25 @@ func registerNode(t *testing.T, client *http.Client, endpoint string, token stri
}
}
func syncNodeExports(t *testing.T, client *http.Client, token string, endpoint string, payload nodeExportsRequest) []storageExport {
t.Helper()
response := putJSONAuthResponse(t, client, token, endpoint, payload)
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
responseBody, _ := io.ReadAll(response.Body)
t.Fatalf("put %s: expected status 200, got %d: %s", endpoint, response.StatusCode, responseBody)
}
var exports []storageExport
if err := json.NewDecoder(response.Body).Decode(&exports); err != nil {
t.Fatalf("decode %s response: %v", endpoint, err)
}
return exports
}
func getJSON[T any](t *testing.T, client *http.Client, endpoint string) T {
t.Helper()
@ -836,15 +924,39 @@ func postJSONAuthStatus(t *testing.T, client *http.Client, token string, endpoin
}
}
func putJSONAuthStatus(t *testing.T, client *http.Client, token string, endpoint string, payload any, expectedStatus int) {
t.Helper()
response := putJSONAuthResponse(t, client, token, endpoint, payload)
defer response.Body.Close()
if response.StatusCode != expectedStatus {
body, _ := io.ReadAll(response.Body)
t.Fatalf("put %s: expected status %d, got %d: %s", endpoint, expectedStatus, response.StatusCode, body)
}
}
func postJSONAuthResponse(t *testing.T, client *http.Client, token string, endpoint string, payload any) *http.Response {
t.Helper()
return jsonAuthResponse(t, client, http.MethodPost, token, endpoint, payload)
}
func putJSONAuthResponse(t *testing.T, client *http.Client, token string, endpoint string, payload any) *http.Response {
t.Helper()
return jsonAuthResponse(t, client, http.MethodPut, token, endpoint, payload)
}
func jsonAuthResponse(t *testing.T, client *http.Client, method string, token string, endpoint string, payload any) *http.Response {
t.Helper()
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload for %s: %v", endpoint, err)
}
return doRequest(t, client, http.MethodPost, endpoint, bytes.NewReader(body), authHeaders(token))
return doRequest(t, client, method, endpoint, bytes.NewReader(body), authHeaders(token))
}
func postRawJSONAuthStatus(t *testing.T, client *http.Client, token string, endpoint string, raw string, expectedStatus int) {

View file

@ -197,14 +197,43 @@ func registerNodeInState(state *storeState, request nodeRegistrationRequest, reg
RelayAddress: copyStringPointer(request.RelayAddress),
}
state.NodesByID[nodeID] = node
return nodeRegistrationResult{
Node: node,
IssuedNodeToken: issuedNodeToken,
}, nil
}
func (s *memoryStore) upsertExports(nodeID string, request nodeExportsRequest) ([]storageExport, error) {
s.mu.Lock()
defer s.mu.Unlock()
nextState := cloneStoreState(s.state)
exports, err := upsertExportsInState(&nextState, nodeID, request.Exports)
if err != nil {
return nil, err
}
if err := s.persistLocked(nextState); err != nil {
return nil, err
}
s.state = nextState
return exports, nil
}
func upsertExportsInState(state *storeState, nodeID string, exports []storageExportInput) ([]storageExport, error) {
if _, ok := state.NodesByID[nodeID]; !ok {
return nil, errNodeNotFound
}
exportIDsByPath, ok := state.ExportIDsByNodePath[nodeID]
if !ok {
exportIDsByPath = make(map[string]string)
state.ExportIDsByNodePath[nodeID] = exportIDsByPath
}
keepPaths := make(map[string]struct{}, len(request.Exports))
for _, export := range request.Exports {
keepPaths := make(map[string]struct{}, len(exports))
for _, export := range exports {
exportID, ok := exportIDsByPath[export.Path]
if !ok {
exportID = nextExportID(state)
@ -233,11 +262,19 @@ func registerNodeInState(state *storeState, request nodeRegistrationRequest, reg
delete(state.ExportsByID, exportID)
}
state.NodesByID[nodeID] = node
return nodeRegistrationResult{
Node: node,
IssuedNodeToken: issuedNodeToken,
}, nil
nodeExports := make([]storageExport, 0, len(exportIDsByPath))
for exportPath, exportID := range exportIDsByPath {
if _, ok := keepPaths[exportPath]; !ok {
continue
}
nodeExports = append(nodeExports, copyStorageExport(state.ExportsByID[exportID]))
}
sort.Slice(nodeExports, func(i, j int) bool {
return nodeExports[i].ID < nodeExports[j].ID
})
return nodeExports, nil
}
func (s *memoryStore) recordHeartbeat(nodeID string, request nodeHeartbeatRequest) error {

View file

@ -7,5 +7,8 @@ For the scaffold it does two things:
- serves `GET /health`
- serves a WebDAV export at `/dav/`
- optionally serves multiple configured exports at deterministic `/dav/exports/<slug>/` paths via `BETTERNAS_EXPORT_PATHS_JSON`
- registers itself with the control plane and syncs its exports when
`BETTERNAS_CONTROL_PLANE_URL` is configured
- enforces issued WebDAV basic-auth mount credentials
This is the first real storage-facing surface in the monorepo.

View file

@ -1,8 +1,6 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@ -20,9 +18,13 @@ const (
type appConfig struct {
exportPaths []string
nodeID string
davAuthSecret string
}
type app struct {
nodeID string
davAuthSecret string
exportMounts []exportMount
}
@ -32,12 +34,26 @@ type exportMount struct {
}
func newApp(config appConfig) (*app, error) {
config.nodeID = strings.TrimSpace(config.nodeID)
if config.nodeID == "" {
return nil, errors.New("nodeID is required")
}
config.davAuthSecret = strings.TrimSpace(config.davAuthSecret)
if config.davAuthSecret == "" {
return nil, errors.New("davAuthSecret is required")
}
exportMounts, err := buildExportMounts(config.exportPaths)
if err != nil {
return nil, err
}
return &app{exportMounts: exportMounts}, nil
return &app{
nodeID: config.nodeID,
davAuthSecret: config.davAuthSecret,
exportMounts: exportMounts,
}, nil
}
func newAppFromEnv() (*app, error) {
@ -46,7 +62,25 @@ func newAppFromEnv() (*app, error) {
return nil, err
}
return newApp(appConfig{exportPaths: exportPaths})
davAuthSecret, err := requiredEnv("BETTERNAS_DAV_AUTH_SECRET")
if err != nil {
return nil, err
}
nodeID := strings.TrimSpace(env("BETTERNAS_NODE_ID", ""))
if strings.TrimSpace(env("BETTERNAS_CONTROL_PLANE_URL", "")) != "" {
bootstrapResult, err := bootstrapNodeAgentFromEnv(exportPaths)
if err != nil {
return nil, err
}
nodeID = bootstrapResult.nodeID
}
return newApp(appConfig{
exportPaths: exportPaths,
nodeID: nodeID,
davAuthSecret: davAuthSecret,
})
}
func exportPathsFromEnv() ([]string, error) {
@ -126,12 +160,38 @@ func (a *app) handler() http.Handler {
FileSystem: webdav.Dir(mount.exportPath),
LockSystem: webdav.NewMemLS(),
}
mux.Handle(mount.mountPath, dav)
mux.Handle(mount.mountPath, a.requireDAVAuth(mount, dav))
}
return mux
}
func (a *app) requireDAVAuth(mount exportMount, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
username, password, ok := r.BasicAuth()
if !ok {
writeDAVUnauthorized(w)
return
}
claims, err := verifyMountCredential(a.davAuthSecret, password)
if err != nil {
writeDAVUnauthorized(w)
return
}
if claims.NodeID != a.nodeID || claims.MountPath != mount.mountPath || claims.Username != username {
writeDAVUnauthorized(w)
return
}
if claims.Readonly && !isDAVReadMethod(r.Method) {
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func mountProfilePathForExport(exportPath string, exportCount int) string {
// Keep /dav/ stable for the common single-export case while exposing distinct
// scoped roots when a node serves more than one export.
@ -147,6 +207,5 @@ func scopedMountPathForExport(exportPath string) string {
}
func exportRouteSlug(exportPath string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(exportPath)))
return hex.EncodeToString(sum[:])
return stableExportRouteSlug(exportPath)
}

View file

@ -1,21 +1,31 @@
package main
import (
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestSingleExportServesDefaultAndScopedMountPaths(t *testing.T) {
const testDAVAuthSecret = "test-dav-auth-secret"
func TestSingleExportServesDefaultAndScopedMountPathsWithValidCredentials(t *testing.T) {
t.Parallel()
exportDir := t.TempDir()
writeExportFile(t, exportDir, "README.txt", "single export\n")
app, err := newApp(appConfig{exportPaths: []string{exportDir}})
app, err := newApp(appConfig{
exportPaths: []string{exportDir},
nodeID: "node-1",
davAuthSecret: testDAVAuthSecret,
})
if err != nil {
t.Fatalf("new app: %v", err)
}
@ -23,13 +33,17 @@ func TestSingleExportServesDefaultAndScopedMountPaths(t *testing.T) {
server := httptest.NewServer(app.handler())
defer server.Close()
assertHTTPStatus(t, server.Client(), "PROPFIND", server.URL+defaultWebDAVPath, http.StatusMultiStatus)
assertHTTPStatus(t, server.Client(), "PROPFIND", server.URL+scopedMountPathForExport(exportDir), http.StatusMultiStatus)
assertMountedFileContents(t, server.Client(), server.URL+defaultWebDAVPath+"README.txt", "single export\n")
assertMountedFileContents(t, server.Client(), server.URL+scopedMountPathForExport(exportDir)+"README.txt", "single export\n")
defaultUsername, defaultPassword := issueTestMountCredential(t, "node-1", defaultWebDAVPath, false)
scopedMountPath := scopedMountPathForExport(exportDir)
scopedUsername, scopedPassword := issueTestMountCredential(t, "node-1", scopedMountPath, false)
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+defaultWebDAVPath, defaultUsername, defaultPassword, http.StatusMultiStatus)
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+scopedMountPath, scopedUsername, scopedPassword, http.StatusMultiStatus)
assertMountedFileContentsWithBasicAuth(t, server.Client(), server.URL+defaultWebDAVPath+"README.txt", defaultUsername, defaultPassword, "single export\n")
assertMountedFileContentsWithBasicAuth(t, server.Client(), server.URL+scopedMountPath+"README.txt", scopedUsername, scopedPassword, "single export\n")
}
func TestMultipleExportsServeDistinctScopedMountPaths(t *testing.T) {
func TestMultipleExportsServeDistinctScopedMountPathsWithValidCredentials(t *testing.T) {
t.Parallel()
firstExportDir := t.TempDir()
@ -37,7 +51,11 @@ func TestMultipleExportsServeDistinctScopedMountPaths(t *testing.T) {
writeExportFile(t, firstExportDir, "README.txt", "first export\n")
writeExportFile(t, secondExportDir, "README.txt", "second export\n")
app, err := newApp(appConfig{exportPaths: []string{firstExportDir, secondExportDir}})
app, err := newApp(appConfig{
exportPaths: []string{firstExportDir, secondExportDir},
nodeID: "node-1",
davAuthSecret: testDAVAuthSecret,
})
if err != nil {
t.Fatalf("new app: %v", err)
}
@ -51,10 +69,13 @@ func TestMultipleExportsServeDistinctScopedMountPaths(t *testing.T) {
t.Fatal("expected distinct mount paths for multiple exports")
}
assertHTTPStatus(t, server.Client(), "PROPFIND", server.URL+firstMountPath, http.StatusMultiStatus)
assertHTTPStatus(t, server.Client(), "PROPFIND", server.URL+secondMountPath, http.StatusMultiStatus)
assertMountedFileContents(t, server.Client(), server.URL+firstMountPath+"README.txt", "first export\n")
assertMountedFileContents(t, server.Client(), server.URL+secondMountPath+"README.txt", "second export\n")
firstUsername, firstPassword := issueTestMountCredential(t, "node-1", firstMountPath, false)
secondUsername, secondPassword := issueTestMountCredential(t, "node-1", secondMountPath, false)
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+firstMountPath, firstUsername, firstPassword, http.StatusMultiStatus)
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+secondMountPath, secondUsername, secondPassword, http.StatusMultiStatus)
assertMountedFileContentsWithBasicAuth(t, server.Client(), server.URL+firstMountPath+"README.txt", firstUsername, firstPassword, "first export\n")
assertMountedFileContentsWithBasicAuth(t, server.Client(), server.URL+secondMountPath+"README.txt", secondUsername, secondPassword, "second export\n")
response, err := server.Client().Get(server.URL + defaultWebDAVPath)
if err != nil {
@ -66,6 +87,48 @@ func TestMultipleExportsServeDistinctScopedMountPaths(t *testing.T) {
}
}
func TestDAVAuthRejectsMissingInvalidAndReadonlyCredentials(t *testing.T) {
t.Parallel()
exportDir := t.TempDir()
writeExportFile(t, exportDir, "README.txt", "readonly export\n")
app, err := newApp(appConfig{
exportPaths: []string{exportDir},
nodeID: "node-1",
davAuthSecret: testDAVAuthSecret,
})
if err != nil {
t.Fatalf("new app: %v", err)
}
server := httptest.NewServer(app.handler())
defer server.Close()
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+defaultWebDAVPath, "", "", http.StatusUnauthorized)
wrongMountUsername, wrongMountPassword := issueTestMountCredential(t, "node-1", "/dav/wrong/", false)
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+defaultWebDAVPath, wrongMountUsername, wrongMountPassword, http.StatusUnauthorized)
expiredUsername, expiredPassword := issueExpiredTestMountCredential(t, "node-1", defaultWebDAVPath, false)
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+defaultWebDAVPath, expiredUsername, expiredPassword, http.StatusUnauthorized)
readonlyUsername, readonlyPassword := issueTestMountCredential(t, "node-1", defaultWebDAVPath, true)
request, err := http.NewRequest(http.MethodPut, server.URL+defaultWebDAVPath+"README.txt", strings.NewReader("updated\n"))
if err != nil {
t.Fatalf("build PUT request: %v", err)
}
request.SetBasicAuth(readonlyUsername, readonlyPassword)
response, err := server.Client().Do(request)
if err != nil {
t.Fatalf("PUT %s: %v", request.URL.String(), err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusForbidden {
t.Fatalf("expected readonly credential to return 403, got %d", response.StatusCode)
}
}
func TestBuildExportMountsRejectsInvalidConfigs(t *testing.T) {
t.Parallel()
@ -80,13 +143,16 @@ func TestBuildExportMountsRejectsInvalidConfigs(t *testing.T) {
}
}
func assertHTTPStatus(t *testing.T, client *http.Client, method string, endpoint string, expectedStatus int) {
func assertHTTPStatusWithBasicAuth(t *testing.T, client *http.Client, method string, endpoint string, username string, password string, expectedStatus int) {
t.Helper()
request, err := http.NewRequest(method, endpoint, nil)
if err != nil {
t.Fatalf("build %s request for %s: %v", method, endpoint, err)
}
if username != "" || password != "" {
request.SetBasicAuth(username, password)
}
response, err := client.Do(request)
if err != nil {
@ -99,10 +165,16 @@ func assertHTTPStatus(t *testing.T, client *http.Client, method string, endpoint
}
}
func assertMountedFileContents(t *testing.T, client *http.Client, endpoint string, expected string) {
func assertMountedFileContentsWithBasicAuth(t *testing.T, client *http.Client, endpoint string, username string, password string, expected string) {
t.Helper()
response, err := client.Get(endpoint)
request, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
t.Fatalf("build GET request for %s: %v", endpoint, err)
}
request.SetBasicAuth(username, password)
response, err := client.Do(request)
if err != nil {
t.Fatalf("get %s: %v", endpoint, err)
}
@ -121,6 +193,54 @@ func assertMountedFileContents(t *testing.T, client *http.Client, endpoint strin
}
}
func issueTestMountCredential(t *testing.T, nodeID string, mountPath string, readonly bool) (string, string) {
t.Helper()
claims := signedMountCredentialClaims{
Version: 1,
NodeID: nodeID,
MountPath: mountPath,
Username: "mount-test-user",
Readonly: readonly,
ExpiresAt: time.Now().UTC().Add(time.Hour).Format(time.RFC3339),
}
password, err := encodeTestMountCredential(claims)
if err != nil {
t.Fatalf("issue test mount credential: %v", err)
}
return claims.Username, password
}
func issueExpiredTestMountCredential(t *testing.T, nodeID string, mountPath string, readonly bool) (string, string) {
t.Helper()
claims := signedMountCredentialClaims{
Version: 1,
NodeID: nodeID,
MountPath: mountPath,
Username: "mount-expired-user",
Readonly: readonly,
ExpiresAt: time.Now().UTC().Add(-time.Minute).Format(time.RFC3339),
}
password, err := encodeTestMountCredential(claims)
if err != nil {
t.Fatalf("issue expired test mount credential: %v", err)
}
return claims.Username, password
}
func encodeTestMountCredential(claims signedMountCredentialClaims) (string, error) {
payload, err := json.Marshal(claims)
if err != nil {
return "", err
}
encodedPayload := base64.RawURLEncoding.EncodeToString(payload)
return encodedPayload + "." + signMountCredentialPayload(testDAVAuthSecret, encodedPayload), nil
}
func writeExportFile(t *testing.T, directory string, name string, contents string) {
t.Helper()

View file

@ -0,0 +1,299 @@
package main
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
const controlPlaneNodeTokenHeader = "X-BetterNAS-Node-Token"
type bootstrapResult struct {
nodeID string
}
type nodeRegistrationRequest struct {
MachineID string `json:"machineId"`
DisplayName string `json:"displayName"`
AgentVersion string `json:"agentVersion"`
DirectAddress *string `json:"directAddress"`
RelayAddress *string `json:"relayAddress"`
}
type nodeRegistrationResponse struct {
ID string `json:"id"`
}
type nodeExportsRequest struct {
Exports []storageExportInput `json:"exports"`
}
type storageExportInput struct {
Label string `json:"label"`
Path string `json:"path"`
MountPath string `json:"mountPath"`
Protocols []string `json:"protocols"`
CapacityBytes *int64 `json:"capacityBytes"`
Tags []string `json:"tags"`
}
type nodeHeartbeatRequest struct {
NodeID string `json:"nodeId"`
Status string `json:"status"`
LastSeenAt string `json:"lastSeenAt"`
}
func bootstrapNodeAgentFromEnv(exportPaths []string) (bootstrapResult, error) {
controlPlaneURL, err := requiredEnv("BETTERNAS_CONTROL_PLANE_URL")
if err != nil {
return bootstrapResult{}, err
}
bootstrapToken, err := requiredEnv("BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN")
if err != nil {
return bootstrapResult{}, err
}
nodeTokenPath, err := requiredEnv("BETTERNAS_NODE_TOKEN_PATH")
if err != nil {
return bootstrapResult{}, err
}
machineID, err := requiredEnv("BETTERNAS_NODE_MACHINE_ID")
if err != nil {
return bootstrapResult{}, err
}
displayName := strings.TrimSpace(env("BETTERNAS_NODE_DISPLAY_NAME", machineID))
if displayName == "" {
displayName = machineID
}
client := &http.Client{Timeout: 5 * time.Second}
nodeToken, err := readNodeToken(nodeTokenPath)
if err != nil {
return bootstrapResult{}, err
}
authToken := nodeToken
if authToken == "" {
authToken = bootstrapToken
}
registration, issuedNodeToken, err := registerNodeWithControlPlane(client, controlPlaneURL, authToken, nodeRegistrationRequest{
MachineID: machineID,
DisplayName: displayName,
AgentVersion: env("BETTERNAS_VERSION", "0.1.0-dev"),
DirectAddress: optionalEnvPointer("BETTERNAS_NODE_DIRECT_ADDRESS"),
RelayAddress: optionalEnvPointer("BETTERNAS_NODE_RELAY_ADDRESS"),
})
if err != nil {
return bootstrapResult{}, err
}
if strings.TrimSpace(issuedNodeToken) != "" {
if err := writeNodeToken(nodeTokenPath, issuedNodeToken); err != nil {
return bootstrapResult{}, err
}
authToken = issuedNodeToken
}
if err := syncNodeExportsWithControlPlane(client, controlPlaneURL, authToken, registration.ID, buildStorageExportInputs(exportPaths)); err != nil {
return bootstrapResult{}, err
}
if err := sendNodeHeartbeat(client, controlPlaneURL, authToken, registration.ID); err != nil {
return bootstrapResult{}, err
}
return bootstrapResult{nodeID: registration.ID}, nil
}
func registerNodeWithControlPlane(client *http.Client, baseURL string, token string, payload nodeRegistrationRequest) (nodeRegistrationResponse, string, error) {
response, err := doControlPlaneJSONRequest(client, http.MethodPost, controlPlaneEndpoint(baseURL, "/api/v1/nodes/register"), token, payload)
if err != nil {
return nodeRegistrationResponse{}, "", err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return nodeRegistrationResponse{}, "", controlPlaneResponseError("register node", response)
}
var registration nodeRegistrationResponse
if err := json.NewDecoder(response.Body).Decode(&registration); err != nil {
return nodeRegistrationResponse{}, "", fmt.Errorf("decode register node response: %w", err)
}
return registration, strings.TrimSpace(response.Header.Get(controlPlaneNodeTokenHeader)), nil
}
func syncNodeExportsWithControlPlane(client *http.Client, baseURL string, token string, nodeID string, exports []storageExportInput) error {
response, err := doControlPlaneJSONRequest(client, http.MethodPut, controlPlaneEndpoint(baseURL, "/api/v1/nodes/"+nodeID+"/exports"), token, nodeExportsRequest{
Exports: exports,
})
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return controlPlaneResponseError("sync node exports", response)
}
_, _ = io.Copy(io.Discard, response.Body)
return nil
}
func sendNodeHeartbeat(client *http.Client, baseURL string, token string, nodeID string) error {
response, err := doControlPlaneJSONRequest(client, http.MethodPost, controlPlaneEndpoint(baseURL, "/api/v1/nodes/"+nodeID+"/heartbeat"), token, nodeHeartbeatRequest{
NodeID: nodeID,
Status: "online",
LastSeenAt: time.Now().UTC().Format(time.RFC3339),
})
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode != http.StatusNoContent {
return controlPlaneResponseError("send node heartbeat", response)
}
return nil
}
func doControlPlaneJSONRequest(client *http.Client, method string, endpoint string, token string, payload any) (*http.Response, error) {
body, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshal %s %s payload: %w", method, endpoint, err)
}
request, err := http.NewRequest(method, endpoint, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("build %s %s request: %w", method, endpoint, err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "Bearer "+token)
response, err := client.Do(request)
if err != nil {
return nil, fmt.Errorf("%s %s: %w", method, endpoint, err)
}
return response, nil
}
func controlPlaneEndpoint(baseURL string, suffix string) string {
return strings.TrimRight(strings.TrimSpace(baseURL), "/") + suffix
}
func controlPlaneResponseError(action string, response *http.Response) error {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("%s: unexpected status %d: %s", action, response.StatusCode, strings.TrimSpace(string(body)))
}
func buildStorageExportInputs(exportPaths []string) []storageExportInput {
inputs := make([]storageExportInput, len(exportPaths))
for index, exportPath := range exportPaths {
inputs[index] = storageExportInput{
Label: exportLabel(exportPath),
Path: strings.TrimSpace(exportPath),
MountPath: mountProfilePathForExport(exportPath, len(exportPaths)),
Protocols: []string{"webdav"},
CapacityBytes: nil,
Tags: []string{},
}
}
return inputs
}
func exportLabel(exportPath string) string {
base := filepath.Base(filepath.Clean(strings.TrimSpace(exportPath)))
if base == "" || base == "." || base == string(filepath.Separator) {
return "export"
}
return base
}
func stableExportRouteSlug(exportPath string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(exportPath)))
return hex.EncodeToString(sum[:])
}
func requiredEnv(key string) (string, error) {
value, ok := os.LookupEnv(key)
if !ok || strings.TrimSpace(value) == "" {
return "", fmt.Errorf("%s is required", key)
}
return strings.TrimSpace(value), nil
}
func optionalEnvPointer(key string) *string {
value := strings.TrimSpace(env(key, ""))
if value == "" {
return nil
}
return &value
}
func readNodeToken(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", fmt.Errorf("read node token %s: %w", path, err)
}
return strings.TrimSpace(string(data)), nil
}
func writeNodeToken(path string, token string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return fmt.Errorf("create node token directory %s: %w", filepath.Dir(path), err)
}
tempFile, err := os.CreateTemp(filepath.Dir(path), ".node-token-*.tmp")
if err != nil {
return fmt.Errorf("create node token temp file in %s: %w", filepath.Dir(path), err)
}
tempFilePath := tempFile.Name()
cleanupTempFile := true
defer func() {
if cleanupTempFile {
_ = os.Remove(tempFilePath)
}
}()
if err := tempFile.Chmod(0o600); err != nil {
_ = tempFile.Close()
return fmt.Errorf("chmod node token temp file %s: %w", tempFilePath, err)
}
if _, err := tempFile.WriteString(strings.TrimSpace(token) + "\n"); err != nil {
_ = tempFile.Close()
return fmt.Errorf("write node token temp file %s: %w", tempFilePath, err)
}
if err := tempFile.Close(); err != nil {
return fmt.Errorf("close node token temp file %s: %w", tempFilePath, err)
}
if err := os.Rename(tempFilePath, path); err != nil {
return fmt.Errorf("replace node token %s: %w", path, err)
}
cleanupTempFile = false
return nil
}

View file

@ -0,0 +1,74 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
)
type signedMountCredentialClaims struct {
Version int `json:"v"`
NodeID string `json:"nodeId"`
MountPath string `json:"mountPath"`
Username string `json:"username"`
Readonly bool `json:"readonly"`
ExpiresAt string `json:"expiresAt"`
}
func verifyMountCredential(secret string, token string) (signedMountCredentialClaims, error) {
encodedPayload, signature, ok := strings.Cut(strings.TrimSpace(token), ".")
if !ok || encodedPayload == "" || signature == "" {
return signedMountCredentialClaims{}, errors.New("invalid mount credential")
}
expectedSignature := signMountCredentialPayload(secret, encodedPayload)
if subtle.ConstantTimeCompare([]byte(expectedSignature), []byte(signature)) != 1 {
return signedMountCredentialClaims{}, errors.New("invalid mount credential")
}
payload, err := base64.RawURLEncoding.DecodeString(encodedPayload)
if err != nil {
return signedMountCredentialClaims{}, errors.New("invalid mount credential")
}
var claims signedMountCredentialClaims
if err := json.Unmarshal(payload, &claims); err != nil {
return signedMountCredentialClaims{}, errors.New("invalid mount credential")
}
if claims.Version != 1 || claims.NodeID == "" || claims.MountPath == "" || claims.Username == "" || claims.ExpiresAt == "" {
return signedMountCredentialClaims{}, errors.New("invalid mount credential")
}
expiresAt, err := time.Parse(time.RFC3339, claims.ExpiresAt)
if err != nil || time.Now().UTC().After(expiresAt) {
return signedMountCredentialClaims{}, errors.New("invalid mount credential")
}
return claims, nil
}
func signMountCredentialPayload(secret string, encodedPayload string) string {
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(encodedPayload))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
func writeDAVUnauthorized(w http.ResponseWriter) {
w.Header().Set("WWW-Authenticate", `Basic realm="betterNAS"`)
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
}
func isDAVReadMethod(method string) bool {
switch method {
case http.MethodGet, http.MethodHead, http.MethodOptions, "PROPFIND":
return true
default:
return false
}
}

View file

@ -11,3 +11,11 @@ Use this app for:
Do not move the product system of record into this app. It should stay a UI and
thin BFF layer over the Go control plane.
The current page reads control-plane config from:
- `BETTERNAS_CONTROL_PLANE_URL` and `BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN`, or
- the repo-local `.env.agent` file
That keeps the page aligned with the running self-hosted stack during local
development.

View file

@ -0,0 +1,42 @@
.field {
display: grid;
gap: 10px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.label {
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #527082;
}
.button {
border: 0;
border-radius: 999px;
padding: 9px 12px;
background: #123043;
color: #f7fbfc;
font: inherit;
font-size: 0.86rem;
cursor: pointer;
}
.value {
display: block;
overflow-wrap: anywhere;
padding: 12px 14px;
border-radius: 14px;
background: #f3f8f7;
border: 1px solid rgba(18, 48, 67, 0.08);
color: #123043;
font-size: 0.92rem;
line-height: 1.5;
}

View file

@ -0,0 +1,29 @@
"use client";
import { Button } from "@betternas/ui/button";
import { Code } from "@betternas/ui/code";
import { useState } from "react";
import styles from "./copy-field.module.css";
export function CopyField({ label, value }: { label: string; value: string }) {
const [copied, setCopied] = useState(false);
return (
<div className={styles.field}>
<div className={styles.header}>
<span className={styles.label}>{label}</span>
<Button
className={styles.button}
onClick={async () => {
await navigator.clipboard.writeText(value);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
}}
>
{copied ? "Copied" : "Copy"}
</Button>
</div>
<Code className={styles.value}>{value}</Code>
</div>
);
}

View file

@ -12,7 +12,7 @@
}
.hero {
max-width: 860px;
max-width: 1200px;
margin: 0 auto 32px;
padding: 32px;
border-radius: 28px;
@ -42,32 +42,252 @@
line-height: 1.7;
}
.grid {
max-width: 860px;
.heroMeta {
margin-top: 22px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 14px;
}
.heroMeta div {
padding: 16px;
border-radius: 18px;
background: rgba(247, 251, 252, 0.12);
border: 1px solid rgba(247, 251, 252, 0.14);
}
.heroMeta dt {
margin: 0 0 8px;
font-size: 0.76rem;
letter-spacing: 0.08em;
text-transform: uppercase;
opacity: 0.76;
}
.heroMeta dd {
margin: 0;
font-size: 0.98rem;
line-height: 1.5;
}
.layout {
max-width: 1200px;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 18px;
grid-template-columns: minmax(0, 1.05fr) minmax(320px, 0.95fr);
gap: 22px;
}
.card {
display: block;
padding: 22px;
border-radius: 20px;
.panel {
display: grid;
align-content: start;
gap: 18px;
padding: 24px;
border-radius: 28px;
background: rgba(255, 255, 255, 0.88);
border: 1px solid rgba(18, 48, 67, 0.1);
box-shadow: 0 18px 50px rgba(18, 48, 67, 0.1);
}
.panelHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.sectionEyebrow {
margin: 0;
font-size: 0.76rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #527082;
}
.sectionTitle {
margin: 8px 0 0;
font-size: clamp(1.4rem, 2vw, 1.85rem);
color: #10212d;
}
.sectionMeta {
align-self: center;
padding: 8px 12px;
border-radius: 999px;
background: #eff6f5;
color: #26485b;
font-size: 0.88rem;
}
.notice {
padding: 14px 16px;
border-radius: 18px;
background: #fff1de;
color: #7a4a12;
line-height: 1.6;
}
.emptyState {
padding: 22px;
border-radius: 22px;
background: #f3f8f7;
border: 1px dashed rgba(18, 48, 67, 0.16);
color: #395667;
line-height: 1.7;
}
.exportList {
display: grid;
gap: 14px;
}
.exportCard,
.exportCardSelected {
display: grid;
gap: 16px;
padding: 20px;
border-radius: 22px;
border: 1px solid rgba(18, 48, 67, 0.08);
box-shadow: 0 12px 30px rgba(18, 48, 67, 0.08);
background: #fbfdfd;
color: inherit;
text-decoration: none;
transition:
transform 140ms ease,
box-shadow 140ms ease,
border-color 140ms ease;
}
.card h2 {
margin: 0 0 10px;
.exportCard:hover,
.exportCardSelected:hover {
transform: translateY(-1px);
box-shadow: 0 12px 26px rgba(18, 48, 67, 0.08);
}
.card p {
.exportCardSelected {
border-color: rgba(29, 84, 102, 0.28);
background: linear-gradient(180deg, #f4fbfa 0%, #eef8f6 100%);
}
.exportCardTop {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.exportTitle {
margin: 0;
line-height: 1.6;
font-size: 1.1rem;
color: #123043;
}
.exportId {
margin: 6px 0 0;
color: #527082;
font-size: 0.92rem;
}
.exportProtocol {
flex-shrink: 0;
padding: 8px 10px;
border-radius: 999px;
background: #eaf3f1;
color: #1d5466;
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.exportFacts,
.mountMeta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
}
.exportFactWide {
grid-column: 1 / -1;
}
.exportFacts dt,
.mountMeta dt {
margin: 0 0 6px;
font-size: 0.76rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #5d7888;
}
.exportFacts dd,
.mountMeta dd {
margin: 0;
overflow-wrap: anywhere;
color: #123043;
line-height: 1.55;
}
.mountPanel {
display: grid;
gap: 20px;
}
.mountStatus {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.mountTitle {
margin: 8px 0 0;
font-size: 1.3rem;
color: #123043;
}
.mountBadge {
flex-shrink: 0;
padding: 8px 12px;
border-radius: 999px;
background: #123043;
color: #f7fbfc;
font-size: 0.84rem;
}
.copyFields {
display: grid;
gap: 14px;
}
.instructions {
padding: 18px 20px;
border-radius: 22px;
background: #f4f8fa;
border: 1px solid rgba(18, 48, 67, 0.08);
}
.instructionsTitle {
margin: 0 0 12px;
color: #123043;
font-size: 1rem;
}
.instructionsList {
padding-left: 20px;
color: #304e60;
line-height: 1.7;
}
.inlineCode {
padding: 2px 6px;
border-radius: 8px;
background: rgba(18, 48, 67, 0.08);
color: #123043;
}
@media (max-width: 980px) {
.layout {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
@ -75,7 +295,19 @@
padding-inline: 16px;
}
.hero {
.hero,
.panel {
padding: 24px;
}
.panelHeader,
.mountStatus,
.exportCardTop {
flex-direction: column;
}
.exportFacts,
.mountMeta {
grid-template-columns: 1fr;
}
}

View file

@ -1,52 +1,254 @@
import { Card } from "@betternas/ui/card";
import { Code } from "@betternas/ui/code";
import { CopyField } from "./copy-field";
import {
ControlPlaneConfigurationError,
ControlPlaneRequestError,
getControlPlaneConfig,
issueMountProfile,
listExports,
type MountProfile,
type StorageExport,
} from "../lib/control-plane";
import styles from "./page.module.css";
const lanes = [
{
title: "NAS node",
body: "Runs on the storage machine. Exposes WebDAV, reports exports, and stays close to the bytes.",
},
{
title: "Control plane",
body: "Owns users, devices, nodes, grants, mount profiles, and cloud profiles.",
},
{
title: "Local device",
body: "Consumes mount profiles and uses Finder WebDAV flows before we ship a helper app.",
},
{
title: "Cloud layer",
body: "Keeps Nextcloud optional and thin for browser, mobile, and sharing flows.",
},
];
export const dynamic = "force-dynamic";
interface PageProps {
searchParams: Promise<{
exportId?: string | string[];
}>;
}
export default async function Home({ searchParams }: PageProps) {
const resolvedSearchParams = await searchParams;
const selectedExportId = readSearchParam(resolvedSearchParams.exportId);
const controlPlaneConfig = await getControlPlaneConfig();
let exports: StorageExport[] = [];
let mountProfile: MountProfile | null = null;
let feedback: string | null = null;
try {
exports = await listExports();
if (selectedExportId !== null) {
if (
exports.some((storageExport) => storageExport.id === selectedExportId)
) {
mountProfile = await issueMountProfile(selectedExportId);
} else {
feedback = `Export ${selectedExportId} was not found in the current control-plane response.`;
}
}
} catch (error) {
if (
error instanceof ControlPlaneConfigurationError ||
error instanceof ControlPlaneRequestError
) {
feedback = error.message;
} else {
throw error;
}
}
const selectedExport =
selectedExportId === null
? null
: (exports.find(
(storageExport) => storageExport.id === selectedExportId,
) ?? null);
export default function Home() {
return (
<main className={styles.page}>
<section className={styles.hero}>
<p className={styles.eyebrow}>betterNAS monorepo</p>
<p className={styles.eyebrow}>betterNAS control plane</p>
<h1 className={styles.title}>
Contract-first scaffold for NAS mounts and cloud mode.
Mount exports from the live control-plane.
</h1>
<p className={styles.copy}>
The repo is organized so each system part can be built in parallel
without inventing new interfaces. The source of truth is the root
contract plus the shared contracts package.
This page reads the running control-plane, lists available exports,
and issues Finder-friendly WebDAV mount credentials for the export you
select.
</p>
<dl className={styles.heroMeta}>
<div>
<dt>Control-plane URL</dt>
<dd>{controlPlaneConfig.baseUrl ?? "Not configured"}</dd>
</div>
<div>
<dt>Auth mode</dt>
<dd>
{controlPlaneConfig.clientToken === null
? "Missing client token"
: "Server-side bearer token"}
</dd>
</div>
<div>
<dt>Exports discovered</dt>
<dd>{exports.length}</dd>
</div>
</dl>
</section>
<section className={styles.grid}>
{lanes.map((lane) => (
<Card
key={lane.title}
className={styles.card}
title={lane.title}
href="/#"
<section className={styles.layout}>
<section className={styles.panel}>
<div className={styles.panelHeader}>
<div>
<p className={styles.sectionEyebrow}>Exports</p>
<h2 className={styles.sectionTitle}>
Registered storage exports
</h2>
</div>
<span className={styles.sectionMeta}>
{exports.length === 1 ? "1 export" : `${exports.length} exports`}
</span>
</div>
{feedback !== null ? (
<div className={styles.notice}>{feedback}</div>
) : null}
{exports.length === 0 ? (
<div className={styles.emptyState}>
No exports are registered yet. Start the node agent and verify the
control-plane connection first.
</div>
) : (
<div className={styles.exportList}>
{exports.map((storageExport) => {
const isSelected = storageExport.id === selectedExportId;
return (
<a
key={storageExport.id}
className={
isSelected ? styles.exportCardSelected : styles.exportCard
}
href={`/?exportId=${encodeURIComponent(storageExport.id)}`}
>
{lane.body}
</Card>
))}
<div className={styles.exportCardTop}>
<div>
<h3 className={styles.exportTitle}>
{storageExport.label}
</h3>
<p className={styles.exportId}>{storageExport.id}</p>
</div>
<span className={styles.exportProtocol}>
{storageExport.protocols.join(", ")}
</span>
</div>
<dl className={styles.exportFacts}>
<div>
<dt>Node</dt>
<dd>{storageExport.nasNodeId}</dd>
</div>
<div>
<dt>Mount path</dt>
<dd>{storageExport.mountPath ?? "/dav/"}</dd>
</div>
<div className={styles.exportFactWide}>
<dt>Export path</dt>
<dd>{storageExport.path}</dd>
</div>
</dl>
</a>
);
})}
</div>
)}
</section>
<aside className={styles.panel}>
<div className={styles.panelHeader}>
<div>
<p className={styles.sectionEyebrow}>Mount instructions</p>
<h2 className={styles.sectionTitle}>
{selectedExport === null
? "Select an export"
: `Mount ${selectedExport.label}`}
</h2>
</div>
</div>
{mountProfile === null ? (
<div className={styles.emptyState}>
Pick an export to issue a WebDAV mount profile and reveal the URL,
username, password, and expiry.
</div>
) : (
<div className={styles.mountPanel}>
<div className={styles.mountStatus}>
<div>
<p className={styles.sectionEyebrow}>Issued profile</p>
<h3 className={styles.mountTitle}>
{mountProfile.displayName}
</h3>
</div>
<span className={styles.mountBadge}>
{mountProfile.readonly ? "Read-only" : "Read-write"}
</span>
</div>
<div className={styles.copyFields}>
<CopyField label="Mount URL" value={mountProfile.mountUrl} />
<CopyField
label="Username"
value={mountProfile.credential.username}
/>
<CopyField
label="Password"
value={mountProfile.credential.password}
/>
</div>
<dl className={styles.mountMeta}>
<div>
<dt>Credential mode</dt>
<dd>{mountProfile.credential.mode}</dd>
</div>
<div>
<dt>Expires at</dt>
<dd>{mountProfile.credential.expiresAt}</dd>
</div>
</dl>
<div className={styles.instructions}>
<h3 className={styles.instructionsTitle}>Finder steps</h3>
<ol className={styles.instructionsList}>
<li>Open Finder and choose Go, then Connect to Server.</li>
<li>
Paste{" "}
<Code className={styles.inlineCode}>
{mountProfile.mountUrl}
</Code>
.
</li>
<li>When prompted, use the issued username and password.</li>
<li>
Save credentials in Keychain only if the expiry fits your
workflow.
</li>
</ol>
</div>
</div>
)}
</aside>
</section>
</main>
);
}
function readSearchParam(value: string | string[] | undefined): string | null {
if (typeof value === "string" && value.trim() !== "") {
return value.trim();
}
if (Array.isArray(value)) {
const firstValue = value.find(
(candidateValue) => candidateValue.trim() !== "",
);
return firstValue?.trim() ?? null;
}
return null;
}

View file

@ -0,0 +1,202 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { cache } from "react";
export interface StorageExport {
id: string;
nasNodeId: string;
label: string;
path: string;
mountPath?: string;
protocols: string[];
capacityBytes: number | null;
tags: string[];
}
export interface MountCredential {
mode: "basic-auth";
username: string;
password: string;
expiresAt: string;
}
export interface MountProfile {
id: string;
exportId: string;
protocol: "webdav";
displayName: string;
mountUrl: string;
readonly: boolean;
credential: MountCredential;
}
export interface ControlPlaneConfig {
baseUrl: string | null;
clientToken: string | null;
}
export class ControlPlaneConfigurationError extends Error {
constructor() {
super(
"Control-plane configuration is missing. Set BETTERNAS_CONTROL_PLANE_URL and BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN, or provide them through .env.agent.",
);
}
}
export class ControlPlaneRequestError extends Error {
constructor(message: string) {
super(message);
}
}
const readAgentEnvFile = cache(async (): Promise<Record<string, string>> => {
const candidatePaths = [
path.resolve(/* turbopackIgnore: true */ process.cwd(), ".env.agent"),
path.resolve(/* turbopackIgnore: true */ process.cwd(), "../../.env.agent"),
];
for (const candidatePath of candidatePaths) {
try {
const raw = await readFile(candidatePath, "utf8");
return parseEnvLikeFile(raw);
} catch (error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code === "ENOENT") {
continue;
}
throw error;
}
}
return {};
});
function parseEnvLikeFile(raw: string): Record<string, string> {
return raw.split(/\r?\n/).reduce<Record<string, string>>((env, line) => {
const trimmedLine = line.trim();
if (trimmedLine === "" || trimmedLine.startsWith("#")) {
return env;
}
const separatorIndex = trimmedLine.indexOf("=");
if (separatorIndex === -1) {
return env;
}
const key = trimmedLine.slice(0, separatorIndex).trim();
const value = trimmedLine.slice(separatorIndex + 1).trim();
env[key] = unwrapEnvValue(value);
return env;
}, {});
}
function unwrapEnvValue(value: string): string {
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
return value.slice(1, -1);
}
return value;
}
export async function getControlPlaneConfig(): Promise<ControlPlaneConfig> {
const agentEnv = await readAgentEnvFile();
const cloneName = firstDefinedValue(
process.env.BETTERNAS_CLONE_NAME,
agentEnv.BETTERNAS_CLONE_NAME,
);
const clientToken = firstDefinedValue(
process.env.BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN,
agentEnv.BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN,
cloneName === null ? undefined : `${cloneName}-local-client-token`,
);
const directBaseUrl = firstDefinedValue(
process.env.BETTERNAS_CONTROL_PLANE_URL,
agentEnv.BETTERNAS_CONTROL_PLANE_URL,
);
if (directBaseUrl !== null) {
return {
baseUrl: trimTrailingSlash(directBaseUrl),
clientToken,
};
}
const controlPlanePort = firstDefinedValue(
process.env.BETTERNAS_CONTROL_PLANE_PORT,
agentEnv.BETTERNAS_CONTROL_PLANE_PORT,
);
return {
baseUrl:
controlPlanePort === null
? null
: trimTrailingSlash(`http://localhost:${controlPlanePort}`),
clientToken,
};
}
export async function listExports(): Promise<StorageExport[]> {
return controlPlaneRequest<StorageExport[]>("/api/v1/exports");
}
export async function issueMountProfile(
exportId: string,
): Promise<MountProfile> {
return controlPlaneRequest<MountProfile>("/api/v1/mount-profiles/issue", {
method: "POST",
body: JSON.stringify({ exportId }),
});
}
async function controlPlaneRequest<T>(
requestPath: string,
init?: RequestInit,
): Promise<T> {
const config = await getControlPlaneConfig();
if (config.baseUrl === null || config.clientToken === null) {
throw new ControlPlaneConfigurationError();
}
const headers = new Headers(init?.headers);
headers.set("Authorization", `Bearer ${config.clientToken}`);
if (init?.body !== undefined) {
headers.set("Content-Type", "application/json");
}
const response = await fetch(`${config.baseUrl}${requestPath}`, {
...init,
headers,
cache: "no-store",
});
if (!response.ok) {
const responseBody = await response.text();
throw new ControlPlaneRequestError(
`Control-plane request failed for ${requestPath} with status ${response.status}: ${responseBody || response.statusText}`,
);
}
return (await response.json()) as T;
}
function firstDefinedValue(
...values: Array<string | undefined>
): string | null {
for (const value of values) {
const trimmedValue = value?.trim();
if (trimmedValue) {
return trimmedValue;
}
}
return null;
}
function trimTrailingSlash(value: string): string {
return value.replace(/\/+$/, "");
}

View file

@ -111,7 +111,7 @@ self-hosted mount flow.
- node registration
- node heartbeat
- export inventory
- export inventory via a dedicated sync endpoint
### Web control plane -> control-server
@ -123,7 +123,20 @@ self-hosted mount flow.
### Local device -> control-server
- fetch mount instructions
- receive issued WebDAV URL and credentials or token material
- receive issued WebDAV URL and standard WebDAV credentials
- username
- password
- expiresAt
## Initial backend route sketch
The first backend contract should stay narrow:
- `POST /api/v1/nodes/register`
- `POST /api/v1/nodes/{nodeId}/heartbeat`
- `PUT /api/v1/nodes/{nodeId}/exports`
- `GET /api/v1/exports`
- `POST /api/v1/mount-profiles/issue`
### Control-server internal

View file

@ -35,6 +35,8 @@ services:
BETTERNAS_CONTROL_PLANE_STATE_PATH: /var/lib/betternas/control-plane/state.json
BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN: ${BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN}
BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN: ${BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN}
BETTERNAS_DAV_AUTH_SECRET: ${BETTERNAS_DAV_AUTH_SECRET}
BETTERNAS_DAV_CREDENTIAL_TTL: ${BETTERNAS_DAV_CREDENTIAL_TTL}
BETTERNAS_EXAMPLE_MOUNT_URL: ${BETTERNAS_EXAMPLE_MOUNT_URL}
BETTERNAS_NODE_DIRECT_ADDRESS: ${BETTERNAS_NODE_DIRECT_ADDRESS}
ports:
@ -54,6 +56,15 @@ services:
environment:
PORT: 8090
BETTERNAS_EXPORT_PATH: /data/export
BETTERNAS_EXPORT_PATHS_JSON: '["/data/export"]'
BETTERNAS_CONTROL_PLANE_URL: http://control-plane:3000
BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN: ${BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN}
BETTERNAS_NODE_TOKEN_PATH: /var/lib/betternas/node-agent/node-token
BETTERNAS_NODE_MACHINE_ID: ${BETTERNAS_NODE_MACHINE_ID}
BETTERNAS_NODE_DISPLAY_NAME: ${BETTERNAS_NODE_DISPLAY_NAME}
BETTERNAS_NODE_DIRECT_ADDRESS: ${BETTERNAS_NODE_DIRECT_ADDRESS}
BETTERNAS_DAV_AUTH_SECRET: ${BETTERNAS_DAV_AUTH_SECRET}
BETTERNAS_VERSION: ${BETTERNAS_VERSION}
ports:
- "${BETTERNAS_NODE_AGENT_PORT}:8090"
healthcheck:
@ -63,6 +74,7 @@ services:
retries: 12
volumes:
- ${BETTERNAS_EXPORT_PATH}:/data/export
- node-agent-data:/var/lib/betternas/node-agent
nextcloud:
image: nextcloud:31-apache
@ -91,6 +103,7 @@ services:
volumes:
control-plane-data:
node-agent-data:
nextcloud-data:
nextcloud-custom-apps:
postgres-data:

View file

@ -66,6 +66,34 @@ paths:
description: Heartbeat accepted
"401":
description: Unauthorized
/api/v1/nodes/{nodeId}/exports:
put:
operationId: syncNodeExports
security:
- NodeToken: []
parameters:
- in: path
name: nodeId
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/NodeExportsRequest"
responses:
"200":
description: Export inventory accepted
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/StorageExport"
"401":
description: Unauthorized
/api/v1/exports:
get:
operationId: listExports
@ -213,7 +241,7 @@ components:
- displayName
- mountUrl
- readonly
- credentialMode
- credential
properties:
id:
type: string
@ -228,9 +256,25 @@ components:
type: string
readonly:
type: boolean
credentialMode:
credential:
$ref: "#/components/schemas/MountCredential"
MountCredential:
type: object
required:
- mode
- username
- password
- expiresAt
properties:
mode:
type: string
enum: [basic-auth]
username:
type: string
password:
type: string
expiresAt:
type: string
enum: [session-token, app-password]
CloudProfile:
type: object
required:
@ -287,7 +331,6 @@ components:
- agentVersion
- directAddress
- relayAddress
- exports
properties:
machineId:
type: string
@ -303,6 +346,11 @@ components:
type:
- string
- "null"
NodeExportsRequest:
type: object
required:
- exports
properties:
exports:
type: array
items:
@ -324,14 +372,8 @@ components:
MountProfileRequest:
type: object
required:
- userId
- deviceId
- exportId
properties:
userId:
type: string
deviceId:
type: string
exportId:
type: string
CloudProfileRequest:

View file

@ -0,0 +1,21 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://betternas.local/schemas/mount-credential.schema.json",
"type": "object",
"additionalProperties": false,
"required": ["mode", "username", "password", "expiresAt"],
"properties": {
"mode": {
"const": "basic-auth"
},
"username": {
"type": "string"
},
"password": {
"type": "string"
},
"expiresAt": {
"type": "string"
}
}
}

View file

@ -10,7 +10,7 @@
"displayName",
"mountUrl",
"readonly",
"credentialMode"
"credential"
],
"properties": {
"id": {
@ -31,8 +31,8 @@
"readonly": {
"type": "boolean"
},
"credentialMode": {
"enum": ["session-token", "app-password"]
"credential": {
"$ref": "./mount-credential.schema.json"
}
}
}

View file

@ -1,6 +1,7 @@
export const FOUNDATION_API_ROUTES = {
registerNode: "/api/v1/nodes/register",
nodeHeartbeat: "/api/v1/nodes/:nodeId/heartbeat",
nodeExports: "/api/v1/nodes/:nodeId/exports",
listExports: "/api/v1/exports",
issueMountProfile: "/api/v1/mount-profiles/issue",
issueCloudProfile: "/api/v1/cloud-profiles/issue",
@ -15,7 +16,7 @@ export type NasNodeStatus = "online" | "offline" | "degraded";
export type StorageAccessProtocol = "webdav";
export type AccessMode = "mount" | "cloud";
export type AccessPrincipalType = "user" | "device";
export type MountCredentialMode = "session-token" | "app-password";
export type MountCredentialMode = "basic-auth";
export type CloudProvider = "nextcloud";
export interface NasNode {
@ -56,7 +57,14 @@ export interface MountProfile {
displayName: string;
mountUrl: string;
readonly: boolean;
credentialMode: MountCredentialMode;
credential: MountCredential;
}
export interface MountCredential {
mode: MountCredentialMode;
username: string;
password: string;
expiresAt: string;
}
export interface CloudProfile {
@ -82,6 +90,9 @@ export interface NodeRegistrationRequest {
agentVersion: string;
directAddress: string | null;
relayAddress: string | null;
}
export interface NodeExportsRequest {
exports: StorageExportInput[];
}
@ -92,8 +103,6 @@ export interface NodeHeartbeatRequest {
}
export interface MountProfileRequest {
userId: string;
deviceId: string;
exportId: string;
}

View file

@ -35,7 +35,8 @@ Control plane: http://localhost:${BETTERNAS_CONTROL_PLANE_PORT}
Node agent: http://localhost:${BETTERNAS_NODE_AGENT_PORT}
Nextcloud: ${NEXTCLOUD_BASE_URL}
Export path: ${BETTERNAS_EXPORT_PATH}
Mount URL: ${BETTERNAS_EXAMPLE_MOUNT_URL}
Example mount URL: ${BETTERNAS_EXAMPLE_MOUNT_URL}
Mount credentials are issued by the control plane.
Next:
pnpm verify

View file

@ -67,4 +67,5 @@ echo "Clone: $BETTERNAS_CLONE_NAME"
echo "Nextcloud: $NEXTCLOUD_BASE_URL"
echo "betterNAS control plane: http://localhost:$BETTERNAS_CONTROL_PLANE_PORT"
echo "betterNAS node agent: http://localhost:$BETTERNAS_NODE_AGENT_PORT"
echo "WebDAV mount URL: $BETTERNAS_EXAMPLE_MOUNT_URL"
echo "Example WebDAV mount URL: $BETTERNAS_EXAMPLE_MOUNT_URL"
echo "Issue a mount profile from the control plane to get WebDAV credentials."

View file

@ -6,87 +6,64 @@ set -euo pipefail
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../lib/runtime-env.sh"
"$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/wait-stack"
"$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/verify-webdav"
control_health="$(curl -fsS "http://localhost:${BETTERNAS_CONTROL_PLANE_PORT}/health")"
verify_run_id="$(date +%s)-$$"
node_machine_id="${BETTERNAS_CLONE_NAME}-machine-${verify_run_id}"
echo "$control_health" | jq -e '.service == "control-plane" and .status == "ok"' >/dev/null
register_headers="$(mktemp)"
register_body="$(mktemp)"
trap 'rm -f "$register_headers" "$register_body"' EXIT
curl -fsS \
-D "$register_headers" \
-o "$register_body" \
-X POST \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer ${BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN}" \
-d @- \
"http://localhost:${BETTERNAS_CONTROL_PLANE_PORT}/api/v1/nodes/register" <<JSON
{"machineId":"${node_machine_id}","displayName":"${BETTERNAS_CLONE_NAME} node","agentVersion":"${BETTERNAS_VERSION}","directAddress":"${BETTERNAS_NODE_DIRECT_ADDRESS}","relayAddress":null,"exports":[{"label":"integration","path":"${BETTERNAS_EXPORT_PATH}","mountPath":"/dav/","protocols":["webdav"],"capacityBytes":null,"tags":["integration"]}]}
JSON
register_response="$(cat "$register_body")"
echo "$register_response" | jq -e '.status == "online"' >/dev/null
node_id="$(echo "$register_response" | jq -er '.id')"
node_token="$(tr -d '\r' < "$register_headers" | awk -F': ' 'tolower($1) == tolower("X-BetterNAS-Node-Token") { print $2 }' | tail -n 1 | tr -d '\n')"
if [[ -z "$node_token" ]]; then
echo "Node registration did not return X-BetterNAS-Node-Token" >&2
exit 1
fi
heartbeat_status="$(curl -sS -o /dev/null -w '%{http_code}' \
-X POST \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer ${node_token}" \
-d "{\"nodeId\":\"${node_id}\",\"status\":\"online\",\"lastSeenAt\":\"2026-01-01T00:00:00Z\"}" \
"http://localhost:${BETTERNAS_CONTROL_PLANE_PORT}/api/v1/nodes/${node_id}/heartbeat")"
if [[ "$heartbeat_status" != "204" ]]; then
echo "Heartbeat did not return 204" >&2
exit 1
fi
export_id=""
for _ in {1..30}; do
exports_response="$(curl -fsS -H "Authorization: Bearer ${BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN}" "http://localhost:${BETTERNAS_CONTROL_PLANE_PORT}/api/v1/exports")"
export_id="$(
export_id="$({
echo "$exports_response" | jq -er \
--arg node_id "$node_id" \
--arg export_path "$BETTERNAS_EXPORT_PATH" \
'map(select(.nasNodeId == $node_id and .path == $export_path)) | .[0].id'
)"
'map(select(.mountPath == "/dav/")) | .[0].id? // empty'
} 2>/dev/null || true)"
if [[ -n "$export_id" ]]; then
break
fi
sleep 1
done
mount_profile="$(
if [[ -z "$export_id" ]]; then
echo "Node agent export did not appear in the control plane." >&2
exit 1
fi
mount_profile="$({
curl -fsS \
-X POST \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer ${BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN}" \
-d "{\"userId\":\"integration-user\",\"deviceId\":\"integration-device\",\"exportId\":\"${export_id}\"}" \
-d "{\"exportId\":\"${export_id}\"}" \
"http://localhost:${BETTERNAS_CONTROL_PLANE_PORT}/api/v1/mount-profiles/issue"
)"
echo "$mount_profile" | jq -e --arg expected "$BETTERNAS_EXAMPLE_MOUNT_URL" '.protocol == "webdav" and .mountUrl == $expected' >/dev/null
})"
echo "$mount_profile" | jq -e --arg expected "$BETTERNAS_EXAMPLE_MOUNT_URL" '.protocol == "webdav" and .mountUrl == $expected and .credential.mode == "basic-auth"' >/dev/null
cloud_profile="$(
BETTERNAS_EXAMPLE_MOUNT_USERNAME="$(echo "$mount_profile" | jq -er '.credential.username')"
BETTERNAS_EXAMPLE_MOUNT_PASSWORD="$(echo "$mount_profile" | jq -er '.credential.password')"
export BETTERNAS_EXAMPLE_MOUNT_USERNAME
export BETTERNAS_EXAMPLE_MOUNT_PASSWORD
"$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/verify-webdav"
cloud_profile="$({
curl -fsS \
-X POST \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer ${BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN}" \
-d "{\"userId\":\"integration-user\",\"exportId\":\"${export_id}\",\"provider\":\"nextcloud\"}" \
"http://localhost:${BETTERNAS_CONTROL_PLANE_PORT}/api/v1/cloud-profiles/issue"
)"
})"
echo "$cloud_profile" | jq -e --arg expected "$NEXTCLOUD_BASE_URL" '.provider == "nextcloud" and .baseUrl == $expected' >/dev/null
echo "$cloud_profile" | jq -e --arg expected "/apps/betternascontrolplane/exports/${export_id}" '.path == $expected' >/dev/null
nextcloud_status="$(curl -fsS "${NEXTCLOUD_BASE_URL}/status.php")"
echo "$nextcloud_status" | jq -e '.installed == true' >/dev/null
nextcloud_app_status="$(
nextcloud_app_status="$({
curl -fsS \
-u "${NEXTCLOUD_ADMIN_USER}:${NEXTCLOUD_ADMIN_PASSWORD}" \
-H 'OCS-APIRequest: true' \
"${NEXTCLOUD_BASE_URL}/ocs/v2.php/apps/betternascontrolplane/api/status?format=json"
)"
})"
echo "$nextcloud_app_status" | jq -e '.ocs.meta.statuscode == 200' >/dev/null
echo "Stack verified for ${BETTERNAS_CLONE_NAME}."

View file

@ -8,7 +8,19 @@ source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../lib/runtime-env.sh"
headers="$(mktemp)"
trap 'rm -f "$headers"' EXIT
curl -fsS -D "$headers" -o /dev/null -X PROPFIND -H 'Depth: 0' "$BETTERNAS_EXAMPLE_MOUNT_URL"
curl_args=(
-fsS
-D "$headers"
-o /dev/null
-X PROPFIND
-H 'Depth: 0'
)
if [[ -n "${BETTERNAS_EXAMPLE_MOUNT_USERNAME:-}" ]] || [[ -n "${BETTERNAS_EXAMPLE_MOUNT_PASSWORD:-}" ]]; then
curl_args+=(-u "${BETTERNAS_EXAMPLE_MOUNT_USERNAME:-}:${BETTERNAS_EXAMPLE_MOUNT_PASSWORD:-}")
fi
curl "${curl_args[@]}" "$BETTERNAS_EXAMPLE_MOUNT_URL"
if ! grep -Eq '^HTTP/[0-9.]+ 207' "$headers"; then
echo "WebDAV PROPFIND did not return 207 for $BETTERNAS_EXAMPLE_MOUNT_URL" >&2

View file

@ -132,6 +132,10 @@ betternas_write_agent_env_file() {
betternas_write_env_assignment "BETTERNAS_VERSION" "local-dev"
betternas_write_env_assignment "BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN" "${clone_name}-local-client-token"
betternas_write_env_assignment "BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN" "${clone_name}-local-node-bootstrap-token"
betternas_write_env_assignment "BETTERNAS_DAV_AUTH_SECRET" "${clone_name}-local-dav-auth-secret"
betternas_write_env_assignment "BETTERNAS_DAV_CREDENTIAL_TTL" "1h"
betternas_write_env_assignment "BETTERNAS_NODE_MACHINE_ID" "${clone_name}-node"
betternas_write_env_assignment "BETTERNAS_NODE_DISPLAY_NAME" "${clone_name} node"
betternas_write_env_assignment "BETTERNAS_NODE_DIRECT_ADDRESS" "http://localhost:${node_agent_port}"
betternas_write_env_assignment "BETTERNAS_EXAMPLE_MOUNT_URL" "http://localhost:${node_agent_port}/dav/"
betternas_write_env_assignment "NEXTCLOUD_BASE_URL" "http://localhost:${nextcloud_port}"

View file

@ -33,6 +33,10 @@ read -r default_nextcloud_port default_node_agent_port default_control_plane_por
: "${BETTERNAS_VERSION:=local-dev}"
: "${BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN:=${BETTERNAS_CLONE_NAME}-local-client-token}"
: "${BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN:=${BETTERNAS_CLONE_NAME}-local-node-bootstrap-token}"
: "${BETTERNAS_DAV_AUTH_SECRET:=${BETTERNAS_CLONE_NAME}-local-dav-auth-secret}"
: "${BETTERNAS_DAV_CREDENTIAL_TTL:=1h}"
: "${BETTERNAS_NODE_MACHINE_ID:=${BETTERNAS_CLONE_NAME}-node}"
: "${BETTERNAS_NODE_DISPLAY_NAME:=${BETTERNAS_CLONE_NAME} node}"
: "${NEXTCLOUD_ADMIN_USER:=admin}"
: "${NEXTCLOUD_ADMIN_PASSWORD:=admin}"
@ -60,6 +64,10 @@ export BETTERNAS_EXPORT_PATH
export BETTERNAS_VERSION
export BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN
export BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN
export BETTERNAS_DAV_AUTH_SECRET
export BETTERNAS_DAV_CREDENTIAL_TTL
export BETTERNAS_NODE_MACHINE_ID
export BETTERNAS_NODE_DISPLAY_NAME
export NEXTCLOUD_ADMIN_USER
export NEXTCLOUD_ADMIN_PASSWORD
export BETTERNAS_NODE_DIRECT_ADDRESS

View file

@ -1,6 +1,11 @@
{
"$schema": "https://turborepo.dev/schema.json",
"ui": "tui",
"globalEnv": [
"BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN",
"BETTERNAS_CONTROL_PLANE_PORT",
"BETTERNAS_CONTROL_PLANE_URL"
],
"tasks": {
"build": {
"dependsOn": ["^build"],