| 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. |
| 13 | set -euo pipefail |
| 14 | umask 077 |
| 15 | |
| 16 | say() { printf '%s\n' "$*"; } |
| 17 | # Homebrew is commonly installed outside the default PATH on macOS. |
| 18 | refresh_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 | } |
| 28 | fail() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } |
| 29 | ask() { |
| 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 | } |
| 42 | confirm_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 | } |
| 50 | install_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 | } |
| 67 | valid_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 | } |
| 78 | valid_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' |
| 83 | import base64, sys |
| 84 | try: |
| 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) |
| 88 | except Exception: |
| 89 | sys.exit(1) |
| 90 | PY |
| 91 | } |
| 92 | valid_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 | } |
| 105 | valid_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.' |
| 119 | OS="$(uname -s)" |
| 120 | [[ "$OS" == Linux || "$OS" == Darwin ]] || fail "Unsupported OS: $OS" |
| 121 | refresh_brew_path |
| 122 | OUT="${1:-$PWD/wireguard-profiles}" |
| 123 | |
| 124 | find_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 | } |
| 137 | ensure_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 | } |
| 162 | ensure_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. |
| 171 | create_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 | } |
| 178 | repair_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 | } |
| 197 | ensure_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 | |
| 232 | show_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' |
| 260 | import sys |
| 261 | import qrcode |
| 262 | |
| 263 | with 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. |
| 266 | qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M, border=4) |
| 267 | qr.add_data(content) |
| 268 | qr.make(fit=True) |
| 269 | matrix = qr.get_matrix() |
| 270 | height, width = len(matrix), len(matrix[0]) |
| 271 | utf8 = bool(sys.stdout.encoding and 'UTF' in sys.stdout.encoding.upper()) |
| 272 | |
| 273 | if not utf8: |
| 274 | print('Terminal is not configured for UTF-8; using standard ASCII QR.') |
| 275 | qr.print_ascii(invert=True) |
| 276 | else: |
| 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') |
| 284 | PYQR |
| 285 | say 'Scan with the WireGuard mobile app. Avoid recording or sharing this terminal.' |
| 286 | } |
| 287 | |
| 288 | generate_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.' |
| 296 | ask '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.' |
| 298 | IP="${ADDRESS%/*}" |
| 299 | ask 'DNS servers (comma-separated, e.g. 1.1.1.1, 9.9.9.9)'; DNS="$REPLY_VALUE" |
| 300 | valid_dns "$DNS" || fail 'DNS must contain IPv4 addresses separated by commas.' |
| 301 | ask '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.' |
| 303 | PORT="${BASH_REMATCH[1]}" |
| 304 | (( 10#$PORT >= 1 && 10#$PORT <= 65535 )) || fail 'Endpoint port must be 1-65535.' |
| 305 | ask 'AllowedIPs (IPv4 CIDR list)' '0.0.0.0/0'; ALLOWED="$REPLY_VALUE" |
| 306 | valid_routes "$ALLOWED" || fail 'AllowedIPs must be comma-separated IPv4 CIDR entries.' |
| 307 | ask 'WireGuard SERVER public key (Base64, no quotes)'; SERVER_KEY="$REPLY_VALUE" |
| 308 | valid_key "$SERVER_KEY" || fail 'Invalid server public key (32-byte Base64 expected).' |
| 309 | |
| 310 | mkdir -p "$OUT" |
| 311 | OUT="$(cd "$OUT" && pwd -P)" |
| 312 | CONF="$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 | |
| 315 | PRIVATE_KEY="$(wg genkey)" || fail 'wg genkey failed.' |
| 316 | PUBLIC_KEY="$(printf '%s\n' "$PRIVATE_KEY" | wg pubkey)" || fail 'wg pubkey failed.' |
| 317 | valid_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. |
| 321 | cleanup() { |
| 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 | } |
| 327 | trap cleanup EXIT |
| 328 | ( set -C; : > "$CONF" ) || fail 'Could not exclusively create config file.' |
| 329 | CREATED_CONF=1 |
| 330 | chmod 600 "$CONF" |
| 331 | printf '[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.' |
| 337 | CREATED_PNG=1 |
| 338 | chmod 600 "$PNG" |
| 339 | "$QR_PYTHON" - "$CONF" "$PNG" <<'PY' |
| 340 | import sys |
| 341 | import qrcode |
| 342 | with open(sys.argv[1], 'r', encoding='ascii') as f: |
| 343 | config = f.read() |
| 344 | qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_M, box_size=8, border=4) |
| 345 | qr.add_data(config) |
| 346 | qr.make(fit=True) |
| 347 | qr.make_image(fill_color='black', back_color='white').save(sys.argv[2]) |
| 348 | PY |
| 349 | [[ -s "$PNG" ]] || fail 'QR generation failed.' |
| 350 | SUCCESS=1 |
| 351 | unset PRIVATE_KEY |
| 352 | say '' |
| 353 | say 'SUCCESS: WireGuard client configuration and QR code created.' |
| 354 | say "Config: $CONF" |
| 355 | say "QR PNG: $PNG" |
| 356 | say "Client PUBLIC key: $PUBLIC_KEY" |
| 357 | say '' |
| 358 | say 'Example RouterOS command (replace <WIREGUARD_INTERFACE> and verify IP is unused):' |
| 359 | printf '/interface wireguard peers add interface=<WIREGUARD_INTERFACE> public-key="%s" allowed-address=%s/32 comment="%s"\n' "$PUBLIC_KEY" "$IP" "$NAME" |
| 360 | say '' |
| 361 | say 'SECURITY: Both files contain the private key. Share securely; never publish them.' |
| 362 | say 'This tool does not check server-side IP conflicts or register peers automatically.' |
| 363 | |
| 364 | trap - EXIT |
| 365 | } |
| 366 | |
| 367 | while :; 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 |
| 380 | done |
| 381 |
dartokloning / WireGuard client provisioning for Linux and macOS (offline QR generation)
Last active 14 hours ago
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.