diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d37a05c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +* +!data/ \ No newline at end of file diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml deleted file mode 100644 index 1aab0d7..0000000 --- a/.github/workflows/build.yaml +++ /dev/null @@ -1,46 +0,0 @@ -name: Build - -on: - push: - tags: - - 'v[0-9]+.[0-9]+.[0-9]+' - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: docker/setup-qemu-action@v2 - - uses: docker/setup-buildx-action@v2 - - - uses: docker/login-action@v2 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - - id: tags - uses: docker/metadata-action@v4 - with: - images: ghcr.io/wfg/openvpn-client - tags: | - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}},enable=${{ !startsWith(github.ref, 'refs/tags/v0.') }} - - - id: build-args - run: | - ref=${{ github.ref }} - vpatch=${ref##refs/*/} - patch=${vpatch#v} - echo "::set-output name=date::$(date --utc --iso-8601=seconds)" - echo "::set-output name=version::$patch" - - - uses: docker/build-push-action@v3 - with: - context: "{{defaultContext}}:build" - platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6 - build-args: | - BUILD_DATE=${{ steps.build-args.outputs.date }} - IMAGE_VERSION=${{ steps.build-args.outputs.version }} - tags: ${{ steps.tags.outputs.tags }} - push: true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..0d5bed5 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,59 @@ +name: Publish + +on: + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+' + +env: + IMAGE_NAME: openvpn-client + +jobs: + publish: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v2 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v1 + + - name: Log in to registry + uses: docker/login-action@v1 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create tags + id: tags + uses: docker/metadata-action@v3 + with: + images: ghcr.io/wfg/openvpn-client + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + + - name: Create build args + id: build-args + run: | + ref=${{ github.ref }} + vpatch=${ref##refs/*/} + patch=${vpatch#v} + echo "::set-output name=date::$(date --utc --iso-8601=seconds)" + echo "::set-output name=version::$patch" + + - name: Build and push + uses: docker/build-push-action@v2 + with: + platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6 + tags: ${{ steps.tags.outputs.tags }} + build-args: | + BUILD_DATE=${{ steps.build-args.outputs.date }} + IMAGE_VERSION=${{ steps.build-args.outputs.version }} + push: true diff --git a/.github/workflows/sweep.yml b/.github/workflows/sweep.yml new file mode 100644 index 0000000..336af5b --- /dev/null +++ b/.github/workflows/sweep.yml @@ -0,0 +1,19 @@ +name: Mark issues as stale + +on: + schedule: + - cron: '0 3 * * *' + +jobs: + stale: + runs-on: ubuntu-latest + + steps: + - uses: actions/stale@v4 + with: + stale-issue-label: no-issue-activity + stale-pr-label: no-pr-activity + days-before-stale: 30 + days-before-close: -1 + exempt-assignees: wfg + any-of-labels: needs-more-info diff --git a/.gitignore b/.gitignore index f03c129..aa052db 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1 @@ -# Anything used during development should be put in local/ to prevent accidental committing. -local/ +local/ \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..793b942 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,9 @@ +repos: + - repo: https://github.com/norwoodj/helm-docs + rev: v1.6.0 + hooks: + - id: helm-docs + args: + - --chart-search-root=chart + - --template-files=./_templates.gotmpl + - --template-files=README.md.gotmpl diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..621dbac --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +## [2.0.0] - 2022-01-02 +### Changed +- `OPENVPN_AUTH_SECRET` changed to `VPN_AUTH_SECRET` for consistency. + +### Fixed +- Commented remotes are no longer processed. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b65d232 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +FROM alpine:3.15 + +ARG IMAGE_VERSION +ARG BUILD_DATE + +LABEL org.opencontainers.image.created="$BUILD_DATE" +LABEL org.opencontainers.image.source="github.com/wfg/docker-openvpn-client" +LABEL org.opencontainers.image.version="$IMAGE_VERSION" + +ENV KILL_SWITCH=on \ + VPN_LOG_LEVEL=3 \ + HTTP_PROXY=off \ + SOCKS_PROXY=off + +RUN apk add --no-cache \ + bash \ + bind-tools \ + dante-server \ + openvpn \ + tinyproxy + +RUN mkdir -p /data/vpn + +COPY data/ /data + +HEALTHCHECK CMD ping -c 3 1.1.1.1 || exit 1 + +ENTRYPOINT ["/data/scripts/entry.sh"] diff --git a/README.md b/README.md index a002dc7..f01df29 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,49 @@ # OpenVPN Client for Docker - -Archived in favor of [a WireGuard version](https://github.com/wfg/docker-wireguard). - ## What is this and what does it do? [`ghcr.io/wfg/openvpn-client`](https://github.com/users/wfg/packages/container/package/openvpn-client) is a containerized OpenVPN client. It has a kill switch built with `iptables` that kills Internet connectivity to the container if the VPN tunnel goes down for any reason. +It also includes an HTTP proxy server ([Tinyproxy](https://tinyproxy.github.io/)) and a SOCKS proxy server ([Dante](https://www.inet.no/dante/index.html)). +This allows hosts and non-containerized applications to use the VPN without having to run VPN clients on those hosts. This image requires you to supply the necessary OpenVPN configuration file(s). Because of this, any VPN provider should work. -If you find something that doesn't work or have an idea for a new feature, issues and **pull requests are welcome** (however, I'm not promising they will be merged). +If you find something that doesn't work or have an idea for a new feature, issues and **pull requests are welcome**. ## Why? Having a containerized VPN client lets you use container networking to easily choose which applications you want using the VPN instead of having to set up split tunnelling. It also keeps you from having to install an OpenVPN client on the underlying host. +The idea for this image came from a similar project by [qdm12](https://github.com/qdm12) that has since evolved into something bigger and more complex than I wanted to use. +I decided to dissect it and take it in my own direction. +I plan to keep everything here well-documented so this is not only a learning experience for me, but also anyone else that uses it. + ## How do I use it? ### Getting the image You can either pull it from GitHub Container Registry or build it yourself. To pull it from GitHub Container Registry, run -``` +```bash docker pull ghcr.io/wfg/openvpn-client ``` To build it yourself, run -``` -docker build -t ghcr.io/wfg/openvpn-client https://github.com/wfg/docker-openvpn-client.git#:build +```bash +docker build -t ghcr.io/wfg/openvpn-client https://github.com/wfg/docker-openvpn-client.git ``` ### Creating and running a container The image requires the container be created with the `NET_ADMIN` capability and `/dev/net/tun` accessible. Below are bare-bones examples for `docker run` and Compose; however, you'll probably want to do more than just run the VPN client. -See the below to learn how to have [other containers use `openvpn-client`'s network stack](#using-with-other-containers). +See the sections below to learn how to use the [proxies](#http_proxy-and-socks_proxy) and have [other containers use `openvpn-client`'s network stack](#using-with-other-containers). #### `docker run` -``` +```bash docker run --detach \ --name=openvpn-client \ --cap-add=NET_ADMIN \ --device=/dev/net/tun \ - --volume :/config \ + --volume :/data/vpn \ ghcr.io/wfg/openvpn-client ``` @@ -55,26 +58,38 @@ services: devices: - /dev/net/tun volumes: - - :/config + - :/data/vpn restart: unless-stopped ``` -#### Environment variables +#### Environment variables (alphabetical) | Variable | Default (blank is unset) | Description | | --- | --- | --- | -| `ALLOWED_SUBNETS` | | A list of one or more comma-separated subnets (e.g. `192.168.0.0/24,192.168.1.0/24`) to allow outside of the VPN tunnel. | -| `AUTH_SECRET` | | Docker secret that contains the credentials for accessing the VPN. | -| `CONFIG_FILE` | | The OpenVPN configuration file or search pattern. If unset, a random `.conf` or `.ovpn` file will be selected. | -| `KILL_SWITCH` | `on` | Whether or not to enable the kill switch. Set to any "truthy" value[1] to enable. | - -[1] "Truthy" values in this context are the following: `true`, `t`, `yes`, `y`, `1`, `on`, `enable`, or `enabled`. +| `HTTP_PROXY` | `off` | The on/off status of Tinyproxy, the built-in HTTP proxy server. To enable, set to `on`. Any other value (including unset) will cause the proxy server to not start. It listens on port 8080. | +| `KILL_SWITCH` | `on` | The on/off status of the network kill switch. | +| `LISTEN_ON` | | Address the proxies will be listening on. Set to `0.0.0.0` to listen on all IP addresses. | +| `PROXY_PASSWORD` | | Credentials for accessing the proxies. If `PROXY_PASSWORD` is specified, you must also specify `PROXY_USERNAME`. | +| `PROXY_PASSWORD_SECRET` | | Docker secrets that contain the credentials for accessing the proxies. If `PROXY_PASSWORD_SECRET` is specified, you must also specify `PROXY_USERNAME_SECRET`. | +| `PROXY_USERNAME` | | Credentials for accessing the proxies. If `PROXY_USERNAME` is specified, you must also specify `PROXY_PASSWORD`. | +| `PROXY_USERNAME_SECRET` | | Docker secrets that contain the credentials for accessing the proxies. If `PROXY_USERNAME_SECRET` is specified, you must also specify `PROXY_PASSWORD_SECRET`. | +| `SOCKS_PROXY` | `off` | The on/off status of Dante, the built-in SOCKS proxy server. To enable, set to `on`. Any other value (including unset) will cause the proxy server to not start. It listens on port 1080. | +| `SUBNETS` | | A list of one or more comma-separated subnets (e.g. `192.168.0.0/24,192.168.1.0/24`) to allow outside of the VPN tunnel. | +| `VPN_AUTH_SECRET` | | Docker secret that contain the credentials for accessing the VPN. | +| `VPN_CONFIG_FILE` | | The OpenVPN config file to use. If this is unset, the first file with the extension .conf will be used. | +| `VPN_LOG_LEVEL` | `3` | OpenVPN verbosity (`1`-`11`) | ##### Environment variable considerations -###### `ALLOWED_SUBNETS` -If you intend on connecting to containers that use the OpenVPN container's network stack (which you probably do), **you will probably want to use this variable**. -Regardless of whether or not you're using the kill switch, the entrypoint script also adds routes to each of the `ALLOWED_SUBNETS` to allow network connectivity from outside of Docker. +###### `HTTP_PROXY` and `SOCKS_PROXY` +If enabling the the proxy server(s), you'll want to publish the appropriate port(s) in order to access the server(s). +To do that using `docker run`, add `-p :8080` and/or `-p :1080` where `` is whatever port you want to use on the host. +If you're using `docker-compose`, add the relevant port specification(s) from the snippet below to the `openvpn-client` service definition in your Compose file. +```yaml +ports: + - :8080 + - :1080 +``` -##### `AUTH_SECRET` +###### `PROXY_USERNAME_SECRET`, `PROXY_PASSWORD_SECRET`, and `VPN_AUTH_SECRET` Compose has support for [Docker secrets](https://docs.docker.com/engine/swarm/secrets/#use-secrets-in-compose). See the [Compose file](docker-compose.yml) in this repository for example usage of passing proxy credentials as Docker secrets. @@ -103,24 +118,6 @@ In both cases, replace `` and `` with the port used b Once you have container running `ghcr.io/wfg/openvpn-client`, run the following command to spin up a temporary container using `openvpn-client` for networking. The `wget -qO - ifconfig.me` bit will return the public IP of the container (and anything else using `openvpn-client` for networking). You should see an IP address owned by your VPN provider. -``` +```bash docker run --rm -it --network=container:openvpn-client alpine wget -qO - ifconfig.me ``` - -### Troubleshooting -#### VPN authentication -Your OpenVPN configuration file may not come with authentication baked in. -To provide OpenVPN the necessary credentials, create a file (any name will work, but this example will use `credentials.txt`) next to the OpenVPN configuration file with your username on the first line and your password on the second line. - -For example: -``` -vpn_username -vpn_password -``` - -In the OpenVPN configuration file, add the following line: -``` -auth-user-pass credentials.txt -``` - -This will tell OpenVPN to read `credentials.txt` whenever it needs credentials. diff --git a/build/.dockerignore b/build/.dockerignore deleted file mode 100644 index 9414382..0000000 --- a/build/.dockerignore +++ /dev/null @@ -1 +0,0 @@ -Dockerfile diff --git a/build/Dockerfile b/build/Dockerfile deleted file mode 100644 index d1f2391..0000000 --- a/build/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -FROM alpine:3.17 - -RUN apk add --no-cache \ - bash \ - bind-tools \ - iptables \ - ip6tables \ - openvpn - -COPY . /usr/local/bin - -ENV KILL_SWITCH=on - -ENTRYPOINT [ "entry.sh" ] diff --git a/build/entry.sh b/build/entry.sh deleted file mode 100755 index 85e7c0f..0000000 --- a/build/entry.sh +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env bash - -set -o errexit -set -o nounset -set -o pipefail - -cleanup() { - kill TERM "$openvpn_pid" - exit 0 -} - -is_enabled() { - [[ ${1,,} =~ ^(true|t|yes|y|1|on|enable|enabled)$ ]] -} - -# Either a specific file name or a pattern. -if [[ $CONFIG_FILE ]]; then - config_file=$(find /config -name "$CONFIG_FILE" 2> /dev/null | sort | shuf -n 1) -else - config_file=$(find /config -name '*.conf' -o -name '*.ovpn' 2> /dev/null | sort | shuf -n 1) -fi - -if [[ -z $config_file ]]; then - echo "no openvpn configuration file found" >&2 - exit 1 -fi - -echo "using openvpn configuration file: $config_file" - - -openvpn_args=( - "--config" "$config_file" - "--cd" "/config" -) - -if is_enabled "$KILL_SWITCH"; then - openvpn_args+=("--route-up" "/usr/local/bin/killswitch.sh $ALLOWED_SUBNETS") -fi - -# Docker secret that contains the credentials for accessing the VPN. -if [[ $AUTH_SECRET ]]; then - openvpn_args+=("--auth-user-pass" "/run/secrets/$AUTH_SECRET") -fi - -openvpn "${openvpn_args[@]}" & -openvpn_pid=$! - -trap cleanup TERM - -wait $openvpn_pid diff --git a/build/killswitch.sh b/build/killswitch.sh deleted file mode 100755 index 5f917f9..0000000 --- a/build/killswitch.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env bash - -set -o errexit -set -o nounset -set -o pipefail - -iptables --insert OUTPUT \ - ! --out-interface tun0 \ - --match addrtype ! --dst-type LOCAL \ - ! --destination "$(ip -4 -oneline addr show dev eth0 | awk 'NR == 1 { print $4 }')" \ - --jump REJECT - -# Create static routes for any ALLOWED_SUBNETS and punch holes in the firewall -# (ALLOWED_SUBNETS is passed as $1 from entry.sh) -default_gateway=$(ip -4 route | awk '$1 == "default" { print $3 }') -for subnet in ${1//,/ }; do - ip route add "$subnet" via "$default_gateway" - iptables --insert OUTPUT --destination "$subnet" --jump ACCEPT -done - -# Punch holes in the firewall for the OpenVPN server addresses -# $config is set by OpenVPN: -# "Name of first --config file. Set on program initiation and reset on SIGHUP." -global_port=$(awk '$1 == "port" { print $2 }' "${config:?"config file not found by kill switch"}") -global_protocol=$(awk '$1 == "proto" { print $2 }' "${config:?"config file not found by kill switch"}") -remotes=$(awk '$1 == "remote" { print $2, $3, $4 }' "${config:?"config file not found by kill switch"}") -ip_regex='^(([1-9]?[0-9]|1[0-9][0-9]|2([0-4][0-9]|5[0-5]))\.){3}([1-9]?[0-9]|1[0-9][0-9]|2([0-4][0-9]|5[0-5]))$' -while IFS= read -r line; do - # Read a comment-stripped version of the line - # Fixes #84 - IFS=" " read -ra remote <<< "${line%%\#*}" - address=${remote[0]} - port=${remote[1]:-${global_port:-1194}} - protocol=${remote[2]:-${global_protocol:-udp}} - - if [[ $address =~ $ip_regex ]]; then - iptables --insert OUTPUT --destination "$address" --protocol "$protocol" --destination-port "$port" --jump ACCEPT - else - for ip in $(dig -4 +short "$address"); do - iptables --insert OUTPUT --destination "$ip" --protocol "$protocol" --destination-port "$port" --jump ACCEPT - echo "$ip $address" >> /etc/hosts - done - fi -done <<< "$remotes" diff --git a/chart/.helmignore b/chart/.helmignore new file mode 100644 index 0000000..0e8a0eb --- /dev/null +++ b/chart/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/chart/Chart.yaml b/chart/Chart.yaml new file mode 100644 index 0000000..4e95a77 --- /dev/null +++ b/chart/Chart.yaml @@ -0,0 +1,15 @@ +apiVersion: v2 +name: openvpn-client +description: A Helm chart for an OpenVPN client with HTTP and SOCKS5 proxies +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: "v1.2.1" diff --git a/chart/README.md b/chart/README.md new file mode 100644 index 0000000..a6ff726 --- /dev/null +++ b/chart/README.md @@ -0,0 +1,41 @@ +# openvpn-client + +![Version: 0.1.0](https://img.shields.io/badge/Version-0.1.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: v1.2.1](https://img.shields.io/badge/AppVersion-v1.2.1-informational?style=flat-square) + +A Helm chart for an OpenVPN client with HTTP and SOCKS5 proxies + +## Values + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| affinity | object | `{}` | | +| auth.enabled | bool | `false` | Whether to turn on authentication for the proxies | +| auth.existingSecret | string | `""` | Existing secret containing the credentials for accessing the proxies. | +| auth.proxyPassword | string | `""` | | +| auth.proxyUsername | string | `""` | | +| autoscaling.enabled | bool | `false` | | +| autoscaling.maxReplicas | int | `100` | | +| autoscaling.minReplicas | int | `1` | | +| autoscaling.targetCPUUtilizationPercentage | int | `80` | | +| configFiles.files | object | `{}` | OpenVPN config files | +| configFiles.openVPNConfig | string | `""` | The OpenVPN config file to use. If this is unset, the first file with the extension `.conf` will be used. | +| fullnameOverride | string | `""` | | +| httpProxy.enabled | bool | `false` | The on/off status of Tinyproxy, the built-in HTTP proxy server. | +| image.pullPolicy | string | `"IfNotPresent"` | | +| image.repository | string | `"ghcr.io/wfg/openvpn-client"` | | +| image.tag | string | `""` | Overrides the image tag whose default is the chart appVersion. | +| killSwitch.enabled | bool | `true` | The on/off status of the network kill switch. | +| listenOn | string | `""` | Address the proxies will be listening on. Set to `0.0.0.0` to allow all IP addresses. | +| nameOverride | string | `""` | | +| nodeSelector | object | `{}` | | +| podAnnotations | object | `{}` | | +| replicaCount | int | `1` | | +| resources | object | `{}` | | +| service.type | string | `"ClusterIP"` | | +| socksProxy.enabled | bool | `false` | The on/off status of Dante, the built-in SOCKS proxy server. | +| subnets | list | `[]` | A list of one or more subnets to allow outside of the VPN tunnel. | +| tolerations | list | `[]` | | +| vpnLogLevel | int | `3` | OpenVPN verbosity (`1`-`11`) | + +---------------------------------------------- +Autogenerated from chart metadata using [helm-docs v1.5.0](https://github.com/norwoodj/helm-docs/releases/v1.5.0) diff --git a/chart/templates/_helpers.tpl b/chart/templates/_helpers.tpl new file mode 100644 index 0000000..26310bd --- /dev/null +++ b/chart/templates/_helpers.tpl @@ -0,0 +1,76 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "openvpn-client.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "openvpn-client.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "openvpn-client.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "openvpn-client.labels" -}} +helm.sh/chart: {{ include "openvpn-client.chart" . }} +{{ include "openvpn-client.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "openvpn-client.selectorLabels" -}} +app.kubernetes.io/name: {{ include "openvpn-client.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Convert boolean to on/off +*/}} +{{- define "openvpn-client.boolean" -}} +{{- if .enabled }} "on" {{- else }} "off" {{- end }} +{{- end }} + +{{/* +Define auth secret name +*/}} +{{- define "openvpn-client.authSecretName" -}} +{{- if .Values.auth.existingSecret -}} + {{- .Values.auth.existingSecret -}} +{{- else -}} + {{- include "openvpn-client.fullname" . | printf "%s-auth" }} +{{- end -}} +{{- end -}} + +{{/* +Define config secret name +*/}} +{{- define "openvpn-client.configSecretName" -}} + {{- include "openvpn-client.fullname" . | printf "%s-config" }} +{{- end -}} diff --git a/chart/templates/auth-secret.yaml b/chart/templates/auth-secret.yaml new file mode 100644 index 0000000..8af8f73 --- /dev/null +++ b/chart/templates/auth-secret.yaml @@ -0,0 +1,12 @@ +{{- if and .Values.auth.enabled (not .Values.auth.existingSecret) (or .Values.httpProxy.enabled .Values.socksProxy.enabled) -}} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "openvpn-client.authSecretName" . }} + labels: + {{- include "openvpn-client.labels" . | nindent 4 }} +type: kubernetes.io/basic-auth +data: + username: {{ .Values.auth.proxyUsername | b64enc | quote }} + password: {{ .Values.auth.proxyPassword | b64enc | quote }} +{{- end -}} diff --git a/chart/templates/config-secret.yaml b/chart/templates/config-secret.yaml new file mode 100644 index 0000000..360258e --- /dev/null +++ b/chart/templates/config-secret.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "openvpn-client.configSecretName" . }} + labels: + {{- include "openvpn-client.labels" . | nindent 4 }} +type: Opaque +data: + {{- range $fileName, $fileContent := $.Values.configFiles.files }} + {{ $fileName }}: |- + {{- $fileContent | b64enc | nindent 4 }} + {{- end }} diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml new file mode 100644 index 0000000..585d04b --- /dev/null +++ b/chart/templates/deployment.yaml @@ -0,0 +1,106 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "openvpn-client.fullname" . }} + labels: + {{- include "openvpn-client.labels" . | nindent 4 }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} + {{- end }} + selector: + matchLabels: + {{- include "openvpn-client.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "openvpn-client.selectorLabels" . | nindent 8 }} + spec: + initContainers: + - name: copy + image: busybox + command: ["/bin/sh", "-c", "cp -r /from/. /to"] + volumeMounts: + - name: openvpn-client + mountPath: /from + - name: configs + mountPath: /to + containers: + - name: {{ .Chart.Name }} + securityContext: + capabilities: + add: + - NET_ADMIN + privileged: true + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + ports: + {{- if .Values.socksProxy.enabled }} + - containerPort: 1080 + {{- end }} + {{- if .Values.httpProxy.enabled }} + - containerPort: 8080 + {{- end }} + readinessProbe: + exec: + command: ["ping", "-c", "3", "1.1.1.1"] + env: + - name: VPN_LOG_LEVEL + value: {{ .Values.vpnLogLevel | quote }} + - name: SOCKS_PROXY + value: {{- include "openvpn-client.boolean" .Values.socksProxy }} + - name: HTTP_PROXY + value: {{- include "openvpn-client.boolean" .Values.httpProxy }} + - name: KILL_SWITCH + value: {{- include "openvpn-client.boolean" .Values.killSwitch }} + {{- if .Values.listenOn }} + - name: LISTEN_ON + value: {{ .Values.listenOn }} + {{- end }} + {{- if .Values.subnets }} + - name: SUBNETS + value: {{ join "," .Values.subnets | quote }} + {{- end }} + {{- if .Values.auth.enabled }} + - name: PROXY_USERNAME + valueFrom: + secretKeyRef: + name: {{ include "openvpn-client.authSecretName" . }} + key: username + - name: PROXY_PASSWORD + valueFrom: + secretKeyRef: + name: {{ include "openvpn-client.authSecretName" . }} + key: password + {{- end }} + {{- with .Values.configFiles.openVPNConfig }} + - name: VPN_CONFIG_FILE + value: {{ . }} + {{- end }} + volumeMounts: + - mountPath: /data/vpn + name: configs + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + volumes: + - name: openvpn-client + secret: + secretName: {{ include "openvpn-client.configSecretName" . }} + - name: configs + emptyDir: {} diff --git a/chart/templates/hpa.yaml b/chart/templates/hpa.yaml new file mode 100644 index 0000000..22d8b37 --- /dev/null +++ b/chart/templates/hpa.yaml @@ -0,0 +1,28 @@ +{{- if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2beta1 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "openvpn-client.fullname" . }} + labels: + {{- include "openvpn-client.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "openvpn-client.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + targetAverageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + targetAverageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} +{{- end }} diff --git a/chart/templates/service.yaml b/chart/templates/service.yaml new file mode 100644 index 0000000..f628a13 --- /dev/null +++ b/chart/templates/service.yaml @@ -0,0 +1,23 @@ +{{- if or .Values.socksProxy.enabled .Values.httpProxy.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "openvpn-client.fullname" . }} + labels: + {{- include "openvpn-client.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + {{- if .Values.socksProxy.enabled }} + - port: 1080 + protocol: TCP + name: socks5 + {{- end }} + {{- if .Values.httpProxy.enabled }} + - port: 8080 + protocol: TCP + name: http + {{- end }} + selector: + {{- include "openvpn-client.selectorLabels" . | nindent 4 }} +{{- end }} diff --git a/chart/values.yaml b/chart/values.yaml new file mode 100644 index 0000000..1eb5f07 --- /dev/null +++ b/chart/values.yaml @@ -0,0 +1,84 @@ +# Default values for openvpn-client. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +replicaCount: 1 + +image: + repository: ghcr.io/wfg/openvpn-client + pullPolicy: IfNotPresent + # -- Overrides the image tag whose default is the chart appVersion. + tag: "" + +nameOverride: "" +fullnameOverride: "" + +podAnnotations: {} + +service: + type: ClusterIP + +resources: + {} + # limits: + # cpu: 10m + # memory: 64Mi + # requests: + # cpu: 10m + # memory: 64Mi + +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 100 + targetCPUUtilizationPercentage: 80 + # targetMemoryUtilizationPercentage: 80 + +nodeSelector: {} + +tolerations: [] + +affinity: {} + +httpProxy: + # -- The on/off status of Tinyproxy, the built-in HTTP proxy server. + enabled: false + +socksProxy: + # -- The on/off status of Dante, the built-in SOCKS proxy server. + enabled: false + +# -- Address the proxies will be listening on. Set to `0.0.0.0` to allow all IP addresses. +listenOn: "" + +auth: + # -- Whether to turn on authentication for the proxies + enabled: false + # -- Existing secret containing the credentials for accessing the proxies. + existingSecret: "" + proxyUsername: "" + proxyPassword: "" + +# -- OpenVPN verbosity (`1`-`11`) +vpnLogLevel: 3 + +# -- A list of one or more subnets to allow outside of the VPN tunnel. +subnets: + [] + # - "192.168.0.0/24" + # - "192.168.1.0/24" + +killSwitch: + # -- The on/off status of the network kill switch. + enabled: true + +configFiles: + # -- The OpenVPN config file to use. If this is unset, the first file with the extension `.conf` will be used. + openVPNConfig: "" + # -- OpenVPN config files + files: + {} + # example.conf: |- + # # Your OpenVPN configuration file + # Line 1 + # Line 2 diff --git a/data/scripts/dante_wrapper.sh b/data/scripts/dante_wrapper.sh new file mode 100755 index 0000000..4e9d566 --- /dev/null +++ b/data/scripts/dante_wrapper.sh @@ -0,0 +1,9 @@ +#!/bin/bash + +echo -e "Running Dante SOCKS proxy server.\n" + +until ip link show tun0 2>&1 | grep -qv "does not exist"; do + sleep 1 +done + +sockd -f /data/sockd.conf diff --git a/data/scripts/entry.sh b/data/scripts/entry.sh new file mode 100755 index 0000000..d7ede9d --- /dev/null +++ b/data/scripts/entry.sh @@ -0,0 +1,226 @@ +#!/usr/bin/env bash + +cleanup() { + # When you run `docker stop` or any equivalent, a SIGTERM signal is sent to PID 1. + # A process running as PID 1 inside a container is treated specially by Linux: + # it ignores any signal with the default action. As a result, the process will + # not terminate on SIGINT or SIGTERM unless it is coded to do so. Because of this, + # I've defined behavior for when SIGINT and SIGTERM is received. + if [[ -n "$openvpn_child" ]]; then + echo "Stopping OpenVPN..." + kill -TERM "$openvpn_child" + fi + + sleep 1 + rm "$config_file_modified" + echo "Exiting." + exit 0 +} + +# OpenVPN log levels are 1-11. +# shellcheck disable=SC2153 +if [[ "$VPN_LOG_LEVEL" -lt 1 || "$VPN_LOG_LEVEL" -gt 11 ]]; then + echo "WARNING: Invalid log level $VPN_LOG_LEVEL. Setting to default." + vpn_log_level=3 +else + vpn_log_level=$VPN_LOG_LEVEL +fi + +echo " +---- Running with the following variables ---- +Kill switch: ${KILL_SWITCH:-off} +HTTP proxy: ${HTTP_PROXY:-off} +SOCKS proxy: ${SOCKS_PROXY:-off} +Proxy username secret: ${PROXY_PASSWORD_SECRET:-none} +Proxy password secret: ${PROXY_USERNAME_SECRET:-none} +Allowing subnets: ${SUBNETS:-none} +Using OpenVPN log level: $vpn_log_level +Listening on: ${LISTEN_ON:-none}" + +if [[ -n "$VPN_CONFIG_FILE" ]]; then + config_file_original="/data/vpn/$VPN_CONFIG_FILE" +else + # Capture the filename of the first .conf file to use as the OpenVPN config. + config_file_original=$(find /data/vpn -name "*.conf" 2> /dev/null | sort | head -1) + if [[ -z "$config_file_original" ]]; then + >&2 echo "ERROR: No configuration file found. Please check your mount and file permissions. Exiting." + exit 1 + fi +fi +echo "Using configuration file: $config_file_original" + +# Create a new configuration file to modify so the original is left untouched. +config_file_modified="${config_file_original}.modified" + +echo "Creating $config_file_modified and making required changes to that file." +cp "$config_file_original" "$config_file_modified" + +# These configuration file changes are required by Alpine. +sed -i \ + -e '/up /c up \/etc\/openvpn\/up.sh' \ + -e '/down /c down \/etc\/openvpn\/down.sh' \ + -e 's/^proto udp$/proto udp4/' \ + -e 's/^proto tcp$/proto tcp4/' \ + "$config_file_modified" + +echo -e "Changes made.\n" + +trap cleanup INT TERM + +default_gateway=$(ip r | grep 'default via' | cut -d " " -f 3) +if [[ "$KILL_SWITCH" == "on" ]]; then + local_subnet=$(ip r | grep -v 'default via' | grep eth0 | tail -n 1 | cut -d " " -f 1) + + echo "Creating VPN kill switch and local routes." + + echo "Allowing established and related connections..." + iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT + + echo "Allowing loopback connections..." + iptables -A INPUT -i lo -j ACCEPT + iptables -A OUTPUT -o lo -j ACCEPT + + echo "Allowing Docker network connections..." + iptables -A INPUT -s "$local_subnet" -j ACCEPT + iptables -A OUTPUT -d "$local_subnet" -j ACCEPT + + echo "Allowing specified subnets..." + # for every specified subnet... + for subnet in ${SUBNETS//,/ }; do + # create a route to it and... + ip route add "$subnet" via "$default_gateway" dev eth0 + # allow connections + iptables -A INPUT -s "$subnet" -j ACCEPT + iptables -A OUTPUT -d "$subnet" -j ACCEPT + done + + echo "Allowing remote servers in configuration file..." + global_port=$(grep "port " "$config_file_modified" | cut -d " " -f 2) + global_protocol=$(grep "proto " "$config_file_modified" | cut -d " " -f 2 | cut -c1-3) + remotes=$(grep "remote " "$config_file_modified") + + echo " Using:" + comment_regex='^[[:space:]]*[#;]' + echo "$remotes" | while IFS= read -r line; do + # Ignore comments. + if ! [[ "$line" =~ $comment_regex ]]; then + # Remove the line prefix 'remote '. + line=${line#remote } + + # Remove any trailing comments. + line=${line%%#*} + + # Split the line into an array. + # The first element is an address (IP or domain), the second is a port, + # and the fourth is a protocol. + IFS=' ' read -r -a remote <<< "$line" + address=${remote[0]} + # Use port from 'remote' line, then 'port' line, then '1194'. + port=${remote[1]:-${global_port:-1194}} + # Use protocol from 'remote' line, then 'proto' line, then 'udp'. + protocol=${remote[2]:-${global_protocol:-udp}} + + ip_regex='^(([1-9]?[0-9]|1[0-9][0-9]|2([0-4][0-9]|5[0-5]))\.){3}([1-9]?[0-9]|1[0-9][0-9]|2([0-4][0-9]|5[0-5]))$' + if [[ "$address" =~ $ip_regex ]]; then + echo " IP: $address PORT: $port PROTOCOL: $protocol" + iptables -A OUTPUT -o eth0 -d "$address" -p "$protocol" --dport "$port" -j ACCEPT + else + for ip in $(dig -4 +short "$address"); do + echo " $address (IP: $ip PORT: $port PROTOCOL: $protocol)" + iptables -A OUTPUT -o eth0 -d "$ip" -p "$protocol" --dport "$port" -j ACCEPT + echo "$ip $address" >> /etc/hosts + done + fi + fi + done + + echo "Allowing connections over VPN interface..." + iptables -A INPUT -i tun0 -j ACCEPT + iptables -A OUTPUT -o tun0 -j ACCEPT + + echo "Preventing anything else..." + iptables -P INPUT DROP + iptables -P OUTPUT DROP + iptables -P FORWARD DROP + + echo -e "iptables rules created and routes configured.\n" +else + echo -e "WARNING: VPN kill switch is disabled. Traffic will be allowed outside of the tunnel if the connection is lost.\n" + echo "Creating routes to specified subnets..." + for subnet in ${SUBNETS//,/ }; do + ip route add "$subnet" via "$default_gateway" dev eth0 + done + echo -e "Routes created.\n" +fi + +if [[ "$HTTP_PROXY" == "on" ]]; then + if [[ -n "$PROXY_USERNAME" ]]; then + if [[ -n "$PROXY_PASSWORD" ]]; then + echo "Configuring HTTP proxy authentication." + echo -e "\nBasicAuth $PROXY_USERNAME $PROXY_PASSWORD" >> /data/tinyproxy.conf + else + echo "WARNING: Proxy username supplied without password. Starting HTTP proxy without credentials." + fi + elif [[ -f "/run/secrets/$PROXY_USERNAME_SECRET" ]]; then + if [[ -f "/run/secrets/$PROXY_PASSWORD_SECRET" ]]; then + echo "Configuring proxy authentication." + echo -e "\nBasicAuth $(cat /run/secrets/$PROXY_USERNAME_SECRET) $(cat /run/secrets/$PROXY_PASSWORD_SECRET)" >> /data/tinyproxy.conf + else + echo "WARNING: Credentials secrets not read. Starting HTTP proxy without credentials." + fi + fi + /data/scripts/tinyproxy_wrapper.sh & +fi + +if [[ "$SOCKS_PROXY" == "on" ]]; then + if [[ -n "$LISTEN_ON" ]]; then + sed -i "s/internal: eth0/internal: $LISTEN_ON/" /data/sockd.conf + fi + if [[ -n "$PROXY_USERNAME" ]]; then + if [[ -n "$PROXY_PASSWORD" ]]; then + echo "Configuring SOCKS proxy authentication." + adduser -S -D -g "$PROXY_USERNAME" -H -h /dev/null "$PROXY_USERNAME" + echo "$PROXY_USERNAME:$PROXY_PASSWORD" | chpasswd 2> /dev/null + sed -i 's/socksmethod: none/socksmethod: username/' /data/sockd.conf + else + echo "WARNING: Proxy username supplied without password. Starting SOCKS proxy without credentials." + fi + elif [[ -f "/run/secrets/$PROXY_USERNAME_SECRET" ]]; then + if [[ -f "/run/secrets/$PROXY_PASSWORD_SECRET" ]]; then + echo "Configuring proxy authentication." + adduser -S -D -g "$(cat /run/secrets/$PROXY_USERNAME_SECRET)" -H -h /dev/null "$(cat /run/secrets/$PROXY_USERNAME_SECRET)" + echo "$(cat /run/secrets/$PROXY_USERNAME_SECRET):$(cat /run/secrets/$PROXY_PASSWORD_SECRET)" | chpasswd 2> /dev/null + sed -i 's/socksmethod: none/socksmethod: username/' /data/sockd.conf + else + echo "WARNING: Credentials secrets not present. Starting SOCKS proxy without credentials." + fi + fi + /data/scripts/dante_wrapper.sh & +fi + +openvpn_args=( + "--config" "$config_file_modified" + "--auth-nocache" + "--cd" "/data/vpn" + "--pull-filter" "ignore" "ifconfig-ipv6" + "--pull-filter" "ignore" "route-ipv6" + "--script-security" "2" + "--up-restart" + "--verb" "$vpn_log_level" +) + +if [[ -n "$VPN_AUTH_SECRET" ]]; then + if [[ -f "/run/secrets/$VPN_AUTH_SECRET" ]]; then + echo "Configuring OpenVPN authentication." + openvpn_args+=("--auth-user-pass" "/run/secrets/$VPN_AUTH_SECRET") + else + echo "WARNING: OpenVPN credentials secrets not present." + fi +fi + +echo -e "Running OpenVPN client.\n" + +openvpn "${openvpn_args[@]}" & +openvpn_child=$! + +wait $openvpn_child diff --git a/data/scripts/tinyproxy_wrapper.sh b/data/scripts/tinyproxy_wrapper.sh new file mode 100755 index 0000000..eb4ef6b --- /dev/null +++ b/data/scripts/tinyproxy_wrapper.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +echo -e "Running Tinyproxy HTTP proxy server.\n" + +until ip link show tun0 2>&1 | grep -qv "does not exist"; do + sleep 1 +done + +get_addr() { + ip a show dev "$1" | grep inet | cut -d " " -f 6 | cut -d "/" -f 1 +} + +addr_eth=${LISTEN_ON:-$(get_addr eth0)} +addr_tun=$(get_addr tun0) +sed -i \ + -e "/Listen/c Listen $addr_eth" \ + -e "/Bind/c Bind $addr_tun" \ + /data/tinyproxy.conf + +tinyproxy -d -c /data/tinyproxy.conf diff --git a/data/sockd.conf b/data/sockd.conf new file mode 100644 index 0000000..78166c2 --- /dev/null +++ b/data/sockd.conf @@ -0,0 +1,65 @@ +# Logging +logoutput: /var/log/sockd.log +errorlog: stderr + +# Server address specification +internal: eth0 port = 1080 +external: tun0 + +# Authentication methods +clientmethod: none +socksmethod: none + +# Server identities +user.unprivileged: sockd + +## +## SOCKS client access rules +## +# Rule processing stops at the first match; no match results in blocking + +# Block access to socks server from 192.0.2.22 +# client block { +# # Block connections from 192.0.2.22/32 +# from: 192.0.2.22/24 to: 0.0.0.0/0 +# log: error # connect disconnect +# } + +# Allow all connections +client pass { + from: 0.0.0.0/0 to: 0.0.0.0/0 + log: error connect disconnect +} + +## +## SOCKS command rules +## +# Rule processing stops at the first match; no match results in blocking + +# Block communication with www.example.org +# socks block { +# from: 0.0.0.0/0 to: www.example.org +# command: bind connect udpassociate +# log: error # connect disconnect iooperation +# } + +# Generic pass statement - bind/outgoing traffic +socks pass { + from: 0.0.0.0/0 to: 0.0.0.0/0 + command: bind connect udpassociate + log: error connect disconnect # iooperation +} + +# Block incoming connections/packets from ftp.example.org +# socks block { +# from: ftp.example.org to: 0.0.0.0/0 +# command: bindreply udpreply +# log: error # connect disconnect iooperation +# } + +# Generic pass statement for incoming connections/packets +socks pass { + from: 0.0.0.0/0 to: 0.0.0.0/0 + command: bindreply udpreply + log: error connect disconnect # iooperation +} diff --git a/data/tinyproxy.conf b/data/tinyproxy.conf new file mode 100644 index 0000000..222ff5c --- /dev/null +++ b/data/tinyproxy.conf @@ -0,0 +1,19 @@ +User tinyproxy +Group tinyproxy + +Port 8080 +Listen +Bind + +Timeout 600 + +DefaultErrorFile "/usr/share/tinyproxy/default.html" +StatFile "/usr/share/tinyproxy/stats.html" +LogFile "/var/log/tinyproxy/tinyproxy.log" + +LogLevel Info + +MaxClients 100 +MinSpareServers 5 +MaxSpareServers 15 +StartServers 10 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 88f22fd..2dc6cbe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,13 +1,32 @@ +# Use this file as an example if you need help writing your Compose files. +# The commented-out parts may or may not be relevant to your setup. + services: - openvpn-client: - image: ghcr.io/wfg/openvpn-client:latest + vpn: + image: ghcr.io/wfg/openvpn-client + # build: . container_name: openvpn-client - cap_add: + cap_add: - NET_ADMIN - devices: + devices: - /dev/net/tun:/dev/net/tun environment: - - ALLOWED_SUBNETS=192.168.10.0/24 - volumes: - - ./local:/config - restart: unless-stopped + # - SUBNETS=192.168.10.0/24 + - HTTP_PROXY=on + - SOCKS_PROXY=on + # - PROXY_USERNAME_SECRET=username # <-- If used, these must match the name of a + # - PROXY_PASSWORD_SECRET=password # <-- secret (NOT the file used by the secret) + # volumes: + # - ~/local/vpn:/data/vpn + ports: + - 1080:1080 + - 8088:8080 + # secrets: + # - username + # - password + +# secrets: +# username: +# file: ~/local/secrets/username +# password: +# file: ~/local/secrets/password