Skip to content

Instantly share code, notes, and snippets.

@holly
Last active June 20, 2026 15:30
Show Gist options
  • Select an option

  • Save holly/953e23087293a365a1f44d1618e479d0 to your computer and use it in GitHub Desktop.

Select an option

Save holly/953e23087293a365a1f44d1618e479d0 to your computer and use it in GitHub Desktop.
Manage WireGuard clients: add / remove / show. Handles server-side wg0.conf editing, client conf generation, and optional /etc/hosts-dnsmasq registration.
#!/usr/bin/env bash
#
# wg-client-manage.sh
# Manage WireGuard clients: add / remove / show.
# Handles server-side wg0.conf editing, client conf generation,
# and optional /etc/hosts-dnsmasq registration.
#
set -euo pipefail
# ====================== Environment-specific settings (verify before use) ======================
WG_IF="${WG_IF:-wg0}" # override: WG_IF=wg1 ./wg_manage.sh ...
WG_DIR="/etc/wireguard"
KEYS_DIR="${WG_DIR}/keys"
CONFS_DIR="${WG_DIR}/confs"
SERVER_CONF="${WG_DIR}/${WG_IF}.conf"
SERVER_PUBKEY_FILE="${KEYS_DIR}/server.pub"
HOSTS_DNSMASQ="${HOSTS_DNSMASQ:-/etc/hosts-dnsmasq}" # override: HOSTS_DNSMASQ=/path ./wg_manage.sh ...
DNSMASQ_SERVICE="dnsmasq"
SERVER_ENDPOINT="${SERVER_ENDPOINT:-}" # required, but only enforced inside cmd_add (see there)
SERVER_PORT="${SERVER_PORT:-51820}" # override: SERVER_PORT=51821 ./wg_manage.sh init ...
PERSISTENT_KEEPALIVE="25"
CLIENT_DNS2="1.1.1.1" # fixed secondary resolver
EGRESS_IF="${EGRESS_IF:-eth0}" # override: EGRESS_IF=ens3 ./wg_manage.sh init ...
# VPN_NETWORK / VPN_SERVER_IP / VPN_PREFIX / CLIENT_DNS1 are derived at runtime
# by load_server_network() from the server's own [Interface] Address line.
# Declared empty here only for readability; do not assign real values above.
VPN_NETWORK=""
VPN_SERVER_IP=""
VPN_PREFIX=""
CLIENT_DNS1=""
# =================================================================================================
err() { echo "ERROR: $*" >&2; exit 1; }
info() { echo "$*" >&2; }
require_root() {
[[ "$(id -u)" -eq 0 ]] || err "must be run as root (use sudo)"
}
require_files() {
[[ -f "$SERVER_CONF" ]] || err "server config not found: ${SERVER_CONF}"
[[ -f "$SERVER_PUBKEY_FILE" ]] || err "server public key not found: ${SERVER_PUBKEY_FILE}"
load_server_network
}
# Derive VPN_SERVER_IP / VPN_PREFIX / VPN_NETWORK / CLIENT_DNS1 from the
# server's own [Interface] Address line in SERVER_CONF. Only the IPv4
# Address line is considered (an IPv6 Address line, if present, is ignored).
load_server_network() {
local addr_line cidr
addr_line="$(awk '
/^\[Interface\]/ { in_iface=1; next }
/^\[/ { in_iface=0 }
in_iface && $0 ~ /^Address[[:space:]]*=/ { print }
' "$SERVER_CONF" | grep -E '[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/[0-9]{1,2}' | head -n1)"
[[ -n "$addr_line" ]] || \
err "could not find an IPv4 Address line in the [Interface] section of ${SERVER_CONF}"
cidr="$(grep -oE '[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}/[0-9]{1,2}' <<< "$addr_line")"
VPN_SERVER_IP="${cidr%%/*}"
VPN_PREFIX="${cidr##*/}"
[[ "$VPN_PREFIX" == "24" ]] || \
err "server Address prefix is /${VPN_PREFIX}, but this script only supports /24 networks"
VPN_NETWORK="${VPN_SERVER_IP%.*}"
CLIENT_DNS1="$VPN_SERVER_IP"
}
is_ip() {
[[ "$1" =~ ^${VPN_NETWORK//./\\.}\.[0-9]{1,3}$ ]]
}
validate_name() {
local name="$1"
[[ "$name" =~ ^[a-zA-Z0-9_-]+$ ]] || err "client name must be alphanumeric, '-' or '_' only"
}
validate_ip_in_subnet() {
local ip="$1"
[[ "$ip" =~ ^${VPN_NETWORK}\.([0-9]{1,3})$ ]] || \
err "IP must be in ${VPN_NETWORK}.0/${VPN_PREFIX} format (e.g. ${VPN_NETWORK}.5)"
local last="${BASH_REMATCH[1]}"
(( last >= 2 && last <= 254 )) || err "last octet must be 2-254 (0/1/255 are reserved)"
}
name_exists() {
local name="$1"
[[ -f "${KEYS_DIR}/${name}.key" ]]
}
ip_in_use() {
local ip="$1"
grep -q "AllowedIPs = ${ip}/32" "$SERVER_CONF" 2>/dev/null
}
# Find the client name associated with a given IP, by reading the
# "# Name = <name>" marker inside the matching [Peer] block.
find_name_by_ip() {
local ip="$1"
awk -v ip="${ip}/32" '
BEGIN { RS=""; FS="\n" }
index($0, "AllowedIPs = " ip) > 0 {
for (i = 1; i <= NF; i++) {
if ($i ~ /^# Name = /) {
sub(/^# Name = /, "", $i)
print $i
exit
}
}
}
' "$SERVER_CONF"
}
# Resolve a user-supplied identifier (name or IP) to a client name.
# Errors out if nothing matches.
resolve_identifier() {
local arg="$1" name=""
if is_ip "$arg"; then
name="$(find_name_by_ip "$arg")"
[[ -n "$name" ]] || err "no client found with IP '${arg}'"
else
name="$arg"
validate_name "$name"
name_exists "$name" || err "no client found with name '${name}'"
fi
echo "$name"
}
# Apply server-side Peer diff without dropping existing sessions.
reload_wireguard() {
if ! wg show "$WG_IF" >/dev/null 2>&1; then
info "WARNING: ${WG_IF} does not appear to be up. Check 'systemctl start wg-quick@${WG_IF}'"
return 0
fi
wg syncconf "$WG_IF" <(wg-quick strip "$WG_IF")
}
reload_dnsmasq() {
if systemctl is-active --quiet "$DNSMASQ_SERVICE" 2>/dev/null; then
systemctl restart "$DNSMASQ_SERVICE"
else
info "WARNING: ${DNSMASQ_SERVICE} is not running. Verify /etc/hosts-dnsmasq manually"
fi
}
# mode: keep (print only the matching block) / drop (print everything except it)
filter_peer_block() {
local name="$1" mode="$2"
local target="# Name = ${name}"
if [[ "$mode" == "keep" ]]; then
awk -v t="$target" 'BEGIN{RS="";ORS="\n\n"} index($0,t)>0' "$SERVER_CONF"
else
awk -v t="$target" 'BEGIN{RS="";ORS="\n\n"} index($0,t)==0' "$SERVER_CONF"
fi
}
# ============================ init ============================
cmd_init() {
local cidr="$1" listen_port="${2:-$SERVER_PORT}"
require_root
[[ "$cidr" =~ ^([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})/([0-9]{1,2})$ ]] || \
err "address must be in CIDR form, e.g. 10.0.0.1/24"
local server_ip="${BASH_REMATCH[1]}" prefix="${BASH_REMATCH[2]}"
[[ "$prefix" == "24" ]] || \
err "this script only supports /24 networks (got /${prefix})"
[[ -e "$SERVER_CONF" ]] && \
err "server config already exists: ${SERVER_CONF} (remove it manually first if you really want to re-init)"
[[ -e "${KEYS_DIR}/server.key" ]] && \
err "server key already exists: ${KEYS_DIR}/server.key (remove it manually first if you really want to re-init)"
mkdir -p "$KEYS_DIR" "$CONFS_DIR"
echo "net.ipv4.ip_forward=1" > /etc/sysctl.d/99-wireguard.conf
sysctl --system >/dev/null
umask 077
wg genkey > "${KEYS_DIR}/server.key"
wg pubkey < "${KEYS_DIR}/server.key" > "${KEYS_DIR}/server.pub"
local server_priv
server_priv="$(cat "${KEYS_DIR}/server.key")"
cat > "$SERVER_CONF" <<EOF
[Interface]
Address = ${cidr}
ListenPort = ${listen_port}
PrivateKey = ${server_priv}
PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -m state --state RELATED,ESTABLISHED -j ACCEPT; iptables -t nat -A POSTROUTING -o ${EGRESS_IF} -j MASQUERADE
PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -m state --state RELATED,ESTABLISHED -j ACCEPT; iptables -t nat -D POSTROUTING -o ${EGRESS_IF} -j MASQUERADE
EOF
chmod 600 "$SERVER_CONF"
echo "Initialized: ${SERVER_CONF}"
echo "Server public key: $(cat "${KEYS_DIR}/server.pub")"
echo "Next steps:"
echo " systemctl enable --now wg-quick@${WG_IF}"
}
# ============================ add ============================
cmd_add() {
local name="" ip="" with_dns=0
name="$1"; ip="$2"; shift 2
while [[ $# -gt 0 ]]; do
case "$1" in
--dns) with_dns=1 ;;
*) err "unknown option: $1" ;;
esac
shift
done
require_root
require_files
validate_name "$name"
validate_ip_in_subnet "$ip"
name_exists "$name" && err "client '${name}' already exists"
ip_in_use "$ip" && err "IP '${ip}' is already in use"
[[ -n "$SERVER_ENDPOINT" ]] || \
err "SERVER_ENDPOINT environment variable is required (e.g. export SERVER_ENDPOINT=vpn.example.com)"
mkdir -p "$KEYS_DIR" "$CONFS_DIR"
umask 077
wg genkey > "${KEYS_DIR}/${name}.key"
wg pubkey < "${KEYS_DIR}/${name}.key" > "${KEYS_DIR}/${name}.pub"
local client_priv client_pub server_pub
client_priv="$(cat "${KEYS_DIR}/${name}.key")"
client_pub="$(cat "${KEYS_DIR}/${name}.pub")"
server_pub="$(cat "$SERVER_PUBKEY_FILE")"
{
echo ""
echo "[Peer]"
echo "# Name = ${name}"
echo "PublicKey = ${client_pub}"
echo "AllowedIPs = ${ip}/32"
} >> "$SERVER_CONF"
cat > "${CONFS_DIR}/${name}.conf" <<EOF
[Interface]
PrivateKey = ${client_priv}
Address = ${ip}/${VPN_PREFIX}
DNS = ${CLIENT_DNS1}, ${CLIENT_DNS2}
[Peer]
PublicKey = ${server_pub}
AllowedIPs = ${VPN_NETWORK}.0/${VPN_PREFIX}
Endpoint = ${SERVER_ENDPOINT}:${SERVER_PORT}
PersistentKeepalive = ${PERSISTENT_KEEPALIVE}
EOF
chmod 600 "${CONFS_DIR}/${name}.conf"
reload_wireguard
echo "Added: ${name} (${ip})"
echo "Client conf: ${CONFS_DIR}/${name}.conf"
if [[ "$with_dns" -eq 1 ]]; then
[[ -f "$HOSTS_DNSMASQ" ]] || touch "$HOSTS_DNSMASQ"
if grep -qE "^${ip}[[:space:]]" "$HOSTS_DNSMASQ"; then
err "an entry for IP '${ip}' already exists in ${HOSTS_DNSMASQ}; check manually"
fi
echo "${ip} ${name}" >> "$HOSTS_DNSMASQ"
reload_dnsmasq
echo "dnsmasq entry added: ${name} -> ${ip}"
fi
}
# ============================ remove ============================
cmd_remove() {
local raw="$1" name
require_root
require_files
name="$(resolve_identifier "$raw")"
echo "----- current config for '${name}' -----"
cmd_show "$name"
echo "------------------------------------------"
read -rp "Delete this client? (y/N): " confirm
[[ "$confirm" =~ ^[yY]$ ]] || { echo "Aborted"; exit 0; }
local tmp
tmp="$(mktemp)"
filter_peer_block "$name" "drop" > "$tmp"
mv "$tmp" "$SERVER_CONF"
rm -f "${KEYS_DIR}/${name}.key" "${KEYS_DIR}/${name}.pub" "${CONFS_DIR}/${name}.conf"
reload_wireguard
if [[ -f "$HOSTS_DNSMASQ" ]] && grep -qE "[[:space:]]${name}$" "$HOSTS_DNSMASQ"; then
sed -i "/[[:space:]]${name}$/d" "$HOSTS_DNSMASQ"
reload_dnsmasq
echo "dnsmasq entry removed as well"
fi
echo "Removed: ${name}"
}
# ============================ show ============================
cmd_show() {
local raw="$1" name
require_files
name="$(resolve_identifier "$raw")"
echo "=== Client: ${name} ==="
echo "--- Public key ---"
cat "${KEYS_DIR}/${name}.pub"
echo "--- Server-side [Peer] block ---"
filter_peer_block "$name" "keep"
if [[ -f "${CONFS_DIR}/${name}.conf" ]]; then
echo "--- Client conf (${CONFS_DIR}/${name}.conf) ---"
cat "${CONFS_DIR}/${name}.conf"
fi
if [[ -f "$HOSTS_DNSMASQ" ]] && grep -qE "[[:space:]]${name}$" "$HOSTS_DNSMASQ"; then
echo "--- dnsmasq entry ---"
grep -E "[[:space:]]${name}$" "$HOSTS_DNSMASQ"
fi
}
# ============================ main ============================
usage() {
cat <<EOF
Usage:
$0 init <server_cidr> [listen_port] Initialize server config (e.g. $0 init 10.0.0.1/24)
$0 add <name> <ip> [--dns] Add a client (e.g. $0 add mypc 10.0.0.5 --dns)
$0 remove <name|ip> Remove a client (confirmation required)
$0 show <name|ip> Show a client's configuration
EOF
exit 1
}
main() {
local cmd="${1:-}"
case "$cmd" in
init)
shift || true
[[ $# -ge 1 ]] || usage
cmd_init "$@"
;;
add)
shift || true
[[ $# -ge 2 ]] || usage
cmd_add "$@"
;;
remove)
shift || true
[[ $# -ge 1 ]] || usage
cmd_remove "$1"
;;
show)
shift || true
[[ $# -ge 1 ]] || usage
cmd_show "$1"
;;
*)
usage
;;
esac
}
main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment