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:
Harivansh Rathi 2026-04-01 17:46:50 +00:00
parent 5bc24fa99d
commit b5f8ea9c52
28 changed files with 1345 additions and 423 deletions

View file

@ -7,5 +7,8 @@ For the scaffold it does two things:
- serves `GET /health`
- serves a WebDAV export at `/dav/`
- optionally serves multiple configured exports at deterministic `/dav/exports/<slug>/` paths via `BETTERNAS_EXPORT_PATHS_JSON`
- registers itself with the control plane and syncs its exports when
`BETTERNAS_CONTROL_PLANE_URL` is configured
- enforces issued WebDAV basic-auth mount credentials
This is the first real storage-facing surface in the monorepo.

View file

@ -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)
}

View file

@ -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()

View 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(&registration); 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
}

View 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
}
}