diff --git a/Makefile b/Makefile index 869f432..2d90a7e 100644 --- a/Makefile +++ b/Makefile @@ -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: cargo build --release @@ -23,3 +23,6 @@ bench-local: bench-docker-ssh: sh scripts/ci-docker-ssh-bench.sh + +bench-docker-mosh: + DOSH_BENCH_INCLUDE_MOSH=1 sh scripts/ci-docker-ssh-bench.sh diff --git a/README.md b/README.md index 9b1058d..82cb170 100644 --- a/README.md +++ b/README.md @@ -134,9 +134,10 @@ dosh --session logs palav 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. -If UDP packets stop arriving, Dosh shows a blue status bar at the top of the -terminal with the disconnected duration and sends keepalive pings until traffic -returns. +If UDP packets stop arriving, Dosh keeps the terminal open, sends keepalive pings, +and attempts ticket-based reconnect. The old in-band status overlay was removed +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: @@ -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 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 Build: @@ -184,6 +194,17 @@ and ControlMaster-backed SSH bootstrap against a containerized `sshd` plus resid 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 `DOSH_BENCH_HOST`, `DOSH_BENCH_USER`, and `DOSH_BENCH_SSH_KEY` repository secrets are configured. diff --git a/docs/PUBLIC_READINESS.md b/docs/PUBLIC_READINESS.md new file mode 100644 index 0000000..0ea33ca --- /dev/null +++ b/docs/PUBLIC_READINESS.md @@ -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 ` 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. diff --git a/packaging/docker/ssh-bench.Dockerfile b/packaging/docker/ssh-bench.Dockerfile index 07221a7..a6f62bd 100644 --- a/packaging/docker/ssh-bench.Dockerfile +++ b/packaging/docker/ssh-bench.Dockerfile @@ -1,7 +1,7 @@ FROM ubuntu:24.04 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/* 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 \ && 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"] diff --git a/scripts/ci-docker-ssh-bench.sh b/scripts/ci-docker-ssh-bench.sh index e0e782f..cd7375b 100755 --- a/scripts/ci-docker-ssh-bench.sh +++ b/scripts/ci-docker-ssh-bench.sh @@ -18,7 +18,8 @@ cp target/release/dosh-auth "$workdir/dosh-auth" 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 -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/.*://')" dosh_port="$(docker port "$container_id" 50000/udp | sed 's/.*://')" @@ -30,6 +31,7 @@ for _ in 1 2 3 4 5; do done 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 \ --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 exit 1 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 diff --git a/src/bin/dosh-bench.rs b/src/bin/dosh-bench.rs index 968a29b..175174f 100644 --- a/src/bin/dosh-bench.rs +++ b/src/bin/dosh-bench.rs @@ -1,7 +1,11 @@ use anyhow::{Context, Result, anyhow}; use clap::Parser; +use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem}; +use std::ffi::OsStr; +use std::io::Read; use std::path::PathBuf; use std::process::Command; +use std::sync::mpsc; use std::time::{Duration, Instant}; #[derive(Debug, Parser)] @@ -36,7 +40,17 @@ struct Args { #[arg(long)] no_cache: bool, #[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, + #[arg(long)] assert_ssh_plus_ms: Option, + #[arg(long)] + assert_mosh_minus_ms: Option, } fn main() -> Result<()> { @@ -44,6 +58,7 @@ fn main() -> Result<()> { let client = args.client.clone().unwrap_or_else(default_client_path); let mut ssh_times = Vec::new(); let mut dosh_times = Vec::new(); + let mut mosh_times = Vec::new(); let generated_control_path = if args.controlmaster { Some(std::env::temp_dir().join(format!("dosh-bench-control-{}", std::process::id()))) } else { @@ -97,6 +112,10 @@ fn main() -> Result<()> { cmd.arg(&args.server); } dosh_times.push(time_command(&mut cmd)?); + + if args.include_mosh { + mosh_times.push(time_mosh_in_pty(&args)?); + } } if !ssh_times.is_empty() { @@ -111,6 +130,13 @@ fn main() -> Result<()> { avg_ms(&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 ssh_times.is_empty() { 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"); } + 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(()) } @@ -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 { + 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 { server: String, ssh_port: u16, diff --git a/src/bin/dosh-client.rs b/src/bin/dosh-client.rs index 183cc87..64ccd4b 100644 --- a/src/bin/dosh-client.rs +++ b/src/bin/dosh-client.rs @@ -18,6 +18,7 @@ use std::collections::BTreeMap; use std::fs; use std::io::{Read, Write}; use std::net::{SocketAddr, ToSocketAddrs}; +use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use tokio::net::UdpSocket; @@ -49,11 +50,11 @@ struct Args { #[arg(long)] ssh_auth_command: Option, #[arg(long)] - ssh_key: Option, + ssh_key: Option, #[arg(long)] - ssh_known_hosts: Option, + ssh_known_hosts: Option, #[arg(long)] - ssh_control_path: Option, + ssh_control_path: Option, #[arg(long)] dosh_port: Option, #[arg(long)] @@ -84,6 +85,9 @@ async fn main() -> Result<()> { if args.server.as_deref() == Some("update") { 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 hosts = load_hosts_config(None).unwrap_or_default(); let host = hosts @@ -105,7 +109,7 @@ async fn main() -> Result<()> { "read-write" } .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 .ssh_auth_command .clone() @@ -356,6 +360,77 @@ fn run_update(config: &dosh::config::ClientConfig) -> Result<()> { 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...]")); + } + 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 { repo.trim_end_matches(".git").to_string() } @@ -420,9 +495,9 @@ fn ssh_bootstrap( server: &str, ssh_port: Option, ssh_auth_command: &str, - ssh_key: Option<&std::path::Path>, - ssh_known_hosts: Option<&std::path::Path>, - ssh_control_path: Option<&std::path::Path>, + ssh_key: Option<&Path>, + ssh_known_hosts: Option<&Path>, + ssh_control_path: Option<&Path>, session: &str, mode: &str, cols: u16, @@ -488,6 +563,18 @@ fn ssh_destination_host(server: &str) -> String { } fn ssh_config_hostname(server: &str, ssh_port: Option) -> Result { + ssh_config(server, ssh_port)? + .hostname + .ok_or_else(|| anyhow!("ssh -G did not print hostname")) +} + +#[derive(Debug, Clone, Default)] +struct SshConfig { + hostname: Option, + port: Option, +} + +fn ssh_config(server: &str, ssh_port: Option) -> Result { let mut command = Command::new("ssh"); command.arg("-G"); if let Some(ssh_port) = ssh_port { @@ -504,21 +591,24 @@ fn ssh_config_hostname(server: &str, ssh_port: Option) -> Result { )); } 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 { - raw.lines().find_map(|line| { +fn parse_ssh_config(raw: &str) -> SshConfig { + let mut config = SshConfig::default(); + for line in raw.lines() { let line = line.trim(); - let (key, value) = line.split_once(char::is_whitespace)?; - if key.eq_ignore_ascii_case("hostname") { - let value = value.trim(); - if !value.is_empty() { - return Some(value.to_string()); - } + let Some((key, value)) = line.split_once(char::is_whitespace) else { + continue; + }; + let value = value.trim(); + if key.eq_ignore_ascii_case("hostname") && !value.is_empty() { + 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 { @@ -1146,7 +1236,8 @@ const TERMINAL_CLEANUP: &[u8] = concat!( #[cfg(test)] mod tests { 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; @@ -1154,7 +1245,7 @@ mod tests { fn parses_ssh_config_hostname() { let raw = "user palav\nhostname palav.dev\nport 22\n"; assert_eq!( - parse_ssh_config_hostname(raw), + parse_ssh_config(raw).hostname, Some("palav.dev".to_string()) ); } @@ -1163,11 +1254,29 @@ mod tests { fn parses_ssh_config_hostname_case_insensitively() { let raw = "HostName 192.0.2.10\n"; assert_eq!( - parse_ssh_config_hostname(raw), + parse_ssh_config(raw).hostname, 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] fn strips_user_from_fallback_udp_host() { assert_eq!(ssh_destination_host("palav@example.com"), "example.com"); diff --git a/src/config.rs b/src/config.rs index cec0aa2..1b8a289 100644 --- a/src/config.rs +++ b/src/config.rs @@ -66,6 +66,7 @@ pub struct HostConfig { pub ssh: Option, pub dosh_host: Option, pub port: Option, + pub ssh_port: Option, pub default_command: Option, pub predict: Option, }