Add public comparison benchmarks and SSH import
ci / test (push) Has been cancelled
ci / remote-bench (push) Has been cancelled

This commit is contained in:
DuProcess
2026-06-12 14:27:58 -04:00
parent 063c44d971
commit 3a38a9da80
8 changed files with 422 additions and 28 deletions
+4 -1
View File
@@ -1,4 +1,4 @@
.PHONY: build test fmt install bench-local bench-docker-ssh .PHONY: build test fmt install bench-local bench-docker-ssh bench-docker-mosh
build: build:
cargo build --release cargo build --release
@@ -23,3 +23,6 @@ bench-local:
bench-docker-ssh: bench-docker-ssh:
sh scripts/ci-docker-ssh-bench.sh sh scripts/ci-docker-ssh-bench.sh
bench-docker-mosh:
DOSH_BENCH_INCLUDE_MOSH=1 sh scripts/ci-docker-ssh-bench.sh
+24 -3
View File
@@ -134,9 +134,10 @@ dosh --session logs palav
Press `Ctrl-]` to detach the current client while leaving the server session alive. Press `Ctrl-]` to detach the current client while leaving the server session alive.
Typing `exit` in the remote shell closes that Dosh session and returns the client. Typing `exit` in the remote shell closes that Dosh session and returns the client.
If UDP packets stop arriving, Dosh shows a blue status bar at the top of the If UDP packets stop arriving, Dosh keeps the terminal open, sends keepalive pings,
terminal with the disconnected duration and sends keepalive pings until traffic and attempts ticket-based reconnect. The old in-band status overlay was removed
returns. because writing directly over the terminal could corrupt TUIs; a non-destructive
status surface is tracked as a public-readiness item.
If SSH and UDP use different public names, specify the UDP address: If SSH and UDP use different public names, specify the UDP address:
@@ -144,6 +145,15 @@ If SSH and UDP use different public names, specify the UDP address:
dosh-client --dosh-host public.example.com --dosh-port 50000 user@host dosh-client --dosh-host public.example.com --dosh-port 50000 user@host
``` ```
Dosh already lets OpenSSH handle SSH aliases, users, keys, ports, known-hosts,
ProxyJump, and other SSH config during bootstrap. It also runs `ssh -G` to infer the
UDP host from an SSH alias when `dosh_host` is not configured. To make that explicit
in Dosh's host config:
```bash
dosh import-ssh palav homelab
```
## Develop ## Develop
Build: Build:
@@ -184,6 +194,17 @@ and ControlMaster-backed SSH bootstrap against a containerized `sshd` plus resid
make bench-docker-ssh make bench-docker-ssh
``` ```
Run the same Docker comparison with Mosh installed in the benchmark container:
```bash
make bench-docker-mosh
```
That prints `ssh_true_ms`, `dosh_attach_ms`, and `mosh_start_true_ms` under the same
container, key, DNS, and network path. See `docs/PUBLIC_READINESS.md` before using
the numbers publicly; Dosh's current strongest claim is fast attach/reconnect, not
full Mosh feature parity yet.
The CI workflow includes an optional remote benchmark job. It runs when The CI workflow includes an optional remote benchmark job. It runs when
`DOSH_BENCH_HOST`, `DOSH_BENCH_USER`, and `DOSH_BENCH_SSH_KEY` repository secrets are `DOSH_BENCH_HOST`, `DOSH_BENCH_USER`, and `DOSH_BENCH_SSH_KEY` repository secrets are
configured. configured.
+110
View File
@@ -0,0 +1,110 @@
# Dosh Public Readiness
Dosh's defensible public claim is fast terminal attach and reconnect. It should not
claim full Mosh replacement status until the feature matrix below is green and the
comparison benchmark is reproducible outside the author's homelab.
## Objective Benchmarks
Run the same-host SSH comparison:
```bash
make bench-docker-ssh
```
Run the Dosh, SSH, and Mosh comparison:
```bash
make bench-docker-mosh
```
The Mosh-inclusive target builds one Docker image with OpenSSH, Mosh, `dosh-server`,
and `dosh-auth`. It uses the same generated key, localhost network path, SSH server,
and container for all samples.
Reported metrics:
| Metric | Meaning |
| --- | --- |
| `ssh_true_ms` | Time to run `ssh host true` with the same key/options. |
| `dosh_attach_ms` | Time for `dosh-client --attach-only` to render the first frame and detach. |
| `mosh_start_true_ms` | Time for `mosh host -- true` to bootstrap, run `true`, and exit. |
These are not identical workloads. They are still useful because they measure the
startup path each tool must pay before useful remote work begins. Public numbers
must include the command, machine, OS, CPU, network path, and sample count.
## Feature Matrix
| Feature | Mosh | Dosh now | Public status |
| --- | --- | --- | --- |
| SSH-based first authentication | yes | yes | ready |
| Encrypted UDP terminal data | yes | yes | ready |
| Roaming by client address change | yes | yes | needs more hostile-network tests |
| Survive sleep or network loss | yes | yes | needs long-running soak tests |
| Fast repeat attach without SSH | no | yes, via attach tickets | core differentiator |
| Resident server daemon | no | yes | core differentiator |
| One UDP port for all sessions | port range by default | yes | ready |
| Fresh session by default | yes | yes | ready |
| Named persistent sessions | no built-in shared session model | yes | ready |
| Multiple clients on one session | no | yes | needs conflict-policy docs |
| View-only clients | no | yes | ready |
| Full-screen TUI correctness | yes | improving | must stay green before public push |
| Predictive local echo | mature | guarded printable-only opt-in | not parity |
| Non-destructive disconnect UI | yes | not currently | needed |
| Unicode edge-case handling | strong | basic terminal emulator dependent | not parity |
| X11 forwarding | no | no | non-goal unless tunneled separately |
| SSH agent forwarding | no | no | planned as forwarding channel |
| Local TCP forwarding, `-L` | no | not implemented | planned |
| Remote TCP forwarding, `-R` | no | not implemented | planned |
| Dynamic SOCKS forwarding, `-D` | no | not implemented | later |
## SSH Config Inheritance
Dosh intentionally delegates SSH parsing and authentication to OpenSSH. Bootstrap
uses the user's normal `ssh` command, so `~/.ssh/config` options such as `Host`,
`HostName`, `User`, `Port`, `IdentityFile`, `ProxyJump`, and `UserKnownHostsFile`
continue to apply.
Dosh also calls `ssh -G <alias>` to infer the UDP target host when no `dosh_host` is
configured. To write explicit Dosh host entries from SSH aliases:
```bash
dosh import-ssh palav homelab
```
This appends entries to `~/.config/dosh/hosts.toml` without trying to become an
OpenSSH config parser.
## Forwarding Plan
SSH forwarding cannot be copied by keeping the bootstrap SSH connection open,
because that would remove Dosh's fast reconnect advantage and would break after
roaming. Dosh forwarding needs native encrypted channels over the Dosh transport.
Minimum viable forwarding design:
| CLI | Meaning |
| --- | --- |
| `dosh -L 8080:127.0.0.1:80 host` | Local listener on the client; server connects to target. |
| `dosh -R 8080:127.0.0.1:80 host` | Remote listener on the server; client connects to target. |
Protocol work required:
- Add stream-open, stream-data, stream-ack, and stream-close packet types.
- Use per-stream flow control separate from terminal frame ordering.
- Never let bulk forwarding traffic delay terminal input/output packets.
- Bind forwarding permissions to the same SSH-authenticated user as the terminal.
- Add tests with dropped/reordered UDP packets.
This is a publishable differentiator once implemented, but it should not be claimed
until it exists.
## Before Public Launch
- Keep `cargo test`, `make bench-docker-ssh`, and `make bench-docker-mosh` green.
- Add a non-destructive disconnect indicator.
- Run scripted TUI tests for alternate-screen apps, arrow keys, resize, mouse mode,
bracketed paste, and terminal cleanup.
- Publish benchmark output with raw samples, not just averages.
- Mark prediction as experimental until it has a real framebuffer model.
+2 -2
View File
@@ -1,7 +1,7 @@
FROM ubuntu:24.04 FROM ubuntu:24.04
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends openssh-server ca-certificates \ && apt-get install -y --no-install-recommends openssh-server ca-certificates mosh \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
RUN useradd -m -s /bin/sh bench \ RUN useradd -m -s /bin/sh bench \
@@ -16,6 +16,6 @@ RUN chmod 0755 /usr/local/bin/dosh-server /usr/local/bin/dosh-auth \
&& chmod 0600 /home/bench/.ssh/authorized_keys \ && chmod 0600 /home/bench/.ssh/authorized_keys \
&& chown bench:bench /home/bench/.ssh/authorized_keys && chown bench:bench /home/bench/.ssh/authorized_keys
EXPOSE 22/tcp 50000/udp EXPOSE 22/tcp 50000/udp 60000/udp
CMD ["/bin/sh", "-lc", "su - bench -c 'nohup /usr/local/bin/dosh-server serve >/tmp/dosh-server.log 2>&1 &' && exec /usr/sbin/sshd -D -e"] CMD ["/bin/sh", "-lc", "su - bench -c 'nohup /usr/local/bin/dosh-server serve >/tmp/dosh-server.log 2>&1 &' && exec /usr/sbin/sshd -D -e"]
+22 -1
View File
@@ -18,7 +18,8 @@ cp target/release/dosh-auth "$workdir/dosh-auth"
cp "$workdir/id_ed25519.pub" "$workdir/bench_authorized_keys" cp "$workdir/id_ed25519.pub" "$workdir/bench_authorized_keys"
docker build -q -t dosh-ssh-bench -f packaging/docker/ssh-bench.Dockerfile "$workdir" >/dev/null docker build -q -t dosh-ssh-bench -f packaging/docker/ssh-bench.Dockerfile "$workdir" >/dev/null
container_id="$(docker run -d -p 127.0.0.1:0:22/tcp -p 127.0.0.1:0:50000/udp dosh-ssh-bench)" mosh_port="${DOSH_BENCH_MOSH_PORT:-60000}"
container_id="$(docker run -d -p 127.0.0.1:0:22/tcp -p 127.0.0.1:0:50000/udp -p 127.0.0.1:${mosh_port}:60000/udp dosh-ssh-bench)"
ssh_port="$(docker port "$container_id" 22/tcp | sed 's/.*://')" ssh_port="$(docker port "$container_id" 22/tcp | sed 's/.*://')"
dosh_port="$(docker port "$container_id" 50000/udp | sed 's/.*://')" dosh_port="$(docker port "$container_id" 50000/udp | sed 's/.*://')"
@@ -30,6 +31,7 @@ for _ in 1 2 3 4 5; do
done done
ssh-keyscan -p "$ssh_port" 127.0.0.1 > "$workdir/known_hosts" ssh-keyscan -p "$ssh_port" 127.0.0.1 > "$workdir/known_hosts"
mkdir -p "$workdir/home" "$workdir/home-controlmaster" "$workdir/home-mosh"
if ! HOME="$workdir/home" target/release/dosh-bench \ if ! HOME="$workdir/home" target/release/dosh-bench \
--server bench@127.0.0.1 \ --server bench@127.0.0.1 \
@@ -61,3 +63,22 @@ if ! HOME="$workdir/home-controlmaster" target/release/dosh-bench \
docker exec "$container_id" sh -lc 'cat /tmp/dosh-server.log 2>/dev/null || true' || true docker exec "$container_id" sh -lc 'cat /tmp/dosh-server.log 2>/dev/null || true' || true
exit 1 exit 1
fi fi
if [ "${DOSH_BENCH_INCLUDE_MOSH:-0}" = "1" ]; then
if ! HOME="$workdir/home-mosh" target/release/dosh-bench \
--server bench@127.0.0.1 \
--ssh-port "$ssh_port" \
--dosh-port "$dosh_port" \
--ssh-key "$workdir/id_ed25519" \
--ssh-known-hosts "$workdir/known_hosts" \
--ssh-auth-command /usr/local/bin/dosh-auth \
--iterations 3 \
--no-cache \
--include-mosh \
--mosh-server-command /usr/bin/mosh-server \
--mosh-port "$mosh_port"; then
docker logs "$container_id" || true
docker exec "$container_id" sh -lc 'cat /tmp/dosh-server.log 2>/dev/null || true' || true
exit 1
fi
fi
+129
View File
@@ -1,7 +1,11 @@
use anyhow::{Context, Result, anyhow}; use anyhow::{Context, Result, anyhow};
use clap::Parser; use clap::Parser;
use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};
use std::ffi::OsStr;
use std::io::Read;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Command; use std::process::Command;
use std::sync::mpsc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
#[derive(Debug, Parser)] #[derive(Debug, Parser)]
@@ -36,7 +40,17 @@ struct Args {
#[arg(long)] #[arg(long)]
no_cache: bool, no_cache: bool,
#[arg(long)] #[arg(long)]
include_mosh: bool,
#[arg(long, default_value = "mosh")]
mosh: PathBuf,
#[arg(long, default_value = "mosh-server")]
mosh_server_command: String,
#[arg(long)]
mosh_port: Option<String>,
#[arg(long)]
assert_ssh_plus_ms: Option<f64>, assert_ssh_plus_ms: Option<f64>,
#[arg(long)]
assert_mosh_minus_ms: Option<f64>,
} }
fn main() -> Result<()> { fn main() -> Result<()> {
@@ -44,6 +58,7 @@ fn main() -> Result<()> {
let client = args.client.clone().unwrap_or_else(default_client_path); let client = args.client.clone().unwrap_or_else(default_client_path);
let mut ssh_times = Vec::new(); let mut ssh_times = Vec::new();
let mut dosh_times = Vec::new(); let mut dosh_times = Vec::new();
let mut mosh_times = Vec::new();
let generated_control_path = if args.controlmaster { let generated_control_path = if args.controlmaster {
Some(std::env::temp_dir().join(format!("dosh-bench-control-{}", std::process::id()))) Some(std::env::temp_dir().join(format!("dosh-bench-control-{}", std::process::id())))
} else { } else {
@@ -97,6 +112,10 @@ fn main() -> Result<()> {
cmd.arg(&args.server); cmd.arg(&args.server);
} }
dosh_times.push(time_command(&mut cmd)?); dosh_times.push(time_command(&mut cmd)?);
if args.include_mosh {
mosh_times.push(time_mosh_in_pty(&args)?);
}
} }
if !ssh_times.is_empty() { if !ssh_times.is_empty() {
@@ -111,6 +130,13 @@ fn main() -> Result<()> {
avg_ms(&dosh_times), avg_ms(&dosh_times),
dosh_times dosh_times
); );
if !mosh_times.is_empty() {
println!(
"mosh_start_true_ms avg={:.2} samples={:?}",
avg_ms(&mosh_times),
mosh_times
);
}
if let Some(margin) = args.assert_ssh_plus_ms { if let Some(margin) = args.assert_ssh_plus_ms {
if ssh_times.is_empty() { if ssh_times.is_empty() {
return Err(anyhow!( return Err(anyhow!(
@@ -126,6 +152,21 @@ fn main() -> Result<()> {
} }
println!("gate ok: dosh avg {dosh_avg:.2}ms <= ssh avg {ssh_avg:.2}ms + {margin:.2}ms"); println!("gate ok: dosh avg {dosh_avg:.2}ms <= ssh avg {ssh_avg:.2}ms + {margin:.2}ms");
} }
if let Some(margin) = args.assert_mosh_minus_ms {
if mosh_times.is_empty() {
return Err(anyhow!(
"--assert-mosh-minus-ms requires --include-mosh benchmark"
));
}
let dosh_avg = avg_ms(&dosh_times);
let mosh_avg = avg_ms(&mosh_times);
if dosh_avg + margin > mosh_avg {
return Err(anyhow!(
"dosh attach avg {dosh_avg:.2}ms was not at least {margin:.2}ms faster than mosh avg {mosh_avg:.2}ms"
));
}
println!("gate ok: dosh avg {dosh_avg:.2}ms + {margin:.2}ms <= mosh avg {mosh_avg:.2}ms");
}
Ok(()) Ok(())
} }
@@ -143,6 +184,94 @@ fn add_ssh_options(cmd: &mut Command, args: &Args, control_path: Option<&PathBuf
} }
} }
fn mosh_ssh_command(args: &Args) -> String {
let mut parts = vec![
"ssh".to_string(),
"-p".to_string(),
args.ssh_port.to_string(),
];
if let Some(key) = &args.ssh_key {
parts.push("-i".to_string());
parts.push(shell_word(key.as_os_str()));
}
if let Some(known_hosts) = &args.ssh_known_hosts {
parts.push("-o".to_string());
parts.push(shell_word(OsStr::new(&format!(
"UserKnownHostsFile={}",
known_hosts.display()
))));
}
parts.join(" ")
}
fn time_mosh_in_pty(args: &Args) -> Result<Duration> {
let pty_system = NativePtySystem::default();
let pair = pty_system
.openpty(PtySize {
rows: 24,
cols: 80,
pixel_width: 0,
pixel_height: 0,
})
.context("open pty for mosh benchmark")?;
let mut command_line = vec![
shell_word(args.mosh.as_os_str()),
shell_word(OsStr::new(&format!("--ssh={}", mosh_ssh_command(args)))),
shell_word(OsStr::new(&format!(
"--server={}",
args.mosh_server_command
))),
"--no-init".to_string(),
];
if let Some(port) = &args.mosh_port {
command_line.push("-p".to_string());
command_line.push(shell_word(OsStr::new(port)));
}
command_line.push(shell_word(OsStr::new(&args.server)));
command_line.push("--".to_string());
command_line.push("true".to_string());
let mut cmd = CommandBuilder::new("/bin/sh");
cmd.arg("-c");
cmd.arg(command_line.join(" "));
let start = Instant::now();
let mut child = pair
.slave
.spawn_command(cmd)
.context("spawn mosh benchmark in pty")?;
drop(pair.slave);
let mut reader = pair.master.try_clone_reader().context("clone pty reader")?;
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let mut output = Vec::new();
let _ = reader.read_to_end(&mut output);
let _ = tx.send(output);
});
{
let _writer = pair.master.take_writer().context("take pty writer")?;
}
let status = child.wait().context("wait for mosh benchmark")?;
let elapsed = start.elapsed();
drop(pair.master);
let output = rx.recv().unwrap_or_default();
if !status.success() {
return Err(anyhow!(
"mosh command failed with status {status:?}\noutput:\n{}",
String::from_utf8_lossy(&output)
));
}
Ok(elapsed)
}
fn shell_word(value: &OsStr) -> String {
let value = value.to_string_lossy();
format!("'{}'", value.replace('\'', "'\\''"))
}
struct ControlMaster { struct ControlMaster {
server: String, server: String,
ssh_port: u16, ssh_port: u16,
+128 -19
View File
@@ -18,6 +18,7 @@ use std::collections::BTreeMap;
use std::fs; use std::fs;
use std::io::{Read, Write}; use std::io::{Read, Write};
use std::net::{SocketAddr, ToSocketAddrs}; use std::net::{SocketAddr, ToSocketAddrs};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio}; use std::process::{Command, Stdio};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tokio::net::UdpSocket; use tokio::net::UdpSocket;
@@ -49,11 +50,11 @@ struct Args {
#[arg(long)] #[arg(long)]
ssh_auth_command: Option<String>, ssh_auth_command: Option<String>,
#[arg(long)] #[arg(long)]
ssh_key: Option<std::path::PathBuf>, ssh_key: Option<PathBuf>,
#[arg(long)] #[arg(long)]
ssh_known_hosts: Option<std::path::PathBuf>, ssh_known_hosts: Option<PathBuf>,
#[arg(long)] #[arg(long)]
ssh_control_path: Option<std::path::PathBuf>, ssh_control_path: Option<PathBuf>,
#[arg(long)] #[arg(long)]
dosh_port: Option<u16>, dosh_port: Option<u16>,
#[arg(long)] #[arg(long)]
@@ -84,6 +85,9 @@ async fn main() -> Result<()> {
if args.server.as_deref() == Some("update") { if args.server.as_deref() == Some("update") {
return run_update(&config); return run_update(&config);
} }
if args.server.as_deref() == Some("import-ssh") {
return run_import_ssh(&config, &args.command);
}
let requested_server = args.server.unwrap_or_else(|| config.server.clone()); let requested_server = args.server.unwrap_or_else(|| config.server.clone());
let hosts = load_hosts_config(None).unwrap_or_default(); let hosts = load_hosts_config(None).unwrap_or_default();
let host = hosts let host = hosts
@@ -105,7 +109,7 @@ async fn main() -> Result<()> {
"read-write" "read-write"
} }
.to_string(); .to_string();
let ssh_port = args.ssh_port.or(config.ssh_port); let ssh_port = args.ssh_port.or(host.ssh_port).or(config.ssh_port);
let ssh_auth_command = args let ssh_auth_command = args
.ssh_auth_command .ssh_auth_command
.clone() .clone()
@@ -356,6 +360,77 @@ fn run_update(config: &dosh::config::ClientConfig) -> Result<()> {
Ok(()) Ok(())
} }
fn run_import_ssh(config: &dosh::config::ClientConfig, aliases: &[String]) -> Result<()> {
if aliases.is_empty() {
return Err(anyhow!("usage: dosh import-ssh <ssh-host> [ssh-host...]"));
}
let path = expand_tilde("~/.config/dosh/hosts.toml");
let mut raw = if path.exists() {
fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?
} else {
String::new()
};
if !raw.ends_with('\n') && !raw.is_empty() {
raw.push('\n');
}
for alias in aliases {
let ssh_config = ssh_config(alias, None)
.with_context(|| format!("read SSH config for {alias} with ssh -G"))?;
let udp_host = ssh_config
.hostname
.clone()
.unwrap_or_else(|| ssh_destination_host(alias));
if raw_contains_host_table(&raw, alias) {
eprintln!("dosh import-ssh: [{alias}] already exists, skipping");
continue;
}
raw.push('\n');
raw.push_str(&format!("[{}]\n", toml_bare_key_or_quoted(alias)));
raw.push_str(&format!("ssh = {}\n", toml_string(alias)));
raw.push_str(&format!("dosh_host = {}\n", toml_string(&udp_host)));
raw.push_str(&format!("port = {}\n", config.dosh_port));
if let Some(port) = ssh_config.port
&& port != 22
{
raw.push_str(&format!("ssh_port = {port}\n"));
}
raw.push_str("predict = false\n");
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&path, raw).with_context(|| format!("write {}", path.display()))?;
eprintln!("dosh import-ssh: wrote {}", path.display());
Ok(())
}
fn raw_contains_host_table(raw: &str, alias: &str) -> bool {
let quoted = toml_string(alias);
raw.lines().any(|line| {
let line = line.trim();
line == format!("[{alias}]") || line == format!("[{quoted}]")
})
}
fn toml_string(value: &str) -> String {
let escaped = value
.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n");
format!("\"{escaped}\"")
}
fn toml_bare_key_or_quoted(value: &str) -> String {
if value
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
value.to_string()
} else {
toml_string(value)
}
}
fn raw_base_from_repo(repo: &str) -> String { fn raw_base_from_repo(repo: &str) -> String {
repo.trim_end_matches(".git").to_string() repo.trim_end_matches(".git").to_string()
} }
@@ -420,9 +495,9 @@ fn ssh_bootstrap(
server: &str, server: &str,
ssh_port: Option<u16>, ssh_port: Option<u16>,
ssh_auth_command: &str, ssh_auth_command: &str,
ssh_key: Option<&std::path::Path>, ssh_key: Option<&Path>,
ssh_known_hosts: Option<&std::path::Path>, ssh_known_hosts: Option<&Path>,
ssh_control_path: Option<&std::path::Path>, ssh_control_path: Option<&Path>,
session: &str, session: &str,
mode: &str, mode: &str,
cols: u16, cols: u16,
@@ -488,6 +563,18 @@ fn ssh_destination_host(server: &str) -> String {
} }
fn ssh_config_hostname(server: &str, ssh_port: Option<u16>) -> Result<String> { fn ssh_config_hostname(server: &str, ssh_port: Option<u16>) -> Result<String> {
ssh_config(server, ssh_port)?
.hostname
.ok_or_else(|| anyhow!("ssh -G did not print hostname"))
}
#[derive(Debug, Clone, Default)]
struct SshConfig {
hostname: Option<String>,
port: Option<u16>,
}
fn ssh_config(server: &str, ssh_port: Option<u16>) -> Result<SshConfig> {
let mut command = Command::new("ssh"); let mut command = Command::new("ssh");
command.arg("-G"); command.arg("-G");
if let Some(ssh_port) = ssh_port { if let Some(ssh_port) = ssh_port {
@@ -504,21 +591,24 @@ fn ssh_config_hostname(server: &str, ssh_port: Option<u16>) -> Result<String> {
)); ));
} }
let raw = String::from_utf8(output.stdout)?; let raw = String::from_utf8(output.stdout)?;
parse_ssh_config_hostname(&raw).ok_or_else(|| anyhow!("ssh -G did not print hostname")) Ok(parse_ssh_config(&raw))
} }
fn parse_ssh_config_hostname(raw: &str) -> Option<String> { fn parse_ssh_config(raw: &str) -> SshConfig {
raw.lines().find_map(|line| { let mut config = SshConfig::default();
for line in raw.lines() {
let line = line.trim(); let line = line.trim();
let (key, value) = line.split_once(char::is_whitespace)?; let Some((key, value)) = line.split_once(char::is_whitespace) else {
if key.eq_ignore_ascii_case("hostname") { continue;
};
let value = value.trim(); let value = value.trim();
if !value.is_empty() { if key.eq_ignore_ascii_case("hostname") && !value.is_empty() {
return Some(value.to_string()); config.hostname = Some(value.to_string());
} else if key.eq_ignore_ascii_case("port") {
config.port = value.parse().ok();
} }
} }
None config
})
} }
fn resolve_addr(host: &str, port: u16) -> Result<SocketAddr> { fn resolve_addr(host: &str, port: u16) -> Result<SocketAddr> {
@@ -1146,7 +1236,8 @@ const TERMINAL_CLEANUP: &[u8] = concat!(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
FrameBuffer, Predictor, parse_ssh_config_hostname, ssh_destination_host, startup_command, FrameBuffer, Predictor, parse_ssh_config, raw_contains_host_table, ssh_destination_host,
startup_command, toml_bare_key_or_quoted,
}; };
use dosh::protocol::Frame; use dosh::protocol::Frame;
@@ -1154,7 +1245,7 @@ mod tests {
fn parses_ssh_config_hostname() { fn parses_ssh_config_hostname() {
let raw = "user palav\nhostname palav.dev\nport 22\n"; let raw = "user palav\nhostname palav.dev\nport 22\n";
assert_eq!( assert_eq!(
parse_ssh_config_hostname(raw), parse_ssh_config(raw).hostname,
Some("palav.dev".to_string()) Some("palav.dev".to_string())
); );
} }
@@ -1163,11 +1254,29 @@ mod tests {
fn parses_ssh_config_hostname_case_insensitively() { fn parses_ssh_config_hostname_case_insensitively() {
let raw = "HostName 192.0.2.10\n"; let raw = "HostName 192.0.2.10\n";
assert_eq!( assert_eq!(
parse_ssh_config_hostname(raw), parse_ssh_config(raw).hostname,
Some("192.0.2.10".to_string()) Some("192.0.2.10".to_string())
); );
} }
#[test]
fn parses_ssh_config_port() {
let parsed = parse_ssh_config("hostname example.com\nport 2222\n");
assert_eq!(parsed.hostname, Some("example.com".to_string()));
assert_eq!(parsed.port, Some(2222));
}
#[test]
fn detects_existing_imported_host_tables() {
assert!(raw_contains_host_table(
"[palav]\nssh = \"palav\"\n",
"palav"
));
assert!(raw_contains_host_table("[\"home box\"]\n", "home box"));
assert_eq!(toml_bare_key_or_quoted("palav"), "palav");
assert_eq!(toml_bare_key_or_quoted("home box"), "\"home box\"");
}
#[test] #[test]
fn strips_user_from_fallback_udp_host() { fn strips_user_from_fallback_udp_host() {
assert_eq!(ssh_destination_host("palav@example.com"), "example.com"); assert_eq!(ssh_destination_host("palav@example.com"), "example.com");
+1
View File
@@ -66,6 +66,7 @@ pub struct HostConfig {
pub ssh: Option<String>, pub ssh: Option<String>,
pub dosh_host: Option<String>, pub dosh_host: Option<String>,
pub port: Option<u16>, pub port: Option<u16>,
pub ssh_port: Option<u16>,
pub default_command: Option<String>, pub default_command: Option<String>,
pub predict: Option<bool>, pub predict: Option<bool>,
} }