Skip to content

Commit 0b33a8d

Browse files
committed
feat: add support for multiple proxy jumps
1 parent dd8badb commit 0b33a8d

21 files changed

Lines changed: 695 additions & 135 deletions

docs/concepts.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ To be considered active, a local volume must have a `.nbkp-vol` file in the root
7070
A reusable configuration for an SSH server that can be shared between multiple remote volumes.
7171
Provides the host, port, user, key, structured connection options, and optional proxy-jump.
7272

73-
The `proxy-jump` field references another ssh-endpoint by slug, enabling connections through a bastion/jump host. This maps to SSH's `-J` flag and Fabric's `gateway` parameter. Circular proxy-jump chains are detected and rejected at config load time.
73+
The `proxy-jump` field references another ssh-endpoint by slug, enabling connections through a bastion/jump host. For multi-hop chains, use `proxy-jumps` (a list of endpoint slugs); the two fields are mutually exclusive. Both map to SSH's `-J` flag (comma-separated) and Fabric's nested `gateway` parameter. Circular proxy-jump chains are detected and rejected at config load time.
7474

7575
The `location` field declares which network location this endpoint is accessible from (e.g. `home`, `office`, `travel`). Used with the `--location` CLI option for endpoint selection (see [Endpoint Filtering](#endpoint-filtering)).
7676

nbkp/check.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ def _check_remote_volume(
115115
ep = resolved_endpoints[volume.slug]
116116
marker_path = f"{volume.path}/.nbkp-vol"
117117
result = run_remote_command(
118-
ep.server, ["test", "-f", marker_path], ep.proxy
118+
ep.server, ["test", "-f", marker_path], ep.proxy_chain
119119
)
120120
reasons: list[VolumeReason] = (
121121
[] if result.returncode == 0 else [VolumeReason.UNREACHABLE]
@@ -145,7 +145,7 @@ def _check_endpoint_marker(
145145
case RemoteVolume():
146146
ep = resolved_endpoints[volume.slug]
147147
result = run_remote_command(
148-
ep.server, ["test", "-f", rel_path], ep.proxy
148+
ep.server, ["test", "-f", rel_path], ep.proxy_chain
149149
)
150150
return result.returncode == 0
151151

@@ -162,7 +162,7 @@ def _check_command_available(
162162
case RemoteVolume():
163163
ep = resolved_endpoints[volume.slug]
164164
result = run_remote_command(
165-
ep.server, ["which", command], ep.proxy
165+
ep.server, ["which", command], ep.proxy_chain
166166
)
167167
return result.returncode == 0
168168

@@ -182,7 +182,7 @@ def _check_btrfs_filesystem(
182182
)
183183
case RemoteVolume():
184184
ep = resolved_endpoints[volume.slug]
185-
result = run_remote_command(ep.server, cmd, ep.proxy)
185+
result = run_remote_command(ep.server, cmd, ep.proxy_chain)
186186
return result.returncode == 0 and result.stdout.strip() == "btrfs"
187187

188188

@@ -207,7 +207,7 @@ def _check_hardlink_support(
207207
)
208208
case RemoteVolume():
209209
ep = resolved_endpoints[volume.slug]
210-
result = run_remote_command(ep.server, cmd, ep.proxy)
210+
result = run_remote_command(ep.server, cmd, ep.proxy_chain)
211211
if result.returncode != 0:
212212
return True # Cannot determine; assume supported
213213
fs_type = result.stdout.strip()
@@ -234,7 +234,7 @@ def _check_directory_exists(
234234
case RemoteVolume():
235235
ep = resolved_endpoints[volume.slug]
236236
result = run_remote_command(
237-
ep.server, ["test", "-d", path], ep.proxy
237+
ep.server, ["test", "-d", path], ep.proxy_chain
238238
)
239239
return result.returncode == 0
240240

@@ -259,7 +259,7 @@ def _check_btrfs_subvolume(
259259
)
260260
case RemoteVolume():
261261
ep = resolved_endpoints[volume.slug]
262-
result = run_remote_command(ep.server, cmd, ep.proxy)
262+
result = run_remote_command(ep.server, cmd, ep.proxy_chain)
263263
return result.returncode == 0 and result.stdout.strip() == "256"
264264

265265

@@ -279,7 +279,7 @@ def _check_btrfs_mount_option(
279279
)
280280
case RemoteVolume():
281281
ep = resolved_endpoints[volume.slug]
282-
result = run_remote_command(ep.server, cmd, ep.proxy)
282+
result = run_remote_command(ep.server, cmd, ep.proxy_chain)
283283
if result.returncode != 0:
284284
return False
285285
options = result.stdout.strip().split(",")

nbkp/config/protocol.py

Lines changed: 47 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,28 @@ class SshEndpoint(_BaseModel):
105105
default_factory=lambda: SshConnectionOptions()
106106
)
107107
proxy_jump: Optional[str] = None
108+
proxy_jumps: Optional[List[str]] = None
108109
location: Optional[str] = None
109110
extends: Optional[str] = None
110111

112+
@model_validator(mode="after")
113+
def validate_proxy_exclusivity(self) -> SshEndpoint:
114+
if self.proxy_jump is not None and self.proxy_jumps is not None:
115+
raise ValueError(
116+
"proxy-jump and proxy-jumps are mutually exclusive"
117+
)
118+
return self
119+
120+
@property
121+
def proxy_jump_chain(self) -> list[str]:
122+
"""Return the proxy-jump chain as a list of slugs."""
123+
if self.proxy_jumps is not None:
124+
return list(self.proxy_jumps)
125+
elif self.proxy_jump is not None:
126+
return [self.proxy_jump]
127+
else:
128+
return []
129+
111130

112131
class RemoteVolume(_BaseModel):
113132
model_config = ConfigDict(frozen=True)
@@ -265,6 +284,13 @@ def _resolve(slug: str, chain: list[str]) -> Any:
265284
**parent,
266285
**{k: v for k, v in ep.items() if k != "extends"},
267286
}
287+
# If child sets proxy-jump or proxy-jumps, remove
288+
# the other to avoid exclusivity clash with parent
289+
proxy_keys = {"proxy-jump", "proxy-jumps"}
290+
child_proxy_keys = proxy_keys & set(ep.keys())
291+
if child_proxy_keys:
292+
for k in proxy_keys - child_proxy_keys:
293+
merged.pop(k, None)
268294
resolved[slug] = merged
269295
return merged
270296

@@ -379,34 +405,35 @@ def resolve_endpoint_for_volume(
379405
# Deterministic pick: first candidate in original order
380406
return self.ssh_endpoints[reachable[0]]
381407

382-
def resolve_proxy(self, server: SshEndpoint) -> SshEndpoint | None:
383-
"""Resolve the proxy-jump server, if any."""
384-
if server.proxy_jump is not None:
385-
return self.ssh_endpoints[server.proxy_jump]
386-
else:
387-
return None
408+
def resolve_proxy_chain(self, server: SshEndpoint) -> list[SshEndpoint]:
409+
"""Resolve the proxy-jump chain as a list of SshEndpoints."""
410+
return [self.ssh_endpoints[slug] for slug in server.proxy_jump_chain]
388411

389412
@model_validator(mode="after")
390413
def validate_cross_references(self) -> Config:
391414
for slug, server in self.ssh_endpoints.items():
392-
if server.proxy_jump is not None:
393-
if server.proxy_jump not in self.ssh_endpoints:
415+
chain = server.proxy_jump_chain
416+
for hop in chain:
417+
if hop not in self.ssh_endpoints:
394418
raise ValueError(
395419
f"Server '{slug}' references "
396420
f"unknown proxy-jump server "
397-
f"'{server.proxy_jump}'"
421+
f"'{hop}'"
398422
)
399-
visited: set[str] = {slug}
400-
current: str | None = server.proxy_jump
401-
while current is not None:
402-
if current in visited:
403-
raise ValueError(
404-
f"Circular proxy-jump chain "
405-
f"detected starting from "
406-
f"server '{slug}'"
407-
)
408-
visited.add(current)
409-
current = self.ssh_endpoints[current].proxy_jump
423+
# Circular detection via BFS through transitive
424+
# proxy chains
425+
visited: set[str] = {slug}
426+
queue = list(chain)
427+
while queue:
428+
current = queue.pop(0)
429+
if current in visited:
430+
raise ValueError(
431+
f"Circular proxy-jump chain "
432+
f"detected starting from "
433+
f"server '{slug}'"
434+
)
435+
visited.add(current)
436+
queue.extend(self.ssh_endpoints[current].proxy_jump_chain)
410437

411438
for vol_slug, vol in self.volumes.items():
412439
match vol:

nbkp/config/resolution.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
from pydantic import ConfigDict
66

7+
from pydantic import Field
8+
79
from .protocol import (
810
Config,
911
EndpointFilter,
@@ -14,11 +16,11 @@
1416

1517

1618
class ResolvedEndpoint(_BaseModel):
17-
"""Pre-resolved SSH endpoint with optional proxy."""
19+
"""Pre-resolved SSH endpoint with proxy chain."""
1820

1921
model_config = ConfigDict(frozen=True)
2022
server: SshEndpoint
21-
proxy: SshEndpoint | None = None
23+
proxy_chain: list[SshEndpoint] = Field(default_factory=list)
2224

2325

2426
ResolvedEndpoints = dict[str, ResolvedEndpoint]
@@ -40,9 +42,9 @@ def resolve_all_endpoints(
4042
server = config.resolve_endpoint_for_volume(
4143
vol, endpoint_filter
4244
)
43-
proxy = config.resolve_proxy(server)
45+
proxy_chain = config.resolve_proxy_chain(server)
4446
result[vol.slug] = ResolvedEndpoint(
4547
server=server,
46-
proxy=proxy,
48+
proxy_chain=proxy_chain,
4749
)
4850
return result

nbkp/output.py

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
)
2626
from .sync import PruneResult, SyncResult
2727
from .check import SyncReason, SyncStatus, VolumeReason, VolumeStatus
28+
from .remote.ssh import format_proxy_jump_chain
2829

2930

3031
class OutputFormat(str, enum.Enum):
@@ -223,13 +224,18 @@ def print_human_prune_results(
223224
console.print(table)
224225

225226

226-
def _ssh_prefix(server: SshEndpoint) -> str:
227+
def _ssh_prefix(
228+
server: SshEndpoint,
229+
proxy_chain: list[SshEndpoint] | None = None,
230+
) -> str:
227231
"""Build human-friendly SSH command prefix."""
228232
parts = ["ssh"]
229233
if server.port != 22:
230234
parts.extend(["-p", str(server.port)])
231235
if server.key:
232236
parts.extend(["-i", server.key])
237+
if proxy_chain:
238+
parts.extend(["-J", format_proxy_jump_chain(proxy_chain)])
233239
host = f"{server.user}@{server.host}" if server.user else server.host
234240
parts.append(host)
235241
return " ".join(parts)
@@ -246,7 +252,8 @@ def _wrap_cmd(
246252
return cmd
247253
case RemoteVolume():
248254
ep = resolved_endpoints[vol.slug]
249-
return f"{_ssh_prefix(ep.server)} '{cmd}'"
255+
prefix = _ssh_prefix(ep.server, ep.proxy_chain)
256+
return f"{prefix} '{cmd}'"
250257

251258

252259
def _endpoint_path(
@@ -343,12 +350,17 @@ def _print_marker_fix(
343350
def _print_ssh_troubleshoot(
344351
console: Console,
345352
server: SshEndpoint,
353+
proxy_chain: list[SshEndpoint] | None = None,
346354
) -> None:
347355
"""Print SSH connectivity troubleshooting instructions."""
348356
p2 = _INDENT * 2
349357
p3 = _INDENT * 3
350-
ssh_cmd = _ssh_prefix(server)
358+
ssh_cmd = _ssh_prefix(server, proxy_chain)
351359
port_flag = f"-p {server.port} " if server.port != 22 else ""
360+
proxy_opt = ""
361+
if proxy_chain:
362+
jump_str = format_proxy_jump_chain(proxy_chain)
363+
proxy_opt = f"-o ProxyJump={jump_str} "
352364
user_host = f"{server.user}@{server.host}" if server.user else server.host
353365
console.print(f"{p2}Server {server.host} is unreachable.")
354366
console.print(f"{p2}Verify connectivity:")
@@ -360,7 +372,8 @@ def _print_ssh_troubleshoot(
360372
console.print(f"{p3}2. Copy it to the server:")
361373
_print_cmd(
362374
console,
363-
f"ssh-copy-id {port_flag}" f"-i {server.key} {user_host}",
375+
f"ssh-copy-id {proxy_opt}{port_flag}"
376+
f"-i {server.key} {user_host}",
364377
indent=4,
365378
)
366379
else:
@@ -369,7 +382,7 @@ def _print_ssh_troubleshoot(
369382
console.print(f"{p3}2. Copy it to the server:")
370383
_print_cmd(
371384
console,
372-
f"ssh-copy-id {port_flag}{user_host}",
385+
f"ssh-copy-id {proxy_opt}{port_flag}" f"{user_host}",
373386
indent=4,
374387
)
375388
console.print(f"{p3}3. Verify passwordless login:")
@@ -393,7 +406,11 @@ def _print_sync_reason_fix(
393406
match src:
394407
case RemoteVolume():
395408
ep = resolved_endpoints[src.slug]
396-
_print_ssh_troubleshoot(console, ep.server)
409+
_print_ssh_troubleshoot(
410+
console,
411+
ep.server,
412+
ep.proxy_chain,
413+
)
397414
case LocalVolume():
398415
console.print(
399416
f"{p2}Source volume"
@@ -405,7 +422,11 @@ def _print_sync_reason_fix(
405422
match dst:
406423
case RemoteVolume():
407424
ep = resolved_endpoints[dst.slug]
408-
_print_ssh_troubleshoot(console, ep.server)
425+
_print_ssh_troubleshoot(
426+
console,
427+
ep.server,
428+
ep.proxy_chain,
429+
)
409430
case LocalVolume():
410431
console.print(
411432
f"{p2}Destination volume"
@@ -566,7 +587,11 @@ def print_human_troubleshoot(
566587
match vol:
567588
case RemoteVolume():
568589
ep = re[vol.slug]
569-
_print_ssh_troubleshoot(console, ep.server)
590+
_print_ssh_troubleshoot(
591+
console,
592+
ep.server,
593+
ep.proxy_chain,
594+
)
570595

571596
for ss in failed_syncs:
572597
console.print(f"\n[bold]Sync {ss.slug!r}:[/bold]")
@@ -621,7 +646,7 @@ def print_human_config(
621646
str(server.port),
622647
server.user or "",
623648
server.key or "",
624-
server.proxy_jump or "",
649+
", ".join(server.proxy_jump_chain) or "",
625650
server.location or "",
626651
)
627652

nbkp/remote/fabricssh.py

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@
1414
from .ssh import format_remote_path as format_remote_path # noqa: F401
1515

1616

17-
def _build_connection(
17+
def _build_single_connection(
1818
server: SshEndpoint,
19-
proxy_server: SshEndpoint | None = None,
19+
gateway: Connection | None = None,
2020
) -> Connection:
21-
"""Build a Fabric Connection from server config."""
21+
"""Build a single Fabric Connection with optional gateway."""
2222
opts = server.connection_options
2323
connect_kwargs: dict[str, object] = {
2424
"allow_agent": opts.allow_agent,
@@ -36,10 +36,6 @@ def _build_connection(
3636
if server.key:
3737
connect_kwargs["key_filename"] = server.key
3838

39-
gateway: Connection | None = None
40-
if proxy_server is not None:
41-
gateway = _build_connection(proxy_server)
42-
4339
conn = Connection(
4440
host=server.host,
4541
port=server.port,
@@ -56,14 +52,25 @@ def _build_connection(
5652
return conn
5753

5854

55+
def _build_connection(
56+
server: SshEndpoint,
57+
proxy_chain: list[SshEndpoint] | None = None,
58+
) -> Connection:
59+
"""Build a Fabric Connection with optional proxy chain."""
60+
gateway: Connection | None = None
61+
for proxy in proxy_chain or []:
62+
gateway = _build_single_connection(proxy, gateway)
63+
return _build_single_connection(server, gateway)
64+
65+
5966
def run_remote_command(
6067
server: SshEndpoint,
6168
command: list[str],
62-
proxy_server: SshEndpoint | None = None,
69+
proxy_chain: list[SshEndpoint] | None = None,
6370
) -> subprocess.CompletedProcess[str]:
6471
"""Run a command on a remote host via Fabric."""
6572
cmd_string = " ".join(shlex.quote(arg) for arg in command)
66-
with _build_connection(server, proxy_server) as conn:
73+
with _build_connection(server, proxy_chain) as conn:
6774
if server.connection_options.server_alive_interval is not None:
6875
conn.transport.set_keepalive(
6976
server.connection_options.server_alive_interval

0 commit comments

Comments
 (0)