sshmd 0.1.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.
- sshm/__init__.py +3 -0
- sshm/__main__.py +4 -0
- sshm/autostart.py +163 -0
- sshm/cli.py +740 -0
- sshm/completions/sshm.fish +137 -0
- sshm/config.py +344 -0
- sshm/daemon.py +289 -0
- sshm/ipc.py +249 -0
- sshm/process.py +545 -0
- sshm/procutil.py +60 -0
- sshm/protocol.py +53 -0
- sshm/py.typed +0 -0
- sshm/state.py +76 -0
- sshm/terminal.py +199 -0
- sshmd-0.1.0.dist-info/METADATA +309 -0
- sshmd-0.1.0.dist-info/RECORD +19 -0
- sshmd-0.1.0.dist-info/WHEEL +4 -0
- sshmd-0.1.0.dist-info/entry_points.txt +4 -0
- sshmd-0.1.0.dist-info/licenses/LICENSE +21 -0
sshm/cli.py
ADDED
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
"""sshm — SSH session manager CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import ClassVar
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
|
|
15
|
+
from . import protocol
|
|
16
|
+
from .ipc import DaemonNotRunning, connect_streaming, ensure_daemon, send_request
|
|
17
|
+
from .terminal import stream_bridge
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _send(cmd: str, **kwargs) -> dict:
|
|
21
|
+
try:
|
|
22
|
+
ensure_daemon()
|
|
23
|
+
resp = send_request(cmd, **kwargs)
|
|
24
|
+
except DaemonNotRunning as e:
|
|
25
|
+
click.echo(f"Error: {e}", err=True)
|
|
26
|
+
sys.exit(1)
|
|
27
|
+
|
|
28
|
+
if not resp.get("ok"):
|
|
29
|
+
click.echo(f"Error: {resp.get('error', 'Unknown error')}", err=True)
|
|
30
|
+
sys.exit(1)
|
|
31
|
+
|
|
32
|
+
return resp.get("data", {})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AliasedGroup(click.Group):
|
|
36
|
+
"""Click group with short aliases and custom help formatting."""
|
|
37
|
+
|
|
38
|
+
COMMAND_ALIASES: ClassVar[dict[str, str]] = {
|
|
39
|
+
"l": "list",
|
|
40
|
+
"c": "connect",
|
|
41
|
+
"a": "add",
|
|
42
|
+
"r": "remove",
|
|
43
|
+
"mv": "rename",
|
|
44
|
+
"e": "enable",
|
|
45
|
+
"d": "disable",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
def get_command(self, ctx, cmd_name):
|
|
49
|
+
cmd_name = self.COMMAND_ALIASES.get(cmd_name, cmd_name)
|
|
50
|
+
return super().get_command(ctx, cmd_name)
|
|
51
|
+
|
|
52
|
+
# Prefix groups with flexible add/remove syntax:
|
|
53
|
+
# sshm p a <alias> -L ... → port-add
|
|
54
|
+
# sshm p <alias> a -L ... → port-add <alias> ...
|
|
55
|
+
# sshm p a <alias> -D <port> → port-add (SOCKS proxy)
|
|
56
|
+
PREFIX_COMMANDS: ClassVar[dict[tuple[str, ...], tuple[str, str]]] = {
|
|
57
|
+
("port", "po", "p"): ("port-add", "port-remove"),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
def resolve_command(self, ctx, args):
|
|
61
|
+
if args:
|
|
62
|
+
for prefixes, (add_name, remove_name) in self.PREFIX_COMMANDS.items():
|
|
63
|
+
if args[0] not in prefixes:
|
|
64
|
+
continue
|
|
65
|
+
rest = list(args[1:])
|
|
66
|
+
for i in range(min(2, len(rest))):
|
|
67
|
+
if rest[i] in ("add", "a"):
|
|
68
|
+
rest.pop(i)
|
|
69
|
+
return super().resolve_command(ctx, [add_name] + rest)
|
|
70
|
+
elif rest[i] in ("remove", "r", "rm"):
|
|
71
|
+
rest.pop(i)
|
|
72
|
+
return super().resolve_command(ctx, [remove_name] + rest)
|
|
73
|
+
# A prefix command (port) with no add/remove action: fail with a
|
|
74
|
+
# usage hint instead of silently falling through to `connect`,
|
|
75
|
+
# which would report a baffling "Unknown alias: port".
|
|
76
|
+
if not ctx.resilient_parsing:
|
|
77
|
+
ctx.fail(
|
|
78
|
+
f"'{args[0]}' needs an action: a|add or r|remove "
|
|
79
|
+
f"(e.g. sshm port <alias> a -L 8080:localhost:80)"
|
|
80
|
+
)
|
|
81
|
+
break
|
|
82
|
+
|
|
83
|
+
# "sshm <alias>" → "sshm connect <alias>" if not a known command
|
|
84
|
+
if args and args[0] not in self.COMMAND_ALIASES and args[0] not in self.list_commands(ctx):
|
|
85
|
+
args = ["connect"] + list(args)
|
|
86
|
+
|
|
87
|
+
return super().resolve_command(ctx, args)
|
|
88
|
+
|
|
89
|
+
def format_help(self, ctx, formatter):
|
|
90
|
+
formatter.write("sshm — SSH Session Manager\n\n")
|
|
91
|
+
|
|
92
|
+
formatter.write("Usage:\n")
|
|
93
|
+
formatter.write(" sshm <alias> Connect (shorthand)\n")
|
|
94
|
+
formatter.write(" sshm <command> [args]\n\n")
|
|
95
|
+
|
|
96
|
+
formatter.write("Sessions:\n")
|
|
97
|
+
formatter.write(" l, list [alias] List hosts or sessions\n")
|
|
98
|
+
formatter.write(" c, connect <alias> [name] Attach to session\n")
|
|
99
|
+
formatter.write(" a, add <alias> user@host[:port] Add server\n")
|
|
100
|
+
formatter.write(" r, remove <alias> Remove host\n")
|
|
101
|
+
formatter.write(" mv, rename <alias> <new-alias> Rename host alias\n")
|
|
102
|
+
formatter.write("\n")
|
|
103
|
+
|
|
104
|
+
formatter.write("Forwarding:\n")
|
|
105
|
+
formatter.write(" p, port <alias> a|r -L|-R <local>:<host>:<remote> Port forward\n")
|
|
106
|
+
formatter.write(" p, port <alias> a|r -D <port> SOCKS proxy (ssh -D)\n")
|
|
107
|
+
formatter.write("\n")
|
|
108
|
+
|
|
109
|
+
formatter.write("Auto-connect:\n")
|
|
110
|
+
formatter.write(" e, enable <alias> Keep session alive\n")
|
|
111
|
+
formatter.write(" d, disable <alias> Stop auto-connect\n")
|
|
112
|
+
formatter.write("\n")
|
|
113
|
+
|
|
114
|
+
formatter.write("Import/Export:\n")
|
|
115
|
+
formatter.write(" export <file> [names] Export hosts + keys\n")
|
|
116
|
+
formatter.write(" import <file> [-o] [name|name=new] Import hosts + keys\n")
|
|
117
|
+
formatter.write(" l, list <file.json> Preview JSON file\n")
|
|
118
|
+
formatter.write("\n")
|
|
119
|
+
|
|
120
|
+
formatter.write("Daemon:\n")
|
|
121
|
+
formatter.write(" status Daemon status\n")
|
|
122
|
+
formatter.write(" stop Stop daemon\n")
|
|
123
|
+
formatter.write(" install Autostart on login\n")
|
|
124
|
+
formatter.write(" uninstall Remove autostart\n")
|
|
125
|
+
formatter.write(" completions [fish] Print shell completion script\n")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@click.group(cls=AliasedGroup)
|
|
129
|
+
def main():
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# --- list ---
|
|
134
|
+
|
|
135
|
+
@main.command("list")
|
|
136
|
+
@click.argument("alias", required=False)
|
|
137
|
+
def list_cmd(alias: str | None):
|
|
138
|
+
"""List hosts or sessions for alias, or contents of a JSON file."""
|
|
139
|
+
if alias and alias.endswith(".json"):
|
|
140
|
+
_list_json(alias)
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
data = _send(protocol.CMD_LIST, alias=alias)
|
|
144
|
+
|
|
145
|
+
if alias:
|
|
146
|
+
_print_sessions(alias, data)
|
|
147
|
+
else:
|
|
148
|
+
_print_hosts(data)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _print_sessions(alias: str, sessions: list[dict]) -> None:
|
|
152
|
+
if not sessions:
|
|
153
|
+
click.echo(f"No active connections for '{alias}'")
|
|
154
|
+
return
|
|
155
|
+
click.echo(f"{'NAME':<20} {'PID':<8} {'STATE':<12} {'UPTIME':<12} {'FORWARDS'}")
|
|
156
|
+
click.echo("-" * 70)
|
|
157
|
+
for s in sessions:
|
|
158
|
+
uptime = _format_uptime(s.get("uptime", 0))
|
|
159
|
+
fwds = ", ".join(s.get("port_forwards", []))
|
|
160
|
+
if not s.get("alive", False):
|
|
161
|
+
state, icon = "dead", "○"
|
|
162
|
+
elif s.get("attached", False):
|
|
163
|
+
state, icon = "attached", "◆"
|
|
164
|
+
else:
|
|
165
|
+
state, icon = "ready", "●"
|
|
166
|
+
click.echo(f"{icon} {s['name']:<18} {s.get('pid', '-'):<8} {state:<12} {uptime:<12} {fwds}")
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _print_hosts(hosts: list[dict]) -> None:
|
|
170
|
+
if not hosts:
|
|
171
|
+
click.echo("No hosts configured. Use 'sshm add' to add one.")
|
|
172
|
+
return
|
|
173
|
+
click.echo(f"{'ALIAS':<16} {'HOST':<20} {'USER':<12} {'SESS':<6} {'ENABLED':<8} {'FORWARDS'}")
|
|
174
|
+
click.echo("-" * 80)
|
|
175
|
+
for e in hosts:
|
|
176
|
+
fwds = ", ".join(e.get("port_forwards", []))
|
|
177
|
+
enabled = "yes" if e.get("enabled") else "-"
|
|
178
|
+
host_port = e["hostname"]
|
|
179
|
+
if e.get("port", 22) != 22:
|
|
180
|
+
host_port += f":{e['port']}"
|
|
181
|
+
conns = e.get("connections", 0)
|
|
182
|
+
attached = e.get("attached", 0)
|
|
183
|
+
sess = f"{attached}/{conns}" if conns else "0"
|
|
184
|
+
click.echo(
|
|
185
|
+
f"{e['alias']:<16} {host_port:<20} {e.get('user', '-'):<12} "
|
|
186
|
+
f"{sess:<6} {enabled:<8} {fwds}"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _list_json(filepath: str) -> None:
|
|
191
|
+
hosts = _read_hosts_file(filepath)
|
|
192
|
+
if not hosts:
|
|
193
|
+
click.echo("No hosts in file.")
|
|
194
|
+
return
|
|
195
|
+
click.echo(f"{'ALIAS':<16} {'HOST':<20} {'USER':<12} {'PORT':<6} {'ENABLED':<8} {'FORWARDS'}")
|
|
196
|
+
click.echo("-" * 80)
|
|
197
|
+
for h in hosts:
|
|
198
|
+
fwds = ", ".join(h.get("port_forwards", []))
|
|
199
|
+
enabled = "yes" if h.get("enabled") else "-"
|
|
200
|
+
host_port = h.get("hostname", "")
|
|
201
|
+
port = h.get("port", 22)
|
|
202
|
+
if port != 22:
|
|
203
|
+
host_port += f":{port}"
|
|
204
|
+
click.echo(
|
|
205
|
+
f"{h['alias']:<16} {host_port:<20} {h.get('user', '-'):<12} "
|
|
206
|
+
f"{port:<6} {enabled:<8} {fwds}"
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _read_hosts_file(filepath: str) -> list[dict]:
|
|
211
|
+
p = Path(filepath)
|
|
212
|
+
if not p.exists():
|
|
213
|
+
click.echo(f"Error: file not found: {filepath}", err=True)
|
|
214
|
+
sys.exit(1)
|
|
215
|
+
try:
|
|
216
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
217
|
+
except (OSError, ValueError) as e:
|
|
218
|
+
click.echo(f"Error: cannot read {filepath}: {e}", err=True)
|
|
219
|
+
sys.exit(1)
|
|
220
|
+
if not isinstance(data, dict) or not isinstance(data.get("hosts"), list):
|
|
221
|
+
click.echo(f"Error: {filepath} is not a valid sshm export (no 'hosts' list)", err=True)
|
|
222
|
+
sys.exit(1)
|
|
223
|
+
# Keep only well-formed host objects that at least carry an alias.
|
|
224
|
+
return [h for h in data["hosts"] if isinstance(h, dict) and h.get("alias")]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# --- connect (attach) ---
|
|
228
|
+
|
|
229
|
+
def _terminal_size() -> tuple[int, int]:
|
|
230
|
+
"""Current (cols, rows) of the controlling terminal, with a sane fallback."""
|
|
231
|
+
try:
|
|
232
|
+
sz = os.get_terminal_size()
|
|
233
|
+
return sz.columns, sz.lines
|
|
234
|
+
except OSError:
|
|
235
|
+
return 80, 24
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@main.command("connect")
|
|
239
|
+
@click.argument("alias")
|
|
240
|
+
@click.argument("name", required=False)
|
|
241
|
+
def connect_cmd(alias: str, name: str | None):
|
|
242
|
+
"""Attach to session (or create new)."""
|
|
243
|
+
cols, rows = _terminal_size()
|
|
244
|
+
try:
|
|
245
|
+
ensure_daemon()
|
|
246
|
+
sock, resp, initial = connect_streaming(
|
|
247
|
+
protocol.CMD_ATTACH, alias=alias, name=name, cli_pid=os.getpid(),
|
|
248
|
+
cols=cols, rows=rows,
|
|
249
|
+
)
|
|
250
|
+
except DaemonNotRunning as e:
|
|
251
|
+
click.echo(f"Error: {e}", err=True)
|
|
252
|
+
sys.exit(1)
|
|
253
|
+
|
|
254
|
+
if not resp.get("ok"):
|
|
255
|
+
click.echo(f"Error: {resp.get('error', 'Unknown error')}", err=True)
|
|
256
|
+
sys.exit(1)
|
|
257
|
+
|
|
258
|
+
session_name = resp["data"]["name"]
|
|
259
|
+
click.echo(f"Attached to {session_name}")
|
|
260
|
+
|
|
261
|
+
# Forward later terminal resizes to the daemon on a separate connection (the
|
|
262
|
+
# bridge socket is a raw byte stream). Fire-and-forget so a slow/again-busy
|
|
263
|
+
# daemon never stalls the interactive session.
|
|
264
|
+
def on_resize(new_cols: int, new_rows: int) -> None:
|
|
265
|
+
def _send() -> None:
|
|
266
|
+
try:
|
|
267
|
+
send_request(
|
|
268
|
+
protocol.CMD_RESIZE, alias=alias, name=session_name,
|
|
269
|
+
cols=new_cols, rows=new_rows,
|
|
270
|
+
)
|
|
271
|
+
except Exception:
|
|
272
|
+
pass
|
|
273
|
+
threading.Thread(target=_send, daemon=True).start()
|
|
274
|
+
|
|
275
|
+
stream_bridge(sock, initial, on_resize=on_resize)
|
|
276
|
+
|
|
277
|
+
click.echo(f"\nDetached from {session_name}")
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# --- add ---
|
|
281
|
+
|
|
282
|
+
def _parse_port(port_str: str) -> int:
|
|
283
|
+
try:
|
|
284
|
+
port = int(port_str)
|
|
285
|
+
except ValueError:
|
|
286
|
+
click.echo(f"Error: invalid port '{port_str}'", err=True)
|
|
287
|
+
sys.exit(1)
|
|
288
|
+
if not 1 <= port <= 65535:
|
|
289
|
+
click.echo(f"Error: port out of range: {port}", err=True)
|
|
290
|
+
sys.exit(1)
|
|
291
|
+
return port
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _parse_target(target: str) -> tuple[str, str, int]:
|
|
295
|
+
"""Split 'user@host[:port]' into (user, hostname, port).
|
|
296
|
+
|
|
297
|
+
Supports bracketed IPv6 literals: user@[::1] or user@[::1]:2222.
|
|
298
|
+
"""
|
|
299
|
+
if "@" not in target:
|
|
300
|
+
click.echo("Error: target must be user@host[:port]", err=True)
|
|
301
|
+
sys.exit(1)
|
|
302
|
+
|
|
303
|
+
user, host_part = target.split("@", 1)
|
|
304
|
+
|
|
305
|
+
def _ok(hostname: str, port: int) -> tuple[str, str, int]:
|
|
306
|
+
if not user or not hostname:
|
|
307
|
+
click.echo(f"Error: target must be user@host[:port], got '{target}'", err=True)
|
|
308
|
+
sys.exit(1)
|
|
309
|
+
return user, hostname, port
|
|
310
|
+
|
|
311
|
+
if host_part.startswith("["): # bracketed IPv6, optional :port after the ]
|
|
312
|
+
end = host_part.find("]")
|
|
313
|
+
if end == -1:
|
|
314
|
+
click.echo("Error: unterminated '[' in IPv6 address", err=True)
|
|
315
|
+
sys.exit(1)
|
|
316
|
+
hostname = host_part[1:end]
|
|
317
|
+
rest = host_part[end + 1:]
|
|
318
|
+
if rest.startswith(":"):
|
|
319
|
+
return _ok(hostname, _parse_port(rest[1:]))
|
|
320
|
+
if rest == "":
|
|
321
|
+
return _ok(hostname, 22)
|
|
322
|
+
click.echo(f"Error: unexpected '{rest}' after IPv6 address", err=True)
|
|
323
|
+
sys.exit(1)
|
|
324
|
+
|
|
325
|
+
# A bare IPv6 literal has multiple colons and no port; everything else uses
|
|
326
|
+
# the last colon as the host/port separator.
|
|
327
|
+
if host_part.count(":") == 1:
|
|
328
|
+
hostname, port_str = host_part.rsplit(":", 1)
|
|
329
|
+
return _ok(hostname, _parse_port(port_str))
|
|
330
|
+
return _ok(host_part, 22)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _ensure_key(alias: str) -> Path:
|
|
334
|
+
"""Generate an ed25519 key for the alias if it doesn't exist yet."""
|
|
335
|
+
ssh_dir = Path.home() / ".ssh"
|
|
336
|
+
ssh_dir.mkdir(mode=0o700, exist_ok=True)
|
|
337
|
+
key_path = ssh_dir / f"sshm_{alias}"
|
|
338
|
+
|
|
339
|
+
if key_path.exists():
|
|
340
|
+
click.echo(f"Using existing key: {key_path}")
|
|
341
|
+
return key_path
|
|
342
|
+
|
|
343
|
+
click.echo(f"Generating SSH key: {key_path}")
|
|
344
|
+
result = subprocess.run(
|
|
345
|
+
["ssh-keygen", "-t", "ed25519", "-f", str(key_path), "-N", "", "-C", f"sshm_{alias}"],
|
|
346
|
+
capture_output=True, text=True,
|
|
347
|
+
)
|
|
348
|
+
if result.returncode != 0:
|
|
349
|
+
click.echo(f"ssh-keygen failed: {result.stderr}", err=True)
|
|
350
|
+
sys.exit(1)
|
|
351
|
+
return key_path
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _copy_key_to_remote(key_path: Path, user: str, hostname: str, port: int) -> None:
|
|
355
|
+
click.echo(f"Copying key to {user}@{hostname}...")
|
|
356
|
+
pub_key = key_path.with_suffix(".pub").read_text(encoding="utf-8").strip()
|
|
357
|
+
|
|
358
|
+
# Install the key idempotently and fix up everything StrictModes cares about:
|
|
359
|
+
# - tighten $HOME perms (group/other-writable home makes sshd ignore the key)
|
|
360
|
+
# - create ~/.ssh and authorized_keys with correct modes
|
|
361
|
+
# - skip the append if the key is already present (no duplicates)
|
|
362
|
+
# - the leading printf newline guards against a file with no trailing newline,
|
|
363
|
+
# which would otherwise glue our key onto the previous one
|
|
364
|
+
remote = (
|
|
365
|
+
'chmod go-w ~ 2>/dev/null; '
|
|
366
|
+
'mkdir -p ~/.ssh && chmod 700 ~/.ssh && '
|
|
367
|
+
'touch ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys && '
|
|
368
|
+
f'grep -qxF "{pub_key}" ~/.ssh/authorized_keys || '
|
|
369
|
+
f'printf \'\\n%s\\n\' "{pub_key}" >> ~/.ssh/authorized_keys'
|
|
370
|
+
)
|
|
371
|
+
cmd = ["ssh"]
|
|
372
|
+
if port != 22:
|
|
373
|
+
cmd.extend(["-p", str(port)])
|
|
374
|
+
cmd.extend([f"{user}@{hostname}", remote])
|
|
375
|
+
|
|
376
|
+
if subprocess.run(cmd).returncode != 0:
|
|
377
|
+
click.echo("Warning: failed to copy key. You may need to copy it manually.", err=True)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
@main.command("add")
|
|
381
|
+
@click.argument("alias")
|
|
382
|
+
@click.argument("target")
|
|
383
|
+
def add_cmd(alias: str, target: str):
|
|
384
|
+
"""Add server (keygen + copy key)."""
|
|
385
|
+
from .config import add_host, find_entry, ssh_config_path
|
|
386
|
+
|
|
387
|
+
user, hostname, port = _parse_target(target)
|
|
388
|
+
|
|
389
|
+
if find_entry(alias):
|
|
390
|
+
click.echo(f"Error: alias '{alias}' already exists", err=True)
|
|
391
|
+
sys.exit(1)
|
|
392
|
+
|
|
393
|
+
key_path = _ensure_key(alias)
|
|
394
|
+
_copy_key_to_remote(key_path, user, hostname, port)
|
|
395
|
+
|
|
396
|
+
add_host(
|
|
397
|
+
alias, hostname, user, port, f"~/.ssh/sshm_{alias}", ssh_config_path(),
|
|
398
|
+
# IdentitiesOnly stops ssh from offering agent/default keys first and
|
|
399
|
+
# exhausting MaxAuthTries before it ever tries this host's key.
|
|
400
|
+
extra_options=[("IdentitiesOnly", "yes")],
|
|
401
|
+
)
|
|
402
|
+
click.echo(f"Added '{alias}' -> {user}@{hostname}:{port}")
|
|
403
|
+
|
|
404
|
+
click.echo("Testing connection...")
|
|
405
|
+
test = subprocess.run(
|
|
406
|
+
["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=5",
|
|
407
|
+
"-o", "IdentitiesOnly=yes",
|
|
408
|
+
"-i", str(key_path), "-p", str(port),
|
|
409
|
+
f"{user}@{hostname}", "echo ok"],
|
|
410
|
+
stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=15,
|
|
411
|
+
)
|
|
412
|
+
if test.returncode == 0:
|
|
413
|
+
click.echo("Connection successful!")
|
|
414
|
+
else:
|
|
415
|
+
click.echo("Warning: test connection failed — key auth is not working yet.")
|
|
416
|
+
err = (test.stderr or "").strip()
|
|
417
|
+
if err:
|
|
418
|
+
click.echo(err, err=True)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
# --- remove ---
|
|
422
|
+
|
|
423
|
+
@main.command("remove")
|
|
424
|
+
@click.argument("alias")
|
|
425
|
+
def remove_cmd(alias: str):
|
|
426
|
+
"""Remove host and disconnect."""
|
|
427
|
+
_send(protocol.CMD_REMOVE, alias=alias)
|
|
428
|
+
click.echo(f"Removed '{alias}'")
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
# --- rename ---
|
|
432
|
+
|
|
433
|
+
def _managed_key_paths(alias: str) -> tuple[Path, Path]:
|
|
434
|
+
ssh_dir = Path.home() / ".ssh"
|
|
435
|
+
key = ssh_dir / f"sshm_{alias}"
|
|
436
|
+
return key, key.with_suffix(".pub")
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _rename_key_files(old_alias: str, new_alias: str) -> None:
|
|
440
|
+
"""Rename the sshm-managed key pair to match a renamed alias."""
|
|
441
|
+
old_key, old_pub = _managed_key_paths(old_alias)
|
|
442
|
+
new_key, new_pub = _managed_key_paths(new_alias)
|
|
443
|
+
for src, dst in ((old_key, new_key), (old_pub, new_pub)):
|
|
444
|
+
if src.exists():
|
|
445
|
+
src.rename(dst)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
@main.command("rename")
|
|
449
|
+
@click.argument("alias")
|
|
450
|
+
@click.argument("new_alias")
|
|
451
|
+
def rename_cmd(alias: str, new_alias: str):
|
|
452
|
+
"""Rename a host alias."""
|
|
453
|
+
from .config import find_entry
|
|
454
|
+
|
|
455
|
+
# Capture the identity file before the rename to decide whether the managed
|
|
456
|
+
# key pair should follow along (the daemon updates the config reference).
|
|
457
|
+
entry = find_entry(alias)
|
|
458
|
+
managed_key = entry is not None and entry.identity_file == f"~/.ssh/sshm_{alias}"
|
|
459
|
+
|
|
460
|
+
# Pre-flight: refuse if the destination key files already exist, BEFORE the
|
|
461
|
+
# daemon rewrites the config. Otherwise the rename could point IdentityFile
|
|
462
|
+
# at a key we then fail to create, leaving a dangling reference.
|
|
463
|
+
if managed_key:
|
|
464
|
+
for dst in _managed_key_paths(new_alias):
|
|
465
|
+
if dst.exists():
|
|
466
|
+
click.echo(
|
|
467
|
+
f"Error: {dst} already exists; remove or rename it first", err=True
|
|
468
|
+
)
|
|
469
|
+
sys.exit(1)
|
|
470
|
+
|
|
471
|
+
_send(protocol.CMD_RENAME, alias=alias, new_alias=new_alias)
|
|
472
|
+
|
|
473
|
+
if managed_key:
|
|
474
|
+
_rename_key_files(alias, new_alias)
|
|
475
|
+
|
|
476
|
+
click.echo(f"Renamed '{alias}' -> '{new_alias}'")
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
# --- port add / port remove (forwards + SOCKS proxy via -D) ---
|
|
480
|
+
|
|
481
|
+
def _parse_port_args(args: tuple[str, ...]) -> tuple[str, str]:
|
|
482
|
+
"""Parse raw forward args into (direction, rule).
|
|
483
|
+
|
|
484
|
+
-L/-R take a <local>:<host>:<remote> rule; -D takes a single <port> and
|
|
485
|
+
declares a SOCKS proxy (DynamicForward).
|
|
486
|
+
"""
|
|
487
|
+
if len(args) == 2 and args[0] in ("-L", "-R", "-D"):
|
|
488
|
+
return args[0][1], args[1] # "L" / "R" / "D", rule
|
|
489
|
+
click.echo(
|
|
490
|
+
"Usage: sshm port <alias> a|r -L|-R <local>:<host>:<remote>\n"
|
|
491
|
+
" sshm port <alias> a|r -D <port>",
|
|
492
|
+
err=True,
|
|
493
|
+
)
|
|
494
|
+
sys.exit(1)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
@main.command("port-add", context_settings=dict(ignore_unknown_options=True))
|
|
498
|
+
@click.argument("alias")
|
|
499
|
+
@click.argument("args", nargs=-1, type=click.UNPROCESSED)
|
|
500
|
+
def port_add_cmd(alias: str, args: tuple[str, ...]):
|
|
501
|
+
"""Add a port forward (-L/-R) or SOCKS proxy (-D)."""
|
|
502
|
+
direction, rule = _parse_port_args(args)
|
|
503
|
+
data = _send(protocol.CMD_PORT_ADD, alias=alias, direction=direction, rule=rule)
|
|
504
|
+
added = data.get("added", rule)
|
|
505
|
+
if direction == "D":
|
|
506
|
+
click.echo(f"Added SOCKS proxy: {added} (socks5://127.0.0.1:{rule})")
|
|
507
|
+
else:
|
|
508
|
+
click.echo(f"Added port forward: {added}")
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
@main.command("port-remove", context_settings=dict(ignore_unknown_options=True))
|
|
512
|
+
@click.argument("alias")
|
|
513
|
+
@click.argument("args", nargs=-1, type=click.UNPROCESSED)
|
|
514
|
+
def port_remove_cmd(alias: str, args: tuple[str, ...]):
|
|
515
|
+
"""Remove a port forward (-L/-R) or SOCKS proxy (-D)."""
|
|
516
|
+
from .config import PortForward
|
|
517
|
+
|
|
518
|
+
direction, rule = _parse_port_args(args)
|
|
519
|
+
try:
|
|
520
|
+
if direction == "D":
|
|
521
|
+
rule_str = PortForward.socks(int(rule)).to_str()
|
|
522
|
+
else:
|
|
523
|
+
rule_str = PortForward.parse_rule(rule, direction).to_str()
|
|
524
|
+
except ValueError:
|
|
525
|
+
click.echo(f"Error: invalid rule format '{rule}'", err=True)
|
|
526
|
+
sys.exit(1)
|
|
527
|
+
_send(protocol.CMD_PORT_REMOVE, alias=alias, rule=rule_str)
|
|
528
|
+
label = "SOCKS proxy" if direction == "D" else "port forward"
|
|
529
|
+
click.echo(f"Removed {label}: {rule_str}")
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
# --- enable / disable ---
|
|
533
|
+
|
|
534
|
+
@main.command("enable")
|
|
535
|
+
@click.argument("alias")
|
|
536
|
+
def enable_cmd(alias: str):
|
|
537
|
+
"""Keep session alive automatically."""
|
|
538
|
+
_send(protocol.CMD_ENABLE, alias=alias)
|
|
539
|
+
click.echo(f"Enabled auto-connect for '{alias}'")
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
@main.command("disable")
|
|
543
|
+
@click.argument("alias")
|
|
544
|
+
def disable_cmd(alias: str):
|
|
545
|
+
"""Stop auto-connect."""
|
|
546
|
+
_send(protocol.CMD_DISABLE, alias=alias)
|
|
547
|
+
click.echo(f"Disabled auto-connect for '{alias}'")
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
# --- export / import ---
|
|
551
|
+
|
|
552
|
+
@main.command("export")
|
|
553
|
+
@click.argument("filepath")
|
|
554
|
+
@click.argument("names", nargs=-1)
|
|
555
|
+
def export_cmd(filepath: str, names: tuple[str, ...]):
|
|
556
|
+
"""Export hosts with SSH keys to JSON."""
|
|
557
|
+
from .config import load_entries
|
|
558
|
+
|
|
559
|
+
entries = [e for e in load_entries() if e.alias not in ("*", "")]
|
|
560
|
+
if names:
|
|
561
|
+
entries = [e for e in entries if e.alias in names]
|
|
562
|
+
|
|
563
|
+
hosts = []
|
|
564
|
+
for e in entries:
|
|
565
|
+
h: dict = {
|
|
566
|
+
"alias": e.alias,
|
|
567
|
+
"hostname": e.hostname,
|
|
568
|
+
"user": e.user,
|
|
569
|
+
"port": e.port,
|
|
570
|
+
"port_forwards": [pf.to_str() for pf in e.port_forwards],
|
|
571
|
+
}
|
|
572
|
+
if e.identity_file:
|
|
573
|
+
h["identity_file"] = e.identity_file
|
|
574
|
+
key_path = Path(e.identity_file).expanduser()
|
|
575
|
+
if key_path.exists():
|
|
576
|
+
h["private_key"] = key_path.read_text(encoding="utf-8")
|
|
577
|
+
pub_path = key_path.with_suffix(".pub")
|
|
578
|
+
if pub_path.exists():
|
|
579
|
+
h["public_key"] = pub_path.read_text(encoding="utf-8")
|
|
580
|
+
hosts.append(h)
|
|
581
|
+
|
|
582
|
+
try:
|
|
583
|
+
Path(filepath).write_text(
|
|
584
|
+
json.dumps({"hosts": hosts}, indent=2, ensure_ascii=False),
|
|
585
|
+
encoding="utf-8",
|
|
586
|
+
)
|
|
587
|
+
except OSError as e:
|
|
588
|
+
click.echo(f"Error: cannot write {filepath}: {e}", err=True)
|
|
589
|
+
sys.exit(1)
|
|
590
|
+
click.echo(f"Exported {len(hosts)} host(s) to {filepath}")
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
@main.command("import")
|
|
594
|
+
@click.argument("filepath")
|
|
595
|
+
@click.option("-o", "--override", is_flag=True, help="Override existing hosts")
|
|
596
|
+
@click.argument("names", nargs=-1)
|
|
597
|
+
def import_cmd(filepath: str, override: bool, names: tuple[str, ...]):
|
|
598
|
+
"""Import hosts from JSON. A name may be `<json-alias>=<new-alias>` to rename."""
|
|
599
|
+
from .config import PortForward, add_host, add_port_forward, find_entry, remove_host, ssh_config_path
|
|
600
|
+
|
|
601
|
+
rename = _parse_import_names(names) # {json_alias: target_alias}; empty → import all
|
|
602
|
+
hosts = _read_hosts_file(filepath)
|
|
603
|
+
if rename:
|
|
604
|
+
hosts = [h for h in hosts if h["alias"] in rename]
|
|
605
|
+
|
|
606
|
+
imported = 0
|
|
607
|
+
skipped = 0
|
|
608
|
+
for h in hosts:
|
|
609
|
+
src_alias = h["alias"]
|
|
610
|
+
alias = rename.get(src_alias, src_alias) # target alias (renamed or as-is)
|
|
611
|
+
existing = find_entry(alias)
|
|
612
|
+
label = f"{src_alias} -> {alias}" if alias != src_alias else alias
|
|
613
|
+
|
|
614
|
+
if existing and not override:
|
|
615
|
+
click.echo(f" skip {label} (already exists, use -o to override)")
|
|
616
|
+
skipped += 1
|
|
617
|
+
continue
|
|
618
|
+
|
|
619
|
+
if existing:
|
|
620
|
+
remove_host(alias)
|
|
621
|
+
|
|
622
|
+
# If the export's key is the managed ~/.ssh/sshm_<src>, retarget it to the
|
|
623
|
+
# new alias so the renamed host gets its own sshm_<new> key (like rename).
|
|
624
|
+
identity = h.get("identity_file")
|
|
625
|
+
if alias != src_alias and identity == f"~/.ssh/sshm_{src_alias}":
|
|
626
|
+
identity = f"~/.ssh/sshm_{alias}"
|
|
627
|
+
if identity and h.get("private_key"):
|
|
628
|
+
_write_key_files(identity, h, override)
|
|
629
|
+
|
|
630
|
+
add_host(
|
|
631
|
+
alias=alias,
|
|
632
|
+
hostname=h.get("hostname", ""),
|
|
633
|
+
user=h.get("user", "root"),
|
|
634
|
+
port=h.get("port", 22),
|
|
635
|
+
identity_file=identity,
|
|
636
|
+
path=ssh_config_path(),
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
for pf_str in h.get("port_forwards", []):
|
|
640
|
+
try:
|
|
641
|
+
add_port_forward(alias, PortForward.from_str(pf_str))
|
|
642
|
+
except Exception:
|
|
643
|
+
pass
|
|
644
|
+
|
|
645
|
+
click.echo(f" {'update' if existing else 'add':>6} {label}")
|
|
646
|
+
imported += 1
|
|
647
|
+
|
|
648
|
+
click.echo(f"\nImported {imported}, skipped {skipped}")
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _parse_import_names(names: tuple[str, ...]) -> dict[str, str]:
|
|
652
|
+
"""Parse import selectors into {json_alias: target_alias}.
|
|
653
|
+
|
|
654
|
+
Each name is either `<alias>` (import as-is) or `<alias>=<new-alias>` (import
|
|
655
|
+
that host under a new alias). An empty tuple means "import everything".
|
|
656
|
+
"""
|
|
657
|
+
mapping: dict[str, str] = {}
|
|
658
|
+
for n in names:
|
|
659
|
+
src, sep, dst = n.partition("=")
|
|
660
|
+
if sep and not (src and dst):
|
|
661
|
+
click.echo(f"Error: invalid selector '{n}', expected <name> or <name>=<new-name>", err=True)
|
|
662
|
+
sys.exit(1)
|
|
663
|
+
mapping[src] = dst if sep else src
|
|
664
|
+
return mapping
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _write_key_files(identity: str, host: dict, override: bool) -> None:
|
|
668
|
+
key_path = Path(identity).expanduser()
|
|
669
|
+
key_path.parent.mkdir(mode=0o700, exist_ok=True)
|
|
670
|
+
if not key_path.exists() or override:
|
|
671
|
+
key_path.write_text(host["private_key"], encoding="utf-8")
|
|
672
|
+
if sys.platform != "win32":
|
|
673
|
+
os.chmod(key_path, 0o600)
|
|
674
|
+
pub_path = key_path.with_suffix(".pub")
|
|
675
|
+
if host.get("public_key") and (not pub_path.exists() or override):
|
|
676
|
+
pub_path.write_text(host["public_key"], encoding="utf-8")
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
# --- daemon control ---
|
|
680
|
+
|
|
681
|
+
@main.command("stop")
|
|
682
|
+
def stop_cmd():
|
|
683
|
+
"""Stop daemon."""
|
|
684
|
+
_send(protocol.CMD_SHUTDOWN)
|
|
685
|
+
click.echo("Daemon stopping...")
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
@main.command("status")
|
|
689
|
+
def status_cmd():
|
|
690
|
+
"""Show daemon status."""
|
|
691
|
+
data = _send(protocol.CMD_STATUS)
|
|
692
|
+
click.echo(f"Daemon: {data.get('status', 'unknown')}, sessions: {data.get('sessions', 0)}")
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
@main.command("install")
|
|
696
|
+
def install_cmd():
|
|
697
|
+
"""Autostart daemon on login."""
|
|
698
|
+
from .autostart import install_autostart
|
|
699
|
+
click.echo(install_autostart())
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
@main.command("uninstall")
|
|
703
|
+
def uninstall_cmd():
|
|
704
|
+
"""Remove autostart."""
|
|
705
|
+
from .autostart import uninstall_autostart
|
|
706
|
+
click.echo(uninstall_autostart())
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
# --- shell completions ---
|
|
710
|
+
|
|
711
|
+
@main.command("completions")
|
|
712
|
+
@click.argument("shell", type=click.Choice(["fish"]), default="fish", required=False)
|
|
713
|
+
def completions_cmd(shell: str):
|
|
714
|
+
"""Print the shell completion script (currently: fish).
|
|
715
|
+
|
|
716
|
+
Install with:
|
|
717
|
+
sshm completions fish > ~/.config/fish/completions/sshm.fish && exec fish
|
|
718
|
+
"""
|
|
719
|
+
from importlib import resources
|
|
720
|
+
|
|
721
|
+
script = resources.files("sshm").joinpath(f"completions/sshm.{shell}").read_text(encoding="utf-8")
|
|
722
|
+
click.echo(script, nl=False)
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
# --- helpers ---
|
|
726
|
+
|
|
727
|
+
def _format_uptime(seconds: float) -> str:
|
|
728
|
+
s = int(seconds)
|
|
729
|
+
if s < 60:
|
|
730
|
+
return f"{s}s"
|
|
731
|
+
elif s < 3600:
|
|
732
|
+
return f"{s // 60}m{s % 60}s"
|
|
733
|
+
elif s < 86400:
|
|
734
|
+
return f"{s // 3600}h{(s % 3600) // 60}m"
|
|
735
|
+
else:
|
|
736
|
+
return f"{s // 86400}d{(s % 86400) // 3600}h"
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
if __name__ == "__main__":
|
|
740
|
+
main()
|