diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..ed0dfa7 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,11 @@ +--- +kind: pipeline +type: docker +name: default + +steps: + - name: quality + image: git.golem.linux.it/golem/vpnunit-develop:1 + commands: + - script/qa + diff --git a/Dockerfile b/Dockerfile.deploy similarity index 100% rename from Dockerfile rename to Dockerfile.deploy diff --git a/Dockerfile.develop b/Dockerfile.develop new file mode 100644 index 0000000..69b9818 --- /dev/null +++ b/Dockerfile.develop @@ -0,0 +1,5 @@ +FROM python:3 + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + diff --git a/README.md b/README.md index 2d0cd60..a935b5c 100644 --- a/README.md +++ b/README.md @@ -28,15 +28,16 @@ Using the REST API, the system administrator can manage the `uug` part of the is Since the whole `2001:470:c844:uug0::/60` network will be forwared to/from the user gateway, then she has to manage the *n* part of the address (if she wants), for example using forwarding again and DHCPv6 or SLAAC with radv. ## REST API endpoints -| endpoint | method | body | description | -|---------------------------|:---------:|-------------------------------|-----------------------| -| /users | GET | | get list of users | -| /users | POST | ```{ "name": "johndoe" }``` | create new user | -| /gateways | GET | | get list of gateways | -| /gateways | POST | ```{ "name": "fqdn.domain.tld", "user": "johndoe" }``` | create new gateway for user | -| /gateway/$fqdn | GET | | get info about gateway | -| /gateway/$fqdn | DELETE | | delete gateway | -| /gateway/$fqdn/config | GET | | get client config file for gateway | +| endpoint | method | body | description | +| --------------------- | :----: | ------------------------------------------------------ | -------------------------------------------------------------------- | +| /users | GET | | get list of users | +| /users | POST | ```{ "name": "johndoe" }``` | create new user | +| /gateways | GET | | get list of gateways | +| /gateways | POST | ```{ "name": "fqdn.domain.tld", "user": "johndoe" }``` | create new gateway for user | +| /gateway/$fqdn | GET | | get info about gateway | +| /gateway/$fqdn | DELETE | | delete gateway | +| /gateway/$fqdn/config | GET | | get client config file for gateway | +| /gateway/$fqdn/renew | POST | | generate new certificate for gateway (min 30 days before expiration) | ## Technical details VPNUnit runs inside a Docker container, and stores its data in the `/data` subvolume (which is usually mounted on `/srv/vpnunit` on the physical machine). @@ -79,4 +80,6 @@ while [ true ]; do sleep 60 done ``` - +# Develop +`Dockerfile.develop` contains instructions to build a container for assessing this project's quality, and `script/qa` is automatically run inside that container by the CI system. +Give a look at it. diff --git a/docker b/docker new file mode 100755 index 0000000..ce61522 --- /dev/null +++ b/docker @@ -0,0 +1,11 @@ +#!/bin/bash + +REPODIR=$(realpath $(dirname "$0")) + +docker run --rm -ti \ + -v "$REPODIR":/workspace \ + -v /tmp:/tmp \ + -w /workspace \ + git.golem.linux.it/golem/vpnunit-develop:1 \ + "$@" + diff --git a/main.py b/main.py index 06daeba..f95efbe 100644 --- a/main.py +++ b/main.py @@ -1,94 +1,157 @@ import sqlite3 import os from flask import Flask, request, jsonify, render_template, Response +from flask_httpauth import HTTPBasicAuth # type: ignore +import configparser + +PREFIX = "/data" +if os.environ.get("PREFIX") is not None: + PREFIX = os.environ["PREFIX"] + app = Flask(__name__) +config = configparser.ConfigParser() +auth = HTTPBasicAuth() + +config.read(os.path.join(PREFIX, "vpnunit.config.ini")) + class Ex(Exception): def __init__(self, code, message): self._code = code self._message = message + def getCode(self): return self._code + def __str__(self): return self._message -DATABASE='/data/database.sqlite3' + +DATABASE = os.path.join(PREFIX, "database.sqlite3") db = sqlite3.connect(DATABASE) cu = db.cursor() -db.execute('SELECT name FROM sqlite_master') +db.execute("SELECT name FROM sqlite_master") if len(cu.fetchall()) == 0: - print('creating database schema...') - db.execute('CREATE TABLE IF NOT EXISTS user (id INTEGER PRIMARY KEY, name TEXT UNIQUE)') - db.execute('CREATE TABLE IF NOT EXISTS gateway (id INTEGER PRIMARY KEY, subid INTEGER NOT NULL, name TEXT UNIQUE, user INTEGER NOT NULL REFERENCES user(id))') + print("creating database schema...") + db.execute( + "CREATE TABLE \ + IF NOT EXISTS \ + user (id INTEGER PRIMARY KEY, name TEXT UNIQUE)" + ) + db.execute( + "CREATE TABLE \ + IF NOT EXISTS \ + gateway \ + (id INTEGER PRIMARY KEY, \ + subid INTEGER NOT NULL, \ + name TEXT UNIQUE, \ + user INTEGER NOT NULL \ + REFERENCES user(id))" + ) db.commit() cu.close() db.close() -os.environ['EASYRSA_PKI'] = '/data/pki' -os.environ['EASYRSA_BATCH'] = '1' -os.environ['PATH'] = os.environ['PATH'] + ':' + os.getcwd() + '/easy-rsa/easyrsa3' +os.environ["EASYRSA_PKI"] = os.path.join(PREFIX, "pki") +os.environ["EASYRSA_BATCH"] = "1" +os.environ["PATH"] = ( + os.environ["PATH"] + ":" + os.getcwd() + "/easy-rsa/easyrsa3" +) -@app.route('/') + +@auth.verify_password +def verify_user(username, password): + if ( + username == config["DEFAULT"]["username"] + and password == config["DEFAULT"]["password"] + ): + return username + + +@app.route("/") def hello_world(): - return 'It works!' + return "It works!" -@app.route('/users', methods=['GET']) + +@app.route("/users", methods=["GET"]) +@auth.login_required def get_users(): db = sqlite3.connect(DATABASE) cu = db.cursor() users = [] - for row in cu.execute('SELECT id, name FROM user'): - users.append({'id': row[0], 'name': row[1]}) + for row in cu.execute("SELECT id, name FROM user"): + users.append({"id": row[0], "name": row[1]}) cu.close() db.close() return jsonify(users) -@app.route('/users', methods=['POST']) + +@app.route("/users", methods=["POST"]) +@auth.login_required def post_users(): db = sqlite3.connect(DATABASE) cu = db.cursor() - name = request.json['name'] - print('creating ' + str(name)) + name = request.json["name"] + print("creating " + str(name)) try: - cu.execute('INSERT INTO user (name) VALUES (?)', (name,)) - return jsonify({'status': 'ok'}) + cu.execute("INSERT INTO user (name) VALUES (?)", (name,)) + return jsonify({"status": "ok"}) except sqlite3.Error as e: - return jsonify({'status': 'error', 'message': str(e)}), 409 + return jsonify({"status": "error", "message": str(e)}), 409 finally: db.commit() cu.close() db.close() -@app.route('/gateways', methods=['GET']) + +@app.route("/gateways", methods=["GET"]) +@auth.login_required def get_gateways(): db = sqlite3.connect(DATABASE) cu = db.cursor() gateways = [] - for row in cu.execute('SELECT g.id, g.subid, g.name, u.name FROM gateway AS g INNER JOIN user AS u ON u.id = g.user'): - gateways.append({'id': row[0], 'subid': row[1], 'name': row[2], 'user': row[3]}) + for row in cu.execute( + "SELECT g.id, g.subid, g.name, u.name \ + FROM gateway AS g \ + INNER JOIN user AS u ON u.id = g.user" + ): + gateways.append( + {"id": row[0], "subid": row[1], "name": row[2], "user": row[3]} + ) cu.close() db.close() return jsonify(gateways) -@app.route('/gateways', methods=['POST']) + +@app.route("/gateways", methods=["POST"]) +@auth.login_required def post_gateways(): db = sqlite3.connect(DATABASE) cu = db.cursor() try: - name = request.json['name'] - user = request.json['user'] + name = request.json["name"] + user = request.json["user"] # TODO sanitize name, it must be a FQDN used_gateway_subids = [] - for row in cu.execute('SELECT g.subid, u.id FROM user AS u LEFT JOIN gateway AS g ON g.user = u.id WHERE u.name = ?', [str(user,)]): + for row in cu.execute( + "SELECT g.subid, u.id \ + FROM user AS u \ + LEFT JOIN gateway AS g ON g.user = u.id WHERE u.name = ?", + [ + str( + user, + ) + ], + ): used_gateway_subids.append(row[0]) userid = row[1] @@ -98,117 +161,179 @@ def post_gateways(): break if subid in used_gateway_subids: - raise Ex(403, 'exit: maximum number of gateways reached for user') + raise Ex(403, "exit: maximum number of gateways reached for user") - cu.execute('INSERT INTO gateway (subid, name, user) VALUES (?, ?, (SELECT id FROM user WHERE name = ?))', [subid, str(name,), str(user,)]) + cu.execute( + "INSERT INTO gateway (subid, name, user) \ + VALUES (?, ?, (SELECT id FROM user WHERE name = ?))", + [ + subid, + str( + name, + ), + str( + user, + ), + ], + ) - os.environ['EASYRSA_REQ_CN'] = name + os.environ["EASYRSA_REQ_CN"] = name - r = os.system('easyrsa gen-req {} nopass'.format(name)) + r = os.system("easyrsa gen-req {} nopass".format(name)) if r != 0: - raise Ex(500, 'exit: {} cannot gen-req'.format(r)) - r = os.system('easyrsa sign-req client {}'.format(name)) + raise Ex(500, "exit: {} cannot gen-req".format(r)) + r = os.system("easyrsa sign-req client {}".format(name)) if r != 0: - raise Ex(500, 'exit: {} cannot sign-req'.format(r)) + raise Ex(500, "exit: {} cannot sign-req".format(r)) - ipid = '{:x}{:x}'.format(userid, subid) + ipid = "{:x}{:x}".format(userid, subid) - address = '2001:470:c844::' + ipid + '0' - network = '2001:470:c844:' + ipid + '0::/60' + address = "2001:470:c844::" + ipid + "0" + network = "2001:470:c844:" + ipid + "0::/60" - staticclient = render_template('staticclient', address=address, network=network) - with open('/data/ovpn/clients/' + name, 'w') as f: + staticclient = render_template( + "staticclient", address=address, network=network + ) + with open(os.path.join(PREFIX, "ovpn/clients/", name), "w") as f: f.write(staticclient) - with open('/data/ip/routes', 'a') as f: - f.write(network + ' via ' + address + '\n') + with open(os.path.join(PREFIX, "ip/routes"), "a") as f: + f.write(network + " via " + address + "\n") db.commit() - return jsonify({'status': 'ok'}) + return jsonify({"status": "ok"}) except KeyError as e: - return jsonify({'status': 'error', 'message': str(e)}), 400 + return jsonify({"status": "error", "message": str(e)}), 400 except sqlite3.Error as e: - return jsonify({'status': 'error', 'message': str(e)}), 409 + return jsonify({"status": "error", "message": str(e)}), 409 except Ex as e: - return jsonify({'status': 'error', 'message': str(e)}), e.getCode() + return jsonify({"status": "error", "message": str(e)}), e.getCode() finally: cu.close() db.close() -@app.route('/gateway/', methods=['GET']) + +@app.route("/gateway/", methods=["GET"]) +@auth.login_required def get_gateway(fqdn): db = sqlite3.connect(DATABASE) cu = db.cursor() gateway = dict() - for row in cu.execute('SELECT g.id, g.subid, g.name, u.name FROM gateway AS g INNER JOIN user AS u ON u.id = g.user'): - gateway['id'] = row[0] - gateway['subid'] = row[1] - gateway['name'] = row[2] - gateway['user'] = row[3] + for row in cu.execute( + "SELECT g.id, g.subid, g.name, u.name \ + FROM gateway AS g \ + INNER JOIN user AS u ON u.id = g.user" + ): + gateway["id"] = row[0] + gateway["subid"] = row[1] + gateway["name"] = row[2] + gateway["user"] = row[3] cu.close() db.close() return jsonify(gateway) -@app.route('/gateway//config', methods=['GET']) + +@app.route("/gateway//config", methods=["GET"]) +@auth.login_required def get_gateway_config(fqdn): # TODO sanity check FQDN # WARNING: maybe you want to do more than a simple sanity check, # or you have to trust the user of these API # eg: he could retrieve the CA.key !!! - with open(os.environ['EASYRSA_PKI'] + '/issued/' + fqdn + '.crt') as f: + with open(os.environ["EASYRSA_PKI"] + "/issued/" + fqdn + ".crt") as f: cert = f.read() - with open(os.environ['EASYRSA_PKI'] + '/private/' + fqdn + '.key') as f: + with open(os.environ["EASYRSA_PKI"] + "/private/" + fqdn + ".key") as f: key = f.read() - with open(os.environ['EASYRSA_PKI'] + '/ca.crt') as f: + with open(os.environ["EASYRSA_PKI"] + "/ca.crt") as f: ca = f.read() - return Response(render_template('config.ovpn', ca=ca, cert=cert, key=key), mimetype='text/plain') + return Response( + render_template("config.ovpn", ca=ca, cert=cert, key=key), + mimetype="text/plain", + ) -@app.route('/gateway/', methods=['DELETE']) + +@app.route("/gateway//renew", methods=["POST"]) +@auth.login_required +def post_gateway_renew(fqdn): + os.environ["EASYRSA_CERT_EXPIRE"] = "180" # days + + r = os.system("easyrsa renew {} nopass".format(fqdn)) + if r != 0: + raise Ex(500, "exit: {} cannot renew") + + return jsonify({"status": "ok"}) + + +@app.route("/gateway/", methods=["DELETE"]) +@auth.login_required def delete_gateway(fqdn): # TODO sanity check for this parameter! Possible system command injection db = sqlite3.connect(DATABASE) cu = db.cursor() - for row in cu.execute('SELECT u.id, g.subid FROM gateway AS g INNER JOIN user AS u ON u.id = g.user WHERE g.name = ?', [str(fqdn,)]): + for row in cu.execute( + "SELECT u.id, g.subid \ + FROM gateway AS g \ + INNER JOIN user AS u ON u.id = g.user WHERE g.name = ?", + [ + str( + fqdn, + ) + ], + ): userid = row[0] subid = row[1] - ipid = '{:x}{:x}'.format(userid, subid) + ipid = "{:x}{:x}".format(userid, subid) break - address = '2001:470:c844::' + ipid + '0/64' - network = '2001:470:c844:' + ipid + '0::/60' + address = "2001:470:c844::" + ipid + "0/64" + network = "2001:470:c844:" + ipid + "0::/60" - sedrm = "sed -i '\\_^" + network + " via " + address + "$_d' /data/ip/routes" - print('[sedrm] ' + sedrm) + sedrm = ( + "sed -i '\\_^" + + network + + " via " + + address + + "$_d' " + + PREFIX + + "/ip/routes" + ) + print("[sedrm] " + sedrm) r = os.system(sedrm) if r != 0: - raise Ex(500, 'exit: {} cannot sed-out ip route'.format(r)) + raise Ex(500, "exit: {} cannot sed-out ip route".format(r)) - cu.execute('DELETE FROM gateway AS g WHERE g.name = ?', [str(fqdn,)]) + cu.execute( + "DELETE FROM gateway AS g WHERE g.name = ?", + [ + str( + fqdn, + ) + ], + ) try: - r = os.system('easyrsa revoke {}'.format(fqdn)) + r = os.system("easyrsa revoke {}".format(fqdn)) if r != 0: - raise Ex(500, 'exit: {} cannot revoke'.format(r)) - r = os.system('easyrsa gen-crl') + raise Ex(500, "exit: {} cannot revoke".format(r)) + r = os.system("easyrsa gen-crl") if r != 0: - raise Ex(500, 'exit: {} cannot gen-crl'.format(r)) + raise Ex(500, "exit: {} cannot gen-crl".format(r)) except Ex as e: - return jsonify({'status': 'error', 'message': str(e)}), e.getCode() + return jsonify({"status": "error", "message": str(e)}), e.getCode() - os.remove('/data/ovpn/clients/' + fqdn) + os.remove(os.path.join(PREFIX, "ovpn/clients", fqdn)) db.commit() cu.close() db.close() - return jsonify({'status': 'ok'}) + return jsonify({"status": "ok"}) -if __name__ == '__main__': +if __name__ == "__main__": app.run(host="::", port=5000, debug=True) - diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f3497b3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[tool.black] +line-length = 79 + diff --git a/requirements.txt b/requirements.txt index 139affa..ddff33d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,20 @@ -click==7.1.2 -Flask==1.1.2 -itsdangerous==1.1.0 -Jinja2==2.11.2 -MarkupSafe==1.1.1 -Werkzeug==1.0.1 +black==23.3.0 +click==8.1.3 +flake8==6.0.0 +Flask==2.2.3 +Flask-HTTPAuth==4.7.0 +itsdangerous==2.1.2 +Jinja2==3.1.2 +MarkupSafe==2.1.2 +mccabe==0.7.0 +mypy==1.2.0 +mypy-extensions==1.0.0 +packaging==23.0 +pathspec==0.11.1 +platformdirs==3.2.0 +pur==7.1.0 +pycodestyle==2.10.0 +pyflakes==3.0.1 +tomli==2.0.1 +typing_extensions==4.5.0 +Werkzeug==2.2.3 diff --git a/script/qa b/script/qa new file mode 100755 index 0000000..51c0984 --- /dev/null +++ b/script/qa @@ -0,0 +1,22 @@ +#!/bin/bash +# Software Quality Assurance tests (formatters, linters, ...) + +if [ "$1" == "--dry-run" ]; then + BLACK_FLAGS="--check" +fi + +set -euo pipefail + +cd "$(dirname "$0")/.." + +FILES=( + main.py +) + +# Code formatting +python3 -m black ${BLACK_FLAGS:-} "${FILES[@]}" + +# Linters +python3 -m flake8 "${FILES[@]}" +python3 -m mypy "${FILES[@]}" + diff --git a/script/setup b/script/setup new file mode 100755 index 0000000..eac4a35 --- /dev/null +++ b/script/setup @@ -0,0 +1,6 @@ +#!/bin/bash + +REPODIR=$(realpath $(dirname "$0")/..) + +docker build -t git.golem.linux.it/golem/vpnunit-develop:1 -f "$REPODIR"/Dockerfile.develop "$REPODIR" +