diff options
-rw-r--r-- | Makefile | 5 | ||||
-rw-r--r-- | PKGBUILD | 5 | ||||
-rw-r--r-- | configs/kvmd/ipmipasswd | 14 | ||||
-rw-r--r-- | configs/kvmd/main/v1-hdmi.yaml | 7 | ||||
-rw-r--r-- | configs/kvmd/main/v1-vga.yaml | 7 | ||||
-rw-r--r-- | configs/os/systemd/kvmd-ipmi.service | 15 | ||||
-rw-r--r-- | kvmd.install | 4 | ||||
-rw-r--r-- | kvmd/apps/__init__.py | 29 | ||||
-rw-r--r-- | kvmd/apps/ipmi/__init__.py | 48 | ||||
-rw-r--r-- | kvmd/apps/ipmi/__main__.py | 24 | ||||
-rw-r--r-- | kvmd/apps/ipmi/auth.py | 84 | ||||
-rw-r--r-- | kvmd/apps/ipmi/server.py | 190 | ||||
-rwxr-xr-x | setup.py | 2 | ||||
-rw-r--r-- | testenv/linters/vulture-wl.py | 1 | ||||
-rw-r--r-- | testenv/main.yaml | 5 | ||||
-rw-r--r-- | testenv/requirements.txt | 1 |
16 files changed, 429 insertions, 12 deletions
@@ -35,7 +35,7 @@ tox: _testenv --volume `pwd`/configs:/usr/share/kvmd/configs.default:ro \ -it $(TESTENV_IMAGE) bash -c " \ cp /usr/share/kvmd/configs.default/kvmd/*.yaml /etc/kvmd \ - && cp /usr/share/kvmd/configs.default/kvmd/htpasswd /etc/kvmd \ + && cp /usr/share/kvmd/configs.default/kvmd/*passwd /etc/kvmd \ && cp /src/testenv/main.yaml /etc/kvmd \ && cd /src \ && tox -c testenv/tox.ini $(if $(E), -e $(E), -p auto) \ @@ -107,11 +107,12 @@ _run_cmd: _testenv --publish 8080:80/tcp \ --publish 8081:8081/tcp \ --publish 8082:8082/tcp \ + --publish 6230:623/udp \ -it $(TESTENV_IMAGE) /bin/bash -c " \ (socat PTY,link=$(TESTENV_HID) PTY,link=/dev/ttyS11 &) \ && cp -r /usr/share/kvmd/configs.default/nginx/* /etc/kvmd/nginx \ && cp /usr/share/kvmd/configs.default/kvmd/*.yaml /etc/kvmd \ - && cp /usr/share/kvmd/configs.default/kvmd/htpasswd /etc/kvmd \ + && cp /usr/share/kvmd/configs.default/kvmd/*passwd /etc/kvmd \ && cp /testenv/main.yaml /etc/kvmd \ && nginx -c /etc/kvmd/nginx/nginx.conf \ && ln -s $(TESTENV_VIDEO) /dev/kvmd-video \ @@ -32,6 +32,7 @@ depends=( python-systemd python-dbus python-pygments + python-pyghmi psmisc v4l-utils nginx-mainline @@ -78,7 +79,7 @@ package_kvmd() { find "$pkgdir" -name ".gitignore" -delete sed -i -e "s/^#PROD//g" "$_cfgdir/nginx/nginx.conf" find "$_cfgdir" -type f -exec chmod 444 '{}' \; - chmod 440 "$_cfgdir/kvmd/htpasswd" + chmod 440 "$_cfgdir/kvmd/*passwd" mkdir -p "$pkgdir/etc/kvmd/nginx/ssl" chmod 750 "$pkgdir/etc/kvmd/nginx/ssl" @@ -87,7 +88,7 @@ package_kvmd() { done rm "$pkgdir/etc/kvmd"/{auth.yaml,meta.yaml} cp "$_cfgdir/kvmd"/{auth.yaml,meta.yaml} "$pkgdir/etc/kvmd" - cp -a "$_cfgdir/kvmd/htpasswd" "$pkgdir/etc/kvmd" + cp -a "$_cfgdir/kvmd/*passwd" "$pkgdir/etc/kvmd" for path in "$_cfgdir/nginx"/*.conf; do ln -sf "/usr/share/kvmd/configs.default/nginx/`basename $path`" "$pkgdir/etc/kvmd/nginx" done diff --git a/configs/kvmd/ipmipasswd b/configs/kvmd/ipmipasswd new file mode 100644 index 00000000..14b45a94 --- /dev/null +++ b/configs/kvmd/ipmipasswd @@ -0,0 +1,14 @@ +# This file describes the credentials for IPMI users. The first pair separated by colon +# is the login and password with which the user can access to IPMI. The second pair +# is the name and password with which the user can access to KVMD API. The arrow is used +# as a separator and shows the direction of user registration in the system. +# +# WARNING! IPMI protocol is completly unsafe by design. In short, the authentication +# process for IPMI 2.0 mandates that the server send a salted SHA1 or MD5 hash of the +# requested user's password to the client, prior to the client authenticating. Never use +# the same passwords for KVMD and IPMI users. This default configuration is shown here +# for example only. +# +# And even better not to use IPMI. Instead, you can directly use KVMD API using curl. + +admin:admin -> admin:admin diff --git a/configs/kvmd/main/v1-hdmi.yaml b/configs/kvmd/main/v1-hdmi.yaml index c36632cc..7b886167 100644 --- a/configs/kvmd/main/v1-hdmi.yaml +++ b/configs/kvmd/main/v1-hdmi.yaml @@ -1,6 +1,8 @@ # Don't touch this file otherwise your device may stop working. # You can find a working configuration in /usr/share/kvmd/configs.default/kvmd. +logging: !include logging.yaml + kvmd: server: host: 127.0.0.1 @@ -40,4 +42,7 @@ kvmd: - "--port={port}" - "--drop-same-frames=30" -logging: !include logging.yaml +ipmi: + kvmd: + host: 127.0.0.1 + port: 8081 diff --git a/configs/kvmd/main/v1-vga.yaml b/configs/kvmd/main/v1-vga.yaml index 46c2fad3..4a9baddc 100644 --- a/configs/kvmd/main/v1-vga.yaml +++ b/configs/kvmd/main/v1-vga.yaml @@ -1,6 +1,8 @@ # Don't touch this file otherwise your device may stop working. # You can find a working configuration in /usr/share/kvmd/configs.default/kvmd. +logging: !include logging.yaml + kvmd: server: host: 127.0.0.1 @@ -44,4 +46,7 @@ kvmd: - "--host={host}" - "--port={port}" -logging: !include logging.yaml +ipmi: + kvmd: + listen: 127.0.0.1 + port: 8081 diff --git a/configs/os/systemd/kvmd-ipmi.service b/configs/os/systemd/kvmd-ipmi.service new file mode 100644 index 00000000..2689aa8c --- /dev/null +++ b/configs/os/systemd/kvmd-ipmi.service @@ -0,0 +1,15 @@ +[Unit] +Description=IPMI to KVMD proxy +After=kvmd.service + +[Service] +User=kvmd +Group=kvmd +Type=simple +Restart=always +RestartSec=3 + +ExecStart=/usr/bin/kvmd-ipmi + +[Install] +WantedBy=multi-user.target diff --git a/kvmd.install b/kvmd.install index 7b9db85a..647184d4 100644 --- a/kvmd.install +++ b/kvmd.install @@ -15,8 +15,8 @@ post_upgrade() { done chown root:kvmd \ - /usr/share/kvmd/configs.default/kvmd/htpasswd \ - /etc/kvmd/htpasswd + /usr/share/kvmd/configs.default/kvmd/*passwd \ + /etc/kvmd/*passwd } post_remove() { diff --git a/kvmd/apps/__init__.py b/kvmd/apps/__init__.py index 65d30f2e..e4b10127 100644 --- a/kvmd/apps/__init__.py +++ b/kvmd/apps/__init__.py @@ -110,11 +110,13 @@ def _init_config(config_path: str, sections: List[str], override_options: List[s _merge_dicts(raw_config, build_raw_from_options(override_options)) config = make_config(raw_config, scheme) - scheme["kvmd"]["auth"]["internal"] = get_auth_service_class(config.kvmd.auth.internal_type).get_options() - if config.kvmd.auth.external_type: - scheme["kvmd"]["auth"]["external"] = get_auth_service_class(config.kvmd.auth.external_type).get_options() + if "kvmd" in sections: + scheme["kvmd"]["auth"]["internal"] = get_auth_service_class(config.kvmd.auth.internal_type).get_options() + if config.kvmd.auth.external_type: + scheme["kvmd"]["auth"]["external"] = get_auth_service_class(config.kvmd.auth.external_type).get_options() + config = make_config(raw_config, scheme) - return make_config(raw_config, scheme) + return config except (ConfigError, UnknownPluginError) as err: raise SystemExit("Config error: %s" % (str(err))) @@ -233,6 +235,25 @@ def _get_config_scheme(sections: List[str]) -> Dict: "cmd": Option(["/bin/true"], type=valid_command), }, }, + + "ipmi": { + "server": { + "host": Option("::", type=valid_ip_or_host), + "port": Option(623, type=valid_port), + "timeout": Option(10.0, type=valid_float_f01), + }, + + "kvmd": { + "host": Option("localhost", type=valid_ip_or_host, unpack_as="kvmd_host"), + "port": Option(0, type=valid_port, unpack_as="kvmd_port"), + "unix": Option("", type=valid_abs_path, only_if="!port", unpack_as="kvmd_unix_path"), + "timeout": Option(5.0, type=valid_float_f01, unpack_as="kvmd_timeout"), + }, + + "auth": { + "file": Option("/etc/kvmd/ipmipasswd", type=valid_abs_path_exists, unpack_as="path"), + }, + }, } if sections: diff --git a/kvmd/apps/ipmi/__init__.py b/kvmd/apps/ipmi/__init__.py new file mode 100644 index 00000000..e2b56b1a --- /dev/null +++ b/kvmd/apps/ipmi/__init__.py @@ -0,0 +1,48 @@ +# ========================================================================== # +# # +# KVMD - The main Pi-KVM daemon. # +# # +# Copyright (C) 2018 Maxim Devaev <[email protected]> # +# # +# This program is free software: you can redistribute it and/or modify # +# it under the terms of the GNU General Public License as published by # +# the Free Software Foundation, either version 3 of the License, or # +# (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program. If not, see <https://www.gnu.org/licenses/>. # +# # +# ========================================================================== # + + +from typing import List +from typing import Optional + +from .. import init + +from .auth import IpmiAuthManager +from .server import IpmiServer + + +# ===== +def main(argv: Optional[List[str]]=None) -> None: + config = init( + prog="kvmd-ipmi", + description="IPMI to KVMD proxy", + sections=["logging", "ipmi"], + argv=argv, + )[2].ipmi + + # pylint: disable=protected-access + IpmiServer( + auth_manager=IpmiAuthManager(**config.auth._unpack()), + **{ # Dirty mypy hack + **config.server._unpack(), + **config.kvmd._unpack(), + }, + ).run() # type: ignore diff --git a/kvmd/apps/ipmi/__main__.py b/kvmd/apps/ipmi/__main__.py new file mode 100644 index 00000000..77f4e294 --- /dev/null +++ b/kvmd/apps/ipmi/__main__.py @@ -0,0 +1,24 @@ +# ========================================================================== # +# # +# KVMD - The main Pi-KVM daemon. # +# # +# Copyright (C) 2018 Maxim Devaev <[email protected]> # +# # +# This program is free software: you can redistribute it and/or modify # +# it under the terms of the GNU General Public License as published by # +# the Free Software Foundation, either version 3 of the License, or # +# (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program. If not, see <https://www.gnu.org/licenses/>. # +# # +# ========================================================================== # + + +from . import main +main() diff --git a/kvmd/apps/ipmi/auth.py b/kvmd/apps/ipmi/auth.py new file mode 100644 index 00000000..4b95abf5 --- /dev/null +++ b/kvmd/apps/ipmi/auth.py @@ -0,0 +1,84 @@ +# ========================================================================== # +# # +# KVMD - The main Pi-KVM daemon. # +# # +# Copyright (C) 2018 Maxim Devaev <[email protected]> # +# # +# This program is free software: you can redistribute it and/or modify # +# it under the terms of the GNU General Public License as published by # +# the Free Software Foundation, either version 3 of the License, or # +# (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program. If not, see <https://www.gnu.org/licenses/>. # +# # +# ========================================================================== # + + +from typing import List +from typing import Dict +from typing import NamedTuple + + +# ===== +class IpmiPasswdError(Exception): + def __init__(self, msg: str) -> None: + super().__init__("Incorrect IPMI passwd file: " + msg) + + +class IpmiUserCredentials(NamedTuple): + ipmi_user: str + ipmi_passwd: str + kvmd_user: str + kvmd_passwd: str + + +class IpmiAuthManager: + def __init__(self, path: str) -> None: + with open(path) as passwd_file: + self.__credentials = self.__parse_passwd_file(passwd_file.read().split("\n")) + + def __contains__(self, ipmi_user: str) -> bool: + return (ipmi_user in self.__credentials) + + def __getitem__(self, ipmi_user: str) -> str: + return self.__credentials[ipmi_user].ipmi_passwd + + def get_credentials(self, ipmi_user: str) -> IpmiUserCredentials: + return self.__credentials[ipmi_user] + + def __parse_passwd_file(self, lines: List[str]) -> Dict[str, IpmiUserCredentials]: + credentials: Dict[str, IpmiUserCredentials] = {} + for (number, line) in enumerate(lines): + if len(line.strip()) == 0 or line.lstrip().startswith("#"): + continue + + if " -> " not in line: + raise IpmiPasswdError("Missing ' -> ' operator at line #%d" % (number)) + + (left, right) = map(str.lstrip, line.split(" -> ", 1)) + for (name, pair) in [("left", left), ("right", right)]: + if ":" not in pair: + raise IpmiPasswdError("Missing ':' operator in %s credentials at line #%d" % (name, number)) + + (ipmi_user, ipmi_passwd) = left.split(":") + ipmi_user = ipmi_user.strip() + + (kvmd_user, kvmd_passwd) = right.split(":") + kvmd_user = kvmd_user.strip() + + if ipmi_user in credentials: + raise IpmiPasswdError("Found duplicating user %r (left) at line #%d" % (ipmi_user, number)) + + credentials[ipmi_user] = IpmiUserCredentials( + ipmi_user=ipmi_user, + ipmi_passwd=ipmi_passwd, + kvmd_user=kvmd_passwd, + kvmd_passwd=kvmd_passwd, + ) + return credentials diff --git a/kvmd/apps/ipmi/server.py b/kvmd/apps/ipmi/server.py new file mode 100644 index 00000000..f71ce85c --- /dev/null +++ b/kvmd/apps/ipmi/server.py @@ -0,0 +1,190 @@ +# ========================================================================== # +# # +# KVMD - The main Pi-KVM daemon. # +# # +# Copyright (C) 2018 Maxim Devaev <[email protected]> # +# # +# This program is free software: you can redistribute it and/or modify # +# it under the terms of the GNU General Public License as published by # +# the Free Software Foundation, either version 3 of the License, or # +# (at your option) any later version. # +# # +# This program is distributed in the hope that it will be useful, # +# but WITHOUT ANY WARRANTY; without even the implied warranty of # +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # +# GNU General Public License for more details. # +# # +# You should have received a copy of the GNU General Public License # +# along with this program. If not, see <https://www.gnu.org/licenses/>. # +# # +# ========================================================================== # + + +import sys +import asyncio +import threading + +from typing import Tuple +from typing import Dict +from typing import Optional + +import aiohttp + +from pyghmi.ipmi.private.session import Session as IpmiSession +from pyghmi.ipmi.private.serversession import IpmiServer as BaseIpmiServer +from pyghmi.ipmi.private.serversession import ServerSession as IpmiServerSession + +from ...logging import get_logger + +from ... import __version__ + +from .auth import IpmiAuthManager + + +# ===== +class IpmiServer(BaseIpmiServer): # pylint: disable=too-many-instance-attributes,abstract-method + # https://www.intel.com/content/dam/www/public/us/en/documents/product-briefs/ipmi-second-gen-interface-spec-v2-rev1-1.pdf + # https://www.thomas-krenn.com/en/wiki/IPMI_Basics + + def __init__( + self, + auth_manager: IpmiAuthManager, + + host: str, + port: str, + timeout: float, + + kvmd_host: str, + kvmd_port: int, + kvmd_unix_path: str, + kvmd_timeout: float, + ) -> None: + + super().__init__(authdata=auth_manager, address=host, port=port) + + self.__auth_manager = auth_manager + + self.__host = host + self.__port = port + self.__timeout = timeout + + self.__kvmd_host = kvmd_host + self.__kvmd_port = kvmd_port + self.__kvmd_unix_path = kvmd_unix_path + self.__kvmd_timeout = kvmd_timeout + + def run(self) -> None: + logger = get_logger(0) + logger.info("Listening IPMI on UPD [%s]:%d ...", self.__host, self.__port) + try: + while True: + IpmiSession.wait_for_rsp(self.__timeout) + except (SystemExit, KeyboardInterrupt): + pass + logger.info("Bye-bye") + + # ===== + + def handle_raw_request(self, request: Dict, session: IpmiServerSession) -> None: + handler = { + (6, 1): lambda _, session: self.send_device_id(session), # Get device ID + (0, 1): self.__get_chassis_status_handler, # Get chassis status + (0, 2): self.__chassis_control_handler, # Chassis control + }.get((request["netfn"], request["command"])) + if handler is not None: + try: + handler(request, session) + except (aiohttp.ClientError, asyncio.TimeoutError): + session.send_ipmi_response(code=0xFF) + except Exception: + get_logger(0).exception("Unexpected exception while handling IPMI request: netfn=%d; command=%d", + request["netfn"], request["command"]) + session.send_ipmi_response(code=0xFF) + else: + session.send_ipmi_response(code=0xC1) + + def __get_chassis_status_handler(self, _: Dict, session: IpmiServerSession) -> None: + result = self.__make_request("GET", "/atx", session)[1] + data = [int(result["leds"]["power"]), 0, 0] + session.send_ipmi_response(data=data) + + def __chassis_control_handler(self, request: Dict, session: IpmiServerSession) -> None: + handle = { + 0: "/atx/power?action=off", + 1: "/atx/power?action=on", + 3: "/atx/power?action=reset", + 5: "/atx/power?action=off_soft", + }.get(request["data"][0], "") + if handle: + if self.__make_request("POST", handle, session)[0] == 409: + code = 0xC0 # Try again later + else: + code = 0 + else: + code = 0xCC # Invalid request + session.send_ipmi_response(code=code) + + # ===== + + def __make_request(self, method: str, handle: str, ipmi_session: IpmiServerSession) -> Tuple[int, Dict]: + result: Optional[Tuple[int, Dict]] = None + exc_info = None + + def make_request() -> None: + nonlocal result + nonlocal exc_info + + loop = asyncio.new_event_loop() + try: + result = loop.run_until_complete(self.__make_request_async(method, handle, ipmi_session)) + except: # noqa: E722 # pylint: disable=bare-except + exc_info = sys.exc_info() + finally: + loop.close() + + thread = threading.Thread(target=make_request, daemon=True) + thread.start() + thread.join() + if exc_info is not None: + raise exc_info[1].with_traceback(exc_info[2]) # type: ignore # pylint: disable=unsubscriptable-object + assert result is not None + # Dirty pylint hack + return (result[0], result[1]) # pylint: disable=unsubscriptable-object + + async def __make_request_async(self, method: str, handle: str, ipmi_session: IpmiServerSession) -> Tuple[int, Dict]: + logger = get_logger(0) + + assert handle.startswith("/") + url = "http://%s:%d%s" % (self.__kvmd_host, self.__kvmd_port, handle) + + credentials = self.__auth_manager.get_credentials(ipmi_session.username.decode()) + logger.info("Performing %r request to %r from user %r (IPMI) as %r (KVMD)", + method, url, credentials.ipmi_user, credentials.kvmd_user) + + async with self.__make_http_session_async() as http_session: + try: + async with http_session.request( + method=method, + url=url, + headers={ + "X-KVMD-User": credentials.kvmd_user, + "X-KVMD-Passwd": credentials.kvmd_passwd, + "User-Agent": "KVMD-IPMI/%s" % (__version__), + }, + timeout=self.__kvmd_timeout, + ) as response: + if response.status != 409: + response.raise_for_status() + return (response.status, (await response.json())["result"]) + except (aiohttp.ClientError, asyncio.TimeoutError) as err: + logger.error("Can't perform %r request to %r: %s: %s", method, url, type(err).__name__, str(err)) + raise + except Exception: + logger.exception("Unexpected exception while performing %r request to %r", method, url) + raise + + def __make_http_session_async(self) -> aiohttp.ClientSession: + if self.__kvmd_unix_path: + return aiohttp.ClientSession(connector=aiohttp.UnixConnector(path=self.__kvmd_unix_path)) + else: + return aiohttp.ClientSession() @@ -46,6 +46,7 @@ def main() -> None: "kvmd.apps.kvmd", "kvmd.apps.htpasswd", "kvmd.apps.cleanup", + "kvmd.apps.ipmi", ], package_data={ @@ -57,6 +58,7 @@ def main() -> None: "kvmd = kvmd.apps.kvmd:main", "kvmd-htpasswd = kvmd.apps.htpasswd:main", "kvmd-cleanup = kvmd.apps.cleanup:main", + "kvmd-ipmi = kvmd.apps.ipmi:main", ], }, diff --git a/testenv/linters/vulture-wl.py b/testenv/linters/vulture-wl.py index 68110436..0933cb62 100644 --- a/testenv/linters/vulture-wl.py +++ b/testenv/linters/vulture-wl.py @@ -7,3 +7,4 @@ fake_rpi.RPi.GPIO _KeyMapping.kvmd_code _KeyMapping.arduino_hid_key _KeyMapping.web_key +IpmiServer.handle_raw_request diff --git a/testenv/main.yaml b/testenv/main.yaml index afbcaba2..79138b9e 100644 --- a/testenv/main.yaml +++ b/testenv/main.yaml @@ -37,4 +37,9 @@ kvmd: - "--host=0.0.0.0" - "--port={port}" +ipmi: + kvmd: + host: 127.0.0.1 + port: 8081 + logging: !include logging.yaml diff --git a/testenv/requirements.txt b/testenv/requirements.txt index 27e4f479..fbbfdbc8 100644 --- a/testenv/requirements.txt +++ b/testenv/requirements.txt @@ -8,3 +8,4 @@ pyyaml pyserial setproctitle pygments +pyghmi |