diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..1f3c7898 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,62 @@ +{ + "name": "pia-dev", + "dockerComposeFile": ["docker-compose.yml"], + "service": "vscode", + "runServices": ["vscode"], + "shutdownAction": "stopCompose", + // "postCreateCommand": "go mod download", + "workspaceFolder": "/workspace", + "extensions": [ + "ms-vscode.go", + "IBM.output-colorizer", + "eamodio.gitlens", + "mhutchie.git-graph", + "davidanson.vscode-markdownlint", + "shardulm94.trailing-spaces", + "alefragnani.Bookmarks", + "Gruntfuggly.todo-tree", + "mohsen1.prettify-json", + "quicktype.quicktype", + "spikespaz.vscode-smoothtype", + "stkb.rewrap", + "vscode-icons-team.vscode-icons" + ], + "settings": { + // General settings + "files.eol": "\n", + // Docker + "remote.extensionKind": { + "ms-azuretools.vscode-docker": "workspace" + }, + // Golang general settings + "go.useLanguageServer": true, + "go.autocompleteUnimportedPackages": true, + "go.gotoSymbol.includeImports": true, + "go.gotoSymbol.includeGoroot": true, + "gopls": { + "completeUnimported": true, + "deepCompletion": true, + "usePlaceholders": false + }, + // Golang on save + "go.buildOnSave": "package", + "go.lintOnSave": "package", + "go.vetOnSave": "package", + "editor.formatOnSave": true, + "[go]": { + "editor.codeActionsOnSave": { + "source.organizeImports": true + } + }, + // Golang testing + "go.toolsEnvVars": { + "GOFLAGS": "-tags=integration" + }, + "gopls.env": { + "GOFLAGS": "-tags=integration" + }, + "go.testEnvVars": {}, + "go.testFlags": ["-v"], + "go.testTimeout": "600s" + } +} \ No newline at end of file diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 00000000..98206c29 --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,15 @@ +version: "3.7" + +services: + vscode: + image: qmcgaw/godevcontainer + volumes: + - ../:/workspace + - ~/.ssh:/home/vscode/.ssh:ro + - ~/.ssh:/root/.ssh:ro + - /var/run/docker.sock:/var/run/docker.sock + cap_add: + - SYS_PTRACE + security_opt: + - seccomp:unconfined + entrypoint: zsh -c "while sleep 1000; do :; done" diff --git a/.dockerignore b/.dockerignore index 1727b566..7596341d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,3 +7,4 @@ ci.sh docker-compose.yml LICENSE README.md +Dockerfile \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 4e29d54f..8575af0e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,78 +1,66 @@ -ARG ALPINE_VERSION=3.10 - -FROM alpine:${ALPINE_VERSION} -ARG VERSION -ARG BUILD_DATE -ARG VCS_REF -LABEL \ - org.opencontainers.image.authors="quentin.mcgaw@gmail.com" \ - org.opencontainers.image.created=$BUILD_DATE \ - org.opencontainers.image.version=$VERSION \ - org.opencontainers.image.revision=$VCS_REF \ - org.opencontainers.image.url="https://github.com/qdm12/private-internet-access-docker" \ - org.opencontainers.image.documentation="https://github.com/qdm12/private-internet-access-docker" \ - org.opencontainers.image.source="https://github.com/qdm12/private-internet-access-docker" \ - org.opencontainers.image.title="PIA client" \ - org.opencontainers.image.description="VPN client to tunnel to private internet access servers using OpenVPN, IPtables, DNS over TLS and Alpine Linux" -ENV USER= \ - PASSWORD= \ - ENCRYPTION=strong \ - PROTOCOL=udp \ - REGION="CA Montreal" \ - NONROOT=yes \ - DOT=on \ - BLOCK_MALICIOUS=off \ - BLOCK_NSA=off \ - UNBLOCK= \ - EXTRA_SUBNETS= \ - PORT_FORWARDING=off \ - PORT_FORWARDING_STATUS_FILE="/forwarded_port" \ - TINYPROXY=off \ - TINYPROXY_LOG=Critical \ - TINYPROXY_PORT=8888 \ - TINYPROXY_USER= \ - TINYPROXY_PASSWORD= \ - SHADOWSOCKS=off \ - SHADOWSOCKS_LOG=on \ - SHADOWSOCKS_PORT=8388 \ - SHADOWSOCKS_PASSWORD= \ - TZ= -ENTRYPOINT /entrypoint.sh -EXPOSE 8888/tcp 8388/tcp 8388/udp -HEALTHCHECK --interval=3m --timeout=3s --start-period=20s --retries=1 CMD /healthcheck.sh -RUN apk add -q --progress --no-cache --update openvpn wget ca-certificates iptables unbound unzip tinyproxy jq tzdata && \ - echo "http://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/apk/repositories && \ - apk add -q --progress --no-cache --update shadowsocks-libev && \ - wget -q https://www.privateinternetaccess.com/openvpn/openvpn.zip \ - https://www.privateinternetaccess.com/openvpn/openvpn-strong.zip \ - https://www.privateinternetaccess.com/openvpn/openvpn-tcp.zip \ - https://www.privateinternetaccess.com/openvpn/openvpn-strong-tcp.zip && \ - mkdir -p /openvpn/target && \ - unzip -q openvpn.zip -d /openvpn/udp-normal && \ - unzip -q openvpn-strong.zip -d /openvpn/udp-strong && \ - unzip -q openvpn-tcp.zip -d /openvpn/tcp-normal && \ - unzip -q openvpn-strong-tcp.zip -d /openvpn/tcp-strong && \ - apk del -q --progress --purge unzip && \ - rm -rf /*.zip /var/cache/apk/* /etc/unbound/* /usr/sbin/unbound-anchor /usr/sbin/unbound-checkconf /usr/sbin/unbound-control /usr/sbin/unbound-control-setup /usr/sbin/unbound-host /etc/tinyproxy/tinyproxy.conf && \ - adduser nonrootuser -D -H --uid 1000 && \ - wget -q https://raw.githubusercontent.com/qdm12/files/master/named.root.updated -O /etc/unbound/root.hints && \ - wget -q https://raw.githubusercontent.com/qdm12/files/master/root.key.updated -O /etc/unbound/root.key && \ - cd /tmp && \ - wget -q https://raw.githubusercontent.com/qdm12/files/master/malicious-hostnames.updated -O malicious-hostnames && \ - wget -q https://raw.githubusercontent.com/qdm12/files/master/surveillance-hostnames.updated -O nsa-hostnames && \ - wget -q https://raw.githubusercontent.com/qdm12/files/master/malicious-ips.updated -O malicious-ips && \ - while read hostname; do echo "local-zone: \""$hostname"\" static" >> blocks-malicious.conf; done < malicious-hostnames && \ - while read ip; do echo "private-address: $ip" >> blocks-malicious.conf; done < malicious-ips && \ - tar -cjf /etc/unbound/blocks-malicious.bz2 blocks-malicious.conf && \ - while read hostname; do echo "local-zone: \""$hostname"\" static" >> blocks-nsa.conf; done < nsa-hostnames && \ - tar -cjf /etc/unbound/blocks-nsa.bz2 blocks-nsa.conf && \ - rm -f /tmp/* -COPY unbound.conf /etc/unbound/unbound.conf -COPY tinyproxy.conf /etc/tinyproxy/tinyproxy.conf -COPY shadowsocks.json /etc/shadowsocks.json -COPY entrypoint.sh healthcheck.sh portforward.sh / -RUN chown nonrootuser -R /etc/unbound /etc/tinyproxy && \ - chmod 700 /etc/unbound /etc/tinyproxy && \ - chmod 600 /etc/unbound/unbound.conf /etc/tinyproxy/tinyproxy.conf /etc/shadowsocks.json && \ - chmod 500 /entrypoint.sh /healthcheck.sh /portforward.sh && \ - chmod 400 /etc/unbound/root.hints /etc/unbound/root.key /etc/unbound/*.bz2 +ARG ALPINE_VERSION=3.11 +ARG GO_VERSION=1.13.7 + +FROM golang:${GO_VERSION}-alpine${ALPINE_VERSION} AS builder +RUN apk --update add git +WORKDIR /tmp/gobuild +ENV CGO_ENABLED=0 +COPY go.mod go.sum ./ +RUN go mod download 2>&1 +COPY internal/ ./internal/ +COPY cmd/main.go . +RUN go test ./... +RUN go build -ldflags="-s -w" -o entrypoint main.go + +FROM alpine:${ALPINE_VERSION} +ARG VERSION +ARG BUILD_DATE +ARG VCS_REF +ENV VERSION=$VERSION \ + BUILD_DATE=$BUILD_DATE \ + VCS_REF=$VCS_REF +LABEL \ + org.opencontainers.image.authors="quentin.mcgaw@gmail.com" \ + org.opencontainers.image.created=$BUILD_DATE \ + org.opencontainers.image.version=$VERSION \ + org.opencontainers.image.revision=$VCS_REF \ + org.opencontainers.image.url="https://github.com/qdm12/private-internet-access-docker" \ + org.opencontainers.image.documentation="https://github.com/qdm12/private-internet-access-docker" \ + org.opencontainers.image.source="https://github.com/qdm12/private-internet-access-docker" \ + org.opencontainers.image.title="PIA client" \ + org.opencontainers.image.description="VPN client to tunnel to private internet access servers using OpenVPN, IPtables, DNS over TLS and Alpine Linux" +ENV USER= \ + PASSWORD= \ + ENCRYPTION=strong \ + PROTOCOL=udp \ + REGION="CA Montreal" \ + DOT=on \ + DOT_PROVIDERS=cloudflare \ + BLOCK_MALICIOUS=on \ + BLOCK_SURVEILLANCE=off \ + BLOCK_ADS=off \ + UNBLOCK= \ + EXTRA_SUBNETS= \ + PORT_FORWARDING=off \ + PORT_FORWARDING_STATUS_FILE="/forwarded_port" \ + TINYPROXY=off \ + TINYPROXY_LOG=Info \ + TINYPROXY_PORT=8888 \ + TINYPROXY_USER= \ + TINYPROXY_PASSWORD= \ + SHADOWSOCKS=off \ + SHADOWSOCKS_LOG=on \ + SHADOWSOCKS_PORT=8388 \ + SHADOWSOCKS_PASSWORD= \ + TZ= +ENTRYPOINT /entrypoint +EXPOSE 8888/tcp 8388/tcp 8388/udp +# HEALTHCHECK --interval=3m --timeout=3s --start-period=20s --retries=1 CMD /entrypoint -healthcheck +RUN apk add -q --progress --no-cache --update openvpn ca-certificates iptables unbound tinyproxy tzdata && \ + echo "http://dl-cdn.alpinelinux.org/alpine/edge/testing" >> /etc/apk/repositories && \ + apk add -q --progress --no-cache --update shadowsocks-libev && \ + rm -rf /*.zip /var/cache/apk/* /etc/unbound/* /usr/sbin/unbound-anchor /usr/sbin/unbound-checkconf /usr/sbin/unbound-control /usr/sbin/unbound-control-setup /usr/sbin/unbound-host /etc/tinyproxy/tinyproxy.conf && \ + adduser nonrootuser -D -H --uid 1000 && \ + chown nonrootuser -R /etc/unbound /etc/tinyproxy && \ + chmod 700 /etc/unbound /etc/tinyproxy +COPY --from=builder --chown=1000:1000 /tmp/gobuild/entrypoint /entrypoint \ No newline at end of file diff --git a/README.md b/README.md index 55729afb..e8d357b8 100644 --- a/README.md +++ b/README.md @@ -1,295 +1,329 @@ -# Private Internet Access Client - -*Lightweight swiss-knife-like VPN client to tunnel to private internet access servers, using OpenVPN, iptables, DNS over TLS, ShadowSocks, Tinyproxy and more* - -**ANNOUCEMENT**: I just published [*Kape acquisition of Private Internet Access: not worry you must*](https://link.medium.com/e70B1j0wz2) - - - - - -[![Build Status](https://travis-ci.org/qdm12/private-internet-access-docker.svg?branch=master)](https://travis-ci.org/qdm12/private-internet-access-docker) -[![Docker Pulls](https://img.shields.io/docker/pulls/qmcgaw/private-internet-access.svg)](https://hub.docker.com/r/qmcgaw/private-internet-access) -[![Docker Stars](https://img.shields.io/docker/stars/qmcgaw/private-internet-access.svg)](https://hub.docker.com/r/qmcgaw/private-internet-access) - -[![GitHub last commit](https://img.shields.io/github/last-commit/qdm12/private-internet-access-docker.svg)](https://github.com/qdm12/private-internet-access-docker/issues) -[![GitHub commit activity](https://img.shields.io/github/commit-activity/y/qdm12/private-internet-access-docker.svg)](https://github.com/qdm12/private-internet-access-docker/issues) -[![GitHub issues](https://img.shields.io/github/issues/qdm12/private-internet-access-docker.svg)](https://github.com/qdm12/private-internet-access-docker/issues) - -[![Image size](https://images.microbadger.com/badges/image/qmcgaw/private-internet-access.svg)](https://microbadger.com/images/qmcgaw/private-internet-access) -[![Image version](https://images.microbadger.com/badges/version/qmcgaw/private-internet-access.svg)](https://microbadger.com/images/qmcgaw/private-internet-access) -[![Join Slack channel](https://img.shields.io/badge/slack-@qdm12-yellow.svg?logo=slack)](https://join.slack.com/t/qdm12/shared_invite/enQtOTE0NjcxNTM1ODc5LTYyZmVlOTM3MGI4ZWU0YmJkMjUxNmQ4ODQ2OTAwYzMxMTlhY2Q1MWQyOWUyNjc2ODliNjFjMDUxNWNmNzk5MDk) - -
Click to show base components

- -- [Alpine 3.10](https://alpinelinux.org) for a tiny image -- [OpenVPN 2.4.7](https://pkgs.alpinelinux.org/package/v3.10/main/x86_64/openvpn) to tunnel to PIA servers -- [IPtables 1.8.3](https://pkgs.alpinelinux.org/package/v3.10/main/x86_64/iptables) enforces the container to communicate only through the VPN or with other containers in its virtual network (acts as a killswitch) -- [Unbound 1.9.1](https://pkgs.alpinelinux.org/package/v3.10/main/x86_64/unbound) configured with Cloudflare's [1.1.1.1](https://1.1.1.1) DNS over TLS -- [Files and blocking lists built periodically](https://github.com/qdm12/updated/tree/master/files) used with Unbound (see `BLOCK_MALICIOUS` and `BLOCK_NSA` environment variables) -- [TinyProxy 1.10.0](https://pkgs.alpinelinux.org/package/v3.10/main/x86_64/tinyproxy) - -

- -## Features - --
Configure everything with environment variables

- - - [Destination region](https://www.privateinternetaccess.com/pages/network) - - Internet protocol - - Level of encryption - - PIA Username and password - - DNS over TLS - - Malicious DNS blocking - - Internal firewall - - Web HTTP proxy - - Run openvpn without root - -

-- Connect other containers to it, [see this](https://github.com/qdm12/private-internet-access-docker#connect-to-it) -- Compatible with amd64, i686 (32 bit), ARM 64 bit, ARM 32 bit v6 and v7, ppc64le and even that s390x 🎆 -- Port forwarding -- The *iptables* firewall allows traffic only with needed PIA servers (IP addresses, port, protocol) combinations -- OpenVPN reconnects automatically on failure -- Docker healthcheck pings the DNS 1.1.1.1 to verify the connection is up -- Unbound DNS runs *without root* -- OpenVPN runs *without root* by default. You can run it with root with the environment variable `NONROOT=no` -- Connect your LAN devices - - HTTP Web proxy *tinyproxy* - - SOCKS5 proxy *shadowsocks* (better as it does UDP too) - -## Setup - -1.
Requirements

- - - A Private Internet Access **username** and **password** - [Sign up](https://www.privateinternetaccess.com/pages/buy-vpn/) - - External firewall requirements, if you have one - - Allow outbound TCP 853 to 1.1.1.1 to allow Unbound to resolve the PIA domain name at start. You can then block it once the container is started. - - For UDP strong encryption, allow outbound UDP 1197 - - For UDP normal encryption, allow outbound UDP 1198 - - For TCP strong encryption, allow outbound TCP 501 - - For TCP normal encryption, allow outbound TCP 502 - - For the built-in web HTTP proxy, allow inbound TCP 8888 - - For the built-in SOCKS5 proxy, allow inbound TCP 8388 and UDP 8388 - - Docker API 1.25 to support `init` - - If you use Docker Compose, docker-compose >= 1.22.0, to support `init: true` - -

- -1. Ensure `/dev/net/tun` is setup on your host with either: - - ```sh - insmod /lib/modules/tun.ko - # or... - modprobe tun - ``` - -1. Launch the container with: - - ```bash - docker run -d --init --name=pia --cap-add=NET_ADMIN --device=/dev/net/tun \ - -e REGION="CA Montreal" -e USER=js89ds7 -e PASSWORD=8fd9s239G \ - qmcgaw/private-internet-access - ``` - - or use [docker-compose.yml](https://github.com/qdm12/private-internet-access-docker/blob/master/docker-compose.yml) with: - - ```bash - docker-compose up -d - ``` - - Note that you can: - - Change the many [environment variables](#environment-variables) available - - Use `-p 8888:8888/tcp` to access the HTTP web proxy (and put your LAN in `EXTRA_SUBNETS` environment variable) - - Use `-p 8388:8388/tcp -p 8388:8388/udp` to access the SOCKS5 proxy (and put your LAN in `EXTRA_SUBNETS` environment variable) - - Pass additional arguments to *openvpn* using Docker's command function (commands after the image name) -1. You can update the image with `docker pull qmcgaw/private-internet-access:latest`. There are also docker tags available: - - `qmcgaw/private-internet-access:v1` linked to the [v1 release](https://github.com/qdm12/private-internet-access-docker/releases/tag/v1.0) - -## Testing - -Check the PIA IP address matches your expectations - -```sh -docker run --rm --network=container:pia alpine:3.10 wget -qO- https://ipinfo.io -``` - -## Environment variables - -| Environment variable | Default | Description | -| --- | --- | --- | -| `REGION` | `CA Montreal` | One of the [PIA regions](https://www.privateinternetaccess.com/pages/network/) | -| `PROTOCOL` | `udp` | `tcp` or `udp` | -| `ENCRYPTION` | `strong` | `normal` or `strong` | -| `USER` | | Your PIA username | -| `PASSWORD` | | Your PIA password | -| `NONROOT` | `yes` | Run OpenVPN without root, `yes` or `no` | -| `DOT` | `on` | `on` or `off`, to activate DNS over TLS to 1.1.1.1 | -| `BLOCK_MALICIOUS` | `off` | `on` or `off`, blocks malicious hostnames and IPs | -| `BLOCK_NSA` | `off` | `on` or `off`, blocks NSA hostnames | -| `UNBLOCK` | | comma separated string (i.e. `web.com,web2.ca`) to unblock hostnames | -| `EXTRA_SUBNETS` | | comma separated subnets allowed in the container firewall (i.e. `192.168.1.0/24,192.168.10.121,10.0.0.5/28`) | -| `PORT_FORWARDING` | `off` | Set to `on` to forward a port on PIA server | -| `PORT_FORWARDING_STATUS_FILE` | `/forwarded_port` | File path to store the forwarded port number | -| `TINYPROXY` | `on` | `on` or `off`, to enable the internal HTTP proxy tinyproxy | -| `TINYPROXY_LOG` | `Critical` | `Info`, `Warning`, `Error` or `Critical` | -| `TINYPROXY_PORT` | `8888` | `1024` to `65535` internal port for HTTP proxy | -| `TINYPROXY_USER` | | Username to use to connect to the HTTP proxy | -| `TINYPROXY_PASSWORD` | | Passsword to use to connect to the HTTP proxy | -| `SHADOWSOCKS` | `on` | `on` or `off`, to enable the internal SOCKS5 proxy Shadowsocks | -| `SHADOWSOCKS_LOG` | `on` | `on` or `off` to enable logging for Shadowsocks | -| `SHADOWSOCKS_PORT` | `8388` | `1024` to `65535` internal port for SOCKS5 proxy | -| `SHADOWSOCKS_PASSWORD` | | Passsword to use to connect to the SOCKS5 proxy | -| `TZ` | | Specify a timezone to use e.g. `Europe/London` | - -## Connect to it - -There are various ways to achieve this, depending on your use case. - --
Connect containers in the same docker-compose.yml as PIA

- - Add `network_mode: "service:pia"` to your *docker-compose.yml* (no need for `depends_on`) - -

--
Connect other containers to PIA

- - Add `--network=container:pia` when launching the container, provided PIA is already running - -

--
Connect containers from another docker-compose.yml

- - Add `network_mode: "container:pia"` to your *docker-compose.yml*, provided PIA is already running - -

--
Connect LAN devices through the built-in HTTP proxy *Tinyproxy* (i.e. with Chrome, Kodi, etc.)

- - 1. Setup a HTTP proxy client, such as [SwitchyOmega for Chrome](https://chrome.google.com/webstore/detail/proxy-switchyomega/padekgcemlokbadohgkifijomclgjgif?hl=en) - 1. Ensure the PIA container is launched with: - - port `8888` published `-p 8888:8888/tcp` - - your LAN subnet, i.e. `192.168.1.0/24`, set as `-e EXTRA_SUBNETS=192.168.1.0/24` - 1. With your HTTP proxy client, connect to the Docker host (i.e. `192.168.1.10`) on port `8888`. You need to enter your credentials if you set them with `TINYPROXY_USER` and `TINYPROXY_PASSWORD`. - 1. If you set `TINYPROXY_LOG` to `Info`, more information will be logged in the Docker logs, merged with the OpenVPN logs. - `TINYPROXY_LOG` defaults to `Critical` to avoid logging everything, for privacy purposes. - -

--
Connect LAN devices through the built-in SOCKS5 proxy *Shadowsocks* (per app, system wide, etc.)

- - 1. Setup a SOCKS5 proxy client, there is a list of [ShadowSocks clients for **all platforms**](https://shadowsocks.org/en/download/clients.html) - - **note** some clients do not tunnel UDP so your DNS queries will be done locally and not through PIA and its built in DNS over TLS - - Clients that support such UDP tunneling are, as far as I know: - - iOS: Potatso Lite - - OSX: ShadowsocksX - - Android: Shadowsocks by Max Lv - 1. Ensure the PIA container is launched with: - - port `8388` published `-p 8388:8388/tcp -p 8388:8388/udp` - - your LAN subnet, i.e. `192.168.1.0/24`, set as `-e EXTRA_SUBNETS=192.168.1.0/24` - 1. With your SOCKS5 proxy client - - Enter the Docker host (i.e. `192.168.1.10`) as the server IP - - Enter port TCP (and UDP, if available) `8388` as the server port - - Use the password you have set with `SHADOWSOCKS_PASSWORD` - - Choose the encryption method/algorithm `chacha20-ietf-poly1305` - 1. If you set `SHADOWSOCKS_LOG` to `on`, more information will be logged in the Docker logs, merged with the OpenVPN logs. - -

--
Access ports of containers connected to PIA

- - In example, to access port `8000` of container `xyz` and `9000` of container `abc` connected to PIA, - publish ports `8000` and `9000` for the PIA container and access them as you would with any other container - -

--
Access ports of containers connected to PIA, all in the same docker-compose.yml

- - In example, to access port `8000` of container `xyz` and `9000` of container `abc` connected to PIA, publish port `8000` and `9000` for the PIA container. - The docker-compose.yml file would look like: - - ```yml - version: '3.7' - services: - pia: - image: qmcgaw/private-internet-access - container_name: pia - init: true - cap_add: - - NET_ADMIN - devices: - - /dev/net/tun - environment: - - USER=js89ds7 - - PASSWORD=8fd9s239G - ports: - - 8000:8000/tcp - - 9000:9000/tcp - abc: - image: abc - container_name: abc - network_mode: "service:pia" - xyz: - image: xyz - container_name: xyz - network_mode: "service:pia" - ``` - -

- -## Port forwarding - -By setting `PORT_FORWARDING` environment variable to `on`, the forwarded port will be read and written to the file specified in `PORT_FORWARDING_STATUS_FILE` (by default, this is set to `/forwarded_port`). If the location for this file does not exist, it will be created automatically. - -You can mount this file as a volume to read it from other containers. - -Note that not all regions support port forwarding. - -## For the paranoids - -- You can review the code which essential consists in the [Dockerfile](https://github.com/qdm12/private-internet-access-docker/blob/master/Dockerfile) and [entrypoint.sh](https://github.com/qdm12/private-internet-access-docker/blob/master/entrypoint.sh) -- Build the images yourself: - - ```bash - docker build -t qmcgaw/private-internet-access https://github.com/qdm12/private-internet-access-docker.git - ``` - -- The download and unziping of PIA openvpn files is done at build for the ones not able to download the zip files -- Checksums for PIA openvpn zip files are not used as these files change often (but HTTPS is used) -- Use `-e ENCRYPTION=strong -e BLOCK_MALICIOUS=on` -- You can test DNSSEC using [internet.nl/connection](https://www.internet.nl/connection/) -- Check DNS leak tests with [https://www.dnsleaktest.com](https://www.dnsleaktest.com) -- DNS Leaks tests might not work because of [this](https://github.com/qdm12/cloudflare-dns-server#verify-dns-connection) (*TLDR*: DNS server is a local caching intermediary) - -## Troubleshooting - -- Password problems `AUTH: Received control message: AUTH_FAILED` - - Your password may contain a special character such as `$`. - You need to escape it with `\` in your run command or docker-compose.yml. - For example you would set `-e PASSWORD=mypa\$\$word`. -- Fallback to a previous version - 1. Clone the repository on your machine - - ```sh - git clone https://github.com/qdm12/private-internet-access-docker.git pia - cd pia - ``` - - 1. Look up which commit you want to go back to [here](https://github.com/qdm12/private-internet-access-docker/commits/master), i.e. `942cc7d4d10545b6f5f89c907b7dd1dbc39368e0` - 1. Revert to this commit locally - - ```sh - git reset --hard 942cc7d4d10545b6f5f89c907b7dd1dbc39368e0 - ``` - - 1. Build the Docker image - - ```sh - docker build -t qmcgaw/private-internet-access . - ``` - -## TODOs - -- Golang binary to setup the container at start, and: - - Mix logs of unbound, tinyproxy, shadowsocks and openvpn together somehow - - support other VPN providers -- Maybe use `--inactive 3600 --ping 10 --ping-exit 60` as default behavior -- Try without tun - -## License - -This repository is under an [MIT license](https://github.com/qdm12/private-internet-access-docker/master/license) +# Private Internet Access Client + +*Lightweight swiss-knife-like VPN client to tunnel to private internet access servers, using OpenVPN, iptables, DNS over TLS, ShadowSocks, Tinyproxy and more* + +**ANNOUCEMENT**: *Total rewrite in Go: see the new features [below](#Features)* (in case something break use the image with tag `:old`) + + + + + +[![Build Status](https://travis-ci.org/qdm12/private-internet-access-docker.svg?branch=master)](https://travis-ci.org/qdm12/private-internet-access-docker) +[![Docker Pulls](https://img.shields.io/docker/pulls/qmcgaw/private-internet-access.svg)](https://hub.docker.com/r/qmcgaw/private-internet-access) +[![Docker Stars](https://img.shields.io/docker/stars/qmcgaw/private-internet-access.svg)](https://hub.docker.com/r/qmcgaw/private-internet-access) + +[![GitHub last commit](https://img.shields.io/github/last-commit/qdm12/private-internet-access-docker.svg)](https://github.com/qdm12/private-internet-access-docker/issues) +[![GitHub commit activity](https://img.shields.io/github/commit-activity/y/qdm12/private-internet-access-docker.svg)](https://github.com/qdm12/private-internet-access-docker/issues) +[![GitHub issues](https://img.shields.io/github/issues/qdm12/private-internet-access-docker.svg)](https://github.com/qdm12/private-internet-access-docker/issues) + +[![Image size](https://images.microbadger.com/badges/image/qmcgaw/private-internet-access.svg)](https://microbadger.com/images/qmcgaw/private-internet-access) +[![Image version](https://images.microbadger.com/badges/version/qmcgaw/private-internet-access.svg)](https://microbadger.com/images/qmcgaw/private-internet-access) +[![Join Slack channel](https://img.shields.io/badge/slack-@qdm12-yellow.svg?logo=slack)](https://join.slack.com/t/qdm12/shared_invite/enQtOTE0NjcxNTM1ODc5LTYyZmVlOTM3MGI4ZWU0YmJkMjUxNmQ4ODQ2OTAwYzMxMTlhY2Q1MWQyOWUyNjc2ODliNjFjMDUxNWNmNzk5MDk) + +
Click to show base components

+ +- [Alpine 3.11](https://alpinelinux.org) for a tiny image (37MB of packages, 6.7MB of Go binary and 5.6MB for Alpine) +- [OpenVPN 2.4.8](https://pkgs.alpinelinux.org/package/v3.11/main/x86_64/openvpn) to tunnel to PIA servers +- [IPtables 1.8.3](https://pkgs.alpinelinux.org/package/v3.11/main/x86_64/iptables) enforces the container to communicate only through the VPN or with other containers in its virtual network (acts as a killswitch) +- [Unbound 1.9.6](https://pkgs.alpinelinux.org/package/v3.11/main/x86_64/unbound) configured with Cloudflare's [1.1.1.1](https://1.1.1.1) DNS over TLS (configurable with 5 different providers) +- [Files and blocking lists built periodically](https://github.com/qdm12/updated/tree/master/files) used with Unbound (see `BLOCK_MALICIOUS`, `BLOCK_SURVEILLANCE` and `BLOCK_ADS` environment variables) +- [TinyProxy 1.10.0](https://pkgs.alpinelinux.org/package/v3.11/main/x86_64/tinyproxy) +- [Shadowsocks 3.3.4](https://pkgs.alpinelinux.org/package/edge/testing/x86/shadowsocks-libev) + +

+ +## Features + +- **New features** + - Choice to block ads, malicious and surveillance at the DNS level + - All program output streams are merged (openvpn, unbound, shadowsocks, tinyproxy, etc.) + - Choice of DNS over TLS provider(s) + - Possibility of split horizon DNS by selecting multiple DNS over TLS providers + - Download block lists and cryptographic files at start instead of at build time + - Can work as a Kubernetes sidecar container, thanks @rorph + - Pick a random region if no region is given, thanks @rorph +-
Configure everything with environment variables

+ + - [Destination region](https://www.privateinternetaccess.com/pages/network) + - Internet protocol + - Level of encryption + - PIA Username and password + - DNS over TLS + - DNS blocking: ads, malicious, surveillance + - Internal firewall + - Socks5 proxy + - Web HTTP proxy + +

+- Connect + - [Other containers to it](https://github.com/qdm12/private-internet-access-docker#connect-to-it) + - [LAN devices to it](https://github.com/qdm12/private-internet-access-docker#connect-to-it) +- Killswitch using *iptables* to allow traffic only with needed PIA servers and LAN devices +- Port forwarding +- Compatible with amd64, i686 (32 bit), **ARM** 64 bit, ARM 32 bit v6 and v7, ppc64le and even that s390x 🎆 +- Sub programs drop root privileges once launched: Openvpn, Unbound, Shadowsocks, Tinyproxy + +## Setup + +1.
Requirements

+ + - A Private Internet Access **username** and **password** - [Sign up](https://www.privateinternetaccess.com/pages/buy-vpn/) + - Docker API 1.25 to support `init` + - If you use Docker Compose, docker-compose >= 1.22.0, to support `init: true` + -

External firewall requirements, if you have one

+ + - At start only + - Allow outbound TCP 443 to github.com and privateinternetaccess.com + - If `DOT=on`, allow outbound TCP 853 to 1.1.1.1 to allow Unbound to resolve the PIA domain name. + - If `DOT=off`, allow outbound UDP 53 to your DNS provider to resolve the PIA domain name. + - For UDP strong encryption, allow outbound UDP 1197 to the corresponding VPN server IPs + - For UDP normal encryption, allow outbound UDP 1198 to the corresponding VPN server IPs + - For TCP strong encryption, allow outbound TCP 501 to the corresponding VPN server IPs + - For TCP normal encryption, allow outbound TCP 502 to the corresponding VPN server IPs + - If `SHADOWSOCKS=on`, allow inbound TCP 8388 and UDP 8388 from your LAN + - If `TINYPROXY=on`, allow inbound TCP 8888 from your LAN + +

+ +

+ +1. Ensure `/dev/net/tun` is setup on your host with either: + + ```sh + insmod /lib/modules/tun.ko + # or... + modprobe tun + ``` + +1. Launch the container with: + + ```bash + docker run -d --init --name=pia --cap-add=NET_ADMIN --device=/dev/net/tun \ + -e REGION="CA Montreal" -e USER=js89ds7 -e PASSWORD=8fd9s239G \ + qmcgaw/private-internet-access + ``` + + or use [docker-compose.yml](https://github.com/qdm12/private-internet-access-docker/blob/master/docker-compose.yml) with: + + ```bash + docker-compose up -d + ``` + + Note that you can: + - Change the many [environment variables](#environment-variables) available + - Use `-p 8888:8888/tcp` to access the HTTP web proxy (and put your LAN in `EXTRA_SUBNETS` environment variable) + - Use `-p 8388:8388/tcp -p 8388:8388/udp` to access the SOCKS5 proxy (and put your LAN in `EXTRA_SUBNETS` environment variable) + - Pass additional arguments to *openvpn* using Docker's command function (commands after the image name) +1. You can update the image with `docker pull qmcgaw/private-internet-access:latest`. There are also docker tags available: + - `qmcgaw/private-internet-access:v1` linked to the [v1 release](https://github.com/qdm12/private-internet-access-docker/releases/tag/v1.0) + +## Testing + +Check the PIA IP address matches your expectations + +```sh +docker run --rm --network=container:pia alpine:3.10 wget -qO- https://ipinfo.io +``` + +## Environment variables + +| Environment variable | Default | Description | +| --- | --- | --- | +| `REGION` | `CA Montreal` | One of the [PIA regions](https://www.privateinternetaccess.com/pages/network/) | +| `PROTOCOL` | `udp` | `tcp` or `udp` | +| `ENCRYPTION` | `strong` | `normal` or `strong` | +| `USER` | | Your PIA username | +| `PASSWORD` | | Your PIA password | +| `DOT` | `on` | `on` or `off`, to activate DNS over TLS to 1.1.1.1 | +| `DOT_PROVIDERS` | `cloudflare` | Comma delimited list of DNS over TLS providers from `cloudflare`, `google`, `quad9`, `quadrant`, `cleanbrowsing`, `securedns`, `libredns` | +| `DOT_VERBOSITY` | `1` | Unbound verbosity level from `0` to `5` (full debug) | +| `DOT_VERBOSITY_DETAILS` | `0` | Unbound details verbosity level from `0` to `4` | +| `DOT_VALIDATION_LOGLEVEL` | `0` | Unbound validation log level from `0` to `2` | +| `BLOCK_MALICIOUS` | `on` | `on` or `off`, blocks malicious hostnames and IPs | +| `BLOCK_SURVEILLANCE` | `off` | `on` or `off`, blocks surveillance hostnames and IPs | +| `BLOCK_ADS` | `off` | `on` or `off`, blocks ads hostnames and IPs | +| `UNBLOCK` | | comma separated string (i.e. `web.com,web2.ca`) to unblock hostnames | +| `EXTRA_SUBNETS` | | comma separated subnets allowed in the container firewall (i.e. `192.168.1.0/24,192.168.10.121,10.0.0.5/28`) | +| `PORT_FORWARDING` | `off` | Set to `on` to forward a port on PIA server | +| `PORT_FORWARDING_STATUS_FILE` | `/forwarded_port` | File path to store the forwarded port number | +| `TINYPROXY` | `on` | `on` or `off`, to enable the internal HTTP proxy tinyproxy | +| `TINYPROXY_LOG` | `Info` | `Info`, `Warning`, `Error` or `Critical` | +| `TINYPROXY_PORT` | `8888` | `1024` to `65535` internal port for HTTP proxy | +| `TINYPROXY_USER` | | Username to use to connect to the HTTP proxy | +| `TINYPROXY_PASSWORD` | | Passsword to use to connect to the HTTP proxy | +| `SHADOWSOCKS` | `on` | `on` or `off`, to enable the internal SOCKS5 proxy Shadowsocks | +| `SHADOWSOCKS_LOG` | `on` | `on` or `off` to enable logging for Shadowsocks | +| `SHADOWSOCKS_PORT` | `8388` | `1024` to `65535` internal port for SOCKS5 proxy | +| `SHADOWSOCKS_PASSWORD` | | Passsword to use to connect to the SOCKS5 proxy | +| `TZ` | | Specify a timezone to use i.e. `Europe/London` | + +## Connect to it + +There are various ways to achieve this, depending on your use case. + +-
Connect containers in the same docker-compose.yml as PIA

+ + Add `network_mode: "service:pia"` to your *docker-compose.yml* (no need for `depends_on`) + +

+-
Connect other containers to PIA

+ + Add `--network=container:pia` when launching the container, provided PIA is already running + +

+-
Connect containers from another docker-compose.yml

+ + Add `network_mode: "container:pia"` to your *docker-compose.yml*, provided PIA is already running + +

+-
Connect LAN devices through the built-in HTTP proxy *Tinyproxy* (i.e. with Chrome, Kodi, etc.)

+ + You might want to use Shadowsocks instead which tunnels UDP as well as TCP, whereas Tinyproxy only tunnels TCP. + + 1. Setup a HTTP proxy client, such as [SwitchyOmega for Chrome](https://chrome.google.com/webstore/detail/proxy-switchyomega/padekgcemlokbadohgkifijomclgjgif?hl=en) + 1. Ensure the PIA container is launched with: + - port `8888` published `-p 8888:8888/tcp` + - your LAN subnet, i.e. `192.168.1.0/24`, set as `-e EXTRA_SUBNETS=192.168.1.0/24` + 1. With your HTTP proxy client, connect to the Docker host (i.e. `192.168.1.10`) on port `8888`. You need to enter your credentials if you set them with `TINYPROXY_USER` and `TINYPROXY_PASSWORD`. + 1. If you set `TINYPROXY_LOG` to `Info`, more information will be logged in the Docker logs + +

+-
Connect LAN devices through the built-in SOCKS5 proxy *Shadowsocks* (per app, system wide, etc.)

+ + 1. Setup a SOCKS5 proxy client, there is a list of [ShadowSocks clients for **all platforms**](https://shadowsocks.org/en/download/clients.html) + - **note** some clients do not tunnel UDP so your DNS queries will be done locally and not through PIA and its built in DNS over TLS + - Clients that support such UDP tunneling are, as far as I know: + - iOS: Potatso Lite + - OSX: ShadowsocksX + - Android: Shadowsocks by Max Lv + 1. Ensure the PIA container is launched with: + - port `8388` published `-p 8388:8388/tcp -p 8388:8388/udp` + - your LAN subnet, i.e. `192.168.1.0/24`, set as `-e EXTRA_SUBNETS=192.168.1.0/24` + 1. With your SOCKS5 proxy client + - Enter the Docker host (i.e. `192.168.1.10`) as the server IP + - Enter port TCP (and UDP, if available) `8388` as the server port + - Use the password you have set with `SHADOWSOCKS_PASSWORD` + - Choose the encryption method/algorithm `chacha20-ietf-poly1305` + 1. If you set `SHADOWSOCKS_LOG` to `on`, more information will be logged in the Docker logs + +

+-
Access ports of containers connected to PIA

+ + In example, to access port `8000` of container `xyz` and `9000` of container `abc` connected to PIA, + publish ports `8000` and `9000` for the PIA container and access them as you would with any other container + +

+-
Access ports of containers connected to PIA, all in the same docker-compose.yml

+ + In example, to access port `8000` of container `xyz` and `9000` of container `abc` connected to PIA, publish port `8000` and `9000` for the PIA container. + The docker-compose.yml file would look like: + + ```yml + version: '3.7' + services: + pia: + image: qmcgaw/private-internet-access + container_name: pia + init: true + cap_add: + - NET_ADMIN + devices: + - /dev/net/tun + environment: + - USER=js89ds7 + - PASSWORD=8fd9s239G + ports: + - 8000:8000/tcp + - 9000:9000/tcp + abc: + image: abc + container_name: abc + network_mode: "service:pia" + xyz: + image: xyz + container_name: xyz + network_mode: "service:pia" + ``` + +

+ +## Port forwarding + +By setting `PORT_FORWARDING` environment variable to `on`, the forwarded port will be read and written to the file specified in `PORT_FORWARDING_STATUS_FILE` (by default, this is set to `/forwarded_port`). If the location for this file does not exist, it will be created automatically. + +You can mount this file as a volume to read it from other containers. + +Note that not all regions support port forwarding. + +## For the paranoids + +- You can review the code which consists in: + - [Dockerfile](https://github.com/qdm12/private-internet-access-docker/blob/master/Dockerfile) + - [main.go](https://github.com/qdm12/private-internet-access-docker/blob/master/cmd/main.go), the main code entrypoint + - [internal package](https://github.com/qdm12/private-internet-access-docker/blob/master/internal) + - [github.com/qdm12/golibs](https://github.com/qdm12/golibs) dependency + - [github.com/qdm12/files](https://github.com/qdm12/files) for files downloaded at start (DNS root hints, block lists, etc.) +- Build the image yourself: + + ```bash + docker build -t qmcgaw/private-internet-access https://github.com/qdm12/private-internet-access-docker.git + ``` + +- The download and parsing of all needed files is done at start (openvpn config files, Unbound files, block lists, etc.) +- Use `-e ENCRYPTION=strong -e BLOCK_MALICIOUS=on` +- You can test DNSSEC using [internet.nl/connection](https://www.internet.nl/connection/) +- Check DNS leak tests with [https://www.dnsleaktest.com](https://www.dnsleaktest.com) +- DNS Leaks tests might not work because of [this](https://github.com/qdm12/cloudflare-dns-server#verify-dns-connection) (*TLDR*: DNS server is a local caching intermediary) + +## Troubleshooting + +- Fallback to a previous version + 1. Clone the repository on your machine + + ```sh + git clone https://github.com/qdm12/private-internet-access-docker.git pia + cd pia + ``` + + 1. Look up which commit you want to go back to [here](https://github.com/qdm12/private-internet-access-docker/commits/master), i.e. `942cc7d4d10545b6f5f89c907b7dd1dbc39368e0` + 1. Revert to this commit locally + + ```sh + git reset --hard 942cc7d4d10545b6f5f89c907b7dd1dbc39368e0 + ``` + + 1. Build the Docker image + + ```sh + docker build -t qmcgaw/private-internet-access . + ``` + +## Development + +### Using VSCode and Docker + +1. Install [Docker](https://docs.docker.com/install) + - On Windows, share a drive with Docker Desktop and have the project on that partition +1. With [Visual Studio Code](https://code.visualstudio.com/download), install the [remote containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) +1. In Visual Studio Code, press on `F1` and select `Remote-Containers: Open Folder in Container...` +1. Your dev environment is ready to go!... and it's running in a container :+1: + +## TODOs + +- Healthcheck checking for IP address, DNS leaks etc. +- Periodic update of malicious block lists with Unbound restart +- Support other VPN providers + - Mullvad + - Windscribe +- Support for other VPN protocols + - Wireguard (wireguard-go) +- Show new versions/commits at start +- Colors & emojis + - Setup + - Logging streams +- More unit tests +- Switch to iptables-go instead of using the shell iptables + +## License + +This repository is under an [MIT license](https://github.com/qdm12/private-internet-access-docker/master/license) diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 00000000..82bd2a75 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,150 @@ +package main + +import ( + "context" + "fmt" + "time" + + "github.com/qdm12/golibs/command" + "github.com/qdm12/golibs/files" + "github.com/qdm12/golibs/logging" + "github.com/qdm12/golibs/network" + "github.com/qdm12/private-internet-access-docker/internal/constants" + "github.com/qdm12/private-internet-access-docker/internal/dns" + "github.com/qdm12/private-internet-access-docker/internal/env" + "github.com/qdm12/private-internet-access-docker/internal/firewall" + "github.com/qdm12/private-internet-access-docker/internal/openvpn" + "github.com/qdm12/private-internet-access-docker/internal/params" + "github.com/qdm12/private-internet-access-docker/internal/pia" + "github.com/qdm12/private-internet-access-docker/internal/settings" + "github.com/qdm12/private-internet-access-docker/internal/shadowsocks" + "github.com/qdm12/private-internet-access-docker/internal/splash" + "github.com/qdm12/private-internet-access-docker/internal/tinyproxy" +) + +const ( + uid, gid = 1000, 1000 +) + +func main() { + logger, err := logging.NewLogger(logging.ConsoleEncoding, logging.InfoLevel, -1) + if err != nil { + panic(err) + } + paramsReader := params.NewParamsReader(logger) + fmt.Println(splash.Splash(paramsReader)) + e := env.New(logger) + client := network.NewClient(3 * time.Second) + // Create configurators + fileManager := files.NewFileManager() + ovpnConf := openvpn.NewConfigurator(logger, fileManager) + dnsConf := dns.NewConfigurator(logger, client, fileManager) + firewallConf := firewall.NewConfigurator(logger, fileManager) + piaConf := pia.NewConfigurator(client, fileManager, firewallConf, logger) + tinyProxyConf := tinyproxy.NewConfigurator(fileManager, logger) + shadowsocksConf := shadowsocks.NewConfigurator(fileManager, logger) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + streamMerger := command.NewStreamMerger(ctx) + + e.PrintVersion("OpenVPN", ovpnConf.Version) + e.PrintVersion("Unbound", dnsConf.Version) + e.PrintVersion("IPtables", firewallConf.Version) + e.PrintVersion("TinyProxy", tinyProxyConf.Version) + e.PrintVersion("ShadowSocks", shadowsocksConf.Version) + + allSettings, err := settings.GetAllSettings(paramsReader) + e.FatalOnError(err) + logger.Info(allSettings.String()) + + err = ovpnConf.CheckTUN() + e.FatalOnError(err) + + err = ovpnConf.WriteAuthFile(allSettings.PIA.User, allSettings.PIA.Password, uid, gid) + e.FatalOnError(err) + + // Temporarily reset chain policies allowing Kubernetes sidecar to + // successfully restart the container. Without this, the existing rules will + // pre-exist, preventing the nslookup of the PIA region address. These will + // simply be redundant at Docker runtime as they will already be set this way + // Thanks to @npawelek https://github.com/npawelek + err = firewallConf.AcceptAll() + e.FatalOnError(err) + + if allSettings.DNS.Enabled { + err = dnsConf.DownloadRootHints(uid, gid) + e.FatalOnError(err) + err = dnsConf.DownloadRootKey(uid, gid) + e.FatalOnError(err) + err = dnsConf.MakeUnboundConf(allSettings.DNS, uid, gid) + e.FatalOnError(err) + stream, err := dnsConf.Start(allSettings.DNS.VerbosityDetailsLevel) + e.FatalOnError(err) + go streamMerger.Merge("unbound", stream) + err = dnsConf.SetLocalNameserver() + e.FatalOnError(err) + } + + lines, err := piaConf.DownloadOvpnConfig(allSettings.PIA.Encryption, allSettings.OpenVPN.NetworkProtocol, allSettings.PIA.Region) + e.FatalOnError(err) + VPNIPs, port, VPNDevice, err := piaConf.ParseConfig(lines) + e.FatalOnError(err) + lines = piaConf.ModifyLines(lines, VPNIPs, port) + fileManager.WriteLinesToFile(string(constants.OpenVPNConf), lines) + e.FatalOnError(err) + + defaultInterface, defaultGateway, defaultSubnet, err := firewallConf.GetDefaultRoute() + e.FatalOnError(err) + err = firewallConf.AddRoutesVia(allSettings.Firewall.AllowedSubnets, defaultGateway, defaultInterface) + e.FatalOnError(err) + err = firewallConf.Clear() + e.FatalOnError(err) + err = firewallConf.BlockAll() + e.FatalOnError(err) + err = firewallConf.CreateGeneralRules() + e.FatalOnError(err) + err = firewallConf.CreateVPNRules(VPNDevice, VPNIPs, defaultInterface, port, allSettings.OpenVPN.NetworkProtocol) + e.FatalOnError(err) + err = firewallConf.CreateLocalSubnetsRules(defaultSubnet, allSettings.Firewall.AllowedSubnets, defaultInterface) + e.FatalOnError(err) + + if allSettings.TinyProxy.Enabled { + err = tinyProxyConf.MakeConf(allSettings.TinyProxy.LogLevel, allSettings.ShadowSocks.Port, allSettings.TinyProxy.User, allSettings.TinyProxy.Password, uid, gid) + e.FatalOnError(err) + stream, err := tinyProxyConf.Start() + e.FatalOnError(err) + go streamMerger.Merge("tinyproxy", stream) + } + + if allSettings.ShadowSocks.Enabled { + err = shadowsocksConf.MakeConf(allSettings.ShadowSocks.Port, allSettings.TinyProxy.Password, uid, gid) + e.FatalOnError(err) + stream, err := shadowsocksConf.Start("0.0.0.0", allSettings.ShadowSocks.Port, allSettings.ShadowSocks.Password, allSettings.ShadowSocks.Log) + e.FatalOnError(err) + go streamMerger.Merge("shadowsocks", stream) + } + + if allSettings.PIA.PortForwarding.Enabled { + time.AfterFunc(10*time.Second, func() { + port, err := piaConf.GetPortForward() + if err != nil { + logger.Error("port forwarding:", err) + } + if err := piaConf.WritePortForward(allSettings.PIA.PortForwarding.Filepath, port); err != nil { + logger.Error("port forwarding:", err) + } + if err := piaConf.AllowPortForwardFirewall(VPNDevice, port); err != nil { + logger.Error("port forwarding:", err) + } + }) + } + + stream, err := ovpnConf.Start() + e.FatalOnError(err) + go streamMerger.Merge("openvpn", stream) + + // Blocking line merging reader for all programs: openvpn, tinyproxy, unbound and shadowsocks + logger.Info("Launching standard output merger") + err = streamMerger.CollectLines(func(line string) { logger.Info(line) }) + e.FatalOnError(err) +} diff --git a/docker-compose.yml b/docker-compose.yml index ef176fae..f0fa9174 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,18 +21,19 @@ services: - ENCRYPTION=strong - PROTOCOL=udp - REGION=CA Montreal - - NONROOT=no - DOT=on + - DOT_PROVIDERS=cloudflare - BLOCK_MALICIOUS=on - - BLOCK_NSA=off + - BLOCK_SURVEILLANCE=off + - BLOCK_ADS=off - UNBLOCK= - - FIREWALL=on - EXTRA_SUBNETS= - - TINYPROXY=on - - TINYPROXY_LOG=Critical + - TINYPROXY=off + - TINYPROXY_LOG=Info - TINYPROXY_USER= - TINYPROXY_PASSWORD= - - SHADOWSOCKS=on + - SHADOWSOCKS=off - SHADOWSOCKS_LOG=on + - SHADOWSOCKS_PORT=8388 - SHADOWSOCKS_PASSWORD= restart: always diff --git a/entrypoint.sh b/entrypoint.sh deleted file mode 100644 index 7d6e7509..00000000 --- a/entrypoint.sh +++ /dev/null @@ -1,492 +0,0 @@ -#!/bin/sh - -exitOnError(){ - # $1 must be set to $? - status=$1 - message=$2 - [ "$message" != "" ] || message="Undefined error" - if [ $status != 0 ]; then - printf "[ERROR] $message, with status $status\n" - exit $status - fi -} - -exitIfUnset(){ - # $1 is the name of the variable to check - not the variable itself - var="$(eval echo "\$$1")" - if [ -z "$var" ]; then - printf "[ERROR] Environment variable $1 is not set\n" - exit 1 - fi -} - -exitIfNotIn(){ - # $1 is the name of the variable to check - not the variable itself - # $2 is a string of comma separated possible values - var="$(eval echo "\$$1")" - for value in ${2//,/ } - do - if [ "$var" = "$value" ]; then - return 0 - fi - done - printf "[ERROR] Environment variable $1 cannot be '$var' and must be one of the following: " - for value in ${2//,/ } - do - printf "$value " - done - printf "\n" - exit 1 -} - -printf " =========================================\n" -printf " =========================================\n" -printf " ============= PIA CONTAINER =============\n" -printf " =========================================\n" -printf " =========================================\n" -printf " == by github.com/qdm12 - Quentin McGaw ==\n\n" - -printf "OpenVPN version: $(openvpn --version | head -n 1 | grep -oE "OpenVPN [0-9\.]* " | cut -d" " -f2)\n" -printf "Unbound version: $(unbound -h | grep "Version" | cut -d" " -f2)\n" -printf "Iptables version: $(iptables --version | cut -d" " -f2)\n" -printf "TinyProxy version: $(tinyproxy -v | cut -d" " -f2)\n" -printf "ShadowSocks version: $(ss-server --help | head -n 2 | tail -n 1 | cut -d" " -f 2)\n" - -############################################ -# BACKWARD COMPATIBILITY PARAMETERS -############################################ -[ "$PORT_FORWARDING" == "false" ] && PORT_FORWARDING=on -[ "$PORT_FORWARDING" == "true" ] && PORT_FORWARDING=off -if [ -z $TINYPROXY ] && [ ! -z $PROXY ]; then - TINYPROXY=$PROXY -fi -if [ -z $TINYPROXY_LOG ] && [ ! -z $PROXY_LOG_LEVEL ]; then - TINYPROXY_LOG=$PROXY_LOG_LEVEL -fi -if [ -z $TINYPROXY_PORT ] && [ ! -z $PROXY_PORT ]; then - TINYPROXY_PORT=$PROXY_PORT -fi -if [ -z $TINYPROXY_USER ] && [ ! -z $PROXY_USER ]; then - TINYPROXY_USER=$PROXY_USER -fi -if [ -z $TINYPROXY_PASSWORD ] && [ ! -z $PROXY_PASSWORD ]; then - TINYPROXY_PASSWORD=$PROXY_PASSWORD -fi - -############################################ -# CHECK PARAMETERS -############################################ -exitIfUnset USER -exitIfUnset PASSWORD -exitIfNotIn ENCRYPTION "normal,strong" -exitIfNotIn PROTOCOL "tcp,udp" -exitIfNotIn NONROOT "yes,no" -cat "/openvpn/$PROTOCOL-$ENCRYPTION/$REGION.ovpn" &> /dev/null -exitOnError $? "/openvpn/$PROTOCOL-$ENCRYPTION/$REGION.ovpn is not accessible" -for EXTRA_SUBNET in ${EXTRA_SUBNETS//,/ }; do - if [ $(echo "$EXTRA_SUBNET" | grep -Eo '^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(/([0-2]?[0-9])|([3]?[0-1]))?$') = "" ]; then - printf "Extra subnet $EXTRA_SUBNET is not a valid IPv4 subnet of the form 255.255.255.255/31 or 255.255.255.255\n" - exit 1 - fi -done -exitIfNotIn DOT "on,off" -exitIfNotIn BLOCK_MALICIOUS "on,off" -exitIfNotIn BLOCK_NSA "on,off" -if [ "$DOT" == "off" ]; then - if [ "$BLOCK_MALICIOUS" == "on" ]; then - printf "DOT is off so BLOCK_MALICIOUS cannot be on\n" - exit 1 - elif [ "$BLOCK_NSA" == "on" ]; then - printf "DOT is off so BLOCK_NSA cannot be on\n" - exit 1 - fi -fi -exitIfNotIn PORT_FORWARDING "on,off" -if [ "$PORT_FORWARDING" == "on" ] && [ -z "$PORT_FORWARDING_STATUS_FILE" ]; then - printf "PORT_FORWARDING is on but PORT_FORWARDING_STATUS_FILE is not set\n" - exit 1 -fi -exitIfNotIn TINYPROXY "on,off" -if [ "$TINYPROXY" == "on" ]; then - exitIfNotIn TINYPROXY_LOG "Info,Warning,Error,Critical" - if [ -z $TINYPROXY_PORT ]; then - TINYPROXY_PORT=8888 - fi - if [ `echo $TINYPROXY_PORT | grep -E "^[0-9]+$"` != $TINYPROXY_PORT ]; then - printf "TINYPROXY_PORT is not a valid number\n" - exit 1 - elif [ $TINYPROXY_PORT -lt 1024 ]; then - printf "TINYPROXY_PORT cannot be a privileged port under port 1024\n" - exit 1 - elif [ $TINYPROXY_PORT -gt 65535 ]; then - printf "TINYPROXY_PORT cannot be a port higher than the maximum port 65535\n" - exit 1 - fi - if [ ! -z "$TINYPROXY_USER" ] && [ -z "$TINYPROXY_PASSWORD" ]; then - printf "TINYPROXY_USER is set but TINYPROXY_PASSWORD is not set\n" - exit 1 - elif [ -z "$TINYPROXY_USER" ] && [ ! -z "$TINYPROXY_PASSWORD" ]; then - printf "TINYPROXY_USER is not set but TINYPROXY_PASSWORD is set\n" - exit 1 - fi -fi -exitIfNotIn SHADOWSOCKS "on,off" -if [ "$SHADOWSOCKS" == "on" ]; then - exitIfNotIn SHADOWSOCKS "on,off" - if [ -z $SHADOWSOCKS_PORT ]; then - SHADOWSOCKS_PORT=8388 - fi - if [ `echo $SHADOWSOCKS_PORT | grep -E "^[0-9]+$"` != $SHADOWSOCKS_PORT ]; then - printf "SHADOWSOCKS_PORT is not a valid number\n" - exit 1 - elif [ $SHADOWSOCKS_PORT -lt 1024 ]; then - printf "SHADOWSOCKS_PORT cannot be a privileged port under port 1024\n" - exit 1 - elif [ $SHADOWSOCKS_PORT -gt 65535 ]; then - printf "SHADOWSOCKS_PORT cannot be a port higher than the maximum port 65535\n" - exit 1 - fi - if [ -z $SHADOWSOCKS_PASSWORD ]; then - printf "SHADOWSOCKS_PASSWORD is not set\n" - exit 1 - fi -fi - -############################################ -# SHOW PARAMETERS -############################################ -printf "\n" -printf "OpenVPN parameters:\n" -printf " * Region: $REGION\n" -printf " * Encryption: $ENCRYPTION\n" -printf " * Protocol: $PROTOCOL\n" -printf " * Running without root: $NONROOT\n" -printf "DNS over TLS:\n" -printf " * Activated: $DOT\n" -if [ "$DOT" = "on" ]; then - printf " * Malicious hostnames DNS blocking: $BLOCK_MALICIOUS\n" - printf " * NSA related DNS blocking: $BLOCK_NSA\n" - printf " * Unblocked hostnames: $UNBLOCK\n" -fi -printf "Local network parameters:\n" -printf " * Extra subnets: $EXTRA_SUBNETS\n" -printf " * Tinyproxy HTTP proxy: $TINYPROXY\n" -if [ "$TINYPROXY" == "on" ]; then - printf " * Tinyproxy port: $TINYPROXY_PORT\n" - tinyproxy_auth=yes - if [ -z $TINYPROXY_USER ]; then - tinyproxy_auth=no - fi - printf " * Tinyproxy has authentication: $tinyproxy_auth\n" - unset -v tinyproxy_auth -fi -printf " * ShadowSocks SOCKS5 proxy: $SHADOWSOCKS\n" -printf "PIA parameters:\n" -printf " * Remote port forwarding: $PORT_FORWARDING\n" -[ "$PORT_FORWARDING" == "on" ] && printf " * Remote port forwarding status file: $PORT_FORWARDING_STATUS_FILE\n" -printf "\n" - -##################################################### -# Writes to protected file and remove USER, PASSWORD -##################################################### -if [ -f /auth.conf ]; then - printf "[INFO] /auth.conf already exists\n" -else - printf "[INFO] Writing USER and PASSWORD to protected file /auth.conf..." - echo "$USER" > /auth.conf - exitOnError $? - echo "$PASSWORD" >> /auth.conf - exitOnError $? - chown nonrootuser /auth.conf - exitOnError $? - chmod 400 /auth.conf - exitOnError $? - printf "DONE\n" - printf "[INFO] Clearing environment variables USER and PASSWORD..." - unset -v USER - unset -v PASSWORD - printf "DONE\n" -fi - -############################################ -# CHECK FOR TUN DEVICE -############################################ -if [ "$(cat /dev/net/tun 2>&1 /dev/null)" != "cat: read error: File descriptor in bad state" ]; then - printf "[WARNING] TUN device is not available, creating it..." - mkdir -p /dev/net - mknod /dev/net/tun c 10 200 - exitOnError $? - chmod 0666 /dev/net/tun - printf "DONE\n" -fi - -############################################ -# BLOCKING MALICIOUS HOSTNAMES AND IPs WITH UNBOUND -############################################ -if [ "$DOT" == "on" ]; then - rm -f /etc/unbound/blocks-malicious.conf - if [ "$BLOCK_MALICIOUS" = "on" ]; then - tar -xjf /etc/unbound/blocks-malicious.bz2 -C /etc/unbound/ - printf "[INFO] $(cat /etc/unbound/blocks-malicious.conf | grep "local-zone" | wc -l ) malicious hostnames and $(cat /etc/unbound/blocks-malicious.conf | grep "private-address" | wc -l) malicious IP addresses blacklisted\n" - else - echo "" > /etc/unbound/blocks-malicious.conf - fi - if [ "$BLOCK_NSA" = "on" ]; then - tar -xjf /etc/unbound/blocks-nsa.bz2 -C /etc/unbound/ - printf "[INFO] $(cat /etc/unbound/blocks-nsa.conf | grep "local-zone" | wc -l ) NSA hostnames blacklisted\n" - cat /etc/unbound/blocks-nsa.conf >> /etc/unbound/blocks-malicious.conf - rm /etc/unbound/blocks-nsa.conf - sort -u -o /etc/unbound/blocks-malicious.conf /etc/unbound/blocks-malicious.conf - fi - for hostname in ${UNBLOCK//,/ } - do - printf "[INFO] Unblocking hostname $hostname\n" - sed -i "/$hostname/d" /etc/unbound/blocks-malicious.conf - done -fi - -############################################ -# SETTING DNS OVER TLS TO 1.1.1.1 / 1.0.0.1 -############################################ -if [ "$DOT" == "on" ]; then - printf "[INFO] Launching Unbound to connect to Cloudflare DNS 1.1.1.1 over TLS..." - unbound - exitOnError $? - printf "DONE\n" - printf "[INFO] Changing DNS to localhost..." - printf "`sed '/^nameserver /d' /etc/resolv.conf`\nnameserver 127.0.0.1\n" > /etc/resolv.conf - exitOnError $? - printf "DONE\n" -fi - -############################################ -# Reading chosen OpenVPN configuration -############################################ -printf "[INFO] Reading OpenVPN configuration...\n" -CONNECTIONSTRING=$(grep -i "/openvpn/$PROTOCOL-$ENCRYPTION/$REGION.ovpn" -e 'privateinternetaccess.com') -exitOnError $? -PORT=$(echo $CONNECTIONSTRING | cut -d' ' -f3) -if [ "$PORT" = "" ]; then - printf "[ERROR] Port not found in /openvpn/$PROTOCOL-$ENCRYPTION/$REGION.ovpn\n" - exit 1 -fi -PIADOMAIN=$(echo $CONNECTIONSTRING | cut -d' ' -f2) -if [ "$PIADOMAIN" = "" ]; then - printf "[ERROR] Domain not found in /openvpn/$PROTOCOL-$ENCRYPTION/$REGION.ovpn\n" - exit 1 -fi -printf " * Port: $PORT\n" -printf " * Domain: $PIADOMAIN\n" -printf "[INFO] Detecting IP addresses corresponding to $PIADOMAIN...\n" -VPNIPS=$(nslookup $PIADOMAIN localhost | tail -n +3 | grep -o '[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}') -exitOnError $? -for ip in $VPNIPS; do - printf " $ip\n"; -done - -############################################ -# Writing target OpenVPN files -############################################ -TARGET_PATH="/openvpn/target" -printf "[INFO] Creating target OpenVPN files in $TARGET_PATH..." -rm -rf $TARGET_PATH/* -cd "/openvpn/$PROTOCOL-$ENCRYPTION" -cp -f *.crt "$TARGET_PATH" -exitOnError $? "Cannot copy crt file to $TARGET_PATH" -cp -f *.pem "$TARGET_PATH" -exitOnError $? "Cannot copy pem file to $TARGET_PATH" -cp -f "$REGION.ovpn" "$TARGET_PATH/config.ovpn" -exitOnError $? "Cannot copy $REGION.ovpn file to $TARGET_PATH" -sed -i "/$CONNECTIONSTRING/d" "$TARGET_PATH/config.ovpn" -exitOnError $? "Cannot delete '$CONNECTIONSTRING' from $TARGET_PATH/config.ovpn" -sed -i '/resolv-retry/d' "$TARGET_PATH/config.ovpn" -exitOnError $? "Cannot delete 'resolv-retry' from $TARGET_PATH/config.ovpn" -for ip in $VPNIPS; do - echo "remote $ip $PORT" >> "$TARGET_PATH/config.ovpn" - exitOnError $? "Cannot add 'remote $ip $PORT' to $TARGET_PATH/config.ovpn" -done -# Uses the username/password from this file to get the token from PIA -echo "auth-user-pass /auth.conf" >> "$TARGET_PATH/config.ovpn" -exitOnError $? "Cannot add 'auth-user-pass /auth.conf' to $TARGET_PATH/config.ovpn" -# Reconnects automatically on failure -echo "auth-retry nointeract" >> "$TARGET_PATH/config.ovpn" -exitOnError $? "Cannot add 'auth-retry nointeract' to $TARGET_PATH/config.ovpn" -# Prevents auth_failed infinite loops - make it interact? Remove persist-tun? nobind? -echo "pull-filter ignore \"auth-token\"" >> "$TARGET_PATH/config.ovpn" -exitOnError $? "Cannot add 'pull-filter ignore \"auth-token\"' to $TARGET_PATH/config.ovpn" -# Runs openvpn without root, as nonrootuser if specified -if [ "$NONROOT" = "yes" ]; then - echo "user nonrootuser" >> "$TARGET_PATH/config.ovpn" - exitOnError $? "Cannot add 'user nonrootuser' to $TARGET_PATH/config.ovpn" -fi -echo "mute-replay-warnings" >> "$TARGET_PATH/config.ovpn" -exitOnError $? "Cannot add 'mute-replay-warnings' to $TARGET_PATH/config.ovpn" -# Note: TUN device re-opening will restart the container due to permissions -printf "DONE\n" - -############################################ -# NETWORKING -############################################ -printf "[INFO] Finding network properties...\n" -printf " * Detecting default gateway..." -DEFAULT_GATEWAY=$(ip r | grep 'default via' | cut -d" " -f 3) -exitOnError $? -printf "$DEFAULT_GATEWAY\n" -printf " * Detecting local interface..." -INTERFACE=$(ip r | grep 'default via' | cut -d" " -f 5) -exitOnError $? -printf "$INTERFACE\n" -printf " * Detecting local subnet..." -SUBNET=$(ip r | grep -v 'default via' | grep $INTERFACE | tail -n 1 | cut -d" " -f 1) -exitOnError $? -printf "$SUBNET\n" -for EXTRASUBNET in ${EXTRA_SUBNETS//,/ } -do - printf " * Adding $EXTRASUBNET as route via $INTERFACE..." - ip route add $EXTRASUBNET via $DEFAULT_GATEWAY dev $INTERFACE - exitOnError $? - printf "DONE\n" -done -printf " * Detecting target VPN interface..." -VPN_DEVICE=$(cat $TARGET_PATH/config.ovpn | grep 'dev ' | cut -d" " -f 2)0 -exitOnError $? -printf "$VPN_DEVICE\n" - - -############################################ -# FIREWALL -############################################ -printf "[INFO] Setting firewall\n" -printf " * Blocking everyting\n" -printf " * Deleting all iptables rules..." -iptables --flush -exitOnError $? -iptables --delete-chain -exitOnError $? -iptables -t nat --flush -exitOnError $? -iptables -t nat --delete-chain -exitOnError $? -printf "DONE\n" -printf " * Block input traffic..." -iptables -P INPUT DROP -exitOnError $? -printf "DONE\n" -printf " * Block output traffic..." -iptables -F OUTPUT -exitOnError $? -iptables -P OUTPUT DROP -exitOnError $? -printf "DONE\n" -printf " * Block forward traffic..." -iptables -P FORWARD DROP -exitOnError $? -printf "DONE\n" - -printf " * Creating general rules\n" -printf " * Accept established and related input and output traffic..." -iptables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT -exitOnError $? -iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT -exitOnError $? -printf "DONE\n" -printf " * Accept local loopback input and output traffic..." -iptables -A OUTPUT -o lo -j ACCEPT -exitOnError $? -iptables -A INPUT -i lo -j ACCEPT -exitOnError $? -printf "DONE\n" - -printf " * Creating VPN rules\n" -for ip in $VPNIPS; do - printf " * Accept output traffic to VPN server $ip through $INTERFACE, port $PROTOCOL $PORT..." - iptables -A OUTPUT -d $ip -o $INTERFACE -p $PROTOCOL -m $PROTOCOL --dport $PORT -j ACCEPT - exitOnError $? - printf "DONE\n" -done -printf " * Accept all output traffic through $VPN_DEVICE..." -iptables -A OUTPUT -o $VPN_DEVICE -j ACCEPT -exitOnError $? -printf "DONE\n" - -printf " * Creating local subnet rules\n" -printf " * Accept input and output traffic to and from $SUBNET..." -iptables -A INPUT -s $SUBNET -d $SUBNET -j ACCEPT -iptables -A OUTPUT -s $SUBNET -d $SUBNET -j ACCEPT -printf "DONE\n" -for EXTRASUBNET in ${EXTRA_SUBNETS//,/ } -do - printf " * Accept input traffic through $INTERFACE from $EXTRASUBNET to $SUBNET..." - iptables -A INPUT -i $INTERFACE -s $EXTRASUBNET -d $SUBNET -j ACCEPT - exitOnError $? - printf "DONE\n" - # iptables -A OUTPUT -d $EXTRASUBNET -j ACCEPT - # iptables -A OUTPUT -o $INTERFACE -s $SUBNET -d $EXTRASUBNET -j ACCEPT -done - -############################################ -# TINYPROXY LAUNCH -############################################ -if [ "$TINYPROXY" == "on" ]; then - printf "[INFO] Setting TinyProxy log level to $TINYPROXY_LOG..." - sed -i "/LogLevel /c\LogLevel $TINYPROXY_LOG" /etc/tinyproxy/tinyproxy.conf - exitOnError $? - printf "DONE\n" - printf "[INFO] Setting TinyProxy port to $TINYPROXY_PORT..." - sed -i "/Port /c\Port $TINYPROXY_PORT" /etc/tinyproxy/tinyproxy.conf - exitOnError $? - printf "DONE\n" - if [ ! -z "$TINYPROXY_USER" ]; then - printf "[INFO] Setting TinyProxy credentials..." - echo "BasicAuth $TINYPROXY_USER $TINYPROXY_PASSWORD" >> /etc/tinyproxy/tinyproxy.conf - unset -v TINYPROXY_USER - unset -v TINYPROXY_PASSWORD - printf "DONE\n" - fi - tinyproxy -d & -fi - -############################################ -# SHADOWSOCKS -############################################ -if [ "$SHADOWSOCKS" == "on" ]; then - ARGS="-c /etc/shadowsocks.json" - if [ "$SHADOWSOCKS_LOG" == " on" ]; then - printf "[INFO] Setting ShadowSocks logging..." - ARGS="$ARGS -v" - printf "DONE\n" - fi - printf "[INFO] Setting ShadowSocks port to $SHADOWSOCKS_PORT..." - jq ".port_password = {\"$SHADOWSOCKS_PORT\":\"\"}" /etc/shadowsocks.json > /tmp/shadowsocks.json && mv /tmp/shadowsocks.json /etc/shadowsocks.json - exitOnError $? - printf "DONE\n" - printf "[INFO] Setting ShadowSocks password..." - jq ".port_password[\"$SHADOWSOCKS_PORT\"] = \"$SHADOWSOCKS_PASSWORD\"" /etc/shadowsocks.json > /tmp/shadowsocks.json && mv /tmp/shadowsocks.json /etc/shadowsocks.json - exitOnError $? - printf "DONE\n" - ARGS="$ARGS -s `jq --raw-output '.server' /etc/shadowsocks.json`" - unset -v SERVER - ARGS="$ARGS -p $SHADOWSOCKS_PORT" - ARGS="$ARGS -k $SHADOWSOCKS_PASSWORD" - ss-server $ARGS & - unset -v ARGS -fi - -############################################ -# READ FORWARDED PORT -############################################ - -if [ "$PORT_FORWARDING" == "on" ]; then - sleep 10 && /portforward.sh & -fi - -############################################ -# OPENVPN LAUNCH -############################################ -printf "[INFO] Launching OpenVPN\n" -cd "$TARGET_PATH" -openvpn --config config.ovpn "$@" -status=$? -printf "\n =========================================\n" -printf " OpenVPN exit with status $status\n" -printf " =========================================\n\n" diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..4f4e51fa --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module github.com/qdm12/private-internet-access-docker + +go 1.13 + +require ( + github.com/kyokomi/emoji v2.1.0+incompatible + github.com/qdm12/golibs v0.0.0-20200206031633-f9e9b1bea1db + github.com/stretchr/testify v1.4.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..4ac930c9 --- /dev/null +++ b/go.sum @@ -0,0 +1,93 @@ +github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/asaskevich/govalidator v0.0.0-20180720115003-f9ffefc3facf/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/docker/go-units v0.3.3/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/globalsign/mgo v0.0.0-20180905125535-1ca0a4f7cbcb/go.mod h1:xkRDCp4j0OGD1HRkm4kmhM+pmpv3AKq5SU7GMg4oO/Q= +github.com/go-openapi/analysis v0.0.0-20180825180245-b006789cd277/go.mod h1:k70tL6pCuVxPJOHXQ+wIac1FUrvNkHolPie/cLEU6hI= +github.com/go-openapi/analysis v0.17.0/go.mod h1:IowGgpVeD0vNm45So8nr+IcQ3pxVtpRoBWb8PVZO0ik= +github.com/go-openapi/errors v0.17.0/go.mod h1:LcZQpmvG4wyF5j4IhA73wkLFQg+QJXOQHVjmcZxhka0= +github.com/go-openapi/errors v0.17.2/go.mod h1:LcZQpmvG4wyF5j4IhA73wkLFQg+QJXOQHVjmcZxhka0= +github.com/go-openapi/jsonpointer v0.17.0/go.mod h1:cOnomiV+CVVwFLk0A/MExoFMjwdsUdVpsRhURCKh+3M= +github.com/go-openapi/jsonreference v0.17.0/go.mod h1:g4xxGn04lDIRh0GJb5QlpE3HfopLOL6uZrK/VgnsK9I= +github.com/go-openapi/loads v0.17.0/go.mod h1:72tmFy5wsWx89uEVddd0RjRWPZm92WRLhf7AC+0+OOU= +github.com/go-openapi/runtime v0.0.0-20180920151709-4f900dc2ade9/go.mod h1:6v9a6LTXWQCdL8k1AO3cvqx5OtZY/Y9wKTgaoP6YRfA= +github.com/go-openapi/runtime v0.17.2/go.mod h1:QO936ZXeisByFmZEO1IS1Dqhtf4QV1sYYFtIq6Ld86Q= +github.com/go-openapi/spec v0.17.0/go.mod h1:XkF/MOi14NmjsfZ8VtAKf8pIlbZzyoTvZsdfssdxcBI= +github.com/go-openapi/strfmt v0.17.0/go.mod h1:P82hnJI0CXkErkXi8IKjPbNBM6lV6+5pLP5l494TcyU= +github.com/go-openapi/swag v0.17.0/go.mod h1:AByQ+nYG6gQg71GINrmuDXCPWdL640yX49/kXLo40Tg= +github.com/go-openapi/validate v0.17.0/go.mod h1:Uh4HdOzKt19xGIGm1qHf/ofbX1YQ4Y+MYsct2VUrAJ4= +github.com/gomodule/redigo v2.0.0+incompatible/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gotify/go-api-client/v2 v2.0.4/go.mod h1:VKiah/UK20bXsr0JObE1eBVLW44zbBouzjuri9iwjFU= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kyokomi/emoji v2.1.0+incompatible h1:+DYU2RgpI6OHG4oQkM5KlqD3Wd3UPEsX8jamTo1Mp6o= +github.com/kyokomi/emoji v2.1.0+incompatible/go.mod h1:mZ6aGCD7yk8j6QY6KICwnZ2pxoszVseX1DNoGtU2tBA= +github.com/mailru/easyjson v0.0.0-20180823135443-60711f1a8329/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mr-tron/base58 v1.1.3 h1:v+sk57XuaCKGXpWtVBX8YJzO7hMGx4Aajh4TQbdEFdc= +github.com/mr-tron/base58 v1.1.3/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= +github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= +github.com/phayes/permbits v0.0.0-20190612203442-39d7c581d2ee h1:P6U24L02WMfj9ymZTxl7CxS73JC99x3ukk+DBkgQGQs= +github.com/phayes/permbits v0.0.0-20190612203442-39d7c581d2ee/go.mod h1:3uODdxMgOaPYeWU7RzZLxVtJHZ/x1f/iHkBZuKJDzuY= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/qdm12/golibs v0.0.0-20200206031633-f9e9b1bea1db h1:Q3bR2GWwvDxQ0EqLKUupzHswsBsEi3eMWxAPfvzKTbM= +github.com/qdm12/golibs v0.0.0-20200206031633-f9e9b1bea1db/go.mod h1:YULaFjj6VGmhjak6f35sUWwEleHUmngN5IQ3kdvd6XE= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +go.uber.org/atomic v1.5.0 h1:OI5t8sDa1Or+q8AeE+yKeB/SDYioSHAgcVljj9JIETY= +go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/multierr v1.3.0 h1:sFPn2GLc3poCkfrpIXGhBD2X0CMIo4Q/zSULXrj/+uc= +go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee h1:0mgffUl7nfd+FpvXMVz4IDEaUSmT1ysygQC7qYo7sG4= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= +go.uber.org/zap v1.13.0 h1:nR6NoDBgAf67s68NhaXbsojM+2gxp3S1hWkHDl27pVU= +go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200117160349-530e935923ad/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/net v0.0.0-20181005035420-146acd28ed58/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5 h1:hKsoRgsbwY1NafxrwTs+k64bikrLBkAgPir1TNCj3Zs= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= diff --git a/healthcheck.sh b/healthcheck.sh deleted file mode 100644 index 4f384e84..00000000 --- a/healthcheck.sh +++ /dev/null @@ -1,6 +0,0 @@ -#!/bin/sh - -out="$(ping -W 3 -c 1 -q -s 8 1.1.1.1)" -[ $? != 0 ] || exit 0 -printf "$out" -exit 1 \ No newline at end of file diff --git a/internal/constants/dns.go b/internal/constants/dns.go new file mode 100644 index 00000000..61f25d74 --- /dev/null +++ b/internal/constants/dns.go @@ -0,0 +1,63 @@ +package constants + +import ( + "github.com/qdm12/private-internet-access-docker/internal/models" +) + +const ( + // Cloudflare is a DNS over TLS provider + Cloudflare models.DNSProvider = "cloudflare" + // Google is a DNS over TLS provider + Google models.DNSProvider = "google" + // Quad9 is a DNS over TLS provider + Quad9 models.DNSProvider = "quad9" + // Quadrant is a DNS over TLS provider + Quadrant models.DNSProvider = "quadrant" + // CleanBrowsing is a DNS over TLS provider + CleanBrowsing models.DNSProvider = "cleanbrowsing" + // SecureDNS is a DNS over TLS provider + SecureDNS models.DNSProvider = "securedns" + // LibreDNS is a DNS over TLS provider + LibreDNS models.DNSProvider = "libredns" +) + +const ( + CloudflareAddress1 models.DNSForwardAddress = "1.1.1.1@853#cloudflare-dns.com" + CloudflareAddress2 models.DNSForwardAddress = "1.0.0.1@853#cloudflare-dns.com" + GoogleAddress1 models.DNSForwardAddress = "8.8.8.8@853#dns.google" + GoogleAddress2 models.DNSForwardAddress = "8.8.4.4@853#dns.google" + Quad9Address1 models.DNSForwardAddress = "9.9.9.9@853#dns.quad9.net" + Quad9Address2 models.DNSForwardAddress = "149.112.112.112@853#dns.quad9.net" + QuadrantAddress models.DNSForwardAddress = "12.159.2.159@853#dns-tls.qis.io" + CleanBrowsingAddress1 models.DNSForwardAddress = "185.228.168.9@853#security-filter-dns.cleanbrowsing.org" + CleanBrowsingAddress2 models.DNSForwardAddress = "185.228.169.9@853#security-filter-dns.cleanbrowsing.org" + SecureDNSAddress models.DNSForwardAddress = "146.185.167.43@853#dot.securedns.eu" + LibreDNSAddress models.DNSForwardAddress = "116.203.115.192@853#dot.libredns.gr" +) + +var DNSAddressesMapping = map[models.DNSProvider][]models.DNSForwardAddress{ + Cloudflare: []models.DNSForwardAddress{CloudflareAddress1, CloudflareAddress2}, + Google: []models.DNSForwardAddress{GoogleAddress1, GoogleAddress2}, + Quad9: []models.DNSForwardAddress{Quad9Address1, Quad9Address2}, + Quadrant: []models.DNSForwardAddress{QuadrantAddress}, + CleanBrowsing: []models.DNSForwardAddress{CleanBrowsingAddress1, CleanBrowsingAddress2}, + SecureDNS: []models.DNSForwardAddress{SecureDNSAddress}, + LibreDNS: []models.DNSForwardAddress{LibreDNSAddress}, +} + +// Block lists URLs +const ( + AdsBlockListHostnamesURL models.URL = "https://raw.githubusercontent.com/qdm12/files/master/ads-hostnames.updated" + AdsBlockListIPsURL models.URL = "https://raw.githubusercontent.com/qdm12/files/master/ads-ips.updated" + MaliciousBlockListHostnamesURL models.URL = "https://raw.githubusercontent.com/qdm12/files/master/malicious-hostnames.updated" + MaliciousBlockListIPsURL models.URL = "https://raw.githubusercontent.com/qdm12/files/master/malicious-ips.updated" + SurveillanceBlockListHostnamesURL models.URL = "https://raw.githubusercontent.com/qdm12/files/master/surveillance-hostnames.updated" + SurveillanceBlockListIPsURL models.URL = "https://raw.githubusercontent.com/qdm12/files/master/surveillance-ips.updated" +) + +// DNS certificates to fetch +// TODO obtain from source directly, see qdm12/updated) +const ( + NamedRootURL models.URL = "https://raw.githubusercontent.com/qdm12/files/master/named.root.updated" + RootKeyURL models.URL = "https://raw.githubusercontent.com/qdm12/files/master/root.key.updated" +) diff --git a/internal/constants/openvpn.go b/internal/constants/openvpn.go new file mode 100644 index 00000000..d878805f --- /dev/null +++ b/internal/constants/openvpn.go @@ -0,0 +1,10 @@ +package constants + +import ( + "github.com/qdm12/private-internet-access-docker/internal/models" +) + +const ( + TUN models.VPNDevice = "tun0" + TAP models.VPNDevice = "tap0" +) diff --git a/internal/constants/paths.go b/internal/constants/paths.go new file mode 100644 index 00000000..b318eeb6 --- /dev/null +++ b/internal/constants/paths.go @@ -0,0 +1,28 @@ +package constants + +import ( + "github.com/qdm12/private-internet-access-docker/internal/models" +) + +const ( + // UnboundConf is the file path to the Unbound configuration file + UnboundConf models.Filepath = "/etc/unbound/unbound.conf" + // ResolvConf is the file path to the system resolv.conf file + ResolvConf models.Filepath = "/etc/resolv.conf" + // OpenVPNAuthConf is the file path to the OpenVPN auth file + OpenVPNAuthConf models.Filepath = "/etc/openvpn/auth.conf" + // OpenVPNConf is the file path to the OpenVPN client configuration file + OpenVPNConf models.Filepath = "/etc/openvpn/target.ovpn" + // TunnelDevice is the file path to tun device + TunnelDevice models.Filepath = "/dev/net/tun" + // NetRoute is the path to the file containing information on the network route + NetRoute models.Filepath = "/proc/net/route" + // TinyProxyConf is the filepath to the tinyproxy configuration file + TinyProxyConf models.Filepath = "/etc/tinyproxy/tinyproxy.conf" + // ShadowsocksConf is the filepath to the shadowsocks configuration file + ShadowsocksConf models.Filepath = "/etc/shadowsocks.json" + // RootHints is the filepath to the root.hints file used by Unbound + RootHints models.Filepath = "/etc/unbound/root.hints" + // RootKey is the filepath to the root.key file used by Unbound + RootKey models.Filepath = "/etc/unbound/root.key" +) diff --git a/internal/constants/pia.go b/internal/constants/pia.go new file mode 100644 index 00000000..7be742d3 --- /dev/null +++ b/internal/constants/pia.go @@ -0,0 +1,70 @@ +package constants + +import ( + "github.com/qdm12/private-internet-access-docker/internal/models" +) + +const ( + // PIAEncryptionNormal is the normal level of encryption for communication with PIA servers + PIAEncryptionNormal models.PIAEncryption = "normal" + // PIAEncryptionStrong is the strong level of encryption for communication with PIA servers + PIAEncryptionStrong models.PIAEncryption = "strong" +) + +const ( + AUMelbourne models.PIARegion = "AU Melbourne" + AUPerth models.PIARegion = "AU Perth" + AUSydney models.PIARegion = "AU Sydney" + Austria models.PIARegion = "Austria" + Belgium models.PIARegion = "Belgium" + CAMontreal models.PIARegion = "CA Montreal" + CAToronto models.PIARegion = "CA Toronto" + CAVancouver models.PIARegion = "CA Vancouver" + CzechRepublic models.PIARegion = "Czech Republic" + DEBerlin models.PIARegion = "DE Berlin" + DEFrankfurt models.PIARegion = "DE Frankfurt" + Denmark models.PIARegion = "Denmark" + Finland models.PIARegion = "Finland" + France models.PIARegion = "France" + HongKong models.PIARegion = "Hong Kong" + Hungary models.PIARegion = "Hungary" + India models.PIARegion = "India" + Ireland models.PIARegion = "Ireland" + Israel models.PIARegion = "Israel" + Italy models.PIARegion = "Italy" + Japan models.PIARegion = "Japan" + Luxembourg models.PIARegion = "Luxembourg" + Mexico models.PIARegion = "Mexico" + Netherlands models.PIARegion = "Netherlands" + NewZealand models.PIARegion = "New Zealand" + Norway models.PIARegion = "Norway" + Poland models.PIARegion = "Poland" + Romania models.PIARegion = "Romania" + Singapore models.PIARegion = "Singapore" + Spain models.PIARegion = "Spain" + Sweden models.PIARegion = "Sweden" + Switzerland models.PIARegion = "Switzerland" + UAE models.PIARegion = "UAE" + UKLondon models.PIARegion = "UK London" + UKManchester models.PIARegion = "UK Manchester" + UKSouthampton models.PIARegion = "UK Southampton" + USAtlanta models.PIARegion = "US Atlanta" + USCalifornia models.PIARegion = "US California" + USChicago models.PIARegion = "US Chicago" + USDenver models.PIARegion = "US Denver" + USEast models.PIARegion = "US East" + USFlorida models.PIARegion = "US Florida" + USHouston models.PIARegion = "US Houston" + USLasVegas models.PIARegion = "US Las Vegas" + USNewYorkCity models.PIARegion = "US New York City" + USSeattle models.PIARegion = "US Seattle" + USSiliconValley models.PIARegion = "US Silicon Valley" + USTexas models.PIARegion = "US Texas" + USWashingtonDC models.PIARegion = "US Washington DC" + USWest models.PIARegion = "US West" +) + +const ( + PIAOpenVPNURL models.URL = "https://www.privateinternetaccess.com/openvpn" + PIAPortForwardURL models.URL = "http://209.222.18.222:2000" +) diff --git a/internal/constants/splash.go b/internal/constants/splash.go new file mode 100644 index 00000000..32b5d773 --- /dev/null +++ b/internal/constants/splash.go @@ -0,0 +1,13 @@ +package constants + +const ( + // Annoucement is a message annoucement + Annoucement = "Total rewrite in Go with many new features" + // AnnoucementExpiration is the expiration time of the annoucement in unix timestamp + AnnoucementExpiration = 1582761600 +) + +const ( + // IssueLink is the link for users to use to create issues + IssueLink = "https://github.com/qdm12/private-internet-access-docker/issues/new" +) diff --git a/internal/constants/tinyproxy.go b/internal/constants/tinyproxy.go new file mode 100644 index 00000000..90cd7100 --- /dev/null +++ b/internal/constants/tinyproxy.go @@ -0,0 +1,16 @@ +package constants + +import ( + "github.com/qdm12/private-internet-access-docker/internal/models" +) + +const ( + // TinyProxyInfoLevel is the info log level for TinyProxy + TinyProxyInfoLevel models.TinyProxyLogLevel = "Info" + // TinyProxyWarnLevel is the warning log level for TinyProxy + TinyProxyWarnLevel models.TinyProxyLogLevel = "Warning" + // TinyProxyErrorLevel is the error log level for TinyProxy + TinyProxyErrorLevel models.TinyProxyLogLevel = "Error" + // TinyProxyCriticalLevel is the critical log level for TinyProxy + TinyProxyCriticalLevel models.TinyProxyLogLevel = "Critical" +) diff --git a/internal/constants/vpn.go b/internal/constants/vpn.go new file mode 100644 index 00000000..9f672e81 --- /dev/null +++ b/internal/constants/vpn.go @@ -0,0 +1,21 @@ +package constants + +import ( + "github.com/qdm12/private-internet-access-docker/internal/models" +) + +const ( + // PrivateInternetAccess is a VPN provider + PrivateInternetAccess models.VPNProvider = "private internet access" + // Mullvad is a VPN provider + Mullvad models.VPNProvider = "mullvad" + // Windscribe is a VPN provider + Windscribe models.VPNProvider = "windscribe" +) + +const ( + // TCP is a network protocol (reliable and slower than UDP) + TCP models.NetworkProtocol = "tcp" + // UDP is a network protocol (unreliable and faster than TCP) + UDP models.NetworkProtocol = "udp" +) diff --git a/internal/dns/command.go b/internal/dns/command.go new file mode 100644 index 00000000..42c68d7a --- /dev/null +++ b/internal/dns/command.go @@ -0,0 +1,40 @@ +package dns + +import ( + "fmt" + "io" + "strings" + + "github.com/qdm12/private-internet-access-docker/internal/constants" +) + +func (c *configurator) Start(verbosityDetailsLevel uint8) (stdout io.ReadCloser, err error) { + c.logger.Info("%s: starting unbound", logPrefix) + args := []string{"-d", "-c", string(constants.UnboundConf)} + if verbosityDetailsLevel > 0 { + args = append(args, "-"+strings.Repeat("v", int(verbosityDetailsLevel))) + } + // Only logs to stderr + _, stdout, _, err = c.commander.Start("unbound", args...) + return stdout, err +} + +func (c *configurator) Version() (version string, err error) { + output, err := c.commander.Run("unbound", "-V") + if err != nil { + return "", fmt.Errorf("unbound version: %w", err) + } + for _, line := range strings.Split(output, "\n") { + if strings.Contains(line, "Version ") { + words := strings.Fields(line) + if len(words) < 2 { + continue + } + version = words[1] + } + } + if version == "" { + return "", fmt.Errorf("unbound version was not found in %q", output) + } + return version, nil +} diff --git a/internal/dns/command_test.go b/internal/dns/command_test.go new file mode 100644 index 00000000..e230c849 --- /dev/null +++ b/internal/dns/command_test.go @@ -0,0 +1,69 @@ +package dns + +import ( + "fmt" + "testing" + + commandMocks "github.com/qdm12/golibs/command/mocks" + loggingMocks "github.com/qdm12/golibs/logging/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/qdm12/private-internet-access-docker/internal/constants" +) + +func Test_Start(t *testing.T) { + t.Parallel() + logger := &loggingMocks.Logger{} + logger.On("Info", "%s: starting unbound", logPrefix).Once() + commander := &commandMocks.Commander{} + commander.On("Start", "unbound", "-d", "-c", string(constants.UnboundConf), "-vv"). + Return(nil, nil, nil, nil).Once() + c := &configurator{commander: commander, logger: logger} + stdout, err := c.Start(2) + assert.Nil(t, stdout) + assert.NoError(t, err) + logger.AssertExpectations(t) + commander.AssertExpectations(t) +} + +func Test_Version(t *testing.T) { + t.Parallel() + tests := map[string]struct { + runOutput string + runErr error + version string + err error + }{ + "no data": { + err: fmt.Errorf(`unbound version was not found in ""`), + }, + "2 lines with version": { + runOutput: "Version \nVersion 1.0-a hello\n", + version: "1.0-a", + }, + "run error": { + runErr: fmt.Errorf("error"), + err: fmt.Errorf("unbound version: error"), + }, + } + for name, tc := range tests { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + commander := &commandMocks.Commander{} + commander.On("Run", "unbound", "-V"). + Return(tc.runOutput, tc.runErr).Once() + c := &configurator{commander: commander} + version, err := c.Version() + if tc.err != nil { + require.Error(t, err) + assert.Equal(t, tc.err.Error(), err.Error()) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tc.version, version) + commander.AssertExpectations(t) + }) + } +} diff --git a/internal/dns/conf.go b/internal/dns/conf.go new file mode 100644 index 00000000..86a77e79 --- /dev/null +++ b/internal/dns/conf.go @@ -0,0 +1,280 @@ +package dns + +import ( + "fmt" + "sort" + "strings" + + "github.com/qdm12/golibs/files" + "github.com/qdm12/golibs/logging" + "github.com/qdm12/golibs/network" + "github.com/qdm12/private-internet-access-docker/internal/constants" + "github.com/qdm12/private-internet-access-docker/internal/settings" +) + +func (c *configurator) MakeUnboundConf(settings settings.DNS, uid, gid int) (err error) { + c.logger.Info("%s: generating Unbound configuration", logPrefix) + lines, warnings, err := generateUnboundConf(settings, c.client, c.logger) + for _, warning := range warnings { + c.logger.Warn(warning) + } + if err != nil { + return err + } + return c.fileManager.WriteLinesToFile( + string(constants.UnboundConf), + lines, + files.FileOwnership(uid, gid), + files.FilePermissions(0400)) +} + +// MakeUnboundConf generates an Unbound configuration from the user provided settings +func generateUnboundConf(settings settings.DNS, client network.Client, logger logging.Logger) (lines []string, warnings []error, err error) { + serverSection := map[string]string{ + // Logging + "verbosity": fmt.Sprintf("%d", settings.VerbosityLevel), + "val-log-level": fmt.Sprintf("%d", settings.ValidationLogLevel), + "use-syslog": "no", + // Performance + "num-threads": "1", + "prefetch": "yes", + "prefetch-key": "yes", + "key-cache-size": "16m", + "key-cache-slabs": "4", + "msg-cache-size": "4m", + "msg-cache-slabs": "4", + "rrset-cache-size": "4m", + "rrset-cache-slabs": "4", + "cache-min-ttl": "3600", + "cache-max-ttl": "9000", + // Privacy + "rrset-roundrobin": "yes", + "hide-identity": "yes", + "hide-version": "yes", + // Security + "tls-cert-bundle": "\"/etc/ssl/certs/ca-certificates.crt\"", + "root-hints": fmt.Sprintf("%q", constants.RootHints), + "trust-anchor-file": fmt.Sprintf("%q", constants.RootKey), + "harden-below-nxdomain": "yes", + "harden-referral-path": "yes", + "harden-algo-downgrade": "yes", + // Network + "do-ip4": "yes", + "do-ip6": "no", + "interface": "127.0.0.1", + "port": "53", + // Other + "username": "\"nonrootuser\"", + } + + // Block lists + hostnamesLines, ipsLines, warnings := buildBlocked(client, + settings.BlockMalicious, settings.BlockAds, settings.BlockSurveillance, + settings.AllowedHostnames, settings.PrivateAddresses, + ) + logger.Info("%s: %d hostnames blocked overall", logPrefix, len(hostnamesLines)) + logger.Info("%s: %d IP addresses blocked overall", logPrefix, len(ipsLines)) + sort.Slice(hostnamesLines, func(i, j int) bool { // for unit tests really + return hostnamesLines[i] < hostnamesLines[j] + }) + sort.Slice(ipsLines, func(i, j int) bool { // for unit tests really + return ipsLines[i] < ipsLines[j] + }) + + // Server + lines = append(lines, "server:") + var serverLines []string + for k, v := range serverSection { + serverLines = append(serverLines, " "+k+": "+v) + } + sort.Slice(serverLines, func(i, j int) bool { + return serverLines[i] < serverLines[j] + }) + lines = append(lines, serverLines...) + lines = append(lines, hostnamesLines...) + lines = append(lines, ipsLines...) + + // Forward zone + lines = append(lines, "forward-zone:") + forwardZoneSection := map[string]string{ + "name": "\".\"", + "forward-tls-upstream": "yes", + } + var forwardZoneLines []string + for k, v := range forwardZoneSection { + forwardZoneLines = append(forwardZoneLines, " "+k+": "+v) + } + sort.Slice(forwardZoneLines, func(i, j int) bool { + return forwardZoneLines[i] < forwardZoneLines[j] + }) + for _, provider := range settings.Providers { + forwardAddresses, ok := constants.DNSAddressesMapping[provider] + if !ok || len(forwardAddresses) == 0 { + return nil, warnings, fmt.Errorf("DNS provider %q does not have any matching forward addresses", provider) + } + for _, forwardAddress := range forwardAddresses { + forwardZoneLines = append(forwardZoneLines, fmt.Sprintf(" forward-addr: %s", forwardAddress)) + } + } + lines = append(lines, forwardZoneLines...) + return lines, warnings, nil +} + +func buildBlocked(client network.Client, blockMalicious, blockAds, blockSurveillance bool, + allowedHostnames, privateAddresses []string) (hostnamesLines, ipsLines []string, errs []error) { + chHostnames := make(chan []string) + chIPs := make(chan []string) + chErrors := make(chan []error) + go func() { + lines, errs := buildBlockedHostnames(client, blockMalicious, blockAds, blockSurveillance, allowedHostnames) + chHostnames <- lines + chErrors <- errs + }() + go func() { + lines, errs := buildBlockedIPs(client, blockMalicious, blockAds, blockSurveillance, privateAddresses) + chIPs <- lines + chErrors <- errs + }() + n := 2 + for n > 0 { + select { + case lines := <-chHostnames: + hostnamesLines = append(hostnamesLines, lines...) + case lines := <-chIPs: + ipsLines = append(ipsLines, lines...) + case routineErrs := <-chErrors: + errs = append(errs, routineErrs...) + n-- + } + } + return hostnamesLines, ipsLines, errs +} + +func getList(client network.Client, URL string) (results []string, err error) { + content, status, err := client.GetContent(URL) + if err != nil { + return nil, err + } else if status != 200 { + return nil, fmt.Errorf("HTTP status code is %d and not 200", status) + } + results = strings.Split(string(content), "\n") + + // remove empty lines + last := len(results) - 1 + for i := range results { + if len(results[i]) == 0 { + results[i] = results[last] + last-- + } + } + results = results[:last+1] + + if len(results) == 0 { + return nil, nil + } + return results, nil +} + +func buildBlockedHostnames(client network.Client, blockMalicious, blockAds, blockSurveillance bool, + allowedHostnames []string) (lines []string, errs []error) { + chResults := make(chan []string) + chError := make(chan error) + listsLeftToFetch := 0 + if blockMalicious { + listsLeftToFetch++ + go func() { + results, err := getList(client, string(constants.MaliciousBlockListHostnamesURL)) + chResults <- results + chError <- err + }() + } + if blockAds { + listsLeftToFetch++ + go func() { + results, err := getList(client, string(constants.AdsBlockListHostnamesURL)) + chResults <- results + chError <- err + }() + } + if blockSurveillance { + listsLeftToFetch++ + go func() { + results, err := getList(client, string(constants.SurveillanceBlockListHostnamesURL)) + chResults <- results + chError <- err + }() + } + uniqueResults := make(map[string]struct{}) + for listsLeftToFetch > 0 { + select { + case results := <-chResults: + for _, result := range results { + uniqueResults[result] = struct{}{} + } + case err := <-chError: + listsLeftToFetch-- + if err != nil { + errs = append(errs, err) + } + } + } + for _, allowedHostname := range allowedHostnames { + delete(uniqueResults, allowedHostname) + } + for result := range uniqueResults { + lines = append(lines, " local-zone: \""+result+"\" static") + } + return lines, errs +} + +func buildBlockedIPs(client network.Client, blockMalicious, blockAds, blockSurveillance bool, + privateAddresses []string) (lines []string, errs []error) { + chResults := make(chan []string) + chError := make(chan error) + listsLeftToFetch := 0 + if blockMalicious { + listsLeftToFetch++ + go func() { + results, err := getList(client, string(constants.MaliciousBlockListIPsURL)) + chResults <- results + chError <- err + }() + } + if blockAds { + listsLeftToFetch++ + go func() { + results, err := getList(client, string(constants.AdsBlockListIPsURL)) + chResults <- results + chError <- err + }() + } + if blockSurveillance { + listsLeftToFetch++ + go func() { + results, err := getList(client, string(constants.SurveillanceBlockListIPsURL)) + chResults <- results + chError <- err + }() + } + uniqueResults := make(map[string]struct{}) + for listsLeftToFetch > 0 { + select { + case results := <-chResults: + for _, result := range results { + uniqueResults[result] = struct{}{} + } + case err := <-chError: + listsLeftToFetch-- + if err != nil { + errs = append(errs, err) + } + } + } + for _, privateAddress := range privateAddresses { + uniqueResults[privateAddress] = struct{}{} + } + for result := range uniqueResults { + lines = append(lines, " private-address: "+result) + } + return lines, errs +} diff --git a/internal/dns/conf_test.go b/internal/dns/conf_test.go new file mode 100644 index 00000000..ec036be2 --- /dev/null +++ b/internal/dns/conf_test.go @@ -0,0 +1,518 @@ +package dns + +import ( + "fmt" + "strings" + "testing" + + "github.com/qdm12/golibs/logging" + "github.com/qdm12/golibs/network/mocks" + "github.com/qdm12/private-internet-access-docker/internal/constants" + "github.com/qdm12/private-internet-access-docker/internal/models" + "github.com/qdm12/private-internet-access-docker/internal/settings" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_generateUnboundConf(t *testing.T) { + t.Parallel() + settings := settings.DNS{ + Providers: []models.DNSProvider{constants.Cloudflare, constants.Quad9}, + AllowedHostnames: []string{"a"}, + PrivateAddresses: []string{"9.9.9.9"}, + BlockMalicious: true, + BlockSurveillance: false, + BlockAds: false, + VerbosityLevel: 2, + ValidationLogLevel: 3, + } + client := &mocks.Client{} + client.On("GetContent", string(constants.MaliciousBlockListHostnamesURL)). + Return([]byte("b\na\nc"), 200, nil).Once() + client.On("GetContent", string(constants.MaliciousBlockListIPsURL)). + Return([]byte("c\nd\n"), 200, nil).Once() + emptyLogger, err := logging.NewEmptyLogger() + require.NoError(t, err) + lines, warnings, err := generateUnboundConf(settings, client, emptyLogger) + require.Len(t, warnings, 0) + require.NoError(t, err) + client.AssertExpectations(t) + expected := ` +server: + cache-max-ttl: 9000 + cache-min-ttl: 3600 + do-ip4: yes + do-ip6: no + harden-algo-downgrade: yes + harden-below-nxdomain: yes + harden-referral-path: yes + hide-identity: yes + hide-version: yes + interface: 127.0.0.1 + key-cache-size: 16m + key-cache-slabs: 4 + msg-cache-size: 4m + msg-cache-slabs: 4 + num-threads: 1 + port: 53 + prefetch-key: yes + prefetch: yes + root-hints: "/etc/unbound/root.hints" + rrset-cache-size: 4m + rrset-cache-slabs: 4 + rrset-roundrobin: yes + tls-cert-bundle: "/etc/ssl/certs/ca-certificates.crt" + trust-anchor-file: "/etc/unbound/root.key" + use-syslog: no + username: "nonrootuser" + val-log-level: 3 + verbosity: 2 + local-zone: "b" static + local-zone: "c" static + private-address: 9.9.9.9 + private-address: c + private-address: d +forward-zone: + forward-tls-upstream: yes + name: "." + forward-addr: 1.1.1.1@853#cloudflare-dns.com + forward-addr: 1.0.0.1@853#cloudflare-dns.com + forward-addr: 9.9.9.9@853#dns.quad9.net + forward-addr: 149.112.112.112@853#dns.quad9.net` + assert.Equal(t, expected, "\n"+strings.Join(lines, "\n")) +} + +func Test_buildBlocked(t *testing.T) { + t.Parallel() + type blockParams struct { + blocked bool + content []byte + clientErr error + } + tests := map[string]struct { + malicious blockParams + ads blockParams + surveillance blockParams + allowedHostnames []string + privateAddresses []string + hostnamesLines []string + ipsLines []string + errsString []string + }{ + "none blocked": {}, + "all blocked without lists": { + malicious: blockParams{ + blocked: true, + }, + ads: blockParams{ + blocked: true, + }, + surveillance: blockParams{ + blocked: true, + }, + }, + "all blocked with lists": { + malicious: blockParams{ + blocked: true, + content: []byte("malicious"), + }, + ads: blockParams{ + blocked: true, + content: []byte("ads"), + }, + surveillance: blockParams{ + blocked: true, + content: []byte("surveillance"), + }, + hostnamesLines: []string{ + " local-zone: \"ads\" static", + " local-zone: \"malicious\" static", + " local-zone: \"surveillance\" static"}, + ipsLines: []string{ + " private-address: ads", + " private-address: malicious", + " private-address: surveillance"}, + }, + "all blocked with allowed hostnames": { + malicious: blockParams{ + blocked: true, + content: []byte("malicious"), + }, + ads: blockParams{ + blocked: true, + content: []byte("ads"), + }, + surveillance: blockParams{ + blocked: true, + content: []byte("surveillance"), + }, + allowedHostnames: []string{"ads"}, + hostnamesLines: []string{ + " local-zone: \"malicious\" static", + " local-zone: \"surveillance\" static"}, + ipsLines: []string{ + " private-address: ads", + " private-address: malicious", + " private-address: surveillance"}, + }, + "all blocked with private addresses": { + malicious: blockParams{ + blocked: true, + content: []byte("malicious"), + }, + ads: blockParams{ + blocked: true, + content: []byte("ads"), + }, + surveillance: blockParams{ + blocked: true, + content: []byte("surveillance"), + }, + privateAddresses: []string{"ads", "192.100.1.5"}, + hostnamesLines: []string{ + " local-zone: \"ads\" static", + " local-zone: \"malicious\" static", + " local-zone: \"surveillance\" static"}, + ipsLines: []string{ + " private-address: 192.100.1.5", + " private-address: ads", + " private-address: malicious", + " private-address: surveillance"}, + }, + "all blocked with lists and one error": { + malicious: blockParams{ + blocked: true, + content: []byte("malicious"), + }, + ads: blockParams{ + blocked: true, + content: []byte("ads"), + clientErr: fmt.Errorf("ads error"), + }, + surveillance: blockParams{ + blocked: true, + content: []byte("surveillance"), + }, + hostnamesLines: []string{ + " local-zone: \"malicious\" static", + " local-zone: \"surveillance\" static"}, + ipsLines: []string{ + " private-address: malicious", + " private-address: surveillance"}, + errsString: []string{"ads error", "ads error"}, + }, + "all blocked with errors": { + malicious: blockParams{ + blocked: true, + clientErr: fmt.Errorf("malicious"), + }, + ads: blockParams{ + blocked: true, + clientErr: fmt.Errorf("ads"), + }, + surveillance: blockParams{ + blocked: true, + clientErr: fmt.Errorf("surveillance"), + }, + errsString: []string{"malicious", "malicious", "ads", "ads", "surveillance", "surveillance"}, + }, + } + for name, tc := range tests { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + client := &mocks.Client{} + if tc.malicious.blocked { + client.On("GetContent", string(constants.MaliciousBlockListHostnamesURL)). + Return(tc.malicious.content, 200, tc.malicious.clientErr).Once() + client.On("GetContent", string(constants.MaliciousBlockListIPsURL)). + Return(tc.malicious.content, 200, tc.malicious.clientErr).Once() + } + if tc.ads.blocked { + client.On("GetContent", string(constants.AdsBlockListHostnamesURL)). + Return(tc.ads.content, 200, tc.ads.clientErr).Once() + client.On("GetContent", string(constants.AdsBlockListIPsURL)). + Return(tc.ads.content, 200, tc.ads.clientErr).Once() + } + if tc.surveillance.blocked { + client.On("GetContent", string(constants.SurveillanceBlockListHostnamesURL)). + Return(tc.surveillance.content, 200, tc.surveillance.clientErr).Once() + client.On("GetContent", string(constants.SurveillanceBlockListIPsURL)). + Return(tc.surveillance.content, 200, tc.surveillance.clientErr).Once() + } + hostnamesLines, ipsLines, errs := buildBlocked(client, tc.malicious.blocked, tc.ads.blocked, tc.surveillance.blocked, + tc.allowedHostnames, tc.privateAddresses) + var errsString []string + for _, err := range errs { + errsString = append(errsString, err.Error()) + } + assert.ElementsMatch(t, tc.errsString, errsString) + assert.ElementsMatch(t, tc.hostnamesLines, hostnamesLines) + assert.ElementsMatch(t, tc.ipsLines, ipsLines) + client.AssertExpectations(t) + }) + } +} + +func Test_getList(t *testing.T) { + t.Parallel() + tests := map[string]struct { + content []byte + status int + clientErr error + results []string + err error + }{ + "no result": {nil, 200, nil, nil, nil}, + "bad status": {nil, 500, nil, nil, fmt.Errorf("HTTP status code is 500 and not 200")}, + "network error": {nil, 200, fmt.Errorf("error"), nil, fmt.Errorf("error")}, + "results": {[]byte("a\nb\nc\n"), 200, nil, []string{"a", "b", "c"}, nil}, + } + for name, tc := range tests { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + client := &mocks.Client{} + client.On("GetContent", "irrelevant_url").Return( + tc.content, tc.status, tc.clientErr, + ).Once() + results, err := getList(client, "irrelevant_url") + if tc.err != nil { + require.Error(t, err) + assert.Equal(t, tc.err.Error(), err.Error()) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tc.results, results) + client.AssertExpectations(t) + }) + } +} + +func Test_buildBlockedHostnames(t *testing.T) { + t.Parallel() + type blockParams struct { + blocked bool + content []byte + clientErr error + } + tests := map[string]struct { + malicious blockParams + ads blockParams + surveillance blockParams + allowedHostnames []string + lines []string + errsString []string + }{ + "nothing blocked": { + lines: nil, + errsString: nil, + }, + "only malicious blocked": { + malicious: blockParams{ + blocked: true, + content: []byte("site_a\nsite_b"), + clientErr: nil, + }, + lines: []string{ + " local-zone: \"site_a\" static", + " local-zone: \"site_b\" static"}, + errsString: nil, + }, + "all blocked with some duplicates": { + malicious: blockParams{ + blocked: true, + content: []byte("site_a\nsite_b"), + }, + ads: blockParams{ + blocked: true, + content: []byte("site_a\nsite_c"), + }, + surveillance: blockParams{ + blocked: true, + content: []byte("site_c\nsite_a"), + }, + lines: []string{ + " local-zone: \"site_a\" static", + " local-zone: \"site_b\" static", + " local-zone: \"site_c\" static"}, + errsString: nil, + }, + "all blocked with one errored": { + malicious: blockParams{ + blocked: true, + content: []byte("site_a\nsite_b"), + }, + ads: blockParams{ + blocked: true, + content: []byte("site_a\nsite_c"), + }, + surveillance: blockParams{ + blocked: true, + clientErr: fmt.Errorf("surveillance error"), + }, + lines: []string{ + " local-zone: \"site_a\" static", + " local-zone: \"site_b\" static", + " local-zone: \"site_c\" static"}, + errsString: []string{"surveillance error"}, + }, + "blocked with allowed hostnames": { + malicious: blockParams{ + blocked: true, + content: []byte("site_a\nsite_b"), + }, + ads: blockParams{ + blocked: true, + content: []byte("site_c\nsite_d"), + }, + allowedHostnames: []string{"site_b", "site_c"}, + lines: []string{ + " local-zone: \"site_a\" static", + " local-zone: \"site_d\" static"}, + }, + } + for name, tc := range tests { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + client := &mocks.Client{} + if tc.malicious.blocked { + client.On("GetContent", string(constants.MaliciousBlockListHostnamesURL)). + Return(tc.malicious.content, 200, tc.malicious.clientErr).Once() + } + if tc.ads.blocked { + client.On("GetContent", string(constants.AdsBlockListHostnamesURL)). + Return(tc.ads.content, 200, tc.ads.clientErr).Once() + } + if tc.surveillance.blocked { + client.On("GetContent", string(constants.SurveillanceBlockListHostnamesURL)). + Return(tc.surveillance.content, 200, tc.surveillance.clientErr).Once() + } + lines, errs := buildBlockedHostnames(client, + tc.malicious.blocked, tc.ads.blocked, tc.surveillance.blocked, tc.allowedHostnames) + var errsString []string + for _, err := range errs { + errsString = append(errsString, err.Error()) + } + assert.ElementsMatch(t, tc.errsString, errsString) + assert.ElementsMatch(t, tc.lines, lines) + client.AssertExpectations(t) + }) + } +} + +func Test_buildBlockedIPs(t *testing.T) { + t.Parallel() + type blockParams struct { + blocked bool + content []byte + clientErr error + } + tests := map[string]struct { + malicious blockParams + ads blockParams + surveillance blockParams + privateAddresses []string + lines []string + errsString []string + }{ + "nothing blocked": { + lines: nil, + errsString: nil, + }, + "only malicious blocked": { + malicious: blockParams{ + blocked: true, + content: []byte("site_a\nsite_b"), + clientErr: nil, + }, + lines: []string{ + " private-address: site_a", + " private-address: site_b"}, + errsString: nil, + }, + "all blocked with some duplicates": { + malicious: blockParams{ + blocked: true, + content: []byte("site_a\nsite_b"), + }, + ads: blockParams{ + blocked: true, + content: []byte("site_a\nsite_c"), + }, + surveillance: blockParams{ + blocked: true, + content: []byte("site_c\nsite_a"), + }, + lines: []string{ + " private-address: site_a", + " private-address: site_b", + " private-address: site_c"}, + errsString: nil, + }, + "all blocked with one errored": { + malicious: blockParams{ + blocked: true, + content: []byte("site_a\nsite_b"), + }, + ads: blockParams{ + blocked: true, + content: []byte("site_a\nsite_c"), + }, + surveillance: blockParams{ + blocked: true, + clientErr: fmt.Errorf("surveillance error"), + }, + lines: []string{ + " private-address: site_a", + " private-address: site_b", + " private-address: site_c"}, + errsString: []string{"surveillance error"}, + }, + "blocked with private addresses": { + malicious: blockParams{ + blocked: true, + content: []byte("site_a\nsite_b"), + }, + ads: blockParams{ + blocked: true, + content: []byte("site_c"), + }, + privateAddresses: []string{"site_c", "site_d"}, + lines: []string{ + " private-address: site_a", + " private-address: site_b", + " private-address: site_c", + " private-address: site_d"}, + }, + } + for name, tc := range tests { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + client := &mocks.Client{} + if tc.malicious.blocked { + client.On("GetContent", string(constants.MaliciousBlockListIPsURL)). + Return(tc.malicious.content, 200, tc.malicious.clientErr).Once() + } + if tc.ads.blocked { + client.On("GetContent", string(constants.AdsBlockListIPsURL)). + Return(tc.ads.content, 200, tc.ads.clientErr).Once() + } + if tc.surveillance.blocked { + client.On("GetContent", string(constants.SurveillanceBlockListIPsURL)). + Return(tc.surveillance.content, 200, tc.surveillance.clientErr).Once() + } + lines, errs := buildBlockedIPs(client, + tc.malicious.blocked, tc.ads.blocked, tc.surveillance.blocked, tc.privateAddresses) + var errsString []string + for _, err := range errs { + errsString = append(errsString, err.Error()) + } + assert.ElementsMatch(t, tc.errsString, errsString) + assert.ElementsMatch(t, tc.lines, lines) + client.AssertExpectations(t) + }) + } +} diff --git a/internal/dns/dns.go b/internal/dns/dns.go new file mode 100644 index 00000000..6b637f5f --- /dev/null +++ b/internal/dns/dns.go @@ -0,0 +1,38 @@ +package dns + +import ( + "io" + + "github.com/qdm12/golibs/command" + "github.com/qdm12/golibs/files" + "github.com/qdm12/golibs/logging" + "github.com/qdm12/golibs/network" + "github.com/qdm12/private-internet-access-docker/internal/settings" +) + +const logPrefix = "dns configurator" + +type Configurator interface { + DownloadRootHints(uid, gid int) error + DownloadRootKey(uid, gid int) error + MakeUnboundConf(settings settings.DNS, uid, gid int) (err error) + SetLocalNameserver() error + Start(logLevel uint8) (stdout io.ReadCloser, err error) + Version() (version string, err error) +} + +type configurator struct { + logger logging.Logger + client network.Client + fileManager files.FileManager + commander command.Commander +} + +func NewConfigurator(logger logging.Logger, client network.Client, fileManager files.FileManager) Configurator { + return &configurator{ + logger: logger, + client: client, + fileManager: fileManager, + commander: command.NewCommander(), + } +} diff --git a/internal/dns/os.go b/internal/dns/os.go new file mode 100644 index 00000000..6a0afda2 --- /dev/null +++ b/internal/dns/os.go @@ -0,0 +1,32 @@ +package dns + +import ( + "strings" + + "github.com/qdm12/private-internet-access-docker/internal/constants" +) + +func (c *configurator) SetLocalNameserver() error { + c.logger.Info("%s: setting local nameserver to 127.0.0.1", logPrefix) + data, err := c.fileManager.ReadFile(string(constants.ResolvConf)) + if err != nil { + return err + } + s := strings.TrimSuffix(string(data), "\n") + lines := strings.Split(s, "\n") + if len(lines) == 1 && lines[0] == "" { + lines = nil + } + found := false + for i := range lines { + if strings.HasPrefix(lines[i], "nameserver ") { + lines[i] = "nameserver 127.0.0.1" + found = true + } + } + if !found { + lines = append(lines, "nameserver 127.0.0.1") + } + data = []byte(strings.Join(lines, "\n")) + return c.fileManager.WriteToFile(string(constants.ResolvConf), data) +} diff --git a/internal/dns/os_test.go b/internal/dns/os_test.go new file mode 100644 index 00000000..f6201d1f --- /dev/null +++ b/internal/dns/os_test.go @@ -0,0 +1,72 @@ +package dns + +import ( + "fmt" + "testing" + + filesmocks "github.com/qdm12/golibs/files/mocks" + loggingmocks "github.com/qdm12/golibs/logging/mocks" + "github.com/qdm12/private-internet-access-docker/internal/constants" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_SetLocalNameserver(t *testing.T) { + t.Parallel() + tests := map[string]struct { + data []byte + writtenData []byte + readErr error + writeErr error + err error + }{ + "no data": { + writtenData: []byte("nameserver 127.0.0.1"), + }, + "read error": { + readErr: fmt.Errorf("error"), + err: fmt.Errorf("error"), + }, + "write error": { + writtenData: []byte("nameserver 127.0.0.1"), + writeErr: fmt.Errorf("error"), + err: fmt.Errorf("error"), + }, + "lines without nameserver": { + data: []byte("abc\ndef\n"), + writtenData: []byte("abc\ndef\nnameserver 127.0.0.1"), + }, + "lines with nameserver": { + data: []byte("abc\nnameserver abc def\ndef\n"), + writtenData: []byte("abc\nnameserver 127.0.0.1\ndef"), + }, + } + for name, tc := range tests { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + fileManager := &filesmocks.FileManager{} + fileManager.On("ReadFile", string(constants.ResolvConf)). + Return(tc.data, tc.readErr).Once() + if tc.readErr == nil { + fileManager.On("WriteToFile", string(constants.ResolvConf), tc.writtenData). + Return(tc.writeErr).Once() + } + logger := &loggingmocks.Logger{} + logger.On("Info", "%s: setting local nameserver to 127.0.0.1", logPrefix).Once() + c := &configurator{ + fileManager: fileManager, + logger: logger, + } + err := c.SetLocalNameserver() + if tc.err != nil { + require.Error(t, err) + assert.Equal(t, tc.err.Error(), err.Error()) + } else { + assert.NoError(t, err) + } + fileManager.AssertExpectations(t) + logger.AssertExpectations(t) + }) + } +} diff --git a/internal/dns/roots.go b/internal/dns/roots.go new file mode 100644 index 00000000..ad15a996 --- /dev/null +++ b/internal/dns/roots.go @@ -0,0 +1,38 @@ +package dns + +import ( + "fmt" + + "github.com/qdm12/golibs/files" + "github.com/qdm12/private-internet-access-docker/internal/constants" +) + +func (c *configurator) DownloadRootHints(uid, gid int) error { + c.logger.Info("%s: downloading root hints from %s", logPrefix, constants.NamedRootURL) + content, status, err := c.client.GetContent(string(constants.NamedRootURL)) + if err != nil { + return err + } else if status != 200 { + return fmt.Errorf("HTTP status code is %d for %s", status, constants.NamedRootURL) + } + return c.fileManager.WriteToFile( + string(constants.RootHints), + content, + files.FileOwnership(uid, gid), + files.FilePermissions(0400)) +} + +func (c *configurator) DownloadRootKey(uid, gid int) error { + c.logger.Info("%s: downloading root key from %s", logPrefix, constants.RootKeyURL) + content, status, err := c.client.GetContent(string(constants.RootKeyURL)) + if err != nil { + return err + } else if status != 200 { + return fmt.Errorf("HTTP status code is %d for %s", status, constants.RootKeyURL) + } + return c.fileManager.WriteToFile( + string(constants.RootKey), + content, + files.FileOwnership(uid, gid), + files.FilePermissions(0400)) +} diff --git a/internal/dns/roots_test.go b/internal/dns/roots_test.go new file mode 100644 index 00000000..5a84a7d3 --- /dev/null +++ b/internal/dns/roots_test.go @@ -0,0 +1,144 @@ +package dns + +import ( + "fmt" + "net/http" + "testing" + + filesMocks "github.com/qdm12/golibs/files/mocks" + loggingMocks "github.com/qdm12/golibs/logging/mocks" + networkMocks "github.com/qdm12/golibs/network/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/qdm12/private-internet-access-docker/internal/constants" +) + +func Test_DownloadRootHints(t *testing.T) { + t.Parallel() + tests := map[string]struct { + content []byte + status int + clientErr error + writeErr error + err error + }{ + "no data": { + status: http.StatusOK, + }, + "bad status": { + status: http.StatusBadRequest, + err: fmt.Errorf("HTTP status code is 400 for https://raw.githubusercontent.com/qdm12/files/master/named.root.updated"), + }, + "client error": { + clientErr: fmt.Errorf("error"), + err: fmt.Errorf("error"), + }, + "write error": { + status: http.StatusOK, + writeErr: fmt.Errorf("error"), + err: fmt.Errorf("error"), + }, + "data": { + content: []byte("content"), + status: http.StatusOK, + }, + } + for name, tc := range tests { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + logger := &loggingMocks.Logger{} + logger.On("Info", "%s: downloading root hints from %s", logPrefix, constants.NamedRootURL).Once() + client := &networkMocks.Client{} + client.On("GetContent", string(constants.NamedRootURL)). + Return(tc.content, tc.status, tc.clientErr).Once() + fileManager := &filesMocks.FileManager{} + if tc.clientErr == nil && tc.status == http.StatusOK { + fileManager.On( + "WriteToFile", + string(constants.RootHints), + tc.content, + mock.AnythingOfType("files.WriteOptionSetter"), + mock.AnythingOfType("files.WriteOptionSetter")). + Return(tc.writeErr).Once() + } + c := &configurator{logger: logger, client: client, fileManager: fileManager} + err := c.DownloadRootHints(1000, 1000) + if tc.err != nil { + require.Error(t, err) + assert.Equal(t, tc.err.Error(), err.Error()) + } else { + assert.NoError(t, err) + } + logger.AssertExpectations(t) + client.AssertExpectations(t) + fileManager.AssertExpectations(t) + }) + } +} + +func Test_DownloadRootKey(t *testing.T) { + t.Parallel() + tests := map[string]struct { + content []byte + status int + clientErr error + writeErr error + err error + }{ + "no data": { + status: http.StatusOK, + }, + "bad status": { + status: http.StatusBadRequest, + err: fmt.Errorf("HTTP status code is 400 for https://raw.githubusercontent.com/qdm12/files/master/root.key.updated"), + }, + "client error": { + clientErr: fmt.Errorf("error"), + err: fmt.Errorf("error"), + }, + "write error": { + status: http.StatusOK, + writeErr: fmt.Errorf("error"), + err: fmt.Errorf("error"), + }, + "data": { + content: []byte("content"), + status: http.StatusOK, + }, + } + for name, tc := range tests { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + logger := &loggingMocks.Logger{} + logger.On("Info", "%s: downloading root key from %s", logPrefix, constants.RootKeyURL).Once() + client := &networkMocks.Client{} + client.On("GetContent", string(constants.RootKeyURL)). + Return(tc.content, tc.status, tc.clientErr).Once() + fileManager := &filesMocks.FileManager{} + if tc.clientErr == nil && tc.status == http.StatusOK { + fileManager.On( + "WriteToFile", + string(constants.RootKey), + tc.content, + mock.AnythingOfType("files.WriteOptionSetter"), + mock.AnythingOfType("files.WriteOptionSetter"), + ).Return(tc.writeErr).Once() + } + c := &configurator{logger: logger, client: client, fileManager: fileManager} + err := c.DownloadRootKey(1000, 1001) + if tc.err != nil { + require.Error(t, err) + assert.Equal(t, tc.err.Error(), err.Error()) + } else { + assert.NoError(t, err) + } + logger.AssertExpectations(t) + client.AssertExpectations(t) + fileManager.AssertExpectations(t) + }) + } +} diff --git a/internal/env/env.go b/internal/env/env.go new file mode 100644 index 00000000..46643388 --- /dev/null +++ b/internal/env/env.go @@ -0,0 +1,40 @@ +package env + +import ( + "os" + + "github.com/qdm12/golibs/logging" +) + +type Env interface { + FatalOnError(err error) + PrintVersion(program string, commandFn func() (string, error)) +} + +type env struct { + logger logging.Logger + osExit func(n int) +} + +func New(logger logging.Logger) Env { + return &env{ + logger: logger, + osExit: os.Exit, + } +} + +func (e *env) FatalOnError(err error) { + if err != nil { + e.logger.Error(err) + e.osExit(1) + } +} + +func (e *env) PrintVersion(program string, commandFn func() (string, error)) { + version, err := commandFn() + if err != nil { + e.logger.Error(err) + } else { + e.logger.Info("%s version: %s", program, version) + } +} diff --git a/internal/env/env_test.go b/internal/env/env_test.go new file mode 100644 index 00000000..5a585f02 --- /dev/null +++ b/internal/env/env_test.go @@ -0,0 +1,90 @@ +package env + +import ( + "fmt" + "testing" + + "github.com/qdm12/golibs/logging/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func Test_FatalOnError(t *testing.T) { + t.Parallel() + tests := map[string]struct { + err error + }{ + "nil": {}, + "err": {fmt.Errorf("error")}, + } + for name, tc := range tests { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + var logged string + var exitCode int + logger := &mocks.Logger{} + if tc.err != nil { + logger.On("Error", tc.err). + Run(func(args mock.Arguments) { + err := args.Get(0).(error) + logged = err.Error() + }).Once() + } + osExit := func(n int) { exitCode = n } + e := &env{logger, osExit} + e.FatalOnError(tc.err) + if tc.err != nil { + assert.Equal(t, logged, tc.err.Error()) + assert.Equal(t, exitCode, 1) + } else { + assert.Empty(t, logged) + assert.Zero(t, exitCode) + } + }) + } +} + +func Test_PrintVersion(t *testing.T) { + t.Parallel() + tests := map[string]struct { + program string + commandVersion string + commandErr error + }{ + "no data": {}, + "data": {"binu", "2.3-5", nil}, + "error": {"binu", "", fmt.Errorf("error")}, + } + for name, tc := range tests { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + var logged string + logger := &mocks.Logger{} + if tc.commandErr != nil { + logger.On("Error", tc.commandErr). + Run(func(args mock.Arguments) { + err := args.Get(0).(error) + logged = err.Error() + }).Once() + } else { + logger.On("Info", "%s version: %s", tc.program, tc.commandVersion). + Run(func(args mock.Arguments) { + format := args.Get(0).(string) + program := args.Get(1).(string) + version := args.Get(2).(string) + logged = fmt.Sprintf(format, program, version) + }).Once() + } + e := &env{logger: logger} + commandFn := func() (string, error) { return tc.commandVersion, tc.commandErr } + e.PrintVersion(tc.program, commandFn) + if tc.commandErr != nil { + assert.Equal(t, logged, tc.commandErr.Error()) + } else { + assert.Equal(t, logged, fmt.Sprintf("%s version: %s", tc.program, tc.commandVersion)) + } + }) + } +} diff --git a/internal/firewall/firewall.go b/internal/firewall/firewall.go new file mode 100644 index 00000000..2c191036 --- /dev/null +++ b/internal/firewall/firewall.go @@ -0,0 +1,42 @@ +package firewall + +import ( + "net" + + "github.com/qdm12/golibs/command" + "github.com/qdm12/golibs/files" + "github.com/qdm12/golibs/logging" + "github.com/qdm12/private-internet-access-docker/internal/models" +) + +const logPrefix = "firewall configurator" + +// Configurator allows to change firewall rules and modify network routes +type Configurator interface { + Version() (string, error) + AcceptAll() error + Clear() error + BlockAll() error + CreateGeneralRules() error + CreateVPNRules(dev models.VPNDevice, serverIPs []net.IP, defaultInterface string, + port uint16, protocol models.NetworkProtocol) error + CreateLocalSubnetsRules(subnet net.IPNet, extraSubnets []net.IPNet, defaultInterface string) error + AddRoutesVia(subnets []net.IPNet, defaultGateway net.IP, defaultInterface string) error + GetDefaultRoute() (defaultInterface string, defaultGateway net.IP, defaultSubnet net.IPNet, err error) + AllowInputTrafficOnPort(device models.VPNDevice, port uint16) error +} + +type configurator struct { + commander command.Commander + logger logging.Logger + fileManager files.FileManager +} + +// NewConfigurator creates a new Configurator instance +func NewConfigurator(logger logging.Logger, fileManager files.FileManager) Configurator { + return &configurator{ + commander: command.NewCommander(), + logger: logger, + fileManager: fileManager, + } +} diff --git a/internal/firewall/iptables.go b/internal/firewall/iptables.go new file mode 100644 index 00000000..7aa4d32a --- /dev/null +++ b/internal/firewall/iptables.go @@ -0,0 +1,130 @@ +package firewall + +import ( + "fmt" + "net" + "strings" + + "github.com/qdm12/private-internet-access-docker/internal/models" +) + +// Version obtains the version of the installed iptables +func (c *configurator) Version() (string, error) { + output, err := c.commander.Run("iptables", "--version") + if err != nil { + return "", err + } + words := strings.Fields(output) + if len(words) < 2 { + return "", fmt.Errorf("iptables --version: output is too short: %q", output) + } + return words[1], nil +} + +func (c *configurator) runIptablesInstructions(instructions []string) error { + for _, instruction := range instructions { + if err := c.runIptablesInstruction(instruction); err != nil { + return err + } + } + return nil +} + +func (c *configurator) runIptablesInstruction(instruction string) error { + flags := strings.Fields(instruction) + if output, err := c.commander.Run("iptables", flags...); err != nil { + return fmt.Errorf("failed executing %q: %s: %w", instruction, output, err) + } + return nil +} + +func (c *configurator) Clear() error { + c.logger.Info("%s: clearing all rules", logPrefix) + return c.runIptablesInstructions([]string{ + "--flush", + "--delete-chain", + "-t nat --flush", + "-t nat --delete-chain", + }) +} + +func (c *configurator) AcceptAll() error { + c.logger.Info("%s: accepting all traffic", logPrefix) + return c.runIptablesInstructions([]string{ + "-P INPUT ACCEPT", + "-P OUTPUT ACCEPT", + "-P FORWARD ACCEPT", + }) +} + +func (c *configurator) BlockAll() error { + c.logger.Info("%s: blocking all traffic", logPrefix) + return c.runIptablesInstructions([]string{ + "-P INPUT DROP", + "-F OUTPUT", + "-P OUTPUT DROP", + "-P FORWARD DROP", + }) +} + +func (c *configurator) CreateGeneralRules() error { + c.logger.Info("%s: creating general rules", logPrefix) + return c.runIptablesInstructions([]string{ + "-A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT", + "-A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT", + "-A OUTPUT -o lo -j ACCEPT", + "-A INPUT -i lo -j ACCEPT", + }) +} + +func (c *configurator) CreateVPNRules(dev models.VPNDevice, serverIPs []net.IP, + defaultInterface string, port uint16, protocol models.NetworkProtocol) error { + for _, serverIP := range serverIPs { + c.logger.Info("%s: allowing output traffic to VPN server %s through %s on port %s %d", + logPrefix, serverIP, defaultInterface, protocol, port) + if err := c.runIptablesInstruction( + fmt.Sprintf("-A OUTPUT -d %s -o %s -p %s -m %s --dport %d -j ACCEPT", + serverIP, defaultInterface, protocol, protocol, port)); err != nil { + return err + } + } + if err := c.runIptablesInstruction(fmt.Sprintf("-A OUTPUT -o %s -j ACCEPT", dev)); err != nil { + return err + } + return nil +} + +func (c *configurator) CreateLocalSubnetsRules(subnet net.IPNet, extraSubnets []net.IPNet, defaultInterface string) error { + subnetStr := subnet.String() + c.logger.Info("%s: accepting input and output traffic for %s", logPrefix, subnetStr) + if err := c.runIptablesInstructions([]string{ + fmt.Sprintf("-A INPUT -s %s -d %s -j ACCEPT", subnetStr, subnetStr), + fmt.Sprintf("-A OUTPUT -s %s -d %s -j ACCEPT", subnetStr, subnetStr), + }); err != nil { + return err + } + for _, extraSubnet := range extraSubnets { + extraSubnetStr := extraSubnet.String() + c.logger.Info("%s: accepting input traffic through %s from %s to %s", logPrefix, defaultInterface, extraSubnetStr, subnetStr) + if err := c.runIptablesInstruction( + fmt.Sprintf("-A INPUT -i %s -s %s -d %s -j ACCEPT", defaultInterface, extraSubnetStr, subnetStr)); err != nil { + return err + } + // Thanks to @npawelek + c.logger.Info("%s: accepting output traffic through %s from %s to %s", logPrefix, defaultInterface, subnetStr, extraSubnetStr) + if err := c.runIptablesInstruction( + fmt.Sprintf("-A OUTPUT -o %s -s %s -d %s -j ACCEPT", defaultInterface, subnetStr, extraSubnetStr)); err != nil { + return err + } + } + return nil +} + +// Used for port forwarding +func (c *configurator) AllowInputTrafficOnPort(device models.VPNDevice, port uint16) error { + c.logger.Info("%s: accepting input traffic through %s on port %d", logPrefix, device, port) + return c.runIptablesInstructions([]string{ + fmt.Sprintf("-A INPUT -i %s -p tcp --dport %d -j ACCEPT", device, port), + fmt.Sprintf("-A INPUT -i %s -p udp --dport %d -j ACCEPT", device, port), + }) +} diff --git a/internal/firewall/route.go b/internal/firewall/route.go new file mode 100644 index 00000000..28b87b8f --- /dev/null +++ b/internal/firewall/route.go @@ -0,0 +1,88 @@ +package firewall + +import ( + "encoding/hex" + "net" + + "fmt" + "strings" + + "github.com/qdm12/private-internet-access-docker/internal/constants" +) + +func (c *configurator) AddRoutesVia(subnets []net.IPNet, defaultGateway net.IP, defaultInterface string) error { + for _, subnet := range subnets { + subnetStr := subnet.String() + output, err := c.commander.Run("ip", "route", "show", subnetStr) + if err != nil { + return fmt.Errorf("cannot read route %s: %s: %w", subnetStr, output, err) + } else if len(output) > 0 { // thanks to @npawelek https://github.com/npawelek + continue // already exists + // TODO remove it instead and continue execution below + } + c.logger.Info("%s: adding %s as route via %s", logPrefix, subnetStr, defaultInterface) + output, err = c.commander.Run("ip", "route", "add", subnetStr, "via", defaultGateway.String(), "dev", defaultInterface) + if err != nil { + return fmt.Errorf("cannot add route for %s via %s %s %s: %s: %w", subnetStr, defaultGateway.String(), "dev", defaultInterface, output, err) + } + } + return nil +} + +func (c *configurator) GetDefaultRoute() (defaultInterface string, defaultGateway net.IP, defaultSubnet net.IPNet, err error) { + c.logger.Info("%s: detecting default network route", logPrefix) + data, err := c.fileManager.ReadFile(string(constants.NetRoute)) + if err != nil { + return "", nil, defaultSubnet, err + } + // Verify number of lines and fields + lines := strings.Split(string(data), "\n") + if len(lines) < 3 { + return "", nil, defaultSubnet, fmt.Errorf("not enough lines (%d) found in %s", len(lines), constants.NetRoute) + } + fieldsLine1 := strings.Fields(lines[1]) + if len(fieldsLine1) < 3 { + return "", nil, defaultSubnet, fmt.Errorf("not enough fields in %q", lines[1]) + } + fieldsLine2 := strings.Fields(lines[2]) + if len(fieldsLine2) < 8 { + return "", nil, defaultSubnet, fmt.Errorf("not enough fields in %q", lines[2]) + } + // get information + defaultInterface = fieldsLine1[0] + defaultGateway, err = reversedHexToIPv4(fieldsLine1[2]) + if err != nil { + return "", nil, defaultSubnet, err + } + netNumber, err := reversedHexToIPv4(fieldsLine2[1]) + if err != nil { + return "", nil, defaultSubnet, err + } + netMask, err := hexToIPv4Mask(fieldsLine2[7]) + if err != nil { + return "", nil, defaultSubnet, err + } + subnet := net.IPNet{IP: netNumber, Mask: netMask} + c.logger.Info("%s: default route found: interface %s, gateway %s, subnet %s", logPrefix, defaultInterface, defaultGateway.String(), subnet.String()) + return defaultInterface, defaultGateway, subnet, nil +} + +func reversedHexToIPv4(reversedHex string) (IP net.IP, err error) { + bytes, err := hex.DecodeString(reversedHex) + if err != nil { + return nil, fmt.Errorf("cannot parse reversed IP hex %q: %s", reversedHex, err) + } else if len(bytes) != 4 { + return nil, fmt.Errorf("hex string contains %d bytes instead of 4", len(bytes)) + } + return []byte{bytes[3], bytes[2], bytes[1], bytes[0]}, nil +} + +func hexToIPv4Mask(hexString string) (mask net.IPMask, err error) { + bytes, err := hex.DecodeString(hexString) + if err != nil { + return nil, fmt.Errorf("cannot parse hex mask %q: %s", hexString, err) + } else if len(bytes) != 4 { + return nil, fmt.Errorf("hex string contains %d bytes instead of 4", len(bytes)) + } + return []byte{bytes[3], bytes[2], bytes[1], bytes[0]}, nil +} diff --git a/internal/firewall/route_test.go b/internal/firewall/route_test.go new file mode 100644 index 00000000..e1f2292f --- /dev/null +++ b/internal/firewall/route_test.go @@ -0,0 +1,171 @@ +package firewall + +import ( + "fmt" + "net" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + filesmocks "github.com/qdm12/golibs/files/mocks" + loggingmocks "github.com/qdm12/golibs/logging/mocks" + "github.com/qdm12/private-internet-access-docker/internal/constants" +) + +func Test_getDefaultRoute(t *testing.T) { + t.Parallel() + tests := map[string]struct { + data []byte + readErr error + defaultInterface string + defaultGateway net.IP + defaultSubnet net.IPNet + err error + }{ + "no data": { + err: fmt.Errorf("not enough lines (1) found in %s", constants.NetRoute)}, + "read error": { + readErr: fmt.Errorf("error"), + err: fmt.Errorf("error")}, + "not enough fields line 1": { + data: []byte(`Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT +eth0 00000000 +eth0 000011AC 00000000 0001 0 0 0 0000FFFF 0 0 0`), + err: fmt.Errorf("not enough fields in \"eth0 00000000\"")}, + "not enough fields line 2": { + data: []byte(`Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT +eth0 00000000 010011AC 0003 0 0 0 00000000 0 0 0 +eth0 000011AC 00000000 0001 0 0 0`), + err: fmt.Errorf("not enough fields in \"eth0 000011AC 00000000 0001 0 0 0\"")}, + "bad gateway": { + data: []byte(`Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT +eth0 00000000 x 0003 0 0 0 00000000 0 0 0 +eth0 000011AC 00000000 0001 0 0 0 0000FFFF 0 0 0`), + err: fmt.Errorf("cannot parse reversed IP hex \"x\": encoding/hex: invalid byte: U+0078 'x'")}, + "bad net number": { + data: []byte(`Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT +eth0 00000000 010011AC 0003 0 0 0 00000000 0 0 0 +eth0 x 00000000 0001 0 0 0 0000FFFF 0 0 0`), + err: fmt.Errorf("cannot parse reversed IP hex \"x\": encoding/hex: invalid byte: U+0078 'x'")}, + "bad net mask": { + data: []byte(`Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT +eth0 00000000 010011AC 0003 0 0 0 00000000 0 0 0 +eth0 000011AC 00000000 0001 0 0 0 x 0 0 0`), + err: fmt.Errorf("cannot parse hex mask \"x\": encoding/hex: invalid byte: U+0078 'x'")}, + "success": { + data: []byte(`Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT +eth0 00000000 010011AC 0003 0 0 0 00000000 0 0 0 +eth0 000011AC 00000000 0001 0 0 0 0000FFFF 0 0 0`), + defaultInterface: "eth0", + defaultGateway: net.IP{0xac, 0x11, 0x0, 0x1}, + defaultSubnet: net.IPNet{ + IP: net.IP{0xac, 0x11, 0x0, 0x0}, + Mask: net.IPMask{0xff, 0xff, 0x0, 0x0}, + }}, + } + for name, tc := range tests { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + fileManager := &filesmocks.FileManager{} + fileManager.On("ReadFile", string(constants.NetRoute)). + Return(tc.data, tc.readErr).Once() + logger := &loggingmocks.Logger{} + logger.On("Info", "%s: detecting default network route", logPrefix).Once() + if tc.err == nil { + logger.On("Info", "%s: default route found: interface %s, gateway %s, subnet %s", + logPrefix, tc.defaultInterface, tc.defaultGateway.String(), tc.defaultSubnet.String()).Once() + } + c := &configurator{logger: logger, fileManager: fileManager} + defaultInterface, defaultGateway, defaultSubnet, err := c.GetDefaultRoute() + if tc.err != nil { + require.Error(t, err) + assert.Equal(t, tc.err.Error(), err.Error()) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tc.defaultInterface, defaultInterface) + assert.Equal(t, tc.defaultGateway, defaultGateway) + assert.Equal(t, tc.defaultSubnet, defaultSubnet) + fileManager.AssertExpectations(t) + logger.AssertExpectations(t) + }) + } +} + +func Test_reversedHexToIPv4(t *testing.T) { + t.Parallel() + tests := map[string]struct { + reversedHex string + IP net.IP + err error + }{ + "empty hex": { + err: fmt.Errorf("hex string contains 0 bytes instead of 4")}, + "bad hex": { + reversedHex: "x", + err: fmt.Errorf("cannot parse reversed IP hex \"x\": encoding/hex: invalid byte: U+0078 'x'")}, + "3 bytes hex": { + reversedHex: "9abcde", + err: fmt.Errorf("hex string contains 3 bytes instead of 4")}, + "correct hex": { + reversedHex: "010011AC", + IP: []byte{0xac, 0x11, 0x0, 0x1}, + err: nil}, + "correct hex 2": { + reversedHex: "000011AC", + IP: []byte{0xac, 0x11, 0x0, 0x0}, + err: nil}, + } + for name, tc := range tests { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + IP, err := reversedHexToIPv4(tc.reversedHex) + if tc.err != nil { + require.Error(t, err) + assert.Equal(t, tc.err.Error(), err.Error()) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tc.IP, IP) + }) + } +} + +func Test_hexMaskToDecMask(t *testing.T) { + t.Parallel() + tests := map[string]struct { + hexString string + mask net.IPMask + err error + }{ + "empty hex": { + err: fmt.Errorf("hex string contains 0 bytes instead of 4")}, + "bad hex": { + hexString: "x", + err: fmt.Errorf("cannot parse hex mask \"x\": encoding/hex: invalid byte: U+0078 'x'")}, + "3 bytes hex": { + hexString: "9abcde", + err: fmt.Errorf("hex string contains 3 bytes instead of 4")}, + "16": { + hexString: "0000FFFF", + mask: []byte{0xff, 0xff, 0x0, 0x0}, + err: nil}, + } + for name, tc := range tests { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + mask, err := hexToIPv4Mask(tc.hexString) + if tc.err != nil { + require.Error(t, err) + assert.Equal(t, tc.err.Error(), err.Error()) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tc.mask, mask) + }) + } +} diff --git a/internal/models/alias.go b/internal/models/alias.go new file mode 100644 index 00000000..e6c9df33 --- /dev/null +++ b/internal/models/alias.go @@ -0,0 +1,24 @@ +package models + +type ( + // VPNDevice is the device name used to tunnel using Openvpn + VPNDevice string + // DNSProvider is a DNS over TLS server provider name + DNSProvider string + // DNSForwardAddress is the Unbound formatted forward address + DNSForwardAddress string + // PIAEncryption defines the level of encryption for communication with PIA servers + PIAEncryption string + // PIARegion contains the list of regions available for PIA + PIARegion string + // URL is an HTTP(s) URL address + URL string + // Filepath is a local filesytem file path + Filepath string + // TinyProxyLogLevel is the log level for TinyProxy + TinyProxyLogLevel string + // VPNProvider is the name of the VPN provider to be used + VPNProvider string + // NetworkProtocol contains the network protocol to be used to communicate with the VPN servers + NetworkProtocol string +) diff --git a/internal/openvpn/auth.go b/internal/openvpn/auth.go new file mode 100644 index 00000000..0e022f2d --- /dev/null +++ b/internal/openvpn/auth.go @@ -0,0 +1,23 @@ +package openvpn + +import ( + "github.com/qdm12/golibs/files" + "github.com/qdm12/private-internet-access-docker/internal/constants" +) + +// WriteAuthFile writes the OpenVPN auth file to disk with the right permissions +func (c *configurator) WriteAuthFile(user, password string, uid, gid int) error { + authExists, err := c.fileManager.FileExists(string(constants.OpenVPNAuthConf)) + if err != nil { + return err + } else if authExists { // in case of container stop/start + c.logger.Info("%s: %s already exists", logPrefix, constants.OpenVPNAuthConf) + return nil + } + c.logger.Info("%s: writing auth file %s", logPrefix, constants.OpenVPNAuthConf) + return c.fileManager.WriteLinesToFile( + string(constants.OpenVPNAuthConf), + []string{user, password}, + files.FileOwnership(uid, gid), + files.FilePermissions(0400)) +} diff --git a/internal/openvpn/command.go b/internal/openvpn/command.go new file mode 100644 index 00000000..947c86c3 --- /dev/null +++ b/internal/openvpn/command.go @@ -0,0 +1,28 @@ +package openvpn + +import ( + "fmt" + "io" + "strings" + + "github.com/qdm12/private-internet-access-docker/internal/constants" +) + +func (c *configurator) Start() (stdout io.ReadCloser, err error) { + c.logger.Info("%s: starting openvpn", logPrefix) + stdout, _, _, err = c.commander.Start("openvpn", "--config", string(constants.OpenVPNConf)) + return stdout, err +} + +func (c *configurator) Version() (string, error) { + output, err := c.commander.Run("openvpn", "--version") + if err != nil && err.Error() != "exit status 1" { + return "", err + } + firstLine := strings.Split(output, "\n")[0] + words := strings.Fields(firstLine) + if len(words) < 2 { + return "", fmt.Errorf("openvpn --version: first line is too short: %q", firstLine) + } + return words[1], nil +} diff --git a/internal/openvpn/openvpn.go b/internal/openvpn/openvpn.go new file mode 100644 index 00000000..c7bfa141 --- /dev/null +++ b/internal/openvpn/openvpn.go @@ -0,0 +1,35 @@ +package openvpn + +import ( + "io" + "os" + + "github.com/qdm12/golibs/command" + "github.com/qdm12/golibs/files" + "github.com/qdm12/golibs/logging" +) + +const logPrefix = "openvpn configurator" + +type Configurator interface { + Version() (string, error) + WriteAuthFile(user, password string, uid, gid int) error + CheckTUN() error + Start() (stdout io.ReadCloser, err error) +} + +type configurator struct { + fileManager files.FileManager + logger logging.Logger + commander command.Commander + openFile func(name string, flag int, perm os.FileMode) (*os.File, error) +} + +func NewConfigurator(logger logging.Logger, fileManager files.FileManager) Configurator { + return &configurator{ + fileManager: fileManager, + logger: logger, + commander: command.NewCommander(), + openFile: os.OpenFile, + } +} diff --git a/internal/openvpn/tun.go b/internal/openvpn/tun.go new file mode 100644 index 00000000..5ccf7d39 --- /dev/null +++ b/internal/openvpn/tun.go @@ -0,0 +1,21 @@ +package openvpn + +import ( + "fmt" + "os" + + "github.com/qdm12/private-internet-access-docker/internal/constants" +) + +// CheckTUN checks the tunnel device is present and accessible +func (c *configurator) CheckTUN() error { + c.logger.Info("%s: checking for device %s", logPrefix, constants.TunnelDevice) + f, err := c.openFile(string(constants.TunnelDevice), os.O_RDWR, 0) + if err != nil { + return fmt.Errorf("TUN device is not available: %w", err) + } + if err := f.Close(); err != nil { + c.logger.Warn("Could not close TUN device file: %s", err) + } + return nil +} diff --git a/internal/params/dns.go b/internal/params/dns.go new file mode 100644 index 00000000..1a60e622 --- /dev/null +++ b/internal/params/dns.go @@ -0,0 +1,93 @@ +package params + +import ( + "fmt" + "strings" + + libparams "github.com/qdm12/golibs/params" + "github.com/qdm12/private-internet-access-docker/internal/constants" + "github.com/qdm12/private-internet-access-docker/internal/models" +) + +// GetDNSOverTLS obtains if the DNS over TLS should be enabled +// from the environment variable DOT +func (p *paramsReader) GetDNSOverTLS() (DNSOverTLS bool, err error) { + return p.envParams.GetOnOff("DOT", libparams.Default("on")) +} + +// GetDNSOverTLSProviders obtains the DNS over TLS providers to use +// from the environment variable DOT_PROVIDERS +func (p *paramsReader) GetDNSOverTLSProviders() (providers []models.DNSProvider, err error) { + s, err := p.envParams.GetEnv("DOT_PROVIDERS", libparams.Default("cloudflare")) + if err != nil { + return nil, err + } + for _, word := range strings.Split(s, ",") { + provider := models.DNSProvider(word) + switch provider { + case constants.Cloudflare, constants.Google, constants.Quad9, constants.Quadrant, constants.CleanBrowsing, constants.SecureDNS, constants.LibreDNS: + providers = append(providers, provider) + default: + return nil, fmt.Errorf("DNS over TLS provider %q is not valid", provider) + } + } + return providers, nil +} + +// GetDNSOverTLSVerbosity obtains the verbosity level to use for Unbound +// from the environment variable DOT_VERBOSITY +func (p *paramsReader) GetDNSOverTLSVerbosity() (verbosityLevel uint8, err error) { + n, err := p.envParams.GetEnvIntRange("DOT_VERBOSITY", 0, 5, libparams.Default("1")) + return uint8(n), err +} + +// GetDNSOverTLSVerbosityDetails obtains the log level to use for Unbound +// from the environment variable DOT_VERBOSITY_DETAILS +func (p *paramsReader) GetDNSOverTLSVerbosityDetails() (verbosityDetailsLevel uint8, err error) { + n, err := p.envParams.GetEnvIntRange("DOT_VERBOSITY_DETAILS", 0, 4, libparams.Default("0")) + return uint8(n), err +} + +// GetDNSOverTLSValidationLogLevel obtains the log level to use for Unbound DOT validation +// from the environment variable DOT_VALIDATION_LOGLEVEL +func (p *paramsReader) GetDNSOverTLSValidationLogLevel() (validationLogLevel uint8, err error) { + n, err := p.envParams.GetEnvIntRange("DOT_VALIDATION_LOGLEVEL", 0, 2, libparams.Default("0")) + return uint8(n), err +} + +// GetDNSMaliciousBlocking obtains if malicious hostnames/IPs should be blocked +// from being resolved by Unbound, using the environment variable BLOCK_MALICIOUS +func (p *paramsReader) GetDNSMaliciousBlocking() (blocking bool, err error) { + return p.envParams.GetOnOff("BLOCK_MALICIOUS", libparams.Default("on")) +} + +// GetDNSSurveillanceBlocking obtains if surveillance hostnames/IPs should be blocked +// from being resolved by Unbound, using the environment variable BLOCK_NSA +func (p *paramsReader) GetDNSSurveillanceBlocking() (blocking bool, err error) { + return p.envParams.GetOnOff("BLOCK_NSA", libparams.Default("off")) +} + +// GetDNSAdsBlocking obtains if ads hostnames/IPs should be blocked +// from being resolved by Unbound, using the environment variable BLOCK_ADS +func (p *paramsReader) GetDNSAdsBlocking() (blocking bool, err error) { + return p.envParams.GetOnOff("BLOCK_ADS", libparams.Default("off")) +} + +// GetDNSUnblockedHostnames obtains a list of hostnames to unblock from block lists +// from the comma separated list for the environment variable UNBLOCK +func (p *paramsReader) GetDNSUnblockedHostnames() (hostnames []string, err error) { + s, err := p.envParams.GetEnv("UNBLOCK") + if err != nil { + return nil, err + } + if len(s) == 0 { + return nil, nil + } + hostnames = strings.Split(s, ",") + for _, hostname := range hostnames { + if !p.verifier.MatchHostname(hostname) { + return nil, fmt.Errorf("hostname %q does not seem valid", hostname) + } + } + return hostnames, nil +} diff --git a/internal/params/firewall.go b/internal/params/firewall.go new file mode 100644 index 00000000..2618ea89 --- /dev/null +++ b/internal/params/firewall.go @@ -0,0 +1,29 @@ +package params + +import ( + "fmt" + "net" + "strings" +) + +// GetExtraSubnets obtains the CIDR subnets from the comma separated list of the +// environment variable EXTRA_SUBNETS +func (p *paramsReader) GetExtraSubnets() (extraSubnets []net.IPNet, err error) { + s, err := p.envParams.GetEnv("EXTRA_SUBNETS") + if err != nil { + return nil, err + } else if s == "" { + return nil, nil + } + subnets := strings.Split(s, ",") + for _, subnet := range subnets { + _, cidr, err := net.ParseCIDR(subnet) + if err != nil { + return nil, fmt.Errorf("could not parse subnet %q from environment variable with key EXTRA_SUBNETS: %w", subnet, err) + } else if cidr == nil { + return nil, fmt.Errorf("parsing subnet %q resulted in a nil CIDR", subnet) + } + extraSubnets = append(extraSubnets, *cidr) + } + return extraSubnets, nil +} diff --git a/internal/params/openvpn.go b/internal/params/openvpn.go new file mode 100644 index 00000000..4d589a36 --- /dev/null +++ b/internal/params/openvpn.go @@ -0,0 +1,13 @@ +package params + +import ( + libparams "github.com/qdm12/golibs/params" + "github.com/qdm12/private-internet-access-docker/internal/models" +) + +// GetNetworkProtocol obtains the network protocol to use to connect to the +// VPN servers from the environment variable PROTOCOL +func (p *paramsReader) GetNetworkProtocol() (protocol models.NetworkProtocol, err error) { + s, err := p.envParams.GetValueIfInside("PROTOCOL", []string{"tcp", "udp"}, libparams.Default("udp")) + return models.NetworkProtocol(s), err +} diff --git a/internal/params/params.go b/internal/params/params.go new file mode 100644 index 00000000..f493c351 --- /dev/null +++ b/internal/params/params.go @@ -0,0 +1,73 @@ +package params + +import ( + "net" + "os" + + "github.com/qdm12/golibs/logging" + libparams "github.com/qdm12/golibs/params" + "github.com/qdm12/golibs/verification" + "github.com/qdm12/private-internet-access-docker/internal/models" +) + +// ParamsReader contains methods to obtain parameters +type ParamsReader interface { + // DNS over TLS getters + GetDNSOverTLS() (DNSOverTLS bool, err error) + GetDNSOverTLSProviders() (providers []models.DNSProvider, err error) + GetDNSOverTLSVerbosity() (verbosityLevel uint8, err error) + GetDNSOverTLSVerbosityDetails() (verbosityDetailsLevel uint8, err error) + GetDNSOverTLSValidationLogLevel() (validationLogLevel uint8, err error) + GetDNSMaliciousBlocking() (blocking bool, err error) + GetDNSSurveillanceBlocking() (blocking bool, err error) + GetDNSAdsBlocking() (blocking bool, err error) + GetDNSUnblockedHostnames() (hostnames []string, err error) + + // Firewall getters + GetExtraSubnets() (extraSubnets []net.IPNet, err error) + + // VPN getters + GetNetworkProtocol() (protocol models.NetworkProtocol, err error) + + // PIA getters + GetUser() (s string, err error) + GetPassword() (s string, err error) + GetPortForwarding() (activated bool, err error) + GetPortForwardingStatusFilepath() (filepath models.Filepath, err error) + GetPIAEncryption() (models.PIAEncryption, error) + GetPIARegion() (models.PIARegion, error) + + // Shadowsocks getters + GetShadowSocks() (activated bool, err error) + GetShadowSocksLog() (activated bool, err error) + GetShadowSocksPort() (port uint16, err error) + GetShadowSocksPassword() (password string, err error) + + // Tinyproxy getters + GetTinyProxy() (activated bool, err error) + GetTinyProxyLog() (models.TinyProxyLogLevel, error) + GetTinyProxyPort() (port uint16, err error) + GetTinyProxyUser() (user string, err error) + GetTinyProxyPassword() (password string, err error) + + // Version getters + GetVersion() string + GetBuildDate() string + GetVcsRef() string +} + +type paramsReader struct { + envParams libparams.EnvParams + logger logging.Logger + verifier verification.Verifier + unsetEnv func(key string) error +} + +func NewParamsReader(logger logging.Logger) ParamsReader { + return ¶msReader{ + envParams: libparams.NewEnvParams(), + logger: logger, + verifier: verification.NewVerifier(), + unsetEnv: os.Unsetenv, + } +} diff --git a/internal/params/pia.go b/internal/params/pia.go new file mode 100644 index 00000000..c66edff7 --- /dev/null +++ b/internal/params/pia.go @@ -0,0 +1,87 @@ +package params + +import ( + "fmt" + "math/rand" + + libparams "github.com/qdm12/golibs/params" + "github.com/qdm12/private-internet-access-docker/internal/constants" + "github.com/qdm12/private-internet-access-docker/internal/models" +) + +// GetUser obtains the user to use to connect to the VPN servers +func (p *paramsReader) GetUser() (s string, err error) { + defer func() { + unsetenvErr := p.unsetEnv("USER") + if err == nil { + err = unsetenvErr + } + }() + s, err = p.envParams.GetEnv("USER") + if err != nil { + return "", err + } else if len(s) == 0 { + return s, fmt.Errorf("USER environment variable cannot be empty") + } + return s, nil +} + +// GetPassword obtains the password to use to connect to the VPN servers +func (p *paramsReader) GetPassword() (s string, err error) { + defer func() { + unsetenvErr := p.unsetEnv("PASSWORD") + if err == nil { + err = unsetenvErr + } + }() + s, err = p.envParams.GetEnv("PASSWORD") + if err != nil { + return "", err + } else if len(s) == 0 { + return s, fmt.Errorf("PASSWORD environment variable cannot be empty") + } + return s, nil +} + +// GetPortForwarding obtains if port forwarding on the VPN provider server +// side is enabled or not from the environment variable PORT_FORWARDING +func (p *paramsReader) GetPortForwarding() (activated bool, err error) { + s, err := p.envParams.GetEnv("PORT_FORWARDING", libparams.Default("off")) + if err != nil { + return false, err + } + // Custom for retro-compatibility + if s == "false" || s == "off" { + return false, nil + } else if s == "true" || s == "on" { + return true, nil + } + return false, fmt.Errorf("PORT_FORWARDING can only be \"on\" or \"off\"") +} + +// GetPortForwardingStatusFilepath obtains the port forwarding status file path +// from the environment variable PORT_FORWARDING_STATUS_FILE +func (p *paramsReader) GetPortForwardingStatusFilepath() (filepath models.Filepath, err error) { + filepathStr, err := p.envParams.GetPath("PORT_FORWARDING_STATUS_FILE", libparams.Default("/forwarded_port")) + return models.Filepath(filepathStr), err +} + +// GetPIAEncryption obtains the encryption level for the PIA connection +// from the environment variable ENCRYPTION +func (p *paramsReader) GetPIAEncryption() (models.PIAEncryption, error) { + s, err := p.envParams.GetValueIfInside("ENCRYPTION", []string{"normal", "strong"}, libparams.Default("strong")) + return models.PIAEncryption(s), err +} + +// GetPIARegion obtains the region for the PIA server from the +// environment variable REGION +func (p *paramsReader) GetPIARegion() (region models.PIARegion, err error) { + choices := []string{ + string(constants.AUMelbourne), string(constants.AUPerth), string(constants.AUSydney), string(constants.Austria), string(constants.Belgium), string(constants.CAMontreal), string(constants.CAToronto), string(constants.CAVancouver), string(constants.CzechRepublic), string(constants.DEBerlin), string(constants.DEFrankfurt), string(constants.Denmark), string(constants.Finland), string(constants.France), string(constants.HongKong), string(constants.Hungary), string(constants.India), string(constants.Ireland), string(constants.Israel), string(constants.Italy), string(constants.Japan), string(constants.Luxembourg), string(constants.Mexico), string(constants.Netherlands), string(constants.NewZealand), string(constants.Norway), string(constants.Poland), string(constants.Romania), string(constants.Singapore), string(constants.Spain), string(constants.Sweden), string(constants.Switzerland), string(constants.UAE), string(constants.UKLondon), string(constants.UKManchester), string(constants.UKSouthampton), string(constants.USAtlanta), string(constants.USCalifornia), string(constants.USChicago), string(constants.USDenver), string(constants.USEast), string(constants.USFlorida), string(constants.USHouston), string(constants.USLasVegas), string(constants.USNewYorkCity), string(constants.USSeattle), string(constants.USSiliconValley), string(constants.USTexas), string(constants.USWashingtonDC), string(constants.USWest), + } + s, err := p.envParams.GetValueIfInside("REGION", choices) + if len(s) == 0 { // Suggestion by @rorph https://github.com/rorph + s = choices[rand.Int()%len(choices)] + } + return models.PIARegion(s), err +} diff --git a/internal/params/shadowsocks.go b/internal/params/shadowsocks.go new file mode 100644 index 00000000..aee2f340 --- /dev/null +++ b/internal/params/shadowsocks.go @@ -0,0 +1,40 @@ +package params + +import ( + "strconv" + + libparams "github.com/qdm12/golibs/params" +) + +// GetShadowSocks obtains if ShadowSocks is on from the environment variable +// SHADOWSOCKS +func (p *paramsReader) GetShadowSocks() (activated bool, err error) { + return p.envParams.GetOnOff("SHADOWSOCKS", libparams.Default("off")) +} + +// GetShadowSocksLog obtains the ShadowSocks log level from the environment variable +// SHADOWSOCKS_LOG +func (p *paramsReader) GetShadowSocksLog() (activated bool, err error) { + return p.envParams.GetOnOff("SHADOWSOCKS_LOG", libparams.Default("off")) +} + +// GetShadowSocksPort obtains the ShadowSocks listening port from the environment variable +// SHADOWSOCKS_PORT +func (p *paramsReader) GetShadowSocksPort() (port uint16, err error) { + portStr, err := p.envParams.GetEnv("SHADOWSOCKS_PORT", libparams.Default("8388")) + if err != nil { + return 0, err + } + if err := p.verifier.VerifyPort(portStr); err != nil { + return 0, err + } + portUint64, err := strconv.ParseUint(portStr, 10, 16) + return uint16(portUint64), err +} + +// GetShadowSocksPassword obtains the ShadowSocks server password from the environment variable +// SHADOWSOCKS_PASSWORD +func (p *paramsReader) GetShadowSocksPassword() (password string, err error) { + defer p.unsetEnv("SHADOWSOCKS_PASSWORD") + return p.envParams.GetEnv("SHADOWSOCKS_PASSWORD") +} diff --git a/internal/params/tinyproxy.go b/internal/params/tinyproxy.go new file mode 100644 index 00000000..2d66b073 --- /dev/null +++ b/internal/params/tinyproxy.go @@ -0,0 +1,94 @@ +package params + +import ( + "strconv" + + libparams "github.com/qdm12/golibs/params" + "github.com/qdm12/private-internet-access-docker/internal/models" +) + +// GetTinyProxy obtains if TinyProxy is on from the environment variable +// TINYPROXY, and using PROXY as a retro-compatibility name +func (p *paramsReader) GetTinyProxy() (activated bool, err error) { + // Retro-compatibility + s, err := p.envParams.GetEnv("PROXY") + if err != nil { + return false, err + } else if len(s) != 0 { + p.logger.Warn("You are using the old environment variable PROXY, please consider changing it to TINYPROXY") + return p.envParams.GetOnOff("PROXY", libparams.Compulsory()) + } + return p.envParams.GetOnOff("TINYPROXY", libparams.Default("off")) +} + +// GetTinyProxyLog obtains the TinyProxy log level from the environment variable +// TINYPROXY_LOG, and using PROXY_LOG_LEVEL as a retro-compatibility name +func (p *paramsReader) GetTinyProxyLog() (models.TinyProxyLogLevel, error) { + // Retro-compatibility + s, err := p.envParams.GetEnv("PROXY_LOG_LEVEL") + if err != nil { + return models.TinyProxyLogLevel(s), err + } else if len(s) != 0 { + p.logger.Warn("You are using the old environment variable PROXY_LOG_LEVEL, please consider changing it to TINYPROXY_LOG") + s, err = p.envParams.GetValueIfInside("PROXY_LOG_LEVEL", []string{"info", "warning", "error", "critical"}, libparams.Compulsory()) + return models.TinyProxyLogLevel(s), err + } + s, err = p.envParams.GetValueIfInside("TINYPROXY_LOG", []string{"info", "warning", "error", "critical"}, libparams.Default("info")) + return models.TinyProxyLogLevel(s), err +} + +// GetTinyProxyPort obtains the TinyProxy listening port from the environment variable +// TINYPROXY_PORT, and using PROXY_PORT as a retro-compatibility name +func (p *paramsReader) GetTinyProxyPort() (port uint16, err error) { + // Retro-compatibility + portStr, err := p.envParams.GetEnv("PROXY_PORT") + if err != nil { + return 0, err + } else if len(portStr) != 0 { + p.logger.Warn("You are using the old environment variable PROXY_PORT, please consider changing it to TINYPROXY_PORT") + } else { + portStr, err = p.envParams.GetEnv("TINYPROXY_PORT", libparams.Default("8888")) + if err != nil { + return 0, err + } + } + if err := p.verifier.VerifyPort(portStr); err != nil { + return 0, err + } + portUint64, err := strconv.ParseUint(portStr, 10, 16) + return uint16(portUint64), err +} + +// GetTinyProxyUser obtains the TinyProxy server user from the environment variable +// TINYPROXY_USER, and using PROXY_USER as a retro-compatibility name +func (p *paramsReader) GetTinyProxyUser() (user string, err error) { + defer p.unsetEnv("PROXY_USER") + defer p.unsetEnv("TINYPROXY_USER") + // Retro-compatibility + user, err = p.envParams.GetEnv("PROXY_USER") + if err != nil { + return user, err + } + if len(user) != 0 { + p.logger.Warn("You are using the old environment variable PROXY_USER, please consider changing it to TINYPROXY_USER") + return user, nil + } + return p.envParams.GetEnv("TINYPROXY_USER") +} + +// GetTinyProxyPassword obtains the TinyProxy server password from the environment variable +// TINYPROXY_PASSWORD, and using PROXY_PASSWORD as a retro-compatibility name +func (p *paramsReader) GetTinyProxyPassword() (password string, err error) { + defer p.unsetEnv("PROXY_PASSWORD") + defer p.unsetEnv("TINYPROXY_PASSWORD") + // Retro-compatibility + password, err = p.envParams.GetEnv("PROXY_PASSWORD") + if err != nil { + return password, err + } + if len(password) != 0 { + p.logger.Warn("You are using the old environment variable PROXY_PASSWORD, please consider changing it to TINYPROXY_PASSWORD") + return password, nil + } + return p.envParams.GetEnv("TINYPROXY_PASSWORD") +} diff --git a/internal/params/version.go b/internal/params/version.go new file mode 100644 index 00000000..02780887 --- /dev/null +++ b/internal/params/version.go @@ -0,0 +1,20 @@ +package params + +import ( + "github.com/qdm12/golibs/params" +) + +func (p *paramsReader) GetVersion() string { + version, _ := p.envParams.GetEnv("VERSION", params.Default("?")) + return version +} + +func (p *paramsReader) GetBuildDate() string { + buildDate, _ := p.envParams.GetEnv("BUILD_DATE", params.Default("?")) + return buildDate +} + +func (p *paramsReader) GetVcsRef() string { + buildDate, _ := p.envParams.GetEnv("VCS_REF", params.Default("?")) + return buildDate +} diff --git a/internal/pia/download.go b/internal/pia/download.go new file mode 100644 index 00000000..e9236d91 --- /dev/null +++ b/internal/pia/download.go @@ -0,0 +1,61 @@ +package pia + +import ( + "archive/zip" + "bytes" + "fmt" + "io/ioutil" + "strings" + + "github.com/qdm12/private-internet-access-docker/internal/constants" + "github.com/qdm12/private-internet-access-docker/internal/models" +) + +func (c *configurator) DownloadOvpnConfig(encryption models.PIAEncryption, + protocol models.NetworkProtocol, region models.PIARegion) (lines []string, err error) { + c.logger.Info("%s: downloading openvpn configuration files", logPrefix) + URL := buildZipURL(encryption, protocol) + content, status, err := c.client.GetContent(URL) + if err != nil { + return nil, err + } else if status != 200 { + return nil, fmt.Errorf("HTTP Get %s resulted in HTTP status code %d", URL, status) + } + filename := fmt.Sprintf("%s.ovpn", region) + fileContent, err := getFileInZip(content, filename) + if err != nil { + return nil, fmt.Errorf("%s: %w", URL, err) + } + lines = strings.Split(string(fileContent), "\n") + return lines, nil +} + +func buildZipURL(encryption models.PIAEncryption, protocol models.NetworkProtocol) (URL string) { + URL = string(constants.PIAOpenVPNURL) + "/openvpn" + if encryption == constants.PIAEncryptionStrong { + URL += "-strong" + } + if protocol == constants.TCP { + URL += "-tcp" + } + return URL + ".zip" +} + +func getFileInZip(zipContent []byte, filename string) (fileContent []byte, err error) { + contentLength := int64(len(zipContent)) + r, err := zip.NewReader(bytes.NewReader(zipContent), contentLength) + if err != nil { + return nil, err + } + for _, f := range r.File { + if f.Name == filename { + readCloser, err := f.Open() + if err != nil { + return nil, err + } + defer readCloser.Close() + return ioutil.ReadAll(readCloser) + } + } + return nil, fmt.Errorf("%s not found in zip archive file", filename) +} diff --git a/internal/pia/modify.go b/internal/pia/modify.go new file mode 100644 index 00000000..ce6c9131 --- /dev/null +++ b/internal/pia/modify.go @@ -0,0 +1,31 @@ +package pia + +import ( + "fmt" + "net" + "strings" + + "github.com/qdm12/private-internet-access-docker/internal/constants" +) + +func (c *configurator) ModifyLines(lines []string, IPs []net.IP, port uint16) (modifiedLines []string) { + c.logger.Info("%s: adapting openvpn configuration for server IP addresses and port %d", logPrefix, port) + // Remove lines + for _, line := range lines { + if strings.Contains(line, "privateinternetaccess.com") || + strings.Contains(line, "resolv-retry") { + continue + } + modifiedLines = append(modifiedLines, line) + } + // Add lines + for _, IP := range IPs { + modifiedLines = append(modifiedLines, fmt.Sprintf("remote %s %d", IP.String(), port)) + } + modifiedLines = append(modifiedLines, "auth-user-pass "+string(constants.OpenVPNAuthConf)) + modifiedLines = append(modifiedLines, "auth-retry nointeract") + modifiedLines = append(modifiedLines, "pull-filter ignore \"auth-token\"") // prevent auth failed loops + modifiedLines = append(modifiedLines, "user nonrootuser") + modifiedLines = append(modifiedLines, "mute-replay-warnings") + return modifiedLines +} diff --git a/internal/pia/modify_test.go b/internal/pia/modify_test.go new file mode 100644 index 00000000..01c1e676 --- /dev/null +++ b/internal/pia/modify_test.go @@ -0,0 +1,31 @@ +package pia + +import ( + "io/ioutil" + "net" + "strings" + "testing" + + loggingMocks "github.com/qdm12/golibs/logging/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_ModifyLines(t *testing.T) { + t.Parallel() + original, err := ioutil.ReadFile("testdata/ovpn.golden") + require.NoError(t, err) + originalLines := strings.Split(string(original), "\n") + expected, err := ioutil.ReadFile("testdata/ovpn.modified.golden") + require.NoError(t, err) + expectedLines := strings.Split(string(expected), "\n") + + var port uint16 = 3000 + IPs := []net.IP{net.IP{100, 10, 10, 10}, net.IP{100, 20, 20, 20}} + logger := &loggingMocks.Logger{} + logger.On("Info", "%s: adapting openvpn configuration for server IP addresses and port %d", logPrefix, port).Once() + c := &configurator{logger: logger} + modifiedLines := c.ModifyLines(originalLines, IPs, port) + assert.Equal(t, expectedLines, modifiedLines) + logger.AssertExpectations(t) +} diff --git a/internal/pia/parse.go b/internal/pia/parse.go new file mode 100644 index 00000000..9b5e8285 --- /dev/null +++ b/internal/pia/parse.go @@ -0,0 +1,54 @@ +package pia + +import ( + "fmt" + "net" + "strconv" + "strings" + + "github.com/qdm12/private-internet-access-docker/internal/constants" + "github.com/qdm12/private-internet-access-docker/internal/models" +) + +func (c *configurator) ParseConfig(lines []string) (IPs []net.IP, port uint16, device models.VPNDevice, err error) { + c.logger.Info("%s: parsing openvpn configuration", logPrefix) + remoteLineFound := false + deviceLineFound := false + for _, line := range lines { + if strings.HasPrefix(line, "remote ") { + remoteLineFound = true + words := strings.Fields(line) + if len(words) != 3 { + return nil, 0, "", fmt.Errorf("line %q misses information", line) + } + host := words[1] + if err := c.verifyPort(words[2]); err != nil { + return nil, 0, "", fmt.Errorf("line %q has an invalid port: %w", line, err) + } + portUint64, _ := strconv.ParseUint(words[2], 10, 16) + port = uint16(portUint64) + IPs, err = c.lookupIP(host) + if err != nil { + return nil, 0, "", err + } + } else if strings.HasPrefix(line, "dev ") { + deviceLineFound = true + fields := strings.Fields(line) + if len(fields) != 2 { + return nil, 0, "", fmt.Errorf("line %q misses information", line) + } + device = models.VPNDevice(fields[1] + "0") + if device != constants.TUN && device != constants.TAP { + return nil, 0, "", fmt.Errorf("device %q is not valid", device) + } + } + } + if remoteLineFound && deviceLineFound { + c.logger.Info("%s: Found %d PIA server IP addresses, port %d and device %s", logPrefix, len(IPs), port, device) + return IPs, port, device, nil + } else if !remoteLineFound { + return nil, 0, "", fmt.Errorf("remote line not found in Openvpn configuration") + } else { + return nil, 0, "", fmt.Errorf("device line not found in Openvpn configuration") + } +} diff --git a/internal/pia/parse_test.go b/internal/pia/parse_test.go new file mode 100644 index 00000000..d0a2d0e9 --- /dev/null +++ b/internal/pia/parse_test.go @@ -0,0 +1,99 @@ +package pia + +import ( + "fmt" + "io/ioutil" + "net" + "strings" + "testing" + + loggingMocks "github.com/qdm12/golibs/logging/mocks" + "github.com/qdm12/golibs/verification" + "github.com/qdm12/private-internet-access-docker/internal/constants" + "github.com/qdm12/private-internet-access-docker/internal/models" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_ParseConfig(t *testing.T) { + t.Parallel() + original, err := ioutil.ReadFile("testdata/ovpn.golden") + require.NoError(t, err) + exampleLines := strings.Split(string(original), "\n") + tests := map[string]struct { + lines []string + lookupIPs []net.IP + lookupIPErr error + IPs []net.IP + port uint16 + device models.VPNDevice + err error + }{ + "no data": { + err: fmt.Errorf("remote line not found in Openvpn configuration"), + }, + "bad remote line": { + lines: []string{"remote field2"}, + err: fmt.Errorf("line \"remote field2\" misses information"), + }, + "bad remote port": { + lines: []string{"remote field2 port"}, + err: fmt.Errorf("line \"remote field2 port\" has an invalid port: port \"port\" is not a valid integer"), + }, + "lookupIP error": { + lines: []string{"remote host 1000"}, + lookupIPErr: fmt.Errorf("lookup error"), + err: fmt.Errorf("lookup error"), + }, + "missing dev line": { + lines: []string{"remote host 1994"}, + err: fmt.Errorf("device line not found in Openvpn configuration"), + }, + "bad dev line": { + lines: []string{"dev field2 field3"}, + err: fmt.Errorf("line \"dev field2 field3\" misses information"), + }, + "bad device": { + lines: []string{"dev xx"}, + err: fmt.Errorf("device \"xx0\" is not valid"), + }, + "valid lines": { + lines: []string{"remote host 1194", "dev tap", "blabla"}, + port: 1194, + device: constants.TAP, + }, + "real data": { + lines: exampleLines, + lookupIPs: []net.IP{{100, 100, 100, 100}, {100, 100, 200, 200}}, + IPs: []net.IP{{100, 100, 100, 100}, {100, 100, 200, 200}}, + port: 1198, + device: constants.TUN, + }, + } + for name, tc := range tests { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + logger := &loggingMocks.Logger{} + logger.On("Info", "%s: parsing openvpn configuration", logPrefix).Once() + if tc.err == nil { + logger.On("Info", "%s: Found %d PIA server IP addresses, port %d and device %s", logPrefix, len(tc.IPs), tc.port, tc.device).Once() + } + lookupIP := func(host string) ([]net.IP, error) { + return tc.lookupIPs, tc.lookupIPErr + } + c := &configurator{logger: logger, verifyPort: verification.NewVerifier().VerifyPort, lookupIP: lookupIP} + IPs, port, device, err := c.ParseConfig(tc.lines) + if tc.err != nil { + require.Error(t, err) + assert.Equal(t, tc.err.Error(), err.Error()) + } else { + assert.NoError(t, err) + } + assert.Equal(t, tc.IPs, IPs) + assert.Equal(t, tc.port, port) + assert.Equal(t, tc.device, device) + logger.AssertExpectations(t) + }) + } +} diff --git a/internal/pia/pia.go b/internal/pia/pia.go new file mode 100644 index 00000000..c0f96df5 --- /dev/null +++ b/internal/pia/pia.go @@ -0,0 +1,41 @@ +package pia + +import ( + "net" + + "github.com/qdm12/golibs/crypto/random" + "github.com/qdm12/golibs/files" + "github.com/qdm12/golibs/logging" + "github.com/qdm12/golibs/network" + "github.com/qdm12/golibs/verification" + "github.com/qdm12/private-internet-access-docker/internal/firewall" + "github.com/qdm12/private-internet-access-docker/internal/models" +) + +const logPrefix = "PIA configurator" + +// Configurator contains methods to download, read and modify the openvpn configuration to connect as a client +type Configurator interface { + DownloadOvpnConfig(encryption models.PIAEncryption, + protocol models.NetworkProtocol, region models.PIARegion) (lines []string, err error) + ParseConfig(lines []string) (IPs []net.IP, port uint16, device models.VPNDevice, err error) + ModifyLines(lines []string, IPs []net.IP, port uint16) (modifiedLines []string) + GetPortForward() (port uint16, err error) + WritePortForward(filepath models.Filepath, port uint16) (err error) + AllowPortForwardFirewall(device models.VPNDevice, port uint16) (err error) +} + +type configurator struct { + client network.Client + fileManager files.FileManager + firewall firewall.Configurator + logger logging.Logger + random random.Random + verifyPort func(port string) error + lookupIP func(host string) ([]net.IP, error) +} + +// NewConfigurator returns a new Configurator object +func NewConfigurator(client network.Client, fileManager files.FileManager, firewall firewall.Configurator, logger logging.Logger) Configurator { + return &configurator{client, fileManager, firewall, logger, random.NewRandom(), verification.NewVerifier().VerifyPort, net.LookupIP} +} diff --git a/internal/pia/portforward.go b/internal/pia/portforward.go new file mode 100644 index 00000000..5e8d73e3 --- /dev/null +++ b/internal/pia/portforward.go @@ -0,0 +1,46 @@ +package pia + +import ( + "encoding/hex" + "encoding/json" + "fmt" + + "github.com/qdm12/private-internet-access-docker/internal/constants" + "github.com/qdm12/private-internet-access-docker/internal/models" +) + +func (c *configurator) GetPortForward() (port uint16, err error) { + c.logger.Info("%s: Obtaining port to be forwarded", logPrefix) + b, err := c.random.GenerateRandomBytes(32) + if err != nil { + return 0, err + } + clientID := hex.EncodeToString(b) + url := fmt.Sprintf("%s/?client_id=%s", constants.PIAPortForwardURL, clientID) + content, status, err := c.client.GetContent(url) + if err != nil { + return 0, err + } else if status != 200 { + return 0, fmt.Errorf("status is %d for %s; does your PIA server support port forwarding?", status, url) + } else if len(content) == 0 { + return 0, fmt.Errorf("port forwarding is already activated on this connection, has expired, or you are not connected to a PIA region that supports port forwarding") + } + body := struct { + Port uint16 `json:"port"` + }{} + if err := json.Unmarshal(content, &body); err != nil { + return 0, fmt.Errorf("port forwarding response: %w", err) + } + c.logger.Info("%s: Port forwarded is %d", logPrefix, port) + return body.Port, nil +} + +func (c *configurator) WritePortForward(filepath models.Filepath, port uint16) (err error) { + c.logger.Info("%s: Writing forwarded port to %s", logPrefix, filepath) + return c.fileManager.WriteLinesToFile(string(filepath), []string{fmt.Sprintf("%d", port)}) +} + +func (c *configurator) AllowPortForwardFirewall(device models.VPNDevice, port uint16) (err error) { + c.logger.Info("%s: Allowing forwarded port %d through firewall", logPrefix, port) + return c.firewall.AllowInputTrafficOnPort(device, port) +} diff --git a/internal/pia/testdata/ovpn.golden b/internal/pia/testdata/ovpn.golden new file mode 100644 index 00000000..2a80fd55 --- /dev/null +++ b/internal/pia/testdata/ovpn.golden @@ -0,0 +1,72 @@ +client +dev tun +proto udp +remote belgium.privateinternetaccess.com 1198 +resolv-retry infinite +nobind +persist-key +persist-tun +cipher aes-128-cbc +auth sha1 +tls-client +remote-cert-tls server + +auth-user-pass +compress +verb 1 +reneg-sec 0 + +-----BEGIN X509 CRL----- +MIICWDCCAUAwDQYJKoZIhvcNAQENBQAwgegxCzAJBgNVBAYTAlVTMQswCQYDVQQI +EwJDQTETMBEGA1UEBxMKTG9zQW5nZWxlczEgMB4GA1UEChMXUHJpdmF0ZSBJbnRl +cm5ldCBBY2Nlc3MxIDAeBgNVBAsTF1ByaXZhdGUgSW50ZXJuZXQgQWNjZXNzMSAw +HgYDVQQDExdQcml2YXRlIEludGVybmV0IEFjY2VzczEgMB4GA1UEKRMXUHJpdmF0 +ZSBJbnRlcm5ldCBBY2Nlc3MxLzAtBgkqhkiG9w0BCQEWIHNlY3VyZUBwcml2YXRl +aW50ZXJuZXRhY2Nlc3MuY29tFw0xNjA3MDgxOTAwNDZaFw0zNjA3MDMxOTAwNDZa +MCYwEQIBARcMMTYwNzA4MTkwMDQ2MBECAQYXDDE2MDcwODE5MDA0NjANBgkqhkiG +9w0BAQ0FAAOCAQEAQZo9X97ci8EcPYu/uK2HB152OZbeZCINmYyluLDOdcSvg6B5 +jI+ffKN3laDvczsG6CxmY3jNyc79XVpEYUnq4rT3FfveW1+Ralf+Vf38HdpwB8EW +B4hZlQ205+21CALLvZvR8HcPxC9KEnev1mU46wkTiov0EKc+EdRxkj5yMgv0V2Re +ze7AP+NQ9ykvDScH4eYCsmufNpIjBLhpLE2cuZZXBLcPhuRzVoU3l7A9lvzG9mjA +5YijHJGHNjlWFqyrn1CfYS6koa4TGEPngBoAziWRbDGdhEgJABHrpoaFYaL61zqy +MR6jC0K2ps9qyZAN74LEBedEfK7tBOzWMwr58A== +-----END X509 CRL----- + + + +-----BEGIN CERTIFICATE----- +MIIFqzCCBJOgAwIBAgIJAKZ7D5Yv87qDMA0GCSqGSIb3DQEBDQUAMIHoMQswCQYD +VQQGEwJVUzELMAkGA1UECBMCQ0ExEzARBgNVBAcTCkxvc0FuZ2VsZXMxIDAeBgNV +BAoTF1ByaXZhdGUgSW50ZXJuZXQgQWNjZXNzMSAwHgYDVQQLExdQcml2YXRlIElu +dGVybmV0IEFjY2VzczEgMB4GA1UEAxMXUHJpdmF0ZSBJbnRlcm5ldCBBY2Nlc3Mx +IDAeBgNVBCkTF1ByaXZhdGUgSW50ZXJuZXQgQWNjZXNzMS8wLQYJKoZIhvcNAQkB +FiBzZWN1cmVAcHJpdmF0ZWludGVybmV0YWNjZXNzLmNvbTAeFw0xNDA0MTcxNzM1 +MThaFw0zNDA0MTIxNzM1MThaMIHoMQswCQYDVQQGEwJVUzELMAkGA1UECBMCQ0Ex +EzARBgNVBAcTCkxvc0FuZ2VsZXMxIDAeBgNVBAoTF1ByaXZhdGUgSW50ZXJuZXQg +QWNjZXNzMSAwHgYDVQQLExdQcml2YXRlIEludGVybmV0IEFjY2VzczEgMB4GA1UE +AxMXUHJpdmF0ZSBJbnRlcm5ldCBBY2Nlc3MxIDAeBgNVBCkTF1ByaXZhdGUgSW50 +ZXJuZXQgQWNjZXNzMS8wLQYJKoZIhvcNAQkBFiBzZWN1cmVAcHJpdmF0ZWludGVy +bmV0YWNjZXNzLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAPXD +L1L9tX6DGf36liA7UBTy5I869z0UVo3lImfOs/GSiFKPtInlesP65577nd7UNzzX +lH/P/CnFPdBWlLp5ze3HRBCc/Avgr5CdMRkEsySL5GHBZsx6w2cayQ2EcRhVTwWp +cdldeNO+pPr9rIgPrtXqT4SWViTQRBeGM8CDxAyTopTsobjSiYZCF9Ta1gunl0G/ +8Vfp+SXfYCC+ZzWvP+L1pFhPRqzQQ8k+wMZIovObK1s+nlwPaLyayzw9a8sUnvWB +/5rGPdIYnQWPgoNlLN9HpSmsAcw2z8DXI9pIxbr74cb3/HSfuYGOLkRqrOk6h4RC +OfuWoTrZup1uEOn+fw8CAwEAAaOCAVQwggFQMB0GA1UdDgQWBBQv63nQ/pJAt5tL +y8VJcbHe22ZOsjCCAR8GA1UdIwSCARYwggESgBQv63nQ/pJAt5tLy8VJcbHe22ZO +sqGB7qSB6zCB6DELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRMwEQYDVQQHEwpM +b3NBbmdlbGVzMSAwHgYDVQQKExdQcml2YXRlIEludGVybmV0IEFjY2VzczEgMB4G +A1UECxMXUHJpdmF0ZSBJbnRlcm5ldCBBY2Nlc3MxIDAeBgNVBAMTF1ByaXZhdGUg +SW50ZXJuZXQgQWNjZXNzMSAwHgYDVQQpExdQcml2YXRlIEludGVybmV0IEFjY2Vz +czEvMC0GCSqGSIb3DQEJARYgc2VjdXJlQHByaXZhdGVpbnRlcm5ldGFjY2Vzcy5j +b22CCQCmew+WL/O6gzAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBDQUAA4IBAQAn +a5PgrtxfwTumD4+3/SYvwoD66cB8IcK//h1mCzAduU8KgUXocLx7QgJWo9lnZ8xU +ryXvWab2usg4fqk7FPi00bED4f4qVQFVfGfPZIH9QQ7/48bPM9RyfzImZWUCenK3 +7pdw4Bvgoys2rHLHbGen7f28knT2j/cbMxd78tQc20TIObGjo8+ISTRclSTRBtyC +GohseKYpTS9himFERpUgNtefvYHbn70mIOzfOJFTVqfrptf9jXa9N8Mpy3ayfodz +1wiqdteqFXkTYoSDctgKMiZ6GdocK9nMroQipIQtpnwd4yBDWIyC6Bvlkrq5TQUt +YDQ8z9v+DMO6iwyIDRiU +-----END CERTIFICATE----- + + +disable-occ diff --git a/internal/pia/testdata/ovpn.modified.golden b/internal/pia/testdata/ovpn.modified.golden new file mode 100644 index 00000000..dfd10da6 --- /dev/null +++ b/internal/pia/testdata/ovpn.modified.golden @@ -0,0 +1,78 @@ +client +dev tun +proto udp +nobind +persist-key +persist-tun +cipher aes-128-cbc +auth sha1 +tls-client +remote-cert-tls server + +auth-user-pass +compress +verb 1 +reneg-sec 0 + +-----BEGIN X509 CRL----- +MIICWDCCAUAwDQYJKoZIhvcNAQENBQAwgegxCzAJBgNVBAYTAlVTMQswCQYDVQQI +EwJDQTETMBEGA1UEBxMKTG9zQW5nZWxlczEgMB4GA1UEChMXUHJpdmF0ZSBJbnRl +cm5ldCBBY2Nlc3MxIDAeBgNVBAsTF1ByaXZhdGUgSW50ZXJuZXQgQWNjZXNzMSAw +HgYDVQQDExdQcml2YXRlIEludGVybmV0IEFjY2VzczEgMB4GA1UEKRMXUHJpdmF0 +ZSBJbnRlcm5ldCBBY2Nlc3MxLzAtBgkqhkiG9w0BCQEWIHNlY3VyZUBwcml2YXRl +aW50ZXJuZXRhY2Nlc3MuY29tFw0xNjA3MDgxOTAwNDZaFw0zNjA3MDMxOTAwNDZa +MCYwEQIBARcMMTYwNzA4MTkwMDQ2MBECAQYXDDE2MDcwODE5MDA0NjANBgkqhkiG +9w0BAQ0FAAOCAQEAQZo9X97ci8EcPYu/uK2HB152OZbeZCINmYyluLDOdcSvg6B5 +jI+ffKN3laDvczsG6CxmY3jNyc79XVpEYUnq4rT3FfveW1+Ralf+Vf38HdpwB8EW +B4hZlQ205+21CALLvZvR8HcPxC9KEnev1mU46wkTiov0EKc+EdRxkj5yMgv0V2Re +ze7AP+NQ9ykvDScH4eYCsmufNpIjBLhpLE2cuZZXBLcPhuRzVoU3l7A9lvzG9mjA +5YijHJGHNjlWFqyrn1CfYS6koa4TGEPngBoAziWRbDGdhEgJABHrpoaFYaL61zqy +MR6jC0K2ps9qyZAN74LEBedEfK7tBOzWMwr58A== +-----END X509 CRL----- + + + +-----BEGIN CERTIFICATE----- +MIIFqzCCBJOgAwIBAgIJAKZ7D5Yv87qDMA0GCSqGSIb3DQEBDQUAMIHoMQswCQYD +VQQGEwJVUzELMAkGA1UECBMCQ0ExEzARBgNVBAcTCkxvc0FuZ2VsZXMxIDAeBgNV +BAoTF1ByaXZhdGUgSW50ZXJuZXQgQWNjZXNzMSAwHgYDVQQLExdQcml2YXRlIElu +dGVybmV0IEFjY2VzczEgMB4GA1UEAxMXUHJpdmF0ZSBJbnRlcm5ldCBBY2Nlc3Mx +IDAeBgNVBCkTF1ByaXZhdGUgSW50ZXJuZXQgQWNjZXNzMS8wLQYJKoZIhvcNAQkB +FiBzZWN1cmVAcHJpdmF0ZWludGVybmV0YWNjZXNzLmNvbTAeFw0xNDA0MTcxNzM1 +MThaFw0zNDA0MTIxNzM1MThaMIHoMQswCQYDVQQGEwJVUzELMAkGA1UECBMCQ0Ex +EzARBgNVBAcTCkxvc0FuZ2VsZXMxIDAeBgNVBAoTF1ByaXZhdGUgSW50ZXJuZXQg +QWNjZXNzMSAwHgYDVQQLExdQcml2YXRlIEludGVybmV0IEFjY2VzczEgMB4GA1UE +AxMXUHJpdmF0ZSBJbnRlcm5ldCBBY2Nlc3MxIDAeBgNVBCkTF1ByaXZhdGUgSW50 +ZXJuZXQgQWNjZXNzMS8wLQYJKoZIhvcNAQkBFiBzZWN1cmVAcHJpdmF0ZWludGVy +bmV0YWNjZXNzLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAPXD +L1L9tX6DGf36liA7UBTy5I869z0UVo3lImfOs/GSiFKPtInlesP65577nd7UNzzX +lH/P/CnFPdBWlLp5ze3HRBCc/Avgr5CdMRkEsySL5GHBZsx6w2cayQ2EcRhVTwWp +cdldeNO+pPr9rIgPrtXqT4SWViTQRBeGM8CDxAyTopTsobjSiYZCF9Ta1gunl0G/ +8Vfp+SXfYCC+ZzWvP+L1pFhPRqzQQ8k+wMZIovObK1s+nlwPaLyayzw9a8sUnvWB +/5rGPdIYnQWPgoNlLN9HpSmsAcw2z8DXI9pIxbr74cb3/HSfuYGOLkRqrOk6h4RC +OfuWoTrZup1uEOn+fw8CAwEAAaOCAVQwggFQMB0GA1UdDgQWBBQv63nQ/pJAt5tL +y8VJcbHe22ZOsjCCAR8GA1UdIwSCARYwggESgBQv63nQ/pJAt5tLy8VJcbHe22ZO +sqGB7qSB6zCB6DELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRMwEQYDVQQHEwpM +b3NBbmdlbGVzMSAwHgYDVQQKExdQcml2YXRlIEludGVybmV0IEFjY2VzczEgMB4G +A1UECxMXUHJpdmF0ZSBJbnRlcm5ldCBBY2Nlc3MxIDAeBgNVBAMTF1ByaXZhdGUg +SW50ZXJuZXQgQWNjZXNzMSAwHgYDVQQpExdQcml2YXRlIEludGVybmV0IEFjY2Vz +czEvMC0GCSqGSIb3DQEJARYgc2VjdXJlQHByaXZhdGVpbnRlcm5ldGFjY2Vzcy5j +b22CCQCmew+WL/O6gzAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBDQUAA4IBAQAn +a5PgrtxfwTumD4+3/SYvwoD66cB8IcK//h1mCzAduU8KgUXocLx7QgJWo9lnZ8xU +ryXvWab2usg4fqk7FPi00bED4f4qVQFVfGfPZIH9QQ7/48bPM9RyfzImZWUCenK3 +7pdw4Bvgoys2rHLHbGen7f28knT2j/cbMxd78tQc20TIObGjo8+ISTRclSTRBtyC +GohseKYpTS9himFERpUgNtefvYHbn70mIOzfOJFTVqfrptf9jXa9N8Mpy3ayfodz +1wiqdteqFXkTYoSDctgKMiZ6GdocK9nMroQipIQtpnwd4yBDWIyC6Bvlkrq5TQUt +YDQ8z9v+DMO6iwyIDRiU +-----END CERTIFICATE----- + + +disable-occ + +remote 100.10.10.10 3000 +remote 100.20.20.20 3000 +auth-user-pass /etc/openvpn/auth.conf +auth-retry nointeract +pull-filter ignore "auth-token" +user nonrootuser +mute-replay-warnings \ No newline at end of file diff --git a/internal/settings/dns.go b/internal/settings/dns.go new file mode 100644 index 00000000..6d725f9e --- /dev/null +++ b/internal/settings/dns.go @@ -0,0 +1,108 @@ +package settings + +import ( + "fmt" + "strings" + + "github.com/qdm12/private-internet-access-docker/internal/models" + "github.com/qdm12/private-internet-access-docker/internal/params" +) + +// DNS contains settings to configure Unbound for DNS over TLS operation +type DNS struct { + Enabled bool + Providers []models.DNSProvider + AllowedHostnames []string + PrivateAddresses []string + BlockMalicious bool + BlockSurveillance bool + BlockAds bool + VerbosityLevel uint8 + VerbosityDetailsLevel uint8 + ValidationLogLevel uint8 +} + +func (d *DNS) String() string { + if !d.Enabled { + return "DNS over TLS settings: disabled" + } + blockMalicious, blockSurveillance, blockAds := "disabed", "disabed", "disabed" + if d.BlockMalicious { + blockMalicious = "enabled" + } + if d.BlockSurveillance { + blockSurveillance = "enabled" + } + if d.BlockAds { + blockAds = "enabled" + } + var providersStr []string + for _, provider := range d.Providers { + providersStr = append(providersStr, string(provider)) + } + settingsList := []string{ + "DNS over TLS settings:", + "DNS over TLS provider: \n |--" + strings.Join(providersStr, "\n |--"), + "Block malicious: " + blockMalicious, + "Block surveillance: " + blockSurveillance, + "Block ads: " + blockAds, + "Allowed hostnames: " + strings.Join(d.AllowedHostnames, ", "), + "Private addresses:\n |--" + strings.Join(d.PrivateAddresses, "\n |--"), + "Verbosity level: " + fmt.Sprintf("%d/5", d.VerbosityLevel), + "Verbosity details level: " + fmt.Sprintf("%d/4", d.VerbosityDetailsLevel), + "Validation log level: " + fmt.Sprintf("%d/2", d.ValidationLogLevel), + } + return strings.Join(settingsList, "\n |--") +} + +// GetDNSSettings obtains DNS over TLS settings from environment variables using the params package. +func GetDNSSettings(params params.ParamsReader) (settings DNS, err error) { + settings.Enabled, err = params.GetDNSOverTLS() + if err != nil || !settings.Enabled { + return settings, err + } + settings.Providers, err = params.GetDNSOverTLSProviders() + if err != nil { + return settings, err + } + settings.AllowedHostnames, err = params.GetDNSUnblockedHostnames() + if err != nil { + return settings, err + } + settings.BlockMalicious, err = params.GetDNSMaliciousBlocking() + if err != nil { + return settings, err + } + settings.BlockSurveillance, err = params.GetDNSSurveillanceBlocking() + if err != nil { + return settings, err + } + settings.BlockAds, err = params.GetDNSAdsBlocking() + if err != nil { + return settings, err + } + settings.VerbosityLevel, err = params.GetDNSOverTLSVerbosity() + if err != nil { + return settings, err + } + settings.VerbosityDetailsLevel, err = params.GetDNSOverTLSVerbosityDetails() + if err != nil { + return settings, err + } + settings.ValidationLogLevel, err = params.GetDNSOverTLSValidationLogLevel() + if err != nil { + return settings, err + } + settings.PrivateAddresses = []string{ // TODO make env variable + "127.0.0.1/8", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "169.254.0.0/16", + "::1/128", + "fc00::/7", + "fe80::/10", + "::ffff:0:0/96", + } + return settings, nil +} diff --git a/internal/settings/firewall.go b/internal/settings/firewall.go new file mode 100644 index 00000000..841416d0 --- /dev/null +++ b/internal/settings/firewall.go @@ -0,0 +1,34 @@ +package settings + +import ( + "net" + "strings" + + "github.com/qdm12/private-internet-access-docker/internal/params" +) + +// Firewall contains settings to customize the firewall operation +type Firewall struct { + AllowedSubnets []net.IPNet +} + +func (f *Firewall) String() string { + var allowedSubnets []string + for _, net := range f.AllowedSubnets { + allowedSubnets = append(allowedSubnets, net.String()) + } + settingsList := []string{ + "Firewall settings:", + "Allowed subnets: " + strings.Join(allowedSubnets, ", "), + } + return strings.Join(settingsList, "\n |--") +} + +// GetFirewallSettings obtains firewall settings from environment variables using the params package. +func GetFirewallSettings(params params.ParamsReader) (settings Firewall, err error) { + settings.AllowedSubnets, err = params.GetExtraSubnets() + if err != nil { + return settings, err + } + return settings, nil +} diff --git a/internal/settings/openvpn.go b/internal/settings/openvpn.go new file mode 100644 index 00000000..21e07872 --- /dev/null +++ b/internal/settings/openvpn.go @@ -0,0 +1,30 @@ +package settings + +import ( + "strings" + + "github.com/qdm12/private-internet-access-docker/internal/models" + "github.com/qdm12/private-internet-access-docker/internal/params" +) + +// OpenVPN contains settings to configure the OpenVPN client +type OpenVPN struct { + NetworkProtocol models.NetworkProtocol +} + +// GetOpenVPNSettings obtains the OpenVPN settings using the params functions +func GetOpenVPNSettings(params params.ParamsReader) (settings OpenVPN, err error) { + settings.NetworkProtocol, err = params.GetNetworkProtocol() + if err != nil { + return settings, err + } + return settings, nil +} + +func (o *OpenVPN) String() string { + settingsList := []string{ + "OpenVPN settings:", + "Network protocol: " + string(o.NetworkProtocol), + } + return strings.Join(settingsList, "\n|--") +} diff --git a/internal/settings/pia.go b/internal/settings/pia.go new file mode 100644 index 00000000..7455e7d2 --- /dev/null +++ b/internal/settings/pia.go @@ -0,0 +1,72 @@ +package settings + +import ( + "fmt" + "strings" + + "github.com/qdm12/private-internet-access-docker/internal/models" + "github.com/qdm12/private-internet-access-docker/internal/params" +) + +// PIA contains the settings to connect to a PIA server +type PIA struct { + User string + Password string + Encryption models.PIAEncryption + Region models.PIARegion + PortForwarding PortForwarding +} + +// PortForwarding contains settings for port forwarding +type PortForwarding struct { + Enabled bool + Filepath models.Filepath +} + +func (p *PortForwarding) String() string { + if p.Enabled { + return fmt.Sprintf("on, saved in %s", p.Filepath) + } + return "off" +} + +func (p *PIA) String() string { + settingsList := []string{ + "PIA settings:", + "Region: " + string(p.Region), + "Encryption: " + string(p.Encryption), + "Port forwarding: " + p.PortForwarding.String(), + } + return strings.Join(settingsList, "\n |--") +} + +// GetPIASettings obtains PIA settings from environment variables using the params package. +func GetPIASettings(params params.ParamsReader) (settings PIA, err error) { + settings.User, err = params.GetUser() + if err != nil { + return settings, err + } + settings.Password, err = params.GetPassword() + if err != nil { + return settings, err + } + settings.Encryption, err = params.GetPIAEncryption() + if err != nil { + return settings, err + } + settings.Region, err = params.GetPIARegion() + if err != nil { + return settings, err + } + settings.PortForwarding.Enabled, err = params.GetPortForwarding() + if err != nil { + return settings, err + } + if settings.PortForwarding.Enabled { + settings.PortForwarding.Filepath, err = params.GetPortForwardingStatusFilepath() + if err != nil { + return settings, err + } + } + return settings, nil +} diff --git a/internal/settings/settings.go b/internal/settings/settings.go new file mode 100644 index 00000000..7b56dc19 --- /dev/null +++ b/internal/settings/settings.go @@ -0,0 +1,60 @@ +package settings + +import ( + "strings" + + "github.com/qdm12/private-internet-access-docker/internal/params" +) + +// Settings contains all settings for the program to run +type Settings struct { + OpenVPN OpenVPN + PIA PIA + DNS DNS + Firewall Firewall + TinyProxy TinyProxy + ShadowSocks ShadowSocks +} + +func (s *Settings) String() string { + return strings.Join([]string{ + "Settings summary below:", + s.OpenVPN.String(), + s.PIA.String(), + s.DNS.String(), + s.Firewall.String(), + s.TinyProxy.String(), + s.ShadowSocks.String(), + "", // new line at the end + }, "\n") +} + +// GetAllSettings obtains all settings for the program and returns an error as soon +// as an error is encountered reading them. +func GetAllSettings(params params.ParamsReader) (settings Settings, err error) { + settings.OpenVPN, err = GetOpenVPNSettings(params) + if err != nil { + return settings, err + } + settings.PIA, err = GetPIASettings(params) + if err != nil { + return settings, err + } + settings.DNS, err = GetDNSSettings(params) + if err != nil { + return settings, err + } + settings.Firewall, err = GetFirewallSettings(params) + if err != nil { + return settings, err + } + settings.TinyProxy, err = GetTinyProxySettings(params) + if err != nil { + return settings, err + } + settings.ShadowSocks, err = GetShadowSocksSettings(params) + if err != nil { + return settings, err + } + return settings, nil +} diff --git a/internal/settings/shadowsocks.go b/internal/settings/shadowsocks.go new file mode 100644 index 00000000..63a77bfe --- /dev/null +++ b/internal/settings/shadowsocks.go @@ -0,0 +1,48 @@ +package settings + +import ( + "fmt" + "strings" + + "github.com/qdm12/private-internet-access-docker/internal/params" +) + +// ShadowSocks contains settings to configure the Shadowsocks server +type ShadowSocks struct { + Enabled bool + Password string + Log bool + Port uint16 +} + +func (s *ShadowSocks) String() string { + if !s.Enabled { + return "ShadowSocks settings: disabled" + } + settingsList := []string{ + "ShadowSocks settings:", + fmt.Sprintf("Port: %d", s.Port), + } + return strings.Join(settingsList, "\n |--") +} + +// GetShadowSocksSettings obtains ShadowSocks settings from environment variables using the params package. +func GetShadowSocksSettings(params params.ParamsReader) (settings ShadowSocks, err error) { + settings.Enabled, err = params.GetShadowSocks() + if err != nil || !settings.Enabled { + return settings, err + } + settings.Port, err = params.GetShadowSocksPort() + if err != nil { + return settings, err + } + settings.Password, err = params.GetShadowSocksPassword() + if err != nil { + return settings, err + } + settings.Log, err = params.GetShadowSocksLog() + if err != nil { + return settings, err + } + return settings, nil +} diff --git a/internal/settings/tinyproxy.go b/internal/settings/tinyproxy.go new file mode 100644 index 00000000..1eb5b527 --- /dev/null +++ b/internal/settings/tinyproxy.go @@ -0,0 +1,60 @@ +package settings + +import ( + "fmt" + "strings" + + "github.com/qdm12/private-internet-access-docker/internal/models" + "github.com/qdm12/private-internet-access-docker/internal/params" +) + +// TinyProxy contains settings to configure TinyProxy +type TinyProxy struct { + Enabled bool + User string + Password string + Port uint16 + LogLevel models.TinyProxyLogLevel +} + +func (t *TinyProxy) String() string { + if !t.Enabled { + return "TinyProxy settings: disabled" + } + auth := "disabled" + if t.User != "" { + auth = "enabled" + } + settingsList := []string{ + "TinyProxy settings:", + fmt.Sprintf("Port: %d", t.Port), + "Authentication: " + auth, + "Log level: " + string(t.LogLevel), + } + return "TinyProxy settings:\n" + strings.Join(settingsList, "\n |--") +} + +// GetTinyProxySettings obtains TinyProxy settings from environment variables using the params package. +func GetTinyProxySettings(params params.ParamsReader) (settings TinyProxy, err error) { + settings.Enabled, err = params.GetTinyProxy() + if err != nil || !settings.Enabled { + return settings, err + } + settings.User, err = params.GetTinyProxyUser() + if err != nil { + return settings, err + } + settings.Password, err = params.GetTinyProxyPassword() + if err != nil { + return settings, err + } + settings.Port, err = params.GetTinyProxyPort() + if err != nil { + return settings, err + } + settings.LogLevel, err = params.GetTinyProxyLog() + if err != nil { + return settings, err + } + return settings, nil +} diff --git a/internal/shadowsocks/command.go b/internal/shadowsocks/command.go new file mode 100644 index 00000000..9684e63d --- /dev/null +++ b/internal/shadowsocks/command.go @@ -0,0 +1,40 @@ +package shadowsocks + +import ( + "fmt" + "io" + "strings" + + "github.com/qdm12/private-internet-access-docker/internal/constants" +) + +func (c *configurator) Start(server string, port uint16, password string, log bool) (stdout io.ReadCloser, err error) { + c.logger.Info("%s: starting shadowsocks server", logPrefix) + args := []string{ + "-c", string(constants.ShadowsocksConf), + "-p", fmt.Sprintf("%d", port), + "-k", password, + } + if log { + args = append(args, "-v") + } + stdout, _, _, err = c.commander.Start("ss-server", args...) + return stdout, err +} + +// Version obtains the version of the installed shadowsocks server +func (c *configurator) Version() (string, error) { + output, err := c.commander.Run("ss-server", "-h") + if err != nil { + return "", err + } + lines := strings.Split(output, "\n") + if len(lines) < 2 { + return "", fmt.Errorf("ss-server -h: not enough lines in %q", output) + } + words := strings.Fields(lines[1]) + if len(words) < 2 { + return "", fmt.Errorf("ss-server -h: line 2 is too short: %q", lines[1]) + } + return words[1], nil +} diff --git a/internal/shadowsocks/conf.go b/internal/shadowsocks/conf.go new file mode 100644 index 00000000..f8e3b107 --- /dev/null +++ b/internal/shadowsocks/conf.go @@ -0,0 +1,49 @@ +package shadowsocks + +import ( + "encoding/json" + "fmt" + + "github.com/qdm12/golibs/files" + "github.com/qdm12/private-internet-access-docker/internal/constants" +) + +func (c *configurator) MakeConf(port uint16, password string, uid, gid int) (err error) { + c.logger.Info("%s: generating configuration file", logPrefix) + data := generateConf(port, password) + return c.fileManager.WriteToFile( + string(constants.ShadowsocksConf), + data, + files.FileOwnership(uid, gid), + files.FilePermissions(0400)) +} + +func generateConf(port uint16, password string) (data []byte) { + conf := struct { + Server string `json:"server"` + User string `json:"user"` + Method string `json:"method"` + Timeout uint `json:"timeout"` + FastOpen bool `json:"fast_open"` + Mode string `json:"mode"` + PortPassword map[string]string `json:"port_password"` + Workers uint `json:"workers"` + Interface string `json:"interface"` + Nameserver string `json:"nameserver"` + }{ + Server: "0.0.0.0", + User: "nonrootuser", + Method: "chacha20-ietf-poly1305", + Timeout: 30, + FastOpen: false, + Mode: "tcp_and_udp", + PortPassword: map[string]string{ + fmt.Sprintf("%d", port): password, + }, + Workers: 2, + Interface: "tun", + Nameserver: "127.0.0.1", + } + data, _ = json.Marshal(conf) + return data +} diff --git a/internal/shadowsocks/conf_test.go b/internal/shadowsocks/conf_test.go new file mode 100644 index 00000000..a7a05788 --- /dev/null +++ b/internal/shadowsocks/conf_test.go @@ -0,0 +1,79 @@ +package shadowsocks + +import ( + "fmt" + "testing" + + filesMocks "github.com/qdm12/golibs/files/mocks" + loggingMocks "github.com/qdm12/golibs/logging/mocks" + "github.com/qdm12/private-internet-access-docker/internal/constants" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func Test_generateConf(t *testing.T) { + t.Parallel() + tests := map[string]struct { + port uint16 + password string + data []byte + }{ + "no data": { + data: []byte(`{"server":"0.0.0.0","user":"nonrootuser","method":"chacha20-ietf-poly1305","timeout":30,"fast_open":false,"mode":"tcp_and_udp","port_password":{"0":""},"workers":2,"interface":"tun","nameserver":"127.0.0.1"}`), + }, + "data": { + port: 2000, + password: "abcde", + data: []byte(`{"server":"0.0.0.0","user":"nonrootuser","method":"chacha20-ietf-poly1305","timeout":30,"fast_open":false,"mode":"tcp_and_udp","port_password":{"2000":"abcde"},"workers":2,"interface":"tun","nameserver":"127.0.0.1"}`), + }, + } + for name, tc := range tests { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + data := generateConf(tc.port, tc.password) + assert.Equal(t, tc.data, data) + }) + } +} + +func Test_MakeConf(t *testing.T) { + t.Parallel() + tests := map[string]struct { + writeErr error + err error + }{ + "no write error": {}, + "write error": { + writeErr: fmt.Errorf("error"), + err: fmt.Errorf("error"), + }, + } + for name, tc := range tests { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + logger := &loggingMocks.Logger{} + logger.On("Info", "%s: generating configuration file", logPrefix).Once() + fileManager := &filesMocks.FileManager{} + fileManager.On("WriteToFile", + string(constants.ShadowsocksConf), + []byte(`{"server":"0.0.0.0","user":"nonrootuser","method":"chacha20-ietf-poly1305","timeout":30,"fast_open":false,"mode":"tcp_and_udp","port_password":{"2000":"abcde"},"workers":2,"interface":"tun","nameserver":"127.0.0.1"}`), + mock.AnythingOfType("files.WriteOptionSetter"), + mock.AnythingOfType("files.WriteOptionSetter"), + ). + Return(tc.writeErr).Once() + c := &configurator{logger: logger, fileManager: fileManager} + err := c.MakeConf(2000, "abcde", 1000, 1001) + if tc.err != nil { + require.Error(t, err) + assert.Equal(t, tc.err.Error(), err.Error()) + } else { + assert.NoError(t, err) + } + logger.AssertExpectations(t) + fileManager.AssertExpectations(t) + }) + } +} diff --git a/internal/shadowsocks/shadowsocks.go b/internal/shadowsocks/shadowsocks.go new file mode 100644 index 00000000..52241128 --- /dev/null +++ b/internal/shadowsocks/shadowsocks.go @@ -0,0 +1,27 @@ +package shadowsocks + +import ( + "io" + + "github.com/qdm12/golibs/command" + "github.com/qdm12/golibs/files" + "github.com/qdm12/golibs/logging" +) + +const logPrefix = "shadowsocks configurator" + +type Configurator interface { + Version() (string, error) + MakeConf(port uint16, password string, uid, gid int) (err error) + Start(server string, port uint16, password string, log bool) (stdout io.ReadCloser, err error) +} + +type configurator struct { + fileManager files.FileManager + logger logging.Logger + commander command.Commander +} + +func NewConfigurator(fileManager files.FileManager, logger logging.Logger) Configurator { + return &configurator{fileManager, logger, command.NewCommander()} +} diff --git a/internal/splash/splash.go b/internal/splash/splash.go new file mode 100644 index 00000000..a63791e9 --- /dev/null +++ b/internal/splash/splash.go @@ -0,0 +1,55 @@ +package splash + +import ( + "fmt" + "strings" + "time" + + "github.com/kyokomi/emoji" + "github.com/qdm12/private-internet-access-docker/internal/constants" + "github.com/qdm12/private-internet-access-docker/internal/params" +) + +func Splash(paramsReader params.ParamsReader) string { + version := paramsReader.GetVersion() + vcsRef := paramsReader.GetVcsRef() + buildDate := paramsReader.GetBuildDate() + lines := title() + lines = append(lines, "") + lines = append(lines, fmt.Sprintf("Running version %s built on %s (commit %s)", version, buildDate, vcsRef)) + lines = append(lines, "") + lines = append(lines, annoucement()...) + lines = append(lines, "") + lines = append(lines, links()...) + return strings.Join(lines, "\n") +} + +func title() []string { + return []string{ + "=========================================", + "============= PIA container =============", + "========== An exquisite mix of ==========", + "==== OpenVPN, Unbound, DNS over TLS, ====", + "===== Shadowsocks, Tinyproxy and Go =====", + "=========================================", + "=== Made with " + emoji.Sprint(":heart:") + " by github.com/qdm12 ====", + "=========================================", + } +} + +func annoucement() []string { + timestamp := time.Now().UnixNano() / 1000000000 + if timestamp < constants.AnnoucementExpiration { + return []string{emoji.Sprint(":rotating_light: ") + constants.Annoucement} + } + return nil +} + +func links() []string { + return []string{ + emoji.Sprint(":wrench: ") + "Need help? " + constants.IssueLink, + emoji.Sprint(":computer: ") + "Email? quentin.mcgaw@gmail.com", + emoji.Sprint(":coffee: ") + "Slack? Join from the Slack button on Github", + emoji.Sprint(":money_with_wings: ") + "Help me? https://github.com/sponsors/qdm12", + } +} diff --git a/internal/tinyproxy/command.go b/internal/tinyproxy/command.go new file mode 100644 index 00000000..15786e28 --- /dev/null +++ b/internal/tinyproxy/command.go @@ -0,0 +1,26 @@ +package tinyproxy + +import ( + "fmt" + "io" + "strings" +) + +func (c *configurator) Start() (stdout io.ReadCloser, err error) { + c.logger.Info("%s: starting tinyproxy server", logPrefix) + stdout, _, _, err = c.commander.Start("tinyproxy", "-d") + return stdout, err +} + +// Version obtains the version of the installed Tinyproxy server +func (c *configurator) Version() (string, error) { + output, err := c.commander.Run("tinyproxy", "-v") + if err != nil { + return "", err + } + words := strings.Fields(output) + if len(words) < 2 { + return "", fmt.Errorf("tinyproxy -v: output is too short: %q", output) + } + return words[1], nil +} diff --git a/internal/tinyproxy/conf.go b/internal/tinyproxy/conf.go new file mode 100644 index 00000000..73d06933 --- /dev/null +++ b/internal/tinyproxy/conf.go @@ -0,0 +1,44 @@ +package tinyproxy + +import ( + "fmt" + + "github.com/qdm12/golibs/files" + "github.com/qdm12/private-internet-access-docker/internal/constants" + "github.com/qdm12/private-internet-access-docker/internal/models" +) + +func (c *configurator) MakeConf(logLevel models.TinyProxyLogLevel, port uint16, user, password string, uid, gid int) error { + c.logger.Info("%s: generating tinyproxy configuration file", logPrefix) + lines := generateConf(logLevel, port, user, password) + return c.fileManager.WriteLinesToFile(string(constants.TinyProxyConf), + lines, + files.FileOwnership(uid, gid), + files.FilePermissions(0400)) +} + +func generateConf(logLevel models.TinyProxyLogLevel, port uint16, user, password string) (lines []string) { + confMapping := map[string]string{ + "User": "nonrootuser", + "Group": "tinyproxy", + "Port": fmt.Sprintf("%d", port), + "Timeout": "600", + "DefaultErrorFile": "/usr/share/tinyproxy/default.html", + "MaxClients": "100", + "MinSpareServers": "5", + "MaxSpareServers": "20", + "StartServers": "10", + "MaxRequestsPerChild": "0", + "DisableViaHeader": "Yes", + "LogLevel": string(logLevel), + // "StatFile": "/usr/share/tinyproxy/stats.html", + } + if len(user) > 0 { + confMapping["BasicAuth"] = fmt.Sprintf("%s %s", user, password) + } + for k, v := range confMapping { + line := fmt.Sprintf("%s %s", k, v) + lines = append(lines, line) + } + return lines +} diff --git a/internal/tinyproxy/tinyproxy.go b/internal/tinyproxy/tinyproxy.go new file mode 100644 index 00000000..663389a0 --- /dev/null +++ b/internal/tinyproxy/tinyproxy.go @@ -0,0 +1,28 @@ +package tinyproxy + +import ( + "io" + + "github.com/qdm12/golibs/command" + "github.com/qdm12/golibs/files" + "github.com/qdm12/golibs/logging" + "github.com/qdm12/private-internet-access-docker/internal/models" +) + +const logPrefix = "tinyproxy configurator" + +type Configurator interface { + Version() (string, error) + MakeConf(logLevel models.TinyProxyLogLevel, port uint16, user, password string, uid, gid int) error + Start() (stdout io.ReadCloser, err error) +} + +type configurator struct { + fileManager files.FileManager + logger logging.Logger + commander command.Commander +} + +func NewConfigurator(fileManager files.FileManager, logger logging.Logger) Configurator { + return &configurator{fileManager, logger, command.NewCommander()} +} diff --git a/portforward.sh b/portforward.sh deleted file mode 100644 index 23205407..00000000 --- a/portforward.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/bin/sh - -exitOnError(){ - # $1 must be set to $? - status=$1 - message=$2 - [ "$message" != "" ] || message="Undefined error" - if [ $status != 0 ]; then - printf "[ERROR] $message, with status $status)\n" - exit $status - fi -} - -warnOnError(){ - # $1 must be set to $? - status=$1 - message=$2 - [ "$message" != "" ] || message="Undefined error" - if [ $status != 0 ]; then - printf "[WARNING] $message, with status $status)\n" - fi -} - -printf "[INFO] Reading forwarded port\n" -printf " * Generating client ID...\n" -client_id=`head -n 100 /dev/urandom | sha256sum | tr -d " -"` -exitOnError $? "Unable to generate Client ID" -printf " * Obtaining forward port from PIA server...\n" -json=`wget -qO- "http://209.222.18.222:2000/?client_id=$client_id"` -exitOnError $? "Could not obtain response from PIA server (does your PIA server support port forwarding?)" -if [ "$json" == "" ]; then - printf "[ERROR] Port forwarding is already activated on this connection, has expired, or you are not connected to a PIA region that supports port forwarding\n" - exit 1 -fi -printf " * Parsing JSON response...\n" -port=`echo $json | jq .port` -exitOnError $? "Cannot find port in JSON response" -printf " * Writing forwarded port to file...\n" -port_status_folder=`dirname "${PORT_FORWARDING_STATUS_FILE}"` -warnOnError $? "Cannot find parent directory of ${PORT_FORWARDING_STATUS_FILE}" -mkdir -p "${port_status_folder}" -warnOnError $? "Cannot create containing directory ${port_status_folder}" -echo "$port" > "${PORT_FORWARDING_STATUS_FILE}" -warnOnError $? "Cannot write port to ${PORT_FORWARDING_STATUS_FILE}" -printf " * Detecting current VPN IP address...\n" -ip=`wget -qO- https://duckduckgo.com/\?q=ip | grep -oE "\b([0-9]{1,3}\.){3}[0-9]{1,3}\b"` -warnOnError $? "Cannot detect remote VPN IP on https://duckduckgo.com" -printf " * Forwarded port accessible at $ip:$port\n" -printf " * Detecting target VPN interface...\n" -vpn_device=$(cat /openvpn/target/config.ovpn | grep 'dev ' | cut -d" " -f 2)0 -exitOnError $? "Unable to find VPN interface in /openvpn/target/config.ovpn" -printf " * Accepting input traffic through $vpn_device to port $port...\n" -iptables -A INPUT -i $vpn_device -p tcp --dport $port -j ACCEPT -exitOnError $? "Unable to allow the forwarded port in TCP" -iptables -A INPUT -i $vpn_device -p udp --dport $port -j ACCEPT -exitOnError $? "Unable to allow the forwarded port in UDP" -printf "[INFO] Port forwarded successfully\n" diff --git a/shadowsocks.json b/shadowsocks.json deleted file mode 100644 index 7293bf8d..00000000 --- a/shadowsocks.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "server": "0.0.0.0", - "user": "nonrootuser", - "method": "chacha20-ietf-poly1305", - "timeout": 30, - "fast_open": false, - "mode": "tcp_and_udp", - "port_password": { - "8388": "" - }, - "workers": 2, - "interface": "tun", - "nameserver": "127.0.0.1" -} \ No newline at end of file diff --git a/tinyproxy.conf b/tinyproxy.conf deleted file mode 100644 index 515f09f2..00000000 --- a/tinyproxy.conf +++ /dev/null @@ -1,13 +0,0 @@ -User tinyproxy -Group tinyproxy -Port 8888 -Timeout 600 -DefaultErrorFile "/usr/share/tinyproxy/default.html" -MaxClients 100 -MinSpareServers 5 -MaxSpareServers 20 -StartServers 10 -MaxRequestsPerChild 0 -DisableViaHeader Yes -LogLevel Critical -# StatFile "/usr/share/tinyproxy/stats.html" \ No newline at end of file diff --git a/unbound.conf b/unbound.conf deleted file mode 100644 index d39fd577..00000000 --- a/unbound.conf +++ /dev/null @@ -1,58 +0,0 @@ -server: - # See https://www.nlnetlabs.nl/documentation/unbound/unbound.conf/ - # logging - verbosity: 0 - val-log-level: 0 - use-syslog: yes - - # performance - num-threads: 1 - prefetch: yes - prefetch-key: yes - key-cache-size: 16m - key-cache-slabs: 4 - msg-cache-size: 4m - msg-cache-slabs: 4 - rrset-cache-size: 4m - rrset-cache-slabs: 4 - cache-min-ttl: 3600 - cache-max-ttl: 9000 - - # privacy - rrset-roundrobin: yes - hide-identity: yes - hide-version: yes - - # security - tls-cert-bundle: "/etc/ssl/certs/ca-certificates.crt" - root-hints: "/etc/unbound/root.hints" - trust-anchor-file: "/etc/unbound/root.key" - harden-below-nxdomain: yes - harden-referral-path: yes - harden-algo-downgrade: yes - # set above to no if there is any problem - # Prevent DNS rebinding - private-address: 127.0.0.1/8 - private-address: 10.0.0.0/8 - private-address: 172.16.0.0/12 - private-address: 192.168.0.0/16 - private-address: 169.254.0.0/16 - private-address: ::1/128 - private-address: fc00::/7 - private-address: fe80::/10 - private-address: ::ffff:0:0/96 - - # network - do-ip4: yes - do-ip6: no - interface: 127.0.0.1 - port: 53 - username: "nonrootuser" - - # other files - include: "/etc/unbound/blocks-malicious.conf" -forward-zone: - name: "." - forward-addr: 1.1.1.1@853#cloudflare-dns.com - forward-addr: 1.0.0.1@853#cloudflare-dns.com - forward-tls-upstream: yes