user-owned DAVs

This commit is contained in:
Harivansh Rathi 2026-04-01 20:26:15 -04:00
parent ca5014750b
commit 0a3234d617
35 changed files with 732 additions and 777 deletions

View file

@ -9,6 +9,9 @@ For the scaffold it does two things:
- optionally serves multiple configured exports at deterministic `/dav/exports/<slug>/` paths via `BETTERNAS_EXPORT_PATHS_JSON`
- registers itself with the control plane and syncs its exports when
`BETTERNAS_CONTROL_PLANE_URL` is configured
- enforces issued WebDAV basic-auth mount credentials
- uses `BETTERNAS_USERNAME` and `BETTERNAS_PASSWORD` both for control-plane login
and for local WebDAV basic auth
This is the first real storage-facing surface in the monorepo.
The user-facing binary should be distributed as `betternas-node`.

View file

@ -1,6 +1,7 @@
package main
import (
"crypto/subtle"
"encoding/json"
"errors"
"fmt"
@ -17,15 +18,15 @@ const (
)
type appConfig struct {
exportPaths []string
nodeID string
davAuthSecret string
exportPaths []string
authUsername string
authPassword string
}
type app struct {
nodeID string
davAuthSecret string
exportMounts []exportMount
authUsername string
authPassword string
exportMounts []exportMount
}
type exportMount struct {
@ -34,14 +35,12 @@ type exportMount struct {
}
func newApp(config appConfig) (*app, error) {
config.nodeID = strings.TrimSpace(config.nodeID)
if config.nodeID == "" {
return nil, errors.New("nodeID is required")
config.authUsername = strings.TrimSpace(config.authUsername)
if config.authUsername == "" {
return nil, errors.New("authUsername is required")
}
config.davAuthSecret = strings.TrimSpace(config.davAuthSecret)
if config.davAuthSecret == "" {
return nil, errors.New("davAuthSecret is required")
if config.authPassword == "" {
return nil, errors.New("authPassword is required")
}
exportMounts, err := buildExportMounts(config.exportPaths)
@ -50,9 +49,9 @@ func newApp(config appConfig) (*app, error) {
}
return &app{
nodeID: config.nodeID,
davAuthSecret: config.davAuthSecret,
exportMounts: exportMounts,
authUsername: config.authUsername,
authPassword: config.authPassword,
exportMounts: exportMounts,
}, nil
}
@ -62,24 +61,24 @@ func newAppFromEnv() (*app, error) {
return nil, err
}
davAuthSecret, err := requiredEnv("BETTERNAS_DAV_AUTH_SECRET")
authUsername, err := requiredEnv("BETTERNAS_USERNAME")
if err != nil {
return nil, err
}
authPassword, err := requiredEnv("BETTERNAS_PASSWORD")
if err != nil {
return nil, err
}
nodeID := strings.TrimSpace(env("BETTERNAS_NODE_ID", ""))
if strings.TrimSpace(env("BETTERNAS_CONTROL_PLANE_URL", "")) != "" {
bootstrapResult, err := bootstrapNodeAgentFromEnv(exportPaths)
if err != nil {
if _, err := bootstrapNodeAgentFromEnv(exportPaths); err != nil {
return nil, err
}
nodeID = bootstrapResult.nodeID
}
return newApp(appConfig{
exportPaths: exportPaths,
nodeID: nodeID,
davAuthSecret: davAuthSecret,
exportPaths: exportPaths,
authUsername: authUsername,
authPassword: authPassword,
})
}
@ -182,25 +181,20 @@ func (a *app) requireDAVAuth(mount exportMount, next http.Handler) http.Handler
writeDAVUnauthorized(w)
return
}
claims, err := verifyMountCredential(a.davAuthSecret, password)
if err != nil {
if !a.matchesAccountCredential(username, password) {
writeDAVUnauthorized(w)
return
}
if claims.NodeID != a.nodeID || claims.MountPath != mount.mountPath || claims.Username != username {
writeDAVUnauthorized(w)
return
}
if claims.Readonly && !isDAVReadMethod(r.Method) {
http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func (a *app) matchesAccountCredential(username string, password string) bool {
return subtle.ConstantTimeCompare([]byte(strings.TrimSpace(username)), []byte(a.authUsername)) == 1 &&
subtle.ConstantTimeCompare([]byte(password), []byte(a.authPassword)) == 1
}
func mountProfilePathForExport(exportPath string, exportCount int) string {
// Keep /dav/ stable for the common single-export case while exposing distinct
// scoped roots when a node serves more than one export.

View file

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

View file

@ -14,8 +14,6 @@ import (
"time"
)
const controlPlaneNodeTokenHeader = "X-BetterNAS-Node-Token"
type bootstrapResult struct {
nodeID string
}
@ -32,6 +30,15 @@ type nodeRegistrationResponse struct {
ID string `json:"id"`
}
type authLoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type authLoginResponse struct {
Token string `json:"token"`
}
type nodeExportsRequest struct {
Exports []storageExportInput `json:"exports"`
}
@ -57,38 +64,28 @@ func bootstrapNodeAgentFromEnv(exportPaths []string) (bootstrapResult, error) {
return bootstrapResult{}, err
}
bootstrapToken, err := requiredEnv("BETTERNAS_CONTROL_PLANE_NODE_BOOTSTRAP_TOKEN")
username, err := requiredEnv("BETTERNAS_USERNAME")
if err != nil {
return bootstrapResult{}, err
}
password, err := requiredEnv("BETTERNAS_PASSWORD")
if err != nil {
return bootstrapResult{}, err
}
nodeTokenPath, err := requiredEnv("BETTERNAS_NODE_TOKEN_PATH")
if err != nil {
return bootstrapResult{}, err
}
machineID, err := requiredEnv("BETTERNAS_NODE_MACHINE_ID")
if err != nil {
return bootstrapResult{}, err
}
displayName := strings.TrimSpace(env("BETTERNAS_NODE_DISPLAY_NAME", machineID))
machineID := strings.TrimSpace(env("BETTERNAS_NODE_MACHINE_ID", defaultNodeMachineID(username)))
displayName := strings.TrimSpace(env("BETTERNAS_NODE_DISPLAY_NAME", defaultNodeDisplayName(machineID)))
if displayName == "" {
displayName = machineID
}
client := &http.Client{Timeout: 5 * time.Second}
nodeToken, err := readNodeToken(nodeTokenPath)
sessionToken, err := loginWithControlPlane(client, controlPlaneURL, username, password)
if err != nil {
return bootstrapResult{}, err
}
authToken := nodeToken
if authToken == "" {
authToken = bootstrapToken
}
registration, issuedNodeToken, err := registerNodeWithControlPlane(client, controlPlaneURL, authToken, nodeRegistrationRequest{
registration, err := registerNodeWithControlPlane(client, controlPlaneURL, sessionToken, nodeRegistrationRequest{
MachineID: machineID,
DisplayName: displayName,
AgentVersion: env("BETTERNAS_VERSION", "0.1.0-dev"),
@ -99,40 +96,58 @@ func bootstrapNodeAgentFromEnv(exportPaths []string) (bootstrapResult, error) {
return bootstrapResult{}, err
}
if strings.TrimSpace(issuedNodeToken) != "" {
if err := writeNodeToken(nodeTokenPath, issuedNodeToken); err != nil {
return bootstrapResult{}, err
}
authToken = issuedNodeToken
}
if err := syncNodeExportsWithControlPlane(client, controlPlaneURL, authToken, registration.ID, buildStorageExportInputs(exportPaths)); err != nil {
if err := syncNodeExportsWithControlPlane(client, controlPlaneURL, sessionToken, registration.ID, buildStorageExportInputs(exportPaths)); err != nil {
return bootstrapResult{}, err
}
if err := sendNodeHeartbeat(client, controlPlaneURL, authToken, registration.ID); err != nil {
if err := sendNodeHeartbeat(client, controlPlaneURL, sessionToken, registration.ID); err != nil {
return bootstrapResult{}, err
}
return bootstrapResult{nodeID: registration.ID}, nil
}
func registerNodeWithControlPlane(client *http.Client, baseURL string, token string, payload nodeRegistrationRequest) (nodeRegistrationResponse, string, error) {
response, err := doControlPlaneJSONRequest(client, http.MethodPost, controlPlaneEndpoint(baseURL, "/api/v1/nodes/register"), token, payload)
func loginWithControlPlane(client *http.Client, baseURL string, username string, password string) (string, error) {
response, err := doControlPlaneJSONRequest(client, http.MethodPost, controlPlaneEndpoint(baseURL, "/api/v1/auth/login"), "", authLoginRequest{
Username: username,
Password: password,
})
if err != nil {
return nodeRegistrationResponse{}, "", err
return "", err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return nodeRegistrationResponse{}, "", controlPlaneResponseError("register node", response)
return "", controlPlaneResponseError("login", response)
}
var auth authLoginResponse
if err := json.NewDecoder(response.Body).Decode(&auth); err != nil {
return "", fmt.Errorf("decode login response: %w", err)
}
if strings.TrimSpace(auth.Token) == "" {
return "", fmt.Errorf("login: missing session token")
}
return strings.TrimSpace(auth.Token), nil
}
func registerNodeWithControlPlane(client *http.Client, baseURL string, token string, payload nodeRegistrationRequest) (nodeRegistrationResponse, error) {
response, err := doControlPlaneJSONRequest(client, http.MethodPost, controlPlaneEndpoint(baseURL, "/api/v1/nodes/register"), token, payload)
if err != nil {
return nodeRegistrationResponse{}, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return nodeRegistrationResponse{}, controlPlaneResponseError("register node", response)
}
var registration nodeRegistrationResponse
if err := json.NewDecoder(response.Body).Decode(&registration); err != nil {
return nodeRegistrationResponse{}, "", fmt.Errorf("decode register node response: %w", err)
return nodeRegistrationResponse{}, fmt.Errorf("decode register node response: %w", err)
}
return registration, strings.TrimSpace(response.Header.Get(controlPlaneNodeTokenHeader)), nil
return registration, nil
}
func syncNodeExportsWithControlPlane(client *http.Client, baseURL string, token string, nodeID string, exports []storageExportInput) error {
@ -181,7 +196,9 @@ func doControlPlaneJSONRequest(client *http.Client, method string, endpoint stri
return nil, fmt.Errorf("build %s %s request: %w", method, endpoint, err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "Bearer "+token)
if strings.TrimSpace(token) != "" {
request.Header.Set("Authorization", "Bearer "+token)
}
response, err := client.Do(request)
if err != nil {
@ -248,52 +265,20 @@ func optionalEnvPointer(key string) *string {
return &value
}
func readNodeToken(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", fmt.Errorf("read node token %s: %w", path, err)
func defaultNodeMachineID(username string) string {
hostname, err := os.Hostname()
if err != nil || strings.TrimSpace(hostname) == "" {
return strings.TrimSpace(username) + "@node"
}
return strings.TrimSpace(string(data)), nil
return strings.TrimSpace(username) + "@" + strings.TrimSpace(hostname)
}
func writeNodeToken(path string, token string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return fmt.Errorf("create node token directory %s: %w", filepath.Dir(path), err)
func defaultNodeDisplayName(machineID string) string {
_, displayName, ok := strings.Cut(strings.TrimSpace(machineID), "@")
if ok && strings.TrimSpace(displayName) != "" {
return strings.TrimSpace(displayName)
}
tempFile, err := os.CreateTemp(filepath.Dir(path), ".node-token-*.tmp")
if err != nil {
return fmt.Errorf("create node token temp file in %s: %w", filepath.Dir(path), err)
}
tempFilePath := tempFile.Name()
cleanupTempFile := true
defer func() {
if cleanupTempFile {
_ = os.Remove(tempFilePath)
}
}()
if err := tempFile.Chmod(0o600); err != nil {
_ = tempFile.Close()
return fmt.Errorf("chmod node token temp file %s: %w", tempFilePath, err)
}
if _, err := tempFile.WriteString(strings.TrimSpace(token) + "\n"); err != nil {
_ = tempFile.Close()
return fmt.Errorf("write node token temp file %s: %w", tempFilePath, err)
}
if err := tempFile.Close(); err != nil {
return fmt.Errorf("close node token temp file %s: %w", tempFilePath, err)
}
if err := os.Rename(tempFilePath, path); err != nil {
return fmt.Errorf("replace node token %s: %w", path, err)
}
cleanupTempFile = false
return nil
return strings.TrimSpace(machineID)
}

View file

@ -1,74 +1,8 @@
package main
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
)
type signedMountCredentialClaims struct {
Version int `json:"v"`
NodeID string `json:"nodeId"`
MountPath string `json:"mountPath"`
Username string `json:"username"`
Readonly bool `json:"readonly"`
ExpiresAt string `json:"expiresAt"`
}
func verifyMountCredential(secret string, token string) (signedMountCredentialClaims, error) {
encodedPayload, signature, ok := strings.Cut(strings.TrimSpace(token), ".")
if !ok || encodedPayload == "" || signature == "" {
return signedMountCredentialClaims{}, errors.New("invalid mount credential")
}
expectedSignature := signMountCredentialPayload(secret, encodedPayload)
if subtle.ConstantTimeCompare([]byte(expectedSignature), []byte(signature)) != 1 {
return signedMountCredentialClaims{}, errors.New("invalid mount credential")
}
payload, err := base64.RawURLEncoding.DecodeString(encodedPayload)
if err != nil {
return signedMountCredentialClaims{}, errors.New("invalid mount credential")
}
var claims signedMountCredentialClaims
if err := json.Unmarshal(payload, &claims); err != nil {
return signedMountCredentialClaims{}, errors.New("invalid mount credential")
}
if claims.Version != 1 || claims.NodeID == "" || claims.MountPath == "" || claims.Username == "" || claims.ExpiresAt == "" {
return signedMountCredentialClaims{}, errors.New("invalid mount credential")
}
expiresAt, err := time.Parse(time.RFC3339, claims.ExpiresAt)
if err != nil || time.Now().UTC().After(expiresAt) {
return signedMountCredentialClaims{}, errors.New("invalid mount credential")
}
return claims, nil
}
func signMountCredentialPayload(secret string, encodedPayload string) string {
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(encodedPayload))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
import "net/http"
func writeDAVUnauthorized(w http.ResponseWriter) {
w.Header().Set("WWW-Authenticate", `Basic realm="betterNAS"`)
http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized)
}
func isDAVReadMethod(method string) bool {
switch method {
case http.MethodGet, http.MethodHead, http.MethodOptions, "PROPFIND":
return true
default:
return false
}
}

View file

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