├── codecov.yml ├── linguard ├── __init__.py ├── web │ ├── static │ │ ├── assets │ │ │ ├── resources.py │ │ │ ├── demo │ │ │ │ ├── chart-pie-demo.js │ │ │ │ ├── chart-bar-demo.js │ │ │ │ └── chart-area-demo.js │ │ │ └── img │ │ │ │ └── error-404-monochrome.svg │ │ └── js │ │ │ ├── libs │ │ │ ├── datatablesUtils.js │ │ │ └── chartUtils.js │ │ │ └── modules │ │ │ ├── settings.mjs │ │ │ ├── dashboard.mjs │ │ │ ├── wireguard-peer.mjs │ │ │ ├── wireguard.mjs │ │ │ ├── wireguard-iface.mjs │ │ │ └── utils.mjs │ ├── templates │ │ ├── error │ │ │ ├── error-img.html │ │ │ ├── error-base.html │ │ │ └── error-main.html │ │ ├── web │ │ │ ├── web-main.html │ │ │ ├── themes.html │ │ │ ├── web-base.html │ │ │ ├── top-nav.html │ │ │ ├── side-nav.html │ │ │ ├── about.html │ │ │ ├── signup.html │ │ │ ├── login.html │ │ │ └── profile.html │ │ ├── main.html │ │ ├── base.html │ │ └── footer.html │ ├── controllers │ │ └── ViewController.py │ ├── utils.py │ └── client.py ├── __version__.py ├── common │ ├── utils │ │ ├── file.py │ │ ├── logs.py │ │ ├── strings.py │ │ ├── time.py │ │ ├── encryption.py │ │ ├── network.py │ │ └── system.py │ ├── properties.py │ └── models │ │ ├── encrypted_yamlable.py │ │ ├── enhanced_dict.py │ │ └── user.py ├── core │ ├── config │ │ ├── base.py │ │ ├── traffic.py │ │ ├── web.py │ │ ├── logger.py │ │ └── wireguard.py │ ├── utils │ │ ├── tools.py │ │ └── wireguard.py │ ├── exceptions.py │ ├── managers │ │ ├── wireguard.py │ │ ├── traffic_storage.py │ │ ├── cron.py │ │ └── config.py │ ├── tools │ │ └── wg-json │ └── drivers │ │ ├── traffic_storage_driver_json.py │ │ └── traffic_storage_driver.py ├── tests │ ├── default │ │ ├── test_profile.py │ │ ├── test_dashboard.py │ │ ├── test_password_reset.py │ │ ├── test_login.py │ │ ├── test_setup.py │ │ └── test_peers.py │ ├── utils.py │ └── test_server.py └── __main__.py ├── docs ├── source │ ├── _build │ │ └── html │ │ │ ├── objects.inv │ │ │ ├── _static │ │ │ ├── custom.css │ │ │ ├── doctools.js │ │ │ ├── file.png │ │ │ ├── jquery.js │ │ │ ├── minus.png │ │ │ ├── plus.png │ │ │ ├── alabaster.css │ │ │ ├── jquery-3.5.1.js │ │ │ ├── searchtools.js │ │ │ ├── underscore.js │ │ │ ├── underscore-1.13.1.js │ │ │ ├── documentation_options.js │ │ │ └── pygments.css │ │ │ ├── installation.html │ │ │ ├── screenshots.html │ │ │ ├── .doctrees │ │ │ ├── index.doctree │ │ │ ├── screenshots.doctree │ │ │ ├── installation.doctree │ │ │ ├── changelog.doctree │ │ │ ├── environment.pickle │ │ │ └── contributing.doctree │ │ │ ├── _sources │ │ │ ├── index.rst.txt │ │ │ ├── changelog.rst.txt │ │ │ ├── installation.rst.txt │ │ │ ├── screenshots.rst.txt │ │ │ └── contributing.rst.txt │ │ │ ├── .buildinfo │ │ │ ├── searchindex.js │ │ │ ├── genindex.html │ │ │ ├── search.html │ │ │ ├── changelog.html │ │ │ └── index.html │ ├── images │ │ ├── login.png │ │ ├── signup.png │ │ ├── profile.png │ │ ├── dashboard-1.png │ │ ├── dashboard-2.png │ │ ├── peer-edit-1.png │ │ ├── peer-edit-2.png │ │ ├── settings-1.png │ │ ├── settings-2.png │ │ ├── wireguard-edit-1.png │ │ ├── wireguard-edit-2.png │ │ ├── wireguard-edit-3.png │ │ ├── network-section-1.png │ │ ├── network-section-2.png │ │ ├── wireguard-section-1.png │ │ └── wireguard-section-2.png │ ├── installation.rst │ ├── screenshots.rst │ ├── conf.py │ ├── index.rst │ ├── changelog.rst │ └── contributing.rst ├── Makefile └── make.bat ├── .gitattributes ├── gen_version_file.sh ├── systemd └── linguard.service ├── config ├── uwsgi.sample.yaml └── linguard.sample.yaml ├── release-notes.md ├── docker ├── docker-compose.yaml ├── Dockerfile └── entrypoint.sh ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── latest-deploy.yaml │ ├── latest-test.yaml │ ├── stable-test.yaml │ └── stable-deploy.yaml ├── pyproject.toml ├── scripts ├── log.sh └── install.sh ├── .gitignore └── README.md /codecov.yml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /linguard/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/source/_build/html/objects.inv: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/source/_build/html/_static/custom.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/source/_build/html/_static/doctools.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/source/_build/html/_static/file.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/source/_build/html/_static/jquery.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/source/_build/html/_static/minus.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/source/_build/html/_static/plus.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/source/_build/html/installation.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/source/_build/html/screenshots.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/source/_build/html/.doctrees/index.doctree: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/source/_build/html/_sources/index.rst.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/source/_build/html/_static/alabaster.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/source/_build/html/_static/jquery-3.5.1.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/source/_build/html/_static/searchtools.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/source/_build/html/_static/underscore.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/source/_build/html/.doctrees/screenshots.doctree: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/source/_build/html/_sources/changelog.rst.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/source/_build/html/_sources/installation.rst.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/source/_build/html/_sources/screenshots.rst.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/source/_build/html/_static/underscore-1.13.1.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/source/_build/html/.doctrees/installation.doctree: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.html linguist-vendored 2 | *.css linguist-vendored 3 | -------------------------------------------------------------------------------- /linguard/web/static/assets/resources.py: -------------------------------------------------------------------------------- 1 | APP_NAME = "Linguard" 2 | EMPTY_FIELD = "None" 3 | -------------------------------------------------------------------------------- /linguard/__version__.py: -------------------------------------------------------------------------------- 1 | release = '0.1.1' 2 | commit = 'ba9bf1162adf017ea5994d74237485a701ba581a' 3 | -------------------------------------------------------------------------------- /docs/source/images/login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseantmazonsb/linguard/HEAD/docs/source/images/login.png -------------------------------------------------------------------------------- /docs/source/images/signup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseantmazonsb/linguard/HEAD/docs/source/images/signup.png -------------------------------------------------------------------------------- /docs/source/images/profile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseantmazonsb/linguard/HEAD/docs/source/images/profile.png -------------------------------------------------------------------------------- /docs/source/images/dashboard-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseantmazonsb/linguard/HEAD/docs/source/images/dashboard-1.png -------------------------------------------------------------------------------- /docs/source/images/dashboard-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseantmazonsb/linguard/HEAD/docs/source/images/dashboard-2.png -------------------------------------------------------------------------------- /docs/source/images/peer-edit-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseantmazonsb/linguard/HEAD/docs/source/images/peer-edit-1.png -------------------------------------------------------------------------------- /docs/source/images/peer-edit-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseantmazonsb/linguard/HEAD/docs/source/images/peer-edit-2.png -------------------------------------------------------------------------------- /docs/source/images/settings-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseantmazonsb/linguard/HEAD/docs/source/images/settings-1.png -------------------------------------------------------------------------------- /docs/source/images/settings-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseantmazonsb/linguard/HEAD/docs/source/images/settings-2.png -------------------------------------------------------------------------------- /docs/source/images/wireguard-edit-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseantmazonsb/linguard/HEAD/docs/source/images/wireguard-edit-1.png -------------------------------------------------------------------------------- /docs/source/images/wireguard-edit-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseantmazonsb/linguard/HEAD/docs/source/images/wireguard-edit-2.png -------------------------------------------------------------------------------- /docs/source/images/wireguard-edit-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseantmazonsb/linguard/HEAD/docs/source/images/wireguard-edit-3.png -------------------------------------------------------------------------------- /docs/source/images/network-section-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseantmazonsb/linguard/HEAD/docs/source/images/network-section-1.png -------------------------------------------------------------------------------- /docs/source/images/network-section-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseantmazonsb/linguard/HEAD/docs/source/images/network-section-2.png -------------------------------------------------------------------------------- /docs/source/images/wireguard-section-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseantmazonsb/linguard/HEAD/docs/source/images/wireguard-section-1.png -------------------------------------------------------------------------------- /docs/source/images/wireguard-section-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseantmazonsb/linguard/HEAD/docs/source/images/wireguard-section-2.png -------------------------------------------------------------------------------- /docs/source/_build/html/.doctrees/changelog.doctree: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseantmazonsb/linguard/HEAD/docs/source/_build/html/.doctrees/changelog.doctree -------------------------------------------------------------------------------- /docs/source/_build/html/.doctrees/environment.pickle: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseantmazonsb/linguard/HEAD/docs/source/_build/html/.doctrees/environment.pickle -------------------------------------------------------------------------------- /docs/source/_build/html/.doctrees/contributing.doctree: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joseantmazonsb/linguard/HEAD/docs/source/_build/html/.doctrees/contributing.doctree -------------------------------------------------------------------------------- /linguard/web/static/js/libs/datatablesUtils.js: -------------------------------------------------------------------------------- 1 | // Call the dataTables jQuery plugin 2 | $(document).ready(function() { 3 | $('.dataTable').DataTable(); 4 | }); 5 | -------------------------------------------------------------------------------- /linguard/web/templates/error/error-img.html: -------------------------------------------------------------------------------- 1 | {% extends "error/error-main.html" %} 2 | 3 | {% block additional_info %} 4 | 5 | {% endblock %} -------------------------------------------------------------------------------- /gen_version_file.sh: -------------------------------------------------------------------------------- 1 | version_file="linguard/__version__.py" 2 | version=$(poetry version -s) 3 | commit=$(git rev-parse HEAD) 4 | echo -e "release = '$version'\ncommit = '$commit'" > "$version_file" -------------------------------------------------------------------------------- /docs/source/_build/html/.buildinfo: -------------------------------------------------------------------------------- 1 | # Sphinx build info version 1 2 | # This file hashes the configuration used when building these files. When it is not found, a full rebuild will be done. 3 | config: 6dc16f5277b787484e841cb89b4f009a 4 | tags: 645f666f9bcd5a90fca523b33c5a78b7 5 | -------------------------------------------------------------------------------- /systemd/linguard.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Linguard: Wireguard management made simple 3 | After=network.target 4 | 5 | [Service] 6 | Type=simple 7 | Restart=always 8 | User=linguard 9 | ExecStart=/usr/bin/uwsgi --yaml /var/www/linguard/data/uwsgi.yaml 10 | 11 | [Install] 12 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /linguard/common/utils/file.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | def write_lines(content: str, path: str): 5 | with open(path, "w") as file: 6 | file.writelines(content) 7 | 8 | 9 | def get_filename_without_extension(path: str) -> str: 10 | filename, extension = os.path.splitext(path) 11 | return os.path.basename(filename) 12 | -------------------------------------------------------------------------------- /config/uwsgi.sample.yaml: -------------------------------------------------------------------------------- 1 | uwsgi: 2 | master: true 3 | enable-threads: true 4 | processes: 1 5 | threads: 1 6 | chdir: /var/www/linguard 7 | venv: venv 8 | wsgi-file: linguard/__main__.py 9 | pyargv: data 10 | need-plugin: python3 11 | callable: app 12 | die-on-term: true 13 | http-socket: 0.0.0.0:8080 14 | chmod-socket: 660 15 | vacuum: true -------------------------------------------------------------------------------- /linguard/web/templates/web/web-main.html: -------------------------------------------------------------------------------- 1 | {% extends 'web/web-base.html' %} 2 | 3 | {% block top_nav %} 4 | {% include "web/top-nav.html" %} 5 | {% endblock %} 6 | 7 | {% block side_nav %} 8 | {% include "web/side-nav.html" %} 9 | {% endblock %} 10 | 11 | {% block content %} 12 | {% endblock %} 13 | 14 | {% block additional_scripts %} 15 | {% endblock %} -------------------------------------------------------------------------------- /linguard/core/config/base.py: -------------------------------------------------------------------------------- 1 | from logging import debug 2 | 3 | from yamlable import YamlAble 4 | 5 | 6 | class BaseConfig(YamlAble): 7 | 8 | def load_defaults(self): 9 | debug(f"Loading default settings for {self.__class__.__name__}...") 10 | pass 11 | 12 | def apply(self): 13 | debug(f"Applying {self.__class__.__name__}...") 14 | pass 15 | -------------------------------------------------------------------------------- /linguard/common/utils/logs.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | from logging import fatal, error, debug 3 | 4 | 5 | def log_exception(e: Exception, is_fatal: bool = False): 6 | error_msg = str(e) or f"{e.__class__.__name__} exception thrown by {e.__class__.__module__}" 7 | if is_fatal: 8 | fatal(error_msg) 9 | else: 10 | error(error_msg) 11 | debug(f"{traceback.format_exc()}") 12 | -------------------------------------------------------------------------------- /linguard/web/controllers/ViewController.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from linguard.web.utils import render_template 4 | 5 | 6 | class ViewController: 7 | 8 | def __init__(self, template: str, **context: Any): 9 | self.template = template 10 | self.context = context 11 | 12 | def load(self): 13 | self.view = render_template(self.template, **self.context) 14 | return self.view 15 | -------------------------------------------------------------------------------- /linguard/web/templates/main.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | 3 | {% block additional_metas %} 4 | {% endblock %} 5 | 6 | {% block title %} 7 | {{ app_name }} | {{ title }} 8 | {% endblock %} 9 | 10 | {% block additional_styles %} 11 | {% endblock %} 12 | 13 | {% block additional_prioritary_scripts %} 14 | {% endblock %} 15 | 16 | {% block body_content %} 17 | {% endblock %} 18 | 19 | {% block scripts %} 20 | {% endblock %} -------------------------------------------------------------------------------- /linguard/web/templates/web/themes.html: -------------------------------------------------------------------------------- 1 | {% extends "web/web-main.html" %} 2 | 3 | {% block content %} 4 | 5 |
6 |
7 |

{{ title }}

8 |
9 |
10 | Coming soon! 11 |
12 |
13 |
14 |
15 | 16 | {% endblock %} -------------------------------------------------------------------------------- /docs/source/_build/html/_static/documentation_options.js: -------------------------------------------------------------------------------- 1 | var DOCUMENTATION_OPTIONS = { 2 | URL_ROOT: document.getElementById("documentation_options").getAttribute('data-url_root'), 3 | VERSION: '0.2.0', 4 | LANGUAGE: 'None', 5 | COLLAPSE_INDEX: false, 6 | BUILDER: 'html', 7 | FILE_SUFFIX: '.html', 8 | LINK_SUFFIX: '.html', 9 | HAS_SOURCE: true, 10 | SOURCELINK_SUFFIX: '.txt', 11 | NAVIGATION_WITH_KEYS: false 12 | }; -------------------------------------------------------------------------------- /release-notes.md: -------------------------------------------------------------------------------- 1 | # Release notes 2 | 3 | ## What's new 4 | 5 | * Ban time is now editable and applies to individual IP addresses instead of globally (which makes much more sense). 6 | 7 | ## Fixes 8 | 9 | * Fixed a bug with the settings page which caused the display of default/last saved settings everytime the page was reloaded, even though the values were actually being stored in the configuration file and applied. 10 | 11 | ## Docs 12 | 13 | * Added entry for ban time. 14 | -------------------------------------------------------------------------------- /docker/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: "3.6" 2 | 3 | services: 4 | linguard: 5 | image: "ghcr.io/joseantmazonsb/linguard:stable" 6 | container_name: "linguard" 7 | cap_add: 8 | - NET_ADMIN 9 | - NET_RAW 10 | volumes: 11 | - "${DATA_FOLDER:-/srv/linguard/data}:/data" # Optional, provides a way to see and edit the configuration files directly from the host. 12 | network_mode: host 13 | restart: unless-stopped 14 | ports: 15 | - "8080:8080" 16 | -------------------------------------------------------------------------------- /linguard/core/utils/tools.py: -------------------------------------------------------------------------------- 1 | from os.path import join, dirname, abspath 2 | 3 | from linguard.common.utils.system import Command, CommandResult 4 | 5 | 6 | def get_tools_folder(): 7 | return join(dirname(dirname(abspath(__file__))), "tools") 8 | 9 | 10 | def get_tool_path(name: str): 11 | return join(get_tools_folder(), name) 12 | 13 | 14 | def run_tool(name: str, as_root: bool = False) -> CommandResult: 15 | return Command(get_tool_path(name)).run(as_root) 16 | 17 | 18 | def run_tool_as_root(name: str) -> CommandResult: 19 | return Command(get_tool_path(name)).run(True) 20 | -------------------------------------------------------------------------------- /linguard/common/utils/strings.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | 4 | def list_to_str(_list: list, separator=", ") -> str: 5 | length = len(_list) 6 | text = "" 7 | count = 0 8 | for item in _list: 9 | if count < length - 1: 10 | text += item + separator 11 | else: 12 | text += item 13 | count += 1 14 | return text 15 | 16 | 17 | def str_to_list(string: str, separator: str = "\n") -> List[str]: 18 | chunks = string.strip().split(separator) 19 | lst = [] 20 | for cmd in chunks: 21 | lst.append(cmd) 22 | return lst 23 | -------------------------------------------------------------------------------- /linguard/web/templates/error/error-base.html: -------------------------------------------------------------------------------- 1 | {% extends 'main.html' %} 2 | 3 | {% set footer_id = "layoutError_footer" %} 4 | 5 | {% block body_content %} 6 |
7 |
8 | {% block content %} 9 | {% endblock %} 10 |
11 | {% include "footer.html" %} 12 |
13 | {% endblock %} 14 | 15 | {% block scripts %} 16 | 17 | 18 | {% endblock %} -------------------------------------------------------------------------------- /linguard/core/exceptions.py: -------------------------------------------------------------------------------- 1 | from http.client import INTERNAL_SERVER_ERROR 2 | 3 | from linguard.web.static.assets.resources import APP_NAME 4 | 5 | 6 | class WireguardError(Exception): 7 | def __init__(self, cause: str, http_code: int = INTERNAL_SERVER_ERROR): 8 | self.http_code = http_code 9 | if "sudo" in cause: 10 | self.cause = f"unable to perform an operation which requires root permissions. " \ 11 | f"Make sure {APP_NAME}'s permissions are correctly set." 12 | self.http_code = 500 13 | else: 14 | self.cause = cause 15 | super() 16 | 17 | def __str__(self): 18 | return self.cause 19 | -------------------------------------------------------------------------------- /linguard/web/static/assets/demo/chart-pie-demo.js: -------------------------------------------------------------------------------- 1 | // Set new default font family and font color to mimic Bootstrap's default styling 2 | Chart.defaults.global.defaultFontFamily = '-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif'; 3 | Chart.defaults.global.defaultFontColor = '#292b2c'; 4 | 5 | // Pie Chart Example 6 | var ctx = document.getElementById("myPieChart"); 7 | var myPieChart = new Chart(ctx, { 8 | type: 'pie', 9 | data: { 10 | labels: ["Blue", "Red", "Yellow", "Green"], 11 | datasets: [{ 12 | data: [12.21, 15.58, 11.25, 8.32], 13 | backgroundColor: ['#007bff', '#dc3545', '#ffc107', '#28a745'], 14 | }], 15 | }, 16 | }); -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: 'enhancement' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /linguard/web/static/js/modules/settings.mjs: -------------------------------------------------------------------------------- 1 | document.getElementById('web_secret_key').setAttribute('type', "password"); 2 | 3 | document.getElementById("toggleSecretKey").addEventListener("click", function () { 4 | const icon = document.getElementById('toggleSecretKeyIcon') 5 | const field = document.getElementById('web_secret_key'); 6 | const type = field.getAttribute('type') === 'password' ? 'text' : 'password'; 7 | field.setAttribute('type', type); 8 | if (type === "password") { 9 | icon.classList.add('fa-eye-slash'); 10 | icon.classList.remove('fa-eye'); 11 | } 12 | else { 13 | icon.classList.add('fa-eye'); 14 | icon.classList.remove('fa-eye-slash'); 15 | } 16 | }, false); 17 | 18 | -------------------------------------------------------------------------------- /linguard/common/properties.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | class Properties: 5 | 6 | def __init__(self): 7 | self.dev_env = False 8 | self.workdir = "" 9 | self.setup_required = True 10 | self.setup_filename = ".setup" 11 | 12 | def join_workdir(self, path: str) -> str: 13 | """ 14 | Prepend the current workdir to the given path. 15 | 16 | :param path: 17 | :return: 18 | """ 19 | return os.path.join(self.workdir, path) 20 | 21 | @property 22 | def setup_filepath(self): 23 | return self.join_workdir(self.setup_filename) 24 | 25 | def setup_file_exists(self): 26 | return os.path.exists(self.setup_filepath) 27 | 28 | 29 | global_properties = Properties() 30 | 31 | -------------------------------------------------------------------------------- /linguard/common/utils/time.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | 4 | def get_time_ago(reference: datetime): 5 | delta = datetime.now() - reference 6 | days = delta.days 7 | if days: 8 | if days > 1: 9 | return f"{days} days ago" 10 | return "1 day ago" 11 | hours, remainder = divmod(delta.seconds, 3600) 12 | if hours: 13 | if hours > 1: 14 | return f"{hours} hours ago" 15 | return "1 hour ago" 16 | minutes, seconds = divmod(remainder, 60) 17 | if minutes: 18 | if minutes > 1: 19 | return f"{minutes} minutes ago" 20 | return "1 minute ago" 21 | if seconds: 22 | if seconds > 1: 23 | return f"{seconds} seconds ago" 24 | return "a moment ago" 25 | -------------------------------------------------------------------------------- /linguard/web/utils.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Any 3 | 4 | import faker 5 | from flask import templating 6 | 7 | from linguard.__version__ import commit, release 8 | from linguard.common.properties import global_properties 9 | from linguard.web.static.assets.resources import APP_NAME 10 | 11 | fake = faker.Faker() 12 | 13 | 14 | def render_template(template_path: str, **variables: Any): 15 | context = { 16 | "app_name": APP_NAME, 17 | "year": datetime.now().strftime("%Y"), 18 | "version_info": {"release": release, "commit": commit}, 19 | "dev_env": global_properties.dev_env 20 | } 21 | if variables: 22 | context.update(variables) 23 | return templating.render_template(template_path, **context) 24 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9-slim-bullseye as python-base 2 | ENV PYTHONFAULTHANDLER=1 \ 3 | PYTHONUNBUFFERED=1 \ 4 | PYTHONDONTWRITEBYTECODE=1 \ 5 | PYTHONHASHSEED=random \ 6 | PIP_NO_CACHE_DIR=off \ 7 | PIP_DISABLE_PIP_VERSION_CHECK=on \ 8 | PIP_DEFAULT_TIMEOUT=100 \ 9 | INSTALL_PATH="/var/www/linguard" \ 10 | DATA_PATH="/var/www/linguard/data" \ 11 | EXPORTED_PATH="/data" 12 | ENV PATH="$POETRY_HOME/bin:$VENV_PATH/bin:$PATH" 13 | COPY dist/*.tar.gz linguard.tar.gz 14 | RUN mkdir linguard && tar -xf linguard.tar.gz -C linguard 15 | WORKDIR linguard 16 | RUN chmod +x ./install.sh && ./install.sh 17 | WORKDIR $EXPORTED_PATH 18 | RUN rm -rf /linguard* 19 | 20 | EXPOSE 8080 21 | 22 | COPY docker/entrypoint.sh /entrypoint.sh 23 | RUN chmod +x /entrypoint.sh 24 | 25 | CMD /bin/bash /entrypoint.sh 26 | -------------------------------------------------------------------------------- /linguard/web/templates/web/web-base.html: -------------------------------------------------------------------------------- 1 | {% extends 'main.html' %} 2 | 3 | {% block body_content %} 4 | {% block top_nav %} 5 | {% endblock %} 6 |
7 | {% block side_nav %} 8 | {% endblock %} 9 |
10 | {% block content %} 11 | {% endblock %} 12 |
13 |
14 | {% include "footer.html" %} 15 | {% endblock %} 16 | 17 | {% block scripts %} 18 | 19 | 20 | 21 | {% block additional_scripts %} 22 | {% endblock %} 23 | {% endblock %} -------------------------------------------------------------------------------- /docker/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | function install { 4 | echo -e "Installing Linguard..." 5 | # Include hidden files (.*) 6 | shopt -s dotglob 7 | # Move all files to exported path 8 | mv "$DATA_PATH"/* "$EXPORTED_PATH" 9 | } 10 | 11 | function run { 12 | echo -e "Running Linguard..." 13 | # Link conf files to install path 14 | rm -rf "$DATA_PATH" 15 | ln -s "$EXPORTED_PATH" "$DATA_PATH" 16 | chown -R linguard:linguard "$DATA_PATH" 17 | chown -R linguard:linguard "$EXPORTED_PATH" 18 | # Start uwsgi 19 | ls -l "$EXPORTED_PATH" 20 | sudo -u linguard /usr/bin/uwsgi --yaml "$DATA_PATH/uwsgi.yaml" 21 | } 22 | 23 | flag_file="$EXPORTED_PATH/.times_ran" 24 | count=1 25 | if [ ! -f "$flag_file" ]; then 26 | install 27 | else 28 | count=$(cat "$flag_file") 29 | let "count++" 30 | fi 31 | echo "$count" > "$flag_file" 32 | run -------------------------------------------------------------------------------- /linguard/web/client.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta, datetime 2 | from ipaddress import IPv4Address 3 | from threading import Thread 4 | from time import sleep 5 | from typing import Dict 6 | 7 | from linguard.core.config.web import config 8 | 9 | 10 | class Client: 11 | def __init__(self, ip: IPv4Address): 12 | self.ip = ip 13 | self.login_attempts = 0 14 | self.banned_until = datetime.now() 15 | 16 | @staticmethod 17 | def __sleep__(): 18 | sleep(config.login_ban_time) 19 | 20 | def ban(self): 21 | self.login_attempts = 0 22 | self.banned_until = datetime.now() + timedelta(seconds=config.login_ban_time) 23 | Thread(target=self.__sleep__, daemon=True).start() 24 | 25 | def is_banned(self): 26 | return self.banned_until > datetime.now() 27 | 28 | 29 | clients: Dict[IPv4Address, Client] = {} 30 | -------------------------------------------------------------------------------- /linguard/common/utils/encryption.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import random 3 | 4 | from cryptography.fernet import Fernet 5 | 6 | import string 7 | 8 | 9 | class CryptoUtils: 10 | 11 | __magic = "Alonso" 12 | KEY_LEN = 32 13 | 14 | def __init__(self): 15 | self.__magic = "Alonso" 16 | 17 | def encrypt(self, data: bytes, key: str) -> bytes: 18 | return self.__magic.encode() + Fernet(base64.b64encode(key.encode())).encrypt(data) 19 | 20 | def decrypt(self, data: bytes, key: str) -> bytes: 21 | return Fernet(base64.b64encode(key.encode())).decrypt(data[len(self.__magic):]) 22 | 23 | def is_encrypted(self, data: bytes): 24 | return data[:len(self.__magic)] != self.__magic 25 | 26 | @classmethod 27 | def generate_key(cls) -> str: 28 | return ''.join(random.choices(string.ascii_letters + string.digits + string.punctuation, k=cls.KEY_LEN)) 29 | -------------------------------------------------------------------------------- /linguard/web/templates/error/error-main.html: -------------------------------------------------------------------------------- 1 | {% extends 'error/error-base.html' %} 2 | 3 | {% block content %} 4 |
5 |
6 |
7 |
8 |
9 |

{{ error_code }}

10 |

{{ error_msg }}

11 | {% block additional_info %} 12 | {% endblock %} 13 |
14 | 15 | 16 | Return to Dashboard 17 | 18 |
19 |
20 |
21 |
22 |
23 | {% endblock %} -------------------------------------------------------------------------------- /linguard/core/managers/wireguard.py: -------------------------------------------------------------------------------- 1 | from logging import info 2 | 3 | from linguard.core.exceptions import WireguardError 4 | from linguard.core.models import interfaces 5 | 6 | 7 | class WireguardManager: 8 | 9 | @staticmethod 10 | def start(): 11 | info("Starting VPN server...") 12 | for iface in interfaces.values(): 13 | if not iface.auto: 14 | continue 15 | try: 16 | iface.up() 17 | except WireguardError: 18 | pass 19 | info("VPN server started.") 20 | 21 | @staticmethod 22 | def stop(): 23 | info("Stopping VPN server...") 24 | for iface in interfaces.values(): 25 | try: 26 | iface.down() 27 | except WireguardError: 28 | pass 29 | info("VPN server stopped.") 30 | 31 | 32 | wireguard_manager = WireguardManager() 33 | -------------------------------------------------------------------------------- /docs/source/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | As a systemd service 5 | -------------------- 6 | 7 | 1. Download `any release `__. 8 | 2. Extract it and run the installation script: 9 | 10 | .. code-block:: bash 11 | 12 | chmod +x install.sh 13 | sudo ./install.sh 14 | 3. Run Linguard: 15 | 16 | .. code-block:: bash 17 | 18 | sudo systemctl start linguard.service 19 | 20 | Using docker 21 | ------------ 22 | 23 | 1. Download the ``docker-compose.yaml`` file `from the repository `__. 24 | 2. Run Linguard: 25 | 26 | .. code-block:: bash 27 | 28 | sudo docker-compose up -d 29 | 30 | .. note:: 31 | You can check all available tags `here `__. 32 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.https://www.sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "linguard" 3 | version = "1.1.0" 4 | description = "Wireguard management made simple" 5 | authors = ["Jose "] 6 | license = "GPL-3.0" 7 | homepage = "https://github.com/joseantmazonsb/linguard" 8 | repository = "https://github.com/joseantmazonsb/linguard" 9 | keywords = ["wireguard", "gui", "webserver", "flask", "vpn"] 10 | 11 | [tool.poetry.dependencies] 12 | python = "^3.7" 13 | coolname = "^1.1.0" 14 | cryptography = "^3.4.8" 15 | Faker = "^8.12.1" 16 | Flask = "^2.0.1" 17 | Flask-Login = "^0.5.0" 18 | Flask-WTF = "^0.15.1" 19 | PyYAML = "^5.4.1" 20 | schedule = "^1.1.0" 21 | WTForms = "^2.3.3" 22 | yamlable = "^1.0.4" 23 | Flask-QRcode = "^3.0.0" 24 | 25 | [tool.poetry.dev-dependencies] 26 | pytest = "^6.2.5" 27 | coverage = "^5.5" 28 | Sphinx = "^4.2.0" 29 | sphinx-rtd-theme = "^1.0.0" 30 | 31 | [tool.coverage.run] 32 | omit = ["*/site-packages/*"] 33 | 34 | [build-system] 35 | requires = ["poetry-core>=1.0.0"] 36 | build-backend = "poetry.core.masonry.api" 37 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: 'bug' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Browser [e.g. chrome, safari] 29 | - Version [e.g. 22] 30 | 31 | **Smartphone (please complete the following information):** 32 | - Device: [e.g. iPhone6] 33 | - OS: [e.g. iOS8.1] 34 | - Browser [e.g. stock browser, safari] 35 | - Version [e.g. 22] 36 | 37 | **Additional context** 38 | Add any other context about the problem here. 39 | -------------------------------------------------------------------------------- /linguard/common/utils/network.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import List, Dict, Any 3 | 4 | from linguard.common.utils.strings import list_to_str 5 | from linguard.common.utils.system import Command 6 | from linguard.web.static.assets.resources import EMPTY_FIELD 7 | 8 | 9 | def get_system_interfaces() -> Dict[str, Any]: 10 | ifaces = {} 11 | for iface in json.loads(Command("ip -json address").run().output): 12 | ifaces[iface["ifname"]] = iface 13 | return ifaces 14 | 15 | 16 | def get_default_gateway() -> str: 17 | return Command("ip route | head -1 | xargs | cut -d ' ' -f 5").run().output 18 | 19 | 20 | def get_routing_table() -> List[Dict[str, Any]]: 21 | table = json.loads(Command("ip -json route").run().output) 22 | for entry in table: 23 | for key in entry: 24 | value = entry[key] 25 | if not value: 26 | entry[key] = EMPTY_FIELD 27 | elif isinstance(value, list): 28 | entry[key] = list_to_str(value) 29 | return table 30 | -------------------------------------------------------------------------------- /linguard/core/managers/traffic_storage.py: -------------------------------------------------------------------------------- 1 | from logging import warning 2 | from typing import Dict 3 | 4 | from schedule import repeat, every 5 | 6 | from linguard.core.config.traffic import config 7 | from linguard.core.drivers.traffic_storage_driver import TrafficStorageDriver 8 | from linguard.core.drivers.traffic_storage_driver_json import TrafficStorageDriverJson 9 | 10 | 11 | @repeat(every(1).hours) 12 | def __update_data__(): 13 | if not config.enabled: 14 | warning("Traffic's data collection is disabled!") 15 | return 16 | config.driver.save_data() 17 | 18 | 19 | registered_drivers: Dict[str, TrafficStorageDriver] = {} 20 | 21 | 22 | def register_driver(driver: TrafficStorageDriver): 23 | name = driver.get_name() 24 | if name not in registered_drivers.keys(): 25 | registered_drivers[name] = driver 26 | 27 | 28 | def unregister_driver(name: str): 29 | if name in registered_drivers.keys(): 30 | del registered_drivers[name] 31 | 32 | 33 | register_driver(TrafficStorageDriverJson()) 34 | -------------------------------------------------------------------------------- /linguard/core/utils/wireguard.py: -------------------------------------------------------------------------------- 1 | from linguard.common.utils.system import Command 2 | from linguard.core.exceptions import WireguardError 3 | 4 | 5 | def is_wg_iface_up(iface_name: str) -> bool: 6 | from linguard.core.config.wireguard import config 7 | return Command(f"{config.wg_bin} show {iface_name}").run_as_root().successful 8 | 9 | 10 | def generate_privkey() -> str: 11 | from linguard.core.config.wireguard import config 12 | result = Command(f"{config.wg_bin} genkey").run_as_root() 13 | if not result.successful: 14 | raise WireguardError(result.err) 15 | return result.output 16 | 17 | 18 | def generate_pubkey(privkey: str) -> str: 19 | from linguard.core.config.wireguard import config 20 | result = Command(f"echo {privkey} | sudo {config.wg_bin} pubkey").run() 21 | if not result.successful: 22 | raise WireguardError(result.err) 23 | return result.output 24 | 25 | 26 | def get_wg_interface_status(name: str) -> str: 27 | if is_wg_iface_up(name): 28 | return "up" 29 | return "down" 30 | -------------------------------------------------------------------------------- /scripts/log.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | bold=$(tput bold) 4 | default=$(tput sgr0) 5 | cyan=$(tput setaf 6) 6 | red=$(tput setaf 1) 7 | yellow=$(tput setaf 3) 8 | dark_gray=$(tput setaf 8) 9 | 10 | function log() { 11 | level=$1 12 | if [ $# -gt 2 ]; then 13 | options=$2 14 | msg=$3 15 | else 16 | options="" 17 | msg=$2 18 | fi 19 | case $level in 20 | DEBUG* ) echo -e $options "${dark_gray}${bold}[DEBUG]${default} ${dark_gray}$msg${default}";; 21 | INFO* ) echo -e $options "${cyan}${bold}[INFO]${default} ${cyan}$msg${default}";; 22 | WARN* ) echo -e $options "${yellow}${bold}[WARN]${default} ${yellow}$msg${default}";; 23 | ERROR* ) echo -e $options "${red}${bold}[ERROR]${default} ${red}$msg${default}";; 24 | FATAL* ) echo -e $options "${red}${bold}[FATAL] $msg${default}";; 25 | * ) echo "Invalid log level: '$level'";; 26 | esac 27 | } 28 | 29 | function debug() { 30 | log DEBUG "$@" 31 | } 32 | 33 | function info() { 34 | log INFO "$@" 35 | } 36 | 37 | function warn() { 38 | log WARN "$@" 39 | } 40 | 41 | function err() { 42 | log ERROR "$@" 43 | } 44 | 45 | function fatal() { 46 | log FATAL "$@" 47 | } -------------------------------------------------------------------------------- /linguard/web/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | {% block additional_metas %} 12 | {% endblock %} 13 | 14 | 15 | {{ app_name }} | {{ title }} 16 | 17 | 18 | 19 | 20 | {% block additional_styles %} 21 | {% endblock %} 22 | 23 | 24 | {% block additional_prioritary_scripts %} 25 | {% endblock %} 26 | 27 | 28 | {% block body_content %} 29 | {% endblock %} 30 | 31 | {% block footer %} 32 | {% endblock %} 33 | 34 | {% block scripts %} 35 | {% endblock %} 36 | 37 | 38 | -------------------------------------------------------------------------------- /linguard/web/templates/web/top-nav.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /linguard/common/models/encrypted_yamlable.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Type 3 | 4 | from yamlable import YamlAble, Y 5 | 6 | from linguard.common.utils.encryption import CryptoUtils 7 | from linguard.common.utils.system import try_makedir 8 | 9 | 10 | class EncryptedYamlAble(YamlAble): 11 | 12 | def save(self, filepath: str, encryption_key: str): 13 | """ 14 | Save the current instance as an encrypted yaml file. 15 | 16 | :param encryption_key: 17 | :param filepath: 18 | :return: 19 | """ 20 | data = CryptoUtils().encrypt(self.dumps_yaml().encode(), encryption_key) 21 | path = os.path.abspath(filepath) 22 | try_makedir(os.path.dirname(path)) 23 | with open(path, "wb") as file: 24 | file.write(data) 25 | 26 | @classmethod 27 | def load(cls: Type[Y], filepath: str, encryption_key: str) -> Y: 28 | """ 29 | Read an encrypted yaml file and return a serialized instance of this class. 30 | 31 | :param encryption_key: 32 | :param filepath: 33 | :return: 34 | """ 35 | with open(filepath, "rb") as file: 36 | yaml_str = CryptoUtils().decrypt(file.read(), encryption_key).decode() 37 | return cls.loads_yaml(yaml_str) -------------------------------------------------------------------------------- /linguard/core/managers/cron.py: -------------------------------------------------------------------------------- 1 | from logging import warning, info 2 | from threading import Thread, Event 3 | 4 | import schedule 5 | 6 | 7 | class CronManager: 8 | 9 | def __init__(self): 10 | self.started = False 11 | self.exit = Event() 12 | 13 | def start(self): 14 | if self.started: 15 | warning("Already started!") 16 | return 17 | info(f"Starting cron manager...") 18 | self.started = True 19 | self.exit.clear() 20 | Thread(target=self.__run_jobs__, daemon=True).start() 21 | info(f"Cron manager started.") 22 | 23 | def __run_jobs__(self): 24 | while not self.exit.is_set(): 25 | n = schedule.idle_seconds() 26 | if n is None: 27 | self.exit.wait(30) 28 | elif n > 0: 29 | self.exit.wait(n) 30 | schedule.run_pending() 31 | 32 | def stop(self): 33 | if not self.started: 34 | warning("Not running!") 35 | return 36 | info(f"Stopping cron manager...") 37 | self.started = False 38 | self.exit.set() 39 | for job in schedule.jobs: 40 | schedule.cancel_job(job) 41 | info(f"Cron manager stopped.") 42 | 43 | 44 | cron_manager = CronManager() 45 | -------------------------------------------------------------------------------- /linguard/tests/default/test_profile.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from flask_login import current_user 3 | 4 | from linguard.tests.utils import default_cleanup, is_http_success, login, exists_credentials_file, get_testing_app 5 | 6 | url = "/profile" 7 | 8 | 9 | @pytest.fixture(autouse=True) 10 | def cleanup(): 11 | yield 12 | default_cleanup() 13 | 14 | 15 | @pytest.fixture 16 | def client(): 17 | with get_testing_app().test_client() as client: 18 | yield client 19 | 20 | 21 | def test_get(client): 22 | login(client) 23 | 24 | response = client.get(url) 25 | assert is_http_success(response.status_code) 26 | assert current_user.name.encode() in response.data 27 | 28 | 29 | def test_post_ok(client): 30 | login(client) 31 | 32 | new_username = "root" 33 | response = client.post(url, data={"username": new_username}) 34 | assert is_http_success(response.status_code) 35 | assert b"alert-danger" not in response.data 36 | assert current_user.name == new_username 37 | assert exists_credentials_file() 38 | 39 | 40 | def test_post_ko(client): 41 | login(client) 42 | 43 | response = client.post(url, data={"username": ""}) 44 | assert is_http_success(response.status_code) 45 | assert b"alert-danger" in response.data 46 | assert not exists_credentials_file() 47 | -------------------------------------------------------------------------------- /linguard/web/static/assets/demo/chart-bar-demo.js: -------------------------------------------------------------------------------- 1 | // Set new default font family and font color to mimic Bootstrap's default styling 2 | Chart.defaults.global.defaultFontFamily = '-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif'; 3 | Chart.defaults.global.defaultFontColor = '#292b2c'; 4 | 5 | // Bar Chart Example 6 | var ctx = document.getElementById("myBarChart"); 7 | var myLineChart = new Chart(ctx, { 8 | type: 'bar', 9 | data: { 10 | labels: ["January", "February", "March", "April", "May", "June"], 11 | datasets: [{ 12 | label: "Revenue", 13 | backgroundColor: "rgba(2,117,216,1)", 14 | borderColor: "rgba(2,117,216,1)", 15 | data: [4215, 5312, 6251, 7841, 9821, 14984], 16 | }], 17 | }, 18 | options: { 19 | scales: { 20 | xAxes: [{ 21 | time: { 22 | unit: 'month' 23 | }, 24 | gridLines: { 25 | display: false 26 | }, 27 | ticks: { 28 | maxTicksLimit: 6 29 | } 30 | }], 31 | yAxes: [{ 32 | ticks: { 33 | min: 0, 34 | max: 15000, 35 | maxTicksLimit: 5 36 | }, 37 | gridLines: { 38 | display: true 39 | } 40 | }], 41 | }, 42 | legend: { 43 | display: false 44 | } 45 | } 46 | }); 47 | -------------------------------------------------------------------------------- /linguard/tests/default/test_dashboard.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from linguard.core.models import interfaces, get_all_peers, Peer 4 | from linguard.tests.utils import default_cleanup, is_http_success, login, create_test_iface, get_testing_app 5 | 6 | url = "/dashboard" 7 | 8 | 9 | @pytest.fixture(autouse=True) 10 | def cleanup(): 11 | yield 12 | default_cleanup() 13 | 14 | 15 | @pytest.fixture 16 | def client(): 17 | with get_testing_app().test_client() as client: 18 | yield client 19 | 20 | 21 | def test_get(client): 22 | login(client) 23 | iface1 = create_test_iface("iface1", "10.0.0.1/24", 50000) 24 | iface2 = create_test_iface("iface2", "10.0.1.1/24", 50001) 25 | peer1 = Peer(name="peer1", description="", ipv4_address="10.0.0.2/24", nat=False, interface=iface1, dns1="8.8.8.8") 26 | peer2 = Peer(name="peer2", description="", ipv4_address="10.0.1.2/24", nat=False, interface=iface2, dns1="8.8.8.8") 27 | iface1.add_peer(peer1) 28 | iface2.add_peer(peer2) 29 | interfaces[iface1.uuid] = iface1 30 | interfaces[iface2.uuid] = iface2 31 | response = client.get(url) 32 | assert is_http_success(response.status_code) 33 | for iface in interfaces.values(): 34 | assert iface.name.encode() in response.data 35 | for peer in get_all_peers().values(): 36 | assert peer.name.encode() in response.data 37 | -------------------------------------------------------------------------------- /linguard/web/templates/footer.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /linguard/web/static/js/modules/dashboard.mjs: -------------------------------------------------------------------------------- 1 | import {prependAlert} from "./utils.mjs"; 2 | 3 | const removeIfaceBtn = $(".removeIfaceBtn"); 4 | removeItem(removeIfaceBtn, "interface"); 5 | 6 | const removePeerBtn = $(".removePeerBtn"); 7 | removeItem(removePeerBtn, "peer"); 8 | 9 | function removeItem(removeBtn, itemType) { 10 | removeBtn.click(function (e) { 11 | const item = e.target.id.split("-")[1]; 12 | const url = "/wireguard/"+itemType+"s/"+item+""; 13 | const alertContainer = "global_alerts"; 14 | const alertType = "danger"; 15 | $.ajax({ 16 | type: "delete", 17 | url: url, 18 | success: function () { 19 | location.reload(); 20 | }, 21 | error: function(resp) { 22 | prependAlert(alertContainer, "Oops, something went wrong: " + resp["responseText"], 23 | alertType); 24 | $("#removeModal").modal("toggle"); 25 | }, 26 | }); 27 | }); 28 | } 29 | 30 | const downloadBtn = $(".downloadBtn"); 31 | downloadBtn.click(function (e) { 32 | let item = e.target.id.split("-")[1]; 33 | if (!item) { 34 | item = e.target.farthestViewportElement.id.split("-")[1]; 35 | } 36 | const url = "/wireguard/peers/"+item+"/download"; 37 | location.replace(url); 38 | }); -------------------------------------------------------------------------------- /linguard/web/static/js/modules/wireguard-peer.mjs: -------------------------------------------------------------------------------- 1 | import {prependAlert} from "./utils.mjs"; 2 | 3 | const alertContainer = "alerts"; 4 | 5 | document.getElementById('private_key').setAttribute('type', "password"); 6 | 7 | document.getElementById("togglePrivateKey").addEventListener("click", function () { 8 | const icon = document.getElementById('togglePrivateKeyIcon') 9 | const field = document.getElementById('private_key'); 10 | const type = field.getAttribute('type') === 'password' ? 'text' : 'password'; 11 | field.setAttribute('type', type); 12 | if (type === "password") { 13 | icon.classList.add('fa-eye-slash'); 14 | icon.classList.remove('fa-eye'); 15 | } 16 | else { 17 | icon.classList.add('fa-eye'); 18 | icon.classList.remove('fa-eye-slash'); 19 | } 20 | }, false); 21 | 22 | document.getElementById("removePeer").addEventListener("click", function () { 23 | const alertType = "danger"; 24 | $.ajax({ 25 | type: "delete", 26 | url: location.href, 27 | success: function (resp) { 28 | location.replace("/wireguard"); 29 | }, 30 | error: function(resp) { 31 | prependAlert(alertContainer, "Oops, something went wrong: " + resp["responseText"], 32 | alertType); 33 | $("#removeModal").modal("toggle"); 34 | }, 35 | }); 36 | }); -------------------------------------------------------------------------------- /linguard/common/models/enhanced_dict.py: -------------------------------------------------------------------------------- 1 | from typing import Mapping, Dict, TypeVar 2 | 3 | K = TypeVar('K') 4 | V = TypeVar('V') 5 | 6 | 7 | class EnhancedDict(Dict, Mapping[K, V]): 8 | 9 | def set_contents(self, dct: "EnhancedDict"): 10 | """ 11 | Clear the dictionary and fill it with the values of the given one. 12 | 13 | :param dct: 14 | :return: 15 | """ 16 | self.clear() 17 | self.update(dct) 18 | 19 | def sort(self, order_by): 20 | self.set_contents(EnhancedDict(sorted(self.items(), key=order_by))) 21 | 22 | def get_key_by_attr(self, attr: str, attr_value: str) -> K: 23 | """ 24 | Get the first key (or None) of the dictionary which contains an attribute whose value is equal to attr_value. 25 | 26 | :param attr: Attribute to compare. 27 | :param attr_value: Value to compare. 28 | :return: The first matching key or None. 29 | """ 30 | return next(iter(filter(lambda k: k.__getattribute__(attr) == attr_value, self.keys())), None) 31 | 32 | def get_value_by_attr(self, attr: str, attr_value: str) -> V: 33 | """ 34 | Get the first value (or None) of the dictionary which contains an attribute whose value is equal to attr_value. 35 | 36 | :param attr: Attribute to compare. 37 | :param attr_value: Value to compare. 38 | :return: The first matching value or None. 39 | """ 40 | return next(iter(filter(lambda v: v.__getattribute__(attr) == attr_value, self.values())), None) -------------------------------------------------------------------------------- /linguard/core/config/traffic.py: -------------------------------------------------------------------------------- 1 | from yamlable import yaml_info 2 | 3 | from linguard.core.config.base import BaseConfig 4 | from linguard.core.drivers.traffic_storage_driver import TrafficStorageDriver 5 | from linguard.core.drivers.traffic_storage_driver_json import TrafficStorageDriverJson 6 | 7 | 8 | @yaml_info(yaml_tag='traffic') 9 | class TrafficConfig(BaseConfig): 10 | enabled: bool 11 | driver: TrafficStorageDriver 12 | 13 | def __init__(self): 14 | super().__init__() 15 | self.load_defaults() 16 | 17 | def load_defaults(self): 18 | self.enabled = True 19 | self.driver = TrafficStorageDriverJson() 20 | 21 | def load(self, config: "TrafficConfig"): 22 | self.enabled = config.enabled 23 | self.driver = config.driver 24 | 25 | def __to_yaml_dict__(self): # type: (...) -> Dict[str, Any] 26 | return { 27 | "enabled": self.enabled, 28 | "driver": self.driver, 29 | } 30 | 31 | @classmethod 32 | def __from_yaml_dict__(cls, # type: Type[Y] 33 | dct, # type: Dict[str, Any] 34 | yaml_tag="" 35 | ): # type: (...) -> Y 36 | config = TrafficConfig() 37 | enabled = config.enabled 38 | config.enabled = dct.get("enabled", None) 39 | if config.enabled is None: 40 | config.enabled = enabled 41 | config.driver = dct.get("driver", None) or config.driver 42 | return config 43 | 44 | 45 | config = TrafficConfig() 46 | -------------------------------------------------------------------------------- /docs/source/screenshots.rst: -------------------------------------------------------------------------------- 1 | How does it look? 2 | ================= 3 | 4 | Here are a bunch of screenshots: 5 | 6 | .. figure:: images/dashboard-1.png 7 | :alt: Dashboard (1) 8 | 9 | Dashboard (1) 10 | 11 | .. figure:: images/dashboard-2.png 12 | :alt: Dashboard (2) 13 | 14 | Dashboard (2) 15 | 16 | .. figure:: images/network-section-1.png 17 | :alt: Network (1) 18 | 19 | Network interfaces 20 | 21 | .. figure:: images/network-section-2.png 22 | :alt: Network (1) 23 | 24 | Routing information 25 | 26 | .. figure:: images/wireguard-section-1.png 27 | :alt: Wireguard section (1) 28 | 29 | Wireguard interfaces 30 | 31 | .. figure:: images/wireguard-section-2.png 32 | :alt: Wireguard section (2) 33 | 34 | Wireguard peers 35 | 36 | .. figure:: images/wireguard-edit-1.png 37 | :alt: Edit wireguard interface configuration (1) 38 | 39 | Interface's actions and configuration 40 | 41 | .. figure:: images/wireguard-edit-2.png 42 | :alt: Edit wireguard interface configuration (2) 43 | 44 | Interface's peers and traffic data (1) 45 | 46 | .. figure:: images/wireguard-edit-3.png 47 | :alt: Edit wireguard interface configuration (3) 48 | 49 | Interface's peers and traffic data (2) 50 | 51 | .. figure:: images/peer-edit-1.png 52 | :alt: Edit wireguard peer configuration (1) 53 | 54 | Peer's configuration 55 | 56 | .. figure:: images/peer-edit-2.png 57 | :alt: Edit wireguard peer configuration (2) 58 | 59 | Peer's traffic data 60 | 61 | .. figure:: images/settings-1.png 62 | :alt: Settings (1) 63 | 64 | Settings (1) 65 | 66 | .. figure:: images/settings-2.png 67 | :alt: Settings (2) 68 | 69 | Settings (2) -------------------------------------------------------------------------------- /linguard/web/static/assets/demo/chart-area-demo.js: -------------------------------------------------------------------------------- 1 | // Set new default font family and font color to mimic Bootstrap's default styling 2 | Chart.defaults.global.defaultFontFamily = '-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif'; 3 | Chart.defaults.global.defaultFontColor = '#292b2c'; 4 | 5 | // Area Chart Example 6 | var ctx = document.getElementById("myAreaChart"); 7 | var myLineChart = new Chart(ctx, { 8 | type: 'line', 9 | data: { 10 | labels: ["Mar 1", "Mar 2", "Mar 3", "Mar 4", "Mar 5", "Mar 6", "Mar 7", "Mar 8", "Mar 9", "Mar 10", "Mar 11", "Mar 12", "Mar 13"], 11 | datasets: [{ 12 | label: "Sessions", 13 | lineTension: 0.3, 14 | backgroundColor: "rgba(2,117,216,0.2)", 15 | borderColor: "rgba(2,117,216,1)", 16 | pointRadius: 5, 17 | pointBackgroundColor: "rgba(2,117,216,1)", 18 | pointBorderColor: "rgba(255,255,255,0.8)", 19 | pointHoverRadius: 5, 20 | pointHoverBackgroundColor: "rgba(2,117,216,1)", 21 | pointHitRadius: 50, 22 | pointBorderWidth: 2, 23 | data: [10000, 30162, 26263, 18394, 18287, 28682, 31274, 33259, 25849, 24159, 32651, 31984, 38451], 24 | }], 25 | }, 26 | options: { 27 | scales: { 28 | xAxes: [{ 29 | time: { 30 | unit: 'date' 31 | }, 32 | gridLines: { 33 | display: false 34 | }, 35 | ticks: { 36 | maxTicksLimit: 7 37 | } 38 | }], 39 | yAxes: [{ 40 | ticks: { 41 | min: 0, 42 | max: 40000, 43 | maxTicksLimit: 5 44 | }, 45 | gridLines: { 46 | color: "rgba(0, 0, 0, .125)", 47 | } 48 | }], 49 | }, 50 | legend: { 51 | display: false 52 | } 53 | } 54 | }); 55 | -------------------------------------------------------------------------------- /linguard/common/utils/system.py: -------------------------------------------------------------------------------- 1 | import os 2 | from logging import debug, error 3 | from subprocess import run, PIPE 4 | 5 | 6 | class CommandResult: 7 | """Represents the result of a command execution.""" 8 | 9 | def __init__(self, code: int, output: str, err: str): 10 | self.code = code 11 | self.output = output 12 | self.err = err 13 | self.successful = (code < 1) 14 | 15 | 16 | class Command: 17 | """ 18 | Represents an interface to interact with a binary, executable file. 19 | """ 20 | 21 | def __init__(self, cmd): 22 | self.cmd = cmd 23 | 24 | def run(self, as_root: bool = False) -> CommandResult: 25 | """ 26 | Execute the command and return information about the execution. 27 | :param as_root: Run the command as root (using sudo) 28 | :return: A CommandResult object containing information about how the execution went. 29 | """ 30 | cmd = self.cmd 31 | if as_root: 32 | cmd = f"sudo {cmd}" 33 | debug(f"Running '{cmd}'...") 34 | proc = run(cmd, shell=True, check=False, stdout=PIPE, stderr=PIPE) 35 | result = CommandResult(proc.returncode, proc.stdout.decode('utf-8').strip(), 36 | proc.stderr.decode('utf-8').strip()) 37 | if not result.successful: 38 | error(f"Failed to run '{cmd}': err={result.err} | out={result.output} | code={result.code}") 39 | return result 40 | 41 | def run_as_root(self) -> CommandResult: 42 | return self.run(True) 43 | 44 | 45 | def try_makedir(path: str): 46 | try: 47 | os.makedirs(path) 48 | debug(f"Created folder ({path})...") 49 | except FileExistsError: 50 | pass 51 | except Exception as e: 52 | error(f"Unable to create folder: {e}.") 53 | raise 54 | -------------------------------------------------------------------------------- /linguard/web/templates/web/side-nav.html: -------------------------------------------------------------------------------- 1 |
2 | 38 |
-------------------------------------------------------------------------------- /config/linguard.sample.yaml: -------------------------------------------------------------------------------- 1 | wireguard: !yamlable/wireguard 2 | endpoint: vpn.example.com 3 | interfaces: !yamlable/interfaces 4 | 39a855187c4c4ca694d8c3f215e76cdc: !yamlable/interface 5 | auto: true 6 | description: 'vpn for scranton branch' 7 | gw_iface: eth0 8 | ipv4_address: 10.0.0.2/24 9 | listen_port: '52123' 10 | name: scranton-vpn 11 | on_down: 12 | - /usr/sbin/iptables -D FORWARD -i tough-moth -j ACCEPT 13 | - /usr/sbin/iptables -D FORWARD -o tough-moth -j ACCEPT 14 | - /usr/sbin/iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE 15 | on_up: 16 | - /usr/sbin/iptables -I FORWARD -i tough-moth -j ACCEPT 17 | - /usr/sbin/iptables -I FORWARD -o tough-moth -j ACCEPT 18 | - /usr/sbin/iptables -t nat -I POSTROUTING -o eth0 -j MASQUERADE 19 | peers: !yamlable/peers 20 | 9ea085f97fdd4022a16df6f0a70b24db: !yamlable/peer 21 | description: '' 22 | dns1: 8.8.8.8 23 | dns2: '' 24 | ipv4_address: 10.0.0.3/24 25 | name: jim halpert 26 | nat: true 27 | private_key: KMLxYphCvI4joTyrf3Dp9Yg1vLUj+b8SjLFrUeYnCk0= 28 | public_key: mvtz+LwXkJur9BkjFmBDOzdE5tacePRfBTFSX4OwmgU= 29 | uuid: 9ea085f97fdd4022a16df6f0a70b24db 30 | private_key: 4Ob32NhyhwIXbj7od6L5ASWle3vbOjn9g80at/dxPm0= 31 | public_key: 7NTAVIucHQJsS9Z8eT3lUV8VUoTSFrB6J272F9ox5RQ= 32 | uuid: 39a855187c4c4ca694d8c3f215e76cdc 33 | iptables_bin: /usr/sbin/iptables 34 | wg_bin: /usr/bin/wg 35 | wg_quick_bin: /usr/bin/wg-quick 36 | logger: !yamlable/logger 37 | level: info 38 | overwrite: false 39 | traffic: !yamlable/traffic 40 | driver: !yamlable/traffic_storage_driver_json 41 | timestamp_format: '%d/%m/%Y %H:%M:%S' 42 | enabled: true 43 | web: !yamlable/web 44 | login_attempts: 0 45 | secret_key: G,XhSEIu{>TFu$y?4-kMy+. All Rights Reserved. 5 | # https://git.zx2c4.com/wireguard-tools/tree/contrib/json 6 | 7 | exec < <(exec sudo wg show all dump) 8 | printf '{' 9 | while read -r -d $'\t' device; do 10 | if [[ $device != "$last_device" ]]; then 11 | [[ -z $last_device ]] && printf '\n' || printf '%s,\n' "$end" 12 | last_device="$device" 13 | read -r private_key public_key listen_port fwmark 14 | printf '\t"%s": {' "$device" 15 | delim=$'\n' 16 | [[ $private_key == "(none)" ]] || { printf '%s\t\t"privateKey": "%s"' "$delim" "$private_key"; delim=$',\n'; } 17 | [[ $public_key == "(none)" ]] || { printf '%s\t\t"publicKey": "%s"' "$delim" "$public_key"; delim=$',\n'; } 18 | [[ $listen_port == "0" ]] || { printf '%s\t\t"listenPort": %u' "$delim" $(( $listen_port )); delim=$',\n'; } 19 | [[ $fwmark == "off" ]] || { printf '%s\t\t"fwmark": %u' "$delim" $(( $fwmark )); delim=$',\n'; } 20 | printf '%s\t\t"peers": {' "$delim"; end=$'\n\t\t}\n\t}' 21 | delim=$'\n' 22 | else 23 | read -r public_key preshared_key endpoint allowed_ips latest_handshake transfer_rx transfer_tx persistent_keepalive 24 | printf '%s\t\t\t"%s": {' "$delim" "$public_key" 25 | delim=$'\n' 26 | [[ $preshared_key == "(none)" ]] || { printf '%s\t\t\t\t"presharedKey": "%s"' "$delim" "$preshared_key"; delim=$',\n'; } 27 | [[ $endpoint == "(none)" ]] || { printf '%s\t\t\t\t"endpoint": "%s"' "$delim" "$endpoint"; delim=$',\n'; } 28 | [[ $latest_handshake == "0" ]] || { printf '%s\t\t\t\t"latestHandshake": %u' "$delim" $(( $latest_handshake )); delim=$',\n'; } 29 | [[ $transfer_rx == "0" ]] || { printf '%s\t\t\t\t"transferRx": %u' "$delim" $(( $transfer_rx )); delim=$',\n'; } 30 | [[ $transfer_tx == "0" ]] || { printf '%s\t\t\t\t"transferTx": %u' "$delim" $(( $transfer_tx )); delim=$',\n'; } 31 | [[ $persistent_keepalive == "off" ]] || { printf '%s\t\t\t\t"persistentKeepalive": %u' "$delim" $(( $persistent_keepalive )); delim=$',\n'; } 32 | printf '%s\t\t\t\t"allowedIps": [' "$delim" 33 | delim=$'\n' 34 | if [[ $allowed_ips != "(none)" ]]; then 35 | old_ifs="$IFS" 36 | IFS=, 37 | for ip in $allowed_ips; do 38 | printf '%s\t\t\t\t\t"%s"' "$delim" "$ip" 39 | delim=$',\n' 40 | done 41 | IFS="$old_ifs" 42 | delim=$'\n' 43 | fi 44 | printf '%s\t\t\t\t]' "$delim" 45 | printf '\n\t\t\t}' 46 | delim=$',\n' 47 | fi 48 | 49 | 50 | done 51 | printf '%s\n' "$end" 52 | printf '}\n' 53 | -------------------------------------------------------------------------------- /linguard/web/static/js/libs/chartUtils.js: -------------------------------------------------------------------------------- 1 | Chart.defaults.font.family = '-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif'; 2 | Chart.defaults.color = '#292b2c'; 3 | 4 | function getRandomInt(min, max) { 5 | return Math.floor(Math.random() * (max - min)) + min; 6 | } 7 | 8 | class ChartUtils { 9 | 10 | /** 11 | * 12 | * @param nColors Desired amount of colors for the palette. 13 | * @param schemeType mono, triade, tetrade, analogic 14 | * @param variation default, soft, pastel, light, hard, pale 15 | * @returns {[]} 16 | */ 17 | getRandomColorPalette(nColors, schemeType = "triade", variation = "pastel") { 18 | const colors = []; 19 | let previousSeed = -1, seed = -1; 20 | const MAX_TRIES = 10; 21 | while (colors.length < nColors) { 22 | let tries = -1; 23 | while (tries < MAX_TRIES && previousSeed === seed) { 24 | seed = getRandomInt(0, 361); 25 | } 26 | let scheme = new ColorScheme; 27 | scheme.from_hue(seed) 28 | .scheme(schemeType) 29 | .variation(variation); 30 | for (let color of scheme.colors().slice(2)) { 31 | colors.push("#"+color); 32 | } 33 | } 34 | return colors; 35 | } 36 | 37 | filesizeCallback(value, max) { 38 | if (max > 1024*1024*1024*1024*1024) { 39 | return (value / 1024 / 1024 / 1024 / 1024 / 1024).toFixed(1) + " PB"; 40 | } 41 | if (max > 1024*1024*1024*1024) { 42 | return (value/1024/1024/1024/1024).toFixed(1) + " TB"; 43 | } 44 | if (max > 1024*1024*1024) { 45 | return (value/1024/1024/1024).toFixed(1) + " GB"; 46 | } 47 | if (max > 1024*1024) { 48 | return (value/1024/1024).toFixed(1) + " MB"; 49 | } 50 | if (max > 1024) { 51 | return (value / 1024).toFixed(1) + " KB"; 52 | } 53 | return (value) + " B"; 54 | } 55 | 56 | ticksFilesizeCallback(value, max) { 57 | return this.filesizeCallback(value, max); 58 | } 59 | 60 | tooltipFilesizeCallback(context) { 61 | let max = context.chart.scales.y.max; 62 | let value = context.dataset.data[context.dataIndex]; 63 | return this.filesizeCallback(value, max); 64 | } 65 | } 66 | let chartUtils = new ChartUtils(); 67 | 68 | if (typeof module === "object" && module.exports) { 69 | module.exports = chartUtils 70 | } 71 | -------------------------------------------------------------------------------- /.github/workflows/stable-deploy.yaml: -------------------------------------------------------------------------------- 1 | name: stable-deploy 2 | on: 3 | pull_request: 4 | types: [closed] 5 | branches: 6 | - main 7 | paths-ignore: 8 | - "docs/**" 9 | - "*.md" 10 | - "*-test.yaml" 11 | 12 | env: 13 | REGISTRY: ghcr.io 14 | IMAGE_NAME: ${{ github.repository }} 15 | 16 | jobs: 17 | deploy: 18 | if: github.event.pull_request.merged 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v2 22 | 23 | - name: Set up Python 3.9 24 | uses: actions/setup-python@v2 25 | with: 26 | python-version: 3.9 27 | 28 | - name: Upgrade pip 29 | run: | 30 | pip install --upgrade pip 31 | 32 | - name: Set up virtual environment using poetry 33 | run: | 34 | wget https://raw.githubusercontent.com/python-poetry/poetry/master/install-poetry.py 35 | python3 install-poetry.py 36 | poetry config virtualenvs.create false 37 | poetry install --no-interaction 38 | 39 | - name: Get version 40 | id: get_version 41 | run: | 42 | echo "::set-output name=version::$(poetry version -s)" 43 | 44 | - name: Build artifacts 45 | run: | 46 | ./build.sh 47 | 48 | - name: Set up QEMU 49 | uses: docker/setup-qemu-action@v1 50 | 51 | - name: Set up Docker Buildx 52 | uses: docker/setup-buildx-action@v1 53 | 54 | - name: Log in to the Container registry 55 | uses: docker/login-action@v1 56 | with: 57 | registry: ${{ env.REGISTRY }} 58 | username: ${{ github.actor }} 59 | password: ${{ secrets.GITHUB_TOKEN }} 60 | 61 | - name: Build and push Docker image 62 | uses: docker/build-push-action@v2 63 | with: 64 | context: . 65 | file: docker/Dockerfile 66 | tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.get_version.outputs.version }}, ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:stable, ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest 67 | platforms: "linux/amd64, linux/arm64" 68 | push: true 69 | 70 | - name: Create GH release 71 | uses: ncipollo/release-action@v1 72 | with: 73 | name: ${{ steps.get_version.outputs.version }} 74 | tag: "v${{ steps.get_version.outputs.version }}" 75 | commit: "main" 76 | artifacts: "dist/*.tar.gz" 77 | bodyFile: "release-notes.md" 78 | discussionCategory: "Announcements" 79 | token: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | db.sqlite3-journal 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # IPython 80 | profile_default/ 81 | ipython_config.py 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # pipenv 87 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 88 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 89 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 90 | # install all needed dependencies. 91 | #Pipfile.lock 92 | 93 | # celery beat schedule file 94 | celerybeat-schedule 95 | 96 | # SageMath parsed files 97 | *.sage.py 98 | 99 | # Environments 100 | .env 101 | .venv 102 | env/ 103 | venv/ 104 | ENV/ 105 | env.bak/ 106 | venv.bak/ 107 | 108 | # Spyder project settings 109 | .spyderproject 110 | .spyproject 111 | 112 | # Rope project settings 113 | .ropeproject 114 | 115 | # mkdocs documentation 116 | /site 117 | 118 | # mypy 119 | .mypy_cache/ 120 | .dmypy.yaml 121 | dmypy.yaml 122 | 123 | # Pyre type checker 124 | .pyre/ 125 | 126 | # Pycharm 127 | .idea/ 128 | 129 | uwsgi.yaml 130 | linguard.yaml 131 | credentials.yaml 132 | version.yaml 133 | logs/ 134 | traffic.* 135 | interfaces/ 136 | data/ 137 | __version__.py -------------------------------------------------------------------------------- /linguard/core/config/web.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Dict, Type 2 | 3 | from yamlable import yaml_info, Y 4 | 5 | from linguard.common.models.user import users 6 | from linguard.common.properties import global_properties 7 | from linguard.common.utils.encryption import CryptoUtils 8 | from linguard.core.config.base import BaseConfig 9 | 10 | 11 | @yaml_info(yaml_tag='web') 12 | class WebConfig(BaseConfig): 13 | MAX_PORT = 65535 14 | MIN_PORT = 1 15 | DEFAULT_LOGIN_ATTEMPTS = 0 16 | DEFAULT_BAN_SECONDS = 120 17 | CREDENTIALS_FILENAME = ".credentials" 18 | 19 | __secret_key: str 20 | login_attempts: int 21 | login_ban_time: int 22 | 23 | @property 24 | def secret_key(self): 25 | return self.__secret_key 26 | 27 | @secret_key.setter 28 | def secret_key(self, value: str): 29 | self.__secret_key = value 30 | 31 | @property 32 | def credentials_file(self): 33 | return global_properties.join_workdir(self.CREDENTIALS_FILENAME) 34 | 35 | def __init__(self): 36 | super().__init__() 37 | self.load_defaults() 38 | 39 | def load_defaults(self): 40 | self.login_attempts = self.DEFAULT_LOGIN_ATTEMPTS 41 | self.login_ban_time = self.DEFAULT_BAN_SECONDS 42 | self.__secret_key = CryptoUtils.generate_key() 43 | 44 | def load(self, config: "WebConfig"): 45 | self.login_attempts = config.login_attempts or self.login_attempts 46 | self.secret_key = config.secret_key or self.secret_key 47 | 48 | def __to_yaml_dict__(self): # type: (...) -> Dict[str, Any] 49 | return { 50 | "login_attempts": self.login_attempts, 51 | "login_ban_time": self.login_ban_time, 52 | "secret_key": self.secret_key 53 | } 54 | 55 | @classmethod 56 | def __from_yaml_dict__(cls, # type: Type[Y] 57 | dct, # type: Dict[str, Any] 58 | yaml_tag="" 59 | ): # type: (...) -> Y 60 | config = WebConfig() 61 | config.login_attempts = dct.get("login_attempts", None) or config.login_attempts 62 | config.login_ban_time = dct.get("login_ban_time", None) or config.login_ban_time 63 | config.secret_key = dct.get("secret_key", None) or config.secret_key 64 | return config 65 | 66 | def apply(self): 67 | super(WebConfig, self).apply() 68 | if not self.credentials_file or len(users) < 1: 69 | return 70 | users.save(self.credentials_file, self.__secret_key) 71 | 72 | 73 | config = WebConfig() 74 | -------------------------------------------------------------------------------- /scripts/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | source ./log.sh 4 | 5 | if [[ $EUID -ne 0 ]]; then 6 | fatal "This script must be run as superuser! Try using sudo." 7 | exit 1 8 | fi 9 | 10 | if [[ $# -gt 0 ]]; then 11 | fatal "Invalid arguments." 12 | info "Usage: $0" 13 | exit 1 14 | fi 15 | 16 | INSTALL_DIR="/var/www/linguard" 17 | 18 | info "Creating '$INSTALL_DIR'..." 19 | 20 | if [[ -d "$INSTALL_DIR" ]]; then 21 | while true; do 22 | warn -n "'$INSTALL_DIR' already exists. Shall I overwrite it? [y/n] " 23 | read yn 24 | case $yn in 25 | [Yy]* ) rm -rf "$INSTALL_DIR"; break;; 26 | [Nn]* ) 27 | info "Aborting..."; 28 | rm -rf "$ETC_DIR" 29 | exit;; 30 | * ) echo "Please answer yes or no.";; 31 | esac 32 | done 33 | fi 34 | mkdir -p "$INSTALL_DIR" 35 | cp -a linguard "$INSTALL_DIR" 36 | SOURCE_DIR="$INSTALL_DIR/linguard" 37 | DATA_DIR="$INSTALL_DIR/data" 38 | mkdir -p "$DATA_DIR" 39 | 40 | cp config/uwsgi.sample.yaml "$DATA_DIR/uwsgi.yaml" 41 | 42 | cp requirements.txt "$INSTALL_DIR" 43 | 44 | info "Installing dependencies..." 45 | debug "Updating packages list..." 46 | apt-get -qq update 47 | dependencies="sudo python3 python3-venv wireguard iptables libpcre3 libpcre3-dev uwsgi uwsgi-plugin-python3 iproute2" 48 | debug "The following packages will be installed: $dependencies" 49 | apt-get -qq install $dependencies 50 | if [ $? -ne 0 ]; then 51 | fatal "Unable to install dependencies." 52 | exit 1 53 | fi 54 | 55 | info "Setting up virtual environment..." 56 | python3 -m venv "$INSTALL_DIR/venv" 57 | source "$INSTALL_DIR/venv/bin/activate" 58 | if [ $? -ne 0 ]; then 59 | fatal "Unable to activate virtual environment." 60 | exit 1 61 | fi 62 | debug "Upgrading pip..." 63 | python3 -m pip install --upgrade pip 64 | debug "Installing python requirements..." 65 | python3 -m pip install -r "$INSTALL_DIR/requirements.txt" 66 | if [ $? -ne 0 ]; then 67 | fatal "Unable to install requirements." 68 | exit 1 69 | fi 70 | deactivate 71 | 72 | info "Settings permissions..." 73 | groupadd linguard 74 | useradd -g linguard linguard 75 | chown -R linguard:linguard "$INSTALL_DIR" 76 | chmod +x -R "$SOURCE_DIR/core/tools" 77 | echo "linguard ALL=(ALL) NOPASSWD: /usr/bin/wg" > /etc/sudoers.d/linguard 78 | echo "linguard ALL=(ALL) NOPASSWD: /usr/bin/wg-quick" >> /etc/sudoers.d/linguard 79 | 80 | info "Adding linguard service..." 81 | cp systemd/linguard.service /etc/systemd/system/ 82 | chmod 644 /etc/systemd/system/linguard.service 83 | 84 | info "All set! Run 'systemctl start linguard.service' to get started." -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | Linguard 2 | ======== 3 | 4 | .. image:: https://img.shields.io/github/license/joseantmazonsb/linguard 5 | :target: https://github.com/joseantmazonsb/linguard/blob/main/LICENSE.md 6 | :alt: License: GPL-3.0 7 | 8 | .. image:: https://img.shields.io/badge/python-3.7%20%7C%203.8%20%7C%203.9-blue?logo=python&logoColor=yellow 9 | :alt: Supported python versions: 3.7, 3.8, 3.9 10 | 11 | .. image:: https://github.com/joseantmazonsb/linguard/actions/workflows/stable-test.yaml/badge.svg 12 | :target: https://github.com/joseantmazonsb/linguard/actions/workflows/stable-test.yaml 13 | :alt: Stable workflow status 14 | 15 | .. image:: https://github.com/joseantmazonsb/linguard/actions/workflows/latest-test.yaml/badge.svg 16 | :target: https://github.com/joseantmazonsb/linguard/actions/workflows/latest-test.yaml 17 | :alt: Latest workflow status 18 | 19 | .. image:: https://readthedocs.org/projects/linguard/badge/?version=stable 20 | :target: https://linguard.readthedocs.io/en/stable/?badge=latest 21 | :alt: Stable Documentation Status 22 | 23 | .. image:: https://codecov.io/gh/joseantmazonsb/linguard/branch/dev/graph/badge.svg 24 | :target: https://codecov.io/gh/joseantmazonsb/linguard 25 | :alt: Code coverage status 26 | 27 | .. image:: https://img.shields.io/github/v/release/joseantmazonsb/linguard?color=green&include_prereleases&logo=github) 28 | :target: https://github.com/joseantmazonsb/linguard/releases 29 | :alt: Latest release (including pre-releases) 30 | 31 | .. image:: https://img.shields.io/github/downloads/joseantmazonsb/linguard/total?logo=github) 32 | :target: https://github.com/joseantmazonsb/linguard/releases 33 | :alt: Downloads counter (from all releases) 34 | 35 | Linguard aims to provide a clean, simple yet powerful web GUI to manage your WireGuard server, and it's powered by Flask. 36 | 37 | Key features 38 | ------------ 39 | 40 | * Management of Wireguard interfaces and peers via web. Interfaces can be created, removed, edited, exported and brought up and down directly from the web GUI. Peers can be created, removed, edited and downloaded at anytime as well. 41 | * Display stored and real time traffic data using charts (storage of traffic data may be manually disabled). 42 | * Display general network information. 43 | * Encrypted user credentials (AES). 44 | * Easy management through the ``linguard`` systemd service. 45 | 46 | Contents 47 | -------- 48 | 49 | .. toctree:: 50 | :maxdepth: 2 51 | 52 | installation 53 | screenshots 54 | in-depth 55 | contributing 56 | changelog 57 | 58 | Indices and tables 59 | ================== 60 | 61 | * :ref:`genindex` 62 | * :ref:`modindex` 63 | * :ref:`search` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Linguard 2 | 3 | [![GitHub](https://img.shields.io/github/license/joseantmazonsb/linguard)](LICENSE.md) ![Python version](https://img.shields.io/badge/python-3.7%20%7C%203.8%20%7C%203.9-blue?logo=python&logoColor=yellow) [![Stable workflow status](https://github.com/joseantmazonsb/linguard/actions/workflows/stable-test.yaml/badge.svg)](https://github.com/joseantmazonsb/linguard/actions/workflows/stable-test.yaml) [![Latest workflow status](https://github.com/joseantmazonsb/linguard/actions/workflows/latest-test.yaml/badge.svg)](https://github.com/joseantmazonsb/linguard/actions/workflows/latest-test.yaml) [![Stable Documentation Status](https://readthedocs.org/projects/linguard/badge/?version=stable)](https://linguard.readthedocs.io/en/stable/?badge=stable) [![codecov](https://codecov.io/gh/joseantmazonsb/linguard/branch/dev/graph/badge.svg)](https://codecov.io/gh/joseantmazonsb/linguard) 4 | 5 | [![GitHub release (latest SemVer including pre-releases)](https://img.shields.io/github/v/release/joseantmazonsb/linguard?color=green&include_prereleases&logo=github)](https://github.com/joseantmazonsb/linguard/releases) [![GitHub all releases](https://img.shields.io/github/downloads/joseantmazonsb/linguard/total?logo=github)](https://github.com/joseantmazonsb/linguard/releases) 6 | 7 | 8 | Linguard aims to provide a clean, simple yet powerful web GUI to manage your WireGuard server, and it's powered by Flask. 9 | 10 | **[Read the docs](https://linguard.readthedocs.io) for further information!** 11 | 12 | ## Key features 13 | 14 | * Management of Wireguard interfaces and peers via web. Interfaces can be created, removed, edited, exported and brought up and down directly from the web GUI. Peers can be created, removed, edited and downloaded at anytime as well. 15 | * Display stored and real time traffic data using charts (storage of traffic data may be manually disabled). 16 | * Display general network information. 17 | * Encrypted user credentials (AES). 18 | * Easy management through the ``linguard`` systemd service. 19 | 20 | ## Installation 21 | 22 | ### As a `systemd` service 23 | 24 | 1. Download [any release](https://github.com/joseantmazonsb/linguard/releases). 25 | 26 | 2. Extract it and run the installation script: 27 | ```bash 28 | chmod +x install.sh 29 | sudo ./install.sh 30 | ``` 31 | 3. Run Linguard: 32 | ```bash 33 | sudo systemctl start linguard.service 34 | ``` 35 | 36 | ### Docker 37 | 38 | 1. Download the [`docker-compose.yaml` file](https://raw.githubusercontent.com/joseantmazonsb/linguard/main/docker/docker-compose.yaml). 39 | 2. Run Linguard: 40 | ```bash 41 | sudo docker-compose up -d 42 | ``` 43 | NOTE: You can check all available tags [here](https://github.com/joseantmazonsb/linguard/pkgs/container/linguard/versions). -------------------------------------------------------------------------------- /linguard/core/config/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Dict, Any, Type 3 | 4 | from yamlable import yaml_info, Y 5 | 6 | from linguard.common.properties import global_properties 7 | from linguard.core.config.base import BaseConfig 8 | from linguard.core.exceptions import WireguardError 9 | from linguard.web.static.assets.resources import APP_NAME 10 | 11 | 12 | @yaml_info(yaml_tag='logger') 13 | class LoggerConfig(BaseConfig): 14 | LEVELS = { 15 | "debug": logging.DEBUG, 16 | "info": logging.INFO, 17 | "warning": logging.WARNING, 18 | "error": logging.ERROR, 19 | "fatal": logging.FATAL 20 | } 21 | DEFAULT_LEVEL = logging.INFO 22 | LOG_FORMAT = "%(asctime)s [%(levelname)s] %(module)s (%(funcName)s): %(message)s" 23 | LOG_FILENAME = f"{APP_NAME.lower()}.log" 24 | 25 | level: str 26 | overwrite = bool 27 | 28 | @property 29 | def logfile(self): 30 | return global_properties.join_workdir(self.LOG_FILENAME) 31 | 32 | def __init__(self): 33 | super().__init__() 34 | self.load_defaults() 35 | 36 | def load_defaults(self): 37 | self.overwrite = False 38 | self.level = logging.getLevelName(self.DEFAULT_LEVEL).lower() 39 | 40 | def load(self, config: "LoggerConfig"): 41 | self.level = config.level or self.level 42 | if self.level not in self.LEVELS: 43 | raise WireguardError(f"'{self.level}' is not a valid log level!") 44 | self.overwrite = config.overwrite 45 | 46 | def __to_yaml_dict__(self): # type: (...) -> Dict[str, Any] 47 | return { 48 | "overwrite": self.overwrite, 49 | "level": self.level, 50 | } 51 | 52 | @classmethod 53 | def __from_yaml_dict__(cls, # type: Type[Y] 54 | dct, # type: Dict[str, Any] 55 | yaml_tag="" 56 | ): # type: (...) -> Y 57 | config = LoggerConfig() 58 | config.level = dct.get("level", None) or config.level 59 | if config.level not in config.LEVELS: 60 | raise WireguardError(f"'{config.level}' is not a valid log level!") 61 | overwrite = config.overwrite 62 | config.overwrite = dct.get("overwrite", None) 63 | if config.overwrite is None: 64 | config.overwrite = overwrite 65 | return config 66 | 67 | def apply(self): 68 | super(LoggerConfig, self).apply() 69 | handlers = [logging.FileHandler(self.logfile, "a", "utf-8")] 70 | logging.basicConfig(format=self.LOG_FORMAT, level=self.LEVELS[self.level], handlers=handlers, force=True) 71 | 72 | def reset_logfile(self): 73 | with open(self.logfile, "w") as f: 74 | f.write("") 75 | 76 | 77 | config = LoggerConfig() 78 | logging.basicConfig(format=LoggerConfig.LOG_FORMAT, level=LoggerConfig.DEFAULT_LEVEL, force=True) 79 | -------------------------------------------------------------------------------- /docs/source/_build/html/searchindex.js: -------------------------------------------------------------------------------- 1 | Search.setIndex({docnames:["changelog","contributing","index","installation","screenshots"],envversion:{"sphinx.domains.c":2,"sphinx.domains.changeset":1,"sphinx.domains.citation":1,"sphinx.domains.cpp":4,"sphinx.domains.index":1,"sphinx.domains.javascript":2,"sphinx.domains.math":2,"sphinx.domains.python":3,"sphinx.domains.rst":2,"sphinx.domains.std":2,sphinx:56},filenames:["changelog.rst","contributing.rst","index.rst","installation.rst","screenshots.rst"],objects:{},objnames:{},objtypes:{},terms:{"0":[],"1":4,"2":[1,4],"20":2,"203":2,"3":[2,4],"5":1,"6":1,"7":2,"7c":2,"8":2,"9":2,"default":0,"new":1,"public":0,"true":1,As:[1,3],In:1,Not:3,The:1,To:1,about:1,access:0,action:2,actual:1,adher:0,aim:2,all:[0,1,2],alwai:1,an:1,ani:[1,3],anywher:1,ar:[0,1,4],architectur:0,around:1,assist:1,automat:[0,1],avail:3,badg:2,base:0,bash:[1,3],befor:1,binari:0,blue:2,branch:[1,2],bugfix:1,build:[1,2],bunch:4,can:1,cd:1,chang:0,changelog:2,check:1,chmod:3,ci:1,clean:2,code:1,codecov:[1,2],color:2,com:[1,2,3],comment:1,compress:1,config:1,configur:[0,1,4],contain:1,content:2,contribut:1,coverag:1,creat:1,curl:1,d:1,dashboard:4,depend:1,deploi:1,detect:0,dev:1,develop:1,directli:1,directori:1,dist:1,doc:1,docker:[2,3],document:0,doe:[1,4],download:[2,3],easi:0,edit:4,en:1,endpoint:0,enforc:1,ensur:1,environ:1,everyth:[0,1],exist:1,extern:1,extract:3,favour:0,featur:1,file:[0,1],first:0,flask:2,flow:1,folder:1,follow:1,gather:1,gener:1,get:1,gh:2,git:1,github:[1,2,3],githubusercont:1,go:0,graph:2,green:2,gui:2,guid:1,ha:1,handl:1,have:1,help:1,henc:1,here:4,how:4,howev:1,http:[1,2,3],hub:2,imag:4,img:2,implement:1,includ:[0,2],include_prereleas:2,index:2,indic:2,inform:4,instal:[1,3],interfac:4,io:[1,2],ip:0,issu:1,its:1,joseantmazonsb:[2,3],just:1,latest:2,licens:2,linguard:[0,1,2,3],locat:0,log:0,logo:2,logocolor:2,look:4,mai:1,main:[1,2],make:1,manag:[1,2],master:1,md:2,merg:1,modul:2,name:1,navbar:0,need:1,network:4,never:1,notabl:0,now:0,onc:1,one:0,ones:1,onli:1,open:1,org:1,other:1,out:1,packag:1,page:2,pass:1,peer:4,pipelin:1,place:0,plai:1,png:4,poetri:1,point:1,power:2,pre:2,previous:1,project:[0,1],properli:1,prove:1,provid:[1,2],publish:1,pull:[1,2],purpos:1,push:1,py:1,pytest:1,python3:1,python:[1,2],raw:1,read:1,readi:0,readthedoc:1,releas:[1,2,3],remov:0,report:1,repositori:[1,2],request:1,requir:[0,1],rout:4,run:[1,3],s:2,sampl:[0,1],screenshot:4,script:[1,3],search:2,section:[1,4],semant:0,semver:2,server:2,servic:3,set:[0,1,4],setup:[0,1],sh:[1,3],shield:2,should:1,side:0,simpl:2,singl:1,sourc:1,ssl:1,start:[1,3],sudo:3,sure:1,svg:2,systemctl:3,systemd:3,tabl:2,target:1,tdd:1,test:1,them:1,thi:[0,1],through:0,time:0,total:2,under:1,up:1,upload:1,us:[1,3],uwsgi:[0,1],v:2,valid:1,version:[0,1,2],virtualenv:1,wai:1,web:2,welcom:1,well:1,when:1,which:[0,1],wireguard:[2,4],word:1,work:1,workdir:0,workflow:[1,2],x:[1,3],yaml:[0,1,2],yellow:2,yet:[2,3],you:1,your:[1,2]},titles:["Changelog","<no title>","<no title>","<no title>","<no title>"],titleterms:{"0":0,"2":0,changelog:0}}) -------------------------------------------------------------------------------- /docs/source/_build/html/genindex.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Index — Linguard 0.2.0 documentation 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 |
27 |
28 |
29 | 30 | 31 |
32 | 33 | 34 |

Index

35 | 36 |
37 | 38 |
39 | 40 | 41 |
42 | 43 |
44 |
45 | 88 |
89 |
90 | 98 | 99 | 100 | 101 | 102 | 103 | -------------------------------------------------------------------------------- /linguard/web/templates/web/about.html: -------------------------------------------------------------------------------- 1 | {% extends "web/web-main.html" %} 2 | 3 | {% block content %} 4 |
5 |
6 |
7 | 8 | Back 9 | 10 |

{{ title }}

11 |
12 |
13 | {% if success %} 14 | 20 | {% if warning %} 21 | 27 | {% endif %} 28 | {% elif error %} 29 | 35 | {% endif %} 36 |
37 | 38 | 39 |
40 |
41 |
42 | 43 | Version 44 |
45 |
46 |
47 |
48 | 49 | 50 |
51 |
52 | 53 | 54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 | {% endblock %} -------------------------------------------------------------------------------- /docs/source/_build/html/_sources/contributing.rst.txt: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | .. note:: 4 | Linguard is and will always be open source. 5 | 6 | You may contribute by opening new issues, commenting on existent ones and creating pull requests with new features and bugfixes. 7 | Any help is welcome, just make sure you read the following sections, which will guide you to set up the development environment. 8 | .. 9 | ## Git flow 10 | 11 | You should never work directly on the `main` branch. This branch is only used to gather new features and bugfixes previously merged to the `dev` branch and publish them in a single package. In other words, its purpose is to release new versions of Linguard. 12 | 13 | Hence, the `dev` branch **should always be your starting point and the target of your pull requests.** 14 | 15 | .. code-block:: 16 | git clone https://github.com/joseantmazonsb/linguard.git 17 | cd linguard 18 | git checkout dev 19 | 20 | ## Dependency management 21 | 22 | [Poetry](https://python-poetry.org/) is used to handle packaging and dependencies. You will need to install it before getting started to code: 23 | 24 | ```bash 25 | curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py | python3 - 26 | ``` 27 | 28 | Once you have checked out the repository, you'd install the python requirements this way: 29 | 30 | ```bash 31 | poetry config virtualenvs.in-project true 32 | poetry install 33 | ``` 34 | 35 | ## Configuration files 36 | 37 | Linguard has a setup assistant and does not require you to have an existing configuration file in its working directory. However, it does require that your configuration file is a valid YAML file named `linguard.yaml`. 38 | 39 | As for the UWSGI configuration, Linguard provides a sample file (`uwsgi.sample.yaml`) for you to play around with it. Just make sure you run UWSGI using a valid file! 40 | 41 | ## Testing 42 | 43 | [PyTest](https://docs.pytest.org/en/6.2.x) and [Coverage](https://coverage.readthedocs.io/en/coverage-5.5) are used to test Linguard and generate coverage reports, which are uploaded to [Codecov](https://about.codecov.io). 44 | 45 | TDD is enforced. Make sure your code passes the existing tests and provide new ones to prove your new features/bugfixes actually work when making pull requests. 46 | 47 | All tests should be anywhere under `linguard/tests`, and you can run them all using Poetry: 48 | 49 | ``` 50 | poetry run pytest 51 | ``` 52 | 53 | You may as well generate a coverage report using poetry: 54 | ``` 55 | poetry run coverage report 56 | ``` 57 | 58 | ## Building 59 | 60 | To build Linguard you may use the `build.sh` script, which automatically generates a `dist` folder containing a compressed file with all you need to publish a release. 61 | 62 | ## CI/CD 63 | 64 | Github Workflows are used to implement a CI/CD pipeline. When code is pushed to any branch, it will be automatically tested to ensure everything is working properly. 65 | 66 | .. note:: 67 | The `main` branch is used to automatically deploy new releases, and **should never be the target of external pull requests**. 68 | -------------------------------------------------------------------------------- /linguard/web/static/js/modules/wireguard.mjs: -------------------------------------------------------------------------------- 1 | import {postJSON, prependAlert} from "./utils.mjs"; 2 | 3 | const startOrStopIfaceBtn = $(".startOrStopIfaceBtn"); 4 | startOrStopIfaceBtn.click(function (e) { 5 | const button = e.target; 6 | const iface = button.value; 7 | const action = button.innerText; 8 | 9 | const url = `/wireguard/interfaces/${iface}/${action}`; 10 | const alertContainer = "wgIfacesHeader"; 11 | const alertType = "danger"; 12 | const loadFeedback = "wgIface-" + iface + "-loading" 13 | 14 | postJSON(url, alertContainer, alertType, loadFeedback); 15 | }); 16 | 17 | const restartIfaceBtn = $(".restartIfaceBtn"); 18 | restartIfaceBtn.click(function (e) { 19 | const iface = e.target.value; 20 | const action = "restart"; 21 | 22 | const url = `/wireguard/interfaces/${iface}/${action}`; 23 | const alertContainer = "wgIfacesHeader"; 24 | const alertType = "danger"; 25 | const loadFeedback = "wgIface-" + iface + "-loading" 26 | 27 | postJSON(url, alertContainer, alertType, loadFeedback); 28 | }); 29 | 30 | const startAllBtn = $("#startAllBtn"); 31 | startAllBtn.click(function (e) { 32 | const action = "start"; 33 | const url = `/wireguard/${action}`; 34 | const alertContainer = "wgIfacesHeader"; 35 | const alertType = "danger"; 36 | const loadFeedback = "wgIfacesLoading"; 37 | 38 | postJSON(url, alertContainer, alertType, loadFeedback); 39 | }); 40 | 41 | const stopAllBtn = $("#stopAllBtn"); 42 | stopAllBtn.click(function (e) { 43 | const action = "stop"; 44 | const url = `/wireguard/${action}`; 45 | const alertContainer = "wgIfacesHeader"; 46 | const alertType = "danger"; 47 | const loadFeedback = "wgIfacesLoading"; 48 | 49 | postJSON(url, alertContainer, alertType, loadFeedback); 50 | }); 51 | 52 | const removeIfaceBtn = $(".removeIfaceBtn"); 53 | removeItem(removeIfaceBtn, "interface"); 54 | 55 | const removePeerBtn = $(".removePeerBtn"); 56 | removeItem(removePeerBtn, "peer"); 57 | 58 | function removeItem(removeBtn, itemType) { 59 | removeBtn.click(function (e) { 60 | const item = e.target.id.split("-")[1]; 61 | const url = "/wireguard/"+itemType+"s/"+item+""; 62 | const alertContainer = "wgIfacesHeader"; 63 | const alertType = "danger"; 64 | $.ajax({ 65 | type: "delete", 66 | url: url, 67 | success: function () { 68 | location.reload(); 69 | }, 70 | error: function(resp) { 71 | prependAlert(alertContainer, "Oops, something went wrong: " + resp["responseText"], 72 | alertType); 73 | $("#removeModal").modal("toggle"); 74 | }, 75 | }); 76 | }); 77 | } 78 | 79 | const downloadBtn = $(".downloadBtn"); 80 | downloadBtn.click(function (e) { 81 | let item = e.target.id.split("-")[1]; 82 | if (!item) { 83 | item = e.target.farthestViewportElement.id.split("-")[1]; 84 | } 85 | const url = "/wireguard/peers/"+item+"/download"; 86 | location.replace(url); 87 | }); -------------------------------------------------------------------------------- /docs/source/changelog.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | All notable changes to this project will be documented here. 5 | 6 | .. note:: 7 | Linguard is adhered to `Semantic Versioning `__. 8 | 9 | 1.1.0 10 | ----- 11 | 12 | What's new 13 | ~~~~~~~~~~ 14 | 15 | * Ban time is now editable and applies to individual IP addresses instead of globally (which makes much more sense). 16 | 17 | Fixes 18 | ~~~~~ 19 | 20 | * Fixed a bug with the settings page which caused the display of default/last saved settings everytime the page was reloaded, even though the values were actually being stored in the configuration file and applied. 21 | 22 | Docs 23 | ~~~~ 24 | 25 | * Added entry for ban time. 26 | 27 | 1.0.1 28 | ----- 29 | 30 | Fixes 31 | ~~~~~ 32 | 33 | * Fixed a bug related to versioning which caused the app to start in dev mode. 34 | 35 | Docs 36 | ~~~~ 37 | 38 | * Removed "Versions" empty section from index. 39 | 40 | 41 | 1.0.0 42 | ----- 43 | 44 | What's new 45 | ~~~~~~~~~~ 46 | 47 | * QR codes! You can scan a QR code to get the WireGuard configuration of any peer or interface. 48 | * Docker is finally here! For now on, there will be official docker images available for every release. 49 | * Display the IP address of the interface to be used when adding or editing a peer. 50 | * Updating the name of an interface also updates all references inside the "On up" and "On down" text areas. 51 | * Delete buttons have been relocated in the Interface and Peer views. 52 | 53 | Fixes 54 | ~~~~~ 55 | 56 | * Fixed a bug when updating the username or password which made the "Logged in {time} ago" sign show no time at all. 57 | * Removed the possibility to add peers if there are no WireGuard interfaces. 58 | * Ensured that peers can only be assigned valid, unused and not reserved IP addressed. 59 | * Ensured that peers' IP addresses are in the same network of their interface. 60 | * Ensured that interfaces can only be assigned valid, unused and not reserved IP addressed. 61 | * Ensured that interfaces' cannot be assigned an IP address belonging to a network which already has an interface. 62 | * Fixed a bug when updating an interface's gateway, which only updated one appearance of the previous gateway in the 63 | "On up" and "On down" text areas. 64 | * Fixed the behaviour of the ``overwrite`` flag regarding the logging settings which was causing to overwrite the log 65 | file each time the settings were saved instead of every time Linguard boots up. 66 | 67 | Docs 68 | ~~~~ 69 | 70 | * Improved documentation about the development environment. 71 | * Fixed a bunch of typos. 72 | * Fixed the Traffic Data Driver table. 73 | 74 | 0.2.0 75 | ----- 76 | 77 | * Easy first time setup, which automatically detects the location of the required binaries and sets the public IP as endpoint by default. 78 | * Everything in one place: workdir-based architecture. 79 | * Removed option to log to standard output. 80 | * Includes a ready-to-go uWSGI configuration file. 81 | * Removed the ``linguard.sample.yaml`` file in favour of the first time setup. 82 | * Settings are now accessible through the side navbar. 83 | -------------------------------------------------------------------------------- /linguard/web/static/js/modules/wireguard-iface.mjs: -------------------------------------------------------------------------------- 1 | import {postJSON, prependAlert} from "./utils.mjs"; 2 | 3 | const ifaceName = $("#name"); 4 | const gwIface = $("#gateway"); 5 | const onUp = $("#on_up") 6 | const onDown = $("#on_down") 7 | const alertContainer = "alerts"; 8 | 9 | let oldName = ifaceName.val(); 10 | let oldGw = gwIface.val(); 11 | 12 | function replaceOnUpDownComands(oldVal, newVal) { 13 | let value = onUp.val(); 14 | value = value.replaceAll(oldVal, newVal); 15 | onUp.val(value); 16 | 17 | value = onDown.val(); 18 | value = value.replaceAll(oldVal, newVal); 19 | onDown.val(value); 20 | } 21 | 22 | ifaceName.focusout(function () { 23 | const newName = ifaceName.val(); 24 | if (!newName) return; 25 | replaceOnUpDownComands(oldName, newName); 26 | oldName = newName; 27 | }); 28 | 29 | gwIface.change(function () { 30 | const newGw = gwIface.val(); 31 | if (!newGw) return; 32 | replaceOnUpDownComands(oldGw, newGw); 33 | oldGw = newGw; 34 | }); 35 | 36 | document.getElementById('private_key').setAttribute('type', "password"); 37 | 38 | document.getElementById("togglePrivateKey").addEventListener("click", function () { 39 | const icon = document.getElementById('togglePrivateKeyIcon') 40 | const field = document.getElementById('private_key'); 41 | const type = field.getAttribute('type') === 'password' ? 'text' : 'password'; 42 | field.setAttribute('type', type); 43 | if (type === "password") { 44 | icon.classList.add('fa-eye-slash'); 45 | icon.classList.remove('fa-eye'); 46 | } 47 | else { 48 | icon.classList.add('fa-eye'); 49 | icon.classList.remove('fa-eye-slash'); 50 | } 51 | }, false); 52 | 53 | const removeIfaceBtn = $(".removeIfaceBtn"); 54 | removeItem(removeIfaceBtn, "interface", function () { 55 | location.replace(document.referrer); 56 | }); 57 | 58 | const removePeerBtn = $(".removePeerBtn"); 59 | removeItem(removePeerBtn, "peer", function () { 60 | location.reload(); 61 | }); 62 | 63 | function removeItem(removeBtn, itemType, onSuccess) { 64 | removeBtn.click(function (e) { 65 | const item = e.target.id.split("-")[1]; 66 | const url = "/wireguard/"+itemType+"s/"+item; 67 | const alertType = "danger"; 68 | $.ajax({ 69 | type: "delete", 70 | url: url, 71 | success: onSuccess, 72 | error: function(resp) { 73 | prependAlert(alertContainer, "Oops, something went wrong: " + resp["responseText"], 74 | alertType); 75 | $("#removeModal").modal("toggle"); 76 | }, 77 | }); 78 | }); 79 | } 80 | 81 | const startOrStopIfaceBtn = $(".startOrStopIfaceBtn"); 82 | startOrStopIfaceBtn.click(function (e) { 83 | const button = e.target; 84 | const iface = button.value; 85 | const action = button.innerText; 86 | 87 | const url = `/wireguard/interfaces/${iface}/${action}`; 88 | const alertType = "danger"; 89 | const loadFeedback = "wgIface-" + iface + "-loading" 90 | 91 | postJSON(url, alertContainer, alertType, loadFeedback); 92 | }); -------------------------------------------------------------------------------- /linguard/tests/utils.py: -------------------------------------------------------------------------------- 1 | import http 2 | import os 3 | import shutil 4 | import sys 5 | 6 | from flask_login import current_user 7 | 8 | from linguard.common.models.user import users, User 9 | from linguard.common.properties import global_properties 10 | from linguard.common.utils.network import get_system_interfaces 11 | from linguard.core.managers.cron import cron_manager 12 | from linguard.core.models import interfaces, Interface 13 | from linguard.web.client import clients 14 | 15 | username = "admin" 16 | password = "admin" 17 | 18 | 19 | def exists_config_file() -> bool: 20 | from linguard.core.managers.config import config_manager 21 | return os.path.exists(config_manager.config_filepath) 22 | 23 | 24 | def exists_credentials_file() -> bool: 25 | from linguard.core.config.web import config 26 | return os.path.exists(config.credentials_file) 27 | 28 | 29 | def exists_traffic_file() -> bool: 30 | from linguard.core.config.traffic import config 31 | if not config.driver.filepath: 32 | return False 33 | return os.path.exists(config.driver.filepath) 34 | 35 | 36 | def exists_log_file() -> bool: 37 | from linguard.core.config.logger import config 38 | return os.path.exists(config.logfile) 39 | 40 | 41 | def default_cleanup(): 42 | for root, dirs, files in os.walk(global_properties.workdir): 43 | for f in files: 44 | os.remove(os.path.join(root, f)) 45 | for d in dirs: 46 | shutil.rmtree(os.path.join(root, d)) 47 | users.clear() 48 | clients.clear() 49 | interfaces.clear() 50 | cron_manager.stop() 51 | if current_user: 52 | current_user.logout() 53 | 54 | 55 | def is_http_success(code: int): 56 | return code < http.HTTPStatus.BAD_REQUEST 57 | 58 | 59 | def login(client): 60 | u = User(username) 61 | u.password = password 62 | users[u.id] = u 63 | 64 | response = client.post("/login", data={"username": username, "password": password, "remember_me": False}) 65 | assert is_http_success(response.status_code), default_cleanup() 66 | assert current_user.name == "admin", default_cleanup() 67 | 68 | 69 | def get_testing_app(): 70 | workdir = "data" 71 | sys.argv = [sys.argv[0], workdir] 72 | global_properties.setup_required = False 73 | global_properties.dev_env = True 74 | from linguard.__main__ import app 75 | app.config["TESTING"] = True 76 | app.config["WTF_CSRF_ENABLED"] = False 77 | return app 78 | 79 | 80 | def create_test_iface(name, ipv4, port): 81 | gw = list(filter(lambda i: i != "lo", get_system_interfaces().keys()))[1] 82 | from linguard.core.config.wireguard import config 83 | on_up = [ 84 | f"{config.iptables_bin} -I FORWARD -i {name} -j ACCEPT\n" + 85 | f"{config.iptables_bin} -I FORWARD -o {name} -j ACCEPT\n" + 86 | f"{config.iptables_bin} -t nat -I POSTROUTING -o {gw} -j MASQUERADE\n" 87 | ] 88 | on_down = [ 89 | f"{config.iptables_bin} -D FORWARD -i {name} -j ACCEPT\n" + 90 | f"{config.iptables_bin} -D FORWARD -o {name} -j ACCEPT\n" + 91 | f"{config.iptables_bin} -t nat -D POSTROUTING -o {gw} -j MASQUERADE\n" 92 | ] 93 | return Interface(name=name, description="", gw_iface=gw, ipv4_address=ipv4, listen_port=port, auto=False, 94 | on_up=on_up, on_down=on_down) -------------------------------------------------------------------------------- /linguard/common/models/user.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import Type, Dict, Any, Mapping 3 | from uuid import uuid4 as gen_uuid 4 | 5 | from flask_login import logout_user, UserMixin 6 | from werkzeug.security import check_password_hash, generate_password_hash 7 | from yamlable import YamlAble, yaml_info, Y 8 | 9 | from linguard.common.models.encrypted_yamlable import EncryptedYamlAble 10 | from linguard.common.models.enhanced_dict import EnhancedDict, K, V 11 | 12 | 13 | @yaml_info(yaml_tag='user') 14 | class User(UserMixin, YamlAble): 15 | HASHING_METHOD = "pbkdf2:sha256" 16 | login_date: datetime 17 | 18 | def __init__(self, name: str): 19 | self.id = gen_uuid().hex 20 | self.name = name 21 | self.__password = None 22 | self.__authenticated = False 23 | 24 | def __str__(self): 25 | return { 26 | "id": self.id, 27 | "name": self.name, 28 | "authenticated": self.__authenticated 29 | }.__str__() 30 | 31 | @property 32 | def password(self): 33 | return self.__password 34 | 35 | @password.setter 36 | def password(self, value: str): 37 | self.__password = generate_password_hash(str(value), self.HASHING_METHOD) 38 | 39 | def __to_yaml_dict__(self): 40 | """ Called when you call yaml.dump()""" 41 | return { 42 | "id": self.id, 43 | "name": self.name, 44 | "password": self.password, 45 | } 46 | 47 | @classmethod 48 | def __from_yaml_dict__(cls, # type: Type[Y] 49 | dct, # type: Dict[str, Any] 50 | yaml_tag # type: str 51 | ): # type: (...) -> Y 52 | u = User(dct["name"]) 53 | u.id = dct["id"] 54 | u.__password = str(dct["password"]) 55 | return u 56 | 57 | def login(self, password: str) -> bool: 58 | if self.is_authenticated: 59 | return True 60 | self.__authenticated = self.check_password(password) 61 | if self.__authenticated: 62 | self.login_date = datetime.now() 63 | return self.__authenticated 64 | 65 | def check_password(self, password: str) -> bool: 66 | """Check if the specified password matches the user's password without triggering a proper login.""" 67 | return check_password_hash(self.password, password) 68 | 69 | def logout(self): 70 | self.__authenticated = False 71 | return logout_user() 72 | 73 | @property 74 | def is_authenticated(self): 75 | return self.__authenticated 76 | 77 | 78 | @yaml_info(yaml_tag='users') 79 | class UserDict(EnhancedDict, EncryptedYamlAble, Mapping[K, V]): 80 | 81 | def __to_yaml_dict__(self): # type: (...) -> Dict[str, Any] 82 | return self 83 | 84 | @classmethod 85 | def __from_yaml_dict__(cls, # type: Type[Y] 86 | dct, # type: Dict[str, Any] 87 | yaml_tag # type: str 88 | ): # type: (...) -> Y 89 | u = UserDict() 90 | u.update(dct) 91 | return u 92 | 93 | def sort(self, order_by=lambda pair: pair[1].name): 94 | super(UserDict, self).sort(order_by) 95 | 96 | 97 | users: UserDict[str, User] 98 | users = UserDict() 99 | -------------------------------------------------------------------------------- /docs/source/_build/html/search.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Search — Linguard 0.2.0 documentation 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 |
33 |
34 |
35 | 36 | 37 |
38 | 39 |

Search

40 | 41 | 49 | 50 | 51 |

52 | Searching for multiple words only shows matches that contain 53 | all words. 54 |

55 | 56 | 57 |
58 | 59 | 60 | 61 |
62 | 63 | 64 | 65 |
66 | 67 |
68 | 69 | 70 |
71 | 72 |
73 |
74 | 107 |
108 |
109 | 117 | 118 | 119 | 120 | 121 | 122 | -------------------------------------------------------------------------------- /linguard/__main__.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import atexit 3 | import os 4 | from logging import warning, fatal, info, debug 5 | 6 | from flask import Flask 7 | from flask_login import LoginManager 8 | from flask_qrcode import QRcode 9 | 10 | from linguard.__version__ import commit, release 11 | from linguard.common.models.user import users 12 | from linguard.common.properties import global_properties 13 | from linguard.common.utils.system import try_makedir 14 | from linguard.core.managers.cron import cron_manager 15 | from linguard.core.managers.wireguard import wireguard_manager 16 | from linguard.web.static.assets.resources import APP_NAME 17 | 18 | login_manager = LoginManager() 19 | 20 | 21 | @login_manager.user_loader 22 | def load_user(user_id): 23 | return users.get(user_id, None) 24 | 25 | 26 | def parse_args(): 27 | parser = argparse.ArgumentParser(description=f"Welcome to {APP_NAME}, the best WireGuard's web GUI :)") 28 | parser.add_argument("workdir", type=str, 29 | help=f"Path to the directory used to store all data related to {APP_NAME}.") 30 | parser.add_argument("--debug", help="Start flask in debug mode.", action="store_true") 31 | return parser.parse_args() 32 | 33 | 34 | args = parse_args() 35 | 36 | workdir = os.path.abspath(args.workdir) 37 | if os.path.exists(workdir) and not os.path.isdir(workdir): 38 | fatal(f"'{workdir}' is not a valid working directory!") 39 | try_makedir(workdir) 40 | global_properties.workdir = workdir 41 | 42 | from linguard.core.config.web import config as web_config 43 | from linguard.core.config.logger import config as log_config 44 | from linguard.core.managers.config import config_manager 45 | from linguard.web.router import router 46 | 47 | app = Flask(__name__, template_folder="web/templates", static_folder="web/static") 48 | info(f"Logging to '{log_config.logfile}'...") 49 | config_manager.load() 50 | if log_config.overwrite: 51 | log_config.reset_logfile() 52 | 53 | app.config['SECRET_KEY'] = web_config.secret_key 54 | app.register_blueprint(router) 55 | QRcode(app) 56 | login_manager.init_app(app) 57 | wireguard_manager.start() 58 | cron_manager.start() 59 | 60 | 61 | @atexit.register 62 | def on_exit(): 63 | warning(f"Shutting down {APP_NAME}...") 64 | cron_manager.stop() 65 | wireguard_manager.stop() 66 | 67 | 68 | if __name__ == "__main__": 69 | warning("**************************") 70 | warning("RUNNING DEVELOPMENT SERVER") 71 | warning("**************************") 72 | global_properties.dev_env = True 73 | # Override log level (although it can be manually edited via UI) 74 | log_config.level = "debug" 75 | log_config.apply() 76 | # Unlike the production scenario, a missing version file is not fatal 77 | if not release or not commit: 78 | warning("!! No versioning information provided !!") 79 | else: 80 | info(f"Running {APP_NAME} {release}") 81 | debug(f"Commit hash: {commit}") 82 | app.run(debug=args.debug, port=8080, host="0.0.0.0") 83 | else: 84 | if not release or not commit: 85 | if global_properties.dev_env: 86 | warning("!! No versioning information provided !!") 87 | else: 88 | fatal("!! No versioning information provided !!") 89 | exit(1) 90 | if "-" in release or "+" in release: 91 | global_properties.dev_env = True 92 | info(f"Running {APP_NAME} {release}") 93 | debug(f"Commit hash: {commit}") 94 | -------------------------------------------------------------------------------- /linguard/tests/test_server.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | from os.path import dirname, join 4 | from time import sleep 5 | 6 | import pytest 7 | from flask_login import current_user 8 | 9 | from linguard.common.models.user import users, User 10 | from linguard.common.properties import global_properties 11 | from linguard.common.utils.system import try_makedir 12 | from linguard.core.config.web import config as web_config 13 | from linguard.core.managers.config import config_manager 14 | from linguard.core.managers.wireguard import wireguard_manager 15 | from linguard.tests.utils import default_cleanup, is_http_success, get_testing_app 16 | 17 | 18 | @pytest.fixture(autouse=True) 19 | def cleanup(): 20 | yield 21 | default_cleanup() 22 | config_manager.load_defaults() 23 | 24 | 25 | @pytest.fixture 26 | def client(): 27 | with get_testing_app().test_client() as client: 28 | yield client 29 | 30 | 31 | def test_signup_ok(client): 32 | response = client.post("/signup", data={"username": "admin", "password": "admin", "confirm": "admin"}, 33 | follow_redirects=True) 34 | assert is_http_success(response.status_code) 35 | assert os.path.exists(web_config.credentials_file) 36 | assert current_user.name == "admin" 37 | 38 | 39 | def test_signup_ko(client): 40 | response = client.post("/signup", data={"username": "admin", "password": "admin"}, 41 | follow_redirects=True) 42 | assert is_http_success(response.status_code) 43 | assert not os.path.exists(web_config.credentials_file) 44 | assert not current_user.is_authenticated 45 | 46 | 47 | def test_login_logout_ok(client): 48 | admin_user = User("admin") 49 | admin_user.password = "admin" 50 | users[admin_user.id] = admin_user 51 | users.save(web_config.credentials_file, web_config.secret_key) 52 | 53 | response = client.post("/login", data={"username": "admin", "password": "admin", "remember_me": False}) 54 | assert is_http_success(response.status_code) 55 | assert current_user.name == "admin" 56 | 57 | response = client.get("/logout") 58 | assert is_http_success(response.status_code) 59 | assert not current_user.is_authenticated 60 | 61 | 62 | def test_login_ko(client): 63 | admin_user = User("admin") 64 | admin_user.password = "admin" 65 | users[admin_user.id] = admin_user 66 | users.save(web_config.credentials_file, web_config.secret_key) 67 | 68 | response = client.post("/login", data={"username": "admin", "password": "1234", "remember_me": False}) 69 | assert is_http_success(response.status_code) 70 | assert not current_user.is_authenticated 71 | 72 | 73 | def test_default_server(): 74 | """Test with not existent configuration file, so that the app loads all default values.""" 75 | workdir = join(dirname(__file__), "data") 76 | try_makedir(workdir) 77 | global_properties.workdir = workdir 78 | config_manager.load() 79 | wireguard_manager.start() 80 | sleep(1) 81 | wireguard_manager.stop() 82 | 83 | 84 | def test_sample_server(): 85 | workdir = join(dirname(__file__), "data") 86 | try_makedir(workdir) 87 | sample_file = join(dirname(dirname(dirname(__file__))), "config", "linguard.sample.yaml") 88 | shutil.copy(sample_file, workdir) 89 | global_properties.workdir = workdir 90 | config_manager.load() 91 | wireguard_manager.start() 92 | sleep(1) 93 | wireguard_manager.stop() 94 | -------------------------------------------------------------------------------- /linguard/core/managers/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | from logging import info, warning, error 3 | 4 | import yaml 5 | 6 | from linguard.common.models.user import UserDict, users 7 | from linguard.common.properties import global_properties 8 | from linguard.common.utils.logs import log_exception 9 | from linguard.common.utils.system import try_makedir 10 | from linguard.core.config.logger import config as logger_config 11 | from linguard.core.config.traffic import config as traffic_config 12 | from linguard.core.config.web import config as web_config 13 | from linguard.core.config.wireguard import config as wireguard_config 14 | from linguard.web.static.assets.resources import APP_NAME 15 | 16 | 17 | class ConfigManager: 18 | 19 | CONFIG_FILENAME = f"{APP_NAME.lower()}.yaml" 20 | 21 | def __init__(self): 22 | self.config_filepath = None 23 | 24 | def load(self): 25 | try: 26 | self.config_filepath = global_properties.join_workdir(self.CONFIG_FILENAME) 27 | self.__load_config__() 28 | self.save(apply=False) 29 | except Exception as e: 30 | log_exception(e, is_fatal=True) 31 | exit(1) 32 | 33 | @staticmethod 34 | def load_defaults(): 35 | logger_config.load_defaults() 36 | web_config.load_defaults() 37 | wireguard_config.load_defaults() 38 | traffic_config.load_defaults() 39 | 40 | def __load_config__(self): 41 | info(f"Restoring configuration from {self.config_filepath}...") 42 | if not os.path.exists(self.config_filepath): 43 | warning(f"Unable to restore configuration file {self.config_filepath}: not found.") 44 | info("Using default configuration...") 45 | return 46 | with open(self.config_filepath, "r") as file: 47 | config = list(yaml.safe_load_all(file))[0] 48 | if "logger" in config: 49 | logger_config.load(config["logger"]) 50 | logger_config.apply() 51 | if "web" in config: 52 | web_config.load(config["web"]) 53 | web_config.apply() 54 | if os.path.exists(web_config.credentials_file) and os.path.getsize(web_config.credentials_file) > 0: 55 | try: 56 | credentials = UserDict.load(web_config.credentials_file, web_config.secret_key) 57 | users.set_contents(credentials) 58 | except Exception: 59 | error(f"Invalid credentials file detected: {web_config.credentials_file}") 60 | raise 61 | if "wireguard" in config: 62 | wireguard_config.load(config["wireguard"]) 63 | wireguard_config.apply() 64 | if "traffic" in config: 65 | traffic_config.load(config["traffic"]) 66 | traffic_config.apply() 67 | info(f"Configuration restored!") 68 | 69 | def save(self, apply: bool = True): 70 | info("Saving configuration...") 71 | config = { 72 | "logger": logger_config, 73 | "web": web_config, 74 | "wireguard": wireguard_config, 75 | "traffic": traffic_config, 76 | } 77 | try_makedir(os.path.dirname(self.config_filepath)) 78 | with open(self.config_filepath, "w") as file: 79 | yaml.safe_dump(config, file) 80 | info("Configuration saved!") 81 | if not apply: 82 | return 83 | logger_config.apply() 84 | wireguard_config.apply() 85 | web_config.apply() 86 | traffic_config.apply() 87 | 88 | @staticmethod 89 | def save_credentials(): 90 | users.save(web_config.credentials_file, web_config.secret_key) 91 | 92 | 93 | config_manager = ConfigManager() 94 | -------------------------------------------------------------------------------- /linguard/core/drivers/traffic_storage_driver_json.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import json 3 | import os 4 | from datetime import datetime 5 | from logging import info 6 | from typing import Dict, Any, Type 7 | 8 | from yamlable import yaml_info, Y 9 | 10 | from linguard.common.properties import global_properties 11 | from linguard.core.drivers.traffic_storage_driver import TrafficStorageDriver, TrafficData 12 | from linguard.core.models import interfaces, get_all_peers 13 | 14 | 15 | @yaml_info(yaml_tag='traffic_storage_driver_json') 16 | class TrafficStorageDriverJson(TrafficStorageDriver): 17 | 18 | FILENAME = "traffic.json" 19 | 20 | def __init__(self, timestamp_format: str = TrafficStorageDriver.DEFAULT_TIMESTAMP_FORMAT): 21 | super().__init__(timestamp_format) 22 | 23 | @property 24 | def filepath(self): 25 | return global_properties.join_workdir(self.FILENAME) 26 | 27 | @classmethod 28 | def get_name(cls) -> str: 29 | return "JSON" 30 | 31 | def save_data(self): 32 | info("Updating traffic data...") 33 | merged_data = self.get_session_and_stored_data() 34 | with open(self.filepath, "w") as f: 35 | json_data = {} 36 | for timestamp, data in merged_data.items(): 37 | device_data = {} 38 | for device, traffic_data in data.items(): 39 | if device in interfaces.keys(): 40 | # Do not store interface data, since it will be calculated from peers data 41 | break 42 | device_data[device] = {"rx": traffic_data.rx, "tx": traffic_data.tx} 43 | json_data[timestamp.strftime(self.timestamp_format)] = device_data 44 | json.dump(json_data, f) 45 | info("Traffic data updated.") 46 | 47 | def load_data(self) -> Dict[datetime, Dict[str, TrafficData]]: 48 | data = {} 49 | if not os.path.exists(self.filepath): 50 | return data 51 | with open(self.filepath, "r") as f: 52 | json_data = json.load(f) 53 | for k, v in json_data.items(): 54 | device_data = {} 55 | for device, traffic_data in v.items(): 56 | device_data[device] = TrafficData(traffic_data["rx"], traffic_data["tx"]) 57 | data[datetime.strptime(k, self.timestamp_format)] = device_data 58 | # Calculate interfaces traffic data 59 | data_with_interfaces = copy.deepcopy(data) 60 | peers = get_all_peers() 61 | for timestamp, peer in data.items(): 62 | for uuid, peer_data in peer.items(): 63 | peer = peers.get(uuid, None) 64 | if not peer: 65 | continue 66 | iface = peer.interface 67 | if iface.uuid not in data_with_interfaces[timestamp]: 68 | data_with_interfaces[timestamp][iface.uuid] = TrafficData(rx_bytes=peer_data.tx, 69 | tx_bytes=peer_data.rx) 70 | continue 71 | data_with_interfaces[timestamp][iface.uuid].tx += peer_data.rx 72 | data_with_interfaces[timestamp][iface.uuid].rx += peer_data.tx 73 | return data_with_interfaces 74 | 75 | def __to_yaml_dict__(self): # type: (...) -> Dict[str, Any] 76 | dct = super(TrafficStorageDriverJson, self).__to_yaml_dict__() 77 | return dct 78 | 79 | @classmethod 80 | def __from_yaml_dict__(cls, # type: Type[Y] 81 | dct, # type: Dict[str, Any] 82 | yaml_tag="" 83 | ): # type: (...) -> Y 84 | timestamp_format = dct.get("timestamp_format", None) 85 | return TrafficStorageDriverJson(timestamp_format) 86 | -------------------------------------------------------------------------------- /linguard/web/templates/web/signup.html: -------------------------------------------------------------------------------- 1 | {% extends "main.html" %} 2 | 3 | {% set footer_id = "layoutAuthentication_footer" %} 4 | 5 | {% block body_content %} 6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |

{{ title }}

15 |
16 |
17 | {{ form.hidden_tag() }} 18 |
19 | {{ form.username.label(class="small") }} 20 |
21 | {% for error in form.username.errors %} 22 | {{ error }} 23 | {% endfor %} 24 | {{ form.username(size=64, class="form-control py-4 mt-1") }} 25 |
26 |
27 | {{ form.password.label(class="small") }} 28 |
29 | {% for error in form.password.errors %} 30 | {{ error }} 31 | {% endfor %} 32 | {{ form.password(class="form-control py-4 mt-1") }} 33 |
34 |
35 | {{ form.confirm.label(class="small") }} 36 |
37 | {% for error in form.confirm.errors %} 38 | {{ error }} 39 | {% endfor %} 40 | {{ form.confirm(class="form-control py-4 mt-1") }} 41 |
42 | 43 |
44 | {{ form.submit(class="btn btn-primary btn-block") }} 45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 | {% include "footer.html" %} 55 |
56 |
57 | {% endblock %} 58 | 59 | {% block scripts %} 60 | 61 | 62 | 63 | {% endblock %} -------------------------------------------------------------------------------- /linguard/web/templates/web/login.html: -------------------------------------------------------------------------------- 1 | {% extends "main.html" %} 2 | 3 | {% set footer_id = "layoutAuthentication_footer" %} 4 | 5 | {% block body_content %} 6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |

Login

15 |
16 |
17 | {{ form.hidden_tag() }} 18 |
19 | {{ form.username.label(class="small") }} 20 |
21 | {% for error in form.username.errors %} 22 | {{ error }} 23 | {% endfor %} 24 | {{ form.username(size=64, class="form-control py-4 mt-1") }} 25 |
26 |
27 | {{ form.password.label(class="small") }} 28 |
29 | {% for error in form.password.errors %} 30 | {{ error }} 31 | {% endfor %} 32 | {{ form.password(class="form-control py-4 mt-1") }} 33 |
34 |
35 | {{ form.remember_me(class="custom-control-input") }} 36 | {{ form.remember_me.label(class="custom-control-label") }} 37 |
38 | 39 |
40 | 41 | {% if banned_for %} 42 | Too many failed attempts to log in. You may try again in {{ banned_for }} seconds. 43 | {% else %} 44 | {{ form.submit(class="btn btn-primary") }} 45 | {% endif %} 46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 | {% include "footer.html" %} 56 |
57 |
58 | {% endblock %} 59 | 60 | {% block scripts %} 61 | 62 | 63 | 64 | {% endblock %} -------------------------------------------------------------------------------- /docs/source/contributing.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | .. note:: 5 | 6 | Linguard is and will always be open source. 7 | 8 | You may contribute by opening new issues, commenting on existent ones and creating pull requests with new features and bugfixes. 9 | Any help is welcome, just make sure you read the following sections, which will guide you to set up the development environment. 10 | 11 | Git flow 12 | -------- 13 | 14 | You should never work directly on the ``main`` branch. This branch is only used to gather new features and bugfixes previously merged to the ``dev`` branch and publish them in a single package. In other words, its purpose is to release new versions of Linguard. 15 | 16 | Hence, the ``dev`` branch **should always be your starting point and the target of your pull requests.** 17 | 18 | .. code-block:: bash 19 | 20 | git clone https://github.com/joseantmazonsb/linguard.git 21 | cd linguard 22 | git checkout dev 23 | 24 | 25 | Requirements 26 | ------------ 27 | 28 | You will need to install the following Linux packages: 29 | 30 | .. code-block:: 31 | 32 | sudo iproute2 python3 python3-venv wireguard iptables libpcre3 libpcre3-dev uwsgi uwsgi-plugin-python3 33 | 34 | 35 | Dependency management 36 | --------------------- 37 | 38 | `Poetry `__ is used to handle packaging and dependencies. You will need to install it before getting started to code: 39 | 40 | .. code-block:: bash 41 | 42 | curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/install-poetry.py | python3 - 43 | 44 | Once you have checked out the repository, you'd install the python requirements this way: 45 | 46 | .. code-block:: bash 47 | 48 | poetry config virtualenvs.in-project true 49 | poetry install 50 | 51 | Then, you would only need to run ``poetry shell`` and voilà, ready to code! 52 | 53 | .. note:: 54 | Actually, you should always run ``poetry run pytest`` before getting started to code in order to check 55 | that everything's all right. 56 | 57 | Configuration files 58 | ------------------- 59 | 60 | Linguard has a setup assistant and does not require you to have an existing configuration file in its working directory. Nonetheless, you may use your own existing file as long as it is valid and named ``linguard.yaml``. 61 | 62 | As for the UWSGI configuration, Linguard provides a sample file (``uwsgi.sample.yaml``) for you to play around with it. Just make sure you run UWSGI using a valid file! 63 | 64 | Testing 65 | ------- 66 | 67 | `PyTest `__ and `Coverage `__ are used to test Linguard and generate coverage reports, which are uploaded to `Codecov `__. 68 | 69 | TDD is enforced. Make sure your code passes the existing tests and provide new ones to prove your new features/bugfixes actually work when making pull requests. 70 | 71 | All tests should be anywhere under ``linguard/tests``, and you can run them all using Poetry: 72 | 73 | .. code-block:: bash 74 | 75 | poetry run pytest 76 | 77 | You may as well generate a coverage report using poetry: 78 | 79 | .. code-block:: bash 80 | 81 | poetry run coverage run -m pytest && poetry run coverage report 82 | 83 | Building 84 | -------- 85 | 86 | To build Linguard you may use the ``build.sh`` script, which automatically generates a ``dist`` folder containing a compressed file with all you need to publish a release. 87 | 88 | Versioning 89 | ---------- 90 | 91 | Linguard is adhered to `Semantic Versioning `__. 92 | 93 | All releases must follow the format ``{MAJOR}.{MINOR}.{PATCH}``, and git tags linked 94 | to releases must follow the format ``v{MAJOR}.{MINOR}.{PATCH}``. Thus, release 95 | ``1.0.0`` would be linked to the ``v1.0.0`` git tag. 96 | 97 | CI/CD 98 | ----- 99 | 100 | Github Workflows are used to implement a CI/CD pipeline. When pull requests targeting the ``main`` or ``dev`` 101 | branches are opened, a series of tests will automatically be ran to ensure everything is working properly. 102 | 103 | .. warning:: 104 | 105 | The ``main`` branch is used to automatically deploy new releases, and **should never be the target of external pull requests**. 106 | -------------------------------------------------------------------------------- /docs/source/_build/html/changelog.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Changelog — Linguard 0.2.0 documentation 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 |
30 |
31 | 32 | 33 |
34 | 35 |
36 |

Changelog

37 |

All notable changes to this project will be documented in this file. 38 | Linguard is adhered to :: Semantic Versioning

39 |
40 |

0.2.0

41 |
    42 |
  • Logging to file.

  • 43 |
  • Easy first time setup, which automatically detects the location of the required binaries and sets the public IP as endpoint by default.

  • 44 |
  • Everything in one place: workdir-based architecture.

  • 45 |
  • Includes a ready-to-go uWSGI configuration file.

  • 46 |
  • Removed the linguard.sample.yaml file in favour of the first time setup.

  • 47 |
  • Settings are now accessible through the side navbar.

  • 48 |
49 |
50 |
51 | 52 | 53 |
54 | 55 |
56 |
57 | 101 |
102 |
103 | 114 | 115 | 116 | 117 | 118 | 119 | -------------------------------------------------------------------------------- /linguard/tests/default/test_login.py: -------------------------------------------------------------------------------- 1 | from time import sleep 2 | 3 | import pytest 4 | from flask_login import current_user 5 | 6 | from linguard.common.models.user import User, users 7 | from linguard.core.config.web import config 8 | from linguard.tests.utils import default_cleanup, is_http_success, username, password, get_testing_app 9 | from linguard.web.client import clients 10 | 11 | url = "/login" 12 | 13 | 14 | @pytest.fixture(autouse=True) 15 | def cleanup(): 16 | yield 17 | default_cleanup() 18 | config.login_attempts = 0 19 | config.login_ban_time = config.DEFAULT_BAN_SECONDS 20 | 21 | 22 | @pytest.fixture 23 | def client(): 24 | with get_testing_app().test_client() as client: 25 | yield client 26 | 27 | 28 | @pytest.fixture(autouse=True) 29 | def setup(): 30 | u = User(username) 31 | u.password = password 32 | users[u.id] = u 33 | 34 | 35 | def test_post_ok(client): 36 | response = client.post(url, data={"username": username, "password": password, "remember_me": False}, 37 | follow_redirects=True) 38 | assert is_http_success(response.status_code) 39 | assert current_user.is_authenticated 40 | assert current_user.name == "admin" 41 | assert b"Dashboard" in response.data 42 | 43 | 44 | def test_post_ko(client): 45 | response = client.post(url, data={"username": username + "a", "password": password, "remember_me": False}, 46 | follow_redirects=True) 47 | assert is_http_success(response.status_code) 48 | assert not current_user.is_authenticated 49 | assert b"Dashboard" not in response.data 50 | 51 | response = client.post(url, data={"username": username, "password": password + "b", "remember_me": False}, 52 | follow_redirects=True) 53 | assert is_http_success(response.status_code) 54 | assert not current_user.is_authenticated 55 | assert b"Dashboard" not in response.data 56 | 57 | response = client.post(url, data={"username": username + "a", "password": password + "b", "remember_me": False}, 58 | follow_redirects=True) 59 | assert is_http_success(response.status_code) 60 | assert not current_user.is_authenticated 61 | assert b"Dashboard" not in response.data 62 | 63 | 64 | def test_ban_time(client): 65 | config.login_attempts = 1 66 | config.login_ban_time = 2 67 | 68 | response = client.post(url, data={"username": username + "a", "password": password, "remember_me": False}, 69 | follow_redirects=True) 70 | assert is_http_success(response.status_code) 71 | assert not current_user.is_authenticated 72 | assert b"Dashboard" not in response.data 73 | 74 | response = client.post(url, data={"username": username + "a", "password": password, "remember_me": False}, 75 | follow_redirects=True) 76 | assert is_http_success(response.status_code) 77 | assert not current_user.is_authenticated 78 | assert b"Dashboard" not in response.data 79 | assert f"try again in {config.login_ban_time} seconds".encode() in response.data or \ 80 | f"try again in {config.login_ban_time - 1} seconds".encode() in response.data 81 | 82 | sleep(config.login_ban_time) 83 | 84 | response = client.post(url, data={"username": username, "password": password, "remember_me": False}, 85 | follow_redirects=True) 86 | assert is_http_success(response.status_code) 87 | assert current_user.is_authenticated 88 | assert b"Dashboard" in response.data 89 | 90 | 91 | def test_ban_by_ip(client): 92 | config.login_attempts = 1 93 | config.login_ban_time = 10 94 | 95 | response = client.post(url, data={"username": username + "a", "password": password, "remember_me": False}, 96 | follow_redirects=True) 97 | assert is_http_success(response.status_code) 98 | assert not current_user.is_authenticated 99 | assert b"Dashboard" not in response.data 100 | 101 | response = client.post(url, data={"username": username + "a", "password": password, "remember_me": False}, 102 | follow_redirects=True) 103 | 104 | assert is_http_success(response.status_code) 105 | assert not current_user.is_authenticated 106 | assert b"Dashboard" not in response.data 107 | assert f"try again in {config.login_ban_time} seconds".encode() in response.data or \ 108 | f"try again in {config.login_ban_time - 1} seconds".encode() in response.data 109 | 110 | clients.clear() 111 | 112 | response = client.post(url, data={"username": username, "password": password, "remember_me": False}, 113 | follow_redirects=True) 114 | assert is_http_success(response.status_code) 115 | assert current_user.is_authenticated 116 | assert b"Dashboard" in response.data 117 | -------------------------------------------------------------------------------- /linguard/core/config/wireguard.py: -------------------------------------------------------------------------------- 1 | import os 2 | from logging import debug, warning, error 3 | from typing import Dict, Type, Any 4 | from urllib import request 5 | 6 | from yamlable import yaml_info, Y 7 | 8 | from linguard.common.properties import global_properties 9 | from linguard.common.utils.network import get_default_gateway 10 | from linguard.common.utils.system import Command 11 | from linguard.core.config.base import BaseConfig 12 | 13 | 14 | @yaml_info(yaml_tag='wireguard') 15 | class WireguardConfig(BaseConfig): 16 | __IP_RETRIEVER_URL = "https://api.ipify.org" 17 | INTERFACES_FOLDER_NAME = "interfaces" 18 | 19 | endpoint: str 20 | wg_bin: str 21 | wg_quick_bin: str 22 | iptables_bin: str 23 | 24 | @property 25 | def interfaces_folder(self): 26 | return global_properties.join_workdir(self.INTERFACES_FOLDER_NAME) 27 | 28 | def __init__(self): 29 | self.load_defaults() 30 | 31 | def load_defaults(self): 32 | self.endpoint = "" 33 | self.iptables_bin = "" 34 | self.wg_bin = "" 35 | self.wg_quick_bin = "" 36 | result = Command("whereis wg | tr ' ' '\n' | grep bin").run() 37 | if result.successful: 38 | self.wg_bin = result.output 39 | result = Command("whereis wg-quick | tr ' ' '\n' | grep bin").run() 40 | if result.successful: 41 | self.wg_quick_bin = result.output 42 | result = Command("whereis iptables | tr ' ' '\n' | grep bin").run() 43 | if result.successful: 44 | self.iptables_bin = result.output 45 | from linguard.core.models import interfaces 46 | self.interfaces = interfaces 47 | 48 | def load(self, config: "WireguardConfig"): 49 | self.endpoint = config.endpoint or self.endpoint 50 | if not self.endpoint: 51 | warning("No endpoint specified. Retrieving public IP address...") 52 | self.set_default_endpoint() 53 | self.wg_bin = config.wg_bin or self.wg_bin 54 | self.wg_quick_bin = config.wg_quick_bin or self.wg_quick_bin 55 | self.iptables_bin = config.iptables_bin or self.iptables_bin 56 | if config.interfaces: 57 | self.interfaces.set_contents(config.interfaces) 58 | for iface in self.interfaces.values(): 59 | iface.conf_file = os.path.join(self.interfaces_folder, iface.name) + ".conf" 60 | iface.save() 61 | 62 | def set_default_endpoint(self): 63 | try: 64 | self.endpoint = request.urlopen(self.__IP_RETRIEVER_URL).read().decode("utf-8") 65 | debug(f"Public IP address is {self.endpoint}. This will be used as default endpoint.") 66 | except Exception as e: 67 | error(f"Unable to obtain server's public IP address: {e}") 68 | ip = (Command(f"ip a show {get_default_gateway()} | grep inet | head -n1 | xargs | cut -d ' ' -f2") 69 | .run().output) 70 | self.endpoint = ip.split("/")[0] 71 | if not self.endpoint: 72 | error("Unable to automatically set endpoint.") 73 | return 74 | warning(f"Server endpoint set to {self.endpoint}: this might not be a public IP address!") 75 | 76 | @classmethod 77 | def __from_yaml_dict__(cls, # type: Type[Y] 78 | dct, # type: Dict[str, Any] 79 | yaml_tag="" 80 | ): # type: (...) -> Y 81 | config = WireguardConfig() 82 | config.endpoint = dct.get("endpoint", None) or config.endpoint 83 | config.wg_bin = dct.get("wg_bin", None) or config.wg_bin 84 | config.wg_quick_bin = dct.get("wg_quick_bin", None) or config.wg_quick_bin 85 | config.iptables_bin = dct.get("iptables_bin", None) or config.iptables_bin 86 | config.interfaces = dct.get("interfaces", None) or config.interfaces 87 | for iface in config.interfaces.values(): 88 | iface.conf_file = os.path.join(config.interfaces_folder, iface.name) + ".conf" 89 | iface.save() 90 | return config 91 | 92 | def __to_yaml_dict__(self): # type: (...) -> Dict[str, Any] 93 | return { 94 | "endpoint": self.endpoint, 95 | "wg_bin": self.wg_bin, 96 | "wg_quick_bin": self.wg_quick_bin, 97 | "iptables_bin": self.iptables_bin, 98 | "interfaces": self.interfaces 99 | } 100 | 101 | def apply(self): 102 | super(WireguardConfig, self).apply() 103 | for iface in self.interfaces.values(): 104 | was_up = iface.is_up 105 | iface.down() 106 | if os.path.exists(iface.conf_file): 107 | os.remove(iface.conf_file) 108 | iface.conf_file = os.path.join(self.interfaces_folder, iface.name) + ".conf" 109 | if was_up: 110 | iface.up() 111 | 112 | 113 | config = WireguardConfig() 114 | -------------------------------------------------------------------------------- /linguard/core/drivers/traffic_storage_driver.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import datetime 3 | from typing import Dict, Any, Type 4 | 5 | from yamlable import YamlAble, Y 6 | 7 | from linguard.core.models import interfaces 8 | from linguard.core.utils.tools import run_tool 9 | 10 | 11 | # Wireguard treats tx data as data sent by the server and rx data as data received by the server. 12 | # Since we're taking the client approach, which is, how much data the client is receiving or 13 | # transmitting, we need to invert the rx and tx values wireguard provides when assigning them to 14 | # peers, but we will maintain them for interfaces. For instance, if a peer downloads a 2GB file, 15 | # wireguard will say that the interface transmitted (tx) 2GB, and that is correct for the interface, 16 | # but for the peer it would mean that it received (rx) 2GB. To sum up: what the peer is receiving is 17 | # what the interface is transmitting, and the other way around. 18 | 19 | 20 | class TrafficData: 21 | 22 | def __init__(self, rx_bytes: int, tx_bytes: int, last_handshake: datetime = None): 23 | self.rx = rx_bytes 24 | self.tx = tx_bytes 25 | self.last_handshake = last_handshake 26 | 27 | 28 | class TrafficStorageDriver(YamlAble): 29 | 30 | DEFAULT_TIMESTAMP_FORMAT = "%d/%m/%Y %H:%M:%S" 31 | 32 | def __init__(self, timestamp_format: str = DEFAULT_TIMESTAMP_FORMAT): 33 | self.timestamp_format = timestamp_format 34 | 35 | @classmethod 36 | def get_name(cls) -> str: 37 | pass 38 | 39 | @staticmethod 40 | def get_session_data() -> Dict[str, TrafficData]: 41 | """ 42 | Get traffic data of current session. This will only retrieve data from running interfaces and since the last 43 | time they were started. 44 | 45 | :return: A dictionary containing traffic data of peers and interfaces, indexed by their names. 46 | """ 47 | dct = {} 48 | json_data = run_tool("wg-json").output 49 | data = json.loads(json_data) 50 | for iface in interfaces.values(): 51 | if iface.name not in data: 52 | continue 53 | iface_rx = 0 54 | iface_tx = 0 55 | for peer in iface.peers.values(): 56 | if peer.public_key not in data[iface.name]["peers"]: 57 | continue 58 | peer_data = data[iface.name]["peers"][peer.public_key] 59 | peer_rx = 0 60 | peer_tx = 0 61 | if "transferRx" in peer_data: 62 | peer_tx = int(peer_data["transferRx"]) 63 | if "transferTx" in peer_data: 64 | peer_rx = int(peer_data["transferTx"]) 65 | last_handshake = None 66 | if "latestHandshake" in peer_data: 67 | last_handshake = datetime.fromtimestamp(int(peer_data["latestHandshake"])) 68 | iface_tx += peer_rx 69 | iface_rx += peer_tx 70 | dct[peer.uuid] = TrafficData(peer_rx, peer_tx, last_handshake) 71 | dct[iface.uuid] = TrafficData(iface_rx, iface_tx) 72 | return dct 73 | 74 | def get_session_and_stored_data(self) -> Dict[datetime, Dict[str, TrafficData]]: 75 | """ 76 | Get the stored traffic data and merge it with the current session's data. 77 | 78 | :return: 79 | """ 80 | stored_traffic = self.load_data() 81 | session_traffic = self.get_session_data() 82 | if len(stored_traffic) > 0: 83 | for device, traffic in session_traffic.items(): 84 | # Look for last registered data of device 85 | for data in reversed(list(stored_traffic.values())): 86 | if device in data: 87 | traffic.rx += data[device].rx 88 | traffic.tx += data[device].tx 89 | break 90 | if len(session_traffic) > 0: 91 | stored_traffic[datetime.now()] = session_traffic 92 | return stored_traffic 93 | 94 | def save_data(self): 95 | """ 96 | Save updated traffic data. 97 | 98 | :return: 99 | """ 100 | pass 101 | 102 | def load_data(self) -> Dict[datetime, Dict[str, TrafficData]]: 103 | """ 104 | Get stored traffic data of all devices. 105 | 106 | :return: A dictionary containing traffic data of interfaces and peers, indexed by timestamp. 107 | """ 108 | pass 109 | 110 | def __to_yaml_dict__(self): # type: (...) -> Dict[str, Any] 111 | return { 112 | "timestamp_format": self.timestamp_format 113 | } 114 | 115 | @classmethod 116 | def __from_yaml_dict__(cls, # type: Type[Y] 117 | dct, # type: Dict[str, Any] 118 | yaml_tag="" 119 | ): # type: (...) -> Y 120 | return TrafficStorageDriver(dct.get("timestamp_format", None)) 121 | -------------------------------------------------------------------------------- /docs/source/_build/html/_static/pygments.css: -------------------------------------------------------------------------------- 1 | pre { line-height: 125%; } 2 | td.linenos .normal { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } 3 | span.linenos { color: inherit; background-color: transparent; padding-left: 5px; padding-right: 5px; } 4 | td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } 5 | span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; } 6 | .highlight .hll { background-color: #ffffcc } 7 | .highlight { background: #f8f8f8; } 8 | .highlight .c { color: #8f5902; font-style: italic } /* Comment */ 9 | .highlight .err { color: #a40000; border: 1px solid #ef2929 } /* Error */ 10 | .highlight .g { color: #000000 } /* Generic */ 11 | .highlight .k { color: #004461; font-weight: bold } /* Keyword */ 12 | .highlight .l { color: #000000 } /* Literal */ 13 | .highlight .n { color: #000000 } /* Name */ 14 | .highlight .o { color: #582800 } /* Operator */ 15 | .highlight .x { color: #000000 } /* Other */ 16 | .highlight .p { color: #000000; font-weight: bold } /* Punctuation */ 17 | .highlight .ch { color: #8f5902; font-style: italic } /* Comment.Hashbang */ 18 | .highlight .cm { color: #8f5902; font-style: italic } /* Comment.Multiline */ 19 | .highlight .cp { color: #8f5902 } /* Comment.Preproc */ 20 | .highlight .cpf { color: #8f5902; font-style: italic } /* Comment.PreprocFile */ 21 | .highlight .c1 { color: #8f5902; font-style: italic } /* Comment.Single */ 22 | .highlight .cs { color: #8f5902; font-style: italic } /* Comment.Special */ 23 | .highlight .gd { color: #a40000 } /* Generic.Deleted */ 24 | .highlight .ge { color: #000000; font-style: italic } /* Generic.Emph */ 25 | .highlight .gr { color: #ef2929 } /* Generic.Error */ 26 | .highlight .gh { color: #000080; font-weight: bold } /* Generic.Heading */ 27 | .highlight .gi { color: #00A000 } /* Generic.Inserted */ 28 | .highlight .go { color: #888888 } /* Generic.Output */ 29 | .highlight .gp { color: #745334 } /* Generic.Prompt */ 30 | .highlight .gs { color: #000000; font-weight: bold } /* Generic.Strong */ 31 | .highlight .gu { color: #800080; font-weight: bold } /* Generic.Subheading */ 32 | .highlight .gt { color: #a40000; font-weight: bold } /* Generic.Traceback */ 33 | .highlight .kc { color: #004461; font-weight: bold } /* Keyword.Constant */ 34 | .highlight .kd { color: #004461; font-weight: bold } /* Keyword.Declaration */ 35 | .highlight .kn { color: #004461; font-weight: bold } /* Keyword.Namespace */ 36 | .highlight .kp { color: #004461; font-weight: bold } /* Keyword.Pseudo */ 37 | .highlight .kr { color: #004461; font-weight: bold } /* Keyword.Reserved */ 38 | .highlight .kt { color: #004461; font-weight: bold } /* Keyword.Type */ 39 | .highlight .ld { color: #000000 } /* Literal.Date */ 40 | .highlight .m { color: #990000 } /* Literal.Number */ 41 | .highlight .s { color: #4e9a06 } /* Literal.String */ 42 | .highlight .na { color: #c4a000 } /* Name.Attribute */ 43 | .highlight .nb { color: #004461 } /* Name.Builtin */ 44 | .highlight .nc { color: #000000 } /* Name.Class */ 45 | .highlight .no { color: #000000 } /* Name.Constant */ 46 | .highlight .nd { color: #888888 } /* Name.Decorator */ 47 | .highlight .ni { color: #ce5c00 } /* Name.Entity */ 48 | .highlight .ne { color: #cc0000; font-weight: bold } /* Name.Exception */ 49 | .highlight .nf { color: #000000 } /* Name.Function */ 50 | .highlight .nl { color: #f57900 } /* Name.Label */ 51 | .highlight .nn { color: #000000 } /* Name.Namespace */ 52 | .highlight .nx { color: #000000 } /* Name.Other */ 53 | .highlight .py { color: #000000 } /* Name.Property */ 54 | .highlight .nt { color: #004461; font-weight: bold } /* Name.Tag */ 55 | .highlight .nv { color: #000000 } /* Name.Variable */ 56 | .highlight .ow { color: #004461; font-weight: bold } /* Operator.Word */ 57 | .highlight .w { color: #f8f8f8; text-decoration: underline } /* Text.Whitespace */ 58 | .highlight .mb { color: #990000 } /* Literal.Number.Bin */ 59 | .highlight .mf { color: #990000 } /* Literal.Number.Float */ 60 | .highlight .mh { color: #990000 } /* Literal.Number.Hex */ 61 | .highlight .mi { color: #990000 } /* Literal.Number.Integer */ 62 | .highlight .mo { color: #990000 } /* Literal.Number.Oct */ 63 | .highlight .sa { color: #4e9a06 } /* Literal.String.Affix */ 64 | .highlight .sb { color: #4e9a06 } /* Literal.String.Backtick */ 65 | .highlight .sc { color: #4e9a06 } /* Literal.String.Char */ 66 | .highlight .dl { color: #4e9a06 } /* Literal.String.Delimiter */ 67 | .highlight .sd { color: #8f5902; font-style: italic } /* Literal.String.Doc */ 68 | .highlight .s2 { color: #4e9a06 } /* Literal.String.Double */ 69 | .highlight .se { color: #4e9a06 } /* Literal.String.Escape */ 70 | .highlight .sh { color: #4e9a06 } /* Literal.String.Heredoc */ 71 | .highlight .si { color: #4e9a06 } /* Literal.String.Interpol */ 72 | .highlight .sx { color: #4e9a06 } /* Literal.String.Other */ 73 | .highlight .sr { color: #4e9a06 } /* Literal.String.Regex */ 74 | .highlight .s1 { color: #4e9a06 } /* Literal.String.Single */ 75 | .highlight .ss { color: #4e9a06 } /* Literal.String.Symbol */ 76 | .highlight .bp { color: #3465a4 } /* Name.Builtin.Pseudo */ 77 | .highlight .fm { color: #000000 } /* Name.Function.Magic */ 78 | .highlight .vc { color: #000000 } /* Name.Variable.Class */ 79 | .highlight .vg { color: #000000 } /* Name.Variable.Global */ 80 | .highlight .vi { color: #000000 } /* Name.Variable.Instance */ 81 | .highlight .vm { color: #000000 } /* Name.Variable.Magic */ 82 | .highlight .il { color: #990000 } /* Literal.Number.Integer.Long */ -------------------------------------------------------------------------------- /linguard/web/static/js/modules/utils.mjs: -------------------------------------------------------------------------------- 1 | (function($) { 2 | $("[data-toggle=popover]").popover(); 3 | // Add active state to sidebar nav links 4 | const path = window.location.href; // because the 'href' property of the DOM element is the absolute path 5 | $("#layoutSidenav_nav .sb-sidenav a.nav-link").each(function() { 6 | if (this.href === path) { 7 | $(this).addClass("active"); 8 | } 9 | }); 10 | // Toggle the side navigation 11 | $("#sidebarToggle").on("click", function(e) { 12 | e.preventDefault(); 13 | $("body").toggleClass("sb-sidenav-toggled"); 14 | }); 15 | })(jQuery); 16 | 17 | export const AlertType = Object.freeze({ 18 | "DANGER": "danger", 19 | "WARN": "warning", 20 | "SUCCESS": "success", 21 | "INFO": "info", 22 | }); 23 | 24 | /** 25 | * Perform a POST request to a given url and display an alert if the server returns a non-successful HTTP code. 26 | * This request may include JSON data. 27 | * @param url URL where the request will be sent. 28 | * @param alertContainer Id of the HTML element where to place an alert if something goes wrong. 29 | * @param alertType Type of the boostrap alert to be shown if anything goes wrong (danger, warning, info...). 30 | * @param loadFeedback Id of the HTML element to be used as visual feedback (a loading circle or bar, for example). 31 | * @param jsonData [Optional] JSON data to post. 32 | */ 33 | export function postJSON(url, alertContainer, alertType = AlertType.DANGER, loadFeedback, jsonData = null) { 34 | const loadItem = $("#"+loadFeedback); 35 | $.ajax({ 36 | type: "post", 37 | url: url, 38 | data: jsonData, 39 | dataType: 'json', 40 | contentType: 'application/json', 41 | beforeSend : function () { 42 | loadItem.show(); 43 | }, 44 | success: function () { 45 | location.reload(); 46 | }, 47 | error: function(resp) { 48 | prependAlert(alertContainer, "Oops, something went wrong: " + resp["responseText"], 49 | alertType); 50 | }, 51 | complete: function () { 52 | loadItem.hide(); 53 | }, 54 | }); 55 | } 56 | 57 | let previousAlert; 58 | 59 | /** 60 | * Prepend a bootstrap alert to a given HTML object. 61 | * @param prependTo Id of the HTML object to prepend the alert. 62 | * @param text Text of the alert. 63 | * @param alertType Type of the alert: danger, warning, info... 64 | * @param delay Amount of time (millis) before automatically closing the alert. Use 0 to avoid auto close. 65 | * @param unique 66 | * @param onEnd 67 | */ 68 | export function prependAlert(prependTo, text, alertType = AlertType.DANGER, delay=7000, unique = false, onEnd) { 69 | const salt = getRndInteger(); 70 | const alertId = "alert-"+salt; 71 | const closeId = "close-"+salt; 72 | 73 | let iconClass = "fas fa-exclamation-circle"; 74 | switch (alertType) { 75 | case AlertType.WARN: 76 | iconClass = "fas fa-exclamation-triangle" 77 | break; 78 | case AlertType.SUCCESS: 79 | iconClass = "fas fa-check-circle" 80 | break; 81 | case AlertType.INFO: 82 | iconClass = "fas fa-info-circle" 83 | break; 84 | default: 85 | break; 86 | } 87 | let icon = '' 88 | 89 | const alert = "
" + icon + text +"\n" + 91 | " \n" + 94 | "
" 95 | const container = $("#"+prependTo); 96 | if (unique && previousAlert !== undefined && previousAlert.type !== AlertType.DANGER) { 97 | fadeHTMLElement(previousAlert.id, 0, 200, 500, onEnd); 98 | } 99 | 100 | previousAlert = {"id": alertId, "type": alertType}; 101 | $(alert).prependTo(container).hide().slideDown(); 102 | $("#"+closeId).click(function (e) { 103 | fadeHTMLElement(alertId, 0, 200, 500, onEnd); 104 | }); 105 | if (delay > 0) { 106 | fadeHTMLElement(alertId, delay, 500, 500, onEnd); 107 | } 108 | } 109 | 110 | /** 111 | * Generate a random integer between min and max (both included). 112 | * @param min 113 | * @param max 114 | * @returns {number} 115 | */ 116 | function getRndInteger(min=0, max=9999999) { 117 | return Math.floor(Math.random() * (max - min + 1) ) + min; 118 | } 119 | 120 | /** 121 | * Fade out an html element and remove it. 122 | * @param id Id of the element. 123 | * @param delay Time (millis) before fade out. 124 | * @param fadeDuration Duration (millis) of the fade effect. 125 | * @param slideDuration Duration (millis) of the slide effect. 126 | * @param onEnd Function to be called once the alert is gone and removed. 127 | */ 128 | function fadeHTMLElement(id, delay, fadeDuration = 500, slideDuration = 500, onEnd = null) { 129 | setTimeout(function() { 130 | $("#"+id).fadeTo(fadeDuration, 0).slideUp(slideDuration, function(){ 131 | $(this).remove(); 132 | if (typeof(onEnd) === "function") { 133 | onEnd(); 134 | } 135 | }); 136 | }, delay); 137 | } 138 | -------------------------------------------------------------------------------- /linguard/tests/default/test_setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | from linguard.common.properties import global_properties 6 | from linguard.tests.utils import default_cleanup, is_http_success, login, get_testing_app 7 | 8 | url = "/setup" 9 | 10 | 11 | @pytest.fixture(autouse=True) 12 | def cleanup(): 13 | yield 14 | default_cleanup() 15 | 16 | 17 | @pytest.fixture 18 | def client(): 19 | with get_testing_app().test_client() as client: 20 | global_properties.setup_required = True 21 | yield client 22 | 23 | 24 | def test_get(client): 25 | login(client) 26 | response = client.get(url) 27 | assert is_http_success(response.status_code) 28 | assert "Setup".encode() in response.data 29 | 30 | 31 | def test_redirect(client): 32 | login(client) 33 | response = client.get("/dashboard") 34 | assert is_http_success(response.status_code) 35 | assert response.status_code == 302 36 | assert "/setup".encode() in response.data 37 | 38 | 39 | def remove_setup_file(): 40 | os.remove(global_properties.setup_filepath) 41 | 42 | 43 | def test_post_ok(client): 44 | login(client) 45 | response = client.post(url, data={ 46 | "app_endpoint": "vpn.example.com", "app_iptables_bin": "/dev/null", "app_wg_bin": "/dev/null", 47 | "app_wg_quick_bin": "/dev/null", "log_overwrite": False, "traffic_enabled": True 48 | }) 49 | assert is_http_success(response.status_code) 50 | assert "Setup".encode() not in response.data 51 | 52 | remove_setup_file() 53 | 54 | response = client.post(url, data={ 55 | "app_endpoint": "10.0.0.1", "app_iptables_bin": "/dev/null", "app_wg_bin": "/dev/null", 56 | "app_wg_quick_bin": "/dev/null", "log_overwrite": False, "traffic_enabled": True 57 | }) 58 | assert is_http_success(response.status_code) 59 | assert "Setup".encode() not in response.data 60 | 61 | 62 | def test_post_ko(client): 63 | login(client) 64 | 65 | response = client.post(url, data={ 66 | "app_endpoint": "", "app_iptables_bin": "/dev/null", "app_wg_bin": "/dev/null", 67 | "app_wg_quick_bin": "/dev/null", "log_overwrite": False, "traffic_enabled": True 68 | }) 69 | assert is_http_success(response.status_code) 70 | assert "Setup".encode() in response.data 71 | 72 | response = client.post(url, data={ 73 | "app_endpoint": 100, "app_iptables_bin": "/dev/null", "app_wg_bin": "/dev/null", 74 | "app_wg_quick_bin": "/dev/null", "log_overwrite": False, "traffic_enabled": True 75 | }) 76 | assert is_http_success(response.status_code) 77 | assert "Setup".encode() in response.data 78 | 79 | response = client.post(url, data={ 80 | "app_endpoint": "vpn.example.com", "app_iptables_bin": "/dev/nulls", "app_wg_bin": "/dev/null", 81 | "app_wg_quick_bin": "/dev/null", "log_overwrite": False, "traffic_enabled": True 82 | }) 83 | assert is_http_success(response.status_code) 84 | assert "Setup".encode() in response.data 85 | 86 | response = client.post(url, data={ 87 | "app_endpoint": "vpn.example.com", "app_iptables_bin": "/dev/null", "app_wg_bin": "/dev/nullg", 88 | "app_wg_quick_bin": "/dev/null", "log_overwrite": False, "traffic_enabled": True 89 | }) 90 | assert is_http_success(response.status_code) 91 | assert "Setup".encode() in response.data 92 | 93 | response = client.post(url, data={ 94 | "app_endpoint": "vpn.example.com", "app_iptables_bin": "/dev/null", "app_wg_bin": "/dev/null", 95 | "app_wg_quick_bin": "/dev/nullk", "log_overwrite": False, "traffic_enabled": True 96 | }) 97 | assert is_http_success(response.status_code) 98 | assert "Setup".encode() in response.data 99 | 100 | response = client.post(url, data={ 101 | "app_endpoint": "vpn.example.com", "app_iptables_bin": "", "app_wg_bin": "/dev/null", 102 | "app_wg_quick_bin": "/dev/null", "log_overwrite": False, "traffic_enabled": True 103 | }) 104 | assert is_http_success(response.status_code) 105 | assert "Setup".encode() in response.data 106 | 107 | response = client.post(url, data={ 108 | "app_endpoint": "vpn.example.com", "app_iptables_bin": "/dev/null", "app_wg_bin": "", 109 | "app_wg_quick_bin": "/dev/null", "log_overwrite": False, "traffic_enabled": True 110 | }) 111 | assert is_http_success(response.status_code) 112 | assert "Setup".encode() in response.data 113 | 114 | response = client.post(url, data={ 115 | "app_endpoint": "vpn.example.com", "app_iptables_bin": "/dev/null", "app_wg_bin": "/dev/null", 116 | "app_wg_quick_bin": "", "log_overwrite": False, "traffic_enabled": True 117 | }) 118 | assert is_http_success(response.status_code) 119 | assert "Setup".encode() in response.data 120 | 121 | response = client.post(url, data={ 122 | "app_endpoint": 1, "app_iptables_bin": "/dev/null", "app_wg_bin": "/dev/null", 123 | "app_wg_quick_bin": "", "log_overwrite": False, "traffic_enabled": True 124 | }) 125 | assert is_http_success(response.status_code) 126 | assert "Setup".encode() in response.data 127 | 128 | response = client.post(url, data={ 129 | "app_endpoint": "vpn.example.com", "app_iptables_bin": 1231, "app_wg_bin": "/dev/null", 130 | "app_wg_quick_bin": "", "log_overwrite": False, "traffic_enabled": True 131 | }) 132 | assert is_http_success(response.status_code) 133 | assert "Setup".encode() in response.data 134 | -------------------------------------------------------------------------------- /linguard/web/static/assets/img/error-404-monochrome.svg: -------------------------------------------------------------------------------- 1 | error-404-monochrome -------------------------------------------------------------------------------- /docs/source/_build/html/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | <no title> — Linguard 0.2.0 documentation 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 57 | 101 |
102 |
103 | 114 | 115 | 116 | 117 | 118 | 119 | -------------------------------------------------------------------------------- /linguard/tests/default/test_peers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from linguard.core.models import interfaces, Peer 4 | from linguard.tests.utils import default_cleanup, is_http_success, login, create_test_iface, get_testing_app 5 | 6 | url = "/wireguard/peers" 7 | 8 | 9 | @pytest.fixture(autouse=True) 10 | def cleanup(): 11 | yield 12 | default_cleanup() 13 | 14 | 15 | @pytest.fixture 16 | def client(): 17 | with get_testing_app().test_client() as client: 18 | yield client 19 | 20 | 21 | def test_get_edit(client): 22 | login(client) 23 | iface = create_test_iface("iface1", "10.0.0.1/24", 50000) 24 | peer = Peer(name="peer1", description="", ipv4_address="10.0.0.2/24", nat=False, interface=iface, dns1="8.8.8.8") 25 | iface.add_peer(peer) 26 | interfaces[iface.uuid] = iface 27 | response = client.get(f"{url}/{peer.uuid}") 28 | assert is_http_success(response.status_code) 29 | assert peer.name.encode() in response.data 30 | assert peer.ipv4_address.encode() in response.data 31 | assert peer.dns1.encode() in response.data 32 | assert iface.name.encode() in response.data 33 | 34 | 35 | def test_post_edit_ok(client): 36 | login(client) 37 | iface = create_test_iface("iface1", "10.0.0.1/24", 50000) 38 | peer = Peer(name="peer1", description="", ipv4_address="10.0.0.2/24", nat=False, interface=iface, dns1="8.8.8.8") 39 | iface.add_peer(peer) 40 | interfaces[iface.uuid] = iface 41 | 42 | response = client.post(f"{url}/{peer.uuid}", data={ 43 | "name": peer.name, "nat": peer.nat, "description": peer.description, "ipv4": "10.0.0.10/24", "dns1": peer.dns1, 44 | "dns2": "10.10.4.4", "interface": peer.interface.name 45 | }) 46 | assert is_http_success(response.status_code) 47 | assert "Error".encode() not in response.data 48 | 49 | response = client.post(f"{url}/{peer.uuid}", data={ 50 | "name": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 51 | "nat": peer.nat, "description": peer.description, "ipv4": "10.0.0.254/24", "dns1": peer.dns1, 52 | "dns2": "10.10.4.4", "interface": peer.interface.name 53 | }) 54 | assert is_http_success(response.status_code) 55 | assert "Error".encode() not in response.data 56 | 57 | response = client.post(f"{url}/{peer.uuid}", data={ 58 | "name": peer.name, "nat": peer.nat, "description": peer.description, "ipv4": "10.0.0.10/24", "dns1": peer.dns1, 59 | "dns2": "", "interface": peer.interface.name 60 | }) 61 | assert is_http_success(response.status_code) 62 | assert "Error".encode() not in response.data 63 | 64 | 65 | def test_post_edit_ko(client): 66 | login(client) 67 | iface = create_test_iface("iface1", "10.0.0.1/24", 50000) 68 | peer = Peer(name="peer1", description="", ipv4_address="10.0.0.2/24", nat=False, interface=iface, dns1="8.8.8.8") 69 | iface.add_peer(peer) 70 | interfaces[iface.uuid] = iface 71 | 72 | response = client.post(f"{url}/{peer.uuid}", data={ 73 | "name": peer.name, "nat": peer.nat, "description": peer.description, "ipv4": "10.0.0.1/24", "dns1": peer.dns1, 74 | "dns2": "10.10.4.4", "interface": peer.interface.name 75 | }) 76 | assert is_http_success(response.status_code) 77 | assert "Error".encode() in response.data 78 | 79 | response = client.post(f"{url}/{peer.uuid}", data={ 80 | "name": peer.name, "nat": peer.nat, "description": peer.description, "ipv4": "10.0.0.0/24", "dns1": peer.dns1, 81 | "dns2": "10.10.4.4", "interface": peer.interface.name 82 | }) 83 | assert is_http_success(response.status_code) 84 | assert "Error".encode() in response.data 85 | 86 | response = client.post(f"{url}/{peer.uuid}", data={ 87 | "name": peer.name, "nat": peer.nat, "description": peer.description, "ipv4": "10.0.0.255/24", "dns1": peer.dns1, 88 | "dns2": "10.10.4.4", "interface": peer.interface.name 89 | }) 90 | assert is_http_success(response.status_code) 91 | assert "Error".encode() in response.data 92 | 93 | response = client.post(f"{url}/{peer.uuid}", data={ 94 | "name": peer.name, "nat": peer.nat, "description": peer.description, "ipv4": "10.0.0.256/24", "dns1": peer.dns1, 95 | "dns2": "10.10.4.4", "interface": peer.interface.name 96 | }) 97 | assert is_http_success(response.status_code) 98 | assert "Error".encode() in response.data 99 | 100 | response = client.post(f"{url}/{peer.uuid}", data={ 101 | "name": peer.name, "nat": peer.nat, "description": peer.description, "ipv4": "10.0.1.2/24", "dns1": peer.dns1, 102 | "dns2": "10.10.4.4", "interface": peer.interface.name 103 | }) 104 | assert is_http_success(response.status_code) 105 | assert "Error".encode() in response.data 106 | 107 | response = client.post(f"{url}/{peer.uuid}", data={ 108 | "name": peer.name, "nat": peer.nat, "description": peer.description, "ipv4": "aaaa", 109 | "dns1": peer.dns1, "dns2": peer.dns2, "interface": peer.interface.name 110 | }) 111 | assert is_http_success(response.status_code) 112 | assert "Error".encode() in response.data 113 | 114 | response = client.post(f"{url}/{peer.uuid}", data={ 115 | "name": peer.name, "nat": peer.nat, "description": peer.description, "ipv4": "10.0.1", 116 | "dns1": peer.dns1, "dns2": peer.dns2, "interface": peer.interface.name 117 | }) 118 | assert is_http_success(response.status_code) 119 | assert "Error".encode() in response.data 120 | 121 | response = client.post(f"{url}/{peer.uuid}", data={ 122 | "name": peer.name, "nat": peer.nat, "description": peer.description, "ipv4": "10.0.1.1/21/1.0", 123 | "dns1": peer.dns1, "dns2": peer.dns2, "interface": peer.interface.name 124 | }) 125 | assert is_http_success(response.status_code) 126 | assert "Error".encode() in response.data 127 | 128 | response = client.post(f"{url}/{peer.uuid}", data={ 129 | "name": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaA", 130 | "nat": peer.nat, "description": peer.description, "ipv4": peer.ipv4_address, "dns1": peer.dns1, 131 | "dns2": "10.10.4.4", "interface": peer.interface.name 132 | }) 133 | assert is_http_success(response.status_code) 134 | assert "Error".encode() in response.data 135 | 136 | response = client.post(f"{url}/{peer.uuid}", data={ 137 | "name": peer.name, "nat": peer.nat, "description": peer.description, "ipv4": peer.ipv4_address, "dns1": "", 138 | "dns2": peer.dns2, "interface": peer.interface.name 139 | }) 140 | assert is_http_success(response.status_code) 141 | assert "Error".encode() in response.data 142 | -------------------------------------------------------------------------------- /linguard/web/templates/web/profile.html: -------------------------------------------------------------------------------- 1 | {% extends "web/web-main.html" %} 2 | 3 | {% block content %} 4 |
5 |
6 |
7 | 8 | Back 9 | 10 |

{{ title }}

11 | 14 |
15 |
16 | {% if success %} 17 | 23 | {% if warning %} 24 | 30 | {% endif %} 31 | {% elif error %} 32 | 38 | {% endif %} 39 |
40 | 41 | 42 |
43 | {{ profile_form.hidden_tag() }} 44 | 45 |
46 |
47 | 48 | Basic info 49 |
50 |
51 |
52 | {% for error in profile_form.username.errors %} 53 | 56 | {% endfor %} 57 |
58 |
59 |
60 | {{ profile_form.username.label() }} 61 | {{ profile_form.username(class="form-control") }} 62 |
63 |
64 |
65 | {{ profile_form.submit(class="btn btn-warning") }} 66 |
67 |
68 |
69 | 70 |
71 | 72 | 73 |
74 | {{ password_reset_form.hidden_tag() }} 75 | 76 |
77 |
78 | 79 | Reset password 80 |
81 |
82 |
83 | {% for error in password_reset_form.old_password.errors %} 84 | 87 | {% endfor %} 88 | {% for error in password_reset_form.new_password.errors %} 89 | 92 | {% endfor %} 93 | {% for error in password_reset_form.confirm.errors %} 94 | 97 | {% endfor %} 98 |
99 |
100 |
101 | {{ password_reset_form.old_password.label() }} 102 | {{ password_reset_form.old_password(class="form-control") }} 103 |
104 |
105 |
106 |
107 | {{ password_reset_form.new_password.label() }} 108 | {{ password_reset_form.new_password(class="form-control") }} 109 |
110 |
111 |
112 |
113 | {{ password_reset_form.confirm.label() }} 114 | {{ password_reset_form.confirm(class="form-control") }} 115 |
116 |
117 |
118 | {{ password_reset_form.submit(class="btn btn-warning") }} 119 |
120 |
121 |
122 |
123 |
124 |
125 | {% endblock %} --------------------------------------------------------------------------------