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