Implement VPN + SSH configuration

This commit is contained in:
Disassembler 2019-03-22 08:47:59 +01:00
parent d863fe6675
commit bba7e0383c
No known key found for this signature in database
GPG Key ID: 524BD33A0EE29499
11 changed files with 262 additions and 36 deletions

View File

@ -56,7 +56,7 @@ def create_selfsigned_cert(domain):
f.write(cert.public_bytes(serialization.Encoding.PEM)) f.write(cert.public_bytes(serialization.Encoding.PEM))
with open(CERT_KEY_FILE, 'wb') as f: with open(CERT_KEY_FILE, 'wb') as f:
f.write(private_key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption())) f.write(private_key.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()))
os.chmod(CERT_KEY_FILE, 0o640) os.chmod(CERT_KEY_FILE, 0o600)
def get_cert_info(): def get_cert_info():
# Gather certificate data important for setup-host # Gather certificate data important for setup-host

View File

@ -56,7 +56,7 @@ def update_hosts_lease(app, is_request):
# If this call is a request for lease, find the first unassigned IP # If this call is a request for lease, find the first unassigned IP
if is_request: if is_request:
used_ips = [l[0] for l in leases] used_ips = [l[0] for l in leases]
for i in range(2, 65534): for i in range(2, 65278): # Reserve last /24 subnet for VPN
ip = '172.17.{}.{}'. format(i // 256, i % 256) ip = '172.17.{}.{}'. format(i // 256, i % 256)
if ip not in used_ips: if ip not in used_ips:
leases.append([ip, app]) leases.append([ip, app])

View File

@ -23,8 +23,11 @@ LXC_ROOT = '/var/lib/lxc'
ISSUE_FILE = '/etc/issue' ISSUE_FILE = '/etc/issue'
NGINX_DIR = '/etc/nginx/conf.d' NGINX_DIR = '/etc/nginx/conf.d'
# Remote support # Remote access
WIREGUARD_FILE = '/etc/wireguard/wg0.conf' AUTHORIZED_KEYS = '/root/.ssh/authorized_keys'
INTERFACES_FILE = '/etc/network/interfaces'
WG_CONF_FILE = '/etc/wireguard/wg0.conf'
WG_CONF_FILE_DISABLED = '/etc/wireguard/wg0.conf.disabled'
# URLs # URLs
MYIP_URL = 'https://tools.dasm.cz/myip.php' MYIP_URL = 'https://tools.dasm.cz/myip.php'

View File

@ -0,0 +1,99 @@
# -*- coding: utf-8 -*-
import configparser
import os
import subprocess
from . import templates
from .paths import AUTHORIZED_KEYS, INTERFACES_FILE, WG_CONF_FILE, WG_CONF_FILE_DISABLED
def get_authorized_keys():
# Fetches content of root's authorized_files
if not os.path.exists(AUTHORIZED_KEYS):
return ''
with open(AUTHORIZED_KEYS) as f:
return f.read()
def set_authorized_keys(keys):
# Saves content of root's authorized_files
with open(AUTHORIZED_KEYS, 'w') as f:
f.write(keys)
def is_wireguard_running():
# Returns status of wg0 interface (inferred from existence of its config file)
return os.path.exists(WG_CONF_FILE)
def regenerate_wireguard_key():
# Regenerates WireGuard key pair for wg0 interface
was_running = is_wireguard_running()
if was_running:
stop_wireguard()
privkey = subprocess.run(['wg', 'genkey'], stdout=subprocess.PIPE).stdout.strip().decode()
with open(WG_CONF_FILE_DISABLED) as f:
conf_lines = f.readlines()
conf_lines[2] = 'PrivateKey = {}\n'.format(privkey)
with open(WG_CONF_FILE_DISABLED, 'w') as f:
f.writelines(conf_lines)
if was_running:
start_wireguard()
return privkey
def get_wireguard_conf():
# Returns dictionary with WireGuard wg0 interface VPN IP, public key, listen port and peer info
result = {'running': is_wireguard_running()}
# IP
with open(INTERFACES_FILE) as f:
for line in f.read().splitlines():
if '172.17.255' in line:
result['ip'] = line.split('.')[-1]
break
# Listen port and peers
with open(WG_CONF_FILE if result['running'] else WG_CONF_FILE_DISABLED) as f:
conf_lines = f.read().splitlines()
result['port'] = conf_lines[1].split()[2]
result['peers'] = '\n'.join(conf_lines[4:])
# Public key
privkey = conf_lines[2].split()[2]
if privkey == 'None':
privkey = regenerate_wireguard_key()
p = subprocess.Popen(['wg', 'pubkey'], stdin=subprocess.PIPE, stdout=subprocess.PIPE)
result['pubkey'] = p.communicate(privkey.encode())[0].strip().decode()
return result
def set_wireguard_conf(ip, port, peers):
# Sets WireGuard wg0 interface VPN IP, listen port and peer info
was_running = is_wireguard_running()
if was_running:
stop_wireguard()
# IP
interface_lines = []
with open(INTERFACES_FILE) as f:
for line in f.readlines():
if '172.17.255' in line:
line = ' address 172.17.255.{}\n'.format(ip)
interface_lines.append(line)
with open(INTERFACES_FILE, 'w') as f:
f.writelines(interface_lines)
# Recreate config (listen port and peers)
with open(WG_CONF_FILE_DISABLED) as f:
conf_lines = f.readlines()[:4]
conf_lines[1] = 'ListenPort = {}\n'.format(port)
with open(WG_CONF_FILE_DISABLED, 'w') as f:
f.writelines(conf_lines)
f.write(peers)
if was_running:
start_wireguard()
def start_wireguard():
# Sets up WireGuard interface
if not os.path.exists(WG_CONF_FILE):
os.rename(WG_CONF_FILE_DISABLED, WG_CONF_FILE)
else:
subprocess.run(['ifdown', 'wg0'])
subprocess.run(['ifup', 'wg0'])
def stop_wireguard():
# Tears down WireGuard interface
subprocess.run(['ifdown', 'wg0'])
if os.path.exists(WG_CONF_FILE):
os.rename(WG_CONF_FILE, WG_CONF_FILE_DISABLED)

View File

@ -112,15 +112,3 @@ ISSUE = '''
- \x1b[1m{url}\x1b[0m - \x1b[1m{url}\x1b[0m
- \x1b[1m{ip}\x1b[0m\x1b[?1c - \x1b[1m{ip}\x1b[0m\x1b[?1c
''' '''
WIREGUARD = '''
[Interface]
ListenPort = 51820
PrivateKey = {privkey}
[Peer]
PublicKey = {pubkey}
AllowedIPs = 172.18.0.1/32
Endpoint = {endpoint}
PersistentKeepalive = 15
'''

View File

@ -10,7 +10,7 @@ import urllib
from . import crypto from . import crypto
from . import templates from . import templates
from . import net from . import net
from .paths import ACME_CRON, ACME_DIR, ISSUE_FILE, NGINX_DIR, RELOAD_URL, WIREGUARD_FILE from .paths import ACME_CRON, ACME_DIR, ISSUE_FILE, NGINX_DIR, RELOAD_URL
class VMMgr: class VMMgr:
def __init__(self, conf): def __init__(self, conf):
@ -134,7 +134,7 @@ class VMMgr:
# Copy certificate files # Copy certificate files
shutil.copyfile(public_file, crypto.CERT_PUB_FILE) shutil.copyfile(public_file, crypto.CERT_PUB_FILE)
shutil.copyfile(private_file, crypto.CERT_KEY_FILE) shutil.copyfile(private_file, crypto.CERT_KEY_FILE)
os.chmod(crypto.CERT_KEY_FILE, 0o640) os.chmod(crypto.CERT_KEY_FILE, 0o600)
# Reload nginx # Reload nginx
self.reload_nginx() self.reload_nginx()
@ -143,15 +143,3 @@ class VMMgr:
def reboot_vm(self): def reboot_vm(self):
subprocess.run(['/sbin/reboot']) subprocess.run(['/sbin/reboot'])
def enable_remote_support(self, pubkey, endpoint):
# Sets up wireguard interface
privkey = subprocess.run(['wg', 'genkey'])
with open(WIREGUARD_FILE, 'w') as f:
f.write(templates.WIREGUARD.format(privkey=privkey, pubkey=pubkey, endpoint=endpoint))
subprocess.check_output(['ip', 'link', 'set', 'wg0', 'up'])
def disable_remote_support(self):
# Tears down wireguard settings
os.unlink(WIREGUARD_FILE)
subprocess.check_output(['ip', 'link', 'set', 'wg0', 'down'])

View File

@ -13,6 +13,7 @@ from cryptography.exceptions import InvalidSignature
from . import crypto from . import crypto
from . import net from . import net
from . import remote
from . import validator from . import validator
from .actionqueue import ActionQueue from .actionqueue import ActionQueue
from .appmgr import AppMgr from .appmgr import AppMgr
@ -38,12 +39,14 @@ class WSGIApp:
Rule('/login', methods=['POST'], endpoint='login_action'), Rule('/login', methods=['POST'], endpoint='login_action'),
Rule('/setup-host', redirect_to='/login?redir=setup-host'), Rule('/setup-host', redirect_to='/login?redir=setup-host'),
Rule('/setup-apps', redirect_to='/login?redir=setup-apps'), Rule('/setup-apps', redirect_to='/login?redir=setup-apps'),
Rule('/setup-remote', redirect_to='/login?redir=setup-remote'),
)) ))
self.admin_url_map = Map(( self.admin_url_map = Map((
Rule('/', endpoint='portal_view'), Rule('/', endpoint='portal_view'),
Rule('/logout', endpoint='logout_action'), Rule('/logout', endpoint='logout_action'),
Rule('/setup-host', endpoint='setup_host_view'), Rule('/setup-host', endpoint='setup_host_view'),
Rule('/setup-apps', endpoint='setup_apps_view'), Rule('/setup-apps', endpoint='setup_apps_view'),
Rule('/setup-remote', endpoint='setup_remote_view'),
Rule('/update-host', endpoint='update_host_action'), Rule('/update-host', endpoint='update_host_action'),
Rule('/verify-dns', endpoint='verify_dns_action'), Rule('/verify-dns', endpoint='verify_dns_action'),
Rule('/verify-https', endpoint='verify_http_action', defaults={'proto': 'https'}), Rule('/verify-https', endpoint='verify_http_action', defaults={'proto': 'https'}),
@ -62,6 +65,11 @@ class WSGIApp:
Rule('/update-password', endpoint='update_password_action'), Rule('/update-password', endpoint='update_password_action'),
Rule('/shutdown-vm', endpoint='shutdown_vm_action'), Rule('/shutdown-vm', endpoint='shutdown_vm_action'),
Rule('/reboot-vm', endpoint='reboot_vm_action'), Rule('/reboot-vm', endpoint='reboot_vm_action'),
Rule('/update-ssh-keys', endpoint='update_ssh_keys_action'),
Rule('/update-vpn', endpoint='update_vpn_action'),
Rule('/generate-vpn-key', endpoint='generate_vpn_key_action'),
Rule('/start-vpn', endpoint='start_vpn_action'),
Rule('/stop-vpn', endpoint='stop_vpn_action'),
)) ))
self.localhost_url_map = Map(( self.localhost_url_map = Map((
Rule('/reload-config', endpoint='reload_config_action'), Rule('/reload-config', endpoint='reload_config_action'),
@ -248,14 +256,22 @@ class WSGIApp:
app_data[app] = {'title': title, 'visible': visible, 'installed': installed, 'autostarted': autostarted, 'status': status, 'actions': actions} app_data[app] = {'title': title, 'visible': visible, 'installed': installed, 'autostarted': autostarted, 'status': status, 'actions': actions}
return self.render_template('setup-apps-table.html', request, app_data=app_data) return self.render_template('setup-apps-table.html', request, app_data=app_data)
def setup_remote_view(self, request):
# Remote access setup view
authorized_keys = remote.get_authorized_keys()
wg_conf = remote.get_wireguard_conf()
wg_conf['status'] = ('info', request.session.lang.status_started()) if wg_conf['running'] else ('error', request.session.lang.status_stopped())
message = self.get_session_message(request)
return self.render_html('setup-remote.html', request, authorized_keys=authorized_keys, wg_conf=wg_conf, message=message)
def update_host_action(self, request): def update_host_action(self, request):
# Update domain and port, then restart nginx # Update domain and port, then restart nginx
domain = request.form['domain'] domain = request.form['domain']
port = request.form['port'] port = request.form['port']
if not validator.is_valid_domain(domain): if not validator.is_valid_domain(domain):
return self.render_json({'error': request.session.lang.invalid_domain(domain)}) return self.render_json({'error': request.session.lang.invalid_domain()})
if not validator.is_valid_port(port): if not validator.is_valid_port(port):
return self.render_json({'error': request.session.lang.invalid_port(port)}) return self.render_json({'error': request.session.lang.invalid_port()})
self.vmmgr.update_host(domain, port) self.vmmgr.update_host(domain, port)
url = '{}/setup-host'.format(net.compile_url(net.get_local_ip(), port)) url = '{}/setup-host'.format(net.compile_url(net.get_local_ip(), port))
response = self.render_json({'ok': request.session.lang.host_updated(url, url)}) response = self.render_json({'ok': request.session.lang.host_updated(url, url)})
@ -410,3 +426,39 @@ class WSGIApp:
def is_app_visible(self, app): def is_app_visible(self, app):
return app in self.conf['apps'] and self.conf['apps'][app]['visible'] and self.appmgr.is_service_started(app) return app in self.conf['apps'] and self.conf['apps'][app]['visible'] and self.appmgr.is_service_started(app)
def update_ssh_keys_action(self, request):
# Update authorized_keys file
remote.set_authorized_keys(request.form['ssh-keys'].replace('\r', ''))
request.session['msg'] = 'ssh:info:{}'.format(request.session.lang.ssh_keys_installed())
return redirect('/setup-remote')
def update_vpn_action(self, request):
# Update WireGuard VPN listen port, virtual IP and peer list
ip = request.form['vpn-lip']
if not ip.isdigit() or not 0 < int(ip) < 255:
request.session['msg'] = 'vpn:error:{}'.format(request.session.lang.invalid_ip())
return redirect('/setup-remote')
port = request.form['vpn-port']
if not port.isdigit() or not 0 < int(port) < 65536:
request.session['msg'] = 'vpn:error:{}'.format(request.session.lang.invalid_port())
return redirect('/setup-remote')
peers = request.form['vpn-peers'].replace('\r', '')
remote.set_wireguard_conf(ip, port, peers)
request.session['msg'] = 'vpn:info:{}'.format(request.session.lang.vpn_updated())
return redirect('/setup-remote')
def generate_vpn_key_action(self, request):
# Regenerate WireGuard key pair
remote.regenerate_wireguard_key()
return redirect('/setup-remote')
def start_vpn_action(self, request):
# Start WireGuard VPN
remote.start_wireguard()
return redirect('/setup-remote')
def stop_vpn_action(self, request):
# Stop WireGuard VPN
remote.stop_wireguard()
return redirect('/setup-remote')

View File

@ -2,8 +2,9 @@
class WSGILang: class WSGILang:
lang = { lang = {
'invalid_domain': 'Zadaný doménový název "{}" není platný.', 'invalid_domain': 'Zadaný doménový název není platný.',
'invalid_port': 'Zadaný port "{}" není platný.', 'invalid_port': 'Zadaný port není platný.',
'invalid_ip': 'Zadaná IP adresa není platná.',
'host_updated': 'Nastavení hostitele bylo úspěšně změněno. Přejděte na URL <a href="{}">{}</a> a pokračujte následujícími kroky.', 'host_updated': 'Nastavení hostitele bylo úspěšně změněno. Přejděte na URL <a href="{}">{}</a> a pokračujte následujícími kroky.',
'dns_record_does_not_exist': 'DNS záznam pro název "{}" neexistuje.', 'dns_record_does_not_exist': 'DNS záznam pro název "{}" neexistuje.',
'dns_record_mismatch': 'DNS záznam pro název "{}" směřuje na IP {} místo očekávané {}.', 'dns_record_mismatch': 'DNS záznam pro název "{}" směřuje na IP {} místo očekávané {}.',
@ -31,7 +32,7 @@ class WSGILang:
'bad_password': 'Nesprávné heslo', 'bad_password': 'Nesprávné heslo',
'password_mismatch': 'Zadaná hesla se neshodují', 'password_mismatch': 'Zadaná hesla se neshodují',
'password_empty': 'Nové heslo nesmí být prázdné', 'password_empty': 'Nové heslo nesmí být prázdné',
'password_changed': 'Heslo úspěšně změněno', 'password_changed': 'Heslo bylo úspěšně změněno',
'reboot_initiated': 'Příkaz odeslán. Vyčkejte na restartování virtuálního stroje.', 'reboot_initiated': 'Příkaz odeslán. Vyčkejte na restartování virtuálního stroje.',
'shutdown_initiated': 'Příkaz odeslán. Vyčkejte na vypnutí virtuálního stroje.', 'shutdown_initiated': 'Příkaz odeslán. Vyčkejte na vypnutí virtuálního stroje.',
'status_queued': 've frontě', 'status_queued': 've frontě',
@ -49,6 +50,8 @@ class WSGILang:
'action_stop': 'Zastavit', 'action_stop': 'Zastavit',
'action_install': 'Instalovat', 'action_install': 'Instalovat',
'action_uninstall': 'Odinstalovat', 'action_uninstall': 'Odinstalovat',
'ssh_keys_installed': 'SSH klíče byly úspěšně změněny.',
'vpn_updated': 'Nastavení VPN bylo úspěšně změněno.',
} }
def __getattr__(self, key): def __getattr__(self, key):

View File

@ -147,12 +147,32 @@ header p,
white-space: nowrap; white-space: nowrap;
} }
.setup-box td.remark { .setup-box .remark {
color: #999; color: #999;
font-size: 80%; font-size: 80%;
font-style: italic; font-style: italic;
line-height: 125%; line-height: 125%;
padding-top: 5px; padding-top: 5px;
margin-top: 0px;
}
.setup-box #ssh-keys {
min-height: 100px;
width: 99%;
}
.setup-box #vpn-peers {
min-height: 180px;
width: 100%;
}
.setup-box #vpn-lip {
width: 40px;
}
.setup-box #vpn-pubkey {
width: 360px;
font-family: monospace;
} }
#app-manager { #app-manager {

View File

@ -27,6 +27,7 @@
{% if session.admin %} {% if session.admin %}
<li><a href="/setup-host">Nastavení hostitele</a></li> <li><a href="/setup-host">Nastavení hostitele</a></li>
<li><a href="/setup-apps">Nastavení aplikací</a></li> <li><a href="/setup-apps">Nastavení aplikací</a></li>
<li><a href="/setup-remote">Nastavení vzdáleného přístupu</a></li>
<li><a href="/logout">Odhlášení</a></li> <li><a href="/logout">Odhlášení</a></li>
{% else %} {% else %}
<li><a href="/login">Přihlášení</a></li> <li><a href="/login">Přihlášení</a></li>

View File

@ -0,0 +1,72 @@
{% extends 'layout.html' %}
{% block title %}Nastavení vzdáleného přístupu{% endblock %}
{% block body %}
<div class="setup-box">
<h2>SSH klíče</h2>
<p>Obsah souboru <em>authorized_hosts</em> uživatele root.</p>
<form id="update-ssh-keys" action="/update-ssh-keys" method="post">
<textarea name="ssh-keys" id="ssh-keys">{{ authorized_keys }}</textarea>
<input type="submit" value="Nastavit klíče">
{% if message and message[0] == 'ssh' %}
<p class="{{ message[1] }}">{{ message[2] }}</p>
{% endif %}
</form>
</div>
<div class="setup-box">
<h2>WireGuard VPN</h2>
<p>Pro vzdálený administrátorský přístup nebo propojení dvou virtuálních strojů v sítích, kde není možno nastavit překlad síťových adres, je možno využít PPP VPN protokol WireGuard.</p>
<form id="update-vpn" action="/update-vpn" method="post">
<table>
<tr>
<td>Naslouchací port</td>
<td><input type="text" name="vpn-port" id="vpn-port" value="{{ wg_conf['port'] }}"></td>
<td class="remark">Port na kterém WireGuard rozhraní tohoto virtuálního stroje naslouchá příchozím spojením.</td>
</tr>
<tr>
<td>VPN IP adresa</td>
<td>172.17.255.<input type="text" name="vpn-lip" id="vpn-lip" value="{{ wg_conf['ip'] }}"></td>
<td class="remark">Virtuální IP adresa WireGuard rozhraní tohoto virtuálního stroje dostupná pouze v rámci VPN.</td>
</tr>
<tr>
<td>Partneři</td>
<td colspan="2">
<textarea name="vpn-peers" id="vpn-peers">{{ wg_conf['peers'] }}</textarea>
<p class="remark">Seznam WireGuard partnerů ve formátu konfiguračního souboru WireGuard. Pro více informací a příklady užití vizte stránku WireGuard VPN v technické dokumentaci.</p>
</td>
</tr>
<tr>
<td>&nbsp;</td>
<td colspan="2">
<input type="submit" value="Nastavit VPN">
<div id="vpn-message"></div>
</td>
</tr>
<tr>
<td colspan="3">&nbsp;</td>
</tr>
<tr>
<td>Váš veřejný klíč</td>
<td colspan="2"><input type="text" id="vpn-pubkey" value="{{ wg_conf['pubkey'] }}" readonly> <input type="submit" formaction="/generate-vpn-key" value="Generovat nový klíč"></td>
</tr>
<tr>
<td>Stav VPN</td>
<td colspan="2" class="{{ wg_conf['status'][0] }}">{{ wg_conf['status'][1] }}</td>
</tr>
<tr>
<td>&nbsp;</td>
<td colspan="2">
{% if wg_conf['running'] %}
<input type="submit" formaction="/stop-vpn" value="Zastavit VPN">
{% else %}
<input type="submit" formaction="/start-vpn" value="Spustit VPN">
{% endif %}
{% if message and message[0] == 'vpn' %}
<p class="{{ message[1] }}">{{ message[2] }}</p>
{% endif %}
</td>
</tr>
</table>
</form>
</div>
{% endblock %}