mirror of
https://github.com/harivansh-afk/betterNAS.git
synced 2026-04-15 09:01:13 +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
|
|
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue