mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-06 05:29:07 +02:00
Add transparent proxy inspection engine with envoy sidecar support
This commit is contained in:
@@ -562,6 +562,9 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf
|
||||
MTU: selectMTU(config.MTU, peerConfig.Mtu),
|
||||
LogPath: logPath,
|
||||
|
||||
InspectionCACertPath: config.InspectionCACertPath,
|
||||
InspectionCAKeyPath: config.InspectionCAKeyPath,
|
||||
|
||||
ProfileConfig: config,
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/iface/device"
|
||||
nbnetstack "github.com/netbirdio/netbird/client/iface/netstack"
|
||||
"github.com/netbirdio/netbird/client/iface/udpmux"
|
||||
"github.com/netbirdio/netbird/client/inspect"
|
||||
"github.com/netbirdio/netbird/client/internal/acl"
|
||||
"github.com/netbirdio/netbird/client/internal/debug"
|
||||
"github.com/netbirdio/netbird/client/internal/dns"
|
||||
@@ -136,6 +137,12 @@ type EngineConfig struct {
|
||||
|
||||
MTU uint16
|
||||
|
||||
// InspectionCACertPath is a local CA cert for transparent proxy MITM.
|
||||
// Takes priority over management-pushed CA.
|
||||
InspectionCACertPath string
|
||||
// InspectionCAKeyPath is the corresponding private key.
|
||||
InspectionCAKeyPath string
|
||||
|
||||
// for debug bundle generation
|
||||
ProfileConfig *profilemanager.Config
|
||||
|
||||
@@ -222,6 +229,10 @@ type Engine struct {
|
||||
latestSyncResponse *mgmProto.SyncResponse
|
||||
flowManager nftypes.FlowManager
|
||||
|
||||
// transparentProxy is the transparent forward proxy for traffic inspection.
|
||||
transparentProxy *inspect.Proxy
|
||||
udpInspectionHookID string
|
||||
|
||||
// auto-update
|
||||
updateManager *updater.Manager
|
||||
|
||||
@@ -1272,6 +1283,9 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error {
|
||||
fwdEntries := toRouteDomains(e.config.WgPrivateKey.PublicKey().String(), routes)
|
||||
e.updateDNSForwarder(dnsRouteFeatureFlag, fwdEntries)
|
||||
|
||||
// Transparent proxy
|
||||
e.updateTransparentProxy(networkMap.GetTransparentProxyConfig())
|
||||
|
||||
// Ingress forward rules
|
||||
forwardingRules, err := e.updateForwardRules(networkMap.GetForwardingRules())
|
||||
if err != nil {
|
||||
@@ -1695,6 +1709,8 @@ func (e *Engine) parseNATExternalIPMappings() []string {
|
||||
func (e *Engine) close() {
|
||||
log.Debugf("removing Netbird interface %s", e.config.WgIfaceName)
|
||||
|
||||
e.stopTransparentProxy()
|
||||
|
||||
if e.wgInterface != nil {
|
||||
if err := e.wgInterface.Close(); err != nil {
|
||||
log.Errorf("failed closing Netbird interface %s %v", e.config.WgIfaceName, err)
|
||||
|
||||
@@ -0,0 +1,571 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/firewall/uspfilter/forwarder"
|
||||
"github.com/netbirdio/netbird/client/inspect"
|
||||
"github.com/netbirdio/netbird/client/internal/acl/id"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
// updateTransparentProxy processes transparent proxy configuration from the network map.
|
||||
func (e *Engine) updateTransparentProxy(cfg *mgmProto.TransparentProxyConfig) {
|
||||
if cfg == nil || !cfg.Enabled {
|
||||
if cfg == nil {
|
||||
log.Tracef("inspect: config is nil")
|
||||
} else {
|
||||
log.Tracef("inspect: config disabled")
|
||||
}
|
||||
// Only stop if explicitly disabled. Don't stop on nil config to avoid
|
||||
// a gap during policy edits where management briefly pushes empty config.
|
||||
if cfg != nil && !cfg.Enabled {
|
||||
e.stopTransparentProxy()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
log.Debugf("inspect: config received: enabled=%v mode=%v default_action=%v rules=%d has_ca=%v",
|
||||
cfg.Enabled, cfg.Mode, cfg.DefaultAction, len(cfg.Rules), len(cfg.CaCertPem) > 0)
|
||||
|
||||
// BlockInbound prevents adding TPROXY rules since kernel TPROXY bypasses ACLs.
|
||||
// The userspace forwarder path still works as it operates within the forwarder hook.
|
||||
if e.config.BlockInbound {
|
||||
log.Warnf("inspect: BlockInbound is set, skipping redirect rules (userspace path still active)")
|
||||
}
|
||||
|
||||
proxyConfig, err := toProxyConfig(cfg)
|
||||
if err != nil {
|
||||
log.Errorf("inspect: parse config: %v", err)
|
||||
e.stopTransparentProxy()
|
||||
return
|
||||
}
|
||||
|
||||
// CA priority: local config > management-pushed > auto-generated self-signed.
|
||||
// Local wins over mgmt to prevent compromised management from injecting a CA.
|
||||
e.resolveInspectionCA(&proxyConfig)
|
||||
|
||||
if e.transparentProxy != nil {
|
||||
// Mode change requires full recreate (envoy lifecycle, listener changes).
|
||||
if proxyConfig.Mode != e.transparentProxy.Mode() {
|
||||
log.Infof("inspect: mode changed to %s, recreating engine", proxyConfig.Mode)
|
||||
e.stopTransparentProxy()
|
||||
} else {
|
||||
e.transparentProxy.UpdateConfig(proxyConfig)
|
||||
e.syncTProxyRules(proxyConfig)
|
||||
e.syncUDPInspectionHook()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if e.wgInterface != nil {
|
||||
proxyConfig.WGNetwork = e.wgInterface.Address().Network
|
||||
proxyConfig.ListenAddr = netip.AddrPortFrom(
|
||||
e.wgInterface.Address().IP.Unmap(),
|
||||
proxyConfig.ListenAddr.Port(),
|
||||
)
|
||||
}
|
||||
|
||||
// Pass local IP checker for SSRF prevention
|
||||
if checker, ok := e.firewall.(inspect.LocalIPChecker); ok {
|
||||
proxyConfig.LocalIPChecker = checker
|
||||
}
|
||||
|
||||
p, err := inspect.New(e.ctx, log.WithField("component", "inspect"), proxyConfig)
|
||||
if err != nil {
|
||||
log.Errorf("inspect: start engine: %v", err)
|
||||
return
|
||||
}
|
||||
e.transparentProxy = p
|
||||
|
||||
e.attachProxyToForwarder(p)
|
||||
e.syncTProxyRules(proxyConfig)
|
||||
e.syncUDPInspectionHook()
|
||||
|
||||
log.Infof("inspect: engine started (mode=%s, rules=%d)", proxyConfig.Mode, len(proxyConfig.Rules))
|
||||
}
|
||||
|
||||
// stopTransparentProxy shuts down the transparent proxy and removes interception.
|
||||
func (e *Engine) stopTransparentProxy() {
|
||||
if e.transparentProxy == nil {
|
||||
return
|
||||
}
|
||||
|
||||
e.attachProxyToForwarder(nil)
|
||||
e.removeTProxyRule()
|
||||
e.removeUDPInspectionHook()
|
||||
|
||||
if err := e.transparentProxy.Close(); err != nil {
|
||||
log.Debugf("inspect: close engine: %v", err)
|
||||
}
|
||||
e.transparentProxy = nil
|
||||
|
||||
log.Info("inspect: engine stopped")
|
||||
}
|
||||
|
||||
const tproxyRuleID = "tproxy-redirect"
|
||||
|
||||
// syncTProxyRules adds a TPROXY rule via the firewall manager to intercept
|
||||
// matching traffic on the WG interface and redirect it to the proxy socket.
|
||||
func (e *Engine) syncTProxyRules(config inspect.Config) {
|
||||
if e.config.BlockInbound {
|
||||
e.removeTProxyRule()
|
||||
return
|
||||
}
|
||||
|
||||
var listenPort uint16
|
||||
if e.transparentProxy != nil {
|
||||
listenPort = e.transparentProxy.ListenPort()
|
||||
}
|
||||
if listenPort == 0 {
|
||||
e.removeTProxyRule()
|
||||
return
|
||||
}
|
||||
|
||||
if e.firewall == nil {
|
||||
return
|
||||
}
|
||||
|
||||
dstPorts := make([]uint16, len(config.RedirectPorts))
|
||||
copy(dstPorts, config.RedirectPorts)
|
||||
|
||||
log.Debugf("inspect: syncing redirect rules: listen port %d, redirect ports %v, sources %v",
|
||||
listenPort, dstPorts, config.RedirectSources)
|
||||
|
||||
if err := e.firewall.AddTProxyRule(tproxyRuleID, config.RedirectSources, dstPorts, listenPort); err != nil {
|
||||
log.Errorf("inspect: add redirect rule: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// removeTProxyRule removes the TPROXY redirect rule.
|
||||
func (e *Engine) removeTProxyRule() {
|
||||
if e.firewall == nil {
|
||||
return
|
||||
}
|
||||
if err := e.firewall.RemoveTProxyRule(tproxyRuleID); err != nil {
|
||||
log.Debugf("inspect: remove redirect rule: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// syncUDPInspectionHook registers a UDP packet hook on port 443 for QUIC SNI blocking.
|
||||
// The hook is called by the USP filter for each UDP packet matching the port,
|
||||
// allowing the inspection engine to extract QUIC SNI and block by domain.
|
||||
func (e *Engine) syncUDPInspectionHook() {
|
||||
e.removeUDPInspectionHook()
|
||||
|
||||
if e.firewall == nil || e.transparentProxy == nil {
|
||||
return
|
||||
}
|
||||
|
||||
p := e.transparentProxy
|
||||
hookID := e.firewall.AddUDPInspectionHook(443, func(packet []byte) bool {
|
||||
srcIP, dstIP, dstPort, udpPayload, ok := parseUDPPacket(packet)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
src := inspect.SourceInfo{IP: srcIP}
|
||||
dst := netip.AddrPortFrom(dstIP, dstPort)
|
||||
action := p.HandleUDPPacket(udpPayload, dst, src)
|
||||
return action == inspect.ActionBlock
|
||||
})
|
||||
|
||||
e.udpInspectionHookID = hookID
|
||||
log.Debugf("inspect: registered UDP inspection hook on port 443 (id=%s)", hookID)
|
||||
}
|
||||
|
||||
// removeUDPInspectionHook removes the QUIC inspection hook.
|
||||
func (e *Engine) removeUDPInspectionHook() {
|
||||
if e.udpInspectionHookID == "" || e.firewall == nil {
|
||||
return
|
||||
}
|
||||
e.firewall.RemoveUDPInspectionHook(e.udpInspectionHookID)
|
||||
e.udpInspectionHookID = ""
|
||||
}
|
||||
|
||||
// parseUDPPacket extracts source/destination IP, destination port, and UDP
|
||||
// payload from a raw IP packet. Supports both IPv4 and IPv6.
|
||||
func parseUDPPacket(packet []byte) (srcIP, dstIP netip.Addr, dstPort uint16, payload []byte, ok bool) {
|
||||
if len(packet) < 1 {
|
||||
return srcIP, dstIP, 0, nil, false
|
||||
}
|
||||
|
||||
version := packet[0] >> 4
|
||||
|
||||
var udpOffset int
|
||||
switch version {
|
||||
case 4:
|
||||
if len(packet) < 20 {
|
||||
return srcIP, dstIP, 0, nil, false
|
||||
}
|
||||
ihl := int(packet[0]&0x0f) * 4
|
||||
if len(packet) < ihl+8 {
|
||||
return srcIP, dstIP, 0, nil, false
|
||||
}
|
||||
var srcOK, dstOK bool
|
||||
srcIP, srcOK = netip.AddrFromSlice(packet[12:16])
|
||||
dstIP, dstOK = netip.AddrFromSlice(packet[16:20])
|
||||
if !srcOK || !dstOK {
|
||||
return srcIP, dstIP, 0, nil, false
|
||||
}
|
||||
udpOffset = ihl
|
||||
|
||||
case 6:
|
||||
// IPv6 fixed header is 40 bytes. Next header must be UDP (17).
|
||||
if len(packet) < 48 { // 40 header + 8 UDP
|
||||
return srcIP, dstIP, 0, nil, false
|
||||
}
|
||||
nextHeader := packet[6]
|
||||
if nextHeader != 17 { // not UDP (may have extension headers)
|
||||
return srcIP, dstIP, 0, nil, false
|
||||
}
|
||||
var srcOK, dstOK bool
|
||||
srcIP, srcOK = netip.AddrFromSlice(packet[8:24])
|
||||
dstIP, dstOK = netip.AddrFromSlice(packet[24:40])
|
||||
if !srcOK || !dstOK {
|
||||
return srcIP, dstIP, 0, nil, false
|
||||
}
|
||||
udpOffset = 40
|
||||
|
||||
default:
|
||||
return srcIP, dstIP, 0, nil, false
|
||||
}
|
||||
|
||||
srcIP = srcIP.Unmap()
|
||||
dstIP = dstIP.Unmap()
|
||||
dstPort = uint16(packet[udpOffset+2])<<8 | uint16(packet[udpOffset+3])
|
||||
payload = packet[udpOffset+8:]
|
||||
|
||||
return srcIP, dstIP, dstPort, payload, true
|
||||
}
|
||||
|
||||
// attachProxyToForwarder sets or clears the proxy on the userspace forwarder.
|
||||
func (e *Engine) attachProxyToForwarder(p *inspect.Proxy) {
|
||||
type forwarderGetter interface {
|
||||
GetForwarder() *forwarder.Forwarder
|
||||
}
|
||||
|
||||
if fg, ok := e.firewall.(forwarderGetter); ok {
|
||||
if fwd := fg.GetForwarder(); fwd != nil {
|
||||
fwd.SetProxy(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// toProxyConfig converts a proto TransparentProxyConfig to the inspect.Config type.
|
||||
func toProxyConfig(cfg *mgmProto.TransparentProxyConfig) (inspect.Config, error) {
|
||||
config := inspect.Config{
|
||||
Enabled: cfg.Enabled,
|
||||
DefaultAction: toProxyAction(cfg.DefaultAction),
|
||||
}
|
||||
|
||||
switch cfg.Mode {
|
||||
case mgmProto.TransparentProxyMode_TP_MODE_ENVOY:
|
||||
config.Mode = inspect.ModeEnvoy
|
||||
case mgmProto.TransparentProxyMode_TP_MODE_EXTERNAL:
|
||||
config.Mode = inspect.ModeExternal
|
||||
default:
|
||||
config.Mode = inspect.ModeBuiltin
|
||||
}
|
||||
|
||||
if cfg.ExternalProxyUrl != "" {
|
||||
u, err := url.Parse(cfg.ExternalProxyUrl)
|
||||
if err != nil {
|
||||
return inspect.Config{}, fmt.Errorf("parse external proxy URL: %w", err)
|
||||
}
|
||||
config.ExternalURL = u
|
||||
}
|
||||
|
||||
for _, s := range cfg.RedirectSources {
|
||||
prefix, err := netip.ParsePrefix(s)
|
||||
if err != nil {
|
||||
return inspect.Config{}, fmt.Errorf("parse redirect source %q: %w", s, err)
|
||||
}
|
||||
config.RedirectSources = append(config.RedirectSources, prefix)
|
||||
}
|
||||
|
||||
for _, p := range cfg.RedirectPorts {
|
||||
config.RedirectPorts = append(config.RedirectPorts, uint16(p))
|
||||
}
|
||||
|
||||
// TPROXY listen port: fixed default, overridable via env var.
|
||||
if config.Mode == inspect.ModeBuiltin {
|
||||
port := uint16(inspect.DefaultTProxyPort)
|
||||
if v := os.Getenv("NB_TPROXY_PORT"); v != "" {
|
||||
if p, err := strconv.ParseUint(v, 10, 16); err == nil {
|
||||
port = uint16(p)
|
||||
} else {
|
||||
log.Warnf("invalid NB_TPROXY_PORT %q, using default %d", v, inspect.DefaultTProxyPort)
|
||||
}
|
||||
}
|
||||
config.ListenAddr = netip.AddrPortFrom(netip.IPv4Unspecified(), port)
|
||||
}
|
||||
|
||||
for _, r := range cfg.Rules {
|
||||
rule, err := toProxyRule(r)
|
||||
if err != nil {
|
||||
return inspect.Config{}, fmt.Errorf("parse rule %q: %w", r.Id, err)
|
||||
}
|
||||
config.Rules = append(config.Rules, rule)
|
||||
}
|
||||
|
||||
if cfg.Icap != nil {
|
||||
icapCfg, err := toICAPConfig(cfg.Icap)
|
||||
if err != nil {
|
||||
return inspect.Config{}, fmt.Errorf("parse ICAP config: %w", err)
|
||||
}
|
||||
config.ICAP = icapCfg
|
||||
}
|
||||
|
||||
if len(cfg.CaCertPem) > 0 && len(cfg.CaKeyPem) > 0 {
|
||||
tlsCfg, err := parseTLSConfig(cfg.CaCertPem, cfg.CaKeyPem)
|
||||
if err != nil {
|
||||
return inspect.Config{}, fmt.Errorf("parse TLS config: %w", err)
|
||||
}
|
||||
config.TLS = tlsCfg
|
||||
}
|
||||
|
||||
if config.Mode == inspect.ModeEnvoy {
|
||||
envCfg := &inspect.EnvoyConfig{
|
||||
BinaryPath: cfg.EnvoyBinaryPath,
|
||||
AdminPort: uint16(cfg.EnvoyAdminPort),
|
||||
}
|
||||
if cfg.EnvoySnippets != nil {
|
||||
envCfg.Snippets = &inspect.EnvoySnippets{
|
||||
HTTPFilters: cfg.EnvoySnippets.HttpFilters,
|
||||
NetworkFilters: cfg.EnvoySnippets.NetworkFilters,
|
||||
Clusters: cfg.EnvoySnippets.Clusters,
|
||||
}
|
||||
}
|
||||
config.Envoy = envCfg
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func toProxyRule(r *mgmProto.TransparentProxyRule) (inspect.Rule, error) {
|
||||
rule := inspect.Rule{
|
||||
ID: id.RuleID(r.Id),
|
||||
Action: toProxyAction(r.Action),
|
||||
Priority: int(r.Priority),
|
||||
}
|
||||
|
||||
for _, d := range r.Domains {
|
||||
dom, err := domain.FromString(d)
|
||||
if err != nil {
|
||||
return inspect.Rule{}, fmt.Errorf("parse domain %q: %w", d, err)
|
||||
}
|
||||
rule.Domains = append(rule.Domains, dom)
|
||||
}
|
||||
|
||||
for _, n := range r.Networks {
|
||||
prefix, err := netip.ParsePrefix(n)
|
||||
if err != nil {
|
||||
return inspect.Rule{}, fmt.Errorf("parse network %q: %w", n, err)
|
||||
}
|
||||
rule.Networks = append(rule.Networks, prefix)
|
||||
}
|
||||
|
||||
for _, p := range r.Ports {
|
||||
rule.Ports = append(rule.Ports, uint16(p))
|
||||
}
|
||||
|
||||
for _, proto := range r.Protocols {
|
||||
rule.Protocols = append(rule.Protocols, toProxyProtoType(proto))
|
||||
}
|
||||
|
||||
rule.Paths = r.Paths
|
||||
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
func toProxyProtoType(p mgmProto.TransparentProxyProtocol) inspect.ProtoType {
|
||||
switch p {
|
||||
case mgmProto.TransparentProxyProtocol_TP_PROTO_HTTP:
|
||||
return inspect.ProtoHTTP
|
||||
case mgmProto.TransparentProxyProtocol_TP_PROTO_HTTPS:
|
||||
return inspect.ProtoHTTPS
|
||||
case mgmProto.TransparentProxyProtocol_TP_PROTO_H2:
|
||||
return inspect.ProtoH2
|
||||
case mgmProto.TransparentProxyProtocol_TP_PROTO_H3:
|
||||
return inspect.ProtoH3
|
||||
case mgmProto.TransparentProxyProtocol_TP_PROTO_WEBSOCKET:
|
||||
return inspect.ProtoWebSocket
|
||||
case mgmProto.TransparentProxyProtocol_TP_PROTO_OTHER:
|
||||
return inspect.ProtoOther
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func toProxyAction(a mgmProto.TransparentProxyAction) inspect.Action {
|
||||
switch a {
|
||||
case mgmProto.TransparentProxyAction_TP_ACTION_BLOCK:
|
||||
return inspect.ActionBlock
|
||||
case mgmProto.TransparentProxyAction_TP_ACTION_INSPECT:
|
||||
return inspect.ActionInspect
|
||||
default:
|
||||
return inspect.ActionAllow
|
||||
}
|
||||
}
|
||||
|
||||
func toICAPConfig(cfg *mgmProto.TransparentProxyICAPConfig) (*inspect.ICAPConfig, error) {
|
||||
icap := &inspect.ICAPConfig{
|
||||
MaxConnections: int(cfg.MaxConnections),
|
||||
}
|
||||
|
||||
if cfg.ReqmodUrl != "" {
|
||||
u, err := url.Parse(cfg.ReqmodUrl)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse ICAP reqmod URL: %w", err)
|
||||
}
|
||||
icap.ReqModURL = u
|
||||
}
|
||||
|
||||
if cfg.RespmodUrl != "" {
|
||||
u, err := url.Parse(cfg.RespmodUrl)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse ICAP respmod URL: %w", err)
|
||||
}
|
||||
icap.RespModURL = u
|
||||
}
|
||||
|
||||
return icap, nil
|
||||
}
|
||||
|
||||
func parseTLSConfig(certPEM, keyPEM []byte) (*inspect.TLSConfig, error) {
|
||||
block, _ := pem.Decode(certPEM)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("decode CA certificate PEM")
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse CA certificate: %w", err)
|
||||
}
|
||||
|
||||
keyBlock, _ := pem.Decode(keyPEM)
|
||||
if keyBlock == nil {
|
||||
return nil, fmt.Errorf("decode CA key PEM")
|
||||
}
|
||||
|
||||
key, err := x509.ParseECPrivateKey(keyBlock.Bytes)
|
||||
if err != nil {
|
||||
// Try PKCS8 as fallback
|
||||
pkcs8Key, pkcs8Err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes)
|
||||
if pkcs8Err != nil {
|
||||
return nil, fmt.Errorf("parse CA private key (tried EC and PKCS8): %w", err)
|
||||
}
|
||||
return &inspect.TLSConfig{CA: cert, CAKey: pkcs8Key}, nil
|
||||
}
|
||||
|
||||
return &inspect.TLSConfig{CA: cert, CAKey: key}, nil
|
||||
}
|
||||
|
||||
// resolveInspectionCA sets the TLS config on the proxy config using priority:
|
||||
// 1. Local config file CA (InspectionCACertPath/InspectionCAKeyPath)
|
||||
// 2. Management-pushed CA (already parsed in toProxyConfig)
|
||||
// 3. Auto-generated self-signed CA (ephemeral, for testing)
|
||||
// Local always wins to prevent a compromised management server from injecting a CA.
|
||||
func (e *Engine) resolveInspectionCA(config *inspect.Config) {
|
||||
// 1. Local CA from config file or env vars
|
||||
certPath := e.config.InspectionCACertPath
|
||||
keyPath := e.config.InspectionCAKeyPath
|
||||
if certPath == "" {
|
||||
certPath = os.Getenv("NB_INSPECTION_CA_CERT")
|
||||
}
|
||||
if keyPath == "" {
|
||||
keyPath = os.Getenv("NB_INSPECTION_CA_KEY")
|
||||
}
|
||||
if certPath != "" && keyPath != "" {
|
||||
certPEM, err := os.ReadFile(certPath)
|
||||
if err != nil {
|
||||
log.Errorf("read local inspection CA cert %s: %v", certPath, err)
|
||||
return
|
||||
}
|
||||
keyPEM, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
log.Errorf("read local inspection CA key %s: %v", keyPath, err)
|
||||
return
|
||||
}
|
||||
tlsCfg, err := parseTLSConfig(certPEM, keyPEM)
|
||||
if err != nil {
|
||||
log.Errorf("parse local inspection CA: %v", err)
|
||||
return
|
||||
}
|
||||
log.Infof("inspect: using local CA from %s", certPath)
|
||||
config.TLS = tlsCfg
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Management-pushed CA (already set by toProxyConfig)
|
||||
if config.TLS != nil {
|
||||
log.Infof("inspect: using management-pushed CA")
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Auto-generate self-signed CA for testing / accept-cert UX
|
||||
tlsCfg, err := generateSelfSignedCA()
|
||||
if err != nil {
|
||||
log.Errorf("generate self-signed inspection CA: %v", err)
|
||||
return
|
||||
}
|
||||
log.Infof("inspect: using auto-generated self-signed CA (clients will see certificate warnings)")
|
||||
config.TLS = tlsCfg
|
||||
}
|
||||
|
||||
// generateSelfSignedCA creates an ephemeral ECDSA P-256 CA certificate.
|
||||
// Clients will see certificate warnings but can choose to accept.
|
||||
func generateSelfSignedCA() (*inspect.TLSConfig, error) {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate CA key: %w", err)
|
||||
}
|
||||
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate serial: %w", err)
|
||||
}
|
||||
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"NetBird Transparent Proxy"},
|
||||
CommonName: "NetBird Inspection CA (auto-generated)",
|
||||
},
|
||||
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||
NotAfter: time.Now().Add(365 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
MaxPathLen: 0,
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create CA certificate: %w", err)
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(certDER)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse generated CA certificate: %w", err)
|
||||
}
|
||||
|
||||
return &inspect.TLSConfig{CA: cert, CAKey: key}, nil
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/inspect"
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
func TestToProxyConfig_Basic(t *testing.T) {
|
||||
cfg := &mgmProto.TransparentProxyConfig{
|
||||
Enabled: true,
|
||||
Mode: mgmProto.TransparentProxyMode_TP_MODE_BUILTIN,
|
||||
DefaultAction: mgmProto.TransparentProxyAction_TP_ACTION_ALLOW,
|
||||
RedirectSources: []string{
|
||||
"10.0.0.0/24",
|
||||
"192.168.1.0/24",
|
||||
},
|
||||
RedirectPorts: []uint32{80, 443},
|
||||
Rules: []*mgmProto.TransparentProxyRule{
|
||||
{
|
||||
Id: "block-evil",
|
||||
Domains: []string{"*.evil.com", "malware.example.com"},
|
||||
Action: mgmProto.TransparentProxyAction_TP_ACTION_BLOCK,
|
||||
Priority: 1,
|
||||
},
|
||||
{
|
||||
Id: "inspect-internal",
|
||||
Domains: []string{"*.internal.corp"},
|
||||
Networks: []string{"10.1.0.0/16"},
|
||||
Ports: []uint32{443, 8443},
|
||||
Action: mgmProto.TransparentProxyAction_TP_ACTION_INSPECT,
|
||||
Priority: 10,
|
||||
},
|
||||
},
|
||||
ListenPort: 8443,
|
||||
}
|
||||
|
||||
config, err := toProxyConfig(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, config.Enabled)
|
||||
assert.Equal(t, inspect.ModeBuiltin, config.Mode)
|
||||
assert.Equal(t, inspect.ActionAllow, config.DefaultAction)
|
||||
|
||||
require.Len(t, config.RedirectSources, 2)
|
||||
assert.Equal(t, "10.0.0.0/24", config.RedirectSources[0].String())
|
||||
assert.Equal(t, "192.168.1.0/24", config.RedirectSources[1].String())
|
||||
|
||||
require.Len(t, config.RedirectPorts, 2)
|
||||
assert.Equal(t, uint16(80), config.RedirectPorts[0])
|
||||
assert.Equal(t, uint16(443), config.RedirectPorts[1])
|
||||
|
||||
require.Len(t, config.Rules, 2)
|
||||
|
||||
// Rule 1: block evil domains
|
||||
assert.Equal(t, "block-evil", string(config.Rules[0].ID))
|
||||
assert.Equal(t, inspect.ActionBlock, config.Rules[0].Action)
|
||||
assert.Equal(t, 1, config.Rules[0].Priority)
|
||||
require.Len(t, config.Rules[0].Domains, 2)
|
||||
assert.Equal(t, "*.evil.com", config.Rules[0].Domains[0].PunycodeString())
|
||||
assert.Equal(t, "malware.example.com", config.Rules[0].Domains[1].PunycodeString())
|
||||
|
||||
// Rule 2: inspect internal
|
||||
assert.Equal(t, "inspect-internal", string(config.Rules[1].ID))
|
||||
assert.Equal(t, inspect.ActionInspect, config.Rules[1].Action)
|
||||
assert.Equal(t, 10, config.Rules[1].Priority)
|
||||
require.Len(t, config.Rules[1].Networks, 1)
|
||||
assert.Equal(t, "10.1.0.0/16", config.Rules[1].Networks[0].String())
|
||||
require.Len(t, config.Rules[1].Ports, 2)
|
||||
|
||||
// Listen address
|
||||
assert.True(t, config.ListenAddr.IsValid())
|
||||
assert.Equal(t, uint16(8443), config.ListenAddr.Port())
|
||||
}
|
||||
|
||||
func TestToProxyConfig_ExternalMode(t *testing.T) {
|
||||
cfg := &mgmProto.TransparentProxyConfig{
|
||||
Enabled: true,
|
||||
Mode: mgmProto.TransparentProxyMode_TP_MODE_EXTERNAL,
|
||||
ExternalProxyUrl: "http://proxy.corp:8080",
|
||||
DefaultAction: mgmProto.TransparentProxyAction_TP_ACTION_BLOCK,
|
||||
}
|
||||
|
||||
config, err := toProxyConfig(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, inspect.ModeExternal, config.Mode)
|
||||
assert.Equal(t, inspect.ActionBlock, config.DefaultAction)
|
||||
require.NotNil(t, config.ExternalURL)
|
||||
assert.Equal(t, "http", config.ExternalURL.Scheme)
|
||||
assert.Equal(t, "proxy.corp:8080", config.ExternalURL.Host)
|
||||
}
|
||||
|
||||
func TestToProxyConfig_ICAP(t *testing.T) {
|
||||
cfg := &mgmProto.TransparentProxyConfig{
|
||||
Enabled: true,
|
||||
Icap: &mgmProto.TransparentProxyICAPConfig{
|
||||
ReqmodUrl: "icap://icap-server:1344/reqmod",
|
||||
RespmodUrl: "icap://icap-server:1344/respmod",
|
||||
MaxConnections: 16,
|
||||
},
|
||||
}
|
||||
|
||||
config, err := toProxyConfig(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotNil(t, config.ICAP)
|
||||
assert.Equal(t, "icap", config.ICAP.ReqModURL.Scheme)
|
||||
assert.Equal(t, "icap-server:1344", config.ICAP.ReqModURL.Host)
|
||||
assert.Equal(t, "/reqmod", config.ICAP.ReqModURL.Path)
|
||||
assert.Equal(t, "/respmod", config.ICAP.RespModURL.Path)
|
||||
assert.Equal(t, 16, config.ICAP.MaxConnections)
|
||||
}
|
||||
|
||||
func TestToProxyConfig_Empty(t *testing.T) {
|
||||
cfg := &mgmProto.TransparentProxyConfig{
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
config, err := toProxyConfig(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, config.Enabled)
|
||||
assert.Equal(t, inspect.ModeBuiltin, config.Mode)
|
||||
assert.Equal(t, inspect.ActionAllow, config.DefaultAction)
|
||||
assert.Empty(t, config.RedirectSources)
|
||||
assert.Empty(t, config.RedirectPorts)
|
||||
assert.Empty(t, config.Rules)
|
||||
assert.Nil(t, config.ICAP)
|
||||
assert.Nil(t, config.TLS)
|
||||
assert.False(t, config.ListenAddr.IsValid())
|
||||
}
|
||||
|
||||
func TestToProxyConfig_InvalidSource(t *testing.T) {
|
||||
cfg := &mgmProto.TransparentProxyConfig{
|
||||
Enabled: true,
|
||||
RedirectSources: []string{"not-a-cidr"},
|
||||
}
|
||||
|
||||
_, err := toProxyConfig(cfg)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "parse redirect source")
|
||||
}
|
||||
|
||||
func TestToProxyConfig_InvalidNetwork(t *testing.T) {
|
||||
cfg := &mgmProto.TransparentProxyConfig{
|
||||
Enabled: true,
|
||||
Rules: []*mgmProto.TransparentProxyRule{
|
||||
{
|
||||
Id: "bad",
|
||||
Networks: []string{"not-a-cidr"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := toProxyConfig(cfg)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "parse network")
|
||||
}
|
||||
|
||||
func TestToProxyAction(t *testing.T) {
|
||||
assert.Equal(t, inspect.ActionAllow, toProxyAction(mgmProto.TransparentProxyAction_TP_ACTION_ALLOW))
|
||||
assert.Equal(t, inspect.ActionBlock, toProxyAction(mgmProto.TransparentProxyAction_TP_ACTION_BLOCK))
|
||||
assert.Equal(t, inspect.ActionInspect, toProxyAction(mgmProto.TransparentProxyAction_TP_ACTION_INSPECT))
|
||||
// Unknown defaults to allow
|
||||
assert.Equal(t, inspect.ActionAllow, toProxyAction(99))
|
||||
}
|
||||
|
||||
func TestParseUDPPacket_IPv4(t *testing.T) {
|
||||
// Build a minimal IPv4/UDP packet: 20-byte IPv4 header + 8-byte UDP header + payload
|
||||
packet := make([]byte, 20+8+4)
|
||||
|
||||
// IPv4 header: version=4, IHL=5 (20 bytes)
|
||||
packet[0] = 0x45
|
||||
// Protocol = UDP (17)
|
||||
packet[9] = 17
|
||||
// Source IP: 10.0.0.1
|
||||
packet[12], packet[13], packet[14], packet[15] = 10, 0, 0, 1
|
||||
// Dest IP: 192.168.1.1
|
||||
packet[16], packet[17], packet[18], packet[19] = 192, 168, 1, 1
|
||||
// UDP source port: 54321 (0xD431)
|
||||
packet[20] = 0xD4
|
||||
packet[21] = 0x31
|
||||
// UDP dest port: 443 (0x01BB)
|
||||
packet[22] = 0x01
|
||||
packet[23] = 0xBB
|
||||
// Payload
|
||||
packet[28] = 0xDE
|
||||
packet[29] = 0xAD
|
||||
packet[30] = 0xBE
|
||||
packet[31] = 0xEF
|
||||
|
||||
srcIP, dstIP, dstPort, payload, ok := parseUDPPacket(packet)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "10.0.0.1", srcIP.String())
|
||||
assert.Equal(t, "192.168.1.1", dstIP.String())
|
||||
assert.Equal(t, uint16(443), dstPort)
|
||||
assert.Equal(t, []byte{0xDE, 0xAD, 0xBE, 0xEF}, payload)
|
||||
}
|
||||
|
||||
func TestParseUDPPacket_IPv6(t *testing.T) {
|
||||
// Build a minimal IPv6/UDP packet: 40-byte IPv6 header + 8-byte UDP header + payload
|
||||
packet := make([]byte, 40+8+4)
|
||||
|
||||
// Version = 6 (0x60 in high nibble)
|
||||
packet[0] = 0x60
|
||||
// Payload length: 8 (UDP header) + 4 (payload)
|
||||
packet[4] = 0
|
||||
packet[5] = 12
|
||||
// Next header: UDP (17)
|
||||
packet[6] = 17
|
||||
// Source: 2001:db8::1
|
||||
packet[8] = 0x20
|
||||
packet[9] = 0x01
|
||||
packet[10] = 0x0d
|
||||
packet[11] = 0xb8
|
||||
packet[23] = 0x01
|
||||
// Dest: 2001:db8::2
|
||||
packet[24] = 0x20
|
||||
packet[25] = 0x01
|
||||
packet[26] = 0x0d
|
||||
packet[27] = 0xb8
|
||||
packet[39] = 0x02
|
||||
// UDP source port: 54321 (0xD431)
|
||||
packet[40] = 0xD4
|
||||
packet[41] = 0x31
|
||||
// UDP dest port: 443 (0x01BB)
|
||||
packet[42] = 0x01
|
||||
packet[43] = 0xBB
|
||||
// Payload
|
||||
packet[48] = 0xCA
|
||||
packet[49] = 0xFE
|
||||
packet[50] = 0xBA
|
||||
packet[51] = 0xBE
|
||||
|
||||
srcIP, dstIP, dstPort, payload, ok := parseUDPPacket(packet)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "2001:db8::1", srcIP.String())
|
||||
assert.Equal(t, "2001:db8::2", dstIP.String())
|
||||
assert.Equal(t, uint16(443), dstPort)
|
||||
assert.Equal(t, []byte{0xCA, 0xFE, 0xBA, 0xBE}, payload)
|
||||
}
|
||||
|
||||
func TestParseUDPPacket_TooShort(t *testing.T) {
|
||||
_, _, _, _, ok := parseUDPPacket(nil)
|
||||
assert.False(t, ok)
|
||||
|
||||
_, _, _, _, ok = parseUDPPacket([]byte{0x45, 0x00})
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestParseUDPPacket_IPv6ExtensionHeader(t *testing.T) {
|
||||
// IPv6 with next header != UDP should be rejected
|
||||
packet := make([]byte, 48)
|
||||
packet[0] = 0x60
|
||||
packet[6] = 6 // TCP, not UDP
|
||||
_, _, _, _, ok := parseUDPPacket(packet)
|
||||
assert.False(t, ok, "should reject IPv6 packets with non-UDP next header")
|
||||
}
|
||||
|
||||
func TestParseUDPPacket_IPv4MappedIPv6(t *testing.T) {
|
||||
// IPv4 packet with normal addresses should Unmap correctly
|
||||
packet := make([]byte, 28)
|
||||
packet[0] = 0x45
|
||||
packet[9] = 17
|
||||
packet[12], packet[13], packet[14], packet[15] = 127, 0, 0, 1
|
||||
packet[16], packet[17], packet[18], packet[19] = 10, 0, 0, 1
|
||||
packet[22] = 0x01
|
||||
packet[23] = 0xBB
|
||||
|
||||
srcIP, dstIP, _, _, ok := parseUDPPacket(packet)
|
||||
require.True(t, ok)
|
||||
assert.True(t, srcIP.Is4(), "should be plain IPv4, not mapped")
|
||||
assert.True(t, dstIP.Is4(), "should be plain IPv4, not mapped")
|
||||
}
|
||||
@@ -97,6 +97,9 @@ type ConfigInput struct {
|
||||
LazyConnectionEnabled *bool
|
||||
|
||||
MTU *uint16
|
||||
|
||||
InspectionCACertPath string
|
||||
InspectionCAKeyPath string
|
||||
}
|
||||
|
||||
// Config Configuration type
|
||||
@@ -171,6 +174,13 @@ type Config struct {
|
||||
LazyConnectionEnabled bool
|
||||
|
||||
MTU uint16
|
||||
|
||||
// InspectionCACertPath is the path to a PEM CA certificate for transparent proxy MITM.
|
||||
// Local CA takes priority over management-pushed CA.
|
||||
InspectionCACertPath string
|
||||
|
||||
// InspectionCAKeyPath is the path to the PEM CA private key for transparent proxy MITM.
|
||||
InspectionCAKeyPath string
|
||||
}
|
||||
|
||||
var ConfigDirOverride string
|
||||
@@ -603,6 +613,17 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.InspectionCACertPath != "" && input.InspectionCACertPath != config.InspectionCACertPath {
|
||||
log.Infof("updating inspection CA cert path to %s", input.InspectionCACertPath)
|
||||
config.InspectionCACertPath = input.InspectionCACertPath
|
||||
updated = true
|
||||
}
|
||||
if input.InspectionCAKeyPath != "" && input.InspectionCAKeyPath != config.InspectionCAKeyPath {
|
||||
log.Infof("updating inspection CA key path to %s", input.InspectionCAKeyPath)
|
||||
config.InspectionCAKeyPath = input.InspectionCAKeyPath
|
||||
updated = true
|
||||
}
|
||||
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user