initial commit

This commit is contained in:
2026-06-23 18:41:23 +08:00
commit 9308a8c5d2
5 changed files with 687 additions and 0 deletions
+21
View File
@@ -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.
+18
View File
@@ -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
+122
View File
@@ -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.
Executable
+362
View File
@@ -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
+164
View File
@@ -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)