├── .github └── workflows │ ├── docker-build-push.yml │ ├── dockerhub-description.yml │ └── shellcheck.yml ├── Dockerfile ├── README.md ├── RegionsListPubKey.pem ├── docker-compose.yml ├── extra ├── README.md ├── pf.sh ├── pia-auth.sh └── wg-gen.sh ├── healthcheck.sh ├── pf_success.sh └── run /.github/workflows/docker-build-push.yml: -------------------------------------------------------------------------------- 1 | name: Docker Build & Push 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'master' 7 | - 'testing' 8 | paths-ignore: 9 | - 'README.md' 10 | - 'docker-compose.yml' 11 | # Rebuild monthly to keep packages up to date 12 | schedule: 13 | - cron: '0 0 1 * *' 14 | 15 | jobs: 16 | docker: 17 | runs-on: ubuntu-latest 18 | permissions: 19 | contents: read 20 | packages: write 21 | steps: 22 | - 23 | uses: actions/checkout@v4 24 | - 25 | name: Set up QEMU 26 | uses: docker/setup-qemu-action@v3 27 | - 28 | name: Set up Docker Buildx 29 | uses: docker/setup-buildx-action@v3 30 | - 31 | name: Login to DockerHub 32 | uses: docker/login-action@v3 33 | if: github.repository_owner == 'thrnz' 34 | with: 35 | username: ${{ secrets.DOCKERHUB_USERNAME }} 36 | password: ${{ secrets.DOCKERHUB_TOKEN }} 37 | - 38 | name: Log in to the Container registry 39 | uses: docker/login-action@v3 40 | if: github.repository_owner == 'thrnz' 41 | with: 42 | registry: ghcr.io 43 | username: ${{ github.repository_owner }} 44 | password: ${{ secrets.GITHUB_TOKEN }} 45 | - 46 | name: Docker Metadata 47 | id: docker-meta 48 | uses: docker/metadata-action@v5 49 | with: 50 | images: | 51 | thrnz/docker-wireguard-pia 52 | ghcr.io/${{ github.repository }} 53 | tags: | 54 | # Tag branches with branch name, but disable for default branch 55 | type=ref,event=branch,enable=${{ github.ref_name != 'master' }} 56 | # set latest tag for default branch 57 | type=raw,value=latest,enable=${{ github.ref_name == 'master' }} 58 | type=raw,value={{date 'YYYYMMDD'}}_{{branch}}_{{sha}} 59 | - name: Note build time 60 | run: | 61 | echo "BUILD_DATE=$(date)" >> ${GITHUB_ENV} 62 | echo "SHA_TRIMMED=${GITHUB_SHA::7}" >> ${GITHUB_ENV} 63 | - 64 | name: Build and push 65 | id: docker_build 66 | uses: docker/build-push-action@v5 67 | with: 68 | platforms: linux/amd64,linux/arm,linux/arm64 69 | push: ${{ github.repository_owner == 'thrnz' }} 70 | tags: ${{ steps.docker-meta.outputs.tags }} 71 | build-args: | 72 | BUILDINFO="${{ github.ref_name }} ${{ env.SHA_TRIMMED }} ${{ env.BUILD_DATE }}" 73 | labels: ${{ steps.docker-meta.outputs.labels }} 74 | - 75 | name: Image digest 76 | run: echo ${{ steps.docker_build.outputs.digest }} 77 | -------------------------------------------------------------------------------- /.github/workflows/dockerhub-description.yml: -------------------------------------------------------------------------------- 1 | name: Update DockerHub description 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths: 8 | - README.md 9 | - .github/workflows/dockerhub-description.yml 10 | 11 | jobs: 12 | dockerHubDescription: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - name: Docker Hub Description 17 | uses: peter-evans/dockerhub-description@v3 18 | with: 19 | username: ${{ secrets.DOCKERHUB_USERNAME }} 20 | password: ${{ secrets.DOCKERHUB_TOKEN }} 21 | repository: thrnz/docker-wireguard-pia 22 | -------------------------------------------------------------------------------- /.github/workflows/shellcheck.yml: -------------------------------------------------------------------------------- 1 | name: "ShellCheck" 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | shellcheck: 6 | name: ShellCheck 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - name: Run ShellCheck 11 | run: shellcheck run ./*.sh ./extra/*.sh 12 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:latest 2 | 3 | RUN apk add --no-cache \ 4 | bash \ 5 | ca-certificates \ 6 | curl \ 7 | grepcidr3 \ 8 | iptables \ 9 | iptables-legacy \ 10 | libcap-utils \ 11 | jq \ 12 | openssl \ 13 | wireguard-tools 14 | 15 | # Modify wg-quick so it doesn't die without --privileged 16 | # Set net.ipv4.conf.all.src_valid_mark=1 on container creation using --sysctl if required instead 17 | # To avoid confusion, also suppress the error message that displays even when pre-set to 1 on container creation 18 | RUN sed -i 's/cmd sysctl.*/set +e \&\& sysctl -q net.ipv4.conf.all.src_valid_mark=1 \&> \/dev\/null \&\& set -e/' /usr/bin/wg-quick 19 | 20 | # Install wireguard-go as a fallback if wireguard is not supported by the host OS or Linux kernel 21 | RUN apk add --no-cache --repository=https://dl-cdn.alpinelinux.org/alpine/edge/community wireguard-go 22 | 23 | # Get the PIA CA cert 24 | ADD https://raw.githubusercontent.com/pia-foss/desktop/master/daemon/res/ca/rsa_4096.crt /rsa_4096.crt 25 | 26 | # The PIA desktop app uses this public key to verify server list downloads 27 | # https://github.com/pia-foss/desktop/blob/master/daemon/src/environment.cpp#L30 28 | COPY ./RegionsListPubKey.pem /RegionsListPubKey.pem 29 | 30 | # Add main work dir to PATH 31 | WORKDIR /scripts 32 | 33 | # Copy scripts to containers 34 | COPY run healthcheck.sh pf_success.sh ./extra/pf.sh ./extra/pia-auth.sh ./extra/wg-gen.sh /scripts/ 35 | RUN chmod 755 /scripts/* 36 | 37 | # Store persistent PIA stuff here (auth token, server list) 38 | VOLUME /pia 39 | 40 | # Store stuff that might be shared with another container here (eg forwarded port) 41 | VOLUME /pia-shared 42 | 43 | HEALTHCHECK --interval=1m --timeout=3s --start-period=30s --start-interval=1s --retries=3 \ 44 | CMD /scripts/healthcheck.sh || exit 1 45 | 46 | ARG BUILDINFO=manual 47 | ENV BUILDINFO=${BUILDINFO} 48 | 49 | CMD ["/scripts/run"] 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # docker-wireguard-pia 2 | 3 | A Docker container for using WireGuard with PIA. 4 | 5 | ## Requirements 6 | * Ideally the host should already support WireGuard. Pre 5.6 kernels may need to have the module manually installed. `wg-quick` should automatically fall back to a userspace implementation (`wireguard-go`) if needed, however the container may need access to the `/dev/net/tun` device for this to work. 7 | * The container requires the `NET_ADMIN` [capability](https://docs.docker.com/compose/compose-file/05-services/#cap_add). `SYS_MODULE` may also be needed in some cases, especially when WireGuard support is provided via kernel module. 8 | * An active [PIA](https://www.privateinternetaccess.com) subscription. 9 | 10 | ## Examples 11 | An example [docker-compose.yml](https://github.com/thrnz/docker-wireguard-pia/blob/master/docker-compose.yml) is available. Some more working examples can be found [here](https://github.com/thrnz/docker-wireguard-pia/wiki/Examples). 12 | 13 | ## Config 14 | The following ENV vars are required: 15 | 16 | | ENV Var | Function | 17 | |-------|------| 18 | |```LOC=swiss```|Location id to connect to. Available server location ids are listed [here](https://serverlist.piaservers.net/vpninfo/servers/v6). Example values include ```us_california```, ```ca_ontario```, and ```swiss```. If left empty the container will print out all currently available location ids and exit.
Multiple ids can be listed, separated by either a space or a comma, and are used as fallback if the initial endpoint registration fails. 19 | |```USER=xxxxxxxx```|PIA username 20 | |```PASS=xxxxxxxx```|PIA password 21 | 22 | The rest are optional: 23 | 24 | | ENV Var | Function | 25 | |-------|------| 26 | |```LOCAL_NETWORK=192.168.1.0/24```|Whether to route and allow input/output traffic to the LAN. LAN access will be unavailable if not specified. Multiple ranges can be specified, separated by a comma or space. Note that there may be DNS issues if this overlaps with PIA's default DNS servers (`10.0.0.243` and `10.0.0.242` as of July 2022). Custom DNS servers can be defined using `VPNDNS` (see below) if this is an issue. 27 | |```KEEPALIVE=25```|If defined, PersistentKeepalive will be set to this in the WireGuard config. This can be used to ensure incoming packets on an idle link aren't lost when behind NAT. The [WireGuard QuickStart guide](https://www.wireguard.com/quickstart/) suggests a value of 25 if needed. By default this remains unset. 28 | |```MTU=1420```|This can be used to override ```wg-quick```'s automatic MTU setting on the Wireguard interface if needed. By default this remains unset (ie. let ```wg-quick``` choose). 29 | |```VPNDNS=8.8.8.8, 8.8.4.4```|Use these DNS servers in the WireGuard config. PIA's DNS servers will be used if not specified. Set to `0` to disable making any changes to the default container DNS settings. When set to `0`, and Docker's embedded DNS server is used, there may be the potential for DNS leaks depending on how Docker internally routes the queries. 30 | |```PORT_FORWARDING=0/1```|Whether to enable port forwarding. Requires a supported server. Defaults to 0 if not specified. 31 | |```PORT_FILE=/pia-shared/port.dat```|The forwarded port number is dumped here for possible access by scripts in other containers. By default this is ```/pia-shared/port.dat```. 32 | |```PORT_FILE_CLEANUP=0/1```|Remove the file containing the forwarded port number on exit. Defaults to 0 if not specified. 33 | |```PORT_PERSIST=0/1```|Set to 1 to attempt to keep the same port forwarded when the container is restarted. The port number may persist for up to two months. Defaults to 0 (always acquire a new port number) if not specified. 34 | |`PORT_FATAL=0/1`|Whether to consider port forwarding errors as fatal or not. May be useful when combined with `EXIT_ON_FATAL` if needed. Defaults to 0 if not specified. 35 | |```PORT_SCRIPT=/path/to/script.sh```|A mounted custom script can be run inside the container once a port is successfully forwarded if needed. The forwarded port number is passed as the first command line argument. By default this remains unset. See [issue #26](https://github.com/thrnz/docker-wireguard-pia/issues/26) for more info. 36 | |```FIREWALL=0/1```|Whether to block non-WireGuard traffic. Defaults to 1 if not specified. 37 | |```EXIT_ON_FATAL=0/1```|By default the container will continue running until manually stopped. Set this to 1 to force the container to exit when an error occurs. Exiting on an error may not be desirable behaviour if other containers are sharing the connection. 38 | |```FATAL_SCRIPT=/path/to/script.sh```|A mounted custom script can be run inside the container if a fatal error occurs. By default this remains unset. 39 | |```USER_FILE=/run/secrets/pia-username``` ```PASS_FILE=/run/secrets/pia-password```|PIA credentials can also be read in from existing files (eg for use with Docker secrets) 40 | |```PIA_IP=x.x.x.x``` ```PIA_CN=hostname401``` ```PIA_PORT=1337```|Connect to a specific server by manually setting all three of these. This will override whatever ```LOC``` is set to. 41 | |```FWD_IFACE``` ```PF_DEST_IP```|If needed, the container can be used as a gateway for other containers or devices by setting these. See [issue #20](https://github.com/thrnz/docker-wireguard-pia/issues/20) for more info. Note that these are for a specific use case, and in many cases using Docker's ```--net=container:xyz``` or docker-compose's ```network_mode: service:xyz``` instead, and leaving these vars unset, would be an easier way of accessing the VPN and forwarded port from other containers. 42 | |`PRE_UP` `POST_UP` `PRE_DOWN` `POST_DOWN` `PRE_RECONNECT` `POST_RECONNECT`|Custom commands and/or scripts can be run at certain stages if needed. See [below](#scripting) for more info. 43 | |`PIA_DIP_TOKEN`|A dedicated ip token can be used by setting this. When set, `LOC` is not used. 44 | |`META_IP=x.x.x.x` `META_CN=hostname401` `META_PORT=443`|On startup, the container needs untunnelled access to PIA's API in order to download the server list and to generate a persistent auth token if needed. Optionally, PIA's 'meta' servers (found in PIA's [server list](https://serverlist.piaservers.net/vpninfo/servers/v6)) can be used instead of the default API endpoints by setting `META_IP` and `META_CN`. These can be set to a different location than `LOC`. `META_PORT` is optional and defaults to 443, although 8080 also appears to be available. See [issue #109](https://github.com/thrnz/docker-wireguard-pia/issues/109) for more info. 45 | |`ACTIVE_HEALTHCHECKS=0/1`|The container contains a very basic Docker [healthcheck](https://docs.docker.com/reference/dockerfile/#healthcheck) script that can be used to ensure the VPN is up before starting other services. By default only passive checks that don't generate any traffic are run. Set this to 1 to also allow checks that generate traffic, such as `ping`, in order to detect if the remote endpoint is responding. 46 | |`HEALTHCHECK_PING_TARGET`|When active healthchecks are enabled or reconnect logic is used, this can be used to override the target/s that gets pinged when testing that the endpoint is still responding. Defaults to `www.privateinternetaccess.com`. Can be set to space or comma separated list of multiple targets, in which case all need to fail for the endpoint to be considered unresponsive. 47 | |`HEALTHCHECK_PING_TIMEOUT`|Can be used to override the number of seconds to wait for a reply when pinging a target. Defaults to 3. 48 | `NFTABLES=0/1`|Alpine uses the `nf_tables` iptables backend by default. The container should automatically fall back to the legacy backend if needed. Set this to `0` to force the use of the legacy backend, or to `1` to force the use of the `nf_tables` backend if desired. 49 | `RECONNECT=0/1`|The container can optionally attempt to detect and recover from an unresponsive endpoint. This is done without the WireGuard interface being brought down. `HEALTHCHECK_PING_TARGET` can be used to set the target used to detect if the remote endpoint is responding. Defaults to 0 if not specified. See [below](#networking) for more info. 50 | `MONITOR_INTERVAL=60` `MONITOR_RETRIES=3`|These are used by the `RECONNECT` logic, and can be used to tweak the probe frequency and the number of retries made before considering an endpoint unresponsive. 51 | 52 | ## Scripting 53 | Custom commands and/or scripts can be run at certain stages of the container's life-cycle by setting the `PRE_UP`, `POST_UP`, `PRE_DOWN`, `POST_DOWN`, `PRE_RECONNECT`, and `POST_RECONNECT` env vars. `PRE_UP` is run prior to generating the WireGuard config, `POST_UP` is run after the WireGuard interface is brought up, `PRE_DOWN` and `POST_DOWN` are run before and after the interface is brought down again when the container exits, and `PRE_RECONNECT` and `POST_RECONNECT` are run before and after an attempt is made to reconnect after an unresponsive endpoint is detected (assuming `RECONNECT=1` is set). 54 | 55 | In addition, scripts mounted in `/pia/scripts` named `pre-up.sh`, `post-up.sh`, `pre-down.sh` and `post-down.sh` will be run at the appropriate stage if present. See [issue #33](https://github.com/thrnz/docker-wireguard-pia/issues/33) for more info. 56 | 57 | ## Networking 58 | To keep things simple, network setup is mostly handled by `wg-quick`. All traffic is routed down the WireGuard tunnel, with exceptions added for any ranges manually defined by `LOCAL_NETWORK`. Note that `LOCAL_NETWORK` must be set correctly if LAN access is needed. More information on `wg-quick`'s routing is available [here](https://www.wireguard.com/netns/) under the 'Improved Rule-based Routing' heading. 59 | 60 | Firewall rules are added dropping all traffic by default, and only encrypted/tunneled traffic, attached Docker network traffic, and `LOCAL_NETWORK` traffic is explicitly allowed. This can be disabled by setting the `FIREWALL=0` env var if desired. 61 | 62 | Other containers can access the VPN connection using Docker's [`--net=container:xyz`](https://docs.docker.com/engine/network/#container-networks) or docker-compose's [`network_mode: service:xyz`](https://docs.docker.com/reference/compose-file/services/#network_mode). Note that network related settings for other containers (such as exposing ports) need to be set on the VPN container itself. When the VPN container is brought down or recreated, Docker appears to bring down the shared network with it, so other containers may also require recreating to regain network access. 63 | 64 | The container doesn't support IPv6. Any IPv6 traffic is dropped unless using `FIREWALL=0`, though it might be worth disabling IPv6 on container creation anyway. 65 | 66 | WireGuard keys seem to expire at PIA's end after several hours of inactivity. Setting the `KEEPALIVE` env var may be enough to prevent this from happening if needed. 67 | 68 | The container has optional recovery logic if the remote endpoint permanently stops responding. In order to register new WireGuard keys, temporary routes and firewall rules are added to allow some requests to bypass the tunnel. To prevent potential leaks while this happens, these requests are limited to a certain interface, ports, and root UID. If successful, new WireGuard settings are then applied without needing to bring the existing interface down. Apart from a change in address (and possibly forwarded port number if used) the process should be mostly transparent to other containers sharing the connection. The recovery logic is disabled by default, and is enabled by setting the `RECONNECT=1` env var. 69 | 70 | ## Notes 71 | * WireGuard config generation and port forwarding was based on what was found in the source code to the PIA desktop app. The standalone [Bash scripts](https://github.com/thrnz/docker-wireguard-pia/tree/master/extra) used by the container are available for use outside of Docker. 72 | * As of Sep 2020, PIA have released their own [scripts](https://github.com/pia-foss/manual-connections) for using WireGuard and port forwarding outside of their app. 73 | * Persistent data is stored in ```/pia```. 74 | * If strict reverse path filtering is used, then the `net.ipv4.conf.all.src_valid_mark=1` sysctl should be set on container creation to prevent incoming packets being dropped. See [issue #96](https://github.com/thrnz/docker-wireguard-pia/issues/96) for more info. 75 | * The userspace implementation through wireguard-go is very stable but lacks in performance. Looking into supporting ([boringtun](https://github.com/cloudflare/boringtun)) might be beneficial. 76 | * Container images are available on both Docker Hub (`thrnz/docker-wireguard-pia`) and GitHub's Container Registry (`ghcr.io/thrnz/docker-wireguard-pia`). Images are rebuilt monthly to keep Alpine packages up to date. 77 | 78 | ## Credits 79 | Some bits and pieces and ideas have been borrowed from the following: 80 | * https://github.com/activeeos/wireguard-docker 81 | * https://github.com/cmulk/wireguard-docker 82 | * https://github.com/dperson/openvpn-client 83 | * https://github.com/pia-foss/desktop 84 | * https://gist.github.com/triffid/da48f3c99f1ff334571ae49be80d591b 85 | -------------------------------------------------------------------------------- /RegionsListPubKey.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzLYHwX5Ug/oUObZ5eH5P 3 | rEwmfj4E/YEfSKLgFSsyRGGsVmmjiXBmSbX2s3xbj/ofuvYtkMkP/VPFHy9E/8ox 4 | Y+cRjPzydxz46LPY7jpEw1NHZjOyTeUero5e1nkLhiQqO/cMVYmUnuVcuFfZyZvc 5 | 8Apx5fBrIp2oWpF/G9tpUZfUUJaaHiXDtuYP8o8VhYtyjuUu3h7rkQFoMxvuoOFH 6 | 6nkc0VQmBsHvCfq4T9v8gyiBtQRy543leapTBMT34mxVIQ4ReGLPVit/6sNLoGLb 7 | gSnGe9Bk/a5V/5vlqeemWF0hgoRtUxMtU1hFbe7e8tSq1j+mu0SHMyKHiHd+OsmU 8 | IQIDAQAB 9 | -----END PUBLIC KEY----- -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | vpn: 3 | image: thrnz/docker-wireguard-pia 4 | # Alternatively you may use ghcr.io 5 | #image: ghcr.io/thrnz/docker-wireguard-pia 6 | container_name: docker-wireguard-pia 7 | volumes: 8 | #Auth token is stored here 9 | - pia:/pia 10 | # If enabled, the forwarded port is dumped to /pia-shared/port.dat for potential use in other containers 11 | - pia-shared:/pia-shared 12 | cap_add: 13 | - NET_ADMIN 14 | # SYS_MODULE might not be needed with a 5.6+ kernel? 15 | #- SYS_MODULE 16 | # If the kernel module isn't available, mounting the tun device may be necessary for userspace implementations 17 | #devices: 18 | #- /dev/net/tun:/dev/net/tun 19 | environment: 20 | # The following env vars are required: 21 | - LOC=swiss 22 | - USER=xxxxxxxxxxxxxxxx 23 | - PASS=xxxxxxxxxxxxxxxx 24 | #The rest are optional: 25 | #- LOCAL_NETWORK=192.168.1.0/24 26 | #- RECONNECT=1 27 | #- KEEPALIVE=25 28 | #- VPNDNS=8.8.8.8,8.8.4.4 29 | #- PORT_FORWARDING=1 30 | sysctls: 31 | # The wg-quick script tries to set this when setting up routing, however this requires running the container 32 | # with the --privileged flag set. Setting it here instead if needed means the container can be run with lower 33 | # privileges. This only needs setting if strict reverse path filtering (rp_filter=1) is used. 34 | - net.ipv4.conf.all.src_valid_mark=1 35 | # May as well disable ipv6. Should be blocked anyway. 36 | - net.ipv6.conf.default.disable_ipv6=1 37 | - net.ipv6.conf.all.disable_ipv6=1 38 | - net.ipv6.conf.lo.disable_ipv6=1 39 | 40 | # Example of another service sharing the VPN 41 | # If this service needs LAN access then LOCAL_NETWORK must be set appropriately on the vpn container 42 | # Forwarded ports should also be set on the vpn container if needed rather than this one in 43 | # order to access from the LAN 44 | # It may be preferable to use a reverse proxy connected via the docker bridge network instead 45 | # to keep the vpn isolated from the LAN 46 | #other-service: 47 | #image: some-other-image 48 | # Other services can share the VPN using 'network_mode: "service:[service name]"' 49 | # See https://docs.docker.com/engine/network/#container-networks and 50 | # https://docs.docker.com/reference/compose-file/services/#network_mode 51 | #network_mode: "service:vpn" 52 | # The following can be used to ensure the vpn is up and functional before the dependant service is started 53 | #depends_on: 54 | #vpn: 55 | #condition: service_healthy 56 | 57 | 58 | # Other containers can access the forwarded port number via /pia-shared/port.dat 59 | # Here's an example of a bare-bones 'helper' container that passes the forwarded port to Deluge 60 | # See https://gist.github.com/thrnz/dcbaa0af66c70af8e302a1c7eb75484a 61 | #deluge-port-helper: 62 | #build: /path/to/deluge-port-helper 63 | #volumes: 64 | #- pia-shared:/pia-shared:ro 65 | #- /path/to/deluge/conf:/deluge/conf 66 | #network_mode: "service:vpn" 67 | #depends_on: 68 | #- vpn 69 | #- other-service 70 | 71 | volumes: 72 | pia: 73 | pia-shared: 74 | -------------------------------------------------------------------------------- /extra/README.md: -------------------------------------------------------------------------------- 1 | # Standalone Stuff 2 | 3 | Here's some standalone stuff for use outside of Docker. 4 | -------------------------------------------------------------------------------- /extra/pf.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Bash script for port-forwarding on the PIA 'next-gen' network. 4 | # 5 | # Requires jq and curl. 6 | # 7 | # Options: 8 | # -t Path to a valid PIA auth token 9 | # -i (Optional) IP to send port-forward API requests to. 10 | # An 'educated guess' is made if not specified. 11 | # -n (Optional) Common name of the VPN server (eg. "london411") 12 | # An 'educated guess' is made if not specified. 13 | # -p (Optional) Dump forwarded port here for access by other scripts 14 | # -f (Optional) Network interface to use for requests 15 | # -s (Optional) Run a script on success. 16 | # The forwarded port is passed as an argument. 17 | # -r (Optional) In order to re-use the same forwarded port number between 18 | # sessions, the port forwarding token can be stored here. Tokens can last 19 | # up to 2 months after which a new token will be retrieved and the forwarded 20 | # port will change 21 | # 22 | # Examples: 23 | # pf.sh -t ~/.pia-token 24 | # pf.sh -t ~/.pia-token -n sydney402 25 | # pf.sh -t ~/.pia-token -i 10.13.14.1 -n london416 -p /port.dat -f wg0 26 | # 27 | # For port forwarding on the next-gen network, we need a valid PIA auth token (see pia-auth.sh) and to know the address to send API requests to. 28 | # 29 | # With Wireguard, the PIA app uses the 'server_vip' address found in the 'addKey' response (eg 10.x.x.1), although 'server_ip' also appears to work. 30 | # With OpenVPN, the PIA app uses the gateway IP (also 10.x.x.1) 31 | # 32 | # Optionally, if we know the common name of the server we're connected to we can verify our HTTPS requests. 33 | # 34 | # Port forwarding appears to involve two api calls. The first (getSignature) is done once on startup and again when the returned pf 'token' will soon expire. 35 | # Port forwarding tokens seem to last ~2 months, so the chances of needing to call it again are low but we may as well do what the app does. 36 | # The second (bindPort) is done once at startup and again every 15 mins to 'keep the forwarded port alive'. 37 | # 38 | # This script has been tested with Wireguard and briefly with OpenVPN 39 | # 40 | # This script is based on what was found in the source code to their desktop app (v.2.2.0): 41 | # https://github.com/pia-foss/desktop/blob/2.2.0/daemon/src/portforwardrequest.cpp 42 | # Use at your own risk! 43 | # 44 | # As of Sep 2020, PIA have released their own standalone scripts for use outside of their app: 45 | # https://github.com/pia-foss/manual-connections 46 | # 47 | # Feel free to take apart and use in your own projects. A link back to the original might be nice though. 48 | 49 | # An error with no recovery logic occured 50 | fatal_error () { 51 | cleanup 52 | echo "$(date): Fatal error" 53 | exit 1 54 | } 55 | 56 | cleanup(){ 57 | [ "$cacert_istemp" == "1" ] && [ -w "$cacert" ] && rm "$cacert" 58 | } 59 | 60 | # Handle shutdown behavior 61 | finish () { 62 | cleanup 63 | echo "$(date): Port forward rebinding stopped. The port will likely close soon." 64 | exit 0 65 | } 66 | trap finish SIGTERM SIGINT SIGQUIT 67 | 68 | usage() { 69 | echo "Options: 70 | -t Path to a valid PIA auth token 71 | -i (Optional) IP to send port-forward API requests to. 72 | An 'educated guess' is made if not specified. 73 | -n (Optional) Common name of the VPN server (eg. \"london411\") 74 | An 'educated guess' is made if not specified. 75 | -p (Optional) Dump forwarded port here for access by other scripts 76 | -f (Optional) Network interface to use for requests 77 | -s (Optional) Run a script on success. 78 | The forwarded port is passed as an argument. 79 | -r (Optional) In order to re-use the same forwarded port number between 80 | sessions, the port forwarding token can be stored here. Tokens can last 81 | up to 2 months after which a new token will be retrieved and the forwarded 82 | port will change" 83 | } 84 | 85 | while getopts ":t:i:n:c:p:f:s:r:" args; do 86 | case ${args} in 87 | t) 88 | tokenfile=$OPTARG 89 | ;; 90 | i) 91 | api_ip=$OPTARG 92 | ;; 93 | n) 94 | vpn_cn=$OPTARG 95 | ;; 96 | c) 97 | cacert=$OPTARG 98 | ;; 99 | p) 100 | portfile=$OPTARG 101 | ;; 102 | f) 103 | iface_tr="-i $OPTARG" 104 | iface_curl="--interface $OPTARG" 105 | ;; 106 | s) 107 | post_script=$OPTARG 108 | ;; 109 | r) 110 | persist_file=$OPTARG 111 | ;; 112 | *) 113 | echo "Unknown option" 114 | exit 1 115 | ;; 116 | esac 117 | done 118 | 119 | bind_port () { 120 | # Store transient errors here. Only display on fail. 121 | local stderr_tmp 122 | stderr_tmp=$(mktemp) 123 | # shellcheck disable=SC2086 124 | pf_bind=$(curl --get --silent --show-error $iface_curl --connect-timeout "$curl_connection_timeout" \ 125 | --retry "$curl_retry" --retry-delay "$curl_retry_delay" --max-time "$curl_max_time" \ 126 | --data-urlencode "payload=$pf_payload" \ 127 | --data-urlencode "signature=$pf_getsignature" \ 128 | $verify \ 129 | "https://$pf_host:19999/bindPort" 2> "$stderr_tmp") 130 | if [ "$(jq -r .status <<< "$pf_bind ")" != "OK" ]; then 131 | echo "$(date): bindPort error" 132 | echo "$(date): Curl error/s:" 133 | cat "$stderr_tmp" 134 | rm "$stderr_tmp" 135 | echo "$(date): API response:" 136 | echo "$pf_bind" 137 | return 1 138 | fi 139 | rm "$stderr_tmp" 140 | return 0 141 | } 142 | 143 | get_sig () { 144 | # Attempt to reuse our previous port if requested 145 | if [ -n "$persist_file" ] && [ -r "$persist_file" ]; then 146 | echo "$(date): Reusing previous PF token" 147 | pf_getsig=$(cat "$persist_file") 148 | else 149 | # shellcheck disable=SC2086 150 | pf_getsig=$(curl --get --silent --show-error $iface_curl --connect-timeout "$curl_connection_timeout" \ 151 | --retry "$curl_retry" --retry-delay "$curl_retry_delay" --max-time "$curl_max_time" \ 152 | --data-urlencode "token=$(cat "$tokenfile")" \ 153 | $verify \ 154 | "https://$pf_host:19999/getSignature") 155 | fi 156 | if [ "$(jq -r .status <<< "$pf_getsig")" != "OK" ]; then 157 | echo "$(date): getSignature error" 158 | echo "$pf_getsig" 159 | fatal_error 160 | fi 161 | # Save response for re-use if requested 162 | [ -n "$persist_file" ] && echo "$pf_getsig" > "$persist_file" 163 | pf_payload=$(jq -r .payload <<< "$pf_getsig") 164 | pf_getsignature=$(jq -r .signature <<< "$pf_getsig") 165 | pf_port=$(base64 -d <<< "$pf_payload" | jq -r .port) 166 | pf_token_expiry_raw=$(base64 -d <<< "$pf_payload " | jq -r .expires_at) 167 | # Coreutils date doesn't need format specified (-D), whereas BusyBox does 168 | if date --help 2>&1 /dev/null | grep -iq 'busybox'; then 169 | pf_token_expiry=$(date -D %Y-%m-%dT%H:%M:%S --date="$pf_token_expiry_raw" +%s) 170 | else 171 | pf_token_expiry=$(date --date="$pf_token_expiry_raw" +%s) 172 | fi 173 | } 174 | 175 | # We don't use any error handling or retry logic beyond what curl provides 176 | curl_max_time=60 177 | curl_retry=10 178 | curl_retry_delay=30 179 | curl_connection_timeout=30 180 | 181 | # Rebind every 15 mins (same as desktop app) 182 | pf_bindinterval=$(( 15 * 60)) 183 | # Get a new token when the current one has less than this remaining 184 | # Defaults to 7 days (same as desktop app) 185 | pf_minreuse=$(( 60 * 60 * 24 * 7 )) 186 | 187 | pf_remaining=0 188 | pf_firstrun=1 189 | 190 | # Minimum args needed to run 191 | if [ -z "$tokenfile" ]; then 192 | usage && exit 0 193 | fi 194 | 195 | # Hacky way to try to automatically get the API IP: use the first hop of a traceroute. 196 | # This seems to work for both Wireguard and OpenVPN. 197 | # Ideally we'd have been provided a cn, in case we 'guess' the wrong IP. 198 | # Must be a better way to do this. 199 | if [ -z "$api_ip" ]; then 200 | # shellcheck disable=SC2086 201 | api_ip=$(traceroute -4 -m 1 $iface_tr privateinternetaccess.com | tail -n 1 | awk '{print $2}') 202 | # Very basic sanity check - make sure it matches 10.x.x.1 203 | if ! grep -q '10\.[0-9]\{1,3\}\.[0-9]\{1,3\}\.1' <<< "$api_ip"; then 204 | echo "$(date): Automatically getting API IP failed." 205 | fatal_error 206 | fi 207 | echo "$(date): Using $api_ip as API endpoint" 208 | fi 209 | 210 | # If we haven't been passed a cn, then use the cn the server is claiming 211 | if [ -z "$vpn_cn" ]; then 212 | # shellcheck disable=SC2086 213 | possible_cn=$(curl $iface_curl --insecure --verbose --head "https://$api_ip:19999" 2>&1 | grep '\\* subject' | sed 's/.*CN=\(.*\)\;.*/\1/') 214 | # Sanity check - match 'lowercase123' 215 | if grep -q '[a-z]*[0-9]\{3\}' <<< "$possible_cn"; then 216 | echo "$(date): Using $possible_cn as cn" 217 | vpn_cn="$possible_cn" 218 | fi 219 | fi 220 | 221 | # If we've been provided a cn, we can verify using the PIA ca cert 222 | if [ -n "$vpn_cn" ]; then 223 | # Get the PIA ca crt if we weren't given it 224 | if [ -z "$cacert" ]; then 225 | echo "$(date): Getting PIA ca cert" 226 | cacert=$(mktemp) 227 | cacert_istemp=1 228 | # shellcheck disable=SC2086 229 | if ! curl $iface_curl --get --silent --max-time "$curl_max_time" --output "$cacert" --connect-timeout "$curl_connection_timeout" \ 230 | --retry "$curl_retry" --retry-delay "$curl_retry_delay" --max-time "$curl_max_time" \ 231 | "https://raw.githubusercontent.com/pia-foss/desktop/master/daemon/res/ca/rsa_4096.crt"; then 232 | echo "(date): Failed to download PIA ca cert" 233 | fatal_error 234 | fi 235 | fi 236 | verify="--cacert $cacert --resolve $vpn_cn:19999:$api_ip" 237 | pf_host="$vpn_cn" 238 | echo "$(date): Verifying API requests. CN: $vpn_cn" 239 | else 240 | # For simplicity, use '--insecure' by default, though show a warning 241 | echo "$(date): API requests may be insecure. Specify a common name using -n." 242 | verify="--insecure" 243 | pf_host="$api_ip" 244 | fi 245 | 246 | # Main loop 247 | while true; do 248 | pf_remaining=$(( pf_token_expiry - $(date +%s) )) 249 | # Get a new pf token as the previous one will expire soon 250 | if [ $pf_remaining -lt $pf_minreuse ]; then 251 | if [ $pf_firstrun -ne 1 ]; then 252 | echo "$(date): PF token will expire soon. Getting new one." 253 | [ -n "$persist_file" ] && [ -w "$persist_file" ] && rm "$persist_file" 254 | else 255 | echo "$(date): Getting PF token" 256 | pf_firstrun=0 257 | fi 258 | get_sig 259 | if ! bind_port; then 260 | # If we attempted to use a previous port and binding failed then discard it and retry 261 | if [ -n "$persist_file" ] && [ -w "$persist_file" ]; then 262 | echo "$(date): Discarding previous PF token and trying again" 263 | rm "$persist_file" 264 | get_sig 265 | bind_port || fatal_error 266 | else 267 | fatal_error 268 | fi 269 | fi 270 | echo "$(date): Obtained PF token. Expires at $pf_token_expiry_raw" 271 | echo "$(date): Server accepted PF bind" 272 | echo "$(date): Forwarding on port $pf_port" 273 | echo "$(date): Rebind interval: $pf_bindinterval seconds" 274 | # Dump port here if requested 275 | [ -n "$portfile" ] && echo "$(date): Port dumped to $portfile" && echo "$pf_port" > "$portfile" 276 | echo "$(date): This script should remain running to keep the forwarded port alive" 277 | echo "$(date): Press Ctrl+C to exit" 278 | # Run another script if requested 279 | [ -n "$post_script" ] && echo "$(date): Running $post_script" && eval "$post_script $pf_port" & 280 | fi 281 | # Rebind at a specific time instead of simply sleeping in case the system itself goes to sleep 282 | # This prevents a delayed rebind after waking up 283 | nextbind=$(( $(date +%s) + pf_bindinterval )) 284 | while [[ $(date +%s) -lt $nextbind ]]; do 285 | sleep 5 & 286 | wait $! 287 | done 288 | bind_port || fatal_error 289 | done 290 | 291 | -------------------------------------------------------------------------------- /extra/pia-auth.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Generate and output a PIA auth token 4 | # 5 | # Requires jq and curl 6 | # 7 | # Options: 8 | # -u 9 | # -p 10 | # -i (Optional) 11 | # -o (Optional) 12 | # -n (Optional) 13 | # -c (Optional) Path to ca cert used to secure communication with "meta" servers 14 | # 15 | # Examples: 16 | # ./pia-auth.sh -u myusername -p mypassword > ~/.pia-token 17 | # ./pia-auth.sh -u myusername -p mypassword -i 12.34.56.78 -n location401 -p 443 -c /path/to/ca.crt > ~/.pia-token 18 | # 19 | # By default, the www.privateinternetaccess.com API endpoint is used. 20 | # If needed, 'meta' services on the VPN servers themselves can be used instead. 21 | # 22 | # deauth using: 23 | # curl --silent --show-error --request POST \ 24 | # --header "Content-Type: application/json" \ 25 | # --header "Authorization: Token $(cat ~/.pia-token)" \ 26 | # --data "{}" \ 27 | # "https://www.privateinternetaccess.com/api/client/v2/expire_token" 28 | 29 | [ -n "$DEBUG" ] && set -o xtrace 30 | 31 | while getopts ":u:p:i:c:o:n:" args; do 32 | case ${args} in 33 | u) 34 | user="$OPTARG" 35 | ;; 36 | p) 37 | pass="$OPTARG" 38 | ;; 39 | i) 40 | meta_ip="$OPTARG" 41 | ;; 42 | c) 43 | cacert="$OPTARG" 44 | ;; 45 | o) 46 | meta_port="$OPTARG" 47 | ;; 48 | n) 49 | meta_cn="$OPTARG" 50 | ;; 51 | *) 52 | echo "Unknown option" 53 | exit 1 54 | ;; 55 | esac 56 | done 57 | 58 | usage() { 59 | echo 'Options: 60 | -u 61 | -p 62 | -i (Optional) 63 | -o (Optional) 64 | -n (Optional) 65 | -c (Optional) Path to ca cert used to secure communication with "meta" servers' 66 | exit 1 67 | } 68 | 69 | get_auth_token () { 70 | if [ -n "$meta_port" ] && [ -n "$meta_ip" ] && [ -n "$meta_cn" ] && [ -n "$cacert" ]; then 71 | # https://github.com/pia-foss/desktop/blob/master/daemon/src/metaserviceapibase.h 72 | # shellcheck disable=SC2086 73 | token_response=$(curl $CURL_OVERRIDE_PARAMS --silent --location --show-error --request POST --max-time "$curl_max_time" \ 74 | --resolve "$meta_cn:$meta_port:$meta_ip" \ 75 | --data-urlencode "username=$user" \ 76 | --data-urlencode "password=$pass" \ 77 | --cacert "$cacert" \ 78 | "https://$meta_cn:$meta_port/api/client/v2/token") 79 | else 80 | # shellcheck disable=SC2086 81 | token_response=$(curl $CURL_OVERRIDE_PARAMS --silent --location --show-error --request POST --max-time "$curl_max_time" \ 82 | 'https://www.privateinternetaccess.com/api/client/v2/token' \ 83 | --data-urlencode "username=$user" \ 84 | --data-urlencode "password=$pass") 85 | fi 86 | TOK=$(jq -r .'token' <<< "$token_response") 87 | if [ -z "$TOK" ] || [ "$TOK" == "null" ]; then 88 | echo "Failed to acquire new auth token. Response:" >&2 89 | echo "$token_response" >&2 90 | exit 1 91 | fi 92 | echo "$TOK" 93 | } 94 | 95 | if [ -z "$pass" ] || [ -z "$user" ]; then 96 | usage 97 | fi 98 | 99 | curl_max_time=15 100 | get_auth_token 101 | exit 0 -------------------------------------------------------------------------------- /extra/wg-gen.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Bash script for generating basic Wireguard config files (for use with wg-quick) for the PIA 'next-gen' network 4 | # 5 | # Requires Wireguard (for generating a key pair), jq and curl 6 | # Optionally requires OpenSSL for verifying server list 7 | # 8 | # Options: 9 | # -t Path to a valid PIA auth token 10 | # -l id of the location to connect to (eg. "swiss") 11 | # -o (Optional) Verify the server list using this public key. Requires OpenSSL. 13 | # -d (Optional) Use these DNS servers in the generated WG config. Defaults to PIA's DNS. 14 | # -m (Optional) Use this as the interface's mtu value in the generated config 15 | # -c (Optional) Path to PIA ca cert. Fetched from the PIA Github repository if not provided. 16 | # -a List available locations and whether they support port forwarding 17 | # 18 | # Examples: 19 | # wg-gen.sh -a 20 | # wg-gen.sh -l swiss -t ~/.token -o ~/wg.conf 21 | # wg-gen.sh -l swiss -t ~/.token -o ~/wg.conf -k ~/pubkey.pem -d 8.8.8.8,8.8.4.4 22 | # 23 | # To force the use of a specific server, the PIA_IP PIA_PORT and PIA_CN env vars must all be set 24 | # eg: $ PIA_IP=1.2.3.4 PIA_PORT=1337 PIA_CN=hostname401 wg-gen.sh -t ~/.token -o ~/wg.conf 25 | # 26 | # To use a dedicated ip, the PIA_DIP_TOKEN env var must be set 27 | # eg: $ PIA_DIP_TOKEN=DIPabc123 wg-gen.sh -t ~/.token -o ~/wg.conf 28 | # 29 | # API requests can be sent via PIA's 'meta' servers by setting the META_IP META_CN and META_PORT env vars 30 | # eg: $ META_IP=123.45.67.89 META_CN=hostname401 META_PORT=443 ./wg-gen.sh -t ~/.token ~/wg.conf 31 | # 32 | # Available servers can be found here: 33 | # https://serverlist.piaservers.net/vpninfo/servers/v6 34 | # The public key for verifying the server list can be found here: 35 | # https://github.com/pia-foss/desktop/blob/122710c6ada5db83620c63faff2d805ea52d7f40/daemon/src/environment.cpp#L30 36 | # The PIA ca cert can be found here: 37 | # https://github.com/pia-foss/desktop/blob/master/daemon/res/ca/rsa_4096.crt 38 | # 39 | # As of Sep 2020, PIA have released their own standalone scripts for use outside of their app: 40 | # https://github.com/pia-foss/manual-connections 41 | 42 | # Exit codes: 43 | # 0: Success 44 | # 1: Anything else 45 | # 2: Auth error 46 | # 3: Invalid server location 47 | # 4: Registration failed 48 | 49 | [ -n "$DEBUG" ] && set -o xtrace 50 | 51 | fatal_error () { 52 | cleanup 53 | [ -n "$1" ] && exit "$1" 54 | exit 1 55 | } 56 | 57 | cleanup(){ 58 | [ -w "$servers_raw" ] && rm "$servers_raw" 59 | [ -w "$servers_json" ] && rm "$servers_json" 60 | [ -w "$servers_sig" ] && rm "$servers_sig" 61 | [ -w "$addkey_response" ] && rm "$addkey_response" 62 | [ -w "$pia_cacert_tmp" ] && rm "$pia_cacert_tmp" 63 | return 0 64 | } 65 | 66 | usage() { 67 | echo "Options:" 68 | echo " -t Path to a valid PIA auth token" 69 | echo " -l id of the location to connect to (eg. \"swiss\")" 70 | echo " -o (Optional) Verify the server list using this public key. Requires OpenSSL." 72 | echo " -d (Optional) Use these DNS servers in the generated WG config. Defaults to PIA's DNS." 73 | echo " -m (Optional) Use this as the interface's mtu value in the generated config" 74 | echo " -c (Optional) Path to PIA ca cert. Fetched from the PIA Github repository if not provided." 75 | echo " -a List available locations and whether they support port forwarding" 76 | } 77 | 78 | parse_args() { 79 | while getopts ":t:l:o:k:c:d:m:a" args; do 80 | case ${args} in 81 | t) 82 | tokenfile="$OPTARG" 83 | ;; 84 | l) 85 | location="$OPTARG" 86 | ;; 87 | o) 88 | wg_out="$OPTARG" 89 | ;; 90 | k) 91 | pia_pubkey="$OPTARG" 92 | ;; 93 | c) 94 | pia_cacert="$OPTARG" 95 | ;; 96 | d) 97 | dns="$OPTARG" 98 | ;; 99 | m) 100 | mtu="$OPTARG" 101 | ;; 102 | a) 103 | list_and_exit=1 104 | ;; 105 | *) 106 | echo "Unknown option" 107 | exit 1 108 | ;; 109 | esac 110 | done 111 | } 112 | 113 | # The PIA desktop app uses a public key to verify server list downloads. 114 | # https://github.com/pia-foss/desktop/blob/b701601bfa806621a41039514bbb507e250466ec/common/src/jsonrefresher.cpp#L93 115 | verify_serverlist () 116 | { 117 | if openssl dgst -sha256 -verify "$pia_pubkey" -signature "$servers_sig" "$servers_json"; then 118 | echo "Verified server list" 119 | else 120 | echo "Failed to verify server list" 121 | fatal_error 122 | fi 123 | } 124 | 125 | get_dip_serverinfo () 126 | { 127 | if [ -n "$META_IP" ] && [ -n "$META_CN" ] && [ -n "$META_PORT" ]; then 128 | echo "$(date): Fetching dedicated ip server info via meta server: ip: $META_IP, cn: $META_CN, port: $META_PORT" 129 | # shellcheck disable=SC2086 130 | dip_response=$(curl --silent --show-error $curl_params --location --request POST \ 131 | "https://$META_CN:$META_PORT/api/client/v2/dedicated_ip" \ 132 | --header 'Content-Type: application/json' \ 133 | --header "Authorization: Token $(cat "$tokenfile")" \ 134 | --cacert "$pia_cacert" --resolve "$META_CN:$META_PORT:$META_IP" \ 135 | --data-raw '{ 136 | "tokens":["'"$PIA_DIP_TOKEN"'"] 137 | }') 138 | else 139 | echo "$(date): Fetching dedicated ip server info" 140 | # shellcheck disable=SC2086 141 | dip_response=$(curl --silent --show-error $curl_params --location --request POST \ 142 | 'https://www.privateinternetaccess.com/api/client/v2/dedicated_ip' \ 143 | --header 'Content-Type: application/json' \ 144 | --header "Authorization: Token $(cat "$tokenfile")" \ 145 | --data-raw '{ 146 | "tokens":["'"$PIA_DIP_TOKEN"'"] 147 | }') 148 | fi 149 | 150 | [ "$dip_response" == "HTTP Token: Access denied." ] && echo "Auth failed" && fatal_error 2 151 | 152 | if [ "$(jq -r '.[0].status' <<< "$dip_response")" != "active" ]; then 153 | echo "$(date): Failed to fetch dedicated ip server info. Response:" 154 | echo "$dip_response" 155 | fatal_error 156 | fi 157 | 158 | wg_port=1337 159 | wg_cn=$(jq -r '.[0].cn' <<< "$dip_response") 160 | wg_ip=$(jq -r '.[0].ip' <<< "$dip_response") 161 | 162 | echo "$(date): Dedicated ip: $wg_ip, cn: $wg_cn" 163 | 164 | # PIA's standalone scripts seem to assume port forwarding is available everywhere apart from the us 165 | [[ $(jq -r '.[0].id' <<< "$dip_response") != us_* ]] && port_forward_avail=1 166 | } 167 | 168 | get_servers() { 169 | if [ -n "$META_IP" ] && [ -n "$META_CN" ] && [ -n "$META_PORT" ]; then 170 | echo "Fetching next-gen PIA server list via meta server: ip: $META_IP, cn: $META_CN, port: $META_PORT" 171 | # shellcheck disable=SC2086 172 | curl --silent --show-error $curl_params --cacert "$pia_cacert" --resolve "$META_CN:$META_PORT:$META_IP" \ 173 | "https://$META_CN:$META_PORT/vpninfo/servers/v6" > "$servers_raw" 174 | else 175 | echo "Fetching next-gen PIA server list" 176 | # shellcheck disable=SC2086 177 | curl --silent --show-error $curl_params \ 178 | "https://serverlist.piaservers.net/vpninfo/servers/v6" > "$servers_raw" 179 | fi 180 | head -n 1 "$servers_raw" | tr -d '\n' > "$servers_json" 181 | tail -n +3 "$servers_raw" | base64 -d > "$servers_sig" 182 | [ -n "$pia_pubkey" ] && verify_serverlist 183 | 184 | [ "$list_and_exit" -eq 1 ] && echo "Available location ids:" && jq '.regions | .[] | {name, id, port_forward}' "$servers_json" && cleanup && exit 0 185 | 186 | # Some locations have multiple servers available. Pick a random one. 187 | totalservers=$(jq -r '.regions | .[] | select(.id=="'"$location"'") | .servers.wg | length' "$servers_json") 188 | if ! [[ "$totalservers" =~ ^[0-9]+$ ]] || [ "$totalservers" -eq 0 ] 2>/dev/null; then 189 | echo "Location \"$location\" not found. Run with -a to list valid servers." 190 | fatal_error 3 191 | fi 192 | serverindex=$(( RANDOM % totalservers)) 193 | wg_cn=$(jq -r '.regions | .[] | select(.id=="'"$location"'") | .servers.wg | .['$serverindex'].cn' "$servers_json") 194 | wg_ip=$(jq -r '.regions | .[] | select(.id=="'"$location"'") | .servers.wg | .['$serverindex'].ip' "$servers_json") 195 | wg_port=$(jq -r '.groups.wg | .[0] | .ports | .[0]' "$servers_json") 196 | 197 | [ "$(jq -r '.regions | .[] | select(.id=="'"$location"'") | .port_forward' "$servers_json")" == "true" ] && port_forward_avail=1 198 | } 199 | 200 | get_wgconf () { 201 | client_private_key="$(wg genkey)" 202 | if ! client_public_key=$(wg pubkey <<< "$client_private_key"); then 203 | echo "$(date) Error generating Wireguard key pair" && fatal_error 204 | fi 205 | 206 | # https://github.com/pia-foss/desktop/blob/754080ce15b6e3555321dde2dcfd0c21ec25b1a9/daemon/src/wireguardmethod.cpp#L1150 207 | 208 | if [ -z "$pia_cacert" ]; then 209 | echo "$(date) Fetching PIA ca cert" 210 | pia_cacert_tmp=$(mktemp) 211 | # shellcheck disable=SC2086 212 | if ! curl --get --silent --show-error $curl_params --output "$pia_cacert_tmp" "https://raw.githubusercontent.com/pia-foss/desktop/master/daemon/res/ca/rsa_4096.crt"; then 213 | echo "Failed to download PIA ca cert" 214 | fatal_error 215 | fi 216 | pia_cacert="$pia_cacert_tmp" 217 | fi 218 | 219 | if [ -n "$PIA_DIP_TOKEN" ]; then 220 | echo "Registering public key with PIA dedicated ip endpoint; cn: $wg_cn, ip: $wg_ip" 221 | # shellcheck disable=SC2086 222 | curl --get --silent --show-error $curl_params \ 223 | --user "dedicated_ip_$PIA_DIP_TOKEN:$wg_ip" \ 224 | --data-urlencode "pubkey=$client_public_key" \ 225 | --cacert "$pia_cacert" \ 226 | --resolve "$wg_cn:$wg_port:$wg_ip" \ 227 | "https://$wg_cn:$wg_port/addKey" > "$addkey_response" 228 | else 229 | echo "Registering public key with PIA endpoint; id: $location, cn: $wg_cn, ip: $wg_ip" 230 | # shellcheck disable=SC2086 231 | curl --get --silent --show-error $curl_params \ 232 | --data-urlencode "pubkey=$client_public_key" \ 233 | --data-urlencode "pt=$(cat "$tokenfile")" \ 234 | --cacert "$pia_cacert" \ 235 | --resolve "$wg_cn:$wg_port:$wg_ip" \ 236 | "https://$wg_cn:$wg_port/addKey" > "$addkey_response" 237 | fi 238 | 239 | [ "$(jq -r .status "$addkey_response")" == "ERROR" ] && [ "$(jq -r .message "$addkey_response")" == "Login failed!" ] && echo "Auth failed" && cat "$addkey_response" && fatal_error 2 240 | [ "$(jq -r .status "$addkey_response")" != "OK" ] && echo "WG key registration failed" && cat "$addkey_response" && fatal_error 4 241 | 242 | peer_ip="$(jq -r .peer_ip "$addkey_response")" 243 | server_public_key="$(jq -r .server_key "$addkey_response")" 244 | server_port="$(jq -r .server_port "$addkey_response")" 245 | pfapi_ip="$(jq -r .server_vip "$addkey_response")" 246 | 247 | echo "Generating $wg_out" 248 | 249 | if [ -z "$dns" ]; then 250 | dns=$(jq -r '.dns_servers[0:2]' "$addkey_response" | grep ^\ | cut -d\" -f2 | xargs echo | sed -e 's/ /,/g') 251 | echo "Using PIA DNS servers: $dns" 252 | elif [ "$dns" = "0" ]; then 253 | echo "Using default container DNS servers" 254 | dns="" 255 | else 256 | echo "Using custom DNS servers: $dns" 257 | fi 258 | 259 | cat < "$wg_out" 260 | [Interface] 261 | PrivateKey = $client_private_key 262 | Address = $peer_ip 263 | DNS = $dns 264 | CONFF 265 | 266 | if [ -n "$mtu" ]; then 267 | echo "Using custom MTU: $mtu" 268 | echo "MTU = $mtu" >> "$wg_out" 269 | fi 270 | 271 | cat <> "$wg_out" 272 | 273 | [Peer] 274 | PublicKey = $server_public_key 275 | AllowedIPs = 0.0.0.0/0 276 | Endpoint = $wg_ip:$server_port 277 | CONFF 278 | 279 | # Store the info needed for port forwarding as comments in the generated config for later use if needed 280 | if [ "$port_forward_avail" -eq 1 ]; then 281 | echo "#cn: $wg_cn" >> "$wg_out" 282 | echo "#pf api ip: $pfapi_ip" >> "$wg_out" 283 | fi 284 | 285 | } 286 | 287 | curl_params="$CURL_OVERRIDE_PARAMS --compressed --retry 5 --retry-delay 5 --max-time 120 --connect-timeout 15" 288 | 289 | port_forward_avail=0 290 | list_and_exit=0 291 | 292 | parse_args "$@" 293 | 294 | # Minimum args needed to run 295 | if [ "$list_and_exit" -eq 0 ]; then 296 | if [ -z "$tokenfile" ] || [ -z "$wg_out" ]; then 297 | usage && exit 0 298 | fi 299 | fi 300 | 301 | servers_raw=$(mktemp) 302 | servers_sig=$(mktemp) 303 | servers_json=$(mktemp) 304 | addkey_response=$(mktemp) 305 | 306 | if [ -n "$PIA_DIP_TOKEN" ]; then 307 | get_dip_serverinfo 308 | # Set env vars PIA_CN, PIA_IP and PIA_PORT to connect to a specific server 309 | elif [ -n "$PIA_CN" ] && [ -n "$PIA_IP" ] && [ -n "$PIA_PORT" ]; then 310 | wg_cn="$PIA_CN" 311 | wg_ip="$PIA_IP" 312 | wg_port="$PIA_PORT" 313 | location="manual" 314 | else 315 | # Otherwise get what we need from the server list 316 | get_servers 317 | fi 318 | 319 | get_wgconf 320 | 321 | if [ "$port_forward_avail" -eq 1 ]; then 322 | echo "Port forwarding is available at this location" 323 | else 324 | echo "Port forwarding is not available at this location" 325 | fi 326 | 327 | echo "Successfully generated $wg_out" 328 | 329 | cleanup 330 | exit 0 -------------------------------------------------------------------------------- /healthcheck.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # By default, only do checks that don't generate any traffic 4 | [[ "$ACTIVE_HEALTHCHECKS" =~ ^[0-1]$ ]] || ACTIVE_HEALTHCHECKS=0 5 | HEALTHCHECK_PING_TARGET="${HEALTHCHECK_PING_TARGET:-www.privateinternetaccess.com}" 6 | [[ "$HEALTHCHECK_PING_TIMEOUT" =~ ^[0-9]+$ ]] || HEALTHCHECK_PING_TIMEOUT=3 7 | 8 | wg show wg0 || exit 1 9 | 10 | if [ "$PORT_FORWARDING" = "1" ]; then 11 | pidof pf.sh || exit 1 12 | fi 13 | 14 | if [ "$ACTIVE_HEALTHCHECKS" = "1" ]; then 15 | # Loop through a list of targets, and fail if none of them respond 16 | # Accept comma separated as well as space separated list 17 | success=0 18 | for target in ${HEALTHCHECK_PING_TARGET//,/ }; do 19 | ping -c 1 -w "$HEALTHCHECK_PING_TIMEOUT" -I wg0 "$target" && success=1 && break 20 | done 21 | [ "$success" = "1" ] || exit 1 22 | fi -------------------------------------------------------------------------------- /pf_success.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # This script is run once a port has been successfully forwarded 4 | # The port number is passed as the first argument 5 | 6 | [[ "$FIREWALL" =~ ^[0-1]$ ]] || FIREWALL=1 7 | 8 | if [ "$FIREWALL" -eq 1 ]; then 9 | iptables -A INPUT -p tcp -i wg0 --dport "$1" -j ACCEPT 10 | iptables -A INPUT -p udp -i wg0 --dport "$1" -j ACCEPT 11 | echo "$(date): Allowing incoming traffic on port $1" 12 | fi 13 | 14 | # Set env var PF_DEST_IP to forward on to another address 15 | # eg PF_DEST_IP=192.168.1.48 16 | if [ -n "$PF_DEST_IP" ] && [ -n "$FWD_IFACE" ]; then 17 | iptables -t nat -A PREROUTING -p tcp --dport "$1" -j DNAT --to-destination "$PF_DEST_IP:$1" 18 | iptables -t nat -A PREROUTING -p udp --dport "$1" -j DNAT --to-destination "$PF_DEST_IP:$1" 19 | iptables -A FORWARD -i wg0 -o "$FWD_IFACE" -p tcp -d "$PF_DEST_IP" --dport "$1" -j ACCEPT 20 | iptables -A FORWARD -i wg0 -o "$FWD_IFACE" -p udp -d "$PF_DEST_IP" --dport "$1" -j ACCEPT 21 | echo "$(date): Forwarding incoming VPN traffic on port $1 to $PF_DEST_IP:$1" 22 | fi 23 | 24 | # Run another user-defined script/command if defined 25 | [ -n "$PORT_SCRIPT" ] && echo "$(date): Running user-defined command: $PORT_SCRIPT" && eval "$PORT_SCRIPT $1" & 26 | -------------------------------------------------------------------------------- /run: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | [ -n "$DEBUG" ] && set -o xtrace 4 | 5 | # Check and/or set default options 6 | # Should be 0/1 7 | [[ "$EXIT_ON_FATAL" =~ ^[0-1]$ ]] || EXIT_ON_FATAL=0 8 | [[ "$FIREWALL" =~ ^[0-1]$ ]] || FIREWALL=1 9 | [[ "$PORT_FILE_CLEANUP" =~ ^[0-1]$ ]] || PORT_FILE_CLEANUP=0 10 | [[ "$PORT_FORWARDING" =~ ^[0-1]$ ]] || PORT_FORWARDING=0 11 | [[ "$PORT_PERSIST" =~ ^[0-1]$ ]] || PORT_PERSIST=0 12 | [[ "$PORT_FATAL" =~ ^[0-1]$ ]] || PORT_FATAL=0 13 | # Should be a positive integer 14 | [[ "$KEEPALIVE" =~ ^[0-9]+$ ]] || KEEPALIVE=0 15 | [[ "$META_PORT" =~ ^[0-9]+$ ]] || export META_PORT=443 16 | [[ "$MONITOR_INTERVAL" =~ ^[0-9]+$ ]] || export MONITOR_INTERVAL=60 17 | [[ "$MONITOR_RETRIES" =~ ^[0-9]+$ ]] || export MONITOR_RETRIES=3 18 | 19 | # Maybe also check the following. They are all blank by default. 20 | # LOCAL_NETWORK= 21 | # PIA_CN= 22 | # PIA_IP= 23 | # PIA_PORT= 24 | # PORT_FILE= 25 | # QDISC= 26 | # VPNDNS= 27 | # MTU= 28 | 29 | configdir="/pia" 30 | tokenfile="$configdir/.token" 31 | pf_persistfile="$configdir/portsig.json" 32 | 33 | # Run custom scripts at the appropriate time if present 34 | # We also run custom commands specified by the PRE_UP, POST_UP, PRE_DOWN, and POST_DOWN env vars at the same time 35 | custom_scriptdir="/pia/scripts" 36 | pre_up_script="$custom_scriptdir/pre-up.sh" 37 | post_up_script="$custom_scriptdir/post-up.sh" 38 | pre_down_script="$custom_scriptdir/pre-down.sh" 39 | post_down_script="$custom_scriptdir/post-down.sh" 40 | 41 | sharedir="/pia-shared" 42 | # Set env var PORT_FILE to override where the forwarded port number is dumped 43 | # Might need to handle setting file ownership/permissions too 44 | portfile="${PORT_FILE:-$sharedir/port.dat}" 45 | 46 | pia_cacrt="/rsa_4096.crt" 47 | wg_conf="/etc/wireguard/wg0.conf" 48 | 49 | firewall_init () { 50 | # Block everything by default 51 | ip6tables -P OUTPUT DROP &> /dev/null 52 | ip6tables -P INPUT DROP &> /dev/null 53 | ip6tables -P FORWARD DROP &> /dev/null 54 | iptables -P OUTPUT DROP &> /dev/null 55 | iptables -P INPUT DROP &> /dev/null 56 | iptables -P FORWARD DROP &> /dev/null 57 | 58 | # Allow loopback traffic and input for established connections 59 | iptables -A OUTPUT -o lo -j ACCEPT 60 | iptables -A INPUT -i lo -j ACCEPT 61 | iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 62 | 63 | firewall_temp_add 64 | } 65 | 66 | # This can be used to test that a particular pattern matching module is available 67 | # A bit blunt, but other methods such as using 'iptables-restore --test' seem to pass in some cases 68 | # when the command itself will fail. 69 | iptables_test () { 70 | iptables -A OUTPUT "$@" &> /dev/null || return 1 71 | iptables -D OUTPUT "$@" 72 | return 0 73 | } 74 | 75 | firewall_temp_add () { 76 | # Temporarily allow the following: 77 | # Only include the following when available 78 | iptables_test -p udp -m owner --uid-owner root -j REJECT && local owner="-m owner --uid-owner root" 79 | iptables_test -p tcp -m multiport --sports "$curl_local_port_min":"$curl_local_port_max" -j REJECT && local multiport="-m multiport --sports $curl_local_port_min:$curl_local_port_max" 80 | # DNS queries 81 | if [ -n "$original_extservers" ]; then 82 | # shellcheck disable=SC2086 83 | iptables -A OUTPUT -o "$outgoing_iface_dns" $owner -p udp --dport 53 -d "$original_nameserver" -j ACCEPT 84 | for extserver in $original_extservers; do 85 | [[ "$extserver" =~ "host" ]] && continue 86 | # shellcheck disable=SC2086 87 | iptables -A OUTPUT $owner -p udp --dport 53 -d "$extserver" -j ACCEPT 88 | done 89 | else 90 | # This is for compatibility with older versions of Docker in case custom container 91 | # dns servers are set as we don't know what they're set to 92 | # shellcheck disable=SC2086 93 | iptables -A OUTPUT $owner -p udp --dport 53 -j ACCEPT 94 | fi 95 | # HTTPS to download the server list and access API for generating auth token 96 | # shellcheck disable=SC2086 97 | iptables -A OUTPUT -o "$outgoing_iface" $owner -p tcp --dport 443 $multiport -j ACCEPT 98 | # API access to register the public WireGuard key 99 | # shellcheck disable=SC2086 100 | iptables -A OUTPUT -o "$outgoing_iface" $owner -p tcp --dport 1337 $multiport -j ACCEPT 101 | # Non-default API port if set 102 | # shellcheck disable=SC2086 103 | [ "$META_PORT" -ne 443 ] && iptables -A OUTPUT -i "$outgoing_iface" $owner -p tcp --dport "$META_PORT" $multiport -j ACCEPT 104 | firewall_temp_state=1 105 | } 106 | 107 | firewall_temp_remove () { 108 | iptables_test -p udp -m owner --uid-owner root -j REJECT && local owner="-m owner --uid-owner root" 109 | iptables_test -p tcp -m multiport --sports "$curl_local_port_min":"$curl_local_port_max" -j REJECT && local multiport="-m multiport --sports $curl_local_port_min:$curl_local_port_max" 110 | if [ -n "$original_extservers" ]; then 111 | # shellcheck disable=SC2086 112 | iptables -D OUTPUT -o "$outgoing_iface_dns" $owner -p udp --dport 53 -d "$original_nameserver" -j ACCEPT 113 | for extserver in $original_extservers; do 114 | [[ "$extserver" =~ "host" ]] && continue 115 | # shellcheck disable=SC2086 116 | iptables -D OUTPUT $owner -p udp --dport 53 -d "$extserver" -j ACCEPT 117 | done 118 | else 119 | # shellcheck disable=SC2086 120 | iptables -D OUTPUT $owner -p udp --dport 53 -j ACCEPT 121 | fi 122 | # shellcheck disable=SC2086 123 | iptables -D OUTPUT -o "$outgoing_iface" $owner -p tcp --dport 443 $multiport -j ACCEPT 124 | # shellcheck disable=SC2086 125 | iptables -D OUTPUT -o "$outgoing_iface" $owner -p tcp --dport 1337 $multiport -j ACCEPT 126 | # shellcheck disable=SC2086 127 | [ "$META_PORT" -ne 443 ] && iptables -D OUTPUT -o "$outgoing_iface" $owner -p tcp --dport "$META_PORT" $multiport -j ACCEPT 128 | unset firewall_temp_state 129 | } 130 | 131 | # Alpine 3.19 changed the default iptables backend to iptables-nft 132 | # Check that the host supports this and revert to iptables-legacy if needed 133 | # Can force one or the other using NFTABLES=0/1 env var 134 | nftables_setup () { 135 | # Run an iptables command to see if things are working 136 | [ -z "$NFTABLES" ] && iptables -L &> /dev/null && return 137 | [ "$NFTABLES" = "1" ] && return 138 | # If not, change to legacy 139 | echo "$(date): Using legacy iptables backend" 140 | ln -sf xtables-legacy-multi "$(type -p iptables)" 141 | ln -sf xtables-legacy-multi "$(type -p iptables-save)" 142 | ln -sf xtables-legacy-multi "$(type -p iptables-restore)" 143 | ln -sf xtables-legacy-multi "$(type -p ip6tables)" 144 | ln -sf xtables-legacy-multi "$(type -p ip6tables-save)" 145 | ln -sf xtables-legacy-multi "$(type -p ip6tables-restore)" 146 | if ! iptables -L &> /dev/null; then 147 | echo "$(date): Error reverting to legacy iptables backend" 148 | fatal_error 149 | fi 150 | } 151 | 152 | # Handle shutdown behavior 153 | finish () { 154 | [ -x "$pre_down_script" ] && run_command "$pre_down_script" 155 | [ -n "$PRE_DOWN" ] && run_command "$PRE_DOWN" 156 | [ "$PORT_FORWARDING" -eq 1 ] && pkill -f 'pf.sh' 157 | echo "$(date): Shutting down WireGuard" 158 | # Remove forwarded port number dump file if requested 159 | [ "$PORT_FILE_CLEANUP" -eq 1 ] && [ -w "$portfile" ] && rm "$portfile" 160 | wg-quick down wg0 161 | [ -x "$post_down_script" ] && run_command "$post_down_script" 162 | [ -n "$POST_DOWN" ] && run_command "$POST_DOWN" 163 | exit 0 164 | } 165 | 166 | trap finish SIGTERM SIGINT SIGQUIT 167 | 168 | endpoint_monitor () { 169 | local failures=0 170 | while true; do 171 | if ! ACTIVE_HEALTHCHECKS=1 PORT_FORWARDING=0 /scripts/healthcheck.sh &> /dev/null; then 172 | ((failures++)) 173 | else 174 | failures=0 175 | fi 176 | [ "$failures" -ge "$MONITOR_RETRIES" ] && return 1 177 | sleep "$MONITOR_INTERVAL" 178 | done 179 | } 180 | 181 | # All done. Sleep and wait for termination. 182 | now_sleep () { 183 | # If we need to, keep an eye on the port forwarding script and also monitor for unresponsive endpoint 184 | if [ "$RECONNECT" = "1" ]; then 185 | endpoint_monitor & 186 | local endpoint_monitor_pid="$!" 187 | echo "$(date): Started endpoint monitor" 188 | fi 189 | 190 | while [ -n "$pf_pid$endpoint_monitor_pid" ]; do 191 | # shellcheck disable=SC2086 192 | wait -n $endpoint_monitor_pid $pf_pid 193 | local result=$? 194 | # Check which pid terminated 195 | if [ -n "$pf_pid" ] && ! kill -0 "$pf_pid" 2> /dev/null; then 196 | if [ "$PORT_FORWARDING" = "1" ] && [ "$PORT_FATAL" = "1" ] && [ $result -ne 0 ];then 197 | echo "$(date): Port forwarding script failed" 198 | [ -n "$endpoint_monitor_pid" ] && kill "$endpoint_monitor_pid" 199 | fatal_error 200 | fi 201 | echo "$(date): Port forwarding script closed" 202 | unset pf_pid 203 | fi 204 | if [ -n "$endpoint_monitor_pid" ] && ! kill -0 "$endpoint_monitor_pid" 2> /dev/null && [ "$result" -ne 0 ]; then 205 | if [ "$PORT_FORWARDING" = "1" ]; then 206 | [ -n "$pf_pid" ] && kill "$pf_pid" 207 | unset pf_pid 208 | local prevport 209 | [ -r "$portfile" ] && prevport=$(cat "$portfile") 210 | if [ "$FIREWALL" = "1" ] && [ -n "$prevport" ]; then 211 | iptables -D INPUT -p tcp -i wg0 --dport "$prevport" -j ACCEPT 212 | iptables -D INPUT -p udp -i wg0 --dport "$prevport" -j ACCEPT 213 | fi 214 | fi 215 | 216 | echo "$(date): Unresponsive endpoint detected. Regenerating WireGuard config" 217 | [ -n "$PRE_RECONNECT" ] && run_command "$PRE_RECONNECT" 218 | 219 | # Temporarily allow the following to bypass the tunnel 220 | # This appears to be needed when curl binds to $outgoing_addr instead of $outgoing_iface, which seems to be needed for --local-port to work 221 | ip rule add from "$outgoing_addr" sport "$curl_local_port_min-$curl_local_port_max" dport 443 uidrange "$UID-$UID" lookup main pref 30 222 | ip rule add from "$outgoing_addr" sport "$curl_local_port_min-$curl_local_port_max" dport 1337 uidrange "$UID-$UID" lookup main pref 40 223 | [ "$META_PORT" -ne 443 ] && ip rule add from "$outgoing_addr" sport "$curl_local_port_min-$curl_local_port_max" dport "$META_PORT" uidrange "$UID-$UID" lookup main pref 50 224 | for extserver in $original_extservers; do 225 | [[ "$extserver" =~ "host" ]] && continue 226 | ip rule add to "$extserver" dport 53 uidrange "$UID-$UID" lookup main pref 60 227 | done 228 | [ "$FIREWALL" = "1" ] && firewall_temp_add 229 | 230 | gen_configs 231 | 232 | [ "$FIREWALL" = "1" ] && firewall_temp_remove 233 | for extserver in $original_extservers; do 234 | [[ "$extserver" =~ "host" ]] && continue 235 | ip rule del to "$extserver" dport 53 uidrange "$UID-$UID" lookup main pref 60 236 | done 237 | ip rule del from "$outgoing_addr" sport "$curl_local_port_min-$curl_local_port_max" dport 443 uidrange "$UID-$UID" lookup main pref 30 238 | ip rule del from "$outgoing_addr" sport "$curl_local_port_min-$curl_local_port_max" dport 1337 uidrange "$UID-$UID" lookup main pref 40 239 | [ "$META_PORT" -ne 443 ] && ip rule del from "$outgoing_addr" sport "$curl_local_port_min-$curl_local_port_max" dport "$META_PORT" uidrange "$UID-$UID" lookup main pref 50 240 | 241 | local new_privkey new_endpoint new_peerpubkey new_ip old_peerpubkey old_ip 242 | new_privkey=$(grep 'PrivateKey = ' "$wg_conf" | sed 's/PrivateKey = \(.*\)/\1/') 243 | new_endpoint=$(grep 'Endpoint = ' "$wg_conf" | sed 's/Endpoint = \(.*\)/\1/') 244 | new_peerpubkey=$(grep 'PublicKey = ' "$wg_conf" | sed 's/PublicKey = \(.*\)/\1/') 245 | new_ip=$(grep 'Address = ' "$wg_conf" | sed 's/Address = \(.*\)/\1/') 246 | old_peerpubkey=$(wg show wg0 |grep 'peer: ' | sed 's/peer: \(.*\)/\1/') 247 | old_ip=$(ip -o addr show dev wg0 | awk '$3 == "inet" {print $4}') 248 | 249 | wg set wg0 private-key <(echo "$new_privkey") || fatal_error 250 | wg set wg0 peer "$new_peerpubkey" endpoint "$new_endpoint" allowed-ips "0.0.0.0/0" || fatal_error 251 | if [ "$KEEPALIVE" -gt 0 ]; then 252 | wg set wg0 peer "$new_peerpubkey" persistent-keepalive "$KEEPALIVE" || fatal_error 253 | fi 254 | wg set wg0 peer "$old_peerpubkey" remove || fatal_error 255 | # Adding the new address first before removing the old one seems to be needed 256 | ip -4 address add "$new_ip" dev wg0 || fatal_error 257 | ip -4 address del "$old_ip" dev wg0 || fatal_error 258 | echo "$(date): Updated WireGuard endpoint" 259 | echo; wg show wg0; echo 260 | 261 | [ -n "$POST_RECONNECT" ] && run_command "$POST_RECONNECT" 262 | 263 | [ "$PORT_FORWARDING" = "1" ] && pf_start 264 | now_sleep 265 | elif [ -n "$endpoint_monitor_pid" ] && ! kill -0 "$endpoint_monitor_pid" 2> /dev/null && [ "$result" -eq 0 ]; then 266 | unset endpoint_monitor_pid 267 | fi 268 | done 269 | 270 | sleep infinity & 271 | wait $! 272 | } 273 | 274 | # An error with no recovery logic occured. Either go to sleep or exit. 275 | fatal_error () { 276 | echo "$(date): Fatal error" 277 | [ "$firewall_temp_state" = "1" ] && firewall_temp_remove 278 | [ -n "$FATAL_SCRIPT" ] && run_command "$FATAL_SCRIPT" 279 | [ "$EXIT_ON_FATAL" -eq 1 ] && exit 1 280 | sleep infinity & 281 | wait $! 282 | } 283 | 284 | run_command () { 285 | echo "$(date): Running: $1" 286 | eval "$1" 287 | } 288 | 289 | gen_wgconf () { 290 | /scripts/wg-gen.sh -l "$1" -t "$tokenfile" -o "$wg_conf" -k "/RegionsListPubKey.pem" -d "$VPNDNS" -m "$MTU" -c "$pia_cacrt" 291 | return $? 292 | } 293 | 294 | # Get a new auth token 295 | # Unsure how long an auth token will remain valid 296 | get_auth_token () { 297 | [ -r "$USER_FILE" ] && echo "$(date): Reading username from $USER_FILE" && USER=$(<"$USER_FILE") 298 | [ -r "$PASS_FILE" ] && echo "$(date): Reading password from $PASS_FILE" && PASS=$(<"$PASS_FILE") 299 | [ -z "$PASS" ] && echo "$(date): PIA password not set. Unable to retrieve new auth token." && fatal_error 300 | [ -z "$USER" ] && echo "$(date): PIA username not set. Unable to retrieve new auth token." && fatal_error 301 | echo "$(date): Generating auth token" 302 | local token 303 | if ! token=$(/scripts/pia-auth.sh -u "$USER" -p "$PASS" -n "$META_CN" -i "$META_IP" -o "$META_PORT" -c "$pia_cacrt"); then 304 | echo "$(date): Failed to acquire new auth token" && fatal_error 305 | fi 306 | echo "$token" > "$tokenfile" 307 | chmod 600 "$tokenfile" 308 | } 309 | 310 | get_outgoing_iface () 311 | { 312 | ip route get "$1" | head -1 | sed 's/.*dev \([^ ]*\) .*/\1/' 313 | } 314 | 315 | if [[ $(getpcaps 0) == "0: =ep" ]]; then 316 | echo "$(date): The container appears to be running with elevated privileges. The container generally only requires the NET_ADMIN capability to run." 317 | elif ! getpcaps 0 | grep cap_net_admin >& /dev/null; then 318 | echo "$(date): The container requires the NET_ADMIN capability to run. See the README for more info." 319 | fatal_error 320 | fi 321 | 322 | echo "$(date): Container build info: $BUILDINFO" 323 | 324 | nftables_setup 325 | 326 | [ -x "$pre_up_script" ] && run_command "$pre_up_script" 327 | [ -n "$PRE_UP" ] && run_command "$PRE_UP" 328 | 329 | # Explicitly set both interface and dns servers to use so we can run setup requests again bypassing the tunnel if needed once connected 330 | # Setting the local port also allows more specific temporary firewall rules 331 | # Re-using a single local port for multiple requests fails so a range is used instead: https://github.com/curl/curl/issues/6288 332 | # Setting --interface to an address rather than a name seems to be needed for --local-port to work 333 | # ExtServers contains dns servers used by docker's internal resolver, however this is only available from Docker v26.0 334 | original_nameserver=$(grep -im 1 '^nameserver' /etc/resolv.conf |cut -F2) 335 | original_extservers=$(grep '# ExtServers:' /etc/resolv.conf | sed 's/# ExtServers: \[\(.*\)\]/\1/') 336 | outgoing_iface=$(ip -4 route show default | awk '/default/ {print $5}' | head -1) 337 | outgoing_iface_dns=$(get_outgoing_iface "$original_nameserver") 338 | outgoing_addr=$(ip -4 -o addr show "$outgoing_iface" | awk '{print $4}' | cut -d "/" -f 1) 339 | curl_local_port_min=12300 340 | curl_local_port_max=12310 341 | export CURL_OVERRIDE_PARAMS="--interface $outgoing_addr --dns-interface $outgoing_iface_dns --dns-servers $original_nameserver --local-port $curl_local_port_min-$curl_local_port_max" 342 | echo "$(date): curl options: $CURL_OVERRIDE_PARAMS" 343 | 344 | [ "$FIREWALL" -eq 1 ] && firewall_init 345 | 346 | # Remove previous forwarded port number dump file if requested and present 347 | [ "$PORT_FILE_CLEANUP" -eq 1 ] && [ -w "$portfile" ] && rm "$portfile" 348 | 349 | # LOC is ignored and may be blank if ip/cn/port override vars or a dedicated ip are used 350 | [ -n "$PIA_CN" ] && [ -n "$PIA_IP" ] && [ -n "$PIA_PORT" ] && LOC="manual" 351 | [ -n "$PIA_DIP_TOKEN" ] && LOC="dip" 352 | 353 | # No LOC or specific ip/port/cn supplied 354 | [ -z "$LOC" ] && /scripts/wg-gen.sh -a && fatal_error 355 | 356 | [ ! -r "$tokenfile" ] && get_auth_token 357 | 358 | # Generate wg0.conf 359 | # LOC can be a single location id, or a space or comma separated list 360 | # Multiple location ids are used as fallback if the initial registration fails 361 | gen_configs() { 362 | gen_success=0 363 | for location in ${LOC//,/ }; do 364 | gen_wgconf "$location" 365 | local result=$? 366 | if [ "$result" -eq 2 ]; then 367 | # Reauth and retry if auth failed 368 | # An auth error implies that the location id is valid and the endpoint responsive 369 | rm "$tokenfile" 370 | get_auth_token 371 | gen_wgconf "$location" || fatal_error 372 | elif [ "$result" -eq 3 ]; then 373 | # Location not found 374 | echo "$(date): Location $location not found" 375 | continue 376 | elif [ "$result" -eq 4 ]; then 377 | # Registration failed 378 | echo "$(date): Registration failed" 379 | continue 380 | elif [ "$result" -ne 0 ]; then 381 | echo "$(date): Failed to generate WireGuard config" 382 | fatal_error 383 | fi 384 | gen_success=1 385 | break 386 | done 387 | 388 | if [ "$gen_success" -eq 0 ]; then 389 | echo "$(date): Failed to generate WireGuard config for the selected location/s: $LOC" 390 | fatal_error 391 | fi 392 | } 393 | 394 | gen_configs 395 | 396 | [ "$FIREWALL" -eq 1 ] && firewall_temp_remove 397 | 398 | # Add PersistentKeepalive if KEEPALIVE is set 399 | [ "$KEEPALIVE" -gt 0 ] && echo "PersistentKeepalive = $KEEPALIVE" >> "$wg_conf" 400 | 401 | # Bring up Wireguard interface 402 | echo "$(date): Bringing up WireGuard interface wg0" 403 | wg-quick up wg0 || fatal_error 404 | 405 | # Print out wg interface info 406 | echo 407 | wg 408 | echo 409 | 410 | echo "$(date): WireGuard successfully started" 411 | 412 | # Show a warning if src_valid_mark=1 needs setting, otherwise incoming packets will be dropped 413 | effective_rp_filter="$(sysctl -n net.ipv4.conf.all.rp_filter)" 414 | [ "$(sysctl -n net.ipv4.conf.default.rp_filter)" -gt "$effective_rp_filter" ] && effective_rp_filter="$(sysctl -n net.ipv4.conf.default.rp_filter)" 415 | [ "$effective_rp_filter" -eq 1 ] && [ "$(sysctl -n net.ipv4.conf.all.src_valid_mark)" -ne 1 ] && \ 416 | echo "$(date): Warning: Container requires net.ipv4.conf.all.src_valid_mark=1 sysctl to be set when rp_filter is set to strict. See the README for more info." 417 | 418 | # Add qdisc to wg0 if requested 419 | # eg: QDISC=cake bandwidth 20Mbit 420 | # shellcheck disable=SC2086 421 | [ -n "$QDISC" ] && echo "$(date): Adding qdisc to wg0: $QDISC" && tc qdisc add root dev wg0 $QDISC && tc -statistics qdisc show dev wg0 422 | 423 | if [ "$FIREWALL" -eq 1 ]; then 424 | # Allow docker network input/output 425 | for iface in /sys/class/net/*; do 426 | iface="${iface##*/}" 427 | [[ "$iface" = @(lo|wg0) ]] && continue 428 | docker_network="$(ip -o addr show dev "$iface"| 429 | awk '$3 == "inet" {print $4}')" 430 | [ -z "$docker_network" ] && continue 431 | echo "$(date): Allowing network access to $docker_network on $iface" 432 | iptables -A OUTPUT -o "$iface" --destination "$docker_network" -j ACCEPT 433 | iptables -A INPUT -i "$iface" --source "$docker_network" -j ACCEPT 434 | done 435 | 436 | # Allow WG stuff 437 | iptables -A OUTPUT -o wg0 -j ACCEPT 438 | iptables -A OUTPUT -m mark --mark "$(wg show wg0 fwmark)" -j ACCEPT 439 | 440 | echo "$(date): Firewall enabled: Blocking non-WireGuard traffic" 441 | fi 442 | 443 | # Set env var LOCAL_NETWORK=192.168.1.0/24 to allow LAN input/output 444 | # Accept comma separated as well as space separated list 445 | if [ -n "$LOCAL_NETWORK" ]; then 446 | gaddr=$(ip -4 route show default | awk '/default/ {print $3}' | head -1) 447 | [ -z "$VPNDNS" ] && pia_dns=$(grep 'DNS = ' "$wg_conf" | sed 's/DNS = \(.*\)/\1/') 448 | for range in ${LOCAL_NETWORK//,/ }; do 449 | if [ -n "$pia_dns" ]; then 450 | grepcidr "$range" <(echo "$pia_dns") >/dev/null && \ 451 | echo "$(date): Warning: LOCAL_NETWORK range $range overlaps with PIA's default dns servers ($pia_dns)" && \ 452 | echo "$(date): Consider setting custom dns servers using the VPNDNS env var if there are name resolution issues" 453 | fi 454 | if [ "$FIREWALL" -eq 1 ]; then 455 | echo "$(date): Allowing network access to $range on $outgoing_iface" 456 | iptables -A OUTPUT -o "$outgoing_iface" --destination "$range" -j ACCEPT 457 | iptables -A INPUT -i "$outgoing_iface" --source "$range" -j ACCEPT 458 | fi 459 | echo "$(date): Adding route to $range" 460 | ip route add "$range" via "$gaddr" 461 | done 462 | fi 463 | 464 | # Nat+forward traffic from a specific interface if requested 465 | # eg. FWD_IFACE=eth1 466 | if [ -n "$FWD_IFACE" ]; then 467 | iptables -t nat -A POSTROUTING -o wg0 -j MASQUERADE 468 | iptables -A FORWARD -i wg0 -o "$FWD_IFACE" -m state --state RELATED,ESTABLISHED -j ACCEPT 469 | iptables -A FORWARD -i "$FWD_IFACE" -o wg0 -j ACCEPT 470 | echo "$(date): Forwarding traffic from $FWD_IFACE to VPN" 471 | fi 472 | 473 | # Setup port forwarding if requested and available 474 | pf_start () { 475 | [ "$PORT_FORWARDING" -eq 1 ] && pkill -f 'pf.sh' 476 | pf_api_ip=$(grep '#pf api' "$wg_conf"| sed 's/#pf api ip: \(.*\)/\1/') 477 | pf_cn=$(grep '#cn: ' "$wg_conf"| sed 's/#cn: \(.*\)/\1/') 478 | if [ "$PORT_FORWARDING" -eq 1 ] && [ -n "$pf_api_ip" ]; then 479 | echo "$(date): Starting port forward script" 480 | # Try to use a persistent port if requested 481 | if [ "$PORT_PERSIST" -eq 1 ]; then 482 | /scripts/pf.sh -t "$tokenfile" -i "$pf_api_ip" -n "$pf_cn" -p "$portfile" -c "$pia_cacrt" -s "/scripts/pf_success.sh" -r "$pf_persistfile" -f wg0 & 483 | else 484 | /scripts/pf.sh -t "$tokenfile" -i "$pf_api_ip" -n "$pf_cn" -p "$portfile" -c "$pia_cacrt" -s "/scripts/pf_success.sh" -f wg0 & 485 | fi 486 | pf_pid=$! 487 | elif [ "$PORT_FORWARDING" -eq 1 ] && [ -z "$pf_api_ip" ]; then 488 | echo "$(date): Warning: Port forwarding is unavailable on this server. Try a different location." 489 | fi 490 | } 491 | 492 | [ "$PORT_FORWARDING" -eq 1 ] && pf_start 493 | 494 | [ -x "$post_up_script" ] && run_command "$post_up_script" 495 | [ -n "$POST_UP" ] && run_command "$POST_UP" 496 | 497 | # Workaround a NAT bug when using Wireguard behind a particular Asus router by regularly changing the local port 498 | # Set env var CYCLE_PORTS to a space-separated list of ports to cycle through 499 | # Eg: CYCLE_PORTS=50001 50002 50003 500 | # Optionally set CYCLE_INTERVAL to number of seconds to use each port for. Defaults to 180 (3mins) 501 | if [ -n "$CYCLE_PORTS" ]; then 502 | echo "$(date): Changing Wireguard's local port every ${CYCLE_INTERVAL:-180}s" 503 | while true; do 504 | for port in $CYCLE_PORTS; do 505 | wg set wg0 listen-port "$port" 506 | sleep "${CYCLE_INTERVAL:-180}" & wait $! 507 | done 508 | done 509 | fi 510 | 511 | now_sleep 512 | --------------------------------------------------------------------------------