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
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# Fish completions for sshm — https://github.com/revsearch/sshm
|
|
2
|
+
#
|
|
3
|
+
# Install: sshm completions fish > ~/.config/fish/completions/sshm.fish && exec fish
|
|
4
|
+
|
|
5
|
+
function __sshm_aliases --description 'Host aliases from ~/.ssh/config'
|
|
6
|
+
set -l cfg "$HOME/.ssh/config"
|
|
7
|
+
test -r "$cfg"
|
|
8
|
+
and awk 'tolower($1) == "host" { for (i = 2; i <= NF; i++) if ($i !~ /[*?]/) print $i }' "$cfg"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
function __sshm_wants_alias --description 'True when the previous token expects a host alias'
|
|
12
|
+
set -l toks (commandline -opc)
|
|
13
|
+
contains -- "$toks[-1]" connect c remove r rename mv enable e disable d
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# port has a flexible order: `port <alias> a|r ...` or `port a|r <alias> ...`.
|
|
17
|
+
# These predicates scan the tokens after the `port` keyword so each piece is only
|
|
18
|
+
# offered while it's still missing.
|
|
19
|
+
function __sshm_port_post_tokens
|
|
20
|
+
set -l toks (commandline -opc)
|
|
21
|
+
set -l i (contains -i -- port $toks; or contains -i -- po $toks; or contains -i -- p $toks)
|
|
22
|
+
test -n "$i"; or return
|
|
23
|
+
set -l rest $toks[(math $i + 1)..-1]
|
|
24
|
+
test (count $rest) -gt 0; and printf '%s\n' $rest
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
function __sshm_port_wants_alias --description 'port subcommand still needs its host alias'
|
|
28
|
+
__fish_seen_subcommand_from port po p; or return 1
|
|
29
|
+
for t in (__sshm_port_post_tokens)
|
|
30
|
+
switch $t
|
|
31
|
+
case add a remove r rm '-*'
|
|
32
|
+
case '*'
|
|
33
|
+
return 1 # a bare token already given — that's the alias
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
return 0
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
function __sshm_port_wants_action --description 'port subcommand has no add/remove yet'
|
|
40
|
+
__fish_seen_subcommand_from port po p; or return 1
|
|
41
|
+
for t in (__sshm_port_post_tokens)
|
|
42
|
+
contains -- $t add a remove r rm; and return 1
|
|
43
|
+
end
|
|
44
|
+
return 0
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
function __sshm_port_has_action --description 'port subcommand already has add/remove'
|
|
48
|
+
__fish_seen_subcommand_from port po p; and not __sshm_port_wants_action
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
function __sshm_port_wants_flag --description 'port has an action but no -L/-R/-D yet'
|
|
52
|
+
__sshm_port_has_action; or return 1
|
|
53
|
+
for t in (__sshm_port_post_tokens)
|
|
54
|
+
contains -- $t -L -R -D; and return 1
|
|
55
|
+
end
|
|
56
|
+
return 0
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Positional (non-flag) tokens after the first of the given subcommand keyword(s),
|
|
60
|
+
# used to offer an argument only while its slot is still empty.
|
|
61
|
+
function __sshm_post_tokens
|
|
62
|
+
set -l toks (commandline -opc)
|
|
63
|
+
set -l i
|
|
64
|
+
for kw in $argv
|
|
65
|
+
set i (contains -i -- $kw $toks)
|
|
66
|
+
test -n "$i"; and break
|
|
67
|
+
end
|
|
68
|
+
test -n "$i"; or return
|
|
69
|
+
for t in $toks[(math $i + 1)..-1]
|
|
70
|
+
string match -q -- '-*' $t; or echo $t
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
function __sshm_list_wants_arg
|
|
75
|
+
__fish_seen_subcommand_from list l; or return 1
|
|
76
|
+
set -l p (__sshm_post_tokens list l)
|
|
77
|
+
test (count $p) -eq 0
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
function __sshm_export_wants_file
|
|
81
|
+
__fish_seen_subcommand_from export; or return 1
|
|
82
|
+
set -l p (__sshm_post_tokens export)
|
|
83
|
+
test (count $p) -eq 0
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
function __sshm_export_wants_alias
|
|
87
|
+
__fish_seen_subcommand_from export; or return 1
|
|
88
|
+
set -l p (__sshm_post_tokens export)
|
|
89
|
+
test (count $p) -ge 1
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
function __sshm_import_wants_file
|
|
93
|
+
__fish_seen_subcommand_from import; or return 1
|
|
94
|
+
set -l p (__sshm_post_tokens import)
|
|
95
|
+
test (count $p) -eq 0
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Don't fall back to file completion unless a rule opts in (-F).
|
|
99
|
+
complete -c sshm -f
|
|
100
|
+
complete -c sshm -n __fish_use_subcommand -l help -d 'Show help'
|
|
101
|
+
|
|
102
|
+
# --- top level: subcommands + host aliases (bare `sshm <alias>` connects) ---
|
|
103
|
+
complete -c sshm -n __fish_use_subcommand -a '(__sshm_aliases)' -d 'Connect to host'
|
|
104
|
+
complete -c sshm -n __fish_use_subcommand -a list -d 'List hosts or sessions'
|
|
105
|
+
complete -c sshm -n __fish_use_subcommand -a connect -d 'Attach to a session'
|
|
106
|
+
complete -c sshm -n __fish_use_subcommand -a add -d 'Add a server'
|
|
107
|
+
complete -c sshm -n __fish_use_subcommand -a remove -d 'Remove a host'
|
|
108
|
+
complete -c sshm -n __fish_use_subcommand -a rename -d 'Rename an alias'
|
|
109
|
+
complete -c sshm -n __fish_use_subcommand -a port -d 'Port forward / SOCKS proxy'
|
|
110
|
+
complete -c sshm -n __fish_use_subcommand -a enable -d 'Keep session alive'
|
|
111
|
+
complete -c sshm -n __fish_use_subcommand -a disable -d 'Stop auto-connect'
|
|
112
|
+
complete -c sshm -n __fish_use_subcommand -a export -d 'Export hosts to JSON'
|
|
113
|
+
complete -c sshm -n __fish_use_subcommand -a import -d 'Import hosts from JSON'
|
|
114
|
+
complete -c sshm -n __fish_use_subcommand -a status -d 'Daemon status'
|
|
115
|
+
complete -c sshm -n __fish_use_subcommand -a stop -d 'Stop the daemon'
|
|
116
|
+
complete -c sshm -n __fish_use_subcommand -a install -d 'Autostart on login'
|
|
117
|
+
complete -c sshm -n __fish_use_subcommand -a uninstall -d 'Remove autostart'
|
|
118
|
+
|
|
119
|
+
# --- host alias as the argument to alias-taking commands ---
|
|
120
|
+
complete -c sshm -n __sshm_wants_alias -a '(__sshm_aliases)' -d host
|
|
121
|
+
|
|
122
|
+
# --- port: host alias (either order), action while missing, flag after the action ---
|
|
123
|
+
complete -c sshm -n __sshm_port_wants_alias -a '(__sshm_aliases)' -d host
|
|
124
|
+
complete -c sshm -n __sshm_port_wants_action -a 'add remove' -d action
|
|
125
|
+
complete -c sshm -n __sshm_port_wants_flag -a '-L -R -D' -d 'forward direction'
|
|
126
|
+
|
|
127
|
+
# --- list: a single arg — host alias or a .json file ---
|
|
128
|
+
complete -c sshm -n __sshm_list_wants_arg -a '(__sshm_aliases)' -d host
|
|
129
|
+
complete -c sshm -n __sshm_list_wants_arg -F
|
|
130
|
+
|
|
131
|
+
# --- export: <file> then host names ---
|
|
132
|
+
complete -c sshm -n __sshm_export_wants_file -F
|
|
133
|
+
complete -c sshm -n __sshm_export_wants_alias -a '(__sshm_aliases)' -d host
|
|
134
|
+
|
|
135
|
+
# --- import: <file>, then -o ---
|
|
136
|
+
complete -c sshm -n __sshm_import_wants_file -F
|
|
137
|
+
complete -c sshm -n '__fish_seen_subcommand_from import' -s o -l override -d 'Override existing hosts'
|
sshm/config.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""SSH config parser/writer with sshm metadata in structured comments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
import tempfile
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class PortForward:
|
|
15
|
+
direction: str # "L"/"R" (tunnel) or "D" (SOCKS dynamic proxy)
|
|
16
|
+
local_port: int
|
|
17
|
+
remote_host: str = ""
|
|
18
|
+
remote_port: int = 0
|
|
19
|
+
|
|
20
|
+
def to_str(self) -> str:
|
|
21
|
+
if self.direction == "D":
|
|
22
|
+
return f"D:{self.local_port}"
|
|
23
|
+
return f"{self.direction}:{self.local_port}:{self.remote_host}:{self.remote_port}"
|
|
24
|
+
|
|
25
|
+
def to_config_line(self) -> str:
|
|
26
|
+
if self.direction == "D":
|
|
27
|
+
return f" DynamicForward {self.local_port}"
|
|
28
|
+
keyword = "LocalForward" if self.direction == "L" else "RemoteForward"
|
|
29
|
+
return f" {keyword} {self.local_port} {self.remote_host}:{self.remote_port}"
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def socks(cls, port: int) -> PortForward:
|
|
33
|
+
"""SOCKS proxy through the host (ssh -D / DynamicForward)."""
|
|
34
|
+
return cls("D", port)
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def parse_rule(cls, rule: str, direction: str) -> PortForward:
|
|
38
|
+
"""Parse a CLI rule like '8080:80' or '8080:host:80'."""
|
|
39
|
+
parts = rule.split(":")
|
|
40
|
+
if len(parts) == 2:
|
|
41
|
+
return cls(direction, int(parts[0]), "localhost", int(parts[1]))
|
|
42
|
+
if len(parts) == 3:
|
|
43
|
+
return cls(direction, int(parts[0]), parts[1], int(parts[2]))
|
|
44
|
+
raise ValueError(f"Invalid port rule: {rule}")
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def from_str(cls, s: str) -> PortForward:
|
|
48
|
+
"""Parse the serialized form 'L:8080:host:80' or 'D:1080'."""
|
|
49
|
+
direction, _, rest = s.partition(":")
|
|
50
|
+
if direction == "D":
|
|
51
|
+
if not rest or ":" in rest:
|
|
52
|
+
raise ValueError(f"Invalid port forward: {s}")
|
|
53
|
+
return cls.socks(int(rest))
|
|
54
|
+
if direction not in ("L", "R") or not rest:
|
|
55
|
+
raise ValueError(f"Invalid port forward: {s}")
|
|
56
|
+
return cls.parse_rule(rest, direction)
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def from_config(cls, direction: str, value: str) -> PortForward:
|
|
60
|
+
"""Parse from an SSH config value like '8080 localhost:80' or '1080'."""
|
|
61
|
+
if direction == "D":
|
|
62
|
+
return cls.socks(int(value.strip()))
|
|
63
|
+
parts = value.strip().split()
|
|
64
|
+
if len(parts) == 2 and ":" in parts[1]:
|
|
65
|
+
remote_host, remote_port = parts[1].rsplit(":", 1)
|
|
66
|
+
return cls(direction, int(parts[0]), remote_host, int(remote_port))
|
|
67
|
+
raise ValueError(f"Invalid forward: {value}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class HostEntry:
|
|
72
|
+
alias: str
|
|
73
|
+
hostname: str = ""
|
|
74
|
+
user: str = ""
|
|
75
|
+
port: int = 22
|
|
76
|
+
identity_file: str | None = None
|
|
77
|
+
enabled: bool = False
|
|
78
|
+
port_forwards: list[PortForward] = field(default_factory=list)
|
|
79
|
+
extra_options: list[tuple[str, str]] = field(default_factory=list)
|
|
80
|
+
_raw_lines: list[str] = field(default_factory=list, repr=False)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
_META_RE = re.compile(r"^# sshm:(\w+)=(.*)$")
|
|
84
|
+
_HOST_RE = re.compile(r"^Host\s+(\S+)\s*$", re.IGNORECASE)
|
|
85
|
+
_OPTION_RE = re.compile(r"^\s+(\S+)\s+(.+)$")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def ssh_config_path() -> Path:
|
|
89
|
+
return Path.home() / ".ssh" / "config"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _apply_option(entry: HostEntry, key: str, val: str) -> None:
|
|
93
|
+
key_lower = key.lower()
|
|
94
|
+
if key_lower == "hostname":
|
|
95
|
+
entry.hostname = val
|
|
96
|
+
elif key_lower == "user":
|
|
97
|
+
entry.user = val
|
|
98
|
+
elif key_lower == "port":
|
|
99
|
+
try:
|
|
100
|
+
entry.port = int(val)
|
|
101
|
+
except ValueError:
|
|
102
|
+
entry.extra_options.append((key, val)) # malformed Port → keep verbatim, don't crash the parse
|
|
103
|
+
elif key_lower == "identityfile":
|
|
104
|
+
# Keep the first IdentityFile as the primary (what sshm reads/writes);
|
|
105
|
+
# preserve any extras as raw options so regeneration doesn't drop them.
|
|
106
|
+
if entry.identity_file is None:
|
|
107
|
+
entry.identity_file = val
|
|
108
|
+
else:
|
|
109
|
+
entry.extra_options.append((key, val))
|
|
110
|
+
elif key_lower in ("localforward", "remoteforward", "dynamicforward"):
|
|
111
|
+
direction = {"localforward": "L", "remoteforward": "R", "dynamicforward": "D"}[key_lower]
|
|
112
|
+
try:
|
|
113
|
+
entry.port_forwards.append(PortForward.from_config(direction, val))
|
|
114
|
+
except ValueError:
|
|
115
|
+
entry.extra_options.append((key, val))
|
|
116
|
+
else:
|
|
117
|
+
entry.extra_options.append((key, val))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def parse_ssh_config(path: Path | None = None) -> tuple[list[str], list[HostEntry]]:
|
|
121
|
+
path = path or ssh_config_path()
|
|
122
|
+
if not path.exists():
|
|
123
|
+
return [], []
|
|
124
|
+
|
|
125
|
+
# surrogateescape: a config with non-UTF-8 bytes (a comment in another
|
|
126
|
+
# encoding, etc.) must not crash the parse or the watchdog that calls it; the
|
|
127
|
+
# bytes round-trip losslessly because write_ssh_config uses it too.
|
|
128
|
+
lines = path.read_text(encoding="utf-8", errors="surrogateescape").splitlines(keepends=True)
|
|
129
|
+
|
|
130
|
+
preamble: list[str] = []
|
|
131
|
+
entries: list[HostEntry] = []
|
|
132
|
+
pending_meta: dict[str, str] = {}
|
|
133
|
+
pending_meta_lines: list[str] = []
|
|
134
|
+
current: HostEntry | None = None
|
|
135
|
+
in_preamble = True
|
|
136
|
+
|
|
137
|
+
for line in lines:
|
|
138
|
+
stripped = line.rstrip("\n\r")
|
|
139
|
+
|
|
140
|
+
meta_match = _META_RE.match(stripped)
|
|
141
|
+
if meta_match:
|
|
142
|
+
pending_meta[meta_match.group(1)] = meta_match.group(2)
|
|
143
|
+
pending_meta_lines.append(line)
|
|
144
|
+
continue
|
|
145
|
+
|
|
146
|
+
host_match = _HOST_RE.match(stripped)
|
|
147
|
+
if host_match:
|
|
148
|
+
if current:
|
|
149
|
+
entries.append(current)
|
|
150
|
+
in_preamble = False
|
|
151
|
+
|
|
152
|
+
current = HostEntry(alias=host_match.group(1), _raw_lines=[line])
|
|
153
|
+
if "enabled" in pending_meta:
|
|
154
|
+
current.enabled = pending_meta["enabled"].lower() == "true"
|
|
155
|
+
|
|
156
|
+
pending_meta.clear()
|
|
157
|
+
pending_meta_lines.clear()
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
if current:
|
|
161
|
+
current._raw_lines.append(line)
|
|
162
|
+
opt_match = _OPTION_RE.match(stripped)
|
|
163
|
+
if opt_match:
|
|
164
|
+
_apply_option(current, opt_match.group(1), opt_match.group(2))
|
|
165
|
+
elif in_preamble:
|
|
166
|
+
if pending_meta_lines:
|
|
167
|
+
preamble.extend(pending_meta_lines)
|
|
168
|
+
pending_meta_lines.clear()
|
|
169
|
+
pending_meta.clear()
|
|
170
|
+
preamble.append(line)
|
|
171
|
+
|
|
172
|
+
if current:
|
|
173
|
+
entries.append(current)
|
|
174
|
+
|
|
175
|
+
return preamble, entries
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def write_ssh_config(
|
|
179
|
+
preamble: list[str],
|
|
180
|
+
entries: list[HostEntry],
|
|
181
|
+
path: Path | None = None,
|
|
182
|
+
) -> None:
|
|
183
|
+
path = path or ssh_config_path()
|
|
184
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
185
|
+
|
|
186
|
+
# If ~/.ssh/config is a symlink (dotfile managers point it at a tracked file),
|
|
187
|
+
# write through to the real target so os.replace swaps the file rather than
|
|
188
|
+
# replacing the link with a regular file and orphaning the target.
|
|
189
|
+
target = path.resolve() if path.is_symlink() else path
|
|
190
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
191
|
+
|
|
192
|
+
if target.exists():
|
|
193
|
+
shutil.copy2(target, target.with_name(target.name + ".bak"))
|
|
194
|
+
|
|
195
|
+
# Write to a temp file in the same directory, then atomically replace, so a
|
|
196
|
+
# crash mid-write can never leave a truncated ~/.ssh/config behind.
|
|
197
|
+
fd, tmp_name = tempfile.mkstemp(dir=target.parent, prefix=target.name + ".", suffix=".tmp")
|
|
198
|
+
try:
|
|
199
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="", errors="surrogateescape") as f:
|
|
200
|
+
for line in preamble:
|
|
201
|
+
f.write(line if line.endswith("\n") else line + "\n")
|
|
202
|
+
|
|
203
|
+
for entry in entries:
|
|
204
|
+
# sshm metadata comment (only the enabled flag; forwards are native directives)
|
|
205
|
+
if entry.enabled:
|
|
206
|
+
f.write("# sshm:enabled=true\n")
|
|
207
|
+
|
|
208
|
+
if entry._raw_lines:
|
|
209
|
+
for line in entry._raw_lines:
|
|
210
|
+
f.write(line if line.endswith("\n") else line + "\n")
|
|
211
|
+
else:
|
|
212
|
+
f.write(f"Host {entry.alias}\n")
|
|
213
|
+
if entry.hostname:
|
|
214
|
+
f.write(f" HostName {entry.hostname}\n")
|
|
215
|
+
if entry.user:
|
|
216
|
+
f.write(f" User {entry.user}\n")
|
|
217
|
+
if entry.port != 22:
|
|
218
|
+
f.write(f" Port {entry.port}\n")
|
|
219
|
+
if entry.identity_file:
|
|
220
|
+
f.write(f" IdentityFile {entry.identity_file}\n")
|
|
221
|
+
for key, val in entry.extra_options:
|
|
222
|
+
f.write(f" {key} {val}\n")
|
|
223
|
+
for pf in entry.port_forwards:
|
|
224
|
+
f.write(pf.to_config_line() + "\n")
|
|
225
|
+
f.write("\n")
|
|
226
|
+
f.flush()
|
|
227
|
+
os.fsync(f.fileno())
|
|
228
|
+
except BaseException:
|
|
229
|
+
os.unlink(tmp_name)
|
|
230
|
+
raise
|
|
231
|
+
|
|
232
|
+
if target.exists():
|
|
233
|
+
shutil.copymode(target, tmp_name)
|
|
234
|
+
os.replace(tmp_name, target)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def load_entries(path: Path | None = None) -> list[HostEntry]:
|
|
238
|
+
_, entries = parse_ssh_config(path)
|
|
239
|
+
return entries
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def find_entry(alias: str, path: Path | None = None) -> HostEntry | None:
|
|
243
|
+
for entry in load_entries(path):
|
|
244
|
+
if entry.alias == alias:
|
|
245
|
+
return entry
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _update_entry(alias: str, mutate, path: Path | None = None) -> None:
|
|
250
|
+
"""Apply `mutate(entry)` to the matching host and rewrite the config."""
|
|
251
|
+
preamble, entries = parse_ssh_config(path)
|
|
252
|
+
for e in entries:
|
|
253
|
+
if e.alias == alias:
|
|
254
|
+
mutate(e)
|
|
255
|
+
break
|
|
256
|
+
else:
|
|
257
|
+
raise ValueError(f"Host '{alias}' not found")
|
|
258
|
+
write_ssh_config(preamble, entries, path)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def add_host(
|
|
262
|
+
alias: str,
|
|
263
|
+
hostname: str,
|
|
264
|
+
user: str,
|
|
265
|
+
port: int = 22,
|
|
266
|
+
identity_file: str | None = None,
|
|
267
|
+
path: Path | None = None,
|
|
268
|
+
extra_options: list[tuple[str, str]] | None = None,
|
|
269
|
+
) -> None:
|
|
270
|
+
preamble, entries = parse_ssh_config(path)
|
|
271
|
+
|
|
272
|
+
if any(e.alias == alias for e in entries):
|
|
273
|
+
raise ValueError(f"Host '{alias}' already exists in SSH config")
|
|
274
|
+
|
|
275
|
+
entries.append(
|
|
276
|
+
HostEntry(
|
|
277
|
+
alias=alias,
|
|
278
|
+
hostname=hostname,
|
|
279
|
+
user=user,
|
|
280
|
+
port=port,
|
|
281
|
+
identity_file=identity_file,
|
|
282
|
+
extra_options=extra_options or [],
|
|
283
|
+
)
|
|
284
|
+
)
|
|
285
|
+
write_ssh_config(preamble, entries, path)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def remove_host(alias: str, path: Path | None = None) -> None:
|
|
289
|
+
preamble, entries = parse_ssh_config(path)
|
|
290
|
+
entries = [e for e in entries if e.alias != alias]
|
|
291
|
+
write_ssh_config(preamble, entries, path)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def rename_host(old_alias: str, new_alias: str, path: Path | None = None) -> None:
|
|
295
|
+
"""Rename a host's alias, keeping its options, forwards and enabled flag.
|
|
296
|
+
|
|
297
|
+
If the IdentityFile is the sshm-managed key for the old alias
|
|
298
|
+
(`~/.ssh/sshm_<old>`), its reference is updated to `~/.ssh/sshm_<new>` to
|
|
299
|
+
match — the caller is responsible for renaming the key file on disk.
|
|
300
|
+
"""
|
|
301
|
+
if old_alias == new_alias:
|
|
302
|
+
raise ValueError("New alias is the same as the old one")
|
|
303
|
+
|
|
304
|
+
preamble, entries = parse_ssh_config(path)
|
|
305
|
+
|
|
306
|
+
if any(e.alias == new_alias for e in entries):
|
|
307
|
+
raise ValueError(f"Host '{new_alias}' already exists in SSH config")
|
|
308
|
+
|
|
309
|
+
for e in entries:
|
|
310
|
+
if e.alias == old_alias:
|
|
311
|
+
break
|
|
312
|
+
else:
|
|
313
|
+
raise ValueError(f"Host '{old_alias}' not found")
|
|
314
|
+
|
|
315
|
+
e.alias = new_alias
|
|
316
|
+
if e.identity_file == f"~/.ssh/sshm_{old_alias}":
|
|
317
|
+
e.identity_file = f"~/.ssh/sshm_{new_alias}"
|
|
318
|
+
e._raw_lines = [] # force regeneration with the new Host line / identity
|
|
319
|
+
write_ssh_config(preamble, entries, path)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def set_enabled(alias: str, enabled: bool, path: Path | None = None) -> None:
|
|
323
|
+
def mutate(e: HostEntry) -> None:
|
|
324
|
+
e.enabled = enabled
|
|
325
|
+
|
|
326
|
+
_update_entry(alias, mutate, path)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def add_port_forward(alias: str, forward: PortForward, path: Path | None = None) -> None:
|
|
330
|
+
def mutate(e: HostEntry) -> None:
|
|
331
|
+
if forward.to_str() in (pf.to_str() for pf in e.port_forwards):
|
|
332
|
+
raise ValueError(f"Port forward {forward.to_str()} already exists")
|
|
333
|
+
e.port_forwards.append(forward)
|
|
334
|
+
e._raw_lines = [] # force regeneration with the new forward
|
|
335
|
+
|
|
336
|
+
_update_entry(alias, mutate, path)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def remove_port_forward(alias: str, rule_str: str, path: Path | None = None) -> None:
|
|
340
|
+
def mutate(e: HostEntry) -> None:
|
|
341
|
+
e.port_forwards = [pf for pf in e.port_forwards if pf.to_str() != rule_str]
|
|
342
|
+
e._raw_lines = [] # force regeneration without the forward
|
|
343
|
+
|
|
344
|
+
_update_entry(alias, mutate, path)
|