diff --git a/.env b/.env index e372c63a..0d16c11c 100644 --- a/.env +++ b/.env @@ -5,6 +5,7 @@ DASHBOARD_DOMAIN=dashboard.openwisp.org API_DOMAIN=api.openwisp.org VPN_DOMAIN=openvpn.openwisp.org +WIREGUARD_UPDATER_DOMAIN=wireguard-updater.openwisp.org EMAIL_DJANGO_DEFAULT=example@example.org DB_USER=admin DB_PASS=admin @@ -42,6 +43,11 @@ X509_COMMON_NAME=OpenWISP # VPN VPN_NAME=default VPN_CLIENT_NAME=default-management-vpn +# WireGuard +WIREGUARD_UPDATER_PORT=8081 +WIREGUARD_UPDATER_ENDPOINT=/trigger-update +WIREGUARD_UPDATER_KEY=openwisp-wireguard-updater-auth-key +WIREGUARD_UPDATER_PUBLIC=False # Developer DEBUG_MODE=False DJANGO_LOG_LEVEL=INFO diff --git a/.github/workflows/branch.yml b/.github/workflows/branch.yml index 5dbc918b..341ea2ca 100644 --- a/.github/workflows/branch.yml +++ b/.github/workflows/branch.yml @@ -30,7 +30,8 @@ jobs: - name: Setup run: | - echo "127.0.0.1 dashboard.openwisp.org api.openwisp.org" | sudo tee -a /etc/hosts + echo "127.0.0.1 dashboard.openwisp.org api.openwisp.org wireguard-updater.openwisp.org" | + sudo tee -a /etc/hosts - name: Build & Publish run: make publish TAG=edge || (docker-compose logs && exit 1) diff --git a/Makefile b/Makefile index 241e61d2..747821e0 100644 --- a/Makefile +++ b/Makefile @@ -91,7 +91,8 @@ TAG = latest publish: compose-build runtests nfs-build for image in 'openwisp-base' 'openwisp-nfs' 'openwisp-api' 'openwisp-dashboard' \ 'openwisp-freeradius' 'openwisp-nginx' 'openwisp-openvpn' 'openwisp-postfix' \ - 'openwisp-websocket' ; do \ + 'openwisp-celery' 'openwisp-websocket' 'openwisp-wireguard' \ + 'openwisp-wireguard-updater' ; do \ docker tag openwisp/$${image}:latest $(USER)/$${image}:$(TAG); \ docker push $(USER)/$${image}:$(TAG); \ docker rmi $(USER)/$${image}:$(TAG); \ diff --git a/README.md b/README.md index fab1c0a1..86fb0c76 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ The sample files for deployment on kubernetes are available in the `deploy/examp - [Quick Setup](#quick-setup) - [Compose](#compose) - [Kubernetes](#kubernetes) + - [Deploying WireGuard VPN](#deploying-wireguard-vpn) - [Customization](#customization) - [Custom Django Settings](#custom-django-settings) - [Custom Styles and JavaScript](#custom-styles-and-javascript) @@ -30,6 +31,7 @@ The sample files for deployment on kubernetes are available in the `deploy/examp - [Development](#development) - [Workbench setup](#workbench-setup) - [Runtests](#runtests) + - [Run Quality Assurance Checks](#run-quality-assurance-checks) - [Usage](#usage) - [Makefile Options](#makefile-options) @@ -112,6 +114,10 @@ by the images: - startup probe example: `test $(ps aux | grep -c uwsgi) -ge 2` - readiness probe example: `python services.py uwsgi_status "127.0.0.1:8001"` +### Deploying WireGuard VPN + +Follow this detailed [step-by-step guide for deploying the WireGuard VPN](docs/tutorials/deploying-wireguard-vpn.md). + ## Customization The following commands will create the directory structure required for @@ -244,7 +250,7 @@ If you want to disable a service, you can simply remove the container for that s - Default username & password are `admin`. - Default domains are: `dashboard.openwisp.org` and `api.openwisp.org`. - To reach the dashboard you may need to add the openwisp domains set in your `.env` to your `hosts` file, - example: `bash -c 'echo "127.0.0.1 dashboard.openwisp.org api.openwisp.org" >> /etc/hosts'` + example: `bash -c 'echo "127.0.0.1 dashboard.openwisp.org api.openwisp.org wireguard-updater.openwisp.org" >> /etc/hosts'` - Now you'll need to do steps (2) everytime you make a changes and want to build the images again. - If you want to perform actions like cleaning everything produced by `docker-openwisp`, please use the [makefile options](#makefile-options). @@ -257,15 +263,15 @@ You can run tests either with `geckodriver` (firefox) or `chromedriver` (chromiu - Setup chromedriver - 1. Install chromium: - + 1. Install chromium: + ```bash - # On debian + # On debian sudo apt --yes install chromium - # On ubuntu + # On ubuntu sudo apt --yes install chromium-browser ``` - + 3. Check version: `chromium --version` 4. Install Driver for your version: [`https://chromedriver.chromium.org/downloads`](https://chromedriver.chromium.org/downloads) 5. Extract chromedriver to one of directories from your `$PATH`. (example: `/usr/bin/`) diff --git a/docker-compose.yml b/docker-compose.yml index 909b588e..6141930d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -57,8 +57,11 @@ services: - dashboard celery: - image: openwisp/openwisp-dashboard:latest + image: openwisp/openwisp-celery:latest restart: always + build: + context: images + dockerfile: openwisp_celery/Dockerfile environment: - MODULE_NAME=celery volumes: @@ -72,8 +75,11 @@ services: - dashboard celery_monitoring: - image: openwisp/openwisp-dashboard:latest + image: openwisp/openwisp-celery:latest restart: always + build: + context: images + dockerfile: openwisp_celery/Dockerfile environment: - MODULE_NAME=celery_monitoring volumes: @@ -87,8 +93,8 @@ services: - dashboard celerybeat: - image: openwisp/openwisp-dashboard:latest restart: always + image: openwisp/openwisp-celery:latest environment: - MODULE_NAME=celerybeat env_file: @@ -118,6 +124,7 @@ services: aliases: - dashboard.internal - api.internal + - wireguard_updater.internal ports: - "80:80" - "443:443" @@ -125,6 +132,7 @@ services: - dashboard - api - websocket + - wireguard_updater freeradius: image: openwisp/openwisp-freeradius:latest @@ -168,6 +176,47 @@ services: cap_add: - NET_ADMIN + wireguard: + image: openwisp/openwisp-wireguard:latest + build: + context: images + dockerfile: openwisp_wireguard/Dockerfile + env_file: + - .env + environment: + # Substitute the placeholder values with the UUID and Key + # of the VPN server. + # These variables needs to be configured on individual + # container to avoid conflicts between multiple VPN servers. + - WIREGUARD_VPN_UUID=ENTER_WIREGUARD_VPN_UUID + - WIREGUARD_VPN_KEY=ENTER_WIREGUARD_VPN_KEY + # Maps the default UDP port (51820) for WireGuard VPN traffic. + # Update this this if you are using different port for WireGuard. + ports: + - 51820:51820/udp + # Following properties allow WireGuard to manage network on the + # machine while running in a container. + volumes: + - /lib/modules:/lib/modules + cap_add: + - NET_ADMIN + - SYS_MODULE + + wireguard_updater: + image: openwisp/openwisp-wireguard-updater:latest + build: + context: images + dockerfile: openwisp_wireguard_updater/Dockerfile + args: + WIREGUARD_UPDATER_APP_PORT: 8081 + env_file: + - .env + environment: + # Create an authentication token consisting alphanumeric + # characters. This token will be used by OpenWISP for + # triggering configuration updates. + - WIREGUARD_UPDATER_KEY=openwisp-wireguard-updater-auth-key + postgres: image: mdillon/postgis:11-alpine restart: always diff --git a/docs/ENV.md b/docs/ENV.md index 6440f4eb..51169c1c 100644 --- a/docs/ENV.md +++ b/docs/ENV.md @@ -25,6 +25,8 @@ Following are the options that can be changed. The list is divided in following - [uWSGI](#uWSGI): uWSGI configurations. - [Nginx](#Nginx): Nginx configurations. - [VPN](#VPN): Default VPN and VPN template related configurations. +- [WireGuard](#WireGuard): WireGuard VPN configurations. +- [WireGuard Updater](#WireGuard-Updater): WireGuard Updater app configurations. - [X509](#X509): Default certificate & certicate Authority configuration options. - [Host](#Hosts): Want to change the host of a particular service? Like pointing all the containers to a different database service. - [Developer](#Developer): DON'T change these values unless you know what you are doing. @@ -661,6 +663,63 @@ Any OpenWISP Configuration of type `string`. `int`, `bool` or `json` is supporte - **Valid Values:** STRING - **Default:** default-management-vpn +## WireGuard + +**Note:** If you have more that one WireGuard container, then these +settings should be configured on individual container. + +### `WIREGUARD_VPN_UUID` + +- **Explanation:** ``UUID`` of the WireGuard VPN server object created on the OpenWISP dashboard. +- **Valid Values:** STRING + +### `WIREGUARD_VPN_KEY` + +- **Explanation:** ``Key`` of the WireGuard VPN server object created on the OpenWISP dashboard. +- **Valid Values:** STRING + +## WireGuard Updater + +### `WIREGUARD_UPDATER_KEY` + +- **Explanation:** The authentication token required to trigger the configuration + updater. It is strongly recommended to change this before deploying the container. +- **Valid Values:** STRING +- **Default:** openwisp-wireguard-updater-auth-key + +### `WIREGUARD_UPDATER_DOMAIN` + +- **Explanation:** Valid domain / IP address to reach the WireGuard updater application. +- **Valid Values:** Domain +- **Default:** wireguard-updater.openwisp.org + +### `WIREGUARD_UPDATER_APP_PORT` + +- **Explanation:** Change the port on which NGINX connects to the updater app on the WireGuard updater container. Don't change unless you know what you are doing. +- **Valid Values:** INTEGER +- **Default:** 8081 + +### `WIREGUARD_UPDATER_ENDPOINT` + +- **Explanation:** The endpoint used for triggering updates to configuration of + WireGuard tunnels. It should lead with a slash (`/`). Don't change unless + you know what you are doing. +- **Valid Values:** STRING +- **Default:** /trigger-update + +### `WIREGUARD_UPDATER_APP_SERVICE` + +- **Explanation:** Host to establish WireGuard updater connection. +- **Valid Values:** Domain | IP address +- **Default:** wireguard_updater + +### `WIREGUARD_UPDATER_PUBLIC` + +- **Explanation:** Whether the WireGuard Updater should be exposed to the + public traffic on [`WIREGUARD_UPDATER_DOMAIN`](#wireguard_updater_domain). +- **Valid Values:** True | False +- **Default:** False + ## X509 ### `X509_NAME_CA` @@ -788,6 +847,13 @@ Any OpenWISP Configuration of type `string`. `int`, `bool` or `json` is supporte - **Valid Values:** STRING - **Default:** api.internal +### `WIREGUARD_UPDATER_INTERNAL` + +- **Explanation:** Internal domain to reach the WireGuard updater app + from other containers. +- **Valid Values:** STRING +- **Default:** wireguard_updater.internal + ### `POSTFIX_DEBUG_MYNETWORKS` - **Explanation:** Set debug_peer_list for given list of networks. diff --git a/docs/images/wireguard-config-update.jpg b/docs/images/wireguard-config-update.jpg new file mode 100644 index 00000000..418b27d5 Binary files /dev/null and b/docs/images/wireguard-config-update.jpg differ diff --git a/docs/tutorials/deploying-wireguard-vpn.md b/docs/tutorials/deploying-wireguard-vpn.md new file mode 100644 index 00000000..22307974 --- /dev/null +++ b/docs/tutorials/deploying-wireguard-vpn.md @@ -0,0 +1,141 @@ +# Deploying WireGuard VPN + +The WireGuard support in docker-openwisp comprises of two containers: + +1. The `wireguard` container which acts as the WireGuard VPN server +2. The `wireguard-updater` container which is responsible for + triggering configuration updates on the `wireguard` container. + +This de-coupling allows management of multiple `wireguard` containers +with only one `wireguard-updater` container. + +With this context, you can now proceed to add WireGuard support on +your installation. + +WireGuard support is implemented as an add-on to docker-openwisp. +Contrary to OpenVPN's support, you need a running docker-openwisp instance +before proceeding with this guide. Follow the instructions in the +["Deployment" section of the project's README](../../README.md#deployment) +if you haven't started with docker-openwisp. + +Once you can access the OpenWISP dashboard, create a WireGuard VPN server +on OpenWISP dashboard as mentioned in [OpenWISP's documentation](https://openwisp.io/docs/user/wireguard.html#how-to-setup-wireguard-tunnels). + +The **Host** will be the public IP address of the machine that runs +WireGuard VPN container. You can leave **Webhook Endpoint** +and **Webhook AuthToken** for now. You can configure these later +after deploying the containers. + +Creating the VPN server object as such will give you the `UUID` and `Key` +of your WireGuard VPN server. This is required for the next step, i.e. +updating the `docker-compose`. + +Add the following sections to your `docker-compose.yml`: +```yaml +# WireGuard Container +wireguard: + image: openwisp/openwisp-wireguard:latest + env_file: + - .env + environment: + # Substitute the placeholder values with the UUID and Key + # of the VPN server created before. + # These variables have to be configured on individual + # container to avoid conflicts between multiple VPN servers. + - WIREGUARD_VPN_UUID= + - WIREGUARD_VPN_KEY= + # Map the default UDP port (51820) for WireGuard VPN traffic. + # Update this if you are using different port for WireGuard. + ports: + - "51820:51820/udp" + # Following properties allow WireGuard to manage network on the + # host machine while running in a container. + volumes: + - /lib/modules:/lib/modules + cap_add: + - NET_ADMIN + - SYS_MODULE + +# Container for running WireGuard configuration updater +wireguard-updater: + image: openwisp/openwisp-wireguard-updater:latest + env_file: + - .env + environment: + # Create an authentication token consisting alphanumeric + # characters. This token will be used by OpenWISP for + # triggering configuration updates. + - WIREGUARD_UPDATER_KEY= +``` + +In your `.env` file, configure the [environment variables for the `wireguard-updater`](../ENV.md#wireguard-updater). + +Add an alias to your `nginx` container as shown below. This enables +routing of update triggers through the internal network. + +```yaml +nginx: + # other configuration + networks: + default: + aliases: + # other aliases + - ${WIREGUARD_UPDATER_INTERNAL} +``` + +**Note:** If you want to use multiple WireGuard VPN servers, just +copy the configuration of the `wireguard` container and update +`ports`, `WIREGUARD_VPN_UUID` and `WIREGUARD_VPN_KEY` according +to the VPN configuration. + +After bringing up the container, you can update the VPN server object +on OpenWISP. You need to enter the following values in **Webhook Endpoint** +and **Webhook AuthToken**: + +```text +Webhook Endpoint: http:///?vpn_id= +Webhook AuthToken: +``` + +You need to substitute the values of environment variables **manually**. +If you haven't changed any default settings, you can use +`http://wireguard_updater.internal/trigger-updater?vpn_id=` +for the **Webhook Endpoint**. The `UUID` is unique to every object, so +you'll have to substitute that **manually**. + +**Note:** If you are deploying with Kubernetes, you can refer to the manifest file +in [docker-openwisp/deploy/examples/WireGuard.yml](https://github.com/openwisp/docker-openwisp/tree/master/deploy/examples/kubernetes/WireGuard.yml) which uses `NodePort` for exposing +WireGuard VPN. + +**Voila!** You have added WireGuard VPN to your docker-openwisp +installation with automatic configuration upgrades. + +## WireGuard in Docker-OpenWISP: Design notes + +**Disclaimer:** This section discusses the design and internal +working of WireGuard in docker-openwisp and is targeted toward developers. + +### Roles of the `wireguard-updater` container + +- It runs a small *Flask* application that listens for update + triggers from OpenWISP +- When the ``WIREGUARD_UPDATER_ENDPOINT`` is triggered by OpenWISP, + it stores the current timestamp on the Redis with `wg-` key. + This timestamp is used by the `wireguard container to perform + configuration updates. + +### Roles of the `wireguard` container + +- It runs the WireGuard VPN server. +- It downloads and applies VPN configuration from OpenWISP. +- It checks the reload timestamp written on Redis by the + `wireguard-updater` container. If the timestamp differs from + the local timestamp, it updates the VPN configuration from OpenWISP. +- It has a cronjob configured to check for configuration updates + from OpenWISP every *5 minutes*. This cronjob serves as a fallback + if the active configuration updates via the `wireguard-updater` container fail. +docker-opennwisp-wireguard.png +The following diagram illustrates the flow of control whenever the WireGuard +VPN server's configuration is updated on OpenWISP. + +![Flow of control for WireGuard VPN configuration update](../images/wireguard-config-update.jpg) diff --git a/images/common/init_command.sh b/images/common/init_command.sh index cba78364..0c3a0f7a 100644 --- a/images/common/init_command.sh +++ b/images/common/init_command.sh @@ -45,6 +45,27 @@ elif [ "$MODULE_NAME" = 'openvpn' ]; then # docker container running, restarting would mean killing # the container while supervisor helps only to restart the service! supervisord --nodaemon --configuration supervisord.conf +elif [ "$MODULE_NAME" = 'wireguard' ]; then + if [[ -z "$WIREGUARD_VPN_UUID" || -z "$WIREGUARD_VPN_KEY" ]]; then + echo "You need to cofigure the WIREGUARD_VPN_UUID and WIREGUARD_ environment varibales." + exit + fi + wait_nginx_services + # sudo raises "unable to resolve host" error if host networking + # is used for this container. Hence, hostname is added to + # /etc/hosts here. + echo "127.0.0.1 $(hostname)" >>/etc/hosts + # The image is started with the root user. This sets the + # environment variables only for the root user. + # These environment variables are required when script is + # executed by the "openwisp" user through cronjob, hence + # the environment variables are saved in this file which + # is loaded by the shell. + env >>/etc/environment + sudo -u openwisp -E bash -c "source utils.sh; wireguard_setup" + +elif [ "$MODULE_NAME" = 'wireguard_updater' ]; then + start_uwsgi elif [ "$MODULE_NAME" = 'nginx' ]; then rm -rf /etc/nginx/conf.d/default.conf if [ "$NGINX_CUSTOM_FILE" = 'True' ]; then diff --git a/images/common/utils.sh b/images/common/utils.sh index aedbf0ea..75e17fb0 100644 --- a/images/common/utils.sh +++ b/images/common/utils.sh @@ -41,12 +41,19 @@ function create_prod_certs { --domain ${API_DOMAIN} \ --email ${CERT_ADMIN_EMAIL} fi + if [ "$WIREGUARD_UPDATER_PUBLIC" == "True" ] && [ ! -f /etc/letsencrypt/live/${WIREGUARD_UPDATER_DOMAIN}/privkey.pem ]; then + certbot certonly --standalone --noninteractive --agree-tos \ + --rsa-key-size 4096 \ + --domain ${WIREGUARD_UPDATER_DOMAIN} \ + --email ${CERT_ADMIN_EMAIL} + fi } function create_dev_certs { # Ensure required directories exist mkdir -p /etc/letsencrypt/live/${DASHBOARD_DOMAIN}/ mkdir -p /etc/letsencrypt/live/${API_DOMAIN}/ + mkdir -p /etc/letsencrypt/live/${WIREGUARD_UPDATER_DOMAIN}/ # Create self-signed certificates if [ ! -f /etc/letsencrypt/live/${DASHBOARD_DOMAIN}/privkey.pem ]; then openssl req -x509 -newkey rsa:4096 \ @@ -60,6 +67,12 @@ function create_dev_certs { -out /etc/letsencrypt/live/${API_DOMAIN}/fullchain.pem \ -days 365 -nodes -subj '/CN=OpenWISP' fi + if [ "$WIREGUARD_UPDATER_PUBLIC" == "True" ] && [ ! -f /etc/letsencrypt/live/${WIREGUARD_UPDATER_DOMAIN}/privkey.pem ]; then + openssl req -x509 -newkey rsa:4096 \ + -keyout /etc/letsencrypt/live/${WIREGUARD_UPDATER_DOMAIN}/privkey.pem \ + -out /etc/letsencrypt/live/${WIREGUARD_UPDATER_DOMAIN}/fullchain.pem \ + -days 365 -nodes -subj '/CN=OpenWISP' + fi } function nginx_dev { @@ -109,14 +122,23 @@ function ssl_http_behaviour { function envsubst_create_config { # Creates nginx configurations files for dashboard # and api instances. - for application in DASHBOARD API; do - eval export APP_SERVICE=\$${application}_APP_SERVICE - eval export APP_PORT=\$${application}_APP_PORT - eval export DOMAIN=\$${application}_${3} + function _create_config { + eval export APP_SERVICE=\$${4}_APP_SERVICE + eval export APP_PORT=\$${4}_APP_PORT + eval export DOMAIN=\$${4}_${3} eval export ROOT_DOMAIN=$(python3 get_domain.py) - application=$(echo "$application" | tr "[:upper:]" "[:lower:]") + application=$(echo "$4" | tr "[:upper:]" "[:lower:]") envsubst <${1} >/etc/nginx/conf.d/${application}.${2}.conf - done + } + + _create_config $1 $2 $3 DASHBOARD + _create_config $1 $2 $3 API + + # Create a reverse proxy for the WireGuard Updater application + # for public traffic only when configured + if [ "$2" == "internal" ] || [ "$WIREGUARD_UPDATER_PUBLIC" == "True" ]; then + _create_config $1 $2 $3 WIREGUARD_UPDATER + fi } function postfix_config { @@ -239,3 +261,11 @@ function crl_download { export CAid=$(psql -qAtc "SELECT ca_id FROM config_vpn where name='${VPN_NAME}';") wget -qO revoked.crl --no-check-certificate ${DASHBOARD_INTERNAL}/admin/pki/ca/${CAid}.crl } + +function wireguard_setup { + bash /opt/openwisp/update_wireguard.sh bring_up_interface + bash /opt/openwisp/update_wireguard.sh check_config + echo "*/5 * * * * bash /opt/openwisp/update_wireguard.sh check_config" | crontab - + sudo cron + bash /opt/openwisp/update_wireguard.sh watch_configuration_change +} diff --git a/images/openwisp_celery/Dockerfile b/images/openwisp_celery/Dockerfile new file mode 100644 index 00000000..77199c03 --- /dev/null +++ b/images/openwisp_celery/Dockerfile @@ -0,0 +1,24 @@ +# hadolint ignore=DL3007 +FROM openwisp/openwisp-base:latest + +WORKDIR /opt/openwisp/ + +# Location: /opt/openwisp/ +COPY --chown=openwisp:root ./openwisp_dashboard/load_init_data.py \ + ./openwisp_dashboard/openvpn.json \ + /opt/openwisp/ +# Location: /opt/openwisp/openwisp/ +COPY --chown=openwisp:root ./openwisp_dashboard/module_settings.py \ + ./openwisp_dashboard/urls.py \ + /opt/openwisp/openwisp/ + +USER root:root +RUN apt install --yes --no-install-recommends \ + iproute2 iptables sudo iputils-ping +RUN echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers + +CMD ["bash", "init_command.sh"] + +ARG DASHBOARD_APP_PORT=8000 +ENV MODULE_NAME=celery \ + CONTAINER_PORT=$DASHBOARD_APP_PORT diff --git a/images/openwisp_nginx/Dockerfile b/images/openwisp_nginx/Dockerfile index 602d4464..eb44565d 100644 --- a/images/openwisp_nginx/Dockerfile +++ b/images/openwisp_nginx/Dockerfile @@ -39,13 +39,17 @@ ENV MODULE_NAME=nginx \ DASHBOARD_APP_PORT=8000 \ API_APP_PORT=8001 \ WEBSOCKET_APP_PORT=8002 \ + WIREGUARD_UPDATER_APP_PORT=8081 \ # Application Service Name DASHBOARD_APP_SERVICE=dashboard \ API_APP_SERVICE=api \ WEBSOCKET_APP_SERVICE=websocket \ + WIREGUARD_UPDATER_APP_SERVICE=wireguard_updater \ # Listen domains DASHBOARD_DOMAIN=dashboard.example.com \ API_DOMAIN=api.example.com \ + WIREGUARD_UPDATER_DOMAIN=wireguard-updater.example.com \ # Inter container communication domains DASHBOARD_INTERNAL=dashboard.internal \ - API_INTERNAL=api.internal + API_INTERNAL=api.internal \ + WIREGUARD_UPDATER_INTERNAL=wireguard_updater.internal diff --git a/images/openwisp_nginx/openwisp.ssl.80.template.conf b/images/openwisp_nginx/openwisp.ssl.80.template.conf index 5cf05519..2afd76fa 100644 --- a/images/openwisp_nginx/openwisp.ssl.80.template.conf +++ b/images/openwisp_nginx/openwisp.ssl.80.template.conf @@ -3,7 +3,7 @@ server { listen 80; $NGINX_IP6_80_STRING - server_name $DASHBOARD_DOMAIN $API_DOMAIN; + server_name $DASHBOARD_DOMAIN $API_DOMAIN $WIREGUARD_UPDATER_DOMAIN; # Necessary for Let's Encrypt domain name ownership validation location /.well-known/ { diff --git a/images/openwisp_wireguard/Dockerfile b/images/openwisp_wireguard/Dockerfile new file mode 100644 index 00000000..60084253 --- /dev/null +++ b/images/openwisp_wireguard/Dockerfile @@ -0,0 +1,32 @@ +# hadolint ignore=DL3007 +FROM linuxserver/wireguard:latest + +WORKDIR /opt/openwisp + +RUN apt update && \ + apt install -y sudo network-manager cron redis-tools wget && \ + apt autoclean + +# Remove services from the base image +RUN rm /etc/cont-init.d/40-confs && \ + rm -r /etc/services.d/wireguard +RUN useradd --system --password '' --create-home --shell /bin/bash \ + --gid root --groups sudo --uid 1001 openwisp +RUN echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers +RUN chown -R openwisp:root /opt/openwisp + +COPY --chown=openwisp:root ./openwisp_wireguard/update_wireguard.sh \ + ./common/init_command.sh \ + ./common/utils.sh \ + ./common/services.py /opt/openwisp/ + +CMD ["bash", "init_command.sh"] + +EXPOSE 51820 + +ENV MODULE_NAME=wireguard \ + DASHBOARD_INTERNAL=dashboard.internal \ + API_INTERNAL=api.internal \ + REDIS_HOST=redis \ + REDIS_DATABASE=15 \ + OPENWISP_USER=openwisp diff --git a/images/openwisp_wireguard/update_wireguard.sh b/images/openwisp_wireguard/update_wireguard.sh new file mode 100644 index 00000000..1be7a66a --- /dev/null +++ b/images/openwisp_wireguard/update_wireguard.sh @@ -0,0 +1,148 @@ +#!/bin/bash + +if [ "$(whoami)" != "$OPENWISP_USER" ]; then + echo "Script should only be run by $OPENWISP_USER. Exiting!" + exit 9 +fi + +# make sure this directory is writable by the user which calls the script +CONF_DIR="/opt/openwisp" + +# do not modify these vars +_VPN_URL_PATH="$API_INTERNAL/controller/vpn" +_VPN_CHECKSUM_URL="$_VPN_URL_PATH/checksum/$WIREGUARD_VPN_UUID/?key=$WIREGUARD_VPN_KEY" +_VPN_DOWNLOAD_URL="$_VPN_URL_PATH/download-config/$WIREGUARD_VPN_UUID/?key=$WIREGUARD_VPN_KEY" +_WORKING_DIR="$CONF_DIR/.openwisp" +_CHECKSUM_FILE="$_WORKING_DIR/checksum" +_TIMESTAMP_FILE="$_WORKING_DIR/timestamp" +_MANAGED_INTERFACE="$_WORKING_DIR/managed-interface" +_APPLIED_CONF_DIR="$_WORKING_DIR/current-conf" +_CONF_TAR="$_WORKING_DIR/conf.tar.gz" +_CURL="curl -s --show-error --fail" + +mkdir -p $_WORKING_DIR +mkdir -p $_APPLIED_CONF_DIR + +assert_exit_code() { + exit_code=$? + lineno=$(($1 - 1)) + if [ "$exit_code" != "0" ] && [ ! -z "$2" ]; then + echo $2 + fi + if [ "$exit_code" != "0" ]; then + echo "Line $lineno: Command returned non zero exit code: $exit_code" + sudo kill 1 + exit $exit_code + fi +} + +check_config() { + _latest_checksum=$($_CURL $_VPN_CHECKSUM_URL) + assert_exit_code $LINENO "Failed to fetch VPN checksum. Ensure VPN UUID and key are correct." + if [ -f "$_CHECKSUM_FILE" ]; then + _current_checksum=$(cat $_CHECKSUM_FILE) + else + _current_checksum="" + fi + + if [ "$_current_checksum" != "$_latest_checksum" ]; then + echo "Configuration changed, downloading new configuration..." + update_config + fi +} + +clean_old_interface() { + echo "Bringing down old wireguard interface $managed_interface_name" + for old_conf_file in $_APPLIED_CONF_DIR/*.conf; do + [ -e "$old_conf_file" ] || continue + sudo wg-quick down $old_conf_file + done + rm $_APPLIED_CONF_DIR/*.conf +} + +create_new_interface() { + echo "Bringing up new wireguard interface $interface" + sudo wg-quick up $file +} + +update_config() { + # Set file permissions to 0660, otherwise wg will complain + # for having public configurations + umask 0117 + $($_CURL $_VPN_DOWNLOAD_URL >"$_CONF_TAR") + assert_exit_code $LINENO "Failed to download VPN configuration. Ensure VPN UUID and key are correct." + echo "Configuration downloaded, extracting it..." + tar -zxvf $_CONF_TAR -C $CONF_DIR >/dev/null + assert_exit_code $LINENO + if [ -e "$_MANAGED_INTERFACE" ]; then + managed_interface_name=$(cat "$_MANAGED_INTERFACE") + fi + + for file in $CONF_DIR/*.conf; do + [ -e "$file" ] || continue + filename=$(basename $file) + interface="${filename%.*}" + + # There is no managed_interface + if [ -z ${managed_interface_name+x} ]; then + create_new_interface + # Current managed interface is not present in new configuration + elif [ "$managed_interface_name" != "$interface" ]; then + clean_old_interface + assert_exit_code $LINENO + create_new_interface + assert_exit_code $LINENO + else + # Update the configuration of current managed interface + echo "Reloading wireguard interface $interface with config file $file..." + wg_conf_filename="$filename-wg" + sudo wg-quick strip "$CONF_DIR/$filename" >"$CONF_DIR/$wg_conf_filename" + assert_exit_code $LINENO + sudo wg syncconf $interface "$CONF_DIR/$wg_conf_filename" + assert_exit_code $LINENO + rm "$CONF_DIR/$wg_conf_filename" + fi + echo "$interface" >"$_MANAGED_INTERFACE" + mv -f "$file" "$_APPLIED_CONF_DIR/$filename" + assert_exit_code $LINENO + done + + # Save checksum of applied configuration + echo $_latest_checksum >$_CHECKSUM_FILE +} + +bring_up_interface() { + for conf_file in $_APPLIED_CONF_DIR/*.conf; do + [ -e "$conf_file" ] || continue + sudo wg-quick up $conf_file || true + done + exit 0 +} + +watch_configuration_change() { + _REDIS_CMD="redis-cli -h $REDIS_HOST -n $REDIS_DATABASE" + if [[ "$REDIS_PORT" ]]; then + _REDIS_CMD="$_REDIS_CMD -p $REDIS_PORT" + fi + if [[ "$REDIS_PASS" ]]; then + _REDIS_CMD="$_REDIS_CMD -a $REDIS_PASS --no-auth-warning" + fi + while true; do + if [ -f "$_TIMESTAMP_FILE" ]; then + local_timestamp=$(cat $_TIMESTAMP_FILE) + else + local_timestamp="" + fi + current_timestamp=$($_REDIS_CMD GET wg-$WIREGUARD_VPN_UUID) + if [ "$current_timestamp" != "$local_timestamp" ]; then + echo "Configuration reload triggered by the updater." + check_config + assert_exit_code $LINENO + # Save timestamp of applied configuration + echo $current_timestamp >$_TIMESTAMP_FILE + fi + sleep 3 + done +} + +"$@" diff --git a/images/openwisp_wireguard_updater/Dockerfile b/images/openwisp_wireguard_updater/Dockerfile new file mode 100644 index 00000000..9fe5233f --- /dev/null +++ b/images/openwisp_wireguard_updater/Dockerfile @@ -0,0 +1,43 @@ +# hadolint ignore=DL3007 +FROM python:3.10-slim-bullseye AS SYSTEM + +RUN apt update && \ + apt install --yes --no-install-recommends \ + gcc python3-dev + +# Building uwsgi requires system libraries which bloats +# the docker image, hence it is done in a different stage. +RUN pip install install wheel uwsgi~=2.0.20 + +FROM python:3.10-slim-bullseye + +WORKDIR /opt/openwisp/ + +RUN apt update && \ + apt install --yes --no-install-recommends gettext-base + +RUN useradd --system --password '' --create-home --shell /bin/bash \ + --gid root --groups sudo --uid 1001 openwisp +RUN echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers +RUN chown -R openwisp:root /opt/openwisp +USER openwisp:root + +COPY --chown=openwisp:root ./openwisp_wireguard_updater/uwsgi.conf.ini \ + ./openwisp_wireguard_updater/vpn_updater.py \ + ./common/init_command.sh \ + ./common/services.py \ + ./common/utils.py \ + ./common/utils.sh ./ + +COPY --from=SYSTEM --chown=openwisp:root /usr/local/bin/uwsgi /usr/local/bin/uwsgi +COPY ./openwisp_wireguard_updater/requirements.txt requirements.txt +RUN pip install -r requirements.txt + +CMD ["bash", "init_command.sh"] + +ARG WIREGUARD_UPDATER_APP_PORT=8081 +ENV MODULE_NAME=wireguard_updater \ + TZ=UTC \ + REDIS_HOST=redis \ + REDIS_DB=15 \ + CONTAINER_PORT=$WIREGUARD_UPDATER_APP_PORT diff --git a/images/openwisp_wireguard_updater/requirements.txt b/images/openwisp_wireguard_updater/requirements.txt new file mode 100644 index 00000000..95bc62d0 --- /dev/null +++ b/images/openwisp_wireguard_updater/requirements.txt @@ -0,0 +1,6 @@ +setuptools +wheel +attrs +Flask~=2.1.2 +requests~=2.27.1 +redis~=4.3.3 diff --git a/images/openwisp_wireguard_updater/uwsgi.conf.ini b/images/openwisp_wireguard_updater/uwsgi.conf.ini new file mode 100644 index 00000000..d07dd7b4 --- /dev/null +++ b/images/openwisp_wireguard_updater/uwsgi.conf.ini @@ -0,0 +1,20 @@ +[uwsgi] +chdir=/opt/openwisp +wsgi-file=/opt/openwisp/vpn_updater.py +callable=app +need-app=true +lazy-apps=true +master=true +socket=0.0.0.0:${CONTAINER_PORT} +processes=2 +threads=2 +max-requests=5000 +vacuum=true +single-interpreter=false +die-on-term=true +procname-prefix-spaced=openwisp2_wireguard_flask_app +env=HTTPS=on +pidfile=openwisp2_wireguard_flask_app.pid +worker-reload-mercy=5 +uid=nobody +gid=nogroup diff --git a/images/openwisp_wireguard_updater/vpn_updater.py b/images/openwisp_wireguard_updater/vpn_updater.py new file mode 100644 index 00000000..bfee5c61 --- /dev/null +++ b/images/openwisp_wireguard_updater/vpn_updater.py @@ -0,0 +1,49 @@ +import os +from datetime import datetime + +import redis +from flask import Flask, Response, request + +app = Flask(__name__) + +KEY = os.environ.get('WIREGUARD_UPDATER_KEY') +REDIS_HOST = os.environ.get('REDIS_HOST') +REDIS_PORT = os.environ.get('REDIS_PORT') +REDIS_PASS = os.environ.get('REDIS_PASS') +REDIS_DATABASE = os.environ.get('REDIS_DB', 15) + + +def _trigger_configuration_update(vpn_id): + redis_kwargs = {} + if REDIS_PASS: + redis_kwargs['password'] = REDIS_PASS + if REDIS_PORT: + redis_kwargs['port'] = REDIS_PORT + unix_timestamp = int(datetime.now().timestamp()) + try: + rs = redis.Redis(REDIS_HOST, db=REDIS_DATABASE, **redis_kwargs) + rs.set(f'wg-{vpn_id}', unix_timestamp) + except redis.RedisError as error: + app.logger.error(error) + return Response(status=500) + return Response(status=200) + + +@app.route(os.environ.get('WIREGUARD_UPDATER_ENDPOINT'), methods=['POST']) +def update_vpn_config(): + if request.args.get('key') != KEY: + return Response(status=403) + if request.args.get('vpn_id') is None: + return Response(status=400) + return _trigger_configuration_update( + vpn_id=request.args.get('vpn_id'), + ) + + +@app.route('/ping', methods=['GET']) +def ping(): + return Response(status=200) + + +if __name__ == '__main__': + app.run() diff --git a/tests/config.json b/tests/config.json index 1607fab2..31057f83 100644 --- a/tests/config.json +++ b/tests/config.json @@ -3,6 +3,7 @@ "headless": true, "app_url": "https://dashboard.openwisp.org", "api_url": "https://api.openwisp.org", + "wg_updater_url": "https://wireguard-updater.openwisp.org", "load_init_data": true, "logs": false, "logs_file": "/tmp/odocker.log", diff --git a/tests/runtests.py b/tests/runtests.py index 6e75bf0c..ecb235eb 100644 --- a/tests/runtests.py +++ b/tests/runtests.py @@ -1,4 +1,5 @@ import os +import re import subprocess import time import unittest @@ -58,6 +59,22 @@ def test_wait_for_services(self): else: self.fail(f'All celery workers are not online: {online_workers}') + # Ensure Wireguard updater is running + wg_updater_ping = f"{self.config['wg_updater_url']}/ping" + for _ in range(1, max_retries): + try: + # check if we can reach the ping endpoint of wireguard-updater + # and the page return 200 OK status code + if request.urlopen(wg_updater_ping, context=self.ctx).getcode() == 200: + isServiceReachable = True + break + except (urlerror.HTTPError, OSError, ConnectionResetError): + # if error occurred, retry to reach the admin + # login page after delay_retries second(s) + time.sleep(delay_retries) + if not isServiceReachable: + self.fail('ERROR: wireguard-updater ping endpoint is not reachable!') + class TestServices(TestUtilities, unittest.TestCase): @property @@ -434,6 +451,9 @@ def test_containers_down(self): cwd=self.root_location, ) output, error = map(str, cmd.communicate()) + # Remove wireguard container from output because it exits when it fails + # to download configuration from the dashboard + output = re.sub('docker-openwisp_wireguard_1(.*)Exit(.*)\\n', '', output) if 'Exit' in output: self.fail( f'One of the containers are down!\nOutput:\n{output}\nError:\n{error}'