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.
This commit is contained in:
parent
8c9829b726
commit
1b26f0738a
114
web/static/vpn.js
Normal file
114
web/static/vpn.js
Normal file
|
@ -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]) +
|
||||||
|
' <code>' + key.key + '</code> ' + key.name +
|
||||||
|
(key.active ? '<font color="red"><sup>★</sup></font> ' : '');
|
||||||
|
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';
|
||||||
|
});
|
||||||
|
});
|
|
@ -2,10 +2,7 @@
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<p>
|
<p>
|
||||||
Za VPN oziroma oddeljano povezavo v omrežje FRI uporabljamo <a href="https://wireguard.com">WireGuard</a>. Več informacij o uporabi in nastavitvah VPN najdete v <a href="https://doku.fri.uni-lj.si/vpn">dokumentaciji</a>.
|
Za VPN oziroma oddeljano povezavo v omrežje FRI uporabljamo <a href="https://wireguard.com">WireGuard</a>. Za priklop v omrežje ustvarite nov ključ in prenesite izpisano datoteko. Nato sledite napotkom za posamezni sistem.
|
||||||
|
|
||||||
<p>
|
|
||||||
Za priklop v omrežje spodaj ustvarite nov ključ in prenesite izpisano datoteko. Nato sledite napotkom za posamezni sistem.
|
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Windows / Mac</summary>
|
<summary>Windows / Mac</summary>
|
||||||
|
@ -43,14 +40,14 @@ Na vsaki napravi, ki jo želite povezati v omrežje FRI, ustvarite nov ključ. P
|
||||||
<input id="name" name="name" pattern="[-._A-Za-z0-9 ]*" maxlength="16" placeholder="A-Z a-z 0-9 . _ - " />
|
<input id="name" name="name" pattern="[-._A-Za-z0-9 ]*" maxlength="16" placeholder="A-Z a-z 0-9 . _ - " />
|
||||||
<button id="submit" type="submit">Ustvari ključ</button>
|
<button id="submit" type="submit">Ustvari ključ</button>
|
||||||
<p>
|
<p>
|
||||||
<input type="checkbox" id="add_default" name="add_default" />
|
<input type="checkbox" id="add-default" name="add-default" />
|
||||||
<label for="add_default">Uporabi VPN za ves promet</label>
|
<label for="add-default">Uporabi VPN za ves promet</label>
|
||||||
<br>
|
<br>
|
||||||
<input type="checkbox" id="use_dns" name="use_dns" checked />
|
<input type="checkbox" id="use-dns" name="use-dns" checked />
|
||||||
<label for="use_dns">Uporabi imenske strežnike FRI</label>
|
<label for="use-dns">Uporabi imenske strežnike FRI</label>
|
||||||
|
|
||||||
<p>
|
<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 <a href="https://doku.fri.uni-lj.si/vpn">najdemo v dokumentaciji</a>.
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<section id="settings" style="display: none;">
|
<section id="settings" style="display: none;">
|
||||||
|
@ -73,125 +70,10 @@ V nastavitvah lahko dodate ali odstranite vnose <code>AllowedIPs</code>. Ti dolo
|
||||||
Če ključa ne uporabljamo, smo ga izgubili ali so nam ga ukradli, ga tukaj odstranimo. Trenutno so registrirani ključi:
|
Če ključa ne uporabljamo, smo ga izgubili ali so nam ga ukradli, ga tukaj odstranimo. Trenutno so registrirani ključi:
|
||||||
<ul class="keys" style="list-style: none;"></ul>
|
<ul class="keys" style="list-style: none;"></ul>
|
||||||
<p class="keys" id="active-key-warning" style="margin-top: 0;">
|
<p class="keys" id="active-key-warning" style="margin-top: 0;">
|
||||||
|
<font color="red"><sup>★</sup></font> Ta ključ uporablja trenutna povezava. Če ga odstranite, bo prekinjena.
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<script type="text/javascript" src="{{ url_for('static', filename='qrcode.js') }}"></script>
|
<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" src="{{ url_for('static', filename='wireguard.js') }}"></script>
|
||||||
<script type="text/javascript">
|
<script type="text/javascript" src="{{ url_for('static', filename='vpn.js') }}"></script>
|
||||||
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.name +
|
|
||||||
(key.active ? '<font color="red"><sup>★</sup></font> ' : '');
|
|
||||||
li.prepend(a);
|
|
||||||
keys.appendChild(li);
|
|
||||||
if (key.active)
|
|
||||||
warning.innerHTML = '<font color="red"><sup>★</sup></font> Ta ključ uporablja trenutna 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 name = document.getElementById('name');
|
|
||||||
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,
|
|
||||||
name: name.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 %}
|
{% endblock %}
|
||||||
|
|
55
web/vpn.py
55
web/vpn.py
|
@ -21,20 +21,22 @@ def index():
|
||||||
@blueprint.route('/list')
|
@blueprint.route('/list')
|
||||||
@flask_login.login_required
|
@flask_login.login_required
|
||||||
def list():
|
def list():
|
||||||
|
# Return logged-in user’s keys, marking the key used for current connection (if any).
|
||||||
user = flask_login.current_user.get_id()
|
user = flask_login.current_user.get_id()
|
||||||
return flask.jsonify(
|
return flask.jsonify({
|
||||||
{k: v | {'active': flask.request.remote_addr in (v.get('ip'), v.get('ip6'))}
|
ip: data | {'active': flask.request.remote_addr in (ip, data.get('ip6'))}
|
||||||
for k, v in db.load('wireguard').items() if v.get('user') == user})
|
for ip, data in db.load('wireguard').items() if data.get('user') == user
|
||||||
|
})
|
||||||
|
|
||||||
@blueprint.route('/new', methods=('POST',))
|
@blueprint.route('/new', methods=('POST',))
|
||||||
@flask_login.login_required
|
@flask_login.login_required
|
||||||
def new():
|
def new():
|
||||||
# Each key is associated with a new IPv4 address from the pool settings['wg_net'].
|
# Each key is assigned 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 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,
|
# 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.
|
# the key for 10.10.0.10/32 would get 1234:5678:90ab:cdef:a::/80.
|
||||||
def ipv4to6(net4, ip4, net6):
|
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)
|
len4 = (net4.max_prefixlen - net4.prefixlen)
|
||||||
len6 = (net6.max_prefixlen - net6.prefixlen)
|
len6 = (net6.max_prefixlen - net6.prefixlen)
|
||||||
# Make sure the network address ends at a colon. Wastes some addresses but IPv6.
|
# 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)
|
return ip6 + '/' + str(net6.max_prefixlen - assigned)
|
||||||
|
|
||||||
pubkey = flask.request.json.get('pubkey', '')
|
pubkey = flask.request.json.get('pubkey', '')
|
||||||
|
options = flask.request.json.get('options', {})
|
||||||
if not re.match(wgkey_regex, pubkey):
|
if not re.match(wgkey_regex, pubkey):
|
||||||
return flask.Response('invalid key', status=400, mimetype='text/plain')
|
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')
|
settings = db.load('settings')
|
||||||
server_pubkey = subprocess.run([f'wg pubkey'], input=settings.get('wg_key'),
|
server_pubkey = subprocess.run([f'wg pubkey'], input=settings.get('wg_key'),
|
||||||
text=True, capture_output=True, shell=True).stdout.strip()
|
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')
|
host = ipaddress.ip_interface(settings.get('wg_net') or '10.0.0.1/24')
|
||||||
ip6 = None
|
key['ip6'] = None
|
||||||
with db.locked():
|
with db.locked():
|
||||||
# Find a free address for the new key.
|
# Find a free address for the new key.
|
||||||
keys = db.read('wireguard')
|
keys = db.read('wireguard')
|
||||||
for index, ip in enumerate(host.network.hosts(), start=1):
|
for index, ip in enumerate(host.network.hosts(), start=1):
|
||||||
if ip != host.ip and str(ip) not in keys:
|
if ip != host.ip and str(ip) not in keys:
|
||||||
if wg_net6 := settings.get('wg_net6'):
|
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
|
break
|
||||||
else:
|
else:
|
||||||
return flask.Response('no more available IP addresses', status=500, mimetype='text/plain')
|
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)] = {
|
# Add remaining attributes to new key and update key database.
|
||||||
'key': pubkey,
|
key['user'] = flask_login.current_user.get_id()
|
||||||
'ip6': str(ip6) if ip6 else None,
|
keys[str(ip)] = key
|
||||||
'time': now.timestamp(),
|
|
||||||
'user': flask_login.current_user.get_id(),
|
|
||||||
'name': name,
|
|
||||||
}
|
|
||||||
db.write('wireguard', keys)
|
db.write('wireguard', keys)
|
||||||
|
|
||||||
# Generate a new config archive for firewall nodes.
|
# Generate a new config archive for firewall nodes.
|
||||||
|
@ -83,13 +89,13 @@ def new():
|
||||||
'port': settings.get('wg_port') or '51820',
|
'port': settings.get('wg_port') or '51820',
|
||||||
'server_key': server_pubkey,
|
'server_key': server_pubkey,
|
||||||
'pubkey': pubkey,
|
'pubkey': pubkey,
|
||||||
'ip': str(ip),
|
|
||||||
'ip6': str(ip6) if ip6 else None,
|
|
||||||
'timestamp': now,
|
'timestamp': now,
|
||||||
'name': name,
|
'ip': str(ip),
|
||||||
'dns': settings.get('wg_dns') if flask.request.json.get('use_dns', True) else False,
|
'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', ''),
|
'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)
|
return flask.render_template('vpn/wg-fri.conf', **args)
|
||||||
|
|
||||||
|
@ -102,7 +108,10 @@ def delete():
|
||||||
|
|
||||||
with db.locked():
|
with db.locked():
|
||||||
user = flask_login.current_user.get_id()
|
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)
|
db.write('wireguard', keys)
|
||||||
|
|
||||||
system.run(system.save_config)
|
system.run(system.save_config)
|
||||||
|
|
Loading…
Reference in a new issue