quorate/services/oci-l4-relay/main_test.go

329 lines
11 KiB
Go

package main
import (
"context"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/google/nftables"
"github.com/google/nftables/expr"
discoveryv1 "k8s.io/api/discovery/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes/fake"
)
type roundTripperFunc func(*http.Request) (*http.Response, error)
func (function roundTripperFunc) RoundTrip(request *http.Request) (*http.Response, error) {
return function(request)
}
func TestRepeatElectionAfterTermFailure(t *testing.T) {
ctx, stop := context.WithCancel(context.Background())
var attempts atomic.Int32
repeatElection(ctx, time.Millisecond, 10*time.Millisecond, func(_ context.Context, cancelElection context.CancelFunc) bool {
if attempts.Add(1) == 1 {
cancelElection()
return false
}
stop()
return false
})
if attempts.Load() != 2 {
t.Fatalf("expected two election attempts, got %d", attempts.Load())
}
}
func TestElectionRetryDelay(t *testing.T) {
base := 5 * time.Second
maximum := 60 * time.Second
for unhealthyTerms, expected := range map[int]time.Duration{
0: base,
1: base,
2: 10 * time.Second,
3: 20 * time.Second,
4: 40 * time.Second,
5: maximum,
20: maximum,
} {
if actual := electionRetryDelay(base, maximum, unhealthyTerms); actual != expected {
t.Fatalf("terms=%d: got %s, want %s", unhealthyTerms, actual, expected)
}
}
}
func TestParseCIDRPreservesHostAddress(t *testing.T) {
address, err := parseCIDR("10.153.238.225/30")
if err != nil {
t.Fatal(err)
}
if got := address.String(); got != "10.153.238.225/30" {
t.Fatalf("got %q", got)
}
}
func TestParseMetricsTarget(t *testing.T) {
tunnel, err := parseCIDR("10.153.238.225/30")
if err != nil {
t.Fatal(err)
}
target, err := parseMetricsTarget("http://10.40.68.115:19100/metrics", tunnel)
if err != nil {
t.Fatal(err)
}
if target.String() != "http://10.40.68.115:19100/metrics" {
t.Fatalf("unexpected target %q", target)
}
for _, value := range []string{
"https://10.40.68.115:19100/metrics",
"http://10.153.238.225:19100/metrics",
"http://10.40.68.115:19100/other",
"http://10.40.68.115:19100/metrics?query=true",
"http://192.0.2.1:19100/metrics",
} {
if _, err := parseMetricsTarget(value, tunnel); err == nil {
t.Fatalf("expected %q to fail", value)
}
}
}
func TestMetricsHandlerRequiresActiveOwner(t *testing.T) {
target, err := url.Parse("http://10.40.68.115:19100/metrics")
if err != nil {
t.Fatal(err)
}
client := &http.Client{Transport: roundTripperFunc(func(request *http.Request) (*http.Response, error) {
if request.URL.String() != target.String() {
t.Fatalf("unexpected upstream request %q", request.URL)
}
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/plain; version=0.0.4"}},
Body: io.NopCloser(strings.NewReader("relay_metric 1\n")),
}, nil
})}
instance := &relay{config: config{metricsTarget: target}, metricsClient: client}
inactive := httptest.NewRecorder()
instance.metricsHandler().ServeHTTP(inactive, httptest.NewRequest(http.MethodGet, "/metrics", nil))
if inactive.Code != http.StatusServiceUnavailable {
t.Fatalf("inactive status = %d", inactive.Code)
}
instance.active.Store(true)
active := httptest.NewRecorder()
instance.metricsHandler().ServeHTTP(active, httptest.NewRequest(http.MethodGet, "/metrics", nil))
if active.Code != http.StatusOK || active.Body.String() != "relay_metric 1\n" || active.Header().Get("Content-Type") != "text/plain; version=0.0.4" {
t.Fatalf("unexpected active response: status=%d content-type=%q body=%q", active.Code, active.Header().Get("Content-Type"), active.Body.String())
}
}
func TestMetricsEndpointFollowsOwner(t *testing.T) {
client := fake.NewSimpleClientset(&discoveryv1.EndpointSlice{
ObjectMeta: metav1.ObjectMeta{Name: "oci-l4-relay-metrics", Namespace: "kube-system"},
AddressType: discoveryv1.AddressTypeIPv4,
})
owner := &relay{
client: client,
config: config{
namespace: "kube-system",
podName: "relay-owner",
podUID: "owner-uid",
podIP: "10.167.40.23",
podNodeName: "node-a",
metricsSliceName: "oci-l4-relay-metrics",
},
}
if err := owner.publishMetricsEndpoint(context.Background()); err != nil {
t.Fatal(err)
}
slice, err := client.DiscoveryV1().EndpointSlices("kube-system").Get(context.Background(), "oci-l4-relay-metrics", metav1.GetOptions{})
if err != nil {
t.Fatal(err)
}
if !owner.metricsEndpointMatches(slice) {
t.Fatalf("endpoint does not match owner: %#v", slice.Endpoints)
}
standby := &relay{client: client, config: config{namespace: "kube-system", podUID: "standby-uid", metricsSliceName: "oci-l4-relay-metrics"}}
if err := standby.clearMetricsEndpoint(context.Background()); err != nil {
t.Fatal(err)
}
slice, err = client.DiscoveryV1().EndpointSlices("kube-system").Get(context.Background(), "oci-l4-relay-metrics", metav1.GetOptions{})
if err != nil {
t.Fatal(err)
}
if len(slice.Endpoints) != 1 {
t.Fatal("standby cleared the active owner's endpoint")
}
if err := owner.clearMetricsEndpoint(context.Background()); err != nil {
t.Fatal(err)
}
slice, err = client.DiscoveryV1().EndpointSlices("kube-system").Get(context.Background(), "oci-l4-relay-metrics", metav1.GetOptions{})
if err != nil {
t.Fatal(err)
}
if len(slice.Endpoints) != 0 {
t.Fatal("owner endpoint was not cleared")
}
}
func TestAllowedRuleContainsGatewayAndPort(t *testing.T) {
rule := allowedRule(net.ParseIP("10.104.40.127"), 443)
if len(rule) != 12 {
t.Fatalf("got %d expressions", len(rule))
}
}
func TestMetricsReturnRuleIsBoundedToEstablishedPeerTraffic(t *testing.T) {
rule := metricsReturnRule(net.ParseIP("10.153.238.225"), net.ParseIP("10.40.68.115"), 19100)
if len(rule) != 16 {
t.Fatalf("got %d expressions", len(rule))
}
comparison, ok := rule[14].(*expr.Cmp)
if !ok || comparison.Op != expr.CmpOpNeq {
t.Fatal("metrics return rule does not require a matching conntrack state")
}
}
func TestParseRelayTargets(t *testing.T) {
targets, err := parseRelayTargets(`[{"address":"10.66.153.202","ports":[993,25,465]},{"address":"10.104.40.127","ports":[443,80]}]`)
if err != nil {
t.Fatal(err)
}
if len(targets) != 2 ||
targets[0].address.String() != "10.104.40.127" ||
targets[1].address.String() != "10.66.153.202" ||
targets[1].ports[0] != 25 ||
targets[1].ports[2] != 993 {
t.Fatalf("unexpected normalized targets: %#v", targets)
}
}
func TestParseRelayTargetsRejectsAmbiguousMappings(t *testing.T) {
for name, value := range map[string]string{
"unknown field": `[{"address":"10.104.40.127","ports":[80],"name":"web"}]`,
"duplicate port": `[{"address":"10.104.40.127","ports":[80]},{"address":"10.66.153.202","ports":[80]}]`,
"unknown port": `[{"address":"10.104.40.127","ports":[22]}]`,
"submission": `[{"address":"10.66.153.202","ports":[587]}]`,
"duplicate IP": `[{"address":"10.104.40.127","ports":[80]},{"address":"10.104.40.127","ports":[443]}]`,
"IPv6": `[{"address":"2001:db8::1","ports":[443]}]`,
"trailing": `[{"address":"10.104.40.127","ports":[80]}] true`,
} {
t.Run(name, func(t *testing.T) {
if _, err := parseRelayTargets(value); err == nil {
t.Fatal("expected target validation failure")
}
})
}
}
func TestParseNetworks(t *testing.T) {
networks, err := parseNetworks("10.63.81.107/24, 10.162.8.60/24")
if err != nil {
t.Fatal(err)
}
if len(networks) != 2 || networks[0].String() != "10.63.81.107/24" || networks[1].String() != "10.162.8.60/24" {
t.Fatalf("unexpected networks: %v", networks)
}
}
func TestFirewallCountersFromRules(t *testing.T) {
rule := func(packets uint64) *nftables.Rule {
return &nftables.Rule{Exprs: []expr.Any{&expr.Counter{Packets: packets}}}
}
targets := []relayTarget{{address: net.ParseIP("10.104.40.127").To4(), ports: []uint16{80, 443}}}
counters, err := firewallCountersFromRules(map[string][]*nftables.Rule{
"prerouting": {{}, rule(1), rule(2), rule(3)},
"output": {rule(4), rule(5)},
}, targets)
if err != nil {
t.Fatal(err)
}
if counters.tunnelAllowed["10.104.40.127:80"] != 1 || counters.tunnelAllowed["10.104.40.127:443"] != 2 || counters.tunnelDenied != 3 || counters.outerAllowed != 4 || counters.outerDenied != 5 {
t.Fatalf("unexpected counters: %+v", counters)
}
}
func TestFirewallCountersRequireEveryRuleCounter(t *testing.T) {
withCounter := &nftables.Rule{Exprs: []expr.Any{&expr.Counter{}}}
withoutCounter := &nftables.Rule{}
_, err := firewallCountersFromRules(map[string][]*nftables.Rule{
"prerouting": {{}, withCounter, withoutCounter, withCounter},
"output": {withCounter, withCounter},
}, []relayTarget{{address: net.ParseIP("10.104.40.127").To4(), ports: []uint16{80, 443}}})
if err == nil {
t.Fatal("expected missing counter error")
}
}
func TestCheckHealth(t *testing.T) {
path := filepath.Join(t.TempDir(), "health")
if err := writeHealth(path, true); err != nil {
t.Fatal(err)
}
if err := checkHealth(path, time.Minute); err != nil {
t.Fatal(err)
}
if err := writeHealth(path, false); err != nil {
t.Fatal(err)
}
if err := checkHealth(path, time.Minute); err == nil || err.Error() != "relay reports unhealthy" {
t.Fatalf("expected unhealthy state failure, got %v", err)
}
if err := writeHealth(path, true); err != nil {
t.Fatal(err)
}
old := time.Now().Add(-time.Hour)
if err := os.Chtimes(path, old, old); err != nil {
t.Fatal(err)
}
if err := checkHealth(path, time.Minute); err == nil {
t.Fatal("expected stale health failure")
}
}
func TestVerifyReversePathFilterAcceptsEffectiveLooseMode(t *testing.T) {
for name, values := range map[string]map[string]string{
"interface": {"all": "0\n", interfaceName: "2\n"},
"all": {"all": "2\n", interfaceName: "1\n"},
} {
t.Run(name, func(t *testing.T) {
root := t.TempDir()
for directoryName, value := range values {
directory := filepath.Join(root, directoryName)
if err := os.MkdirAll(directory, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(directory, "rp_filter"), []byte(value), 0644); err != nil {
t.Fatal(err)
}
}
if err := verifyReversePathFilter(root, interfaceName); err != nil {
t.Fatal(err)
}
})
}
}
func TestVerifyReversePathFilterRejectsStrictMode(t *testing.T) {
root := t.TempDir()
for _, name := range []string{"all", interfaceName} {
directory := filepath.Join(root, name)
if err := os.MkdirAll(directory, 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(directory, "rp_filter"), []byte("1\n"), 0644); err != nil {
t.Fatal(err)
}
}
if err := verifyReversePathFilter(root, interfaceName); err == nil {
t.Fatal("expected strict reverse-path filtering to fail")
}
}