Compare commits

...
Author SHA1 Message Date
levaliciousandClaude Opus 5.5 5d6b0719b3 lb: route filters may not change the forwarding headers
images / images (push) Successful in 7m29s
The proxy sets Forwarded, X-Forwarded-For/-Host/-Proto and X-Real-IP from
the connection, but applied a route's request header filters after that,
so whoever could write a route to a backend could tell it any client
address (a backend that trusts the gateway's X-Forwarded-For believes it).

An HTTPRoute whose request header filter sets, adds or removes one of
them (names compared case-insensitively) is now not accepted
(UnsupportedValue), like the other filters we do not serve. The proxy
also sets them after the filters, clearing them first: SetXForwarded
appends to an X-Forwarded-For already there.

The proxy test's backend now prints every X-Forwarded-For value; it
printed only the first, which hid an appended one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHXzGrsxfkGk8LmwrBbHNZ
2026-10-14 10:29:35 -04:00
levaliciousandClaude Opus 5.5 e7583c4177 reweave/test/gateway: end-to-end test of the agent's Gateways
images / images (push) Failing after 4m14s
On test ports of a held address, against a live cluster: the class and the
Gateway accepted and programmed with their routes attached, route statuses
(including an unresolved backend), a TCP listener forwarding in the kernel
to a pod on another node that sees the client's own address, HTTPS with the
listener's certificate by SNI over HTTP/2 routed by exact path, prefix with a
rewrite and default, X-Forwarded-For equal to that same client address, the
HTTP-to-HTTPS redirect, 500 for the unresolved backend and no certificate
for an unknown name, a pod inside the cluster reaching it, a renewed
certificate served without a restart, failing closed on an address nobody
holds and recovering, and the holder's rules gone with the Gateway.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHXzGrsxfkGk8LmwrBbHNZ
2026-10-14 00:06:47 -04:00
levaliciousandClaude Opus 5.5 7405b51e53 lb: serve Gateways (Gateway API v1) from the node holding their address
images / images (push) Successful in 6m32s
Gateways of a GatewayClass whose controllerName is reweave.levk.dev/gateway
are served like LoadBalancer Services: a Gateway names one IPv4 address in
spec.addresses, and the node holding that address serves all of it and
writes its status.

- TCP listeners forward the oldest TCPRoute attached to them in the kernel,
  through the same DNAT and reply steering as LoadBalancer Services, so the
  backend sees the client's own address.
- HTTP and HTTPS listeners are served by the agent itself on the holder:
  TLS terminated with the listener's certificate (chosen by SNI, swapped in
  place when its Secret changes, HTTP/2 offered), requests routed by the
  attached HTTPRoutes in the order the spec prescribes (hostname, exact
  path, longest prefix, method, headers, query, age, name, rule), and passed
  to a ready endpoint of the backend with X-Forwarded-For built from this
  hop only. RequestRedirect, URLRewrite and both header modifiers are
  implemented; other filters and regular expressions are refused
  (Accepted False, UnsupportedValue). Bodies are streamed, never limited.
  Unresolved backends answer 500, backends without ready endpoints 503,
  requests nothing matches 404. Those ports are let in ahead of the host
  firewall (REWEAVE-LB-IN).

Listeners are checked for protocol, route kinds, certificates (Secrets in
the Gateway's namespace or granted by a ReferenceGrant, and only in the
namespaces the agent may read: --gateway-namespaces, default
gateway-system), conflicts on a port, and ports LoadBalancer Services
already forward. Routes attach by parentRef (sectionName, port),
allowedRoutes (namespaces Same, All or Selector; kinds) and hostname
intersection, with the spec's conditions and reasons.

One writer per status: the holder writes its Gateway's (the first node by
name when nobody holds the address, so failures are reported once), the
first node by name writes GatewayClass and route statuses, keeping other
controllers' route parents. Writes are conditional on the resourceVersion
they were computed from, and a condition keeps its transition time while
its status stands, so an unchanged status is never rewritten.

The types are plain structs for the subset implemented (the upstream module
needs a newer client-go than the fork's); the CRDs are installed apart from
this manifest, which grants the agent read access to the Gateway API kinds,
patch on their status, and Secrets in gateway-system only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHXzGrsxfkGk8LmwrBbHNZ
2026-10-13 23:51:44 -04:00
levaliciousandClaude Opus 5.5 685d084af7 cni: lock each pod's port to the pod's own addresses
images / images (push) Successful in 7m6s
A pod could put any source MAC or address on the wire: weave has no layer-2
anti-spoofing, so a pod with NET_RAW could pose as another pod or as a node's
bridge, on its own node (local bridging never reaches the router) and, through
weave, on others. That undercut every rule that trusts a source MAC or address,
including the egress holder's check of which node a tunnel packet came from.

The CNI plugin now locks the host side of each pod's veth with tc ingress
filters: IPv4 from the pod's MAC and address, ARP speaking for exactly those
(or probing from 0.0.0.0) pass; everything else is dropped before the bridge
sees it. The u32 keys follow iproute2's layout for "ether src" (-8, -4) and
"ip src" (12). ADD fails if the port cannot be locked, so an unlocked pod never
starts. The filters go away with the veth.

Pods attached before this plugin get locked by weave-init: "weaveutil
lock-ports" walks libcni's result cache (each attachment's netns, interface and
address) and locks their ports, leaving locked ones alone. weave-init mounts
the cache and /var/run/netns read-only for it.

TestPortLockInKernel (root) builds a bridge and two pods in throwaway
namespaces and checks what reaches the other pod; reweave/test/portlock runs
the same against a live cluster, across nodes, after checking every port is
locked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHXzGrsxfkGk8LmwrBbHNZ
2026-10-13 22:51:16 -04:00
levaliciousandClaude Opus 5.5 9261a48983 lb: members' ping identifiers in their node's block too
images / images (push) Successful in 6m34s
The live run found ping from a member on one node failing right after a
member on another node pinged the same host: busybox ping uses its PID as
the ICMP echo identifier, containers' PIDs are small and alike, so both
nodes' echoes reached the holder as the same connection, and the holder
dropped the second as a collision (fail closed, as intended). NAT treats
the echo identifier as ICMP's port, and iptables accepts a range for it:
members' ICMP is now SNATed into the node's block like TCP, UDP and SCTP,
so every node's pings are distinct on the holder. Replies follow the
holder's tag as before.

The end-to-end test's ping and traceroute pods run as root (the curl
image's user cannot use raw sockets even with NET_RAW).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHXzGrsxfkGk8LmwrBbHNZ
2026-10-13 19:57:22 -04:00
levaliciousandClaude Opus 5.5 b040f97149 ci: free the runner's disk after every build
images / images (push) Successful in 11m16s
The images go to the registry with --push, but each build also left its
tagged images in the runner's local store and an unbounded build cache:
after a day of builds the runner's 10Gi Docker volume filled up and run 21
died mid-build (no space left on device). A final step, run even when the
build fails, removes our pushed tags and caps the build cache at 4GB
(recent layers stay, so builds stay cached).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHXzGrsxfkGk8LmwrBbHNZ
2026-10-13 19:39:08 -04:00
levaliciousandClaude Opus 5.5 602e2da73c lb: one egress tunnel per peer; replies and ICMP errors follow a tag
images / images (push) Failing after 9m54s
The holder could tell a member's node only by source port block, so
replies without ports never got back: ICMP errors (path-MTU "too big",
unreachable, time exceeded), ping, and port-less protocols. Now every
node pair has its own ipip tunnel (rweg-<hash of the peer>, between the
two weave bridges), so the holder knows the sending node by the device a
packet arrives on:

- it tags each connection arriving from a peer's tunnel with that peer
  (connmark: peer index in the top byte, plus 0x200000), once;
- replies and related ICMP errors restore the tag, and a policy rule per
  peer (ahead of the local table, like the port rules) routes them into
  that peer's tunnel, where the member node's conntrack translates them
  back to the member;
- an arrival on one peer's tunnel whose connection another peer already
  holds (a port-less protocol to the same remote from two nodes) is
  dropped, never delivered across;
- the stateless port-block rules stay, now for TCP, UDP and SCTP.

Members' SNAT keeps TCP, UDP and SCTP within the node's block and puts
everything else (ping, GRE, ...) on the address alone; the guard lets any
protocol into the holder's tunnel and nothing anywhere else. The holder
only accepts, per tunnel, the address with that node's ports.

Routes in the reply tables are now read with one dump per pass instead of
one per candidate table.

reweave/test/egress/run.sh also checks ping from every node, that ICMP
errors from routers beyond the holder reach a member (traceroute), and
that ping is dropped while nobody holds the address.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHXzGrsxfkGk8LmwrBbHNZ
2026-10-13 19:27:09 -04:00
levaliciousandClaude Opus 5.5 31bf46404a lb: delete default routes as listed (RouteDel refuses a nil Dst)
images / images (push) Successful in 17m38s
RouteList returns a default route without Dst, and netlink's RouteDel
refuses a route with no Dst, Src or Gw. The egress unreachable table's
"unreachable default" could not be removed once installed: every later
pass failed there, before updating rules, so members stayed dropped after
their EgressService got a holder again (fail closed, but stuck). S1's
stale reply tables had the same latent fault. Both now delete through
delRoute, which spells the default destination out; the unreachable route
is compared by type alone, so it is not replaced on every pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHXzGrsxfkGk8LmwrBbHNZ
2026-10-13 16:01:59 -04:00
levaliciousandClaude Opus 5.5 ee1c2f905d lb, weave: three faults the first live EgressService run found
images / images (push) Successful in 17m57s
- The tunnel needs an address. Reverse-path filtering (loose, as on our
  nodes) rejects what arrives on a device without an IPv4 address unless
  the reverse route leaves by that very device: the holder dropped every
  member packet after decapsulation (ip route get: EXDEV), and a member's
  node would drop the replies. reweave-eg now carries the node's bridge
  address as a /32 (it never shows on the wire: the tunnel sets the outer
  source itself, and what crosses it is forwarded).
- The kernel leaves out the priority attribute of a rule at 0, so the
  default local rule lists as unset (-1) and moveLocalRule never found
  it: it stayed at 0 beside the new one at 100, and the holder delivered
  replies to its own address to itself (answering with RSTs). Unset now
  counts as 0.
- install_cni_plugin copied the plugin only when weave-plugin-$VERSION was
  missing, and every build of ours is "latest": nodes kept a plugin that
  ignores egressAdmission, so a new member's first connection left from
  its node. It is now replaced whenever its content differs (sha256),
  atomically.

Also: the end-to-end manifest's non-member label is a string ("none", not
YAML's boolean no).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHXzGrsxfkGk8LmwrBbHNZ
2026-10-13 15:31:29 -04:00
levaliciousandClaude Opus 5.5 b413f2a5f8 lb: a failed pass blocks pod admission only where EgressServices exist
images / images (push) Successful in 19m11s
With no EgressService and no member rules installed, an admission has
nothing to protect or clean up, so a failing pass (a datapath bug, a
bridge not yet addressed) answers "not a member" instead of stopping
every pod on the node from starting. Wherever EgressServices exist,
admission still fails closed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHXzGrsxfkGk8LmwrBbHNZ
2026-10-13 14:40:32 -04:00
levaliciousandClaude Opus 5.5 305ac4f3d3 lb: EgressService - a LoadBalancer Service's pods leave from its address
images / images (push) Successful in 18m34s
EgressService (reweave.levk.dev/v1alpha1, named after a LoadBalancer
Service of class reweave.levk.dev/node-address) makes every pod the
Service selects (by pod IP, never readiness) leave the cluster from the
Service's address, or not at all. spec.emit is explicit: Holder (the only
mode so far) tunnels members' packets to the node holding the address,
which emits them.

The holder (S1's: the one node with the address on an interface) is the
status' sole writer: host, address, and a 512-port block per node in
61000-65535 (above the default ephemeral range), stable across churn.
A member's node SNATs it to address:its-block before it leaves, so the
holder forwards it unchanged and routes replies back by port alone: ip
rules (to address, ipproto tcp/udp, dport block) ahead of the local
table, which moves from 0 to 100, since the address is local on the
holder. Members' packets travel both ways in reweave-eg, an ipip tunnel
between weave bridges (NPC drops new routed packets into weave), with
accept_local and loose rp_filter on that device only.

Members' new connections are classed at the tail of nat PREROUTING,
after kube-proxy's DNAT has ended the nat walk for Services: the pod
network, multicast and the nodes' addresses are not outside. Marked
packets follow a per-member rule to the holder's table (or to an
unreachable one), are SNATed into the tunnel, and a FORWARD guard drops
anything else a member sends outside (other protocols, other ways out).
The tunnel is accepted only from the nodes' bridges, a holder accepts
from it only the blocks' ports, and TCP MSS is clamped into it.

Fail closed throughout: until the holder has published the status,
without a block, with no single holder, or for a pod two EgressServices
select, a member's outside traffic is dropped. The CNI plugin asks
weave-npc to admit every pod (unix socket /run/weave-npc/egress.sock,
conflist "egressAdmission": true, now rewritten when it differs) after
IPAM and before the pod has an interface, so a member's first packet
already leaves from the address; no answer, no pod. weave-npc applies
nothing about egress from caches that are not synced (unless the API is
absent): a restart never strips members' rules.

reweave/test/egress/run.sh checks the design's assertions against a
live cluster; both end-to-end scripts now re-evaluate each check on
every retry (it was expanded once, before the loop).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHXzGrsxfkGk8LmwrBbHNZ
2026-10-13 14:39:44 -04:00
levaliciousandClaude Opus 5.5 8018ea4bf9 lb: keep the client's address past weave's masquerade
images / images (push) Successful in 19m13s
Weave's expose rule (nat WEAVE: ! -s <alloc> -d <alloc> -j MASQUERADE)
masquerades everything entering the pod network from outside unless it is
for a local pod (weaver-no-masq-local). Live on the SG cluster a pod on
another node than the holder saw the holder's bridge address instead of
the client's. A new chain, REWEAVE-LB-POST, hooked first in nat
POSTROUTING, lets a connection we DNATed to a held address leave the nat
table with its source untouched; one that comes from a pod (hairpin) is
masqueraded to the holder instead, so its reply returns through the
holder's conntrack.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHXzGrsxfkGk8LmwrBbHNZ
2026-10-13 13:30:08 -04:00
levaliciousandClaude Opus 5.5 ef50dc921f lb: DNAT rules carry their protocol; end-to-end test against a cluster
images / images (push) Successful in 14m35s
iptables refuses a DNAT to ip:port unless the same rule names the
protocol, so every per-port chain's DNAT now has -p. Live on the SG
cluster the first sync (no endpoints yet) applied; every later one failed
with "Need TCP, UDP, SCTP or DCCP with port specification" and left the
chains empty. The unit test now rejects any DNAT line without its
protocol; the rendered nat and mangle payloads were also checked with the
nodes' own iptables-restore --test (nf_tables).

reweave/test/lb/run.sh is the end-to-end test: from outside, both a pod on
the holder and one on another node see the client's own address; status
is [{ip, ipMode: Proxy}] and kube-proxy has nothing for the address; a pod
on a third node reaches it; an unheld address clears the status and stops
the forward; deleting the Services removes the holder's chains.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHXzGrsxfkGk8LmwrBbHNZ
2026-10-13 13:10:49 -04:00
levaliciousandClaude Opus 5.5 9bc3415111 lb: LoadBalancer Services from addresses nodes hold, client address kept
images / images (push) Successful in 14m46s
A Service of type LoadBalancer with loadBalancerClass
reweave.levk.dev/node-address names its address in spec.loadBalancerIP.
Each node's weave-npc publishes the IPv4 addresses on its host interfaces
and its weave bridge (Node annotations reweave.levk.dev/addresses and
reweave.levk.dev/weave). The one node that holds a Service's address is
its holder: it alone writes status.loadBalancer (ipMode Proxy, so
kube-proxy installs nothing for the address) and DNATs the address's ports
to ready endpoints (EndpointSlices), without SNAT, from nat PREROUTING
ahead of kube-proxy. Every other node marks the connections the holder
forwards to its pods (new, from the holder's bridge MAC, from outside the
pod network) and routes their replies back to the holder's bridge through
a per-holder policy rule and table, so the holder's conntrack undoes the
DNAT and the reply leaves from the address the client connected to.

Fail closed: an address no node holds, or that several nodes hold, is
served by no node, and its status is cleared (by its holder, or by the
first node by name if none holds it). Compute is the policy as a pure
function; Datapath replaces exactly the chains it owns with
iptables-restore --noflush and keeps its rules and tables exact.

weave-npc runs it unless --lb=false; the ClusterRole gains services
(watch), services/status (patch), endpointslices (watch), nodes (patch).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UHXzGrsxfkGk8LmwrBbHNZ
2026-10-13 12:45:54 -04:00
37 changed files with 8008 additions and 13 deletions

No files matched your search

+11
View File
@@ -54,3 +54,14 @@ jobs:
.
echo "pushed $image"
done
# The images live in the registry now; the runner keeps only a bounded build cache. Without this the
# runner's 10Gi Docker volume fills up after a handful of builds (no space left on device mid-build).
- name: Free the runner's disk
if: always()
run: |
set -u
docker image ls --format '{{.Repository}}:{{.Tag}}' | grep "^$IMAGE_PREFIX/" | xargs -r docker image rm -f
docker image prune -f
docker builder prune -f --keep-storage 4GB
docker system df
+87
View File
@@ -0,0 +1,87 @@
package lb
import (
"fmt"
"net"
"sort"
"strings"
"github.com/vishvananda/netlink"
"golang.org/x/sys/unix"
)
// Local is what this node publishes about itself, read from its interfaces.
type Local struct {
Addresses []net.IP
WeaveIP net.IP
WeaveMAC net.HardwareAddr
PodNet *net.IPNet // the weave allocation range: the bridge address's network
}
// overlay- and container-side interfaces: their addresses are never held ones
var overlayPrefixes = []string{"weave", "datapath", "vxlan-", "veth", "docker", "cni"}
func overlay(name string) bool {
for _, p := range overlayPrefixes {
if strings.HasPrefix(name, p) {
return true
}
}
return false
}
// ReadLocal lists the global IPv4 addresses on host interfaces, and the weave bridge's address and MAC.
func ReadLocal() (Local, error) {
var l Local
links, err := netlink.LinkList()
if err != nil {
return l, err
}
for _, link := range links {
name := link.Attrs().Name
addrs, err := netlink.AddrList(link, unix.AF_INET)
if err != nil {
return l, fmt.Errorf("%s: %v", name, err)
}
if name == bridge {
for _, a := range addrs {
if a.IPNet != nil && a.IP.To4() != nil {
l.WeaveIP = a.IP.To4()
l.WeaveMAC = link.Attrs().HardwareAddr
l.PodNet = &net.IPNet{IP: a.IP.Mask(a.Mask), Mask: a.Mask}
break
}
}
continue
}
if overlay(name) || link.Attrs().Flags&net.FlagLoopback != 0 {
continue
}
for _, a := range addrs {
if a.Scope == unix.RT_SCOPE_UNIVERSE && a.IP.To4() != nil && a.IP.IsGlobalUnicast() {
l.Addresses = append(l.Addresses, a.IP.To4())
}
}
}
// an address inside the pod network is never a host one (whatever interface it is on)
if l.PodNet != nil {
kept := l.Addresses[:0]
for _, ip := range l.Addresses {
if !l.PodNet.Contains(ip) {
kept = append(kept, ip)
}
}
l.Addresses = kept
}
sort.Slice(l.Addresses, func(i, j int) bool { return l.Addresses[i].String() < l.Addresses[j].String() })
return l, nil
}
// Annotations is what this node's Node object should carry for l.
func (l Local) Annotations() map[string]string {
a := map[string]string{AnnAddresses: FormatAddresses(l.Addresses)}
if l.WeaveIP != nil && l.WeaveMAC != nil {
a[AnnWeave] = l.WeaveIP.String() + "," + l.WeaveMAC.String()
}
return a
}
+88
View File
@@ -0,0 +1,88 @@
// Package admit is the protocol between the weave CNI plugin and weave-npc's egress agent: at CNI ADD the
// plugin tells the agent which pod got which IP, and the agent answers once the pod's egress rules are in
// place, so an EgressService member's first packet already leaves from the Service's address (or not at all).
//
// It depends on the standard library only: the plugin binary must not grow a Kubernetes client.
package admit
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
)
// SocketPath is where the agent listens (a hostPath directory shared with the host, where the plugin runs).
const SocketPath = "/run/weave-npc/egress.sock"
// Request is one pod's admission: the IP the plugin just allocated to it.
type Request struct {
Namespace string `json:"namespace"`
Name string `json:"name"`
IP string `json:"ip"`
}
// Response says whether the pod is an EgressService member (its rules are installed either way when 200).
type Response struct {
Member bool `json:"member"`
}
// Timeout bounds one admission (the agent may have to fetch the pod from the API server).
const Timeout = 20 * time.Second
// PodFromArgs reads the pod's namespace and name from CNI_ARGS ("K8S_POD_NAMESPACE=..;K8S_POD_NAME=..").
func PodFromArgs(args string) (namespace, name string) {
for _, kv := range strings.Split(args, ";") {
k, v, ok := strings.Cut(kv, "=")
if !ok {
continue
}
switch k {
case "K8S_POD_NAMESPACE":
namespace = v
case "K8S_POD_NAME":
name = v
}
}
return namespace, name
}
// Admit asks the agent at socket to admit req, and returns only once the agent has installed the pod's rules.
func Admit(ctx context.Context, socket string, req Request) (Response, error) {
var resp Response
body, err := json.Marshal(req)
if err != nil {
return resp, err
}
client := &http.Client{Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, "unix", socket)
},
}}
ctx, cancel := context.WithTimeout(ctx, Timeout)
defer cancel()
hreq, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://weave-npc/admit", bytes.NewReader(body))
if err != nil {
return resp, err
}
hreq.Header.Set("Content-Type", "application/json")
hresp, err := client.Do(hreq)
if err != nil {
return resp, fmt.Errorf("egress admission (weave-npc at %s): %v", socket, err)
}
defer hresp.Body.Close()
out, _ := io.ReadAll(io.LimitReader(hresp.Body, 1<<16))
if hresp.StatusCode != http.StatusOK {
return resp, fmt.Errorf("egress admission refused (%s): %s", hresp.Status, strings.TrimSpace(string(out)))
}
if err := json.Unmarshal(out, &resp); err != nil {
return resp, fmt.Errorf("egress admission: bad answer: %v", err)
}
return resp, nil
}
+629
View File
@@ -0,0 +1,629 @@
package lb
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"os"
"path/filepath"
"sync"
"time"
coreapi "k8s.io/api/core/v1"
discovery "k8s.io/api/discovery/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/fields"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/cache"
"github.com/rajch/weave/common"
"github.com/rajch/weave/lb/admit"
)
// Controller watches Services, EndpointSlices, Nodes, EgressServices and this node's Pods, and keeps this
// node's part of the picture true. It also answers the CNI plugin's egress admissions.
type Controller struct {
self string
client kubernetes.Interface
egress rest.Interface
dp *Datapath
// readLocal is the node's own interfaces (swappable in tests)
readLocal func() (Local, error)
services, slices, nodes, pods, egresses cache.Store
synced []cache.InformerSynced
egressSynced cache.InformerSynced // nil while the EgressService API is absent
kick chan struct{}
ready chan struct{} // closed once the first pass ran
mu sync.Mutex // one pass at a time (the loop, or an admission)
lastProblems map[string]bool
// Services whose status this node wrote since it started (so a restart rewrites ipMode once)
written map[types.UID]string
// admitted: IP -> pod the CNI plugin gave it; fetched: pods admitted before the pod informer had them
admitted map[string]Admission
fetched map[string]*coreapi.Pod
at map[string]time.Time
// lastEgress is what was last applied, reused while the EgressService caches are not synced (never wipe
// members' rules on a partial view)
lastEgress EgressDesired
// Gateways (nil gateway client: not served). Secrets are read only in gwNamespaces.
gateway rest.Interface
gwNamespaces []string
proxy *Proxy
classes, gateways, httpRoutes, tcpRoutes, grants, namespaces cache.Store
secrets []cache.Store
gatewaySynced []cache.InformerSynced // empty while the Gateway API is absent
unbound map[string]string // listeners the proxy could not bind, last pass
}
// Resync is how often the node re-reads its interfaces and re-applies everything even without API events.
const Resync = 30 * time.Second
// NewController runs nothing yet; Run does. egress is the EgressService API client.
func NewController(self string, client kubernetes.Interface, egress rest.Interface, dp *Datapath) *Controller {
return &Controller{self: self, client: client, egress: egress, dp: dp, readLocal: ReadLocal,
kick: make(chan struct{}, 1), ready: make(chan struct{}), lastProblems: map[string]bool{},
written: map[types.UID]string{}, admitted: map[string]Admission{}, fetched: map[string]*coreapi.Pod{},
at: map[string]time.Time{}}
}
func (c *Controller) poke() {
select {
case c.kick <- struct{}{}:
default:
}
}
func newInformer(c *Controller, lw cache.ListerWatcher, obj runtime.Object) (cache.Store, cache.Controller) {
return cache.NewInformer(lw, obj, 0, cache.ResourceEventHandlerFuncs{
AddFunc: func(interface{}) { c.poke() },
UpdateFunc: func(interface{}, interface{}) { c.poke() },
DeleteFunc: func(interface{}) { c.poke() },
})
}
// egressAPI reports whether the EgressService API is served (the CRD is installed).
func (c *Controller) egressAPI() (bool, error) {
_, err := c.client.Discovery().ServerResourcesForGroupVersion(GroupVersion.String())
if err == nil {
return true, nil
}
if apierrors.IsNotFound(err) {
return false, nil
}
return false, err
}
// WithGateway has the controller serve Gateways of the classes whose controllerName is GatewayControllerName,
// reading their certificates from Secrets in these namespaces only.
func (c *Controller) WithGateway(client rest.Interface, namespaces []string) *Controller {
c.gateway, c.gwNamespaces, c.proxy, c.unbound = client, namespaces, NewProxy(), map[string]string{}
return c
}
// gatewayAPI is which Gateway API resources are served (nil: none, the CRDs are not installed).
func (c *Controller) gatewayAPI() (map[string]bool, error) {
l, err := c.client.Discovery().ServerResourcesForGroupVersion(GatewayGroupVersion.String())
if apierrors.IsNotFound(err) {
return nil, nil
}
if err != nil {
return nil, err
}
served := map[string]bool{}
for _, r := range l.APIResources {
served[r.Name] = true
}
return served, nil
}
func (c *Controller) startGateway(ctx context.Context, served map[string]bool) {
run := func(store *cache.Store, resource string, obj runtime.Object) {
if !served[resource] {
return // an older bundle without this kind: nothing of it to serve
}
var ctl cache.Controller
*store, ctl = newInformer(c, cache.NewListWatchFromClient(c.gateway, resource, "", fields.Everything()), obj)
go ctl.Run(ctx.Done())
c.gatewaySynced = append(c.gatewaySynced, ctl.HasSynced)
}
run(&c.classes, GatewayClasses, &GatewayClass{})
run(&c.gateways, Gateways, &Gateway{})
run(&c.httpRoutes, HTTPRoutes, &HTTPRoute{})
run(&c.tcpRoutes, TCPRoutes, &TCPRoute{})
run(&c.grants, ReferenceGrants, &ReferenceGrant{})
var ctl cache.Controller
c.namespaces, ctl = newInformer(c, cache.NewListWatchFromClient(c.client.CoreV1().RESTClient(), "namespaces", "", fields.Everything()), &coreapi.Namespace{})
go ctl.Run(ctx.Done())
c.gatewaySynced = append(c.gatewaySynced, ctl.HasSynced)
for _, ns := range c.gwNamespaces {
var store cache.Store
store, ctl = newInformer(c, cache.NewListWatchFromClient(c.client.CoreV1().RESTClient(), "secrets", ns, fields.Everything()), &coreapi.Secret{})
go ctl.Run(ctx.Done())
c.secrets = append(c.secrets, store)
c.gatewaySynced = append(c.gatewaySynced, ctl.HasSynced)
}
common.Log.Infof("lb: serving Gateways of controller %s (certificates from %v)", GatewayControllerName, c.gwNamespaces)
}
// gatewayReady: the Gateway view can be trusted (the API is served and every informer has synced).
func (c *Controller) gatewayReady() bool {
if c.gateway == nil || len(c.gatewaySynced) == 0 {
return false
}
for _, s := range c.gatewaySynced {
if !s() {
return false
}
}
return true
}
// tryStartGateway starts the Gateway informers once the API is served.
func (c *Controller) tryStartGateway(ctx context.Context) {
if c.gateway == nil || len(c.gatewaySynced) != 0 {
return
}
served, err := c.gatewayAPI()
if err != nil {
common.Log.Warnf("lb: cannot tell whether the Gateway API is served: %v", err)
return
}
if served[Gateways] {
c.startGateway(ctx, served)
}
}
func (c *Controller) startEgress(ctx context.Context) {
var ctl cache.Controller
c.egresses, ctl = newInformer(c, cache.NewListWatchFromClient(c.egress, EgressServices, "", fields.Everything()), &EgressService{})
go ctl.Run(ctx.Done())
c.egressSynced = ctl.HasSynced
}
// Run blocks until ctx ends.
func (c *Controller) Run(ctx context.Context) {
var sc, ec, nc, pc cache.Controller
c.services, sc = newInformer(c, cache.NewListWatchFromClient(c.client.CoreV1().RESTClient(), "services", "", fields.Everything()), &coreapi.Service{})
c.slices, ec = newInformer(c, cache.NewListWatchFromClient(c.client.DiscoveryV1().RESTClient(), "endpointslices", "", fields.Everything()), &discovery.EndpointSlice{})
c.nodes, nc = newInformer(c, cache.NewListWatchFromClient(c.client.CoreV1().RESTClient(), "nodes", "", fields.Everything()), &coreapi.Node{})
c.pods, pc = newInformer(c, cache.NewListWatchFromClient(c.client.CoreV1().RESTClient(), "pods", "",
fields.OneTermEqualSelector("spec.nodeName", c.self)), &coreapi.Pod{})
for _, ctl := range []cache.Controller{sc, ec, nc, pc} {
go ctl.Run(ctx.Done())
c.synced = append(c.synced, ctl.HasSynced)
}
// with the API present, nothing is applied before EgressServices are known: an empty view would wipe
// members' rules (their traffic would leave from the node); without it, there can be no members
for {
present, err := c.egressAPI()
if err == nil {
if present {
c.startEgress(ctx)
c.synced = append(c.synced, c.egressSynced)
}
break
}
common.Log.Warnf("lb: cannot tell whether the EgressService API is served (retrying): %v", err)
select {
case <-ctx.Done():
return
case <-time.After(5 * time.Second):
}
}
c.tryStartGateway(ctx)
if c.proxy != nil {
defer c.proxy.Close()
}
go c.serveAdmission(ctx)
if !cache.WaitForCacheSync(ctx.Done(), c.synced...) {
return
}
tick := time.NewTicker(Resync)
defer tick.Stop()
c.poke()
first := true
for {
select {
case <-ctx.Done():
return
case <-tick.C:
c.mu.Lock()
c.tryStartGateway(ctx)
c.mu.Unlock()
if c.egressSynced == nil {
// the API may have been installed since
if present, err := c.egressAPI(); err == nil && present {
c.mu.Lock()
c.startEgress(ctx)
c.mu.Unlock()
}
}
case <-c.kick:
// let a burst of events settle into one pass
time.Sleep(200 * time.Millisecond)
select {
case <-c.kick:
default:
}
}
c.mu.Lock()
err := c.sync(ctx)
c.mu.Unlock()
if err != nil {
common.Log.Errorf("lb: %v", err)
}
if first {
first = false
close(c.ready)
}
}
}
// egressViewSynced: the EgressService view can be trusted (absent API: trivially empty; else synced).
func (c *Controller) egressViewSynced() bool {
return c.egressSynced == nil || c.egressSynced()
}
// sync is one pass: publish this node, compute, apply, write statuses. Callers hold c.mu.
func (c *Controller) sync(ctx context.Context) error {
local, err := c.readLocal()
if err != nil {
return fmt.Errorf("reading interfaces: %v", err)
}
if err := c.publish(ctx, local); err != nil {
return err
}
if local.PodNet == nil {
return fmt.Errorf("weave bridge has no address yet; not forwarding")
}
var nodes []Node
var problems []string
for _, o := range c.nodes.List() {
n, err := ParseNode(o.(*coreapi.Node))
if err != nil {
problems = append(problems, err.Error())
}
nodes = append(nodes, n)
}
var svcs []*coreapi.Service
for _, o := range c.services.List() {
svcs = append(svcs, o.(*coreapi.Service))
}
var slices []*discovery.EndpointSlice
for _, o := range c.slices.List() {
slices = append(slices, o.(*discovery.EndpointSlice))
}
d := Compute(c.self, nodes, svcs, slices)
var g *GatewayDesired
if c.gatewayReady() {
gd := ComputeGateways(c.self, nodes, c.gatewayInput(svcs, slices))
d = MergeGateways(d, gd, c.self, nodes)
g = &gd
} else if c.gateway != nil && len(c.gatewaySynced) != 0 {
problems = append(problems, "Gateway view not synced yet: Gateways not served")
}
// rewrite once after start what an earlier run may have written without ipMode
for _, f := range d.Forwards {
if s := c.service(f.Namespace, f.Name); s != nil && c.written[s.UID] != f.Address.String() && !hasStatusFor(d, f) {
d.Status = append(d.Status, Status{Namespace: f.Namespace, Name: f.Name, Address: f.Address})
}
}
e := c.lastEgress
e.Status = nil // reusing the datapath, never the writes of an earlier view
if c.egressViewSynced() {
e = c.computeEgress(nodes, svcs)
} else {
problems = append(problems, "EgressServices not synced yet: keeping the egress rules last applied")
}
current := map[string]bool{}
for _, p := range append(append(problems, d.Problems...), e.Problems...) {
current[p] = true
if !c.lastProblems[p] {
common.Log.Warnf("lb: %s", p)
}
}
c.lastProblems = current
if err := c.dp.Apply(d, e, c.self, local); err != nil {
return fmt.Errorf("applying datapath: %v", err)
}
c.lastEgress = e
if g != nil {
// the ports are let in by now; bind what this node serves, close what it no longer does
unbound := map[string]string{}
for k, err := range c.proxy.Apply(g.Serve) {
unbound[k] = err.Error()
}
if !sameJSON(unbound, c.unbound) {
c.unbound = unbound
c.poke() // the statuses say so on the next pass
}
c.writeGatewayStatuses(ctx, *g)
}
for _, st := range d.Status {
if err := c.writeStatus(ctx, st); err != nil {
return err
}
}
for _, st := range e.Status {
if err := PatchEgressStatus(ctx, c.egress, st.Namespace, st.Name, st.Status); err != nil {
return fmt.Errorf("status of egress %s/%s: %v", st.Namespace, st.Name, err)
}
common.Log.Infof("lb: egress %s/%s: host %q, address %q, %d port blocks", st.Namespace, st.Name, st.Status.Host, st.Status.Address, len(st.Status.Blocks))
}
return nil
}
// computeEgress gathers this node's pods (informer, plus pods admitted before the informer had them) and computes.
func (c *Controller) computeEgress(nodes []Node, svcs []*coreapi.Service) EgressDesired {
var egresses []*EgressService
if c.egresses != nil {
for _, o := range c.egresses.List() {
egresses = append(egresses, o.(*EgressService))
}
}
seen := map[string]bool{}
var pods []*coreapi.Pod
for _, o := range c.pods.List() {
p := o.(*coreapi.Pod)
seen[p.Namespace+"/"+p.Name] = true
pods = append(pods, p)
}
// forget admissions the API has caught up with or that are long gone
for ip, a := range c.admitted {
key := a.Namespace + "/" + a.Name
o, ok, _ := c.pods.GetByKey(key)
caughtUp := ok && o.(*coreapi.Pod).Status.PodIP == ip
if caughtUp || (!ok && time.Since(c.at[ip]) > 2*time.Minute) {
delete(c.admitted, ip)
delete(c.at, ip)
}
}
for key, p := range c.fetched {
stillAdmitted := false
for _, a := range c.admitted {
if a.Namespace+"/"+a.Name == key {
stillAdmitted = true
}
}
if !stillAdmitted {
delete(c.fetched, key)
continue
}
if !seen[key] {
pods = append(pods, p)
}
}
return ComputeEgress(c.self, nodes, svcs, egresses, pods, c.admitted)
}
func hasStatusFor(d Desired, f Forward) bool {
for _, s := range d.Status {
if s.Namespace == f.Namespace && s.Name == f.Name {
return true
}
}
return false
}
func (c *Controller) service(ns, name string) *coreapi.Service {
o, ok, err := c.services.GetByKey(ns + "/" + name)
if err != nil || !ok {
return nil
}
return o.(*coreapi.Service)
}
// serveAdmission answers the CNI plugin on admit.SocketPath: record the pod's IP, run a pass, answer once the
// pod's rules are in place. A pod is refused (the plugin fails the ADD, kubelet retries) while this node cannot
// yet tell whether it is a member.
func (c *Controller) serveAdmission(ctx context.Context) {
if err := os.MkdirAll(filepath.Dir(admit.SocketPath), 0o700); err != nil {
common.Log.Errorf("lb: admission socket: %v", err)
return
}
_ = os.Remove(admit.SocketPath)
l, err := net.Listen("unix", admit.SocketPath)
if err != nil {
common.Log.Errorf("lb: admission socket: %v", err)
return
}
_ = os.Chmod(admit.SocketPath, 0o600)
mux := http.NewServeMux()
mux.HandleFunc("/admit", func(w http.ResponseWriter, r *http.Request) {
var req admit.Request
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&req); err != nil {
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
return
}
member, code, err := c.admit(r.Context(), req)
if err != nil {
common.Log.Warnf("lb: admission of %s/%s (%s): %v", req.Namespace, req.Name, req.IP, err)
http.Error(w, err.Error(), code)
return
}
_ = json.NewEncoder(w).Encode(admit.Response{Member: member})
})
srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
go func() {
<-ctx.Done()
_ = srv.Close()
}()
if err := srv.Serve(l); err != nil && err != http.ErrServerClosed {
common.Log.Errorf("lb: admission server: %v", err)
}
}
func (c *Controller) admit(ctx context.Context, req admit.Request) (bool, int, error) {
ip := net.ParseIP(req.IP).To4()
if req.Namespace == "" || req.Name == "" || ip == nil {
return false, http.StatusBadRequest, fmt.Errorf("need namespace, name and an IPv4 address")
}
select {
case <-c.ready:
case <-ctx.Done():
return false, http.StatusServiceUnavailable, ctx.Err()
case <-time.After(admit.Timeout - 5*time.Second):
return false, http.StatusServiceUnavailable, fmt.Errorf("not ready yet")
}
key := req.Namespace + "/" + req.Name
var pod *coreapi.Pod
if o, ok, _ := c.pods.GetByKey(key); ok {
pod = o.(*coreapi.Pod)
} else {
gctx, cancel := context.WithTimeout(ctx, 10*time.Second)
p, err := c.client.CoreV1().Pods(req.Namespace).Get(gctx, req.Name, metav1.GetOptions{})
cancel()
if err != nil {
return false, http.StatusServiceUnavailable, fmt.Errorf("fetching pod: %v", err)
}
pod = p
}
if pod.Spec.NodeName != c.self {
return false, http.StatusConflict, fmt.Errorf("pod is bound to %q, not this node", pod.Spec.NodeName)
}
c.mu.Lock()
defer c.mu.Unlock()
if !c.egressViewSynced() {
return false, http.StatusServiceUnavailable, fmt.Errorf("EgressServices not synced yet")
}
c.admitted[ip.String()] = Admission{Namespace: req.Namespace, Name: req.Name}
c.at[ip.String()] = time.Now()
if _, ok, _ := c.pods.GetByKey(key); !ok {
c.fetched[key] = pod
}
if err := c.sync(ctx); err != nil {
// with no EgressService and no member rules in place there is nothing to protect or to clean up for
// this IP: a broken pass must not stop every pod on the node from starting
if len(c.lastEgress.Members) == 0 && (c.egresses == nil || len(c.egresses.List()) == 0) {
common.Log.Warnf("lb: admitting %s/%s despite a failed pass (no EgressServices): %v", req.Namespace, req.Name, err)
return false, http.StatusOK, nil
}
return false, http.StatusServiceUnavailable, err
}
for _, m := range c.lastEgress.Members {
if m.IP.Equal(ip) {
return true, http.StatusOK, nil
}
}
return false, http.StatusOK, nil
}
// publish keeps this node's annotations equal to what its interfaces say.
func (c *Controller) publish(ctx context.Context, local Local) error {
want := local.Annotations()
o, ok, err := c.nodes.GetByKey(c.self)
if err != nil {
return err
}
if ok {
have := o.(*coreapi.Node).Annotations
same := true
for k, v := range want {
if have[k] != v {
same = false
}
}
if same {
return nil
}
}
patch, err := json.Marshal(map[string]interface{}{"metadata": map[string]interface{}{"annotations": want}})
if err != nil {
return err
}
if _, err := c.client.CoreV1().Nodes().Patch(ctx, c.self, types.MergePatchType, patch, metav1.PatchOptions{}); err != nil {
return fmt.Errorf("publishing node annotations: %v", err)
}
common.Log.Infof("lb: node %s holds [%s]", c.self, want[AnnAddresses])
return nil
}
// writeStatus sets (ipMode Proxy: kube-proxy leaves the address to us) or clears a Service's load-balancer status.
func (c *Controller) writeStatus(ctx context.Context, st Status) error {
ingress := []map[string]string{}
if st.Address != nil {
ingress = append(ingress, map[string]string{"ip": st.Address.String(), "ipMode": "Proxy"})
}
patch, err := json.Marshal(map[string]interface{}{"status": map[string]interface{}{"loadBalancer": map[string]interface{}{"ingress": ingress}}})
if err != nil {
return err
}
s, err := c.client.CoreV1().Services(st.Namespace).Patch(ctx, st.Name, types.MergePatchType, patch, metav1.PatchOptions{}, "status")
if err != nil {
return fmt.Errorf("status of %s/%s: %v", st.Namespace, st.Name, err)
}
if st.Address != nil {
c.written[s.UID] = st.Address.String()
common.Log.Infof("lb: %s/%s at %s", st.Namespace, st.Name, st.Address)
} else {
delete(c.written, s.UID)
common.Log.Infof("lb: %s/%s has no address", st.Namespace, st.Name)
}
return nil
}
// gatewayInput is the Gateway view the policy reads.
func (c *Controller) gatewayInput(svcs []*coreapi.Service, slices []*discovery.EndpointSlice) GatewayInput {
in := GatewayInput{Services: svcs, Slices: slices, SecretNamespaces: map[string]bool{}, Unbound: c.unbound, Now: metav1.Now()}
list := func(s cache.Store, each func(interface{})) {
if s != nil {
for _, o := range s.List() {
each(o)
}
}
}
list(c.classes, func(o interface{}) { in.Classes = append(in.Classes, o.(*GatewayClass)) })
list(c.gateways, func(o interface{}) { in.Gateways = append(in.Gateways, o.(*Gateway)) })
list(c.httpRoutes, func(o interface{}) { in.HTTPRoutes = append(in.HTTPRoutes, o.(*HTTPRoute)) })
list(c.tcpRoutes, func(o interface{}) { in.TCPRoutes = append(in.TCPRoutes, o.(*TCPRoute)) })
list(c.grants, func(o interface{}) { in.Grants = append(in.Grants, o.(*ReferenceGrant)) })
list(c.namespaces, func(o interface{}) { in.Namespaces = append(in.Namespaces, o.(*coreapi.Namespace)) })
for _, s := range c.secrets {
list(s, func(o interface{}) { in.Secrets = append(in.Secrets, o.(*coreapi.Secret)) })
}
for _, ns := range c.gwNamespaces {
in.SecretNamespaces[ns] = true
}
return in
}
// writeGatewayStatuses writes what the policy says this node writes. Each write is conditional on the version it
// was computed from: one built from a stale view fails, and the next pass recomputes it.
func (c *Controller) writeGatewayStatuses(ctx context.Context, g GatewayDesired) {
for _, w := range g.Classes {
if err := patchStatus(ctx, c.gateway, GatewayClasses, "", w.Name, w.ResourceVersion, w.Status); err != nil {
common.Log.Warnf("lb: status of GatewayClass %s: %v", w.Name, err)
continue
}
common.Log.Infof("lb: GatewayClass %s accepted", w.Name)
}
for _, w := range g.Gateways {
if err := patchStatus(ctx, c.gateway, Gateways, w.Namespace, w.Name, w.ResourceVersion, w.Status); err != nil {
common.Log.Warnf("lb: status of Gateway %s/%s: %v", w.Namespace, w.Name, err)
continue
}
var conds []string
for _, cd := range w.Status.Conditions {
conds = append(conds, fmt.Sprintf("%s=%s (%s)", cd.Type, cd.Status, cd.Reason))
}
common.Log.Infof("lb: Gateway %s/%s: %v", w.Namespace, w.Name, conds)
}
for _, w := range g.Routes {
if err := patchStatus(ctx, c.gateway, w.Resource, w.Namespace, w.Name, w.ResourceVersion, w.Status); err != nil {
common.Log.Warnf("lb: status of %s %s/%s: %v", w.Resource, w.Namespace, w.Name, err)
}
}
}
+340
View File
@@ -0,0 +1,340 @@
package lb
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"net"
"os/exec"
"strings"
"github.com/coreos/go-iptables/iptables"
"github.com/vishvananda/netlink"
"golang.org/x/sys/unix"
)
const (
// ForwardChain (nat, from PREROUTING) DNATs held addresses' ports to endpoints.
ForwardChain = "REWEAVE-LB"
// PostChain (nat, from POSTROUTING, ahead of weave's WEAVE) keeps the client's address on what ForwardChain DNATed.
PostChain = "REWEAVE-LB-POST"
// MarkChain (mangle, from PREROUTING) marks holders' connections and steers their replies.
MarkChain = "REWEAVE-LB-MARK"
// servicePrefix names the per-port chains ForwardChain jumps to.
servicePrefix = "REWEAVE-LBS-"
markShift = 24
markMask = uint32(0xff) << markShift
// RulePriority is where the reply-steering rules sit: before the main table (32766).
RulePriority = 9000
// TableBase + mark is the routing table that sends replies to that mark's holder.
TableBase = 52000
bridge = "weave"
)
func markValue(m uint32) uint32 { return m << markShift }
// portChain is the per-(service, protocol, port) chain name: stable, short enough for iptables (28 chars).
func portChain(f Forward, p Port) string {
h := sha256.Sum256([]byte(fmt.Sprintf("%s/%s/%s/%d", f.Namespace, f.Name, p.Protocol, p.Port)))
return servicePrefix + strings.ToUpper(hex.EncodeToString(h[:])[:12])
}
// Rules is the full content of the chains this package owns, in iptables-restore form.
type Rules struct {
Nat, Mangle, Filter string
}
// Render turns the desired state (load-balancer forwards and egress) into iptables-restore input for the nat,
// mangle and filter tables. podNet is the weave allocation range (pods and bridges).
// Each payload declares every chain it owns, so a restore with --noflush replaces exactly those.
func Render(d Desired, e EgressDesired, self string, podNet *net.IPNet, stale []string) Rules {
var nat bytes.Buffer
nat.WriteString("*nat\n")
for _, c := range []string{ForwardChain, PostChain, ClassChain} {
fmt.Fprintf(&nat, ":%s - [0:0]\n", c)
}
var body bytes.Buffer
seen := map[string]bool{}
for _, f := range d.Forwards {
a := f.Address.String()
if seen[a] {
continue
}
seen[a] = true
// weave masquerades everything entering the pod network from outside unless it is for a local pod
// (its expose rule); a connection we DNATed must keep the client's address, so it leaves the nat
// table here. One from a pod (hairpin) is masqueraded to this node, so its reply comes back here.
fmt.Fprintf(&body, "-A %s -m conntrack --ctstate DNAT --ctorigdst %s/32 -s %s -j MASQUERADE\n", PostChain, a, podNet)
fmt.Fprintf(&body, "-A %s -m conntrack --ctstate DNAT --ctorigdst %s/32 -j ACCEPT\n", PostChain, a)
}
for _, f := range d.Forwards {
for _, p := range f.Ports {
c := portChain(f, p)
fmt.Fprintf(&nat, ":%s - [0:0]\n", c)
proto := strings.ToLower(string(p.Protocol))
fmt.Fprintf(&body, "-A %s -d %s/32 -p %s -m %s --dport %d -m comment --comment %q -j %s\n",
ForwardChain, f.Address, proto, proto, p.Port, f.Namespace+"/"+f.Name, c)
n := len(p.Endpoints)
for i, e := range p.Endpoints {
// DNAT to an ip:port needs the protocol in the same rule
if i < n-1 {
// the i-th endpoint takes 1/(n-i) of what the earlier ones left: uniform overall
fmt.Fprintf(&body, "-A %s -p %s -m statistic --mode random --probability %.10f -j DNAT --to-destination %s\n",
c, proto, 1/float64(n-i), e)
} else {
fmt.Fprintf(&body, "-A %s -p %s -j DNAT --to-destination %s\n", c, proto, e)
}
}
}
}
for _, c := range stale {
// chains of ports no longer forwarded: emptied here, deleted after the restore
fmt.Fprintf(&nat, ":%s - [0:0]\n", c)
}
var mangle bytes.Buffer
mangle.WriteString("*mangle\n")
for _, c := range []string{MarkChain, MSSChain} {
fmt.Fprintf(&mangle, ":%s - [0:0]\n", c)
}
if len(d.Holders) > 0 {
// replies of a holder's connection carry its mark: the policy rule routes them back to it
fmt.Fprintf(&mangle, "-A %s -m conntrack --ctdir REPLY -m connmark ! --mark 0x0/0x%x -j CONNMARK --restore-mark --nfmask 0x%x --ctmask 0x%x\n",
MarkChain, markMask, markMask, markMask)
for _, h := range d.Holders {
// a new connection to a pod here, arriving from the holder's bridge, from outside the pod network:
// the holder DNATed it and must see its replies
fmt.Fprintf(&mangle, "-A %s -i %s -m mac --mac-source %s ! -s %s -d %s -m conntrack --ctstate NEW -m comment --comment %q -j CONNMARK --set-xmark 0x%x/0x%x\n",
MarkChain, bridge, h.Node.WeaveMAC, podNet, podNet, "holder "+h.Node.Name, markValue(h.Mark), markMask)
}
}
var filter bytes.Buffer
filter.WriteString("*filter\n")
for _, c := range []string{FwdChain, InChain} {
fmt.Fprintf(&filter, ":%s - [0:0]\n", c)
}
for _, a := range d.Accept {
// served by the agent itself (a Gateway's HTTP or HTTPS listener): in, whatever the host firewall says
fmt.Fprintf(&filter, "-A %s -d %s/32 -p tcp -m tcp --dport %d -m comment --comment %q -j ACCEPT\n", InChain, a.Address, a.Port, a.Comment)
}
renderEgress(e, self, podNet, &body, &mangle, &filter)
nat.Write(body.Bytes())
nat.WriteString("COMMIT\n")
mangle.WriteString("COMMIT\n")
filter.WriteString("COMMIT\n")
return Rules{Nat: nat.String(), Mangle: mangle.String(), Filter: filter.String()}
}
// Datapath applies Desired to this node.
type Datapath struct {
ipt *iptables.IPTables
// restore runs iptables-restore --noflush with the given input (swappable in tests)
restore func(input string) error
}
// NewDatapath uses the host's iptables (whichever backend the image's launch script selected).
func NewDatapath(ipt *iptables.IPTables) *Datapath {
return &Datapath{ipt: ipt, restore: func(input string) error {
cmd := exec.Command("iptables-restore", "--noflush", "-w", "5")
cmd.Stdin = strings.NewReader(input)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("iptables-restore: %v: %s", err, out)
}
return nil
}}
}
// Apply makes the node's iptables chains, tunnel, policy rules and routes exactly what d and e say.
func (dp *Datapath) Apply(d Desired, e EgressDesired, self string, local Local) error {
podNet := local.PodNet
if err := dp.hooks(); err != nil {
return err
}
// the tunnels must exist before the routes into them (iptables rules may name a device that does not exist yet)
var peers []Node
if e.Tunnelled(self) {
peers = e.Peers(self)
}
if err := ensureTunnels(peers, local); err != nil {
return err
}
existing, err := dp.ipt.ListChains("nat")
if err != nil {
return err
}
want := map[string]bool{}
for _, f := range d.Forwards {
for _, p := range f.Ports {
want[portChain(f, p)] = true
}
}
var stale []string
for _, c := range existing {
if strings.HasPrefix(c, servicePrefix) && !want[c] {
stale = append(stale, c)
}
}
r := Render(d, e, self, podNet, stale)
// filter first: the guards are in place before the marks and SNATs that they guard
for _, payload := range []string{r.Filter, r.Nat, r.Mangle} {
if err := dp.restore(payload); err != nil {
return err
}
}
for _, c := range stale {
if err := dp.ipt.DeleteChain("nat", c); err != nil {
return fmt.Errorf("deleting stale chain %s: %v", c, err)
}
}
if err := dp.routes(d.Holders); err != nil {
return err
}
return egressNet(e, self)
}
// hooks makes sure the built-in chains jump to ours: first (kube-proxy's and weave's rules must not see these
// connections first), except ClassChain, which must come after kube-proxy's KUBE-SERVICES (whose DNAT ends the
// nat walk, so a connection to a Service is never classed as going outside).
func (dp *Datapath) hooks() error {
for _, h := range []struct {
table, from, chain string
last bool
}{
{"nat", "PREROUTING", ForwardChain, false},
{"nat", "POSTROUTING", PostChain, false},
{"nat", "PREROUTING", ClassChain, true},
{"mangle", "PREROUTING", MarkChain, false},
{"mangle", "FORWARD", MSSChain, false},
{"filter", "FORWARD", FwdChain, false},
{"filter", "INPUT", InChain, false},
} {
ok, err := dp.ipt.ChainExists(h.table, h.chain)
if err != nil {
return err
}
if !ok {
if err := dp.ipt.NewChain(h.table, h.chain); err != nil {
return err
}
}
rule := []string{"-m", "comment", "--comment", "reweave lb", "-j", h.chain}
rules, err := dp.ipt.List(h.table, h.from)
if err != nil {
return err
}
if h.last {
ours, kube := -1, -1
for i, r := range rules {
if strings.HasSuffix(r, "-j "+h.chain) {
ours = i
}
if strings.HasSuffix(r, "-j KUBE-SERVICES") {
kube = i
}
}
if ours > kube {
continue
}
if err := dp.ipt.DeleteIfExists(h.table, h.from, rule...); err != nil {
return err
}
if err := dp.ipt.Append(h.table, h.from, rule...); err != nil {
return err
}
continue
}
// rules[0] is the policy line; our jump must be rules[1]
if len(rules) > 1 && strings.HasSuffix(rules[1], "-j "+h.chain) {
continue
}
if err := dp.ipt.DeleteIfExists(h.table, h.from, rule...); err != nil {
return err
}
if err := dp.ipt.Insert(h.table, h.from, 1, rule...); err != nil {
return err
}
}
return nil
}
// routes keeps one policy rule and one table per holder: marked replies go to the holder's bridge.
func (dp *Datapath) routes(holders []Holder) error {
link, err := netlink.LinkByName(bridge)
if err != nil {
if len(holders) == 0 {
return nil
}
return fmt.Errorf("weave bridge: %v", err)
}
want := map[int]Holder{}
for _, h := range holders {
want[TableBase+int(h.Mark)] = h
}
rules, err := netlink.RuleList(unix.AF_INET)
if err != nil {
return err
}
have := map[int]bool{}
for _, r := range rules {
if r.Priority != RulePriority || r.Table < TableBase || r.Table > TableBase+MaxMarks {
continue
}
h, ok := want[r.Table]
if ok && uint32(r.Mark) == markValue(h.Mark) && uint32(r.Mask) == markMask {
have[r.Table] = true
continue
}
r := r
if err := netlink.RuleDel(&r); err != nil {
return fmt.Errorf("removing stale rule to table %d: %v", r.Table, err)
}
}
existing, err := routesByTable(TableBase+1, TableBase+MaxMarks)
if err != nil {
return err
}
for table := TableBase + 1; table <= TableBase+MaxMarks; table++ {
h, ok := want[table]
if !ok {
for _, rt := range existing[table] {
if err := delRoute(rt); err != nil {
return fmt.Errorf("removing stale route in table %d: %v", table, err)
}
}
continue
}
if err := netlink.RouteReplace(&netlink.Route{
LinkIndex: link.Attrs().Index,
Dst: &net.IPNet{IP: net.IPv4zero, Mask: net.CIDRMask(0, 32)},
Gw: h.Node.WeaveIP,
Table: table,
Flags: int(netlink.FLAG_ONLINK),
}); err != nil {
return fmt.Errorf("route to holder %s: %v", h.Node.Name, err)
}
if !have[table] {
rule := netlink.NewRule()
rule.Priority = RulePriority
rule.Table = table
rule.Mark = int(markValue(h.Mark))
rule.Mask = int(markMask)
if err := netlink.RuleAdd(rule); err != nil {
return fmt.Errorf("rule for holder %s: %v", h.Node.Name, err)
}
}
}
return nil
}
// delRoute deletes a route as RouteList returned it: a default route lists without Dst, which RouteDel refuses.
func delRoute(rt netlink.Route) error {
if rt.Dst == nil {
rt.Dst = &net.IPNet{IP: net.IPv4zero, Mask: net.CIDRMask(0, 32)}
}
return netlink.RouteDel(&rt)
}
+340
View File
@@ -0,0 +1,340 @@
package lb
import (
"fmt"
"net"
"sort"
"strings"
coreapi "k8s.io/api/core/v1"
)
// Port blocks: every node gets its own source ports on an EgressService's address, so the holder can route a
// reply back to the member's node by port alone (no state on the holder). The range lies above the default
// ephemeral range (32768-60999), so it never meets the holder's own connections from the address.
const (
BlockBase = 61000
BlockSize = 512
BlockCount = 8 // 61000-65095
)
// Admission is a pod the CNI plugin announced at ADD, before the API shows its IP.
type Admission struct {
Namespace, Name string
}
// Member is one pod on this node that an EgressService selects, and where its outside traffic goes.
type Member struct {
IP net.IP
Pod string // namespace/name
Service string // namespace/name of its EgressService (empty if it is in several)
// Unreachable: its outside traffic is dropped (no holder, no block, several EgressServices, ...)
Unreachable bool
Address net.IP // it leaves from Address:[From, To]
From, To int32
Holder Node // the node that emits it; Holder.Name == self: emitted here
}
// BlockRoute is, on the holder, the replies to Address:[From, To] going back to Peer.
type BlockRoute struct {
Address net.IP
From, To int32
Peer Node
}
// EgressStatusWrite is a status this node writes (the holder, or the first node by name to clear it).
type EgressStatusWrite struct {
Namespace, Name string
Status EgressServiceStatus
}
// EgressDesired is this node's part of every EgressService.
type EgressDesired struct {
Members []Member
Routes []BlockRoute
Status []EgressStatusWrite
NodeAddrs []net.IP // the cluster's node addresses: members' traffic to them is not "outside"
Problems []string
}
// Tunnelled reports whether this node sends or receives anything over the egress tunnel.
func (e EgressDesired) Tunnelled(self string) bool {
if len(e.Routes) > 0 {
return true
}
for _, m := range e.Members {
if !m.Unreachable && m.Holder.Name != self {
return true
}
}
return false
}
// Peers are the nodes this node exchanges tunnelled packets with (sorted by name, deduplicated).
func (e EgressDesired) Peers(self string) []Node {
seen := map[string]Node{}
for _, r := range e.Routes {
seen[r.Peer.Name] = r.Peer
}
for _, m := range e.Members {
if !m.Unreachable && m.Holder.Name != self {
seen[m.Holder.Name] = m.Holder
}
}
out := make([]Node, 0, len(seen))
for _, n := range seen {
out = append(out, n)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// AllocateBlocks keeps every valid block a node already has and gives each node without one the lowest free
// block, in name order. A node beyond BlockCount gets none (its members are dropped, and that is reported).
func AllocateBlocks(current []PortBlock, nodeNames []string) []PortBlock {
present := map[string]bool{}
for _, n := range nodeNames {
present[n] = true
}
used := map[int]bool{}
have := map[string]PortBlock{}
for _, b := range current {
k := int(b.From-BlockBase) / BlockSize
valid := present[b.Node] && b.From >= BlockBase && int(b.From-BlockBase)%BlockSize == 0 &&
k < BlockCount && b.To == b.From+BlockSize-1
if !valid || used[k] {
continue
}
if _, dup := have[b.Node]; dup {
continue
}
used[k] = true
have[b.Node] = b
}
names := append([]string(nil), nodeNames...)
sort.Strings(names)
for _, n := range names {
if _, ok := have[n]; ok {
continue
}
for k := 0; k < BlockCount; k++ {
if !used[k] {
used[k] = true
from := int32(BlockBase + k*BlockSize)
have[n] = PortBlock{Node: n, From: from, To: from + BlockSize - 1}
break
}
}
}
out := make([]PortBlock, 0, len(have))
for _, b := range have {
out = append(out, b)
}
sort.Slice(out, func(i, j int) bool { return out[i].Node < out[j].Node })
return out
}
func sameStatus(a, b EgressServiceStatus) bool {
if a.Host != b.Host || a.Address != b.Address || len(a.Blocks) != len(b.Blocks) {
return false
}
for i := range a.Blocks {
if a.Blocks[i] != b.Blocks[i] {
return false
}
}
return true
}
// holderOf is S1's rule for a Service's address: the one node that holds spec.loadBalancerIP.
func holderOf(s *coreapi.Service, nodes []Node) (*Node, net.IP) {
want := net.ParseIP(s.Spec.LoadBalancerIP).To4()
if want == nil {
return nil, nil
}
var hs []Node
for _, n := range nodes {
if n.Holds(want) {
hs = append(hs, n)
}
}
if len(hs) != 1 {
return nil, want
}
return &hs[0], want
}
// selects reports whether a Service's selector (non-empty) matches the labels.
func selects(s *coreapi.Service, labels map[string]string) bool {
if len(s.Spec.Selector) == 0 {
return false
}
for k, v := range s.Spec.Selector {
if labels[k] != v {
return false
}
}
return true
}
// ComputeEgress decides this node's part of every EgressService: the statuses it writes, the members it hosts and
// where their traffic goes, and (as a holder) where replies to each node's port block go.
// pods are this node's pods; admitted maps an IP to the pod the CNI plugin gave it (newer than the API may show).
func ComputeEgress(self string, nodes []Node, services []*coreapi.Service, egresses []*EgressService,
pods []*coreapi.Pod, admitted map[string]Admission) EgressDesired {
var d EgressDesired
sorted := append([]Node(nil), nodes...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
byName := map[string]Node{}
var names []string
for _, n := range sorted {
byName[n.Name] = n
names = append(names, n.Name)
d.NodeAddrs = append(d.NodeAddrs, n.Addresses...)
}
svcs := map[string]*coreapi.Service{}
for _, s := range services {
svcs[s.Namespace+"/"+s.Name] = s
}
es := append([]*EgressService(nil), egresses...)
sort.Slice(es, func(i, j int) bool { return es[i].Namespace+"/"+es[i].Name < es[j].Namespace+"/"+es[j].Name })
// valid[key]: the EgressService is in force: its Service is served from an address one node holds, and the
// holder has published that (status) - what members and holder both act on
valid := map[string]bool{}
for _, e := range es {
key := e.Namespace + "/" + e.Name
var want EgressServiceStatus
var holder *Node
var addr net.IP
s := svcs[key]
switch {
case e.Spec.Emit != EmitHolder:
d.Problems = append(d.Problems, fmt.Sprintf("egress %s: spec.emit must be %q (the only mode so far), got %q", key, EmitHolder, e.Spec.Emit))
case e.Spec.SourceIPBy != "" && e.Spec.SourceIPBy != SourceIPByLoadBalancerIP:
d.Problems = append(d.Problems, fmt.Sprintf("egress %s: spec.sourceIPBy must be %q, got %q", key, SourceIPByLoadBalancerIP, e.Spec.SourceIPBy))
case s == nil:
d.Problems = append(d.Problems, fmt.Sprintf("egress %s: no Service %s", key, key))
case !eligible(s):
d.Problems = append(d.Problems, fmt.Sprintf("egress %s: Service %s is not a LoadBalancer of class %s", key, key, Class))
default:
holder, addr = holderOf(s, sorted)
if holder == nil {
d.Problems = append(d.Problems, fmt.Sprintf("egress %s: no single node holds %s; members' outside traffic is dropped", key, s.Spec.LoadBalancerIP))
}
}
writer := ""
if holder != nil {
want = EgressServiceStatus{Host: holder.Name, Address: addr.String(), Blocks: AllocateBlocks(e.Status.Blocks, names)}
writer = holder.Name
if len(want.Blocks) < len(names) {
d.Problems = append(d.Problems, fmt.Sprintf("egress %s: only %d port blocks for %d nodes; members on nodes without one are dropped", key, BlockCount, len(names)))
}
} else if len(names) > 0 {
writer = names[0]
}
if writer == self && !sameStatus(e.Status, want) {
d.Status = append(d.Status, EgressStatusWrite{Namespace: e.Namespace, Name: e.Name, Status: want})
}
// in force only once the status in the API says what holds now (members and holder read the same thing)
if holder != nil && e.Status.Host == holder.Name && e.Status.Address == addr.String() {
valid[key] = true
if holder.Name == self {
for _, b := range e.Status.Blocks {
if b.Node == self {
continue
}
peer, ok := byName[b.Node]
if !ok || peer.WeaveIP == nil {
d.Problems = append(d.Problems, fmt.Sprintf("egress %s: node %s has no weave bridge published; its members' replies cannot reach it", key, b.Node))
continue
}
d.Routes = append(d.Routes, BlockRoute{Address: addr, From: b.From, To: b.To, Peer: peer})
}
}
}
}
// this node's pods, one per IP: the CNI plugin's latest word wins, then live pods over terminating ones
type candidate struct {
pod *coreapi.Pod
ip string
}
byIP := map[string]*coreapi.Pod{}
podByKey := map[string]*coreapi.Pod{}
for _, p := range pods {
podByKey[p.Namespace+"/"+p.Name] = p
}
var cands []candidate
for _, p := range pods {
if p.Spec.NodeName != self || p.Spec.HostNetwork || p.Status.Phase == coreapi.PodSucceeded || p.Status.Phase == coreapi.PodFailed {
continue
}
if ip := net.ParseIP(p.Status.PodIP).To4(); ip != nil {
cands = append(cands, candidate{p, ip.String()})
}
}
for ip, a := range admitted {
if p := podByKey[a.Namespace+"/"+a.Name]; p != nil && p.Spec.NodeName == self && !p.Spec.HostNetwork {
cands = append(cands, candidate{p, ip})
}
}
for _, c := range cands {
if a, ok := admitted[c.ip]; ok && (a.Namespace != c.pod.Namespace || a.Name != c.pod.Name) {
continue // the IP was given to another pod since
}
cur := byIP[c.ip]
if cur == nil || (cur.DeletionTimestamp != nil && c.pod.DeletionTimestamp == nil) ||
(cur.DeletionTimestamp == nil) == (c.pod.DeletionTimestamp == nil) && c.pod.CreationTimestamp.After(cur.CreationTimestamp.Time) {
byIP[c.ip] = c.pod
}
}
ips := make([]string, 0, len(byIP))
for ip := range byIP {
ips = append(ips, ip)
}
sort.Strings(ips)
for _, ip := range ips {
p := byIP[ip]
var in []*EgressService
for _, e := range es {
if e.Namespace != p.Namespace {
continue
}
if s := svcs[e.Namespace+"/"+e.Name]; s != nil && selects(s, p.Labels) {
in = append(in, e)
}
}
if len(in) == 0 {
continue
}
m := Member{IP: net.ParseIP(ip).To4(), Pod: p.Namespace + "/" + p.Name, Unreachable: true}
if len(in) > 1 {
keys := make([]string, len(in))
for i, e := range in {
keys[i] = e.Namespace + "/" + e.Name
}
d.Problems = append(d.Problems, fmt.Sprintf("pod %s is selected by several EgressServices (%s); its outside traffic is dropped", m.Pod, strings.Join(keys, ", ")))
d.Members = append(d.Members, m)
continue
}
e := in[0]
m.Service = e.Namespace + "/" + e.Name
if valid[m.Service] {
holder := byName[e.Status.Host]
for _, b := range e.Status.Blocks {
if b.Node == self && (holder.Name == self || holder.WeaveIP != nil) {
m.Unreachable = false
m.Address = net.ParseIP(e.Status.Address).To4()
m.From, m.To = b.From, b.To
m.Holder = holder
}
}
if m.Unreachable {
d.Problems = append(d.Problems, fmt.Sprintf("pod %s: egress %s has no port block for %s (or its holder's bridge is unknown); its outside traffic is dropped", m.Pod, m.Service, self))
}
}
d.Members = append(d.Members, m)
}
return d
}
+144
View File
@@ -0,0 +1,144 @@
package lb
import (
"context"
"encoding/json"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/runtime/serializer"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/rest"
)
// EgressService (reweave.levk.dev/v1alpha1) makes the pods a LoadBalancer Service selects leave the cluster
// from the Service's address. It is named after the Service (same namespace, same name). Its semantics follow
// OVN-Kubernetes' EgressService (k8s.ovn.org/v1, sourceIPBy: LoadBalancerIP) where they agree; they differ in
// that membership is every pod the Service's selector matches (by pod IP, never by readiness), the holder is
// the node that holds the address (no election), and a member's traffic to the outside leaves as the address
// or not at all.
type EgressService struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec EgressServiceSpec `json:"spec"`
Status EgressServiceStatus `json:"status,omitempty"`
}
// EgressServiceSpec is what the operator asks for.
type EgressServiceSpec struct {
// SourceIPBy is LoadBalancerIP: members leave from the Service's load-balancer address (the only mode in v1).
SourceIPBy string `json:"sourceIPBy,omitempty"`
// Emit is where members' packets leave the cluster. Explicit, no default: Holder (tunnelled to the node
// holding the address, which emits them) is the only mode in v1.
Emit string `json:"emit"`
}
// EgressServiceStatus is written by the holder alone.
type EgressServiceStatus struct {
// Host is the node that holds the address and emits members' packets.
Host string `json:"host,omitempty"`
// Address is the address members leave from.
Address string `json:"address,omitempty"`
// Blocks gives every node its own source ports on Address, so the holder routes replies back by port.
Blocks []PortBlock `json:"blocks,omitempty"`
}
// PortBlock is the source-port range [From, To] on the address that one node's members use.
type PortBlock struct {
Node string `json:"node"`
From int32 `json:"from"`
To int32 `json:"to"`
}
// EgressServiceList is a list of EgressServices.
type EgressServiceList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []EgressService `json:"items"`
}
const (
// Group and Version of the EgressService API.
Group = "reweave.levk.dev"
Version = "v1alpha1"
// EgressServices is the resource name.
EgressServices = "egressservices"
SourceIPByLoadBalancerIP = "LoadBalancerIP"
EmitHolder = "Holder"
)
// GroupVersion of the EgressService API.
var GroupVersion = schema.GroupVersion{Group: Group, Version: Version}
// DeepCopyObject implements runtime.Object.
func (e *EgressService) DeepCopyObject() runtime.Object { return e.DeepCopy() }
// DeepCopy copies the whole object.
func (e *EgressService) DeepCopy() *EgressService {
if e == nil {
return nil
}
out := *e
e.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
out.Status.Blocks = append([]PortBlock(nil), e.Status.Blocks...)
return &out
}
// DeepCopyObject implements runtime.Object.
func (l *EgressServiceList) DeepCopyObject() runtime.Object {
if l == nil {
return nil
}
out := *l
l.ListMeta.DeepCopyInto(&out.ListMeta)
out.Items = make([]EgressService, len(l.Items))
for i := range l.Items {
out.Items[i] = *l.Items[i].DeepCopy()
}
return &out
}
func addKnownTypes(s *runtime.Scheme) error {
s.AddKnownTypes(GroupVersion, &EgressService{}, &EgressServiceList{})
metav1.AddToGroupVersion(s, GroupVersion)
return nil
}
// NewEgressClient is a REST client for the EgressService API (no generated clientset: two plain types).
func NewEgressClient(base *rest.Config) (*rest.RESTClient, error) {
scheme := runtime.NewScheme()
if err := addKnownTypes(scheme); err != nil {
return nil, err
}
cfg := *base
cfg.GroupVersion = &GroupVersion
cfg.APIPath = "/apis"
cfg.ContentType = runtime.ContentTypeJSON
cfg.NegotiatedSerializer = serializer.NewCodecFactory(scheme).WithoutConversion()
if cfg.UserAgent == "" {
cfg.UserAgent = rest.DefaultKubernetesUserAgent()
}
return rest.RESTClientFor(&cfg)
}
// PatchEgressStatus merge-patches an EgressService's status (nil fields are removed).
func PatchEgressStatus(ctx context.Context, c rest.Interface, namespace, name string, st EgressServiceStatus) error {
body := map[string]interface{}{"host": nil, "address": nil, "blocks": nil}
if st.Host != "" {
body["host"] = st.Host
}
if st.Address != "" {
body["address"] = st.Address
}
if len(st.Blocks) > 0 {
body["blocks"] = st.Blocks
}
patch, err := json.Marshal(map[string]interface{}{"status": body})
if err != nil {
return err
}
return c.Patch(types.MergePatchType).Namespace(namespace).Resource(EgressServices).Name(name).
SubResource("status").Body(patch).Do(ctx).Error()
}
+530
View File
@@ -0,0 +1,530 @@
package lb
import (
"bytes"
"fmt"
"hash/fnv"
"net"
"os"
"sort"
"strings"
"github.com/vishvananda/netlink"
"golang.org/x/sys/unix"
)
const (
// ClassChain (nat, appended to PREROUTING after kube-proxy's KUBE-SERVICES) marks members' new connections to
// the outside. kube-proxy's DNAT ends the nat walk, so a connection to a Service never gets here.
ClassChain = "REWEAVE-LB-CLASS"
// FwdChain (filter, first in FORWARD) drops members' outside packets that would not leave as their address.
FwdChain = "REWEAVE-LB-FWD"
// InChain (filter, first in INPUT) accepts the egress tunnels only from the cluster's nodes.
InChain = "REWEAVE-LB-IN"
// MSSChain (mangle, first in FORWARD) clamps TCP MSS into the tunnels.
MSSChain = "REWEAVE-LB-MSS"
// TunnelPrefix names the egress tunnels: one ipip device per peer node, between the two nodes' weave bridges,
// so a holder knows which node a member's packet came from by the device it arrives on.
TunnelPrefix = "rweg-"
// tunnelGlob matches every egress tunnel in an iptables rule.
tunnelGlob = "rweg+"
// legacyTunnel is the single shared tunnel of earlier builds (removed when found).
legacyTunnel = "reweave-eg"
// EgressMark marks a member's packets to the outside (connmark on its connections, fwmark on each packet).
EgressMark = uint32(0x100000)
// peerFlag, with a peer's index in the top byte, tags (on the holder) a connection that arrived from that peer's
// tunnel: replies and related ICMP errors carry it back there.
peerFlag = uint32(0x200000)
peerMask = markMask | peerFlag
// HolderRulePriority: replies to a block go to its node; ahead of the local table, which moves to LocalRulePriority,
// since the address is usually local on the holder.
HolderRulePriority = 90
LocalRulePriority = 100
// MemberRulePriority: a member's marked packets go to its holder (or nowhere).
MemberRulePriority = 9100
// EgressTableBase + peer index routes into that peer's tunnel; EgressTableBase itself is unreachable.
EgressTableBase = 53000
ipipOverhead = 20
)
// portProtocols carry ports: their replies are routed by port block alone (stateless), and members' SNAT keeps
// within the node's block. Everything else is routed back by the tag the holder put on the connection.
var portProtocols = []struct {
name string
num int
}{{"tcp", unix.IPPROTO_TCP}, {"udp", unix.IPPROTO_UDP}, {"sctp", unix.IPPROTO_SCTP}}
// TunnelName is the egress tunnel towards a peer node (stable, within the 15 characters a device name may have).
func TunnelName(peer string) string {
h := fnv.New32a()
h.Write([]byte(peer))
return fmt.Sprintf("%s%08x", TunnelPrefix, h.Sum32())
}
func peerTag(idx uint32) uint32 { return markValue(idx) | peerFlag }
// renderEgress appends the egress rules of each table to the bodies of S1's payloads.
func renderEgress(e EgressDesired, self string, podNet *net.IPNet, nat, mangle, filter *bytes.Buffer) {
members := e.Members
if len(members) > 0 {
// what is not "outside": the pod network, multicast, the cluster's nodes
fmt.Fprintf(nat, "-A %s -d %s -j RETURN\n", ClassChain, podNet)
fmt.Fprintf(nat, "-A %s -d 224.0.0.0/4 -j RETURN\n", ClassChain)
seen := map[string]bool{}
for _, a := range e.NodeAddrs {
if !seen[a.String()] {
seen[a.String()] = true
fmt.Fprintf(nat, "-A %s -d %s/32 -j RETURN\n", ClassChain, a)
}
}
for _, m := range members {
fmt.Fprintf(nat, "-A %s -s %s/32 -m comment --comment %q -j CONNMARK --set-xmark 0x%x/0x%x\n", ClassChain, m.IP, m.Pod, EgressMark, EgressMark)
fmt.Fprintf(nat, "-A %s -s %s/32 -j MARK --set-xmark 0x%x/0x%x\n", ClassChain, m.IP, EgressMark, EgressMark)
}
// later packets of a member's outside connection carry the mark too (the nat table saw only the first)
fmt.Fprintf(mangle, "-A %s -m conntrack --ctdir ORIGINAL -m connmark --mark 0x%x/0x%x -j MARK --set-xmark 0x%x/0x%x\n",
MarkChain, EgressMark, EgressMark, EgressMark, EgressMark)
}
for _, m := range members {
if m.Unreachable {
fmt.Fprintf(filter, "-A %s -s %s/32 -m mark --mark 0x%x/0x%x -m comment --comment %q -j DROP\n", FwdChain, m.IP, EgressMark, EgressMark, m.Pod)
continue
}
out := "-o " + TunnelName(m.Holder.Name)
if m.Holder.Name == self {
// emitted here, from the address: never into the pod network unSNATed
out = "! -o " + bridge
fmt.Fprintf(filter, "-A %s -s %s/32 -o %s -m mark --mark 0x%x/0x%x -j DROP\n", FwdChain, m.IP, bridge, EgressMark, EgressMark)
}
// within this node's block for what has ports; the address alone for the rest (the holder tells those
// apart by the tunnel they arrive on)
for _, p := range portProtocols {
fmt.Fprintf(nat, "-A %s -s %s/32 %s -m mark --mark 0x%x/0x%x -p %s -j SNAT --to-source %s:%d-%d\n",
PostChain, m.IP, out, EgressMark, EgressMark, p.name, m.Address, m.From, m.To)
}
// ICMP queries (ping) too: NAT treats the echo identifier as a port, and without a block of their own two
// nodes' pods (whose small, alike PIDs make alike identifiers) collide on the holder
fmt.Fprintf(nat, "-A %s -s %s/32 %s -m mark --mark 0x%x/0x%x -p icmp -j SNAT --to-source %s:%d-%d\n",
PostChain, m.IP, out, EgressMark, EgressMark, m.Address, m.From, m.To)
fmt.Fprintf(nat, "-A %s -s %s/32 %s -m mark --mark 0x%x/0x%x -j SNAT --to-source %s\n",
PostChain, m.IP, out, EgressMark, EgressMark, m.Address)
fmt.Fprintf(filter, "-A %s -s %s/32 %s -m mark --mark 0x%x/0x%x -j RETURN\n", FwdChain, m.IP, out, EgressMark, EgressMark)
// a member's outside traffic leaves no other way
fmt.Fprintf(filter, "-A %s -s %s/32 -m mark --mark 0x%x/0x%x -m comment --comment %q -j DROP\n", FwdChain, m.IP, EgressMark, EgressMark, m.Pod)
}
if !e.Tunnelled(self) {
return
}
tags := peerIndex(e, self)
// as holder: tag each connection arriving from a peer's tunnel with that peer, once; replies and related ICMP
// errors restore the tag, and the policy rule for it sends them back into that tunnel
byPeer := map[string][]BlockRoute{}
var peerNames []string
for _, r := range e.Routes {
if _, ok := byPeer[r.Peer.Name]; !ok {
peerNames = append(peerNames, r.Peer.Name)
}
byPeer[r.Peer.Name] = append(byPeer[r.Peer.Name], r)
}
sort.Strings(peerNames)
if len(peerNames) > 0 {
fmt.Fprintf(mangle, "-A %s -m conntrack --ctdir REPLY -m connmark --mark 0x%x/0x%x -j CONNMARK --restore-mark --nfmask 0x%x --ctmask 0x%x\n",
MarkChain, peerFlag, peerFlag, peerMask, peerMask)
}
for _, name := range peerNames {
dev, tag := TunnelName(name), peerTag(tags[name])
fmt.Fprintf(mangle, "-A %s -i %s -m conntrack --ctstate NEW -m connmark --mark 0x0/0x%x -m comment --comment %q -j CONNMARK --set-xmark 0x%x/0x%x\n",
MarkChain, dev, peerMask, "from "+name, tag, peerMask)
// a connection another node already holds (a port-less protocol to the same remote): never cross-delivered
fmt.Fprintf(filter, "-A %s -i %s -m conntrack --ctdir ORIGINAL -m connmark ! --mark 0x%x/0x%x -j DROP\n", FwdChain, dev, tag, peerMask)
addrs := map[string]bool{}
for _, r := range byPeer[name] {
for _, p := range portProtocols {
fmt.Fprintf(filter, "-A %s -i %s -m conntrack --ctdir ORIGINAL -s %s/32 -p %s -m %s --sport %d:%d -m comment --comment %q -j RETURN\n",
FwdChain, dev, r.Address, p.name, p.name, r.From, r.To, "block of "+name)
}
addrs[r.Address.String()] = true
}
for _, a := range sortedSet(addrs) {
// outside its own block a node may not use the ports of others
for _, p := range portProtocols {
fmt.Fprintf(filter, "-A %s -i %s -m conntrack --ctdir ORIGINAL -s %s/32 -p %s -j DROP\n", FwdChain, dev, a, p.name)
}
fmt.Fprintf(filter, "-A %s -i %s -m conntrack --ctdir ORIGINAL -s %s/32 -j RETURN\n", FwdChain, dev, a)
}
}
// as a member's node: replies (and related errors) from the holder; nothing else comes back
addrs := map[string]bool{}
for _, m := range members {
if !m.Unreachable && m.Holder.Name != self {
addrs[m.Address.String()] = true
}
}
for _, a := range sortedSet(addrs) {
// still addressed to the address after nat: no member connection matched it
fmt.Fprintf(filter, "-A %s -i %s -d %s/32 -j DROP\n", FwdChain, tunnelGlob, a)
}
fmt.Fprintf(filter, "-A %s -i %s -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN\n", FwdChain, tunnelGlob)
fmt.Fprintf(filter, "-A %s -i %s -j DROP\n", FwdChain, tunnelGlob)
// the tunnels themselves only from the cluster's nodes' bridges
for _, p := range e.Peers(self) {
fmt.Fprintf(filter, "-A %s -p 4 -s %s/32 -m mac --mac-source %s -j RETURN\n", InChain, p.WeaveIP, p.WeaveMAC)
}
fmt.Fprintf(filter, "-A %s -p 4 -j DROP\n", InChain)
fmt.Fprintf(mangle, "-A %s -o %s -p tcp -m tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu\n", MSSChain, tunnelGlob)
}
func sortedSet(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// peerIndex gives each tunnel peer a stable index (1..MaxMarks): its tag and its routing table.
func peerIndex(e EgressDesired, self string) map[string]uint32 {
out := map[string]uint32{}
for _, h := range assignMarks(e.Peers(self)) {
out[h.Node.Name] = h.Mark
}
return out
}
// peerTables gives each tunnel peer its routing table (EgressTableBase + its index).
func peerTables(e EgressDesired, self string) map[string]int {
out := map[string]int{}
for name, idx := range peerIndex(e, self) {
out[name] = EgressTableBase + int(idx)
}
return out
}
// egressRule is a policy rule this package owns, in a comparable form.
type egressRule struct {
priority int
src, dst string
ipproto int
from, to uint16
mark, mask uint32
table int
}
func (r egressRule) key() string {
return fmt.Sprintf("%d|%s|%s|%d|%d-%d|%x/%x|%d", r.priority, r.src, r.dst, r.ipproto, r.from, r.to, r.mark, r.mask, r.table)
}
// wantRules is every egress policy rule this node needs.
func wantRules(e EgressDesired, self string) []egressRule {
tables := peerTables(e, self)
tags := peerIndex(e, self)
var out []egressRule
tagged := map[string]bool{}
for _, r := range e.Routes {
for _, p := range portProtocols {
out = append(out, egressRule{priority: HolderRulePriority, dst: r.Address.String() + "/32", ipproto: p.num,
from: uint16(r.From), to: uint16(r.To), table: tables[r.Peer.Name]})
}
if !tagged[r.Peer.Name] {
// whatever the tag says came from that peer (replies without ports, ICMP errors) goes back to it
tagged[r.Peer.Name] = true
out = append(out, egressRule{priority: HolderRulePriority, mark: peerTag(tags[r.Peer.Name]), mask: peerMask, table: tables[r.Peer.Name]})
}
}
for _, m := range e.Members {
if !m.Unreachable && m.Holder.Name == self {
continue // emitted here: the main table already sends it out
}
table := EgressTableBase
if !m.Unreachable {
table = tables[m.Holder.Name]
}
out = append(out, egressRule{priority: MemberRulePriority, src: m.IP.String() + "/32", mark: EgressMark, mask: EgressMark, table: table})
}
sort.Slice(out, func(i, j int) bool { return out[i].key() < out[j].key() })
return out
}
func ruleOf(r netlink.Rule) egressRule {
er := egressRule{priority: r.Priority, ipproto: r.IPProto, table: r.Table}
if r.Src != nil {
er.src = r.Src.String()
}
if r.Dst != nil {
er.dst = r.Dst.String()
}
if r.Dport != nil {
er.from, er.to = r.Dport.Start, r.Dport.End
}
if r.Mark > 0 {
er.mark = uint32(r.Mark)
er.mask = uint32(r.Mask)
}
if er.ipproto < 0 {
er.ipproto = 0
}
return er
}
// routesByTable lists, in one dump, the IPv4 routes of tables lo..hi.
func routesByTable(lo, hi int) (map[int][]netlink.Route, error) {
all, err := netlink.RouteListFiltered(unix.AF_INET, &netlink.Route{Table: unix.RT_TABLE_UNSPEC}, netlink.RT_FILTER_TABLE)
if err != nil {
return nil, err
}
out := map[int][]netlink.Route{}
for _, rt := range all {
if rt.Table >= lo && rt.Table <= hi {
out[rt.Table] = append(out[rt.Table], rt)
}
}
return out, nil
}
// egressNet makes the local rule's place, the policy rules and the tunnel tables exactly what e needs (the
// tunnels themselves are made first, by Apply).
func egressNet(e EgressDesired, self string) error {
if len(e.Routes) > 0 {
if err := moveLocalRule(); err != nil {
return err
}
}
tables := peerTables(e, self)
peerOf := map[int]Node{}
for _, p := range e.Peers(self) {
peerOf[tables[p.Name]] = p
}
unreachable := false
for _, m := range e.Members {
unreachable = unreachable || m.Unreachable
}
existing, err := routesByTable(EgressTableBase, EgressTableBase+MaxMarks)
if err != nil {
return err
}
for table := EgressTableBase; table <= EgressTableBase+MaxMarks; table++ {
var want *netlink.Route
if p, ok := peerOf[table]; ok {
link, err := netlink.LinkByName(TunnelName(p.Name))
if err != nil {
return fmt.Errorf("egress tunnel to %s: %v", p.Name, err)
}
want = &netlink.Route{LinkIndex: link.Attrs().Index, Dst: &net.IPNet{IP: net.IPv4zero, Mask: net.CIDRMask(0, 32)},
Scope: netlink.SCOPE_LINK, Table: table}
} else if table == EgressTableBase && unreachable {
want = &netlink.Route{Dst: &net.IPNet{IP: net.IPv4zero, Mask: net.CIDRMask(0, 32)}, Table: table, Type: unix.RTN_UNREACHABLE}
}
keep := false
for _, rt := range existing[table] {
if want != nil && rt.Type == want.Type &&
(want.Type == unix.RTN_UNREACHABLE || rt.LinkIndex == want.LinkIndex && rt.Gw == nil) {
keep = true
continue
}
if err := delRoute(rt); err != nil {
return fmt.Errorf("removing stale route in table %d: %v", table, err)
}
}
if want != nil && !keep {
if err := netlink.RouteReplace(want); err != nil {
return fmt.Errorf("route in table %d: %v", table, err)
}
}
}
want := map[string]egressRule{}
for _, r := range wantRules(e, self) {
want[r.key()] = r
}
rules, err := netlink.RuleList(unix.AF_INET)
if err != nil {
return err
}
have := map[string]bool{}
for _, r := range rules {
if r.Priority != HolderRulePriority && r.Priority != MemberRulePriority {
continue
}
k := ruleOf(r).key()
if _, ok := want[k]; ok && !have[k] {
have[k] = true
continue
}
r := r
if err := netlink.RuleDel(&r); err != nil {
return fmt.Errorf("removing stale egress rule: %v", err)
}
}
for k, r := range want {
if have[k] {
continue
}
rule := netlink.NewRule()
rule.Priority = r.priority
rule.Table = r.table
if r.src != "" {
_, rule.Src, _ = net.ParseCIDR(r.src)
}
if r.dst != "" {
_, rule.Dst, _ = net.ParseCIDR(r.dst)
}
if r.ipproto != 0 {
rule.IPProto = r.ipproto
rule.Dport = netlink.NewRulePortRange(r.from, r.to)
}
if r.mark != 0 {
rule.Mark = int(r.mark)
rule.Mask = int(r.mask)
}
if err := netlink.RuleAdd(rule); err != nil {
return fmt.Errorf("adding egress rule %s: %v", k, err)
}
}
return nil
}
// moveLocalRule puts the local table's lookup after HolderRulePriority (it is at 0 by default), so the holder can
// route replies to its own address onwards to their node instead of delivering them to itself.
func moveLocalRule() error {
rules, err := netlink.RuleList(unix.AF_INET)
if err != nil {
return err
}
var at0, atLocal bool
for _, r := range rules {
if r.Table == unix.RT_TABLE_LOCAL && r.Src == nil && r.Dst == nil && r.Mark <= 0 {
switch {
case r.Priority <= 0:
// the kernel leaves out the priority attribute when it is 0: listed as unset (-1)
at0 = true
case r.Priority == LocalRulePriority:
atLocal = true
}
}
}
if !atLocal {
rule := netlink.NewRule()
rule.Priority = LocalRulePriority
rule.Table = unix.RT_TABLE_LOCAL
if err := netlink.RuleAdd(rule); err != nil {
return fmt.Errorf("adding local table rule at %d: %v", LocalRulePriority, err)
}
}
if at0 {
rule := netlink.NewRule()
rule.Priority = 0
rule.Table = unix.RT_TABLE_LOCAL
if err := netlink.RuleDel(rule); err != nil {
return fmt.Errorf("removing local table rule at 0: %v", err)
}
}
return nil
}
// ensureTunnels makes one ipip tunnel per peer (this node's bridge address to the peer's), and removes every
// other egress tunnel (including the shared one of earlier builds).
func ensureTunnels(peers []Node, local Local) error {
links, err := netlink.LinkList()
if err != nil {
return err
}
want := map[string]Node{}
for _, p := range peers {
want[TunnelName(p.Name)] = p
}
have := map[string]netlink.Link{}
for _, l := range links {
name := l.Attrs().Name
if name == legacyTunnel || strings.HasPrefix(name, TunnelPrefix) {
if _, ok := want[name]; ok {
have[name] = l
continue
}
if err := netlink.LinkDel(l); err != nil {
return fmt.Errorf("removing egress tunnel %s: %v", name, err)
}
}
}
if len(want) == 0 {
return nil
}
if local.WeaveIP == nil {
return fmt.Errorf("egress tunnels need the weave bridge's address")
}
br, err := netlink.LinkByName(bridge)
if err != nil {
return fmt.Errorf("weave bridge: %v", err)
}
mtu := br.Attrs().MTU - ipipOverhead
names := make([]string, 0, len(want))
for n := range want {
names = append(names, n)
}
sort.Strings(names)
for _, name := range names {
peer := want[name]
link := have[name]
if link != nil {
t, ok := link.(*netlink.Iptun)
if !ok || !t.Local.Equal(local.WeaveIP) || !t.Remote.Equal(peer.WeaveIP) || t.Attrs().MTU != mtu {
if err := netlink.LinkDel(link); err != nil {
return err
}
link = nil
}
}
if link == nil {
t := &netlink.Iptun{LinkAttrs: netlink.LinkAttrs{Name: name, MTU: mtu}, Local: local.WeaveIP, Remote: peer.WeaveIP, PMtuDisc: 1}
if err := netlink.LinkAdd(t); err != nil {
return fmt.Errorf("creating egress tunnel %s to %s: %v", name, peer.Name, err)
}
if link, err = netlink.LinkByName(name); err != nil {
return err
}
}
if err := tunnelSetup(link, local); err != nil {
return fmt.Errorf("%s: %v", name, err)
}
}
return nil
}
// tunnelSetup: the holder receives members' packets from its own address (accept_local); reverse-path filtering
// rejects what arrives on a device with no IPv4 address unless the reverse route leaves by that very device, so
// the tunnel carries this node's bridge address (never on the wire from here: the tunnel sets the outer source,
// and what crosses it is forwarded).
func tunnelSetup(link netlink.Link, local Local) error {
name := link.Attrs().Name
for k, v := range map[string]string{"accept_local": "1", "rp_filter": "2"} {
if err := os.WriteFile("/proc/sys/net/ipv4/conf/"+name+"/"+k, []byte(v), 0o644); err != nil {
return fmt.Errorf("sysctl %s: %v", k, err)
}
}
addrs, err := netlink.AddrList(link, unix.AF_INET)
if err != nil {
return err
}
has := false
for _, a := range addrs {
if a.IP.Equal(local.WeaveIP) {
has = true
continue
}
a := a
if err := netlink.AddrDel(link, &a); err != nil {
return fmt.Errorf("removing stale address %s: %v", a.IPNet, err)
}
}
if !has {
addr := &netlink.Addr{IPNet: &net.IPNet{IP: local.WeaveIP, Mask: net.CIDRMask(32, 32)}, Scope: unix.RT_SCOPE_LINK}
if err := netlink.AddrAdd(link, addr); err != nil {
return fmt.Errorf("address: %v", err)
}
}
return netlink.LinkSetUp(link)
}
+355
View File
@@ -0,0 +1,355 @@
package lb
import (
"fmt"
"net"
"strings"
"testing"
"time"
coreapi "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// the S1 fixtures: cp, w1 (holds 86.107.168.15), w2
func egressFor(name string, status EgressServiceStatus) *EgressService {
return &EgressService{ObjectMeta: metav1.ObjectMeta{Namespace: "gitea", Name: name},
Spec: EgressServiceSpec{Emit: EmitHolder}, Status: status}
}
func mailSvc() *coreapi.Service {
return svc("mail", "86.107.168.15", func(s *coreapi.Service) {
s.Spec.Selector = map[string]string{"app": "mail"}
})
}
func pod(name, node, ip string, labels map[string]string, mutate ...func(*coreapi.Pod)) *coreapi.Pod {
p := &coreapi.Pod{ObjectMeta: metav1.ObjectMeta{Namespace: "gitea", Name: name, Labels: labels,
CreationTimestamp: metav1.NewTime(time.Unix(1000, 0))},
Spec: coreapi.PodSpec{NodeName: node}, Status: coreapi.PodStatus{PodIP: ip, Phase: coreapi.PodRunning}}
for _, m := range mutate {
m(p)
}
return p
}
var mailLabels = map[string]string{"app": "mail"}
// the status the holder (w1) writes for three nodes, blocks in name order
func written() EgressServiceStatus {
return EgressServiceStatus{Host: "w1", Address: "86.107.168.15", Blocks: []PortBlock{
{Node: "cp", From: 61000, To: 61511}, {Node: "w1", From: 61512, To: 62023}, {Node: "w2", From: 62024, To: 62535}}}
}
func TestBlocksAreStableAndCapped(t *testing.T) {
got := AllocateBlocks(nil, []string{"w2", "cp", "w1"})
if !sameStatus(EgressServiceStatus{Blocks: got}, EgressServiceStatus{Blocks: written().Blocks}) {
t.Fatalf("fresh blocks: %+v", got)
}
// a node leaves, another joins: survivors keep their blocks, the newcomer takes the freed lowest one
got = AllocateBlocks(got, []string{"w1", "w2", "w3"})
want := []PortBlock{{"w1", 61512, 62023}, {"w2", 62024, 62535}, {"w3", 61000, 61511}}
if !sameStatus(EgressServiceStatus{Blocks: got}, EgressServiceStatus{Blocks: want}) {
t.Fatalf("after churn: %+v", got)
}
// misaligned or duplicate blocks are not kept
got = AllocateBlocks([]PortBlock{{"w1", 61001, 61512}, {"w2", 61000, 61511}, {"w3", 61000, 61511}}, []string{"w1", "w2", "w3"})
for _, b := range got {
if (b.From-BlockBase)%BlockSize != 0 || b.To != b.From+BlockSize-1 {
t.Fatalf("invalid block kept: %+v", got)
}
}
if got[1].Node != "w2" || got[1].From != 61000 {
t.Fatalf("first valid claim wins: %+v", got)
}
var many []string
for i := 0; i < BlockCount+2; i++ {
many = append(many, string(rune('a'+i)))
}
if n := len(AllocateBlocks(nil, many)); n != BlockCount {
t.Fatalf("more blocks than exist: %d", n)
}
for _, b := range AllocateBlocks(nil, many) {
if b.To > 65535 || b.From < 61000 {
t.Fatalf("block outside the range: %+v", b)
}
}
}
func TestHolderWritesStatusAndMembersWaitForIt(t *testing.T) {
e := egressFor("mail", EgressServiceStatus{})
pods := []*coreapi.Pod{pod("mail-0", "w2", "10.44.0.9", mailLabels)}
d := ComputeEgress("w1", nodes, []*coreapi.Service{mailSvc()}, []*EgressService{e}, nil, nil)
if len(d.Status) != 1 || !sameStatus(d.Status[0].Status, written()) {
t.Fatalf("holder's status: %+v", d.Status)
}
// until the status is in the API, a member's outside traffic goes nowhere (fail closed)
d = ComputeEgress("w2", nodes, []*coreapi.Service{mailSvc()}, []*EgressService{e}, pods, nil)
if len(d.Status) != 0 || len(d.Members) != 1 || !d.Members[0].Unreachable {
t.Fatalf("member before status: %+v", d)
}
if d.Tunnelled("w2") {
t.Fatalf("no tunnel for an unreachable member")
}
}
func TestMemberTunnelsToHolderWhichRoutesItsBlockBack(t *testing.T) {
e := egressFor("mail", written())
svcs := []*coreapi.Service{mailSvc()}
pods := []*coreapi.Pod{
pod("mail-0", "w2", "10.44.0.9", mailLabels),
pod("web", "w2", "10.44.0.10", map[string]string{"app": "web"}), // not a member (A4)
}
d := ComputeEgress("w2", nodes, svcs, []*EgressService{e}, pods, nil)
if len(d.Members) != 1 || d.Members[0].Pod != "gitea/mail-0" {
t.Fatalf("members: %+v", d.Members)
}
m := d.Members[0]
if m.Unreachable || !m.Address.Equal(net.ParseIP("86.107.168.15")) || m.From != 62024 || m.To != 62535 || m.Holder.Name != "w1" {
t.Fatalf("member path: %+v", m)
}
if !d.Tunnelled("w2") || len(d.Peers("w2")) != 1 || d.Peers("w2")[0].Name != "w1" {
t.Fatalf("w2 tunnels to w1 only: %+v", d.Peers("w2"))
}
if len(d.Status) != 0 || len(d.Routes) != 0 {
t.Fatalf("only the holder writes and routes: %+v", d)
}
// the holder: blocks of the other nodes route back to them; its own block is plain conntrack
h := ComputeEgress("w1", nodes, svcs, []*EgressService{e}, nil, nil)
if len(h.Status) != 0 {
t.Fatalf("status already right: %+v", h.Status)
}
if len(h.Routes) != 2 || h.Routes[0].Peer.Name != "cp" || h.Routes[1].Peer.Name != "w2" || h.Routes[1].From != 62024 {
t.Fatalf("holder routes: %+v", h.Routes)
}
// a member on the holder leaves from there
hm := ComputeEgress("w1", nodes, svcs, []*EgressService{e}, []*coreapi.Pod{pod("mail-1", "w1", "10.40.0.4", mailLabels)}, nil)
if len(hm.Members) != 1 || hm.Members[0].Holder.Name != "w1" || hm.Members[0].From != 61512 {
t.Fatalf("member on the holder: %+v", hm.Members)
}
}
func TestEgressFailsClosed(t *testing.T) {
pods := []*coreapi.Pod{pod("mail-0", "w2", "10.44.0.9", mailLabels)}
// no node holds the address: status cleared (by the first node by name), member dropped
unheld := svc("mail", "86.107.168.99", func(s *coreapi.Service) { s.Spec.Selector = mailLabels })
for _, self := range []string{"cp", "w2"} {
d := ComputeEgress(self, nodes, []*coreapi.Service{unheld}, []*EgressService{egressFor("mail", written())}, pods, nil)
clears := len(d.Status) == 1 && d.Status[0].Status.Host == ""
if clears != (self == "cp") {
t.Fatalf("%s clears=%v: %+v", self, clears, d.Status)
}
if self == "w2" && (len(d.Members) != 1 || !d.Members[0].Unreachable) {
t.Fatalf("member of an unheld address: %+v", d.Members)
}
}
// two EgressServices select the pod: dropped, reported
other := svc("mail2", "86.107.168.15", func(s *coreapi.Service) { s.Spec.Selector = mailLabels })
d := ComputeEgress("w2", nodes, []*coreapi.Service{mailSvc(), other},
[]*EgressService{egressFor("mail", written()), egressFor("mail2", written())}, pods, nil)
if len(d.Members) != 1 || !d.Members[0].Unreachable || !strings.Contains(strings.Join(d.Problems, "\n"), "several EgressServices") {
t.Fatalf("double member: %+v", d)
}
// a spec this agent does not implement is refused, not guessed at
bad := egressFor("mail", written())
bad.Spec.Emit = "Direct"
d = ComputeEgress("w2", nodes, []*coreapi.Service{mailSvc()}, []*EgressService{bad}, pods, nil)
if len(d.Members) != 1 || !d.Members[0].Unreachable {
t.Fatalf("unknown emit mode: %+v", d)
}
// hostNetwork pods are never members (their traffic is the node's)
hn := pod("mail-h", "w2", "86.107.168.88", mailLabels, func(p *coreapi.Pod) { p.Spec.HostNetwork = true })
d = ComputeEgress("w2", nodes, []*coreapi.Service{mailSvc()}, []*EgressService{egressFor("mail", written())}, []*coreapi.Pod{hn}, nil)
if len(d.Members) != 0 {
t.Fatalf("hostNetwork pod became a member: %+v", d.Members)
}
}
func TestAdmissionDecidesWhoOwnsAnIP(t *testing.T) {
e := egressFor("mail", written())
svcs := []*coreapi.Service{mailSvc()}
old := pod("mail-0", "w2", "10.44.0.9", mailLabels, func(p *coreapi.Pod) {
now := metav1.Now()
p.DeletionTimestamp = &now
})
newer := pod("web", "w2", "", map[string]string{"app": "web"}) // API has no IP for it yet
// the CNI plugin just gave 10.44.0.9 to web: the terminating member must not keep it (A4)
d := ComputeEgress("w2", nodes, svcs, []*EgressService{e}, []*coreapi.Pod{old, newer},
map[string]Admission{"10.44.0.9": {Namespace: "gitea", Name: "web"}})
if len(d.Members) != 0 {
t.Fatalf("reused IP kept the old member's rules: %+v", d.Members)
}
// and the other way round: a member admitted before the API shows its IP is protected at once (A1)
fresh := pod("mail-1", "w2", "", mailLabels)
d = ComputeEgress("w2", nodes, svcs, []*EgressService{e}, []*coreapi.Pod{fresh},
map[string]Admission{"10.44.0.11": {Namespace: "gitea", Name: "mail-1"}})
if len(d.Members) != 1 || d.Members[0].IP.String() != "10.44.0.11" || d.Members[0].Unreachable {
t.Fatalf("admitted member: %+v", d.Members)
}
// without an admission, a live pod wins a shared IP over a terminating one
live := pod("web", "w2", "10.44.0.9", map[string]string{"app": "web"})
d = ComputeEgress("w2", nodes, svcs, []*EgressService{e}, []*coreapi.Pod{old, live}, nil)
if len(d.Members) != 0 {
t.Fatalf("terminating member won the IP: %+v", d.Members)
}
}
func TestRenderedEgressRules(t *testing.T) {
_, podNet, _ := net.ParseCIDR("10.32.0.0/12")
e := egressFor("mail", written())
svcs := []*coreapi.Service{mailSvc()}
toW1, toW2, toCP := TunnelName("w1"), TunnelName("w2"), TunnelName("cp")
member := ComputeEgress("w2", nodes, svcs, []*EgressService{e}, []*coreapi.Pod{pod("mail-0", "w2", "10.44.0.9", mailLabels)}, nil)
r := Render(Desired{}, member, "w2", podNet, nil)
for _, want := range []string{
"-A REWEAVE-LB-CLASS -d 10.32.0.0/12 -j RETURN\n",
"-A REWEAVE-LB-CLASS -d 86.107.168.24/32 -j RETURN\n", // a node is not outside
"-A REWEAVE-LB-CLASS -s 10.44.0.9/32 -m comment --comment \"gitea/mail-0\" -j CONNMARK --set-xmark 0x100000/0x100000\n",
"-A REWEAVE-LB-CLASS -s 10.44.0.9/32 -j MARK --set-xmark 0x100000/0x100000\n",
// port protocols within this node's block, into the holder's own tunnel
"-A REWEAVE-LB-POST -s 10.44.0.9/32 -o " + toW1 + " -m mark --mark 0x100000/0x100000 -p tcp -j SNAT --to-source 86.107.168.15:62024-62535\n",
"-A REWEAVE-LB-POST -s 10.44.0.9/32 -o " + toW1 + " -m mark --mark 0x100000/0x100000 -p udp -j SNAT --to-source 86.107.168.15:62024-62535\n",
"-A REWEAVE-LB-POST -s 10.44.0.9/32 -o " + toW1 + " -m mark --mark 0x100000/0x100000 -p sctp -j SNAT --to-source 86.107.168.15:62024-62535\n",
// ping within the block too (the echo identifier is NAT's port for ICMP)
"-A REWEAVE-LB-POST -s 10.44.0.9/32 -o " + toW1 + " -m mark --mark 0x100000/0x100000 -p icmp -j SNAT --to-source 86.107.168.15:62024-62535\n",
// everything else (GRE, ...) from the address alone
"-A REWEAVE-LB-POST -s 10.44.0.9/32 -o " + toW1 + " -m mark --mark 0x100000/0x100000 -j SNAT --to-source 86.107.168.15\n",
} {
if !strings.Contains(r.Nat, want) {
t.Fatalf("member nat lacks %q:\n%s", want, r.Nat)
}
}
// the catch-all SNAT comes after the port-ranged ones (or ports would not stay in the block)
if strings.Index(r.Nat, "-j SNAT --to-source 86.107.168.15\n") < strings.Index(r.Nat, "-p icmp -j SNAT") {
t.Fatalf("SNAT order:\n%s", r.Nat)
}
for _, want := range []string{
"-A REWEAVE-LB-FWD -s 10.44.0.9/32 -o " + toW1 + " -m mark --mark 0x100000/0x100000 -j RETURN\n",
"-A REWEAVE-LB-FWD -s 10.44.0.9/32 -m mark --mark 0x100000/0x100000 -m comment --comment \"gitea/mail-0\" -j DROP\n",
"-A REWEAVE-LB-FWD -i rweg+ -d 86.107.168.15/32 -j DROP\n",
"-A REWEAVE-LB-FWD -i rweg+ -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN\n",
"-A REWEAVE-LB-FWD -i rweg+ -j DROP\n",
"-A REWEAVE-LB-IN -p 4 -s 10.40.0.0/32 -m mac --mac-source aa:aa:aa:aa:aa:02 -j RETURN\n",
"-A REWEAVE-LB-IN -p 4 -j DROP\n",
} {
if !strings.Contains(r.Filter, want) {
t.Fatalf("member filter lacks %q:\n%s", want, r.Filter)
}
}
for _, want := range []string{
"-A REWEAVE-LB-MARK -m conntrack --ctdir ORIGINAL -m connmark --mark 0x100000/0x100000 -j MARK --set-xmark 0x100000/0x100000\n",
"-A REWEAVE-LB-MSS -o rweg+ -p tcp -m tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu\n",
} {
if !strings.Contains(r.Mangle, want) {
t.Fatalf("member mangle lacks %q:\n%s", want, r.Mangle)
}
}
if strings.Contains(r.Mangle, "--set-xmark 0x") && strings.Contains(r.Mangle, "-i "+toW1) {
t.Fatalf("a member's node does not tag what its holder sends back:\n%s", r.Mangle)
}
// the guard's DROP comes after the member's own RETURN: nothing else of its leaves
if strings.Index(r.Filter, toW1+" -m mark --mark 0x100000/0x100000 -j RETURN") > strings.Index(r.Filter, "\"gitea/mail-0\" -j DROP") {
t.Fatalf("guard order:\n%s", r.Filter)
}
// the holder: tags each tunnel's connections with its node, accepts each node's block, nothing foreign
h := ComputeEgress("w1", nodes, svcs, []*EgressService{e}, []*coreapi.Pod{pod("mail-1", "w1", "10.40.0.4", mailLabels)}, nil)
r = Render(Desired{}, h, "w1", podNet, nil)
idx := peerIndex(h, "w1")
tagW2 := fmt.Sprintf("0x%x", peerTag(idx["w2"]))
for _, want := range []string{
"-A REWEAVE-LB-MARK -m conntrack --ctdir REPLY -m connmark --mark 0x200000/0x200000 -j CONNMARK --restore-mark --nfmask 0xff200000 --ctmask 0xff200000\n",
"-A REWEAVE-LB-MARK -i " + toW2 + " -m conntrack --ctstate NEW -m connmark --mark 0x0/0xff200000 -m comment --comment \"from w2\" -j CONNMARK --set-xmark " + tagW2 + "/0xff200000\n",
} {
if !strings.Contains(r.Mangle, want) {
t.Fatalf("holder mangle lacks %q:\n%s", want, r.Mangle)
}
}
for _, want := range []string{
"-A REWEAVE-LB-FWD -i " + toW2 + " -m conntrack --ctdir ORIGINAL -m connmark ! --mark " + tagW2 + "/0xff200000 -j DROP\n",
"-A REWEAVE-LB-FWD -i " + toW2 + " -m conntrack --ctdir ORIGINAL -s 86.107.168.15/32 -p tcp -m tcp --sport 62024:62535 -m comment --comment \"block of w2\" -j RETURN\n",
"-A REWEAVE-LB-FWD -i " + toCP + " -m conntrack --ctdir ORIGINAL -s 86.107.168.15/32 -p sctp -m sctp --sport 61000:61511 -m comment --comment \"block of cp\" -j RETURN\n",
"-A REWEAVE-LB-FWD -i " + toW2 + " -m conntrack --ctdir ORIGINAL -s 86.107.168.15/32 -p udp -j DROP\n",
"-A REWEAVE-LB-FWD -i " + toW2 + " -m conntrack --ctdir ORIGINAL -s 86.107.168.15/32 -j RETURN\n",
"-A REWEAVE-LB-FWD -s 10.40.0.4/32 -o weave -m mark --mark 0x100000/0x100000 -j DROP\n",
} {
if !strings.Contains(r.Filter, want) {
t.Fatalf("holder filter lacks %q:\n%s", want, r.Filter)
}
}
// foreign tags are refused before anything of that tunnel is accepted; a port outside the block before the
// catch-all accept
if strings.Index(r.Filter, "! --mark "+tagW2) > strings.Index(r.Filter, "\"block of w2\"") ||
strings.Index(r.Filter, "-i "+toW2+" -m conntrack --ctdir ORIGINAL -s 86.107.168.15/32 -p udp -j DROP") >
strings.Index(r.Filter, "-i "+toW2+" -m conntrack --ctdir ORIGINAL -s 86.107.168.15/32 -j RETURN") {
t.Fatalf("holder filter order:\n%s", r.Filter)
}
if !strings.Contains(r.Nat, "-A REWEAVE-LB-POST -s 10.40.0.4/32 ! -o weave -m mark --mark 0x100000/0x100000 -p tcp -j SNAT --to-source 86.107.168.15:61512-62023\n") {
t.Fatalf("holder's own member:\n%s", r.Nat)
}
for _, line := range strings.Split(r.Nat+r.Filter, "\n") {
if strings.Contains(line, "10.44.0.10") {
t.Fatalf("a rule names a non-member: %s", line)
}
}
}
func TestTunnelNames(t *testing.T) {
a, b := TunnelName("reweave-worker-fe19b040"), TunnelName("reweave-cp-94a8db43")
if a == b || len(a) > 15 || !strings.HasPrefix(a, TunnelPrefix) || a != TunnelName("reweave-worker-fe19b040") {
t.Fatalf("tunnel names must be distinct, stable, short: %q %q", a, b)
}
}
func TestPolicyRules(t *testing.T) {
e := egressFor("mail", written())
svcs := []*coreapi.Service{mailSvc()}
h := ComputeEgress("w1", nodes, svcs, []*EgressService{e}, nil, nil)
rules := wantRules(h, "w1")
tables := peerTables(h, "w1")
idx := peerIndex(h, "w1")
ports, tags := 0, 0
for _, r := range rules {
if r.priority != HolderRulePriority || r.src != "" {
t.Fatalf("holder rule: %+v", r)
}
switch {
case r.ipproto != 0:
ports++
if r.dst != "86.107.168.15/32" || r.mark != 0 {
t.Fatalf("port rule: %+v", r)
}
if (r.from == 62024) != (r.table == tables["w2"]) || (r.from == 61000) != (r.table == tables["cp"]) {
t.Fatalf("block routed to the wrong node: %+v %v", r, tables)
}
default:
tags++
if r.mask != peerMask || r.dst != "" {
t.Fatalf("tag rule: %+v", r)
}
for name, i := range idx {
if r.mark == peerTag(i) && r.table != tables[name] {
t.Fatalf("tag of %s routed to table %d, want %d", name, r.table, tables[name])
}
}
}
}
if ports != 2*len(portProtocols) || tags != 2 {
t.Fatalf("2 peers: %d port rules (tcp, udp, sctp each), %d tag rules: %+v", ports, tags, rules)
}
if HolderRulePriority >= LocalRulePriority {
t.Fatalf("holder rules must precede the local table")
}
m := ComputeEgress("w2", nodes, svcs, []*EgressService{e}, []*coreapi.Pod{pod("mail-0", "w2", "10.44.0.9", mailLabels)}, nil)
rules = wantRules(m, "w2")
if len(rules) != 1 || rules[0].src != "10.44.0.9/32" || rules[0].mark != EgressMark || rules[0].mask != EgressMark ||
rules[0].table != peerTables(m, "w2")["w1"] {
t.Fatalf("member rule: %+v", rules)
}
u := ComputeEgress("w2", nodes, svcs, []*EgressService{egressFor("mail", EgressServiceStatus{})}, []*coreapi.Pod{pod("mail-0", "w2", "10.44.0.9", mailLabels)}, nil)
rules = wantRules(u, "w2")
if len(rules) != 1 || rules[0].table != EgressTableBase {
t.Fatalf("unreachable member must hit the unreachable table: %+v", rules)
}
}
+1049
View File
File diff suppressed because it is too large. Load diff
+415
View File
@@ -0,0 +1,415 @@
package lb
import (
"context"
"encoding/json"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/runtime/serializer"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/rest"
)
// The Gateway API (gateway.networking.k8s.io/v1, as the v1.6.3 standard channel serves it): the subset this agent
// implements, as plain types. The upstream Go module needs a newer client-go than the fork's; the JSON is the
// contract, and fields this agent does not implement are simply not decoded.
const (
GatewayGroup = "gateway.networking.k8s.io"
GatewayVersion = "v1"
// GatewayControllerName is the spec.controllerName of the GatewayClasses this agent implements.
GatewayControllerName = "reweave.levk.dev/gateway"
GatewayClasses = "gatewayclasses"
Gateways = "gateways"
HTTPRoutes = "httproutes"
TCPRoutes = "tcproutes"
ReferenceGrants = "referencegrants"
)
// GatewayGroupVersion of the Gateway API.
var GatewayGroupVersion = schema.GroupVersion{Group: GatewayGroup, Version: GatewayVersion}
type GatewayClass struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec GatewayClassSpec `json:"spec"`
Status GatewayClassStatus `json:"status,omitempty"`
}
type GatewayClassSpec struct {
ControllerName string `json:"controllerName"`
}
type GatewayClassStatus struct {
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
type Gateway struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec GatewaySpec `json:"spec"`
Status GatewayStatus `json:"status,omitempty"`
}
type GatewaySpec struct {
GatewayClassName string `json:"gatewayClassName"`
Listeners []Listener `json:"listeners"`
Addresses []GatewayAddress `json:"addresses,omitempty"`
}
// GatewayAddress is a spec or status address; Type defaults to IPAddress.
type GatewayAddress struct {
Type *string `json:"type,omitempty"`
Value string `json:"value"`
}
type Listener struct {
Name string `json:"name"`
Hostname *string `json:"hostname,omitempty"`
Port int32 `json:"port"`
Protocol string `json:"protocol"`
TLS *ListenerTLSConfig `json:"tls,omitempty"`
AllowedRoutes *AllowedRoutes `json:"allowedRoutes,omitempty"`
}
type ListenerTLSConfig struct {
Mode *string `json:"mode,omitempty"`
CertificateRefs []SecretObjectReference `json:"certificateRefs,omitempty"`
}
type SecretObjectReference struct {
Group *string `json:"group,omitempty"`
Kind *string `json:"kind,omitempty"`
Name string `json:"name"`
Namespace *string `json:"namespace,omitempty"`
}
type AllowedRoutes struct {
Namespaces *RouteNamespaces `json:"namespaces,omitempty"`
Kinds []RouteGroupKind `json:"kinds,omitempty"`
}
type RouteNamespaces struct {
From *string `json:"from,omitempty"`
Selector *metav1.LabelSelector `json:"selector,omitempty"`
}
type RouteGroupKind struct {
Group *string `json:"group,omitempty"`
Kind string `json:"kind"`
}
type GatewayStatus struct {
Addresses []GatewayAddress `json:"addresses,omitempty"`
Conditions []metav1.Condition `json:"conditions,omitempty"`
Listeners []ListenerStatus `json:"listeners,omitempty"`
}
type ListenerStatus struct {
Name string `json:"name"`
SupportedKinds []RouteGroupKind `json:"supportedKinds"`
AttachedRoutes int32 `json:"attachedRoutes"`
Conditions []metav1.Condition `json:"conditions"`
}
type ParentReference struct {
Group *string `json:"group,omitempty"`
Kind *string `json:"kind,omitempty"`
Namespace *string `json:"namespace,omitempty"`
Name string `json:"name"`
SectionName *string `json:"sectionName,omitempty"`
Port *int32 `json:"port,omitempty"`
}
type BackendRef struct {
Group *string `json:"group,omitempty"`
Kind *string `json:"kind,omitempty"`
Name string `json:"name"`
Namespace *string `json:"namespace,omitempty"`
Port *int32 `json:"port,omitempty"`
Weight *int32 `json:"weight,omitempty"`
}
type RouteStatus struct {
Parents []RouteParentStatus `json:"parents"`
}
type RouteParentStatus struct {
ParentRef ParentReference `json:"parentRef"`
ControllerName string `json:"controllerName"`
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
type HTTPRoute struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec HTTPRouteSpec `json:"spec"`
Status RouteStatus `json:"status,omitempty"`
}
type HTTPRouteSpec struct {
ParentRefs []ParentReference `json:"parentRefs,omitempty"`
Hostnames []string `json:"hostnames,omitempty"`
Rules []HTTPRouteRule `json:"rules,omitempty"`
}
type HTTPRouteRule struct {
Matches []HTTPRouteMatch `json:"matches,omitempty"`
Filters []HTTPRouteFilter `json:"filters,omitempty"`
BackendRefs []HTTPBackendRef `json:"backendRefs,omitempty"`
}
type HTTPRouteMatch struct {
Path *HTTPPathMatch `json:"path,omitempty"`
Headers []HTTPHeaderMatch `json:"headers,omitempty"`
QueryParams []HTTPQueryParamMatch `json:"queryParams,omitempty"`
Method *string `json:"method,omitempty"`
}
type HTTPPathMatch struct {
Type *string `json:"type,omitempty"`
Value *string `json:"value,omitempty"`
}
type HTTPHeaderMatch struct {
Type *string `json:"type,omitempty"`
Name string `json:"name"`
Value string `json:"value"`
}
type HTTPQueryParamMatch struct {
Type *string `json:"type,omitempty"`
Name string `json:"name"`
Value string `json:"value"`
}
// HTTPRouteFilter: the filters this agent implements are decoded; for the others only their presence is known
// (through Type), which is enough to refuse them.
type HTTPRouteFilter struct {
Type string `json:"type"`
RequestHeaderModifier *HTTPHeaderFilter `json:"requestHeaderModifier,omitempty"`
ResponseHeaderModifier *HTTPHeaderFilter `json:"responseHeaderModifier,omitempty"`
RequestRedirect *HTTPRequestRedirectFilter `json:"requestRedirect,omitempty"`
URLRewrite *HTTPURLRewriteFilter `json:"urlRewrite,omitempty"`
}
type HTTPHeaderFilter struct {
Set []HTTPHeader `json:"set,omitempty"`
Add []HTTPHeader `json:"add,omitempty"`
Remove []string `json:"remove,omitempty"`
}
type HTTPHeader struct {
Name string `json:"name"`
Value string `json:"value"`
}
type HTTPRequestRedirectFilter struct {
Scheme *string `json:"scheme,omitempty"`
Hostname *string `json:"hostname,omitempty"`
Path *HTTPPathModifier `json:"path,omitempty"`
Port *int32 `json:"port,omitempty"`
StatusCode *int `json:"statusCode,omitempty"`
}
type HTTPURLRewriteFilter struct {
Hostname *string `json:"hostname,omitempty"`
Path *HTTPPathModifier `json:"path,omitempty"`
}
type HTTPPathModifier struct {
Type string `json:"type"`
ReplaceFullPath *string `json:"replaceFullPath,omitempty"`
ReplacePrefixMatch *string `json:"replacePrefixMatch,omitempty"`
}
type HTTPBackendRef struct {
BackendRef `json:",inline"`
Filters []HTTPRouteFilter `json:"filters,omitempty"`
}
type TCPRoute struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec TCPRouteSpec `json:"spec"`
Status RouteStatus `json:"status,omitempty"`
}
type TCPRouteSpec struct {
ParentRefs []ParentReference `json:"parentRefs,omitempty"`
Rules []TCPRouteRule `json:"rules,omitempty"`
}
type TCPRouteRule struct {
BackendRefs []BackendRef `json:"backendRefs,omitempty"`
}
type ReferenceGrant struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec ReferenceGrantSpec `json:"spec"`
}
type ReferenceGrantSpec struct {
From []ReferenceGrantFrom `json:"from"`
To []ReferenceGrantTo `json:"to"`
}
type ReferenceGrantFrom struct {
Group string `json:"group"`
Kind string `json:"kind"`
Namespace string `json:"namespace"`
}
type ReferenceGrantTo struct {
Group string `json:"group"`
Kind string `json:"kind"`
Name *string `json:"name,omitempty"`
}
type GatewayClassList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []GatewayClass `json:"items"`
}
type GatewayList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []Gateway `json:"items"`
}
type HTTPRouteList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []HTTPRoute `json:"items"`
}
type TCPRouteList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []TCPRoute `json:"items"`
}
type ReferenceGrantList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []ReferenceGrant `json:"items"`
}
// jsonCopy deep-copies through JSON: these types are only what the JSON says, so nothing is lost.
func jsonCopy(in, out interface{}) {
b, err := json.Marshal(in)
if err != nil {
panic(err)
}
if err := json.Unmarshal(b, out); err != nil {
panic(err)
}
}
func (o *GatewayClass) DeepCopyObject() runtime.Object {
out := &GatewayClass{}
jsonCopy(o, out)
return out
}
func (o *Gateway) DeepCopyObject() runtime.Object {
out := &Gateway{}
jsonCopy(o, out)
return out
}
func (o *HTTPRoute) DeepCopyObject() runtime.Object {
out := &HTTPRoute{}
jsonCopy(o, out)
return out
}
func (o *TCPRoute) DeepCopyObject() runtime.Object {
out := &TCPRoute{}
jsonCopy(o, out)
return out
}
func (o *ReferenceGrant) DeepCopyObject() runtime.Object {
out := &ReferenceGrant{}
jsonCopy(o, out)
return out
}
func (o *GatewayClassList) DeepCopyObject() runtime.Object {
out := &GatewayClassList{}
jsonCopy(o, out)
return out
}
func (o *GatewayList) DeepCopyObject() runtime.Object {
out := &GatewayList{}
jsonCopy(o, out)
return out
}
func (o *HTTPRouteList) DeepCopyObject() runtime.Object {
out := &HTTPRouteList{}
jsonCopy(o, out)
return out
}
func (o *TCPRouteList) DeepCopyObject() runtime.Object {
out := &TCPRouteList{}
jsonCopy(o, out)
return out
}
func (o *ReferenceGrantList) DeepCopyObject() runtime.Object {
out := &ReferenceGrantList{}
jsonCopy(o, out)
return out
}
func addGatewayTypes(s *runtime.Scheme) error {
s.AddKnownTypes(GatewayGroupVersion,
&GatewayClass{}, &GatewayClassList{}, &Gateway{}, &GatewayList{}, &HTTPRoute{}, &HTTPRouteList{},
&TCPRoute{}, &TCPRouteList{}, &ReferenceGrant{}, &ReferenceGrantList{})
metav1.AddToGroupVersion(s, GatewayGroupVersion)
return nil
}
// NewGatewayClient is a REST client for the Gateway API kinds this agent reads and writes the status of.
func NewGatewayClient(base *rest.Config) (*rest.RESTClient, error) {
scheme := runtime.NewScheme()
if err := addGatewayTypes(scheme); err != nil {
return nil, err
}
cfg := *base
cfg.GroupVersion = &GatewayGroupVersion
cfg.APIPath = "/apis"
cfg.ContentType = runtime.ContentTypeJSON
cfg.NegotiatedSerializer = serializer.NewCodecFactory(scheme).WithoutConversion()
if cfg.UserAgent == "" {
cfg.UserAgent = rest.DefaultKubernetesUserAgent()
}
return rest.RESTClientFor(&cfg)
}
// patchStatus merge-patches an object's status. resourceVersion, when given, makes it conditional: a status built
// from a stale read fails instead of overwriting what someone else wrote since.
func patchStatus(ctx context.Context, c rest.Interface, resource, namespace, name, resourceVersion string, status interface{}) error {
body := map[string]interface{}{"status": status}
if resourceVersion != "" {
body["metadata"] = map[string]interface{}{"resourceVersion": resourceVersion}
}
patch, err := json.Marshal(body)
if err != nil {
return err
}
req := c.Patch(types.MergePatchType).Resource(resource).Name(name).SubResource("status").Body(patch)
if namespace != "" {
req = req.Namespace(namespace)
}
return req.Do(ctx).Error()
}
+547
View File
@@ -0,0 +1,547 @@
package lb
import (
"context"
"crypto/tls"
"fmt"
"log"
"math/rand"
"net"
"net/http"
"net/http/httputil"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
coreapi "k8s.io/api/core/v1"
"github.com/rajch/weave/common"
)
// Proxy serves the HTTP and HTTPS listeners of the Gateways this node holds. TLS is terminated with the
// listener's certificate, chosen by SNI and swapped in place when its Secret changes (no restart, no rebind).
// Requests are routed by the attached HTTPRoutes in the order the spec prescribes and passed to a ready endpoint
// of the chosen backend, with the client's own address in X-Forwarded-For. Bodies are streamed, never limited.
type Proxy struct {
mu sync.Mutex
servers map[string]*portServer // by "address:port"
// listen binds an address (swappable in tests)
listen func(addr string) (net.Listener, error)
transport http.RoundTripper
certs map[string]*tls.Certificate // parsed certificates, by Secret uid@resourceVersion
rr sync.Map // backend key -> *uint64, for round robin over its endpoints
}
func NewProxy() *Proxy {
return &Proxy{
servers: map[string]*portServer{},
certs: map[string]*tls.Certificate{},
listen: func(addr string) (net.Listener, error) { return net.Listen("tcp", addr) },
transport: &http.Transport{
DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
MaxIdleConns: 256,
MaxIdleConnsPerHost: 32,
IdleConnTimeout: 90 * time.Second,
ExpectContinueTimeout: time.Second,
},
}
}
// portServer is one bound address:port and what it currently serves.
type portServer struct {
addr string
https bool
ln net.Listener
srv *http.Server
cfg atomic.Pointer[portConfig]
}
// portConfig is everything one address:port serves: its listeners, most specific hostname first.
type portConfig struct {
listeners []*liveListener
}
type liveListener struct {
spec ServeListener
cert *tls.Certificate
}
// Apply serves exactly these listeners: ports no longer listed are closed, new ones bound, and every served
// port's routes and certificates replaced at once. It returns, by "namespace/gateway/listener", the listeners
// it could not bind.
func (p *Proxy) Apply(listeners []ServeListener) map[string]error {
p.mu.Lock()
defer p.mu.Unlock()
failed := map[string]error{}
byAddr := map[string][]ServeListener{}
for _, l := range listeners {
a := net.JoinHostPort(l.Address.String(), strconv.Itoa(int(l.Port)))
byAddr[a] = append(byAddr[a], l)
}
for a, s := range p.servers {
if group, ok := byAddr[a]; !ok || group[0].HTTPS != s.https {
s.close()
delete(p.servers, a)
}
}
used := map[string]bool{}
for a, group := range byAddr {
cfg := p.compile(group, used)
s := p.servers[a]
if s == nil {
var err error
if s, err = p.start(a, group[0].HTTPS); err != nil {
for _, l := range group {
failed[l.Gateway+"/"+l.Name] = err
}
continue
}
p.servers[a] = s
}
s.cfg.Store(cfg)
}
// forget certificates no listener uses any more
for k := range p.certs {
if !used[k] {
delete(p.certs, k)
}
}
return failed
}
// Close stops serving everything.
func (p *Proxy) Close() {
p.Apply(nil)
}
func (p *Proxy) compile(group []ServeListener, used map[string]bool) *portConfig {
cfg := &portConfig{}
for _, l := range group {
ll := &liveListener{spec: l}
if l.HTTPS && l.Cert != nil {
ll.cert = p.cert(l.Cert)
used[certKey(l.Cert)] = true
}
cfg.listeners = append(cfg.listeners, ll)
}
// most specific first: exact hostnames, then wildcards (longest first), then the catch-all
sort.SliceStable(cfg.listeners, func(i, j int) bool {
return hostRank(cfg.listeners[i].spec.Hostname) > hostRank(cfg.listeners[j].spec.Hostname)
})
return cfg
}
func certKey(s *coreapi.Secret) string {
return string(s.UID) + "@" + s.ResourceVersion + "/" + s.Namespace + "/" + s.Name
}
// cert parses a Secret's certificate once per version of the Secret.
func (p *Proxy) cert(s *coreapi.Secret) *tls.Certificate {
k := certKey(s)
if c, ok := p.certs[k]; ok {
return c
}
c, err := tls.X509KeyPair(s.Data[coreapi.TLSCertKey], s.Data[coreapi.TLSPrivateKeyKey])
if err != nil {
common.Log.Warnf("lb: gateway certificate %s/%s: %v", s.Namespace, s.Name, err)
return nil
}
p.certs[k] = &c
return &c
}
// hostRank orders listener hostnames: exact (by length) above wildcards (by length) above none.
func hostRank(h string) int {
switch {
case h == "":
return 0
case strings.HasPrefix(h, "*."):
return 1000 + len(h)
}
return 100000 + len(h)
}
func (p *Proxy) start(addr string, https bool) (*portServer, error) {
ln, err := p.listen(addr)
if err != nil {
return nil, err
}
s := &portServer{addr: addr, https: https, ln: ln}
s.cfg.Store(&portConfig{})
s.srv = &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { p.serve(s, w, r) }),
ReadHeaderTimeout: 30 * time.Second,
IdleTimeout: 2 * time.Minute,
// handshakes from scanners and the like would flood the log
ErrorLog: log.New(debugWriter{}, "lb: gateway "+addr+": ", 0),
}
if https {
s.srv.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS12, GetCertificate: s.certificate}
go func() {
if err := s.srv.ServeTLS(ln, "", ""); err != nil && err != http.ErrServerClosed {
common.Log.Errorf("lb: gateway %s: %v", addr, err)
}
}()
} else {
go func() {
if err := s.srv.Serve(ln); err != nil && err != http.ErrServerClosed {
common.Log.Errorf("lb: gateway %s: %v", addr, err)
}
}()
}
common.Log.Infof("lb: gateway serving %s (%s)", addr, map[bool]string{true: "HTTPS", false: "HTTP"}[https])
return s, nil
}
func (s *portServer) close() {
srv := s.srv
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
_ = srv.Close()
}()
common.Log.Infof("lb: gateway no longer serving %s", s.addr)
}
type debugWriter struct{}
func (debugWriter) Write(b []byte) (int, error) {
common.Log.Debugf("%s", strings.TrimRight(string(b), "\n"))
return len(b), nil
}
// listenerFor picks the listener a name (SNI or Host) reaches on a port, most specific hostname first.
func (c *portConfig) listenerFor(name string) *liveListener {
name = strings.ToLower(name)
for _, l := range c.listeners {
if l.spec.Hostname == "" || hostMatches(l.spec.Hostname, name) {
return l
}
}
return nil
}
// certificate answers a TLS handshake with the certificate of the listener its SNI names.
func (s *portServer) certificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
cfg := s.cfg.Load()
l := cfg.listenerFor(hello.ServerName)
if l == nil || l.cert == nil {
return nil, fmt.Errorf("no listener for %q", hello.ServerName)
}
return l.cert, nil
}
// candidate is one match of one rule that a request satisfies, with what decides its precedence.
type candidate struct {
route *ServeRoute
rule *ServeRule
ruleIdx int
// the matched hostname: characters if not a wildcard, and characters
hostExact, hostLen int
exact bool
prefixLen int
method bool
headers, query int
prefix string // the path prefix matched (for ReplacePrefixMatch)
}
func (p *Proxy) serve(s *portServer, w http.ResponseWriter, r *http.Request) {
cfg := s.cfg.Load()
host := hostOnly(r.Host)
name := host
if r.TLS != nil && r.TLS.ServerName != "" {
name = r.TLS.ServerName
}
l := cfg.listenerFor(name)
if l == nil {
http.Error(w, "no listener for this host", http.StatusNotFound)
return
}
c := pick(l, host, r)
if c == nil {
http.Error(w, "no route", http.StatusNotFound)
return
}
for _, f := range c.rule.Filters {
if f.Type == filterRequestRedirect && f.RequestRedirect != nil {
to, code := redirect(r, f.RequestRedirect, l.spec.Port, c.prefix)
http.Redirect(w, r, to, code)
return
}
}
b := chooseBackend(c.rule.Backends)
if b == nil {
// no resolved backend to send it to: the spec's answer for unresolved backends
http.Error(w, "no usable backend", http.StatusInternalServerError)
return
}
if len(b.Endpoints) == 0 {
http.Error(w, "no ready endpoint", http.StatusServiceUnavailable)
return
}
n, _ := p.rr.LoadOrStore(b.Key, new(uint64))
ep := b.Endpoints[int(atomic.AddUint64(n.(*uint64), 1)-1)%len(b.Endpoints)]
filters := append(append([]HTTPRouteFilter(nil), c.rule.Filters...), b.Filters...)
rp := &httputil.ReverseProxy{
Transport: p.transport,
FlushInterval: -1, // stream as it comes (git, server-sent events)
Rewrite: func(pr *httputil.ProxyRequest) {
pr.SetURL(&url.URL{Scheme: "http", Host: ep.String()})
pr.Out.Host = pr.In.Host // forwarded unmodified unless a filter says otherwise
for _, f := range filters {
switch {
case f.Type == filterRequestHeaderModifier && f.RequestHeaderModifier != nil:
modifyHeaders(pr.Out.Header, f.RequestHeaderModifier)
case f.Type == filterURLRewrite && f.URLRewrite != nil:
if f.URLRewrite.Hostname != nil {
pr.Out.Host = *f.URLRewrite.Hostname
}
if f.URLRewrite.Path != nil {
pr.Out.URL.Path = rewritePath(pr.In.URL.Path, f.URLRewrite.Path, c.prefix)
pr.Out.URL.RawPath = ""
}
}
}
// After the filters, so none can change these (the model already refuses routes that try): only this
// hop's view of the connection is passed on. Cleared first, as SetXForwarded appends to an
// X-Forwarded-For already there.
for _, h := range forwardingHeaders {
pr.Out.Header.Del(h)
}
pr.SetXForwarded()
if ip, _, err := net.SplitHostPort(pr.In.RemoteAddr); err == nil {
pr.Out.Header.Set("X-Real-IP", ip)
}
},
ModifyResponse: func(resp *http.Response) error {
for _, f := range filters {
if f.Type == filterResponseHeaderModifier && f.ResponseHeaderModifier != nil {
modifyHeaders(resp.Header, f.ResponseHeaderModifier)
}
}
return nil
},
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
common.Log.Debugf("lb: gateway %s -> %s: %v", s.addr, ep, err)
w.WriteHeader(http.StatusBadGateway)
},
}
rp.ServeHTTP(w, r)
}
// pick is the match that takes the request, by the spec's precedence: the most specific matching hostname, then
// an exact path, the longest prefix, a method, the most header matches, the most query matches, then the oldest
// route, its namespace/name, and the first rule.
func pick(l *liveListener, host string, r *http.Request) *candidate {
var best *candidate
for ri := range l.spec.Routes {
route := &l.spec.Routes[ri]
hostExact, hostLen, ok := routeHost(route, l.spec.Hostname, host)
if !ok {
continue
}
for i := range route.Rules {
rule := &route.Rules[i]
for _, m := range rule.Matches {
c, ok := matches(m, r)
if !ok {
continue
}
c.route, c.rule, c.ruleIdx, c.hostExact, c.hostLen = route, rule, i, hostExact, hostLen
if best == nil || better(c, *best) {
cc := c
best = &cc
}
}
}
}
return best
}
func better(a, b candidate) bool {
switch {
case a.hostExact != b.hostExact:
return a.hostExact > b.hostExact
case a.hostLen != b.hostLen:
return a.hostLen > b.hostLen
case a.exact != b.exact:
return a.exact
case a.prefixLen != b.prefixLen:
return a.prefixLen > b.prefixLen
case a.method != b.method:
return a.method
case a.headers != b.headers:
return a.headers > b.headers
case a.query != b.query:
return a.query > b.query
case !a.route.Created.Equal(b.route.Created):
return a.route.Created.Before(b.route.Created)
case a.route.Key != b.route.Key:
return a.route.Key < b.route.Key
}
return a.ruleIdx < b.ruleIdx
}
// routeHost reports whether a route answers this host on its listener, and how specifically.
func routeHost(route *ServeRoute, listenerHost, host string) (int, int, bool) {
names := route.Hostnames
if len(names) == 0 {
if listenerHost == "" {
return 0, 0, true
}
names = []string{listenerHost}
}
exact, length, ok := 0, 0, false
for _, h := range names {
if !hostMatches(h, host) {
continue
}
ok = true
e := 0
if !strings.HasPrefix(h, "*.") {
e = len(h)
}
if e > exact || (e == exact && len(h) > length) {
exact, length = e, len(h)
}
}
return exact, length, ok
}
func matches(m HTTPRouteMatch, r *http.Request) (candidate, bool) {
var c candidate
path, value := r.URL.Path, str(m.Path.Value)
switch str(m.Path.Type) {
case pathExact:
if path != value {
return c, false
}
c.exact = true
c.prefixLen = len(value)
default:
trimmed := strings.TrimSuffix(value, "/")
if trimmed != "" && path != trimmed && !strings.HasPrefix(path, trimmed+"/") {
return c, false
}
c.prefixLen = len(value)
c.prefix = value
}
if m.Method != nil {
if r.Method != *m.Method {
return c, false
}
c.method = true
}
for _, h := range m.Headers {
if r.Header.Get(h.Name) != h.Value {
return c, false
}
}
c.headers = len(m.Headers)
q := r.URL.Query()
for _, qp := range m.QueryParams {
if q.Get(qp.Name) != qp.Value {
return c, false
}
}
c.query = len(m.QueryParams)
return c, true
}
// chooseBackend picks a backend by weight (nil when there is none to pick).
func chooseBackend(bs []ServeBackend) *ServeBackend {
total := int32(0)
for _, b := range bs {
if b.Weight > 0 {
total += b.Weight
}
}
if total == 0 {
return nil
}
n := rand.Int31n(total)
for i := range bs {
if bs[i].Weight <= 0 {
continue
}
if n < bs[i].Weight {
return &bs[i]
}
n -= bs[i].Weight
}
return nil
}
// redirect is where a RequestRedirect filter sends a request, and with which status.
func redirect(r *http.Request, f *HTTPRequestRedirectFilter, listenerPort int32, prefix string) (string, int) {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
port := int(listenerPort)
if f.Scheme != nil {
// a new scheme brings its well-known port, unless a port is given
scheme = *f.Scheme
port = map[string]int{"http": 80, "https": 443}[scheme]
}
if f.Port != nil {
port = int(*f.Port)
}
host := hostOnly(r.Host)
if f.Hostname != nil {
host = *f.Hostname
}
if !(scheme == "http" && port == 80) && !(scheme == "https" && port == 443) && port != 0 {
host = net.JoinHostPort(host, strconv.Itoa(port))
}
path := r.URL.Path
if f.Path != nil {
path = rewritePath(path, f.Path, prefix)
}
code := http.StatusFound
if f.StatusCode != nil {
code = *f.StatusCode
}
return (&url.URL{Scheme: scheme, Host: host, Path: path, RawQuery: r.URL.RawQuery}).String(), code
}
// rewritePath applies a path modifier: the whole path, or the matched prefix replaced.
func rewritePath(path string, m *HTTPPathModifier, prefix string) string {
switch m.Type {
case "ReplaceFullPath":
return str(m.ReplaceFullPath)
case "ReplacePrefixMatch":
rest := strings.TrimPrefix(path, strings.TrimSuffix(prefix, "/"))
out := strings.TrimSuffix(str(m.ReplacePrefixMatch), "/") + rest
if !strings.HasPrefix(out, "/") {
out = "/" + out
}
return out
}
return path
}
func modifyHeaders(h http.Header, f *HTTPHeaderFilter) {
for _, s := range f.Set {
h.Set(s.Name, s.Value)
}
for _, a := range f.Add {
h.Add(a.Name, a.Value)
}
for _, name := range f.Remove {
h.Del(name)
}
}
// hostOnly is a Host header without its port, lowercased (the spec ignores the port when matching).
func hostOnly(h string) string {
if host, _, err := net.SplitHostPort(h); err == nil {
h = host
}
return strings.ToLower(strings.TrimSuffix(h, "."))
}
+333
View File
@@ -0,0 +1,333 @@
package lb
import (
"bytes"
"crypto/tls"
"crypto/x509"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"testing"
"time"
coreapi "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/types"
)
// testProxy binds every requested address on loopback and remembers where.
type testProxy struct {
*Proxy
mu sync.Mutex
actual map[string]string
}
func newTestProxy(t *testing.T) *testProxy {
tp := &testProxy{Proxy: NewProxy(), actual: map[string]string{}}
tp.listen = func(addr string) (net.Listener, error) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err == nil {
tp.mu.Lock()
tp.actual[addr] = ln.Addr().String()
tp.mu.Unlock()
}
return ln, err
}
t.Cleanup(tp.Close)
return tp
}
func (tp *testProxy) at(port int32) string {
tp.mu.Lock()
defer tp.mu.Unlock()
return tp.actual[net.JoinHostPort("192.0.2.1", strconv.Itoa(int(port)))]
}
// named is a backend that answers with its name and the request it saw.
func named(t *testing.T, name string) (*httptest.Server, Endpoint) {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Backend", name)
n, _ := io.Copy(io.Discard, r.Body)
fmt.Fprintf(w, "%s path=%s host=%s xff=%s proto=%s xhost=%s realip=%s added=%s removed=%s body=%d",
name, r.URL.Path, r.Host, strings.Join(r.Header.Values("X-Forwarded-For"), ","), r.Header.Get("X-Forwarded-Proto"),
r.Header.Get("X-Forwarded-Host"), r.Header.Get("X-Real-IP"), strings.Join(r.Header.Values("X-Added"), ","),
r.Header.Get("X-Remove-Me"), n)
}))
t.Cleanup(s.Close)
_, port, _ := net.SplitHostPort(s.Listener.Addr().String())
p, _ := strconv.Atoi(port)
return s, Endpoint{IP: net.ParseIP("127.0.0.1").To4(), Port: int32(p)}
}
func backend(name string, ep Endpoint) []ServeBackend {
return []ServeBackend{{Key: name, Weight: 1, Endpoints: []Endpoint{ep}}}
}
func prefix(v string) HTTPRouteMatch {
return HTTPRouteMatch{Path: &HTTPPathMatch{Type: ptr(pathPrefix), Value: ptr(v)}}
}
func exact(v string) HTTPRouteMatch {
return HTTPRouteMatch{Path: &HTTPPathMatch{Type: ptr(pathExact), Value: ptr(v)}}
}
func get(t *testing.T, url, host string, hdr map[string]string) (int, string, http.Header) {
t.Helper()
req, _ := http.NewRequest("GET", url, nil)
if host != "" {
req.Host = host
}
for k, v := range hdr {
req.Header.Set(k, v)
}
c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
resp, err := c.Do(req)
if err != nil {
t.Fatalf("GET %s: %v", url, err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(b), resp.Header
}
func TestProxyRoutesByPrecedence(t *testing.T) {
tp := newTestProxy(t)
_, a := named(t, "A")
_, b := named(t, "B")
_, c := named(t, "C")
_, d := named(t, "D")
_, e := named(t, "E")
t0 := time.Date(2026, 10, 14, 1, 0, 0, 0, time.UTC)
routes := []ServeRoute{
{Key: "ns/first", Created: t0, Rules: []ServeRule{
{Matches: []HTTPRouteMatch{exact("/exact")}, Backends: backend("A", a)},
{Matches: []HTTPRouteMatch{prefix("/api")}, Backends: backend("B", b)},
{Matches: []HTTPRouteMatch{prefix("/")}, Backends: backend("C", c)},
}},
{Key: "ns/second", Created: t0.Add(time.Minute), Rules: []ServeRule{
{Matches: []HTTPRouteMatch{prefix("/api/v2")}, Backends: backend("D", d)},
{Matches: []HTTPRouteMatch{{Path: &HTTPPathMatch{Type: ptr(pathPrefix), Value: ptr("/api")},
Headers: []HTTPHeaderMatch{{Name: "X-Ver", Value: "2"}}}}, Backends: backend("E", e)},
}},
}
if f := tp.Apply([]ServeListener{{Gateway: "ns/gw", Name: "http", Address: net.ParseIP("192.0.2.1"), Port: 8080, Routes: routes}}); len(f) != 0 {
t.Fatal(f)
}
base := "http://" + tp.at(8080)
for path, want := range map[string]string{
"/exact": "A", "/exactly": "C", "/api/x": "B", "/api": "B", "/api/v2/y": "D", "/apix": "C", "/": "C",
} {
if _, body, _ := get(t, base+path, "example.com", nil); !strings.HasPrefix(body, want+" ") {
t.Errorf("%s went to %q, want %s", path, body, want)
}
}
// same prefix length: the match with more headers takes it, though its route is younger
if _, body, _ := get(t, base+"/api/x", "example.com", map[string]string{"X-Ver": "2"}); !strings.HasPrefix(body, "E ") {
t.Errorf("header match lost: %q", body)
}
}
func TestProxyHostPrecedence(t *testing.T) {
tp := newTestProxy(t)
_, w := named(t, "W")
_, x := named(t, "X")
routes := []ServeRoute{
{Key: "ns/wild", Hostnames: []string{"*.example.com"}, Rules: []ServeRule{{Matches: []HTTPRouteMatch{prefix("/")}, Backends: backend("W", w)}}},
{Key: "ns/exact", Hostnames: []string{"a.example.com"}, Rules: []ServeRule{{Matches: []HTTPRouteMatch{prefix("/")}, Backends: backend("X", x)}}},
}
tp.Apply([]ServeListener{{Gateway: "ns/gw", Name: "http", Address: net.ParseIP("192.0.2.1"), Port: 8081, Routes: routes}})
base := "http://" + tp.at(8081)
for host, want := range map[string]string{"a.example.com": "X", "A.Example.com:8081": "X", "b.example.com": "W"} {
if _, body, _ := get(t, base+"/", host, nil); !strings.HasPrefix(body, want+" ") {
t.Errorf("host %s went to %q, want %s", host, body, want)
}
}
if code, _, _ := get(t, base+"/", "example.org", nil); code != http.StatusNotFound {
t.Errorf("a host no route names: %d, want 404", code)
}
}
func certSecret(t *testing.T, name, rv string) *coreapi.Secret {
s := tlsSecret(t, "gateway-system", strings.ReplaceAll(name, ".", "-"), name)
s.UID = types.UID("uid-" + name)
s.ResourceVersion = rv
return s
}
func dialTLS(t *testing.T, addr, sni string) (*x509.Certificate, error) {
conn, err := tls.Dial("tcp", addr, &tls.Config{ServerName: sni, InsecureSkipVerify: true})
if err != nil {
return nil, err
}
defer conn.Close()
return conn.ConnectionState().PeerCertificates[0], nil
}
func TestProxyTLSBySNIAndSwapsCertificates(t *testing.T) {
tp := newTestProxy(t)
_, a := named(t, "A")
_, b := named(t, "B")
listeners := func(certA *coreapi.Secret) []ServeListener {
return []ServeListener{
{Gateway: "ns/gw", Name: "a", Address: net.ParseIP("192.0.2.1"), Port: 8443, HTTPS: true, Hostname: "a.test", Cert: certA,
Routes: []ServeRoute{{Key: "ns/a", Rules: []ServeRule{{Matches: []HTTPRouteMatch{prefix("/")}, Backends: backend("A", a)}}}}},
{Gateway: "ns/gw", Name: "b", Address: net.ParseIP("192.0.2.1"), Port: 8443, HTTPS: true, Hostname: "b.test", Cert: certSecret(t, "b.test", "1"),
Routes: []ServeRoute{{Key: "ns/b", Rules: []ServeRule{{Matches: []HTTPRouteMatch{prefix("/")}, Backends: backend("B", b)}}}}},
}
}
first := certSecret(t, "a.test", "1")
if f := tp.Apply(listeners(first)); len(f) != 0 {
t.Fatal(f)
}
addr := tp.at(8443)
ca, err := dialTLS(t, addr, "a.test")
if err != nil || ca.Subject.CommonName != "a.test" {
t.Fatalf("SNI a.test: %v %v", ca, err)
}
cb, err := dialTLS(t, addr, "b.test")
if err != nil || cb.Subject.CommonName != "b.test" {
t.Fatalf("SNI b.test: %v %v", cb, err)
}
if _, err := dialTLS(t, addr, "c.test"); err == nil {
t.Fatal("a name no listener has must not get a certificate")
}
// requests go by listener, over HTTP/2 when the client offers it
client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true, ServerName: "b.test"}, ForceAttemptHTTP2: true}}
req, _ := http.NewRequest("GET", "https://"+addr+"/", nil)
req.Host = "b.test"
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if !strings.HasPrefix(string(body), "B ") || !strings.Contains(string(body), "proto=https") || resp.ProtoMajor != 2 {
t.Fatalf("over HTTPS to b.test: %s %q", resp.Proto, body)
}
// a renewed Secret: new handshakes get the new certificate on the same, still-bound port
renewed := certSecret(t, "a.test", "2")
tp.Apply(listeners(renewed))
if tp.at(8443) != addr {
t.Fatal("the port was rebound for a certificate change")
}
cn, err := dialTLS(t, addr, "a.test")
if err != nil || cn.SerialNumber.Cmp(ca.SerialNumber) == 0 {
t.Fatalf("still the old certificate after the swap: %v", err)
}
}
func TestProxyRedirectsAndForwards(t *testing.T) {
tp := newTestProxy(t)
_, a := named(t, "A")
routes := []ServeRoute{{Key: "ns/r", Rules: []ServeRule{
{Matches: []HTTPRouteMatch{prefix("/old")}, Filters: []HTTPRouteFilter{{Type: filterRequestRedirect,
RequestRedirect: &HTTPRequestRedirectFilter{Scheme: ptr("https"), StatusCode: ptr(301)}}}},
{Matches: []HTTPRouteMatch{prefix("/moved")}, Filters: []HTTPRouteFilter{{Type: filterRequestRedirect,
RequestRedirect: &HTTPRequestRedirectFilter{Path: &HTTPPathModifier{Type: "ReplacePrefixMatch", ReplacePrefixMatch: ptr("/new")}}}}},
{Matches: []HTTPRouteMatch{prefix("/v1")}, Backends: backend("A", a), Filters: []HTTPRouteFilter{
{Type: filterRequestHeaderModifier, RequestHeaderModifier: &HTTPHeaderFilter{
Set: []HTTPHeader{{Name: "X-Set", Value: "s"}}, Add: []HTTPHeader{{Name: "X-Added", Value: "two"}}, Remove: []string{"X-Remove-Me"}}},
{Type: filterResponseHeaderModifier, ResponseHeaderModifier: &HTTPHeaderFilter{Set: []HTTPHeader{{Name: "X-Resp", Value: "r"}}}},
{Type: filterURLRewrite, URLRewrite: &HTTPURLRewriteFilter{Path: &HTTPPathModifier{Type: "ReplacePrefixMatch", ReplacePrefixMatch: ptr("/v2")}}},
}},
{Matches: []HTTPRouteMatch{prefix("/")}, Backends: backend("A", a)},
}}}
tp.Apply([]ServeListener{{Gateway: "ns/gw", Name: "http", Address: net.ParseIP("192.0.2.1"), Port: 80, Routes: routes}})
base := "http://" + tp.at(80)
code, _, h := get(t, base+"/old/page?q=1", "git.levk.dev", nil)
if code != http.StatusMovedPermanently || h.Get("Location") != "https://git.levk.dev/old/page?q=1" {
t.Fatalf("scheme redirect: %d %q", code, h.Get("Location"))
}
code, _, h = get(t, base+"/moved/x", "git.levk.dev", nil)
if code != http.StatusFound || h.Get("Location") != "http://git.levk.dev/new/x" {
t.Fatalf("prefix redirect: %d %q", code, h.Get("Location"))
}
// the client's own claims about who it is are not passed on; the backend sees this hop's view
_, body, _ := get(t, base+"/", "git.levk.dev", map[string]string{"X-Forwarded-For": "6.6.6.6", "X-Real-IP": "6.6.6.6"})
if !strings.Contains(body, "host=git.levk.dev") || !strings.Contains(body, "xff=127.0.0.1 ") ||
!strings.Contains(body, "proto=http ") || !strings.Contains(body, "xhost=git.levk.dev") || !strings.Contains(body, "realip=127.0.0.1") {
t.Fatalf("forwarded headers: %q", body)
}
_, body, h = get(t, base+"/v1/thing", "git.levk.dev", map[string]string{"X-Added": "one", "X-Remove-Me": "x"})
if !strings.Contains(body, "path=/v2/thing") || !strings.Contains(body, "added=one,two") || !strings.Contains(body, "removed= ") || h.Get("X-Resp") != "r" {
t.Fatalf("filters: %q %v", body, h)
}
}
// The model refuses routes whose filters touch the forwarding headers; the proxy holds the line anyway.
func TestProxyForwardingHeadersSurviveFilters(t *testing.T) {
tp := newTestProxy(t)
_, a := named(t, "A")
routes := []ServeRoute{{Key: "ns/r", Rules: []ServeRule{{Matches: []HTTPRouteMatch{prefix("/")}, Backends: backend("A", a),
Filters: []HTTPRouteFilter{{Type: filterRequestHeaderModifier, RequestHeaderModifier: &HTTPHeaderFilter{
Set: []HTTPHeader{{Name: "X-Real-IP", Value: "6.6.6.6"}, {Name: "x-forwarded-host", Value: "evil.example"}},
Add: []HTTPHeader{{Name: "X-Forwarded-For", Value: "6.6.6.6"}},
Remove: []string{"X-Forwarded-Proto"},
}}}}}}}
tp.Apply([]ServeListener{{Gateway: "ns/gw", Name: "http", Address: net.ParseIP("192.0.2.1"), Port: 80, Routes: routes}})
_, body, _ := get(t, "http://"+tp.at(80)+"/", "git.levk.dev", map[string]string{"X-Forwarded-For": "7.7.7.7"})
for _, want := range []string{"xff=127.0.0.1 ", "proto=http ", "xhost=git.levk.dev ", "realip=127.0.0.1 "} {
if !strings.Contains(body, want) {
t.Fatalf("a filter changed the forwarding headers: %q (want %q)", body, want)
}
}
}
func TestProxyAnswersWhatItCannotRoute(t *testing.T) {
tp := newTestProxy(t)
routes := []ServeRoute{{Key: "ns/r", Rules: []ServeRule{
{Matches: []HTTPRouteMatch{prefix("/broken")}, Broken: true},
{Matches: []HTTPRouteMatch{prefix("/empty")}, Backends: []ServeBackend{{Key: "ns/svc:80", Weight: 1}}},
}}}
tp.Apply([]ServeListener{{Gateway: "ns/gw", Name: "http", Address: net.ParseIP("192.0.2.1"), Port: 8082, Routes: routes}})
base := "http://" + tp.at(8082)
for path, want := range map[string]int{"/broken": 500, "/empty": 503, "/nothing": 404} {
if code, _, _ := get(t, base+path, "x", nil); code != want {
t.Errorf("%s: %d, want %d", path, code, want)
}
}
}
func TestProxyStreamsLargeBodies(t *testing.T) {
tp := newTestProxy(t)
_, a := named(t, "A")
tp.Apply([]ServeListener{{Gateway: "ns/gw", Name: "http", Address: net.ParseIP("192.0.2.1"), Port: 8083,
Routes: []ServeRoute{{Key: "ns/r", Rules: []ServeRule{{Matches: []HTTPRouteMatch{prefix("/")}, Backends: backend("A", a)}}}}}})
const size = 32 << 20
resp, err := http.Post("http://"+tp.at(8083)+"/upload", "application/octet-stream", bytes.NewReader(make([]byte, size)))
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if !strings.Contains(string(body), fmt.Sprintf("body=%d", size)) {
t.Fatalf("upload: %q", body)
}
}
func TestProxyBindFailureAndClose(t *testing.T) {
tp := newTestProxy(t)
tp.listen = func(addr string) (net.Listener, error) { return nil, fmt.Errorf("address in use") }
f := tp.Apply([]ServeListener{{Gateway: "ns/gw", Name: "http", Address: net.ParseIP("192.0.2.1"), Port: 8084}})
if f["ns/gw/http"] == nil {
t.Fatalf("bind failure not reported: %v", f)
}
tp2 := newTestProxy(t)
tp2.Apply([]ServeListener{{Gateway: "ns/gw", Name: "http", Address: net.ParseIP("192.0.2.1"), Port: 8085}})
addr := tp2.at(8085)
tp2.Apply(nil)
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
c, err := net.DialTimeout("tcp", addr, 200*time.Millisecond)
if err != nil {
return
}
c.Close()
time.Sleep(50 * time.Millisecond)
}
t.Fatal("a port no longer served still accepts connections")
}
+611
View File
@@ -0,0 +1,611 @@
package lb
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"strings"
"testing"
"time"
coreapi "k8s.io/api/core/v1"
discovery "k8s.io/api/discovery/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
var gwNow = metav1.NewTime(time.Date(2026, 10, 14, 4, 0, 0, 0, time.UTC))
// selfSigned is a certificate and key for the given names, PEM-encoded.
func selfSigned(t *testing.T, names ...string) ([]byte, []byte) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatal(err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()), Subject: pkix.Name{CommonName: names[0]}, DNSNames: names,
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatal(err)
}
kb, _ := x509.MarshalECPrivateKey(key)
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: kb})
}
func tlsSecret(t *testing.T, namespace, name string, names ...string) *coreapi.Secret {
crt, key := selfSigned(t, names...)
return &coreapi.Secret{ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name}, Type: coreapi.SecretTypeTLS,
Data: map[string][]byte{coreapi.TLSCertKey: crt, coreapi.TLSPrivateKeyKey: key}}
}
func gwNodes() []Node {
return []Node{
{Name: "cp", Addresses: []net.IP{net.ParseIP("86.107.168.24").To4()}, WeaveIP: net.ParseIP("10.32.0.1").To4(), WeaveMAC: mustMAC("02:00:00:00:00:01")},
{Name: "w15", Addresses: []net.IP{net.ParseIP("86.107.168.15").To4()}, WeaveIP: net.ParseIP("10.44.0.0").To4(), WeaveMAC: mustMAC("02:00:00:00:00:02")},
}
}
func mustMAC(s string) net.HardwareAddr {
m, _ := net.ParseMAC(s)
return m
}
func ourClass() *GatewayClass {
return &GatewayClass{ObjectMeta: metav1.ObjectMeta{Name: "reweave", Generation: 1}, Spec: GatewayClassSpec{ControllerName: GatewayControllerName}}
}
// giteaGateway is the shape Gitea's documentation prescribes: HTTPS with a certificate, plain HTTP, and SSH over TCP.
func giteaGateway() *Gateway {
return &Gateway{
ObjectMeta: metav1.ObjectMeta{Namespace: "gateway-system", Name: "shared", Generation: 3},
Spec: GatewaySpec{
GatewayClassName: "reweave",
Addresses: []GatewayAddress{{Value: "86.107.168.15"}},
Listeners: []Listener{
{Name: "https-gitea", Port: 443, Protocol: ProtocolHTTPS, Hostname: ptr("git.levk.dev"),
TLS: &ListenerTLSConfig{CertificateRefs: []SecretObjectReference{{Name: "git-levk-dev"}}},
AllowedRoutes: &AllowedRoutes{Namespaces: &RouteNamespaces{From: ptr("Selector"), Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"kubernetes.io/metadata.name": "gitea"}}}}},
{Name: "http", Port: 80, Protocol: ProtocolHTTP, Hostname: ptr("git.levk.dev"),
AllowedRoutes: &AllowedRoutes{Namespaces: &RouteNamespaces{From: ptr("All")}}},
{Name: "ssh", Port: 22, Protocol: ProtocolTCP,
AllowedRoutes: &AllowedRoutes{Namespaces: &RouteNamespaces{From: ptr("All")}}},
},
},
}
}
func giteaService() *coreapi.Service {
return &coreapi.Service{ObjectMeta: metav1.ObjectMeta{Namespace: "gitea", Name: "gitea"}, Spec: coreapi.ServiceSpec{
Ports: []coreapi.ServicePort{{Name: "http", Port: 3000}, {Name: "ssh", Port: 22}},
}}
}
func giteaSlice() *discovery.EndpointSlice {
return &discovery.EndpointSlice{
ObjectMeta: metav1.ObjectMeta{Namespace: "gitea", Name: "gitea-abc", Labels: map[string]string{serviceNameLabel: "gitea"}},
AddressType: discovery.AddressTypeIPv4,
Ports: []discovery.EndpointPort{{Name: ptr("http"), Port: ptr(int32(3000))}, {Name: ptr("ssh"), Port: ptr(int32(22))}},
Endpoints: []discovery.Endpoint{{Addresses: []string{"10.44.0.12"}, Conditions: discovery.EndpointConditions{Ready: ptr(true)}}},
}
}
func giteaRoutes() ([]*HTTPRoute, []*TCPRoute) {
t0 := metav1.NewTime(time.Date(2026, 10, 14, 1, 0, 0, 0, time.UTC))
https := &HTTPRoute{
ObjectMeta: metav1.ObjectMeta{Namespace: "gitea", Name: "gitea", Generation: 1, CreationTimestamp: t0},
Spec: HTTPRouteSpec{
ParentRefs: []ParentReference{{Namespace: ptr("gateway-system"), Name: "shared", SectionName: ptr("https-gitea")}},
Hostnames: []string{"git.levk.dev"},
Rules: []HTTPRouteRule{{BackendRefs: []HTTPBackendRef{{BackendRef: BackendRef{Name: "gitea", Port: ptr(int32(3000))}}}}},
},
}
redirect := &HTTPRoute{
ObjectMeta: metav1.ObjectMeta{Namespace: "gitea", Name: "gitea-redirect", Generation: 1, CreationTimestamp: t0},
Spec: HTTPRouteSpec{
ParentRefs: []ParentReference{{Namespace: ptr("gateway-system"), Name: "shared", SectionName: ptr("http")}},
Rules: []HTTPRouteRule{{Filters: []HTTPRouteFilter{{Type: filterRequestRedirect,
RequestRedirect: &HTTPRequestRedirectFilter{Scheme: ptr("https"), StatusCode: ptr(301)}}}}},
},
}
ssh := &TCPRoute{
ObjectMeta: metav1.ObjectMeta{Namespace: "gitea", Name: "gitea-ssh", Generation: 1, CreationTimestamp: t0},
Spec: TCPRouteSpec{
ParentRefs: []ParentReference{{Namespace: ptr("gateway-system"), Name: "shared", SectionName: ptr("ssh")}},
Rules: []TCPRouteRule{{BackendRefs: []BackendRef{{Name: "gitea", Port: ptr(int32(22))}}}},
},
}
return []*HTTPRoute{https, redirect}, []*TCPRoute{ssh}
}
func giteaInput(t *testing.T) GatewayInput {
hr, tr := giteaRoutes()
return GatewayInput{
Classes: []*GatewayClass{ourClass()}, Gateways: []*Gateway{giteaGateway()}, HTTPRoutes: hr, TCPRoutes: tr,
Namespaces: []*coreapi.Namespace{
{ObjectMeta: metav1.ObjectMeta{Name: "gitea", Labels: map[string]string{"kubernetes.io/metadata.name": "gitea"}}},
{ObjectMeta: metav1.ObjectMeta{Name: "gateway-system", Labels: map[string]string{"kubernetes.io/metadata.name": "gateway-system"}}},
},
Services: []*coreapi.Service{giteaService()}, Slices: []*discovery.EndpointSlice{giteaSlice()},
Secrets: []*coreapi.Secret{tlsSecret(t, "gateway-system", "git-levk-dev", "git.levk.dev")},
SecretNamespaces: map[string]bool{"gateway-system": true},
Now: gwNow,
}
}
func condOf(cs []metav1.Condition, typ string) metav1.Condition {
for _, c := range cs {
if c.Type == typ {
return c
}
}
return metav1.Condition{}
}
func TestGatewayServedByItsHolder(t *testing.T) {
in := giteaInput(t)
d := ComputeGateways("w15", gwNodes(), in)
if len(d.Problems) != 0 {
t.Fatalf("problems: %v", d.Problems)
}
// HTTPS and HTTP served by the agent; SSH forwarded in the kernel to Gitea's pod with the client address intact
if len(d.Serve) != 2 {
t.Fatalf("want the https and http listeners served, got %+v", d.Serve)
}
https := d.Serve[0]
if !https.HTTPS || https.Port != 443 || https.Hostname != "git.levk.dev" || https.Cert == nil || len(https.Routes) != 1 {
t.Fatalf("https listener: %+v", https)
}
be := https.Routes[0].Rules[0].Backends
if len(be) != 1 || be[0].Key != "gitea/gitea:3000" || len(be[0].Endpoints) != 1 || be[0].Endpoints[0].String() != "10.44.0.12:3000" {
t.Fatalf("https backends: %+v", be)
}
if len(d.Forwards) != 1 || d.Forwards[0].Ports[0].Port != 22 || d.Forwards[0].Ports[0].Endpoints[0].String() != "10.44.0.12:22" {
t.Fatalf("ssh forward: %+v", d.Forwards)
}
if len(d.Holding) != 1 || d.Holding[0] != "w15" {
t.Fatalf("holding: %v", d.Holding)
}
// the holder writes the Gateway's status: accepted, programmed, its address, each listener with one route
if len(d.Gateways) != 1 {
t.Fatalf("gateway status writes: %+v", d.Gateways)
}
st := d.Gateways[0].Status
if c := condOf(st.Conditions, condProgrammed); c.Status != metav1.ConditionTrue || c.ObservedGeneration != 3 {
t.Fatalf("programmed: %+v", c)
}
if len(st.Addresses) != 1 || st.Addresses[0].Value != "86.107.168.15" {
t.Fatalf("addresses: %+v", st.Addresses)
}
for _, l := range st.Listeners {
if l.AttachedRoutes != 1 || condOf(l.Conditions, condProgrammed).Status != metav1.ConditionTrue {
t.Fatalf("listener %s: %+v", l.Name, l)
}
}
// route statuses come from the first node by name, not the holder
if len(d.Routes) != 0 || len(d.Classes) != 0 {
t.Fatalf("w15 wrote route or class statuses: %+v %+v", d.Routes, d.Classes)
}
}
func TestGatewayElsewhere(t *testing.T) {
in := giteaInput(t)
d := ComputeGateways("cp", gwNodes(), in)
if len(d.Serve) != 0 || len(d.Forwards) != 0 || len(d.Gateways) != 0 {
t.Fatalf("cp serves or writes what w15 holds: %+v %+v %+v", d.Serve, d.Forwards, d.Gateways)
}
// but steers replies of w15's forwards back to it, and writes class and route statuses (first by name)
if len(d.Holding) != 1 || d.Holding[0] != "w15" {
t.Fatalf("holding: %v", d.Holding)
}
if len(d.Classes) != 1 || condOf(d.Classes[0].Status.Conditions, condAccepted).Status != metav1.ConditionTrue {
t.Fatalf("class status: %+v", d.Classes)
}
if len(d.Routes) != 3 {
t.Fatalf("want 3 route statuses, got %+v", d.Routes)
}
for _, r := range d.Routes {
if len(r.Status.Parents) != 1 || condOf(r.Status.Parents[0].Conditions, condAccepted).Status != metav1.ConditionTrue ||
condOf(r.Status.Parents[0].Conditions, condResolvedRefs).Status != metav1.ConditionTrue {
t.Fatalf("route %s/%s status: %+v", r.Namespace, r.Name, r.Status)
}
}
}
func TestGatewayStatusIsWrittenOnce(t *testing.T) {
in := giteaInput(t)
d := ComputeGateways("w15", gwNodes(), in)
in.Gateways[0].Status = d.Gateways[0].Status
in.Now = metav1.NewTime(gwNow.Add(time.Hour)) // later passes see the status they wrote
if again := ComputeGateways("w15", gwNodes(), in); len(again.Gateways) != 0 {
t.Fatalf("unchanged status rewritten: %+v", again.Gateways)
}
c := ComputeGateways("cp", gwNodes(), in)
for _, r := range c.Routes {
for _, hr := range in.HTTPRoutes {
if hr.Name == r.Name {
hr.Status = r.Status
}
}
for _, tr := range in.TCPRoutes {
if tr.Name == r.Name {
tr.Status = r.Status
}
}
}
in.Classes[0].Status = c.Classes[0].Status
if again := ComputeGateways("cp", gwNodes(), in); len(again.Routes) != 0 || len(again.Classes) != 0 {
t.Fatalf("unchanged route or class status rewritten: %+v %+v", again.Routes, again.Classes)
}
}
func TestGatewayFailsClosedWithoutHolder(t *testing.T) {
in := giteaInput(t)
in.Gateways[0].Spec.Addresses[0].Value = "198.51.100.7"
for _, self := range []string{"cp", "w15"} {
d := ComputeGateways(self, gwNodes(), in)
if len(d.Serve) != 0 || len(d.Forwards) != 0 || len(d.Holding) != 0 {
t.Fatalf("%s serves an address nobody holds: %+v", self, d)
}
}
d := ComputeGateways("cp", gwNodes(), in)
if len(d.Gateways) != 1 {
t.Fatalf("the first node by name must write the failure: %+v", d.Gateways)
}
c := condOf(d.Gateways[0].Status.Conditions, condProgrammed)
if c.Status != metav1.ConditionFalse || c.Reason != "AddressNotUsable" || len(d.Gateways[0].Status.Addresses) != 0 {
t.Fatalf("status: %+v", d.Gateways[0].Status)
}
}
func TestGatewayAddressMustBeOneIPv4(t *testing.T) {
in := giteaInput(t)
in.Gateways[0].Spec.Addresses = nil
d := ComputeGateways("cp", gwNodes(), in)
if c := condOf(d.Gateways[0].Status.Conditions, condAccepted); c.Status != metav1.ConditionFalse || c.Reason != "UnsupportedAddress" {
t.Fatalf("accepted: %+v", c)
}
}
func TestListenerCertificateReferences(t *testing.T) {
cases := map[string]struct {
mutate func(in *GatewayInput)
reason string
}{
"missing Secret": {func(in *GatewayInput) { in.Secrets = nil }, "InvalidCertificateRef"},
"other namespace without grant": {func(in *GatewayInput) {
in.Gateways[0].Spec.Listeners[0].TLS.CertificateRefs[0].Namespace = ptr("gitea")
in.SecretNamespaces["gitea"] = true
}, "RefNotPermitted"},
"namespace the agent cannot read": {func(in *GatewayInput) {
in.SecretNamespaces = map[string]bool{}
}, "RefNotPermitted"},
"not a certificate": {func(in *GatewayInput) {
in.Secrets[0].Data[coreapi.TLSCertKey] = []byte("nope")
}, "InvalidCertificateRef"},
}
for name, c := range cases {
in := giteaInput(t)
c.mutate(&in)
d := ComputeGateways("w15", gwNodes(), in)
var https ListenerStatus
for _, l := range d.Gateways[0].Status.Listeners {
if l.Name == "https-gitea" {
https = l
}
}
if r := condOf(https.Conditions, condResolvedRefs); r.Status != metav1.ConditionFalse || r.Reason != c.reason {
t.Errorf("%s: resolvedRefs %+v", name, r)
}
if p := condOf(https.Conditions, condProgrammed); p.Status != metav1.ConditionFalse {
t.Errorf("%s: an HTTPS listener without its certificate must not be programmed", name)
}
for _, s := range d.Serve {
if s.HTTPS {
t.Errorf("%s: HTTPS listener served without a certificate", name)
}
}
if a := condOf(d.Gateways[0].Status.Conditions, condAccepted); a.Reason != "ListenersNotValid" || a.Status != metav1.ConditionTrue {
t.Errorf("%s: gateway accepted %+v", name, a)
}
}
}
func TestCrossNamespaceCertificateWithGrant(t *testing.T) {
in := giteaInput(t)
in.Gateways[0].Spec.Listeners[0].TLS.CertificateRefs[0].Namespace = ptr("gitea")
in.Secrets = []*coreapi.Secret{tlsSecret(t, "gitea", "git-levk-dev", "git.levk.dev")}
in.SecretNamespaces = map[string]bool{"gitea": true}
in.Grants = []*ReferenceGrant{{ObjectMeta: metav1.ObjectMeta{Namespace: "gitea", Name: "gw"}, Spec: ReferenceGrantSpec{
From: []ReferenceGrantFrom{{Group: GatewayGroup, Kind: kindGateway, Namespace: "gateway-system"}},
To: []ReferenceGrantTo{{Group: "", Kind: kindSecret}},
}}}
d := ComputeGateways("w15", gwNodes(), in)
if len(d.Serve) != 2 || d.Serve[0].Cert == nil {
t.Fatalf("a granted certificate in another namespace must be served: %+v", d.Serve)
}
}
func TestListenerConflicts(t *testing.T) {
in := giteaInput(t)
l := in.Gateways[0].Spec.Listeners
in.Gateways[0].Spec.Listeners = append(l,
Listener{Name: "ssh2", Port: 22, Protocol: ProtocolTCP}, // two TCP listeners on 22
Listener{Name: "http-on-443", Port: 443, Protocol: ProtocolHTTP}, // HTTP and HTTPS on 443
)
d := ComputeGateways("w15", gwNodes(), in)
want := map[string]string{"ssh": "HostnameConflict", "ssh2": "HostnameConflict", "https-gitea": "ProtocolConflict", "http-on-443": "ProtocolConflict", "http": ""}
for _, ls := range d.Gateways[0].Status.Listeners {
c := condOf(ls.Conditions, condConflicted)
if want[ls.Name] == "" {
if c.Status != metav1.ConditionFalse {
t.Errorf("%s: conflicted %+v", ls.Name, c)
}
continue
}
if c.Status != metav1.ConditionTrue || c.Reason != want[ls.Name] {
t.Errorf("%s: want conflicted %s, got %+v", ls.Name, want[ls.Name], c)
}
}
if len(d.Forwards) != 0 {
t.Fatalf("conflicted TCP listeners forwarded: %+v", d.Forwards)
}
if len(d.Serve) != 1 || d.Serve[0].Name != "http" {
t.Fatalf("only the plain http listener on 80 is unconflicted: %+v", d.Serve)
}
}
func TestListenerPortTakenByLoadBalancerService(t *testing.T) {
in := giteaInput(t)
class := Class
in.Services = append(in.Services, &coreapi.Service{ObjectMeta: metav1.ObjectMeta{Namespace: "mail", Name: "smtp"}, Spec: coreapi.ServiceSpec{
Type: coreapi.ServiceTypeLoadBalancer, LoadBalancerClass: &class, LoadBalancerIP: "86.107.168.15",
Ports: []coreapi.ServicePort{{Port: 22}},
}})
d := ComputeGateways("w15", gwNodes(), in)
for _, ls := range d.Gateways[0].Status.Listeners {
if ls.Name == "ssh" {
if c := condOf(ls.Conditions, condAccepted); c.Status != metav1.ConditionFalse || c.Reason != "PortUnavailable" {
t.Fatalf("ssh accepted: %+v", c)
}
}
}
if len(d.Forwards) != 0 {
t.Fatalf("a listener on a port a Service forwards must not be forwarded: %+v", d.Forwards)
}
}
func TestRouteAttachment(t *testing.T) {
cases := map[string]struct {
mutate func(in *GatewayInput)
route string
reason string
}{
"unknown section": {func(in *GatewayInput) { in.HTTPRoutes[0].Spec.ParentRefs[0].SectionName = ptr("nope") }, "gitea", "NoMatchingParent"},
"namespace not selected": {func(in *GatewayInput) { in.Namespaces[0].Labels = nil }, "gitea", "NotAllowedByListeners"},
"hostname outside the listener's": {func(in *GatewayInput) { in.HTTPRoutes[0].Spec.Hostnames = []string{"git.example.net"} }, "gitea", "NoMatchingListenerHostname"},
"unsupported filter": {func(in *GatewayInput) {
in.HTTPRoutes[0].Spec.Rules[0].Filters = []HTTPRouteFilter{{Type: "RequestMirror"}}
}, "gitea", "UnsupportedValue"},
"regular expression path": {func(in *GatewayInput) {
in.HTTPRoutes[0].Spec.Rules[0].Matches = []HTTPRouteMatch{{Path: &HTTPPathMatch{Type: ptr("RegularExpression"), Value: ptr("/.*")}}}
}, "gitea", "UnsupportedValue"},
"filter sets X-Forwarded-For": {func(in *GatewayInput) {
in.HTTPRoutes[0].Spec.Rules[0].Filters = []HTTPRouteFilter{{Type: filterRequestHeaderModifier,
RequestHeaderModifier: &HTTPHeaderFilter{Set: []HTTPHeader{{Name: "X-Forwarded-For", Value: "6.6.6.6"}}}}}
}, "gitea", "UnsupportedValue"},
"backend filter adds x-real-ip": {func(in *GatewayInput) {
in.HTTPRoutes[0].Spec.Rules[0].BackendRefs[0].Filters = []HTTPRouteFilter{{Type: filterRequestHeaderModifier,
RequestHeaderModifier: &HTTPHeaderFilter{Add: []HTTPHeader{{Name: "x-real-ip", Value: "6.6.6.6"}}}}}
}, "gitea", "UnsupportedValue"},
"filter removes Forwarded": {func(in *GatewayInput) {
in.HTTPRoutes[0].Spec.Rules[0].Filters = []HTTPRouteFilter{{Type: filterRequestHeaderModifier,
RequestHeaderModifier: &HTTPHeaderFilter{Remove: []string{"FORWARDED"}}}}
}, "gitea", "UnsupportedValue"},
"wrong port": {func(in *GatewayInput) { in.TCPRoutes[0].Spec.ParentRefs[0].Port = ptr(int32(2222)) }, "gitea-ssh", "NoMatchingParent"},
}
for name, c := range cases {
in := giteaInput(t)
c.mutate(&in)
d := ComputeGateways("cp", gwNodes(), in)
found := false
for _, r := range d.Routes {
if r.Name != c.route {
continue
}
found = true
a := condOf(r.Status.Parents[0].Conditions, condAccepted)
if a.Status != metav1.ConditionFalse || a.Reason != c.reason {
t.Errorf("%s: accepted %+v", name, a)
}
}
if !found {
t.Errorf("%s: no status for route %s", name, c.route)
}
h := ComputeGateways("w15", gwNodes(), in)
if c.route == "gitea" {
for _, s := range h.Serve {
if s.Name == "https-gitea" && len(s.Routes) != 0 {
t.Errorf("%s: a route that is not accepted was attached", name)
}
}
}
}
}
func TestForwardingHeadersAreTheGateways(t *testing.T) {
accepted := func(name string, value string) metav1.Condition {
in := giteaInput(t)
in.HTTPRoutes[0].Spec.Rules[0].Filters = []HTTPRouteFilter{{Type: filterRequestHeaderModifier,
RequestHeaderModifier: &HTTPHeaderFilter{Set: []HTTPHeader{{Name: name, Value: value}}}}}
for _, r := range ComputeGateways("cp", gwNodes(), in).Routes {
if r.Name == "gitea" {
return condOf(r.Status.Parents[0].Conditions, condAccepted)
}
}
t.Fatalf("no status for route gitea")
return metav1.Condition{}
}
a := accepted("x-forwarded-proto", "https")
if a.Status != metav1.ConditionFalse || a.Reason != "UnsupportedValue" || !strings.Contains(a.Message, "X-Forwarded-Proto") {
t.Fatalf("a filter on X-Forwarded-Proto: %+v", a)
}
// a header that only looks like one of them is the route's to set
if a := accepted("X-Forwarded-Prefix", "/git"); a.Status != metav1.ConditionTrue {
t.Fatalf("a filter on X-Forwarded-Prefix: %+v", a)
}
}
func TestBackendNotFoundBreaksTheRule(t *testing.T) {
in := giteaInput(t)
in.HTTPRoutes[0].Spec.Rules[0].BackendRefs[0].Name = "nope"
d := ComputeGateways("cp", gwNodes(), in)
for _, r := range d.Routes {
if r.Name == "gitea" {
if c := condOf(r.Status.Parents[0].Conditions, condResolvedRefs); c.Status != metav1.ConditionFalse || c.Reason != "BackendNotFound" {
t.Fatalf("resolvedRefs: %+v", c)
}
if c := condOf(r.Status.Parents[0].Conditions, condAccepted); c.Status != metav1.ConditionTrue {
t.Fatalf("a route with an unresolved backend is still accepted: %+v", c)
}
}
}
h := ComputeGateways("w15", gwNodes(), in)
for _, s := range h.Serve {
if s.Name == "https-gitea" && (len(s.Routes) != 1 || !s.Routes[0].Rules[0].Broken) {
t.Fatalf("the rule must be served broken (500): %+v", s.Routes)
}
}
}
func TestTCPListenerForwardsTheOldestRoute(t *testing.T) {
in := giteaInput(t)
later := in.TCPRoutes[0].DeepCopyObject().(*TCPRoute)
later.Name = "other-ssh"
later.CreationTimestamp = metav1.NewTime(later.CreationTimestamp.Add(time.Minute))
later.Spec.Rules[0].BackendRefs[0].Name = "other"
in.TCPRoutes = append(in.TCPRoutes, later)
d := ComputeGateways("cp", gwNodes(), in)
for _, r := range d.Routes {
a := condOf(r.Status.Parents[0].Conditions, condAccepted)
switch r.Name {
case "gitea-ssh":
if a.Status != metav1.ConditionTrue {
t.Fatalf("oldest route: %+v", a)
}
case "other-ssh":
if a.Status != metav1.ConditionFalse || !strings.Contains(a.Message, "gitea/gitea-ssh") {
t.Fatalf("second route: %+v", a)
}
}
}
}
func TestOtherControllersParentsKept(t *testing.T) {
in := giteaInput(t)
theirs := RouteParentStatus{ParentRef: ParentReference{Name: "elsewhere"}, ControllerName: "example.com/other",
Conditions: []metav1.Condition{{Type: condAccepted, Status: metav1.ConditionTrue, Reason: "Accepted", LastTransitionTime: gwNow}}}
in.HTTPRoutes[0].Status.Parents = []RouteParentStatus{theirs}
d := ComputeGateways("cp", gwNodes(), in)
for _, r := range d.Routes {
if r.Name == "gitea" {
if len(r.Status.Parents) != 2 || r.Status.Parents[0].ControllerName != "example.com/other" {
t.Fatalf("another controller's entry must be kept: %+v", r.Status.Parents)
}
}
}
}
func TestOtherClassesIgnored(t *testing.T) {
in := giteaInput(t)
in.Classes[0].Spec.ControllerName = "example.com/other"
for _, self := range []string{"cp", "w15"} {
d := ComputeGateways(self, gwNodes(), in)
if len(d.Serve)+len(d.Forwards)+len(d.Gateways)+len(d.Routes)+len(d.Classes) != 0 {
t.Fatalf("%s acted on another controller's class: %+v", self, d)
}
}
}
func TestNarrowHostnames(t *testing.T) {
cases := []struct {
listener string
route []string
want []string
ok bool
}{
{"", nil, nil, true},
{"git.levk.dev", nil, []string{"git.levk.dev"}, true},
{"", []string{"a.example.com"}, []string{"a.example.com"}, true},
{"test.example.com", []string{"*.example.com"}, []string{"test.example.com"}, true},
{"*.example.com", []string{"test.example.com", "test.example.net", "foo.test.example.com"}, []string{"test.example.com", "foo.test.example.com"}, true},
{"*.example.com", []string{"example.com"}, nil, false},
{"*.example.com", []string{"*.a.example.com"}, []string{"*.a.example.com"}, true},
{"*.a.example.com", []string{"*.example.com"}, []string{"*.a.example.com"}, true},
{"git.levk.dev", []string{"git.example.net"}, nil, false},
}
for _, c := range cases {
got, ok := narrowHostnames(c.listener, c.route)
if ok != c.ok || strings.Join(got, ",") != strings.Join(c.want, ",") {
t.Errorf("listener %q route %v: got %v %v, want %v %v", c.listener, c.route, got, ok, c.want, c.ok)
}
}
}
func TestMergeGateways(t *testing.T) {
in := giteaInput(t)
nodes := gwNodes()
// on the holder: Gitea's SSH forwarded, its HTTP(S) ports let in
d := MergeGateways(Compute("w15", nodes, nil, nil), ComputeGateways("w15", nodes, in), "w15", nodes)
if len(d.Forwards) != 1 || len(d.Accept) != 2 || len(d.Holders) != 0 {
t.Fatalf("holder: forwards %+v accept %+v holders %+v", d.Forwards, d.Accept, d.Holders)
}
r := Render(d, EgressDesired{}, "w15", &net.IPNet{IP: net.ParseIP("10.32.0.0"), Mask: net.CIDRMask(12, 32)}, nil)
for _, want := range []string{
"-A REWEAVE-LB-IN -d 86.107.168.15/32 -p tcp -m tcp --dport 443",
"-A REWEAVE-LB-IN -d 86.107.168.15/32 -p tcp -m tcp --dport 80",
} {
if !strings.Contains(r.Filter, want) {
t.Errorf("filter lacks %q:\n%s", want, r.Filter)
}
}
if !strings.Contains(r.Nat, "-A REWEAVE-LB -d 86.107.168.15/32 -p tcp -m tcp --dport 22") || !strings.Contains(r.Nat, "--to-destination 10.44.0.12:22") {
t.Errorf("nat lacks the SSH forward:\n%s", r.Nat)
}
// elsewhere: w15 is a holder whose replies come back to it
e := MergeGateways(Compute("cp", nodes, nil, nil), ComputeGateways("cp", nodes, in), "cp", nodes)
if len(e.Forwards) != 0 || len(e.Accept) != 0 || len(e.Holders) != 1 || e.Holders[0].Node.Name != "w15" {
t.Fatalf("cp: forwards %+v accept %+v holders %+v", e.Forwards, e.Accept, e.Holders)
}
}
func TestUnboundListenerReported(t *testing.T) {
in := giteaInput(t)
in.Unbound = map[string]string{"gateway-system/shared/https-gitea": "address already in use"}
d := ComputeGateways("w15", gwNodes(), in)
for _, l := range d.Gateways[0].Status.Listeners {
if l.Name != "https-gitea" {
continue
}
if a := condOf(l.Conditions, condAccepted); a.Status != metav1.ConditionFalse || a.Reason != "PortUnavailable" {
t.Fatalf("accepted: %+v", a)
}
if p := condOf(l.Conditions, condProgrammed); p.Status != metav1.ConditionFalse {
t.Fatalf("programmed: %+v", p)
}
}
served := false
for _, s := range d.Serve {
served = served || s.Name == "https-gitea"
}
if !served {
t.Fatal("an unbound listener is still tried on every pass")
}
}
+318
View File
@@ -0,0 +1,318 @@
// Package lb gives LoadBalancer Services an address that a node holds, and
// forwards that address's traffic to the Service's endpoints with the
// client's address intact.
//
// A Service opts in with spec.loadBalancerClass = Class and names its
// address in spec.loadBalancerIP. The node that holds the address (has it on
// one of its interfaces, as published in its AnnAddresses annotation) is the
// address's holder: it alone writes the Service's status (ipMode Proxy, so
// kube-proxy installs no rules for the address) and DNATs the address's
// ports to ready endpoints, without SNAT. Every other node marks the
// connections that the holder forwards to its pods, and routes their replies
// back to the holder over weave, so that the holder's conntrack can undo the
// DNAT and the reply leaves from the address the client connected to.
//
// Compute is the whole policy as a pure function of what the API shows; the
// datapath and the controller only carry it out.
package lb
import (
"fmt"
"hash/fnv"
"net"
"sort"
"strings"
coreapi "k8s.io/api/core/v1"
discovery "k8s.io/api/discovery/v1"
)
const (
// Class is the spec.loadBalancerClass a Service sets to be served by this agent.
Class = "reweave.levk.dev/node-address"
// AnnAddresses lists the IPv4 addresses a node holds on its host interfaces (comma-separated).
AnnAddresses = "reweave.levk.dev/addresses"
// AnnWeave is "<ip>,<mac>" of the node's weave bridge: where replies to its forwards go.
AnnWeave = "reweave.levk.dev/weave"
serviceNameLabel = "kubernetes.io/service-name"
)
// Node is what one node publishes about itself.
type Node struct {
Name string
Addresses []net.IP
WeaveIP net.IP
WeaveMAC net.HardwareAddr
}
// Holds reports whether the node has ip on one of its host interfaces.
func (n Node) Holds(ip net.IP) bool {
for _, a := range n.Addresses {
if a.Equal(ip) {
return true
}
}
return false
}
// ParseNode reads a Node's annotations. A node that has published nothing
// (yet) holds nothing and cannot take replies.
func ParseNode(n *coreapi.Node) (Node, error) {
out := Node{Name: n.Name}
if s := n.Annotations[AnnAddresses]; s != "" {
for _, f := range strings.Split(s, ",") {
ip := net.ParseIP(strings.TrimSpace(f)).To4()
if ip == nil {
return out, fmt.Errorf("node %s: %s: %q is not an IPv4 address", n.Name, AnnAddresses, f)
}
out.Addresses = append(out.Addresses, ip)
}
}
if s := n.Annotations[AnnWeave]; s != "" {
f := strings.Split(s, ",")
if len(f) != 2 {
return out, fmt.Errorf("node %s: %s: want \"<ip>,<mac>\", got %q", n.Name, AnnWeave, s)
}
ip := net.ParseIP(f[0]).To4()
mac, err := net.ParseMAC(f[1])
if ip == nil || err != nil {
return out, fmt.Errorf("node %s: %s: want \"<ip>,<mac>\", got %q", n.Name, AnnWeave, s)
}
out.WeaveIP, out.WeaveMAC = ip, mac
}
return out, nil
}
// FormatAddresses is the AnnAddresses value for a set of addresses (sorted, so it only changes when the set does).
func FormatAddresses(ips []net.IP) string {
s := make([]string, 0, len(ips))
for _, ip := range ips {
s = append(s, ip.String())
}
sort.Strings(s)
return strings.Join(s, ",")
}
// Endpoint is one ready backend of one Service port.
type Endpoint struct {
IP net.IP
Port int32
}
func (e Endpoint) String() string { return fmt.Sprintf("%s:%d", e.IP, e.Port) }
// Port is one Service port at the held address.
type Port struct {
Protocol coreapi.Protocol
Port int32
Endpoints []Endpoint
}
// Forward is a Service whose address this node holds.
type Forward struct {
Namespace, Name string
Address net.IP
Ports []Port
}
// Status is a write to a Service's status.loadBalancer: Address set, or cleared when nil.
type Status struct {
Namespace, Name string
Address net.IP
}
// Holder is another node that forwards held addresses' connections to pods; Mark identifies its connections here.
type Holder struct {
Node Node
Mark uint32 // 1..MaxMarks, in the connmark's top byte
}
// MaxMarks is how many holders a node can tell apart (the top byte of the mark, 0 meaning none).
const MaxMarks = 250
// Desired is what one node must do.
type Desired struct {
Forwards []Forward
Holders []Holder
Status []Status
// Accept are the address:ports this node serves itself (Gateways' HTTP and HTTPS listeners), let in ahead of
// the host firewall
Accept []Accept
Problems []string // what an operator should hear about (logged and evented by the controller)
}
// Accept is a TCP address:port this node serves itself.
type Accept struct {
Address net.IP
Port int32
Comment string
}
// eligible reports whether the Service asks for this agent.
func eligible(s *coreapi.Service) bool {
return s.Spec.Type == coreapi.ServiceTypeLoadBalancer && s.Spec.LoadBalancerClass != nil &&
*s.Spec.LoadBalancerClass == Class
}
// shown is the address status.loadBalancer currently shows (nil if none or not a single IP).
func shown(s *coreapi.Service) net.IP {
if len(s.Status.LoadBalancer.Ingress) != 1 {
return nil
}
return net.ParseIP(s.Status.LoadBalancer.Ingress[0].IP).To4()
}
func hasStatus(s *coreapi.Service) bool { return len(s.Status.LoadBalancer.Ingress) != 0 }
// Compute decides what node `self` does, given every node, every Service and every EndpointSlice.
func Compute(self string, nodes []Node, services []*coreapi.Service, slices []*discovery.EndpointSlice) Desired {
var d Desired
sortedNodes := append([]Node(nil), nodes...)
sort.Slice(sortedNodes, func(i, j int) bool { return sortedNodes[i].Name < sortedNodes[j].Name })
holdersOf := func(ip net.IP) []Node {
var hs []Node
for _, n := range sortedNodes {
if ip != nil && n.Holds(ip) {
hs = append(hs, n)
}
}
return hs
}
// who may write a status that shows `ip`: its single holder, or (nobody holds it) the first node by name
writerFor := func(ip net.IP) string {
if hs := holdersOf(ip); len(hs) == 1 {
return hs[0].Name
}
if len(sortedNodes) > 0 {
return sortedNodes[0].Name
}
return ""
}
holding := map[string]bool{} // nodes that forward some address
svcs := append([]*coreapi.Service(nil), services...)
sort.Slice(svcs, func(i, j int) bool { return key(svcs[i]) < key(svcs[j]) })
for _, s := range svcs {
if !eligible(s) {
// loadBalancerClass is immutable and only allowed on type LoadBalancer, so a
// Service never leaves the class while keeping a status we wrote
continue
}
want := net.ParseIP(s.Spec.LoadBalancerIP).To4()
var holder *Node
switch hs := holdersOf(want); {
case want == nil:
d.Problems = append(d.Problems, fmt.Sprintf("%s: spec.loadBalancerIP must name an IPv4 address a node holds (got %q)", key(s), s.Spec.LoadBalancerIP))
case len(hs) == 0:
d.Problems = append(d.Problems, fmt.Sprintf("%s: no node holds %s", key(s), want))
case len(hs) > 1:
names := make([]string, len(hs))
for i, h := range hs {
names[i] = h.Name
}
d.Problems = append(d.Problems, fmt.Sprintf("%s: %s is held by several nodes (%s); serving it from none", key(s), want, strings.Join(names, ", ")))
default:
holder = &hs[0]
}
if holder == nil {
// fail closed: nobody forwards, and the status must not claim an address
if hasStatus(s) && writerFor(shown(s)) == self {
d.Status = append(d.Status, Status{Namespace: s.Namespace, Name: s.Name})
}
continue
}
holding[holder.Name] = true
if holder.Name != self {
continue
}
if cur := shown(s); cur == nil || !cur.Equal(want) {
d.Status = append(d.Status, Status{Namespace: s.Namespace, Name: s.Name, Address: want})
}
d.Forwards = append(d.Forwards, Forward{Namespace: s.Namespace, Name: s.Name, Address: want, Ports: ports(s, slices)})
}
var others []Node
for _, n := range sortedNodes {
if holding[n.Name] && n.Name != self {
if n.WeaveIP == nil {
d.Problems = append(d.Problems, fmt.Sprintf("node %s holds an address but has not published its weave bridge; replies to its forwards cannot reach it", n.Name))
continue
}
others = append(others, n)
}
}
d.Holders = assignMarks(others)
return d
}
// assignMarks gives each holder a mark from its name, probing past collisions in name order: stable while the set is.
func assignMarks(nodes []Node) []Holder {
taken := map[uint32]bool{}
out := make([]Holder, 0, len(nodes))
for _, n := range nodes {
if len(out) == MaxMarks {
break
}
h := fnv.New32a()
h.Write([]byte(n.Name))
m := h.Sum32()%MaxMarks + 1
for taken[m] {
m = m%MaxMarks + 1
}
taken[m] = true
out = append(out, Holder{Node: n, Mark: m})
}
return out
}
func key(s *coreapi.Service) string { return s.Namespace + "/" + s.Name }
// ports pairs each Service port with its ready endpoints.
func ports(s *coreapi.Service, slices []*discovery.EndpointSlice) []Port {
var out []Port
for _, sp := range s.Spec.Ports {
proto := sp.Protocol
if proto == "" {
proto = coreapi.ProtocolTCP
}
seen := map[string]bool{}
var eps []Endpoint
for _, sl := range slices {
if sl.Namespace != s.Namespace || sl.Labels[serviceNameLabel] != s.Name || sl.AddressType != discovery.AddressTypeIPv4 {
continue
}
for _, p := range sl.Ports {
pname, pproto := "", coreapi.ProtocolTCP
if p.Name != nil {
pname = *p.Name
}
if p.Protocol != nil {
pproto = *p.Protocol
}
if pname != sp.Name || pproto != proto || p.Port == nil {
continue
}
for _, ep := range sl.Endpoints {
if ep.Conditions.Ready != nil && !*ep.Conditions.Ready {
continue
}
for _, a := range ep.Addresses {
ip := net.ParseIP(a).To4()
if ip == nil {
continue
}
e := Endpoint{IP: ip, Port: *p.Port}
if !seen[e.String()] {
seen[e.String()] = true
eps = append(eps, e)
}
}
}
}
}
sort.Slice(eps, func(i, j int) bool { return eps[i].String() < eps[j].String() })
out = append(out, Port{Protocol: proto, Port: sp.Port, Endpoints: eps})
}
return out
}
+253
View File
@@ -0,0 +1,253 @@
package lb
import (
"fmt"
"net"
"strings"
"testing"
coreapi "k8s.io/api/core/v1"
discovery "k8s.io/api/discovery/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
func mac(s string) net.HardwareAddr {
m, err := net.ParseMAC(s)
if err != nil {
panic(err)
}
return m
}
var (
cp = Node{Name: "cp", Addresses: []net.IP{net.ParseIP("86.107.168.24").To4()},
WeaveIP: net.ParseIP("10.32.0.1").To4(), WeaveMAC: mac("aa:aa:aa:aa:aa:01")}
w1 = Node{Name: "w1", Addresses: []net.IP{net.ParseIP("86.107.168.15").To4()},
WeaveIP: net.ParseIP("10.40.0.0").To4(), WeaveMAC: mac("aa:aa:aa:aa:aa:02")}
w2 = Node{Name: "w2", Addresses: []net.IP{net.ParseIP("86.107.168.88").To4()},
WeaveIP: net.ParseIP("10.44.0.0").To4(), WeaveMAC: mac("aa:aa:aa:aa:aa:03")}
nodes = []Node{w2, cp, w1}
)
func svc(name, ip string, mutate ...func(*coreapi.Service)) *coreapi.Service {
class := Class
s := &coreapi.Service{
ObjectMeta: metav1.ObjectMeta{Namespace: "gitea", Name: name},
Spec: coreapi.ServiceSpec{
Type: coreapi.ServiceTypeLoadBalancer,
LoadBalancerClass: &class,
LoadBalancerIP: ip,
Ports: []coreapi.ServicePort{
{Name: "ssh", Protocol: coreapi.ProtocolTCP, Port: 22},
{Name: "https", Port: 443}, // protocol defaults to TCP
},
},
}
for _, m := range mutate {
m(s)
}
return s
}
func shows(ip string) func(*coreapi.Service) {
return func(s *coreapi.Service) {
s.Status.LoadBalancer.Ingress = []coreapi.LoadBalancerIngress{{IP: ip}}
}
}
func ptr[T any](v T) *T { return &v }
func slice(svcName string, eps map[string]bool, ports map[string]int32) *discovery.EndpointSlice {
sl := &discovery.EndpointSlice{
ObjectMeta: metav1.ObjectMeta{Namespace: "gitea", Name: svcName + "-x", Labels: map[string]string{serviceNameLabel: svcName}},
AddressType: discovery.AddressTypeIPv4,
}
for ip, ready := range eps {
sl.Endpoints = append(sl.Endpoints, discovery.Endpoint{Addresses: []string{ip}, Conditions: discovery.EndpointConditions{Ready: ptr(ready)}})
}
for name, port := range ports {
sl.Ports = append(sl.Ports, discovery.EndpointPort{Name: ptr(name), Protocol: ptr(coreapi.ProtocolTCP), Port: ptr(port)})
}
return sl
}
func TestHolderForwardsToReadyEndpointsAndWritesStatus(t *testing.T) {
s := svc("gitea", "86.107.168.15")
sl := slice("gitea", map[string]bool{"10.40.0.7": true, "10.44.0.3": true, "10.44.0.9": false},
map[string]int32{"ssh": 2222, "https": 3000})
d := Compute("w1", nodes, []*coreapi.Service{s}, []*discovery.EndpointSlice{sl})
if len(d.Problems) != 0 {
t.Fatalf("problems: %v", d.Problems)
}
if len(d.Forwards) != 1 || !d.Forwards[0].Address.Equal(net.ParseIP("86.107.168.15")) {
t.Fatalf("forwards: %+v", d.Forwards)
}
f := d.Forwards[0]
if len(f.Ports) != 2 || f.Ports[0].Port != 22 || f.Ports[1].Port != 443 || f.Ports[1].Protocol != coreapi.ProtocolTCP {
t.Fatalf("ports: %+v", f.Ports)
}
if got := f.Ports[0].Endpoints; len(got) != 2 || got[0].String() != "10.40.0.7:2222" || got[1].String() != "10.44.0.3:2222" {
t.Fatalf("ssh endpoints (not-ready one must be left out): %v", got)
}
if got := f.Ports[1].Endpoints; len(got) != 2 || got[0].Port != 3000 {
t.Fatalf("https endpoints: %v", got)
}
if len(d.Status) != 1 || !d.Status[0].Address.Equal(net.ParseIP("86.107.168.15")) {
t.Fatalf("status: %+v", d.Status)
}
if len(d.Holders) != 0 {
t.Fatalf("a holder does not steer its own replies: %+v", d.Holders)
}
// already shown: nothing to write
d = Compute("w1", nodes, []*coreapi.Service{svc("gitea", "86.107.168.15", shows("86.107.168.15"))}, nil)
if len(d.Status) != 0 {
t.Fatalf("status rewritten: %+v", d.Status)
}
}
func TestOtherNodesSteerRepliesToTheHolderAndNeverWrite(t *testing.T) {
s := svc("gitea", "86.107.168.15")
for _, self := range []string{"cp", "w2"} {
d := Compute(self, nodes, []*coreapi.Service{s}, nil)
if len(d.Forwards) != 0 || len(d.Status) != 0 {
t.Fatalf("%s: only the holder forwards and writes: %+v", self, d)
}
if len(d.Holders) != 1 || d.Holders[0].Node.Name != "w1" || d.Holders[0].Mark < 1 || d.Holders[0].Mark > MaxMarks {
t.Fatalf("%s: holders: %+v", self, d.Holders)
}
}
}
func TestUnheldOrContestedAddressFailsClosed(t *testing.T) {
// nobody holds it: no forward anywhere; the stale status is cleared by exactly one node (first by name)
s := svc("gitea", "86.107.168.99", shows("86.107.168.99"))
for _, self := range []string{"cp", "w1", "w2"} {
d := Compute(self, nodes, []*coreapi.Service{s}, nil)
if len(d.Forwards) != 0 || len(d.Holders) != 0 || len(d.Problems) != 1 || !strings.Contains(d.Problems[0], "no node holds") {
t.Fatalf("%s: %+v", self, d)
}
clears := len(d.Status) == 1 && d.Status[0].Address == nil
if clears != (self == "cp") {
t.Fatalf("%s: clears=%v", self, clears)
}
}
// two nodes claim it: neither serves it, its holder's view clears the status
both := w2
both.Addresses = append([]net.IP{net.ParseIP("86.107.168.15").To4()}, w2.Addresses...)
d := Compute("w1", []Node{cp, w1, both}, []*coreapi.Service{svc("gitea", "86.107.168.15", shows("86.107.168.15"))}, nil)
if len(d.Forwards) != 0 || len(d.Problems) != 1 || !strings.Contains(d.Problems[0], "several nodes") {
t.Fatalf("contested: %+v", d)
}
// a bad request is reported, not guessed at
d = Compute("w1", nodes, []*coreapi.Service{svc("gitea", "")}, nil)
if len(d.Forwards) != 0 || len(d.Problems) != 1 || !strings.Contains(d.Problems[0], "spec.loadBalancerIP") {
t.Fatalf("no address: %+v", d)
}
}
func TestOnlyOptedInLoadBalancersAreServed(t *testing.T) {
other := svc("a", "86.107.168.15", func(s *coreapi.Service) { s.Spec.LoadBalancerClass = ptr("example.com/other") })
noClass := svc("b", "86.107.168.15", func(s *coreapi.Service) { s.Spec.LoadBalancerClass = nil })
clusterIP := svc("c", "86.107.168.15", func(s *coreapi.Service) {
s.Spec.Type = coreapi.ServiceTypeClusterIP
})
d := Compute("w1", nodes, []*coreapi.Service{other, noClass, clusterIP}, nil)
if len(d.Forwards)+len(d.Status)+len(d.Holders)+len(d.Problems) != 0 {
t.Fatalf("touched a Service that did not opt in: %+v", d)
}
}
func TestParseNodeAndAnnotationsRoundTrip(t *testing.T) {
l := Local{Addresses: []net.IP{net.ParseIP("86.107.168.88").To4(), net.ParseIP("86.107.168.15").To4()},
WeaveIP: net.ParseIP("10.44.0.0").To4(), WeaveMAC: mac("aa:aa:aa:aa:aa:03")}
a := l.Annotations()
if a[AnnAddresses] != "86.107.168.15,86.107.168.88" || a[AnnWeave] != "10.44.0.0,aa:aa:aa:aa:aa:03" {
t.Fatalf("annotations: %v", a)
}
n, err := ParseNode(&coreapi.Node{ObjectMeta: metav1.ObjectMeta{Name: "w2", Annotations: a}})
if err != nil || !n.Holds(net.ParseIP("86.107.168.15")) || !n.WeaveIP.Equal(l.WeaveIP) || n.WeaveMAC.String() != "aa:aa:aa:aa:aa:03" {
t.Fatalf("parsed %+v, %v", n, err)
}
for _, bad := range []map[string]string{{AnnAddresses: "1.2.3.4,nope"}, {AnnWeave: "10.44.0.0"}, {AnnWeave: "x,aa:aa:aa:aa:aa:03"}} {
if _, err := ParseNode(&coreapi.Node{ObjectMeta: metav1.ObjectMeta{Name: "w2", Annotations: bad}}); err == nil {
t.Fatalf("accepted %v", bad)
}
}
if n, err := ParseNode(&coreapi.Node{ObjectMeta: metav1.ObjectMeta{Name: "new"}}); err != nil || len(n.Addresses) != 0 || n.WeaveIP != nil {
t.Fatalf("a node that published nothing holds nothing: %+v %v", n, err)
}
}
func TestMarksAreDistinctAndStable(t *testing.T) {
var ns []Node
for _, name := range []string{"a", "b", "c", "d", "e", "f", "g", "h"} {
ns = append(ns, Node{Name: name})
}
first := assignMarks(ns)
seen := map[uint32]bool{}
for _, h := range first {
if seen[h.Mark] || h.Mark < 1 || h.Mark > MaxMarks {
t.Fatalf("bad or repeated mark: %+v", first)
}
seen[h.Mark] = true
}
again := assignMarks(ns)
for i := range first {
if first[i].Mark != again[i].Mark {
t.Fatalf("marks moved between passes")
}
}
}
func TestRenderedRules(t *testing.T) {
_, podNet, _ := net.ParseCIDR("10.32.0.0/12")
s := svc("gitea", "86.107.168.15")
sl := slice("gitea", map[string]bool{"10.40.0.7": true, "10.44.0.3": true, "10.44.0.4": true}, map[string]int32{"ssh": 2222, "https": 3000})
d := Compute("w1", nodes, []*coreapi.Service{s}, []*discovery.EndpointSlice{sl})
r := Render(d, EgressDesired{}, "w1", podNet, []string{"REWEAVE-LBS-DEADBEEF0000"})
for _, want := range []string{
"*nat\n:REWEAVE-LB - [0:0]\n:REWEAVE-LB-POST - [0:0]\n:REWEAVE-LB-CLASS - [0:0]\n",
// the client's address survives weave's masquerade; a pod's own connection (hairpin) is masqueraded here
"-A REWEAVE-LB-POST -m conntrack --ctstate DNAT --ctorigdst 86.107.168.15/32 -s 10.32.0.0/12 -j MASQUERADE\n",
"-A REWEAVE-LB-POST -m conntrack --ctstate DNAT --ctorigdst 86.107.168.15/32 -j ACCEPT\n",
":REWEAVE-LBS-DEADBEEF0000 - [0:0]\n", // stale chain emptied
"-A REWEAVE-LB -d 86.107.168.15/32 -p tcp -m tcp --dport 22 -m comment --comment \"gitea/gitea\" -j REWEAVE-LBS-",
"-p tcp -m statistic --mode random --probability 0.3333333333 -j DNAT --to-destination 10.40.0.7:2222\n",
"-p tcp -m statistic --mode random --probability 0.5000000000 -j DNAT --to-destination 10.44.0.3:2222\n",
"-p tcp -j DNAT --to-destination 10.44.0.4:2222\n",
"COMMIT\n",
} {
if !strings.Contains(r.Nat, want) {
t.Fatalf("nat lacks %q:\n%s", want, r.Nat)
}
}
for _, line := range strings.Split(r.Nat, "\n") {
// iptables refuses a DNAT to ip:port without the protocol in the same rule
if strings.Contains(line, "-j DNAT") && !strings.Contains(line, " -p tcp ") {
t.Fatalf("DNAT without its protocol: %q", line)
}
}
if strings.Count(r.Nat, "MASQUERADE") != 1 || strings.Contains(r.Nat, "SNAT") {
t.Fatalf("only a pod's hairpin connection may be masqueraded:\n%s", r.Nat)
}
if r.Mangle != "*mangle\n:REWEAVE-LB-MARK - [0:0]\n:REWEAVE-LB-MSS - [0:0]\nCOMMIT\n" {
t.Fatalf("a holder marks nothing:\n%s", r.Mangle)
}
d = Compute("w2", nodes, []*coreapi.Service{s}, []*discovery.EndpointSlice{sl})
r = Render(d, EgressDesired{}, "w2", podNet, nil)
m := d.Holders[0].Mark << 24
for _, want := range []string{
"-A REWEAVE-LB-MARK -m conntrack --ctdir REPLY -m connmark ! --mark 0x0/0xff000000 -j CONNMARK --restore-mark --nfmask 0xff000000 --ctmask 0xff000000\n",
"-A REWEAVE-LB-MARK -i weave -m mac --mac-source aa:aa:aa:aa:aa:02 ! -s 10.32.0.0/12 -d 10.32.0.0/12 -m conntrack --ctstate NEW -m comment --comment \"holder w1\" -j CONNMARK --set-xmark " + fmt.Sprintf("0x%x", m) + "/0xff000000\n",
} {
if !strings.Contains(r.Mangle, want) {
t.Fatalf("mangle lacks %q:\n%s", want, r.Mangle)
}
}
if r.Nat != "*nat\n:REWEAVE-LB - [0:0]\n:REWEAVE-LB-POST - [0:0]\n:REWEAVE-LB-CLASS - [0:0]\nCOMMIT\n" {
t.Fatalf("a non-holder forwards nothing:\n%s", r.Nat)
}
if r.Filter != "*filter\n:REWEAVE-LB-FWD - [0:0]\n:REWEAVE-LB-IN - [0:0]\nCOMMIT\n" {
t.Fatalf("no EgressService, no filter rules:\n%s", r.Filter)
}
}
+220
View File
@@ -0,0 +1,220 @@
package net
import (
"encoding/binary"
"errors"
"fmt"
"net"
"strings"
"github.com/vishvananda/netlink"
"github.com/vishvananda/netlink/nl"
"github.com/vishvananda/netns"
"golang.org/x/sys/unix"
)
// A pod's port is locked to the pod's own addresses: what the pod sends in through the host side of its veth has to
// carry the pod's MAC as its Ethernet source and be either IPv4 from the pod's address or ARP speaking for exactly
// that MAC and address (or probing, from 0.0.0.0). Everything else is dropped before the bridge sees it, so a pod can
// claim neither another pod's identity nor a node's, on its own node or, through weave, on any other.
//
// The lock is a set of tc filters on the veth's ingress qdisc. They match the way iproute2 lays out "ether src"
// (the Ethernet source at -8 and -4 from the network header) and "ip src" (at 12), and they go away with the veth.
const (
portLockPrioIPv4 = 1
portLockPrioARP = 2
portLockPrioProbe = 3
portLockPrioDrop = 9
)
var ingressParent = netlink.MakeHandle(0xffff, 0)
// PortLockFilters are the filters that lock the port with the given index to a pod's MAC and IPv4 address.
func PortLockFilters(index int, mac net.HardwareAddr, ip net.IP) ([]netlink.Filter, error) {
if len(mac) != 6 {
return nil, fmt.Errorf("port lock needs an Ethernet MAC, got %q", mac)
}
ip4 := ip.To4()
if ip4 == nil {
return nil, fmt.Errorf("port lock needs an IPv4 address, got %q", ip)
}
src := etherSource(mac)
arp := func(spa net.IP) []netlink.TcU32Key {
keys := append([]netlink.TcU32Key{}, src...)
return append(keys,
// Ethernet/IPv4 ARP: hardware type 1, protocol 0x0800, address lengths 6 and 4 (any operation)
netlink.TcU32Key{Mask: 0xffffffff, Val: 0x00010800, Off: 0},
netlink.TcU32Key{Mask: 0xffff0000, Val: 0x06040000, Off: 4},
// the sender hardware address (8..13) and the sender protocol address (14..17)
netlink.TcU32Key{Mask: 0xffffffff, Val: binary.BigEndian.Uint32(mac[0:4]), Off: 8},
netlink.TcU32Key{Mask: 0xffffffff, Val: uint32(binary.BigEndian.Uint16(mac[4:6]))<<16 | uint32(binary.BigEndian.Uint16(spa[0:2])), Off: 12},
netlink.TcU32Key{Mask: 0xffff0000, Val: uint32(binary.BigEndian.Uint16(spa[2:4])) << 16, Off: 16},
)
}
ipv4 := append(append([]netlink.TcU32Key{}, src...), netlink.TcU32Key{Mask: 0xffffffff, Val: binary.BigEndian.Uint32(ip4), Off: 12})
return []netlink.Filter{
portLockU32(index, portLockPrioIPv4, unix.ETH_P_IP, ipv4),
portLockU32(index, portLockPrioARP, unix.ETH_P_ARP, arp(ip4)),
portLockU32(index, portLockPrioProbe, unix.ETH_P_ARP, arp(net.IPv4zero.To4())),
&netlink.MatchAll{
FilterAttrs: netlink.FilterAttrs{LinkIndex: index, Parent: ingressParent, Priority: portLockPrioDrop, Protocol: unix.ETH_P_ALL},
Actions: []netlink.Action{gact(netlink.TC_ACT_SHOT)},
},
}, nil
}
// etherSource matches the Ethernet source address, which sits 8 bytes before the network header.
func etherSource(mac net.HardwareAddr) []netlink.TcU32Key {
return []netlink.TcU32Key{
{Mask: 0xffffffff, Val: binary.BigEndian.Uint32(mac[0:4]), Off: -8},
{Mask: 0xffff0000, Val: uint32(binary.BigEndian.Uint16(mac[4:6])) << 16, Off: -4},
}
}
func portLockU32(index int, prio uint16, proto uint16, keys []netlink.TcU32Key) *netlink.U32 {
// clipped: the vendored netlink serialises cap(Keys) keys, and spare capacity would go out as extra all-zero keys
keys = keys[:len(keys):len(keys)]
return &netlink.U32{
FilterAttrs: netlink.FilterAttrs{LinkIndex: index, Parent: ingressParent, Priority: prio, Protocol: proto},
Sel: &netlink.TcU32Sel{Flags: nl.TC_U32_TERMINAL, Keys: keys},
Actions: []netlink.Action{gact(netlink.TC_ACT_OK)},
}
}
func gact(action netlink.TcAct) netlink.Action {
return &netlink.GenericAction{ActionAttrs: netlink.ActionAttrs{Action: action}}
}
func ingressQdisc(index int) *netlink.Ingress {
return &netlink.Ingress{QdiscAttrs: netlink.QdiscAttrs{LinkIndex: index, Handle: ingressParent, Parent: netlink.HANDLE_INGRESS}}
}
// LockPort locks a pod's port (the host side of its veth) to the pod's MAC and IPv4 address. A port already locked
// to them is left alone; anything else on its ingress is replaced, and the pod's own traffic is let through before
// the rest is dropped, so a running pod never loses its own frames.
func LockPort(link netlink.Link, mac net.HardwareAddr, ip net.IP) error {
index := link.Attrs().Index
want, err := PortLockFilters(index, mac, ip)
if err != nil {
return err
}
if locked, _ := portHasFilters(link, want); locked {
return nil
}
// removing the ingress qdisc takes its filters with it
if err := netlink.QdiscDel(ingressQdisc(index)); err != nil && !errors.Is(err, unix.ENOENT) && !errors.Is(err, unix.EINVAL) {
return fmt.Errorf("clearing ingress of %s: %w", link.Attrs().Name, err)
}
if err := netlink.QdiscAdd(ingressQdisc(index)); err != nil {
return fmt.Errorf("adding ingress qdisc to %s: %w", link.Attrs().Name, err)
}
for _, f := range want {
if err := netlink.FilterAdd(f); err != nil {
return fmt.Errorf("adding port lock filter (prio %d) to %s: %w", f.Attrs().Priority, link.Attrs().Name, err)
}
}
return nil
}
// LockContainerPort locks the port of a pod attached by the CNI plugin: the host side of the veth whose container
// end is ifName in ns, to that end's MAC and the pod's address.
func LockContainerPort(ns netns.NsHandle, ifName string, ip net.IP) error {
var mac net.HardwareAddr
peer := 0
if err := WithNetNSLink(ns, ifName, func(link netlink.Link) error {
// a veth's link attribute is its peer's index, in the peer's namespace: the host's
mac, peer = link.Attrs().HardwareAddr, link.Attrs().ParentIndex
return nil
}); err != nil {
return err
}
if peer == 0 {
return fmt.Errorf("%s has no peer: not a veth", ifName)
}
host, err := netlink.LinkByIndex(peer)
if err != nil {
return fmt.Errorf("host side of %s: %w", ifName, err)
}
if host.Type() != "veth" || !strings.HasPrefix(host.Attrs().Name, vethPrefix+"pl") {
return fmt.Errorf("host side of %s is %s (%s), not a weave pod veth", ifName, host.Attrs().Name, host.Type())
}
return LockPort(host, mac, ip)
}
// PortLocked reports whether a port is locked to exactly this MAC and address.
func PortLocked(link netlink.Link, mac net.HardwareAddr, ip net.IP) (bool, error) {
want, err := PortLockFilters(link.Attrs().Index, mac, ip)
if err != nil {
return false, err
}
return portHasFilters(link, want)
}
func portHasFilters(link netlink.Link, want []netlink.Filter) (bool, error) {
have, err := netlink.FilterList(link, ingressParent)
if err != nil {
return false, err
}
var got []netlink.Filter
for _, f := range have {
// u32 lists the hash table that holds a filter as a filter of its own, without a selector
if u, ok := f.(*netlink.U32); ok && u.Sel == nil {
continue
}
got = append(got, f)
}
if len(got) != len(want) {
return false, nil
}
for _, w := range want {
found := false
for _, g := range got {
if sameFilter(g, w) {
found = true
break
}
}
if !found {
return false, nil
}
}
return true, nil
}
func sameFilter(a, b netlink.Filter) bool {
if a.Attrs().Priority != b.Attrs().Priority || a.Attrs().Protocol != b.Attrs().Protocol || a.Type() != b.Type() {
return false
}
switch fa := a.(type) {
case *netlink.U32:
fb := b.(*netlink.U32)
if fa.Sel == nil || fb.Sel == nil || len(fa.Sel.Keys) != len(fb.Sel.Keys) || fa.Sel.Flags&nl.TC_U32_TERMINAL == 0 {
return false
}
for i := range fa.Sel.Keys {
ka, kb := fa.Sel.Keys[i], fb.Sel.Keys[i]
if ka.Mask != kb.Mask || ka.Val != kb.Val || ka.Off != kb.Off {
return false
}
}
return sameActions(fa.Actions, fb.Actions)
case *netlink.MatchAll:
return sameActions(fa.Actions, b.(*netlink.MatchAll).Actions)
}
return false
}
func sameActions(a, b []netlink.Action) bool {
if len(a) != len(b) {
return false
}
for i := range a {
ga, ok1 := a[i].(*netlink.GenericAction)
gb, ok2 := b[i].(*netlink.GenericAction)
if !ok1 || !ok2 || ga.Attrs().Action != gb.Attrs().Action {
return false
}
}
return true
}
+228
View File
@@ -0,0 +1,228 @@
package net
import (
"bytes"
"encoding/binary"
"net"
"os"
"runtime"
"testing"
"time"
"github.com/vishvananda/netlink"
"github.com/vishvananda/netns"
"golang.org/x/sys/unix"
)
// TestPortLockInKernel builds a bridge with two pods in throwaway network namespaces, locks pod A's port and sends
// frames from A: what A may send reaches B, what claims anything but A's own addresses does not. It needs root.
func TestPortLockInKernel(t *testing.T) {
if os.Geteuid() != 0 {
t.Skip("needs root (creates network namespaces)")
}
runtime.LockOSThread()
defer runtime.UnlockOSThread()
orig, err := netns.Get()
if err != nil {
t.Fatal(err)
}
defer orig.Close()
defer netns.Set(orig)
hub, err := netns.New()
if err != nil {
t.Fatal(err)
}
defer hub.Close()
podA, _ := netns.New()
defer podA.Close()
podB, _ := netns.New()
defer podB.Close()
must(t, netns.Set(hub))
br := &netlink.Bridge{LinkAttrs: netlink.LinkAttrs{Name: "br0"}}
must(t, netlink.LinkAdd(br))
must(t, netlink.LinkSetUp(br))
ipA, ipB := net.IPv4(10, 99, 0, 1).To4(), net.IPv4(10, 99, 0, 2).To4()
hostA, macA := attach(t, hub, podA, br, "vethwepltsta", ipA)
_, macB := attach(t, hub, podB, br, "vethwepltstb", ipB)
// as the CNI plugin does it: from the pod's end of the veth, in the pod's namespace
must(t, LockContainerPort(podA, "eth0", ipA))
if ok, err := PortLocked(hostA, macA, ipA); err != nil || !ok {
dumpIngress(t, hostA)
t.Fatalf("port not locked after LockContainerPort: %v %v", ok, err)
}
// locking again changes nothing (the filters are compared, not rebuilt)
must(t, LockPort(hostA, macA, ipA))
if ok, _ := PortLocked(hostA, macA, ipA); !ok {
t.Fatal("port lost its lock on a repeated LockPort")
}
send := socketIn(t, podA, "eth0")
recv := socketIn(t, podB, "eth0")
defer unix.Close(send)
defer unix.Close(recv)
other, _ := net.ParseMAC("02:00:00:00:00:99")
spoofIP := net.IPv4(10, 99, 0, 200).To4()
cases := []struct {
name string
frame []byte
passes bool
}{
{"ipv4 from its own addresses", ipv4Frame(macB, macA, ipA, ipB, "own-ip"), true},
{"ipv4 from another MAC", ipv4Frame(macB, other, ipA, ipB, "mac-spoof"), false},
{"ipv4 from another address", ipv4Frame(macB, macA, spoofIP, ipB, "ip-spoof"), false},
{"arp for itself", arpFrame(macA, macA, ipA, ipB, "own-arp"), true},
{"arp probe", arpFrame(macA, macA, net.IPv4zero.To4(), ipB, "probe"), true},
{"arp claiming another address", arpFrame(macA, macA, spoofIP, ipB, "arp-ip-spoof"), false},
{"arp claiming another MAC", arpFrame(macA, other, ipA, ipB, "arp-sha-spoof"), false},
{"arp from another MAC", arpFrame(other, other, ipA, ipB, "arp-eth-spoof"), false},
{"ipv6", etherFrame(macB, macA, 0x86dd, []byte("ipv6-frame")), false},
{"other ethertype", etherFrame(macB, macA, 0x88b5, []byte("experimental")), false},
}
for _, c := range cases {
drain(recv)
if _, err := unix.Write(send, c.frame); err != nil {
t.Fatalf("%s: send: %v", c.name, err)
}
got := sawPayload(recv, c.frame[len(c.frame)-12:], 300*time.Millisecond)
if got != c.passes {
t.Errorf("%s: reached B = %v, want %v", c.name, got, c.passes)
}
}
}
func dumpIngress(t *testing.T, link netlink.Link) {
have, _ := netlink.FilterList(link, netlink.MakeHandle(0xffff, 0))
for _, f := range have {
t.Logf("have %s %+v", f.Type(), f.Attrs())
if u, ok := f.(*netlink.U32); ok && u.Sel != nil {
t.Logf(" sel flags %x keys %+v actions %+v", u.Sel.Flags, u.Sel.Keys, u.Actions)
}
if m, ok := f.(*netlink.MatchAll); ok {
t.Logf(" actions %+v", m.Actions)
}
}
}
func must(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
// attach plugs a pod namespace into the bridge through a veth named host on the hub side, eth0 on the pod side.
func attach(t *testing.T, hub, pod netns.NsHandle, br netlink.Link, host string, ip net.IP) (netlink.Link, net.HardwareAddr) {
t.Helper()
must(t, netlink.LinkAdd(&netlink.Veth{LinkAttrs: netlink.LinkAttrs{Name: host}, PeerName: "tmp" + host[len(host)-2:]}))
h, err := netlink.LinkByName(host)
must(t, err)
must(t, netlink.LinkSetMaster(h, br))
must(t, netlink.LinkSetUp(h))
p, err := netlink.LinkByName("tmp" + host[len(host)-2:])
must(t, err)
must(t, netlink.LinkSetNsFd(p, int(pod)))
must(t, netns.Set(pod))
p, err = netlink.LinkByName("tmp" + host[len(host)-2:])
must(t, err)
must(t, netlink.LinkSetName(p, "eth0"))
must(t, netlink.AddrAdd(p, &netlink.Addr{IPNet: &net.IPNet{IP: ip, Mask: net.CIDRMask(24, 32)}}))
must(t, netlink.LinkSetUp(p))
mac := p.Attrs().HardwareAddr
must(t, netns.Set(hub))
return h, mac
}
// socketIn opens a raw packet socket on an interface of a namespace (the socket stays in that namespace).
func socketIn(t *testing.T, ns netns.NsHandle, ifName string) int {
t.Helper()
cur, _ := netns.Get()
defer cur.Close()
must(t, netns.Set(ns))
defer netns.Set(cur)
link, err := netlink.LinkByName(ifName)
must(t, err)
fd, err := unix.Socket(unix.AF_PACKET, unix.SOCK_RAW, int(htons(unix.ETH_P_ALL)))
must(t, err)
must(t, unix.Bind(fd, &unix.SockaddrLinklayer{Protocol: htons(unix.ETH_P_ALL), Ifindex: link.Attrs().Index}))
return fd
}
func htons(v uint16) uint16 { return v<<8 | v>>8 }
func drain(fd int) {
buf := make([]byte, 2048)
for {
if _, _, err := unix.Recvfrom(fd, buf, unix.MSG_DONTWAIT); err != nil {
return
}
}
}
func sawPayload(fd int, marker []byte, within time.Duration) bool {
buf := make([]byte, 2048)
deadline := time.Now().Add(within)
for time.Now().Before(deadline) {
n, _, err := unix.Recvfrom(fd, buf, unix.MSG_DONTWAIT)
if err != nil {
time.Sleep(5 * time.Millisecond)
continue
}
if bytes.Contains(buf[:n], marker) {
return true
}
}
return false
}
func etherFrame(dst, src net.HardwareAddr, ethertype uint16, payload []byte) []byte {
f := make([]byte, 14, 14+len(payload)+12)
copy(f[0:6], dst)
copy(f[6:12], src)
binary.BigEndian.PutUint16(f[12:14], ethertype)
f = append(f, payload...)
// a fixed-size tail the receiver looks for, so each case is told apart
tail := make([]byte, 12)
copy(tail, payload)
return append(f, tail...)
}
func ipv4Frame(dst, src net.HardwareAddr, from, to net.IP, tag string) []byte {
body := []byte(tag)
h := make([]byte, 20)
h[0] = 0x45
binary.BigEndian.PutUint16(h[2:4], uint16(20+len(body)+12))
h[8] = 64
h[9] = 253 // experimental protocol: nothing answers it
copy(h[12:16], from)
copy(h[16:20], to)
binary.BigEndian.PutUint16(h[10:12], checksum(h))
return etherFrame(dst, src, unix.ETH_P_IP, append(h, body...))
}
func arpFrame(ethSrc, sha net.HardwareAddr, spa, tpa net.IP, tag string) []byte {
a := make([]byte, 28)
binary.BigEndian.PutUint16(a[0:2], 1)
binary.BigEndian.PutUint16(a[2:4], unix.ETH_P_IP)
a[4], a[5] = 6, 4
binary.BigEndian.PutUint16(a[6:8], 1) // request
copy(a[8:14], sha)
copy(a[14:18], spa)
copy(a[24:28], tpa)
bcast, _ := net.ParseMAC("ff:ff:ff:ff:ff:ff")
return etherFrame(bcast, ethSrc, unix.ETH_P_ARP, append(a, []byte(tag)...))
}
func checksum(b []byte) uint16 {
var s uint32
for i := 0; i+1 < len(b); i += 2 {
s += uint32(binary.BigEndian.Uint16(b[i:]))
}
for s>>16 != 0 {
s = s&0xffff + s>>16
}
return ^uint16(s)
}
+115
View File
@@ -0,0 +1,115 @@
package net
import (
"net"
"testing"
"github.com/vishvananda/netlink"
"github.com/vishvananda/netlink/nl"
"golang.org/x/sys/unix"
)
// The keys iproute2 writes for "u32 match ether src 02:11:22:33:44:55 match ip src 10.32.0.7/32" (tc filter show,
// iproute2 6.x): the port lock has to match the same bytes.
func TestPortLockMatchesIproute2Layout(t *testing.T) {
mac, _ := net.ParseMAC("02:11:22:33:44:55")
fs, err := PortLockFilters(7, mac, net.ParseIP("10.32.0.7"))
if err != nil {
t.Fatal(err)
}
if len(fs) != 4 {
t.Fatalf("want 4 filters, got %d", len(fs))
}
ipv4 := fs[0].(*netlink.U32)
want := []netlink.TcU32Key{
{Val: 0x02112233, Mask: 0xffffffff, Off: -8},
{Val: 0x44550000, Mask: 0xffff0000, Off: -4},
{Val: 0x0a200007, Mask: 0xffffffff, Off: 12},
}
if ipv4.Protocol != unix.ETH_P_IP || ipv4.Priority != portLockPrioIPv4 || ipv4.Sel.Flags&nl.TC_U32_TERMINAL == 0 {
t.Fatalf("ipv4 filter attrs: %+v flags %x", ipv4.FilterAttrs, ipv4.Sel.Flags)
}
assertKeys(t, "ipv4", ipv4.Sel.Keys, want)
for _, f := range fs {
if f.Attrs().LinkIndex != 7 || f.Attrs().Parent != netlink.MakeHandle(0xffff, 0) {
t.Fatalf("filter not on ingress of link 7: %+v", f.Attrs())
}
}
}
func TestPortLockARP(t *testing.T) {
mac, _ := net.ParseMAC("02:11:22:33:44:55")
fs, _ := PortLockFilters(7, mac, net.ParseIP("10.32.0.7"))
common := []netlink.TcU32Key{
{Val: 0x02112233, Mask: 0xffffffff, Off: -8},
{Val: 0x44550000, Mask: 0xffff0000, Off: -4},
{Val: 0x00010800, Mask: 0xffffffff, Off: 0},
{Val: 0x06040000, Mask: 0xffff0000, Off: 4},
{Val: 0x02112233, Mask: 0xffffffff, Off: 8},
}
own := append(append([]netlink.TcU32Key{}, common...),
netlink.TcU32Key{Val: 0x44550a20, Mask: 0xffffffff, Off: 12}, // sha 44:55, spa 10.32
netlink.TcU32Key{Val: 0x00070000, Mask: 0xffff0000, Off: 16}, // spa .0.7
)
probe := append(append([]netlink.TcU32Key{}, common...),
netlink.TcU32Key{Val: 0x44550000, Mask: 0xffffffff, Off: 12},
netlink.TcU32Key{Val: 0x00000000, Mask: 0xffff0000, Off: 16},
)
arp, pr := fs[1].(*netlink.U32), fs[2].(*netlink.U32)
if arp.Protocol != unix.ETH_P_ARP || pr.Protocol != unix.ETH_P_ARP {
t.Fatal("ARP filters must be protocol arp")
}
assertKeys(t, "arp", arp.Sel.Keys, own)
assertKeys(t, "probe", pr.Sel.Keys, probe)
drop := fs[3].(*netlink.MatchAll)
if drop.Protocol != unix.ETH_P_ALL || drop.Priority != portLockPrioDrop {
t.Fatalf("drop filter attrs: %+v", drop.FilterAttrs)
}
if a := drop.Actions[0].(*netlink.GenericAction); a.Attrs().Action != netlink.TC_ACT_SHOT {
t.Fatalf("last filter must drop, has action %v", a.Attrs().Action)
}
for _, f := range fs[:3] {
if a := f.(*netlink.U32).Actions[0].(*netlink.GenericAction); a.Attrs().Action != netlink.TC_ACT_OK {
t.Fatalf("prio %d must pass, has action %v", f.Attrs().Priority, a.Attrs().Action)
}
}
}
func TestPortLockRefusesWhatItCannotPin(t *testing.T) {
mac, _ := net.ParseMAC("02:11:22:33:44:55")
if _, err := PortLockFilters(7, mac, net.ParseIP("fd00::1")); err == nil {
t.Fatal("an IPv6 address must be refused: the lock pins IPv4 and ARP")
}
long, _ := net.ParseMAC("02:11:22:33:44:55:66:77")
if _, err := PortLockFilters(7, long, net.ParseIP("10.32.0.7")); err == nil {
t.Fatal("a non-Ethernet hardware address must be refused")
}
}
func TestSameFilterComparesKeysAndActions(t *testing.T) {
mac, _ := net.ParseMAC("02:11:22:33:44:55")
a, _ := PortLockFilters(7, mac, net.ParseIP("10.32.0.7"))
b, _ := PortLockFilters(7, mac, net.ParseIP("10.32.0.8"))
if !sameFilter(a[0], a[0]) || sameFilter(a[0], b[0]) {
t.Fatal("ipv4 filters for different addresses must differ")
}
if !sameFilter(a[3], b[3]) {
t.Fatal("the drop filters are the same for every pod")
}
if sameFilter(a[1], a[2]) {
t.Fatal("the ARP and probe filters differ in priority and keys")
}
}
func assertKeys(t *testing.T, what string, got, want []netlink.TcU32Key) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("%s: want %d keys, got %d: %+v", what, len(want), len(got), got)
}
for i := range want {
if got[i].Val != want[i].Val || got[i].Mask != want[i].Mask || got[i].Off != want[i].Off {
t.Fatalf("%s key %d: want %08x/%08x at %d, got %08x/%08x at %d", what, i,
want[i].Val, want[i].Mask, want[i].Off, got[i].Val, got[i].Mask, got[i].Off)
}
}
}
+21
View File
@@ -1,6 +1,7 @@
package plugin
import (
"context"
"crypto/rand"
"encoding/json"
"errors"
@@ -14,6 +15,7 @@ import (
current "github.com/containernetworking/cni/pkg/types/100"
"github.com/containernetworking/plugins/pkg/ipam"
weaveapi "github.com/rajch/weave/api"
"github.com/rajch/weave/lb/admit"
weavenet "github.com/rajch/weave/net"
ipamplugin "github.com/rajch/weave/plugin/ipam"
"github.com/vishvananda/netlink"
@@ -86,6 +88,18 @@ func (c *CNIPlugin) CmdAdd(args *skel.CmdArgs) error {
// Only expecting one address
ip := result.IPs[0]
// An EgressService member's first packet must already leave from its Service's address (or not at all):
// weave-npc installs the pod's egress rules before the pod has an interface. No answer, no pod (the
// runtime calls DEL, which releases the address, and kubelet retries).
if conf.EgressAdmission {
if ns, name := admit.PodFromArgs(args.Args); ns != "" && name != "" {
if _, err := admit.Admit(context.Background(), admit.SocketPath,
admit.Request{Namespace: ns, Name: name, IP: ip.Address.IP.String()}); err != nil {
return err
}
}
}
// If config says nothing about routes or gateway, default one will be via the bridge
if len(result.Routes) == 0 && ip.Gateway == nil {
bridgeIP, err := weavenet.FindBridgeIP(conf.BrName, &ip.Address)
@@ -147,6 +161,11 @@ func (c *CNIPlugin) CmdAdd(args *skel.CmdArgs) error {
}); err != nil {
return fmt.Errorf("error setting up routes: %s", err)
}
// the pod may only speak as itself: its port drops whatever claims another MAC or address. The pod has not
// started yet; if its port cannot be locked, it does not start.
if err := weavenet.LockContainerPort(ns, args.IfName, ip.Address.IP); err != nil {
return fmt.Errorf("error locking the pod's port: %s", err)
}
result.DNS = conf.DNS
return types.PrintResult(result, conf.CNIVersion)
@@ -241,4 +260,6 @@ type NetConf struct {
IPMasq bool `json:"ipMasq"`
MTU int `json:"mtu"`
HairpinMode bool `json:"hairpinMode"`
// EgressAdmission: weave-npc must admit every pod before it gets its interface (EgressService members)
EgressAdmission bool `json:"egressAdmission"`
}
+6 -1
View File
@@ -63,4 +63,9 @@ if ! mkdir -p $HOST_ROOT/opt/cni/bin ; then
fi
mkdir -p $HOST_ROOT/etc/cni/net.d
export HOST_ROOT
/home/weave/weave --local setup-cni
/home/weave/weave --local setup-cni
# Lock the ports of pods attached before the CNI plugin locked ports itself (it locks every pod it attaches from
# now on), from the runtime's record of each attachment. A port that cannot be locked stays as it was and is
# reported; weave starts regardless, since holding back the whole node's network would not make that port safer.
/usr/bin/weaveutil lock-ports "$HOST_ROOT/var/lib/cni/results" weave || echo "Some pods' ports could not be locked (see above)" >&2
@@ -1,6 +1,68 @@
apiVersion: v1
kind: List
items:
# EgressService (reweave.levk.dev/v1alpha1), served by weave-npc's lb agent: a LoadBalancer Service's pods
# leave the cluster from the Service's address (named after the Service, same namespace)
- apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: egressservices.reweave.levk.dev
labels:
name: weave-net
spec:
group: reweave.levk.dev
scope: Namespaced
names:
plural: egressservices
singular: egressservice
kind: EgressService
listKind: EgressServiceList
versions:
- name: v1alpha1
served: true
storage: true
subresources:
status: {}
additionalPrinterColumns:
- {name: Emit, type: string, jsonPath: .spec.emit}
- {name: Host, type: string, jsonPath: .status.host}
- {name: Address, type: string, jsonPath: .status.address}
- {name: Age, type: date, jsonPath: .metadata.creationTimestamp}
schema:
openAPIV3Schema:
type: object
description: >-
Makes every pod the same-named LoadBalancer Service (class reweave.levk.dev/node-address)
selects leave the cluster from that Service's address, or not at all.
required: [spec]
properties:
spec:
type: object
required: [emit]
properties:
sourceIPBy:
type: string
description: LoadBalancerIP - members leave from the Service's load-balancer address.
enum: [LoadBalancerIP]
default: LoadBalancerIP
emit:
type: string
description: Holder - members' packets are tunnelled to the node holding the address, which emits them.
enum: [Holder]
status:
type: object
properties:
host: {type: string}
address: {type: string}
blocks:
type: array
items:
type: object
required: [node, from, to]
properties:
node: {type: string}
from: {type: integer, format: int32}
to: {type: integer, format: int32}
- apiVersion: v1
kind: ServiceAccount
metadata:
@@ -48,6 +110,72 @@ items:
verbs:
- patch
- update
# weave-npc's LoadBalancer agent (lb): Services of class reweave.levk.dev/node-address
- apiGroups:
- ''
resources:
- services
verbs:
- get
- list
- watch
- apiGroups:
- ''
resources:
- services/status
verbs:
- patch
- apiGroups:
- discovery.k8s.io
resources:
- endpointslices
verbs:
- get
- list
- watch
# each node publishes the addresses it holds and its weave bridge (annotations)
- apiGroups:
- ''
resources:
- nodes
verbs:
- patch
- apiGroups:
- reweave.levk.dev
resources:
- egressservices
verbs:
- get
- list
- watch
- apiGroups:
- reweave.levk.dev
resources:
- egressservices/status
verbs:
- patch
# Gateways of controller reweave.levk.dev/gateway (the Gateway API CRDs are installed separately)
- apiGroups:
- gateway.networking.k8s.io
resources:
- gatewayclasses
- gateways
- httproutes
- tcproutes
- referencegrants
verbs:
- get
- list
- watch
- apiGroups:
- gateway.networking.k8s.io
resources:
- gatewayclasses/status
- gateways/status
- httproutes/status
- tcproutes/status
verbs:
- patch
- apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
@@ -100,6 +228,42 @@ items:
- kind: ServiceAccount
name: weave-net
namespace: kube-system
# Gateways' certificates: the agent reads Secrets in gateway-system only (weave-npc --gateway-namespaces)
- apiVersion: v1
kind: Namespace
metadata:
name: gateway-system
- apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: weave-net-gateway-certificates
namespace: gateway-system
labels:
name: weave-net
rules:
- apiGroups:
- ''
resources:
- secrets
verbs:
- get
- list
- watch
- apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: weave-net-gateway-certificates
namespace: gateway-system
labels:
name: weave-net
roleRef:
kind: Role
name: weave-net-gateway-certificates
apiGroup: rbac.authorization.k8s.io
subjects:
- kind: ServiceAccount
name: weave-net
namespace: kube-system
- apiVersion: apps/v1
kind: DaemonSet
metadata:
@@ -139,6 +303,15 @@ items:
- name: xtables-lock
mountPath: /run/xtables.lock
readOnly: false
# the runtime's record of each pod attachment, and the pods' network namespaces it names: init
# locks the ports of pods attached before the CNI plugin locked ports itself
- name: cni-cache
mountPath: /host/var/lib/cni
readOnly: true
- name: netns
mountPath: /var/run/netns
mountPropagation: HostToContainer
readOnly: true
containers:
- name: weave
command:
@@ -196,6 +369,8 @@ items:
- name: xtables-lock
mountPath: /run/xtables.lock
readOnly: false
- name: weave-npc-run
mountPath: /run/weave-npc
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
hostPID: false
@@ -234,6 +409,18 @@ items:
hostPath:
path: /run/xtables.lock
type: FileOrCreate
- name: weave-npc-run
hostPath:
path: /run/weave-npc
type: DirectoryOrCreate
- name: cni-cache
hostPath:
path: /var/lib/cni
type: DirectoryOrCreate
- name: netns
hostPath:
path: /var/run/netns
type: DirectoryOrCreate
priorityClassName: system-node-critical
updateStrategy:
type: RollingUpdate
+29 -7
View File
@@ -6,6 +6,7 @@ import (
"os"
"os/signal"
goruntime "runtime"
"strings"
"syscall"
"github.com/coreos/go-iptables/iptables"
@@ -21,6 +22,7 @@ import (
"github.com/rajch/weave/common"
"github.com/rajch/weave/common/chains"
"github.com/rajch/weave/lb"
"github.com/rajch/weave/net"
"github.com/rajch/weave/net/ipset"
"github.com/rajch/weave/npc"
@@ -29,13 +31,15 @@ import (
)
var (
version = "unreleased"
metricsAddr string
logLevel string
allowMcast bool
nodeName string
maxList int
bridgePortName string
version = "unreleased"
metricsAddr string
logLevel string
allowMcast bool
nodeName string
maxList int
bridgePortName string
serveLB bool
gatewayNamespaces string
)
func handleError(err error) {
@@ -337,6 +341,21 @@ func root(cmd *cobra.Command, args []string) {
go podController.Run(wait.NeverStop)
go npController.Run(wait.NeverStop)
if serveLB {
// LoadBalancer Services of class lb.Class: addresses this node holds, client address kept; and
// EgressServices: those Services' pods leave the cluster from that address (or not at all)
egress, err := lb.NewEgressClient(config)
handleFatal(err)
ctl := lb.NewController(nodeName, client, egress, lb.NewDatapath(ipt))
if gatewayNamespaces != "" {
// and Gateways of class controller lb.GatewayControllerName, from the node holding their address
gw, err := lb.NewGatewayClient(config)
handleFatal(err)
ctl = ctl.WithGateway(gw, strings.Split(gatewayNamespaces, ","))
}
go ctl.Run(context.Background())
}
signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
common.Log.Fatalf("Exiting: %v", <-signals)
@@ -354,6 +373,9 @@ func main() {
rootCmd.PersistentFlags().StringVar(&nodeName, "node-name", "", "only generate rules that apply to this node")
rootCmd.PersistentFlags().IntVar(&maxList, "max-list-size", 1024, "maximum size of ipset list (for namespaces)")
rootCmd.PersistentFlags().StringVar(&bridgePortName, "bridge-port-name", "vethwe-bridge", "name of the brige port on which packets are received and sent")
rootCmd.PersistentFlags().BoolVar(&serveLB, "lb", true, "serve LoadBalancer Services of class "+lb.Class+" from addresses nodes hold")
rootCmd.PersistentFlags().StringVar(&gatewayNamespaces, "gateway-namespaces", "gateway-system",
"serve Gateways of controller "+lb.GatewayControllerName+", with certificates from Secrets in these namespaces (comma-separated; empty: no Gateways)")
handleFatal(rootCmd.Execute())
}
+1
View File
@@ -42,6 +42,7 @@ func init() {
"rewrite-etc-hosts": rewriteEtcHosts,
"get-db-flag": getDBFlag,
"set-db-flag": setDBFlag,
"lock-ports": lockPorts,
}
}
+114
View File
@@ -0,0 +1,114 @@
package main
import (
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"github.com/vishvananda/netns"
weavenet "github.com/rajch/weave/net"
)
// lockPorts locks the ports of pods that were attached before the CNI plugin locked ports itself (it locks every
// pod it attaches). It works from the runtime's own record of each attachment, libcni's result cache: the pod's
// network namespace, the interface the plugin set up in it and the address it gave it. Ports already locked are
// left alone; pods whose namespace is gone are skipped.
func lockPorts(args []string) error {
if len(args) != 2 {
cmdUsage("lock-ports", "<cni-result-cache-dir> <network-name>")
}
dir, network := args[0], args[1]
entries, err := os.ReadDir(dir)
if os.IsNotExist(err) {
return nil // nothing attached yet
}
if err != nil {
return err
}
var locked, gone, failed int
for _, e := range entries {
if e.IsDir() || !strings.HasPrefix(e.Name(), network+"-") {
continue
}
data, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
fmt.Fprintf(os.Stderr, "lock-ports: %s: %v\n", e.Name(), err)
failed++
continue
}
a, err := parseAttachment(data)
if err != nil {
fmt.Fprintf(os.Stderr, "lock-ports: %s: %v\n", e.Name(), err)
failed++
continue
}
if a.network != network {
continue
}
ns, err := netns.GetFromPath(a.netns)
if os.IsNotExist(err) {
gone++ // the pod is gone; the runtime has yet to clear its record
continue
}
if err != nil {
fmt.Fprintf(os.Stderr, "lock-ports: %s: namespace %s: %v\n", a.containerID, a.netns, err)
failed++
continue
}
err = weavenet.LockContainerPort(ns, a.ifName, a.ip)
ns.Close()
if err != nil {
fmt.Fprintf(os.Stderr, "lock-ports: %s (%s): %v\n", a.containerID, a.ip, err)
failed++
continue
}
locked++
}
fmt.Printf("lock-ports: %d locked, %d gone, %d failed\n", locked, gone, failed)
if failed > 0 {
return fmt.Errorf("lock-ports: %d pods' ports not locked", failed)
}
return nil
}
type attachment struct {
network, containerID, netns, ifName string
ip net.IP
}
// parseAttachment reads one libcni cache entry ("cniCacheV1"): which network attached which container, in which
// namespace, through which interface, with which IPv4 address.
func parseAttachment(data []byte) (attachment, error) {
var c struct {
Kind string `json:"kind"`
ContainerID string `json:"containerId"`
IfName string `json:"ifName"`
NetworkName string `json:"networkName"`
NetNS string `json:"netns"`
Result struct {
IPs []struct {
Address string `json:"address"`
} `json:"ips"`
} `json:"result"`
}
if err := json.Unmarshal(data, &c); err != nil {
return attachment{}, err
}
if c.Kind != "cniCacheV1" {
return attachment{}, fmt.Errorf("unknown cache entry kind %q", c.Kind)
}
if c.NetNS == "" || c.IfName == "" {
return attachment{}, fmt.Errorf("cache entry for %s names no namespace or interface", c.ContainerID)
}
for _, a := range c.Result.IPs {
ip, _, err := net.ParseCIDR(a.Address)
if err == nil && ip.To4() != nil {
return attachment{network: c.NetworkName, containerID: c.ContainerID, netns: c.NetNS, ifName: c.IfName, ip: ip.To4()}, nil
}
}
return attachment{}, fmt.Errorf("cache entry for %s has no IPv4 address", c.ContainerID)
}
+37
View File
@@ -0,0 +1,37 @@
package main
import (
"net"
"testing"
)
// shaped like CRI-O's libcni cache on the SG nodes (cniCacheV1, CNI 1.0.0 result); the config is elided
const cacheEntry = `{"kind":"cniCacheV1","containerId":"2b61982132d6039b210e2fa5896555c8a921a07c756bd8753963440e07dc5f98",
"config":"eyJ9","ifName":"eth0","networkName":"weave","netns":"/var/run/netns/8828bbba-9a68-460c-980b-266dc3c9342c",
"cniArgs":[["K8S_POD_NAMESPACE","default"]],"capabilityArgs":{},
"result":{"cniVersion":"1.0.0","ips":[{"address":"10.44.0.12/12","gateway":"10.44.0.0"}]}}`
func TestParseAttachment(t *testing.T) {
a, err := parseAttachment([]byte(cacheEntry))
if err != nil {
t.Fatal(err)
}
if a.network != "weave" || a.ifName != "eth0" || a.netns != "/var/run/netns/8828bbba-9a68-460c-980b-266dc3c9342c" ||
!a.ip.Equal(net.ParseIP("10.44.0.12")) || a.containerID[:7] != "2b61982" {
t.Fatalf("parsed %+v", a)
}
}
func TestParseAttachmentRefusesWhatItCannotLock(t *testing.T) {
for name, entry := range map[string]string{
"other kind": `{"kind":"cniCacheV2","netns":"/x","ifName":"eth0","result":{"ips":[{"address":"10.0.0.1/8"}]}}`,
"no netns": `{"kind":"cniCacheV1","ifName":"eth0","result":{"ips":[{"address":"10.0.0.1/8"}]}}`,
"no ipv4": `{"kind":"cniCacheV1","netns":"/x","ifName":"eth0","result":{"ips":[{"address":"fd00::1/64"}]}}`,
"not json": `{`,
"no addresses": `{"kind":"cniCacheV1","netns":"/x","ifName":"eth0","result":{}}`,
} {
if _, err := parseAttachment([]byte(entry)); err == nil {
t.Errorf("%s: want an error", name)
}
}
}
+76
View File
@@ -0,0 +1,76 @@
# End-to-end test of EgressService (weave-npc's lb agent). Placeholders are filled by run.sh: ADDR (an
# address HOLDER holds), HOLDER, REMOTE and OTHER (the cluster's other two nodes).
apiVersion: v1
kind: List
items:
- apiVersion: v1
kind: Namespace
metadata:
name: egtest
- apiVersion: v1
kind: Service
metadata:
name: mail
namespace: egtest
spec:
type: LoadBalancer
loadBalancerClass: reweave.levk.dev/node-address
loadBalancerIP: ADDR
allocateLoadBalancerNodePorts: false
selector: {egress: member}
ports: [{name: probe, port: 18090, targetPort: 8080}]
- apiVersion: reweave.levk.dev/v1alpha1
kind: EgressService
metadata:
name: mail
namespace: egtest
spec:
emit: Holder
# members on every node, and one that is not a member
- apiVersion: v1
kind: Pod
metadata: {name: member-remote, namespace: egtest, labels: {egress: member}}
spec:
nodeName: REMOTE
containers:
# root + NET_RAW: ping and traceroute need raw sockets (the image runs as an unprivileged user)
- {name: c, image: "curlimages/curl:8.10.1", command: ["sleep", "3600"], securityContext: {runAsUser: 0, capabilities: {add: [NET_RAW]}}}
- apiVersion: v1
kind: Pod
metadata: {name: member-other, namespace: egtest, labels: {egress: member}}
spec:
nodeName: OTHER
containers:
# root + NET_RAW: ping and traceroute need raw sockets (the image runs as an unprivileged user)
- {name: c, image: "curlimages/curl:8.10.1", command: ["sleep", "3600"], securityContext: {runAsUser: 0, capabilities: {add: [NET_RAW]}}}
- apiVersion: v1
kind: Pod
metadata: {name: member-holder, namespace: egtest, labels: {egress: member}}
spec:
nodeName: HOLDER
containers:
# root + NET_RAW: ping and traceroute need raw sockets (the image runs as an unprivileged user)
- {name: c, image: "curlimages/curl:8.10.1", command: ["sleep", "3600"], securityContext: {runAsUser: 0, capabilities: {add: [NET_RAW]}}}
- apiVersion: v1
kind: Pod
metadata: {name: member-dns, namespace: egtest, labels: {egress: member}}
spec:
nodeName: REMOTE
containers: [{name: c, image: "registry.k8s.io/e2e-test-images/jessie-dnsutils:1.7", command: ["sleep", "3600"]}]
- apiVersion: v1
kind: Pod
metadata: {name: plain, namespace: egtest, labels: {egress: "none"}}
spec:
nodeName: REMOTE
containers: [{name: c, image: "curlimages/curl:8.10.1", command: ["sleep", "3600"]}]
# east-west: /clientip answers with the address the connection came from
- apiVersion: v1
kind: Pod
metadata: {name: echo, namespace: egtest, labels: {app: echo}}
spec:
nodeName: OTHER
containers:
- name: echo
image: registry.k8s.io/e2e-test-images/agnhost:2.53
args: ["netexec", "--http-port=8080"]
readinessProbe: {httpGet: {path: /healthz, port: 8080}}
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
# End-to-end test of EgressService against a live cluster (assertions A1-A6 of the design, plus the first
# packet). Members ask outside services which address they come from: api.ipify.org (TCP) and OpenDNS's
# myip.opendns.com (UDP).
#
# CP="ssh ... root@<cp>" HOLDER_SSH="ssh ... root@<holder>" REMOTE_SSH="ssh ... root@<remote>" \
# ADDR=<address HOLDER holds> HOLDER=<node> REMOTE=<node> OTHER=<node> \
# reweave/test/egress/run.sh
#
# Asserts, in order:
# 0. the holder writes the status: host, address, a port block for every node
# 1. the first packet of a member started after the EgressService leaves as ADDR (CNI admission)
# A1/A2. members on every node leave as ADDR (TCP and UDP) and get their replies; ping works from every node
# ICMP errors from routers on the way (traceroute's time-exceeded) reach a member on another node
# A4. a non-member pod on the same node as a member leaves as its node (untouched), and no rule names it
# A3. a member talking to a pod sees its own pod address there (east-west untouched)
# A5. naming an address nobody holds clears the status, and members' outside traffic (TCP, ping) is dropped
# A6. deleting the EgressService removes its state: members leave as their node again, rules are gone
# Leaves nothing behind (namespace egtest is deleted, also on failure).
set -euo pipefail
: "${CP:?}" "${HOLDER_SSH:?}" "${REMOTE_SSH:?}" "${ADDR:?}" "${HOLDER:?}" "${REMOTE:?}" "${OTHER:?}"
here=$(cd "$(dirname "$0")" && pwd)
k() { $CP "KUBECONFIG=/etc/kubernetes/admin.conf kubectl $*"; }
fail() { echo "FAIL: $*" >&2; exit 1; }
ok() { echo "ok: $*"; }
cleanup() { k delete namespace egtest --ignore-not-found --wait=false >/dev/null 2>&1 || true; }
trap cleanup EXIT
eventually() {
local n=$1; shift
for _ in $(seq "$n"); do
if "$@" >/dev/null 2>&1; then return 0; fi
sleep 1
done
return 1
}
whoami_tcp() { k -n egtest exec "$1" -- curl -s --max-time 10 https://api.ipify.org; }
# each check re-reads on every try of eventually
tcp_is() { [ "$(whoami_tcp "$1" || true)" = "$2" ]; }
whoami_udp() { k -n egtest exec "$1" -- dig +short +time=5 +tries=1 myip.opendns.com @208.67.222.222; }
node_ip() { k get node "$1" -o "jsonpath='{.status.addresses[?(@.type==\"InternalIP\")].address}'"; }
remote_ip=$(node_ip "$REMOTE")
[ -n "$remote_ip" ] || fail "no address for $REMOTE"
sed -e "s/\bADDR\b/$ADDR/g; s/\bHOLDER\b/$HOLDER/g; s/\bREMOTE\b/$REMOTE/g; s/\bOTHER\b/$OTHER/g" \
"$here/egress-test.yaml" | $CP "KUBECONFIG=/etc/kubernetes/admin.conf kubectl apply -f -" >/dev/null
k -n egtest wait --for=condition=Ready pod --all --timeout=300s >/dev/null || fail "test pods not ready"
status() { k -n egtest get egressservice mail -o "jsonpath='{.status.host} {.status.address} {.status.blocks[*].node}'"; }
status_is() { [ "$(status)" = "$1" ]; }
first_line() { k -n egtest logs member-first 2>/dev/null | head -1; }
said_something() { [ -n "$(first_line)" ]; }
want="$HOLDER $ADDR $(printf '%s\n' "$HOLDER" "$REMOTE" "$OTHER" | sort | tr '\n' ' ' | sed 's/ $//')"
eventually 60 status_is "$want" || fail "status: '$(status)', want '$want'"
ok "0. status: host $HOLDER, address $ADDR, a port block for each node"
# a member created now: its very first connection must already leave as ADDR
$CP "KUBECONFIG=/etc/kubernetes/admin.conf kubectl -n egtest run member-first --labels egress=member --restart=Never \
--overrides='{\"spec\":{\"nodeName\":\"$REMOTE\"}}' --image=curlimages/curl:8.10.1 --command -- \
sh -c 'curl -s --max-time 10 https://api.ipify.org; echo; sleep 3600'" >/dev/null
eventually 120 said_something || fail "member-first said nothing"
first=$(first_line)
[ "$first" = "$ADDR" ] || fail "member-first's first connection left as '$first', want $ADDR"
ok "1. a new member's first connection leaves as $ADDR"
for p in member-remote member-other member-holder; do
got=$(whoami_tcp $p || true)
[ "$got" = "$ADDR" ] || fail "A1: $p left as '$got', want $ADDR"
done
got=$(whoami_udp member-dns || true)
[ "$got" = "$ADDR" ] || fail "A1: member-dns (UDP) left as '$got', want $ADDR"
ok "A1/A2. members on $REMOTE, $OTHER and $HOLDER leave as $ADDR over TCP and UDP, replies come back"
for p in member-remote member-other member-holder; do
k -n egtest exec $p -- ping -c 2 -W 3 1.1.1.1 >/dev/null || fail "ping from $p got no reply"
done
ok "ping from members on all three nodes gets its replies"
# hop 1 is the member's own node, hop 2 the holder (it answers its own address, the member cannot see it); every
# later hop is a router on the internet whose time-exceeded comes back to ADDR and must find the member
hops=$(k -n egtest exec member-remote -- traceroute -n -m 8 -q 1 -w 2 1.1.1.1 2>&1 || true)
echo "$hops" | awk '$1 >= 3 && $2 ~ /^[0-9]+\./ {found=1} END {exit !found}' \
|| fail "no router beyond the holder answered the member's traceroute:
$hops"
ok "ICMP errors from routers beyond the holder reach a member on $REMOTE (traceroute)"
got=$(whoami_tcp plain || true)
[ "$got" = "$remote_ip" ] || fail "A4: non-member left as '$got', want its node $remote_ip"
plain_ip=$(k -n egtest get pod plain -o "jsonpath='{.status.podIP}'")
if $REMOTE_SSH "iptables-save | grep -F -- '$plain_ip/32'; ip rule | grep -F -- '$plain_ip'"; then
fail "A4: a rule names the non-member $plain_ip"
fi
ok "A4. the non-member leaves as its node ($remote_ip); no rule names it"
echo_ip=$(k -n egtest get pod echo -o "jsonpath='{.status.podIP}'")
member_ip=$(k -n egtest get pod member-remote -o "jsonpath='{.status.podIP}'")
got=$(k -n egtest exec member-remote -- curl -s --max-time 5 "http://$echo_ip:8080/clientip" || true)
[ "${got%:*}" = "$member_ip" ] || fail "A3: echo saw '$got', want the member's pod address $member_ip"
ok "A3. east-west: the echo pod sees the member's own address ($member_ip)"
k -n egtest patch svc mail --type merge -p "'{\"spec\":{\"loadBalancerIP\":\"192.0.2.1\"}}'" >/dev/null
eventually 60 status_is " " || fail "A5: status not cleared: '$(status)'"
got=$(whoami_tcp member-remote || true)
[ -z "$got" ] || fail "A5: with no holder a member still got out, as '$got'"
if k -n egtest exec member-remote -- ping -c 1 -W 3 1.1.1.1 >/dev/null 2>&1; then
fail "A5: with no holder a member's ping still got out"
fi
k -n egtest patch svc mail --type merge -p "'{\"spec\":{\"loadBalancerIP\":\"$ADDR\"}}'" >/dev/null
eventually 60 status_is "$want" || fail "status not restored: '$(status)'"
eventually 30 tcp_is member-remote "$ADDR" || fail "member did not get back out as $ADDR"
ok "A5. no holder: status cleared, members dropped (not leaked); restored afterwards"
k -n egtest delete egressservice mail >/dev/null
eventually 60 tcp_is member-remote "$remote_ip" || fail "A6: after delete, member left as '$(whoami_tcp member-remote || true)'"
if $REMOTE_SSH "ip rule | grep -E '^9100:'"; then fail "A6: member rules left on $REMOTE"; fi
if $HOLDER_SSH "ip rule | grep -E '^90:'"; then fail "A6: holder rules left on $HOLDER"; fi
ok "A6. EgressService deleted: members leave as their node again, no rules left"
echo PASS
+126
View File
@@ -0,0 +1,126 @@
# Objects for the Gateway e2e (run.sh): two echo backends (agnhost netexec: /hostname, /clientip, /header), one on
# the holder and one elsewhere, and a Gateway of class reweave on test ports of ADDR (HTTPS with the test
# certificate, plain HTTP that redirects, and TCP straight to a backend), with its routes.
apiVersion: v1
kind: Namespace
metadata:
name: gwtest
---
apiVersion: v1
kind: Pod
metadata: {name: echo-a, namespace: gwtest, labels: {app: echo-a}}
spec:
nodeName: HOLDER
containers:
- {name: c, image: "registry.k8s.io/e2e-test-images/agnhost:2.59", args: ["netexec", "--http-port=8080"]}
---
apiVersion: v1
kind: Pod
metadata: {name: echo-b, namespace: gwtest, labels: {app: echo-b}}
spec:
nodeName: OTHER
containers:
- {name: c, image: "registry.k8s.io/e2e-test-images/agnhost:2.59", args: ["netexec", "--http-port=8080"]}
---
apiVersion: v1
kind: Pod
metadata: {name: client, namespace: gwtest}
spec:
nodeName: OTHER
containers:
- {name: c, image: "curlimages/curl:8.10.1", command: ["sleep", "3600"]}
---
apiVersion: v1
kind: Service
metadata: {name: echo-a, namespace: gwtest}
spec:
selector: {app: echo-a}
ports: [{name: http, port: 80, targetPort: 8080}]
---
apiVersion: v1
kind: Service
metadata: {name: echo-b, namespace: gwtest}
spec:
selector: {app: echo-b}
ports: [{name: http, port: 80, targetPort: 8080}]
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata: {name: gwtest, namespace: gateway-system}
spec:
gatewayClassName: reweave
addresses: [{type: IPAddress, value: ADDR}]
listeners:
- name: https
port: 18443
protocol: HTTPS
hostname: gw.test
tls: {certificateRefs: [{name: gwtest-cert}]}
allowedRoutes: {namespaces: {from: Selector, selector: {matchLabels: {kubernetes.io/metadata.name: gwtest}}}}
- name: http
port: 18080
protocol: HTTP
allowedRoutes: {namespaces: {from: All}}
- name: tcp
port: 18022
protocol: TCP
allowedRoutes: {namespaces: {from: All}}
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata: {name: main, namespace: gwtest}
spec:
parentRefs: [{name: gwtest, namespace: gateway-system, sectionName: https}]
hostnames: [gw.test]
rules:
# exact beats the default prefix "/"; /a/... reaches echo-a with /a cut off; the rest goes to echo-a as is
- matches: [{path: {type: Exact, value: /hostname}}]
backendRefs: [{name: echo-b, port: 80}]
- matches: [{path: {type: PathPrefix, value: /a}}]
backendRefs: [{name: echo-a, port: 80}]
filters:
- type: URLRewrite
urlRewrite: {path: {type: ReplacePrefixMatch, replacePrefixMatch: /}}
- backendRefs: [{name: echo-a, port: 80}]
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata: {name: broken, namespace: gwtest}
spec:
parentRefs: [{name: gwtest, namespace: gateway-system, sectionName: https}]
hostnames: [gw.test]
rules:
- matches: [{path: {type: PathPrefix, value: /broken}}]
backendRefs: [{name: missing, port: 80}]
---
# refused: the gateway sets the forwarding headers itself, so no route may change them
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata: {name: forge, namespace: gwtest}
spec:
parentRefs: [{name: gwtest, namespace: gateway-system, sectionName: https}]
hostnames: [gw.test]
rules:
- matches: [{path: {type: PathPrefix, value: /forge}}]
filters:
- type: RequestHeaderModifier
requestHeaderModifier: {set: [{name: X-Forwarded-For, value: 6.6.6.6}]}
backendRefs: [{name: echo-a, port: 80}]
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata: {name: redirect, namespace: gwtest}
spec:
parentRefs: [{name: gwtest, namespace: gateway-system, sectionName: http}]
rules:
- filters:
- type: RequestRedirect
requestRedirect: {scheme: https, port: 18443, statusCode: 301}
---
apiVersion: gateway.networking.k8s.io/v1
kind: TCPRoute
metadata: {name: raw, namespace: gwtest}
spec:
parentRefs: [{name: gwtest, namespace: gateway-system, sectionName: tcp}]
rules:
- backendRefs: [{name: echo-b, port: 80}]
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env bash
# End-to-end test of the agent's Gateways (class reweave) against a live cluster, on test ports of ADDR.
#
# CP="ssh ... root@<cp>" HOLDER_SSH="ssh ... root@<holder>" ADDR=<address HOLDER holds> HOLDER=<node> \
# OTHER=<node> reweave/test/gateway/run.sh
#
# The machine running this script is the outside client. Asserts, in order:
# G0 GatewayClass reweave is accepted
# G1 the Gateway is accepted and programmed at ADDR, its listeners with their routes attached
# G2 route statuses: accepted and resolved, the broken one ResolvedRefs False (BackendNotFound), and the one whose
# filter sets X-Forwarded-For refused (Accepted False, UnsupportedValue)
# G3 TCP listener: forwarded in the kernel to a pod on another node, which sees this client's own address
# G4 HTTPS from outside: the listener's certificate by SNI, HTTP/2, routing by exact path, by prefix with a
# rewrite, and by default; the backend sees this client (the same address as G3) in X-Forwarded-For
# G5 plain HTTP redirects to HTTPS on the listener's port, path and query kept
# G6 an unresolved backend answers 500; a name no listener has gets no certificate
# G7 a pod inside the cluster reaches the Gateway too
# G8 a renewed certificate is served without a restart
# G9 fail closed: an address nobody holds is not programmed and nothing is served; back at ADDR, it is again
# G10 deleting the Gateway removes its accept and forward rules from the holder
# Leaves nothing behind (namespace gwtest, the Gateway and its Secret are deleted, also on failure).
set -euo pipefail
: "${CP:?}" "${HOLDER_SSH:?}" "${ADDR:?}" "${HOLDER:?}" "${OTHER:?}"
here=$(cd "$(dirname "$0")" && pwd)
k() { $CP "KUBECONFIG=/etc/kubernetes/admin.conf kubectl $*"; }
kin() { $CP "KUBECONFIG=/etc/kubernetes/admin.conf kubectl $* -f -"; }
fail() { echo "FAIL: $*" >&2; exit 1; }
ok() { echo "ok: $*"; }
work=$(mktemp -d)
cleanup() {
k -n gateway-system delete gateway gwtest --ignore-not-found >/dev/null 2>&1 || true
k -n gateway-system delete secret gwtest-cert --ignore-not-found >/dev/null 2>&1 || true
k delete namespace gwtest --ignore-not-found --wait=false >/dev/null 2>&1 || true
rm -rf "$work"
}
trap cleanup EXIT
eventually() {
local n=$1; shift
for _ in $(seq "$n"); do
if "$@" >/dev/null 2>&1; then return 0; fi
sleep 1
done
return 1
}
# a test certificate for gw.test, as a TLS Secret in gateway-system
cert() {
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -days 1 -subj "/CN=gw.test" \
-addext "subjectAltName=DNS:gw.test" -keyout "$work/$1.key" -out "$work/$1.crt" 2>/dev/null
printf 'apiVersion: v1\nkind: Secret\nmetadata: {name: gwtest-cert, namespace: gateway-system}\ntype: kubernetes.io/tls\ndata:\n tls.crt: %s\n tls.key: %s\n' \
"$(base64 -w0 < "$work/$1.crt")" "$(base64 -w0 < "$work/$1.key")" | kin apply >/dev/null
}
fingerprint() { openssl x509 -noout -fingerprint -sha256 -in "$1" | cut -d= -f2; }
served() {
echo | openssl s_client -connect "$ADDR:18443" -servername gw.test 2>/dev/null |
openssl x509 -noout -fingerprint -sha256 2>/dev/null | cut -d= -f2
}
https() { curl -sk --max-time 10 --resolve "gw.test:18443:$ADDR" "$@"; }
cert v1
sed -e "s/\bADDR\b/$ADDR/g; s/\bHOLDER\b/$HOLDER/g; s/\bOTHER\b/$OTHER/g" "$here/gateway-test.yaml" | kin apply >/dev/null
k -n gwtest wait --for=condition=Ready pod --all --timeout=300s >/dev/null || fail "test pods not ready"
cond() { k get $1 -o "jsonpath='{.status.conditions[?(@.type==\"$2\")].status}/{.status.conditions[?(@.type==\"$2\")].reason}'"; }
is() { [ "$(cond "$1" "$2")" = "$3" ]; }
eventually 60 is "gatewayclass reweave" Accepted "True/Accepted" || fail "G0: GatewayClass: $(cond "gatewayclass reweave" Accepted)"
ok "G0. GatewayClass reweave accepted"
gw="-n gateway-system gateway gwtest"
eventually 90 is "$gw" Programmed "True/Programmed" || fail "G1: programmed: $(cond "$gw" Programmed)"
is "$gw" Accepted "True/Accepted" || fail "G1: accepted: $(cond "$gw" Accepted)"
[ "$(k get $gw -o "jsonpath='{.status.addresses[0].value}'")" = "$ADDR" ] || fail "G1: status address"
attached=$(k get $gw -o "jsonpath='{range .status.listeners[*]}{.name}={.attachedRoutes} {end}'")
[ "$attached" = "https=2 http=1 tcp=1 " ] || fail "G1: attached routes: '$attached'"
ok "G1. Gateway accepted and programmed at $ADDR; https=2 http=1 tcp=1 routes attached"
route() { k -n gwtest get "$1" "$2" -o "jsonpath='{.status.parents[0].conditions[?(@.type==\"$3\")].status}/{.status.parents[0].conditions[?(@.type==\"$3\")].reason}'"; }
route_is() { [ "$(route "$1" "$2" "$3")" = "$4" ]; }
for r in "httproute main" "httproute redirect" "tcproute raw" "httproute broken"; do
eventually 30 route_is $r Accepted "True/Accepted" || fail "G2: $r accepted: $(route $r Accepted)"
done
for r in "httproute main" "httproute redirect" "tcproute raw"; do
route_is $r ResolvedRefs "True/ResolvedRefs" || fail "G2: $r resolved: $(route $r ResolvedRefs)"
done
route_is httproute broken ResolvedRefs "False/BackendNotFound" || fail "G2: broken: $(route httproute broken ResolvedRefs)"
eventually 30 route_is httproute forge Accepted "False/UnsupportedValue" || fail "G2: forge: $(route httproute forge Accepted)"
ok "G2. routes accepted and resolved; the broken one ResolvedRefs False (BackendNotFound); the forging one refused"
tcp_hostname() { [ "$(curl -s --max-time 5 "http://$ADDR:18022/hostname")" = echo-b ]; }
eventually 30 tcp_hostname || fail "G3: TCP listener did not reach echo-b"
me=$(curl -s --max-time 5 "http://$ADDR:18022/clientip" | sed 's/:[0-9]*$//')
case "$me" in
10.*|"") fail "G3: the backend saw '$me', not this client's own address" ;;
esac
ok "G3. TCP listener forwards to echo-b on $OTHER, which sees this client as $me"
[ "$(served)" = "$(fingerprint "$work/v1.crt")" ] || fail "G4: wrong certificate for gw.test"
[ "$(https -o /dev/null -w '%{http_version}' https://gw.test:18443/hostname)" = 2 ] || fail "G4: no HTTP/2"
[ "$(https https://gw.test:18443/hostname)" = echo-b ] || fail "G4: exact /hostname did not reach echo-b"
[ "$(https https://gw.test:18443/a/hostname)" = echo-a ] || fail "G4: prefix /a (rewritten to /) did not reach echo-a"
xff=$(https "https://gw.test:18443/header?key=X-Forwarded-For")
[ "$xff" = "$me" ] || fail "G4: X-Forwarded-For '$xff', want $me"
proto=$(https "https://gw.test:18443/header?key=X-Forwarded-Proto")
[ "$proto" = https ] || fail "G4: X-Forwarded-Proto '$proto'"
ok "G4. HTTPS: gw.test's certificate, HTTP/2, exact/prefix+rewrite/default routing; X-Forwarded-For $me"
loc=$(curl -s -o /dev/null --max-time 5 -w '%{http_code} %{redirect_url}' -H 'Host: gw.test' "http://$ADDR:18080/a/x?y=1")
[ "$loc" = "301 https://gw.test:18443/a/x?y=1" ] || fail "G5: redirect: '$loc'"
ok "G5. HTTP redirects to https://gw.test:18443, path and query kept"
[ "$(https -o /dev/null -w '%{http_code}' https://gw.test:18443/broken)" = 500 ] || fail "G6: broken backend did not answer 500"
if curl -sk --max-time 5 --resolve "other.test:18443:$ADDR" https://other.test:18443/ >/dev/null 2>&1; then
fail "G6: a name no listener has got a certificate"
fi
ok "G6. unresolved backend answers 500; a name no listener has gets no certificate"
inside=$(k -n gwtest exec client -- curl -sk --max-time 10 --resolve "gw.test:18443:$ADDR" https://gw.test:18443/hostname)
[ "$inside" = echo-b ] || fail "G7: from a pod: '$inside'"
ok "G7. a pod on $OTHER reaches the Gateway"
cert v2
renewed() { [ "$(served)" = "$(fingerprint "$work/v2.crt")" ]; }
eventually 60 renewed || fail "G8: the renewed certificate is not served"
ok "G8. renewed certificate served without a restart"
k patch $gw --type=merge -p "'{\"spec\":{\"addresses\":[{\"type\":\"IPAddress\",\"value\":\"198.51.100.7\"}]}}'" >/dev/null
eventually 60 is "$gw" Programmed "False/AddressNotUsable" || fail "G9: unheld address: $(cond "$gw" Programmed)"
gone() { ! https -o /dev/null https://gw.test:18443/hostname; }
eventually 30 gone || fail "G9: still served from $ADDR after the Gateway moved off it"
k patch $gw --type=merge -p "'{\"spec\":{\"addresses\":[{\"type\":\"IPAddress\",\"value\":\"$ADDR\"}]}}'" >/dev/null
back() { [ "$(https https://gw.test:18443/hostname)" = echo-b ]; }
eventually 60 back || fail "G9: not served again at $ADDR"
ok "G9. an address nobody holds: Programmed False (AddressNotUsable), nothing served; back at $ADDR: served"
k -n gateway-system delete gateway gwtest >/dev/null
rules_gone() {
! $HOLDER_SSH "iptables -S REWEAVE-LB-IN; iptables -t nat -S REWEAVE-LB" | grep -q 'gateway-system/gwtest\|gateway gateway-system/gwtest'
}
eventually 60 rules_gone || fail "G10: the holder still has the Gateway's rules"
ok "G10. Gateway deleted: its accept and forward rules are gone from $HOLDER"
echo PASS
+75
View File
@@ -0,0 +1,75 @@
# End-to-end test of weave-npc's LoadBalancer agent (lb). Placeholders are
# filled by run.sh: ADDR (an address HOLDER holds), HOLDER (that node),
# REMOTE (another node: its pod's replies must be steered back to HOLDER),
# OTHER (a third node: a pod there connects to ADDR from inside the cluster).
apiVersion: v1
kind: List
items:
- apiVersion: v1
kind: Namespace
metadata:
name: lbtest
# /clientip answers with the address the connection came from
- apiVersion: v1
kind: Pod
metadata:
name: echo-remote
namespace: lbtest
labels: {app: echo-remote}
spec:
nodeName: REMOTE
containers:
- name: echo
image: registry.k8s.io/e2e-test-images/agnhost:2.53
args: ["netexec", "--http-port=8080"]
ports: [{name: http, containerPort: 8080}]
readinessProbe: {httpGet: {path: /healthz, port: 8080}}
- apiVersion: v1
kind: Pod
metadata:
name: echo-local
namespace: lbtest
labels: {app: echo-local}
spec:
nodeName: HOLDER
containers:
- name: echo
image: registry.k8s.io/e2e-test-images/agnhost:2.53
args: ["netexec", "--http-port=8080"]
ports: [{name: http, containerPort: 8080}]
readinessProbe: {httpGet: {path: /healthz, port: 8080}}
- apiVersion: v1
kind: Pod
metadata:
name: client
namespace: lbtest
spec:
nodeName: OTHER
containers:
- name: client
image: registry.k8s.io/e2e-test-images/agnhost:2.53
args: ["pause"]
- apiVersion: v1
kind: Service
metadata:
name: echo-remote
namespace: lbtest
spec:
type: LoadBalancer
loadBalancerClass: reweave.levk.dev/node-address
loadBalancerIP: ADDR
allocateLoadBalancerNodePorts: false
selector: {app: echo-remote}
ports: [{name: http, port: 18080, targetPort: http}]
- apiVersion: v1
kind: Service
metadata:
name: echo-local
namespace: lbtest
spec:
type: LoadBalancer
loadBalancerClass: reweave.levk.dev/node-address
loadBalancerIP: ADDR
allocateLoadBalancerNodePorts: false
selector: {app: echo-local}
ports: [{name: http, port: 18081, targetPort: http}]
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# End-to-end test of weave-npc's LoadBalancer agent against a live cluster.
#
# CP="ssh ... root@<control plane>" HOLDER_SSH="ssh ... root@<holder>" \
# ADDR=<address HOLDER holds> HOLDER=<node> REMOTE=<node> OTHER=<node> \
# reweave/test/lb/run.sh
#
# CP runs kubectl (admin.conf); HOLDER_SSH reads the holder's iptables. The
# machine running this script is the outside client. Asserts, in order:
# 1. each Service's status is exactly [{ip: ADDR, ipMode: Proxy}]
# 2. kube-proxy installed nothing for ADDR (ipMode Proxy leaves it to us)
# 3. from outside, both ports answer, and the pod sees the client's own
# address: from the holder's own pod, and from a pod on another node
# (whose replies must be steered back through the holder)
# 4. a pod on a third node reaches ADDR too
# 5. fail closed: naming an address nobody holds clears the status and
# stops the forward
# 6. deleting the Services removes the holder's chains
# Leaves nothing behind (namespace lbtest is deleted, also on failure).
set -euo pipefail
: "${CP:?}" "${HOLDER_SSH:?}" "${ADDR:?}" "${HOLDER:?}" "${REMOTE:?}" "${OTHER:?}"
here=$(cd "$(dirname "$0")" && pwd)
k() { $CP "KUBECONFIG=/etc/kubernetes/admin.conf kubectl $*"; }
fail() { echo "FAIL: $*" >&2; exit 1; }
ok() { echo "ok: $*"; }
cleanup() { k delete namespace lbtest --ignore-not-found --wait=false >/dev/null 2>&1 || true; }
trap cleanup EXIT
# eventually <tries> <command...>: retry once a second
eventually() {
local n=$1; shift
for _ in $(seq "$n"); do
if "$@" >/dev/null 2>&1; then return 0; fi
sleep 1
done
return 1
}
sed -e "s/\bADDR\b/$ADDR/g; s/\bHOLDER\b/$HOLDER/g; s/\bREMOTE\b/$REMOTE/g; s/\bOTHER\b/$OTHER/g" \
"$here/lb-test.yaml" | $CP "KUBECONFIG=/etc/kubernetes/admin.conf kubectl apply -f -" >/dev/null
k -n lbtest wait --for=condition=Ready pod --all --timeout=180s >/dev/null || fail "test pods not ready"
status() { k -n lbtest get svc "$1" -o "jsonpath='{.status.loadBalancer.ingress}'"; }
# each check re-reads on every try of eventually
status_is() { [ "$(status "$1")" = "$2" ]; }
chains_gone() { [ "$($HOLDER_SSH "iptables-save -t nat | grep -c '^:REWEAVE-LBS-'" || true)" = 0 ]; }
for s in echo-remote echo-local; do
want="[{\"ip\":\"$ADDR\",\"ipMode\":\"Proxy\"}]"
eventually 30 status_is $s "$want" || fail "$s status: $(status $s), want $want"
done
ok "1. status [{ip: $ADDR, ipMode: Proxy}] on both Services"
if $HOLDER_SSH "iptables-save -t nat | grep -E '^-A KUBE-' | grep -F -- '-d $ADDR/32'"; then
fail "kube-proxy has rules for $ADDR"
fi
ok "2. kube-proxy installed nothing for $ADDR"
# the client's own address, as the holder sees this machine's SSH session
me=$($HOLDER_SSH 'echo ${SSH_CLIENT%% *}')
for port in 18080 18081; do
got=""
for _ in $(seq 30); do
got=$(curl -s --max-time 3 "http://$ADDR:$port/clientip" || true)
[ -n "$got" ] && break
sleep 1
done
[ "${got%:*}" = "$me" ] || fail "port $port: pod saw '${got}', want this client ($me)"
done
ok "3. from outside, both pods see this client's own address ($me), remote pod's replies steered back"
k -n lbtest exec client -- /agnhost connect "$ADDR:18080" --timeout 5s >/dev/null || fail "pod on $OTHER cannot reach $ADDR:18080"
ok "4. a pod on $OTHER reaches $ADDR:18080"
k -n lbtest patch svc echo-remote --type merge -p "'{\"spec\":{\"loadBalancerIP\":\"192.0.2.1\"}}'" >/dev/null
eventually 30 status_is echo-remote "" || fail "status not cleared for an unheld address: $(status echo-remote)"
if curl -s --max-time 3 "http://$ADDR:18080/clientip" >/dev/null; then
fail "$ADDR:18080 still forwards after its Service moved to an unheld address"
fi
ok "5. unheld address: status cleared, forward gone (fail closed)"
k -n lbtest delete svc echo-remote echo-local >/dev/null
eventually 30 chains_gone || fail "holder kept per-port chains after the Services went away"
ok "6. Services deleted: holder's chains removed"
echo PASS
+14
View File
@@ -0,0 +1,14 @@
# netns.py <namespace> <pod>: the network namespace the weave CNI plugin attached a pod in, from the runtime's
# record of the attachment (libcni's result cache). Runs on the node.
import glob
import json
import sys
namespace, pod = sys.argv[1], sys.argv[2]
for path in glob.glob("/var/lib/cni/results/weave-*"):
with open(path) as f:
entry = json.load(f)
args = dict(entry.get("cniArgs") or [])
if args.get("K8S_POD_NAMESPACE") == namespace and args.get("K8S_POD_NAME") == pod:
print(entry["netns"])
break
+30
View File
@@ -0,0 +1,30 @@
# Pods for the port lock e2e (run.sh): a forger and a victim on node A, a victim on node B. None has any added
# capability; the forged frames are sent from the forger's network namespace by root on the node.
apiVersion: v1
kind: Namespace
metadata:
name: pltest
---
apiVersion: v1
kind: Pod
metadata: {name: forger, namespace: pltest}
spec:
nodeName: NODE_A
containers:
- {name: c, image: "curlimages/curl:8.10.1", command: ["sleep", "3600"]}
---
apiVersion: v1
kind: Pod
metadata: {name: victim-local, namespace: pltest}
spec:
nodeName: NODE_A
containers:
- {name: c, image: "curlimages/curl:8.10.1", command: ["sleep", "3600"]}
---
apiVersion: v1
kind: Pod
metadata: {name: victim-remote, namespace: pltest}
spec:
nodeName: NODE_B
containers:
- {name: c, image: "curlimages/curl:8.10.1", command: ["sleep", "3600"]}
+200
View File
@@ -0,0 +1,200 @@
// Command probe sends hand-made Ethernet frames out of an interface, or waits for one carrying a tag. The port lock
// e2e runs it inside pods' network namespaces (nsenter, as root on the node): a pod forging another's addresses
// must not reach anyone.
//
// probe send <ifname> <dst-mac> <src-mac|own> ipv4|arp <src-ip|own> <dst-ip> <tag>
// probe listen <ifname> <tag> <seconds>
package main
import (
"bytes"
"encoding/binary"
"fmt"
"net"
"os"
"strconv"
"time"
"golang.org/x/sys/unix"
)
func main() {
if len(os.Args) < 2 {
usage()
}
var err error
switch os.Args[1] {
case "send":
if len(os.Args) != 9 {
usage()
}
err = send(os.Args[2], os.Args[3], os.Args[4], os.Args[5], os.Args[6], os.Args[7], os.Args[8])
case "listen":
if len(os.Args) != 5 {
usage()
}
secs, _ := strconv.Atoi(os.Args[4])
var seen bool
seen, err = listen(os.Args[2], os.Args[3], time.Duration(secs)*time.Second)
if err == nil {
if seen {
fmt.Println("seen")
return
}
fmt.Println("not seen")
os.Exit(1)
}
default:
usage()
}
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
}
func usage() {
fmt.Fprintln(os.Stderr, "usage: probe send <ifname> <dst-mac> <src-mac|own> ipv4|arp <src-ip|own> <dst-ip> <tag>")
fmt.Fprintln(os.Stderr, " probe listen <ifname> <tag> <seconds>")
os.Exit(2)
}
func send(ifName, dstMAC, srcMAC, kind, srcIP, dstIP, tag string) error {
iface, err := net.InterfaceByName(ifName)
if err != nil {
return err
}
dst, err := net.ParseMAC(dstMAC)
if err != nil {
return err
}
src := iface.HardwareAddr
if srcMAC != "own" {
if src, err = net.ParseMAC(srcMAC); err != nil {
return err
}
}
var from net.IP
if srcIP == "own" {
addrs, err := iface.Addrs()
if err != nil {
return err
}
for _, a := range addrs {
if n, ok := a.(*net.IPNet); ok && n.IP.To4() != nil {
from = n.IP.To4()
}
}
if from == nil {
return fmt.Errorf("%s has no IPv4 address", ifName)
}
} else if from = net.ParseIP(srcIP).To4(); from == nil {
return fmt.Errorf("bad source address %q", srcIP)
}
to := net.ParseIP(dstIP).To4()
if to == nil {
return fmt.Errorf("bad destination address %q", dstIP)
}
var frame []byte
switch kind {
case "ipv4":
frame = ipv4Frame(dst, src, from, to, []byte(tag))
case "arp":
frame = arpFrame(dst, src, from, to, []byte(tag))
default:
return fmt.Errorf("unknown kind %q", kind)
}
fd, err := unix.Socket(unix.AF_PACKET, unix.SOCK_RAW, int(htons(unix.ETH_P_ALL)))
if err != nil {
return err
}
defer unix.Close(fd)
if err := unix.Bind(fd, &unix.SockaddrLinklayer{Protocol: htons(unix.ETH_P_ALL), Ifindex: iface.Index}); err != nil {
return err
}
// a few copies, spaced out: the bridge and weave may still be learning where the destination is
for i := 0; i < 3; i++ {
if _, err := unix.Write(fd, frame); err != nil {
return err
}
time.Sleep(200 * time.Millisecond)
}
return nil
}
func listen(ifName, tag string, within time.Duration) (bool, error) {
iface, err := net.InterfaceByName(ifName)
if err != nil {
return false, err
}
fd, err := unix.Socket(unix.AF_PACKET, unix.SOCK_RAW, int(htons(unix.ETH_P_ALL)))
if err != nil {
return false, err
}
defer unix.Close(fd)
if err := unix.Bind(fd, &unix.SockaddrLinklayer{Protocol: htons(unix.ETH_P_ALL), Ifindex: iface.Index}); err != nil {
return false, err
}
tv := unix.NsecToTimeval((100 * time.Millisecond).Nanoseconds())
if err := unix.SetsockoptTimeval(fd, unix.SOL_SOCKET, unix.SO_RCVTIMEO, &tv); err != nil {
return false, err
}
buf := make([]byte, 65536)
deadline := time.Now().Add(within)
for time.Now().Before(deadline) {
n, _, err := unix.Recvfrom(fd, buf, 0)
if err != nil {
continue
}
if bytes.Contains(buf[:n], []byte(tag)) {
return true, nil
}
}
return false, nil
}
func htons(v uint16) uint16 { return v<<8 | v>>8 }
func ether(dst, src net.HardwareAddr, ethertype uint16, payload []byte) []byte {
f := make([]byte, 14, 14+len(payload))
copy(f[0:6], dst)
copy(f[6:12], src)
binary.BigEndian.PutUint16(f[12:14], ethertype)
return append(f, payload...)
}
func ipv4Frame(dst, src net.HardwareAddr, from, to net.IP, body []byte) []byte {
h := make([]byte, 20)
h[0] = 0x45
binary.BigEndian.PutUint16(h[2:4], uint16(20+len(body)))
h[8] = 64
h[9] = 253 // experimental protocol: nothing answers it, nothing on the way acts on it
copy(h[12:16], from)
copy(h[16:20], to)
binary.BigEndian.PutUint16(h[10:12], checksum(h))
return ether(dst, src, unix.ETH_P_IP, append(h, body...))
}
// arpFrame is an ARP request speaking for (src, from), asking for to; the tag rides as Ethernet trailer.
func arpFrame(dst, src net.HardwareAddr, from, to net.IP, tag []byte) []byte {
a := make([]byte, 28)
binary.BigEndian.PutUint16(a[0:2], 1)
binary.BigEndian.PutUint16(a[2:4], unix.ETH_P_IP)
a[4], a[5] = 6, 4
binary.BigEndian.PutUint16(a[6:8], 1)
copy(a[8:14], src)
copy(a[14:18], from)
copy(a[24:28], to)
return ether(dst, src, unix.ETH_P_ARP, append(a, tag...))
}
func checksum(b []byte) uint16 {
var s uint32
for i := 0; i+1 < len(b); i += 2 {
s += uint32(binary.BigEndian.Uint16(b[i:]))
}
for s>>16 != 0 {
s = s&0xffff + s>>16
}
return ^uint16(s)
}
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env bash
# End-to-end test of the port lock against a live cluster (assertion A7): a pod speaks only as itself.
#
# CP="ssh ... root@<cp>" A_SSH="ssh ... root@<a>" B_SSH="ssh ... root@<b>" A=<node a> B=<node b> \
# reweave/test/portlock/run.sh
#
# Asserts, in order:
# 0. every pod port on the three nodes (the CP's, A's and B's) carries the lock
# 1. a pod's own frames reach a pod on its node and a pod on another node (the control)
# 2. frames forging another pod's address reach neither
# 3. frames forging a node's bridge (its weave MAC and address) reach neither
# 4. ARP claiming another pod's address reaches no one; ARP for itself does
# The forged frames are sent from the forging pod's network namespace by root on its node (nsenter), the most a
# pod could ever do; the pod itself has no added capability.
# Leaves nothing behind (namespace pltest is deleted, also on failure; the probe is removed from the nodes).
set -euo pipefail
: "${CP:?}" "${A_SSH:?}" "${B_SSH:?}" "${A:?}" "${B:?}"
here=$(cd "$(dirname "$0")" && pwd)
k() { $CP "KUBECONFIG=/etc/kubernetes/admin.conf kubectl $*"; }
fail() { echo "FAIL: $*" >&2; exit 1; }
ok() { echo "ok: $*"; }
work=$(mktemp -d)
cleanup() {
k delete namespace pltest --ignore-not-found --wait=false >/dev/null 2>&1 || true
for s in "$A_SSH" "$B_SSH"; do $s 'rm -f /root/plprobe /root/plnetns.py' >/dev/null 2>&1 || true; done
rm -rf "$work"
}
trap cleanup EXIT
(cd "$here/../../.." && CGO_ENABLED=0 go build -o "$work/probe" ./reweave/test/portlock/probe/)
for s in "$A_SSH" "$B_SSH"; do
$s 'cat > /root/plprobe && chmod +x /root/plprobe' < "$work/probe"
$s 'cat > /root/plnetns.py' < "$here/netns.py"
done
# a port is locked when its ingress carries the three passes and the drop
unlocked() {
$1 'for p in $(ip -br link show master weave | sed "s/@.*//; s/ .*//" | grep ^vethwepl); do
f=$(tc filter show dev $p ingress)
for want in "protocol ip pref 1 u32" "protocol arp pref 2 u32" "protocol arp pref 3 u32" \
"protocol all pref 9 matchall" "gact action drop"; do
echo "$f" | grep -q "$want" || { echo "$p"; break; }
done
done'
}
all_locked() {
for s in "$CP" "$A_SSH" "$B_SSH"; do
u=$(unlocked "$s")
[ -z "$u" ] || fail "0. unlocked pod ports ($1): $u"
done
}
all_locked "before the test pods"
ok "0. every pod port on the three nodes carries the lock"
sed -e "s/\bNODE_A\b/$A/g; s/\bNODE_B\b/$B/g" "$here/portlock-test.yaml" |
$CP "KUBECONFIG=/etc/kubernetes/admin.conf kubectl apply -f -" >/dev/null
k -n pltest wait --for=condition=Ready pod --all --timeout=300s >/dev/null || fail "test pods not ready"
all_locked "with the test pods"
netns_of() { $1 "python3 /root/plnetns.py pltest $2"; }
mac_in() { $1 "nsenter --net=$2 ip -br link show eth0" | awk '{print $3}'; }
pod_ip() { k -n pltest get pod "$1" -o "jsonpath='{.status.podIP}'"; }
f_ns=$(netns_of "$A_SSH" forger)
vl_ns=$(netns_of "$A_SSH" victim-local)
vr_ns=$(netns_of "$B_SSH" victim-remote)
[ -n "$f_ns" ] && [ -n "$vl_ns" ] && [ -n "$vr_ns" ] || fail "test pods' namespaces not found"
vl_mac=$(mac_in "$A_SSH" "$vl_ns")
vr_mac=$(mac_in "$B_SSH" "$vr_ns")
vl_ip=$(pod_ip victim-local)
vr_ip=$(pod_ip victim-remote)
# the nodes' bridges, from the annotation the lb agent writes ("ip,mac")
bridge_of() { k get node "$1" -o "jsonpath='{.metadata.annotations.reweave\.levk\.dev/weave}'"; }
b_bridge=$(bridge_of "$B"); b_ip=${b_bridge%,*}; b_mac=${b_bridge#*,}
a_bridge=$(bridge_of "$A"); a_ip=${a_bridge%,*}; a_mac=${a_bridge#*,}
[ -n "$b_ip" ] && [ -n "$b_mac" ] && [ -n "$a_ip" ] && [ -n "$a_mac" ] || fail "no weave bridge annotation on $A or $B"
# expect <seen|not seen> <victim: local|remote> <tag> <send args...>: the victim listens while the forger sends
expect() {
local want=$1 where=$2 tag=$3-$RANDOM
shift 3
local vs vns
if [ "$where" = local ]; then vs=$A_SSH vns=$vl_ns; else vs=$B_SSH vns=$vr_ns; fi
$vs "nsenter --net=$vns /root/plprobe listen eth0 $tag 6" > "$work/listen" 2>&1 &
local listener=$!
sleep 1
$A_SSH "nsenter --net=$f_ns /root/plprobe send eth0 $* $tag" || fail "probe could not send: $*"
wait $listener || true
local got
got=$(cat "$work/listen")
[ "$got" = "$want" ] || fail "$tag ($where): '$got', want '$want'"
}
expect "seen" local own-local "$vl_mac own ipv4 own $vl_ip"
expect "seen" remote own-remote "$vr_mac own ipv4 own $vr_ip"
ok "1. the forger's own frames reach a pod on its node and one on $B"
expect "not seen" remote pod-ip-remote "$vr_mac own ipv4 $vl_ip $vr_ip"
expect "not seen" local pod-ip-local "$vl_mac own ipv4 $vr_ip $vl_ip"
expect "not seen" remote pod-mac-remote "$vr_mac $vl_mac ipv4 $vl_ip $vr_ip"
ok "2. frames forging another pod's address reach no one"
expect "not seen" local node-b-local "$vl_mac $b_mac ipv4 $b_ip $vl_ip"
expect "not seen" remote node-b-remote "$vr_mac $b_mac ipv4 $b_ip $vr_ip"
expect "not seen" remote node-a-remote "$vr_mac $a_mac ipv4 $a_ip $vr_ip"
ok "3. frames forging a node's bridge ($A's, $B's) reach no one"
expect "not seen" local arp-claim-local "ff:ff:ff:ff:ff:ff own arp $vr_ip $vl_ip"
expect "not seen" remote arp-claim-remote "ff:ff:ff:ff:ff:ff own arp $vl_ip $vr_ip"
expect "seen" remote arp-own-remote "ff:ff:ff:ff:ff:ff own arp own $vr_ip"
ok "4. ARP claiming another pod's address reaches no one; the forger's ARP for itself does"
echo "PASS"
+19 -5
View File
@@ -725,8 +725,11 @@ peer_args() {
install_cni_plugin() {
mkdir -p $1 || return 1
if [ ! -f "$1/$CNI_PLUGIN_NAME" ]; then
cp /usr/bin/weaveutil "$1/$CNI_PLUGIN_NAME"
# (re)install whenever the image's plugin differs from the installed one: the name
# (weave-plugin-$IMAGE_VERSION) stays the same across builds of one version ("latest")
if [ ! -f "$1/$CNI_PLUGIN_NAME" ] || \
[ "$(sha256sum < /usr/bin/weaveutil)" != "$(sha256sum < "$1/$CNI_PLUGIN_NAME")" ]; then
cp /usr/bin/weaveutil "$1/$CNI_PLUGIN_NAME.tmp" && mv -f "$1/$CNI_PLUGIN_NAME.tmp" "$1/$CNI_PLUGIN_NAME"
fi
}
@@ -763,7 +766,8 @@ create_cni_config() {
{
"name": "weave",
"type": "weave-net",
"hairpinMode": $2
"hairpinMode": $2,
"egressAdmission": $3
},
{
"type": "portmap",
@@ -778,6 +782,9 @@ EOF
setup_cni() {
# if env var HAIRPIN_MODE is not set, default it to true
HAIRPIN_MODE=${HAIRPIN_MODE:-true}
# weave-npc admits every pod before it gets its interface (EgressService members leave from their
# Service's address from the first packet); set to false only where weave-npc runs with --lb=false
EGRESS_ADMISSION=${EGRESS_ADMISSION:-true}
if install_cni_plugin $CNI_PLUGIN_DIR ; then
upgrade_cni_plugin $CNI_PLUGIN_DIR
fi
@@ -786,8 +793,15 @@ setup_cni() {
# clusters with Weave version <= 2.4.0 remove existing 10-weave.conf
rm -f $HOST_ROOT/etc/cni/net.d/10-weave.conf
if [ -d $HOST_ROOT/etc/cni/net.d -a ! -f $HOST_ROOT/etc/cni/net.d/10-weave.conflist ] ; then
create_cni_config $HOST_ROOT/etc/cni/net.d/10-weave.conflist $HAIRPIN_MODE
# (re)write the conflist whenever what it should say changed, so upgrades reach existing nodes
if [ -d $HOST_ROOT/etc/cni/net.d ] ; then
CONFLIST=$HOST_ROOT/etc/cni/net.d/10-weave.conflist
create_cni_config $CONFLIST.new $HAIRPIN_MODE $EGRESS_ADMISSION
if [ -f $CONFLIST ] && [ "$(cat $CONFLIST.new)" = "$(cat $CONFLIST)" ] ; then
rm -f $CONFLIST.new
else
mv -f $CONFLIST.new $CONFLIST
fi
fi
}