Make a squash

This commit is contained in:
Timotej Lazar 2022-01-03 11:33:02 +01:00
commit 113992f95b
21 changed files with 3339 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
__pycache__/
/config/
*.db

6
pusher Executable file
View file

@ -0,0 +1,6 @@
#!/bin/sh
(echo ; inotifywait -m --include '[0-9]*\.tar\.gz' -e create config) |
while read ; do
FLASK_APP=web python3 -m flask push
done

4
requirements.txt Normal file
View file

@ -0,0 +1,4 @@
click
flask
flask-login
flask-ldap3-login

69
web/__init__.py Normal file
View file

@ -0,0 +1,69 @@
import os
import flask
import flask_ldap3_login
import flask_login
def create_app(test_config=None):
app = flask.Flask(__name__)
app.config['DEBUG'] = True
app.config['SECRET_KEY'] = 'KagjQoUSTtjYC3GQPpfBHcpMJvZg5R1L'
# try:
# os.makedirs(app.instance_path)
# except OSError:
# pass
from . import db
db.init_app(app)
from . import auth
app.register_blueprint(auth.blueprint)
from . import config
app.register_blueprint(config.blueprint)
from . import dnat
app.register_blueprint(dnat.blueprint)
from . import vpn
app.register_blueprint(vpn.blueprint)
from . import system
system.init_app(app)
settings = db.load('settings')
app.config['LDAP_USE_SSL'] = True
app.config['LDAP_HOST'] = settings.get('ldap_host', '')
app.config['LDAP_PORT'] = int(settings.get('ldap_port', '636'))
app.config['LDAP_BASE_DN'] = settings.get('ldap_base_dn', '')
app.config['LDAP_USER_DN'] = settings.get('ldap_user_dn', '')
app.config['LDAP_BIND_USER_DN'] = settings.get('ldap_user', '')
app.config['LDAP_BIND_USER_PASSWORD'] = settings.get('ldap_pass', '')
app.config['LDAP_USER_LOGIN_ATTR'] = settings.get('ldap_login_attr', 'userPrincipalName')
app.config['LDAP_USER_SEARCH_SCOPE'] = 'SUBTREE'
login_manager = flask_login.LoginManager(app)
ldap_manager = flask_ldap3_login.LDAP3LoginManager(app)
users = {}
@login_manager.user_loader
def load_user(id):
return users.get(id)
@ldap_manager.save_user
def save_user(dn, username, data, memberships):
user = auth.User(dn, username, data)
users[dn] = user
return user
@login_manager.unauthorized_handler
def unauth_handler():
return flask.redirect(flask.url_for('auth.login', next=flask.request.endpoint))
@app.route('/')
@flask_login.login_required
def home():
return flask.render_template('index.html')
return app

39
web/auth.py Normal file
View file

@ -0,0 +1,39 @@
import flask
import flask_login
import flask_ldap3_login.forms
from . import db
blueprint = flask.Blueprint('auth', __name__, url_prefix='/auth')
class User(flask_login.UserMixin):
def __init__(self, dn, username, data):
self.dn = dn
self.username = username
self.data = data
self.groups = data.get('memberOf', [])
try:
self.is_admin = db.load('settings').get('ldap_admin') in self.groups
except:
self.is_admin = False
def __repr__(self):
return self.dn
def get_id(self):
return self.dn
@blueprint.route('/login', methods=['GET', 'POST'])
def login():
form = flask_ldap3_login.forms.LDAPLoginForm()
if form.validate_on_submit():
flask_login.login_user(form.user)
return flask.redirect('/')
return flask.render_template('auth/login.html', form=form)
@blueprint.route('/logout')
@flask_login.login_required
def logout():
flask_login.logout_user()
return flask.redirect('/')

41
web/config.py Normal file
View file

@ -0,0 +1,41 @@
import json
import flask
import flask_login
from . import db
from . import system
blueprint = flask.Blueprint('config', __name__, url_prefix='/config')
@blueprint.route('/', methods=('GET', 'POST'))
@flask_login.login_required
def index():
try:
if not flask_login.current_user.is_admin:
return flask.Response('forbidden', status=403, mimetype='text/plain')
with db.locked('settings'):
if flask.request.method == 'POST':
form = flask.request.form
db.write('settings', dict(zip(form.getlist('setting'), form.getlist('value'))))
settings = db.read('settings')
return flask.render_template('config/index.html', **locals())
except Exception as e:
return flask.Response(f'something went catastrophically wrong: {e}',
status=400, mimetype='text/plain')
@blueprint.route('/edit/<name>', methods=('GET', 'POST'))
@flask_login.login_required
def edit(name):
try:
if not flask_login.current_user.is_admin:
return flask.Response('forbidden', status=403, mimetype='text/plain')
if flask.request.method == 'POST':
form = flask.request.form
db.save(name, json.loads(form.get('text')))
system.run(system.save_config)
content = json.dumps(db.load(name), indent=2)
return flask.render_template('config/edit.html', **locals())
except Exception as e:
return flask.Response(f'something went catastrophically wrong: {e}',
status=400, mimetype='text/plain')

58
web/db.py Normal file
View file

@ -0,0 +1,58 @@
import contextlib
import json
import pathlib
import time
import click
import flask
import flask.cli
def lock(name):
lockfile = pathlib.Path(f'{name}.lock')
for i in range(5):
try:
lockfile.symlink_to('/dev/null')
return
except FileExistsError:
time.sleep(1)
raise TimeoutError(f'could not lock {name}')
def unlock(name):
lockfile = pathlib.Path(f'{name}.lock')
lockfile.unlink(missing_ok=True)
@contextlib.contextmanager
def locked(name):
lock(name)
try:
yield name
finally:
unlock(name)
def read(name):
with open(f'{name}.json', 'a+', encoding='utf-8') as f:
f.seek(0)
return json.loads(f.read() or '{}')
def write(name, data):
with open(f'{name}.json', 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2)
f.close()
def load(name):
with locked(name):
return read(name)
def save(name, data):
with locked(name):
write(name, data)
def init_app(app):
#app.teardown_appcontext(close_db)
app.cli.add_command(init_db)
@click.command('init-db')
@flask.cli.with_appcontext
def init_db():
"""Clear the existing data and create new tables."""
pass # TODO, if needed

16
web/dnat.py Normal file
View file

@ -0,0 +1,16 @@
import flask
import flask_login
#from .db import get_db
blueprint = flask.Blueprint('dnat', __name__, url_prefix='/dnat')
@blueprint.route('/', methods=('GET', 'POST'))
@flask_login.login_required
def index():
# with get_db() as db:
# if flask.request.method == 'POST':
# for name, value in flask.request.form.items():
# db.execute('INSERT INTO setting(name, value) VALUES(:name, :value) ON CONFLICT(name) DO UPDATE SET value = :value', ({"name": name, "value": value}))
# dnat = [tuple(row) for row in (db.execute('SELECT ext_ip, int_ip FROM dnat'))]
return flask.render_template('dnat/index.html', **locals())

2298
web/static/qrcode.js Normal file

File diff suppressed because it is too large Load diff

185
web/static/wireguard.js Normal file
View file

@ -0,0 +1,185 @@
/*! SPDX-License-Identifier: GPL-2.0
*
* Copyright (C) 2015-2020 Jason A. Donenfeld <Jason@zx2c4.com>. All Rights Reserved.
*/
(function() {
function gf(init) {
var r = new Float64Array(16);
if (init) {
for (var i = 0; i < init.length; ++i)
r[i] = init[i];
}
return r;
}
function pack(o, n) {
var b, m = gf(), t = gf();
for (var i = 0; i < 16; ++i)
t[i] = n[i];
carry(t);
carry(t);
carry(t);
for (var j = 0; j < 2; ++j) {
m[0] = t[0] - 0xffed;
for (var i = 1; i < 15; ++i) {
m[i] = t[i] - 0xffff - ((m[i - 1] >> 16) & 1);
m[i - 1] &= 0xffff;
}
m[15] = t[15] - 0x7fff - ((m[14] >> 16) & 1);
b = (m[15] >> 16) & 1;
m[14] &= 0xffff;
cswap(t, m, 1 - b);
}
for (var i = 0; i < 16; ++i) {
o[2 * i] = t[i] & 0xff;
o[2 * i + 1] = t[i] >> 8;
}
}
function carry(o) {
var c;
for (var i = 0; i < 16; ++i) {
o[(i + 1) % 16] += (i < 15 ? 1 : 38) * Math.floor(o[i] / 65536);
o[i] &= 0xffff;
}
}
function cswap(p, q, b) {
var t, c = ~(b - 1);
for (var i = 0; i < 16; ++i) {
t = c & (p[i] ^ q[i]);
p[i] ^= t;
q[i] ^= t;
}
}
function add(o, a, b) {
for (var i = 0; i < 16; ++i)
o[i] = (a[i] + b[i]) | 0;
}
function subtract(o, a, b) {
for (var i = 0; i < 16; ++i)
o[i] = (a[i] - b[i]) | 0;
}
function multmod(o, a, b) {
var t = new Float64Array(31);
for (var i = 0; i < 16; ++i) {
for (var j = 0; j < 16; ++j)
t[i + j] += a[i] * b[j];
}
for (var i = 0; i < 15; ++i)
t[i] += 38 * t[i + 16];
for (var i = 0; i < 16; ++i)
o[i] = t[i];
carry(o);
carry(o);
}
function invert(o, i) {
var c = gf();
for (var a = 0; a < 16; ++a)
c[a] = i[a];
for (var a = 253; a >= 0; --a) {
multmod(c, c, c);
if (a !== 2 && a !== 4)
multmod(c, c, i);
}
for (var a = 0; a < 16; ++a)
o[a] = c[a];
}
function clamp(z) {
z[31] = (z[31] & 127) | 64;
z[0] &= 248;
}
function generatePublicKey(privateKey) {
var r, z = new Uint8Array(32);
var a = gf([1]),
b = gf([9]),
c = gf(),
d = gf([1]),
e = gf(),
f = gf(),
_121665 = gf([0xdb41, 1]),
_9 = gf([9]);
for (var i = 0; i < 32; ++i)
z[i] = privateKey[i];
clamp(z);
for (var i = 254; i >= 0; --i) {
r = (z[i >>> 3] >>> (i & 7)) & 1;
cswap(a, b, r);
cswap(c, d, r);
add(e, a, c);
subtract(a, a, c);
add(c, b, d);
subtract(b, b, d);
multmod(d, e, e);
multmod(f, a, a);
multmod(a, c, a);
multmod(c, b, e);
add(e, a, c);
subtract(a, a, c);
multmod(b, a, a);
subtract(c, d, f);
multmod(a, c, _121665);
add(a, a, d);
multmod(c, c, a);
multmod(a, d, f);
multmod(d, b, _9);
multmod(b, e, e);
cswap(a, b, r);
cswap(c, d, r);
}
invert(c, c);
multmod(a, a, c);
pack(z, a);
return z;
}
function generatePresharedKey() {
var privateKey = new Uint8Array(32);
window.crypto.getRandomValues(privateKey);
return privateKey;
}
function generatePrivateKey() {
var privateKey = generatePresharedKey();
clamp(privateKey);
return privateKey;
}
function encodeBase64(dest, src) {
var input = Uint8Array.from([(src[0] >> 2) & 63, ((src[0] << 4) | (src[1] >> 4)) & 63, ((src[1] << 2) | (src[2] >> 6)) & 63, src[2] & 63]);
for (var i = 0; i < 4; ++i)
dest[i] = input[i] + 65 +
(((25 - input[i]) >> 8) & 6) -
(((51 - input[i]) >> 8) & 75) -
(((61 - input[i]) >> 8) & 15) +
(((62 - input[i]) >> 8) & 3);
}
function keyToBase64(key) {
var i, base64 = new Uint8Array(44);
for (i = 0; i < 32 / 3; ++i)
encodeBase64(base64.subarray(i * 4), key.subarray(i * 3));
encodeBase64(base64.subarray(i * 4), Uint8Array.from([key[i * 3 + 0], key[i * 3 + 1], 0]));
base64[43] = 61;
return String.fromCharCode.apply(null, base64);
}
window.wireguard = {
generateKeypair: function() {
var privateKey = generatePrivateKey();
var publicKey = generatePublicKey(privateKey);
return {
publicKey: keyToBase64(publicKey),
privateKey: keyToBase64(privateKey)
};
}
};
})();

178
web/system.py Normal file
View file

@ -0,0 +1,178 @@
#!/usr/bin/python3
import collections
import multiprocessing
import os
import shutil
import sqlite3
import subprocess
import sys
import syslog
import time
import click
import flask
import flask.cli
import ldap3
from . import db
def init_app(app):
app.cli.add_command(generate)
app.cli.add_command(push)
def run(fun, args=()):
def task():
if os.fork() == 0:
os.setsid()
fun(*args)
multiprocessing.Process(target=task).start()
def save_config():
output = None
try:
# Just load the settings here but don’t lock the database while we load
# stuff from LDAP.
settings = db.load('settings')
groups = db.load('groups')
# Get users’ group membership from LDAP server. Only query the groups used
# by at least one network, and query each group just once.
user_groups = collections.defaultdict(set)
ldap = ldap3.Connection(ldap3.Server(settings.get('ldap_host'), use_ssl=True),
settings.get('ldap_user'), settings.get('ldap_pass'), auto_bind=True)
for group in groups:
ldap.search(settings.get('ldap_base_dn', ''),
f'(distinguishedName={group})', attributes='member')
if ldap.entries:
for user in ldap.entries[0]['member']:
user_groups[user].add(group)
# Now read the settings again and lock the database while generating
# config files, then increment version before unlocking.
with db.locked('settings'):
settings = db.read('settings')
version = settings['version'] = int(settings.get('version', 0)) + 1
# Populate IP sets.
wireguard = db.read('wireguard')
ipsets = collections.defaultdict(set)
for ip, key in wireguard.items():
for group in user_groups.get(key.get('user', ''), ()):
for network in groups[group]:
ipsets[network].add(f'{ip}/32')
# Create config files.
output = f'config/{version}'
shutil.rmtree(output, ignore_errors=True)
os.makedirs(f'{output}/etc/nftables.d', exist_ok=True)
os.makedirs(f'{output}/etc/wireguard', exist_ok=True)
# Add registered VPN addresses for each network based on
# LDAP group membership.
with open(f'{output}/etc/nftables.d/sets-vpn.nft', 'w', encoding='utf-8') as f:
def format_set(name, ips):
return f'''\
set {name} {{
typeof ip daddr; flags interval
elements = {{ {', '.join(ips)} }}
}}'''
for name, networks in ipsets.items():
print(format_set(name, networks), file=f)
# Print forwarding rules.
with open(f'{output}/etc/nftables.d/forward.nft', 'w', encoding='utf-8') as f:
def format_forward(src, dst):
rule = 'iifname @ifaces_inside oifname @ifaces_inside'
if src:
rule += f' ip saddr @{src}'
if dst:
rule += f' ip daddr @{dst}'
return rule + ' accept'
for src, dst in db.load('forwards'):
print(format_forward(src, dst), file=f)
# Print wireguard config.
with open(f'{output}/etc/wireguard/wg.conf', 'w', encoding='utf-8') as f:
def format_wg_peer(ip, data):
return f'''\
# {data.get('user')}
[Peer]
PublicKey = {data.get('key')}
AllowedIPs = {ip}
'''
print(f'''\
[Interface]
ListenPort = {settings.get('wg_port', 51820)}
PrivateKey = {settings.get('wg_key')}
''', file=f)
for ip, key in wireguard.items():
print(format_wg_peer(ip, key), file=f)
# Make a config archive in a temporary place, so we don’t send
# incomplete tars.
tarfile = shutil.make_archive(f'{output}-tmp', 'gztar', root_dir=output, owner='root', group='root')
# Move config archive to the final destination.
os.rename(tarfile, f'{output}.tar.gz')
# If we get here, write settings with the new version.
db.write('settings', settings)
return True
except Exception as e:
syslog.syslog(f'exception while generating config: {e}')
import traceback
with open('/tmp/wtflog', 'a+') as f:
traceback.print_exc(file=f)
return False
finally:
# Remove temporary directory.
if output:
shutil.rmtree(output, ignore_errors=True)
@click.command('generate')
@flask.cli.with_appcontext
def generate():
save_config()
@click.command('push')
@click.option('--version', '-v', type=click.INT, default=None, help="Config version to push")
@flask.cli.with_appcontext
def push(version=None):
if version is None:
version = db.load('settings').get('version', 0)
# Write wanted version to file for uploading to firewall nodes.
with open('config/version', 'w') as f:
print(version, file=f)
try:
with db.locked('nodes'):
nodes = db.read('nodes')
tarfile = f'config/{version}.tar.gz'
done = True
for node, node_version in nodes.items():
if node_version != version:
if not os.path.exists(tarfile):
syslog.syslog(f'wanted to push version {version} but {version}.tar.gz doesn’t exist')
return
# Push config tarfile.
syslog.syslog(f'updating {node} from {node_version} to {version}')
result = subprocess.run([f'sftp root@{node}'], shell=True, text=True,
input=f'put {tarfile}\nput config/version\n', capture_output=True)
if result.returncode == 0:
nodes[node] = version
db.write('nodes', nodes)
else:
syslog.syslog(f'error updating node {node}: {result.stderr}')
done = False
return done
except Exception as e:
import traceback
with open('/tmp/wtflog', 'a+') as f:
traceback.print_exc(file=f)
return False

View file

@ -0,0 +1,16 @@
{% extends 'base.html' %}
{% block content %}
<!-- {{ get_flashed_messages() }}-->
<form method="POST">
<p>
<label for="username">Uporabnik</label> {{ form.username() }}
<p>
<label for="password">Geslo</label> {{ form.password() }}
<p style="color: red;">{{ form.errors['username'] | join(' ') }}
<p>
{{ form.submit() }}
{{ form.hidden_tag() }}
</form>
{% endblock %}

56
web/templates/base.html Normal file
View file

@ -0,0 +1,56 @@
<!DOCTYPE html>
<html>
<meta charset="utf-8" />
<style>
body {
hyphens: auto;
max-width: 66em;
margin: 1em auto;
}
code {
background-color: #eeeeee;
}
details {
margin: 0.5em 1em;
}
details > summary {
cursor: pointer;
}
details > p {
margin: 0.5em;
}
h1 {
margin-bottom: 0.5em;
}
h1 > a {
color: unset;
text-decoration: none;
}
pre {
background-color: #eeeeee;
border: 1px solid black;
padding: 0.5em;
margin: 0;
}
ul.keys {
margin: 0 0.5em 0.5em;
padding-left: 1em;
}
ul.keys a {
text-decoration: none;
}
</style>
<title>FRIwall</title>
{% if current_user.is_authenticated %}
<div style="float:right;">
{{ current_user['username'] }} |
<a href="{{ url_for('auth.logout') }}">Odjava</a>
</div>
{% endif %}
<h1><a href="/">FRIwall</a></h1>
{% block content %}{% endblock %}

View file

@ -0,0 +1,12 @@
{% extends 'base.html' %}
{% block content %}
<p>
Urejate datoteko <code>{{ name }}.json</code>.
<form id="request" method="POST">
<p><textarea name="text" style="width: 100%; height: 20em;">{{ content }}</textarea>
<p><button id="submit" type="submit">Shrani</button>
</form>
{% endblock %}

View file

@ -0,0 +1,17 @@
{% extends 'base.html' %}
{% block content %}
<p>
Tu lahko urejate splošne nastavitve.
<form id="request" method="POST">
<h2>Nastavitve</h2>
{% for name, value in settings.items() %}
<p>
<input type="hidden" name="setting" value="{{ name }}" />
<label>{{ name }}<br><input name="value" value="{{ value }}" /></label>
{% endfor %}
<p><button id="submit" type="submit">Shrani</button>
</form>
{% endblock %}

View file

@ -0,0 +1,15 @@
{% extends 'base.html' %}
{% block content %}
<p>
Tu lahko urejate preusmeritve DNAT za IPv4.
<form id="request" method="POST">
{% for int_ip, ext_ip in dnat %}
<p>
<input name="ext_ip" value="{{ ext_ip }}"/>
<input name="int_ip" value="{{ int_ip }}"/>
{% endfor %}
<button id="submit" type="submit">Shrani</button>
</form>
{% endblock %}

13
web/templates/index.html Normal file
View file

@ -0,0 +1,13 @@
{% extends 'base.html' %}
{% block content %}
<ul>
{% if current_user.is_admin %}
<li><a href="{{ url_for('config.index') }}">Nastavitve
<li><a href="{{ url_for('config.edit', name='groups') }}">Skupine
<li><a href="{{ url_for('config.edit', name='forwards') }}">Luknje
{% endif %}
<li><a href="{{ url_for('dnat.index') }}">DNAT
<li><a href="{{ url_for('vpn.index') }}">VPN
</ul>
{% endblock %}

View file

@ -0,0 +1,186 @@
{% extends 'base.html' %}
{% block content %}
<p>
Za oddeljano povezavo v omrežje FRI namestite <a href="https://www.wireguard.com/install/">WireGuard</a>, ustvarite ključ in sledite napotkom za posamezni sistem.
<details>
<summary>Windows / Mac</summary>
<p>
Zaženite WireGuard, kliknite <em>Import tunnel(s) from file</em> in izberite preneseno datoteko z nastavitvami. VPN nato (de)aktivirate s klikom na gumb <em>(De)activate</em>.
</details>
<details>
<summary>Android / iOS</summary>
<p>
Zaženite WireGuard, izberite <em>Scan from QR code</em> in skenirajte kodo, prikazano ob izdelavi novega ključa.
</details>
<details>
<summary>Linux / BSD</summary>
<p>
Nastavitve shranite (kot skrbnik) v <code>/etc/wireguard/wg-fri.conf</code>. VPN nato (de)aktivirate s <code>sudo wg-quick up wg-fri</code> oz. <code>sudo wg-quick down wg-fri</code>. Povezavo lahko uvozite tudi v <a href="https://www.xmodulo.com/wireguard-vpn-network-manager-gui.html">NetworkManager</a> ali podobno.
</details>
<section id="new-key">
<h1>Nov ključ</h1>
<form id="request">
<p>
Vnesite poljubno oznako in kliknite <em>Ustvari ključ</em>. Če vklopite prvo opcijo, bo vaš računalnik čez VPN usmeril ves mrežni promet, ne le tistega, ki je namenjen strežnikom na FRI. Če izklopite drugo opcijo, bodo nekatere storitve dostopne le prek naslova IP. Če ste v dvomih, pustite privzete nastavitve.
<p>
<input id="comment" name="comment" pattern="[\w ]*" maxlength="16" placeholder="Oznaka ključa" />
<br>
<input type="checkbox" id="add_default" name="add_default" />
<label for="add_default">Uporabi VPN za ves promet</label>
<br>
<input type="checkbox" id="use_dns" name="use_dns" checked />
<label for="use_dns">Uporabi imenske strežnike FRI</label>
<br>
<button id="submit" type="submit">Ustvari ključ</button>
</form>
<section id="settings" style="display: none;">
<p>
Nastavitve za povezavo so izpisane spodaj. Zasebni ključ varujte enako skrbno kot geslo, s katerim ste se prijavili; priporočena je raba šifriranega diska. Za nov ključ osvežite to stran.
<section style="display: flex; align-items: center;">
<pre style="flex-grow: 3;"><a id="download" href="" style="float: right; padding: 0.5em;">Prenesi</a><code id="config"></code></pre>
<div id="qr" style="flex-grow: 1; text-align: center;"></div>
</section>
<p>
V nastavitvah lahko dodate ali odstranite vnose <code>AllowedIPs</code>. Ti določajo naslove, do katerih bo vaš računalnik dostopal skozi omrežje FRI. Da VPN uporabite za ves promet, dodajte vrstice <code>AllowedIPs = 0.0.0.0/0</code>. Če ne želite uporabljati imenskih strežnikov FRI, odstranite vnos <code>DNS</code>; to lahko vpliva na dostopnost nekaterih storitev.
</section>
</section>
<section class="keys" style="display: none;">
<h1>Obstoječi ključi</h1>
<p>
Za vsako napravo ustvarite nov ključ. Ključe, ki jih ne uporabljate, lahko tukaj odstranite. Trenutno so registrirani ključi:
<ul class="keys" style="list-style: none;"></ul>
<p class="keys" id="active-key-warning" style="margin-top: 0;">
</section>
<script type="text/javascript" src="{{ url_for('static', filename='qrcode.js') }}"></script>
<script type="text/javascript" src="{{ url_for('static', filename='wireguard.js') }}"></script>
<script type="text/javascript">
function del_key(key) {
fetch('del', {
credentials: 'include',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pubkey: key })
})
.then(response => {
if (!response.ok)
throw new Error('deleting key failed');
return response.text();
})
.then(data => {
// reload key list
window.dispatchEvent(new Event('load'));
})
.catch(error => console.error(error));
}
function fetch_keys() {
fetch('list', {
credentials: 'include'
})
.then(response => {
if (!response.ok)
throw new Error('fetching keys failed');
return response.json();
})
.then(data => {
const keys = document.querySelector('ul.keys');
keys.innerHTML = '';
const warning = document.getElementById('active-key-warning');
warning.innerHTML = '';
for (let key of Object.values(data)) {
var a = document.createElement('a');
a.innerText = '✖';
a.href = '';
a.addEventListener('click', event => {
del_key(key.key);
event.preventDefault();
});
var li = document.createElement('li');
li.innerHTML = ' ' + (new Date(key.time*1000).toISOString().split('T')[0]) +
' <code>' + key.key + '</code>' +
(key.active ? '<font color="red"><sup></sup></font> ' : ' ') +
key.comment;
li.prepend(a);
keys.appendChild(li);
if (key.active)
warning.innerHTML = '<font color="red"><sup></sup></font>Ta ključ uporablja aktivna povezava. Če ga odstranite, bo prekinjena.';
}
document.querySelector('section.keys').style.display = (Object.keys(data).length ? 'unset' : 'none');
})
.catch(error => console.error(error));
}
window.addEventListener('load', fetch_keys);
const request = document.getElementById('request');
request.addEventListener('submit', event => {
event.preventDefault();
const comment = document.getElementById('comment');
const key = wireguard.generateKeypair();
const settings = document.getElementById('settings');
const submit = document.getElementById('submit');
const use_dns = document.getElementById('use_dns');
const add_default = document.getElementById('add_default');
submit.innerHTML = 'Obdelovanje…';
submit.disabled = true;
fetch('new', {
credentials: 'include',
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pubkey: key.publicKey,
comment: comment.value,
use_dns: use_dns.checked,
add_default: add_default.checked,
})
})
.then(response => {
if (!response.ok) {
response.text().then(text => {
settings.innerHTML = response.status + ' ' + response.statusText + ': ' + text;
});
} else {
return response.text();
}
})
.then(text => {
var complete = text.replace(/PrivateKey = .*/, "PrivateKey = "+key.privateKey).trim();
document.getElementById("config").innerHTML = complete;
var blob = new Blob([complete], { type: 'text/plain;charset=utf-8' });
const link = document.getElementById('download');
link.download = 'wg-fri.conf';
link.href = window.URL.createObjectURL(blob);
var qr = qrcode(0, 'L');
qr.addData(complete.replace(/#.*\n/g, ''));
qr.make();
document.getElementById('qr').innerHTML = qr.createSvgTag(3);
// reload key list
fetch_keys();
})
.catch(error => {
settings.innerHTML = error;
})
.finally(() => {
request.style.display = 'none';
settings.style.display = 'unset';
});
});
</script>
{% endblock %}

View file

@ -0,0 +1,22 @@
[Interface]
# {{ timestamp }} {{ current_user['username'] }} {{ comment }}
# PublicKey = {{ pubkey }}
PrivateKey = # paste private key here
Address = {{ ip }}
{%- if use_dns -%}
DNS = 212.235.188.28,212.235.188.29,fri1.uni-lj.si
{%- endif %}
[Peer]
Endpoint = {{ server }}:{{ port }}
PublicKey = {{ server_key }}
PersistentKeepalive = 25
{% if add_default -%}
AllowedIPs = 0.0.0.0/0
{%- else -%}
AllowedIPs = 10.32.0.0/14
AllowedIPs = 212.235.188.16/28
AllowedIPs = 212.235.188.32/27
AllowedIPs = 212.235.188.64/26
{%- endif %}

102
web/vpn.py Normal file
View file

@ -0,0 +1,102 @@
import datetime
import ipaddress
import json
import re
import subprocess
import flask
import flask_login
from . import system
from . import db
blueprint = flask.Blueprint('vpn', __name__, url_prefix='/vpn')
wgkey_regex = re.compile(r'^[A-Za-z0-9/+=]{44}$')
@blueprint.route('/')
@flask_login.login_required
def index():
return flask.render_template('vpn/index.html')
@blueprint.route('/list')
@flask_login.login_required
def list():
try:
user = flask_login.current_user.get_id()
return flask.jsonify({k: v for k, v in db.load('wireguard').items() if v.get('user') == user})
except Exception as e:
return flask.Response(f'failed: {e}', status=500, mimetype='text/plain')
@blueprint.route('/new', methods=('POST',))
@flask_login.login_required
def new():
pubkey = flask.request.json.get('pubkey', '')
if not re.match(wgkey_regex, pubkey):
return flask.Response('invalid key', status=400, mimetype='text/plain')
try:
settings = db.load('settings')
server_pubkey = subprocess.run([f'wg pubkey'], input=settings.get('wg_key'),
text=True, capture_output=True, shell=True).stdout.strip()
with db.locked('wireguard'):
# Find a free address for the new key.
ips = db.read('wireguard')
network = ipaddress.ip_network(settings.get('wg_net', '10.0.0.1/24'), strict=False)
for ip in network.hosts():
if str(ip) not in ips:
break
else:
return flask.Response('no more available IP addresses', status=500, mimetype='text/plain')
now = datetime.datetime.utcnow()
comment = re.sub('[^\w ]', '', flask.request.json.get('comment', ''))
ips[str(ip)] = {
'key': pubkey,
'time': now.timestamp(),
'user': flask_login.current_user.get_id(),
'comment': comment,
}
db.write('wireguard', ips)
# Generate a new config archive for firewall nodes.
system.run(system.save_config)
# Template arguments.
args = {
'server': f'{settings.get("wg_endpoint")}',
'port': f'{settings.get("wg_port", 51820)}',
'server_key': server_pubkey,
'pubkey': pubkey,
'ip': str(ip),
'timestamp': now,
'comment': comment,
'add_default': flask.request.json.get('add_default', False),
'use_dns': flask.request.json.get('use_dns', True),
}
return flask.render_template('vpn/wg-fri.conf', **args)
except Exception as e:
return flask.Response(f'something went catastrophically wrong: {e}',
status=400, mimetype='text/plain')
@blueprint.route('/del', methods=('POST',))
@flask_login.login_required
def delete():
pubkey = flask.request.json.get('pubkey', '')
if not wgkey_regex.match(pubkey):
return flask.Response('invalid key', status=400, mimetype='text/plain')
try:
with db.locked('wireguard'):
user = flask_login.current_user.get_id()
ips = {k: v for k, v in db.read('wireguard').items() if v.get('user') != user or v.get('key') != pubkey}
db.write('wireguard', ips)
system.run(system.save_config)
return flask.Response(f'deleted key {pubkey}', status=200, mimetype='text/plain')
except Exception as e:
return flask.Response(f'something went catastrophically wrong: {e}',
status=400, mimetype='text/plain')

3
wsgi.py Normal file
View file

@ -0,0 +1,3 @@
from web import create_app
app = create_app()