From 8c9829b726fc7c626cb9fae09d505f88b0f0909b Mon Sep 17 00:00:00 2001 From: Timotej Lazar Date: Mon, 29 Jul 2024 11:16:08 +0200 Subject: [PATCH 1/4] Fix default wg_dns setting All settings are processed as strings so use empty string in place of False for default. --- web/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/__init__.py b/web/__init__.py index 92da140..966e54e 100644 --- a/web/__init__.py +++ b/web/__init__.py @@ -25,7 +25,7 @@ def create_app(test_config=None): 'wg_endpoint': '', 'wg_port': '51820', 'wg_allowed_nets': '', - 'wg_dns': False, + 'wg_dns': '', 'wg_key': '', 'wg_net': '', 'wg_net6': '', From 1b26f0738ac300ac839032971eb8bf2c08fa7951 Mon Sep 17 00:00:00 2001 From: Timotej Lazar Date: Sat, 27 Jul 2024 13:40:02 +0200 Subject: [PATCH 2/4] vpn: refactor key handling code Move JS code for listing, creating and deleting WG keys into a separate file and improve it somewhat. Also the related Python code. --- web/static/vpn.js | 114 +++++++++++++++++++++++++++++ web/templates/vpn/index.html | 134 +++-------------------------------- web/vpn.py | 55 ++++++++------ 3 files changed, 154 insertions(+), 149 deletions(-) create mode 100644 web/static/vpn.js diff --git a/web/static/vpn.js b/web/static/vpn.js new file mode 100644 index 0000000..c8e66af --- /dev/null +++ b/web/static/vpn.js @@ -0,0 +1,114 @@ +function delKey(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 => { + fetchKeys(); + }) + .catch(error => console.error(error)); +} + +function fetchKeys() { + 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.querySelector('p#active-key-warning'); + warning.hidden = true; + + for (let key of Object.values(data)) { + var a = document.createElement('a'); + a.innerText = '✖'; + a.href = ''; + a.addEventListener('click', event => { + delKey(key.key); + event.preventDefault(); + }); + var li = document.createElement('li'); + li.innerHTML = ' ' + (new Date(key.time*1000).toISOString().split('T')[0]) + + ' ' + key.key + ' ' + key.name + + (key.active ? ' ' : ''); + li.prepend(a); + keys.appendChild(li); + if (key.active) + warning.hidden = false; + } + document.querySelector('section.keys').style.display = (Object.keys(data).length ? 'unset' : 'none'); + }) + .catch(error => console.error(error)); +} + +window.addEventListener('load', fetchKeys); + +const form = document.querySelector('form#request'); +form.addEventListener('submit', event => { + event.preventDefault(); + + const inputs = Array.from(document.querySelectorAll('form input')); + const settings = document.querySelector('section#settings'); + const submit = document.querySelector('button#submit'); + + const key = wireguard.generateKeypair(); + const options = Object.fromEntries(inputs.map(e => [e.name, e.type === 'checkbox' ? e.checked : e.value])); + const networks = Array.from(document.querySelectorAll('select#networks option:checked')).map(e => e.value); + + submit.innerHTML = 'Obdelovanje…'; + submit.disabled = true; + fetch('new', { + credentials: 'include', + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + pubkey: key.publicKey, + options: options, + networks: networks, + }) + }) + .then(response => { + if (!response.ok) { + response.text().then(text => { + settings.innerHTML = response.status + ' ' + response.statusText + ': ' + text; + }); + } else { + return response.text(); + } + }) + .then(text => { + const config = text.replace(/PrivateKey = .*/, "PrivateKey = "+key.privateKey).trim(); + document.querySelector('code#config').innerHTML = config; + + const blob = new Blob([config], { type: 'text/plain;charset=utf-8' }); + const link = document.getElementById('download'); + link.download = 'wg-fri.conf'; + link.href = window.URL.createObjectURL(blob); + + const qr = qrcode(0, 'L'); + qr.addData(config.replace(/#.*\n/g, '')); + qr.make(); + document.getElementById('qr').innerHTML = qr.createSvgTag(3); + + fetchKeys(); + }) + .catch(error => { + settings.innerHTML = error; + }) + .finally(() => { + form.style.display = 'none'; + settings.style.display = 'unset'; + }); +}); diff --git a/web/templates/vpn/index.html b/web/templates/vpn/index.html index 473704d..c78a323 100644 --- a/web/templates/vpn/index.html +++ b/web/templates/vpn/index.html @@ -2,10 +2,7 @@ {% block content %}

-Za VPN oziroma oddeljano povezavo v omrežje FRI uporabljamo WireGuard. Več informacij o uporabi in nastavitvah VPN najdete v dokumentaciji. - -

-Za priklop v omrežje spodaj ustvarite nov ključ in prenesite izpisano datoteko. Nato sledite napotkom za posamezni sistem. +Za VPN oziroma oddeljano povezavo v omrežje FRI uporabljamo WireGuard. Za priklop v omrežje ustvarite nov ključ in prenesite izpisano datoteko. Nato sledite napotkom za posamezni sistem.

Windows / Mac @@ -43,14 +40,14 @@ Na vsaki napravi, ki jo želite povezati v omrežje FRI, ustvarite nov ključ. P

- - + +
- - + +

-Če vklopite prvo opcijo, bo vaš računalnik čez VPN usmerjal ves promet. Če izklopite drugo opcijo, bodo nekateri strežniki dostopni le prek naslova IP. Če ste v dvomih, pustite privzete nastavitve. +Če vklopite prvo opcijo, bo vaš računalnik čez VPN usmerjal ves promet. Če izklopite drugo opcijo, bodo nekateri strežniki dostopni le prek naslova IP. Če ste v dvomih, pustite privzete nastavitve. Več informacij o uporabi in nastavitvah VPN najdemo v dokumentaciji.

- + {% endblock %} diff --git a/web/vpn.py b/web/vpn.py index ce82fe4..46f0431 100644 --- a/web/vpn.py +++ b/web/vpn.py @@ -21,20 +21,22 @@ def index(): @blueprint.route('/list') @flask_login.login_required def list(): + # Return logged-in user’s keys, marking the key used for current connection (if any). user = flask_login.current_user.get_id() - return flask.jsonify( - {k: v | {'active': flask.request.remote_addr in (v.get('ip'), v.get('ip6'))} - for k, v in db.load('wireguard').items() if v.get('user') == user}) + return flask.jsonify({ + ip: data | {'active': flask.request.remote_addr in (ip, data.get('ip6'))} + for ip, data in db.load('wireguard').items() if data.get('user') == user + }) @blueprint.route('/new', methods=('POST',)) @flask_login.login_required def new(): - # Each key is associated with a new IPv4 address from the pool settings['wg_net']. - # Each key gets an IPv6 subnet depending on the amount of surplus addresses available. + # Each key is assigned a new IPv4 address from the pool settings['wg_net']. + # Each key is then assigned a corresponding IPv6 subnet, depending on the amount of surplus addresses available. # For wg_net 10.10.0.0/18 and wg_net6 1234:5678:90ab:cdef::/64, # the key for 10.10.0.10/32 would get 1234:5678:90ab:cdef:a::/80. def ipv4to6(net4, ip4, net6): - # Calculate the address and prefix length for the assigned IPv6 network. + # Calculate the address and prefix length for the IPv6 network that can be assigned to this key. len4 = (net4.max_prefixlen - net4.prefixlen) len6 = (net6.max_prefixlen - net6.prefixlen) # Make sure the network address ends at a colon. Wastes some addresses but IPv6. @@ -43,35 +45,39 @@ def new(): return ip6 + '/' + str(net6.max_prefixlen - assigned) pubkey = flask.request.json.get('pubkey', '') + options = flask.request.json.get('options', {}) if not re.match(wgkey_regex, pubkey): return flask.Response('invalid key', status=400, mimetype='text/plain') + # Read server’s private key and find the corresponding public key. 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() + now = datetime.datetime.utcnow() + key = { + 'key': pubkey, + 'time': now.timestamp(), + 'name': re.sub('[^-._A-Za-z0-9]', '', options.get('name', '')), + } + + # Determine IPv4 and IPv6 addresses for the new key. host = ipaddress.ip_interface(settings.get('wg_net') or '10.0.0.1/24') - ip6 = None + key['ip6'] = None with db.locked(): # Find a free address for the new key. keys = db.read('wireguard') for index, ip in enumerate(host.network.hosts(), start=1): if ip != host.ip and str(ip) not in keys: if wg_net6 := settings.get('wg_net6'): - ip6 = ipv4to6(host.network, ip, ipaddress.ip_interface(wg_net6).network) + key['ip6'] = ipv4to6(host.network, ip, ipaddress.ip_interface(wg_net6).network) break else: return flask.Response('no more available IP addresses', status=500, mimetype='text/plain') - now = datetime.datetime.utcnow() - name = re.sub('[^-._A-Za-z0-9]', '', flask.request.json.get('name', '')) - keys[str(ip)] = { - 'key': pubkey, - 'ip6': str(ip6) if ip6 else None, - 'time': now.timestamp(), - 'user': flask_login.current_user.get_id(), - 'name': name, - } + # Add remaining attributes to new key and update key database. + key['user'] = flask_login.current_user.get_id() + keys[str(ip)] = key db.write('wireguard', keys) # Generate a new config archive for firewall nodes. @@ -83,13 +89,13 @@ def new(): 'port': settings.get('wg_port') or '51820', 'server_key': server_pubkey, 'pubkey': pubkey, - 'ip': str(ip), - 'ip6': str(ip6) if ip6 else None, 'timestamp': now, - 'name': name, - 'dns': settings.get('wg_dns') if flask.request.json.get('use_dns', True) else False, + 'ip': str(ip), + 'ip6': key['ip6'], + 'name': key['name'], + 'dns': settings.get('wg_dns', '') if options.get('use-dns', True) else False, 'allowed_nets': settings.get('wg_allowed_nets', ''), - 'add_default': flask.request.json.get('add_default', False), + 'add_default': options.get('add-default', False), } return flask.render_template('vpn/wg-fri.conf', **args) @@ -102,7 +108,10 @@ def delete(): with db.locked(): user = flask_login.current_user.get_id() - keys = {k: v for k, v in db.read('wireguard').items() if v.get('user') != user or v.get('key') != pubkey} + keys = { + ip: data for ip, data in db.read('wireguard').items() + if not (data.get('key') == pubkey and (data.get('user') == user or flask_login.current_user.is_admin)) + } db.write('wireguard', keys) system.run(system.save_config) From 3c25cbe88adf8891b1e2032bd0607e6a9da10c8b Mon Sep 17 00:00:00 2001 From: Timotej Lazar Date: Tue, 30 Jul 2024 10:53:57 +0200 Subject: [PATCH 3/4] vpn: add support for custom keys Custom keys are created by admin and specify networks directly, bypassing AD permissions. They are intended to join managed devices into networks where users are not allowed to create keys themselves. Also comprehend a set directly. --- web/static/vpn.js | 26 ++------------ web/system.py | 19 ++++++---- web/templates/base.html | 13 ++++--- web/templates/index.html | 4 ++- web/templates/vpn/custom.html | 68 +++++++++++++++++++++++++++++++++++ web/templates/vpn/index.html | 29 +++++++++++++-- web/templates/vpn/wg-fri.conf | 2 +- web/vpn.py | 33 ++++++++++++++--- 8 files changed, 152 insertions(+), 42 deletions(-) create mode 100644 web/templates/vpn/custom.html diff --git a/web/static/vpn.js b/web/static/vpn.js index c8e66af..5a846bd 100644 --- a/web/static/vpn.js +++ b/web/static/vpn.js @@ -17,7 +17,7 @@ function delKey(key) { } function fetchKeys() { - fetch('list', { + fetch(endpoint, { credentials: 'include' }) .then(response => { @@ -26,29 +26,7 @@ function fetchKeys() { return response.json(); }) .then(data => { - const keys = document.querySelector('ul.keys'); - keys.innerHTML = ''; - const warning = document.querySelector('p#active-key-warning'); - warning.hidden = true; - - for (let key of Object.values(data)) { - var a = document.createElement('a'); - a.innerText = '✖'; - a.href = ''; - a.addEventListener('click', event => { - delKey(key.key); - event.preventDefault(); - }); - var li = document.createElement('li'); - li.innerHTML = ' ' + (new Date(key.time*1000).toISOString().split('T')[0]) + - ' ' + key.key + ' ' + key.name + - (key.active ? ' ' : ''); - li.prepend(a); - keys.appendChild(li); - if (key.active) - warning.hidden = false; - } - document.querySelector('section.keys').style.display = (Object.keys(data).length ? 'unset' : 'none'); + update(Object.values(data)); }) .catch(error => console.error(error)); } diff --git a/web/system.py b/web/system.py index 3bb66b9..1bde640 100644 --- a/web/system.py +++ b/web/system.py @@ -77,17 +77,24 @@ def save_config(): settings = db.read('settings') version = settings['version'] = int(settings.get('version') or '0') + 1 - # Update IP sets with VPN addresses based on AD group membership. - vpn_groups = set([e['vpn'] for e in ipsets.values() if e.get('vpn')]) + # Find networks accessible to VPN users for each AD group. + vpn_groups = {e['vpn'] for e in ipsets.values() if e.get('vpn')} group_networks = { group: [name for name, data in ipsets.items() if data['vpn'] == group] for group in vpn_groups } + + # Add VPN addresses to IP sets. for ip, key in wireguard.items(): + # Find all networks this IP should belong to: + # - manually specified networks for custom keys, + # - networks accessible to any of the user’s groups. + key_networks = set(key.get('networks', ())) for group in user_groups.get(key.get('user', ''), ()): - for network in group_networks.get(group, ()): - ipsets[network]['ip'].append(f'{ip}/32') - if ip6 := key.get('ip6'): - ipsets[network]['ip6'].append(ip6) + key_networks |= set(group_networks.get(group, ())) + for network in key_networks: + ipsets[network]['ip'].append(f'{ip}/32') + if ip6 := key.get('ip6'): + ipsets[network]['ip6'].append(ip6) # Create config files. output = pathlib.Path.home() / 'config' / f'{version}' diff --git a/web/templates/base.html b/web/templates/base.html index add9ed1..7b0832c 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -9,7 +9,8 @@ body { margin: 1em auto; } code { - background-color: #eeeeee; + background-color: #f8f8f8; + padding: 0.1em 0.25em; } details { margin: 0.5em 1em; @@ -31,18 +32,22 @@ input:read-only { border-style: dotted; } pre { - background-color: #eeeeee; + background-color: #f8f8f8; border: 1px solid #cccccc; padding: 0.5em; } +table { + border-spacing: 0 0.1em; +} th { text-align: left; } th, td { padding-right: 1em; + vertical-align: middle; } -th { - border-bottom: 1px solid black; +tbody > tr:hover { + background-color: #f8f8f8; } ul.keys { margin: 0 0.5em 0.5em; diff --git a/web/templates/index.html b/web/templates/index.html index 01320ba..80ea4a6 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -3,7 +3,7 @@
VPN -
urejanje ključev za WireGuard VPN +
urejanje ključev za oddaljeni dostop
Pravila
vklop / izklop pravil za požarni zid
@@ -20,6 +20,8 @@
pravila za posredovanje prometa
Netmap
statične 1:1 preslikave naslovov za strežniška omrežja +
VPN po meri +
urejanje ključev za oddaljeni dostop do posebnih omrežij
Nastavitve
nastavitve aplikacije FRIwall diff --git a/web/templates/vpn/custom.html b/web/templates/vpn/custom.html new file mode 100644 index 0000000..d03398a --- /dev/null +++ b/web/templates/vpn/custom.html @@ -0,0 +1,68 @@ +{% extends 'base.html' %} +{% block header %} + +{% endblock %} + +{% block content %} +

+Urejate ključe WireGuard s posebnimi dostopi. + + + + +
KljučIPIPv6NapravaOmrežja +
+ +

+

Nov ključ

+
+

+
+ +

+
+ +

+ +

+ + +
+ + + + + +{% endblock %} diff --git a/web/templates/vpn/index.html b/web/templates/vpn/index.html index c78a323..c47b61c 100644 --- a/web/templates/vpn/index.html +++ b/web/templates/vpn/index.html @@ -68,12 +68,37 @@ V nastavitvah lahko dodate ali odstranite vnose AllowedIPs. Ti dolo

Ključi

Če ključa ne uporabljamo, smo ga izgubili ali so nam ga ukradli, ga tukaj odstranimo. Trenutno so registrirani ključi: -

    -

    + + + +
    KljučIPIPv6Naprava +
    + +

    Ta ključ uporablja trenutna povezava. Če ga odstranite, bo prekinjena.

    + {% endblock %} diff --git a/web/templates/vpn/wg-fri.conf b/web/templates/vpn/wg-fri.conf index 09bbf9c..136490b 100644 --- a/web/templates/vpn/wg-fri.conf +++ b/web/templates/vpn/wg-fri.conf @@ -1,5 +1,5 @@ [Interface] -# {{ timestamp }} {{ current_user['username'] }} {{ name }} +# {{ timestamp }} {{ user }} {{ name }} # PublicKey = {{ pubkey }} PrivateKey = # paste private key here Address = {{ ip }}{% if ip6 %}, {{ ip6 }}{% endif %} diff --git a/web/vpn.py b/web/vpn.py index 46f0431..876fc50 100644 --- a/web/vpn.py +++ b/web/vpn.py @@ -18,15 +18,36 @@ wgkey_regex = re.compile(r'^[A-Za-z0-9/+=]{44}$') def index(): return flask.render_template('vpn/index.html') +@blueprint.route('/custom') +@flask_login.login_required +def custom(): + if not flask_login.current_user.is_admin: + return flask.Response('forbidden', status=403, mimetype='text/plain') + with db.locked(): + keys = {ip: data for ip, data in db.read('wireguard').items() if data.get('networks') and not data.get('user')} + ipsets = db.read('networks') | db.read('ipsets') + return flask.render_template('vpn/custom.html', keys=keys, ipsets=ipsets.keys()) + @blueprint.route('/list') @flask_login.login_required def list(): # Return logged-in user’s keys, marking the key used for current connection (if any). user = flask_login.current_user.get_id() - return flask.jsonify({ - ip: data | {'active': flask.request.remote_addr in (ip, data.get('ip6'))} + return flask.jsonify([ + data | {'ip': ip, 'active': flask.request.remote_addr in (ip, data.get('ip6'))} for ip, data in db.load('wireguard').items() if data.get('user') == user - }) + ]) + +@blueprint.route('/list-custom') +@flask_login.login_required +def list_custom(): + # Return all custom keys. + if not flask_login.current_user.is_admin: + return flask.Response('forbidden', status=403, mimetype='text/plain') + return flask.jsonify([ + data | {'ip': ip, 'active': flask.request.remote_addr in (ip, data.get('ip6'))} + for ip, data in db.load('wireguard').items() if data.get('networks') and not data.get('user') + ]) @blueprint.route('/new', methods=('POST',)) @flask_login.login_required @@ -76,7 +97,10 @@ def new(): return flask.Response('no more available IP addresses', status=500, mimetype='text/plain') # Add remaining attributes to new key and update key database. - key['user'] = flask_login.current_user.get_id() + if flask_login.current_user.is_admin and flask.request.json.get('networks'): + key['networks'] = flask.request.json.get('networks') + else: + key['user'] = flask_login.current_user.get_id() keys[str(ip)] = key db.write('wireguard', keys) @@ -93,6 +117,7 @@ def new(): 'ip': str(ip), 'ip6': key['ip6'], 'name': key['name'], + 'user': key.get('user', 'custom'), 'dns': settings.get('wg_dns', '') if options.get('use-dns', True) else False, 'allowed_nets': settings.get('wg_allowed_nets', ''), 'add_default': options.get('add-default', False), From b6c191e2ce75ce82e07feda97e17a217c78c5d95 Mon Sep 17 00:00:00 2001 From: Timotej Lazar Date: Sat, 3 Aug 2024 12:13:13 +0200 Subject: [PATCH 4/4] Add a rather rudimentary README --- README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..41dad29 --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# FRIwall + +Web application for managing the main firewall at FRI. + +## Operation + +The firewall consists of two servers (or “bricks”) in active–backup configuration, using weighted BGP routes for failover. Traffic filtering and VPN are done with nftables and WireGuard. Both are configured exactly the same on both servers. The settings are managed with this application. + +On each configuration change, a tarball of relevant files in `/etc` is generated and pushed via SSH to both nodes. This happens for instance each time an IP set or a forwarding rule is modified, or a VPN key is added or removed. Firewall nodes and the current configuration version for each are stored in `nodes.json`. + +## IP sets + +Names and IP prefixes for physical networks are configured in NetBox and stored in `networks.json`, which is never modified by the application. Custom IP sets used for forwarding rules may be defined at `/ipsets`, as well as NAT addresse and VPN access for all networks. These settings are stored in `ipsets.json` and added to static definitions in `networks.json` when generating firewall configuration. + +## Rules + +Forwarding rules are configured at `/rules` and toggled at `/rules/manage`. Enabled rules are included directly in the nftables configuration. For each rule, one or more manager groups can be set; users in these AD groups may toggle the rule without admin access. This is used for instance to allow teachers to disable Internet access in classrooms. + +## VPN + +WireGuard is used for remote access. Domain users can self‐register new keys at `/vpn`. The key database `wireguard.json` is a dictionary of entries like + + "10.0.0.26": { + "key": "ABC…XYZ=", + "ip6": "aaaa:bbbb:cccc:dddd:1a::/80", + "time": 1682166836.88937, + "name": "machine1", + "user": "user@domain" + } + +IP addresses for new keys are assigned automatically from the subnets defined in `wg_net` and `wg_net6` settings. When generating firewall configuration, each IP is placed in the nftables sets based on its user’s group and settings in `ipsets.json`. + +### Custom keys + +Administrators can define custom keys with access to specified networks at `/vpn/custom`. These keys are used to connect machines into secure networks where users are not allowed to attach arbitrary devices themselves. + +Custom keys are stored in the same database, but with a `networks` entry in place of `user`, for instance + + "10.0.0.27": { + "key": "FOO…BAR=", + "ip6": "aaaa:bbbb:cccc:dddd:1b::/80", + "time": 1682166836.88937, + "name": "machine2", + "networks": ["net1", "net2"] + }