own wireguard
Build and Push Docker Images / build-and-push (push) Successful in 1m28s

This commit is contained in:
2026-05-05 21:02:00 +02:00
parent d96ea7ce29
commit 1180e9e1b0
7 changed files with 532 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
FROM debian:bookworm-slim
ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
curl \
iproute2 \
iptables \
jq \
procps \
wireguard-tools \
&& rm -rf /var/lib/apt/lists/*
COPY ca.rsa.4096.crt /opt/pia/ca.rsa.4096.crt
COPY pia.sh /usr/local/lib/pia.sh
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod 0644 /opt/pia/ca.rsa.4096.crt \
&& chmod 0755 /usr/local/lib/pia.sh /usr/local/bin/entrypoint.sh
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
+38
View File
@@ -0,0 +1,38 @@
# `pia-wireguard` (PIA WireGuard gateway container)
Deze container zet **Private Internet Access (PIA)** op via **WireGuard** en laat andere containers dezelfde VPN-stack gebruiken via `network_mode: service:...`.
## Vereisten
- Docker/Compose
- Kernel WireGuard support op de host
- Container runtime permissies:
- `cap_add: [NET_ADMIN]`
- `devices: [/dev/net/tun]`
## Environment variables
- **Required**
- `PIA_USER`: je PIA username
- `PIA_PASS`: je PIA password
- `PIA_REGIONS`: CSV (`nl_amsterdam,de_berlin`) of JSON array (`["nl_amsterdam","de_berlin"]`)
- **Optional**
- `PIA_PORT_FORWARD`: `true|false` (default `false`)
- `PIA_STATE_DIR`: default `/pia`
- `PIA_PF_FILE`: default `/pia/port_forward.json`
- `PIA_PF_PORT_FILE`: default `/pia/port_forward.port`
- `PIA_HANDSHAKE_MAX_AGE_SECONDS`: default `180`
- `PIA_HEALTH_URL`: default `https://api64.ipify.org`
## Output
Wanneer `PIA_PORT_FORWARD=true`:
- `port_forward.json` (mountbaar naar host): bevat `port`, `region`, `expires_at`, `refreshed_at`
- `port_forward.port`: bevat enkel het poortnummer
## Compose voorbeeld
Zie [`docker-compose.example.yml`](docker-compose.example.yml).
+43
View File
@@ -0,0 +1,43 @@
-----BEGIN CERTIFICATE-----
MIIHqzCCBZOgAwIBAgIJAJ0u+vODZJntMA0GCSqGSIb3DQEBDQUAMIHoMQswCQYD
VQQGEwJVUzELMAkGA1UECBMCQ0ExEzARBgNVBAcTCkxvc0FuZ2VsZXMxIDAeBgNV
BAoTF1ByaXZhdGUgSW50ZXJuZXQgQWNjZXNzMSAwHgYDVQQLExdQcml2YXRlIElu
dGVybmV0IEFjY2VzczEgMB4GA1UEAxMXUHJpdmF0ZSBJbnRlcm5ldCBBY2Nlc3Mx
IDAeBgNVBCkTF1ByaXZhdGUgSW50ZXJuZXQgQWNjZXNzMS8wLQYJKoZIhvcNAQkB
FiBzZWN1cmVAcHJpdmF0ZWludGVybmV0YWNjZXNzLmNvbTAeFw0xNDA0MTcxNzQw
MzNaFw0zNDA0MTIxNzQwMzNaMIHoMQswCQYDVQQGEwJVUzELMAkGA1UECBMCQ0Ex
EzARBgNVBAcTCkxvc0FuZ2VsZXMxIDAeBgNVBAoTF1ByaXZhdGUgSW50ZXJuZXQg
QWNjZXNzMSAwHgYDVQQLExdQcml2YXRlIEludGVybmV0IEFjY2VzczEgMB4GA1UE
AxMXUHJpdmF0ZSBJbnRlcm5ldCBBY2Nlc3MxIDAeBgNVBCkTF1ByaXZhdGUgSW50
ZXJuZXQgQWNjZXNzMS8wLQYJKoZIhvcNAQkBFiBzZWN1cmVAcHJpdmF0ZWludGVy
bmV0YWNjZXNzLmNvbTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBALVk
hjumaqBbL8aSgj6xbX1QPTfTd1qHsAZd2B97m8Vw31c/2yQgZNf5qZY0+jOIHULN
De4R9TIvyBEbvnAg/OkPw8n/+ScgYOeH876VUXzjLDBnDb8DLr/+w9oVsuDeFJ9K
V2UFM1OYX0SnkHnrYAN2QLF98ESK4NCSU01h5zkcgmQ+qKSfA9Ny0/UpsKPBFqsQ
25NvjDWFhCpeqCHKUJ4Be27CDbSl7lAkBuHMPHJs8f8xPgAbHRXZOxVCpayZ2SND
fCwsnGWpWFoMGvdMbygngCn6jA/W1VSFOlRlfLuuGe7QFfDwA0jaLCxuWt/BgZyl
p7tAzYKR8lnWmtUCPm4+BtjyVDYtDCiGBD9Z4P13RFWvJHw5aapx/5W/CuvVyI7p
Kwvc2IT+KPxCUhH1XI8ca5RN3C9NoPJJf6qpg4g0rJH3aaWkoMRrYvQ+5PXXYUzj
tRHImghRGd/ydERYoAZXuGSbPkm9Y/p2X8unLcW+F0xpJD98+ZI+tzSsI99Zs5wi
jSUGYr9/j18KHFTMQ8n+1jauc5bCCegN27dPeKXNSZ5riXFL2XX6BkY68y58UaNz
meGMiUL9BOV1iV+PMb7B7PYs7oFLjAhh0EdyvfHkrh/ZV9BEhtFa7yXp8XR0J6vz
1YV9R6DYJmLjOEbhU8N0gc3tZm4Qz39lIIG6w3FDAgMBAAGjggFUMIIBUDAdBgNV
HQ4EFgQUrsRtyWJftjpdRM0+925Y6Cl08SUwggEfBgNVHSMEggEWMIIBEoAUrsRt
yWJftjpdRM0+925Y6Cl08SWhge6kgeswgegxCzAJBgNVBAYTAlVTMQswCQYDVQQI
EwJDQTETMBEGA1UEBxMKTG9zQW5nZWxlczEgMB4GA1UEChMXUHJpdmF0ZSBJbnRl
cm5ldCBBY2Nlc3MxIDAeBgNVBAsTF1ByaXZhdGUgSW50ZXJuZXQgQWNjZXNzMSAw
HgYDVQQDExdQcml2YXRlIEludGVybmV0IEFjY2VzczEgMB4GA1UEKRMXUHJpdmF0
ZSBJbnRlcm5ldCBBY2Nlc3MxLzAtBgkqhkiG9w0BCQEWIHNlY3VyZUBwcml2YXRl
aW50ZXJuZXRhY2Nlc3MuY29tggkAnS7684Nkme0wDAYDVR0TBAUwAwEB/zANBgkq
hkiG9w0BAQ0FAAOCAgEAJsfhsPk3r8kLXLxY+v+vHzbr4ufNtqnL9/1Uuf8NrsCt
pXAoyZ0YqfbkWx3NHTZ7OE9ZRhdMP/RqHQE1p4N4Sa1nZKhTKasV6KhHDqSCt/dv
Em89xWm2MVA7nyzQxVlHa9AkcBaemcXEiyT19XdpiXOP4Vhs+J1R5m8zQOxZlV1G
tF9vsXmJqWZpOVPmZ8f35BCsYPvv4yMewnrtAC8PFEK/bOPeYcKN50bol22QYaZu
LfpkHfNiFTnfMh8sl/ablPyNY7DUNiP5DRcMdIwmfGQxR5WEQoHL3yPJ42LkB5zs
6jIm26DGNXfwura/mi105+ENH1CaROtRYwkiHb08U6qLXXJz80mWJkT90nr8Asj3
5xN2cUppg74nG3YVav/38P48T56hG1NHbYF5uOCske19F6wi9maUoto/3vEr0rnX
JUp2KODmKdvBI7co245lHBABWikk8VfejQSlCtDBXn644ZMtAdoxKNfR2WTFVEwJ
iyd1Fzx0yujuiXDROLhISLQDRjVVAvawrAtLZWYK31bY7KlezPlQnl/D9Asxe85l
8jO5+0LdJ6VyOs/Hd4w52alDW/MFySDZSfQHMTIc30hLBJ8OnCEIvluVQQ2UQvoW
+no177N9L2Y+M9TcTA62ZyMXShHQGeh20rb4kK8f+iFX8NxtdHVSkxMEFSfDDyQ=
-----END CERTIFICATE-----
@@ -0,0 +1,25 @@
services:
piauplink:
build: .
container_name: piauplink
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
sysctls:
- net.ipv4.conf.all.src_valid_mark=1
environment:
- PIA_USER=${PIA_USER}
- PIA_PASS=${PIA_PASS}
- PIA_REGIONS=nl_amsterdam,de_berlin
- PIA_PORT_FORWARD=false
volumes:
- ./pia-state:/pia
restart: unless-stopped
whoami_behind_vpn:
image: traefik/whoami:latest
network_mode: service:piauplink
depends_on:
- piauplink
restart: unless-stopped
+282
View File
@@ -0,0 +1,282 @@
#!/usr/bin/env bash
set -euo pipefail
source /usr/local/lib/pia.sh
PIA_IFACE="${PIA_IFACE:-wg0}"
PIA_STATE_DIR="${PIA_STATE_DIR:-/pia}"
PIA_PF_ENABLED="${PIA_PORT_FORWARD:-false}"
PIA_PF_FILE="${PIA_PF_FILE:-$PIA_STATE_DIR/port_forward.json}"
PIA_PF_PORT_FILE="${PIA_PF_PORT_FILE:-$PIA_STATE_DIR/port_forward.port}"
PIA_DNS_ENABLE="${PIA_DNS_ENABLE:-true}"
PIA_DNS_FALLBACK="${PIA_DNS_FALLBACK:-10.0.0.242}"
PIA_HEALTH_URL="${PIA_HEALTH_URL:-https://api64.ipify.org}"
PIA_HANDSHAKE_MAX_AGE_SECONDS="${PIA_HANDSHAKE_MAX_AGE_SECONDS:-180}"
PIA_RECONNECT_BACKOFF_SECONDS="${PIA_RECONNECT_BACKOFF_SECONDS:-5}"
CA_CERT_PATH="/opt/pia/ca.rsa.4096.crt"
pf_pid=""
current_region=""
current_wg_ip=""
current_wg_cn=""
cleanup() {
set +e
if [[ -n "$pf_pid" ]]; then
kill "$pf_pid" 2>/dev/null || true
wait "$pf_pid" 2>/dev/null || true
pf_pid=""
fi
iptables -D OUTPUT -j PIA_KILLSWITCH 2>/dev/null || true
iptables -F PIA_KILLSWITCH 2>/dev/null || true
iptables -X PIA_KILLSWITCH 2>/dev/null || true
wg-quick down "$PIA_IFACE" 2>/dev/null || true
}
trap cleanup EXIT INT TERM
ensure_state_dir() {
mkdir -p "$PIA_STATE_DIR"
chmod 0755 "$PIA_STATE_DIR"
}
write_resolv_conf() {
local dns="$1"
log "Setting /etc/resolv.conf to DNS $dns"
printf "nameserver %s\n" "$dns" > /etc/resolv.conf
}
setup_killswitch() {
local endpoint_ip="$1"
local endpoint_port="$2"
iptables -N PIA_KILLSWITCH 2>/dev/null || true
iptables -F PIA_KILLSWITCH
iptables -A PIA_KILLSWITCH -o lo -j ACCEPT
iptables -A PIA_KILLSWITCH -o "$PIA_IFACE" -j ACCEPT
iptables -A PIA_KILLSWITCH -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
# Allow negotiating the tunnel itself (endpoint traffic leaves on eth0)
iptables -A PIA_KILLSWITCH -p udp -d "$endpoint_ip" --dport "$endpoint_port" -j ACCEPT
# Allow local traffic (docker internal + loopback ranges)
iptables -A PIA_KILLSWITCH -d 127.0.0.0/8 -j ACCEPT
iptables -A PIA_KILLSWITCH -d 172.16.0.0/12 -j ACCEPT
iptables -A PIA_KILLSWITCH -d 192.168.0.0/16 -j ACCEPT
iptables -A PIA_KILLSWITCH -d 10.0.0.0/8 -j ACCEPT
iptables -A PIA_KILLSWITCH -j REJECT
iptables -C OUTPUT -j PIA_KILLSWITCH 2>/dev/null || iptables -I OUTPUT 1 -j PIA_KILLSWITCH
}
is_tunnel_healthy() {
if ! wg show "$PIA_IFACE" >/dev/null 2>&1; then
return 1
fi
local now latest age
now="$(date +%s)"
latest="$(wg show "$PIA_IFACE" latest-handshakes 2>/dev/null | awk '{print $2}' | sort -nr | head -n1 || true)"
if [[ -z "$latest" || "$latest" == "0" ]]; then
return 1
fi
age="$(( now - latest ))"
if (( age > PIA_HANDSHAKE_MAX_AGE_SECONDS )); then
return 1
fi
curl -fsS --max-time 5 "$PIA_HEALTH_URL" >/dev/null
}
start_port_forwarding_loop() {
local token="$1"
local wg_ip="$2"
local wg_cn="$3"
local region="$4"
(
set -euo pipefail
local sigresp status payload signature port expires_at bindresp
while true; do
sigresp="$(pia_pf_get_signature "$wg_ip" "$wg_cn" "$token" "$CA_CERT_PATH")"
status="$(jq -r '.status' <<<"$sigresp")"
if [[ "$status" != "OK" ]]; then
>&2 echo "PF getSignature did not return OK: $sigresp"
sleep 5
continue
fi
payload="$(jq -r '.payload' <<<"$sigresp")"
signature="$(jq -r '.signature' <<<"$sigresp")"
port="$(echo "$payload" | base64 -d | jq -r '.port')"
expires_at="$(echo "$payload" | base64 -d | jq -r '.expires_at')"
bindresp="$(pia_pf_bind_port "$wg_ip" "$wg_cn" "$payload" "$signature" "$CA_CERT_PATH")"
if [[ "$(jq -r '.status' <<<"$bindresp")" != "OK" ]]; then
>&2 echo "PF bindPort did not return OK: $bindresp"
sleep 10
continue
fi
ensure_state_dir
jq -n \
--arg region "$region" \
--arg server_ip "$wg_ip" \
--arg server_cn "$wg_cn" \
--argjson port "$port" \
--arg expires_at "$expires_at" \
--arg refreshed_at "$(date -Is)" \
'{region:$region,server:{ip:$server_ip,cn:$server_cn},port:$port,expires_at:$expires_at,refreshed_at:$refreshed_at}' \
> "$PIA_PF_FILE.tmp" \
&& mv "$PIA_PF_FILE.tmp" "$PIA_PF_FILE"
echo "$port" > "$PIA_PF_PORT_FILE.tmp" && mv "$PIA_PF_PORT_FILE.tmp" "$PIA_PF_PORT_FILE"
sleep 900
done
) &
pf_pid="$!"
log "Port-forward loop started (pid=$pf_pid)."
}
connect_region() {
local region="$1"
local wg_ip="$2"
local wg_cn="$3"
local token="$4"
log "Connecting region=$region wg=${wg_cn} (${wg_ip})"
local privKey pubKey wgjson status peer_ip server_key server_port dns_server
privKey="$(wg genkey)"
pubKey="$(echo "$privKey" | wg pubkey)"
wgjson="$(pia_wireguard_add_key "$wg_ip" "$wg_cn" "$token" "$pubKey" "$CA_CERT_PATH")"
status="$(jq -r '.status' <<<"$wgjson")"
if [[ "$status" != "OK" ]]; then
>&2 echo "WireGuard addKey failed: $wgjson"
return 1
fi
peer_ip="$(jq -r '.peer_ip' <<<"$wgjson")"
server_key="$(jq -r '.server_key' <<<"$wgjson")"
server_port="$(jq -r '.server_port' <<<"$wgjson")"
dns_server="$(jq -r '.dns_servers[0] // empty' <<<"$wgjson")"
if [[ -z "$dns_server" ]]; then
dns_server="$PIA_DNS_FALLBACK"
fi
mkdir -p /etc/wireguard
cat >"/etc/wireguard/${PIA_IFACE}.conf" <<EOF
[Interface]
Address = ${peer_ip}
PrivateKey = ${privKey}
[Peer]
PersistentKeepalive = 25
PublicKey = ${server_key}
AllowedIPs = 0.0.0.0/0
Endpoint = ${wg_ip}:${server_port}
EOF
if [[ "$PIA_DNS_ENABLE" == "true" ]]; then
write_resolv_conf "$dns_server"
fi
wg-quick down "$PIA_IFACE" 2>/dev/null || true
wg-quick up "$PIA_IFACE"
setup_killswitch "$wg_ip" "$server_port"
current_region="$region"
current_wg_ip="$wg_ip"
current_wg_cn="$wg_cn"
log "Connected. Verifying egress."
curl -fsS --max-time 10 "$PIA_HEALTH_URL" | tr -d '\n' | sed 's/^/PublicIP=/' || true
echo
if [[ "$PIA_PF_ENABLED" == "true" ]]; then
start_port_forwarding_loop "$token" "$wg_ip" "$wg_cn" "$region"
fi
}
main() {
require_env PIA_USER
require_env PIA_PASS
require_env PIA_REGIONS
ensure_state_dir
log "Authenticating to PIA."
local token
token="$(pia_get_token)"
log "Fetching serverlist."
local serverlist
serverlist="$(pia_serverlist_v4)"
local candidates
candidates="$(pia_pick_region_candidates <<<"$serverlist")"
if [[ -z "$candidates" ]]; then
>&2 echo "No matching WireGuard servers found for PIA_REGIONS=$(printf %q "${PIA_REGIONS}")"
exit 4
fi
log "Starting connect loop."
local line region wg_ip wg_cn connected=0
while IFS= read -r line; do
region="${line%%|*}"
wg_ip="$(cut -d'|' -f2 <<<"$line")"
wg_cn="$(cut -d'|' -f3 <<<"$line")"
if connect_region "$region" "$wg_ip" "$wg_cn" "$token"; then
connected=1
break
fi
done <<<"$candidates"
if (( connected == 0 )); then
>&2 echo "Failed to connect to any region in PIA_REGIONS."
exit 5
fi
# Recovery loop (runs forever)
local idx=0 total
total="$(wc -l <<<"$candidates" | tr -d ' ')"
while true; do
sleep 15
if is_tunnel_healthy; then
continue
fi
log "Tunnel unhealthy. Reconnecting (rotate region)."
cleanup
idx=$(( (idx + 1) % total ))
line="$(sed -n "$((idx + 1))p" <<<"$candidates")"
region="${line%%|*}"
wg_ip="$(cut -d'|' -f2 <<<"$line")"
wg_cn="$(cut -d'|' -f3 <<<"$line")"
until connect_region "$region" "$wg_ip" "$wg_cn" "$token"; do
log "Reconnect failed; sleeping ${PIA_RECONNECT_BACKOFF_SECONDS}s."
sleep "$PIA_RECONNECT_BACKOFF_SECONDS"
idx=$(( (idx + 1) % total ))
line="$(sed -n "$((idx + 1))p" <<<"$candidates")"
region="${line%%|*}"
wg_ip="$(cut -d'|' -f2 <<<"$line")"
wg_cn="$(cut -d'|' -f3 <<<"$line")"
done
done
}
main "$@"
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env bash
set -euo pipefail
log() { echo "[$(date -Is)] $*"; }
require_env() {
local name="$1"
if [[ -z "${!name:-}" ]]; then
>&2 echo "Missing required env var: $name"
exit 2
fi
}
pia_get_token() {
require_env PIA_USER
require_env PIA_PASS
local resp token
resp="$(curl -fsS --location --request POST \
'https://www.privateinternetaccess.com/api/client/v2/token' \
--form "username=${PIA_USER}" \
--form "password=${PIA_PASS}")"
token="$(jq -r '.token // empty' <<<"$resp")"
if [[ -z "$token" ]]; then
>&2 echo "Failed to authenticate to PIA (no token). Response:"
>&2 echo "$resp" | head -c 2000
exit 3
fi
echo "$token"
}
pia_serverlist_v4() {
curl -fsS 'https://serverlist.piaservers.net/vpninfo/servers/v4'
}
parse_regions() {
# Supports:
# - CSV: "nl_amsterdam,de_berlin"
# - JSON: '["nl_amsterdam","de_berlin"]'
local raw="${PIA_REGIONS:-}"
if [[ -z "$raw" ]]; then
>&2 echo "Missing required env var: PIA_REGIONS"
exit 2
fi
if [[ "$raw" =~ ^\[.*\]$ ]]; then
jq -r '.[]' <<<"$raw"
return
fi
tr ',' '\n' <<<"$raw" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' | awk 'NF'
}
pia_pick_region_candidates() {
# Input: serverlist JSON via stdin
# Output: for each requested region id -> line "region_id|wg_ip|wg_cn"
local requested
requested="$(parse_regions | tr '\n' ' ')"
jq -r --arg requested "$requested" '
def wanted($id): ($requested | split(" ") | index($id)) != null;
.regions[]
| select(wanted(.id))
| . as $r
| ($r.servers.wg[0] // empty) as $wg
| select($wg != null)
| "\($r.id)|\($wg.ip)|\($wg.cn)"
'
}
pia_wireguard_add_key() {
# Args: WG_SERVER_IP WG_HOSTNAME PIA_TOKEN PUBKEY CA_CERT_PATH
local wg_ip="$1"
local wg_hostname="$2"
local token="$3"
local pubkey="$4"
local ca_cert="$5"
curl -fsS -G \
--connect-to "${wg_hostname}::${wg_ip}:" \
--cacert "$ca_cert" \
--data-urlencode "pt=${token}" \
--data-urlencode "pubkey=${pubkey}" \
"https://${wg_hostname}:1337/addKey"
}
pia_pf_get_signature() {
# Args: PF_GATEWAY PF_HOSTNAME PIA_TOKEN CA_CERT_PATH
local gateway="$1"
local hostname="$2"
local token="$3"
local ca_cert="$4"
curl -fsS -m 10 \
--connect-to "${hostname}::${gateway}:" \
--cacert "$ca_cert" \
-G --data-urlencode "token=${token}" \
"https://${hostname}:19999/getSignature"
}
pia_pf_bind_port() {
# Args: PF_GATEWAY PF_HOSTNAME PAYLOAD SIGNATURE CA_CERT_PATH
local gateway="$1"
local hostname="$2"
local payload="$3"
local signature="$4"
local ca_cert="$5"
curl -fsS -m 10 \
--connect-to "${hostname}::${gateway}:" \
--cacert "$ca_cert" \
-G \
--data-urlencode "payload=${payload}" \
--data-urlencode "signature=${signature}" \
"https://${hostname}:19999/bindPort"
}
+1
View File
@@ -0,0 +1 @@
latest