Initial public commit

This commit is contained in:
Henri
2020-11-19 21:55:45 +01:00
parent 45f5f64235
commit 3cde7d371d
16 changed files with 922 additions and 0 deletions

108
src/config.py Normal file
View File

@@ -0,0 +1,108 @@
# -*- coding: utf-8 -*-
import configparser
import logging
import os
import textwrap
import pwdtools
logger = logging.getLogger(__name__)
config_filename = '/etc/wgfrontend/wgfrontend.conf'
class Configuration():
"""Class for reading/writing the configuration file"""
_config = None
def exists(self):
"""Checks whether the config file exists"""
return os.path.isfile(self.filename)
def read_config(self):
"""Reads the config file"""
try:
logger.debug('Attempting to read config file [{0}]'.format(self.filename))
cfg = configparser.ConfigParser()
cfg.read(self.filename)
self._config = dict(cfg['general'])
self._users = dict(cfg['users'])
except Exception as e:
logger.warning('Config file [{0}] could not be read [{1}], using defaults'.format(self.filename, str(e)))
self._config = dict()
def write_config(self, wg_configfile='', user='', users={}):
"""Writes a new config file with the given attributes"""
# Set default values
if not wg_configfile.strip():
wg_configfile = '/etc/wireguard/wg_rw.conf'
if not user.strip():
user = 'wgfrontend'
users = { username if username.strip() else 'admin': password for username, password in users.items() }
username = next(iter(users.keys()))
password = pwdtools.hash_password(users[username])
# Config file content
config_content = textwrap.dedent(f'''\
### Config file of the Towalink WireGuard Frontend ###
[general]
# The WireGuard config file to read and write
wg_configfile = {wg_configfile}
# The system user to be used for the frontend
user = {user}
[users]
{username} = {password}
''')
# Write to file system
try:
with open(self.filename, 'w') as config_file:
config_file.write(config_content)
except OSError as e:
logger.error('Could not write config file [{0}], [{1}]'.format(self.filename, str(e)))
@property
def filename(self):
"""Return the name of the config file (incl. path)"""
return config_filename
@property
def config(self):
"""Return the config dictionary"""
if self._config is None:
self.read_config()
return self._config
@property
def users(self):
"""Return the users dictionary"""
if self._users is None:
self.read_config()
return self._users
@property
def wg_configfile(self):
"""The filename incl. path of the config file for the WireGuard interface"""
return self.config.get('wg_configfile', '/etc/wireguard/wg_rw.conf')
@property
def sslcertfile(self):
"""The filename incl. path for the server certificate"""
return os.path.join(os.path.dirname(self.filename), 'server.pem')
@property
def sslkeyfile(self):
"""The filename incl. path for the server private key"""
return os.path.join(os.path.dirname(self.filename), 'key.pem')
@property
def libdir(self):
"""The directory for the generated config files"""
return '/var/lib/wgfrontend'
@property
def user(self):
"""The configured name for the wgfrontend system user"""
return self.config.get('user', 'wgfrontend')

32
src/pwdtools.py Normal file
View File

@@ -0,0 +1,32 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Thx to https://www.vitoshacademy.com/hashing-passwords-in-python/
import hashlib, binascii, os
def hash_password(password):
"""Hash a password for storing."""
salt = hashlib.sha256(os.urandom(60)).hexdigest().encode('ascii')
pwdhash = hashlib.pbkdf2_hmac('sha512', password.encode('utf-8'), salt, 100000)
pwdhash = binascii.hexlify(pwdhash)
return (salt + pwdhash).decode('ascii')
def verify_password(stored_password, provided_password):
"""Verify a stored password against one provided by user"""
salt = stored_password[:64]
stored_password = stored_password[64:]
pwdhash = hashlib.pbkdf2_hmac('sha512', provided_password.encode('utf-8'), salt.encode('ascii'), 100000)
pwdhash = binascii.hexlify(pwdhash).decode('ascii')
return pwdhash == stored_password
def hash_password_interactively():
"""Ask for a password and print it in hashed form"""
pwd = input('Please enter password to hash: ')
print('Hashed password:')
print(hash_password(pwd))
if __name__ == '__main__':
hash_password_interactively()

178
src/setupenv.py Normal file
View File

@@ -0,0 +1,178 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import getpass
import grp
import os
import pwd
import string
import wgconfig
import wgconfig.wgexec as wgexec
import config
def is_root():
"""Returns whether this script is run with user is 0 (root)"""
return os.getuid() == 0
def get_user():
"""Returns the effective user that executes this script"""
return getpass.getuser()
def check_user(username):
"""Returns a boolean that indicates if the given user exists on the system"""
try:
pwd.getpwnam(username)
return True
except KeyError:
return False
def create_user(username):
"""Create the given user on the system"""
if os.path.exists('/usr/sbin/useradd'):
os.system(f'useradd {username}')
else:
os.system(f'adduser {username} -D')
def ensure_user(username):
"""Ensures that the given user exists on the system"""
if not check_user(username):
create_user(username)
def check_wg():
"""Check whether the wg tool is present"""
return os.path.isfile('/usr/bin/wg')
def check_wgquick():
"""Check whether the wg-quick tool is present"""
return os.path.isfile('/usr/bin/wg-quick')
def touch_file(filename, perm=0o640):
"""Touch the given file with the provided permissions"""
if not os.path.exists(os.path.dirname(filename)):
os.makedirs(os.path.dirname(filename))
with os.fdopen(os.open(filename, os.O_WRONLY | os.O_CREAT, perm), 'w') as handle:
pass
def check_validcharacters(value, valid_chars=string.ascii_letters):
"""Checks whether the provided string only contains the listed characters"""
invalid = [ k for k in value if k not in valid_chars ]
if invalid:
return False
return True
def chown(username, path):
"""Change file/path ownership to given user"""
uid = pwd.getpwnam(username).pw_uid
os.chown(path, uid, -1)
def drop_privileges(uid_name='nobody', gid_name='nogroup'):
""""""
if not is_root():
raise ValueError('No privileges present to drop')
uid = pwd.getpwnam(uid_name).pw_uid
gid = grp.getgrnam(gid_name).gr_gid
# os.setgroups([]) # remove group privileges
os.setgid(gid)
os.setuid(uid)
def setup_environment():
"""Environment setup assistant"""
cfg = config.Configuration()
if is_root():
print('Welcome to Towalink WireGuard Frontend')
print('======================================')
print('You are executing "wgfrontend" as root user. We\'ll now make sure that everything is properly installed.')
if check_wg():
print(f'1a) Wireguard (wg) is available. Ok.')
else:
print(f'1a) Wireguard (wg) is not available. FAIL.')
if check_wg():
print(f'1b) Wireguard (wg-quick) is available. Ok.')
else:
print(f'1b) Wireguard (wg-quick) is not available. FAIL.')
if cfg.exists():
print(f'2) Config file {cfg.filename} already exists. Ok.')
else:
print(f'2) Config file {cfg.filename} does not yet exist. Let\'s create one...')
print(f' Press enter to select defaults.')
wg_configfile = input(f'2a) Please specify the WireGuard config file to be used [/etc/wireguard/wg_rw.conf]: ')
user = input(f'2b) Please specify the system user for the web frontend [wgfrontend]: ')
ok = False
while not ok:
username = input(f'2c) Please specify the username for your web frontend user [admin]: ')
if check_validcharacters(username, string.ascii_letters + '_'):
ok = True
else:
print(' Username must only contain letters and underscores. Please enter anew.')
ok = False
while not ok:
password = input(f'2d) Please specify the password for your web frontend user: ')
if len(password) >= 8:
ok = True
else:
print(' Password must have at least eight characters. Please enter anew.')
touch_file(cfg.filename, perm=0o640) # create without world read permissions
cfg.write_config(wg_configfile=wg_configfile, user=user, users={username: password})
print(' Config file written. Ok.')
print(f'3) Ensuring that system user "{cfg.user}" exists.')
ensure_user(cfg.user)
print(f'4) Ensuring ownership of config file {cfg.filename}.')
chown(cfg.user, cfg.filename)
if os.path.exists(cfg.libdir):
print(f'5) Directory {cfg.libdir} already exists. Ok.')
else:
print(f'5) Directory {cfg.libdir} does not yet exist. Let\'s create it...')
os.makedirs(cfg.libdir, mode=0o640, exist_ok=True)
print(' Directory created. Ok.')
print(f'6) Ensuring ownership of directory {cfg.libdir}.')
chown(cfg.user, cfg.libdir)
if os.path.exists(cfg.wg_configfile):
print(f'7) WireGuard config file {cfg.wg_configfile} already exists. Ok.')
else:
print(f'7) WireGuard config file {cfg.wg_configfile} does not yet exist. Let\'s create one...')
wg_listenport = input(f'7a) Please specify the listen port of the WireGuard interface [51820]: ')
if not wg_listenport.strip():
wg_listenport = 51820
ok = False
while not ok:
endpoint = input(f'7b) Please specify the endpoint hostname (and optionally port) to reach your WireGuard server: ')
if len(endpoint) > 0:
ok = True
else:
print(' You need to enter an endpoint hostname.')
wg_address = input(f'7c) Please specify the IP address of the WireGuard interface incl. prefix length [192.168.0.1/24]: ')
if not wg_address.strip():
wg_address = '192.168.0.1/24'
wg_networks = input(f'7d) Please specify the network ranges that the clients shall route to the WireGuard server [192.168.0.0/16]: ')
if not wg_networks.strip():
wg_networks = '192.168.0.0/16'
wc = wgconfig.WGConfig(cfg.wg_configfile)
wc.initialize_file('# This file has been created and is managed by wgfrontend. Only change manually if you know what you\'re doing.')
if not ':' in endpoint:
endpoint += ':51820'
wc.add_attr(None, 'ListenPort', wg_listenport, '# Endpoint = ' + endpoint, append_as_line=True)
wc.add_attr(None, 'PrivateKey', wgexec.generate_privatekey())
wc.add_attr(None, 'Address', wg_address, '# Networks = ' + wg_networks, append_as_line=True)
wc.write_file()
print(' Config file written. Ok.')
print(f'8a) Ensuring list permission of WireGuard config directory {os.path.dirname(cfg.wg_configfile)}.')
os.chmod(os.path.dirname(cfg.wg_configfile), 0o711)
print(f'8b) Ensuring ownership of WireGuard config file {cfg.wg_configfile}.')
chown(cfg.user, cfg.wg_configfile)
print(f'8c) Ensuring ownership of server certificate file {cfg.sslcertfile} in case it exists.')
if os.path.exists(cfg.sslcertfile):
chown(cfg.user, cfg.sslcertfile)
print(f'8d) Ensuring ownership of server private key file {cfg.sslkeyfile} in case it exists.')
if os.path.exists(cfg.sslkeyfile):
chown(cfg.user, cfg.sslkeyfile)
print(f'9) Dropping root privileges to user/group "{cfg.user}".')
drop_privileges(cfg.user, cfg.user)
print(f'Attempting to start web frontend...')
return cfg
if __name__ == '__main__':
setup_environment()

15
src/templates/base.html Normal file
View File

@@ -0,0 +1,15 @@
<html>
<head>
<meta http-equiv='content-type' content='text/html; charset=utf-8' />
<title>Towalink WireGuard Frontend</title>
<link rel='stylesheet' media='screen' href='/static/layout.css' />
</head>
<body>
<div class='container'>
<div class='header'>
{% include 'part_header.html' %}
</div>
<div class='content'>{% block content %}{% endblock %}</div>
</div>
</body>
</html>

24
src/templates/config.html Normal file
View File

@@ -0,0 +1,24 @@
{% extends 'base.html' %}
{% block content %}
<h3>Client Config</h3>
<div class='instructions'>
<a href='https://www.wireguard.com/install/' target="_blank">WireGuard client installation instructions</a>
</div>
<div class='span-18 last'>
<form method="get" action="download">
<table>
<tr>
<td>
{{ peerdata['Description'] }}<br>
{{ peerdata['Address'] }}
</td>
<td>
<img src="/configs/{{ peerdata['Id'] }}.png" alt="QR Code">
<button type="submit" name="id" value="{{ peerdata['Id'] }}">Download Config</button>
<button type="submit" formaction=".." }}">Return</button>
</td>
</tr>
</table>
</form>
</div>
{% endblock %}

22
src/templates/edit.html Normal file
View File

@@ -0,0 +1,22 @@
{% extends 'base.html' %}
{% block content %}
<h3>Configured Clients</h3>
<div class='span-18 last'>
<form method="get" action="edit">
<table>
<tr>
<td>
<input type="hidden" name="id" value="{{ peerdata['Id'] }}" />
<input type="text" name="description" value="{{ peerdata['Description'] }}" size="40" /><br>
{{ peerdata['Address'] }}
</td>
<td>
<button type="submit" name="action" value="save">Save Data</button>
<button type="submit" name="action" value="delete" formaction="/" onclick="return confirm('Do you really want to delete this client?')">Delete Client</button>
<button type="submit" formaction="/">Return to List</button>
</td>
</tr>
</table>
</form>
</div>
{% endblock %}

23
src/templates/index.html Normal file
View File

@@ -0,0 +1,23 @@
{% extends 'base.html' %}
{% block content %}
<h3>Configured Clients</h3>
<div class='span-18 last'>
<form method="get" action="edit">
<button type="submit" name="action" value="new">Add Client</button>
<table>
{%- for peer, peerdata in peers.items()|sort(attribute='1.Description') %}
<tr>
<td>
{{ peerdata['Description'] }}<br>
{{ peerdata['Address'] }}
</td>
<td>
<button type="submit" name="id" value="{{ peerdata['Id'] }}">Edit Client</button>
<button type="submit" name="id" value="{{ peerdata['Id'] }}" formaction="config">Get Config</button>
</td>
</tr>
{%- endfor %}
</table>
</form>
</div>
{% endblock %}

View File

@@ -0,0 +1,3 @@
<div class='heading'>
<h2><a href='/'>Towalink WireGuard Frontend</a></h2>
</div>

151
src/webapp.py Normal file
View File

@@ -0,0 +1,151 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import cherrypy
import jinja2
import os
import random
import string
import pwdtools
import wgcfg
def login_screen(from_page='..', username='', error_msg='', **kwargs):
"""Based on https://docs.cherrypy.org/en/latest/_modules/cherrypy/lib/cptools.html"""
content="""
<form method="post" action="do_login">
<div align=center>
<span class=errormsg>%s</span>
<table>
<tr>
<td>
Login:
</td>
<td>
<input type="text" name="username" value="%s" size="40" />
</td>
<tr>
<td>
Password:
</td>
<td>
<input type="password" name="password" size="40" />
<input type="hidden" name="from_page" value="%s" />
</td>
</tr>
<tr>
<td colspan=2 align=right>
<input type="submit" value="Login" />
</td>
</tr>
</table>
</div>
</form>
""" % (error_msg, username, from_page)
title='Login'
return ('<html><body>' + content + '</body></html>').encode('utf-8')
# return cherrypy.tools.encode('<html><body>' + content + '</body></html>')
class WebApp():
def __init__(self, cfg):
"""Instance initialization"""
self.cfg = cfg
self.jinja_env = jinja2.Environment(loader=jinja2.FileSystemLoader('templates'))
self.wg = wgcfg.WGCfg(self.cfg.wg_configfile, self.cfg.libdir)
@cherrypy.expose
def index(self, action=None, id=None, description=None):
if (action == 'delete') and id:
peer, peerdata = self.wg.get_peer_byid(id)
self.wg.delete_peer(peer)
peers = self.wg.get_peers()
tmpl = self.jinja_env.get_template('index.html')
return tmpl.render(peers=peers)
@cherrypy.expose
def config(self, id):
peer, peerdata = self.wg.get_peer_byid(id)
tmpl = self.jinja_env.get_template('config.html')
return tmpl.render(peerdata=peerdata)
@cherrypy.expose
def edit(self, action='edit', id=None, description=None):
if id: # existing client
peer, peerdata = self.wg.get_peer_byid(id)
if description:
peerdata = self.wg.update_peer(peer, description)
else:
if not description:
description = 'My new client'
if action == 'new': # default values for new client
peerdata = { 'Description': description, 'Id': '' }
else: # save changes
peer = self.wg.create_peer(description)
peerdata = self.wg.get_peer(peer)
tmpl = self.jinja_env.get_template('edit.html')
return tmpl.render(peerdata=peerdata)
@cherrypy.expose
def download(self, id):
peer, peerdata = self.wg.get_peer_byid(id)
config, peerdata = self.wg.get_peerconfig(peer)
cherrypy.response.headers['Content-Disposition'] = f'attachment; filename=wg_{id}.conf'
cherrypy.response.headers['Content-Type'] = 'text/plain' # 'application/x-download' 'application/octet-stream'
return config.encode('utf-8')
@cherrypy.expose
def logout(self):
username = cherrypy.session['username']
cherrypy.session.clear()
return '"{0}" has been logged out'.format(username)
def run_webapp(cfg):
def check_username_and_password(username, password):
"""Check whether provided username and password are valid when authenticating"""
if (username in cfg.users) and (pwdtools.verify_password(cfg.users[username], password)):
return
return 'invalid username/password'
script_path = os.path.dirname(os.path.abspath(__file__))
conf = {
'/': {
'tools.sessions.on': True,
'tools.staticdir.root': os.path.join(script_path, 'webroot'),
# 'tools.session_auth.on': True,
'tools.session_auth.login_screen': login_screen,
'tools.session_auth.check_username_and_password': check_username_and_password,
},
'/configs': {
'tools.staticdir.on': True,
'tools.staticdir.root': None,
'tools.staticdir.dir': cfg.libdir
},
'/static': {
'tools.staticdir.on': True,
'tools.staticdir.dir': 'static'
},
'/favicon.ico':
{
'tools.staticfile.on': True,
'tools.staticfile.filename': os.path.join(script_path, 'webroot', 'static', 'favicon.ico')
}
}
if os.path.exists(cfg.sslcertfile) and os.path.exists(cfg.sslkeyfile):
cherrypy.server.ssl_module = 'builtin'
cherrypy.server.ssl_certificate = cfg.sslcertfile
cherrypy.server.ssl_private_key = cfg.sslkeyfile
cherrypy.config.update({'server.socket_host': '0.0.0.0',
'server.socket_port': 8080,
})
cherrypy.quickstart(WebApp(cfg), '/', conf)
if __name__ == '__main__':
pass

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1 @@
TEST

160
src/wgcfg.py Normal file
View File

@@ -0,0 +1,160 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import ipaddress
import logging
import os
import qrcode
import textwrap
import wgconfig
import wgconfig.wgexec as wgexec
logger = logging.getLogger(__name__)
class WGCfg():
"""Class for reading/writing the WireGuard configuration file"""
def __init__(self, filename, libdir):
"""Initialize instance for the given config file"""
self.filename = filename
self.libdir = libdir
self.wc = wgconfig.WGConfig(self.filename)
self.wc.read_file()
def get_interface(self):
"""Get WireGuard interface data"""
return self.wc.interface
def transform_to_clientdata(self, peer, peerdata):
"""Transform data of a single peer from server into a dictionary of client config data"""
result = dict()
description = peerdata['_rawdata'][0]
if description[0] == '#':
description = description[2:]
else:
description = 'Peer: ' + peer
result['Description'] = description
for item in peerdata['_rawdata']:
if item.startswith('# PrivateKey = '):
result['PrivateKey'] = item[15:]
result['PublicKey'] = peer
result['PresharedKey'] = peerdata['PresharedKey']
address = peerdata['AllowedIPs'].partition(',')[0] # get first allowed ip range
address = address.partition('/')[0] + '/' + self.get_interface()['Address'].partition('/')[2] # take prefix length from interface address
result['Address'] = address
result['Id'] = address.partition('/')[0].replace('.', '-')
result['QRCode'] = os.path.join(self.libdir, result['Id'] + '.png')
return result
def get_peer(self, peer):
"""Get data of the given WireGuard peer"""
return self.transform_to_clientdata(peer, self.wc.peers[peer])
def get_peers(self):
"""Get data of all WireGuard peers"""
return { peer: self.get_peer(peer) for peer in self.wc.peers.keys() }
def get_peer_byid(self, id):
"""Get data WireGuard peer with the given id"""
peer = next(peer for peer, peerdata in self.get_peers().items() if peerdata['Id'] == id)
return peer, self.get_peer(peer)
def get_peerconfig(self, peer):
"""Get config for the given WireGuard peer"""
peerdata = self.get_peer(peer)
for item in self.get_interface()['_rawdata']:
if item.startswith('# Endpoint = '):
endpoint = item[13:]
if item.startswith('# Networks = '):
allowed_ips = item[13:]
public_key = wgexec.get_publickey(peerdata['PrivateKey'])
public_key_server = wgexec.get_publickey(self.get_interface()['PrivateKey'])
config = textwrap.dedent(f'''\
# {peerdata['Description']}
[Interface]
ListenPort = 51820
PrivateKey = {peerdata['PrivateKey']}
# PublicKey = {public_key}
Address = {peerdata['Address']}
[Peer]
Endpoint = {endpoint}
PublicKey = {public_key_server}
PresharedKey = {peerdata['PresharedKey']}
AllowedIPs = {allowed_ips}
PersistentKeepalive = 25
''')
return config, peerdata
def create_peer(self, description, ip=None):
"""Create peer with the given description"""
if ip is None:
ip = self.find_free_ip()
private_key = wgexec.generate_privatekey()
peer = wgexec.get_publickey(private_key)
self.wc.add_peer(peer, '# ' + description)
comment = '# PrivateKey = ' + private_key
self.wc.add_attr(peer, 'PresharedKey', wgexec.generate_presharedkey(), comment, append_as_line=True)
self.wc.add_attr(peer, 'AllowedIPs', ip + '/32')
self.wc.add_attr(peer, 'PersistentKeepalive', 25)
self.wc.write_file()
self.write_qrcode(peer)
return peer
def update_peer(self, peer, description):
"""Update the given peer"""
peerdata = self.wc.peers[peer]
first_line = peerdata['_index_firstline']
if self.wc.lines[first_line][0] != '#':
raise ValueError(f'Comment expected in first line of config for peer [{peerdata}]')
self.wc.lines[first_line] = '# ' + description
self.wc.invalidate_data()
self.wc.write_file()
return self.get_peer(peer)
def delete_peer(self, peer):
"""Delete the given peer"""
self.wc.del_peer(peer)
self.wc.write_file()
def find_free_ip(self):
"""Find the first free address in the given network"""
interface_address = ipaddress.ip_interface(self.get_interface()['Address'])
network = interface_address.network
interface_address = interface_address.ip
addresses = [ ipaddress.ip_interface(peerdata['Address']).ip for peer, peerdata in self.get_peers().items() ]
print(addresses) # ***
ip = None
for addr in network.hosts():
if addr == interface_address:
continue
if addr in addresses:
continue
ip = addr
break
if ip is None:
raise ValueError('No free IP address available any more')
return str(ip)
def write_qrcode(self, peer):
"""Generate a QRCode for the given peers configuration file and store in lib directory"""
config, peerdata = self.get_peerconfig(peer)
#img = qrcode.make(config)
qr = qrcode.QRCode(version=15, error_correction=qrcode.constants.ERROR_CORRECT_M, box_size=2, border=5)
qr.add_data(config)
qr.make(fit=True)
img = qr.make_image(fill_color='black', back_color='white')
img.save(peerdata['QRCode'])
if __name__ == '__main__':
import pprint
wg = WGCfg('/etc/wireguard/wg_rw.conf', '/var/lib/wgfrontend')
peer = wg.create_peer('This is a first test peer')
peer2 = wg.create_peer('This is a second test peer')
wg.update_peer(peer2, 'CHANGED')
print(wg.get_peerconfig(peer2)[0])
wg.delete_peer(peer)
wg.delete_peer(peer2)

15
src/wgfrontend.py Normal file
View File

@@ -0,0 +1,15 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import setupenv
import webapp
def main():
cfg = setupenv.setup_environment()
webapp.run_webapp(cfg)
if __name__ == '__main__':
main()