mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-20 16:12:26 -04:00
Compare commits
109 Commits
feature/cl
...
prototype/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
134b5ce819 | ||
|
|
4433f44a12 | ||
|
|
ad64ba1916 | ||
|
|
7504e718d7 | ||
|
|
9b0387e7ee | ||
|
|
5ccce1ab3f | ||
|
|
e366fe340e | ||
|
|
b01809f8e3 | ||
|
|
790ef39187 | ||
|
|
3af16cf333 | ||
|
|
d09c69f303 | ||
|
|
096d4ac529 | ||
|
|
8fafde614a | ||
|
|
694ae13418 | ||
|
|
b5b7dd4f53 | ||
|
|
476785b122 | ||
|
|
907677f835 | ||
|
|
7d844b9410 | ||
|
|
eeabc64a73 | ||
|
|
5da2b0fdcc | ||
|
|
a0005a604e | ||
|
|
a89bb807a6 | ||
|
|
28f3354ffa | ||
|
|
562923c600 | ||
|
|
0dd0c67b3b | ||
|
|
ca33849f31 | ||
|
|
18cd0f1480 | ||
|
|
b02982f6b1 | ||
|
|
4d89ae27ef | ||
|
|
733ea77c5c | ||
|
|
92f72bfce6 | ||
|
|
bffb25bea7 | ||
|
|
3af4543e80 | ||
|
|
146774860b | ||
|
|
5243481316 | ||
|
|
76a39c1dcb | ||
|
|
02ce918114 | ||
|
|
30cfc22cb6 | ||
|
|
3168afbfcb | ||
|
|
a73ee47557 | ||
|
|
fa6ff005f2 | ||
|
|
095379fa60 | ||
|
|
30572fe1b8 | ||
|
|
3a6f364b03 | ||
|
|
5345d716ee | ||
|
|
f882c36e0a | ||
|
|
e95cfa1a00 | ||
|
|
0d480071b6 | ||
|
|
8e0b7b6c25 | ||
|
|
f204da0d68 | ||
|
|
7d74904d62 | ||
|
|
760ac5e07d | ||
|
|
4352228797 | ||
|
|
74c770609c | ||
|
|
f4ca36ed7e | ||
|
|
c86da92fc6 | ||
|
|
3f0c577456 | ||
|
|
717da8c7b7 | ||
|
|
a0a61d4f47 | ||
|
|
5b1fced872 | ||
|
|
c98dcf5ef9 | ||
|
|
57cb6bfccb | ||
|
|
95bf97dc3c | ||
|
|
3d116c9d33 | ||
|
|
a9ce9f8d5a | ||
|
|
10b981a855 | ||
|
|
7700b4333d | ||
|
|
7d0131111e | ||
|
|
1daea35e4b | ||
|
|
f97544af0d | ||
|
|
231e80cc15 | ||
|
|
a4c1362bff | ||
|
|
b611d4a751 | ||
|
|
2c9decfa55 | ||
|
|
3c5ac17e2f | ||
|
|
ae42bbb898 | ||
|
|
b86722394b | ||
|
|
a103f69767 | ||
|
|
73fbb3fc62 | ||
|
|
7b3523e25e | ||
|
|
6e4e1386e7 | ||
|
|
671e9af6eb | ||
|
|
50f42caf94 | ||
|
|
b7eeefc102 | ||
|
|
8dd22f3a4f | ||
|
|
4b89427447 | ||
|
|
b71e2860cf | ||
|
|
160b27bc60 | ||
|
|
c084386b88 | ||
|
|
6889047350 | ||
|
|
245bbb4acf | ||
|
|
2b2fc02d83 | ||
|
|
703ef29199 | ||
|
|
b0b60b938a | ||
|
|
e3a026bf1c | ||
|
|
94503465ee | ||
|
|
8d959b0abc | ||
|
|
1d8390b935 | ||
|
|
2851e38a1f | ||
|
|
51261fe7a9 | ||
|
|
304321d019 | ||
|
|
f8c3295645 | ||
|
|
183619d1e1 | ||
|
|
3b832d1f21 | ||
|
|
fcb849698f | ||
|
|
7527e0ebdb | ||
|
|
ed5f98da5b | ||
|
|
12b38e25da | ||
|
|
626e892e3b |
@@ -60,8 +60,8 @@
|
||||
|
||||
https://github.com/user-attachments/assets/10cec749-bb56-4ab3-97af-4e38850108d2
|
||||
|
||||
### Self-Host NetBird (Video)
|
||||
[](https://youtu.be/bZAgpT6nzaQ)
|
||||
### NetBird on Lawrence Systems (Video)
|
||||
[](https://www.youtube.com/watch?v=Kwrff6h0rEw)
|
||||
|
||||
### Key features
|
||||
|
||||
|
||||
@@ -162,6 +162,7 @@ func New(opts Options) (*Client, error) {
|
||||
setupKey: opts.SetupKey,
|
||||
jwtToken: opts.JWTToken,
|
||||
config: config,
|
||||
recorder: peer.NewRecorder(config.ManagementURL.String()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -183,6 +184,7 @@ func (c *Client) Start(startCtx context.Context) error {
|
||||
|
||||
// nolint:staticcheck
|
||||
ctx = context.WithValue(ctx, system.DeviceNameCtxKey, c.deviceName)
|
||||
|
||||
authClient, err := auth.NewAuth(ctx, c.config.PrivateKey, c.config.ManagementURL, c.config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create auth client: %w", err)
|
||||
@@ -192,10 +194,7 @@ func (c *Client) Start(startCtx context.Context) error {
|
||||
if err, _ := authClient.Login(ctx, c.setupKey, c.jwtToken); err != nil {
|
||||
return fmt.Errorf("login: %w", err)
|
||||
}
|
||||
|
||||
recorder := peer.NewRecorder(c.config.ManagementURL.String())
|
||||
c.recorder = recorder
|
||||
client := internal.NewConnectClient(ctx, c.config, recorder, false)
|
||||
client := internal.NewConnectClient(ctx, c.config, c.recorder, false)
|
||||
client.SetSyncResponsePersistence(true)
|
||||
|
||||
// either startup error (permanent backoff err) or nil err (successful engine up)
|
||||
@@ -348,14 +347,9 @@ func (c *Client) NewHTTPClient() *http.Client {
|
||||
// Status returns the current status of the client.
|
||||
func (c *Client) Status() (peer.FullStatus, error) {
|
||||
c.mu.Lock()
|
||||
recorder := c.recorder
|
||||
connect := c.connect
|
||||
c.mu.Unlock()
|
||||
|
||||
if recorder == nil {
|
||||
return peer.FullStatus{}, errors.New("client not started")
|
||||
}
|
||||
|
||||
if connect != nil {
|
||||
engine := connect.Engine()
|
||||
if engine != nil {
|
||||
@@ -363,7 +357,7 @@ func (c *Client) Status() (peer.FullStatus, error) {
|
||||
}
|
||||
}
|
||||
|
||||
return recorder.GetFullStatus(), nil
|
||||
return c.recorder.GetFullStatus(), nil
|
||||
}
|
||||
|
||||
// GetLatestSyncResponse returns the latest sync response from the management server.
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
package uspfilter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/statemanager"
|
||||
)
|
||||
|
||||
@@ -11,7 +17,33 @@ func (m *Manager) Close(stateManager *statemanager.Manager) error {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
m.resetState()
|
||||
m.outgoingRules = make(map[netip.Addr]RuleSet)
|
||||
m.incomingDenyRules = make(map[netip.Addr]RuleSet)
|
||||
m.incomingRules = make(map[netip.Addr]RuleSet)
|
||||
|
||||
if m.udpTracker != nil {
|
||||
m.udpTracker.Close()
|
||||
}
|
||||
|
||||
if m.icmpTracker != nil {
|
||||
m.icmpTracker.Close()
|
||||
}
|
||||
|
||||
if m.tcpTracker != nil {
|
||||
m.tcpTracker.Close()
|
||||
}
|
||||
|
||||
if fwder := m.forwarder.Load(); fwder != nil {
|
||||
fwder.Stop()
|
||||
}
|
||||
|
||||
if m.logger != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := m.logger.Stop(ctx); err != nil {
|
||||
log.Errorf("failed to shutdown logger: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if m.nativeFirewall != nil {
|
||||
return m.nativeFirewall.Close(stateManager)
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package uspfilter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
@@ -23,7 +26,33 @@ func (m *Manager) Close(*statemanager.Manager) error {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
m.resetState()
|
||||
m.outgoingRules = make(map[netip.Addr]RuleSet)
|
||||
m.incomingDenyRules = make(map[netip.Addr]RuleSet)
|
||||
m.incomingRules = make(map[netip.Addr]RuleSet)
|
||||
|
||||
if m.udpTracker != nil {
|
||||
m.udpTracker.Close()
|
||||
}
|
||||
|
||||
if m.icmpTracker != nil {
|
||||
m.icmpTracker.Close()
|
||||
}
|
||||
|
||||
if m.tcpTracker != nil {
|
||||
m.tcpTracker.Close()
|
||||
}
|
||||
|
||||
if fwder := m.forwarder.Load(); fwder != nil {
|
||||
fwder.Stop()
|
||||
}
|
||||
|
||||
if m.logger != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := m.logger.Stop(ctx); err != nil {
|
||||
log.Errorf("failed to shutdown logger: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !isWindowsFirewallReachable() {
|
||||
return nil
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package uspfilter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -13,13 +12,11 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/google/gopacket"
|
||||
"github.com/google/gopacket/layers"
|
||||
"github.com/google/uuid"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/exp/maps"
|
||||
|
||||
firewall "github.com/netbirdio/netbird/client/firewall/manager"
|
||||
"github.com/netbirdio/netbird/client/firewall/uspfilter/common"
|
||||
@@ -27,7 +24,6 @@ import (
|
||||
"github.com/netbirdio/netbird/client/firewall/uspfilter/forwarder"
|
||||
nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log"
|
||||
"github.com/netbirdio/netbird/client/iface/netstack"
|
||||
nbid "github.com/netbirdio/netbird/client/internal/acl/id"
|
||||
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
|
||||
"github.com/netbirdio/netbird/client/internal/statemanager"
|
||||
)
|
||||
@@ -93,7 +89,6 @@ type Manager struct {
|
||||
incomingDenyRules map[netip.Addr]RuleSet
|
||||
incomingRules map[netip.Addr]RuleSet
|
||||
routeRules RouteRules
|
||||
routeRulesMap map[nbid.RuleID]*RouteRule
|
||||
decoders sync.Pool
|
||||
wgIface common.IFaceMapper
|
||||
nativeFirewall firewall.Manager
|
||||
@@ -234,7 +229,6 @@ func create(iface common.IFaceMapper, nativeFirewall firewall.Manager, disableSe
|
||||
flowLogger: flowLogger,
|
||||
netstack: netstack.IsEnabled(),
|
||||
localForwarding: enableLocalForwarding,
|
||||
routeRulesMap: make(map[nbid.RuleID]*RouteRule),
|
||||
dnatMappings: make(map[netip.Addr]netip.Addr),
|
||||
portDNATRules: []portDNATRule{},
|
||||
netstackServices: make(map[serviceKey]struct{}),
|
||||
@@ -486,15 +480,11 @@ func (m *Manager) addRouteFiltering(
|
||||
return m.nativeFirewall.AddRouteFiltering(id, sources, destination, proto, sPort, dPort, action)
|
||||
}
|
||||
|
||||
ruleKey := nbid.GenerateRouteRuleKey(sources, destination, proto, sPort, dPort, action)
|
||||
|
||||
if existingRule, ok := m.routeRulesMap[ruleKey]; ok {
|
||||
return existingRule, nil
|
||||
}
|
||||
ruleID := uuid.New().String()
|
||||
|
||||
rule := RouteRule{
|
||||
// TODO: consolidate these IDs
|
||||
id: string(ruleKey),
|
||||
id: ruleID,
|
||||
mgmtId: id,
|
||||
sources: sources,
|
||||
dstSet: destination.Set,
|
||||
@@ -509,7 +499,6 @@ func (m *Manager) addRouteFiltering(
|
||||
|
||||
m.routeRules = append(m.routeRules, &rule)
|
||||
m.routeRules.Sort()
|
||||
m.routeRulesMap[ruleKey] = &rule
|
||||
|
||||
return &rule, nil
|
||||
}
|
||||
@@ -526,20 +515,15 @@ func (m *Manager) deleteRouteRule(rule firewall.Rule) error {
|
||||
return m.nativeFirewall.DeleteRouteRule(rule)
|
||||
}
|
||||
|
||||
ruleKey := nbid.RuleID(rule.ID())
|
||||
if _, ok := m.routeRulesMap[ruleKey]; !ok {
|
||||
return fmt.Errorf("route rule not found: %s", ruleKey)
|
||||
}
|
||||
|
||||
ruleID := rule.ID()
|
||||
idx := slices.IndexFunc(m.routeRules, func(r *RouteRule) bool {
|
||||
return r.id == string(ruleKey)
|
||||
return r.id == ruleID
|
||||
})
|
||||
if idx < 0 {
|
||||
return fmt.Errorf("route rule not found in slice: %s", ruleKey)
|
||||
return fmt.Errorf("route rule not found: %s", ruleID)
|
||||
}
|
||||
|
||||
m.routeRules = slices.Delete(m.routeRules, idx, idx+1)
|
||||
delete(m.routeRulesMap, ruleKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -586,40 +570,6 @@ func (m *Manager) SetLegacyManagement(isLegacy bool) error {
|
||||
// Flush doesn't need to be implemented for this manager
|
||||
func (m *Manager) Flush() error { return nil }
|
||||
|
||||
// resetState clears all firewall rules and closes connection trackers.
|
||||
// Must be called with m.mutex held.
|
||||
func (m *Manager) resetState() {
|
||||
maps.Clear(m.outgoingRules)
|
||||
maps.Clear(m.incomingDenyRules)
|
||||
maps.Clear(m.incomingRules)
|
||||
maps.Clear(m.routeRulesMap)
|
||||
m.routeRules = m.routeRules[:0]
|
||||
|
||||
if m.udpTracker != nil {
|
||||
m.udpTracker.Close()
|
||||
}
|
||||
|
||||
if m.icmpTracker != nil {
|
||||
m.icmpTracker.Close()
|
||||
}
|
||||
|
||||
if m.tcpTracker != nil {
|
||||
m.tcpTracker.Close()
|
||||
}
|
||||
|
||||
if fwder := m.forwarder.Load(); fwder != nil {
|
||||
fwder.Stop()
|
||||
}
|
||||
|
||||
if m.logger != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := m.logger.Stop(ctx); err != nil {
|
||||
log.Errorf("failed to shutdown logger: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetupEBPFProxyNoTrack creates notrack rules for eBPF proxy loopback traffic.
|
||||
func (m *Manager) SetupEBPFProxyNoTrack(proxyPort, wgPort uint16) error {
|
||||
if m.nativeFirewall == nil {
|
||||
|
||||
@@ -1,376 +0,0 @@
|
||||
package uspfilter
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/google/gopacket/layers"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
wgdevice "golang.zx2c4.com/wireguard/device"
|
||||
|
||||
fw "github.com/netbirdio/netbird/client/firewall/manager"
|
||||
"github.com/netbirdio/netbird/client/iface"
|
||||
"github.com/netbirdio/netbird/client/iface/device"
|
||||
"github.com/netbirdio/netbird/client/iface/mocks"
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
)
|
||||
|
||||
// TestAddRouteFilteringReturnsExistingRule verifies that adding the same route
|
||||
// filtering rule twice returns the same rule ID (idempotent behavior).
|
||||
func TestAddRouteFilteringReturnsExistingRule(t *testing.T) {
|
||||
manager := setupTestManager(t)
|
||||
|
||||
sources := []netip.Prefix{
|
||||
netip.MustParsePrefix("100.64.1.0/24"),
|
||||
netip.MustParsePrefix("100.64.2.0/24"),
|
||||
}
|
||||
destination := fw.Network{Prefix: netip.MustParsePrefix("192.168.1.0/24")}
|
||||
|
||||
// Add rule first time
|
||||
rule1, err := manager.AddRouteFiltering(
|
||||
[]byte("policy-1"),
|
||||
sources,
|
||||
destination,
|
||||
fw.ProtocolTCP,
|
||||
nil,
|
||||
&fw.Port{Values: []uint16{443}},
|
||||
fw.ActionAccept,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, rule1)
|
||||
|
||||
// Add the same rule again
|
||||
rule2, err := manager.AddRouteFiltering(
|
||||
[]byte("policy-1"),
|
||||
sources,
|
||||
destination,
|
||||
fw.ProtocolTCP,
|
||||
nil,
|
||||
&fw.Port{Values: []uint16{443}},
|
||||
fw.ActionAccept,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, rule2)
|
||||
|
||||
// These should be the same (idempotent) like nftables/iptables implementations
|
||||
assert.Equal(t, rule1.ID(), rule2.ID(),
|
||||
"Adding the same rule twice should return the same rule ID (idempotent)")
|
||||
|
||||
manager.mutex.RLock()
|
||||
ruleCount := len(manager.routeRules)
|
||||
manager.mutex.RUnlock()
|
||||
|
||||
assert.Equal(t, 2, ruleCount,
|
||||
"Should have exactly 2 rules (1 user rule + 1 block rule)")
|
||||
}
|
||||
|
||||
// TestAddRouteFilteringDifferentRulesGetDifferentIDs verifies that rules with
|
||||
// different parameters get distinct IDs.
|
||||
func TestAddRouteFilteringDifferentRulesGetDifferentIDs(t *testing.T) {
|
||||
manager := setupTestManager(t)
|
||||
|
||||
sources := []netip.Prefix{netip.MustParsePrefix("100.64.1.0/24")}
|
||||
|
||||
// Add first rule
|
||||
rule1, err := manager.AddRouteFiltering(
|
||||
[]byte("policy-1"),
|
||||
sources,
|
||||
fw.Network{Prefix: netip.MustParsePrefix("192.168.1.0/24")},
|
||||
fw.ProtocolTCP,
|
||||
nil,
|
||||
&fw.Port{Values: []uint16{443}},
|
||||
fw.ActionAccept,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add different rule (different destination)
|
||||
rule2, err := manager.AddRouteFiltering(
|
||||
[]byte("policy-2"),
|
||||
sources,
|
||||
fw.Network{Prefix: netip.MustParsePrefix("192.168.2.0/24")}, // Different!
|
||||
fw.ProtocolTCP,
|
||||
nil,
|
||||
&fw.Port{Values: []uint16{443}},
|
||||
fw.ActionAccept,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEqual(t, rule1.ID(), rule2.ID(),
|
||||
"Different rules should have different IDs")
|
||||
|
||||
manager.mutex.RLock()
|
||||
ruleCount := len(manager.routeRules)
|
||||
manager.mutex.RUnlock()
|
||||
|
||||
assert.Equal(t, 3, ruleCount, "Should have 3 rules (2 user rules + 1 block rule)")
|
||||
}
|
||||
|
||||
// TestRouteRuleUpdateDoesNotCauseGap verifies that re-adding the same route
|
||||
// rule during a network map update does not disrupt existing traffic.
|
||||
func TestRouteRuleUpdateDoesNotCauseGap(t *testing.T) {
|
||||
manager := setupTestManager(t)
|
||||
|
||||
sources := []netip.Prefix{netip.MustParsePrefix("100.64.1.0/24")}
|
||||
destination := fw.Network{Prefix: netip.MustParsePrefix("192.168.1.0/24")}
|
||||
|
||||
rule1, err := manager.AddRouteFiltering(
|
||||
[]byte("policy-1"),
|
||||
sources,
|
||||
destination,
|
||||
fw.ProtocolTCP,
|
||||
nil,
|
||||
nil,
|
||||
fw.ActionAccept,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
srcIP := netip.MustParseAddr("100.64.1.5")
|
||||
dstIP := netip.MustParseAddr("192.168.1.10")
|
||||
_, pass := manager.routeACLsPass(srcIP, dstIP, layers.LayerTypeTCP, 12345, 443)
|
||||
require.True(t, pass, "Traffic should pass with rule in place")
|
||||
|
||||
// Re-add same rule (simulates network map update)
|
||||
rule2, err := manager.AddRouteFiltering(
|
||||
[]byte("policy-1"),
|
||||
sources,
|
||||
destination,
|
||||
fw.ProtocolTCP,
|
||||
nil,
|
||||
nil,
|
||||
fw.ActionAccept,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Idempotent IDs mean rule1.ID() == rule2.ID(), so the ACL manager
|
||||
// won't delete rule1 during cleanup. If IDs differed, deleting rule1
|
||||
// would remove the only matching rule and cause a traffic gap.
|
||||
if rule1.ID() != rule2.ID() {
|
||||
err = manager.DeleteRouteRule(rule1)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
_, passAfter := manager.routeACLsPass(srcIP, dstIP, layers.LayerTypeTCP, 12345, 443)
|
||||
assert.True(t, passAfter,
|
||||
"Traffic should still pass after rule update - no gap should occur")
|
||||
}
|
||||
|
||||
// TestBlockInvalidRoutedIdempotent verifies that blockInvalidRouted creates
|
||||
// exactly one drop rule for the WireGuard network prefix, and calling it again
|
||||
// returns the same rule without duplicating.
|
||||
func TestBlockInvalidRoutedIdempotent(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
dev := mocks.NewMockDevice(ctrl)
|
||||
dev.EXPECT().MTU().Return(1500, nil).AnyTimes()
|
||||
|
||||
wgNet := netip.MustParsePrefix("100.64.0.1/16")
|
||||
|
||||
ifaceMock := &IFaceMock{
|
||||
SetFilterFunc: func(device.PacketFilter) error { return nil },
|
||||
AddressFunc: func() wgaddr.Address {
|
||||
return wgaddr.Address{
|
||||
IP: wgNet.Addr(),
|
||||
Network: wgNet,
|
||||
}
|
||||
},
|
||||
GetDeviceFunc: func() *device.FilteredDevice {
|
||||
return &device.FilteredDevice{Device: dev}
|
||||
},
|
||||
GetWGDeviceFunc: func() *wgdevice.Device {
|
||||
return &wgdevice.Device{}
|
||||
},
|
||||
}
|
||||
|
||||
manager, err := Create(ifaceMock, false, flowLogger, iface.DefaultMTU)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, manager.Close(nil))
|
||||
})
|
||||
|
||||
// Call blockInvalidRouted directly multiple times
|
||||
rule1, err := manager.blockInvalidRouted(ifaceMock)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, rule1)
|
||||
|
||||
rule2, err := manager.blockInvalidRouted(ifaceMock)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, rule2)
|
||||
|
||||
rule3, err := manager.blockInvalidRouted(ifaceMock)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, rule3)
|
||||
|
||||
// All should return the same rule
|
||||
assert.Equal(t, rule1.ID(), rule2.ID(), "Second call should return same rule")
|
||||
assert.Equal(t, rule2.ID(), rule3.ID(), "Third call should return same rule")
|
||||
|
||||
// Should have exactly 1 route rule
|
||||
manager.mutex.RLock()
|
||||
ruleCount := len(manager.routeRules)
|
||||
manager.mutex.RUnlock()
|
||||
|
||||
assert.Equal(t, 1, ruleCount, "Should have exactly 1 block rule after 3 calls")
|
||||
|
||||
// Verify the rule blocks traffic to the WG network
|
||||
srcIP := netip.MustParseAddr("10.0.0.1")
|
||||
dstIP := netip.MustParseAddr("100.64.0.50")
|
||||
_, pass := manager.routeACLsPass(srcIP, dstIP, layers.LayerTypeTCP, 12345, 80)
|
||||
assert.False(t, pass, "Block rule should deny traffic to WG prefix")
|
||||
}
|
||||
|
||||
// TestBlockRuleNotAccumulatedOnRepeatedEnableRouting verifies that calling
|
||||
// EnableRouting multiple times (as happens on each route update) does not
|
||||
// accumulate duplicate block rules in the routeRules slice.
|
||||
func TestBlockRuleNotAccumulatedOnRepeatedEnableRouting(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
dev := mocks.NewMockDevice(ctrl)
|
||||
dev.EXPECT().MTU().Return(1500, nil).AnyTimes()
|
||||
|
||||
wgNet := netip.MustParsePrefix("100.64.0.1/16")
|
||||
|
||||
ifaceMock := &IFaceMock{
|
||||
SetFilterFunc: func(device.PacketFilter) error { return nil },
|
||||
AddressFunc: func() wgaddr.Address {
|
||||
return wgaddr.Address{
|
||||
IP: wgNet.Addr(),
|
||||
Network: wgNet,
|
||||
}
|
||||
},
|
||||
GetDeviceFunc: func() *device.FilteredDevice {
|
||||
return &device.FilteredDevice{Device: dev}
|
||||
},
|
||||
GetWGDeviceFunc: func() *wgdevice.Device {
|
||||
return &wgdevice.Device{}
|
||||
},
|
||||
}
|
||||
|
||||
manager, err := Create(ifaceMock, false, flowLogger, iface.DefaultMTU)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, manager.Close(nil))
|
||||
})
|
||||
|
||||
// Call EnableRouting multiple times (simulating repeated route updates)
|
||||
for i := 0; i < 5; i++ {
|
||||
require.NoError(t, manager.EnableRouting())
|
||||
}
|
||||
|
||||
manager.mutex.RLock()
|
||||
ruleCount := len(manager.routeRules)
|
||||
manager.mutex.RUnlock()
|
||||
|
||||
assert.Equal(t, 1, ruleCount,
|
||||
"Repeated EnableRouting should not accumulate block rules")
|
||||
}
|
||||
|
||||
// TestRouteRuleCountStableAcrossUpdates verifies that adding the same route
|
||||
// rule multiple times does not create duplicate entries.
|
||||
func TestRouteRuleCountStableAcrossUpdates(t *testing.T) {
|
||||
manager := setupTestManager(t)
|
||||
|
||||
sources := []netip.Prefix{netip.MustParsePrefix("100.64.1.0/24")}
|
||||
destination := fw.Network{Prefix: netip.MustParsePrefix("192.168.1.0/24")}
|
||||
|
||||
// Simulate 5 network map updates with the same route rule
|
||||
for i := 0; i < 5; i++ {
|
||||
rule, err := manager.AddRouteFiltering(
|
||||
[]byte("policy-1"),
|
||||
sources,
|
||||
destination,
|
||||
fw.ProtocolTCP,
|
||||
nil,
|
||||
&fw.Port{Values: []uint16{443}},
|
||||
fw.ActionAccept,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, rule)
|
||||
}
|
||||
|
||||
manager.mutex.RLock()
|
||||
ruleCount := len(manager.routeRules)
|
||||
manager.mutex.RUnlock()
|
||||
|
||||
assert.Equal(t, 2, ruleCount,
|
||||
"Should have exactly 2 rules (1 user rule + 1 block rule) after 5 updates")
|
||||
}
|
||||
|
||||
// TestDeleteRouteRuleAfterIdempotentAdd verifies that deleting a route rule
|
||||
// after adding it multiple times works correctly.
|
||||
func TestDeleteRouteRuleAfterIdempotentAdd(t *testing.T) {
|
||||
manager := setupTestManager(t)
|
||||
|
||||
sources := []netip.Prefix{netip.MustParsePrefix("100.64.1.0/24")}
|
||||
destination := fw.Network{Prefix: netip.MustParsePrefix("192.168.1.0/24")}
|
||||
|
||||
// Add same rule twice
|
||||
rule1, err := manager.AddRouteFiltering(
|
||||
[]byte("policy-1"),
|
||||
sources,
|
||||
destination,
|
||||
fw.ProtocolTCP,
|
||||
nil,
|
||||
nil,
|
||||
fw.ActionAccept,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
rule2, err := manager.AddRouteFiltering(
|
||||
[]byte("policy-1"),
|
||||
sources,
|
||||
destination,
|
||||
fw.ProtocolTCP,
|
||||
nil,
|
||||
nil,
|
||||
fw.ActionAccept,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, rule1.ID(), rule2.ID(), "Should return same rule ID")
|
||||
|
||||
// Delete using first reference
|
||||
err = manager.DeleteRouteRule(rule1)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify traffic no longer passes
|
||||
srcIP := netip.MustParseAddr("100.64.1.5")
|
||||
dstIP := netip.MustParseAddr("192.168.1.10")
|
||||
_, pass := manager.routeACLsPass(srcIP, dstIP, layers.LayerTypeTCP, 12345, 443)
|
||||
assert.False(t, pass, "Traffic should not pass after rule deletion")
|
||||
}
|
||||
|
||||
func setupTestManager(t *testing.T) *Manager {
|
||||
t.Helper()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
dev := mocks.NewMockDevice(ctrl)
|
||||
dev.EXPECT().MTU().Return(1500, nil).AnyTimes()
|
||||
|
||||
wgNet := netip.MustParsePrefix("100.64.0.1/16")
|
||||
|
||||
ifaceMock := &IFaceMock{
|
||||
SetFilterFunc: func(device.PacketFilter) error { return nil },
|
||||
AddressFunc: func() wgaddr.Address {
|
||||
return wgaddr.Address{
|
||||
IP: wgNet.Addr(),
|
||||
Network: wgNet,
|
||||
}
|
||||
},
|
||||
GetDeviceFunc: func() *device.FilteredDevice {
|
||||
return &device.FilteredDevice{Device: dev}
|
||||
},
|
||||
GetWGDeviceFunc: func() *wgdevice.Device {
|
||||
return &wgdevice.Device{}
|
||||
},
|
||||
}
|
||||
|
||||
manager, err := Create(ifaceMock, false, flowLogger, iface.DefaultMTU)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, manager.EnableRouting())
|
||||
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, manager.Close(nil))
|
||||
})
|
||||
|
||||
return manager
|
||||
}
|
||||
@@ -263,158 +263,6 @@ func TestAddUDPPacketHook(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPeerRuleLifecycleDenyRules verifies that deny rules are correctly added
|
||||
// to the deny map and can be cleanly deleted without leaving orphans.
|
||||
func TestPeerRuleLifecycleDenyRules(t *testing.T) {
|
||||
ifaceMock := &IFaceMock{
|
||||
SetFilterFunc: func(device.PacketFilter) error { return nil },
|
||||
}
|
||||
|
||||
m, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
require.NoError(t, m.Close(nil))
|
||||
}()
|
||||
|
||||
ip := net.ParseIP("192.168.1.1")
|
||||
addr := netip.MustParseAddr("192.168.1.1")
|
||||
|
||||
// Add multiple deny rules for different ports
|
||||
rule1, err := m.AddPeerFiltering(nil, ip, fw.ProtocolTCP, nil,
|
||||
&fw.Port{Values: []uint16{22}}, fw.ActionDrop, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
rule2, err := m.AddPeerFiltering(nil, ip, fw.ProtocolTCP, nil,
|
||||
&fw.Port{Values: []uint16{80}}, fw.ActionDrop, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
m.mutex.RLock()
|
||||
denyCount := len(m.incomingDenyRules[addr])
|
||||
m.mutex.RUnlock()
|
||||
require.Equal(t, 2, denyCount, "Should have exactly 2 deny rules")
|
||||
|
||||
// Delete the first deny rule
|
||||
err = m.DeletePeerRule(rule1[0])
|
||||
require.NoError(t, err)
|
||||
|
||||
m.mutex.RLock()
|
||||
denyCount = len(m.incomingDenyRules[addr])
|
||||
m.mutex.RUnlock()
|
||||
require.Equal(t, 1, denyCount, "Should have 1 deny rule after deleting first")
|
||||
|
||||
// Delete the second deny rule
|
||||
err = m.DeletePeerRule(rule2[0])
|
||||
require.NoError(t, err)
|
||||
|
||||
m.mutex.RLock()
|
||||
_, exists := m.incomingDenyRules[addr]
|
||||
m.mutex.RUnlock()
|
||||
require.False(t, exists, "Deny rules IP entry should be cleaned up when empty")
|
||||
}
|
||||
|
||||
// TestPeerRuleAddAndDeleteDontLeak verifies that repeatedly adding and deleting
|
||||
// peer rules (simulating network map updates) does not leak rules in the maps.
|
||||
func TestPeerRuleAddAndDeleteDontLeak(t *testing.T) {
|
||||
ifaceMock := &IFaceMock{
|
||||
SetFilterFunc: func(device.PacketFilter) error { return nil },
|
||||
}
|
||||
|
||||
m, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
require.NoError(t, m.Close(nil))
|
||||
}()
|
||||
|
||||
ip := net.ParseIP("192.168.1.1")
|
||||
addr := netip.MustParseAddr("192.168.1.1")
|
||||
|
||||
// Simulate 10 network map updates: add rule, delete old, add new
|
||||
for i := 0; i < 10; i++ {
|
||||
// Add a deny rule
|
||||
rules, err := m.AddPeerFiltering(nil, ip, fw.ProtocolTCP, nil,
|
||||
&fw.Port{Values: []uint16{22}}, fw.ActionDrop, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add an allow rule
|
||||
allowRules, err := m.AddPeerFiltering(nil, ip, fw.ProtocolTCP, nil,
|
||||
&fw.Port{Values: []uint16{80}}, fw.ActionAccept, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Delete them (simulating ACL manager cleanup)
|
||||
for _, r := range rules {
|
||||
require.NoError(t, m.DeletePeerRule(r))
|
||||
}
|
||||
for _, r := range allowRules {
|
||||
require.NoError(t, m.DeletePeerRule(r))
|
||||
}
|
||||
}
|
||||
|
||||
m.mutex.RLock()
|
||||
denyCount := len(m.incomingDenyRules[addr])
|
||||
allowCount := len(m.incomingRules[addr])
|
||||
m.mutex.RUnlock()
|
||||
|
||||
require.Equal(t, 0, denyCount, "No deny rules should remain after cleanup")
|
||||
require.Equal(t, 0, allowCount, "No allow rules should remain after cleanup")
|
||||
}
|
||||
|
||||
// TestMixedAllowDenyRulesSameIP verifies that allow and deny rules for the same
|
||||
// IP are stored in separate maps and don't interfere with each other.
|
||||
func TestMixedAllowDenyRulesSameIP(t *testing.T) {
|
||||
ifaceMock := &IFaceMock{
|
||||
SetFilterFunc: func(device.PacketFilter) error { return nil },
|
||||
}
|
||||
|
||||
m, err := Create(ifaceMock, false, flowLogger, nbiface.DefaultMTU)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
require.NoError(t, m.Close(nil))
|
||||
}()
|
||||
|
||||
ip := net.ParseIP("192.168.1.1")
|
||||
|
||||
// Add allow rule for port 80
|
||||
allowRule, err := m.AddPeerFiltering(nil, ip, fw.ProtocolTCP, nil,
|
||||
&fw.Port{Values: []uint16{80}}, fw.ActionAccept, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add deny rule for port 22
|
||||
denyRule, err := m.AddPeerFiltering(nil, ip, fw.ProtocolTCP, nil,
|
||||
&fw.Port{Values: []uint16{22}}, fw.ActionDrop, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
addr := netip.MustParseAddr("192.168.1.1")
|
||||
m.mutex.RLock()
|
||||
allowCount := len(m.incomingRules[addr])
|
||||
denyCount := len(m.incomingDenyRules[addr])
|
||||
m.mutex.RUnlock()
|
||||
|
||||
require.Equal(t, 1, allowCount, "Should have 1 allow rule")
|
||||
require.Equal(t, 1, denyCount, "Should have 1 deny rule")
|
||||
|
||||
// Delete allow rule should not affect deny rule
|
||||
err = m.DeletePeerRule(allowRule[0])
|
||||
require.NoError(t, err)
|
||||
|
||||
m.mutex.RLock()
|
||||
denyCountAfter := len(m.incomingDenyRules[addr])
|
||||
m.mutex.RUnlock()
|
||||
|
||||
require.Equal(t, 1, denyCountAfter, "Deny rule should still exist after deleting allow rule")
|
||||
|
||||
// Delete deny rule
|
||||
err = m.DeletePeerRule(denyRule[0])
|
||||
require.NoError(t, err)
|
||||
|
||||
m.mutex.RLock()
|
||||
_, denyExists := m.incomingDenyRules[addr]
|
||||
_, allowExists := m.incomingRules[addr]
|
||||
m.mutex.RUnlock()
|
||||
|
||||
require.False(t, denyExists, "Deny rules should be empty")
|
||||
require.False(t, allowExists, "Allow rules should be empty")
|
||||
}
|
||||
|
||||
func TestManagerReset(t *testing.T) {
|
||||
ifaceMock := &IFaceMock{
|
||||
SetFilterFunc: func(device.PacketFilter) error { return nil },
|
||||
|
||||
@@ -189,212 +189,6 @@ func TestDefaultManagerStateless(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestDenyRulesNotAccumulatedOnRepeatedApply verifies that applying the same
|
||||
// deny rules repeatedly does not accumulate duplicate rules in the uspfilter.
|
||||
// This tests the full ACL manager -> uspfilter integration.
|
||||
func TestDenyRulesNotAccumulatedOnRepeatedApply(t *testing.T) {
|
||||
t.Setenv("NB_WG_KERNEL_DISABLED", "true")
|
||||
|
||||
networkMap := &mgmProto.NetworkMap{
|
||||
FirewallRules: []*mgmProto.FirewallRule{
|
||||
{
|
||||
PeerIP: "10.93.0.1",
|
||||
Direction: mgmProto.RuleDirection_IN,
|
||||
Action: mgmProto.RuleAction_DROP,
|
||||
Protocol: mgmProto.RuleProtocol_TCP,
|
||||
Port: "22",
|
||||
},
|
||||
{
|
||||
PeerIP: "10.93.0.2",
|
||||
Direction: mgmProto.RuleDirection_IN,
|
||||
Action: mgmProto.RuleAction_DROP,
|
||||
Protocol: mgmProto.RuleProtocol_TCP,
|
||||
Port: "80",
|
||||
},
|
||||
{
|
||||
PeerIP: "10.93.0.3",
|
||||
Direction: mgmProto.RuleDirection_IN,
|
||||
Action: mgmProto.RuleAction_ACCEPT,
|
||||
Protocol: mgmProto.RuleProtocol_TCP,
|
||||
Port: "443",
|
||||
},
|
||||
},
|
||||
FirewallRulesIsEmpty: false,
|
||||
}
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
ifaceMock := mocks.NewMockIFaceMapper(ctrl)
|
||||
ifaceMock.EXPECT().IsUserspaceBind().Return(true).AnyTimes()
|
||||
ifaceMock.EXPECT().SetFilter(gomock.Any())
|
||||
network := netip.MustParsePrefix("172.0.0.1/32")
|
||||
ifaceMock.EXPECT().Name().Return("lo").AnyTimes()
|
||||
ifaceMock.EXPECT().Address().Return(wgaddr.Address{
|
||||
IP: network.Addr(),
|
||||
Network: network,
|
||||
}).AnyTimes()
|
||||
ifaceMock.EXPECT().GetWGDevice().Return(nil).AnyTimes()
|
||||
|
||||
fw, err := firewall.NewFirewall(ifaceMock, nil, flowLogger, false, iface.DefaultMTU)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
require.NoError(t, fw.Close(nil))
|
||||
}()
|
||||
|
||||
acl := NewDefaultManager(fw)
|
||||
|
||||
// Apply the same rules 5 times (simulating repeated network map updates)
|
||||
for i := 0; i < 5; i++ {
|
||||
acl.ApplyFiltering(networkMap, false)
|
||||
}
|
||||
|
||||
// The ACL manager should track exactly 3 rule pairs (2 deny + 1 accept inbound)
|
||||
assert.Equal(t, 3, len(acl.peerRulesPairs),
|
||||
"Should have exactly 3 rule pairs after 5 identical updates")
|
||||
}
|
||||
|
||||
// TestDenyRulesCleanedUpOnRemoval verifies that deny rules are properly cleaned
|
||||
// up when they're removed from the network map in a subsequent update.
|
||||
func TestDenyRulesCleanedUpOnRemoval(t *testing.T) {
|
||||
t.Setenv("NB_WG_KERNEL_DISABLED", "true")
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
ifaceMock := mocks.NewMockIFaceMapper(ctrl)
|
||||
ifaceMock.EXPECT().IsUserspaceBind().Return(true).AnyTimes()
|
||||
ifaceMock.EXPECT().SetFilter(gomock.Any())
|
||||
network := netip.MustParsePrefix("172.0.0.1/32")
|
||||
ifaceMock.EXPECT().Name().Return("lo").AnyTimes()
|
||||
ifaceMock.EXPECT().Address().Return(wgaddr.Address{
|
||||
IP: network.Addr(),
|
||||
Network: network,
|
||||
}).AnyTimes()
|
||||
ifaceMock.EXPECT().GetWGDevice().Return(nil).AnyTimes()
|
||||
|
||||
fw, err := firewall.NewFirewall(ifaceMock, nil, flowLogger, false, iface.DefaultMTU)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
require.NoError(t, fw.Close(nil))
|
||||
}()
|
||||
|
||||
acl := NewDefaultManager(fw)
|
||||
|
||||
// First update: add deny and accept rules
|
||||
networkMap1 := &mgmProto.NetworkMap{
|
||||
FirewallRules: []*mgmProto.FirewallRule{
|
||||
{
|
||||
PeerIP: "10.93.0.1",
|
||||
Direction: mgmProto.RuleDirection_IN,
|
||||
Action: mgmProto.RuleAction_DROP,
|
||||
Protocol: mgmProto.RuleProtocol_TCP,
|
||||
Port: "22",
|
||||
},
|
||||
{
|
||||
PeerIP: "10.93.0.2",
|
||||
Direction: mgmProto.RuleDirection_IN,
|
||||
Action: mgmProto.RuleAction_ACCEPT,
|
||||
Protocol: mgmProto.RuleProtocol_TCP,
|
||||
Port: "443",
|
||||
},
|
||||
},
|
||||
FirewallRulesIsEmpty: false,
|
||||
}
|
||||
|
||||
acl.ApplyFiltering(networkMap1, false)
|
||||
assert.Equal(t, 2, len(acl.peerRulesPairs), "Should have 2 rules after first update")
|
||||
|
||||
// Second update: remove the deny rule, keep only accept
|
||||
networkMap2 := &mgmProto.NetworkMap{
|
||||
FirewallRules: []*mgmProto.FirewallRule{
|
||||
{
|
||||
PeerIP: "10.93.0.2",
|
||||
Direction: mgmProto.RuleDirection_IN,
|
||||
Action: mgmProto.RuleAction_ACCEPT,
|
||||
Protocol: mgmProto.RuleProtocol_TCP,
|
||||
Port: "443",
|
||||
},
|
||||
},
|
||||
FirewallRulesIsEmpty: false,
|
||||
}
|
||||
|
||||
acl.ApplyFiltering(networkMap2, false)
|
||||
assert.Equal(t, 1, len(acl.peerRulesPairs),
|
||||
"Should have 1 rule after removing deny rule")
|
||||
|
||||
// Third update: remove all rules
|
||||
networkMap3 := &mgmProto.NetworkMap{
|
||||
FirewallRules: []*mgmProto.FirewallRule{},
|
||||
FirewallRulesIsEmpty: true,
|
||||
}
|
||||
|
||||
acl.ApplyFiltering(networkMap3, false)
|
||||
assert.Equal(t, 0, len(acl.peerRulesPairs),
|
||||
"Should have 0 rules after removing all rules")
|
||||
}
|
||||
|
||||
// TestRuleUpdateChangingAction verifies that when a rule's action changes from
|
||||
// accept to deny (or vice versa), the old rule is properly removed and the new
|
||||
// one added without leaking.
|
||||
func TestRuleUpdateChangingAction(t *testing.T) {
|
||||
t.Setenv("NB_WG_KERNEL_DISABLED", "true")
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
defer ctrl.Finish()
|
||||
|
||||
ifaceMock := mocks.NewMockIFaceMapper(ctrl)
|
||||
ifaceMock.EXPECT().IsUserspaceBind().Return(true).AnyTimes()
|
||||
ifaceMock.EXPECT().SetFilter(gomock.Any())
|
||||
network := netip.MustParsePrefix("172.0.0.1/32")
|
||||
ifaceMock.EXPECT().Name().Return("lo").AnyTimes()
|
||||
ifaceMock.EXPECT().Address().Return(wgaddr.Address{
|
||||
IP: network.Addr(),
|
||||
Network: network,
|
||||
}).AnyTimes()
|
||||
ifaceMock.EXPECT().GetWGDevice().Return(nil).AnyTimes()
|
||||
|
||||
fw, err := firewall.NewFirewall(ifaceMock, nil, flowLogger, false, iface.DefaultMTU)
|
||||
require.NoError(t, err)
|
||||
defer func() {
|
||||
require.NoError(t, fw.Close(nil))
|
||||
}()
|
||||
|
||||
acl := NewDefaultManager(fw)
|
||||
|
||||
// First update: accept rule
|
||||
networkMap := &mgmProto.NetworkMap{
|
||||
FirewallRules: []*mgmProto.FirewallRule{
|
||||
{
|
||||
PeerIP: "10.93.0.1",
|
||||
Direction: mgmProto.RuleDirection_IN,
|
||||
Action: mgmProto.RuleAction_ACCEPT,
|
||||
Protocol: mgmProto.RuleProtocol_TCP,
|
||||
Port: "22",
|
||||
},
|
||||
},
|
||||
FirewallRulesIsEmpty: false,
|
||||
}
|
||||
acl.ApplyFiltering(networkMap, false)
|
||||
assert.Equal(t, 1, len(acl.peerRulesPairs))
|
||||
|
||||
// Second update: change to deny (same IP/port/proto, different action)
|
||||
networkMap.FirewallRules = []*mgmProto.FirewallRule{
|
||||
{
|
||||
PeerIP: "10.93.0.1",
|
||||
Direction: mgmProto.RuleDirection_IN,
|
||||
Action: mgmProto.RuleAction_DROP,
|
||||
Protocol: mgmProto.RuleProtocol_TCP,
|
||||
Port: "22",
|
||||
},
|
||||
}
|
||||
acl.ApplyFiltering(networkMap, false)
|
||||
|
||||
// Should still have exactly 1 rule (the old accept removed, new deny added)
|
||||
assert.Equal(t, 1, len(acl.peerRulesPairs),
|
||||
"Changing action should result in exactly 1 rule, not 2")
|
||||
}
|
||||
|
||||
func TestPortInfoEmpty(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -219,11 +219,6 @@ const (
|
||||
darwinStdoutLogPath = "/var/log/netbird.err.log"
|
||||
)
|
||||
|
||||
// MetricsExporter is an interface for exporting metrics
|
||||
type MetricsExporter interface {
|
||||
Export(w io.Writer) error
|
||||
}
|
||||
|
||||
type BundleGenerator struct {
|
||||
anonymizer *anonymize.Anonymizer
|
||||
|
||||
@@ -234,7 +229,6 @@ type BundleGenerator struct {
|
||||
logPath string
|
||||
cpuProfile []byte
|
||||
refreshStatus func() // Optional callback to refresh status before bundle generation
|
||||
clientMetrics MetricsExporter
|
||||
|
||||
anonymize bool
|
||||
includeSystemInfo bool
|
||||
@@ -256,7 +250,6 @@ type GeneratorDependencies struct {
|
||||
LogPath string
|
||||
CPUProfile []byte
|
||||
RefreshStatus func() // Optional callback to refresh status before bundle generation
|
||||
ClientMetrics MetricsExporter
|
||||
}
|
||||
|
||||
func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGenerator {
|
||||
@@ -275,7 +268,6 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
|
||||
logPath: deps.LogPath,
|
||||
cpuProfile: deps.CPUProfile,
|
||||
refreshStatus: deps.RefreshStatus,
|
||||
clientMetrics: deps.ClientMetrics,
|
||||
|
||||
anonymize: cfg.Anonymize,
|
||||
includeSystemInfo: cfg.IncludeSystemInfo,
|
||||
@@ -359,10 +351,6 @@ func (g *BundleGenerator) createArchive() error {
|
||||
log.Errorf("failed to add corrupted state files to debug bundle: %v", err)
|
||||
}
|
||||
|
||||
if err := g.addMetrics(); err != nil {
|
||||
log.Errorf("failed to add metrics to debug bundle: %v", err)
|
||||
}
|
||||
|
||||
if err := g.addWgShow(); err != nil {
|
||||
log.Errorf("failed to add wg show output: %v", err)
|
||||
}
|
||||
@@ -756,25 +744,6 @@ func (g *BundleGenerator) addCorruptedStateFiles() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *BundleGenerator) addMetrics() error {
|
||||
if g.clientMetrics == nil {
|
||||
log.Debugf("skipping metrics in debug bundle: no metrics collector")
|
||||
return nil
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := g.clientMetrics.Export(&buf); err != nil {
|
||||
return fmt.Errorf("export metrics: %w", err)
|
||||
}
|
||||
|
||||
if err := g.addFileToZip(&buf, "metrics.txt"); err != nil {
|
||||
return fmt.Errorf("add metrics file to zip: %w", err)
|
||||
}
|
||||
|
||||
log.Debugf("added metrics to debug bundle")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *BundleGenerator) addLogfile() error {
|
||||
if g.logPath == "" {
|
||||
log.Debugf("skipping empty log file in debug bundle")
|
||||
|
||||
@@ -6,9 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -29,8 +27,6 @@ import (
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
)
|
||||
|
||||
const envSkipDNSProbe = "NB_SKIP_DNS_PROBE"
|
||||
|
||||
// ReadyListener is a notification mechanism what indicate the server is ready to handle host dns address changes
|
||||
type ReadyListener interface {
|
||||
OnReady()
|
||||
@@ -443,17 +439,6 @@ func (s *DefaultServer) SearchDomains() []string {
|
||||
// ProbeAvailability tests each upstream group's servers for availability
|
||||
// and deactivates the group if no server responds
|
||||
func (s *DefaultServer) ProbeAvailability() {
|
||||
if val := os.Getenv(envSkipDNSProbe); val != "" {
|
||||
skipProbe, err := strconv.ParseBool(val)
|
||||
if err != nil {
|
||||
log.Warnf("failed to parse %s: %v", envSkipDNSProbe, err)
|
||||
}
|
||||
if skipProbe {
|
||||
log.Infof("skipping DNS probe due to %s", envSkipDNSProbe)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for _, mux := range s.dnsMuxMap {
|
||||
wg.Add(1)
|
||||
|
||||
@@ -190,75 +190,50 @@ func (f *DNSForwarder) Close(ctx context.Context) error {
|
||||
return nberrors.FormatErrorOrNil(result)
|
||||
}
|
||||
|
||||
func (f *DNSForwarder) handleDNSQuery(logger *log.Entry, w dns.ResponseWriter, query *dns.Msg, startTime time.Time) {
|
||||
func (f *DNSForwarder) handleDNSQuery(logger *log.Entry, w dns.ResponseWriter, query *dns.Msg) *dns.Msg {
|
||||
if len(query.Question) == 0 {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
question := query.Question[0]
|
||||
qname := strings.ToLower(question.Name)
|
||||
logger.Tracef("received DNS request for DNS forwarder: domain=%s type=%s class=%s",
|
||||
question.Name, dns.TypeToString[question.Qtype], dns.ClassToString[question.Qclass])
|
||||
|
||||
logger.Tracef("question: domain=%s type=%s class=%s",
|
||||
qname, dns.TypeToString[question.Qtype], dns.ClassToString[question.Qclass])
|
||||
domain := strings.ToLower(question.Name)
|
||||
|
||||
resp := query.SetReply(query)
|
||||
network := resutil.NetworkForQtype(question.Qtype)
|
||||
if network == "" {
|
||||
resp.Rcode = dns.RcodeNotImplemented
|
||||
f.writeResponse(logger, w, resp, qname, startTime)
|
||||
return
|
||||
if err := w.WriteMsg(resp); err != nil {
|
||||
logger.Errorf("failed to write DNS response: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
mostSpecificResId, matchingEntries := f.getMatchingEntries(strings.TrimSuffix(qname, "."))
|
||||
mostSpecificResId, matchingEntries := f.getMatchingEntries(strings.TrimSuffix(domain, "."))
|
||||
// query doesn't match any configured domain
|
||||
if mostSpecificResId == "" {
|
||||
resp.Rcode = dns.RcodeRefused
|
||||
f.writeResponse(logger, w, resp, qname, startTime)
|
||||
return
|
||||
if err := w.WriteMsg(resp); err != nil {
|
||||
logger.Errorf("failed to write DNS response: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), upstreamTimeout)
|
||||
defer cancel()
|
||||
|
||||
result := resutil.LookupIP(ctx, f.resolver, network, qname, question.Qtype)
|
||||
result := resutil.LookupIP(ctx, f.resolver, network, domain, question.Qtype)
|
||||
if result.Err != nil {
|
||||
f.handleDNSError(ctx, logger, w, question, resp, qname, result, startTime)
|
||||
return
|
||||
f.handleDNSError(ctx, logger, w, question, resp, domain, result)
|
||||
return nil
|
||||
}
|
||||
|
||||
f.updateInternalState(result.IPs, mostSpecificResId, matchingEntries)
|
||||
resp.Answer = append(resp.Answer, resutil.IPsToRRs(qname, result.IPs, f.ttl)...)
|
||||
f.cache.set(qname, question.Qtype, result.IPs)
|
||||
resp.Answer = append(resp.Answer, resutil.IPsToRRs(domain, result.IPs, f.ttl)...)
|
||||
f.cache.set(domain, question.Qtype, result.IPs)
|
||||
|
||||
f.writeResponse(logger, w, resp, qname, startTime)
|
||||
}
|
||||
|
||||
func (f *DNSForwarder) writeResponse(logger *log.Entry, w dns.ResponseWriter, resp *dns.Msg, qname string, startTime time.Time) {
|
||||
if err := w.WriteMsg(resp); err != nil {
|
||||
logger.Errorf("failed to write DNS response: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Tracef("response: domain=%s rcode=%s answers=%s took=%s",
|
||||
qname, dns.RcodeToString[resp.Rcode], resutil.FormatAnswers(resp.Answer), time.Since(startTime))
|
||||
}
|
||||
|
||||
// udpResponseWriter wraps a dns.ResponseWriter to handle UDP-specific truncation.
|
||||
type udpResponseWriter struct {
|
||||
dns.ResponseWriter
|
||||
query *dns.Msg
|
||||
}
|
||||
|
||||
func (u *udpResponseWriter) WriteMsg(resp *dns.Msg) error {
|
||||
opt := u.query.IsEdns0()
|
||||
maxSize := dns.MinMsgSize
|
||||
if opt != nil {
|
||||
maxSize = int(opt.UDPSize())
|
||||
}
|
||||
|
||||
if resp.Len() > maxSize {
|
||||
resp.Truncate(maxSize)
|
||||
}
|
||||
|
||||
return u.ResponseWriter.WriteMsg(resp)
|
||||
return resp
|
||||
}
|
||||
|
||||
func (f *DNSForwarder) handleDNSQueryUDP(w dns.ResponseWriter, query *dns.Msg) {
|
||||
@@ -268,7 +243,30 @@ func (f *DNSForwarder) handleDNSQueryUDP(w dns.ResponseWriter, query *dns.Msg) {
|
||||
"dns_id": fmt.Sprintf("%04x", query.Id),
|
||||
})
|
||||
|
||||
f.handleDNSQuery(logger, &udpResponseWriter{ResponseWriter: w, query: query}, query, startTime)
|
||||
resp := f.handleDNSQuery(logger, w, query)
|
||||
if resp == nil {
|
||||
return
|
||||
}
|
||||
|
||||
opt := query.IsEdns0()
|
||||
maxSize := dns.MinMsgSize
|
||||
if opt != nil {
|
||||
// client advertised a larger EDNS0 buffer
|
||||
maxSize = int(opt.UDPSize())
|
||||
}
|
||||
|
||||
// if our response is too big, truncate and set the TC bit
|
||||
if resp.Len() > maxSize {
|
||||
resp.Truncate(maxSize)
|
||||
}
|
||||
|
||||
if err := w.WriteMsg(resp); err != nil {
|
||||
logger.Errorf("failed to write DNS response: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Tracef("response: domain=%s rcode=%s answers=%s took=%s",
|
||||
query.Question[0].Name, dns.RcodeToString[resp.Rcode], resutil.FormatAnswers(resp.Answer), time.Since(startTime))
|
||||
}
|
||||
|
||||
func (f *DNSForwarder) handleDNSQueryTCP(w dns.ResponseWriter, query *dns.Msg) {
|
||||
@@ -278,7 +276,18 @@ func (f *DNSForwarder) handleDNSQueryTCP(w dns.ResponseWriter, query *dns.Msg) {
|
||||
"dns_id": fmt.Sprintf("%04x", query.Id),
|
||||
})
|
||||
|
||||
f.handleDNSQuery(logger, w, query, startTime)
|
||||
resp := f.handleDNSQuery(logger, w, query)
|
||||
if resp == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := w.WriteMsg(resp); err != nil {
|
||||
logger.Errorf("failed to write DNS response: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
logger.Tracef("response: domain=%s rcode=%s answers=%s took=%s",
|
||||
query.Question[0].Name, dns.RcodeToString[resp.Rcode], resutil.FormatAnswers(resp.Answer), time.Since(startTime))
|
||||
}
|
||||
|
||||
func (f *DNSForwarder) updateInternalState(ips []netip.Addr, mostSpecificResId route.ResID, matchingEntries []*ForwarderEntry) {
|
||||
@@ -325,7 +334,6 @@ func (f *DNSForwarder) handleDNSError(
|
||||
resp *dns.Msg,
|
||||
domain string,
|
||||
result resutil.LookupResult,
|
||||
startTime time.Time,
|
||||
) {
|
||||
qType := question.Qtype
|
||||
qTypeName := dns.TypeToString[qType]
|
||||
@@ -335,7 +343,9 @@ func (f *DNSForwarder) handleDNSError(
|
||||
// NotFound: cache negative result and respond
|
||||
if result.Rcode == dns.RcodeNameError || result.Rcode == dns.RcodeSuccess {
|
||||
f.cache.set(domain, question.Qtype, nil)
|
||||
f.writeResponse(logger, w, resp, domain, startTime)
|
||||
if writeErr := w.WriteMsg(resp); writeErr != nil {
|
||||
logger.Errorf("failed to write failure DNS response: %v", writeErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -345,7 +355,9 @@ func (f *DNSForwarder) handleDNSError(
|
||||
logger.Debugf("serving cached DNS response after upstream failure: domain=%s type=%s", domain, qTypeName)
|
||||
resp.Answer = append(resp.Answer, resutil.IPsToRRs(domain, ips, f.ttl)...)
|
||||
resp.Rcode = dns.RcodeSuccess
|
||||
f.writeResponse(logger, w, resp, domain, startTime)
|
||||
if writeErr := w.WriteMsg(resp); writeErr != nil {
|
||||
logger.Errorf("failed to write cached DNS response: %v", writeErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -353,7 +365,9 @@ func (f *DNSForwarder) handleDNSError(
|
||||
verifyResult := resutil.LookupIP(ctx, f.resolver, resutil.NetworkForQtype(qType), domain, qType)
|
||||
if verifyResult.Rcode == dns.RcodeNameError || verifyResult.Rcode == dns.RcodeSuccess {
|
||||
resp.Rcode = verifyResult.Rcode
|
||||
f.writeResponse(logger, w, resp, domain, startTime)
|
||||
if writeErr := w.WriteMsg(resp); writeErr != nil {
|
||||
logger.Errorf("failed to write failure DNS response: %v", writeErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -361,12 +375,15 @@ func (f *DNSForwarder) handleDNSError(
|
||||
// No cache or verification failed. Log with or without the server field for more context.
|
||||
var dnsErr *net.DNSError
|
||||
if errors.As(result.Err, &dnsErr) && dnsErr.Server != "" {
|
||||
logger.Warnf("upstream failure: type=%s domain=%s server=%s: %v", qTypeName, domain, dnsErr.Server, result.Err)
|
||||
logger.Warnf("failed to resolve: type=%s domain=%s server=%s: %v", qTypeName, domain, dnsErr.Server, result.Err)
|
||||
} else {
|
||||
logger.Warnf(errResolveFailed, domain, result.Err)
|
||||
}
|
||||
|
||||
f.writeResponse(logger, w, resp, domain, startTime)
|
||||
// Write final failure response.
|
||||
if writeErr := w.WriteMsg(resp); writeErr != nil {
|
||||
logger.Errorf("failed to write failure DNS response: %v", writeErr)
|
||||
}
|
||||
}
|
||||
|
||||
// getMatchingEntries retrieves the resource IDs for a given domain.
|
||||
|
||||
@@ -318,9 +318,8 @@ func TestDNSForwarder_UnauthorizedDomainAccess(t *testing.T) {
|
||||
query.SetQuestion(dns.Fqdn(tt.queryDomain), dns.TypeA)
|
||||
|
||||
mockWriter := &test.MockResponseWriter{}
|
||||
forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query, time.Now())
|
||||
resp := forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query)
|
||||
|
||||
resp := mockWriter.GetLastResponse()
|
||||
if tt.shouldResolve {
|
||||
require.NotNil(t, resp, "Expected response for authorized domain")
|
||||
require.Equal(t, dns.RcodeSuccess, resp.Rcode, "Expected successful response")
|
||||
@@ -330,9 +329,10 @@ func TestDNSForwarder_UnauthorizedDomainAccess(t *testing.T) {
|
||||
mockFirewall.AssertExpectations(t)
|
||||
mockResolver.AssertExpectations(t)
|
||||
} else {
|
||||
require.NotNil(t, resp, "Expected response")
|
||||
assert.True(t, len(resp.Answer) == 0 || resp.Rcode != dns.RcodeSuccess,
|
||||
"Unauthorized domain should not return successful answers")
|
||||
if resp != nil {
|
||||
assert.True(t, len(resp.Answer) == 0 || resp.Rcode != dns.RcodeSuccess,
|
||||
"Unauthorized domain should not return successful answers")
|
||||
}
|
||||
mockFirewall.AssertNotCalled(t, "UpdateSet")
|
||||
mockResolver.AssertNotCalled(t, "LookupNetIP")
|
||||
}
|
||||
@@ -466,16 +466,14 @@ func TestDNSForwarder_FirewallSetUpdates(t *testing.T) {
|
||||
dnsQuery.SetQuestion(dns.Fqdn(tt.query), dns.TypeA)
|
||||
|
||||
mockWriter := &test.MockResponseWriter{}
|
||||
forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, dnsQuery, time.Now())
|
||||
resp := forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, dnsQuery)
|
||||
|
||||
// Verify response
|
||||
resp := mockWriter.GetLastResponse()
|
||||
if tt.shouldResolve {
|
||||
require.NotNil(t, resp, "Expected response for authorized domain")
|
||||
require.Equal(t, dns.RcodeSuccess, resp.Rcode)
|
||||
require.NotEmpty(t, resp.Answer)
|
||||
} else {
|
||||
require.NotNil(t, resp, "Expected response")
|
||||
} else if resp != nil {
|
||||
assert.True(t, resp.Rcode == dns.RcodeRefused || len(resp.Answer) == 0,
|
||||
"Unauthorized domain should be refused or have no answers")
|
||||
}
|
||||
@@ -530,10 +528,9 @@ func TestDNSForwarder_MultipleIPsInSingleUpdate(t *testing.T) {
|
||||
query.SetQuestion("example.com.", dns.TypeA)
|
||||
|
||||
mockWriter := &test.MockResponseWriter{}
|
||||
forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query, time.Now())
|
||||
resp := forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query)
|
||||
|
||||
// Verify response contains all IPs
|
||||
resp := mockWriter.GetLastResponse()
|
||||
require.NotNil(t, resp)
|
||||
require.Equal(t, dns.RcodeSuccess, resp.Rcode)
|
||||
require.Len(t, resp.Answer, 3, "Should have 3 answer records")
|
||||
@@ -608,7 +605,7 @@ func TestDNSForwarder_ResponseCodes(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query, time.Now())
|
||||
_ = forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query)
|
||||
|
||||
// Check the response written to the writer
|
||||
require.NotNil(t, writtenResp, "Expected response to be written")
|
||||
@@ -678,8 +675,7 @@ func TestDNSForwarder_ServeFromCacheOnUpstreamFailure(t *testing.T) {
|
||||
q1 := &dns.Msg{}
|
||||
q1.SetQuestion(dns.Fqdn("example.com"), dns.TypeA)
|
||||
w1 := &test.MockResponseWriter{}
|
||||
forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), w1, q1, time.Now())
|
||||
resp1 := w1.GetLastResponse()
|
||||
resp1 := forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), w1, q1)
|
||||
require.NotNil(t, resp1)
|
||||
require.Equal(t, dns.RcodeSuccess, resp1.Rcode)
|
||||
require.Len(t, resp1.Answer, 1)
|
||||
@@ -687,13 +683,13 @@ func TestDNSForwarder_ServeFromCacheOnUpstreamFailure(t *testing.T) {
|
||||
// Second query: serve from cache after upstream failure
|
||||
q2 := &dns.Msg{}
|
||||
q2.SetQuestion(dns.Fqdn("example.com"), dns.TypeA)
|
||||
w2 := &test.MockResponseWriter{}
|
||||
forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), w2, q2, time.Now())
|
||||
var writtenResp *dns.Msg
|
||||
w2 := &test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { writtenResp = m; return nil }}
|
||||
_ = forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), w2, q2)
|
||||
|
||||
resp2 := w2.GetLastResponse()
|
||||
require.NotNil(t, resp2, "expected response to be written")
|
||||
require.Equal(t, dns.RcodeSuccess, resp2.Rcode)
|
||||
require.Len(t, resp2.Answer, 1)
|
||||
require.NotNil(t, writtenResp, "expected response to be written")
|
||||
require.Equal(t, dns.RcodeSuccess, writtenResp.Rcode)
|
||||
require.Len(t, writtenResp.Answer, 1)
|
||||
|
||||
mockResolver.AssertExpectations(t)
|
||||
}
|
||||
@@ -719,8 +715,7 @@ func TestDNSForwarder_CacheNormalizationCasingAndDot(t *testing.T) {
|
||||
q1 := &dns.Msg{}
|
||||
q1.SetQuestion(mixedQuery+".", dns.TypeA)
|
||||
w1 := &test.MockResponseWriter{}
|
||||
forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), w1, q1, time.Now())
|
||||
resp1 := w1.GetLastResponse()
|
||||
resp1 := forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), w1, q1)
|
||||
require.NotNil(t, resp1)
|
||||
require.Equal(t, dns.RcodeSuccess, resp1.Rcode)
|
||||
require.Len(t, resp1.Answer, 1)
|
||||
@@ -732,13 +727,13 @@ func TestDNSForwarder_CacheNormalizationCasingAndDot(t *testing.T) {
|
||||
|
||||
q2 := &dns.Msg{}
|
||||
q2.SetQuestion("EXAMPLE.COM", dns.TypeA)
|
||||
w2 := &test.MockResponseWriter{}
|
||||
forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), w2, q2, time.Now())
|
||||
var writtenResp *dns.Msg
|
||||
w2 := &test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { writtenResp = m; return nil }}
|
||||
_ = forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), w2, q2)
|
||||
|
||||
resp2 := w2.GetLastResponse()
|
||||
require.NotNil(t, resp2)
|
||||
require.Equal(t, dns.RcodeSuccess, resp2.Rcode)
|
||||
require.Len(t, resp2.Answer, 1)
|
||||
require.NotNil(t, writtenResp)
|
||||
require.Equal(t, dns.RcodeSuccess, writtenResp.Rcode)
|
||||
require.Len(t, writtenResp.Answer, 1)
|
||||
|
||||
mockResolver.AssertExpectations(t)
|
||||
}
|
||||
@@ -789,9 +784,8 @@ func TestDNSForwarder_MultipleOverlappingPatterns(t *testing.T) {
|
||||
query.SetQuestion("smtp.mail.example.com.", dns.TypeA)
|
||||
|
||||
mockWriter := &test.MockResponseWriter{}
|
||||
forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query, time.Now())
|
||||
resp := forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query)
|
||||
|
||||
resp := mockWriter.GetLastResponse()
|
||||
require.NotNil(t, resp)
|
||||
assert.Equal(t, dns.RcodeSuccess, resp.Rcode)
|
||||
|
||||
@@ -903,15 +897,26 @@ func TestDNSForwarder_NodataVsNxdomain(t *testing.T) {
|
||||
query := &dns.Msg{}
|
||||
query.SetQuestion(dns.Fqdn("example.com"), tt.queryType)
|
||||
|
||||
mockWriter := &test.MockResponseWriter{}
|
||||
forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query, time.Now())
|
||||
var writtenResp *dns.Msg
|
||||
mockWriter := &test.MockResponseWriter{
|
||||
WriteMsgFunc: func(m *dns.Msg) error {
|
||||
writtenResp = m
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
resp := mockWriter.GetLastResponse()
|
||||
require.NotNil(t, resp, "Expected response to be written")
|
||||
assert.Equal(t, tt.expectedCode, resp.Rcode, tt.description)
|
||||
resp := forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query)
|
||||
|
||||
// If a response was returned, it means it should be written (happens in wrapper functions)
|
||||
if resp != nil && writtenResp == nil {
|
||||
writtenResp = resp
|
||||
}
|
||||
|
||||
require.NotNil(t, writtenResp, "Expected response to be written")
|
||||
assert.Equal(t, tt.expectedCode, writtenResp.Rcode, tt.description)
|
||||
|
||||
if tt.expectNoAnswer {
|
||||
assert.Empty(t, resp.Answer, "Response should have no answer records")
|
||||
assert.Empty(t, writtenResp.Answer, "Response should have no answer records")
|
||||
}
|
||||
|
||||
mockResolver.AssertExpectations(t)
|
||||
@@ -926,8 +931,15 @@ func TestDNSForwarder_EmptyQuery(t *testing.T) {
|
||||
query := &dns.Msg{}
|
||||
// Don't set any question
|
||||
|
||||
mockWriter := &test.MockResponseWriter{}
|
||||
forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query, time.Now())
|
||||
writeCalled := false
|
||||
mockWriter := &test.MockResponseWriter{
|
||||
WriteMsgFunc: func(m *dns.Msg) error {
|
||||
writeCalled = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
resp := forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query)
|
||||
|
||||
assert.Nil(t, mockWriter.GetLastResponse(), "Should not write response for empty query")
|
||||
assert.Nil(t, resp, "Should return nil for empty query")
|
||||
assert.False(t, writeCalled, "Should not write response for empty query")
|
||||
}
|
||||
|
||||
@@ -36,7 +36,6 @@ import (
|
||||
dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config"
|
||||
"github.com/netbirdio/netbird/client/internal/dnsfwd"
|
||||
"github.com/netbirdio/netbird/client/internal/ingressgw"
|
||||
"github.com/netbirdio/netbird/client/internal/metrics"
|
||||
"github.com/netbirdio/netbird/client/internal/netflow"
|
||||
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
|
||||
"github.com/netbirdio/netbird/client/internal/networkmonitor"
|
||||
@@ -66,7 +65,6 @@ import (
|
||||
signal "github.com/netbirdio/netbird/shared/signal/client"
|
||||
sProto "github.com/netbirdio/netbird/shared/signal/proto"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
"github.com/netbirdio/netbird/version"
|
||||
)
|
||||
|
||||
// PeerConnectionTimeoutMax is a timeout of an initial connection attempt to a remote peer.
|
||||
@@ -223,9 +221,6 @@ type Engine struct {
|
||||
|
||||
probeStunTurn *relay.StunTurnProbe
|
||||
|
||||
// clientMetrics collects and pushes metrics
|
||||
clientMetrics *metrics.ClientMetrics
|
||||
|
||||
jobExecutor *jobexec.Executor
|
||||
jobExecutorWG sync.WaitGroup
|
||||
}
|
||||
@@ -253,12 +248,6 @@ func NewEngine(
|
||||
checks []*mgmProto.Checks,
|
||||
stateManager *statemanager.Manager,
|
||||
) *Engine {
|
||||
// Initialize metrics based on deployment type
|
||||
var deploymentType metrics.DeploymentType
|
||||
if mgmClient != nil {
|
||||
deploymentType = metrics.DetermineDeploymentType(mgmClient.GetServerURL())
|
||||
}
|
||||
|
||||
engine := &Engine{
|
||||
clientCtx: clientCtx,
|
||||
clientCancel: clientCancel,
|
||||
@@ -281,10 +270,6 @@ func NewEngine(
|
||||
jobExecutor: jobexec.NewExecutor(),
|
||||
}
|
||||
|
||||
engine.clientMetrics = metrics.NewClientMetrics(metrics.AgentInfo{
|
||||
DeploymentType: deploymentType,
|
||||
Version: version.NetbirdVersion()})
|
||||
|
||||
log.Infof("I am: %s", config.WgPrivateKey.PublicKey().String())
|
||||
return engine
|
||||
}
|
||||
@@ -843,12 +828,6 @@ func (e *Engine) handleAutoUpdateVersion(autoUpdateSettings *mgmProto.AutoUpdate
|
||||
}
|
||||
|
||||
func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
|
||||
started := time.Now()
|
||||
defer func() {
|
||||
duration := time.Since(started)
|
||||
log.Infof("sync finished in %s", duration)
|
||||
e.clientMetrics.RecordSyncDuration(e.ctx, duration)
|
||||
}()
|
||||
e.syncMsgMux.Lock()
|
||||
defer e.syncMsgMux.Unlock()
|
||||
|
||||
@@ -1094,7 +1073,6 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR
|
||||
StatusRecorder: e.statusRecorder,
|
||||
SyncResponse: syncResponse,
|
||||
LogPath: e.config.LogPath,
|
||||
ClientMetrics: e.clientMetrics,
|
||||
RefreshStatus: func() {
|
||||
e.RunHealthProbes(true)
|
||||
},
|
||||
@@ -1550,13 +1528,12 @@ func (e *Engine) createPeerConn(pubKey string, allowedIPs []netip.Prefix, agentV
|
||||
}
|
||||
|
||||
serviceDependencies := peer.ServiceDependencies{
|
||||
StatusRecorder: e.statusRecorder,
|
||||
Signaler: e.signaler,
|
||||
IFaceDiscover: e.mobileDep.IFaceDiscover,
|
||||
RelayManager: e.relayManager,
|
||||
SrWatcher: e.srWatcher,
|
||||
Semaphore: e.connSemaphore,
|
||||
MetricsRecorder: e.clientMetrics,
|
||||
StatusRecorder: e.statusRecorder,
|
||||
Signaler: e.signaler,
|
||||
IFaceDiscover: e.mobileDep.IFaceDiscover,
|
||||
RelayManager: e.relayManager,
|
||||
SrWatcher: e.srWatcher,
|
||||
Semaphore: e.connSemaphore,
|
||||
}
|
||||
peerConn, err := peer.NewConn(config, serviceDependencies)
|
||||
if err != nil {
|
||||
@@ -1842,11 +1819,6 @@ func (e *Engine) GetFirewallManager() firewallManager.Manager {
|
||||
return e.firewall
|
||||
}
|
||||
|
||||
// GetClientMetrics returns the client metrics
|
||||
func (e *Engine) GetClientMetrics() *metrics.ClientMetrics {
|
||||
return e.clientMetrics
|
||||
}
|
||||
|
||||
func findIPFromInterfaceName(ifaceName string) (net.IP, error) {
|
||||
iface, err := net.InterfaceByName(ifaceName)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
package metrics
|
||||
|
||||
// ConnectionType represents the type of peer connection
|
||||
type ConnectionType string
|
||||
|
||||
const (
|
||||
// ConnectionTypeICE represents a direct peer-to-peer connection using ICE
|
||||
ConnectionTypeICE ConnectionType = "ice"
|
||||
|
||||
// ConnectionTypeRelay represents a relayed connection
|
||||
ConnectionTypeRelay ConnectionType = "relay"
|
||||
)
|
||||
|
||||
// String returns the string representation of the connection type
|
||||
func (c ConnectionType) String() string {
|
||||
return string(c)
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DeploymentType represents the type of NetBird deployment
|
||||
type DeploymentType int
|
||||
|
||||
const (
|
||||
// DeploymentTypeUnknown represents an unknown or uninitialized deployment type
|
||||
DeploymentTypeUnknown DeploymentType = iota
|
||||
|
||||
// DeploymentTypeCloud represents a cloud-hosted NetBird deployment
|
||||
DeploymentTypeCloud
|
||||
|
||||
// DeploymentTypeSelfHosted represents a self-hosted NetBird deployment
|
||||
DeploymentTypeSelfHosted
|
||||
)
|
||||
|
||||
// String returns the string representation of the deployment type
|
||||
func (d DeploymentType) String() string {
|
||||
switch d {
|
||||
case DeploymentTypeCloud:
|
||||
return "cloud"
|
||||
case DeploymentTypeSelfHosted:
|
||||
return "selfhosted"
|
||||
default:
|
||||
return "selfhosted"
|
||||
}
|
||||
}
|
||||
|
||||
// DetermineDeploymentType determines if the deployment is cloud or self-hosted
|
||||
// based on the management URL string
|
||||
func DetermineDeploymentType(managementURL string) DeploymentType {
|
||||
if managementURL == "" {
|
||||
return DeploymentTypeUnknown
|
||||
}
|
||||
|
||||
// Check for NetBird cloud API domain
|
||||
if strings.Contains(strings.ToLower(managementURL), "api.netbird.io") {
|
||||
return DeploymentTypeCloud
|
||||
}
|
||||
|
||||
return DeploymentTypeSelfHosted
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AgentInfo holds static information about the agent
|
||||
type AgentInfo struct {
|
||||
DeploymentType DeploymentType
|
||||
Version string
|
||||
}
|
||||
|
||||
// metricsImplementation defines the internal interface for metrics implementations
|
||||
type metricsImplementation interface {
|
||||
// RecordConnectionStages records connection stage metrics from timestamps
|
||||
RecordConnectionStages(
|
||||
ctx context.Context,
|
||||
connectionType ConnectionType,
|
||||
isReconnection bool,
|
||||
timestamps ConnectionStageTimestamps,
|
||||
)
|
||||
|
||||
// RecordSyncDuration records how long it took to process a sync message
|
||||
RecordSyncDuration(ctx context.Context, duration time.Duration)
|
||||
|
||||
// Export exports metrics in Prometheus format
|
||||
Export(w io.Writer) error
|
||||
}
|
||||
|
||||
type ClientMetrics struct {
|
||||
impl metricsImplementation
|
||||
}
|
||||
|
||||
// ConnectionStageTimestamps holds timestamps for each connection stage
|
||||
type ConnectionStageTimestamps struct {
|
||||
Created time.Time
|
||||
SemaphoreAcquired time.Time
|
||||
Signaling time.Time // First signal sent (initial) or signal received (reconnection)
|
||||
ConnectionReady time.Time
|
||||
WgHandshakeSuccess time.Time
|
||||
}
|
||||
|
||||
// NewClientMetrics creates a new ClientMetrics instance
|
||||
func NewClientMetrics(agentInfo AgentInfo) *ClientMetrics {
|
||||
return &ClientMetrics{impl: newVictoriaMetrics(agentInfo)}
|
||||
}
|
||||
|
||||
// RecordConnectionStages calculates stage durations from timestamps and records them
|
||||
func (c *ClientMetrics) RecordConnectionStages(
|
||||
ctx context.Context,
|
||||
connectionType ConnectionType,
|
||||
isReconnection bool,
|
||||
timestamps ConnectionStageTimestamps,
|
||||
) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.impl.RecordConnectionStages(ctx, connectionType, isReconnection, timestamps)
|
||||
}
|
||||
|
||||
// RecordSyncDuration records the duration of sync message processing
|
||||
func (c *ClientMetrics) RecordSyncDuration(ctx context.Context, duration time.Duration) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.impl.RecordSyncDuration(ctx, duration)
|
||||
}
|
||||
|
||||
// Export exports metrics to the writer
|
||||
func (c *ClientMetrics) Export(w io.Writer) error {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
return c.impl.Export(w)
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/VictoriaMetrics/metrics"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// victoriaMetrics is the VictoriaMetrics implementation of ClientMetrics
|
||||
type victoriaMetrics struct {
|
||||
// Static agent information applied to all metrics
|
||||
agentInfo AgentInfo
|
||||
|
||||
// Metrics set for managing all metrics
|
||||
set *metrics.Set
|
||||
}
|
||||
|
||||
func newVictoriaMetrics(agentInfo AgentInfo) metricsImplementation {
|
||||
return &victoriaMetrics{
|
||||
agentInfo: agentInfo,
|
||||
set: metrics.NewSet(),
|
||||
}
|
||||
}
|
||||
|
||||
// RecordConnectionStages records the duration of each connection stage from timestamps
|
||||
func (m *victoriaMetrics) RecordConnectionStages(
|
||||
ctx context.Context,
|
||||
connectionType ConnectionType,
|
||||
isReconnection bool,
|
||||
timestamps ConnectionStageTimestamps,
|
||||
) {
|
||||
// Calculate stage durations
|
||||
var creationToSemaphore, semaphoreToSignaling, signalingToConnection, connectionToHandshake, totalDuration float64
|
||||
|
||||
if !timestamps.Created.IsZero() && !timestamps.SemaphoreAcquired.IsZero() {
|
||||
creationToSemaphore = timestamps.SemaphoreAcquired.Sub(timestamps.Created).Seconds()
|
||||
}
|
||||
|
||||
if !timestamps.SemaphoreAcquired.IsZero() && !timestamps.Signaling.IsZero() {
|
||||
semaphoreToSignaling = timestamps.Signaling.Sub(timestamps.SemaphoreAcquired).Seconds()
|
||||
}
|
||||
|
||||
if !timestamps.Signaling.IsZero() && !timestamps.ConnectionReady.IsZero() {
|
||||
signalingToConnection = timestamps.ConnectionReady.Sub(timestamps.Signaling).Seconds()
|
||||
}
|
||||
|
||||
if !timestamps.ConnectionReady.IsZero() && !timestamps.WgHandshakeSuccess.IsZero() {
|
||||
connectionToHandshake = timestamps.WgHandshakeSuccess.Sub(timestamps.ConnectionReady).Seconds()
|
||||
}
|
||||
|
||||
// Calculate total duration:
|
||||
// For initial connections: Created → WgHandshakeSuccess
|
||||
// For reconnections: Signaling → WgHandshakeSuccess (since Created is not tracked)
|
||||
if !timestamps.Created.IsZero() && !timestamps.WgHandshakeSuccess.IsZero() {
|
||||
totalDuration = timestamps.WgHandshakeSuccess.Sub(timestamps.Created).Seconds()
|
||||
} else if !timestamps.Signaling.IsZero() && !timestamps.WgHandshakeSuccess.IsZero() {
|
||||
totalDuration = timestamps.WgHandshakeSuccess.Sub(timestamps.Signaling).Seconds()
|
||||
}
|
||||
|
||||
// Determine attempt type
|
||||
attemptType := "initial"
|
||||
if isReconnection {
|
||||
attemptType = "reconnection"
|
||||
}
|
||||
|
||||
connTypeStr := connectionType.String()
|
||||
|
||||
// Record observations using histograms
|
||||
m.set.GetOrCreateHistogram(
|
||||
m.getMetricName("netbird_peer_connection_stage_creation_to_semaphore", connTypeStr, attemptType),
|
||||
).Update(creationToSemaphore)
|
||||
|
||||
m.set.GetOrCreateHistogram(
|
||||
m.getMetricName("netbird_peer_connection_stage_semaphore_to_signaling", connTypeStr, attemptType),
|
||||
).Update(semaphoreToSignaling)
|
||||
|
||||
m.set.GetOrCreateHistogram(
|
||||
m.getMetricName("netbird_peer_connection_stage_signaling_to_connection", connTypeStr, attemptType),
|
||||
).Update(signalingToConnection)
|
||||
|
||||
m.set.GetOrCreateHistogram(
|
||||
m.getMetricName("netbird_peer_connection_stage_connection_to_handshake", connTypeStr, attemptType),
|
||||
).Update(connectionToHandshake)
|
||||
|
||||
m.set.GetOrCreateHistogram(
|
||||
m.getMetricName("netbird_peer_connection_total_creation_to_handshake", connTypeStr, attemptType),
|
||||
).Update(totalDuration)
|
||||
|
||||
log.Tracef("peer connection metrics [%s, %s, %s]: creation→semaphore: %.3fs, semaphore→signaling: %.3fs, signaling→connection: %.3fs, connection→handshake: %.3fs, total: %.3fs",
|
||||
m.agentInfo.DeploymentType.String(), connTypeStr, attemptType,
|
||||
creationToSemaphore, semaphoreToSignaling, signalingToConnection, connectionToHandshake,
|
||||
totalDuration)
|
||||
}
|
||||
|
||||
// getMetricName constructs a metric name with labels
|
||||
func (m *victoriaMetrics) getMetricName(baseName, connectionType, attemptType string) string {
|
||||
return fmt.Sprintf(`%s{deployment_type=%q,connection_type=%q,attempt_type=%q,version=%q}`,
|
||||
baseName,
|
||||
m.agentInfo.DeploymentType.String(),
|
||||
connectionType,
|
||||
attemptType,
|
||||
m.agentInfo.Version,
|
||||
)
|
||||
}
|
||||
|
||||
// RecordSyncDuration records the duration of sync message processing
|
||||
func (m *victoriaMetrics) RecordSyncDuration(ctx context.Context, duration time.Duration) {
|
||||
metricName := fmt.Sprintf(`netbird_sync_duration_seconds{deployment_type=%q,version=%q}`,
|
||||
m.agentInfo.DeploymentType.String(),
|
||||
m.agentInfo.Version,
|
||||
)
|
||||
|
||||
m.set.GetOrCreateHistogram(metricName).Update(duration.Seconds())
|
||||
}
|
||||
|
||||
// Export writes metrics in Prometheus text format with HELP comments
|
||||
func (m *victoriaMetrics) Export(w io.Writer) error {
|
||||
if m.set == nil {
|
||||
return fmt.Errorf("metrics set not initialized")
|
||||
}
|
||||
|
||||
// Write metrics in Prometheus format
|
||||
m.set.WritePrometheus(w)
|
||||
return nil
|
||||
}
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface/configurer"
|
||||
"github.com/netbirdio/netbird/client/iface/wgproxy"
|
||||
"github.com/netbirdio/netbird/client/internal/metrics"
|
||||
"github.com/netbirdio/netbird/client/internal/peer/conntype"
|
||||
"github.com/netbirdio/netbird/client/internal/peer/dispatcher"
|
||||
"github.com/netbirdio/netbird/client/internal/peer/guard"
|
||||
@@ -29,16 +28,6 @@ import (
|
||||
semaphoregroup "github.com/netbirdio/netbird/util/semaphore-group"
|
||||
)
|
||||
|
||||
// MetricsRecorder is an interface for recording peer connection metrics
|
||||
type MetricsRecorder interface {
|
||||
RecordConnectionStages(
|
||||
ctx context.Context,
|
||||
connectionType metrics.ConnectionType,
|
||||
isReconnection bool,
|
||||
timestamps metrics.ConnectionStageTimestamps,
|
||||
)
|
||||
}
|
||||
|
||||
type ServiceDependencies struct {
|
||||
StatusRecorder *Status
|
||||
Signaler *Signaler
|
||||
@@ -47,7 +36,6 @@ type ServiceDependencies struct {
|
||||
SrWatcher *guard.SRWatcher
|
||||
Semaphore *semaphoregroup.SemaphoreGroup
|
||||
PeerConnDispatcher *dispatcher.ConnectionDispatcher
|
||||
MetricsRecorder MetricsRecorder
|
||||
}
|
||||
|
||||
type WgConfig struct {
|
||||
@@ -131,10 +119,6 @@ type Conn struct {
|
||||
dumpState *stateDump
|
||||
|
||||
endpointUpdater *EndpointUpdater
|
||||
|
||||
// Connection stage timestamps for metrics
|
||||
metricsRecorder MetricsRecorder
|
||||
metricsStages MetricsStages
|
||||
}
|
||||
|
||||
// NewConn creates a new not opened Conn to the remote peer.
|
||||
@@ -160,11 +144,9 @@ func NewConn(config ConnConfig, services ServiceDependencies) (*Conn, error) {
|
||||
statusICE: worker.NewAtomicStatus(),
|
||||
dumpState: dumpState,
|
||||
endpointUpdater: NewEndpointUpdater(connLog, config.WgConfig, isController(config)),
|
||||
metricsRecorder: services.MetricsRecorder,
|
||||
wgWatcher: NewWGWatcher(connLog, config.WgConfig.WgInterface, config.Key, dumpState),
|
||||
}
|
||||
|
||||
conn.wgWatcher = NewWGWatcher(connLog, config.WgConfig.WgInterface, config.Key, dumpState)
|
||||
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
@@ -172,20 +154,6 @@ func NewConn(config ConnConfig, services ServiceDependencies) (*Conn, error) {
|
||||
// It will try to establish a connection using ICE and in parallel with relay. The higher priority connection type will
|
||||
// be used.
|
||||
func (conn *Conn) Open(engineCtx context.Context) error {
|
||||
conn.mu.Lock()
|
||||
if conn.opened {
|
||||
conn.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Record the start time - beginning of connection attempt
|
||||
conn.metricsStages = MetricsStages{}
|
||||
conn.metricsStages.RecordCreated()
|
||||
|
||||
conn.mu.Unlock()
|
||||
|
||||
// Semaphore.Add() blocks here until there's a free slot
|
||||
// todo create common semaphor logic for reconnection and connections too that can remote seats from semaphor on the fly
|
||||
if err := conn.semaphore.Add(engineCtx); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -198,9 +166,6 @@ func (conn *Conn) Open(engineCtx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Record when semaphore was acquired (after the wait)
|
||||
conn.metricsStages.RecordSemaphoreAcquired()
|
||||
|
||||
conn.ctx, conn.ctxCancel = context.WithCancel(engineCtx)
|
||||
|
||||
conn.workerRelay = NewWorkerRelay(conn.ctx, conn.Log, isController(conn.config), conn.config, conn, conn.relayManager)
|
||||
@@ -213,7 +178,7 @@ func (conn *Conn) Open(engineCtx context.Context) error {
|
||||
}
|
||||
conn.workerICE = workerICE
|
||||
|
||||
conn.handshaker = NewHandshaker(conn.Log, conn.config, conn.signaler, conn.workerICE, conn.workerRelay, &conn.metricsStages)
|
||||
conn.handshaker = NewHandshaker(conn.Log, conn.config, conn.signaler, conn.workerICE, conn.workerRelay)
|
||||
|
||||
conn.handshaker.AddRelayListener(conn.workerRelay.OnNewOffer)
|
||||
if !isForceRelayed() {
|
||||
@@ -385,7 +350,7 @@ func (conn *Conn) onICEConnectionIsReady(priority conntype.ConnPriority, iceConn
|
||||
if conn.currentConnPriority > priority {
|
||||
conn.Log.Infof("current connection priority (%s) is higher than the new one (%s), do not upgrade connection", conn.currentConnPriority, priority)
|
||||
conn.statusICE.SetConnected()
|
||||
conn.updateIceState(iceConnInfo, time.Now())
|
||||
conn.updateIceState(iceConnInfo)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -425,8 +390,7 @@ func (conn *Conn) onICEConnectionIsReady(priority conntype.ConnPriority, iceConn
|
||||
}
|
||||
|
||||
conn.Log.Infof("configure WireGuard endpoint to: %s", ep.String())
|
||||
updateTime := time.Now()
|
||||
conn.enableWgWatcherIfNeeded(updateTime)
|
||||
conn.enableWgWatcherIfNeeded()
|
||||
|
||||
presharedKey := conn.presharedKey(iceConnInfo.RosenpassPubKey)
|
||||
if err = conn.endpointUpdater.ConfigureWGEndpoint(ep, presharedKey); err != nil {
|
||||
@@ -442,8 +406,8 @@ func (conn *Conn) onICEConnectionIsReady(priority conntype.ConnPriority, iceConn
|
||||
|
||||
conn.currentConnPriority = priority
|
||||
conn.statusICE.SetConnected()
|
||||
conn.updateIceState(iceConnInfo, updateTime)
|
||||
conn.doOnConnected(iceConnInfo.RosenpassPubKey, iceConnInfo.RosenpassAddr, updateTime)
|
||||
conn.updateIceState(iceConnInfo)
|
||||
conn.doOnConnected(iceConnInfo.RosenpassPubKey, iceConnInfo.RosenpassAddr)
|
||||
}
|
||||
|
||||
func (conn *Conn) onICEStateDisconnected() {
|
||||
@@ -491,10 +455,6 @@ func (conn *Conn) onICEStateDisconnected() {
|
||||
|
||||
conn.disableWgWatcherIfNeeded()
|
||||
|
||||
if conn.currentConnPriority == conntype.None {
|
||||
conn.metricsStages.Disconnected()
|
||||
}
|
||||
|
||||
peerState := State{
|
||||
PubKey: conn.config.Key,
|
||||
ConnStatus: conn.evalStatus(),
|
||||
@@ -535,15 +495,14 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) {
|
||||
conn.Log.Debugf("do not switch to relay because current priority is: %s", conn.currentConnPriority.String())
|
||||
conn.setRelayedProxy(wgProxy)
|
||||
conn.statusRelay.SetConnected()
|
||||
conn.updateRelayStatus(rci.relayedConn.RemoteAddr().String(), rci.rosenpassPubKey, time.Now())
|
||||
conn.updateRelayStatus(rci.relayedConn.RemoteAddr().String(), rci.rosenpassPubKey)
|
||||
return
|
||||
}
|
||||
|
||||
wgProxy.Work()
|
||||
presharedKey := conn.presharedKey(rci.rosenpassPubKey)
|
||||
|
||||
updateTime := time.Now()
|
||||
conn.enableWgWatcherIfNeeded(updateTime)
|
||||
conn.enableWgWatcherIfNeeded()
|
||||
|
||||
if err := conn.endpointUpdater.ConfigureWGEndpoint(wgProxy.EndpointAddr(), presharedKey); err != nil {
|
||||
if err := wgProxy.CloseConn(); err != nil {
|
||||
@@ -554,14 +513,13 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) {
|
||||
}
|
||||
|
||||
wgConfigWorkaround()
|
||||
|
||||
conn.rosenpassRemoteKey = rci.rosenpassPubKey
|
||||
conn.currentConnPriority = conntype.Relay
|
||||
conn.statusRelay.SetConnected()
|
||||
conn.setRelayedProxy(wgProxy)
|
||||
conn.updateRelayStatus(rci.relayedConn.RemoteAddr().String(), rci.rosenpassPubKey, updateTime)
|
||||
conn.updateRelayStatus(rci.relayedConn.RemoteAddr().String(), rci.rosenpassPubKey)
|
||||
conn.Log.Infof("start to communicate with peer via relay")
|
||||
conn.doOnConnected(rci.rosenpassPubKey, rci.rosenpassAddr, updateTime)
|
||||
conn.doOnConnected(rci.rosenpassPubKey, rci.rosenpassAddr)
|
||||
}
|
||||
|
||||
func (conn *Conn) onRelayDisconnected() {
|
||||
@@ -599,10 +557,6 @@ func (conn *Conn) handleRelayDisconnectedLocked() {
|
||||
|
||||
conn.disableWgWatcherIfNeeded()
|
||||
|
||||
if conn.currentConnPriority == conntype.None {
|
||||
conn.metricsStages.Disconnected()
|
||||
}
|
||||
|
||||
peerState := State{
|
||||
PubKey: conn.config.Key,
|
||||
ConnStatus: conn.evalStatus(),
|
||||
@@ -619,7 +573,6 @@ func (conn *Conn) onGuardEvent() {
|
||||
if err := conn.handshaker.SendOffer(); err != nil {
|
||||
conn.Log.Errorf("failed to send offer: %v", err)
|
||||
}
|
||||
conn.metricsStages.RecordSignaling()
|
||||
}
|
||||
|
||||
func (conn *Conn) onWGDisconnected() {
|
||||
@@ -644,10 +597,10 @@ func (conn *Conn) onWGDisconnected() {
|
||||
}
|
||||
}
|
||||
|
||||
func (conn *Conn) updateRelayStatus(relayServerAddr string, rosenpassPubKey []byte, updateTime time.Time) {
|
||||
func (conn *Conn) updateRelayStatus(relayServerAddr string, rosenpassPubKey []byte) {
|
||||
peerState := State{
|
||||
PubKey: conn.config.Key,
|
||||
ConnStatusUpdate: updateTime,
|
||||
ConnStatusUpdate: time.Now(),
|
||||
ConnStatus: conn.evalStatus(),
|
||||
Relayed: conn.isRelayed(),
|
||||
RelayServerAddress: relayServerAddr,
|
||||
@@ -660,10 +613,10 @@ func (conn *Conn) updateRelayStatus(relayServerAddr string, rosenpassPubKey []by
|
||||
}
|
||||
}
|
||||
|
||||
func (conn *Conn) updateIceState(iceConnInfo ICEConnInfo, updateTime time.Time) {
|
||||
func (conn *Conn) updateIceState(iceConnInfo ICEConnInfo) {
|
||||
peerState := State{
|
||||
PubKey: conn.config.Key,
|
||||
ConnStatusUpdate: updateTime,
|
||||
ConnStatusUpdate: time.Now(),
|
||||
ConnStatus: conn.evalStatus(),
|
||||
Relayed: iceConnInfo.Relayed,
|
||||
LocalIceCandidateType: iceConnInfo.LocalIceCandidateType,
|
||||
@@ -701,13 +654,11 @@ func (conn *Conn) setStatusToDisconnected() {
|
||||
}
|
||||
}
|
||||
|
||||
func (conn *Conn) doOnConnected(remoteRosenpassPubKey []byte, remoteRosenpassAddr string, updateTime time.Time) {
|
||||
func (conn *Conn) doOnConnected(remoteRosenpassPubKey []byte, remoteRosenpassAddr string) {
|
||||
if runtime.GOOS == "ios" {
|
||||
runtime.GC()
|
||||
}
|
||||
|
||||
conn.metricsStages.RecordConnectionReady(updateTime)
|
||||
|
||||
if conn.onConnected != nil {
|
||||
conn.onConnected(conn.config.Key, remoteRosenpassPubKey, conn.config.WgConfig.AllowedIps[0].Addr().String(), remoteRosenpassAddr)
|
||||
}
|
||||
@@ -772,14 +723,14 @@ func (conn *Conn) isConnectedOnAllWay() (connected bool) {
|
||||
return true
|
||||
}
|
||||
|
||||
func (conn *Conn) enableWgWatcherIfNeeded(enabledTime time.Time) {
|
||||
func (conn *Conn) enableWgWatcherIfNeeded() {
|
||||
if !conn.wgWatcher.IsEnabled() {
|
||||
wgWatcherCtx, wgWatcherCancel := context.WithCancel(conn.ctx)
|
||||
conn.wgWatcherCancel = wgWatcherCancel
|
||||
conn.wgWatcherWg.Add(1)
|
||||
go func() {
|
||||
defer conn.wgWatcherWg.Done()
|
||||
conn.wgWatcher.EnableWgWatcher(wgWatcherCtx, enabledTime, conn.onWGDisconnected, conn.onWGHandshakeSuccess)
|
||||
conn.wgWatcher.EnableWgWatcher(wgWatcherCtx, conn.onWGDisconnected)
|
||||
}()
|
||||
}
|
||||
}
|
||||
@@ -843,36 +794,6 @@ func (conn *Conn) setRelayedProxy(proxy wgproxy.Proxy) {
|
||||
conn.wgProxyRelay = proxy
|
||||
}
|
||||
|
||||
// onWGHandshakeSuccess is called when the first WireGuard handshake is detected
|
||||
func (conn *Conn) onWGHandshakeSuccess(when time.Time) {
|
||||
conn.metricsStages.RecordWGHandshakeSuccess(when)
|
||||
conn.recordConnectionMetrics()
|
||||
}
|
||||
|
||||
// recordConnectionMetrics records connection stage timestamps as metrics
|
||||
func (conn *Conn) recordConnectionMetrics() {
|
||||
if conn.metricsRecorder == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Determine connection type based on current priority
|
||||
var connType metrics.ConnectionType
|
||||
switch conn.currentConnPriority {
|
||||
case conntype.Relay:
|
||||
connType = metrics.ConnectionTypeRelay
|
||||
default:
|
||||
connType = metrics.ConnectionTypeICE
|
||||
}
|
||||
|
||||
// Record metrics with timestamps - duration calculation happens in metrics package
|
||||
conn.metricsRecorder.RecordConnectionStages(
|
||||
context.Background(),
|
||||
connType,
|
||||
conn.metricsStages.IsReconnection(),
|
||||
conn.metricsStages.GetTimestamps(),
|
||||
)
|
||||
}
|
||||
|
||||
// AllowedIP returns the allowed IP of the remote peer
|
||||
func (conn *Conn) AllowedIP() netip.Addr {
|
||||
return conn.config.WgConfig.AllowedIps[0].Addr()
|
||||
|
||||
@@ -44,13 +44,12 @@ type OfferAnswer struct {
|
||||
}
|
||||
|
||||
type Handshaker struct {
|
||||
mu sync.Mutex
|
||||
log *log.Entry
|
||||
config ConnConfig
|
||||
signaler *Signaler
|
||||
ice *WorkerICE
|
||||
relay *WorkerRelay
|
||||
metricsStages *MetricsStages
|
||||
mu sync.Mutex
|
||||
log *log.Entry
|
||||
config ConnConfig
|
||||
signaler *Signaler
|
||||
ice *WorkerICE
|
||||
relay *WorkerRelay
|
||||
// relayListener is not blocking because the listener is using a goroutine to process the messages
|
||||
// and it will only keep the latest message if multiple offers are received in a short time
|
||||
// this is to avoid blocking the handshaker if the listener is doing some heavy processing
|
||||
@@ -65,14 +64,13 @@ type Handshaker struct {
|
||||
remoteAnswerCh chan OfferAnswer
|
||||
}
|
||||
|
||||
func NewHandshaker(log *log.Entry, config ConnConfig, signaler *Signaler, ice *WorkerICE, relay *WorkerRelay, metricsStages *MetricsStages) *Handshaker {
|
||||
func NewHandshaker(log *log.Entry, config ConnConfig, signaler *Signaler, ice *WorkerICE, relay *WorkerRelay) *Handshaker {
|
||||
return &Handshaker{
|
||||
log: log,
|
||||
config: config,
|
||||
signaler: signaler,
|
||||
ice: ice,
|
||||
relay: relay,
|
||||
metricsStages: metricsStages,
|
||||
remoteOffersCh: make(chan OfferAnswer),
|
||||
remoteAnswerCh: make(chan OfferAnswer),
|
||||
}
|
||||
@@ -91,12 +89,6 @@ func (h *Handshaker) Listen(ctx context.Context) {
|
||||
select {
|
||||
case remoteOfferAnswer := <-h.remoteOffersCh:
|
||||
h.log.Infof("received offer, running version %s, remote WireGuard listen port %d, session id: %s", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString())
|
||||
|
||||
// Record signaling received for reconnection attempts
|
||||
if h.metricsStages != nil {
|
||||
h.metricsStages.RecordSignalingReceived()
|
||||
}
|
||||
|
||||
if h.relayListener != nil {
|
||||
h.relayListener.Notify(&remoteOfferAnswer)
|
||||
}
|
||||
@@ -111,12 +103,6 @@ func (h *Handshaker) Listen(ctx context.Context) {
|
||||
}
|
||||
case remoteOfferAnswer := <-h.remoteAnswerCh:
|
||||
h.log.Infof("received answer, running version %s, remote WireGuard listen port %d, session id: %s", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString())
|
||||
|
||||
// Record signaling received for reconnection attempts
|
||||
if h.metricsStages != nil {
|
||||
h.metricsStages.RecordSignalingReceived()
|
||||
}
|
||||
|
||||
if h.relayListener != nil {
|
||||
h.relayListener.Notify(&remoteOfferAnswer)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package ice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -33,6 +32,24 @@ type ThreadSafeAgent struct {
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (a *ThreadSafeAgent) Close() error {
|
||||
var err error
|
||||
a.once.Do(func() {
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- a.Agent.Close()
|
||||
}()
|
||||
|
||||
select {
|
||||
case err = <-done:
|
||||
case <-time.After(iceAgentCloseTimeout):
|
||||
log.Warnf("ICE agent close timed out after %v, proceeding with cleanup", iceAgentCloseTimeout)
|
||||
err = nil
|
||||
}
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func NewAgent(ctx context.Context, iFaceDiscover stdnet.ExternalIFaceDiscover, config Config, candidateTypes []ice.CandidateType, ufrag string, pwd string) (*ThreadSafeAgent, error) {
|
||||
iceKeepAlive := iceKeepAlive()
|
||||
iceDisconnectedTimeout := iceDisconnectedTimeout()
|
||||
@@ -76,41 +93,9 @@ func NewAgent(ctx context.Context, iFaceDiscover stdnet.ExternalIFaceDiscover, c
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if agent == nil {
|
||||
return nil, fmt.Errorf("ice.NewAgent returned nil agent without error")
|
||||
}
|
||||
|
||||
return &ThreadSafeAgent{Agent: agent}, nil
|
||||
}
|
||||
|
||||
func (a *ThreadSafeAgent) Close() error {
|
||||
var err error
|
||||
a.once.Do(func() {
|
||||
// Defensive check to prevent nil pointer dereference
|
||||
// This can happen during sleep/wake transitions or memory corruption scenarios
|
||||
// github.com/netbirdio/netbird/client/internal/peer/ice.(*ThreadSafeAgent).Close(0x40006883f0?)
|
||||
// [signal 0xc0000005 code=0x0 addr=0x0 pc=0x7ff7e73af83c]
|
||||
agent := a.Agent
|
||||
if agent == nil {
|
||||
log.Warnf("ICE agent is nil during close, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- agent.Close()
|
||||
}()
|
||||
|
||||
select {
|
||||
case err = <-done:
|
||||
case <-time.After(iceAgentCloseTimeout):
|
||||
log.Warnf("ICE agent close timed out after %v, proceeding with cleanup", iceAgentCloseTimeout)
|
||||
err = nil
|
||||
}
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func GenerateICECredentials() (string, string, error) {
|
||||
ufrag, err := randutil.GenerateCryptoRandomString(lenUFrag, runesAlpha)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
package peer
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/metrics"
|
||||
)
|
||||
|
||||
type MetricsStages struct {
|
||||
isReconnectionAttempt bool // Track if current attempt is a reconnection
|
||||
stageTimestamps metrics.ConnectionStageTimestamps
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (s *MetricsStages) RecordCreated() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.stageTimestamps.Created = time.Now()
|
||||
}
|
||||
|
||||
func (s *MetricsStages) RecordSemaphoreAcquired() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.stageTimestamps.SemaphoreAcquired = time.Now()
|
||||
}
|
||||
|
||||
// RecordSignaling records the signaling timestamp when sending offers
|
||||
// For initial connections: records when we start sending
|
||||
// For reconnections: does nothing (we wait for RecordSignalingReceived)
|
||||
func (s *MetricsStages) RecordSignaling() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.isReconnectionAttempt {
|
||||
return
|
||||
}
|
||||
|
||||
if s.stageTimestamps.Signaling.IsZero() {
|
||||
s.stageTimestamps.Signaling = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
// RecordSignalingReceived records the signaling timestamp when receiving offers/answers
|
||||
// For reconnections: records when we receive the first signal
|
||||
// For initial connections: does nothing (already recorded in RecordSignaling)
|
||||
func (s *MetricsStages) RecordSignalingReceived() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Only record for reconnections when we receive a signal
|
||||
if s.isReconnectionAttempt && s.stageTimestamps.Signaling.IsZero() {
|
||||
s.stageTimestamps.Signaling = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MetricsStages) RecordConnectionReady(when time.Time) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.stageTimestamps.ConnectionReady.IsZero() {
|
||||
s.stageTimestamps.ConnectionReady = when
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MetricsStages) RecordWGHandshakeSuccess(handshakeTime time.Time) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if !s.stageTimestamps.ConnectionReady.IsZero() {
|
||||
// WireGuard only reports handshake times with second precision, but ConnectionReady
|
||||
// is captured with microsecond precision. If handshake appears before ConnectionReady
|
||||
// due to truncation (e.g., handshake at 6.042s truncated to 6.000s), normalize to
|
||||
// ConnectionReady to avoid negative duration metrics.
|
||||
if handshakeTime.Before(s.stageTimestamps.ConnectionReady) {
|
||||
s.stageTimestamps.WgHandshakeSuccess = s.stageTimestamps.ConnectionReady
|
||||
} else {
|
||||
s.stageTimestamps.WgHandshakeSuccess = handshakeTime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnected sets the mode to reconnection. It is called only when both ICE and Relay have been disconnected at the same time.
|
||||
func (s *MetricsStages) Disconnected() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Reset all timestamps for reconnection
|
||||
// For reconnections, we only track from Signaling onwards
|
||||
// This avoids meaningless creation→semaphore and semaphore→signaling metrics
|
||||
s.stageTimestamps = metrics.ConnectionStageTimestamps{}
|
||||
s.isReconnectionAttempt = true
|
||||
}
|
||||
|
||||
func (s *MetricsStages) IsReconnection() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.isReconnectionAttempt
|
||||
}
|
||||
|
||||
func (s *MetricsStages) GetTimestamps() metrics.ConnectionStageTimestamps {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.stageTimestamps
|
||||
}
|
||||
@@ -45,7 +45,7 @@ func NewWGWatcher(log *log.Entry, wgIfaceStater WGInterfaceStater, peerKey strin
|
||||
|
||||
// EnableWgWatcher starts the WireGuard watcher. If it is already enabled, it will return immediately and do nothing.
|
||||
// The watcher runs until ctx is cancelled. Caller is responsible for context lifecycle management.
|
||||
func (w *WGWatcher) EnableWgWatcher(ctx context.Context, enabledTime time.Time, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time)) {
|
||||
func (w *WGWatcher) EnableWgWatcher(ctx context.Context, onDisconnectedFn func()) {
|
||||
w.muEnabled.Lock()
|
||||
if w.enabled {
|
||||
w.muEnabled.Unlock()
|
||||
@@ -53,6 +53,7 @@ func (w *WGWatcher) EnableWgWatcher(ctx context.Context, enabledTime time.Time,
|
||||
}
|
||||
|
||||
w.log.Debugf("enable WireGuard watcher")
|
||||
enabledTime := time.Now()
|
||||
w.enabled = true
|
||||
w.muEnabled.Unlock()
|
||||
|
||||
@@ -61,7 +62,7 @@ func (w *WGWatcher) EnableWgWatcher(ctx context.Context, enabledTime time.Time,
|
||||
w.log.Warnf("failed to read initial wg stats: %v", err)
|
||||
}
|
||||
|
||||
w.periodicHandshakeCheck(ctx, onDisconnectedFn, onHandshakeSuccessFn, enabledTime, initialHandshake)
|
||||
w.periodicHandshakeCheck(ctx, onDisconnectedFn, enabledTime, initialHandshake)
|
||||
|
||||
w.muEnabled.Lock()
|
||||
w.enabled = false
|
||||
@@ -76,7 +77,7 @@ func (w *WGWatcher) IsEnabled() bool {
|
||||
}
|
||||
|
||||
// wgStateCheck help to check the state of the WireGuard handshake and relay connection
|
||||
func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn func(), onHandshakeSuccessFn func(when time.Time), enabledTime time.Time, initialHandshake time.Time) {
|
||||
func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn func(), enabledTime time.Time, initialHandshake time.Time) {
|
||||
w.log.Infof("WireGuard watcher started")
|
||||
|
||||
timer := time.NewTimer(wgHandshakeOvertime)
|
||||
@@ -95,9 +96,6 @@ func (w *WGWatcher) periodicHandshakeCheck(ctx context.Context, onDisconnectedFn
|
||||
if lastHandshake.IsZero() {
|
||||
elapsed := calcElapsed(enabledTime, *handshake)
|
||||
w.log.Infof("first wg handshake detected within: %.2fsec, (%s)", elapsed, handshake)
|
||||
if onHandshakeSuccessFn != nil {
|
||||
onHandshakeSuccessFn(*handshake)
|
||||
}
|
||||
}
|
||||
|
||||
lastHandshake = *handshake
|
||||
|
||||
@@ -35,11 +35,9 @@ func TestWGWatcher_EnableWgWatcher(t *testing.T) {
|
||||
defer cancel()
|
||||
|
||||
onDisconnected := make(chan struct{}, 1)
|
||||
go watcher.EnableWgWatcher(ctx, time.Now(), func() {
|
||||
go watcher.EnableWgWatcher(ctx, func() {
|
||||
mlog.Infof("onDisconnectedFn")
|
||||
onDisconnected <- struct{}{}
|
||||
}, func(when time.Time) {
|
||||
mlog.Infof("onHandshakeSuccess: %v", when)
|
||||
})
|
||||
|
||||
// wait for initial reading
|
||||
@@ -66,7 +64,7 @@ func TestWGWatcher_ReEnable(t *testing.T) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
watcher.EnableWgWatcher(ctx, time.Now(), func() {}, func(when time.Time) {})
|
||||
watcher.EnableWgWatcher(ctx, func() {})
|
||||
}()
|
||||
cancel()
|
||||
|
||||
@@ -77,9 +75,9 @@ func TestWGWatcher_ReEnable(t *testing.T) {
|
||||
defer cancel()
|
||||
|
||||
onDisconnected := make(chan struct{}, 1)
|
||||
go watcher.EnableWgWatcher(ctx, time.Now(), func() {
|
||||
go watcher.EnableWgWatcher(ctx, func() {
|
||||
onDisconnected <- struct{}{}
|
||||
}, func(when time.Time) {})
|
||||
})
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
mocWgIface.disconnect()
|
||||
|
||||
@@ -107,10 +107,8 @@ func (w *WorkerICE) OnNewOffer(remoteOfferAnswer *OfferAnswer) {
|
||||
}
|
||||
w.log.Debugf("agent already exists, recreate the connection")
|
||||
w.agentDialerCancel()
|
||||
if w.agent != nil {
|
||||
if err := w.agent.Close(); err != nil {
|
||||
w.log.Warnf("failed to close ICE agent: %s", err)
|
||||
}
|
||||
if err := w.agent.Close(); err != nil {
|
||||
w.log.Warnf("failed to close ICE agent: %s", err)
|
||||
}
|
||||
|
||||
sessionID, err := NewICESessionID()
|
||||
|
||||
@@ -252,7 +252,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
}
|
||||
|
||||
if config.AdminURL == nil {
|
||||
log.Infof("using default Admin URL %s", DefaultAdminURL)
|
||||
log.Infof("using default Admin URL %s", DefaultManagementURL)
|
||||
config.AdminURL, err = parseURL("Admin URL", DefaultAdminURL)
|
||||
if err != nil {
|
||||
return false, err
|
||||
|
||||
@@ -173,21 +173,12 @@ func (m *DefaultManager) setupAndroidRoutes(config ManagerConfig) {
|
||||
}
|
||||
|
||||
func (m *DefaultManager) setupRefCounters(useNoop bool) {
|
||||
var once sync.Once
|
||||
var wgIface *net.Interface
|
||||
toInterface := func() *net.Interface {
|
||||
once.Do(func() {
|
||||
wgIface = m.wgInterface.ToInterface()
|
||||
})
|
||||
return wgIface
|
||||
}
|
||||
|
||||
m.routeRefCounter = refcounter.New(
|
||||
func(prefix netip.Prefix, _ struct{}) (struct{}, error) {
|
||||
return struct{}{}, m.sysOps.AddVPNRoute(prefix, toInterface())
|
||||
return struct{}{}, m.sysOps.AddVPNRoute(prefix, m.wgInterface.ToInterface())
|
||||
},
|
||||
func(prefix netip.Prefix, _ struct{}) error {
|
||||
return m.sysOps.RemoveVPNRoute(prefix, toInterface())
|
||||
return m.sysOps.RemoveVPNRoute(prefix, m.wgInterface.ToInterface())
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -4,17 +4,16 @@ package systemops
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// filterRoutesByFlags returns true if the route message should be ignored based on its flags.
|
||||
func filterRoutesByFlags(routeMessageFlags int) bool {
|
||||
if routeMessageFlags&unix.RTF_UP == 0 {
|
||||
if routeMessageFlags&syscall.RTF_UP == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
if routeMessageFlags&(unix.RTF_REJECT|unix.RTF_BLACKHOLE|unix.RTF_WASCLONED) != 0 {
|
||||
if routeMessageFlags&(syscall.RTF_REJECT|syscall.RTF_BLACKHOLE|syscall.RTF_WASCLONED) != 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -25,51 +24,42 @@ func filterRoutesByFlags(routeMessageFlags int) bool {
|
||||
func formatBSDFlags(flags int) string {
|
||||
var flagStrs []string
|
||||
|
||||
if flags&unix.RTF_UP != 0 {
|
||||
if flags&syscall.RTF_UP != 0 {
|
||||
flagStrs = append(flagStrs, "U")
|
||||
}
|
||||
if flags&unix.RTF_GATEWAY != 0 {
|
||||
if flags&syscall.RTF_GATEWAY != 0 {
|
||||
flagStrs = append(flagStrs, "G")
|
||||
}
|
||||
if flags&unix.RTF_HOST != 0 {
|
||||
if flags&syscall.RTF_HOST != 0 {
|
||||
flagStrs = append(flagStrs, "H")
|
||||
}
|
||||
if flags&unix.RTF_REJECT != 0 {
|
||||
if flags&syscall.RTF_REJECT != 0 {
|
||||
flagStrs = append(flagStrs, "R")
|
||||
}
|
||||
if flags&unix.RTF_DYNAMIC != 0 {
|
||||
if flags&syscall.RTF_DYNAMIC != 0 {
|
||||
flagStrs = append(flagStrs, "D")
|
||||
}
|
||||
if flags&unix.RTF_MODIFIED != 0 {
|
||||
if flags&syscall.RTF_MODIFIED != 0 {
|
||||
flagStrs = append(flagStrs, "M")
|
||||
}
|
||||
if flags&unix.RTF_STATIC != 0 {
|
||||
if flags&syscall.RTF_STATIC != 0 {
|
||||
flagStrs = append(flagStrs, "S")
|
||||
}
|
||||
if flags&unix.RTF_LLINFO != 0 {
|
||||
if flags&syscall.RTF_LLINFO != 0 {
|
||||
flagStrs = append(flagStrs, "L")
|
||||
}
|
||||
if flags&unix.RTF_LOCAL != 0 {
|
||||
if flags&syscall.RTF_LOCAL != 0 {
|
||||
flagStrs = append(flagStrs, "l")
|
||||
}
|
||||
if flags&unix.RTF_BLACKHOLE != 0 {
|
||||
if flags&syscall.RTF_BLACKHOLE != 0 {
|
||||
flagStrs = append(flagStrs, "B")
|
||||
}
|
||||
if flags&unix.RTF_CLONING != 0 {
|
||||
if flags&syscall.RTF_CLONING != 0 {
|
||||
flagStrs = append(flagStrs, "C")
|
||||
}
|
||||
if flags&unix.RTF_WASCLONED != 0 {
|
||||
if flags&syscall.RTF_WASCLONED != 0 {
|
||||
flagStrs = append(flagStrs, "W")
|
||||
}
|
||||
if flags&unix.RTF_PROTO1 != 0 {
|
||||
flagStrs = append(flagStrs, "1")
|
||||
}
|
||||
if flags&unix.RTF_PROTO2 != 0 {
|
||||
flagStrs = append(flagStrs, "2")
|
||||
}
|
||||
if flags&unix.RTF_PROTO3 != 0 {
|
||||
flagStrs = append(flagStrs, "3")
|
||||
}
|
||||
|
||||
if len(flagStrs) == 0 {
|
||||
return "-"
|
||||
|
||||
@@ -4,18 +4,17 @@ package systemops
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// filterRoutesByFlags returns true if the route message should be ignored based on its flags.
|
||||
func filterRoutesByFlags(routeMessageFlags int) bool {
|
||||
if routeMessageFlags&unix.RTF_UP == 0 {
|
||||
if routeMessageFlags&syscall.RTF_UP == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
// NOTE: RTF_WASCLONED deprecated in FreeBSD 8.0
|
||||
if routeMessageFlags&(unix.RTF_REJECT|unix.RTF_BLACKHOLE) != 0 {
|
||||
// NOTE: syscall.RTF_WASCLONED deprecated in FreeBSD 8.0
|
||||
if routeMessageFlags&(syscall.RTF_REJECT|syscall.RTF_BLACKHOLE) != 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -26,46 +25,37 @@ func filterRoutesByFlags(routeMessageFlags int) bool {
|
||||
func formatBSDFlags(flags int) string {
|
||||
var flagStrs []string
|
||||
|
||||
if flags&unix.RTF_UP != 0 {
|
||||
if flags&syscall.RTF_UP != 0 {
|
||||
flagStrs = append(flagStrs, "U")
|
||||
}
|
||||
if flags&unix.RTF_GATEWAY != 0 {
|
||||
if flags&syscall.RTF_GATEWAY != 0 {
|
||||
flagStrs = append(flagStrs, "G")
|
||||
}
|
||||
if flags&unix.RTF_HOST != 0 {
|
||||
if flags&syscall.RTF_HOST != 0 {
|
||||
flagStrs = append(flagStrs, "H")
|
||||
}
|
||||
if flags&unix.RTF_REJECT != 0 {
|
||||
if flags&syscall.RTF_REJECT != 0 {
|
||||
flagStrs = append(flagStrs, "R")
|
||||
}
|
||||
if flags&unix.RTF_DYNAMIC != 0 {
|
||||
if flags&syscall.RTF_DYNAMIC != 0 {
|
||||
flagStrs = append(flagStrs, "D")
|
||||
}
|
||||
if flags&unix.RTF_MODIFIED != 0 {
|
||||
if flags&syscall.RTF_MODIFIED != 0 {
|
||||
flagStrs = append(flagStrs, "M")
|
||||
}
|
||||
if flags&unix.RTF_STATIC != 0 {
|
||||
if flags&syscall.RTF_STATIC != 0 {
|
||||
flagStrs = append(flagStrs, "S")
|
||||
}
|
||||
if flags&unix.RTF_LLINFO != 0 {
|
||||
if flags&syscall.RTF_LLINFO != 0 {
|
||||
flagStrs = append(flagStrs, "L")
|
||||
}
|
||||
if flags&unix.RTF_LOCAL != 0 {
|
||||
if flags&syscall.RTF_LOCAL != 0 {
|
||||
flagStrs = append(flagStrs, "l")
|
||||
}
|
||||
if flags&unix.RTF_BLACKHOLE != 0 {
|
||||
if flags&syscall.RTF_BLACKHOLE != 0 {
|
||||
flagStrs = append(flagStrs, "B")
|
||||
}
|
||||
// Note: RTF_CLONING and RTF_WASCLONED deprecated in FreeBSD 8.0
|
||||
if flags&unix.RTF_PROTO1 != 0 {
|
||||
flagStrs = append(flagStrs, "1")
|
||||
}
|
||||
if flags&unix.RTF_PROTO2 != 0 {
|
||||
flagStrs = append(flagStrs, "2")
|
||||
}
|
||||
if flags&unix.RTF_PROTO3 != 0 {
|
||||
flagStrs = append(flagStrs, "3")
|
||||
}
|
||||
|
||||
if len(flagStrs) == 0 {
|
||||
return "-"
|
||||
|
||||
@@ -26,13 +26,6 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (
|
||||
log.Warnf("failed to get latest sync response: %v", err)
|
||||
}
|
||||
|
||||
var clientMetrics debug.MetricsExporter
|
||||
if s.connectClient != nil {
|
||||
if engine := s.connectClient.Engine(); engine != nil {
|
||||
clientMetrics = engine.GetClientMetrics()
|
||||
}
|
||||
}
|
||||
|
||||
var cpuProfileData []byte
|
||||
if s.cpuProfileBuf != nil && !s.cpuProfiling {
|
||||
cpuProfileData = s.cpuProfileBuf.Bytes()
|
||||
@@ -61,7 +54,6 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (
|
||||
LogPath: s.logFile,
|
||||
CPUProfile: cpuProfileData,
|
||||
RefreshStatus: refreshStatus,
|
||||
ClientMetrics: clientMetrics,
|
||||
},
|
||||
debug.BundleConfig{
|
||||
Anonymize: req.GetAnonymize(),
|
||||
|
||||
6
go.mod
6
go.mod
@@ -33,7 +33,6 @@ require (
|
||||
fyne.io/fyne/v2 v2.7.0
|
||||
fyne.io/systray v1.12.1-0.20260116214250-81f8e1a496f9
|
||||
github.com/TheJumpCloud/jcapi-go v3.0.0+incompatible
|
||||
github.com/VictoriaMetrics/metrics v1.40.2
|
||||
github.com/awnumar/memguard v0.23.0
|
||||
github.com/aws/aws-sdk-go-v2 v1.36.3
|
||||
github.com/aws/aws-sdk-go-v2/config v1.29.14
|
||||
@@ -41,8 +40,10 @@ require (
|
||||
github.com/c-robinson/iplib v1.0.3
|
||||
github.com/caddyserver/certmagic v0.21.3
|
||||
github.com/cilium/ebpf v0.15.0
|
||||
github.com/cloudflare/backoff v0.0.0-20240920015135-e46b80a3a7d0
|
||||
github.com/coder/websocket v1.8.13
|
||||
github.com/coreos/go-iptables v0.7.0
|
||||
github.com/coreos/go-oidc/v3 v3.14.1
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/dexidp/dex v0.0.0-00010101000000-000000000000
|
||||
github.com/dexidp/dex/api/v2 v2.4.0
|
||||
@@ -168,7 +169,6 @@ require (
|
||||
github.com/containerd/containerd v1.7.29 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/containerd/platforms v0.2.1 // indirect
|
||||
github.com/coreos/go-oidc/v3 v3.14.1 // indirect
|
||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
@@ -264,8 +264,6 @@ require (
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.14 // indirect
|
||||
github.com/tklauser/numcpus v0.8.0 // indirect
|
||||
github.com/valyala/fastrand v1.1.0 // indirect
|
||||
github.com/valyala/histogram v1.2.0 // indirect
|
||||
github.com/vishvananda/netns v0.0.5 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
github.com/wlynxg/anet v0.0.5 // indirect
|
||||
|
||||
8
go.sum
8
go.sum
@@ -38,8 +38,6 @@ github.com/Microsoft/hcsshim v0.12.3/go.mod h1:Iyl1WVpZzr+UkzjekHZbV8o5Z9ZkxNGx6
|
||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
||||
github.com/TheJumpCloud/jcapi-go v3.0.0+incompatible h1:hqcTK6ZISdip65SR792lwYJTa/axESA0889D3UlZbLo=
|
||||
github.com/TheJumpCloud/jcapi-go v3.0.0+incompatible/go.mod h1:6B1nuc1MUs6c62ODZDl7hVE5Pv7O2XGSkgg2olnq34I=
|
||||
github.com/VictoriaMetrics/metrics v1.40.2 h1:OVSjKcQEx6JAwGeu8/KQm9Su5qJ72TMEW4xYn5vw3Ac=
|
||||
github.com/VictoriaMetrics/metrics v1.40.2/go.mod h1:XE4uudAAIRaJE614Tl5HMrtoEU6+GDZO4QTnNSsZRuA=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
@@ -109,6 +107,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk=
|
||||
github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso=
|
||||
github.com/cloudflare/backoff v0.0.0-20240920015135-e46b80a3a7d0 h1:pRcxfaAlK0vR6nOeQs7eAEvjJzdGXl8+KaBlcvpQTyQ=
|
||||
github.com/cloudflare/backoff v0.0.0-20240920015135-e46b80a3a7d0/go.mod h1:rzgs2ZOiguV6/NpiDgADjRLPNyZlApIWxKpkT+X8SdY=
|
||||
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
|
||||
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
|
||||
github.com/containerd/containerd v1.7.29 h1:90fWABQsaN9mJhGkoVnuzEY+o1XDPbg9BTC9QTAHnuE=
|
||||
@@ -576,10 +576,6 @@ github.com/tklauser/numcpus v0.8.0 h1:Mx4Wwe/FjZLeQsK/6kt2EOepwwSl7SmJrK5bV/dXYg
|
||||
github.com/tklauser/numcpus v0.8.0/go.mod h1:ZJZlAY+dmR4eut8epnzf0u/VwodKmryxR8txiloSqBE=
|
||||
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
|
||||
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
|
||||
github.com/valyala/fastrand v1.1.0 h1:f+5HkLW4rsgzdNoleUOB69hyT9IlD2ZQh9GyDMfb5G8=
|
||||
github.com/valyala/fastrand v1.1.0/go.mod h1:HWqCzkrkg6QXT8V2EXWvXCoow7vLwOFN002oeRzjapQ=
|
||||
github.com/valyala/histogram v1.2.0 h1:wyYGAZZt3CpwUiIb9AU/Zbllg1llXyrtApRS815OLoQ=
|
||||
github.com/valyala/histogram v1.2.0/go.mod h1:Hb4kBwb4UxsaNbbbh+RRz8ZR6pdodR57tzWUS3BUzXY=
|
||||
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
|
||||
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
|
||||
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
|
||||
|
||||
@@ -327,60 +327,6 @@ func ensureLocalConnector(ctx context.Context, stor storage.Storage) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasNonLocalConnectors checks if there are any connectors other than the local connector.
|
||||
func (p *Provider) HasNonLocalConnectors(ctx context.Context) (bool, error) {
|
||||
connectors, err := p.storage.ListConnectors(ctx)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to list connectors: %w", err)
|
||||
}
|
||||
|
||||
p.logger.Info("checking for non-local connectors", "total_connectors", len(connectors))
|
||||
for _, conn := range connectors {
|
||||
p.logger.Info("found connector in storage", "id", conn.ID, "type", conn.Type, "name", conn.Name)
|
||||
if conn.ID != "local" || conn.Type != "local" {
|
||||
p.logger.Info("found non-local connector", "id", conn.ID)
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
p.logger.Info("no non-local connectors found")
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// DisableLocalAuth removes the local (password) connector.
|
||||
// Returns an error if no other connectors are configured.
|
||||
func (p *Provider) DisableLocalAuth(ctx context.Context) error {
|
||||
hasOthers, err := p.HasNonLocalConnectors(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasOthers {
|
||||
return fmt.Errorf("cannot disable local authentication: no other identity providers configured")
|
||||
}
|
||||
|
||||
// Check if local connector exists
|
||||
_, err = p.storage.GetConnector(ctx, "local")
|
||||
if errors.Is(err, storage.ErrNotFound) {
|
||||
// Already disabled
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check local connector: %w", err)
|
||||
}
|
||||
|
||||
// Delete the local connector
|
||||
if err := p.storage.DeleteConnector(ctx, "local"); err != nil {
|
||||
return fmt.Errorf("failed to delete local connector: %w", err)
|
||||
}
|
||||
|
||||
p.logger.Info("local authentication disabled")
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnableLocalAuth creates the local (password) connector if it doesn't exist.
|
||||
func (p *Provider) EnableLocalAuth(ctx context.Context) error {
|
||||
return ensureLocalConnector(ctx, p.storage)
|
||||
}
|
||||
|
||||
// ensureStaticConnectors creates or updates static connectors in storage
|
||||
func ensureStaticConnectors(ctx context.Context, stor storage.Storage, connectors []Connector) error {
|
||||
for _, conn := range connectors {
|
||||
|
||||
@@ -180,6 +180,8 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
|
||||
resourcePolicies := account.GetResourcePoliciesMap()
|
||||
routers := account.GetResourceRoutersMap()
|
||||
groupIDToUserIDs := account.GetActiveGroupUsers()
|
||||
exposedServices := account.GetExposedServicesMap()
|
||||
proxyPeers := account.GetProxyPeers()
|
||||
|
||||
if c.experimentalNetworkMap(accountID) {
|
||||
c.initNetworkMapBuilderIfNeeded(account, approvedPeersMap)
|
||||
@@ -232,7 +234,7 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
|
||||
if c.experimentalNetworkMap(accountID) {
|
||||
remotePeerNetworkMap = c.getPeerNetworkMapExp(ctx, p.AccountID, p.ID, approvedPeersMap, peersCustomZone, accountZones, c.accountManagerMetrics)
|
||||
} else {
|
||||
remotePeerNetworkMap = account.GetPeerNetworkMap(ctx, p.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs)
|
||||
remotePeerNetworkMap = account.GetPeerNetworkMap(ctx, p.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs, exposedServices, proxyPeers)
|
||||
}
|
||||
|
||||
c.metrics.CountCalcPeerNetworkMapDuration(time.Since(start))
|
||||
@@ -247,10 +249,7 @@ func (c *Controller) sendUpdateAccountPeers(ctx context.Context, accountID strin
|
||||
update := grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, p, nil, nil, remotePeerNetworkMap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSetting, maps.Keys(peerGroups), dnsFwdPort)
|
||||
c.metrics.CountToSyncResponseDuration(time.Since(start))
|
||||
|
||||
c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{
|
||||
Update: update,
|
||||
MessageType: network_map.MessageTypeNetworkMap,
|
||||
})
|
||||
c.peersUpdateManager.SendUpdate(ctx, p.ID, &network_map.UpdateMessage{Update: update})
|
||||
}(peer)
|
||||
}
|
||||
|
||||
@@ -356,7 +355,7 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe
|
||||
if c.experimentalNetworkMap(accountId) {
|
||||
remotePeerNetworkMap = c.getPeerNetworkMapExp(ctx, peer.AccountID, peer.ID, approvedPeersMap, peersCustomZone, accountZones, c.accountManagerMetrics)
|
||||
} else {
|
||||
remotePeerNetworkMap = account.GetPeerNetworkMap(ctx, peerId, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs)
|
||||
remotePeerNetworkMap = account.GetPeerNetworkMap(ctx, peerId, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, groupIDToUserIDs, account.GetExposedServicesMap(), account.GetProxyPeers())
|
||||
}
|
||||
|
||||
proxyNetworkMap, ok := proxyNetworkMaps[peer.ID]
|
||||
@@ -373,10 +372,7 @@ func (c *Controller) UpdateAccountPeer(ctx context.Context, accountId string, pe
|
||||
dnsFwdPort := computeForwarderPort(maps.Values(account.Peers), network_map.DnsForwarderPortMinVersion)
|
||||
|
||||
update := grpc.ToSyncResponse(ctx, nil, c.config.HttpConfig, c.config.DeviceAuthorizationFlow, peer, nil, nil, remotePeerNetworkMap, dnsDomain, postureChecks, dnsCache, account.Settings, extraSettings, maps.Keys(peerGroups), dnsFwdPort)
|
||||
c.peersUpdateManager.SendUpdate(ctx, peer.ID, &network_map.UpdateMessage{
|
||||
Update: update,
|
||||
MessageType: network_map.MessageTypeNetworkMap,
|
||||
})
|
||||
c.peersUpdateManager.SendUpdate(ctx, peer.ID, &network_map.UpdateMessage{Update: update})
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -475,7 +471,7 @@ func (c *Controller) GetValidatedPeerWithMap(ctx context.Context, isRequiresAppr
|
||||
} else {
|
||||
resourcePolicies := account.GetResourcePoliciesMap()
|
||||
routers := account.GetResourceRoutersMap()
|
||||
networkMap = account.GetPeerNetworkMap(ctx, peer.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, account.GetActiveGroupUsers())
|
||||
networkMap = account.GetPeerNetworkMap(ctx, peer.ID, peersCustomZone, accountZones, approvedPeersMap, resourcePolicies, routers, c.accountManagerMetrics, account.GetActiveGroupUsers(), account.GetExposedServicesMap(), account.GetProxyPeers())
|
||||
}
|
||||
|
||||
proxyNetworkMap, ok := proxyNetworkMaps[peer.ID]
|
||||
@@ -784,7 +780,6 @@ func (c *Controller) OnPeersDeleted(ctx context.Context, accountID string, peerI
|
||||
},
|
||||
},
|
||||
},
|
||||
MessageType: network_map.MessageTypeNetworkMap,
|
||||
})
|
||||
c.peersUpdateManager.CloseChannel(ctx, peerID)
|
||||
|
||||
@@ -849,7 +844,7 @@ func (c *Controller) GetNetworkMap(ctx context.Context, peerID string) (*types.N
|
||||
} else {
|
||||
resourcePolicies := account.GetResourcePoliciesMap()
|
||||
routers := account.GetResourceRoutersMap()
|
||||
networkMap = account.GetPeerNetworkMap(ctx, peer.ID, peersCustomZone, accountZones, validatedPeers, resourcePolicies, routers, nil, account.GetActiveGroupUsers())
|
||||
networkMap = account.GetPeerNetworkMap(ctx, peer.ID, peersCustomZone, accountZones, validatedPeers, resourcePolicies, routers, nil, account.GetActiveGroupUsers(), account.GetExposedServicesMap(), account.GetProxyPeers())
|
||||
}
|
||||
|
||||
proxyNetworkMap, ok := proxyNetworkMaps[peer.ID]
|
||||
|
||||
@@ -25,14 +25,11 @@ func TestCreateChannel(t *testing.T) {
|
||||
func TestSendUpdate(t *testing.T) {
|
||||
peer := "test-sendupdate"
|
||||
peersUpdater := NewPeersUpdateManager(nil)
|
||||
update1 := &network_map.UpdateMessage{
|
||||
Update: &proto.SyncResponse{
|
||||
NetworkMap: &proto.NetworkMap{
|
||||
Serial: 0,
|
||||
},
|
||||
update1 := &network_map.UpdateMessage{Update: &proto.SyncResponse{
|
||||
NetworkMap: &proto.NetworkMap{
|
||||
Serial: 0,
|
||||
},
|
||||
MessageType: network_map.MessageTypeNetworkMap,
|
||||
}
|
||||
}}
|
||||
_ = peersUpdater.CreateChannel(context.Background(), peer)
|
||||
if _, ok := peersUpdater.peerChannels[peer]; !ok {
|
||||
t.Error("Error creating the channel")
|
||||
@@ -48,14 +45,11 @@ func TestSendUpdate(t *testing.T) {
|
||||
peersUpdater.SendUpdate(context.Background(), peer, update1)
|
||||
}
|
||||
|
||||
update2 := &network_map.UpdateMessage{
|
||||
Update: &proto.SyncResponse{
|
||||
NetworkMap: &proto.NetworkMap{
|
||||
Serial: 10,
|
||||
},
|
||||
update2 := &network_map.UpdateMessage{Update: &proto.SyncResponse{
|
||||
NetworkMap: &proto.NetworkMap{
|
||||
Serial: 10,
|
||||
},
|
||||
MessageType: network_map.MessageTypeNetworkMap,
|
||||
}
|
||||
}}
|
||||
|
||||
peersUpdater.SendUpdate(context.Background(), peer, update2)
|
||||
timeout := time.After(5 * time.Second)
|
||||
|
||||
@@ -4,19 +4,6 @@ import (
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
// MessageType indicates the type of update message for debouncing strategy
|
||||
type MessageType int
|
||||
|
||||
const (
|
||||
// MessageTypeNetworkMap represents network map updates (peers, routes, DNS, firewall)
|
||||
// These updates can be safely debounced - only the latest state matters
|
||||
MessageTypeNetworkMap MessageType = iota
|
||||
// MessageTypeControlConfig represents control/config updates (tokens, peer expiration)
|
||||
// These updates should not be dropped as they contain time-sensitive information
|
||||
MessageTypeControlConfig
|
||||
)
|
||||
|
||||
type UpdateMessage struct {
|
||||
Update *proto.SyncResponse
|
||||
MessageType MessageType
|
||||
Update *proto.SyncResponse
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/rs/xid"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
|
||||
@@ -32,6 +33,7 @@ type Manager interface {
|
||||
SetIntegratedPeerValidator(integratedPeerValidator integrated_validator.IntegratedValidator)
|
||||
SetAccountManager(accountManager account.Manager)
|
||||
GetPeerID(ctx context.Context, peerKey string) (string, error)
|
||||
CreateProxyPeer(ctx context.Context, accountID string, peerKey string) error
|
||||
}
|
||||
|
||||
type managerImpl struct {
|
||||
@@ -182,3 +184,33 @@ func (m *managerImpl) DeletePeers(ctx context.Context, accountID string, peerIDs
|
||||
func (m *managerImpl) GetPeerID(ctx context.Context, peerKey string) (string, error) {
|
||||
return m.store.GetPeerIDByKey(ctx, store.LockingStrengthNone, peerKey)
|
||||
}
|
||||
|
||||
func (m *managerImpl) CreateProxyPeer(ctx context.Context, accountID string, peerKey string) error {
|
||||
existingPeerID, err := m.store.GetPeerIDByKey(ctx, store.LockingStrengthNone, peerKey)
|
||||
if err == nil && existingPeerID != "" {
|
||||
// Peer already exists
|
||||
return nil
|
||||
}
|
||||
|
||||
name := fmt.Sprintf("proxy-%s", xid.New().String())
|
||||
peer := &peer.Peer{
|
||||
Ephemeral: true,
|
||||
ProxyEmbedded: true,
|
||||
Name: name,
|
||||
Key: peerKey,
|
||||
LoginExpirationEnabled: false,
|
||||
InactivityExpirationEnabled: false,
|
||||
Meta: peer.PeerSystemMeta{
|
||||
Hostname: name,
|
||||
GoOS: "proxy",
|
||||
OS: "proxy",
|
||||
},
|
||||
}
|
||||
|
||||
_, _, _, err = m.accountManager.AddPeer(ctx, accountID, "", "", peer, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create proxy peer: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package accesslogs
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
type AccessLogEntry struct {
|
||||
ID string `gorm:"primaryKey"`
|
||||
AccountID string `gorm:"index"`
|
||||
ProxyID string `gorm:"index"`
|
||||
Timestamp time.Time `gorm:"index"`
|
||||
GeoLocation peer.Location `gorm:"embedded;embeddedPrefix:location_"`
|
||||
Method string
|
||||
Host string
|
||||
Path string
|
||||
Duration time.Duration
|
||||
StatusCode int
|
||||
Reason string
|
||||
UserId string
|
||||
AuthMethodUsed string
|
||||
}
|
||||
|
||||
// FromProto creates an AccessLogEntry from a proto.AccessLog
|
||||
func (a *AccessLogEntry) FromProto(proxyLog *proto.AccessLog) {
|
||||
a.ID = proxyLog.GetLogId()
|
||||
a.ProxyID = proxyLog.GetServiceId()
|
||||
a.Timestamp = proxyLog.GetTimestamp().AsTime()
|
||||
a.Method = proxyLog.GetMethod()
|
||||
a.Host = proxyLog.GetHost()
|
||||
a.Path = proxyLog.GetPath()
|
||||
a.Duration = time.Duration(proxyLog.GetDurationMs()) * time.Millisecond
|
||||
a.StatusCode = int(proxyLog.GetResponseCode())
|
||||
a.UserId = proxyLog.GetUserId()
|
||||
a.AuthMethodUsed = proxyLog.GetAuthMechanism()
|
||||
a.AccountID = proxyLog.GetAccountId()
|
||||
|
||||
if sourceIP := proxyLog.GetSourceIp(); sourceIP != "" {
|
||||
if ip, err := netip.ParseAddr(sourceIP); err == nil {
|
||||
a.GeoLocation.ConnectionIP = net.IP(ip.AsSlice())
|
||||
}
|
||||
}
|
||||
|
||||
if !proxyLog.GetAuthSuccess() {
|
||||
a.Reason = "Authentication failed"
|
||||
} else if proxyLog.GetResponseCode() >= 400 {
|
||||
a.Reason = "Request failed"
|
||||
}
|
||||
}
|
||||
|
||||
// ToAPIResponse converts an AccessLogEntry to the API ProxyAccessLog type
|
||||
func (a *AccessLogEntry) ToAPIResponse() *api.ProxyAccessLog {
|
||||
var sourceIP *string
|
||||
if a.GeoLocation.ConnectionIP != nil {
|
||||
ip := a.GeoLocation.ConnectionIP.String()
|
||||
sourceIP = &ip
|
||||
}
|
||||
|
||||
var reason *string
|
||||
if a.Reason != "" {
|
||||
reason = &a.Reason
|
||||
}
|
||||
|
||||
var userID *string
|
||||
if a.UserId != "" {
|
||||
userID = &a.UserId
|
||||
}
|
||||
|
||||
var authMethod *string
|
||||
if a.AuthMethodUsed != "" {
|
||||
authMethod = &a.AuthMethodUsed
|
||||
}
|
||||
|
||||
var countryCode *string
|
||||
if a.GeoLocation.CountryCode != "" {
|
||||
countryCode = &a.GeoLocation.CountryCode
|
||||
}
|
||||
|
||||
var cityName *string
|
||||
if a.GeoLocation.CityName != "" {
|
||||
cityName = &a.GeoLocation.CityName
|
||||
}
|
||||
|
||||
return &api.ProxyAccessLog{
|
||||
Id: a.ID,
|
||||
ProxyId: a.ProxyID,
|
||||
Timestamp: a.Timestamp,
|
||||
Method: a.Method,
|
||||
Host: a.Host,
|
||||
Path: a.Path,
|
||||
DurationMs: int(a.Duration.Milliseconds()),
|
||||
StatusCode: a.StatusCode,
|
||||
SourceIp: sourceIP,
|
||||
Reason: reason,
|
||||
UserId: userID,
|
||||
AuthMethodUsed: authMethod,
|
||||
CountryCode: countryCode,
|
||||
CityName: cityName,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package accesslogs
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
type Manager interface {
|
||||
SaveAccessLog(ctx context.Context, proxyLog *AccessLogEntry) error
|
||||
GetAllAccessLogs(ctx context.Context, accountID, userID string) ([]*AccessLogEntry, error)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
|
||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
"github.com/netbirdio/netbird/shared/management/http/util"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
manager accesslogs.Manager
|
||||
}
|
||||
|
||||
func RegisterEndpoints(router *mux.Router, manager accesslogs.Manager) {
|
||||
h := &handler{
|
||||
manager: manager,
|
||||
}
|
||||
|
||||
router.HandleFunc("/events/proxy", h.getAccessLogs).Methods("GET", "OPTIONS")
|
||||
}
|
||||
|
||||
func (h *handler) getAccessLogs(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
logs, err := h.manager.GetAllAccessLogs(r.Context(), userAuth.AccountId, userAuth.UserId)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
apiLogs := make([]api.ProxyAccessLog, 0, len(logs))
|
||||
for _, log := range logs {
|
||||
apiLogs = append(apiLogs, *log.ToAPIResponse())
|
||||
}
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, apiLogs)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
|
||||
"github.com/netbirdio/netbird/management/server/geolocation"
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/modules"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/operations"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
type managerImpl struct {
|
||||
store store.Store
|
||||
permissionsManager permissions.Manager
|
||||
geo geolocation.Geolocation
|
||||
}
|
||||
|
||||
func NewManager(store store.Store, permissionsManager permissions.Manager, geo geolocation.Geolocation) accesslogs.Manager {
|
||||
return &managerImpl{
|
||||
store: store,
|
||||
permissionsManager: permissionsManager,
|
||||
geo: geo,
|
||||
}
|
||||
}
|
||||
|
||||
// SaveAccessLog saves an access log entry to the database after enriching it
|
||||
func (m *managerImpl) SaveAccessLog(ctx context.Context, logEntry *accesslogs.AccessLogEntry) error {
|
||||
if m.geo != nil && logEntry.GeoLocation.ConnectionIP != nil {
|
||||
location, err := m.geo.Lookup(logEntry.GeoLocation.ConnectionIP)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Warnf("failed to get location for access log source IP [%s]: %v", logEntry.GeoLocation.ConnectionIP.String(), err)
|
||||
} else {
|
||||
logEntry.GeoLocation.CountryCode = location.Country.ISOCode
|
||||
logEntry.GeoLocation.CityName = location.City.Names.En
|
||||
logEntry.GeoLocation.GeoNameID = location.City.GeonameID
|
||||
}
|
||||
}
|
||||
|
||||
if err := m.store.CreateAccessLog(ctx, logEntry); err != nil {
|
||||
log.WithContext(ctx).WithFields(log.Fields{
|
||||
"proxy_id": logEntry.ProxyID,
|
||||
"method": logEntry.Method,
|
||||
"host": logEntry.Host,
|
||||
"path": logEntry.Path,
|
||||
"status": logEntry.StatusCode,
|
||||
}).Errorf("failed to save access log: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAllAccessLogs retrieves all access logs for an account
|
||||
func (m *managerImpl) GetAllAccessLogs(ctx context.Context, accountID, userID string) ([]*accesslogs.AccessLogEntry, error) {
|
||||
ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read)
|
||||
if err != nil {
|
||||
return nil, status.NewPermissionValidationError(err)
|
||||
}
|
||||
if !ok {
|
||||
return nil, status.NewPermissionDeniedError()
|
||||
}
|
||||
|
||||
logs, err := m.store.GetAccountAccessLogs(ctx, store.LockingStrengthNone, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return logs, nil
|
||||
}
|
||||
131
management/internals/modules/reverseproxy/domain/api.go
Normal file
131
management/internals/modules/reverseproxy/domain/api.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
"github.com/netbirdio/netbird/shared/management/http/util"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
manager Manager
|
||||
}
|
||||
|
||||
func RegisterEndpoints(router *mux.Router, manager Manager) {
|
||||
h := &handler{
|
||||
manager: manager,
|
||||
}
|
||||
|
||||
router.HandleFunc("/domains", h.getAllDomains).Methods("GET", "OPTIONS")
|
||||
router.HandleFunc("/domains", h.createCustomDomain).Methods("POST", "OPTIONS")
|
||||
router.HandleFunc("/domains/{domainId}", h.deleteCustomDomain).Methods("DELETE", "OPTIONS")
|
||||
router.HandleFunc("/domains/{domainId}/validate", h.triggerCustomDomainValidation).Methods("GET", "OPTIONS")
|
||||
}
|
||||
|
||||
func domainTypeToApi(t domainType) api.ReverseProxyDomainType {
|
||||
switch t {
|
||||
case TypeCustom:
|
||||
return api.ReverseProxyDomainTypeCustom
|
||||
case TypeFree:
|
||||
return api.ReverseProxyDomainTypeFree
|
||||
}
|
||||
// By default return as a "free" domain as that is more restrictive.
|
||||
// TODO: is this correct?
|
||||
return api.ReverseProxyDomainTypeFree
|
||||
}
|
||||
|
||||
func domainToApi(d *Domain) api.ReverseProxyDomain {
|
||||
return api.ReverseProxyDomain{
|
||||
Domain: d.Domain,
|
||||
Id: d.ID,
|
||||
Type: domainTypeToApi(d.Type),
|
||||
Validated: d.Validated,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) getAllDomains(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
domains, err := h.manager.GetDomains(r.Context(), userAuth.AccountId)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
ret := make([]api.ReverseProxyDomain, 0)
|
||||
for _, d := range domains {
|
||||
ret = append(ret, domainToApi(d))
|
||||
}
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, ret)
|
||||
}
|
||||
|
||||
func (h *handler) createCustomDomain(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
var req api.PostApiReverseProxiesDomainsJSONRequestBody
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
|
||||
return
|
||||
}
|
||||
|
||||
domain, err := h.manager.CreateDomain(r.Context(), userAuth.AccountId, req.Domain)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, domainToApi(domain))
|
||||
}
|
||||
|
||||
func (h *handler) deleteCustomDomain(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
domainID := mux.Vars(r)["domainId"]
|
||||
if domainID == "" {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "domain ID is required"), w)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.manager.DeleteDomain(r.Context(), userAuth.AccountId, domainID); err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *handler) triggerCustomDomainValidation(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
domainID := mux.Vars(r)["domainId"]
|
||||
if domainID == "" {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "domain ID is required"), w)
|
||||
return
|
||||
}
|
||||
|
||||
go h.manager.ValidateDomain(userAuth.AccountId, domainID)
|
||||
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}
|
||||
239
management/internals/modules/reverseproxy/domain/manager.go
Normal file
239
management/internals/modules/reverseproxy/domain/manager.go
Normal file
@@ -0,0 +1,239 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type domainType string
|
||||
|
||||
const (
|
||||
TypeFree domainType = "free"
|
||||
TypeCustom domainType = "custom"
|
||||
)
|
||||
|
||||
type Domain struct {
|
||||
ID string `gorm:"unique;primaryKey;autoIncrement"`
|
||||
Domain string `gorm:"unique"` // Domain records must be unique, this avoids domain reuse across accounts.
|
||||
AccountID string `gorm:"index"`
|
||||
Type domainType `gorm:"-"`
|
||||
Validated bool
|
||||
}
|
||||
|
||||
type store interface {
|
||||
GetAccount(ctx context.Context, accountID string) (*types.Account, error)
|
||||
|
||||
GetCustomDomain(ctx context.Context, accountID string, domainID string) (*Domain, error)
|
||||
ListFreeDomains(ctx context.Context, accountID string) ([]string, error)
|
||||
ListCustomDomains(ctx context.Context, accountID string) ([]*Domain, error)
|
||||
CreateCustomDomain(ctx context.Context, accountID string, domainName string, validated bool) (*Domain, error)
|
||||
UpdateCustomDomain(ctx context.Context, accountID string, d *Domain) (*Domain, error)
|
||||
DeleteCustomDomain(ctx context.Context, accountID string, domainID string) error
|
||||
}
|
||||
|
||||
type proxyURLProvider interface {
|
||||
GetConnectedProxyURLs() []string
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
store store
|
||||
validator Validator
|
||||
proxyURLProvider proxyURLProvider
|
||||
}
|
||||
|
||||
func NewManager(store store, proxyURLProvider proxyURLProvider) Manager {
|
||||
return Manager{
|
||||
store: store,
|
||||
proxyURLProvider: proxyURLProvider,
|
||||
validator: Validator{
|
||||
resolver: net.DefaultResolver,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m Manager) GetDomains(ctx context.Context, accountID string) ([]*Domain, error) {
|
||||
domains, err := m.store.ListCustomDomains(ctx, accountID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list custom domains: %w", err)
|
||||
}
|
||||
|
||||
var ret []*Domain
|
||||
|
||||
// Add connected proxy clusters as free domains.
|
||||
// The cluster address itself is the free domain base (e.g., "eu.proxy.netbird.io").
|
||||
allowList := m.proxyURLAllowList()
|
||||
log.WithFields(log.Fields{
|
||||
"accountID": accountID,
|
||||
"proxyAllowList": allowList,
|
||||
}).Debug("getting domains with proxy allow list")
|
||||
|
||||
for _, cluster := range allowList {
|
||||
ret = append(ret, &Domain{
|
||||
Domain: cluster,
|
||||
AccountID: accountID,
|
||||
Type: TypeFree,
|
||||
Validated: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Add custom domains.
|
||||
for _, domain := range domains {
|
||||
ret = append(ret, &Domain{
|
||||
ID: domain.ID,
|
||||
Domain: domain.Domain,
|
||||
AccountID: accountID,
|
||||
Type: TypeCustom,
|
||||
Validated: domain.Validated,
|
||||
})
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (m Manager) CreateDomain(ctx context.Context, accountID, domainName string) (*Domain, error) {
|
||||
// Attempt an initial validation; however, a failure is still acceptable for creation
|
||||
// because the user may not yet have configured their DNS records, or the DNS update
|
||||
// has not yet reached the servers that are queried by the validation resolver.
|
||||
var validated bool
|
||||
if m.validator.IsValid(ctx, domainName, m.proxyURLAllowList()) {
|
||||
validated = true
|
||||
}
|
||||
|
||||
d, err := m.store.CreateCustomDomain(ctx, accountID, domainName, validated)
|
||||
if err != nil {
|
||||
return d, fmt.Errorf("create domain in store: %w", err)
|
||||
}
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (m Manager) DeleteDomain(ctx context.Context, accountID, domainID string) error {
|
||||
if err := m.store.DeleteCustomDomain(ctx, accountID, domainID); err != nil {
|
||||
// TODO: check for "no records" type error. Because that is a success condition.
|
||||
return fmt.Errorf("delete domain from store: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Manager) ValidateDomain(accountID, domainID string) {
|
||||
log.WithFields(log.Fields{
|
||||
"accountID": accountID,
|
||||
"domainID": domainID,
|
||||
}).Info("starting domain validation")
|
||||
|
||||
d, err := m.store.GetCustomDomain(context.Background(), accountID, domainID)
|
||||
if err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"accountID": accountID,
|
||||
"domainID": domainID,
|
||||
}).WithError(err).Error("get custom domain from store")
|
||||
return
|
||||
}
|
||||
|
||||
allowList := m.proxyURLAllowList()
|
||||
log.WithFields(log.Fields{
|
||||
"accountID": accountID,
|
||||
"domainID": domainID,
|
||||
"domain": d.Domain,
|
||||
"proxyAllowList": allowList,
|
||||
}).Info("validating domain against proxy allow list")
|
||||
|
||||
if m.validator.IsValid(context.Background(), d.Domain, allowList) {
|
||||
log.WithFields(log.Fields{
|
||||
"accountID": accountID,
|
||||
"domainID": domainID,
|
||||
"domain": d.Domain,
|
||||
}).Info("domain validated successfully")
|
||||
d.Validated = true
|
||||
if _, err := m.store.UpdateCustomDomain(context.Background(), accountID, d); err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"accountID": accountID,
|
||||
"domainID": domainID,
|
||||
"domain": d.Domain,
|
||||
}).WithError(err).Error("update custom domain in store")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
log.WithFields(log.Fields{
|
||||
"accountID": accountID,
|
||||
"domainID": domainID,
|
||||
"domain": d.Domain,
|
||||
"proxyAllowList": allowList,
|
||||
}).Warn("domain validation failed - CNAME does not match any connected proxy")
|
||||
}
|
||||
}
|
||||
|
||||
// proxyURLAllowList retrieves a list of currently connected proxies and
|
||||
// their URLs (as reported by the proxy servers). It performs some clean
|
||||
// up on those URLs to attempt to retrieve domain names as we would
|
||||
// expect to see them in a validation check.
|
||||
func (m Manager) proxyURLAllowList() []string {
|
||||
var reverseProxyAddresses []string
|
||||
if m.proxyURLProvider != nil {
|
||||
reverseProxyAddresses = m.proxyURLProvider.GetConnectedProxyURLs()
|
||||
}
|
||||
var allowedProxyURLs []string
|
||||
for _, addr := range reverseProxyAddresses {
|
||||
if addr == "" {
|
||||
continue
|
||||
}
|
||||
host := extractHostFromAddress(addr)
|
||||
if host != "" {
|
||||
allowedProxyURLs = append(allowedProxyURLs, host)
|
||||
}
|
||||
}
|
||||
return allowedProxyURLs
|
||||
}
|
||||
|
||||
// extractHostFromAddress extracts the hostname from an address string.
|
||||
// It handles both URL format (https://host:port) and plain hostname (host or host:port).
|
||||
func extractHostFromAddress(addr string) string {
|
||||
// If it looks like a URL with a scheme, parse it
|
||||
if strings.Contains(addr, "://") {
|
||||
proxyUrl, err := url.Parse(addr)
|
||||
if err != nil {
|
||||
log.WithError(err).Debugf("failed to parse proxy URL %s", addr)
|
||||
return ""
|
||||
}
|
||||
host, _, err := net.SplitHostPort(proxyUrl.Host)
|
||||
if err != nil {
|
||||
return proxyUrl.Host
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// Otherwise treat as hostname or host:port
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
// No port, use as-is
|
||||
return addr
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// DeriveClusterFromDomain determines the proxy cluster for a given domain.
|
||||
// For free domains (those ending with a known cluster suffix), the cluster is extracted from the domain.
|
||||
// For custom domains, the cluster is determined by looking up the CNAME target.
|
||||
func (m Manager) DeriveClusterFromDomain(ctx context.Context, domain string) (string, error) {
|
||||
allowList := m.proxyURLAllowList()
|
||||
if len(allowList) == 0 {
|
||||
return "", fmt.Errorf("no proxy clusters available")
|
||||
}
|
||||
|
||||
if cluster, ok := ExtractClusterFromFreeDomain(domain, allowList); ok {
|
||||
return cluster, nil
|
||||
}
|
||||
|
||||
cluster, valid := m.validator.ValidateWithCluster(ctx, domain, allowList)
|
||||
if valid {
|
||||
return cluster, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("domain %s does not match any available proxy cluster", domain)
|
||||
}
|
||||
100
management/internals/modules/reverseproxy/domain/validator.go
Normal file
100
management/internals/modules/reverseproxy/domain/validator.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type resolver interface {
|
||||
LookupCNAME(context.Context, string) (string, error)
|
||||
}
|
||||
|
||||
type Validator struct {
|
||||
resolver resolver
|
||||
}
|
||||
|
||||
// NewValidator initializes a validator with a specific DNS resolver.
|
||||
// If a Validator is used without specifying a resolver, then it will
|
||||
// use the net.DefaultResolver.
|
||||
func NewValidator(resolver resolver) *Validator {
|
||||
return &Validator{
|
||||
resolver: resolver,
|
||||
}
|
||||
}
|
||||
|
||||
// IsValid looks up the CNAME record for the passed domain with a prefix
|
||||
// and compares it against the acceptable domains.
|
||||
// If the returned CNAME matches any accepted domain, it will return true,
|
||||
// otherwise, including in the event of a DNS error, it will return false.
|
||||
// The comparison is very simple, so wildcards will not match if included
|
||||
// in the acceptable domain list.
|
||||
func (v *Validator) IsValid(ctx context.Context, domain string, accept []string) bool {
|
||||
_, valid := v.ValidateWithCluster(ctx, domain, accept)
|
||||
return valid
|
||||
}
|
||||
|
||||
// ValidateWithCluster validates a custom domain and returns the matched cluster address.
|
||||
// Returns the cluster address and true if valid, or empty string and false if invalid.
|
||||
func (v *Validator) ValidateWithCluster(ctx context.Context, domain string, accept []string) (string, bool) {
|
||||
if v.resolver == nil {
|
||||
v.resolver = net.DefaultResolver
|
||||
}
|
||||
|
||||
lookupDomain := "validation." + domain
|
||||
log.WithFields(log.Fields{
|
||||
"domain": domain,
|
||||
"lookupDomain": lookupDomain,
|
||||
"acceptList": accept,
|
||||
}).Debug("looking up CNAME for domain validation")
|
||||
|
||||
cname, err := v.resolver.LookupCNAME(ctx, lookupDomain)
|
||||
if err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"domain": domain,
|
||||
"lookupDomain": lookupDomain,
|
||||
}).WithError(err).Warn("CNAME lookup failed for domain validation")
|
||||
return "", false
|
||||
}
|
||||
|
||||
nakedCNAME := strings.TrimSuffix(cname, ".")
|
||||
log.WithFields(log.Fields{
|
||||
"domain": domain,
|
||||
"cname": cname,
|
||||
"nakedCNAME": nakedCNAME,
|
||||
"acceptList": accept,
|
||||
}).Debug("CNAME lookup result for domain validation")
|
||||
|
||||
for _, acceptDomain := range accept {
|
||||
normalizedAccept := strings.TrimSuffix(acceptDomain, ".")
|
||||
if nakedCNAME == normalizedAccept {
|
||||
log.WithFields(log.Fields{
|
||||
"domain": domain,
|
||||
"cname": nakedCNAME,
|
||||
"cluster": acceptDomain,
|
||||
}).Info("domain CNAME matched cluster")
|
||||
return acceptDomain, true
|
||||
}
|
||||
}
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"domain": domain,
|
||||
"cname": nakedCNAME,
|
||||
"acceptList": accept,
|
||||
}).Warn("domain CNAME does not match any accepted cluster")
|
||||
return "", false
|
||||
}
|
||||
|
||||
// ExtractClusterFromFreeDomain extracts the cluster address from a free domain.
|
||||
// Free domains have the format: <name>.<nonce>.<cluster> (e.g., myapp.abc123.eu.proxy.netbird.io)
|
||||
// It matches the domain suffix against available clusters and returns the matching cluster.
|
||||
func ExtractClusterFromFreeDomain(domain string, availableClusters []string) (string, bool) {
|
||||
for _, cluster := range availableClusters {
|
||||
if strings.HasSuffix(domain, "."+cluster) {
|
||||
return cluster, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package domain_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
|
||||
)
|
||||
|
||||
type resolver struct {
|
||||
CNAME string
|
||||
}
|
||||
|
||||
func (r resolver) LookupCNAME(_ context.Context, _ string) (string, error) {
|
||||
return r.CNAME, nil
|
||||
}
|
||||
|
||||
func TestIsValid(t *testing.T) {
|
||||
tests := map[string]struct {
|
||||
resolver interface {
|
||||
LookupCNAME(context.Context, string) (string, error)
|
||||
}
|
||||
domain string
|
||||
accept []string
|
||||
expect bool
|
||||
}{
|
||||
"match": {
|
||||
resolver: resolver{"bar.example.com."}, // Including trailing "." in response.
|
||||
domain: "foo.example.com",
|
||||
accept: []string{"bar.example.com"},
|
||||
expect: true,
|
||||
},
|
||||
"no match": {
|
||||
resolver: resolver{"invalid"},
|
||||
domain: "foo.example.com",
|
||||
accept: []string{"bar.example.com"},
|
||||
expect: false,
|
||||
},
|
||||
"accept trailing dot": {
|
||||
resolver: resolver{"bar.example.com."},
|
||||
domain: "foo.example.com",
|
||||
accept: []string{"bar.example.com."}, // Including trailing "." in accept.
|
||||
expect: true,
|
||||
},
|
||||
}
|
||||
|
||||
for name, test := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
validator := domain.NewValidator(test.resolver)
|
||||
actual := validator.IsValid(t.Context(), test.domain, test.accept)
|
||||
if test.expect != actual {
|
||||
t.Errorf("Incorrect return value:\nexpect: %v\nactual: %v", test.expect, actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
15
management/internals/modules/reverseproxy/interface.go
Normal file
15
management/internals/modules/reverseproxy/interface.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package reverseproxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
type Manager interface {
|
||||
GetAllReverseProxies(ctx context.Context, accountID, userID string) ([]*ReverseProxy, error)
|
||||
GetReverseProxy(ctx context.Context, accountID, userID, reverseProxyID string) (*ReverseProxy, error)
|
||||
CreateReverseProxy(ctx context.Context, accountID, userID string, reverseProxy *ReverseProxy) (*ReverseProxy, error)
|
||||
UpdateReverseProxy(ctx context.Context, accountID, userID string, reverseProxy *ReverseProxy) (*ReverseProxy, error)
|
||||
DeleteReverseProxy(ctx context.Context, accountID, userID, reverseProxyID string) error
|
||||
SetCertificateIssuedAt(ctx context.Context, accountID, reverseProxyID string) error
|
||||
SetStatus(ctx context.Context, accountID, reverseProxyID string, status ProxyStatus) error
|
||||
}
|
||||
170
management/internals/modules/reverseproxy/manager/api.go
Normal file
170
management/internals/modules/reverseproxy/manager/api.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
|
||||
accesslogsmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs/manager"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
|
||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
"github.com/netbirdio/netbird/shared/management/http/util"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
manager reverseproxy.Manager
|
||||
}
|
||||
|
||||
// RegisterEndpoints registers all reverse proxy HTTP endpoints.
|
||||
func RegisterEndpoints(manager reverseproxy.Manager, domainManager domain.Manager, accessLogsManager accesslogs.Manager, router *mux.Router) {
|
||||
h := &handler{
|
||||
manager: manager,
|
||||
}
|
||||
|
||||
domainRouter := router.PathPrefix("/reverse-proxies").Subrouter()
|
||||
domain.RegisterEndpoints(domainRouter, domainManager)
|
||||
|
||||
accesslogsmanager.RegisterEndpoints(router, accessLogsManager)
|
||||
|
||||
router.HandleFunc("/reverse-proxies", h.getAllReverseProxies).Methods("GET", "OPTIONS")
|
||||
router.HandleFunc("/reverse-proxies", h.createReverseProxy).Methods("POST", "OPTIONS")
|
||||
router.HandleFunc("/reverse-proxies/{proxyId}", h.getReverseProxy).Methods("GET", "OPTIONS")
|
||||
router.HandleFunc("/reverse-proxies/{proxyId}", h.updateReverseProxy).Methods("PUT", "OPTIONS")
|
||||
router.HandleFunc("/reverse-proxies/{proxyId}", h.deleteReverseProxy).Methods("DELETE", "OPTIONS")
|
||||
}
|
||||
|
||||
func (h *handler) getAllReverseProxies(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
allReverseProxies, err := h.manager.GetAllReverseProxies(r.Context(), userAuth.AccountId, userAuth.UserId)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
apiReverseProxies := make([]*api.ReverseProxy, 0, len(allReverseProxies))
|
||||
for _, reverseProxy := range allReverseProxies {
|
||||
apiReverseProxies = append(apiReverseProxies, reverseProxy.ToAPIResponse())
|
||||
}
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, apiReverseProxies)
|
||||
}
|
||||
|
||||
func (h *handler) createReverseProxy(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
var req api.ReverseProxyRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
|
||||
return
|
||||
}
|
||||
|
||||
reverseProxy := new(reverseproxy.ReverseProxy)
|
||||
reverseProxy.FromAPIRequest(&req, userAuth.AccountId)
|
||||
|
||||
if err = reverseProxy.Validate(); err != nil {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "%s", err.Error()), w)
|
||||
return
|
||||
}
|
||||
|
||||
createdReverseProxy, err := h.manager.CreateReverseProxy(r.Context(), userAuth.AccountId, userAuth.UserId, reverseProxy)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, createdReverseProxy.ToAPIResponse())
|
||||
}
|
||||
|
||||
func (h *handler) getReverseProxy(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
reverseProxyID := mux.Vars(r)["proxyId"]
|
||||
if reverseProxyID == "" {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "reverse proxy ID is required"), w)
|
||||
return
|
||||
}
|
||||
|
||||
reverseProxy, err := h.manager.GetReverseProxy(r.Context(), userAuth.AccountId, userAuth.UserId, reverseProxyID)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, reverseProxy.ToAPIResponse())
|
||||
}
|
||||
|
||||
func (h *handler) updateReverseProxy(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
reverseProxyID := mux.Vars(r)["proxyId"]
|
||||
if reverseProxyID == "" {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "reverse proxy ID is required"), w)
|
||||
return
|
||||
}
|
||||
|
||||
var req api.ReverseProxyRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
util.WriteErrorResponse("couldn't parse JSON request", http.StatusBadRequest, w)
|
||||
return
|
||||
}
|
||||
|
||||
reverseProxy := new(reverseproxy.ReverseProxy)
|
||||
reverseProxy.ID = reverseProxyID
|
||||
reverseProxy.FromAPIRequest(&req, userAuth.AccountId)
|
||||
|
||||
if err = reverseProxy.Validate(); err != nil {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "%s", err.Error()), w)
|
||||
return
|
||||
}
|
||||
|
||||
updatedReverseProxy, err := h.manager.UpdateReverseProxy(r.Context(), userAuth.AccountId, userAuth.UserId, reverseProxy)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, updatedReverseProxy.ToAPIResponse())
|
||||
}
|
||||
|
||||
func (h *handler) deleteReverseProxy(w http.ResponseWriter, r *http.Request) {
|
||||
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
reverseProxyID := mux.Vars(r)["proxyId"]
|
||||
if reverseProxyID == "" {
|
||||
util.WriteError(r.Context(), status.Errorf(status.InvalidArgument, "reverse proxy ID is required"), w)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.manager.DeleteReverseProxy(r.Context(), userAuth.AccountId, userAuth.UserId, reverseProxyID); err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, util.EmptyObject{})
|
||||
}
|
||||
274
management/internals/modules/reverseproxy/manager/manager.go
Normal file
274
management/internals/modules/reverseproxy/manager/manager.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey"
|
||||
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
|
||||
"github.com/netbirdio/netbird/management/server/account"
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/modules"
|
||||
"github.com/netbirdio/netbird/management/server/permissions/operations"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
// ClusterDeriver derives the proxy cluster from a domain.
|
||||
type ClusterDeriver interface {
|
||||
DeriveClusterFromDomain(ctx context.Context, domain string) (string, error)
|
||||
}
|
||||
|
||||
type managerImpl struct {
|
||||
store store.Store
|
||||
accountManager account.Manager
|
||||
permissionsManager permissions.Manager
|
||||
proxyGRPCServer *nbgrpc.ProxyServiceServer
|
||||
clusterDeriver ClusterDeriver
|
||||
tokenStore *nbgrpc.OneTimeTokenStore
|
||||
}
|
||||
|
||||
// NewManager creates a new reverse proxy manager.
|
||||
func NewManager(store store.Store, accountManager account.Manager, permissionsManager permissions.Manager, proxyGRPCServer *nbgrpc.ProxyServiceServer, clusterDeriver ClusterDeriver, tokenStore *nbgrpc.OneTimeTokenStore) reverseproxy.Manager {
|
||||
return &managerImpl{
|
||||
store: store,
|
||||
accountManager: accountManager,
|
||||
permissionsManager: permissionsManager,
|
||||
proxyGRPCServer: proxyGRPCServer,
|
||||
clusterDeriver: clusterDeriver,
|
||||
tokenStore: tokenStore,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *managerImpl) GetAllReverseProxies(ctx context.Context, accountID, userID string) ([]*reverseproxy.ReverseProxy, error) {
|
||||
ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read)
|
||||
if err != nil {
|
||||
return nil, status.NewPermissionValidationError(err)
|
||||
}
|
||||
if !ok {
|
||||
return nil, status.NewPermissionDeniedError()
|
||||
}
|
||||
|
||||
return m.store.GetAccountReverseProxies(ctx, store.LockingStrengthNone, accountID)
|
||||
}
|
||||
|
||||
func (m *managerImpl) GetReverseProxy(ctx context.Context, accountID, userID, reverseProxyID string) (*reverseproxy.ReverseProxy, error) {
|
||||
ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read)
|
||||
if err != nil {
|
||||
return nil, status.NewPermissionValidationError(err)
|
||||
}
|
||||
if !ok {
|
||||
return nil, status.NewPermissionDeniedError()
|
||||
}
|
||||
|
||||
return m.store.GetReverseProxyByID(ctx, store.LockingStrengthNone, accountID, reverseProxyID)
|
||||
}
|
||||
|
||||
func (m *managerImpl) CreateReverseProxy(ctx context.Context, accountID, userID string, reverseProxy *reverseproxy.ReverseProxy) (*reverseproxy.ReverseProxy, error) {
|
||||
ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Create)
|
||||
if err != nil {
|
||||
return nil, status.NewPermissionValidationError(err)
|
||||
}
|
||||
if !ok {
|
||||
return nil, status.NewPermissionDeniedError()
|
||||
}
|
||||
|
||||
var proxyCluster string
|
||||
if m.clusterDeriver != nil {
|
||||
proxyCluster, err = m.clusterDeriver.DeriveClusterFromDomain(ctx, reverseProxy.Domain)
|
||||
if err != nil {
|
||||
log.WithError(err).Warnf("could not derive cluster from domain %s, updates will broadcast to all proxies", reverseProxy.Domain)
|
||||
}
|
||||
}
|
||||
|
||||
authConfig := reverseProxy.Auth
|
||||
|
||||
reverseProxy = reverseproxy.NewReverseProxy(accountID, reverseProxy.Name, reverseProxy.Domain, proxyCluster, reverseProxy.Targets, reverseProxy.Enabled)
|
||||
|
||||
reverseProxy.Auth = authConfig
|
||||
|
||||
// Generate session JWT signing keys
|
||||
keyPair, err := sessionkey.GenerateKeyPair()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate session keys: %w", err)
|
||||
}
|
||||
reverseProxy.SessionPrivateKey = keyPair.PrivateKey
|
||||
reverseProxy.SessionPublicKey = keyPair.PublicKey
|
||||
|
||||
err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||
// Check for duplicate domain
|
||||
existingReverseProxy, err := transaction.GetReverseProxyByDomain(ctx, accountID, reverseProxy.Domain)
|
||||
if err != nil {
|
||||
if sErr, ok := status.FromError(err); !ok || sErr.Type() != status.NotFound {
|
||||
return fmt.Errorf("failed to check existing reverse proxy: %w", err)
|
||||
}
|
||||
}
|
||||
if existingReverseProxy != nil {
|
||||
return status.Errorf(status.AlreadyExists, "reverse proxy with domain %s already exists", reverseProxy.Domain)
|
||||
}
|
||||
|
||||
if err = transaction.CreateReverseProxy(ctx, reverseProxy); err != nil {
|
||||
return fmt.Errorf("failed to create reverse proxy: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
token, err := m.tokenStore.GenerateToken(accountID, reverseProxy.ID, 5*time.Minute)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate authentication token: %w", err)
|
||||
}
|
||||
|
||||
m.accountManager.StoreEvent(ctx, userID, reverseProxy.ID, accountID, activity.ReverseProxyCreated, reverseProxy.EventMeta())
|
||||
|
||||
m.proxyGRPCServer.SendReverseProxyUpdateToCluster(reverseProxy.ToProtoMapping(reverseproxy.Create, token, m.proxyGRPCServer.GetOIDCValidationConfig()), reverseProxy.ProxyCluster)
|
||||
|
||||
return reverseProxy, nil
|
||||
}
|
||||
|
||||
func (m *managerImpl) UpdateReverseProxy(ctx context.Context, accountID, userID string, reverseProxy *reverseproxy.ReverseProxy) (*reverseproxy.ReverseProxy, error) {
|
||||
ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Update)
|
||||
if err != nil {
|
||||
return nil, status.NewPermissionValidationError(err)
|
||||
}
|
||||
if !ok {
|
||||
return nil, status.NewPermissionDeniedError()
|
||||
}
|
||||
|
||||
var oldCluster string
|
||||
var domainChanged bool
|
||||
|
||||
err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||
existingReverseProxy, err := transaction.GetReverseProxyByID(ctx, store.LockingStrengthUpdate, accountID, reverseProxy.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
oldCluster = existingReverseProxy.ProxyCluster
|
||||
|
||||
if existingReverseProxy.Domain != reverseProxy.Domain {
|
||||
domainChanged = true
|
||||
conflictReverseProxy, err := transaction.GetReverseProxyByDomain(ctx, accountID, reverseProxy.Domain)
|
||||
if err != nil {
|
||||
if sErr, ok := status.FromError(err); !ok || sErr.Type() != status.NotFound {
|
||||
return fmt.Errorf("check existing reverse proxy: %w", err)
|
||||
}
|
||||
}
|
||||
if conflictReverseProxy != nil && conflictReverseProxy.ID != reverseProxy.ID {
|
||||
return status.Errorf(status.AlreadyExists, "reverse proxy with domain %s already exists", reverseProxy.Domain)
|
||||
}
|
||||
|
||||
if m.clusterDeriver != nil {
|
||||
newCluster, err := m.clusterDeriver.DeriveClusterFromDomain(ctx, reverseProxy.Domain)
|
||||
if err != nil {
|
||||
log.WithError(err).Warnf("could not derive cluster from domain %s", reverseProxy.Domain)
|
||||
}
|
||||
reverseProxy.ProxyCluster = newCluster
|
||||
}
|
||||
} else {
|
||||
reverseProxy.ProxyCluster = existingReverseProxy.ProxyCluster
|
||||
}
|
||||
|
||||
reverseProxy.Meta = existingReverseProxy.Meta
|
||||
|
||||
if err = transaction.UpdateReverseProxy(ctx, reverseProxy); err != nil {
|
||||
return fmt.Errorf("update reverse proxy: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
m.accountManager.StoreEvent(ctx, userID, reverseProxy.ID, accountID, activity.ReverseProxyUpdated, reverseProxy.EventMeta())
|
||||
|
||||
oidcConfig := m.proxyGRPCServer.GetOIDCValidationConfig()
|
||||
if domainChanged && oldCluster != reverseProxy.ProxyCluster {
|
||||
m.proxyGRPCServer.SendReverseProxyUpdateToCluster(reverseProxy.ToProtoMapping(reverseproxy.Delete, "", oidcConfig), oldCluster)
|
||||
m.proxyGRPCServer.SendReverseProxyUpdateToCluster(reverseProxy.ToProtoMapping(reverseproxy.Create, "", oidcConfig), reverseProxy.ProxyCluster)
|
||||
} else {
|
||||
m.proxyGRPCServer.SendReverseProxyUpdateToCluster(reverseProxy.ToProtoMapping(reverseproxy.Update, "", oidcConfig), reverseProxy.ProxyCluster)
|
||||
}
|
||||
|
||||
return reverseProxy, nil
|
||||
}
|
||||
|
||||
func (m *managerImpl) DeleteReverseProxy(ctx context.Context, accountID, userID, reverseProxyID string) error {
|
||||
ok, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete)
|
||||
if err != nil {
|
||||
return status.NewPermissionValidationError(err)
|
||||
}
|
||||
if !ok {
|
||||
return status.NewPermissionDeniedError()
|
||||
}
|
||||
|
||||
var reverseProxy *reverseproxy.ReverseProxy
|
||||
err = m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||
var err error
|
||||
reverseProxy, err = transaction.GetReverseProxyByID(ctx, store.LockingStrengthUpdate, accountID, reverseProxyID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = transaction.DeleteReverseProxy(ctx, accountID, reverseProxyID); err != nil {
|
||||
return fmt.Errorf("failed to delete reverse proxy: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.accountManager.StoreEvent(ctx, userID, reverseProxyID, accountID, activity.ReverseProxyDeleted, reverseProxy.EventMeta())
|
||||
|
||||
m.proxyGRPCServer.SendReverseProxyUpdateToCluster(reverseProxy.ToProtoMapping(reverseproxy.Delete, "", m.proxyGRPCServer.GetOIDCValidationConfig()), reverseProxy.ProxyCluster)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetCertificateIssuedAt sets the certificate issued timestamp to the current time.
|
||||
// Call this when receiving a gRPC notification that the certificate was issued.
|
||||
func (m *managerImpl) SetCertificateIssuedAt(ctx context.Context, accountID, reverseProxyID string) error {
|
||||
return m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||
proxy, err := transaction.GetReverseProxyByID(ctx, store.LockingStrengthUpdate, accountID, reverseProxyID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get reverse proxy: %w", err)
|
||||
}
|
||||
|
||||
proxy.Meta.CertificateIssuedAt = time.Now()
|
||||
|
||||
if err = transaction.UpdateReverseProxy(ctx, proxy); err != nil {
|
||||
return fmt.Errorf("failed to update reverse proxy certificate timestamp: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// SetStatus updates the status of the reverse proxy (e.g., "active", "tunnel_not_created", etc.)
|
||||
func (m *managerImpl) SetStatus(ctx context.Context, accountID, reverseProxyID string, status reverseproxy.ProxyStatus) error {
|
||||
return m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||
proxy, err := transaction.GetReverseProxyByID(ctx, store.LockingStrengthUpdate, accountID, reverseProxyID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get reverse proxy: %w", err)
|
||||
}
|
||||
|
||||
proxy.Meta.Status = string(status)
|
||||
|
||||
if err = transaction.UpdateReverseProxy(ctx, proxy); err != nil {
|
||||
return fmt.Errorf("failed to update reverse proxy status: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
365
management/internals/modules/reverseproxy/reverseproxy.go
Normal file
365
management/internals/modules/reverseproxy/reverseproxy.go
Normal file
@@ -0,0 +1,365 @@
|
||||
package reverseproxy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/util/crypt"
|
||||
"github.com/rs/xid"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
type Operation string
|
||||
|
||||
const (
|
||||
Create Operation = "create"
|
||||
Update Operation = "update"
|
||||
Delete Operation = "delete"
|
||||
)
|
||||
|
||||
type ProxyStatus string
|
||||
|
||||
const (
|
||||
StatusPending ProxyStatus = "pending"
|
||||
StatusActive ProxyStatus = "active"
|
||||
StatusTunnelNotCreated ProxyStatus = "tunnel_not_created"
|
||||
StatusCertificatePending ProxyStatus = "certificate_pending"
|
||||
StatusCertificateFailed ProxyStatus = "certificate_failed"
|
||||
StatusError ProxyStatus = "error"
|
||||
|
||||
TargetTypePeer = "peer"
|
||||
TargetTypeResource = "resource"
|
||||
)
|
||||
|
||||
type Target struct {
|
||||
Path *string `json:"path,omitempty"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Protocol string `json:"protocol"`
|
||||
TargetId string `json:"target_id"`
|
||||
TargetType string `json:"target_type"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type PasswordAuthConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type PINAuthConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Pin string `json:"pin"`
|
||||
}
|
||||
|
||||
type BearerAuthConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
DistributionGroups []string `json:"distribution_groups,omitempty" gorm:"serializer:json"`
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
PasswordAuth *PasswordAuthConfig `json:"password_auth,omitempty" gorm:"serializer:json"`
|
||||
PinAuth *PINAuthConfig `json:"pin_auth,omitempty" gorm:"serializer:json"`
|
||||
BearerAuth *BearerAuthConfig `json:"bearer_auth,omitempty" gorm:"serializer:json"`
|
||||
}
|
||||
|
||||
type OIDCValidationConfig struct {
|
||||
Issuer string
|
||||
Audiences []string
|
||||
KeysLocation string
|
||||
MaxTokenAgeSeconds int64
|
||||
}
|
||||
|
||||
type ReverseProxyMeta struct {
|
||||
CreatedAt time.Time
|
||||
CertificateIssuedAt time.Time
|
||||
Status string
|
||||
}
|
||||
|
||||
type ReverseProxy struct {
|
||||
ID string `gorm:"primaryKey"`
|
||||
AccountID string `gorm:"index"`
|
||||
Name string
|
||||
Domain string `gorm:"index"`
|
||||
ProxyCluster string `gorm:"index"`
|
||||
Targets []Target `gorm:"serializer:json"`
|
||||
Enabled bool
|
||||
Auth AuthConfig `gorm:"serializer:json"`
|
||||
Meta ReverseProxyMeta `gorm:"embedded;embeddedPrefix:meta_"`
|
||||
SessionPrivateKey string `gorm:"column:session_private_key"`
|
||||
SessionPublicKey string `gorm:"column:session_public_key"`
|
||||
}
|
||||
|
||||
func NewReverseProxy(accountID, name, domain, proxyCluster string, targets []Target, enabled bool) *ReverseProxy {
|
||||
return &ReverseProxy{
|
||||
ID: xid.New().String(),
|
||||
AccountID: accountID,
|
||||
Name: name,
|
||||
Domain: domain,
|
||||
ProxyCluster: proxyCluster,
|
||||
Targets: targets,
|
||||
Enabled: enabled,
|
||||
Meta: ReverseProxyMeta{
|
||||
CreatedAt: time.Now(),
|
||||
Status: string(StatusPending),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ReverseProxy) ToAPIResponse() *api.ReverseProxy {
|
||||
authConfig := api.ReverseProxyAuthConfig{}
|
||||
|
||||
if r.Auth.PasswordAuth != nil {
|
||||
authConfig.PasswordAuth = &api.PasswordAuthConfig{
|
||||
Enabled: r.Auth.PasswordAuth.Enabled,
|
||||
Password: r.Auth.PasswordAuth.Password,
|
||||
}
|
||||
}
|
||||
|
||||
if r.Auth.PinAuth != nil {
|
||||
authConfig.PinAuth = &api.PINAuthConfig{
|
||||
Enabled: r.Auth.PinAuth.Enabled,
|
||||
Pin: r.Auth.PinAuth.Pin,
|
||||
}
|
||||
}
|
||||
|
||||
if r.Auth.BearerAuth != nil {
|
||||
authConfig.BearerAuth = &api.BearerAuthConfig{
|
||||
Enabled: r.Auth.BearerAuth.Enabled,
|
||||
DistributionGroups: &r.Auth.BearerAuth.DistributionGroups,
|
||||
}
|
||||
}
|
||||
|
||||
// Convert internal targets to API targets
|
||||
apiTargets := make([]api.ReverseProxyTarget, 0, len(r.Targets))
|
||||
for _, target := range r.Targets {
|
||||
apiTargets = append(apiTargets, api.ReverseProxyTarget{
|
||||
Path: target.Path,
|
||||
Host: target.Host,
|
||||
Port: target.Port,
|
||||
Protocol: api.ReverseProxyTargetProtocol(target.Protocol),
|
||||
TargetId: target.TargetId,
|
||||
TargetType: api.ReverseProxyTargetTargetType(target.TargetType),
|
||||
Enabled: target.Enabled,
|
||||
})
|
||||
}
|
||||
|
||||
meta := api.ReverseProxyMeta{
|
||||
CreatedAt: r.Meta.CreatedAt,
|
||||
Status: api.ReverseProxyMetaStatus(r.Meta.Status),
|
||||
}
|
||||
|
||||
if !r.Meta.CertificateIssuedAt.IsZero() {
|
||||
meta.CertificateIssuedAt = &r.Meta.CertificateIssuedAt
|
||||
}
|
||||
|
||||
resp := &api.ReverseProxy{
|
||||
Id: r.ID,
|
||||
Name: r.Name,
|
||||
Domain: r.Domain,
|
||||
Targets: apiTargets,
|
||||
Enabled: r.Enabled,
|
||||
Auth: authConfig,
|
||||
Meta: meta,
|
||||
}
|
||||
|
||||
if r.ProxyCluster != "" {
|
||||
resp.ProxyCluster = &r.ProxyCluster
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
func (r *ReverseProxy) ToProtoMapping(operation Operation, authToken string, oidcConfig OIDCValidationConfig) *proto.ProxyMapping {
|
||||
pathMappings := make([]*proto.PathMapping, 0, len(r.Targets))
|
||||
for _, target := range r.Targets {
|
||||
if !target.Enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
path := "/"
|
||||
if target.Path != nil {
|
||||
path = *target.Path
|
||||
}
|
||||
|
||||
targetURL := url.URL{
|
||||
Scheme: target.Protocol,
|
||||
Host: target.Host,
|
||||
Path: path,
|
||||
}
|
||||
if target.Port > 0 {
|
||||
targetURL.Host = net.JoinHostPort(targetURL.Host, strconv.Itoa(target.Port))
|
||||
}
|
||||
|
||||
pathMappings = append(pathMappings, &proto.PathMapping{
|
||||
Path: path,
|
||||
Target: targetURL.String(),
|
||||
})
|
||||
}
|
||||
|
||||
auth := &proto.Authentication{
|
||||
SessionKey: r.SessionPublicKey,
|
||||
MaxSessionAgeSeconds: int64((time.Hour * 24).Seconds()),
|
||||
}
|
||||
|
||||
if r.Auth.PasswordAuth != nil && r.Auth.PasswordAuth.Enabled {
|
||||
auth.Password = true
|
||||
}
|
||||
|
||||
if r.Auth.PinAuth != nil && r.Auth.PinAuth.Enabled {
|
||||
auth.Pin = true
|
||||
}
|
||||
|
||||
if r.Auth.BearerAuth != nil && r.Auth.BearerAuth.Enabled {
|
||||
auth.Oidc = true
|
||||
}
|
||||
|
||||
return &proto.ProxyMapping{
|
||||
Type: operationToProtoType(operation),
|
||||
Id: r.ID,
|
||||
Domain: r.Domain,
|
||||
Path: pathMappings,
|
||||
AuthToken: authToken,
|
||||
Auth: auth,
|
||||
AccountId: r.AccountID,
|
||||
}
|
||||
}
|
||||
|
||||
func operationToProtoType(op Operation) proto.ProxyMappingUpdateType {
|
||||
switch op {
|
||||
case Create:
|
||||
return proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED
|
||||
case Update:
|
||||
return proto.ProxyMappingUpdateType_UPDATE_TYPE_MODIFIED
|
||||
case Delete:
|
||||
return proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED
|
||||
default:
|
||||
log.Fatalf("unknown operation type: %v", op)
|
||||
return proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ReverseProxy) FromAPIRequest(req *api.ReverseProxyRequest, accountID string) {
|
||||
r.Name = req.Name
|
||||
r.Domain = req.Domain
|
||||
r.AccountID = accountID
|
||||
|
||||
targets := make([]Target, 0, len(req.Targets))
|
||||
for _, apiTarget := range req.Targets {
|
||||
targets = append(targets, Target{
|
||||
Path: apiTarget.Path,
|
||||
Host: apiTarget.Host,
|
||||
Port: apiTarget.Port,
|
||||
Protocol: string(apiTarget.Protocol),
|
||||
TargetId: apiTarget.TargetId,
|
||||
TargetType: string(apiTarget.TargetType),
|
||||
Enabled: apiTarget.Enabled,
|
||||
})
|
||||
}
|
||||
r.Targets = targets
|
||||
|
||||
r.Enabled = req.Enabled
|
||||
|
||||
if req.Auth.PasswordAuth != nil {
|
||||
r.Auth.PasswordAuth = &PasswordAuthConfig{
|
||||
Enabled: req.Auth.PasswordAuth.Enabled,
|
||||
Password: req.Auth.PasswordAuth.Password,
|
||||
}
|
||||
}
|
||||
|
||||
if req.Auth.PinAuth != nil {
|
||||
r.Auth.PinAuth = &PINAuthConfig{
|
||||
Enabled: req.Auth.PinAuth.Enabled,
|
||||
Pin: req.Auth.PinAuth.Pin,
|
||||
}
|
||||
}
|
||||
|
||||
if req.Auth.BearerAuth != nil {
|
||||
bearerAuth := &BearerAuthConfig{
|
||||
Enabled: req.Auth.BearerAuth.Enabled,
|
||||
}
|
||||
if req.Auth.BearerAuth.DistributionGroups != nil {
|
||||
bearerAuth.DistributionGroups = *req.Auth.BearerAuth.DistributionGroups
|
||||
}
|
||||
r.Auth.BearerAuth = bearerAuth
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ReverseProxy) Validate() error {
|
||||
if r.Name == "" {
|
||||
return errors.New("reverse proxy name is required")
|
||||
}
|
||||
if len(r.Name) > 255 {
|
||||
return errors.New("reverse proxy name exceeds maximum length of 255 characters")
|
||||
}
|
||||
|
||||
if r.Domain == "" {
|
||||
return errors.New("reverse proxy domain is required")
|
||||
}
|
||||
|
||||
if len(r.Targets) == 0 {
|
||||
return errors.New("at least one target is required")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ReverseProxy) EventMeta() map[string]any {
|
||||
return map[string]any{"name": r.Name, "domain": r.Domain, "proxy_cluster": r.ProxyCluster}
|
||||
}
|
||||
|
||||
func (r *ReverseProxy) Copy() *ReverseProxy {
|
||||
targets := make([]Target, len(r.Targets))
|
||||
copy(targets, r.Targets)
|
||||
|
||||
return &ReverseProxy{
|
||||
ID: r.ID,
|
||||
AccountID: r.AccountID,
|
||||
Name: r.Name,
|
||||
Domain: r.Domain,
|
||||
ProxyCluster: r.ProxyCluster,
|
||||
Targets: targets,
|
||||
Enabled: r.Enabled,
|
||||
Auth: r.Auth,
|
||||
Meta: r.Meta,
|
||||
SessionPrivateKey: r.SessionPrivateKey,
|
||||
SessionPublicKey: r.SessionPublicKey,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ReverseProxy) EncryptSensitiveData(enc *crypt.FieldEncrypt) error {
|
||||
if enc == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if r.SessionPrivateKey != "" {
|
||||
var err error
|
||||
r.SessionPrivateKey, err = enc.Encrypt(r.SessionPrivateKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ReverseProxy) DecryptSensitiveData(enc *crypt.FieldEncrypt) error {
|
||||
if enc == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if r.SessionPrivateKey != "" {
|
||||
var err error
|
||||
r.SessionPrivateKey, err = enc.Decrypt(r.SessionPrivateKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package sessionkey
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/netbirdio/netbird/proxy/auth"
|
||||
)
|
||||
|
||||
type KeyPair struct {
|
||||
PrivateKey string
|
||||
PublicKey string
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
jwt.RegisteredClaims
|
||||
Method auth.Method `json:"method"`
|
||||
}
|
||||
|
||||
func GenerateKeyPair() (*KeyPair, error) {
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate ed25519 key: %w", err)
|
||||
}
|
||||
|
||||
return &KeyPair{
|
||||
PrivateKey: base64.StdEncoding.EncodeToString(priv),
|
||||
PublicKey: base64.StdEncoding.EncodeToString(pub),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func SignToken(privKeyB64, userID, domain string, method auth.Method, expiration time.Duration) (string, error) {
|
||||
privKeyBytes, err := base64.StdEncoding.DecodeString(privKeyB64)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decode private key: %w", err)
|
||||
}
|
||||
|
||||
if len(privKeyBytes) != ed25519.PrivateKeySize {
|
||||
return "", fmt.Errorf("invalid private key size: got %d, want %d", len(privKeyBytes), ed25519.PrivateKeySize)
|
||||
}
|
||||
|
||||
privKey := ed25519.PrivateKey(privKeyBytes)
|
||||
|
||||
now := time.Now()
|
||||
claims := Claims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: auth.SessionJWTIssuer,
|
||||
Subject: userID,
|
||||
Audience: jwt.ClaimStrings{domain},
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(expiration)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
NotBefore: jwt.NewNumericDate(now),
|
||||
},
|
||||
Method: method,
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
|
||||
signedToken, err := token.SignedString(privKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("sign token: %w", err)
|
||||
}
|
||||
|
||||
return signedToken, nil
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
grpcMiddleware "github.com/grpc-ecosystem/go-grpc-middleware/v2"
|
||||
@@ -21,6 +22,8 @@ import (
|
||||
"github.com/netbirdio/management-integrations/integrations"
|
||||
"github.com/netbirdio/netbird/encryption"
|
||||
"github.com/netbirdio/netbird/formatter/hook"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
|
||||
accesslogsmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs/manager"
|
||||
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
nbContext "github.com/netbirdio/netbird/management/server/context"
|
||||
@@ -92,7 +95,7 @@ func (s *BaseServer) EventStore() activity.Store {
|
||||
|
||||
func (s *BaseServer) APIHandler() http.Handler {
|
||||
return Create(s, func() http.Handler {
|
||||
httpAPIHandler, err := nbhttp.NewAPIHandler(context.Background(), s.AccountManager(), s.NetworksManager(), s.ResourcesManager(), s.RoutesManager(), s.GroupsManager(), s.GeoLocationManager(), s.AuthManager(), s.Metrics(), s.IntegratedValidator(), s.ProxyController(), s.PermissionsManager(), s.PeersManager(), s.SettingsManager(), s.ZonesManager(), s.RecordsManager(), s.NetworkMapController(), s.IdpManager())
|
||||
httpAPIHandler, err := nbhttp.NewAPIHandler(context.Background(), s.AccountManager(), s.NetworksManager(), s.ResourcesManager(), s.RoutesManager(), s.GroupsManager(), s.GeoLocationManager(), s.AuthManager(), s.Metrics(), s.IntegratedValidator(), s.ProxyController(), s.PermissionsManager(), s.PeersManager(), s.SettingsManager(), s.ZonesManager(), s.RecordsManager(), s.NetworkMapController(), s.IdpManager(), s.ReverseProxyManager(), s.ReverseProxyDomainManager(), s.AccessLogsManager(), s.ReverseProxyGRPCServer())
|
||||
if err != nil {
|
||||
log.Fatalf("failed to create API handler: %v", err)
|
||||
}
|
||||
@@ -150,10 +153,59 @@ func (s *BaseServer) GRPCServer() *grpc.Server {
|
||||
}
|
||||
mgmtProto.RegisterManagementServiceServer(gRPCAPIHandler, srv)
|
||||
|
||||
mgmtProto.RegisterProxyServiceServer(gRPCAPIHandler, s.ReverseProxyGRPCServer())
|
||||
log.Info("ProxyService registered on gRPC server")
|
||||
|
||||
return gRPCAPIHandler
|
||||
})
|
||||
}
|
||||
|
||||
func (s *BaseServer) ReverseProxyGRPCServer() *nbgrpc.ProxyServiceServer {
|
||||
return Create(s, func() *nbgrpc.ProxyServiceServer {
|
||||
proxyService := nbgrpc.NewProxyServiceServer(s.Store(), s.AccessLogsManager(), s.ProxyTokenStore(), s.proxyOIDCConfig(), s.PeersManager())
|
||||
s.AfterInit(func(s *BaseServer) {
|
||||
proxyService.SetProxyManager(s.ReverseProxyManager())
|
||||
})
|
||||
return proxyService
|
||||
})
|
||||
}
|
||||
|
||||
func (s *BaseServer) proxyOIDCConfig() nbgrpc.ProxyOIDCConfig {
|
||||
return Create(s, func() nbgrpc.ProxyOIDCConfig {
|
||||
// TODO: this is weird, double check
|
||||
// Build callback URL - this should be the management server's callback endpoint
|
||||
// For embedded IdP, derive from issuer. For external, use a configured value or derive from issuer.
|
||||
// The callback URL should be registered in the IdP's allowed redirect URIs for the dashboard client.
|
||||
callbackURL := strings.TrimSuffix(s.Config.HttpConfig.AuthIssuer, "/oauth2")
|
||||
callbackURL = callbackURL + "/api/oauth/callback"
|
||||
|
||||
return nbgrpc.ProxyOIDCConfig{
|
||||
Issuer: s.Config.HttpConfig.AuthIssuer,
|
||||
ClientID: "netbird-dashboard", // Reuse dashboard client
|
||||
Scopes: []string{"openid", "profile", "email"},
|
||||
CallbackURL: callbackURL,
|
||||
HMACKey: []byte(s.Config.DataStoreEncryptionKey), // Use the datastore encryption key for OIDC state HMACs, this should ensure all management instances are using the same key.
|
||||
Audience: s.Config.HttpConfig.AuthAudience,
|
||||
KeysLocation: s.Config.HttpConfig.AuthKeysLocation,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (s *BaseServer) ProxyTokenStore() *nbgrpc.OneTimeTokenStore {
|
||||
return Create(s, func() *nbgrpc.OneTimeTokenStore {
|
||||
tokenStore := nbgrpc.NewOneTimeTokenStore(1 * time.Minute)
|
||||
log.Info("One-time token store initialized for proxy authentication")
|
||||
return tokenStore
|
||||
})
|
||||
}
|
||||
|
||||
func (s *BaseServer) AccessLogsManager() accesslogs.Manager {
|
||||
return Create(s, func() accesslogs.Manager {
|
||||
accessLogManager := accesslogsmanager.NewManager(s.Store(), s.PermissionsManager(), s.GeoLocationManager())
|
||||
return accessLogManager
|
||||
})
|
||||
}
|
||||
|
||||
func loadTLSConfig(certFile string, certKey string) (*tls.Config, error) {
|
||||
// Load server's certificate and private key
|
||||
serverCert, err := tls.LoadX509KeyPair(certFile, certKey)
|
||||
|
||||
@@ -8,6 +8,9 @@ import (
|
||||
|
||||
"github.com/netbirdio/management-integrations/integrations"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/peers"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
|
||||
nbreverseproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/manager"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/zones"
|
||||
zonesManager "github.com/netbirdio/netbird/management/internals/modules/zones/manager"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/zones/records"
|
||||
@@ -69,14 +72,7 @@ func (s *BaseServer) UsersManager() users.Manager {
|
||||
func (s *BaseServer) SettingsManager() settings.Manager {
|
||||
return Create(s, func() settings.Manager {
|
||||
extraSettingsManager := integrations.NewManager(s.EventStore())
|
||||
|
||||
idpConfig := settings.IdpConfig{}
|
||||
if s.Config.EmbeddedIdP != nil && s.Config.EmbeddedIdP.Enabled {
|
||||
idpConfig.EmbeddedIdpEnabled = true
|
||||
idpConfig.LocalAuthDisabled = s.Config.EmbeddedIdP.LocalAuthDisabled
|
||||
}
|
||||
|
||||
return settings.NewManager(s.Store(), s.UsersManager(), extraSettingsManager, s.PermissionsManager(), idpConfig)
|
||||
return settings.NewManager(s.Store(), s.UsersManager(), extraSettingsManager, s.PermissionsManager())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -181,3 +177,17 @@ func (s *BaseServer) RecordsManager() records.Manager {
|
||||
return recordsManager.NewManager(s.Store(), s.AccountManager(), s.PermissionsManager())
|
||||
})
|
||||
}
|
||||
|
||||
func (s *BaseServer) ReverseProxyManager() reverseproxy.Manager {
|
||||
return Create(s, func() reverseproxy.Manager {
|
||||
domainMgr := s.ReverseProxyDomainManager()
|
||||
return nbreverseproxy.NewManager(s.Store(), s.AccountManager(), s.PermissionsManager(), s.ReverseProxyGRPCServer(), domainMgr, s.ProxyTokenStore())
|
||||
})
|
||||
}
|
||||
|
||||
func (s *BaseServer) ReverseProxyDomainManager() *domain.Manager {
|
||||
return Create(s, func() *domain.Manager {
|
||||
m := domain.NewManager(s.Store(), s.ReverseProxyGRPCServer())
|
||||
return &m
|
||||
})
|
||||
}
|
||||
|
||||
167
management/internals/shared/grpc/onetime_token.go
Normal file
167
management/internals/shared/grpc/onetime_token.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// OneTimeTokenStore manages short-lived, single-use authentication tokens
|
||||
// for proxy-to-management RPC authentication. Tokens are generated when
|
||||
// a reverse proxy is created and must be used exactly once by the proxy
|
||||
// to authenticate a subsequent RPC call.
|
||||
type OneTimeTokenStore struct {
|
||||
tokens map[string]*tokenMetadata
|
||||
mu sync.RWMutex
|
||||
cleanup *time.Ticker
|
||||
cleanupDone chan struct{}
|
||||
}
|
||||
|
||||
// tokenMetadata stores information about a one-time token
|
||||
type tokenMetadata struct {
|
||||
ReverseProxyID string
|
||||
AccountID string
|
||||
ExpiresAt time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// NewOneTimeTokenStore creates a new token store with automatic cleanup
|
||||
// of expired tokens. The cleanupInterval determines how often expired
|
||||
// tokens are removed from memory.
|
||||
func NewOneTimeTokenStore(cleanupInterval time.Duration) *OneTimeTokenStore {
|
||||
store := &OneTimeTokenStore{
|
||||
tokens: make(map[string]*tokenMetadata),
|
||||
cleanup: time.NewTicker(cleanupInterval),
|
||||
cleanupDone: make(chan struct{}),
|
||||
}
|
||||
|
||||
// Start background cleanup goroutine
|
||||
go store.cleanupExpired()
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
// GenerateToken creates a new cryptographically secure one-time token
|
||||
// with the specified TTL. The token is associated with a specific
|
||||
// accountID and reverseProxyID for validation purposes.
|
||||
//
|
||||
// Returns the generated token string or an error if random generation fails.
|
||||
func (s *OneTimeTokenStore) GenerateToken(accountID, reverseProxyID string, ttl time.Duration) (string, error) {
|
||||
// Generate 32 bytes (256 bits) of cryptographically secure random data
|
||||
randomBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(randomBytes); err != nil {
|
||||
return "", fmt.Errorf("failed to generate random token: %w", err)
|
||||
}
|
||||
|
||||
// Encode as URL-safe base64 for easy transmission in gRPC
|
||||
token := base64.URLEncoding.EncodeToString(randomBytes)
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.tokens[token] = &tokenMetadata{
|
||||
ReverseProxyID: reverseProxyID,
|
||||
AccountID: accountID,
|
||||
ExpiresAt: time.Now().Add(ttl),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
log.Debugf("Generated one-time token for proxy %s in account %s (expires in %s)",
|
||||
reverseProxyID, accountID, ttl)
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// ValidateAndConsume verifies the token against the provided accountID and
|
||||
// reverseProxyID, checks expiration, and then deletes it to enforce single-use.
|
||||
//
|
||||
// This method uses constant-time comparison to prevent timing attacks.
|
||||
//
|
||||
// Returns nil on success, or an error if:
|
||||
// - Token doesn't exist
|
||||
// - Token has expired
|
||||
// - Account ID doesn't match
|
||||
// - Reverse proxy ID doesn't match
|
||||
func (s *OneTimeTokenStore) ValidateAndConsume(token, accountID, reverseProxyID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
metadata, exists := s.tokens[token]
|
||||
if !exists {
|
||||
log.Warnf("Token validation failed: token not found (proxy: %s, account: %s)",
|
||||
reverseProxyID, accountID)
|
||||
return fmt.Errorf("invalid token")
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if time.Now().After(metadata.ExpiresAt) {
|
||||
delete(s.tokens, token)
|
||||
log.Warnf("Token validation failed: token expired (proxy: %s, account: %s)",
|
||||
reverseProxyID, accountID)
|
||||
return fmt.Errorf("token expired")
|
||||
}
|
||||
|
||||
// Validate account ID using constant-time comparison (prevents timing attacks)
|
||||
if subtle.ConstantTimeCompare([]byte(metadata.AccountID), []byte(accountID)) != 1 {
|
||||
log.Warnf("Token validation failed: account ID mismatch (expected: %s, got: %s)",
|
||||
metadata.AccountID, accountID)
|
||||
return fmt.Errorf("account ID mismatch")
|
||||
}
|
||||
|
||||
// Validate reverse proxy ID using constant-time comparison
|
||||
if subtle.ConstantTimeCompare([]byte(metadata.ReverseProxyID), []byte(reverseProxyID)) != 1 {
|
||||
log.Warnf("Token validation failed: reverse proxy ID mismatch (expected: %s, got: %s)",
|
||||
metadata.ReverseProxyID, reverseProxyID)
|
||||
return fmt.Errorf("reverse proxy ID mismatch")
|
||||
}
|
||||
|
||||
// Delete token immediately to enforce single-use
|
||||
delete(s.tokens, token)
|
||||
|
||||
log.Infof("Token validated and consumed for proxy %s in account %s",
|
||||
reverseProxyID, accountID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanupExpired removes expired tokens in the background to prevent memory leaks
|
||||
func (s *OneTimeTokenStore) cleanupExpired() {
|
||||
for {
|
||||
select {
|
||||
case <-s.cleanup.C:
|
||||
s.mu.Lock()
|
||||
now := time.Now()
|
||||
removed := 0
|
||||
for token, metadata := range s.tokens {
|
||||
if now.After(metadata.ExpiresAt) {
|
||||
delete(s.tokens, token)
|
||||
removed++
|
||||
}
|
||||
}
|
||||
if removed > 0 {
|
||||
log.Debugf("Cleaned up %d expired one-time tokens", removed)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
case <-s.cleanupDone:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close stops the cleanup goroutine and releases resources
|
||||
func (s *OneTimeTokenStore) Close() {
|
||||
s.cleanup.Stop()
|
||||
close(s.cleanupDone)
|
||||
}
|
||||
|
||||
// GetTokenCount returns the current number of tokens in the store (for debugging/metrics)
|
||||
func (s *OneTimeTokenStore) GetTokenCount() int {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return len(s.tokens)
|
||||
}
|
||||
741
management/internals/shared/grpc/proxy.go
Normal file
741
management/internals/shared/grpc/proxy.go
Normal file
@@ -0,0 +1,741 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/oauth2"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/peer"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/peers"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
proxyauth "github.com/netbirdio/netbird/proxy/auth"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
type ProxyOIDCConfig struct {
|
||||
Issuer string
|
||||
ClientID string
|
||||
Scopes []string
|
||||
CallbackURL string
|
||||
HMACKey []byte
|
||||
|
||||
Audience string
|
||||
KeysLocation string
|
||||
}
|
||||
|
||||
type reverseProxyStore interface {
|
||||
GetReverseProxies(ctx context.Context, lockStrength store.LockingStrength) ([]*reverseproxy.ReverseProxy, error)
|
||||
GetAccountReverseProxies(ctx context.Context, lockStrength store.LockingStrength, accountID string) ([]*reverseproxy.ReverseProxy, error)
|
||||
GetReverseProxyByID(ctx context.Context, lockStrength store.LockingStrength, accountID string, serviceID string) (*reverseproxy.ReverseProxy, error)
|
||||
}
|
||||
|
||||
type reverseProxyManager interface {
|
||||
SetCertificateIssuedAt(ctx context.Context, accountID, reverseProxyID string) error
|
||||
SetStatus(ctx context.Context, accountID, reverseProxyID string, status reverseproxy.ProxyStatus) error
|
||||
}
|
||||
|
||||
// ClusterInfo contains information about a proxy cluster.
|
||||
type ClusterInfo struct {
|
||||
Address string
|
||||
ConnectedProxies int
|
||||
}
|
||||
|
||||
// ProxyServiceServer implements the ProxyService gRPC server
|
||||
type ProxyServiceServer struct {
|
||||
proto.UnimplementedProxyServiceServer
|
||||
|
||||
// Map of connected proxies: proxy_id -> proxy connection
|
||||
connectedProxies sync.Map
|
||||
|
||||
// Map of cluster address -> set of proxy IDs
|
||||
clusterProxies sync.Map
|
||||
|
||||
// Channel for broadcasting reverse proxy updates to all proxies
|
||||
updatesChan chan *proto.ProxyMapping
|
||||
|
||||
// Store of reverse proxies
|
||||
reverseProxyStore reverseProxyStore
|
||||
|
||||
// Manager for access logs
|
||||
accessLogManager accesslogs.Manager
|
||||
|
||||
// Manager for reverse proxy operations
|
||||
reverseProxyManager reverseProxyManager
|
||||
|
||||
// Manager for peers
|
||||
peersManager peers.Manager
|
||||
|
||||
// Store for one-time authentication tokens
|
||||
tokenStore *OneTimeTokenStore
|
||||
|
||||
// OIDC configuration for proxy authentication
|
||||
oidcConfig ProxyOIDCConfig
|
||||
|
||||
// TODO: use database to store these instead?
|
||||
pkceVerifiers sync.Map
|
||||
}
|
||||
|
||||
// proxyConnection represents a connected proxy
|
||||
type proxyConnection struct {
|
||||
proxyID string
|
||||
address string
|
||||
stream proto.ProxyService_GetMappingUpdateServer
|
||||
sendChan chan *proto.ProxyMapping
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewProxyServiceServer creates a new proxy service server
|
||||
func NewProxyServiceServer(store reverseProxyStore, accessLogMgr accesslogs.Manager, tokenStore *OneTimeTokenStore, oidcConfig ProxyOIDCConfig, peersManager peers.Manager) *ProxyServiceServer {
|
||||
return &ProxyServiceServer{
|
||||
updatesChan: make(chan *proto.ProxyMapping, 100),
|
||||
reverseProxyStore: store,
|
||||
accessLogManager: accessLogMgr,
|
||||
oidcConfig: oidcConfig,
|
||||
tokenStore: tokenStore,
|
||||
peersManager: peersManager,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ProxyServiceServer) SetProxyManager(manager reverseProxyManager) {
|
||||
s.reverseProxyManager = manager
|
||||
}
|
||||
|
||||
// GetMappingUpdate handles the control stream with proxy clients
|
||||
func (s *ProxyServiceServer) GetMappingUpdate(req *proto.GetMappingUpdateRequest, stream proto.ProxyService_GetMappingUpdateServer) error {
|
||||
ctx := stream.Context()
|
||||
|
||||
peerInfo := ""
|
||||
if p, ok := peer.FromContext(ctx); ok {
|
||||
peerInfo = p.Addr.String()
|
||||
}
|
||||
|
||||
log.Infof("New proxy connection from %s", peerInfo)
|
||||
|
||||
proxyID := req.GetProxyId()
|
||||
if proxyID == "" {
|
||||
return status.Errorf(codes.InvalidArgument, "proxy_id is required")
|
||||
}
|
||||
|
||||
proxyAddress := req.GetAddress()
|
||||
log.WithFields(log.Fields{
|
||||
"proxy_id": proxyID,
|
||||
"address": proxyAddress,
|
||||
"version": req.GetVersion(),
|
||||
"started": req.GetStartedAt().AsTime(),
|
||||
}).Info("Proxy connected")
|
||||
|
||||
connCtx, cancel := context.WithCancel(ctx)
|
||||
conn := &proxyConnection{
|
||||
proxyID: proxyID,
|
||||
address: proxyAddress,
|
||||
stream: stream,
|
||||
sendChan: make(chan *proto.ProxyMapping, 100),
|
||||
ctx: connCtx,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
s.connectedProxies.Store(proxyID, conn)
|
||||
s.addToCluster(conn.address, proxyID)
|
||||
log.WithFields(log.Fields{
|
||||
"proxy_id": proxyID,
|
||||
"address": proxyAddress,
|
||||
"cluster_addr": extractClusterAddr(proxyAddress),
|
||||
"total_proxies": len(s.GetConnectedProxies()),
|
||||
}).Info("Proxy registered in cluster")
|
||||
defer func() {
|
||||
s.connectedProxies.Delete(proxyID)
|
||||
s.removeFromCluster(conn.address, proxyID)
|
||||
cancel()
|
||||
log.Infof("Proxy %s disconnected", proxyID)
|
||||
}()
|
||||
|
||||
if err := s.sendSnapshot(ctx, conn); err != nil {
|
||||
log.Errorf("Failed to send snapshot to proxy %s: %v", proxyID, err)
|
||||
return err
|
||||
}
|
||||
|
||||
errChan := make(chan error, 2)
|
||||
go s.sender(conn, errChan)
|
||||
|
||||
select {
|
||||
case err := <-errChan:
|
||||
return err
|
||||
case <-connCtx.Done():
|
||||
return connCtx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// sendSnapshot sends the initial snapshot of reverse proxies to the connecting proxy.
|
||||
// Only reverse proxies matching the proxy's cluster address are sent.
|
||||
func (s *ProxyServiceServer) sendSnapshot(ctx context.Context, conn *proxyConnection) error {
|
||||
reverseProxies, err := s.reverseProxyStore.GetReverseProxies(ctx, store.LockingStrengthNone)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get reverse proxies from store: %w", err)
|
||||
}
|
||||
|
||||
proxyClusterAddr := extractClusterAddr(conn.address)
|
||||
|
||||
for _, rp := range reverseProxies {
|
||||
if !rp.Enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
if rp.ProxyCluster != "" && proxyClusterAddr != "" && rp.ProxyCluster != proxyClusterAddr {
|
||||
continue
|
||||
}
|
||||
|
||||
// Generate one-time authentication token for each proxy in the snapshot
|
||||
// Tokens are not persistent on the proxy, so we need to generate new ones on reconnection
|
||||
token, err := s.tokenStore.GenerateToken(rp.AccountID, rp.ID, 5*time.Minute)
|
||||
if err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"proxy": rp.Name,
|
||||
"account": rp.AccountID,
|
||||
}).WithError(err).Error("Failed to generate auth token for snapshot")
|
||||
continue
|
||||
}
|
||||
|
||||
if err := conn.stream.Send(&proto.GetMappingUpdateResponse{
|
||||
Mapping: []*proto.ProxyMapping{
|
||||
rp.ToProtoMapping(
|
||||
reverseproxy.Create,
|
||||
token,
|
||||
s.GetOIDCValidationConfig(),
|
||||
),
|
||||
},
|
||||
}); err != nil {
|
||||
log.WithError(err).Error("Failed to send proxy mapping")
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractClusterAddr extracts the host from a proxy address URL.
|
||||
func extractClusterAddr(addr string) string {
|
||||
if addr == "" {
|
||||
return ""
|
||||
}
|
||||
u, err := url.Parse(addr)
|
||||
if err != nil {
|
||||
return addr
|
||||
}
|
||||
host := u.Host
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
return h
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
// sender handles sending messages to proxy
|
||||
func (s *ProxyServiceServer) sender(conn *proxyConnection, errChan chan<- error) {
|
||||
for {
|
||||
select {
|
||||
case msg := <-conn.sendChan:
|
||||
if err := conn.stream.Send(&proto.GetMappingUpdateResponse{Mapping: []*proto.ProxyMapping{msg}}); err != nil {
|
||||
log.Errorf("Failed to send message to proxy %s: %v", conn.proxyID, err)
|
||||
errChan <- err
|
||||
return
|
||||
}
|
||||
case <-conn.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SendAccessLog processes access log from proxy
|
||||
func (s *ProxyServiceServer) SendAccessLog(ctx context.Context, req *proto.SendAccessLogRequest) (*proto.SendAccessLogResponse, error) {
|
||||
accessLog := req.GetLog()
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"reverse_proxy_id": accessLog.GetServiceId(),
|
||||
"account_id": accessLog.GetAccountId(),
|
||||
"host": accessLog.GetHost(),
|
||||
"path": accessLog.GetPath(),
|
||||
"method": accessLog.GetMethod(),
|
||||
"response_code": accessLog.GetResponseCode(),
|
||||
"duration_ms": accessLog.GetDurationMs(),
|
||||
"source_ip": accessLog.GetSourceIp(),
|
||||
"auth_mechanism": accessLog.GetAuthMechanism(),
|
||||
"user_id": accessLog.GetUserId(),
|
||||
"auth_success": accessLog.GetAuthSuccess(),
|
||||
}).Debug("Access log from proxy")
|
||||
|
||||
logEntry := &accesslogs.AccessLogEntry{}
|
||||
logEntry.FromProto(accessLog)
|
||||
|
||||
if err := s.accessLogManager.SaveAccessLog(ctx, logEntry); err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to save access log: %v", err)
|
||||
return nil, status.Errorf(codes.Internal, "failed to save access log: %v", err)
|
||||
}
|
||||
|
||||
return &proto.SendAccessLogResponse{}, nil
|
||||
}
|
||||
|
||||
// SendReverseProxyUpdate broadcasts a reverse proxy update to all connected proxies.
|
||||
// Management should call this when reverse proxies are created/updated/removed
|
||||
func (s *ProxyServiceServer) SendReverseProxyUpdate(update *proto.ProxyMapping) {
|
||||
// Send it to all connected proxies
|
||||
log.Debugf("Broadcasting reverse proxy update to all connected proxies")
|
||||
s.connectedProxies.Range(func(key, value interface{}) bool {
|
||||
conn := value.(*proxyConnection)
|
||||
select {
|
||||
case conn.sendChan <- update:
|
||||
log.Debugf("Sent reverse proxy update with id %s to proxy %s", update.Id, conn.proxyID)
|
||||
default:
|
||||
log.Warnf("Failed to send reverse proxy update to proxy %s (channel full)", conn.proxyID)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// GetConnectedProxies returns a list of connected proxy IDs
|
||||
func (s *ProxyServiceServer) GetConnectedProxies() []string {
|
||||
var proxies []string
|
||||
s.connectedProxies.Range(func(key, value interface{}) bool {
|
||||
proxies = append(proxies, key.(string))
|
||||
return true
|
||||
})
|
||||
return proxies
|
||||
}
|
||||
|
||||
// GetConnectedProxyURLs returns a deduplicated list of URLs from all connected proxies.
|
||||
func (s *ProxyServiceServer) GetConnectedProxyURLs() []string {
|
||||
seenUrls := make(map[string]struct{})
|
||||
var urls []string
|
||||
var proxyCount int
|
||||
s.connectedProxies.Range(func(key, value interface{}) bool {
|
||||
proxyCount++
|
||||
conn := value.(*proxyConnection)
|
||||
log.WithFields(log.Fields{
|
||||
"proxy_id": conn.proxyID,
|
||||
"address": conn.address,
|
||||
}).Debug("checking connected proxy for URL")
|
||||
if _, seen := seenUrls[conn.address]; conn.address != "" && !seen {
|
||||
seenUrls[conn.address] = struct{}{}
|
||||
urls = append(urls, conn.address)
|
||||
}
|
||||
return true
|
||||
})
|
||||
log.WithFields(log.Fields{
|
||||
"total_proxies": proxyCount,
|
||||
"unique_urls": len(urls),
|
||||
"connected_urls": urls,
|
||||
}).Debug("GetConnectedProxyURLs result")
|
||||
return urls
|
||||
}
|
||||
|
||||
// addToCluster registers a proxy in a cluster.
|
||||
func (s *ProxyServiceServer) addToCluster(clusterAddr, proxyID string) {
|
||||
if clusterAddr == "" {
|
||||
return
|
||||
}
|
||||
proxySet, _ := s.clusterProxies.LoadOrStore(clusterAddr, &sync.Map{})
|
||||
proxySet.(*sync.Map).Store(proxyID, struct{}{})
|
||||
log.Debugf("Added proxy %s to cluster %s", proxyID, clusterAddr)
|
||||
}
|
||||
|
||||
// removeFromCluster removes a proxy from a cluster.
|
||||
func (s *ProxyServiceServer) removeFromCluster(clusterAddr, proxyID string) {
|
||||
if clusterAddr == "" {
|
||||
return
|
||||
}
|
||||
if proxySet, ok := s.clusterProxies.Load(clusterAddr); ok {
|
||||
proxySet.(*sync.Map).Delete(proxyID)
|
||||
log.Debugf("Removed proxy %s from cluster %s", proxyID, clusterAddr)
|
||||
}
|
||||
}
|
||||
|
||||
// SendReverseProxyUpdateToCluster sends a reverse proxy update to all proxies in a specific cluster.
|
||||
// If clusterAddr is empty, broadcasts to all connected proxies (backward compatibility).
|
||||
func (s *ProxyServiceServer) SendReverseProxyUpdateToCluster(update *proto.ProxyMapping, clusterAddr string) {
|
||||
if clusterAddr == "" {
|
||||
s.SendReverseProxyUpdate(update)
|
||||
return
|
||||
}
|
||||
|
||||
proxySet, ok := s.clusterProxies.Load(clusterAddr)
|
||||
if !ok {
|
||||
log.Debugf("No proxies connected for cluster %s", clusterAddr)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debugf("Sending reverse proxy update to cluster %s", clusterAddr)
|
||||
proxySet.(*sync.Map).Range(func(key, _ interface{}) bool {
|
||||
proxyID := key.(string)
|
||||
if connVal, ok := s.connectedProxies.Load(proxyID); ok {
|
||||
conn := connVal.(*proxyConnection)
|
||||
select {
|
||||
case conn.sendChan <- update:
|
||||
log.Debugf("Sent reverse proxy update with id %s to proxy %s in cluster %s", update.Id, proxyID, clusterAddr)
|
||||
default:
|
||||
log.Warnf("Failed to send reverse proxy update to proxy %s in cluster %s (channel full)", proxyID, clusterAddr)
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// GetAvailableClusters returns information about all connected proxy clusters.
|
||||
func (s *ProxyServiceServer) GetAvailableClusters() []ClusterInfo {
|
||||
clusterCounts := make(map[string]int)
|
||||
s.clusterProxies.Range(func(key, value interface{}) bool {
|
||||
clusterAddr := key.(string)
|
||||
proxySet := value.(*sync.Map)
|
||||
count := 0
|
||||
proxySet.Range(func(_, _ interface{}) bool {
|
||||
count++
|
||||
return true
|
||||
})
|
||||
if count > 0 {
|
||||
clusterCounts[clusterAddr] = count
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
clusters := make([]ClusterInfo, 0, len(clusterCounts))
|
||||
for addr, count := range clusterCounts {
|
||||
clusters = append(clusters, ClusterInfo{
|
||||
Address: addr,
|
||||
ConnectedProxies: count,
|
||||
})
|
||||
}
|
||||
return clusters
|
||||
}
|
||||
|
||||
func (s *ProxyServiceServer) Authenticate(ctx context.Context, req *proto.AuthenticateRequest) (*proto.AuthenticateResponse, error) {
|
||||
proxy, err := s.reverseProxyStore.GetReverseProxyByID(ctx, store.LockingStrengthNone, req.GetAccountId(), req.GetId())
|
||||
if err != nil {
|
||||
// TODO: log the error
|
||||
return nil, status.Errorf(codes.FailedPrecondition, "failed to get reverse proxy from store: %v", err)
|
||||
}
|
||||
|
||||
var authenticated bool
|
||||
var userId string
|
||||
var method proxyauth.Method
|
||||
switch v := req.GetRequest().(type) {
|
||||
case *proto.AuthenticateRequest_Pin:
|
||||
auth := proxy.Auth.PinAuth
|
||||
if auth == nil || !auth.Enabled {
|
||||
// TODO: log
|
||||
// Break here and use the default authenticated == false.
|
||||
break
|
||||
}
|
||||
authenticated = subtle.ConstantTimeCompare([]byte(auth.Pin), []byte(v.Pin.GetPin())) == 1
|
||||
userId = "pin-user"
|
||||
method = proxyauth.MethodPIN
|
||||
case *proto.AuthenticateRequest_Password:
|
||||
auth := proxy.Auth.PasswordAuth
|
||||
if auth == nil || !auth.Enabled {
|
||||
// TODO: log
|
||||
// Break here and use the default authenticated == false.
|
||||
break
|
||||
}
|
||||
authenticated = subtle.ConstantTimeCompare([]byte(auth.Password), []byte(v.Password.GetPassword())) == 1
|
||||
userId = "password-user"
|
||||
method = proxyauth.MethodPassword
|
||||
}
|
||||
|
||||
var token string
|
||||
if authenticated && proxy.SessionPrivateKey != "" {
|
||||
token, err = sessionkey.SignToken(
|
||||
proxy.SessionPrivateKey,
|
||||
userId,
|
||||
proxy.Domain,
|
||||
method,
|
||||
proxyauth.DefaultSessionExpiry,
|
||||
)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("Failed to sign session token")
|
||||
authenticated = false
|
||||
}
|
||||
}
|
||||
|
||||
return &proto.AuthenticateResponse{
|
||||
Success: authenticated,
|
||||
SessionToken: token,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SendStatusUpdate handles status updates from proxy clients
|
||||
func (s *ProxyServiceServer) SendStatusUpdate(ctx context.Context, req *proto.SendStatusUpdateRequest) (*proto.SendStatusUpdateResponse, error) {
|
||||
accountID := req.GetAccountId()
|
||||
reverseProxyID := req.GetReverseProxyId()
|
||||
protoStatus := req.GetStatus()
|
||||
certificateIssued := req.GetCertificateIssued()
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"reverse_proxy_id": reverseProxyID,
|
||||
"account_id": accountID,
|
||||
"status": protoStatus,
|
||||
"certificate_issued": certificateIssued,
|
||||
"error_message": req.GetErrorMessage(),
|
||||
}).Debug("Status update from proxy")
|
||||
|
||||
if reverseProxyID == "" || accountID == "" {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "reverse_proxy_id and account_id are required")
|
||||
}
|
||||
|
||||
if certificateIssued {
|
||||
if err := s.reverseProxyManager.SetCertificateIssuedAt(ctx, accountID, reverseProxyID); err != nil {
|
||||
log.WithContext(ctx).WithError(err).Error("Failed to set certificate issued timestamp")
|
||||
return nil, status.Errorf(codes.Internal, "failed to update certificate timestamp: %v", err)
|
||||
}
|
||||
log.WithFields(log.Fields{
|
||||
"reverse_proxy_id": reverseProxyID,
|
||||
"account_id": accountID,
|
||||
}).Info("Certificate issued timestamp updated")
|
||||
}
|
||||
|
||||
internalStatus := protoStatusToInternal(protoStatus)
|
||||
|
||||
if err := s.reverseProxyManager.SetStatus(ctx, accountID, reverseProxyID, internalStatus); err != nil {
|
||||
log.WithContext(ctx).WithError(err).Error("Failed to set proxy status")
|
||||
return nil, status.Errorf(codes.Internal, "failed to update proxy status: %v", err)
|
||||
}
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"reverse_proxy_id": reverseProxyID,
|
||||
"account_id": accountID,
|
||||
"status": internalStatus,
|
||||
}).Info("Proxy status updated")
|
||||
|
||||
return &proto.SendStatusUpdateResponse{}, nil
|
||||
}
|
||||
|
||||
// protoStatusToInternal maps proto status to internal status
|
||||
func protoStatusToInternal(protoStatus proto.ProxyStatus) reverseproxy.ProxyStatus {
|
||||
switch protoStatus {
|
||||
case proto.ProxyStatus_PROXY_STATUS_PENDING:
|
||||
return reverseproxy.StatusPending
|
||||
case proto.ProxyStatus_PROXY_STATUS_ACTIVE:
|
||||
return reverseproxy.StatusActive
|
||||
case proto.ProxyStatus_PROXY_STATUS_TUNNEL_NOT_CREATED:
|
||||
return reverseproxy.StatusTunnelNotCreated
|
||||
case proto.ProxyStatus_PROXY_STATUS_CERTIFICATE_PENDING:
|
||||
return reverseproxy.StatusCertificatePending
|
||||
case proto.ProxyStatus_PROXY_STATUS_CERTIFICATE_FAILED:
|
||||
return reverseproxy.StatusCertificateFailed
|
||||
case proto.ProxyStatus_PROXY_STATUS_ERROR:
|
||||
return reverseproxy.StatusError
|
||||
default:
|
||||
return reverseproxy.StatusError
|
||||
}
|
||||
}
|
||||
|
||||
// CreateProxyPeer handles proxy peer creation with one-time token authentication
|
||||
func (s *ProxyServiceServer) CreateProxyPeer(ctx context.Context, req *proto.CreateProxyPeerRequest) (*proto.CreateProxyPeerResponse, error) {
|
||||
reverseProxyID := req.GetReverseProxyId()
|
||||
accountID := req.GetAccountId()
|
||||
token := req.GetToken()
|
||||
key := req.WireguardPublicKey
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"reverse_proxy_id": reverseProxyID,
|
||||
"account_id": accountID,
|
||||
}).Debug("CreateProxyPeer request received")
|
||||
|
||||
if reverseProxyID == "" || accountID == "" || token == "" {
|
||||
log.Warn("CreateProxyPeer: missing required fields")
|
||||
return &proto.CreateProxyPeerResponse{
|
||||
Success: false,
|
||||
ErrorMessage: strPtr("missing required fields: reverse_proxy_id, account_id, and token are required"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
if err := s.tokenStore.ValidateAndConsume(token, accountID, reverseProxyID); err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"reverse_proxy_id": reverseProxyID,
|
||||
"account_id": accountID,
|
||||
}).WithError(err).Warn("CreateProxyPeer: token validation failed")
|
||||
return &proto.CreateProxyPeerResponse{
|
||||
Success: false,
|
||||
ErrorMessage: strPtr("authentication failed: invalid or expired token"),
|
||||
}, status.Errorf(codes.Unauthenticated, "token validation failed: %v", err)
|
||||
}
|
||||
|
||||
err := s.peersManager.CreateProxyPeer(ctx, accountID, key)
|
||||
if err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"reverse_proxy_id": reverseProxyID,
|
||||
"account_id": accountID,
|
||||
}).WithError(err).Error("CreateProxyPeer: failed to create proxy peer")
|
||||
return &proto.CreateProxyPeerResponse{
|
||||
Success: false,
|
||||
ErrorMessage: strPtr(fmt.Sprintf("failed to create proxy peer: %v", err)),
|
||||
}, status.Errorf(codes.Internal, "failed to create proxy peer: %v", err)
|
||||
}
|
||||
|
||||
return &proto.CreateProxyPeerResponse{
|
||||
Success: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// strPtr is a helper to create a string pointer for optional proto fields
|
||||
func strPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
func (s *ProxyServiceServer) GetOIDCURL(ctx context.Context, req *proto.GetOIDCURLRequest) (*proto.GetOIDCURLResponse, error) {
|
||||
redirectURL, err := url.Parse(req.GetRedirectUrl())
|
||||
if err != nil {
|
||||
// TODO: log
|
||||
return nil, status.Errorf(codes.InvalidArgument, "failed to parse redirect url: %v", err)
|
||||
}
|
||||
// Validate redirectURL against known proxy endpoints to avoid abuse of OIDC redirection.
|
||||
proxies, err := s.reverseProxyStore.GetAccountReverseProxies(ctx, store.LockingStrengthNone, req.GetAccountId())
|
||||
if err != nil {
|
||||
// TODO: log
|
||||
return nil, status.Errorf(codes.FailedPrecondition, "failed to get reverse proxy from store: %v", err)
|
||||
}
|
||||
var found bool
|
||||
for _, proxy := range proxies {
|
||||
if proxy.Domain == redirectURL.Hostname() {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
// TODO: log
|
||||
return nil, status.Errorf(codes.FailedPrecondition, "reverse proxy not found in store")
|
||||
}
|
||||
|
||||
provider, err := oidc.NewProvider(ctx, s.oidcConfig.Issuer)
|
||||
if err != nil {
|
||||
// TODO: log
|
||||
return nil, status.Errorf(codes.FailedPrecondition, "failed to create OIDC provider: %v", err)
|
||||
}
|
||||
|
||||
scopes := s.oidcConfig.Scopes
|
||||
if len(scopes) == 0 {
|
||||
scopes = []string{oidc.ScopeOpenID, "profile", "email"}
|
||||
}
|
||||
|
||||
// Using an HMAC here to avoid redirection state being modified.
|
||||
// State format: base64(redirectURL)|hmac
|
||||
hmacSum := s.generateHMAC(redirectURL.String())
|
||||
state := fmt.Sprintf("%s|%s", base64.URLEncoding.EncodeToString([]byte(redirectURL.String())), hmacSum)
|
||||
|
||||
codeVerifier := oauth2.GenerateVerifier()
|
||||
s.pkceVerifiers.Store(state, codeVerifier)
|
||||
|
||||
return &proto.GetOIDCURLResponse{
|
||||
Url: (&oauth2.Config{
|
||||
ClientID: s.oidcConfig.ClientID,
|
||||
Endpoint: provider.Endpoint(),
|
||||
RedirectURL: s.oidcConfig.CallbackURL,
|
||||
Scopes: scopes,
|
||||
}).AuthCodeURL(state, oauth2.S256ChallengeOption(codeVerifier)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetOIDCConfig returns the OIDC configuration for token validation.
|
||||
func (s *ProxyServiceServer) GetOIDCConfig() ProxyOIDCConfig {
|
||||
return s.oidcConfig
|
||||
}
|
||||
|
||||
// GetOIDCValidationConfig returns the OIDC configuration for token validation
|
||||
// in the format needed by ToProtoMapping.
|
||||
func (s *ProxyServiceServer) GetOIDCValidationConfig() reverseproxy.OIDCValidationConfig {
|
||||
return reverseproxy.OIDCValidationConfig{
|
||||
Issuer: s.oidcConfig.Issuer,
|
||||
Audiences: []string{s.oidcConfig.Audience},
|
||||
KeysLocation: s.oidcConfig.KeysLocation,
|
||||
MaxTokenAgeSeconds: 0, // No max token age by default
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ProxyServiceServer) generateHMAC(input string) string {
|
||||
mac := hmac.New(sha256.New, s.oidcConfig.HMACKey)
|
||||
mac.Write([]byte(input))
|
||||
return hex.EncodeToString(mac.Sum(nil))
|
||||
}
|
||||
|
||||
// ValidateState validates the state parameter from an OAuth callback.
|
||||
// Returns the original redirect URL if valid, or an error if invalid.
|
||||
func (s *ProxyServiceServer) ValidateState(state string) (verifier, redirectURL string, err error) {
|
||||
v, ok := s.pkceVerifiers.LoadAndDelete(state)
|
||||
if !ok {
|
||||
return "", "", errors.New("no verifier for state")
|
||||
}
|
||||
verifier, ok = v.(string)
|
||||
if !ok {
|
||||
return "", "", errors.New("invalid verifier for state")
|
||||
}
|
||||
|
||||
parts := strings.Split(state, "|")
|
||||
if len(parts) != 2 {
|
||||
return "", "", errors.New("invalid state format")
|
||||
}
|
||||
|
||||
encodedURL := parts[0]
|
||||
providedHMAC := parts[1]
|
||||
|
||||
redirectURLBytes, err := base64.URLEncoding.DecodeString(encodedURL)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid state encoding: %w", err)
|
||||
}
|
||||
redirectURL = string(redirectURLBytes)
|
||||
|
||||
expectedHMAC := s.generateHMAC(redirectURL)
|
||||
|
||||
if !hmac.Equal([]byte(providedHMAC), []byte(expectedHMAC)) {
|
||||
return "", "", fmt.Errorf("invalid state signature")
|
||||
}
|
||||
|
||||
return verifier, redirectURL, nil
|
||||
}
|
||||
|
||||
// GenerateSessionToken creates a signed session JWT for the given domain and user.
|
||||
func (s *ProxyServiceServer) GenerateSessionToken(ctx context.Context, domain, userID string, method proxyauth.Method) (string, error) {
|
||||
// Find the proxy by domain to get its signing key
|
||||
proxies, err := s.reverseProxyStore.GetReverseProxies(ctx, store.LockingStrengthNone)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get reverse proxies: %w", err)
|
||||
}
|
||||
|
||||
var proxy *reverseproxy.ReverseProxy
|
||||
for _, p := range proxies {
|
||||
if p.Domain == domain {
|
||||
proxy = p
|
||||
break
|
||||
}
|
||||
}
|
||||
if proxy == nil {
|
||||
return "", fmt.Errorf("reverse proxy not found for domain: %s", domain)
|
||||
}
|
||||
|
||||
if proxy.SessionPrivateKey == "" {
|
||||
return "", fmt.Errorf("no session key configured for domain: %s", domain)
|
||||
}
|
||||
|
||||
return sessionkey.SignToken(
|
||||
proxy.SessionPrivateKey,
|
||||
userID,
|
||||
domain,
|
||||
method,
|
||||
proxyauth.DefaultSessionExpiry,
|
||||
)
|
||||
}
|
||||
@@ -77,9 +77,8 @@ type Server struct {
|
||||
|
||||
oAuthConfigProvider idp.OAuthConfigProvider
|
||||
|
||||
syncSem atomic.Int32
|
||||
syncLimEnabled bool
|
||||
syncLim int32
|
||||
syncSem atomic.Int32
|
||||
syncLim int32
|
||||
}
|
||||
|
||||
// NewServer creates a new Management server
|
||||
@@ -109,7 +108,6 @@ func NewServer(
|
||||
blockPeersWithSameConfig := strings.ToLower(os.Getenv(envBlockPeers)) == "true"
|
||||
|
||||
syncLim := int32(defaultSyncLim)
|
||||
syncLimEnabled := true
|
||||
if syncLimStr := os.Getenv(envConcurrentSyncs); syncLimStr != "" {
|
||||
syncLimParsed, err := strconv.Atoi(syncLimStr)
|
||||
if err != nil {
|
||||
@@ -117,9 +115,6 @@ func NewServer(
|
||||
} else {
|
||||
//nolint:gosec
|
||||
syncLim = int32(syncLimParsed)
|
||||
if syncLim < 0 {
|
||||
syncLimEnabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,8 +134,7 @@ func NewServer(
|
||||
|
||||
loginFilter: newLoginFilter(),
|
||||
|
||||
syncLim: syncLim,
|
||||
syncLimEnabled: syncLimEnabled,
|
||||
syncLim: syncLim,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -218,7 +212,7 @@ func (s *Server) Job(srv proto.ManagementService_JobServer) error {
|
||||
// Sync validates the existence of a connecting peer, sends an initial state (all available for the connecting peers) and
|
||||
// notifies the connected peer of any updates (e.g. new peers under the same account)
|
||||
func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_SyncServer) error {
|
||||
if s.syncLimEnabled && s.syncSem.Load() >= s.syncLim {
|
||||
if s.syncSem.Load() >= s.syncLim {
|
||||
return status.Errorf(codes.ResourceExhausted, "too many concurrent sync requests, please try again later")
|
||||
}
|
||||
s.syncSem.Add(1)
|
||||
@@ -300,7 +294,7 @@ func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_S
|
||||
metahash := metaHash(peerMeta, realIP.String())
|
||||
s.loginFilter.addLogin(peerKey.String(), metahash)
|
||||
|
||||
peer, netMap, postureChecks, dnsFwdPort, err := s.accountManager.SyncAndMarkPeer(ctx, accountID, peerKey.String(), peerMeta, realIP, reqStart)
|
||||
peer, netMap, postureChecks, dnsFwdPort, err := s.accountManager.SyncAndMarkPeer(ctx, accountID, peerKey.String(), peerMeta, realIP)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Debugf("error while syncing peer %s: %v", peerKey.String(), err)
|
||||
s.syncSem.Add(-1)
|
||||
@@ -311,7 +305,7 @@ func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_S
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Debugf("error while sending initial sync for %s: %v", peerKey.String(), err)
|
||||
s.syncSem.Add(-1)
|
||||
s.cancelPeerRoutinesWithoutLock(ctx, accountID, peer, reqStart)
|
||||
s.cancelPeerRoutines(ctx, accountID, peer)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -319,7 +313,7 @@ func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_S
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Debugf("error while notify peer connected for %s: %v", peerKey.String(), err)
|
||||
s.syncSem.Add(-1)
|
||||
s.cancelPeerRoutinesWithoutLock(ctx, accountID, peer, reqStart)
|
||||
s.cancelPeerRoutines(ctx, accountID, peer)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -336,7 +330,7 @@ func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_S
|
||||
|
||||
s.syncSem.Add(-1)
|
||||
|
||||
return s.handleUpdates(ctx, accountID, peerKey, peer, updates, srv, reqStart)
|
||||
return s.handleUpdates(ctx, accountID, peerKey, peer, updates, srv)
|
||||
}
|
||||
|
||||
func (s *Server) handleHandshake(ctx context.Context, srv proto.ManagementService_JobServer) (wgtypes.Key, error) {
|
||||
@@ -404,20 +398,11 @@ func (s *Server) sendJobsLoop(ctx context.Context, accountID string, peerKey wgt
|
||||
}
|
||||
|
||||
// handleUpdates sends updates to the connected peer until the updates channel is closed.
|
||||
// It implements a backpressure mechanism that sends the first update immediately,
|
||||
// then debounces subsequent rapid updates, ensuring only the latest update is sent
|
||||
// after a quiet period.
|
||||
func (s *Server) handleUpdates(ctx context.Context, accountID string, peerKey wgtypes.Key, peer *nbpeer.Peer, updates chan *network_map.UpdateMessage, srv proto.ManagementService_SyncServer, streamStartTime time.Time) error {
|
||||
func (s *Server) handleUpdates(ctx context.Context, accountID string, peerKey wgtypes.Key, peer *nbpeer.Peer, updates chan *network_map.UpdateMessage, srv proto.ManagementService_SyncServer) error {
|
||||
log.WithContext(ctx).Tracef("starting to handle updates for peer %s", peerKey.String())
|
||||
|
||||
// Create a debouncer for this peer connection
|
||||
debouncer := NewUpdateDebouncer(1000 * time.Millisecond)
|
||||
defer debouncer.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
// condition when there are some updates
|
||||
// todo set the updates channel size to 1
|
||||
case update, open := <-updates:
|
||||
if s.appMetrics != nil {
|
||||
s.appMetrics.GRPCMetrics().UpdateChannelQueueLength(len(updates) + 1)
|
||||
@@ -425,38 +410,20 @@ func (s *Server) handleUpdates(ctx context.Context, accountID string, peerKey wg
|
||||
|
||||
if !open {
|
||||
log.WithContext(ctx).Debugf("updates channel for peer %s was closed", peerKey.String())
|
||||
s.cancelPeerRoutines(ctx, accountID, peer, streamStartTime)
|
||||
s.cancelPeerRoutines(ctx, accountID, peer)
|
||||
return nil
|
||||
}
|
||||
|
||||
log.WithContext(ctx).Debugf("received an update for peer %s", peerKey.String())
|
||||
if debouncer.ProcessUpdate(update) {
|
||||
// Send immediately (first update or after quiet period)
|
||||
if err := s.sendUpdate(ctx, accountID, peerKey, peer, update, srv, streamStartTime); err != nil {
|
||||
log.WithContext(ctx).Debugf("error while sending an update to peer %s: %v", peerKey.String(), err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Timer expired - quiet period reached, send pending updates if any
|
||||
case <-debouncer.TimerChannel():
|
||||
pendingUpdates := debouncer.GetPendingUpdates()
|
||||
if len(pendingUpdates) == 0 {
|
||||
continue
|
||||
}
|
||||
log.WithContext(ctx).Debugf("sending %d debounced update(s) for peer %s", len(pendingUpdates), peerKey.String())
|
||||
for _, pendingUpdate := range pendingUpdates {
|
||||
if err := s.sendUpdate(ctx, accountID, peerKey, peer, pendingUpdate, srv, streamStartTime); err != nil {
|
||||
log.WithContext(ctx).Debugf("error while sending an update to peer %s: %v", peerKey.String(), err)
|
||||
return err
|
||||
}
|
||||
if err := s.sendUpdate(ctx, accountID, peerKey, peer, update, srv); err != nil {
|
||||
log.WithContext(ctx).Debugf("error while sending an update to peer %s: %v", peerKey.String(), err)
|
||||
return err
|
||||
}
|
||||
|
||||
// condition when client <-> server connection has been terminated
|
||||
case <-srv.Context().Done():
|
||||
// happens when connection drops, e.g. client disconnects
|
||||
log.WithContext(ctx).Debugf("stream of peer %s has been closed", peerKey.String())
|
||||
s.cancelPeerRoutines(ctx, accountID, peer, streamStartTime)
|
||||
s.cancelPeerRoutines(ctx, accountID, peer)
|
||||
return srv.Context().Err()
|
||||
}
|
||||
}
|
||||
@@ -464,16 +431,16 @@ func (s *Server) handleUpdates(ctx context.Context, accountID string, peerKey wg
|
||||
|
||||
// sendUpdate encrypts the update message using the peer key and the server's wireguard key,
|
||||
// then sends the encrypted message to the connected peer via the sync server.
|
||||
func (s *Server) sendUpdate(ctx context.Context, accountID string, peerKey wgtypes.Key, peer *nbpeer.Peer, update *network_map.UpdateMessage, srv proto.ManagementService_SyncServer, streamStartTime time.Time) error {
|
||||
func (s *Server) sendUpdate(ctx context.Context, accountID string, peerKey wgtypes.Key, peer *nbpeer.Peer, update *network_map.UpdateMessage, srv proto.ManagementService_SyncServer) error {
|
||||
key, err := s.secretsManager.GetWGKey()
|
||||
if err != nil {
|
||||
s.cancelPeerRoutines(ctx, accountID, peer, streamStartTime)
|
||||
s.cancelPeerRoutines(ctx, accountID, peer)
|
||||
return status.Errorf(codes.Internal, "failed processing update message")
|
||||
}
|
||||
|
||||
encryptedResp, err := encryption.EncryptMessage(peerKey, key, update.Update)
|
||||
if err != nil {
|
||||
s.cancelPeerRoutines(ctx, accountID, peer, streamStartTime)
|
||||
s.cancelPeerRoutines(ctx, accountID, peer)
|
||||
return status.Errorf(codes.Internal, "failed processing update message")
|
||||
}
|
||||
err = srv.Send(&proto.EncryptedMessage{
|
||||
@@ -481,7 +448,7 @@ func (s *Server) sendUpdate(ctx context.Context, accountID string, peerKey wgtyp
|
||||
Body: encryptedResp,
|
||||
})
|
||||
if err != nil {
|
||||
s.cancelPeerRoutines(ctx, accountID, peer, streamStartTime)
|
||||
s.cancelPeerRoutines(ctx, accountID, peer)
|
||||
return status.Errorf(codes.Internal, "failed sending update message")
|
||||
}
|
||||
log.WithContext(ctx).Debugf("sent an update to peer %s", peerKey.String())
|
||||
@@ -513,15 +480,11 @@ func (s *Server) sendJob(ctx context.Context, peerKey wgtypes.Key, job *job.Even
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) cancelPeerRoutines(ctx context.Context, accountID string, peer *nbpeer.Peer, streamStartTime time.Time) {
|
||||
func (s *Server) cancelPeerRoutines(ctx context.Context, accountID string, peer *nbpeer.Peer) {
|
||||
unlock := s.acquirePeerLockByUID(ctx, peer.Key)
|
||||
defer unlock()
|
||||
|
||||
s.cancelPeerRoutinesWithoutLock(ctx, accountID, peer, streamStartTime)
|
||||
}
|
||||
|
||||
func (s *Server) cancelPeerRoutinesWithoutLock(ctx context.Context, accountID string, peer *nbpeer.Peer, streamStartTime time.Time) {
|
||||
err := s.accountManager.OnPeerDisconnected(ctx, accountID, peer.Key, streamStartTime)
|
||||
err := s.accountManager.OnPeerDisconnected(ctx, accountID, peer.Key)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("failed to disconnect peer %s properly: %v", peer.Key, err)
|
||||
}
|
||||
|
||||
@@ -242,10 +242,7 @@ func (m *TimeBasedAuthSecretsManager) pushNewTURNAndRelayTokens(ctx context.Cont
|
||||
m.extendNetbirdConfig(ctx, peerID, accountID, update)
|
||||
|
||||
log.WithContext(ctx).Debugf("sending new TURN credentials to peer %s", peerID)
|
||||
m.updateManager.SendUpdate(ctx, peerID, &network_map.UpdateMessage{
|
||||
Update: update,
|
||||
MessageType: network_map.MessageTypeControlConfig,
|
||||
})
|
||||
m.updateManager.SendUpdate(ctx, peerID, &network_map.UpdateMessage{Update: update})
|
||||
}
|
||||
|
||||
func (m *TimeBasedAuthSecretsManager) pushNewRelayTokens(ctx context.Context, accountID, peerID string) {
|
||||
@@ -269,10 +266,7 @@ func (m *TimeBasedAuthSecretsManager) pushNewRelayTokens(ctx context.Context, ac
|
||||
m.extendNetbirdConfig(ctx, peerID, accountID, update)
|
||||
|
||||
log.WithContext(ctx).Debugf("sending new relay credentials to peer %s", peerID)
|
||||
m.updateManager.SendUpdate(ctx, peerID, &network_map.UpdateMessage{
|
||||
Update: update,
|
||||
MessageType: network_map.MessageTypeControlConfig,
|
||||
})
|
||||
m.updateManager.SendUpdate(ctx, peerID, &network_map.UpdateMessage{Update: update})
|
||||
}
|
||||
|
||||
func (m *TimeBasedAuthSecretsManager) extendNetbirdConfig(ctx context.Context, peerID, accountID string, update *proto.SyncResponse) {
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
|
||||
)
|
||||
|
||||
// UpdateDebouncer implements a backpressure mechanism that:
|
||||
// - Sends the first update immediately
|
||||
// - Coalesces rapid subsequent network map updates (only latest matters)
|
||||
// - Queues control/config updates (all must be delivered)
|
||||
// - Preserves the order of messages (important for control configs between network maps)
|
||||
// - Ensures pending updates are sent after a quiet period
|
||||
type UpdateDebouncer struct {
|
||||
debounceInterval time.Duration
|
||||
timer *time.Timer
|
||||
pendingUpdates []*network_map.UpdateMessage // Queue that preserves order
|
||||
timerC <-chan time.Time
|
||||
}
|
||||
|
||||
// NewUpdateDebouncer creates a new debouncer with the specified interval
|
||||
func NewUpdateDebouncer(interval time.Duration) *UpdateDebouncer {
|
||||
return &UpdateDebouncer{
|
||||
debounceInterval: interval,
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessUpdate handles an incoming update and returns whether it should be sent immediately
|
||||
func (d *UpdateDebouncer) ProcessUpdate(update *network_map.UpdateMessage) bool {
|
||||
if d.timer == nil {
|
||||
// No active debounce timer, signal to send immediately
|
||||
// and start the debounce period
|
||||
d.startTimer()
|
||||
return true
|
||||
}
|
||||
|
||||
// Already in debounce period, accumulate this update preserving order
|
||||
// Check if we should coalesce with the last pending update
|
||||
if len(d.pendingUpdates) > 0 &&
|
||||
update.MessageType == network_map.MessageTypeNetworkMap &&
|
||||
d.pendingUpdates[len(d.pendingUpdates)-1].MessageType == network_map.MessageTypeNetworkMap {
|
||||
// Replace the last network map with this one (coalesce consecutive network maps)
|
||||
d.pendingUpdates[len(d.pendingUpdates)-1] = update
|
||||
} else {
|
||||
// Append to the queue (preserves order for control configs and non-consecutive network maps)
|
||||
d.pendingUpdates = append(d.pendingUpdates, update)
|
||||
}
|
||||
d.resetTimer()
|
||||
return false
|
||||
}
|
||||
|
||||
// TimerChannel returns the timer channel for select statements
|
||||
func (d *UpdateDebouncer) TimerChannel() <-chan time.Time {
|
||||
if d.timer == nil {
|
||||
return nil
|
||||
}
|
||||
return d.timerC
|
||||
}
|
||||
|
||||
// GetPendingUpdates returns and clears all pending updates after timer expiration.
|
||||
// Updates are returned in the order they were received, with consecutive network maps
|
||||
// already coalesced to only the latest one.
|
||||
// If there were pending updates, it restarts the timer to continue debouncing.
|
||||
// If there were no pending updates, it clears the timer (true quiet period).
|
||||
func (d *UpdateDebouncer) GetPendingUpdates() []*network_map.UpdateMessage {
|
||||
updates := d.pendingUpdates
|
||||
d.pendingUpdates = nil
|
||||
|
||||
if len(updates) > 0 {
|
||||
// There were pending updates, so updates are still coming rapidly
|
||||
// Restart the timer to continue debouncing mode
|
||||
if d.timer != nil {
|
||||
d.timer.Reset(d.debounceInterval)
|
||||
}
|
||||
} else {
|
||||
// No pending updates means true quiet period - return to immediate mode
|
||||
d.timer = nil
|
||||
d.timerC = nil
|
||||
}
|
||||
|
||||
return updates
|
||||
}
|
||||
|
||||
// Stop stops the debouncer and cleans up resources
|
||||
func (d *UpdateDebouncer) Stop() {
|
||||
if d.timer != nil {
|
||||
d.timer.Stop()
|
||||
d.timer = nil
|
||||
d.timerC = nil
|
||||
}
|
||||
d.pendingUpdates = nil
|
||||
}
|
||||
|
||||
func (d *UpdateDebouncer) startTimer() {
|
||||
d.timer = time.NewTimer(d.debounceInterval)
|
||||
d.timerC = d.timer.C
|
||||
}
|
||||
|
||||
func (d *UpdateDebouncer) resetTimer() {
|
||||
d.timer.Stop()
|
||||
d.timer.Reset(d.debounceInterval)
|
||||
}
|
||||
@@ -1,587 +0,0 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
func TestUpdateDebouncer_FirstUpdateSentImmediately(t *testing.T) {
|
||||
debouncer := NewUpdateDebouncer(50 * time.Millisecond)
|
||||
defer debouncer.Stop()
|
||||
|
||||
update := &network_map.UpdateMessage{
|
||||
Update: &proto.SyncResponse{},
|
||||
MessageType: network_map.MessageTypeNetworkMap,
|
||||
}
|
||||
|
||||
shouldSend := debouncer.ProcessUpdate(update)
|
||||
|
||||
if !shouldSend {
|
||||
t.Error("First update should be sent immediately")
|
||||
}
|
||||
|
||||
if debouncer.TimerChannel() == nil {
|
||||
t.Error("Timer should be started after first update")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDebouncer_RapidUpdatesCoalesced(t *testing.T) {
|
||||
debouncer := NewUpdateDebouncer(50 * time.Millisecond)
|
||||
defer debouncer.Stop()
|
||||
|
||||
update1 := &network_map.UpdateMessage{
|
||||
Update: &proto.SyncResponse{},
|
||||
MessageType: network_map.MessageTypeNetworkMap,
|
||||
}
|
||||
update2 := &network_map.UpdateMessage{
|
||||
Update: &proto.SyncResponse{},
|
||||
MessageType: network_map.MessageTypeNetworkMap,
|
||||
}
|
||||
update3 := &network_map.UpdateMessage{
|
||||
Update: &proto.SyncResponse{},
|
||||
MessageType: network_map.MessageTypeNetworkMap,
|
||||
}
|
||||
|
||||
// First update should be sent immediately
|
||||
if !debouncer.ProcessUpdate(update1) {
|
||||
t.Error("First update should be sent immediately")
|
||||
}
|
||||
|
||||
// Rapid subsequent updates should be coalesced
|
||||
if debouncer.ProcessUpdate(update2) {
|
||||
t.Error("Second rapid update should not be sent immediately")
|
||||
}
|
||||
|
||||
if debouncer.ProcessUpdate(update3) {
|
||||
t.Error("Third rapid update should not be sent immediately")
|
||||
}
|
||||
|
||||
// Wait for debounce period
|
||||
select {
|
||||
case <-debouncer.TimerChannel():
|
||||
pendingUpdates := debouncer.GetPendingUpdates()
|
||||
if len(pendingUpdates) != 1 {
|
||||
t.Errorf("Should get exactly 1 pending update, got %d", len(pendingUpdates))
|
||||
}
|
||||
if pendingUpdates[0] != update3 {
|
||||
t.Error("Should get the last update (update3)")
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Error("Timer should have fired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDebouncer_LastUpdateAlwaysSent(t *testing.T) {
|
||||
debouncer := NewUpdateDebouncer(30 * time.Millisecond)
|
||||
defer debouncer.Stop()
|
||||
|
||||
update1 := &network_map.UpdateMessage{
|
||||
Update: &proto.SyncResponse{},
|
||||
MessageType: network_map.MessageTypeNetworkMap,
|
||||
}
|
||||
update2 := &network_map.UpdateMessage{
|
||||
Update: &proto.SyncResponse{},
|
||||
MessageType: network_map.MessageTypeNetworkMap,
|
||||
}
|
||||
|
||||
// Send first update
|
||||
debouncer.ProcessUpdate(update1)
|
||||
|
||||
// Send second update within debounce period
|
||||
debouncer.ProcessUpdate(update2)
|
||||
|
||||
// Wait for timer
|
||||
select {
|
||||
case <-debouncer.TimerChannel():
|
||||
pendingUpdates := debouncer.GetPendingUpdates()
|
||||
if len(pendingUpdates) != 1 {
|
||||
t.Errorf("Should get exactly 1 pending update, got %d", len(pendingUpdates))
|
||||
}
|
||||
if pendingUpdates[0] != update2 {
|
||||
t.Error("Should get the last update")
|
||||
}
|
||||
if pendingUpdates[0] == update1 {
|
||||
t.Error("Should not get the first update")
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Error("Timer should have fired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDebouncer_TimerResetOnNewUpdate(t *testing.T) {
|
||||
debouncer := NewUpdateDebouncer(50 * time.Millisecond)
|
||||
defer debouncer.Stop()
|
||||
|
||||
update1 := &network_map.UpdateMessage{
|
||||
Update: &proto.SyncResponse{},
|
||||
MessageType: network_map.MessageTypeNetworkMap,
|
||||
}
|
||||
update2 := &network_map.UpdateMessage{
|
||||
Update: &proto.SyncResponse{},
|
||||
MessageType: network_map.MessageTypeNetworkMap,
|
||||
}
|
||||
update3 := &network_map.UpdateMessage{
|
||||
Update: &proto.SyncResponse{},
|
||||
MessageType: network_map.MessageTypeNetworkMap,
|
||||
}
|
||||
|
||||
// Send first update
|
||||
debouncer.ProcessUpdate(update1)
|
||||
|
||||
// Wait a bit, but not the full debounce period
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
|
||||
// Send second update - should reset timer
|
||||
debouncer.ProcessUpdate(update2)
|
||||
|
||||
// Wait a bit more
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
|
||||
// Send third update - should reset timer again
|
||||
debouncer.ProcessUpdate(update3)
|
||||
|
||||
// Now wait for the timer (should fire after last update's reset)
|
||||
select {
|
||||
case <-debouncer.TimerChannel():
|
||||
pendingUpdates := debouncer.GetPendingUpdates()
|
||||
if len(pendingUpdates) != 1 {
|
||||
t.Errorf("Should get exactly 1 pending update, got %d", len(pendingUpdates))
|
||||
}
|
||||
if pendingUpdates[0] != update3 {
|
||||
t.Error("Should get the last update (update3)")
|
||||
}
|
||||
// Timer should be restarted since there was a pending update
|
||||
if debouncer.TimerChannel() == nil {
|
||||
t.Error("Timer should be restarted after sending pending update")
|
||||
}
|
||||
case <-time.After(150 * time.Millisecond):
|
||||
t.Error("Timer should have fired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDebouncer_TimerRestartsAfterPendingUpdateSent(t *testing.T) {
|
||||
debouncer := NewUpdateDebouncer(30 * time.Millisecond)
|
||||
defer debouncer.Stop()
|
||||
|
||||
update1 := &network_map.UpdateMessage{
|
||||
Update: &proto.SyncResponse{},
|
||||
MessageType: network_map.MessageTypeNetworkMap,
|
||||
}
|
||||
update2 := &network_map.UpdateMessage{
|
||||
Update: &proto.SyncResponse{},
|
||||
MessageType: network_map.MessageTypeNetworkMap,
|
||||
}
|
||||
update3 := &network_map.UpdateMessage{
|
||||
Update: &proto.SyncResponse{},
|
||||
MessageType: network_map.MessageTypeNetworkMap,
|
||||
}
|
||||
|
||||
// First update sent immediately
|
||||
debouncer.ProcessUpdate(update1)
|
||||
|
||||
// Second update coalesced
|
||||
debouncer.ProcessUpdate(update2)
|
||||
|
||||
// Wait for timer to expire
|
||||
select {
|
||||
case <-debouncer.TimerChannel():
|
||||
pendingUpdates := debouncer.GetPendingUpdates()
|
||||
|
||||
if len(pendingUpdates) == 0 {
|
||||
t.Fatal("Should have pending update")
|
||||
}
|
||||
|
||||
// After sending pending update, timer is restarted, so next update is NOT immediate
|
||||
if debouncer.ProcessUpdate(update3) {
|
||||
t.Error("Update after debounced send should not be sent immediately (timer restarted)")
|
||||
}
|
||||
|
||||
// Wait for the restarted timer and verify update3 is pending
|
||||
select {
|
||||
case <-debouncer.TimerChannel():
|
||||
finalUpdates := debouncer.GetPendingUpdates()
|
||||
if len(finalUpdates) != 1 || finalUpdates[0] != update3 {
|
||||
t.Error("Should get update3 as pending")
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Error("Timer should have fired for restarted timer")
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Error("Timer should have fired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDebouncer_StopCleansUp(t *testing.T) {
|
||||
debouncer := NewUpdateDebouncer(50 * time.Millisecond)
|
||||
|
||||
update := &network_map.UpdateMessage{
|
||||
Update: &proto.SyncResponse{},
|
||||
MessageType: network_map.MessageTypeNetworkMap,
|
||||
}
|
||||
|
||||
// Send update to start timer
|
||||
debouncer.ProcessUpdate(update)
|
||||
|
||||
// Stop should clean up
|
||||
debouncer.Stop()
|
||||
|
||||
// Multiple stops should be safe
|
||||
debouncer.Stop()
|
||||
}
|
||||
|
||||
func TestUpdateDebouncer_HighFrequencyUpdates(t *testing.T) {
|
||||
debouncer := NewUpdateDebouncer(50 * time.Millisecond)
|
||||
defer debouncer.Stop()
|
||||
|
||||
// Simulate high-frequency updates
|
||||
var lastUpdate *network_map.UpdateMessage
|
||||
sentImmediately := 0
|
||||
for i := 0; i < 100; i++ {
|
||||
update := &network_map.UpdateMessage{
|
||||
Update: &proto.SyncResponse{
|
||||
NetworkMap: &proto.NetworkMap{
|
||||
Serial: uint64(i),
|
||||
},
|
||||
},
|
||||
MessageType: network_map.MessageTypeNetworkMap,
|
||||
}
|
||||
lastUpdate = update
|
||||
if debouncer.ProcessUpdate(update) {
|
||||
sentImmediately++
|
||||
}
|
||||
time.Sleep(1 * time.Millisecond) // Very rapid updates
|
||||
}
|
||||
|
||||
// Only first update should be sent immediately
|
||||
if sentImmediately != 1 {
|
||||
t.Errorf("Expected only 1 update sent immediately, got %d", sentImmediately)
|
||||
}
|
||||
|
||||
// Wait for debounce period
|
||||
select {
|
||||
case <-debouncer.TimerChannel():
|
||||
pendingUpdates := debouncer.GetPendingUpdates()
|
||||
if len(pendingUpdates) != 1 {
|
||||
t.Errorf("Should get exactly 1 pending update, got %d", len(pendingUpdates))
|
||||
}
|
||||
if pendingUpdates[0] != lastUpdate {
|
||||
t.Error("Should get the very last update")
|
||||
}
|
||||
if pendingUpdates[0].Update.NetworkMap.Serial != 99 {
|
||||
t.Errorf("Expected serial 99, got %d", pendingUpdates[0].Update.NetworkMap.Serial)
|
||||
}
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
t.Error("Timer should have fired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDebouncer_NoUpdatesAfterFirst(t *testing.T) {
|
||||
debouncer := NewUpdateDebouncer(30 * time.Millisecond)
|
||||
defer debouncer.Stop()
|
||||
|
||||
update := &network_map.UpdateMessage{
|
||||
Update: &proto.SyncResponse{},
|
||||
MessageType: network_map.MessageTypeNetworkMap,
|
||||
}
|
||||
|
||||
// Send first update
|
||||
if !debouncer.ProcessUpdate(update) {
|
||||
t.Error("First update should be sent immediately")
|
||||
}
|
||||
|
||||
// Wait for timer to expire with no additional updates (true quiet period)
|
||||
select {
|
||||
case <-debouncer.TimerChannel():
|
||||
pendingUpdates := debouncer.GetPendingUpdates()
|
||||
if len(pendingUpdates) != 0 {
|
||||
t.Error("Should have no pending updates")
|
||||
}
|
||||
// After true quiet period, timer should be cleared
|
||||
if debouncer.TimerChannel() != nil {
|
||||
t.Error("Timer should be cleared after quiet period")
|
||||
}
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Error("Timer should have fired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDebouncer_IntermediateUpdatesDropped(t *testing.T) {
|
||||
debouncer := NewUpdateDebouncer(50 * time.Millisecond)
|
||||
defer debouncer.Stop()
|
||||
|
||||
updates := make([]*network_map.UpdateMessage, 5)
|
||||
for i := range updates {
|
||||
updates[i] = &network_map.UpdateMessage{
|
||||
Update: &proto.SyncResponse{
|
||||
NetworkMap: &proto.NetworkMap{
|
||||
Serial: uint64(i),
|
||||
},
|
||||
},
|
||||
MessageType: network_map.MessageTypeNetworkMap,
|
||||
}
|
||||
}
|
||||
|
||||
// First update sent immediately
|
||||
debouncer.ProcessUpdate(updates[0])
|
||||
|
||||
// Send updates 1, 2, 3, 4 rapidly - only last one should remain pending
|
||||
debouncer.ProcessUpdate(updates[1])
|
||||
debouncer.ProcessUpdate(updates[2])
|
||||
debouncer.ProcessUpdate(updates[3])
|
||||
debouncer.ProcessUpdate(updates[4])
|
||||
|
||||
// Wait for debounce
|
||||
<-debouncer.TimerChannel()
|
||||
pendingUpdates := debouncer.GetPendingUpdates()
|
||||
|
||||
if len(pendingUpdates) != 1 {
|
||||
t.Errorf("Should get exactly 1 pending update, got %d", len(pendingUpdates))
|
||||
}
|
||||
if pendingUpdates[0].Update.NetworkMap.Serial != 4 {
|
||||
t.Errorf("Expected only the last update (serial 4), got serial %d", pendingUpdates[0].Update.NetworkMap.Serial)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDebouncer_TrueQuietPeriodResetsToImmediateMode(t *testing.T) {
|
||||
debouncer := NewUpdateDebouncer(30 * time.Millisecond)
|
||||
defer debouncer.Stop()
|
||||
|
||||
update1 := &network_map.UpdateMessage{
|
||||
Update: &proto.SyncResponse{},
|
||||
MessageType: network_map.MessageTypeNetworkMap,
|
||||
}
|
||||
update2 := &network_map.UpdateMessage{
|
||||
Update: &proto.SyncResponse{},
|
||||
MessageType: network_map.MessageTypeNetworkMap,
|
||||
}
|
||||
|
||||
// First update sent immediately
|
||||
if !debouncer.ProcessUpdate(update1) {
|
||||
t.Error("First update should be sent immediately")
|
||||
}
|
||||
|
||||
// Wait for timer without sending any more updates (true quiet period)
|
||||
<-debouncer.TimerChannel()
|
||||
pendingUpdates := debouncer.GetPendingUpdates()
|
||||
|
||||
if len(pendingUpdates) != 0 {
|
||||
t.Error("Should have no pending updates during quiet period")
|
||||
}
|
||||
|
||||
// After true quiet period, next update should be sent immediately
|
||||
if !debouncer.ProcessUpdate(update2) {
|
||||
t.Error("Update after true quiet period should be sent immediately")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDebouncer_ContinuousHighFrequencyStaysInDebounceMode(t *testing.T) {
|
||||
debouncer := NewUpdateDebouncer(50 * time.Millisecond)
|
||||
defer debouncer.Stop()
|
||||
|
||||
// Simulate continuous high-frequency updates
|
||||
for i := 0; i < 10; i++ {
|
||||
update := &network_map.UpdateMessage{
|
||||
Update: &proto.SyncResponse{
|
||||
NetworkMap: &proto.NetworkMap{
|
||||
Serial: uint64(i),
|
||||
},
|
||||
},
|
||||
MessageType: network_map.MessageTypeNetworkMap,
|
||||
}
|
||||
|
||||
if i == 0 {
|
||||
// First one sent immediately
|
||||
if !debouncer.ProcessUpdate(update) {
|
||||
t.Error("First update should be sent immediately")
|
||||
}
|
||||
} else {
|
||||
// All others should be coalesced (not sent immediately)
|
||||
if debouncer.ProcessUpdate(update) {
|
||||
t.Errorf("Update %d should not be sent immediately", i)
|
||||
}
|
||||
}
|
||||
|
||||
// Wait a bit but send next update before debounce expires
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
|
||||
// Now wait for final debounce
|
||||
select {
|
||||
case <-debouncer.TimerChannel():
|
||||
pendingUpdates := debouncer.GetPendingUpdates()
|
||||
if len(pendingUpdates) == 0 {
|
||||
t.Fatal("Should have the last update pending")
|
||||
}
|
||||
if pendingUpdates[0].Update.NetworkMap.Serial != 9 {
|
||||
t.Errorf("Expected serial 9, got %d", pendingUpdates[0].Update.NetworkMap.Serial)
|
||||
}
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
t.Error("Timer should have fired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDebouncer_ControlConfigMessagesQueued(t *testing.T) {
|
||||
debouncer := NewUpdateDebouncer(50 * time.Millisecond)
|
||||
defer debouncer.Stop()
|
||||
|
||||
netmapUpdate := &network_map.UpdateMessage{
|
||||
Update: &proto.SyncResponse{NetworkMap: &proto.NetworkMap{Serial: 1}},
|
||||
MessageType: network_map.MessageTypeNetworkMap,
|
||||
}
|
||||
tokenUpdate1 := &network_map.UpdateMessage{
|
||||
Update: &proto.SyncResponse{NetbirdConfig: &proto.NetbirdConfig{}},
|
||||
MessageType: network_map.MessageTypeControlConfig,
|
||||
}
|
||||
tokenUpdate2 := &network_map.UpdateMessage{
|
||||
Update: &proto.SyncResponse{NetbirdConfig: &proto.NetbirdConfig{}},
|
||||
MessageType: network_map.MessageTypeControlConfig,
|
||||
}
|
||||
|
||||
// First update sent immediately
|
||||
debouncer.ProcessUpdate(netmapUpdate)
|
||||
|
||||
// Send multiple control config updates - they should all be queued
|
||||
debouncer.ProcessUpdate(tokenUpdate1)
|
||||
debouncer.ProcessUpdate(tokenUpdate2)
|
||||
|
||||
// Wait for debounce period
|
||||
select {
|
||||
case <-debouncer.TimerChannel():
|
||||
pendingUpdates := debouncer.GetPendingUpdates()
|
||||
// Should get both control config updates
|
||||
if len(pendingUpdates) != 2 {
|
||||
t.Errorf("Expected 2 control config updates, got %d", len(pendingUpdates))
|
||||
}
|
||||
// Control configs should come first
|
||||
if pendingUpdates[0] != tokenUpdate1 {
|
||||
t.Error("First pending update should be tokenUpdate1")
|
||||
}
|
||||
if pendingUpdates[1] != tokenUpdate2 {
|
||||
t.Error("Second pending update should be tokenUpdate2")
|
||||
}
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
t.Error("Timer should have fired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDebouncer_MixedMessageTypes(t *testing.T) {
|
||||
debouncer := NewUpdateDebouncer(50 * time.Millisecond)
|
||||
defer debouncer.Stop()
|
||||
|
||||
netmapUpdate1 := &network_map.UpdateMessage{
|
||||
Update: &proto.SyncResponse{NetworkMap: &proto.NetworkMap{Serial: 1}},
|
||||
MessageType: network_map.MessageTypeNetworkMap,
|
||||
}
|
||||
netmapUpdate2 := &network_map.UpdateMessage{
|
||||
Update: &proto.SyncResponse{NetworkMap: &proto.NetworkMap{Serial: 2}},
|
||||
MessageType: network_map.MessageTypeNetworkMap,
|
||||
}
|
||||
tokenUpdate := &network_map.UpdateMessage{
|
||||
Update: &proto.SyncResponse{NetbirdConfig: &proto.NetbirdConfig{}},
|
||||
MessageType: network_map.MessageTypeControlConfig,
|
||||
}
|
||||
|
||||
// First update sent immediately
|
||||
debouncer.ProcessUpdate(netmapUpdate1)
|
||||
|
||||
// Send token update and network map update
|
||||
debouncer.ProcessUpdate(tokenUpdate)
|
||||
debouncer.ProcessUpdate(netmapUpdate2)
|
||||
|
||||
// Wait for debounce period
|
||||
select {
|
||||
case <-debouncer.TimerChannel():
|
||||
pendingUpdates := debouncer.GetPendingUpdates()
|
||||
// Should get 2 updates in order: token, then network map
|
||||
if len(pendingUpdates) != 2 {
|
||||
t.Errorf("Expected 2 pending updates, got %d", len(pendingUpdates))
|
||||
}
|
||||
// Token update should come first (preserves order)
|
||||
if pendingUpdates[0] != tokenUpdate {
|
||||
t.Error("First pending update should be tokenUpdate")
|
||||
}
|
||||
// Network map update should come second
|
||||
if pendingUpdates[1] != netmapUpdate2 {
|
||||
t.Error("Second pending update should be netmapUpdate2")
|
||||
}
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
t.Error("Timer should have fired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDebouncer_OrderPreservation(t *testing.T) {
|
||||
debouncer := NewUpdateDebouncer(50 * time.Millisecond)
|
||||
defer debouncer.Stop()
|
||||
|
||||
// Simulate: 50 network maps -> 1 control config -> 50 network maps
|
||||
// Expected result: 3 messages (netmap, controlConfig, netmap)
|
||||
|
||||
// Send first network map immediately
|
||||
firstNetmap := &network_map.UpdateMessage{
|
||||
Update: &proto.SyncResponse{NetworkMap: &proto.NetworkMap{Serial: 0}},
|
||||
MessageType: network_map.MessageTypeNetworkMap,
|
||||
}
|
||||
if !debouncer.ProcessUpdate(firstNetmap) {
|
||||
t.Error("First update should be sent immediately")
|
||||
}
|
||||
|
||||
// Send 49 more network maps (will be coalesced to last one)
|
||||
var lastNetmapBatch1 *network_map.UpdateMessage
|
||||
for i := 1; i < 50; i++ {
|
||||
lastNetmapBatch1 = &network_map.UpdateMessage{
|
||||
Update: &proto.SyncResponse{NetworkMap: &proto.NetworkMap{Serial: uint64(i)}},
|
||||
MessageType: network_map.MessageTypeNetworkMap,
|
||||
}
|
||||
debouncer.ProcessUpdate(lastNetmapBatch1)
|
||||
}
|
||||
|
||||
// Send 1 control config
|
||||
controlConfig := &network_map.UpdateMessage{
|
||||
Update: &proto.SyncResponse{NetbirdConfig: &proto.NetbirdConfig{}},
|
||||
MessageType: network_map.MessageTypeControlConfig,
|
||||
}
|
||||
debouncer.ProcessUpdate(controlConfig)
|
||||
|
||||
// Send 50 more network maps (will be coalesced to last one)
|
||||
var lastNetmapBatch2 *network_map.UpdateMessage
|
||||
for i := 50; i < 100; i++ {
|
||||
lastNetmapBatch2 = &network_map.UpdateMessage{
|
||||
Update: &proto.SyncResponse{NetworkMap: &proto.NetworkMap{Serial: uint64(i)}},
|
||||
MessageType: network_map.MessageTypeNetworkMap,
|
||||
}
|
||||
debouncer.ProcessUpdate(lastNetmapBatch2)
|
||||
}
|
||||
|
||||
// Wait for debounce period
|
||||
select {
|
||||
case <-debouncer.TimerChannel():
|
||||
pendingUpdates := debouncer.GetPendingUpdates()
|
||||
// Should get exactly 3 updates: netmap, controlConfig, netmap
|
||||
if len(pendingUpdates) != 3 {
|
||||
t.Errorf("Expected 3 pending updates, got %d", len(pendingUpdates))
|
||||
}
|
||||
// First should be the last netmap from batch 1
|
||||
if pendingUpdates[0] != lastNetmapBatch1 {
|
||||
t.Error("First pending update should be last netmap from batch 1")
|
||||
}
|
||||
if pendingUpdates[0].Update.NetworkMap.Serial != 49 {
|
||||
t.Errorf("Expected serial 49, got %d", pendingUpdates[0].Update.NetworkMap.Serial)
|
||||
}
|
||||
// Second should be the control config
|
||||
if pendingUpdates[1] != controlConfig {
|
||||
t.Error("Second pending update should be control config")
|
||||
}
|
||||
// Third should be the last netmap from batch 2
|
||||
if pendingUpdates[2] != lastNetmapBatch2 {
|
||||
t.Error("Third pending update should be last netmap from batch 2")
|
||||
}
|
||||
if pendingUpdates[2].Update.NetworkMap.Serial != 99 {
|
||||
t.Errorf("Expected serial 99, got %d", pendingUpdates[2].Update.NetworkMap.Serial)
|
||||
}
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
t.Error("Timer should have fired")
|
||||
}
|
||||
}
|
||||
@@ -795,19 +795,6 @@ func IsEmbeddedIdp(i idp.Manager) bool {
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsLocalAuthDisabled checks if local (email/password) authentication is disabled.
|
||||
// Returns true only when using embedded IDP with local auth disabled in config.
|
||||
func IsLocalAuthDisabled(ctx context.Context, i idp.Manager) bool {
|
||||
if isNil(i) {
|
||||
return false
|
||||
}
|
||||
embeddedIdp, ok := i.(*idp.EmbeddedIdPManager)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return embeddedIdp.IsLocalAuthDisabled()
|
||||
}
|
||||
|
||||
// addAccountIDToIDPAppMeta update user's app metadata in idp manager
|
||||
func (am *DefaultAccountManager) addAccountIDToIDPAppMeta(ctx context.Context, userID string, accountID string) error {
|
||||
if !isNil(am.idpManager) && !IsEmbeddedIdp(am.idpManager) {
|
||||
@@ -1670,13 +1657,13 @@ func domainIsUpToDate(domain string, domainCategory string, userAuth auth.UserAu
|
||||
return domainCategory == types.PrivateCategory || userAuth.DomainCategory != types.PrivateCategory || domain != userAuth.Domain
|
||||
}
|
||||
|
||||
func (am *DefaultAccountManager) SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) {
|
||||
func (am *DefaultAccountManager) SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) {
|
||||
peer, netMap, postureChecks, dnsfwdPort, err := am.SyncPeer(ctx, types.PeerSync{WireGuardPubKey: peerPubKey, Meta: meta}, accountID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, 0, fmt.Errorf("error syncing peer: %w", err)
|
||||
}
|
||||
|
||||
err = am.MarkPeerConnected(ctx, peerPubKey, true, realIP, accountID, syncTime)
|
||||
err = am.MarkPeerConnected(ctx, peerPubKey, true, realIP, accountID)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Warnf("failed marking peer as connected %s %v", peerPubKey, err)
|
||||
}
|
||||
@@ -1684,20 +1671,8 @@ func (am *DefaultAccountManager) SyncAndMarkPeer(ctx context.Context, accountID
|
||||
return peer, netMap, postureChecks, dnsfwdPort, nil
|
||||
}
|
||||
|
||||
func (am *DefaultAccountManager) OnPeerDisconnected(ctx context.Context, accountID string, peerPubKey string, streamStartTime time.Time) error {
|
||||
peer, err := am.Store.GetPeerByPeerPubKey(ctx, store.LockingStrengthNone, peerPubKey)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Warnf("failed to get peer %s for disconnect check: %v", peerPubKey, err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if peer.Status.LastSeen.After(streamStartTime) {
|
||||
log.WithContext(ctx).Tracef("peer %s has newer activity (lastSeen=%s > streamStart=%s), skipping disconnect",
|
||||
peerPubKey, peer.Status.LastSeen.Format(time.RFC3339), streamStartTime.Format(time.RFC3339))
|
||||
return nil
|
||||
}
|
||||
|
||||
err = am.MarkPeerConnected(ctx, peerPubKey, false, nil, accountID, time.Now().UTC())
|
||||
func (am *DefaultAccountManager) OnPeerDisconnected(ctx context.Context, accountID string, peerPubKey string) error {
|
||||
err := am.MarkPeerConnected(ctx, peerPubKey, false, nil, accountID)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Warnf("failed marking peer as disconnected %s %v", peerPubKey, err)
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ type Manager interface {
|
||||
GetUserFromUserAuth(ctx context.Context, userAuth auth.UserAuth) (*types.User, error)
|
||||
ListUsers(ctx context.Context, accountID string) ([]*types.User, error)
|
||||
GetPeers(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error)
|
||||
MarkPeerConnected(ctx context.Context, peerKey string, connected bool, realIP net.IP, accountID string, syncTime time.Time) error
|
||||
MarkPeerConnected(ctx context.Context, peerKey string, connected bool, realIP net.IP, accountID string) error
|
||||
DeletePeer(ctx context.Context, accountID, peerID, userID string) error
|
||||
UpdatePeer(ctx context.Context, accountID, userID string, peer *nbpeer.Peer) (*nbpeer.Peer, error)
|
||||
UpdatePeerIP(ctx context.Context, accountID, userID, peerID string, newIP netip.Addr) error
|
||||
@@ -114,8 +114,8 @@ type Manager interface {
|
||||
UpdateIntegratedValidator(ctx context.Context, accountID, userID, validator string, groups []string) error
|
||||
GroupValidation(ctx context.Context, accountId string, groups []string) (bool, error)
|
||||
GetValidatedPeers(ctx context.Context, accountID string) (map[string]struct{}, map[string]string, error)
|
||||
SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error)
|
||||
OnPeerDisconnected(ctx context.Context, accountID string, peerPubKey string, streamStartTime time.Time) error
|
||||
SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error)
|
||||
OnPeerDisconnected(ctx context.Context, accountID string, peerPubKey string) error
|
||||
SyncPeerMeta(ctx context.Context, peerPubKey string, meta nbpeer.PeerSystemMeta) error
|
||||
FindExistingPostureCheck(accountID string, checks *posture.ChecksDefinition) (*posture.Checks, error)
|
||||
GetAccountIDForPeerKey(ctx context.Context, peerKey string) (string, error)
|
||||
|
||||
@@ -1881,7 +1881,7 @@ func TestDefaultAccountManager_UpdatePeer_PeerLoginExpiration(t *testing.T) {
|
||||
accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID})
|
||||
require.NoError(t, err, "unable to get the account")
|
||||
|
||||
err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), true, nil, accountID, time.Now().UTC())
|
||||
err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), true, nil, accountID)
|
||||
require.NoError(t, err, "unable to mark peer connected")
|
||||
|
||||
_, err = manager.UpdateAccountSettings(context.Background(), accountID, userID, &types.Settings{
|
||||
@@ -1952,7 +1952,7 @@ func TestDefaultAccountManager_MarkPeerConnected_PeerLoginExpiration(t *testing.
|
||||
require.NoError(t, err, "unable to get the account")
|
||||
|
||||
// when we mark peer as connected, the peer login expiration routine should trigger
|
||||
err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), true, nil, accountID, time.Now().UTC())
|
||||
err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), true, nil, accountID)
|
||||
require.NoError(t, err, "unable to mark peer connected")
|
||||
|
||||
failed := waitTimeout(wg, time.Second)
|
||||
@@ -1961,82 +1961,6 @@ func TestDefaultAccountManager_MarkPeerConnected_PeerLoginExpiration(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultAccountManager_OnPeerDisconnected_LastSeenCheck(t *testing.T) {
|
||||
manager, _, err := createManager(t)
|
||||
require.NoError(t, err, "unable to create account manager")
|
||||
|
||||
accountID, err := manager.GetAccountIDByUserID(context.Background(), auth.UserAuth{UserId: userID})
|
||||
require.NoError(t, err, "unable to create an account")
|
||||
|
||||
key, err := wgtypes.GenerateKey()
|
||||
require.NoError(t, err, "unable to generate WireGuard key")
|
||||
peerPubKey := key.PublicKey().String()
|
||||
|
||||
_, _, _, err = manager.AddPeer(context.Background(), "", "", userID, &nbpeer.Peer{
|
||||
Key: peerPubKey,
|
||||
Meta: nbpeer.PeerSystemMeta{Hostname: "test-peer"},
|
||||
}, false)
|
||||
require.NoError(t, err, "unable to add peer")
|
||||
|
||||
t.Run("disconnect peer when streamStartTime is after LastSeen", func(t *testing.T) {
|
||||
err = manager.MarkPeerConnected(context.Background(), peerPubKey, true, nil, accountID, time.Now().UTC())
|
||||
require.NoError(t, err, "unable to mark peer connected")
|
||||
|
||||
peer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey)
|
||||
require.NoError(t, err, "unable to get peer")
|
||||
require.True(t, peer.Status.Connected, "peer should be connected")
|
||||
|
||||
streamStartTime := time.Now().UTC()
|
||||
|
||||
err = manager.OnPeerDisconnected(context.Background(), accountID, peerPubKey, streamStartTime)
|
||||
require.NoError(t, err)
|
||||
|
||||
peer, err = manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey)
|
||||
require.NoError(t, err)
|
||||
require.False(t, peer.Status.Connected, "peer should be disconnected")
|
||||
})
|
||||
|
||||
t.Run("skip disconnect when LastSeen is after streamStartTime (zombie stream protection)", func(t *testing.T) {
|
||||
err = manager.MarkPeerConnected(context.Background(), peerPubKey, true, nil, accountID, time.Now().UTC())
|
||||
require.NoError(t, err, "unable to mark peer connected")
|
||||
|
||||
peer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey)
|
||||
require.NoError(t, err)
|
||||
require.True(t, peer.Status.Connected, "peer should be connected")
|
||||
|
||||
streamStartTime := peer.Status.LastSeen.Add(-1 * time.Hour)
|
||||
|
||||
err = manager.OnPeerDisconnected(context.Background(), accountID, peerPubKey, streamStartTime)
|
||||
require.NoError(t, err)
|
||||
|
||||
peer, err = manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey)
|
||||
require.NoError(t, err)
|
||||
require.True(t, peer.Status.Connected,
|
||||
"peer should remain connected because LastSeen > streamStartTime (zombie stream protection)")
|
||||
})
|
||||
|
||||
t.Run("skip stale connect when peer already has newer LastSeen (blocked goroutine protection)", func(t *testing.T) {
|
||||
node2SyncTime := time.Now().UTC()
|
||||
err = manager.MarkPeerConnected(context.Background(), peerPubKey, true, nil, accountID, node2SyncTime)
|
||||
require.NoError(t, err, "node 2 should connect peer")
|
||||
|
||||
peer, err := manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey)
|
||||
require.NoError(t, err)
|
||||
require.True(t, peer.Status.Connected, "peer should be connected")
|
||||
require.Equal(t, node2SyncTime.Unix(), peer.Status.LastSeen.Unix(), "LastSeen should be node2SyncTime")
|
||||
|
||||
node1StaleSyncTime := node2SyncTime.Add(-1 * time.Minute)
|
||||
err = manager.MarkPeerConnected(context.Background(), peerPubKey, true, nil, accountID, node1StaleSyncTime)
|
||||
require.NoError(t, err, "stale connect should not return error")
|
||||
|
||||
peer, err = manager.Store.GetPeerByPeerPubKey(context.Background(), store.LockingStrengthNone, peerPubKey)
|
||||
require.NoError(t, err)
|
||||
require.True(t, peer.Status.Connected, "peer should still be connected")
|
||||
require.Equal(t, node2SyncTime.Unix(), peer.Status.LastSeen.Unix(),
|
||||
"LastSeen should NOT be overwritten by stale syncTime from blocked goroutine")
|
||||
})
|
||||
}
|
||||
|
||||
func TestDefaultAccountManager_UpdateAccountSettings_PeerLoginExpiration(t *testing.T) {
|
||||
manager, _, err := createManager(t)
|
||||
require.NoError(t, err, "unable to create account manager")
|
||||
@@ -2059,7 +1983,7 @@ func TestDefaultAccountManager_UpdateAccountSettings_PeerLoginExpiration(t *test
|
||||
account, err := manager.Store.GetAccount(context.Background(), accountID)
|
||||
require.NoError(t, err, "unable to get the account")
|
||||
|
||||
err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), true, nil, accountID, time.Now().UTC())
|
||||
err = manager.MarkPeerConnected(context.Background(), key.PublicKey().String(), true, nil, accountID)
|
||||
require.NoError(t, err, "unable to mark peer connected")
|
||||
|
||||
wg := &sync.WaitGroup{}
|
||||
@@ -3252,7 +3176,7 @@ func BenchmarkSyncAndMarkPeer(b *testing.B) {
|
||||
b.ResetTimer()
|
||||
start := time.Now()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _, _, _, err := manager.SyncAndMarkPeer(context.Background(), account.Id, account.Peers["peer-1"].Key, nbpeer.PeerSystemMeta{Hostname: strconv.Itoa(i)}, net.IP{1, 1, 1, 1}, time.Now().UTC())
|
||||
_, _, _, _, err := manager.SyncAndMarkPeer(context.Background(), account.Id, account.Peers["peer-1"].Key, nbpeer.PeerSystemMeta{Hostname: strconv.Itoa(i)}, net.IP{1, 1, 1, 1})
|
||||
assert.NoError(b, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -204,6 +204,10 @@ const (
|
||||
UserInviteLinkRegenerated Activity = 106
|
||||
UserInviteLinkDeleted Activity = 107
|
||||
|
||||
ReverseProxyCreated Activity = 108
|
||||
ReverseProxyUpdated Activity = 109
|
||||
ReverseProxyDeleted Activity = 110
|
||||
|
||||
AccountDeleted Activity = 99999
|
||||
)
|
||||
|
||||
@@ -337,6 +341,10 @@ var activityMap = map[Activity]Code{
|
||||
UserInviteLinkAccepted: {"User invite link accepted", "user.invite.link.accept"},
|
||||
UserInviteLinkRegenerated: {"User invite link regenerated", "user.invite.link.regenerate"},
|
||||
UserInviteLinkDeleted: {"User invite link deleted", "user.invite.link.delete"},
|
||||
|
||||
ReverseProxyCreated: {"Reverse proxy created", "reverseproxy.create"},
|
||||
ReverseProxyUpdated: {"Reverse proxy updated", "reverseproxy.update"},
|
||||
ReverseProxyDeleted: {"Reverse proxy deleted", "reverseproxy.delete"},
|
||||
}
|
||||
|
||||
// StringCode returns a string code of the activity
|
||||
|
||||
@@ -12,6 +12,11 @@ import (
|
||||
"github.com/rs/cors"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
|
||||
reverseproxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/manager"
|
||||
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
|
||||
idpmanager "github.com/netbirdio/netbird/management/server/idp"
|
||||
|
||||
"github.com/netbirdio/management-integrations/integrations"
|
||||
@@ -39,6 +44,7 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/http/handlers/networks"
|
||||
"github.com/netbirdio/netbird/management/server/http/handlers/peers"
|
||||
"github.com/netbirdio/netbird/management/server/http/handlers/policies"
|
||||
"github.com/netbirdio/netbird/management/server/http/handlers/proxy"
|
||||
"github.com/netbirdio/netbird/management/server/http/handlers/routes"
|
||||
"github.com/netbirdio/netbird/management/server/http/handlers/setup_keys"
|
||||
"github.com/netbirdio/netbird/management/server/http/handlers/users"
|
||||
@@ -60,7 +66,7 @@ const (
|
||||
)
|
||||
|
||||
// NewAPIHandler creates the Management service HTTP API handler registering all the available endpoints.
|
||||
func NewAPIHandler(ctx context.Context, accountManager account.Manager, networksManager nbnetworks.Manager, resourceManager resources.Manager, routerManager routers.Manager, groupsManager nbgroups.Manager, LocationManager geolocation.Geolocation, authManager auth.Manager, appMetrics telemetry.AppMetrics, integratedValidator integrated_validator.IntegratedValidator, proxyController port_forwarding.Controller, permissionsManager permissions.Manager, peersManager nbpeers.Manager, settingsManager settings.Manager, zManager zones.Manager, rManager records.Manager, networkMapController network_map.Controller, idpManager idpmanager.Manager) (http.Handler, error) {
|
||||
func NewAPIHandler(ctx context.Context, accountManager account.Manager, networksManager nbnetworks.Manager, resourceManager resources.Manager, routerManager routers.Manager, groupsManager nbgroups.Manager, LocationManager geolocation.Geolocation, authManager auth.Manager, appMetrics telemetry.AppMetrics, integratedValidator integrated_validator.IntegratedValidator, proxyController port_forwarding.Controller, permissionsManager permissions.Manager, peersManager nbpeers.Manager, settingsManager settings.Manager, zManager zones.Manager, rManager records.Manager, networkMapController network_map.Controller, idpManager idpmanager.Manager, reverseProxyManager reverseproxy.Manager, reverseProxyDomainManager *domain.Manager, reverseProxyAccessLogsManager accesslogs.Manager, proxyGRPCServer *nbgrpc.ProxyServiceServer) (http.Handler, error) {
|
||||
|
||||
// Register bypass paths for unauthenticated endpoints
|
||||
if err := bypass.AddBypassPath("/api/instance"); err != nil {
|
||||
@@ -76,6 +82,10 @@ func NewAPIHandler(ctx context.Context, accountManager account.Manager, networks
|
||||
if err := bypass.AddBypassPath("/api/users/invites/nbi_*/accept"); err != nil {
|
||||
return nil, fmt.Errorf("failed to add bypass path: %w", err)
|
||||
}
|
||||
// OAuth callback for proxy authentication
|
||||
if err := bypass.AddBypassPath("/api/oauth/callback"); err != nil {
|
||||
return nil, fmt.Errorf("failed to add bypass path: %w", err)
|
||||
}
|
||||
|
||||
var rateLimitingConfig *middleware.RateLimiterConfig
|
||||
if os.Getenv(rateLimitingEnabledKey) == "true" {
|
||||
@@ -130,15 +140,15 @@ func NewAPIHandler(ctx context.Context, accountManager account.Manager, networks
|
||||
return nil, fmt.Errorf("register integrations endpoints: %w", err)
|
||||
}
|
||||
|
||||
// Check if embedded IdP is enabled for instance manager
|
||||
// Check if embedded IdP is enabled
|
||||
embeddedIdP, embeddedIdpEnabled := idpManager.(*idpmanager.EmbeddedIdPManager)
|
||||
instanceManager, err := nbinstance.NewManager(ctx, accountManager.GetStore(), embeddedIdP)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create instance manager: %w", err)
|
||||
}
|
||||
|
||||
accounts.AddEndpoints(accountManager, settingsManager, router)
|
||||
peers.AddEndpoints(accountManager, router, networkMapController, permissionsManager)
|
||||
accounts.AddEndpoints(accountManager, settingsManager, embeddedIdpEnabled, router)
|
||||
peers.AddEndpoints(accountManager, router, networkMapController)
|
||||
users.AddEndpoints(accountManager, router)
|
||||
users.AddInvitesEndpoints(accountManager, router)
|
||||
users.AddPublicInvitesEndpoints(accountManager, router)
|
||||
@@ -156,6 +166,15 @@ func NewAPIHandler(ctx context.Context, accountManager account.Manager, networks
|
||||
idp.AddEndpoints(accountManager, router)
|
||||
instance.AddEndpoints(instanceManager, router)
|
||||
instance.AddVersionEndpoint(instanceManager, router)
|
||||
if reverseProxyManager != nil && reverseProxyDomainManager != nil {
|
||||
reverseproxymanager.RegisterEndpoints(reverseProxyManager, *reverseProxyDomainManager, reverseProxyAccessLogsManager, router)
|
||||
}
|
||||
|
||||
// Register OAuth callback handler for proxy authentication
|
||||
if proxyGRPCServer != nil {
|
||||
oauthHandler := proxy.NewAuthCallbackHandler(proxyGRPCServer)
|
||||
oauthHandler.RegisterEndpoints(router)
|
||||
}
|
||||
|
||||
// Mount embedded IdP handler at /oauth2 path if configured
|
||||
if embeddedIdpEnabled {
|
||||
|
||||
@@ -36,22 +36,24 @@ const (
|
||||
|
||||
// handler is a handler that handles the server.Account HTTP endpoints
|
||||
type handler struct {
|
||||
accountManager account.Manager
|
||||
settingsManager settings.Manager
|
||||
accountManager account.Manager
|
||||
settingsManager settings.Manager
|
||||
embeddedIdpEnabled bool
|
||||
}
|
||||
|
||||
func AddEndpoints(accountManager account.Manager, settingsManager settings.Manager, router *mux.Router) {
|
||||
accountsHandler := newHandler(accountManager, settingsManager)
|
||||
func AddEndpoints(accountManager account.Manager, settingsManager settings.Manager, embeddedIdpEnabled bool, router *mux.Router) {
|
||||
accountsHandler := newHandler(accountManager, settingsManager, embeddedIdpEnabled)
|
||||
router.HandleFunc("/accounts/{accountId}", accountsHandler.updateAccount).Methods("PUT", "OPTIONS")
|
||||
router.HandleFunc("/accounts/{accountId}", accountsHandler.deleteAccount).Methods("DELETE", "OPTIONS")
|
||||
router.HandleFunc("/accounts", accountsHandler.getAllAccounts).Methods("GET", "OPTIONS")
|
||||
}
|
||||
|
||||
// newHandler creates a new handler HTTP handler
|
||||
func newHandler(accountManager account.Manager, settingsManager settings.Manager) *handler {
|
||||
func newHandler(accountManager account.Manager, settingsManager settings.Manager, embeddedIdpEnabled bool) *handler {
|
||||
return &handler{
|
||||
accountManager: accountManager,
|
||||
settingsManager: settingsManager,
|
||||
accountManager: accountManager,
|
||||
settingsManager: settingsManager,
|
||||
embeddedIdpEnabled: embeddedIdpEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +165,7 @@ func (h *handler) getAllAccounts(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
resp := toAccountResponse(accountID, settings, meta, onboarding)
|
||||
resp := toAccountResponse(accountID, settings, meta, onboarding, h.embeddedIdpEnabled)
|
||||
util.WriteJSONObject(r.Context(), w, []*api.Account{resp})
|
||||
}
|
||||
|
||||
@@ -290,7 +292,7 @@ func (h *handler) updateAccount(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
resp := toAccountResponse(accountID, updatedSettings, meta, updatedOnboarding)
|
||||
resp := toAccountResponse(accountID, updatedSettings, meta, updatedOnboarding, h.embeddedIdpEnabled)
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, &resp)
|
||||
}
|
||||
@@ -319,7 +321,7 @@ func (h *handler) deleteAccount(w http.ResponseWriter, r *http.Request) {
|
||||
util.WriteJSONObject(r.Context(), w, util.EmptyObject{})
|
||||
}
|
||||
|
||||
func toAccountResponse(accountID string, settings *types.Settings, meta *types.AccountMeta, onboarding *types.AccountOnboarding) *api.Account {
|
||||
func toAccountResponse(accountID string, settings *types.Settings, meta *types.AccountMeta, onboarding *types.AccountOnboarding, embeddedIdpEnabled bool) *api.Account {
|
||||
jwtAllowGroups := settings.JWTAllowGroups
|
||||
if jwtAllowGroups == nil {
|
||||
jwtAllowGroups = []string{}
|
||||
@@ -339,8 +341,7 @@ func toAccountResponse(accountID string, settings *types.Settings, meta *types.A
|
||||
LazyConnectionEnabled: &settings.LazyConnectionEnabled,
|
||||
DnsDomain: &settings.DNSDomain,
|
||||
AutoUpdateVersion: &settings.AutoUpdateVersion,
|
||||
EmbeddedIdpEnabled: &settings.EmbeddedIdpEnabled,
|
||||
LocalAuthDisabled: &settings.LocalAuthDisabled,
|
||||
EmbeddedIdpEnabled: &embeddedIdpEnabled,
|
||||
}
|
||||
|
||||
if settings.NetworkRange.IsValid() {
|
||||
|
||||
@@ -33,6 +33,7 @@ func initAccountsTestData(t *testing.T, account *types.Account) *handler {
|
||||
AnyTimes()
|
||||
|
||||
return &handler{
|
||||
embeddedIdpEnabled: false,
|
||||
accountManager: &mock_server.MockAccountManager{
|
||||
GetAccountSettingsFunc: func(ctx context.Context, accountID string, userID string) (*types.Settings, error) {
|
||||
return account.Settings, nil
|
||||
@@ -123,7 +124,6 @@ func TestAccounts_AccountsHandler(t *testing.T) {
|
||||
DnsDomain: sr(""),
|
||||
AutoUpdateVersion: sr(""),
|
||||
EmbeddedIdpEnabled: br(false),
|
||||
LocalAuthDisabled: br(false),
|
||||
},
|
||||
expectedArray: true,
|
||||
expectedID: accountID,
|
||||
@@ -148,7 +148,6 @@ func TestAccounts_AccountsHandler(t *testing.T) {
|
||||
DnsDomain: sr(""),
|
||||
AutoUpdateVersion: sr(""),
|
||||
EmbeddedIdpEnabled: br(false),
|
||||
LocalAuthDisabled: br(false),
|
||||
},
|
||||
expectedArray: false,
|
||||
expectedID: accountID,
|
||||
@@ -173,7 +172,6 @@ func TestAccounts_AccountsHandler(t *testing.T) {
|
||||
DnsDomain: sr(""),
|
||||
AutoUpdateVersion: sr("latest"),
|
||||
EmbeddedIdpEnabled: br(false),
|
||||
LocalAuthDisabled: br(false),
|
||||
},
|
||||
expectedArray: false,
|
||||
expectedID: accountID,
|
||||
@@ -198,7 +196,6 @@ func TestAccounts_AccountsHandler(t *testing.T) {
|
||||
DnsDomain: sr(""),
|
||||
AutoUpdateVersion: sr(""),
|
||||
EmbeddedIdpEnabled: br(false),
|
||||
LocalAuthDisabled: br(false),
|
||||
},
|
||||
expectedArray: false,
|
||||
expectedID: accountID,
|
||||
@@ -223,7 +220,6 @@ func TestAccounts_AccountsHandler(t *testing.T) {
|
||||
DnsDomain: sr(""),
|
||||
AutoUpdateVersion: sr(""),
|
||||
EmbeddedIdpEnabled: br(false),
|
||||
LocalAuthDisabled: br(false),
|
||||
},
|
||||
expectedArray: false,
|
||||
expectedID: accountID,
|
||||
@@ -248,7 +244,6 @@ func TestAccounts_AccountsHandler(t *testing.T) {
|
||||
DnsDomain: sr(""),
|
||||
AutoUpdateVersion: sr(""),
|
||||
EmbeddedIdpEnabled: br(false),
|
||||
LocalAuthDisabled: br(false),
|
||||
},
|
||||
expectedArray: false,
|
||||
expectedID: accountID,
|
||||
|
||||
@@ -46,7 +46,7 @@ func (h *handler) getInstanceStatus(w http.ResponseWriter, r *http.Request) {
|
||||
util.WriteErrorResponse("failed to check instance status", http.StatusInternalServerError, w)
|
||||
return
|
||||
}
|
||||
log.WithContext(r.Context()).Infof("instance setup status: %v", setupRequired)
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, api.InstanceStatus{
|
||||
SetupRequired: setupRequired,
|
||||
})
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||
"github.com/netbirdio/netbird/management/server/groups"
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
"github.com/netbirdio/netbird/shared/management/http/util"
|
||||
@@ -27,12 +26,11 @@ import (
|
||||
// Handler is a handler that returns peers of the account
|
||||
type Handler struct {
|
||||
accountManager account.Manager
|
||||
permissionsManager permissions.Manager
|
||||
networkMapController network_map.Controller
|
||||
}
|
||||
|
||||
func AddEndpoints(accountManager account.Manager, router *mux.Router, networkMapController network_map.Controller, permissionsManager permissions.Manager) {
|
||||
peersHandler := NewHandler(accountManager, networkMapController, permissionsManager)
|
||||
func AddEndpoints(accountManager account.Manager, router *mux.Router, networkMapController network_map.Controller) {
|
||||
peersHandler := NewHandler(accountManager, networkMapController)
|
||||
router.HandleFunc("/peers", peersHandler.GetAllPeers).Methods("GET", "OPTIONS")
|
||||
router.HandleFunc("/peers/{peerId}", peersHandler.HandlePeer).
|
||||
Methods("GET", "PUT", "DELETE", "OPTIONS")
|
||||
@@ -44,11 +42,10 @@ func AddEndpoints(accountManager account.Manager, router *mux.Router, networkMap
|
||||
}
|
||||
|
||||
// NewHandler creates a new peers Handler
|
||||
func NewHandler(accountManager account.Manager, networkMapController network_map.Controller, permissionsManager permissions.Manager) *Handler {
|
||||
func NewHandler(accountManager account.Manager, networkMapController network_map.Controller) *Handler {
|
||||
return &Handler{
|
||||
accountManager: accountManager,
|
||||
networkMapController: networkMapController,
|
||||
permissionsManager: permissionsManager,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,19 +359,13 @@ func (h *Handler) GetAccessiblePeers(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.accountManager.GetUserByID(r.Context(), userID)
|
||||
account, err := h.accountManager.GetAccountByID(r.Context(), accountID, activity.SystemInitiator)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
|
||||
err = h.permissionsManager.ValidateAccountAccess(r.Context(), accountID, user, false)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), status.NewPermissionDeniedError(), w)
|
||||
return
|
||||
}
|
||||
|
||||
account, err := h.accountManager.GetAccountByID(r.Context(), accountID, activity.SystemInitiator)
|
||||
user, err := h.accountManager.GetUserByID(r.Context(), userID)
|
||||
if err != nil {
|
||||
util.WriteError(r.Context(), err, w)
|
||||
return
|
||||
@@ -404,7 +395,7 @@ func (h *Handler) GetAccessiblePeers(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
dnsDomain := h.networkMapController.GetDNSDomain(account.Settings)
|
||||
|
||||
netMap := account.GetPeerNetworkMap(r.Context(), peerID, dns.CustomZone{}, nil, validPeers, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), nil, account.GetActiveGroupUsers())
|
||||
netMap := account.GetPeerNetworkMap(r.Context(), peerID, dns.CustomZone{}, nil, validPeers, account.GetResourcePoliciesMap(), account.GetResourceRoutersMap(), nil, account.GetActiveGroupUsers(), account.GetExposedServicesMap(), account.GetProxyPeers())
|
||||
|
||||
util.WriteJSONObject(r.Context(), w, toAccessiblePeers(netMap, dnsDomain))
|
||||
}
|
||||
|
||||
@@ -13,15 +13,13 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang/mock/gomock"
|
||||
"github.com/gorilla/mux"
|
||||
ugomock "go.uber.org/mock/gomock"
|
||||
"go.uber.org/mock/gomock"
|
||||
"golang.org/x/exp/maps"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
|
||||
nbcontext "github.com/netbirdio/netbird/management/server/context"
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/auth"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
@@ -104,7 +102,7 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler {
|
||||
},
|
||||
}
|
||||
|
||||
ctrl := ugomock.NewController(t)
|
||||
ctrl := gomock.NewController(t)
|
||||
|
||||
networkMapController := network_map.NewMockController(ctrl)
|
||||
networkMapController.EXPECT().
|
||||
@@ -112,10 +110,6 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler {
|
||||
Return("domain").
|
||||
AnyTimes()
|
||||
|
||||
ctrl2 := gomock.NewController(t)
|
||||
permissionsManager := permissions.NewMockManager(ctrl2)
|
||||
permissionsManager.EXPECT().ValidateAccountAccess(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
|
||||
|
||||
return &Handler{
|
||||
accountManager: &mock_server.MockAccountManager{
|
||||
UpdatePeerFunc: func(_ context.Context, accountID, userID string, update *nbpeer.Peer) (*nbpeer.Peer, error) {
|
||||
@@ -205,7 +199,6 @@ func initTestMetaData(t *testing.T, peers ...*nbpeer.Peer) *Handler {
|
||||
},
|
||||
},
|
||||
networkMapController: networkMapController,
|
||||
permissionsManager: permissionsManager,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
143
management/server/http/handlers/proxy/auth.go
Normal file
143
management/server/http/handlers/proxy/auth.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/gorilla/mux"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/oauth2"
|
||||
|
||||
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
|
||||
"github.com/netbirdio/netbird/proxy/auth"
|
||||
)
|
||||
|
||||
type AuthCallbackHandler struct {
|
||||
proxyService *nbgrpc.ProxyServiceServer
|
||||
}
|
||||
|
||||
func NewAuthCallbackHandler(proxyService *nbgrpc.ProxyServiceServer) *AuthCallbackHandler {
|
||||
return &AuthCallbackHandler{
|
||||
proxyService: proxyService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AuthCallbackHandler) RegisterEndpoints(router *mux.Router) {
|
||||
router.HandleFunc("/oauth/callback", h.handleCallback).Methods(http.MethodGet)
|
||||
}
|
||||
|
||||
func (h *AuthCallbackHandler) handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
state := r.URL.Query().Get("state")
|
||||
|
||||
codeVerifier, originalURL, err := h.proxyService.ValidateState(state)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("OAuth callback state validation failed")
|
||||
http.Error(w, "Invalid state parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
redirectURL, err := url.Parse(originalURL)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("Failed to parse redirect URL")
|
||||
http.Error(w, "Invalid redirect URL", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get OIDC configuration
|
||||
oidcConfig := h.proxyService.GetOIDCConfig()
|
||||
|
||||
// Create OIDC provider to discover endpoints
|
||||
provider, err := oidc.NewProvider(r.Context(), oidcConfig.Issuer)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("Failed to create OIDC provider")
|
||||
http.Error(w, "Failed to create OIDC provider", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := (&oauth2.Config{
|
||||
ClientID: oidcConfig.ClientID,
|
||||
Endpoint: provider.Endpoint(),
|
||||
RedirectURL: oidcConfig.CallbackURL,
|
||||
}).Exchange(r.Context(), r.URL.Query().Get("code"), oauth2.VerifierOption(codeVerifier))
|
||||
if err != nil {
|
||||
log.WithError(err).Error("Failed to exchange code for token")
|
||||
http.Error(w, "Failed to exchange code for token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract user ID from the OIDC token
|
||||
userID := extractUserIDFromToken(r.Context(), provider, oidcConfig, token)
|
||||
if userID == "" {
|
||||
log.Error("Failed to extract user ID from OIDC token")
|
||||
http.Error(w, "Failed to validate token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate session JWT instead of passing OIDC access_token
|
||||
sessionToken, err := h.proxyService.GenerateSessionToken(r.Context(), redirectURL.Hostname(), userID, auth.MethodOIDC)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("Failed to create session token")
|
||||
http.Error(w, "Failed to create session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect must be HTTPS, regardless of what was originally intended (which should always be HTTPS but better to double-check here).
|
||||
redirectURL.Scheme = "https"
|
||||
|
||||
// Pass the session token in the URL query parameter. The proxy middleware will
|
||||
// extract it, validate it, set its own cookie, and redirect to remove the token from the URL.
|
||||
// We cannot set the cookie here because cookies are domain-scoped (RFC 6265) and the
|
||||
// management server cannot set cookies for the proxy's domain.
|
||||
query := redirectURL.Query()
|
||||
query.Set("session_token", sessionToken)
|
||||
redirectURL.RawQuery = query.Encode()
|
||||
|
||||
log.WithField("redirect", redirectURL.Host).Debug("OAuth callback: redirecting user with session token")
|
||||
http.Redirect(w, r, redirectURL.String(), http.StatusFound)
|
||||
}
|
||||
|
||||
// extractUserIDFromToken extracts the user ID from an OIDC token.
|
||||
func extractUserIDFromToken(ctx context.Context, provider *oidc.Provider, config nbgrpc.ProxyOIDCConfig, token *oauth2.Token) string {
|
||||
// Try to get ID token from the oauth2 token extras
|
||||
rawIDToken, ok := token.Extra("id_token").(string)
|
||||
if !ok {
|
||||
log.Warn("No id_token in OIDC response")
|
||||
return ""
|
||||
}
|
||||
|
||||
verifier := provider.Verifier(&oidc.Config{
|
||||
ClientID: config.ClientID,
|
||||
})
|
||||
|
||||
idToken, err := verifier.Verify(ctx, rawIDToken)
|
||||
if err != nil {
|
||||
log.WithError(err).Warn("Failed to verify ID token")
|
||||
return ""
|
||||
}
|
||||
|
||||
// Extract claims
|
||||
var claims struct {
|
||||
Subject string `json:"sub"`
|
||||
Email string `json:"email"`
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
if err := idToken.Claims(&claims); err != nil {
|
||||
log.WithError(err).Warn("Failed to extract claims from ID token")
|
||||
return ""
|
||||
}
|
||||
|
||||
// Prefer subject, fall back to user_id or email
|
||||
if claims.Subject != "" {
|
||||
return claims.Subject
|
||||
}
|
||||
if claims.UserID != "" {
|
||||
return claims.UserID
|
||||
}
|
||||
if claims.Email != "" {
|
||||
return claims.Email
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
@@ -205,14 +205,6 @@ func TestCreateInvite(t *testing.T) {
|
||||
return nil, status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "local auth disabled",
|
||||
requestBody: `{"email":"test@example.com","name":"Test User","role":"user","auto_groups":[]}`,
|
||||
expectedStatus: http.StatusPreconditionFailed,
|
||||
mockFunc: func(ctx context.Context, accountID, initiatorUserID string, invite *types.UserInfo, expiresIn int) (*types.UserInvite, error) {
|
||||
return nil, status.Errorf(status.PreconditionFailed, "local user creation is disabled - use an external identity provider")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid JSON",
|
||||
requestBody: `{invalid json}`,
|
||||
@@ -384,15 +376,6 @@ func TestAcceptInvite(t *testing.T) {
|
||||
return status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "local auth disabled",
|
||||
token: testInviteToken,
|
||||
requestBody: `{"password":"SecurePass123!"}`,
|
||||
expectedStatus: http.StatusPreconditionFailed,
|
||||
mockFunc: func(ctx context.Context, token, password string) error {
|
||||
return status.Errorf(status.PreconditionFailed, "local user creation is disabled - use an external identity provider")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing token",
|
||||
token: "",
|
||||
|
||||
@@ -73,7 +73,7 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee
|
||||
proxyController := integrations.NewController(store)
|
||||
userManager := users.NewManager(store)
|
||||
permissionsManager := permissions.NewManager(store)
|
||||
settingsManager := settings.NewManager(store, userManager, integrations.NewManager(&activity.InMemoryEventStore{}), permissionsManager, settings.IdpConfig{})
|
||||
settingsManager := settings.NewManager(store, userManager, integrations.NewManager(&activity.InMemoryEventStore{}), permissionsManager)
|
||||
peersManager := peers.NewManager(store, permissionsManager)
|
||||
|
||||
jobManager := job.NewJobManager(nil, store, peersManager)
|
||||
@@ -102,7 +102,7 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee
|
||||
customZonesManager := zonesManager.NewManager(store, am, permissionsManager, "")
|
||||
zoneRecordsManager := recordsManager.NewManager(store, am, permissionsManager)
|
||||
|
||||
apiHandler, err := http2.NewAPIHandler(context.Background(), am, networksManagerMock, resourcesManagerMock, routersManagerMock, groupsManagerMock, geoMock, authManagerMock, metrics, validatorMock, proxyController, permissionsManager, peersManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil)
|
||||
apiHandler, err := http2.NewAPIHandler(context.Background(), am, networksManagerMock, resourcesManagerMock, routersManagerMock, groupsManagerMock, geoMock, authManagerMock, metrics, validatorMock, proxyController, permissionsManager, peersManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create API handler: %v", err)
|
||||
}
|
||||
|
||||
@@ -135,7 +135,7 @@ func NewAuth0Manager(config Auth0ClientConfig, appMetrics telemetry.AppMetrics)
|
||||
httpTransport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
httpTransport.MaxIdleConns = 5
|
||||
|
||||
httpClient := &http.Client{
|
||||
httpClient := &http.Client{
|
||||
Timeout: idpTimeout(),
|
||||
Transport: httpTransport,
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ func NewAuthentikManager(config AuthentikClientConfig, appMetrics telemetry.AppM
|
||||
Timeout: idpTimeout(),
|
||||
Transport: httpTransport,
|
||||
}
|
||||
|
||||
|
||||
helper := JsonParser{}
|
||||
|
||||
if config.ClientID == "" {
|
||||
|
||||
@@ -57,11 +57,11 @@ func NewAzureManager(config AzureClientConfig, appMetrics telemetry.AppMetrics)
|
||||
httpTransport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
httpTransport.MaxIdleConns = 5
|
||||
|
||||
httpClient := &http.Client{
|
||||
httpClient := &http.Client{
|
||||
Timeout: idpTimeout(),
|
||||
Transport: httpTransport,
|
||||
}
|
||||
|
||||
|
||||
helper := JsonParser{}
|
||||
|
||||
if config.ClientID == "" {
|
||||
|
||||
@@ -43,11 +43,6 @@ type EmbeddedIdPConfig struct {
|
||||
Owner *OwnerConfig
|
||||
// SignKeyRefreshEnabled enables automatic key rotation for signing keys
|
||||
SignKeyRefreshEnabled bool
|
||||
// LocalAuthDisabled disables the local (email/password) authentication connector.
|
||||
// When true, users cannot authenticate via email/password, only via external identity providers.
|
||||
// Existing local users are preserved and will be able to login again if re-enabled.
|
||||
// Cannot be enabled if no external identity provider connectors are configured.
|
||||
LocalAuthDisabled bool
|
||||
}
|
||||
|
||||
// EmbeddedStorageConfig holds storage configuration for the embedded IdP.
|
||||
@@ -91,6 +86,11 @@ func (c *EmbeddedIdPConfig) ToYAMLConfig() (*dex.YAMLConfig, error) {
|
||||
cliRedirectURIs = append(cliRedirectURIs, "/device/callback")
|
||||
cliRedirectURIs = append(cliRedirectURIs, c.Issuer+"/device/callback")
|
||||
|
||||
// Build dashboard redirect URIs including the OAuth callback for proxy authentication
|
||||
dashboardRedirectURIs := c.DashboardRedirectURIs
|
||||
baseURL := strings.TrimSuffix(c.Issuer, "/oauth2")
|
||||
dashboardRedirectURIs = append(dashboardRedirectURIs, baseURL+"/api/oauth/callback")
|
||||
|
||||
cfg := &dex.YAMLConfig{
|
||||
Issuer: c.Issuer,
|
||||
Storage: dex.Storage{
|
||||
@@ -110,15 +110,13 @@ func (c *EmbeddedIdPConfig) ToYAMLConfig() (*dex.YAMLConfig, error) {
|
||||
Issuer: "NetBird",
|
||||
Theme: "light",
|
||||
},
|
||||
// Always enable password DB initially - we disable the local connector after startup if needed.
|
||||
// This ensures Dex has at least one connector during initialization.
|
||||
EnablePasswordDB: true,
|
||||
StaticClients: []storage.Client{
|
||||
{
|
||||
ID: staticClientDashboard,
|
||||
Name: "NetBird Dashboard",
|
||||
Public: true,
|
||||
RedirectURIs: c.DashboardRedirectURIs,
|
||||
RedirectURIs: dashboardRedirectURIs,
|
||||
},
|
||||
{
|
||||
ID: staticClientCLI,
|
||||
@@ -199,32 +197,11 @@ func NewEmbeddedIdPManager(ctx context.Context, config *EmbeddedIdPConfig, appMe
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.WithContext(ctx).Debugf("initializing embedded Dex IDP with config: %+v", config)
|
||||
|
||||
provider, err := dex.NewProviderFromYAML(ctx, yamlConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create embedded IdP provider: %w", err)
|
||||
}
|
||||
|
||||
// If local auth is disabled, validate that other connectors exist
|
||||
if config.LocalAuthDisabled {
|
||||
hasOthers, err := provider.HasNonLocalConnectors(ctx)
|
||||
if err != nil {
|
||||
_ = provider.Stop(ctx)
|
||||
return nil, fmt.Errorf("failed to check connectors: %w", err)
|
||||
}
|
||||
if !hasOthers {
|
||||
_ = provider.Stop(ctx)
|
||||
return nil, fmt.Errorf("cannot disable local authentication: no other identity providers configured")
|
||||
}
|
||||
// Ensure local connector is removed (it might exist from a previous run)
|
||||
if err := provider.DisableLocalAuth(ctx); err != nil {
|
||||
_ = provider.Stop(ctx)
|
||||
return nil, fmt.Errorf("failed to disable local auth: %w", err)
|
||||
}
|
||||
log.WithContext(ctx).Info("local authentication disabled - only external identity providers can be used")
|
||||
}
|
||||
|
||||
log.WithContext(ctx).Infof("embedded Dex IDP initialized with issuer: %s", yamlConfig.Issuer)
|
||||
|
||||
return &EmbeddedIdPManager{
|
||||
@@ -309,8 +286,6 @@ func (m *EmbeddedIdPManager) GetAllAccounts(ctx context.Context) (map[string][]*
|
||||
return nil, fmt.Errorf("failed to list users: %w", err)
|
||||
}
|
||||
|
||||
log.WithContext(ctx).Debugf("retrieved %d users from embedded IdP", len(users))
|
||||
|
||||
indexedUsers := make(map[string][]*UserData)
|
||||
for _, user := range users {
|
||||
indexedUsers[UnsetAccountID] = append(indexedUsers[UnsetAccountID], &UserData{
|
||||
@@ -320,17 +295,11 @@ func (m *EmbeddedIdPManager) GetAllAccounts(ctx context.Context) (map[string][]*
|
||||
})
|
||||
}
|
||||
|
||||
log.WithContext(ctx).Debugf("retrieved %d users from embedded IdP", len(indexedUsers[UnsetAccountID]))
|
||||
|
||||
return indexedUsers, nil
|
||||
}
|
||||
|
||||
// CreateUser creates a new user in the embedded IdP.
|
||||
func (m *EmbeddedIdPManager) CreateUser(ctx context.Context, email, name, accountID, invitedByEmail string) (*UserData, error) {
|
||||
if m.config.LocalAuthDisabled {
|
||||
return nil, fmt.Errorf("local user creation is disabled")
|
||||
}
|
||||
|
||||
if m.appMetrics != nil {
|
||||
m.appMetrics.IDPMetrics().CountCreateUser()
|
||||
}
|
||||
@@ -400,10 +369,6 @@ func (m *EmbeddedIdPManager) GetUserByEmail(ctx context.Context, email string) (
|
||||
// Unlike CreateUser which auto-generates a password, this method uses the provided password.
|
||||
// This is useful for instance setup where the user provides their own password.
|
||||
func (m *EmbeddedIdPManager) CreateUserWithPassword(ctx context.Context, email, password, name string) (*UserData, error) {
|
||||
if m.config.LocalAuthDisabled {
|
||||
return nil, fmt.Errorf("local user creation is disabled")
|
||||
}
|
||||
|
||||
if m.appMetrics != nil {
|
||||
m.appMetrics.IDPMetrics().CountCreateUser()
|
||||
}
|
||||
@@ -593,13 +558,3 @@ func (m *EmbeddedIdPManager) GetClientIDs() []string {
|
||||
func (m *EmbeddedIdPManager) GetUserIDClaim() string {
|
||||
return defaultUserIDClaim
|
||||
}
|
||||
|
||||
// IsLocalAuthDisabled returns whether local authentication is disabled based on configuration.
|
||||
func (m *EmbeddedIdPManager) IsLocalAuthDisabled() bool {
|
||||
return m.config.LocalAuthDisabled
|
||||
}
|
||||
|
||||
// HasNonLocalConnectors checks if there are any identity provider connectors other than local.
|
||||
func (m *EmbeddedIdPManager) HasNonLocalConnectors(ctx context.Context) (bool, error) {
|
||||
return m.provider.HasNonLocalConnectors(ctx)
|
||||
}
|
||||
|
||||
@@ -370,234 +370,3 @@ func TestEmbeddedIdPManager_GetLocalKeysLocation(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddedIdPManager_LocalAuthDisabled(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("cannot start with local auth disabled without other connectors", func(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
config := &EmbeddedIdPConfig{
|
||||
Enabled: true,
|
||||
Issuer: "http://localhost:5556/dex",
|
||||
LocalAuthDisabled: true,
|
||||
Storage: EmbeddedStorageConfig{
|
||||
Type: "sqlite3",
|
||||
Config: EmbeddedStorageTypeConfig{
|
||||
File: filepath.Join(tmpDir, "dex.db"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err = NewEmbeddedIdPManager(ctx, config, nil)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no other identity providers configured")
|
||||
})
|
||||
|
||||
t.Run("local auth enabled by default", func(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
config := &EmbeddedIdPConfig{
|
||||
Enabled: true,
|
||||
Issuer: "http://localhost:5556/dex",
|
||||
Storage: EmbeddedStorageConfig{
|
||||
Type: "sqlite3",
|
||||
Config: EmbeddedStorageTypeConfig{
|
||||
File: filepath.Join(tmpDir, "dex.db"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
manager, err := NewEmbeddedIdPManager(ctx, config, nil)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = manager.Stop(ctx) }()
|
||||
|
||||
// Verify local auth is enabled by default
|
||||
assert.False(t, manager.IsLocalAuthDisabled())
|
||||
})
|
||||
|
||||
t.Run("start with local auth disabled when connector exists", func(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dbFile := filepath.Join(tmpDir, "dex.db")
|
||||
|
||||
// First, create a manager with local auth enabled and add a connector
|
||||
config1 := &EmbeddedIdPConfig{
|
||||
Enabled: true,
|
||||
Issuer: "http://localhost:5556/dex",
|
||||
Storage: EmbeddedStorageConfig{
|
||||
Type: "sqlite3",
|
||||
Config: EmbeddedStorageTypeConfig{
|
||||
File: dbFile,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
manager1, err := NewEmbeddedIdPManager(ctx, config1, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a user
|
||||
userData, err := manager1.CreateUser(ctx, "preserved@example.com", "Preserved User", "account1", "admin@example.com")
|
||||
require.NoError(t, err)
|
||||
userID := userData.ID
|
||||
|
||||
// Add an external connector (Google doesn't require OIDC discovery)
|
||||
_, err = manager1.CreateConnector(ctx, &dex.ConnectorConfig{
|
||||
ID: "google-test",
|
||||
Name: "Google Test",
|
||||
Type: "google",
|
||||
ClientID: "test-client-id",
|
||||
ClientSecret: "test-client-secret",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Stop the first manager
|
||||
err = manager1.Stop(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Now create a new manager with local auth disabled
|
||||
config2 := &EmbeddedIdPConfig{
|
||||
Enabled: true,
|
||||
Issuer: "http://localhost:5556/dex",
|
||||
LocalAuthDisabled: true,
|
||||
Storage: EmbeddedStorageConfig{
|
||||
Type: "sqlite3",
|
||||
Config: EmbeddedStorageTypeConfig{
|
||||
File: dbFile,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
manager2, err := NewEmbeddedIdPManager(ctx, config2, nil)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = manager2.Stop(ctx) }()
|
||||
|
||||
// Verify local auth is disabled via config
|
||||
assert.True(t, manager2.IsLocalAuthDisabled())
|
||||
|
||||
// Verify the user still exists in storage (just can't login via local)
|
||||
lookedUp, err := manager2.GetUserDataByID(ctx, userID, AppMetadata{})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "preserved@example.com", lookedUp.Email)
|
||||
})
|
||||
|
||||
t.Run("CreateUser fails when local auth is disabled", func(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dbFile := filepath.Join(tmpDir, "dex.db")
|
||||
|
||||
// First, create a manager and add an external connector
|
||||
config1 := &EmbeddedIdPConfig{
|
||||
Enabled: true,
|
||||
Issuer: "http://localhost:5556/dex",
|
||||
Storage: EmbeddedStorageConfig{
|
||||
Type: "sqlite3",
|
||||
Config: EmbeddedStorageTypeConfig{
|
||||
File: dbFile,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
manager1, err := NewEmbeddedIdPManager(ctx, config1, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = manager1.CreateConnector(ctx, &dex.ConnectorConfig{
|
||||
ID: "google-test",
|
||||
Name: "Google Test",
|
||||
Type: "google",
|
||||
ClientID: "test-client-id",
|
||||
ClientSecret: "test-client-secret",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = manager1.Stop(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create manager with local auth disabled
|
||||
config2 := &EmbeddedIdPConfig{
|
||||
Enabled: true,
|
||||
Issuer: "http://localhost:5556/dex",
|
||||
LocalAuthDisabled: true,
|
||||
Storage: EmbeddedStorageConfig{
|
||||
Type: "sqlite3",
|
||||
Config: EmbeddedStorageTypeConfig{
|
||||
File: dbFile,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
manager2, err := NewEmbeddedIdPManager(ctx, config2, nil)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = manager2.Stop(ctx) }()
|
||||
|
||||
// Try to create a user - should fail
|
||||
_, err = manager2.CreateUser(ctx, "newuser@example.com", "New User", "account1", "admin@example.com")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "local user creation is disabled")
|
||||
})
|
||||
|
||||
t.Run("CreateUserWithPassword fails when local auth is disabled", func(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "embedded-idp-test-*")
|
||||
require.NoError(t, err)
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
dbFile := filepath.Join(tmpDir, "dex.db")
|
||||
|
||||
// First, create a manager and add an external connector
|
||||
config1 := &EmbeddedIdPConfig{
|
||||
Enabled: true,
|
||||
Issuer: "http://localhost:5556/dex",
|
||||
Storage: EmbeddedStorageConfig{
|
||||
Type: "sqlite3",
|
||||
Config: EmbeddedStorageTypeConfig{
|
||||
File: dbFile,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
manager1, err := NewEmbeddedIdPManager(ctx, config1, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = manager1.CreateConnector(ctx, &dex.ConnectorConfig{
|
||||
ID: "google-test",
|
||||
Name: "Google Test",
|
||||
Type: "google",
|
||||
ClientID: "test-client-id",
|
||||
ClientSecret: "test-client-secret",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = manager1.Stop(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create manager with local auth disabled
|
||||
config2 := &EmbeddedIdPConfig{
|
||||
Enabled: true,
|
||||
Issuer: "http://localhost:5556/dex",
|
||||
LocalAuthDisabled: true,
|
||||
Storage: EmbeddedStorageConfig{
|
||||
Type: "sqlite3",
|
||||
Config: EmbeddedStorageTypeConfig{
|
||||
File: dbFile,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
manager2, err := NewEmbeddedIdPManager(ctx, config2, nil)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = manager2.Stop(ctx) }()
|
||||
|
||||
// Try to create a user with password - should fail
|
||||
_, err = manager2.CreateUserWithPassword(ctx, "newuser@example.com", "SecurePass123!", "New User")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "local user creation is disabled")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ func NewGoogleWorkspaceManager(ctx context.Context, config GoogleWorkspaceClient
|
||||
Timeout: idpTimeout(),
|
||||
Transport: httpTransport,
|
||||
}
|
||||
|
||||
|
||||
helper := JsonParser{}
|
||||
|
||||
if config.CustomerID == "" {
|
||||
|
||||
@@ -66,7 +66,7 @@ func NewKeycloakManager(config KeycloakClientConfig, appMetrics telemetry.AppMet
|
||||
Timeout: idpTimeout(),
|
||||
Transport: httpTransport,
|
||||
}
|
||||
|
||||
|
||||
helper := JsonParser{}
|
||||
|
||||
if config.ClientID == "" {
|
||||
|
||||
@@ -90,7 +90,7 @@ func NewPocketIdManager(config PocketIdClientConfig, appMetrics telemetry.AppMet
|
||||
Timeout: idpTimeout(),
|
||||
Transport: httpTransport,
|
||||
}
|
||||
|
||||
|
||||
helper := JsonParser{}
|
||||
|
||||
if config.ManagementEndpoint == "" {
|
||||
|
||||
@@ -76,7 +76,7 @@ const (
|
||||
// Provides the env variable name for use with idpTimeout function
|
||||
idpTimeoutEnv = "NB_IDP_TIMEOUT"
|
||||
// Sets the defaultTimeout to 10s.
|
||||
defaultTimeout = 10 * time.Second
|
||||
defaultTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// idpTimeout returns a timeout value for the IDP
|
||||
|
||||
@@ -167,7 +167,7 @@ func NewZitadelManager(config ZitadelClientConfig, appMetrics telemetry.AppMetri
|
||||
Timeout: idpTimeout(),
|
||||
Transport: httpTransport,
|
||||
}
|
||||
|
||||
|
||||
helper := JsonParser{}
|
||||
|
||||
hasPAT := config.PAT != ""
|
||||
|
||||
@@ -104,22 +104,13 @@ func NewManager(ctx context.Context, store store.Store, idpManager idp.Manager)
|
||||
}
|
||||
|
||||
func (m *DefaultManager) loadSetupRequired(ctx context.Context) error {
|
||||
// Check if there are any accounts in the NetBird store
|
||||
numAccounts, err := m.store.GetAccountsCounter(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hasAccounts := numAccounts > 0
|
||||
|
||||
// Check if there are any users in the embedded IdP (Dex)
|
||||
users, err := m.embeddedIdpManager.GetAllAccounts(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hasLocalUsers := len(users) > 0
|
||||
|
||||
m.setupMu.Lock()
|
||||
m.setupRequired = !(hasAccounts || hasLocalUsers)
|
||||
m.setupRequired = len(users) == 0
|
||||
m.setupMu.Unlock()
|
||||
|
||||
return nil
|
||||
|
||||
@@ -610,7 +610,6 @@ func TestSync10PeersGetUpdates(t *testing.T) {
|
||||
|
||||
initialPeers := 10
|
||||
additionalPeers := 10
|
||||
expectedPeerCount := initialPeers + additionalPeers - 1 // -1 because peer doesn't see itself
|
||||
|
||||
var peers []wgtypes.Key
|
||||
for i := 0; i < initialPeers; i++ {
|
||||
@@ -619,19 +618,8 @@ func TestSync10PeersGetUpdates(t *testing.T) {
|
||||
peers = append(peers, key)
|
||||
}
|
||||
|
||||
// Track the maximum peer count each peer has seen
|
||||
type peerState struct {
|
||||
mu sync.Mutex
|
||||
maxPeerCount int
|
||||
done bool
|
||||
}
|
||||
peerStates := make(map[string]*peerState)
|
||||
for _, pk := range peers {
|
||||
peerStates[pk.PublicKey().String()] = &peerState{}
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(initialPeers) // One completion per initial peer
|
||||
wg.Add(initialPeers + initialPeers*additionalPeers)
|
||||
|
||||
var syncClients []mgmtProto.ManagementService_SyncClient
|
||||
for _, pk := range peers {
|
||||
@@ -655,9 +643,6 @@ func TestSync10PeersGetUpdates(t *testing.T) {
|
||||
syncClients = append(syncClients, s)
|
||||
|
||||
go func(pk wgtypes.Key, syncStream mgmtProto.ManagementService_SyncClient) {
|
||||
pubKey := pk.PublicKey().String()
|
||||
state := peerStates[pubKey]
|
||||
|
||||
for {
|
||||
encMsg := &mgmtProto.EncryptedMessage{}
|
||||
err := syncStream.RecvMsg(encMsg)
|
||||
@@ -666,28 +651,19 @@ func TestSync10PeersGetUpdates(t *testing.T) {
|
||||
}
|
||||
decryptedBytes, decErr := encryption.Decrypt(encMsg.Body, ts.serverPubKey, pk)
|
||||
if decErr != nil {
|
||||
t.Errorf("failed to decrypt SyncResponse for peer %s: %v", pubKey, decErr)
|
||||
t.Errorf("failed to decrypt SyncResponse for peer %s: %v", pk.PublicKey().String(), decErr)
|
||||
return
|
||||
}
|
||||
resp := &mgmtProto.SyncResponse{}
|
||||
umErr := pb.Unmarshal(decryptedBytes, resp)
|
||||
if umErr != nil {
|
||||
t.Errorf("failed to unmarshal SyncResponse for peer %s: %v", pubKey, umErr)
|
||||
t.Errorf("failed to unmarshal SyncResponse for peer %s: %v", pk.PublicKey().String(), umErr)
|
||||
return
|
||||
}
|
||||
|
||||
// Track the maximum peer count seen (due to debouncing, updates are coalesced)
|
||||
peerCount := len(resp.GetRemotePeers())
|
||||
state.mu.Lock()
|
||||
if peerCount > state.maxPeerCount {
|
||||
state.maxPeerCount = peerCount
|
||||
}
|
||||
// Signal completion when this peer has seen all expected peers
|
||||
if !state.done && state.maxPeerCount >= expectedPeerCount {
|
||||
state.done = true
|
||||
// We only count if there's a new peer update
|
||||
if len(resp.GetRemotePeers()) > 0 {
|
||||
wg.Done()
|
||||
}
|
||||
state.mu.Unlock()
|
||||
}
|
||||
}(pk, s)
|
||||
}
|
||||
@@ -701,30 +677,7 @@ func TestSync10PeersGetUpdates(t *testing.T) {
|
||||
time.Sleep(time.Duration(n) * time.Millisecond)
|
||||
}
|
||||
|
||||
// Wait for debouncer to flush final updates (debounce interval is 1000ms)
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
|
||||
// Wait with timeout
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// Success - all peers received expected peer count
|
||||
case <-time.After(5 * time.Second):
|
||||
// Timeout - report which peers didn't receive all updates
|
||||
t.Error("Timeout waiting for all peers to receive updates")
|
||||
for pubKey, state := range peerStates {
|
||||
state.mu.Lock()
|
||||
if state.maxPeerCount < expectedPeerCount {
|
||||
t.Errorf("Peer %s only saw %d peers, expected %d", pubKey, state.maxPeerCount, expectedPeerCount)
|
||||
}
|
||||
state.mu.Unlock()
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
for _, sc := range syncClients {
|
||||
err := sc.CloseSend()
|
||||
|
||||
@@ -37,8 +37,8 @@ type MockAccountManager struct {
|
||||
GetUserFromUserAuthFunc func(ctx context.Context, userAuth auth.UserAuth) (*types.User, error)
|
||||
ListUsersFunc func(ctx context.Context, accountID string) ([]*types.User, error)
|
||||
GetPeersFunc func(ctx context.Context, accountID, userID, nameFilter, ipFilter string) ([]*nbpeer.Peer, error)
|
||||
MarkPeerConnectedFunc func(ctx context.Context, peerKey string, connected bool, realIP net.IP, syncTime time.Time) error
|
||||
SyncAndMarkPeerFunc func(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error)
|
||||
MarkPeerConnectedFunc func(ctx context.Context, peerKey string, connected bool, realIP net.IP) error
|
||||
SyncAndMarkPeerFunc func(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error)
|
||||
DeletePeerFunc func(ctx context.Context, accountID, peerKey, userID string) error
|
||||
GetNetworkMapFunc func(ctx context.Context, peerKey string) (*types.NetworkMap, error)
|
||||
GetPeerNetworkFunc func(ctx context.Context, peerKey string) (*types.Network, error)
|
||||
@@ -214,15 +214,16 @@ func (am *MockAccountManager) DeleteSetupKey(ctx context.Context, accountID, use
|
||||
return status.Errorf(codes.Unimplemented, "method DeleteSetupKey is not implemented")
|
||||
}
|
||||
|
||||
func (am *MockAccountManager) SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP, syncTime time.Time) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) {
|
||||
func (am *MockAccountManager) SyncAndMarkPeer(ctx context.Context, accountID string, peerPubKey string, meta nbpeer.PeerSystemMeta, realIP net.IP) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, int64, error) {
|
||||
if am.SyncAndMarkPeerFunc != nil {
|
||||
return am.SyncAndMarkPeerFunc(ctx, accountID, peerPubKey, meta, realIP, syncTime)
|
||||
return am.SyncAndMarkPeerFunc(ctx, accountID, peerPubKey, meta, realIP)
|
||||
}
|
||||
return nil, nil, nil, 0, status.Errorf(codes.Unimplemented, "method MarkPeerConnected is not implemented")
|
||||
}
|
||||
|
||||
func (am *MockAccountManager) OnPeerDisconnected(_ context.Context, accountID string, peerPubKey string, streamStartTime time.Time) error {
|
||||
return nil
|
||||
func (am *MockAccountManager) OnPeerDisconnected(_ context.Context, accountID string, peerPubKey string) error {
|
||||
// TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (am *MockAccountManager) GetValidatedPeers(ctx context.Context, accountID string) (map[string]struct{}, map[string]string, error) {
|
||||
@@ -322,9 +323,9 @@ func (am *MockAccountManager) GetAccountIDByUserID(ctx context.Context, userAuth
|
||||
}
|
||||
|
||||
// MarkPeerConnected mock implementation of MarkPeerConnected from server.AccountManager interface
|
||||
func (am *MockAccountManager) MarkPeerConnected(ctx context.Context, peerKey string, connected bool, realIP net.IP, accountID string, syncTime time.Time) error {
|
||||
func (am *MockAccountManager) MarkPeerConnected(ctx context.Context, peerKey string, connected bool, realIP net.IP, accountID string) error {
|
||||
if am.MarkPeerConnectedFunc != nil {
|
||||
return am.MarkPeerConnectedFunc(ctx, peerKey, connected, realIP, syncTime)
|
||||
return am.MarkPeerConnectedFunc(ctx, peerKey, connected, realIP)
|
||||
}
|
||||
return status.Errorf(codes.Unimplemented, "method MarkPeerConnected is not implemented")
|
||||
}
|
||||
|
||||
@@ -103,13 +103,11 @@ func (am *DefaultAccountManager) getUserAccessiblePeers(ctx context.Context, acc
|
||||
}
|
||||
|
||||
// MarkPeerConnected marks peer as connected (true) or disconnected (false)
|
||||
// syncTime is used as the LastSeen timestamp and for stale request detection
|
||||
func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubKey string, connected bool, realIP net.IP, accountID string, syncTime time.Time) error {
|
||||
func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubKey string, connected bool, realIP net.IP, accountID string) error {
|
||||
var peer *nbpeer.Peer
|
||||
var settings *types.Settings
|
||||
var expired bool
|
||||
var err error
|
||||
var skipped bool
|
||||
|
||||
err = am.Store.ExecuteInTransaction(ctx, func(transaction store.Store) error {
|
||||
peer, err = transaction.GetPeerByPeerPubKey(ctx, store.LockingStrengthUpdate, peerPubKey)
|
||||
@@ -117,19 +115,9 @@ func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubK
|
||||
return err
|
||||
}
|
||||
|
||||
if connected && !syncTime.After(peer.Status.LastSeen) {
|
||||
log.WithContext(ctx).Tracef("peer %s has newer activity (lastSeen=%s >= syncTime=%s), skipping connect",
|
||||
peer.ID, peer.Status.LastSeen.Format(time.RFC3339), syncTime.Format(time.RFC3339))
|
||||
skipped = true
|
||||
return nil
|
||||
}
|
||||
|
||||
expired, err = updatePeerStatusAndLocation(ctx, am.geo, transaction, peer, connected, realIP, accountID, syncTime)
|
||||
expired, err = updatePeerStatusAndLocation(ctx, am.geo, transaction, peer, connected, realIP, accountID)
|
||||
return err
|
||||
})
|
||||
if skipped {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -159,10 +147,10 @@ func (am *DefaultAccountManager) MarkPeerConnected(ctx context.Context, peerPubK
|
||||
return nil
|
||||
}
|
||||
|
||||
func updatePeerStatusAndLocation(ctx context.Context, geo geolocation.Geolocation, transaction store.Store, peer *nbpeer.Peer, connected bool, realIP net.IP, accountID string, syncTime time.Time) (bool, error) {
|
||||
func updatePeerStatusAndLocation(ctx context.Context, geo geolocation.Geolocation, transaction store.Store, peer *nbpeer.Peer, connected bool, realIP net.IP, accountID string) (bool, error) {
|
||||
oldStatus := peer.Status.Copy()
|
||||
newStatus := oldStatus
|
||||
newStatus.LastSeen = syncTime
|
||||
newStatus.LastSeen = time.Now().UTC()
|
||||
newStatus.Connected = connected
|
||||
// whenever peer got connected that means that it logged in successfully
|
||||
if newStatus.Connected {
|
||||
@@ -557,7 +545,7 @@ func (am *DefaultAccountManager) GetPeerNetwork(ctx context.Context, peerID stri
|
||||
// Each new Peer will be assigned a new next net.IP from the Account.Network and Account.Network.LastIP will be updated (IP's are not reused).
|
||||
// The peer property is just a placeholder for the Peer properties to pass further
|
||||
func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKey, userID string, peer *nbpeer.Peer, temporary bool) (*nbpeer.Peer, *types.NetworkMap, []*posture.Checks, error) {
|
||||
if setupKey == "" && userID == "" {
|
||||
if setupKey == "" && userID == "" && !peer.ProxyEmbedded {
|
||||
// no auth method provided => reject access
|
||||
return nil, nil, nil, status.Errorf(status.Unauthenticated, "no peer auth method provided, please use a setup key or interactive SSO login")
|
||||
}
|
||||
@@ -566,6 +554,7 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe
|
||||
hashedKey := sha256.Sum256([]byte(upperKey))
|
||||
encodedHashedKey := b64.StdEncoding.EncodeToString(hashedKey[:])
|
||||
addedByUser := len(userID) > 0
|
||||
addedBySetupKey := len(setupKey) > 0
|
||||
|
||||
// This is a handling for the case when the same machine (with the same WireGuard pub key) tries to register twice.
|
||||
// Such case is possible when AddPeer function takes long time to finish after AcquireWriteLockByUID (e.g., database is slow)
|
||||
@@ -588,7 +577,8 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe
|
||||
var ephemeral bool
|
||||
var groupsToAdd []string
|
||||
var allowExtraDNSLabels bool
|
||||
if addedByUser {
|
||||
switch {
|
||||
case addedByUser:
|
||||
user, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, userID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, status.Errorf(status.NotFound, "failed adding new peer: user not found")
|
||||
@@ -611,7 +601,7 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe
|
||||
}
|
||||
opEvent.InitiatorID = userID
|
||||
opEvent.Activity = activity.PeerAddedByUser
|
||||
} else {
|
||||
case addedBySetupKey:
|
||||
// Validate the setup key
|
||||
sk, err := am.Store.GetSetupKeyBySecret(ctx, store.LockingStrengthNone, encodedHashedKey)
|
||||
if err != nil {
|
||||
@@ -634,6 +624,12 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe
|
||||
if !sk.AllowExtraDNSLabels && len(peer.ExtraDNSLabels) > 0 {
|
||||
return nil, nil, nil, status.Errorf(status.PreconditionFailed, "couldn't add peer: setup key doesn't allow extra DNS labels")
|
||||
}
|
||||
default:
|
||||
if peer.ProxyEmbedded {
|
||||
log.WithContext(ctx).Debugf("adding peer for proxy embedded, accountID: %s", accountID)
|
||||
} else {
|
||||
log.WithContext(ctx).Warnf("adding peer without setup key or userID, accountID: %s", accountID)
|
||||
}
|
||||
}
|
||||
opEvent.AccountID = accountID
|
||||
|
||||
@@ -669,6 +665,7 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe
|
||||
CreatedAt: registrationTime,
|
||||
LoginExpirationEnabled: addedByUser && !temporary,
|
||||
Ephemeral: ephemeral,
|
||||
ProxyEmbedded: peer.ProxyEmbedded,
|
||||
Location: peer.Location,
|
||||
InactivityExpirationEnabled: addedByUser && !temporary,
|
||||
ExtraDNSLabels: peer.ExtraDNSLabels,
|
||||
@@ -740,12 +737,13 @@ func (am *DefaultAccountManager) AddPeer(ctx context.Context, accountID, setupKe
|
||||
return fmt.Errorf("failed adding peer to All group: %w", err)
|
||||
}
|
||||
|
||||
if addedByUser {
|
||||
switch {
|
||||
case addedByUser:
|
||||
err := transaction.SaveUserLastLogin(ctx, accountID, userID, newPeer.GetLastLogin())
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Debugf("failed to update user last login: %v", err)
|
||||
}
|
||||
} else {
|
||||
case addedBySetupKey:
|
||||
sk, err := transaction.GetSetupKeyBySecret(ctx, store.LockingStrengthUpdate, encodedHashedKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get setup key: %w", err)
|
||||
|
||||
@@ -48,6 +48,8 @@ type Peer struct {
|
||||
CreatedAt time.Time
|
||||
// Indicate ephemeral peer attribute
|
||||
Ephemeral bool `gorm:"index"`
|
||||
// ProxyEmbedded indicates whether the peer is embedded in a reverse proxy
|
||||
ProxyEmbedded bool `gorm:"index"`
|
||||
// Geo location based on connection IP
|
||||
Location Location `gorm:"embedded;embeddedPrefix:location_"`
|
||||
|
||||
@@ -224,6 +226,7 @@ func (p *Peer) Copy() *Peer {
|
||||
LastLogin: p.LastLogin,
|
||||
CreatedAt: p.CreatedAt,
|
||||
Ephemeral: p.Ephemeral,
|
||||
ProxyEmbedded: p.ProxyEmbedded,
|
||||
Location: p.Location,
|
||||
InactivityExpirationEnabled: p.InactivityExpirationEnabled,
|
||||
ExtraDNSLabels: slices.Clone(p.ExtraDNSLabels),
|
||||
|
||||
@@ -3,37 +3,38 @@ package modules
|
||||
type Module string
|
||||
|
||||
const (
|
||||
Networks Module = "networks"
|
||||
Peers Module = "peers"
|
||||
RemoteJobs Module = "remote_jobs"
|
||||
Groups Module = "groups"
|
||||
Settings Module = "settings"
|
||||
Accounts Module = "accounts"
|
||||
Dns Module = "dns"
|
||||
Nameservers Module = "nameservers"
|
||||
Events Module = "events"
|
||||
Policies Module = "policies"
|
||||
Routes Module = "routes"
|
||||
Users Module = "users"
|
||||
SetupKeys Module = "setup_keys"
|
||||
Pats Module = "pats"
|
||||
Networks Module = "networks"
|
||||
Peers Module = "peers"
|
||||
RemoteJobs Module = "remote_jobs"
|
||||
Groups Module = "groups"
|
||||
Settings Module = "settings"
|
||||
Accounts Module = "accounts"
|
||||
Dns Module = "dns"
|
||||
Nameservers Module = "nameservers"
|
||||
Events Module = "events"
|
||||
Policies Module = "policies"
|
||||
Routes Module = "routes"
|
||||
Users Module = "users"
|
||||
SetupKeys Module = "setup_keys"
|
||||
Pats Module = "pats"
|
||||
IdentityProviders Module = "identity_providers"
|
||||
Services Module = "services"
|
||||
)
|
||||
|
||||
var All = map[Module]struct{}{
|
||||
Networks: {},
|
||||
Peers: {},
|
||||
RemoteJobs: {},
|
||||
Groups: {},
|
||||
Settings: {},
|
||||
Accounts: {},
|
||||
Dns: {},
|
||||
Nameservers: {},
|
||||
Events: {},
|
||||
Policies: {},
|
||||
Routes: {},
|
||||
Users: {},
|
||||
SetupKeys: {},
|
||||
Pats: {},
|
||||
Networks: {},
|
||||
Peers: {},
|
||||
RemoteJobs: {},
|
||||
Groups: {},
|
||||
Settings: {},
|
||||
Accounts: {},
|
||||
Dns: {},
|
||||
Nameservers: {},
|
||||
Events: {},
|
||||
Policies: {},
|
||||
Routes: {},
|
||||
Users: {},
|
||||
SetupKeys: {},
|
||||
Pats: {},
|
||||
IdentityProviders: {},
|
||||
}
|
||||
|
||||
@@ -24,28 +24,19 @@ type Manager interface {
|
||||
UpdateExtraSettings(ctx context.Context, accountID, userID string, extraSettings *types.ExtraSettings) (bool, error)
|
||||
}
|
||||
|
||||
// IdpConfig holds IdP-related configuration that is set at runtime
|
||||
// and not stored in the database.
|
||||
type IdpConfig struct {
|
||||
EmbeddedIdpEnabled bool
|
||||
LocalAuthDisabled bool
|
||||
}
|
||||
|
||||
type managerImpl struct {
|
||||
store store.Store
|
||||
extraSettingsManager extra_settings.Manager
|
||||
userManager users.Manager
|
||||
permissionsManager permissions.Manager
|
||||
idpConfig IdpConfig
|
||||
}
|
||||
|
||||
func NewManager(store store.Store, userManager users.Manager, extraSettingsManager extra_settings.Manager, permissionsManager permissions.Manager, idpConfig IdpConfig) Manager {
|
||||
func NewManager(store store.Store, userManager users.Manager, extraSettingsManager extra_settings.Manager, permissionsManager permissions.Manager) Manager {
|
||||
return &managerImpl{
|
||||
store: store,
|
||||
extraSettingsManager: extraSettingsManager,
|
||||
userManager: userManager,
|
||||
permissionsManager: permissionsManager,
|
||||
idpConfig: idpConfig,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,10 +74,6 @@ func (m *managerImpl) GetSettings(ctx context.Context, accountID, userID string)
|
||||
settings.Extra.FlowDnsCollectionEnabled = extraSettings.FlowDnsCollectionEnabled
|
||||
}
|
||||
|
||||
// Fill in IdP-related runtime settings
|
||||
settings.EmbeddedIdpEnabled = m.idpConfig.EmbeddedIdpEnabled
|
||||
settings.LocalAuthDisabled = m.idpConfig.LocalAuthDisabled
|
||||
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/rs/xid"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
@@ -27,6 +28,9 @@ import (
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/zones"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/zones/records"
|
||||
resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
|
||||
@@ -126,7 +130,8 @@ func NewSqlStore(ctx context.Context, db *gorm.DB, storeEngine types.Engine, met
|
||||
&types.Account{}, &types.Policy{}, &types.PolicyRule{}, &route.Route{}, &nbdns.NameServerGroup{},
|
||||
&installation{}, &types.ExtraSettings{}, &posture.Checks{}, &nbpeer.NetworkAddress{},
|
||||
&networkTypes.Network{}, &routerTypes.NetworkRouter{}, &resourceTypes.NetworkResource{}, &types.AccountOnboarding{},
|
||||
&types.Job{}, &zones.Zone{}, &records.Record{}, &types.UserInviteRecord{},
|
||||
&types.Job{}, &zones.Zone{}, &records.Record{}, &types.UserInviteRecord{}, &reverseproxy.ReverseProxy{}, &domain.Domain{},
|
||||
&accesslogs.AccessLogEntry{},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("auto migratePreAuto: %w", err)
|
||||
@@ -1094,6 +1099,7 @@ func (s *SqlStore) getAccountGorm(ctx context.Context, accountID string) (*types
|
||||
Preload("NetworkRouters").
|
||||
Preload("NetworkResources").
|
||||
Preload("Onboarding").
|
||||
Preload("ReverseProxies").
|
||||
Take(&account, idQueryCondition, accountID)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("error when getting account %s from the store: %s", accountID, result.Error)
|
||||
@@ -1271,6 +1277,17 @@ func (s *SqlStore) getAccountPgx(ctx context.Context, accountID string) (*types.
|
||||
account.PostureChecks = checks
|
||||
}()
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
proxies, err := s.getProxies(ctx, accountID)
|
||||
if err != nil {
|
||||
errChan <- err
|
||||
return
|
||||
}
|
||||
account.ReverseProxies = proxies
|
||||
}()
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
@@ -1672,7 +1689,7 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee
|
||||
meta_kernel_version, meta_network_addresses, meta_system_serial_number, meta_system_product_name, meta_system_manufacturer,
|
||||
meta_environment, meta_flags, meta_files, peer_status_last_seen, peer_status_connected, peer_status_login_expired,
|
||||
peer_status_requires_approval, location_connection_ip, location_country_code, location_city_name,
|
||||
location_geo_name_id FROM peers WHERE account_id = $1`
|
||||
location_geo_name_id, proxy_embedded FROM peers WHERE account_id = $1`
|
||||
rows, err := s.pool.Query(ctx, query, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1685,7 +1702,7 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee
|
||||
lastLogin, createdAt sql.NullTime
|
||||
sshEnabled, loginExpirationEnabled, inactivityExpirationEnabled, ephemeral, allowExtraDNSLabels sql.NullBool
|
||||
peerStatusLastSeen sql.NullTime
|
||||
peerStatusConnected, peerStatusLoginExpired, peerStatusRequiresApproval sql.NullBool
|
||||
peerStatusConnected, peerStatusLoginExpired, peerStatusRequiresApproval, proxyEmbedded sql.NullBool
|
||||
ip, extraDNS, netAddr, env, flags, files, connIP []byte
|
||||
metaHostname, metaGoOS, metaKernel, metaCore, metaPlatform sql.NullString
|
||||
metaOS, metaOSVersion, metaWtVersion, metaUIVersion, metaKernelVersion sql.NullString
|
||||
@@ -1700,7 +1717,7 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee
|
||||
&metaOS, &metaOSVersion, &metaWtVersion, &metaUIVersion, &metaKernelVersion, &netAddr,
|
||||
&metaSystemSerialNumber, &metaSystemProductName, &metaSystemManufacturer, &env, &flags, &files,
|
||||
&peerStatusLastSeen, &peerStatusConnected, &peerStatusLoginExpired, &peerStatusRequiresApproval, &connIP,
|
||||
&locationCountryCode, &locationCityName, &locationGeoNameID)
|
||||
&locationCountryCode, &locationCityName, &locationGeoNameID, &proxyEmbedded)
|
||||
|
||||
if err == nil {
|
||||
if lastLogin.Valid {
|
||||
@@ -1784,6 +1801,9 @@ func (s *SqlStore) getPeers(ctx context.Context, accountID string) ([]nbpeer.Pee
|
||||
if locationGeoNameID.Valid {
|
||||
p.Location.GeoNameID = uint(locationGeoNameID.Int64)
|
||||
}
|
||||
if proxyEmbedded.Valid {
|
||||
p.ProxyEmbedded = proxyEmbedded.Bool
|
||||
}
|
||||
if ip != nil {
|
||||
_ = json.Unmarshal(ip, &p.IP)
|
||||
}
|
||||
@@ -2039,6 +2059,65 @@ func (s *SqlStore) getPostureChecks(ctx context.Context, accountID string) ([]*p
|
||||
return checks, nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) getProxies(ctx context.Context, accountID string) ([]*reverseproxy.ReverseProxy, error) {
|
||||
const query = `SELECT id, account_id, name, domain, targets, enabled, auth,
|
||||
meta_created_at, meta_certificate_issued_at, meta_status
|
||||
FROM reverse_proxies WHERE account_id = $1`
|
||||
rows, err := s.pool.Query(ctx, query, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
proxies, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (*reverseproxy.ReverseProxy, error) {
|
||||
var p reverseproxy.ReverseProxy
|
||||
var auth []byte
|
||||
var targets []byte
|
||||
var createdAt, certIssuedAt sql.NullTime
|
||||
var status sql.NullString
|
||||
err := row.Scan(
|
||||
&p.ID,
|
||||
&p.AccountID,
|
||||
&p.Name,
|
||||
&p.Domain,
|
||||
&targets,
|
||||
&p.Enabled,
|
||||
&auth,
|
||||
&createdAt,
|
||||
&certIssuedAt,
|
||||
&status,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Unmarshal JSON fields
|
||||
if auth != nil {
|
||||
if err := json.Unmarshal(auth, &p.Auth); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if targets != nil {
|
||||
if err := json.Unmarshal(targets, &p.Targets); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
p.Meta = reverseproxy.ReverseProxyMeta{}
|
||||
if createdAt.Valid {
|
||||
p.Meta.CreatedAt = createdAt.Time
|
||||
}
|
||||
if certIssuedAt.Valid {
|
||||
p.Meta.CertificateIssuedAt = certIssuedAt.Time
|
||||
}
|
||||
if status.Valid {
|
||||
p.Meta.Status = status.String
|
||||
}
|
||||
|
||||
return &p, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return proxies, nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) getNetworks(ctx context.Context, accountID string) ([]*networkTypes.Network, error) {
|
||||
const query = `SELECT id, account_id, name, description FROM networks WHERE account_id = $1`
|
||||
rows, err := s.pool.Query(ctx, query, accountID)
|
||||
@@ -4602,3 +4681,245 @@ func (s *SqlStore) GetPeerIDByKey(ctx context.Context, lockStrength LockingStren
|
||||
|
||||
return peerID, nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) CreateReverseProxy(ctx context.Context, proxy *reverseproxy.ReverseProxy) error {
|
||||
proxyCopy := proxy.Copy()
|
||||
if err := proxyCopy.EncryptSensitiveData(s.fieldEncrypt); err != nil {
|
||||
return fmt.Errorf("encrypt reverse proxy data: %w", err)
|
||||
}
|
||||
result := s.db.Create(proxyCopy)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to create reverse proxy to store: %v", result.Error)
|
||||
return status.Errorf(status.Internal, "failed to create reverse proxy to store")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) UpdateReverseProxy(ctx context.Context, proxy *reverseproxy.ReverseProxy) error {
|
||||
proxyCopy := proxy.Copy()
|
||||
if err := proxyCopy.EncryptSensitiveData(s.fieldEncrypt); err != nil {
|
||||
return fmt.Errorf("encrypt reverse proxy data: %w", err)
|
||||
}
|
||||
result := s.db.Select("*").Save(proxyCopy)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to update reverse proxy to store: %v", result.Error)
|
||||
return status.Errorf(status.Internal, "failed to update reverse proxy to store")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) DeleteReverseProxy(ctx context.Context, accountID, proxyID string) error {
|
||||
result := s.db.Delete(&reverseproxy.ReverseProxy{}, accountAndIDQueryCondition, accountID, proxyID)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to delete reverse proxy from store: %v", result.Error)
|
||||
return status.Errorf(status.Internal, "failed to delete reverse proxy from store")
|
||||
}
|
||||
|
||||
if result.RowsAffected == 0 {
|
||||
return status.Errorf(status.NotFound, "reverse proxy %s not found", proxyID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) GetReverseProxyByID(ctx context.Context, lockStrength LockingStrength, accountID, proxyID string) (*reverseproxy.ReverseProxy, error) {
|
||||
tx := s.db
|
||||
if lockStrength != LockingStrengthNone {
|
||||
tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
|
||||
}
|
||||
|
||||
var proxy *reverseproxy.ReverseProxy
|
||||
result := tx.Take(&proxy, accountAndIDQueryCondition, accountID, proxyID)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, status.Errorf(status.NotFound, "reverse proxy %s not found", proxyID)
|
||||
}
|
||||
|
||||
log.WithContext(ctx).Errorf("failed to get reverse proxy from store: %v", result.Error)
|
||||
return nil, status.Errorf(status.Internal, "failed to get reverse proxy from store")
|
||||
}
|
||||
|
||||
if err := proxy.DecryptSensitiveData(s.fieldEncrypt); err != nil {
|
||||
return nil, fmt.Errorf("decrypt reverse proxy data: %w", err)
|
||||
}
|
||||
|
||||
return proxy, nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) GetReverseProxyByDomain(ctx context.Context, accountID, domain string) (*reverseproxy.ReverseProxy, error) {
|
||||
var proxy *reverseproxy.ReverseProxy
|
||||
result := s.db.Where("account_id = ? AND domain = ?", accountID, domain).First(&proxy)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, status.Errorf(status.NotFound, "reverse proxy with domain %s not found", domain)
|
||||
}
|
||||
|
||||
log.WithContext(ctx).Errorf("failed to get reverse proxy by domain from store: %v", result.Error)
|
||||
return nil, status.Errorf(status.Internal, "failed to get reverse proxy by domain from store")
|
||||
}
|
||||
|
||||
if err := proxy.DecryptSensitiveData(s.fieldEncrypt); err != nil {
|
||||
return nil, fmt.Errorf("decrypt reverse proxy data: %w", err)
|
||||
}
|
||||
|
||||
return proxy, nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) GetReverseProxies(ctx context.Context, lockStrength LockingStrength) ([]*reverseproxy.ReverseProxy, error) {
|
||||
tx := s.db
|
||||
if lockStrength != LockingStrengthNone {
|
||||
tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
|
||||
}
|
||||
|
||||
var proxyList []*reverseproxy.ReverseProxy
|
||||
result := tx.Find(&proxyList)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to get reverse proxy from the store: %s", result.Error)
|
||||
return nil, status.Errorf(status.Internal, "failed to get reverse proxy from store")
|
||||
}
|
||||
|
||||
for _, proxy := range proxyList {
|
||||
if err := proxy.DecryptSensitiveData(s.fieldEncrypt); err != nil {
|
||||
return nil, fmt.Errorf("decrypt reverse proxy data: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return proxyList, nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) GetAccountReverseProxies(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*reverseproxy.ReverseProxy, error) {
|
||||
tx := s.db
|
||||
if lockStrength != LockingStrengthNone {
|
||||
tx = tx.Clauses(clause.Locking{Strength: string(lockStrength)})
|
||||
}
|
||||
|
||||
var proxyList []*reverseproxy.ReverseProxy
|
||||
result := tx.Find(&proxyList, accountIDCondition, accountID)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to get reverse proxy from the store: %s", result.Error)
|
||||
return nil, status.Errorf(status.Internal, "failed to get reverse proxy from store")
|
||||
}
|
||||
|
||||
for _, proxy := range proxyList {
|
||||
if err := proxy.DecryptSensitiveData(s.fieldEncrypt); err != nil {
|
||||
return nil, fmt.Errorf("decrypt reverse proxy data: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return proxyList, nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) GetCustomDomain(ctx context.Context, accountID string, domainID string) (*domain.Domain, error) {
|
||||
tx := s.db
|
||||
|
||||
customDomain := &domain.Domain{}
|
||||
result := tx.Take(&customDomain, accountAndIDQueryCondition, accountID, domainID)
|
||||
if result.Error != nil {
|
||||
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, status.Errorf(status.NotFound, "reverse proxy custom domain %s not found", domainID)
|
||||
}
|
||||
|
||||
log.WithContext(ctx).Errorf("failed to get reverse proxy custom domain from store: %v", result.Error)
|
||||
return nil, status.Errorf(status.Internal, "failed to get reverse proxy custom domain from store")
|
||||
}
|
||||
|
||||
return customDomain, nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) ListFreeDomains(ctx context.Context, accountID string) ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) ListCustomDomains(ctx context.Context, accountID string) ([]*domain.Domain, error) {
|
||||
tx := s.db
|
||||
|
||||
var domains []*domain.Domain
|
||||
result := tx.Find(&domains, accountIDCondition, accountID)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to get reverse proxy custom domains from the store: %s", result.Error)
|
||||
return nil, status.Errorf(status.Internal, "failed to get reverse proxy custom domains from store")
|
||||
}
|
||||
|
||||
return domains, nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) CreateCustomDomain(ctx context.Context, accountID string, domainName string, validated bool) (*domain.Domain, error) {
|
||||
newDomain := &domain.Domain{
|
||||
ID: xid.New().String(), // Generate our own ID because gorm doesn't always configure the database to handle this for us.
|
||||
Domain: domainName,
|
||||
AccountID: accountID,
|
||||
Type: domain.TypeCustom,
|
||||
Validated: validated,
|
||||
}
|
||||
result := s.db.Create(newDomain)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to create reverse proxy custom domain to store: %v", result.Error)
|
||||
return nil, status.Errorf(status.Internal, "failed to create reverse proxy custom domain to store")
|
||||
}
|
||||
|
||||
return newDomain, nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) UpdateCustomDomain(ctx context.Context, accountID string, d *domain.Domain) (*domain.Domain, error) {
|
||||
d.AccountID = accountID
|
||||
result := s.db.Select("*").Save(d)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to update reverse proxy custom domain to store: %v", result.Error)
|
||||
return nil, status.Errorf(status.Internal, "failed to update reverse proxy custom domain to store")
|
||||
}
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) DeleteCustomDomain(ctx context.Context, accountID string, domainID string) error {
|
||||
result := s.db.Delete(domain.Domain{}, accountAndIDQueryCondition, accountID, domainID)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to delete reverse proxy custom domain from store: %v", result.Error)
|
||||
return status.Errorf(status.Internal, "failed to delete reverse proxy custom domain from store")
|
||||
}
|
||||
|
||||
if result.RowsAffected == 0 {
|
||||
return status.Errorf(status.NotFound, "reverse proxy custom domain %s not found", domainID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateAccessLog creates a new access log entry in the database
|
||||
func (s *SqlStore) CreateAccessLog(ctx context.Context, logEntry *accesslogs.AccessLogEntry) error {
|
||||
result := s.db.Create(logEntry)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).WithFields(log.Fields{
|
||||
"proxy_id": logEntry.ProxyID,
|
||||
"method": logEntry.Method,
|
||||
"host": logEntry.Host,
|
||||
"path": logEntry.Path,
|
||||
}).Errorf("failed to create access log entry in store: %v", result.Error)
|
||||
return status.Errorf(status.Internal, "failed to create access log entry in store")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAccountAccessLogs retrieves all access logs for a given account
|
||||
func (s *SqlStore) GetAccountAccessLogs(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*accesslogs.AccessLogEntry, error) {
|
||||
var logs []*accesslogs.AccessLogEntry
|
||||
|
||||
query := s.db.WithContext(ctx).
|
||||
Where(accountIDCondition, accountID).
|
||||
Order("timestamp DESC").
|
||||
Limit(1000)
|
||||
|
||||
if lockStrength != LockingStrengthNone {
|
||||
query = query.Clauses(clause.Locking{Strength: string(lockStrength)})
|
||||
}
|
||||
|
||||
result := query.Find(&logs)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to get access logs from store: %v", result.Error)
|
||||
return nil, status.Errorf(status.Internal, "failed to get access logs from store")
|
||||
}
|
||||
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
@@ -23,6 +23,9 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/netbirdio/netbird/dns"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/zones"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/zones/records"
|
||||
"github.com/netbirdio/netbird/management/server/telemetry"
|
||||
@@ -240,6 +243,24 @@ type Store interface {
|
||||
MarkPendingJobsAsFailed(ctx context.Context, accountID, peerID, jobID, reason string) error
|
||||
MarkAllPendingJobsAsFailed(ctx context.Context, accountID, peerID, reason string) error
|
||||
GetPeerIDByKey(ctx context.Context, lockStrength LockingStrength, key string) (string, error)
|
||||
|
||||
CreateReverseProxy(ctx context.Context, service *reverseproxy.ReverseProxy) error
|
||||
UpdateReverseProxy(ctx context.Context, service *reverseproxy.ReverseProxy) error
|
||||
DeleteReverseProxy(ctx context.Context, accountID, serviceID string) error
|
||||
GetReverseProxyByID(ctx context.Context, lockStrength LockingStrength, accountID, serviceID string) (*reverseproxy.ReverseProxy, error)
|
||||
GetReverseProxyByDomain(ctx context.Context, accountID, domain string) (*reverseproxy.ReverseProxy, error)
|
||||
GetReverseProxies(ctx context.Context, lockStrength LockingStrength) ([]*reverseproxy.ReverseProxy, error)
|
||||
GetAccountReverseProxies(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*reverseproxy.ReverseProxy, error)
|
||||
|
||||
GetCustomDomain(ctx context.Context, accountID string, domainID string) (*domain.Domain, error)
|
||||
ListFreeDomains(ctx context.Context, accountID string) ([]string, error)
|
||||
ListCustomDomains(ctx context.Context, accountID string) ([]*domain.Domain, error)
|
||||
CreateCustomDomain(ctx context.Context, accountID string, domainName string, validated bool) (*domain.Domain, error)
|
||||
UpdateCustomDomain(ctx context.Context, accountID string, d *domain.Domain) (*domain.Domain, error)
|
||||
DeleteCustomDomain(ctx context.Context, accountID string, domainID string) error
|
||||
|
||||
CreateAccessLog(ctx context.Context, log *accesslogs.AccessLogEntry) error
|
||||
GetAccountAccessLogs(ctx context.Context, lockStrength LockingStrength, accountID string) ([]*accesslogs.AccessLogEntry, error)
|
||||
}
|
||||
|
||||
const (
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/client/ssh/auth"
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/zones"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/zones/records"
|
||||
resourceTypes "github.com/netbirdio/netbird/management/server/networks/resources/types"
|
||||
@@ -99,6 +100,7 @@ type Account struct {
|
||||
NameServerGroupsG []nbdns.NameServerGroup `json:"-" gorm:"foreignKey:AccountID;references:id"`
|
||||
DNSSettings DNSSettings `gorm:"embedded;embeddedPrefix:dns_settings_"`
|
||||
PostureChecks []*posture.Checks `gorm:"foreignKey:AccountID;references:id"`
|
||||
ReverseProxies []*reverseproxy.ReverseProxy `gorm:"foreignKey:AccountID;references:id"`
|
||||
// Settings is a dictionary of Account settings
|
||||
Settings *Settings `gorm:"embedded;embeddedPrefix:settings_"`
|
||||
Networks []*networkTypes.Network `gorm:"foreignKey:AccountID;references:id"`
|
||||
@@ -108,6 +110,8 @@ type Account struct {
|
||||
|
||||
NetworkMapCache *NetworkMapBuilder `gorm:"-"`
|
||||
nmapInitOnce *sync.Once `gorm:"-"`
|
||||
|
||||
ReverseProxyFreeDomainNonce string
|
||||
}
|
||||
|
||||
func (a *Account) InitOnce() {
|
||||
@@ -281,6 +285,8 @@ func (a *Account) GetPeerNetworkMap(
|
||||
routers map[string]map[string]*routerTypes.NetworkRouter,
|
||||
metrics *telemetry.AccountManagerMetrics,
|
||||
groupIDToUserIDs map[string][]string,
|
||||
exposedServices map[string][]*reverseproxy.ReverseProxy, // routerPeer -> list of exposed services
|
||||
proxyPeers []*nbpeer.Peer,
|
||||
) *NetworkMap {
|
||||
start := time.Now()
|
||||
peer := a.Peers[peerID]
|
||||
@@ -298,10 +304,21 @@ func (a *Account) GetPeerNetworkMap(
|
||||
|
||||
peerGroups := a.GetPeerGroups(peerID)
|
||||
|
||||
aclPeers, firewallRules, authorizedUsers, enableSSH := a.GetPeerConnectionResources(ctx, peer, validatedPeersMap, groupIDToUserIDs)
|
||||
var aclPeers []*nbpeer.Peer
|
||||
var firewallRules []*FirewallRule
|
||||
var authorizedUsers map[string]map[string]struct{}
|
||||
var enableSSH bool
|
||||
if peer.ProxyEmbedded {
|
||||
aclPeers, firewallRules = a.GetProxyConnectionResources(exposedServices)
|
||||
} else {
|
||||
aclPeers, firewallRules, authorizedUsers, enableSSH = a.GetPeerConnectionResources(ctx, peer, validatedPeersMap, groupIDToUserIDs)
|
||||
proxyAclPeers, proxyFirewallRules := a.GetPeerProxyResources(exposedServices[peerID], proxyPeers)
|
||||
aclPeers = append(aclPeers, proxyAclPeers...)
|
||||
firewallRules = append(firewallRules, proxyFirewallRules...)
|
||||
}
|
||||
|
||||
var peersToConnect, expiredPeers []*nbpeer.Peer
|
||||
// exclude expired peers
|
||||
var peersToConnect []*nbpeer.Peer
|
||||
var expiredPeers []*nbpeer.Peer
|
||||
for _, p := range aclPeers {
|
||||
expired, _ := p.LoginExpired(a.Settings.PeerLoginExpiration)
|
||||
if a.Settings.PeerLoginExpirationEnabled && expired {
|
||||
@@ -370,6 +387,74 @@ func (a *Account) GetPeerNetworkMap(
|
||||
return nm
|
||||
}
|
||||
|
||||
func (a *Account) GetProxyConnectionResources(exposedServices map[string][]*reverseproxy.ReverseProxy) ([]*nbpeer.Peer, []*FirewallRule) {
|
||||
var aclPeers []*nbpeer.Peer
|
||||
var firewallRules []*FirewallRule
|
||||
|
||||
for _, peerServices := range exposedServices {
|
||||
for _, service := range peerServices {
|
||||
if !service.Enabled {
|
||||
continue
|
||||
}
|
||||
for _, target := range service.Targets {
|
||||
if !target.Enabled {
|
||||
continue
|
||||
}
|
||||
switch target.TargetType {
|
||||
case reverseproxy.TargetTypePeer:
|
||||
tpeer := a.GetPeer(target.TargetId)
|
||||
if tpeer == nil {
|
||||
continue
|
||||
}
|
||||
aclPeers = append(aclPeers, tpeer)
|
||||
firewallRules = append(firewallRules, &FirewallRule{
|
||||
PolicyID: "proxy-" + service.ID,
|
||||
PeerIP: tpeer.IP.String(),
|
||||
Direction: FirewallRuleDirectionOUT,
|
||||
Action: "allow",
|
||||
Protocol: string(PolicyRuleProtocolTCP),
|
||||
PortRange: RulePortRange{Start: uint16(target.Port), End: uint16(target.Port)},
|
||||
})
|
||||
case reverseproxy.TargetTypeResource:
|
||||
// TODO: handle resource type targets
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return aclPeers, firewallRules
|
||||
}
|
||||
|
||||
func (a *Account) GetPeerProxyResources(services []*reverseproxy.ReverseProxy, proxyPeers []*nbpeer.Peer) ([]*nbpeer.Peer, []*FirewallRule) {
|
||||
var aclPeers []*nbpeer.Peer
|
||||
var firewallRules []*FirewallRule
|
||||
|
||||
for _, service := range services {
|
||||
if !service.Enabled {
|
||||
continue
|
||||
}
|
||||
for _, target := range service.Targets {
|
||||
if !target.Enabled {
|
||||
continue
|
||||
}
|
||||
aclPeers = proxyPeers
|
||||
for _, peer := range aclPeers {
|
||||
firewallRules = append(firewallRules, &FirewallRule{
|
||||
PolicyID: "proxy-" + service.ID,
|
||||
PeerIP: peer.IP.String(),
|
||||
Direction: FirewallRuleDirectionIN,
|
||||
Action: "allow",
|
||||
Protocol: string(PolicyRuleProtocolTCP),
|
||||
PortRange: RulePortRange{Start: uint16(target.Port), End: uint16(target.Port)},
|
||||
})
|
||||
}
|
||||
// TODO: handle routes
|
||||
}
|
||||
}
|
||||
|
||||
return aclPeers, firewallRules
|
||||
}
|
||||
|
||||
func (a *Account) addNetworksRoutingPeers(
|
||||
networkResourcesRoutes []*route.Route,
|
||||
peer *nbpeer.Peer,
|
||||
@@ -1213,7 +1298,7 @@ func (a *Account) getAllPeersFromGroups(ctx context.Context, groups []string, pe
|
||||
filteredPeers := make([]*nbpeer.Peer, 0, len(uniquePeerIDs))
|
||||
for _, p := range uniquePeerIDs {
|
||||
peer, ok := a.Peers[p]
|
||||
if !ok || peer == nil {
|
||||
if !ok || peer == nil || peer.ProxyEmbedded {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1240,7 +1325,7 @@ func (a *Account) getAllPeersFromGroups(ctx context.Context, groups []string, pe
|
||||
|
||||
func (a *Account) getPeerFromResource(resource Resource, peerID string) ([]*nbpeer.Peer, bool) {
|
||||
peer := a.GetPeer(resource.ID)
|
||||
if peer == nil {
|
||||
if peer == nil || peer.ProxyEmbedded {
|
||||
return []*nbpeer.Peer{}, false
|
||||
}
|
||||
|
||||
@@ -1776,6 +1861,40 @@ func (a *Account) GetActiveGroupUsers() map[string][]string {
|
||||
return groups
|
||||
}
|
||||
|
||||
func (a *Account) GetProxyPeers() []*nbpeer.Peer {
|
||||
var proxyPeers []*nbpeer.Peer
|
||||
for _, peer := range a.Peers {
|
||||
if peer.ProxyEmbedded {
|
||||
proxyPeers = append(proxyPeers, peer)
|
||||
}
|
||||
}
|
||||
return proxyPeers
|
||||
}
|
||||
|
||||
func (a *Account) GetExposedServicesMap() map[string][]*reverseproxy.ReverseProxy {
|
||||
services := make(map[string][]*reverseproxy.ReverseProxy)
|
||||
resourcesMap := make(map[string]*resourceTypes.NetworkResource)
|
||||
for _, resource := range a.NetworkResources {
|
||||
resourcesMap[resource.ID] = resource
|
||||
}
|
||||
routersMap := a.GetResourceRoutersMap()
|
||||
for _, proxy := range a.ReverseProxies {
|
||||
for _, target := range proxy.Targets {
|
||||
switch target.TargetType {
|
||||
case reverseproxy.TargetTypePeer:
|
||||
services[target.TargetId] = append(services[target.TargetId], proxy)
|
||||
case reverseproxy.TargetTypeResource:
|
||||
resource := resourcesMap[target.TargetId]
|
||||
routers := routersMap[resource.NetworkID]
|
||||
for peerID := range routers {
|
||||
services[peerID] = append(services[peerID], proxy)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return services
|
||||
}
|
||||
|
||||
// expandPortsAndRanges expands Ports and PortRanges of a rule into individual firewall rules
|
||||
func expandPortsAndRanges(base FirewallRule, rule *PolicyRule, peer *nbpeer.Peer) []*FirewallRule {
|
||||
features := peerSupportedFirewallFeatures(peer.Meta.WtVersion)
|
||||
|
||||
@@ -55,14 +55,6 @@ type Settings struct {
|
||||
|
||||
// AutoUpdateVersion client auto-update version
|
||||
AutoUpdateVersion string `gorm:"default:'disabled'"`
|
||||
|
||||
// EmbeddedIdpEnabled indicates if the embedded identity provider is enabled.
|
||||
// This is a runtime-only field, not stored in the database.
|
||||
EmbeddedIdpEnabled bool `gorm:"-"`
|
||||
|
||||
// LocalAuthDisabled indicates if local (email/password) authentication is disabled.
|
||||
// This is a runtime-only field, not stored in the database.
|
||||
LocalAuthDisabled bool `gorm:"-"`
|
||||
}
|
||||
|
||||
// Copy copies the Settings struct
|
||||
@@ -84,8 +76,6 @@ func (s *Settings) Copy() *Settings {
|
||||
DNSDomain: s.DNSDomain,
|
||||
NetworkRange: s.NetworkRange,
|
||||
AutoUpdateVersion: s.AutoUpdateVersion,
|
||||
EmbeddedIdpEnabled: s.EmbeddedIdpEnabled,
|
||||
LocalAuthDisabled: s.LocalAuthDisabled,
|
||||
}
|
||||
if s.Extra != nil {
|
||||
settings.Extra = s.Extra.Copy()
|
||||
|
||||
@@ -191,10 +191,6 @@ func (am *DefaultAccountManager) createNewIdpUser(ctx context.Context, accountID
|
||||
// Unlike createNewIdpUser, this method fetches user data directly from the database
|
||||
// since the embedded IdP usage ensures the username and email are stored locally in the User table.
|
||||
func (am *DefaultAccountManager) createEmbeddedIdpUser(ctx context.Context, accountID string, inviterID string, invite *types.UserInfo) (*idp.UserData, error) {
|
||||
if IsLocalAuthDisabled(ctx, am.idpManager) {
|
||||
return nil, status.Errorf(status.PreconditionFailed, "local user creation is disabled - use an external identity provider")
|
||||
}
|
||||
|
||||
inviter, err := am.Store.GetUserByUserID(ctx, store.LockingStrengthNone, inviterID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get inviter user: %w", err)
|
||||
@@ -1466,10 +1462,6 @@ func (am *DefaultAccountManager) CreateUserInvite(ctx context.Context, accountID
|
||||
return nil, status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider")
|
||||
}
|
||||
|
||||
if IsLocalAuthDisabled(ctx, am.idpManager) {
|
||||
return nil, status.Errorf(status.PreconditionFailed, "local user creation is disabled - use an external identity provider")
|
||||
}
|
||||
|
||||
if err := validateUserInvite(invite); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1629,10 +1621,6 @@ func (am *DefaultAccountManager) AcceptUserInvite(ctx context.Context, token, pa
|
||||
return status.Errorf(status.PreconditionFailed, "invite links are only available with embedded identity provider")
|
||||
}
|
||||
|
||||
if IsLocalAuthDisabled(ctx, am.idpManager) {
|
||||
return status.Errorf(status.PreconditionFailed, "local user creation is disabled - use an external identity provider")
|
||||
}
|
||||
|
||||
if password == "" {
|
||||
return status.Errorf(status.InvalidArgument, "password is required")
|
||||
}
|
||||
|
||||
@@ -50,4 +50,3 @@ func contains[T comparableObject[T]](slice []T, element T) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
25
proxy/Dockerfile
Normal file
25
proxy/Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
||||
FROM golang:1.25-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o netbird-proxy ./proxy/cmd/proxy
|
||||
|
||||
RUN echo "netbird:x:1000:1000:netbird:/var/lib/netbird:/sbin/nologin" > /tmp/passwd && \
|
||||
echo "netbird:x:1000:netbird" > /tmp/group && \
|
||||
mkdir -p /tmp/var/lib/netbird && \
|
||||
mkdir -p /tmp/cert
|
||||
|
||||
FROM gcr.io/distroless/base:debug
|
||||
COPY --from=builder /app/netbird-proxy /usr/bin/netbird-proxy
|
||||
COPY --from=builder /tmp/passwd /etc/passwd
|
||||
COPY --from=builder /tmp/group /etc/group
|
||||
COPY --from=builder --chown=1000:1000 /tmp/var/lib/netbird /var/lib/netbird
|
||||
COPY --from=builder --chown=1000:1000 /tmp/cert /cert
|
||||
USER netbird:netbird
|
||||
ENV HOME=/var/lib/netbird
|
||||
ENV NB_PROXY_ADDRESS=":8443"
|
||||
EXPOSE 8443
|
||||
ENTRYPOINT ["/usr/bin/netbird-proxy"]
|
||||
661
proxy/LICENSE
Normal file
661
proxy/LICENSE
Normal file
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
80
proxy/README.md
Normal file
80
proxy/README.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Netbird Reverse Proxy
|
||||
|
||||
The NetBird Reverse Proxy is a separate service that can act as a public entrypoint to certain resources within a NetBird network.
|
||||
At a high level, the way that it operates is:
|
||||
- Configured routes are communicated from the Management server to the proxy.
|
||||
- For each route the proxy creates a NetBird connection to the NetBird Peer that hosts the resource.
|
||||
- When traffic hits the proxy at the address and path configured for the proxied resource, the NetBird Proxy brings up a relevant authentication method for that resource.
|
||||
- On successful authentication the proxy will forward traffic onwards to the NetBird Peer.
|
||||
|
||||
Proxy Authentication methods supported are:
|
||||
- No authentication
|
||||
- Oauth2/OIDC
|
||||
- Emailed Magic Link
|
||||
- Simple PIN
|
||||
- HTTP Basic Auth Username and Password
|
||||
|
||||
## Management Connection and Authentication
|
||||
|
||||
The Proxy communicates with the Management server over a gRPC connection.
|
||||
Proxies act as clients to the Management server, the following RPCs are used:
|
||||
- Server-side streaming for proxied service updates.
|
||||
- Client-side streaming for proxy logs.
|
||||
|
||||
To authenticate with the Management server, the proxy server uses Machine-to-Machine OAuth2.
|
||||
If you are using the embedded IdP //TODO: explain how to get credentials.
|
||||
Otherwise, create a new machine-to-machine profile in your IdP for proxy servers and set the relevant settings in the proxy's environment or flags (see below).
|
||||
|
||||
## User Authentication
|
||||
|
||||
When a request hits the Proxy, it looks up the permitted authentication methods for the Host domain.
|
||||
If no authentication methods are registered for the Host domain, then no authentication will be applied (for fully public resources).
|
||||
If any authentication methods are registered for the Host domain, then the Proxy will first serve an authentication page allowing the user to select an authentication method (from the permitted methods) and enter the required information for that authentication method.
|
||||
If the user is successfully authenticated, their request will be forwarded through to the Proxy to be proxied to the relevant Peer.
|
||||
Successful authentication does not guarantee a successful forwarding of the request as there may be failures behind the Proxy, such as with Peer connectivity or the underlying resource.
|
||||
|
||||
## TLS
|
||||
|
||||
Due to the authentication provided, the Proxy uses HTTPS for its endpoint, even if the underlying service is HTTP.
|
||||
Certificate generation can either be via ACME (by default, using Let's Encrypt, but alternative ACME providers can be used) or through certificate files.
|
||||
When not using ACME, the proxy server attempts to load a certificate and key from the files `tls.crt` and `tls.key` in a specified certificate directory.
|
||||
When using ACME, the proxy server will store generated certificates in the specified certificate directory.
|
||||
|
||||
|
||||
## Auth UI
|
||||
|
||||
The authentication UI is a Vite + React application located in the `web/` directory. It is embedded into the Go binary at build time.
|
||||
|
||||
To build the UI:
|
||||
```bash
|
||||
cd web
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
For UI development with hot reload (served at http://localhost:3031):
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The built assets in `web/dist/` are embedded via `//go:embed` and served by the `web.ServeHTTP` handler.
|
||||
|
||||
## Configuration
|
||||
|
||||
NetBird Proxy deployment configuration is via flags or environment variables, with flags taking precedence over the environment.
|
||||
The following deployment configuration is available:
|
||||
|
||||
| Flag | Env | Purpose | Default |
|
||||
|------------------|----------------------------------|------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------|
|
||||
| `-debug` | `NB_PROXY_DEBUG_LOGS` | Enable debug logging | `false` |
|
||||
| `-mgmt` | `NB_PROXY_MANAGEMENT_ADDRESS` | The address of the management server for the proxy to get configuration from. | `"https://api.netbird.io:443"` |
|
||||
| `-addr` | `NB_PROXY_ADDRESS` | The address that the reverse proxy will listen on. | `":443` |
|
||||
| `-url` | `NB_PROXY_URL` | The URL that the proxy will be reached at (where endpoints will be CNAMEd to). If unset, this will fall back to the proxy address. | `"proxy.netbird.io"` |
|
||||
| `-cert-dir` | `NB_PROXY_CERTIFICATE_DIRECTORY` | The location that certficates are stored in. | `"./certs"` |
|
||||
| `-acme-certs` | `NB_PROXY_ACME_CERTIFICATES` | Whether to use ACME to generate certificates. | `false` |
|
||||
| `-acme-addr` | `NB_PROXY_ACME_ADDRESS` | The HTTP address the proxy will listen on to respond to HTTP-01 ACME challenges | `":80"` |
|
||||
| `-acme-dir` | `NB_PROXY_ACME_DIRECTORY` | The directory URL of the ACME server to be used | `"https://acme-v02.api.letsencrypt.org/directory"` |
|
||||
| `-oidc-id` | `NB_PROXY_OIDC_CLIENT_ID` | The OAuth2 Client ID for OIDC User Authentication | `"netbird-proxy"` |
|
||||
| `-oidc-secret` | `NB_PROXY_OIDC_CLIENT_SECRET` | The OAuth2 Client Secret for OIDC User Authentication | `""` |
|
||||
| `-oidc-endpoint` | `NB_PROXY_OIDC_ENDPOINT` | The OAuth2 provider endpoint for OIDC User Authentication | `"https://api.netbird.io/oauth2"` |
|
||||
| `-oidc-scopes` | `NB_PROXY_OIDC_SCOPES` | The OAuth2 scopes for OIDC User Authentication, comma separated | `"openid,profile,email"` |
|
||||
60
proxy/auth/auth.go
Normal file
60
proxy/auth/auth.go
Normal file
@@ -0,0 +1,60 @@
|
||||
// Package auth contains exported proxy auth values.
|
||||
// These are used to ensure coherent usage across management and proxy implementations.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type Method string
|
||||
|
||||
var (
|
||||
MethodPassword Method = "password"
|
||||
MethodPIN Method = "pin"
|
||||
MethodOIDC Method = "oidc"
|
||||
)
|
||||
|
||||
func (m Method) String() string {
|
||||
return string(m)
|
||||
}
|
||||
|
||||
const (
|
||||
SessionCookieName = "nb_session"
|
||||
DefaultSessionExpiry = 24 * time.Hour
|
||||
SessionJWTIssuer = "netbird-management"
|
||||
)
|
||||
|
||||
// ValidateSessionJWT validates a session JWT and returns the user ID and method.
|
||||
func ValidateSessionJWT(tokenString, domain string, publicKey ed25519.PublicKey) (userID, method string, err error) {
|
||||
if publicKey == nil {
|
||||
return "", "", fmt.Errorf("no public key configured for domain")
|
||||
}
|
||||
|
||||
token, err := jwt.Parse(tokenString, func(t *jwt.Token) (interface{}, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodEd25519); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
|
||||
}
|
||||
return publicKey, nil
|
||||
}, jwt.WithAudience(domain), jwt.WithIssuer(SessionJWTIssuer))
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("parse token: %w", err)
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok || !token.Valid {
|
||||
return "", "", fmt.Errorf("invalid token claims")
|
||||
}
|
||||
|
||||
sub, _ := claims.GetSubject()
|
||||
if sub == "" {
|
||||
return "", "", fmt.Errorf("missing subject claim")
|
||||
}
|
||||
|
||||
methodClaim, _ := claims["method"].(string)
|
||||
|
||||
return sub, methodClaim, nil
|
||||
}
|
||||
166
proxy/cmd/proxy/cmd/debug.go
Normal file
166
proxy/cmd/proxy/cmd/debug.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/netbirdio/netbird/proxy/internal/debug"
|
||||
)
|
||||
|
||||
var (
|
||||
debugAddr string
|
||||
jsonOutput bool
|
||||
|
||||
// status filters
|
||||
statusFilterByIPs []string
|
||||
statusFilterByNames []string
|
||||
statusFilterByStatus string
|
||||
statusFilterByConnectionType string
|
||||
)
|
||||
|
||||
var debugCmd = &cobra.Command{
|
||||
Use: "debug",
|
||||
Short: "Debug commands for inspecting proxy state",
|
||||
Long: "Debug commands for inspecting the reverse proxy state via the debug HTTP endpoint.",
|
||||
}
|
||||
|
||||
var debugHealthCmd = &cobra.Command{
|
||||
Use: "health",
|
||||
Short: "Show proxy health status",
|
||||
RunE: runDebugHealth,
|
||||
SilenceUsage: true,
|
||||
}
|
||||
|
||||
var debugClientsCmd = &cobra.Command{
|
||||
Use: "clients",
|
||||
Aliases: []string{"list"},
|
||||
Short: "List all connected clients",
|
||||
RunE: runDebugClients,
|
||||
SilenceUsage: true,
|
||||
}
|
||||
|
||||
var debugStatusCmd = &cobra.Command{
|
||||
Use: "status <account-id>",
|
||||
Short: "Show client status",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runDebugStatus,
|
||||
SilenceUsage: true,
|
||||
}
|
||||
|
||||
var debugSyncCmd = &cobra.Command{
|
||||
Use: "sync-response <account-id>",
|
||||
Short: "Show client sync response",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runDebugSync,
|
||||
SilenceUsage: true,
|
||||
}
|
||||
|
||||
var pingTimeout string
|
||||
|
||||
var debugPingCmd = &cobra.Command{
|
||||
Use: "ping <account-id> <host> [port]",
|
||||
Short: "TCP ping through a client",
|
||||
Long: "Perform a TCP ping through a client's network to test connectivity.\nPort defaults to 80 if not specified.",
|
||||
Args: cobra.RangeArgs(2, 3),
|
||||
RunE: runDebugPing,
|
||||
SilenceUsage: true,
|
||||
}
|
||||
|
||||
var debugLogLevelCmd = &cobra.Command{
|
||||
Use: "loglevel <account-id> <level>",
|
||||
Short: "Set client log level",
|
||||
Long: "Set the log level for a client (trace, debug, info, warn, error).",
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: runDebugLogLevel,
|
||||
SilenceUsage: true,
|
||||
}
|
||||
|
||||
var debugStartCmd = &cobra.Command{
|
||||
Use: "start <account-id>",
|
||||
Short: "Start a client",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runDebugStart,
|
||||
SilenceUsage: true,
|
||||
}
|
||||
|
||||
var debugStopCmd = &cobra.Command{
|
||||
Use: "stop <account-id>",
|
||||
Short: "Stop a client",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: runDebugStop,
|
||||
SilenceUsage: true,
|
||||
}
|
||||
|
||||
func init() {
|
||||
debugCmd.PersistentFlags().StringVar(&debugAddr, "addr", envStringOrDefault("NB_PROXY_DEBUG_ADDRESS", "localhost:8444"), "Debug endpoint address")
|
||||
debugCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "Output JSON instead of pretty format")
|
||||
|
||||
debugStatusCmd.Flags().StringSliceVar(&statusFilterByIPs, "filter-by-ips", nil, "Filter by peer IPs (comma-separated)")
|
||||
debugStatusCmd.Flags().StringSliceVar(&statusFilterByNames, "filter-by-names", nil, "Filter by peer names (comma-separated)")
|
||||
debugStatusCmd.Flags().StringVar(&statusFilterByStatus, "filter-by-status", "", "Filter by status (idle|connecting|connected)")
|
||||
debugStatusCmd.Flags().StringVar(&statusFilterByConnectionType, "filter-by-connection-type", "", "Filter by connection type (P2P|Relayed)")
|
||||
|
||||
debugPingCmd.Flags().StringVar(&pingTimeout, "timeout", "", "Ping timeout (e.g., 10s)")
|
||||
|
||||
debugCmd.AddCommand(debugHealthCmd)
|
||||
debugCmd.AddCommand(debugClientsCmd)
|
||||
debugCmd.AddCommand(debugStatusCmd)
|
||||
debugCmd.AddCommand(debugSyncCmd)
|
||||
debugCmd.AddCommand(debugPingCmd)
|
||||
debugCmd.AddCommand(debugLogLevelCmd)
|
||||
debugCmd.AddCommand(debugStartCmd)
|
||||
debugCmd.AddCommand(debugStopCmd)
|
||||
|
||||
rootCmd.AddCommand(debugCmd)
|
||||
}
|
||||
|
||||
func getDebugClient(cmd *cobra.Command) *debug.Client {
|
||||
return debug.NewClient(debugAddr, jsonOutput, cmd.OutOrStdout())
|
||||
}
|
||||
|
||||
func runDebugHealth(cmd *cobra.Command, _ []string) error {
|
||||
return getDebugClient(cmd).Health(cmd.Context())
|
||||
}
|
||||
|
||||
func runDebugClients(cmd *cobra.Command, _ []string) error {
|
||||
return getDebugClient(cmd).ListClients(cmd.Context())
|
||||
}
|
||||
|
||||
func runDebugStatus(cmd *cobra.Command, args []string) error {
|
||||
return getDebugClient(cmd).ClientStatus(cmd.Context(), args[0], debug.StatusFilters{
|
||||
IPs: statusFilterByIPs,
|
||||
Names: statusFilterByNames,
|
||||
Status: statusFilterByStatus,
|
||||
ConnectionType: statusFilterByConnectionType,
|
||||
})
|
||||
}
|
||||
|
||||
func runDebugSync(cmd *cobra.Command, args []string) error {
|
||||
return getDebugClient(cmd).ClientSyncResponse(cmd.Context(), args[0])
|
||||
}
|
||||
|
||||
func runDebugPing(cmd *cobra.Command, args []string) error {
|
||||
port := 80
|
||||
if len(args) > 2 {
|
||||
p, err := strconv.Atoi(args[2])
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid port: %w", err)
|
||||
}
|
||||
port = p
|
||||
}
|
||||
return getDebugClient(cmd).PingTCP(cmd.Context(), args[0], args[1], port, pingTimeout)
|
||||
}
|
||||
|
||||
func runDebugLogLevel(cmd *cobra.Command, args []string) error {
|
||||
return getDebugClient(cmd).SetLogLevel(cmd.Context(), args[0], args[1])
|
||||
}
|
||||
|
||||
func runDebugStart(cmd *cobra.Command, args []string) error {
|
||||
return getDebugClient(cmd).StartClient(cmd.Context(), args[0])
|
||||
}
|
||||
|
||||
func runDebugStop(cmd *cobra.Command, args []string) error {
|
||||
return getDebugClient(cmd).StopClient(cmd.Context(), args[0])
|
||||
}
|
||||
140
proxy/cmd/proxy/cmd/root.go
Normal file
140
proxy/cmd/proxy/cmd/root.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/crypto/acme"
|
||||
|
||||
"github.com/netbirdio/netbird/proxy"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
const DefaultManagementURL = "https://api.netbird.io:443"
|
||||
|
||||
var (
|
||||
Version = "dev"
|
||||
Commit = "unknown"
|
||||
BuildDate = "unknown"
|
||||
GoVersion = "unknown"
|
||||
)
|
||||
|
||||
var (
|
||||
debugLogs bool
|
||||
mgmtAddr string
|
||||
addr string
|
||||
proxyURL string
|
||||
certDir string
|
||||
acmeCerts bool
|
||||
acmeAddr string
|
||||
acmeDir string
|
||||
debugEndpoint bool
|
||||
debugEndpointAddr string
|
||||
healthAddr string
|
||||
oidcClientID string
|
||||
oidcClientSecret string
|
||||
oidcEndpoint string
|
||||
oidcScopes string
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "proxy",
|
||||
Short: "NetBird reverse proxy server",
|
||||
Long: "NetBird reverse proxy server for proxying traffic to NetBird networks.",
|
||||
Version: Version,
|
||||
RunE: runServer,
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.PersistentFlags().BoolVar(&debugLogs, "debug", envBoolOrDefault("NB_PROXY_DEBUG_LOGS", false), "Enable debug logs")
|
||||
rootCmd.Flags().StringVar(&mgmtAddr, "mgmt", envStringOrDefault("NB_PROXY_MANAGEMENT_ADDRESS", DefaultManagementURL), "Management address to connect to")
|
||||
rootCmd.Flags().StringVar(&addr, "addr", envStringOrDefault("NB_PROXY_ADDRESS", ":443"), "Reverse proxy address to listen on")
|
||||
rootCmd.Flags().StringVar(&proxyURL, "url", envStringOrDefault("NB_PROXY_URL", ""), "The URL at which this proxy will be reached")
|
||||
rootCmd.Flags().StringVar(&certDir, "cert-dir", envStringOrDefault("NB_PROXY_CERTIFICATE_DIRECTORY", "./certs"), "Directory to store certificates")
|
||||
rootCmd.Flags().BoolVar(&acmeCerts, "acme-certs", envBoolOrDefault("NB_PROXY_ACME_CERTIFICATES", false), "Generate ACME certificates using HTTP-01 challenges")
|
||||
rootCmd.Flags().StringVar(&acmeAddr, "acme-addr", envStringOrDefault("NB_PROXY_ACME_ADDRESS", ":80"), "HTTP address for ACME HTTP-01 challenges")
|
||||
rootCmd.Flags().StringVar(&acmeDir, "acme-dir", envStringOrDefault("NB_PROXY_ACME_DIRECTORY", acme.LetsEncryptURL), "URL of ACME challenge directory")
|
||||
rootCmd.Flags().BoolVar(&debugEndpoint, "debug-endpoint", envBoolOrDefault("NB_PROXY_DEBUG_ENDPOINT", false), "Enable debug HTTP endpoint")
|
||||
rootCmd.Flags().StringVar(&debugEndpointAddr, "debug-endpoint-addr", envStringOrDefault("NB_PROXY_DEBUG_ENDPOINT_ADDRESS", "localhost:8444"), "Address for the debug HTTP endpoint")
|
||||
rootCmd.Flags().StringVar(&healthAddr, "health-addr", envStringOrDefault("NB_PROXY_HEALTH_ADDRESS", "localhost:8080"), "Address for the health probe endpoint (liveness/readiness/startup)")
|
||||
rootCmd.Flags().StringVar(&oidcClientID, "oidc-id", envStringOrDefault("NB_PROXY_OIDC_CLIENT_ID", "netbird-proxy"), "The OAuth2 Client ID for OIDC User Authentication")
|
||||
rootCmd.Flags().StringVar(&oidcClientSecret, "oidc-secret", envStringOrDefault("NB_PROXY_OIDC_CLIENT_SECRET", ""), "The OAuth2 Client Secret for OIDC User Authentication")
|
||||
rootCmd.Flags().StringVar(&oidcEndpoint, "oidc-endpoint", envStringOrDefault("NB_PROXY_OIDC_ENDPOINT", ""), "The OIDC Endpoint for OIDC User Authentication")
|
||||
rootCmd.Flags().StringVar(&oidcScopes, "oidc-scopes", envStringOrDefault("NB_PROXY_OIDC_SCOPES", "openid,profile,email"), "The OAuth2 scopes for OIDC User Authentication, comma separated")
|
||||
}
|
||||
|
||||
// Execute runs the root command.
|
||||
func Execute() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// SetVersionInfo sets version information for the CLI.
|
||||
func SetVersionInfo(version, commit, buildDate, goVersion string) {
|
||||
Version = version
|
||||
Commit = commit
|
||||
BuildDate = buildDate
|
||||
GoVersion = goVersion
|
||||
rootCmd.Version = version
|
||||
rootCmd.SetVersionTemplate("Version: {{.Version}}, Commit: " + Commit + ", BuildDate: " + BuildDate + ", Go: " + GoVersion + "\n")
|
||||
}
|
||||
|
||||
func runServer(cmd *cobra.Command, args []string) error {
|
||||
level := "error"
|
||||
if debugLogs {
|
||||
level = "debug"
|
||||
}
|
||||
logger := log.New()
|
||||
|
||||
_ = util.InitLogger(logger, level, util.LogConsole)
|
||||
|
||||
log.Infof("configured log level: %s", level)
|
||||
|
||||
srv := proxy.Server{
|
||||
Logger: logger,
|
||||
Version: Version,
|
||||
ManagementAddress: mgmtAddr,
|
||||
ProxyURL: proxyURL,
|
||||
CertificateDirectory: certDir,
|
||||
GenerateACMECertificates: acmeCerts,
|
||||
ACMEChallengeAddress: acmeAddr,
|
||||
ACMEDirectory: acmeDir,
|
||||
DebugEndpointEnabled: debugEndpoint,
|
||||
DebugEndpointAddress: debugEndpointAddr,
|
||||
HealthAddress: healthAddr,
|
||||
OIDCClientId: oidcClientID,
|
||||
OIDCClientSecret: oidcClientSecret,
|
||||
OIDCEndpoint: oidcEndpoint,
|
||||
OIDCScopes: strings.Split(oidcScopes, ","),
|
||||
}
|
||||
|
||||
if err := srv.ListenAndServe(context.TODO(), addr); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func envBoolOrDefault(key string, def bool) bool {
|
||||
v, exists := os.LookupEnv(key)
|
||||
if !exists {
|
||||
return def
|
||||
}
|
||||
parsed, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func envStringOrDefault(key string, def string) string {
|
||||
v, exists := os.LookupEnv(key)
|
||||
if !exists {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
26
proxy/cmd/proxy/main.go
Normal file
26
proxy/cmd/proxy/main.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"github.com/netbirdio/netbird/proxy/cmd/proxy/cmd"
|
||||
)
|
||||
|
||||
var (
|
||||
// Version is the application version (set via ldflags during build)
|
||||
Version = "dev"
|
||||
|
||||
// Commit is the git commit hash (set via ldflags during build)
|
||||
Commit = "unknown"
|
||||
|
||||
// BuildDate is the build date (set via ldflags during build)
|
||||
BuildDate = "unknown"
|
||||
|
||||
// GoVersion is the Go version used to build the binary
|
||||
GoVersion = runtime.Version()
|
||||
)
|
||||
|
||||
func main() {
|
||||
cmd.SetVersionInfo(Version, Commit, BuildDate, GoVersion)
|
||||
cmd.Execute()
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user