diff --git a/extras/python-sambacc.spec b/extras/python-sambacc.spec index 0688ae58..c4d1c097 100644 --- a/extras/python-sambacc.spec +++ b/extras/python-sambacc.spec @@ -46,6 +46,7 @@ Requires: python3-pyxattr Recommends: %{name}+toml Recommends: %{name}+yaml Recommends: %{name}+rados +Recommends: %{name}+grpc %endif %if 0%{?fedora} >= 37 Recommends: %{name}+validation @@ -78,6 +79,7 @@ Recommends: %{name}+validation %doc README.* %{_bindir}/samba-container %{_bindir}/samba-dc-container +%{_bindir}/samba-remote-control %{_datadir}/%{bname}/examples/ @@ -85,6 +87,7 @@ Recommends: %{name}+validation %pyproject_extras_subpkg -n python3-%{bname} toml %pyproject_extras_subpkg -n python3-%{bname} yaml %pyproject_extras_subpkg -n python3-%{bname} rados +%pyproject_extras_subpkg -n python3-%{bname} grpc %changelog diff --git a/pyproject.toml b/pyproject.toml index fe86f3fa..6c176ddf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,3 +32,8 @@ disallow_untyped_defs = false [[tool.mypy.overrides]] module = "sambacc.schema.*" disallow_untyped_defs = false + +[[tool.mypy.overrides]] +module = "sambacc.grpc.generated.*" +disallow_untyped_defs = false +ignore_errors = true diff --git a/sambacc/commands/remotecontrol/__init__.py b/sambacc/commands/remotecontrol/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sambacc/commands/remotecontrol/main.py b/sambacc/commands/remotecontrol/main.py new file mode 100644 index 00000000..e933cca8 --- /dev/null +++ b/sambacc/commands/remotecontrol/main.py @@ -0,0 +1,61 @@ +# +# sambacc: a samba container configuration tool +# Copyright (C) 2025 John Mulligan +# +# 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 +# + +import sys +import typing + + +from .. import skips +from ..cli import Context, Fail, commands +from ..common import ( + CommandContext, + enable_logging, + env_to_cli, + global_args, + pre_action, +) + + +def _default(ctx: Context) -> None: + sys.stdout.write(f"{sys.argv[0]} requires a subcommand, like 'serve'.\n") + sys.exit(1) + + +def main(args: typing.Optional[typing.Sequence[str]] = None) -> None: + pkg = "sambacc.commands.remotecontrol" + commands.include(".server", package=pkg) + + cli = commands.assemble(arg_func=global_args).parse_args(args) + env_to_cli(cli) + enable_logging(cli) + if not cli.identity: + raise Fail("missing container identity") + + pre_action(cli) + ctx = CommandContext(cli) + skip = skips.test(ctx) + if skip: + print(f"Command Skipped: {skip}") + return + cfunc = getattr(cli, "cfunc", _default) + cfunc(ctx) + return + + +if __name__ == "__main__": + main() diff --git a/sambacc/commands/remotecontrol/server.py b/sambacc/commands/remotecontrol/server.py new file mode 100644 index 00000000..b13aed92 --- /dev/null +++ b/sambacc/commands/remotecontrol/server.py @@ -0,0 +1,129 @@ +# +# sambacc: a samba container configuration tool +# Copyright (C) 2025 John Mulligan +# +# 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 +# + +import argparse +import logging +import signal +import sys +import typing + +from ..cli import Context, Fail, commands + +_logger = logging.getLogger(__name__) +_MTLS = "mtls" +_FORCE = "force" + + +def _serve_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "--address", + "-a", + help="Specify an {address:port} value to bind to.", + ) + # Force an explicit choice of (the only) rpc type in order to clearly + # prepare the space for possible alternatives + egroup = parser.add_mutually_exclusive_group(required=True) + egroup.add_argument( + "--grpc", + dest="rpc_type", + action="store_const", + default="grpc", + const="grpc", + help="Use gRPC", + ) + # security settings + parser.add_argument( + "--insecure", + action="store_true", + help="Disable TLS", + ) + parser.add_argument( + "--allow-modify", + choices=(_MTLS, _FORCE), + default=_MTLS, + help="Control modification mode", + ) + parser.add_argument( + "--tls-key", + help="Server TLS Key", + ) + parser.add_argument( + "--tls-cert", + help="Server TLS Certificate", + ) + parser.add_argument( + "--tls-ca-cert", + help="CA Certificate", + ) + + +class Restart(Exception): + pass + + +@commands.command(name="serve", arg_func=_serve_args) +def serve(ctx: Context) -> None: + """Start an RPC server.""" + + def _handler(*args: typing.Any) -> None: + raise Restart() + + signal.signal(signal.SIGHUP, _handler) + while True: + try: + _serve(ctx) + return + except KeyboardInterrupt: + _logger.info("Exiting") + sys.exit(0) + except Restart: + _logger.info("Re-starting server") + continue + + +def _serve(ctx: Context) -> None: + import sambacc.grpc.backend + import sambacc.grpc.server + + config = sambacc.grpc.server.ServerConfig() + config.insecure = bool(ctx.cli.insecure) + if ctx.cli.address: + config.address = ctx.cli.address + if not (ctx.cli.insecure or ctx.cli.tls_key): + raise Fail("Specify --tls-key=... or --insecure") + if not (ctx.cli.insecure or ctx.cli.tls_cert): + raise Fail("Specify --tls-cert=... or --insecure") + if ctx.cli.tls_key: + config.server_key = _read(ctx, ctx.cli.tls_key) + if ctx.cli.tls_cert: + config.server_cert = _read(ctx, ctx.cli.tls_cert) + if ctx.cli.tls_ca_cert: + config.ca_cert = _read(ctx, ctx.cli.tls_ca_cert) + config.read_only = not ( + ctx.cli.allow_modify == _FORCE + or (not config.insecure and config.ca_cert) + ) + + backend = sambacc.grpc.backend.ControlBackend(ctx.instance_config) + sambacc.grpc.server.serve(config, backend) + + +def _read(ctx: Context, path_or_url: str) -> bytes: + with ctx.opener.open(path_or_url) as fh: + content = fh.read() + return content if isinstance(content, bytes) else content.encode() diff --git a/sambacc/grpc/__init__.py b/sambacc/grpc/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sambacc/grpc/backend.py b/sambacc/grpc/backend.py new file mode 100644 index 00000000..a814378a --- /dev/null +++ b/sambacc/grpc/backend.py @@ -0,0 +1,161 @@ +# +# sambacc: a samba container configuration tool (and more) +# Copyright (C) 2025 John Mulligan +# +# 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 +# + +from typing import Any, Union + +import dataclasses +import json +import os +import subprocess + +from sambacc.typelets import Self +import sambacc.config +import sambacc.samba_cmds + + +@dataclasses.dataclass +class Versions: + samba_version: str = "foo" + sambacc_version: str = "bar" + container_version: str = "baz" + + +@dataclasses.dataclass +class Session: + session_id: str + username: str + groupname: str + remote_machine: str + hostname: str + session_dialect: str + uid: int + gid: int + + @classmethod + def load(cls, json_object: dict[str, Any]) -> Self: + return cls( + session_id=json_object.get("session_id", ""), + username=json_object.get("username", ""), + groupname=json_object.get("groupname", ""), + remote_machine=json_object.get("remote_machine", ""), + hostname=json_object.get("hostname", ""), + session_dialect=json_object.get("session_dialect", ""), + uid=int(json_object.get("uid", -1)), + gid=int(json_object.get("gid", -1)), + ) + + +@dataclasses.dataclass +class TreeConnection: + tcon_id: str + session_id: str + service_name: str + + @classmethod + def load(cls, json_object: dict[str, Any]) -> Self: + return cls( + tcon_id=json_object.get("tcon_id", ""), + session_id=json_object.get("session_id", ""), + service_name=json_object.get("service", ""), + ) + + +@dataclasses.dataclass +class Status: + timestamp: str + version: str + sessions: list[Session] + tcons: list[TreeConnection] + + @classmethod + def load(cls, json_object: dict[str, Any]) -> Self: + return cls( + timestamp=json_object.get("timestamp", ""), + version=json_object.get("version", ""), + sessions=[ + Session.load(v) + for _, v in json_object.get("sessions", {}).items() + ], + tcons=[ + TreeConnection.load(v) + for _, v in json_object.get("tcons", {}).items() + ], + ) + + @classmethod + def parse(cls, txt: Union[str, bytes]) -> Self: + return cls.load(json.loads(txt)) + + +class ControlBackend: + def __init__(self, config: sambacc.config.InstanceConfig) -> None: + self._config = config + + def _samba_version(self) -> str: + smbd_ver = sambacc.samba_cmds.smbd["--version"] + res = subprocess.run(list(smbd_ver), check=True, capture_output=True) + return res.stdout.decode().strip() + + def _sambacc_version(self) -> str: + try: + import sambacc._version + + return sambacc._version.version + except ImportError: + return "(unknown)" + + def _container_version(self) -> str: + return os.environ.get("SAMBA_CONTAINER_VERSION", "(unknown)") + + def get_versions(self) -> Versions: + versions = Versions() + versions.samba_version = self._samba_version() + versions.sambacc_version = self._sambacc_version() + versions.container_version = self._container_version() + return versions + + def is_clustered(self) -> bool: + return self._config.with_ctdb + + def get_status(self) -> Status: + smbstatus = sambacc.samba_cmds.smbstatus["--json"] + proc = subprocess.Popen( + list(smbstatus), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + # TODO: the json output of smbstatus is potentially large + # investigate streaming reads instead of fully buffered read + # later + stdout, stderr = proc.communicate() + if proc.returncode != 0: + raise RuntimeError( + f"smbstatus error: {proc.returncode}: {stderr!r}" + ) + return Status.parse(stdout) + + def close_share(self, share_name: str, denied_users: bool) -> None: + _close = "close-denied-share" if denied_users else "close-share" + cmd = sambacc.samba_cmds.smbcontrol["smbd", _close, share_name] + subprocess.run(list(cmd), check=True) + + def kill_client(self, ip_address: str) -> None: + cmd = sambacc.samba_cmds.smbcontrol[ + "smbd", "kill-client-ip", ip_address + ] + subprocess.run(list(cmd), check=True) diff --git a/sambacc/grpc/generated/__init__.py b/sambacc/grpc/generated/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/sambacc/grpc/generated/control_pb2.py b/sambacc/grpc/generated/control_pb2.py new file mode 100644 index 00000000..99bd974d --- /dev/null +++ b/sambacc/grpc/generated/control_pb2.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: control.proto +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import message as _message +from google.protobuf import reflection as _reflection +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\rcontrol.proto\"\r\n\x0bInfoRequest\"/\n\tSambaInfo\x12\x0f\n\x07version\x18\x01 \x01(\t\x12\x11\n\tclustered\x18\x02 \x01(\x08\"H\n\x12SambaContainerInfo\x12\x17\n\x0fsambacc_version\x18\x01 \x01(\t\x12\x19\n\x11\x63ontainer_version\x18\x02 \x01(\t\"Z\n\x0bGeneralInfo\x12\x1e\n\nsamba_info\x18\x01 \x01(\x0b\x32\n.SambaInfo\x12+\n\x0e\x63ontainer_info\x18\x02 \x01(\x0b\x32\x13.SambaContainerInfo\"\x0f\n\rStatusRequest\"\xa3\x01\n\x0bSessionInfo\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x10\n\x08username\x18\x02 \x01(\t\x12\x11\n\tgroupname\x18\x03 \x01(\t\x12\x16\n\x0eremote_machine\x18\x04 \x01(\t\x12\x10\n\x08hostname\x18\x05 \x01(\t\x12\x17\n\x0fsession_dialect\x18\x06 \x01(\t\x12\x0b\n\x03uid\x18\x07 \x01(\r\x12\x0b\n\x03gid\x18\x08 \x01(\r\"E\n\x08\x43onnInfo\x12\x0f\n\x07tcon_id\x18\x01 \x01(\t\x12\x12\n\nsession_id\x18\x02 \x01(\t\x12\x14\n\x0cservice_name\x18\x03 \x01(\t\"k\n\nStatusInfo\x12\x18\n\x10server_timestamp\x18\x01 \x01(\t\x12\x1e\n\x08sessions\x18\x02 \x03(\x0b\x32\x0c.SessionInfo\x12#\n\x10tree_connections\x18\x03 \x03(\x0b\x32\t.ConnInfo\"=\n\x11\x43loseShareRequest\x12\x12\n\nshare_name\x18\x01 \x01(\t\x12\x14\n\x0c\x64\x65nied_users\x18\x02 \x01(\x08\"\x10\n\x0e\x43loseShareInfo\"\'\n\x11KillClientRequest\x12\x12\n\nip_address\x18\x01 \x01(\t\"\x10\n\x0eKillClientInfo2\xc9\x01\n\x0cSambaControl\x12\"\n\x04Info\x12\x0c.InfoRequest\x1a\x0c.GeneralInfo\x12%\n\x06Status\x12\x0e.StatusRequest\x1a\x0b.StatusInfo\x12\x31\n\nCloseShare\x12\x12.CloseShareRequest\x1a\x0f.CloseShareInfo\x12;\n\x14KillClientConnection\x12\x12.KillClientRequest\x1a\x0f.KillClientInfob\x06proto3') + + + +_INFOREQUEST = DESCRIPTOR.message_types_by_name['InfoRequest'] +_SAMBAINFO = DESCRIPTOR.message_types_by_name['SambaInfo'] +_SAMBACONTAINERINFO = DESCRIPTOR.message_types_by_name['SambaContainerInfo'] +_GENERALINFO = DESCRIPTOR.message_types_by_name['GeneralInfo'] +_STATUSREQUEST = DESCRIPTOR.message_types_by_name['StatusRequest'] +_SESSIONINFO = DESCRIPTOR.message_types_by_name['SessionInfo'] +_CONNINFO = DESCRIPTOR.message_types_by_name['ConnInfo'] +_STATUSINFO = DESCRIPTOR.message_types_by_name['StatusInfo'] +_CLOSESHAREREQUEST = DESCRIPTOR.message_types_by_name['CloseShareRequest'] +_CLOSESHAREINFO = DESCRIPTOR.message_types_by_name['CloseShareInfo'] +_KILLCLIENTREQUEST = DESCRIPTOR.message_types_by_name['KillClientRequest'] +_KILLCLIENTINFO = DESCRIPTOR.message_types_by_name['KillClientInfo'] +InfoRequest = _reflection.GeneratedProtocolMessageType('InfoRequest', (_message.Message,), { + 'DESCRIPTOR' : _INFOREQUEST, + '__module__' : 'control_pb2' + # @@protoc_insertion_point(class_scope:InfoRequest) + }) +_sym_db.RegisterMessage(InfoRequest) + +SambaInfo = _reflection.GeneratedProtocolMessageType('SambaInfo', (_message.Message,), { + 'DESCRIPTOR' : _SAMBAINFO, + '__module__' : 'control_pb2' + # @@protoc_insertion_point(class_scope:SambaInfo) + }) +_sym_db.RegisterMessage(SambaInfo) + +SambaContainerInfo = _reflection.GeneratedProtocolMessageType('SambaContainerInfo', (_message.Message,), { + 'DESCRIPTOR' : _SAMBACONTAINERINFO, + '__module__' : 'control_pb2' + # @@protoc_insertion_point(class_scope:SambaContainerInfo) + }) +_sym_db.RegisterMessage(SambaContainerInfo) + +GeneralInfo = _reflection.GeneratedProtocolMessageType('GeneralInfo', (_message.Message,), { + 'DESCRIPTOR' : _GENERALINFO, + '__module__' : 'control_pb2' + # @@protoc_insertion_point(class_scope:GeneralInfo) + }) +_sym_db.RegisterMessage(GeneralInfo) + +StatusRequest = _reflection.GeneratedProtocolMessageType('StatusRequest', (_message.Message,), { + 'DESCRIPTOR' : _STATUSREQUEST, + '__module__' : 'control_pb2' + # @@protoc_insertion_point(class_scope:StatusRequest) + }) +_sym_db.RegisterMessage(StatusRequest) + +SessionInfo = _reflection.GeneratedProtocolMessageType('SessionInfo', (_message.Message,), { + 'DESCRIPTOR' : _SESSIONINFO, + '__module__' : 'control_pb2' + # @@protoc_insertion_point(class_scope:SessionInfo) + }) +_sym_db.RegisterMessage(SessionInfo) + +ConnInfo = _reflection.GeneratedProtocolMessageType('ConnInfo', (_message.Message,), { + 'DESCRIPTOR' : _CONNINFO, + '__module__' : 'control_pb2' + # @@protoc_insertion_point(class_scope:ConnInfo) + }) +_sym_db.RegisterMessage(ConnInfo) + +StatusInfo = _reflection.GeneratedProtocolMessageType('StatusInfo', (_message.Message,), { + 'DESCRIPTOR' : _STATUSINFO, + '__module__' : 'control_pb2' + # @@protoc_insertion_point(class_scope:StatusInfo) + }) +_sym_db.RegisterMessage(StatusInfo) + +CloseShareRequest = _reflection.GeneratedProtocolMessageType('CloseShareRequest', (_message.Message,), { + 'DESCRIPTOR' : _CLOSESHAREREQUEST, + '__module__' : 'control_pb2' + # @@protoc_insertion_point(class_scope:CloseShareRequest) + }) +_sym_db.RegisterMessage(CloseShareRequest) + +CloseShareInfo = _reflection.GeneratedProtocolMessageType('CloseShareInfo', (_message.Message,), { + 'DESCRIPTOR' : _CLOSESHAREINFO, + '__module__' : 'control_pb2' + # @@protoc_insertion_point(class_scope:CloseShareInfo) + }) +_sym_db.RegisterMessage(CloseShareInfo) + +KillClientRequest = _reflection.GeneratedProtocolMessageType('KillClientRequest', (_message.Message,), { + 'DESCRIPTOR' : _KILLCLIENTREQUEST, + '__module__' : 'control_pb2' + # @@protoc_insertion_point(class_scope:KillClientRequest) + }) +_sym_db.RegisterMessage(KillClientRequest) + +KillClientInfo = _reflection.GeneratedProtocolMessageType('KillClientInfo', (_message.Message,), { + 'DESCRIPTOR' : _KILLCLIENTINFO, + '__module__' : 'control_pb2' + # @@protoc_insertion_point(class_scope:KillClientInfo) + }) +_sym_db.RegisterMessage(KillClientInfo) + +_SAMBACONTROL = DESCRIPTOR.services_by_name['SambaControl'] +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + _INFOREQUEST._serialized_start=17 + _INFOREQUEST._serialized_end=30 + _SAMBAINFO._serialized_start=32 + _SAMBAINFO._serialized_end=79 + _SAMBACONTAINERINFO._serialized_start=81 + _SAMBACONTAINERINFO._serialized_end=153 + _GENERALINFO._serialized_start=155 + _GENERALINFO._serialized_end=245 + _STATUSREQUEST._serialized_start=247 + _STATUSREQUEST._serialized_end=262 + _SESSIONINFO._serialized_start=265 + _SESSIONINFO._serialized_end=428 + _CONNINFO._serialized_start=430 + _CONNINFO._serialized_end=499 + _STATUSINFO._serialized_start=501 + _STATUSINFO._serialized_end=608 + _CLOSESHAREREQUEST._serialized_start=610 + _CLOSESHAREREQUEST._serialized_end=671 + _CLOSESHAREINFO._serialized_start=673 + _CLOSESHAREINFO._serialized_end=689 + _KILLCLIENTREQUEST._serialized_start=691 + _KILLCLIENTREQUEST._serialized_end=730 + _KILLCLIENTINFO._serialized_start=732 + _KILLCLIENTINFO._serialized_end=748 + _SAMBACONTROL._serialized_start=751 + _SAMBACONTROL._serialized_end=952 +# @@protoc_insertion_point(module_scope) diff --git a/sambacc/grpc/generated/control_pb2.pyi b/sambacc/grpc/generated/control_pb2.pyi new file mode 100644 index 00000000..d2264cc1 --- /dev/null +++ b/sambacc/grpc/generated/control_pb2.pyi @@ -0,0 +1,235 @@ +""" +@generated by mypy-protobuf. Do not edit manually! +isort:skip_file +Use proto3 as the older protobuf we need for centos doesn't support +2023 edition. +""" +import builtins +import collections.abc +import google.protobuf.descriptor +import google.protobuf.internal.containers +import google.protobuf.message +import sys + +if sys.version_info >= (3, 8): + import typing as typing_extensions +else: + import typing_extensions + +DESCRIPTOR: google.protobuf.descriptor.FileDescriptor + +class InfoRequest(google.protobuf.message.Message): + """--- Info --- + Provide version numbers and basic information about the samba + container instance. Mainly for debugging. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + +global___InfoRequest = InfoRequest + +class SambaInfo(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + VERSION_FIELD_NUMBER: builtins.int + CLUSTERED_FIELD_NUMBER: builtins.int + version: builtins.str + clustered: builtins.bool + def __init__( + self, + *, + version: builtins.str = ..., + clustered: builtins.bool = ..., + ) -> None: ... + def ClearField(self, field_name: typing_extensions.Literal["clustered", b"clustered", "version", b"version"]) -> None: ... + +global___SambaInfo = SambaInfo + +class SambaContainerInfo(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + SAMBACC_VERSION_FIELD_NUMBER: builtins.int + CONTAINER_VERSION_FIELD_NUMBER: builtins.int + sambacc_version: builtins.str + container_version: builtins.str + def __init__( + self, + *, + sambacc_version: builtins.str = ..., + container_version: builtins.str = ..., + ) -> None: ... + def ClearField(self, field_name: typing_extensions.Literal["container_version", b"container_version", "sambacc_version", b"sambacc_version"]) -> None: ... + +global___SambaContainerInfo = SambaContainerInfo + +class GeneralInfo(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + SAMBA_INFO_FIELD_NUMBER: builtins.int + CONTAINER_INFO_FIELD_NUMBER: builtins.int + @property + def samba_info(self) -> global___SambaInfo: ... + @property + def container_info(self) -> global___SambaContainerInfo: ... + def __init__( + self, + *, + samba_info: global___SambaInfo | None = ..., + container_info: global___SambaContainerInfo | None = ..., + ) -> None: ... + def HasField(self, field_name: typing_extensions.Literal["container_info", b"container_info", "samba_info", b"samba_info"]) -> builtins.bool: ... + def ClearField(self, field_name: typing_extensions.Literal["container_info", b"container_info", "samba_info", b"samba_info"]) -> None: ... + +global___GeneralInfo = GeneralInfo + +class StatusRequest(google.protobuf.message.Message): + """--- Status --- + Fetch status information from the samba instance. Includes basic + information about connected clients. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + +global___StatusRequest = StatusRequest + +class SessionInfo(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + SESSION_ID_FIELD_NUMBER: builtins.int + USERNAME_FIELD_NUMBER: builtins.int + GROUPNAME_FIELD_NUMBER: builtins.int + REMOTE_MACHINE_FIELD_NUMBER: builtins.int + HOSTNAME_FIELD_NUMBER: builtins.int + SESSION_DIALECT_FIELD_NUMBER: builtins.int + UID_FIELD_NUMBER: builtins.int + GID_FIELD_NUMBER: builtins.int + session_id: builtins.str + username: builtins.str + groupname: builtins.str + remote_machine: builtins.str + hostname: builtins.str + session_dialect: builtins.str + uid: builtins.int + gid: builtins.int + def __init__( + self, + *, + session_id: builtins.str = ..., + username: builtins.str = ..., + groupname: builtins.str = ..., + remote_machine: builtins.str = ..., + hostname: builtins.str = ..., + session_dialect: builtins.str = ..., + uid: builtins.int = ..., + gid: builtins.int = ..., + ) -> None: ... + def ClearField(self, field_name: typing_extensions.Literal["gid", b"gid", "groupname", b"groupname", "hostname", b"hostname", "remote_machine", b"remote_machine", "session_dialect", b"session_dialect", "session_id", b"session_id", "uid", b"uid", "username", b"username"]) -> None: ... + +global___SessionInfo = SessionInfo + +class ConnInfo(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + TCON_ID_FIELD_NUMBER: builtins.int + SESSION_ID_FIELD_NUMBER: builtins.int + SERVICE_NAME_FIELD_NUMBER: builtins.int + tcon_id: builtins.str + session_id: builtins.str + service_name: builtins.str + def __init__( + self, + *, + tcon_id: builtins.str = ..., + session_id: builtins.str = ..., + service_name: builtins.str = ..., + ) -> None: ... + def ClearField(self, field_name: typing_extensions.Literal["service_name", b"service_name", "session_id", b"session_id", "tcon_id", b"tcon_id"]) -> None: ... + +global___ConnInfo = ConnInfo + +class StatusInfo(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + SERVER_TIMESTAMP_FIELD_NUMBER: builtins.int + SESSIONS_FIELD_NUMBER: builtins.int + TREE_CONNECTIONS_FIELD_NUMBER: builtins.int + server_timestamp: builtins.str + @property + def sessions(self) -> google.protobuf.internal.containers.RepeatedCompositeFieldContainer[global___SessionInfo]: ... + @property + def tree_connections(self) -> google.protobuf.internal.containers.RepeatedCompositeFieldContainer[global___ConnInfo]: ... + def __init__( + self, + *, + server_timestamp: builtins.str = ..., + sessions: collections.abc.Iterable[global___SessionInfo] | None = ..., + tree_connections: collections.abc.Iterable[global___ConnInfo] | None = ..., + ) -> None: ... + def ClearField(self, field_name: typing_extensions.Literal["server_timestamp", b"server_timestamp", "sessions", b"sessions", "tree_connections", b"tree_connections"]) -> None: ... + +global___StatusInfo = StatusInfo + +class CloseShareRequest(google.protobuf.message.Message): + """--- CloseShare --- + Close shares to clients. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + SHARE_NAME_FIELD_NUMBER: builtins.int + DENIED_USERS_FIELD_NUMBER: builtins.int + share_name: builtins.str + denied_users: builtins.bool + def __init__( + self, + *, + share_name: builtins.str = ..., + denied_users: builtins.bool = ..., + ) -> None: ... + def ClearField(self, field_name: typing_extensions.Literal["denied_users", b"denied_users", "share_name", b"share_name"]) -> None: ... + +global___CloseShareRequest = CloseShareRequest + +class CloseShareInfo(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + +global___CloseShareInfo = CloseShareInfo + +class KillClientRequest(google.protobuf.message.Message): + """--- KillClientConnection --- + Forcibly disconnect a client. + """ + + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + IP_ADDRESS_FIELD_NUMBER: builtins.int + ip_address: builtins.str + def __init__( + self, + *, + ip_address: builtins.str = ..., + ) -> None: ... + def ClearField(self, field_name: typing_extensions.Literal["ip_address", b"ip_address"]) -> None: ... + +global___KillClientRequest = KillClientRequest + +class KillClientInfo(google.protobuf.message.Message): + DESCRIPTOR: google.protobuf.descriptor.Descriptor + + def __init__( + self, + ) -> None: ... + +global___KillClientInfo = KillClientInfo diff --git a/sambacc/grpc/generated/control_pb2_grpc.py b/sambacc/grpc/generated/control_pb2_grpc.py new file mode 100644 index 00000000..a8cffe52 --- /dev/null +++ b/sambacc/grpc/generated/control_pb2_grpc.py @@ -0,0 +1,171 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc + +from . import control_pb2 as control__pb2 + + +class SambaControlStub(object): + """--- define rpcs --- + + """ + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.Info = channel.unary_unary( + '/SambaControl/Info', + request_serializer=control__pb2.InfoRequest.SerializeToString, + response_deserializer=control__pb2.GeneralInfo.FromString, + ) + self.Status = channel.unary_unary( + '/SambaControl/Status', + request_serializer=control__pb2.StatusRequest.SerializeToString, + response_deserializer=control__pb2.StatusInfo.FromString, + ) + self.CloseShare = channel.unary_unary( + '/SambaControl/CloseShare', + request_serializer=control__pb2.CloseShareRequest.SerializeToString, + response_deserializer=control__pb2.CloseShareInfo.FromString, + ) + self.KillClientConnection = channel.unary_unary( + '/SambaControl/KillClientConnection', + request_serializer=control__pb2.KillClientRequest.SerializeToString, + response_deserializer=control__pb2.KillClientInfo.FromString, + ) + + +class SambaControlServicer(object): + """--- define rpcs --- + + """ + + def Info(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def Status(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def CloseShare(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def KillClientConnection(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_SambaControlServicer_to_server(servicer, server): + rpc_method_handlers = { + 'Info': grpc.unary_unary_rpc_method_handler( + servicer.Info, + request_deserializer=control__pb2.InfoRequest.FromString, + response_serializer=control__pb2.GeneralInfo.SerializeToString, + ), + 'Status': grpc.unary_unary_rpc_method_handler( + servicer.Status, + request_deserializer=control__pb2.StatusRequest.FromString, + response_serializer=control__pb2.StatusInfo.SerializeToString, + ), + 'CloseShare': grpc.unary_unary_rpc_method_handler( + servicer.CloseShare, + request_deserializer=control__pb2.CloseShareRequest.FromString, + response_serializer=control__pb2.CloseShareInfo.SerializeToString, + ), + 'KillClientConnection': grpc.unary_unary_rpc_method_handler( + servicer.KillClientConnection, + request_deserializer=control__pb2.KillClientRequest.FromString, + response_serializer=control__pb2.KillClientInfo.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'SambaControl', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + + + # This class is part of an EXPERIMENTAL API. +class SambaControl(object): + """--- define rpcs --- + + """ + + @staticmethod + def Info(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/SambaControl/Info', + control__pb2.InfoRequest.SerializeToString, + control__pb2.GeneralInfo.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def Status(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/SambaControl/Status', + control__pb2.StatusRequest.SerializeToString, + control__pb2.StatusInfo.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def CloseShare(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/SambaControl/CloseShare', + control__pb2.CloseShareRequest.SerializeToString, + control__pb2.CloseShareInfo.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) + + @staticmethod + def KillClientConnection(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary(request, target, '/SambaControl/KillClientConnection', + control__pb2.KillClientRequest.SerializeToString, + control__pb2.KillClientInfo.FromString, + options, channel_credentials, + insecure, call_credentials, compression, wait_for_ready, timeout, metadata) diff --git a/sambacc/grpc/protobufs/control.proto b/sambacc/grpc/protobufs/control.proto new file mode 100644 index 00000000..32f16f3c --- /dev/null +++ b/sambacc/grpc/protobufs/control.proto @@ -0,0 +1,84 @@ +// Use proto3 as the older protobuf we need for centos doesn't support +// 2023 edition. +syntax = "proto3"; + +// Some requests and respose types are currently empty. However, we don't use +// Empty in the case we want to extend them in the future. + +// --- Info --- +// Provide version numbers and basic information about the samba +// container instance. Mainly for debugging. + +message InfoRequest {} + +message SambaInfo { + string version = 1; + bool clustered = 2; +} + +message SambaContainerInfo { + string sambacc_version = 1; + string container_version = 2; +} + +message GeneralInfo { + SambaInfo samba_info = 1; + SambaContainerInfo container_info = 2; +} + +// --- Status --- +// Fetch status information from the samba instance. Includes basic +// information about connected clients. + +message StatusRequest {} + +message SessionInfo { + string session_id = 1; + string username = 2; + string groupname = 3; + string remote_machine = 4; + string hostname = 5; + string session_dialect = 6; + uint32 uid = 7; + uint32 gid = 8; +} + +message ConnInfo { + string tcon_id = 1; + string session_id = 2; + string service_name = 3; +} + +message StatusInfo { + string server_timestamp = 1; + repeated SessionInfo sessions = 2; + repeated ConnInfo tree_connections = 3; +} + +// --- CloseShare --- +// Close shares to clients. + +message CloseShareRequest { + string share_name = 1; + bool denied_users = 2; +} + +message CloseShareInfo {} + +// --- KillClientConnection --- +// Forcibly disconnect a client. + +message KillClientRequest { + string ip_address = 1; +} + +message KillClientInfo {} + +// --- define rpcs --- + +service SambaControl { + rpc Info (InfoRequest) returns (GeneralInfo); + rpc Status (StatusRequest) returns (StatusInfo); + rpc CloseShare (CloseShareRequest) returns (CloseShareInfo); + rpc KillClientConnection (KillClientRequest) returns (KillClientInfo); +} diff --git a/sambacc/grpc/server.py b/sambacc/grpc/server.py new file mode 100644 index 00000000..134c6d41 --- /dev/null +++ b/sambacc/grpc/server.py @@ -0,0 +1,198 @@ +# +# sambacc: a samba container configuration tool (and more) +# Copyright (C) 2025 John Mulligan +# +# 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 +# + +from typing import Iterator, Protocol, Optional + +import concurrent.futures +import contextlib +import logging + +import grpc + +import sambacc.grpc.backend as rbe +import sambacc.grpc.generated.control_pb2 as pb +import sambacc.grpc.generated.control_pb2_grpc as control_rpc + +_logger = logging.getLogger(__name__) + + +class Backend(Protocol): + def get_versions(self) -> rbe.Versions: ... + + def is_clustered(self) -> bool: ... + + def get_status(self) -> rbe.Status: ... + + def close_share(self, share_name: str, denied_users: bool) -> None: ... + + def kill_client(self, ip_address: str) -> None: ... + + +@contextlib.contextmanager +def _in_rpc(context: grpc.ServicerContext, allowed: bool) -> Iterator[None]: + if not allowed: + _logger.error("Blocking operation") + context.abort( + grpc.StatusCode.PERMISSION_DENIED, "Operation not permitted" + ) + try: + yield + except Exception: + _logger.exception("exception in rpc call") + context.abort(grpc.StatusCode.UNKNOWN, "Unexpected server error") + + +def _get_info(backend: Backend) -> pb.GeneralInfo: + _info = backend.get_versions() + clustered = backend.is_clustered() + return pb.GeneralInfo( + samba_info=pb.SambaInfo( + version=_info.samba_version, + clustered=clustered, + ), + container_info=pb.SambaContainerInfo( + sambacc_version=_info.sambacc_version, + container_version=_info.container_version, + ), + ) + + +def _convert_session(session: rbe.Session) -> pb.SessionInfo: + info = pb.SessionInfo( + session_id=session.session_id, + username=session.username, + groupname=session.groupname, + remote_machine=session.remote_machine, + hostname=session.hostname, + session_dialect=session.session_dialect, + ) + # python side takes -1 to mean not found uid/gid. in protobufs + # that would mean the fields are unset + if session.uid > 0: + info.uid = session.uid + if session.gid > 0: + info.gid = session.gid + return info + + +def _convert_tcon(tcon: rbe.TreeConnection) -> pb.ConnInfo: + return pb.ConnInfo( + tcon_id=tcon.tcon_id, + session_id=tcon.session_id, + service_name=tcon.service_name, + ) + + +def _convert_status(status: rbe.Status) -> pb.StatusInfo: + return pb.StatusInfo( + server_timestamp=status.timestamp, + sessions=[_convert_session(s) for s in status.sessions], + tree_connections=[_convert_tcon(t) for t in status.tcons], + ) + + +class ControlService(control_rpc.SambaControlServicer): + def __init__(self, backend: Backend, *, read_only: bool = False): + self._backend = backend + self._read_only = read_only + self._ok_to_read = True + self._ok_to_modify = not read_only + + def Info( + self, request: pb.InfoRequest, context: grpc.ServicerContext + ) -> pb.GeneralInfo: + _logger.debug("RPC Called: Info") + with _in_rpc(context, self._ok_to_read): + info = _get_info(self._backend) + return info + + def Status( + self, request: pb.StatusRequest, context: grpc.ServicerContext + ) -> pb.StatusInfo: + _logger.debug("RPC Called: Status") + with _in_rpc(context, self._ok_to_read): + info = _convert_status(self._backend.get_status()) + return info + + def CloseShare( + self, request: pb.CloseShareRequest, context: grpc.ServicerContext + ) -> pb.CloseShareInfo: + _logger.debug("RPC Called: CloseShare") + with _in_rpc(context, self._ok_to_modify): + self._backend.close_share(request.share_name, request.denied_users) + info = pb.CloseShareInfo() + return info + + def KillClientConnection( + self, request: pb.KillClientRequest, context: grpc.ServicerContext + ) -> pb.KillClientInfo: + _logger.debug("RPC Called: KillClientConnection") + with _in_rpc(context, self._ok_to_modify): + self._backend.kill_client(request.ip_address) + info = pb.KillClientInfo() + return info + + +class ServerConfig: + max_workers: int = 8 + address: str = "localhost:54445" + read_only: bool = False + insecure: bool = True + server_key: Optional[bytes] = None + server_cert: Optional[bytes] = None + ca_cert: Optional[bytes] = None + + +def serve(config: ServerConfig, backend: Backend) -> None: + _logger.info( + "Starting gRPC server on %s (%s, %s)", + config.address, + "insecure" if config.insecure else "tls", + "read-only" if config.read_only else "read-modify", + ) + service = ControlService(backend, read_only=config.read_only) + executor = concurrent.futures.ThreadPoolExecutor( + max_workers=config.max_workers + ) + server = grpc.server(executor) + control_rpc.add_SambaControlServicer_to_server(service, server) + if config.insecure: + server.add_insecure_port(config.address) + else: + if not config.server_key: + raise ValueError("missing server TLS key") + if not config.server_cert: + raise ValueError("missing server TLS cert") + if config.ca_cert: + creds = grpc.ssl_server_credentials( + [(config.server_key, config.server_cert)], + root_certificates=config.ca_cert, + require_client_auth=True, + ) + else: + creds = grpc.ssl_server_credentials( + [(config.server_key, config.server_cert)], + ) + server.add_secure_port(config.address, creds) + server.start() + # hack for testing + wait_fn = getattr(config, "wait", None) + if wait_fn: + wait_fn(server) + else: + server.wait_for_termination() diff --git a/sambacc/samba_cmds.py b/sambacc/samba_cmds.py index 177aa971..fa5e8b8b 100644 --- a/sambacc/samba_cmds.py +++ b/sambacc/samba_cmds.py @@ -194,6 +194,8 @@ def samba_dc_foreground() -> SambaCommand: smbcontrol = SambaCommand("smbcontrol") +smbstatus = SambaCommand("smbstatus") + ctdb_mutex_ceph_rados_helper = SambaCommand( "/usr/libexec/ctdb/ctdb_mutex_ceph_rados_helper" ) diff --git a/setup.cfg b/setup.cfg index e4b3016e..e4ea95f1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -15,13 +15,20 @@ long_description = file: README.md long_description_content_type = text/markdown [options] -packages = sambacc, sambacc.commands, sambacc.schema +packages = + sambacc + sambacc.commands + sambacc.schema + sambacc.grpc + sambacc.grpc.generated + sambacc.commands.remotecontrol include_package_data = True [options.entry_points] console_scripts = samba-container = sambacc.commands.main:main samba-dc-container = sambacc.commands.dcmain:main + samba-remote-control = sambacc.commands.remotecontrol.main:main [options.data_files] share/sambacc/examples = @@ -39,3 +46,6 @@ toml = tomli;python_version<"3.11" rados = rados +grpc = + grpcio>=1.48 + protobuf>=3.19 diff --git a/tests/test_grpc_backend.py b/tests/test_grpc_backend.py new file mode 100644 index 00000000..320f0d1b --- /dev/null +++ b/tests/test_grpc_backend.py @@ -0,0 +1,246 @@ +# +# sambacc: a samba container configuration tool +# Copyright (C) 2025 John Mulligan +# +# 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 +# + +import io +import os + +import pytest + +import sambacc.grpc.backend + + +config1 = """ +{ + "samba-container-config": "v0", + "configs": { + "foobar": { + "shares": [ + "share", + "stuff" + ], + "globals": ["global0"], + "instance_name": "GANDOLPH" + } + }, + "shares": { + "share": { + "options": { + "path": "/share", + "read only": "no", + "valid users": "sambauser", + "guest ok": "no", + "force user": "root" + } + }, + "stuff": { + "options": { + "path": "/mnt/stuff" + } + } + }, + "globals": { + "global0": { + "options": { + "workgroup": "SAMBA", + "security": "user", + "server min protocol": "SMB2", + "load printers": "no", + "printing": "bsd", + "printcap name": "/dev/null", + "disable spoolss": "yes", + "guest ok": "no" + } + } + }, + "_extra_junk": 0 +} +""" + +json1 = """ +{ + "timestamp": "2025-05-08T20:41:57.273489+0000", + "version": "4.23.0pre1-UNKNOWN", + "smb_conf": "/etc/samba/smb.conf", + "sessions": { + "2891148582": { + "session_id": "2891148582", + "server_id": { + "pid": "1243", + "task_id": "0", + "vnn": "2", + "unique_id": "1518712196307698939" + }, + "uid": 103107, + "gid": 102513, + "username": "DOMAIN1\\\\bwayne", + "groupname": "DOMAIN1\\\\domain users", + "creation_time": "2025-05-08T20:39:36.456835+00:00", + "expiration_time": "30828-09-14T02:48:05.477581+00:00", + "auth_time": "2025-05-08T20:39:36.457633+00:00", + "remote_machine": "127.0.0.1", + "hostname": "ipv4:127.0.0.1:59396", + "session_dialect": "SMB3_11", + "client_guid": "adc145fe-0677-4ab6-9d61-c25b30211174", + "encryption": { + "cipher": "-", + "degree": "none" + }, + "signing": { + "cipher": "AES-128-GMAC", + "degree": "partial" + }, + "channels": { + "1": { + "channel_id": "1", + "creation_time": "2025-05-08T20:39:36.456835+00:00", + "local_address": "ipv4:127.0.0.1:445", + "remote_address": "ipv4:127.0.0.1:59396", + "transport": "tcp" + } + } + } + }, + "tcons": { + "3757739897": { + "service": "cephomatic", + "server_id": { + "pid": "1243", + "task_id": "0", + "vnn": "2", + "unique_id": "1518712196307698939" + }, + "tcon_id": "3757739897", + "session_id": "2891148582", + "machine": "127.0.0.1", + "connected_at": "2025-05-08T20:39:36.464088+00:00", + "encryption": { + "cipher": "-", + "degree": "none" + }, + "signing": { + "cipher": "-", + "degree": "none" + } + } + }, + "open_files": {} +} +""" + + +def _status_json1_check(status): + assert status.timestamp == "2025-05-08T20:41:57.273489+0000" + assert len(status.sessions) == 1 + s1 = status.sessions[0] + assert s1.session_id == "2891148582" + assert s1.username == "DOMAIN1\\bwayne" + assert s1.groupname == "DOMAIN1\\domain users" + assert s1.remote_machine == "127.0.0.1" + assert s1.hostname == "ipv4:127.0.0.1:59396" + assert s1.session_dialect == "SMB3_11" + assert s1.uid == 103107 + assert s1.gid == 102513 + assert len(status.tcons) == 1 + t1 = status.tcons[0] + assert t1.tcon_id == "3757739897" + assert t1.session_id == "2891148582" + assert t1.service_name == "cephomatic" + + +def _fake_command(tmp_path, monkeypatch, *, output="", exitcode=0): + fakedir = tmp_path / "fake" + fake = fakedir / "fake.sh" + outfile = fakedir / "stdout" + + print(fakedir) + print(fakedir.mkdir, fakedir.mkdir.__doc__) + fakedir.mkdir(parents=True, exist_ok=True) + monkeypatch.setattr(sambacc.samba_cmds, "_GLOBAL_PREFIX", [str(fake)]) + + if output: + outfile.write_text(output) + fake.write_text( + "#!/bin/sh\n" + f"test -f {outfile} && cat {outfile}\n" + f"exit {exitcode}\n" + ) + os.chmod(fake, 0o755) + + +def _instance_config(): + fh = io.StringIO(config1) + g = sambacc.config.GlobalConfig(fh) + return g.get("foobar") + + +def test_parse_status(): + status = sambacc.grpc.backend.Status.parse(json1) + _status_json1_check(status) + + +def test_backend_versions(tmp_path, monkeypatch): + _fake_command(tmp_path, monkeypatch, output="Version 4.99.99\n") + backend = sambacc.grpc.backend.ControlBackend(_instance_config()) + v = backend.get_versions() + assert v.samba_version == "Version 4.99.99" + + +def test_backend_is_clustered(tmp_path, monkeypatch): + _fake_command(tmp_path, monkeypatch) + backend = sambacc.grpc.backend.ControlBackend(_instance_config()) + assert not backend.is_clustered() + + +def test_backend_status(tmp_path, monkeypatch): + _fake_command(tmp_path, monkeypatch, output=json1) + backend = sambacc.grpc.backend.ControlBackend(_instance_config()) + status = backend.get_status() + _status_json1_check(status) + + +def test_backend_status_error(tmp_path, monkeypatch): + _fake_command(tmp_path, monkeypatch, exitcode=2) + backend = sambacc.grpc.backend.ControlBackend(_instance_config()) + with pytest.raises(Exception): + backend.get_status() + + +def test_backend_close_share(tmp_path, monkeypatch): + _fake_command(tmp_path, monkeypatch) + backend = sambacc.grpc.backend.ControlBackend(_instance_config()) + backend.close_share("share", denied_users=False) + + +def test_backend_close_share_error(tmp_path, monkeypatch): + _fake_command(tmp_path, monkeypatch, exitcode=2) + backend = sambacc.grpc.backend.ControlBackend(_instance_config()) + with pytest.raises(Exception): + backend.close_share("share", denied_users=False) + + +def test_backend_kill_client(tmp_path, monkeypatch): + _fake_command(tmp_path, monkeypatch) + backend = sambacc.grpc.backend.ControlBackend(_instance_config()) + backend.kill_client("127.0.0.1") + + +def test_backend_kill_client_error(tmp_path, monkeypatch): + _fake_command(tmp_path, monkeypatch, exitcode=2) + backend = sambacc.grpc.backend.ControlBackend(_instance_config()) + with pytest.raises(Exception): + backend.kill_client("127.0.0.1") diff --git a/tests/test_grpc_server.py b/tests/test_grpc_server.py new file mode 100644 index 00000000..0c7d87c4 --- /dev/null +++ b/tests/test_grpc_server.py @@ -0,0 +1,225 @@ +# +# sambacc: a samba container configuration tool +# Copyright (C) 2025 John Mulligan +# +# 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 +# + +import collections + +import pytest + +from sambacc.grpc import backend + +json1 = """ +{ + "timestamp": "2025-05-08T20:41:57.273489+0000", + "version": "4.23.0pre1-UNKNOWN", + "smb_conf": "/etc/samba/smb.conf", + "sessions": { + "2891148582": { + "session_id": "2891148582", + "server_id": { + "pid": "1243", + "task_id": "0", + "vnn": "2", + "unique_id": "1518712196307698939" + }, + "uid": 103107, + "gid": 102513, + "username": "DOMAIN1\\\\bwayne", + "groupname": "DOMAIN1\\\\domain users", + "creation_time": "2025-05-08T20:39:36.456835+00:00", + "expiration_time": "30828-09-14T02:48:05.477581+00:00", + "auth_time": "2025-05-08T20:39:36.457633+00:00", + "remote_machine": "127.0.0.1", + "hostname": "ipv4:127.0.0.1:59396", + "session_dialect": "SMB3_11", + "client_guid": "adc145fe-0677-4ab6-9d61-c25b30211174", + "encryption": { + "cipher": "-", + "degree": "none" + }, + "signing": { + "cipher": "AES-128-GMAC", + "degree": "partial" + }, + "channels": { + "1": { + "channel_id": "1", + "creation_time": "2025-05-08T20:39:36.456835+00:00", + "local_address": "ipv4:127.0.0.1:445", + "remote_address": "ipv4:127.0.0.1:59396", + "transport": "tcp" + } + } + } + }, + "tcons": { + "3757739897": { + "service": "cephomatic", + "server_id": { + "pid": "1243", + "task_id": "0", + "vnn": "2", + "unique_id": "1518712196307698939" + }, + "tcon_id": "3757739897", + "session_id": "2891148582", + "machine": "127.0.0.1", + "connected_at": "2025-05-08T20:39:36.464088+00:00", + "encryption": { + "cipher": "-", + "degree": "none" + }, + "signing": { + "cipher": "-", + "degree": "none" + } + } + }, + "open_files": {} +} +""" + + +class MockBackend: + def __init__(self): + self._counter = collections.Counter() + self._versions = backend.Versions( + samba_version="4.99.5", + sambacc_version="a.b.c", + container_version="test.v", + ) + self._is_clustered = False + self._status = backend.Status.parse(json1) + self._kaboom = None + + def get_versions(self) -> backend.Versions: + self._counter["get_versions"] += 1 + if self._kaboom: + raise self._kaboom + return self._versions + + def is_clustered(self) -> bool: + self._counter["is_clustered"] += 1 + return self._is_clustered + + def get_status(self) -> backend.Status: + self._counter["get_status"] += 1 + return self._status + + def close_share(self, share_name: str, denied_users: bool) -> None: + self._counter["close_share"] += 1 + + def kill_client(self, ip_address: str) -> None: + self._counter["kill_client"] += 1 + + +@pytest.fixture() +def mock_grpc_server(): + try: + import sambacc.grpc.server + except ImportError: + pytest.skip("can not import grpc server") + + class TestConfig(sambacc.grpc.server.ServerConfig): + max_workers = 3 + address = "localhost:54445" + insecure = True + _server = None + backend = None + + def wait(self, server): + self._server = server + + tc = TestConfig() + tc.backend = MockBackend() + sambacc.grpc.server.serve(tc, tc.backend) + assert tc._server + assert tc.backend + yield tc + tc._server.stop(0.1).wait() + + +def test_info(mock_grpc_server): + import grpc + import sambacc.grpc.generated.control_pb2_grpc as _rpc + import sambacc.grpc.generated.control_pb2 as _pb + + with grpc.insecure_channel(mock_grpc_server.address) as channel: + client = _rpc.SambaControlStub(channel) + rsp = client.Info(_pb.InfoRequest()) + + assert mock_grpc_server.backend._counter["get_versions"] == 1 + assert rsp.samba_info.version == "4.99.5" + assert not rsp.samba_info.clustered + assert rsp.container_info.sambacc_version == "a.b.c" + assert rsp.container_info.container_version == "test.v" + + +def test_info_error(mock_grpc_server): + import grpc + import sambacc.grpc.generated.control_pb2_grpc as _rpc + import sambacc.grpc.generated.control_pb2 as _pb + + mock_grpc_server.backend._kaboom = ValueError("kaboom") + with grpc.insecure_channel(mock_grpc_server.address) as channel: + client = _rpc.SambaControlStub(channel) + with pytest.raises(grpc.RpcError): + client.Info(_pb.InfoRequest()) + + assert mock_grpc_server.backend._counter["get_versions"] == 1 + + +def test_status(mock_grpc_server): + import grpc + import sambacc.grpc.generated.control_pb2_grpc as _rpc + import sambacc.grpc.generated.control_pb2 as _pb + + with grpc.insecure_channel(mock_grpc_server.address) as channel: + client = _rpc.SambaControlStub(channel) + rsp = client.Status(_pb.StatusRequest()) + + assert mock_grpc_server.backend._counter["get_status"] == 1 + assert rsp.server_timestamp == "2025-05-08T20:41:57.273489+0000" + # TODO data assertions + + +def test_close_share(mock_grpc_server): + import grpc + import sambacc.grpc.generated.control_pb2_grpc as _rpc + import sambacc.grpc.generated.control_pb2 as _pb + + with grpc.insecure_channel(mock_grpc_server.address) as channel: + client = _rpc.SambaControlStub(channel) + rsp = client.CloseShare(_pb.CloseShareRequest(share_name="bob")) + + assert mock_grpc_server.backend._counter["close_share"] == 1 + assert rsp + + +def test_kill_client(mock_grpc_server): + import grpc + import sambacc.grpc.generated.control_pb2_grpc as _rpc + import sambacc.grpc.generated.control_pb2 as _pb + + with grpc.insecure_channel(mock_grpc_server.address) as channel: + client = _rpc.SambaControlStub(channel) + rsp = client.KillClientConnection( + _pb.KillClientRequest(ip_address="192.168.76.18") + ) + + assert mock_grpc_server.backend._counter["kill_client"] == 1 + assert rsp diff --git a/tox.ini b/tox.ini index 7f30cb18..8148880c 100644 --- a/tox.ini +++ b/tox.ini @@ -13,7 +13,7 @@ deps = pytest pytest-cov dnspython - -e .[validation,yaml,toml] + -e .[validation,yaml,toml,grpc] commands = py.test -v --cov=sambacc --cov-report=html {posargs:tests} @@ -24,6 +24,8 @@ deps = types-setuptools types-pyyaml types-jsonschema>=4.10 + types-protobuf + types-grpcio tomli {[testenv]deps} commands = @@ -55,14 +57,14 @@ description = Check the style/formatting for the source files deps = black>=24, <25 commands = - black --check -v . + black --check -v --extend-exclude sambacc/grpc/generated . [testenv:flake8] description = Basic python linting for the source files deps = flake8 commands = - flake8 sambacc tests + flake8 --exclude sambacc/grpc/generated sambacc tests [testenv:schemacheck] description = Check the JSON Schema files are valid @@ -88,3 +90,47 @@ deps = gitlint==0.19.1 commands = gitlint -C .gitlint --commits origin/master.. lint + + +# IMPORTANT: note that there are two environments provided here for generating +# the grpc/protobuf files. One uses a typical tox environment with versions +# and the other uses system packages (sitepackages=True). +# The former is what developers are expected to use HOWEVER because we must +# deliver on enterprise linux platforms we provide a way to generate +# the code using system packages for comparison purposes. + +# Generate grpc/protobuf code from .proto files. +# Includes a generator for .pyi files. +# Uses sed to fix the foolish import behavior of the grpc generator. +[testenv:grpc-generate] +description = Generate gRPC files +deps = + grpcio-tools ~= 1.48.0 + protobuf ~= 3.19.0 + mypy-protobuf +allowlist_externals = sed +commands = + python -m grpc_tools.protoc \ + -I sambacc/grpc/protobufs \ + --python_out=sambacc/grpc/generated \ + --grpc_python_out=sambacc/grpc/generated \ + --mypy_out=sambacc/grpc/generated \ + sambacc/grpc/protobufs/control.proto + sed -i -E 's/^import.*_pb2/from . \0/' \ + sambacc/grpc/generated/control_pb2_grpc.py + +# Generate grpc/protobuf code from .proto files using system packages. +# Does NOT include a generator for .pyi files. +# Uses sed to fix the foolish import behavior of the grpc generator. +[testenv:grpc-sys-generate] +description = Generate gRPC files using system python packages +sitepackages = True +allowlist_externals = sed +commands = + python -m grpc_tools.protoc \ + -I sambacc/grpc/protobufs \ + --python_out=sambacc/grpc/generated \ + --grpc_python_out=sambacc/grpc/generated \ + sambacc/grpc/protobufs/control.proto + sed -i -E 's/^import.*_pb2/from . \0/' \ + sambacc/grpc/generated/control_pb2_grpc.py