mirror of
https://github.com/harivansh-afk/betterNAS.git
synced 2026-04-15 11:02:17 +00:00
Secure first-loop control-plane auth and mount routing.
Protect the control-plane API with explicit bearer auth, add node-scoped registration/heartbeat credentials, and make export mount paths an explicit contract field so mount profiles stay correct across runtimes. Generated with [Devin](https://cli.devin.ai/docs) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
a7f85f4871
commit
ed40da7326
23 changed files with 3676 additions and 124 deletions
|
|
@ -6,5 +6,6 @@ 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`
|
||||
|
||||
This is the first real storage-facing surface in the monorepo.
|
||||
|
|
|
|||
152
apps/node-agent/cmd/node-agent/app.go
Normal file
152
apps/node-agent/cmd/node-agent/app.go
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/webdav"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultWebDAVPath = "/dav/"
|
||||
exportScopedWebDAVPrefix = "/dav/exports/"
|
||||
)
|
||||
|
||||
type appConfig struct {
|
||||
exportPaths []string
|
||||
}
|
||||
|
||||
type app struct {
|
||||
exportMounts []exportMount
|
||||
}
|
||||
|
||||
type exportMount struct {
|
||||
exportPath string
|
||||
mountPath string
|
||||
}
|
||||
|
||||
func newApp(config appConfig) (*app, error) {
|
||||
exportMounts, err := buildExportMounts(config.exportPaths)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &app{exportMounts: exportMounts}, nil
|
||||
}
|
||||
|
||||
func newAppFromEnv() (*app, error) {
|
||||
exportPaths, err := exportPathsFromEnv()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return newApp(appConfig{exportPaths: exportPaths})
|
||||
}
|
||||
|
||||
func exportPathsFromEnv() ([]string, error) {
|
||||
rawValue, _ := os.LookupEnv("BETTERNAS_EXPORT_PATHS_JSON")
|
||||
raw := strings.TrimSpace(rawValue)
|
||||
if raw == "" {
|
||||
return []string{env("BETTERNAS_EXPORT_PATH", ".")}, nil
|
||||
}
|
||||
|
||||
var exportPaths []string
|
||||
if err := json.Unmarshal([]byte(raw), &exportPaths); err != nil {
|
||||
return nil, fmt.Errorf("BETTERNAS_EXPORT_PATHS_JSON must be a JSON array of strings: %w", err)
|
||||
}
|
||||
if len(exportPaths) == 0 {
|
||||
return nil, errors.New("BETTERNAS_EXPORT_PATHS_JSON must not be empty")
|
||||
}
|
||||
|
||||
return exportPaths, nil
|
||||
}
|
||||
|
||||
func buildExportMounts(exportPaths []string) ([]exportMount, error) {
|
||||
if len(exportPaths) == 0 {
|
||||
return nil, errors.New("at least one export path is required")
|
||||
}
|
||||
|
||||
normalizedPaths := make([]string, len(exportPaths))
|
||||
seenPaths := make(map[string]struct{}, len(exportPaths))
|
||||
for index, exportPath := range exportPaths {
|
||||
normalizedPath := strings.TrimSpace(exportPath)
|
||||
if normalizedPath == "" {
|
||||
return nil, fmt.Errorf("exportPaths[%d] is required", index)
|
||||
}
|
||||
if _, ok := seenPaths[normalizedPath]; ok {
|
||||
return nil, fmt.Errorf("exportPaths[%d] must be unique", index)
|
||||
}
|
||||
|
||||
seenPaths[normalizedPath] = struct{}{}
|
||||
normalizedPaths[index] = normalizedPath
|
||||
}
|
||||
|
||||
mounts := make([]exportMount, 0, len(normalizedPaths)+1)
|
||||
if len(normalizedPaths) == 1 {
|
||||
singleExportPath := normalizedPaths[0]
|
||||
mounts = append(mounts, exportMount{
|
||||
exportPath: singleExportPath,
|
||||
mountPath: defaultWebDAVPath,
|
||||
})
|
||||
mounts = append(mounts, exportMount{
|
||||
exportPath: singleExportPath,
|
||||
mountPath: scopedMountPathForExport(singleExportPath),
|
||||
})
|
||||
|
||||
return mounts, nil
|
||||
}
|
||||
|
||||
for _, exportPath := range normalizedPaths {
|
||||
mounts = append(mounts, exportMount{
|
||||
exportPath: exportPath,
|
||||
mountPath: scopedMountPathForExport(exportPath),
|
||||
})
|
||||
}
|
||||
|
||||
return mounts, nil
|
||||
}
|
||||
|
||||
func (a *app) handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte("ok\n"))
|
||||
})
|
||||
|
||||
for _, mount := range a.exportMounts {
|
||||
mountPathPrefix := strings.TrimSuffix(mount.mountPath, "/")
|
||||
dav := &webdav.Handler{
|
||||
Prefix: mountPathPrefix,
|
||||
FileSystem: webdav.Dir(mount.exportPath),
|
||||
LockSystem: webdav.NewMemLS(),
|
||||
}
|
||||
mux.Handle(mount.mountPath, dav)
|
||||
}
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
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.
|
||||
if exportCount <= 1 {
|
||||
return defaultWebDAVPath
|
||||
}
|
||||
|
||||
return scopedMountPathForExport(exportPath)
|
||||
}
|
||||
|
||||
func scopedMountPathForExport(exportPath string) string {
|
||||
return exportScopedWebDAVPrefix + exportRouteSlug(exportPath) + "/"
|
||||
}
|
||||
|
||||
func exportRouteSlug(exportPath string) string {
|
||||
sum := sha256.Sum256([]byte(strings.TrimSpace(exportPath)))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
130
apps/node-agent/cmd/node-agent/app_test.go
Normal file
130
apps/node-agent/cmd/node-agent/app_test.go
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSingleExportServesDefaultAndScopedMountPaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
exportDir := t.TempDir()
|
||||
writeExportFile(t, exportDir, "README.txt", "single export\n")
|
||||
|
||||
app, err := newApp(appConfig{exportPaths: []string{exportDir}})
|
||||
if err != nil {
|
||||
t.Fatalf("new app: %v", err)
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
func TestMultipleExportsServeDistinctScopedMountPaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
firstExportDir := t.TempDir()
|
||||
secondExportDir := t.TempDir()
|
||||
writeExportFile(t, firstExportDir, "README.txt", "first export\n")
|
||||
writeExportFile(t, secondExportDir, "README.txt", "second export\n")
|
||||
|
||||
app, err := newApp(appConfig{exportPaths: []string{firstExportDir, secondExportDir}})
|
||||
if err != nil {
|
||||
t.Fatalf("new app: %v", err)
|
||||
}
|
||||
|
||||
server := httptest.NewServer(app.handler())
|
||||
defer server.Close()
|
||||
|
||||
firstMountPath := mountProfilePathForExport(firstExportDir, 2)
|
||||
secondMountPath := mountProfilePathForExport(secondExportDir, 2)
|
||||
if firstMountPath == secondMountPath {
|
||||
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")
|
||||
|
||||
response, err := server.Client().Get(server.URL + defaultWebDAVPath)
|
||||
if err != nil {
|
||||
t.Fatalf("get default multi-export mount path: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("expected %s to return 404 for multi-export config, got %d", defaultWebDAVPath, response.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildExportMountsRejectsInvalidConfigs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if _, err := buildExportMounts(nil); err == nil {
|
||||
t.Fatal("expected empty export paths to fail")
|
||||
}
|
||||
if _, err := buildExportMounts([]string{" "}); err == nil {
|
||||
t.Fatal("expected blank export path to fail")
|
||||
}
|
||||
if _, err := buildExportMounts([]string{"/srv/docs", "/srv/docs"}); err == nil {
|
||||
t.Fatal("expected duplicate export paths to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func assertHTTPStatus(t *testing.T, client *http.Client, method string, endpoint 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)
|
||||
}
|
||||
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, endpoint, err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode != expectedStatus {
|
||||
t.Fatalf("%s %s: expected status %d, got %d", method, endpoint, expectedStatus, response.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func assertMountedFileContents(t *testing.T, client *http.Client, endpoint string, expected string) {
|
||||
t.Helper()
|
||||
|
||||
response, err := client.Get(endpoint)
|
||||
if err != nil {
|
||||
t.Fatalf("get %s: %v", endpoint, err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
if response.StatusCode != http.StatusOK {
|
||||
t.Fatalf("get %s: expected status 200, got %d", endpoint, response.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s response: %v", endpoint, err)
|
||||
}
|
||||
if string(body) != expected {
|
||||
t.Fatalf("expected %s body %q, got %q", endpoint, expected, string(body))
|
||||
}
|
||||
}
|
||||
|
||||
func writeExportFile(t *testing.T, directory string, name string, contents string) {
|
||||
t.Helper()
|
||||
|
||||
if err := os.WriteFile(filepath.Join(directory, name), []byte(contents), 0o644); err != nil {
|
||||
t.Fatalf("write export file %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -5,34 +5,22 @@ import (
|
|||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/webdav"
|
||||
)
|
||||
|
||||
func main() {
|
||||
port := env("PORT", "8090")
|
||||
exportPath := env("BETTERNAS_EXPORT_PATH", ".")
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte("ok\n"))
|
||||
})
|
||||
|
||||
dav := &webdav.Handler{
|
||||
Prefix: "/dav",
|
||||
FileSystem: webdav.Dir(exportPath),
|
||||
LockSystem: webdav.NewMemLS(),
|
||||
app, err := newAppFromEnv()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
mux.Handle("/dav/", dav)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: ":" + port,
|
||||
Handler: mux,
|
||||
Handler: app.handler(),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
log.Printf("betterNAS node agent serving %s on :%s", exportPath, port)
|
||||
log.Printf("betterNAS node agent listening on :%s", port)
|
||||
log.Fatal(server.ListenAndServe())
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue