#!/usr/bin/env sh # SPDX-License-Identifier: 0BSD # SPDX-FileCopyrightText: 2026 Kris Lamoureux # Allow local variable scoping, therefore not strictly POSIX # shellcheck disable=SC3043 msg() { local format format='%s' OPTIND=1 while getopts 'f:' opt; do case $opt in f) format="$OPTARG" ;; *) return 1 ;; esac done shift $((OPTIND - 1)) printf "[%s]: ${format}\n" "${2-INFO}" "$1" } info() { [ "$PIAWG_VERBOSE" -eq 1 ] && msg "$1"; } debug() { [ "$PIAWG_DEBUG" -eq 1 ] && msg "$@" 'DEBUG'; } warn() { msg "$1" 'WARN'; } err() { msg "$1" 'ERROR' exit 1 } check_http() { [ "$1" = "${2:-200}" ] && return 0 || return 1; } check_token() { printf '%s\n' "$1" | grep -q '^[0-9A-Fa-f]\{128\}$'; } _curl() { curl -sS --connect-timeout 5 --max-time 20 \ --retry 5 --retry-delay 2 "$@" } bao_curl() { local path local response local error local http_code local notfound local content_type notfound=0 content_type='application/json' OPTIND=1 while getopts "np" opt; do case "$opt" in n) notfound=1 ;; p) content_type='application/merge-patch+json' ;; *) err "bao_curl option '$opt' not found" ;; esac done shift $((OPTIND - 1)) path="$BAO_ADDR/v1/$1" shift error="Failed request to '$path'" if ! response=$(_curl -H "Content-Type: $content_type" \ -H "X-Vault-Token: $bao_token" -w '\n%{http_code}' "$@" "$path"); then err "$error" fi http_code="$(printf '%s' "$response" | tail -1)" if [ "$(check_http "$http_code" "404")" ] && [ "$notfound" -eq 1 ]; then info "OpenBao path at $path not found" return 4 fi check_http "$http_code" || err "$error" printf '%s' "$response" | sed '$d' } opn_curl() { local path local error local response local http_code path="$opn_endpoint/$1" shift error="Failed request to '$path'" if ! response="$(_curl -H 'Content-Type: application/json' \ -u "$opn_key:$opn_secret" -w '\n%{http_code}' \ "$@" "$path")"; then err "$error" fi http_code="$(printf '%s' "$response" | tail -1)" response="$(printf '%s' "$response" | sed '$d')" if ! check_http "$http_code"; then error_msg="$(printf '%s' "$response" | jq -r '.errorMessage // empty' 2>/dev/null)" [ -n "$error_msg" ] && error="$error_msg ($path)" fi check_http "$http_code" || err "$error" printf '%s' "$response" } pia_addkey() { local response local response_gw local key local port local peer_ip local updates local server_vip local gateway_ip local gateway_id # Add pubkey via PIA API and get connection details info "Adding '$piawg_pubkey' to PIA server $server_cn" if ! response=$( _curl -G --resolve "$server_cn:$server_port:$server_ip" \ --cacert ./ca.rsa.4096.crt \ --data-urlencode "pt=$pia_token" \ --data-urlencode "pubkey=$piawg_pubkey" \ "https://$server_cn:$server_port/addKey" ); then err "Failed connect to $server_cn to addKey" fi [ "$(printf '%s' "$response" | jq -r '.status')" != "OK" ] && err "Failed to addKey to $server_cn" debug -f 'PIA API addKey response\n%s' "$(echo "$response" | jq .)" # Update Wireguard config on OPNsense updates='{}' if [ "$server_ip" != "$piawgsrv_srvaddr" ]; then updates="$(printf '%s' "$updates" | jq --arg a "$server_ip" '.serveraddress = $a')" fi key="$(printf '%s' "$response" | jq -r '.server_key')" if [ "$key" != "$piawgsrv_pubkey" ]; then updates="$(printf '%s' "$updates" | jq --arg k "$key" '.pubkey = $k')" fi port="$(printf '%s' "$response" | jq -r '.server_port')" if [ "$port" != "$piawgsrv_srvport" ]; then updates="$(printf '%s' "$updates" | jq --arg p "$port" '.serverport = $p')" fi if [ "$updates" != '{}' ]; then updates="$(printf '%s' "$updates" | jq --arg s "$piawg_uuid" '.servers = $s')" info "Updating connection details of $OPN_IF peer $OPN_PEER" debug -f "$OPN_PEER updates\n%s" "$(echo "$updates" | jq .)" if [ "$(opn_curl "wireguard/client/setClient/$piawgsrv_uuid" \ -d "$(printf '%s' "$updates" | jq '{client: .}')" | jq -r '.result')" != "saved" ]; then err "Failed to update $OPN_PEER pubkey/port" fi fi peer_ip="$(printf '%s' "$response" | jq -r '.peer_ip')" if [ "$peer_ip" != "$piawg_tunaddr" ]; then info "Updating connection details of $OPN_IF instance" if [ "$(opn_curl "wireguard/server/setServer/$piawg_uuid" \ -d "$(jq -n --arg ip "$peer_ip/32" \ '{server: {tunneladdress: $ip}}')" | jq -r '.result')" != "saved" ]; then err "Failed to update $OPN_WG tunnel address to $peer_ip" fi fi info "Reloading Wireguard service" if [ "$(opn_curl 'wireguard/service/reconfigure' -d '{}' | jq -r '.result')" != "ok" ]; then err "Failed to reload Wireguard service" fi # Update OpenBao config with response data server_vip="$(printf '%s' "$response" | jq -r '.server_vip')" info "Update server_vip at $BAO_PATH_CONFIG to $server_vip" bao_curl -p "$BAO_KV_MOUNT/data/$BAO_PATH_CONFIG" -X PATCH -d \ "$(jq -n --arg ip "$server_vip" '{data:{server_vip:$ip}}')" >/dev/null # Update gateway address if ! response_gw="$(opn_curl \ 'routing/settings/search_gateway' -X POST -d '{}')"; then err "Failed to search gateways" fi debug -f "search_gateway\n%s" "$(printf '%s' "$response_gw" | jq .)" gateway_id="$(printf '%s' "$response_gw" | jq -r --arg name "$OPN_GW" \ '.rows[] | select(.name == $name) | .uuid')" gateway_ip="$(printf '%s' "$response_gw" | jq -r --arg name "$OPN_GW" \ '.rows[] | select(.name == $name) | .gateway')" if [ "$gateway_ip" != "$server_vip" ]; then info "Updating gateway $OPN_GW address from $gateway_ip to $server_vip" if ! update_gw="$(opn_curl \ "routing/settings/set_gateway/$gateway_id" -X POST \ -d "$(jq -nc --arg ip "$server_vip" \ '{gateway_item: {gateway: $ip}}')")"; then err "Failed to update gateway ($gateway_id)" fi if [ "$(printf '%s' "$update_gw" | jq -r '.result')" != "saved" ]; then err "Failed to save gateway update" fi info "Restarting WireGuard interface after gateway update" if [ "$(opn_curl "core/service/restart/wireguard/$piawg_uuid" \ -X POST -d '{}' | jq -r '.result')" != "ok" ]; then err "Failed to restart WireGuard interface after gateway update" fi fi # Update firewall rule alias piawg_ip_alias="$(opn_curl 'firewall/alias/searchItem' -d '{}' | jq -r ".rows[] | select(.name == \"$OPN_ALIAS\") | .uuid")" info "Updating alias $OPN_ALIAS to $peer_ip" piawg_ip_update="$(opn_curl "firewall/alias/setItem/$piawg_ip_alias" \ -d "$(jq -n --arg ip "$peer_ip" '{alias: {content: $ip}}')")" if [ "$(echo "$piawg_ip_update" | jq -r '.result')" != "saved" ]; then err "Failed to update $OPN_ALIAS" fi info "Applying alias update to the firewall" if [ "$(opn_curl 'firewall/alias/reconfigure' -d '{}' | jq -r '.status')" != "ok" ]; then err "Failed to reconfigure the firewall alias $OPN_ALIAS" fi } pia_forward() { local config local server_cn local server_vip local pf_signature local pf_payload local pf_port local pf_expires local pf_sig_reply local pf_updated_at local pf_update local epoch_now local epoch_expire local epoch_diff local bind_resp config="$1" server_cn="$(printf '%s' "$config" | jq -r '.data.data.server_cn')" server_vip="$(printf '%s' "$config" | jq -r '.data.data.server_vip')" pf_signature="$(printf '%s' "$config" | jq -r '.data.data.pf_signature')" pf_payload="$(printf '%s' "$config" | jq -r '.data.data.pf_payload')" pf_port="$(printf '%s' "$config" | jq -r '.data.data.pf_port')" pf_expires="$(printf '%s' "$config" | jq -r '.data.data.pf_expires')" pf_updated_at="$(printf '%s' "$config" | jq -r '.data.data.pf_updated_at')" epoch_now="$(date '+%s')" epoch_expire="$(date -j -f '%Y-%m-%dT%H:%M:%S' \ "${pf_expires%%.*}" '+%s' 2>/dev/null)" epoch_diff=$((epoch_now - ${pf_updated_at:-0})) if [ -z "$epoch_expire" ] || [ "$epoch_expire" -lt "$epoch_now" ]; then info "Requesting new port forward signature" if ! pf_sig_reply="$(_curl -G --cacert ./ca.rsa.4096.crt \ --resolve "$server_cn:19999:$server_vip" \ --data-urlencode "token=$pia_token" \ "https://$server_cn:19999/getSignature")"; then err "Failed to connect to https://$server_cn:19999/getSignature" fi debug -f "getSignature\n%s" "$(printf '%s' "$pf_sig_reply" | jq .)" [ "$(printf '%s' "$pf_sig_reply" | jq -r '.status')" != "OK" ] && err "getSignature failed" pf_signature="$(printf '%s' "$pf_sig_reply" | jq -r '.signature')" pf_payload="$(printf '%s' "$pf_sig_reply" | jq -r '.payload')" pf_port="$(printf '%s' "$pf_payload" | base64 -d 2>/dev/null | jq -r '.port // empty')" pf_expires="$(printf '%s' "$pf_payload" | base64 -d 2>/dev/null | jq -r '.expires_at // empty')" pf_update="$(jq -n --arg v "$pf_signature" '.data.pf_signature = $v')" pf_update="$(printf '%s' "$pf_update" | jq --arg v "$pf_payload" '.data.pf_payload = $v')" pf_update="$(printf '%s' "$pf_update" | jq --arg v "$pf_port" '.data.pf_port = $v')" pf_update="$(printf '%s' "$pf_update" | jq --arg v "$pf_expires" '.data.pf_expires = $v')" pf_update="$(printf '%s' "$pf_update" | jq --arg v "$(date '+%s')" '.data.pf_updated_at = $v')" debug -f "pf_update\n%s" "$(printf '%s' "$pf_update" | jq .)" info "Updating $BAO_PATH_CONFIG with pf_port=$pf_port" bao_curl -p "$BAO_KV_MOUNT/data/$BAO_PATH_CONFIG" \ -X PATCH -d "$pf_update" >/dev/null elif [ "$epoch_diff" -ge 300 ]; then info "Renewing port forward ($epoch_diff/300)" if ! bind_resp="$(_curl -G --resolve "${server_cn}:19999:${server_vip}" \ --data-urlencode "payload=${pf_payload}" \ --data-urlencode "signature=${pf_signature}" \ "https://${server_cn}:19999/bindPort")"; then err "Failed to bindPort (https://${server_cn}:19999/bindPort)" fi [ "$(printf '%s' "$bind_resp" | jq -r '.status')" != "OK" ] && err "Failed to get bindPort status" debug -f "bind_resp\n%s" "$(printf '%s' "$bind_resp" | jq .)" pf_update="$(jq -n --arg v "$(date '+%s')" '.data.pf_updated_at = $v')" debug -f "pf_update\n%s" "$(printf '%s' "$pf_update" | jq .)" info "Updating pf_updated_at $BAO_PATH_CONFIG" bao_curl -p "$BAO_KV_MOUNT/data/$BAO_PATH_CONFIG" \ -X PATCH -d "$pf_update" >/dev/null else info "Port forward still valid, updated ${epoch_diff}s ago" fi } check_tunnel() { local tunneladdr local response local peer_status tunneladdr="$(opn_curl 'wireguard/server/searchServer' -d '{}' | jq -r ".rows[] | select(.name == \"$OPN_IF\") | .tunneladdress")" response="$(opn_curl 'wireguard/service/show' -d '{}' | jq ".rows[] | select(.name == \"$OPN_PEER\")")" peer_status="$(printf '%s' "$response" | jq -r '."peer-status"')" [ "$peer_status" != "online" ] && return 1 if ! ping -c1 -W3 -S "${tunneladdr%/32}" 1.1.1.1 >/dev/null 2>&1; then if ! ping -c1 -W3 -S "${tunneladdr%/32}" 8.8.8.8 >/dev/null 2>&1; then return 1 fi fi return 0 } # Get a new PIA API token and store renew_token() { local login_response local token_response local pia_user local pia_pass local pia_token login_response="$(bao_curl "$BAO_KV_MOUNT/data/$BAO_PATH_PIA")" pia_user="$(printf '%s' "$login_response" | jq -r '.data.data.username')" pia_pass="$(printf '%s' "$login_response" | jq -r '.data.data.password')" unset login_response if ! token_response="$(_curl -X POST "$PIA_API" \ -F "username=$pia_user" \ -F "password=$pia_pass")"; then err "Failed to get a new PIA token" fi unset pia_pass unset pia_user pia_token="$(echo "$token_response" | jq -r .token)" unset token_response check_token "$pia_token" || err "Invalid PIA token during renewal attempt" bao_curl "$BAO_KV_MOUNT/data/$BAO_PATH_TOKEN" -X POST \ -d "$(jq -n --arg t "$pia_token" '{data:{token:$t}}')" unset pia_token } PIAWG_DEBUG=0 PIAWG_VERBOSE=0 OPTIND=1 while getopts "dv" opt; do case $opt in d) PIAWG_VERBOSE=1 PIAWG_DEBUG=1 ;; v) PIAWG_VERBOSE=1 ;; *) printf '%s\n' "Usage: $0 [-v]" >&2 exit 1 ;; esac done shift $((OPTIND - 1)) # Check for required external commands for rbin in curl jq openssl; do command -v "$rbin" >/dev/null 2>&1 || err "Required binary '$rbin' not found" done # Setup config structure : "${PIAWG_CONF_DIR:=$HOME/.config/piawg}" : "${PIAWG_CONF:=$PIAWG_CONF_DIR/config}" [ -d "$HOME/.config" ] || install -d -m 0755 "$HOME/.config" [ -d "$PIAWG_CONF_DIR" ] || install -d -m 0700 "$PIAWG_CONF_DIR" if [ ! -f "$PIAWG_CONF" ]; then install -m 0600 /dev/null "$PIAWG_CONF" cat <<-'EOF' >"$PIAWG_CONF" # piawg configuration #BAO_ADDR= #BAO_ROLE= #BAO_SECRET= EOF info "Created '$PIAWG_CONF' configuration file" fi # Source config if [ -r "$PIAWG_CONF" ]; then # shellcheck source=/dev/null . "$PIAWG_CONF" else err "Can't find config at '$PIAWG_CONF'" fi # Must set these in PIAWG_CONF : "${BAO_ADDR:?[ERROR]: BAO_ADDR is not set}" : "${BAO_ROLE:?[ERROR]: BAO_ROLE is not set}" : "${BAO_SECRET:?[ERROR]: BAO_SECRET is not set}" # Overridable defaults _fingerprint=1fd25658456eab3041fba77ccd398ab8124edcc1b8b2fc1d55fdf6b1bbfc9d70 : "${PIA_API:=https://www.privateinternetaccess.com/api/client/v2/token}" : "${PIA_CRT:=https://www.privateinternetaccess.com/openvpn/ca.rsa.4096.crt}" : "${PIA_HASH:=$_fingerprint}" : "${BAO_AUTH_PATH:=approle}" : "${BAO_KV_MOUNT:=kv}" : "${BAO_PATH_CONFIG:=piawg/config/wireguard}" : "${BAO_PATH_OPNSENSE:=piawg/creds/opnsense}" : "${BAO_PATH_PIA:=piawg/creds/pia}" : "${BAO_PATH_TOKEN:=piawg/session/token}" : "${OPN_IF:=PIAwg}" : "${OPN_PEER:=PIAwg_srv}" : "${OPN_ALIAS:=PIAwg_IP}" : "${OPN_GW:=PIAWG_VPN}" # Get ephemeral session token from AppRole login if ! bao_token_reply=$(_curl -H 'Content-Type: application/json' \ -d "{\"role_id\":\"$BAO_ROLE\",\"secret_id\":\"$BAO_SECRET\"}" \ "$BAO_ADDR/v1/auth/$BAO_AUTH_PATH/login"); then err "Failed to login to '$BAO_ADDR'" fi bao_token=$(printf '%s' "$bao_token_reply" | jq -er '.auth.client_token') unset bao_token_reply [ -n "$bao_token" ] || err "Failed to get token from '$BAO_ADDR'" # Get latest PIA token get_token_reply="$( bao_curl -n "$BAO_KV_MOUNT/data/$BAO_PATH_TOKEN" )" && rc=0 || rc=$? if [ "$rc" -eq 4 ]; then info "Renewing PIA token" renew_token get_token_reply="$(bao_curl "$BAO_KV_MOUNT/data/$BAO_PATH_TOKEN")" fi pia_token="$(printf '%s' "$get_token_reply" | jq -r .data.data.token)" unset get_token_reply check_token "$pia_token" || err "Failed to get valid PIA token" # Download PIA RSA CA certificate if [ ! -f ./ca.rsa.4096.crt ]; then [ -f ./.ca.rsa.4096.crt ] && rm ./.ca.rsa.4096.crt info "Downloading PIA's CA certificate" _curl -o ./.ca.rsa.4096.crt "$PIA_CRT" pia_file_hash="$(openssl x509 -in ./.ca.rsa.4096.crt -outform DER | openssl dgst -sha256 -r | awk '{print $1}')" [ "$pia_file_hash" != "$PIA_HASH" ] && err "PIA CA fingerprint mismatch" mv ./.ca.rsa.4096.crt ./ca.rsa.4096.crt fi # Get OPNsense login details bao_opn_login="$(bao_curl "$BAO_KV_MOUNT/data/$BAO_PATH_OPNSENSE")" opn_key="$(printf '%s' "$bao_opn_login" | jq -r .data.data.key)" opn_secret="$(printf '%s' "$bao_opn_login" | jq -r .data.data.secret)" opn_endpoint="$(printf '%s' "$bao_opn_login" | jq -r .data.data.endpoint)" debug -f "OPNsense login details from OpenBao ($BAO_PATH_OPNSENSE)\n%s" \ "$(printf '%s' "$bao_opn_login" | jq '.data.data.secret = "[CENSORED]" | .data.data.key = "[CENSORED]"')" opn_endpoint="${opn_endpoint%/}" # Get OPNsense Wireguard config opn_if_reply="$(opn_curl 'wireguard/server/searchServer' -d '{}' | jq ".rows[] | select(.name == \"$OPN_IF\")")" piawg_uuid="$(printf '%s' "$opn_if_reply" | jq -r .uuid)" piawg_pubkey="$(printf '%s' "$opn_if_reply" | jq -r .pubkey)" piawg_tunaddr="$(printf '%s' "$opn_if_reply" | jq -r .tunneladdress)" debug -f "Wireguard instance $OPN_IF from OPNsense API\n%s" \ "$(printf '%s' "$opn_if_reply" | jq '.privkey = "[CENSORED]"')" unset opn_if_reply opn_peer_reply="$(opn_curl 'wireguard/client/searchClient' -d '{}' | jq ".rows[] | select(.name == \"$OPN_PEER\")")" piawgsrv_uuid="$(printf '%s' "$opn_peer_reply" | jq -r .uuid)" piawgsrv_pubkey="$(printf '%s' "$opn_peer_reply" | jq -r .pubkey)" piawgsrv_srvaddr="$(printf '%s' "$opn_peer_reply" | jq -r .serveraddress)" piawgsrv_srvport="$(printf '%s' "$opn_peer_reply" | jq -r .serverport)" debug -f "Wireguard peer $OPN_PEER from OPNsense API\n%s" \ "$(printf '%s' "$opn_peer_reply" | jq .)" unset opn_peer_reply # Get target server IP, common name, and OPNsense WG pubkey wg_reply="$(bao_curl "$BAO_KV_MOUNT/data/$BAO_PATH_CONFIG")" server_ip="$(printf '%s' "$wg_reply" | jq -r .data.data.server_ip)" server_cn="$(printf '%s' "$wg_reply" | jq -r .data.data.server_cn)" server_port="$(printf '%s' "$wg_reply" | jq -r .data.data.server_port)" server_vip="$(printf '%s' "$wg_reply" | jq -r .data.data.server_vip)" debug -f "Config from OpenBao ($BAO_PATH_CONFIG)\n%s" \ "$(printf '%s' "$wg_reply" | jq .)" unset wg_reply # Update to reflect desired state if [ "$server_ip" != "$piawgsrv_srvaddr" ]; then info "Updating $OPN_IF tunnel with new IP $server_ip" pia_addkey info "Pausing 2 seconds for new tunnel" sleep 2 if check_tunnel; then info "New tunnel on $OPN_IF is working" else err "New tunnel on $OPN_IF is broken" fi else if check_tunnel; then info "Tunnel on $OPN_IF is working" else warn "Tunnel on $OPN_IF is broken" pia_addkey info "Pausing 2 seconds for new tunnel" sleep 2 if check_tunnel; then info "New tunnel on $OPN_IF is working" else err "New tunnel on $OPN_IF is broken" fi fi fi # Optional: port forward if conf_reply="$(bao_curl "$BAO_KV_MOUNT/data/$BAO_PATH_CONFIG")"; then debug -f "Check for port_forward value in OpenBao ($BAO_PATH_CONFIG)\n%s" \ "$(printf '%s' "$conf_reply" | jq .)" port_forward="$(printf '%s' "$conf_reply" | jq -r '.data.data.port_forward')" [ "$port_forward" = "true" ] && pia_forward "$conf_reply" fi