mirror of
https://github.com/netbirdio/netbird.git
synced 2026-04-20 08:22:27 -04:00
Compare commits
1 Commits
sleep-dete
...
transparen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afbddae472 |
62
.github/workflows/proto-version-check.yml
vendored
62
.github/workflows/proto-version-check.yml
vendored
@@ -1,62 +0,0 @@
|
||||
name: Proto Version Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "**/*.pb.go"
|
||||
|
||||
jobs:
|
||||
check-proto-versions:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check for proto tool version changes
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.issue.number,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const pbFiles = files.filter(f => f.filename.endsWith('.pb.go'));
|
||||
const missingPatch = pbFiles.filter(f => !f.patch).map(f => f.filename);
|
||||
if (missingPatch.length > 0) {
|
||||
core.setFailed(
|
||||
`Cannot inspect patch data for:\n` +
|
||||
missingPatch.map(f => `- ${f}`).join('\n') +
|
||||
`\nThis can happen with very large PRs. Verify proto versions manually.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const versionPattern = /^[+-]\s*\/\/\s+protoc(?:-gen-go)?\s+v[\d.]+/;
|
||||
const violations = [];
|
||||
|
||||
for (const file of pbFiles) {
|
||||
const changed = file.patch
|
||||
.split('\n')
|
||||
.filter(line => versionPattern.test(line));
|
||||
if (changed.length > 0) {
|
||||
violations.push({
|
||||
file: file.filename,
|
||||
lines: changed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
const details = violations.map(v =>
|
||||
`${v.file}:\n${v.lines.map(l => ' ' + l).join('\n')}`
|
||||
).join('\n\n');
|
||||
|
||||
core.setFailed(
|
||||
`Proto version strings changed in generated files.\n` +
|
||||
`This usually means the wrong protoc or protoc-gen-go version was used.\n` +
|
||||
`Regenerate with the matching tool versions.\n\n` +
|
||||
details
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('No proto version string changes detected');
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -9,7 +9,7 @@ on:
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
SIGN_PIPE_VER: "v0.1.2"
|
||||
SIGN_PIPE_VER: "v0.1.1"
|
||||
GORELEASER_VER: "v2.14.3"
|
||||
PRODUCT_NAME: "NetBird"
|
||||
COPYRIGHT: "NetBird GmbH"
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -33,3 +33,5 @@ infrastructure_files/setup-*.env
|
||||
vendor/
|
||||
/netbird
|
||||
client/netbird-electron/
|
||||
management/server/types/testdata/comparison/
|
||||
management/server/types/testdata/*.json
|
||||
|
||||
@@ -75,7 +75,6 @@ var (
|
||||
mtu uint16
|
||||
profilesDisabled bool
|
||||
updateSettingsDisabled bool
|
||||
networksDisabled bool
|
||||
|
||||
rootCmd = &cobra.Command{
|
||||
Use: "netbird",
|
||||
|
||||
@@ -44,13 +44,10 @@ func init() {
|
||||
serviceCmd.AddCommand(runCmd, startCmd, stopCmd, restartCmd, svcStatusCmd, installCmd, uninstallCmd, reconfigureCmd, resetParamsCmd)
|
||||
serviceCmd.PersistentFlags().BoolVar(&profilesDisabled, "disable-profiles", false, "Disables profiles feature. If enabled, the client will not be able to change or edit any profile. To persist this setting, use: netbird service install --disable-profiles")
|
||||
serviceCmd.PersistentFlags().BoolVar(&updateSettingsDisabled, "disable-update-settings", false, "Disables update settings feature. If enabled, the client will not be able to change or edit any settings. To persist this setting, use: netbird service install --disable-update-settings")
|
||||
serviceCmd.PersistentFlags().BoolVar(&networksDisabled, "disable-networks", false, "Disables network selection. If enabled, the client will not allow listing, selecting, or deselecting networks. To persist, use: netbird service install --disable-networks")
|
||||
|
||||
rootCmd.PersistentFlags().StringVarP(&serviceName, "service", "s", defaultServiceName, "Netbird system service name")
|
||||
serviceEnvDesc := `Sets extra environment variables for the service. ` +
|
||||
`You can specify a comma-separated list of KEY=VALUE pairs. ` +
|
||||
`New keys are merged with previously saved env vars; existing keys are overwritten. ` +
|
||||
`Use --service-env "" to clear all saved env vars. ` +
|
||||
`E.g. --service-env NB_LOG_LEVEL=debug,CUSTOM_VAR=value`
|
||||
|
||||
installCmd.Flags().StringSliceVar(&serviceEnvVars, "service-env", nil, serviceEnvDesc)
|
||||
|
||||
@@ -61,7 +61,7 @@ func (p *program) Start(svc service.Service) error {
|
||||
}
|
||||
}
|
||||
|
||||
serverInstance := server.New(p.ctx, util.FindFirstLogPath(logFiles), configPath, profilesDisabled, updateSettingsDisabled, networksDisabled)
|
||||
serverInstance := server.New(p.ctx, util.FindFirstLogPath(logFiles), configPath, profilesDisabled, updateSettingsDisabled)
|
||||
if err := serverInstance.Start(); err != nil {
|
||||
log.Fatalf("failed to start daemon: %v", err)
|
||||
}
|
||||
|
||||
@@ -59,10 +59,6 @@ func buildServiceArguments() []string {
|
||||
args = append(args, "--disable-update-settings")
|
||||
}
|
||||
|
||||
if networksDisabled {
|
||||
args = append(args, "--disable-networks")
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ type serviceParams struct {
|
||||
LogFiles []string `json:"log_files,omitempty"`
|
||||
DisableProfiles bool `json:"disable_profiles,omitempty"`
|
||||
DisableUpdateSettings bool `json:"disable_update_settings,omitempty"`
|
||||
DisableNetworks bool `json:"disable_networks,omitempty"`
|
||||
ServiceEnvVars map[string]string `json:"service_env_vars,omitempty"`
|
||||
}
|
||||
|
||||
@@ -79,12 +78,11 @@ func currentServiceParams() *serviceParams {
|
||||
LogFiles: logFiles,
|
||||
DisableProfiles: profilesDisabled,
|
||||
DisableUpdateSettings: updateSettingsDisabled,
|
||||
DisableNetworks: networksDisabled,
|
||||
}
|
||||
|
||||
if len(serviceEnvVars) > 0 {
|
||||
parsed, err := parseServiceEnvVars(serviceEnvVars)
|
||||
if err == nil {
|
||||
if err == nil && len(parsed) > 0 {
|
||||
params.ServiceEnvVars = parsed
|
||||
}
|
||||
}
|
||||
@@ -144,46 +142,31 @@ func applyServiceParams(cmd *cobra.Command, params *serviceParams) {
|
||||
updateSettingsDisabled = params.DisableUpdateSettings
|
||||
}
|
||||
|
||||
if !serviceCmd.PersistentFlags().Changed("disable-networks") {
|
||||
networksDisabled = params.DisableNetworks
|
||||
}
|
||||
|
||||
applyServiceEnvParams(cmd, params)
|
||||
}
|
||||
|
||||
// applyServiceEnvParams merges saved service environment variables.
|
||||
// If --service-env was explicitly set with values, explicit values win on key
|
||||
// conflict but saved keys not in the explicit set are carried over.
|
||||
// If --service-env was explicitly set to empty, all saved env vars are cleared.
|
||||
// If --service-env was explicitly set, explicit values win on key conflict
|
||||
// but saved keys not in the explicit set are carried over.
|
||||
// If --service-env was not set, saved env vars are used entirely.
|
||||
func applyServiceEnvParams(cmd *cobra.Command, params *serviceParams) {
|
||||
if !cmd.Flags().Changed("service-env") {
|
||||
if len(params.ServiceEnvVars) > 0 {
|
||||
// No explicit env vars: rebuild serviceEnvVars from saved params.
|
||||
serviceEnvVars = envMapToSlice(params.ServiceEnvVars)
|
||||
}
|
||||
if len(params.ServiceEnvVars) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Flag was explicitly set: parse what the user provided.
|
||||
if !cmd.Flags().Changed("service-env") {
|
||||
// No explicit env vars: rebuild serviceEnvVars from saved params.
|
||||
serviceEnvVars = envMapToSlice(params.ServiceEnvVars)
|
||||
return
|
||||
}
|
||||
|
||||
// Explicit env vars were provided: merge saved values underneath.
|
||||
explicit, err := parseServiceEnvVars(serviceEnvVars)
|
||||
if err != nil {
|
||||
cmd.PrintErrf("Warning: parse explicit service env vars for merge: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// If the user passed an empty value (e.g. --service-env ""), clear all
|
||||
// saved env vars rather than merging.
|
||||
if len(explicit) == 0 {
|
||||
serviceEnvVars = nil
|
||||
return
|
||||
}
|
||||
|
||||
if len(params.ServiceEnvVars) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Merge saved values underneath explicit ones.
|
||||
merged := make(map[string]string, len(params.ServiceEnvVars)+len(explicit))
|
||||
maps.Copy(merged, params.ServiceEnvVars)
|
||||
maps.Copy(merged, explicit) // explicit wins on conflict
|
||||
|
||||
@@ -327,41 +327,6 @@ func TestApplyServiceEnvParams_NotChanged(t *testing.T) {
|
||||
assert.Equal(t, map[string]string{"FROM_SAVED": "val"}, result)
|
||||
}
|
||||
|
||||
func TestApplyServiceEnvParams_ExplicitEmptyClears(t *testing.T) {
|
||||
origServiceEnvVars := serviceEnvVars
|
||||
t.Cleanup(func() { serviceEnvVars = origServiceEnvVars })
|
||||
|
||||
// Simulate --service-env "" which produces [""] in the slice.
|
||||
serviceEnvVars = []string{""}
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
cmd.Flags().StringSlice("service-env", nil, "")
|
||||
require.NoError(t, cmd.Flags().Set("service-env", ""))
|
||||
|
||||
saved := &serviceParams{
|
||||
ServiceEnvVars: map[string]string{"OLD_VAR": "should_be_cleared"},
|
||||
}
|
||||
|
||||
applyServiceEnvParams(cmd, saved)
|
||||
|
||||
assert.Nil(t, serviceEnvVars, "explicit empty --service-env should clear all saved env vars")
|
||||
}
|
||||
|
||||
func TestCurrentServiceParams_EmptyEnvVarsAfterParse(t *testing.T) {
|
||||
origServiceEnvVars := serviceEnvVars
|
||||
t.Cleanup(func() { serviceEnvVars = origServiceEnvVars })
|
||||
|
||||
// Simulate --service-env "" which produces [""] in the slice.
|
||||
serviceEnvVars = []string{""}
|
||||
|
||||
params := currentServiceParams()
|
||||
|
||||
// After parsing, the empty string is skipped, resulting in an empty map.
|
||||
// The map should still be set (not nil) so it overwrites saved values.
|
||||
assert.NotNil(t, params.ServiceEnvVars, "empty env vars should produce empty map, not nil")
|
||||
assert.Empty(t, params.ServiceEnvVars, "no valid env vars should be parsed from empty string")
|
||||
}
|
||||
|
||||
// TestServiceParams_FieldsCoveredInFunctions ensures that all serviceParams fields are
|
||||
// referenced in both currentServiceParams() and applyServiceParams(). If a new field is
|
||||
// added to serviceParams but not wired into these functions, this test fails.
|
||||
@@ -535,7 +500,6 @@ func fieldToGlobalVar(field string) string {
|
||||
"LogFiles": "logFiles",
|
||||
"DisableProfiles": "profilesDisabled",
|
||||
"DisableUpdateSettings": "updateSettingsDisabled",
|
||||
"DisableNetworks": "networksDisabled",
|
||||
"ServiceEnvVars": "serviceEnvVars",
|
||||
}
|
||||
if v, ok := m[field]; ok {
|
||||
|
||||
@@ -13,8 +13,6 @@ import (
|
||||
|
||||
"github.com/netbirdio/management-integrations/integrations"
|
||||
|
||||
nbcache "github.com/netbirdio/netbird/management/server/cache"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/peers"
|
||||
@@ -102,16 +100,9 @@ func startManagement(t *testing.T, config *config.Config, testFile string) (*grp
|
||||
|
||||
jobManager := job.NewJobManager(nil, store, peersmanager)
|
||||
|
||||
ctx := context.Background()
|
||||
iv, _ := integrations.NewIntegratedValidator(context.Background(), peersmanager, settingsManagerMock, eventStore)
|
||||
|
||||
cacheStore, err := nbcache.NewStore(ctx, 100*time.Millisecond, 300*time.Millisecond, 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
iv, _ := integrations.NewIntegratedValidator(ctx, peersmanager, settingsManagerMock, eventStore, cacheStore)
|
||||
|
||||
metrics, err := telemetry.NewDefaultAppMetrics(ctx)
|
||||
metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
settingsMockManager := settings.NewMockManager(ctrl)
|
||||
@@ -122,11 +113,12 @@ func startManagement(t *testing.T, config *config.Config, testFile string) (*grp
|
||||
Return(&types.Settings{}, nil).
|
||||
AnyTimes()
|
||||
|
||||
ctx := context.Background()
|
||||
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||
requestBuffer := mgmt.NewAccountRequestBuffer(ctx, store)
|
||||
networkMapController := controller.NewController(ctx, store, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.cloud", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersmanager), config)
|
||||
|
||||
accountManager, err := mgmt.BuildManager(ctx, config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore)
|
||||
accountManager, err := mgmt.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -160,7 +152,7 @@ func startClientDaemon(
|
||||
s := grpc.NewServer()
|
||||
|
||||
server := client.New(ctx,
|
||||
"", "", false, false, false)
|
||||
"", "", false, false)
|
||||
if err := server.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -56,13 +56,6 @@ func NewFirewall(iface IFaceMapper, stateManager *statemanager.Manager, flowLogg
|
||||
return createUserspaceFirewall(iface, nil, disableServerRoutes, flowLogger, mtu)
|
||||
}
|
||||
|
||||
// Native firewall handles packet filtering, but the userspace WireGuard bind
|
||||
// needs a device filter for DNS interception hooks. Install a minimal
|
||||
// hooks-only filter that passes all traffic through to the kernel firewall.
|
||||
if err := iface.SetFilter(&uspfilter.HooksFilter{}); err != nil {
|
||||
log.Warnf("failed to set hooks filter, DNS via memory hooks will not work: %v", err)
|
||||
}
|
||||
|
||||
return fm, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -21,10 +21,6 @@ const (
|
||||
|
||||
// rules chains contains the effective ACL rules
|
||||
chainNameInputRules = "NETBIRD-ACL-INPUT"
|
||||
|
||||
// mangleFwdKey is the entries map key for mangle FORWARD guard rules that prevent
|
||||
// external DNAT from bypassing ACL rules.
|
||||
mangleFwdKey = "MANGLE-FORWARD"
|
||||
)
|
||||
|
||||
type aclEntries map[string][][]string
|
||||
@@ -278,12 +274,6 @@ func (m *aclManager) cleanChains() error {
|
||||
}
|
||||
}
|
||||
|
||||
for _, rule := range m.entries[mangleFwdKey] {
|
||||
if err := m.iptablesClient.DeleteIfExists(tableMangle, chainFORWARD, rule...); err != nil {
|
||||
log.Errorf("failed to delete mangle FORWARD guard rule: %v, %s", rule, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, ipsetName := range m.ipsetStore.ipsetNames() {
|
||||
if err := m.flushIPSet(ipsetName); err != nil {
|
||||
if errors.Is(err, ipset.ErrSetNotExist) {
|
||||
@@ -313,10 +303,6 @@ func (m *aclManager) createDefaultChains() error {
|
||||
}
|
||||
|
||||
for chainName, rules := range m.entries {
|
||||
// mangle FORWARD guard rules are handled separately below
|
||||
if chainName == mangleFwdKey {
|
||||
continue
|
||||
}
|
||||
for _, rule := range rules {
|
||||
if err := m.iptablesClient.InsertUnique(tableName, chainName, 1, rule...); err != nil {
|
||||
log.Debugf("failed to create input chain jump rule: %s", err)
|
||||
@@ -336,13 +322,6 @@ func (m *aclManager) createDefaultChains() error {
|
||||
}
|
||||
clear(m.optionalEntries)
|
||||
|
||||
// Insert mangle FORWARD guard rules to prevent external DNAT bypass.
|
||||
for _, rule := range m.entries[mangleFwdKey] {
|
||||
if err := m.iptablesClient.AppendUnique(tableMangle, chainFORWARD, rule...); err != nil {
|
||||
log.Errorf("failed to add mangle FORWARD guard rule: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -364,22 +343,6 @@ func (m *aclManager) seedInitialEntries() {
|
||||
|
||||
m.appendToEntries("FORWARD", []string{"-o", m.wgIface.Name(), "-j", chainRTFWDOUT})
|
||||
m.appendToEntries("FORWARD", []string{"-i", m.wgIface.Name(), "-j", chainRTFWDIN})
|
||||
|
||||
// Mangle FORWARD guard: when external DNAT redirects traffic from the wg interface, it
|
||||
// traverses FORWARD instead of INPUT, bypassing ACL rules. ACCEPT rules in filter FORWARD
|
||||
// can be inserted above ours. Mangle runs before filter, so these guard rules enforce the
|
||||
// ACL mark check where it cannot be overridden.
|
||||
m.appendToEntries(mangleFwdKey, []string{
|
||||
"-i", m.wgIface.Name(),
|
||||
"-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED",
|
||||
"-j", "ACCEPT",
|
||||
})
|
||||
m.appendToEntries(mangleFwdKey, []string{
|
||||
"-i", m.wgIface.Name(),
|
||||
"-m", "conntrack", "--ctstate", "DNAT",
|
||||
"-m", "mark", "!", "--mark", fmt.Sprintf("%#x", nbnet.PreroutingFwmarkRedirected),
|
||||
"-j", "DROP",
|
||||
})
|
||||
}
|
||||
|
||||
func (m *aclManager) seedInitialOptionalEntries() {
|
||||
|
||||
@@ -364,6 +364,28 @@ func (m *Manager) SetupEBPFProxyNoTrack(proxyPort, wgPort uint16) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddTProxyRule adds TPROXY redirect rules for the transparent proxy.
|
||||
func (m *Manager) AddTProxyRule(ruleID string, sources []netip.Prefix, dstPorts []uint16, redirectPort uint16) error {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
return m.router.AddTProxyRule(ruleID, sources, dstPorts, redirectPort)
|
||||
}
|
||||
|
||||
// RemoveTProxyRule removes TPROXY redirect rules by ID.
|
||||
func (m *Manager) RemoveTProxyRule(ruleID string) error {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
return m.router.RemoveTProxyRule(ruleID)
|
||||
}
|
||||
|
||||
// AddUDPInspectionHook is a no-op for iptables (kernel-mode firewall has no userspace packet hooks).
|
||||
func (m *Manager) AddUDPInspectionHook(_ uint16, _ func([]byte) bool) string { return "" }
|
||||
|
||||
// RemoveUDPInspectionHook is a no-op for iptables.
|
||||
func (m *Manager) RemoveUDPInspectionHook(_ string) {}
|
||||
|
||||
func (m *Manager) initNoTrackChain() error {
|
||||
if err := m.cleanupNoTrackChain(); err != nil {
|
||||
log.Debugf("cleanup notrack chain: %v", err)
|
||||
|
||||
@@ -89,6 +89,8 @@ type router struct {
|
||||
|
||||
stateManager *statemanager.Manager
|
||||
ipFwdState *ipfwdstate.IPForwardingState
|
||||
|
||||
tproxyRules []tproxyRuleEntry
|
||||
}
|
||||
|
||||
func newRouter(iptablesClient *iptables.IPTables, wgIface iFaceMapper, mtu uint16) (*router, error) {
|
||||
@@ -1109,3 +1111,92 @@ func (r *router) addPrefixToIPSet(name string, prefix netip.Prefix) error {
|
||||
func (r *router) destroyIPSet(name string) error {
|
||||
return ipset.Destroy(name)
|
||||
}
|
||||
|
||||
// AddTProxyRule adds iptables nat PREROUTING REDIRECT rules for transparent proxy interception.
|
||||
// Traffic from sources on dstPorts arriving on the WG interface is redirected
|
||||
// to the transparent proxy listener on redirectPort.
|
||||
func (r *router) AddTProxyRule(ruleID string, sources []netip.Prefix, dstPorts []uint16, redirectPort uint16) error {
|
||||
portStr := fmt.Sprintf("%d", redirectPort)
|
||||
|
||||
for _, proto := range []string{"tcp", "udp"} {
|
||||
srcSpecs := r.buildSourceSpecs(sources)
|
||||
|
||||
for _, srcSpec := range srcSpecs {
|
||||
if len(dstPorts) == 0 {
|
||||
rule := append(srcSpec,
|
||||
"-i", r.wgIface.Name(),
|
||||
"-p", proto,
|
||||
"-j", "REDIRECT",
|
||||
"--to-ports", portStr,
|
||||
)
|
||||
if err := r.iptablesClient.AppendUnique(tableNat, chainRTRDR, rule...); err != nil {
|
||||
return fmt.Errorf("add redirect rule %s/%s: %w", ruleID, proto, err)
|
||||
}
|
||||
r.tproxyRules = append(r.tproxyRules, tproxyRuleEntry{
|
||||
ruleID: ruleID,
|
||||
table: tableNat,
|
||||
chain: chainRTRDR,
|
||||
spec: rule,
|
||||
})
|
||||
} else {
|
||||
for _, port := range dstPorts {
|
||||
rule := append(srcSpec,
|
||||
"-i", r.wgIface.Name(),
|
||||
"-p", proto,
|
||||
"--dport", fmt.Sprintf("%d", port),
|
||||
"-j", "REDIRECT",
|
||||
"--to-ports", portStr,
|
||||
)
|
||||
if err := r.iptablesClient.AppendUnique(tableNat, chainRTRDR, rule...); err != nil {
|
||||
return fmt.Errorf("add redirect rule %s/%s/%d: %w", ruleID, proto, port, err)
|
||||
}
|
||||
r.tproxyRules = append(r.tproxyRules, tproxyRuleEntry{
|
||||
ruleID: ruleID,
|
||||
table: tableNat,
|
||||
chain: chainRTRDR,
|
||||
spec: rule,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveTProxyRule removes all iptables REDIRECT rules for the given ruleID.
|
||||
func (r *router) RemoveTProxyRule(ruleID string) error {
|
||||
var remaining []tproxyRuleEntry
|
||||
for _, entry := range r.tproxyRules {
|
||||
if entry.ruleID != ruleID {
|
||||
remaining = append(remaining, entry)
|
||||
continue
|
||||
}
|
||||
if err := r.iptablesClient.DeleteIfExists(entry.table, entry.chain, entry.spec...); err != nil {
|
||||
log.Debugf("remove tproxy rule %s: %v", ruleID, err)
|
||||
}
|
||||
}
|
||||
r.tproxyRules = remaining
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type tproxyRuleEntry struct {
|
||||
ruleID string
|
||||
table string
|
||||
chain string
|
||||
spec []string
|
||||
}
|
||||
|
||||
func (r *router) buildSourceSpecs(sources []netip.Prefix) [][]string {
|
||||
if len(sources) == 0 {
|
||||
return [][]string{{}} // empty spec = match any source
|
||||
}
|
||||
|
||||
specs := make([][]string, 0, len(sources))
|
||||
for _, src := range sources {
|
||||
specs = append(specs, []string{"-s", src.String()})
|
||||
}
|
||||
return specs
|
||||
}
|
||||
|
||||
|
||||
@@ -180,6 +180,22 @@ type Manager interface {
|
||||
// SetupEBPFProxyNoTrack creates static notrack rules for eBPF proxy loopback traffic.
|
||||
// This prevents conntrack from interfering with WireGuard proxy communication.
|
||||
SetupEBPFProxyNoTrack(proxyPort, wgPort uint16) error
|
||||
|
||||
// AddTProxyRule adds TPROXY redirect rules for specific source CIDRs and destination ports.
|
||||
// Traffic from sources on dstPorts is redirected to the transparent proxy on redirectPort.
|
||||
// Empty dstPorts means redirect all ports.
|
||||
AddTProxyRule(ruleID string, sources []netip.Prefix, dstPorts []uint16, redirectPort uint16) error
|
||||
|
||||
// RemoveTProxyRule removes TPROXY redirect rules by ID.
|
||||
RemoveTProxyRule(ruleID string) error
|
||||
|
||||
// AddUDPInspectionHook registers a hook that inspects UDP packets before forwarding.
|
||||
// The hook receives the raw packet and returns true to drop it.
|
||||
// Used for QUIC SNI-based blocking. Returns a hook ID for removal.
|
||||
AddUDPInspectionHook(dstPort uint16, hook func(packet []byte) bool) string
|
||||
|
||||
// RemoveUDPInspectionHook removes a previously registered inspection hook.
|
||||
RemoveUDPInspectionHook(hookID string)
|
||||
}
|
||||
|
||||
func GenKey(format string, pair RouterPair) string {
|
||||
|
||||
@@ -482,6 +482,28 @@ func (m *Manager) SetupEBPFProxyNoTrack(proxyPort, wgPort uint16) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddTProxyRule adds TPROXY redirect rules for the transparent proxy.
|
||||
func (m *Manager) AddTProxyRule(ruleID string, sources []netip.Prefix, dstPorts []uint16, redirectPort uint16) error {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
return m.router.AddTProxyRule(ruleID, sources, dstPorts, redirectPort)
|
||||
}
|
||||
|
||||
// RemoveTProxyRule removes TPROXY redirect rules by ID.
|
||||
func (m *Manager) RemoveTProxyRule(ruleID string) error {
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
return m.router.RemoveTProxyRule(ruleID)
|
||||
}
|
||||
|
||||
// AddUDPInspectionHook is a no-op for nftables (kernel-mode firewall has no userspace packet hooks).
|
||||
func (m *Manager) AddUDPInspectionHook(_ uint16, _ func([]byte) bool) string { return "" }
|
||||
|
||||
// RemoveUDPInspectionHook is a no-op for nftables.
|
||||
func (m *Manager) RemoveUDPInspectionHook(_ string) {}
|
||||
|
||||
func (m *Manager) initNoTrackChains(table *nftables.Table) error {
|
||||
m.notrackOutputChain = m.rConn.AddChain(&nftables.Chain{
|
||||
Name: chainNameRawOutput,
|
||||
|
||||
@@ -77,6 +77,7 @@ type router struct {
|
||||
ipFwdState *ipfwdstate.IPForwardingState
|
||||
legacyManagement bool
|
||||
mtu uint16
|
||||
|
||||
}
|
||||
|
||||
func newRouter(workTable *nftables.Table, wgIface iFaceMapper, mtu uint16) (*router, error) {
|
||||
@@ -2137,3 +2138,227 @@ func getIpSetExprs(ref refcounter.Ref[*nftables.Set], isSource bool) ([]expr.Any
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AddTProxyRule adds nftables TPROXY redirect rules in the mangle prerouting chain.
|
||||
// Traffic from sources on dstPorts arriving on the WG interface is redirected to
|
||||
// the transparent proxy listener on redirectPort.
|
||||
// Separate rules are created for TCP and UDP protocols.
|
||||
func (r *router) AddTProxyRule(ruleID string, sources []netip.Prefix, dstPorts []uint16, redirectPort uint16) error {
|
||||
if err := r.refreshRulesMap(); err != nil {
|
||||
return fmt.Errorf(refreshRulesMapError, err)
|
||||
}
|
||||
|
||||
// Use the nat redirect chain for DNAT rules.
|
||||
// TPROXY doesn't work on WG kernel interfaces (socket assignment silently fails),
|
||||
// so we use DNAT to 127.0.0.1:proxy_port instead. The proxy reads the original
|
||||
// destination via SO_ORIGINAL_DST (conntrack).
|
||||
chain := r.chains[chainNameRoutingRdr]
|
||||
if chain == nil {
|
||||
return fmt.Errorf("nat redirect chain not initialized")
|
||||
}
|
||||
|
||||
for _, proto := range []uint8{unix.IPPROTO_TCP, unix.IPPROTO_UDP} {
|
||||
protoName := "tcp"
|
||||
if proto == unix.IPPROTO_UDP {
|
||||
protoName = "udp"
|
||||
}
|
||||
|
||||
ruleKey := fmt.Sprintf("tproxy-%s-%s", ruleID, protoName)
|
||||
|
||||
if existing, ok := r.rules[ruleKey]; ok && existing.Handle != 0 {
|
||||
if err := r.decrementSetCounter(existing); err != nil {
|
||||
log.Debugf("decrement set counter for %s: %v", ruleKey, err)
|
||||
}
|
||||
if err := r.conn.DelRule(existing); err != nil {
|
||||
log.Debugf("remove existing tproxy rule %s: %v", ruleKey, err)
|
||||
}
|
||||
delete(r.rules, ruleKey)
|
||||
}
|
||||
|
||||
exprs, err := r.buildRedirectExprs(proto, sources, dstPorts, redirectPort)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build redirect exprs for %s: %w", protoName, err)
|
||||
}
|
||||
|
||||
r.rules[ruleKey] = r.conn.AddRule(&nftables.Rule{
|
||||
Table: r.workTable,
|
||||
Chain: chain,
|
||||
Exprs: exprs,
|
||||
UserData: []byte(ruleKey),
|
||||
})
|
||||
}
|
||||
|
||||
// Accept redirected packets in the ACL input chain. After REDIRECT, the
|
||||
// destination port becomes the proxy port. Without this rule, the ACL filter
|
||||
// drops the packet. We match on ct state dnat so only REDIRECT'd connections
|
||||
// are accepted: direct connections to the proxy port are blocked.
|
||||
inputAcceptKey := fmt.Sprintf("tproxy-%s-input", ruleID)
|
||||
if _, ok := r.rules[inputAcceptKey]; !ok {
|
||||
inputChain := &nftables.Chain{
|
||||
Name: "netbird-acl-input-rules",
|
||||
Table: r.workTable,
|
||||
}
|
||||
r.rules[inputAcceptKey] = r.conn.InsertRule(&nftables.Rule{
|
||||
Table: r.workTable,
|
||||
Chain: inputChain,
|
||||
Exprs: []expr.Any{
|
||||
// Only accept connections that were REDIRECT'd (ct status dnat)
|
||||
&expr.Ct{Register: 1, Key: expr.CtKeySTATUS},
|
||||
&expr.Bitwise{
|
||||
SourceRegister: 1,
|
||||
DestRegister: 1,
|
||||
Len: 4,
|
||||
Mask: binaryutil.NativeEndian.PutUint32(0x20), // IPS_DST_NAT
|
||||
Xor: binaryutil.NativeEndian.PutUint32(0),
|
||||
},
|
||||
&expr.Cmp{
|
||||
Op: expr.CmpOpNeq,
|
||||
Register: 1,
|
||||
Data: binaryutil.NativeEndian.PutUint32(0),
|
||||
},
|
||||
// Accept both TCP and UDP redirected to the proxy port.
|
||||
&expr.Payload{
|
||||
DestRegister: 1,
|
||||
Base: expr.PayloadBaseTransportHeader,
|
||||
Offset: 2,
|
||||
Len: 2,
|
||||
},
|
||||
&expr.Cmp{
|
||||
Op: expr.CmpOpEq,
|
||||
Register: 1,
|
||||
Data: binaryutil.BigEndian.PutUint16(redirectPort),
|
||||
},
|
||||
&expr.Verdict{Kind: expr.VerdictAccept},
|
||||
},
|
||||
UserData: []byte(inputAcceptKey),
|
||||
})
|
||||
}
|
||||
|
||||
if err := r.conn.Flush(); err != nil {
|
||||
return fmt.Errorf("flush tproxy rules for %s: %w", ruleID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveTProxyRule removes TPROXY redirect rules by ID (both TCP and UDP variants).
|
||||
func (r *router) RemoveTProxyRule(ruleID string) error {
|
||||
if err := r.refreshRulesMap(); err != nil {
|
||||
return fmt.Errorf(refreshRulesMapError, err)
|
||||
}
|
||||
|
||||
var removed int
|
||||
for _, suffix := range []string{"tcp", "udp", "input"} {
|
||||
ruleKey := fmt.Sprintf("tproxy-%s-%s", ruleID, suffix)
|
||||
|
||||
rule, ok := r.rules[ruleKey]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if rule.Handle == 0 {
|
||||
delete(r.rules, ruleKey)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := r.decrementSetCounter(rule); err != nil {
|
||||
log.Debugf("decrement set counter for %s: %v", ruleKey, err)
|
||||
}
|
||||
if err := r.conn.DelRule(rule); err != nil {
|
||||
log.Debugf("delete tproxy rule %s: %v", ruleKey, err)
|
||||
}
|
||||
delete(r.rules, ruleKey)
|
||||
removed++
|
||||
}
|
||||
|
||||
if removed > 0 {
|
||||
if err := r.conn.Flush(); err != nil {
|
||||
return fmt.Errorf("flush tproxy rule removal for %s: %w", ruleID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildRedirectExprs builds nftables expressions for a REDIRECT rule.
|
||||
// Matches WG interface ingress, source CIDRs, destination ports, then REDIRECTs to the proxy port.
|
||||
func (r *router) buildRedirectExprs(proto uint8, sources []netip.Prefix, dstPorts []uint16, redirectPort uint16) ([]expr.Any, error) {
|
||||
var exprs []expr.Any
|
||||
|
||||
exprs = append(exprs,
|
||||
&expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1},
|
||||
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: ifname(r.wgIface.Name())},
|
||||
)
|
||||
|
||||
exprs = append(exprs,
|
||||
&expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1},
|
||||
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{proto}},
|
||||
)
|
||||
|
||||
// Source CIDRs use the named ipset shared with route rules.
|
||||
if len(sources) > 0 {
|
||||
srcSet := firewall.NewPrefixSet(sources)
|
||||
srcExprs, err := r.getIpSet(srcSet, sources, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get source ipset: %w", err)
|
||||
}
|
||||
exprs = append(exprs, srcExprs...)
|
||||
}
|
||||
|
||||
if len(dstPorts) == 1 {
|
||||
exprs = append(exprs,
|
||||
&expr.Payload{
|
||||
DestRegister: 1,
|
||||
Base: expr.PayloadBaseTransportHeader,
|
||||
Offset: 2,
|
||||
Len: 2,
|
||||
},
|
||||
&expr.Cmp{
|
||||
Op: expr.CmpOpEq,
|
||||
Register: 1,
|
||||
Data: binaryutil.BigEndian.PutUint16(dstPorts[0]),
|
||||
},
|
||||
)
|
||||
} else if len(dstPorts) > 1 {
|
||||
setElements := make([]nftables.SetElement, len(dstPorts))
|
||||
for i, p := range dstPorts {
|
||||
setElements[i] = nftables.SetElement{Key: binaryutil.BigEndian.PutUint16(p)}
|
||||
}
|
||||
portSet := &nftables.Set{
|
||||
Table: r.workTable,
|
||||
Anonymous: true,
|
||||
Constant: true,
|
||||
KeyType: nftables.TypeInetService,
|
||||
}
|
||||
if err := r.conn.AddSet(portSet, setElements); err != nil {
|
||||
return nil, fmt.Errorf("create port set: %w", err)
|
||||
}
|
||||
exprs = append(exprs,
|
||||
&expr.Payload{
|
||||
DestRegister: 1,
|
||||
Base: expr.PayloadBaseTransportHeader,
|
||||
Offset: 2,
|
||||
Len: 2,
|
||||
},
|
||||
&expr.Lookup{
|
||||
SourceRegister: 1,
|
||||
SetName: portSet.Name,
|
||||
SetID: portSet.ID,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// REDIRECT to local proxy port. Changes the destination to the interface's
|
||||
// primary address + specified port. Conntrack tracks the original destination,
|
||||
// readable via SO_ORIGINAL_DST.
|
||||
exprs = append(exprs,
|
||||
&expr.Immediate{Register: 1, Data: binaryutil.BigEndian.PutUint16(redirectPort)},
|
||||
&expr.Redir{
|
||||
RegisterProtoMin: 1,
|
||||
},
|
||||
)
|
||||
|
||||
return exprs, nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// PacketHook stores a registered hook for a specific IP:port.
|
||||
type PacketHook struct {
|
||||
IP netip.Addr
|
||||
Port uint16
|
||||
Fn func([]byte) bool
|
||||
}
|
||||
|
||||
// HookMatches checks if a packet's destination matches the hook and invokes it.
|
||||
func HookMatches(h *PacketHook, dstIP netip.Addr, dport uint16, packetData []byte) bool {
|
||||
if h == nil {
|
||||
return false
|
||||
}
|
||||
if h.IP == dstIP && h.Port == dport {
|
||||
return h.Fn(packetData)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SetHook atomically stores a hook, handling nil removal.
|
||||
func SetHook(ptr *atomic.Pointer[PacketHook], ip netip.Addr, dPort uint16, hook func([]byte) bool) {
|
||||
if hook == nil {
|
||||
ptr.Store(nil)
|
||||
return
|
||||
}
|
||||
ptr.Store(&PacketHook{
|
||||
IP: ip,
|
||||
Port: dPort,
|
||||
Fn: hook,
|
||||
})
|
||||
}
|
||||
@@ -142,8 +142,15 @@ type Manager struct {
|
||||
mssClampEnabled bool
|
||||
|
||||
// Only one hook per protocol is supported. Outbound direction only.
|
||||
udpHookOut atomic.Pointer[common.PacketHook]
|
||||
tcpHookOut atomic.Pointer[common.PacketHook]
|
||||
udpHookOut atomic.Pointer[packetHook]
|
||||
tcpHookOut atomic.Pointer[packetHook]
|
||||
}
|
||||
|
||||
// packetHook stores a registered hook for a specific IP:port.
|
||||
type packetHook struct {
|
||||
ip netip.Addr
|
||||
port uint16
|
||||
fn func([]byte) bool
|
||||
}
|
||||
|
||||
// decoder for packages
|
||||
@@ -634,6 +641,45 @@ func (m *Manager) SetupEBPFProxyNoTrack(proxyPort, wgPort uint16) error {
|
||||
return m.nativeFirewall.SetupEBPFProxyNoTrack(proxyPort, wgPort)
|
||||
}
|
||||
|
||||
// AddTProxyRule delegates to the native firewall for TPROXY rules.
|
||||
// In userspace mode (no native firewall), this is a no-op since the
|
||||
// forwarder intercepts traffic directly.
|
||||
func (m *Manager) AddTProxyRule(ruleID string, sources []netip.Prefix, dstPorts []uint16, redirectPort uint16) error {
|
||||
if m.nativeFirewall == nil {
|
||||
return nil
|
||||
}
|
||||
return m.nativeFirewall.AddTProxyRule(ruleID, sources, dstPorts, redirectPort)
|
||||
}
|
||||
|
||||
// AddUDPInspectionHook registers a hook for QUIC/UDP inspection via the packet filter.
|
||||
func (m *Manager) AddUDPInspectionHook(dstPort uint16, hook func(packet []byte) bool) string {
|
||||
m.SetUDPPacketHook(netip.Addr{}, dstPort, hook)
|
||||
return "udp-inspection"
|
||||
}
|
||||
|
||||
// RemoveUDPInspectionHook removes a previously registered inspection hook.
|
||||
func (m *Manager) RemoveUDPInspectionHook(_ string) {
|
||||
m.SetUDPPacketHook(netip.Addr{}, 0, nil)
|
||||
}
|
||||
|
||||
// RemoveTProxyRule delegates to the native firewall for TPROXY rules.
|
||||
func (m *Manager) RemoveTProxyRule(ruleID string) error {
|
||||
if m.nativeFirewall == nil {
|
||||
return nil
|
||||
}
|
||||
return m.nativeFirewall.RemoveTProxyRule(ruleID)
|
||||
}
|
||||
|
||||
// IsLocalIP reports whether the given IP belongs to the local machine.
|
||||
func (m *Manager) IsLocalIP(ip netip.Addr) bool {
|
||||
return m.localipmanager.IsLocalIP(ip)
|
||||
}
|
||||
|
||||
// GetForwarder returns the userspace packet forwarder, or nil if not initialized.
|
||||
func (m *Manager) GetForwarder() *forwarder.Forwarder {
|
||||
return m.forwarder.Load()
|
||||
}
|
||||
|
||||
// UpdateSet updates the rule destinations associated with the given set
|
||||
// by merging the existing prefixes with the new ones, then deduplicating.
|
||||
func (m *Manager) UpdateSet(set firewall.Set, prefixes []netip.Prefix) error {
|
||||
@@ -905,11 +951,21 @@ func (m *Manager) trackInbound(d *decoder, srcIP, dstIP netip.Addr, ruleID []byt
|
||||
}
|
||||
|
||||
func (m *Manager) udpHooksDrop(dport uint16, dstIP netip.Addr, packetData []byte) bool {
|
||||
return common.HookMatches(m.udpHookOut.Load(), dstIP, dport, packetData)
|
||||
return hookMatches(m.udpHookOut.Load(), dstIP, dport, packetData)
|
||||
}
|
||||
|
||||
func (m *Manager) tcpHooksDrop(dport uint16, dstIP netip.Addr, packetData []byte) bool {
|
||||
return common.HookMatches(m.tcpHookOut.Load(), dstIP, dport, packetData)
|
||||
return hookMatches(m.tcpHookOut.Load(), dstIP, dport, packetData)
|
||||
}
|
||||
|
||||
func hookMatches(h *packetHook, dstIP netip.Addr, dport uint16, packetData []byte) bool {
|
||||
if h == nil {
|
||||
return false
|
||||
}
|
||||
if h.ip == dstIP && h.port == dport {
|
||||
return h.fn(packetData)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// filterInbound implements filtering logic for incoming packets.
|
||||
@@ -1320,12 +1376,28 @@ func (m *Manager) ruleMatches(rule *RouteRule, srcAddr, dstAddr netip.Addr, prot
|
||||
|
||||
// SetUDPPacketHook sets the outbound UDP packet hook. Pass nil hook to remove.
|
||||
func (m *Manager) SetUDPPacketHook(ip netip.Addr, dPort uint16, hook func(packet []byte) bool) {
|
||||
common.SetHook(&m.udpHookOut, ip, dPort, hook)
|
||||
if hook == nil {
|
||||
m.udpHookOut.Store(nil)
|
||||
return
|
||||
}
|
||||
m.udpHookOut.Store(&packetHook{
|
||||
ip: ip,
|
||||
port: dPort,
|
||||
fn: hook,
|
||||
})
|
||||
}
|
||||
|
||||
// SetTCPPacketHook sets the outbound TCP packet hook. Pass nil hook to remove.
|
||||
func (m *Manager) SetTCPPacketHook(ip netip.Addr, dPort uint16, hook func(packet []byte) bool) {
|
||||
common.SetHook(&m.tcpHookOut, ip, dPort, hook)
|
||||
if hook == nil {
|
||||
m.tcpHookOut.Store(nil)
|
||||
return
|
||||
}
|
||||
m.tcpHookOut.Store(&packetHook{
|
||||
ip: ip,
|
||||
port: dPort,
|
||||
fn: hook,
|
||||
})
|
||||
}
|
||||
|
||||
// SetLogLevel sets the log level for the firewall manager
|
||||
|
||||
@@ -202,9 +202,9 @@ func TestSetUDPPacketHook(t *testing.T) {
|
||||
|
||||
h := manager.udpHookOut.Load()
|
||||
require.NotNil(t, h)
|
||||
assert.Equal(t, netip.MustParseAddr("10.168.0.1"), h.IP)
|
||||
assert.Equal(t, uint16(8000), h.Port)
|
||||
assert.True(t, h.Fn(nil))
|
||||
assert.Equal(t, netip.MustParseAddr("10.168.0.1"), h.ip)
|
||||
assert.Equal(t, uint16(8000), h.port)
|
||||
assert.True(t, h.fn(nil))
|
||||
assert.True(t, called)
|
||||
|
||||
manager.SetUDPPacketHook(netip.MustParseAddr("10.168.0.1"), 8000, nil)
|
||||
@@ -226,9 +226,9 @@ func TestSetTCPPacketHook(t *testing.T) {
|
||||
|
||||
h := manager.tcpHookOut.Load()
|
||||
require.NotNil(t, h)
|
||||
assert.Equal(t, netip.MustParseAddr("10.168.0.1"), h.IP)
|
||||
assert.Equal(t, uint16(53), h.Port)
|
||||
assert.True(t, h.Fn(nil))
|
||||
assert.Equal(t, netip.MustParseAddr("10.168.0.1"), h.ip)
|
||||
assert.Equal(t, uint16(53), h.port)
|
||||
assert.True(t, h.fn(nil))
|
||||
assert.True(t, called)
|
||||
|
||||
manager.SetTCPPacketHook(netip.MustParseAddr("10.168.0.1"), 53, nil)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/netip"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -21,6 +22,7 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/client/firewall/uspfilter/common"
|
||||
"github.com/netbirdio/netbird/client/firewall/uspfilter/conntrack"
|
||||
"github.com/netbirdio/netbird/client/inspect"
|
||||
nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log"
|
||||
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
|
||||
)
|
||||
@@ -46,6 +48,10 @@ type Forwarder struct {
|
||||
netstack bool
|
||||
hasRawICMPAccess bool
|
||||
pingSemaphore chan struct{}
|
||||
// proxy is the optional inspection engine.
|
||||
// When set, TCP connections are handed to the engine for protocol detection
|
||||
// and rule evaluation. Swapped atomically for lock-free hot-path access.
|
||||
proxy atomic.Pointer[inspect.Proxy]
|
||||
}
|
||||
|
||||
func New(iface common.IFaceMapper, logger *nblog.Logger, flowLogger nftypes.FlowLogger, netstack bool, mtu uint16) (*Forwarder, error) {
|
||||
@@ -79,7 +85,7 @@ func New(iface common.IFaceMapper, logger *nblog.Logger, flowLogger nftypes.Flow
|
||||
}
|
||||
|
||||
if err := s.AddProtocolAddress(nicID, protoAddr, stack.AddressProperties{}); err != nil {
|
||||
return nil, fmt.Errorf("failed to add protocol address: %s", err)
|
||||
return nil, fmt.Errorf("add protocol address: %s", err)
|
||||
}
|
||||
|
||||
defaultSubnet, err := tcpip.NewSubnet(
|
||||
@@ -155,6 +161,13 @@ func (f *Forwarder) InjectIncomingPacket(payload []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetProxy sets the inspection engine. When set, TCP connections are handed
|
||||
// to it for protocol detection and rule evaluation instead of direct relay.
|
||||
// Pass nil to disable inspection.
|
||||
func (f *Forwarder) SetProxy(p *inspect.Proxy) {
|
||||
f.proxy.Store(p)
|
||||
}
|
||||
|
||||
// Stop gracefully shuts down the forwarder
|
||||
func (f *Forwarder) Stop() {
|
||||
f.cancel()
|
||||
@@ -167,6 +180,25 @@ func (f *Forwarder) Stop() {
|
||||
f.stack.Wait()
|
||||
}
|
||||
|
||||
// CheckUDPPacket inspects a UDP payload against proxy rules before injection.
|
||||
// This is called by the filter for QUIC SNI-based blocking.
|
||||
// Returns true if the packet should be allowed, false if it should be dropped.
|
||||
func (f *Forwarder) CheckUDPPacket(payload []byte, srcIP, dstIP netip.Addr, srcPort, dstPort uint16, ruleID []byte) bool {
|
||||
p := f.proxy.Load()
|
||||
if p == nil {
|
||||
return true
|
||||
}
|
||||
|
||||
dst := netip.AddrPortFrom(dstIP, dstPort)
|
||||
src := inspect.SourceInfo{
|
||||
IP: srcIP,
|
||||
PolicyID: inspect.PolicyID(ruleID),
|
||||
}
|
||||
|
||||
action := p.HandleUDPPacket(payload, dst, src)
|
||||
return action != inspect.ActionBlock
|
||||
}
|
||||
|
||||
func (f *Forwarder) determineDialAddr(addr tcpip.Address) net.IP {
|
||||
if f.netstack && f.ip.Equal(addr) {
|
||||
return net.IPv4(127, 0, 0, 1)
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"gvisor.dev/gvisor/pkg/tcpip/transport/tcp"
|
||||
"gvisor.dev/gvisor/pkg/waiter"
|
||||
|
||||
"github.com/netbirdio/netbird/client/inspect"
|
||||
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
|
||||
)
|
||||
|
||||
@@ -23,6 +24,86 @@ import (
|
||||
func (f *Forwarder) handleTCP(r *tcp.ForwarderRequest) {
|
||||
id := r.ID()
|
||||
|
||||
// If the inspection engine is configured, accept the connection first and hand it off.
|
||||
if p := f.proxy.Load(); p != nil {
|
||||
f.handleTCPWithInspection(r, id, p)
|
||||
return
|
||||
}
|
||||
|
||||
f.handleTCPDirect(r, id)
|
||||
}
|
||||
|
||||
// handleTCPWithInspection accepts the connection and hands it to the inspection
|
||||
// engine. For allow decisions, the forwarder does its own relay (passthrough).
|
||||
// For block/inspect, the engine handles everything internally.
|
||||
func (f *Forwarder) handleTCPWithInspection(r *tcp.ForwarderRequest, id stack.TransportEndpointID, p *inspect.Proxy) {
|
||||
flowID := uuid.New()
|
||||
f.sendTCPEvent(nftypes.TypeStart, flowID, id, 0, 0, 0, 0)
|
||||
|
||||
wq := waiter.Queue{}
|
||||
ep, epErr := r.CreateEndpoint(&wq)
|
||||
if epErr != nil {
|
||||
f.logger.Error1("forwarder: create TCP endpoint for inspection: %v", epErr)
|
||||
r.Complete(true)
|
||||
f.sendTCPEvent(nftypes.TypeEnd, flowID, id, 0, 0, 0, 0)
|
||||
return
|
||||
}
|
||||
r.Complete(false)
|
||||
|
||||
inConn := gonet.NewTCPConn(&wq, ep)
|
||||
|
||||
srcIP := netip.AddrFrom4(id.RemoteAddress.As4())
|
||||
dstIP := netip.AddrFrom4(id.LocalAddress.As4())
|
||||
dst := netip.AddrPortFrom(dstIP, id.LocalPort)
|
||||
|
||||
var policyID []byte
|
||||
if ruleID, ok := f.getRuleID(srcIP, dstIP, id.RemotePort, id.LocalPort); ok {
|
||||
policyID = ruleID
|
||||
}
|
||||
|
||||
src := inspect.SourceInfo{
|
||||
IP: srcIP,
|
||||
PolicyID: inspect.PolicyID(policyID),
|
||||
}
|
||||
|
||||
f.logger.Trace1("forwarder: handing TCP %v to inspection engine", epID(id))
|
||||
|
||||
go func() {
|
||||
result, err := p.InspectTCP(f.ctx, inConn, dst, src)
|
||||
if err != nil && err != inspect.ErrBlocked {
|
||||
f.logger.Debug2("forwarder: inspection error for %v: %v", epID(id), err)
|
||||
}
|
||||
|
||||
// Passthrough: engine returned allow, forwarder does the relay.
|
||||
if result.PassthroughConn != nil {
|
||||
dialAddr := fmt.Sprintf("%s:%d", f.determineDialAddr(id.LocalAddress), id.LocalPort)
|
||||
outConn, dialErr := (&net.Dialer{}).DialContext(f.ctx, "tcp", dialAddr)
|
||||
if dialErr != nil {
|
||||
f.logger.Trace2("forwarder: passthrough dial error for %v: %v", epID(id), dialErr)
|
||||
if closeErr := result.PassthroughConn.Close(); closeErr != nil {
|
||||
f.logger.Debug1("forwarder: close passthrough conn: %v", closeErr)
|
||||
}
|
||||
ep.Close()
|
||||
f.sendTCPEvent(nftypes.TypeEnd, flowID, id, 0, 0, 0, 0)
|
||||
return
|
||||
}
|
||||
f.proxyTCPPassthrough(id, result.PassthroughConn, outConn, ep, flowID)
|
||||
return
|
||||
}
|
||||
|
||||
// Engine handled it (block/inspect/HTTP). Capture stats and clean up.
|
||||
var rxPackets, txPackets uint64
|
||||
if tcpStats, ok := ep.Stats().(*tcp.Stats); ok {
|
||||
rxPackets = tcpStats.SegmentsSent.Value()
|
||||
txPackets = tcpStats.SegmentsReceived.Value()
|
||||
}
|
||||
ep.Close()
|
||||
f.sendTCPEvent(nftypes.TypeEnd, flowID, id, 0, 0, rxPackets, txPackets)
|
||||
}()
|
||||
}
|
||||
|
||||
// handleTCPDirect handles TCP connections with direct relay (no proxy).
|
||||
func (f *Forwarder) handleTCPDirect(r *tcp.ForwarderRequest, id stack.TransportEndpointID) {
|
||||
flowID := uuid.New()
|
||||
|
||||
f.sendTCPEvent(nftypes.TypeStart, flowID, id, 0, 0, 0, 0)
|
||||
@@ -42,7 +123,6 @@ func (f *Forwarder) handleTCP(r *tcp.ForwarderRequest) {
|
||||
return
|
||||
}
|
||||
|
||||
// Create wait queue for blocking syscalls
|
||||
wq := waiter.Queue{}
|
||||
|
||||
ep, epErr := r.CreateEndpoint(&wq)
|
||||
@@ -55,7 +135,6 @@ func (f *Forwarder) handleTCP(r *tcp.ForwarderRequest) {
|
||||
return
|
||||
}
|
||||
|
||||
// Complete the handshake
|
||||
r.Complete(false)
|
||||
|
||||
inConn := gonet.NewTCPConn(&wq, ep)
|
||||
@@ -73,7 +152,6 @@ func (f *Forwarder) proxyTCP(id stack.TransportEndpointID, inConn *gonet.TCPConn
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
// Close connections and endpoint.
|
||||
if err := inConn.Close(); err != nil && !isClosedError(err) {
|
||||
f.logger.Debug1("forwarder: inConn close error: %v", err)
|
||||
}
|
||||
@@ -132,6 +210,66 @@ func (f *Forwarder) proxyTCP(id stack.TransportEndpointID, inConn *gonet.TCPConn
|
||||
f.sendTCPEvent(nftypes.TypeEnd, flowID, id, uint64(bytesFromOutToIn), uint64(bytesFromInToOut), rxPackets, txPackets)
|
||||
}
|
||||
|
||||
// proxyTCPPassthrough relays traffic between a peeked inbound connection
|
||||
// (from the inspection engine passthrough) and the outbound connection.
|
||||
// It accepts net.Conn for inConn since the inspection engine wraps it in a peekConn.
|
||||
func (f *Forwarder) proxyTCPPassthrough(id stack.TransportEndpointID, inConn net.Conn, outConn net.Conn, ep tcpip.Endpoint, flowID uuid.UUID) {
|
||||
ctx, cancel := context.WithCancel(f.ctx)
|
||||
defer cancel()
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
if err := inConn.Close(); err != nil && !isClosedError(err) {
|
||||
f.logger.Debug1("forwarder: passthrough inConn close: %v", err)
|
||||
}
|
||||
if err := outConn.Close(); err != nil && !isClosedError(err) {
|
||||
f.logger.Debug1("forwarder: passthrough outConn close: %v", err)
|
||||
}
|
||||
ep.Close()
|
||||
}()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
|
||||
var (
|
||||
bytesIn int64
|
||||
bytesOut int64
|
||||
errIn error
|
||||
errOut error
|
||||
)
|
||||
|
||||
go func() {
|
||||
bytesIn, errIn = io.Copy(outConn, inConn)
|
||||
cancel()
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
go func() {
|
||||
bytesOut, errOut = io.Copy(inConn, outConn)
|
||||
cancel()
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
if errIn != nil && !isClosedError(errIn) {
|
||||
f.logger.Error2("proxyTCPPassthrough: copy error (in→out) for %s: %v", epID(id), errIn)
|
||||
}
|
||||
if errOut != nil && !isClosedError(errOut) {
|
||||
f.logger.Error2("proxyTCPPassthrough: copy error (out→in) for %s: %v", epID(id), errOut)
|
||||
}
|
||||
|
||||
var rxPackets, txPackets uint64
|
||||
if tcpStats, ok := ep.Stats().(*tcp.Stats); ok {
|
||||
rxPackets = tcpStats.SegmentsSent.Value()
|
||||
txPackets = tcpStats.SegmentsReceived.Value()
|
||||
}
|
||||
|
||||
f.logger.Trace5("forwarder: passthrough TCP %s [in: %d Pkts/%d B, out: %d Pkts/%d B]", epID(id), rxPackets, bytesOut, txPackets, bytesIn)
|
||||
|
||||
f.sendTCPEvent(nftypes.TypeEnd, flowID, id, uint64(bytesOut), uint64(bytesIn), rxPackets, txPackets)
|
||||
}
|
||||
|
||||
func (f *Forwarder) sendTCPEvent(typ nftypes.Type, flowID uuid.UUID, id stack.TransportEndpointID, rxBytes, txBytes, rxPackets, txPackets uint64) {
|
||||
srcIp := netip.AddrFrom4(id.RemoteAddress.As4())
|
||||
dstIp := netip.AddrFrom4(id.LocalAddress.As4())
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
package uspfilter
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"net/netip"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/netbirdio/netbird/client/firewall/uspfilter/common"
|
||||
"github.com/netbirdio/netbird/client/iface/device"
|
||||
)
|
||||
|
||||
const (
|
||||
ipv4HeaderMinLen = 20
|
||||
ipv4ProtoOffset = 9
|
||||
ipv4FlagsOffset = 6
|
||||
ipv4DstOffset = 16
|
||||
ipProtoUDP = 17
|
||||
ipProtoTCP = 6
|
||||
ipv4FragOffMask = 0x1fff
|
||||
// dstPortOffset is the offset of the destination port within a UDP or TCP header.
|
||||
dstPortOffset = 2
|
||||
)
|
||||
|
||||
// HooksFilter is a minimal packet filter that only handles outbound DNS hooks.
|
||||
// It is installed on the WireGuard interface when the userspace bind is active
|
||||
// but a full firewall filter (Manager) is not needed because a native kernel
|
||||
// firewall (nftables/iptables) handles packet filtering.
|
||||
type HooksFilter struct {
|
||||
udpHook atomic.Pointer[common.PacketHook]
|
||||
tcpHook atomic.Pointer[common.PacketHook]
|
||||
}
|
||||
|
||||
var _ device.PacketFilter = (*HooksFilter)(nil)
|
||||
|
||||
// FilterOutbound checks outbound packets for DNS hook matches.
|
||||
// Only IPv4 packets matching the registered hook IP:port are intercepted.
|
||||
// IPv6 and non-IP packets pass through unconditionally.
|
||||
func (f *HooksFilter) FilterOutbound(packetData []byte, _ int) bool {
|
||||
if len(packetData) < ipv4HeaderMinLen {
|
||||
return false
|
||||
}
|
||||
|
||||
// Only process IPv4 packets, let everything else pass through.
|
||||
if packetData[0]>>4 != 4 {
|
||||
return false
|
||||
}
|
||||
|
||||
ihl := int(packetData[0]&0x0f) * 4
|
||||
if ihl < ipv4HeaderMinLen || len(packetData) < ihl+4 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Skip non-first fragments: they don't carry L4 headers.
|
||||
flagsAndOffset := binary.BigEndian.Uint16(packetData[ipv4FlagsOffset : ipv4FlagsOffset+2])
|
||||
if flagsAndOffset&ipv4FragOffMask != 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
dstIP, ok := netip.AddrFromSlice(packetData[ipv4DstOffset : ipv4DstOffset+4])
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
proto := packetData[ipv4ProtoOffset]
|
||||
dstPort := binary.BigEndian.Uint16(packetData[ihl+dstPortOffset : ihl+dstPortOffset+2])
|
||||
|
||||
switch proto {
|
||||
case ipProtoUDP:
|
||||
return common.HookMatches(f.udpHook.Load(), dstIP, dstPort, packetData)
|
||||
case ipProtoTCP:
|
||||
return common.HookMatches(f.tcpHook.Load(), dstIP, dstPort, packetData)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// FilterInbound allows all inbound packets (native firewall handles filtering).
|
||||
func (f *HooksFilter) FilterInbound([]byte, int) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// SetUDPPacketHook registers the UDP packet hook.
|
||||
func (f *HooksFilter) SetUDPPacketHook(ip netip.Addr, dPort uint16, hook func([]byte) bool) {
|
||||
common.SetHook(&f.udpHook, ip, dPort, hook)
|
||||
}
|
||||
|
||||
// SetTCPPacketHook registers the TCP packet hook.
|
||||
func (f *HooksFilter) SetTCPPacketHook(ip netip.Addr, dPort uint16, hook func([]byte) bool) {
|
||||
common.SetHook(&f.tcpHook, ip, dPort, hook)
|
||||
}
|
||||
@@ -171,7 +171,7 @@ func (u *UDPConn) performFilterCheck(addr net.Addr) error {
|
||||
}
|
||||
|
||||
if u.address.Network.Contains(a) {
|
||||
log.Warnf("address %s is part of the NetBird network %s, refusing to write", addr, u.address)
|
||||
log.Warnf("Address %s is part of the NetBird network %s, refusing to write", addr, u.address)
|
||||
return fmt.Errorf("address %s is part of the NetBird network %s, refusing to write", addr, u.address)
|
||||
}
|
||||
|
||||
@@ -181,7 +181,7 @@ func (u *UDPConn) performFilterCheck(addr net.Addr) error {
|
||||
u.addrCache.Store(addr.String(), isRouted)
|
||||
if isRouted {
|
||||
// Extra log, as the error only shows up with ICE logging enabled
|
||||
log.Infof("address %s is part of routed network %s, refusing to write", addr, prefix)
|
||||
log.Infof("Address %s is part of routed network %s, refusing to write", addr, prefix)
|
||||
return fmt.Errorf("address %s is part of routed network %s, refusing to write", addr, prefix)
|
||||
}
|
||||
}
|
||||
|
||||
212
client/inspect/config.go
Normal file
212
client/inspect/config.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/x509"
|
||||
"net"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/acl/id"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
)
|
||||
|
||||
// InspectResult holds the outcome of connection inspection.
|
||||
type InspectResult struct {
|
||||
// Action is the rule evaluation result.
|
||||
Action Action
|
||||
// PassthroughConn is the client connection with buffered peeked bytes.
|
||||
// Non-nil only when Action is ActionAllow and the caller should relay
|
||||
// (TLS passthrough or non-HTTP/TLS protocol). The caller takes ownership
|
||||
// and is responsible for closing this connection.
|
||||
PassthroughConn net.Conn
|
||||
}
|
||||
|
||||
const (
|
||||
// DefaultTProxyPort is the default TPROXY listener port for kernel mode.
|
||||
// Override with NB_TPROXY_PORT environment variable.
|
||||
DefaultTProxyPort = 22080
|
||||
)
|
||||
|
||||
// Action determines how the proxy handles a matched connection.
|
||||
type Action string
|
||||
|
||||
const (
|
||||
// ActionAllow passes the connection through without decryption.
|
||||
ActionAllow Action = "allow"
|
||||
// ActionBlock denies the connection.
|
||||
ActionBlock Action = "block"
|
||||
// ActionInspect decrypts (MITM) and inspects the connection.
|
||||
ActionInspect Action = "inspect"
|
||||
)
|
||||
|
||||
// ProxyMode determines the proxy operating mode.
|
||||
type ProxyMode string
|
||||
|
||||
const (
|
||||
// ModeBuiltin uses the built-in proxy with rules and optional ICAP.
|
||||
ModeBuiltin ProxyMode = "builtin"
|
||||
// ModeEnvoy runs a local envoy sidecar for L7 processing.
|
||||
// Go manages envoy lifecycle, config generation, and rule evaluation.
|
||||
// USP path forwards via PROXY protocol v2; kernel path uses nftables redirect.
|
||||
ModeEnvoy ProxyMode = "envoy"
|
||||
// ModeExternal forwards all traffic to an external proxy.
|
||||
ModeExternal ProxyMode = "external"
|
||||
)
|
||||
|
||||
// PolicyID is the management policy identifier associated with a connection.
|
||||
type PolicyID []byte
|
||||
|
||||
// MatchDomain reports whether target matches the pattern.
|
||||
// If pattern starts with "*.", it matches any subdomain (but not the base itself).
|
||||
// Otherwise it requires an exact match.
|
||||
func MatchDomain(pattern, target domain.Domain) bool {
|
||||
p := pattern.PunycodeString()
|
||||
t := target.PunycodeString()
|
||||
|
||||
if strings.HasPrefix(p, "*.") {
|
||||
base := p[2:]
|
||||
return strings.HasSuffix(t, "."+base)
|
||||
}
|
||||
|
||||
return p == t
|
||||
}
|
||||
|
||||
// SourceInfo carries source identity context for rule evaluation.
|
||||
// The source may be a direct WireGuard peer or a host behind
|
||||
// a site-to-site gateway.
|
||||
type SourceInfo struct {
|
||||
// IP is the original source address from the packet.
|
||||
IP netip.Addr
|
||||
// PolicyID is the management policy that allowed this traffic
|
||||
// through route ACLs.
|
||||
PolicyID PolicyID
|
||||
}
|
||||
|
||||
// ProtoType identifies a protocol handled by the proxy.
|
||||
type ProtoType string
|
||||
|
||||
const (
|
||||
ProtoHTTP ProtoType = "http"
|
||||
ProtoHTTPS ProtoType = "https"
|
||||
ProtoH2 ProtoType = "h2"
|
||||
ProtoH3 ProtoType = "h3"
|
||||
ProtoWebSocket ProtoType = "websocket"
|
||||
ProtoOther ProtoType = "other"
|
||||
)
|
||||
|
||||
// Rule defines a proxy inspection/filtering rule.
|
||||
type Rule struct {
|
||||
// ID uniquely identifies this rule.
|
||||
ID id.RuleID
|
||||
// Sources are the source CIDRs this rule applies to.
|
||||
// Includes both direct peer IPs and routed networks behind gateways.
|
||||
Sources []netip.Prefix
|
||||
// Domains are the destination domain patterns to match (via SNI or Host header).
|
||||
// Supports exact match ("example.com") and wildcard ("*.example.com").
|
||||
Domains []domain.Domain
|
||||
// Networks are the destination CIDRs to match.
|
||||
Networks []netip.Prefix
|
||||
// Ports are the destination ports to match. Empty means all ports.
|
||||
Ports []uint16
|
||||
// Protocols restricts which protocols this rule applies to.
|
||||
// Empty means all protocols.
|
||||
Protocols []ProtoType
|
||||
// Paths are URL path patterns to match (HTTP only, requires inspect for HTTPS).
|
||||
// Supports prefix ("/api/"), exact ("/login"), and wildcard ("/admin/*").
|
||||
// Empty means all paths.
|
||||
Paths []string
|
||||
// Action determines what to do with matched connections.
|
||||
Action Action
|
||||
// Priority controls evaluation order. Lower values are evaluated first.
|
||||
Priority int
|
||||
}
|
||||
|
||||
// ICAPConfig holds ICAP service configuration.
|
||||
type ICAPConfig struct {
|
||||
// ReqModURL is the ICAP REQMOD service URL (e.g., icap://server:1344/reqmod).
|
||||
ReqModURL *url.URL
|
||||
// RespModURL is the ICAP RESPMOD service URL (e.g., icap://server:1344/respmod).
|
||||
RespModURL *url.URL
|
||||
// MaxConnections is the connection pool size. Zero uses a default.
|
||||
MaxConnections int
|
||||
}
|
||||
|
||||
// TLSConfig holds the MITM CA configuration for TLS inspection.
|
||||
type TLSConfig struct {
|
||||
// CA is the certificate authority used to sign dynamic certificates.
|
||||
CA *x509.Certificate
|
||||
// CAKey is the CA's private key.
|
||||
CAKey crypto.PrivateKey
|
||||
}
|
||||
|
||||
// Config holds the transparent proxy configuration.
|
||||
type Config struct {
|
||||
// Enabled controls whether the proxy is active.
|
||||
Enabled bool
|
||||
// Mode selects built-in or external proxy operation.
|
||||
Mode ProxyMode
|
||||
// ExternalURL is the upstream proxy URL for ModeExternal.
|
||||
// Supports http:// and socks5:// schemes.
|
||||
ExternalURL *url.URL
|
||||
|
||||
// DefaultAction applies when no rule matches a connection.
|
||||
DefaultAction Action
|
||||
|
||||
// RedirectSources are the source CIDRs whose traffic should be intercepted.
|
||||
// Admin decides: "activate for these users/subnets."
|
||||
// Used for both kernel TPROXY rules and userspace forwarder source filtering.
|
||||
RedirectSources []netip.Prefix
|
||||
// RedirectPorts are the destination ports to intercept. Empty means all ports.
|
||||
RedirectPorts []uint16
|
||||
|
||||
// Rules are the proxy inspection/filtering rules, evaluated in Priority order.
|
||||
Rules []Rule
|
||||
|
||||
// ICAP holds ICAP service configuration. Nil disables ICAP.
|
||||
ICAP *ICAPConfig
|
||||
// TLS holds the MITM CA. Nil means no MITM capability (ActionInspect rules ignored).
|
||||
TLS *TLSConfig
|
||||
|
||||
// Envoy configuration (ModeEnvoy only)
|
||||
Envoy *EnvoyConfig
|
||||
|
||||
// ListenAddr is the TPROXY listen address for kernel mode.
|
||||
// Zero value disables the TPROXY listener.
|
||||
ListenAddr netip.AddrPort
|
||||
// WGNetwork is the WireGuard overlay network prefix.
|
||||
// The proxy blocks dialing destinations inside this network.
|
||||
WGNetwork netip.Prefix
|
||||
// LocalIPChecker reports whether an IP belongs to the routing peer.
|
||||
// Used to prevent SSRF to local services. May be nil.
|
||||
LocalIPChecker LocalIPChecker
|
||||
}
|
||||
|
||||
// EnvoyConfig holds configuration for the envoy sidecar mode.
|
||||
type EnvoyConfig struct {
|
||||
// BinaryPath is the path to the envoy binary.
|
||||
// Empty means search $PATH for "envoy".
|
||||
BinaryPath string
|
||||
// AdminPort is the port for envoy's admin API (health checks, stats).
|
||||
// Zero means auto-assign.
|
||||
AdminPort uint16
|
||||
// Snippets are user-provided config fragments merged into the generated bootstrap.
|
||||
Snippets *EnvoySnippets
|
||||
}
|
||||
|
||||
// EnvoySnippets holds user-provided YAML fragments for envoy config customization.
|
||||
// Only safe snippet types are allowed: filters (HTTP and network) and clusters
|
||||
// needed as dependencies for filter services. Listeners and bootstrap overrides
|
||||
// are not exposed since we manage the listener and bootstrap.
|
||||
type EnvoySnippets struct {
|
||||
// HTTPFilters is YAML injected into the HCM filter chain before the router filter.
|
||||
// Used for ext_authz, rate limiting, Lua, Wasm, RBAC, JWT auth, etc.
|
||||
HTTPFilters string
|
||||
// NetworkFilters is YAML injected into the TLS filter chain before tcp_proxy.
|
||||
// Used for network-level RBAC, rate limiting, ext_authz on raw TCP.
|
||||
NetworkFilters string
|
||||
// Clusters is YAML for additional upstream clusters referenced by filters.
|
||||
// Needed when filters call external services (ext_authz backend, rate limit service).
|
||||
Clusters string
|
||||
}
|
||||
93
client/inspect/config_test.go
Normal file
93
client/inspect/config_test.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
)
|
||||
|
||||
func TestMatchDomain(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
target string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "exact match",
|
||||
pattern: "example.com",
|
||||
target: "example.com",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "exact no match",
|
||||
pattern: "example.com",
|
||||
target: "other.com",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "wildcard matches subdomain",
|
||||
pattern: "*.example.com",
|
||||
target: "foo.example.com",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard matches deep subdomain",
|
||||
pattern: "*.example.com",
|
||||
target: "a.b.c.example.com",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard does not match base",
|
||||
pattern: "*.example.com",
|
||||
target: "example.com",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "wildcard does not match unrelated",
|
||||
pattern: "*.example.com",
|
||||
target: "foo.other.com",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "case insensitive exact match",
|
||||
pattern: "Example.COM",
|
||||
target: "example.com",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "case insensitive wildcard match",
|
||||
pattern: "*.Example.COM",
|
||||
target: "FOO.example.com",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard does not match partial suffix",
|
||||
pattern: "*.example.com",
|
||||
target: "notexample.com",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "unicode domain punycode match",
|
||||
pattern: "*.münchen.de",
|
||||
target: "sub.xn--mnchen-3ya.de",
|
||||
want: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
pattern, err := domain.FromString(tt.pattern)
|
||||
require.NoError(t, err)
|
||||
|
||||
target, err := domain.FromString(tt.target)
|
||||
require.NoError(t, err)
|
||||
|
||||
got := MatchDomain(pattern, target)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
25
client/inspect/dialer_linux.go
Normal file
25
client/inspect/dialer_linux.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"net"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// newOutboundDialer creates a net.Dialer that clears the socket fwmark.
|
||||
// In kernel TPROXY mode, accepted connections inherit the TPROXY fwmark.
|
||||
// Without clearing it, outbound connections from the proxy would match
|
||||
// the ip rule (fwmark -> local loopback) and loop back to the proxy
|
||||
// instead of reaching the real destination.
|
||||
func newOutboundDialer() net.Dialer {
|
||||
return net.Dialer{
|
||||
Control: func(_, _ string, c syscall.RawConn) error {
|
||||
var sockErr error
|
||||
if err := c.Control(func(fd uintptr) {
|
||||
sockErr = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_MARK, 0)
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return sockErr
|
||||
},
|
||||
}
|
||||
}
|
||||
11
client/inspect/dialer_other.go
Normal file
11
client/inspect/dialer_other.go
Normal file
@@ -0,0 +1,11 @@
|
||||
//go:build !linux
|
||||
|
||||
package inspect
|
||||
|
||||
import "net"
|
||||
|
||||
// newOutboundDialer returns a plain dialer on non-Linux platforms.
|
||||
// TPROXY is Linux-only, so no fwmark clearing is needed.
|
||||
func newOutboundDialer() net.Dialer {
|
||||
return net.Dialer{}
|
||||
}
|
||||
298
client/inspect/envoy.go
Normal file
298
client/inspect/envoy.go
Normal file
@@ -0,0 +1,298 @@
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
envoyStartTimeout = 15 * time.Second
|
||||
envoyHealthInterval = 500 * time.Millisecond
|
||||
envoyStopTimeout = 10 * time.Second
|
||||
envoyDrainTime = 5
|
||||
)
|
||||
|
||||
// envoyManager manages the lifecycle of an envoy sidecar process.
|
||||
type envoyManager struct {
|
||||
log *log.Entry
|
||||
cmd *exec.Cmd
|
||||
configPath string
|
||||
listenPort uint16
|
||||
adminPort uint16
|
||||
cancel context.CancelFunc
|
||||
|
||||
blockPagePath string
|
||||
|
||||
mu sync.Mutex
|
||||
running bool
|
||||
}
|
||||
|
||||
// startEnvoy finds the envoy binary, generates config, and spawns the process.
|
||||
// It blocks until envoy reports healthy or the timeout expires.
|
||||
func startEnvoy(ctx context.Context, logger *log.Entry, config Config) (*envoyManager, error) {
|
||||
envCfg := config.Envoy
|
||||
if envCfg == nil {
|
||||
return nil, fmt.Errorf("envoy config is nil")
|
||||
}
|
||||
|
||||
binaryPath, err := findEnvoyBinary(envCfg.BinaryPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find envoy binary: %w", err)
|
||||
}
|
||||
|
||||
// Pick admin port
|
||||
adminPort := envCfg.AdminPort
|
||||
if adminPort == 0 {
|
||||
p, err := findFreePort()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find free admin port: %w", err)
|
||||
}
|
||||
adminPort = p
|
||||
}
|
||||
|
||||
// Pick listener port
|
||||
listenPort, err := findFreePort()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("find free listener port: %w", err)
|
||||
}
|
||||
|
||||
// Use a private temp directory (0700) to prevent local attackers from
|
||||
// replacing the config file between write and envoy read.
|
||||
configDir, err := os.MkdirTemp("", "nb-envoy-*")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create envoy config directory: %w", err)
|
||||
}
|
||||
|
||||
// Write the block page HTML for envoy's direct_response to reference.
|
||||
blockPagePath := filepath.Join(configDir, "block.html")
|
||||
blockHTML := fmt.Sprintf(blockPageHTML, "blocked domain", "this domain")
|
||||
if err := os.WriteFile(blockPagePath, []byte(blockHTML), 0600); err != nil {
|
||||
return nil, fmt.Errorf("write envoy block page: %w", err)
|
||||
}
|
||||
|
||||
// Generate config with the block page path embedded.
|
||||
bootstrap, err := generateBootstrap(config, listenPort, adminPort, blockPagePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate envoy bootstrap: %w", err)
|
||||
}
|
||||
|
||||
configPath := filepath.Join(configDir, "bootstrap.yaml")
|
||||
if err := os.WriteFile(configPath, bootstrap, 0600); err != nil {
|
||||
return nil, fmt.Errorf("write envoy config: %w", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
|
||||
cmd := exec.CommandContext(ctx, binaryPath,
|
||||
"-c", configPath,
|
||||
"--drain-time-s", fmt.Sprintf("%d", envoyDrainTime),
|
||||
)
|
||||
|
||||
// Pipe envoy output to our logger.
|
||||
cmd.Stdout = &logWriter{entry: logger, level: log.DebugLevel}
|
||||
cmd.Stderr = &logWriter{entry: logger, level: log.WarnLevel}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
cancel()
|
||||
os.Remove(configPath)
|
||||
return nil, fmt.Errorf("start envoy: %w", err)
|
||||
}
|
||||
|
||||
mgr := &envoyManager{
|
||||
log: logger,
|
||||
cmd: cmd,
|
||||
configPath: configPath,
|
||||
listenPort: listenPort,
|
||||
adminPort: adminPort,
|
||||
blockPagePath: blockPagePath,
|
||||
cancel: cancel,
|
||||
running: true,
|
||||
}
|
||||
|
||||
// Wait for envoy to become healthy.
|
||||
if err := mgr.waitHealthy(ctx); err != nil {
|
||||
mgr.Stop()
|
||||
return nil, fmt.Errorf("wait for envoy readiness: %w", err)
|
||||
}
|
||||
|
||||
logger.Infof("inspect: envoy started (pid=%d, listen=%d, admin=%d)", cmd.Process.Pid, listenPort, adminPort)
|
||||
|
||||
// Monitor process exit in background.
|
||||
go mgr.monitor()
|
||||
|
||||
return mgr, nil
|
||||
}
|
||||
|
||||
// ListenAddr returns the address envoy listens on for forwarded connections.
|
||||
func (m *envoyManager) ListenAddr() netip.AddrPort {
|
||||
return netip.AddrPortFrom(netip.AddrFrom4([4]byte{127, 0, 0, 1}), m.listenPort)
|
||||
}
|
||||
|
||||
// AdminAddr returns the envoy admin API address.
|
||||
func (m *envoyManager) AdminAddr() string {
|
||||
return fmt.Sprintf("127.0.0.1:%d", m.adminPort)
|
||||
}
|
||||
|
||||
// Reload writes a new config and sends SIGHUP to envoy.
|
||||
func (m *envoyManager) Reload(config Config) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if !m.running {
|
||||
return fmt.Errorf("envoy is not running")
|
||||
}
|
||||
|
||||
bootstrap, err := generateBootstrap(config, m.listenPort, m.adminPort, m.blockPagePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate envoy bootstrap: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(m.configPath, bootstrap, 0600); err != nil {
|
||||
return fmt.Errorf("write envoy config: %w", err)
|
||||
}
|
||||
|
||||
if err := signalReload(m.cmd.Process); err != nil {
|
||||
return fmt.Errorf("signal envoy reload: %w", err)
|
||||
}
|
||||
|
||||
m.log.Debugf("inspect: envoy config reloaded")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Healthy checks the envoy admin API /ready endpoint.
|
||||
func (m *envoyManager) Healthy() bool {
|
||||
resp, err := http.Get(fmt.Sprintf("http://%s/ready", m.AdminAddr()))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return resp.StatusCode == http.StatusOK
|
||||
}
|
||||
|
||||
// Stop terminates the envoy process and cleans up.
|
||||
func (m *envoyManager) Stop() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if !m.running {
|
||||
return
|
||||
}
|
||||
m.running = false
|
||||
|
||||
m.cancel()
|
||||
|
||||
if m.cmd.Process != nil {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
m.cmd.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(envoyStopTimeout):
|
||||
m.log.Warnf("inspect: envoy did not exit in %s, killing", envoyStopTimeout)
|
||||
m.cmd.Process.Kill()
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
os.RemoveAll(filepath.Dir(m.configPath))
|
||||
m.log.Infof("inspect: envoy stopped")
|
||||
}
|
||||
|
||||
// waitHealthy polls the admin API until envoy is ready or timeout.
|
||||
func (m *envoyManager) waitHealthy(ctx context.Context) error {
|
||||
deadline := time.After(envoyStartTimeout)
|
||||
ticker := time.NewTicker(envoyHealthInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-deadline:
|
||||
return fmt.Errorf("envoy not ready after %s", envoyStartTimeout)
|
||||
case <-ticker.C:
|
||||
if m.Healthy() {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// monitor watches for unexpected envoy exits.
|
||||
func (m *envoyManager) monitor() {
|
||||
err := m.cmd.Wait()
|
||||
|
||||
m.mu.Lock()
|
||||
wasRunning := m.running
|
||||
m.running = false
|
||||
m.mu.Unlock()
|
||||
|
||||
if wasRunning {
|
||||
m.log.Errorf("inspect: envoy exited unexpectedly: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// findEnvoyBinary resolves the envoy binary path.
|
||||
func findEnvoyBinary(configPath string) (string, error) {
|
||||
if configPath != "" {
|
||||
if _, err := os.Stat(configPath); err != nil {
|
||||
return "", fmt.Errorf("envoy binary not found at %s: %w", configPath, err)
|
||||
}
|
||||
return configPath, nil
|
||||
}
|
||||
|
||||
path, err := exec.LookPath("envoy")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("envoy not found in PATH: %w", err)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// findFreePort asks the OS for an available TCP port.
|
||||
func findFreePort() (uint16, error) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
port := uint16(ln.Addr().(*net.TCPAddr).Port)
|
||||
ln.Close()
|
||||
return port, nil
|
||||
}
|
||||
|
||||
// logWriter adapts log.Entry to io.Writer for piping process output.
|
||||
type logWriter struct {
|
||||
entry *log.Entry
|
||||
level log.Level
|
||||
}
|
||||
|
||||
func (w *logWriter) Write(p []byte) (int, error) {
|
||||
msg := strings.TrimRight(string(p), "\n\r")
|
||||
if msg == "" {
|
||||
return len(p), nil
|
||||
}
|
||||
switch w.level {
|
||||
case log.WarnLevel:
|
||||
w.entry.Warn(msg)
|
||||
default:
|
||||
w.entry.Debug(msg)
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// Ensure logWriter satisfies io.Writer.
|
||||
var _ io.Writer = (*logWriter)(nil)
|
||||
382
client/inspect/envoy_config.go
Normal file
382
client/inspect/envoy_config.go
Normal file
@@ -0,0 +1,382 @@
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
)
|
||||
|
||||
// envoyBootstrapTmpl generates the full envoy bootstrap with rule translation.
|
||||
// TLS rules become per-SNI filter chains; HTTP rules become per-domain virtual hosts.
|
||||
var envoyBootstrapTmpl = template.Must(template.New("bootstrap").Funcs(template.FuncMap{
|
||||
"quote": func(s string) string { return fmt.Sprintf("%q", s) },
|
||||
}).Parse(`node:
|
||||
id: netbird-inspect
|
||||
cluster: netbird
|
||||
admin:
|
||||
address:
|
||||
socket_address:
|
||||
address: 127.0.0.1
|
||||
port_value: {{.AdminPort}}
|
||||
static_resources:
|
||||
listeners:
|
||||
- name: inspect_listener
|
||||
address:
|
||||
socket_address:
|
||||
address: 127.0.0.1
|
||||
port_value: {{.ListenPort}}
|
||||
listener_filters:
|
||||
- name: envoy.filters.listener.proxy_protocol
|
||||
typed_config:
|
||||
"@type": type.googleapis.com/envoy.extensions.filters.listener.proxy_protocol.v3.ProxyProtocol
|
||||
- name: envoy.filters.listener.tls_inspector
|
||||
typed_config:
|
||||
"@type": type.googleapis.com/envoy.extensions.filters.listener.tls_inspector.v3.TlsInspector
|
||||
filter_chains:
|
||||
{{- /* TLS filter chains: per-SNI block/allow + default */ -}}
|
||||
{{- range .TLSChains}}
|
||||
- filter_chain_match:
|
||||
transport_protocol: tls
|
||||
{{- if .ServerNames}}
|
||||
server_names:
|
||||
{{- range .ServerNames}}
|
||||
- {{quote .}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
filters:
|
||||
{{$.NetworkFiltersSnippet}} - name: envoy.filters.network.tcp_proxy
|
||||
typed_config:
|
||||
"@type": type.googleapis.com/envoy.extensions.filters.network.tcp_proxy.v3.TcpProxy
|
||||
stat_prefix: {{.StatPrefix}}
|
||||
cluster: original_dst
|
||||
access_log:
|
||||
- name: envoy.access_loggers.stderr
|
||||
typed_config:
|
||||
"@type": type.googleapis.com/envoy.extensions.access_loggers.stream.v3.StderrAccessLog
|
||||
log_format:
|
||||
text_format: "[%START_TIME%] tcp %DOWNSTREAM_REMOTE_ADDRESS% -> %UPSTREAM_HOST% %RESPONSE_FLAGS% %DURATION%ms\n"
|
||||
{{- end}}
|
||||
{{- /* Plain HTTP filter chain with per-domain virtual hosts */}}
|
||||
- filters:
|
||||
- name: envoy.filters.network.http_connection_manager
|
||||
typed_config:
|
||||
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
|
||||
stat_prefix: inspect_http
|
||||
access_log:
|
||||
- name: envoy.access_loggers.stderr
|
||||
typed_config:
|
||||
"@type": type.googleapis.com/envoy.extensions.access_loggers.stream.v3.StderrAccessLog
|
||||
log_format:
|
||||
text_format: "[%START_TIME%] http %DOWNSTREAM_REMOTE_ADDRESS% %REQ(:AUTHORITY)% %REQ(:METHOD)% %REQ(X-ENVOY-ORIGINAL-PATH?:PATH)% %RESPONSE_CODE% %RESPONSE_FLAGS% %DURATION%ms\n"
|
||||
http_filters:
|
||||
{{.HTTPFiltersSnippet}} - name: envoy.filters.http.router
|
||||
typed_config:
|
||||
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
|
||||
route_config:
|
||||
virtual_hosts:
|
||||
{{- range .VirtualHosts}}
|
||||
- name: {{.Name}}
|
||||
domains: [{{.DomainsStr}}]
|
||||
routes:
|
||||
{{- range .Routes}}
|
||||
- match:
|
||||
prefix: "{{if .PathPrefix}}{{.PathPrefix}}{{else}}/{{end}}"
|
||||
{{- if .Block}}
|
||||
direct_response:
|
||||
status: 403
|
||||
body:
|
||||
filename: "{{$.BlockPagePath}}"
|
||||
{{- else}}
|
||||
route:
|
||||
cluster: original_dst
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
clusters:
|
||||
- name: original_dst
|
||||
type: ORIGINAL_DST
|
||||
lb_policy: CLUSTER_PROVIDED
|
||||
connect_timeout: 10s
|
||||
{{.ExtraClusters}}`))
|
||||
|
||||
// tlsChain represents a TLS filter chain entry for the template.
|
||||
// All TLS chains are passthrough (block decisions happen in Go before envoy).
|
||||
type tlsChain struct {
|
||||
// ServerNames restricts this chain to specific SNIs. Empty is catch-all.
|
||||
ServerNames []string
|
||||
StatPrefix string
|
||||
}
|
||||
|
||||
// envoyRoute represents a single route entry within a virtual host.
|
||||
type envoyRoute struct {
|
||||
// PathPrefix for envoy prefix match. Empty means catch-all "/".
|
||||
PathPrefix string
|
||||
Block bool
|
||||
}
|
||||
|
||||
// virtualHost represents an HTTP virtual host entry for the template.
|
||||
type virtualHost struct {
|
||||
Name string
|
||||
// DomainsStr is pre-formatted for the template: "a", "b".
|
||||
DomainsStr string
|
||||
Routes []envoyRoute
|
||||
}
|
||||
|
||||
type bootstrapData struct {
|
||||
AdminPort uint16
|
||||
ListenPort uint16
|
||||
BlockPagePath string
|
||||
TLSChains []tlsChain
|
||||
VirtualHosts []virtualHost
|
||||
HTTPFiltersSnippet string
|
||||
NetworkFiltersSnippet string
|
||||
ExtraClusters string
|
||||
}
|
||||
|
||||
// generateBootstrap produces the envoy bootstrap YAML from the inspect config.
|
||||
// Translates inspection rules into envoy-native per-SNI and per-domain routing.
|
||||
// blockPagePath is the path to the HTML block page file served by direct_response.
|
||||
func generateBootstrap(config Config, listenPort, adminPort uint16, blockPagePath string) ([]byte, error) {
|
||||
data := bootstrapData{
|
||||
AdminPort: adminPort,
|
||||
BlockPagePath: blockPagePath,
|
||||
ListenPort: listenPort,
|
||||
TLSChains: buildTLSChains(config),
|
||||
VirtualHosts: buildVirtualHosts(config),
|
||||
}
|
||||
|
||||
if config.Envoy != nil && config.Envoy.Snippets != nil {
|
||||
s := config.Envoy.Snippets
|
||||
data.HTTPFiltersSnippet = indentSnippet(s.HTTPFilters, 18)
|
||||
data.NetworkFiltersSnippet = indentSnippet(s.NetworkFilters, 12)
|
||||
data.ExtraClusters = indentSnippet(s.Clusters, 4)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := envoyBootstrapTmpl.Execute(&buf, data); err != nil {
|
||||
return nil, fmt.Errorf("execute bootstrap template: %w", err)
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// buildTLSChains translates inspection rules into envoy TLS filter chains.
|
||||
// Block rules -> per-SNI chain routing to blackhole.
|
||||
// Allow rules (when default=block) -> per-SNI chain routing to original_dst.
|
||||
// Default chain follows DefaultAction.
|
||||
func buildTLSChains(config Config) []tlsChain {
|
||||
// TLS block decisions happen in Go before forwarding to envoy, so we only
|
||||
// generate allow/passthrough chains here. Envoy can't cleanly close a TLS
|
||||
// connection without completing a handshake, so blocked SNIs never reach envoy.
|
||||
var allowed []string
|
||||
|
||||
for _, rule := range config.Rules {
|
||||
if !ruleTouchesProtocol(rule, ProtoHTTPS, ProtoH2) {
|
||||
continue
|
||||
}
|
||||
for _, d := range rule.Domains {
|
||||
sni := d.PunycodeString()
|
||||
if rule.Action == ActionAllow || rule.Action == ActionInspect {
|
||||
allowed = append(allowed, sni)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var chains []tlsChain
|
||||
|
||||
if len(allowed) > 0 && config.DefaultAction == ActionBlock {
|
||||
chains = append(chains, tlsChain{
|
||||
ServerNames: allowed,
|
||||
StatPrefix: "tls_allowed",
|
||||
})
|
||||
}
|
||||
|
||||
// Default catch-all: passthrough (blocked SNIs never arrive here)
|
||||
chains = append(chains, tlsChain{
|
||||
StatPrefix: "tls_default",
|
||||
})
|
||||
|
||||
return chains
|
||||
}
|
||||
|
||||
// buildVirtualHosts translates inspection rules into envoy HTTP virtual hosts.
|
||||
// Groups rules by domain, generates per-path routes within each virtual host.
|
||||
func buildVirtualHosts(config Config) []virtualHost {
|
||||
// Group rules by domain for per-domain virtual hosts.
|
||||
type domainRules struct {
|
||||
domains []string
|
||||
routes []envoyRoute
|
||||
}
|
||||
|
||||
domainRouteMap := make(map[string][]envoyRoute)
|
||||
|
||||
for _, rule := range config.Rules {
|
||||
if !ruleTouchesProtocol(rule, ProtoHTTP, ProtoWebSocket) {
|
||||
continue
|
||||
}
|
||||
isBlock := rule.Action == ActionBlock
|
||||
|
||||
// Rules without domains or paths are handled by the default action.
|
||||
if len(rule.Domains) == 0 && len(rule.Paths) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Build routes for this rule's paths
|
||||
var routes []envoyRoute
|
||||
if len(rule.Paths) > 0 {
|
||||
for _, p := range rule.Paths {
|
||||
// Convert our path patterns to envoy prefix match.
|
||||
// Strip trailing * for envoy prefix matching.
|
||||
prefix := strings.TrimSuffix(p, "*")
|
||||
routes = append(routes, envoyRoute{PathPrefix: prefix, Block: isBlock})
|
||||
}
|
||||
} else {
|
||||
routes = append(routes, envoyRoute{Block: isBlock})
|
||||
}
|
||||
|
||||
if len(rule.Domains) > 0 {
|
||||
for _, d := range rule.Domains {
|
||||
host := d.PunycodeString()
|
||||
domainRouteMap[host] = append(domainRouteMap[host], routes...)
|
||||
}
|
||||
} else {
|
||||
// No domain: applies to all, add to default host
|
||||
domainRouteMap["*"] = append(domainRouteMap["*"], routes...)
|
||||
}
|
||||
}
|
||||
|
||||
var hosts []virtualHost
|
||||
idx := 0
|
||||
|
||||
// Per-domain virtual hosts with path routes
|
||||
for domain, routes := range domainRouteMap {
|
||||
if domain == "*" {
|
||||
continue
|
||||
}
|
||||
// Add a catch-all route after path-specific routes.
|
||||
// The catch-all follows the default action.
|
||||
routes = append(routes, envoyRoute{Block: config.DefaultAction == ActionBlock})
|
||||
|
||||
hosts = append(hosts, virtualHost{
|
||||
Name: fmt.Sprintf("domain_%d", idx),
|
||||
DomainsStr: fmt.Sprintf("%q", domain),
|
||||
Routes: routes,
|
||||
})
|
||||
idx++
|
||||
}
|
||||
|
||||
// Default virtual host (catch-all for unmatched domains)
|
||||
defaultRoutes := domainRouteMap["*"]
|
||||
defaultRoutes = append(defaultRoutes, envoyRoute{Block: config.DefaultAction == ActionBlock})
|
||||
hosts = append(hosts, virtualHost{
|
||||
Name: "default",
|
||||
DomainsStr: `"*"`,
|
||||
Routes: defaultRoutes,
|
||||
})
|
||||
|
||||
return hosts
|
||||
}
|
||||
|
||||
// ruleTouchesProtocol returns true if the rule's protocol list includes any of the given protocols,
|
||||
// or if the protocol list is empty (matches all).
|
||||
func ruleTouchesProtocol(rule Rule, protos ...ProtoType) bool {
|
||||
if len(rule.Protocols) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, rp := range rule.Protocols {
|
||||
for _, p := range protos {
|
||||
if rp == p {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// indentSnippet prepends each line of the YAML snippet with the given number of spaces.
|
||||
// Returns empty string if snippet is empty.
|
||||
func indentSnippet(snippet string, spaces int) string {
|
||||
if snippet == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
prefix := make([]byte, spaces)
|
||||
for i := range prefix {
|
||||
prefix[i] = ' '
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
for i, line := range bytes.Split([]byte(snippet), []byte("\n")) {
|
||||
if i > 0 {
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
if len(line) > 0 {
|
||||
buf.Write(prefix)
|
||||
buf.Write(line)
|
||||
}
|
||||
}
|
||||
buf.WriteByte('\n')
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// ValidateSnippets checks that user-provided snippets are safe to inject
|
||||
// into the envoy config. Returns an error describing the first violation found.
|
||||
//
|
||||
// Validation rules:
|
||||
// - Each snippet must be valid YAML (prevents syntax-level injection)
|
||||
// - Snippets must not contain YAML document separators (--- or ...) that could
|
||||
// break out of the indentation context
|
||||
// - Snippets must only contain list items (starting with "- ") at the top level,
|
||||
// matching what envoy expects for filters and clusters
|
||||
func ValidateSnippets(snippets *EnvoySnippets) error {
|
||||
if snippets == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
fields := []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{"http_filters", snippets.HTTPFilters},
|
||||
{"network_filters", snippets.NetworkFilters},
|
||||
{"clusters", snippets.Clusters},
|
||||
}
|
||||
|
||||
for _, f := range fields {
|
||||
if f.value == "" {
|
||||
continue
|
||||
}
|
||||
if err := validateSnippetYAML(f.name, f.value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSnippetYAML(name, snippet string) error {
|
||||
// Check for YAML document markers that could break template structure.
|
||||
for _, line := range strings.Split(snippet, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "---" || trimmed == "..." {
|
||||
return fmt.Errorf("snippet %q: YAML document separators (--- or ...) are not allowed", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify it's valid YAML by checking it doesn't cause template execution issues.
|
||||
// We can't import yaml.v3 here without adding a dependency, so we do structural checks.
|
||||
|
||||
// Check for null bytes or control characters that could confuse YAML parsers.
|
||||
for i, b := range []byte(snippet) {
|
||||
if b == 0 {
|
||||
return fmt.Errorf("snippet %q: null byte at position %d", name, i)
|
||||
}
|
||||
if b < 0x09 || (b > 0x0D && b < 0x20 && b != 0x1B) {
|
||||
return fmt.Errorf("snippet %q: control character 0x%02x at position %d", name, b, i)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
88
client/inspect/envoy_forward.go
Normal file
88
client/inspect/envoy_forward.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
)
|
||||
|
||||
// PROXY protocol v2 constants (RFC 7239 / HAProxy spec)
|
||||
var proxyV2Signature = [12]byte{
|
||||
0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51,
|
||||
0x55, 0x49, 0x54, 0x0A,
|
||||
}
|
||||
|
||||
const (
|
||||
proxyV2VersionCommand = 0x21 // version 2, PROXY command
|
||||
proxyV2FamilyTCP4 = 0x11 // AF_INET, STREAM
|
||||
proxyV2FamilyTCP6 = 0x21 // AF_INET6, STREAM
|
||||
)
|
||||
|
||||
// forwardToEnvoy forwards a connection to the given envoy sidecar via PROXY protocol v2.
|
||||
// The caller provides the envoy manager snapshot to avoid accessing p.envoy without lock.
|
||||
func (p *Proxy) forwardToEnvoy(ctx context.Context, pconn *peekConn, dst netip.AddrPort, src SourceInfo, em *envoyManager) error {
|
||||
envoyAddr := em.ListenAddr()
|
||||
|
||||
conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", envoyAddr.String())
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial envoy at %s: %w", envoyAddr, err)
|
||||
}
|
||||
defer func() {
|
||||
if err := conn.Close(); err != nil {
|
||||
p.log.Debugf("close envoy conn: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := writeProxyV2Header(conn, src.IP, dst); err != nil {
|
||||
return fmt.Errorf("write PROXY v2 header: %w", err)
|
||||
}
|
||||
|
||||
p.log.Tracef("envoy: forwarded %s -> %s via PROXY v2", src.IP, dst)
|
||||
|
||||
return relay(ctx, pconn, conn)
|
||||
}
|
||||
|
||||
// writeProxyV2Header writes a PROXY protocol v2 header to w.
|
||||
// The header encodes the original source IP and the destination address:port.
|
||||
func writeProxyV2Header(w net.Conn, srcIP netip.Addr, dst netip.AddrPort) error {
|
||||
srcIP = srcIP.Unmap()
|
||||
dstIP := dst.Addr().Unmap()
|
||||
|
||||
var (
|
||||
family byte
|
||||
addrs []byte
|
||||
)
|
||||
|
||||
if srcIP.Is4() && dstIP.Is4() {
|
||||
family = proxyV2FamilyTCP4
|
||||
s4 := srcIP.As4()
|
||||
d4 := dstIP.As4()
|
||||
addrs = make([]byte, 12) // 4+4+2+2
|
||||
copy(addrs[0:4], s4[:])
|
||||
copy(addrs[4:8], d4[:])
|
||||
binary.BigEndian.PutUint16(addrs[8:10], 0) // src port unknown
|
||||
binary.BigEndian.PutUint16(addrs[10:12], dst.Port())
|
||||
} else {
|
||||
family = proxyV2FamilyTCP6
|
||||
s16 := srcIP.As16()
|
||||
d16 := dstIP.As16()
|
||||
addrs = make([]byte, 36) // 16+16+2+2
|
||||
copy(addrs[0:16], s16[:])
|
||||
copy(addrs[16:32], d16[:])
|
||||
binary.BigEndian.PutUint16(addrs[32:34], 0) // src port unknown
|
||||
binary.BigEndian.PutUint16(addrs[34:36], dst.Port())
|
||||
}
|
||||
|
||||
// Header: signature(12) + ver_cmd(1) + family(1) + len(2) + addrs
|
||||
header := make([]byte, 16+len(addrs))
|
||||
copy(header[0:12], proxyV2Signature[:])
|
||||
header[12] = proxyV2VersionCommand
|
||||
header[13] = family
|
||||
binary.BigEndian.PutUint16(header[14:16], uint16(len(addrs)))
|
||||
copy(header[16:], addrs)
|
||||
|
||||
_, err := w.Write(header)
|
||||
return err
|
||||
}
|
||||
13
client/inspect/envoy_signal.go
Normal file
13
client/inspect/envoy_signal.go
Normal file
@@ -0,0 +1,13 @@
|
||||
//go:build !windows
|
||||
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// signalReload sends SIGHUP to the envoy process to trigger config reload.
|
||||
func signalReload(p *os.Process) error {
|
||||
return p.Signal(syscall.SIGHUP)
|
||||
}
|
||||
13
client/inspect/envoy_signal_windows.go
Normal file
13
client/inspect/envoy_signal_windows.go
Normal file
@@ -0,0 +1,13 @@
|
||||
//go:build windows
|
||||
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// signalReload is not supported on Windows. Envoy must be restarted.
|
||||
func signalReload(_ *os.Process) error {
|
||||
return fmt.Errorf("envoy config reload via signal not supported on Windows")
|
||||
}
|
||||
229
client/inspect/external.go
Normal file
229
client/inspect/external.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
externalDialTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// handleExternal forwards the connection to an external proxy.
|
||||
// For TLS connections, it uses HTTP CONNECT to tunnel through the proxy.
|
||||
// For HTTP connections, it rewrites the request to use the proxy.
|
||||
func (p *Proxy) handleExternal(ctx context.Context, pconn *peekConn, dst netip.AddrPort) error {
|
||||
p.mu.RLock()
|
||||
proxyURL := p.config.ExternalURL
|
||||
p.mu.RUnlock()
|
||||
|
||||
if proxyURL == nil {
|
||||
return fmt.Errorf("external proxy URL not configured")
|
||||
}
|
||||
|
||||
switch proxyURL.Scheme {
|
||||
case "http", "https":
|
||||
return p.externalHTTPProxy(ctx, pconn, dst, proxyURL)
|
||||
case "socks5":
|
||||
return p.externalSOCKS5(ctx, pconn, dst, proxyURL)
|
||||
default:
|
||||
return fmt.Errorf("unsupported external proxy scheme: %s", proxyURL.Scheme)
|
||||
}
|
||||
}
|
||||
|
||||
// externalHTTPProxy tunnels through an HTTP proxy using CONNECT.
|
||||
func (p *Proxy) externalHTTPProxy(ctx context.Context, pconn *peekConn, dst netip.AddrPort, proxyURL *url.URL) error {
|
||||
proxyAddr := proxyURL.Host
|
||||
if _, _, err := net.SplitHostPort(proxyAddr); err != nil {
|
||||
proxyAddr = net.JoinHostPort(proxyAddr, "8080")
|
||||
}
|
||||
|
||||
proxyConn, err := (&net.Dialer{Timeout: externalDialTimeout}).DialContext(ctx, "tcp", proxyAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial external proxy %s: %w", proxyAddr, err)
|
||||
}
|
||||
defer func() {
|
||||
if err := proxyConn.Close(); err != nil {
|
||||
p.log.Debugf("close external proxy conn: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
connectReq := fmt.Sprintf("CONNECT %s HTTP/1.1\r\nHost: %s\r\n", dst.String(), dst.String())
|
||||
if proxyURL.User != nil {
|
||||
connectReq += "Proxy-Authorization: Basic " + basicAuth(proxyURL.User) + "\r\n"
|
||||
}
|
||||
connectReq += "\r\n"
|
||||
|
||||
if _, err := io.WriteString(proxyConn, connectReq); err != nil {
|
||||
return fmt.Errorf("send CONNECT to proxy: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.ReadResponse(bufio.NewReader(proxyConn), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read CONNECT response: %w", err)
|
||||
}
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
p.log.Debugf("close CONNECT resp body: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("proxy CONNECT failed: %s", resp.Status)
|
||||
}
|
||||
|
||||
return relay(ctx, pconn, proxyConn)
|
||||
}
|
||||
|
||||
// externalSOCKS5 tunnels through a SOCKS5 proxy.
|
||||
func (p *Proxy) externalSOCKS5(ctx context.Context, pconn *peekConn, dst netip.AddrPort, proxyURL *url.URL) error {
|
||||
proxyAddr := proxyURL.Host
|
||||
if _, _, err := net.SplitHostPort(proxyAddr); err != nil {
|
||||
proxyAddr = net.JoinHostPort(proxyAddr, "1080")
|
||||
}
|
||||
|
||||
proxyConn, err := (&net.Dialer{Timeout: externalDialTimeout}).DialContext(ctx, "tcp", proxyAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial SOCKS5 proxy %s: %w", proxyAddr, err)
|
||||
}
|
||||
defer func() {
|
||||
if err := proxyConn.Close(); err != nil {
|
||||
p.log.Debugf("close SOCKS5 proxy conn: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := socks5Handshake(proxyConn, dst, proxyURL.User); err != nil {
|
||||
return fmt.Errorf("SOCKS5 handshake: %w", err)
|
||||
}
|
||||
|
||||
return relay(ctx, pconn, proxyConn)
|
||||
}
|
||||
|
||||
// socks5Handshake performs the SOCKS5 handshake to connect through the proxy.
|
||||
func socks5Handshake(conn net.Conn, dst netip.AddrPort, userinfo *url.Userinfo) error {
|
||||
needAuth := userinfo != nil
|
||||
|
||||
// Greeting
|
||||
var methods []byte
|
||||
if needAuth {
|
||||
methods = []byte{0x00, 0x02} // no auth, username/password
|
||||
} else {
|
||||
methods = []byte{0x00} // no auth
|
||||
}
|
||||
greeting := append([]byte{0x05, byte(len(methods))}, methods...)
|
||||
if _, err := conn.Write(greeting); err != nil {
|
||||
return fmt.Errorf("send greeting: %w", err)
|
||||
}
|
||||
|
||||
// Server method selection
|
||||
var methodResp [2]byte
|
||||
if _, err := io.ReadFull(conn, methodResp[:]); err != nil {
|
||||
return fmt.Errorf("read method selection: %w", err)
|
||||
}
|
||||
if methodResp[0] != 0x05 {
|
||||
return fmt.Errorf("unexpected SOCKS version: %d", methodResp[0])
|
||||
}
|
||||
|
||||
// Handle authentication if selected
|
||||
if methodResp[1] == 0x02 {
|
||||
if err := socks5Auth(conn, userinfo); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if methodResp[1] != 0x00 {
|
||||
return fmt.Errorf("unsupported SOCKS5 auth method: %d", methodResp[1])
|
||||
}
|
||||
|
||||
// Connection request
|
||||
addr := dst.Addr()
|
||||
var addrBytes []byte
|
||||
if addr.Is4() {
|
||||
a4 := addr.As4()
|
||||
addrBytes = append([]byte{0x01}, a4[:]...) // IPv4
|
||||
} else {
|
||||
a16 := addr.As16()
|
||||
addrBytes = append([]byte{0x04}, a16[:]...) // IPv6
|
||||
}
|
||||
|
||||
port := dst.Port()
|
||||
connectReq := append([]byte{0x05, 0x01, 0x00}, addrBytes...)
|
||||
connectReq = append(connectReq, byte(port>>8), byte(port))
|
||||
|
||||
if _, err := conn.Write(connectReq); err != nil {
|
||||
return fmt.Errorf("send connect request: %w", err)
|
||||
}
|
||||
|
||||
// Read response (minimum 10 bytes for IPv4)
|
||||
var respHeader [4]byte
|
||||
if _, err := io.ReadFull(conn, respHeader[:]); err != nil {
|
||||
return fmt.Errorf("read connect response: %w", err)
|
||||
}
|
||||
if respHeader[1] != 0x00 {
|
||||
return fmt.Errorf("SOCKS5 connect failed: status %d", respHeader[1])
|
||||
}
|
||||
|
||||
// Skip bound address
|
||||
switch respHeader[3] {
|
||||
case 0x01: // IPv4
|
||||
var skip [4 + 2]byte
|
||||
if _, err := io.ReadFull(conn, skip[:]); err != nil {
|
||||
return fmt.Errorf("read SOCKS5 bound IPv4 address: %w", err)
|
||||
}
|
||||
case 0x04: // IPv6
|
||||
var skip [16 + 2]byte
|
||||
if _, err := io.ReadFull(conn, skip[:]); err != nil {
|
||||
return fmt.Errorf("read SOCKS5 bound IPv6 address: %w", err)
|
||||
}
|
||||
case 0x03: // Domain
|
||||
var dLen [1]byte
|
||||
if _, err := io.ReadFull(conn, dLen[:]); err != nil {
|
||||
return fmt.Errorf("read domain length: %w", err)
|
||||
}
|
||||
skip := make([]byte, int(dLen[0])+2)
|
||||
if _, err := io.ReadFull(conn, skip); err != nil {
|
||||
return fmt.Errorf("read SOCKS5 bound domain address: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func socks5Auth(conn net.Conn, userinfo *url.Userinfo) error {
|
||||
if userinfo == nil {
|
||||
return fmt.Errorf("SOCKS5 auth required but no credentials provided")
|
||||
}
|
||||
|
||||
user := userinfo.Username()
|
||||
pass, _ := userinfo.Password()
|
||||
|
||||
// Username/password auth (RFC 1929)
|
||||
auth := []byte{0x01, byte(len(user))}
|
||||
auth = append(auth, []byte(user)...)
|
||||
auth = append(auth, byte(len(pass)))
|
||||
auth = append(auth, []byte(pass)...)
|
||||
|
||||
if _, err := conn.Write(auth); err != nil {
|
||||
return fmt.Errorf("send auth: %w", err)
|
||||
}
|
||||
|
||||
var resp [2]byte
|
||||
if _, err := io.ReadFull(conn, resp[:]); err != nil {
|
||||
return fmt.Errorf("read auth response: %w", err)
|
||||
}
|
||||
if resp[1] != 0x00 {
|
||||
return fmt.Errorf("SOCKS5 auth failed: status %d", resp[1])
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func basicAuth(userinfo *url.Userinfo) string {
|
||||
user := userinfo.Username()
|
||||
pass, _ := userinfo.Password()
|
||||
return base64.StdEncoding.EncodeToString([]byte(user + ":" + pass))
|
||||
}
|
||||
532
client/inspect/http.go
Normal file
532
client/inspect/http.go
Normal file
@@ -0,0 +1,532 @@
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
headerUpgrade = "Upgrade"
|
||||
valueWebSocket = "websocket"
|
||||
)
|
||||
|
||||
// inspectHTTP runs the HTTP inspection pipeline on decrypted traffic.
|
||||
// It handles HTTP/1.1 (request-response loop), HTTP/2 (via Go stdlib reverse proxy),
|
||||
// and WebSocket upgrade detection.
|
||||
func (p *Proxy) inspectHTTP(ctx context.Context, client, remote net.Conn, dst netip.AddrPort, sni domain.Domain, src SourceInfo, proto string) error {
|
||||
if proto == "h2" {
|
||||
return p.inspectH2(ctx, client, remote, dst, sni, src)
|
||||
}
|
||||
return p.inspectH1(ctx, client, remote, dst, sni, src)
|
||||
}
|
||||
|
||||
// inspectH1 handles HTTP/1.1 request-response inspection in a loop.
|
||||
func (p *Proxy) inspectH1(ctx context.Context, client, remote net.Conn, dst netip.AddrPort, sni domain.Domain, src SourceInfo) error {
|
||||
clientReader := bufio.NewReader(client)
|
||||
remoteReader := bufio.NewReader(remote)
|
||||
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// Set idle timeout between requests to prevent connection hogging.
|
||||
if err := client.SetReadDeadline(time.Now().Add(idleTimeout)); err != nil {
|
||||
return fmt.Errorf("set idle deadline: %w", err)
|
||||
}
|
||||
req, err := http.ReadRequest(clientReader)
|
||||
if err != nil {
|
||||
if isClosedErr(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("read HTTP request: %w", err)
|
||||
}
|
||||
if err := client.SetReadDeadline(time.Time{}); err != nil {
|
||||
return fmt.Errorf("clear read deadline: %w", err)
|
||||
}
|
||||
|
||||
// Re-evaluate rules based on Host header if SNI was empty
|
||||
host := hostFromRequest(req, sni)
|
||||
|
||||
// Domain fronting: Host header doesn't match TLS SNI
|
||||
if isDomainFronting(req, sni) {
|
||||
p.log.Debugf("domain fronting detected: SNI=%s Host=%s", sni.PunycodeString(), host.PunycodeString())
|
||||
writeBlockResponse(client, req, host)
|
||||
return ErrBlocked
|
||||
}
|
||||
|
||||
proto := ProtoHTTP
|
||||
if isWebSocketUpgrade(req) {
|
||||
proto = ProtoWebSocket
|
||||
}
|
||||
action := p.evaluateAction(src.IP, host, dst, proto, req.URL.Path)
|
||||
if action == ActionBlock {
|
||||
p.log.Debugf("block: HTTP %s %s (host=%s)", req.Method, req.URL.Path, host.PunycodeString())
|
||||
writeBlockResponse(client, req, host)
|
||||
return ErrBlocked
|
||||
}
|
||||
p.log.Tracef("allow: HTTP %s %s (host=%s, action=%s)", req.Method, req.URL.Path, host.PunycodeString(), action)
|
||||
|
||||
// ICAP REQMOD: send request for inspection.
|
||||
// Snapshot ICAP client under lock to avoid use-after-close races.
|
||||
p.mu.RLock()
|
||||
icap := p.icap
|
||||
p.mu.RUnlock()
|
||||
if icap != nil {
|
||||
modified, err := icap.ReqMod(req)
|
||||
if err != nil {
|
||||
p.log.Debugf("ICAP REQMOD error for %s: %v", host.PunycodeString(), err)
|
||||
// Fail-closed: block on ICAP error
|
||||
writeBlockResponse(client, req, host)
|
||||
return fmt.Errorf("ICAP REQMOD: %w", err)
|
||||
}
|
||||
req = modified
|
||||
}
|
||||
|
||||
if isWebSocketUpgrade(req) {
|
||||
return p.handleWebSocket(ctx, req, client, clientReader, remote, remoteReader)
|
||||
}
|
||||
|
||||
removeHopByHopHeaders(req.Header)
|
||||
|
||||
if err := req.Write(remote); err != nil {
|
||||
return fmt.Errorf("forward request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.ReadResponse(remoteReader, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read HTTP response: %w", err)
|
||||
}
|
||||
|
||||
// ICAP RESPMOD: send response for inspection
|
||||
if icap != nil {
|
||||
modified, err := icap.RespMod(req, resp)
|
||||
if err != nil {
|
||||
p.log.Debugf("ICAP RESPMOD error for %s: %v", host.PunycodeString(), err)
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
p.log.Debugf("close resp body: %v", err)
|
||||
}
|
||||
writeBlockResponse(client, req, host)
|
||||
return fmt.Errorf("ICAP RESPMOD: %w", err)
|
||||
}
|
||||
resp = modified
|
||||
}
|
||||
|
||||
removeHopByHopHeaders(resp.Header)
|
||||
|
||||
if err := resp.Write(client); err != nil {
|
||||
if closeErr := resp.Body.Close(); closeErr != nil {
|
||||
p.log.Debugf("close resp body: %v", closeErr)
|
||||
}
|
||||
return fmt.Errorf("forward response: %w", err)
|
||||
}
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
p.log.Debugf("close resp body: %v", err)
|
||||
}
|
||||
|
||||
// Connection: close means we're done
|
||||
if resp.Close || req.Close {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// inspectH2 proxies HTTP/2 traffic using Go's http stack.
|
||||
// Client and remote are already-established TLS connections with h2 negotiated.
|
||||
func (p *Proxy) inspectH2(ctx context.Context, client, remote net.Conn, dst netip.AddrPort, sni domain.Domain, src SourceInfo) error {
|
||||
// For h2 MITM inspection, we use a local http.Server reading from the client
|
||||
// connection and an http.Transport writing to the remote connection.
|
||||
//
|
||||
// The transport is configured to use the existing TLS connection to the
|
||||
// real server. The handler inspects each request/response pair.
|
||||
|
||||
transport := &http.Transport{
|
||||
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
|
||||
return remote, nil
|
||||
},
|
||||
DialTLSContext: func(_ context.Context, _, _ string) (net.Conn, error) {
|
||||
return remote, nil
|
||||
},
|
||||
ForceAttemptHTTP2: true,
|
||||
}
|
||||
|
||||
handler := &h2InspectionHandler{
|
||||
proxy: p,
|
||||
transport: transport,
|
||||
dst: dst,
|
||||
sni: sni,
|
||||
src: src,
|
||||
}
|
||||
|
||||
server := &http.Server{
|
||||
Handler: handler,
|
||||
}
|
||||
|
||||
// Serve the single client connection.
|
||||
// ServeConn blocks until the connection is done.
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
// http.Server doesn't have a direct ServeConn for h2,
|
||||
// so we use Serve with a single-connection listener.
|
||||
ln := &singleConnListener{conn: client}
|
||||
errCh <- server.Serve(ln)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if err := server.Close(); err != nil {
|
||||
p.log.Debugf("close h2 server: %v", err)
|
||||
}
|
||||
return ctx.Err()
|
||||
case err := <-errCh:
|
||||
if err == http.ErrServerClosed {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// h2InspectionHandler inspects each HTTP/2 request/response pair.
|
||||
type h2InspectionHandler struct {
|
||||
proxy *Proxy
|
||||
transport http.RoundTripper
|
||||
dst netip.AddrPort
|
||||
sni domain.Domain
|
||||
src SourceInfo
|
||||
}
|
||||
|
||||
func (h *h2InspectionHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
host := hostFromRequest(req, h.sni)
|
||||
|
||||
if isDomainFronting(req, h.sni) {
|
||||
h.proxy.log.Debugf("domain fronting detected: SNI=%s Host=%s", h.sni.PunycodeString(), host.PunycodeString())
|
||||
writeBlockPage(w, host)
|
||||
return
|
||||
}
|
||||
|
||||
action := h.proxy.evaluateAction(h.src.IP, host, h.dst, ProtoH2, req.URL.Path)
|
||||
if action == ActionBlock {
|
||||
h.proxy.log.Debugf("block: H2 %s %s (host=%s)", req.Method, req.URL.Path, host.PunycodeString())
|
||||
writeBlockPage(w, host)
|
||||
return
|
||||
}
|
||||
|
||||
// ICAP REQMOD
|
||||
if h.proxy.icap != nil {
|
||||
modified, err := h.proxy.icap.ReqMod(req)
|
||||
if err != nil {
|
||||
h.proxy.log.Debugf("ICAP REQMOD error for %s: %v", host.PunycodeString(), err)
|
||||
writeBlockPage(w, host)
|
||||
return
|
||||
}
|
||||
req = modified
|
||||
}
|
||||
|
||||
// Forward to upstream
|
||||
req.URL.Scheme = "https"
|
||||
req.URL.Host = h.sni.PunycodeString()
|
||||
req.RequestURI = ""
|
||||
|
||||
resp, err := h.transport.RoundTrip(req)
|
||||
if err != nil {
|
||||
h.proxy.log.Debugf("h2 upstream error for %s: %v", host.PunycodeString(), err)
|
||||
http.Error(w, "Bad Gateway", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
h.proxy.log.Debugf("close h2 resp body: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// ICAP RESPMOD
|
||||
if h.proxy.icap != nil {
|
||||
modified, err := h.proxy.icap.RespMod(req, resp)
|
||||
if err != nil {
|
||||
h.proxy.log.Debugf("ICAP RESPMOD error for %s: %v", host.PunycodeString(), err)
|
||||
writeBlockPage(w, host)
|
||||
return
|
||||
}
|
||||
resp = modified
|
||||
}
|
||||
|
||||
// Copy response headers and body
|
||||
for k, vals := range resp.Header {
|
||||
for _, v := range vals {
|
||||
w.Header().Add(k, v)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
if _, err := io.Copy(w, resp.Body); err != nil {
|
||||
h.proxy.log.Debugf("h2 response copy error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// handleWebSocket completes the WebSocket upgrade and relays frames bidirectionally.
|
||||
func (p *Proxy) handleWebSocket(ctx context.Context, req *http.Request, client io.ReadWriter, clientReader *bufio.Reader, remote io.ReadWriter, remoteReader *bufio.Reader) error {
|
||||
if err := req.Write(remote); err != nil {
|
||||
return fmt.Errorf("forward WebSocket upgrade: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.ReadResponse(remoteReader, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read WebSocket upgrade response: %w", err)
|
||||
}
|
||||
|
||||
if err := resp.Write(client); err != nil {
|
||||
if closeErr := resp.Body.Close(); closeErr != nil {
|
||||
p.log.Debugf("close ws resp body: %v", closeErr)
|
||||
}
|
||||
return fmt.Errorf("forward WebSocket upgrade response: %w", err)
|
||||
}
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
p.log.Debugf("close ws resp body: %v", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusSwitchingProtocols {
|
||||
return fmt.Errorf("WebSocket upgrade rejected: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
p.log.Tracef("allow: WebSocket upgrade for %s", req.Host)
|
||||
|
||||
// Relay WebSocket frames bidirectionally.
|
||||
// clientReader/remoteReader may have buffered data.
|
||||
clientConn := mergeReadWriter(clientReader, client)
|
||||
remoteConn := mergeReadWriter(remoteReader, remote)
|
||||
|
||||
return relayRW(ctx, clientConn, remoteConn)
|
||||
}
|
||||
|
||||
// hostFromRequest extracts a domain.Domain from the HTTP request Host header,
|
||||
// falling back to the SNI if Host is empty or an IP.
|
||||
func hostFromRequest(req *http.Request, fallback domain.Domain) domain.Domain {
|
||||
host := req.Host
|
||||
if host == "" {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// Strip port if present
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = h
|
||||
}
|
||||
|
||||
// If it's an IP address, use the SNI fallback
|
||||
if _, err := netip.ParseAddr(host); err == nil {
|
||||
return fallback
|
||||
}
|
||||
|
||||
d, err := domain.FromString(host)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// isDomainFronting detects domain fronting: the Host header doesn't match the
|
||||
// SNI used during the TLS handshake. Only meaningful when SNI is non-empty
|
||||
// (i.e., we're in MITM mode and know the original SNI).
|
||||
func isDomainFronting(req *http.Request, sni domain.Domain) bool {
|
||||
if sni == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
host := hostFromRequest(req, "")
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
// Host should match SNI or be a subdomain of SNI
|
||||
if host == sni {
|
||||
return false
|
||||
}
|
||||
|
||||
// Allow www.example.com when SNI is example.com
|
||||
sniStr := sni.PunycodeString()
|
||||
hostStr := host.PunycodeString()
|
||||
if strings.HasSuffix(hostStr, "."+sniStr) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func isWebSocketUpgrade(req *http.Request) bool {
|
||||
return strings.EqualFold(req.Header.Get(headerUpgrade), valueWebSocket)
|
||||
}
|
||||
|
||||
// writeBlockPage writes the styled HTML block page to an http.ResponseWriter (H2 path).
|
||||
func writeBlockPage(w http.ResponseWriter, host domain.Domain) {
|
||||
hostname := host.PunycodeString()
|
||||
body := fmt.Sprintf(blockPageHTML, hostname, hostname)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
io.WriteString(w, body)
|
||||
}
|
||||
|
||||
func writeBlockResponse(w io.Writer, _ *http.Request, host domain.Domain) {
|
||||
hostname := host.PunycodeString()
|
||||
body := fmt.Sprintf(blockPageHTML, hostname, hostname)
|
||||
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusForbidden,
|
||||
ProtoMajor: 1,
|
||||
ProtoMinor: 1,
|
||||
Header: make(http.Header),
|
||||
ContentLength: int64(len(body)),
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
}
|
||||
resp.Header.Set("Content-Type", "text/html; charset=utf-8")
|
||||
resp.Header.Set("Connection", "close")
|
||||
resp.Header.Set("Cache-Control", "no-store")
|
||||
_ = resp.Write(w)
|
||||
}
|
||||
|
||||
// blockPageHTML is the self-contained HTML block page.
|
||||
// Uses NetBird dark theme with orange accent. Two format args: page title domain, displayed domain.
|
||||
const blockPageHTML = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Blocked - %s</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{background:#181a1d;color:#d1d5db;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;min-height:100vh;display:flex;align-items:center;justify-content:center}
|
||||
.c{text-align:center;max-width:460px;padding:2rem}
|
||||
.shield{width:56px;height:56px;margin:0 auto 1.5rem;border-radius:16px;background:#2b2f33;display:flex;align-items:center;justify-content:center}
|
||||
.shield svg{width:28px;height:28px;color:#f68330}
|
||||
.code{font-size:.8rem;font-weight:500;color:#f68330;font-family:ui-monospace,monospace;letter-spacing:.05em;margin-bottom:.5rem}
|
||||
h1{font-size:1.5rem;font-weight:600;color:#f4f4f5;margin-bottom:.5rem}
|
||||
p{font-size:.95rem;line-height:1.5;color:#9ca3af;margin-bottom:1.75rem}
|
||||
.domain{display:inline-block;background:#25282d;border:1px solid #32363d;border-radius:6px;padding:.15rem .5rem;font-family:ui-monospace,monospace;font-size:.85rem;color:#d1d5db}
|
||||
.footer{font-size:.7rem;color:#6b7280;margin-top:2rem;letter-spacing:.03em}
|
||||
.footer a{color:#6b7280;text-decoration:none}
|
||||
.footer a:hover{color:#9ca3af}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="c">
|
||||
<div class="shield"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m0-10.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.75c0 5.592 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751A11.96 11.96 0 0 0 12 3.714Z"/></svg></div>
|
||||
<div class="code">403 BLOCKED</div>
|
||||
<h1>Access Denied</h1>
|
||||
<p>This connection to <span class="domain">%s</span> has been blocked by your organization's network policy.</p>
|
||||
<div class="footer">Protected by <a href="https://netbird.io" target="_blank" rel="noopener">NetBird</a></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// singleConnListener is a net.Listener that yields a single connection.
|
||||
type singleConnListener struct {
|
||||
conn net.Conn
|
||||
once sync.Once
|
||||
ch chan struct{}
|
||||
}
|
||||
|
||||
func (l *singleConnListener) Accept() (net.Conn, error) {
|
||||
var accepted bool
|
||||
l.once.Do(func() {
|
||||
l.ch = make(chan struct{})
|
||||
accepted = true
|
||||
})
|
||||
if accepted {
|
||||
return l.conn, nil
|
||||
}
|
||||
// Block until Close
|
||||
<-l.ch
|
||||
return nil, net.ErrClosed
|
||||
}
|
||||
|
||||
func (l *singleConnListener) Close() error {
|
||||
l.once.Do(func() {
|
||||
l.ch = make(chan struct{})
|
||||
})
|
||||
select {
|
||||
case <-l.ch:
|
||||
default:
|
||||
close(l.ch)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *singleConnListener) Addr() net.Addr {
|
||||
return l.conn.LocalAddr()
|
||||
}
|
||||
|
||||
type readWriter struct {
|
||||
io.Reader
|
||||
io.Writer
|
||||
}
|
||||
|
||||
func mergeReadWriter(r io.Reader, w io.Writer) io.ReadWriter {
|
||||
return &readWriter{Reader: r, Writer: w}
|
||||
}
|
||||
|
||||
// relayRW copies data bidirectionally between two ReadWriters.
|
||||
func relayRW(ctx context.Context, a, b io.ReadWriter) error {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
errCh := make(chan error, 2)
|
||||
|
||||
go func() {
|
||||
_, err := io.Copy(b, a)
|
||||
cancel()
|
||||
errCh <- err
|
||||
}()
|
||||
|
||||
go func() {
|
||||
_, err := io.Copy(a, b)
|
||||
cancel()
|
||||
errCh <- err
|
||||
}()
|
||||
|
||||
var firstErr error
|
||||
for range 2 {
|
||||
if err := <-errCh; err != nil && firstErr == nil {
|
||||
if !isClosedErr(err) {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// hopByHopHeaders are HTTP/1.1 headers that apply to a single connection
|
||||
// and must not be forwarded by a proxy (RFC 7230, Section 6.1).
|
||||
var hopByHopHeaders = []string{
|
||||
"Connection",
|
||||
"Keep-Alive",
|
||||
"Proxy-Authenticate",
|
||||
"Proxy-Authorization",
|
||||
"TE",
|
||||
"Trailers",
|
||||
"Transfer-Encoding",
|
||||
"Upgrade",
|
||||
}
|
||||
|
||||
// removeHopByHopHeaders strips hop-by-hop headers from h.
|
||||
// Also removes headers listed in the Connection header value.
|
||||
func removeHopByHopHeaders(h http.Header) {
|
||||
// First, remove any headers named in the Connection header
|
||||
for _, connHeader := range h["Connection"] {
|
||||
for _, name := range strings.Split(connHeader, ",") {
|
||||
h.Del(strings.TrimSpace(name))
|
||||
}
|
||||
}
|
||||
|
||||
for _, name := range hopByHopHeaders {
|
||||
h.Del(name)
|
||||
}
|
||||
}
|
||||
479
client/inspect/icap.go
Normal file
479
client/inspect/icap.go
Normal file
@@ -0,0 +1,479 @@
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/textproto"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
icapVersion = "ICAP/1.0"
|
||||
icapDefaultPort = "1344"
|
||||
icapConnTimeout = 30 * time.Second
|
||||
icapRWTimeout = 60 * time.Second
|
||||
icapMaxPoolSize = 8
|
||||
icapIdleTimeout = 60 * time.Second
|
||||
icapMaxRespSize = 4 * 1024 * 1024 // 4 MB
|
||||
)
|
||||
|
||||
// ICAPClient implements an ICAP (RFC 3507) client with persistent connection pooling.
|
||||
type ICAPClient struct {
|
||||
reqModURL *url.URL
|
||||
respModURL *url.URL
|
||||
pool chan *icapConn
|
||||
mu sync.Mutex
|
||||
log *log.Entry
|
||||
maxPool int
|
||||
}
|
||||
|
||||
type icapConn struct {
|
||||
conn net.Conn
|
||||
reader *bufio.Reader
|
||||
lastUse time.Time
|
||||
}
|
||||
|
||||
// NewICAPClient creates an ICAP client. Either or both URLs may be nil
|
||||
// to disable that mode.
|
||||
func NewICAPClient(logger *log.Entry, cfg *ICAPConfig) *ICAPClient {
|
||||
maxPool := cfg.MaxConnections
|
||||
if maxPool <= 0 {
|
||||
maxPool = icapMaxPoolSize
|
||||
}
|
||||
|
||||
return &ICAPClient{
|
||||
reqModURL: cfg.ReqModURL,
|
||||
respModURL: cfg.RespModURL,
|
||||
pool: make(chan *icapConn, maxPool),
|
||||
log: logger,
|
||||
maxPool: maxPool,
|
||||
}
|
||||
}
|
||||
|
||||
// ReqMod sends an HTTP request to the ICAP REQMOD service for inspection.
|
||||
// Returns the (possibly modified) request, or the original if ICAP returns 204.
|
||||
// Returns nil, nil if REQMOD is not configured.
|
||||
func (c *ICAPClient) ReqMod(req *http.Request) (*http.Request, error) {
|
||||
if c.reqModURL == nil {
|
||||
return req, nil
|
||||
}
|
||||
|
||||
var reqBuf bytes.Buffer
|
||||
if err := req.Write(&reqBuf); err != nil {
|
||||
return nil, fmt.Errorf("serialize request: %w", err)
|
||||
}
|
||||
|
||||
respBody, err := c.send("REQMOD", c.reqModURL, reqBuf.Bytes(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if respBody == nil {
|
||||
return req, nil
|
||||
}
|
||||
|
||||
modified, err := http.ReadRequest(bufio.NewReader(bytes.NewReader(respBody)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse ICAP modified request: %w", err)
|
||||
}
|
||||
return modified, nil
|
||||
}
|
||||
|
||||
// RespMod sends an HTTP response to the ICAP RESPMOD service for inspection.
|
||||
// Returns the (possibly modified) response, or the original if ICAP returns 204.
|
||||
// Returns nil, nil if RESPMOD is not configured.
|
||||
func (c *ICAPClient) RespMod(req *http.Request, resp *http.Response) (*http.Response, error) {
|
||||
if c.respModURL == nil {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
var reqBuf bytes.Buffer
|
||||
if err := req.Write(&reqBuf); err != nil {
|
||||
return nil, fmt.Errorf("serialize request: %w", err)
|
||||
}
|
||||
|
||||
var respBuf bytes.Buffer
|
||||
if err := resp.Write(&respBuf); err != nil {
|
||||
return nil, fmt.Errorf("serialize response: %w", err)
|
||||
}
|
||||
|
||||
respBody, err := c.send("RESPMOD", c.respModURL, reqBuf.Bytes(), respBuf.Bytes())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if respBody == nil {
|
||||
// 204 No Content: ICAP server didn't modify the response.
|
||||
// Reconstruct from the buffered copy since resp.Body was consumed by Write.
|
||||
reconstructed, err := http.ReadResponse(bufio.NewReader(bytes.NewReader(respBuf.Bytes())), req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reconstruct response after ICAP 204: %w", err)
|
||||
}
|
||||
return reconstructed, nil
|
||||
}
|
||||
|
||||
modified, err := http.ReadResponse(bufio.NewReader(bytes.NewReader(respBody)), req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse ICAP modified response: %w", err)
|
||||
}
|
||||
return modified, nil
|
||||
}
|
||||
|
||||
// Close drains and closes all pooled connections.
|
||||
func (c *ICAPClient) Close() {
|
||||
close(c.pool)
|
||||
for ic := range c.pool {
|
||||
if err := ic.conn.Close(); err != nil {
|
||||
c.log.Debugf("close ICAP connection: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// send executes an ICAP request and returns the encapsulated body from the response.
|
||||
// Returns nil body for 204 No Content (no modification).
|
||||
// Retries once on stale pooled connection (EOF on read).
|
||||
func (c *ICAPClient) send(method string, serviceURL *url.URL, reqData, respData []byte) ([]byte, error) {
|
||||
statusCode, headers, body, err := c.trySend(method, serviceURL, reqData, respData)
|
||||
if err != nil && isStaleConnErr(err) {
|
||||
// Retry once with a fresh connection (stale pool entry).
|
||||
c.log.Debugf("ICAP %s: retrying after stale connection: %v", method, err)
|
||||
statusCode, headers, body, err = c.trySend(method, serviceURL, reqData, respData)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch statusCode {
|
||||
case 204:
|
||||
return nil, nil
|
||||
case 200:
|
||||
return body, nil
|
||||
default:
|
||||
c.log.Debugf("ICAP %s returned status %d, headers: %v", method, statusCode, headers)
|
||||
return nil, fmt.Errorf("ICAP %s: status %d", method, statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ICAPClient) trySend(method string, serviceURL *url.URL, reqData, respData []byte) (int, textproto.MIMEHeader, []byte, error) {
|
||||
ic, err := c.getConn(serviceURL)
|
||||
if err != nil {
|
||||
return 0, nil, nil, fmt.Errorf("get ICAP connection: %w", err)
|
||||
}
|
||||
|
||||
if err := c.writeRequest(ic, method, serviceURL, reqData, respData); err != nil {
|
||||
if closeErr := ic.conn.Close(); closeErr != nil {
|
||||
c.log.Debugf("close ICAP conn after write error: %v", closeErr)
|
||||
}
|
||||
return 0, nil, nil, fmt.Errorf("write ICAP %s: %w", method, err)
|
||||
}
|
||||
|
||||
statusCode, headers, body, err := c.readResponse(ic)
|
||||
if err != nil {
|
||||
if closeErr := ic.conn.Close(); closeErr != nil {
|
||||
c.log.Debugf("close ICAP conn after read error: %v", closeErr)
|
||||
}
|
||||
return 0, nil, nil, fmt.Errorf("read ICAP response: %w", err)
|
||||
}
|
||||
|
||||
c.putConn(ic)
|
||||
return statusCode, headers, body, nil
|
||||
}
|
||||
|
||||
func isStaleConnErr(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
s := err.Error()
|
||||
return strings.Contains(s, "EOF") || strings.Contains(s, "broken pipe") || strings.Contains(s, "connection reset")
|
||||
}
|
||||
|
||||
func (c *ICAPClient) writeRequest(ic *icapConn, method string, serviceURL *url.URL, reqData, respData []byte) error {
|
||||
if err := ic.conn.SetWriteDeadline(time.Now().Add(icapRWTimeout)); err != nil {
|
||||
return fmt.Errorf("set write deadline: %w", err)
|
||||
}
|
||||
|
||||
// For RESPMOD, split the serialized HTTP response into headers and body.
|
||||
// The body must be sent chunked per RFC 3507.
|
||||
var respHdr, respBody []byte
|
||||
if respData != nil {
|
||||
if idx := bytes.Index(respData, []byte("\r\n\r\n")); idx >= 0 {
|
||||
respHdr = respData[:idx+4] // include the \r\n\r\n separator
|
||||
respBody = respData[idx+4:]
|
||||
} else {
|
||||
respHdr = respData
|
||||
}
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
// Request line
|
||||
fmt.Fprintf(&buf, "%s %s %s\r\n", method, serviceURL.String(), icapVersion)
|
||||
|
||||
// Headers
|
||||
host := serviceURL.Host
|
||||
fmt.Fprintf(&buf, "Host: %s\r\n", host)
|
||||
fmt.Fprintf(&buf, "Connection: keep-alive\r\n")
|
||||
fmt.Fprintf(&buf, "Allow: 204\r\n")
|
||||
|
||||
// Build Encapsulated header
|
||||
offset := 0
|
||||
var encapParts []string
|
||||
if reqData != nil {
|
||||
encapParts = append(encapParts, fmt.Sprintf("req-hdr=%d", offset))
|
||||
offset += len(reqData)
|
||||
}
|
||||
if respHdr != nil {
|
||||
encapParts = append(encapParts, fmt.Sprintf("res-hdr=%d", offset))
|
||||
offset += len(respHdr)
|
||||
}
|
||||
if len(respBody) > 0 {
|
||||
encapParts = append(encapParts, fmt.Sprintf("res-body=%d", offset))
|
||||
} else {
|
||||
encapParts = append(encapParts, fmt.Sprintf("null-body=%d", offset))
|
||||
}
|
||||
fmt.Fprintf(&buf, "Encapsulated: %s\r\n", strings.Join(encapParts, ", "))
|
||||
fmt.Fprintf(&buf, "\r\n")
|
||||
|
||||
// Encapsulated sections
|
||||
if reqData != nil {
|
||||
buf.Write(reqData)
|
||||
}
|
||||
if respHdr != nil {
|
||||
buf.Write(respHdr)
|
||||
}
|
||||
// Body in chunked encoding (only when there is an actual body section).
|
||||
// Per RFC 3507 Section 4.4.1, null-body must not include any entity data.
|
||||
if len(respBody) > 0 {
|
||||
fmt.Fprintf(&buf, "%x\r\n", len(respBody))
|
||||
buf.Write(respBody)
|
||||
buf.WriteString("\r\n")
|
||||
buf.WriteString("0\r\n\r\n")
|
||||
}
|
||||
|
||||
_, err := ic.conn.Write(buf.Bytes())
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *ICAPClient) readResponse(ic *icapConn) (int, textproto.MIMEHeader, []byte, error) {
|
||||
if err := ic.conn.SetReadDeadline(time.Now().Add(icapRWTimeout)); err != nil {
|
||||
return 0, nil, nil, fmt.Errorf("set read deadline: %w", err)
|
||||
}
|
||||
|
||||
tp := textproto.NewReader(ic.reader)
|
||||
|
||||
// Status line: "ICAP/1.0 200 OK"
|
||||
statusLine, err := tp.ReadLine()
|
||||
if err != nil {
|
||||
return 0, nil, nil, fmt.Errorf("read status line: %w", err)
|
||||
}
|
||||
|
||||
statusCode, err := parseICAPStatus(statusLine)
|
||||
if err != nil {
|
||||
return 0, nil, nil, err
|
||||
}
|
||||
|
||||
// Headers
|
||||
headers, err := tp.ReadMIMEHeader()
|
||||
if err != nil {
|
||||
return statusCode, nil, nil, fmt.Errorf("read ICAP headers: %w", err)
|
||||
}
|
||||
|
||||
if statusCode == 204 {
|
||||
return statusCode, headers, nil, nil
|
||||
}
|
||||
|
||||
// Read encapsulated body based on Encapsulated header
|
||||
body, err := c.readEncapsulatedBody(ic.reader, headers)
|
||||
if err != nil {
|
||||
return statusCode, headers, nil, fmt.Errorf("read encapsulated body: %w", err)
|
||||
}
|
||||
|
||||
return statusCode, headers, body, nil
|
||||
}
|
||||
|
||||
func (c *ICAPClient) readEncapsulatedBody(r *bufio.Reader, headers textproto.MIMEHeader) ([]byte, error) {
|
||||
encap := headers.Get("Encapsulated")
|
||||
if encap == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Find the body offset from the Encapsulated header.
|
||||
// The last section with a non-zero offset is the body.
|
||||
// Read everything from the reader as the encapsulated content.
|
||||
var totalSize int
|
||||
parts := strings.Split(encap, ",")
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
eqIdx := strings.Index(part, "=")
|
||||
if eqIdx < 0 {
|
||||
continue
|
||||
}
|
||||
offset, err := strconv.Atoi(strings.TrimSpace(part[eqIdx+1:]))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if offset > totalSize {
|
||||
totalSize = offset
|
||||
}
|
||||
}
|
||||
|
||||
// Read all available encapsulated data (headers + body)
|
||||
// The body section uses chunked encoding per RFC 3507
|
||||
var buf bytes.Buffer
|
||||
if totalSize > 0 {
|
||||
// Read the header sections (everything before the body offset)
|
||||
headerBytes := make([]byte, totalSize)
|
||||
if _, err := io.ReadFull(r, headerBytes); err != nil {
|
||||
return nil, fmt.Errorf("read encapsulated headers: %w", err)
|
||||
}
|
||||
buf.Write(headerBytes)
|
||||
}
|
||||
|
||||
// Read chunked body
|
||||
chunked := newChunkedReader(r)
|
||||
body, err := io.ReadAll(io.LimitReader(chunked, icapMaxRespSize))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read chunked body: %w", err)
|
||||
}
|
||||
buf.Write(body)
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func (c *ICAPClient) getConn(serviceURL *url.URL) (*icapConn, error) {
|
||||
// Try to get a pooled connection
|
||||
for {
|
||||
select {
|
||||
case ic := <-c.pool:
|
||||
if time.Since(ic.lastUse) > icapIdleTimeout {
|
||||
if err := ic.conn.Close(); err != nil {
|
||||
c.log.Debugf("close idle ICAP connection: %v", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
return ic, nil
|
||||
default:
|
||||
return c.dialConn(serviceURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ICAPClient) putConn(ic *icapConn) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
ic.lastUse = time.Now()
|
||||
select {
|
||||
case c.pool <- ic:
|
||||
default:
|
||||
// Pool full, close connection.
|
||||
if err := ic.conn.Close(); err != nil {
|
||||
c.log.Debugf("close excess ICAP connection: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ICAPClient) dialConn(serviceURL *url.URL) (*icapConn, error) {
|
||||
host := serviceURL.Host
|
||||
if _, _, err := net.SplitHostPort(host); err != nil {
|
||||
host = net.JoinHostPort(host, icapDefaultPort)
|
||||
}
|
||||
|
||||
conn, err := net.DialTimeout("tcp", host, icapConnTimeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial ICAP %s: %w", host, err)
|
||||
}
|
||||
|
||||
return &icapConn{
|
||||
conn: conn,
|
||||
reader: bufio.NewReader(conn),
|
||||
lastUse: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseICAPStatus(line string) (int, error) {
|
||||
// "ICAP/1.0 200 OK"
|
||||
parts := strings.SplitN(line, " ", 3)
|
||||
if len(parts) < 2 {
|
||||
return 0, fmt.Errorf("malformed ICAP status line: %q", line)
|
||||
}
|
||||
code, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse ICAP status code %q: %w", parts[1], err)
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// chunkedReader reads ICAP chunked encoding (same as HTTP chunked, terminated by "0\r\n\r\n").
|
||||
type chunkedReader struct {
|
||||
r *bufio.Reader
|
||||
remaining int
|
||||
done bool
|
||||
}
|
||||
|
||||
func newChunkedReader(r *bufio.Reader) *chunkedReader {
|
||||
return &chunkedReader{r: r}
|
||||
}
|
||||
|
||||
func (cr *chunkedReader) Read(p []byte) (int, error) {
|
||||
if cr.done {
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
if cr.remaining == 0 {
|
||||
// Read chunk size line
|
||||
line, err := cr.r.ReadString('\n')
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
// Strip any chunk extensions
|
||||
if idx := strings.Index(line, ";"); idx >= 0 {
|
||||
line = line[:idx]
|
||||
}
|
||||
|
||||
size, err := strconv.ParseInt(line, 16, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse chunk size %q: %w", line, err)
|
||||
}
|
||||
|
||||
if size == 0 {
|
||||
cr.done = true
|
||||
// Consume trailing \r\n
|
||||
_, _ = cr.r.ReadString('\n')
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
if size < 0 || size > icapMaxRespSize {
|
||||
return 0, fmt.Errorf("chunk size %d out of range (max %d)", size, icapMaxRespSize)
|
||||
}
|
||||
|
||||
cr.remaining = int(size)
|
||||
}
|
||||
|
||||
toRead := len(p)
|
||||
if toRead > cr.remaining {
|
||||
toRead = cr.remaining
|
||||
}
|
||||
|
||||
n, err := cr.r.Read(p[:toRead])
|
||||
cr.remaining -= n
|
||||
|
||||
if cr.remaining == 0 {
|
||||
// Consume chunk-terminating \r\n
|
||||
_, _ = cr.r.ReadString('\n')
|
||||
}
|
||||
|
||||
return n, err
|
||||
}
|
||||
21
client/inspect/listener.go
Normal file
21
client/inspect/listener.go
Normal file
@@ -0,0 +1,21 @@
|
||||
//go:build !linux
|
||||
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// newTPROXYListener is not supported on non-Linux platforms.
|
||||
func newTPROXYListener(_ *log.Entry, addr netip.AddrPort, _ netip.Prefix) (net.Listener, error) {
|
||||
return nil, fmt.Errorf("TPROXY listener not supported on this platform (requested %s)", addr)
|
||||
}
|
||||
|
||||
// getOriginalDst is not supported on non-Linux platforms.
|
||||
func getOriginalDst(_ net.Conn) (netip.AddrPort, error) {
|
||||
return netip.AddrPort{}, fmt.Errorf("SO_ORIGINAL_DST not supported on this platform")
|
||||
}
|
||||
89
client/inspect/listener_linux.go
Normal file
89
client/inspect/listener_linux.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"unsafe"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// newTPROXYListener creates a TCP listener for the transparent proxy.
|
||||
// After nftables REDIRECT, accepted connections have LocalAddr = WG_IP:proxy_port.
|
||||
// The original destination is retrieved via getsockopt(SO_ORIGINAL_DST).
|
||||
func newTPROXYListener(logger *log.Entry, addr netip.AddrPort, _ netip.Prefix) (net.Listener, error) {
|
||||
ln, err := net.Listen("tcp", addr.String())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listen on %s: %w", addr, err)
|
||||
}
|
||||
|
||||
logger.Infof("inspect: listener started on %s", ln.Addr())
|
||||
return ln, nil
|
||||
}
|
||||
|
||||
// getOriginalDst reads the original destination from conntrack via SO_ORIGINAL_DST.
|
||||
// This is set by the kernel when the connection was REDIRECT'd/DNAT'd.
|
||||
// Tries IPv4 first, then falls back to IPv6 (IP6T_SO_ORIGINAL_DST).
|
||||
func getOriginalDst(conn net.Conn) (netip.AddrPort, error) {
|
||||
tc, ok := conn.(*net.TCPConn)
|
||||
if !ok {
|
||||
return netip.AddrPort{}, fmt.Errorf("not a TCPConn")
|
||||
}
|
||||
|
||||
raw, err := tc.SyscallConn()
|
||||
if err != nil {
|
||||
return netip.AddrPort{}, fmt.Errorf("get syscall conn: %w", err)
|
||||
}
|
||||
|
||||
var origDst netip.AddrPort
|
||||
var sockErr error
|
||||
if err := raw.Control(func(fd uintptr) {
|
||||
// Try IPv4 first (SO_ORIGINAL_DST = 80)
|
||||
var sa4 unix.RawSockaddrInet4
|
||||
sa4Len := uint32(unsafe.Sizeof(sa4))
|
||||
_, _, errno := unix.Syscall6(
|
||||
unix.SYS_GETSOCKOPT,
|
||||
fd,
|
||||
unix.SOL_IP,
|
||||
80, // SO_ORIGINAL_DST
|
||||
uintptr(unsafe.Pointer(&sa4)),
|
||||
uintptr(unsafe.Pointer(&sa4Len)),
|
||||
0,
|
||||
)
|
||||
if errno == 0 {
|
||||
addr := netip.AddrFrom4(sa4.Addr)
|
||||
port := uint16(sa4.Port>>8) | uint16(sa4.Port<<8)
|
||||
origDst = netip.AddrPortFrom(addr.Unmap(), port)
|
||||
return
|
||||
}
|
||||
|
||||
// Fall back to IPv6 (IP6T_SO_ORIGINAL_DST = 80 on SOL_IPV6)
|
||||
var sa6 unix.RawSockaddrInet6
|
||||
sa6Len := uint32(unsafe.Sizeof(sa6))
|
||||
_, _, errno = unix.Syscall6(
|
||||
unix.SYS_GETSOCKOPT,
|
||||
fd,
|
||||
unix.SOL_IPV6,
|
||||
80, // IP6T_SO_ORIGINAL_DST
|
||||
uintptr(unsafe.Pointer(&sa6)),
|
||||
uintptr(unsafe.Pointer(&sa6Len)),
|
||||
0,
|
||||
)
|
||||
if errno != 0 {
|
||||
sockErr = fmt.Errorf("getsockopt SO_ORIGINAL_DST (v4 and v6): %w", errno)
|
||||
return
|
||||
}
|
||||
addr := netip.AddrFrom16(sa6.Addr)
|
||||
port := uint16(sa6.Port>>8) | uint16(sa6.Port<<8)
|
||||
origDst = netip.AddrPortFrom(addr.Unmap(), port)
|
||||
}); err != nil {
|
||||
return netip.AddrPort{}, fmt.Errorf("control raw conn: %w", err)
|
||||
}
|
||||
if sockErr != nil {
|
||||
return netip.AddrPort{}, sockErr
|
||||
}
|
||||
|
||||
return origDst, nil
|
||||
}
|
||||
200
client/inspect/mitm.go
Normal file
200
client/inspect/mitm.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"fmt"
|
||||
"math/big"
|
||||
mrand "math/rand/v2"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// certCacheSize is the maximum number of cached leaf certificates.
|
||||
certCacheSize = 1024
|
||||
// certTTL is how long generated certificates remain valid.
|
||||
certTTL = 24 * time.Hour
|
||||
)
|
||||
|
||||
// certCache is a bounded LRU cache for generated TLS certificates.
|
||||
type certCache struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]*certEntry
|
||||
// order tracks LRU eviction, most recent at end.
|
||||
order []string
|
||||
maxSize int
|
||||
}
|
||||
|
||||
type certEntry struct {
|
||||
cert *tls.Certificate
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
func newCertCache(maxSize int) *certCache {
|
||||
return &certCache{
|
||||
entries: make(map[string]*certEntry, maxSize),
|
||||
order: make([]string, 0, maxSize),
|
||||
maxSize: maxSize,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *certCache) get(hostname string) (*tls.Certificate, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
entry, ok := c.entries[hostname]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
if time.Now().After(entry.expiresAt) {
|
||||
c.removeLocked(hostname)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Move to end (most recently used)
|
||||
c.touchLocked(hostname)
|
||||
return entry.cert, true
|
||||
}
|
||||
|
||||
func (c *certCache) put(hostname string, cert *tls.Certificate) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// Jitter the TTL by +/- 20% to prevent thundering herd on expiry.
|
||||
jitter := time.Duration(float64(certTTL) * (0.8 + 0.4*mrand.Float64()))
|
||||
|
||||
if _, exists := c.entries[hostname]; exists {
|
||||
c.entries[hostname] = &certEntry{
|
||||
cert: cert,
|
||||
expiresAt: time.Now().Add(jitter),
|
||||
}
|
||||
c.touchLocked(hostname)
|
||||
return
|
||||
}
|
||||
|
||||
// Evict oldest if at capacity
|
||||
for len(c.entries) >= c.maxSize && len(c.order) > 0 {
|
||||
c.removeLocked(c.order[0])
|
||||
}
|
||||
|
||||
c.entries[hostname] = &certEntry{
|
||||
cert: cert,
|
||||
expiresAt: time.Now().Add(jitter),
|
||||
}
|
||||
c.order = append(c.order, hostname)
|
||||
}
|
||||
|
||||
func (c *certCache) touchLocked(hostname string) {
|
||||
for i, h := range c.order {
|
||||
if h == hostname {
|
||||
c.order = append(c.order[:i], c.order[i+1:]...)
|
||||
c.order = append(c.order, hostname)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *certCache) removeLocked(hostname string) {
|
||||
delete(c.entries, hostname)
|
||||
for i, h := range c.order {
|
||||
if h == hostname {
|
||||
c.order = append(c.order[:i], c.order[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CertProvider generates TLS certificates on the fly, signed by a CA.
|
||||
// Generated certificates are cached in an LRU cache.
|
||||
type CertProvider struct {
|
||||
ca *x509.Certificate
|
||||
caKey crypto.PrivateKey
|
||||
cache *certCache
|
||||
}
|
||||
|
||||
// NewCertProvider creates a certificate provider using the given CA.
|
||||
func NewCertProvider(ca *x509.Certificate, caKey crypto.PrivateKey) *CertProvider {
|
||||
return &CertProvider{
|
||||
ca: ca,
|
||||
caKey: caKey,
|
||||
cache: newCertCache(certCacheSize),
|
||||
}
|
||||
}
|
||||
|
||||
// GetCertificate returns a TLS certificate for the given hostname,
|
||||
// generating and caching one if necessary.
|
||||
func (p *CertProvider) GetCertificate(hostname string) (*tls.Certificate, error) {
|
||||
if cert, ok := p.cache.get(hostname); ok {
|
||||
return cert, nil
|
||||
}
|
||||
|
||||
cert, err := p.generateCert(hostname)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate cert for %s: %w", hostname, err)
|
||||
}
|
||||
|
||||
p.cache.put(hostname, cert)
|
||||
return cert, nil
|
||||
}
|
||||
|
||||
// GetTLSConfig returns a tls.Config that dynamically provides certificates
|
||||
// for any hostname using the MITM CA.
|
||||
func (p *CertProvider) GetTLSConfig() *tls.Config {
|
||||
return &tls.Config{
|
||||
GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
return p.GetCertificate(hello.ServerName)
|
||||
},
|
||||
NextProtos: []string{"h2", "http/1.1"},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *CertProvider) generateCert(hostname string) (*tls.Certificate, error) {
|
||||
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate serial number: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: serialNumber,
|
||||
Subject: pkix.Name{
|
||||
CommonName: hostname,
|
||||
},
|
||||
NotBefore: now.Add(-5 * time.Minute),
|
||||
NotAfter: now.Add(certTTL),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{
|
||||
x509.ExtKeyUsageServerAuth,
|
||||
},
|
||||
DNSNames: []string{hostname},
|
||||
}
|
||||
|
||||
leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate leaf key: %w", err)
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, template, p.ca, &leafKey.PublicKey, p.caKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sign leaf certificate: %w", err)
|
||||
}
|
||||
|
||||
leafCert, err := x509.ParseCertificate(certDER)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse generated certificate: %w", err)
|
||||
}
|
||||
|
||||
return &tls.Certificate{
|
||||
Certificate: [][]byte{certDER, p.ca.Raw},
|
||||
PrivateKey: leafKey,
|
||||
Leaf: leafCert,
|
||||
}, nil
|
||||
}
|
||||
133
client/inspect/mitm_test.go
Normal file
133
client/inspect/mitm_test.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"math/big"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func generateTestCA(t *testing.T) (*x509.Certificate, *ecdsa.PrivateKey) {
|
||||
t.Helper()
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{
|
||||
CommonName: "Test CA",
|
||||
},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
require.NoError(t, err)
|
||||
|
||||
cert, err := x509.ParseCertificate(certDER)
|
||||
require.NoError(t, err)
|
||||
|
||||
return cert, key
|
||||
}
|
||||
|
||||
func TestCertProvider_GetCertificate(t *testing.T) {
|
||||
ca, caKey := generateTestCA(t)
|
||||
provider := NewCertProvider(ca, caKey)
|
||||
|
||||
cert, err := provider.GetCertificate("example.com")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cert)
|
||||
|
||||
// Verify the leaf certificate
|
||||
assert.Equal(t, "example.com", cert.Leaf.Subject.CommonName)
|
||||
assert.Contains(t, cert.Leaf.DNSNames, "example.com")
|
||||
|
||||
// Verify chain: leaf + CA
|
||||
assert.Len(t, cert.Certificate, 2)
|
||||
|
||||
// Verify leaf is signed by our CA
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(ca)
|
||||
_, err = cert.Leaf.Verify(x509.VerifyOptions{
|
||||
Roots: pool,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestCertProvider_CachesResults(t *testing.T) {
|
||||
ca, caKey := generateTestCA(t)
|
||||
provider := NewCertProvider(ca, caKey)
|
||||
|
||||
cert1, err := provider.GetCertificate("cached.example.com")
|
||||
require.NoError(t, err)
|
||||
|
||||
cert2, err := provider.GetCertificate("cached.example.com")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Same pointer = cached
|
||||
assert.Equal(t, cert1, cert2)
|
||||
}
|
||||
|
||||
func TestCertProvider_DifferentHostsDifferentCerts(t *testing.T) {
|
||||
ca, caKey := generateTestCA(t)
|
||||
provider := NewCertProvider(ca, caKey)
|
||||
|
||||
cert1, err := provider.GetCertificate("a.example.com")
|
||||
require.NoError(t, err)
|
||||
|
||||
cert2, err := provider.GetCertificate("b.example.com")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEqual(t, cert1.Leaf.SerialNumber, cert2.Leaf.SerialNumber)
|
||||
}
|
||||
|
||||
func TestCertProvider_TLSConfigHandshake(t *testing.T) {
|
||||
ca, caKey := generateTestCA(t)
|
||||
provider := NewCertProvider(ca, caKey)
|
||||
|
||||
tlsConfig := provider.GetTLSConfig()
|
||||
require.NotNil(t, tlsConfig)
|
||||
require.NotNil(t, tlsConfig.GetCertificate)
|
||||
|
||||
// Simulate a ClientHelloInfo
|
||||
hello := &tls.ClientHelloInfo{
|
||||
ServerName: "handshake.example.com",
|
||||
}
|
||||
|
||||
cert, err := tlsConfig.GetCertificate(hello)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "handshake.example.com", cert.Leaf.Subject.CommonName)
|
||||
}
|
||||
|
||||
func TestCertCache_Eviction(t *testing.T) {
|
||||
cache := newCertCache(3)
|
||||
|
||||
for i := range 5 {
|
||||
hostname := string(rune('a'+i)) + ".example.com"
|
||||
cache.put(hostname, &tls.Certificate{})
|
||||
}
|
||||
|
||||
// Only 3 should remain (c, d, e - the most recent)
|
||||
assert.Len(t, cache.entries, 3)
|
||||
|
||||
_, ok := cache.get("a.example.com")
|
||||
assert.False(t, ok, "oldest entry should be evicted")
|
||||
|
||||
_, ok = cache.get("b.example.com")
|
||||
assert.False(t, ok, "second oldest should be evicted")
|
||||
|
||||
_, ok = cache.get("e.example.com")
|
||||
assert.True(t, ok, "newest entry should exist")
|
||||
}
|
||||
109
client/inspect/peek.go
Normal file
109
client/inspect/peek.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
)
|
||||
|
||||
// peekConn wraps a net.Conn with a buffer that allows reading ahead
|
||||
// without consuming data. Subsequent Read calls return the buffered
|
||||
// bytes first, then read from the underlying connection.
|
||||
type peekConn struct {
|
||||
net.Conn
|
||||
buf bytes.Buffer
|
||||
// peeked holds the raw bytes that were peeked, available for replay.
|
||||
peeked []byte
|
||||
}
|
||||
|
||||
// newPeekConn wraps conn for peek-ahead reading.
|
||||
func newPeekConn(conn net.Conn) *peekConn {
|
||||
return &peekConn{Conn: conn}
|
||||
}
|
||||
|
||||
// Peek reads exactly n bytes from the connection without consuming them.
|
||||
// The peeked bytes are replayed on subsequent Read calls.
|
||||
// Peek may only be called once; calling it again returns an error.
|
||||
func (c *peekConn) Peek(n int) ([]byte, error) {
|
||||
if c.peeked != nil {
|
||||
return nil, fmt.Errorf("peek already called")
|
||||
}
|
||||
|
||||
buf := make([]byte, n)
|
||||
if _, err := io.ReadFull(c.Conn, buf); err != nil {
|
||||
return nil, fmt.Errorf("peek %d bytes: %w", n, err)
|
||||
}
|
||||
|
||||
c.peeked = buf
|
||||
c.buf.Write(buf)
|
||||
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
// PeekAll reads up to n bytes, returning whatever is available.
|
||||
// Unlike Peek, it does not require exactly n bytes.
|
||||
func (c *peekConn) PeekAll(n int) ([]byte, error) {
|
||||
if c.peeked != nil {
|
||||
return nil, fmt.Errorf("peek already called")
|
||||
}
|
||||
|
||||
buf := make([]byte, n)
|
||||
nr, err := c.Conn.Read(buf)
|
||||
if nr > 0 {
|
||||
c.peeked = buf[:nr]
|
||||
c.buf.Write(c.peeked)
|
||||
}
|
||||
if err != nil && nr == 0 {
|
||||
return nil, fmt.Errorf("peek: %w", err)
|
||||
}
|
||||
|
||||
return c.peeked, nil
|
||||
}
|
||||
|
||||
// PeekMore extends the peeked buffer to at least n total bytes.
|
||||
// The buffer is reset and refilled with the extended data.
|
||||
// The returned slice is the internal peeked buffer; callers must not
|
||||
// retain references from prior Peek/PeekMore calls after calling this.
|
||||
func (c *peekConn) PeekMore(n int) ([]byte, error) {
|
||||
if len(c.peeked) >= n {
|
||||
return c.peeked[:n], nil
|
||||
}
|
||||
|
||||
remaining := n - len(c.peeked)
|
||||
extra := make([]byte, remaining)
|
||||
if _, err := io.ReadFull(c.Conn, extra); err != nil {
|
||||
return nil, fmt.Errorf("peek more %d bytes: %w", remaining, err)
|
||||
}
|
||||
|
||||
// Pre-allocate to avoid reallocation detaching previously returned slices.
|
||||
combined := make([]byte, 0, n)
|
||||
combined = append(combined, c.peeked...)
|
||||
combined = append(combined, extra...)
|
||||
c.peeked = combined
|
||||
c.buf.Reset()
|
||||
c.buf.Write(c.peeked)
|
||||
|
||||
return c.peeked, nil
|
||||
}
|
||||
|
||||
// Peeked returns the bytes that were peeked so far, or nil if Peek hasn't been called.
|
||||
func (c *peekConn) Peeked() []byte {
|
||||
return c.peeked
|
||||
}
|
||||
|
||||
// Read returns buffered peek data first, then reads from the underlying connection.
|
||||
func (c *peekConn) Read(p []byte) (int, error) {
|
||||
if c.buf.Len() > 0 {
|
||||
return c.buf.Read(p)
|
||||
}
|
||||
return c.Conn.Read(p)
|
||||
}
|
||||
|
||||
// reader returns an io.Reader that replays buffered bytes then reads from conn.
|
||||
func (c *peekConn) reader() io.Reader {
|
||||
if c.buf.Len() > 0 {
|
||||
return io.MultiReader(&c.buf, c.Conn)
|
||||
}
|
||||
return c.Conn
|
||||
}
|
||||
482
client/inspect/proxy.go
Normal file
482
client/inspect/proxy.go
Normal file
@@ -0,0 +1,482 @@
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// ErrBlocked is returned when a connection is denied by proxy policy.
|
||||
var ErrBlocked = errors.New("connection blocked by proxy policy")
|
||||
|
||||
const (
|
||||
// headerReadTimeout is the deadline for reading the initial protocol header.
|
||||
// Prevents slow loris attacks where a client opens a connection but sends data slowly.
|
||||
headerReadTimeout = 10 * time.Second
|
||||
|
||||
// idleTimeout is the deadline for idle connections between HTTP requests.
|
||||
idleTimeout = 120 * time.Second
|
||||
)
|
||||
|
||||
// Proxy is the inspection engine for traffic passing through a NetBird
|
||||
// routing peer. It handles protocol detection, rule evaluation, MITM TLS
|
||||
// decryption, ICAP delegation, and external proxy forwarding.
|
||||
type Proxy struct {
|
||||
config Config
|
||||
rules *RuleEngine
|
||||
certs *CertProvider
|
||||
icap *ICAPClient
|
||||
// envoy is nil unless mode is ModeEnvoy.
|
||||
envoy *envoyManager
|
||||
// dialer is the outbound dialer (with SO_MARK cleared on Linux).
|
||||
dialer net.Dialer
|
||||
log *log.Entry
|
||||
// wgNetwork is the WG overlay prefix; dial targets inside it are blocked.
|
||||
wgNetwork netip.Prefix
|
||||
// localIPs reports the routing peer's own IPs; dial targets are blocked.
|
||||
localIPs LocalIPChecker
|
||||
// listener is the TPROXY/REDIRECT listener for kernel mode.
|
||||
listener net.Listener
|
||||
|
||||
mu sync.RWMutex
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
// LocalIPChecker reports whether an IP belongs to the local machine.
|
||||
type LocalIPChecker interface {
|
||||
IsLocalIP(netip.Addr) bool
|
||||
}
|
||||
|
||||
// New creates a transparent proxy with the given configuration.
|
||||
func New(ctx context.Context, logger *log.Entry, config Config) (*Proxy, error) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
|
||||
p := &Proxy{
|
||||
config: config,
|
||||
rules: NewRuleEngine(logger, config.DefaultAction),
|
||||
dialer: newOutboundDialer(),
|
||||
log: logger,
|
||||
wgNetwork: config.WGNetwork,
|
||||
localIPs: config.LocalIPChecker,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
p.rules.UpdateRules(config.Rules, config.DefaultAction)
|
||||
|
||||
// Initialize MITM certificate provider
|
||||
if config.TLS != nil {
|
||||
p.certs = NewCertProvider(config.TLS.CA, config.TLS.CAKey)
|
||||
}
|
||||
|
||||
// Initialize ICAP client
|
||||
if config.ICAP != nil {
|
||||
p.icap = NewICAPClient(logger, config.ICAP)
|
||||
}
|
||||
|
||||
// Start envoy sidecar if configured
|
||||
if config.Mode == ModeEnvoy {
|
||||
envoyLog := logger.WithField("sidecar", "envoy")
|
||||
em, err := startEnvoy(ctx, envoyLog, config)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("start envoy sidecar: %w", err)
|
||||
}
|
||||
p.envoy = em
|
||||
}
|
||||
|
||||
// Start TPROXY listener for kernel mode
|
||||
if config.ListenAddr.IsValid() {
|
||||
ln, err := newTPROXYListener(logger, config.ListenAddr, netip.Prefix{})
|
||||
if err != nil {
|
||||
cancel()
|
||||
return nil, fmt.Errorf("start TPROXY listener on %s: %w", config.ListenAddr, err)
|
||||
}
|
||||
p.listener = ln
|
||||
go p.acceptLoop(ln)
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// HandleTCP is the entry point for TCP connections from the userspace forwarder.
|
||||
// It determines the protocol (TLS or plaintext HTTP), evaluates rules,
|
||||
// and either blocks, passes through, inspects, or forwards to an external proxy.
|
||||
func (p *Proxy) HandleTCP(ctx context.Context, clientConn net.Conn, dst netip.AddrPort, src SourceInfo) error {
|
||||
defer func() {
|
||||
if err := clientConn.Close(); err != nil {
|
||||
p.log.Debugf("close client conn: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
p.mu.RLock()
|
||||
mode := p.config.Mode
|
||||
p.mu.RUnlock()
|
||||
|
||||
if mode == ModeExternal {
|
||||
pconn := newPeekConn(clientConn)
|
||||
return p.handleExternal(ctx, pconn, dst)
|
||||
}
|
||||
|
||||
// Envoy and builtin modes both peek the protocol header for rule evaluation.
|
||||
// Envoy mode forwards non-blocked traffic to envoy; builtin mode handles all locally.
|
||||
// TLS blocks are handled by Go (instant close) since envoy can't cleanly RST a TLS connection.
|
||||
|
||||
// Built-in and envoy mode: peek 5 bytes (TLS record header size) to determine protocol.
|
||||
// Set a read deadline to prevent slow loris attacks.
|
||||
if err := clientConn.SetReadDeadline(time.Now().Add(headerReadTimeout)); err != nil {
|
||||
return fmt.Errorf("set read deadline: %w", err)
|
||||
}
|
||||
pconn := newPeekConn(clientConn)
|
||||
header, err := pconn.Peek(5)
|
||||
if err != nil {
|
||||
return fmt.Errorf("peek protocol header: %w", err)
|
||||
}
|
||||
if err := clientConn.SetReadDeadline(time.Time{}); err != nil {
|
||||
return fmt.Errorf("clear read deadline: %w", err)
|
||||
}
|
||||
|
||||
if isTLSHandshake(header[0]) {
|
||||
return p.handleTLS(ctx, pconn, dst, src)
|
||||
}
|
||||
|
||||
if isHTTPMethod(header) {
|
||||
return p.handlePlainHTTP(ctx, pconn, dst, src)
|
||||
}
|
||||
|
||||
// Not TLS and not HTTP: evaluate rules with ProtoOther.
|
||||
// If no rule explicitly allows "other", this falls through to the default action.
|
||||
action := p.rules.Evaluate(src.IP, "", dst.Addr(), dst.Port(), ProtoOther, "")
|
||||
if action == ActionAllow {
|
||||
remote, err := p.dialTCP(ctx, dst)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial for passthrough: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := remote.Close(); err != nil {
|
||||
p.log.Debugf("close remote conn: %v", err)
|
||||
}
|
||||
}()
|
||||
return relay(ctx, pconn, remote)
|
||||
}
|
||||
|
||||
p.log.Debugf("block: non-HTTP/TLS to %s (action=%s, first bytes: %x)", dst, action, header)
|
||||
return ErrBlocked
|
||||
}
|
||||
|
||||
// InspectTCP evaluates rules for a TCP connection and returns the result.
|
||||
// Unlike HandleTCP, it can return early for allow decisions, letting the caller
|
||||
// handle the relay (USP forwarder passthrough optimization).
|
||||
//
|
||||
// When InspectResult.PassthroughConn is non-nil, ownership transfers to the caller:
|
||||
// the caller must close the connection and relay traffic. The engine does not close it.
|
||||
//
|
||||
// When PassthroughConn is nil, the engine handled everything internally
|
||||
// (block, inspect/MITM, or plain HTTP inspection) and closed the connection.
|
||||
func (p *Proxy) InspectTCP(ctx context.Context, clientConn net.Conn, dst netip.AddrPort, src SourceInfo) (InspectResult, error) {
|
||||
p.mu.RLock()
|
||||
mode := p.config.Mode
|
||||
envoy := p.envoy
|
||||
p.mu.RUnlock()
|
||||
|
||||
// External mode: handle internally, engine owns the connection.
|
||||
if mode == ModeExternal {
|
||||
defer func() {
|
||||
if err := clientConn.Close(); err != nil {
|
||||
p.log.Debugf("close client conn: %v", err)
|
||||
}
|
||||
}()
|
||||
pconn := newPeekConn(clientConn)
|
||||
err := p.handleExternal(ctx, pconn, dst)
|
||||
return InspectResult{Action: ActionAllow}, err
|
||||
}
|
||||
|
||||
// Peek protocol header.
|
||||
if err := clientConn.SetReadDeadline(time.Now().Add(headerReadTimeout)); err != nil {
|
||||
clientConn.Close()
|
||||
return InspectResult{}, fmt.Errorf("set read deadline: %w", err)
|
||||
}
|
||||
pconn := newPeekConn(clientConn)
|
||||
header, err := pconn.Peek(5)
|
||||
if err != nil {
|
||||
clientConn.Close()
|
||||
return InspectResult{}, fmt.Errorf("peek protocol header: %w", err)
|
||||
}
|
||||
if err := clientConn.SetReadDeadline(time.Time{}); err != nil {
|
||||
clientConn.Close()
|
||||
return InspectResult{}, fmt.Errorf("clear read deadline: %w", err)
|
||||
}
|
||||
|
||||
// TLS: may return passthrough for allow.
|
||||
if isTLSHandshake(header[0]) {
|
||||
result, err := p.inspectTLS(ctx, pconn, dst, src)
|
||||
if err != nil && result.PassthroughConn == nil {
|
||||
clientConn.Close()
|
||||
return result, err
|
||||
}
|
||||
// Envoy mode: forward allowed TLS to envoy instead of returning passthrough.
|
||||
if result.PassthroughConn != nil && envoy != nil {
|
||||
defer clientConn.Close()
|
||||
envoyErr := p.forwardToEnvoy(ctx, pconn, dst, src, envoy)
|
||||
return InspectResult{Action: ActionAllow}, envoyErr
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Plain HTTP: in envoy mode, forward to envoy for L7 processing.
|
||||
// In builtin mode, inspect per-request locally.
|
||||
if isHTTPMethod(header) {
|
||||
defer func() {
|
||||
if err := clientConn.Close(); err != nil {
|
||||
p.log.Debugf("close client conn: %v", err)
|
||||
}
|
||||
}()
|
||||
if envoy != nil {
|
||||
err := p.forwardToEnvoy(ctx, pconn, dst, src, envoy)
|
||||
return InspectResult{Action: ActionAllow}, err
|
||||
}
|
||||
err := p.handlePlainHTTP(ctx, pconn, dst, src)
|
||||
return InspectResult{Action: ActionInspect}, err
|
||||
}
|
||||
|
||||
// Other protocol: evaluate rules.
|
||||
action := p.rules.Evaluate(src.IP, "", dst.Addr(), dst.Port(), ProtoOther, "")
|
||||
if action == ActionAllow {
|
||||
// Envoy mode: forward to envoy.
|
||||
if envoy != nil {
|
||||
defer clientConn.Close()
|
||||
err := p.forwardToEnvoy(ctx, pconn, dst, src, envoy)
|
||||
return InspectResult{Action: ActionAllow}, err
|
||||
}
|
||||
return InspectResult{Action: ActionAllow, PassthroughConn: pconn}, nil
|
||||
}
|
||||
|
||||
p.log.Debugf("block: non-HTTP/TLS to %s (action=%s, first bytes: %x)", dst, action, header)
|
||||
clientConn.Close()
|
||||
return InspectResult{Action: ActionBlock}, ErrBlocked
|
||||
}
|
||||
|
||||
// HandleUDPPacket inspects a UDP packet for QUIC Initial packets.
|
||||
// Returns the action to take: ActionAllow to continue normal forwarding,
|
||||
// ActionBlock to drop the packet.
|
||||
// Non-QUIC packets always return ActionAllow.
|
||||
func (p *Proxy) HandleUDPPacket(data []byte, dst netip.AddrPort, src SourceInfo) Action {
|
||||
if len(data) < 5 {
|
||||
return ActionAllow
|
||||
}
|
||||
|
||||
// Check for QUIC Long Header
|
||||
if data[0]&0x80 == 0 {
|
||||
return ActionAllow
|
||||
}
|
||||
|
||||
sni, err := ExtractQUICSNI(data)
|
||||
if err != nil {
|
||||
// Can't parse QUIC, allow through (could be non-QUIC UDP)
|
||||
p.log.Tracef("QUIC SNI extraction failed for %s: %v", dst, err)
|
||||
return ActionAllow
|
||||
}
|
||||
|
||||
if sni == "" {
|
||||
return ActionAllow
|
||||
}
|
||||
|
||||
action := p.rules.Evaluate(src.IP, sni, dst.Addr(), dst.Port(), ProtoH3, "")
|
||||
|
||||
if action == ActionBlock {
|
||||
p.log.Debugf("block: QUIC to %s (SNI=%s)", dst, sni.PunycodeString())
|
||||
return ActionBlock
|
||||
}
|
||||
|
||||
// QUIC can't be MITMed, treat Inspect as Allow
|
||||
if action == ActionInspect {
|
||||
p.log.Debugf("allow: QUIC to %s (SNI=%s), MITM not supported for QUIC", dst, sni.PunycodeString())
|
||||
} else {
|
||||
p.log.Tracef("allow: QUIC to %s (SNI=%s)", dst, sni.PunycodeString())
|
||||
}
|
||||
|
||||
return ActionAllow
|
||||
}
|
||||
|
||||
// handlePlainHTTP handles plaintext HTTP connections.
|
||||
func (p *Proxy) handlePlainHTTP(ctx context.Context, pconn *peekConn, dst netip.AddrPort, src SourceInfo) error {
|
||||
remote, err := p.dialTCP(ctx, dst)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial %s: %w", dst, err)
|
||||
}
|
||||
defer func() {
|
||||
if err := remote.Close(); err != nil {
|
||||
p.log.Debugf("close remote for %s: %v", dst, err)
|
||||
}
|
||||
}()
|
||||
|
||||
// For plaintext HTTP, always inspect (we can see the traffic)
|
||||
return p.inspectHTTP(ctx, pconn, remote, dst, "", src, "http/1.1")
|
||||
}
|
||||
|
||||
// UpdateConfig replaces the inspection engine configuration at runtime.
|
||||
func (p *Proxy) UpdateConfig(config Config) {
|
||||
p.log.Debugf("config update: mode=%s rules=%d default=%s has_tls=%v has_icap=%v",
|
||||
config.Mode, len(config.Rules), config.DefaultAction, config.TLS != nil, config.ICAP != nil)
|
||||
|
||||
p.mu.Lock()
|
||||
|
||||
p.config = config
|
||||
p.rules.UpdateRules(config.Rules, config.DefaultAction)
|
||||
|
||||
// Update MITM provider
|
||||
if config.TLS != nil {
|
||||
p.certs = NewCertProvider(config.TLS.CA, config.TLS.CAKey)
|
||||
} else {
|
||||
p.certs = nil
|
||||
}
|
||||
|
||||
// Swap ICAP client under lock, close the old one outside to avoid blocking.
|
||||
var oldICAP *ICAPClient
|
||||
if config.ICAP != nil {
|
||||
oldICAP = p.icap
|
||||
p.icap = NewICAPClient(p.log, config.ICAP)
|
||||
} else {
|
||||
oldICAP = p.icap
|
||||
p.icap = nil
|
||||
}
|
||||
|
||||
// If switching away from envoy mode, clear and stop the old envoy.
|
||||
var oldEnvoy *envoyManager
|
||||
if config.Mode != ModeEnvoy && p.envoy != nil {
|
||||
oldEnvoy = p.envoy
|
||||
p.envoy = nil
|
||||
}
|
||||
|
||||
envoy := p.envoy
|
||||
|
||||
p.mu.Unlock()
|
||||
|
||||
if oldICAP != nil {
|
||||
oldICAP.Close()
|
||||
}
|
||||
|
||||
if oldEnvoy != nil {
|
||||
oldEnvoy.Stop()
|
||||
}
|
||||
|
||||
// Reload envoy config if still in envoy mode.
|
||||
if envoy != nil && config.Mode == ModeEnvoy {
|
||||
if err := envoy.Reload(config); err != nil {
|
||||
p.log.Errorf("inspect: envoy config reload: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mode returns the current proxy operating mode.
|
||||
func (p *Proxy) Mode() ProxyMode {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.config.Mode
|
||||
}
|
||||
|
||||
// ListenPort returns the port to use for kernel-mode nftables REDIRECT.
|
||||
// For builtin mode: the TPROXY listener port.
|
||||
// For envoy mode: the envoy listener port (nftables redirects directly to envoy).
|
||||
// Returns 0 if no listener is active.
|
||||
func (p *Proxy) ListenPort() uint16 {
|
||||
p.mu.RLock()
|
||||
envoy := p.envoy
|
||||
p.mu.RUnlock()
|
||||
|
||||
if envoy != nil {
|
||||
return envoy.listenPort
|
||||
}
|
||||
if p.listener == nil {
|
||||
return 0
|
||||
}
|
||||
tcpAddr, ok := p.listener.Addr().(*net.TCPAddr)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return uint16(tcpAddr.Port)
|
||||
}
|
||||
|
||||
// Close shuts down the proxy and releases resources.
|
||||
func (p *Proxy) Close() error {
|
||||
p.cancel()
|
||||
|
||||
p.mu.Lock()
|
||||
envoy := p.envoy
|
||||
p.envoy = nil
|
||||
icap := p.icap
|
||||
p.icap = nil
|
||||
p.mu.Unlock()
|
||||
|
||||
if envoy != nil {
|
||||
envoy.Stop()
|
||||
}
|
||||
|
||||
if p.listener != nil {
|
||||
if err := p.listener.Close(); err != nil {
|
||||
p.log.Debugf("close TPROXY listener: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if icap != nil {
|
||||
icap.Close()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// acceptLoop accepts connections from the redirected listener (kernel mode).
|
||||
// Connections arrive via nftables REDIRECT; original destination is read from conntrack.
|
||||
func (p *Proxy) acceptLoop(ln net.Listener) {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
if p.ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
p.log.Debugf("accept error: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
go func() {
|
||||
// Read original destination from conntrack (SO_ORIGINAL_DST).
|
||||
// nftables REDIRECT changes dst to the local WG IP:proxy_port,
|
||||
// but conntrack preserves the real destination.
|
||||
dstAddr, err := getOriginalDst(conn)
|
||||
if err != nil {
|
||||
p.log.Debugf("get original dst: %v", err)
|
||||
if closeErr := conn.Close(); closeErr != nil {
|
||||
p.log.Debugf("close conn: %v", closeErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
p.log.Tracef("accepted: %s -> %s (original dst %s)",
|
||||
conn.RemoteAddr(), conn.LocalAddr(), dstAddr)
|
||||
|
||||
srcAddr, err := netip.ParseAddrPort(conn.RemoteAddr().String())
|
||||
if err != nil {
|
||||
p.log.Debugf("parse source: %v", err)
|
||||
if closeErr := conn.Close(); closeErr != nil {
|
||||
p.log.Debugf("close conn: %v", closeErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
src := SourceInfo{
|
||||
IP: srcAddr.Addr().Unmap(),
|
||||
}
|
||||
|
||||
if err := p.HandleTCP(p.ctx, conn, dstAddr, src); err != nil && !errors.Is(err, ErrBlocked) {
|
||||
p.log.Debugf("connection to %s: %v", dstAddr, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
388
client/inspect/quic.go
Normal file
388
client/inspect/quic.go
Normal file
@@ -0,0 +1,388 @@
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"golang.org/x/crypto/hkdf"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
)
|
||||
|
||||
// QUIC version constants
|
||||
const (
|
||||
quicV1Version uint32 = 0x00000001
|
||||
quicV2Version uint32 = 0x6b3343cf
|
||||
)
|
||||
|
||||
// quicV1Salt is the initial salt for QUIC v1 (RFC 9001 Section 5.2).
|
||||
var quicV1Salt = []byte{
|
||||
0x38, 0x76, 0x2c, 0xf7, 0xf5, 0x59, 0x34, 0xb3,
|
||||
0x4d, 0x17, 0x9a, 0xe6, 0xa4, 0xc8, 0x0c, 0xad,
|
||||
0xcc, 0xbb, 0x7f, 0x0a,
|
||||
}
|
||||
|
||||
// quicV2Salt is the initial salt for QUIC v2 (RFC 9369).
|
||||
var quicV2Salt = []byte{
|
||||
0x0d, 0xed, 0xe3, 0xde, 0xf7, 0x00, 0xa6, 0xdb,
|
||||
0x81, 0x93, 0x81, 0xbe, 0x6e, 0x26, 0x9d, 0xcb,
|
||||
0xf9, 0xbd, 0x2e, 0xd9,
|
||||
}
|
||||
|
||||
// ExtractQUICSNI extracts the SNI from a QUIC Initial packet.
|
||||
// The Initial packet's encryption uses well-known keys derived from the
|
||||
// Destination Connection ID, so any observer can decrypt it (by design).
|
||||
func ExtractQUICSNI(data []byte) (domain.Domain, error) {
|
||||
if len(data) < 5 {
|
||||
return "", fmt.Errorf("packet too short")
|
||||
}
|
||||
|
||||
// Check for QUIC Long Header (form bit set)
|
||||
if data[0]&0x80 == 0 {
|
||||
return "", fmt.Errorf("not a QUIC long header packet")
|
||||
}
|
||||
|
||||
// Version
|
||||
version := binary.BigEndian.Uint32(data[1:5])
|
||||
|
||||
var salt []byte
|
||||
var initialLabel, keyLabel, ivLabel, hpLabel string
|
||||
|
||||
switch version {
|
||||
case quicV1Version:
|
||||
salt = quicV1Salt
|
||||
initialLabel = "client in"
|
||||
keyLabel = "quic key"
|
||||
ivLabel = "quic iv"
|
||||
hpLabel = "quic hp"
|
||||
case quicV2Version:
|
||||
salt = quicV2Salt
|
||||
initialLabel = "client in"
|
||||
keyLabel = "quicv2 key"
|
||||
ivLabel = "quicv2 iv"
|
||||
hpLabel = "quicv2 hp"
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported QUIC version: 0x%08x", version)
|
||||
}
|
||||
|
||||
// Parse Long Header
|
||||
if len(data) < 6 {
|
||||
return "", fmt.Errorf("packet too short for DCID length")
|
||||
}
|
||||
dcidLen := int(data[5])
|
||||
if len(data) < 6+dcidLen+1 {
|
||||
return "", fmt.Errorf("packet too short for DCID")
|
||||
}
|
||||
dcid := data[6 : 6+dcidLen]
|
||||
|
||||
scidLenOff := 6 + dcidLen
|
||||
scidLen := int(data[scidLenOff])
|
||||
tokenLenOff := scidLenOff + 1 + scidLen
|
||||
|
||||
if tokenLenOff >= len(data) {
|
||||
return "", fmt.Errorf("packet too short for token length")
|
||||
}
|
||||
|
||||
// Token length is a variable-length integer
|
||||
tokenLen, tokenLenSize, err := readVarInt(data[tokenLenOff:])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read token length: %w", err)
|
||||
}
|
||||
|
||||
payloadLenOff := tokenLenOff + tokenLenSize + int(tokenLen)
|
||||
if payloadLenOff >= len(data) {
|
||||
return "", fmt.Errorf("packet too short for payload length")
|
||||
}
|
||||
|
||||
// Payload length is a variable-length integer
|
||||
payloadLen, payloadLenSize, err := readVarInt(data[payloadLenOff:])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read payload length: %w", err)
|
||||
}
|
||||
|
||||
pnOffset := payloadLenOff + payloadLenSize
|
||||
if pnOffset+4 > len(data) {
|
||||
return "", fmt.Errorf("packet too short for packet number")
|
||||
}
|
||||
|
||||
// Derive initial keys
|
||||
clientKey, clientIV, clientHP, err := deriveInitialKeys(dcid, salt, initialLabel, keyLabel, ivLabel, hpLabel)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("derive initial keys: %w", err)
|
||||
}
|
||||
|
||||
// Remove header protection
|
||||
sampleOffset := pnOffset + 4 // sample starts 4 bytes after pn offset
|
||||
if sampleOffset+16 > len(data) {
|
||||
return "", fmt.Errorf("packet too short for HP sample")
|
||||
}
|
||||
sample := data[sampleOffset : sampleOffset+16]
|
||||
|
||||
hpBlock, err := aes.NewCipher(clientHP)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create HP cipher: %w", err)
|
||||
}
|
||||
|
||||
mask := make([]byte, 16)
|
||||
hpBlock.Encrypt(mask, sample)
|
||||
|
||||
// Unmask header byte
|
||||
header := make([]byte, len(data))
|
||||
copy(header, data)
|
||||
header[0] ^= mask[0] & 0x0f // Long header: low 4 bits
|
||||
|
||||
// Determine packet number length
|
||||
pnLen := int(header[0]&0x03) + 1
|
||||
|
||||
// Unmask packet number
|
||||
for i := 0; i < pnLen; i++ {
|
||||
header[pnOffset+i] ^= mask[1+i]
|
||||
}
|
||||
|
||||
// Reconstruct packet number
|
||||
var pn uint32
|
||||
for i := 0; i < pnLen; i++ {
|
||||
pn = (pn << 8) | uint32(header[pnOffset+i])
|
||||
}
|
||||
|
||||
// Build nonce
|
||||
nonce := make([]byte, len(clientIV))
|
||||
copy(nonce, clientIV)
|
||||
for i := 0; i < 4; i++ {
|
||||
nonce[len(nonce)-1-i] ^= byte(pn >> (8 * i))
|
||||
}
|
||||
|
||||
// Decrypt payload
|
||||
block, err := aes.NewCipher(clientKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create AES cipher: %w", err)
|
||||
}
|
||||
|
||||
aead, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create AEAD: %w", err)
|
||||
}
|
||||
|
||||
encryptedPayload := header[pnOffset+pnLen : pnOffset+int(payloadLen)]
|
||||
aad := header[:pnOffset+pnLen]
|
||||
|
||||
plaintext, err := aead.Open(nil, nonce, encryptedPayload, aad)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decrypt QUIC payload: %w", err)
|
||||
}
|
||||
|
||||
// Parse CRYPTO frames to extract ClientHello
|
||||
clientHello, err := extractCryptoFrames(plaintext)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("extract CRYPTO frames: %w", err)
|
||||
}
|
||||
|
||||
info, err := parseHelloBody(clientHello)
|
||||
return info.SNI, err
|
||||
}
|
||||
|
||||
// deriveInitialKeys derives the client's initial encryption keys from the DCID.
|
||||
func deriveInitialKeys(dcid, salt []byte, initialLabel, keyLabel, ivLabel, hpLabel string) (key, iv, hp []byte, err error) {
|
||||
// initial_secret = HKDF-Extract(salt, DCID)
|
||||
initialSecret := hkdf.Extract(sha256.New, dcid, salt)
|
||||
|
||||
// client_initial_secret = HKDF-Expand-Label(initial_secret, initialLabel, "", 32)
|
||||
clientSecret, err := hkdfExpandLabel(initialSecret, initialLabel, nil, 32)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("derive client secret: %w", err)
|
||||
}
|
||||
|
||||
// client_key = HKDF-Expand-Label(client_secret, keyLabel, "", 16)
|
||||
key, err = hkdfExpandLabel(clientSecret, keyLabel, nil, 16)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("derive key: %w", err)
|
||||
}
|
||||
|
||||
// client_iv = HKDF-Expand-Label(client_secret, ivLabel, "", 12)
|
||||
iv, err = hkdfExpandLabel(clientSecret, ivLabel, nil, 12)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("derive IV: %w", err)
|
||||
}
|
||||
|
||||
// client_hp = HKDF-Expand-Label(client_secret, hpLabel, "", 16)
|
||||
hp, err = hkdfExpandLabel(clientSecret, hpLabel, nil, 16)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("derive HP key: %w", err)
|
||||
}
|
||||
|
||||
return key, iv, hp, nil
|
||||
}
|
||||
|
||||
// hkdfExpandLabel implements TLS 1.3 HKDF-Expand-Label.
|
||||
func hkdfExpandLabel(secret []byte, label string, context []byte, length int) ([]byte, error) {
|
||||
// HkdfLabel = struct {
|
||||
// uint16 length;
|
||||
// opaque label<7..255> = "tls13 " + Label;
|
||||
// opaque context<0..255> = Context;
|
||||
// }
|
||||
fullLabel := "tls13 " + label
|
||||
|
||||
hkdfLabel := make([]byte, 2+1+len(fullLabel)+1+len(context))
|
||||
binary.BigEndian.PutUint16(hkdfLabel[0:2], uint16(length))
|
||||
hkdfLabel[2] = byte(len(fullLabel))
|
||||
copy(hkdfLabel[3:], fullLabel)
|
||||
hkdfLabel[3+len(fullLabel)] = byte(len(context))
|
||||
if len(context) > 0 {
|
||||
copy(hkdfLabel[4+len(fullLabel):], context)
|
||||
}
|
||||
|
||||
expander := hkdf.Expand(sha256.New, secret, hkdfLabel)
|
||||
out := make([]byte, length)
|
||||
if _, err := io.ReadFull(expander, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// maxCryptoFrameSize limits total CRYPTO frame data to prevent memory exhaustion.
|
||||
const maxCryptoFrameSize = 64 * 1024
|
||||
|
||||
// extractCryptoFrames reassembles CRYPTO frame data from QUIC frames.
|
||||
func extractCryptoFrames(frames []byte) ([]byte, error) {
|
||||
var result []byte
|
||||
pos := 0
|
||||
|
||||
for pos < len(frames) {
|
||||
frameType := frames[pos]
|
||||
|
||||
switch {
|
||||
case frameType == 0x00:
|
||||
// PADDING frame
|
||||
pos++
|
||||
|
||||
case frameType == 0x06:
|
||||
// CRYPTO frame
|
||||
pos++
|
||||
|
||||
offset, n, err := readVarInt(frames[pos:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read crypto offset: %w", err)
|
||||
}
|
||||
pos += n
|
||||
_ = offset // We assume ordered, offset 0 for Initial
|
||||
|
||||
dataLen, n, err := readVarInt(frames[pos:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read crypto data length: %w", err)
|
||||
}
|
||||
pos += n
|
||||
|
||||
end := pos + int(dataLen)
|
||||
if end > len(frames) {
|
||||
return nil, fmt.Errorf("CRYPTO frame data truncated")
|
||||
}
|
||||
|
||||
result = append(result, frames[pos:end]...)
|
||||
if len(result) > maxCryptoFrameSize {
|
||||
return nil, fmt.Errorf("CRYPTO frame data exceeds %d bytes", maxCryptoFrameSize)
|
||||
}
|
||||
pos = end
|
||||
|
||||
case frameType == 0x01:
|
||||
// PING frame
|
||||
pos++
|
||||
|
||||
case frameType == 0x02 || frameType == 0x03:
|
||||
// ACK frame - skip
|
||||
pos++
|
||||
// Largest Acknowledged
|
||||
_, n, err := readVarInt(frames[pos:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read ACK: %w", err)
|
||||
}
|
||||
pos += n
|
||||
// ACK Delay
|
||||
_, n, err = readVarInt(frames[pos:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read ACK delay: %w", err)
|
||||
}
|
||||
pos += n
|
||||
// ACK Range Count
|
||||
rangeCount, n, err := readVarInt(frames[pos:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read ACK range count: %w", err)
|
||||
}
|
||||
pos += n
|
||||
// First ACK Range
|
||||
_, n, err = readVarInt(frames[pos:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read first ACK range: %w", err)
|
||||
}
|
||||
pos += n
|
||||
// Additional ranges
|
||||
for i := uint64(0); i < rangeCount; i++ {
|
||||
_, n, err = readVarInt(frames[pos:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read ACK gap: %w", err)
|
||||
}
|
||||
pos += n
|
||||
_, n, err = readVarInt(frames[pos:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read ACK range: %w", err)
|
||||
}
|
||||
pos += n
|
||||
}
|
||||
// ECN counts for type 0x03
|
||||
if frameType == 0x03 {
|
||||
for range 3 {
|
||||
_, n, err = readVarInt(frames[pos:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read ECN count: %w", err)
|
||||
}
|
||||
pos += n
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
// Unknown frame type, stop parsing
|
||||
if len(result) > 0 {
|
||||
return result, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unknown QUIC frame type: 0x%02x at offset %d", frameType, pos)
|
||||
}
|
||||
}
|
||||
|
||||
if len(result) == 0 {
|
||||
return nil, fmt.Errorf("no CRYPTO frames found")
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// readVarInt reads a QUIC variable-length integer.
|
||||
// Returns (value, bytes consumed, error).
|
||||
func readVarInt(data []byte) (uint64, int, error) {
|
||||
if len(data) == 0 {
|
||||
return 0, 0, fmt.Errorf("empty data for varint")
|
||||
}
|
||||
|
||||
prefix := data[0] >> 6
|
||||
length := 1 << prefix
|
||||
|
||||
if len(data) < length {
|
||||
return 0, 0, fmt.Errorf("varint truncated: need %d, have %d", length, len(data))
|
||||
}
|
||||
|
||||
var val uint64
|
||||
switch length {
|
||||
case 1:
|
||||
val = uint64(data[0] & 0x3f)
|
||||
case 2:
|
||||
val = uint64(binary.BigEndian.Uint16(data[:2])) & 0x3fff
|
||||
case 4:
|
||||
val = uint64(binary.BigEndian.Uint32(data[:4])) & 0x3fffffff
|
||||
case 8:
|
||||
val = binary.BigEndian.Uint64(data[:8]) & 0x3fffffffffffffff
|
||||
}
|
||||
|
||||
return val, length, nil
|
||||
}
|
||||
99
client/inspect/quic_test.go
Normal file
99
client/inspect/quic_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestReadVarInt(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data []byte
|
||||
want uint64
|
||||
n int
|
||||
}{
|
||||
{
|
||||
name: "1 byte value",
|
||||
data: []byte{0x25},
|
||||
want: 37,
|
||||
n: 1,
|
||||
},
|
||||
{
|
||||
name: "2 byte value",
|
||||
data: []byte{0x7b, 0xbd},
|
||||
want: 15293,
|
||||
n: 2,
|
||||
},
|
||||
{
|
||||
name: "4 byte value",
|
||||
data: []byte{0x9d, 0x7f, 0x3e, 0x7d},
|
||||
want: 494878333,
|
||||
n: 4,
|
||||
},
|
||||
{
|
||||
name: "zero",
|
||||
data: []byte{0x00},
|
||||
want: 0,
|
||||
n: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
val, n, err := readVarInt(tt.data)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, val)
|
||||
assert.Equal(t, tt.n, n)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadVarInt_Empty(t *testing.T) {
|
||||
_, _, err := readVarInt(nil)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestReadVarInt_Truncated(t *testing.T) {
|
||||
// 2-byte prefix but only 1 byte
|
||||
_, _, err := readVarInt([]byte{0x40})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestExtractQUICSNI_NotLongHeader(t *testing.T) {
|
||||
// Short header packet (form bit not set)
|
||||
data := make([]byte, 100)
|
||||
data[0] = 0x40 // short header
|
||||
|
||||
_, err := ExtractQUICSNI(data)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not a QUIC long header")
|
||||
}
|
||||
|
||||
func TestExtractQUICSNI_UnsupportedVersion(t *testing.T) {
|
||||
data := make([]byte, 100)
|
||||
data[0] = 0xC0 // long header
|
||||
// Version 0xdeadbeef
|
||||
data[1] = 0xde
|
||||
data[2] = 0xad
|
||||
data[3] = 0xbe
|
||||
data[4] = 0xef
|
||||
|
||||
_, err := ExtractQUICSNI(data)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unsupported QUIC version")
|
||||
}
|
||||
|
||||
func TestExtractQUICSNI_TooShort(t *testing.T) {
|
||||
_, err := ExtractQUICSNI([]byte{0xC0, 0x00})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestHkdfExpandLabel(t *testing.T) {
|
||||
// Smoke test: ensure it returns the right length and doesn't error
|
||||
secret := make([]byte, 32)
|
||||
result, err := hkdfExpandLabel(secret, "quic key", nil, 16)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result, 16)
|
||||
}
|
||||
253
client/inspect/rules.go
Normal file
253
client/inspect/rules.go
Normal file
@@ -0,0 +1,253 @@
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/acl/id"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
)
|
||||
|
||||
// RuleEngine evaluates proxy rules against connection metadata.
|
||||
// It is safe for concurrent use.
|
||||
type RuleEngine struct {
|
||||
mu sync.RWMutex
|
||||
rules []Rule
|
||||
// defaultAction applies when no rule matches.
|
||||
defaultAction Action
|
||||
log *log.Entry
|
||||
}
|
||||
|
||||
// NewRuleEngine creates a rule engine with the given default action.
|
||||
func NewRuleEngine(logger *log.Entry, defaultAction Action) *RuleEngine {
|
||||
return &RuleEngine{
|
||||
defaultAction: defaultAction,
|
||||
log: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateRules replaces the rule set and default action. Rules are sorted by priority.
|
||||
func (e *RuleEngine) UpdateRules(rules []Rule, defaultAction Action) {
|
||||
sorted := make([]Rule, len(rules))
|
||||
copy(sorted, rules)
|
||||
sort.Slice(sorted, func(i, j int) bool {
|
||||
return sorted[i].Priority < sorted[j].Priority
|
||||
})
|
||||
|
||||
e.mu.Lock()
|
||||
e.rules = sorted
|
||||
e.defaultAction = defaultAction
|
||||
e.mu.Unlock()
|
||||
}
|
||||
|
||||
// EvalResult holds the outcome of a rule evaluation.
|
||||
type EvalResult struct {
|
||||
Action Action
|
||||
RuleID id.RuleID
|
||||
}
|
||||
|
||||
// Evaluate determines the action for a connection based on the rule set.
|
||||
// Pass empty path for connection-level evaluation (TLS/SNI), non-empty for request-level (HTTP).
|
||||
func (e *RuleEngine) Evaluate(src netip.Addr, dstDomain domain.Domain, dstAddr netip.Addr, dstPort uint16, proto ProtoType, path string) Action {
|
||||
r := e.EvaluateWithResult(src, dstDomain, dstAddr, dstPort, proto, path)
|
||||
return r.Action
|
||||
}
|
||||
|
||||
// EvaluateWithResult is like Evaluate but also returns the matched rule ID.
|
||||
func (e *RuleEngine) EvaluateWithResult(src netip.Addr, dstDomain domain.Domain, dstAddr netip.Addr, dstPort uint16, proto ProtoType, path string) EvalResult {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
|
||||
for i := range e.rules {
|
||||
rule := &e.rules[i]
|
||||
if e.ruleMatches(rule, src, dstDomain, dstAddr, dstPort, proto, path) {
|
||||
e.log.Tracef("rule %s matched: action=%s src=%s domain=%s dst=%s:%d proto=%s path=%s",
|
||||
rule.ID, rule.Action, src, dstDomain.SafeString(), dstAddr, dstPort, proto, path)
|
||||
return EvalResult{Action: rule.Action, RuleID: rule.ID}
|
||||
}
|
||||
}
|
||||
|
||||
e.log.Tracef("no rule matched, default=%s: src=%s domain=%s dst=%s:%d proto=%s path=%s",
|
||||
e.defaultAction, src, dstDomain.SafeString(), dstAddr, dstPort, proto, path)
|
||||
return EvalResult{Action: e.defaultAction}
|
||||
}
|
||||
|
||||
// HasPathRulesForDomain returns true if any rule matching the domain has non-empty Paths.
|
||||
// Used to force MITM inspection when path-level rules exist (paths are only visible after decryption).
|
||||
func (e *RuleEngine) HasPathRulesForDomain(dstDomain domain.Domain) bool {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
|
||||
for i := range e.rules {
|
||||
if len(e.rules[i].Paths) > 0 && e.matchDomain(&e.rules[i], dstDomain) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ruleMatches checks whether all non-empty fields of a rule match.
|
||||
// Empty fields are treated as "match any".
|
||||
// All specified fields must match (AND logic).
|
||||
func (e *RuleEngine) ruleMatches(rule *Rule, src netip.Addr, dstDomain domain.Domain, dstAddr netip.Addr, dstPort uint16, proto ProtoType, path string) bool {
|
||||
if !e.matchSource(rule, src) {
|
||||
return false
|
||||
}
|
||||
|
||||
if !e.matchDomain(rule, dstDomain) {
|
||||
return false
|
||||
}
|
||||
|
||||
if !e.matchNetwork(rule, dstAddr) {
|
||||
return false
|
||||
}
|
||||
|
||||
if !e.matchPort(rule, dstPort) {
|
||||
return false
|
||||
}
|
||||
|
||||
if !e.matchProtocol(rule, proto) {
|
||||
return false
|
||||
}
|
||||
|
||||
if !e.matchPaths(rule, path) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// matchSource returns true if src matches any of the rule's source CIDRs,
|
||||
// or if no source CIDRs are specified (match any).
|
||||
func (e *RuleEngine) matchSource(rule *Rule, src netip.Addr) bool {
|
||||
if len(rule.Sources) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, prefix := range rule.Sources {
|
||||
if prefix.Contains(src) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// matchDomain returns true if dstDomain matches any of the rule's domain patterns,
|
||||
// or if no domain patterns are specified (match any).
|
||||
func (e *RuleEngine) matchDomain(rule *Rule, dstDomain domain.Domain) bool {
|
||||
if len(rule.Domains) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
// If we have domain rules but no domain to match against (e.g., raw IP connection),
|
||||
// the domain condition does not match.
|
||||
if dstDomain == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, pattern := range rule.Domains {
|
||||
if MatchDomain(pattern, dstDomain) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// matchNetwork returns true if dstAddr is within any of the rule's destination CIDRs,
|
||||
// or if no destination CIDRs are specified (match any).
|
||||
func (e *RuleEngine) matchNetwork(rule *Rule, dstAddr netip.Addr) bool {
|
||||
if len(rule.Networks) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, prefix := range rule.Networks {
|
||||
if prefix.Contains(dstAddr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// matchProtocol returns true if proto matches any of the rule's protocols,
|
||||
// or if no protocols are specified (match any).
|
||||
func (e *RuleEngine) matchProtocol(rule *Rule, proto ProtoType) bool {
|
||||
if len(rule.Protocols) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, p := range rule.Protocols {
|
||||
if p == proto {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// matchPort returns true if dstPort matches any of the rule's destination ports,
|
||||
// or if no ports are specified (match any).
|
||||
func (e *RuleEngine) matchPort(rule *Rule, dstPort uint16) bool {
|
||||
if len(rule.Ports) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
return slices.Contains(rule.Ports, dstPort)
|
||||
}
|
||||
|
||||
// matchPaths returns true if path matches any of the rule's path patterns,
|
||||
// or if no paths are specified (match any). Empty path (connection-level eval) matches all.
|
||||
func (e *RuleEngine) matchPaths(rule *Rule, path string) bool {
|
||||
if len(rule.Paths) == 0 {
|
||||
return true
|
||||
}
|
||||
// Connection-level (path=""): rules with paths don't match at connection level.
|
||||
// HasPathRulesForDomain forces the connection to inspect, so paths are
|
||||
// checked per-request once the HTTP request is visible.
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
for _, pattern := range rule.Paths {
|
||||
if matchPath(pattern, path) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// matchPath checks if a URL path matches a pattern.
|
||||
// Supports: exact ("/login"), prefix with wildcard ("/api/*"),
|
||||
// and contains ("*/admin/*"). A bare "*" matches everything.
|
||||
func matchPath(pattern, path string) bool {
|
||||
if pattern == "*" {
|
||||
return true
|
||||
}
|
||||
|
||||
hasLeadingStar := strings.HasPrefix(pattern, "*")
|
||||
hasTrailingStar := strings.HasSuffix(pattern, "*")
|
||||
|
||||
switch {
|
||||
case hasLeadingStar && hasTrailingStar:
|
||||
// */admin/* = contains
|
||||
middle := strings.Trim(pattern, "*")
|
||||
return strings.Contains(path, middle)
|
||||
case hasTrailingStar:
|
||||
// /api/* = prefix
|
||||
prefix := strings.TrimSuffix(pattern, "*")
|
||||
return strings.HasPrefix(path, prefix)
|
||||
case hasLeadingStar:
|
||||
// *.json = suffix
|
||||
suffix := strings.TrimPrefix(pattern, "*")
|
||||
return strings.HasSuffix(path, suffix)
|
||||
default:
|
||||
// exact
|
||||
return path == pattern
|
||||
}
|
||||
}
|
||||
338
client/inspect/rules_test.go
Normal file
338
client/inspect/rules_test.go
Normal file
@@ -0,0 +1,338 @@
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/acl/id"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
)
|
||||
|
||||
func testLogger() *log.Entry {
|
||||
return log.WithField("test", true)
|
||||
}
|
||||
|
||||
func mustDomain(t *testing.T, s string) domain.Domain {
|
||||
t.Helper()
|
||||
d, err := domain.FromString(s)
|
||||
require.NoError(t, err)
|
||||
return d
|
||||
}
|
||||
|
||||
func TestRuleEngine_Evaluate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rules []Rule
|
||||
defaultAction Action
|
||||
src netip.Addr
|
||||
dstDomain domain.Domain
|
||||
dstAddr netip.Addr
|
||||
dstPort uint16
|
||||
want Action
|
||||
}{
|
||||
{
|
||||
name: "no rules returns default allow",
|
||||
defaultAction: ActionAllow,
|
||||
src: netip.MustParseAddr("10.0.0.1"),
|
||||
dstAddr: netip.MustParseAddr("1.2.3.4"),
|
||||
dstPort: 443,
|
||||
want: ActionAllow,
|
||||
},
|
||||
{
|
||||
name: "no rules returns default block",
|
||||
defaultAction: ActionBlock,
|
||||
src: netip.MustParseAddr("10.0.0.1"),
|
||||
dstAddr: netip.MustParseAddr("1.2.3.4"),
|
||||
dstPort: 443,
|
||||
want: ActionBlock,
|
||||
},
|
||||
{
|
||||
name: "domain exact match blocks",
|
||||
defaultAction: ActionAllow,
|
||||
rules: []Rule{
|
||||
{
|
||||
ID: id.RuleID("r1"),
|
||||
Domains: []domain.Domain{mustDomain(t, "malware.example.com")},
|
||||
Action: ActionBlock,
|
||||
},
|
||||
},
|
||||
src: netip.MustParseAddr("10.0.0.1"),
|
||||
dstDomain: mustDomain(t, "malware.example.com"),
|
||||
dstAddr: netip.MustParseAddr("1.2.3.4"),
|
||||
dstPort: 443,
|
||||
want: ActionBlock,
|
||||
},
|
||||
{
|
||||
name: "domain wildcard match blocks",
|
||||
defaultAction: ActionAllow,
|
||||
rules: []Rule{
|
||||
{
|
||||
ID: id.RuleID("r1"),
|
||||
Domains: []domain.Domain{mustDomain(t, "*.evil.com")},
|
||||
Action: ActionBlock,
|
||||
},
|
||||
},
|
||||
src: netip.MustParseAddr("10.0.0.1"),
|
||||
dstDomain: mustDomain(t, "phishing.evil.com"),
|
||||
dstAddr: netip.MustParseAddr("1.2.3.4"),
|
||||
dstPort: 443,
|
||||
want: ActionBlock,
|
||||
},
|
||||
{
|
||||
name: "domain wildcard does not match base",
|
||||
defaultAction: ActionAllow,
|
||||
rules: []Rule{
|
||||
{
|
||||
ID: id.RuleID("r1"),
|
||||
Domains: []domain.Domain{mustDomain(t, "*.evil.com")},
|
||||
Action: ActionBlock,
|
||||
},
|
||||
},
|
||||
src: netip.MustParseAddr("10.0.0.1"),
|
||||
dstDomain: mustDomain(t, "evil.com"),
|
||||
dstAddr: netip.MustParseAddr("1.2.3.4"),
|
||||
dstPort: 443,
|
||||
want: ActionAllow,
|
||||
},
|
||||
{
|
||||
name: "case insensitive domain match",
|
||||
defaultAction: ActionAllow,
|
||||
rules: []Rule{
|
||||
{
|
||||
ID: id.RuleID("r1"),
|
||||
Domains: []domain.Domain{mustDomain(t, "Example.COM")},
|
||||
Action: ActionBlock,
|
||||
},
|
||||
},
|
||||
src: netip.MustParseAddr("10.0.0.1"),
|
||||
dstDomain: mustDomain(t, "EXAMPLE.com"),
|
||||
dstAddr: netip.MustParseAddr("1.2.3.4"),
|
||||
dstPort: 443,
|
||||
want: ActionBlock,
|
||||
},
|
||||
{
|
||||
name: "source CIDR match",
|
||||
defaultAction: ActionAllow,
|
||||
rules: []Rule{
|
||||
{
|
||||
ID: id.RuleID("r1"),
|
||||
Sources: []netip.Prefix{netip.MustParsePrefix("192.168.1.0/24")},
|
||||
Action: ActionInspect,
|
||||
},
|
||||
},
|
||||
src: netip.MustParseAddr("192.168.1.50"),
|
||||
dstAddr: netip.MustParseAddr("1.2.3.4"),
|
||||
dstPort: 443,
|
||||
want: ActionInspect,
|
||||
},
|
||||
{
|
||||
name: "source CIDR no match",
|
||||
defaultAction: ActionAllow,
|
||||
rules: []Rule{
|
||||
{
|
||||
ID: id.RuleID("r1"),
|
||||
Sources: []netip.Prefix{netip.MustParsePrefix("192.168.1.0/24")},
|
||||
Action: ActionBlock,
|
||||
},
|
||||
},
|
||||
src: netip.MustParseAddr("10.0.0.5"),
|
||||
dstAddr: netip.MustParseAddr("1.2.3.4"),
|
||||
dstPort: 443,
|
||||
want: ActionAllow,
|
||||
},
|
||||
{
|
||||
name: "destination network match",
|
||||
defaultAction: ActionAllow,
|
||||
rules: []Rule{
|
||||
{
|
||||
ID: id.RuleID("r1"),
|
||||
Networks: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")},
|
||||
Action: ActionInspect,
|
||||
},
|
||||
},
|
||||
src: netip.MustParseAddr("192.168.1.1"),
|
||||
dstAddr: netip.MustParseAddr("10.50.0.1"),
|
||||
dstPort: 80,
|
||||
want: ActionInspect,
|
||||
},
|
||||
{
|
||||
name: "port match",
|
||||
defaultAction: ActionAllow,
|
||||
rules: []Rule{
|
||||
{
|
||||
ID: id.RuleID("r1"),
|
||||
Ports: []uint16{443, 8443},
|
||||
Action: ActionInspect,
|
||||
},
|
||||
},
|
||||
src: netip.MustParseAddr("10.0.0.1"),
|
||||
dstAddr: netip.MustParseAddr("1.2.3.4"),
|
||||
dstPort: 443,
|
||||
want: ActionInspect,
|
||||
},
|
||||
{
|
||||
name: "port no match",
|
||||
defaultAction: ActionAllow,
|
||||
rules: []Rule{
|
||||
{
|
||||
ID: id.RuleID("r1"),
|
||||
Ports: []uint16{443, 8443},
|
||||
Action: ActionBlock,
|
||||
},
|
||||
},
|
||||
src: netip.MustParseAddr("10.0.0.1"),
|
||||
dstAddr: netip.MustParseAddr("1.2.3.4"),
|
||||
dstPort: 22,
|
||||
want: ActionAllow,
|
||||
},
|
||||
{
|
||||
name: "priority ordering first match wins",
|
||||
defaultAction: ActionAllow,
|
||||
rules: []Rule{
|
||||
{
|
||||
ID: id.RuleID("allow-internal"),
|
||||
Domains: []domain.Domain{mustDomain(t, "*.internal.corp")},
|
||||
Action: ActionAllow,
|
||||
Priority: 1,
|
||||
},
|
||||
{
|
||||
ID: id.RuleID("inspect-all"),
|
||||
Action: ActionInspect,
|
||||
Priority: 10,
|
||||
},
|
||||
},
|
||||
src: netip.MustParseAddr("10.0.0.1"),
|
||||
dstDomain: mustDomain(t, "api.internal.corp"),
|
||||
dstAddr: netip.MustParseAddr("10.1.0.5"),
|
||||
dstPort: 443,
|
||||
want: ActionAllow,
|
||||
},
|
||||
{
|
||||
name: "all fields must match (AND logic)",
|
||||
defaultAction: ActionAllow,
|
||||
rules: []Rule{
|
||||
{
|
||||
ID: id.RuleID("r1"),
|
||||
Sources: []netip.Prefix{netip.MustParsePrefix("192.168.1.0/24")},
|
||||
Domains: []domain.Domain{mustDomain(t, "*.evil.com")},
|
||||
Ports: []uint16{443},
|
||||
Action: ActionBlock,
|
||||
},
|
||||
},
|
||||
// Source matches, domain matches, but port doesn't
|
||||
src: netip.MustParseAddr("192.168.1.10"),
|
||||
dstDomain: mustDomain(t, "phish.evil.com"),
|
||||
dstAddr: netip.MustParseAddr("1.2.3.4"),
|
||||
dstPort: 8080,
|
||||
want: ActionAllow,
|
||||
},
|
||||
{
|
||||
name: "empty domain with domain rule does not match",
|
||||
defaultAction: ActionAllow,
|
||||
rules: []Rule{
|
||||
{
|
||||
ID: id.RuleID("r1"),
|
||||
Domains: []domain.Domain{mustDomain(t, "example.com")},
|
||||
Action: ActionBlock,
|
||||
},
|
||||
},
|
||||
src: netip.MustParseAddr("10.0.0.1"),
|
||||
dstDomain: "", // raw IP connection, no SNI
|
||||
dstAddr: netip.MustParseAddr("1.2.3.4"),
|
||||
dstPort: 443,
|
||||
want: ActionAllow,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
engine := NewRuleEngine(testLogger(), tt.defaultAction)
|
||||
engine.UpdateRules(tt.rules, tt.defaultAction)
|
||||
|
||||
got := engine.Evaluate(tt.src, tt.dstDomain, tt.dstAddr, tt.dstPort, "", "")
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleEngine_ProtocolMatching(t *testing.T) {
|
||||
engine := NewRuleEngine(testLogger(), ActionAllow)
|
||||
engine.UpdateRules([]Rule{
|
||||
{
|
||||
ID: "block-websocket",
|
||||
Protocols: []ProtoType{ProtoWebSocket},
|
||||
Action: ActionBlock,
|
||||
Priority: 1,
|
||||
},
|
||||
{
|
||||
ID: "inspect-h2",
|
||||
Protocols: []ProtoType{ProtoH2},
|
||||
Action: ActionInspect,
|
||||
Priority: 2,
|
||||
},
|
||||
}, ActionAllow)
|
||||
|
||||
src := netip.MustParseAddr("10.0.0.1")
|
||||
dst := netip.MustParseAddr("1.2.3.4")
|
||||
|
||||
// WebSocket: blocked by rule
|
||||
assert.Equal(t, ActionBlock, engine.Evaluate(src, "", dst, 443, ProtoWebSocket, ""))
|
||||
|
||||
// HTTP/2: inspected by rule
|
||||
assert.Equal(t, ActionInspect, engine.Evaluate(src, "", dst, 443, ProtoH2, ""))
|
||||
|
||||
// Plain HTTP: no protocol rule matches, default allow
|
||||
assert.Equal(t, ActionAllow, engine.Evaluate(src, "", dst, 80, ProtoHTTP, ""))
|
||||
|
||||
// HTTPS: no protocol rule matches, default allow
|
||||
assert.Equal(t, ActionAllow, engine.Evaluate(src, "", dst, 443, ProtoHTTPS, ""))
|
||||
|
||||
// QUIC/H3: no protocol rule matches, default allow
|
||||
assert.Equal(t, ActionAllow, engine.Evaluate(src, "", dst, 443, ProtoH3, ""))
|
||||
|
||||
// Empty protocol (unknown): no protocol rule matches, default allow
|
||||
assert.Equal(t, ActionAllow, engine.Evaluate(src, "", dst, 443, "", ""))
|
||||
}
|
||||
|
||||
func TestRuleEngine_EmptyProtocolsMatchAll(t *testing.T) {
|
||||
engine := NewRuleEngine(testLogger(), ActionAllow)
|
||||
engine.UpdateRules([]Rule{
|
||||
{
|
||||
ID: "block-all-protos",
|
||||
Action: ActionBlock,
|
||||
// No Protocols field = match all protocols
|
||||
Priority: 1,
|
||||
},
|
||||
}, ActionAllow)
|
||||
|
||||
src := netip.MustParseAddr("10.0.0.1")
|
||||
dst := netip.MustParseAddr("1.2.3.4")
|
||||
|
||||
assert.Equal(t, ActionBlock, engine.Evaluate(src, "", dst, 443, ProtoHTTP, ""))
|
||||
assert.Equal(t, ActionBlock, engine.Evaluate(src, "", dst, 443, ProtoHTTPS, ""))
|
||||
assert.Equal(t, ActionBlock, engine.Evaluate(src, "", dst, 443, ProtoWebSocket, ""))
|
||||
assert.Equal(t, ActionBlock, engine.Evaluate(src, "", dst, 443, ProtoH2, ""))
|
||||
assert.Equal(t, ActionBlock, engine.Evaluate(src, "", dst, 443, "", ""))
|
||||
}
|
||||
|
||||
func TestRuleEngine_UpdateRulesSortsByPriority(t *testing.T) {
|
||||
engine := NewRuleEngine(testLogger(), ActionAllow)
|
||||
|
||||
engine.UpdateRules([]Rule{
|
||||
{ID: "c", Priority: 30, Action: ActionBlock},
|
||||
{ID: "a", Priority: 10, Action: ActionInspect},
|
||||
{ID: "b", Priority: 20, Action: ActionAllow},
|
||||
}, ActionAllow)
|
||||
|
||||
engine.mu.RLock()
|
||||
defer engine.mu.RUnlock()
|
||||
|
||||
require.Len(t, engine.rules, 3)
|
||||
assert.Equal(t, id.RuleID("a"), engine.rules[0].ID)
|
||||
assert.Equal(t, id.RuleID("b"), engine.rules[1].ID)
|
||||
assert.Equal(t, id.RuleID("c"), engine.rules[2].ID)
|
||||
}
|
||||
287
client/inspect/sni.go
Normal file
287
client/inspect/sni.go
Normal file
@@ -0,0 +1,287 @@
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
recordTypeHandshake = 0x16
|
||||
handshakeTypeClientHello = 0x01
|
||||
extensionTypeSNI = 0x0000
|
||||
extensionTypeALPN = 0x0010
|
||||
sniTypeHostName = 0x00
|
||||
|
||||
// maxClientHelloSize is the maximum ClientHello size we'll read.
|
||||
// Real-world ClientHellos are typically under 1KB but can reach ~16KB with
|
||||
// many extensions (post-quantum key shares, etc.).
|
||||
maxClientHelloSize = 16384
|
||||
)
|
||||
|
||||
// ClientHelloInfo holds data extracted from a TLS ClientHello.
|
||||
type ClientHelloInfo struct {
|
||||
SNI domain.Domain
|
||||
ALPN []string
|
||||
}
|
||||
|
||||
// isTLSHandshake reports whether the first byte indicates a TLS handshake record.
|
||||
func isTLSHandshake(b byte) bool {
|
||||
return b == recordTypeHandshake
|
||||
}
|
||||
|
||||
// httpMethods lists the first bytes of valid HTTP method tokens.
|
||||
var httpMethods = [][]byte{
|
||||
[]byte("GET "),
|
||||
[]byte("POST"),
|
||||
[]byte("PUT "),
|
||||
[]byte("DELE"),
|
||||
[]byte("HEAD"),
|
||||
[]byte("OPTI"),
|
||||
[]byte("PATC"),
|
||||
[]byte("CONN"),
|
||||
[]byte("TRAC"),
|
||||
}
|
||||
|
||||
// isHTTPMethod reports whether the peeked bytes look like the start of an HTTP request.
|
||||
func isHTTPMethod(b []byte) bool {
|
||||
if len(b) < 4 {
|
||||
return false
|
||||
}
|
||||
for _, m := range httpMethods {
|
||||
if b[0] == m[0] && b[1] == m[1] && b[2] == m[2] && b[3] == m[3] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// parseClientHello reads a TLS ClientHello from r and returns SNI and ALPN.
|
||||
func parseClientHello(r io.Reader) (ClientHelloInfo, error) {
|
||||
// TLS record header: type(1) + version(2) + length(2)
|
||||
var recordHeader [5]byte
|
||||
if _, err := io.ReadFull(r, recordHeader[:]); err != nil {
|
||||
return ClientHelloInfo{}, fmt.Errorf("read TLS record header: %w", err)
|
||||
}
|
||||
|
||||
if recordHeader[0] != recordTypeHandshake {
|
||||
return ClientHelloInfo{}, fmt.Errorf("not a TLS handshake record (type=%d)", recordHeader[0])
|
||||
}
|
||||
|
||||
recordLen := int(binary.BigEndian.Uint16(recordHeader[3:5]))
|
||||
if recordLen < 4 || recordLen > maxClientHelloSize {
|
||||
return ClientHelloInfo{}, fmt.Errorf("invalid TLS record length: %d", recordLen)
|
||||
}
|
||||
|
||||
// Read the full handshake message
|
||||
msg := make([]byte, recordLen)
|
||||
if _, err := io.ReadFull(r, msg); err != nil {
|
||||
return ClientHelloInfo{}, fmt.Errorf("read handshake message: %w", err)
|
||||
}
|
||||
|
||||
return parseClientHelloMsg(msg)
|
||||
}
|
||||
|
||||
// extractSNI reads a TLS ClientHello from r and returns the SNI hostname.
|
||||
// Returns empty domain if no SNI extension is present.
|
||||
func extractSNI(r io.Reader) (domain.Domain, error) {
|
||||
info, err := parseClientHello(r)
|
||||
return info.SNI, err
|
||||
}
|
||||
|
||||
// extractSNIFromBytes parses SNI from raw bytes that start with the TLS record header.
|
||||
func extractSNIFromBytes(data []byte) (domain.Domain, error) {
|
||||
info, err := parseClientHelloFromBytes(data)
|
||||
return info.SNI, err
|
||||
}
|
||||
|
||||
// parseClientHelloFromBytes parses a ClientHello from raw bytes starting with the TLS record header.
|
||||
func parseClientHelloFromBytes(data []byte) (ClientHelloInfo, error) {
|
||||
if len(data) < 5 {
|
||||
return ClientHelloInfo{}, fmt.Errorf("data too short for TLS record header")
|
||||
}
|
||||
|
||||
if data[0] != recordTypeHandshake {
|
||||
return ClientHelloInfo{}, fmt.Errorf("not a TLS handshake record (type=%d)", data[0])
|
||||
}
|
||||
|
||||
recordLen := int(binary.BigEndian.Uint16(data[3:5]))
|
||||
if recordLen < 4 {
|
||||
return ClientHelloInfo{}, fmt.Errorf("invalid TLS record length: %d", recordLen)
|
||||
}
|
||||
|
||||
end := 5 + recordLen
|
||||
if end > len(data) {
|
||||
return ClientHelloInfo{}, fmt.Errorf("TLS record truncated: need %d, have %d", end, len(data))
|
||||
}
|
||||
|
||||
return parseClientHelloMsg(data[5:end])
|
||||
}
|
||||
|
||||
// parseClientHelloMsg extracts SNI and ALPN from a raw ClientHello handshake message.
|
||||
// msg starts at the handshake type byte.
|
||||
func parseClientHelloMsg(msg []byte) (ClientHelloInfo, error) {
|
||||
if len(msg) < 4 {
|
||||
return ClientHelloInfo{}, fmt.Errorf("handshake message too short")
|
||||
}
|
||||
|
||||
if msg[0] != handshakeTypeClientHello {
|
||||
return ClientHelloInfo{}, fmt.Errorf("not a ClientHello (type=%d)", msg[0])
|
||||
}
|
||||
|
||||
// Handshake header: type(1) + length(3)
|
||||
helloLen := int(msg[1])<<16 | int(msg[2])<<8 | int(msg[3])
|
||||
if helloLen+4 > len(msg) {
|
||||
return ClientHelloInfo{}, fmt.Errorf("ClientHello truncated")
|
||||
}
|
||||
|
||||
hello := msg[4 : 4+helloLen]
|
||||
return parseHelloBody(hello)
|
||||
}
|
||||
|
||||
// parseHelloBody parses the ClientHello body (after handshake header)
|
||||
// and extracts SNI and ALPN.
|
||||
func parseHelloBody(hello []byte) (ClientHelloInfo, error) {
|
||||
// ClientHello structure:
|
||||
// version(2) + random(32) + session_id_len(1) + session_id(var)
|
||||
// + cipher_suites_len(2) + cipher_suites(var)
|
||||
// + compression_len(1) + compression(var)
|
||||
// + extensions_len(2) + extensions(var)
|
||||
|
||||
var info ClientHelloInfo
|
||||
|
||||
if len(hello) < 35 {
|
||||
return info, fmt.Errorf("ClientHello body too short")
|
||||
}
|
||||
|
||||
pos := 2 + 32 // skip version + random
|
||||
|
||||
// Skip session ID
|
||||
if pos >= len(hello) {
|
||||
return info, fmt.Errorf("ClientHello truncated at session ID")
|
||||
}
|
||||
sessionIDLen := int(hello[pos])
|
||||
pos += 1 + sessionIDLen
|
||||
|
||||
// Skip cipher suites
|
||||
if pos+2 > len(hello) {
|
||||
return info, fmt.Errorf("ClientHello truncated at cipher suites")
|
||||
}
|
||||
cipherLen := int(binary.BigEndian.Uint16(hello[pos : pos+2]))
|
||||
pos += 2 + cipherLen
|
||||
|
||||
// Skip compression methods
|
||||
if pos >= len(hello) {
|
||||
return info, fmt.Errorf("ClientHello truncated at compression")
|
||||
}
|
||||
compLen := int(hello[pos])
|
||||
pos += 1 + compLen
|
||||
|
||||
// Extensions
|
||||
if pos+2 > len(hello) {
|
||||
return info, nil
|
||||
}
|
||||
|
||||
extLen := int(binary.BigEndian.Uint16(hello[pos : pos+2]))
|
||||
pos += 2
|
||||
|
||||
extEnd := pos + extLen
|
||||
if extEnd > len(hello) {
|
||||
return info, fmt.Errorf("extensions block truncated")
|
||||
}
|
||||
|
||||
// Walk extensions looking for SNI and ALPN
|
||||
for pos+4 <= extEnd {
|
||||
extType := binary.BigEndian.Uint16(hello[pos : pos+2])
|
||||
extDataLen := int(binary.BigEndian.Uint16(hello[pos+2 : pos+4]))
|
||||
pos += 4
|
||||
|
||||
if pos+extDataLen > extEnd {
|
||||
return info, fmt.Errorf("extension data truncated")
|
||||
}
|
||||
|
||||
switch extType {
|
||||
case extensionTypeSNI:
|
||||
sni, err := parseSNIExtension(hello[pos : pos+extDataLen])
|
||||
if err != nil {
|
||||
return info, err
|
||||
}
|
||||
info.SNI = sni
|
||||
case extensionTypeALPN:
|
||||
info.ALPN = parseALPNExtension(hello[pos : pos+extDataLen])
|
||||
}
|
||||
|
||||
pos += extDataLen
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// parseALPNExtension parses the ALPN extension data and returns protocol names.
|
||||
// ALPN extension: list_length(2) + entries (each: len(1) + protocol_name(var))
|
||||
func parseALPNExtension(data []byte) []string {
|
||||
if len(data) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
listLen := int(binary.BigEndian.Uint16(data[0:2]))
|
||||
if listLen+2 > len(data) {
|
||||
return nil
|
||||
}
|
||||
|
||||
var protocols []string
|
||||
pos := 2
|
||||
end := 2 + listLen
|
||||
|
||||
for pos < end {
|
||||
if pos >= len(data) {
|
||||
break
|
||||
}
|
||||
nameLen := int(data[pos])
|
||||
pos++
|
||||
if pos+nameLen > end {
|
||||
break
|
||||
}
|
||||
protocols = append(protocols, string(data[pos:pos+nameLen]))
|
||||
pos += nameLen
|
||||
}
|
||||
|
||||
return protocols
|
||||
}
|
||||
|
||||
// parseSNIExtension parses the SNI extension data and returns the hostname.
|
||||
func parseSNIExtension(data []byte) (domain.Domain, error) {
|
||||
// SNI extension: list_length(2) + entries
|
||||
if len(data) < 2 {
|
||||
return "", fmt.Errorf("SNI extension too short")
|
||||
}
|
||||
|
||||
listLen := int(binary.BigEndian.Uint16(data[0:2]))
|
||||
if listLen+2 > len(data) {
|
||||
return "", fmt.Errorf("SNI list truncated")
|
||||
}
|
||||
|
||||
pos := 2
|
||||
end := 2 + listLen
|
||||
|
||||
for pos+3 <= end {
|
||||
nameType := data[pos]
|
||||
nameLen := int(binary.BigEndian.Uint16(data[pos+1 : pos+3]))
|
||||
pos += 3
|
||||
|
||||
if pos+nameLen > end {
|
||||
return "", fmt.Errorf("SNI name truncated")
|
||||
}
|
||||
|
||||
if nameType == sniTypeHostName {
|
||||
hostname := string(data[pos : pos+nameLen])
|
||||
return domain.FromString(hostname)
|
||||
}
|
||||
|
||||
pos += nameLen
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
109
client/inspect/sni_test.go
Normal file
109
client/inspect/sni_test.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestExtractSNI(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sni string
|
||||
wantSNI string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "standard domain",
|
||||
sni: "example.com",
|
||||
wantSNI: "example.com",
|
||||
},
|
||||
{
|
||||
name: "subdomain",
|
||||
sni: "api.staging.example.com",
|
||||
wantSNI: "api.staging.example.com",
|
||||
},
|
||||
{
|
||||
name: "mixed case normalized to lowercase",
|
||||
sni: "Example.COM",
|
||||
wantSNI: "example.com",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
clientHello := buildClientHello(t, tt.sni)
|
||||
|
||||
sni, err := extractSNI(bytes.NewReader(clientHello))
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantSNI, sni.PunycodeString())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractSNI_NotTLS(t *testing.T) {
|
||||
// HTTP request instead of TLS
|
||||
data := []byte("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n")
|
||||
_, err := extractSNI(bytes.NewReader(data))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not a TLS handshake")
|
||||
}
|
||||
|
||||
func TestExtractSNI_Truncated(t *testing.T) {
|
||||
// Just the record header, no body
|
||||
data := []byte{0x16, 0x03, 0x01, 0x00, 0x05}
|
||||
_, err := extractSNI(bytes.NewReader(data))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestExtractSNIFromBytes(t *testing.T) {
|
||||
clientHello := buildClientHello(t, "test.example.com")
|
||||
|
||||
sni, err := extractSNIFromBytes(clientHello)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "test.example.com", sni.PunycodeString())
|
||||
}
|
||||
|
||||
// buildClientHello generates a real TLS ClientHello with the given SNI.
|
||||
func buildClientHello(t *testing.T, serverName string) []byte {
|
||||
t.Helper()
|
||||
|
||||
// Use a pipe to capture the ClientHello bytes
|
||||
clientConn, serverConn := net.Pipe()
|
||||
|
||||
done := make(chan []byte, 1)
|
||||
go func() {
|
||||
buf := make([]byte, 4096)
|
||||
n, _ := serverConn.Read(buf)
|
||||
done <- buf[:n]
|
||||
serverConn.Close()
|
||||
}()
|
||||
|
||||
tlsConn := tls.Client(clientConn, &tls.Config{
|
||||
ServerName: serverName,
|
||||
InsecureSkipVerify: true,
|
||||
})
|
||||
|
||||
// Trigger the handshake (will fail since server isn't TLS, but we capture the ClientHello)
|
||||
go func() {
|
||||
_ = tlsConn.Handshake()
|
||||
tlsConn.Close()
|
||||
}()
|
||||
|
||||
clientHello := <-done
|
||||
clientConn.Close()
|
||||
|
||||
require.True(t, len(clientHello) > 5, "ClientHello too short")
|
||||
require.Equal(t, byte(0x16), clientHello[0], "not a TLS handshake record")
|
||||
|
||||
return clientHello
|
||||
}
|
||||
287
client/inspect/tls.go
Normal file
287
client/inspect/tls.go
Normal file
@@ -0,0 +1,287 @@
|
||||
package inspect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/netip"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
)
|
||||
|
||||
// handleTLS processes a TLS connection for the kernel-mode path: extracts SNI,
|
||||
// evaluates rules, and handles the connection internally.
|
||||
// In envoy mode, allowed connections are forwarded to envoy instead of direct relay.
|
||||
func (p *Proxy) handleTLS(ctx context.Context, pconn *peekConn, dst netip.AddrPort, src SourceInfo) error {
|
||||
result, err := p.inspectTLS(ctx, pconn, dst, src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result.PassthroughConn != nil {
|
||||
p.mu.RLock()
|
||||
envoy := p.envoy
|
||||
p.mu.RUnlock()
|
||||
|
||||
if envoy != nil {
|
||||
return p.forwardToEnvoy(ctx, pconn, dst, src, envoy)
|
||||
}
|
||||
return p.tlsPassthrough(ctx, pconn, dst, "")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// inspectTLS extracts SNI, evaluates rules, and returns the result.
|
||||
// For ActionAllow: returns the peekConn as PassthroughConn (caller relays).
|
||||
// For ActionBlock/ActionInspect: handles internally and returns nil PassthroughConn.
|
||||
func (p *Proxy) inspectTLS(ctx context.Context, pconn *peekConn, dst netip.AddrPort, src SourceInfo) (InspectResult, error) {
|
||||
// The first 5 bytes (TLS record header) are already peeked.
|
||||
// Extend to read the full TLS record so bytes remain in the buffer for passthrough.
|
||||
peeked := pconn.Peeked()
|
||||
recordLen := int(peeked[3])<<8 | int(peeked[4])
|
||||
if _, err := pconn.PeekMore(5 + recordLen); err != nil {
|
||||
return InspectResult{}, fmt.Errorf("read TLS record: %w", err)
|
||||
}
|
||||
|
||||
hello, err := parseClientHelloFromBytes(pconn.Peeked())
|
||||
if err != nil {
|
||||
return InspectResult{}, fmt.Errorf("parse ClientHello: %w", err)
|
||||
}
|
||||
|
||||
sni := hello.SNI
|
||||
proto := protoFromALPN(hello.ALPN)
|
||||
// Connection-level evaluation: pass empty path.
|
||||
action := p.evaluateAction(src.IP, sni, dst, proto, "")
|
||||
|
||||
// If any rule for this domain has path patterns, force inspect so paths can
|
||||
// be checked per-request after MITM decryption.
|
||||
if action == ActionAllow && p.rules.HasPathRulesForDomain(sni) {
|
||||
p.log.Debugf("upgrading to inspect for %s (path rules exist)", sni.PunycodeString())
|
||||
action = ActionInspect
|
||||
}
|
||||
|
||||
// Snapshot cert provider under lock for use in this connection.
|
||||
p.mu.RLock()
|
||||
certs := p.certs
|
||||
p.mu.RUnlock()
|
||||
|
||||
switch action {
|
||||
case ActionBlock:
|
||||
p.log.Debugf("block: TLS to %s (SNI=%s)", dst, sni.PunycodeString())
|
||||
if certs != nil {
|
||||
return InspectResult{Action: ActionBlock}, p.tlsBlockPage(ctx, pconn, sni, certs)
|
||||
}
|
||||
return InspectResult{Action: ActionBlock}, ErrBlocked
|
||||
|
||||
case ActionAllow:
|
||||
p.log.Tracef("allow: TLS passthrough to %s (SNI=%s)", dst, sni.PunycodeString())
|
||||
return InspectResult{Action: ActionAllow, PassthroughConn: pconn}, nil
|
||||
|
||||
case ActionInspect:
|
||||
if certs == nil {
|
||||
p.log.Warnf("allow: %s (inspect requested but no MITM CA configured)", sni.PunycodeString())
|
||||
return InspectResult{Action: ActionAllow, PassthroughConn: pconn}, nil
|
||||
}
|
||||
err := p.tlsMITM(ctx, pconn, dst, sni, src, certs)
|
||||
return InspectResult{Action: ActionInspect}, err
|
||||
|
||||
default:
|
||||
p.log.Warnf("block: unknown action %q for %s", action, sni.PunycodeString())
|
||||
return InspectResult{Action: ActionBlock}, ErrBlocked
|
||||
}
|
||||
}
|
||||
|
||||
// tlsBlockPage completes a MITM TLS handshake with the client using a dynamic
|
||||
// certificate, then serves an HTTP 403 block page so the user sees a clear
|
||||
// message instead of a cryptic SSL error.
|
||||
func (p *Proxy) tlsBlockPage(ctx context.Context, pconn *peekConn, sni domain.Domain, certs *CertProvider) error {
|
||||
hostname := sni.PunycodeString()
|
||||
|
||||
// Force HTTP/1.1 only: block pages are simple responses, no need for h2
|
||||
tlsCfg := certs.GetTLSConfig()
|
||||
tlsCfg.NextProtos = []string{"http/1.1"}
|
||||
clientTLS := tls.Server(pconn, tlsCfg)
|
||||
if err := clientTLS.HandshakeContext(ctx); err != nil {
|
||||
// Client may not trust our CA, handshake fails. That's expected.
|
||||
return fmt.Errorf("block page TLS handshake for %s: %w", hostname, err)
|
||||
}
|
||||
defer func() {
|
||||
if err := clientTLS.Close(); err != nil {
|
||||
p.log.Debugf("close block page TLS for %s: %v", hostname, err)
|
||||
}
|
||||
}()
|
||||
|
||||
writeBlockResponse(clientTLS, nil, sni)
|
||||
return ErrBlocked
|
||||
}
|
||||
|
||||
// tlsPassthrough connects to the destination and relays encrypted traffic
|
||||
// without decryption. The peeked ClientHello bytes are replayed.
|
||||
func (p *Proxy) tlsPassthrough(ctx context.Context, pconn *peekConn, dst netip.AddrPort, sni domain.Domain) error {
|
||||
remote, err := p.dialTCP(ctx, dst)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial %s: %w", dst, err)
|
||||
}
|
||||
defer func() {
|
||||
if err := remote.Close(); err != nil {
|
||||
p.log.Debugf("close remote for %s: %v", dst, err)
|
||||
}
|
||||
}()
|
||||
|
||||
p.log.Tracef("allow: TLS passthrough to %s (SNI=%s)", dst, sni.PunycodeString())
|
||||
|
||||
return relay(ctx, pconn, remote)
|
||||
}
|
||||
|
||||
// tlsMITM terminates the client TLS connection with a dynamic certificate,
|
||||
// establishes a new TLS connection to the real destination, and runs the
|
||||
// HTTP inspection pipeline on the decrypted traffic.
|
||||
func (p *Proxy) tlsMITM(ctx context.Context, pconn *peekConn, dst netip.AddrPort, sni domain.Domain, src SourceInfo, certs *CertProvider) error {
|
||||
hostname := sni.PunycodeString()
|
||||
|
||||
// TLS handshake with client using dynamic cert
|
||||
clientTLS := tls.Server(pconn, certs.GetTLSConfig())
|
||||
if err := clientTLS.HandshakeContext(ctx); err != nil {
|
||||
return fmt.Errorf("client TLS handshake for %s: %w", hostname, err)
|
||||
}
|
||||
defer func() {
|
||||
if err := clientTLS.Close(); err != nil {
|
||||
p.log.Debugf("close client TLS for %s: %v", hostname, err)
|
||||
}
|
||||
}()
|
||||
|
||||
// TLS connection to real destination
|
||||
remoteTLS, err := p.dialTLS(ctx, dst, hostname)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial TLS %s (%s): %w", dst, hostname, err)
|
||||
}
|
||||
defer func() {
|
||||
if err := remoteTLS.Close(); err != nil {
|
||||
p.log.Debugf("close remote TLS for %s: %v", hostname, err)
|
||||
}
|
||||
}()
|
||||
|
||||
negotiatedProto := clientTLS.ConnectionState().NegotiatedProtocol
|
||||
p.log.Tracef("inspect: MITM established for %s (proto=%s)", hostname, negotiatedProto)
|
||||
|
||||
return p.inspectHTTP(ctx, clientTLS, remoteTLS, dst, sni, src, negotiatedProto)
|
||||
}
|
||||
|
||||
// dialTLS connects to the destination with TLS, verifying the real server certificate.
|
||||
func (p *Proxy) dialTLS(ctx context.Context, dst netip.AddrPort, serverName string) (net.Conn, error) {
|
||||
rawConn, err := p.dialTCP(ctx, dst)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tlsConn := tls.Client(rawConn, &tls.Config{
|
||||
ServerName: serverName,
|
||||
NextProtos: []string{"h2", "http/1.1"},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
})
|
||||
|
||||
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
||||
if closeErr := rawConn.Close(); closeErr != nil {
|
||||
p.log.Debugf("close raw conn after TLS handshake failure: %v", closeErr)
|
||||
}
|
||||
return nil, fmt.Errorf("TLS handshake with %s: %w", serverName, err)
|
||||
}
|
||||
|
||||
return tlsConn, nil
|
||||
}
|
||||
|
||||
// protoFromALPN maps TLS ALPN protocol names to proxy ProtoType.
|
||||
// Falls back to ProtoHTTPS when no recognized ALPN is present.
|
||||
func protoFromALPN(alpn []string) ProtoType {
|
||||
for _, p := range alpn {
|
||||
switch p {
|
||||
case "h2":
|
||||
return ProtoH2
|
||||
case "h3": // unlikely in TLS, but handle anyway
|
||||
return ProtoH3
|
||||
}
|
||||
}
|
||||
// No ALPN or only "http/1.1": treat as HTTPS
|
||||
return ProtoHTTPS
|
||||
}
|
||||
|
||||
// relay copies data bidirectionally between client and remote until one
|
||||
// side closes or the context is cancelled.
|
||||
func relay(ctx context.Context, client, remote net.Conn) error {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
errCh := make(chan error, 2)
|
||||
|
||||
go func() {
|
||||
_, err := io.Copy(remote, client)
|
||||
cancel()
|
||||
errCh <- err
|
||||
}()
|
||||
|
||||
go func() {
|
||||
_, err := io.Copy(client, remote)
|
||||
cancel()
|
||||
errCh <- err
|
||||
}()
|
||||
|
||||
var firstErr error
|
||||
for range 2 {
|
||||
if err := <-errCh; err != nil && firstErr == nil {
|
||||
if !isClosedErr(err) {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return firstErr
|
||||
}
|
||||
|
||||
// evaluateAction runs rule evaluation and resolves the effective action.
|
||||
// Pass empty path for connection-level (TLS), non-empty for request-level (HTTP).
|
||||
func (p *Proxy) evaluateAction(src netip.Addr, sni domain.Domain, dst netip.AddrPort, proto ProtoType, path string) Action {
|
||||
return p.rules.Evaluate(src, sni, dst.Addr(), dst.Port(), proto, path)
|
||||
}
|
||||
|
||||
// dialTCP dials the destination, blocking connections to loopback, link-local,
|
||||
// multicast, and WG overlay network addresses.
|
||||
func (p *Proxy) dialTCP(ctx context.Context, dst netip.AddrPort) (net.Conn, error) {
|
||||
ip := dst.Addr().Unmap()
|
||||
if err := p.validateDialTarget(ip); err != nil {
|
||||
return nil, fmt.Errorf("dial %s: %w", dst, err)
|
||||
}
|
||||
return p.dialer.DialContext(ctx, "tcp", dst.String())
|
||||
}
|
||||
|
||||
// validateDialTarget blocks destinations that should never be dialed by the proxy.
|
||||
// Mirrors the route validation in systemops.validateRoute.
|
||||
func (p *Proxy) validateDialTarget(addr netip.Addr) error {
|
||||
switch {
|
||||
case !addr.IsValid():
|
||||
return fmt.Errorf("invalid address")
|
||||
case addr.IsLoopback():
|
||||
return fmt.Errorf("loopback address not allowed")
|
||||
case addr.IsLinkLocalUnicast(), addr.IsLinkLocalMulticast(), addr.IsInterfaceLocalMulticast():
|
||||
return fmt.Errorf("link-local address not allowed")
|
||||
case addr.IsMulticast():
|
||||
return fmt.Errorf("multicast address not allowed")
|
||||
case p.wgNetwork.IsValid() && p.wgNetwork.Contains(addr):
|
||||
return fmt.Errorf("overlay network address not allowed")
|
||||
case p.localIPs != nil && p.localIPs.IsLocalIP(addr):
|
||||
return fmt.Errorf("local address not allowed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isClosedErr(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return err == io.EOF ||
|
||||
err == io.ErrClosedPipe ||
|
||||
err == net.ErrClosed ||
|
||||
err == context.Canceled
|
||||
}
|
||||
@@ -562,6 +562,9 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf
|
||||
MTU: selectMTU(config.MTU, peerConfig.Mtu),
|
||||
LogPath: logPath,
|
||||
|
||||
InspectionCACertPath: config.InspectionCACertPath,
|
||||
InspectionCAKeyPath: config.InspectionCAKeyPath,
|
||||
|
||||
ProfileConfig: config,
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/iface/device"
|
||||
nbnetstack "github.com/netbirdio/netbird/client/iface/netstack"
|
||||
"github.com/netbirdio/netbird/client/iface/udpmux"
|
||||
"github.com/netbirdio/netbird/client/inspect"
|
||||
"github.com/netbirdio/netbird/client/internal/acl"
|
||||
"github.com/netbirdio/netbird/client/internal/debug"
|
||||
"github.com/netbirdio/netbird/client/internal/dns"
|
||||
@@ -136,6 +137,12 @@ type EngineConfig struct {
|
||||
|
||||
MTU uint16
|
||||
|
||||
// InspectionCACertPath is a local CA cert for transparent proxy MITM.
|
||||
// Takes priority over management-pushed CA.
|
||||
InspectionCACertPath string
|
||||
// InspectionCAKeyPath is the corresponding private key.
|
||||
InspectionCAKeyPath string
|
||||
|
||||
// for debug bundle generation
|
||||
ProfileConfig *profilemanager.Config
|
||||
|
||||
@@ -222,6 +229,10 @@ type Engine struct {
|
||||
latestSyncResponse *mgmProto.SyncResponse
|
||||
flowManager nftypes.FlowManager
|
||||
|
||||
// transparentProxy is the transparent forward proxy for traffic inspection.
|
||||
transparentProxy *inspect.Proxy
|
||||
udpInspectionHookID string
|
||||
|
||||
// auto-update
|
||||
updateManager *updater.Manager
|
||||
|
||||
@@ -1272,6 +1283,9 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error {
|
||||
fwdEntries := toRouteDomains(e.config.WgPrivateKey.PublicKey().String(), routes)
|
||||
e.updateDNSForwarder(dnsRouteFeatureFlag, fwdEntries)
|
||||
|
||||
// Transparent proxy
|
||||
e.updateTransparentProxy(networkMap.GetTransparentProxyConfig())
|
||||
|
||||
// Ingress forward rules
|
||||
forwardingRules, err := e.updateForwardRules(networkMap.GetForwardingRules())
|
||||
if err != nil {
|
||||
@@ -1695,6 +1709,8 @@ func (e *Engine) parseNATExternalIPMappings() []string {
|
||||
func (e *Engine) close() {
|
||||
log.Debugf("removing Netbird interface %s", e.config.WgIfaceName)
|
||||
|
||||
e.stopTransparentProxy()
|
||||
|
||||
if e.wgInterface != nil {
|
||||
if err := e.wgInterface.Close(); err != nil {
|
||||
log.Errorf("failed closing Netbird interface %s %v", e.config.WgIfaceName, err)
|
||||
|
||||
@@ -55,7 +55,6 @@ import (
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
"github.com/netbirdio/netbird/management/server"
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
nbcache "github.com/netbirdio/netbird/management/server/cache"
|
||||
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
"github.com/netbirdio/netbird/management/server/settings"
|
||||
@@ -1635,12 +1634,7 @@ func startManagement(t *testing.T, dataDir, testFile string) (*grpc.Server, stri
|
||||
peersManager := peers.NewManager(store, permissionsManager)
|
||||
jobManager := job.NewJobManager(nil, store, peersManager)
|
||||
|
||||
cacheStore, err := nbcache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
ia, _ := integrations.NewIntegratedValidator(context.Background(), peersManager, nil, eventStore, cacheStore)
|
||||
ia, _ := integrations.NewIntegratedValidator(context.Background(), peersManager, nil, eventStore)
|
||||
|
||||
metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
|
||||
require.NoError(t, err)
|
||||
@@ -1662,7 +1656,7 @@ func startManagement(t *testing.T, dataDir, testFile string) (*grpc.Server, stri
|
||||
updateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||
requestBuffer := server.NewAccountRequestBuffer(context.Background(), store)
|
||||
networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config)
|
||||
accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
|
||||
accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
571
client/internal/engine_tproxy.go
Normal file
571
client/internal/engine_tproxy.go
Normal file
@@ -0,0 +1,571 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/firewall/uspfilter/forwarder"
|
||||
"github.com/netbirdio/netbird/client/inspect"
|
||||
"github.com/netbirdio/netbird/client/internal/acl/id"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
// updateTransparentProxy processes transparent proxy configuration from the network map.
|
||||
func (e *Engine) updateTransparentProxy(cfg *mgmProto.TransparentProxyConfig) {
|
||||
if cfg == nil || !cfg.Enabled {
|
||||
if cfg == nil {
|
||||
log.Tracef("inspect: config is nil")
|
||||
} else {
|
||||
log.Tracef("inspect: config disabled")
|
||||
}
|
||||
// Only stop if explicitly disabled. Don't stop on nil config to avoid
|
||||
// a gap during policy edits where management briefly pushes empty config.
|
||||
if cfg != nil && !cfg.Enabled {
|
||||
e.stopTransparentProxy()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
log.Debugf("inspect: config received: enabled=%v mode=%v default_action=%v rules=%d has_ca=%v",
|
||||
cfg.Enabled, cfg.Mode, cfg.DefaultAction, len(cfg.Rules), len(cfg.CaCertPem) > 0)
|
||||
|
||||
// BlockInbound prevents adding TPROXY rules since kernel TPROXY bypasses ACLs.
|
||||
// The userspace forwarder path still works as it operates within the forwarder hook.
|
||||
if e.config.BlockInbound {
|
||||
log.Warnf("inspect: BlockInbound is set, skipping redirect rules (userspace path still active)")
|
||||
}
|
||||
|
||||
proxyConfig, err := toProxyConfig(cfg)
|
||||
if err != nil {
|
||||
log.Errorf("inspect: parse config: %v", err)
|
||||
e.stopTransparentProxy()
|
||||
return
|
||||
}
|
||||
|
||||
// CA priority: local config > management-pushed > auto-generated self-signed.
|
||||
// Local wins over mgmt to prevent compromised management from injecting a CA.
|
||||
e.resolveInspectionCA(&proxyConfig)
|
||||
|
||||
if e.transparentProxy != nil {
|
||||
// Mode change requires full recreate (envoy lifecycle, listener changes).
|
||||
if proxyConfig.Mode != e.transparentProxy.Mode() {
|
||||
log.Infof("inspect: mode changed to %s, recreating engine", proxyConfig.Mode)
|
||||
e.stopTransparentProxy()
|
||||
} else {
|
||||
e.transparentProxy.UpdateConfig(proxyConfig)
|
||||
e.syncTProxyRules(proxyConfig)
|
||||
e.syncUDPInspectionHook()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if e.wgInterface != nil {
|
||||
proxyConfig.WGNetwork = e.wgInterface.Address().Network
|
||||
proxyConfig.ListenAddr = netip.AddrPortFrom(
|
||||
e.wgInterface.Address().IP.Unmap(),
|
||||
proxyConfig.ListenAddr.Port(),
|
||||
)
|
||||
}
|
||||
|
||||
// Pass local IP checker for SSRF prevention
|
||||
if checker, ok := e.firewall.(inspect.LocalIPChecker); ok {
|
||||
proxyConfig.LocalIPChecker = checker
|
||||
}
|
||||
|
||||
p, err := inspect.New(e.ctx, log.WithField("component", "inspect"), proxyConfig)
|
||||
if err != nil {
|
||||
log.Errorf("inspect: start engine: %v", err)
|
||||
return
|
||||
}
|
||||
e.transparentProxy = p
|
||||
|
||||
e.attachProxyToForwarder(p)
|
||||
e.syncTProxyRules(proxyConfig)
|
||||
e.syncUDPInspectionHook()
|
||||
|
||||
log.Infof("inspect: engine started (mode=%s, rules=%d)", proxyConfig.Mode, len(proxyConfig.Rules))
|
||||
}
|
||||
|
||||
// stopTransparentProxy shuts down the transparent proxy and removes interception.
|
||||
func (e *Engine) stopTransparentProxy() {
|
||||
if e.transparentProxy == nil {
|
||||
return
|
||||
}
|
||||
|
||||
e.attachProxyToForwarder(nil)
|
||||
e.removeTProxyRule()
|
||||
e.removeUDPInspectionHook()
|
||||
|
||||
if err := e.transparentProxy.Close(); err != nil {
|
||||
log.Debugf("inspect: close engine: %v", err)
|
||||
}
|
||||
e.transparentProxy = nil
|
||||
|
||||
log.Info("inspect: engine stopped")
|
||||
}
|
||||
|
||||
const tproxyRuleID = "tproxy-redirect"
|
||||
|
||||
// syncTProxyRules adds a TPROXY rule via the firewall manager to intercept
|
||||
// matching traffic on the WG interface and redirect it to the proxy socket.
|
||||
func (e *Engine) syncTProxyRules(config inspect.Config) {
|
||||
if e.config.BlockInbound {
|
||||
e.removeTProxyRule()
|
||||
return
|
||||
}
|
||||
|
||||
var listenPort uint16
|
||||
if e.transparentProxy != nil {
|
||||
listenPort = e.transparentProxy.ListenPort()
|
||||
}
|
||||
if listenPort == 0 {
|
||||
e.removeTProxyRule()
|
||||
return
|
||||
}
|
||||
|
||||
if e.firewall == nil {
|
||||
return
|
||||
}
|
||||
|
||||
dstPorts := make([]uint16, len(config.RedirectPorts))
|
||||
copy(dstPorts, config.RedirectPorts)
|
||||
|
||||
log.Debugf("inspect: syncing redirect rules: listen port %d, redirect ports %v, sources %v",
|
||||
listenPort, dstPorts, config.RedirectSources)
|
||||
|
||||
if err := e.firewall.AddTProxyRule(tproxyRuleID, config.RedirectSources, dstPorts, listenPort); err != nil {
|
||||
log.Errorf("inspect: add redirect rule: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// removeTProxyRule removes the TPROXY redirect rule.
|
||||
func (e *Engine) removeTProxyRule() {
|
||||
if e.firewall == nil {
|
||||
return
|
||||
}
|
||||
if err := e.firewall.RemoveTProxyRule(tproxyRuleID); err != nil {
|
||||
log.Debugf("inspect: remove redirect rule: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// syncUDPInspectionHook registers a UDP packet hook on port 443 for QUIC SNI blocking.
|
||||
// The hook is called by the USP filter for each UDP packet matching the port,
|
||||
// allowing the inspection engine to extract QUIC SNI and block by domain.
|
||||
func (e *Engine) syncUDPInspectionHook() {
|
||||
e.removeUDPInspectionHook()
|
||||
|
||||
if e.firewall == nil || e.transparentProxy == nil {
|
||||
return
|
||||
}
|
||||
|
||||
p := e.transparentProxy
|
||||
hookID := e.firewall.AddUDPInspectionHook(443, func(packet []byte) bool {
|
||||
srcIP, dstIP, dstPort, udpPayload, ok := parseUDPPacket(packet)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
src := inspect.SourceInfo{IP: srcIP}
|
||||
dst := netip.AddrPortFrom(dstIP, dstPort)
|
||||
action := p.HandleUDPPacket(udpPayload, dst, src)
|
||||
return action == inspect.ActionBlock
|
||||
})
|
||||
|
||||
e.udpInspectionHookID = hookID
|
||||
log.Debugf("inspect: registered UDP inspection hook on port 443 (id=%s)", hookID)
|
||||
}
|
||||
|
||||
// removeUDPInspectionHook removes the QUIC inspection hook.
|
||||
func (e *Engine) removeUDPInspectionHook() {
|
||||
if e.udpInspectionHookID == "" || e.firewall == nil {
|
||||
return
|
||||
}
|
||||
e.firewall.RemoveUDPInspectionHook(e.udpInspectionHookID)
|
||||
e.udpInspectionHookID = ""
|
||||
}
|
||||
|
||||
// parseUDPPacket extracts source/destination IP, destination port, and UDP
|
||||
// payload from a raw IP packet. Supports both IPv4 and IPv6.
|
||||
func parseUDPPacket(packet []byte) (srcIP, dstIP netip.Addr, dstPort uint16, payload []byte, ok bool) {
|
||||
if len(packet) < 1 {
|
||||
return srcIP, dstIP, 0, nil, false
|
||||
}
|
||||
|
||||
version := packet[0] >> 4
|
||||
|
||||
var udpOffset int
|
||||
switch version {
|
||||
case 4:
|
||||
if len(packet) < 20 {
|
||||
return srcIP, dstIP, 0, nil, false
|
||||
}
|
||||
ihl := int(packet[0]&0x0f) * 4
|
||||
if len(packet) < ihl+8 {
|
||||
return srcIP, dstIP, 0, nil, false
|
||||
}
|
||||
var srcOK, dstOK bool
|
||||
srcIP, srcOK = netip.AddrFromSlice(packet[12:16])
|
||||
dstIP, dstOK = netip.AddrFromSlice(packet[16:20])
|
||||
if !srcOK || !dstOK {
|
||||
return srcIP, dstIP, 0, nil, false
|
||||
}
|
||||
udpOffset = ihl
|
||||
|
||||
case 6:
|
||||
// IPv6 fixed header is 40 bytes. Next header must be UDP (17).
|
||||
if len(packet) < 48 { // 40 header + 8 UDP
|
||||
return srcIP, dstIP, 0, nil, false
|
||||
}
|
||||
nextHeader := packet[6]
|
||||
if nextHeader != 17 { // not UDP (may have extension headers)
|
||||
return srcIP, dstIP, 0, nil, false
|
||||
}
|
||||
var srcOK, dstOK bool
|
||||
srcIP, srcOK = netip.AddrFromSlice(packet[8:24])
|
||||
dstIP, dstOK = netip.AddrFromSlice(packet[24:40])
|
||||
if !srcOK || !dstOK {
|
||||
return srcIP, dstIP, 0, nil, false
|
||||
}
|
||||
udpOffset = 40
|
||||
|
||||
default:
|
||||
return srcIP, dstIP, 0, nil, false
|
||||
}
|
||||
|
||||
srcIP = srcIP.Unmap()
|
||||
dstIP = dstIP.Unmap()
|
||||
dstPort = uint16(packet[udpOffset+2])<<8 | uint16(packet[udpOffset+3])
|
||||
payload = packet[udpOffset+8:]
|
||||
|
||||
return srcIP, dstIP, dstPort, payload, true
|
||||
}
|
||||
|
||||
// attachProxyToForwarder sets or clears the proxy on the userspace forwarder.
|
||||
func (e *Engine) attachProxyToForwarder(p *inspect.Proxy) {
|
||||
type forwarderGetter interface {
|
||||
GetForwarder() *forwarder.Forwarder
|
||||
}
|
||||
|
||||
if fg, ok := e.firewall.(forwarderGetter); ok {
|
||||
if fwd := fg.GetForwarder(); fwd != nil {
|
||||
fwd.SetProxy(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// toProxyConfig converts a proto TransparentProxyConfig to the inspect.Config type.
|
||||
func toProxyConfig(cfg *mgmProto.TransparentProxyConfig) (inspect.Config, error) {
|
||||
config := inspect.Config{
|
||||
Enabled: cfg.Enabled,
|
||||
DefaultAction: toProxyAction(cfg.DefaultAction),
|
||||
}
|
||||
|
||||
switch cfg.Mode {
|
||||
case mgmProto.TransparentProxyMode_TP_MODE_ENVOY:
|
||||
config.Mode = inspect.ModeEnvoy
|
||||
case mgmProto.TransparentProxyMode_TP_MODE_EXTERNAL:
|
||||
config.Mode = inspect.ModeExternal
|
||||
default:
|
||||
config.Mode = inspect.ModeBuiltin
|
||||
}
|
||||
|
||||
if cfg.ExternalProxyUrl != "" {
|
||||
u, err := url.Parse(cfg.ExternalProxyUrl)
|
||||
if err != nil {
|
||||
return inspect.Config{}, fmt.Errorf("parse external proxy URL: %w", err)
|
||||
}
|
||||
config.ExternalURL = u
|
||||
}
|
||||
|
||||
for _, s := range cfg.RedirectSources {
|
||||
prefix, err := netip.ParsePrefix(s)
|
||||
if err != nil {
|
||||
return inspect.Config{}, fmt.Errorf("parse redirect source %q: %w", s, err)
|
||||
}
|
||||
config.RedirectSources = append(config.RedirectSources, prefix)
|
||||
}
|
||||
|
||||
for _, p := range cfg.RedirectPorts {
|
||||
config.RedirectPorts = append(config.RedirectPorts, uint16(p))
|
||||
}
|
||||
|
||||
// TPROXY listen port: fixed default, overridable via env var.
|
||||
if config.Mode == inspect.ModeBuiltin {
|
||||
port := uint16(inspect.DefaultTProxyPort)
|
||||
if v := os.Getenv("NB_TPROXY_PORT"); v != "" {
|
||||
if p, err := strconv.ParseUint(v, 10, 16); err == nil {
|
||||
port = uint16(p)
|
||||
} else {
|
||||
log.Warnf("invalid NB_TPROXY_PORT %q, using default %d", v, inspect.DefaultTProxyPort)
|
||||
}
|
||||
}
|
||||
config.ListenAddr = netip.AddrPortFrom(netip.IPv4Unspecified(), port)
|
||||
}
|
||||
|
||||
for _, r := range cfg.Rules {
|
||||
rule, err := toProxyRule(r)
|
||||
if err != nil {
|
||||
return inspect.Config{}, fmt.Errorf("parse rule %q: %w", r.Id, err)
|
||||
}
|
||||
config.Rules = append(config.Rules, rule)
|
||||
}
|
||||
|
||||
if cfg.Icap != nil {
|
||||
icapCfg, err := toICAPConfig(cfg.Icap)
|
||||
if err != nil {
|
||||
return inspect.Config{}, fmt.Errorf("parse ICAP config: %w", err)
|
||||
}
|
||||
config.ICAP = icapCfg
|
||||
}
|
||||
|
||||
if len(cfg.CaCertPem) > 0 && len(cfg.CaKeyPem) > 0 {
|
||||
tlsCfg, err := parseTLSConfig(cfg.CaCertPem, cfg.CaKeyPem)
|
||||
if err != nil {
|
||||
return inspect.Config{}, fmt.Errorf("parse TLS config: %w", err)
|
||||
}
|
||||
config.TLS = tlsCfg
|
||||
}
|
||||
|
||||
if config.Mode == inspect.ModeEnvoy {
|
||||
envCfg := &inspect.EnvoyConfig{
|
||||
BinaryPath: cfg.EnvoyBinaryPath,
|
||||
AdminPort: uint16(cfg.EnvoyAdminPort),
|
||||
}
|
||||
if cfg.EnvoySnippets != nil {
|
||||
envCfg.Snippets = &inspect.EnvoySnippets{
|
||||
HTTPFilters: cfg.EnvoySnippets.HttpFilters,
|
||||
NetworkFilters: cfg.EnvoySnippets.NetworkFilters,
|
||||
Clusters: cfg.EnvoySnippets.Clusters,
|
||||
}
|
||||
}
|
||||
config.Envoy = envCfg
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func toProxyRule(r *mgmProto.TransparentProxyRule) (inspect.Rule, error) {
|
||||
rule := inspect.Rule{
|
||||
ID: id.RuleID(r.Id),
|
||||
Action: toProxyAction(r.Action),
|
||||
Priority: int(r.Priority),
|
||||
}
|
||||
|
||||
for _, d := range r.Domains {
|
||||
dom, err := domain.FromString(d)
|
||||
if err != nil {
|
||||
return inspect.Rule{}, fmt.Errorf("parse domain %q: %w", d, err)
|
||||
}
|
||||
rule.Domains = append(rule.Domains, dom)
|
||||
}
|
||||
|
||||
for _, n := range r.Networks {
|
||||
prefix, err := netip.ParsePrefix(n)
|
||||
if err != nil {
|
||||
return inspect.Rule{}, fmt.Errorf("parse network %q: %w", n, err)
|
||||
}
|
||||
rule.Networks = append(rule.Networks, prefix)
|
||||
}
|
||||
|
||||
for _, p := range r.Ports {
|
||||
rule.Ports = append(rule.Ports, uint16(p))
|
||||
}
|
||||
|
||||
for _, proto := range r.Protocols {
|
||||
rule.Protocols = append(rule.Protocols, toProxyProtoType(proto))
|
||||
}
|
||||
|
||||
rule.Paths = r.Paths
|
||||
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
func toProxyProtoType(p mgmProto.TransparentProxyProtocol) inspect.ProtoType {
|
||||
switch p {
|
||||
case mgmProto.TransparentProxyProtocol_TP_PROTO_HTTP:
|
||||
return inspect.ProtoHTTP
|
||||
case mgmProto.TransparentProxyProtocol_TP_PROTO_HTTPS:
|
||||
return inspect.ProtoHTTPS
|
||||
case mgmProto.TransparentProxyProtocol_TP_PROTO_H2:
|
||||
return inspect.ProtoH2
|
||||
case mgmProto.TransparentProxyProtocol_TP_PROTO_H3:
|
||||
return inspect.ProtoH3
|
||||
case mgmProto.TransparentProxyProtocol_TP_PROTO_WEBSOCKET:
|
||||
return inspect.ProtoWebSocket
|
||||
case mgmProto.TransparentProxyProtocol_TP_PROTO_OTHER:
|
||||
return inspect.ProtoOther
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func toProxyAction(a mgmProto.TransparentProxyAction) inspect.Action {
|
||||
switch a {
|
||||
case mgmProto.TransparentProxyAction_TP_ACTION_BLOCK:
|
||||
return inspect.ActionBlock
|
||||
case mgmProto.TransparentProxyAction_TP_ACTION_INSPECT:
|
||||
return inspect.ActionInspect
|
||||
default:
|
||||
return inspect.ActionAllow
|
||||
}
|
||||
}
|
||||
|
||||
func toICAPConfig(cfg *mgmProto.TransparentProxyICAPConfig) (*inspect.ICAPConfig, error) {
|
||||
icap := &inspect.ICAPConfig{
|
||||
MaxConnections: int(cfg.MaxConnections),
|
||||
}
|
||||
|
||||
if cfg.ReqmodUrl != "" {
|
||||
u, err := url.Parse(cfg.ReqmodUrl)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse ICAP reqmod URL: %w", err)
|
||||
}
|
||||
icap.ReqModURL = u
|
||||
}
|
||||
|
||||
if cfg.RespmodUrl != "" {
|
||||
u, err := url.Parse(cfg.RespmodUrl)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse ICAP respmod URL: %w", err)
|
||||
}
|
||||
icap.RespModURL = u
|
||||
}
|
||||
|
||||
return icap, nil
|
||||
}
|
||||
|
||||
func parseTLSConfig(certPEM, keyPEM []byte) (*inspect.TLSConfig, error) {
|
||||
block, _ := pem.Decode(certPEM)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("decode CA certificate PEM")
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse CA certificate: %w", err)
|
||||
}
|
||||
|
||||
keyBlock, _ := pem.Decode(keyPEM)
|
||||
if keyBlock == nil {
|
||||
return nil, fmt.Errorf("decode CA key PEM")
|
||||
}
|
||||
|
||||
key, err := x509.ParseECPrivateKey(keyBlock.Bytes)
|
||||
if err != nil {
|
||||
// Try PKCS8 as fallback
|
||||
pkcs8Key, pkcs8Err := x509.ParsePKCS8PrivateKey(keyBlock.Bytes)
|
||||
if pkcs8Err != nil {
|
||||
return nil, fmt.Errorf("parse CA private key (tried EC and PKCS8): %w", err)
|
||||
}
|
||||
return &inspect.TLSConfig{CA: cert, CAKey: pkcs8Key}, nil
|
||||
}
|
||||
|
||||
return &inspect.TLSConfig{CA: cert, CAKey: key}, nil
|
||||
}
|
||||
|
||||
// resolveInspectionCA sets the TLS config on the proxy config using priority:
|
||||
// 1. Local config file CA (InspectionCACertPath/InspectionCAKeyPath)
|
||||
// 2. Management-pushed CA (already parsed in toProxyConfig)
|
||||
// 3. Auto-generated self-signed CA (ephemeral, for testing)
|
||||
// Local always wins to prevent a compromised management server from injecting a CA.
|
||||
func (e *Engine) resolveInspectionCA(config *inspect.Config) {
|
||||
// 1. Local CA from config file or env vars
|
||||
certPath := e.config.InspectionCACertPath
|
||||
keyPath := e.config.InspectionCAKeyPath
|
||||
if certPath == "" {
|
||||
certPath = os.Getenv("NB_INSPECTION_CA_CERT")
|
||||
}
|
||||
if keyPath == "" {
|
||||
keyPath = os.Getenv("NB_INSPECTION_CA_KEY")
|
||||
}
|
||||
if certPath != "" && keyPath != "" {
|
||||
certPEM, err := os.ReadFile(certPath)
|
||||
if err != nil {
|
||||
log.Errorf("read local inspection CA cert %s: %v", certPath, err)
|
||||
return
|
||||
}
|
||||
keyPEM, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
log.Errorf("read local inspection CA key %s: %v", keyPath, err)
|
||||
return
|
||||
}
|
||||
tlsCfg, err := parseTLSConfig(certPEM, keyPEM)
|
||||
if err != nil {
|
||||
log.Errorf("parse local inspection CA: %v", err)
|
||||
return
|
||||
}
|
||||
log.Infof("inspect: using local CA from %s", certPath)
|
||||
config.TLS = tlsCfg
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Management-pushed CA (already set by toProxyConfig)
|
||||
if config.TLS != nil {
|
||||
log.Infof("inspect: using management-pushed CA")
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Auto-generate self-signed CA for testing / accept-cert UX
|
||||
tlsCfg, err := generateSelfSignedCA()
|
||||
if err != nil {
|
||||
log.Errorf("generate self-signed inspection CA: %v", err)
|
||||
return
|
||||
}
|
||||
log.Infof("inspect: using auto-generated self-signed CA (clients will see certificate warnings)")
|
||||
config.TLS = tlsCfg
|
||||
}
|
||||
|
||||
// generateSelfSignedCA creates an ephemeral ECDSA P-256 CA certificate.
|
||||
// Clients will see certificate warnings but can choose to accept.
|
||||
func generateSelfSignedCA() (*inspect.TLSConfig, error) {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate CA key: %w", err)
|
||||
}
|
||||
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generate serial: %w", err)
|
||||
}
|
||||
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"NetBird Transparent Proxy"},
|
||||
CommonName: "NetBird Inspection CA (auto-generated)",
|
||||
},
|
||||
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||
NotAfter: time.Now().Add(365 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
MaxPathLen: 0,
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create CA certificate: %w", err)
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(certDER)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse generated CA certificate: %w", err)
|
||||
}
|
||||
|
||||
return &inspect.TLSConfig{CA: cert, CAKey: key}, nil
|
||||
}
|
||||
279
client/internal/engine_tproxy_test.go
Normal file
279
client/internal/engine_tproxy_test.go
Normal file
@@ -0,0 +1,279 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/inspect"
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
func TestToProxyConfig_Basic(t *testing.T) {
|
||||
cfg := &mgmProto.TransparentProxyConfig{
|
||||
Enabled: true,
|
||||
Mode: mgmProto.TransparentProxyMode_TP_MODE_BUILTIN,
|
||||
DefaultAction: mgmProto.TransparentProxyAction_TP_ACTION_ALLOW,
|
||||
RedirectSources: []string{
|
||||
"10.0.0.0/24",
|
||||
"192.168.1.0/24",
|
||||
},
|
||||
RedirectPorts: []uint32{80, 443},
|
||||
Rules: []*mgmProto.TransparentProxyRule{
|
||||
{
|
||||
Id: "block-evil",
|
||||
Domains: []string{"*.evil.com", "malware.example.com"},
|
||||
Action: mgmProto.TransparentProxyAction_TP_ACTION_BLOCK,
|
||||
Priority: 1,
|
||||
},
|
||||
{
|
||||
Id: "inspect-internal",
|
||||
Domains: []string{"*.internal.corp"},
|
||||
Networks: []string{"10.1.0.0/16"},
|
||||
Ports: []uint32{443, 8443},
|
||||
Action: mgmProto.TransparentProxyAction_TP_ACTION_INSPECT,
|
||||
Priority: 10,
|
||||
},
|
||||
},
|
||||
ListenPort: 8443,
|
||||
}
|
||||
|
||||
config, err := toProxyConfig(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, config.Enabled)
|
||||
assert.Equal(t, inspect.ModeBuiltin, config.Mode)
|
||||
assert.Equal(t, inspect.ActionAllow, config.DefaultAction)
|
||||
|
||||
require.Len(t, config.RedirectSources, 2)
|
||||
assert.Equal(t, "10.0.0.0/24", config.RedirectSources[0].String())
|
||||
assert.Equal(t, "192.168.1.0/24", config.RedirectSources[1].String())
|
||||
|
||||
require.Len(t, config.RedirectPorts, 2)
|
||||
assert.Equal(t, uint16(80), config.RedirectPorts[0])
|
||||
assert.Equal(t, uint16(443), config.RedirectPorts[1])
|
||||
|
||||
require.Len(t, config.Rules, 2)
|
||||
|
||||
// Rule 1: block evil domains
|
||||
assert.Equal(t, "block-evil", string(config.Rules[0].ID))
|
||||
assert.Equal(t, inspect.ActionBlock, config.Rules[0].Action)
|
||||
assert.Equal(t, 1, config.Rules[0].Priority)
|
||||
require.Len(t, config.Rules[0].Domains, 2)
|
||||
assert.Equal(t, "*.evil.com", config.Rules[0].Domains[0].PunycodeString())
|
||||
assert.Equal(t, "malware.example.com", config.Rules[0].Domains[1].PunycodeString())
|
||||
|
||||
// Rule 2: inspect internal
|
||||
assert.Equal(t, "inspect-internal", string(config.Rules[1].ID))
|
||||
assert.Equal(t, inspect.ActionInspect, config.Rules[1].Action)
|
||||
assert.Equal(t, 10, config.Rules[1].Priority)
|
||||
require.Len(t, config.Rules[1].Networks, 1)
|
||||
assert.Equal(t, "10.1.0.0/16", config.Rules[1].Networks[0].String())
|
||||
require.Len(t, config.Rules[1].Ports, 2)
|
||||
|
||||
// Listen address
|
||||
assert.True(t, config.ListenAddr.IsValid())
|
||||
assert.Equal(t, uint16(8443), config.ListenAddr.Port())
|
||||
}
|
||||
|
||||
func TestToProxyConfig_ExternalMode(t *testing.T) {
|
||||
cfg := &mgmProto.TransparentProxyConfig{
|
||||
Enabled: true,
|
||||
Mode: mgmProto.TransparentProxyMode_TP_MODE_EXTERNAL,
|
||||
ExternalProxyUrl: "http://proxy.corp:8080",
|
||||
DefaultAction: mgmProto.TransparentProxyAction_TP_ACTION_BLOCK,
|
||||
}
|
||||
|
||||
config, err := toProxyConfig(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, inspect.ModeExternal, config.Mode)
|
||||
assert.Equal(t, inspect.ActionBlock, config.DefaultAction)
|
||||
require.NotNil(t, config.ExternalURL)
|
||||
assert.Equal(t, "http", config.ExternalURL.Scheme)
|
||||
assert.Equal(t, "proxy.corp:8080", config.ExternalURL.Host)
|
||||
}
|
||||
|
||||
func TestToProxyConfig_ICAP(t *testing.T) {
|
||||
cfg := &mgmProto.TransparentProxyConfig{
|
||||
Enabled: true,
|
||||
Icap: &mgmProto.TransparentProxyICAPConfig{
|
||||
ReqmodUrl: "icap://icap-server:1344/reqmod",
|
||||
RespmodUrl: "icap://icap-server:1344/respmod",
|
||||
MaxConnections: 16,
|
||||
},
|
||||
}
|
||||
|
||||
config, err := toProxyConfig(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotNil(t, config.ICAP)
|
||||
assert.Equal(t, "icap", config.ICAP.ReqModURL.Scheme)
|
||||
assert.Equal(t, "icap-server:1344", config.ICAP.ReqModURL.Host)
|
||||
assert.Equal(t, "/reqmod", config.ICAP.ReqModURL.Path)
|
||||
assert.Equal(t, "/respmod", config.ICAP.RespModURL.Path)
|
||||
assert.Equal(t, 16, config.ICAP.MaxConnections)
|
||||
}
|
||||
|
||||
func TestToProxyConfig_Empty(t *testing.T) {
|
||||
cfg := &mgmProto.TransparentProxyConfig{
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
config, err := toProxyConfig(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, config.Enabled)
|
||||
assert.Equal(t, inspect.ModeBuiltin, config.Mode)
|
||||
assert.Equal(t, inspect.ActionAllow, config.DefaultAction)
|
||||
assert.Empty(t, config.RedirectSources)
|
||||
assert.Empty(t, config.RedirectPorts)
|
||||
assert.Empty(t, config.Rules)
|
||||
assert.Nil(t, config.ICAP)
|
||||
assert.Nil(t, config.TLS)
|
||||
assert.False(t, config.ListenAddr.IsValid())
|
||||
}
|
||||
|
||||
func TestToProxyConfig_InvalidSource(t *testing.T) {
|
||||
cfg := &mgmProto.TransparentProxyConfig{
|
||||
Enabled: true,
|
||||
RedirectSources: []string{"not-a-cidr"},
|
||||
}
|
||||
|
||||
_, err := toProxyConfig(cfg)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "parse redirect source")
|
||||
}
|
||||
|
||||
func TestToProxyConfig_InvalidNetwork(t *testing.T) {
|
||||
cfg := &mgmProto.TransparentProxyConfig{
|
||||
Enabled: true,
|
||||
Rules: []*mgmProto.TransparentProxyRule{
|
||||
{
|
||||
Id: "bad",
|
||||
Networks: []string{"not-a-cidr"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := toProxyConfig(cfg)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "parse network")
|
||||
}
|
||||
|
||||
func TestToProxyAction(t *testing.T) {
|
||||
assert.Equal(t, inspect.ActionAllow, toProxyAction(mgmProto.TransparentProxyAction_TP_ACTION_ALLOW))
|
||||
assert.Equal(t, inspect.ActionBlock, toProxyAction(mgmProto.TransparentProxyAction_TP_ACTION_BLOCK))
|
||||
assert.Equal(t, inspect.ActionInspect, toProxyAction(mgmProto.TransparentProxyAction_TP_ACTION_INSPECT))
|
||||
// Unknown defaults to allow
|
||||
assert.Equal(t, inspect.ActionAllow, toProxyAction(99))
|
||||
}
|
||||
|
||||
func TestParseUDPPacket_IPv4(t *testing.T) {
|
||||
// Build a minimal IPv4/UDP packet: 20-byte IPv4 header + 8-byte UDP header + payload
|
||||
packet := make([]byte, 20+8+4)
|
||||
|
||||
// IPv4 header: version=4, IHL=5 (20 bytes)
|
||||
packet[0] = 0x45
|
||||
// Protocol = UDP (17)
|
||||
packet[9] = 17
|
||||
// Source IP: 10.0.0.1
|
||||
packet[12], packet[13], packet[14], packet[15] = 10, 0, 0, 1
|
||||
// Dest IP: 192.168.1.1
|
||||
packet[16], packet[17], packet[18], packet[19] = 192, 168, 1, 1
|
||||
// UDP source port: 54321 (0xD431)
|
||||
packet[20] = 0xD4
|
||||
packet[21] = 0x31
|
||||
// UDP dest port: 443 (0x01BB)
|
||||
packet[22] = 0x01
|
||||
packet[23] = 0xBB
|
||||
// Payload
|
||||
packet[28] = 0xDE
|
||||
packet[29] = 0xAD
|
||||
packet[30] = 0xBE
|
||||
packet[31] = 0xEF
|
||||
|
||||
srcIP, dstIP, dstPort, payload, ok := parseUDPPacket(packet)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "10.0.0.1", srcIP.String())
|
||||
assert.Equal(t, "192.168.1.1", dstIP.String())
|
||||
assert.Equal(t, uint16(443), dstPort)
|
||||
assert.Equal(t, []byte{0xDE, 0xAD, 0xBE, 0xEF}, payload)
|
||||
}
|
||||
|
||||
func TestParseUDPPacket_IPv6(t *testing.T) {
|
||||
// Build a minimal IPv6/UDP packet: 40-byte IPv6 header + 8-byte UDP header + payload
|
||||
packet := make([]byte, 40+8+4)
|
||||
|
||||
// Version = 6 (0x60 in high nibble)
|
||||
packet[0] = 0x60
|
||||
// Payload length: 8 (UDP header) + 4 (payload)
|
||||
packet[4] = 0
|
||||
packet[5] = 12
|
||||
// Next header: UDP (17)
|
||||
packet[6] = 17
|
||||
// Source: 2001:db8::1
|
||||
packet[8] = 0x20
|
||||
packet[9] = 0x01
|
||||
packet[10] = 0x0d
|
||||
packet[11] = 0xb8
|
||||
packet[23] = 0x01
|
||||
// Dest: 2001:db8::2
|
||||
packet[24] = 0x20
|
||||
packet[25] = 0x01
|
||||
packet[26] = 0x0d
|
||||
packet[27] = 0xb8
|
||||
packet[39] = 0x02
|
||||
// UDP source port: 54321 (0xD431)
|
||||
packet[40] = 0xD4
|
||||
packet[41] = 0x31
|
||||
// UDP dest port: 443 (0x01BB)
|
||||
packet[42] = 0x01
|
||||
packet[43] = 0xBB
|
||||
// Payload
|
||||
packet[48] = 0xCA
|
||||
packet[49] = 0xFE
|
||||
packet[50] = 0xBA
|
||||
packet[51] = 0xBE
|
||||
|
||||
srcIP, dstIP, dstPort, payload, ok := parseUDPPacket(packet)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "2001:db8::1", srcIP.String())
|
||||
assert.Equal(t, "2001:db8::2", dstIP.String())
|
||||
assert.Equal(t, uint16(443), dstPort)
|
||||
assert.Equal(t, []byte{0xCA, 0xFE, 0xBA, 0xBE}, payload)
|
||||
}
|
||||
|
||||
func TestParseUDPPacket_TooShort(t *testing.T) {
|
||||
_, _, _, _, ok := parseUDPPacket(nil)
|
||||
assert.False(t, ok)
|
||||
|
||||
_, _, _, _, ok = parseUDPPacket([]byte{0x45, 0x00})
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestParseUDPPacket_IPv6ExtensionHeader(t *testing.T) {
|
||||
// IPv6 with next header != UDP should be rejected
|
||||
packet := make([]byte, 48)
|
||||
packet[0] = 0x60
|
||||
packet[6] = 6 // TCP, not UDP
|
||||
_, _, _, _, ok := parseUDPPacket(packet)
|
||||
assert.False(t, ok, "should reject IPv6 packets with non-UDP next header")
|
||||
}
|
||||
|
||||
func TestParseUDPPacket_IPv4MappedIPv6(t *testing.T) {
|
||||
// IPv4 packet with normal addresses should Unmap correctly
|
||||
packet := make([]byte, 28)
|
||||
packet[0] = 0x45
|
||||
packet[9] = 17
|
||||
packet[12], packet[13], packet[14], packet[15] = 127, 0, 0, 1
|
||||
packet[16], packet[17], packet[18], packet[19] = 10, 0, 0, 1
|
||||
packet[22] = 0x01
|
||||
packet[23] = 0xBB
|
||||
|
||||
srcIP, dstIP, _, _, ok := parseUDPPacket(packet)
|
||||
require.True(t, ok)
|
||||
assert.True(t, srcIP.Is4(), "should be plain IPv4, not mapped")
|
||||
assert.True(t, dstIP.Is4(), "should be plain IPv4, not mapped")
|
||||
}
|
||||
@@ -7,9 +7,7 @@ import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
"github.com/google/uuid"
|
||||
log "github.com/sirupsen/logrus"
|
||||
nfct "github.com/ti-mo/conntrack"
|
||||
@@ -19,64 +17,31 @@ import (
|
||||
nbnet "github.com/netbirdio/netbird/client/net"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultChannelSize = 100
|
||||
reconnectInitInterval = 5 * time.Second
|
||||
reconnectMaxInterval = 5 * time.Minute
|
||||
reconnectRandomization = 0.5
|
||||
)
|
||||
|
||||
// listener abstracts a netlink conntrack connection for testability.
|
||||
type listener interface {
|
||||
Listen(evChan chan<- nfct.Event, numWorkers uint8, groups []netfilter.NetlinkGroup) (chan error, error)
|
||||
Close() error
|
||||
}
|
||||
const defaultChannelSize = 100
|
||||
|
||||
// ConnTrack manages kernel-based conntrack events
|
||||
type ConnTrack struct {
|
||||
flowLogger nftypes.FlowLogger
|
||||
iface nftypes.IFaceMapper
|
||||
|
||||
conn listener
|
||||
conn *nfct.Conn
|
||||
mux sync.Mutex
|
||||
|
||||
dial func() (listener, error)
|
||||
instanceID uuid.UUID
|
||||
started bool
|
||||
done chan struct{}
|
||||
sysctlModified bool
|
||||
}
|
||||
|
||||
// DialFunc is a constructor for netlink conntrack connections.
|
||||
type DialFunc func() (listener, error)
|
||||
|
||||
// Option configures a ConnTrack instance.
|
||||
type Option func(*ConnTrack)
|
||||
|
||||
// WithDialer overrides the default netlink dialer, primarily for testing.
|
||||
func WithDialer(dial DialFunc) Option {
|
||||
return func(c *ConnTrack) {
|
||||
c.dial = dial
|
||||
}
|
||||
}
|
||||
|
||||
func defaultDial() (listener, error) {
|
||||
return nfct.Dial(nil)
|
||||
}
|
||||
|
||||
// New creates a new connection tracker that interfaces with the kernel's conntrack system
|
||||
func New(flowLogger nftypes.FlowLogger, iface nftypes.IFaceMapper, opts ...Option) *ConnTrack {
|
||||
ct := &ConnTrack{
|
||||
func New(flowLogger nftypes.FlowLogger, iface nftypes.IFaceMapper) *ConnTrack {
|
||||
return &ConnTrack{
|
||||
flowLogger: flowLogger,
|
||||
iface: iface,
|
||||
instanceID: uuid.New(),
|
||||
dial: defaultDial,
|
||||
started: false,
|
||||
done: make(chan struct{}, 1),
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(ct)
|
||||
}
|
||||
return ct
|
||||
}
|
||||
|
||||
// Start begins tracking connections by listening for conntrack events. This method is idempotent.
|
||||
@@ -94,9 +59,8 @@ func (c *ConnTrack) Start(enableCounters bool) error {
|
||||
c.EnableAccounting()
|
||||
}
|
||||
|
||||
conn, err := c.dial()
|
||||
conn, err := nfct.Dial(nil)
|
||||
if err != nil {
|
||||
c.RestoreAccounting()
|
||||
return fmt.Errorf("dial conntrack: %w", err)
|
||||
}
|
||||
c.conn = conn
|
||||
@@ -112,16 +76,9 @@ func (c *ConnTrack) Start(enableCounters bool) error {
|
||||
log.Errorf("Error closing conntrack connection: %v", err)
|
||||
}
|
||||
c.conn = nil
|
||||
c.RestoreAccounting()
|
||||
return fmt.Errorf("start conntrack listener: %w", err)
|
||||
}
|
||||
|
||||
// Drain any stale stop signal from a previous cycle.
|
||||
select {
|
||||
case <-c.done:
|
||||
default:
|
||||
}
|
||||
|
||||
c.started = true
|
||||
|
||||
go c.receiverRoutine(events, errChan)
|
||||
@@ -135,98 +92,17 @@ func (c *ConnTrack) receiverRoutine(events chan nfct.Event, errChan chan error)
|
||||
case event := <-events:
|
||||
c.handleEvent(event)
|
||||
case err := <-errChan:
|
||||
if events, errChan = c.handleListenerError(err); events == nil {
|
||||
return
|
||||
log.Errorf("Error from conntrack event listener: %v", err)
|
||||
if err := c.conn.Close(); err != nil {
|
||||
log.Errorf("Error closing conntrack connection: %v", err)
|
||||
}
|
||||
return
|
||||
case <-c.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleListenerError closes the failed connection and attempts to reconnect.
|
||||
// Returns new channels on success, or nil if shutdown was requested.
|
||||
func (c *ConnTrack) handleListenerError(err error) (chan nfct.Event, chan error) {
|
||||
log.Warnf("conntrack event listener failed: %v", err)
|
||||
c.closeConn()
|
||||
return c.reconnect()
|
||||
}
|
||||
|
||||
func (c *ConnTrack) closeConn() {
|
||||
c.mux.Lock()
|
||||
defer c.mux.Unlock()
|
||||
|
||||
if c.conn != nil {
|
||||
if err := c.conn.Close(); err != nil {
|
||||
log.Debugf("close conntrack connection: %v", err)
|
||||
}
|
||||
c.conn = nil
|
||||
}
|
||||
}
|
||||
|
||||
// reconnect attempts to re-establish the conntrack netlink listener with exponential backoff.
|
||||
// Returns new channels on success, or nil if shutdown was requested.
|
||||
func (c *ConnTrack) reconnect() (chan nfct.Event, chan error) {
|
||||
bo := &backoff.ExponentialBackOff{
|
||||
InitialInterval: reconnectInitInterval,
|
||||
RandomizationFactor: reconnectRandomization,
|
||||
Multiplier: backoff.DefaultMultiplier,
|
||||
MaxInterval: reconnectMaxInterval,
|
||||
MaxElapsedTime: 0, // retry indefinitely
|
||||
Clock: backoff.SystemClock,
|
||||
}
|
||||
bo.Reset()
|
||||
|
||||
for {
|
||||
delay := bo.NextBackOff()
|
||||
log.Infof("reconnecting conntrack listener in %s", delay)
|
||||
|
||||
select {
|
||||
case <-c.done:
|
||||
c.mux.Lock()
|
||||
c.started = false
|
||||
c.mux.Unlock()
|
||||
return nil, nil
|
||||
case <-time.After(delay):
|
||||
}
|
||||
|
||||
conn, err := c.dial()
|
||||
if err != nil {
|
||||
log.Warnf("reconnect conntrack dial: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
events := make(chan nfct.Event, defaultChannelSize)
|
||||
errChan, err := conn.Listen(events, 1, []netfilter.NetlinkGroup{
|
||||
netfilter.GroupCTNew,
|
||||
netfilter.GroupCTDestroy,
|
||||
})
|
||||
if err != nil {
|
||||
log.Warnf("reconnect conntrack listen: %v", err)
|
||||
if closeErr := conn.Close(); closeErr != nil {
|
||||
log.Debugf("close conntrack connection: %v", closeErr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
c.mux.Lock()
|
||||
if !c.started {
|
||||
// Stop() ran while we were reconnecting.
|
||||
c.mux.Unlock()
|
||||
if closeErr := conn.Close(); closeErr != nil {
|
||||
log.Debugf("close conntrack connection: %v", closeErr)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
c.conn = conn
|
||||
c.mux.Unlock()
|
||||
|
||||
log.Infof("conntrack listener reconnected successfully")
|
||||
|
||||
return events, errChan
|
||||
}
|
||||
}
|
||||
|
||||
// Stop stops the connection tracking. This method is idempotent.
|
||||
func (c *ConnTrack) Stop() {
|
||||
c.mux.Lock()
|
||||
@@ -260,27 +136,23 @@ func (c *ConnTrack) Close() error {
|
||||
c.mux.Lock()
|
||||
defer c.mux.Unlock()
|
||||
|
||||
if !c.started {
|
||||
return nil
|
||||
if c.started {
|
||||
select {
|
||||
case c.done <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case c.done <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
|
||||
c.started = false
|
||||
|
||||
var closeErr error
|
||||
if c.conn != nil {
|
||||
closeErr = c.conn.Close()
|
||||
err := c.conn.Close()
|
||||
c.conn = nil
|
||||
}
|
||||
c.started = false
|
||||
|
||||
c.RestoreAccounting()
|
||||
c.RestoreAccounting()
|
||||
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("close conntrack: %w", closeErr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("close conntrack: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
//go:build linux && !android
|
||||
|
||||
package conntrack
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
nfct "github.com/ti-mo/conntrack"
|
||||
"github.com/ti-mo/netfilter"
|
||||
)
|
||||
|
||||
type mockListener struct {
|
||||
errChan chan error
|
||||
closed atomic.Bool
|
||||
closedCh chan struct{}
|
||||
}
|
||||
|
||||
func newMockListener() *mockListener {
|
||||
return &mockListener{
|
||||
errChan: make(chan error, 1),
|
||||
closedCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockListener) Listen(evChan chan<- nfct.Event, _ uint8, _ []netfilter.NetlinkGroup) (chan error, error) {
|
||||
return m.errChan, nil
|
||||
}
|
||||
|
||||
func (m *mockListener) Close() error {
|
||||
if m.closed.CompareAndSwap(false, true) {
|
||||
close(m.closedCh)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestReconnectAfterError(t *testing.T) {
|
||||
first := newMockListener()
|
||||
second := newMockListener()
|
||||
third := newMockListener()
|
||||
listeners := []*mockListener{first, second, third}
|
||||
callCount := atomic.Int32{}
|
||||
|
||||
ct := New(nil, nil, WithDialer(func() (listener, error) {
|
||||
n := int(callCount.Add(1)) - 1
|
||||
return listeners[n], nil
|
||||
}))
|
||||
|
||||
err := ct.Start(false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Inject an error on the first listener.
|
||||
first.errChan <- assert.AnError
|
||||
|
||||
// Wait for reconnect to complete.
|
||||
require.Eventually(t, func() bool {
|
||||
return callCount.Load() >= 2
|
||||
}, 15*time.Second, 100*time.Millisecond, "reconnect should dial a new connection")
|
||||
|
||||
// The first connection must have been closed.
|
||||
select {
|
||||
case <-first.closedCh:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("first connection was not closed")
|
||||
}
|
||||
|
||||
// Verify the receiver is still running by injecting and handling a second error.
|
||||
second.errChan <- assert.AnError
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return callCount.Load() >= 3
|
||||
}, 15*time.Second, 100*time.Millisecond, "second reconnect should succeed")
|
||||
|
||||
ct.Stop()
|
||||
}
|
||||
|
||||
func TestStopDuringReconnectBackoff(t *testing.T) {
|
||||
mock := newMockListener()
|
||||
|
||||
ct := New(nil, nil, WithDialer(func() (listener, error) {
|
||||
return mock, nil
|
||||
}))
|
||||
|
||||
err := ct.Start(false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Trigger an error so the receiver enters reconnect.
|
||||
mock.errChan <- assert.AnError
|
||||
|
||||
// Wait for the error handler to close the old listener before calling Stop.
|
||||
select {
|
||||
case <-mock.closedCh:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for reconnect to start")
|
||||
}
|
||||
|
||||
// Stop while reconnecting.
|
||||
ct.Stop()
|
||||
|
||||
ct.mux.Lock()
|
||||
assert.False(t, ct.started, "started should be false after Stop")
|
||||
assert.Nil(t, ct.conn, "conn should be nil after Stop")
|
||||
ct.mux.Unlock()
|
||||
}
|
||||
|
||||
func TestStopRaceWithReconnectDial(t *testing.T) {
|
||||
first := newMockListener()
|
||||
dialStarted := make(chan struct{})
|
||||
dialProceed := make(chan struct{})
|
||||
second := newMockListener()
|
||||
callCount := atomic.Int32{}
|
||||
|
||||
ct := New(nil, nil, WithDialer(func() (listener, error) {
|
||||
n := callCount.Add(1)
|
||||
if n == 1 {
|
||||
return first, nil
|
||||
}
|
||||
// Second dial: signal that we're in progress, wait for test to call Stop.
|
||||
close(dialStarted)
|
||||
<-dialProceed
|
||||
return second, nil
|
||||
}))
|
||||
|
||||
err := ct.Start(false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Trigger error to enter reconnect.
|
||||
first.errChan <- assert.AnError
|
||||
|
||||
// Wait for reconnect's second dial to begin.
|
||||
select {
|
||||
case <-dialStarted:
|
||||
case <-time.After(15 * time.Second):
|
||||
t.Fatal("timed out waiting for reconnect dial")
|
||||
}
|
||||
|
||||
// Stop while dial is in progress (conn is nil at this point).
|
||||
ct.Stop()
|
||||
|
||||
// Let the dial complete. reconnect should detect started==false and close the new conn.
|
||||
close(dialProceed)
|
||||
|
||||
// The second connection should be closed (not leaked).
|
||||
select {
|
||||
case <-second.closedCh:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("second connection was leaked after Stop")
|
||||
}
|
||||
|
||||
ct.mux.Lock()
|
||||
assert.False(t, ct.started)
|
||||
assert.Nil(t, ct.conn)
|
||||
ct.mux.Unlock()
|
||||
}
|
||||
|
||||
func TestCloseRaceWithReconnectDial(t *testing.T) {
|
||||
first := newMockListener()
|
||||
dialStarted := make(chan struct{})
|
||||
dialProceed := make(chan struct{})
|
||||
second := newMockListener()
|
||||
callCount := atomic.Int32{}
|
||||
|
||||
ct := New(nil, nil, WithDialer(func() (listener, error) {
|
||||
n := callCount.Add(1)
|
||||
if n == 1 {
|
||||
return first, nil
|
||||
}
|
||||
close(dialStarted)
|
||||
<-dialProceed
|
||||
return second, nil
|
||||
}))
|
||||
|
||||
err := ct.Start(false)
|
||||
require.NoError(t, err)
|
||||
|
||||
first.errChan <- assert.AnError
|
||||
|
||||
select {
|
||||
case <-dialStarted:
|
||||
case <-time.After(15 * time.Second):
|
||||
t.Fatal("timed out waiting for reconnect dial")
|
||||
}
|
||||
|
||||
// Close while dial is in progress (conn is nil).
|
||||
require.NoError(t, ct.Close())
|
||||
|
||||
close(dialProceed)
|
||||
|
||||
// The second connection should be closed (not leaked).
|
||||
select {
|
||||
case <-second.closedCh:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("second connection was leaked after Close")
|
||||
}
|
||||
|
||||
ct.mux.Lock()
|
||||
assert.False(t, ct.started)
|
||||
assert.Nil(t, ct.conn)
|
||||
ct.mux.Unlock()
|
||||
}
|
||||
|
||||
func TestStartIsIdempotent(t *testing.T) {
|
||||
mock := newMockListener()
|
||||
callCount := atomic.Int32{}
|
||||
|
||||
ct := New(nil, nil, WithDialer(func() (listener, error) {
|
||||
callCount.Add(1)
|
||||
return mock, nil
|
||||
}))
|
||||
|
||||
err := ct.Start(false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Second Start should be a no-op.
|
||||
err = ct.Start(false)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, int32(1), callCount.Load(), "dial should only be called once")
|
||||
|
||||
ct.Stop()
|
||||
}
|
||||
@@ -8,27 +8,18 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
envDisableNATMapper = "NB_DISABLE_NAT_MAPPER"
|
||||
envDisablePCPHealthCheck = "NB_DISABLE_PCP_HEALTH_CHECK"
|
||||
envDisableNATMapper = "NB_DISABLE_NAT_MAPPER"
|
||||
)
|
||||
|
||||
func isDisabledByEnv() bool {
|
||||
return parseBoolEnv(envDisableNATMapper)
|
||||
}
|
||||
|
||||
func isHealthCheckDisabled() bool {
|
||||
return parseBoolEnv(envDisablePCPHealthCheck)
|
||||
}
|
||||
|
||||
func parseBoolEnv(key string) bool {
|
||||
val := os.Getenv(key)
|
||||
val := os.Getenv(envDisableNATMapper)
|
||||
if val == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
disabled, err := strconv.ParseBool(val)
|
||||
if err != nil {
|
||||
log.Warnf("failed to parse %s: %v", key, err)
|
||||
log.Warnf("failed to parse %s: %v", envDisableNATMapper, err)
|
||||
return false
|
||||
}
|
||||
return disabled
|
||||
|
||||
@@ -12,15 +12,12 @@ import (
|
||||
|
||||
"github.com/libp2p/go-nat"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/portforward/pcp"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultMappingTTL = 2 * time.Hour
|
||||
healthCheckInterval = 1 * time.Minute
|
||||
discoveryTimeout = 10 * time.Second
|
||||
mappingDescription = "NetBird"
|
||||
defaultMappingTTL = 2 * time.Hour
|
||||
discoveryTimeout = 10 * time.Second
|
||||
mappingDescription = "NetBird"
|
||||
)
|
||||
|
||||
// upnpErrPermanentLeaseOnly matches UPnP error 725 in SOAP fault XML,
|
||||
@@ -157,7 +154,7 @@ func (m *Manager) setup(ctx context.Context) (nat.NAT, *Mapping, error) {
|
||||
discoverCtx, discoverCancel := context.WithTimeout(ctx, discoveryTimeout)
|
||||
defer discoverCancel()
|
||||
|
||||
gateway, err := discoverGateway(discoverCtx)
|
||||
gateway, err := nat.DiscoverGateway(discoverCtx)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("discover gateway: %w", err)
|
||||
}
|
||||
@@ -192,6 +189,7 @@ func (m *Manager) createMapping(ctx context.Context, gateway nat.NAT) (*Mapping,
|
||||
externalIP, err := gateway.GetExternalAddress()
|
||||
if err != nil {
|
||||
log.Debugf("failed to get external address: %v", err)
|
||||
// todo return with err?
|
||||
}
|
||||
|
||||
mapping := &Mapping{
|
||||
@@ -210,87 +208,27 @@ func (m *Manager) createMapping(ctx context.Context, gateway nat.NAT) (*Mapping,
|
||||
|
||||
func (m *Manager) renewLoop(ctx context.Context, gateway nat.NAT, ttl time.Duration) {
|
||||
if ttl == 0 {
|
||||
// Permanent mappings don't expire, just wait for cancellation
|
||||
// but still run health checks for PCP gateways.
|
||||
m.permanentLeaseLoop(ctx, gateway)
|
||||
// Permanent mappings don't expire, just wait for cancellation.
|
||||
<-ctx.Done()
|
||||
return
|
||||
}
|
||||
|
||||
renewTicker := time.NewTicker(ttl / 2)
|
||||
healthTicker := time.NewTicker(healthCheckInterval)
|
||||
defer renewTicker.Stop()
|
||||
defer healthTicker.Stop()
|
||||
ticker := time.NewTicker(ttl / 2)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-renewTicker.C:
|
||||
case <-ticker.C:
|
||||
if err := m.renewMapping(ctx, gateway); err != nil {
|
||||
log.Warnf("failed to renew port mapping: %v", err)
|
||||
continue
|
||||
}
|
||||
case <-healthTicker.C:
|
||||
if m.checkHealthAndRecreate(ctx, gateway) {
|
||||
renewTicker.Reset(ttl / 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) permanentLeaseLoop(ctx context.Context, gateway nat.NAT) {
|
||||
healthTicker := time.NewTicker(healthCheckInterval)
|
||||
defer healthTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-healthTicker.C:
|
||||
m.checkHealthAndRecreate(ctx, gateway)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) checkHealthAndRecreate(ctx context.Context, gateway nat.NAT) bool {
|
||||
if isHealthCheckDisabled() {
|
||||
return false
|
||||
}
|
||||
|
||||
m.mappingLock.Lock()
|
||||
hasMapping := m.mapping != nil
|
||||
m.mappingLock.Unlock()
|
||||
|
||||
if !hasMapping {
|
||||
return false
|
||||
}
|
||||
|
||||
pcpNAT, ok := gateway.(*pcp.NAT)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
epoch, serverRestarted, err := pcpNAT.CheckServerHealth(ctx)
|
||||
if err != nil {
|
||||
log.Debugf("PCP health check failed: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
if serverRestarted {
|
||||
log.Warnf("PCP server restart detected (epoch=%d), recreating port mapping", epoch)
|
||||
if err := m.renewMapping(ctx, gateway); err != nil {
|
||||
log.Errorf("failed to recreate port mapping after server restart: %v", err)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *Manager) renewMapping(ctx context.Context, gateway nat.NAT) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -1,408 +0,0 @@
|
||||
package pcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultTimeout = 3 * time.Second
|
||||
responseBufferSize = 128
|
||||
|
||||
// RFC 6887 Section 8.1.1 retry timing
|
||||
initialRetryDelay = 3 * time.Second
|
||||
maxRetryDelay = 1024 * time.Second
|
||||
maxRetries = 4 // 3s + 6s + 12s + 24s = 45s total worst case
|
||||
)
|
||||
|
||||
// Client is a PCP protocol client.
|
||||
// All methods are safe for concurrent use.
|
||||
type Client struct {
|
||||
gateway netip.Addr
|
||||
timeout time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
// localIP caches the resolved local IP address.
|
||||
localIP netip.Addr
|
||||
// lastEpoch is the last observed server epoch value.
|
||||
lastEpoch uint32
|
||||
// epochTime tracks when lastEpoch was received for state loss detection.
|
||||
epochTime time.Time
|
||||
// externalIP caches the external IP from the last successful MAP response.
|
||||
externalIP netip.Addr
|
||||
// epochStateLost is set when epoch indicates server restart.
|
||||
epochStateLost bool
|
||||
}
|
||||
|
||||
// NewClient creates a new PCP client for the gateway at the given IP.
|
||||
func NewClient(gateway net.IP) *Client {
|
||||
addr, ok := netip.AddrFromSlice(gateway)
|
||||
if !ok {
|
||||
log.Debugf("invalid gateway IP: %v", gateway)
|
||||
}
|
||||
return &Client{
|
||||
gateway: addr.Unmap(),
|
||||
timeout: defaultTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
// NewClientWithTimeout creates a new PCP client with a custom timeout.
|
||||
func NewClientWithTimeout(gateway net.IP, timeout time.Duration) *Client {
|
||||
addr, ok := netip.AddrFromSlice(gateway)
|
||||
if !ok {
|
||||
log.Debugf("invalid gateway IP: %v", gateway)
|
||||
}
|
||||
return &Client{
|
||||
gateway: addr.Unmap(),
|
||||
timeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
// SetLocalIP sets the local IP address to use in PCP requests.
|
||||
func (c *Client) SetLocalIP(ip net.IP) {
|
||||
addr, ok := netip.AddrFromSlice(ip)
|
||||
if !ok {
|
||||
log.Debugf("invalid local IP: %v", ip)
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.localIP = addr.Unmap()
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// Gateway returns the gateway IP address.
|
||||
func (c *Client) Gateway() net.IP {
|
||||
return c.gateway.AsSlice()
|
||||
}
|
||||
|
||||
// Announce sends a PCP ANNOUNCE request to discover PCP support.
|
||||
// Returns the server's epoch time on success.
|
||||
func (c *Client) Announce(ctx context.Context) (epoch uint32, err error) {
|
||||
localIP, err := c.getLocalIP()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("get local IP: %w", err)
|
||||
}
|
||||
|
||||
req := buildAnnounceRequest(localIP)
|
||||
resp, err := c.sendRequest(ctx, req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("send announce: %w", err)
|
||||
}
|
||||
|
||||
parsed, err := parseResponse(resp)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse announce response: %w", err)
|
||||
}
|
||||
|
||||
if parsed.ResultCode != ResultSuccess {
|
||||
return 0, fmt.Errorf("PCP ANNOUNCE failed: %s", ResultCodeString(parsed.ResultCode))
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
if c.updateEpochLocked(parsed.Epoch) {
|
||||
log.Warnf("PCP server epoch indicates state loss - mappings may need refresh")
|
||||
}
|
||||
c.mu.Unlock()
|
||||
return parsed.Epoch, nil
|
||||
}
|
||||
|
||||
// AddPortMapping requests a port mapping from the PCP server.
|
||||
func (c *Client) AddPortMapping(ctx context.Context, protocol string, internalPort int, lifetime time.Duration) (*MapResponse, error) {
|
||||
return c.addPortMappingWithHint(ctx, protocol, internalPort, internalPort, netip.Addr{}, lifetime)
|
||||
}
|
||||
|
||||
// AddPortMappingWithHint requests a port mapping with suggested external port and IP.
|
||||
// Use lifetime <= 0 to delete a mapping.
|
||||
func (c *Client) AddPortMappingWithHint(ctx context.Context, protocol string, internalPort, suggestedExtPort int, suggestedExtIP net.IP, lifetime time.Duration) (*MapResponse, error) {
|
||||
var extIP netip.Addr
|
||||
if suggestedExtIP != nil {
|
||||
var ok bool
|
||||
extIP, ok = netip.AddrFromSlice(suggestedExtIP)
|
||||
if !ok {
|
||||
log.Debugf("invalid suggested external IP: %v", suggestedExtIP)
|
||||
}
|
||||
extIP = extIP.Unmap()
|
||||
}
|
||||
return c.addPortMappingWithHint(ctx, protocol, internalPort, suggestedExtPort, extIP, lifetime)
|
||||
}
|
||||
|
||||
func (c *Client) addPortMappingWithHint(ctx context.Context, protocol string, internalPort, suggestedExtPort int, suggestedExtIP netip.Addr, lifetime time.Duration) (*MapResponse, error) {
|
||||
localIP, err := c.getLocalIP()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get local IP: %w", err)
|
||||
}
|
||||
|
||||
proto, err := protocolNumber(protocol)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse protocol: %w", err)
|
||||
}
|
||||
|
||||
var nonce [12]byte
|
||||
if _, err := rand.Read(nonce[:]); err != nil {
|
||||
return nil, fmt.Errorf("generate nonce: %w", err)
|
||||
}
|
||||
|
||||
// Convert lifetime to seconds. Lifetime 0 means delete, so only apply
|
||||
// default for positive durations that round to 0 seconds.
|
||||
var lifetimeSec uint32
|
||||
if lifetime > 0 {
|
||||
lifetimeSec = uint32(lifetime.Seconds())
|
||||
if lifetimeSec == 0 {
|
||||
lifetimeSec = DefaultLifetime
|
||||
}
|
||||
}
|
||||
|
||||
req := buildMapRequest(localIP, nonce, proto, uint16(internalPort), uint16(suggestedExtPort), suggestedExtIP, lifetimeSec)
|
||||
|
||||
resp, err := c.sendRequest(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("send map request: %w", err)
|
||||
}
|
||||
|
||||
mapResp, err := parseMapResponse(resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse map response: %w", err)
|
||||
}
|
||||
|
||||
if mapResp.Nonce != nonce {
|
||||
return nil, fmt.Errorf("nonce mismatch in response")
|
||||
}
|
||||
|
||||
if mapResp.Protocol != proto {
|
||||
return nil, fmt.Errorf("protocol mismatch: requested %d, got %d", proto, mapResp.Protocol)
|
||||
}
|
||||
if mapResp.InternalPort != uint16(internalPort) {
|
||||
return nil, fmt.Errorf("internal port mismatch: requested %d, got %d", internalPort, mapResp.InternalPort)
|
||||
}
|
||||
|
||||
if mapResp.ResultCode != ResultSuccess {
|
||||
return nil, &Error{
|
||||
Code: mapResp.ResultCode,
|
||||
Message: ResultCodeString(mapResp.ResultCode),
|
||||
}
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
if c.updateEpochLocked(mapResp.Epoch) {
|
||||
log.Warnf("PCP server epoch indicates state loss - mappings may need refresh")
|
||||
}
|
||||
c.cacheExternalIPLocked(mapResp.ExternalIP)
|
||||
c.mu.Unlock()
|
||||
return mapResp, nil
|
||||
}
|
||||
|
||||
// DeletePortMapping removes a port mapping by requesting zero lifetime.
|
||||
func (c *Client) DeletePortMapping(ctx context.Context, protocol string, internalPort int) error {
|
||||
if _, err := c.addPortMappingWithHint(ctx, protocol, internalPort, 0, netip.Addr{}, 0); err != nil {
|
||||
var pcpErr *Error
|
||||
if errors.As(err, &pcpErr) && pcpErr.Code == ResultNotAuthorized {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("delete mapping: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetExternalAddress returns the external IP address.
|
||||
// First checks for a cached value from previous MAP responses.
|
||||
// If not cached, creates a short-lived mapping to discover the external IP.
|
||||
func (c *Client) GetExternalAddress(ctx context.Context) (net.IP, error) {
|
||||
c.mu.Lock()
|
||||
if c.externalIP.IsValid() {
|
||||
ip := c.externalIP.AsSlice()
|
||||
c.mu.Unlock()
|
||||
return ip, nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
// Use an ephemeral port in the dynamic range (49152-65535).
|
||||
// Port 0 is not valid with UDP/TCP protocols per RFC 6887.
|
||||
ephemeralPort := 49152 + int(uint16(time.Now().UnixNano()))%(65535-49152)
|
||||
|
||||
// Use minimal lifetime (1 second) for discovery.
|
||||
resp, err := c.AddPortMapping(ctx, "udp", ephemeralPort, time.Second)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create temporary mapping: %w", err)
|
||||
}
|
||||
|
||||
if err := c.DeletePortMapping(ctx, "udp", ephemeralPort); err != nil {
|
||||
log.Debugf("cleanup temporary PCP mapping: %v", err)
|
||||
}
|
||||
|
||||
return resp.ExternalIP.AsSlice(), nil
|
||||
}
|
||||
|
||||
// LastEpoch returns the last observed server epoch value.
|
||||
// A decrease in epoch indicates the server may have restarted and mappings may be lost.
|
||||
func (c *Client) LastEpoch() uint32 {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.lastEpoch
|
||||
}
|
||||
|
||||
// EpochStateLost returns true if epoch state loss was detected and clears the flag.
|
||||
func (c *Client) EpochStateLost() bool {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
lost := c.epochStateLost
|
||||
c.epochStateLost = false
|
||||
return lost
|
||||
}
|
||||
|
||||
// updateEpoch updates the epoch tracking and detects potential state loss.
|
||||
// Returns true if state loss was detected (server likely restarted).
|
||||
// Caller must hold c.mu.
|
||||
func (c *Client) updateEpochLocked(newEpoch uint32) bool {
|
||||
now := time.Now()
|
||||
stateLost := false
|
||||
|
||||
// RFC 6887 Section 8.5: Detect invalid epoch indicating server state loss.
|
||||
// client_delta = time since last response
|
||||
// server_delta = epoch change since last response
|
||||
// Invalid if: client_delta+2 < server_delta - server_delta/16
|
||||
// OR: server_delta+2 < client_delta - client_delta/16
|
||||
// The +2 handles quantization, /16 (6.25%) handles clock drift.
|
||||
if !c.epochTime.IsZero() && c.lastEpoch > 0 {
|
||||
clientDelta := uint32(now.Sub(c.epochTime).Seconds())
|
||||
serverDelta := newEpoch - c.lastEpoch
|
||||
|
||||
// Check for epoch going backwards or jumping unexpectedly.
|
||||
// Subtraction is safe: serverDelta/16 is always <= serverDelta.
|
||||
if clientDelta+2 < serverDelta-(serverDelta/16) ||
|
||||
serverDelta+2 < clientDelta-(clientDelta/16) {
|
||||
stateLost = true
|
||||
c.epochStateLost = true
|
||||
}
|
||||
}
|
||||
|
||||
c.lastEpoch = newEpoch
|
||||
c.epochTime = now
|
||||
return stateLost
|
||||
}
|
||||
|
||||
// cacheExternalIP stores the external IP from a successful MAP response.
|
||||
// Caller must hold c.mu.
|
||||
func (c *Client) cacheExternalIPLocked(ip netip.Addr) {
|
||||
if ip.IsValid() && !ip.IsUnspecified() {
|
||||
c.externalIP = ip
|
||||
}
|
||||
}
|
||||
|
||||
// sendRequest sends a PCP request with retries per RFC 6887 Section 8.1.1.
|
||||
func (c *Client) sendRequest(ctx context.Context, req []byte) ([]byte, error) {
|
||||
addr := &net.UDPAddr{IP: c.gateway.AsSlice(), Port: Port}
|
||||
|
||||
var lastErr error
|
||||
delay := initialRetryDelay
|
||||
|
||||
for range maxRetries {
|
||||
resp, err := c.sendOnce(ctx, addr, req)
|
||||
if err == nil {
|
||||
return resp, nil
|
||||
}
|
||||
lastErr = err
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
|
||||
// RFC 6887 Section 8.1.1: RT = (1 + RAND) * MIN(2 * RTprev, MRT)
|
||||
// RAND is random between -0.1 and +0.1
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(retryDelayWithJitter(delay)):
|
||||
}
|
||||
delay = min(delay*2, maxRetryDelay)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("PCP request failed after %d retries: %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
// retryDelayWithJitter applies RFC 6887 jitter: multiply by (1 + RAND) where RAND is [-0.1, +0.1].
|
||||
func retryDelayWithJitter(d time.Duration) time.Duration {
|
||||
var b [1]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
// Convert byte to range [-0.1, +0.1]: (b/255 * 0.2) - 0.1
|
||||
jitter := (float64(b[0])/255.0)*0.2 - 0.1
|
||||
return time.Duration(float64(d) * (1 + jitter))
|
||||
}
|
||||
|
||||
func (c *Client) sendOnce(ctx context.Context, addr *net.UDPAddr, req []byte) ([]byte, error) {
|
||||
// Use ListenUDP instead of DialUDP to validate response source address per RFC 6887 §8.3.
|
||||
conn, err := net.ListenUDP("udp", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listen: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := conn.Close(); err != nil {
|
||||
log.Debugf("close UDP connection: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
timeout := c.timeout
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
if remaining := time.Until(deadline); remaining < timeout {
|
||||
timeout = remaining
|
||||
}
|
||||
}
|
||||
|
||||
if err := conn.SetDeadline(time.Now().Add(timeout)); err != nil {
|
||||
return nil, fmt.Errorf("set deadline: %w", err)
|
||||
}
|
||||
|
||||
if _, err := conn.WriteToUDP(req, addr); err != nil {
|
||||
return nil, fmt.Errorf("write: %w", err)
|
||||
}
|
||||
|
||||
resp := make([]byte, responseBufferSize)
|
||||
n, from, err := conn.ReadFromUDP(resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read: %w", err)
|
||||
}
|
||||
|
||||
// RFC 6887 §8.3: Validate response came from expected PCP server.
|
||||
if !from.IP.Equal(addr.IP) {
|
||||
return nil, fmt.Errorf("response from unexpected source %s (expected %s)", from.IP, addr.IP)
|
||||
}
|
||||
|
||||
return resp[:n], nil
|
||||
}
|
||||
|
||||
func (c *Client) getLocalIP() (netip.Addr, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if !c.localIP.IsValid() {
|
||||
return netip.Addr{}, fmt.Errorf("local IP not set for gateway %s", c.gateway)
|
||||
}
|
||||
return c.localIP, nil
|
||||
}
|
||||
|
||||
func protocolNumber(protocol string) (uint8, error) {
|
||||
switch protocol {
|
||||
case "udp", "UDP":
|
||||
return ProtoUDP, nil
|
||||
case "tcp", "TCP":
|
||||
return ProtoTCP, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unsupported protocol: %s", protocol)
|
||||
}
|
||||
}
|
||||
|
||||
// Error represents a PCP error response.
|
||||
type Error struct {
|
||||
Code uint8
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
return fmt.Sprintf("PCP error: %s (%d)", e.Message, e.Code)
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
package pcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAddrConversion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
addr netip.Addr
|
||||
}{
|
||||
{"IPv4", netip.MustParseAddr("192.168.1.100")},
|
||||
{"IPv4 loopback", netip.MustParseAddr("127.0.0.1")},
|
||||
{"IPv6", netip.MustParseAddr("2001:db8::1")},
|
||||
{"IPv6 loopback", netip.MustParseAddr("::1")},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
b16 := addrTo16(tt.addr)
|
||||
|
||||
recovered := addrFrom16(b16)
|
||||
assert.Equal(t, tt.addr, recovered, "address should round-trip")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAnnounceRequest(t *testing.T) {
|
||||
clientIP := netip.MustParseAddr("192.168.1.100")
|
||||
req := buildAnnounceRequest(clientIP)
|
||||
|
||||
require.Len(t, req, headerSize)
|
||||
assert.Equal(t, byte(Version), req[0], "version")
|
||||
assert.Equal(t, byte(OpAnnounce), req[1], "opcode")
|
||||
|
||||
// Check client IP is properly encoded as IPv4-mapped IPv6
|
||||
assert.Equal(t, byte(0xff), req[18], "IPv4-mapped prefix byte 10")
|
||||
assert.Equal(t, byte(0xff), req[19], "IPv4-mapped prefix byte 11")
|
||||
assert.Equal(t, byte(192), req[20], "IP octet 1")
|
||||
assert.Equal(t, byte(168), req[21], "IP octet 2")
|
||||
assert.Equal(t, byte(1), req[22], "IP octet 3")
|
||||
assert.Equal(t, byte(100), req[23], "IP octet 4")
|
||||
}
|
||||
|
||||
func TestBuildMapRequest(t *testing.T) {
|
||||
clientIP := netip.MustParseAddr("192.168.1.100")
|
||||
nonce := [12]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12}
|
||||
req := buildMapRequest(clientIP, nonce, ProtoUDP, 51820, 51820, netip.Addr{}, 3600)
|
||||
|
||||
require.Len(t, req, mapRequestSize)
|
||||
assert.Equal(t, byte(Version), req[0], "version")
|
||||
assert.Equal(t, byte(OpMap), req[1], "opcode")
|
||||
|
||||
// Lifetime at bytes 4-7
|
||||
assert.Equal(t, uint32(3600), (uint32(req[4])<<24)|(uint32(req[5])<<16)|(uint32(req[6])<<8)|uint32(req[7]), "lifetime")
|
||||
|
||||
// Nonce at bytes 24-35
|
||||
assert.Equal(t, nonce[:], req[24:36], "nonce")
|
||||
|
||||
// Protocol at byte 36
|
||||
assert.Equal(t, byte(ProtoUDP), req[36], "protocol")
|
||||
|
||||
// Internal port at bytes 40-41
|
||||
assert.Equal(t, uint16(51820), (uint16(req[40])<<8)|uint16(req[41]), "internal port")
|
||||
|
||||
// External port at bytes 42-43
|
||||
assert.Equal(t, uint16(51820), (uint16(req[42])<<8)|uint16(req[43]), "external port")
|
||||
}
|
||||
|
||||
func TestParseResponse(t *testing.T) {
|
||||
// Construct a valid ANNOUNCE response
|
||||
resp := make([]byte, headerSize)
|
||||
resp[0] = Version
|
||||
resp[1] = OpAnnounce | OpReply
|
||||
// Result code = 0 (success)
|
||||
// Lifetime = 0
|
||||
// Epoch = 12345
|
||||
resp[8] = 0
|
||||
resp[9] = 0
|
||||
resp[10] = 0x30
|
||||
resp[11] = 0x39
|
||||
|
||||
parsed, err := parseResponse(resp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, uint8(Version), parsed.Version)
|
||||
assert.Equal(t, uint8(OpAnnounce|OpReply), parsed.Opcode)
|
||||
assert.Equal(t, uint8(ResultSuccess), parsed.ResultCode)
|
||||
assert.Equal(t, uint32(12345), parsed.Epoch)
|
||||
}
|
||||
|
||||
func TestParseResponseErrors(t *testing.T) {
|
||||
t.Run("too short", func(t *testing.T) {
|
||||
_, err := parseResponse([]byte{1, 2, 3})
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("wrong version", func(t *testing.T) {
|
||||
resp := make([]byte, headerSize)
|
||||
resp[0] = 1 // Wrong version
|
||||
resp[1] = OpReply
|
||||
_, err := parseResponse(resp)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("missing reply bit", func(t *testing.T) {
|
||||
resp := make([]byte, headerSize)
|
||||
resp[0] = Version
|
||||
resp[1] = OpAnnounce // Missing OpReply bit
|
||||
_, err := parseResponse(resp)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestResultCodeString(t *testing.T) {
|
||||
assert.Equal(t, "SUCCESS", ResultCodeString(ResultSuccess))
|
||||
assert.Equal(t, "NOT_AUTHORIZED", ResultCodeString(ResultNotAuthorized))
|
||||
assert.Equal(t, "ADDRESS_MISMATCH", ResultCodeString(ResultAddressMismatch))
|
||||
assert.Contains(t, ResultCodeString(255), "UNKNOWN")
|
||||
}
|
||||
|
||||
func TestProtocolNumber(t *testing.T) {
|
||||
proto, err := protocolNumber("udp")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, uint8(ProtoUDP), proto)
|
||||
|
||||
proto, err = protocolNumber("tcp")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, uint8(ProtoTCP), proto)
|
||||
|
||||
proto, err = protocolNumber("UDP")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, uint8(ProtoUDP), proto)
|
||||
|
||||
_, err = protocolNumber("icmp")
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestClientCreation(t *testing.T) {
|
||||
gateway := netip.MustParseAddr("192.168.1.1").AsSlice()
|
||||
|
||||
client := NewClient(gateway)
|
||||
assert.Equal(t, net.IP(gateway), client.Gateway())
|
||||
assert.Equal(t, defaultTimeout, client.timeout)
|
||||
|
||||
clientWithTimeout := NewClientWithTimeout(gateway, 5*time.Second)
|
||||
assert.Equal(t, 5*time.Second, clientWithTimeout.timeout)
|
||||
}
|
||||
|
||||
func TestNATType(t *testing.T) {
|
||||
n := NewNAT(netip.MustParseAddr("192.168.1.1").AsSlice(), netip.MustParseAddr("192.168.1.100").AsSlice())
|
||||
assert.Equal(t, "PCP", n.Type())
|
||||
}
|
||||
|
||||
// Integration test - skipped unless PCP_TEST_GATEWAY env is set
|
||||
func TestClientIntegration(t *testing.T) {
|
||||
t.Skip("Integration test - run manually with PCP_TEST_GATEWAY=<gateway-ip>")
|
||||
|
||||
gateway := netip.MustParseAddr("10.0.1.1").AsSlice() // Change to your test gateway
|
||||
localIP := netip.MustParseAddr("10.0.1.100").AsSlice() // Change to your local IP
|
||||
|
||||
client := NewClient(gateway)
|
||||
client.SetLocalIP(localIP)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Test ANNOUNCE
|
||||
epoch, err := client.Announce(ctx)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Server epoch: %d", epoch)
|
||||
|
||||
// Test MAP
|
||||
resp, err := client.AddPortMapping(ctx, "udp", 51820, 1*time.Hour)
|
||||
require.NoError(t, err)
|
||||
t.Logf("Mapping: internal=%d external=%d externalIP=%s",
|
||||
resp.InternalPort, resp.ExternalPort, resp.ExternalIP)
|
||||
|
||||
// Cleanup
|
||||
err = client.DeletePortMapping(ctx, "udp", 51820)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
package pcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/libp2p/go-nat"
|
||||
"github.com/libp2p/go-netroute"
|
||||
)
|
||||
|
||||
var _ nat.NAT = (*NAT)(nil)
|
||||
|
||||
// NAT implements the go-nat NAT interface using PCP.
|
||||
// Supports dual-stack (IPv4 and IPv6) when available.
|
||||
// All methods are safe for concurrent use.
|
||||
//
|
||||
// TODO: IPv6 pinholes use the local IPv6 address. If the address changes
|
||||
// (e.g., due to SLAAC rotation or network change), the pinhole becomes stale
|
||||
// and needs to be recreated with the new address.
|
||||
type NAT struct {
|
||||
client *Client
|
||||
|
||||
mu sync.RWMutex
|
||||
// client6 is the IPv6 PCP client, nil if IPv6 is unavailable.
|
||||
client6 *Client
|
||||
// localIP6 caches the local IPv6 address used for PCP requests.
|
||||
localIP6 netip.Addr
|
||||
}
|
||||
|
||||
// NewNAT creates a new NAT instance backed by PCP.
|
||||
func NewNAT(gateway, localIP net.IP) *NAT {
|
||||
client := NewClient(gateway)
|
||||
client.SetLocalIP(localIP)
|
||||
return &NAT{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
// Type returns "PCP" as the NAT type.
|
||||
func (n *NAT) Type() string {
|
||||
return "PCP"
|
||||
}
|
||||
|
||||
// GetDeviceAddress returns the gateway IP address.
|
||||
func (n *NAT) GetDeviceAddress() (net.IP, error) {
|
||||
return n.client.Gateway(), nil
|
||||
}
|
||||
|
||||
// GetExternalAddress returns the external IP address.
|
||||
func (n *NAT) GetExternalAddress() (net.IP, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
return n.client.GetExternalAddress(ctx)
|
||||
}
|
||||
|
||||
// GetInternalAddress returns the local IP address used to communicate with the gateway.
|
||||
func (n *NAT) GetInternalAddress() (net.IP, error) {
|
||||
addr, err := n.client.getLocalIP()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return addr.AsSlice(), nil
|
||||
}
|
||||
|
||||
// AddPortMapping creates a port mapping on both IPv4 and IPv6 (if available).
|
||||
func (n *NAT) AddPortMapping(ctx context.Context, protocol string, internalPort int, _ string, timeout time.Duration) (int, error) {
|
||||
resp, err := n.client.AddPortMapping(ctx, protocol, internalPort, timeout)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("add mapping: %w", err)
|
||||
}
|
||||
|
||||
n.mu.RLock()
|
||||
client6 := n.client6
|
||||
localIP6 := n.localIP6
|
||||
n.mu.RUnlock()
|
||||
|
||||
if client6 == nil {
|
||||
return int(resp.ExternalPort), nil
|
||||
}
|
||||
|
||||
if _, err := client6.AddPortMapping(ctx, protocol, internalPort, timeout); err != nil {
|
||||
log.Warnf("IPv6 PCP mapping failed (continuing with IPv4): %v", err)
|
||||
return int(resp.ExternalPort), nil
|
||||
}
|
||||
|
||||
log.Infof("created IPv6 PCP pinhole: %s:%d", localIP6, internalPort)
|
||||
return int(resp.ExternalPort), nil
|
||||
}
|
||||
|
||||
// DeletePortMapping removes a port mapping from both IPv4 and IPv6.
|
||||
func (n *NAT) DeletePortMapping(ctx context.Context, protocol string, internalPort int) error {
|
||||
err := n.client.DeletePortMapping(ctx, protocol, internalPort)
|
||||
|
||||
n.mu.RLock()
|
||||
client6 := n.client6
|
||||
n.mu.RUnlock()
|
||||
|
||||
if client6 != nil {
|
||||
if err6 := client6.DeletePortMapping(ctx, protocol, internalPort); err6 != nil {
|
||||
log.Warnf("IPv6 PCP delete mapping failed: %v", err6)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete mapping: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CheckServerHealth sends an ANNOUNCE to verify the server is still responsive.
|
||||
// Returns the current epoch and whether the server may have restarted (epoch state loss detected).
|
||||
func (n *NAT) CheckServerHealth(ctx context.Context) (epoch uint32, serverRestarted bool, err error) {
|
||||
epoch, err = n.client.Announce(ctx)
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("announce: %w", err)
|
||||
}
|
||||
return epoch, n.client.EpochStateLost(), nil
|
||||
}
|
||||
|
||||
// DiscoverPCP attempts to discover a PCP-capable gateway.
|
||||
// Returns a NAT interface if PCP is supported, or an error otherwise.
|
||||
// Discovers both IPv4 and IPv6 gateways when available.
|
||||
func DiscoverPCP(ctx context.Context) (nat.NAT, error) {
|
||||
gateway, localIP, err := getDefaultGateway()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get default gateway: %w", err)
|
||||
}
|
||||
|
||||
client := NewClient(gateway)
|
||||
client.SetLocalIP(localIP)
|
||||
if _, err := client.Announce(ctx); err != nil {
|
||||
return nil, fmt.Errorf("PCP announce: %w", err)
|
||||
}
|
||||
|
||||
result := &NAT{client: client}
|
||||
discoverIPv6(ctx, result)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func discoverIPv6(ctx context.Context, result *NAT) {
|
||||
gateway6, localIP6, err := getDefaultGateway6()
|
||||
if err != nil {
|
||||
log.Debugf("IPv6 gateway discovery failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
client6 := NewClient(gateway6)
|
||||
client6.SetLocalIP(localIP6)
|
||||
if _, err := client6.Announce(ctx); err != nil {
|
||||
log.Debugf("PCP IPv6 announce failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
addr, ok := netip.AddrFromSlice(localIP6)
|
||||
if !ok {
|
||||
log.Debugf("invalid IPv6 local IP: %v", localIP6)
|
||||
return
|
||||
}
|
||||
result.mu.Lock()
|
||||
result.client6 = client6
|
||||
result.localIP6 = addr
|
||||
result.mu.Unlock()
|
||||
log.Debugf("PCP IPv6 gateway discovered: %s (local: %s)", gateway6, localIP6)
|
||||
}
|
||||
|
||||
// getDefaultGateway returns the default IPv4 gateway and local IP using the system routing table.
|
||||
func getDefaultGateway() (gateway net.IP, localIP net.IP, err error) {
|
||||
router, err := netroute.New()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
_, gateway, localIP, err = router.Route(net.IPv4zero)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if gateway == nil {
|
||||
return nil, nil, nat.ErrNoNATFound
|
||||
}
|
||||
|
||||
return gateway, localIP, nil
|
||||
}
|
||||
|
||||
// getDefaultGateway6 returns the default IPv6 gateway IP address using the system routing table.
|
||||
func getDefaultGateway6() (gateway net.IP, localIP net.IP, err error) {
|
||||
router, err := netroute.New()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
_, gateway, localIP, err = router.Route(net.IPv6zero)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if gateway == nil {
|
||||
return nil, nil, nat.ErrNoNATFound
|
||||
}
|
||||
|
||||
return gateway, localIP, nil
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
// Package pcp implements the Port Control Protocol (RFC 6887).
|
||||
//
|
||||
// # Implemented Features
|
||||
//
|
||||
// - ANNOUNCE opcode: Discovers PCP server support
|
||||
// - MAP opcode: Creates/deletes port mappings (IPv4 NAT) and firewall pinholes (IPv6)
|
||||
// - Dual-stack: Simultaneous IPv4 and IPv6 support via separate clients
|
||||
// - Nonce validation: Prevents response spoofing
|
||||
// - Epoch tracking: Detects server restarts per Section 8.5
|
||||
// - RFC-compliant retry timing: 3s initial, exponential backoff to 1024s max (Section 8.1.1)
|
||||
//
|
||||
// # Not Implemented
|
||||
//
|
||||
// - PEER opcode: For outbound peer connections (not needed for inbound NAT traversal)
|
||||
// - THIRD_PARTY option: For managing mappings on behalf of other devices
|
||||
// - PREFER_FAILURE option: Requires exact external port or fail (IPv4 NAT only, not needed for IPv6 pinholing)
|
||||
// - FILTER option: To restrict remote peer addresses
|
||||
//
|
||||
// These optional features are omitted because the primary use case is simple
|
||||
// port forwarding for WireGuard, which only requires MAP with default behavior.
|
||||
package pcp
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
)
|
||||
|
||||
const (
|
||||
// Version is the PCP protocol version (RFC 6887).
|
||||
Version = 2
|
||||
|
||||
// Port is the standard PCP server port.
|
||||
Port = 5351
|
||||
|
||||
// DefaultLifetime is the default requested mapping lifetime in seconds.
|
||||
DefaultLifetime = 7200 // 2 hours
|
||||
|
||||
// Header sizes
|
||||
headerSize = 24
|
||||
mapPayloadSize = 36
|
||||
mapRequestSize = headerSize + mapPayloadSize // 60 bytes
|
||||
)
|
||||
|
||||
// Opcodes
|
||||
const (
|
||||
OpAnnounce = 0
|
||||
OpMap = 1
|
||||
OpPeer = 2
|
||||
OpReply = 0x80 // OR'd with opcode in responses
|
||||
)
|
||||
|
||||
// Protocol numbers for MAP requests
|
||||
const (
|
||||
ProtoUDP = 17
|
||||
ProtoTCP = 6
|
||||
)
|
||||
|
||||
// Result codes (RFC 6887 Section 7.4)
|
||||
const (
|
||||
ResultSuccess = 0
|
||||
ResultUnsuppVersion = 1
|
||||
ResultNotAuthorized = 2
|
||||
ResultMalformedRequest = 3
|
||||
ResultUnsuppOpcode = 4
|
||||
ResultUnsuppOption = 5
|
||||
ResultMalformedOption = 6
|
||||
ResultNetworkFailure = 7
|
||||
ResultNoResources = 8
|
||||
ResultUnsuppProtocol = 9
|
||||
ResultUserExQuota = 10
|
||||
ResultCannotProvideExt = 11
|
||||
ResultAddressMismatch = 12
|
||||
ResultExcessiveRemotePeers = 13
|
||||
)
|
||||
|
||||
// ResultCodeString returns a human-readable string for a result code.
|
||||
func ResultCodeString(code uint8) string {
|
||||
switch code {
|
||||
case ResultSuccess:
|
||||
return "SUCCESS"
|
||||
case ResultUnsuppVersion:
|
||||
return "UNSUPP_VERSION"
|
||||
case ResultNotAuthorized:
|
||||
return "NOT_AUTHORIZED"
|
||||
case ResultMalformedRequest:
|
||||
return "MALFORMED_REQUEST"
|
||||
case ResultUnsuppOpcode:
|
||||
return "UNSUPP_OPCODE"
|
||||
case ResultUnsuppOption:
|
||||
return "UNSUPP_OPTION"
|
||||
case ResultMalformedOption:
|
||||
return "MALFORMED_OPTION"
|
||||
case ResultNetworkFailure:
|
||||
return "NETWORK_FAILURE"
|
||||
case ResultNoResources:
|
||||
return "NO_RESOURCES"
|
||||
case ResultUnsuppProtocol:
|
||||
return "UNSUPP_PROTOCOL"
|
||||
case ResultUserExQuota:
|
||||
return "USER_EX_QUOTA"
|
||||
case ResultCannotProvideExt:
|
||||
return "CANNOT_PROVIDE_EXTERNAL"
|
||||
case ResultAddressMismatch:
|
||||
return "ADDRESS_MISMATCH"
|
||||
case ResultExcessiveRemotePeers:
|
||||
return "EXCESSIVE_REMOTE_PEERS"
|
||||
default:
|
||||
return fmt.Sprintf("UNKNOWN(%d)", code)
|
||||
}
|
||||
}
|
||||
|
||||
// Response represents a parsed PCP response header.
|
||||
type Response struct {
|
||||
Version uint8
|
||||
Opcode uint8
|
||||
ResultCode uint8
|
||||
Lifetime uint32
|
||||
Epoch uint32
|
||||
}
|
||||
|
||||
// MapResponse contains the full response to a MAP request.
|
||||
type MapResponse struct {
|
||||
Response
|
||||
Nonce [12]byte
|
||||
Protocol uint8
|
||||
InternalPort uint16
|
||||
ExternalPort uint16
|
||||
ExternalIP netip.Addr
|
||||
}
|
||||
|
||||
// addrTo16 converts an address to its 16-byte IPv4-mapped IPv6 representation.
|
||||
func addrTo16(addr netip.Addr) [16]byte {
|
||||
if addr.Is4() {
|
||||
return netip.AddrFrom4(addr.As4()).As16()
|
||||
}
|
||||
return addr.As16()
|
||||
}
|
||||
|
||||
// addrFrom16 extracts an address from a 16-byte representation, unmapping IPv4.
|
||||
func addrFrom16(b [16]byte) netip.Addr {
|
||||
return netip.AddrFrom16(b).Unmap()
|
||||
}
|
||||
|
||||
// buildAnnounceRequest creates a PCP ANNOUNCE request packet.
|
||||
func buildAnnounceRequest(clientIP netip.Addr) []byte {
|
||||
req := make([]byte, headerSize)
|
||||
req[0] = Version
|
||||
req[1] = OpAnnounce
|
||||
mapped := addrTo16(clientIP)
|
||||
copy(req[8:24], mapped[:])
|
||||
return req
|
||||
}
|
||||
|
||||
// buildMapRequest creates a PCP MAP request packet.
|
||||
func buildMapRequest(clientIP netip.Addr, nonce [12]byte, protocol uint8, internalPort, suggestedExtPort uint16, suggestedExtIP netip.Addr, lifetime uint32) []byte {
|
||||
req := make([]byte, mapRequestSize)
|
||||
|
||||
// Header
|
||||
req[0] = Version
|
||||
req[1] = OpMap
|
||||
binary.BigEndian.PutUint32(req[4:8], lifetime)
|
||||
mapped := addrTo16(clientIP)
|
||||
copy(req[8:24], mapped[:])
|
||||
|
||||
// MAP payload
|
||||
copy(req[24:36], nonce[:])
|
||||
req[36] = protocol
|
||||
binary.BigEndian.PutUint16(req[40:42], internalPort)
|
||||
binary.BigEndian.PutUint16(req[42:44], suggestedExtPort)
|
||||
if suggestedExtIP.IsValid() {
|
||||
extMapped := addrTo16(suggestedExtIP)
|
||||
copy(req[44:60], extMapped[:])
|
||||
}
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
// parseResponse parses the common PCP response header.
|
||||
func parseResponse(data []byte) (*Response, error) {
|
||||
if len(data) < headerSize {
|
||||
return nil, fmt.Errorf("response too short: %d bytes", len(data))
|
||||
}
|
||||
|
||||
resp := &Response{
|
||||
Version: data[0],
|
||||
Opcode: data[1],
|
||||
ResultCode: data[3], // Byte 2 is reserved, byte 3 is result code (RFC 6887 §7.2)
|
||||
Lifetime: binary.BigEndian.Uint32(data[4:8]),
|
||||
Epoch: binary.BigEndian.Uint32(data[8:12]),
|
||||
}
|
||||
|
||||
if resp.Version != Version {
|
||||
return nil, fmt.Errorf("unsupported PCP version: %d", resp.Version)
|
||||
}
|
||||
|
||||
if resp.Opcode&OpReply == 0 {
|
||||
return nil, fmt.Errorf("response missing reply bit: opcode=0x%02x", resp.Opcode)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// parseMapResponse parses a complete MAP response.
|
||||
func parseMapResponse(data []byte) (*MapResponse, error) {
|
||||
if len(data) < mapRequestSize {
|
||||
return nil, fmt.Errorf("MAP response too short: %d bytes", len(data))
|
||||
}
|
||||
|
||||
resp, err := parseResponse(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse header: %w", err)
|
||||
}
|
||||
|
||||
mapResp := &MapResponse{
|
||||
Response: *resp,
|
||||
Protocol: data[36],
|
||||
InternalPort: binary.BigEndian.Uint16(data[40:42]),
|
||||
ExternalPort: binary.BigEndian.Uint16(data[42:44]),
|
||||
ExternalIP: addrFrom16([16]byte(data[44:60])),
|
||||
}
|
||||
copy(mapResp.Nonce[:], data[24:36])
|
||||
|
||||
return mapResp, nil
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
//go:build !js
|
||||
|
||||
package portforward
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/libp2p/go-nat"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/portforward/pcp"
|
||||
)
|
||||
|
||||
// discoverGateway is the function used for NAT gateway discovery.
|
||||
// It can be replaced in tests to avoid real network operations.
|
||||
// Tries PCP first, then falls back to NAT-PMP/UPnP.
|
||||
var discoverGateway = defaultDiscoverGateway
|
||||
|
||||
func defaultDiscoverGateway(ctx context.Context) (nat.NAT, error) {
|
||||
pcpGateway, err := pcp.DiscoverPCP(ctx)
|
||||
if err == nil {
|
||||
return pcpGateway, nil
|
||||
}
|
||||
log.Debugf("PCP discovery failed: %v, trying NAT-PMP/UPnP", err)
|
||||
|
||||
return nat.DiscoverGateway(ctx)
|
||||
}
|
||||
|
||||
// State is persisted only for crash recovery cleanup
|
||||
type State struct {
|
||||
InternalPort uint16 `json:"internal_port,omitempty"`
|
||||
Protocol string `json:"protocol,omitempty"`
|
||||
}
|
||||
|
||||
func (s *State) Name() string {
|
||||
return "port_forward_state"
|
||||
}
|
||||
|
||||
// Cleanup implements statemanager.CleanableState for crash recovery
|
||||
func (s *State) Cleanup() error {
|
||||
if s.InternalPort == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Infof("cleaning up stale port mapping for port %d", s.InternalPort)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), discoveryTimeout)
|
||||
defer cancel()
|
||||
|
||||
gateway, err := discoverGateway(ctx)
|
||||
if err != nil {
|
||||
// Discovery failure is not an error - gateway may not exist
|
||||
log.Debugf("cleanup: no gateway found: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := gateway.DeletePortMapping(ctx, s.Protocol, int(s.InternalPort)); err != nil {
|
||||
return fmt.Errorf("delete port mapping: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -97,6 +97,9 @@ type ConfigInput struct {
|
||||
LazyConnectionEnabled *bool
|
||||
|
||||
MTU *uint16
|
||||
|
||||
InspectionCACertPath string
|
||||
InspectionCAKeyPath string
|
||||
}
|
||||
|
||||
// Config Configuration type
|
||||
@@ -171,6 +174,13 @@ type Config struct {
|
||||
LazyConnectionEnabled bool
|
||||
|
||||
MTU uint16
|
||||
|
||||
// InspectionCACertPath is the path to a PEM CA certificate for transparent proxy MITM.
|
||||
// Local CA takes priority over management-pushed CA.
|
||||
InspectionCACertPath string
|
||||
|
||||
// InspectionCAKeyPath is the path to the PEM CA private key for transparent proxy MITM.
|
||||
InspectionCAKeyPath string
|
||||
}
|
||||
|
||||
var ConfigDirOverride string
|
||||
@@ -603,6 +613,17 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.InspectionCACertPath != "" && input.InspectionCACertPath != config.InspectionCACertPath {
|
||||
log.Infof("updating inspection CA cert path to %s", input.InspectionCACertPath)
|
||||
config.InspectionCACertPath = input.InspectionCACertPath
|
||||
updated = true
|
||||
}
|
||||
if input.InspectionCAKeyPath != "" && input.InspectionCAKeyPath != config.InspectionCAKeyPath {
|
||||
log.Infof("updating inspection CA key path to %s", input.InspectionCAKeyPath)
|
||||
config.InspectionCAKeyPath = input.InspectionCAKeyPath
|
||||
updated = true
|
||||
}
|
||||
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -168,7 +168,6 @@ func (m *DefaultManager) setupAndroidRoutes(config ManagerConfig) {
|
||||
NetworkType: route.IPv4Network,
|
||||
}
|
||||
cr = append(cr, fakeIPRoute)
|
||||
m.notifier.SetFakeIPRoute(fakeIPRoute)
|
||||
}
|
||||
|
||||
m.notifier.SetInitialClientRoutes(cr, routesForComparison)
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
type Notifier struct {
|
||||
initialRoutes []*route.Route
|
||||
currentRoutes []*route.Route
|
||||
fakeIPRoute *route.Route
|
||||
|
||||
listener listener.NetworkChangeListener
|
||||
listenerMux sync.Mutex
|
||||
@@ -32,17 +31,13 @@ func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
|
||||
n.listener = listener
|
||||
}
|
||||
|
||||
// SetInitialClientRoutes stores the initial route sets for TUN configuration.
|
||||
// SetInitialClientRoutes stores the full initial route set (including fake IP blocks)
|
||||
// and a separate comparison set (without fake IP blocks) for diff detection.
|
||||
func (n *Notifier) SetInitialClientRoutes(initialRoutes []*route.Route, routesForComparison []*route.Route) {
|
||||
n.initialRoutes = filterStatic(initialRoutes)
|
||||
n.currentRoutes = filterStatic(routesForComparison)
|
||||
}
|
||||
|
||||
// SetFakeIPRoute stores the fake IP route to be included in every TUN rebuild.
|
||||
func (n *Notifier) SetFakeIPRoute(r *route.Route) {
|
||||
n.fakeIPRoute = r
|
||||
}
|
||||
|
||||
func (n *Notifier) OnNewRoutes(idMap route.HAMap) {
|
||||
var newRoutes []*route.Route
|
||||
for _, routes := range idMap {
|
||||
@@ -74,9 +69,7 @@ func (n *Notifier) notify() {
|
||||
}
|
||||
|
||||
allRoutes := slices.Clone(n.currentRoutes)
|
||||
if n.fakeIPRoute != nil {
|
||||
allRoutes = append(allRoutes, n.fakeIPRoute)
|
||||
}
|
||||
allRoutes = append(allRoutes, n.extraInitialRoutes()...)
|
||||
|
||||
routeStrings := n.routesToStrings(allRoutes)
|
||||
sort.Strings(routeStrings)
|
||||
@@ -85,6 +78,23 @@ func (n *Notifier) notify() {
|
||||
}(n.listener)
|
||||
}
|
||||
|
||||
// extraInitialRoutes returns initialRoutes whose network prefix is absent
|
||||
// from currentRoutes (e.g. the fake IP block added at setup time).
|
||||
func (n *Notifier) extraInitialRoutes() []*route.Route {
|
||||
currentNets := make(map[netip.Prefix]struct{}, len(n.currentRoutes))
|
||||
for _, r := range n.currentRoutes {
|
||||
currentNets[r.Network] = struct{}{}
|
||||
}
|
||||
|
||||
var extra []*route.Route
|
||||
for _, r := range n.initialRoutes {
|
||||
if _, ok := currentNets[r.Network]; !ok {
|
||||
extra = append(extra, r)
|
||||
}
|
||||
}
|
||||
return extra
|
||||
}
|
||||
|
||||
func filterStatic(routes []*route.Route) []*route.Route {
|
||||
out := make([]*route.Route, 0, len(routes))
|
||||
for _, r := range routes {
|
||||
|
||||
@@ -34,10 +34,6 @@ func (n *Notifier) SetInitialClientRoutes([]*route.Route, []*route.Route) {
|
||||
// iOS doesn't care about initial routes
|
||||
}
|
||||
|
||||
func (n *Notifier) SetFakeIPRoute(*route.Route) {
|
||||
// Not used on iOS
|
||||
}
|
||||
|
||||
func (n *Notifier) OnNewRoutes(route.HAMap) {
|
||||
// Not used on iOS
|
||||
}
|
||||
|
||||
@@ -23,10 +23,6 @@ func (n *Notifier) SetInitialClientRoutes([]*route.Route, []*route.Route) {
|
||||
// Not used on non-mobile platforms
|
||||
}
|
||||
|
||||
func (n *Notifier) SetFakeIPRoute(*route.Route) {
|
||||
// Not used on non-mobile platforms
|
||||
}
|
||||
|
||||
func (n *Notifier) OnNewRoutes(idMap route.HAMap) {
|
||||
// Not used on non-mobile platforms
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
//go:build (dragonfly || freebsd || netbsd || openbsd) && !darwin
|
||||
|
||||
package systemops
|
||||
|
||||
// Non-darwin BSDs don't support the IP_BOUND_IF + scoped default model. They
|
||||
// always fall through to the ref-counter exclusion-route path; these stubs
|
||||
// exist only so systemops_unix.go compiles.
|
||||
func (r *SysOps) setupAdvancedRouting() error { return nil }
|
||||
func (r *SysOps) cleanupAdvancedRouting() error { return nil }
|
||||
func (r *SysOps) flushPlatformExtras() error { return nil }
|
||||
@@ -1,241 +0,0 @@
|
||||
//go:build darwin && !ios
|
||||
|
||||
package systemops
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/go-multierror"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/net/route"
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
nberrors "github.com/netbirdio/netbird/client/errors"
|
||||
"github.com/netbirdio/netbird/client/internal/routemanager/vars"
|
||||
nbnet "github.com/netbirdio/netbird/client/net"
|
||||
)
|
||||
|
||||
// scopedRouteBudget bounds retries for the scoped default route. Installing or
|
||||
// deleting it matters enough that we're willing to spend longer waiting for the
|
||||
// kernel reply than for per-prefix exclusion routes.
|
||||
const scopedRouteBudget = 5 * time.Second
|
||||
|
||||
// setupAdvancedRouting installs an RTF_IFSCOPE default route per address family
|
||||
// pinned to the current physical egress, so IP_BOUND_IF scoped lookups can
|
||||
// resolve gateway'd destinations while the VPN's split default owns the
|
||||
// unscoped table.
|
||||
//
|
||||
// Timing note: this runs during routeManager.Init, which happens before the
|
||||
// VPN interface is created and before any peer routes propagate. The initial
|
||||
// mgmt / signal / relay TCP dials always fire before this runs, so those
|
||||
// sockets miss the IP_BOUND_IF binding and rely on the kernel's normal route
|
||||
// lookup, which at that point correctly picks the physical default. Those
|
||||
// already-established TCP flows keep their originally-selected interface for
|
||||
// their lifetime on Darwin because the kernel caches the egress route
|
||||
// per-socket at connect time; adding the VPN's 0/1 + 128/1 split default
|
||||
// afterwards does not migrate them since the original en0 default stays in
|
||||
// the table. Any subsequent reconnect via nbnet.NewDialer picks up the
|
||||
// populated bound-iface cache and gets IP_BOUND_IF set cleanly.
|
||||
func (r *SysOps) setupAdvancedRouting() error {
|
||||
// Drop any previously-cached egress interface before reinstalling. On a
|
||||
// refresh, a family that no longer resolves would otherwise keep the stale
|
||||
// binding, causing new sockets to scope to an interface without a matching
|
||||
// scoped default.
|
||||
nbnet.ClearBoundInterfaces()
|
||||
|
||||
if err := r.flushScopedDefaults(); err != nil {
|
||||
log.Warnf("flush residual scoped defaults: %v", err)
|
||||
}
|
||||
|
||||
var merr *multierror.Error
|
||||
installed := 0
|
||||
|
||||
for _, unspec := range []netip.Addr{netip.IPv4Unspecified(), netip.IPv6Unspecified()} {
|
||||
ok, err := r.installScopedDefaultFor(unspec)
|
||||
if err != nil {
|
||||
merr = multierror.Append(merr, err)
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
installed++
|
||||
}
|
||||
}
|
||||
|
||||
if installed == 0 && merr != nil {
|
||||
return nberrors.FormatErrorOrNil(merr)
|
||||
}
|
||||
if merr != nil {
|
||||
log.Warnf("advanced routing setup partially succeeded: %v", nberrors.FormatErrorOrNil(merr))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// installScopedDefaultFor resolves the physical default nexthop for the given
|
||||
// address family, installs a scoped default via it, and caches the iface for
|
||||
// subsequent IP_BOUND_IF / IPV6_BOUND_IF socket binds.
|
||||
func (r *SysOps) installScopedDefaultFor(unspec netip.Addr) (bool, error) {
|
||||
nexthop, err := GetNextHop(unspec)
|
||||
if err != nil {
|
||||
if errors.Is(err, vars.ErrRouteNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("get default nexthop for %s: %w", unspec, err)
|
||||
}
|
||||
if nexthop.Intf == nil {
|
||||
return false, fmt.Errorf("unusable default nexthop for %s (no interface)", unspec)
|
||||
}
|
||||
|
||||
if err := r.addScopedDefault(unspec, nexthop); err != nil {
|
||||
return false, fmt.Errorf("add scoped default on %s: %w", nexthop.Intf.Name, err)
|
||||
}
|
||||
|
||||
af := unix.AF_INET
|
||||
if unspec.Is6() {
|
||||
af = unix.AF_INET6
|
||||
}
|
||||
nbnet.SetBoundInterface(af, nexthop.Intf)
|
||||
via := "point-to-point"
|
||||
if nexthop.IP.IsValid() {
|
||||
via = nexthop.IP.String()
|
||||
}
|
||||
log.Infof("installed scoped default route via %s on %s for %s", via, nexthop.Intf.Name, afOf(unspec))
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (r *SysOps) cleanupAdvancedRouting() error {
|
||||
nbnet.ClearBoundInterfaces()
|
||||
return r.flushScopedDefaults()
|
||||
}
|
||||
|
||||
// flushPlatformExtras runs darwin-specific residual cleanup hooked into the
|
||||
// generic FlushMarkedRoutes path, so a crashed daemon's scoped defaults get
|
||||
// removed on the next boot regardless of whether a profile is brought up.
|
||||
func (r *SysOps) flushPlatformExtras() error {
|
||||
return r.flushScopedDefaults()
|
||||
}
|
||||
|
||||
// flushScopedDefaults removes any scoped default routes tagged with routeProtoFlag.
|
||||
// Safe to call at startup to clear residual entries from a prior session.
|
||||
func (r *SysOps) flushScopedDefaults() error {
|
||||
rib, err := retryFetchRIB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetch routing table: %w", err)
|
||||
}
|
||||
|
||||
msgs, err := route.ParseRIB(route.RIBTypeRoute, rib)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse routing table: %w", err)
|
||||
}
|
||||
|
||||
var merr *multierror.Error
|
||||
removed := 0
|
||||
|
||||
for _, msg := range msgs {
|
||||
rtMsg, ok := msg.(*route.RouteMessage)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if rtMsg.Flags&routeProtoFlag == 0 {
|
||||
continue
|
||||
}
|
||||
if rtMsg.Flags&unix.RTF_IFSCOPE == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
info, err := MsgToRoute(rtMsg)
|
||||
if err != nil {
|
||||
log.Debugf("skip scoped flush: %v", err)
|
||||
continue
|
||||
}
|
||||
if !info.Dst.IsValid() || info.Dst.Bits() != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := r.deleteScopedRoute(rtMsg); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("delete scoped default %s on index %d: %w",
|
||||
info.Dst, rtMsg.Index, err))
|
||||
continue
|
||||
}
|
||||
removed++
|
||||
log.Debugf("flushed residual scoped default %s on index %d", info.Dst, rtMsg.Index)
|
||||
}
|
||||
|
||||
if removed > 0 {
|
||||
log.Infof("flushed %d residual scoped default route(s)", removed)
|
||||
}
|
||||
return nberrors.FormatErrorOrNil(merr)
|
||||
}
|
||||
|
||||
func (r *SysOps) addScopedDefault(unspec netip.Addr, nexthop Nexthop) error {
|
||||
return r.scopedRouteSocket(unix.RTM_ADD, unspec, nexthop)
|
||||
}
|
||||
|
||||
func (r *SysOps) deleteScopedRoute(rtMsg *route.RouteMessage) error {
|
||||
// Preserve identifying flags from the stored route (including RTF_GATEWAY
|
||||
// only if present); kernel-set bits like RTF_DONE don't belong on RTM_DELETE.
|
||||
keep := unix.RTF_UP | unix.RTF_STATIC | unix.RTF_GATEWAY | unix.RTF_IFSCOPE | routeProtoFlag
|
||||
del := &route.RouteMessage{
|
||||
Type: unix.RTM_DELETE,
|
||||
Flags: rtMsg.Flags & keep,
|
||||
Version: unix.RTM_VERSION,
|
||||
Seq: r.getSeq(),
|
||||
Index: rtMsg.Index,
|
||||
Addrs: rtMsg.Addrs,
|
||||
}
|
||||
return r.writeRouteMessage(del, scopedRouteBudget)
|
||||
}
|
||||
|
||||
func (r *SysOps) scopedRouteSocket(action int, unspec netip.Addr, nexthop Nexthop) error {
|
||||
flags := unix.RTF_UP | unix.RTF_STATIC | unix.RTF_IFSCOPE | routeProtoFlag
|
||||
|
||||
msg := &route.RouteMessage{
|
||||
Type: action,
|
||||
Flags: flags,
|
||||
Version: unix.RTM_VERSION,
|
||||
ID: uintptr(os.Getpid()),
|
||||
Seq: r.getSeq(),
|
||||
Index: nexthop.Intf.Index,
|
||||
}
|
||||
|
||||
const numAddrs = unix.RTAX_NETMASK + 1
|
||||
addrs := make([]route.Addr, numAddrs)
|
||||
|
||||
dst, err := addrToRouteAddr(unspec)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build destination: %w", err)
|
||||
}
|
||||
mask, err := prefixToRouteNetmask(netip.PrefixFrom(unspec, 0))
|
||||
if err != nil {
|
||||
return fmt.Errorf("build netmask: %w", err)
|
||||
}
|
||||
addrs[unix.RTAX_DST] = dst
|
||||
addrs[unix.RTAX_NETMASK] = mask
|
||||
|
||||
if nexthop.IP.IsValid() {
|
||||
msg.Flags |= unix.RTF_GATEWAY
|
||||
gw, err := addrToRouteAddr(nexthop.IP.Unmap())
|
||||
if err != nil {
|
||||
return fmt.Errorf("build gateway: %w", err)
|
||||
}
|
||||
addrs[unix.RTAX_GATEWAY] = gw
|
||||
} else {
|
||||
addrs[unix.RTAX_GATEWAY] = &route.LinkAddr{
|
||||
Index: nexthop.Intf.Index,
|
||||
Name: nexthop.Intf.Name,
|
||||
}
|
||||
}
|
||||
msg.Addrs = addrs
|
||||
|
||||
return r.writeRouteMessage(msg, scopedRouteBudget)
|
||||
}
|
||||
|
||||
func afOf(a netip.Addr) string {
|
||||
if a.Is4() {
|
||||
return "IPv4"
|
||||
}
|
||||
return "IPv6"
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/routemanager/util"
|
||||
"github.com/netbirdio/netbird/client/internal/routemanager/vars"
|
||||
"github.com/netbirdio/netbird/client/internal/statemanager"
|
||||
nbnet "github.com/netbirdio/netbird/client/net"
|
||||
"github.com/netbirdio/netbird/client/net/hooks"
|
||||
)
|
||||
|
||||
@@ -32,6 +31,8 @@ var splitDefaultv4_2 = netip.PrefixFrom(netip.AddrFrom4([4]byte{128}), 1)
|
||||
var splitDefaultv6_1 = netip.PrefixFrom(netip.IPv6Unspecified(), 1)
|
||||
var splitDefaultv6_2 = netip.PrefixFrom(netip.AddrFrom16([16]byte{0x80}), 1)
|
||||
|
||||
var ErrRoutingIsSeparate = errors.New("routing is separate")
|
||||
|
||||
func (r *SysOps) setupRefCounter(initAddresses []net.IP, stateManager *statemanager.Manager) error {
|
||||
stateManager.RegisterState(&ShutdownState{})
|
||||
|
||||
@@ -396,16 +397,12 @@ func ipToAddr(ip net.IP, intf *net.Interface) (netip.Addr, error) {
|
||||
}
|
||||
|
||||
// IsAddrRouted checks if the candidate address would route to the vpn, in which case it returns true and the matched prefix.
|
||||
// When advanced routing is active the WG socket is bound to the physical interface (fwmark on linux,
|
||||
// IP_UNICAST_IF on windows, IP_BOUND_IF on darwin) and bypasses the main routing table, so the check is skipped.
|
||||
func IsAddrRouted(addr netip.Addr, vpnRoutes []netip.Prefix) (bool, netip.Prefix) {
|
||||
if nbnet.AdvancedRouting() {
|
||||
return false, netip.Prefix{}
|
||||
}
|
||||
|
||||
localRoutes, err := GetRoutesFromTable()
|
||||
localRoutes, err := hasSeparateRouting()
|
||||
if err != nil {
|
||||
log.Errorf("Failed to get routes: %v", err)
|
||||
if !errors.Is(err, ErrRoutingIsSeparate) {
|
||||
log.Errorf("Failed to get routes: %v", err)
|
||||
}
|
||||
return false, netip.Prefix{}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,10 @@ func GetRoutesFromTable() ([]netip.Prefix, error) {
|
||||
return []netip.Prefix{}, nil
|
||||
}
|
||||
|
||||
func hasSeparateRouting() ([]netip.Prefix, error) {
|
||||
return []netip.Prefix{}, nil
|
||||
}
|
||||
|
||||
// GetDetailedRoutesFromTable returns empty routes for WASM.
|
||||
func GetDetailedRoutesFromTable() ([]DetailedRoute, error) {
|
||||
return []DetailedRoute{}, nil
|
||||
|
||||
@@ -894,6 +894,13 @@ func getAddressFamily(prefix netip.Prefix) int {
|
||||
return netlink.FAMILY_V6
|
||||
}
|
||||
|
||||
func hasSeparateRouting() ([]netip.Prefix, error) {
|
||||
if !nbnet.AdvancedRouting() {
|
||||
return GetRoutesFromTable()
|
||||
}
|
||||
return nil, ErrRoutingIsSeparate
|
||||
}
|
||||
|
||||
func isOpErr(err error) bool {
|
||||
// EAFTNOSUPPORT when ipv6 is disabled via sysctl, EOPNOTSUPP when disabled in boot options or otherwise not supported
|
||||
if errors.Is(err, syscall.EAFNOSUPPORT) || errors.Is(err, syscall.EOPNOTSUPP) {
|
||||
|
||||
@@ -48,6 +48,10 @@ func EnableIPForwarding() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasSeparateRouting() ([]netip.Prefix, error) {
|
||||
return GetRoutesFromTable()
|
||||
}
|
||||
|
||||
// GetIPRules returns IP rules for debugging (not supported on non-Linux platforms)
|
||||
func GetIPRules() ([]IPRule, error) {
|
||||
log.Infof("IP rules collection is not supported on %s", runtime.GOOS)
|
||||
|
||||
@@ -25,9 +25,6 @@ import (
|
||||
|
||||
const (
|
||||
envRouteProtoFlag = "NB_ROUTE_PROTO_FLAG"
|
||||
|
||||
// routeBudget bounds retries for per-prefix exclusion route programming.
|
||||
routeBudget = 1 * time.Second
|
||||
)
|
||||
|
||||
var routeProtoFlag int
|
||||
@@ -44,42 +41,26 @@ func init() {
|
||||
}
|
||||
|
||||
func (r *SysOps) SetupRouting(initAddresses []net.IP, stateManager *statemanager.Manager, advancedRouting bool) error {
|
||||
if advancedRouting {
|
||||
return r.setupAdvancedRouting()
|
||||
}
|
||||
|
||||
log.Infof("Using legacy routing setup with ref counters")
|
||||
return r.setupRefCounter(initAddresses, stateManager)
|
||||
}
|
||||
|
||||
func (r *SysOps) CleanupRouting(stateManager *statemanager.Manager, advancedRouting bool) error {
|
||||
if advancedRouting {
|
||||
return r.cleanupAdvancedRouting()
|
||||
}
|
||||
|
||||
return r.cleanupRefCounter(stateManager)
|
||||
}
|
||||
|
||||
// FlushMarkedRoutes removes single IP exclusion routes marked with the configured RTF_PROTO flag.
|
||||
// On darwin it also flushes residual RTF_IFSCOPE scoped default routes so a
|
||||
// crashed prior session can't leave crud in the table.
|
||||
func (r *SysOps) FlushMarkedRoutes() error {
|
||||
var merr *multierror.Error
|
||||
|
||||
if err := r.flushPlatformExtras(); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("flush platform extras: %w", err))
|
||||
}
|
||||
|
||||
rib, err := retryFetchRIB()
|
||||
if err != nil {
|
||||
return nberrors.FormatErrorOrNil(multierror.Append(merr, fmt.Errorf("fetch routing table: %w", err)))
|
||||
return fmt.Errorf("fetch routing table: %w", err)
|
||||
}
|
||||
|
||||
msgs, err := route.ParseRIB(route.RIBTypeRoute, rib)
|
||||
if err != nil {
|
||||
return nberrors.FormatErrorOrNil(multierror.Append(merr, fmt.Errorf("parse routing table: %w", err)))
|
||||
return fmt.Errorf("parse routing table: %w", err)
|
||||
}
|
||||
|
||||
var merr *multierror.Error
|
||||
flushedCount := 0
|
||||
|
||||
for _, msg := range msgs {
|
||||
@@ -136,12 +117,12 @@ func (r *SysOps) routeSocket(action int, prefix netip.Prefix, nexthop Nexthop) e
|
||||
return fmt.Errorf("invalid prefix: %s", prefix)
|
||||
}
|
||||
|
||||
msg, err := r.buildRouteMessage(action, prefix, nexthop)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build route message: %w", err)
|
||||
}
|
||||
expBackOff := backoff.NewExponentialBackOff()
|
||||
expBackOff.InitialInterval = 50 * time.Millisecond
|
||||
expBackOff.MaxInterval = 500 * time.Millisecond
|
||||
expBackOff.MaxElapsedTime = 1 * time.Second
|
||||
|
||||
if err := r.writeRouteMessage(msg, routeBudget); err != nil {
|
||||
if err := backoff.Retry(r.routeOp(action, prefix, nexthop), expBackOff); err != nil {
|
||||
a := "add"
|
||||
if action == unix.RTM_DELETE {
|
||||
a = "remove"
|
||||
@@ -151,91 +132,50 @@ func (r *SysOps) routeSocket(action int, prefix netip.Prefix, nexthop Nexthop) e
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeRouteMessage sends a route message over AF_ROUTE and waits for the
|
||||
// kernel's matching reply, retrying transient failures until budget elapses.
|
||||
// Callers do not need to manage sockets or seq numbers themselves.
|
||||
func (r *SysOps) writeRouteMessage(msg *route.RouteMessage, budget time.Duration) error {
|
||||
expBackOff := backoff.NewExponentialBackOff()
|
||||
expBackOff.InitialInterval = 50 * time.Millisecond
|
||||
expBackOff.MaxInterval = 500 * time.Millisecond
|
||||
expBackOff.MaxElapsedTime = budget
|
||||
|
||||
return backoff.Retry(func() error { return routeMessageRoundtrip(msg) }, expBackOff)
|
||||
}
|
||||
|
||||
func routeMessageRoundtrip(msg *route.RouteMessage) error {
|
||||
fd, err := unix.Socket(syscall.AF_ROUTE, syscall.SOCK_RAW, syscall.AF_UNSPEC)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open routing socket: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := unix.Close(fd); err != nil && !errors.Is(err, unix.EBADF) {
|
||||
log.Warnf("close routing socket: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
tv := unix.Timeval{Sec: 1}
|
||||
if err := unix.SetsockoptTimeval(fd, unix.SOL_SOCKET, unix.SO_RCVTIMEO, &tv); err != nil {
|
||||
return backoff.Permanent(fmt.Errorf("set recv timeout: %w", err))
|
||||
}
|
||||
|
||||
// AF_ROUTE is a broadcast channel: every route socket on the host sees
|
||||
// every RTM_* event. With concurrent route programming the default
|
||||
// per-socket queue overflows and our own reply gets dropped.
|
||||
if err := unix.SetsockoptInt(fd, unix.SOL_SOCKET, unix.SO_RCVBUF, 1<<20); err != nil {
|
||||
log.Debugf("set SO_RCVBUF on route socket: %v", err)
|
||||
}
|
||||
|
||||
bytes, err := msg.Marshal()
|
||||
if err != nil {
|
||||
return backoff.Permanent(fmt.Errorf("marshal: %w", err))
|
||||
}
|
||||
|
||||
if _, err = unix.Write(fd, bytes); err != nil {
|
||||
if errors.Is(err, unix.ENOBUFS) || errors.Is(err, unix.EAGAIN) {
|
||||
return fmt.Errorf("write: %w", err)
|
||||
}
|
||||
return backoff.Permanent(fmt.Errorf("write: %w", err))
|
||||
}
|
||||
return readRouteResponse(fd, msg.Type, msg.Seq)
|
||||
}
|
||||
|
||||
// readRouteResponse reads from the AF_ROUTE socket until it sees a reply
|
||||
// matching our write (same type, seq, and pid). AF_ROUTE SOCK_RAW is a
|
||||
// broadcast channel: interface up/down, third-party route changes and neighbor
|
||||
// discovery events can all land between our write and read, so we must filter.
|
||||
func readRouteResponse(fd, wantType, wantSeq int) error {
|
||||
pid := int32(os.Getpid())
|
||||
resp := make([]byte, 2048)
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for {
|
||||
if time.Now().After(deadline) {
|
||||
// Transient: under concurrent pressure the kernel can drop our reply
|
||||
// from the socket buffer. Let backoff.Retry re-send with a fresh seq.
|
||||
return fmt.Errorf("read: timeout waiting for route reply type=%d seq=%d", wantType, wantSeq)
|
||||
}
|
||||
n, err := unix.Read(fd, resp)
|
||||
func (r *SysOps) routeOp(action int, prefix netip.Prefix, nexthop Nexthop) func() error {
|
||||
operation := func() error {
|
||||
fd, err := unix.Socket(syscall.AF_ROUTE, syscall.SOCK_RAW, syscall.AF_UNSPEC)
|
||||
if err != nil {
|
||||
if errors.Is(err, unix.EAGAIN) || errors.Is(err, unix.EWOULDBLOCK) {
|
||||
// SO_RCVTIMEO fired while waiting; loop to re-check the absolute deadline.
|
||||
continue
|
||||
return fmt.Errorf("open routing socket: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := unix.Close(fd); err != nil && !errors.Is(err, unix.EBADF) {
|
||||
log.Warnf("failed to close routing socket: %v", err)
|
||||
}
|
||||
return backoff.Permanent(fmt.Errorf("read: %w", err))
|
||||
}()
|
||||
|
||||
msg, err := r.buildRouteMessage(action, prefix, nexthop)
|
||||
if err != nil {
|
||||
return backoff.Permanent(fmt.Errorf("build route message: %w", err))
|
||||
}
|
||||
if n < int(unsafe.Sizeof(unix.RtMsghdr{})) {
|
||||
continue
|
||||
|
||||
msgBytes, err := msg.Marshal()
|
||||
if err != nil {
|
||||
return backoff.Permanent(fmt.Errorf("marshal route message: %w", err))
|
||||
}
|
||||
hdr := (*unix.RtMsghdr)(unsafe.Pointer(&resp[0]))
|
||||
// Darwin reflects the sender's pid on replies; matching (Type, Seq, Pid)
|
||||
// uniquely identifies our own reply among broadcast traffic.
|
||||
if int(hdr.Type) != wantType || int(hdr.Seq) != wantSeq || hdr.Pid != pid {
|
||||
continue
|
||||
|
||||
if _, err = unix.Write(fd, msgBytes); err != nil {
|
||||
if errors.Is(err, unix.ENOBUFS) || errors.Is(err, unix.EAGAIN) {
|
||||
return fmt.Errorf("write: %w", err)
|
||||
}
|
||||
return backoff.Permanent(fmt.Errorf("write: %w", err))
|
||||
}
|
||||
if hdr.Errno != 0 {
|
||||
return backoff.Permanent(fmt.Errorf("kernel: %w", syscall.Errno(hdr.Errno)))
|
||||
|
||||
respBuf := make([]byte, 2048)
|
||||
n, err := unix.Read(fd, respBuf)
|
||||
if err != nil {
|
||||
return backoff.Permanent(fmt.Errorf("read route response: %w", err))
|
||||
}
|
||||
|
||||
if n > 0 {
|
||||
if err := r.parseRouteResponse(respBuf[:n]); err != nil {
|
||||
return backoff.Permanent(err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
return operation
|
||||
}
|
||||
|
||||
func (r *SysOps) buildRouteMessage(action int, prefix netip.Prefix, nexthop Nexthop) (msg *route.RouteMessage, err error) {
|
||||
@@ -243,7 +183,6 @@ func (r *SysOps) buildRouteMessage(action int, prefix netip.Prefix, nexthop Next
|
||||
Type: action,
|
||||
Flags: unix.RTF_UP | routeProtoFlag,
|
||||
Version: unix.RTM_VERSION,
|
||||
ID: uintptr(os.Getpid()),
|
||||
Seq: r.getSeq(),
|
||||
}
|
||||
|
||||
@@ -282,6 +221,19 @@ func (r *SysOps) buildRouteMessage(action int, prefix netip.Prefix, nexthop Next
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func (r *SysOps) parseRouteResponse(buf []byte) error {
|
||||
if len(buf) < int(unsafe.Sizeof(unix.RtMsghdr{})) {
|
||||
return nil
|
||||
}
|
||||
|
||||
rtMsg := (*unix.RtMsghdr)(unsafe.Pointer(&buf[0]))
|
||||
if rtMsg.Errno != 0 {
|
||||
return fmt.Errorf("parse: %d", rtMsg.Errno)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addrToRouteAddr converts a netip.Addr to the appropriate route.Addr (*route.Inet4Addr or *route.Inet6Addr).
|
||||
func addrToRouteAddr(addr netip.Addr) (route.Addr, error) {
|
||||
if addr.Is4() {
|
||||
|
||||
@@ -2,386 +2,217 @@
|
||||
|
||||
package sleep
|
||||
|
||||
/*
|
||||
#cgo LDFLAGS: -framework IOKit -framework CoreFoundation
|
||||
#include <IOKit/pwr_mgt/IOPMLib.h>
|
||||
#include <IOKit/IOMessage.h>
|
||||
#include <CoreFoundation/CoreFoundation.h>
|
||||
|
||||
extern void sleepCallbackBridge();
|
||||
extern void poweredOnCallbackBridge();
|
||||
extern void suspendedCallbackBridge();
|
||||
extern void resumedCallbackBridge();
|
||||
|
||||
|
||||
// C global variables for IOKit state
|
||||
static IONotificationPortRef g_notifyPortRef = NULL;
|
||||
static io_object_t g_notifierObject = 0;
|
||||
static io_object_t g_generalInterestNotifier = 0;
|
||||
static io_connect_t g_rootPort = 0;
|
||||
static CFRunLoopRef g_runLoop = NULL;
|
||||
|
||||
static void sleepCallback(void* refCon, io_service_t service, natural_t messageType, void* messageArgument) {
|
||||
switch (messageType) {
|
||||
case kIOMessageSystemWillSleep:
|
||||
sleepCallbackBridge();
|
||||
IOAllowPowerChange(g_rootPort, (long)messageArgument);
|
||||
break;
|
||||
case kIOMessageSystemHasPoweredOn:
|
||||
poweredOnCallbackBridge();
|
||||
break;
|
||||
case kIOMessageServiceIsSuspended:
|
||||
suspendedCallbackBridge();
|
||||
break;
|
||||
case kIOMessageServiceIsResumed:
|
||||
resumedCallbackBridge();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
static void registerNotifications() {
|
||||
g_rootPort = IORegisterForSystemPower(
|
||||
NULL,
|
||||
&g_notifyPortRef,
|
||||
(IOServiceInterestCallback)sleepCallback,
|
||||
&g_notifierObject
|
||||
);
|
||||
|
||||
if (g_rootPort == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
CFRunLoopAddSource(CFRunLoopGetCurrent(),
|
||||
IONotificationPortGetRunLoopSource(g_notifyPortRef),
|
||||
kCFRunLoopCommonModes);
|
||||
|
||||
g_runLoop = CFRunLoopGetCurrent();
|
||||
CFRunLoopRun();
|
||||
}
|
||||
|
||||
static void unregisterNotifications() {
|
||||
CFRunLoopRemoveSource(g_runLoop,
|
||||
IONotificationPortGetRunLoopSource(g_notifyPortRef),
|
||||
kCFRunLoopCommonModes);
|
||||
|
||||
IODeregisterForSystemPower(&g_notifierObject);
|
||||
IOServiceClose(g_rootPort);
|
||||
IONotificationPortDestroy(g_notifyPortRef);
|
||||
CFRunLoopStop(g_runLoop);
|
||||
|
||||
g_notifyPortRef = NULL;
|
||||
g_notifierObject = 0;
|
||||
g_rootPort = 0;
|
||||
g_runLoop = NULL;
|
||||
}
|
||||
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/ebitengine/purego"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// IOKit message types from IOKit/IOMessage.h.
|
||||
const (
|
||||
kIOMessageCanSystemSleep uintptr = 0xe0000270
|
||||
kIOMessageSystemWillSleep uintptr = 0xe0000280
|
||||
kIOMessageSystemHasPoweredOn uintptr = 0xe0000300
|
||||
)
|
||||
|
||||
// IOKit / CoreFoundation symbols, resolved once at init.
|
||||
type iokitFuncs struct {
|
||||
IORegisterForSystemPower func(refcon uintptr, portRef *uintptr, callback uintptr, notifier *uintptr) uintptr
|
||||
IODeregisterForSystemPower func(notifier *uintptr) int32
|
||||
IOAllowPowerChange func(kernelPort uintptr, notificationID uintptr) int32
|
||||
IOServiceClose func(connect uintptr) int32
|
||||
IONotificationPortGetRunLoopSource func(port uintptr) uintptr
|
||||
IONotificationPortDestroy func(port uintptr)
|
||||
}
|
||||
|
||||
type cfFuncs struct {
|
||||
CFRunLoopGetCurrent func() uintptr
|
||||
CFRunLoopRun func()
|
||||
CFRunLoopStop func(rl uintptr)
|
||||
CFRunLoopAddSource func(rl, source, mode uintptr)
|
||||
CFRunLoopRemoveSource func(rl, source, mode uintptr)
|
||||
}
|
||||
|
||||
var (
|
||||
ioKit iokitFuncs
|
||||
cf cfFuncs
|
||||
cfCommonModes uintptr
|
||||
|
||||
libInitOnce sync.Once
|
||||
libInitErr error
|
||||
|
||||
// callbackThunk is the single C-callable trampoline registered with IOKit.
|
||||
callbackThunk uintptr
|
||||
|
||||
serviceRegistry = make(map[*Detector]struct{})
|
||||
serviceRegistryMu sync.Mutex
|
||||
|
||||
// lifecycleMu serializes Register and Deregister so concurrent lifecycle
|
||||
// transitions can't interleave (e.g. a new registration starting a second
|
||||
// runloop while the previous teardown is still pending).
|
||||
lifecycleMu sync.Mutex
|
||||
|
||||
// runtime state, protected by serviceRegistryMu
|
||||
runLoopRef uintptr
|
||||
notifyPort uintptr
|
||||
notifierObj uintptr
|
||||
rootPort uintptr
|
||||
runLoopReady chan struct{}
|
||||
runLoopErr error
|
||||
)
|
||||
|
||||
func initLibs() error {
|
||||
libInitOnce.Do(func() {
|
||||
iokit, err := purego.Dlopen("/System/Library/Frameworks/IOKit.framework/IOKit", purego.RTLD_NOW|purego.RTLD_GLOBAL)
|
||||
if err != nil {
|
||||
libInitErr = fmt.Errorf("dlopen IOKit: %w", err)
|
||||
return
|
||||
}
|
||||
cfLib, err := purego.Dlopen("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", purego.RTLD_NOW|purego.RTLD_GLOBAL)
|
||||
if err != nil {
|
||||
libInitErr = fmt.Errorf("dlopen CoreFoundation: %w", err)
|
||||
return
|
||||
}
|
||||
//export sleepCallbackBridge
|
||||
func sleepCallbackBridge() {
|
||||
log.Info("sleepCallbackBridge event triggered")
|
||||
|
||||
purego.RegisterLibFunc(&ioKit.IORegisterForSystemPower, iokit, "IORegisterForSystemPower")
|
||||
purego.RegisterLibFunc(&ioKit.IODeregisterForSystemPower, iokit, "IODeregisterForSystemPower")
|
||||
purego.RegisterLibFunc(&ioKit.IOAllowPowerChange, iokit, "IOAllowPowerChange")
|
||||
purego.RegisterLibFunc(&ioKit.IOServiceClose, iokit, "IOServiceClose")
|
||||
purego.RegisterLibFunc(&ioKit.IONotificationPortGetRunLoopSource, iokit, "IONotificationPortGetRunLoopSource")
|
||||
purego.RegisterLibFunc(&ioKit.IONotificationPortDestroy, iokit, "IONotificationPortDestroy")
|
||||
|
||||
purego.RegisterLibFunc(&cf.CFRunLoopGetCurrent, cfLib, "CFRunLoopGetCurrent")
|
||||
purego.RegisterLibFunc(&cf.CFRunLoopRun, cfLib, "CFRunLoopRun")
|
||||
purego.RegisterLibFunc(&cf.CFRunLoopStop, cfLib, "CFRunLoopStop")
|
||||
purego.RegisterLibFunc(&cf.CFRunLoopAddSource, cfLib, "CFRunLoopAddSource")
|
||||
purego.RegisterLibFunc(&cf.CFRunLoopRemoveSource, cfLib, "CFRunLoopRemoveSource")
|
||||
|
||||
modeAddr, err := purego.Dlsym(cfLib, "kCFRunLoopCommonModes")
|
||||
if err != nil {
|
||||
libInitErr = fmt.Errorf("dlsym kCFRunLoopCommonModes: %w", err)
|
||||
return
|
||||
}
|
||||
// kCFRunLoopCommonModes is a CFStringRef variable. Launder the
|
||||
// uintptr-to-pointer conversion through the address of our Go
|
||||
// variable so go vet's unsafeptr analyzer doesn't flag it; the
|
||||
// address is a stable system-library global, not a Go heap pointer.
|
||||
cfCommonModes = **(**uintptr)(unsafe.Pointer(&modeAddr))
|
||||
|
||||
// Register the callback once for the lifetime of the process. NewCallback slots
|
||||
// are a finite, non-reclaimable resource, so a single thunk that dispatches
|
||||
// to the current Detector set is safer than registering per Register().
|
||||
callbackThunk = purego.NewCallback(powerCallback)
|
||||
})
|
||||
return libInitErr
|
||||
}
|
||||
|
||||
// powerCallback is the IOServiceInterestCallback trampoline. It runs on the
|
||||
// runloop thread (the OS-locked goroutine in runRunLoop). All args are
|
||||
// word-sized so purego can forward them without conversion. A Go panic
|
||||
// crossing the purego boundary has undefined behavior, so contain it here.
|
||||
func powerCallback(refcon, service, messageType, messageArgument uintptr) uintptr {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Errorf("panic in sleep powerCallback: %v", r)
|
||||
}
|
||||
}()
|
||||
switch messageType {
|
||||
case kIOMessageCanSystemSleep:
|
||||
// Consent query that precedes idle sleep; not acknowledging
|
||||
// forces a 30s IOKit timeout before sleep proceeds.
|
||||
allowPowerChange(messageArgument)
|
||||
case kIOMessageSystemWillSleep:
|
||||
dispatchEvent(EventTypeSleep)
|
||||
// Must acknowledge so the system proceeds with sleep.
|
||||
allowPowerChange(messageArgument)
|
||||
case kIOMessageSystemHasPoweredOn:
|
||||
dispatchEvent(EventTypeWakeUp)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func allowPowerChange(messageArgument uintptr) {
|
||||
serviceRegistryMu.Lock()
|
||||
port := rootPort
|
||||
serviceRegistryMu.Unlock()
|
||||
if port != 0 {
|
||||
ioKit.IOAllowPowerChange(port, messageArgument)
|
||||
defer serviceRegistryMu.Unlock()
|
||||
|
||||
for svc := range serviceRegistry {
|
||||
svc.triggerCallback(EventTypeSleep)
|
||||
}
|
||||
}
|
||||
|
||||
func dispatchEvent(event EventType) {
|
||||
serviceRegistryMu.Lock()
|
||||
detectors := make([]*Detector, 0, len(serviceRegistry))
|
||||
for d := range serviceRegistry {
|
||||
detectors = append(detectors, d)
|
||||
}
|
||||
serviceRegistryMu.Unlock()
|
||||
//export resumedCallbackBridge
|
||||
func resumedCallbackBridge() {
|
||||
log.Info("resumedCallbackBridge event triggered")
|
||||
}
|
||||
|
||||
for _, d := range detectors {
|
||||
d.triggerCallback(event)
|
||||
//export suspendedCallbackBridge
|
||||
func suspendedCallbackBridge() {
|
||||
log.Info("suspendedCallbackBridge event triggered")
|
||||
}
|
||||
|
||||
//export poweredOnCallbackBridge
|
||||
func poweredOnCallbackBridge() {
|
||||
log.Info("poweredOnCallbackBridge event triggered")
|
||||
serviceRegistryMu.Lock()
|
||||
defer serviceRegistryMu.Unlock()
|
||||
|
||||
for svc := range serviceRegistry {
|
||||
svc.triggerCallback(EventTypeWakeUp)
|
||||
}
|
||||
}
|
||||
|
||||
type Detector struct {
|
||||
callback func(event EventType)
|
||||
done chan struct{}
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func NewDetector() (*Detector, error) {
|
||||
if err := initLibs(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Detector{}, nil
|
||||
}
|
||||
|
||||
// Register installs callback for power events. The first registration starts
|
||||
// the CFRunLoop on a dedicated OS-locked thread and blocks until IOKit
|
||||
// registration succeeds or fails; subsequent registrations just add to the
|
||||
// dispatch set.
|
||||
func (d *Detector) Register(callback func(event EventType)) error {
|
||||
lifecycleMu.Lock()
|
||||
defer lifecycleMu.Unlock()
|
||||
|
||||
serviceRegistryMu.Lock()
|
||||
defer serviceRegistryMu.Unlock()
|
||||
|
||||
if _, exists := serviceRegistry[d]; exists {
|
||||
serviceRegistryMu.Unlock()
|
||||
return fmt.Errorf("detector service already registered")
|
||||
}
|
||||
|
||||
d.callback = callback
|
||||
d.done = make(chan struct{})
|
||||
|
||||
d.ctx, d.cancel = context.WithCancel(context.Background())
|
||||
|
||||
if len(serviceRegistry) > 0 {
|
||||
serviceRegistry[d] = struct{}{}
|
||||
return nil
|
||||
}
|
||||
|
||||
serviceRegistry[d] = struct{}{}
|
||||
|
||||
if len(serviceRegistry) > 1 {
|
||||
ready := runLoopReady
|
||||
serviceRegistryMu.Unlock()
|
||||
if ready != nil {
|
||||
<-ready
|
||||
}
|
||||
return d.rollbackIfRunLoopFailed()
|
||||
}
|
||||
// CFRunLoop must run on a single fixed OS thread
|
||||
go func() {
|
||||
runtime.LockOSThread()
|
||||
defer runtime.UnlockOSThread()
|
||||
|
||||
runLoopReady = make(chan struct{})
|
||||
runLoopErr = nil
|
||||
ready := runLoopReady
|
||||
serviceRegistryMu.Unlock()
|
||||
|
||||
go runRunLoop()
|
||||
<-ready
|
||||
|
||||
if err := d.rollbackIfRunLoopFailed(); err != nil {
|
||||
serviceRegistryMu.Lock()
|
||||
runLoopReady = nil
|
||||
serviceRegistryMu.Unlock()
|
||||
return err
|
||||
}
|
||||
C.registerNotifications()
|
||||
}()
|
||||
|
||||
log.Info("sleep detection service started on macOS")
|
||||
return nil
|
||||
}
|
||||
|
||||
// rollbackIfRunLoopFailed removes the detector from the registry and returns
|
||||
// the runloop setup error if one occurred. Must be called after runLoopReady
|
||||
// has been closed.
|
||||
func (d *Detector) rollbackIfRunLoopFailed() error {
|
||||
serviceRegistryMu.Lock()
|
||||
err := runLoopErr
|
||||
if err != nil {
|
||||
delete(serviceRegistry, d)
|
||||
close(d.done)
|
||||
d.done = nil
|
||||
}
|
||||
serviceRegistryMu.Unlock()
|
||||
return err
|
||||
}
|
||||
|
||||
// Deregister removes the detector. When the last detector leaves, IOKit
|
||||
// notifications are torn down and the runloop is stopped.
|
||||
// Deregister removes the detector. When the last detector is removed, IOKit registration is torn down
|
||||
// and the runloop is stopped and cleaned up.
|
||||
func (d *Detector) Deregister() error {
|
||||
lifecycleMu.Lock()
|
||||
defer lifecycleMu.Unlock()
|
||||
|
||||
serviceRegistryMu.Lock()
|
||||
if _, exists := serviceRegistry[d]; !exists {
|
||||
serviceRegistryMu.Unlock()
|
||||
defer serviceRegistryMu.Unlock()
|
||||
_, exists := serviceRegistry[d]
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
close(d.done)
|
||||
// cancel and remove this detector
|
||||
d.cancel()
|
||||
delete(serviceRegistry, d)
|
||||
|
||||
// If other Detectors still exist, leave IOKit running
|
||||
if len(serviceRegistry) > 0 {
|
||||
serviceRegistryMu.Unlock()
|
||||
return nil
|
||||
}
|
||||
ready := runLoopReady
|
||||
serviceRegistryMu.Unlock()
|
||||
|
||||
log.Info("sleep detection service stopping (deregister)")
|
||||
|
||||
// Wait for the runloop setup to publish its state before we read it.
|
||||
// If setup already failed, the fields stayed zero and the checks below
|
||||
// become no-ops.
|
||||
if ready != nil {
|
||||
<-ready
|
||||
}
|
||||
|
||||
serviceRegistryMu.Lock()
|
||||
rl := runLoopRef
|
||||
port := notifyPort
|
||||
notifier := notifierObj
|
||||
rp := rootPort
|
||||
serviceRegistryMu.Unlock()
|
||||
|
||||
// CFRunLoopStop and CFRunLoopRemoveSource are thread-safe; deregistering
|
||||
// notifications from another thread is allowed by IOKit.
|
||||
if rl != 0 && port != 0 {
|
||||
source := ioKit.IONotificationPortGetRunLoopSource(port)
|
||||
cf.CFRunLoopRemoveSource(rl, source, cfCommonModes)
|
||||
}
|
||||
if notifier != 0 {
|
||||
n := notifier
|
||||
ioKit.IODeregisterForSystemPower(&n)
|
||||
}
|
||||
if rp != 0 {
|
||||
ioKit.IOServiceClose(rp)
|
||||
}
|
||||
if port != 0 {
|
||||
ioKit.IONotificationPortDestroy(port)
|
||||
}
|
||||
if rl != 0 {
|
||||
cf.CFRunLoopStop(rl)
|
||||
}
|
||||
|
||||
serviceRegistryMu.Lock()
|
||||
runLoopRef = 0
|
||||
notifyPort = 0
|
||||
notifierObj = 0
|
||||
rootPort = 0
|
||||
runLoopReady = nil
|
||||
serviceRegistryMu.Unlock()
|
||||
// Deregister IOKit notifications, stop runloop, and free resources
|
||||
C.unregisterNotifications()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Detector) triggerCallback(event EventType) {
|
||||
cb := d.callback
|
||||
if cb == nil {
|
||||
return
|
||||
}
|
||||
|
||||
doneChan := make(chan struct{})
|
||||
|
||||
timeout := time.NewTimer(500 * time.Millisecond)
|
||||
defer timeout.Stop()
|
||||
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Errorf("panic in sleep callback: %v", r)
|
||||
}
|
||||
}()
|
||||
cb := d.callback
|
||||
go func(callback func(event EventType)) {
|
||||
log.Info("sleep detection event fired")
|
||||
cb(event)
|
||||
}()
|
||||
callback(event)
|
||||
close(doneChan)
|
||||
}(cb)
|
||||
|
||||
select {
|
||||
case <-doneChan:
|
||||
case <-d.done:
|
||||
case <-d.ctx.Done():
|
||||
case <-timeout.C:
|
||||
log.Warn("sleep callback timed out")
|
||||
log.Warnf("sleep callback timed out")
|
||||
}
|
||||
}
|
||||
|
||||
// runRunLoop registers IOKit notifications and blocks on CFRunLoopRun.
|
||||
// Must own a locked OS thread because CFRunLoop is thread-affine. Publishes
|
||||
// runloop state to the package globals, then signals runLoopReady. On setup
|
||||
// failure runLoopErr is set and the goroutine exits without entering the
|
||||
// runloop.
|
||||
func runRunLoop() {
|
||||
runtime.LockOSThread()
|
||||
defer runtime.UnlockOSThread()
|
||||
|
||||
// Ensure runLoopReady is closed even on panic so Register/Deregister
|
||||
// waiters don't hang.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
serviceRegistryMu.Lock()
|
||||
runLoopErr = fmt.Errorf("panic during runloop setup: %v", r)
|
||||
ready := runLoopReady
|
||||
serviceRegistryMu.Unlock()
|
||||
if ready != nil {
|
||||
select {
|
||||
case <-ready:
|
||||
// already closed
|
||||
default:
|
||||
close(ready)
|
||||
}
|
||||
}
|
||||
log.Errorf("panic in sleep runloop: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
var portRef uintptr
|
||||
var notifier uintptr
|
||||
rp := ioKit.IORegisterForSystemPower(0, &portRef, callbackThunk, ¬ifier)
|
||||
if rp == 0 {
|
||||
serviceRegistryMu.Lock()
|
||||
runLoopErr = fmt.Errorf("IORegisterForSystemPower returned zero")
|
||||
ready := runLoopReady
|
||||
serviceRegistryMu.Unlock()
|
||||
close(ready)
|
||||
return
|
||||
}
|
||||
|
||||
rl := cf.CFRunLoopGetCurrent()
|
||||
source := ioKit.IONotificationPortGetRunLoopSource(portRef)
|
||||
cf.CFRunLoopAddSource(rl, source, cfCommonModes)
|
||||
|
||||
serviceRegistryMu.Lock()
|
||||
runLoopRef = rl
|
||||
notifyPort = portRef
|
||||
notifierObj = notifier
|
||||
rootPort = rp
|
||||
ready := runLoopReady
|
||||
serviceRegistryMu.Unlock()
|
||||
close(ready)
|
||||
|
||||
cf.CFRunLoopRun()
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
package net
|
||||
|
||||
func (d *Dialer) init() {
|
||||
d.Dialer.Control = applyBoundIfToSocket
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !linux && !windows && !darwin
|
||||
//go:build !linux && !windows
|
||||
|
||||
package net
|
||||
|
||||
|
||||
24
client/net/env_android.go
Normal file
24
client/net/env_android.go
Normal file
@@ -0,0 +1,24 @@
|
||||
//go:build android
|
||||
|
||||
package net
|
||||
|
||||
// Init initializes the network environment for Android
|
||||
func Init() {
|
||||
// No initialization needed on Android
|
||||
}
|
||||
|
||||
// AdvancedRouting reports whether routing loops can be avoided without using exclusion routes.
|
||||
// Always returns true on Android since we cannot handle routes dynamically.
|
||||
func AdvancedRouting() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// SetVPNInterfaceName is a no-op on Android
|
||||
func SetVPNInterfaceName(name string) {
|
||||
// No-op on Android - not needed for Android VPN service
|
||||
}
|
||||
|
||||
// GetVPNInterfaceName returns empty string on Android
|
||||
func GetVPNInterfaceName() string {
|
||||
return ""
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !linux && !windows && !darwin
|
||||
//go:build !linux && !windows && !android
|
||||
|
||||
package net
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
//go:build ios || android
|
||||
|
||||
package net
|
||||
|
||||
// Init initializes the network environment for mobile platforms.
|
||||
func Init() {
|
||||
// no-op on mobile: routing scope is owned by the VPN extension.
|
||||
}
|
||||
|
||||
// AdvancedRouting reports whether routing loops can be avoided without using exclusion routes.
|
||||
// Always returns true on mobile since routes cannot be handled dynamically and the VPN extension
|
||||
// owns the routing scope.
|
||||
func AdvancedRouting() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// SetVPNInterfaceName is a no-op on mobile.
|
||||
func SetVPNInterfaceName(string) {
|
||||
// no-op on mobile: the VPN extension manages the interface.
|
||||
}
|
||||
|
||||
// GetVPNInterfaceName returns an empty string on mobile.
|
||||
func GetVPNInterfaceName() string {
|
||||
return ""
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build (darwin && !ios) || windows
|
||||
//go:build windows
|
||||
|
||||
package net
|
||||
|
||||
@@ -24,22 +24,17 @@ func Init() {
|
||||
}
|
||||
|
||||
func checkAdvancedRoutingSupport() bool {
|
||||
legacyRouting := false
|
||||
var err error
|
||||
var legacyRouting bool
|
||||
if val := os.Getenv(envUseLegacyRouting); val != "" {
|
||||
parsed, err := strconv.ParseBool(val)
|
||||
legacyRouting, err = strconv.ParseBool(val)
|
||||
if err != nil {
|
||||
log.Warnf("ignoring unparsable %s=%q: %v", envUseLegacyRouting, val, err)
|
||||
} else {
|
||||
legacyRouting = parsed
|
||||
log.Warnf("failed to parse %s: %v", envUseLegacyRouting, err)
|
||||
}
|
||||
}
|
||||
|
||||
if legacyRouting {
|
||||
log.Infof("advanced routing disabled: legacy routing requested via %s", envUseLegacyRouting)
|
||||
return false
|
||||
}
|
||||
if netstack.IsEnabled() {
|
||||
log.Info("advanced routing disabled: netstack mode is enabled")
|
||||
if legacyRouting || netstack.IsEnabled() {
|
||||
log.Info("advanced routing has been requested to be disabled")
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
package net
|
||||
|
||||
func (l *ListenerConfig) init() {
|
||||
l.ListenConfig.Control = applyBoundIfToSocket
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !linux && !windows && !darwin
|
||||
//go:build !linux && !windows
|
||||
|
||||
package net
|
||||
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
package net
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// On darwin IPV6_BOUND_IF also scopes v4-mapped egress from dual-stack
|
||||
// (IPV6_V6ONLY=0) AF_INET6 sockets, so a single setsockopt on "udp6"/"tcp6"
|
||||
// covers both families. Setting IP_BOUND_IF on an AF_INET6 socket returns
|
||||
// EINVAL regardless of V6ONLY because the IPPROTO_IP ctloutput path is
|
||||
// dispatched by socket domain (AF_INET only) not by inp_vflag.
|
||||
|
||||
// boundIface holds the physical interface chosen at routing setup time. Sockets
|
||||
// created via nbnet.NewDialer / nbnet.NewListener bind to it via IP_BOUND_IF
|
||||
// (IPv4) or IPV6_BOUND_IF (IPv6 / dual-stack) so their scoped route lookup
|
||||
// hits the RTF_IFSCOPE default installed by the routemanager, rather than
|
||||
// following the VPN's split default.
|
||||
var (
|
||||
boundIfaceMu sync.RWMutex
|
||||
boundIface4 *net.Interface
|
||||
boundIface6 *net.Interface
|
||||
)
|
||||
|
||||
// SetBoundInterface records the egress interface for an address family. Called
|
||||
// by the routemanager after a scoped default route has been installed.
|
||||
// af must be unix.AF_INET or unix.AF_INET6; other values are ignored.
|
||||
// nil iface is rejected — use ClearBoundInterfaces to clear all slots.
|
||||
func SetBoundInterface(af int, iface *net.Interface) {
|
||||
if iface == nil {
|
||||
log.Warnf("SetBoundInterface: nil iface for AF %d, ignored", af)
|
||||
return
|
||||
}
|
||||
boundIfaceMu.Lock()
|
||||
defer boundIfaceMu.Unlock()
|
||||
switch af {
|
||||
case unix.AF_INET:
|
||||
boundIface4 = iface
|
||||
case unix.AF_INET6:
|
||||
boundIface6 = iface
|
||||
default:
|
||||
log.Warnf("SetBoundInterface: unsupported address family %d", af)
|
||||
}
|
||||
}
|
||||
|
||||
// ClearBoundInterfaces resets the cached egress interfaces. Called by the
|
||||
// routemanager during cleanup.
|
||||
func ClearBoundInterfaces() {
|
||||
boundIfaceMu.Lock()
|
||||
defer boundIfaceMu.Unlock()
|
||||
boundIface4 = nil
|
||||
boundIface6 = nil
|
||||
}
|
||||
|
||||
// boundInterfaceFor returns the cached egress interface for a socket's address
|
||||
// family, falling back to the other family if the preferred slot is empty.
|
||||
// The kernel stores both IP_BOUND_IF and IPV6_BOUND_IF in inp_boundifp, so
|
||||
// either setsockopt scopes the socket; preferring same-family still matters
|
||||
// when v4 and v6 defaults egress different NICs.
|
||||
func boundInterfaceFor(network, address string) *net.Interface {
|
||||
if iface := zoneInterface(address); iface != nil {
|
||||
return iface
|
||||
}
|
||||
|
||||
boundIfaceMu.RLock()
|
||||
defer boundIfaceMu.RUnlock()
|
||||
|
||||
primary, secondary := boundIface4, boundIface6
|
||||
if isV6Network(network) {
|
||||
primary, secondary = boundIface6, boundIface4
|
||||
}
|
||||
if primary != nil {
|
||||
return primary
|
||||
}
|
||||
return secondary
|
||||
}
|
||||
|
||||
func isV6Network(network string) bool {
|
||||
return strings.HasSuffix(network, "6")
|
||||
}
|
||||
|
||||
// zoneInterface extracts an explicit interface from an IPv6 link-local zone (e.g. fe80::1%en0).
|
||||
func zoneInterface(address string) *net.Interface {
|
||||
if address == "" {
|
||||
return nil
|
||||
}
|
||||
addr, err := netip.ParseAddrPort(address)
|
||||
if err != nil {
|
||||
a, err := netip.ParseAddr(address)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
addr = netip.AddrPortFrom(a, 0)
|
||||
}
|
||||
zone := addr.Addr().Zone()
|
||||
if zone == "" {
|
||||
return nil
|
||||
}
|
||||
if iface, err := net.InterfaceByName(zone); err == nil {
|
||||
return iface
|
||||
}
|
||||
if idx, err := strconv.Atoi(zone); err == nil {
|
||||
if iface, err := net.InterfaceByIndex(idx); err == nil {
|
||||
return iface
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setIPv4BoundIf(fd uintptr, iface *net.Interface) error {
|
||||
if err := unix.SetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_BOUND_IF, iface.Index); err != nil {
|
||||
return fmt.Errorf("set IP_BOUND_IF: %w (interface: %s, index: %d)", err, iface.Name, iface.Index)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setIPv6BoundIf(fd uintptr, iface *net.Interface) error {
|
||||
if err := unix.SetsockoptInt(int(fd), unix.IPPROTO_IPV6, unix.IPV6_BOUND_IF, iface.Index); err != nil {
|
||||
return fmt.Errorf("set IPV6_BOUND_IF: %w (interface: %s, index: %d)", err, iface.Name, iface.Index)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyBoundIfToSocket binds the socket to the cached physical egress interface
|
||||
// so scoped route lookup avoids the VPN utun and egresses the underlay directly.
|
||||
func applyBoundIfToSocket(network, address string, c syscall.RawConn) error {
|
||||
if !AdvancedRouting() {
|
||||
return nil
|
||||
}
|
||||
|
||||
iface := boundInterfaceFor(network, address)
|
||||
if iface == nil {
|
||||
log.Debugf("no bound iface cached for %s to %s, skipping BOUND_IF", network, address)
|
||||
return nil
|
||||
}
|
||||
|
||||
isV6 := isV6Network(network)
|
||||
var controlErr error
|
||||
if err := c.Control(func(fd uintptr) {
|
||||
if isV6 {
|
||||
controlErr = setIPv6BoundIf(fd, iface)
|
||||
} else {
|
||||
controlErr = setIPv4BoundIf(fd, iface)
|
||||
}
|
||||
if controlErr == nil {
|
||||
log.Debugf("set BOUND_IF=%d on %s for %s to %s", iface.Index, iface.Name, network, address)
|
||||
}
|
||||
}); err != nil {
|
||||
return fmt.Errorf("control: %w", err)
|
||||
}
|
||||
return controlErr
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -104,6 +104,8 @@ service DaemonService {
|
||||
// StopCPUProfile stops CPU profiling in the daemon
|
||||
rpc StopCPUProfile(StopCPUProfileRequest) returns (StopCPUProfileResponse) {}
|
||||
|
||||
rpc NotifyOSLifecycle(OSLifecycleRequest) returns(OSLifecycleResponse) {}
|
||||
|
||||
rpc GetInstallerResult(InstallerResultRequest) returns (InstallerResultResponse) {}
|
||||
|
||||
// ExposeService exposes a local port via the NetBird reverse proxy
|
||||
@@ -112,6 +114,20 @@ service DaemonService {
|
||||
|
||||
|
||||
|
||||
message OSLifecycleRequest {
|
||||
// avoid collision with loglevel enum
|
||||
enum CycleType {
|
||||
UNKNOWN = 0;
|
||||
SLEEP = 1;
|
||||
WAKEUP = 2;
|
||||
}
|
||||
|
||||
CycleType type = 1;
|
||||
}
|
||||
|
||||
message OSLifecycleResponse {}
|
||||
|
||||
|
||||
message LoginRequest {
|
||||
// setupKey netbird setup key.
|
||||
string setupKey = 1;
|
||||
@@ -711,7 +727,6 @@ message GetFeaturesRequest{}
|
||||
message GetFeaturesResponse{
|
||||
bool disable_profiles = 1;
|
||||
bool disable_update_settings = 2;
|
||||
bool disable_networks = 3;
|
||||
}
|
||||
|
||||
message TriggerUpdateRequest {}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,8 +9,6 @@ import (
|
||||
"strings"
|
||||
|
||||
"golang.org/x/exp/maps"
|
||||
"google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
@@ -29,10 +27,6 @@ func (s *Server) ListNetworks(context.Context, *proto.ListNetworksRequest) (*pro
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
if s.networksDisabled {
|
||||
return nil, gstatus.Errorf(codes.Unavailable, errNetworksDisabled)
|
||||
}
|
||||
|
||||
if s.connectClient == nil {
|
||||
return nil, fmt.Errorf("not connected")
|
||||
}
|
||||
@@ -124,10 +118,6 @@ func (s *Server) SelectNetworks(_ context.Context, req *proto.SelectNetworksRequ
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
if s.networksDisabled {
|
||||
return nil, gstatus.Errorf(codes.Unavailable, errNetworksDisabled)
|
||||
}
|
||||
|
||||
if s.connectClient == nil {
|
||||
return nil, fmt.Errorf("not connected")
|
||||
}
|
||||
@@ -174,10 +164,6 @@ func (s *Server) DeselectNetworks(_ context.Context, req *proto.SelectNetworksRe
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
if s.networksDisabled {
|
||||
return nil, gstatus.Errorf(codes.Unavailable, errNetworksDisabled)
|
||||
}
|
||||
|
||||
if s.connectClient == nil {
|
||||
return nil, fmt.Errorf("not connected")
|
||||
}
|
||||
|
||||
@@ -53,7 +53,6 @@ const (
|
||||
errRestoreResidualState = "failed to restore residual state: %v"
|
||||
errProfilesDisabled = "profiles are disabled, you cannot use this feature without profiles enabled"
|
||||
errUpdateSettingsDisabled = "update settings are disabled, you cannot use this feature without update settings enabled"
|
||||
errNetworksDisabled = "network selection is disabled by the administrator"
|
||||
)
|
||||
|
||||
var ErrServiceNotUp = errors.New("service is not up")
|
||||
@@ -89,7 +88,6 @@ type Server struct {
|
||||
profileManager *profilemanager.ServiceManager
|
||||
profilesDisabled bool
|
||||
updateSettingsDisabled bool
|
||||
networksDisabled bool
|
||||
|
||||
sleepHandler *sleephandler.SleepHandler
|
||||
|
||||
@@ -106,7 +104,7 @@ type oauthAuthFlow struct {
|
||||
}
|
||||
|
||||
// New server instance constructor.
|
||||
func New(ctx context.Context, logFile string, configFile string, profilesDisabled bool, updateSettingsDisabled bool, networksDisabled bool) *Server {
|
||||
func New(ctx context.Context, logFile string, configFile string, profilesDisabled bool, updateSettingsDisabled bool) *Server {
|
||||
s := &Server{
|
||||
rootCtx: ctx,
|
||||
logFile: logFile,
|
||||
@@ -115,12 +113,10 @@ func New(ctx context.Context, logFile string, configFile string, profilesDisable
|
||||
profileManager: profilemanager.NewServiceManager(configFile),
|
||||
profilesDisabled: profilesDisabled,
|
||||
updateSettingsDisabled: updateSettingsDisabled,
|
||||
networksDisabled: networksDisabled,
|
||||
jwtCache: newJWTCache(),
|
||||
}
|
||||
agent := &serverAgent{s}
|
||||
s.sleepHandler = sleephandler.New(agent)
|
||||
s.startSleepDetector()
|
||||
|
||||
return s
|
||||
}
|
||||
@@ -1632,7 +1628,6 @@ func (s *Server) GetFeatures(ctx context.Context, msg *proto.GetFeaturesRequest)
|
||||
features := &proto.GetFeaturesResponse{
|
||||
DisableProfiles: s.checkProfilesDisabled(),
|
||||
DisableUpdateSettings: s.checkUpdateSettingsDisabled(),
|
||||
DisableNetworks: s.networksDisabled,
|
||||
}
|
||||
|
||||
return features, nil
|
||||
|
||||
@@ -36,7 +36,6 @@ import (
|
||||
daemonProto "github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/management/server"
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
nbcache "github.com/netbirdio/netbird/management/server/cache"
|
||||
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
"github.com/netbirdio/netbird/management/server/settings"
|
||||
@@ -104,7 +103,7 @@ func TestConnectWithRetryRuns(t *testing.T) {
|
||||
t.Fatalf("failed to set active profile state: %v", err)
|
||||
}
|
||||
|
||||
s := New(ctx, "debug", "", false, false, false)
|
||||
s := New(ctx, "debug", "", false, false)
|
||||
|
||||
s.config = config
|
||||
|
||||
@@ -165,7 +164,7 @@ func TestServer_Up(t *testing.T) {
|
||||
t.Fatalf("failed to set active profile state: %v", err)
|
||||
}
|
||||
|
||||
s := New(ctx, "console", "", false, false, false)
|
||||
s := New(ctx, "console", "", false, false)
|
||||
err = s.Start()
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -235,7 +234,7 @@ func TestServer_SubcribeEvents(t *testing.T) {
|
||||
t.Fatalf("failed to set active profile state: %v", err)
|
||||
}
|
||||
|
||||
s := New(ctx, "console", "", false, false, false)
|
||||
s := New(ctx, "console", "", false, false)
|
||||
|
||||
err = s.Start()
|
||||
require.NoError(t, err)
|
||||
@@ -310,12 +309,7 @@ func startManagement(t *testing.T, signalAddr string, counter *int) (*grpc.Serve
|
||||
|
||||
jobManager := job.NewJobManager(nil, store, peersManager)
|
||||
|
||||
cacheStore, err := nbcache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
ia, _ := integrations.NewIntegratedValidator(context.Background(), peersManager, settingsManagerMock, eventStore, cacheStore)
|
||||
ia, _ := integrations.NewIntegratedValidator(context.Background(), peersManager, settingsManagerMock, eventStore)
|
||||
|
||||
metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
|
||||
require.NoError(t, err)
|
||||
@@ -326,7 +320,7 @@ func startManagement(t *testing.T, signalAddr string, counter *int) (*grpc.Serve
|
||||
requestBuffer := server.NewAccountRequestBuffer(context.Background(), store)
|
||||
peersUpdateManager := update_channel.NewPeersUpdateManager(metrics)
|
||||
networkMapController := controller.NewController(context.Background(), store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config)
|
||||
accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore)
|
||||
accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
s := New(ctx, "console", "", false, false, false)
|
||||
s := New(ctx, "console", "", false, false)
|
||||
|
||||
rosenpassEnabled := true
|
||||
rosenpassPermissive := true
|
||||
|
||||
@@ -2,18 +2,13 @@ package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/internal/sleep"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
const envDisableSleepDetector = "NB_DISABLE_SLEEP_DETECTOR"
|
||||
|
||||
// serverAgent adapts Server to the handler.Agent and handler.StatusChecker interfaces
|
||||
type serverAgent struct {
|
||||
s *Server
|
||||
@@ -33,61 +28,19 @@ func (a *serverAgent) Status() (internal.StatusType, error) {
|
||||
return internal.CtxGetState(a.s.rootCtx).Status()
|
||||
}
|
||||
|
||||
// startSleepDetector starts the OS sleep/wake detector and forwards events to
|
||||
// the sleep handler. On platforms without a supported detector the attempt
|
||||
// logs a warning and returns. Setting NB_DISABLE_SLEEP_DETECTOR=true skips
|
||||
// registration entirely.
|
||||
func (s *Server) startSleepDetector() {
|
||||
if sleepDetectorDisabled() {
|
||||
log.Info("sleep detection disabled via " + envDisableSleepDetector)
|
||||
return
|
||||
}
|
||||
|
||||
svc, err := sleep.New()
|
||||
if err != nil {
|
||||
log.Warnf("failed to initialize sleep detection: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
err = svc.Register(func(event sleep.EventType) {
|
||||
switch event {
|
||||
case sleep.EventTypeSleep:
|
||||
log.Info("handling sleep event")
|
||||
if err := s.sleepHandler.HandleSleep(s.rootCtx); err != nil {
|
||||
log.Errorf("failed to handle sleep event: %v", err)
|
||||
}
|
||||
case sleep.EventTypeWakeUp:
|
||||
log.Info("handling wakeup event")
|
||||
if err := s.sleepHandler.HandleWakeUp(s.rootCtx); err != nil {
|
||||
log.Errorf("failed to handle wakeup event: %v", err)
|
||||
}
|
||||
// NotifyOSLifecycle handles operating system lifecycle events by executing appropriate logic based on the request type.
|
||||
func (s *Server) NotifyOSLifecycle(callerCtx context.Context, req *proto.OSLifecycleRequest) (*proto.OSLifecycleResponse, error) {
|
||||
switch req.GetType() {
|
||||
case proto.OSLifecycleRequest_WAKEUP:
|
||||
if err := s.sleepHandler.HandleWakeUp(callerCtx); err != nil {
|
||||
return &proto.OSLifecycleResponse{}, err
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
log.Errorf("failed to register sleep detector: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("sleep detection service initialized")
|
||||
|
||||
go func() {
|
||||
<-s.rootCtx.Done()
|
||||
log.Info("stopping sleep event listener")
|
||||
if err := svc.Deregister(); err != nil {
|
||||
log.Errorf("failed to deregister sleep detector: %v", err)
|
||||
case proto.OSLifecycleRequest_SLEEP:
|
||||
if err := s.sleepHandler.HandleSleep(callerCtx); err != nil {
|
||||
return &proto.OSLifecycleResponse{}, err
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func sleepDetectorDisabled() bool {
|
||||
val := os.Getenv(envDisableSleepDetector)
|
||||
if val == "" {
|
||||
return false
|
||||
default:
|
||||
log.Errorf("unknown OSLifecycleRequest type: %v", req.GetType())
|
||||
}
|
||||
disabled, err := strconv.ParseBool(val)
|
||||
if err != nil {
|
||||
log.Warnf("failed to parse %s=%q: %v", envDisableSleepDetector, val, err)
|
||||
return false
|
||||
}
|
||||
return disabled
|
||||
return &proto.OSLifecycleResponse{}, nil
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/internal/routemanager/systemops"
|
||||
"github.com/netbirdio/netbird/client/internal/statemanager"
|
||||
nbnet "github.com/netbirdio/netbird/client/net"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
@@ -137,8 +138,10 @@ func restoreResidualState(ctx context.Context, statePath string) error {
|
||||
}
|
||||
|
||||
// clean up any remaining routes independently of the state file
|
||||
if err := systemops.New(nil, nil).FlushMarkedRoutes(); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("flush marked routes: %w", err))
|
||||
if !nbnet.AdvancedRouting() {
|
||||
if err := systemops.New(nil, nil).FlushMarkedRoutes(); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("flush marked routes: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
return nberrors.FormatErrorOrNil(merr)
|
||||
|
||||
@@ -4,12 +4,10 @@ import (
|
||||
"context"
|
||||
"runtime"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/version"
|
||||
)
|
||||
|
||||
// UpdateStaticInfoAsync is a no-op on iOS as there is no static info to update
|
||||
// UpdateStaticInfoAsync is a no-op on Android as there is no static info to update
|
||||
func UpdateStaticInfoAsync() {
|
||||
// do nothing
|
||||
}
|
||||
@@ -17,24 +15,11 @@ func UpdateStaticInfoAsync() {
|
||||
// GetInfo retrieves and parses the system information
|
||||
func GetInfo(ctx context.Context) *Info {
|
||||
|
||||
// Convert fixed-size byte arrays to Go strings
|
||||
sysName := extractOsName(ctx, "sysName")
|
||||
swVersion := extractOsVersion(ctx, "swVersion")
|
||||
|
||||
addrs, err := networkAddresses()
|
||||
if err != nil {
|
||||
log.Warnf("failed to discover network addresses: %s", err)
|
||||
}
|
||||
|
||||
gio := &Info{
|
||||
Kernel: sysName,
|
||||
OSVersion: swVersion,
|
||||
Platform: "unknown",
|
||||
OS: sysName,
|
||||
GoOS: runtime.GOOS,
|
||||
CPUs: runtime.NumCPU(),
|
||||
KernelVersion: swVersion,
|
||||
NetworkAddresses: addrs,
|
||||
}
|
||||
gio := &Info{Kernel: sysName, OSVersion: swVersion, Platform: "unknown", OS: sysName, GoOS: runtime.GOOS, CPUs: runtime.NumCPU(), KernelVersion: swVersion}
|
||||
gio.Hostname = extractDeviceName(ctx, "hostname")
|
||||
gio.NetbirdVersion = version.NetbirdVersion()
|
||||
gio.UIVersion = extractUserAgent(ctx)
|
||||
|
||||
@@ -38,6 +38,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/iface"
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/internal/sleep"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/ui/desktop"
|
||||
"github.com/netbirdio/netbird/client/ui/event"
|
||||
@@ -313,7 +314,6 @@ type serviceClient struct {
|
||||
lastNotifiedVersion string
|
||||
settingsEnabled bool
|
||||
profilesEnabled bool
|
||||
networksEnabled bool
|
||||
showNetworks bool
|
||||
wNetworks fyne.Window
|
||||
wProfiles fyne.Window
|
||||
@@ -368,7 +368,6 @@ func newServiceClient(args *newServiceClientArgs) *serviceClient {
|
||||
|
||||
showAdvancedSettings: args.showSettings,
|
||||
showNetworks: args.showNetworks,
|
||||
networksEnabled: true,
|
||||
}
|
||||
|
||||
s.eventHandler = newEventHandler(s)
|
||||
@@ -921,10 +920,8 @@ func (s *serviceClient) updateStatus() error {
|
||||
s.mStatus.SetIcon(s.icConnectedDot)
|
||||
s.mUp.Disable()
|
||||
s.mDown.Enable()
|
||||
if s.networksEnabled {
|
||||
s.mNetworks.Enable()
|
||||
s.mExitNode.Enable()
|
||||
}
|
||||
s.mNetworks.Enable()
|
||||
s.mExitNode.Enable()
|
||||
s.startExitNodeRefresh()
|
||||
systrayIconState = true
|
||||
case status.Status == string(internal.StatusConnecting):
|
||||
@@ -1096,14 +1093,14 @@ func (s *serviceClient) onTrayReady() {
|
||||
s.getSrvConfig()
|
||||
time.Sleep(100 * time.Millisecond) // To prevent race condition caused by systray not being fully initialized and ignoring setIcon
|
||||
for {
|
||||
// Check features before status so menus respect disable flags before being enabled
|
||||
s.checkAndUpdateFeatures()
|
||||
|
||||
err := s.updateStatus()
|
||||
if err != nil {
|
||||
log.Errorf("error while updating status: %v", err)
|
||||
}
|
||||
|
||||
// Check features periodically to handle daemon restarts
|
||||
s.checkAndUpdateFeatures()
|
||||
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}()
|
||||
@@ -1145,6 +1142,9 @@ func (s *serviceClient) onTrayReady() {
|
||||
|
||||
go s.eventManager.Start(s.ctx)
|
||||
go s.eventHandler.listen(s.ctx)
|
||||
|
||||
// Start sleep detection listener
|
||||
go s.startSleepListener()
|
||||
}
|
||||
|
||||
func (s *serviceClient) attachOutput(cmd *exec.Cmd) *os.File {
|
||||
@@ -1205,6 +1205,62 @@ func (s *serviceClient) getSrvClient(timeout time.Duration) (proto.DaemonService
|
||||
return s.conn, nil
|
||||
}
|
||||
|
||||
// startSleepListener initializes the sleep detection service and listens for sleep events
|
||||
func (s *serviceClient) startSleepListener() {
|
||||
sleepService, err := sleep.New()
|
||||
if err != nil {
|
||||
log.Warnf("%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := sleepService.Register(s.handleSleepEvents); err != nil {
|
||||
log.Errorf("failed to start sleep detection: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("sleep detection service initialized")
|
||||
|
||||
// Cleanup on context cancellation
|
||||
go func() {
|
||||
<-s.ctx.Done()
|
||||
log.Info("stopping sleep event listener")
|
||||
if err := sleepService.Deregister(); err != nil {
|
||||
log.Errorf("failed to deregister sleep detection: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// handleSleepEvents sends a sleep notification to the daemon via gRPC
|
||||
func (s *serviceClient) handleSleepEvents(event sleep.EventType) {
|
||||
conn, err := s.getSrvClient(0)
|
||||
if err != nil {
|
||||
log.Errorf("failed to get daemon client for sleep notification: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
req := &proto.OSLifecycleRequest{}
|
||||
|
||||
switch event {
|
||||
case sleep.EventTypeWakeUp:
|
||||
log.Infof("handle wakeup event: %v", event)
|
||||
req.Type = proto.OSLifecycleRequest_WAKEUP
|
||||
case sleep.EventTypeSleep:
|
||||
log.Infof("handle sleep event: %v", event)
|
||||
req.Type = proto.OSLifecycleRequest_SLEEP
|
||||
default:
|
||||
log.Infof("unknown event: %v", event)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = conn.NotifyOSLifecycle(s.ctx, req)
|
||||
if err != nil {
|
||||
log.Errorf("failed to notify daemon about os lifecycle notification: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Info("successfully notified daemon about os lifecycle")
|
||||
}
|
||||
|
||||
// setSettingsEnabled enables or disables the settings menu based on the provided state
|
||||
func (s *serviceClient) setSettingsEnabled(enabled bool) {
|
||||
if s.mSettings != nil {
|
||||
@@ -1243,16 +1299,6 @@ func (s *serviceClient) checkAndUpdateFeatures() {
|
||||
s.mProfile.setEnabled(profilesEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
// Update networks and exit node menus based on current features
|
||||
s.networksEnabled = features == nil || !features.DisableNetworks
|
||||
if s.networksEnabled && s.connected {
|
||||
s.mNetworks.Enable()
|
||||
s.mExitNode.Enable()
|
||||
} else {
|
||||
s.mNetworks.Disable()
|
||||
s.mExitNode.Disable()
|
||||
}
|
||||
}
|
||||
|
||||
// getFeatures from the daemon to determine which features are enabled/disabled.
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/connectivity"
|
||||
"google.golang.org/grpc/credentials"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/keepalive"
|
||||
@@ -25,22 +26,11 @@ import (
|
||||
"github.com/netbirdio/netbird/util/wsproxy"
|
||||
)
|
||||
|
||||
var ErrClientClosed = errors.New("client is closed")
|
||||
|
||||
// minHealthyDuration is the minimum time a stream must survive before a failure
|
||||
// resets the backoff timer. Streams that fail faster are considered unhealthy and
|
||||
// should not reset backoff, so that MaxElapsedTime can eventually stop retries.
|
||||
const minHealthyDuration = 5 * time.Second
|
||||
|
||||
type GRPCClient struct {
|
||||
realClient proto.FlowServiceClient
|
||||
clientConn *grpc.ClientConn
|
||||
stream proto.FlowService_EventsClient
|
||||
target string
|
||||
opts []grpc.DialOption
|
||||
closed bool // prevent creating conn in the middle of the Close
|
||||
receiving bool // prevent concurrent Receive calls
|
||||
mu sync.Mutex // protects clientConn, realClient, stream, closed, and receiving
|
||||
streamMu sync.Mutex
|
||||
}
|
||||
|
||||
func NewClient(addr, payload, signature string, interval time.Duration) (*GRPCClient, error) {
|
||||
@@ -75,8 +65,7 @@ func NewClient(addr, payload, signature string, interval time.Duration) (*GRPCCl
|
||||
grpc.WithDefaultServiceConfig(`{"healthCheckConfig": {"serviceName": ""}}`),
|
||||
)
|
||||
|
||||
target := parsedURL.Host
|
||||
conn, err := grpc.NewClient(target, opts...)
|
||||
conn, err := grpc.NewClient(fmt.Sprintf("%s:%s", parsedURL.Hostname(), parsedURL.Port()), opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating new grpc client: %w", err)
|
||||
}
|
||||
@@ -84,73 +73,30 @@ func NewClient(addr, payload, signature string, interval time.Duration) (*GRPCCl
|
||||
return &GRPCClient{
|
||||
realClient: proto.NewFlowServiceClient(conn),
|
||||
clientConn: conn,
|
||||
target: target,
|
||||
opts: opts,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *GRPCClient) Close() error {
|
||||
c.mu.Lock()
|
||||
c.closed = true
|
||||
c.streamMu.Lock()
|
||||
defer c.streamMu.Unlock()
|
||||
|
||||
c.stream = nil
|
||||
conn := c.clientConn
|
||||
c.clientConn = nil
|
||||
c.mu.Unlock()
|
||||
|
||||
if conn == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := conn.Close(); err != nil && !errors.Is(err, context.Canceled) {
|
||||
if err := c.clientConn.Close(); err != nil && !errors.Is(err, context.Canceled) {
|
||||
return fmt.Errorf("close client connection: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *GRPCClient) Send(event *proto.FlowEvent) error {
|
||||
c.mu.Lock()
|
||||
stream := c.stream
|
||||
c.mu.Unlock()
|
||||
|
||||
if stream == nil {
|
||||
return errors.New("stream not initialized")
|
||||
}
|
||||
|
||||
if err := stream.Send(event); err != nil {
|
||||
return fmt.Errorf("send flow event: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *GRPCClient) Receive(ctx context.Context, interval time.Duration, msgHandler func(msg *proto.FlowEventAck) error) error {
|
||||
c.mu.Lock()
|
||||
if c.receiving {
|
||||
c.mu.Unlock()
|
||||
return errors.New("concurrent Receive calls are not supported")
|
||||
}
|
||||
c.receiving = true
|
||||
c.mu.Unlock()
|
||||
defer func() {
|
||||
c.mu.Lock()
|
||||
c.receiving = false
|
||||
c.mu.Unlock()
|
||||
}()
|
||||
|
||||
backOff := defaultBackoff(ctx, interval)
|
||||
operation := func() error {
|
||||
stream, err := c.establishStream(ctx)
|
||||
if err != nil {
|
||||
log.Errorf("failed to establish flow stream, retrying: %v", err)
|
||||
return c.handleRetryableError(err, time.Time{}, backOff)
|
||||
}
|
||||
|
||||
streamStart := time.Now()
|
||||
|
||||
if err := c.receive(stream, msgHandler); err != nil {
|
||||
if err := c.establishStreamAndReceive(ctx, msgHandler); err != nil {
|
||||
if s, ok := status.FromError(err); ok && s.Code() == codes.Canceled {
|
||||
return fmt.Errorf("receive: %w: %w", err, context.Canceled)
|
||||
}
|
||||
log.Errorf("receive failed: %v", err)
|
||||
return c.handleRetryableError(err, streamStart, backOff)
|
||||
return fmt.Errorf("receive: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -162,106 +108,37 @@ func (c *GRPCClient) Receive(ctx context.Context, interval time.Duration, msgHan
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleRetryableError resets the backoff timer if the stream was healthy long
|
||||
// enough and recreates the underlying ClientConn so that gRPC's internal
|
||||
// subchannel backoff does not accumulate and compete with our own retry timer.
|
||||
// A zero streamStart means the stream was never established.
|
||||
func (c *GRPCClient) handleRetryableError(err error, streamStart time.Time, backOff backoff.BackOff) error {
|
||||
if isContextDone(err) {
|
||||
return backoff.Permanent(err)
|
||||
func (c *GRPCClient) establishStreamAndReceive(ctx context.Context, msgHandler func(msg *proto.FlowEventAck) error) error {
|
||||
if c.clientConn.GetState() == connectivity.Shutdown {
|
||||
return errors.New("connection to flow receiver has been shut down")
|
||||
}
|
||||
|
||||
var permErr *backoff.PermanentError
|
||||
if errors.As(err, &permErr) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Reset the backoff so the next retry starts with a short delay instead of
|
||||
// continuing the already-elapsed timer. Only do this if the stream was healthy
|
||||
// long enough; short-lived connect/drop cycles must not defeat MaxElapsedTime.
|
||||
if !streamStart.IsZero() && time.Since(streamStart) >= minHealthyDuration {
|
||||
backOff.Reset()
|
||||
}
|
||||
|
||||
if recreateErr := c.recreateConnection(); recreateErr != nil {
|
||||
log.Errorf("recreate connection: %v", recreateErr)
|
||||
return recreateErr
|
||||
}
|
||||
|
||||
log.Infof("connection recreated, retrying stream")
|
||||
return fmt.Errorf("retrying after error: %w", err)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) recreateConnection() error {
|
||||
c.mu.Lock()
|
||||
if c.closed {
|
||||
c.mu.Unlock()
|
||||
return backoff.Permanent(ErrClientClosed)
|
||||
}
|
||||
|
||||
conn, err := grpc.NewClient(c.target, c.opts...)
|
||||
stream, err := c.realClient.Events(ctx, grpc.WaitForReady(true))
|
||||
if err != nil {
|
||||
c.mu.Unlock()
|
||||
return fmt.Errorf("create new connection: %w", err)
|
||||
return fmt.Errorf("create event stream: %w", err)
|
||||
}
|
||||
|
||||
old := c.clientConn
|
||||
c.clientConn = conn
|
||||
c.realClient = proto.NewFlowServiceClient(conn)
|
||||
c.stream = nil
|
||||
c.mu.Unlock()
|
||||
|
||||
_ = old.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *GRPCClient) establishStream(ctx context.Context) (proto.FlowService_EventsClient, error) {
|
||||
c.mu.Lock()
|
||||
if c.closed {
|
||||
c.mu.Unlock()
|
||||
return nil, backoff.Permanent(ErrClientClosed)
|
||||
}
|
||||
cl := c.realClient
|
||||
c.mu.Unlock()
|
||||
|
||||
// open stream outside the lock — blocking operation
|
||||
stream, err := cl.Events(ctx)
|
||||
err = stream.Send(&proto.FlowEvent{IsInitiator: true})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create event stream: %w", err)
|
||||
}
|
||||
streamReady := false
|
||||
defer func() {
|
||||
if !streamReady {
|
||||
_ = stream.CloseSend()
|
||||
}
|
||||
}()
|
||||
|
||||
if err = stream.Send(&proto.FlowEvent{IsInitiator: true}); err != nil {
|
||||
return nil, fmt.Errorf("send initiator: %w", err)
|
||||
log.Infof("failed to send initiator message to flow receiver but will attempt to continue. Error: %s", err)
|
||||
}
|
||||
|
||||
if err = checkHeader(stream); err != nil {
|
||||
return nil, fmt.Errorf("check header: %w", err)
|
||||
return fmt.Errorf("check header: %w", err)
|
||||
}
|
||||
|
||||
c.mu.Lock()
|
||||
if c.closed {
|
||||
c.mu.Unlock()
|
||||
return nil, backoff.Permanent(ErrClientClosed)
|
||||
}
|
||||
c.streamMu.Lock()
|
||||
c.stream = stream
|
||||
c.mu.Unlock()
|
||||
streamReady = true
|
||||
c.streamMu.Unlock()
|
||||
|
||||
return stream, nil
|
||||
return c.receive(stream, msgHandler)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) receive(stream proto.FlowService_EventsClient, msgHandler func(msg *proto.FlowEventAck) error) error {
|
||||
for {
|
||||
msg, err := stream.Recv()
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("receive from stream: %w", err)
|
||||
}
|
||||
|
||||
if msg.IsInitiator {
|
||||
@@ -292,7 +169,7 @@ func checkHeader(stream proto.FlowService_EventsClient) error {
|
||||
func defaultBackoff(ctx context.Context, interval time.Duration) backoff.BackOff {
|
||||
return backoff.WithContext(&backoff.ExponentialBackOff{
|
||||
InitialInterval: 800 * time.Millisecond,
|
||||
RandomizationFactor: 0.5,
|
||||
RandomizationFactor: 1,
|
||||
Multiplier: 1.7,
|
||||
MaxInterval: interval / 2,
|
||||
MaxElapsedTime: 3 * 30 * 24 * time.Hour, // 3 months
|
||||
@@ -301,12 +178,18 @@ func defaultBackoff(ctx context.Context, interval time.Duration) backoff.BackOff
|
||||
}, ctx)
|
||||
}
|
||||
|
||||
func isContextDone(err error) bool {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return true
|
||||
func (c *GRPCClient) Send(event *proto.FlowEvent) error {
|
||||
c.streamMu.Lock()
|
||||
stream := c.stream
|
||||
c.streamMu.Unlock()
|
||||
|
||||
if stream == nil {
|
||||
return errors.New("stream not initialized")
|
||||
}
|
||||
if s, ok := status.FromError(err); ok {
|
||||
return s.Code() == codes.Canceled || s.Code() == codes.DeadlineExceeded
|
||||
|
||||
if err := stream.Send(event); err != nil {
|
||||
return fmt.Errorf("send flow event: %w", err)
|
||||
}
|
||||
return false
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,11 +2,8 @@ package client_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -14,8 +11,6 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
flow "github.com/netbirdio/netbird/flow/client"
|
||||
"github.com/netbirdio/netbird/flow/proto"
|
||||
@@ -23,89 +18,21 @@ import (
|
||||
|
||||
type testServer struct {
|
||||
proto.UnimplementedFlowServiceServer
|
||||
events chan *proto.FlowEvent
|
||||
acks chan *proto.FlowEventAck
|
||||
grpcSrv *grpc.Server
|
||||
addr string
|
||||
listener *connTrackListener
|
||||
closeStream chan struct{} // signal server to close the stream
|
||||
handlerDone chan struct{} // signaled each time Events() exits
|
||||
handlerStarted chan struct{} // signaled each time Events() begins
|
||||
}
|
||||
|
||||
// connTrackListener wraps a net.Listener to track accepted connections
|
||||
// so tests can forcefully close them to simulate PROTOCOL_ERROR/RST_STREAM.
|
||||
type connTrackListener struct {
|
||||
net.Listener
|
||||
mu sync.Mutex
|
||||
conns []net.Conn
|
||||
}
|
||||
|
||||
func (l *connTrackListener) Accept() (net.Conn, error) {
|
||||
c, err := l.Listener.Accept()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l.mu.Lock()
|
||||
l.conns = append(l.conns, c)
|
||||
l.mu.Unlock()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// sendRSTStream writes a raw HTTP/2 RST_STREAM frame with PROTOCOL_ERROR
|
||||
// (error code 0x1) on every tracked connection. This produces the exact error:
|
||||
//
|
||||
// rpc error: code = Internal desc = stream terminated by RST_STREAM with error code: PROTOCOL_ERROR
|
||||
//
|
||||
// HTTP/2 RST_STREAM frame format (9-byte header + 4-byte payload):
|
||||
//
|
||||
// Length (3 bytes): 0x000004
|
||||
// Type (1 byte): 0x03 (RST_STREAM)
|
||||
// Flags (1 byte): 0x00
|
||||
// Stream ID (4 bytes): target stream (must have bit 31 clear)
|
||||
// Error Code (4 bytes): 0x00000001 (PROTOCOL_ERROR)
|
||||
func (l *connTrackListener) connCount() int {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return len(l.conns)
|
||||
}
|
||||
|
||||
func (l *connTrackListener) sendRSTStream(streamID uint32) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
frame := make([]byte, 13) // 9-byte header + 4-byte payload
|
||||
// Length = 4 (3 bytes, big-endian)
|
||||
frame[0], frame[1], frame[2] = 0, 0, 4
|
||||
// Type = RST_STREAM (0x03)
|
||||
frame[3] = 0x03
|
||||
// Flags = 0
|
||||
frame[4] = 0x00
|
||||
// Stream ID (4 bytes, big-endian, bit 31 reserved = 0)
|
||||
binary.BigEndian.PutUint32(frame[5:9], streamID)
|
||||
// Error Code = PROTOCOL_ERROR (0x1)
|
||||
binary.BigEndian.PutUint32(frame[9:13], 0x1)
|
||||
|
||||
for _, c := range l.conns {
|
||||
_, _ = c.Write(frame)
|
||||
}
|
||||
events chan *proto.FlowEvent
|
||||
acks chan *proto.FlowEventAck
|
||||
grpcSrv *grpc.Server
|
||||
addr string
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T) *testServer {
|
||||
rawListener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
|
||||
listener := &connTrackListener{Listener: rawListener}
|
||||
|
||||
s := &testServer{
|
||||
events: make(chan *proto.FlowEvent, 100),
|
||||
acks: make(chan *proto.FlowEventAck, 100),
|
||||
grpcSrv: grpc.NewServer(),
|
||||
addr: rawListener.Addr().String(),
|
||||
listener: listener,
|
||||
closeStream: make(chan struct{}, 1),
|
||||
handlerDone: make(chan struct{}, 10),
|
||||
handlerStarted: make(chan struct{}, 10),
|
||||
events: make(chan *proto.FlowEvent, 100),
|
||||
acks: make(chan *proto.FlowEventAck, 100),
|
||||
grpcSrv: grpc.NewServer(),
|
||||
addr: listener.Addr().String(),
|
||||
}
|
||||
|
||||
proto.RegisterFlowServiceServer(s.grpcSrv, s)
|
||||
@@ -124,23 +51,11 @@ func newTestServer(t *testing.T) *testServer {
|
||||
}
|
||||
|
||||
func (s *testServer) Events(stream proto.FlowService_EventsServer) error {
|
||||
defer func() {
|
||||
select {
|
||||
case s.handlerDone <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}()
|
||||
|
||||
err := stream.Send(&proto.FlowEventAck{IsInitiator: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
select {
|
||||
case s.handlerStarted <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(stream.Context())
|
||||
defer cancel()
|
||||
|
||||
@@ -176,8 +91,6 @@ func (s *testServer) Events(stream proto.FlowService_EventsServer) error {
|
||||
if err := stream.Send(ack); err != nil {
|
||||
return err
|
||||
}
|
||||
case <-s.closeStream:
|
||||
return status.Errorf(codes.Internal, "server closing stream")
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
@@ -197,13 +110,16 @@ func TestReceive(t *testing.T) {
|
||||
assert.NoError(t, err, "failed to close flow")
|
||||
})
|
||||
|
||||
var ackCount atomic.Int32
|
||||
receivedAcks := make(map[string]bool)
|
||||
receiveDone := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
err := client.Receive(ctx, 1*time.Second, func(msg *proto.FlowEventAck) error {
|
||||
if !msg.IsInitiator && len(msg.EventId) > 0 {
|
||||
if ackCount.Add(1) >= 3 {
|
||||
id := string(msg.EventId)
|
||||
receivedAcks[id] = true
|
||||
|
||||
if len(receivedAcks) >= 3 {
|
||||
close(receiveDone)
|
||||
}
|
||||
}
|
||||
@@ -214,11 +130,7 @@ func TestReceive(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-server.handlerStarted:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("timeout waiting for stream to be established")
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
eventID := uuid.New().String()
|
||||
@@ -241,7 +153,7 @@ func TestReceive(t *testing.T) {
|
||||
t.Fatal("timeout waiting for acks to be processed")
|
||||
}
|
||||
|
||||
assert.Equal(t, int32(3), ackCount.Load())
|
||||
assert.Equal(t, 3, len(receivedAcks))
|
||||
}
|
||||
|
||||
func TestReceive_ContextCancellation(t *testing.T) {
|
||||
@@ -342,195 +254,3 @@ func TestSend(t *testing.T) {
|
||||
t.Fatal("timeout waiting for ack to be received by flow")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClient_PermanentClose(t *testing.T) {
|
||||
server := newTestServer(t)
|
||||
|
||||
client, err := flow.NewClient("http://"+server.addr, "test-payload", "test-signature", 1*time.Second)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = client.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- client.Receive(ctx, 1*time.Second, func(msg *proto.FlowEventAck) error {
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
require.ErrorIs(t, err, flow.ErrClientClosed)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Receive did not return after Close — stuck in retry loop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClient_CloseVerify(t *testing.T) {
|
||||
server := newTestServer(t)
|
||||
|
||||
client, err := flow.NewClient("http://"+server.addr, "test-payload", "test-signature", 1*time.Second)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- client.Receive(ctx, 1*time.Second, func(msg *proto.FlowEventAck) error {
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
|
||||
closeDone := make(chan struct{}, 1)
|
||||
go func() {
|
||||
_ = client.Close()
|
||||
closeDone <- struct{}{}
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
require.Error(t, err)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Receive did not return after Close — stuck in retry loop")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-closeDone:
|
||||
return
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Close did not return — blocked in retry loop")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestClose_WhileReceiving(t *testing.T) {
|
||||
server := newTestServer(t)
|
||||
client, err := flow.NewClient("http://"+server.addr, "test-payload", "test-signature", 1*time.Second)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background() // no timeout — intentional
|
||||
receiveDone := make(chan struct{})
|
||||
go func() {
|
||||
_ = client.Receive(ctx, 1*time.Second, func(msg *proto.FlowEventAck) error {
|
||||
return nil
|
||||
})
|
||||
close(receiveDone)
|
||||
}()
|
||||
|
||||
// Wait for the server-side handler to confirm the stream is established.
|
||||
select {
|
||||
case <-server.handlerStarted:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("timeout waiting for stream to be established")
|
||||
}
|
||||
|
||||
closeDone := make(chan struct{})
|
||||
go func() {
|
||||
_ = client.Close()
|
||||
close(closeDone)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-closeDone:
|
||||
// Close returned — good
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Close blocked forever — Receive stuck in retry loop")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-receiveDone:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Receive did not exit after Close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceive_ProtocolErrorStreamReconnect(t *testing.T) {
|
||||
server := newTestServer(t)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
client, err := flow.NewClient("http://"+server.addr, "test-payload", "test-signature", 1*time.Second)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
err := client.Close()
|
||||
assert.NoError(t, err, "failed to close flow")
|
||||
})
|
||||
|
||||
// Track acks received before and after server-side stream close
|
||||
var ackCount atomic.Int32
|
||||
receivedFirst := make(chan struct{})
|
||||
receivedAfterReconnect := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
err := client.Receive(ctx, 1*time.Second, func(msg *proto.FlowEventAck) error {
|
||||
if msg.IsInitiator || len(msg.EventId) == 0 {
|
||||
return nil
|
||||
}
|
||||
n := ackCount.Add(1)
|
||||
if n == 1 {
|
||||
close(receivedFirst)
|
||||
}
|
||||
if n == 2 {
|
||||
close(receivedAfterReconnect)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
t.Logf("receive error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for stream to be established, then send first ack
|
||||
select {
|
||||
case <-server.handlerStarted:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("timeout waiting for stream to be established")
|
||||
}
|
||||
server.acks <- &proto.FlowEventAck{EventId: []byte("before-close")}
|
||||
|
||||
select {
|
||||
case <-receivedFirst:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("timeout waiting for first ack")
|
||||
}
|
||||
|
||||
// Snapshot connection count before injecting the fault.
|
||||
connsBefore := server.listener.connCount()
|
||||
|
||||
// Send a raw HTTP/2 RST_STREAM frame with PROTOCOL_ERROR on the TCP connection.
|
||||
// gRPC multiplexes streams on stream IDs 1, 3, 5, ... (odd, client-initiated).
|
||||
// Stream ID 1 is the client's first stream (our Events bidi stream).
|
||||
// This produces the exact error the client sees in production:
|
||||
// "stream terminated by RST_STREAM with error code: PROTOCOL_ERROR"
|
||||
server.listener.sendRSTStream(1)
|
||||
|
||||
// Wait for the old Events() handler to fully exit so it can no longer
|
||||
// drain s.acks and drop our injected ack on a broken stream.
|
||||
select {
|
||||
case <-server.handlerDone:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("old Events() handler did not exit after RST_STREAM")
|
||||
}
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
return server.listener.connCount() > connsBefore
|
||||
}, 5*time.Second, 50*time.Millisecond, "client did not open a new TCP connection after RST_STREAM")
|
||||
|
||||
server.acks <- &proto.FlowEventAck{EventId: []byte("after-close")}
|
||||
|
||||
select {
|
||||
case <-receivedAfterReconnect:
|
||||
// Client successfully reconnected and received ack after server-side stream close
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timeout waiting for ack after server-side stream close — client did not reconnect")
|
||||
}
|
||||
|
||||
assert.GreaterOrEqual(t, int(ackCount.Load()), 2, "should have received acks before and after stream close")
|
||||
assert.GreaterOrEqual(t, server.listener.connCount(), 2, "client should have created at least 2 TCP connections (original + reconnect)")
|
||||
}
|
||||
|
||||
133
go.mod
133
go.mod
@@ -13,28 +13,28 @@ require (
|
||||
github.com/onsi/ginkgo v1.16.5
|
||||
github.com/onsi/gomega v1.27.6
|
||||
github.com/rs/cors v1.8.0
|
||||
github.com/sirupsen/logrus v1.9.4
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/cobra v1.10.1
|
||||
github.com/spf13/pflag v1.0.9
|
||||
github.com/vishvananda/netlink v1.3.1
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/sys v0.42.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/sys v0.41.0
|
||||
golang.zx2c4.com/wireguard v0.0.0-20230704135630-469159ecf7d1
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3
|
||||
google.golang.org/grpc v1.80.0
|
||||
google.golang.org/grpc v1.79.3
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.0.0
|
||||
)
|
||||
|
||||
require (
|
||||
fyne.io/fyne/v2 v2.7.0
|
||||
fyne.io/systray v1.12.1-0.20260116214250-81f8e1a496f9
|
||||
github.com/awnumar/memguard v0.23.0
|
||||
github.com/aws/aws-sdk-go-v2 v1.38.3
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.6
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.10
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3
|
||||
github.com/aws/aws-sdk-go-v2 v1.36.3
|
||||
github.com/aws/aws-sdk-go-v2/config v1.29.14
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.67
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.79.2
|
||||
github.com/c-robinson/iplib v1.0.3
|
||||
github.com/caddyserver/certmagic v0.21.3
|
||||
github.com/cilium/ebpf v0.15.0
|
||||
@@ -42,11 +42,8 @@ require (
|
||||
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/crowdsecurity/crowdsec v1.7.7
|
||||
github.com/crowdsecurity/go-cs-bouncer v0.0.21
|
||||
github.com/dexidp/dex v0.0.0-00010101000000-000000000000
|
||||
github.com/dexidp/dex/api/v2 v2.4.0
|
||||
github.com/ebitengine/purego v0.8.4
|
||||
github.com/eko/gocache/lib/v4 v4.2.0
|
||||
github.com/eko/gocache/store/go_cache/v4 v4.2.2
|
||||
github.com/eko/gocache/store/redis/v4 v4.2.2
|
||||
@@ -63,7 +60,7 @@ require (
|
||||
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.0.2-0.20240212192251-757544f21357
|
||||
github.com/hashicorp/go-multierror v1.1.1
|
||||
github.com/hashicorp/go-secure-stdlib/base62 v0.1.2
|
||||
github.com/hashicorp/go-version v1.7.0
|
||||
github.com/hashicorp/go-version v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.5.5
|
||||
github.com/libdns/route53 v1.5.0
|
||||
github.com/libp2p/go-nat v0.2.0
|
||||
@@ -72,7 +69,7 @@ require (
|
||||
github.com/mdlayher/socket v0.5.1
|
||||
github.com/miekg/dns v1.1.59
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2
|
||||
github.com/netbirdio/management-integrations/integrations v0.0.0-20260416123949-2355d972be42
|
||||
github.com/netbirdio/management-integrations/integrations v0.0.0-20260210160626-df4b180c7b25
|
||||
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45
|
||||
github.com/oapi-codegen/runtime v1.1.2
|
||||
github.com/okta/okta-sdk-golang/v2 v2.18.0
|
||||
@@ -107,22 +104,22 @@ require (
|
||||
github.com/yusufpapurcu/wmi v1.2.4
|
||||
github.com/zcalusic/sysinfo v1.1.3
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0
|
||||
go.opentelemetry.io/otel v1.43.0
|
||||
go.opentelemetry.io/otel v1.42.0
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.64.0
|
||||
go.opentelemetry.io/otel/metric v1.43.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0
|
||||
go.opentelemetry.io/otel/metric v1.42.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0
|
||||
go.uber.org/mock v0.5.2
|
||||
go.uber.org/zap v1.27.0
|
||||
goauthentik.io/api/v3 v3.2023051.3
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
|
||||
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842
|
||||
golang.org/x/mobile v0.0.0-20251113184115-a159579294ab
|
||||
golang.org/x/mod v0.33.0
|
||||
golang.org/x/net v0.52.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/term v0.41.0
|
||||
golang.org/x/time v0.15.0
|
||||
google.golang.org/api v0.276.0
|
||||
golang.org/x/mod v0.32.0
|
||||
golang.org/x/net v0.51.0
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
golang.org/x/sync v0.19.0
|
||||
golang.org/x/term v0.40.0
|
||||
golang.org/x/time v0.14.0
|
||||
google.golang.org/api v0.257.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/driver/mysql v1.5.7
|
||||
gorm.io/driver/postgres v1.5.7
|
||||
@@ -132,11 +129,11 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/auth v0.20.0 // indirect
|
||||
cloud.google.com/go/auth v0.17.0 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
dario.cat/mergo v1.0.1 // indirect
|
||||
filippo.io/edwards25519 v1.1.1 // indirect
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/AppsFlyer/go-sundheit v0.6.0 // indirect
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
||||
@@ -147,38 +144,36 @@ require (
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||
github.com/awnumar/memcall v0.4.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/route53 v1.42.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.29.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.38.2 // indirect
|
||||
github.com/aws/smithy-go v1.23.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 // indirect
|
||||
github.com/aws/smithy-go v1.22.2 // indirect
|
||||
github.com/beevik/etree v1.6.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/caddyserver/zerossl v0.1.3 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/containerd/log v0.1.0 // indirect
|
||||
github.com/containerd/platforms v0.2.1 // indirect
|
||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||
github.com/crowdsecurity/go-cs-lib v0.0.25 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/docker/docker v28.0.1+incompatible // indirect
|
||||
github.com/docker/go-connections v0.6.0 // indirect
|
||||
github.com/docker/go-connections v0.5.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/ebitengine/purego v0.8.2 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fredbi/uri v1.1.1 // indirect
|
||||
github.com/fyne-io/gl-js v0.2.0 // indirect
|
||||
@@ -192,26 +187,14 @@ require (
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/go-openapi/analysis v0.23.0 // indirect
|
||||
github.com/go-openapi/errors v0.22.2 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.1 // indirect
|
||||
github.com/go-openapi/jsonreference v0.21.0 // indirect
|
||||
github.com/go-openapi/loads v0.22.0 // indirect
|
||||
github.com/go-openapi/spec v0.21.0 // indirect
|
||||
github.com/go-openapi/strfmt v0.23.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.1 // indirect
|
||||
github.com/go-openapi/validate v0.24.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.9.3 // indirect
|
||||
github.com/go-text/render v0.2.0 // indirect
|
||||
github.com/go-text/typesetting v0.2.1 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||
github.com/google/btree v1.1.2 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.21.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
|
||||
github.com/gorilla/handlers v1.5.2 // indirect
|
||||
github.com/hack-pad/go-indexeddb v0.3.2 // indirect
|
||||
github.com/hack-pad/safejs v0.1.0 // indirect
|
||||
@@ -229,18 +212,16 @@ require (
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
github.com/jonboulle/clockwork v0.5.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
|
||||
github.com/kelseyhightower/envconfig v1.4.0 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/koron/go-ssdp v0.0.4 // indirect
|
||||
github.com/kr/fs v0.1.0 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/libdns/libdns v0.2.2 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
github.com/mailru/easyjson v0.9.0 // indirect
|
||||
github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.32 // indirect
|
||||
github.com/mdelapenya/tlscert v0.2.0 // indirect
|
||||
@@ -248,7 +229,6 @@ require (
|
||||
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect
|
||||
github.com/mholt/acmez/v2 v2.0.1 // indirect
|
||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
github.com/moby/patternmatcher v0.6.0 // indirect
|
||||
@@ -260,8 +240,7 @@ require (
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
|
||||
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
|
||||
github.com/nxadm/tail v1.4.11 // indirect
|
||||
github.com/oklog/ulid v1.3.1 // indirect
|
||||
github.com/nxadm/tail v1.4.8 // indirect
|
||||
github.com/onsi/ginkgo/v2 v2.9.5 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
@@ -271,43 +250,41 @@ require (
|
||||
github.com/pion/transport/v2 v2.2.4 // indirect
|
||||
github.com/pion/turn/v4 v4.1.1 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/otlptranslator v1.0.0 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/russellhaering/goxmldsig v1.6.0 // indirect
|
||||
github.com/russellhaering/goxmldsig v1.5.0 // indirect
|
||||
github.com/rymdport/portal v0.4.2 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.25.8 // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.25.1 // indirect
|
||||
github.com/shoenig/go-m1cpu v0.2.1 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/spf13/cast v1.7.0 // indirect
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
|
||||
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
|
||||
github.com/stretchr/objx v0.5.2 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
||||
github.com/tklauser/numcpus v0.10.0 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.14 // indirect
|
||||
github.com/tklauser/numcpus v0.8.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
|
||||
github.com/yuin/goldmark v1.7.8 // indirect
|
||||
github.com/zeebo/blake3 v0.2.3 // indirect
|
||||
go.mongodb.org/mongo-driver v1.17.9 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.42.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/image v0.33.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
|
||||
gopkg.in/square/go-jose.v2 v2.6.0 // indirect
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
)
|
||||
|
||||
replace github.com/kardianos/service => github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502
|
||||
|
||||
281
go.sum
281
go.sum
@@ -1,5 +1,5 @@
|
||||
cloud.google.com/go/auth v0.20.0 h1:kXTssoVb4azsVDoUiF8KvxAqrsQcQtB53DcSgta74CA=
|
||||
cloud.google.com/go/auth v0.20.0/go.mod h1:942/yi/itH1SsmpyrbnTMDgGfdy2BUqIKyd0cyYLc5Q=
|
||||
cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4=
|
||||
cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.2.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||
@@ -9,8 +9,8 @@ cunicu.li/go-rosenpass v0.4.0 h1:LtPtBgFWY/9emfgC4glKLEqS0MJTylzV6+ChRhiZERw=
|
||||
cunicu.li/go-rosenpass v0.4.0/go.mod h1:MPbjH9nxV4l3vEagKVdFNwHOketqgS5/To1VYJplf/M=
|
||||
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
|
||||
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||
filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw=
|
||||
filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
fyne.io/fyne/v2 v2.7.0 h1:GvZSpE3X0liU/fqstInVvRsaboIVpIWQ4/sfjDGIGGQ=
|
||||
fyne.io/fyne/v2 v2.7.0/go.mod h1:xClVlrhxl7D+LT+BWYmcrW4Nf+dJTvkhnPgji7spAwE=
|
||||
fyne.io/systray v1.12.1-0.20260116214250-81f8e1a496f9 h1:829+77I4TaMrcg9B3wf+gHhdSgoCVEgH2czlPXPbfj4=
|
||||
@@ -40,50 +40,48 @@ github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFI
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
|
||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||
github.com/awnumar/memcall v0.4.0 h1:B7hgZYdfH6Ot1Goaz8jGne/7i8xD4taZie/PNSFZ29g=
|
||||
github.com/awnumar/memcall v0.4.0/go.mod h1:8xOx1YbfyuCg3Fy6TO8DK0kZUua3V42/goA5Ru47E8w=
|
||||
github.com/awnumar/memguard v0.23.0 h1:sJ3a1/SWlcuKIQ7MV+R9p0Pvo9CWsMbGZvcZQtmc68A=
|
||||
github.com/awnumar/memguard v0.23.0/go.mod h1:olVofBrsPdITtJ2HgxQKrEYEMyIBAIciVG4wNnZhW9M=
|
||||
github.com/aws/aws-sdk-go-v2 v1.38.3 h1:B6cV4oxnMs45fql4yRH+/Po/YU+597zgWqvDpYMturk=
|
||||
github.com/aws/aws-sdk-go-v2 v1.38.3/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 h1:i8p8P4diljCr60PpJp6qZXNlgX4m2yQFpYk+9ZT+J4E=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.1/go.mod h1:ddqbooRZYNoJ2dsTwOty16rM+/Aqmk/GOXrK8cg7V00=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.6 h1:a1t8fXY4GT4xjyJExz4knbuoxSCacB5hT/WgtfPyLjo=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.31.6/go.mod h1:5ByscNi7R+ztvOGzeUaIu49vkMk2soq5NaH5PYe33MQ=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.10 h1:xdJnXCouCx8Y0NncgoptztUocIYLKeQxrCgN6x9sdhg=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.10/go.mod h1:7tQk08ntj914F/5i9jC4+2HQTAuJirq7m1vZVIhEkWs=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6 h1:wbjnrrMnKew78/juW7I2BtKQwa1qlf6EjQgS69uYY14=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.6/go.mod h1:AtiqqNrDioJXuUgz3+3T0mBWN7Hro2n9wll2zRUc0ww=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6 h1:uF68eJA6+S9iVr9WgX1NaRGyQ/6MdIyc4JNUo6TN1FA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.6/go.mod h1:qlPeVZCGPiobx8wb1ft0GHT5l+dc6ldnwInDFaMvC7Y=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6 h1:pa1DEC6JoI0zduhZePp3zmhWvk/xxm4NB8Hy/Tlsgos=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.6/go.mod h1:gxEjPebnhWGJoaDdtDkA0JX46VRg1wcTHYe63OfX5pE=
|
||||
github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM=
|
||||
github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10/go.mod h1:qqvMj6gHLR/EXWZw4ZbqlPbQUyenf4h82UQUlKc+l14=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.29.14 h1:f+eEi/2cKCg9pqKBoAIwRGzVb70MRKqWX4dg1BDcSJM=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.29.14/go.mod h1:wVPHWcIFv3WO89w0rE10gzf17ZYy+UVS1Geq8Iei34g=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.67 h1:9KxtdcIA/5xPNQyZRgUSpYOE6j9Bc4+D7nZua0KGYOM=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.67/go.mod h1:p3C44m+cfnbv763s52gCqrjaqyPikj9Sg47kUVaNZQQ=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6 h1:R0tNFJqfjHL3900cqhXuwQ+1K4G0xc9Yf8EDbFXCKEw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.6/go.mod h1:y/7sDdu+aJvPtGXr4xYosdpq9a6T9Z0jkXfugmti0rI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6 h1:hncKj/4gR+TPauZgTAsxOxNcvBayhUlYZ6LO/BYiQ30=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.8.6/go.mod h1:OiIh45tp6HdJDDJGnja0mw8ihQGz3VGrUflLqSL0SmM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6 h1:LHS1YAIJXJ4K9zS+1d/xa9JAA9sL2QyXIQCQFQW/X08=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.6/go.mod h1:c9PCiTEuh0wQID5/KqA32J+HAgZxN9tOGXKCiYJjTZI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6 h1:nEXUSAwyUfLTgnc9cxlDWy637qsq4UWwp3sNAfl0Z3Y=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.6/go.mod h1:HGzIULx4Ge3Do2V0FaiYKcyKzOqwrhUZgCI77NisswQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 h1:ZNTqv4nIdE/DiBfUUfXcLZ/Spcuz+RjeziUtNJackkM=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34/go.mod h1:zf7Vcd1ViW7cPqYWEHLHJkS50X0JS2IKz9Cgaj6ugrs=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.0 h1:lguz0bmOoGzozP9XfRJR1QIayEYo+2vP/No3OfLF0pU=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.0/go.mod h1:iu6FSzgt+M2/x3Dk8zhycdIcHjEFb36IS8HVUVFoMg0=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 h1:moLQUoVq91LiqT1nbvzDukyqAlCv89ZmwaHw/ZFlFZg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15/go.mod h1:ZH34PJUc8ApjBIfgQCFvkWcUDBtl/WTD+uiYHjd8igA=
|
||||
github.com/aws/aws-sdk-go-v2/service/route53 v1.42.3 h1:MmLCRqP4U4Cw9gJ4bNrCG0mWqEtBlmAVleyelcHARMU=
|
||||
github.com/aws/aws-sdk-go-v2/service/route53 v1.42.3/go.mod h1:AMPjK2YnRh0YgOID3PqhJA1BRNfXDfGOnSsKHtAe8yA=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3 h1:ETkfWcXP2KNPLecaDa++5bsQhCRa5M5sLUJa5DWYIIg=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.87.3/go.mod h1:+/3ZTqoYb3Ur7DObD00tarKMLMuKg8iqz5CHEanqTnw=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.29.1 h1:8OLZnVJPvjnrxEwHFg9hVUof/P4sibH+Ea4KKuqAGSg=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.29.1/go.mod h1:27M3BpVi0C02UiQh1w9nsBEit6pLhlaH3NHna6WUbDE=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2 h1:gKWSTnqudpo8dAxqBqZnDoDWCiEh/40FziUjr/mo6uA=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.34.2/go.mod h1:x7+rkNmRoEN1U13A6JE2fXne9EWyJy54o3n6d4mGaXQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.38.2 h1:YZPjhyaGzhDQEvsffDEcpycq49nl7fiGcfJTIo8BszI=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.38.2/go.mod h1:2dIN8qhQfv37BdUYGgEC8Q3tteM3zFxTI1MLO2O3J3c=
|
||||
github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE=
|
||||
github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.79.2 h1:tWUG+4wZqdMl/znThEk9tcCy8tTMxq8dW0JTgamohrY=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.79.2/go.mod h1:U5SNqwhXB3Xe6F47kXvWihPl/ilGaEDe8HD/50Z9wxc=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 h1:1Gw+9ajCV1jogloEv1RRnvfRFia2cL6c9cuKV2Ps+G8=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.25.3/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 h1:hXmVKytPfTy5axZ+fYbR5d0cFmC3JvwLm5kM83luako=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 h1:1XuUZ8mYJw9B6lzAkXhqHlJd/XvaX32evhproijJEZY=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.33.19/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4=
|
||||
github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ=
|
||||
github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
|
||||
github.com/beevik/etree v1.6.0 h1:u8Kwy8pp9D9XeITj2Z0XtA5qqZEmtJtuXZRQi+j03eE=
|
||||
github.com/beevik/etree v1.6.0/go.mod h1:bh4zJxiIr62SOf9pRzN7UUYaEDa9HEKafK25+sLc0Gc=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
@@ -101,8 +99,6 @@ github.com/caddyserver/zerossl v0.1.3 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+Y
|
||||
github.com/caddyserver/zerossl v0.1.3/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk=
|
||||
@@ -122,18 +118,11 @@ github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHf
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/crowdsecurity/crowdsec v1.7.7 h1:sduZN763iXsrZodocWDrsR//7nLeffGu+RVkkIsbQkE=
|
||||
github.com/crowdsecurity/crowdsec v1.7.7/go.mod h1:L1HLGPDnBYCcY+yfSFnuBbQ1G9DHEJN9c+Kevv9F+4Q=
|
||||
github.com/crowdsecurity/go-cs-bouncer v0.0.21 h1:arPz0VtdVSaz+auOSfHythzkZVLyy18CzYvYab8UJDU=
|
||||
github.com/crowdsecurity/go-cs-bouncer v0.0.21/go.mod h1:4JiH0XXA4KKnnWThItUpe5+heJHWzsLOSA2IWJqUDBA=
|
||||
github.com/crowdsecurity/go-cs-lib v0.0.25 h1:Ov6VPW9yV+OPsbAIQk1iTkEWhwkpaG0v3lrBzeqjzj4=
|
||||
github.com/crowdsecurity/go-cs-lib v0.0.25/go.mod h1:X0GMJY2CxdA1S09SpuqIKaWQsvRGxXmecUp9cP599dE=
|
||||
github.com/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6 h1:/DS5cDX3FJdl+XaN2D7XAwFpuanTxnp52DBLZAaJKx0=
|
||||
github.com/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6/go.mod h1:+CauBF6R70Jqcyl8N2hC8pAXYbWkGIezuSbuGLtRhnw=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dexidp/dex/api/v2 v2.4.0 h1:gNba7n6BKVp8X4Jp24cxYn5rIIGhM6kDOXcZoL6tr9A=
|
||||
github.com/dexidp/dex/api/v2 v2.4.0/go.mod h1:/p550ADvFFh7K95VmhUD+jgm15VdaNnab9td8DHOpyI=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
@@ -142,12 +131,12 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v28.0.1+incompatible h1:FCHjSRdXhNRFjlHMTv4jUNlIBbTeRjrWfeFuJp7jpo0=
|
||||
github.com/docker/docker v28.0.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
|
||||
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
|
||||
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/eko/gocache/lib/v4 v4.2.0 h1:MNykyi5Xw+5Wu3+PUrvtOCaKSZM1nUSVftbzmeC7Yuw=
|
||||
github.com/eko/gocache/lib/v4 v4.2.0/go.mod h1:7ViVmbU+CzDHzRpmB4SXKyyzyuJ8A3UW3/cszpcqB4M=
|
||||
github.com/eko/gocache/store/go_cache/v4 v4.2.2 h1:tAI9nl6TLoJyKG1ujF0CS0n/IgTEMl+NivxtR5R3/hw=
|
||||
@@ -166,7 +155,6 @@ github.com/fredbi/uri v1.1.1 h1:xZHJC08GZNIUhbP5ImTHnt5Ya0T8FI2VAwI/37kh2Ko=
|
||||
github.com/fredbi/uri v1.1.1/go.mod h1:4+DZQ5zBjEwQCDmXW5JdIjz0PUA+yJbvtBv+u+adr5o=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fyne-io/gl-js v0.2.0 h1:+EXMLVEa18EfkXBVKhifYB6OGs3HwKO3lUElA0LlAjs=
|
||||
@@ -199,24 +187,6 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/go-openapi/analysis v0.23.0 h1:aGday7OWupfMs+LbmLZG4k0MYXIANxcuBTYUC03zFCU=
|
||||
github.com/go-openapi/analysis v0.23.0/go.mod h1:9mz9ZWaSlV8TvjQHLl2mUW2PbZtemkE8yA5v22ohupo=
|
||||
github.com/go-openapi/errors v0.22.2 h1:rdxhzcBUazEcGccKqbY1Y7NS8FDcMyIRr0934jrYnZg=
|
||||
github.com/go-openapi/errors v0.22.2/go.mod h1:+n/5UdIqdVnLIJ6Q9Se8HNGUXYaY6CN8ImWzfi/Gzp0=
|
||||
github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic=
|
||||
github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk=
|
||||
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
|
||||
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
|
||||
github.com/go-openapi/loads v0.22.0 h1:ECPGd4jX1U6NApCGG1We+uEozOAvXvJSF4nnwHZ8Aco=
|
||||
github.com/go-openapi/loads v0.22.0/go.mod h1:yLsaTCS92mnSAZX5WWoxszLj0u+Ojl+Zs5Stn1oF+rs=
|
||||
github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
|
||||
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
|
||||
github.com/go-openapi/strfmt v0.23.0 h1:nlUS6BCqcnAk0pyhi9Y+kdDVZdZMHfEKQiS4HaMgO/c=
|
||||
github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4=
|
||||
github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU=
|
||||
github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0=
|
||||
github.com/go-openapi/validate v0.24.0 h1:LdfDKwNbpB6Vn40xhTdNZAnfLECL81w+VX3BumrGD58=
|
||||
github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ=
|
||||
github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM=
|
||||
github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY=
|
||||
github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI=
|
||||
@@ -233,14 +203,10 @@ github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg
|
||||
github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M=
|
||||
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0=
|
||||
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
|
||||
@@ -264,7 +230,6 @@ github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl76
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
@@ -272,8 +237,6 @@ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
|
||||
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
|
||||
@@ -285,10 +248,10 @@ github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
|
||||
github.com/googleapis/gax-go/v2 v2.21.0 h1:h45NjjzEO3faG9Lg/cFrBh2PgegVVgzqKzuZl/wMbiI=
|
||||
github.com/googleapis/gax-go/v2 v2.21.0/go.mod h1:But/NJU6TnZsrLai/xBAQLLz+Hc7fHZJt/hsCz3Fih4=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
|
||||
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
|
||||
github.com/gopacket/gopacket v1.1.1 h1:zbx9F9d6A7sWNkFKrvMBZTfGgxFoY4NgUudFVVHMfcw=
|
||||
github.com/gopacket/gopacket v1.1.1/go.mod h1:HavMeONEl7W9036of9LbSWoonqhH7HA1+ZRO+rMIvFs=
|
||||
github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE=
|
||||
@@ -313,8 +276,8 @@ github.com/hashicorp/go-secure-stdlib/base62 v0.1.2/go.mod h1:EdWO6czbmthiwZ3/PU
|
||||
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
|
||||
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
|
||||
github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
|
||||
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||
@@ -356,8 +319,6 @@ github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGw
|
||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
|
||||
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
|
||||
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
|
||||
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
|
||||
@@ -369,8 +330,8 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/koron/go-ssdp v0.0.4 h1:1IDwrghSKYM7yLf7XCzbByg2sJ/JcNOZRXS2jczTwz0=
|
||||
github.com/koron/go-ssdp v0.0.4/go.mod h1:oDXq+E5IL5q0U8uSBcoAXzTzInwy5lEgC91HoKtbmZk=
|
||||
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
|
||||
@@ -400,8 +361,6 @@ github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae h1:dIZY4ULFcto4tA
|
||||
github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
|
||||
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||
github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU=
|
||||
github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To=
|
||||
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
|
||||
@@ -425,8 +384,6 @@ github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa1
|
||||
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
||||
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
@@ -453,8 +410,8 @@ github.com/netbirdio/go-netroute v0.0.0-20240611143515-f59b0e1d3944 h1:TDtJKmM6S
|
||||
github.com/netbirdio/go-netroute v0.0.0-20240611143515-f59b0e1d3944/go.mod h1:sHA6TRxjQ6RLbnI+3R4DZo2Eseg/iKiPRfNmcuNySVQ=
|
||||
github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51 h1:Ov4qdafATOgGMB1wbSuh+0aAHcwz9hdvB6VZjh1mVMI=
|
||||
github.com/netbirdio/ice/v4 v4.0.0-20250908184934-6202be846b51/go.mod h1:ZSIbPdBn5hePO8CpF1PekH2SfpTxg1PDhEwtbqZS7R8=
|
||||
github.com/netbirdio/management-integrations/integrations v0.0.0-20260416123949-2355d972be42 h1:F3zS5fT9xzD1OFLfcdAE+3FfyiwjGukF1hvj0jErgs8=
|
||||
github.com/netbirdio/management-integrations/integrations v0.0.0-20260416123949-2355d972be42/go.mod h1:n47r67ZSPgwSmT/Z1o48JjZQW9YJ6m/6Bd/uAXkL3Pg=
|
||||
github.com/netbirdio/management-integrations/integrations v0.0.0-20260210160626-df4b180c7b25 h1:iwAq/Ncaq0etl4uAlVsbNBzC1yY52o0AmY7uCm2AMTs=
|
||||
github.com/netbirdio/management-integrations/integrations v0.0.0-20260210160626-df4b180c7b25/go.mod h1:y7CxagMYzg9dgu+masRqYM7BQlOGA5Y8US85MCNFPlY=
|
||||
github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 h1:3tHlFmhTdX9axERMVN63dqyFqnvuD+EMJHzM7mNGON8=
|
||||
github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM=
|
||||
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ=
|
||||
@@ -466,13 +423,10 @@ github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S
|
||||
github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
|
||||
github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=
|
||||
github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI=
|
||||
github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
|
||||
github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=
|
||||
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
|
||||
github.com/okta/okta-sdk-golang/v2 v2.18.0 h1:cfDasMb7CShbZvOrF6n+DnLevWwiHgedWMGJ8M8xKDc=
|
||||
github.com/okta/okta-sdk-golang/v2 v2.18.0/go.mod h1:dz30v3ctAiMb7jpsCngGfQUAEGm1/NsWT92uTbNDQIs=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
@@ -493,8 +447,8 @@ github.com/oschwald/maxminddb-golang v1.12.0 h1:9FnTOD0YOhP7DGxGsq4glzpGy5+w7pq5
|
||||
github.com/oschwald/maxminddb-golang v1.12.0/go.mod h1:q0Nob5lTCqyQ8WT6FYgS1L7PXKVVbgiymefNwIjPzgY=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pelletier/go-toml/v2 v2.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N7Xxu0=
|
||||
github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
||||
github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203 h1:E7Kmf11E4K7B5hDti2K2NqPb1nlYlGYsu02S1JNd/Bs=
|
||||
github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||
github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s=
|
||||
@@ -532,9 +486,8 @@ github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
|
||||
github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo=
|
||||
github.com/pkg/sftp v1.13.9 h1:4NGkvGudBL7GteO3m6qnaQ4pC0Kvf0onSVc9gR3EWBw=
|
||||
github.com/pkg/sftp v1.13.9/go.mod h1:OBN7bVXdstkFFN/gdnHPUb5TE8eb8G1Rp9wCItqjkkA=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
@@ -558,15 +511,15 @@ github.com/rs/cors v1.8.0 h1:P2KMzcFwrPoSjkF1WLRPsp3UMLyql8L4v9hQpVeK5so=
|
||||
github.com/rs/cors v1.8.0/go.mod h1:EBwu+T5AvHOcXwvZIkQFjUN6s8Czyqw12GL/Y0tUyRM=
|
||||
github.com/rs/xid v1.3.0 h1:6NjYksEUlhurdVehpc7S7dk6DAmcKv8V9gG0FsVN2U4=
|
||||
github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/russellhaering/goxmldsig v1.6.0 h1:8fdWXEPh2k/NZNQBPFNoVfS3JmzS4ZprY/sAOpKQLks=
|
||||
github.com/russellhaering/goxmldsig v1.6.0/go.mod h1:TrnaquDcYxWXfJrOjeMBTX4mLBeYAqaHEyUeWPxZlBM=
|
||||
github.com/russellhaering/goxmldsig v1.5.0 h1:AU2UkkYIUOTyZRbe08XMThaOCelArgvNfYapcmSjBNw=
|
||||
github.com/russellhaering/goxmldsig v1.5.0/go.mod h1:x98CjQNFJcWfMxeOrMnMKg70lvDP6tE0nTaeUnjXDmk=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU=
|
||||
github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
|
||||
github.com/shirou/gopsutil/v3 v3.24.4 h1:dEHgzZXt4LMNm+oYELpzl9YCqV65Yr/6SfrvgRBtXeU=
|
||||
github.com/shirou/gopsutil/v3 v3.24.4/go.mod h1:lTd2mdiOspcqLgAnr9/nGi71NkeMpWKdmhuxm9GusH8=
|
||||
github.com/shirou/gopsutil/v4 v4.25.8 h1:NnAsw9lN7587WHxjJA9ryDnqhJpFH6A+wagYWTOH970=
|
||||
github.com/shirou/gopsutil/v4 v4.25.8/go.mod h1:q9QdMmfAOVIw7a+eF86P7ISEU6ka+NLgkUxlopV4RwI=
|
||||
github.com/shirou/gopsutil/v4 v4.25.1 h1:QSWkTc+fu9LTAWfkZwZ6j8MSUk4A2LV7rbH0ZqmLjXs=
|
||||
github.com/shirou/gopsutil/v4 v4.25.1/go.mod h1:RoUCUpndaJFtT+2zsZzzmhvbfGoDCJ7nFXKJf8GqJbI=
|
||||
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||
github.com/shoenig/go-m1cpu v0.2.1 h1:yqRB4fvOge2+FyRXFkXqsyMoqPazv14Yyy+iyccT2E4=
|
||||
github.com/shoenig/go-m1cpu v0.2.1/go.mod h1:KkDOw6m3ZJQAPHbrzkZki4hnx+pDRR1Lo+ldA56wD5w=
|
||||
@@ -575,8 +528,8 @@ github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk=
|
||||
github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsBHFoI=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
|
||||
github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 h1:TG/diQgUe0pntT/2D9tmUCz4VNwm9MfrtPr0SU2qSX8=
|
||||
@@ -625,11 +578,11 @@ github.com/ti-mo/conntrack v0.5.1/go.mod h1:T6NCbkMdVU4qEIgwL0njA6lw/iCAbzchlnwm
|
||||
github.com/ti-mo/netfilter v0.5.2 h1:CTjOwFuNNeZ9QPdRXt1MZFLFUf84cKtiQutNauHWd40=
|
||||
github.com/ti-mo/netfilter v0.5.2/go.mod h1:Btx3AtFiOVdHReTDmP9AE+hlkOcvIy403u7BXXbWZKo=
|
||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
||||
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
||||
github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU=
|
||||
github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY=
|
||||
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
||||
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
||||
github.com/tklauser/numcpus v0.8.0 h1:Mx4Wwe/FjZLeQsK/6kt2EOepwwSl7SmJrK5bV/dXYgY=
|
||||
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/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
|
||||
@@ -658,30 +611,28 @@ github.com/zeebo/blake3 v0.2.3 h1:TFoLXsjeXqRNFxSbk35Dk4YtszE/MQQGK10BH4ptoTg=
|
||||
github.com/zeebo/blake3 v0.2.3/go.mod h1:mjJjZpnsyIVtVgTOSpJ9vmRE4wgDeyt2HU3qXvvKCaQ=
|
||||
github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
|
||||
github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
|
||||
go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU=
|
||||
go.mongodb.org/mongo-driver v1.17.9/go.mod h1:LlOhpH5NUEfhxcAwG0UEkMqwYcc4JU18gtCdGudk/tQ=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0 h1:yI1/OhfEPy7J9eoa6Sj051C7n5dvpj0QX8g4sRchg04=
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.67.0/go.mod h1:NoUCKYWK+3ecatC4HjkRktREheMeEtrXoQxrqYFeHSc=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
|
||||
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
|
||||
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.64.0 h1:g0LRDXMX/G1SEZtK8zl8Chm4K6GBwRkjPKE36LxiTYs=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.64.0/go.mod h1:UrgcjnarfdlBDP3GjDIJWe6HTprwSazNjwsI+Ru6hro=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
|
||||
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
|
||||
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
|
||||
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
|
||||
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
|
||||
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
|
||||
go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I=
|
||||
go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
@@ -707,10 +658,10 @@ golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1m
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM=
|
||||
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
|
||||
golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
|
||||
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
|
||||
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
|
||||
@@ -725,8 +676,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
@@ -745,11 +696,11 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/oauth2 v0.8.0/go.mod h1:yr7u4HXZRm1R1kBWqr/xKNqewf0plRYoB7sla+BCIXE=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -761,8 +712,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -782,8 +733,8 @@ golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -797,8 +748,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -811,8 +762,8 @@ golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
@@ -824,10 +775,10 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
@@ -839,8 +790,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -851,19 +802,19 @@ golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 h1:CawjfCvY
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6/go.mod h1:3rxYc4HtVcSG9gVaTs2GEBdehh+sYPOwKtyUWEOTb80=
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE=
|
||||
golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/api v0.276.0 h1:nVArUtfLEihtW+b0DdcqRGK1xoEm2+ltAihyztq7MKY=
|
||||
google.golang.org/api v0.276.0/go.mod h1:Fnag/EWUPIcJXuIkP1pjoTgS5vdxlk3eeemL7Do6bvw=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/api v0.257.0 h1:8Y0lzvHlZps53PEaw+G29SsQIkuKrumGWs9puiexNAA=
|
||||
google.golang.org/api v0.257.0/go.mod h1:4eJrr+vbVaZSqs7vovFd1Jb/A6ml6iw2e6FBYf3GAO4=
|
||||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7 h1:XzmzkmB14QhVhgnawEVsOn6OFsnpyxNPRY9QV01dNB0=
|
||||
google.golang.org/genproto v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:L43LFes82YgSonw6iTXTxXUX1OlULt4AQtkik4ULL/I=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
|
||||
google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
@@ -885,8 +836,8 @@ gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8
|
||||
gopkg.in/go-playground/validator.v9 v9.29.1/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.0.0/go.mod h1:l0ndWWf7gzL7RNwBG7wST/UCcT4T24xpD6X8LsfU/+k=
|
||||
gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI=
|
||||
gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user