diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..b334b1e --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "utils/reverse-traceroute-docker/revtrvp"] + path = utils/reverse-traceroute-docker/revtrvp + url = git@github.com:NEU-SNS/revtrvp.git diff --git a/utils/reverse-traceroute-docker/.gitignore b/utils/reverse-traceroute-docker/.gitignore new file mode 100644 index 0000000..0907414 --- /dev/null +++ b/utils/reverse-traceroute-docker/.gitignore @@ -0,0 +1,4 @@ +docker-compose.yml +certs/ca.crt +certs/client.crt +certs/client.key diff --git a/utils/reverse-traceroute-docker/Makefile b/utils/reverse-traceroute-docker/Makefile new file mode 100644 index 0000000..5af0a1c --- /dev/null +++ b/utils/reverse-traceroute-docker/Makefile @@ -0,0 +1,101 @@ +# Docker Compose file for the Reverse Traceroute + PEERING stack. +COMPOSE_FILE := docker-compose.yml +TEMPLATES = templates/ +CONFIGS = config/ +# Hardcoded data-plane egress mux (see docs/superpowers/specs/2026-08-06-...). +EGRESS_MUX := ufmg01 + +.PHONY: clean up down build logs peering-shell announce withdraw \ + bgp-status bgp-start dataplane-test setup-docker openvpn + +clean: + rm -f $(COMPOSE_FILE) + +# Start the stack detached, establish ufmg01 egress for all backends, then +# follow logs. Ctrl-C stops log follow only; use `make down` to tear down. +# +# Host-level prerequisite: disable bridge-nf-call-iptables first. On hosts +# where it's enabled (e.g. because libvirt turned it on for its own bridges), +# reply traffic that `peering` relays back to `revtrvp` across a backend +# Docker bridge gets silently dropped one-way -- confirmed via packet capture +# (revtrvp<->peering direct traffic works, but forwarded/relayed traffic +# doesn't) -- even though BGP/the tunnel/everything else is healthy. This is a +# host-wide sysctl (not scoped to this project's containers/networks) and +# does NOT persist across reboots, so we re-assert it every `make up` rather +# than relying on it being set already. See README Troubleshooting. +up: + sudo sysctl -w net.bridge.bridge-nf-call-iptables=0 + docker compose -f $(COMPOSE_FILE) up -d + @echo "waiting for peering container..." + @for i in $$(seq 1 60); do \ + if docker compose -f $(COMPOSE_FILE) exec -T peering true 2>/dev/null; then \ + break; \ + fi; \ + if [ $$i -eq 60 ]; then \ + echo "peering container not ready after 60s"; \ + exit 1; \ + fi; \ + sleep 1; \ + done + $(MAKE) openvpn mux=$(EGRESS_MUX) + docker compose -f $(COMPOSE_FILE) logs -f + +# Bring up OpenVPN to mux=. When mux is ufmg01, also install backend +# policy routes so all revtrvp traffic egresses via that mux. +openvpn: + @test -n "$(mux)" || (echo 'usage: make openvpn mux='; exit 1) + docker compose -f $(COMPOSE_FILE) exec peering \ + /peering-openvpn-up.sh "$(mux)" + +# Start BIRD/BGP toward whatever mux tunnels are already up. +bgp-start: + docker compose -f $(COMPOSE_FILE) exec peering ./peering bgp start + +# Stop and remove containers, networks, and volumes created by compose. +down: + docker compose -f $(COMPOSE_FILE) down + +# Build or rebuild service images before starting. +build: + docker compose -f $(COMPOSE_FILE) build + +logs: + docker compose -f $(COMPOSE_FILE) logs revtrvp + +peering-shell: + docker compose -f $(COMPOSE_FILE) exec peering bash + +revtrvp-shell: + docker compose -f $(COMPOSE_FILE) exec revtrvp bash + +# Announce the PEERING prefix to the egress mux (manual data-plane step). +# -R skips the client's built-in data-plane config; our Makefile/openvpn +# helper owns tunnel + policy routing. +announce: + docker compose -f $(COMPOSE_FILE) exec peering \ + ./peering prefix announce -R -m vtrtoronto 184.164.231.0/24 + +# Withdraw the PEERING prefix from all muxes. +withdraw: + docker compose -f $(COMPOSE_FILE) exec peering \ + ./peering prefix withdraw 184.164.231.0/24 + +# Show BGP session status (Idle before announce is normal). +bgp-status: + docker compose -f $(COMPOSE_FILE) exec peering ./peering bgp status + +# Basic data-plane check: ping from inside the revtrvp container. +dataplane-test: + docker compose -f $(COMPOSE_FILE) exec revtrvp ping -c 4 1.1.1.1 + +# Generate docker-compose.yml from templates via scripts/setup_docker.py. +# Expands one backend-* network, peering .254 attachment, and revtrvp (.1) +# service per prefix in config/prefixes.txt. +setup-docker: clean + python3 scripts/setup_docker.py \ + --compose-template $(TEMPLATES)/docker-compose.template.yml \ + --backend-template $(TEMPLATES)/backend.template.yml \ + --peering-attach-template $(TEMPLATES)/peering-backend-attach.template.yml \ + --revtrvp-template $(TEMPLATES)/revtrvp.template.yml \ + --prefixes $(CONFIGS)/prefixes.txt \ + --output $(COMPOSE_FILE) diff --git a/utils/reverse-traceroute-docker/README.md b/utils/reverse-traceroute-docker/README.md new file mode 100644 index 0000000..3e7d8a7 --- /dev/null +++ b/utils/reverse-traceroute-docker/README.md @@ -0,0 +1,199 @@ +# Reverse Traceroute VP behind PEERING (Docker Compose) + +This repo runs a [Reverse Traceroute](https://github.com/NEU-SNS/revtrvp) (RevTr) +vantage point (`revtrvp`) behind the [PEERING](https://peering.ee.columbia.edu/) +testbed using Docker Compose. A `peering` container acts as the gateway/router: +Makefile targets establish OpenVPN tunnels and BGP, and source-route each VP's +traffic out the hardcoded data-plane egress mux, so RevTr probes and control +connections originate from our allocated PEERING prefix(es). + +- **Prefixes:** listed in `config/prefixes.txt` (one backend network + `revtrvp-*` + per prefix) +- **Data-plane egress mux:** `ufmg01` (tunnel device `tap7`, policy table `10007`) +- **Probing rate:** 100 pps + +## Topology + +``` + host internet (NAT) + │ + afrontend (NAT bridge, 172.16.50.0/24) + │ (peering's control plane: + │ OpenVPN->muxes, DNS, git) + ┌──────┴───────┐ + │ peering │ afrontend: 172.16.50.x (default route) + │ container │ tap7: OpenVPN tunnel to ufmg01 (egress) + │ (BIRD+OVPN) │ backend-*: .254 (gateway per prefix) + └──────┬───────┘ + backend-* (one no-NAT bridge per prefix) + ┌──────┴───────┐ + │ revtrvp-* │ eth0: .1, default route -> peering .254 + │ (per prefix)│ + └──────────────┘ +``` + +Networks: + +- **`afrontend`** (NAT enabled): carries the `peering` container's *own* control + traffic (OpenVPN to muxes, DNS, git). Masqueraded out the host normally. +- **`backend-*`** (NAT disabled, one per prefix): carries that prefix between + `peering` (`.254`) and the matching `revtrvp-*` (`.1`). Masquerade is off so + the VP's source IP is preserved. IPAM/host-side gateway is parked at `.253`; + the real gateway each VP routes through is `peering` at `.254`. + +All `revtrvp-*` forwarded traffic egresses via **`ufmg01`** (policy routing: +`iif → table 10007 → tap7`). Other mux tunnels (e.g. `vtrtoronto`) are +optional and used for BGP reachability/announcements, not data-plane egress. + +## Prerequisites + +- Docker + Docker Compose. +- PEERING client certificates in `certs/` (`ca.crt`, `client.crt`, `client.key`). +- Prefixes allocated to you listed in `config/prefixes.txt`. +- Passwordless (or interactive) `sudo` for `sysctl`: `make up` disables + `net.bridge.bridge-nf-call-iptables` on the host as a prerequisite step (see + Troubleshooting for why) and will prompt for a password if needed. + +## Files of interest + +- `templates/docker-compose.template.yml` / `templates/backend.template.yml` / + `templates/peering-backend-attach.template.yml` / + `templates/revtrvp.template.yml` — sources for the generated compose file; + one `backend-*` network, peering attachment, and `revtrvp-*` service is + expanded per prefix in `config/prefixes.txt`. +- `scripts/setup_docker.py` — renders `docker-compose.yml` from those templates + (`make setup-docker`). +- `docker-compose.yml` — generated stack (frontend + one backend network and + revtrvp per prefix). +- `scripts/peering-gateway-entrypoint.sh` — pins the default route to + `afrontend`, enables forwarding, stays alive (no OpenVPN/BGP). +- `scripts/peering-openvpn-up.sh` — brings up a mux tunnel; for `ufmg01`, also + installs backend→egress policy routes (`make openvpn` / `make up`). +- `scripts/revtrvp-entrypoint.sh` — points each VP's default route at the + gateway, then starts the VP. +- `config/prefixes.txt` / `config/prefixes6.txt` — PEERING prefix databases + (mounted into the client; also drive backend networks via `setup-docker`). +- `config/plvp.config` — RevTr VP config (interface `eth0`, scamper rate 100). +- `client/` and `revtrvp/` — git submodules for the PEERING client and the + RevTr VP. + +## Usage + +Regenerate `docker-compose.yml` after changing prefixes or templates: + +```bash +make setup-docker +``` + +Build the images (first time; note `revtrvp` compiles scamper and can take a +few minutes): + +```bash +make build +``` + +Start the stack. `make up` disables `net.bridge.bridge-nf-call-iptables`, starts +Compose detached, brings up the **`ufmg01`** egress tunnel and backend policy +routes, then follows logs. Ctrl-C stops log follow only; use `make down` to +tear down. BGP and prefix announcement are separate manual steps. + +```bash +make up +# optional extra BGP muxes (tunnel only; egress stays ufmg01): +# make openvpn mux=vtrtoronto +make bgp-start +``` + +Announce a prefix over BGP (live action) and wait for convergence (~180s): + +```bash +make announce +``` + +Check status and the data plane: + +```bash +make bgp-status # sessions for connected muxes; Established after converge +make dataplane-test # ping 1.1.1.1 from inside a revtrvp container +make logs # revtrvp should connect to plcontroller.revtr.ccs.neu.edu +``` + +Tear down: + +```bash +make withdraw # stop announcing the prefix +make down # stop and remove containers/networks +``` + +> Each `revtrvp-*` uses `restart: unless-stopped`. Before the prefix is +> announced it cannot resolve/reach the controller and will restart-loop; this +> is expected and it will connect once `make announce` has converged. + +## Verification / troubleshooting + +Inspect the egress tunnel and routing: + +```bash +docker compose -f docker-compose.yml exec peering ip -4 addr show tap7 +docker compose -f docker-compose.yml exec peering ip rule show +docker compose -f docker-compose.yml exec peering ip route show table 10007 +``` + +Expected: `tap7` has an address in `100.71.128.0/24`; an `iif ` +rule points at table `10007` for each backend; table `10007` has +`default via 100.71.128.1 dev tap7`. + +Inspect a VP (service name is prefix-derived, e.g. `revtrvp-184-164-231-0-24`): + +```bash +docker compose -f docker-compose.yml exec revtrvp-184-164-231-0-24 ip route +# default via 184.164.231.254 +``` + +Confirm probes carry the prefix source IP (after announce): + +```bash +docker compose -f docker-compose.yml exec peering tcpdump -ni tap7 +``` + +Common issues: + +1. Prefix not announced yet → `revtrvp-*` restart-loops on DNS failure. Run + `make announce` and wait for convergence. +2. `announce` rejects the prefix → ensure it is present in `config/prefixes.txt`. +3. No egress after announce → check `rp_filter` is relaxed and source-routing is + installed (commands above); check `make bgp-status` shows `Established`. + Ensure `make up` (or `make openvpn mux=ufmg01`) ran successfully. +4. **Traceroute/ping from `revtrvp` reaches the `peering` gateway (`.254`) but + never progresses further, even though the prefix is announced, BGP shows + `Established`, and #3 above looks fine.** This is caused by + `net.bridge.bridge-nf-call-iptables=1` on the host (commonly enabled by + `libvirt` for its own bridges): it silently drops reply traffic that + `peering` *relays* back to `revtrvp` across a backend Docker bridge, + even though direct `peering`<->`revtrvp` traffic on that same bridge works + fine. Confirmed via packet capture in a real debugging session on this + exact stack: replies correctly arrived back through the tunnel and were + re-transmitted by `peering` onto the bridge with correct source/destination + MACs, yet never arrived at `revtrvp`'s interface. Scoped mitigations (a + `DOCKER-USER` iptables accept rule, `rp_filter=0`, `send_redirects=0`) did + **not** fix it in isolation — only disabling + `net.bridge.bridge-nf-call-iptables` globally did. `make up` now runs + `sudo sysctl -w net.bridge.bridge-nf-call-iptables=0` before starting + compose specifically to avoid this. If you bypassed `make up` (e.g. ran + `docker compose up` directly) and hit this, run that command manually, or + restart the stack via `make up`. + - This is a **host-wide** setting (affects all Docker bridges and any + `libvirt` VM networks on the machine, not just this project) and does + **not** persist across reboots — `make up` re-asserts it every time + rather than assuming it's already set. + +## Configuration knobs + +- **Egress mux** is hardcoded as `EGRESS_MUX=ufmg01` in the Makefile / + `scripts/peering-openvpn-up.sh` (tap/table derived from the mux OpenVPN + config: `tap7` → `100.71.128.1` / table `10007`). +- **Extra muxes** for BGP: `make openvpn mux=` (does not change + data-plane egress). +- **Prefixes / addressing:** edit `config/prefixes.txt` and re-run + `make setup-docker`. Each prefix gets a `backend-*` network, peering at + `.254`, and a `revtrvp-*` at `.1` with `REVTR_GATEWAY=.254`. diff --git a/utils/reverse-traceroute-docker/certs/.gitignore b/utils/reverse-traceroute-docker/certs/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/utils/reverse-traceroute-docker/config/plvp.config b/utils/reverse-traceroute-docker/config/plvp.config new file mode 100644 index 0000000..fbb8c6b --- /dev/null +++ b/utils/reverse-traceroute-docker/config/plvp.config @@ -0,0 +1,18 @@ +environment: + debug: true +local: + autoconnect: true + proto: tcp + host: 'plcontroller.revtr.ccs.neu.edu' + port: 4380 + pprofaddr: ':55557' + startscamp: true + rootca: /server.crt + interface: eth0 + senderonly: false +scamper: + host: 'plcontroller.revtr.ccs.neu.edu' + binpath: /usr/local/bin/scamper + port: 4381 + rate: 20 + cafile: /server.crt diff --git a/utils/reverse-traceroute-docker/revtrvp b/utils/reverse-traceroute-docker/revtrvp new file mode 160000 index 0000000..ce0e02e --- /dev/null +++ b/utils/reverse-traceroute-docker/revtrvp @@ -0,0 +1 @@ +Subproject commit ce0e02eaa715b4d77ca94d46b88336230257cde8 diff --git a/utils/reverse-traceroute-docker/scripts/peering-gateway-entrypoint.sh b/utils/reverse-traceroute-docker/scripts/peering-gateway-entrypoint.sh new file mode 100755 index 0000000..fb4ba10 --- /dev/null +++ b/utils/reverse-traceroute-docker/scripts/peering-gateway-entrypoint.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# ============================================================================= +# PEERING gateway entrypoint +# ----------------------------------------------------------------------------- +# Runs inside the `peering` Compose service. Responsibilities: +# 1. Force the container's own control traffic (OpenVPN, DNS, git) out the +# NAT'd `afrontend` interface (172.16.50.0/24). +# 2. Enable IPv4 forwarding and relax global reverse-path filtering so later +# Makefile-driven mux/backend setup can install asymmetric routes. +# 3. Stay alive so `make openvpn` / `make bgp-start` / shells work. +# +# OpenVPN tunnels, backend policy routing, and BGP are NOT started here; see +# `make up` / `make openvpn` / `make bgp-start` and scripts/peering-openvpn-up.sh. +# ============================================================================= +set -eu +set -x + +CLIENT_DIR=/root/client +# Compose afrontend IPAM subnet; used to find the control-plane iface by addr. +AFRONTEND_PREFIX="172.16.50" + +cd "$CLIENT_DIR" + +# ----- 1. Identify afrontend and pin the default route ---------------------- +# Interface names inside containers are not deterministic; resolve by subnet. +AF_IF=$(ip -o -4 addr show | awk -v pfx="${AFRONTEND_PREFIX}." ' + $4 ~ "^"pfx { print $2; exit } +') +if [[ -z "${AF_IF}" ]]; then + echo "error: could not find afrontend iface on ${AFRONTEND_PREFIX}.0/24" >&2 + exit 1 +fi +# Docker assigns .1 of each user bridge subnet to the host side; use it as GW. +AF_GW=$(ip -o -4 addr show dev "$AF_IF" | awk '{print $4}' | sed 's#\.[0-9]*/.*#.1#') +ip route replace default via "$AF_GW" dev "$AF_IF" + +# ----- 2. Forwarding + relaxed RPF (global) --------------------------------- +sysctl -w net.ipv4.ip_forward=1 +sysctl -w net.ipv4.conf.all.rp_filter=0 + +# ----- 3. Stay alive -------------------------------------------------------- +# Keep the service up for exec targets. Prefer existing up-down log if present; +# otherwise block forever. +mkdir -p var +touch var/up-down.log +exec tail -F var/up-down.log diff --git a/utils/reverse-traceroute-docker/scripts/peering-openvpn-up.sh b/utils/reverse-traceroute-docker/scripts/peering-openvpn-up.sh new file mode 100755 index 0000000..7fc31e6 --- /dev/null +++ b/utils/reverse-traceroute-docker/scripts/peering-openvpn-up.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# ============================================================================= +# Bring up a PEERING OpenVPN mux tunnel, and install backend egress routes +# when the mux is the hardcoded data-plane egress (ufmg01). +# +# Invoked from the host via: +# docker compose exec peering /peering-openvpn-up.sh +# +# Non-egress muxes only establish the tunnel (for BGP reachability). All +# revtrvp-forwarded traffic continues to egress via ufmg01 / table 10007. +# ============================================================================= +set -eu +set -x + +CLIENT_DIR=/root/client +# Hardcoded data-plane egress mux (no RPF that breaks return traffic). +EGRESS_MUX="${EGRESS_MUX:-ufmg01}" +AFRONTEND_PREFIX="172.16.50" +TAP_WAIT_SECS="${TAP_WAIT_SECS:-60}" + +usage() { + echo "usage: $0 " >&2 + exit 1 +} + +test $# -ge 1 || usage +MUX="$1" + +cd "$CLIENT_DIR" + +# Resolve tap device for this mux from the PEERING mux2dev DB (built on demand +# the same way peering-config does when the file is missing). +mux2dev_db="var/mux2dev.txt" +openvpn_cfgs="configs/openvpn" +mkdir -p var +if [[ ! -s "$mux2dev_db" ]]; then + : > "$mux2dev_db" + for fn in "$openvpn_cfgs"/*.conf; do + name=$(basename "$fn" .conf) + echo -n "$name " >> "$mux2dev_db" + grep -Ee "^dev " "$fn" | cut -d " " -f 2 >> "$mux2dev_db" + done +fi + +TAPDEV=$(awk -v m="$MUX" '$1 == m { print $2; exit }' "$mux2dev_db") +if [[ -z "$TAPDEV" ]]; then + echo "error: unknown mux '$MUX' (not in $mux2dev_db)" >&2 + exit 1 +fi + +# Match PEERING client's tap/table convention: +# gateway = 100.(64 + devid).128.1 ; table = 10000 + devid +DEVID="${TAPDEV##tap}" +GWIP="100.$((64 + DEVID)).128.1" +TABLE="$((10000 + DEVID))" + +# ----- OpenVPN -------------------------------------------------------------- +# `peering openvpn up` is idempotent: already-up tunnels exit 0 via `term`. +./peering openvpn up "$MUX" + +for i in $(seq 1 "$TAP_WAIT_SECS"); do + if ip -4 addr show "$TAPDEV" 2>/dev/null | grep -q "inet "; then + break + fi + if [[ "$i" -eq "$TAP_WAIT_SECS" ]]; then + echo "error: $TAPDEV for mux $MUX did not get an IPv4 address in ${TAP_WAIT_SECS}s" >&2 + exit 1 + fi + sleep 1 +done + +sysctl -w "net.ipv4.conf.${TAPDEV}.rp_filter=0" || true + +# Non-egress muxes: tunnel only (BGP reachability). +if [[ "$MUX" != "$EGRESS_MUX" ]]; then + echo "mux $MUX up on $TAPDEV (non-egress; skipping backend policy routes)" + exit 0 +fi + +# ----- Egress policy routes for ALL backend ifaces -------------------------- +# Backend = not lo, not afrontend (172.16.50/24), not tap*. +mapfile -t BACKEND_IFS < <( + ip -o -4 addr show | awk -v pfx="${AFRONTEND_PREFIX}." ' + $2 == "lo" { next } + $2 ~ /^tap/ { next } + $4 ~ "^"pfx { next } + { print $2 } + ' | sort -u +) + +if [[ ${#BACKEND_IFS[@]} -eq 0 ]]; then + echo "error: no backend interfaces found while installing egress routes" >&2 + exit 1 +fi + +sysctl -w net.ipv4.conf.all.rp_filter=0 || true +ip route replace default via "$GWIP" dev "$TAPDEV" table "$TABLE" + +for BB_IF in "${BACKEND_IFS[@]}"; do + sysctl -w "net.ipv4.conf.${BB_IF}.rp_filter=0" || true + # Idempotent: rule may already exist from a previous make openvpn. + ip rule add iif "$BB_IF" table "$TABLE" 2>/dev/null || true +done + +echo "egress mux $MUX up: backends [${BACKEND_IFS[*]}] -> table $TABLE via $GWIP dev $TAPDEV" diff --git a/utils/reverse-traceroute-docker/scripts/revtrvp-entrypoint.sh b/utils/reverse-traceroute-docker/scripts/revtrvp-entrypoint.sh new file mode 100755 index 0000000..f3845fa --- /dev/null +++ b/utils/reverse-traceroute-docker/scripts/revtrvp-entrypoint.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# ============================================================================= +# revtrvp wrapper entrypoint +# ----------------------------------------------------------------------------- +# Points the container's default route at the PEERING gateway container so all +# probe and control traffic egresses via the PEERING prefix, then hands off to +# the revtrvp binary. Requires `iproute2` in the image (added in Task 3) and +# NET_ADMIN (granted in compose). +# ============================================================================= +set -eu + +# The PEERING gateway container's bbackend IP (overridable via compose env). +GATEWAY="${REVTR_GATEWAY:-184.164.231.254}" + +# Replace Docker's default route (via the unused .253 bridge gateway) with the +# route through the peering container, which forwards/source-routes our traffic. +ip route replace default via "$GATEWAY" + +# Hand off to revtrvp with the args supplied by compose `command:`. +exec /revtrvp "$@" diff --git a/utils/reverse-traceroute-docker/scripts/setup_docker.py b/utils/reverse-traceroute-docker/scripts/setup_docker.py new file mode 100755 index 0000000..e949758 --- /dev/null +++ b/utils/reverse-traceroute-docker/scripts/setup_docker.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +"""Generate docker-compose.yml from templates and config/prefixes.txt. + +Reads the compose template and expands, per IPv4 prefix in prefixes.txt: + +* one full backend network definition (templates/backend.template.yml) +* one peering-service attachment to that network + (templates/peering-backend-attach.template.yml) +* one revtrvp service attached only to that backend + (templates/revtrvp.template.yml) + +Placeholders in templates/backend.template.yml: + __NETWORK_NAME__ -> unique Compose network key starting with ``backend-`` + __BRIDGE_NAME__ -> unique host bridge name (<=15 chars; Docker IFNAMSIZ) + __PREFIX__ -> the prefix as written (e.g. 184.164.231.0/24) + __PREFIX_GATEWAY__ -> first three octets + .253 (e.g. 184.164.231.253) + +Placeholders in templates/peering-backend-attach.template.yml: + __NETWORK_NAME__ -> same Compose network key as the backend network + __PREFIX_PEERING_IP__ -> first three octets + .254 (peering's address on + that backend, e.g. 184.164.231.254) + +Placeholders in templates/revtrvp.template.yml: + __SERVICE_NAME__ -> unique Compose service key starting with ``revtrvp-`` + __NETWORK_NAME__ -> the single backend network for this VP's prefix + __PREFIX__ -> the prefix as written + __PREFIX_PEERING_IP__ -> peering's .254 address (used as REVTR_GATEWAY) + __PREFIX_REVTRVP_IP__ -> first three octets + .1 (this VP's address) + +Markers in templates/docker-compose.template.yml: + __BACKEND_NETS__ -> under top-level ``networks:`` + __PEERING_BACKEND_NETS__ -> under ``services.peering.networks`` + __REVTRVP_SERVICES__ -> under top-level ``services:`` (after peering) +""" + +from __future__ import annotations + +import argparse +import hashlib +from pathlib import Path + + +# Markers in the compose template where rendered blocks are inserted. +BACKEND_NETS_MARKER = "__BACKEND_NETS__" +PEERING_BACKEND_NETS_MARKER = "__PEERING_BACKEND_NETS__" +REVTRVP_SERVICES_MARKER = "__REVTRVP_SERVICES__" + +# Placeholders shared / used across the per-prefix templates. +SERVICE_NAME_PLACEHOLDER = "__SERVICE_NAME__" +NETWORK_NAME_PLACEHOLDER = "__NETWORK_NAME__" +BRIDGE_NAME_PLACEHOLDER = "__BRIDGE_NAME__" +PREFIX_PLACEHOLDER = "__PREFIX__" +PREFIX_GATEWAY_PLACEHOLDER = "__PREFIX_GATEWAY__" +PREFIX_PEERING_IP_PLACEHOLDER = "__PREFIX_PEERING_IP__" +PREFIX_REVTRVP_IP_PLACEHOLDER = "__PREFIX_REVTRVP_IP__" + +# Compose key prefixes for generated per-prefix resources. +NETWORK_NAME_PREFIX = "backend-" +SERVICE_NAME_PREFIX = "revtrvp-" + +# Linux interface name limit (IFNAMSIZ); Docker rejects longer bridge names. +MAX_BRIDGE_NAME_LEN = 15 + +# Well-known host addresses within each /24 PEERING prefix. +HOST_SIDE_GATEWAY_OCTET = 253 # parked IPAM/host-bridge gateway +PEERING_CONTAINER_OCTET = 254 # peering service address on each backend +REVTRVP_CONTAINER_OCTET = 1 # revtrvp service address on its backend + + +def prefix_host(prefix: str, last_octet: int) -> str: + """Build ``a.b.c.`` from an IPv4 prefix like ``a.b.c.0/24``.""" + # Split on '.' so "184.164.231.0/24" yields ["184", "164", "231", "0/24"]. + octets = prefix.split(".") + if len(octets) < 3: + raise ValueError(f"prefix must have at least three octets: {prefix!r}") + return f"{octets[0]}.{octets[1]}.{octets[2]}.{last_octet}" + + +def prefix_gateway(prefix: str) -> str: + """Host-side bridge gateway IP (.253) for ``prefix``.""" + return prefix_host(prefix, HOST_SIDE_GATEWAY_OCTET) + + +def prefix_peering_ip(prefix: str) -> str: + """Peering container address (.254) on the backend for ``prefix``.""" + return prefix_host(prefix, PEERING_CONTAINER_OCTET) + + +def prefix_revtrvp_ip(prefix: str) -> str: + """Revtrvp container address (.1) on the backend for ``prefix``.""" + return prefix_host(prefix, REVTRVP_CONTAINER_OCTET) + + +def sanitize_prefix_for_name(prefix: str) -> str: + """Turn ``184.164.231.0/24`` into a Compose-safe token ``184-164-231-0-24``.""" + return prefix.replace(".", "-").replace("/", "-") + + +def network_name_for_prefix(prefix: str) -> str: + """Build a unique Compose network key for ``prefix``.""" + return f"{NETWORK_NAME_PREFIX}{sanitize_prefix_for_name(prefix)}" + + +def service_name_for_prefix(prefix: str) -> str: + """Build a unique Compose service key for the revtrvp of ``prefix``.""" + return f"{SERVICE_NAME_PREFIX}{sanitize_prefix_for_name(prefix)}" + + +def bridge_name_for_prefix(prefix: str) -> str: + """Build a unique host bridge name for ``prefix`` within IFNAMSIZ. + + Prefer a short readable form ``bk`` + network octets without dots. If that + would exceed Docker's 15-character limit, fall back to a stable short hash. + """ + # "184.164.231.0/24" -> network address octets before the mask. + network_addr = prefix.split("/", 1)[0] + compact = "bk" + network_addr.replace(".", "") + if len(compact) <= MAX_BRIDGE_NAME_LEN: + return compact + # Stable, collision-resistant short name when the compact form is too long. + digest = hashlib.sha1(prefix.encode("utf-8")).hexdigest()[ + : MAX_BRIDGE_NAME_LEN - 2 + ] + return f"bk{digest}" + + +def load_prefixes(prefixes_path: Path) -> list[str]: + """Return non-empty, non-comment lines from the prefixes file.""" + prefixes: list[str] = [] + for line in prefixes_path.read_text(encoding="utf-8").splitlines(): + # Strip inline whitespace; ignore blanks and '#' comment lines. + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + prefixes.append(stripped) + return prefixes + + +def render_template_per_prefix(prefixes: list[str], template: str) -> str: + """Render ``template`` once per prefix, substituting all known placeholders. + + Placeholders absent from a given template are left alone by ``str.replace`` + only when they do not appear; we still compute every substitution so one + helper can drive backend, peering-attach, and revtrvp templates. + """ + blocks: list[str] = [] + for prefix in prefixes: + rendered = ( + template.replace(SERVICE_NAME_PLACEHOLDER, service_name_for_prefix(prefix)) + .replace(NETWORK_NAME_PLACEHOLDER, network_name_for_prefix(prefix)) + .replace(BRIDGE_NAME_PLACEHOLDER, bridge_name_for_prefix(prefix)) + .replace(PREFIX_PLACEHOLDER, prefix) + .replace(PREFIX_GATEWAY_PLACEHOLDER, prefix_gateway(prefix)) + .replace(PREFIX_PEERING_IP_PLACEHOLDER, prefix_peering_ip(prefix)) + .replace(PREFIX_REVTRVP_IP_PLACEHOLDER, prefix_revtrvp_ip(prefix)) + ) + # Normalize trailing newlines so adjacent blocks concatenate cleanly. + blocks.append(rendered.rstrip("\n")) + return "\n".join(blocks) + + +def generate_compose( + compose_template: str, + backend_template: str, + peering_attach_template: str, + revtrvp_template: str, + prefixes: list[str], +) -> str: + """Substitute rendered per-prefix blocks for all compose template markers.""" + markers = ( + BACKEND_NETS_MARKER, + PEERING_BACKEND_NETS_MARKER, + REVTRVP_SERVICES_MARKER, + ) + for marker in markers: + if marker not in compose_template: + raise ValueError(f"compose template is missing {marker!r} marker") + + return ( + compose_template.replace( + BACKEND_NETS_MARKER, + render_template_per_prefix(prefixes, backend_template), + ) + .replace( + PEERING_BACKEND_NETS_MARKER, + render_template_per_prefix(prefixes, peering_attach_template), + ) + .replace( + REVTRVP_SERVICES_MARKER, + render_template_per_prefix(prefixes, revtrvp_template), + ) + ) + + +def default_repo_root() -> Path: + """Resolve the repository root as the parent of this script's directory.""" + return Path(__file__).resolve().parent.parent + + +def parse_args() -> argparse.Namespace: + """CLI for regenerating docker-compose.yml from repo-relative defaults.""" + root = default_repo_root() + parser = argparse.ArgumentParser( + description=( + "Generate docker-compose.yml from templates, expanding one backend " + "network, peering attachment, and revtrvp service per prefix in " + "config/prefixes.txt." + ) + ) + parser.add_argument( + "--compose-template", + type=Path, + default=root / "templates" / "docker-compose.template.yml", + help="Path to docker-compose.template.yml", + ) + parser.add_argument( + "--backend-template", + type=Path, + default=root / "templates" / "backend.template.yml", + help="Path to backend.template.yml", + ) + parser.add_argument( + "--peering-attach-template", + type=Path, + default=root / "templates" / "peering-backend-attach.template.yml", + help="Path to peering-backend-attach.template.yml", + ) + parser.add_argument( + "--revtrvp-template", + type=Path, + default=root / "templates" / "revtrvp.template.yml", + help="Path to revtrvp.template.yml", + ) + parser.add_argument( + "--prefixes", + type=Path, + default=root / "config" / "prefixes.txt", + help="Path to prefixes.txt (one IPv4 prefix per line)", + ) + parser.add_argument( + "--output", + type=Path, + default=root / "docker-compose.yml", + help="Destination path for the generated compose file", + ) + return parser.parse_args() + + +def main() -> None: + """Load templates + prefixes, render, and write docker-compose.yml.""" + args = parse_args() + + compose_template = args.compose_template.read_text(encoding="utf-8") + backend_template = args.backend_template.read_text(encoding="utf-8") + peering_attach_template = args.peering_attach_template.read_text( + encoding="utf-8" + ) + revtrvp_template = args.revtrvp_template.read_text(encoding="utf-8") + prefixes = load_prefixes(args.prefixes) + if not prefixes: + raise SystemExit(f"no prefixes found in {args.prefixes}") + + rendered = generate_compose( + compose_template, + backend_template, + peering_attach_template, + revtrvp_template, + prefixes, + ) + # Ensure the output file ends with a single trailing newline. + args.output.write_text(rendered.rstrip("\n") + "\n", encoding="utf-8") + print( + f"wrote {args.output} " + f"({len(prefixes)} backend network(s), {len(prefixes)} revtrvp service(s))" + ) + + +if __name__ == "__main__": + main() diff --git a/utils/reverse-traceroute-docker/templates/backend.template.yml b/utils/reverse-traceroute-docker/templates/backend.template.yml new file mode 100644 index 0000000..f21abfd --- /dev/null +++ b/utils/reverse-traceroute-docker/templates/backend.template.yml @@ -0,0 +1,15 @@ + # No-NAT network carrying the public PEERING prefix between peering<->revtrvp. + # Masquerade is disabled so revtrvp's source IP is preserved on egress. + __NETWORK_NAME__: + driver: bridge + driver_opts: + com.docker.network.bridge.enable_ip_masquerade: "false" + # Unique per prefix (Docker host bridge names are capped at 15 chars). + com.docker.network.bridge.name: __BRIDGE_NAME__ + ipam: + driver: default + config: + # Park the host-side bridge gateway at .253; the real gateway that + # revtrvp routes through is the peering container at .254. + - subnet: __PREFIX__ + gateway: __PREFIX_GATEWAY__ diff --git a/utils/reverse-traceroute-docker/templates/docker-compose.template.yml b/utils/reverse-traceroute-docker/templates/docker-compose.template.yml new file mode 100644 index 0000000..2c1507e --- /dev/null +++ b/utils/reverse-traceroute-docker/templates/docker-compose.template.yml @@ -0,0 +1,41 @@ +# Docker Compose stack: Reverse Traceroute VP(s) behind PEERING. +# The `peering` service is the gateway/router. Data-plane egress is established +# by `make up` / `make openvpn mux=ufmg01` (not by the container entrypoint). +networks: + # NAT'd network for the peering container's OWN control traffic (OpenVPN to + # the mux, DNS, git). Docker masquerades this out the host as normal. + afrontend: + driver: bridge + ipam: + driver: default + config: + - subnet: 172.16.50.0/24 + +__BACKEND_NETS__ + +services: + # PEERING client acting as the gateway/router. + peering: + build: ../../docker + working_dir: /root/client + entrypoint: ["/peering-gateway-entrypoint.sh"] + stdin_open: true + tty: true + networks: + afrontend: +__PEERING_BACKEND_NETS__ + volumes: + # Pinned client submodule provides the peering CLI, scripts and configs. + - "./client:/root/client" + # Certs and prefix DB overlaid on top (kept out of the submodule). + - "./certs:/root/client/certs:ro" + - "./config/prefixes.txt:/root/client/prefixes.txt:ro" + - "./config/prefixes6.txt:/root/client/prefixes6.txt:ro" + # Gateway entrypoint + Makefile-driven OpenVPN/egress helper. + - "./scripts/peering-gateway-entrypoint.sh:/peering-gateway-entrypoint.sh:ro" + - "./scripts/peering-openvpn-up.sh:/peering-openvpn-up.sh:ro" + cap_add: + - NET_ADMIN + privileged: true + +__REVTRVP_SERVICES__ diff --git a/utils/reverse-traceroute-docker/templates/peering-backend-attach.template.yml b/utils/reverse-traceroute-docker/templates/peering-backend-attach.template.yml new file mode 100644 index 0000000..c837d2e --- /dev/null +++ b/utils/reverse-traceroute-docker/templates/peering-backend-attach.template.yml @@ -0,0 +1,5 @@ + __NETWORK_NAME__: + aliases: + - gateway + - peering + ipv4_address: __PREFIX_PEERING_IP__ diff --git a/utils/reverse-traceroute-docker/templates/revtrvp.template.yml b/utils/reverse-traceroute-docker/templates/revtrvp.template.yml new file mode 100644 index 0000000..feedb47 --- /dev/null +++ b/utils/reverse-traceroute-docker/templates/revtrvp.template.yml @@ -0,0 +1,29 @@ + # Reverse Traceroute vantage point for prefix __PREFIX__. + __SERVICE_NAME__: + build: + context: ./revtrvp + args: + GO_FLAGS: "-buildvcs=false" + entrypoint: ["/revtrvp-entrypoint.sh"] + command: ["/server.crt", "./plvp.config", "-loglevel", "debug"] + # The VP exits if it cannot resolve/reach the controller, which is expected + # until the prefix is announced and BGP converges. Restart so it keeps + # retrying until `make announce` brings the egress up. + restart: unless-stopped + depends_on: + - peering + dns: + - 8.8.8.8 + environment: + # Peering container's address on this VP's backend network. + REVTR_GATEWAY: __PREFIX_PEERING_IP__ + networks: + __NETWORK_NAME__: + aliases: + - revtrvp + ipv4_address: __PREFIX_REVTRVP_IP__ + volumes: + - "./scripts/revtrvp-entrypoint.sh:/revtrvp-entrypoint.sh:ro" + - "./config/plvp.config:/plvp.config:ro" + cap_add: + - NET_ADMIN