Compare commits
17
Commits
release-0.2
..
0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d422b4db8c | ||
|
|
c7b16dd275 | ||
|
|
925d266ab4 | ||
|
|
74f0a2f5ab | ||
|
|
222877a847 | ||
|
|
08c871f84b | ||
|
|
acfd556452 | ||
|
|
92cd1da6f0 | ||
|
|
f8854b4fa6 | ||
|
|
dcc0b6607b | ||
|
|
021bd254f1 | ||
|
|
3b9715843f | ||
|
|
171bf82dc1 | ||
|
|
2d5aacf397 | ||
|
|
a5f2f5f102 | ||
|
|
868a80f527 | ||
|
|
07d282555c |
@@ -0,0 +1,3 @@
|
||||
.git
|
||||
/bin
|
||||
/dist
|
||||
Executable
+30
@@ -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
|
||||
@@ -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: ["*"]
|
||||
@@ -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
|
||||
@@ -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[@]}"
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
/bin
|
||||
/dist
|
||||
.*
|
||||
!.gitignore
|
||||
!.githooks
|
||||
!.github
|
||||
!.dockerignore
|
||||
!.golangci.yml
|
||||
@@ -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
|
||||
+22
-18
@@ -1,25 +1,29 @@
|
||||
# ---- Base ----
|
||||
FROM alpine:3 AS base
|
||||
WORKDIR /app
|
||||
# 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
|
||||
|
||||
# Install necessary packages
|
||||
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"]
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -40,20 +42,21 @@ In case routes clash or cant be added to Wireguard, Warnings will be logged.
|
||||
|
||||
Variable|Description|Type|Default
|
||||
-|-|-|-
|
||||
`INTERFACE`* | Wireguard-Interface Name | String |
|
||||
`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 | -
|
||||
|
||||
*\* Required*
|
||||
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.2. Docker
|
||||
|
||||
Depending on the needs, the Container can be run in `network_mode: host` to be able to access routing-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.
|
||||
|
||||
Additionally the capability `NET_ADMIN` is required for interacting with interfaces.
|
||||
The only required capability is `NET_ADMIN`, for interacting with interfaces. All others can be dropped.
|
||||
|
||||
<details><summary><code>docker-compose.yml</code></summary>
|
||||
|
||||
@@ -62,9 +65,11 @@ version: '3'
|
||||
|
||||
services:
|
||||
routingtabletowg:
|
||||
image: ruakij/routingtabletowg
|
||||
image: ghcr.io/ruakij/routingtabletowg
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
environment:
|
||||
@@ -76,20 +81,70 @@ services:
|
||||
|
||||
### 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
|
||||
|
||||

|
||||
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
|
||||
|
||||

|
||||
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.
|
||||
|
||||
@@ -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 \
|
||||
.
|
||||
@@ -1,7 +0,0 @@
|
||||
TAG="ruakij/routingtabletowg"
|
||||
EXTRA_ARGS="$@"
|
||||
|
||||
docker build \
|
||||
--tag $TAG \
|
||||
$EXTRA_ARGS \
|
||||
.
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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...))
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+17
-189
@@ -1,28 +1,23 @@
|
||||
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",
|
||||
@@ -32,14 +27,10 @@ var envDefaults = map[string]string{
|
||||
|
||||
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 {
|
||||
@@ -65,190 +56,27 @@ func main() {
|
||||
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")
|
||||
syncCurrentRoutesToHandler(routeSubChan, routeList)
|
||||
|
||||
if(periodicSync > 0){
|
||||
go runPeriodicSync(periodicSync, link, routeSubChan)
|
||||
}
|
||||
|
||||
select {}
|
||||
}
|
||||
|
||||
func runPeriodicSync(seconds int, link netlink.Link, routeSubChan chan netlink.RouteUpdate){
|
||||
interval := time.Duration(seconds) * time.Second
|
||||
for {
|
||||
time.Sleep(interval)
|
||||
|
||||
// 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("Periodically syncing all routes")
|
||||
syncCurrentRoutesToHandler(routeSubChan, routeList)
|
||||
}
|
||||
}
|
||||
|
||||
func syncCurrentRoutesToHandler(routeSubChan chan netlink.RouteUpdate, routeList []netlink.Route){
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 22 KiB |
@@ -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
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 22 KiB |
@@ -1,22 +1,21 @@
|
||||
module git.ruekov.eu/ruakij/routingtabletowg
|
||||
|
||||
go 1.19
|
||||
go 1.27.3
|
||||
|
||||
require (
|
||||
github.com/vishvananda/netlink v1.1.0
|
||||
golang.org/x/sys v0.6.0
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230215201556-9c5414ab4bde
|
||||
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.1.0 // indirect
|
||||
github.com/mdlayher/genetlink v1.3.1 // indirect
|
||||
github.com/mdlayher/netlink v1.7.1 // indirect
|
||||
github.com/mdlayher/socket v0.4.0 // indirect
|
||||
github.com/vishvananda/netns v0.0.4 // indirect
|
||||
golang.org/x/crypto v0.7.0 // indirect
|
||||
golang.org/x/net v0.8.0 // indirect
|
||||
golang.org/x/sync v0.1.0 // indirect
|
||||
golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b // 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
|
||||
)
|
||||
|
||||
@@ -1,29 +1,28 @@
|
||||
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.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
|
||||
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
|
||||
github.com/mdlayher/genetlink v1.3.1 h1:roBiPnual+eqtRkKX2Jb8UQN5ZPWnhDCGj/wR6Jlz2w=
|
||||
github.com/mdlayher/genetlink v1.3.1/go.mod h1:uaIPxkWmGk753VVIzDtROxQ8+T+dkHqOI0vB1NA9S/Q=
|
||||
github.com/mdlayher/netlink v1.7.1 h1:FdUaT/e33HjEXagwELR8R3/KL1Fq5x3G5jgHLp/BTmg=
|
||||
github.com/mdlayher/netlink v1.7.1/go.mod h1:nKO5CSjE/DJjVhk/TNp6vCE1ktVxEA8VEh8drhZzxsQ=
|
||||
github.com/mdlayher/socket v0.4.0 h1:280wsy40IC9M9q1uPGcLBwXpcTQDtoGwVt+BNoITxIw=
|
||||
github.com/mdlayher/socket v0.4.0/go.mod h1:xxFqz5GRCUN3UEOm9CZqEJsAbe1C8OwSK46NlmWuVoc=
|
||||
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/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/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU=
|
||||
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
|
||||
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
||||
golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A=
|
||||
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
|
||||
golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
|
||||
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||
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-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b h1:J1CaxgLerRR5lgx3wnr6L04cJFbWoceSK9JWBdglINo=
|
||||
golang.zx2c4.com/wireguard v0.0.0-20230325221338-052af4a8072b/go.mod h1:tqur9LnfstdR9ep2LaJT4lFUl0EjlHtge+gAjmsHUG4=
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230215201556-9c5414ab4bde h1:ybF7AMzIUikL9x4LgwEmzhXtzRpKNqngme1VGDWz+Nk=
|
||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230215201556-9c5414ab4bde/go.mod h1:mQqgjkW8GQQcJQsbBvK890TKqUK1DfKWkuBGbOkuMHQ=
|
||||
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=
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
v2.15.0
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = append(Errors,
|
||||
fmt.Errorf("failed reading 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(text == "" || strings.HasPrefix(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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package wgChecks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"git.ruekov.eu/ruakij/routingtabletowg/lib/wgchecks/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
|
||||
}
|
||||
Reference in New Issue
Block a user