Compare commits
6 Commits
cd2abf637d
...
161172a174
Author | SHA1 | Date |
---|---|---|
giomba | 161172a174 | |
giomba | 7798356bda | |
giomba | 7eb3e946e5 | |
giomba | 72dab0b374 | |
giomba | ac82d57b09 | |
giomba | 0ce99876b4 |
|
@ -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
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
FROM python:3
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
23
README.md
23
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.
|
||||
|
|
|
@ -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 \
|
||||
"$@"
|
||||
|
271
main.py
271
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/<fqdn>', methods=['GET'])
|
||||
|
||||
@app.route("/gateway/<fqdn>", 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/<fqdn>/config', methods=['GET'])
|
||||
|
||||
@app.route("/gateway/<fqdn>/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/<fqdn>', methods=['DELETE'])
|
||||
|
||||
@app.route("/gateway/<fqdn>/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/<fqdn>", 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)
|
||||
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
[tool.black]
|
||||
line-length = 79
|
||||
|
|
@ -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
|
||||
|
|
|
@ -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[@]}"
|
||||
|
|
@ -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"
|
||||
|
Loading…
Reference in New Issue