mirror of
https://github.com/harivansh-afk/betterNAS.git
synced 2026-04-15 03:00:44 +00:00
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:
parent
5bc24fa99d
commit
b5f8ea9c52
28 changed files with 1345 additions and 423 deletions
|
|
@ -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
46
CLAUDE.md
Normal 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`.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
71
apps/control-plane/cmd/control-plane/mount_credentials.go
Normal file
71
apps/control-plane/cmd/control-plane/mount_credentials.go
Normal 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))
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
299
apps/node-agent/cmd/node-agent/control_plane.go
Normal file
299
apps/node-agent/cmd/node-agent/control_plane.go
Normal 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(®istration); 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
|
||||
}
|
||||
74
apps/node-agent/cmd/node-agent/dav_auth.go
Normal file
74
apps/node-agent/cmd/node-agent/dav_auth.go
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
21
packages/contracts/schemas/mount-credential.schema.json
Normal file
21
packages/contracts/schemas/mount-credential.schema.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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}."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue