1210 lines
40 KiB
Go
1210 lines
40 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/google/nftables"
|
|
"github.com/google/nftables/binaryutil"
|
|
"github.com/google/nftables/expr"
|
|
"github.com/vishvananda/netlink"
|
|
"golang.org/x/sys/unix"
|
|
"golang.zx2c4.com/wireguard/wgctrl"
|
|
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
|
corev1 "k8s.io/api/core/v1"
|
|
discoveryv1 "k8s.io/api/discovery/v1"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/types"
|
|
"k8s.io/client-go/kubernetes"
|
|
"k8s.io/client-go/rest"
|
|
"k8s.io/client-go/tools/leaderelection"
|
|
"k8s.io/client-go/tools/leaderelection/resourcelock"
|
|
"k8s.io/client-go/util/retry"
|
|
)
|
|
|
|
const (
|
|
interfaceName = "wg-oci0"
|
|
routingTable = 51820
|
|
rulePriority = 100
|
|
localPriority = 90
|
|
firewallTable = "oci_l4_relay_home"
|
|
rpFilterRoot = "/proc/sys/net/ipv4/conf"
|
|
mailConnectionLimit = 1024
|
|
)
|
|
|
|
type config struct {
|
|
namespace string
|
|
podName string
|
|
podUID string
|
|
podIP string
|
|
podNodeName string
|
|
leaseName string
|
|
privateKeyPath string
|
|
peerPublicKey string
|
|
peerEndpoint string
|
|
listenPort int
|
|
tunnelAddress *net.IPNet
|
|
targets []relayTarget
|
|
healthPath string
|
|
metricsListen string
|
|
metricsTarget *url.URL
|
|
metricsSliceName string
|
|
endpointAddress net.IP
|
|
localReturnCIDRs []*net.IPNet
|
|
leaseDuration time.Duration
|
|
renewDeadline time.Duration
|
|
retryPeriod time.Duration
|
|
maxRetryPeriod time.Duration
|
|
handshakeMaxAge time.Duration
|
|
mailTarget *relayTarget
|
|
mailUpstream string
|
|
}
|
|
|
|
type relayTarget struct {
|
|
address net.IP
|
|
ports []uint16
|
|
}
|
|
|
|
type relayTargetDocument struct {
|
|
Address string `json:"address"`
|
|
Ports []int `json:"ports"`
|
|
}
|
|
|
|
type relay struct {
|
|
config config
|
|
client kubernetes.Interface
|
|
metricsClient *http.Client
|
|
active atomic.Bool
|
|
unhealthy atomic.Bool
|
|
stop context.CancelFunc
|
|
fence *nftables.Set
|
|
proxies []*mailProxy
|
|
proxyFailures chan error
|
|
cleanupMu sync.Mutex
|
|
}
|
|
|
|
func main() {
|
|
if len(os.Args) == 2 && os.Args[1] == "health" {
|
|
path := envOrDefault("RELAY_HEALTH_PATH", "/run/relay-agent/health")
|
|
if err := checkHealth(path, 20*time.Second); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
return
|
|
}
|
|
|
|
configuration, err := loadConfig()
|
|
if err != nil {
|
|
slog.Error("invalid configuration", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
if len(os.Args) == 2 && os.Args[1] == "status" {
|
|
if err := (&relay{config: configuration}).verify(); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
counters, err := readFirewallCounters(configuration.targets)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
allowed, err := json.Marshal(counters.tunnelAllowed)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Printf("network_state=ready tunnel_allowed_packets=%s tunnel_denied_packets=%d outer_allowed_packets=%d outer_denied_packets=%d\n",
|
|
allowed,
|
|
counters.tunnelDenied,
|
|
counters.outerAllowed,
|
|
counters.outerDenied,
|
|
)
|
|
return
|
|
}
|
|
clientConfig, err := rest.InClusterConfig()
|
|
if err != nil {
|
|
slog.Error("load Kubernetes client configuration", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
client, err := kubernetes.NewForConfig(clientConfig)
|
|
if err != nil {
|
|
slog.Error("create Kubernetes client", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
signalContext, stopSignals := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
defer stopSignals()
|
|
instance := &relay{config: configuration, client: client, proxyFailures: make(chan error, 1)}
|
|
listener, err := net.Listen("tcp4", configuration.metricsListen)
|
|
if err != nil {
|
|
slog.Error("listen for relay metrics", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
metricsServer := &http.Server{
|
|
Handler: instance.metricsHandler(),
|
|
ReadHeaderTimeout: 5 * time.Second,
|
|
WriteTimeout: 10 * time.Second,
|
|
IdleTimeout: 30 * time.Second,
|
|
}
|
|
go func() {
|
|
if err := metricsServer.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
slog.Error("serve relay metrics", "error", err)
|
|
stopSignals()
|
|
}
|
|
}()
|
|
defer func() {
|
|
shutdownContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := metricsServer.Shutdown(shutdownContext); err != nil {
|
|
slog.Error("shut down relay metrics", "error", err)
|
|
}
|
|
}()
|
|
if err := instance.cleanup(); err != nil {
|
|
slog.Error("reconcile stale local relay state", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
go instance.heartbeat(signalContext)
|
|
|
|
repeatElection(signalContext, configuration.retryPeriod, configuration.maxRetryPeriod, func(electionContext context.Context, cancelElection context.CancelFunc) bool {
|
|
instance.unhealthy.Store(false)
|
|
instance.stop = cancelElection
|
|
lock := &resourcelock.LeaseLock{
|
|
LeaseMeta: metav1.ObjectMeta{Name: configuration.leaseName, Namespace: configuration.namespace},
|
|
Client: client.CoordinationV1(),
|
|
LockConfig: resourcelock.ResourceLockConfig{
|
|
Identity: configuration.podName + "_" + configuration.podUID,
|
|
},
|
|
}
|
|
leaderelection.RunOrDie(electionContext, leaderelection.LeaderElectionConfig{
|
|
Lock: lock,
|
|
LeaseDuration: configuration.leaseDuration,
|
|
RenewDeadline: configuration.renewDeadline,
|
|
RetryPeriod: configuration.retryPeriod,
|
|
ReleaseOnCancel: true,
|
|
Name: configuration.leaseName,
|
|
Callbacks: leaderelection.LeaderCallbacks{
|
|
OnStartedLeading: instance.runActive,
|
|
OnStoppedLeading: func() {
|
|
instance.stopActive()
|
|
slog.Info("relay ownership released")
|
|
},
|
|
OnNewLeader: func(identity string) {
|
|
if identity != configuration.podName+"_"+configuration.podUID {
|
|
slog.Info("standby relay observed active owner", "identity", identity)
|
|
}
|
|
},
|
|
},
|
|
})
|
|
return instance.unhealthy.Load()
|
|
})
|
|
}
|
|
|
|
func repeatElection(ctx context.Context, retryPeriod, maxRetryPeriod time.Duration, run func(context.Context, context.CancelFunc) bool) {
|
|
unhealthyTerms := 0
|
|
for ctx.Err() == nil {
|
|
electionContext, cancelElection := context.WithCancel(ctx)
|
|
unhealthy := run(electionContext, cancelElection)
|
|
cancelElection()
|
|
if ctx.Err() != nil {
|
|
return
|
|
}
|
|
if unhealthy {
|
|
unhealthyTerms++
|
|
} else {
|
|
unhealthyTerms = 0
|
|
}
|
|
delay := electionRetryDelay(retryPeriod, maxRetryPeriod, unhealthyTerms)
|
|
slog.Info("retry relay election", "delay", delay, "consecutive_unhealthy_terms", unhealthyTerms)
|
|
timer := time.NewTimer(delay)
|
|
select {
|
|
case <-ctx.Done():
|
|
timer.Stop()
|
|
case <-timer.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
func electionRetryDelay(retryPeriod, maxRetryPeriod time.Duration, unhealthyTerms int) time.Duration {
|
|
delay := retryPeriod
|
|
for term := 1; term < unhealthyTerms && delay < maxRetryPeriod; term++ {
|
|
if delay > maxRetryPeriod/2 {
|
|
return maxRetryPeriod
|
|
}
|
|
delay *= 2
|
|
}
|
|
return min(delay, maxRetryPeriod)
|
|
}
|
|
|
|
func loadConfig() (config, error) {
|
|
tunnelAddress, err := parseCIDR(requiredEnv("RELAY_TUNNEL_ADDRESS"))
|
|
if err != nil {
|
|
return config{}, fmt.Errorf("RELAY_TUNNEL_ADDRESS: %w", err)
|
|
}
|
|
targets, err := parseRelayTargets(requiredEnv("RELAY_TARGETS"))
|
|
if err != nil {
|
|
return config{}, fmt.Errorf("RELAY_TARGETS: %w", err)
|
|
}
|
|
listenPort, err := strconv.Atoi(requiredEnv("OCI_RELAY_LISTEN_PORT"))
|
|
if err != nil || listenPort < 1 || listenPort > 65535 {
|
|
return config{}, errors.New("OCI_RELAY_LISTEN_PORT must be a valid port")
|
|
}
|
|
endpointAddress := net.ParseIP(requiredEnv("OCI_RELAY_ENDPOINT_IP")).To4()
|
|
if endpointAddress == nil {
|
|
return config{}, errors.New("OCI_RELAY_ENDPOINT_IP must be an IPv4 address")
|
|
}
|
|
localReturnCIDRs, err := parseNetworks(requiredEnv("RELAY_LOCAL_RETURN_CIDRS"))
|
|
if err != nil {
|
|
return config{}, fmt.Errorf("RELAY_LOCAL_RETURN_CIDRS: %w", err)
|
|
}
|
|
if len(localReturnCIDRs) > rulePriority-localPriority {
|
|
return config{}, fmt.Errorf("RELAY_LOCAL_RETURN_CIDRS supports at most %d networks", rulePriority-localPriority)
|
|
}
|
|
configuration := config{
|
|
namespace: requiredEnv("POD_NAMESPACE"),
|
|
podName: requiredEnv("POD_NAME"),
|
|
podUID: requiredEnv("POD_UID"),
|
|
podIP: requiredEnv("POD_IP"),
|
|
podNodeName: requiredEnv("POD_NODE_NAME"),
|
|
leaseName: envOrDefault("RELAY_LEASE_NAME", "oci-l4-relay"),
|
|
privateKeyPath: envOrDefault("RELAY_PRIVATE_KEY_PATH", "/run/secrets/wireguard/private_key"),
|
|
peerPublicKey: requiredEnv("OCI_RELAY_PUBLIC_KEY"),
|
|
peerEndpoint: net.JoinHostPort(requiredEnv("OCI_RELAY_ENDPOINT_IP"), strconv.Itoa(listenPort)),
|
|
listenPort: listenPort,
|
|
tunnelAddress: tunnelAddress,
|
|
targets: targets,
|
|
healthPath: envOrDefault("RELAY_HEALTH_PATH", "/run/relay-agent/health"),
|
|
metricsListen: envOrDefault("RELAY_METRICS_LISTEN_ADDRESS", ":19100"),
|
|
metricsSliceName: envOrDefault("RELAY_METRICS_ENDPOINT_SLICE", "oci-l4-relay-metrics"),
|
|
endpointAddress: endpointAddress,
|
|
localReturnCIDRs: localReturnCIDRs,
|
|
leaseDuration: 30 * time.Second,
|
|
renewDeadline: 20 * time.Second,
|
|
retryPeriod: 5 * time.Second,
|
|
maxRetryPeriod: 60 * time.Second,
|
|
handshakeMaxAge: 180 * time.Second,
|
|
}
|
|
metricsTarget, err := parseMetricsTarget(requiredEnv("OCI_RELAY_METRICS_URL"), tunnelAddress)
|
|
if err != nil {
|
|
return config{}, fmt.Errorf("OCI_RELAY_METRICS_URL: %w", err)
|
|
}
|
|
configuration.metricsTarget = metricsTarget
|
|
mailTarget, mailUpstream, err := mailProxyTarget(requiredEnv("RELAY_MAIL_UPSTREAM"), tunnelAddress)
|
|
if err != nil {
|
|
return config{}, err
|
|
}
|
|
if mailTarget != nil {
|
|
configuration.targets = append(configuration.targets, *mailTarget)
|
|
sort.Slice(configuration.targets, func(left, right int) bool {
|
|
return bytes.Compare(configuration.targets[left].address, configuration.targets[right].address) < 0
|
|
})
|
|
}
|
|
configuration.mailTarget = mailTarget
|
|
configuration.mailUpstream = mailUpstream
|
|
if address := net.ParseIP(configuration.podIP).To4(); address == nil || address.String() != configuration.podIP {
|
|
return config{}, errors.New("POD_IP must be a canonical IPv4 address")
|
|
}
|
|
if configuration.podNodeName == "" {
|
|
return config{}, errors.New("POD_NODE_NAME must not be empty")
|
|
}
|
|
if _, err := wgtypes.ParseKey(configuration.peerPublicKey); err != nil {
|
|
return config{}, fmt.Errorf("OCI_RELAY_PUBLIC_KEY: %w", err)
|
|
}
|
|
return configuration, nil
|
|
}
|
|
|
|
func mailProxyTarget(upstream string, tunnelAddress *net.IPNet) (*relayTarget, string, error) {
|
|
if upstream == "" {
|
|
return nil, "", nil
|
|
}
|
|
if strings.Contains(upstream, ":") {
|
|
return nil, "", errors.New("RELAY_MAIL_UPSTREAM must be a DNS hostname")
|
|
}
|
|
return &relayTarget{address: tunnelAddress.IP, ports: []uint16{25, 465, 993}}, upstream, nil
|
|
}
|
|
|
|
func parseMetricsTarget(value string, tunnelAddress *net.IPNet) (*url.URL, error) {
|
|
target, err := url.Parse(value)
|
|
if err != nil || target.Scheme != "http" || target.Path != "/metrics" || target.RawQuery != "" || target.Fragment != "" || target.User != nil {
|
|
return nil, errors.New("must be an HTTP /metrics URL without credentials, query, or fragment")
|
|
}
|
|
address := net.ParseIP(target.Hostname()).To4()
|
|
if address == nil || !tunnelAddress.Contains(address) || address.Equal(tunnelAddress.IP) {
|
|
return nil, errors.New("host must be the IPv4 peer in RELAY_TUNNEL_ADDRESS")
|
|
}
|
|
port, err := strconv.Atoi(target.Port())
|
|
if err != nil || port < 1 || port > 65535 {
|
|
return nil, errors.New("port must be valid")
|
|
}
|
|
return target, nil
|
|
}
|
|
|
|
func parseRelayTargets(value string) ([]relayTarget, error) {
|
|
decoder := json.NewDecoder(strings.NewReader(value))
|
|
decoder.DisallowUnknownFields()
|
|
var documents []relayTargetDocument
|
|
if err := decoder.Decode(&documents); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
|
return nil, errors.New("contains trailing data")
|
|
}
|
|
if len(documents) == 0 || len(documents) > 16 {
|
|
return nil, errors.New("must contain between 1 and 16 targets")
|
|
}
|
|
allowedPorts := map[int]bool{25: true, 80: true, 443: true, 465: true, 993: true}
|
|
addresses := make(map[string]bool, len(documents))
|
|
ports := make(map[int]bool)
|
|
targets := make([]relayTarget, 0, len(documents))
|
|
for _, document := range documents {
|
|
address := net.ParseIP(document.Address).To4()
|
|
if address == nil || address.String() != document.Address || addresses[document.Address] {
|
|
return nil, fmt.Errorf("invalid or duplicate target address %q", document.Address)
|
|
}
|
|
if len(document.Ports) == 0 {
|
|
return nil, fmt.Errorf("target %s has no ports", document.Address)
|
|
}
|
|
addresses[document.Address] = true
|
|
targetPorts := make([]uint16, 0, len(document.Ports))
|
|
for _, port := range document.Ports {
|
|
if !allowedPorts[port] || ports[port] {
|
|
return nil, fmt.Errorf("invalid or duplicate target port %d", port)
|
|
}
|
|
ports[port] = true
|
|
targetPorts = append(targetPorts, uint16(port))
|
|
}
|
|
sort.Slice(targetPorts, func(left, right int) bool {
|
|
return targetPorts[left] < targetPorts[right]
|
|
})
|
|
targets = append(targets, relayTarget{address: address, ports: targetPorts})
|
|
}
|
|
sort.Slice(targets, func(left, right int) bool {
|
|
return bytes.Compare(targets[left].address, targets[right].address) < 0
|
|
})
|
|
return targets, nil
|
|
}
|
|
|
|
func requiredEnv(name string) string {
|
|
return strings.TrimSpace(os.Getenv(name))
|
|
}
|
|
|
|
func envOrDefault(name, fallback string) string {
|
|
if value := requiredEnv(name); value != "" {
|
|
return value
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func parseCIDR(value string) (*net.IPNet, error) {
|
|
ip, network, err := net.ParseCIDR(value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ip = ip.To4()
|
|
if ip == nil {
|
|
return nil, errors.New("must be an IPv4 CIDR")
|
|
}
|
|
network.IP = ip
|
|
return network, nil
|
|
}
|
|
|
|
func parseNetworks(value string) ([]*net.IPNet, error) {
|
|
var networks []*net.IPNet
|
|
for item := range strings.SplitSeq(value, ",") {
|
|
_, network, err := net.ParseCIDR(strings.TrimSpace(item))
|
|
if err != nil || network.IP.To4() == nil {
|
|
return nil, fmt.Errorf("%q is not an IPv4 network", item)
|
|
}
|
|
networks = append(networks, network)
|
|
}
|
|
return networks, nil
|
|
}
|
|
|
|
func (r *relay) heartbeat(ctx context.Context) {
|
|
ticker := time.NewTicker(5 * time.Second)
|
|
defer ticker.Stop()
|
|
for {
|
|
if err := writeHealth(r.config.healthPath, !r.unhealthy.Load()); err != nil {
|
|
slog.Error("update health heartbeat", "error", err)
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *relay) metricsHandler() http.Handler {
|
|
client := r.metricsClient
|
|
if client == nil {
|
|
client = &http.Client{
|
|
Timeout: 8 * time.Second,
|
|
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
}
|
|
}
|
|
return http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
|
|
if request.Method != http.MethodGet || request.URL.Path != "/metrics" || request.URL.RawQuery != "" {
|
|
http.NotFound(response, request)
|
|
return
|
|
}
|
|
if !r.active.Load() {
|
|
http.Error(response, "relay owner is inactive", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
upstreamRequest, err := http.NewRequestWithContext(request.Context(), http.MethodGet, r.config.metricsTarget.String(), nil)
|
|
if err != nil {
|
|
http.Error(response, "relay metrics request failed", http.StatusBadGateway)
|
|
return
|
|
}
|
|
upstreamResponse, err := client.Do(upstreamRequest)
|
|
if err != nil {
|
|
http.Error(response, "relay metrics request failed", http.StatusBadGateway)
|
|
return
|
|
}
|
|
defer upstreamResponse.Body.Close()
|
|
body, err := io.ReadAll(io.LimitReader(upstreamResponse.Body, 8*1024*1024+1))
|
|
if err != nil || len(body) > 8*1024*1024 {
|
|
http.Error(response, "relay metrics response failed", http.StatusBadGateway)
|
|
return
|
|
}
|
|
if contentType := upstreamResponse.Header.Get("Content-Type"); contentType != "" {
|
|
response.Header().Set("Content-Type", contentType)
|
|
}
|
|
response.WriteHeader(upstreamResponse.StatusCode)
|
|
_, _ = response.Write(body)
|
|
})
|
|
}
|
|
|
|
func (r *relay) runActive(ctx context.Context) {
|
|
drainFailures(r.proxyFailures)
|
|
if err := r.configure(); err != nil {
|
|
slog.Error("configure active relay", "error", err)
|
|
r.unhealthy.Store(true)
|
|
r.stop()
|
|
return
|
|
}
|
|
if err := r.startProxies(); err != nil {
|
|
slog.Error("start mail proxy", "error", err)
|
|
r.unhealthy.Store(true)
|
|
r.stop()
|
|
return
|
|
}
|
|
r.active.Store(true)
|
|
if err := r.publishMetricsEndpoint(ctx); err != nil {
|
|
slog.Error("publish active relay metrics endpoint", "error", err)
|
|
}
|
|
slog.Info("relay ownership acquired")
|
|
ticker := time.NewTicker(5 * time.Second)
|
|
started := time.Now()
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case err := <-r.proxyFailures:
|
|
slog.Error("mail proxy failed", "error", err)
|
|
r.unhealthy.Store(true)
|
|
r.stop()
|
|
return
|
|
case <-ticker.C:
|
|
if err := r.publishMetricsEndpoint(ctx); err != nil {
|
|
slog.Error("reconcile active relay metrics endpoint", "error", err)
|
|
}
|
|
if err := r.refreshFence(); err != nil {
|
|
slog.Error("refresh relay ownership fence", "error", err)
|
|
r.unhealthy.Store(true)
|
|
r.stop()
|
|
return
|
|
}
|
|
if time.Since(started) < 30*time.Second {
|
|
continue
|
|
}
|
|
if err := r.verify(); err != nil {
|
|
slog.Error("active relay state failed verification", "error", err)
|
|
r.unhealthy.Store(true)
|
|
r.stop()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *relay) stopActive() {
|
|
r.active.Store(false)
|
|
r.stopProxies()
|
|
if err := r.cleanup(); err != nil {
|
|
slog.Error("clean up active relay", "error", err)
|
|
}
|
|
clearContext, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := r.clearMetricsEndpoint(clearContext); err != nil {
|
|
slog.Error("clear active relay metrics endpoint", "error", err)
|
|
}
|
|
}
|
|
|
|
func (r *relay) publishMetricsEndpoint(ctx context.Context) error {
|
|
return retry.RetryOnConflict(retry.DefaultRetry, func() error {
|
|
slices := r.client.DiscoveryV1().EndpointSlices(r.config.namespace)
|
|
slice, err := slices.Get(ctx, r.config.metricsSliceName, metav1.GetOptions{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if r.metricsEndpointMatches(slice) {
|
|
return nil
|
|
}
|
|
ready := true
|
|
serving := true
|
|
terminating := false
|
|
slice.Endpoints = []discoveryv1.Endpoint{{
|
|
Addresses: []string{r.config.podIP},
|
|
Conditions: discoveryv1.EndpointConditions{
|
|
Ready: &ready,
|
|
Serving: &serving,
|
|
Terminating: &terminating,
|
|
},
|
|
NodeName: &r.config.podNodeName,
|
|
TargetRef: &corev1.ObjectReference{
|
|
Kind: "Pod",
|
|
Namespace: r.config.namespace,
|
|
Name: r.config.podName,
|
|
UID: types.UID(r.config.podUID),
|
|
},
|
|
}}
|
|
_, err = slices.Update(ctx, slice, metav1.UpdateOptions{})
|
|
return err
|
|
})
|
|
}
|
|
|
|
func (r *relay) metricsEndpointMatches(slice *discoveryv1.EndpointSlice) bool {
|
|
if len(slice.Endpoints) != 1 {
|
|
return false
|
|
}
|
|
endpoint := slice.Endpoints[0]
|
|
return len(endpoint.Addresses) == 1 && endpoint.Addresses[0] == r.config.podIP &&
|
|
endpoint.NodeName != nil && *endpoint.NodeName == r.config.podNodeName &&
|
|
endpoint.TargetRef != nil && endpoint.TargetRef.UID == types.UID(r.config.podUID) &&
|
|
endpoint.Conditions.Ready != nil && *endpoint.Conditions.Ready &&
|
|
endpoint.Conditions.Serving != nil && *endpoint.Conditions.Serving &&
|
|
endpoint.Conditions.Terminating != nil && !*endpoint.Conditions.Terminating
|
|
}
|
|
|
|
func (r *relay) clearMetricsEndpoint(ctx context.Context) error {
|
|
return retry.RetryOnConflict(retry.DefaultRetry, func() error {
|
|
slices := r.client.DiscoveryV1().EndpointSlices(r.config.namespace)
|
|
slice, err := slices.Get(ctx, r.config.metricsSliceName, metav1.GetOptions{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(slice.Endpoints) != 1 || slice.Endpoints[0].TargetRef == nil || slice.Endpoints[0].TargetRef.UID != types.UID(r.config.podUID) {
|
|
return nil
|
|
}
|
|
slice.Endpoints = nil
|
|
_, err = slices.Update(ctx, slice, metav1.UpdateOptions{})
|
|
return err
|
|
})
|
|
}
|
|
|
|
func (r *relay) configure() error {
|
|
if err := r.cleanup(); err != nil {
|
|
return err
|
|
}
|
|
link := &netlink.GenericLink{LinkAttrs: netlink.LinkAttrs{Name: interfaceName, MTU: 1420}, LinkType: "wireguard"}
|
|
if err := netlink.LinkAdd(link); err != nil {
|
|
return fmt.Errorf("add WireGuard link: %w", err)
|
|
}
|
|
configured := false
|
|
defer func() {
|
|
if !configured {
|
|
_ = r.cleanup()
|
|
}
|
|
}()
|
|
created, err := netlink.LinkByName(interfaceName)
|
|
if err != nil {
|
|
return fmt.Errorf("find WireGuard link: %w", err)
|
|
}
|
|
if err := verifyReversePathFilter(rpFilterRoot, interfaceName); err != nil {
|
|
return err
|
|
}
|
|
privateKeyBytes, err := os.ReadFile(r.config.privateKeyPath)
|
|
if err != nil {
|
|
return fmt.Errorf("read private key: %w", err)
|
|
}
|
|
privateKey, err := wgtypes.ParseKey(strings.TrimSpace(string(privateKeyBytes)))
|
|
if err != nil {
|
|
return fmt.Errorf("parse private key: %w", err)
|
|
}
|
|
peerKey, _ := wgtypes.ParseKey(r.config.peerPublicKey)
|
|
endpoint, err := net.ResolveUDPAddr("udp4", r.config.peerEndpoint)
|
|
if err != nil {
|
|
return fmt.Errorf("resolve peer endpoint: %w", err)
|
|
}
|
|
keepalive := 25 * time.Second
|
|
client, err := wgctrl.New()
|
|
if err != nil {
|
|
return fmt.Errorf("open WireGuard control: %w", err)
|
|
}
|
|
defer func() { _ = client.Close() }()
|
|
if err := client.ConfigureDevice(interfaceName, wgtypes.Config{
|
|
PrivateKey: &privateKey,
|
|
Peers: []wgtypes.PeerConfig{{
|
|
PublicKey: peerKey,
|
|
Endpoint: endpoint,
|
|
PersistentKeepaliveInterval: &keepalive,
|
|
ReplaceAllowedIPs: true,
|
|
AllowedIPs: []net.IPNet{{IP: net.IPv4zero, Mask: net.CIDRMask(0, 32)}},
|
|
}},
|
|
}); err != nil {
|
|
return fmt.Errorf("configure WireGuard device: %w", err)
|
|
}
|
|
if err := netlink.AddrAdd(created, &netlink.Addr{IPNet: r.config.tunnelAddress}); err != nil {
|
|
return fmt.Errorf("add tunnel address: %w", err)
|
|
}
|
|
metricsPort, _ := strconv.Atoi(r.config.metricsTarget.Port())
|
|
fence, err := installFirewall(r.config.targets, r.config.endpointAddress, uint16(r.config.listenPort), r.config.tunnelAddress.IP, net.ParseIP(r.config.metricsTarget.Hostname()), uint16(metricsPort))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
r.fence = fence
|
|
if err := netlink.LinkSetUp(created); err != nil {
|
|
return fmt.Errorf("bring WireGuard link up: %w", err)
|
|
}
|
|
defaultRoute := &net.IPNet{IP: net.IPv4zero, Mask: net.CIDRMask(0, 32)}
|
|
if err := netlink.RouteReplace(&netlink.Route{LinkIndex: created.Attrs().Index, Dst: defaultRoute, Table: routingTable, Scope: netlink.SCOPE_UNIVERSE}); err != nil {
|
|
return fmt.Errorf("install relay default route: %w", err)
|
|
}
|
|
for _, target := range r.config.targets {
|
|
for index, destination := range r.config.localReturnCIDRs {
|
|
rule := netlink.NewRule()
|
|
rule.Priority = localPriority + index
|
|
rule.Table = unix.RT_TABLE_MAIN
|
|
rule.Src = &net.IPNet{IP: target.address, Mask: net.CIDRMask(32, 32)}
|
|
rule.Dst = destination
|
|
if err := netlink.RuleAdd(rule); err != nil {
|
|
return fmt.Errorf("install local return policy rule: %w", err)
|
|
}
|
|
}
|
|
}
|
|
for _, target := range r.config.targets {
|
|
rule := netlink.NewRule()
|
|
rule.Priority = rulePriority
|
|
rule.Table = routingTable
|
|
rule.Src = &net.IPNet{IP: target.address, Mask: net.CIDRMask(32, 32)}
|
|
if err := netlink.RuleAdd(rule); err != nil {
|
|
return fmt.Errorf("install relay policy rule: %w", err)
|
|
}
|
|
}
|
|
configured = true
|
|
return nil
|
|
}
|
|
|
|
func installFirewall(targets []relayTarget, endpointAddress net.IP, endpointPort uint16, tunnelAddress, metricsPeer net.IP, metricsPort uint16) (*nftables.Set, error) {
|
|
connection := &nftables.Conn{}
|
|
tables, err := connection.ListTables()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list nftables tables: %w", err)
|
|
}
|
|
for _, table := range tables {
|
|
if table.Family == nftables.TableFamilyINet && table.Name == firewallTable {
|
|
connection.DelTable(table)
|
|
}
|
|
}
|
|
table := connection.AddTable(&nftables.Table{Family: nftables.TableFamilyINet, Name: firewallTable})
|
|
fence := &nftables.Set{
|
|
Table: table,
|
|
Name: "owner_alive",
|
|
KeyType: nftables.TypeIPAddr,
|
|
HasTimeout: true,
|
|
Timeout: 15 * time.Second,
|
|
}
|
|
if err := connection.AddSet(fence, []nftables.SetElement{{Key: endpointAddress.To4(), Timeout: 15 * time.Second}}); err != nil {
|
|
return nil, fmt.Errorf("create ownership fence: %w", err)
|
|
}
|
|
policy := nftables.ChainPolicyAccept
|
|
chain := connection.AddChain(&nftables.Chain{
|
|
Name: "prerouting",
|
|
Table: table,
|
|
Type: nftables.ChainTypeFilter,
|
|
Hooknum: nftables.ChainHookPrerouting,
|
|
Priority: nftables.ChainPriorityFilter,
|
|
Policy: &policy,
|
|
})
|
|
connection.AddRule(&nftables.Rule{Table: table, Chain: chain, Exprs: metricsReturnRule(tunnelAddress, metricsPeer, metricsPort)})
|
|
for _, target := range targets {
|
|
for _, port := range target.ports {
|
|
connection.AddRule(&nftables.Rule{Table: table, Chain: chain, Exprs: allowedRule(target.address, port)})
|
|
}
|
|
}
|
|
connection.AddRule(&nftables.Rule{Table: table, Chain: chain, Exprs: interfaceVerdict(expr.VerdictDrop)})
|
|
output := connection.AddChain(&nftables.Chain{
|
|
Name: "output",
|
|
Table: table,
|
|
Type: nftables.ChainTypeFilter,
|
|
Hooknum: nftables.ChainHookOutput,
|
|
Priority: nftables.ChainPriorityFilter,
|
|
Policy: &policy,
|
|
})
|
|
connection.AddRule(&nftables.Rule{Table: table, Chain: output, Exprs: fencedOutputRule(endpointAddress, endpointPort, fence)})
|
|
connection.AddRule(&nftables.Rule{Table: table, Chain: output, Exprs: outerVerdictRule(endpointAddress, endpointPort, expr.VerdictDrop)})
|
|
if err := connection.Flush(); err != nil {
|
|
return nil, fmt.Errorf("install relay firewall: %w", err)
|
|
}
|
|
return fence, nil
|
|
}
|
|
|
|
func metricsReturnRule(tunnelAddress, peerAddress net.IP, port uint16) []expr.Any {
|
|
portBytes := make([]byte, 2)
|
|
binary.BigEndian.PutUint16(portBytes, port)
|
|
stateMask := expr.CtStateBitESTABLISHED | expr.CtStateBitRELATED
|
|
return []expr.Any{
|
|
&expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1},
|
|
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: append([]byte(interfaceName), 0)},
|
|
&expr.Meta{Key: expr.MetaKeyNFPROTO, Register: 1},
|
|
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{unix.NFPROTO_IPV4}},
|
|
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 12, Len: 4},
|
|
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: peerAddress.To4()},
|
|
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 16, Len: 4},
|
|
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: tunnelAddress.To4()},
|
|
&expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1},
|
|
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{unix.IPPROTO_TCP}},
|
|
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 0, Len: 2},
|
|
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: portBytes},
|
|
&expr.Ct{Register: 1, Key: expr.CtKeySTATE},
|
|
&expr.Bitwise{SourceRegister: 1, DestRegister: 1, Len: 4, Mask: binaryutil.NativeEndian.PutUint32(stateMask), Xor: binaryutil.NativeEndian.PutUint32(0)},
|
|
&expr.Cmp{Op: expr.CmpOpNeq, Register: 1, Data: binaryutil.NativeEndian.PutUint32(0)},
|
|
&expr.Verdict{Kind: expr.VerdictAccept},
|
|
}
|
|
}
|
|
|
|
func allowedRule(gatewayAddress net.IP, port uint16) []expr.Any {
|
|
portBytes := make([]byte, 2)
|
|
binary.BigEndian.PutUint16(portBytes, port)
|
|
return []expr.Any{
|
|
&expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1},
|
|
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: append([]byte(interfaceName), 0)},
|
|
&expr.Meta{Key: expr.MetaKeyNFPROTO, Register: 1},
|
|
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{unix.NFPROTO_IPV4}},
|
|
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 16, Len: 4},
|
|
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: gatewayAddress.To4()},
|
|
&expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1},
|
|
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{unix.IPPROTO_TCP}},
|
|
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 2, Len: 2},
|
|
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: portBytes},
|
|
&expr.Counter{},
|
|
&expr.Verdict{Kind: expr.VerdictAccept},
|
|
}
|
|
}
|
|
|
|
func interfaceVerdict(verdict expr.VerdictKind) []expr.Any {
|
|
return []expr.Any{
|
|
&expr.Meta{Key: expr.MetaKeyIIFNAME, Register: 1},
|
|
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: append([]byte(interfaceName), 0)},
|
|
&expr.Counter{},
|
|
&expr.Verdict{Kind: verdict},
|
|
}
|
|
}
|
|
|
|
func fencedOutputRule(endpointAddress net.IP, endpointPort uint16, fence *nftables.Set) []expr.Any {
|
|
rule := outerRulePrefix(endpointAddress, endpointPort)
|
|
rule = append(rule,
|
|
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 16, Len: 4},
|
|
&expr.Lookup{SourceRegister: 1, SetName: fence.Name, SetID: fence.ID},
|
|
&expr.Counter{},
|
|
&expr.Verdict{Kind: expr.VerdictAccept},
|
|
)
|
|
return rule
|
|
}
|
|
|
|
func outerVerdictRule(endpointAddress net.IP, endpointPort uint16, verdict expr.VerdictKind) []expr.Any {
|
|
rule := outerRulePrefix(endpointAddress, endpointPort)
|
|
return append(rule, &expr.Counter{}, &expr.Verdict{Kind: verdict})
|
|
}
|
|
|
|
func outerRulePrefix(endpointAddress net.IP, endpointPort uint16) []expr.Any {
|
|
portBytes := make([]byte, 2)
|
|
binary.BigEndian.PutUint16(portBytes, endpointPort)
|
|
return []expr.Any{
|
|
&expr.Meta{Key: expr.MetaKeyNFPROTO, Register: 1},
|
|
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{unix.NFPROTO_IPV4}},
|
|
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 16, Len: 4},
|
|
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: endpointAddress.To4()},
|
|
&expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1},
|
|
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{unix.IPPROTO_UDP}},
|
|
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 2, Len: 2},
|
|
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: portBytes},
|
|
}
|
|
}
|
|
|
|
func (r *relay) refreshFence() error {
|
|
if r.fence == nil {
|
|
return errors.New("relay ownership fence is absent")
|
|
}
|
|
connection := &nftables.Conn{}
|
|
connection.FlushSet(r.fence)
|
|
if err := connection.SetAddElements(r.fence, []nftables.SetElement{{Key: r.config.endpointAddress.To4(), Timeout: 15 * time.Second}}); err != nil {
|
|
return fmt.Errorf("queue ownership fence refresh: %w", err)
|
|
}
|
|
if err := connection.Flush(); err != nil {
|
|
return fmt.Errorf("refresh ownership fence: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *relay) verify() error {
|
|
link, err := netlink.LinkByName(interfaceName)
|
|
if err != nil {
|
|
return fmt.Errorf("WireGuard interface: %w", err)
|
|
}
|
|
if link.Attrs().Flags&net.FlagUp == 0 {
|
|
return errors.New("WireGuard interface is down")
|
|
}
|
|
rules, err := netlink.RuleList(netlink.FAMILY_V4)
|
|
if err != nil {
|
|
return fmt.Errorf("list policy rules: %w", err)
|
|
}
|
|
for _, target := range r.config.targets {
|
|
foundRule := false
|
|
for _, rule := range rules {
|
|
if isRelayPolicyRule(rule, target.address) {
|
|
foundRule = true
|
|
break
|
|
}
|
|
}
|
|
if !foundRule {
|
|
return fmt.Errorf("relay policy rule for %s is absent", target.address)
|
|
}
|
|
}
|
|
for _, target := range r.config.targets {
|
|
for index, destination := range r.config.localReturnCIDRs {
|
|
found := false
|
|
for _, rule := range rules {
|
|
if isLocalReturnRule(rule, target.address, destination, localPriority+index) {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
return fmt.Errorf("local return policy rule for %s from %s is absent", destination, target.address)
|
|
}
|
|
}
|
|
}
|
|
routes, err := netlink.RouteListFiltered(netlink.FAMILY_V4, &netlink.Route{Table: routingTable}, netlink.RT_FILTER_TABLE)
|
|
if err != nil {
|
|
return fmt.Errorf("list relay routes: %w", err)
|
|
}
|
|
foundRoute := false
|
|
for _, route := range routes {
|
|
if route.LinkIndex == link.Attrs().Index && (route.Dst == nil || route.Dst.String() == "0.0.0.0/0") {
|
|
foundRoute = true
|
|
break
|
|
}
|
|
}
|
|
if !foundRoute {
|
|
return errors.New("relay default route is absent")
|
|
}
|
|
if err := verifyReversePathFilter(rpFilterRoot, interfaceName); err != nil {
|
|
return err
|
|
}
|
|
if err := verifyFirewall(r.config.endpointAddress, r.config.targets); err != nil {
|
|
return err
|
|
}
|
|
client, err := wgctrl.New()
|
|
if err != nil {
|
|
return fmt.Errorf("open WireGuard control: %w", err)
|
|
}
|
|
defer func() { _ = client.Close() }()
|
|
device, err := client.Device(interfaceName)
|
|
if err != nil {
|
|
return fmt.Errorf("read WireGuard device: %w", err)
|
|
}
|
|
if len(device.Peers) != 1 || device.Peers[0].LastHandshakeTime.IsZero() || time.Since(device.Peers[0].LastHandshakeTime) > r.config.handshakeMaxAge {
|
|
return errors.New("WireGuard handshake is stale")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func verifyReversePathFilter(root, name string) error {
|
|
all, err := readReversePathFilter(filepath.Join(root, "all", "rp_filter"))
|
|
if err != nil {
|
|
return fmt.Errorf("read all reverse-path filtering: %w", err)
|
|
}
|
|
value, err := readReversePathFilter(filepath.Join(root, name, "rp_filter"))
|
|
if err != nil {
|
|
return fmt.Errorf("read %s reverse-path filtering: %w", name, err)
|
|
}
|
|
if max(all, value) != 2 {
|
|
return fmt.Errorf("effective %s reverse-path filtering is not loose: all=%d interface=%d", name, all, value)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func readReversePathFilter(path string) (int, error) {
|
|
value, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
parsed, err := strconv.Atoi(strings.TrimSpace(string(value)))
|
|
if err != nil || parsed < 0 || parsed > 2 {
|
|
return 0, fmt.Errorf("invalid value %q", strings.TrimSpace(string(value)))
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
func verifyFirewall(endpointAddress net.IP, targets []relayTarget) error {
|
|
connection := &nftables.Conn{}
|
|
table := &nftables.Table{Family: nftables.TableFamilyINet, Name: firewallTable}
|
|
fence, err := connection.GetSetByName(table, "owner_alive")
|
|
if err != nil {
|
|
return fmt.Errorf("read relay ownership fence: %w", err)
|
|
}
|
|
elements, err := connection.GetSetElements(fence)
|
|
if err != nil {
|
|
return fmt.Errorf("read relay ownership fence elements: %w", err)
|
|
}
|
|
fenceAlive := false
|
|
for _, element := range elements {
|
|
if net.IP(element.Key).Equal(endpointAddress) {
|
|
fenceAlive = true
|
|
break
|
|
}
|
|
}
|
|
if !fenceAlive {
|
|
return errors.New("relay ownership fence is expired")
|
|
}
|
|
chains, err := connection.ListChainsOfTableFamily(nftables.TableFamilyINet)
|
|
if err != nil {
|
|
return fmt.Errorf("list relay firewall chains: %w", err)
|
|
}
|
|
allowedRuleCount := 0
|
|
for _, target := range targets {
|
|
allowedRuleCount += len(target.ports)
|
|
}
|
|
requiredRules := map[string]int{"prerouting": allowedRuleCount + 2, "output": 2}
|
|
for _, chain := range chains {
|
|
if chain.Table == nil || chain.Table.Name != firewallTable {
|
|
continue
|
|
}
|
|
minimum, required := requiredRules[chain.Name]
|
|
if !required {
|
|
continue
|
|
}
|
|
rules, err := connection.GetRules(table, chain)
|
|
if err != nil {
|
|
return fmt.Errorf("read relay firewall chain %s: %w", chain.Name, err)
|
|
}
|
|
if len(rules) < minimum {
|
|
return fmt.Errorf("relay firewall chain %s has %d rules", chain.Name, len(rules))
|
|
}
|
|
delete(requiredRules, chain.Name)
|
|
}
|
|
if len(requiredRules) != 0 {
|
|
return errors.New("relay firewall chains are incomplete")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type firewallCounters struct {
|
|
tunnelAllowed map[string]uint64
|
|
tunnelDenied uint64
|
|
outerAllowed uint64
|
|
outerDenied uint64
|
|
}
|
|
|
|
func readFirewallCounters(targets []relayTarget) (firewallCounters, error) {
|
|
connection := &nftables.Conn{}
|
|
table := &nftables.Table{Family: nftables.TableFamilyINet, Name: firewallTable}
|
|
chains, err := connection.ListChainsOfTableFamily(nftables.TableFamilyINet)
|
|
if err != nil {
|
|
return firewallCounters{}, fmt.Errorf("list relay firewall chains for counters: %w", err)
|
|
}
|
|
rulesByChain := make(map[string][]*nftables.Rule)
|
|
for _, chain := range chains {
|
|
if chain.Table == nil || chain.Table.Name != firewallTable || (chain.Name != "prerouting" && chain.Name != "output") {
|
|
continue
|
|
}
|
|
rules, err := connection.GetRules(table, chain)
|
|
if err != nil {
|
|
return firewallCounters{}, fmt.Errorf("read relay firewall chain %s counters: %w", chain.Name, err)
|
|
}
|
|
rulesByChain[chain.Name] = rules
|
|
}
|
|
return firewallCountersFromRules(rulesByChain, targets)
|
|
}
|
|
|
|
func firewallCountersFromRules(rulesByChain map[string][]*nftables.Rule, targets []relayTarget) (firewallCounters, error) {
|
|
prerouting := rulesByChain["prerouting"]
|
|
output := rulesByChain["output"]
|
|
allowedRuleCount := 0
|
|
for _, target := range targets {
|
|
allowedRuleCount += len(target.ports)
|
|
}
|
|
if len(prerouting) < allowedRuleCount+2 || len(output) < 2 {
|
|
return firewallCounters{}, errors.New("relay firewall rules are incomplete for counters")
|
|
}
|
|
allowed := make(map[string]uint64, allowedRuleCount)
|
|
index := 1
|
|
for _, target := range targets {
|
|
for _, port := range target.ports {
|
|
packets, ok := rulePacketCounter(prerouting[index])
|
|
if !ok {
|
|
return firewallCounters{}, errors.New("relay firewall rule counter is absent")
|
|
}
|
|
allowed[net.JoinHostPort(target.address.String(), strconv.Itoa(int(port)))] = packets
|
|
index++
|
|
}
|
|
}
|
|
values := make([]uint64, 0, 3)
|
|
for _, rule := range []*nftables.Rule{prerouting[allowedRuleCount+1], output[0], output[1]} {
|
|
packets, ok := rulePacketCounter(rule)
|
|
if !ok {
|
|
return firewallCounters{}, errors.New("relay firewall rule counter is absent")
|
|
}
|
|
values = append(values, packets)
|
|
}
|
|
return firewallCounters{
|
|
tunnelAllowed: allowed,
|
|
tunnelDenied: values[0],
|
|
outerAllowed: values[1],
|
|
outerDenied: values[2],
|
|
}, nil
|
|
}
|
|
|
|
func rulePacketCounter(rule *nftables.Rule) (uint64, bool) {
|
|
for _, expression := range rule.Exprs {
|
|
if counter, ok := expression.(*expr.Counter); ok {
|
|
return counter.Packets, true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func equalCIDR(network *net.IPNet, address net.IP) bool {
|
|
return network != nil && network.IP.Equal(address) && bytes.Equal(network.Mask, net.CIDRMask(32, 32))
|
|
}
|
|
|
|
func containsTargetAddress(targets []relayTarget, network *net.IPNet) bool {
|
|
for _, target := range targets {
|
|
if equalCIDR(network, target.address) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isRelayPolicyRule(rule netlink.Rule, gatewayAddress net.IP) bool {
|
|
return rule.Priority == rulePriority && rule.Table == routingTable && equalCIDR(rule.Src, gatewayAddress)
|
|
}
|
|
|
|
func isLocalReturnRule(rule netlink.Rule, gatewayAddress net.IP, destination *net.IPNet, priority int) bool {
|
|
return rule.Priority == priority && rule.Table == unix.RT_TABLE_MAIN && equalCIDR(rule.Src, gatewayAddress) && rule.Dst != nil && rule.Dst.String() == destination.String()
|
|
}
|
|
|
|
func (r *relay) cleanup() error {
|
|
r.cleanupMu.Lock()
|
|
defer r.cleanupMu.Unlock()
|
|
var failures []error
|
|
linkRemoved := true
|
|
link, err := netlink.LinkByName(interfaceName)
|
|
if err == nil {
|
|
if err := netlink.LinkDel(link); err != nil {
|
|
linkRemoved = false
|
|
failures = append(failures, fmt.Errorf("remove WireGuard link: %w", err))
|
|
}
|
|
} else if _, ok := err.(netlink.LinkNotFoundError); !ok {
|
|
linkRemoved = false
|
|
failures = append(failures, fmt.Errorf("find WireGuard link for cleanup: %w", err))
|
|
}
|
|
rules, err := netlink.RuleList(netlink.FAMILY_V4)
|
|
if err != nil {
|
|
failures = append(failures, fmt.Errorf("list relay policy rules: %w", err))
|
|
} else {
|
|
for _, candidate := range rules {
|
|
if containsTargetAddress(r.config.targets, candidate.Src) && candidate.Priority >= localPriority && candidate.Priority <= rulePriority && (candidate.Table == unix.RT_TABLE_MAIN || candidate.Table == routingTable) {
|
|
rule := candidate
|
|
if err := netlink.RuleDel(&rule); err != nil {
|
|
failures = append(failures, fmt.Errorf("remove relay policy rule: %w", err))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
routes, err := netlink.RouteListFiltered(netlink.FAMILY_V4, &netlink.Route{Table: routingTable}, netlink.RT_FILTER_TABLE)
|
|
if err != nil {
|
|
failures = append(failures, fmt.Errorf("list relay routes: %w", err))
|
|
} else {
|
|
for _, candidate := range routes {
|
|
route := candidate
|
|
if err := netlink.RouteDel(&route); err != nil {
|
|
failures = append(failures, fmt.Errorf("remove relay route: %w", err))
|
|
}
|
|
}
|
|
}
|
|
if linkRemoved {
|
|
connection := &nftables.Conn{}
|
|
tables, err := connection.ListTables()
|
|
if err != nil {
|
|
failures = append(failures, fmt.Errorf("list nftables tables for cleanup: %w", err))
|
|
} else {
|
|
for _, table := range tables {
|
|
if table.Family == nftables.TableFamilyINet && table.Name == firewallTable {
|
|
connection.DelTable(table)
|
|
}
|
|
}
|
|
if err := connection.Flush(); err != nil {
|
|
failures = append(failures, fmt.Errorf("remove relay firewall: %w", err))
|
|
}
|
|
}
|
|
}
|
|
r.fence = nil
|
|
return errors.Join(failures...)
|
|
}
|
|
|
|
func writeHealth(path string, healthy bool) error {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
|
return err
|
|
}
|
|
state := []byte("unhealthy\n")
|
|
if healthy {
|
|
state = []byte("healthy\n")
|
|
}
|
|
return os.WriteFile(path, state, 0644)
|
|
}
|
|
|
|
func checkHealth(path string, maxAge time.Duration) error {
|
|
if path == "" {
|
|
path = "/run/relay-agent/health"
|
|
}
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if time.Since(info.ModTime()) > maxAge {
|
|
return errors.New("relay heartbeat is stale")
|
|
}
|
|
state, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if string(state) != "healthy\n" {
|
|
return errors.New("relay reports unhealthy")
|
|
}
|
|
return nil
|
|
}
|