Last active
June 20, 2026 15:30
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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