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)