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