Add public comparison benchmarks and SSH import
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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");
|
||||||
|
|||||||
@@ -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>,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user