Skip to content

Commit 77140bc

Browse files
committed
Add opt-in remote shell
1 parent 1eed79c commit 77140bc

10 files changed

Lines changed: 817 additions & 61 deletions

File tree

Cargo.lock

Lines changed: 185 additions & 21 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
[package]
22
name = "fabric"
3-
version = "0.1.3"
3+
version = "0.1.4"
44
edition = "2024"
55

66
[dependencies]
77
anyhow = "1"
88
clap = { version = "4", features = ["derive"] }
9+
crossterm = "0.29.0"
910
iroh = "1.0.2"
11+
portable-pty = "0.9.0"
1012
rand = "0.10.2"
1113
serde = { version = "1", features = ["derive"] }
1214
serde_json = "1"

README.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,14 +134,17 @@ fabric remove <nodeid-or-name>
134134
Remove a trusted peer.
135135

136136
```sh
137-
fabric up [--foreground]
137+
fabric up [--foreground] [--allow-shell]
138138
```
139139

140140
Start the local fabric daemon. Without `--foreground`, this spawns a background
141141
daemon and logs to `<home>/logs/daemon.log`. After the daemon is ready, `fabric
142142
up` runs the same echo-ping reachability check used by `fabric status` and
143143
prints one line per trusted peer.
144144

145+
`--allow-shell` opts this daemon into serving remote shells for trusted peers.
146+
It is off by default.
147+
145148
```sh
146149
fabric down
147150
```
@@ -179,6 +182,20 @@ back, and prints the round-trip latency. When available, it also reports whether
179182
iroh used a direct, relay, or mixed path. Use this first when bringing up a new
180183
machine.
181184

185+
```sh
186+
fabric shell <peer>
187+
```
188+
189+
Open an interactive remote shell on a trusted peer over fabric. The server side
190+
must have been started with `fabric up --allow-shell`; a default `fabric up`
191+
refuses shell requests. The shell runs as the remote daemon's user and uses the
192+
remote user's `$SHELL`.
193+
194+
Enabling shell is a security-sensitive opt-in: every trusted peer in
195+
`peers.toml` can obtain a remote shell while `--allow-shell` is active. Keep the
196+
allow-list tight, enable shell only on machines where that access is intended,
197+
and stop/restart the daemon without `--allow-shell` to turn it back off.
198+
182199
## Declarative Peer Config
183200

184201
`peers.toml` is intentionally human-editable. The minimal form is:

src/config.rs

Lines changed: 8 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use std::{
2-
collections::{HashMap, HashSet},
2+
collections::{HashMap, HashSet, hash_map::DefaultHasher},
33
env, fs,
4+
hash::{Hash, Hasher},
45
io::Write,
56
path::{Path, PathBuf},
67
str::FromStr,
@@ -91,10 +92,10 @@ impl FabricHome {
9192

9293
pub fn dial_socket_path(&self, peer: EndpointId, protocol: &str) -> PathBuf {
9394
let peer = peer.to_string();
94-
let short_peer = &peer[..peer.len().min(12)];
95+
let short_peer = &peer[..peer.len().min(8)];
9596
self.root
9697
.join("dials")
97-
.join(format!("{}-{}.sock", short_peer, safe_component(protocol)))
98+
.join(format!("{}-{:08x}.sock", short_peer, short_hash(protocol)))
9899
}
99100
}
100101

@@ -311,20 +312,8 @@ pub fn validate_protocol(protocol: &str) -> Result<Vec<u8>> {
311312
Ok(protocol.as_bytes().to_vec())
312313
}
313314

314-
fn safe_component(input: &str) -> String {
315-
let out: String = input
316-
.chars()
317-
.map(|ch| {
318-
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
319-
ch
320-
} else {
321-
'_'
322-
}
323-
})
324-
.collect();
325-
if out.is_empty() {
326-
"protocol".to_string()
327-
} else {
328-
out
329-
}
315+
fn short_hash(input: &str) -> u64 {
316+
let mut hasher = DefaultHasher::new();
317+
input.hash(&mut hasher);
318+
hasher.finish()
330319
}

src/control.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ pub enum ControlRequest {
1111
Expose { protocol: String, socket: PathBuf },
1212
Dial { peer: String, protocol: String },
1313
Ping { peer: String },
14+
Shell { peer: String },
1415
Shutdown,
1516
}
1617

@@ -34,6 +35,9 @@ pub enum ControlResponse {
3435
Dial {
3536
socket: PathBuf,
3637
},
38+
Shell {
39+
socket: PathBuf,
40+
},
3741
Pong {
3842
peer: String,
3943
bytes: usize,

src/daemon.rs

Lines changed: 54 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ use tokio_util::sync::CancellationToken;
2828
use crate::{
2929
config::{FabricHome, Peer, PeerBook, load_or_create_identity, validate_protocol},
3030
control::{ControlRequest, ControlResponse, PeerReachability},
31+
shell,
3132
};
3233

3334
const BUILTIN_ECHO_ALPN: &[u8] = b"fabric/echo/0";
@@ -64,18 +65,23 @@ pub struct DaemonState {
6465
exposures: RwLock<HashMap<Vec<u8>, PathBuf>>,
6566
dial_sockets: Mutex<HashMap<(String, String), PathBuf>>,
6667
builtin_echo_hits: AtomicUsize,
68+
allow_shell: bool,
6769
cancel: CancellationToken,
6870
}
6971

7072
impl DaemonState {
71-
async fn new(home: FabricHome, cancel: CancellationToken) -> Result<Arc<Self>> {
73+
async fn new(
74+
home: FabricHome,
75+
cancel: CancellationToken,
76+
allow_shell: bool,
77+
) -> Result<Arc<Self>> {
7278
home.prepare()?;
7379
let secret_key = load_or_create_identity(&home)?;
7480
let peer_book = PeerBook::load(&home)?;
7581
let allowed = Arc::new(RwLock::new(peer_book.trusted_ids()));
7682
let endpoint = Endpoint::builder(presets::N0)
7783
.secret_key(secret_key)
78-
.alpns(vec![BUILTIN_ECHO_ALPN.to_vec()])
84+
.alpns(accepted_alpns(&HashMap::new()))
7985
.hooks(AllowListHook {
8086
allowed: allowed.clone(),
8187
})
@@ -92,6 +98,7 @@ impl DaemonState {
9298
exposures: RwLock::new(HashMap::new()),
9399
dial_sockets: Mutex::new(HashMap::new()),
94100
builtin_echo_hits: AtomicUsize::new(0),
101+
allow_shell,
95102
cancel,
96103
}))
97104
}
@@ -113,8 +120,8 @@ impl DaemonState {
113120

114121
pub async fn expose(&self, protocol: &str, socket: PathBuf) -> Result<()> {
115122
let alpn = validate_protocol(protocol)?;
116-
if alpn == BUILTIN_ECHO_ALPN {
117-
bail!("{protocol:?} is reserved for fabric's built-in echo protocol");
123+
if matches_reserved_alpn(&alpn) {
124+
bail!("{protocol:?} is reserved for fabric's built-in protocols");
118125
}
119126
if !socket.is_absolute() {
120127
bail!("expose socket must be an absolute path");
@@ -170,6 +177,10 @@ impl DaemonState {
170177

171178
pub async fn dial(&self, peer: &str, protocol: &str) -> Result<PathBuf> {
172179
let alpn = validate_protocol(protocol)?;
180+
self.dial_alpn(peer, protocol, alpn).await
181+
}
182+
183+
async fn dial_alpn(&self, peer: &str, protocol: &str, alpn: Vec<u8>) -> Result<PathBuf> {
173184
let peer_addr = self.peer_book.read().await.resolve(peer)?;
174185
let key = (peer_addr.id.to_string(), protocol.to_string());
175186

@@ -314,8 +325,12 @@ pub struct FabricNode {
314325

315326
impl FabricNode {
316327
pub async fn start(home: FabricHome) -> Result<Self> {
328+
Self::start_with_options(home, false).await
329+
}
330+
331+
pub async fn start_with_options(home: FabricHome, allow_shell: bool) -> Result<Self> {
317332
let cancel = CancellationToken::new();
318-
let state = DaemonState::new(home, cancel).await?;
333+
let state = DaemonState::new(home, cancel, allow_shell).await?;
319334
let task = tokio::spawn(serve(state.clone()));
320335
Ok(Self { state, task })
321336
}
@@ -354,8 +369,11 @@ impl FabricNode {
354369
}
355370
}
356371

357-
pub async fn run_daemon(home: FabricHome) -> Result<()> {
358-
FabricNode::start(home).await?.wait().await
372+
pub async fn run_daemon(home: FabricHome, allow_shell: bool) -> Result<()> {
373+
FabricNode::start_with_options(home, allow_shell)
374+
.await?
375+
.wait()
376+
.await
359377
}
360378

361379
pub async fn send_control(home: &FabricHome, request: ControlRequest) -> Result<ControlResponse> {
@@ -464,6 +482,12 @@ async fn process_control_request(
464482
transport: pong.transport,
465483
}
466484
}
485+
ControlRequest::Shell { peer } => {
486+
let socket = state
487+
.dial_alpn(&peer, shell::SHELL_PROTOCOL, shell::SHELL_ALPN.to_vec())
488+
.await?;
489+
ControlResponse::Shell { socket }
490+
}
467491
ControlRequest::Shutdown => {
468492
state.cancel.cancel();
469493
ControlResponse::Ok
@@ -501,6 +525,11 @@ async fn process_incoming_iroh(incoming: Incoming, state: Arc<DaemonState>) -> R
501525
handle_builtin_echo(connection, state).await?;
502526
return Ok(());
503527
}
528+
if alpn == shell::SHELL_ALPN {
529+
let connection = accepting.await?;
530+
handle_builtin_shell(connection, state).await?;
531+
return Ok(());
532+
}
504533

505534
let socket = {
506535
let exposures = state.exposures.read().await;
@@ -528,13 +557,30 @@ async fn handle_builtin_echo(connection: Connection, state: Arc<DaemonState>) ->
528557
Ok(())
529558
}
530559

560+
async fn handle_builtin_shell(connection: Connection, state: Arc<DaemonState>) -> Result<()> {
561+
let (mut send, mut recv) = connection.accept_bi().await?;
562+
if state.allow_shell {
563+
shell::serve_shell_session(&mut recv, &mut send).await?;
564+
} else {
565+
shell::serve_shell_disabled(&mut send).await?;
566+
}
567+
send.finish()?;
568+
connection.closed().await;
569+
Ok(())
570+
}
571+
531572
fn accepted_alpns(exposures: &HashMap<Vec<u8>, PathBuf>) -> Vec<Vec<u8>> {
532-
let mut alpns = Vec::with_capacity(exposures.len() + 1);
573+
let mut alpns = Vec::with_capacity(exposures.len() + 2);
533574
alpns.push(BUILTIN_ECHO_ALPN.to_vec());
575+
alpns.push(shell::SHELL_ALPN.to_vec());
534576
alpns.extend(exposures.keys().cloned());
535577
alpns
536578
}
537579

580+
fn matches_reserved_alpn(alpn: &[u8]) -> bool {
581+
alpn == BUILTIN_ECHO_ALPN || alpn == shell::SHELL_ALPN
582+
}
583+
538584
fn classify_connection_transport(connection: &Connection) -> Option<String> {
539585
let paths = connection.paths();
540586
let mut selected_ip = false;

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use iroh::{
88
pub mod config;
99
pub mod control;
1010
pub mod daemon;
11+
pub mod shell;
1112

1213
const SPIKE_ALPN: &[u8] = b"fabric/spike/echo/0";
1314

0 commit comments

Comments
 (0)