mirror of
https://github.com/harivansh-afk/betterNAS.git
synced 2026-04-15 13:03:43 +00:00
Make control-plane the real mount authority
Split node enrollment from export sync and issue Finder-compatible DAV credentials so the stack proves the real backend seam before any web UI consumes it.
This commit is contained in:
parent
5bc24fa99d
commit
b5f8ea9c52
28 changed files with 1345 additions and 423 deletions
|
|
@ -1,8 +1,6 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
|
@ -19,11 +17,15 @@ const (
|
|||
)
|
||||
|
||||
type appConfig struct {
|
||||
exportPaths []string
|
||||
exportPaths []string
|
||||
nodeID string
|
||||
davAuthSecret string
|
||||
}
|
||||
|
||||
type app struct {
|
||||
exportMounts []exportMount
|
||||
nodeID string
|
||||
davAuthSecret string
|
||||
exportMounts []exportMount
|
||||
}
|
||||
|
||||
type exportMount struct {
|
||||
|
|
@ -32,12 +34,26 @@ type exportMount struct {
|
|||
}
|
||||
|
||||
func newApp(config appConfig) (*app, error) {
|
||||
config.nodeID = strings.TrimSpace(config.nodeID)
|
||||
if config.nodeID == "" {
|
||||
return nil, errors.New("nodeID is required")
|
||||
}
|
||||
|
||||
config.davAuthSecret = strings.TrimSpace(config.davAuthSecret)
|
||||
if config.davAuthSecret == "" {
|
||||
return nil, errors.New("davAuthSecret is required")
|
||||
}
|
||||
|
||||
exportMounts, err := buildExportMounts(config.exportPaths)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &app{exportMounts: exportMounts}, nil
|
||||
return &app{
|
||||
nodeID: config.nodeID,
|
||||
davAuthSecret: config.davAuthSecret,
|
||||
exportMounts: exportMounts,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newAppFromEnv() (*app, error) {
|
||||
|
|
@ -46,7 +62,25 @@ func newAppFromEnv() (*app, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
return newApp(appConfig{exportPaths: exportPaths})
|
||||
davAuthSecret, err := requiredEnv("BETTERNAS_DAV_AUTH_SECRET")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nodeID := strings.TrimSpace(env("BETTERNAS_NODE_ID", ""))
|
||||
if strings.TrimSpace(env("BETTERNAS_CONTROL_PLANE_URL", "")) != "" {
|
||||
bootstrapResult, err := bootstrapNodeAgentFromEnv(exportPaths)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodeID = bootstrapResult.nodeID
|
||||
}
|
||||
|
||||
return newApp(appConfig{
|
||||
exportPaths: exportPaths,
|
||||
nodeID: nodeID,
|
||||
davAuthSecret: davAuthSecret,
|
||||
})
|
||||
}
|
||||
|
||||
func exportPathsFromEnv() ([]string, error) {
|
||||
|
|
@ -126,12 +160,38 @@ func (a *app) handler() http.Handler {
|
|||
FileSystem: webdav.Dir(mount.exportPath),
|
||||
LockSystem: webdav.NewMemLS(),
|
||||
}
|
||||
mux.Handle(mount.mountPath, dav)
|
||||
mux.Handle(mount.mountPath, a.requireDAVAuth(mount, dav))
|
||||
}
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
func (a *app) requireDAVAuth(mount exportMount, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
username, password, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
writeDAVUnauthorized(w)
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := verifyMountCredential(a.davAuthSecret, password)
|
||||
if err != nil {
|
||||
writeDAVUnauthorized(w)
|
||||
return
|
||||
}
|
||||
if claims.NodeID != a.nodeID || claims.MountPath != mount.mountPath || claims.Username != username {
|
||||
writeDAVUnauthorized(w)
|
||||
return
|
||||
}
|
||||
if claims.Readonly && !isDAVReadMethod(r.Method) {
|
||||
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func mountProfilePathForExport(exportPath string, exportCount int) string {
|
||||
// Keep /dav/ stable for the common single-export case while exposing distinct
|
||||
// scoped roots when a node serves more than one export.
|
||||
|
|
@ -147,6 +207,5 @@ func scopedMountPathForExport(exportPath string) string {
|
|||
}
|
||||
|
||||
func exportRouteSlug(exportPath string) string {
|
||||
sum := sha256.Sum256([]byte(strings.TrimSpace(exportPath)))
|
||||
return hex.EncodeToString(sum[:])
|
||||
return stableExportRouteSlug(exportPath)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,31 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSingleExportServesDefaultAndScopedMountPaths(t *testing.T) {
|
||||
const testDAVAuthSecret = "test-dav-auth-secret"
|
||||
|
||||
func TestSingleExportServesDefaultAndScopedMountPathsWithValidCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
exportDir := t.TempDir()
|
||||
writeExportFile(t, exportDir, "README.txt", "single export\n")
|
||||
|
||||
app, err := newApp(appConfig{exportPaths: []string{exportDir}})
|
||||
app, err := newApp(appConfig{
|
||||
exportPaths: []string{exportDir},
|
||||
nodeID: "node-1",
|
||||
davAuthSecret: testDAVAuthSecret,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("new app: %v", err)
|
||||
}
|
||||
|
|
@ -23,13 +33,17 @@ func TestSingleExportServesDefaultAndScopedMountPaths(t *testing.T) {
|
|||
server := httptest.NewServer(app.handler())
|
||||
defer server.Close()
|
||||
|
||||
assertHTTPStatus(t, server.Client(), "PROPFIND", server.URL+defaultWebDAVPath, http.StatusMultiStatus)
|
||||
assertHTTPStatus(t, server.Client(), "PROPFIND", server.URL+scopedMountPathForExport(exportDir), http.StatusMultiStatus)
|
||||
assertMountedFileContents(t, server.Client(), server.URL+defaultWebDAVPath+"README.txt", "single export\n")
|
||||
assertMountedFileContents(t, server.Client(), server.URL+scopedMountPathForExport(exportDir)+"README.txt", "single export\n")
|
||||
defaultUsername, defaultPassword := issueTestMountCredential(t, "node-1", defaultWebDAVPath, false)
|
||||
scopedMountPath := scopedMountPathForExport(exportDir)
|
||||
scopedUsername, scopedPassword := issueTestMountCredential(t, "node-1", scopedMountPath, false)
|
||||
|
||||
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+defaultWebDAVPath, defaultUsername, defaultPassword, http.StatusMultiStatus)
|
||||
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+scopedMountPath, scopedUsername, scopedPassword, http.StatusMultiStatus)
|
||||
assertMountedFileContentsWithBasicAuth(t, server.Client(), server.URL+defaultWebDAVPath+"README.txt", defaultUsername, defaultPassword, "single export\n")
|
||||
assertMountedFileContentsWithBasicAuth(t, server.Client(), server.URL+scopedMountPath+"README.txt", scopedUsername, scopedPassword, "single export\n")
|
||||
}
|
||||
|
||||
func TestMultipleExportsServeDistinctScopedMountPaths(t *testing.T) {
|
||||
func TestMultipleExportsServeDistinctScopedMountPathsWithValidCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
firstExportDir := t.TempDir()
|
||||
|
|
@ -37,7 +51,11 @@ func TestMultipleExportsServeDistinctScopedMountPaths(t *testing.T) {
|
|||
writeExportFile(t, firstExportDir, "README.txt", "first export\n")
|
||||
writeExportFile(t, secondExportDir, "README.txt", "second export\n")
|
||||
|
||||
app, err := newApp(appConfig{exportPaths: []string{firstExportDir, secondExportDir}})
|
||||
app, err := newApp(appConfig{
|
||||
exportPaths: []string{firstExportDir, secondExportDir},
|
||||
nodeID: "node-1",
|
||||
davAuthSecret: testDAVAuthSecret,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("new app: %v", err)
|
||||
}
|
||||
|
|
@ -51,10 +69,13 @@ func TestMultipleExportsServeDistinctScopedMountPaths(t *testing.T) {
|
|||
t.Fatal("expected distinct mount paths for multiple exports")
|
||||
}
|
||||
|
||||
assertHTTPStatus(t, server.Client(), "PROPFIND", server.URL+firstMountPath, http.StatusMultiStatus)
|
||||
assertHTTPStatus(t, server.Client(), "PROPFIND", server.URL+secondMountPath, http.StatusMultiStatus)
|
||||
assertMountedFileContents(t, server.Client(), server.URL+firstMountPath+"README.txt", "first export\n")
|
||||
assertMountedFileContents(t, server.Client(), server.URL+secondMountPath+"README.txt", "second export\n")
|
||||
firstUsername, firstPassword := issueTestMountCredential(t, "node-1", firstMountPath, false)
|
||||
secondUsername, secondPassword := issueTestMountCredential(t, "node-1", secondMountPath, false)
|
||||
|
||||
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+firstMountPath, firstUsername, firstPassword, http.StatusMultiStatus)
|
||||
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+secondMountPath, secondUsername, secondPassword, http.StatusMultiStatus)
|
||||
assertMountedFileContentsWithBasicAuth(t, server.Client(), server.URL+firstMountPath+"README.txt", firstUsername, firstPassword, "first export\n")
|
||||
assertMountedFileContentsWithBasicAuth(t, server.Client(), server.URL+secondMountPath+"README.txt", secondUsername, secondPassword, "second export\n")
|
||||
|
||||
response, err := server.Client().Get(server.URL + defaultWebDAVPath)
|
||||
if err != nil {
|
||||
|
|
@ -66,6 +87,48 @@ func TestMultipleExportsServeDistinctScopedMountPaths(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestDAVAuthRejectsMissingInvalidAndReadonlyCredentials(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
exportDir := t.TempDir()
|
||||
writeExportFile(t, exportDir, "README.txt", "readonly export\n")
|
||||
|
||||
app, err := newApp(appConfig{
|
||||
exportPaths: []string{exportDir},
|
||||
nodeID: "node-1",
|
||||
davAuthSecret: testDAVAuthSecret,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("new app: %v", err)
|
||||
}
|
||||
|
||||
server := httptest.NewServer(app.handler())
|
||||
defer server.Close()
|
||||
|
||||
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+defaultWebDAVPath, "", "", http.StatusUnauthorized)
|
||||
|
||||
wrongMountUsername, wrongMountPassword := issueTestMountCredential(t, "node-1", "/dav/wrong/", false)
|
||||
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+defaultWebDAVPath, wrongMountUsername, wrongMountPassword, http.StatusUnauthorized)
|
||||
|
||||
expiredUsername, expiredPassword := issueExpiredTestMountCredential(t, "node-1", defaultWebDAVPath, false)
|
||||
assertHTTPStatusWithBasicAuth(t, server.Client(), "PROPFIND", server.URL+defaultWebDAVPath, expiredUsername, expiredPassword, http.StatusUnauthorized)
|
||||
|
||||
readonlyUsername, readonlyPassword := issueTestMountCredential(t, "node-1", defaultWebDAVPath, true)
|
||||
request, err := http.NewRequest(http.MethodPut, server.URL+defaultWebDAVPath+"README.txt", strings.NewReader("updated\n"))
|
||||
if err != nil {
|
||||
t.Fatalf("build PUT request: %v", err)
|
||||
}
|
||||
request.SetBasicAuth(readonlyUsername, readonlyPassword)
|
||||
response, err := server.Client().Do(request)
|
||||
if err != nil {
|
||||
t.Fatalf("PUT %s: %v", request.URL.String(), err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("expected readonly credential to return 403, got %d", response.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExportMountsRejectsInvalidConfigs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
|
@ -80,13 +143,16 @@ func TestBuildExportMountsRejectsInvalidConfigs(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func assertHTTPStatus(t *testing.T, client *http.Client, method string, endpoint string, expectedStatus int) {
|
||||
func assertHTTPStatusWithBasicAuth(t *testing.T, client *http.Client, method string, endpoint string, username string, password string, expectedStatus int) {
|
||||
t.Helper()
|
||||
|
||||
request, err := http.NewRequest(method, endpoint, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("build %s request for %s: %v", method, endpoint, err)
|
||||
}
|
||||
if username != "" || password != "" {
|
||||
request.SetBasicAuth(username, password)
|
||||
}
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
|
|
@ -99,10 +165,16 @@ func assertHTTPStatus(t *testing.T, client *http.Client, method string, endpoint
|
|||
}
|
||||
}
|
||||
|
||||
func assertMountedFileContents(t *testing.T, client *http.Client, endpoint string, expected string) {
|
||||
func assertMountedFileContentsWithBasicAuth(t *testing.T, client *http.Client, endpoint string, username string, password string, expected string) {
|
||||
t.Helper()
|
||||
|
||||
response, err := client.Get(endpoint)
|
||||
request, err := http.NewRequest(http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("build GET request for %s: %v", endpoint, err)
|
||||
}
|
||||
request.SetBasicAuth(username, password)
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatalf("get %s: %v", endpoint, err)
|
||||
}
|
||||
|
|
@ -121,6 +193,54 @@ func assertMountedFileContents(t *testing.T, client *http.Client, endpoint strin
|
|||
}
|
||||
}
|
||||
|
||||
func issueTestMountCredential(t *testing.T, nodeID string, mountPath string, readonly bool) (string, string) {
|
||||
t.Helper()
|
||||
|
||||
claims := signedMountCredentialClaims{
|
||||
Version: 1,
|
||||
NodeID: nodeID,
|
||||
MountPath: mountPath,
|
||||
Username: "mount-test-user",
|
||||
Readonly: readonly,
|
||||
ExpiresAt: time.Now().UTC().Add(time.Hour).Format(time.RFC3339),
|
||||
}
|
||||
password, err := encodeTestMountCredential(claims)
|
||||
if err != nil {
|
||||
t.Fatalf("issue test mount credential: %v", err)
|
||||
}
|
||||
|
||||
return claims.Username, password
|
||||
}
|
||||
|
||||
func issueExpiredTestMountCredential(t *testing.T, nodeID string, mountPath string, readonly bool) (string, string) {
|
||||
t.Helper()
|
||||
|
||||
claims := signedMountCredentialClaims{
|
||||
Version: 1,
|
||||
NodeID: nodeID,
|
||||
MountPath: mountPath,
|
||||
Username: "mount-expired-user",
|
||||
Readonly: readonly,
|
||||
ExpiresAt: time.Now().UTC().Add(-time.Minute).Format(time.RFC3339),
|
||||
}
|
||||
password, err := encodeTestMountCredential(claims)
|
||||
if err != nil {
|
||||
t.Fatalf("issue expired test mount credential: %v", err)
|
||||
}
|
||||
|
||||
return claims.Username, password
|
||||
}
|
||||
|
||||
func encodeTestMountCredential(claims signedMountCredentialClaims) (string, error) {
|
||||
payload, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
encodedPayload := base64.RawURLEncoding.EncodeToString(payload)
|
||||
return encodedPayload + "." + signMountCredentialPayload(testDAVAuthSecret, encodedPayload), nil
|
||||
}
|
||||
|
||||
func writeExportFile(t *testing.T, directory string, name string, contents string) {
|
||||
t.Helper()
|
||||
|
||||
|
|
|
|||
299
apps/node-agent/cmd/node-agent/control_plane.go
Normal file
299
apps/node-agent/cmd/node-agent/control_plane.go
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const controlPlaneNodeTokenHeader = "X-BetterNAS-Node-Token"
|
||||
|
||||
type bootstrapResult struct {
|
||||
nodeID string
|
||||
}
|
||||
|
||||
type nodeRegistrationRequest struct {
|
||||
MachineID string `json:"machineId"`
|
||||
DisplayName string `json:"displayName"`
|
||||
AgentVersion string `json:"agentVersion"`
|
||||
DirectAddress *string `json:"directAddress"`
|
||||
RelayAddress *string `json:"relayAddress"`
|
||||
}
|
||||
|
||||
type nodeRegistrationResponse struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type nodeExportsRequest struct {
|
||||
Exports []storageExportInput `json:"exports"`
|
||||
}
|
||||
|
||||
type storageExportInput struct {
|
||||
Label string `json:"label"`
|
||||
Path string `json:"path"`
|
||||
MountPath string `json:"mountPath"`
|
||||
Protocols []string `json:"protocols"`
|
||||
CapacityBytes *int64 `json:"capacityBytes"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
|
||||
type nodeHeartbeatRequest struct {
|
||||
NodeID string `json:"nodeId"`
|
||||
Status string `json:"status"`
|
||||
LastSeenAt string `json:"lastSeenAt"`
|
||||
}
|
||||
|
||||
func bootstrapNodeAgentFromEnv(exportPaths []string) (bootstrapResult, error) {
|
||||
controlPlaneURL, err := requiredEnv("BETTERNAS_CONTROL_PLANE_URL")
|
||||
if err != nil {
|
||||
return bootstrapResult{}, err
|
||||
}
|
||||
|
||||
bootstrapToken, err := requiredEnv("BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN")
|
||||
if err != nil {
|
||||
return bootstrapResult{}, err
|
||||
}
|
||||
|
||||
nodeTokenPath, err := requiredEnv("BETTERNAS_NODE_TOKEN_PATH")
|
||||
if err != nil {
|
||||
return bootstrapResult{}, err
|
||||
}
|
||||
|
||||
machineID, err := requiredEnv("BETTERNAS_NODE_MACHINE_ID")
|
||||
if err != nil {
|
||||
return bootstrapResult{}, err
|
||||
}
|
||||
|
||||
displayName := strings.TrimSpace(env("BETTERNAS_NODE_DISPLAY_NAME", machineID))
|
||||
if displayName == "" {
|
||||
displayName = machineID
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
nodeToken, err := readNodeToken(nodeTokenPath)
|
||||
if err != nil {
|
||||
return bootstrapResult{}, err
|
||||
}
|
||||
|
||||
authToken := nodeToken
|
||||
if authToken == "" {
|
||||
authToken = bootstrapToken
|
||||
}
|
||||
|
||||
registration, issuedNodeToken, err := registerNodeWithControlPlane(client, controlPlaneURL, authToken, nodeRegistrationRequest{
|
||||
MachineID: machineID,
|
||||
DisplayName: displayName,
|
||||
AgentVersion: env("BETTERNAS_VERSION", "0.1.0-dev"),
|
||||
DirectAddress: optionalEnvPointer("BETTERNAS_NODE_DIRECT_ADDRESS"),
|
||||
RelayAddress: optionalEnvPointer("BETTERNAS_NODE_RELAY_ADDRESS"),
|
||||
})
|
||||
if err != nil {
|
||||
return bootstrapResult{}, err
|
||||
}
|
||||
|
||||
if strings.TrimSpace(issuedNodeToken) != "" {
|
||||
if err := writeNodeToken(nodeTokenPath, issuedNodeToken); err != nil {
|
||||
return bootstrapResult{}, err
|
||||
}
|
||||
authToken = issuedNodeToken
|
||||
}
|
||||
|
||||
if err := syncNodeExportsWithControlPlane(client, controlPlaneURL, authToken, registration.ID, buildStorageExportInputs(exportPaths)); err != nil {
|
||||
return bootstrapResult{}, err
|
||||
}
|
||||
if err := sendNodeHeartbeat(client, controlPlaneURL, authToken, registration.ID); err != nil {
|
||||
return bootstrapResult{}, err
|
||||
}
|
||||
|
||||
return bootstrapResult{nodeID: registration.ID}, nil
|
||||
}
|
||||
|
||||
func registerNodeWithControlPlane(client *http.Client, baseURL string, token string, payload nodeRegistrationRequest) (nodeRegistrationResponse, string, error) {
|
||||
response, err := doControlPlaneJSONRequest(client, http.MethodPost, controlPlaneEndpoint(baseURL, "/api/v1/nodes/register"), token, payload)
|
||||
if err != nil {
|
||||
return nodeRegistrationResponse{}, "", err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nodeRegistrationResponse{}, "", controlPlaneResponseError("register node", response)
|
||||
}
|
||||
|
||||
var registration nodeRegistrationResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(®istration); err != nil {
|
||||
return nodeRegistrationResponse{}, "", fmt.Errorf("decode register node response: %w", err)
|
||||
}
|
||||
|
||||
return registration, strings.TrimSpace(response.Header.Get(controlPlaneNodeTokenHeader)), nil
|
||||
}
|
||||
|
||||
func syncNodeExportsWithControlPlane(client *http.Client, baseURL string, token string, nodeID string, exports []storageExportInput) error {
|
||||
response, err := doControlPlaneJSONRequest(client, http.MethodPut, controlPlaneEndpoint(baseURL, "/api/v1/nodes/"+nodeID+"/exports"), token, nodeExportsRequest{
|
||||
Exports: exports,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return controlPlaneResponseError("sync node exports", response)
|
||||
}
|
||||
|
||||
_, _ = io.Copy(io.Discard, response.Body)
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendNodeHeartbeat(client *http.Client, baseURL string, token string, nodeID string) error {
|
||||
response, err := doControlPlaneJSONRequest(client, http.MethodPost, controlPlaneEndpoint(baseURL, "/api/v1/nodes/"+nodeID+"/heartbeat"), token, nodeHeartbeatRequest{
|
||||
NodeID: nodeID,
|
||||
Status: "online",
|
||||
LastSeenAt: time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode != http.StatusNoContent {
|
||||
return controlPlaneResponseError("send node heartbeat", response)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func doControlPlaneJSONRequest(client *http.Client, method string, endpoint string, token string, payload any) (*http.Response, error) {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal %s %s payload: %w", method, endpoint, err)
|
||||
}
|
||||
|
||||
request, err := http.NewRequest(method, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build %s %s request: %w", method, endpoint, err)
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s %s: %w", method, endpoint, err)
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func controlPlaneEndpoint(baseURL string, suffix string) string {
|
||||
return strings.TrimRight(strings.TrimSpace(baseURL), "/") + suffix
|
||||
}
|
||||
|
||||
func controlPlaneResponseError(action string, response *http.Response) error {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("%s: unexpected status %d: %s", action, response.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
|
||||
func buildStorageExportInputs(exportPaths []string) []storageExportInput {
|
||||
inputs := make([]storageExportInput, len(exportPaths))
|
||||
for index, exportPath := range exportPaths {
|
||||
inputs[index] = storageExportInput{
|
||||
Label: exportLabel(exportPath),
|
||||
Path: strings.TrimSpace(exportPath),
|
||||
MountPath: mountProfilePathForExport(exportPath, len(exportPaths)),
|
||||
Protocols: []string{"webdav"},
|
||||
CapacityBytes: nil,
|
||||
Tags: []string{},
|
||||
}
|
||||
}
|
||||
|
||||
return inputs
|
||||
}
|
||||
|
||||
func exportLabel(exportPath string) string {
|
||||
base := filepath.Base(filepath.Clean(strings.TrimSpace(exportPath)))
|
||||
if base == "" || base == "." || base == string(filepath.Separator) {
|
||||
return "export"
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
|
||||
func stableExportRouteSlug(exportPath string) string {
|
||||
sum := sha256.Sum256([]byte(strings.TrimSpace(exportPath)))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func requiredEnv(key string) (string, error) {
|
||||
value, ok := os.LookupEnv(key)
|
||||
if !ok || strings.TrimSpace(value) == "" {
|
||||
return "", fmt.Errorf("%s is required", key)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(value), nil
|
||||
}
|
||||
|
||||
func optionalEnvPointer(key string) *string {
|
||||
value := strings.TrimSpace(env(key, ""))
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &value
|
||||
}
|
||||
|
||||
func readNodeToken(path string) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("read node token %s: %w", path, err)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(string(data)), nil
|
||||
}
|
||||
|
||||
func writeNodeToken(path string, token string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return fmt.Errorf("create node token directory %s: %w", filepath.Dir(path), err)
|
||||
}
|
||||
|
||||
tempFile, err := os.CreateTemp(filepath.Dir(path), ".node-token-*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create node token temp file in %s: %w", filepath.Dir(path), err)
|
||||
}
|
||||
|
||||
tempFilePath := tempFile.Name()
|
||||
cleanupTempFile := true
|
||||
defer func() {
|
||||
if cleanupTempFile {
|
||||
_ = os.Remove(tempFilePath)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := tempFile.Chmod(0o600); err != nil {
|
||||
_ = tempFile.Close()
|
||||
return fmt.Errorf("chmod node token temp file %s: %w", tempFilePath, err)
|
||||
}
|
||||
if _, err := tempFile.WriteString(strings.TrimSpace(token) + "\n"); err != nil {
|
||||
_ = tempFile.Close()
|
||||
return fmt.Errorf("write node token temp file %s: %w", tempFilePath, err)
|
||||
}
|
||||
if err := tempFile.Close(); err != nil {
|
||||
return fmt.Errorf("close node token temp file %s: %w", tempFilePath, err)
|
||||
}
|
||||
if err := os.Rename(tempFilePath, path); err != nil {
|
||||
return fmt.Errorf("replace node token %s: %w", path, err)
|
||||
}
|
||||
|
||||
cleanupTempFile = false
|
||||
return nil
|
||||
}
|
||||
74
apps/node-agent/cmd/node-agent/dav_auth.go
Normal file
74
apps/node-agent/cmd/node-agent/dav_auth.go
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type signedMountCredentialClaims struct {
|
||||
Version int `json:"v"`
|
||||
NodeID string `json:"nodeId"`
|
||||
MountPath string `json:"mountPath"`
|
||||
Username string `json:"username"`
|
||||
Readonly bool `json:"readonly"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
}
|
||||
|
||||
func verifyMountCredential(secret string, token string) (signedMountCredentialClaims, error) {
|
||||
encodedPayload, signature, ok := strings.Cut(strings.TrimSpace(token), ".")
|
||||
if !ok || encodedPayload == "" || signature == "" {
|
||||
return signedMountCredentialClaims{}, errors.New("invalid mount credential")
|
||||
}
|
||||
|
||||
expectedSignature := signMountCredentialPayload(secret, encodedPayload)
|
||||
if subtle.ConstantTimeCompare([]byte(expectedSignature), []byte(signature)) != 1 {
|
||||
return signedMountCredentialClaims{}, errors.New("invalid mount credential")
|
||||
}
|
||||
|
||||
payload, err := base64.RawURLEncoding.DecodeString(encodedPayload)
|
||||
if err != nil {
|
||||
return signedMountCredentialClaims{}, errors.New("invalid mount credential")
|
||||
}
|
||||
|
||||
var claims signedMountCredentialClaims
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
return signedMountCredentialClaims{}, errors.New("invalid mount credential")
|
||||
}
|
||||
if claims.Version != 1 || claims.NodeID == "" || claims.MountPath == "" || claims.Username == "" || claims.ExpiresAt == "" {
|
||||
return signedMountCredentialClaims{}, errors.New("invalid mount credential")
|
||||
}
|
||||
|
||||
expiresAt, err := time.Parse(time.RFC3339, claims.ExpiresAt)
|
||||
if err != nil || time.Now().UTC().After(expiresAt) {
|
||||
return signedMountCredentialClaims{}, errors.New("invalid mount credential")
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func signMountCredentialPayload(secret string, encodedPayload string) string {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(encodedPayload))
|
||||
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
func writeDAVUnauthorized(w http.ResponseWriter) {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="betterNAS"`)
|
||||
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
func isDAVReadMethod(method string) bool {
|
||||
switch method {
|
||||
case http.MethodGet, http.MethodHead, http.MethodOptions, "PROPFIND":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue