Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@ jobs:
files: coverage.out
token: ${{ secrets.CODECOV_TOKEN }}

generate:
# The BPF programs are committed compiled, so that building sixup needs no clang; they have to
# be what bpf/*.c compiles to, with the toolchain AGENTS.md names.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: docker run --rm -v "$PWD:/src" -w /src golang:1.27-alpine3.24 sh -c 'apk add -q clang llvm libbpf-dev linux-headers && go generate ./...'
- name: Check the generated files
run: |
test -z "$(git status --porcelain)" || { git status --porcelain; echo "bpf/*.c changed: run go generate as AGENTS.md says and commit the result"; exit 1; }

lint:
runs-on: ubuntu-latest
steps:
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
- Write modern Go 1.27: generics, `slices`/`maps`/`cmp`, `errors.Is`/`%w`, `log/slog`, context. Compatibility with older versions is not a concern.
- Do not rush to commit. After changing code, run `go build ./... && go vet ./... && go test ./...` and `GOOS=linux golangci-lint run` (the Linux target is the real one; on macOS the linux-only netlink constants are reported as unused), and wait for the user to confirm before committing.
- Comments, log messages and command-line help are always in English.
- The BPF programs in `bpf/` are committed compiled. After changing them, regenerate with the toolchain CI checks against, and commit the result: `docker run --rm -v "$PWD:/src" -w /src golang:1.27-alpine3.24 sh -c 'apk add -q clang llvm libbpf-dev linux-headers && go generate ./...'`.
38 changes: 33 additions & 5 deletions bpf.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,15 +221,43 @@ func (o *bpfObjects) close() {
}
}

// attachTCX attaches the program to the egress of a device; closing the descriptor it returns
// detaches it, and so does sixup ending.
func (o *bpfObjects) attachTCX(ifindex int) (int, error) {
// attachTCX attaches the program to a device, at unix.BPF_TCX_EGRESS or unix.BPF_TCX_INGRESS, and
// returns the link, which detaches it when closed, as it does when sixup ends.
func (o *bpfObjects) attachTCX(ifindex int, at uint32) (*os.File, error) {
attr := struct {
prog, ifindex, attachType, flags uint32
relative, _ uint32
expectedRevision uint64
}{prog: uint32(o.prog), ifindex: uint32(ifindex), attachType: unix.BPF_TCX_EGRESS}
return bpfSys(unix.BPF_LINK_CREATE, unsafe.Pointer(&attr), unsafe.Sizeof(attr))
}{prog: uint32(o.prog), ifindex: uint32(ifindex), attachType: at}
fd, err := bpfSys(unix.BPF_LINK_CREATE, unsafe.Pointer(&attr), unsafe.Sizeof(attr))
if err != nil {
return nil, fmt.Errorf("attaching the program, which needs Linux 6.6 or later: %w", err)
}
return os.NewFile(uintptr(fd), "tcx"), nil
}

// drops sums the counters of a program's stats map over the CPUs, by the names of those that
// count drops, and adds what its slow path dropped; those that are not zero.
func drops(stats *bpfMap, names []string, slow *slowPath) string {
var parts []string
per := make([]byte, stats.valueSize)
for i, name := range names {
if name == "" {
continue
}
stats.lookup(nativeEndian.AppendUint32(nil, uint32(i)), per) // every index of an array is there
var n uint64
for c := 0; c < len(per); c += 8 {
n += nativeEndian.Uint64(per[c:])
}
if n != 0 {
parts = append(parts, fmt.Sprintf("%s %d", name, n))
}
}
if n := slow.dropped.Load(); n != 0 {
parts = append(parts, fmt.Sprintf("slow-path %d", n))
}
return strings.Join(parts, ", ")
}

// errRingClosed is what a ring buffer reader returns once closed.
Expand Down
99 changes: 99 additions & 0 deletions bpf/clat.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// SPDX-License-Identifier: (MIT OR GPL-2.0)
//
// A CLAT (RFC 6877) for this host's own IPv4, through the NAT64 of the line's PREF64. IPv4 leaves
// by the low-priority default route on the device sixup-clat, from 192.0.0.4, and the program on
// that device's egress translates it to IPv6 from the host's CLAT address and sends it out of the
// WAN, the kernel filling in the link layer. On the WAN's ingress the same program takes the IPv6
// from the NAT64 prefix to the CLAT address, translates it back and hands it to sixup-clat's
// ingress, where the host receives it, with the errors routers on the IPv6 path send the address.
// Everything else on the WAN passes untouched. The WAN is an Ethernet link.

#include "xlat.h"

volatile const __u8 clat6[16]; // the host's CLAT address
volatile const __be32 clat4; // 192.0.0.4
volatile const __u32 clat_ifindex; // sixup-clat
volatile const __u8 clat_mac[6]; // and its address, which what it receives has to be for
volatile const __u32 wan_ifindex; // the WAN

static __always_inline int is_clat6(const struct in6_addr *a)
{
#pragma unroll
for (int i = 0; i < 16; i++)
if (a->s6_addr[i] != clat6[i])
return 0;
return 1;
}

// map6to4 extracts what the NAT64 prefix carries. Any other source is a router on the IPv6 path
// reporting an error, which becomes the IPv4 dummy address (RFC 7600).
static __always_inline int map6to4(const struct in6_addr *a, __be32 *out, int alloc)
{
if (in_pref64(a))
*out = extract4(a);
else if (is_clat6(a))
*out = clat4;
else
*out = bpf_htonl(0xc0000008); // 192.0.0.8
return 0;
}

static __always_inline int map4to6(__be32 a, struct in6_addr *out)
{
if (a != clat4) {
embed4(out, a);
return 0;
}
#pragma unroll
for (int i = 0; i < 16; i++)
out->s6_addr[i] = clat6[i];
return 0;
}

// On the WAN, the program's is what the NAT64 sends the CLAT address, and the errors any router on
// the way sends it; neighbour discovery and the rest of ICMPv6 for the address are the kernel's
static __always_inline int skip6(struct __sk_buff *skb, const struct ipv6hdr *ip6)
{
if (!is_clat6(&ip6->daddr))
return TC_ACT_OK;
if (in_pref64(&ip6->saddr))
return XLAT;
__u8 type;
if (ip6->nexthdr == IPPROTO_ICMPV6 && !bpf_skb_load_bytes(skb, ETH_HLEN + sizeof(*ip6), &type, 1) && type < 128)
return XLAT;
return TC_ACT_OK;
}

// On sixup-clat, only what the host sends from 192.0.0.4; anything else has no way back
static __always_inline int skip4(struct __sk_buff *skb, const struct iphdr *ip)
{
return ip->saddr == clat4 ? XLAT : TC_ACT_SHOT;
}

static __always_inline int deliver(struct __sk_buff *skb, int to6)
{
if (to6)
return bpf_redirect_neigh(wan_ifindex, NULL, 0, 0);
// Received on the WAN, the frame is addressed to it, which sixup-clat would not take for its own
if (bpf_skb_store_bytes(skb, 0, (const void *)clat_mac, sizeof(clat_mac), 0))
return count(STAT_DROP_ERR, TC_ACT_SHOT);
return bpf_redirect(clat_ifindex, BPF_F_INGRESS);
}

SEC("tc")
int clat(struct __sk_buff *skb)
{
int v6 = skb->protocol == bpf_htons(ETH_P_IPV6);
if (skb->ifindex == wan_ifindex)
return v6 ? six_to_four(skb) : TC_ACT_OK;
// sixup-clat's egress. What sixup's slow path translated goes on like what the program does
if (skb->mark == slow_mark) {
skb->mark = 0;
return deliver(skb, v6);
}
if (skb->protocol == bpf_htons(ETH_P_IP))
return four_to_six(skb);
return TC_ACT_SHOT; // the device's own MLD and router solicitations
}

char LICENSE[] SEC("license") = "Dual MIT/GPL";
Loading
Loading