Skip to content

Commit 22eae73

Browse files
committed
Restore shell compatibility and skip converged scans
1 parent ed8e7ad commit 22eae73

8 files changed

Lines changed: 894 additions & 91 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,19 @@ EXPERIMENTAL, so on-disk formats and the CLI may change without notice.
6060

6161
### Fixed
6262

63+
- **Remote shell upgrades remain mixed-version compatible.** Resumable shell
64+
sessions now use their own `fabric/shell/1` ALPN. The established
65+
`fabric/shell/0` ALPN keeps its original raw shell framing in both directions,
66+
and new clients explicitly fall back to it when an older peer does not
67+
advertise `shell/1`. Transport reconnect still resumes the same remote PTY,
68+
and signal exits restore the caller's exact terminal settings.
69+
70+
- **Converged periodic syncs no longer hash the full tree twice.** An inbound
71+
peer whose manifest exactly matches the local node can bypass both folder
72+
scans when the local content store is complete. Differing manifests, missing
73+
content, and every potentially mutating reconcile retain the guarded
74+
pre-merge and completion scans that protect local archive/delete intent.
75+
6376
- **Inherited catalog tombstones no longer leave folders divergent.** Under
6477
catalog policy, any surviving physical copy now advances an inherited
6578
Tombstone to a higher Present version and supplies its content over the wire,

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -694,7 +694,10 @@ fabric shell <peer>
694694
Open an interactive remote shell on a trusted peer over fabric. The server side
695695
must have been started with `fabric up --allow-shell`; a default `fabric up`
696696
refuses shell requests. The shell runs as the remote daemon's user and uses the
697-
remote user's `$SHELL`.
697+
remote user's `$SHELL`. Current peers negotiate resumable `fabric/shell/1`, so
698+
the same remote PTY survives a transient transport drop. A new client
699+
automatically falls back to the byte-compatible one-shot `fabric/shell/0`
700+
protocol when the peer is running an older Fabric release.
698701

699702
Enabling shell is a security-sensitive opt-in: every trusted peer in
700703
`peers.toml` can obtain a remote shell while `--allow-shell` is active. Keep the

src/daemon.rs

Lines changed: 191 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -999,10 +999,10 @@ impl DaemonState {
999999
let listener_cancel = CancellationToken::new();
10001000
let lease = DialListenerLease::new(self.active_dial_listeners.clone());
10011001

1002-
// Built-in exec remains a one-shot raw framed stream. Built-in shell
1003-
// rides the resumable tunnel path so its PTY outlives transient iroh
1004-
// attaches; local reconnect notices are encoded as shell status frames.
1005-
let listener_task = if alpn == exec::EXEC_ALPN {
1002+
// Built-in exec and legacy shell/0 remain one-shot raw framed streams.
1003+
// Resumable shell/1 negotiates its own tunnel path and falls back to
1004+
// shell/0 when the peer does not advertise the new ALPN.
1005+
let listener_task = if alpn == exec::EXEC_ALPN || alpn == shell::SHELL_ALPN {
10061006
tokio::spawn(run_raw_dial_socket(
10071007
listener,
10081008
self.endpoint_rx(),
@@ -1014,8 +1014,21 @@ impl DaemonState {
10141014
self.dial_slots.clone(),
10151015
lease,
10161016
))
1017+
} else if alpn == shell::RESUMABLE_SHELL_ALPN {
1018+
tokio::spawn(run_shell_dial_socket(
1019+
listener,
1020+
self.endpoint_rx(),
1021+
self.home.clone(),
1022+
peer.to_string(),
1023+
peer_addr.clone(),
1024+
listener_cancel.clone(),
1025+
self.cancel.clone(),
1026+
self.tunnel_drop_rx(),
1027+
self.dial_failures.clone(),
1028+
self.dial_slots.clone(),
1029+
lease,
1030+
))
10171031
} else {
1018-
let notices = (alpn == shell::SHELL_ALPN).then(shell_client_notices);
10191032
tokio::spawn(run_dial_socket(
10201033
listener,
10211034
self.endpoint_rx(),
@@ -1028,7 +1041,7 @@ impl DaemonState {
10281041
self.dial_failures.clone(),
10291042
self.dial_slots.clone(),
10301043
lease,
1031-
notices,
1044+
None,
10321045
))
10331046
};
10341047
sockets.insert(
@@ -2437,7 +2450,7 @@ async fn process_control_request(
24372450
.dial_alpn(
24382451
&peer,
24392452
shell::SHELL_PROTOCOL,
2440-
shell::SHELL_ALPN.to_vec(),
2453+
shell::RESUMABLE_SHELL_ALPN.to_vec(),
24412454
false,
24422455
)
24432456
.await?;
@@ -2584,8 +2597,14 @@ async fn process_incoming_iroh(incoming: Incoming, state: Arc<DaemonState>) -> R
25842597
}
25852598
if alpn == shell::SHELL_ALPN {
25862599
let connection = accepting.await?;
2587-
log_connection_paths("builtin_shell_accept", &connection);
2588-
handle_builtin_shell(connection, state).await?;
2600+
log_connection_paths("builtin_legacy_shell_accept", &connection);
2601+
handle_builtin_legacy_shell(connection, state).await?;
2602+
return Ok(());
2603+
}
2604+
if alpn == shell::RESUMABLE_SHELL_ALPN {
2605+
let connection = accepting.await?;
2606+
log_connection_paths("builtin_resumable_shell_accept", &connection);
2607+
handle_builtin_resumable_shell(connection, state).await?;
25892608
return Ok(());
25902609
}
25912610
if alpn == exec::EXEC_ALPN {
@@ -2639,7 +2658,26 @@ async fn handle_builtin_echo(connection: Connection, state: Arc<DaemonState>) ->
26392658
Ok(())
26402659
}
26412660

2642-
async fn handle_builtin_shell(connection: Connection, state: Arc<DaemonState>) -> Result<()> {
2661+
async fn handle_builtin_legacy_shell(
2662+
connection: Connection,
2663+
state: Arc<DaemonState>,
2664+
) -> Result<()> {
2665+
let peer = connection.remote_id().to_string();
2666+
let (mut send, mut recv) = connection.accept_bi().await?;
2667+
if state.allow_shell {
2668+
shell::serve_shell_session(&mut recv, &mut send, &peer).await?;
2669+
} else {
2670+
shell::serve_shell_disabled(&mut send).await?;
2671+
}
2672+
send.finish()?;
2673+
connection.closed().await;
2674+
Ok(())
2675+
}
2676+
2677+
async fn handle_builtin_resumable_shell(
2678+
connection: Connection,
2679+
state: Arc<DaemonState>,
2680+
) -> Result<()> {
26432681
let peer_id = connection.remote_id();
26442682
let (send, recv) = connection.accept_bi().await?;
26452683
tunnel::serve_connection(
@@ -2680,10 +2718,12 @@ async fn handle_sync(connection: Connection, state: Arc<DaemonState>) -> Result<
26802718
let (send, recv) = connection.accept_bi().await?;
26812719
let stream = tokio::io::join(recv, send);
26822720
let resolver_engine = engine.clone();
2683-
let outcome = sync::wire::run_server(stream, move |name| {
2721+
let outcome = sync::wire::run_server(stream, move |name, remote_manifest| {
26842722
let engine = resolver_engine.clone();
26852723
async move {
2686-
let prepared = engine.prepare_inbound(&name).await?;
2724+
let prepared = engine
2725+
.prepare_inbound_for_manifest(&name, &remote_manifest)
2726+
.await?;
26872727
Ok(prepared.map(|prepared| (prepared.node(), prepared)))
26882728
}
26892729
})
@@ -2706,9 +2746,10 @@ async fn handle_sync(connection: Connection, state: Arc<DaemonState>) -> Result<
27062746
}
27072747

27082748
fn accepted_alpns(exposures: &HashMap<Vec<u8>, Exposure>) -> Vec<Vec<u8>> {
2709-
let mut alpns = Vec::with_capacity(exposures.len() + 4);
2749+
let mut alpns = Vec::with_capacity(exposures.len() + 5);
27102750
alpns.push(BUILTIN_ECHO_ALPN.to_vec());
27112751
alpns.push(shell::SHELL_ALPN.to_vec());
2752+
alpns.push(shell::RESUMABLE_SHELL_ALPN.to_vec());
27122753
alpns.push(exec::EXEC_ALPN.to_vec());
27132754
alpns.push(SYNC_ALPN.to_vec());
27142755
alpns.extend(exposures.keys().cloned());
@@ -2718,6 +2759,7 @@ fn accepted_alpns(exposures: &HashMap<Vec<u8>, Exposure>) -> Vec<Vec<u8>> {
27182759
fn matches_reserved_alpn(alpn: &[u8]) -> bool {
27192760
alpn == BUILTIN_ECHO_ALPN
27202761
|| alpn == shell::SHELL_ALPN
2762+
|| alpn == shell::RESUMABLE_SHELL_ALPN
27212763
|| alpn == exec::EXEC_ALPN
27222764
|| alpn == SYNC_ALPN
27232765
}
@@ -3133,6 +3175,140 @@ async fn run_dial_socket(
31333175
}
31343176
}
31353177

3178+
async fn run_shell_dial_socket(
3179+
listener: UnixListener,
3180+
endpoint_rx: watch::Receiver<CurrentEndpoint>,
3181+
home: FabricHome,
3182+
peer: String,
3183+
peer_addr: EndpointAddr,
3184+
listener_cancel: CancellationToken,
3185+
daemon_cancel: CancellationToken,
3186+
drop_rx: watch::Receiver<u64>,
3187+
dial_failures: Arc<FailureBackoff>,
3188+
dial_slots: Arc<Semaphore>,
3189+
_lease: DialListenerLease,
3190+
) {
3191+
loop {
3192+
tokio::select! {
3193+
biased;
3194+
_ = listener_cancel.cancelled() => break,
3195+
_ = daemon_cancel.cancelled() => break,
3196+
accepted = listener.accept() => {
3197+
let Ok((local, _)) = accepted else {
3198+
break;
3199+
};
3200+
let permit = tokio::select! {
3201+
biased;
3202+
_ = listener_cancel.cancelled() => break,
3203+
_ = daemon_cancel.cancelled() => break,
3204+
permit = dial_slots.clone().acquire_owned() => {
3205+
let Ok(permit) = permit else {
3206+
break;
3207+
};
3208+
permit
3209+
}
3210+
};
3211+
let endpoint_rx = endpoint_rx.clone();
3212+
let home = home.clone();
3213+
let peer = peer.clone();
3214+
let peer_addr = peer_addr.clone();
3215+
let cancel = daemon_cancel.clone();
3216+
let drop_rx = drop_rx.clone();
3217+
let dial_failures = dial_failures.clone();
3218+
tokio::spawn(async move {
3219+
let _permit = permit;
3220+
if !dial_failures.wait(&cancel).await {
3221+
return;
3222+
}
3223+
match handle_shell_dial_socket_connection(
3224+
local,
3225+
endpoint_rx,
3226+
home,
3227+
peer,
3228+
peer_addr,
3229+
cancel,
3230+
drop_rx,
3231+
)
3232+
.await
3233+
{
3234+
Ok(()) => dial_failures.record_success().await,
3235+
Err(error) => {
3236+
dial_failures
3237+
.record_failure("shell dial socket connection failed", &error)
3238+
.await;
3239+
}
3240+
}
3241+
});
3242+
}
3243+
}
3244+
}
3245+
}
3246+
3247+
async fn handle_shell_dial_socket_connection(
3248+
local: UnixStream,
3249+
endpoint_rx: watch::Receiver<CurrentEndpoint>,
3250+
home: FabricHome,
3251+
peer: String,
3252+
peer_addr: EndpointAddr,
3253+
cancel: CancellationToken,
3254+
drop_rx: watch::Receiver<u64>,
3255+
) -> Result<()> {
3256+
let endpoint = endpoint_rx.borrow().endpoint.clone();
3257+
match endpoint
3258+
.connect(peer_addr.clone(), shell::RESUMABLE_SHELL_ALPN)
3259+
.await
3260+
{
3261+
Ok(connection) => {
3262+
tunnel::run_client_connection_with_initial(
3263+
local,
3264+
endpoint_rx,
3265+
home,
3266+
peer,
3267+
shell::RESUMABLE_SHELL_ALPN.to_vec(),
3268+
cancel,
3269+
drop_rx,
3270+
Some(shell_client_notices()),
3271+
connection,
3272+
)
3273+
.await
3274+
}
3275+
Err(error) => {
3276+
let error = anyhow::Error::new(error);
3277+
if !shell_resumable_alpn_unsupported(&error) {
3278+
// A transient initial failure should retain the resumable
3279+
// attach loop and its backoff. Only a definitive ALPN mismatch
3280+
// is allowed to downgrade the session to legacy shell/0.
3281+
return tunnel::run_client_connection(
3282+
local,
3283+
endpoint_rx,
3284+
home,
3285+
peer,
3286+
shell::RESUMABLE_SHELL_ALPN.to_vec(),
3287+
cancel,
3288+
drop_rx,
3289+
Some(shell_client_notices()),
3290+
)
3291+
.await;
3292+
}
3293+
handle_raw_dial_socket_connection(
3294+
local,
3295+
endpoint,
3296+
peer_addr,
3297+
shell::SHELL_ALPN.to_vec(),
3298+
)
3299+
.await
3300+
}
3301+
}
3302+
}
3303+
3304+
fn shell_resumable_alpn_unsupported(error: &anyhow::Error) -> bool {
3305+
error.chain().any(|cause| {
3306+
let message = cause.to_string().to_ascii_lowercase();
3307+
message.contains("peer doesn't support any known protocol")
3308+
|| message.contains("no application protocol")
3309+
})
3310+
}
3311+
31363312
fn shell_client_notices() -> tunnel::ClientConnectionNotices {
31373313
tunnel::ClientConnectionNotices::new(|event| {
31383314
let encoded = match event {
@@ -3757,9 +3933,9 @@ mod tests {
37573933
let state = node.state();
37583934
let cases = [
37593935
("peer-a", exec::EXEC_PROTOCOL, exec::EXEC_ALPN),
3760-
("peer-a", shell::SHELL_PROTOCOL, shell::SHELL_ALPN),
3936+
("peer-a", shell::SHELL_PROTOCOL, shell::RESUMABLE_SHELL_ALPN),
37613937
("peer-b", exec::EXEC_PROTOCOL, exec::EXEC_ALPN),
3762-
("peer-b", shell::SHELL_PROTOCOL, shell::SHELL_ALPN),
3938+
("peer-b", shell::SHELL_PROTOCOL, shell::RESUMABLE_SHELL_ALPN),
37633939
];
37643940

37653941
// More replacements than the production macOS soft FD limit. The old

src/shell.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,12 @@ use tokio::{
1111
};
1212
use tokio_util::sync::CancellationToken;
1313

14+
/// Legacy one-shot shell framing. This ALPN is wire-compatible with every
15+
/// released Fabric shell and must never carry generic tunnel frames.
1416
pub const SHELL_ALPN: &[u8] = b"fabric/shell/0";
1517
pub const SHELL_PROTOCOL: &str = "fabric/shell/0";
18+
/// Resumable shell framing carried by the generic tunnel session protocol.
19+
pub const RESUMABLE_SHELL_ALPN: &[u8] = b"fabric/shell/1";
1620

1721
const MAX_FRAME_LEN: usize = 1024 * 1024;
1822
const CLIENT_STDIN: u8 = 1;

0 commit comments

Comments
 (0)