diff --git a/.env b/.env index e372c63a..8294ff1f 100644 --- a/.env +++ b/.env @@ -39,9 +39,12 @@ X509_ORGANIZATION_NAME=OpenWISP X509_ORGANIZATION_UNIT_NAME=OpenWISP X509_EMAIL=certificate@example.com X509_COMMON_NAME=OpenWISP -# VPN -VPN_NAME=default -VPN_CLIENT_NAME=default-management-vpn +# WireGuard +WIREGUARD_VPN_DOMAIN=wireguard.openwisp.org +WIREGUARD_FLASK_HOST=0.0.0.0 +WIREGUARD_FLASK_PORT=8081 +WIREGUARD_FLASK_ENDPOINT=/trigger-update +WIREGUARD_FLASK_KEY='openwisp-wireguard-updater-auth-key' # Developer DEBUG_MODE=False DJANGO_LOG_LEVEL=INFO diff --git a/docker-compose.yml b/docker-compose.yml index a759b6bd..5ea143dc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -158,6 +158,33 @@ services: cap_add: - NET_ADMIN + wireguard: + image: openwisp/openwisp-wireguard:latest + build: + context: images + dockerfile: openwisp_wireguard/Dockerfile + env_file: + - .env + ports: + - 51820:51820/udp + sysctls: + - net.ipv4.conf.all.src_valid_mark=1 + cap_add: + - NET_ADMIN + - SYS_MODULE + + wireguard_updater: + image: openwisp/openwisp-wireguard-updater:latest + build: + context: images + dockerfile: openwisp_wireguard_updater/Dockerfile + env_file: + - .env + networks: + default: + aliases: + - wireguard.internal + postgres: image: mdillon/postgis:11-alpine environment: diff --git a/images/common/init_command.sh b/images/common/init_command.sh index cba78364..a307e0b0 100644 --- a/images/common/init_command.sh +++ b/images/common/init_command.sh @@ -45,6 +45,14 @@ 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 "$VPN_UUID" || -z "$VPN_KEY" ]]; then + echo "You need to cofigure VPN_UUID and VPN_KEY environment varibales." + fi + wait_nginx_services + 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..adc5bfc2 100644 --- a/images/common/utils.sh +++ b/images/common/utils.sh @@ -239,3 +239,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" | sudo crontab + sudo cron + bash /opt/openwisp/update_wireguard.sh watch_configuration_change +} diff --git a/images/openwisp_wireguard/Dockerfile b/images/openwisp_wireguard/Dockerfile new file mode 100644 index 00000000..fe563023 --- /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 + +RUN rm /etc/cont-init.d/40-confs && rm /etc/services.d/wireguard -r +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/update_vxlan.py \ + ./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 \ + OPENWISP_USER=root diff --git a/images/openwisp_wireguard/update_vxlan.py b/images/openwisp_wireguard/update_vxlan.py new file mode 100644 index 00000000..9aafd423 --- /dev/null +++ b/images/openwisp_wireguard/update_vxlan.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 + +import json +import os +import subprocess +import sys + +VXLAN_IPV4_METHOD = os.environ.get('VXLAN_IPV4_METHOD', 'link-local') +VXLAN_IPV6_METHOD = os.environ.get('VXLAN_IPV6_METHOD', 'link-local') + +try: + peer_file_path = sys.argv[1] +except IndexError: + print('peer file must be passed as first argument', file=sys.stderr) + sys.exit(1) + +try: + with open(peer_file_path, 'r') as peer_file: + contents = peer_file.read() +except FileNotFoundError as e: + print(e, file=sys.stderr) + sys.exit(2) + +try: + peers = json.loads(contents) + assert isinstance(peers, list) +except Exception as e: + print(f'Error while parsing JSON file: {e}', file=sys.stderr) + sys.exit(3) + + +remote_peers = {} + +for peer in peers: + remote_peers[f'vxlan-vxlan{peer["vni"]}'] = peer + + +class Nmcli: + @classmethod + def _exec_command(cls, command): + process = subprocess.Popen( + command.split(' '), stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + stdout, stderr = process.communicate() + if stderr: + raise ValueError(stderr) + return stdout.decode('utf8').strip() + + @classmethod + def list_connections(cls, type=None): + output = cls._exec_command('nmcli connection show') + lines = output.split('\n') + connections = [] + for line in lines[1:]: + parts = line.split() + connection = { + 'name': parts[0].strip(), + 'uuid': parts[1].strip(), + 'type': parts[2].strip(), + 'device': parts[3].strip(), + } + if not type or type and type == connection['type']: + connections.append(connection) + return connections + + @classmethod + def get_connection(cls, connection): + output = cls._exec_command(f'sudo nmcli connection show {connection}') + data = {} + lines = output.split('\n') + for line in lines: + parts = line.split() + data[parts[0][:-1]] = parts[1] + return data + + @classmethod + def get_local_vxlan_peers(cls): + peers = {} + vxlan_connections = cls.list_connections(type='vxlan') + for vxlan in vxlan_connections: + data = cls.get_connection(vxlan['uuid']) + peers[data['connection.id']] = { + 'remote': data['vxlan.remote'], + 'vni': int(data['vxlan.id']), + } + return peers + + @classmethod + def add_connection(cls, ifname, vni, remote): + return cls._exec_command( + f'sudo nmcli connection add type vxlan ifname {ifname} ' + f'id {vni} remote {remote} destination-port 4789 ' + f'ipv4.method {VXLAN_IPV4_METHOD} ipv6.method {VXLAN_IPV6_METHOD}' + ) + + @classmethod + def edit_connection(cls, connection, vni, remote): + return cls._exec_command( + f'sudo nmcli connection modify {connection}' + f' vxlan.id {vni} vxlan.remote {remote}' + ) + + @classmethod + def delete_connection(cls, connection): + return cls._exec_command(f'sudo nmcli connection delete {connection}') + + +local_peers = Nmcli.get_local_vxlan_peers() + + +for connection_name, peer_data in local_peers.items(): + if connection_name not in remote_peers: + Nmcli.delete_connection(connection_name) + print(f'Removed {connection_name}') + + +for connection_name, peer_data in remote_peers.items(): + vni = peer_data['vni'] + remote = peer_data['remote'] + if connection_name not in local_peers: + Nmcli.add_connection(f'vxlan{vni}', vni, remote) + print(f'Added {connection_name}') + continue + elif peer_data == local_peers[connection_name]: + print(f'Skipping {connection_name}, already up to date') + continue + else: + Nmcli.edit_connection(connection_name, vni, remote) + print(f'Updated {connection_name}') diff --git a/images/openwisp_wireguard/update_wireguard.sh b/images/openwisp_wireguard/update_wireguard.sh new file mode 100644 index 00000000..932ea8f6 --- /dev/null +++ b/images/openwisp_wireguard/update_wireguard.sh @@ -0,0 +1,154 @@ +#!/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/$VPN_UUID/?key=$VPN_KEY" +_VPN_DOWNLOAD_URL="$_VPN_URL_PATH/download-config/$VPN_UUID/?key=$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" +if [ "$INSECURE_CURL" == true ]; then + _CURL = "$_CURL --insecure" +fi + +mkdir -p $_WORKING_DIR +mkdir -p $_APPLIED_CONF_DIR + +assert_exit_code() { + exit_code=$? + lineno=$(($1 - 1)) + if [ "$exit_code" != "0" ]; then + echo "Line $lineno: Command returned non zero exit code: $exit_code" + exit $exit_code + fi +} + +check_config() { + _latest_checksum=$($_CURL $_VPN_CHECKSUM_URL) + assert_exit_code $LINENO + 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 + 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 + + export VXLAN_IPV4_METHOD="{{ openwisp2_wireguard_vxlan_ipv4_method }}" \ + VXLAN_IPV6_METHOD="{{ openwisp2_wireguard_vxlan_ipv6_method }}" + if [ -e "$CONF_DIR/vxlan.json" ]; then + "$CONF_DIR/update_vxlan.py" "$CONF_DIR/vxlan.json" + mv -f "$CONF_DIR/vxlan.json" "$_APPLIED_CONF_DIR/vxlan.json" + fi +} + +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" + if [[ "$REDIS_PORT" ]]; then + _REDIS_CMD="$_REDIS_CMD -p $REDIS_PORT" + fi + if [[ "$REDIS_PASSWORD" ]]; then + _REDIS_CMD="$_REDIS_CMD -a $REDIS_PASSWORD -n 15" + fi + while true; do + if [ -f "$_TIMESTAMP_FILE" ]; then + local_timestamp=$(cat $_TIMESTAMP_FILE) + else + local_timestamp="" + fi + current_timestamp=$($_REDIS_CMD GET wg-$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..2bc7fb79 --- /dev/null +++ b/images/openwisp_wireguard_updater/Dockerfile @@ -0,0 +1,29 @@ +# hadolint ignore=DL3007 +FROM python:3.10-slim-bullseye + +RUN apt update && \ + apt install --yes --no-install-recommends \ + gcc python3-dev gettext-base + +RUN pip3 install setuptools wheel attrs Flask~=2.1.2 \ + requests~=2.27.1 uwsgi~=2.0.20 redis~=4.3.3 + +WORKDIR /opt/openwisp/ + +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/utils.sh ./ + +CMD ["bash", "init_command.sh"] + +ENV MODULE_NAME=wireguard_updater \ + TZ=UTC \ + REDIS_HOST=redis \ + REDIS_DB=15 diff --git a/images/openwisp_wireguard_updater/uwsgi.conf.ini b/images/openwisp_wireguard_updater/uwsgi.conf.ini new file mode 100644 index 00000000..973780dc --- /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 +processes=2 +threads=2 +single-interpreter=false +die-on-term=true +procname-prefix-spaced=openwisp2_wireguard_flask_app +vacuum=true +pidfile=openwisp2_wireguard_flask_app.pid +socket=openwisp2_wireguard_flask_app.sock +worker-reload-mercy=5 +ssl-verify-depth = 8 +http=${WIREGUARD_FLASK_HOST}:${WIREGUARD_FLASK_PORT} +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..da97bb91 --- /dev/null +++ b/images/openwisp_wireguard_updater/vpn_updater.py @@ -0,0 +1,44 @@ +import os +from datetime import datetime + +import redis +from flask import Flask, Response, request + +app = Flask(__name__) + +KEY = os.environ.get('WIREGUARD_FLASK_KEY') +REDIS_HOST = os.environ.get('REDIS_HOST') +REDIS_PORT = os.environ.get('REDIS_PORT') +REDIS_PASSWORD = os.environ.get('REDIS_PASSWORD') +REDIS_DATABASE = os.environ.get('REDIS_DB', 15) + + +def _trigger_configuration_update(vpn_id): + redis_kwargs = {} + if REDIS_PASSWORD: + redis_kwargs['password'] = REDIS_PASSWORD + 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_FLASK_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'), + ) + + +if __name__ == '__main__': + app.run()