pinning + new version of backups
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1536,16 +1536,16 @@
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold">Export & 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> — Downloads a JSON file with all current settings and auth tokens.
|
||||
<strong>Export</strong> — 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 — it contains sensitive tokens.</p>
|
||||
<p class="text-xs text-slate-500">Keep this file secure — 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> — Restore settings from a previously exported JSON file.
|
||||
<strong>Import</strong> — 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>
|
||||
|
||||
|
||||
Vendored
+6
@@ -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;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user