user-owned DAVs (#14)

This commit is contained in:
Hari 2026-04-01 20:26:44 -04:00 committed by GitHub
parent ca5014750b
commit 1bbfb6647d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 732 additions and 777 deletions

View file

@ -17,10 +17,10 @@ The request and response shapes must follow the contracts in
[`packages/contracts`](../../packages/contracts).
`/api/v1/*` endpoints require bearer auth. New nodes register with
`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 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
the same username and password session that users use in the web app.
`BETTERNAS_USERNAME` and `BETTERNAS_PASSWORD` may be provided to seed a default
account for local or self-hosted setups. Nodes and exports are owned by users,
and mount profiles return the account username plus the mount URL so Finder can
authenticate with that same betterNAS password. Multi-export sync should send
an explicit `mountPath` per export so mount profiles can stay stable across
runtimes.

View file

@ -1,8 +1,6 @@
package main
import (
"errors"
"strings"
"time"
)
@ -11,10 +9,6 @@ type appConfig struct {
nextcloudBaseURL string
statePath string
dbPath string
clientToken string
nodeBootstrapToken string
davAuthSecret string
davCredentialTTL time.Duration
sessionTTL time.Duration
registrationEnabled bool
corsOrigin string
@ -28,21 +22,6 @@ type app struct {
}
func newApp(config appConfig, startedAt time.Time) (*app, error) {
config.clientToken = strings.TrimSpace(config.clientToken)
config.nodeBootstrapToken = strings.TrimSpace(config.nodeBootstrapToken)
if config.nodeBootstrapToken == "" {
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")
}
var s store
var err error
if config.dbPath != "" {
@ -91,6 +70,7 @@ type nasNode struct {
LastSeenAt string `json:"lastSeenAt"`
DirectAddress *string `json:"directAddress"`
RelayAddress *string `json:"relayAddress"`
OwnerID string `json:"-"`
}
type storageExport struct {
@ -102,6 +82,7 @@ type storageExport struct {
Protocols []string `json:"protocols"`
CapacityBytes *int64 `json:"capacityBytes"`
Tags []string `json:"tags"`
OwnerID string `json:"-"`
}
type mountProfile struct {

View file

@ -196,21 +196,25 @@ func TestAuthSessionUsedForClientEndpoints(t *testing.T) {
}
}
func TestAuthStaticTokenFallback(t *testing.T) {
func TestAuthSessionIsTheOnlyClientAuthPath(t *testing.T) {
t.Parallel()
_, server := newTestSQLiteApp(t, appConfig{
version: "test-version",
clientToken: "static-fallback-token",
version: "test-version",
registrationEnabled: true,
})
defer server.Close()
// Static token should work for client endpoints.
exports := getJSONAuth[[]storageExport](t, server.Client(), "static-fallback-token", server.URL+"/api/v1/exports")
reg := postJSONAuthCreated[authLoginResponse](t, server.Client(), "", server.URL+"/api/v1/auth/register", authRegisterRequest{
Username: "sessiononly",
Password: "password123",
})
exports := getJSONAuth[[]storageExport](t, server.Client(), reg.Token, server.URL+"/api/v1/exports")
if exports == nil {
t.Fatal("expected exports list, got nil")
}
// Wrong token should fail.
getStatusWithAuth(t, server.Client(), "static-fallback-token", server.URL+"/api/v1/exports", http.StatusUnauthorized)
getStatusWithAuth(t, server.Client(), "wrong", server.URL+"/api/v1/exports", http.StatusUnauthorized)
}

View file

@ -1,6 +1,7 @@
package main
import (
"errors"
"log"
"net/http"
"strings"
@ -25,44 +26,52 @@ func main() {
}
func newAppFromEnv(startedAt time.Time) (*app, error) {
nodeBootstrapToken, err := requiredEnv("BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN")
if err != nil {
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
}
var sessionTTL time.Duration
rawSessionTTL := strings.TrimSpace(env("BETTERNAS_SESSION_TTL", "720h"))
if rawSessionTTL != "" {
sessionTTL, err = time.ParseDuration(rawSessionTTL)
parsedSessionTTL, err := time.ParseDuration(rawSessionTTL)
if err != nil {
return nil, err
}
sessionTTL = parsedSessionTTL
}
return newApp(
app, err := newApp(
appConfig{
version: env("BETTERNAS_VERSION", "0.1.0-dev"),
nextcloudBaseURL: env("NEXTCLOUD_BASE_URL", ""),
statePath: env("BETTERNAS_CONTROL_PLANE_STATE_PATH", ".state/control-plane/state.json"),
dbPath: env("BETTERNAS_CONTROL_PLANE_DB_PATH", ""),
clientToken: env("BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN", ""),
nodeBootstrapToken: nodeBootstrapToken,
davAuthSecret: davAuthSecret,
davCredentialTTL: davCredentialTTL,
dbPath: env("BETTERNAS_CONTROL_PLANE_DB_PATH", ".state/control-plane/betternas.db"),
sessionTTL: sessionTTL,
registrationEnabled: env("BETTERNAS_REGISTRATION_ENABLED", "true") == "true",
corsOrigin: env("BETTERNAS_CORS_ORIGIN", ""),
},
startedAt,
)
if err != nil {
return nil, err
}
if err := seedDefaultUserFromEnv(app); err != nil {
return nil, err
}
return app, nil
}
func seedDefaultUserFromEnv(app *app) error {
username := strings.TrimSpace(env("BETTERNAS_USERNAME", ""))
password := env("BETTERNAS_PASSWORD", "")
if username == "" || password == "" {
return nil
}
if _, err := app.store.createUser(username, password); err != nil {
if errors.Is(err, errUsernameTaken) {
_, authErr := app.store.authenticateUser(username, password)
return authErr
}
return err
}
return nil
}

View file

@ -1,89 +1,12 @@
package main
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"time"
)
const mountCredentialModeBasicAuth = "basic-auth"
// mountCredentialUsernameTokenBytes controls the random token size in mount
// credential usernames (e.g. "mount-<token>"). The username is also embedded
// inside the signed password payload, so longer tokens produce longer
// passwords. macOS WebDAVFS truncates Basic Auth passwords at 255 bytes,
// which corrupts the HMAC signature and causes auth failures. 24 bytes
// (32 base64url chars) keeps the total password under 250 characters with
// margin for longer node IDs and mount paths.
const mountCredentialUsernameTokenBytes = 24
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 := newMountCredentialUsernameToken()
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{
func buildAccountMountCredential(username string) mountCredential {
return mountCredential{
Mode: mountCredentialModeBasicAuth,
Username: claims.Username,
Password: password,
ExpiresAt: claims.ExpiresAt,
}, nil
}
func newMountCredentialUsernameToken() (string, error) {
raw := make([]byte, mountCredentialUsernameTokenBytes)
if _, err := rand.Read(raw); err != nil {
return "", fmt.Errorf("generate mount credential username token: %w", err)
Username: username,
Password: "",
ExpiresAt: "",
}
return base64.RawURLEncoding.EncodeToString(raw), nil
}
func signMountCredentialClaims(secret string, claims signedMountCredentialClaims) (string, error) {
payload, err := json.Marshal(claims)
if err != nil {
return "", fmt.Errorf("encode mount credential claims: %w", err)
}
encodedPayload := base64.RawURLEncoding.EncodeToString(payload)
signature := signMountCredentialPayload(secret, encodedPayload)
return encodedPayload + "." + signature, nil
}
func signMountCredentialPayload(secret string, encodedPayload string) string {
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(encodedPayload))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}

View file

@ -31,10 +31,7 @@ var (
nodeAgentBinaryErr error
)
const (
runtimeDAVAuthSecret = "runtime-dav-auth-secret"
runtimeDAVCredentialTTL = "1h"
)
const runtimeUsername = "runtime-user"
func TestControlPlaneBinaryMountLoopIntegration(t *testing.T) {
exportDir := t.TempDir()
@ -47,7 +44,7 @@ func TestControlPlaneBinaryMountLoopIntegration(t *testing.T) {
nodeAgent := startNodeAgentBinaryWithExports(t, controlPlane.baseURL, []string{exportDir}, "machine-runtime-1")
client := &http.Client{Timeout: 2 * time.Second}
exports := waitForExportsByPath(t, client, controlPlane.baseURL+"/api/v1/exports", []string{exportDir})
exports := waitForExportsByPath(t, client, controlPlane.sessionToken, 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)
@ -56,7 +53,7 @@ func TestControlPlaneBinaryMountLoopIntegration(t *testing.T) {
t.Fatalf("expected mountPath %q, got %q", defaultWebDAVPath, export.MountPath)
}
mount := postJSONAuth[mountProfile](t, client, testClientToken, controlPlane.baseURL+"/api/v1/mount-profiles/issue", mountProfileRequest{
mount := postJSONAuth[mountProfile](t, client, controlPlane.sessionToken, controlPlane.baseURL+"/api/v1/mount-profiles/issue", mountProfileRequest{
ExportID: export.ID,
})
if mount.MountURL != nodeAgent.baseURL+defaultWebDAVPath {
@ -66,11 +63,11 @@ func TestControlPlaneBinaryMountLoopIntegration(t *testing.T) {
t.Fatalf("expected mount credential mode %q, got %q", mountCredentialModeBasicAuth, mount.Credential.Mode)
}
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")
assertHTTPStatusWithBasicAuth(t, client, "PROPFIND", mount.MountURL, controlPlane.username, controlPlane.password, http.StatusMultiStatus)
assertMountedFileContentsWithBasicAuth(t, client, mount.MountURL+"README.txt", controlPlane.username, controlPlane.password, "betterNAS export\n")
cloud := postJSONAuth[cloudProfile](t, client, testClientToken, controlPlane.baseURL+"/api/v1/cloud-profiles/issue", cloudProfileRequest{
UserID: "runtime-user",
cloud := postJSONAuth[cloudProfile](t, client, controlPlane.sessionToken, controlPlane.baseURL+"/api/v1/cloud-profiles/issue", cloudProfileRequest{
UserID: controlPlane.userID,
ExportID: export.ID,
Provider: "nextcloud",
})
@ -97,12 +94,12 @@ func TestControlPlaneBinaryMultiExportProfilesStayDistinct(t *testing.T) {
firstMountPath := nodeAgentMountPathForExport(firstExportDir, 2)
secondMountPath := nodeAgentMountPathForExport(secondExportDir, 2)
exports := waitForExportsByPath(t, client, controlPlane.baseURL+"/api/v1/exports", []string{firstExportDir, secondExportDir})
exports := waitForExportsByPath(t, client, controlPlane.sessionToken, 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{ExportID: firstExport.ID})
secondMount := postJSONAuth[mountProfile](t, client, testClientToken, controlPlane.baseURL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: secondExport.ID})
firstMount := postJSONAuth[mountProfile](t, client, controlPlane.sessionToken, controlPlane.baseURL+"/api/v1/mount-profiles/issue", mountProfileRequest{ExportID: firstExport.ID})
secondMount := postJSONAuth[mountProfile](t, client, controlPlane.sessionToken, 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)
}
@ -113,18 +110,18 @@ func TestControlPlaneBinaryMultiExportProfilesStayDistinct(t *testing.T) {
t.Fatalf("expected second runtime mount URL %q, got %q", nodeAgent.baseURL+secondMountPath, secondMount.MountURL)
}
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")
assertHTTPStatusWithBasicAuth(t, client, "PROPFIND", firstMount.MountURL, controlPlane.username, controlPlane.password, http.StatusMultiStatus)
assertHTTPStatusWithBasicAuth(t, client, "PROPFIND", secondMount.MountURL, controlPlane.username, controlPlane.password, http.StatusMultiStatus)
assertMountedFileContentsWithBasicAuth(t, client, firstMount.MountURL+"README.txt", controlPlane.username, controlPlane.password, "first runtime export\n")
assertMountedFileContentsWithBasicAuth(t, client, secondMount.MountURL+"README.txt", controlPlane.username, controlPlane.password, "second runtime export\n")
firstCloud := postJSONAuth[cloudProfile](t, client, testClientToken, controlPlane.baseURL+"/api/v1/cloud-profiles/issue", cloudProfileRequest{
UserID: "runtime-user",
firstCloud := postJSONAuth[cloudProfile](t, client, controlPlane.sessionToken, controlPlane.baseURL+"/api/v1/cloud-profiles/issue", cloudProfileRequest{
UserID: controlPlane.userID,
ExportID: firstExport.ID,
Provider: "nextcloud",
})
secondCloud := postJSONAuth[cloudProfile](t, client, testClientToken, controlPlane.baseURL+"/api/v1/cloud-profiles/issue", cloudProfileRequest{
UserID: "runtime-user",
secondCloud := postJSONAuth[cloudProfile](t, client, controlPlane.sessionToken, controlPlane.baseURL+"/api/v1/cloud-profiles/issue", cloudProfileRequest{
UserID: controlPlane.userID,
ExportID: secondExport.ID,
Provider: "nextcloud",
})
@ -140,8 +137,12 @@ func TestControlPlaneBinaryMultiExportProfilesStayDistinct(t *testing.T) {
}
type runningBinary struct {
baseURL string
logPath string
baseURL string
logPath string
sessionToken string
username string
password string
userID string
}
func startControlPlaneBinary(t *testing.T, version string, nextcloudBaseURL string) runningBinary {
@ -149,7 +150,7 @@ func startControlPlaneBinary(t *testing.T, version string, nextcloudBaseURL stri
port := reserveTCPPort(t)
logPath := filepath.Join(t.TempDir(), "control-plane.log")
statePath := filepath.Join(t.TempDir(), "control-plane-state.json")
dbPath := filepath.Join(t.TempDir(), "control-plane.db")
logFile, err := os.Create(logPath)
if err != nil {
t.Fatalf("create control-plane log file: %v", err)
@ -162,11 +163,8 @@ func startControlPlaneBinary(t *testing.T, version string, nextcloudBaseURL stri
"PORT="+port,
"BETTERNAS_VERSION="+version,
"NEXTCLOUD_BASE_URL="+nextcloudBaseURL,
"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,
"BETTERNAS_CONTROL_PLANE_DB_PATH="+dbPath,
"BETTERNAS_REGISTRATION_ENABLED=true",
)
cmd.Stdout = logFile
cmd.Stderr = logFile
@ -183,11 +181,16 @@ func startControlPlaneBinary(t *testing.T, version string, nextcloudBaseURL stri
baseURL := fmt.Sprintf("http://127.0.0.1:%s", port)
waitForHTTPStatus(t, baseURL+"/health", waitDone, logPath, http.StatusOK)
session := registerRuntimeUser(t, &http.Client{Timeout: 2 * time.Second}, baseURL)
registerProcessCleanup(t, ctx, cancel, cmd, waitDone, logFile, logPath, "control-plane")
return runningBinary{
baseURL: baseURL,
logPath: logPath,
baseURL: baseURL,
logPath: logPath,
sessionToken: session.Token,
username: runtimeUsername,
password: testPassword,
userID: session.User.ID,
}
}
@ -197,7 +200,6 @@ func startNodeAgentBinaryWithExports(t *testing.T, controlPlaneBaseURL string, e
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)
@ -215,12 +217,11 @@ func startNodeAgentBinaryWithExports(t *testing.T, controlPlaneBaseURL string, e
"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_USERNAME="+runtimeUsername,
"BETTERNAS_PASSWORD="+testPassword,
"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
@ -245,12 +246,12 @@ func startNodeAgentBinaryWithExports(t *testing.T, controlPlaneBaseURL string, e
}
}
func waitForExportsByPath(t *testing.T, client *http.Client, endpoint string, expectedPaths []string) map[string]storageExport {
func waitForExportsByPath(t *testing.T, client *http.Client, token string, 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)
exports := getJSONAuth[[]storageExport](t, client, token, endpoint)
exportsByPath := exportsByPath(exports)
allPresent := true
for _, expectedPath := range expectedPaths {
@ -269,6 +270,15 @@ func waitForExportsByPath(t *testing.T, client *http.Client, endpoint string, ex
return nil
}
func registerRuntimeUser(t *testing.T, client *http.Client, baseURL string) authLoginResponse {
t.Helper()
return postJSONAuthCreated[authLoginResponse](t, client, "", baseURL+"/api/v1/auth/register", authRegisterRequest{
Username: runtimeUsername,
Password: testPassword,
})
}
func buildControlPlaneBinary(t *testing.T) string {
t.Helper()

View file

@ -2,7 +2,6 @@ package main
import (
"bytes"
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
@ -21,12 +20,12 @@ var (
errMountTargetUnavailable = errors.New("mount target is not available")
errNodeIDMismatch = errors.New("node id path and body must match")
errNodeNotFound = errors.New("node not found")
errNodeOwnedByAnotherUser = errors.New("node is already owned by another user")
)
const (
authorizationHeader = "Authorization"
controlPlaneNodeTokenKey = "X-BetterNAS-Node-Token"
bearerScheme = "Bearer"
authorizationHeader = "Authorization"
bearerScheme = "Bearer"
)
func (a *app) handler() http.Handler {
@ -76,6 +75,11 @@ func (a *app) handleVersion(w http.ResponseWriter, _ *http.Request) {
}
func (a *app) handleNodeRegister(w http.ResponseWriter, r *http.Request) {
currentUser, ok := a.requireSessionUser(w, r)
if !ok {
return
}
request, err := decodeNodeRegistrationRequest(w, r)
if err != nil {
writeDecodeError(w, err)
@ -87,23 +91,25 @@ func (a *app) handleNodeRegister(w http.ResponseWriter, r *http.Request) {
return
}
if !a.authorizeNodeRegistration(w, r, request.MachineID) {
return
}
result, err := a.store.registerNode(request, a.now())
result, err := a.store.registerNode(currentUser.ID, request, a.now())
if err != nil {
if errors.Is(err, errNodeOwnedByAnotherUser) {
http.Error(w, err.Error(), http.StatusConflict)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if result.IssuedNodeToken != "" {
w.Header().Set(controlPlaneNodeTokenKey, result.IssuedNodeToken)
}
writeJSON(w, http.StatusOK, result.Node)
}
func (a *app) handleNodeHeartbeat(w http.ResponseWriter, r *http.Request) {
currentUser, ok := a.requireSessionUser(w, r)
if !ok {
return
}
nodeID := r.PathValue("nodeId")
var request nodeHeartbeatRequest
@ -121,11 +127,7 @@ func (a *app) handleNodeHeartbeat(w http.ResponseWriter, r *http.Request) {
return
}
if !a.authorizeNode(w, r, nodeID) {
return
}
if err := a.store.recordHeartbeat(nodeID, request); err != nil {
if err := a.store.recordHeartbeat(nodeID, currentUser.ID, request); err != nil {
statusCode := http.StatusInternalServerError
if errors.Is(err, errNodeNotFound) {
statusCode = http.StatusNotFound
@ -138,6 +140,11 @@ func (a *app) handleNodeHeartbeat(w http.ResponseWriter, r *http.Request) {
}
func (a *app) handleNodeExports(w http.ResponseWriter, r *http.Request) {
currentUser, ok := a.requireSessionUser(w, r)
if !ok {
return
}
nodeID := r.PathValue("nodeId")
request, err := decodeNodeExportsRequest(w, r)
@ -151,11 +158,7 @@ func (a *app) handleNodeExports(w http.ResponseWriter, r *http.Request) {
return
}
if !a.authorizeNode(w, r, nodeID) {
return
}
exports, err := a.store.upsertExports(nodeID, request)
exports, err := a.store.upsertExports(nodeID, currentUser.ID, request)
if err != nil {
statusCode := http.StatusInternalServerError
if errors.Is(err, errNodeNotFound) {
@ -169,15 +172,17 @@ func (a *app) handleNodeExports(w http.ResponseWriter, r *http.Request) {
}
func (a *app) handleExportsList(w http.ResponseWriter, r *http.Request) {
if !a.requireClientAuth(w, r) {
currentUser, ok := a.requireSessionUser(w, r)
if !ok {
return
}
writeJSON(w, http.StatusOK, a.store.listExports())
writeJSON(w, http.StatusOK, a.store.listExports(currentUser.ID))
}
func (a *app) handleMountProfileIssue(w http.ResponseWriter, r *http.Request) {
if !a.requireClientAuth(w, r) {
currentUser, ok := a.requireSessionUser(w, r)
if !ok {
return
}
@ -192,8 +197,8 @@ func (a *app) handleMountProfileIssue(w http.ResponseWriter, r *http.Request) {
return
}
context, ok := a.store.exportContext(request.ExportID)
if !ok {
context, found := a.store.exportContext(request.ExportID, currentUser.ID)
if !found {
http.Error(w, errExportNotFound.Error(), http.StatusNotFound)
return
}
@ -204,32 +209,20 @@ 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: credentialID,
ID: context.export.ID,
ExportID: context.export.ID,
Protocol: "webdav",
DisplayName: context.export.Label,
MountURL: mountURL,
Readonly: false,
Credential: credential,
Credential: buildAccountMountCredential(currentUser.Username),
})
}
func (a *app) handleCloudProfileIssue(w http.ResponseWriter, r *http.Request) {
if !a.requireClientAuth(w, r) {
currentUser, ok := a.requireSessionUser(w, r)
if !ok {
return
}
@ -244,8 +237,8 @@ func (a *app) handleCloudProfileIssue(w http.ResponseWriter, r *http.Request) {
return
}
context, ok := a.store.exportContext(request.ExportID)
if !ok {
context, found := a.store.exportContext(request.ExportID, currentUser.ID)
if !found {
http.Error(w, errExportNotFound.Error(), http.StatusNotFound)
return
}
@ -257,7 +250,7 @@ func (a *app) handleCloudProfileIssue(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, cloudProfile{
ID: fmt.Sprintf("cloud-%s-%s", request.UserID, context.export.ID),
ID: fmt.Sprintf("cloud-%s-%s", currentUser.ID, context.export.ID),
ExportID: context.export.ID,
Provider: "nextcloud",
BaseURL: baseURL,
@ -1034,71 +1027,22 @@ func corsMiddleware(allowedOrigin string, next http.Handler) http.Handler {
})
}
// --- client auth ---
// --- session auth ---
func (a *app) requireClientAuth(w http.ResponseWriter, r *http.Request) bool {
func (a *app) requireSessionUser(w http.ResponseWriter, r *http.Request) (user, bool) {
presentedToken, ok := bearerToken(r)
if !ok {
writeUnauthorized(w)
return false
return user{}, false
}
// Session-based auth (SQLite).
if _, err := a.store.validateSession(presentedToken); err == nil {
return true
}
// Fall back to static client token for backwards compatibility.
if a.config.clientToken != "" && secureStringEquals(a.config.clientToken, presentedToken) {
return true
}
writeUnauthorized(w)
return false
}
func (a *app) authorizeNodeRegistration(w http.ResponseWriter, r *http.Request, machineID string) bool {
presentedToken, ok := bearerToken(r)
if !ok {
currentUser, err := a.store.validateSession(presentedToken)
if err != nil {
writeUnauthorized(w)
return false
return user{}, false
}
authState, exists := a.store.nodeAuthByMachineID(machineID)
if !exists || strings.TrimSpace(authState.TokenHash) == "" {
if !secureStringEquals(a.config.nodeBootstrapToken, presentedToken) {
writeUnauthorized(w)
return false
}
return true
}
if !tokenHashMatches(authState.TokenHash, presentedToken) {
writeUnauthorized(w)
return false
}
return true
}
func (a *app) authorizeNode(w http.ResponseWriter, r *http.Request, nodeID string) bool {
presentedToken, ok := bearerToken(r)
if !ok {
writeUnauthorized(w)
return false
}
authState, exists := a.store.nodeAuthByID(nodeID)
if !exists {
http.Error(w, errNodeNotFound.Error(), http.StatusNotFound)
return false
}
if strings.TrimSpace(authState.TokenHash) == "" || !tokenHashMatches(authState.TokenHash, presentedToken) {
writeUnauthorized(w)
return false
}
return true
return currentUser, true
}
func bearerToken(r *http.Request) (string, bool) {
@ -1124,11 +1068,3 @@ func writeUnauthorized(w http.ResponseWriter) {
w.Header().Set("WWW-Authenticate", bearerScheme)
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
}
func secureStringEquals(expected string, actual string) bool {
return subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) == 1
}
func tokenHashMatches(expectedHash string, token string) bool {
return secureStringEquals(expectedHash, hashOpaqueToken(token))
}

View file

@ -3,6 +3,7 @@ package main
import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
@ -15,8 +16,9 @@ import (
var testControlPlaneNow = time.Date(2025, time.January, 1, 12, 0, 0, 0, time.UTC)
const (
testPassword = "password123"
testClientToken = "test-client-token"
testNodeBootstrapToken = "test-node-bootstrap-token"
testNodeBootstrapToken = "test-node-session-token"
)
type registeredNode struct {
@ -94,7 +96,7 @@ func TestControlPlaneRegistrationProfilesAndHeartbeat(t *testing.T) {
RelayAddress: &relayAddress,
})
if registration.NodeToken == "" {
t.Fatal("expected node registration to return a node token")
t.Fatal("expected node registration to preserve the session token")
}
syncedExports := syncNodeExports(t, server.Client(), registration.NodeToken, server.URL+"/api/v1/nodes/"+registration.Node.ID+"/exports", nodeExportsRequest{
@ -169,14 +171,14 @@ func TestControlPlaneRegistrationProfilesAndHeartbeat(t *testing.T) {
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.Username != "fixture" {
t.Fatalf("expected mount credential username %q, got %q", "fixture", mount.Credential.Username)
}
if mount.Credential.Password == "" {
t.Fatal("expected mount credential password to be set")
if mount.Credential.Password != "" {
t.Fatalf("expected mount credential password to be blank, got %q", mount.Credential.Password)
}
if mount.Credential.ExpiresAt == "" {
t.Fatal("expected mount credential expiry to be set")
if mount.Credential.ExpiresAt != "" {
t.Fatalf("expected mount credential expiry to be blank, got %q", mount.Credential.ExpiresAt)
}
cloud := postJSONAuth[cloudProfile](t, server.Client(), testClientToken, server.URL+"/api/v1/cloud-profiles/issue", cloudProfileRequest{
@ -231,7 +233,7 @@ func TestControlPlaneExportSyncReconcilesExportsAndKeepsStableIDs(t *testing.T)
RelayAddress: nil,
})
putJSONAuthStatus(t, server.Client(), testNodeBootstrapToken, server.URL+"/api/v1/nodes/"+firstRegistration.Node.ID+"/exports", nodeExportsRequest{
putJSONAuthStatus(t, server.Client(), "wrong-session-token", server.URL+"/api/v1/nodes/"+firstRegistration.Node.ID+"/exports", nodeExportsRequest{
Exports: []storageExportInput{
{
Label: "Docs",
@ -285,7 +287,7 @@ func TestControlPlaneExportSyncReconcilesExportsAndKeepsStableIDs(t *testing.T)
RelayAddress: nil,
})
putJSONAuthStatus(t, server.Client(), testClientToken, server.URL+"/api/v1/nodes/"+firstRegistration.Node.ID+"/exports", nodeExportsRequest{
putJSONAuthStatus(t, server.Client(), "wrong-session-token", server.URL+"/api/v1/nodes/"+firstRegistration.Node.ID+"/exports", nodeExportsRequest{
Exports: []storageExportInput{
{
Label: "Docs v2",
@ -330,8 +332,8 @@ func TestControlPlaneExportSyncReconcilesExportsAndKeepsStableIDs(t *testing.T)
if secondRegistration.Node.ID != firstRegistration.Node.ID {
t.Fatalf("expected re-registration to keep node ID %q, got %q", firstRegistration.Node.ID, secondRegistration.Node.ID)
}
if secondRegistration.NodeToken != "" {
t.Fatalf("expected re-registration to keep the existing node token, got %q", secondRegistration.NodeToken)
if secondRegistration.NodeToken != firstRegistration.NodeToken {
t.Fatalf("expected re-registration to keep the existing session token %q, got %q", firstRegistration.NodeToken, secondRegistration.NodeToken)
}
updatedExports := exportsByPath(getJSONAuth[[]storageExport](t, server.Client(), testClientToken, server.URL+"/api/v1/exports"))
@ -539,12 +541,12 @@ func TestControlPlaneCloudProfilesRequireConfiguredBaseURLAndExistingExport(t *t
func TestControlPlanePersistsRegistryAcrossAppRestart(t *testing.T) {
t.Parallel()
statePath := filepath.Join(t.TempDir(), "control-plane-state.json")
dbPath := filepath.Join(t.TempDir(), "control-plane.db")
directAddress := "http://nas.local:8090"
_, firstServer := newTestControlPlaneServer(t, appConfig{
version: "test-version",
statePath: statePath,
version: "test-version",
dbPath: dbPath,
})
registration := registerNode(t, firstServer.Client(), firstServer.URL+"/api/v1/nodes/register", testNodeBootstrapToken, nodeRegistrationRequest{
MachineID: "machine-persisted",
@ -566,8 +568,8 @@ func TestControlPlanePersistsRegistryAcrossAppRestart(t *testing.T) {
firstServer.Close()
_, secondServer := newTestControlPlaneServer(t, appConfig{
version: "test-version",
statePath: statePath,
version: "test-version",
dbPath: dbPath,
})
defer secondServer.Close()
@ -656,15 +658,12 @@ func TestControlPlaneRejectsInvalidRequestsAndEnforcesAuth(t *testing.T) {
if err := json.NewDecoder(response.Body).Decode(&node); err != nil {
t.Fatalf("decode registration response: %v", err)
}
nodeToken := strings.TrimSpace(response.Header.Get(controlPlaneNodeTokenKey))
if nodeToken == "" {
t.Fatal("expected node registration to return a node token")
}
nodeToken := testNodeBootstrapToken
if node.ID != "dev-node" {
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{
putJSONAuthStatus(t, server.Client(), "wrong-session-token", server.URL+"/api/v1/nodes/"+node.ID+"/exports", nodeExportsRequest{
Exports: []storageExportInput{{
Label: "Docs",
Path: "/srv/docs",
@ -716,7 +715,7 @@ func TestControlPlaneRejectsInvalidRequestsAndEnforcesAuth(t *testing.T) {
},
}, http.StatusBadRequest)
postJSONAuthStatus(t, server.Client(), testClientToken, server.URL+"/api/v1/nodes/"+node.ID+"/heartbeat", nodeHeartbeatRequest{
postJSONAuthStatus(t, server.Client(), "wrong-session-token", server.URL+"/api/v1/nodes/"+node.ID+"/heartbeat", nodeHeartbeatRequest{
NodeID: node.ID,
Status: "online",
LastSeenAt: "2025-01-02T03:04:05Z",
@ -765,21 +764,12 @@ func TestControlPlaneRejectsInvalidRequestsAndEnforcesAuth(t *testing.T) {
func newTestControlPlaneServer(t *testing.T, config appConfig) (*app, *httptest.Server) {
t.Helper()
if config.dbPath == "" {
config.dbPath = filepath.Join(t.TempDir(), "test.db")
}
if config.version == "" {
config.version = "test-version"
}
if config.clientToken == "" {
config.clientToken = testClientToken
}
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 {
@ -788,11 +778,46 @@ func newTestControlPlaneServer(t *testing.T, config appConfig) (*app, *httptest.
app.now = func() time.Time {
return testControlPlaneNow
}
seedDefaultSessionUser(t, app)
server := httptest.NewServer(app.handler())
return app, server
}
func seedDefaultSessionUser(t *testing.T, app *app) {
t.Helper()
u, err := app.store.createUser("fixture", testPassword)
if err != nil && !errors.Is(err, errUsernameTaken) {
t.Fatalf("seed default test user: %v", err)
}
if errors.Is(err, errUsernameTaken) {
u, err = app.store.authenticateUser("fixture", testPassword)
if err != nil {
t.Fatalf("authenticate seeded test user: %v", err)
}
}
sqliteStore, ok := app.store.(*sqliteStore)
if !ok {
return
}
createdAt := time.Now().UTC().Format(time.RFC3339)
expiresAt := time.Now().UTC().Add(24 * time.Hour).Format(time.RFC3339)
for _, token := range []string{testClientToken, testNodeBootstrapToken} {
if _, err := sqliteStore.db.Exec(
"INSERT OR REPLACE INTO sessions (token, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)",
token,
u.ID,
createdAt,
expiresAt,
); err != nil {
t.Fatalf("seed session %s: %v", token, err)
}
}
}
func exportsByPath(exports []storageExport) map[string]storageExport {
byPath := make(map[string]storageExport, len(exports))
for _, export := range exports {
@ -820,10 +845,19 @@ func registerNode(t *testing.T, client *http.Client, endpoint string, token stri
return registeredNode{
Node: node,
NodeToken: strings.TrimSpace(response.Header.Get(controlPlaneNodeTokenKey)),
NodeToken: strings.TrimSpace(token),
}
}
func registerSessionUser(t *testing.T, client *http.Client, baseURL string, username string) authLoginResponse {
t.Helper()
return postJSONAuthCreated[authLoginResponse](t, client, "", baseURL+"/api/v1/auth/register", authRegisterRequest{
Username: username,
Password: testPassword,
})
}
func syncNodeExports(t *testing.T, client *http.Client, token string, endpoint string, payload nodeExportsRequest) []storageExport {
t.Helper()

View file

@ -17,8 +17,8 @@ import (
)
var (
errUsernameTaken = errors.New("username already taken")
errInvalidLogin = errors.New("invalid username or password")
errUsernameTaken = errors.New("username already taken")
errInvalidLogin = errors.New("invalid username or password")
errSessionExpired = errors.New("session expired or invalid")
)
@ -32,6 +32,7 @@ INSERT OR IGNORE INTO ordinals (name, value) VALUES ('node', 0), ('export', 0);
CREATE TABLE IF NOT EXISTS nodes (
id TEXT PRIMARY KEY,
machine_id TEXT NOT NULL UNIQUE,
owner_id TEXT REFERENCES users(id),
display_name TEXT NOT NULL DEFAULT '',
agent_version TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'online',
@ -48,6 +49,7 @@ CREATE TABLE IF NOT EXISTS node_tokens (
CREATE TABLE IF NOT EXISTS exports (
id TEXT PRIMARY KEY,
node_id TEXT NOT NULL REFERENCES nodes(id),
owner_id TEXT REFERENCES users(id),
label TEXT NOT NULL DEFAULT '',
path TEXT NOT NULL,
mount_path TEXT NOT NULL DEFAULT '',
@ -101,10 +103,40 @@ func newSQLiteStore(dbPath string) (*sqliteStore, error) {
db.Close()
return nil, fmt.Errorf("initialize database schema: %w", err)
}
if err := migrateSQLiteSchema(db); err != nil {
db.Close()
return nil, err
}
return &sqliteStore{db: db}, nil
}
func migrateSQLiteSchema(db *sql.DB) error {
migrations := []string{
"ALTER TABLE nodes ADD COLUMN owner_id TEXT REFERENCES users(id)",
"ALTER TABLE exports ADD COLUMN owner_id TEXT REFERENCES users(id)",
}
for _, statement := range migrations {
if _, err := db.Exec(statement); err != nil && !strings.Contains(err.Error(), "duplicate column name") {
return fmt.Errorf("run sqlite migration %q: %w", statement, err)
}
}
if _, err := db.Exec(`
UPDATE exports
SET owner_id = (
SELECT owner_id
FROM nodes
WHERE nodes.id = exports.node_id
)
WHERE owner_id IS NULL
`); err != nil {
return fmt.Errorf("backfill export owners: %w", err)
}
return nil
}
func (s *sqliteStore) nextOrdinal(tx *sql.Tx, name string) (int, error) {
var value int
err := tx.QueryRow("UPDATE ordinals SET value = value + 1 WHERE name = ? RETURNING value", name).Scan(&value)
@ -128,7 +160,7 @@ func ordinalToExportID(ordinal int) string {
return fmt.Sprintf("dev-export-%d", ordinal)
}
func (s *sqliteStore) registerNode(request nodeRegistrationRequest, registeredAt time.Time) (nodeRegistrationResult, error) {
func (s *sqliteStore) registerNode(ownerID string, request nodeRegistrationRequest, registeredAt time.Time) (nodeRegistrationResult, error) {
tx, err := s.db.Begin()
if err != nil {
return nodeRegistrationResult{}, fmt.Errorf("begin transaction: %w", err)
@ -137,7 +169,8 @@ func (s *sqliteStore) registerNode(request nodeRegistrationRequest, registeredAt
// Check if machine already registered.
var nodeID string
err = tx.QueryRow("SELECT id FROM nodes WHERE machine_id = ?", request.MachineID).Scan(&nodeID)
var existingOwnerID sql.NullString
err = tx.QueryRow("SELECT id, owner_id FROM nodes WHERE machine_id = ?", request.MachineID).Scan(&nodeID, &existingOwnerID)
if err == sql.ErrNoRows {
ordinal, err := s.nextOrdinal(tx, "node")
if err != nil {
@ -146,57 +179,40 @@ func (s *sqliteStore) registerNode(request nodeRegistrationRequest, registeredAt
nodeID = ordinalToNodeID(ordinal)
} else if err != nil {
return nodeRegistrationResult{}, fmt.Errorf("lookup node by machine_id: %w", err)
} else if existingOwnerID.Valid && strings.TrimSpace(existingOwnerID.String) != "" && existingOwnerID.String != ownerID {
return nodeRegistrationResult{}, errNodeOwnedByAnotherUser
}
// Upsert node.
_, err = tx.Exec(`
INSERT INTO nodes (id, machine_id, display_name, agent_version, status, last_seen_at, direct_address, relay_address)
VALUES (?, ?, ?, ?, 'online', ?, ?, ?)
INSERT INTO nodes (id, machine_id, owner_id, display_name, agent_version, status, last_seen_at, direct_address, relay_address)
VALUES (?, ?, ?, ?, ?, 'online', ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
owner_id = excluded.owner_id,
display_name = excluded.display_name,
agent_version = excluded.agent_version,
status = 'online',
last_seen_at = excluded.last_seen_at,
direct_address = excluded.direct_address,
relay_address = excluded.relay_address
`, nodeID, request.MachineID, request.DisplayName, request.AgentVersion,
`, nodeID, request.MachineID, ownerID, request.DisplayName, request.AgentVersion,
registeredAt.UTC().Format(time.RFC3339),
nullableString(request.DirectAddress), nullableString(request.RelayAddress))
if err != nil {
return nodeRegistrationResult{}, fmt.Errorf("upsert node: %w", err)
}
// Issue token if none exists.
var issuedNodeToken string
var existingHash sql.NullString
_ = tx.QueryRow("SELECT token_hash FROM node_tokens WHERE node_id = ?", nodeID).Scan(&existingHash)
if !existingHash.Valid || strings.TrimSpace(existingHash.String) == "" {
nodeToken, err := newOpaqueToken()
if err != nil {
return nodeRegistrationResult{}, err
}
_, err = tx.Exec(
"INSERT OR REPLACE INTO node_tokens (node_id, token_hash) VALUES (?, ?)",
nodeID, hashOpaqueToken(nodeToken))
if err != nil {
return nodeRegistrationResult{}, fmt.Errorf("store node token: %w", err)
}
issuedNodeToken = nodeToken
}
if err := tx.Commit(); err != nil {
return nodeRegistrationResult{}, fmt.Errorf("commit registration: %w", err)
}
node, _ := s.nodeByID(nodeID)
return nodeRegistrationResult{
Node: node,
IssuedNodeToken: issuedNodeToken,
Node: node,
}, nil
}
func (s *sqliteStore) upsertExports(nodeID string, request nodeExportsRequest) ([]storageExport, error) {
func (s *sqliteStore) upsertExports(nodeID string, ownerID string, request nodeExportsRequest) ([]storageExport, error) {
tx, err := s.db.Begin()
if err != nil {
return nil, fmt.Errorf("begin transaction: %w", err)
@ -205,7 +221,7 @@ func (s *sqliteStore) upsertExports(nodeID string, request nodeExportsRequest) (
// Verify node exists.
var exists bool
err = tx.QueryRow("SELECT 1 FROM nodes WHERE id = ?", nodeID).Scan(&exists)
err = tx.QueryRow("SELECT 1 FROM nodes WHERE id = ? AND owner_id = ?", nodeID, ownerID).Scan(&exists)
if err != nil {
return nil, errNodeNotFound
}
@ -238,13 +254,14 @@ func (s *sqliteStore) upsertExports(nodeID string, request nodeExportsRequest) (
}
_, err = tx.Exec(`
INSERT INTO exports (id, node_id, label, path, mount_path, capacity_bytes)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO exports (id, node_id, owner_id, label, path, mount_path, capacity_bytes)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
owner_id = excluded.owner_id,
label = excluded.label,
mount_path = excluded.mount_path,
capacity_bytes = excluded.capacity_bytes
`, exportID, nodeID, input.Label, input.Path, input.MountPath, nullableInt64(input.CapacityBytes))
`, exportID, nodeID, ownerID, input.Label, input.Path, input.MountPath, nullableInt64(input.CapacityBytes))
if err != nil {
return nil, fmt.Errorf("upsert export %q: %w", input.Path, err)
}
@ -288,10 +305,10 @@ func (s *sqliteStore) upsertExports(nodeID string, request nodeExportsRequest) (
return s.listExportsForNode(nodeID), nil
}
func (s *sqliteStore) recordHeartbeat(nodeID string, request nodeHeartbeatRequest) error {
func (s *sqliteStore) recordHeartbeat(nodeID string, ownerID string, request nodeHeartbeatRequest) error {
result, err := s.db.Exec(
"UPDATE nodes SET status = ?, last_seen_at = ? WHERE id = ?",
request.Status, request.LastSeenAt, nodeID)
"UPDATE nodes SET status = ?, last_seen_at = ? WHERE id = ? AND owner_id = ?",
request.Status, request.LastSeenAt, nodeID, ownerID)
if err != nil {
return fmt.Errorf("update heartbeat: %w", err)
}
@ -302,8 +319,8 @@ func (s *sqliteStore) recordHeartbeat(nodeID string, request nodeHeartbeatReques
return nil
}
func (s *sqliteStore) listExports() []storageExport {
rows, err := s.db.Query("SELECT id, node_id, label, path, mount_path, capacity_bytes FROM exports ORDER BY id")
func (s *sqliteStore) listExports(ownerID string) []storageExport {
rows, err := s.db.Query("SELECT id, node_id, owner_id, label, path, mount_path, capacity_bytes FROM exports WHERE owner_id = ? ORDER BY id", ownerID)
if err != nil {
return nil
}
@ -330,7 +347,7 @@ func (s *sqliteStore) listExports() []storageExport {
}
func (s *sqliteStore) listExportsForNode(nodeID string) []storageExport {
rows, err := s.db.Query("SELECT id, node_id, label, path, mount_path, capacity_bytes FROM exports WHERE node_id = ? ORDER BY id", nodeID)
rows, err := s.db.Query("SELECT id, node_id, owner_id, label, path, mount_path, capacity_bytes FROM exports WHERE node_id = ? ORDER BY id", nodeID)
if err != nil {
return nil
}
@ -356,15 +373,19 @@ func (s *sqliteStore) listExportsForNode(nodeID string) []storageExport {
return exports
}
func (s *sqliteStore) exportContext(exportID string) (exportContext, bool) {
func (s *sqliteStore) exportContext(exportID string, ownerID string) (exportContext, bool) {
var e storageExport
var capacityBytes sql.NullInt64
var exportOwnerID sql.NullString
err := s.db.QueryRow(
"SELECT id, node_id, label, path, mount_path, capacity_bytes FROM exports WHERE id = ?",
exportID).Scan(&e.ID, &e.NasNodeID, &e.Label, &e.Path, &e.MountPath, &capacityBytes)
"SELECT id, node_id, owner_id, label, path, mount_path, capacity_bytes FROM exports WHERE id = ? AND owner_id = ?",
exportID, ownerID).Scan(&e.ID, &e.NasNodeID, &exportOwnerID, &e.Label, &e.Path, &e.MountPath, &capacityBytes)
if err != nil {
return exportContext{}, false
}
if exportOwnerID.Valid {
e.OwnerID = exportOwnerID.String
}
if capacityBytes.Valid {
e.CapacityBytes = &capacityBytes.Int64
}
@ -383,12 +404,16 @@ func (s *sqliteStore) nodeByID(nodeID string) (nasNode, bool) {
var n nasNode
var directAddr, relayAddr sql.NullString
var lastSeenAt sql.NullString
var ownerID sql.NullString
err := s.db.QueryRow(
"SELECT id, machine_id, display_name, agent_version, status, last_seen_at, direct_address, relay_address FROM nodes WHERE id = ?",
nodeID).Scan(&n.ID, &n.MachineID, &n.DisplayName, &n.AgentVersion, &n.Status, &lastSeenAt, &directAddr, &relayAddr)
"SELECT id, machine_id, owner_id, display_name, agent_version, status, last_seen_at, direct_address, relay_address FROM nodes WHERE id = ?",
nodeID).Scan(&n.ID, &n.MachineID, &ownerID, &n.DisplayName, &n.AgentVersion, &n.Status, &lastSeenAt, &directAddr, &relayAddr)
if err != nil {
return nasNode{}, false
}
if ownerID.Valid {
n.OwnerID = ownerID.String
}
if lastSeenAt.Valid {
n.LastSeenAt = lastSeenAt.String
}
@ -442,9 +467,13 @@ func (s *sqliteStore) nodeAuthByID(nodeID string) (nodeAuthState, bool) {
func (s *sqliteStore) scanExport(rows *sql.Rows) storageExport {
var e storageExport
var capacityBytes sql.NullInt64
if err := rows.Scan(&e.ID, &e.NasNodeID, &e.Label, &e.Path, &e.MountPath, &capacityBytes); err != nil {
var ownerID sql.NullString
if err := rows.Scan(&e.ID, &e.NasNodeID, &ownerID, &e.Label, &e.Path, &e.MountPath, &capacityBytes); err != nil {
return storageExport{}
}
if ownerID.Valid {
e.OwnerID = ownerID.String
}
if capacityBytes.Valid {
e.CapacityBytes = &capacityBytes.Int64
}

View file

@ -18,24 +18,13 @@ func newTestSQLiteApp(t *testing.T, config appConfig) (*app, *httptest.Server) {
if config.version == "" {
config.version = "test-version"
}
if config.clientToken == "" {
config.clientToken = testClientToken
}
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 {
t.Fatalf("new app: %v", err)
}
app.now = func() time.Time { return testControlPlaneNow }
seedDefaultSessionUser(t, app)
server := httptest.NewServer(app.handler())
return app, server
@ -79,7 +68,7 @@ func TestSQLiteRegistrationAndExports(t *testing.T) {
RelayAddress: nil,
})
if registration.NodeToken == "" {
t.Fatal("expected node registration to return a node token")
t.Fatal("expected node registration to preserve the session token")
}
if registration.Node.ID != "dev-node" {
t.Fatalf("expected node ID %q, got %q", "dev-node", registration.Node.ID)
@ -142,8 +131,8 @@ func TestSQLiteReRegistrationKeepsNodeID(t *testing.T) {
if second.Node.ID != first.Node.ID {
t.Fatalf("expected re-registration to keep node ID %q, got %q", first.Node.ID, second.Node.ID)
}
if second.NodeToken != "" {
t.Fatalf("expected re-registration to not issue new token, got %q", second.NodeToken)
if second.NodeToken != first.NodeToken {
t.Fatalf("expected re-registration to keep the existing session token %q, got %q", first.NodeToken, second.NodeToken)
}
if second.Node.DisplayName != "NAS Updated" {
t.Fatalf("expected updated display name, got %q", second.Node.DisplayName)

View file

@ -31,8 +31,7 @@ type memoryStore struct {
}
type nodeRegistrationResult struct {
Node nasNode
IssuedNodeToken string
Node nasNode
}
type nodeAuthState struct {
@ -153,12 +152,12 @@ func cloneStoreState(state storeState) storeState {
return cloned
}
func (s *memoryStore) registerNode(request nodeRegistrationRequest, registeredAt time.Time) (nodeRegistrationResult, error) {
func (s *memoryStore) registerNode(ownerID string, request nodeRegistrationRequest, registeredAt time.Time) (nodeRegistrationResult, error) {
s.mu.Lock()
defer s.mu.Unlock()
nextState := cloneStoreState(s.state)
result, err := registerNodeInState(&nextState, request, registeredAt)
result, err := registerNodeInState(&nextState, ownerID, request, registeredAt)
if err != nil {
return nodeRegistrationResult{}, err
}
@ -170,21 +169,14 @@ func (s *memoryStore) registerNode(request nodeRegistrationRequest, registeredAt
return result, nil
}
func registerNodeInState(state *storeState, request nodeRegistrationRequest, registeredAt time.Time) (nodeRegistrationResult, error) {
func registerNodeInState(state *storeState, ownerID string, request nodeRegistrationRequest, registeredAt time.Time) (nodeRegistrationResult, error) {
nodeID, ok := state.NodeIDByMachineID[request.MachineID]
if !ok {
nodeID = nextNodeID(state)
state.NodeIDByMachineID[request.MachineID] = nodeID
}
issuedNodeToken := ""
if stringsTrimmedEmpty(state.NodeTokenHashByID[nodeID]) {
nodeToken, err := newOpaqueToken()
if err != nil {
return nodeRegistrationResult{}, err
}
state.NodeTokenHashByID[nodeID] = hashOpaqueToken(nodeToken)
issuedNodeToken = nodeToken
if existingNode, exists := state.NodesByID[nodeID]; exists && existingNode.OwnerID != "" && existingNode.OwnerID != ownerID {
return nodeRegistrationResult{}, errNodeOwnedByAnotherUser
}
node := nasNode{
@ -196,21 +188,21 @@ func registerNodeInState(state *storeState, request nodeRegistrationRequest, reg
LastSeenAt: registeredAt.UTC().Format(time.RFC3339),
DirectAddress: copyStringPointer(request.DirectAddress),
RelayAddress: copyStringPointer(request.RelayAddress),
OwnerID: ownerID,
}
state.NodesByID[nodeID] = node
return nodeRegistrationResult{
Node: node,
IssuedNodeToken: issuedNodeToken,
Node: node,
}, nil
}
func (s *memoryStore) upsertExports(nodeID string, request nodeExportsRequest) ([]storageExport, error) {
func (s *memoryStore) upsertExports(nodeID string, ownerID string, request nodeExportsRequest) ([]storageExport, error) {
s.mu.Lock()
defer s.mu.Unlock()
nextState := cloneStoreState(s.state)
exports, err := upsertExportsInState(&nextState, nodeID, request.Exports)
exports, err := upsertExportsInState(&nextState, nodeID, ownerID, request.Exports)
if err != nil {
return nil, err
}
@ -222,8 +214,9 @@ func (s *memoryStore) upsertExports(nodeID string, request nodeExportsRequest) (
return exports, nil
}
func upsertExportsInState(state *storeState, nodeID string, exports []storageExportInput) ([]storageExport, error) {
if _, ok := state.NodesByID[nodeID]; !ok {
func upsertExportsInState(state *storeState, nodeID string, ownerID string, exports []storageExportInput) ([]storageExport, error) {
node, ok := state.NodesByID[nodeID]
if !ok || node.OwnerID != ownerID {
return nil, errNodeNotFound
}
@ -250,6 +243,7 @@ func upsertExportsInState(state *storeState, nodeID string, exports []storageExp
Protocols: copyStringSlice(export.Protocols),
CapacityBytes: copyInt64Pointer(export.CapacityBytes),
Tags: copyStringSlice(export.Tags),
OwnerID: ownerID,
}
keepPaths[export.Path] = struct{}{}
}
@ -278,12 +272,12 @@ func upsertExportsInState(state *storeState, nodeID string, exports []storageExp
return nodeExports, nil
}
func (s *memoryStore) recordHeartbeat(nodeID string, request nodeHeartbeatRequest) error {
func (s *memoryStore) recordHeartbeat(nodeID string, ownerID string, request nodeHeartbeatRequest) error {
s.mu.Lock()
defer s.mu.Unlock()
nextState := cloneStoreState(s.state)
if err := recordHeartbeatInState(&nextState, nodeID, request); err != nil {
if err := recordHeartbeatInState(&nextState, nodeID, ownerID, request); err != nil {
return err
}
if err := s.persistLocked(nextState); err != nil {
@ -294,9 +288,9 @@ func (s *memoryStore) recordHeartbeat(nodeID string, request nodeHeartbeatReques
return nil
}
func recordHeartbeatInState(state *storeState, nodeID string, request nodeHeartbeatRequest) error {
func recordHeartbeatInState(state *storeState, nodeID string, ownerID string, request nodeHeartbeatRequest) error {
node, ok := state.NodesByID[nodeID]
if !ok {
if !ok || node.OwnerID != ownerID {
return errNodeNotFound
}
@ -307,12 +301,15 @@ func recordHeartbeatInState(state *storeState, nodeID string, request nodeHeartb
return nil
}
func (s *memoryStore) listExports() []storageExport {
func (s *memoryStore) listExports(ownerID string) []storageExport {
s.mu.RLock()
defer s.mu.RUnlock()
exports := make([]storageExport, 0, len(s.state.ExportsByID))
for _, export := range s.state.ExportsByID {
if export.OwnerID != ownerID {
continue
}
exports = append(exports, copyStorageExport(export))
}
@ -323,17 +320,17 @@ func (s *memoryStore) listExports() []storageExport {
return exports
}
func (s *memoryStore) exportContext(exportID string) (exportContext, bool) {
func (s *memoryStore) exportContext(exportID string, ownerID string) (exportContext, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
export, ok := s.state.ExportsByID[exportID]
if !ok {
if !ok || export.OwnerID != ownerID {
return exportContext{}, false
}
node, ok := s.state.NodesByID[export.NasNodeID]
if !ok {
if !ok || node.OwnerID != ownerID {
return exportContext{}, false
}
@ -468,6 +465,7 @@ func copyNasNode(node nasNode) nasNode {
LastSeenAt: node.LastSeenAt,
DirectAddress: copyStringPointer(node.DirectAddress),
RelayAddress: copyStringPointer(node.RelayAddress),
OwnerID: node.OwnerID,
}
}
@ -481,6 +479,7 @@ func copyStorageExport(export storageExport) storageExport {
Protocols: copyStringSlice(export.Protocols),
CapacityBytes: copyInt64Pointer(export.CapacityBytes),
Tags: copyStringSlice(export.Tags),
OwnerID: export.OwnerID,
}
}

View file

@ -5,14 +5,12 @@ import "time"
// store defines the persistence interface for the control-plane.
type store interface {
// Node management
registerNode(request nodeRegistrationRequest, registeredAt time.Time) (nodeRegistrationResult, error)
upsertExports(nodeID string, request nodeExportsRequest) ([]storageExport, error)
recordHeartbeat(nodeID string, request nodeHeartbeatRequest) error
listExports() []storageExport
exportContext(exportID string) (exportContext, bool)
registerNode(ownerID string, request nodeRegistrationRequest, registeredAt time.Time) (nodeRegistrationResult, error)
upsertExports(nodeID string, ownerID string, request nodeExportsRequest) ([]storageExport, error)
recordHeartbeat(nodeID string, ownerID string, request nodeHeartbeatRequest) error
listExports(ownerID string) []storageExport
exportContext(exportID string, ownerID string) (exportContext, bool)
nodeByID(nodeID string) (nasNode, bool)
nodeAuthByMachineID(machineID string) (nodeAuthState, bool)
nodeAuthByID(nodeID string) (nodeAuthState, bool)
// User auth
createUser(username string, password string) (user, error)

View file

@ -9,6 +9,9 @@ For the scaffold it does two things:
- 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
- uses `BETTERNAS_USERNAME` and `BETTERNAS_PASSWORD` both for control-plane login
and for local WebDAV basic auth
This is the first real storage-facing surface in the monorepo.
The user-facing binary should be distributed as `betternas-node`.

View file

@ -1,6 +1,7 @@
package main
import (
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
@ -17,15 +18,15 @@ const (
)
type appConfig struct {
exportPaths []string
nodeID string
davAuthSecret string
exportPaths []string
authUsername string
authPassword string
}
type app struct {
nodeID string
davAuthSecret string
exportMounts []exportMount
authUsername string
authPassword string
exportMounts []exportMount
}
type exportMount struct {
@ -34,14 +35,12 @@ 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.authUsername = strings.TrimSpace(config.authUsername)
if config.authUsername == "" {
return nil, errors.New("authUsername is required")
}
config.davAuthSecret = strings.TrimSpace(config.davAuthSecret)
if config.davAuthSecret == "" {
return nil, errors.New("davAuthSecret is required")
if config.authPassword == "" {
return nil, errors.New("authPassword is required")
}
exportMounts, err := buildExportMounts(config.exportPaths)
@ -50,9 +49,9 @@ func newApp(config appConfig) (*app, error) {
}
return &app{
nodeID: config.nodeID,
davAuthSecret: config.davAuthSecret,
exportMounts: exportMounts,
authUsername: config.authUsername,
authPassword: config.authPassword,
exportMounts: exportMounts,
}, nil
}
@ -62,24 +61,24 @@ func newAppFromEnv() (*app, error) {
return nil, err
}
davAuthSecret, err := requiredEnv("BETTERNAS_DAV_AUTH_SECRET")
authUsername, err := requiredEnv("BETTERNAS_USERNAME")
if err != nil {
return nil, err
}
authPassword, err := requiredEnv("BETTERNAS_PASSWORD")
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 {
if _, err := bootstrapNodeAgentFromEnv(exportPaths); err != nil {
return nil, err
}
nodeID = bootstrapResult.nodeID
}
return newApp(appConfig{
exportPaths: exportPaths,
nodeID: nodeID,
davAuthSecret: davAuthSecret,
exportPaths: exportPaths,
authUsername: authUsername,
authPassword: authPassword,
})
}
@ -182,25 +181,20 @@ func (a *app) requireDAVAuth(mount exportMount, next http.Handler) http.Handler
writeDAVUnauthorized(w)
return
}
claims, err := verifyMountCredential(a.davAuthSecret, password)
if err != nil {
if !a.matchesAccountCredential(username, password) {
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 (a *app) matchesAccountCredential(username string, password string) bool {
return subtle.ConstantTimeCompare([]byte(strings.TrimSpace(username)), []byte(a.authUsername)) == 1 &&
subtle.ConstantTimeCompare([]byte(password), []byte(a.authPassword)) == 1
}
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.

View file

@ -1,8 +1,6 @@
package main
import (
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
@ -10,10 +8,12 @@ import (
"path/filepath"
"strings"
"testing"
"time"
)
const testDAVAuthSecret = "test-dav-auth-secret"
const (
testUsername = "alice"
testPassword = "password123"
)
func TestSingleExportServesDefaultAndScopedMountPathsWithValidCredentials(t *testing.T) {
t.Parallel()
@ -22,9 +22,9 @@ func TestSingleExportServesDefaultAndScopedMountPathsWithValidCredentials(t *tes
writeExportFile(t, exportDir, "README.txt", "single export\n")
app, err := newApp(appConfig{
exportPaths: []string{exportDir},
nodeID: "node-1",
davAuthSecret: testDAVAuthSecret,
exportPaths: []string{exportDir},
authUsername: testUsername,
authPassword: testPassword,
})
if err != nil {
t.Fatalf("new app: %v", err)
@ -33,14 +33,12 @@ func TestSingleExportServesDefaultAndScopedMountPathsWithValidCredentials(t *tes
server := httptest.NewServer(app.handler())
defer server.Close()
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")
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+defaultWebDAVPath, testUsername, testPassword, http.StatusMultiStatus)
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+scopedMountPath, testUsername, testPassword, http.StatusMultiStatus)
assertMountedFileContentsWithBasicAuth(t, server.Client(), server.URL+defaultWebDAVPath+"README.txt", testUsername, testPassword, "single export\n")
assertMountedFileContentsWithBasicAuth(t, server.Client(), server.URL+scopedMountPath+"README.txt", testUsername, testPassword, "single export\n")
}
func TestMultipleExportsServeDistinctScopedMountPathsWithValidCredentials(t *testing.T) {
@ -52,9 +50,9 @@ func TestMultipleExportsServeDistinctScopedMountPathsWithValidCredentials(t *tes
writeExportFile(t, secondExportDir, "README.txt", "second export\n")
app, err := newApp(appConfig{
exportPaths: []string{firstExportDir, secondExportDir},
nodeID: "node-1",
davAuthSecret: testDAVAuthSecret,
exportPaths: []string{firstExportDir, secondExportDir},
authUsername: testUsername,
authPassword: testPassword,
})
if err != nil {
t.Fatalf("new app: %v", err)
@ -69,13 +67,10 @@ func TestMultipleExportsServeDistinctScopedMountPathsWithValidCredentials(t *tes
t.Fatal("expected distinct mount paths for multiple exports")
}
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")
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+firstMountPath, testUsername, testPassword, http.StatusMultiStatus)
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+secondMountPath, testUsername, testPassword, http.StatusMultiStatus)
assertMountedFileContentsWithBasicAuth(t, server.Client(), server.URL+firstMountPath+"README.txt", testUsername, testPassword, "first export\n")
assertMountedFileContentsWithBasicAuth(t, server.Client(), server.URL+secondMountPath+"README.txt", testUsername, testPassword, "second export\n")
response, err := server.Client().Get(server.URL + defaultWebDAVPath)
if err != nil {
@ -87,16 +82,16 @@ func TestMultipleExportsServeDistinctScopedMountPathsWithValidCredentials(t *tes
}
}
func TestDAVAuthRejectsMissingInvalidAndReadonlyCredentials(t *testing.T) {
func TestDAVAuthRejectsMissingAndInvalidCredentials(t *testing.T) {
t.Parallel()
exportDir := t.TempDir()
writeExportFile(t, exportDir, "README.txt", "readonly export\n")
writeExportFile(t, exportDir, "README.txt", "mutable export\n")
app, err := newApp(appConfig{
exportPaths: []string{exportDir},
nodeID: "node-1",
davAuthSecret: testDAVAuthSecret,
exportPaths: []string{exportDir},
authUsername: testUsername,
authPassword: testPassword,
})
if err != nil {
t.Fatalf("new app: %v", err)
@ -106,27 +101,24 @@ func TestDAVAuthRejectsMissingInvalidAndReadonlyCredentials(t *testing.T) {
defer server.Close()
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+defaultWebDAVPath, "", "", http.StatusUnauthorized)
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+defaultWebDAVPath, "wrong-user", testPassword, http.StatusUnauthorized)
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+defaultWebDAVPath, testUsername, "wrong-password", 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)
request.SetBasicAuth(testUsername, testPassword)
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)
if response.StatusCode != http.StatusCreated && response.StatusCode != http.StatusNoContent && response.StatusCode != http.StatusOK {
t.Fatalf("expected write with valid credentials to succeed, got %d", response.StatusCode)
}
assertMountedFileContentsWithBasicAuth(t, server.Client(), server.URL+defaultWebDAVPath+"README.txt", testUsername, testPassword, "updated\n")
}
func TestBuildExportMountsRejectsInvalidConfigs(t *testing.T) {
@ -193,54 +185,6 @@ func assertMountedFileContentsWithBasicAuth(t *testing.T, client *http.Client, e
}
}
func issueTestMountCredential(t *testing.T, nodeID string, mountPath string, readonly bool) (string, string) {
t.Helper()
claims := signedMountCredentialClaims{
Version: 1,
NodeID: nodeID,
MountPath: mountPath,
Username: "mount-test-user",
Readonly: readonly,
ExpiresAt: time.Now().UTC().Add(time.Hour).Format(time.RFC3339),
}
password, err := encodeTestMountCredential(claims)
if err != nil {
t.Fatalf("issue test mount credential: %v", err)
}
return claims.Username, password
}
func issueExpiredTestMountCredential(t *testing.T, nodeID string, mountPath string, readonly bool) (string, string) {
t.Helper()
claims := signedMountCredentialClaims{
Version: 1,
NodeID: nodeID,
MountPath: mountPath,
Username: "mount-expired-user",
Readonly: readonly,
ExpiresAt: time.Now().UTC().Add(-time.Minute).Format(time.RFC3339),
}
password, err := encodeTestMountCredential(claims)
if err != nil {
t.Fatalf("issue expired test mount credential: %v", err)
}
return claims.Username, password
}
func encodeTestMountCredential(claims signedMountCredentialClaims) (string, error) {
payload, err := json.Marshal(claims)
if err != nil {
return "", err
}
encodedPayload := base64.RawURLEncoding.EncodeToString(payload)
return encodedPayload + "." + signMountCredentialPayload(testDAVAuthSecret, encodedPayload), nil
}
func writeExportFile(t *testing.T, directory string, name string, contents string) {
t.Helper()

View file

@ -14,8 +14,6 @@ import (
"time"
)
const controlPlaneNodeTokenHeader = "X-BetterNAS-Node-Token"
type bootstrapResult struct {
nodeID string
}
@ -32,6 +30,15 @@ type nodeRegistrationResponse struct {
ID string `json:"id"`
}
type authLoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type authLoginResponse struct {
Token string `json:"token"`
}
type nodeExportsRequest struct {
Exports []storageExportInput `json:"exports"`
}
@ -57,38 +64,28 @@ func bootstrapNodeAgentFromEnv(exportPaths []string) (bootstrapResult, error) {
return bootstrapResult{}, err
}
bootstrapToken, err := requiredEnv("BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN")
username, err := requiredEnv("BETTERNAS_USERNAME")
if err != nil {
return bootstrapResult{}, err
}
password, err := requiredEnv("BETTERNAS_PASSWORD")
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))
machineID := strings.TrimSpace(env("BETTERNAS_NODE_MACHINE_ID", defaultNodeMachineID(username)))
displayName := strings.TrimSpace(env("BETTERNAS_NODE_DISPLAY_NAME", defaultNodeDisplayName(machineID)))
if displayName == "" {
displayName = machineID
}
client := &http.Client{Timeout: 5 * time.Second}
nodeToken, err := readNodeToken(nodeTokenPath)
sessionToken, err := loginWithControlPlane(client, controlPlaneURL, username, password)
if err != nil {
return bootstrapResult{}, err
}
authToken := nodeToken
if authToken == "" {
authToken = bootstrapToken
}
registration, issuedNodeToken, err := registerNodeWithControlPlane(client, controlPlaneURL, authToken, nodeRegistrationRequest{
registration, err := registerNodeWithControlPlane(client, controlPlaneURL, sessionToken, nodeRegistrationRequest{
MachineID: machineID,
DisplayName: displayName,
AgentVersion: env("BETTERNAS_VERSION", "0.1.0-dev"),
@ -99,40 +96,58 @@ func bootstrapNodeAgentFromEnv(exportPaths []string) (bootstrapResult, error) {
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 {
if err := syncNodeExportsWithControlPlane(client, controlPlaneURL, sessionToken, registration.ID, buildStorageExportInputs(exportPaths)); err != nil {
return bootstrapResult{}, err
}
if err := sendNodeHeartbeat(client, controlPlaneURL, authToken, registration.ID); err != nil {
if err := sendNodeHeartbeat(client, controlPlaneURL, sessionToken, 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)
func loginWithControlPlane(client *http.Client, baseURL string, username string, password string) (string, error) {
response, err := doControlPlaneJSONRequest(client, http.MethodPost, controlPlaneEndpoint(baseURL, "/api/v1/auth/login"), "", authLoginRequest{
Username: username,
Password: password,
})
if err != nil {
return nodeRegistrationResponse{}, "", err
return "", err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return nodeRegistrationResponse{}, "", controlPlaneResponseError("register node", response)
return "", controlPlaneResponseError("login", response)
}
var auth authLoginResponse
if err := json.NewDecoder(response.Body).Decode(&auth); err != nil {
return "", fmt.Errorf("decode login response: %w", err)
}
if strings.TrimSpace(auth.Token) == "" {
return "", fmt.Errorf("login: missing session token")
}
return strings.TrimSpace(auth.Token), nil
}
func registerNodeWithControlPlane(client *http.Client, baseURL string, token string, payload nodeRegistrationRequest) (nodeRegistrationResponse, error) {
response, err := doControlPlaneJSONRequest(client, http.MethodPost, controlPlaneEndpoint(baseURL, "/api/v1/nodes/register"), token, payload)
if err != nil {
return nodeRegistrationResponse{}, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return nodeRegistrationResponse{}, controlPlaneResponseError("register node", response)
}
var registration nodeRegistrationResponse
if err := json.NewDecoder(response.Body).Decode(&registration); err != nil {
return nodeRegistrationResponse{}, "", fmt.Errorf("decode register node response: %w", err)
return nodeRegistrationResponse{}, fmt.Errorf("decode register node response: %w", err)
}
return registration, strings.TrimSpace(response.Header.Get(controlPlaneNodeTokenHeader)), nil
return registration, nil
}
func syncNodeExportsWithControlPlane(client *http.Client, baseURL string, token string, nodeID string, exports []storageExportInput) error {
@ -181,7 +196,9 @@ func doControlPlaneJSONRequest(client *http.Client, method string, endpoint stri
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)
if strings.TrimSpace(token) != "" {
request.Header.Set("Authorization", "Bearer "+token)
}
response, err := client.Do(request)
if err != nil {
@ -248,52 +265,20 @@ func optionalEnvPointer(key string) *string {
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)
func defaultNodeMachineID(username string) string {
hostname, err := os.Hostname()
if err != nil || strings.TrimSpace(hostname) == "" {
return strings.TrimSpace(username) + "@node"
}
return strings.TrimSpace(string(data)), nil
return strings.TrimSpace(username) + "@" + strings.TrimSpace(hostname)
}
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)
func defaultNodeDisplayName(machineID string) string {
_, displayName, ok := strings.Cut(strings.TrimSpace(machineID), "@")
if ok && strings.TrimSpace(displayName) != "" {
return strings.TrimSpace(displayName)
}
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
return strings.TrimSpace(machineID)
}

View file

@ -1,74 +1,8 @@
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))
}
import "net/http"
func writeDAVUnauthorized(w http.ResponseWriter) {
w.Header().Set("WWW-Authenticate", `Basic realm="betterNAS"`)
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
}
func isDAVReadMethod(method string) bool {
switch method {
case http.MethodGet, http.MethodHead, http.MethodOptions, "PROPFIND":
return true
default:
return false
}
}

View file

@ -4,7 +4,7 @@
"private": true,
"scripts": {
"dev": "CGO_ENABLED=0 go run ./cmd/node-agent",
"build": "mkdir -p dist && CGO_ENABLED=0 go build -o dist/node-agent ./cmd/node-agent",
"build": "mkdir -p dist && CGO_ENABLED=0 go build -o dist/betternas-node ./cmd/node-agent",
"format": "files=$(find . -name '*.go' -type f) && if [ -n \"$files\" ]; then gofmt -w $files; fi",
"format:check": "files=$(find . -name '*.go' -type f) && if [ -n \"$files\" ]; then test -z \"$(gofmt -l $files)\"; fi",
"lint": "CGO_ENABLED=0 go vet ./...",

View file

@ -5,17 +5,9 @@ Next.js control-plane UI for betterNAS.
Use this app for:
- admin and operator workflows
- node and export visibility
- issuing mount profiles
- user-scoped node and export visibility
- issuing mount profiles that reuse the same betterNAS account credentials
- later cloud-mode management
Do not move the product system of record into this app. It should stay a UI and
thin BFF layer over the Go control plane.
The current page reads control-plane config from:
- `BETTERNAS_CONTROL_PLANE_URL` and `BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN`, or
- the repo-local `.env.agent` file
That keeps the page aligned with the running self-hosted stack during local
development.

View file

@ -55,8 +55,8 @@ export default function LoginPage() {
</CardTitle>
<CardDescription>
{mode === "login"
? "Sign in to your betterNAS control plane."
: "Create your betterNAS account."}
? "Sign in to your betterNAS control plane with the same credentials you use for the node agent and Finder."
: "Create your betterNAS account. You will use the same username and password for the web app, node agent, and Finder."}
</CardDescription>
</CardHeader>
<CardContent>
@ -103,9 +103,7 @@ export default function LoginPage() {
/>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<Button type="submit" disabled={loading} className="w-full">
{loading

View file

@ -78,7 +78,9 @@ export default function Home() {
const profile = await issueMountProfile(exportId);
setMountProfile(profile);
} catch (err) {
setFeedback(err instanceof Error ? err.message : "Failed to issue mount profile");
setFeedback(
err instanceof Error ? err.message : "Failed to issue mount profile",
);
}
}
@ -96,7 +98,7 @@ export default function Home() {
}
const selectedExport = selectedExportId
? exports.find((e) => e.id === selectedExportId) ?? null
? (exports.find((e) => e.id === selectedExportId) ?? null)
: null;
return (
@ -117,11 +119,7 @@ export default function Home() {
<span className="text-sm text-muted-foreground">
{user.username}
</span>
<Button
variant="ghost"
size="sm"
onClick={handleLogout}
>
<Button variant="ghost" size="sm" onClick={handleLogout}>
<SignOut className="mr-1 size-4" />
Sign out
</Button>
@ -138,6 +136,25 @@ export default function Home() {
{exports.length === 1 ? "1 export" : `${exports.length} exports`}
</Badge>
</div>
{user && (
<Card>
<CardHeader>
<CardTitle>Node agent setup</CardTitle>
<CardDescription>
Run the node binary on the machine that owns the files with
the same account credentials you use here and in Finder.
</CardDescription>
</CardHeader>
<CardContent>
<pre className="overflow-x-auto rounded-xl border bg-muted/40 p-4 text-xs text-foreground">
<code>
{`BETTERNAS_USERNAME=${user.username} BETTERNAS_PASSWORD=... BETTERNAS_EXPORT_PATH=/path/to/export betternas-node`}
</code>
</pre>
</CardContent>
</Card>
)}
</div>
{feedback !== null && (
@ -245,8 +262,8 @@ export default function Home() {
</CardTitle>
<CardDescription>
{selectedExport !== null
? "Issued WebDAV credentials for Finder."
: "Select an export to issue mount credentials."}
? "WebDAV mount details for Finder."
: "Select an export to see the mount URL and account login details."}
</CardDescription>
</CardHeader>
<CardContent>
@ -254,7 +271,8 @@ export default function Home() {
<div className="flex flex-col items-center gap-3 rounded-xl border border-dashed py-10 text-center">
<LinkSimple size={32} className="text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">
Pick an export to issue WebDAV credentials for Finder.
Pick an export to see the Finder mount URL and the username
to use with your betterNAS account password.
</p>
</div>
) : (
@ -281,10 +299,16 @@ export default function Home() {
label="Username"
value={mountProfile.credential.username}
/>
<CopyField
label="Password"
value={mountProfile.credential.password}
/>
<Alert>
<AlertTitle>
Use your betterNAS account password
</AlertTitle>
<AlertDescription>
Enter the same password you use to sign in to betterNAS
and run the node agent. v1 does not issue a separate
WebDAV password.
</AlertDescription>
</Alert>
</div>
<Separator />
@ -300,10 +324,10 @@ export default function Home() {
</div>
<div>
<dt className="mb-0.5 text-xs uppercase tracking-wide text-muted-foreground">
Expires
Password source
</dt>
<dd className="text-xs text-foreground">
{mountProfile.credential.expiresAt}
Your betterNAS account password
</dd>
</div>
</dl>
@ -316,8 +340,8 @@ export default function Home() {
{[
"Open Finder and choose Go, then Connect to Server.",
"Paste the mount URL into the server address field.",
"Enter the issued username and password when prompted.",
"Save to Keychain only if the credential expiry suits your workflow.",
"Enter your betterNAS username and account password when prompted.",
"Save to Keychain only if you want Finder to reuse that same account password.",
].map((step, index) => (
<li
key={index}

View file

@ -64,10 +64,7 @@ export function isAuthenticated(): boolean {
return getToken() !== null;
}
async function apiFetch<T>(
path: string,
options?: RequestInit,
): Promise<T> {
async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
const headers: Record<string, string> = {};
const token = getToken();
if (token) {
@ -79,7 +76,10 @@ async function apiFetch<T>(
const response = await fetch(`${API_URL}${path}`, {
...options,
headers: { ...headers, ...Object.fromEntries(new Headers(options?.headers).entries()) },
headers: {
...headers,
...Object.fromEntries(new Headers(options?.headers).entries()),
},
});
if (!response.ok) {