Make control-plane the real mount authority

Split node enrollment from export sync and issue Finder-compatible DAV
credentials so the stack proves the real backend seam before any web UI
consumes it.
This commit is contained in:
Harivansh Rathi 2026-04-01 17:46:50 +00:00
parent 5bc24fa99d
commit b5f8ea9c52
28 changed files with 1345 additions and 423 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=

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
@ -88,13 +98,20 @@ type storageExport struct {
}
type mountProfile struct {
ID string `json:"id"`
ExportID string `json:"exportId"`
Protocol string `json:"protocol"`
DisplayName string `json:"displayName"`
MountURL string `json:"mountUrl"`
Readonly bool `json:"readonly"`
CredentialMode string `json:"credentialMode"`
ID string `json:"id"`
ExportID string `json:"exportId"`
Protocol string `json:"protocol"`
DisplayName string `json:"displayName"`
MountURL string `json:"mountUrl"`
Readonly bool `json:"readonly"`
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 {
@ -115,12 +132,15 @@ type storageExportInput struct {
}
type nodeRegistrationRequest struct {
MachineID string `json:"machineId"`
DisplayName string `json:"displayName"`
AgentVersion string `json:"agentVersion"`
DirectAddress *string `json:"directAddress"`
RelayAddress *string `json:"relayAddress"`
Exports []storageExportInput `json:"exports"`
MachineID string `json:"machineId"`
DisplayName string `json:"displayName"`
AgentVersion string `json:"agentVersion"`
DirectAddress *string `json:"directAddress"`
RelayAddress *string `json:"relayAddress"`
}
type nodeExportsRequest struct {
Exports []storageExportInput `json:"exports"`
}
type nodeHeartbeatRequest struct {
@ -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),
ExportID: context.export.ID,
Protocol: "webdav",
DisplayName: context.export.Label,
MountURL: mountURL,
Readonly: false,
CredentialMode: "session-token",
ID: credentialID,
ExportID: context.export.ID,
Protocol: "webdav",
DisplayName: context.export.Label,
MountURL: mountURL,
Readonly: false,
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"
@ -19,11 +17,15 @@ const (
)
type appConfig struct {
exportPaths []string
exportPaths []string
nodeID string
davAuthSecret string
}
type app struct {
exportMounts []exportMount
nodeID string
davAuthSecret string
exportMounts []exportMount
}
type exportMount struct {
@ -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

@ -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
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="$({
echo "$exports_response" | jq -er \
'map(select(.mountPath == "/dav/")) | .[0].id? // empty'
} 2>/dev/null || true)"
if [[ -n "$export_id" ]]; then
break
fi
sleep 1
done
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
if [[ -z "$export_id" ]]; then
echo "Node agent export did not appear in the control plane." >&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
exports_response="$(curl -fsS -H "Authorization: Bearer ${BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN}" "http://localhost:${BETTERNAS_CONTROL_PLANE_PORT}/api/v1/exports")"
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'
)"
mount_profile="$(
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