diff options
| -rw-r--r-- | LICENSE | 21 | ||||
| -rw-r--r-- | Makefile | 18 | ||||
| -rw-r--r-- | README.md | 122 | ||||
| -rwxr-xr-x | wg-connect | 362 | ||||
| -rw-r--r-- | wg-connect.1 | 164 |
5 files changed, 687 insertions, 0 deletions
@@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Emmett1 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0e09ab9 --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +PREFIX ?= /usr/local +BINDIR ?= $(PREFIX)/bin +MANDIR ?= $(PREFIX)/share/man/man1 +INSTALL ?= install +INSTALL_FL = -m 0755 +INSTALL_FM = -m 0644 + +.PHONY: install uninstall + +install: + $(INSTALL) -d $(DESTDIR)$(BINDIR) + $(INSTALL) -d $(DESTDIR)$(MANDIR) + $(INSTALL) $(INSTALL_FL) wg-connect $(DESTDIR)$(BINDIR)/wg-connect + $(INSTALL) $(INSTALL_FM) wg-connect.1 $(DESTDIR)$(MANDIR)/wg-connect.1 + +uninstall: + rm -f $(DESTDIR)$(BINDIR)/wg-connect + rm -f $(DESTDIR)$(MANDIR)/wg-connect.1 diff --git a/README.md b/README.md new file mode 100644 index 0000000..97a2e0c --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +# wg-connect + +Bring a WireGuard tunnel up and down on BusyBox-based systems without +`wg-quick`, systemd, or bash. + +## Requirements + +- `wg` - WireGuard userspace tool +- `ip` - iproute2 or BusyBox `ip` +- `grep` - BusyBox grep (with `-E` support) +- POSIX `/bin/sh` + +Root privileges are required (the script creates and configures network +interfaces). + +## Installation + +```sh +make install # to /usr/local +make install PREFIX=/usr # to /usr +make install DESTDIR=/tmp/pkg # for packaging +``` + +Or manually: + +```sh +install -m 0755 wg-connect /usr/local/bin/wg-connect +install -m 0644 wg-connect.1 /usr/local/share/man/man1/wg-connect.1 +``` + +## Usage + +```sh +wg-connect up <config> # bring a tunnel up +wg-connect down [name] # tear a tunnel down +``` + +**`up`** requires a config argument, resolved as follows: + +| Argument | Resolves to | +|---|---| +| `myvpn` | `/etc/wireguard/myvpn.conf` | +| `myvpn.conf` | `./myvpn.conf`, then `/etc/wireguard/myvpn.conf` | +| `./foo.conf` | `./foo.conf` (path with `/` - used as-is) | +| `/any/path/c.conf` | `/any/path/c.conf` (absolute path) | + +The interface name is derived from the config file's basename (minus +`.conf`). For example, `wg-connect up myvpn` creates interface `myvpn`, +and `wg-connect up /etc/wireguard/home.conf` creates interface `home`. + +**`down`** accepts an optional name to tear down a specific tunnel. +Without a name it defaults to `wg0`. + +## Config format + +Standard WireGuard `[Interface]` + `[Peer]` sections. `wg-quick` +extensions (`Address`, `DNS`, etc.) are accepted. The script strips +them before passing the config to `wg setconf` and handles them itself. + +```ini +[Interface] +Address = 10.0.0.2/24 +PrivateKey = oBKGh1W0UeO7R2aV5pLkdMn8Xq3TcFyRbzJwZsPvCg= +DNS = 1.1.1.1 + +[Peer] +PublicKey = xTIBA5rboUvnH4htjbDs6TFeFHS4k0mrKV4xJCzO0H8= +PresharedKey = /pUcv4j6DZ1UGm0PwR7aBr9Lk2sFdXq3OcVy5Jh8Tg= +Endpoint = 203.0.113.45:51820 +AllowedIPs = 0.0.0.0/0 +``` + +Multiple `[Peer]` sections are supported; `AllowedIPs` and `Endpoint` +values are accumulated across all of them. + +Note: if you run multiple tunnels, each must have a unique `ListenPort` +(or omit it entirely). The script detects port conflicts before +setting up the interface. + +## What it does + +**`up`** + +1. Resolves the config path and derives the interface name. +2. Checks for port conflicts with any existing WireGuard interface. +3. Parses the config for `Address`, `DNS`, `Endpoint`, `AllowedIPs`, + and `ListenPort`. +4. Adds explicit routes for each peer endpoint through the current + default gateway, so encrypted UDP packets are not caught in the + tunnel's own routing. +5. Creates the WireGuard interface and applies the config. +6. Assigns the `Address` (appending `/32` if no CIDR is given). +7. Brings the interface up. +8. Installs routes for each `AllowedIPs` entry. `0.0.0.0/0` replaces + the default route. +9. If `DNS` is set, backs up `/etc/resolv.conf` and writes the VPN + nameserver. +10. Saves state to `/tmp/wg-connect.<iface>.state` for teardown. + +If any step fails, the trap handler rolls back everything created so +far (interface, endpoint routes, state file). + +**`down`** + +1. Reads `/tmp/wg-connect.<name>.state`. +2. Restores the original `/etc/resolv.conf`. +3. Removes the endpoint-specific routes. +4. Deletes the interface (which also removes its addresses and routes). +5. Restores the original default route if one was saved. +6. Removes the state file. + +If the state file is missing but the interface still exists, `down` +falls back to cleaning up the leftover interface. + +## Limitations + +- IPv6 addresses and routes are skipped (BusyBox `ip` may lack `-6` + support). +- `DNS` supports a single nameserver only. +- PostUp / PostDown / PreUp / PreDown hooks are not executed. +- No firewall rules are added. If you need a kill switch, configure + `iptables` separately. diff --git a/wg-connect b/wg-connect new file mode 100755 index 0000000..c588417 --- /dev/null +++ b/wg-connect @@ -0,0 +1,362 @@ +#!/bin/sh +# +# wg-connect - bring a WireGuard tunnel up/down on BusyBox systems. +# Usage: wg-connect up [config] (defaults to ./peer.conf) +# wg-connect down +# +# Requires: wg, ip, grep, sed, cut (all typically available on BusyBox). + +set -e + +STATE_FILE="/tmp/wg-connect.state" +IFACE_DEFAULT="wg0" + +usage() { + echo "Usage: wg-connect up <config> | wg-connect down [name]" >&2 + echo " up <config> config name (looked up in /etc/wireguard/<name>.conf)" >&2 + echo " up <path> full or relative path to a .conf file" >&2 + echo " down [name] tear down a tunnel (default: wg0)" >&2 + exit 1 +} + +die() { + echo "wg-connect: $*" >&2 + exit 1 +} + +# Resolve a config argument to a path: +# - empty → usage error +# - contains / → use as-is (already a path) +# - *.conf → try cwd, then /etc/wireguard/ +# - plain name → /etc/wireguard/<name>.conf +resolve_config() { + _name="$1" + + [ -z "$_name" ] && { usage; exit 1; } + + # Full or relative path given + case "$_name" in + */* ) echo "$_name"; return 0 ;; + esac + + # Explicit .conf filename - try cwd first, then /etc/wireguard + case "$_name" in + *.conf ) + [ -f "$_name" ] && { echo "$_name"; return 0; } + [ -f "/etc/wireguard/$_name" ] && { echo "/etc/wireguard/$_name"; return 0; } + die "config not found: $_name (tried . and /etc/wireguard/)" + ;; + esac + + # Bare name → /etc/wireguard/<name>.conf + _path="/etc/wireguard/${_name}.conf" + [ -f "$_path" ] || die "config not found: $_path" + echo "$_path" +} + +# -- config parser ------------------------------------------------------- +# Reads a WireGuard .conf file and sets global vars: +# IFACE, ADDRESS, DNS, LISTEN_PORT, PRIVATE_KEY +# Each Peer is stored as positional-ish vars: we only extract what we need +# for route/DNS handling, since wg setconf reads the raw config directly. + +parse_config() { + _cfg="$1" + + [ -f "$_cfg" ] || die "config file not found: $_cfg" + + # Reset globals + ADDRESS="" + DNS="" + LISTEN_PORT="" + PRIVATE_KEY="" + ALLOWED_IPS="" + ENDPOINTS="" + + while IFS= read -r line || [ -n "$line" ]; do + # Strip comments and leading/trailing whitespace + line="${line%%#*}" + line="${line# }" + line="${line% }" + [ -z "$line" ] && continue + + case "$line" in + \[*\] ) + _section="${line#\[}" + _section="${_section%\]}" + ;; + Address\ *=\ * ) + ADDRESS="${line#*= }" + ADDRESS="${ADDRESS# }" + ;; + DNS\ *=\ * ) + DNS="${line#*= }" + DNS="${DNS# }" + ;; + ListenPort\ *=\ * ) + LISTEN_PORT="${line#*= }" + LISTEN_PORT="${LISTEN_PORT# }" + ;; + PrivateKey\ *=\ * ) + PRIVATE_KEY="${line#*= }" + PRIVATE_KEY="${PRIVATE_KEY# }" + ;; + AllowedIPs\ *=\ * ) + _ips="${line#*= }" + _ips="${_ips# }" + if [ -z "$ALLOWED_IPS" ]; then + ALLOWED_IPS="$_ips" + else + ALLOWED_IPS="$ALLOWED_IPS, $_ips" + fi + ;; + Endpoint\ *=\ * ) + _ep="${line#*= }" + _ep="${_ep# }" + _ep="${_ep%:*}" # strip port, keep only IP + if [ -z "$ENDPOINTS" ]; then + ENDPOINTS="$_ep" + else + ENDPOINTS="$ENDPOINTS $_ep" + fi + ;; + esac + done < "$_cfg" +} + +# -- helpers ------------------------------------------------------------- + +is_iface_up() { + ip link show "$1" >/dev/null 2>&1 +} + +save_default_route() { + # Returns the current IPv4 default route line, if any + ip route show default 2>/dev/null || true +} + +add_routes() { + # Add routes for AllowedIPs. Handles IPv4 only for BusyBox compat. + # 0.0.0.0/0 is handled specially: it replaces the default route. + _iface="$1" + _ips="$2" + _old_IFS="$IFS" + + IFS="," + for _net in $_ips; do + _net="${_net# }" + _net="${_net% }" + [ -z "$_net" ] && continue + + # Skip IPv6 - BusyBox ip may not support it + case "$_net" in + *:* ) continue ;; + esac + + if [ "$_net" = "0.0.0.0/0" ] || [ "$_net" = "0.0.0.0" ]; then + ip route replace default dev "$_iface" 2>/dev/null || \ + ip route add default dev "$_iface" 2>/dev/null || true + else + ip route add "$_net" dev "$_iface" 2>/dev/null || true + fi + done + IFS="$_old_IFS" +} + +setup_dns() { + _dns="$1" + [ -z "$_dns" ] && return 0 + + cp /etc/resolv.conf "$DNS_BACKUP" + + # Write minimal resolv.conf + { + echo "nameserver $_dns" + } > /etc/resolv.conf +} + +restore_dns() { + if [ -f "$DNS_BACKUP" ]; then + cp "$DNS_BACKUP" /etc/resolv.conf + rm -f "$DNS_BACKUP" + fi +} + +# -- commands ------------------------------------------------------------ + +cmd_up() { + _cfg="$(resolve_config "${1:-}")" + + parse_config "$_cfg" + + # Interface name comes from config filename (matching wg-quick behaviour) + _iface_name="${_cfg##*/}" # strip path + _iface_name="${_iface_name%.conf}" # strip .conf + IFACE="${IFACE:-$_iface_name}" + + STATE_FILE="/tmp/wg-connect.${IFACE}.state" + + if is_iface_up "$IFACE"; then + die "interface $IFACE already exists - run 'wg-connect down $IFACE' first" + fi + + # Check for port conflicts with any existing WireGuard interface + if [ -n "$LISTEN_PORT" ]; then + _used_ports="$(wg show interfaces 2>/dev/null | while read -r _wgif; do + wg show "$_wgif" listen-port 2>/dev/null + done)" + for _p in $_used_ports; do + [ "$_p" = "$LISTEN_PORT" ] && die "port $LISTEN_PORT is already in use by another WireGuard interface" + done + fi + + # Trap to roll back partial setup on failure. + _up_done="" + trap 'rollback' INT TERM EXIT + + rollback() { + trap - INT TERM EXIT + if [ -n "$_up_done" ]; then + return 0 + fi + # Undo whatever was created, in reverse order. + restore_dns 2>/dev/null || true + is_iface_up "$IFACE" && ip link del "$IFACE" 2>/dev/null || true + for _ep in $ENDPOINT_ROUTES; do + [ -z "$_ep" ] && continue + ip route del "$_ep" 2>/dev/null || true + done + rm -f "$STATE_FILE" + } + + # Save current default route and extract its gateway + DEFAULT_ROUTE="$(save_default_route)" + _gw="" + case "$DEFAULT_ROUTE" in + *\ via\ * ) _gw="${DEFAULT_ROUTE##* via }"; _gw="${_gw%% *}" ;; + esac + + # Add explicit routes for WireGuard endpoints through the physical + # gateway BEFORE we replace the default route, otherwise the encrypted + # UDP packets have no path to the server. + ENDPOINT_ROUTES="" + for _ep in $ENDPOINTS; do + [ -z "$_ep" ] && continue + case "$_ep" in + *:* ) continue ;; # skip IPv6 endpoints + esac + if [ -n "$_gw" ]; then + ip route add "$_ep" via "$_gw" 2>/dev/null || true + fi + ENDPOINT_ROUTES="${ENDPOINT_ROUTES}${_ep} " + done + + # Create the interface + ip link add "$IFACE" type wireguard + + # Configure WireGuard - strip wg-quick-only fields (Address, DNS, etc.) + # that wg setconf rejects. These are handled manually below. + _wg_conf="/tmp/wg-connect.$$.conf" + grep -v -E '^[[:space:]]*(Address|DNS|MTU|Table|PreUp|PostUp|PreDown|PostDown|SaveConfig)[[:space:]]*=' "$_cfg" > "$_wg_conf" + wg setconf "$IFACE" "$_wg_conf" + rm -f "$_wg_conf" + + # Assign address (append /32 if no CIDR given) + case "$ADDRESS" in + */* ) ;; + * ) ADDRESS="${ADDRESS}/32" ;; + esac + ip addr add "$ADDRESS" dev "$IFACE" + + # Bring interface up + ip link set "$IFACE" up + + # Add routes (including default route replacement) + add_routes "$IFACE" "$ALLOWED_IPS" + + # Set up DNS + DNS_BACKUP="/tmp/resolv.conf.wg.bak" + setup_dns "$DNS" + + # Write state file for cmd_down + { + echo "IFACE=$IFACE" + echo "DNS_BACKUP=$DNS_BACKUP" + echo "DEFAULT_ROUTE=$DEFAULT_ROUTE" + echo "ENDPOINT_ROUTES=$ENDPOINT_ROUTES" + } > "$STATE_FILE" + + # Mark success - disable rollback + _up_done=1 + trap - INT TERM EXIT + + echo "wg-connect: $IFACE is up" +} + +cmd_down() { + # Resolve state file: explicit name, or default "peer" (from peer.conf) + _down_name="${1:-}" + + if [ -n "$_down_name" ]; then + case "$_down_name" in + *.conf ) _down_name="${_down_name%.conf}" ;; + esac + case "$_down_name" in + */* ) _down_name="${_down_name##*/}" ;; + esac + STATE_FILE="/tmp/wg-connect.${_down_name}.state" + IFACE="$_down_name" + fi + + if [ -f "$STATE_FILE" ]; then + # Read state + while IFS='=' read -r key value; do + case "$key" in + IFACE) IFACE="$value" ;; + DNS_BACKUP) DNS_BACKUP="$value" ;; + DEFAULT_ROUTE) DEFAULT_ROUTE="$value" ;; + ENDPOINT_ROUTES) ENDPOINT_ROUTES="$value" ;; + esac + done < "$STATE_FILE" + + # Restore DNS + restore_dns + + # Remove endpoint routes + for _ep in $ENDPOINT_ROUTES; do + [ -z "$_ep" ] && continue + ip route del "$_ep" 2>/dev/null || true + done + else + # No state file - try to clean up a partially-created interface + if [ -z "$IFACE" ]; then + IFACE="$IFACE_DEFAULT" + fi + if ! is_iface_up "$IFACE"; then + die "no state file found and $IFACE does not exist - nothing to tear down" + fi + echo "wg-connect: no state file, cleaning up leftover $IFACE" >&2 + fi + + # Delete the interface (this also removes its routes and address) + if is_iface_up "$IFACE"; then + ip link del "$IFACE" + fi + + # Restore the previous default route if there was one + if [ -n "$DEFAULT_ROUTE" ]; then + ip route add $DEFAULT_ROUTE 2>/dev/null || true + fi + + rm -f "$STATE_FILE" + + echo "wg-connect: $IFACE is down" +} + +# -- main ---------------------------------------------------------------- + +case "${1:-}" in + up) shift; cmd_up "$@" ;; + down) shift; cmd_down "$@" ;; + *) usage ;; +esac diff --git a/wg-connect.1 b/wg-connect.1 new file mode 100644 index 0000000..dc8b356 --- /dev/null +++ b/wg-connect.1 @@ -0,0 +1,164 @@ +.TH WG-CONNECT 1 "2026-06-23" "wg-connect 1.1" "User Commands" +.SH NAME +wg-connect \- bring a WireGuard tunnel up or down +.SH SYNOPSIS +.B wg-connect up +.I config +.br +.B wg-connect down +.RI [ name ] +.SH DESCRIPTION +.B wg-connect +manages a WireGuard tunnel on systems that lack +.BR wg-quick (8), +systemd, or bash - typically BusyBox-based distributions. +.PP +The +.B up +command parses a WireGuard configuration file, creates the tunnel +interface, applies keys and peers, assigns the address, installs +routes, and optionally updates +.IR /etc/resolv.conf . +State is saved to +.I /tmp/wg-connect.<iface>.state +so that +.B down +can cleanly tear everything down. +.PP +The +.B down +command reverses every action: restores the original DNS configuration, +removes endpoint-specific routes, deletes the interface, and restores +the previous default route. +.SH OPTIONS +.TP +.B up +.I config +Bring the tunnel up. +.I config +is required and is resolved as follows: +.RS +.IP \[bu] +If it contains a +.BR / , +it is used as a file path directly. +.IP \[bu] +If it ends in +.BR .conf , +it is tried in the current directory, then in +.IR /etc/wireguard/ . +.IP \[bu] +Otherwise it is treated as a name and looked up as +.IR /etc/wireguard/ <name> .conf . +.RE +.TP +.B down +.RI [ name ] +Tear a tunnel down. If +.I name +is given, the state file +.I /tmp/wg-connect.<name>.state +is used and the interface +.I <name> +is torn down. If omitted, the interface defaults to +.BR wg0 . +.RS +.IP +If the expected state file is missing but the interface still exists, +it is cleaned up as a leftover from a failed +.B up +attempt. +.RE +.SH CONFIGURATION FORMAT +The configuration file follows standard WireGuard syntax with an +.B [Interface] +section and one or more +.B [Peer] +sections. The +.BR wg-quick (8) +extensions +.BR Address , +.BR DNS , +.BR MTU , +.BR Table , +.BR PreUp , +.BR PostUp , +.BR PreDown , +.BR PostDown , +and +.B SaveConfig +are tolerated in the file but are handled by +.B wg-connect +itself rather than passed to +.BR "wg setconf" . +.PP +.BR Address , +.BR DNS , +.BR Endpoint , +and +.B AllowedIPs +are the fields that drive the script's own setup logic. All other +fields are forwarded to the kernel through +.BR "wg setconf" . +.PP +The interface name is derived from the configuration file's basename +(minus the +.I .conf +extension), matching +.BR wg-quick (8) +behaviour. +.PP +Multiple +.B [Peer] +sections are supported. +.B AllowedIPs +values are accumulated across all peers, and an explicit route to each +.BR Endpoint 's +IP address is added through the original default gateway before the +default route is replaced. +.SH FILES +.TP +.I /etc/wireguard/*.conf +Configuration files, looked up by name. +.TP +.I /tmp/wg-connect.<iface>.state +Runtime state written by +.B up +and consumed by +.BR down . +.TP +.I /tmp/resolv.conf.wg.bak +Backup of +.I /etc/resolv.conf +taken before the VPN DNS is installed. +.TP +.I /tmp/wg-connect.<pid>.conf +Temporary filtered configuration passed to +.BR "wg setconf" . +.SH EXIT STATUS +.TP +0 +Success. +.TP +1 +An error occurred (missing arguments, configuration file not found, +interface already up, state file missing on +.BR down , +or usage error). +.SH NOTES +.B wg-connect +must be run as root. It manipulates network interfaces, the routing +table, and +.IR /etc/resolv.conf . +.PP +IPv6 addresses in +.B AllowedIPs +and +.B Endpoint +are silently skipped. BusyBox +.B ip +often lacks reliable IPv6 support. +.SH SEE ALSO +.BR wg (8), +.BR wg-quick (8), +.BR ip (8) |
