From e272683bced0b611553c328679bd1f5f7dd9228f Mon Sep 17 00:00:00 2001 From: Ricardo Maraschini Date: Wed, 6 Nov 2024 11:30:13 +0100 Subject: [PATCH] feat: implement collector and analyser for network namespace connectivity (#1670) * feat: implement collector and analyser for network namespace connectivity checks if two network namespaces can talk to each other on udp and tcp. its usage is as follows: ```yaml apiVersion: troubleshoot.sh/v1beta2 kind: SupportBundle metadata: name: test spec: hostCollectors: - networkNamespaceConnectivity: collectorName: check-network-connectivity fromCIDR: 10.0.0.0/24 toCIDR: 10.0.1.0/24 hostAnalyzers: - networkNamespaceConnectivity: collectorName: check-network-connectivity outcomes: - pass: message: "Communication between 10.0.0.0/24 and 10.0.1.0/24 is working" - fail: message: "Communication between 10.0.0.0/24 and 10.0.1.0/24 isn't working" ``` if this fails then you may need to enable `forwarding` with: ```bash sysctl -w net.ipv4.ip_forward=1 ``` if it still fails then you may need to configure firewalld to allow the traffic or simply disable it for sake of testing. * chore: rebuild schemas * chore: remove unused property * chore: disable namespaces for other platforms * chore: make sure we timeout temporary servers * feat: analyzer now supports multi-node collection * feat: check both udp and tcp even on failure check both protocols even if one fails. this pr commit also introduces a timeout that can be set by the user. * feat: add templating to the failure outcome allow users to dump the errors found during the analysis. * chore: addressing pr comments * feat: delete interface pair before namespace even though the interface pair is deleted everyttime we delete the namespace on my tests we better delete it before we delete the namespace. this comes out of a review comment where some people seem to still be able to see the interface pair even after the namespace is deleted. i.e. better safe than sorry. * chore: fix typo on comment --- config/crds/troubleshoot.sh_analyzers.yaml | 49 ++++ config/crds/troubleshoot.sh_collectors.yaml | 19 ++ .../crds/troubleshoot.sh_hostcollectors.yaml | 68 +++++ .../crds/troubleshoot.sh_hostpreflights.yaml | 68 +++++ .../crds/troubleshoot.sh_supportbundles.yaml | 68 +++++ go.mod | 3 + go.sum | 7 + pkg/analyze/host_analyzer.go | 2 + .../host_network_namespace_connectivity.go | 80 ++++++ .../v1beta2/hostanalyzer_shared.go | 53 ++-- .../v1beta2/hostcollector_shared.go | 63 +++-- .../v1beta2/zz_generated.deepcopy.go | 53 ++++ pkg/collect/host_collector.go | 2 + .../host_network_namespace_connectivity.go | 231 ++++++++++++++++ pkg/namespaces/errors.go | 18 ++ pkg/namespaces/errors_test.go | 57 ++++ pkg/namespaces/interface-pair.go | 91 +++++++ pkg/namespaces/interface-pair_test.go | 119 +++++++++ pkg/namespaces/managed-namespace.go | 110 ++++++++ .../managed-namespace_unsupported.go | 42 +++ pkg/namespaces/namespace-handler.go | 38 +++ pkg/namespaces/namespace-pinger.go | 217 +++++++++++++++ pkg/namespaces/netlink-handler.go | 62 +++++ pkg/namespaces/network-namespace.go | 242 +++++++++++++++++ pkg/namespaces/network-namespace_test.go | 248 ++++++++++++++++++ pkg/namespaces/options.go | 51 ++++ schemas/analyzer-troubleshoot-v1beta2.json | 76 ++++++ schemas/collector-troubleshoot-v1beta2.json | 28 ++ .../supportbundle-troubleshoot-v1beta2.json | 104 ++++++++ 29 files changed, 2219 insertions(+), 50 deletions(-) create mode 100644 pkg/analyze/host_network_namespace_connectivity.go create mode 100644 pkg/collect/host_network_namespace_connectivity.go create mode 100644 pkg/namespaces/errors.go create mode 100644 pkg/namespaces/errors_test.go create mode 100644 pkg/namespaces/interface-pair.go create mode 100644 pkg/namespaces/interface-pair_test.go create mode 100644 pkg/namespaces/managed-namespace.go create mode 100644 pkg/namespaces/managed-namespace_unsupported.go create mode 100644 pkg/namespaces/namespace-handler.go create mode 100644 pkg/namespaces/namespace-pinger.go create mode 100644 pkg/namespaces/netlink-handler.go create mode 100644 pkg/namespaces/network-namespace.go create mode 100644 pkg/namespaces/network-namespace_test.go create mode 100644 pkg/namespaces/options.go diff --git a/config/crds/troubleshoot.sh_analyzers.yaml b/config/crds/troubleshoot.sh_analyzers.yaml index 60517b8e9..9151c9954 100644 --- a/config/crds/troubleshoot.sh_analyzers.yaml +++ b/config/crds/troubleshoot.sh_analyzers.yaml @@ -2557,6 +2557,55 @@ spec: required: - outcomes type: object + networkNamespaceConnectivity: + properties: + annotations: + additionalProperties: + type: string + type: object + checkName: + type: string + collectorName: + type: string + exclude: + type: BoolString + outcomes: + items: + properties: + fail: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + pass: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + warn: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + type: object + type: array + strict: + type: BoolString + required: + - outcomes + type: object subnetAvailable: properties: annotations: diff --git a/config/crds/troubleshoot.sh_collectors.yaml b/config/crds/troubleshoot.sh_collectors.yaml index ed5222823..ba955f220 100644 --- a/config/crds/troubleshoot.sh_collectors.yaml +++ b/config/crds/troubleshoot.sh_collectors.yaml @@ -17326,6 +17326,25 @@ spec: exclude: type: BoolString type: object + networkNamespaceConnectivity: + properties: + collectorName: + type: string + exclude: + type: BoolString + fromCIDR: + type: string + port: + type: integer + timeout: + type: string + toCIDR: + type: string + required: + - fromCIDR + - port + - toCIDR + type: object run: properties: args: diff --git a/config/crds/troubleshoot.sh_hostcollectors.yaml b/config/crds/troubleshoot.sh_hostcollectors.yaml index 14414a0b3..701301bc7 100644 --- a/config/crds/troubleshoot.sh_hostcollectors.yaml +++ b/config/crds/troubleshoot.sh_hostcollectors.yaml @@ -797,6 +797,55 @@ spec: required: - outcomes type: object + networkNamespaceConnectivity: + properties: + annotations: + additionalProperties: + type: string + type: object + checkName: + type: string + collectorName: + type: string + exclude: + type: BoolString + outcomes: + items: + properties: + fail: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + pass: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + warn: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + type: object + type: array + strict: + type: BoolString + required: + - outcomes + type: object subnetAvailable: properties: annotations: @@ -1603,6 +1652,25 @@ spec: exclude: type: BoolString type: object + networkNamespaceConnectivity: + properties: + collectorName: + type: string + exclude: + type: BoolString + fromCIDR: + type: string + port: + type: integer + timeout: + type: string + toCIDR: + type: string + required: + - fromCIDR + - port + - toCIDR + type: object run: properties: args: diff --git a/config/crds/troubleshoot.sh_hostpreflights.yaml b/config/crds/troubleshoot.sh_hostpreflights.yaml index 1b1bf828b..236862169 100644 --- a/config/crds/troubleshoot.sh_hostpreflights.yaml +++ b/config/crds/troubleshoot.sh_hostpreflights.yaml @@ -797,6 +797,55 @@ spec: required: - outcomes type: object + networkNamespaceConnectivity: + properties: + annotations: + additionalProperties: + type: string + type: object + checkName: + type: string + collectorName: + type: string + exclude: + type: BoolString + outcomes: + items: + properties: + fail: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + pass: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + warn: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + type: object + type: array + strict: + type: BoolString + required: + - outcomes + type: object subnetAvailable: properties: annotations: @@ -1603,6 +1652,25 @@ spec: exclude: type: BoolString type: object + networkNamespaceConnectivity: + properties: + collectorName: + type: string + exclude: + type: BoolString + fromCIDR: + type: string + port: + type: integer + timeout: + type: string + toCIDR: + type: string + required: + - fromCIDR + - port + - toCIDR + type: object run: properties: args: diff --git a/config/crds/troubleshoot.sh_supportbundles.yaml b/config/crds/troubleshoot.sh_supportbundles.yaml index 2c8c5c2ed..7430d68f3 100644 --- a/config/crds/troubleshoot.sh_supportbundles.yaml +++ b/config/crds/troubleshoot.sh_supportbundles.yaml @@ -19444,6 +19444,55 @@ spec: required: - outcomes type: object + networkNamespaceConnectivity: + properties: + annotations: + additionalProperties: + type: string + type: object + checkName: + type: string + collectorName: + type: string + exclude: + type: BoolString + outcomes: + items: + properties: + fail: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + pass: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + warn: + properties: + message: + type: string + uri: + type: string + when: + type: string + type: object + type: object + type: array + strict: + type: BoolString + required: + - outcomes + type: object subnetAvailable: properties: annotations: @@ -20250,6 +20299,25 @@ spec: exclude: type: BoolString type: object + networkNamespaceConnectivity: + properties: + collectorName: + type: string + exclude: + type: BoolString + fromCIDR: + type: string + port: + type: integer + timeout: + type: string + toCIDR: + type: string + required: + - fromCIDR + - port + - toCIDR + type: object run: properties: args: diff --git a/go.mod b/go.mod index 077e6da1c..d6814fc30 100644 --- a/go.mod +++ b/go.mod @@ -38,6 +38,8 @@ require ( github.com/spf13/viper v1.19.0 github.com/stretchr/testify v1.9.0 github.com/tj/go-spin v1.1.0 + github.com/vishvananda/netlink v1.2.1-beta.2 + github.com/vishvananda/netns v0.0.4 github.com/vmware-tanzu/velero v1.14.1 go.opentelemetry.io/otel v1.31.0 go.opentelemetry.io/otel/sdk v1.31.0 @@ -113,6 +115,7 @@ require ( github.com/shopspring/decimal v1.4.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/sourcegraph/conc v0.3.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/sylabs/sif/v2 v2.18.0 // indirect github.com/tchap/go-patricia/v2 v2.3.1 // indirect github.com/vladimirvivien/gexe v0.3.0 // indirect diff --git a/go.sum b/go.sum index 45488eddd..0dc63c576 100644 --- a/go.sum +++ b/go.sum @@ -885,6 +885,11 @@ github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc= github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/vbatts/tar-split v0.11.5 h1:3bHCTIheBm1qFTcgh9oPu+nNBtX+XJIupG/vacinCts= github.com/vbatts/tar-split v0.11.5/go.mod h1:yZbwRsSeGjusneWgA781EKej9HF8vme8okylkAeNKLk= +github.com/vishvananda/netlink v1.2.1-beta.2 h1:Llsql0lnQEbHj0I1OuKyp8otXp0r3q0mPkuhwHfStVs= +github.com/vishvananda/netlink v1.2.1-beta.2/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho= +github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= +github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8= +github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/vladimirvivien/gexe v0.3.0 h1:4xwiOwGrDob5OMR6E92B9olDXYDglXdHhzR1ggYtWJM= github.com/vladimirvivien/gexe v0.3.0/go.mod h1:fp7cy60ON1xjhtEI/+bfSEIXX35qgmI+iRYlGOqbBFM= github.com/vmware-tanzu/velero v1.14.1 h1:HYj73scn7ZqtfTanjW/X4W0Hn3w/qcfoRbrHCWM52iI= @@ -1131,6 +1136,7 @@ golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -1139,6 +1145,7 @@ golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/pkg/analyze/host_analyzer.go b/pkg/analyze/host_analyzer.go index 5f25f312c..47caa3797 100644 --- a/pkg/analyze/host_analyzer.go +++ b/pkg/analyze/host_analyzer.go @@ -61,6 +61,8 @@ func GetHostAnalyzer(analyzer *troubleshootv1beta2.HostAnalyze) (HostAnalyzer, b return &AnalyzeHostKernelConfigs{analyzer.KernelConfigs}, true case analyzer.JsonCompare != nil: return &AnalyzeHostJsonCompare{analyzer.JsonCompare}, true + case analyzer.NetworkNamespaceConnectivity != nil: + return &AnalyzeHostNetworkNamespaceConnectivity{analyzer.NetworkNamespaceConnectivity}, true default: return nil, false } diff --git a/pkg/analyze/host_network_namespace_connectivity.go b/pkg/analyze/host_network_namespace_connectivity.go new file mode 100644 index 000000000..62ecfb723 --- /dev/null +++ b/pkg/analyze/host_network_namespace_connectivity.go @@ -0,0 +1,80 @@ +package analyzer + +import ( + "encoding/json" + "fmt" + "path/filepath" + + util "github.com/replicatedhq/troubleshoot/internal/util" + troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" + "github.com/replicatedhq/troubleshoot/pkg/collect" +) + +type AnalyzeHostNetworkNamespaceConnectivity struct { + hostAnalyzer *troubleshootv1beta2.NetworkNamespaceConnectivityAnalyze +} + +func (a *AnalyzeHostNetworkNamespaceConnectivity) Title() string { + return hostAnalyzerTitleOrDefault(a.hostAnalyzer.AnalyzeMeta, "Network Namespace Connectivity") +} + +func (a *AnalyzeHostNetworkNamespaceConnectivity) IsExcluded() (bool, error) { + return isExcluded(a.hostAnalyzer.Exclude) +} + +func (a *AnalyzeHostNetworkNamespaceConnectivity) Analyze( + getCollectedFileContents func(string) ([]byte, error), findFiles getChildCollectedFileContents, +) ([]*AnalyzeResult, error) { + hostAnalyzer := a.hostAnalyzer + + collectedPath := filepath.Join("host-collectors/system", "networkNamespaceConnectivity.json") + fileName := "networkNamespaceConnectivity.json" + if hostAnalyzer.CollectorName != "" { + collectedPath = filepath.Join("host-collectors/system", hostAnalyzer.CollectorName+".json") + fileName = hostAnalyzer.CollectorName + ".json" + } + + collectedContents, err := retrieveCollectedContents( + getCollectedFileContents, + collectedPath, + collect.NodeInfoBaseDir, + fileName, + ) + if err != nil { + return nil, fmt.Errorf("failed to retrieve collected contents: %w", err) + } + + var results []*AnalyzeResult + for _, collected := range collectedContents { + var info collect.NetworkNamespaceConnectivityInfo + if err := json.Unmarshal(collected.Data, &info); err != nil { + return nil, fmt.Errorf("failed to unmarshal disk usage info: %w", err) + } + + for _, outcome := range hostAnalyzer.Outcomes { + result := &AnalyzeResult{Title: a.Title()} + + if outcome.Pass != nil && info.Success { + result.IsPass = true + result.Message, err = util.RenderTemplate(outcome.Pass.Message, &info) + if err != nil { + return nil, fmt.Errorf("failed to render template on outcome message: %w", err) + } + results = append(results, result) + break + } + + if outcome.Fail != nil && !info.Success { + result.IsFail = true + result.Message, err = util.RenderTemplate(outcome.Fail.Message, &info) + if err != nil { + return nil, fmt.Errorf("failed to render template on outcome message: %w", err) + } + results = append(results, result) + break + } + } + } + + return results, nil +} diff --git a/pkg/apis/troubleshoot/v1beta2/hostanalyzer_shared.go b/pkg/apis/troubleshoot/v1beta2/hostanalyzer_shared.go index e9e6fa26f..3d5c92984 100644 --- a/pkg/apis/troubleshoot/v1beta2/hostanalyzer_shared.go +++ b/pkg/apis/troubleshoot/v1beta2/hostanalyzer_shared.go @@ -6,6 +6,12 @@ type CPUAnalyze struct { Outcomes []*Outcome `json:"outcomes" yaml:"outcomes"` } +type NetworkNamespaceConnectivityAnalyze struct { + AnalyzeMeta `json:",inline" yaml:",inline"` + CollectorName string `json:"collectorName,omitempty" yaml:"collectorName,omitempty"` + Outcomes []*Outcome `json:"outcomes" yaml:"outcomes"` +} + type MemoryAnalyze struct { AnalyzeMeta `json:",inline" yaml:",inline"` CollectorName string `json:"collectorName,omitempty" yaml:"collectorName,omitempty"` @@ -130,27 +136,28 @@ type KernelConfigsAnalyze struct { } type HostAnalyze struct { - CPU *CPUAnalyze `json:"cpu,omitempty" yaml:"cpu,omitempty"` - TCPLoadBalancer *TCPLoadBalancerAnalyze `json:"tcpLoadBalancer,omitempty" yaml:"tcpLoadBalancer,omitempty"` - HTTPLoadBalancer *HTTPLoadBalancerAnalyze `json:"httpLoadBalancer,omitempty" yaml:"httpLoadBalancer,omitempty"` - DiskUsage *DiskUsageAnalyze `json:"diskUsage,omitempty" yaml:"diskUsage,omitempty"` - Memory *MemoryAnalyze `json:"memory,omitempty" yaml:"memory,omitempty"` - TCPPortStatus *TCPPortStatusAnalyze `json:"tcpPortStatus,omitempty" yaml:"tcpPortStatus,omitempty"` - UDPPortStatus *UDPPortStatusAnalyze `json:"udpPortStatus,omitempty" yaml:"udpPortStatus,omitempty"` - HTTP *HTTPAnalyze `json:"http,omitempty" yaml:"http,omitempty"` - Time *TimeAnalyze `json:"time,omitempty" yaml:"time,omitempty"` - BlockDevices *BlockDevicesAnalyze `json:"blockDevices,omitempty" yaml:"blockDevices,omitempty"` - SystemPackages *SystemPackagesAnalyze `json:"systemPackages,omitempty" yaml:"systemPackages,omitempty"` - KernelModules *KernelModulesAnalyze `json:"kernelModules,omitempty" yaml:"kernelModules,omitempty"` - TCPConnect *TCPConnectAnalyze `json:"tcpConnect,omitempty" yaml:"tcpConnect,omitempty"` - IPV4Interfaces *IPV4InterfacesAnalyze `json:"ipv4Interfaces,omitempty" yaml:"ipv4Interfaces,omitempty"` - SubnetAvailable *SubnetAvailableAnalyze `json:"subnetAvailable,omitempty" yaml:"subnetAvailable,omitempty"` - FilesystemPerformance *FilesystemPerformanceAnalyze `json:"filesystemPerformance,omitempty" yaml:"filesystemPerformance,omitempty"` - Certificate *CertificateAnalyze `json:"certificate,omitempty" yaml:"certificate,omitempty"` - CertificatesCollection *HostCertificatesCollectionAnalyze `json:"certificatesCollection,omitempty" yaml:"certificatesCollection,omitempty"` - HostServices *HostServicesAnalyze `json:"hostServices,omitempty" yaml:"hostServices,omitempty"` - HostOS *HostOSAnalyze `json:"hostOS,omitempty" yaml:"hostOS,omitempty"` - TextAnalyze *TextAnalyze `json:"textAnalyze,omitempty" yaml:"textAnalyze,omitempty"` - KernelConfigs *KernelConfigsAnalyze `json:"kernelConfigs,omitempty" yaml:"kernelConfigs,omitempty"` - JsonCompare *JsonCompare `json:"jsonCompare,omitempty" yaml:"jsonCompare,omitempty"` + CPU *CPUAnalyze `json:"cpu,omitempty" yaml:"cpu,omitempty"` + TCPLoadBalancer *TCPLoadBalancerAnalyze `json:"tcpLoadBalancer,omitempty" yaml:"tcpLoadBalancer,omitempty"` + HTTPLoadBalancer *HTTPLoadBalancerAnalyze `json:"httpLoadBalancer,omitempty" yaml:"httpLoadBalancer,omitempty"` + DiskUsage *DiskUsageAnalyze `json:"diskUsage,omitempty" yaml:"diskUsage,omitempty"` + Memory *MemoryAnalyze `json:"memory,omitempty" yaml:"memory,omitempty"` + TCPPortStatus *TCPPortStatusAnalyze `json:"tcpPortStatus,omitempty" yaml:"tcpPortStatus,omitempty"` + UDPPortStatus *UDPPortStatusAnalyze `json:"udpPortStatus,omitempty" yaml:"udpPortStatus,omitempty"` + HTTP *HTTPAnalyze `json:"http,omitempty" yaml:"http,omitempty"` + Time *TimeAnalyze `json:"time,omitempty" yaml:"time,omitempty"` + BlockDevices *BlockDevicesAnalyze `json:"blockDevices,omitempty" yaml:"blockDevices,omitempty"` + SystemPackages *SystemPackagesAnalyze `json:"systemPackages,omitempty" yaml:"systemPackages,omitempty"` + KernelModules *KernelModulesAnalyze `json:"kernelModules,omitempty" yaml:"kernelModules,omitempty"` + TCPConnect *TCPConnectAnalyze `json:"tcpConnect,omitempty" yaml:"tcpConnect,omitempty"` + IPV4Interfaces *IPV4InterfacesAnalyze `json:"ipv4Interfaces,omitempty" yaml:"ipv4Interfaces,omitempty"` + SubnetAvailable *SubnetAvailableAnalyze `json:"subnetAvailable,omitempty" yaml:"subnetAvailable,omitempty"` + FilesystemPerformance *FilesystemPerformanceAnalyze `json:"filesystemPerformance,omitempty" yaml:"filesystemPerformance,omitempty"` + Certificate *CertificateAnalyze `json:"certificate,omitempty" yaml:"certificate,omitempty"` + CertificatesCollection *HostCertificatesCollectionAnalyze `json:"certificatesCollection,omitempty" yaml:"certificatesCollection,omitempty"` + HostServices *HostServicesAnalyze `json:"hostServices,omitempty" yaml:"hostServices,omitempty"` + HostOS *HostOSAnalyze `json:"hostOS,omitempty" yaml:"hostOS,omitempty"` + TextAnalyze *TextAnalyze `json:"textAnalyze,omitempty" yaml:"textAnalyze,omitempty"` + KernelConfigs *KernelConfigsAnalyze `json:"kernelConfigs,omitempty" yaml:"kernelConfigs,omitempty"` + JsonCompare *JsonCompare `json:"jsonCompare,omitempty" yaml:"jsonCompare,omitempty"` + NetworkNamespaceConnectivity *NetworkNamespaceConnectivityAnalyze `json:"networkNamespaceConnectivity,omitempty" yaml:"networkNamespaceConnectivity,omitempty"` } diff --git a/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go b/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go index 9d17ee47a..6cdbb7bc7 100644 --- a/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go +++ b/pkg/apis/troubleshoot/v1beta2/hostcollector_shared.go @@ -76,6 +76,14 @@ type HostCopy struct { Path string `json:"path" yaml:"path"` } +type HostNetworkNamespaceConnectivity struct { + HostCollectorMeta `json:",inline" yaml:",inline"` + FromCIDR string `json:"fromCIDR" yaml:"fromCIDR"` + ToCIDR string `json:"toCIDR" yaml:"toCIDR"` + Timeout string `json:"timeout,omitempty" yaml:"timeout,omitempty"` + Port int `json:"port" yaml:"port"` +} + type HostCGroups struct { HostCollectorMeta `json:",inline" yaml:",inline"` MountPoint string `json:"mountPoint,omitempty" yaml:"mountPoint,omitempty"` @@ -224,33 +232,34 @@ type HostDNS struct { } type HostCollect struct { - CPU *CPU `json:"cpu,omitempty" yaml:"cpu,omitempty"` - Memory *Memory `json:"memory,omitempty" yaml:"memory,omitempty"` - TCPLoadBalancer *TCPLoadBalancer `json:"tcpLoadBalancer,omitempty" yaml:"tcpLoadBalancer,omitempty"` - HTTPLoadBalancer *HTTPLoadBalancer `json:"httpLoadBalancer,omitempty" yaml:"httpLoadBalancer,omitempty"` - TCPPortStatus *TCPPortStatus `json:"tcpPortStatus,omitempty" yaml:"tcpPortStatus,omitempty"` - UDPPortStatus *UDPPortStatus `json:"udpPortStatus,omitempty" yaml:"udpPortStatus,omitempty"` - Kubernetes *Kubernetes `json:"kubernetes,omitempty" yaml:"kubernetes,omitempty"` - IPV4Interfaces *IPV4Interfaces `json:"ipv4Interfaces,omitempty" yaml:"ipv4Interfaces,omitempty"` - SubnetAvailable *SubnetAvailable `json:"subnetAvailable,omitempty" yaml:"subnetAvailable,omitempty"` - DiskUsage *DiskUsage `json:"diskUsage,omitempty" yaml:"diskUsage,omitempty"` - HTTP *HostHTTP `json:"http,omitempty" yaml:"http,omitempty"` - Time *HostTime `json:"time,omitempty" yaml:"time,omitempty"` - BlockDevices *HostBlockDevices `json:"blockDevices,omitempty" yaml:"blockDevices,omitempty"` - SystemPackages *HostSystemPackages `json:"systemPackages,omitempty" yaml:"systemPackages,omitempty"` - KernelModules *HostKernelModules `json:"kernelModules,omitempty" yaml:"kernelModules,omitempty"` - TCPConnect *TCPConnect `json:"tcpConnect,omitempty" yaml:"tcpConnect,omitempty"` - FilesystemPerformance *FilesystemPerformance `json:"filesystemPerformance,omitempty" yaml:"filesystemPerformance,omitempty"` - Certificate *Certificate `json:"certificate,omitempty" yaml:"certificate,omitempty"` - CertificatesCollection *HostCertificatesCollection `json:"certificatesCollection,omitempty" yaml:"certificatesCollection,omitempty"` - HostServices *HostServices `json:"hostServices,omitempty" yaml:"hostServices,omitempty"` - HostOS *HostOS `json:"hostOS,omitempty" yaml:"hostOS,omitempty"` - HostRun *HostRun `json:"run,omitempty" yaml:"run,omitempty"` - HostCopy *HostCopy `json:"copy,omitempty" yaml:"copy,omitempty"` - HostKernelConfigs *HostKernelConfigs `json:"kernelConfigs,omitempty" yaml:"kernelConfigs,omitempty"` - HostJournald *HostJournald `json:"journald,omitempty" yaml:"journald,omitempty"` - HostCGroups *HostCGroups `json:"cgroups,omitempty" yaml:"cgroups,omitempty"` - HostDNS *HostDNS `json:"dns,omitempty" yaml:"dns,omitempty"` + CPU *CPU `json:"cpu,omitempty" yaml:"cpu,omitempty"` + Memory *Memory `json:"memory,omitempty" yaml:"memory,omitempty"` + TCPLoadBalancer *TCPLoadBalancer `json:"tcpLoadBalancer,omitempty" yaml:"tcpLoadBalancer,omitempty"` + HTTPLoadBalancer *HTTPLoadBalancer `json:"httpLoadBalancer,omitempty" yaml:"httpLoadBalancer,omitempty"` + TCPPortStatus *TCPPortStatus `json:"tcpPortStatus,omitempty" yaml:"tcpPortStatus,omitempty"` + UDPPortStatus *UDPPortStatus `json:"udpPortStatus,omitempty" yaml:"udpPortStatus,omitempty"` + Kubernetes *Kubernetes `json:"kubernetes,omitempty" yaml:"kubernetes,omitempty"` + IPV4Interfaces *IPV4Interfaces `json:"ipv4Interfaces,omitempty" yaml:"ipv4Interfaces,omitempty"` + SubnetAvailable *SubnetAvailable `json:"subnetAvailable,omitempty" yaml:"subnetAvailable,omitempty"` + DiskUsage *DiskUsage `json:"diskUsage,omitempty" yaml:"diskUsage,omitempty"` + HTTP *HostHTTP `json:"http,omitempty" yaml:"http,omitempty"` + Time *HostTime `json:"time,omitempty" yaml:"time,omitempty"` + BlockDevices *HostBlockDevices `json:"blockDevices,omitempty" yaml:"blockDevices,omitempty"` + SystemPackages *HostSystemPackages `json:"systemPackages,omitempty" yaml:"systemPackages,omitempty"` + KernelModules *HostKernelModules `json:"kernelModules,omitempty" yaml:"kernelModules,omitempty"` + TCPConnect *TCPConnect `json:"tcpConnect,omitempty" yaml:"tcpConnect,omitempty"` + FilesystemPerformance *FilesystemPerformance `json:"filesystemPerformance,omitempty" yaml:"filesystemPerformance,omitempty"` + Certificate *Certificate `json:"certificate,omitempty" yaml:"certificate,omitempty"` + CertificatesCollection *HostCertificatesCollection `json:"certificatesCollection,omitempty" yaml:"certificatesCollection,omitempty"` + HostServices *HostServices `json:"hostServices,omitempty" yaml:"hostServices,omitempty"` + HostOS *HostOS `json:"hostOS,omitempty" yaml:"hostOS,omitempty"` + HostRun *HostRun `json:"run,omitempty" yaml:"run,omitempty"` + HostCopy *HostCopy `json:"copy,omitempty" yaml:"copy,omitempty"` + HostKernelConfigs *HostKernelConfigs `json:"kernelConfigs,omitempty" yaml:"kernelConfigs,omitempty"` + HostJournald *HostJournald `json:"journald,omitempty" yaml:"journald,omitempty"` + HostCGroups *HostCGroups `json:"cgroups,omitempty" yaml:"cgroups,omitempty"` + HostDNS *HostDNS `json:"dns,omitempty" yaml:"dns,omitempty"` + NetworkNamespaceConnectivity *HostNetworkNamespaceConnectivity `json:"networkNamespaceConnectivity,omitempty" yaml:"networkNamespaceConnectivity,omitempty"` } // GetName gets the name of the collector diff --git a/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go b/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go index 8fb1ac586..a7e810864 100644 --- a/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go +++ b/pkg/apis/troubleshoot/v1beta2/zz_generated.deepcopy.go @@ -1920,6 +1920,11 @@ func (in *HostAnalyze) DeepCopyInto(out *HostAnalyze) { *out = new(JsonCompare) (*in).DeepCopyInto(*out) } + if in.NetworkNamespaceConnectivity != nil { + in, out := &in.NetworkNamespaceConnectivity, &out.NetworkNamespaceConnectivity + *out = new(NetworkNamespaceConnectivityAnalyze) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostAnalyze. @@ -2150,6 +2155,11 @@ func (in *HostCollect) DeepCopyInto(out *HostCollect) { *out = new(HostDNS) (*in).DeepCopyInto(*out) } + if in.NetworkNamespaceConnectivity != nil { + in, out := &in.NetworkNamespaceConnectivity, &out.NetworkNamespaceConnectivity + *out = new(HostNetworkNamespaceConnectivity) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostCollect. @@ -2414,6 +2424,22 @@ func (in *HostKernelModules) DeepCopy() *HostKernelModules { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HostNetworkNamespaceConnectivity) DeepCopyInto(out *HostNetworkNamespaceConnectivity) { + *out = *in + in.HostCollectorMeta.DeepCopyInto(&out.HostCollectorMeta) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HostNetworkNamespaceConnectivity. +func (in *HostNetworkNamespaceConnectivity) DeepCopy() *HostNetworkNamespaceConnectivity { + if in == nil { + return nil + } + out := new(HostNetworkNamespaceConnectivity) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *HostOS) DeepCopyInto(out *HostOS) { *out = *in @@ -3198,6 +3224,33 @@ func (in *MetricRequest) DeepCopy() *MetricRequest { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NetworkNamespaceConnectivityAnalyze) DeepCopyInto(out *NetworkNamespaceConnectivityAnalyze) { + *out = *in + in.AnalyzeMeta.DeepCopyInto(&out.AnalyzeMeta) + if in.Outcomes != nil { + in, out := &in.Outcomes, &out.Outcomes + *out = make([]*Outcome, len(*in)) + for i := range *in { + if (*in)[i] != nil { + in, out := &(*in)[i], &(*out)[i] + *out = new(Outcome) + (*in).DeepCopyInto(*out) + } + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkNamespaceConnectivityAnalyze. +func (in *NetworkNamespaceConnectivityAnalyze) DeepCopy() *NetworkNamespaceConnectivityAnalyze { + if in == nil { + return nil + } + out := new(NetworkNamespaceConnectivityAnalyze) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *NodeMetrics) DeepCopyInto(out *NodeMetrics) { *out = *in diff --git a/pkg/collect/host_collector.go b/pkg/collect/host_collector.go index 767313a5d..ff93e6655 100644 --- a/pkg/collect/host_collector.go +++ b/pkg/collect/host_collector.go @@ -99,6 +99,8 @@ func GetHostCollector(collector *troubleshootv1beta2.HostCollect, bundlePath str return &CollectHostCGroups{collector.HostCGroups, bundlePath}, true case collector.HostDNS != nil: return &CollectHostDNS{collector.HostDNS, bundlePath}, true + case collector.NetworkNamespaceConnectivity != nil: + return &CollectHostNetworkNamespaceConnectivity{collector.NetworkNamespaceConnectivity, bundlePath}, true default: return nil, false } diff --git a/pkg/collect/host_network_namespace_connectivity.go b/pkg/collect/host_network_namespace_connectivity.go new file mode 100644 index 000000000..354267c18 --- /dev/null +++ b/pkg/collect/host_network_namespace_connectivity.go @@ -0,0 +1,231 @@ +package collect + +import ( + "bytes" + "encoding/json" + "fmt" + "net" + "path/filepath" + "strings" + "sync" + "time" + + troubleshootv1beta2 "github.com/replicatedhq/troubleshoot/pkg/apis/troubleshoot/v1beta2" + "github.com/replicatedhq/troubleshoot/pkg/namespaces" +) + +// NetworkNamespaceConnectivityInfo is the output of this collector, here we +// have the logs, the information from the source and destination namespaces, +// errors and a success flag. +type NetworkNamespaceConnectivityInfo struct { + FromNamespace string `json:"from_namespace"` + ToNamespace string `json:"to_namespace"` + Errors NetworkNamespaceConnectivityErrors `json:"errors"` + Output NetworkNamespaceConnectivityOutput `json:"output"` + Success bool `json:"success"` +} + +// ErrorMessage returns the error message from the errors field. +func (n *NetworkNamespaceConnectivityInfo) ErrorMessage() string { + return n.Errors.Errors() +} + +// NetworkNamespaceConnectivityErrors is a struct that contains the errors that +// occurred during the network namespace connectivity test +type NetworkNamespaceConnectivityErrors struct { + FromNamespaceCreation string `json:"from_namespace_creation"` + ToNamespaceCreation string `json:"to_namespace_creation"` + UDPClient string `json:"udp_client"` + UDPServer string `json:"udp_server"` + TCPClient string `json:"tcp_client"` + TCPServer string `json:"tcp_server"` +} + +// Errors returns a string representation of the errors found during the +// network namespace connectivity test. +func (e NetworkNamespaceConnectivityErrors) Errors() string { + var sb strings.Builder + if e.FromNamespaceCreation != "" { + sb.WriteString("Failed to create 'from' namespace: ") + sb.WriteString(e.FromNamespaceCreation + "\n") + } + + if e.ToNamespaceCreation != "" { + sb.WriteString("Failed to create 'to' namespace: ") + sb.WriteString(e.ToNamespaceCreation + "\n") + } + + if e.UDPClient != "" { + sb.WriteString("UDP connection failed with: ") + sb.WriteString(e.UDPClient + "\n") + } + + if e.UDPServer != "" { + sb.WriteString("UDP server failed with: ") + sb.WriteString(e.UDPServer + "\n") + } + + if e.TCPClient != "" { + sb.WriteString("TCP connection failed with: ") + sb.WriteString(e.TCPClient + "\n") + } + + if e.TCPServer != "" { + sb.WriteString("TCP server failed with: ") + sb.WriteString(e.TCPServer + "\n") + } + return sb.String() +} + +// NetworkNamespaceConnectivityOutput is a struct that contains the logs from +// the network namespace connectivity collector. +type NetworkNamespaceConnectivityOutput struct { + mtx sync.Mutex + Logs []string `json:"logs"` +} + +// Printf is a method that allows us to print the logs directly into a slice. +func (l *NetworkNamespaceConnectivityOutput) Printf(format string, v ...interface{}) { + l.mtx.Lock() + defer l.mtx.Unlock() + + format = fmt.Sprintf("[%s] %s", time.Now().Format(time.RFC3339), format) + l.Logs = append(l.Logs, fmt.Sprintf(format, v...)) +} + +// CollectHostNetworkNamespaceConnectivity collects information about the +// capability of the host to route traffic between two different network +// namespaces. This collector will create two network namespaces and attempt to +// issue TCP and UDP requests between them. +type CollectHostNetworkNamespaceConnectivity struct { + hostCollector *troubleshootv1beta2.HostNetworkNamespaceConnectivity + BundlePath string +} + +// Title returns the title of the collector. +func (c *CollectHostNetworkNamespaceConnectivity) Title() string { + return hostCollectorTitleOrDefault(c.hostCollector.HostCollectorMeta, "Host Network Namespace Connectivity") +} + +// IsExcluded returns true if the collector should be excluded. +func (c *CollectHostNetworkNamespaceConnectivity) IsExcluded() (bool, error) { + return isExcluded(c.hostCollector.Exclude) +} + +// marshal marshals the network namespace connectivity info into a JSON file, +// writes it to the bundle path and returns the file name and the data. +func (c *CollectHostNetworkNamespaceConnectivity) marshal(info *NetworkNamespaceConnectivityInfo) (map[string][]byte, error) { + collectorName := c.hostCollector.CollectorName + if collectorName == "" { + collectorName = "networkNamespaceConnectivity" + } + name := filepath.Join("host-collectors/system", collectorName+".json") + + data, err := json.MarshalIndent(info, "", " ") + if err != nil { + return nil, fmt.Errorf("failed to marshal network namespace connectivity info: %w", err) + } + + output := NewResult() + output.SaveResult(c.BundlePath, name, bytes.NewBuffer(data)) + + return map[string][]byte{ + name: data, + }, nil +} + +// validateCIDRs validates both the from and to CIDRs. They must be provided, +// be different and be valid CIDRs. +func (c *CollectHostNetworkNamespaceConnectivity) validateCIDRs() error { + if c.hostCollector.FromCIDR == "" || c.hostCollector.ToCIDR == "" { + return fmt.Errorf("fromCIDR and toCIDR must be provided") + } + + if c.hostCollector.FromCIDR == c.hostCollector.ToCIDR { + return fmt.Errorf("fromCIDR and toCIDR must be different") + } + + if _, _, err := net.ParseCIDR(c.hostCollector.FromCIDR); err != nil { + return fmt.Errorf("%s is not a valid cidr: %w", c.hostCollector.FromCIDR, err) + } + + if _, _, err := net.ParseCIDR(c.hostCollector.ToCIDR); err != nil { + return fmt.Errorf("%s is not a valid cidr: %w", c.hostCollector.ToCIDR, err) + } + + return nil +} + +// Collect collects the network namespace connectivity information. This +// function expects both the from and to CIDRs to be provided and different +// from each other. +func (c *CollectHostNetworkNamespaceConnectivity) Collect(progressChan chan<- interface{}) (map[string][]byte, error) { + if err := c.validateCIDRs(); err != nil { + return nil, err + } + + result := &NetworkNamespaceConnectivityInfo{ + FromNamespace: c.hostCollector.FromCIDR, + ToNamespace: c.hostCollector.ToCIDR, + } + + opts := []namespaces.Option{namespaces.WithLogf(result.Output.Printf)} + + // if user has chosen to use a specific port, use it. + if c.hostCollector.Port != 0 { + opts = append(opts, namespaces.WithPort(c.hostCollector.Port)) + } + + // if user has chosen to use a specific timeout and it is valid, use it. + if c.hostCollector.Timeout != "" { + timeout, err := time.ParseDuration(c.hostCollector.Timeout) + if err != nil { + return nil, fmt.Errorf("invalid timeout %s", c.hostCollector.Timeout) + } + result.Output.Printf("using user provided timeout of %q", c.hostCollector.Timeout) + opts = append(opts, namespaces.WithTimeout(timeout)) + } + + fromNS, err := namespaces.NewNamespacePinger("from", c.hostCollector.FromCIDR, opts...) + if err != nil { + result.Errors.ToNamespaceCreation = err.Error() + return c.marshal(result) + } + defer fromNS.Close() + + toNS, err := namespaces.NewNamespacePinger("to", c.hostCollector.ToCIDR, opts...) + if err != nil { + result.Errors.FromNamespaceCreation = err.Error() + return c.marshal(result) + } + defer toNS.Close() + + udpErrors, tcpErrors := make(chan error), make(chan error) + toNS.StartUDPEchoServer(udpErrors) + toNS.StartTCPEchoServer(tcpErrors) + + success := true + if err := fromNS.PingUDP(toNS.InternalIP); err != nil { + result.Errors.UDPClient = err.Error() + success = false + } + + if err := <-udpErrors; err != nil { + result.Errors.UDPServer = err.Error() + success = false + } + + if err := fromNS.PingTCP(toNS.InternalIP); err != nil { + result.Errors.TCPClient = err.Error() + success = false + } + + if err := <-tcpErrors; err != nil { + result.Errors.TCPServer = err.Error() + success = false + } + + result.Success = success + result.Output.Printf("network namespace connectivity test finished") + return c.marshal(result) +} diff --git a/pkg/namespaces/errors.go b/pkg/namespaces/errors.go new file mode 100644 index 000000000..a18f2b431 --- /dev/null +++ b/pkg/namespaces/errors.go @@ -0,0 +1,18 @@ +package namespaces + +import "fmt" + +// WrapIfFail executes the provided function. If the function succeeds it +// simply returns the original error (that can be nil). If the function fails +// then it assesses if an original error was provided and wraps it if true. +// This function is a sugar to be used at when deferring function that can also +// return errors, we don't want to loose any context. +func WrapIfFail(msg string, originalerr error, fn func() error) error { + if fnerr := fn(); fnerr != nil { + if originalerr == nil { + return fmt.Errorf("%s: %w", msg, fnerr) + } + return fmt.Errorf("%s: %w: %w", msg, fnerr, originalerr) + } + return originalerr +} diff --git a/pkg/namespaces/errors_test.go b/pkg/namespaces/errors_test.go new file mode 100644 index 000000000..e46650427 --- /dev/null +++ b/pkg/namespaces/errors_test.go @@ -0,0 +1,57 @@ +package namespaces + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestWrapIfFail(t *testing.T) { + for _, tt := range []struct { + name string + msg string + originalerr error + fn func() error + expectederr string + }{ + { + name: "function succeeds, no original error", + msg: "test message", + originalerr: nil, + fn: func() error { return nil }, + expectederr: "", + }, + { + name: "no original error, function fails", + msg: "test message", + originalerr: nil, + fn: func() error { return fmt.Errorf("test error") }, + expectederr: "test error", + }, + { + name: "original error, function succeeds", + msg: "test message", + originalerr: fmt.Errorf("original error"), + fn: func() error { return nil }, + expectederr: "original error", + }, + { + name: "original error, and function fails", + msg: "test message", + originalerr: fmt.Errorf("original error"), + fn: func() error { return fmt.Errorf("func error") }, + expectederr: "test message: func error: original error", + }, + } { + t.Run(tt.name, func(t *testing.T) { + err := WrapIfFail(tt.msg, tt.originalerr, tt.fn) + if tt.expectederr == "" { + assert.NoError(t, err) + return + } + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.expectederr) + }) + } +} diff --git a/pkg/namespaces/interface-pair.go b/pkg/namespaces/interface-pair.go new file mode 100644 index 000000000..8fd5e37aa --- /dev/null +++ b/pkg/namespaces/interface-pair.go @@ -0,0 +1,91 @@ +//go:build linux + +package namespaces + +import ( + "errors" + "fmt" + "syscall" + + "github.com/vishvananda/netlink" +) + +// InterfacePair represents a pair of virtual ethernets that are connected to +// each other. these are used to connect a network namespace to the outside +// world. +type InterfacePair struct { + nethandler NetlinkHandler + prefix string + in netlink.Link + out netlink.Link + cfg Configuration +} + +// SetExternalIP assigns an ip address to the interface living in the default +// namespace (outside interface). +func (p *InterfacePair) SetExternalIP(outaddr string) error { + addr, err := p.nethandler.ParseAddr(outaddr) + if err != nil { + return fmt.Errorf("error parsing ip: %w", err) + } + + if err := p.nethandler.AddrAdd(p.out, addr); err != nil { + return fmt.Errorf("error assigning ip: %w", err) + } + + if err := p.nethandler.LinkSetUp(p.out); err != nil { + return fmt.Errorf("error bringing up: %w", err) + } + return nil +} + +// Close deletes the interface pair. by deleting one of the interfaces, the +// other is deleted as well. +func (p *InterfacePair) Close() error { + for _, ifc := range []netlink.Link{p.in, p.out} { + if err := p.nethandler.LinkDel(ifc); err != nil { + var scerr syscall.Errno + if errors.As(err, &scerr) && scerr == syscall.ENODEV { + continue + } + return fmt.Errorf("error deleting %s: %w", ifc.Attrs().Name, scerr) + } + } + return nil +} + +// Setup sets up the interface pair. this function will create the veth pair +// and bring the interfaces up. +func (p *InterfacePair) Setup() (err error) { + in := fmt.Sprintf("%s-in", p.prefix) + out := fmt.Sprintf("%s-out", p.prefix) + veth := &netlink.Veth{ + LinkAttrs: netlink.LinkAttrs{Name: in}, + PeerName: out, + } + + p.cfg.Logf("creating interface pair %q and %q", in, out) + if err := p.nethandler.LinkAdd(veth); err != nil { + return fmt.Errorf("error creating veth pair: %w", err) + } + + if p.in, err = p.nethandler.LinkByName(in); err != nil { + return fmt.Errorf("error finding %s: %w", in, err) + } + + if p.out, err = p.nethandler.LinkByName(out); err != nil { + return fmt.Errorf("error finding %s: %w", out, err) + } + return nil +} + +// NewInterfacePair creates a pair of connected virtual ethernets. interfaces +// are named `prefix-in` and `prefix-out`. +func NewInterfacePair(prefix string, options ...Option) *InterfacePair { + config := NewConfiguration(options...) + return &InterfacePair{ + nethandler: NetlinkHandle{}, + prefix: prefix, + cfg: config, + } +} diff --git a/pkg/namespaces/interface-pair_test.go b/pkg/namespaces/interface-pair_test.go new file mode 100644 index 000000000..a86658f51 --- /dev/null +++ b/pkg/namespaces/interface-pair_test.go @@ -0,0 +1,119 @@ +//go:build linux + +package namespaces + +import ( + "net" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/vishvananda/netlink" +) + +// MockNetlink is a mock for netlink handler used in InterfacePair. +type MockNetlink struct { + mock.Mock +} + +// Mock methods +func (m *MockNetlink) ParseAddr(addr string) (*netlink.Addr, error) { + args := m.Called(addr) + return args.Get(0).(*netlink.Addr), args.Error(1) +} + +func (m *MockNetlink) AddrAdd(link netlink.Link, addr *netlink.Addr) error { + args := m.Called(link, addr) + return args.Error(0) +} + +func (m *MockNetlink) LinkSetUp(link netlink.Link) error { + args := m.Called(link) + return args.Error(0) +} + +func (m *MockNetlink) LinkDel(link netlink.Link) error { + args := m.Called(link) + return args.Error(0) +} + +func (m *MockNetlink) LinkAdd(link netlink.Link) error { + args := m.Called(link) + return args.Error(0) +} + +func (m *MockNetlink) LinkByName(name string) (netlink.Link, error) { + args := m.Called(name) + return args.Get(0).(netlink.Link), args.Error(1) +} + +func (m *MockNetlink) LinkSetNsFd(link netlink.Link, fd int) error { + args := m.Called(link, fd) + return args.Error(0) +} + +func (m *MockNetlink) RouteAdd(route *netlink.Route) error { + args := m.Called(route) + return args.Error(0) +} + +func TestInterfacePairSetExternalIP(t *testing.T) { + mockNetlink := &MockNetlink{} + pair := InterfacePair{ + prefix: "test", + nethandler: mockNetlink, + } + + result := &netlink.Addr{ + IPNet: &net.IPNet{ + IP: net.ParseIP("10.0.0.1"), + }, + } + + mockNetlink.On("ParseAddr", "10.0.0.1").Return(result, nil) + mockNetlink.On("AddrAdd", mock.Anything, mock.Anything).Return(nil) + mockNetlink.On("LinkSetUp", mock.Anything).Return(nil) + + err := pair.SetExternalIP("10.0.0.1") + assert.NoError(t, err) + mockNetlink.AssertCalled(t, "ParseAddr", "10.0.0.1") + mockNetlink.AssertCalled(t, "AddrAdd", mock.Anything, mock.Anything) + mockNetlink.AssertCalled(t, "LinkSetUp", mock.Anything, mock.Anything) + +} + +func TestInterfacePairClose(t *testing.T) { + mockNetlink := &MockNetlink{} + pair := InterfacePair{nethandler: mockNetlink} + + mockNetlink.On("LinkDel", mock.Anything).Return(nil) + + err := pair.Close() + assert.NoError(t, err) + mockNetlink.AssertCalled(t, "LinkDel", mock.Anything) +} + +func TestInterfacePairSetup(t *testing.T) { + mockNetlink := &MockNetlink{} + pair := InterfacePair{ + prefix: "test", + nethandler: mockNetlink, + cfg: NewConfiguration(), + } + + veth := &netlink.Veth{ + LinkAttrs: netlink.LinkAttrs{Name: "test-in"}, + PeerName: "test-out", + } + + mockNetlink.On("LinkAdd", veth).Return(nil) + mockNetlink.On("LinkByName", "test-in").Return(&netlink.Device{}, nil) + mockNetlink.On("LinkByName", "test-out").Return(&netlink.Device{}, nil) + + err := pair.Setup() + assert.NoError(t, err) + + mockNetlink.AssertCalled(t, "LinkAdd", veth) + mockNetlink.AssertCalled(t, "LinkByName", "test-in") + mockNetlink.AssertCalled(t, "LinkByName", "test-out") +} diff --git a/pkg/namespaces/managed-namespace.go b/pkg/namespaces/managed-namespace.go new file mode 100644 index 000000000..da109abc0 --- /dev/null +++ b/pkg/namespaces/managed-namespace.go @@ -0,0 +1,110 @@ +//go:build linux + +package namespaces + +import ( + "fmt" + "net" + + "github.com/apparentlymart/go-cidr/cidr" +) + +// ManagedNetworkNamespace is a struct that helps up setting up a namespace +// with a pre-defined configuration. See NewManagedNetworkNamespace for more +// information on how the namespace is configured. +type ManagedNetworkNamespace struct { + *NetworkNamespace + *InterfacePair + + InternalIP net.IP + ExternalIP net.IP + cfg Configuration +} + +// NewManagedNetworkNamespace creates a new configured network namespace. This +// network namespace will have an interface configurwed with the first ip +// address of the provided cidr. The external interface (living in the default +// namespace) will be configured with the last ip address of the provided cidr +// and will be set as the default gateway for the namespace. +func NewManagedNetworkNamespace(name, cidraddr string, options ...Option) (*ManagedNetworkNamespace, error) { + config := NewConfiguration(options...) + config.Logf("creating network namespace %q with cidr %q", name, cidraddr) + + _, netaddr, err := net.ParseCIDR(cidraddr) + if err != nil { + return nil, fmt.Errorf("failed to parse cidr: %w", err) + } + + // AddressRange() returns the first and the last addresses of the cidrs. + // Those aren't useful as the first is the network address and the last + // is the broadcast address. We need to adjust them here. + netsize, _ := netaddr.Mask.Size() + first, last := cidr.AddressRange(netaddr) + first, last = cidr.Inc(first), cidr.Dec(last) + config.Logf("network namespace %q address range: %q - %q", name, first, last) + + pair := NewInterfacePair(name, options...) + if err := pair.Setup(); err != nil { + return nil, fmt.Errorf("error creating interface pair: %w", err) + } + + fulladdr := fmt.Sprintf("%s/%d", last, netsize) + if err := pair.SetExternalIP(fulladdr); err != nil { + pair.Close() + return nil, fmt.Errorf("error setting external interface: %w", err) + } + + namespace := NewNetworkNamespace(name, options...) + if err := namespace.Setup(); err != nil { + pair.Close() + return nil, fmt.Errorf("error creating namespace: %w", err) + } + + if err := namespace.AttachInterface(pair.in.Attrs().Name); err != nil { + pair.Close() + namespace.Close() + return nil, fmt.Errorf("error attaching interface pair: %w", err) + } + + fulladdr = fmt.Sprintf("%s/%d", first, netsize) + if err := namespace.SetInterfaceIP(pair.in.Attrs().Name, fulladdr); err != nil { + pair.Close() + namespace.Close() + return nil, fmt.Errorf("error setting interface ip: %w", err) + } + + for _, ifname := range []string{"lo", pair.in.Attrs().Name} { + if err := namespace.BringInterfaceUp(ifname); err != nil { + pair.Close() + namespace.Close() + return nil, fmt.Errorf("error bringing %s interface up: %w", ifname, err) + } + } + + if err := namespace.SetDefaultGateway(last.String()); err != nil { + pair.Close() + namespace.Close() + return nil, fmt.Errorf("error setting default gateway: %w", err) + } + + return &ManagedNetworkNamespace{ + NetworkNamespace: namespace, + InterfacePair: pair, + InternalIP: first, + ExternalIP: last, + cfg: config, + }, nil +} + +// Close destroys both the interface pair and the namespace. Here we only need +// to worry about deleting the namespace as the veth pair will be deleted +// automatically. +func (n *ManagedNetworkNamespace) Close() error { + if err := n.InterfacePair.Close(); err != nil { + return fmt.Errorf("error closing interface pair: %w", err) + } + if err := n.NetworkNamespace.Close(); err != nil { + return fmt.Errorf("error closing namespace: %w", err) + } + return nil +} diff --git a/pkg/namespaces/managed-namespace_unsupported.go b/pkg/namespaces/managed-namespace_unsupported.go new file mode 100644 index 000000000..6f2c91b97 --- /dev/null +++ b/pkg/namespaces/managed-namespace_unsupported.go @@ -0,0 +1,42 @@ +//go:build !linux + +package namespaces + +import ( + "fmt" + "net" + "runtime" +) + +type NamespacePinger struct { + InternalIP net.IP + ExternalIP net.IP +} + +func (n *NamespacePinger) Close() error { + return fmt.Errorf("namespaces not supported on %s platform", runtime.GOOS) +} + +func (n *NamespacePinger) PingUDP(_ net.IP) error { + return fmt.Errorf("namespaces not supported on %s platform", runtime.GOOS) +} + +func (n *NamespacePinger) PingTCP(_ net.IP) error { + return fmt.Errorf("namespaces not supported on %s platform", runtime.GOOS) +} + +func (n *NamespacePinger) StartTCPEchoServer(errors chan error) { + go func() { + errors <- fmt.Errorf("namespaces not supported on %s platform", runtime.GOOS) + }() +} + +func (n *NamespacePinger) StartUDPEchoServer(errors chan error) { + go func() { + errors <- fmt.Errorf("namespaces not supported on %s platform", runtime.GOOS) + }() +} + +func NewNamespacePinger(_, _ string, _ ...Option) (*NamespacePinger, error) { + return nil, fmt.Errorf("namespaces not supported on %s platform", runtime.GOOS) +} diff --git a/pkg/namespaces/namespace-handler.go b/pkg/namespaces/namespace-handler.go new file mode 100644 index 000000000..5822cb3ec --- /dev/null +++ b/pkg/namespaces/namespace-handler.go @@ -0,0 +1,38 @@ +//go:build linux + +package namespaces + +import "github.com/vishvananda/netns" + +// NamespaceHandler is an interface that represents the netns functions that +// we need to mock. This only exists for test purposes. +type NamespaceHandler interface { + DeleteNamed(string) error + Set(netns.NsHandle) error + Get() (netns.NsHandle, error) + NewNamed(string) (netns.NsHandle, error) +} + +// NamespaceHandle is a struct that exists solely for the purpose of mocking +// netns functions on tests. It just wraps calls to the netns package. +type NamespaceHandle struct{} + +// DeleteNamed calls netns.DeleteNamed. +func (n NamespaceHandle) DeleteNamed(name string) error { + return netns.DeleteNamed(name) +} + +// Set calls netns.Set. +func (n NamespaceHandle) Set(ns netns.NsHandle) error { + return netns.Set(ns) +} + +// Get calls netns.Get. +func (n NamespaceHandle) Get() (netns.NsHandle, error) { + return netns.Get() +} + +// NewNamed calls netns.NewNamed. +func (n NamespaceHandle) NewNamed(name string) (netns.NsHandle, error) { + return netns.NewNamed(name) +} diff --git a/pkg/namespaces/namespace-pinger.go b/pkg/namespaces/namespace-pinger.go new file mode 100644 index 000000000..b6f23d720 --- /dev/null +++ b/pkg/namespaces/namespace-pinger.go @@ -0,0 +1,217 @@ +//go:build linux + +package namespaces + +import ( + "fmt" + "net" + "time" +) + +type NamespacePinger struct { + *ManagedNetworkNamespace + cfg Configuration +} + +// PingUDP communicates with the provided IP address from within the namespace. +// This functions sends an UDP packet and expects to receive an echo back. +func (n *NamespacePinger) PingUDP(dst net.IP) error { + n.cfg.Logf("reaching to %q from %q with udp", dst, n.InternalIP) + pinger := func() error { + addr := &net.UDPAddr{IP: dst, Port: n.cfg.Port} + conn, err := net.DialUDP("udp", nil, addr) + if err != nil { + return fmt.Errorf("error dialing udp: %w", err) + } + defer conn.Close() + + if _, err = conn.Write([]byte("echo")); err != nil { + return fmt.Errorf("error writing to udp socket: %w", err) + } + + deadline := time.Now().Add(n.cfg.Timeout) + if err := conn.SetReadDeadline(deadline); err != nil { + return fmt.Errorf("error setting udp read deadline: %w", err) + } + + // XXX: review this buffer size and validation. + buffer := make([]byte, 6) + if _, _, err = conn.ReadFromUDP(buffer); err != nil { + return fmt.Errorf("error reading from udp socket: %w", err) + } + + return nil + } + + return n.Run(pinger) +} + +// PingTCP communicates with the provided IP address from within the namespace. +// This functions sends an TCP packet and expects to receive an echo back. +func (n *NamespacePinger) PingTCP(dst net.IP) error { + n.cfg.Logf("reaching to %q from %q with tcp", dst, n.InternalIP) + pinger := func() error { + addr := fmt.Sprintf("%s:%d", dst, n.cfg.Port) + conn, err := net.DialTimeout("tcp", addr, n.cfg.Timeout) + if err != nil { + return fmt.Errorf("error dialing tcp: %w", err) + } + defer conn.Close() + + if _, err = conn.Write([]byte("echo")); err != nil { + return fmt.Errorf("error writing to tcp socket: %w", err) + } + + // XXX: review this buffer size and validation. + buffer := make([]byte, 6) + if _, err = conn.Read(buffer); err != nil { + return fmt.Errorf("error reading from tcp socket: %w", err) + } + + return nil + } + return n.Run(pinger) +} + +// StartTCPEchoServer is a helper to run startTCPEchoServer inside a goroutine. +// This function blocks until the server is ready to receive packets or failed +// to start. Errors are sent to the provided channel. +func (n *NamespacePinger) StartTCPEchoServer(errors chan error) { + ready := make(chan struct{}) + go func() { + errors <- n.startTCPEchoServer(ready) + }() + <-ready +} + +// startTCPEchoServer starts a tcp server inside the namespace. The thread +// running the goroutine that process this call will be moved to the namespace. +// This echo servers just returns "echo" as a response. Once one packet is +// received, the server ends. Callers must wait until the ready channel is +// closed before they can start sending packets. +func (n *NamespacePinger) startTCPEchoServer(ready chan struct{}) (err error) { + addr := fmt.Sprintf("%s:%d", n.InternalIP, n.cfg.Port) + n.cfg.Logf("starting tcp echo server on namespace %q(%q)", n.name, addr) + + if err = n.Join(); err != nil { + close(ready) + return fmt.Errorf("error joining namespace: %w", err) + } + + defer func() { + err = WrapIfFail("error leaving namespace", err, n.Leave) + }() + + listener, err := net.Listen("tcp", addr) + if err != nil { + close(ready) + return fmt.Errorf("error starting tcp server: %w", err) + } + defer listener.Close() + + deadline := time.Now().Add(n.cfg.Timeout) + tcplistener := listener.(*net.TCPListener) + if err = tcplistener.SetDeadline(deadline); err != nil { + close(ready) + return fmt.Errorf("error setting tcp listener deadline: %w", err) + } + + go func() { + // XXX: here be dragons. we can't signalize we are ready until + // the call to read is done so we artificially sleep for a bit + // here. + time.Sleep(100 * time.Millisecond) + close(ready) + }() + + var conn net.Conn + if conn, err = listener.Accept(); err != nil { + return fmt.Errorf("error accepting connection: %w", err) + } + + n.cfg.Logf("received tcp packet on %q from %q", n.InternalIP, conn.RemoteAddr()) + + if _, err = conn.Write([]byte("echo\n")); err != nil { + return fmt.Errorf("error writing to tcp socket: %w", err) + } + + return nil +} + +// StartUDPEchoServer is a helper to run startUDPTCPEchoServer inside a goroutine. +// This function blocks until the server is ready to receive packets or failed +// to start. Errors are sent to the provided channel. +func (n *NamespacePinger) StartUDPEchoServer(errors chan error) { + ready := make(chan struct{}) + go func() { + errors <- n.startUDPEchoServer(ready) + }() + <-ready +} + +// startUDPEchoServers starts an udp server inside the namespace. The thread +// running the goroutine that process this call will be moved to the namespace. +// This echo servers just returns "echo" as a response. Once one packet is +// received, the server ends. Callers must wait until the ready channel is +// closed before they can start sending packets. +func (n *NamespacePinger) startUDPEchoServer(ready chan struct{}) (err error) { + addr := net.UDPAddr{Port: n.cfg.Port, IP: n.InternalIP} + n.cfg.Logf("starting udp echo server on namespace %q(%q)", n.name, addr.String()) + + if err = n.Join(); err != nil { + close(ready) + return fmt.Errorf("error joining namespace: %w", err) + } + + defer func() { + err = WrapIfFail("error leaving namespace", err, n.Leave) + }() + + conn, err := net.ListenUDP("udp", &addr) + if err != nil { + close(ready) + return fmt.Errorf("error starting udp server: %w", err) + } + defer conn.Close() + + deadline := time.Now().Add(n.cfg.Timeout) + if err = conn.SetDeadline(deadline); err != nil { + close(ready) + return fmt.Errorf("error setting udp listener deadline: %w", err) + } + + go func() { + // XXX: here be dragons. we can't signalize we are ready until + // the call to read is done so we artificially sleep for a bit + // here. + time.Sleep(100 * time.Millisecond) + close(ready) + }() + + var source *net.UDPAddr + var buffer = make([]byte, 1024) + if _, source, err = conn.ReadFromUDP(buffer); err != nil { + return fmt.Errorf("error reading from udp socket: %w", err) + } + + n.cfg.Logf("received udp packet on %q from %q", n.InternalIP, source.AddrPort()) + + if _, err = conn.WriteToUDP([]byte("echo"), source); err != nil { + return fmt.Errorf("error writing to udp socket: %w", err) + } + + return nil +} + +func NewNamespacePinger(name, cidraddr string, options ...Option) (*NamespacePinger, error) { + config := NewConfiguration(options...) + + namespace, err := NewManagedNetworkNamespace(name, cidraddr, options...) + if err != nil { + return nil, fmt.Errorf("error creating network namespace: %w", err) + } + return &NamespacePinger{ + ManagedNetworkNamespace: namespace, + cfg: config, + }, nil +} diff --git a/pkg/namespaces/netlink-handler.go b/pkg/namespaces/netlink-handler.go new file mode 100644 index 000000000..8dba5af75 --- /dev/null +++ b/pkg/namespaces/netlink-handler.go @@ -0,0 +1,62 @@ +//go:build linux + +package namespaces + +import "github.com/vishvananda/netlink" + +// NetlinkHandler is an interface that represents the netlink functions that +// we need to mock. This only exists for test purposes. +type NetlinkHandler interface { + ParseAddr(string) (*netlink.Addr, error) + AddrAdd(netlink.Link, *netlink.Addr) error + LinkSetUp(netlink.Link) error + LinkDel(netlink.Link) error + LinkAdd(netlink.Link) error + LinkByName(string) (netlink.Link, error) + LinkSetNsFd(netlink.Link, int) error + RouteAdd(*netlink.Route) error +} + +// NetlinkHandle is a struct that exists solely for the purpose of mocking +// netlink functions on tests. +type NetlinkHandle struct{} + +// ParseAddr calls netlink.ParseAddr. +func (n NetlinkHandle) ParseAddr(s string) (*netlink.Addr, error) { + return netlink.ParseAddr(s) +} + +// AddrAdd calls netlink.AddrAdd. +func (n NetlinkHandle) AddrAdd(l netlink.Link, a *netlink.Addr) error { + return netlink.AddrAdd(l, a) +} + +// LinkSetUp calls netlink.LinkSetUp. +func (n NetlinkHandle) LinkSetUp(link netlink.Link) error { + return netlink.LinkSetUp(link) +} + +// LinkDel calls netlink.LinkDel. +func (n NetlinkHandle) LinkDel(link netlink.Link) error { + return netlink.LinkDel(link) +} + +// LinkAdd calls netlink.LinkAdd. +func (n NetlinkHandle) LinkAdd(link netlink.Link) error { + return netlink.LinkAdd(link) +} + +// LinkByName calls netlink.LinkByName. +func (n NetlinkHandle) LinkByName(name string) (netlink.Link, error) { + return netlink.LinkByName(name) +} + +// LinkSetNsFd calls netlink.LinkSetNsFd. +func (n NetlinkHandle) LinkSetNsFd(link netlink.Link, fd int) error { + return netlink.LinkSetNsFd(link, fd) +} + +// RouteAdd calls netlink.RouteAdd. +func (n NetlinkHandle) RouteAdd(route *netlink.Route) error { + return netlink.RouteAdd(route) +} diff --git a/pkg/namespaces/network-namespace.go b/pkg/namespaces/network-namespace.go new file mode 100644 index 000000000..65d36323e --- /dev/null +++ b/pkg/namespaces/network-namespace.go @@ -0,0 +1,242 @@ +//go:build linux + +package namespaces + +import ( + "fmt" + "net" + "runtime" + "sync" + "syscall" + + "github.com/vishvananda/netlink" + "github.com/vishvananda/netns" +) + +// NetworkNamespace represents a network namespace. +type NetworkNamespace struct { + nethandler NetlinkHandler + nshandler NamespaceHandler + handle netns.NsHandle + mutex sync.Mutex + origins map[int]netns.NsHandle + name string + cfg Configuration +} + +// Close closes and deletes the network namespace. +func (n *NetworkNamespace) Close() error { + if err := n.handle.Close(); err != nil { + return fmt.Errorf("error closing namespace: %w", err) + } + if err := n.nshandler.DeleteNamed(n.name); err != nil { + return fmt.Errorf("error deleting namespace: %w", err) + } + return nil +} + +// AttachInterface attaches the the provided interface into the namespace. This +// function does not bring the interface up. +func (n *NetworkNamespace) AttachInterface(ifname string) error { + n.cfg.Logf("attaching interface %q to namespace %q", ifname, n.name) + iface, err := n.nethandler.LinkByName(ifname) + if err != nil { + return fmt.Errorf("error finding interface: %w", err) + } + + // put the `in` interface into the namespace. + if err := n.nethandler.LinkSetNsFd(iface, int(n.handle)); err != nil { + return fmt.Errorf("error moving peer into namespace: %w", err) + } + + return nil +} + +// Leave makes the thread leave the namespace. This function returns the thread +// to the previous namespace. Leaves() can't be called without Joining first. +// This function unlocks the current OS thread so it can be used again by +// multiple goroutines. +func (n *NetworkNamespace) Leave() error { + n.mutex.Lock() + defer n.mutex.Unlock() + + var origin netns.NsHandle + var ok bool + + threadID := syscall.Gettid() + if origin, ok = n.origins[threadID]; !ok { + return fmt.Errorf("error leaving namespace: namespace not joined") + } + + if err := n.nshandler.Set(origin); err != nil { + return fmt.Errorf("error switching to original namespace: %w", err) + } + + if err := origin.Close(); err != nil { + return fmt.Errorf("error closing original namespace: %w", err) + } + + delete(n.origins, threadID) + runtime.UnlockOSThread() + return nil +} + +// Join makes the thread join the namespace. The current thread is saved in the +// origin field. Callers are responsible for calling Leave() once they are +// done. This namespace can only be joined once and this is by design. You need +// to Leave() before Joining again. The current OS thread will be locked to the +// namespace. +func (n *NetworkNamespace) Join() (err error) { + n.mutex.Lock() + defer n.mutex.Unlock() + + runtime.LockOSThread() + defer func() { + if err != nil { + runtime.UnlockOSThread() + } + }() + + threadID := syscall.Gettid() + if _, ok := n.origins[threadID]; ok { + return fmt.Errorf("error joining namespace: namespace already joined") + } + + origin, err := n.nshandler.Get() + if err != nil { + return fmt.Errorf("error getting current namespace: %w", err) + } + + if err := n.nshandler.Set(n.handle); err != nil { + return fmt.Errorf("error switching to the namespace: %w", err) + } + + n.origins[threadID] = origin + return nil +} + +// SetInterfaceIP sets the ip address for the provided interface. +func (n *NetworkNamespace) SetInterfaceIP(ifname, ipaddr string) error { + addr, err := n.nethandler.ParseAddr(ipaddr) + if err != nil { + return fmt.Errorf("error parsing ip: %w", err) + } + + // this function will be executed inside the namespace. + fn := func() error { + iface, err := n.nethandler.LinkByName(ifname) + if err != nil { + return err + } + return n.nethandler.AddrAdd(iface, addr) + } + + if err := n.Run(fn); err != nil { + return fmt.Errorf("error setting interface ip: %w", err) + } + + return nil +} + +// BringInterfaceUp brings the provided interface up inside the namespace. +func (n *NetworkNamespace) BringInterfaceUp(ifname string) error { + fn := func() error { + iface, err := n.nethandler.LinkByName(ifname) + if err != nil { + return err + } + return n.nethandler.LinkSetUp(iface) + } + + if err := n.Run(fn); err != nil { + return fmt.Errorf("error bringing interface up: %w", err) + } + + return nil +} + +// SetDefaultGateway sets the default gateway for the namespace. +func (n *NetworkNamespace) SetDefaultGateway(addr string) error { + n.cfg.Logf("setting default gateway %q for namespace %q", addr, n.name) + gw := net.ParseIP(addr) + if gw == nil { + return fmt.Errorf("error parsing invalid gateway: %s", addr) + } + + if err := n.Run( + func() error { + route := netlink.Route{Gw: gw} + return n.nethandler.RouteAdd(&route) + }, + ); err != nil { + return fmt.Errorf("error setting default gateway: %w", err) + } + + return nil +} + +// Run runs the provided function inside the namespace. Restores the original +// namespace once the function has finished. +func (n *NetworkNamespace) Run(f func() error) (err error) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + var origin netns.NsHandle + if origin, err = n.nshandler.Get(); err != nil { + return fmt.Errorf("error getting current namespace: %w", err) + } + + defer func() { + err = WrapIfFail("error closing namespace", err, origin.Close) + }() + + if err := n.nshandler.Set(n.handle); err != nil { + return fmt.Errorf("error switching to namespace: %w", err) + } + + defer func() { + setter := func() error { return n.nshandler.Set(origin) } + err = WrapIfFail("error exiting namespace", err, setter) + }() + + return f() +} + +func (n *NetworkNamespace) Setup() (err error) { + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + var origin netns.NsHandle + if origin, err = n.nshandler.Get(); err != nil { + return fmt.Errorf("error getting current namespace: %w", err) + } + + defer func() { + err = WrapIfFail("error closing original namespace", err, origin.Close) + }() + + var handle netns.NsHandle + if handle, err = n.nshandler.NewNamed(n.name); err != nil { + return fmt.Errorf("error creating network namespace: %w", err) + } + + defer func() { + setter := func() error { return n.nshandler.Set(origin) } + err = WrapIfFail("error exiting namespace", err, setter) + }() + + n.handle = handle + return nil +} + +// NewNetworkNamespace creates a new network namespace. once the namespace is +// created this function restores the thread to the original namespace. +func NewNetworkNamespace(name string, options ...Option) *NetworkNamespace { + return &NetworkNamespace{ + nethandler: NetlinkHandle{}, + nshandler: NamespaceHandle{}, + name: name, + cfg: NewConfiguration(options...), + origins: map[int]netns.NsHandle{}, + } +} diff --git a/pkg/namespaces/network-namespace_test.go b/pkg/namespaces/network-namespace_test.go new file mode 100644 index 000000000..151684e5b --- /dev/null +++ b/pkg/namespaces/network-namespace_test.go @@ -0,0 +1,248 @@ +//go:build linux + +package namespaces + +import ( + "fmt" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/vishvananda/netlink" + "github.com/vishvananda/netns" +) + +type MockNamespace struct { + mock.Mock +} + +func (m *MockNamespace) DeleteNamed(name string) error { + args := m.Called(name) + return args.Error(0) +} + +func (m *MockNamespace) Set(ns netns.NsHandle) error { + args := m.Called(ns) + return args.Error(0) +} + +func (m *MockNamespace) Get() (netns.NsHandle, error) { + args := m.Called() + return args.Get(0).(netns.NsHandle), args.Error(1) +} + +func (m *MockNamespace) NewNamed(name string) (netns.NsHandle, error) { + args := m.Called(name) + return args.Get(0).(netns.NsHandle), args.Error(1) +} + +func TestNetworkNamespaceAttachInterface(t *testing.T) { + mockNetlink := &MockNetlink{} + + ns := NewNetworkNamespace("test") + ns.nethandler = mockNetlink + + mockNetlink.On("LinkByName", "test-in").Return(&netlink.Device{}, nil) + mockNetlink.On("LinkSetNsFd", mock.Anything, mock.Anything).Return(nil) + + err := ns.AttachInterface("test-in") + assert.NoError(t, err, "error attaching interface") + + mockNetlink.AssertCalled(t, "LinkByName", "test-in") + mockNetlink.AssertCalled(t, "LinkSetNsFd", mock.Anything, mock.Anything) +} + +func TestNetworkNamespaceJoin(t *testing.T) { + mockNetlink := &MockNetlink{} + mockNamespace := &MockNamespace{} + + ns := NewNetworkNamespace("test") + ns.nethandler = mockNetlink + ns.nshandler = mockNamespace + + mockNamespace.On("Get").Return(netns.NsHandle(0), nil) + mockNamespace.On("Set", mock.Anything).Return(nil) + + err := ns.Join() + assert.NoError(t, err, "error joining namespace") + assert.NotEmpty(t, ns.origins) + + mockNamespace.AssertCalled(t, "Get") + mockNamespace.AssertCalled(t, "Set", mock.Anything) +} + +func TestNetworkNamespaceLeave(t *testing.T) { + mockNetlink := &MockNetlink{} + mockNamespace := &MockNamespace{} + + ns := NewNetworkNamespace("test") + ns.nethandler = mockNetlink + ns.nshandler = mockNamespace + + mockNamespace.On("Get").Return(netns.NsHandle(1), nil) + mockNamespace.On("Set", mock.Anything).Return(nil) + + err := ns.Join() + assert.NoError(t, err, "error joining namespace") + assert.NotEmpty(t, ns.origins) + + err = ns.Leave() + assert.NoError(t, err, "error leaving namespace") + assert.Empty(t, ns.origins) + + mockNamespace.AssertCalled(t, "Get") + mockNamespace.AssertCalled(t, "Set", mock.Anything) +} + +func TestNetworkNamespaceSetInterfaceIP(t *testing.T) { + mockNetlink := &MockNetlink{} + mockNamespace := &MockNamespace{} + + ns := NewNetworkNamespace("test") + ns.nethandler = mockNetlink + ns.nshandler = mockNamespace + + mockNetlink.On("ParseAddr", "10.0.0.1").Return(&netlink.Addr{}, nil) + mockNetlink.On("LinkByName", "test-in").Return(&netlink.Device{}, nil) + mockNetlink.On("AddrAdd", mock.Anything, mock.Anything).Return(nil) + + mockNamespace.On("Get").Return(netns.NsHandle(0), nil) + mockNamespace.On("Set", mock.Anything).Return(nil) + + err := ns.SetInterfaceIP("test-in", "10.0.0.1") + assert.NoError(t, err, "error setting interface ip") + + mockNetlink.AssertCalled(t, "ParseAddr", "10.0.0.1") + mockNetlink.AssertCalled(t, "LinkByName", "test-in") + mockNetlink.AssertCalled(t, "AddrAdd", mock.Anything, mock.Anything) + mockNamespace.AssertCalled(t, "Get") + mockNamespace.AssertCalled(t, "Set", mock.Anything) +} + +func TestNetworkNamespaceBringInterfaceUp(t *testing.T) { + mockNetlink := &MockNetlink{} + mockNamespace := &MockNamespace{} + + ns := NewNetworkNamespace("test") + ns.nethandler = mockNetlink + ns.nshandler = mockNamespace + + mockNetlink.On("LinkByName", "test-in").Return(&netlink.Device{}, nil) + mockNetlink.On("LinkSetUp", mock.Anything).Return(nil) + + fd, err := os.CreateTemp("", "test-in") + assert.NoError(t, err, "error creating temporary file") + defer os.Remove(fd.Name()) + + mockNamespace.On("Get").Return(netns.NsHandle(fd.Fd()), nil) + mockNamespace.On("Set", mock.Anything).Return(nil) + + err = ns.BringInterfaceUp("test-in") + assert.NoError(t, err, "error bringing interface up") + + mockNetlink.AssertCalled(t, "LinkByName", "test-in") + mockNetlink.AssertCalled(t, "LinkSetUp", mock.Anything) + mockNamespace.AssertCalled(t, "Get") + mockNamespace.AssertCalled(t, "Set", mock.Anything) +} + +func TestNetworkManagerSetDefaultGateway(t *testing.T) { + mockNetlink := &MockNetlink{} + mockNamespace := &MockNamespace{} + + ns := NewNetworkNamespace("test") + ns.nethandler = mockNetlink + ns.nshandler = mockNamespace + + fd, err := os.CreateTemp("", "test-in") + assert.NoError(t, err, "error creating temporary file") + defer os.Remove(fd.Name()) + + mockNetlink.On("RouteAdd", mock.Anything).Return(nil) + mockNamespace.On("Get").Return(netns.NsHandle(fd.Fd()), nil) + mockNamespace.On("Set", mock.Anything).Return(nil) + + err = ns.SetDefaultGateway("10.0.0.1") + assert.NoError(t, err, "error setting default gateway") + + mockNetlink.AssertCalled(t, "RouteAdd", mock.Anything) + mockNamespace.AssertCalled(t, "Get") + mockNamespace.AssertCalled(t, "Set", mock.Anything) +} + +func TestNetworkNamespaceRun(t *testing.T) { + mockNamespace := &MockNamespace{} + + ns := NewNetworkNamespace("test") + ns.nshandler = mockNamespace + + fd, err := os.CreateTemp("", "test-in") + assert.NoError(t, err, "error creating temporary file") + defer os.Remove(fd.Name()) + + mockNamespace.On("Get").Return(netns.NsHandle(fd.Fd()), nil) + mockNamespace.On("Set", mock.Anything).Return(nil) + + err = ns.Run(func() error { return nil }) + assert.NoError(t, err, "error running function") + + err = ns.Run(func() error { return fmt.Errorf("test error") }) + assert.Error(t, err, "error running function") + + mockNamespace.AssertCalled(t, "Get") + mockNamespace.AssertCalled(t, "Set", mock.Anything) +} + +func TestNetworkNamespaceSetup(t *testing.T) { + // succeeds to create the namespace. + mockNamespace := &MockNamespace{} + + ns := NewNetworkNamespace("test") + ns.nshandler = mockNamespace + + fd, err := os.CreateTemp("", "test-in") + assert.NoError(t, err, "error creating temporary file") + defer os.Remove(fd.Name()) + + fd2, err := os.CreateTemp("", "test-in") + assert.NoError(t, err, "error creating temporary file") + defer os.Remove(fd2.Name()) + + mockNamespace.On("NewNamed", "test").Return(netns.NsHandle(fd2.Fd()), nil) + mockNamespace.On("Get").Return(netns.NsHandle(fd.Fd()), nil) + mockNamespace.On("Set", mock.Anything).Return(nil) + + err = ns.Setup() + assert.NoError(t, err, "error setting up namespace") + + mockNamespace.AssertCalled(t, "NewNamed", "test") + mockNamespace.AssertCalled(t, "Get") + mockNamespace.AssertCalled(t, "Set", mock.Anything) + + // os fails to create the namespace. + mockNamespace = &MockNamespace{} + ns = NewNetworkNamespace("test") + ns.nshandler = mockNamespace + + mockNamespace.On("NewNamed", "test").Return(netns.NsHandle(0), fmt.Errorf("test error")) + mockNamespace.On("Get").Return(netns.NsHandle(fd.Fd()), nil) + mockNamespace.On("Set", mock.Anything).Return(nil) + + err = ns.Setup() + assert.Error(t, err, "expected error setting up namespace") + + mockNamespace.AssertCalled(t, "NewNamed", "test") + + // fail to open the default namespace. + mockNamespace = &MockNamespace{} + ns = NewNetworkNamespace("test") + ns.nshandler = mockNamespace + + mockNamespace.On("Get").Return(netns.NsHandle(0), fmt.Errorf("test error")) + + err = ns.Setup() + assert.Error(t, err, "expected error setting up namespace") + + mockNamespace.AssertCalled(t, "Get") +} diff --git a/pkg/namespaces/options.go b/pkg/namespaces/options.go new file mode 100644 index 000000000..a1e5730fa --- /dev/null +++ b/pkg/namespaces/options.go @@ -0,0 +1,51 @@ +package namespaces + +import "time" + +// Option is a function that sets an optional configuration. +type Option func(*Configuration) + +// Configuration holds the runtime configuration for this package. +type Configuration struct { + // Logf is a function that will be used to log messages. If not + // provided the default logger will be used. + Logf func(string, ...interface{}) + // Port is the port to use for the UDP and TCP pings. + Port int + // Timeout is the timeout for the UDP and TCP connection to finish. + Timeout time.Duration +} + +// NewConfiguration creates a new configuration with the provided options. +func NewConfiguration(options ...Option) Configuration { + cfg := Configuration{ + Logf: func(string, ...interface{}) {}, + Port: 8080, + Timeout: 5 * time.Second, + } + for _, o := range options { + o(&cfg) + } + return cfg +} + +// WithLogf sets the log function for this package. +func WithLogf(f func(string, ...interface{})) Option { + return func(c *Configuration) { + c.Logf = f + } +} + +// WithPort sets the port to use for the UDP and TCP pings. +func WithPort(port int) Option { + return func(c *Configuration) { + c.Port = port + } +} + +// WithTimeout sets the timeout for the UDP and TCP connections. +func WithTimeout(timeout time.Duration) Option { + return func(c *Configuration) { + c.Timeout = timeout + } +} diff --git a/schemas/analyzer-troubleshoot-v1beta2.json b/schemas/analyzer-troubleshoot-v1beta2.json index 06fc51e1f..f6bd0768e 100644 --- a/schemas/analyzer-troubleshoot-v1beta2.json +++ b/schemas/analyzer-troubleshoot-v1beta2.json @@ -3900,6 +3900,82 @@ } } }, + "networkNamespaceConnectivity": { + "type": "object", + "required": [ + "outcomes" + ], + "properties": { + "annotations": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "checkName": { + "type": "string" + }, + "collectorName": { + "type": "string" + }, + "exclude": { + "oneOf": [{"type": "string"},{"type": "boolean"}] + }, + "outcomes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "fail": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "uri": { + "type": "string" + }, + "when": { + "type": "string" + } + } + }, + "pass": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "uri": { + "type": "string" + }, + "when": { + "type": "string" + } + } + }, + "warn": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "uri": { + "type": "string" + }, + "when": { + "type": "string" + } + } + } + } + } + }, + "strict": { + "oneOf": [{"type": "string"},{"type": "boolean"}] + } + } + }, "subnetAvailable": { "type": "object", "required": [ diff --git a/schemas/collector-troubleshoot-v1beta2.json b/schemas/collector-troubleshoot-v1beta2.json index d54ff65a6..200c262b0 100644 --- a/schemas/collector-troubleshoot-v1beta2.json +++ b/schemas/collector-troubleshoot-v1beta2.json @@ -15024,6 +15024,34 @@ } } }, + "networkNamespaceConnectivity": { + "type": "object", + "required": [ + "fromCIDR", + "port", + "toCIDR" + ], + "properties": { + "collectorName": { + "type": "string" + }, + "exclude": { + "oneOf": [{"type": "string"},{"type": "boolean"}] + }, + "fromCIDR": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "timeout": { + "type": "string" + }, + "toCIDR": { + "type": "string" + } + } + }, "run": { "type": "object", "required": [ diff --git a/schemas/supportbundle-troubleshoot-v1beta2.json b/schemas/supportbundle-troubleshoot-v1beta2.json index 2c1ac4ff7..9d805017e 100644 --- a/schemas/supportbundle-troubleshoot-v1beta2.json +++ b/schemas/supportbundle-troubleshoot-v1beta2.json @@ -18355,6 +18355,82 @@ } } }, + "networkNamespaceConnectivity": { + "type": "object", + "required": [ + "outcomes" + ], + "properties": { + "annotations": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "checkName": { + "type": "string" + }, + "collectorName": { + "type": "string" + }, + "exclude": { + "oneOf": [{"type": "string"},{"type": "boolean"}] + }, + "outcomes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "fail": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "uri": { + "type": "string" + }, + "when": { + "type": "string" + } + } + }, + "pass": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "uri": { + "type": "string" + }, + "when": { + "type": "string" + } + } + }, + "warn": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "uri": { + "type": "string" + }, + "when": { + "type": "string" + } + } + } + } + } + }, + "strict": { + "oneOf": [{"type": "string"},{"type": "boolean"}] + } + } + }, "subnetAvailable": { "type": "object", "required": [ @@ -19536,6 +19612,34 @@ } } }, + "networkNamespaceConnectivity": { + "type": "object", + "required": [ + "fromCIDR", + "port", + "toCIDR" + ], + "properties": { + "collectorName": { + "type": "string" + }, + "exclude": { + "oneOf": [{"type": "string"},{"type": "boolean"}] + }, + "fromCIDR": { + "type": "string" + }, + "port": { + "type": "integer" + }, + "timeout": { + "type": "string" + }, + "toCIDR": { + "type": "string" + } + } + }, "run": { "type": "object", "required": [