├── .flake8 ├── .gitignore ├── .pylintrc ├── Makefile ├── main.py ├── pics └── ex.jpg ├── readme.md ├── req.txt ├── sql └── stats.sql └── src ├── __init__.py ├── preprocess.py └── utils.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 105 3 | max-complexity = 5 4 | show-source = true 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | .idea/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | .pypirc 8 | 9 | 10 | # C extensions 11 | *.so 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | pip-wheel-metadata/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/\ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderprojectm 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | .vscode/ 135 | © 2022 GitHub, Inc. 136 | Terms 137 | Privacy 138 | Security 139 | Status 140 | Docs 141 | Contact GitHub 142 | Pricing 143 | API 144 | Training 145 | Blog 146 | About 147 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MESSAGES CONTROL] 2 | disable= 3 | R1732, 4 | W0612 5 | output-format = colorized 6 | max-locals=18 -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: help clean build_eggs build 2 | 3 | help: 4 | @echo -e " \033[0;36mhelp\033[0m - print this message" 5 | @echo -e " \033[0;36mclean\033[0m - remove artifacts" 6 | @echo -e " \033[0;36mpackage\033[0m - package submittable artifacts" 7 | @echo -e " \033[0;36mbuild\033[0m - clean -> lint -> test -> package" 8 | 9 | clean: 10 | @echo -e "\033[0;36mClean\033[0m" 11 | rm -rf .mypy_cache/ .pytest_cache/ htmlcov/ .coverage 12 | rm -rf build/ dist/ 13 | find . -name '*.pyc' -exec rm -rf {} + 14 | 15 | build: 16 | @echo -e "\033[0;36mBuild Eggs\033[0m" 17 | isort src 18 | black --skip-string-normalization --line-length=100 src 19 | flake8 src 20 | pylint src 21 | 22 | 23 | build: clean build -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | from src.preprocess import ( 2 | convert_output, 3 | get_user_public_id, 4 | get_users, 5 | read_wg0, 6 | run_shell_cmd, 7 | ) 8 | from src.utils import insert, create_parser 9 | 10 | 11 | def main(): 12 | args = create_parser() 13 | 14 | for stat in convert_output(run_shell_cmd("wg show all dump")): 15 | print(stat) 16 | insert(table="stats", column_values=stat) 17 | 18 | for user_c in read_wg0(): 19 | print(user_c) 20 | insert(table="wg0_users", column_values=user_c) 21 | 22 | users_path = args.users_path 23 | for user_a in get_users(users_path): 24 | print(user_a) 25 | insert(table="user_map", column_values=get_user_public_id(users_path, user_a)) 26 | 27 | 28 | if __name__ == "__main__": 29 | main() 30 | -------------------------------------------------------------------------------- /pics/ex.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/meanother/wg-stats/a57783955d5a1619c4a2d4121da692a1a8089468/pics/ex.jpg -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Wireguard lite monitoring 2 | 3 | 4 | ## sqlite3.37 on Ubuntu 5 | ```shell 6 | cd ~ ;\ 7 | wget https://github.com/nalgeon/sqlite/releases/download/3.38.0/sqlite3-ubuntu ;\ 8 | mv sqlite3-ubuntu sqlite3 ;\ 9 | chmod +x sqlite3 10 | ``` 11 | 12 | ## Run 13 | ```shell 14 | python main.py --users_path /path/to/clients/keys 15 | ``` 16 | 17 | ## Check stats 18 | ```shell 19 | ~/sqlite3 $HOME/db/wg-stats.db -box 20 | ``` 21 | 22 | ## Check stats hotkey 23 | ```shell 24 | vim $HOME/check.sh 25 | 26 | $HOME/code/wg-stats/env/bin/python $HOME/code/wg-stats/main.py --users_path /root/clients/ > /dev/null ;\ 27 | $HOME/sqlite3 -box $HOME/db/wg-stats.db -box "select * from v_stats;" ".q" 28 | 29 | chmod +x check.sh 30 | vim .zshrc 31 | alias pp="$HOME/check.sh" 32 | 33 | source .zshrc 34 | # RUN 35 | pp 36 | ``` 37 | 38 | ![img](pics/ex.jpg) 39 | -------------------------------------------------------------------------------- /req.txt: -------------------------------------------------------------------------------- 1 | astroid==2.11.5 2 | black==22.3.0 3 | click==8.1.3 4 | dill==0.3.5.1 5 | flake8==4.0.1 6 | isort==5.10.1 7 | lazy-object-proxy==1.7.1 8 | loguru==0.6.0 9 | mccabe==0.6.1 10 | mypy-extensions==0.4.3 11 | pathspec==0.9.0 12 | platformdirs==2.5.2 13 | pycodestyle==2.8.0 14 | pyflakes==2.4.0 15 | pylint==2.13.9 16 | tomli==2.0.1 17 | typing_extensions==4.2.0 18 | wrapt==1.14.1 19 | -------------------------------------------------------------------------------- /sql/stats.sql: -------------------------------------------------------------------------------- 1 | create table IF NOT EXISTS stats( 2 | id integer primary key AUTOINCREMENT, 3 | interface text null, 4 | peers text null, 5 | preshared_keys text null, 6 | endpoints text null, 7 | ip_address text null, 8 | allowed_ips text null, 9 | latest_handshakes text null, 10 | transfer_receiver real null, 11 | transfer_sender real null, 12 | persistent_keepalive text null, 13 | event_ts timestamp not null 14 | ); 15 | 16 | create table IF NOT EXISTS wg0_users( 17 | id integer primary key AUTOINCREMENT, 18 | peer_key text null, 19 | peer_ip text null, 20 | event_ts timestamp not null 21 | ); 22 | 23 | create table IF NOT EXISTS user_map( 24 | id integer primary key AUTOINCREMENT, 25 | username text null, 26 | key text null, 27 | event_ts timestamp not null 28 | ); 29 | 30 | create view v_stats as 31 | with user_map_ as 32 | ( 33 | select *, row_number() over(partition by username order by event_ts desc) as row_n from user_map 34 | ), 35 | stats_ as 36 | ( 37 | select *, row_number() over(partition by peers order by event_ts desc) as row_n from stats 38 | ) 39 | select 40 | row_number() over(order by s.transfer_sender desc) as "№" 41 | , case 42 | when (julianday('now', 'localtime')-julianday(latest_handshakes))*86400.0/60 < 5 then "Online" 43 | else "Offline" 44 | end as status 45 | , u.username 46 | , s.interface 47 | , s.ip_address 48 | , s.allowed_ips 49 | , s.latest_handshakes 50 | , s.transfer_receiver as transfer_out_gb 51 | , s.transfer_sender as transfer_in_gb 52 | from 53 | user_map_ u 54 | left join stats_ s 55 | on u.key = s.peers 56 | and s.row_n = 1 57 | where 58 | u.row_n = 1 59 | order by transfer_in_gb desc; -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- 1 | """pass""" 2 | -------------------------------------------------------------------------------- /src/preprocess.py: -------------------------------------------------------------------------------- 1 | """Preprocessing raw data""" 2 | import os 3 | import re 4 | import subprocess 5 | from datetime import datetime 6 | 7 | 8 | def run_shell_cmd(cmd: str) -> str: 9 | """run shel cmd""" 10 | result = subprocess.Popen( 11 | cmd.split(), 12 | stdin=subprocess.PIPE, 13 | stdout=subprocess.PIPE, 14 | stderr=subprocess.PIPE, 15 | universal_newlines=True, 16 | bufsize=0, 17 | ) 18 | stdout, stderr = result.communicate() 19 | return stdout[:-1] 20 | 21 | 22 | def size_gb(num: str) -> float: 23 | """convert bytes to GB""" 24 | return round(int(num) / 1024 / 1024 / 1024, 3) 25 | 26 | 27 | def get_dt() -> str: 28 | """return now dt""" 29 | return datetime.today().strftime("%Y-%m-%d %H:%M:%S") 30 | 31 | 32 | def to_datetime(timestamp: int) -> str: 33 | """convert unixtime to %Y-%m-%d %H:%M:%S""" 34 | # return datetime.fromtimestamp(int(timestamp) + 60 * 60 * 3).strftime("%Y-%m-%d %H:%M:%S") 35 | return datetime.fromtimestamp(int(timestamp)).strftime("%Y-%m-%d %H:%M:%S") 36 | 37 | 38 | def get_ip(string: str): 39 | """parse raw ip address to x.x.x.x""" 40 | try: 41 | pat = re.compile(r"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}") 42 | return pat.search(string).group() 43 | except AttributeError: 44 | return None 45 | 46 | 47 | def convert_list_to_dict(item: list) -> dict: 48 | """convert str to key:value""" 49 | result = { 50 | "interface": item[0], 51 | "peers": item[1], 52 | "preshared_keys": None if item[2] == "(none)" else item[2], 53 | "endpoints": item[3], 54 | "ip_address": get_ip(item[3]), 55 | "allowed_ips": item[4], 56 | "latest_handshakes": to_datetime(item[5]), 57 | "transfer_receiver": size_gb(item[6]), 58 | "transfer_sender": size_gb(item[7]), 59 | "persistent_keepalive": item[8], 60 | "event_ts": get_dt(), 61 | } 62 | return result 63 | 64 | 65 | def convert_output(result: str) -> list: 66 | """convert str from wg show all dump to List[Dict]""" 67 | result_list = [] 68 | prepare = result.split("\n") 69 | # header = prepare[0].split("\t") 70 | data = [i.split("\t") for i in prepare[1:]] 71 | for row in data: 72 | result_list.append(convert_list_to_dict(row)) 73 | return result_list 74 | 75 | 76 | def read_wg0() -> list: 77 | """parse wg0.conf file""" 78 | result_list = [] 79 | with open("/etc/wireguard/wg0.conf", "r", encoding="utf-8") as file: 80 | raw = file.read() 81 | for line in raw.split("\n\n")[1:-1]: 82 | peer = line.split("\n") 83 | peer_key = peer[1].split(" = ")[1] 84 | peer_ip = peer[2].split(" = ")[1] 85 | result_list.append({"peer_key": peer_key, "peer_ip": peer_ip, "event_ts": get_dt()}) 86 | return result_list 87 | 88 | 89 | def get_users(path: str) -> list: 90 | """get user list from LS path""" 91 | result_list = [] 92 | for dir_name, xpath, files in os.walk(path): 93 | result_list = [file for file in files if "_publickey" in file] 94 | return result_list 95 | 96 | 97 | def get_user_public_id(path: str, username: str) -> dict: 98 | """get file data wor username""" 99 | with open(f"{path}{username}", "r", encoding="utf-8") as file: 100 | key = file.read().strip() 101 | return {"username": username.replace("_publickey", ""), "key": key, "event_ts": get_dt()} 102 | -------------------------------------------------------------------------------- /src/utils.py: -------------------------------------------------------------------------------- 1 | """Utils""" 2 | import argparse 3 | import os 4 | import pathlib 5 | import sqlite3 6 | 7 | from loguru import logger 8 | 9 | log_dir = pathlib.Path.home() 10 | log_dir.joinpath("logs").mkdir(parents=True, exist_ok=True) 11 | log_dir.joinpath("db").mkdir(parents=True, exist_ok=True) 12 | 13 | 14 | logger.add( 15 | log_dir.joinpath("logs").joinpath("wg-statistics.log"), 16 | format="{time} [{level}] {module} {name} {function} - {message}", 17 | level="DEBUG", 18 | compression="zip", 19 | rotation="30 MB", 20 | ) 21 | 22 | conn = sqlite3.connect(os.path.join(log_dir.joinpath("db"), "wg-stats.db")) 23 | cursor = conn.cursor() 24 | 25 | 26 | def _init_db(): 27 | """Инициализирует БД""" 28 | init_path = pathlib.Path(__file__).resolve().parent.parent.joinpath("sql/stats.sql") 29 | with open(init_path, "r", encoding='utf-8') as file: 30 | init_src = file.read() 31 | cursor.executescript(init_src) 32 | conn.commit() 33 | 34 | 35 | def check_db_exists(): 36 | """Проверяет, инициализирована ли БД, если нет — инициализирует""" 37 | cursor.execute( 38 | "SELECT count(*) FROM sqlite_master " 39 | "WHERE name in ('stats', 'wg0_users', 'user_map', 'v_stats')" 40 | ) 41 | table_exists = cursor.fetchall()[0][0] 42 | if table_exists >= 4: 43 | return 44 | _init_db() 45 | 46 | 47 | def insert(table: str, column_values: dict): 48 | """insert Dict to sqlite3 tables""" 49 | columns = ", ".join(column_values.keys()) 50 | values = [tuple(column_values.values())] 51 | placeholders = ", ".join("?" * len(column_values.keys())) 52 | cursor.executemany(f"INSERT INTO {table} " f"({columns}) " f"VALUES ({placeholders})", values) 53 | conn.commit() 54 | 55 | 56 | def create_parser(): 57 | """argument parser""" 58 | parser = argparse.ArgumentParser(description="Options for run service") 59 | parser.add_argument("-p", "--users_path", action="store", dest="users_path", type=str) 60 | args = parser.parse_args() 61 | return args 62 | 63 | 64 | check_db_exists() 65 | --------------------------------------------------------------------------------