Requirements: Bash 3.2+, WireGuard wg, Python 3, Python venv/pip, qrcode[pil]. Linux automatic system dependency installation supports apt, dnf, pacman. macOS supports Homebrew.

wireguard-provision.sh Raw
1#!/usr/bin/env bash
2# WireGuard client provisioning for Linux and macOS.
3# Based on the workflow of wireguard-provision.ps1: detect dependencies,
4# offer automatic/manual installation, ask for client details, generate
5# <device>.conf + <device>.png locally, and print the client's public key.
6# Usage: chmod +x wireguard-provision.sh && ./wireguard-provision.sh
7# Menu: generate new .conf/.png, or display QR from an existing .conf.
8# Optional: ./wireguard-provision.sh /path/to/output-directory
9# Requirements: Bash 3.2+, WireGuard wg, Python 3, Python venv/pip, qrcode[pil].
10# Linux automatic system dependency installation supports apt, dnf, pacman;
11# macOS supports Homebrew. Other platforms: install dependencies manually.
12# Never publish .conf or .png: both contain the client private key.
13set -euo pipefail
14umask 077
15
16say() { printf '%s\n' "$*"; }
17# Homebrew is commonly installed outside the default PATH on macOS.
18refresh_brew_path() {
19 [[ "$OS" == Darwin ]] || return 0
20 local brew_bin
21 for brew_bin in /opt/homebrew/bin/brew /usr/local/bin/brew; do
22 if [[ -x "$brew_bin" ]]; then
23 eval "$("$brew_bin" shellenv)"
24 break
25 fi
26 done
27}
28fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
29ask() {
30 local prompt="$1" default="${2-}" value
31 while :; do
32 if [[ -n "$default" ]]; then
33 read -r -p "$prompt [$default]: " value || exit 0
34 value="${value:-$default}"
35 else
36 read -r -p "$prompt: " value || exit 0
37 fi
38 [[ -n "$value" ]] && { REPLY_VALUE="$value"; return; }
39 say 'Value cannot be empty.'
40 done
41}
42confirm_install() {
43 local response
44 read -r -p "$1 Install automatically? [Y]es / [M]anual (exit): " response || exit 0
45 case "$response" in
46 y|Y|yes|YES|Yes) return 0 ;;
47 *) say 'Manual installation selected. Install the missing dependencies and rerun this script.'; exit 0 ;;
48 esac
49}
50install_system_deps() {
51 local os="$1"
52 if [[ "$os" == Darwin ]]; then
53 command -v brew >/dev/null 2>&1 || fail 'Homebrew not found. Install Homebrew from https://brew.sh/ and rerun.'
54 brew install wireguard-tools python
55 refresh_brew_path
56 elif command -v apt-get >/dev/null 2>&1; then
57 sudo apt-get update
58 sudo apt-get install -y wireguard-tools python3 python3-venv python3-pip
59 elif command -v dnf >/dev/null 2>&1; then
60 sudo dnf install -y wireguard-tools python3 python3-pip
61 elif command -v pacman >/dev/null 2>&1; then
62 sudo pacman -S --needed --noconfirm wireguard-tools python python-pip
63 else
64 fail 'No supported package manager found. Install wg, Python 3, Python venv/pip manually.'
65 fi
66}
67valid_ipv4() {
68 local ip="$1" octet
69 local -a octets
70 [[ "$ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] || return 1
71 IFS=. read -r -a octets <<< "$ip"
72 [[ ${#octets[@]} -eq 4 ]] || return 1
73 for octet in "${octets[@]}"; do
74 [[ ${#octet} -le 3 ]] || return 1
75 (( 10#$octet <= 255 )) || return 1
76 done
77}
78valid_key() {
79 local key="$1" decoded
80 [[ "$key" =~ ^[A-Za-z0-9+/]{43}=$ ]] || return 1
81 # Python validates the decoded length and canonical base64 representation.
82 "$PYTHON" - "$key" <<'PY'
83import base64, sys
84try:
85 key = sys.argv[1]
86 raw = base64.b64decode(key, validate=True)
87 sys.exit(0 if len(raw) == 32 and base64.b64encode(raw).decode() == key else 1)
88except Exception:
89 sys.exit(1)
90PY
91}
92valid_routes() {
93 local entry ip prefix
94 local -a routes
95 IFS=, read -r -a routes <<< "$1"
96 (( ${#routes[@]} > 0 )) || return 1
97 for entry in "${routes[@]}"; do
98 entry="${entry//[[:space:]]/}"
99 [[ "$entry" =~ ^([0-9.]+)/([0-9]{1,2})$ ]] || return 1
100 ip="${BASH_REMATCH[1]}"; prefix="${BASH_REMATCH[2]}"
101 valid_ipv4 "$ip" || return 1
102 (( 10#$prefix <= 32 )) || return 1
103 done
104}
105valid_dns() {
106 local entry
107 local -a entries
108 IFS=, read -r -a entries <<< "$1"
109 (( ${#entries[@]} > 0 )) || return 1
110 for entry in "${entries[@]}"; do
111 entry="${entry//[[:space:]]/}"
112 valid_ipv4 "$entry" || return 1
113 done
114}
115
116# Keep privilege escalation limited to the package manager; never generate
117# credentials as root, where they could end up in /root.
118[[ "$(id -u)" -ne 0 ]] || fail 'Run as your regular user, not sudo. The script will request sudo only for package installation.'
119OS="$(uname -s)"
120[[ "$OS" == Linux || "$OS" == Darwin ]] || fail "Unsupported OS: $OS"
121refresh_brew_path
122OUT="${1:-$PWD/wireguard-profiles}"
123
124find_brew_python() {
125 [[ "$OS" == Darwin ]] || return 1
126 command -v brew >/dev/null 2>&1 || return 1
127 local prefix candidate
128 prefix="$(brew --prefix python 2>/dev/null)" || return 1
129 for candidate in "$prefix/libexec/bin/python3" "$prefix/bin/python3" "$(brew --prefix)/bin/python3"; do
130 if [[ -x "$candidate" ]] && "$candidate" -c 'import sys, venv; assert sys.version_info >= (3, 10)' >/dev/null 2>&1; then
131 printf '%s\n' "$candidate"
132 return 0
133 fi
134 done
135 return 1
136}
137ensure_python() {
138 PYTHON=''
139 local candidate
140 if [[ "$OS" == Darwin ]]; then
141 candidate="$(find_brew_python)" || candidate=''
142 [[ -n "$candidate" ]] && PYTHON="$candidate"
143 fi
144 if [[ -z "$PYTHON" ]] && command -v python3 >/dev/null 2>&1 && python3 -c 'import sys, venv; assert sys.version_info >= (3, 10)' >/dev/null 2>&1; then
145 PYTHON="$(command -v python3)"
146 fi
147 if [[ -z "$PYTHON" ]]; then
148 say 'Python 3 (>=3.10) with venv support is missing.'
149 confirm_install 'Python is required.'
150 install_system_deps "$OS"
151 refresh_brew_path
152 if [[ "$OS" == Darwin ]]; then
153 candidate="$(find_brew_python)" || candidate=''
154 [[ -n "$candidate" ]] && PYTHON="$candidate"
155 fi
156 if [[ -z "$PYTHON" ]]; then
157 command -v python3 >/dev/null 2>&1 || fail 'Python was not found after installation.'
158 PYTHON="$(command -v python3)"
159 fi
160 fi
161}
162ensure_wg() {
163 if ! command -v wg >/dev/null 2>&1; then
164 say 'WireGuard wg is missing.'
165 confirm_install 'WireGuard tools are required for key generation.'
166 install_system_deps "$OS"
167 command -v wg >/dev/null 2>&1 || fail 'wg was not found after installation.'
168 fi
169}
170# Recreate only this tool's private venv; never touch system Python or other venvs.
171create_qr_venv() {
172 local venv="$1"
173 if ! "$PYTHON" -m venv "$venv"; then
174 return 1
175 fi
176 [[ -x "$venv/bin/python" ]] && "$venv/bin/python" -m pip --version >/dev/null 2>&1
177}
178repair_venv_support() {
179 local pyver
180 if [[ "$OS" == Linux ]] && command -v apt-get >/dev/null 2>&1; then
181 pyver="$("$PYTHON" -c 'import sys; print("%d.%d" % sys.version_info[:2])')"
182 say "Installing Python venv support for Python $pyver..."
183 sudo apt-get update
184 if ! sudo apt-get install -y "python${pyver}-venv"; then
185 sudo apt-get install -y python3-venv
186 fi
187 elif [[ "$OS" == Darwin ]] && command -v brew >/dev/null 2>&1; then
188 brew install python
189 refresh_brew_path
190 local brew_python
191 brew_python="$(find_brew_python)" || fail 'Homebrew Python not found after installation.'
192 PYTHON="$brew_python"
193 else
194 fail 'Automatic venv repair is unavailable. Install Python venv/ensurepip support manually.'
195 fi
196}
197ensure_qr() {
198 ensure_python
199 local state_home venv
200 state_home="${XDG_STATE_HOME:-$HOME/.local/state}"
201 venv="$state_home/wireguard-provision/venv"
202 mkdir -p "$(dirname "$venv")"
203 chmod 700 "$(dirname "$venv")"
204
205 # A previous failed installation may have left a Python executable but no pip.
206 if [[ -x "$venv/bin/python" ]] && ! "$venv/bin/python" -m pip --version >/dev/null 2>&1; then
207 say 'Existing private Python environment has no pip. Attempting repair...'
208 "$venv/bin/python" -m ensurepip --upgrade >/dev/null 2>&1 || true
209 fi
210 if [[ ! -x "$venv/bin/python" ]] || ! "$venv/bin/python" -m pip --version >/dev/null 2>&1; then
211 say 'Preparing a complete isolated Python environment...'
212 # The directory is owned by this script and contains no user profiles.
213 rm -rf -- "$venv"
214 if ! create_qr_venv "$venv"; then
215 say 'Python venv/ensurepip support is missing or incomplete.'
216 confirm_install 'Install the system Python venv package?'
217 repair_venv_support
218 rm -rf -- "$venv"
219 create_qr_venv "$venv" || fail 'Virtual environment still lacks pip after repair.'
220 fi
221 fi
222 QR_PYTHON="$venv/bin/python"
223 if ! "$QR_PYTHON" -c 'import qrcode; from PIL import Image' >/dev/null 2>&1; then
224 say 'Python QR dependencies are missing.'
225 confirm_install 'Install qrcode[pil] in the private venv?'
226 "$QR_PYTHON" -m pip install --upgrade pip || fail 'pip upgrade failed.'
227 "$QR_PYTHON" -m pip install --upgrade 'qrcode[pil]' || fail 'qrcode/Pillow installation failed.'
228 "$QR_PYTHON" -c 'import qrcode; from PIL import Image' >/dev/null 2>&1 || fail 'QR dependency verification failed.'
229 fi
230}
231
232show_qr() {
233 local files=() f choice index selected
234 [[ -d "$OUT" ]] || { say "No profile directory found: $OUT"; return; }
235 # Use globbing rather than ls parsing, preserving filenames with spaces.
236 for f in "$OUT"/*.conf; do
237 [[ -f "$f" && ! -L "$f" ]] && files+=("$f")
238 done
239 if (( ${#files[@]} == 0 )); then
240 say "No .conf profiles found in: $OUT"
241 return
242 fi
243 say ''
244 say 'Available configurations:'
245 for ((index=0; index<${#files[@]}; index++)); do
246 printf '%d. %s\n' "$((index+1))" "${files[index]##*/}"
247 done
248 read -r -p "Select profile [1-${#files[@]}] (0 to cancel): " choice || return
249 [[ "$choice" =~ ^[0-9]+$ ]] || { say 'Invalid selection.'; return; }
250 (( 10#$choice == 0 )) && return
251 (( 10#$choice >= 1 && 10#$choice <= ${#files[@]} )) || { say 'Invalid selection.'; return; }
252 selected="${files[10#$choice-1]}"
253 say ''
254 say 'WARNING: The QR code contains the WireGuard private key.'
255 read -r -p 'Display this sensitive QR code on screen? [y/N]: ' choice || return
256 [[ "$choice" == y || "$choice" == Y ]] || { say 'Cancelled.'; return; }
257 say ''
258 ensure_qr
259 "$QR_PYTHON" - "$selected" <<'PYQR'
260import sys
261import qrcode
262
263with open(sys.argv[1], encoding='utf-8') as f:
264 content = f.read()
265# Keep a standard, scannable half-block QR. Do not resize the PNG.
266qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M, border=4)
267qr.add_data(content)
268qr.make(fit=True)
269matrix = qr.get_matrix()
270height, width = len(matrix), len(matrix[0])
271utf8 = bool(sys.stdout.encoding and 'UTF' in sys.stdout.encoding.upper())
272
273if not utf8:
274 print('Terminal is not configured for UTF-8; using standard ASCII QR.')
275 qr.print_ascii(invert=True)
276else:
277 # Two QR module rows per terminal row; black on white.
278 glyphs = (' ', '▄', '▀', '█')
279 for y in range(0, height, 2):
280 top = matrix[y]
281 bottom = matrix[y + 1] if y + 1 < height else [False] * width
282 line = ''.join(glyphs[(int(t) << 1) | int(b)] for t, b in zip(top, bottom))
283 print('\033[30;47m' + line + '\033[0m')
284PYQR
285 say 'Scan with the WireGuard mobile app. Avoid recording or sharing this terminal.'
286}
287
288generate_profile() {
289 ensure_wg
290 ensure_qr
291 SUCCESS=0
292 CREATED_CONF=0
293 CREATED_PNG=0
294 ask 'User / device name (filename, e.g. Laptop-Alice)'; NAME="$REPLY_VALUE"
295[[ "$NAME" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$ ]] || fail 'Invalid filename. Use 1-64 letters, digits, dots, underscores or dashes.'
296ask 'Client tunnel IPv4/CIDR (e.g. 10.77.0.2/32)'; ADDRESS="$REPLY_VALUE"
297[[ "$ADDRESS" =~ ^([0-9.]+)/32$ ]] && valid_ipv4 "${BASH_REMATCH[1]}" || fail 'Client address must be a valid IPv4 /32.'
298IP="${ADDRESS%/*}"
299ask 'DNS servers (comma-separated, e.g. 1.1.1.1, 9.9.9.9)'; DNS="$REPLY_VALUE"
300valid_dns "$DNS" || fail 'DNS must contain IPv4 addresses separated by commas.'
301ask 'Server endpoint (hostname:port, e.g. vpn.example.net:51820)'; ENDPOINT="$REPLY_VALUE"
302[[ "$ENDPOINT" =~ ^[A-Za-z0-9.-]+:([0-9]{1,5})$ ]] || fail 'Endpoint must be hostname-or-IPv4:port.'
303PORT="${BASH_REMATCH[1]}"
304(( 10#$PORT >= 1 && 10#$PORT <= 65535 )) || fail 'Endpoint port must be 1-65535.'
305ask 'AllowedIPs (IPv4 CIDR list)' '0.0.0.0/0'; ALLOWED="$REPLY_VALUE"
306valid_routes "$ALLOWED" || fail 'AllowedIPs must be comma-separated IPv4 CIDR entries.'
307ask 'WireGuard SERVER public key (Base64, no quotes)'; SERVER_KEY="$REPLY_VALUE"
308valid_key "$SERVER_KEY" || fail 'Invalid server public key (32-byte Base64 expected).'
309
310mkdir -p "$OUT"
311OUT="$(cd "$OUT" && pwd -P)"
312CONF="$OUT/$NAME.conf"; PNG="$OUT/$NAME.png"
313[[ ! -e "$CONF" && ! -L "$CONF" && ! -e "$PNG" && ! -L "$PNG" ]] || fail 'Profile already exists. Choose another name; no files overwritten.'
314
315PRIVATE_KEY="$(wg genkey)" || fail 'wg genkey failed.'
316PUBLIC_KEY="$(printf '%s\n' "$PRIVATE_KEY" | wg pubkey)" || fail 'wg pubkey failed.'
317valid_key "$PRIVATE_KEY" && valid_key "$PUBLIC_KEY" || fail 'WireGuard returned an invalid key.'
318
319# Exclusive creation prevents overwrites, including in race conditions.
320# File descriptors are kept open only for the config write.
321cleanup() {
322 if [[ "${SUCCESS:-0}" != 1 ]]; then
323 [[ "${CREATED_CONF:-0}" == 1 ]] && rm -f -- "$CONF"
324 [[ "${CREATED_PNG:-0}" == 1 ]] && rm -f -- "$PNG"
325 fi
326}
327trap cleanup EXIT
328( set -C; : > "$CONF" ) || fail 'Could not exclusively create config file.'
329CREATED_CONF=1
330chmod 600 "$CONF"
331printf '[Interface]\nPrivateKey = %s\nAddress = %s\nDNS = %s\n\n[Peer]\nPublicKey = %s\nAllowedIPs = %s\nEndpoint = %s\nPersistentKeepalive = 25\n' \
332 "$PRIVATE_KEY" "$ADDRESS" "$DNS" "$SERVER_KEY" "$ALLOWED" "$ENDPOINT" > "$CONF"
333
334# PNG is generated locally; no key or config is sent to any online QR service.
335# Reserve the filename first to prevent overwriting another file.
336( set -C; : > "$PNG" ) || fail 'Could not exclusively create QR file.'
337CREATED_PNG=1
338chmod 600 "$PNG"
339"$QR_PYTHON" - "$CONF" "$PNG" <<'PY'
340import sys
341import qrcode
342with open(sys.argv[1], 'r', encoding='ascii') as f:
343 config = f.read()
344qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M, box_size=8, border=4)
345qr.add_data(config)
346qr.make(fit=True)
347qr.make_image(fill_color='black', back_color='white').save(sys.argv[2])
348PY
349[[ -s "$PNG" ]] || fail 'QR generation failed.'
350SUCCESS=1
351unset PRIVATE_KEY
352say ''
353say 'SUCCESS: WireGuard client configuration and QR code created.'
354say "Config: $CONF"
355say "QR PNG: $PNG"
356say "Client PUBLIC key: $PUBLIC_KEY"
357say ''
358say 'Example RouterOS command (replace <WIREGUARD_INTERFACE> and verify IP is unused):'
359printf '/interface wireguard peers add interface=<WIREGUARD_INTERFACE> public-key="%s" allowed-address=%s/32 comment="%s"\n' "$PUBLIC_KEY" "$IP" "$NAME"
360say ''
361say 'SECURITY: Both files contain the private key. Share securely; never publish them.'
362say 'This tool does not check server-side IP conflicts or register peers automatically.'
363
364 trap - EXIT
365}
366
367while :; do
368 say ''
369 say '=== WireGuard Provisioning Manager (Linux / macOS / WSL) ==='
370 say '1. Generate new WireGuard configuration + QR PNG'
371 say '2. Display existing configuration as terminal QR code'
372 say '3. Exit'
373 read -r -p 'Select an option [1-3]: ' TASK || { say ''; exit 0; }
374 case "$TASK" in
375 1) generate_profile ;;
376 2) show_qr ;;
377 3) say 'Goodbye.'; exit 0 ;;
378 *) say 'Invalid selection. Choose 1, 2 or 3.' ;;
379 esac
380done
381