mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-15 19:29:08 +02:00
Merge branch 'main' into poc/certificate-posture
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -17,17 +18,20 @@ import (
|
||||
nberrors "github.com/netbirdio/netbird/client/errors"
|
||||
|
||||
firewall "github.com/netbirdio/netbird/client/firewall/manager"
|
||||
"github.com/netbirdio/netbird/client/internal/ebpf"
|
||||
ebpfMgr "github.com/netbirdio/netbird/client/internal/ebpf/manager"
|
||||
)
|
||||
|
||||
const (
|
||||
customPort = 5053
|
||||
// randomPortAttempts bounds the search for a port free on both protocols.
|
||||
randomPortAttempts = 5
|
||||
)
|
||||
|
||||
var (
|
||||
defaultIP = netip.MustParseAddr("127.0.0.1")
|
||||
customIP = netip.MustParseAddr("127.0.0.153")
|
||||
|
||||
// dnatProtocols are the protocols the port 53 redirect covers.
|
||||
dnatProtocols = []firewall.Protocol{firewall.ProtocolUDP, firewall.ProtocolTCP}
|
||||
)
|
||||
|
||||
type serviceViaListener struct {
|
||||
@@ -40,9 +44,20 @@ type serviceViaListener struct {
|
||||
listenPort uint16
|
||||
listenerIsRunning bool
|
||||
listenerFlagLock sync.Mutex
|
||||
ebpfService ebpfMgr.Manager
|
||||
firewall Firewall
|
||||
tcpDNATConfigured bool
|
||||
// dnatRules holds the port 53 redirects that are installed and not yet
|
||||
// removed, so a removal that fails can be retried.
|
||||
dnatRules []dnatRule
|
||||
}
|
||||
|
||||
// dnatRule is a port 53 redirect as it was installed. The target is kept with
|
||||
// the rule because the listener can come back on a different address or port,
|
||||
// and a retried removal has to name the address and port the rule was added
|
||||
// with, not the ones in use now.
|
||||
type dnatRule struct {
|
||||
protocol firewall.Protocol
|
||||
ip netip.Addr
|
||||
port uint16
|
||||
}
|
||||
|
||||
func newServiceViaListener(wgIface WGIface, customAddr *netip.AddrPort, fw Firewall) *serviceViaListener {
|
||||
@@ -112,34 +127,93 @@ func (s *serviceViaListener) Listen() error {
|
||||
}
|
||||
}()
|
||||
|
||||
// When eBPF redirects UDP port 53 to our listen port, TCP still needs
|
||||
// a DNAT rule because eBPF only handles UDP.
|
||||
if s.ebpfService != nil && s.firewall != nil && s.listenPort != DefaultPort {
|
||||
if err := s.firewall.AddOutputDNAT(s.listenIP, firewall.ProtocolTCP, DefaultPort, s.listenPort); err != nil {
|
||||
log.Warnf("failed to add DNS TCP DNAT rule, TCP DNS on port 53 will not work: %v", err)
|
||||
} else {
|
||||
s.tcpDNATConfigured = true
|
||||
log.Infof("added DNS TCP DNAT rule: %s:%d -> %s:%d", s.listenIP, DefaultPort, s.listenIP, s.listenPort)
|
||||
}
|
||||
if s.listenPort != DefaultPort {
|
||||
s.setupDNAT()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setupDNAT redirects port 53 to the port the DNS server actually listens on.
|
||||
// Both protocols must be redirected or none: RuntimePort reports port 53 only
|
||||
// while the full redirect is in place, so a half-configured redirect would
|
||||
// advertise a resolver that answers over one protocol.
|
||||
func (s *serviceViaListener) setupDNAT() {
|
||||
if s.firewall == nil {
|
||||
log.Errorf("no firewall manager available to redirect DNS port %d to %d, "+
|
||||
"clients pointed at %s will not reach the resolver", DefaultPort, s.listenPort, s.listenIP)
|
||||
return
|
||||
}
|
||||
|
||||
// Clear whatever an earlier removal left behind first. Those rules can point
|
||||
// at an address or port this listener no longer uses, and they are matched
|
||||
// before anything added now, so adding a redirect on top of one would keep
|
||||
// sending port 53 traffic to the previous listener while reporting the
|
||||
// redirect as complete. The rules stay recorded for a later attempt.
|
||||
if err := s.removeDNAT(); err != nil {
|
||||
log.Errorf("failed to remove stale DNS DNAT rules, leaving port %d redirected to the previous listener: %v",
|
||||
DefaultPort, err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, proto := range dnatProtocols {
|
||||
if err := s.firewall.AddOutputDNAT(s.listenIP, proto, DefaultPort, s.listenPort); err != nil {
|
||||
log.Errorf("failed to add DNS %s DNAT rule, DNS on port %d will not work: %v",
|
||||
proto, DefaultPort, err)
|
||||
if err := s.removeDNAT(); err != nil {
|
||||
log.Warnf("failed to roll back DNS DNAT rules, retrying on stop: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
s.dnatRules = append(s.dnatRules, dnatRule{protocol: proto, ip: s.listenIP, port: s.listenPort})
|
||||
}
|
||||
|
||||
log.Infof("added DNS DNAT rules: %s:%d -> %s:%d (UDP + TCP)", s.listenIP, DefaultPort, s.listenIP, s.listenPort)
|
||||
}
|
||||
|
||||
// removeDNAT removes every installed port 53 redirect. A rule whose removal
|
||||
// fails stays recorded so a later setup or Stop retries it, rather than leaving
|
||||
// port 53 pointing at a resolver that is no longer listening.
|
||||
func (s *serviceViaListener) removeDNAT() error {
|
||||
if s.firewall == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var merr *multierror.Error
|
||||
var remaining []dnatRule
|
||||
for _, rule := range s.dnatRules {
|
||||
if err := s.firewall.RemoveOutputDNAT(rule.ip, rule.protocol, DefaultPort, rule.port); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("remove DNS %s DNAT rule for %s:%d: %w",
|
||||
rule.protocol, rule.ip, rule.port, err))
|
||||
remaining = append(remaining, rule)
|
||||
}
|
||||
}
|
||||
s.dnatRules = remaining
|
||||
|
||||
return nberrors.FormatErrorOrNil(merr)
|
||||
}
|
||||
|
||||
func (s *serviceViaListener) Stop() error {
|
||||
s.listenerFlagLock.Lock()
|
||||
defer s.listenerFlagLock.Unlock()
|
||||
|
||||
var merr *multierror.Error
|
||||
|
||||
// Redirects are removed even when the listener is already stopped, so that
|
||||
// a removal which failed earlier is retried instead of leaving port 53
|
||||
// pointing at a resolver that no longer listens.
|
||||
if err := s.removeDNAT(); err != nil {
|
||||
merr = multierror.Append(merr, err)
|
||||
}
|
||||
|
||||
if !s.listenerIsRunning {
|
||||
return nil
|
||||
return nberrors.FormatErrorOrNil(merr)
|
||||
}
|
||||
s.listenerIsRunning = false
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var merr *multierror.Error
|
||||
|
||||
if err := s.server.ShutdownContext(ctx); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("stop DNS UDP server: %w", err))
|
||||
}
|
||||
@@ -148,19 +222,6 @@ func (s *serviceViaListener) Stop() error {
|
||||
merr = multierror.Append(merr, fmt.Errorf("stop DNS TCP server: %w", err))
|
||||
}
|
||||
|
||||
if s.tcpDNATConfigured && s.firewall != nil {
|
||||
if err := s.firewall.RemoveOutputDNAT(s.listenIP, firewall.ProtocolTCP, DefaultPort, s.listenPort); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("remove DNS TCP DNAT rule: %w", err))
|
||||
}
|
||||
s.tcpDNATConfigured = false
|
||||
}
|
||||
|
||||
if s.ebpfService != nil {
|
||||
if err := s.ebpfService.FreeDNSFwd(); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("stop traffic forwarder: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
return nberrors.FormatErrorOrNil(merr)
|
||||
}
|
||||
|
||||
@@ -177,11 +238,23 @@ func (s *serviceViaListener) RuntimePort() int {
|
||||
s.listenerFlagLock.Lock()
|
||||
defer s.listenerFlagLock.Unlock()
|
||||
|
||||
if s.ebpfService != nil {
|
||||
if s.redirectInstalled() {
|
||||
return DefaultPort
|
||||
} else {
|
||||
return int(s.listenPort)
|
||||
}
|
||||
return int(s.listenPort)
|
||||
}
|
||||
|
||||
// redirectInstalled reports whether every protocol is redirected from port 53
|
||||
// to the address and port the listener currently serves. Rules left over from
|
||||
// an earlier listener do not count.
|
||||
func (s *serviceViaListener) redirectInstalled() bool {
|
||||
for _, proto := range dnatProtocols {
|
||||
current := dnatRule{protocol: proto, ip: s.listenIP, port: s.listenPort}
|
||||
if !slices.Contains(s.dnatRules, current) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *serviceViaListener) RuntimeIP() netip.Addr {
|
||||
@@ -190,30 +263,29 @@ func (s *serviceViaListener) RuntimeIP() netip.Addr {
|
||||
|
||||
// evalListenAddress figures out the listen address for the DNS server.
|
||||
// IPv4-only: all peers have a v4 overlay address, and DNS config points to v4.
|
||||
// First checks port 53 on WG interface or lo, then tries eBPF on a random port,
|
||||
// then falls back to port 5053.
|
||||
// Prefers port 53 on the overlay interface or lo, so no redirect is needed at
|
||||
// all; when it is taken it falls back to port 5053 and then to a random free
|
||||
// port, both of which need the port 53 redirect set up by setupDNAT.
|
||||
func (s *serviceViaListener) evalListenAddress() (netip.Addr, uint16, error) {
|
||||
if s.customAddr != nil {
|
||||
return s.customAddr.Addr(), s.customAddr.Port(), nil
|
||||
}
|
||||
|
||||
ip, ok := s.testFreePort(DefaultPort)
|
||||
if ok {
|
||||
if ip, ok := s.testFreePort(DefaultPort); ok {
|
||||
return ip, DefaultPort, nil
|
||||
}
|
||||
|
||||
ebpfSrv, port, ok := s.tryToUseeBPF()
|
||||
if ok {
|
||||
s.ebpfService = ebpfSrv
|
||||
return s.wgInterface.Address().IP, port, nil
|
||||
}
|
||||
|
||||
ip, ok = s.testFreePort(customPort)
|
||||
if ok {
|
||||
if ip, ok := s.testFreePort(customPort); ok {
|
||||
return ip, customPort, nil
|
||||
}
|
||||
|
||||
return netip.Addr{}, 0, fmt.Errorf("failed to find a free port for DNS server")
|
||||
ip := s.wgInterface.Address().IP
|
||||
port, err := s.randomFreePort(ip)
|
||||
if err != nil {
|
||||
return netip.Addr{}, 0, fmt.Errorf("find a free port for DNS server: %w", err)
|
||||
}
|
||||
|
||||
return ip, port, nil
|
||||
}
|
||||
|
||||
func (s *serviceViaListener) testFreePort(port int) (netip.Addr, bool) {
|
||||
@@ -260,48 +332,25 @@ func (s *serviceViaListener) tryToBind(ip netip.Addr, port int) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// tryToUseeBPF decides whether to apply eBPF program to capture DNS traffic on port 53.
|
||||
// This is needed because on some operating systems if we start a DNS server not on a default port 53,
|
||||
// the domain name resolution won't work. So, in case we are running on Linux and picked a free
|
||||
// port we should fall back to the eBPF solution that will capture traffic on port 53 and forward
|
||||
// it to a local DNS server running on the chosen port.
|
||||
func (s *serviceViaListener) tryToUseeBPF() (ebpfMgr.Manager, uint16, bool) {
|
||||
if runtime.GOOS != "linux" {
|
||||
return nil, 0, false
|
||||
// randomFreePort returns a port that is free on ip for both UDP and TCP, since
|
||||
// the DNS server binds both. The probe listeners are closed again, so the port
|
||||
// is only likely, not guaranteed, to still be free when the server binds it.
|
||||
func (s *serviceViaListener) randomFreePort(ip netip.Addr) (uint16, error) {
|
||||
for range randomPortAttempts {
|
||||
probeListener, err := net.ListenUDP("udp4", &net.UDPAddr{})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("bind random port: %w", err)
|
||||
}
|
||||
|
||||
port := uint16(probeListener.LocalAddr().(*net.UDPAddr).Port)
|
||||
if err := probeListener.Close(); err != nil {
|
||||
return 0, fmt.Errorf("free up probed port: %w", err)
|
||||
}
|
||||
|
||||
if s.tryToBind(ip, int(port)) {
|
||||
return port, nil
|
||||
}
|
||||
}
|
||||
|
||||
port, err := s.generateFreePort() //nolint:staticcheck,unused
|
||||
if err != nil {
|
||||
log.Warnf("failed to generate a free port for eBPF DNS forwarder server: %s", err)
|
||||
return nil, 0, false
|
||||
}
|
||||
|
||||
ebpfSrv := ebpf.GetEbpfManagerInstance()
|
||||
err = ebpfSrv.LoadDNSFwd(s.wgInterface.Address().IP, int(port))
|
||||
if err != nil {
|
||||
log.Warnf("failed to load DNS forwarder eBPF program, error: %s", err)
|
||||
return nil, 0, false
|
||||
}
|
||||
|
||||
return ebpfSrv, port, true
|
||||
}
|
||||
|
||||
func (s *serviceViaListener) generateFreePort() (uint16, error) {
|
||||
ok := s.tryToBind(s.wgInterface.Address().IP, customPort)
|
||||
if ok {
|
||||
return customPort, nil
|
||||
}
|
||||
|
||||
probeListener, err := net.ListenUDP("udp4", &net.UDPAddr{})
|
||||
if err != nil {
|
||||
log.Debugf("failed to bind random port for DNS: %s", err)
|
||||
return 0, err
|
||||
}
|
||||
|
||||
port := uint16(probeListener.LocalAddr().(*net.UDPAddr).Port)
|
||||
if err = probeListener.Close(); err != nil {
|
||||
log.Debugf("failed to free up DNS port: %s", err)
|
||||
return 0, err
|
||||
}
|
||||
return port, nil
|
||||
return 0, fmt.Errorf("no port free for UDP and TCP on %s after %d attempts", ip, randomPortAttempts)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package dns
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
@@ -10,6 +11,8 @@ import (
|
||||
"github.com/miekg/dns"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
firewall "github.com/netbirdio/netbird/client/firewall/manager"
|
||||
)
|
||||
|
||||
func TestServiceViaListener_TCPAndUDP(t *testing.T) {
|
||||
@@ -84,3 +87,133 @@ func TestServiceViaListener_TCPAndUDP(t *testing.T) {
|
||||
require.NotEmpty(t, tcpResp.Answer)
|
||||
assert.Contains(t, tcpResp.Answer[0].String(), "192.0.2.1", "TCP response should contain expected IP")
|
||||
}
|
||||
|
||||
type dnatCall struct {
|
||||
rule dnatRule
|
||||
added bool
|
||||
}
|
||||
|
||||
// fakeFirewall records DNAT calls and fails the ones named in addErrs/removeErrs.
|
||||
type fakeFirewall struct {
|
||||
calls []dnatCall
|
||||
addErrs map[firewall.Protocol]error
|
||||
removeErrs map[firewall.Protocol]error
|
||||
}
|
||||
|
||||
func (f *fakeFirewall) AddOutputDNAT(ip netip.Addr, protocol firewall.Protocol, _, translatedPort uint16) error {
|
||||
if err := f.addErrs[protocol]; err != nil {
|
||||
return err
|
||||
}
|
||||
f.calls = append(f.calls, dnatCall{rule: dnatRule{protocol: protocol, ip: ip, port: translatedPort}, added: true})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeFirewall) RemoveOutputDNAT(ip netip.Addr, protocol firewall.Protocol, _, translatedPort uint16) error {
|
||||
if err := f.removeErrs[protocol]; err != nil {
|
||||
return err
|
||||
}
|
||||
f.calls = append(f.calls, dnatCall{rule: dnatRule{protocol: protocol, ip: ip, port: translatedPort}})
|
||||
return nil
|
||||
}
|
||||
|
||||
func newDNATTestService(fw Firewall) *serviceViaListener {
|
||||
return &serviceViaListener{
|
||||
listenIP: netip.MustParseAddr("100.64.0.1"),
|
||||
listenPort: customPort,
|
||||
firewall: fw,
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupDNAT_BothProtocols(t *testing.T) {
|
||||
svc := newDNATTestService(&fakeFirewall{})
|
||||
|
||||
svc.setupDNAT()
|
||||
|
||||
assert.Len(t, svc.dnatRules, len(dnatProtocols))
|
||||
assert.Equal(t, DefaultPort, svc.RuntimePort(), "port 53 is advertised once both redirects are installed")
|
||||
}
|
||||
|
||||
func TestSetupDNAT_RollsBackPartialRedirect(t *testing.T) {
|
||||
fw := &fakeFirewall{addErrs: map[firewall.Protocol]error{firewall.ProtocolTCP: errors.New("nftables busy")}}
|
||||
svc := newDNATTestService(fw)
|
||||
|
||||
svc.setupDNAT()
|
||||
|
||||
assert.Empty(t, svc.dnatRules, "the UDP redirect installed before the failure must be rolled back")
|
||||
assert.Equal(t, int(svc.listenPort), svc.RuntimePort(), "an incomplete redirect must not advertise port 53")
|
||||
udp := dnatRule{protocol: firewall.ProtocolUDP, ip: svc.listenIP, port: svc.listenPort}
|
||||
assert.Contains(t, fw.calls, dnatCall{rule: udp}, "UDP removal should have been attempted")
|
||||
}
|
||||
|
||||
// A rollback that fails must keep the rule recorded, so port 53 is not left
|
||||
// redirected to a resolver that no longer listens.
|
||||
func TestStop_RetriesFailedDNATRemoval(t *testing.T) {
|
||||
fw := &fakeFirewall{
|
||||
addErrs: map[firewall.Protocol]error{firewall.ProtocolTCP: errors.New("nftables busy")},
|
||||
removeErrs: map[firewall.Protocol]error{firewall.ProtocolUDP: errors.New("nftables busy")},
|
||||
}
|
||||
svc := newDNATTestService(fw)
|
||||
|
||||
svc.setupDNAT()
|
||||
udp := dnatRule{protocol: firewall.ProtocolUDP, ip: svc.listenIP, port: svc.listenPort}
|
||||
require.Equal(t, []dnatRule{udp}, svc.dnatRules, "a failed rollback keeps the rule for a later retry")
|
||||
|
||||
require.Error(t, svc.Stop(), "the failing removal should be reported")
|
||||
require.Equal(t, []dnatRule{udp}, svc.dnatRules)
|
||||
|
||||
delete(fw.removeErrs, firewall.ProtocolUDP)
|
||||
require.NoError(t, svc.Stop(), "a later stop retries the removal")
|
||||
assert.Empty(t, svc.dnatRules)
|
||||
}
|
||||
|
||||
// A stale rule that cannot be removed is matched before anything added now, so
|
||||
// no new redirect may be installed on top of it and port 53 must not be
|
||||
// advertised as reaching this listener.
|
||||
func TestSetupDNAT_AbortsWhileStaleRuleRemains(t *testing.T) {
|
||||
fw := &fakeFirewall{removeErrs: map[firewall.Protocol]error{firewall.ProtocolUDP: errors.New("nftables busy")}}
|
||||
svc := newDNATTestService(fw)
|
||||
stalePort := svc.listenPort
|
||||
|
||||
svc.setupDNAT()
|
||||
require.Error(t, svc.Stop())
|
||||
staleUDP := dnatRule{protocol: firewall.ProtocolUDP, ip: svc.listenIP, port: stalePort}
|
||||
require.Equal(t, []dnatRule{staleUDP}, svc.dnatRules)
|
||||
|
||||
svc.listenPort = stalePort + 1
|
||||
fw.calls = nil
|
||||
|
||||
svc.setupDNAT()
|
||||
|
||||
assert.Equal(t, []dnatRule{staleUDP}, svc.dnatRules, "the stale rule stays recorded for a later attempt")
|
||||
for _, call := range fw.calls {
|
||||
assert.False(t, call.added, "no redirect may be installed while a stale one is still in place")
|
||||
}
|
||||
assert.Equal(t, int(svc.listenPort), svc.RuntimePort(), "port 53 must not be advertised")
|
||||
}
|
||||
|
||||
// A rule left behind by a failed removal must be removed with the address and
|
||||
// port it was installed with, even when the listener has since moved to another
|
||||
// port, and it must not count towards the redirect the new listener advertises.
|
||||
func TestSetupDNAT_ClearsStaleRuleAfterPortChange(t *testing.T) {
|
||||
fw := &fakeFirewall{removeErrs: map[firewall.Protocol]error{firewall.ProtocolUDP: errors.New("nftables busy")}}
|
||||
svc := newDNATTestService(fw)
|
||||
stalePort := svc.listenPort
|
||||
|
||||
svc.setupDNAT()
|
||||
require.Error(t, svc.Stop())
|
||||
staleUDP := dnatRule{protocol: firewall.ProtocolUDP, ip: svc.listenIP, port: stalePort}
|
||||
require.Equal(t, []dnatRule{staleUDP}, svc.dnatRules)
|
||||
|
||||
delete(fw.removeErrs, firewall.ProtocolUDP)
|
||||
svc.listenPort = stalePort + 1
|
||||
fw.calls = nil
|
||||
|
||||
svc.setupDNAT()
|
||||
|
||||
assert.Contains(t, fw.calls, dnatCall{rule: staleUDP}, "the stale rule must be removed with its original port")
|
||||
assert.Len(t, svc.dnatRules, len(dnatProtocols))
|
||||
assert.Equal(t, DefaultPort, svc.RuntimePort(), "the new listener is fully redirected")
|
||||
for _, rule := range svc.dnatRules {
|
||||
assert.Equal(t, svc.listenPort, rule.port, "only rules for the current listener remain")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Code generated by bpf2go; DO NOT EDIT.
|
||||
//go:build arm64be || armbe || mips || mips64 || mips64p32 || ppc64 || s390 || s390x || sparc || sparc64
|
||||
//go:build mips || mips64 || ppc64 || s390x
|
||||
|
||||
package ebpf
|
||||
|
||||
@@ -47,9 +47,10 @@ func loadBpfObjects(obj interface{}, opts *ebpf.CollectionOptions) error {
|
||||
type bpfSpecs struct {
|
||||
bpfProgramSpecs
|
||||
bpfMapSpecs
|
||||
bpfVariableSpecs
|
||||
}
|
||||
|
||||
// bpfSpecs contains programs before they are loaded into the kernel.
|
||||
// bpfProgramSpecs contains programs before they are loaded into the kernel.
|
||||
//
|
||||
// It can be passed ebpf.CollectionSpec.Assign.
|
||||
type bpfProgramSpecs struct {
|
||||
@@ -61,17 +62,28 @@ type bpfProgramSpecs struct {
|
||||
// It can be passed ebpf.CollectionSpec.Assign.
|
||||
type bpfMapSpecs struct {
|
||||
NbFeatures *ebpf.MapSpec `ebpf:"nb_features"`
|
||||
NbMapDnsIp *ebpf.MapSpec `ebpf:"nb_map_dns_ip"`
|
||||
NbMapDnsPort *ebpf.MapSpec `ebpf:"nb_map_dns_port"`
|
||||
NbWgProxySettingsMap *ebpf.MapSpec `ebpf:"nb_wg_proxy_settings_map"`
|
||||
}
|
||||
|
||||
// bpfVariableSpecs contains global variables before they are loaded into the kernel.
|
||||
//
|
||||
// It can be passed ebpf.CollectionSpec.Assign.
|
||||
type bpfVariableSpecs struct {
|
||||
FlagFeatureWgProxy *ebpf.VariableSpec `ebpf:"flag_feature_wg_proxy"`
|
||||
MapKeyFeatures *ebpf.VariableSpec `ebpf:"map_key_features"`
|
||||
MapKeyProxyPort *ebpf.VariableSpec `ebpf:"map_key_proxy_port"`
|
||||
MapKeyWgPort *ebpf.VariableSpec `ebpf:"map_key_wg_port"`
|
||||
ProxyPort *ebpf.VariableSpec `ebpf:"proxy_port"`
|
||||
WgPort *ebpf.VariableSpec `ebpf:"wg_port"`
|
||||
}
|
||||
|
||||
// bpfObjects contains all objects after they have been loaded into the kernel.
|
||||
//
|
||||
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||
type bpfObjects struct {
|
||||
bpfPrograms
|
||||
bpfMaps
|
||||
bpfVariables
|
||||
}
|
||||
|
||||
func (o *bpfObjects) Close() error {
|
||||
@@ -86,20 +98,28 @@ func (o *bpfObjects) Close() error {
|
||||
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||
type bpfMaps struct {
|
||||
NbFeatures *ebpf.Map `ebpf:"nb_features"`
|
||||
NbMapDnsIp *ebpf.Map `ebpf:"nb_map_dns_ip"`
|
||||
NbMapDnsPort *ebpf.Map `ebpf:"nb_map_dns_port"`
|
||||
NbWgProxySettingsMap *ebpf.Map `ebpf:"nb_wg_proxy_settings_map"`
|
||||
}
|
||||
|
||||
func (m *bpfMaps) Close() error {
|
||||
return _BpfClose(
|
||||
m.NbFeatures,
|
||||
m.NbMapDnsIp,
|
||||
m.NbMapDnsPort,
|
||||
m.NbWgProxySettingsMap,
|
||||
)
|
||||
}
|
||||
|
||||
// bpfVariables contains all global variables after they have been loaded into the kernel.
|
||||
//
|
||||
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||
type bpfVariables struct {
|
||||
FlagFeatureWgProxy *ebpf.Variable `ebpf:"flag_feature_wg_proxy"`
|
||||
MapKeyFeatures *ebpf.Variable `ebpf:"map_key_features"`
|
||||
MapKeyProxyPort *ebpf.Variable `ebpf:"map_key_proxy_port"`
|
||||
MapKeyWgPort *ebpf.Variable `ebpf:"map_key_wg_port"`
|
||||
ProxyPort *ebpf.Variable `ebpf:"proxy_port"`
|
||||
WgPort *ebpf.Variable `ebpf:"wg_port"`
|
||||
}
|
||||
|
||||
// bpfPrograms contains all programs after they have been loaded into the kernel.
|
||||
//
|
||||
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||
|
||||
Binary file not shown.
@@ -1,5 +1,5 @@
|
||||
// Code generated by bpf2go; DO NOT EDIT.
|
||||
//go:build 386 || amd64 || amd64p32 || arm || arm64 || loong64 || mips64le || mips64p32le || mipsle || ppc64le || riscv64
|
||||
//go:build 386 || amd64 || arm || arm64 || loong64 || mips64le || mipsle || ppc64le || riscv64 || wasm
|
||||
|
||||
package ebpf
|
||||
|
||||
@@ -47,9 +47,10 @@ func loadBpfObjects(obj interface{}, opts *ebpf.CollectionOptions) error {
|
||||
type bpfSpecs struct {
|
||||
bpfProgramSpecs
|
||||
bpfMapSpecs
|
||||
bpfVariableSpecs
|
||||
}
|
||||
|
||||
// bpfSpecs contains programs before they are loaded into the kernel.
|
||||
// bpfProgramSpecs contains programs before they are loaded into the kernel.
|
||||
//
|
||||
// It can be passed ebpf.CollectionSpec.Assign.
|
||||
type bpfProgramSpecs struct {
|
||||
@@ -61,17 +62,28 @@ type bpfProgramSpecs struct {
|
||||
// It can be passed ebpf.CollectionSpec.Assign.
|
||||
type bpfMapSpecs struct {
|
||||
NbFeatures *ebpf.MapSpec `ebpf:"nb_features"`
|
||||
NbMapDnsIp *ebpf.MapSpec `ebpf:"nb_map_dns_ip"`
|
||||
NbMapDnsPort *ebpf.MapSpec `ebpf:"nb_map_dns_port"`
|
||||
NbWgProxySettingsMap *ebpf.MapSpec `ebpf:"nb_wg_proxy_settings_map"`
|
||||
}
|
||||
|
||||
// bpfVariableSpecs contains global variables before they are loaded into the kernel.
|
||||
//
|
||||
// It can be passed ebpf.CollectionSpec.Assign.
|
||||
type bpfVariableSpecs struct {
|
||||
FlagFeatureWgProxy *ebpf.VariableSpec `ebpf:"flag_feature_wg_proxy"`
|
||||
MapKeyFeatures *ebpf.VariableSpec `ebpf:"map_key_features"`
|
||||
MapKeyProxyPort *ebpf.VariableSpec `ebpf:"map_key_proxy_port"`
|
||||
MapKeyWgPort *ebpf.VariableSpec `ebpf:"map_key_wg_port"`
|
||||
ProxyPort *ebpf.VariableSpec `ebpf:"proxy_port"`
|
||||
WgPort *ebpf.VariableSpec `ebpf:"wg_port"`
|
||||
}
|
||||
|
||||
// bpfObjects contains all objects after they have been loaded into the kernel.
|
||||
//
|
||||
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||
type bpfObjects struct {
|
||||
bpfPrograms
|
||||
bpfMaps
|
||||
bpfVariables
|
||||
}
|
||||
|
||||
func (o *bpfObjects) Close() error {
|
||||
@@ -86,20 +98,28 @@ func (o *bpfObjects) Close() error {
|
||||
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||
type bpfMaps struct {
|
||||
NbFeatures *ebpf.Map `ebpf:"nb_features"`
|
||||
NbMapDnsIp *ebpf.Map `ebpf:"nb_map_dns_ip"`
|
||||
NbMapDnsPort *ebpf.Map `ebpf:"nb_map_dns_port"`
|
||||
NbWgProxySettingsMap *ebpf.Map `ebpf:"nb_wg_proxy_settings_map"`
|
||||
}
|
||||
|
||||
func (m *bpfMaps) Close() error {
|
||||
return _BpfClose(
|
||||
m.NbFeatures,
|
||||
m.NbMapDnsIp,
|
||||
m.NbMapDnsPort,
|
||||
m.NbWgProxySettingsMap,
|
||||
)
|
||||
}
|
||||
|
||||
// bpfVariables contains all global variables after they have been loaded into the kernel.
|
||||
//
|
||||
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||
type bpfVariables struct {
|
||||
FlagFeatureWgProxy *ebpf.Variable `ebpf:"flag_feature_wg_proxy"`
|
||||
MapKeyFeatures *ebpf.Variable `ebpf:"map_key_features"`
|
||||
MapKeyProxyPort *ebpf.Variable `ebpf:"map_key_proxy_port"`
|
||||
MapKeyWgPort *ebpf.Variable `ebpf:"map_key_wg_port"`
|
||||
ProxyPort *ebpf.Variable `ebpf:"proxy_port"`
|
||||
WgPort *ebpf.Variable `ebpf:"wg_port"`
|
||||
}
|
||||
|
||||
// bpfPrograms contains all programs after they have been loaded into the kernel.
|
||||
//
|
||||
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
|
||||
|
||||
Binary file not shown.
@@ -1,52 +0,0 @@
|
||||
package ebpf
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
mapKeyDNSIP uint32 = 0
|
||||
mapKeyDNSPort uint32 = 1
|
||||
)
|
||||
|
||||
func (tf *GeneralManager) LoadDNSFwd(ip netip.Addr, dnsPort int) error {
|
||||
log.Debugf("load eBPF DNS forwarder, watching addr: %s:53, redirect to port: %d", ip, dnsPort)
|
||||
tf.lock.Lock()
|
||||
defer tf.lock.Unlock()
|
||||
|
||||
err := tf.loadXdp()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !ip.Is4() {
|
||||
return fmt.Errorf("eBPF DNS forwarder only supports IPv4, got %s", ip)
|
||||
}
|
||||
ip4 := ip.As4()
|
||||
err = tf.bpfObjs.NbMapDnsIp.Put(mapKeyDNSIP, binary.BigEndian.Uint32(ip4[:]))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = tf.bpfObjs.NbMapDnsPort.Put(mapKeyDNSPort, uint16(dnsPort))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tf.setFeatureFlag(featureFlagDnsForwarder)
|
||||
err = tf.bpfObjs.NbFeatures.Put(mapKeyFeatures, tf.featureFlags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tf *GeneralManager) FreeDNSFwd() error {
|
||||
log.Debugf("free ebpf DNS forwarder")
|
||||
return tf.unsetFeatureFlag(featureFlagDnsForwarder)
|
||||
}
|
||||
|
||||
@@ -15,8 +15,7 @@ import (
|
||||
const (
|
||||
mapKeyFeatures uint32 = 0
|
||||
|
||||
featureFlagWGProxy = 0b00000001
|
||||
featureFlagDnsForwarder = 0b00000010
|
||||
featureFlagWGProxy = 0b00000001
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -28,9 +27,9 @@ var (
|
||||
|
||||
// GeneralManager is used to load multiple eBPF programs with a custom check (if then) done in prog.c
|
||||
// The manager simply adds a feature (byte) of each program to a map that is shared between the userspace and kernel.
|
||||
// When packet arrives, the C code checks for each feature (if it is set) and executes each enabled program (e.g., dns_fwd.c and wg_proxy.c).
|
||||
// When packet arrives, the C code checks for each feature (if it is set) and executes each enabled program (e.g., wg_proxy.c).
|
||||
//
|
||||
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang-14 bpf src/prog.c -- -I /usr/x86_64-linux-gnu/include
|
||||
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang-14 bpf src/prog.c -- -I /usr/x86_64-linux-gnu/include -include src/bpf_map_def.h
|
||||
type GeneralManager struct {
|
||||
lock sync.Mutex
|
||||
link link.Link
|
||||
|
||||
@@ -7,33 +7,24 @@ import (
|
||||
func TestManager_setFeatureFlag(t *testing.T) {
|
||||
mgr := GeneralManager{}
|
||||
mgr.setFeatureFlag(featureFlagWGProxy)
|
||||
if mgr.featureFlags != 1 {
|
||||
if mgr.featureFlags != featureFlagWGProxy {
|
||||
t.Errorf("invalid feature state")
|
||||
}
|
||||
|
||||
mgr.setFeatureFlag(featureFlagDnsForwarder)
|
||||
if mgr.featureFlags != 3 {
|
||||
t.Errorf("invalid feature state")
|
||||
mgr.setFeatureFlag(featureFlagWGProxy)
|
||||
if mgr.featureFlags != featureFlagWGProxy {
|
||||
t.Errorf("setting a flag twice must be idempotent, got: %d", mgr.featureFlags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManager_unsetFeatureFlag(t *testing.T) {
|
||||
mgr := GeneralManager{}
|
||||
mgr.setFeatureFlag(featureFlagWGProxy)
|
||||
mgr.setFeatureFlag(featureFlagDnsForwarder)
|
||||
|
||||
err := mgr.unsetFeatureFlag(featureFlagWGProxy)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %s", err)
|
||||
}
|
||||
if mgr.featureFlags != 2 {
|
||||
t.Errorf("invalid feature state, expected: %d, got: %d", 2, mgr.featureFlags)
|
||||
}
|
||||
|
||||
err = mgr.unsetFeatureFlag(featureFlagDnsForwarder)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %s", err)
|
||||
}
|
||||
if mgr.featureFlags != 0 {
|
||||
t.Errorf("invalid feature state, expected: %d, got: %d", 0, mgr.featureFlags)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// libbpf 1.0 removed struct bpf_map_def, but the programs here keep the legacy
|
||||
// map definitions: they load on kernels built without BTF, which BTF-style
|
||||
// (SEC(".maps")) definitions do not. Define the struct ourselves so the
|
||||
// programs compile against current libbpf headers.
|
||||
#ifndef NB_BPF_MAP_DEF_H
|
||||
#define NB_BPF_MAP_DEF_H
|
||||
|
||||
struct bpf_map_def {
|
||||
unsigned int type;
|
||||
unsigned int key_size;
|
||||
unsigned int value_size;
|
||||
unsigned int max_entries;
|
||||
unsigned int map_flags;
|
||||
};
|
||||
|
||||
#endif
|
||||
@@ -1,67 +0,0 @@
|
||||
const __u32 map_key_dns_ip = 0;
|
||||
const __u32 map_key_dns_port = 1;
|
||||
|
||||
struct bpf_map_def SEC("maps") nb_map_dns_ip = {
|
||||
.type = BPF_MAP_TYPE_ARRAY,
|
||||
.key_size = sizeof(__u32),
|
||||
.value_size = sizeof(__u32),
|
||||
.max_entries = 10,
|
||||
};
|
||||
|
||||
struct bpf_map_def SEC("maps") nb_map_dns_port = {
|
||||
.type = BPF_MAP_TYPE_ARRAY,
|
||||
.key_size = sizeof(__u32),
|
||||
.value_size = sizeof(__u16),
|
||||
.max_entries = 10,
|
||||
};
|
||||
|
||||
__be32 dns_ip = 0;
|
||||
__be16 dns_port = 0;
|
||||
|
||||
// 13568 is 53 in big endian
|
||||
__be16 GENERAL_DNS_PORT = 13568;
|
||||
|
||||
bool read_settings() {
|
||||
__u16 *port_value;
|
||||
__u32 *ip_value;
|
||||
|
||||
// read dns ip
|
||||
ip_value = bpf_map_lookup_elem(&nb_map_dns_ip, &map_key_dns_ip);
|
||||
if(!ip_value) {
|
||||
return false;
|
||||
}
|
||||
dns_ip = htonl(*ip_value);
|
||||
|
||||
// read dns port
|
||||
port_value = bpf_map_lookup_elem(&nb_map_dns_port, &map_key_dns_port);
|
||||
if (!port_value) {
|
||||
return false;
|
||||
}
|
||||
dns_port = htons(*port_value);
|
||||
return true;
|
||||
}
|
||||
|
||||
int xdp_dns_fwd(struct iphdr *ip, struct udphdr *udp) {
|
||||
if (dns_port == 0) {
|
||||
if(!read_settings()){
|
||||
return XDP_PASS;
|
||||
}
|
||||
// bpf_printk("dns port: %d", ntohs(dns_port));
|
||||
// bpf_printk("dns ip: %d", ntohl(dns_ip));
|
||||
}
|
||||
|
||||
if (udp->dest == GENERAL_DNS_PORT && ip->daddr == dns_ip) {
|
||||
udp->dest = dns_port;
|
||||
// Clear the now-stale checksum; zero means "not computed" for IPv4.
|
||||
udp->check = 0;
|
||||
return XDP_PASS;
|
||||
}
|
||||
|
||||
if (udp->source == dns_port && ip->saddr == dns_ip) {
|
||||
udp->source = GENERAL_DNS_PORT;
|
||||
udp->check = 0;
|
||||
return XDP_PASS;
|
||||
}
|
||||
|
||||
return XDP_PASS;
|
||||
}
|
||||
@@ -5,11 +5,9 @@
|
||||
#include <netinet/in.h>
|
||||
#include <linux/bpf.h>
|
||||
#include <bpf/bpf_helpers.h>
|
||||
#include "dns_fwd.c"
|
||||
#include "wg_proxy.c"
|
||||
|
||||
const __u16 flag_feature_wg_proxy = 0b01;
|
||||
const __u16 flag_feature_dns_fwd = 0b10;
|
||||
|
||||
const __u32 map_key_features = 0;
|
||||
struct bpf_map_def SEC("maps") nb_features = {
|
||||
@@ -48,10 +46,6 @@ int nb_xdp_prog(struct xdp_md *ctx) {
|
||||
return XDP_PASS;
|
||||
}
|
||||
|
||||
if (*features & flag_feature_dns_fwd) {
|
||||
xdp_dns_fwd(ip, udp);
|
||||
}
|
||||
|
||||
if (*features & flag_feature_wg_proxy) {
|
||||
xdp_wg_proxy(ip, udp);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
# DNS forwarder
|
||||
# XDP programs
|
||||
|
||||
The agent attach the XDP program to the lo device. We can not use fake address in eBPF because the
|
||||
traffic does not appear in the eBPF program. The program capture the traffic on wg_ip:53 and
|
||||
overwrite in it the destination port to 5053.
|
||||
`prog.c` is attached to the `lo` device and dispatches to the features enabled in the
|
||||
`nb_features` map. The only feature is the WireGuard proxy (`wg_proxy.c`): it rewrites
|
||||
loopback UDP sent from the WireGuard listen port so it reaches the userspace relay proxy
|
||||
port instead, and swaps the peer endpoint port into the source so the proxy can tell
|
||||
peers apart.
|
||||
|
||||
Maps use the legacy `struct bpf_map_def` form, defined in `bpf_map_def.h` because libbpf
|
||||
1.0 removed it. They load on kernels built without BTF, which BTF-style (`SEC(".maps")`)
|
||||
definitions do not.
|
||||
|
||||
Regenerate the objects with `go generate ./client/internal/ebpf/ebpf/`; it needs
|
||||
`clang-14`. Loading a regenerated object needs root, attaching it needs `bpf_link`
|
||||
(kernel >= 5.7), and only one XDP program can own `lo` at a time.
|
||||
|
||||
# Debug
|
||||
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
package manager
|
||||
|
||||
import "net/netip"
|
||||
|
||||
// Manager is used to load multiple eBPF programs. E.g., current DNS programs and WireGuard proxy
|
||||
// Manager is used to load multiple eBPF programs. E.g., the WireGuard proxy
|
||||
type Manager interface {
|
||||
LoadDNSFwd(ip netip.Addr, dnsPort int) error
|
||||
FreeDNSFwd() error
|
||||
LoadWgProxy(proxyPort, wgPort int) error
|
||||
FreeWGProxy() error
|
||||
}
|
||||
|
||||
@@ -14,12 +14,14 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/go-multierror"
|
||||
"github.com/pion/ice/v4"
|
||||
"github.com/pion/stun/v3"
|
||||
log "github.com/sirupsen/logrus"
|
||||
wgdevice "golang.zx2c4.com/wireguard/device"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
@@ -237,6 +239,12 @@ type Engine struct {
|
||||
|
||||
wgInterface WGIface
|
||||
|
||||
// wgDevice is a lock-free handle on the WireGuard device behind
|
||||
// wgInterface. Reaching the device through wgInterface requires
|
||||
// syncMsgMux, which handleSync holds while it adds and removes peers;
|
||||
// SetPerformance must stay reachable exactly when that work is stuck.
|
||||
wgDevice atomic.Pointer[wgdevice.Device]
|
||||
|
||||
udpMux *udpmux.UniversalUDPMuxDefault
|
||||
|
||||
// networkSerial is the latest CurrentSerial (state ID) of the network sent by the Management service
|
||||
@@ -652,6 +660,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
|
||||
log.Errorf("failed to pull up wgInterface [%s]: %s", e.wgInterface.Name(), err.Error())
|
||||
return fmt.Errorf("up wg interface: %w", err)
|
||||
}
|
||||
e.wgDevice.Store(e.wgInterface.GetWGDevice())
|
||||
|
||||
// Set up notrack rules immediately after proxy is listening to prevent
|
||||
// conntrack entries from being created before the rules are in place
|
||||
@@ -2155,6 +2164,10 @@ func (e *Engine) close() {
|
||||
log.Debugf("removing Netbird interface %s", e.config.WgIfaceName)
|
||||
|
||||
if e.wgInterface != nil {
|
||||
// Drop the handle before the close starts: a retune that loads it
|
||||
// afterwards would touch a device on its way out and report success
|
||||
// for an engine that is already gone.
|
||||
e.wgDevice.Store(nil)
|
||||
if err := e.wgInterface.Close(); err != nil {
|
||||
log.Errorf("failed closing Netbird interface %s %v", e.config.WgIfaceName, err)
|
||||
}
|
||||
@@ -2314,15 +2327,16 @@ type Performance struct {
|
||||
}
|
||||
|
||||
// SetPerformance applies the given tuning to this engine's live Device.
|
||||
//
|
||||
// It deliberately does not take syncMsgMux. Raising the buffer pool cap is the
|
||||
// recovery path for a device whose pool is exhausted, and an exhausted pool
|
||||
// blocks peer removal inside handleSync, which holds syncMsgMux for as long as
|
||||
// it stays blocked. Taking the lock here would make the retune unreachable in
|
||||
// the one situation that needs it.
|
||||
func (e *Engine) SetPerformance(t Performance) error {
|
||||
e.syncMsgMux.Lock()
|
||||
defer e.syncMsgMux.Unlock()
|
||||
if e.wgInterface == nil {
|
||||
return fmt.Errorf("wg interface not initialized")
|
||||
}
|
||||
dev := e.wgInterface.GetWGDevice()
|
||||
dev := e.wgDevice.Load()
|
||||
if dev == nil {
|
||||
return fmt.Errorf("wg device not initialized")
|
||||
return errors.New("wg device not initialized")
|
||||
}
|
||||
if t.PreallocatedBuffersPerPool != nil {
|
||||
dev.SetPreallocatedBuffersPerPool(*t.PreallocatedBuffersPerPool)
|
||||
|
||||
@@ -116,7 +116,7 @@ func (h *Handshaker) Listen(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case remoteOfferAnswer := <-h.remoteOffersCh:
|
||||
h.log.Infof("received offer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials())
|
||||
h.log.Infof("received offer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t, relay server: %s, relay IP: %s", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials(), remoteOfferAnswer.RelaySrvAddress, remoteOfferAnswer.RelaySrvIP)
|
||||
|
||||
// Record signaling received for reconnection attempts
|
||||
if h.metricsStages != nil {
|
||||
@@ -138,7 +138,7 @@ func (h *Handshaker) Listen(ctx context.Context) {
|
||||
continue
|
||||
}
|
||||
case remoteOfferAnswer := <-h.remoteAnswerCh:
|
||||
h.log.Infof("received answer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials())
|
||||
h.log.Infof("received answer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t, relay server: %s, relay IP: %s", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials(), remoteOfferAnswer.RelaySrvAddress, remoteOfferAnswer.RelaySrvIP)
|
||||
|
||||
// Record signaling received for reconnection attempts
|
||||
if h.metricsStages != nil {
|
||||
@@ -209,14 +209,14 @@ func (h *Handshaker) sendOffer() error {
|
||||
}
|
||||
|
||||
offer := h.buildOfferAnswer()
|
||||
h.log.Debugf("sending offer with serial: %s", offer.SessionIDString())
|
||||
h.log.Debugf("sending offer with serial: %s, relay server: %s, relay IP: %s", offer.SessionIDString(), offer.RelaySrvAddress, offer.RelaySrvIP)
|
||||
|
||||
return h.signaler.SignalOffer(offer, h.config.Key)
|
||||
}
|
||||
|
||||
func (h *Handshaker) sendAnswer() error {
|
||||
answer := h.buildOfferAnswer()
|
||||
h.log.Debugf("sending answer with serial: %s", answer.SessionIDString())
|
||||
h.log.Debugf("sending answer with serial: %s, relay server: %s, relay IP: %s", answer.SessionIDString(), answer.RelaySrvAddress, answer.RelaySrvIP)
|
||||
|
||||
return h.signaler.SignalAnswer(answer, h.config.Key)
|
||||
}
|
||||
|
||||
@@ -58,10 +58,6 @@ var DefaultInterfaceBlacklist = []string{
|
||||
"Tailscale", "tailscale", "docker", "veth", "br-", "lo",
|
||||
}
|
||||
|
||||
// loadMDMPolicy is the package-level indirection used by apply() to read the
|
||||
// active MDM policy. Tests override this to inject a fake policy.
|
||||
var loadMDMPolicy = mdm.LoadPolicy
|
||||
|
||||
// ConfigInput carries configuration changes to the client
|
||||
type ConfigInput struct {
|
||||
ManagementURL string
|
||||
@@ -202,14 +198,26 @@ type Config struct {
|
||||
|
||||
MTU uint16
|
||||
|
||||
// policy is the MDM policy that produced the currently-set values for
|
||||
// any MDM-enforced fields. Set by applyMDMPolicy at the tail of apply()
|
||||
// and reset on every apply() invocation. Never persisted to disk.
|
||||
// Callers query enforcement state via Policy() and the mdm.Policy API
|
||||
// (HasKey, ManagedKeys, IsEmpty).
|
||||
// policy is the MDM policy that produced the currently-set values
|
||||
// for any MDM-enforced fields. Set by ApplyMDMPolicy on every
|
||||
// invocation. Never persisted to disk. Callers query enforcement
|
||||
// state via Policy() and the mdm.Policy API (HasKey, ManagedKeys,
|
||||
// IsEmpty).
|
||||
policy *mdm.Policy `json:"-"`
|
||||
}
|
||||
|
||||
// ApplyMDMPolicy overlays the supplied MDM Policy on top of the current
|
||||
// Config values and records it as Policy(). The overlay is not reversible:
|
||||
// an empty Policy only clears the enforcement metadata, so resolve the base
|
||||
// Config again (from disk or JSON) before applying a changed policy, the way
|
||||
// the lifecycle owners do on every load.
|
||||
func (config *Config) ApplyMDMPolicy(policy *mdm.Policy) {
|
||||
if config == nil {
|
||||
return
|
||||
}
|
||||
config.applyMDMPolicy(policy)
|
||||
}
|
||||
|
||||
// Policy returns the MDM policy applied to this Config. Returns a non-nil
|
||||
// empty Policy when MDM enforcement is inactive; callers can always invoke
|
||||
// HasKey / ManagedKeys / IsEmpty without a nil check.
|
||||
@@ -712,9 +720,11 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
updated = true
|
||||
}
|
||||
|
||||
// MDM is the last override layer: any key present in the policy
|
||||
// supersedes defaults, on-disk config, env vars and CLI input.
|
||||
config.applyMDMPolicy(loadMDMPolicy())
|
||||
// Initialise the MDM overlay to "no enforcement" so Config.Policy()
|
||||
// never returns a stale or nil policy on a freshly applied Config.
|
||||
// Lifecycle owners that want to enforce a real MDM policy invoke
|
||||
// Config.ApplyMDMPolicy(loader.Load()) after this returns.
|
||||
config.applyMDMPolicy(mdm.NewPolicy(nil))
|
||||
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package profilemanager
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
)
|
||||
|
||||
// ErrMDMManagedFields marks a config change rejected because it diverges from
|
||||
// MDM-enforced values.
|
||||
var ErrMDMManagedFields = errors.New("fields managed by MDM cannot be modified")
|
||||
|
||||
// MDMConflicts returns the names of MDM-managed keys whose requested value in
|
||||
// the ConfigInput differs from the policy-enforced value; a field set to the
|
||||
// enforced value is a no-op echo, not a conflict.
|
||||
func MDMConflicts(input ConfigInput, policy *mdm.Policy) []string {
|
||||
pskGot := input.PreSharedKey
|
||||
if isPreSharedKeyHidden(pskGot) {
|
||||
pskGot = nil
|
||||
}
|
||||
var port *int64
|
||||
if input.WireguardPort != nil {
|
||||
v := int64(*input.WireguardPort)
|
||||
port = &v
|
||||
}
|
||||
return mdm.ResolveConflicts(policy, []mdm.ConflictCheck{
|
||||
mdm.ConflictURL(mdm.KeyManagementURL, input.ManagementURL),
|
||||
mdm.ConflictStringPtr(mdm.KeyPreSharedKey, pskGot),
|
||||
mdm.ConflictBool(mdm.KeyRosenpassEnabled, input.RosenpassEnabled),
|
||||
mdm.ConflictBool(mdm.KeyRosenpassPermissive, input.RosenpassPermissive),
|
||||
mdm.ConflictBool(mdm.KeyDisableAutoConnect, input.DisableAutoConnect),
|
||||
mdm.ConflictBool(mdm.KeyAllowServerSSH, input.ServerSSHAllowed),
|
||||
mdm.ConflictBool(mdm.KeyRemoteJobsAllowed, input.RemoteJobsAllowed),
|
||||
mdm.ConflictBool(mdm.KeyDisableClientRoutes, input.DisableClientRoutes),
|
||||
mdm.ConflictBool(mdm.KeyDisableServerRoutes, input.DisableServerRoutes),
|
||||
mdm.ConflictBool(mdm.KeyBlockInbound, input.BlockInbound),
|
||||
mdm.ConflictInt64(mdm.KeyWireguardPort, port),
|
||||
mdm.ConflictBool(mdm.KeyEnableLocalMetrics, input.LocalMetricsEnabled),
|
||||
mdm.ConflictStringPtr(mdm.KeyLocalMetricsAddress, input.LocalMetricsAddress),
|
||||
})
|
||||
}
|
||||
|
||||
// CheckMDMConflicts returns an ErrMDMManagedFields-wrapped error naming the
|
||||
// conflicting keys, or nil when the input does not fight the policy.
|
||||
func CheckMDMConflicts(input ConfigInput, policy *mdm.Policy) error {
|
||||
conflicts := MDMConflicts(input, policy)
|
||||
if len(conflicts) == 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%w: %v", ErrMDMManagedFields, conflicts)
|
||||
}
|
||||
@@ -10,24 +10,58 @@ import (
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
)
|
||||
|
||||
// withMDMPolicy temporarily overrides the package-level loadMDMPolicy hook so
|
||||
// apply() observes the supplied Policy. The original loader is restored at
|
||||
// test cleanup.
|
||||
func withMDMPolicy(t *testing.T, policy *mdm.Policy) {
|
||||
// fakeFetcher implements mdm.PolicyFetcher returning a pre-set policy
|
||||
// map. Test helper used to construct a Loader without touching the OS
|
||||
// or any package-level state.
|
||||
type fakeFetcher struct{ values map[string]any }
|
||||
|
||||
func (f *fakeFetcher) Fetch() map[string]any { return f.values }
|
||||
|
||||
// loaderFor builds an mdm.Loader whose loadPlatform returns the
|
||||
// supplied Policy's underlying values.
|
||||
func loaderFor(policy *mdm.Policy) *mdm.Loader {
|
||||
if policy == nil || policy.IsEmpty() {
|
||||
return mdm.NewLoader(&fakeFetcher{values: nil})
|
||||
}
|
||||
values := make(map[string]any)
|
||||
for _, k := range policy.ManagedKeys() {
|
||||
if v, ok := policy.GetString(k); ok {
|
||||
values[k] = v
|
||||
continue
|
||||
}
|
||||
if v, ok := policy.GetInt(k); ok {
|
||||
values[k] = v
|
||||
continue
|
||||
}
|
||||
if v, ok := policy.GetBool(k); ok {
|
||||
values[k] = v
|
||||
continue
|
||||
}
|
||||
if v, ok := policy.GetStringSlice(k); ok {
|
||||
values[k] = v
|
||||
}
|
||||
}
|
||||
return mdm.NewLoader(&fakeFetcher{values: values})
|
||||
}
|
||||
|
||||
// configWithMDM is the test convenience that builds a Config via
|
||||
// UpdateOrCreateConfig and overlays the supplied MDM policy on top —
|
||||
// mirrors the production pattern (Server.getConfig / Client.applyMDMOverlay)
|
||||
// where the Loader lives outside Config and the apply step is driven
|
||||
// by the lifecycle owner.
|
||||
func configWithMDM(t *testing.T, input ConfigInput, policy *mdm.Policy) *Config {
|
||||
t.Helper()
|
||||
prev := loadMDMPolicy
|
||||
loadMDMPolicy = func() *mdm.Policy { return policy }
|
||||
t.Cleanup(func() { loadMDMPolicy = prev })
|
||||
cfg, err := UpdateOrCreateConfig(input)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
cfg.ApplyMDMPolicy(loaderFor(policy).Load())
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestApply_MDMEmpty_NoEnforcement(t *testing.T) {
|
||||
withMDMPolicy(t, mdm.NewPolicy(nil))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
||||
cfg := configWithMDM(t, ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
}, mdm.NewPolicy(nil))
|
||||
|
||||
assert.True(t, cfg.Policy().IsEmpty(), "no MDM source ⇒ empty Policy")
|
||||
assert.False(t, cfg.Policy().HasKey(mdm.KeyManagementURL))
|
||||
@@ -39,18 +73,15 @@ func TestApply_MDMEmpty_NoEnforcement(t *testing.T) {
|
||||
|
||||
func TestApply_MDMOnly_OverridesDefaults(t *testing.T) {
|
||||
const mdmURL = "https://corp.mdm.example.com:443"
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
|
||||
cfg := configWithMDM(t, ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
}, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyManagementURL: mdmURL,
|
||||
mdm.KeyDisableClientRoutes: true,
|
||||
mdm.KeyBlockInbound: true,
|
||||
}))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
assert.Equal(t, mdmURL, cfg.ManagementURL.String())
|
||||
assert.True(t, cfg.DisableClientRoutes)
|
||||
assert.True(t, cfg.BlockInbound)
|
||||
@@ -65,16 +96,12 @@ func TestApply_MDMBeatsCLIInput(t *testing.T) {
|
||||
const mdmURL = "https://mdm.example.com:443"
|
||||
const cliURL = "https://cli.example.com:443"
|
||||
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyManagementURL: mdmURL,
|
||||
}))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
||||
cfg := configWithMDM(t, ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
ManagementURL: cliURL,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
}, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyManagementURL: mdmURL,
|
||||
}))
|
||||
|
||||
// MDM wins over CLI-supplied management URL.
|
||||
assert.Equal(t, mdmURL, cfg.ManagementURL.String())
|
||||
@@ -82,16 +109,12 @@ func TestApply_MDMBeatsCLIInput(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApply_MDMInvalidURL_KeepsPreviousValue(t *testing.T) {
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
cfg := configWithMDM(t, ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
}, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyManagementURL: "not-a-url",
|
||||
}))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
// Invalid MDM URL is logged and skipped: default URL stays in place
|
||||
// to keep the client functional.
|
||||
assert.Equal(t, DefaultManagementURL, cfg.ManagementURL.String())
|
||||
@@ -106,24 +129,20 @@ func TestApply_MDMBoolKeysOverrideOnDiskValue(t *testing.T) {
|
||||
tmp := filepath.Join(t.TempDir(), "config.json")
|
||||
|
||||
// Seed without MDM.
|
||||
withMDMPolicy(t, mdm.NewPolicy(nil))
|
||||
_, err := UpdateOrCreateConfig(ConfigInput{
|
||||
configWithMDM(t, ConfigInput{
|
||||
ConfigPath: tmp,
|
||||
DisableClientRoutes: boolPtr(false),
|
||||
RosenpassEnabled: boolPtr(false),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}, mdm.NewPolicy(nil))
|
||||
|
||||
// Now enable MDM enforcement for these keys.
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
cfg := configWithMDM(t, ConfigInput{
|
||||
ConfigPath: tmp,
|
||||
}, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyDisableClientRoutes: true,
|
||||
mdm.KeyRosenpassEnabled: true,
|
||||
}))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: tmp})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
assert.True(t, cfg.DisableClientRoutes, "MDM override should flip on-disk false to true")
|
||||
assert.True(t, cfg.RosenpassEnabled)
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyDisableClientRoutes))
|
||||
@@ -134,22 +153,19 @@ func TestApply_MDMLocalMetrics(t *testing.T) {
|
||||
tmp := filepath.Join(t.TempDir(), "config.json")
|
||||
|
||||
// Seed without MDM.
|
||||
withMDMPolicy(t, mdm.NewPolicy(nil))
|
||||
_, err := UpdateOrCreateConfig(ConfigInput{
|
||||
configWithMDM(t, ConfigInput{
|
||||
ConfigPath: tmp,
|
||||
LocalMetricsEnabled: boolPtr(false),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}, mdm.NewPolicy(nil))
|
||||
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
// Now enable MDM enforcement for these keys.
|
||||
cfg := configWithMDM(t, ConfigInput{
|
||||
ConfigPath: tmp,
|
||||
}, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyEnableLocalMetrics: true,
|
||||
mdm.KeyLocalMetricsAddress: "127.0.0.1:9292",
|
||||
}))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: tmp})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
assert.True(t, cfg.LocalMetricsEnabled, "MDM override should flip on-disk false to true")
|
||||
assert.Equal(t, "127.0.0.1:9292", cfg.LocalMetricsAddress)
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyEnableLocalMetrics))
|
||||
@@ -171,16 +187,12 @@ func TestApply_MDMLazyConnection(t *testing.T) {
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
cfg := configWithMDM(t, ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
}, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyLazyConnection: c.raw,
|
||||
}))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
assert.Equal(t, c.want, cfg.LazyConnection)
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyLazyConnection))
|
||||
})
|
||||
@@ -188,22 +200,83 @@ func TestApply_MDMLazyConnection(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApply_MDMPreSharedKeyRedactionSentinelRejected(t *testing.T) {
|
||||
const maskSentinel = "**********"
|
||||
const maskSentinel = mdm.PreSharedKeyRedactedSentinel
|
||||
|
||||
withMDMPolicy(t, mdm.NewPolicy(map[string]any{
|
||||
cfg := configWithMDM(t, ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
}, mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyPreSharedKey: maskSentinel,
|
||||
}))
|
||||
|
||||
cfg, err := UpdateOrCreateConfig(ConfigInput{
|
||||
ConfigPath: filepath.Join(t.TempDir(), "config.json"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cfg)
|
||||
|
||||
// Mask sentinel must not be persisted as the actual PSK.
|
||||
assert.NotEqual(t, maskSentinel, cfg.PreSharedKey)
|
||||
// Key still marked managed so user writes are still rejected.
|
||||
assert.True(t, cfg.Policy().HasKey(mdm.KeyPreSharedKey))
|
||||
}
|
||||
|
||||
func TestMDMConflicts_PreSharedKey(t *testing.T) {
|
||||
policy := mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyPreSharedKey: "mdm-enforced-psk",
|
||||
})
|
||||
empty := ""
|
||||
sentinel := mdm.PreSharedKeyRedactedSentinel
|
||||
same := "mdm-enforced-psk"
|
||||
other := "user-psk"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
psk *string
|
||||
want []string
|
||||
}{
|
||||
{name: "unset", psk: nil, want: nil},
|
||||
{name: "explicit empty", psk: &empty, want: []string{mdm.KeyPreSharedKey}},
|
||||
{name: "sentinel echo", psk: &sentinel, want: nil},
|
||||
{name: "same value", psk: &same, want: nil},
|
||||
{name: "divergent", psk: &other, want: []string{mdm.KeyPreSharedKey}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, MDMConflicts(ConfigInput{PreSharedKey: tc.psk}, policy))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMDMConflicts_RemoteJobsAndLocalMetrics(t *testing.T) {
|
||||
policy := mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyRemoteJobsAllowed: false,
|
||||
mdm.KeyEnableLocalMetrics: true,
|
||||
mdm.KeyLocalMetricsAddress: "127.0.0.1:9999",
|
||||
})
|
||||
sameAddr := "127.0.0.1:9999"
|
||||
otherAddr := "0.0.0.0:9999"
|
||||
emptyAddr := ""
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input ConfigInput
|
||||
want []string
|
||||
}{
|
||||
{name: "unset", input: ConfigInput{}, want: nil},
|
||||
{name: "echo", input: ConfigInput{
|
||||
RemoteJobsAllowed: boolPtr(false),
|
||||
LocalMetricsEnabled: boolPtr(true),
|
||||
LocalMetricsAddress: &sameAddr,
|
||||
}, want: nil},
|
||||
{name: "remote jobs divergent", input: ConfigInput{RemoteJobsAllowed: boolPtr(true)}, want: []string{mdm.KeyRemoteJobsAllowed}},
|
||||
{name: "metrics disabled", input: ConfigInput{LocalMetricsEnabled: boolPtr(false)}, want: []string{mdm.KeyEnableLocalMetrics}},
|
||||
{name: "metrics address divergent", input: ConfigInput{LocalMetricsAddress: &otherAddr}, want: []string{mdm.KeyLocalMetricsAddress}},
|
||||
{name: "metrics address explicit empty", input: ConfigInput{LocalMetricsAddress: &emptyAddr}, want: []string{mdm.KeyLocalMetricsAddress}},
|
||||
{name: "all divergent", input: ConfigInput{
|
||||
RemoteJobsAllowed: boolPtr(true),
|
||||
LocalMetricsEnabled: boolPtr(false),
|
||||
LocalMetricsAddress: &otherAddr,
|
||||
}, want: []string{mdm.KeyRemoteJobsAllowed, mdm.KeyEnableLocalMetrics, mdm.KeyLocalMetricsAddress}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, MDMConflicts(tc.input, policy))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func boolPtr(b bool) *bool { return &b }
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
@@ -13,17 +14,21 @@ import (
|
||||
const envSudoUser = "SUDO_USER"
|
||||
|
||||
var (
|
||||
geteuid = os.Geteuid
|
||||
lookupUser = user.Lookup
|
||||
currentUser = user.Current
|
||||
getegid = os.Getegid
|
||||
geteuid = os.Geteuid
|
||||
lookupUser = user.Lookup
|
||||
)
|
||||
|
||||
// InvokingUser returns the user a CLI invocation acts for. Under sudo that is
|
||||
// the user who ran sudo, not root: privileged flags force commands through
|
||||
// sudo, and resolving profiles as root would silently switch the daemon to
|
||||
// root's (default) profile instead of the invoking user's. Privilege decisions
|
||||
// are not made here — those stay on the kernel credentials of the daemon
|
||||
// connection, which SUDO_USER (a plain environment variable) can never
|
||||
// influence; a forged value only selects a profile root could select anyway.
|
||||
// root's (default) profile instead of the invoking user's. An unmapped positive
|
||||
// process UID uses its numeric kernel identity; root, sudo lookup failures, and
|
||||
// unavailable platform identities still fail closed. Privilege decisions stay
|
||||
// on the kernel credentials of the daemon connection, which SUDO_USER (a plain
|
||||
// environment variable) can never influence; a forged value only selects a
|
||||
// profile root could select anyway.
|
||||
func InvokingUser() (*user.User, error) {
|
||||
if u, ok := sudoInvokingUser(); ok {
|
||||
return u, nil
|
||||
@@ -35,7 +40,23 @@ func InvokingUser() (*user.User, error) {
|
||||
if sudoActive() {
|
||||
return nil, fmt.Errorf("resolve sudo invoking user %q: refusing to fall back to root", os.Getenv(envSudoUser))
|
||||
}
|
||||
return user.Current()
|
||||
u, err := currentUser()
|
||||
if err == nil {
|
||||
return u, nil
|
||||
}
|
||||
|
||||
uid := geteuid()
|
||||
if uid <= 0 {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Debugf("current user lookup for UID %d: %v; using numeric UID", uid, err)
|
||||
uidString := strconv.Itoa(uid)
|
||||
return &user.User{
|
||||
Username: uidString,
|
||||
Uid: uidString,
|
||||
Gid: strconv.Itoa(getegid()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// IsPlainRoot reports that the process runs as root with no usable sudo
|
||||
|
||||
@@ -2,6 +2,7 @@ package profilemanager
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/user"
|
||||
@@ -21,7 +22,51 @@ func TestInvokingUserFallsBackToProcessUser(t *testing.T) {
|
||||
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, current.Username, got.Username)
|
||||
assert.Equal(t, current.Username, got.Username, "invoking user should match the process user without sudo")
|
||||
}
|
||||
|
||||
func TestInvokingUserFailsClosedWithoutPositiveUID(t *testing.T) {
|
||||
for _, uid := range []int{0, -1} {
|
||||
t.Run(fmt.Sprintf("UID%d", uid), func(t *testing.T) {
|
||||
t.Setenv(envSudoUser, "")
|
||||
lookupErr := errors.New("current user unavailable")
|
||||
fakeUnmappedUser(t, uid, 0, lookupErr)
|
||||
|
||||
got, err := InvokingUser()
|
||||
require.ErrorIs(t, err, lookupErr)
|
||||
assert.Nil(t, got, "root or unavailable UID must not become a synthetic identity")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileFilePathUsesNumericIdentityForUnmappedNonRoot(t *testing.T) {
|
||||
t.Setenv(envSudoUser, "")
|
||||
fakeUnmappedUser(t, 1001230000, 0, errors.New("user: unknown userid 1001230000"))
|
||||
|
||||
profilesRoot := t.TempDir()
|
||||
origDir := DefaultConfigPathDir
|
||||
origOverride := ConfigDirOverride
|
||||
DefaultConfigPathDir = profilesRoot
|
||||
ConfigDirOverride = ""
|
||||
t.Cleanup(func() {
|
||||
DefaultConfigPathDir = origDir
|
||||
ConfigDirOverride = origOverride
|
||||
})
|
||||
|
||||
profileID := ID("0123456789abcdef0123456789abcdef")
|
||||
got, err := (&Profile{ID: profileID}).FilePath()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t,
|
||||
filepath.Join(profilesRoot, "1001230000", profileID.String()+".json"),
|
||||
got,
|
||||
"profile path should use the numeric UID namespace",
|
||||
)
|
||||
|
||||
entries, err := os.ReadDir(profilesRoot)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 1, "only the numeric UID directory should be created")
|
||||
assert.Equal(t, "1001230000", entries[0].Name(), "profile namespace should be numeric")
|
||||
assert.True(t, entries[0].IsDir(), "profile namespace should be a directory")
|
||||
}
|
||||
|
||||
func TestSudoInvokingUserInactiveWithoutSudoContext(t *testing.T) {
|
||||
@@ -60,6 +105,13 @@ func TestInvokingUserFailsClosedWhenSudoLookupFails(t *testing.T) {
|
||||
fakeSudo(t, filepath.Join("/home", "misha"))
|
||||
lookupUser = func(string) (*user.User, error) { return nil, errors.New("nss unavailable") }
|
||||
|
||||
origCurrentUser := currentUser
|
||||
currentUser = func() (*user.User, error) {
|
||||
t.Fatal("currentUser must not be called after a sudo lookup failure")
|
||||
return nil, errors.New("currentUser called unexpectedly")
|
||||
}
|
||||
t.Cleanup(func() { currentUser = origCurrentUser })
|
||||
|
||||
got, err := InvokingUser()
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, got, "must not resolve to the root process user")
|
||||
@@ -215,6 +267,22 @@ func fakeSudo(t *testing.T, home string) {
|
||||
})
|
||||
}
|
||||
|
||||
func fakeUnmappedUser(t *testing.T, uid, gid int, lookupErr error) {
|
||||
t.Helper()
|
||||
|
||||
origCurrentUser := currentUser
|
||||
origEuid := geteuid
|
||||
origEgid := getegid
|
||||
currentUser = func() (*user.User, error) { return nil, lookupErr }
|
||||
geteuid = func() int { return uid }
|
||||
getegid = func() int { return gid }
|
||||
t.Cleanup(func() {
|
||||
currentUser = origCurrentUser
|
||||
geteuid = origEuid
|
||||
getegid = origEgid
|
||||
})
|
||||
}
|
||||
|
||||
func assertNoEntries(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
err := filepath.WalkDir(root, func(path string, _ fs.DirEntry, err error) error {
|
||||
|
||||
Reference in New Issue
Block a user