36 Commits
Author SHA1 Message Date
Ruakij d422b4db8c Make INTERFACE optional with default wg0, document host networking, capabilities and name lookup 2026-10-20 23:33:14 +00:00
Ruakij c7b16dd275 Reconcile AllowedIPs from the routes of the interface instead of replaying route events 2026-10-20 23:28:49 +00:00
Ruakij 925d266ab4 Read iproute2 names from /usr/share, rt_tables.d and any whitespace, no panic on short lines 2026-10-20 21:17:17 +00:00
Ruakij 74f0a2f5ab Replace Jenkinsfile with GitHub Actions CI, release and lint tooling 2026-10-20 20:47:44 +00:00
Ruakij 222877a847 Fix golangci-lint findings 2026-10-20 15:28:00 +00:00
Ruakij 08c871f84b gofmt 2026-10-20 15:09:00 +00:00
Ruakij acfd556452 Upgrade to Go 1.27.3 and update dependencies 2026-10-20 13:17:22 +00:00
Ruakij 92cd1da6f0 Merge branch 'dev' 2024-02-07 12:06:44 +01:00
Ruakij f8854b4fa6 Add Jenkinsfile 2024-02-07 12:05:50 +01:00
Ruakij dcc0b6607b Merge branch 'release-0.2' 2023-03-31 16:22:22 +02:00
Ruakij 438d80cda6 Merge branch 'dev' into release-0.2 2023-03-31 16:22:15 +02:00
Ruakij 6d14614043 Pass Slice by reference, not value 2023-03-31 16:22:01 +02:00
Ruakij 021bd254f1 Merge branch 'release-0.2' 2023-03-30 14:29:50 +02:00
Ruakij 8488a9e4cd move netchecks to subfolder of wgchecks 2023-03-30 14:28:19 +02:00
Ruakij 0c85d8ae1a Update dependencies 2023-03-30 14:28:05 +02:00
Ruakij 05bab9dd60 Fix compose-example 2023-03-30 14:25:59 +02:00
Ruakij 36e8aa9b87 Merge branch 'feat_periodicSync' into dev 2023-03-30 12:44:40 +02:00
Ruakij 9a700a117c Add documentation for PERIODIC_SYNC 2023-03-30 12:44:09 +02:00
Ruakij add4e8dbcf Add function to run periodic checks 2023-03-30 12:39:38 +02:00
Ruakij 74bc32fc2c Add env-var and check for periodic-sync 2023-03-30 12:34:18 +02:00
Ruakij 56b47e8fb6 Move setting all routes to own func 2023-03-30 12:31:22 +02:00
Ruakij 3b9715843f Merge branch 'release-0.1' 2022-11-17 14:52:39 +01:00
Ruakij 171bf82dc1 Merge branch 'dev' into release-0.1 2022-11-17 14:52:26 +01:00
Ruakij d5886fcb4a Add iproute2 as packet 2022-11-17 14:51:53 +01:00
Ruakij 180f11cb86 Add warning-output for errors in iproute2mapping 2022-11-17 14:51:38 +01:00
Ruakij d0e6fa65d2 Fix error-reporting 2022-11-17 14:51:18 +01:00
Ruakij f136bcb207 Fix wrong docker-service and image name 2022-11-15 22:49:06 +01:00
Ruakij d0840e6171 Swap check for slightly better performance 2022-11-15 22:48:10 +01:00
Ruakij 9d31008d29 Remove unnecessary require 2022-11-15 22:47:53 +01:00
Ruakij 2d5aacf397 Merge branch 'release-0.1' 2022-11-15 19:25:35 +01:00
Ruakij a5f2f5f102 Merge branch 'dev' into release-0.1 2022-11-15 19:25:27 +01:00
Ruakij 323db065ad Add information about required capabilites for docker 2022-11-15 19:25:16 +01:00
Ruakij ca9507e26f Add documentation about environment-vars 2022-11-15 19:14:19 +01:00
Ruakij 5860e53690 Merge branch 'release-0.1' into dev 2022-11-15 18:47:43 +01:00
Ruakij 868a80f527 Merge branch 'release-0.1' 2022-11-15 18:47:31 +01:00
Ruakij 111d316802 Add missing \ 2022-11-15 18:47:21 +01:00
40 changed files with 3140 additions and 1366 deletions
+3
View File
@@ -0,0 +1,3 @@
.git
/bin
/dist
+30
View File
@@ -0,0 +1,30 @@
#!/bin/sh
set -e
go mod tidy -diff
files=$(git diff --cached --name-only --diff-filter=ACM -- '*.go')
[ -n "$files" ] || exit 0
pkgs=$(echo "$files" | xargs -n1 dirname | sort -u | sed 's|^|./|')
unformatted=$(gofmt -l $files)
if [ -n "$unformatted" ]; then
echo "gofmt: $unformatted" >&2
exit 1
fi
# The program is Linux-only (netlink), so everything is checked for Linux.
export GOOS=linux
go vet $pkgs
# Test binaries only run on a Linux host; elsewhere they are compiled, not run.
if [ "$(go env GOHOSTOS)" = linux ]; then
go test -race $pkgs
else
go test -exec /usr/bin/true $pkgs
fi
if command -v golangci-lint >/dev/null; then
golangci-lint run $pkgs
fi
+26
View File
@@ -0,0 +1,26 @@
version: 2
updates:
- package-ecosystem: gomod
directory: /
schedule:
interval: weekly
groups:
go-dependencies:
patterns: ["*"]
update-types: [minor, patch]
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
groups:
actions:
patterns: ["*"]
- package-ecosystem: docker
directory: /
schedule:
interval: weekly
groups:
base-images:
patterns: ["*"]
+83
View File
@@ -0,0 +1,83 @@
name: ci
on:
push:
branches: [main]
pull_request:
# A CVE published after a merge is still our problem, so rescan daily.
schedule:
- cron: "0 4 * * *"
workflow_dispatch:
permissions:
contents: read
# A push supersedes the run of the commit before it, so drop that one instead of
# waiting for a verdict on code nobody has any more.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
go:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
- run: gofmt -l cmd lib | tee /dev/stderr | (! read -r)
- run: go mod tidy -diff
- run: go vet ./...
- run: go test -race ./...
- run: make build
# Read from a plain file so the upstream-sync workflow can bump it without a
# token carrying the workflow scope.
- id: lint-version
run: echo "version=$(cat hack/golangci-lint.version)" >> "$GITHUB_OUTPUT"
- uses: golangci/golangci-lint-action@v9
with:
version: ${{ steps.lint-version.outputs.version }}
security:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# Reachability-aware, so it only fails on vulnerabilities we actually call.
# It installs its own Go, hence no setup-go step; go-version-input must be
# cleared or it collides with go-version-file.
- uses: golang/govulncheck-action@v1.1.0
with:
repo-checkout: false
go-version-file: go.mod
go-version-input: ""
- uses: aquasecurity/trivy-action@v0.36.0
with:
scan-type: fs
scan-ref: .
scanners: vuln,secret
format: table
exit-code: "1"
ignore-unfixed: true
severity: CRITICAL,HIGH
image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: docker/setup-buildx-action@v4
- uses: docker/build-push-action@v7
with:
context: .
push: false
load: true
tags: routingtabletowg:ci
cache-from: type=gha
cache-to: type=gha,mode=max
- uses: aquasecurity/trivy-action@v0.36.0
with:
image-ref: routingtabletowg:ci
format: table
exit-code: "1"
ignore-unfixed: true
severity: CRITICAL,HIGH
+68
View File
@@ -0,0 +1,68 @@
name: release
# 1.2.3 is a release, anything with a suffix (1.2.3-rc.1) a prerelease. The tags
# carry no v prefix.
on:
push:
tags: ["[0-9]+.[0-9]+.[0-9]+*"]
permissions:
contents: write
packages: write
id-token: write
jobs:
image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
# Emulation for the RUN steps of the non-native platforms.
- uses: docker/setup-qemu-action@v4
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# The tag is v-less already, so {{version}} is the tag itself. major.minor and
# major are skipped for prereleases by the action itself.
- id: meta
uses: docker/metadata-action@v6
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
sbom: true
provenance: mode=max
release:
runs-on: ubuntu-latest
needs: image
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Create the GitHub release
env:
GH_TOKEN: ${{ github.token }}
run: |
flags=()
case "${GITHUB_REF_NAME}" in
*-*) flags+=(--prerelease) ;;
# Notes for a stable release span back to the previous stable tag in its
# history, not to the last RC or a newer release on another branch.
*)
prev=$(git describe --tags --abbrev=0 --match '[0-9]*' --exclude '*-*' "${GITHUB_REF_NAME}^" 2>/dev/null || true)
[ -n "${prev}" ] && flags+=(--notes-start-tag "${prev}")
;;
esac
gh release create "${GITHUB_REF_NAME}" --verify-tag --generate-notes "${flags[@]}"
+75
View File
@@ -0,0 +1,75 @@
# The golangci-lint version lives in hack/golangci-lint.version, which Dependabot
# cannot parse. This bumps it and opens a PR.
#
# Events caused by GITHUB_TOKEN start no workflows. AUTOMATION_SSH_KEY is an optional
# repository secret holding the private half of a deploy key with write access; the
# branch is pushed over SSH with it, so pushes to an open PR run ci.yaml. Opening the PR
# is still a GITHUB_TOKEN event, so a new PR gets its checks from a dispatched run on
# its branch, as workflow_dispatch is the exception to that rule.
name: upstream-sync
on:
schedule:
- cron: "17 4 * * *"
workflow_dispatch:
permissions:
contents: write
pull-requests: write
actions: write
jobs:
golangci-lint:
runs-on: ubuntu-latest
concurrency:
group: upstream-sync-golangci-lint
cancel-in-progress: true
steps:
- uses: actions/checkout@v7
with:
ssh-key: ${{ secrets.AUTOMATION_SSH_KEY }}
- id: detect
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
current=$(cat hack/golangci-lint.version)
latest=$(gh release view --repo golangci/golangci-lint --json tagName -q .tagName)
echo "current=${current}" >> "$GITHUB_OUTPUT"
echo "latest=${latest}" >> "$GITHUB_OUTPUT"
[ "${current}" = "${latest}" ] && echo "changed=false" >> "$GITHUB_OUTPUT" || echo "changed=true" >> "$GITHUB_OUTPUT"
- if: steps.detect.outputs.changed == 'true'
run: echo "${{ steps.detect.outputs.latest }}" > hack/golangci-lint.version
# A new release usually means new checks, so let the PR say whether we pass.
- if: steps.detect.outputs.changed == 'true'
id: lint
continue-on-error: true
uses: golangci/golangci-lint-action@v9
with:
version: ${{ steps.detect.outputs.latest }}
- if: steps.detect.outputs.changed == 'true'
id: pr
uses: peter-evans/create-pull-request@v8
with:
branch: automation/golangci-lint-version-sync
delete-branch: true
commit-message: "Bump golangci-lint to ${{ steps.detect.outputs.latest }}"
title: "Bump golangci-lint to ${{ steps.detect.outputs.latest }}"
body: |
Automated golangci-lint upstream sync.
- Previous: `${{ steps.detect.outputs.current }}`
- New: `${{ steps.detect.outputs.latest }}`
Result of running the new version against the tree: `${{ steps.lint.outcome }}`.
A failure here is expected when a release adds checks; the findings are in the
job log of the run that opened this PR.
- if: steps.pr.outputs.pull-request-operation == 'created'
env:
GH_TOKEN: ${{ github.token }}
run: gh workflow run ci.yaml --ref automation/golangci-lint-version-sync
+8
View File
@@ -0,0 +1,8 @@
/bin
/dist
.*
!.gitignore
!.githooks
!.github
!.dockerignore
!.golangci.yml
+21
View File
@@ -0,0 +1,21 @@
version: "2"
linters:
enable:
- bodyclose
- contextcheck
- errorlint
- gocritic
- nilerr
- rowserrcheck
- unconvert
- wastedassign
- revive
settings:
revive:
rules:
- name: unused-parameter
exclusions:
presets:
# Close and os.Remove on cleanup paths, with nothing to report the error to.
- std-error-handling
+24 -17
View File
@@ -1,22 +1,29 @@
# ---- Base ----
FROM alpine:3 AS base
# Cross-compiling from the build platform, so a multi-arch build needs no
# emulated toolchain.
FROM --platform=$BUILDPLATFORM golang:1.27-alpine AS build
ARG TARGETOS
ARG TARGETARCH
ARG TARGETVARIANT
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} GOARM=${TARGETVARIANT#v} \
go build -trimpath -ldflags "-s -w" -o app ./cmd/app
# The binary reads protocol and table names from the iproute2 package. Alpine
# also keeps a shell and ip for debugging.
FROM alpine:3.24 AS release
RUN apk add --no-cache iproute2
WORKDIR /app
# ---- Build ----
FROM golang:1.19-alpine AS build
WORKDIR /build
# Copy sources
ADD . .
# Get dependencies
RUN go get ./cmd/app
# Compile
RUN CGO_ENABLED=0 go build -a -o app ./cmd/app
# ---- Release ----
FROM base AS release
# Copy build-target
COPY --from=build /build/app .
CMD ["./app"]
+5
View File
@@ -0,0 +1,5 @@
LDFLAGS = -s -w
.PHONY: build
build:
CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "$(LDFLAGS)" -o bin/routingtabletowg ./cmd/app
+85 -17
View File
@@ -10,8 +10,10 @@ Translate Routing-Table Entries to Wireguard AllowedIPs with Filters
- [1.1. Usage](#11-usage)
- [1.2. Install](#12-install)
- [2. Behaviour](#2-behaviour)
- [2.1. Adding Route](#21-adding-route)
- [2.2. Deleting Route](#22-deleting-route)
- [2.1. Synced routes](#21-synced-routes)
- [2.2. Reconcile](#22-reconcile)
- [2.3. Ownership of AllowedIPs](#23-ownership-of-allowedips)
- [2.4. Errors and limits](#24-errors-and-limits)
<!-- /TOC -->
<br>
@@ -20,9 +22,9 @@ Translate Routing-Table Entries to Wireguard AllowedIPs with Filters
## 1.1. Usage
The Program will listen for RoutingTable Changes and can translate the changes to a Wireguard-Interface.
The Program watches the routes of a Wireguard-Interface and translates them to the AllowedIPs of its Peers.
It will detect the Peer to add the Route to using the Gateway from the Route-Entry.<br>
It detects the Peer for a Route using the Gateway from the Route-Entry.<br>
In case routes clash or cant be added to Wireguard, Warnings will be logged.
<br>
@@ -36,13 +38,25 @@ In case routes clash or cant be added to Wireguard, Warnings will be logged.
## 1.2. Install
### 1.2.1. Environment
Variable|Description|Type|Default
-|-|-|-
`INTERFACE` | Wireguard-Interface Name, empty for the default | String | wg0
`FILTER_PROTOCOL` | Protocol to react on | Number / iproute2-name | All
`FILTER_TABLE` | Table to react on | Number / iproute2-name | All
`PERIODIC_SYNC` | Reguarly sync the routing-table <br> Useful when the wg-interface is changed/updated without us knowing | Seconds | -1
`PROTECTED_PREFIXES` | Prefixes that are never added, moved or removed, see [2.3](#23-ownership-of-allowedips) | Comma-separated CIDRs | -
Protocol and table names are read from `rt_protos` and `rt_tables`, and the `*.conf` files in `rt_protos.d` and `rt_tables.d`, in `/usr/share/iproute2` and `/etc/iproute2`. Entries in `/etc/iproute2` take precedence.
<br>
### 1.2.1. Docker
### 1.2.2. Docker
Depending on the needs, the Container can be run in `network_mode: host` to be able to accessrouting-tables and interfaces of the host.
The Container has to run in the network namespace of the Wireguard-Interface, which is the host for most setups. That makes it `network_mode: host`, as the routing-tables and interfaces are only visible there.
The only required capability is `NET_ADMIN`, for interacting with interfaces. All others can be dropped.
<details><summary><code>docker-compose.yml</code></summary>
@@ -50,33 +64,87 @@ Depending on the needs, the Container can be run in `network_mode: host` to be a
version: '3'
services:
routingTableWGTranslator:
image: ruakij/RoutingTableWGTranslator
routingtabletowg:
image: ghcr.io/ruakij/routingtabletowg
restart: unless-stopped
network_mode: "host"
network_mode: host
cap_drop:
- ALL
cap_add:
- NET_ADMIN
environment:
- INTERFACE="<wgInterfaceName or empty for wg0>"
- INTERFACE=<wgInterfaceName or empty for wg0>
```
</details>
<br>
### 1.2.2. Without Docker
### 1.2.3. Without Docker
Clone the Repository `git clone <URI>` and build the Program with `go build cmd/app`
Requires Go 1.27.3 or newer and Linux.
Then you can run it with `./app`
Clone the Repository `git clone <URI>` and build the Program with `make build` (or `go build -o bin/routingtabletowg ./cmd/app`)
Then you can run it with `./bin/routingtabletowg`
<br>
### 1.2.4. Development
Enable the pre-commit hook (formatting, `go vet`, tests, `golangci-lint`) with `git config core.hooksPath .githooks`.
The program only builds for Linux; on other systems set `GOOS=linux` for `go vet` and `golangci-lint`.
The integration test needs root, the Wireguard kernel module and the `wg` tool, e.g. in a privileged container: `docker run --privileged --rm -v "$PWD":/src -w /src golang:1.27-alpine sh -c "apk add --no-cache wireguard-tools-wg && go test -tags integration ./cmd/app"`
CI runs on GitHub Actions. Pushing a tag like `1.2.3` publishes `ghcr.io/ruakij/routingtabletowg` for `linux/amd64`, `linux/arm64` and `linux/arm/v7` and creates a GitHub release.
<br>
# 2. Behaviour
## 2.1. Adding Route
## 2.1. Synced routes
![](doc/add-route.svg)
A route is translated when
- it is on the interface (`INTERFACE`) and passes `FILTER_PROTOCOL` and `FILTER_TABLE`. Without `FILTER_TABLE` the routes of all tables are used, not only the main table.
- it has a single gateway. Routes without gateway (e.g. connected routes) and multipath routes are ignored.
- its destination can be an AllowedIP. IPv4-mapped IPv6 prefixes such as `::ffff:10.0.0.0/104` cannot, so routes to them are ignored with a warning.
- the gateway lies in the AllowedIPs of a Peer. If several Peers contain it, the one with the longest matching prefix is chosen, like Wireguard does.
Gateways are looked up in the AllowedIPs the program does not manage (see [2.3](#23-ownership-of-allowedips)), so a gateway has to lie in an AllowedIP set up by hand. Right after the start the prefixes a route points to are left out as well, because they may be taken over. List prefixes that serve as gateways in `PROTECTED_PREFIXES`.<br>
Default routes are translated to `0.0.0.0/0` and `::/0`.
If several routes have the same destination, the one with the lowest metric is used (then the main table, then the lowest table number, then the lowest gateway). A backup route with a higher metric takes over when the primary route is gone.
<br>
## 2.2. Deleting Route
## 2.2. Reconcile
![](doc/del-route.svg)
Route changes only trigger a sync, the state is always read anew. This makes the result independent of lost or reordered route changes.
1. A sync starts on a change of a route of the interface or of a route replacing one of its destinations (changes within 250ms are combined), every `PERIODIC_SYNC` seconds, after (re)subscribing to route changes and after a failed sync.
2. The interface is looked up by name, so it may be recreated (it is then treated like a fresh start, see [2.3](#23-ownership-of-allowedips)). Its routes and the Peers are listed.
3. Every destination gets the Peer of its route, see [2.1](#21-synced-routes).
4. Missing prefixes are added to their Peer. Only the new prefixes are sent and Peers are never created, so nothing else on the Peer changes. A managed prefix that is on another Peer is moved to the Peer of the route.
5. Prefixes without a route that the program manages are removed.
<br>
## 2.3. Ownership of AllowedIPs
The program only removes prefixes it manages. These are the prefixes it added because of a route, or that were on the Peer of the route when it started. All other AllowedIPs are left alone, also those on Peers without a route.
- At the start, a prefix that is on a Peer already and that a route points to is taken over and managed from then on: it stays when it is on the Peer of the route, and it is moved there otherwise. Start means the first sync that could read the routes and the Peers, and the first one after the interface was recreated. This is the only point where a prefix that is there already can be taken over. It picks up what an earlier run left behind.
- Afterwards, a prefix that is there and not managed is manual. A route to it on the same Peer changes nothing and the prefix stays manual. A route to it via another Peer is a conflict: a warning is logged once and the prefix stays where it is.
- Prefixes in `PROTECTED_PREFIXES` are not touched at all, also not at the start. They are matched exactly, a covering or covered prefix does not protect. Prefixes that serve as gateways should be listed there: if a routing daemon has learned such a prefix as a route on the interface when the program starts, the prefix would be moved to the Peer of that route and routes behind it would lose their Peer.
- The managed prefixes are kept in memory. Prefixes derived from routes by an earlier run, whose routes were removed while the program was not running, are not recognised and stay until removed by hand.
<br>
## 2.4. Errors and limits
- Failing calls to Wireguard or netlink (also a missing interface) are logged and retried after 1s, doubling up to 30s. The program only exits on invalid configuration at start, or on `SIGINT` / `SIGTERM`.
- When the subscription to route changes breaks (e.g. a burst overflows its buffer), it is renewed and everything is synced.
- Wireguard can only replace the whole list of AllowedIPs of a Peer. Removing prefixes reads the list right before writing it, but changes to the same Peer by other tools within these milliseconds can be lost. Adding prefixes has no such window.
- The library used to configure Wireguard (wgctrl) cannot write an IPv4-mapped IPv6 AllowedIP such as `::ffff:10.0.0.0/104`: the kernel rejects it, and replacing the list would lose it and all entries after it. Removals on a Peer having one are skipped: the prefixes stay, with a warning, until the entry is gone.
-9
View File
@@ -1,9 +0,0 @@
TAG="ruakij/routingtabletowg"
PLATFORM="linux/amd64,linux/arm64/v8,linux/arm/v7"
EXTRA_ARGS="$@"
docker buildx build \
--platform $PLATFORM \
--tag $TAG \
$EXTRA_ARGS
.
-7
View File
@@ -1,7 +0,0 @@
TAG="ruakij/routingtabletowg"
EXTRA_ARGS="$@"
docker build \
--tag $TAG \
$EXTRA_ARGS \
.
+103
View File
@@ -0,0 +1,103 @@
package main
import (
"net"
"net/netip"
"slices"
"git.ruekov.eu/ruakij/routingtabletowg/lib/reconcile"
"github.com/vishvananda/netlink"
"golang.org/x/sys/unix"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
// routeDst returns the destination of a route, the default route has none.
func routeDst(route netlink.Route) (netip.Prefix, bool) {
if route.Dst != nil {
return reconcile.PrefixFromIPNet(*route.Dst)
}
// The family comes from the route, as a default route may have no gateway to tell it
switch route.Family {
case unix.AF_INET:
return netip.PrefixFrom(netip.IPv4Unspecified(), 0), true
case unix.AF_INET6:
return netip.PrefixFrom(netip.IPv6Unspecified(), 0), true
}
return netip.Prefix{}, false
}
// toRoutes converts the routes that can be translated, which are the ones with a
// gateway and a destination that can be written to WireGuard. The destinations of
// the latter are returned as skipped.
func toRoutes(list []netlink.Route) (routes []reconcile.Route, skipped []netip.Prefix) {
routes = make([]reconcile.Route, 0, len(list))
for _, route := range list {
gw, ok := reconcile.AddrFromIP(route.Gw)
if !ok {
continue
}
dst, ok := routeDst(route)
if !ok {
continue
}
if !writable(reconcile.ToIPNet(dst)) {
skipped = append(skipped, dst)
continue
}
routes = append(routes, reconcile.Route{
Dst: dst,
Gw: gw,
Metric: route.Priority,
Table: route.Table,
})
}
return routes, skipped
}
func toPeers(device *wgtypes.Device) []reconcile.Peer {
peers := make([]reconcile.Peer, 0, len(device.Peers))
for _, peer := range device.Peers {
allowed := make([]netip.Prefix, 0, len(peer.AllowedIPs))
for _, ipNet := range peer.AllowedIPs {
if prefix, ok := reconcile.PrefixFromIPNet(ipNet); ok {
allowed = append(allowed, prefix)
}
}
peers = append(peers, reconcile.Peer{Key: peer.PublicKey, AllowedIPs: allowed})
}
return peers
}
func toIPNets(prefixes []netip.Prefix) []net.IPNet {
ipNets := make([]net.IPNet, len(prefixes))
for i, prefix := range prefixes {
ipNets[i] = reconcile.ToIPNet(prefix)
}
return ipNets
}
// withoutPrefixes returns the list without the entries equal to one of the prefixes.
// The others are returned as they are, so nothing gets rewritten into another form.
func withoutPrefixes(list []net.IPNet, drop []netip.Prefix) []net.IPNet {
return slices.DeleteFunc(slices.Clone(list), func(n net.IPNet) bool {
prefix, ok := reconcile.PrefixFromIPNet(n)
return ok && slices.Contains(drop, prefix)
})
}
// writable tells if wgctrl sends the entry to the kernel as it is. wgctrl takes
// every IP with a 4 byte form for IPv4, so an IPv4-mapped IPv6 entry such as
// ::ffff:10.0.0.0/104 goes out as IPv4 with a mask too long for it and is rejected.
func writable(n net.IPNet) bool {
_, bits := n.Mask.Size()
return bits != 128 || n.IP.To4() == nil
}
func prefixString(n net.IPNet) string {
if prefix, ok := reconcile.PrefixFromIPNet(n); ok {
return prefix.String()
}
return n.String()
}
+155
View File
@@ -0,0 +1,155 @@
package main
import (
"net"
"net/netip"
"slices"
"testing"
"git.ruekov.eu/ruakij/routingtabletowg/lib/reconcile"
"github.com/vishvananda/netlink"
"golang.org/x/sys/unix"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
func TestToRoutes(t *testing.T) {
_, v4Net, _ := net.ParseCIDR("203.0.113.0/24")
_, v6Net, _ := net.ParseCIDR("2001:db8::/32")
list := []netlink.Route{
{Dst: v4Net, Gw: net.ParseIP("10.0.0.1"), Priority: 5, Table: 254, Family: unix.AF_INET},
{Dst: v6Net, Gw: net.ParseIP("fd00::1"), Priority: 1024, Family: unix.AF_INET6},
// default routes have no Dst, the family comes from the route and not from the gateway
{Gw: net.ParseIP("10.0.0.1"), Family: unix.AF_INET},
{Gw: net.ParseIP("fd00::1"), Family: unix.AF_INET6},
// gateway as 16 bytes
{Gw: net.ParseIP("10.0.0.2").To16(), Family: unix.AF_INET},
// no gateway
{Dst: v4Net, Family: unix.AF_INET},
{Family: unix.AF_INET},
// no destination to tell
{Gw: net.ParseIP("10.0.0.1")},
}
got, skipped := toRoutes(list)
if len(skipped) != 0 {
t.Errorf("skipped %v", skipped)
}
want := []reconcile.Route{
{Dst: netip.MustParsePrefix("203.0.113.0/24"), Gw: netip.MustParseAddr("10.0.0.1"), Metric: 5, Table: 254},
{Dst: netip.MustParsePrefix("2001:db8::/32"), Gw: netip.MustParseAddr("fd00::1"), Metric: 1024},
{Dst: netip.MustParsePrefix("0.0.0.0/0"), Gw: netip.MustParseAddr("10.0.0.1")},
{Dst: netip.MustParsePrefix("::/0"), Gw: netip.MustParseAddr("fd00::1")},
{Dst: netip.MustParsePrefix("0.0.0.0/0"), Gw: netip.MustParseAddr("10.0.0.2")},
}
if !slices.Equal(got, want) {
t.Errorf("got %v\nwant %v", got, want)
}
}
// wgctrl would send an IPv4-mapped IPv6 destination as IPv4 with a mask too long for it,
// which the kernel rejects for the whole call.
func TestToRoutesSkipsUnwritableDestinations(t *testing.T) {
_, mapped, _ := net.ParseCIDR("::ffff:10.0.0.0/104")
_, mappedRange, _ := net.ParseCIDR("::ffff:0:0/96")
_, short, _ := net.ParseCIDR("::ffff:0:0/80") // nothing of the mapped form is left in it
_, plain, _ := net.ParseCIDR("2001:db8::/32")
list := []netlink.Route{
{Dst: mapped, Gw: net.ParseIP("fd00::1"), Family: unix.AF_INET6},
{Dst: mappedRange, Gw: net.ParseIP("fd00::1"), Family: unix.AF_INET6},
{Dst: short, Gw: net.ParseIP("fd00::1"), Family: unix.AF_INET6},
{Dst: plain, Gw: net.ParseIP("fd00::1"), Family: unix.AF_INET6},
// without gateway it is not translated anyway, so there is nothing to report
{Dst: mapped, Family: unix.AF_INET6},
}
routes, skipped := toRoutes(list)
if len(routes) != 2 || routes[0].Dst != netip.MustParsePrefix("::/80") || routes[1].Dst != netip.MustParsePrefix("2001:db8::/32") {
t.Errorf("routes = %v", routes)
}
wantSkipped := []netip.Prefix{netip.MustParsePrefix("::ffff:10.0.0.0/104"), netip.MustParsePrefix("::ffff:0.0.0.0/96")}
if !slices.Equal(skipped, wantSkipped) {
t.Errorf("skipped = %v, want %v", skipped, wantSkipped)
}
}
// wgctrl reports IPv4 prefixes with 4 byte IPs and masks, so they must equal the synthesized default route.
func TestToPeersMatchesDefaultRoute(t *testing.T) {
device := &wgtypes.Device{Peers: []wgtypes.Peer{{
PublicKey: wgtypes.Key{1},
AllowedIPs: []net.IPNet{
{IP: net.IP{0, 0, 0, 0}, Mask: net.CIDRMask(0, 32)},
{IP: net.IPv6zero, Mask: net.CIDRMask(0, 128)},
},
}}}
peers := toPeers(device)
want := []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0")}
if len(peers) != 1 || !slices.Equal(peers[0].AllowedIPs, want) {
t.Errorf("got %v, want %v", peers, want)
}
routes, _ := toRoutes([]netlink.Route{{Gw: net.ParseIP("10.0.0.1"), Family: unix.AF_INET}})
if len(routes) != 1 || !slices.Contains(peers[0].AllowedIPs, routes[0].Dst) {
t.Errorf("default route %v not found in %v", routes, peers[0].AllowedIPs)
}
}
// A prefix that is IPv6 for WireGuard must not turn into an IPv4 one on the way back.
func TestRemovalKeepsOtherEntriesAsTheyAre(t *testing.T) {
mapped := net.IPNet{IP: net.ParseIP("::ffff:0:0"), Mask: net.CIDRMask(96, 128)}
v4Default := net.IPNet{IP: net.IP{0, 0, 0, 0}, Mask: net.CIDRMask(0, 32)}
other := net.IPNet{IP: net.IP{10, 1, 2, 0}, Mask: net.CIDRMask(24, 32)}
list := []net.IPNet{mapped, v4Default, other}
device := &wgtypes.Device{Peers: []wgtypes.Peer{{PublicKey: wgtypes.Key{1}, AllowedIPs: list}}}
wantPrefixes := []netip.Prefix{netip.MustParsePrefix("::ffff:0.0.0.0/96"), netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("10.1.2.0/24")}
if got := toPeers(device)[0].AllowedIPs; !slices.Equal(got, wantPrefixes) {
t.Fatalf("got %v, want %v", got, wantPrefixes)
}
got := withoutPrefixes(list, []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("203.0.113.0/24")})
want := []net.IPNet{mapped, other}
if len(got) != len(want) {
t.Fatalf("got %v, want %v", got, want)
}
for i := range want {
if !got[i].IP.Equal(want[i].IP) || len(got[i].IP) != len(want[i].IP) || !slices.Equal(got[i].Mask, want[i].Mask) {
t.Errorf("entry %d: got %v, want %v", i, got[i], want[i])
}
}
if len(list) != 3 {
t.Errorf("input was modified: %v", list)
}
}
func TestRouteFilter(t *testing.T) {
tests := []struct {
name string
options FilterOptions
wantTable int
wantProto bool
protoValue netlink.RouteProtocol
}{
{name: "all", options: FilterOptions{Table: -1, Protocol: -1}},
{name: "table", options: FilterOptions{Table: 100, Protocol: -1}, wantTable: 100},
{name: "protocol", options: FilterOptions{Table: -1, Protocol: 12}, wantProto: true, protoValue: 12},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
filter, mask := tt.options.routeFilter(7)
if mask&netlink.RT_FILTER_OIF == 0 || mask&netlink.RT_FILTER_TABLE == 0 || filter.LinkIndex != 7 {
t.Errorf("link and table must always be filtered: %v %b", filter, mask)
}
if filter.Table != tt.wantTable {
t.Errorf("table = %d, want %d", filter.Table, tt.wantTable)
}
if (mask&netlink.RT_FILTER_PROTOCOL != 0) != tt.wantProto || filter.Protocol != tt.protoValue {
t.Errorf("protocol filter = %v/%d, want %v/%d", mask&netlink.RT_FILTER_PROTOCOL != 0, filter.Protocol, tt.wantProto, tt.protoValue)
}
})
}
}
+15 -5
View File
@@ -2,17 +2,27 @@ package main
import (
"github.com/vishvananda/netlink"
"golang.org/x/sys/unix"
)
// FilterOptions select the routes to translate, -1 selects all.
type FilterOptions struct {
Table int
Protocol int
}
func CheckFilter(options FilterOptions, route netlink.Route) bool {
if (options.Table != -1 && options.Table != route.Table) ||
(options.Protocol != -1 && options.Protocol != route.Protocol) {
return false
// routeFilter returns the filter and mask to list the matching routes of a link.
func (o FilterOptions) routeFilter(ifindex int) (*netlink.Route, uint64) {
// RT_TABLE_UNSPEC selects all tables, otherwise only the main table is listed
filter := &netlink.Route{LinkIndex: ifindex, Table: unix.RT_TABLE_UNSPEC}
mask := netlink.RT_FILTER_OIF | netlink.RT_FILTER_TABLE
if o.Table > 0 {
filter.Table = o.Table
}
return true
if o.Protocol != -1 {
filter.Protocol = netlink.RouteProtocol(o.Protocol)
mask |= netlink.RT_FILTER_PROTOCOL
}
return filter, mask
}
+443
View File
@@ -0,0 +1,443 @@
//go:build linux && integration
// Needs root, the wireguard kernel module, the wg tool (wireguard-tools-wg on Alpine) and ip
// (BusyBox on Alpine is enough), run it in a privileged container:
//
// go test -tags integration ./cmd/app
package main
import (
"net"
"os/exec"
"slices"
"strings"
"sync/atomic"
"testing"
"time"
"git.ruekov.eu/ruakij/routingtabletowg/lib/reconcile"
"github.com/vishvananda/netlink"
"golang.org/x/sys/unix"
"golang.zx2c4.com/wireguard/wgctrl"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
const (
wgName = "wgtest0"
otherName = "dmytest0"
)
type env struct {
t *testing.T
client *wgctrl.Client
priv wgtypes.Key
keyA wgtypes.Key
keyB wgtypes.Key
}
func mustKey(t *testing.T) wgtypes.Key {
t.Helper()
key, err := wgtypes.GeneratePrivateKey()
if err != nil {
t.Fatal(err)
}
return key
}
func cidr(s string) net.IPNet {
_, n, err := net.ParseCIDR(s)
if err != nil {
panic(err)
}
return *n
}
func cidrs(list ...string) []net.IPNet {
var out []net.IPNet
for _, s := range list {
out = append(out, cidr(s))
}
return out
}
func check(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatal(err)
}
}
// createWg creates the interface with two peers. A has 10.200.0.0/24 and fd00:200::/64 for its
// gateways, B has 10.201.0.0/24 and 10.99.0.0/24. The extra prefixes are set before the syncer starts.
func (e *env) createWg(extraA ...string) netlink.Link {
t := e.t
t.Helper()
check(t, netlink.LinkAdd(&netlink.Wireguard{LinkAttrs: netlink.LinkAttrs{Name: wgName}}))
link, err := netlink.LinkByName(wgName)
check(t, err)
check(t, e.client.ConfigureDevice(wgName, wgtypes.Config{
PrivateKey: &e.priv,
ReplacePeers: true,
Peers: []wgtypes.PeerConfig{
{PublicKey: e.keyA, AllowedIPs: cidrs(append([]string{"10.200.0.0/24", "fd00:200::/64"}, extraA...)...)},
{PublicKey: e.keyB, AllowedIPs: cidrs("10.201.0.0/24", "10.99.0.0/24")},
},
}))
for _, addr := range []string{"10.200.0.254/24", "10.201.0.254/24", "fd00:200::254/64"} {
a, err := netlink.ParseAddr(addr)
check(t, err)
a.Flags = unix.IFA_F_NODAD
check(t, netlink.AddrAdd(link, a))
}
check(t, netlink.LinkSetUp(link))
return link
}
func (e *env) route(link netlink.Link, dst, gw string, metric int) *netlink.Route {
r := &netlink.Route{LinkIndex: link.Attrs().Index, Gw: net.ParseIP(gw), Priority: metric}
if dst != "" {
n := cidr(dst)
r.Dst = &n
}
return r
}
// allowed returns the AllowedIPs of the peers as sorted strings.
func (e *env) allowed() map[wgtypes.Key][]string {
device, err := e.client.Device(wgName)
if err != nil {
return nil
}
out := make(map[wgtypes.Key][]string)
for _, peer := range toPeers(device) {
var list []string
for _, p := range peer.AllowedIPs {
list = append(list, p.String())
}
slices.Sort(list)
out[peer.Key] = list
}
return out
}
func sortedCopy(list []string) []string {
out := slices.Clone(list)
slices.Sort(out)
return out
}
// expect waits until the peers have exactly these AllowedIPs.
func (e *env) expect(step string, a, b []string) {
e.t.Helper()
want := map[wgtypes.Key][]string{e.keyA: sortedCopy(a), e.keyB: sortedCopy(b)}
var got map[wgtypes.Key][]string
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
got = e.allowed()
if slices.Equal(got[e.keyA], want[e.keyA]) && slices.Equal(got[e.keyB], want[e.keyB]) {
e.t.Logf("%s: A=%v B=%v", step, got[e.keyA], got[e.keyB])
return
}
time.Sleep(50 * time.Millisecond)
}
e.t.Fatalf("%s:\n got A=%v B=%v\nwant A=%v B=%v", step, got[e.keyA], got[e.keyB], want[e.keyA], want[e.keyB])
}
// settle gives the syncer time to react to something that must not change anything.
func settle() { time.Sleep(4 * debounce) }
func TestSyncIntegration(t *testing.T) {
client, err := wgctrl.New()
check(t, err)
t.Cleanup(func() { client.Close() })
e := &env{t: t, client: client, priv: mustKey(t), keyA: mustKey(t).PublicKey(), keyB: mustKey(t).PublicKey()}
// Leftovers of a previous run
for _, name := range []string{wgName, otherName} {
if link, err := netlink.LinkByName(name); err == nil {
check(t, netlink.LinkDel(link))
}
}
t.Cleanup(func() {
for _, name := range []string{wgName, otherName} {
if link, err := netlink.LinkByName(name); err == nil {
if err := netlink.LinkDel(link); err != nil {
t.Log(err)
}
}
}
})
// A has a manual prefix, and one a route backs from before the start
link := e.createWg("192.0.2.0/24", "198.51.100.0/24")
check(t, netlink.RouteAdd(e.route(link, "198.51.100.0/24", "10.200.0.1", 0)))
// An interface of another kind with a route whose gateway is in a prefix of B
check(t, netlink.LinkAdd(&netlink.Dummy{LinkAttrs: netlink.LinkAttrs{Name: otherName}}))
other, err := netlink.LinkByName(otherName)
check(t, err)
otherAddr, err := netlink.ParseAddr("10.99.0.1/24")
check(t, err)
check(t, netlink.AddrAdd(other, otherAddr))
check(t, netlink.LinkSetUp(other))
// The subscription can be cut to see that it is restored
var (
subscriptions atomic.Int32
cut = make(chan struct{}, 1)
)
s := newSyncer(wgName, FilterOptions{Table: -1, Protocol: -1}, make(reconcile.Set), client)
s.subscribe = func() (<-chan netlink.RouteUpdate, chan struct{}, error) {
updates, done, err := subscribeRoutes()
if err != nil {
return nil, nil, err
}
subscriptions.Add(1)
out := make(chan netlink.RouteUpdate)
go func() {
defer close(out)
for {
select {
case u := <-updates:
out <- u
case <-cut:
return
case <-done:
return
}
}
}()
return out, done, nil
}
// t.Context() ends before the cleanup functions are called
finished := make(chan struct{})
go func() {
defer close(finished)
s.run(t.Context(), 0)
}()
t.Cleanup(func() {
select {
case <-finished:
case <-time.After(5 * time.Second):
t.Error("run did not stop")
}
})
manual := "192.0.2.0/24"
base := []string{"10.200.0.0/24", "fd00:200::/64", manual}
baseB := []string{"10.201.0.0/24", "10.99.0.0/24"}
t.Run("start leaves everything as it is", func(t *testing.T) {
e.t = t
settle()
e.expect("start", append([]string{"198.51.100.0/24"}, base...), baseB)
})
t.Run("add route", func(t *testing.T) {
e.t = t
check(t, netlink.RouteAdd(e.route(link, "203.0.113.0/24", "10.200.0.1", 0)))
e.expect("A", append([]string{"198.51.100.0/24", "203.0.113.0/24"}, base...), baseB)
check(t, netlink.RouteAdd(e.route(link, "198.18.0.0/24", "10.201.0.1", 0)))
e.expect("B", append([]string{"198.51.100.0/24", "203.0.113.0/24"}, base...), append([]string{"198.18.0.0/24"}, baseB...))
})
t.Run("replace route changes the peer", func(t *testing.T) {
e.t = t
check(t, netlink.RouteReplace(e.route(link, "203.0.113.0/24", "10.201.0.1", 0)))
e.expect("moved", append([]string{"198.51.100.0/24"}, base...), append([]string{"198.18.0.0/24", "203.0.113.0/24"}, baseB...))
})
t.Run("delete route of a previous run and of this one", func(t *testing.T) {
e.t = t
check(t, netlink.RouteDel(e.route(link, "198.51.100.0/24", "10.200.0.1", 0)))
e.expect("adopted prefix removed, manual stays", base, append([]string{"198.18.0.0/24", "203.0.113.0/24"}, baseB...))
check(t, netlink.RouteDel(e.route(link, "198.18.0.0/24", "10.201.0.1", 0)))
check(t, netlink.RouteDel(e.route(link, "203.0.113.0/24", "10.201.0.1", 0)))
e.expect("all removed", base, baseB)
})
t.Run("primary and backup route", func(t *testing.T) {
e.t = t
primary := e.route(link, "100.64.0.0/10", "10.200.0.1", 10)
backup := e.route(link, "100.64.0.0/10", "10.201.0.1", 20)
check(t, netlink.RouteAdd(backup))
check(t, netlink.RouteAdd(primary))
e.expect("primary wins", append([]string{"100.64.0.0/10"}, base...), baseB)
check(t, netlink.RouteDel(backup))
settle()
e.expect("backup deleted, primary keeps it", append([]string{"100.64.0.0/10"}, base...), baseB)
check(t, netlink.RouteAdd(backup))
check(t, netlink.RouteDel(primary))
e.expect("primary deleted, backup takes over", base, append([]string{"100.64.0.0/10"}, baseB...))
check(t, netlink.RouteDel(backup))
e.expect("both deleted", base, baseB)
})
t.Run("default routes", func(t *testing.T) {
e.t = t
// The container has default routes of its own
v4 := e.route(link, "", "10.200.0.1", 100)
v6 := e.route(link, "", "fd00:200::1", 100)
check(t, netlink.RouteAdd(v4))
check(t, netlink.RouteAdd(v6))
e.expect("added", append([]string{"0.0.0.0/0", "::/0"}, base...), baseB)
check(t, netlink.RouteDel(v4))
check(t, netlink.RouteDel(v6))
e.expect("deleted", base, baseB)
})
t.Run("routes of other interfaces are ignored", func(t *testing.T) {
e.t = t
sub := e.route(other, "172.16.0.0/16", "10.99.0.5", 0)
def := e.route(other, "", "10.99.0.5", 200)
check(t, netlink.RouteAdd(sub))
check(t, netlink.RouteAdd(def))
settle()
e.expect("added", base, baseB)
// An existing route of ours stays when another interface drops a route
ours := e.route(link, "203.0.113.0/24", "10.200.0.1", 0)
check(t, netlink.RouteAdd(ours))
e.expect("ours added", append([]string{"203.0.113.0/24"}, base...), baseB)
check(t, netlink.RouteDel(def))
check(t, netlink.RouteDel(sub))
settle()
e.expect("other deleted", append([]string{"203.0.113.0/24"}, base...), baseB)
// Replacing ours from another interface is no delete, but the prefix has to go
check(t, netlink.RouteReplace(e.route(other, "203.0.113.0/24", "10.99.0.5", 0)))
e.expect("replaced from other interface", base, baseB)
check(t, netlink.RouteDel(e.route(other, "203.0.113.0/24", "10.99.0.5", 0)))
})
t.Run("lost subscription is restored", func(t *testing.T) {
e.t = t
before := subscriptions.Load()
cut <- struct{}{}
// Added while there is no subscription
check(t, netlink.RouteAdd(e.route(link, "203.0.113.0/24", "10.200.0.1", 0)))
e.expect("added in the gap", append([]string{"203.0.113.0/24"}, base...), baseB)
if subscriptions.Load() <= before {
t.Error("did not subscribe again")
}
check(t, netlink.RouteDel(e.route(link, "203.0.113.0/24", "10.200.0.1", 0)))
e.expect("deleted again", base, baseB)
})
t.Run("manual gateway prefix is not taken over by a learned route", func(t *testing.T) {
e.t = t
const gateway = "10.250.0.2/32"
// Set by hand after the start, so it is never adopted or moved
check(t, e.client.ConfigureDevice(wgName, wgtypes.Config{Peers: []wgtypes.PeerConfig{
{PublicKey: e.keyA, UpdateOnly: true, AllowedIPs: cidrs(gateway)},
}}))
withGateway := append([]string{gateway}, base...)
via := e.route(link, "203.0.113.0/24", "10.250.0.2", 0)
via.Flags = int(netlink.FLAG_ONLINK)
check(t, netlink.RouteAdd(via))
e.expect("route via the manual prefix", append([]string{"203.0.113.0/24"}, withGateway...), baseB)
// A routing daemon learns the gateway itself on another peer
learned := e.route(link, gateway, "10.201.0.1", 0)
check(t, netlink.RouteAdd(learned))
settle()
e.expect("learned route to the gateway", append([]string{"203.0.113.0/24"}, withGateway...), baseB)
check(t, netlink.RouteDel(learned))
settle()
e.expect("learned route gone", append([]string{"203.0.113.0/24"}, withGateway...), baseB)
check(t, netlink.RouteDel(via))
e.expect("route gone, manual stays", withGateway, baseB)
check(t, e.client.ConfigureDevice(wgName, wgtypes.Config{Peers: []wgtypes.PeerConfig{
{PublicKey: e.keyA, UpdateOnly: true, ReplaceAllowedIPs: true, AllowedIPs: cidrs(base...)},
}}))
e.expect("restored", base, baseB)
})
t.Run("peer with an entry wgctrl cannot write is left alone", func(t *testing.T) {
e.t = t
// wgctrl sends IPv4-mapped IPv6 as IPv4 with a mask too long for it, and
// the kernel then drops this entry and all after it when replacing the list
const mapped = "::ffff:10.0.0.0/104"
wgSet := func(list ...string) {
t.Helper()
out, err := exec.Command("wg", "set", wgName, "peer", e.keyA.String(), "allowed-ips", strings.Join(list, ",")).CombinedOutput()
if err != nil {
t.Fatalf("wg set: %v: %s", err, out)
}
}
ours := "203.0.113.0/24"
route := e.route(link, ours, "10.200.0.1", 0)
check(t, netlink.RouteAdd(route))
e.expect("added", append([]string{ours}, base...), baseB)
wgSet(append([]string{ours, mapped}, base...)...)
stuck := append([]string{ours, mapped}, base...)
e.expect("entry set", stuck, baseB)
check(t, netlink.RouteDel(route))
settle()
e.expect("nothing lost", stuck, baseB)
// Once the entry is gone the removal goes through, also when nothing else triggers it
wgSet(append([]string{ours}, base...)...)
other := e.route(link, "198.18.0.0/24", "10.200.0.1", 0)
check(t, netlink.RouteAdd(other))
e.expect("removed once possible", append([]string{"198.18.0.0/24"}, base...), baseB)
check(t, netlink.RouteDel(other))
e.expect("restored", base, baseB)
})
t.Run("route to an IPv4-mapped prefix is skipped", func(t *testing.T) {
e.t = t
// The netlink library cannot add this one, ip can
const mapped = "::ffff:10.0.0.0/104"
if out, err := exec.Command("ip", "-6", "route", "add", mapped, "via", "fd00:200::1", "dev", wgName).CombinedOutput(); err != nil {
t.Fatalf("ip route add: %v: %s", err, out)
}
t.Cleanup(func() { _ = exec.Command("ip", "-6", "route", "del", mapped, "dev", wgName).Run() })
// wgctrl would reject the mapped prefix and with it the ones sorted after it
after := e.route(link, "2001:db8::/32", "fd00:200::1", 0)
check(t, netlink.RouteAdd(after))
e.expect("others are added", append([]string{"2001:db8::/32"}, base...), baseB)
check(t, netlink.RouteDel(after))
e.expect("others are removed", base, baseB)
})
t.Run("recreated interface", func(t *testing.T) {
e.t = t
check(t, netlink.RouteAdd(e.route(link, "203.0.113.0/24", "10.200.0.1", 0)))
e.expect("added", append([]string{"203.0.113.0/24"}, base...), baseB)
// The new interface has the prefix in the config of A. It is not one of ours anymore.
check(t, netlink.LinkDel(link))
link = e.createWg(manual, "203.0.113.0/24")
check(t, netlink.RouteAdd(e.route(link, "198.18.0.0/24", "10.201.0.1", 0)))
e.expect("new interface follows its routes", append([]string{"203.0.113.0/24"}, base...), append([]string{"198.18.0.0/24"}, baseB...))
})
}
+1
View File
@@ -10,6 +10,7 @@ type Log struct {
Warn log.Logger
Error log.Logger
}
var logger Log = Log{
Info: *log.New(os.Stdout, "[INFO]\t", log.Ltime|log.Lshortfile),
Warn: *log.New(os.Stderr, "[WARN]\t", log.Ltime|log.Lshortfile),
+161
View File
@@ -0,0 +1,161 @@
package main
import (
"context"
"time"
"github.com/vishvananda/netlink"
)
const (
// debounce coalesces bursts of route updates into one reconcile
debounce = 250 * time.Millisecond
minBackoff = time.Second
maxBackoff = 30 * time.Second
// Receive buffer of the subscription, so bursts of route updates (e.g. a
// full table being loaded) do not overflow it while a reconcile is running.
// An overflow closes the subscription.
subscribeBufferSize = 4 << 20
updatesQueueSize = 4096
)
// backoff doubles the delay on every failure up to maxBackoff.
type backoff time.Duration
func (b *backoff) next() time.Duration {
d := min(max(time.Duration(*b)*2, minBackoff), maxBackoff)
*b = backoff(d)
return d
}
func (b *backoff) reset() { *b = 0 }
// subscribeRoutes subscribes to route updates. Closing done ends the subscription.
func subscribeRoutes() (<-chan netlink.RouteUpdate, chan struct{}, error) {
updates := make(chan netlink.RouteUpdate, updatesQueueSize)
done := make(chan struct{})
err := netlink.RouteSubscribeWithOptions(updates, done, netlink.RouteSubscribeOptions{
ErrorCallback: func(err error) {
select {
case <-done: // closing the socket makes the receive fail
default:
logger.Warn.Printf("Route-subscription: %s", err)
}
},
ReceiveBufferSize: subscribeBufferSize,
ReceiveBufferForceSize: true,
})
if err != nil {
close(done)
return nil, nil, err
}
return updates, done, nil
}
// run reconciles until the context ends. A reconcile happens
// - after route updates of the interface, debounced,
// - every periodic if it is set,
// - after (re)subscribing, as updates may have been missed,
// - after a failure, with backoff.
func (s *syncer) run(ctx context.Context, periodic time.Duration) {
var ticks <-chan time.Time
if periodic > 0 {
ticker := time.NewTicker(periodic)
defer ticker.Stop()
ticks = ticker.C
}
var (
updates <-chan netlink.RouteUpdate // nil without subscription
done chan struct{}
// dirty: the state has to be reconciled
// recheck: updates of other interfaces came in, one of them may be a recreated interface
// armed: the work timer is running
dirty, recheck, armed = true, false, true
reconcileBackoff, subscribeBackoff backoff
)
work := time.NewTimer(0)
resubscribe := time.NewTimer(0)
arm := func() {
if !armed {
work.Reset(debounce)
armed = true
}
}
trigger := func() {
dirty = true
arm()
}
unsubscribe := func() {
if done != nil {
close(done)
}
updates, done = nil, nil
}
defer unsubscribe()
for {
select {
case <-ctx.Done():
return
case <-resubscribe.C:
var err error
updates, done, err = s.subscribe()
if err != nil {
delay := subscribeBackoff.next()
logger.Error.Printf("Couldn't subscribe to route-changes, retrying in %s: %s", delay, err)
resubscribe.Reset(delay)
continue
}
subscribeBackoff.reset()
trigger()
case update, ok := <-updates:
if !ok {
unsubscribe()
delay := subscribeBackoff.next()
logger.Warn.Printf("Route-subscription closed, resubscribing in %s", delay)
resubscribe.Reset(delay)
continue
}
if s.watches(update.Route) {
trigger()
} else {
recheck = true
arm()
}
case <-ticks:
logger.Info.Printf("Periodically syncing all routes")
trigger()
case <-work.C:
armed = false
if recheck && s.linkChanged() {
dirty = true
}
recheck = false
if !dirty {
continue
}
if err := s.reconcile(); err != nil {
delay := reconcileBackoff.next()
logger.Error.Printf("Sync failed, retrying in %s: %s", delay, err)
work.Reset(delay)
armed = true
continue
}
dirty = false
reconcileBackoff.reset()
}
}
}
+25
View File
@@ -0,0 +1,25 @@
package main
import (
"testing"
"time"
)
func TestBackoff(t *testing.T) {
var b backoff
var got []time.Duration
for range 8 {
got = append(got, b.next())
}
want := []time.Duration{1, 2, 4, 8, 16, 30, 30, 30}
for i := range want {
if got[i] != want[i]*time.Second {
t.Fatalf("step %d: got %s, want %s", i, got[i], want[i]*time.Second)
}
}
b.reset()
if d := b.next(); d != minBackoff {
t.Errorf("after reset: got %s, want %s", d, minBackoff)
}
}
+32 -164
View File
@@ -1,41 +1,41 @@
package main
import (
"net"
"context"
"os"
"os/signal"
"strconv"
"syscall"
"time"
envChecks "git.ruekov.eu/ruakij/routingtabletowg/lib/environmentchecks"
ip2Map "git.ruekov.eu/ruakij/routingtabletowg/lib/iproute2mapping"
"git.ruekov.eu/ruakij/routingtabletowg/lib/wgchecks"
"github.com/vishvananda/netlink"
"golang.org/x/sys/unix"
"git.ruekov.eu/ruakij/routingtabletowg/lib/reconcile"
"golang.zx2c4.com/wireguard/wgctrl"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
var envRequired = []string{
"INTERFACE",
}
var envDefaults = map[string]string{
"INTERFACE": "wg0",
//"MANAGE_ALL": "true",
// "MANAGE_ALL": "true",
"FILTER_PROTOCOL": "-1",
"FILTER_TABLE": "-1",
"PERIODIC_SYNC": "-1",
}
func main() {
// Environment-vars
err := envChecks.HandleRequired(envRequired)
if(err != nil){
logger.Error.Fatal(err)
}
envChecks.HandleDefaults(envDefaults)
iface := os.Getenv("INTERFACE")
//MANAGE_ALL = os.Getenv("MANAGE_ALL")
// MANAGE_ALL = os.Getenv("MANAGE_ALL")
// Check if ip2Map has init-errors
for _, err := range ip2Map.Errors {
logger.Warn.Printf("iproute2mapping: %s", err)
}
// Parse filter-env-vars
filterProtocolStr := os.Getenv("FILTER_PROTOCOL")
@@ -50,165 +50,33 @@ func main() {
logger.Error.Fatalf("Couldn't read FILTER_TABLE '%s': %s", filterTableStr, err)
}
periodicSyncStr := os.Getenv("PERIODIC_SYNC")
periodicSync, err := strconv.Atoi(periodicSyncStr)
if err != nil {
logger.Error.Fatalf("Couldn't read PERIODIC_SYNC '%s': %s", periodicSyncStr, err)
}
protectedStr := os.Getenv("PROTECTED_PREFIXES")
protected, err := reconcile.ParsePrefixes(protectedStr)
if err != nil {
logger.Error.Fatalf("Couldn't read PROTECTED_PREFIXES '%s': %s", protectedStr, err)
}
// Create filter
filterOptions := FilterOptions{
Table: filterTable,
Protocol: filterProtocol,
}
// Get Link-Device
link, err := netlink.LinkByName(iface)
if err != nil {
logger.Error.Fatalf("Couldn't get interface '%s': %s", iface, err)
}
// Test getting wg-client
client, err := wgctrl.New()
if err != nil {
logger.Error.Fatalf("Couldn't create wgctl-client: %s", err)
}
// Test getting wg-device
_, err = client.Device(iface)
if err != nil {
logger.Error.Fatalf("Couldn't get wg-interface '%s': %s", iface, err)
}
defer client.Close()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// Subscribe to route-change events
routeSubChan, routeSubDoneChan := make(chan netlink.RouteUpdate), make(chan struct{})
netlink.RouteSubscribe(routeSubChan, routeSubDoneChan)
go handleRouteEvents(routeSubChan, filterOptions, iface)
//# Initial Route-setup
// Get routing-table entries from device
routeList, err := netlink.RouteList(link, netlink.FAMILY_ALL)
if err != nil {
logger.Error.Fatalf("Couldn't get route-entries: %s", err)
}
logger.Info.Printf("Initially setting all current routes")
for _, route := range routeList {
// Ignore routes with empty gateway
if(route.Gw == nil){
continue
}
// Send current routes to handler
routeSubChan <- netlink.RouteUpdate{
Type: unix.RTM_NEWROUTE,
Route: route,
}
}
select {}
}
var routeUpdateTypeMapFromId = map[uint16]string{
unix.RTM_NEWROUTE: "+",
unix.RTM_DELROUTE: "-",
}
// TODO: Add proxy to apply filter in channels rather than.. this mess
func handleRouteEvents(routeSubChan <-chan netlink.RouteUpdate, filterOptions FilterOptions, iface string) {
// Create wg-client
client, err := wgctrl.New()
if err != nil {
logger.Error.Fatalf("Couldn't create wgctl-client: %s", err)
}
for {
// Receive Route-Updates
routeUpdate := <-routeSubChan
route := routeUpdate.Route
// Check filter
if(!CheckFilter(filterOptions, routeUpdate.Route)){
continue
}
// Special case for default-route
if route.Dst == nil{
if route.Gw.To4() != nil { // IPv4
route.Dst = &net.IPNet{
IP: net.IPv4zero,
Mask: net.CIDRMask(0, 32),
}
} else { // IPv6
route.Dst = &net.IPNet{
IP: net.IPv6zero,
Mask: net.CIDRMask(0, 128),
}
}
}
logger.Info.Printf("Route-Update: [%s] %s via %s", routeUpdateTypeMapFromId[routeUpdate.Type], route.Dst, route.Gw)
// Get wgDevice
wgDevice, err := client.Device(iface)
if err != nil {
logger.Error.Fatalf("Couldn't get wg-interface '%s' while running: %s", iface, err)
}
// Empty config for filling in switch
var wgConfig wgtypes.Config
switch routeUpdate.Type{
case unix.RTM_NEWROUTE:
// Check if gateway is set
if route.Gw == nil{
logger.Warn.Printf("Gateway unset, ignoring")
continue
}
// Check if other peer already has exact same dst
if peer, err := wgChecks.PeerByIPNet(wgDevice.Peers, *route.Dst); err == nil {
logger.Warn.Printf("dst-IPNet already set for Peer '%s', ignoring", peer.PublicKey)
continue
}
// Get peer containing gateway-addr
peer, err := wgChecks.PeerByIP(wgDevice.Peers, route.Gw)
if(err != nil){
logger.Warn.Printf("No peer found containing gw-IP '%s', ignoring", route.Gw)
continue
}
// Set peerConfig, this will override set values for that peer
wgConfig.Peers = []wgtypes.PeerConfig{
{
PublicKey: peer.PublicKey,
AllowedIPs: append(peer.AllowedIPs, *route.Dst),
},
}
case unix.RTM_DELROUTE:
// Get peer containing dst-NetIP
peerIndex, ipNetIndex, err := wgChecks.PeerIndexByIPNet(wgDevice.Peers, *route.Dst)
if(err != nil){
logger.Warn.Printf("No peer found having dst-IPNet '%s', ignoring", route.Dst)
continue
}
peer := wgDevice.Peers[peerIndex]
// Delete dstNet from allowedIPs
peer.AllowedIPs[ipNetIndex] = peer.AllowedIPs[len(peer.AllowedIPs)-1]
peer.AllowedIPs = peer.AllowedIPs[:len(peer.AllowedIPs)-1]
// Set peerConfig, this will override set values for that peer
wgConfig.Peers = []wgtypes.PeerConfig{
{
PublicKey: peer.PublicKey,
UpdateOnly: true,
ReplaceAllowedIPs: true,
AllowedIPs: peer.AllowedIPs,
},
}
}
err = client.ConfigureDevice(iface, wgConfig)
if(err != nil){
logger.Error.Fatalf("Error configuring wg-device '%s': %s", iface, err)
}
}
newSyncer(iface, filterOptions, protected, client).run(ctx, time.Duration(periodicSync)*time.Second)
logger.Info.Printf("Shutting down")
}
+300
View File
@@ -0,0 +1,300 @@
package main
import (
"errors"
"fmt"
"maps"
"net"
"net/netip"
"slices"
"git.ruekov.eu/ruakij/routingtabletowg/lib/reconcile"
"github.com/vishvananda/netlink"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
// wireguard is the part of wgctrl.Client the syncer uses, so tests can replace it.
type wireguard interface {
Device(name string) (*wgtypes.Device, error)
ConfigureDevice(name string, cfg wgtypes.Config) error
}
// syncer makes the AllowedIPs of the peers follow the routes of the interface.
//
// Routes only tell when to look. Every reconcile lists the routes and the
// peers anew, so lost or reordered route updates cannot leave a stale state.
// It is not safe for concurrent use, the run loop is the only caller.
type syncer struct {
iface string
filter FilterOptions
protected reconcile.Set
wg wireguard
// subscribe is replaced in tests
subscribe func() (<-chan netlink.RouteUpdate, chan struct{}, error)
// ifindex of the interface at the last reconcile, 0 if it did not exist
ifindex int
// known is the last index the interface had. Another one means it was recreated.
known int
// managed are the prefixes added or adopted because of a route. Others are
// never removed. It is kept in memory only.
managed reconcile.Set
// started is set once a reconcile of the interface got as far as planning, also
// if writing failed. Until then the prefixes present already are taken over if a route backs them.
started bool
// warned are the warnings logged for the current state, so they are not repeated
warned map[string]struct{}
}
func newSyncer(iface string, filter FilterOptions, protected reconcile.Set, client wireguard) *syncer {
return &syncer{
iface: iface,
filter: filter,
protected: protected,
wg: client,
subscribe: subscribeRoutes,
managed: make(reconcile.Set),
}
}
func (s *syncer) reconcile() error {
// The interface can be recreated, so it is resolved again every time
link, err := netlink.LinkByName(s.iface)
if err != nil {
s.ifindex = 0
return fmt.Errorf("couldn't get interface '%s': %w", s.iface, err)
}
s.observe(link.Attrs().Index)
filter, mask := s.filter.routeFilter(s.ifindex)
routeList, err := netlink.RouteListFiltered(netlink.FAMILY_ALL, filter, mask)
if err != nil {
return fmt.Errorf("couldn't get route-entries: %w", err)
}
device, err := s.wg.Device(s.iface)
if err != nil {
return fmt.Errorf("couldn't get wg-interface '%s': %w", s.iface, err)
}
routes, skipped := toRoutes(routeList)
plan := reconcile.Compute(reconcile.Input{
Routes: routes,
Peers: toPeers(device),
Managed: s.managed,
Protected: s.protected,
Startup: !s.started,
})
return s.commit(plan, skippedWarnings(skipped))
}
// commit applies the plan and takes over the resulting state. The notes are
// warnings about what was left out before the plan.
func (s *syncer) commit(plan reconcile.Plan, notes []string) error {
// From here on prefixes that are present are not taken over anymore, also when
// applying fails: the managed set below covers what the plan took over.
s.started = true
result, err := s.apply(plan)
s.warn(slices.Concat(notes, warnings(plan), result.warnings))
if err != nil {
// It is unknown how far the plan got, so keep what may belong to us
maps.Copy(s.managed, plan.Managed)
maps.Copy(s.managed, result.kept)
return err
}
s.managed = plan.Managed
maps.Copy(s.managed, result.kept)
return nil
}
// observe records the index of the interface. Another index than before means the
// interface was recreated, so what was learned about the old one is dropped. The
// prefixes of the new one are not ours, except the ones a route backs, like at the start.
func (s *syncer) observe(index int) {
s.ifindex = index
if s.known != 0 && s.known != index {
logger.Info.Printf("Interface '%s' was recreated, starting over", s.iface)
s.managed = make(reconcile.Set)
s.started = false
s.warned = nil
}
s.known = index
}
// outcome of applying a plan, besides the error
type outcome struct {
// warnings about changes that were not made
warnings []string
// kept are the prefixes that were to be removed and are still there. They stay managed.
kept reconcile.Set
}
// apply writes the changes of the plan to the wg-interface.
func (s *syncer) apply(plan reconcile.Plan) (outcome, error) {
// Adding a prefix to a peer takes it from the one having it. Only the new
// prefixes are sent, so nothing else on the peer is touched.
var add wgtypes.Config
for _, change := range plan.Changes {
if len(change.Add) == 0 {
continue
}
add.Peers = append(add.Peers, wgtypes.PeerConfig{
PublicKey: change.Peer,
UpdateOnly: true, // never recreate a peer removed in the meantime
AllowedIPs: toIPNets(change.Add),
})
}
if len(add.Peers) > 0 {
if err := s.wg.ConfigureDevice(s.iface, add); err != nil {
return outcome{}, fmt.Errorf("error adding to wg-device '%s': %w", s.iface, err)
}
for _, change := range plan.Changes {
for _, prefix := range change.Add {
logger.Info.Printf("[+] %s to peer %s", prefix, change.Peer)
}
}
}
return s.remove(plan.Changes)
}
// remove deletes prefixes from their peers. WireGuard can only replace the
// whole list, so it is read right before to keep the window for changes made
// by others small.
//
// The kernel drops the list first and then inserts the new entries one by one,
// until one is rejected. A list that cannot be written back completely would
// lose the entries from the rejected one on, so such a peer is left alone.
// Every peer is written on its own, so a failing one does not stop the others.
func (s *syncer) remove(changes []reconcile.Change) (outcome, error) {
result := outcome{kept: make(reconcile.Set)}
if !slices.ContainsFunc(changes, func(c reconcile.Change) bool { return len(c.Remove) > 0 }) {
return result, nil
}
device, err := s.wg.Device(s.iface)
if err != nil {
return result, fmt.Errorf("couldn't get wg-interface '%s': %w", s.iface, err)
}
current := make(map[wgtypes.Key][]net.IPNet)
for _, peer := range device.Peers {
current[peer.PublicKey] = peer.AllowedIPs
}
var errs []error
for _, change := range changes {
allowed := current[change.Peer]
left := withoutPrefixes(allowed, change.Remove)
if len(left) == len(allowed) { // gone already, or the peer is
continue
}
if i := slices.IndexFunc(left, func(n net.IPNet) bool { return !writable(n) }); i >= 0 {
result.warnings = append(result.warnings, fmt.Sprintf(
"Peer %s has the AllowedIP %s, which cannot be written back to Wireguard, so %v stay on the peer",
change.Peer, prefixString(left[i]), change.Remove))
for _, prefix := range change.Remove {
result.kept[prefix] = struct{}{}
}
continue
}
err := s.wg.ConfigureDevice(s.iface, wgtypes.Config{Peers: []wgtypes.PeerConfig{{
PublicKey: change.Peer,
UpdateOnly: true,
ReplaceAllowedIPs: true,
AllowedIPs: left,
}}})
if err != nil {
errs = append(errs, fmt.Errorf("error removing from peer %s of wg-device '%s': %w", change.Peer, s.iface, err))
continue
}
for _, prefix := range change.Remove {
logger.Info.Printf("[-] %s from peer %s", prefix, change.Peer)
}
}
return result, errors.Join(errs...)
}
// linkChanged tells if the interface is another one than at the last reconcile.
func (s *syncer) linkChanged() bool {
link, err := netlink.LinkByName(s.iface)
if err != nil {
return s.ifindex != 0
}
return link.Attrs().Index != s.ifindex
}
// watches tells if a route update may change what the last reconcile did.
func (s *syncer) watches(route netlink.Route) bool {
if s.ifindex != 0 && route.LinkIndex == s.ifindex {
return true
}
// A route of another interface can replace one of ours without a delete
dst, ok := routeDst(route)
_, managed := s.managed[dst]
return ok && managed
}
// warnings describes what a plan leaves out, in a stable order.
func warnings(plan reconcile.Plan) []string {
counts := make(map[netip.Addr]int)
for _, route := range plan.Unresolved {
counts[route.Gw]++
}
var messages []string
for gw, count := range counts {
messages = append(messages, fmt.Sprintf("No peer found containing gw-IP '%s', ignoring %d route(s)", gw, count))
}
for _, c := range plan.Conflicts {
messages = append(messages, fmt.Sprintf(
"Route to %s points to peer %s, but the prefix is on peer %s and not managed by this program, leaving it",
c.Dst, c.Wanted, c.Present))
}
slices.Sort(messages)
return messages
}
// skippedWarnings describes the routes that were left out because their destination
// cannot be written to WireGuard, once per destination.
func skippedWarnings(skipped []netip.Prefix) []string {
messages := make([]string, 0, len(skipped))
for _, prefix := range skipped {
messages = append(messages, fmt.Sprintf("Ignoring route to %s, WireGuard cannot take an IPv4-mapped IPv6 prefix as AllowedIP", prefix))
}
slices.Sort(messages)
return slices.Compact(messages)
}
// warn logs the messages that were not part of the last call. A condition that
// lasts is logged when it appears, not at every reconcile.
func (s *syncer) warn(messages []string) {
for _, message := range s.fresh(messages) {
logger.Warn.Print(message)
}
}
// fresh returns the messages that were not part of the last call and remembers the current ones.
func (s *syncer) fresh(messages []string) []string {
var fresh []string
current := make(map[string]struct{}, len(messages))
for _, message := range messages {
current[message] = struct{}{}
if _, seen := s.warned[message]; !seen {
fresh = append(fresh, message)
}
}
s.warned = current
return fresh
}
+279
View File
@@ -0,0 +1,279 @@
package main
import (
"errors"
"net"
"net/netip"
"slices"
"strings"
"testing"
"git.ruekov.eu/ruakij/routingtabletowg/lib/reconcile"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
func TestWarnings(t *testing.T) {
plan := reconcile.Plan{
Unresolved: []reconcile.Route{
{Dst: netip.MustParsePrefix("203.0.113.0/24"), Gw: netip.MustParseAddr("10.9.9.9")},
{Dst: netip.MustParsePrefix("198.51.100.0/24"), Gw: netip.MustParseAddr("10.9.9.9")},
},
Conflicts: []reconcile.Conflict{
{Dst: netip.MustParsePrefix("192.0.2.0/24"), Present: wgtypes.Key{1}, Wanted: wgtypes.Key{2}},
},
}
got := warnings(plan)
if len(got) != 2 || got[0] != "No peer found containing gw-IP '10.9.9.9', ignoring 2 route(s)" {
t.Errorf("got %q", got)
}
if want := warnings(plan); !slices.Equal(got, want) {
t.Errorf("order is not stable: %q vs %q", got, want)
}
}
func TestFreshReportsChangesOnly(t *testing.T) {
s := &syncer{}
steps := []struct {
in, want []string
}{
{[]string{"a", "b"}, []string{"a", "b"}},
{[]string{"a", "b"}, nil},
{[]string{"b", "c"}, []string{"c"}},
{nil, nil},
{[]string{"a"}, []string{"a"}}, // gone and back is a change
}
for i, step := range steps {
if got := s.fresh(step.in); !slices.Equal(got, step.want) {
t.Errorf("step %d: got %q, want %q", i, got, step.want)
}
}
}
func TestObserveStartsOverForAnotherInterface(t *testing.T) {
s := newSyncer("wg0", FilterOptions{}, nil, nil)
s.observe(7)
s.managed[netip.MustParsePrefix("203.0.113.0/24")] = struct{}{}
s.started = true
s.warned = map[string]struct{}{"x": {}}
s.observe(7)
if len(s.managed) != 1 || !s.started || len(s.warned) != 1 {
t.Errorf("same interface must keep its state: %v %v %v", s.managed, s.started, s.warned)
}
// Gone for a moment, back as the same one
s.ifindex = 0
s.observe(7)
if len(s.managed) != 1 || !s.started {
t.Errorf("same interface must keep its state: %v %v", s.managed, s.started)
}
s.observe(9)
if len(s.managed) != 0 || s.started || s.warned != nil || s.ifindex != 9 || s.known != 9 {
t.Errorf("recreated interface must start over: %v %v %v %d %d", s.managed, s.started, s.warned, s.ifindex, s.known)
}
}
var (
keyA = wgtypes.Key{1}
keyB = wgtypes.Key{2}
)
func ipNet(t *testing.T, cidr string) net.IPNet {
t.Helper()
_, n, err := net.ParseCIDR(cidr)
if err != nil {
t.Fatal(err)
}
return *n
}
func ipNets(t *testing.T, list ...string) []net.IPNet {
t.Helper()
var out []net.IPNet
for _, s := range list {
out = append(out, ipNet(t, s))
}
return out
}
// fakeWg records the writes. Writes touching a peer in fail return an error.
type fakeWg struct {
device *wgtypes.Device
configs []wgtypes.Config
fail map[wgtypes.Key]bool
}
func (f *fakeWg) Device(string) (*wgtypes.Device, error) { return f.device, nil }
func (f *fakeWg) ConfigureDevice(_ string, cfg wgtypes.Config) error {
f.configs = append(f.configs, cfg)
for _, peer := range cfg.Peers {
if f.fail[peer.PublicKey] {
return errors.New("rejected")
}
}
return nil
}
func TestWritable(t *testing.T) {
tests := []struct {
cidr string
want bool
}{
{"10.200.0.0/24", true},
{"0.0.0.0/0", true},
{"fd00:200::/64", true},
{"::/0", true},
{"::ffff:0:0/80", true}, // the host bits are cleared, nothing of the mapped form is left
{"::ffff:10.0.0.0/104", false},
{"::ffff:0:0/96", false},
{"::ffff:10.1.2.3/128", false},
}
for _, tt := range tests {
if got := writable(ipNet(t, tt.cidr)); got != tt.want {
t.Errorf("writable(%s) = %v, want %v", tt.cidr, got, tt.want)
}
}
if !writable(net.IPNet{IP: net.IP{10, 0, 0, 0}, Mask: net.CIDRMask(8, 32)}) {
t.Error("4 byte IPv4 must be writable")
}
}
func TestRemoveLeavesPeerWithUnwritableEntryAlone(t *testing.T) {
fake := &fakeWg{device: &wgtypes.Device{Peers: []wgtypes.Peer{
{PublicKey: keyA, AllowedIPs: ipNets(t, "10.200.0.0/24", "::ffff:10.0.0.0/104", "fd00:200::/64", "203.0.113.0/24")},
{PublicKey: keyB, AllowedIPs: ipNets(t, "10.201.0.0/24", "198.51.100.0/24")},
}}}
s := newSyncer("wg0", FilterOptions{}, nil, fake)
result, err := s.remove([]reconcile.Change{
{Peer: keyA, Remove: []netip.Prefix{netip.MustParsePrefix("203.0.113.0/24")}},
{Peer: keyB, Remove: []netip.Prefix{netip.MustParsePrefix("198.51.100.0/24")}},
})
if err != nil {
t.Fatal(err)
}
if len(fake.configs) != 1 || len(fake.configs[0].Peers) != 1 || fake.configs[0].Peers[0].PublicKey != keyB {
t.Fatalf("only peer B may be written: %+v", fake.configs)
}
peer := fake.configs[0].Peers[0]
if !peer.ReplaceAllowedIPs || !peer.UpdateOnly || len(peer.AllowedIPs) != 1 || peer.AllowedIPs[0].String() != "10.201.0.0/24" {
t.Errorf("wrong write for peer B: %+v", peer)
}
if _, kept := result.kept[netip.MustParsePrefix("203.0.113.0/24")]; !kept || len(result.kept) != 1 {
t.Errorf("kept = %v", result.kept)
}
if len(result.warnings) != 1 || !strings.Contains(result.warnings[0], "::ffff:10.0.0.0/104") ||
!strings.Contains(result.warnings[0], "203.0.113.0/24") {
t.Errorf("warnings = %q", result.warnings)
}
}
func TestRemoveWritesEachPeerOnItsOwn(t *testing.T) {
fake := &fakeWg{
device: &wgtypes.Device{Peers: []wgtypes.Peer{
{PublicKey: keyA, AllowedIPs: ipNets(t, "10.200.0.0/24", "203.0.113.0/24")},
{PublicKey: keyB, AllowedIPs: ipNets(t, "10.201.0.0/24", "198.51.100.0/24")},
}},
fail: map[wgtypes.Key]bool{keyA: true},
}
s := newSyncer("wg0", FilterOptions{}, nil, fake)
_, err := s.remove([]reconcile.Change{
{Peer: keyA, Remove: []netip.Prefix{netip.MustParsePrefix("203.0.113.0/24")}},
{Peer: keyB, Remove: []netip.Prefix{netip.MustParsePrefix("198.51.100.0/24")}},
})
if err == nil || !strings.Contains(err.Error(), keyA.String()) {
t.Errorf("error = %v, want one naming peer A", err)
}
if len(fake.configs) != 2 {
t.Fatalf("want a write per peer, got %d", len(fake.configs))
}
for _, cfg := range fake.configs {
if len(cfg.Peers) != 1 {
t.Errorf("a write must touch one peer: %+v", cfg)
}
}
}
func TestCommitStartsEvenIfApplyFails(t *testing.T) {
fake := &fakeWg{fail: map[wgtypes.Key]bool{keyA: true}}
s := newSyncer("wg0", FilterOptions{}, nil, fake)
prefix := netip.MustParsePrefix("203.0.113.0/24")
err := s.commit(reconcile.Plan{
Changes: []reconcile.Change{{Peer: keyA, Add: []netip.Prefix{prefix}}},
Managed: reconcile.Set{prefix: {}},
}, nil)
if err == nil {
t.Fatal("want the error of the write")
}
if !s.started {
t.Error("startup must end with the first plan, or it lasts over the retries")
}
if _, ok := s.managed[prefix]; !ok {
t.Errorf("what the plan took over must stay managed: %v", s.managed)
}
}
func TestCommitKeepsPrefixManagedThatCannotBeRemoved(t *testing.T) {
fake := &fakeWg{device: &wgtypes.Device{Peers: []wgtypes.Peer{
{PublicKey: keyA, AllowedIPs: ipNets(t, "::ffff:10.0.0.0/104", "203.0.113.0/24")},
}}}
s := newSyncer("wg0", FilterOptions{}, nil, fake)
prefix := netip.MustParsePrefix("203.0.113.0/24")
s.managed[prefix] = struct{}{}
err := s.commit(reconcile.Plan{
Changes: []reconcile.Change{{Peer: keyA, Remove: []netip.Prefix{prefix}}},
Managed: make(reconcile.Set),
}, nil)
if err != nil {
t.Fatal(err)
}
if _, ok := s.managed[prefix]; !ok {
t.Errorf("the removal has to be tried again later: %v", s.managed)
}
if len(fake.configs) != 0 {
t.Errorf("nothing may be written: %+v", fake.configs)
}
if len(s.warned) != 1 {
t.Errorf("want one warning, got %v", s.warned)
}
}
func TestSkippedWarnings(t *testing.T) {
mapped := netip.MustParsePrefix("::ffff:10.0.0.0/104")
other := netip.MustParsePrefix("::ffff:0:0/96")
got := skippedWarnings([]netip.Prefix{mapped, other, mapped})
if len(got) != 2 || !strings.Contains(got[0], "::ffff:0.0.0.0/96") || !strings.Contains(got[1], "::ffff:10.0.0.0/104") {
t.Errorf("want one message per prefix, got %q", got)
}
if got := skippedWarnings(nil); len(got) != 0 {
t.Errorf("got %q", got)
}
}
// What a reconcile leaves out is reported when it appears, not at every reconcile.
func TestCommitReportsNotesOnce(t *testing.T) {
s := newSyncer("wg0", FilterOptions{}, nil, &fakeWg{})
notes := skippedWarnings([]netip.Prefix{netip.MustParsePrefix("::ffff:10.0.0.0/104")})
for range 2 {
if err := s.commit(reconcile.Plan{Managed: make(reconcile.Set)}, notes); err != nil {
t.Fatal(err)
}
if _, remembered := s.warned[notes[0]]; !remembered {
t.Fatalf("note was not remembered: %v", s.warned)
}
}
if fresh := s.fresh(notes); len(fresh) != 0 {
t.Errorf("a repeated note must not be logged again: %q", fresh)
}
}
-28
View File
@@ -1,28 +0,0 @@
@startuml
autonumber
hide footbox
footer "RoutingTableToWg | Ruakij"
title "Add Route"
participant System
control Program
boundary Wireguard
System ++
Wireguard ++
System ->> Program ++ : Add Route
Program -> Program : Filter Route
Program -> Wireguard ++ : Get current State
Wireguard -> Program -- : State
Program -> Program : Find Peer with Gateway\nfrom AllowedIPs
Program -> Wireguard ++ : Add Dst-Net to\nAllowedIPs from Peer
Wireguard --> Program --
Program --
@enduml
-436
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 22 KiB

-28
View File
@@ -1,28 +0,0 @@
@startuml
autonumber
hide footbox
footer "RoutingTableToWg | Ruakij"
title "Delete Route"
participant System
control Program
boundary Wireguard
System ++
Wireguard ++
System ->> Program ++ : Delete Route
Program -> Program : Filter Route
Program -> Wireguard ++ : Get current State
Wireguard -> Program -- : State
Program -> Program : Find Peer with Dst-Net\nfrom AllowedIPs
Program -> Wireguard ++ : Remove Dst-Net from\nAllowedIPs from Peer
Wireguard --> Program --
Program --
@enduml
-436
View File
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 22 KiB

+13 -15
View File
@@ -1,23 +1,21 @@
module git.ruekov.eu/ruakij/routingtabletowg
go 1.19
go 1.27.3
require (
github.com/jsimonetti/rtnetlink v1.2.3
github.com/vishvananda/netlink v1.1.0
golang.org/x/sys v0.1.0
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20221104135756-97bc4ad4a1cb
github.com/vishvananda/netlink v1.3.1
golang.org/x/sys v0.49.0
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
)
require (
github.com/google/go-cmp v0.5.9 // indirect
github.com/josharian/native v1.0.0 // indirect
github.com/mdlayher/genetlink v1.2.0 // indirect
github.com/mdlayher/netlink v1.6.2 // indirect
github.com/mdlayher/socket v0.2.3 // indirect
github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df // indirect
golang.org/x/crypto v0.1.0 // indirect
golang.org/x/net v0.1.0 // indirect
golang.org/x/sync v0.1.0 // indirect
golang.zx2c4.com/wireguard v0.0.0-20220920152132-bb719d3a6e2c // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/mdlayher/genetlink v1.4.0 // indirect
github.com/mdlayher/netlink v1.11.2 // indirect
github.com/mdlayher/socket v0.8.0 // indirect
github.com/vishvananda/netns v0.0.5 // indirect
golang.org/x/crypto v0.58.0 // indirect
golang.org/x/net v0.61.0 // indirect
golang.org/x/sync v0.24.0 // indirect
golang.zx2c4.com/wireguard v0.0.0-20261006164505-2631ce99a06f // indirect
)
+27 -69
View File
@@ -1,70 +1,28 @@
github.com/cilium/ebpf v0.9.3/go.mod h1:w27N4UjpaQ9X/DGrSugxUG+H+NhgntDuPb5lCzxCn8A=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/frankban/quicktest v1.14.0/go.mod h1:NeW+ay9A/U67EYXNFA1nPE8e/tnQv/09mUdL/ijj8og=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/josharian/native v1.0.0 h1:Ts/E8zCSEsG17dUqv7joXJFybuMLjQfWE04tsBODTxk=
github.com/josharian/native v1.0.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/jsimonetti/rtnetlink v1.2.3 h1:JntWIxmljlDswWwebzpZCz2Aa3t2kThJ79f658zgsPU=
github.com/jsimonetti/rtnetlink v1.2.3/go.mod h1:5r5Rj9WEseVOUzDk5RN9v0gVXbkBz9XtENwhC6PwvtU=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mdlayher/genetlink v1.2.0 h1:4yrIkRV5Wfk1WfpWTcoOlGmsWgQj3OtQN9ZsbrE+XtU=
github.com/mdlayher/genetlink v1.2.0/go.mod h1:ra5LDov2KrUCZJiAtEvXXZBxGMInICMXIwshlJ+qRxQ=
github.com/mdlayher/netlink v1.6.0/go.mod h1:0o3PlBmGst1xve7wQ7j/hwpNaFaH4qCRyWCdcZk8/vA=
github.com/mdlayher/netlink v1.6.2 h1:D2zGSkvYsJ6NreeED3JiVTu1lj2sIYATqSaZlhPzUgQ=
github.com/mdlayher/netlink v1.6.2/go.mod h1:O1HXX2sIWSMJ3Qn1BYZk1yZM+7iMki/uYGGiwGyq/iU=
github.com/mdlayher/socket v0.1.1/go.mod h1:mYV5YIZAfHh4dzDVzI8x8tWLWCliuX8Mon5Awbj+qDs=
github.com/mdlayher/socket v0.2.3 h1:XZA2X2TjdOwNoNPVPclRCURoX/hokBY8nkTmRZFEheM=
github.com/mdlayher/socket v0.2.3/go.mod h1:bz12/FozYNH/VbvC3q7TRIK/Y6dH1kCKsXaUeXi/FmY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/mdlayher/genetlink v1.4.0 h1:f/Xs7Y2T+GyX9b3dbiUhnLE9InGs5F9RxJ2JwBMl71o=
github.com/mdlayher/genetlink v1.4.0/go.mod h1:d1hrKr8fwZU2JkcAtQUAzeTrI7nbgQSl+5k1cC0biSA=
github.com/mdlayher/netlink v1.11.2 h1:HKh2jqe+omdSWcQ88nrT7INE61B0NXfiSPFdgL4YbNI=
github.com/mdlayher/netlink v1.11.2/go.mod h1:uT2Yc/QLaZubzDpZIBi9d4GoeLwtp3x1AMeqSRrK2sA=
github.com/mdlayher/socket v0.8.0 h1:1lIVvHAs3sT623kPkPDa+I0nLVusskwnpWzp6ZUcNyc=
github.com/mdlayher/socket v0.8.0/go.mod h1:EhNb63aXqexVoTJDblxvDOSuIGIJinC4H5ZktnaJ6Rk=
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/vishvananda/netlink v1.1.0 h1:1iyaYNBLmP6L0220aDnYQpo1QEV4t4hJ+xEEhhJH8j0=
github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE=
github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df h1:OviZH7qLw/7ZovXvuNyL3XQl8UFofeikI1NW1Gypu7k=
github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.1.0 h1:MDRAIl0xIo9Io2xV565hzXHw3zVseKrJKodhohM5CjU=
golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20210928044308-7d9f5e0b762b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220923203811-8be639271d50/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.1.0 h1:hZ/3BUoy5aId7sCpA/Tc5lt8DkFgdVS2onTpJsZ/fl0=
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220923202941-7f9b1623fab7/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.zx2c4.com/wireguard v0.0.0-20220920152132-bb719d3a6e2c h1:Okh6a1xpnJslG9Mn84pId1Mn+Q8cvpo4HCeeFWHo0cA=
golang.zx2c4.com/wireguard v0.0.0-20220920152132-bb719d3a6e2c/go.mod h1:enML0deDxY1ux+B6ANGiwtg0yAJi1rctkTpcHNAVPyg=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20221104135756-97bc4ad4a1cb h1:9aqVcYEDHmSNb0uOWukxV5lHV09WqiSiCuhEgWNETLY=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20221104135756-97bc4ad4a1cb/go.mod h1:mQqgjkW8GQQcJQsbBvK890TKqUK1DfKWkuBGbOkuMHQ=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.zx2c4.com/wireguard v0.0.0-20261006164505-2631ce99a06f h1:gJI6OVshWW0mvGyTVmW8mJoaw2OM69wVCeZkwpX+Jdc=
golang.zx2c4.com/wireguard v0.0.0-20261006164505-2631ce99a06f/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ=
+1
View File
@@ -0,0 +1 @@
v2.15.0
+4 -17
View File
@@ -2,26 +2,13 @@ package environmentchecks
import (
"os"
"fmt"
)
func HandleDefaults(envDefaults map[string]string){
for env, defaultValue := range envDefaults{
// Check if env is set
_, isSet := os.LookupEnv(env)
if(!isSet){
// HandleDefaults sets every env that is unset or empty to its default.
func HandleDefaults(envDefaults map[string]string) {
for env, defaultValue := range envDefaults {
if os.Getenv(env) == "" {
os.Setenv(env, defaultValue)
}
}
}
func HandleRequired(envRequired []string) error {
for _, env := range envRequired{
// Check if env is set
_, isSet := os.LookupEnv(env)
if(!isSet){
return fmt.Errorf("env '%s' required, but not set", env)
}
}
return nil
}
@@ -0,0 +1,29 @@
package environmentchecks
import (
"os"
"testing"
)
func TestHandleDefaults(t *testing.T) {
t.Setenv("TEST_UNSET", "")
os.Unsetenv("TEST_UNSET")
t.Setenv("TEST_EMPTY", "")
t.Setenv("TEST_SET", "value")
HandleDefaults(map[string]string{
"TEST_UNSET": "default",
"TEST_EMPTY": "default",
"TEST_SET": "default",
})
for env, want := range map[string]string{
"TEST_UNSET": "default",
"TEST_EMPTY": "default",
"TEST_SET": "value",
} {
if got := os.Getenv(env); got != want {
t.Errorf("%s = %q, want %q", env, got, want)
}
}
}
+91 -39
View File
@@ -1,11 +1,14 @@
package iproute2mapping
import (
"os"
"bufio"
"strings"
"strconv"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strconv"
"strings"
)
// Storage for mapping
@@ -18,83 +21,132 @@ const (
TABLE
)
// Paths
var filePaths = map[int]string{
PROTOCOL: "/etc/iproute2/rt_protos",
TABLE: "/etc/iproute2/rt_tables",
// Mapping file of each type, relative to the directories in roots.
// A directory <file>.d next to it may hold additional *.conf files.
var fileNames = map[int]string{
PROTOCOL: "rt_protos",
TABLE: "rt_tables",
}
// Directories holding the mapping files. Later entries override earlier ones,
// like iproute2 lets /etc override the distribution defaults in /usr/share.
var roots = []string{"/usr/share/iproute2", "/etc/iproute2"}
// Export error-check
var Errors []error;
var Errors []error
func init() {
var err error
for mapType, filePath := range filePaths{
ByName[mapType], ById[mapType], err = readFromFile(filePath)
if(err != nil){
Errors = []error{
fmt.Errorf("failed reading iproute2 mapping-file '%s': %s", filePath, err),
};
}
for mapType, fileName := range fileNames {
m := load(roots, fileName)
ByName[mapType], ById[mapType] = m.byName, m.byId
Errors = append(Errors, m.errs...)
}
}
func readFromFile(filePath string) (mapByName map[string]int, mapById map[int]string, err error){
type mapping struct {
byName map[string]int
byId map[int]string
errs []error
}
// load reads the mapping file and its .d directory from every root.
// Roots or files that do not exist are skipped, unless none exists at all.
func load(roots []string, fileName string) *mapping {
m := &mapping{
byName: make(map[string]int),
byId: make(map[int]string),
}
found := false
for _, root := range roots {
paths := []string{filepath.Join(root, fileName)}
confs, err := filepath.Glob(filepath.Join(root, fileName+".d", "*.conf"))
if err != nil {
m.errs = append(m.errs, err)
}
paths = append(paths, confs...)
for _, path := range paths {
err := m.readFile(path)
if errors.Is(err, fs.ErrNotExist) {
continue
}
found = true
if err != nil {
m.errs = append(m.errs, fmt.Errorf("failed reading mapping-file '%s': %w", path, err))
}
}
}
if !found {
m.errs = append(m.errs, fmt.Errorf("no mapping-file '%s' found in %s", fileName, strings.Join(roots, ", ")))
}
return m
}
// readFile adds all valid entries of the file to the mapping.
// Invalid lines are added to the errors and skipped.
func (m *mapping) readFile(filePath string) error {
file, err := os.Open(filePath)
if(err != nil){
return nil, nil, err;
if err != nil {
return err
}
defer file.Close()
scanner := bufio.NewScanner(file)
scanner.Split(bufio.ScanLines)
mapByName = make(map[string]int)
mapById = make(map[int]string)
// Go through file line-by-line
for scanner.Scan() {
text := scanner.Text()
if(strings.HasPrefix(text, "#") || text == ""){
text := strings.TrimSpace(scanner.Text())
if text == "" || strings.HasPrefix(text, "#") {
continue
}
id, name, err := getMappingFromText(text)
if(err != nil){
if err != nil {
// Only warn as we can continue processing the file
Errors = append(Errors,
fmt.Errorf("mappig-data invalid '%s': %s", text, err),
m.errs = append(m.errs,
fmt.Errorf("mapping-data invalid in '%s': '%s': %w", filePath, text, err),
)
continue
}
mapByName[name] = id
mapById[id] = name
m.byName[name] = id
m.byId[id] = name
}
return
return scanner.Err()
}
// getMappingFromText parses "<id> <name>" with any whitespace in between.
// The id is decimal or hexadecimal with a 0x prefix, anything after the name is ignored.
func getMappingFromText(text string) (int, string, error) {
// Split and read/convert data
data := strings.Split(text, "\t")
data := strings.Fields(text)
if len(data) < 2 {
return 0, "", fmt.Errorf("expected '<id> <name>'")
}
id, err := strconv.Atoi(data[0])
if(err != nil){
idStr, base := data[0], 10
if len(idStr) > 2 && (idStr[:2] == "0x" || idStr[:2] == "0X") {
idStr, base = idStr[2:], 16
}
// 31 bits so that the id also fits into an int on 32-bit platforms
id, err := strconv.ParseUint(idStr, base, 31)
if err != nil {
return 0, "", err
}
return id, data[1], nil
return int(id), data[1], nil
}
// Try getting an id from a name (or a string containing an id) with a specified type
func TryGetId(mappingType int, name string) (int, error){
func TryGetId(mappingType int, name string) (int, error) {
// Try to convert name to id
id, err := strconv.Atoi(name)
if(err != nil){ // name given -> Convert to id
if err != nil { // name given -> Convert to id
var found bool
id, found = ByName[mappingType][name]
if(!found){
if !found {
return 0, fmt.Errorf("no id found from name")
}
}
+142
View File
@@ -1,2 +1,144 @@
package iproute2mapping
import (
"maps"
"os"
"path/filepath"
"strings"
"testing"
)
func TestGetMappingFromText(t *testing.T) {
tests := []struct {
name string
text string
wantId int
wantName string
wantErr bool
}{
{name: "tab", text: "254\tmain", wantId: 254, wantName: "main"},
{name: "space", text: "254 main", wantId: 254, wantName: "main"},
{name: "many spaces", text: "254 main", wantId: 254, wantName: "main"},
{name: "mixed whitespace", text: "254 \t main", wantId: 254, wantName: "main"},
{name: "trailing comment", text: "100 mytable # my table", wantId: 100, wantName: "mytable"},
{name: "hex", text: "0x10 hextable", wantId: 16, wantName: "hextable"},
{name: "leading zeros are decimal", text: "010 oct", wantId: 10, wantName: "oct"},
{name: "only a number", text: "254", wantErr: true},
{name: "only a name", text: "main", wantErr: true},
{name: "name before number", text: "main 254", wantErr: true},
{name: "negative", text: "-1 neg", wantErr: true},
{name: "too large", text: "99999999999 big", wantErr: true},
{name: "empty hex", text: "0x hex", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
id, name, err := getMappingFromText(tt.text)
if (err != nil) != tt.wantErr {
t.Fatalf("error = %v, wantErr %v", err, tt.wantErr)
}
if err == nil && (id != tt.wantId || name != tt.wantName) {
t.Errorf("got (%d, %q), want (%d, %q)", id, name, tt.wantId, tt.wantName)
}
})
}
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func TestLoad(t *testing.T) {
usr, etc := t.TempDir(), t.TempDir()
writeFile(t, filepath.Join(usr, "rt_tables"),
"# comment\n\n255\tlocal\n254 main\n253 default\n0\tunspec\n")
writeFile(t, filepath.Join(usr, "rt_tables.d", "extra.conf"), "200 extra\n")
writeFile(t, filepath.Join(usr, "rt_tables.d", "ignored.txt"), "201 ignored\n")
// etc overrides usr
writeFile(t, filepath.Join(etc, "rt_tables"), " 100 custom \n254 mainoverride\n")
writeFile(t, filepath.Join(etc, "rt_tables.d", "more.conf"), "101 more\n")
m := load([]string{usr, etc}, "rt_tables")
if len(m.errs) != 0 {
t.Errorf("unexpected errors: %v", m.errs)
}
wantByName := map[string]int{
"local": 255, "main": 254, "default": 253, "unspec": 0,
"extra": 200, "custom": 100, "mainoverride": 254, "more": 101,
}
if !maps.Equal(m.byName, wantByName) {
t.Errorf("byName = %v, want %v", m.byName, wantByName)
}
if m.byId[254] != "mainoverride" || m.byId[100] != "custom" || m.byId[200] != "extra" {
t.Errorf("byId = %v", m.byId)
}
}
func TestLoadMissingRootIsFine(t *testing.T) {
usr := t.TempDir()
writeFile(t, filepath.Join(usr, "rt_protos"), "2 kernel\n")
m := load([]string{usr, filepath.Join(usr, "does-not-exist")}, "rt_protos")
if len(m.errs) != 0 {
t.Errorf("unexpected errors: %v", m.errs)
}
if m.byName["kernel"] != 2 {
t.Errorf("byName = %v", m.byName)
}
}
func TestLoadNothingFound(t *testing.T) {
m := load([]string{filepath.Join(t.TempDir(), "a"), filepath.Join(t.TempDir(), "b")}, "rt_tables")
if len(m.errs) != 1 || !strings.Contains(m.errs[0].Error(), "no mapping-file") {
t.Errorf("errs = %v", m.errs)
}
}
func TestLoadMalformedLinesAreSkipped(t *testing.T) {
dir := t.TempDir()
writeFile(t, filepath.Join(dir, "rt_tables"), "254\nfoo bar\n100 ok\n300\n")
m := load([]string{dir}, "rt_tables")
if len(m.errs) != 3 {
t.Errorf("want 3 errors, got %v", m.errs)
}
if !maps.Equal(m.byName, map[string]int{"ok": 100}) {
t.Errorf("byName = %v", m.byName)
}
}
func TestTryGetId(t *testing.T) {
origByName := ByName
t.Cleanup(func() { ByName = origByName })
ByName = map[int]map[string]int{TABLE: {"main": 254}}
tests := []struct {
name string
input string
want int
wantErr bool
}{
{name: "name", input: "main", want: 254},
{name: "number", input: "100", want: 100},
{name: "negative number", input: "-1", want: -1},
{name: "unknown name", input: "nope", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := TryGetId(TABLE, tt.input)
if (err != nil) != tt.wantErr {
t.Fatalf("error = %v, wantErr %v", err, tt.wantErr)
}
if err == nil && got != tt.want {
t.Errorf("got %d, want %d", got, tt.want)
}
})
}
}
-25
View File
@@ -1,25 +0,0 @@
package netchecks
import (
"fmt"
"net"
"reflect"
)
func IPNetIndexByIP(list []net.IPNet, ip net.IP) (int, error) {
for index, ipNetEntry := range list {
if ipNetEntry.Contains(ip) {
return index, nil
}
}
return -1, fmt.Errorf("ip not in ipNet-list")
}
func IPNetIndexByIPNet(list []net.IPNet, ipNet net.IPNet) (int, error) {
for index, ipNetEntry := range list {
if reflect.DeepEqual(ipNetEntry, ipNet) {
return index, nil
}
}
return -1, fmt.Errorf("ipNet not in ipNet-list")
}
+258
View File
@@ -0,0 +1,258 @@
// Package reconcile computes the AllowedIPs changes that make WireGuard peers
// follow a set of routes. It does no I/O.
package reconcile
import (
"bytes"
"cmp"
"net/netip"
"slices"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
// Set of prefixes.
type Set = map[netip.Prefix]struct{}
// Peer with its current AllowedIPs, which must be canonical (see PrefixFromIPNet).
type Peer struct {
Key wgtypes.Key
AllowedIPs []netip.Prefix
}
// Route to a destination via a gateway, with canonical prefix and address.
type Route struct {
Dst netip.Prefix
Gw netip.Addr
Metric int
Table int
}
type Input struct {
Routes []Route
Peers []Peer
// Managed are the prefixes the program added or adopted because of a route.
// Only these are removed once no route backs them anymore.
Managed Set
// Protected prefixes are never added, moved or removed.
Protected Set
// Startup is set for the first reconcile after the start or after the interface
// was recreated. Only then a prefix that is present already and not managed is
// adopted or moved when a route backs it, as it can be left from an earlier run.
// Later it is manual, whatever the routes say.
Startup bool
}
// Change to the AllowedIPs of one peer.
type Change struct {
Peer wgtypes.Key
// Add prefixes. When a prefix is currently on another peer, adding it moves it.
Add []netip.Prefix
Remove []netip.Prefix
}
// Conflict is a route to a prefix that is on another peer and not managed.
// It is left as it is.
type Conflict struct {
Dst netip.Prefix
Present wgtypes.Key // peer having the prefix
Wanted wgtypes.Key // peer the route points to
}
type Plan struct {
Changes []Change
// Unresolved are the routes whose gateway belongs to no peer.
Unresolved []Route
// Conflicts are ordered by prefix.
Conflicts []Conflict
// Managed is the new managed set once all changes are applied.
// It is the set of prefixes backed by a route that the program added or adopted.
Managed Set
}
// Compute the changes that bring the peers in line with the routes.
//
// Every destination is assigned to the peer with the longest AllowedIPs prefix
// containing the gateway. Of several routes to a destination the lowest metric wins.
// A managed prefix on the wrong peer is moved, a managed prefix without a route is removed.
// Prefixes the program does not manage are left alone, except at startup (see Input.Startup).
func Compute(in Input) Plan {
present := make(map[netip.Prefix]wgtypes.Key)
for _, peer := range in.Peers {
for _, prefix := range peer.AllowedIPs {
present[prefix] = peer.Key
}
}
desired, unresolved := desiredState(in)
changes := make(map[wgtypes.Key]*Change)
change := func(key wgtypes.Key) *Change {
if changes[key] == nil {
changes[key] = &Change{Peer: key}
}
return changes[key]
}
plan := Plan{Unresolved: unresolved}
managed := make(Set, len(desired))
for dst, key := range desired {
current, isPresent := present[dst]
_, ours := in.Managed[dst]
if isPresent && !ours && !in.Startup {
// Somebody else set it up, so the route has no say about it
if current != key {
plan.Conflicts = append(plan.Conflicts, Conflict{Dst: dst, Present: current, Wanted: key})
}
continue
}
managed[dst] = struct{}{}
if !isPresent || current != key {
c := change(key)
c.Add = append(c.Add, dst)
}
}
for dst := range in.Managed {
_, wanted := desired[dst]
_, protected := in.Protected[dst]
if wanted || protected {
continue
}
if key, ok := present[dst]; ok {
c := change(key)
c.Remove = append(c.Remove, dst)
}
}
plan.Managed = managed
slices.SortFunc(plan.Conflicts, func(a, b Conflict) int { return comparePrefix(a.Dst, b.Dst) })
for _, c := range changes {
slices.SortFunc(c.Add, comparePrefix)
slices.SortFunc(c.Remove, comparePrefix)
plan.Changes = append(plan.Changes, *c)
}
slices.SortFunc(plan.Changes, func(a, b Change) int { return bytes.Compare(a.Peer[:], b.Peer[:]) })
return plan
}
type entry struct {
prefix netip.Prefix
key wgtypes.Key
}
// desiredState assigns the destination of every usable route to a peer.
func desiredState(in Input) (map[netip.Prefix]wgtypes.Key, []Route) {
// Destinations that may be adopted or moved at startup
dsts := make(Set, len(in.Routes))
if in.Startup {
for _, route := range in.Routes {
if _, protected := in.Protected[route.Dst]; !protected {
dsts[route.Dst] = struct{}{}
}
}
}
// Gateways are matched against the prefixes set up by hand only. Prefixes
// derived from routes would make the result depend on what was applied before.
// At startup the managed set is not known, so the prefixes a route backs are
// left out, as they are about to be taken over.
var static []entry
for _, peer := range in.Peers {
for _, prefix := range peer.AllowedIPs {
_, managed := in.Managed[prefix]
_, routed := dsts[prefix]
if !managed && !routed {
static = append(static, entry{prefix, peer.Key})
}
}
}
type owner struct {
key wgtypes.Key
ok bool
}
owners := make(map[netip.Addr]owner)
peerFor := func(gw netip.Addr) (wgtypes.Key, bool) {
if o, seen := owners[gw]; seen {
return o.key, o.ok
}
key, ok := longestMatch(static, gw)
owners[gw] = owner{key, ok}
return key, ok
}
best := make(map[netip.Prefix]Route)
desired := make(map[netip.Prefix]wgtypes.Key)
var unresolved []Route
for _, route := range in.Routes {
if !route.Dst.IsValid() || !route.Gw.IsValid() {
continue
}
if _, protected := in.Protected[route.Dst]; protected {
continue
}
key, ok := peerFor(route.Gw)
if !ok {
unresolved = append(unresolved, route)
continue
}
if current, ok := best[route.Dst]; !ok || compareRoutes(route, current) < 0 {
best[route.Dst] = route
desired[route.Dst] = key
}
}
return desired, unresolved
}
// longestMatch returns the peer owning the longest prefix that contains the address,
// like WireGuard picks the peer for a destination.
func longestMatch(entries []entry, addr netip.Addr) (wgtypes.Key, bool) {
var (
key wgtypes.Key
bits = -1
)
for _, e := range entries {
if e.prefix.Bits() > bits && e.prefix.Contains(addr) {
key, bits = e.key, e.prefix.Bits()
}
}
return key, bits >= 0
}
// tableMain is the main routing table.
const tableMain = 254
// compareRoutes orders routes by preference: metric, then table (main first, then the
// lower number) and gateway as stable tie-breakers.
func compareRoutes(a, b Route) int {
return cmp.Or(
cmp.Compare(a.Metric, b.Metric),
cmp.Compare(tableRank(a.Table), tableRank(b.Table)),
a.Gw.Compare(b.Gw),
)
}
func tableRank(table int) int {
if table == tableMain {
return -1
}
return table
}
func comparePrefix(a, b netip.Prefix) int {
return cmp.Or(
a.Addr().Compare(b.Addr()),
cmp.Compare(a.Bits(), b.Bits()),
)
}
+472
View File
@@ -0,0 +1,472 @@
package reconcile
import (
"fmt"
"net/netip"
"slices"
"strings"
"testing"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
var (
keyA = wgtypes.Key{1}
keyB = wgtypes.Key{2}
)
func pfx(s string) netip.Prefix { return netip.MustParsePrefix(s) }
func pfxs(list ...string) []netip.Prefix {
var out []netip.Prefix
for _, s := range list {
out = append(out, pfx(s))
}
return out
}
func set(list ...string) Set {
s := make(Set)
for _, p := range list {
s[pfx(p)] = struct{}{}
}
return s
}
func sorted(s Set) []string {
var out []string
for p := range s {
out = append(out, p.String())
}
slices.Sort(out)
return out
}
func route(dst, gw string, metric int) Route {
return Route{Dst: pfx(dst), Gw: netip.MustParseAddr(gw), Metric: metric}
}
func peerA(allowed ...string) Peer { return Peer{Key: keyA, AllowedIPs: pfxs(allowed...)} }
func peerB(allowed ...string) Peer { return Peer{Key: keyB, AllowedIPs: pfxs(allowed...)} }
func peerName(key wgtypes.Key) string {
return map[wgtypes.Key]string{keyA: "A", keyB: "B"}[key]
}
func render(plan Plan) []string {
var out []string
for _, c := range plan.Changes {
var parts []string
for _, p := range c.Add {
parts = append(parts, "+"+p.String())
}
for _, p := range c.Remove {
parts = append(parts, "-"+p.String())
}
out = append(out, fmt.Sprintf("%s %s", peerName(c.Peer), strings.Join(parts, " ")))
}
return out
}
func TestCompute(t *testing.T) {
tests := []struct {
name string
in Input
want []string // changes
wantManaged []string
wantUnresolved int
wantConflicts []string
}{
{
name: "route is added to the peer containing the gateway",
in: Input{
Routes: []Route{route("203.0.113.0/24", "10.0.0.1", 0)},
Peers: []Peer{peerA("10.0.0.0/24"), peerB("10.1.0.0/24")},
},
want: []string{"A +203.0.113.0/24"},
wantManaged: []string{"203.0.113.0/24"},
},
{
name: "gateway matches the longest prefix",
in: Input{
Routes: []Route{route("203.0.113.0/24", "10.1.0.1", 0)},
Peers: []Peer{peerA("10.0.0.0/8"), peerB("10.1.0.0/16")},
},
want: []string{"B +203.0.113.0/24"},
wantManaged: []string{"203.0.113.0/24"},
},
{
name: "ipv6 gateway",
in: Input{
Routes: []Route{route("2001:db8::/32", "fd00::1", 1024)},
Peers: []Peer{peerA("10.0.0.0/24"), peerB("fd00::/64")},
},
want: []string{"B +2001:db8::/32"},
wantManaged: []string{"2001:db8::/32"},
},
{
name: "default routes of both families",
in: Input{
Routes: []Route{route("0.0.0.0/0", "10.0.0.1", 0), route("::/0", "fd00::1", 1024)},
Peers: []Peer{peerA("10.0.0.0/24"), peerB("fd00::/64")},
},
want: []string{"A +0.0.0.0/0", "B +::/0"},
wantManaged: []string{"0.0.0.0/0", "::/0"},
},
{
name: "gateway in no peer",
in: Input{
Routes: []Route{route("203.0.113.0/24", "192.168.9.9", 0)},
Peers: []Peer{peerA("10.0.0.0/24")},
},
wantUnresolved: 1,
},
{
name: "startup: prefix on the right peer is kept and adopted",
in: Input{
Routes: []Route{route("203.0.113.0/24", "10.0.0.1", 0)},
Peers: []Peer{peerA("10.0.0.0/24", "203.0.113.0/24")},
Startup: true,
},
wantManaged: []string{"203.0.113.0/24"},
},
{
name: "prefix on the wrong peer moves, without a removal",
in: Input{
Routes: []Route{route("203.0.113.0/24", "10.1.0.1", 0)},
Peers: []Peer{peerA("10.0.0.0/24", "203.0.113.0/24"), peerB("10.1.0.0/24")},
Managed: set("203.0.113.0/24"),
},
want: []string{"B +203.0.113.0/24"},
wantManaged: []string{"203.0.113.0/24"},
},
{
name: "lowest metric wins",
in: Input{
Routes: []Route{route("203.0.113.0/24", "10.1.0.1", 200), route("203.0.113.0/24", "10.0.0.1", 100)},
Peers: []Peer{peerA("10.0.0.0/24"), peerB("10.1.0.0/24")},
},
want: []string{"A +203.0.113.0/24"},
wantManaged: []string{"203.0.113.0/24"},
},
{
name: "equal metric prefers the main table, then the lower table, then the lower gateway",
in: Input{
Routes: []Route{
{Dst: pfx("203.0.113.0/24"), Gw: netip.MustParseAddr("10.1.0.1"), Table: 100},
{Dst: pfx("203.0.113.0/24"), Gw: netip.MustParseAddr("10.0.0.1"), Table: 254},
{Dst: pfx("198.51.100.0/24"), Gw: netip.MustParseAddr("10.0.0.1"), Table: 200},
{Dst: pfx("198.51.100.0/24"), Gw: netip.MustParseAddr("10.1.0.1"), Table: 100},
{Dst: pfx("192.0.2.0/24"), Gw: netip.MustParseAddr("10.1.0.1"), Table: 254},
{Dst: pfx("192.0.2.0/24"), Gw: netip.MustParseAddr("10.0.0.1"), Table: 254},
},
Peers: []Peer{peerA("10.0.0.0/24"), peerB("10.1.0.0/24")},
},
want: []string{"A +192.0.2.0/24 +203.0.113.0/24", "B +198.51.100.0/24"},
wantManaged: []string{"192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24"},
},
{
name: "a lower metric wins over the main table",
in: Input{
Routes: []Route{
{Dst: pfx("203.0.113.0/24"), Gw: netip.MustParseAddr("10.1.0.1"), Table: 100, Metric: 10},
{Dst: pfx("203.0.113.0/24"), Gw: netip.MustParseAddr("10.0.0.1"), Table: 254, Metric: 20},
},
Peers: []Peer{peerA("10.0.0.0/24"), peerB("10.1.0.0/24")},
},
want: []string{"B +203.0.113.0/24"},
wantManaged: []string{"203.0.113.0/24"},
},
{
name: "deleting the backup route keeps the primary peer's prefix",
in: Input{
Routes: []Route{route("203.0.113.0/24", "10.0.0.1", 100)},
Peers: []Peer{peerA("10.0.0.0/24", "203.0.113.0/24"), peerB("10.1.0.0/24")},
Managed: set("203.0.113.0/24"),
},
wantManaged: []string{"203.0.113.0/24"},
},
{
name: "deleting the primary route moves the prefix to the backup peer",
in: Input{
Routes: []Route{route("203.0.113.0/24", "10.1.0.1", 200)},
Peers: []Peer{peerA("10.0.0.0/24", "203.0.113.0/24"), peerB("10.1.0.0/24")},
Managed: set("203.0.113.0/24"),
},
want: []string{"B +203.0.113.0/24"},
wantManaged: []string{"203.0.113.0/24"},
},
{
name: "managed prefix without route is removed",
in: Input{
Peers: []Peer{peerA("10.0.0.0/24", "203.0.113.0/24", "198.51.100.0/24")},
Managed: set("203.0.113.0/24"),
},
want: []string{"A -203.0.113.0/24"},
},
{
name: "managed prefix is removed from the peer it is on now",
in: Input{
Peers: []Peer{peerA("10.0.0.0/24"), peerB("10.1.0.0/24", "203.0.113.0/24")},
Managed: set("203.0.113.0/24"),
},
want: []string{"B -203.0.113.0/24"},
},
{
name: "managed prefix that is gone already is forgotten",
in: Input{
Peers: []Peer{peerA("10.0.0.0/24")},
Managed: set("203.0.113.0/24"),
},
},
{
name: "startup: present prefix with a route on another peer is moved",
in: Input{
Routes: []Route{route("192.0.2.0/24", "10.1.0.1", 0)},
Peers: []Peer{peerA("10.0.0.0/24", "192.0.2.0/24"), peerB("10.1.0.0/24")},
Startup: true,
},
want: []string{"B +192.0.2.0/24"},
wantManaged: []string{"192.0.2.0/24"},
},
{
name: "startup: prefix without a route is manual and stays",
in: Input{
Peers: []Peer{peerA("10.0.0.0/24", "192.0.2.0/24")},
Startup: true,
},
},
{
name: "after startup: present prefix with a route on the same peer stays manual",
in: Input{
Routes: []Route{route("192.0.2.0/24", "10.0.0.1", 0)},
Peers: []Peer{peerA("10.0.0.0/24", "192.0.2.0/24")},
},
},
{
name: "after startup: present prefix with a route on another peer is a conflict and stays",
in: Input{
Routes: []Route{route("192.0.2.0/24", "10.1.0.1", 0)},
Peers: []Peer{peerA("10.0.0.0/24", "192.0.2.0/24"), peerB("10.1.0.0/24")},
},
wantConflicts: []string{"192.0.2.0/24 on A, route to B"},
},
{
name: "after startup: managed prefix on another peer than its route is moved",
in: Input{
Routes: []Route{route("192.0.2.0/24", "10.1.0.1", 0)},
Peers: []Peer{peerA("10.0.0.0/24", "192.0.2.0/24"), peerB("10.1.0.0/24")},
Managed: set("192.0.2.0/24"),
},
want: []string{"B +192.0.2.0/24"},
wantManaged: []string{"192.0.2.0/24"},
},
{
name: "after startup: manual gateway prefix with a learned route on another peer resolves gateways",
in: Input{
Routes: []Route{
route("203.0.113.0/24", "10.250.0.2", 0),
route("10.250.0.2/32", "10.1.0.1", 0),
},
Peers: []Peer{peerA("10.0.0.0/24", "10.250.0.2/32"), peerB("10.1.0.0/24")},
},
want: []string{"A +203.0.113.0/24"},
wantManaged: []string{"203.0.113.0/24"},
wantConflicts: []string{"10.250.0.2/32 on A, route to B"},
},
{
name: "startup: prefix of a learned route does not resolve gateways, protecting it does",
in: Input{
Routes: []Route{
route("203.0.113.0/24", "10.250.0.2", 0),
route("198.51.100.0/24", "10.251.0.2", 0),
route("10.250.0.2/32", "10.1.0.1", 0),
route("10.251.0.2/32", "10.1.0.1", 0),
},
Peers: []Peer{peerA("10.0.0.0/24", "10.250.0.2/32", "10.251.0.2/32"), peerB("10.1.0.0/24")},
Protected: set("10.250.0.2/32"),
Startup: true,
},
want: []string{"A +203.0.113.0/24", "B +10.251.0.2/32"},
wantManaged: []string{"10.251.0.2/32", "203.0.113.0/24"},
wantUnresolved: 1,
},
{
name: "protected prefixes are not added, moved or removed",
in: Input{
Routes: []Route{
route("192.0.2.0/24", "10.1.0.1", 0),
route("198.51.100.0/24", "10.1.0.1", 0),
},
Peers: []Peer{peerA("10.0.0.0/24", "192.0.2.0/24"), peerB("10.1.0.0/24")},
Managed: set("203.0.113.0/24"),
Protected: set("192.0.2.0/24", "203.0.113.0/24"),
},
want: []string{"B +198.51.100.0/24"},
wantManaged: []string{"198.51.100.0/24"},
},
{
name: "route derived prefixes do not resolve gateways",
in: Input{
Routes: []Route{
route("10.0.0.0/8", "192.168.1.1", 0),
route("172.16.0.0/12", "10.5.5.5", 0),
},
Peers: []Peer{peerA("192.168.1.0/24", "10.0.0.0/8")},
Managed: set("10.0.0.0/8"),
},
wantManaged: []string{"10.0.0.0/8"},
wantUnresolved: 1,
},
{
name: "startup: prefix present that a route backs does not resolve gateways either",
in: Input{
Routes: []Route{
route("10.0.0.0/8", "192.168.1.1", 0),
route("172.16.0.0/12", "10.5.5.5", 0),
},
Peers: []Peer{peerA("192.168.1.0/24", "10.0.0.0/8")},
Startup: true,
},
wantManaged: []string{"10.0.0.0/8"},
wantUnresolved: 1,
},
{
name: "unresolved route removes its managed prefix",
in: Input{
Routes: []Route{route("203.0.113.0/24", "10.9.9.9", 0)},
Peers: []Peer{peerA("10.0.0.0/24", "203.0.113.0/24")},
Managed: set("203.0.113.0/24"),
},
want: []string{"A -203.0.113.0/24"},
wantUnresolved: 1,
},
{
name: "changes are ordered by peer and prefix",
in: Input{
Routes: []Route{
route("203.0.113.0/24", "10.1.0.1", 0),
route("198.51.100.0/24", "10.1.0.1", 0),
route("192.0.2.0/24", "10.0.0.1", 0),
},
Peers: []Peer{peerB("10.1.0.0/24"), peerA("10.0.0.0/24")},
},
want: []string{"A +192.0.2.0/24", "B +198.51.100.0/24 +203.0.113.0/24"},
wantManaged: []string{"192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
plan := Compute(tt.in)
if got := render(plan); !slices.Equal(got, tt.want) {
t.Errorf("changes:\n got %q\nwant %q", got, tt.want)
}
if got := sorted(plan.Managed); !slices.Equal(got, tt.wantManaged) {
t.Errorf("managed:\n got %q\nwant %q", got, tt.wantManaged)
}
if got := len(plan.Unresolved); got != tt.wantUnresolved {
t.Errorf("unresolved = %d, want %d", got, tt.wantUnresolved)
}
var conflicts []string
for _, c := range plan.Conflicts {
conflicts = append(conflicts, fmt.Sprintf("%s on %s, route to %s", c.Dst, peerName(c.Present), peerName(c.Wanted)))
}
if !slices.Equal(conflicts, tt.wantConflicts) {
t.Errorf("conflicts:\n got %q\nwant %q", conflicts, tt.wantConflicts)
}
})
}
}
// without returns the list without the removed prefixes.
func without(list, removed []netip.Prefix) []netip.Prefix {
return slices.DeleteFunc(slices.Clone(list), func(p netip.Prefix) bool { return slices.Contains(removed, p) })
}
// apply the plan like WireGuard does and carry over the managed set.
func apply(in *Input, plan Plan) {
for _, c := range plan.Changes {
for i := range in.Peers {
if in.Peers[i].Key == c.Peer {
in.Peers[i].AllowedIPs = append(without(in.Peers[i].AllowedIPs, c.Remove), c.Add...)
} else {
in.Peers[i].AllowedIPs = without(in.Peers[i].AllowedIPs, c.Add) // kernel moves
}
}
}
in.Managed = plan.Managed
}
// Applying a plan and computing again must not change anything.
func TestComputeConverges(t *testing.T) {
for _, startup := range []bool{true, false} {
in := Input{
Routes: []Route{
route("203.0.113.0/24", "10.1.0.1", 100),
route("203.0.113.0/24", "10.0.0.1", 200),
route("0.0.0.0/0", "10.0.0.1", 0),
},
Peers: []Peer{
peerA("10.0.0.0/24", "203.0.113.0/24", "192.0.2.0/24"),
peerB("10.1.0.0/24"),
},
Managed: set("203.0.113.0/24", "198.51.100.0/24"),
Startup: startup,
}
apply(&in, Compute(in))
in.Startup = false
if again := Compute(in); len(again.Changes) != 0 {
t.Errorf("startup=%v: second pass has changes: %q", startup, render(again))
}
}
}
// A manual prefix that is a gateway must survive a routing daemon learning it on another peer.
func TestComputeKeepsManualGateway(t *testing.T) {
in := Input{
Peers: []Peer{peerA("10.0.0.0/24", "10.250.0.2/32"), peerB("10.1.0.0/24")},
}
step := func(name string, want ...string) {
t.Helper()
plan := Compute(in)
if got := render(plan); !slices.Equal(got, want) {
t.Errorf("%s:\n got %q\nwant %q", name, got, want)
}
apply(&in, plan)
}
in.Routes = []Route{route("203.0.113.0/24", "10.250.0.2", 0)}
step("route via the manual prefix", "A +203.0.113.0/24")
in.Routes = append(in.Routes, route("10.250.0.2/32", "10.1.0.1", 0))
step("gateway learned on B")
step("again")
in.Routes = in.Routes[:1]
step("learned route gone")
in.Routes = nil
step("route gone", "A -203.0.113.0/24")
if want := pfxs("10.0.0.0/24", "10.250.0.2/32"); !slices.Equal(in.Peers[0].AllowedIPs, want) {
t.Errorf("A = %v, want %v", in.Peers[0].AllowedIPs, want)
}
}
// What an earlier run left behind is taken over once, at startup.
func TestComputeStartupTakesOverLeftovers(t *testing.T) {
in := Input{
Routes: []Route{route("203.0.113.0/24", "10.0.0.1", 0)},
Peers: []Peer{peerA("10.0.0.0/24", "203.0.113.0/24", "192.0.2.0/24"), peerB("10.1.0.0/24")},
Startup: true,
}
apply(&in, Compute(in))
in.Startup = false
in.Routes = nil
plan := Compute(in)
if got, want := render(plan), []string{"A -203.0.113.0/24"}; !slices.Equal(got, want) {
t.Errorf("got %q, want %q", got, want)
}
}
+66
View File
@@ -0,0 +1,66 @@
package reconcile
import (
"fmt"
"net"
"net/netip"
"strings"
)
// AddrFromIP converts an address to its canonical form, where IPv4-in-IPv6 is plain IPv4.
func AddrFromIP(ip net.IP) (netip.Addr, bool) {
addr, ok := netip.AddrFromSlice(ip)
return addr.Unmap(), ok
}
// PrefixFromIPNet converts to the canonical prefix: with the host bits cleared, and IPv4 as
// netip.Is4. This makes prefixes comparable, which net.IPNet is not (4 or 16 byte IPs and masks).
// The family follows the mask, so ::ffff:0:0/96 stays IPv6 while a 16 byte IP with a 32 bit mask is IPv4.
func PrefixFromIPNet(n net.IPNet) (netip.Prefix, bool) {
addr, ok := netip.AddrFromSlice(n.IP)
if !ok {
return netip.Prefix{}, false
}
ones, bits := n.Mask.Size()
switch {
case bits == 0: // non-canonical mask
return netip.Prefix{}, false
case bits == 32:
addr = addr.Unmap()
case addr.Is4() && bits == 128: // 4 byte IP with an IPv6 sized mask
ones -= 96
}
prefix := netip.PrefixFrom(addr, ones)
if !prefix.IsValid() {
return netip.Prefix{}, false
}
return prefix.Masked(), true
}
// ToIPNet converts a prefix, IPv4 gets 4 byte IP and mask.
func ToIPNet(p netip.Prefix) net.IPNet {
return net.IPNet{
IP: p.Addr().AsSlice(),
Mask: net.CIDRMask(p.Bits(), p.Addr().BitLen()),
}
}
// ParsePrefixes parses a comma-separated list of CIDRs.
func ParsePrefixes(list string) (map[netip.Prefix]struct{}, error) {
prefixes := make(map[netip.Prefix]struct{})
for _, entry := range strings.Split(list, ",") {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
prefix, err := netip.ParsePrefix(entry)
if err != nil {
return nil, fmt.Errorf("invalid prefix '%s': %w", entry, err)
}
prefixes[prefix.Masked()] = struct{}{}
}
return prefixes, nil
}
+83
View File
@@ -0,0 +1,83 @@
package reconcile
import (
"net"
"net/netip"
"slices"
"testing"
)
func TestPrefixFromIPNet(t *testing.T) {
tests := []struct {
name string
in net.IPNet
want string // empty: invalid
}{
{"v4 4-byte", net.IPNet{IP: net.IP{10, 1, 2, 0}, Mask: net.CIDRMask(24, 32)}, "10.1.2.0/24"},
{"v4 16-byte ip", net.IPNet{IP: net.ParseIP("10.1.2.0"), Mask: net.CIDRMask(24, 32)}, "10.1.2.0/24"},
{"v4 default as synthesized", net.IPNet{IP: net.IPv4zero, Mask: net.CIDRMask(0, 32)}, "0.0.0.0/0"},
{"v4 default as wgctrl", net.IPNet{IP: net.IP{0, 0, 0, 0}, Mask: net.CIDRMask(0, 32)}, "0.0.0.0/0"},
{"v4 4-byte ip with 128-bit mask", net.IPNet{IP: net.IP{10, 1, 2, 0}, Mask: net.CIDRMask(120, 128)}, "10.1.2.0/24"},
{"v4 4-byte ip with short 128-bit mask", net.IPNet{IP: net.IP{10, 1, 2, 0}, Mask: net.CIDRMask(64, 128)}, ""},
{"v4-mapped v6 stays v6", net.IPNet{IP: net.ParseIP("10.1.2.0"), Mask: net.CIDRMask(120, 128)}, "::ffff:10.1.2.0/120"},
{"v4-mapped v6 default range", net.IPNet{IP: net.ParseIP("::ffff:0:0"), Mask: net.CIDRMask(96, 128)}, "::ffff:0.0.0.0/96"},
{"v6", net.IPNet{IP: net.ParseIP("fd00::"), Mask: net.CIDRMask(64, 128)}, "fd00::/64"},
{"v6 default", net.IPNet{IP: net.IPv6zero, Mask: net.CIDRMask(0, 128)}, "::/0"},
{"host bits are cleared", net.IPNet{IP: net.IP{10, 1, 2, 77}, Mask: net.CIDRMask(24, 32)}, "10.1.2.0/24"},
{"non-canonical mask", net.IPNet{IP: net.IP{10, 1, 2, 0}, Mask: net.IPMask{255, 0, 255, 0}}, ""},
{"no ip", net.IPNet{Mask: net.CIDRMask(24, 32)}, ""},
{"v4 with 32-bit mask is unmapped", net.IPNet{IP: net.ParseIP("::ffff:10.1.2.0"), Mask: net.CIDRMask(24, 32)}, "10.1.2.0/24"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := PrefixFromIPNet(tt.in)
if ok != (tt.want != "") {
t.Fatalf("ok = %v, want %v (got %s)", ok, tt.want != "", got)
}
if ok && got != netip.MustParsePrefix(tt.want) {
t.Errorf("got %s, want %s", got, tt.want)
}
})
}
}
func TestToIPNetRoundTrip(t *testing.T) {
for _, s := range []string{"10.1.2.0/24", "0.0.0.0/0", "fd00::/64", "::/0", "192.0.2.1/32", "::ffff:0.0.0.0/96"} {
want := netip.MustParsePrefix(s)
n := ToIPNet(want)
if want.Addr().Is4() && (len(n.IP) != 4 || len(n.Mask) != 4) {
t.Errorf("%s: IPv4 is not 4 bytes: %v", s, n)
}
got, ok := PrefixFromIPNet(n)
if !ok || got != want {
t.Errorf("%s: round trip gave %s", s, got)
}
}
}
func TestParsePrefixes(t *testing.T) {
tests := []struct {
name string
in string
want []string
wantErr bool
}{
{name: "empty", in: ""},
{name: "one", in: "10.0.0.0/8", want: []string{"10.0.0.0/8"}},
{name: "spaces and trailing comma", in: " 10.0.0.0/8 , fd00::/8,", want: []string{"10.0.0.0/8", "fd00::/8"}},
{name: "host bits are cleared", in: "10.1.2.3/8", want: []string{"10.0.0.0/8"}},
{name: "not a prefix", in: "10.0.0.0", wantErr: true},
{name: "one bad entry", in: "10.0.0.0/8,nope", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParsePrefixes(tt.in)
if (err != nil) != tt.wantErr {
t.Fatalf("error = %v, wantErr %v", err, tt.wantErr)
}
if !slices.Equal(sorted(got), tt.want) {
t.Errorf("got %v, want %v", sorted(got), tt.want)
}
})
}
}
-42
View File
@@ -1,42 +0,0 @@
package wgChecks
import (
"fmt"
"net"
"git.ruekov.eu/ruakij/routingtabletowg/lib/netchecks"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
func PeerIndexByIP(peers []wgtypes.Peer, ip net.IP) (int, int, error) {
for index, peer := range peers {
if ipIndex, err := netchecks.IPNetIndexByIP(peer.AllowedIPs, ip); err == nil {
return index, ipIndex, nil
}
}
return -1, -1, fmt.Errorf("no peer by ip in list")
}
func PeerByIP(peers []wgtypes.Peer, ip net.IP) (*wgtypes.Peer, error) {
index, _, err := PeerIndexByIP(peers, ip)
if(err != nil) {
return nil, err
}
return &peers[index], nil
}
func PeerIndexByIPNet(peers []wgtypes.Peer, ipNet net.IPNet) (int, int, error) {
for index, peer := range peers {
if ipNetIndex, err := netchecks.IPNetIndexByIPNet(peer.AllowedIPs, ipNet); err == nil {
return index, ipNetIndex, nil
}
}
return -1, -1, fmt.Errorf("no peer by ipNet in list")
}
func PeerByIPNet(peers []wgtypes.Peer, ipNet net.IPNet) (*wgtypes.Peer, error) {
index, _, err := PeerIndexByIPNet(peers, ipNet)
if(err != nil) {
return nil, err
}
return &peers[index], nil
}