ipmi bmc proxy

This commit is contained in:
Devaev Maxim 2019-04-28 08:31:37 +03:00
parent 380b1d15e3
commit 0bde12e24d
16 changed files with 429 additions and 12 deletions

View File

@ -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 \

View File

@ -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

14
configs/kvmd/ipmipasswd Normal file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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() {

View File

@ -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)
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:

View File

@ -0,0 +1,48 @@
# ========================================================================== #
# #
# KVMD - The main Pi-KVM daemon. #
# #
# Copyright (C) 2018 Maxim Devaev <mdevaev@gmail.com> #
# #
# 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

View File

@ -0,0 +1,24 @@
# ========================================================================== #
# #
# KVMD - The main Pi-KVM daemon. #
# #
# Copyright (C) 2018 Maxim Devaev <mdevaev@gmail.com> #
# #
# 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()

84
kvmd/apps/ipmi/auth.py Normal file
View File

@ -0,0 +1,84 @@
# ========================================================================== #
# #
# KVMD - The main Pi-KVM daemon. #
# #
# Copyright (C) 2018 Maxim Devaev <mdevaev@gmail.com> #
# #
# 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

190
kvmd/apps/ipmi/server.py Normal file
View File

@ -0,0 +1,190 @@
# ========================================================================== #
# #
# KVMD - The main Pi-KVM daemon. #
# #
# Copyright (C) 2018 Maxim Devaev <mdevaev@gmail.com> #
# #
# 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()

View File

@ -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",
],
},

View File

@ -7,3 +7,4 @@ fake_rpi.RPi.GPIO
_KeyMapping.kvmd_code
_KeyMapping.arduino_hid_key
_KeyMapping.web_key
IpmiServer.handle_raw_request

View File

@ -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

View File

@ -8,3 +8,4 @@ pyyaml
pyserial
setproctitle
pygments
pyghmi