diff --git a/Overlord-Client/.env.example b/Overlord-Client/.env.example index 998b2c3..cc5fac9 100644 --- a/Overlord-Client/.env.example +++ b/Overlord-Client/.env.example @@ -12,6 +12,11 @@ OVERLORD_SERVER=wss://your-server.com:5173 # OVERLORD_TLS_CA=./certs/server.crt OVERLORD_TLS_INSECURE_SKIP_VERIFY=false +# Stronger server identity verification (recommended). Comma-separate the +# current and next SHA-256 SPKI pins during certificate-key rotation. +# Generated agents receive the active in-process server pin automatically. +# OVERLORD_TLS_SPKI_PINS=base64-sha256-current,base64-sha256-next + # Client certificate for mutual TLS (optional, advanced) # OVERLORD_TLS_CLIENT_CERT=./certs/client.crt # OVERLORD_TLS_CLIENT_KEY=./certs/client.key diff --git a/Overlord-Client/cmd/agent/config/config.go b/Overlord-Client/cmd/agent/config/config.go index f75f913..c0cd3a2 100644 --- a/Overlord-Client/cmd/agent/config/config.go +++ b/Overlord-Client/cmd/agent/config/config.go @@ -34,6 +34,7 @@ var DefaultSleepSeconds = "0" var DefaultCriticalProcess = "false" var DefaultFetchPublicIP = "false" var DefaultSecureLogPublicKey = "" +var DefaultTLSSPKIPins = "" const settingsFile = "config/settings.json" const serverIndexFile = "config/server_index.json" @@ -71,6 +72,7 @@ type Config struct { TLSCAPath string TLSClientCert string TLSClientKey string + TLSSPKIPins []string AgentToken string BuildTag string SleepSeconds int @@ -177,13 +179,17 @@ func Load() Config { fetchPublicIP = isTruthy(v) } - tlsInsecureSkipVerify := true + tlsInsecureSkipVerify := false if v := strings.ToLower(strings.TrimSpace(os.Getenv("OVERLORD_TLS_INSECURE_SKIP_VERIFY"))); v != "" { tlsInsecureSkipVerify = v == "true" || v == "1" || v == "yes" } tlsCAPath := strings.TrimSpace(os.Getenv("OVERLORD_TLS_CA")) tlsClientCert := strings.TrimSpace(os.Getenv("OVERLORD_TLS_CLIENT_CERT")) tlsClientKey := strings.TrimSpace(os.Getenv("OVERLORD_TLS_CLIENT_KEY")) + tlsSPKIPins := parseCommaList(firstNonEmpty( + strings.TrimSpace(os.Getenv("OVERLORD_TLS_SPKI_PINS")), + DefaultTLSSPKIPins, + )) agentToken := strings.TrimSpace(os.Getenv("OVERLORD_AGENT_TOKEN")) if agentToken == "" { agentToken = strings.TrimSpace(DefaultAgentToken) @@ -220,6 +226,7 @@ func Load() Config { TLSCAPath: tlsCAPath, TLSClientCert: tlsClientCert, TLSClientKey: tlsClientKey, + TLSSPKIPins: tlsSPKIPins, AgentToken: agentToken, BuildTag: strings.TrimSpace(DefaultBuildTag), SleepSeconds: parseSleepSeconds(DefaultSleepSeconds), @@ -358,6 +365,23 @@ func firstNonEmpty(values ...string) string { return "" } +func parseCommaList(value string) []string { + seen := make(map[string]struct{}) + result := make([]string, 0) + for _, item := range strings.Split(value, ",") { + item = strings.TrimSpace(item) + if item == "" { + continue + } + if _, exists := seen[item]; exists { + continue + } + seen[item] = struct{}{} + result = append(result, item) + } + return result +} + func deriveHWID() string { machineID := platformMachineID() if machineID != "" { diff --git a/Overlord-Client/cmd/agent/config/transport_security_test.go b/Overlord-Client/cmd/agent/config/transport_security_test.go new file mode 100644 index 0000000..599b9ca --- /dev/null +++ b/Overlord-Client/cmd/agent/config/transport_security_test.go @@ -0,0 +1,28 @@ +package config + +import "testing" + +func TestTLSVerificationIsSecureByDefault(t *testing.T) { + originalPins := DefaultTLSSPKIPins + DefaultTLSSPKIPins = "pin-one,pin-two,pin-one" + t.Cleanup(func() { DefaultTLSSPKIPins = originalPins }) + + t.Setenv("OVERLORD_TLS_INSECURE_SKIP_VERIFY", "") + t.Setenv("OVERLORD_TLS_SPKI_PINS", "") + + cfg := Load() + if cfg.TLSInsecureSkipVerify { + t.Fatal("TLS certificate verification must be enabled by default") + } + if len(cfg.TLSSPKIPins) != 2 || cfg.TLSSPKIPins[0] != "pin-one" || cfg.TLSSPKIPins[1] != "pin-two" { + t.Fatalf("unexpected embedded TLS pins: %#v", cfg.TLSSPKIPins) + } +} + +func TestTLSInsecureModeRequiresExplicitOptIn(t *testing.T) { + t.Setenv("OVERLORD_TLS_INSECURE_SKIP_VERIFY", "true") + cfg := Load() + if !cfg.TLSInsecureSkipVerify { + t.Fatal("explicit development opt-out was not honored") + } +} diff --git a/Overlord-Client/cmd/agent/handlers/files.go b/Overlord-Client/cmd/agent/handlers/files.go index 2714e0d..d3f428b 100644 --- a/Overlord-Client/cmd/agent/handlers/files.go +++ b/Overlord-Client/cmd/agent/handlers/files.go @@ -8,8 +8,6 @@ import ( "crypto/md5" "crypto/sha1" "crypto/sha256" - "crypto/tls" - "crypto/x509" "encoding/hex" "errors" "fmt" @@ -29,6 +27,7 @@ import ( "unicode/utf8" agentRuntime "overlord-client/cmd/agent/runtime" + agentTLS "overlord-client/cmd/agent/tlsconfig" "overlord-client/cmd/agent/wire" ) @@ -769,17 +768,13 @@ func HandleFileUploadHTTP(ctx context.Context, env *agentRuntime.Env, cmdID stri return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: "unsupported upload url scheme"}) } - tlsConfig := &tls.Config{InsecureSkipVerify: env.Cfg.TLSInsecureSkipVerify, MinVersion: tls.VersionTLS12} - if caPath := strings.TrimSpace(env.Cfg.TLSCAPath); caPath != "" { - caBytes, err := os.ReadFile(caPath) - if err != nil { - return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: fmt.Sprintf("failed to read TLS CA: %v", err)}) - } - pool := x509.NewCertPool() - if !pool.AppendCertsFromPEM(caBytes) { - return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: "failed to parse TLS CA"}) - } - tlsConfig.RootCAs = pool + tlsConfig, err := agentTLS.Build(agentTLS.Options{ + InsecureSkipVerify: env.Cfg.TLSInsecureSkipVerify, + CAPath: env.Cfg.TLSCAPath, + SPKIPins: env.Cfg.TLSSPKIPins, + }) + if err != nil { + return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: fmt.Sprintf("invalid TLS configuration: %v", err)}) } transport := &http.Transport{ @@ -986,6 +981,9 @@ func resolveUploadPullURL(env *agentRuntime.Env, raw string) (string, error) { if scheme != "http" && scheme != "https" { return "", fmt.Errorf("unsupported upload url scheme: %s", parsed.Scheme) } + if scheme == "http" && !env.Cfg.TLSInsecureSkipVerify { + return "", errors.New("plaintext file transfers are disabled; use https") + } if !strings.HasPrefix(parsed.Path, "/api/file/upload/pull/") { return parsed.String(), nil } @@ -1010,8 +1008,15 @@ func resolveUploadPullURL(env *agentRuntime.Env, raw string) (string, error) { case "wss": server.Scheme = "https" case "ws": + if !env.Cfg.TLSInsecureSkipVerify { + return "", errors.New("plaintext file transfers are disabled; use wss") + } server.Scheme = "http" - case "https", "http": + case "https": + case "http": + if !env.Cfg.TLSInsecureSkipVerify { + return "", errors.New("plaintext file transfers are disabled; use https") + } default: return "", fmt.Errorf("unsupported agent server scheme: %s", server.Scheme) } diff --git a/Overlord-Client/cmd/agent/handlers/files_test.go b/Overlord-Client/cmd/agent/handlers/files_test.go index 7967dfd..4b81461 100644 --- a/Overlord-Client/cmd/agent/handlers/files_test.go +++ b/Overlord-Client/cmd/agent/handlers/files_test.go @@ -545,7 +545,8 @@ func TestHandleFileUploadHTTP_RewritesUploadPullURLToAgentServer(t *testing.T) { env := &rt.Env{ Conn: writer, Cfg: config.Config{ - ServerURLs: []string{strings.Replace(ts.URL, "http://", "ws://", 1)}, + ServerURLs: []string{strings.Replace(ts.URL, "http://", "ws://", 1)}, + TLSInsecureSkipVerify: true, }, } diff --git a/Overlord-Client/cmd/agent/handlers/plugin_load.go b/Overlord-Client/cmd/agent/handlers/plugin_load.go index 366af5b..5e31111 100644 --- a/Overlord-Client/cmd/agent/handlers/plugin_load.go +++ b/Overlord-Client/cmd/agent/handlers/plugin_load.go @@ -2,19 +2,17 @@ package handlers import ( "context" - "crypto/tls" - "crypto/x509" "errors" "fmt" "io" "log" "net/http" "net/url" - "os" "strings" "overlord-client/cmd/agent/plugins" agentRuntime "overlord-client/cmd/agent/runtime" + agentTLS "overlord-client/cmd/agent/tlsconfig" "overlord-client/cmd/agent/wire" ) @@ -31,17 +29,13 @@ func HandlePluginLoadHTTP(ctx context.Context, env *agentRuntime.Env, cmdID stri return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: err.Error()}) } - tlsConfig := &tls.Config{InsecureSkipVerify: env.Cfg.TLSInsecureSkipVerify, MinVersion: tls.VersionTLS12} - if caPath := strings.TrimSpace(env.Cfg.TLSCAPath); caPath != "" { - caBytes, err := os.ReadFile(caPath) - if err != nil { - return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: fmt.Sprintf("failed to read TLS CA: %v", err)}) - } - pool := x509.NewCertPool() - if !pool.AppendCertsFromPEM(caBytes) { - return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: "failed to parse TLS CA"}) - } - tlsConfig.RootCAs = pool + tlsConfig, err := agentTLS.Build(agentTLS.Options{ + InsecureSkipVerify: env.Cfg.TLSInsecureSkipVerify, + CAPath: env.Cfg.TLSCAPath, + SPKIPins: env.Cfg.TLSSPKIPins, + }) + if err != nil { + return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: fmt.Sprintf("invalid TLS configuration: %v", err)}) } client := &http.Client{Transport: &http.Transport{TLSClientConfig: tlsConfig}} @@ -114,6 +108,9 @@ func resolvePluginPullURL(env *agentRuntime.Env, raw string) (string, error) { if scheme != "http" && scheme != "https" { return "", fmt.Errorf("unsupported plugin pull scheme: %s", parsed.Scheme) } + if scheme == "http" && !env.Cfg.TLSInsecureSkipVerify { + return "", errors.New("plaintext plugin downloads are disabled; use https") + } return parsed.String(), nil } @@ -132,8 +129,15 @@ func resolvePluginPullURL(env *agentRuntime.Env, raw string) (string, error) { case "wss": server.Scheme = "https" case "ws": + if !env.Cfg.TLSInsecureSkipVerify { + return "", errors.New("plaintext plugin downloads are disabled; use wss") + } server.Scheme = "http" - case "https", "http": + case "https": + case "http": + if !env.Cfg.TLSInsecureSkipVerify { + return "", errors.New("plaintext plugin downloads are disabled; use https") + } default: return "", fmt.Errorf("unsupported agent server scheme: %s", server.Scheme) } diff --git a/Overlord-Client/cmd/agent/handlers/transport_security_test.go b/Overlord-Client/cmd/agent/handlers/transport_security_test.go new file mode 100644 index 0000000..db75e4f --- /dev/null +++ b/Overlord-Client/cmd/agent/handlers/transport_security_test.go @@ -0,0 +1,48 @@ +package handlers + +import ( + "strings" + "testing" + + "overlord-client/cmd/agent/config" + agentRuntime "overlord-client/cmd/agent/runtime" +) + +func TestSensitiveTransfersRejectPlaintextByDefault(t *testing.T) { + env := &agentRuntime.Env{Cfg: config.Config{ + ServerURLs: []string{"ws://server.example.test:5173"}, + }} + + if _, err := resolveUploadPullURL(env, "/api/file/upload/pull/id"); err == nil || + !strings.Contains(err.Error(), "plaintext") { + t.Fatalf("expected plaintext file transfer rejection, got %v", err) + } + if _, err := resolvePluginPullURL(env, "/api/plugins/pull/id"); err == nil || + !strings.Contains(err.Error(), "plaintext") { + t.Fatalf("expected plaintext plugin transfer rejection, got %v", err) + } + if _, err := buildWhipURL(env, "/api/webrtc/whip"); err == nil || + !strings.Contains(err.Error(), "plaintext") { + t.Fatalf("expected plaintext WebRTC signaling rejection, got %v", err) + } +} + +func TestSensitiveTransfersAllowExplicitDevelopmentOptOut(t *testing.T) { + env := &agentRuntime.Env{Cfg: config.Config{ + ServerURLs: []string{"ws://127.0.0.1:5173"}, + TLSInsecureSkipVerify: true, + }} + + if resolved, err := resolveUploadPullURL(env, "/api/file/upload/pull/id"); err != nil || + !strings.HasPrefix(resolved, "http://") { + t.Fatalf("expected explicit plaintext file transfer, got %q, %v", resolved, err) + } + if resolved, err := resolvePluginPullURL(env, "/api/plugins/pull/id"); err != nil || + !strings.HasPrefix(resolved, "http://") { + t.Fatalf("expected explicit plaintext plugin transfer, got %q, %v", resolved, err) + } + if resolved, err := buildWhipURL(env, "/api/webrtc/whip"); err != nil || + !strings.HasPrefix(resolved, "http://") { + t.Fatalf("expected explicit plaintext signaling, got %q, %v", resolved, err) + } +} diff --git a/Overlord-Client/cmd/agent/handlers/webrtc.go b/Overlord-Client/cmd/agent/handlers/webrtc.go index dce5a53..9125e2b 100644 --- a/Overlord-Client/cmd/agent/handlers/webrtc.go +++ b/Overlord-Client/cmd/agent/handlers/webrtc.go @@ -103,6 +103,7 @@ func handleWebrtcPublish(ctx context.Context, env *runtime.Env, cmdID string, pa PublishToken: token, TLSInsecureSkipVerify: env.Cfg.TLSInsecureSkipVerify, TLSCAPath: env.Cfg.TLSCAPath, + TLSSPKIPins: env.Cfg.TLSSPKIPins, ICEServers: parseICEServers(payload["iceServers"]), HasVideo: hasVideo, HasAudio: hasAudio, @@ -309,7 +310,17 @@ func buildWhipURL(env *runtime.Env, whipPath string) (string, error) { case "wss": base.Scheme = "https" case "ws": + if !env.Cfg.TLSInsecureSkipVerify { + return "", fmt.Errorf("plaintext WebRTC signaling is disabled; use wss") + } base.Scheme = "http" + case "http": + if !env.Cfg.TLSInsecureSkipVerify { + return "", fmt.Errorf("plaintext WebRTC signaling is disabled; use https") + } + case "https": + default: + return "", fmt.Errorf("unsupported WebRTC signaling scheme: %s", base.Scheme) } if !strings.HasPrefix(whipPath, "/") { whipPath = "/" + whipPath diff --git a/Overlord-Client/cmd/agent/session.go b/Overlord-Client/cmd/agent/session.go index e6d71b3..4d02c11 100644 --- a/Overlord-Client/cmd/agent/session.go +++ b/Overlord-Client/cmd/agent/session.go @@ -3,7 +3,6 @@ package main import ( "context" "crypto/tls" - "crypto/x509" "encoding/base64" "errors" "fmt" @@ -27,6 +26,7 @@ import ( "overlord-client/cmd/agent/plugins" rt "overlord-client/cmd/agent/runtime" "overlord-client/cmd/agent/sysinfo" + agentTLS "overlord-client/cmd/agent/tlsconfig" "overlord-client/cmd/agent/wire" "nhooyr.io/websocket" @@ -51,7 +51,11 @@ func runClient(cfg config.Config) { } } - transport := createHTTPTransport(cfg) + transport, err := createHTTPTransport(cfg) + if err != nil { + log.Printf("[TLS] refusing to start with invalid TLS configuration: %v", err) + return + } currentIndex := cfg.ServerIndex consecutiveFailures := 0 var loggedTLS bool @@ -67,6 +71,12 @@ func runClient(cfg config.Config) { } currentServer := cfg.ServerURLs[currentIndex] + if strings.HasPrefix(strings.ToLower(currentServer), "ws://") && !cfg.TLSInsecureSkipVerify { + log.Printf("[TLS] refusing plaintext WebSocket server %s; use wss://", currentServer) + rotateToNextServer(¤tIndex, cfg.ServerURLs) + time.Sleep(backoff) + continue + } ctx, cancel := context.WithCancel(context.Background()) url := fmt.Sprintf("%s/api/clients/%s/stream/ws?role=client", currentServer, cfg.ID) @@ -302,38 +312,21 @@ func refreshServerURLsFromRaw(cfg *config.Config) bool { return true } -func createHTTPTransport(cfg config.Config) *http.Transport { - tlsConfig := &tls.Config{ +func createHTTPTransport(cfg config.Config) (*http.Transport, error) { + tlsConfig, err := agentTLS.Build(agentTLS.Options{ InsecureSkipVerify: cfg.TLSInsecureSkipVerify, - MinVersion: tls.VersionTLS12, + CAPath: cfg.TLSCAPath, + SPKIPins: cfg.TLSSPKIPins, + ClientCertPath: cfg.TLSClientCert, + ClientKeyPath: cfg.TLSClientKey, + }) + if err != nil { + return nil, err } - if cfg.TLSCAPath != "" { - caCert, err := os.ReadFile(cfg.TLSCAPath) - if err != nil { - log.Printf("[TLS] WARNING: Failed to read CA certificate from %s: %v", cfg.TLSCAPath, err) - } else { - caCertPool := x509.NewCertPool() - if caCertPool.AppendCertsFromPEM(caCert) { - tlsConfig.RootCAs = caCertPool - log.Printf("[TLS] Loaded custom CA certificate from %s", cfg.TLSCAPath) - } else { - log.Printf("[TLS] WARNING: Failed to parse CA certificate from %s", cfg.TLSCAPath) - } - } - } - - if cfg.TLSClientCert != "" && cfg.TLSClientKey != "" { - cert, err := tls.LoadX509KeyPair(cfg.TLSClientCert, cfg.TLSClientKey) - if err != nil { - log.Printf("[TLS] WARNING: Failed to load client certificate: %v", err) - } else { - tlsConfig.Certificates = []tls.Certificate{cert} - log.Printf("[TLS] Loaded client certificate for mutual TLS") - } - } - - if cfg.TLSInsecureSkipVerify { + if len(cfg.TLSSPKIPins) > 0 { + log.Printf("[TLS] Server identity pinning enabled with %d trusted SPKI pin(s)", len(cfg.TLSSPKIPins)) + } else if cfg.TLSInsecureSkipVerify { log.Printf("[TLS] WARNING: Certificate verification is DISABLED. This is insecure!") } @@ -343,7 +336,7 @@ func createHTTPTransport(cfg config.Config) *http.Transport { Timeout: 30 * time.Second, KeepAlive: 15 * time.Second, }).DialContext - return transport + return transport, nil } func classifyDialError(err error) string { diff --git a/Overlord-Client/cmd/agent/tlsconfig/tlsconfig.go b/Overlord-Client/cmd/agent/tlsconfig/tlsconfig.go new file mode 100644 index 0000000..71d248f --- /dev/null +++ b/Overlord-Client/cmd/agent/tlsconfig/tlsconfig.go @@ -0,0 +1,106 @@ +package tlsconfig + +import ( + "bytes" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "errors" + "fmt" + "os" + "strings" + "time" +) + +type Options struct { + InsecureSkipVerify bool + CAPath string + SPKIPins []string + ClientCertPath string + ClientKeyPath string +} + +func Build(options Options) (*tls.Config, error) { + pins, err := decodePins(options.SPKIPins) + if err != nil { + return nil, err + } + + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + } + + if strings.TrimSpace(options.CAPath) != "" { + caCert, err := os.ReadFile(options.CAPath) + if err != nil { + return nil, fmt.Errorf("read TLS CA: %w", err) + } + caCertPool := x509.NewCertPool() + if !caCertPool.AppendCertsFromPEM(caCert) { + return nil, errors.New("parse TLS CA: no certificates found") + } + tlsConfig.RootCAs = caCertPool + } + + if strings.TrimSpace(options.ClientCertPath) != "" || strings.TrimSpace(options.ClientKeyPath) != "" { + if strings.TrimSpace(options.ClientCertPath) == "" || strings.TrimSpace(options.ClientKeyPath) == "" { + return nil, errors.New("both TLS client certificate and key are required") + } + certificate, err := tls.LoadX509KeyPair(options.ClientCertPath, options.ClientKeyPath) + if err != nil { + return nil, fmt.Errorf("load TLS client certificate: %w", err) + } + tlsConfig.Certificates = []tls.Certificate{certificate} + } + + if len(pins) > 0 { + // Verification is performed below against an embedded application pin. + // This intentionally supports self-signed certificates without trusting + // arbitrary certificates from the network or host trust store. + tlsConfig.InsecureSkipVerify = true //nolint:gosec + tlsConfig.VerifyConnection = func(state tls.ConnectionState) error { + return verifyPinnedConnection(state, pins, time.Now()) + } + return tlsConfig, nil + } + + tlsConfig.InsecureSkipVerify = options.InsecureSkipVerify //nolint:gosec + return tlsConfig, nil +} + +func decodePins(values []string) ([][]byte, error) { + result := make([][]byte, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(strings.TrimPrefix(value, "sha256/")) + if value == "" { + continue + } + decoded, err := base64.StdEncoding.DecodeString(value) + if err != nil || len(decoded) != sha256.Size { + return nil, fmt.Errorf("invalid TLS SPKI pin %q", value) + } + result = append(result, decoded) + } + return result, nil +} + +func verifyPinnedConnection(state tls.ConnectionState, pins [][]byte, now time.Time) error { + if len(state.PeerCertificates) == 0 { + return errors.New("TLS peer did not provide a certificate") + } + leaf := state.PeerCertificates[0] + if now.Before(leaf.NotBefore) || now.After(leaf.NotAfter) { + return fmt.Errorf("TLS peer certificate is not valid at %s", now.UTC().Format(time.RFC3339)) + } + sum := sha256.Sum256(leaf.RawSubjectPublicKeyInfo) + for _, pin := range pins { + if bytes.Equal(sum[:], pin) { + return nil + } + } + return fmt.Errorf( + "TLS server identity pin mismatch (received sha256/%s)", + base64.StdEncoding.EncodeToString(sum[:]), + ) +} diff --git a/Overlord-Client/cmd/agent/tlsconfig/tlsconfig_test.go b/Overlord-Client/cmd/agent/tlsconfig/tlsconfig_test.go new file mode 100644 index 0000000..f9674c2 --- /dev/null +++ b/Overlord-Client/cmd/agent/tlsconfig/tlsconfig_test.go @@ -0,0 +1,100 @@ +package tlsconfig + +import ( + "crypto/sha256" + "crypto/tls" + "encoding/base64" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func pinForCertificate(certificate *tls.Certificate) string { + sum := sha256.Sum256(certificate.Leaf.RawSubjectPublicKeyInfo) + return base64.StdEncoding.EncodeToString(sum[:]) +} + +func clientForConfig(config *tls.Config) *http.Client { + return &http.Client{ + Transport: &http.Transport{TLSClientConfig: config}, + } +} + +func TestPinnedServerIdentity(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, "ok") + })) + defer server.Close() + + correctPin := pinForCertificate(&server.TLS.Certificates[0]) + config, err := Build(Options{SPKIPins: []string{correctPin}}) + if err != nil { + t.Fatalf("Build failed: %v", err) + } + + response, err := clientForConfig(config).Get(server.URL) + if err != nil { + t.Fatalf("pinned request failed: %v", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("unexpected status: %d", response.StatusCode) + } +} + +func TestPinnedServerRejectsRogueKey(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + defer server.Close() + + roguePin := base64.StdEncoding.EncodeToString(bytesOf(sha256.Size, 0x42)) + config, err := Build(Options{SPKIPins: []string{roguePin}}) + if err != nil { + t.Fatalf("Build failed: %v", err) + } + + _, err = clientForConfig(config).Get(server.URL) + if err == nil || !strings.Contains(err.Error(), "identity pin mismatch") { + t.Fatalf("expected pin mismatch, got %v", err) + } +} + +func TestInvalidPinFailsClosed(t *testing.T) { + if _, err := Build(Options{SPKIPins: []string{"not-a-pin"}}); err == nil { + t.Fatal("expected invalid pin to be rejected") + } +} + +func TestExplicitInsecureModeRemainsOptIn(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + secureConfig, err := Build(Options{}) + if err != nil { + t.Fatalf("Build secure config failed: %v", err) + } + if _, err := clientForConfig(secureConfig).Get(server.URL); err == nil { + t.Fatal("untrusted self-signed certificate unexpectedly succeeded") + } + + insecureConfig, err := Build(Options{InsecureSkipVerify: true}) + if err != nil { + t.Fatalf("Build insecure config failed: %v", err) + } + response, err := clientForConfig(insecureConfig).Get(server.URL) + if err != nil { + t.Fatalf("explicit insecure request failed: %v", err) + } + response.Body.Close() +} + +func bytesOf(count int, value byte) []byte { + result := make([]byte, count) + for i := range result { + result[i] = value + } + return result +} diff --git a/Overlord-Client/cmd/agent/webrtcpub/state.go b/Overlord-Client/cmd/agent/webrtcpub/state.go index af97963..29dcd4b 100644 --- a/Overlord-Client/cmd/agent/webrtcpub/state.go +++ b/Overlord-Client/cmd/agent/webrtcpub/state.go @@ -276,7 +276,8 @@ type Options struct { // TLSInsecureSkipVerify mirrors the agent's existing TLS config. TLSInsecureSkipVerify bool // TLSCAPath is an optional custom CA bundle. - TLSCAPath string + TLSCAPath string + TLSSPKIPins []string // ICEServers contains the server-issued, short-lived Coturn configuration. ICEServers []ICEServer // HasVideo / HasAudio select which tracks to add to the peer connection. diff --git a/Overlord-Client/cmd/agent/webrtcpub/whip_pion.go b/Overlord-Client/cmd/agent/webrtcpub/whip_pion.go index 323cdca..5b38ba1 100644 --- a/Overlord-Client/cmd/agent/webrtcpub/whip_pion.go +++ b/Overlord-Client/cmd/agent/webrtcpub/whip_pion.go @@ -5,8 +5,6 @@ package webrtcpub import ( "bytes" "context" - "crypto/tls" - "crypto/x509" "errors" "fmt" "io" @@ -19,6 +17,8 @@ import ( "sync/atomic" "time" + agentTLS "overlord-client/cmd/agent/tlsconfig" + "github.com/pion/interceptor" "github.com/pion/rtcp" "github.com/pion/webrtc/v4" @@ -133,7 +133,10 @@ func Start(ctx context.Context, kind Kind, opts Options) (*Publisher, error) { } Stop(kind) - httpClient := buildHTTPClient(opts) + httpClient, err := buildHTTPClient(opts) + if err != nil { + return nil, fmt.Errorf("webrtcpub: invalid TLS configuration: %w", err) + } mediaEngine := &webrtc.MediaEngine{} if opts.HasVideo { @@ -379,23 +382,19 @@ func postWhip(ctx context.Context, client *http.Client, whipURL, token, sdp stri return string(body), resource, nil } -func buildHTTPClient(opts Options) *http.Client { - tlsCfg := &tls.Config{ +func buildHTTPClient(opts Options) (*http.Client, error) { + tlsCfg, err := agentTLS.Build(agentTLS.Options{ InsecureSkipVerify: opts.TLSInsecureSkipVerify, - MinVersion: tls.VersionTLS12, - } - if path := strings.TrimSpace(opts.TLSCAPath); path != "" { - if pem, err := os.ReadFile(path); err == nil { - pool := x509.NewCertPool() - if pool.AppendCertsFromPEM(pem) { - tlsCfg.RootCAs = pool - } - } + CAPath: opts.TLSCAPath, + SPKIPins: opts.TLSSPKIPins, + }) + if err != nil { + return nil, err } return &http.Client{ Timeout: 15 * time.Second, Transport: &http.Transport{ TLSClientConfig: tlsCfg, }, - } + }, nil } diff --git a/Overlord-Server/.env.example b/Overlord-Server/.env.example index 2c66825..b281b36 100644 --- a/Overlord-Server/.env.example +++ b/Overlord-Server/.env.example @@ -23,6 +23,12 @@ OVERLORD_AGENT_TOKEN= # OVERLORD_TLS_KEY=./certs/server.key # OVERLORD_TLS_CA=./certs/ca.crt +# Additional SHA-256 SPKI pins embedded in newly generated agents. Use this for +# a planned next certificate key, or provide the externally terminated TLS +# certificate pins when OVERLORD_TLS_OFFLOAD=true. Comma-separated; an optional +# "sha256/" prefix is accepted. +# OVERLORD_TLS_SPKI_PINS=base64-sha256-current,base64-sha256-next + # Reverse-proxy TLS offload. This serves internal HTTP only; keep the listener # on loopback and expose HTTPS/WSS through nginx, Caddy, or another trusted proxy. # HOST=127.0.0.1 diff --git a/Overlord-Server/public/assets/main.js b/Overlord-Server/public/assets/main.js index c48efa9..4fa8d83 100644 --- a/Overlord-Server/public/assets/main.js +++ b/Overlord-Server/public/assets/main.js @@ -576,27 +576,6 @@ async function loadCurrentUser() { usersLink?.classList.remove("hidden"); } - if (currentUser.role === "admin" && !localStorage.getItem("overlord_settings_exported")) { - localStorage.setItem("overlord_settings_exported", "1"); - try { - const expRes = await fetch("/api/settings/export", { credentials: "include" }); - if (expRes.ok) { - const blob = await expRes.blob(); - const disposition = expRes.headers.get("Content-Disposition") || ""; - const match = disposition.match(/filename="?([^"]+)"?/); - const filename = match ? match[1] : "overlord-settings.json"; - const dlUrl = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = dlUrl; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(dlUrl); - } - } catch {} - } - if (currentUser.role === "admin" || currentUser.role === "operator" || currentUser.canBuild) { buildLink?.classList.remove("hidden"); } diff --git a/Overlord-Server/public/assets/settings.js b/Overlord-Server/public/assets/settings.js index ff311dd..604a949 100644 --- a/Overlord-Server/public/assets/settings.js +++ b/Overlord-Server/public/assets/settings.js @@ -2018,7 +2018,7 @@ async function exportSettings() { const blob = await res.blob(); const disposition = res.headers.get("Content-Disposition") || ""; const filenameMatch = disposition.match(/filename="?([^"]+)"?/); - const filename = filenameMatch ? filenameMatch[1] : "overlord-settings.json"; + const filename = filenameMatch ? filenameMatch[1] : "overlord-server-backup.zip"; const url = URL.createObjectURL(blob); const a = document.createElement("a"); @@ -2046,35 +2046,39 @@ async function importSettings(event) { event.target.value = ""; - if (file.size > 512 * 1024) { - showExportImportMessage("File too large (max 512 KB).", "error"); + if (file.size > 1024 * 1024 * 1024) { + showExportImportMessage("File too large (max 1 GB).", "error"); return; } - let data; - try { - const text = await file.text(); - data = JSON.parse(text); - } catch { - showExportImportMessage("Invalid JSON file.", "error"); - return; + const isLegacyJson = file.name.toLowerCase().endsWith(".json") || file.type === "application/json"; + let requestBody; + let contentType; + if (isLegacyJson) { + try { + const data = JSON.parse(await file.text()); + if (!data || typeof data !== "object") throw new Error(); + requestBody = JSON.stringify(data); + contentType = "application/json"; + } catch { + showExportImportMessage("Invalid legacy settings JSON file.", "error"); + return; + } + } else { + requestBody = await file.arrayBuffer(); + contentType = "application/zip"; } - if (!data || typeof data !== "object") { - showExportImportMessage("File does not contain a valid settings object.", "error"); - return; - } - - if (!confirm("Import settings from this file? This will overwrite your current configuration.")) { + if (!confirm("Stage this server restore? On the next restart it will replace the current database, identity keys, TLS certificate, plugins, and persistent server data.")) { return; } try { const res = await fetch("/api/settings/import", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": contentType }, credentials: "include", - body: JSON.stringify(data), + body: requestBody, }); const result = await res.json().catch(() => ({})); @@ -2086,7 +2090,8 @@ async function importSettings(event) { const appliedStr = result.applied?.length ? result.applied.join(", ") : "nothing"; const warningStr = result.warnings?.length ? " \u26A0 " + result.warnings.join(" ") : ""; const msgType = result.warnings?.length ? "warning" : "ok"; - showExportImportMessage(`Imported: ${appliedStr}.${warningStr}`, msgType); + const restartStr = result.restartRequired ? " Restart the server to apply the restore." : ""; + showExportImportMessage(`Imported: ${appliedStr}.${restartStr}${warningStr}`, msgType); await loadSecurityPolicy(); await loadTlsSettings(); diff --git a/Overlord-Server/public/settings.html b/Overlord-Server/public/settings.html index 07c80f4..19a8d6e 100644 --- a/Overlord-Server/public/settings.html +++ b/Overlord-Server/public/settings.html @@ -1536,16 +1536,16 @@

Export & Import Settings

-

Backup or restore your Overlord configuration (notifications, security, TLS, appearance, auth tokens).

+

Create or restore a portable server backup without changing its pinned identity.

- Export — Downloads a JSON file with all current settings and auth tokens. + Export — Downloads a checksummed ZIP containing the database, settings, identity keys, TLS certificate/private key, plugins, and persistent server files.

-

Keep this file secure — it contains sensitive tokens.

+

Keep this file secure — it contains private keys, credentials, users, and server data.

diff --git a/Overlord-Server/src/adm-zip.d.ts b/Overlord-Server/src/adm-zip.d.ts index 43087dd..7d475c9 100644 --- a/Overlord-Server/src/adm-zip.d.ts +++ b/Overlord-Server/src/adm-zip.d.ts @@ -1,9 +1,15 @@ declare module "adm-zip" { class AdmZip { constructor(input?: string | Buffer); + addFile(entryName: string, content: Buffer, comment?: string, attr?: number): void; + toBuffer(): Buffer; getEntries(): Array<{ isDirectory: boolean; entryName: string; + header: { + size: number; + compressedSize: number; + }; getData(): Buffer; }>; } diff --git a/Overlord-Server/src/index.ts b/Overlord-Server/src/index.ts index 3a5722e..6d85ed7 100644 --- a/Overlord-Server/src/index.ts +++ b/Overlord-Server/src/index.ts @@ -1 +1,7 @@ -import "./main-server"; +import { applyPendingServerRestore } from "./server/server-backup"; + +if (applyPendingServerRestore()) { + console.log("[restore] Portable server backup applied successfully"); +} + +await import("./main-server"); diff --git a/Overlord-Server/src/main-server.ts b/Overlord-Server/src/main-server.ts index 23073dd..31b7838 100644 --- a/Overlord-Server/src/main-server.ts +++ b/Overlord-Server/src/main-server.ts @@ -561,7 +561,11 @@ async function startServer() { getRdSessionCount: sessionManager.getRdSessionCount, getFileBrowserSessionCount: sessionManager.getFileBrowserSessionCount, getProcessSessionCount: sessionManager.getProcessSessionCount, + DATA_DIR, + PLUGIN_ROOT, tlsCertPath: tls?.certPathUsed, + tlsKeyPath: TLS_KEY_PATH, + tlsCaPath: TLS_CA_PATH, tlsSource: tls?.source, }, assets: { diff --git a/Overlord-Server/src/server/build-process-tls-pinning.test.ts b/Overlord-Server/src/server/build-process-tls-pinning.test.ts new file mode 100644 index 0000000..1be14e3 --- /dev/null +++ b/Overlord-Server/src/server/build-process-tls-pinning.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "bun:test"; +import { createAgentTlsPinsLdflag } from "./build-process"; + +describe("agent build TLS pin embedding", () => { + test("creates the linker flag consumed by the Go agent config", () => { + const currentPin = Buffer.alloc(32, 0x11).toString("base64"); + const nextPin = Buffer.alloc(32, 0x22).toString("base64"); + + expect(createAgentTlsPinsLdflag([currentPin, nextPin])).toBe( + `-X overlord-client/cmd/agent/config.DefaultTLSSPKIPins=${currentPin},${nextPin}`, + ); + }); + + test("does not create a linker flag without pins", () => { + expect(createAgentTlsPinsLdflag([])).toBe(""); + }); +}); diff --git a/Overlord-Server/src/server/build-process.ts b/Overlord-Server/src/server/build-process.ts index c44305e..f05f43a 100644 --- a/Overlord-Server/src/server/build-process.ts +++ b/Overlord-Server/src/server/build-process.ts @@ -24,6 +24,12 @@ import { runSgn } from "./sgn-manager"; import { resolveContainedPath } from "./upload-security"; import { createIsolatedBuildEnv } from "./build-environment"; import { cleanupMacosSdkUpload, extractAndValidateMacosSdk } from "./macos-sdk-manager"; +import { getActiveTlsSpkiPins } from "./tls-bootstrap"; + +export function createAgentTlsPinsLdflag(pins: string[]): string { + if (pins.length === 0) return ""; + return `-X overlord-client/cmd/agent/config.DefaultTLSSPKIPins=${pins.join(",")}`; +} function isClientModuleDir(dir: string): boolean { return ( @@ -628,6 +634,16 @@ export async function startBuildProcess( const serverConfig = getConfig(); const buildAgentToken = (serverConfig.auth.agentToken || "").trim(); + const buildTlsSpkiPins = getActiveTlsSpkiPins(); + if ( + String(process.env.OVERLORD_TLS_OFFLOAD || "").toLowerCase() === "true" && + buildTlsSpkiPins.length === 0 + ) { + throw new Error( + "TLS offload requires OVERLORD_TLS_SPKI_PINS before building agents; " + + "pin the external HTTPS/WSS certificate public key", + ); + } sendToStream({ type: "status", text: "Preparing build environment..." }); @@ -1205,6 +1221,22 @@ func runBoundFiles() { sendToStream({ type: "output", text: `Server URL: ${config.serverUrl}\n`, level: "info" }); } + if (buildTlsSpkiPins.length > 0) { + const tlsPinsFlag = createAgentTlsPinsLdflag(buildTlsSpkiPins); + ldflags = `${ldflags} ${tlsPinsFlag}`; + sendToStream({ + type: "output", + text: `TLS identity pinning: enabled (${buildTlsSpkiPins.length} trusted key${buildTlsSpkiPins.length === 1 ? "" : "s"})\n`, + level: "info", + }); + } else { + sendToStream({ + type: "output", + text: "WARNING: No TLS SPKI pin is available. Configure OVERLORD_TLS_SPKI_PINS when TLS is terminated externally.\n", + level: "warn", + }); + } + if (config.rawServerList) { const rawServerFlag = "-X overlord-client/cmd/agent/config.DefaultServerURLIsRaw=true"; ldflags = ldflags ? `${ldflags} ${rawServerFlag}` : rawServerFlag; diff --git a/Overlord-Server/src/server/routes/misc-routes.ts b/Overlord-Server/src/server/routes/misc-routes.ts index eb54781..44ef62a 100644 --- a/Overlord-Server/src/server/routes/misc-routes.ts +++ b/Overlord-Server/src/server/routes/misc-routes.ts @@ -1,7 +1,7 @@ import { authenticateRequest } from "../../auth"; import { AuditAction, getAuditLogs, logAudit } from "../../auditLog"; import { logger } from "../../logger"; -import { getConfig, updateSecurityConfig, updateTlsConfig, updateOidcConfig, updateAppearanceConfig, updateChatConfig, getExportableConfig, importFullConfig, updateRegistrationConfig, updateBuildRateLimitConfig, updateThumbnailsConfig, updateInputArchiveConfig, updateFileTransfersConfig } from "../../config"; +import { getConfig, updateSecurityConfig, updateTlsConfig, updateOidcConfig, updateAppearanceConfig, updateChatConfig, importFullConfig, updateRegistrationConfig, updateBuildRateLimitConfig, updateThumbnailsConfig, updateInputArchiveConfig, updateFileTransfersConfig } from "../../config"; import { saveBrandingImage, getClientMetricsSummary, @@ -23,6 +23,13 @@ import { startProxy, stopProxy, } from "../socks5-proxy-manager"; +import { db } from "../../db/connection"; +import { + createDatabaseSnapshot, + createPortableServerBackup, + resolveServerBackupPaths, + stagePortableServerRestore, +} from "../server-backup"; let activeServerProfile: Promise | null = null; let jscRuntimePromise: Promise | null | undefined; @@ -38,7 +45,11 @@ type MiscRouteDeps = { getFileBrowserSessionCount: () => number; getProcessSessionCount: () => number; tlsCertPath?: string; + tlsKeyPath?: string; + tlsCaPath?: string; tlsSource?: "certbot" | "configured" | "self-signed"; + DATA_DIR?: string; + PLUGIN_ROOT?: string; }; const BRAND_IMAGE_MAX_BYTES = 5 * 1024 * 1024; @@ -1070,7 +1081,21 @@ export async function handleMiscRoutes( return new Response("Forbidden", { status: 403 }); } - const exportData = getExportableConfig(deps.SERVER_VERSION); + const backupPaths = resolveServerBackupPaths({ + dataDir: deps.DATA_DIR, + pluginRoot: deps.PLUGIN_ROOT, + tlsCertPath: deps.tlsCertPath, + tlsKeyPath: deps.tlsKeyPath, + tlsCaPath: deps.tlsCaPath, + }); + const databaseSnapshot = createDatabaseSnapshot(db); + const configSnapshot = Buffer.from(JSON.stringify(getConfig(), null, 2)); + const exportData = createPortableServerBackup( + backupPaths, + deps.SERVER_VERSION, + databaseSnapshot, + configSnapshot, + ); logAudit({ timestamp: Date.now(), @@ -1082,10 +1107,11 @@ export async function handleMiscRoutes( }); const dateStr = new Date().toISOString().slice(0, 10); - return new Response(JSON.stringify(exportData, null, 2), { + return new Response(Uint8Array.from(exportData), { headers: { - "Content-Type": "application/json", - "Content-Disposition": `attachment; filename="overlord-settings-${dateStr}.json"`, + "Content-Type": "application/zip", + "Content-Disposition": `attachment; filename="overlord-server-backup-${dateStr}.zip"`, + "Cache-Control": "no-store", ...deps.CORS_HEADERS, }, }); @@ -1103,6 +1129,49 @@ export async function handleMiscRoutes( return new Response("Forbidden", { status: 403 }); } + const contentType = String(req.headers.get("content-type") || "").toLowerCase(); + if (contentType.includes("application/zip") || contentType.includes("application/octet-stream")) { + try { + const archive = Buffer.from(await req.arrayBuffer()); + const result = stagePortableServerRestore( + archive, + resolveServerBackupPaths({ + dataDir: deps.DATA_DIR, + pluginRoot: deps.PLUGIN_ROOT, + tlsCertPath: deps.tlsCertPath, + tlsKeyPath: deps.tlsKeyPath, + tlsCaPath: deps.tlsCaPath, + }), + deps.SERVER_VERSION, + ); + logAudit({ + timestamp: Date.now(), + username: user.username, + ip: deps.requestIP?.(req)?.address || "unknown", + action: AuditAction.COMMAND, + details: `Staged portable server restore (${result.files} files); restart required`, + success: true, + }); + return Response.json({ ok: true, applied: ["portable server restore staged"], ...result }, { + headers: deps.CORS_HEADERS, + }); + } catch (error: any) { + logAudit({ + timestamp: Date.now(), + username: user.username, + ip: deps.requestIP?.(req)?.address || "unknown", + action: AuditAction.COMMAND, + details: "Portable server restore validation failed", + success: false, + errorMessage: String(error?.message || error), + }); + return Response.json({ ok: false, error: String(error?.message || "Restore failed") }, { + status: 400, + headers: deps.CORS_HEADERS, + }); + } + } + let body: any = {}; try { body = await req.json(); diff --git a/Overlord-Server/src/server/routes/settings-backup.test.ts b/Overlord-Server/src/server/routes/settings-backup.test.ts new file mode 100644 index 0000000..e97dfcc --- /dev/null +++ b/Overlord-Server/src/server/routes/settings-backup.test.ts @@ -0,0 +1,99 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import AdmZip from "adm-zip"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import path from "path"; +import { generateToken } from "../../auth"; +import { generateSelfSignedCert } from "../../certGenerator"; +import { createUser, deleteUser, getUserById } from "../../users"; +import { handleMiscRoutes } from "./misc-routes"; + +const root = mkdtempSync(path.join(tmpdir(), "overlord-settings-backup-route-")); +const dataDir = path.join(root, "data"); +const pluginRoot = path.join(root, "plugins"); +const certPath = path.join(root, "certs", "server.crt"); +const keyPath = path.join(root, "certs", "server.key"); +let userId = 0; +let token = ""; + +const deps = { + CORS_HEADERS: {}, + SERVER_VERSION: "3.0.0", + PUBLIC_ROOT: ".", + DATA_DIR: dataDir, + PLUGIN_ROOT: pluginRoot, + tlsCertPath: certPath, + tlsKeyPath: keyPath, + tlsSource: "self-signed" as const, + requestIP: () => ({ address: "127.0.0.1" }), + getConsoleSessionCount: () => 0, + getRdSessionCount: () => 0, + getFileBrowserSessionCount: () => 0, + getProcessSessionCount: () => 0, +}; + +beforeAll(async () => { + mkdirSync(dataDir, { recursive: true }); + mkdirSync(pluginRoot, { recursive: true }); + writeFileSync(path.join(dataDir, "save.json"), JSON.stringify({ + auth: { jwtSecret: "route-jwt", agentToken: "route-agent" }, + })); + await generateSelfSignedCert({ certPath, keyPath, commonName: "localhost" }); + const created = await createUser( + `backup_route_${Date.now().toString(36)}`, + "Aa1!PortableBackupRoute_2026", + "admin", + "test", + ); + if (!created.success || !created.userId) throw new Error("failed to create backup route test user"); + userId = created.userId; + token = await generateToken(getUserById(userId)!); +}); + +afterAll(() => { + if (userId) deleteUser(userId); + rmSync(root, { recursive: true, force: true }); +}); + +describe("portable settings backup routes", () => { + test("exports a ZIP and accepts it as a staged restore", async () => { + const exportUrl = new URL("https://localhost/api/settings/export"); + const exportResponse = await handleMiscRoutes( + new Request(exportUrl, { headers: { Cookie: `overlord_token=${token}` } }), + exportUrl, + deps, + ); + expect(exportResponse?.status).toBe(200); + expect(exportResponse?.headers.get("content-type")).toBe("application/zip"); + expect(exportResponse?.headers.get("content-disposition")).toContain("overlord-server-backup-"); + expect(exportResponse?.headers.get("cache-control")).toBe("no-store"); + + const archive = Buffer.from(await exportResponse!.arrayBuffer()); + const zip = new AdmZip(archive); + const names = new Set(zip.getEntries().map((entry) => entry.entryName)); + expect(names.has("manifest.json")).toBe(true); + expect(names.has("data/overlord.db")).toBe(true); + expect(names.has("data/save.json")).toBe(true); + expect(names.has("tls/server.crt")).toBe(true); + expect(names.has("tls/server.key")).toBe(true); + + const importUrl = new URL("https://localhost/api/settings/import"); + const importResponse = await handleMiscRoutes( + new Request(importUrl, { + method: "POST", + headers: { + Cookie: `overlord_token=${token}`, + "Content-Type": "application/zip", + }, + body: Uint8Array.from(archive), + }), + importUrl, + deps, + ); + expect(importResponse?.status).toBe(200); + const result = await importResponse!.json() as any; + expect(result.restartRequired).toBe(true); + expect(result.applied).toContain("portable server restore staged"); + expect(existsSync(path.join(dataDir, ".pending-server-restore.json"))).toBe(true); + }); +}); diff --git a/Overlord-Server/src/server/server-backup.test.ts b/Overlord-Server/src/server/server-backup.test.ts new file mode 100644 index 0000000..0246a27 --- /dev/null +++ b/Overlord-Server/src/server/server-backup.test.ts @@ -0,0 +1,174 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import AdmZip from "adm-zip"; +import Database from "bun:sqlite"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "fs"; +import { tmpdir } from "os"; +import path from "path"; +import { generateSelfSignedCert } from "../certGenerator"; +import { + applyPendingServerRestore, + createDatabaseSnapshot, + createPortableServerBackup, + stagePortableServerRestore, + type ServerBackupPaths, +} from "./server-backup"; + +const roots: string[] = []; + +function tempRoot(label: string): string { + const root = mkdtempSync(path.join(tmpdir(), label)); + roots.push(root); + return root; +} + +function pathsAt(root: string): ServerBackupPaths { + return { + dataDir: path.join(root, "data"), + pluginRoot: path.join(root, "plugins"), + tlsCertPath: path.join(root, "certs", "server.crt"), + tlsKeyPath: path.join(root, "certs", "server.key"), + tlsCaPath: path.join(root, "certs", "ca.crt"), + }; +} + +function createSourceDatabase(databasePath: string): Database { + mkdirSync(path.dirname(databasePath), { recursive: true }); + const database = new Database(databasePath); + database.exec(` + CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT NOT NULL); + CREATE TABLE clients (id TEXT PRIMARY KEY, nickname TEXT); + INSERT INTO users (id, username) VALUES (7, 'portable-admin'); + INSERT INTO clients (id, nickname) VALUES ('agent-1', 'portable-agent'); + `); + return database; +} + +afterAll(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("portable server backup", () => { + test("round-trips database, identity, secrets, plugins, and persistent files", async () => { + const source = pathsAt(tempRoot("overlord-backup-source-")); + mkdirSync(source.dataDir, { recursive: true }); + mkdirSync(path.join(source.dataDir, "file-share"), { recursive: true }); + mkdirSync(path.join(source.pluginRoot, "example", "data"), { recursive: true }); + writeFileSync(path.join(source.dataDir, "save.json"), JSON.stringify({ + auth: { jwtSecret: "jwt-secret", agentToken: "agent-token" }, + buildSigning: { privateKey: "build-private", publicKey: "build-public" }, + clientLogs: { privateKey: "log-private", publicKey: "log-public" }, + })); + writeFileSync(path.join(source.dataDir, "vapid-keys.json"), '{"publicKey":"vapid-public","privateKey":"vapid-private"}'); + writeFileSync(path.join(source.dataDir, "file-share", "payload.txt"), "portable file"); + writeFileSync(path.join(source.pluginRoot, ".plugin-state.json"), '{"example":{"enabled":true}}'); + writeFileSync(path.join(source.pluginRoot, "example", "data", "state.json"), '{"value":42}'); + await generateSelfSignedCert({ + certPath: source.tlsCertPath, + keyPath: source.tlsKeyPath, + commonName: "localhost", + daysValid: 3650, + }); + + const sourceDatabase = createSourceDatabase(path.join(source.dataDir, "overlord.db")); + const snapshot = createDatabaseSnapshot(sourceDatabase); + sourceDatabase.close(); + const configSnapshot = Buffer.from(JSON.stringify({ + tls: { + certPath: "/old-machine/certs/server.crt", + keyPath: "/old-machine/certs/server.key", + certbot: { enabled: true }, + }, + appearance: { customCSS: "body{}" }, + })); + const archive = createPortableServerBackup(source, "3.0.0", snapshot, configSnapshot); + + const destination = pathsAt(tempRoot("overlord-backup-destination-")); + mkdirSync(destination.dataDir, { recursive: true }); + writeFileSync(path.join(destination.dataDir, "save.json"), '{"old":true}'); + const staged = stagePortableServerRestore(archive, destination, "3.0.0"); + expect(staged.restartRequired).toBe(true); + expect(staged.serverVersion).toBe("3.0.0"); + expect(existsSync(path.join(destination.dataDir, ".pending-server-restore.json"))).toBe(true); + + expect(applyPendingServerRestore(destination)).toBe(true); + expect(readFileSync(destination.tlsCertPath)).toEqual(readFileSync(source.tlsCertPath)); + expect(readFileSync(destination.tlsKeyPath)).toEqual(readFileSync(source.tlsKeyPath)); + expect(readFileSync(path.join(destination.dataDir, "file-share", "payload.txt"), "utf8")).toBe("portable file"); + expect(readFileSync(path.join(destination.pluginRoot, "example", "data", "state.json"), "utf8")).toBe('{"value":42}'); + + const restoredConfig = JSON.parse(readFileSync(path.join(destination.dataDir, "config.json"), "utf8")); + expect(restoredConfig.tls.certPath).toBe(destination.tlsCertPath); + expect(restoredConfig.tls.keyPath).toBe(destination.tlsKeyPath); + expect(restoredConfig.tls.certbot.enabled).toBe(false); + + const restoredDatabase = new Database(path.join(destination.dataDir, "overlord.db"), { readonly: true }); + expect(restoredDatabase.query("SELECT username FROM users WHERE id = 7").get()).toEqual({ + username: "portable-admin", + }); + expect(restoredDatabase.query("SELECT nickname FROM clients WHERE id = 'agent-1'").get()).toEqual({ + nickname: "portable-agent", + }); + restoredDatabase.close(); + expect(applyPendingServerRestore(destination)).toBe(false); + }); + + test("rejects a modified archive before staging any restore", async () => { + const source = pathsAt(tempRoot("overlord-backup-tamper-source-")); + mkdirSync(source.dataDir, { recursive: true }); + writeFileSync(path.join(source.dataDir, "save.json"), '{"auth":{"jwtSecret":"a","agentToken":"b"}}'); + await generateSelfSignedCert({ + certPath: source.tlsCertPath, + keyPath: source.tlsKeyPath, + commonName: "localhost", + }); + const database = createSourceDatabase(path.join(source.dataDir, "overlord.db")); + const archive = createPortableServerBackup( + source, + "3.0.0", + createDatabaseSnapshot(database), + Buffer.from('{"tls":{}}'), + ); + database.close(); + + const originalZip = new AdmZip(archive); + const tamperedZip = new AdmZip(); + for (const entry of originalZip.getEntries()) { + if (entry.isDirectory) continue; + tamperedZip.addFile( + entry.entryName, + entry.entryName === "data/save.json" ? Buffer.from('{"tampered":true}') : entry.getData(), + ); + } + + const destination = pathsAt(tempRoot("overlord-backup-tamper-destination-")); + mkdirSync(destination.dataDir, { recursive: true }); + writeFileSync(path.join(destination.dataDir, "save.json"), '{"keep":"current"}'); + expect(() => stagePortableServerRestore(tamperedZip.toBuffer(), destination)).toThrow( + "checksum validation failed", + ); + expect(existsSync(path.join(destination.dataDir, ".pending-server-restore.json"))).toBe(false); + expect(readFileSync(path.join(destination.dataDir, "save.json"), "utf8")).toBe('{"keep":"current"}'); + + const newerZip = new AdmZip(); + for (const entry of originalZip.getEntries()) { + if (entry.isDirectory) continue; + if (entry.entryName === "manifest.json") { + const manifest = JSON.parse(entry.getData().toString("utf8")); + manifest.serverVersion = "4.0.0"; + newerZip.addFile(entry.entryName, Buffer.from(JSON.stringify(manifest))); + } else { + newerZip.addFile(entry.entryName, entry.getData()); + } + } + expect(() => stagePortableServerRestore(newerZip.toBuffer(), destination, "3.0.0")).toThrow( + "created by newer server 4.0.0", + ); + expect(existsSync(path.join(destination.dataDir, ".pending-server-restore.json"))).toBe(false); + + const unsafeZip = new AdmZip(archive); + unsafeZip.addFile("../outside.txt", Buffer.from("must not escape")); + expect(() => stagePortableServerRestore(unsafeZip.toBuffer(), destination, "3.0.0")).toThrow(); + expect(existsSync(path.join(path.dirname(destination.dataDir), "outside.txt"))).toBe(false); + expect(existsSync(path.join(destination.dataDir, ".pending-server-restore.json"))).toBe(false); + }); +}); diff --git a/Overlord-Server/src/server/server-backup.ts b/Overlord-Server/src/server/server-backup.ts new file mode 100644 index 0000000..208d2bc --- /dev/null +++ b/Overlord-Server/src/server/server-backup.ts @@ -0,0 +1,571 @@ +import AdmZip from "adm-zip"; +import Database from "bun:sqlite"; +import { createHash, createPrivateKey, createPublicKey, X509Certificate } from "crypto"; +import { + chmodSync, + copyFileSync, + cpSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + writeFileSync, +} from "fs"; +import { tmpdir } from "os"; +import path from "path"; +import { ensureDataDir } from "../paths"; +import { resolveRuntimeRoot } from "./runtime-paths"; + +const BACKUP_SCHEMA = "overlord-server-backup"; +const BACKUP_FORMAT_VERSION = 1; +const MAX_ARCHIVE_BYTES = 1024 * 1024 * 1024; +const MAX_ENTRY_COUNT = 20_000; +const MAX_ENTRY_BYTES = 512 * 1024 * 1024; +const MAX_TOTAL_BYTES = 2 * 1024 * 1024 * 1024; +const PENDING_MARKER = ".pending-server-restore.json"; + +type BackupEntry = { + path: string; + size: number; + sha256: string; + required: boolean; +}; + +type BackupManifest = { + schema: typeof BACKUP_SCHEMA; + formatVersion: typeof BACKUP_FORMAT_VERSION; + serverVersion: string; + exportedAt: string; + entries: BackupEntry[]; +}; + +export type ServerBackupPaths = { + dataDir: string; + pluginRoot: string; + tlsCertPath: string; + tlsKeyPath: string; + tlsCaPath?: string; +}; + +const DATA_FILES = ["config.json", "save.json", "vapid-keys.json"] as const; +const DATA_DIRECTORIES = ["file-share", "deploy", "winre", "rd-recordings", "macos-sdk-uploads"] as const; + +function sha256(bytes: Buffer): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +function normalizeArchivePath(value: string): string { + const normalized = value.replace(/\\/g, "/").replace(/^\/+/, ""); + if ( + !normalized || + normalized.includes("\0") || + normalized.split("/").some((part) => part === "" || part === "." || part === "..") + ) { + throw new Error(`Unsafe backup entry path: ${value}`); + } + return normalized; +} + +function isAllowedArchivePath(entryPath: string): boolean { + if (entryPath === "data/overlord.db") return true; + if (DATA_FILES.some((name) => entryPath === `data/${name}`)) return true; + if (entryPath === "tls/server.crt" || entryPath === "tls/server.key" || entryPath === "tls/ca.crt") return true; + if (entryPath.startsWith("plugins/")) return true; + return DATA_DIRECTORIES.some((name) => entryPath.startsWith(`data/${name}/`)); +} + +function addArchiveFile( + zip: AdmZip, + manifestEntries: BackupEntry[], + entryPath: string, + bytes: Buffer, + required: boolean, +): void { + const safePath = normalizeArchivePath(entryPath); + if (!isAllowedArchivePath(safePath)) { + throw new Error(`Backup entry is outside the portable server allowlist: ${safePath}`); + } + if (bytes.length > MAX_ENTRY_BYTES) { + throw new Error(`Backup entry exceeds the ${MAX_ENTRY_BYTES} byte limit: ${safePath}`); + } + zip.addFile(safePath, bytes); + manifestEntries.push({ + path: safePath, + size: bytes.length, + sha256: sha256(bytes), + required, + }); +} + +function addDirectory( + zip: AdmZip, + manifestEntries: BackupEntry[], + sourceRoot: string, + archiveRoot: string, +): void { + if (!existsSync(sourceRoot)) return; + const walk = (current: string, relative: string): void => { + for (const item of readdirSync(current, { withFileTypes: true })) { + const sourcePath = path.join(current, item.name); + const relativePath = relative ? `${relative}/${item.name}` : item.name; + const archivePath = `${archiveRoot}/${relativePath}`; + const stat = lstatSync(sourcePath); + if (stat.isSymbolicLink()) { + throw new Error(`Refusing to back up symbolic link: ${sourcePath}`); + } + if (stat.isDirectory()) { + walk(sourcePath, relativePath); + } else if (stat.isFile()) { + addArchiveFile(zip, manifestEntries, archivePath, readFileSync(sourcePath), false); + } + } + }; + walk(sourceRoot, ""); +} + +function validateTlsIdentity(certPem: Buffer, keyPem: Buffer): void { + const certificate = new X509Certificate(certPem); + const certificateSpki = certificate.publicKey.export({ type: "spki", format: "der" }); + const keySpki = createPublicKey(createPrivateKey(keyPem)).export({ type: "spki", format: "der" }); + if (!Buffer.from(certificateSpki).equals(Buffer.from(keySpki))) { + throw new Error("TLS certificate and private key do not match"); + } +} + +export function resolveServerBackupPaths(overrides: Partial = {}): ServerBackupPaths { + const dataDir = path.resolve(overrides.dataDir || ensureDataDir()); + const runtimeRoot = resolveRuntimeRoot(); + return { + dataDir, + pluginRoot: path.resolve( + overrides.pluginRoot || + process.env.OVERLORD_PLUGIN_ROOT?.trim() || + path.join(runtimeRoot, "plugins"), + ), + tlsCertPath: path.resolve( + overrides.tlsCertPath || + process.env.OVERLORD_TLS_CERT?.trim() || + "./certs/server.crt", + ), + tlsKeyPath: path.resolve( + overrides.tlsKeyPath || + process.env.OVERLORD_TLS_KEY?.trim() || + "./certs/server.key", + ), + tlsCaPath: path.resolve( + overrides.tlsCaPath || + process.env.OVERLORD_TLS_CA?.trim() || + "./certs/ca.crt", + ), + }; +} + +export function createDatabaseSnapshot(database: { exec: (sql: string) => unknown }): Buffer { + const root = mkdtempSync(path.join(tmpdir(), "overlord-db-backup-")); + const snapshotPath = path.join(root, "overlord.db"); + try { + database.exec(`VACUUM INTO '${snapshotPath.replace(/'/g, "''")}'`); + return readFileSync(snapshotPath); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +export function createPortableServerBackup( + paths: ServerBackupPaths, + serverVersion: string, + databaseSnapshot: Buffer, + configSnapshot?: Buffer, +): Buffer { + if (!existsSync(paths.tlsCertPath) || !existsSync(paths.tlsKeyPath)) { + throw new Error("TLS certificate and private key are required for a portable server backup"); + } + + const cert = readFileSync(paths.tlsCertPath); + const key = readFileSync(paths.tlsKeyPath); + validateTlsIdentity(cert, key); + + const zip = new AdmZip(); + const entries: BackupEntry[] = []; + addArchiveFile(zip, entries, "data/overlord.db", databaseSnapshot, true); + addArchiveFile(zip, entries, "tls/server.crt", cert, true); + addArchiveFile(zip, entries, "tls/server.key", key, true); + + if (paths.tlsCaPath && existsSync(paths.tlsCaPath)) { + addArchiveFile(zip, entries, "tls/ca.crt", readFileSync(paths.tlsCaPath), false); + } + for (const name of DATA_FILES) { + const filePath = path.join(paths.dataDir, name); + if (name === "config.json" && configSnapshot) { + JSON.parse(configSnapshot.toString("utf8")); + addArchiveFile(zip, entries, "data/config.json", configSnapshot, true); + } else if (existsSync(filePath)) { + addArchiveFile(zip, entries, `data/${name}`, readFileSync(filePath), name !== "vapid-keys.json"); + } else if (name !== "vapid-keys.json") { + throw new Error(`Required server state file is missing: ${filePath}`); + } + } + for (const name of DATA_DIRECTORIES) { + addDirectory(zip, entries, path.join(paths.dataDir, name), `data/${name}`); + } + addDirectory(zip, entries, paths.pluginRoot, "plugins"); + + if (entries.length > MAX_ENTRY_COUNT) { + throw new Error(`Backup contains too many files (${entries.length}; max ${MAX_ENTRY_COUNT})`); + } + const totalBytes = entries.reduce((sum, entry) => sum + entry.size, 0); + if (totalBytes > MAX_TOTAL_BYTES) { + throw new Error(`Backup contents exceed the ${MAX_TOTAL_BYTES} byte limit`); + } + + const manifest: BackupManifest = { + schema: BACKUP_SCHEMA, + formatVersion: BACKUP_FORMAT_VERSION, + serverVersion, + exportedAt: new Date().toISOString(), + entries, + }; + zip.addFile("manifest.json", Buffer.from(JSON.stringify(manifest, null, 2))); + const archive = zip.toBuffer(); + if (archive.length > MAX_ARCHIVE_BYTES) { + throw new Error(`Compressed backup exceeds the ${MAX_ARCHIVE_BYTES} byte limit`); + } + return archive; +} + +function parseAndValidateArchive(archive: Buffer): { + manifest: BackupManifest; + files: Map; +} { + if (archive.length === 0 || archive.length > MAX_ARCHIVE_BYTES) { + throw new Error("Backup archive is empty or exceeds the maximum size"); + } + + let zip: AdmZip; + try { + zip = new AdmZip(archive); + } catch { + throw new Error("Backup is not a valid ZIP archive"); + } + const zipEntries = zip.getEntries().filter((entry) => !entry.isDirectory); + if (zipEntries.length > MAX_ENTRY_COUNT + 1) { + throw new Error("Backup contains too many entries"); + } + let declaredUncompressedBytes = 0; + for (const entry of zipEntries) { + const declaredSize = Number(entry.header?.size); + if (!Number.isSafeInteger(declaredSize) || declaredSize < 0 || declaredSize > MAX_ENTRY_BYTES) { + throw new Error(`Backup entry has an invalid or excessive declared size: ${entry.entryName}`); + } + declaredUncompressedBytes += declaredSize; + if (declaredUncompressedBytes > MAX_TOTAL_BYTES + 1024 * 1024) { + throw new Error("Backup declares excessive uncompressed content"); + } + } + + const entryMap = new Map(); + for (const entry of zipEntries) { + const safePath = normalizeArchivePath(entry.entryName); + if (entryMap.has(safePath)) throw new Error(`Duplicate backup entry: ${safePath}`); + const bytes = entry.getData(); + if (bytes.length > MAX_ENTRY_BYTES) throw new Error(`Backup entry is too large: ${safePath}`); + entryMap.set(safePath, bytes); + } + + const manifestBytes = entryMap.get("manifest.json"); + if (!manifestBytes || manifestBytes.length > 1024 * 1024) { + throw new Error("Backup manifest is missing or too large"); + } + let manifest: BackupManifest; + try { + manifest = JSON.parse(manifestBytes.toString("utf8")); + } catch { + throw new Error("Backup manifest is invalid JSON"); + } + if (manifest.schema !== BACKUP_SCHEMA || manifest.formatVersion !== BACKUP_FORMAT_VERSION) { + throw new Error("Unsupported server backup format"); + } + if (!Array.isArray(manifest.entries) || manifest.entries.length > MAX_ENTRY_COUNT) { + throw new Error("Backup manifest entries are invalid"); + } + + const files = new Map(); + let totalBytes = 0; + const declared = new Set(); + for (const item of manifest.entries) { + if (!item || typeof item !== "object") throw new Error("Invalid backup manifest entry"); + const safePath = normalizeArchivePath(String(item.path || "")); + if (!isAllowedArchivePath(safePath) || declared.has(safePath)) { + throw new Error(`Invalid or duplicate manifest path: ${safePath}`); + } + declared.add(safePath); + const bytes = entryMap.get(safePath); + if (!bytes) throw new Error(`Backup entry is missing: ${safePath}`); + if (bytes.length !== item.size || sha256(bytes) !== item.sha256) { + throw new Error(`Backup checksum validation failed: ${safePath}`); + } + totalBytes += bytes.length; + if (totalBytes > MAX_TOTAL_BYTES) throw new Error("Uncompressed backup exceeds the maximum size"); + files.set(safePath, bytes); + } + for (const entryPath of entryMap.keys()) { + if (entryPath !== "manifest.json" && !declared.has(entryPath)) { + throw new Error(`Undeclared backup entry: ${entryPath}`); + } + } + for (const required of ["data/overlord.db", "data/config.json", "data/save.json", "tls/server.crt", "tls/server.key"]) { + if (!files.has(required)) throw new Error(`Required backup entry is missing: ${required}`); + } + + validateTlsIdentity(files.get("tls/server.crt")!, files.get("tls/server.key")!); + JSON.parse(files.get("data/config.json")!.toString("utf8")); + JSON.parse(files.get("data/save.json")!.toString("utf8")); + return { manifest, files }; +} + +function validateDatabase(bytes: Buffer): void { + const root = mkdtempSync(path.join(tmpdir(), "overlord-db-validate-")); + const databasePath = path.join(root, "overlord.db"); + try { + writeFileSync(databasePath, bytes, { mode: 0o600 }); + const database = new Database(databasePath, { readonly: true }); + try { + const result = database.query("PRAGMA quick_check").get() as Record | null; + if (!result || !Object.values(result).includes("ok")) { + throw new Error("SQLite integrity check did not return ok"); + } + const tables = database + .query("SELECT name FROM sqlite_master WHERE type = 'table'") + .all() as Array<{ name: string }>; + const names = new Set(tables.map((row) => row.name)); + if (!names.has("users") || !names.has("clients")) { + throw new Error("Backup database does not contain the required Overlord schema"); + } + } finally { + database.close(); + } + } catch (error) { + throw new Error(`Backup database validation failed: ${(error as Error).message}`); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +function writeStagedFiles(stageRoot: string, files: Map): void { + for (const [entryPath, bytes] of files) { + const destination = path.join(stageRoot, ...entryPath.split("/")); + mkdirSync(path.dirname(destination), { recursive: true }); + writeFileSync(destination, bytes, { mode: entryPath.endsWith(".key") || entryPath === "data/save.json" ? 0o600 : 0o644 }); + } +} + +export function stagePortableServerRestore( + archive: Buffer, + paths: ServerBackupPaths, + currentServerVersion?: string, +): { restartRequired: true; serverVersion: string; files: number; warnings: string[] } { + const { manifest, files } = parseAndValidateArchive(archive); + if (currentServerVersion && compareVersions(manifest.serverVersion, currentServerVersion) > 0) { + throw new Error( + `Backup was created by newer server ${manifest.serverVersion}; upgrade this server (${currentServerVersion}) before restoring`, + ); + } + validateDatabase(files.get("data/overlord.db")!); + + const restoredConfig = JSON.parse(files.get("data/config.json")!.toString("utf8")); + restoredConfig.tls = { + ...(restoredConfig.tls && typeof restoredConfig.tls === "object" ? restoredConfig.tls : {}), + certPath: paths.tlsCertPath, + keyPath: paths.tlsKeyPath, + caPath: files.has("tls/ca.crt") ? paths.tlsCaPath : "", + certbot: { + ...(restoredConfig.tls?.certbot && typeof restoredConfig.tls.certbot === "object" + ? restoredConfig.tls.certbot + : {}), + enabled: false, + }, + }; + const portableConfig = Buffer.from(JSON.stringify(restoredConfig, null, 2)); + files.set("data/config.json", portableConfig); + const configEntry = manifest.entries.find((entry) => entry.path === "data/config.json")!; + configEntry.size = portableConfig.length; + configEntry.sha256 = sha256(portableConfig); + + mkdirSync(paths.dataDir, { recursive: true }); + const markerPath = path.join(paths.dataDir, PENDING_MARKER); + if (existsSync(markerPath)) { + throw new Error("A server restore is already pending; restart the server before importing another backup"); + } + const stageRoot = mkdtempSync(path.join(paths.dataDir, ".server-restore-stage-")); + try { + writeStagedFiles(stageRoot, files); + writeFileSync(path.join(stageRoot, "manifest.json"), JSON.stringify(manifest, null, 2), { mode: 0o600 }); + const markerTemp = `${markerPath}.tmp`; + writeFileSync(markerTemp, JSON.stringify({ + schema: BACKUP_SCHEMA, + formatVersion: BACKUP_FORMAT_VERSION, + stageDirectory: path.basename(stageRoot), + stagedAt: new Date().toISOString(), + }), { mode: 0o600 }); + renameSync(markerTemp, markerPath); + } catch (error) { + rmSync(stageRoot, { recursive: true, force: true }); + throw error; + } + + const warnings: string[] = []; + if (String(process.env.JWT_SECRET || "").trim() || String(process.env.OVERLORD_AGENT_TOKEN || "").trim()) { + warnings.push("JWT_SECRET or OVERLORD_AGENT_TOKEN environment overrides will take priority over restored identity secrets."); + } + return { + restartRequired: true, + serverVersion: manifest.serverVersion, + files: files.size, + warnings, + }; +} + +function replaceFile(source: string, destination: string, rollbackRoot: string, label: string): void { + mkdirSync(path.dirname(destination), { recursive: true }); + const rollback = path.join(rollbackRoot, label); + mkdirSync(path.dirname(rollback), { recursive: true }); + const incoming = `${destination}.restore-incoming`; + const hadDestination = existsSync(destination); + if (hadDestination) renameSync(destination, rollback); + try { + copyFileSync(source, incoming); + chmodSync(incoming, destination.endsWith(".key") || destination.endsWith("save.json") ? 0o600 : 0o644); + renameSync(incoming, destination); + } catch (error) { + rmSync(incoming, { force: true }); + if (hadDestination && existsSync(rollback) && !existsSync(destination)) { + renameSync(rollback, destination); + } + throw error; + } +} + +function replaceDirectory(source: string, destination: string, rollbackRoot: string, label: string): void { + const rollback = path.join(rollbackRoot, label); + mkdirSync(path.dirname(destination), { recursive: true }); + mkdirSync(path.dirname(rollback), { recursive: true }); + const incoming = `${destination}.restore-incoming`; + const hadDestination = existsSync(destination); + if (hadDestination) renameSync(destination, rollback); + try { + if (existsSync(source)) { + cpSync(source, incoming, { recursive: true, errorOnExist: true }); + renameSync(incoming, destination); + } else { + mkdirSync(destination, { recursive: true }); + } + } catch (error) { + rmSync(incoming, { recursive: true, force: true }); + if (hadDestination && existsSync(rollback) && !existsSync(destination)) { + renameSync(rollback, destination); + } + throw error; + } +} + +function compareVersions(left: string, right: string): number { + const parse = (value: string): number[] | null => { + const match = String(value || "").trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/); + return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null; + }; + const a = parse(left); + const b = parse(right); + if (!a || !b) return 0; + for (let index = 0; index < 3; index++) { + if (a[index] !== b[index]) return a[index] > b[index] ? 1 : -1; + } + return 0; +} + +export function applyPendingServerRestore(overrides: Partial = {}): boolean { + const paths = resolveServerBackupPaths(overrides); + const markerPath = path.join(paths.dataDir, PENDING_MARKER); + if (!existsSync(markerPath)) return false; + + const marker = JSON.parse(readFileSync(markerPath, "utf8")) as { + schema?: string; + formatVersion?: number; + stageDirectory?: string; + }; + if ( + marker.schema !== BACKUP_SCHEMA || + marker.formatVersion !== BACKUP_FORMAT_VERSION || + !marker.stageDirectory || + path.basename(marker.stageDirectory) !== marker.stageDirectory || + !marker.stageDirectory.startsWith(".server-restore-stage-") + ) { + throw new Error("Pending server restore marker is invalid"); + } + const stageRoot = path.join(paths.dataDir, marker.stageDirectory); + const manifestPath = path.join(stageRoot, "manifest.json"); + if (!existsSync(manifestPath)) throw new Error("Pending server restore staging directory is incomplete"); + + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as BackupManifest; + const stagedZip = new AdmZip(); + for (const entry of manifest.entries) { + const source = path.join(stageRoot, ...normalizeArchivePath(entry.path).split("/")); + if (!existsSync(source)) throw new Error(`Pending restore entry is missing: ${entry.path}`); + const bytes = readFileSync(source); + if (bytes.length !== entry.size || sha256(bytes) !== entry.sha256) { + throw new Error(`Pending restore checksum failed: ${entry.path}`); + } + stagedZip.addFile(entry.path, bytes); + } + stagedZip.addFile("manifest.json", Buffer.from(JSON.stringify(manifest))); + parseAndValidateArchive(stagedZip.toBuffer()); + + const rollbackRoot = mkdtempSync(path.join(paths.dataDir, ".server-restore-rollback-")); + const completed: Array<{ destination: string; rollback: string; directory: boolean }> = []; + const replaceTracked = (source: string, destination: string, label: string, directory: boolean) => { + const rollback = path.join(rollbackRoot, label); + if (directory) replaceDirectory(source, destination, rollbackRoot, label); + else replaceFile(source, destination, rollbackRoot, label); + completed.push({ destination, rollback, directory }); + }; + + try { + replaceTracked(path.join(stageRoot, "data", "overlord.db"), path.join(paths.dataDir, "overlord.db"), "data/overlord.db", false); + for (const name of DATA_FILES) { + const source = path.join(stageRoot, "data", name); + if (existsSync(source)) replaceTracked(source, path.join(paths.dataDir, name), `data/${name}`, false); + } + for (const name of DATA_DIRECTORIES) { + replaceTracked(path.join(stageRoot, "data", name), path.join(paths.dataDir, name), `data/${name}`, true); + } + replaceTracked(path.join(stageRoot, "plugins"), paths.pluginRoot, "plugins", true); + replaceTracked(path.join(stageRoot, "tls", "server.crt"), paths.tlsCertPath, "tls/server.crt", false); + replaceTracked(path.join(stageRoot, "tls", "server.key"), paths.tlsKeyPath, "tls/server.key", false); + const stagedCa = path.join(stageRoot, "tls", "ca.crt"); + if (paths.tlsCaPath && existsSync(stagedCa)) { + replaceTracked(stagedCa, paths.tlsCaPath, "tls/ca.crt", false); + } + for (const suffix of ["-wal", "-shm"]) { + const sidecar = path.join(paths.dataDir, `overlord.db${suffix}`); + if (existsSync(sidecar)) rmSync(sidecar, { force: true }); + } + rmSync(markerPath, { force: true }); + rmSync(stageRoot, { recursive: true, force: true }); + rmSync(rollbackRoot, { recursive: true, force: true }); + return true; + } catch (error) { + for (const item of completed.reverse()) { + try { + rmSync(item.destination, { recursive: item.directory, force: true }); + if (existsSync(item.rollback)) { + mkdirSync(path.dirname(item.destination), { recursive: true }); + renameSync(item.rollback, item.destination); + } + } catch {} + } + throw new Error(`Server restore failed and was rolled back: ${(error as Error).message}`); + } +} diff --git a/Overlord-Server/src/server/tls-bootstrap.test.ts b/Overlord-Server/src/server/tls-bootstrap.test.ts new file mode 100644 index 0000000..9c85fa7 --- /dev/null +++ b/Overlord-Server/src/server/tls-bootstrap.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createHash, X509Certificate } from "crypto"; +import { mkdtempSync, readFileSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import path from "path"; +import { + computeCertificateSpkiPin, + getActiveTlsSpkiPins, + prepareTlsOptions, +} from "./tls-bootstrap"; + +const originalPins = process.env.OVERLORD_TLS_SPKI_PINS; +const tempRoots: string[] = []; + +afterEach(() => { + if (originalPins === undefined) delete process.env.OVERLORD_TLS_SPKI_PINS; + else process.env.OVERLORD_TLS_SPKI_PINS = originalPins; + for (const root of tempRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe("TLS identity pin bootstrap", () => { + test("creates a self-signed certificate and publishes its SPKI pin", async () => { + const root = mkdtempSync(path.join(tmpdir(), "overlord-tls-pin-")); + tempRoots.push(root); + const certPath = path.join(root, "server.crt"); + const keyPath = path.join(root, "server.key"); + const rotationPin = Buffer.alloc(32, 0x42).toString("base64"); + process.env.OVERLORD_TLS_SPKI_PINS = + `sha256/${rotationPin},invalid,${rotationPin}`; + + const result = await prepareTlsOptions({ certPath, keyPath }); + const certificatePem = readFileSync(certPath, "utf8"); + const certificate = new X509Certificate(certificatePem); + const expectedPin = createHash("sha256") + .update(certificate.publicKey.export({ type: "spki", format: "der" })) + .digest("base64"); + + expect(result.source).toBe("self-signed"); + expect(result.tlsOptions.cert).toBe(certificatePem); + expect(computeCertificateSpkiPin(certificatePem)).toBe(expectedPin); + expect(getActiveTlsSpkiPins()).toEqual([expectedPin, rotationPin]); + }); +}); diff --git a/Overlord-Server/src/server/tls-bootstrap.ts b/Overlord-Server/src/server/tls-bootstrap.ts index 4e78a9c..8e2bf2a 100644 --- a/Overlord-Server/src/server/tls-bootstrap.ts +++ b/Overlord-Server/src/server/tls-bootstrap.ts @@ -1,6 +1,6 @@ import { certificatesExist, generateSelfSignedCert, getLocalIPs, isOpenSSLAvailable } from "../certGenerator"; import { logger } from "../logger"; -import { X509Certificate } from "crypto"; +import { X509Certificate, createHash } from "crypto"; import path from "path"; type TlsBootstrapParams = { @@ -23,6 +23,39 @@ type TlsBootstrapResult = { source: "certbot" | "configured" | "self-signed"; }; +let activeTlsSpkiPins: string[] = []; + +function normalizeSpkiPins(value: string | undefined): string[] { + if (!value) return []; + return [...new Set(value.split(",").map((pin) => + pin.trim().replace(/^sha256\//i, "") + ).filter((pin) => + /^[A-Za-z0-9+/]{43}=$/.test(pin) + ))]; +} + +export function computeCertificateSpkiPin(certificatePem: string): string { + const certificate = new X509Certificate(certificatePem); + const spki = certificate.publicKey.export({ type: "spki", format: "der" }); + return createHash("sha256").update(spki).digest("base64"); +} + +function publishActiveTlsPins(certificatePem: string): void { + activeTlsSpkiPins = [ + computeCertificateSpkiPin(certificatePem), + ...normalizeSpkiPins(process.env.OVERLORD_TLS_SPKI_PINS), + ].filter((pin, index, pins) => pins.indexOf(pin) === index); + logger.info(`[TLS] Active SPKI pin: sha256/${activeTlsSpkiPins[0]}`); + if (activeTlsSpkiPins.length > 1) { + logger.info(`[TLS] ${activeTlsSpkiPins.length - 1} additional SPKI rotation pin(s) configured`); + } +} + +export function getActiveTlsSpkiPins(): string[] { + if (activeTlsSpkiPins.length > 0) return [...activeTlsSpkiPins]; + return normalizeSpkiPins(process.env.OVERLORD_TLS_SPKI_PINS); +} + export async function prepareTlsOptions(params: TlsBootstrapParams): Promise { logger.info("[TLS] TLS/HTTPS is always enabled for security"); @@ -57,6 +90,7 @@ export async function prepareTlsOptions(params: TlsBootstrapParams): Promise