From 2c8179530f03784c1dfef27b16d75f51a54c0a68 Mon Sep 17 00:00:00 2001 From: Alexandre Aubin Date: Thu, 19 Jan 2023 00:32:49 +0100 Subject: [PATCH] Initial commit --- .gitignore | 2 + README.md | 111 ++++++++++++++++++++++++++++ __init__.py | 0 app.py | 155 ++++++++++++++++++++++++++++++++++++++++ config.yml | 2 + gunicorn.py | 11 +++ regen_named_conf.py | 24 +++++++ requirements.txt | 21 ++++++ templates/named.conf.j2 | 27 +++++++ wsgi.py | 4 ++ 10 files changed, 357 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 __init__.py create mode 100644 app.py create mode 100644 config.yml create mode 100644 gunicorn.py create mode 100644 regen_named_conf.py create mode 100644 requirements.txt create mode 100644 templates/named.conf.j2 create mode 100644 wsgi.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f93ebf --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +venv +__pycache__ diff --git a/README.md b/README.md new file mode 100644 index 0000000..d0d1380 --- /dev/null +++ b/README.md @@ -0,0 +1,111 @@ + + + +### Setup + +```bash +python3 -m venv venv +source venv/bin/activate +pip3 install -r requirements.txt +``` + +### Dev + + +```bash +FLASK_APP=app.py flask run +``` + + +### Production + +- You should also install bind9 +- Include `/etc/bind/named.conf.local` in `/etc/bind/named.conf` +- Install the following services + +##### `dynette.service` + +``` +# Systemd config +[Unit] +Description=Dynette gunicorn daemon +After=network.target + +[Service] +PIDFile=/run/gunicorn/dynette-pid +User=dynette +Group=dynette +WorkingDirectory=/var/www/dynette +ExecStart=/var/www/dynette/venv/bin/gunicorn -c /var/www/dynette/gunicorn.py wsgi:app +ExecReload=/bin/kill -s HUP $MAINPID +ExecStop=/bin/kill -s TERM $MAINPID +PrivateTmp=true + +[Install] +WantedBy=multi-user.target +``` + +##### `dynette-regen-named-conf.service` + +``` +[Unit] +Description=Dynette named.conf regen +After=network.target +StartLimitIntervalSec=10 +StartLimitBurst=5 + +[Service] +Type=oneshot +WorkingDirectory=/var/www/dynette +ExecStart=/var/www/dynette/venv/bin/python3 /var/www/dynette/regen_named_conf.py +User=root +Group=root + +[Install] +WantedBy=multi-user.target +``` + +##### `dynette-regen-named-conf.path` + +``` +[Path] +Unit=dynette-regen-named-conf.service +PathChanged=/var/dynette/db/ + +[Install] +WantedBy=multi-user.target +``` + +##### NGINX conf snippet + +``` +location / { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_redirect off; + proxy_pass http://unix:/var/www/dynette/sock; + proxy_read_timeout 210s; +} +``` + +### If we ever decide to add another base domain + +We should initialize `/var/lib/bind/BASE_DOMAIN.db` (replace `BASE_DOMAIN` with e.g. nohost.me) with: + +```text +$ORIGIN . +$TTL 10 ; 10 seconds +BASE_DOMAIN IN SOA ns0.yunohost.org. hostmaster.yunohost.org. ( + 1006380 ; serial + 10800 ; refresh (3 hours) + 3600 ; retry (1 hour) + 604800 ; expire (1 week) + 10 ; minimum (10 seconds) + ) +$TTL 3600 ; 1 hour + NS ns0.yunohost.org. + NS ns1.yunohost.org. +$ORIGIN BASE_DOMAIN. +``` diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app.py b/app.py new file mode 100644 index 0000000..a6f69e7 --- /dev/null +++ b/app.py @@ -0,0 +1,155 @@ +import hmac +import base64 +import os +import re +import yaml +import bcrypt + +from flask import Flask, jsonify, request +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address + +DOMAIN_REGEX = re.compile(r"^([a-z0-9]{1}([a-z0-9\-]*[a-z0-9])*)(\.[a-z0-9]{1}([a-z0-9\-]*[a-z0-9])*)*(\.[a-z]{1}([a-z0-9\-]*[a-z0-9])*)$") + +app = Flask(__name__) +app.config.from_file("config.yml", load=yaml.safe_load) +limiter = Limiter( + get_remote_address, + app=app, + default_limits=["50 per hour"], + storage_uri="memory://", +) + +assert os.path.isdir(app.config['DB_FOLDER']), "You should create the DB folder declared in the config" + +def _validate_domain(domain): + + if not DOMAIN_REGEX.match(domain): + return {"error": f"This is not a valid domain: {domain}"}, 400 + + if len(domain.split(".")) != 3 or domain.split(".", 1)[-1] not in app.config["DOMAINS"]: + return {"error": f"This subdomain is not handled by this dynette server."}, 400 + + +def _is_available(domain): + + return not os.path.exists(f"{app.config['DB_FOLDER']}/{domain}.key") + + +@app.route('/') +@limiter.exempt +def home(): + return 'Wanna play the dynette?' + + +@app.route('/domains') +@limiter.exempt +def domains(): + return jsonify(app.config["DOMAINS"]), 200 + + +@app.route('/test/') +@limiter.limit("3 per minute") +def availability(domain): + + error = _validate_domain(domain) + if error: + return error + + if _is_available(domain): + return f"Domain {domain} is available", 200 + else: + return {"error": f"Subdomain already taken: {domain}"}, 409 + + +@app.route('/key/', methods=['POST']) +@limiter.limit("5 per hour") +def register(key): + + try: + key = base64.b64decode(key).decode() + except Exception as e: + return {"error": "Key format is invalid"}, 400 + else: + if len(key) != 89: + return {"error": "Key format is invalid"}, 400 + + try: + data = request.get_json(force=True) + assert isinstance(data, dict) + subdomain = data.get("subdomain") + assert isinstance(subdomain, str) + except Exception: + return {"error": "Invalid request"}, 400 + + error = _validate_domain(subdomain) + if error: + return error + + if not _is_available(subdomain): + return {"error": f"Subdomain already taken: {subdomain}"}, 409 + + recovery_password = data.get("recovery_password") + if recovery_password and isinstance(recovery_password, str): + if len(recovery_password) < 8: + return {"error": "Recovery password too short"}, 409 + if len(recovery_password) > 1024: + return {"error": "Recovery password too long"}, 409 + + r_init = recovery_password + recovery_password = bcrypt.hashpw(password=recovery_password.encode(), salt=bcrypt.gensalt(14)) + recovery_password = base64.b64encode(recovery_password).decode() + + + with open(f"{app.config['DB_FOLDER']}/{subdomain}.key", "w") as f: + f.write(key) + + if recovery_password: + with open(f"{app.config['DB_FOLDER']}/{subdomain}.recovery_password", "w") as f: + f.write(recovery_password) + + return "OK", 201 + +@app.route('/domains/', methods=['DELETE']) +@limiter.limit("5 per hour") +def delete_using_recovery_password_or_key(subdomain): + + try: + assert isinstance(subdomain, str) + data = request.get_json(force=True) + assert isinstance(data, dict) + recovery_password = data.get("recovery_password") + key = data.get("key") + assert (recovery_password and isinstance(recovery_password, str)) \ + or (key and isinstance(key, str)) + if key: + key = base64.b64decode(key).decode() + except Exception: + return {"error": "Invalid request"}, 400 + + error = _validate_domain(subdomain) + if error: + return error + + if _is_available(subdomain): + return {"error": "Subdomain already deleted"}, 409 + + if key: + with open(f"{app.config['DB_FOLDER']}/{subdomain}.key") as f: + if not hmac.compare_digest(key, f.read()): + return "Access denied", 403 + if recovery_password: + if not os.path.exists(f"{app.config['DB_FOLDER']}/{subdomain}.recovery_password"): + return "Access denied", 403 + with open(f"{app.config['DB_FOLDER']}/{subdomain}.recovery_password") as f: + hashed = base64.b64decode(f.read()) + + if not bcrypt.checkpw(recovery_password.encode(), hashed): + return "Access denied", 403 + + if os.path.exists(f"{app.config['DB_FOLDER']}/{subdomain}.key"): + os.remove(f"{app.config['DB_FOLDER']}/{subdomain}.key") + if os.path.exists(f"{app.config['DB_FOLDER']}/{subdomain}.recovery_password"): + os.remove(f"{app.config['DB_FOLDER']}/{subdomain}.recovery_password") + + return "OK", 200 diff --git a/config.yml b/config.yml new file mode 100644 index 0000000..6164ee5 --- /dev/null +++ b/config.yml @@ -0,0 +1,2 @@ +DOMAINS: [nohost.me, noho.st, ynh.fr] +DB_FOLDER: /var/dynette/db/ diff --git a/gunicorn.py b/gunicorn.py new file mode 100644 index 0000000..90ab8dd --- /dev/null +++ b/gunicorn.py @@ -0,0 +1,11 @@ +command = '/var/www/dynette/venv/bin/gunicorn' +pythonpath = '/var/www/dynette' +workers = 4 +user = 'dynette' +bind = 'unix:/var/www/dynette/sock' +pid = '/run/gunicorn/dynette-pid' +errorlog = '/var/log/dynette/error.log' +accesslog = '/var/log/dynette/access.log' +access_log_format = '%({X-Real-IP}i)s %({X-Forwarded-For}i)s %(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s"' +loglevel = 'warning' +capture_output = True diff --git a/regen_named_conf.py b/regen_named_conf.py new file mode 100644 index 0000000..6d55fda --- /dev/null +++ b/regen_named_conf.py @@ -0,0 +1,24 @@ +import os +import yaml +import glob +import jinja2 + +config = yaml.safe_load(open("config.yml").read()) + +domains = [{"name": domain, "subdomains": []} for domain in config["DOMAINS"]] + +for infos in domains: + domain = infos["name"] + for f in glob.glob(config["DB_FOLDER"] + f"*.{domain}.key"): + key = open(f).read() + subdomain = f.split("/")[-1].rsplit(".", 1)[0] + infos["subdomains"].append({"name": subdomain, "key": key}) + +templateLoader = jinja2.FileSystemLoader(searchpath="./templates/") +templateEnv = jinja2.Environment(loader=templateLoader) +template = templateEnv.get_template("named.conf.j2") +named_conf = template.render(domains=domains) + +open('/etc/bind/named.conf.local', 'w').write(named_conf) +os.system('chown -R bind:bind /etc/bind/named.conf.local /var/lib/bind/') +os.system('/usr/sbin/rndc reload') diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..99dc8c2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,21 @@ +bcrypt==4.0.1 +click==8.1.3 +commonmark==0.9.1 +Deprecated==1.2.13 +Flask==2.2.2 +Flask-Limiter==3.1.0 +gunicorn==20.1.0 +importlib-metadata==6.0.0 +itsdangerous==2.1.2 +Jinja2==3.1.2 +limits==3.1.6 +MarkupSafe==2.1.2 +ordered-set==4.1.0 +packaging==23.0 +Pygments==2.14.0 +PyYAML==6.0 +rich==12.6.0 +typing-extensions==4.4.0 +Werkzeug==2.2.2 +wrapt==1.14.1 +zipp==3.11.0 diff --git a/templates/named.conf.j2 b/templates/named.conf.j2 new file mode 100644 index 0000000..f95ac3e --- /dev/null +++ b/templates/named.conf.j2 @@ -0,0 +1,27 @@ +{% for domain in domains %} +zone "{{ domain.name }}" { + type master; + file "/var/lib/bind/{{ domain.name }}.db"; + update-policy { + {% for subdomain in domain.subdomains %} + grant {{ subdomain.name }}. name {{ subdomain.name }}. A AAAA TXT MX CAA; + grant {{ subdomain.name }}. name *.{{ subdomain.name }}. A AAAA; + grant {{ subdomain.name }}. name mail._domainkey.{{ subdomain.name }}. TXT; + grant {{ subdomain.name }}. name _dmarc.{{ subdomain.name }}. TXT; + grant {{ subdomain.name }}. name _xmpp-client._tcp.{{ subdomain.name }}. SRV; + grant {{ subdomain.name }}. name _xmpp-server._tcp.{{ subdomain.name }}. SRV; + grant {{ subdomain.name }}. name xmpp-upload.{{ subdomain.name }}. A AAAA CNAME; + grant {{ subdomain.name }}. name muc.{{ subdomain.name }}. A AAAA CNAME; + grant {{ subdomain.name }}. name vjud.{{ subdomain.name }}. A AAAA CNAME; + grant {{ subdomain.name }}. name pubsub.{{ subdomain.name }}. A AAAA CNAME; + {% endfor %} + }; +}; + +{% for subdomain in domain.subdomains %} +key {{ subdomain.name }}. { + algorithm hmac-sha512; + secret "{{ subdomain.key }}"; +}; +{% endfor %} +{% endfor %} diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..d11e960 --- /dev/null +++ b/wsgi.py @@ -0,0 +1,4 @@ +from app import app + +if __name__ == "__main__": + app.run()