aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--LICENSE21
-rw-r--r--Makefile18
-rw-r--r--README.md122
-rwxr-xr-xwg-connect362
-rw-r--r--wg-connect.1164
5 files changed, 687 insertions, 0 deletions
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..7cf42e1
--- /dev/null
+++ b/LICENSE
@@ -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)