mirror of
https://github.com/harivansh-afk/betterNAS.git
synced 2026-04-15 12:03:49 +00:00
user-owned DAVs (#14)
This commit is contained in:
parent
ca5014750b
commit
1bbfb6647d
35 changed files with 732 additions and 777 deletions
|
|
@ -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 ./...",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue