server-management-lib 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,380 @@
1
+ """
2
+ Optional SSH local port forwarding for loopback-only metric endpoints.
3
+
4
+ A metric service (InfluxDB, Prometheus) may listen only on the target host's
5
+ loopback interface. In that case the MCP cannot reach it directly: the normal
6
+ mode is an HTTPS reverse proxy with a token. This module lets the MCP create a
7
+ local tunnel through the configured SSH connection instead of requiring the
8
+ operator to keep ``ssh -N -L`` running by hand.
9
+
10
+ The tunnel is deliberately narrow:
11
+
12
+ - it listens on a loopback address only (never ``0.0.0.0``);
13
+ - it forwards to the host/port configured in the database section;
14
+ - it uses the same SSH settings (host key verification, key, agent) as the
15
+ executor.
16
+ """
17
+
18
+ import asyncio
19
+ import logging
20
+ from dataclasses import dataclass
21
+ from typing import Any
22
+
23
+ import asyncssh
24
+
25
+ from .security import SecurityValidator
26
+ from .ssh_manager import SSHManager
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+ LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
31
+ MAX_PORT = 65535
32
+
33
+ # Section name -> default remote port. The database port is the remote port
34
+ # when ``ssh_tunnel.remote_port`` is not set.
35
+ METRIC_SECTIONS: tuple[tuple[str, int], ...] = (
36
+ ("influxdb", 8181),
37
+ ("prometheus", 9090),
38
+ )
39
+
40
+
41
+ class SSHTunnelError(RuntimeError):
42
+ """Raised when an SSH tunnel cannot be configured or opened."""
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class TunnelEndpoint:
47
+ """Local address a tunnel is listening on."""
48
+
49
+ host: str
50
+ port: int
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class TunnelConfig:
55
+ """Validated ``ssh_tunnel`` settings for one metric section."""
56
+
57
+ remote_host: str
58
+ remote_port: int
59
+ local_host: str = "127.0.0.1"
60
+ local_port: int = 0
61
+
62
+ @classmethod
63
+ def from_config(
64
+ cls,
65
+ value: Any,
66
+ *,
67
+ default_remote_port: int,
68
+ ) -> "TunnelConfig":
69
+ """Validate a raw ``ssh_tunnel`` mapping.
70
+
71
+ ``default_remote_port`` is the database section's ``port`` (or its
72
+ built-in default), so a minimal configuration only needs
73
+ ``enabled: true``.
74
+ """
75
+ if not isinstance(value, dict):
76
+ raise SSHTunnelError("ssh_tunnel must be a mapping")
77
+
78
+ remote_host = _validate_host(
79
+ value.get("remote_host", "127.0.0.1"),
80
+ "ssh_tunnel.remote_host",
81
+ )
82
+ raw_remote_port = value.get("remote_port")
83
+ if raw_remote_port is None:
84
+ raw_remote_port = default_remote_port
85
+ remote_port = _validate_port(
86
+ raw_remote_port,
87
+ "ssh_tunnel.remote_port",
88
+ allow_zero=False,
89
+ )
90
+ local_host = _validate_host(
91
+ value.get("local_host", "127.0.0.1"),
92
+ "ssh_tunnel.local_host",
93
+ )
94
+ if local_host not in LOOPBACK_HOSTS:
95
+ raise SSHTunnelError(
96
+ "ssh_tunnel.local_host must be a loopback address "
97
+ f"({', '.join(sorted(LOOPBACK_HOSTS))})"
98
+ )
99
+ local_port = _validate_port(
100
+ value.get("local_port", 0),
101
+ "ssh_tunnel.local_port",
102
+ allow_zero=True,
103
+ )
104
+ return cls(
105
+ remote_host=remote_host,
106
+ remote_port=remote_port,
107
+ local_host=local_host,
108
+ local_port=local_port,
109
+ )
110
+
111
+
112
+ def _validate_host(value: Any, name: str) -> str:
113
+ if not isinstance(value, str):
114
+ raise SSHTunnelError(f"{name} must be a hostname or IP address")
115
+ host = value.strip()
116
+ if not host:
117
+ raise SSHTunnelError(f"{name} must not be empty")
118
+ if len(host) > 253 or any(char.isspace() or ord(char) < 32 for char in host):
119
+ raise SSHTunnelError(f"{name} contains an invalid character")
120
+ return host
121
+
122
+
123
+ def _validate_port(value: Any, name: str, *, allow_zero: bool) -> int:
124
+ if isinstance(value, bool):
125
+ raise SSHTunnelError(f"{name} must be an integer between 0 and {MAX_PORT}")
126
+ try:
127
+ port = int(value)
128
+ except (TypeError, ValueError) as e:
129
+ raise SSHTunnelError(
130
+ f"{name} must be an integer between 0 and {MAX_PORT}"
131
+ ) from e
132
+ minimum = 0 if allow_zero else 1
133
+ if not minimum <= port <= MAX_PORT:
134
+ raise SSHTunnelError(f"{name} must be between {minimum} and {MAX_PORT}")
135
+ return port
136
+
137
+
138
+ class SSHTunnel:
139
+ """One local port forward over a dedicated SSH connection.
140
+
141
+ A dropped SSH connection closes the forwarding listener. The tunnel keeps a
142
+ monitor task that detects the close and re-opens both the connection and
143
+ the listener on the same local port, so clients that already point at the
144
+ endpoint keep working. Reconnection is retried until :meth:`close`; while
145
+ it is failing, ``down`` is true and ``last_error`` describes the failure.
146
+ """
147
+
148
+ def __init__(
149
+ self,
150
+ ssh_config: dict[str, Any],
151
+ tunnel_config: dict[str, Any],
152
+ *,
153
+ default_remote_port: int,
154
+ ):
155
+ self.ssh_config = dict(ssh_config or {})
156
+ self.config = TunnelConfig.from_config(
157
+ tunnel_config,
158
+ default_remote_port=default_remote_port,
159
+ )
160
+ self._manager: SSHManager | None = None
161
+ self._listener: asyncssh.SSHListener | None = None
162
+ self._endpoint: TunnelEndpoint | None = None
163
+ self._monitor_task: asyncio.Task | None = None
164
+ self._closed = False
165
+ self.down = False
166
+ self.last_error: str | None = None
167
+
168
+ async def _open(self, local_port: int) -> TunnelEndpoint:
169
+ """Open one SSH connection and local forward."""
170
+ manager = SSHManager({"ssh": self.ssh_config}, SecurityValidator({}))
171
+ try:
172
+ await manager.connect()
173
+ connection = manager.connection
174
+ if connection is None:
175
+ raise SSHTunnelError("SSH connection failed")
176
+ listener = await connection.forward_local_port(
177
+ self.config.local_host,
178
+ local_port,
179
+ self.config.remote_host,
180
+ self.config.remote_port,
181
+ )
182
+ except SSHTunnelError:
183
+ await manager.disconnect()
184
+ raise
185
+ except Exception as e:
186
+ await manager.disconnect()
187
+ raise SSHTunnelError(f"Could not open SSH tunnel: {e}") from e
188
+
189
+ self._manager = manager
190
+ self._listener = listener
191
+ port = listener.get_port() or local_port
192
+ self._endpoint = TunnelEndpoint(self.config.local_host, port)
193
+ logger.info(
194
+ "SSH tunnel listening on %s:%s for %s:%s",
195
+ self.config.local_host,
196
+ port,
197
+ self.config.remote_host,
198
+ self.config.remote_port,
199
+ )
200
+ return self._endpoint
201
+
202
+ async def start(self) -> TunnelEndpoint:
203
+ """Open the SSH connection and the local listener."""
204
+ self._closed = False
205
+ endpoint = await self._open(self.config.local_port)
206
+ self._start_monitor()
207
+ return endpoint
208
+
209
+ def _start_monitor(self) -> None:
210
+ """Watch the current connection and reconnect when it closes."""
211
+ manager = self._manager
212
+ connection = manager.connection if manager is not None else None
213
+ wait_closed = getattr(connection, "wait_closed", None)
214
+ if connection is None or wait_closed is None:
215
+ # Test doubles and older asyncssh releases may not expose
216
+ # wait_closed; reconnection is best effort in that case.
217
+ return
218
+ self._monitor_task = asyncio.create_task(
219
+ self._monitor_connection(connection, wait_closed)
220
+ )
221
+
222
+ async def _monitor_connection(self, connection, wait_closed) -> None:
223
+ try:
224
+ await wait_closed()
225
+ except asyncio.CancelledError:
226
+ raise
227
+ except Exception as e: # pragma: no cover - defensive
228
+ logger.debug("SSH tunnel connection close reported an error: %s", e)
229
+
230
+ if self._closed:
231
+ return
232
+ logger.warning(
233
+ "SSH tunnel connection dropped; reconnecting to %s:%s",
234
+ self.config.remote_host,
235
+ self.config.remote_port,
236
+ )
237
+ await self._reconnect()
238
+
239
+ async def _reconnect(self) -> None:
240
+ """Re-open the forward on the previously bound local port."""
241
+ local_port = self._endpoint.port if self._endpoint else self.config.local_port
242
+ delay = 1.0
243
+
244
+ old_listener = self._listener
245
+ self._listener = None
246
+ if old_listener is not None:
247
+ old_listener.close()
248
+ try:
249
+ await old_listener.wait_closed()
250
+ except Exception as e: # pragma: no cover - best effort
251
+ logger.debug("Error while closing the dropped tunnel listener: %s", e)
252
+
253
+ old_manager = self._manager
254
+ self._manager = None
255
+ if old_manager is not None:
256
+ try:
257
+ await old_manager.disconnect()
258
+ except Exception as e: # pragma: no cover - best effort
259
+ logger.debug("Error while discarding the dropped SSH tunnel: %s", e)
260
+
261
+ while not self._closed:
262
+ try:
263
+ await self._open(local_port)
264
+ except Exception as e:
265
+ self.down = True
266
+ self.last_error = str(e)
267
+ logger.error("SSH tunnel is down (%s); retrying in %.0fs", e, delay)
268
+ try:
269
+ await asyncio.sleep(delay)
270
+ except asyncio.CancelledError:
271
+ raise
272
+ delay = min(delay * 2, 30.0)
273
+ continue
274
+
275
+ self.down = False
276
+ self.last_error = None
277
+ self._start_monitor()
278
+ return
279
+
280
+ async def close(self) -> None:
281
+ """Close the listener and the SSH connection (idempotent)."""
282
+ self._closed = True
283
+ monitor = self._monitor_task
284
+ self._monitor_task = None
285
+ if monitor is not None:
286
+ monitor.cancel()
287
+ try:
288
+ await monitor
289
+ except asyncio.CancelledError:
290
+ pass
291
+ except Exception as e: # pragma: no cover - best effort
292
+ logger.debug("Error while stopping the SSH tunnel monitor: %s", e)
293
+
294
+ listener = self._listener
295
+ self._listener = None
296
+ if listener is not None:
297
+ listener.close()
298
+ try:
299
+ await listener.wait_closed()
300
+ except Exception as e: # pragma: no cover - best effort
301
+ logger.debug("Error while closing SSH tunnel listener: %s", e)
302
+
303
+ manager = self._manager
304
+ self._manager = None
305
+ if manager is not None:
306
+ await manager.disconnect()
307
+
308
+ async def __aenter__(self) -> TunnelEndpoint:
309
+ return await self.start()
310
+
311
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
312
+ await self.close()
313
+
314
+
315
+ @dataclass
316
+ class ActiveTunnel:
317
+ """A tunnel opened from configuration and the endpoint it replaced."""
318
+
319
+ section: str
320
+ tunnel: SSHTunnel
321
+ endpoint: TunnelEndpoint
322
+
323
+
324
+ async def open_metric_tunnels(config: dict[str, Any]) -> list[ActiveTunnel]:
325
+ """Open tunnels for enabled ``influxdb``/``prometheus`` sections.
326
+
327
+ The section's ``host``/``port`` are rewritten to the local tunnel endpoint
328
+ so the existing HTTP clients can use them unchanged. When any tunnel
329
+ fails, all previously opened tunnels are closed before the error is
330
+ re-raised.
331
+ """
332
+ ssh_config = config.get("ssh", {}) or {}
333
+ pending: list[tuple[str, dict[str, Any], dict[str, Any], int]] = []
334
+
335
+ # Validate every configured tunnel before opening any of them so a typo
336
+ # cannot leave an earlier tunnel running.
337
+ for section, default_port in METRIC_SECTIONS:
338
+ section_config = config.get(section)
339
+ if not isinstance(section_config, dict):
340
+ continue
341
+ if not section_config.get("enabled", False):
342
+ continue
343
+ tunnel_config = section_config.get("ssh_tunnel")
344
+ if tunnel_config is None or tunnel_config is False:
345
+ continue
346
+ if not isinstance(tunnel_config, dict):
347
+ raise SSHTunnelError(
348
+ f"{section}.ssh_tunnel must be a mapping with enabled: true"
349
+ )
350
+ if not tunnel_config.get("enabled"):
351
+ continue
352
+ pending.append((section, section_config, tunnel_config, default_port))
353
+
354
+ active: list[ActiveTunnel] = []
355
+ try:
356
+ for section, section_config, tunnel_config, default_port in pending:
357
+ tunnel = SSHTunnel(
358
+ ssh_config,
359
+ tunnel_config,
360
+ default_remote_port=section_config.get("port") or default_port,
361
+ )
362
+ endpoint = await tunnel.start()
363
+ section_config["host"] = endpoint.host
364
+ section_config["port"] = endpoint.port
365
+ active.append(ActiveTunnel(section, tunnel, endpoint))
366
+ except Exception:
367
+ await close_metric_tunnels(active)
368
+ raise
369
+
370
+ return active
371
+
372
+
373
+ async def close_metric_tunnels(tunnels: list[ActiveTunnel]) -> None:
374
+ """Close every tunnel opened by :func:`open_metric_tunnels`."""
375
+ while tunnels:
376
+ active = tunnels.pop()
377
+ try:
378
+ await active.tunnel.close()
379
+ except Exception as e: # pragma: no cover - best effort
380
+ logger.debug("Error while closing %s tunnel: %s", active.section, e)
@@ -0,0 +1,46 @@
1
+ Metadata-Version: 2.4
2
+ Name: server-management-lib
3
+ Version: 0.2.0
4
+ Summary: Shared security, SSH/local execution, configuration and HTTP clients for the Server MCP suite.
5
+ Author-email: Alessio Elmi <alessio.elmi@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/elmuz/server-mcp
8
+ Project-URL: Repository, https://github.com/elmuz/server-mcp
9
+ Project-URL: Documentation, https://github.com/elmuz/server-mcp/tree/main/docs
10
+ Project-URL: Issues, https://github.com/elmuz/server-mcp/issues
11
+ Project-URL: Changelog, https://github.com/elmuz/server-mcp/blob/main/CHANGELOG.md
12
+ Keywords: mcp,model-context-protocol,linux,server-management,docker,homelab,self-hosting
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: System Administrators
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: System :: Systems Administration
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.11
25
+ Description-Content-Type: text/markdown
26
+ Requires-Dist: asyncssh<3,>=2.22
27
+ Requires-Dist: pyyaml>=6.0
28
+ Requires-Dist: aiohttp>=3.9.0
29
+ Requires-Dist: python-dotenv>=1.0.0
30
+ Requires-Dist: sqlparse>=0.5
31
+ Requires-Dist: pydantic>=2.12
32
+
33
+ # server-management-lib
34
+
35
+ Shared core for the Server MCP suite: security validation, SSH and local executors,
36
+ configuration resolution, output limits, audit logging, and read-only InfluxDB/Prometheus
37
+ clients.
38
+
39
+ Part of the [Server MCP suite](../../README.md).
40
+
41
+ - [Architecture](../../docs/architecture.md)
42
+ - [Security model](../../docs/security-model.md)
43
+ - [Configuration](../../docs/configuration.md)
44
+
45
+ The two MCP servers depend on this package through the uv workspace. It has no MCP
46
+ dependency and can be used by other server-management tools.
@@ -0,0 +1,21 @@
1
+ server_management_lib/__init__.py,sha256=CC39UORohKcFBrIAf9RVMrMmE9_xx46V5kZF3ByUcAM,2883
2
+ server_management_lib/audit.py,sha256=hcMGLW04aHdeqhC_fX5obbxAUd2FIaCsJgF6dvkJzj4,2791
3
+ server_management_lib/base.py,sha256=moZdJKvaVtlLlNxguDjwq_XCv2DWS7r2djCfnFyQNak,1510
4
+ server_management_lib/config.py,sha256=rykN_Hx5VtX4ssrJHd2DI-7H0CYbfv5G4Tq1Xyy_2CE,13185
5
+ server_management_lib/executor.py,sha256=Npebfva17WK9lvNgGNfpIsPh0npellQH-USOb5Xl0sc,1539
6
+ server_management_lib/http_clients.py,sha256=nXmTgKGWYVdpcUg45oafwFZdHc_3PN9ll4GQln4vq40,24102
7
+ server_management_lib/local_executor.py,sha256=WzEgYQBnZQ5FB8TVb0hwWsjy_7uUYG6VbliNX0GWBdM,7037
8
+ server_management_lib/operations.py,sha256=9u51YxawFeEkWMd47WC8NAePkCNx4EzrMWpl06gr30E,1017
9
+ server_management_lib/output.py,sha256=Z94OzAg5bkeCX70Sm7Ijvie5rXqej-moH7C7aB9iW4s,1980
10
+ server_management_lib/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ server_management_lib/rate_limit.py,sha256=K2UJt1L1_kfd92b1UjnIhoR9SrNK2tr2zUilm1kxsTI,3826
12
+ server_management_lib/redaction.py,sha256=sEsT_Nw537JotKU-UsG8Jre1n0YERh5ZIm9mHcofFU8,6390
13
+ server_management_lib/responses.py,sha256=XnFyTRSQ_pNQx8dx8jIDVUqt9Qh9LOldKZuQFzG3gpE,4481
14
+ server_management_lib/security.py,sha256=F969RKY2oif3bp5JQLdaPDMS2bBS2-5a-h_lMR04iQ8,56146
15
+ server_management_lib/ssh_manager.py,sha256=6-2nNGtzDr18lJa0dw8SCW9cDq8Jzua810rBPNyG18w,16339
16
+ server_management_lib/transport.py,sha256=vmfRqdSZc3EuCJEnGm-D16HjGXUr1XPCaN_haVFCGlA,4187
17
+ server_management_lib/tunnel.py,sha256=tmhTrgYWjKfXi--SXc6pJp4Y47EyFEef1ZfFWSSuvHs,13290
18
+ server_management_lib-0.2.0.dist-info/METADATA,sha256=JWOOz6TpSjnRej5lGwG7BMkzo15j2pmhTTIFDWD9Ifk,1989
19
+ server_management_lib-0.2.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
20
+ server_management_lib-0.2.0.dist-info/top_level.txt,sha256=03qQ0Fx0rrnN6WBTUgRUmoZkiv1oMn6oStik9gl12tk,22
21
+ server_management_lib-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ server_management_lib