Initial commit

This commit is contained in:
Alexandre Aubin 2023-01-19 00:32:49 +01:00
commit 2c8179530f
10 changed files with 357 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
venv
__pycache__

111
README.md Normal file
View file

@ -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.
```

0
__init__.py Normal file
View file

155
app.py Normal file
View file

@ -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/<domain>')
@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/<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/<subdomain>', 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

2
config.yml Normal file
View file

@ -0,0 +1,2 @@
DOMAINS: [nohost.me, noho.st, ynh.fr]
DB_FOLDER: /var/dynette/db/

11
gunicorn.py Normal file
View file

@ -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

24
regen_named_conf.py Normal file
View file

@ -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')

21
requirements.txt Normal file
View file

@ -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

27
templates/named.conf.j2 Normal file
View file

@ -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 %}

4
wsgi.py Normal file
View file

@ -0,0 +1,4 @@
from app import app
if __name__ == "__main__":
app.run()