Skip to content

Commit 36768ee

Browse files
committed
tailscale: ssh helpers and example
Provide a happy-path integration with `russh` and `ratatui` that makes it easier to build in-process SSH TUIs with tailscale-rs. Signed-off-by: Nathan Perry <nathan@tailscale.com> Change-Id: I1c49b666d92dcc76076e35585ae4fe7e6a6a6964
1 parent fb46c0b commit 36768ee

10 files changed

Lines changed: 2828 additions & 144 deletions

File tree

Cargo.lock

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

Cargo.toml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,11 @@ ts_netstack_smoltcp = { workspace = true, features = ["tokio"] }
205205
ts_keys.workspace = true
206206

207207
axum = { version = "0.8", optional = true }
208+
bytes = { workspace = true, optional = true }
208209
rand.workspace = true
210+
ratatui = { version = "0.30", optional = true }
211+
# exclude rsa to avoid https://rustsec.org/advisories/RUSTSEC-2023-0071
212+
russh = { version = "0.60", optional = true, default-features = false, features = ["flate2", "aws-lc-rs"] }
209213
serde.workspace = true
210214
serde_json.workspace = true
211215
thiserror.workspace = true
@@ -215,8 +219,10 @@ url.workspace = true
215219

216220
[dev-dependencies]
217221
# Dependencies for examples
218-
clap = { workspace = true, features = ["derive", "env"] }
222+
clap = { workspace = true, features = ["derive", "env", "string"] }
223+
chrono.workspace = true
219224
include_dir = "0.7"
225+
itertools.workspace = true
220226
mime_guess = "2.0"
221227
tokio = { workspace = true, features = ["full"] }
222228
tracing = { workspace = true, features = ["release_max_level_info"] }
@@ -233,6 +239,7 @@ workspace = true
233239
# Enable the `axum` module, which enables you to run an `axum` HTTP server on top of a tailscale TCP
234240
# listener.
235241
axum = ["dep:axum"]
242+
ssh = ["dep:russh", "dep:bytes", "dep:ratatui"]
236243

237244
[[example]]
238245
name = "axum"
@@ -243,3 +250,7 @@ name = "peer_ping"
243250

244251
[[example]]
245252
name = "tcp_echo"
253+
254+
[[example]]
255+
name = "ssh_peer_lookup"
256+
required-features = ["ssh"]

examples/ssh_peer_lookup/README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# example: ssh peer lookup
2+
3+
Run an SSH server hosting a TUI that lets connecting clients look up info about peers in the
4+
tailnet.
5+
6+
Please be aware there's currently no auth checking implemented beyond what's globally provided by
7+
tailscale-rs and network packet filters. The ssh policy file block is not consulted.
8+
9+
The server key is randomized on each start, so you will likely want to connect using
10+
`-o StrictHostKeyChecking=no`.
11+
12+
## Example usage
13+
14+
```shell
15+
$ cargo run --example ssh_peer_lookup --features ssh -- -k $MY_AUTH_KEY -c $MY_CONFIG_FILE
16+
...
17+
INFO tailscale::ssh: ssh server listening listen_addr=$TAILNET_IP:1234
18+
...
19+
20+
# in another terminal:
21+
$ ssh $TAILNET_IP -p 1234 -o StrictHostKeyChecking=no
22+
```

examples/ssh_peer_lookup/main.rs

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
//! Run an SSH server hosting a custom TUI console that lets clients look up info about
2+
//! peers in the tailnet.
3+
4+
use std::{collections::VecDeque, net::IpAddr, path::PathBuf, sync::Arc};
5+
6+
use chrono::Datelike;
7+
use clap::Parser;
8+
use itertools::Itertools;
9+
use ratatui::{
10+
Frame,
11+
layout::{Constraint, Layout},
12+
macros::span,
13+
prelude::{Line, Span},
14+
style::{Style, Stylize},
15+
text::{Text, ToSpan},
16+
widgets::{Block, List, ListItem, Paragraph},
17+
};
18+
use russh::keys::Algorithm;
19+
use tailscale::ssh;
20+
use tracing_subscriber::filter::LevelFilter;
21+
use ts_control::Node;
22+
23+
/// Run an SSH server running a custom console over the tailnet supporting peer ip lookups.
24+
///
25+
/// This does _no_ authentication -- anyone on the tailnet permitted to talk to the relevant
26+
/// port can connect.
27+
#[derive(clap::Parser)]
28+
#[command(version, about)]
29+
struct Args {
30+
/// Path to a key file to use. Will be created if it doesn't exist.
31+
#[arg(short = 'c', long, default_value = "tsrs_keys.json")]
32+
key_file: PathBuf,
33+
34+
/// The auth key to connect with.
35+
///
36+
/// Can be omitted if the key file is already authenticated.
37+
#[arg(short = 'k', long)]
38+
auth_key: Option<String>,
39+
40+
/// Port to listen on (on tailnet IPv4).
41+
#[clap(short, long, default_value_t = 1234)]
42+
listen_port: u16,
43+
}
44+
45+
#[derive(Default)]
46+
struct PeerLookupTui {
47+
input_state: String,
48+
messages: VecDeque<(String, Option<Node>)>,
49+
}
50+
51+
impl ssh::RatatuiApp for PeerLookupTui {
52+
async fn input(&mut self, data: &[u8], env: impl ssh::RatatuiEnv) {
53+
let new_data = String::from_utf8_lossy(data);
54+
55+
// NOTE(npry): this is essentially a manual terminal event parser. Ideally we'd hook this up
56+
// to one of the terminal crates' existing parsers, but none of them expose it. `crossterm`
57+
// (which we're using as our backend) has all the machinery to do it, but it's not exposed
58+
// as part of their API; instead, it's hardcoded to a system-specific implementation.
59+
//
60+
// Issue tracking this (https://github.com/crossterm-rs/crossterm/issues/694) has been open
61+
// since 2022.
62+
for c in new_data.chars() {
63+
match c {
64+
// ^C, ^D
65+
'\u{3}' | '\u{4}' => {
66+
tracing::debug!("got ^C or ^D, closing terminal");
67+
env.close().await;
68+
return;
69+
}
70+
71+
// BKSP
72+
'\u{8}' | '\u{7f}' => {
73+
if let Some((idx, _)) = self.input_state.char_indices().next_back() {
74+
self.input_state.truncate(idx);
75+
}
76+
}
77+
78+
// ESC
79+
'\u{1b}' => {
80+
// punt, not implementing a full control sequence parser here
81+
}
82+
83+
'\r' | '\n' => {
84+
let line = core::mem::take(&mut self.input_state);
85+
if line.is_empty() {
86+
continue;
87+
}
88+
89+
tracing::trace!(query = line);
90+
91+
let peer = env.tailscale().peer_by_name(&line).await.ok().flatten();
92+
93+
self.messages.truncate(31);
94+
self.messages.push_front((line, peer));
95+
}
96+
c if !c.is_control() => {
97+
self.input_state.push(c);
98+
}
99+
_ignore => {}
100+
}
101+
}
102+
}
103+
104+
fn draw(&mut self, frame: &mut Frame) {
105+
let layout = Layout::vertical([Constraint::Length(3), Constraint::Min(1)]);
106+
107+
let [input_area, msg_area] = frame.area().layout(&layout);
108+
109+
let input = Paragraph::new(Line::from_iter([
110+
Span::raw(&self.input_state),
111+
'█'.slow_blink(),
112+
]))
113+
.style(Style::default())
114+
.block(Block::bordered().title("peer query"));
115+
116+
frame.render_widget(input, input_area);
117+
118+
#[allow(unstable_name_collisions)]
119+
let messages = self
120+
.messages
121+
.iter()
122+
.map(|(query, node)| ListItem::new(render_node(query, node.as_ref())))
123+
.intersperse(ListItem::new(""))
124+
.collect::<Vec<_>>();
125+
126+
let messages = List::new(messages).block(Block::bordered().title("results"));
127+
128+
frame.render_widget(messages, msg_area);
129+
}
130+
}
131+
132+
fn render_node<'a>(query: &'a str, node: Option<&'a Node>) -> Text<'a> {
133+
let Some(node) = node else {
134+
return Text::from_iter([Line::from_iter([
135+
span!(Style::new().red().bold(); "{query}"),
136+
span!(": no match"),
137+
])]);
138+
};
139+
140+
let mut text = Text::from_iter([
141+
Line::from_iter([
142+
span!(Style::new().green().bold(); "{} ", node.fqdn(false)),
143+
span!("({})", node.stable_id.0),
144+
":".into(),
145+
]),
146+
Line::from_iter([
147+
"ipv4: ".into(),
148+
node.tailnet_address.ipv4.to_span().light_cyan(),
149+
]),
150+
Line::from_iter([
151+
"ipv6: ".into(),
152+
node.tailnet_address.ipv6.to_span().light_cyan(),
153+
]),
154+
Line::from_iter([
155+
"node key: ".into(),
156+
node.node_key.to_span().yellow(),
157+
" (expires ".into(),
158+
if let Some(nk) = &node.node_key_expiry {
159+
span!("{}/{}/{}", nk.year(), nk.month(), nk.day())
160+
} else {
161+
"never".red()
162+
},
163+
")".into(),
164+
]),
165+
]);
166+
167+
if let Some(disco_key) = &node.disco_key {
168+
text.push_line(Line::from_iter([
169+
"disco key: ".into(),
170+
disco_key.to_span().yellow(),
171+
]));
172+
}
173+
174+
if let Some(derp_region) = &node.derp_region {
175+
text.push_line(Line::from_iter([
176+
"derp region: ".into(),
177+
derp_region.to_span().light_cyan(),
178+
]));
179+
}
180+
181+
if !node.tags.is_empty() {
182+
let mut line = Line::raw("tags: ");
183+
184+
#[allow(unstable_name_collisions)]
185+
line.extend(
186+
node.tags
187+
.iter()
188+
.map(Span::raw)
189+
.map(|span| span.light_blue())
190+
.intersperse(Span::raw(", ")),
191+
);
192+
193+
text.push_line(line);
194+
}
195+
196+
text
197+
}
198+
199+
#[tokio::main(flavor = "multi_thread")]
200+
async fn main() -> Result<(), Box<dyn core::error::Error>> {
201+
tracing_subscriber::fmt()
202+
.with_env_filter(
203+
tracing_subscriber::EnvFilter::builder()
204+
.with_default_directive(LevelFilter::INFO.into())
205+
.from_env_lossy(),
206+
)
207+
.init();
208+
209+
let args = Args::parse();
210+
211+
let dev = tailscale::Device::new(
212+
&tailscale::Config::default_with_key_file(&args.key_file).await?,
213+
args.auth_key,
214+
)
215+
.await?;
216+
217+
let ipv4: IpAddr = dev.ipv4_addr().await?.into();
218+
let dev = Arc::new(dev);
219+
220+
dev.serve_ssh_tui::<PeerLookupTui>(
221+
russh::server::Config {
222+
keys: vec![
223+
russh::keys::PrivateKey::random(&mut rand::rng(), Algorithm::Ed25519).unwrap(),
224+
],
225+
methods: russh::MethodSet::from(&[russh::MethodKind::None][..]),
226+
nodelay: true,
227+
..Default::default()
228+
},
229+
(ipv4, args.listen_port).into(),
230+
)
231+
.await?;
232+
233+
Ok(())
234+
}

flake.nix

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,8 +244,25 @@
244244
fmt = pkgs.craneLibNightlyFmt.cargoFmt common;
245245

246246
# Consults rustsec advisory db for reported vulnerabilities in dependencies
247+
#
248+
# We run this in addition to `cargo deny` because the deny check can't download the advisory
249+
# db inside Nix -- for whatever reason, `cargo audit` has that hooked up in crane, while
250+
# `deny` doesn't. Notably, the behavior of `cargo audit` appears to deviate from that of
251+
# `deny`, so we ignore some advisories here which are known to be fine when checked via
252+
# `deny`.
247253
audit = pkgs.craneLib.cargoAudit (common // {
248254
advisory-db = inputs.rust-advisory-db;
255+
256+
cargoAuditExtraArgs = let
257+
ignored = [
258+
# default ignore in crane
259+
"yanked"
260+
261+
# old version of `rsa` used through russh, feature is turned off (deny doesn't mind)
262+
"RUSTSEC-2023-0071"
263+
];
264+
265+
in "--ignore " + (builtins.concatStringsSep " --ignore " ignored);
249266
});
250267

251268
# This does the same as `cargo test`, it's just a pretty harness

src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,8 @@ use ts_netstack_smoltcp::{CreateSocket, netcore::Channel};
142142
pub mod axum;
143143
pub mod config;
144144
mod error;
145+
#[cfg(feature = "ssh")]
146+
pub mod ssh;
145147

146148
/// How a program connects to a tailnet and communicates with peers.
147149
///

0 commit comments

Comments
 (0)