mirror of
https://github.com/YunoHost/dynette.git
synced 2024-09-03 20:06:17 +02:00
Initial commit
This commit is contained in:
commit
2c8179530f
10 changed files with 357 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
venv
|
||||||
|
__pycache__
|
111
README.md
Normal file
111
README.md
Normal 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
0
__init__.py
Normal file
155
app.py
Normal file
155
app.py
Normal 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
2
config.yml
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
DOMAINS: [nohost.me, noho.st, ynh.fr]
|
||||||
|
DB_FOLDER: /var/dynette/db/
|
11
gunicorn.py
Normal file
11
gunicorn.py
Normal 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
24
regen_named_conf.py
Normal 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
21
requirements.txt
Normal 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
27
templates/named.conf.j2
Normal 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
4
wsgi.py
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
from app import app
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run()
|
Loading…
Reference in a new issue