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.
- Import — Restore settings from a previously exported JSON file.
+ Import — Validate and stage a portable ZIP backup. Legacy settings JSON files remain supported.
-
Auth token changes require a server restart. Environment variables will still take priority.
+
Portable restores are validated now and applied before startup with rollback protection. Explicit environment variables still take priority.
Import Settings
-
+
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