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

1210 lines
40 KiB
Go
Raw Normal View History

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
}