pinning + new version of backups

This commit is contained in:
KingKDot
2026-07-30 14:03:16 -05:00
parent 477b9a5353
commit 2669ab1388
32 changed files with 1527 additions and 133 deletions
+5
View File
@@ -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
+25 -1
View File
@@ -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 != "" {
@@ -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")
}
}
+19 -14
View File
@@ -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)
}
@@ -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,
},
}
@@ -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)
}
@@ -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)
}
}
@@ -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
+25 -32
View File
@@ -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(&currentIndex, 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 {
@@ -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[:]),
)
}
@@ -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
}
+2 -1
View File
@@ -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.
@@ -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
}
+6
View File
@@ -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
-21
View File
@@ -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");
}
+24 -19
View File
@@ -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();
+6 -6
View File
@@ -1536,16 +1536,16 @@
</div>
<div>
<h2 class="text-lg font-semibold">Export &amp; Import Settings</h2>
<p class="text-sm text-slate-400">Backup or restore your Overlord configuration (notifications, security, TLS, appearance, auth tokens).</p>
<p class="text-sm text-slate-400">Create or restore a portable server backup without changing its pinned identity.</p>
</div>
</div>
<div class="rounded-lg border border-slate-700 bg-slate-900 px-4 py-4 flex flex-col sm:flex-row sm:items-center gap-4">
<div class="flex-1 space-y-1">
<p class="text-sm text-slate-300">
<strong>Export</strong> &mdash; Downloads a JSON file with all current settings and auth tokens.
<strong>Export</strong> &mdash; Downloads a checksummed ZIP containing the database, settings, identity keys, TLS certificate/private key, plugins, and persistent server files.
</p>
<p class="text-xs text-slate-500">Keep this file secure &mdash; it contains sensitive tokens.</p>
<p class="text-xs text-slate-500">Keep this file secure &mdash; it contains private keys, credentials, users, and server data.</p>
</div>
<button
id="export-settings-btn"
@@ -1560,16 +1560,16 @@
<div class="rounded-lg border border-slate-700 bg-slate-900 px-4 py-4 flex flex-col sm:flex-row sm:items-center gap-4">
<div class="flex-1 space-y-1">
<p class="text-sm text-slate-300">
<strong>Import</strong> &mdash; Restore settings from a previously exported JSON file.
<strong>Import</strong> &mdash; Validate and stage a portable ZIP backup. Legacy settings JSON files remain supported.
</p>
<p class="text-xs text-slate-500">Auth token changes require a server restart. Environment variables will still take priority.</p>
<p class="text-xs text-slate-500">Portable restores are validated now and applied before startup with rollback protection. Explicit environment variables still take priority.</p>
</div>
<label
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-slate-700 hover:bg-slate-600 transition-colors text-white text-sm font-medium whitespace-nowrap cursor-pointer"
>
<i class="fa-solid fa-upload"></i>
Import Settings
<input id="import-settings-file" type="file" accept=".json,application/json" class="hidden" />
<input id="import-settings-file" type="file" accept=".zip,application/zip,.json,application/json" class="hidden" />
</label>
</div>
+6
View File
@@ -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;
}>;
}
+7 -1
View File
@@ -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");
+4
View File
@@ -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: {
@@ -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("");
});
});
@@ -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;
@@ -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<any> | null = null;
let jscRuntimePromise: Promise<any> | 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();
@@ -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);
});
});
@@ -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);
});
});
+571
View File
@@ -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> = {}): 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<string, Buffer>;
} {
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<string, Buffer>();
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<string, Buffer>();
let totalBytes = 0;
const declared = new Set<string>();
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<string, unknown> | 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<string, Buffer>): 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<ServerBackupPaths> = {}): 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}`);
}
}
@@ -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]);
});
});
+36 -1
View File
@@ -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<TlsBootstrapResult> {
logger.info("[TLS] TLS/HTTPS is always enabled for security");
@@ -57,6 +90,7 @@ export async function prepareTlsOptions(params: TlsBootstrapParams): Promise<Tls
if (await caFile.exists()) {
tlsOptions.ca = await caFile.text();
}
publishActiveTlsPins(tlsOptions.cert!);
return {
tlsOptions,
@@ -128,6 +162,7 @@ export async function prepareTlsOptions(params: TlsBootstrapParams): Promise<Tls
}
} catch { }
}
publishActiveTlsPins(tlsOptions.cert!);
return {
tlsOptions,
+15 -1
View File
@@ -54,6 +54,15 @@ Operators are responsible for securing their deployment. At minimum:
- Restrict network exposure and place internet-facing installations behind a
properly configured TLS endpoint.
- Use generated agents with embedded TLS SPKI pins. When TLS terminates at an
external proxy, set `OVERLORD_TLS_SPKI_PINS` to the proxy certificate's
public-key pin before building agents.
- Rotate certificate keys with overlapping pins: add the next pin, rebuild and
deploy agents, switch the certificate, and remove the old pin only after the
fleet has updated.
- Never enable `OVERLORD_TLS_INSECURE_SKIP_VERIFY` outside isolated
development. Unpinned agents now use normal certificate-chain and hostname
verification by default.
- Change or otherwise protect default credentials before exposing the service
to untrusted networks.
- Protect `save.json`, databases, certificates, signing keys, agent tokens,
@@ -65,6 +74,12 @@ Operators are responsible for securing their deployment. At minimum:
- Review plugins and signing keys before installation.
- Back up persistent data and periodically test restoration.
The Settings → Export / Import backup is a portable, checksummed server
archive. It includes the self-signed certificate and private key, database,
runtime identity secrets, plugins, and persistent server data. Restoring it
before moving agents preserves the server's SPKI identity. Protect the archive
like a private key and restart the destination server after import.
## Scope
The following are generally in scope when they affect the latest release:
@@ -87,4 +102,3 @@ The following are generally not security vulnerabilities by themselves:
- Automated scanner output without validation.
- Social engineering, physical attacks, or attacks against third-party
services outside the project's control.
+1
View File
@@ -27,6 +27,7 @@ services:
- OVERLORD_TLS_CERT=/app/certs/server.crt
- OVERLORD_TLS_KEY=/app/certs/server.key
- OVERLORD_TLS_CA=
- OVERLORD_TLS_SPKI_PINS=${OVERLORD_TLS_SPKI_PINS:-}
# Set true only behind a trusted reverse proxy that terminates TLS (for example Render).
# Default false keeps current behavior: Overlord terminates TLS in-process.
- OVERLORD_TLS_OFFLOAD=false
+3
View File
@@ -27,6 +27,9 @@ services:
OVERLORD_TLS_CERT: ${OVERLORD_TLS_CERT:-/app/certs/server.crt}
OVERLORD_TLS_KEY: ${OVERLORD_TLS_KEY:-/app/certs/server.key}
OVERLORD_TLS_CA: ${OVERLORD_TLS_CA:-}
# Required for generated-agent pinning when TLS is terminated by a proxy.
# May contain current,next pins during certificate key rotation.
OVERLORD_TLS_SPKI_PINS: ${OVERLORD_TLS_SPKI_PINS:-}
# Set true only behind a trusted reverse proxy that terminates TLS (for example Render).
# Default false keeps current behavior: Overlord terminates TLS in-process.
OVERLORD_TLS_OFFLOAD: ${OVERLORD_TLS_OFFLOAD:-false}
+3
View File
@@ -27,6 +27,9 @@ services:
OVERLORD_TLS_CERT: ${OVERLORD_TLS_CERT:-/app/certs/server.crt}
OVERLORD_TLS_KEY: ${OVERLORD_TLS_KEY:-/app/certs/server.key}
OVERLORD_TLS_CA: ${OVERLORD_TLS_CA:-}
# Required for generated-agent pinning when TLS is terminated by a proxy.
# May contain current,next pins during certificate key rotation.
OVERLORD_TLS_SPKI_PINS: ${OVERLORD_TLS_SPKI_PINS:-}
# Set true only behind a trusted reverse proxy that terminates TLS (for example Render).
# Default false keeps current behavior: Overlord terminates TLS in-process.
OVERLORD_TLS_OFFLOAD: ${OVERLORD_TLS_OFFLOAD:-false}