susops 3.0.0rc3.dev1__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.
susops/core/socat.py ADDED
@@ -0,0 +1,200 @@
1
+ """UDP port forwarding via socat over SSH ControlMaster.
2
+
3
+ Architecture:
4
+ - Local UDP forward: socat EXEC approach — no SSH port forward slave needed.
5
+ One process: local socat pipes each UDP conversation through ControlMaster
6
+ to a remote socat instance (spawned per conversation via EXEC).
7
+ - Remote UDP forward: SSH -R slave + remote socat + local socat.
8
+ Three processes: an intermediate TCP port bridges the two socat instances.
9
+
10
+ Error handling: FileNotFoundError when socat is not installed locally;
11
+ subprocess exit errors when the remote host blocks command execution or
12
+ lacks socat — both surface through the process manager log file.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import shlex
17
+ from pathlib import Path
18
+
19
+ from susops.core.config import Connection, PortForward
20
+ from susops.core.ports import get_random_free_port
21
+ from susops.core.process import ProcessManager
22
+ from susops.core.ssh import socket_path
23
+
24
+ __all__ = [
25
+ "UDP_PROCESS_PREFIX",
26
+ "start_udp_forward",
27
+ "stop_udp_forward",
28
+ "stop_all_udp_forwards_for_connection",
29
+ "is_udp_forward_running",
30
+ ]
31
+
32
+ UDP_PROCESS_PREFIX = "susops-udp"
33
+
34
+
35
+ def _fw_tag(fw: PortForward, direction: str) -> str:
36
+ """Return the identifying tag for a forward (tag field or direction-port)."""
37
+ return fw.tag or f"{direction}-{fw.src_port}"
38
+
39
+
40
+ def _udp_process_name(conn_tag: str, fw_tag: str, suffix: str) -> str:
41
+ """Build a process name like susops-udp-<conn>-<fw_tag>-<suffix>."""
42
+ return f"{UDP_PROCESS_PREFIX}-{conn_tag}-{fw_tag}-{suffix}"
43
+
44
+
45
+ def start_udp_forward(
46
+ conn: Connection,
47
+ fw: PortForward,
48
+ direction: str,
49
+ process_mgr: ProcessManager,
50
+ workspace: Path,
51
+ ) -> None:
52
+ """Start socat process(es) for a UDP port forward.
53
+
54
+ direction="local": one local socat process using EXEC through ControlMaster.
55
+ direction="remote": SSH -R slave + remote socat (via SSH) + local socat.
56
+
57
+ Raises FileNotFoundError if socat is not installed locally.
58
+ Remote errors (socat missing, shell access blocked) surface as immediate
59
+ process exit — check the log file at workspace/logs/<name>.log.
60
+ """
61
+ sock = socket_path(conn.tag, workspace)
62
+ tag = _fw_tag(fw, direction)
63
+ log_dir = workspace / "logs"
64
+ log_dir.mkdir(parents=True, exist_ok=True)
65
+
66
+ if direction == "local":
67
+ _start_local_udp(conn, fw, sock, tag, process_mgr, log_dir)
68
+ else:
69
+ _start_remote_udp(conn, fw, sock, tag, process_mgr, log_dir)
70
+
71
+
72
+ def _start_local_udp(
73
+ conn: Connection,
74
+ fw: PortForward,
75
+ sock: Path,
76
+ tag: str,
77
+ process_mgr: ProcessManager,
78
+ log_dir: Path,
79
+ ) -> None:
80
+ """Local UDP forward: socat EXEC piped through SSH ControlMaster.
81
+
82
+ Each UDP conversation forks one SSH session (multiplexed via ControlMaster).
83
+ -T15 closes idle forked children after 15 seconds.
84
+ """
85
+ name = _udp_process_name(conn.tag, tag, "lsocat")
86
+ ssh_exec = (
87
+ f"ssh -o ControlPath={shlex.quote(str(sock))} -T {conn.ssh_host} "
88
+ f"socat - UDP4-SENDTO:{fw.dst_addr}:{fw.dst_port}"
89
+ )
90
+ cmd = [
91
+ "socat",
92
+ "-T15",
93
+ f"UDP4-RECVFROM:{fw.src_port},reuseaddr,fork",
94
+ f"EXEC:'{ssh_exec}'",
95
+ ]
96
+ log_file = log_dir / f"{name}.log"
97
+ with open(log_file, "a") as log:
98
+ process_mgr.start(name, cmd, stdout=log, stderr=log)
99
+
100
+
101
+ def _start_remote_udp(
102
+ conn: Connection,
103
+ fw: PortForward,
104
+ sock: Path,
105
+ tag: str,
106
+ process_mgr: ProcessManager,
107
+ log_dir: Path,
108
+ ) -> None:
109
+ """Remote UDP forward: local socat + SSH -R slave + remote socat (via SSH).
110
+
111
+ Startup order matters:
112
+ 1. lsocat — bind local:intermediate so it is ready when rsocat connects.
113
+ 2. SSH -R — request remote SSH server to bind remote:intermediate.
114
+ 3. rsocat — connect to remote:intermediate (with retry for binding lag).
115
+
116
+ The rsocat shell command retries up to 5 times (1 s apart) because the
117
+ SSH server may not finish binding the remote port before rsocat executes.
118
+ """
119
+ intermediate = get_random_free_port()
120
+
121
+ # 1. Local socat: TCP intermediate → UDP local service (start first)
122
+ lsocat_name = _udp_process_name(conn.tag, tag, "lsocat")
123
+ lsocat_cmd = [
124
+ "socat",
125
+ f"TCP4-LISTEN:{intermediate},reuseaddr,fork",
126
+ f"UDP4-SENDTO:{fw.dst_addr}:{fw.dst_port}",
127
+ ]
128
+ log_file = log_dir / f"{lsocat_name}.log"
129
+ with open(log_file, "a") as log:
130
+ process_mgr.start(lsocat_name, lsocat_cmd, stdout=log, stderr=log)
131
+
132
+ # 2. SSH -R slave: bind intermediate port on remote, forward to local
133
+ ssh_name = _udp_process_name(conn.tag, tag, "ssh")
134
+ ssh_cmd = [
135
+ "ssh", "-N", "-T",
136
+ "-o", f"ControlPath={sock}",
137
+ "-R", f"{intermediate}:localhost:{intermediate}",
138
+ conn.ssh_host,
139
+ ]
140
+ log_file = log_dir / f"{ssh_name}.log"
141
+ with open(log_file, "a") as log:
142
+ process_mgr.start(ssh_name, ssh_cmd, stdout=log, stderr=log)
143
+
144
+ # 3. Remote socat: UDP → TCP intermediate (retry loop for port binding lag)
145
+ rsocat_name = _udp_process_name(conn.tag, tag, "rsocat")
146
+ rsocat_shell = (
147
+ f"for _i in 1 2 3 4 5; do "
148
+ f"socat -T15 UDP4-RECVFROM:{fw.src_port},reuseaddr,fork "
149
+ f"TCP4:localhost:{intermediate} && break; sleep 1; done"
150
+ )
151
+ rsocat_cmd = [
152
+ "ssh", "-T",
153
+ "-o", f"ControlPath={sock}",
154
+ conn.ssh_host,
155
+ rsocat_shell,
156
+ ]
157
+ log_file = log_dir / f"{rsocat_name}.log"
158
+ with open(log_file, "a") as log:
159
+ process_mgr.start(rsocat_name, rsocat_cmd, stdout=log, stderr=log)
160
+
161
+
162
+ def stop_udp_forward(
163
+ conn_tag: str,
164
+ fw_tag: str,
165
+ process_mgr: ProcessManager,
166
+ ) -> bool:
167
+ """Stop all socat/SSH processes for a single UDP forward.
168
+
169
+ Returns True if at least one process was stopped.
170
+ """
171
+ prefix = f"{UDP_PROCESS_PREFIX}-{conn_tag}-{fw_tag}-"
172
+ stopped_any = False
173
+ for name in list(process_mgr.status_all().keys()):
174
+ if name.startswith(prefix):
175
+ if process_mgr.stop(name):
176
+ stopped_any = True
177
+ return stopped_any
178
+
179
+
180
+ def is_udp_forward_running(
181
+ conn_tag: str,
182
+ fw: PortForward,
183
+ direction: str,
184
+ process_mgr: ProcessManager,
185
+ ) -> bool:
186
+ """Return True if the main socat process (lsocat) for this UDP forward is alive."""
187
+ tag = _fw_tag(fw, direction)
188
+ name = _udp_process_name(conn_tag, tag, "lsocat")
189
+ return process_mgr.is_running(name)
190
+
191
+
192
+ def stop_all_udp_forwards_for_connection(
193
+ conn_tag: str,
194
+ process_mgr: ProcessManager,
195
+ ) -> None:
196
+ """Stop all UDP socat processes for every forward on a connection."""
197
+ prefix = f"{UDP_PROCESS_PREFIX}-{conn_tag}-"
198
+ for name in list(process_mgr.status_all().keys()):
199
+ if name.startswith(prefix):
200
+ process_mgr.stop(name)
susops/core/ssh.py ADDED
@@ -0,0 +1,330 @@
1
+ """SSH tunnel management using ssh + ProcessManager.
2
+
3
+ Architecture:
4
+ - One ControlMaster process per connection (manages the multiplexed socket).
5
+ - Each port forward (local, remote, share) is its own lightweight slave process
6
+ that attaches to the master via the Unix socket.
7
+ - Forwards can be added/removed without restarting the master.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import subprocess
12
+ import time
13
+ from pathlib import Path
14
+
15
+ from susops.core.config import Connection, PortForward
16
+ from susops.core.process import ProcessManager
17
+
18
+ __all__ = [
19
+ "build_ssh_cmd", # legacy alias — kept for CLI compat
20
+ "build_master_cmd",
21
+ "start_tunnel", # starts master (forwards bundled in cmd)
22
+ "start_master",
23
+ "start_forward", # ssh -O forward — registers live forward via socket
24
+ "cancel_forward", # ssh -O cancel — releases master-held port
25
+ "stop_tunnel", # stops master (and all its forwards)
26
+ "is_tunnel_running",
27
+ "is_socket_alive",
28
+ "find_master_pid",
29
+ "socket_path",
30
+ "test_ssh_connectivity",
31
+ "SSH_PROCESS_PREFIX",
32
+ "FWD_PROCESS_PREFIX",
33
+ ]
34
+
35
+ SSH_PROCESS_PREFIX = "susops-ssh"
36
+ FWD_PROCESS_PREFIX = "susops-fwd"
37
+ _SOCKET_DIR = "sockets"
38
+
39
+
40
+ def _master_name(tag: str) -> str:
41
+ return f"{SSH_PROCESS_PREFIX}-{tag}"
42
+
43
+
44
+ def _forward_name(conn_tag: str, fw_tag: str) -> str:
45
+ return f"{FWD_PROCESS_PREFIX}-{conn_tag}-{fw_tag}"
46
+
47
+
48
+ def socket_path(tag: str, workspace: Path) -> Path:
49
+ """Return the ControlMaster Unix socket path for a connection."""
50
+ return workspace / _SOCKET_DIR / f"{tag}.sock"
51
+
52
+
53
+ def build_master_cmd(conn: Connection, sock: Path) -> list[str]:
54
+ """Build the ControlMaster command.
55
+
56
+ Intentionally contains no -L/-R forward flags — forwards are registered
57
+ live via ``start_forward`` (``ssh -O forward``) after the master socket is
58
+ ready. This keeps process arguments minimal and avoids exposing forward
59
+ destinations in ``ps aux``.
60
+
61
+ ControlPersist is intentionally omitted: with -N the process stays in the
62
+ foreground with a stable, trackable PID. Reconnection is handled by the
63
+ Python-side _ReconnectMonitor which restarts the master on dropout.
64
+ """
65
+ return [
66
+ "ssh",
67
+ "-N", "-T",
68
+ "-D", str(conn.socks_proxy_port),
69
+ "-o", "ControlMaster=yes",
70
+ "-o", f"ControlPath={sock}",
71
+ "-o", "ServerAliveInterval=5",
72
+ "-o", "ServerAliveCountMax=3",
73
+ conn.ssh_host,
74
+ ]
75
+
76
+
77
+ # Legacy alias — keeps existing callers (CLI, old tests) working.
78
+ def build_ssh_cmd(conn: Connection) -> list[str]:
79
+ """Legacy: build the monolithic SSH command (no ControlMaster).
80
+
81
+ Kept for backwards compatibility. New code should use build_master_cmd.
82
+ """
83
+ cmd: list[str] = ["ssh"]
84
+
85
+ cmd += [
86
+ "-N", "-T",
87
+ "-D", str(conn.socks_proxy_port),
88
+ "-o", "ExitOnForwardFailure=yes",
89
+ "-o", "ServerAliveInterval=30",
90
+ "-o", "ServerAliveCountMax=3",
91
+ ]
92
+
93
+ for fw in conn.forwards.local:
94
+ cmd += ["-L", f"{fw.src_addr}:{fw.src_port}:{fw.dst_addr}:{fw.dst_port}"]
95
+
96
+ for fw in conn.forwards.remote:
97
+ cmd += ["-R", f"{fw.src_addr}:{fw.src_port}:{fw.dst_addr}:{fw.dst_port}"]
98
+
99
+ cmd.append(conn.ssh_host)
100
+ return cmd
101
+
102
+
103
+ def start_master(
104
+ conn: Connection,
105
+ process_mgr: ProcessManager,
106
+ workspace: Path,
107
+ ) -> int:
108
+ """Start the ControlMaster SSH process for a connection.
109
+
110
+ Returns the PID of the started process.
111
+ Raises ValueError if socks_proxy_port is 0.
112
+ Raises RuntimeError if the process fails to start.
113
+ """
114
+ if conn.socks_proxy_port == 0:
115
+ raise ValueError(
116
+ f"Connection '{conn.tag}' has no SOCKS port assigned. "
117
+ "Assign one before starting."
118
+ )
119
+
120
+ sock = socket_path(conn.tag, workspace)
121
+ sock.parent.mkdir(parents=True, exist_ok=True)
122
+
123
+ # Remove stale socket so the new master can take ownership.
124
+ # A live master is never reached here (facade checks is_tunnel_running +
125
+ # is_socket_alive before calling start_master), so a socket at this
126
+ # point is always left over from a dead master.
127
+ if sock.exists():
128
+ sock.unlink()
129
+
130
+ name = _master_name(conn.tag)
131
+ cmd = build_master_cmd(conn, sock)
132
+
133
+ log_file = workspace / "logs" / f"{name}.log"
134
+ log_file.parent.mkdir(parents=True, exist_ok=True)
135
+
136
+ with open(log_file, "a") as log:
137
+ pid = process_mgr.start(name, cmd, stdout=log, stderr=log)
138
+
139
+ return pid
140
+
141
+
142
+ def start_forward(
143
+ conn: Connection,
144
+ fw: PortForward,
145
+ direction: str,
146
+ workspace: Path,
147
+ ) -> None:
148
+ """Register a TCP port forward with the running ControlMaster via ``ssh -O forward``.
149
+
150
+ Sends the forward request through the Unix socket — no SSH handshake,
151
+ no new TCP connection. The master holds the port until ``cancel_forward``
152
+ is called or the master exits.
153
+
154
+ Raises RuntimeError if the socket is not ready or the forward fails.
155
+ """
156
+ sock = socket_path(conn.tag, workspace)
157
+
158
+ for _ in range(100):
159
+ if sock.exists():
160
+ break
161
+ time.sleep(0.1)
162
+ else:
163
+ raise RuntimeError(f"ControlMaster socket {sock} not ready after 10 s")
164
+
165
+ flag = "-L" if direction == "local" else "-R"
166
+ fwd_spec = f"{fw.src_addr}:{fw.src_port}:{fw.dst_addr}:{fw.dst_port}"
167
+ result = subprocess.run(
168
+ ["ssh", "-O", "forward", "-o", f"ControlPath={sock}", flag, fwd_spec, conn.ssh_host],
169
+ capture_output=True,
170
+ timeout=5,
171
+ )
172
+ if result.returncode != 0:
173
+ stderr = result.stderr.decode(errors="replace").strip()
174
+ raise RuntimeError(f"ssh -O forward failed: {stderr}")
175
+
176
+
177
+ def cancel_forward(
178
+ conn: Connection,
179
+ fw: PortForward,
180
+ direction: str,
181
+ workspace: Path,
182
+ ) -> None:
183
+ """Release a master-held TCP port forward via ``ssh -O cancel``.
184
+
185
+ Best-effort: silently ignores errors (master may already be gone).
186
+ """
187
+ sock = socket_path(conn.tag, workspace)
188
+ if not sock.exists():
189
+ return
190
+ flag = "-L" if direction == "local" else "-R"
191
+ fwd_spec = f"{fw.src_addr}:{fw.src_port}:{fw.dst_addr}:{fw.dst_port}"
192
+ try:
193
+ subprocess.run(
194
+ ["ssh", "-O", "cancel", "-o", f"ControlPath={sock}", flag, fwd_spec, conn.ssh_host],
195
+ capture_output=True,
196
+ timeout=5,
197
+ )
198
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
199
+ pass
200
+
201
+
202
+ def start_tunnel(
203
+ conn: Connection,
204
+ process_mgr: ProcessManager,
205
+ workspace: Path,
206
+ ) -> int:
207
+ """Start the ControlMaster for a connection.
208
+
209
+ All enabled TCP forwards are bundled in the master command and python
210
+ restarts them automatically on reconnect. Returns the PID of the master.
211
+ """
212
+ return start_master(conn, process_mgr, workspace)
213
+
214
+
215
+ def stop_tunnel(
216
+ tag: str,
217
+ process_mgr: ProcessManager,
218
+ workspace: Path | None = None,
219
+ ssh_host: str | None = None,
220
+ ) -> bool:
221
+ """Stop the ControlMaster for a connection.
222
+
223
+ Sends SIGTERM to the ssh master process via ProcessManager, then sends
224
+ ``ssh -O exit`` through the socket for a clean shutdown. workspace and
225
+ ssh_host are both required for the -O exit step; if either is absent that
226
+ step is skipped.
227
+
228
+ Returns True if the master was stopped, False if it wasn't running.
229
+ """
230
+ # Clean up any lingering forward slave PIDs (no-op with Approach B but safe
231
+ # to keep for processes left over from older susops versions).
232
+ prefix = f"{FWD_PROCESS_PREFIX}-{tag}-"
233
+ for name in list(process_mgr.status_all().keys()):
234
+ if name.startswith(prefix):
235
+ process_mgr.stop(name)
236
+
237
+ stopped = process_mgr.stop(_master_name(tag))
238
+
239
+ # Best-effort clean shutdown via the ControlMaster socket.
240
+ if workspace is not None and ssh_host is not None:
241
+ sock = socket_path(tag, workspace)
242
+ if sock.exists():
243
+ try:
244
+ subprocess.run(
245
+ ["ssh", "-O", "exit", "-o", f"ControlPath={sock}", ssh_host],
246
+ capture_output=True,
247
+ timeout=3,
248
+ )
249
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
250
+ pass
251
+
252
+ return stopped
253
+
254
+
255
+ def is_tunnel_running(tag: str, process_mgr: ProcessManager) -> bool:
256
+ """Return True if the ControlMaster SSH process for tag is currently running."""
257
+ return process_mgr.is_running(_master_name(tag))
258
+
259
+
260
+ def find_master_pid(tag: str, workspace: Path) -> int | None:
261
+ """Find the PID of the ControlMaster by scanning /proc cmdline (Linux only).
262
+
263
+ Used to recover the PID when the PID file is stale but the socket is alive.
264
+ Returns None if the process cannot be found or /proc is unavailable.
265
+ """
266
+ sock_str = str(socket_path(tag, workspace))
267
+ proc = Path("/proc")
268
+ if not proc.exists():
269
+ return None
270
+ for pid_dir in proc.iterdir():
271
+ if not pid_dir.name.isdigit():
272
+ continue
273
+ try:
274
+ cmdline = (pid_dir / "cmdline").read_bytes().replace(b"\x00", b" ").decode(errors="replace")
275
+ if "ControlMaster=yes" in cmdline and sock_str in cmdline:
276
+ return int(pid_dir.name)
277
+ except OSError:
278
+ continue
279
+ return None
280
+
281
+
282
+ def is_socket_alive(tag: str, workspace: Path) -> bool:
283
+ """Return True if the ControlMaster socket is responsive.
284
+
285
+ Uses `ssh -O check` to verify the master is healthy.
286
+ """
287
+ sock = socket_path(tag, workspace)
288
+ if not sock.exists():
289
+ return False
290
+ try:
291
+ result = subprocess.run(
292
+ ["ssh", "-O", "check", "-o", f"ControlPath={sock}", "placeholder"],
293
+ capture_output=True,
294
+ timeout=3,
295
+ )
296
+ return result.returncode == 0
297
+ except (subprocess.TimeoutExpired, FileNotFoundError):
298
+ return False
299
+
300
+
301
+ def test_ssh_connectivity(ssh_host: str, timeout: int = 5) -> bool:
302
+ """Test SSH connectivity to a host without establishing a full tunnel.
303
+
304
+ Uses ssh with BatchMode=yes and a short timeout. Returns True if the
305
+ host is reachable (even if auth fails — we just need network reachability).
306
+
307
+ Note: A return code of 255 means connection failed. Code 1 means auth
308
+ failed (host is reachable but key not accepted). We treat both 0 and 1
309
+ as "reachable" since the SSH port is open.
310
+ """
311
+ cmd = [
312
+ "ssh",
313
+ "-q",
314
+ "-o", "BatchMode=yes",
315
+ "-o", f"ConnectTimeout={timeout}",
316
+ "-o", "StrictHostKeyChecking=no",
317
+ "-T",
318
+ ssh_host,
319
+ "true",
320
+ ]
321
+ try:
322
+ result = subprocess.run(
323
+ cmd,
324
+ capture_output=True,
325
+ timeout=timeout + 2,
326
+ )
327
+ # rc=0: success, rc=1: connected but auth failed, rc=255: connection error
328
+ return result.returncode != 255
329
+ except (subprocess.TimeoutExpired, FileNotFoundError):
330
+ return False
@@ -0,0 +1,40 @@
1
+ """SSH config parser for hostname autocompletion."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from pathlib import Path
6
+
7
+ __all__ = ["get_ssh_hosts"]
8
+
9
+ _SSH_CONFIG = Path.home() / ".ssh" / "config"
10
+
11
+
12
+ def get_ssh_hosts(ssh_config_path: Path = _SSH_CONFIG) -> list[str]:
13
+ """Parse ~/.ssh/config and return all non-wildcard Host entries.
14
+
15
+ Returns a sorted list of hostnames suitable for autocomplete.
16
+ Skips wildcard entries like '*' or '*.example.com'.
17
+ """
18
+ if not ssh_config_path.exists():
19
+ return []
20
+
21
+ hosts: list[str] = []
22
+ try:
23
+ content = ssh_config_path.read_text(errors="replace")
24
+ except OSError:
25
+ return []
26
+
27
+ for line in content.splitlines():
28
+ line = line.strip()
29
+ # Match "Host <name>" lines (case-insensitive)
30
+ match = re.match(r'^[Hh]ost\s+(.+)$', line)
31
+ if not match:
32
+ continue
33
+ # A Host line can have multiple space-separated entries
34
+ for entry in match.group(1).split():
35
+ # Skip wildcards
36
+ if '*' in entry or '?' in entry:
37
+ continue
38
+ hosts.append(entry)
39
+
40
+ return sorted(set(hosts))