mirror of
https://github.com/harivansh-afk/betterNAS.git
synced 2026-04-15 03:00:44 +00:00
user-owned DAVs (#14)
This commit is contained in:
parent
ca5014750b
commit
1bbfb6647d
35 changed files with 732 additions and 777 deletions
|
|
@ -7,8 +7,8 @@ BETTERNAS_NODE_AGENT_PORT=
|
|||
BETTERNAS_NEXTCLOUD_PORT=
|
||||
BETTERNAS_EXPORT_PATH=
|
||||
BETTERNAS_VERSION=local-dev
|
||||
BETTERNAS_DAV_AUTH_SECRET=
|
||||
BETTERNAS_DAV_CREDENTIAL_TTL=
|
||||
BETTERNAS_USERNAME=
|
||||
BETTERNAS_PASSWORD=
|
||||
BETTERNAS_NODE_MACHINE_ID=
|
||||
BETTERNAS_NODE_DISPLAY_NAME=
|
||||
BETTERNAS_NODE_DIRECT_ADDRESS=
|
||||
|
|
|
|||
36
.github/workflows/ci.yml
vendored
Normal file
36
.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
name: ci
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.33.0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.26.0"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Verify
|
||||
run: pnpm verify
|
||||
32
.github/workflows/release.yml
vendored
Normal file
32
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
name: release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
goreleaser:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.26.0"
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: "~> v2"
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
30
.goreleaser.yaml
Normal file
30
.goreleaser.yaml
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
version: 2
|
||||
|
||||
project_name: betternas-node
|
||||
|
||||
monorepo:
|
||||
tag_prefix: v
|
||||
|
||||
builds:
|
||||
- id: betternas-node
|
||||
main: ./apps/node-agent/cmd/node-agent
|
||||
binary: betternas-node
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- darwin
|
||||
- linux
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
ldflags:
|
||||
- -s -w
|
||||
|
||||
archives:
|
||||
- id: betternas-node
|
||||
builds:
|
||||
- betternas-node
|
||||
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
|
||||
changelog:
|
||||
use: git
|
||||
17
CLAUDE.md
17
CLAUDE.md
|
|
@ -44,3 +44,20 @@
|
|||
|
||||
- Keep `node-service` limited to the WebDAV mount surface.
|
||||
- Route admin and control actions through `control-server`, not directly from browsers to `node-service`.
|
||||
|
||||
## User-scoped auth requirements
|
||||
|
||||
- Remove the bootstrap token flow for v1.
|
||||
- Use a single user-provided username and password across the entire stack:
|
||||
- `apps/node-agent` authenticates with the user's username and password from environment variables
|
||||
- web app sessions authenticate with the same username and password
|
||||
- WebDAV and Finder authentication use the same username and password
|
||||
- Do not generate separate WebDAV credentials for users.
|
||||
- Nodes and exports must be owned by users and scoped so authenticated users can only view and mount their own resources.
|
||||
- Package the node binary for user download and distribution.
|
||||
|
||||
## V1 simplicity
|
||||
|
||||
- Keep the implementation as simple as possible.
|
||||
- Do not over-engineer the auth or distribution model for v1.
|
||||
- Prefer the smallest change set that makes the product usable and distributable.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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(®istration); 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ./...",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -120,12 +120,14 @@ self-hosted mount flow.
|
|||
- issue mount profile
|
||||
- issue share or cloud profile later
|
||||
|
||||
For v1, the same betterNAS username and password should be used across the web
|
||||
UI, node enrollment, and Finder WebDAV login.
|
||||
|
||||
### Local device -> control-server
|
||||
|
||||
- fetch mount instructions
|
||||
- receive issued WebDAV URL and standard WebDAV credentials
|
||||
- receive issued WebDAV URL plus the betterNAS account username
|
||||
- username
|
||||
- password
|
||||
- expiresAt
|
||||
|
||||
## Initial backend route sketch
|
||||
|
|
|
|||
|
|
@ -32,11 +32,9 @@ services:
|
|||
PORT: 3000
|
||||
BETTERNAS_VERSION: ${BETTERNAS_VERSION}
|
||||
NEXTCLOUD_BASE_URL: ${NEXTCLOUD_BASE_URL}
|
||||
BETTERNAS_CONTROL_PLANE_STATE_PATH: /var/lib/betternas/control-plane/state.json
|
||||
BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN: ${BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN}
|
||||
BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN: ${BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN}
|
||||
BETTERNAS_DAV_AUTH_SECRET: ${BETTERNAS_DAV_AUTH_SECRET}
|
||||
BETTERNAS_DAV_CREDENTIAL_TTL: ${BETTERNAS_DAV_CREDENTIAL_TTL}
|
||||
BETTERNAS_CONTROL_PLANE_DB_PATH: /var/lib/betternas/control-plane/betternas.db
|
||||
BETTERNAS_USERNAME: ${BETTERNAS_USERNAME}
|
||||
BETTERNAS_PASSWORD: ${BETTERNAS_PASSWORD}
|
||||
BETTERNAS_EXAMPLE_MOUNT_URL: ${BETTERNAS_EXAMPLE_MOUNT_URL}
|
||||
BETTERNAS_NODE_DIRECT_ADDRESS: ${BETTERNAS_NODE_DIRECT_ADDRESS}
|
||||
ports:
|
||||
|
|
@ -58,12 +56,11 @@ services:
|
|||
BETTERNAS_EXPORT_PATH: /data/export
|
||||
BETTERNAS_EXPORT_PATHS_JSON: '["/data/export"]'
|
||||
BETTERNAS_CONTROL_PLANE_URL: http://control-plane:3000
|
||||
BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN: ${BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN}
|
||||
BETTERNAS_NODE_TOKEN_PATH: /var/lib/betternas/node-agent/node-token
|
||||
BETTERNAS_USERNAME: ${BETTERNAS_USERNAME}
|
||||
BETTERNAS_PASSWORD: ${BETTERNAS_PASSWORD}
|
||||
BETTERNAS_NODE_MACHINE_ID: ${BETTERNAS_NODE_MACHINE_ID}
|
||||
BETTERNAS_NODE_DISPLAY_NAME: ${BETTERNAS_NODE_DISPLAY_NAME}
|
||||
BETTERNAS_NODE_DIRECT_ADDRESS: ${BETTERNAS_NODE_DIRECT_ADDRESS}
|
||||
BETTERNAS_DAV_AUTH_SECRET: ${BETTERNAS_DAV_AUTH_SECRET}
|
||||
BETTERNAS_VERSION: ${BETTERNAS_VERSION}
|
||||
ports:
|
||||
- "${BETTERNAS_NODE_AGENT_PORT}:8090"
|
||||
|
|
@ -94,7 +91,7 @@ services:
|
|||
POSTGRES_PASSWORD: nextcloud
|
||||
REDIS_HOST: redis
|
||||
BETTERNAS_CONTROL_PLANE_URL: http://control-plane:3000
|
||||
BETTERNAS_CONTROL_PLANE_API_TOKEN: ${BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN}
|
||||
BETTERNAS_CONTROL_PLANE_API_TOKEN: ""
|
||||
ports:
|
||||
- "${BETTERNAS_NEXTCLOUD_PORT}:80"
|
||||
volumes:
|
||||
|
|
|
|||
|
|
@ -22,8 +22,7 @@ paths:
|
|||
post:
|
||||
operationId: registerNode
|
||||
security:
|
||||
- NodeBootstrapToken: []
|
||||
- NodeToken: []
|
||||
- UserSession: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
|
|
@ -33,11 +32,6 @@ paths:
|
|||
responses:
|
||||
"200":
|
||||
description: Registered node
|
||||
headers:
|
||||
X-BetterNAS-Node-Token:
|
||||
description: Returned when a node is first registered or migrated to node-scoped auth.
|
||||
schema:
|
||||
type: string
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
|
|
@ -48,7 +42,7 @@ paths:
|
|||
post:
|
||||
operationId: recordNodeHeartbeat
|
||||
security:
|
||||
- NodeToken: []
|
||||
- UserSession: []
|
||||
parameters:
|
||||
- in: path
|
||||
name: nodeId
|
||||
|
|
@ -70,7 +64,7 @@ paths:
|
|||
put:
|
||||
operationId: syncNodeExports
|
||||
security:
|
||||
- NodeToken: []
|
||||
- UserSession: []
|
||||
parameters:
|
||||
- in: path
|
||||
name: nodeId
|
||||
|
|
@ -98,7 +92,7 @@ paths:
|
|||
get:
|
||||
operationId: listExports
|
||||
security:
|
||||
- ClientToken: []
|
||||
- UserSession: []
|
||||
responses:
|
||||
"200":
|
||||
description: Export list
|
||||
|
|
@ -114,7 +108,7 @@ paths:
|
|||
post:
|
||||
operationId: issueMountProfile
|
||||
security:
|
||||
- ClientToken: []
|
||||
- UserSession: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
|
|
@ -134,7 +128,7 @@ paths:
|
|||
post:
|
||||
operationId: issueCloudProfile
|
||||
security:
|
||||
- ClientToken: []
|
||||
- UserSession: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
|
|
@ -152,18 +146,10 @@ paths:
|
|||
description: Unauthorized
|
||||
components:
|
||||
securitySchemes:
|
||||
ClientToken:
|
||||
UserSession:
|
||||
type: http
|
||||
scheme: bearer
|
||||
description: Bearer token required for export listing and profile issuance.
|
||||
NodeBootstrapToken:
|
||||
type: http
|
||||
scheme: bearer
|
||||
description: Bearer token required to register a new node before it receives a node-scoped token.
|
||||
NodeToken:
|
||||
type: http
|
||||
scheme: bearer
|
||||
description: Bearer token scoped to a previously registered node.
|
||||
description: Bearer session token returned by the username and password auth endpoints.
|
||||
schemas:
|
||||
NasNode:
|
||||
type: object
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ export const FOUNDATION_API_ROUTES = {
|
|||
|
||||
export const FOUNDATION_API_HEADERS = {
|
||||
authorization: "Authorization",
|
||||
nodeToken: "X-BetterNAS-Node-Token",
|
||||
} as const;
|
||||
|
||||
export type NasNodeStatus = "online" | "offline" | "degraded";
|
||||
|
|
|
|||
|
|
@ -10,9 +10,29 @@ source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../lib/runtime-env.sh"
|
|||
control_health="$(curl -fsS "http://localhost:${BETTERNAS_CONTROL_PLANE_PORT}/health")"
|
||||
echo "$control_health" | jq -e '.service == "control-plane" and .status == "ok"' >/dev/null
|
||||
|
||||
auth_payload="$(jq -nc --arg username "$BETTERNAS_USERNAME" --arg password "$BETTERNAS_PASSWORD" '{username: $username, password: $password}')"
|
||||
session_token="$({
|
||||
curl -fsS \
|
||||
-X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$auth_payload" \
|
||||
"http://localhost:${BETTERNAS_CONTROL_PLANE_PORT}/api/v1/auth/login" \
|
||||
| jq -er '.token'
|
||||
} 2>/dev/null || true)"
|
||||
if [[ -z "$session_token" ]]; then
|
||||
session_token="$({
|
||||
curl -fsS \
|
||||
-X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d "$auth_payload" \
|
||||
"http://localhost:${BETTERNAS_CONTROL_PLANE_PORT}/api/v1/auth/register" \
|
||||
| jq -er '.token'
|
||||
})"
|
||||
fi
|
||||
|
||||
export_id=""
|
||||
for _ in {1..30}; do
|
||||
exports_response="$(curl -fsS -H "Authorization: Bearer ${BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN}" "http://localhost:${BETTERNAS_CONTROL_PLANE_PORT}/api/v1/exports")"
|
||||
exports_response="$(curl -fsS -H "Authorization: Bearer ${session_token}" "http://localhost:${BETTERNAS_CONTROL_PLANE_PORT}/api/v1/exports")"
|
||||
export_id="$({
|
||||
echo "$exports_response" | jq -er \
|
||||
'map(select(.mountPath == "/dav/")) | .[0].id? // empty'
|
||||
|
|
@ -32,14 +52,14 @@ mount_profile="$({
|
|||
curl -fsS \
|
||||
-X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H "Authorization: Bearer ${BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN}" \
|
||||
-H "Authorization: Bearer ${session_token}" \
|
||||
-d "{\"exportId\":\"${export_id}\"}" \
|
||||
"http://localhost:${BETTERNAS_CONTROL_PLANE_PORT}/api/v1/mount-profiles/issue"
|
||||
})"
|
||||
echo "$mount_profile" | jq -e --arg expected "$BETTERNAS_EXAMPLE_MOUNT_URL" '.protocol == "webdav" and .mountUrl == $expected and .credential.mode == "basic-auth"' >/dev/null
|
||||
|
||||
BETTERNAS_EXAMPLE_MOUNT_USERNAME="$(echo "$mount_profile" | jq -er '.credential.username')"
|
||||
BETTERNAS_EXAMPLE_MOUNT_PASSWORD="$(echo "$mount_profile" | jq -er '.credential.password')"
|
||||
BETTERNAS_EXAMPLE_MOUNT_USERNAME="${BETTERNAS_USERNAME}"
|
||||
BETTERNAS_EXAMPLE_MOUNT_PASSWORD="${BETTERNAS_PASSWORD}"
|
||||
export BETTERNAS_EXAMPLE_MOUNT_USERNAME
|
||||
export BETTERNAS_EXAMPLE_MOUNT_PASSWORD
|
||||
"$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/verify-webdav"
|
||||
|
|
@ -48,7 +68,7 @@ cloud_profile="$({
|
|||
curl -fsS \
|
||||
-X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H "Authorization: Bearer ${BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN}" \
|
||||
-H "Authorization: Bearer ${session_token}" \
|
||||
-d "{\"userId\":\"integration-user\",\"exportId\":\"${export_id}\",\"provider\":\"nextcloud\"}" \
|
||||
"http://localhost:${BETTERNAS_CONTROL_PLANE_PORT}/api/v1/cloud-profiles/issue"
|
||||
})"
|
||||
|
|
|
|||
|
|
@ -130,10 +130,8 @@ betternas_write_agent_env_file() {
|
|||
betternas_write_env_assignment "BETTERNAS_NEXTCLOUD_PORT" "$nextcloud_port"
|
||||
betternas_write_env_assignment "BETTERNAS_EXPORT_PATH" ".state/${clone_name}/export"
|
||||
betternas_write_env_assignment "BETTERNAS_VERSION" "local-dev"
|
||||
betternas_write_env_assignment "BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN" "${clone_name}-local-client-token"
|
||||
betternas_write_env_assignment "BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN" "${clone_name}-local-node-bootstrap-token"
|
||||
betternas_write_env_assignment "BETTERNAS_DAV_AUTH_SECRET" "${clone_name}-local-dav-auth-secret"
|
||||
betternas_write_env_assignment "BETTERNAS_DAV_CREDENTIAL_TTL" "1h"
|
||||
betternas_write_env_assignment "BETTERNAS_USERNAME" "${clone_name}-user"
|
||||
betternas_write_env_assignment "BETTERNAS_PASSWORD" "${clone_name}-password123"
|
||||
betternas_write_env_assignment "BETTERNAS_NODE_MACHINE_ID" "${clone_name}-node"
|
||||
betternas_write_env_assignment "BETTERNAS_NODE_DISPLAY_NAME" "${clone_name} node"
|
||||
betternas_write_env_assignment "BETTERNAS_NODE_DIRECT_ADDRESS" "http://localhost:${node_agent_port}"
|
||||
|
|
|
|||
|
|
@ -31,10 +31,8 @@ read -r default_nextcloud_port default_node_agent_port default_control_plane_por
|
|||
: "${BETTERNAS_NODE_AGENT_PORT:=$default_node_agent_port}"
|
||||
: "${BETTERNAS_NEXTCLOUD_PORT:=$default_nextcloud_port}"
|
||||
: "${BETTERNAS_VERSION:=local-dev}"
|
||||
: "${BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN:=${BETTERNAS_CLONE_NAME}-local-client-token}"
|
||||
: "${BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN:=${BETTERNAS_CLONE_NAME}-local-node-bootstrap-token}"
|
||||
: "${BETTERNAS_DAV_AUTH_SECRET:=${BETTERNAS_CLONE_NAME}-local-dav-auth-secret}"
|
||||
: "${BETTERNAS_DAV_CREDENTIAL_TTL:=1h}"
|
||||
: "${BETTERNAS_USERNAME:=${BETTERNAS_CLONE_NAME}-user}"
|
||||
: "${BETTERNAS_PASSWORD:=${BETTERNAS_CLONE_NAME}-password123}"
|
||||
: "${BETTERNAS_NODE_MACHINE_ID:=${BETTERNAS_CLONE_NAME}-node}"
|
||||
: "${BETTERNAS_NODE_DISPLAY_NAME:=${BETTERNAS_CLONE_NAME} node}"
|
||||
: "${NEXTCLOUD_ADMIN_USER:=admin}"
|
||||
|
|
@ -62,10 +60,8 @@ export BETTERNAS_NODE_AGENT_PORT
|
|||
export BETTERNAS_NEXTCLOUD_PORT
|
||||
export BETTERNAS_EXPORT_PATH
|
||||
export BETTERNAS_VERSION
|
||||
export BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN
|
||||
export BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN
|
||||
export BETTERNAS_DAV_AUTH_SECRET
|
||||
export BETTERNAS_DAV_CREDENTIAL_TTL
|
||||
export BETTERNAS_USERNAME
|
||||
export BETTERNAS_PASSWORD
|
||||
export BETTERNAS_NODE_MACHINE_ID
|
||||
export BETTERNAS_NODE_DISPLAY_NAME
|
||||
export NEXTCLOUD_ADMIN_USER
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ Deploy the betterNAS control-plane as a production service on netty (Netcup VPS)
|
|||
- Deployment is declarative via NixOS configuration.nix
|
||||
|
||||
### Verification:
|
||||
|
||||
1. `curl https://api.betternas.com/health` returns `ok`
|
||||
2. Web UI at `betternas.com` loads, shows login page
|
||||
3. User can register, log in, see exports, one-click mount
|
||||
|
|
@ -57,6 +58,7 @@ Five phases, each independently deployable and testable:
|
|||
## Phase 1: SQLite Store
|
||||
|
||||
### Overview
|
||||
|
||||
Replace `memoryStore` (in-memory + JSON file) with a `sqliteStore` using `modernc.org/sqlite` (pure Go, no CGo, `database/sql` compatible). This keeps all existing API behavior identical while switching the persistence layer.
|
||||
|
||||
### Schema
|
||||
|
|
@ -118,15 +120,19 @@ CREATE TABLE export_tags (
|
|||
### Changes Required
|
||||
|
||||
#### 1. Add SQLite dependency
|
||||
|
||||
**File**: `apps/control-plane/go.mod`
|
||||
|
||||
```
|
||||
go get modernc.org/sqlite
|
||||
```
|
||||
|
||||
#### 2. New file: `sqlite_store.go`
|
||||
|
||||
**File**: `apps/control-plane/cmd/control-plane/sqlite_store.go`
|
||||
|
||||
Implements the same operations as `memoryStore` but backed by SQLite:
|
||||
|
||||
- `newSQLiteStore(dbPath string) (*sqliteStore, error)` - opens DB, runs migrations
|
||||
- `registerNode(...)` - INSERT/UPDATE node + token hash in a transaction
|
||||
- `upsertExports(...)` - DELETE removed exports, UPSERT current ones in a transaction
|
||||
|
|
@ -138,6 +144,7 @@ Implements the same operations as `memoryStore` but backed by SQLite:
|
|||
- `nextOrdinal(name)` - UPDATE ordinals SET value = value + 1 RETURNING value
|
||||
|
||||
Key design decisions:
|
||||
|
||||
- Use `database/sql` with `modernc.org/sqlite` driver
|
||||
- WAL mode enabled at connection: `PRAGMA journal_mode=WAL`
|
||||
- Foreign keys enabled: `PRAGMA foreign_keys=ON`
|
||||
|
|
@ -146,9 +153,11 @@ Key design decisions:
|
|||
- No ORM - raw SQL with prepared statements
|
||||
|
||||
#### 3. Update `app.go` to use SQLite store
|
||||
|
||||
**File**: `apps/control-plane/cmd/control-plane/app.go`
|
||||
|
||||
Replace `memoryStore` initialization with `sqliteStore`:
|
||||
|
||||
```go
|
||||
// Replace:
|
||||
// store, err := newMemoryStore(statePath)
|
||||
|
|
@ -159,6 +168,7 @@ Replace `memoryStore` initialization with `sqliteStore`:
|
|||
New env var: `BETTERNAS_CONTROL_PLANE_DB_PATH` (default: `/var/lib/betternas/control-plane/betternas.db`)
|
||||
|
||||
#### 4. Update `server.go` to use new store interface
|
||||
|
||||
**File**: `apps/control-plane/cmd/control-plane/server.go`
|
||||
|
||||
The server handlers currently call methods directly on `*memoryStore`. These need to call the equivalent methods on the new store. If the method signatures match, this is a straight swap. If not, introduce a `store` interface that both implement during migration, then delete `memoryStore`.
|
||||
|
|
@ -166,12 +176,14 @@ The server handlers currently call methods directly on `*memoryStore`. These nee
|
|||
### Success Criteria
|
||||
|
||||
#### Automated Verification:
|
||||
|
||||
- [ ] `go build ./apps/control-plane/cmd/control-plane/` compiles with `CGO_ENABLED=0`
|
||||
- [ ] `go test ./apps/control-plane/cmd/control-plane/ -v` passes all existing tests
|
||||
- [ ] New SQLite store tests pass (register node, upsert exports, list exports, auth lookup)
|
||||
- [ ] `curl` against a local instance: register node, sync exports, issue mount profile - all return expected responses
|
||||
|
||||
#### Manual Verification:
|
||||
|
||||
- [ ] Start control-plane locally, SQLite file is created at configured path
|
||||
- [ ] Restart control-plane - state persists across restarts
|
||||
- [ ] Node-agent can register and sync exports against the SQLite-backed control-plane
|
||||
|
|
@ -181,6 +193,7 @@ The server handlers currently call methods directly on `*memoryStore`. These nee
|
|||
## Phase 2: User Auth
|
||||
|
||||
### Overview
|
||||
|
||||
Add user accounts with username/password (bcrypt) and session tokens stored in SQLite. The session token replaces the static `BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN` for web UI access. Node-agent auth (bootstrap token + node token) is unchanged.
|
||||
|
||||
### Additional Schema
|
||||
|
|
@ -216,6 +229,7 @@ GET /api/v1/auth/me - Return current user info (session validation)
|
|||
### Changes Required
|
||||
|
||||
#### 1. New file: `auth.go`
|
||||
|
||||
**File**: `apps/control-plane/cmd/control-plane/auth.go`
|
||||
|
||||
```go
|
||||
|
|
@ -250,15 +264,18 @@ func (s *sqliteStore) cleanExpiredSessions() error
|
|||
```
|
||||
|
||||
#### 2. New env vars
|
||||
|
||||
```
|
||||
BETTERNAS_SESSION_TTL # Session duration (default: "720h" = 30 days)
|
||||
BETTERNAS_REGISTRATION_ENABLED # Allow new registrations (default: "true")
|
||||
```
|
||||
|
||||
#### 3. Update `server.go` - auth middleware and routes
|
||||
|
||||
**File**: `apps/control-plane/cmd/control-plane/server.go`
|
||||
|
||||
Add auth routes:
|
||||
|
||||
```go
|
||||
mux.HandleFunc("POST /api/v1/auth/register", s.handleRegister)
|
||||
mux.HandleFunc("POST /api/v1/auth/login", s.handleLogin)
|
||||
|
|
@ -267,6 +284,7 @@ mux.HandleFunc("GET /api/v1/auth/me", s.handleMe)
|
|||
```
|
||||
|
||||
Update client-auth middleware:
|
||||
|
||||
```go
|
||||
// Currently: checks Bearer token against static BETTERNAS_CONTROL_PLANE_CLIENT_TOKEN
|
||||
// New: checks Bearer token against sessions table first, falls back to static token
|
||||
|
|
@ -297,12 +315,14 @@ func (s *server) requireClientAuth(next http.Handler) http.Handler {
|
|||
### Success Criteria
|
||||
|
||||
#### Automated Verification:
|
||||
|
||||
- [ ] `go test` passes for auth endpoints (register, login, logout, me)
|
||||
- [ ] `go test` passes for session middleware (valid token, expired token, invalid token)
|
||||
- [ ] Existing client token auth still works (backwards compat)
|
||||
- [ ] Existing node auth unchanged
|
||||
|
||||
#### Manual Verification:
|
||||
|
||||
- [ ] Register a user via curl, login, use session token to list exports
|
||||
- [ ] Session expires after TTL
|
||||
- [ ] Logout invalidates session immediately
|
||||
|
|
@ -313,11 +333,13 @@ func (s *server) requireClientAuth(next http.Handler) http.Handler {
|
|||
## Phase 3: CORS + Frontend Auth Integration
|
||||
|
||||
### Overview
|
||||
|
||||
Add CORS headers to the control-plane so the Vercel-hosted frontend can make API calls. Update the web frontend to use session-based auth (login page, session cookie/token management).
|
||||
|
||||
### Changes Required
|
||||
|
||||
#### 1. CORS middleware in control-plane
|
||||
|
||||
**File**: `apps/control-plane/cmd/control-plane/server.go`
|
||||
|
||||
```go
|
||||
|
|
@ -342,14 +364,17 @@ func corsMiddleware(allowedOrigin string, next http.Handler) http.Handler {
|
|||
```
|
||||
|
||||
#### 2. Frontend auth flow
|
||||
|
||||
**Files**: `apps/web/`
|
||||
|
||||
New pages/components:
|
||||
|
||||
- `app/login/page.tsx` - Login form (username + password)
|
||||
- `app/register/page.tsx` - Registration form (if enabled)
|
||||
- `lib/auth.ts` - Client-side auth helpers (store token, attach to requests)
|
||||
|
||||
Update `lib/control-plane.ts`:
|
||||
|
||||
- Remove `.env.agent` file reading (production doesn't need it)
|
||||
- Read `NEXT_PUBLIC_BETTERNAS_API_URL` env var for the backend URL
|
||||
- Use session token from localStorage/cookie instead of static client token
|
||||
|
|
@ -372,7 +397,11 @@ export function clearSessionToken(): void {
|
|||
localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export async function login(apiUrl: string, username: string, password: string): Promise<string> {
|
||||
export async function login(
|
||||
apiUrl: string,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<string> {
|
||||
const res = await fetch(`${apiUrl}/api/v1/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
|
@ -386,6 +415,7 @@ export async function login(apiUrl: string, username: string, password: string):
|
|||
```
|
||||
|
||||
Update `lib/control-plane.ts`:
|
||||
|
||||
```typescript
|
||||
// Replace the current getControlPlaneConfig with:
|
||||
export function getControlPlaneConfig(): ControlPlaneConfig {
|
||||
|
|
@ -396,6 +426,7 @@ export function getControlPlaneConfig(): ControlPlaneConfig {
|
|||
```
|
||||
|
||||
#### 3. Auth-gated layout
|
||||
|
||||
**File**: `apps/web/app/layout.tsx` or a middleware
|
||||
|
||||
Redirect to `/login` if no valid session. The `/login` and `/register` pages are public.
|
||||
|
|
@ -403,11 +434,13 @@ Redirect to `/login` if no valid session. The `/login` and `/register` pages are
|
|||
### Success Criteria
|
||||
|
||||
#### Automated Verification:
|
||||
|
||||
- [ ] CORS preflight (OPTIONS) returns correct headers
|
||||
- [ ] Frontend builds: `cd apps/web && pnpm build`
|
||||
- [ ] No TypeScript errors
|
||||
|
||||
#### Manual Verification:
|
||||
|
||||
- [ ] Open `betternas.com` (or localhost:3000) - redirected to login
|
||||
- [ ] Register a new account, login, see exports dashboard
|
||||
- [ ] Click an export, get mount credentials
|
||||
|
|
@ -419,20 +452,25 @@ Redirect to `/login` if no valid session. The `/login` and `/register` pages are
|
|||
## Phase 4: NixOS Deployment (netty)
|
||||
|
||||
### Overview
|
||||
|
||||
Deploy the control-plane as a NixOS-managed systemd service on netty, behind NGINX with ACME TLS at `api.betternas.com`. Stop the Docker Compose stack.
|
||||
|
||||
### Changes Required
|
||||
|
||||
#### 1. DNS: Point `api.betternas.com` to netty
|
||||
|
||||
Run from local machine (Vercel CLI):
|
||||
|
||||
```bash
|
||||
vercel dns add betternas.com api A 152.53.195.59
|
||||
```
|
||||
|
||||
#### 2. Build the Go binary for Linux
|
||||
|
||||
**File**: `apps/control-plane/Dockerfile` (or local cross-compile)
|
||||
|
||||
For NixOS, we can either:
|
||||
|
||||
- (a) Cross-compile locally: `GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o control-plane ./cmd/control-plane`
|
||||
- (b) Build a Nix package (cleaner, but more work)
|
||||
- (c) Build on netty directly from the git repo
|
||||
|
|
@ -440,6 +478,7 @@ For NixOS, we can either:
|
|||
Recommendation: **(c) Build on netty** from the cloned repo. Simple, works now. Add a Nix package later if desired.
|
||||
|
||||
#### 3. NixOS configuration changes
|
||||
|
||||
**File**: `/home/rathi/Documents/GitHub/nix/hosts/netty/configuration.nix`
|
||||
|
||||
Add these blocks (following the existing forgejo/vaultwarden pattern):
|
||||
|
|
@ -479,6 +518,7 @@ Add these blocks (following the existing forgejo/vaultwarden pattern):
|
|||
```
|
||||
|
||||
#### 4. Environment file on netty
|
||||
|
||||
**File**: `/var/lib/betternas/control-plane/control-plane.env`
|
||||
|
||||
```bash
|
||||
|
|
@ -496,6 +536,7 @@ BETTERNAS_NODE_DIRECT_ADDRESS=https://api.betternas.com
|
|||
```
|
||||
|
||||
#### 5. Build and deploy script
|
||||
|
||||
**File**: `apps/control-plane/scripts/deploy-netty.sh`
|
||||
|
||||
```bash
|
||||
|
|
@ -516,7 +557,9 @@ ssh "$REMOTE" "cd $REPO && git pull && \
|
|||
```
|
||||
|
||||
#### 6. Stop Docker Compose stack
|
||||
|
||||
After the systemd service is running and verified:
|
||||
|
||||
```bash
|
||||
ssh netty 'bash -c "cd /home/rathi/Documents/GitHub/betterNAS/betterNAS && source scripts/lib/runtime-env.sh && compose down"'
|
||||
```
|
||||
|
|
@ -524,12 +567,14 @@ ssh netty 'bash -c "cd /home/rathi/Documents/GitHub/betterNAS/betterNAS && sourc
|
|||
### Success Criteria
|
||||
|
||||
#### Automated Verification:
|
||||
|
||||
- [ ] `curl https://api.betternas.com/health` returns `ok`
|
||||
- [ ] `curl https://api.betternas.com/version` returns version JSON
|
||||
- [ ] TLS certificate is valid (Let's Encrypt)
|
||||
- [ ] `systemctl status betternas-control-plane` shows active
|
||||
|
||||
#### Manual Verification:
|
||||
|
||||
- [ ] Node-agent can register against `https://api.betternas.com`
|
||||
- [ ] Mount credentials issued via the API work in Finder
|
||||
- [ ] Service survives restart: `sudo systemctl restart betternas-control-plane`
|
||||
|
|
@ -540,44 +585,52 @@ ssh netty 'bash -c "cd /home/rathi/Documents/GitHub/betterNAS/betterNAS && sourc
|
|||
## Phase 5: Vercel Deployment
|
||||
|
||||
### Overview
|
||||
|
||||
Deploy the Next.js web UI to Vercel at `betternas.com`.
|
||||
|
||||
### Changes Required
|
||||
|
||||
#### 1. Create Vercel project
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
vercel link # or vercel --yes
|
||||
```
|
||||
|
||||
#### 2. Configure environment variables on Vercel
|
||||
|
||||
```bash
|
||||
vercel env add NEXT_PUBLIC_BETTERNAS_API_URL production
|
||||
# Value: https://api.betternas.com
|
||||
```
|
||||
|
||||
#### 3. Configure domain
|
||||
|
||||
```bash
|
||||
vercel domains add betternas.com
|
||||
# Already have wildcard ALIAS to vercel-dns, so this should work
|
||||
```
|
||||
|
||||
#### 4. Deploy
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
vercel --prod
|
||||
```
|
||||
|
||||
#### 5. Verify CORS
|
||||
|
||||
The backend at `api.betternas.com` must have `BETTERNAS_CORS_ORIGIN=https://betternas.com` set (done in Phase 4).
|
||||
|
||||
### Success Criteria
|
||||
|
||||
#### Automated Verification:
|
||||
|
||||
- [ ] `curl -I https://betternas.com` returns 200
|
||||
- [ ] CORS preflight from `betternas.com` to `api.betternas.com` succeeds
|
||||
|
||||
#### Manual Verification:
|
||||
|
||||
- [ ] Visit `betternas.com` - see login page
|
||||
- [ ] Register, login, see exports, issue mount credentials
|
||||
- [ ] Mount from Finder using issued credentials
|
||||
|
|
@ -600,16 +653,19 @@ This is a follow-up task, not part of the initial deployment.
|
|||
## Testing Strategy
|
||||
|
||||
### Unit Tests (Go):
|
||||
|
||||
- SQLite store: CRUD operations, transactions, concurrent access
|
||||
- Auth: registration, login, session validation, expiry, logout
|
||||
- Migration: schema creates cleanly on empty DB
|
||||
|
||||
### Integration Tests:
|
||||
|
||||
- Full API flow: register user -> login -> list exports -> issue mount profile
|
||||
- Node registration + export sync against SQLite store
|
||||
- Session expiry and cleanup
|
||||
|
||||
### Manual Testing:
|
||||
|
||||
1. Fresh deploy: start control-plane with empty DB
|
||||
2. Register first user via API
|
||||
3. Login from web UI
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue