Make a squash
This commit is contained in:
commit
113992f95b
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
__pycache__/
|
||||
/config/
|
||||
*.db
|
6
pusher
Executable file
6
pusher
Executable 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
4
requirements.txt
Normal file
|
@ -0,0 +1,4 @@
|
|||
click
|
||||
flask
|
||||
flask-login
|
||||
flask-ldap3-login
|
69
web/__init__.py
Normal file
69
web/__init__.py
Normal 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
39
web/auth.py
Normal 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
41
web/config.py
Normal 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
58
web/db.py
Normal 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
16
web/dnat.py
Normal 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
2298
web/static/qrcode.js
Normal file
File diff suppressed because it is too large
Load diff
185
web/static/wireguard.js
Normal file
185
web/static/wireguard.js
Normal 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
178
web/system.py
Normal 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
|
16
web/templates/auth/login.html
Normal file
16
web/templates/auth/login.html
Normal 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
56
web/templates/base.html
Normal 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 %}
|
12
web/templates/config/edit.html
Normal file
12
web/templates/config/edit.html
Normal 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 %}
|
17
web/templates/config/index.html
Normal file
17
web/templates/config/index.html
Normal 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 %}
|
15
web/templates/dnat/index.html
Normal file
15
web/templates/dnat/index.html
Normal 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
13
web/templates/index.html
Normal 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 %}
|
186
web/templates/vpn/index.html
Normal file
186
web/templates/vpn/index.html
Normal 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 %}
|
22
web/templates/vpn/wg-fri.conf
Normal file
22
web/templates/vpn/wg-fri.conf
Normal 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
102
web/vpn.py
Normal 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')
|
Loading…
Reference in a new issue