mirror of
https://github.com/towalink/wgfrontend.git
synced 2025-04-19 00:45:15 +00:00
Initial public commit
This commit is contained in:
parent
45f5f64235
commit
3cde7d371d
23
CHANGELOG.md
Normal file
23
CHANGELOG.md
Normal file
@ -0,0 +1,23 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project are documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- n/a
|
||||
|
||||
### Changed
|
||||
|
||||
- n/a
|
||||
|
||||
### Fixed
|
||||
|
||||
- n/a
|
||||
|
||||
## [0.1.0] - 2020-11-19
|
||||
|
||||
### Added
|
||||
|
||||
- First public release to Github.
|
113
README.md
Normal file
113
README.md
Normal file
@ -0,0 +1,113 @@
|
||||
# wgfrontend
|
||||
|
||||
A simple web frontend for configuring peers within a WireGuard configuration file to thus administer road warrior clients.
|
||||
|
||||
There are lot of user interfaces for administering WireGuard configuration files available. However, many of them have a bunch of dependencies, require root privileges to operate, or are a hassle to set up. "wgfrontend" provides a user interface that can be easily installed by just installing a package from Python's package repository PyPi (i.e. using pip).
|
||||
|
||||
IMPORTANT NOTE: This tool is in an early development stage. Be warned. It is already working but looking "ugly" (the user interface does not have any nice formatting or CSS yet).
|
||||
|
||||
This little tool is independent of the Towalink site connectivity solution (see https://towalink.readthedocs.io).
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- Web frontend for adding, modifying, and deleting WireGuard peers
|
||||
- Config files for WireGuard peers can be downloaded
|
||||
- Config files for WireGuard peers are shown as QR Code
|
||||
- Assistant for initial set-up
|
||||
- Web frontend does not run with root privileges
|
||||
- Simple installation
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
Install using PyPi:
|
||||
|
||||
```shell
|
||||
pip3 install wgfrontend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quickstart
|
||||
|
||||
After installing "wgfrontend" as shown above, just execute the tool with root permissions to get started:
|
||||
|
||||
```shell
|
||||
wgfrontend
|
||||
```
|
||||
|
||||
An interactive set-up assistant queries for the needed configuration data and sets up the environment.
|
||||
Once everything is configured, "wgfrontend" drops root privileges and runs a small web server on port 8080 to serve the web frontend.
|
||||
|
||||
Note that the changes done by wgfrontend in the WireGuard configuration file do not take affect automatically. The new configuration needs to be taken over to the WireGuard interface manually. Automating this is on the roadmap.
|
||||
|
||||
---
|
||||
|
||||
## Details
|
||||
|
||||
|
||||
### The wgfrontend configuration file
|
||||
|
||||
The interactive set-up assistant creates a configuration file with the desired information. It is located at "/etc/wgfrontend/wgfrontend.conf".
|
||||
|
||||
Here is an example:
|
||||
|
||||
```
|
||||
### Config file of the Towalink WireGuard Frontend ###
|
||||
[general]
|
||||
# The WireGuard config file to read and write
|
||||
wg_configfile = /etc/wireguard/wg_rw.conf
|
||||
|
||||
# The system user to be used for the frontend
|
||||
user = wgfrontend
|
||||
|
||||
[users]
|
||||
admin = dc524e423d9762830649d4d9e18f4b47a56c92f96646104dd06c71b26b54f732e8318d5b60a6b2b01b4f269407771496e879c9bf65ca9ef4f55a243ff358fc8dfea0bd9d30d766320857093eb95022822f71b098215f26f6d2644033d956bfdd
|
||||
```
|
||||
|
||||
### Add an additional frontend user
|
||||
|
||||
Create a password hash using the following command:
|
||||
|
||||
```shell
|
||||
wgfrontend-password
|
||||
```
|
||||
|
||||
Using this, you can add another user to the [users] section in the wgfrontend configuration file.
|
||||
|
||||
### A note on security
|
||||
|
||||
Don't expose the web frontend to the Internet without another layer of protection.
|
||||
|
||||
The wgfrontend web server does not run with root permissions. That's a start and better than many other WireGuard frontends. But the web server user has the permission to write to a WireGuard configuration file. This file may reference scripts that are run with root permissions when wg-quick is run. In case of a vulnerability in wgfrontend, this can be abused for privilege escalation. Thus add an additional safeguard layer of protection.
|
||||
|
||||
---
|
||||
|
||||
## Reporting bugs
|
||||
|
||||
In case you encounter any bugs, please report the expected behavior and the actual behavior so that the issue can be reproduced and fixed.
|
||||
|
||||
---
|
||||
## Developers
|
||||
|
||||
### Clone repository
|
||||
|
||||
Clone this repo to your local machine using `https://github.com/towalink/wgfrontend.git`
|
||||
|
||||
Install the module temporarily to make it available in your Python installation:
|
||||
```shell
|
||||
pip3 install -e <path to root of "src" directory>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
[](https://opensource.org/licenses/AGPL-3.0)
|
||||
|
||||
- **[AGPL3 license](https://opensource.org/licenses/AGPL-3.0)**
|
||||
- Copyright 2020 © <a href="https://github.com/towalink/wgfrontend" target="_blank">Dirk Henrici</a>.
|
||||
- [WireGuard](https://www.wireguard.com/) is a registered trademark of Jason A. Donenfeld.
|
54
setup.py
Normal file
54
setup.py
Normal file
@ -0,0 +1,54 @@
|
||||
import os
|
||||
import setuptools
|
||||
|
||||
|
||||
with open('README.md', 'r') as f:
|
||||
long_description = f.read()
|
||||
|
||||
setup_kwargs = {
|
||||
'name': 'wgfrontend',
|
||||
'version': '0.1.0',
|
||||
'author': 'The Towalink Project',
|
||||
'author_email': 'pypi.wgfrontend@towalink.net',
|
||||
'description': 'web-based user interface for configuring WireGuard for roadwarriors',
|
||||
'long_description': long_description,
|
||||
'long_description_content_type': 'text/markdown',
|
||||
'url': 'https://www.github.com/towalink/wgfrontend',
|
||||
'packages': setuptools.find_packages('src'),
|
||||
'package_dir': {'': 'src'},
|
||||
'include_package_data': True,
|
||||
'install_requires': ['cherrypy',
|
||||
'jinja2',
|
||||
'qrcode',
|
||||
'wgconfig'
|
||||
],
|
||||
'entry_points': '''
|
||||
[console_scripts]
|
||||
wgfrontend=wgfrontend:main
|
||||
wgfrontend-password=pwdtools:hash_password_interactively
|
||||
''',
|
||||
'classifiers': [
|
||||
'Programming Language :: Python',
|
||||
'Programming Language :: Python :: 3',
|
||||
'License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)',
|
||||
'Operating System :: POSIX :: Linux',
|
||||
'Development Status :: 3 - Alpha',
|
||||
#'Development Status :: 4 - Beta',
|
||||
#'Development Status :: 5 - Production/Stable',
|
||||
'Intended Audience :: System Administrators',
|
||||
'Intended Audience :: Information Technology',
|
||||
'Intended Audience :: Telecommunications Industry',
|
||||
'Topic :: System :: Networking'
|
||||
],
|
||||
'python_requires': '>=3.6',
|
||||
'keywords': 'Towalink VPN webfrontend WireGuard',
|
||||
'project_urls': {
|
||||
'Project homepage': 'https://www.towalink.net',
|
||||
'Repository': 'https://www.github.com/towalink/wgfrontend',
|
||||
'Documentation': 'https://towalink.readthedocs.io',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
setuptools.setup(**setup_kwargs)
|
108
src/config.py
Normal file
108
src/config.py
Normal 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
32
src/pwdtools.py
Normal 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
178
src/setupenv.py
Normal 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
15
src/templates/base.html
Normal 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
24
src/templates/config.html
Normal 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
22
src/templates/edit.html
Normal 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
23
src/templates/index.html
Normal 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 %}
|
3
src/templates/part_header.html
Normal file
3
src/templates/part_header.html
Normal file
@ -0,0 +1,3 @@
|
||||
<div class='heading'>
|
||||
<h2><a href='/'>Towalink WireGuard Frontend</a></h2>
|
||||
</div>
|
151
src/webapp.py
Normal file
151
src/webapp.py
Normal 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
|
BIN
src/webroot/static/favicon.ico
Normal file
BIN
src/webroot/static/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 2.2 KiB |
1
src/webroot/static/test.html
Normal file
1
src/webroot/static/test.html
Normal file
@ -0,0 +1 @@
|
||||
TEST
|
160
src/wgcfg.py
Normal file
160
src/wgcfg.py
Normal 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
15
src/wgfrontend.py
Normal 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()
|
Loading…
x
Reference in New Issue
Block a user