├── .dockerignore ├── .editorconfig ├── .github ├── FUNDING.yml └── workflows │ └── dockerimage.yml ├── .gitignore ├── Makefile ├── README.md ├── config.yaml ├── deploy ├── Dockerfile └── requirements.txt ├── linters ├── flake8.ini ├── mypy.ini ├── pylint.ini ├── tox.ini └── vulture-wl.py └── server.py /.dockerignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | /linters/ 3 | /config.mk 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_file = lf 5 | indent_style = tab 6 | indent_size = 4 7 | 8 | [*.{py,yaml}] 9 | indent_style = space 10 | indent_size = 4 11 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | patreon: pikvm 4 | custom: https://paypal.me/pikvm 5 | -------------------------------------------------------------------------------- /.github/workflows/dockerimage.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Building an image ... 17 | run: make build 18 | 19 | - name: Running linters ... 20 | run: make tox 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /linters/.tox/ 2 | /linters/.mypy_cache/ 3 | __pycache__/ 4 | *.swp 5 | *.swo 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | IMAGE ?= kvmd-auth-server 2 | 3 | 4 | # ===== 5 | define optbool 6 | $(filter $(shell echo $(1) | tr A-Z a-z),yes on 1) 7 | endef 8 | 9 | 10 | # ===== 11 | all: 12 | true 13 | 14 | 15 | tox: build 16 | docker run --rm \ 17 | --volume `pwd`:/root:ro \ 18 | --volume `pwd`/deploy:/root/deploy:ro \ 19 | --volume `pwd`/linters:/root/linters:rw \ 20 | -t $(IMAGE) tox -q -c /root/linters/tox.ini $(if $(E),-e $(E),-p auto) 21 | 22 | 23 | run: build 24 | docker run --rm \ 25 | --net host \ 26 | --volume `pwd`/config.yaml:/root/config.yaml:ro \ 27 | -t $(IMAGE) $(if $(CMD),$(CMD),/root/server.py --config /root/config.yaml) 28 | 29 | 30 | build: 31 | docker build --rm $(if $(call optbool,$(NC)),--no-cache,) -t $(IMAGE) -f deploy/Dockerfile . 32 | 33 | 34 | clean-all: build 35 | docker run --rm \ 36 | --volume `pwd`:/root:rw \ 37 | -t $(IMAGE) bash -c "rm -rf /root/linters/{.tox,.mypy_cache,.coverage}" 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # KVMD-Auth-Server 2 | [![CI](https://github.com/pikvm/kvmd-auth-server/workflows/CI/badge.svg)](https://github.com/pikvm/kvmd-auth-server/actions?query=workflow%3ACI) 3 | [![Discord](https://img.shields.io/discord/580094191938437144?logo=discord)](https://discord.gg/bpmXfz5) 4 | 5 | This repository demonstrates the ability to organize a centralized HTTP authorization server for Pi-KVM with a single user database. 6 | It's assumed that you already have a MySQL server that used to store user's credentials. 7 | Please note that passwords are stored in plain text. In addition, passwords are transmitted over the network over HTTP, not HTTPS. 8 | This server only demonstrates how authorization works. 9 | In a real secure infrastructure we recommend that you salt passwords and hash them and configure HTTPS. 10 | 11 | ----- 12 | # The process 13 | 14 | When using HTTP authorization, [KVMD](https://github.com/pikvm/kvmd) sends the following 15 | [JSON POST request](https://github.com/pikvm/kvmd/blob/master/kvmd/plugins/auth/http.py) to the server specified 16 | in the settings (for example `http://kvmauth/auth`): 17 | ```json 18 | { 19 | "user": "", 20 | "passwd": "", 21 | "secret": "<12345>" 22 | } 23 | ``` 24 | 25 | This request contains the name of the user who wants to log in to Pi-KVM, his password, and a "secret" that appears in KVMD config. 26 | In our case, it's used as a KVM ID in the network. Based on this secret, the server will decide whether the user is allowed access to a specific KVM. 27 | 28 | ❗NOTE: Usernames needs to adhere to [a-zA-Z] as a starting character otherwise it will fail ❗ 29 | 30 | If the auth server responds with `200 OK`, KVMD will allow the user to log in. 31 | For other response codes, the login will be denied. 32 | 33 | ---- 34 | # HOWTO 35 | 1. Create MySQL database `kvm_users` and allow the `kvmauth` user access to this database. 36 | 37 | 2. Create table: 38 | ```sql 39 | CREATE TABLE kvm_users ( 40 | id INT(32) NOT NULL AUTO_INCREMENT, 41 | kvm_id VARCHAR(50) NOT NULL, 42 | user VARCHAR(50) NOT NULL, 43 | passwd VARCHAR(60) NOT NULL, 44 | PRIMARY KEY (id), 45 | UNIQUE KEY user (user) 46 | ); 47 | ``` 48 | 49 | 3. Add an `example` user: 50 | ```sql 51 | INSERT INTO kvm_users (kvm_id, user, passwd) VALUES ("12345", "example", "pa$$word"); 52 | ``` 53 | 54 | 4. Clone this repo to your server: 55 | ```bash 56 | $ git clone https://github.com/pikvm/kvmd-auth-server 57 | ``` 58 | 59 | 5. Edit `config.yaml`. Set DB and auth server params. It will listen `server.host` and `server.port` for upcoming requests from Pi-KVM devices. 60 | 61 | 6. Run and run server: 62 | ```bash 63 | $ make build 64 | $ make run 65 | ``` 66 | 67 | 6. Edit `/etc/kvmd/auth.yaml` on your Pi-KVM and reboot it: 68 | ```yaml 69 | internal: 70 | force_users: admin 71 | external: 72 | type: http 73 | url: http://your_auth_server:port/auth 74 | secret: 12345 # KVM ID 75 | ```` 76 | 77 | The `admin` user will be checked through local KVM auth. Any other users will only be logged in through the auth server. 78 | 79 | ----- 80 | # License 81 | Copyright (C) 2018-2023 by Maxim Devaev mdevaev@gmail.com 82 | 83 | This program is free software: you can redistribute it and/or modify 84 | it under the terms of the GNU General Public License as published by 85 | the Free Software Foundation, either version 3 of the License, or 86 | (at your option) any later version. 87 | 88 | This program is distributed in the hope that it will be useful, 89 | but WITHOUT ANY WARRANTY; without even the implied warranty of 90 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 91 | GNU General Public License for more details. 92 | 93 | You should have received a copy of the GNU General Public License 94 | along with this program. If not, see https://www.gnu.org/licenses/. 95 | -------------------------------------------------------------------------------- /config.yaml: -------------------------------------------------------------------------------- 1 | server: 2 | host: localhost 3 | port: 8080 4 | 5 | db: 6 | host: localhost 7 | port: 3306 8 | user: kvmauth 9 | passwd: pass 10 | name: kvm_users 11 | 12 | query: 13 | ping: "SELECT version()" 14 | auth: "SELECT 1 FROM kvm_users WHERE user = %(user)s AND passwd = %(passwd)s AND kvm_id = %(secret)s" 15 | 16 | logging: 17 | version: 1 18 | disable_existing_loggers: false 19 | 20 | formatters: 21 | console: 22 | (): logging.Formatter 23 | style: "{" 24 | format: "{asctime} {name:30.30} {levelname:>7} --- {message}" 25 | 26 | handlers: 27 | console: 28 | level: DEBUG 29 | class: logging.StreamHandler 30 | stream: ext://sys.stdout 31 | formatter: console 32 | 33 | root: 34 | level: INFO 35 | handlers: 36 | - console 37 | -------------------------------------------------------------------------------- /deploy/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM archlinux/archlinux:base 2 | MAINTAINER Devaev Maxim 3 | 4 | RUN mkdir -p /etc/pacman.d/hooks \ 5 | && ln -s /dev/null /etc/pacman.d/hooks/30-systemd-tmpfiles.hook 6 | 7 | RUN pacman --noconfirm -Syu \ 8 | && pacman --needed --noconfirm -S \ 9 | git \ 10 | python \ 11 | python-pip \ 12 | python-tox \ 13 | && (pacman --noconfirm -Sc || true) \ 14 | && rm -rf /var/cache/pacman/pkg/* 15 | 16 | COPY deploy/requirements.txt /root/requirements.txt 17 | RUN pip install -r /root/requirements.txt 18 | 19 | COPY server.py /root/server.py 20 | 21 | CMD /bin/bash 22 | -------------------------------------------------------------------------------- /deploy/requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp 2 | aiomysql 3 | pyyaml 4 | -------------------------------------------------------------------------------- /linters/flake8.ini: -------------------------------------------------------------------------------- 1 | [flake8] 2 | inline-quotes = double 3 | max-line-length = 160 4 | ignore = W503, E225, E227, E241, E252 5 | # W503 line break before binary operator 6 | # E225 missing whitespace around operator 7 | # E227 missing whitespace around bitwise or shift operator 8 | # E241 multiple spaces after 9 | # E252 missing whitespace around parameter equals 10 | -------------------------------------------------------------------------------- /linters/mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | python_version = 3.9 3 | ignore_missing_imports = true 4 | disallow_untyped_defs = true 5 | strict_optional = true 6 | -------------------------------------------------------------------------------- /linters/pylint.ini: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | extension-pkg-whitelist = 3 | 4 | [DESIGN] 5 | min-public-methods = 0 6 | max-args = 10 7 | 8 | [TYPECHECK] 9 | ignored-classes= 10 | AioQueue, 11 | 12 | [MESSAGES CONTROL] 13 | disable = 14 | file-ignored, 15 | locally-disabled, 16 | fixme, 17 | missing-docstring, 18 | superfluous-parens, 19 | duplicate-code, 20 | broad-except, 21 | redundant-keyword-arg, 22 | wrong-import-order, 23 | too-many-ancestors, 24 | no-else-return, 25 | len-as-condition, 26 | consider-using-set-comprehension, 27 | raise-missing-from, 28 | unsubscriptable-object, 29 | unused-private-member, 30 | unspecified-encoding, 31 | # https://github.com/PyCQA/pylint/issues/3882 32 | 33 | [REPORTS] 34 | msg-template = {symbol} -- {path}:{line}({obj}): {msg} 35 | 36 | [FORMAT] 37 | max-line-length = 160 38 | 39 | [BASIC] 40 | # Regular expression matching correct method names 41 | method-rgx = [a-z_][a-z0-9_]{2,50}$ 42 | 43 | # Regular expression matching correct function names 44 | function-rgx = [a-z_][a-z0-9_]{2,50}$ 45 | 46 | # Regular expression which should only match correct module level names 47 | const-rgx = ([a-zA-Z_][a-zA-Z0-9_]*)$ 48 | 49 | # Regular expression which should only match correct argument names 50 | argument-rgx = [a-z_][a-z0-9_]{1,30}$ 51 | 52 | # Regular expression which should only match correct variable names 53 | variable-rgx = [a-z_][a-z0-9_]{1,30}$ 54 | 55 | # Regular expression which should only match correct instance attribute names 56 | attr-rgx = [a-z_][a-z0-9_]{1,30}$ 57 | -------------------------------------------------------------------------------- /linters/tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = flake8, pylint, mypy, vulture 3 | skipsdist = true 4 | 5 | [testenv] 6 | basepython = python3.11 7 | 8 | [testenv:flake8] 9 | commands = flake8 --config=/root/linters/flake8.ini /root/server.py 10 | deps = 11 | flake8 12 | flake8-quotes 13 | -r/root/deploy/requirements.txt 14 | 15 | [testenv:pylint] 16 | commands = pylint --rcfile=/root/linters/pylint.ini --output-format=colorized --reports=no /root/server.py 17 | deps = 18 | pylint 19 | -r/root/deploy/requirements.txt 20 | 21 | [testenv:mypy] 22 | commands = mypy --config-file=/root/linters/mypy.ini /root/server.py 23 | deps = 24 | mypy 25 | types-PyYAML 26 | -r/root/deploy/requirements.txt 27 | 28 | [testenv:vulture] 29 | commands = vulture --ignore-decorators=@_exposed /root/server.py /root/linters/vulture-wl.py 30 | deps = 31 | vulture 32 | -r/root/deploy/requirements.txt 33 | -------------------------------------------------------------------------------- /linters/vulture-wl.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pikvm/kvmd-auth-server/4b2b69706a4f390850850b5d89768eab3f11d080/linters/vulture-wl.py -------------------------------------------------------------------------------- /server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # ========================================================================== # 3 | # # 4 | # KVMD-Auth-Server - The basic HTTP/MySQL auth server for Pi-KVM. # 5 | # # 6 | # Copyright (C) 2018-2023 Maxim Devaev # 7 | # # 8 | # This program is free software: you can redistribute it and/or modify # 9 | # it under the terms of the GNU General Public License as published by # 10 | # the Free Software Foundation, either version 3 of the License, or # 11 | # (at your option) any later version. # 12 | # # 13 | # This program is distributed in the hope that it will be useful, # 14 | # but WITHOUT ANY WARRANTY; without even the implied warranty of # 15 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # 16 | # GNU General Public License for more details. # 17 | # # 18 | # You should have received a copy of the GNU General Public License # 19 | # along with this program. If not, see . # 20 | # # 21 | # ========================================================================== # 22 | 23 | 24 | import contextlib 25 | import inspect 26 | import argparse 27 | import logging 28 | import logging.config 29 | 30 | from typing import Dict 31 | from typing import Callable 32 | from typing import AsyncGenerator 33 | from typing import Optional 34 | from typing import Any 35 | 36 | import aiohttp.web 37 | import aiomysql 38 | import yaml 39 | 40 | 41 | # ===== 42 | _logger = logging.getLogger("kvmd-auth-server") 43 | 44 | 45 | # ===== 46 | class BadRequestError(Exception): 47 | pass 48 | 49 | 50 | def _make_response(text: str, status: int=200) -> aiohttp.web.Response: 51 | return aiohttp.web.Response(text=f"{text}\r\n", status=status) 52 | 53 | 54 | _ATTR_EXPOSED = "exposed" 55 | _ATTR_EXPOSED_METHOD = "exposed_method" 56 | _ATTR_EXPOSED_PATH = "exposed_path" 57 | 58 | 59 | def _exposed(http_method: str, path: str) -> Callable: 60 | def make_wrapper(handler: Callable) -> Callable: 61 | async def wrapper(self: "_Server", request: aiohttp.web.Request) -> aiohttp.web.Response: 62 | try: 63 | return (await handler(self, request)) 64 | except BadRequestError as err: 65 | return _make_response(f"BAD REQUEST: {err}", 400) 66 | except Exception as err: 67 | _logger.exception("Unhandled API exception") 68 | return _make_response(f"SERVER ERROR: {type(err).__name__}: {err}", 500) 69 | 70 | setattr(wrapper, _ATTR_EXPOSED, True) 71 | setattr(wrapper, _ATTR_EXPOSED_METHOD, http_method) 72 | setattr(wrapper, _ATTR_EXPOSED_PATH, path) 73 | return wrapper 74 | return make_wrapper 75 | 76 | 77 | class _Server: 78 | def __init__( 79 | self, 80 | ping_query: str, 81 | auth_query: str, 82 | db_params: Dict[str, Any], 83 | ) -> None: 84 | 85 | self.__ping_query = ping_query 86 | self.__auth_query = auth_query 87 | self.__db_params = db_params 88 | 89 | self.__db_pool: Optional[aiomysql.Pool] = None 90 | 91 | def make_app(self) -> aiohttp.web.Application: 92 | app = aiohttp.web.Application() 93 | app.on_cleanup.append(self.__cleanup) 94 | for name in dir(self): 95 | method = getattr(self, name) 96 | if inspect.ismethod(method) and getattr(method, _ATTR_EXPOSED, False): 97 | app.router.add_route( 98 | getattr(method, _ATTR_EXPOSED_METHOD), 99 | getattr(method, _ATTR_EXPOSED_PATH), 100 | method, 101 | ) 102 | return app 103 | 104 | async def __cleanup(self, _: aiohttp.web.Application) -> None: 105 | if self.__db_pool: 106 | self.__db_pool.close() 107 | await self.__db_pool.wait_closed() 108 | 109 | @contextlib.asynccontextmanager 110 | async def __ensure_db_cursor(self) -> AsyncGenerator[aiomysql.Cursor, None]: 111 | if not self.__db_pool: 112 | self.__db_pool = await aiomysql.create_pool(**self.__db_params) 113 | async with self.__db_pool.acquire() as conn: 114 | async with conn.cursor() as cursor: 115 | yield cursor 116 | 117 | # ===== 118 | 119 | @_exposed("GET", "/ping") 120 | async def __ping_handler(self, _: aiohttp.web.Request) -> aiohttp.web.Response: 121 | async with self.__ensure_db_cursor() as cursor: 122 | await cursor.execute(self.__ping_query) 123 | await cursor.fetchone() 124 | return _make_response("OK") 125 | 126 | @_exposed("POST", "/auth") 127 | async def __auth_handler(self, request: aiohttp.web.Request) -> aiohttp.web.Response: 128 | data = await self.__get_json(request) 129 | credentials = { 130 | key: self.__get_credential(data, key) 131 | for key in ["user", "passwd", "secret"] 132 | } 133 | async with self.__ensure_db_cursor() as cursor: 134 | await cursor.execute(self.__auth_query, credentials) 135 | if len(await cursor.fetchall()) > 0: 136 | return _make_response("OK") 137 | return _make_response("FORBIDDEN", 403) 138 | 139 | async def __get_json(self, request: aiohttp.web.Request) -> Dict: 140 | try: 141 | return (await request.json()) 142 | except Exception as err: 143 | raise BadRequestError(f"Can't parse JSON request: {err}") 144 | 145 | def __get_credential(self, data: Dict, key: str) -> aiohttp.web.Response: 146 | value: Any = data.get(key) 147 | if value is None: 148 | raise BadRequestError(f"Missing {key!r}") 149 | value = str(value) 150 | if len(value) > 256: 151 | raise BadRequestError(f"Too long {key!r}") 152 | return value 153 | 154 | 155 | # ===== 156 | def main() -> None: 157 | parser = argparse.ArgumentParser() 158 | parser.add_argument("-c", "--config", default="config.yaml") 159 | options = parser.parse_args() 160 | 161 | with open(options.config) as config_file: 162 | config = yaml.safe_load(config_file) 163 | 164 | logging.captureWarnings(True) 165 | logging.config.dictConfig(config["logging"]) 166 | 167 | aiohttp.web.run_app( 168 | app=_Server( 169 | ping_query=config["query"]["ping"], 170 | auth_query=config["query"]["auth"], 171 | db_params={ 172 | "host": config["db"]["host"], 173 | "port": config["db"]["port"], 174 | "user": (config["db"]["user"] or None), 175 | "password": config["db"]["passwd"], 176 | "db": config["db"]["name"], 177 | }, 178 | ).make_app(), 179 | host=config["server"]["host"], 180 | port=config["server"]["port"], 181 | ) 182 | 183 | 184 | if __name__ == "__main__": 185 | main() 186 | --------------------------------------------------------------------------------