Compare commits

...

2 Commits

Author SHA1 Message Date
DuProcess a202f97704 Quiet missing release lookup
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-20 19:20:55 -04:00
DuProcess f6ead86db4 Add setup selftest and diagnostics polish
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-06-20 19:19:29 -04:00
7 changed files with 425 additions and 32 deletions
Generated
+1 -1
View File
@@ -436,7 +436,7 @@ dependencies = [
[[package]] [[package]]
name = "dosh" name = "dosh"
version = "0.1.0" version = "0.1.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "dosh" name = "dosh"
version = "0.1.0" version = "0.1.1"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
+24 -3
View File
@@ -123,9 +123,10 @@ dosh --version
`dosh update` first tries a release tarball named for the platform `dosh update` first tries a release tarball named for the platform
(`dosh-macos-aarch64.tar.gz`, `dosh-linux-x86_64.tar.gz`, etc.) from the latest (`dosh-macos-aarch64.tar.gz`, `dosh-linux-x86_64.tar.gz`, etc.) from the latest
Gitea/GitHub release. If that asset is not published yet, it falls back to the Gitea/GitHub release. Gitea's concrete tag download route is detected when
source build path. If the release also publishes `<artifact>.sha256`, the installer `/releases/latest/download/...` is not supported. If that asset is not published
verifies the archive before installing it. yet, it falls back to the source build path. If the release also publishes
`<artifact>.sha256`, the installer verifies the archive before installing it.
Install the client on Windows PowerShell: Install the client on Windows PowerShell:
@@ -139,6 +140,19 @@ Attach:
dosh user@server.example.com dosh user@server.example.com
``` ```
First-time setup for an existing SSH alias:
```bash
dosh setup homelab
dosh selftest homelab
dosh homelab
```
`dosh setup <ssh-alias>` imports the OpenSSH alias into
`~/.config/dosh/hosts.toml`, pins the Dosh host key over SSH, runs `dosh doctor`,
and prints next commands. `dosh selftest <host>` checks local terminal overlay
safety, forwarding syntax, and the remote doctor path.
Plain `dosh user@server.example.com` opens a fresh terminal session. Use named Plain `dosh user@server.example.com` opens a fresh terminal session. Use named
sessions when you want to reattach to the same persistent terminal from multiple sessions when you want to reattach to the same persistent terminal from multiple
clients: clients:
@@ -165,6 +179,13 @@ 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
``` ```
Forwarding can use SSH-shaped flags directly or the forward-only wrapper:
```bash
dosh -N -L 8080:127.0.0.1:80 homelab
dosh forward homelab -L 8080:127.0.0.1:80 -D 1080
```
Dosh already lets OpenSSH handle SSH aliases, users, keys, ports, known-hosts, 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 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 UDP host from an SSH alias when `dosh_host` is not configured. To make that explicit
+6 -1
View File
@@ -200,7 +200,12 @@ Write-Host "Installed Dosh client to $bindir"
Write-Host "Configured UDP port $Port" Write-Host "Configured UDP port $Port"
Write-Host "" Write-Host ""
$displayServer = if ($Server) { $Server } else { "user@host" } $displayServer = if ($Server) { $Server } else { "user@host" }
Write-Host "Client command:" Write-Host "Client commands:"
Write-Host " $bindir\dosh.exe $displayServer" Write-Host " $bindir\dosh.exe $displayServer"
Write-Host " $bindir\dosh.exe setup <ssh-alias>"
Write-Host " $bindir\dosh.exe update --check"
Write-Host ""
Write-Host "Client config:"
Write-Host " $configDir\client.toml"
Write-Host "" Write-Host ""
Write-Host "Open a new terminal for PATH changes to apply." Write-Host "Open a new terminal for PATH changes to apply."
+9 -2
View File
@@ -527,10 +527,17 @@ Configured UDP port $port
EOF EOF
if [ "$role" = "client" ] || [ "$role" = "both" ]; then if [ "$role" = "client" ] || [ "$role" = "both" ]; then
next_server="${server:-user@host}"
cat <<EOF cat <<EOF
Client command: Client commands:
$bindir/dosh ${server:-user@host} $bindir/dosh $next_server
$bindir/dosh setup <ssh-alias>
$bindir/dosh update --check
Client config:
$config_dir/client.toml
$config_dir/hosts.toml
EOF EOF
fi fi
+1 -1
View File
@@ -43,7 +43,7 @@ fi
api="$base/api/v1/repos/$repo" api="$base/api/v1/repos/$repo"
auth_header="Authorization: token $token" auth_header="Authorization: token $token"
release_json="$(curl -fsS -H "$auth_header" "$api/releases/tags/$tag" || true)" release_json="$(curl -fsS -H "$auth_header" "$api/releases/tags/$tag" 2>/dev/null || true)"
release_id="" release_id=""
if [ -n "$release_json" ] && command -v jq >/dev/null 2>&1; then if [ -n "$release_json" ] && command -v jq >/dev/null 2>&1; then
release_id="$(printf '%s\n' "$release_json" | jq -r '.id // empty')" release_id="$(printf '%s\n' "$release_json" | jq -r '.id // empty')"
+383 -23
View File
@@ -66,7 +66,7 @@ fn terminal_size() -> (u16, u16) {
} }
} }
#[derive(Debug, Parser)] #[derive(Debug, Clone, Parser)]
#[command(name = "dosh-client", version)] #[command(name = "dosh-client", version)]
struct Args { struct Args {
#[arg()] #[arg()]
@@ -189,11 +189,20 @@ struct PendingStreamChunk {
#[tokio::main(flavor = "current_thread")] #[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> { async fn main() -> Result<()> {
let args = Args::parse(); let mut args = Args::parse();
let config = load_client_config(None).unwrap_or_default(); let config = load_client_config(None).unwrap_or_default();
if args.server.as_deref() == Some("update") { if args.server.as_deref() == Some("update") {
return run_update(&config, update_check_requested(&args.command)?); return run_update(&config, update_check_requested(&args.command)?);
} }
if args.server.as_deref() == Some("setup") {
return run_setup_command(&config, &args).await;
}
if args.server.as_deref() == Some("selftest") {
return run_selftest_command(&config, &args).await;
}
if args.server.as_deref() == Some("forward") {
args = rewrite_forward_command(args)?;
}
if args.server.as_deref() == Some("import-ssh") { if args.server.as_deref() == Some("import-ssh") {
return run_import_ssh(&config, &args.command); return run_import_ssh(&config, &args.command);
} }
@@ -587,15 +596,113 @@ fn run_trust_command(config: &dosh::config::ClientConfig, args: &Args) -> Result
Ok(()) Ok(())
} }
async fn run_setup_command(config: &dosh::config::ClientConfig, args: &Args) -> Result<()> {
let host = args
.command
.first()
.cloned()
.ok_or_else(|| anyhow!("usage: dosh setup <ssh-host>"))?;
println!("Dosh setup for {host}");
let imported = import_ssh_aliases(config, std::slice::from_ref(&host))?;
for entry in imported {
match entry.action {
ImportAction::Added => println!(
"[ok] host config: [{}] ssh={} dosh_host={} port={}",
entry.alias, entry.ssh, entry.dosh_host, entry.port
),
ImportAction::Skipped => println!("[ok] host config: [{}] already exists", entry.alias),
}
}
let hosts = load_hosts_config(None).unwrap_or_default();
let host_config = hosts.hosts.get(&host).cloned().unwrap_or_default();
let raw_server = host_config.ssh.clone().unwrap_or_else(|| host.clone());
let server = ssh_with_user(&raw_server, host_config.user.as_deref());
let ssh_port = args.ssh_port.or(host_config.ssh_port).or(config.ssh_port);
let ssh_auth_command = args
.ssh_auth_command
.clone()
.or_else(|| config.ssh_auth_command.clone())
.unwrap_or_else(|| "~/.local/bin/dosh-auth".to_string());
let known_hosts = expand_tilde(&config.known_hosts);
let public_key = fetch_host_key_over_ssh(
&server,
ssh_port,
&ssh_auth_command,
args.ssh_key.as_deref(),
args.ssh_known_hosts.as_deref(),
args.ssh_control_path.as_deref(),
)
.with_context(|| {
format!("fetch Dosh host key over SSH; make sure dosh-auth is installed on {server}")
})?;
match trust_host(&known_hosts, &host, &public_key, "ssh", args.replace)? {
TrustResult::AlreadyTrusted => println!(
"[ok] trust: already trusted {}",
host_fingerprint(&public_key)
),
TrustResult::Trusted => println!(
"[ok] trust: pinned {} in {}",
host_fingerprint(&public_key),
known_hosts.display()
),
}
println!();
run_doctor_for_host(config, args, &host).await?;
println!();
println!("Next:");
println!(" dosh {host}");
println!(" dosh update --check");
Ok(())
}
async fn run_selftest_command(config: &dosh::config::ClientConfig, args: &Args) -> Result<()> {
let host = args
.command
.first()
.cloned()
.ok_or_else(|| anyhow!("usage: dosh selftest <host>"))?;
println!("Dosh selftest for {host}");
println!("[ok] terminal size fallback: {:?}", terminal_size());
let overlay = render_status_overlay("[dosh] reconnecting — last contact 3s ago", 24);
ensure_tui_safe_status_overlay(&overlay)?;
println!("[ok] disconnect UI: save/restore bottom-row overlay");
parse_local_forward("18080:127.0.0.1:80")?;
parse_remote_forward("19090:127.0.0.1:5432")?;
parse_dynamic_forward("11080")?;
println!("[ok] forwarding parser: -L, -R, -D");
println!();
run_doctor_for_host(config, args, &host).await?;
println!();
println!("[ok] selftest complete");
Ok(())
}
async fn run_doctor_command(config: &dosh::config::ClientConfig, args: &Args) -> Result<()> { async fn run_doctor_command(config: &dosh::config::ClientConfig, args: &Args) -> Result<()> {
let requested = args let requested = args
.command .command
.first() .first()
.cloned() .cloned()
.ok_or_else(|| anyhow!("usage: dosh doctor <host>"))?; .ok_or_else(|| anyhow!("usage: dosh doctor <host>"))?;
run_doctor_for_host(config, args, &requested).await
}
async fn run_doctor_for_host(
config: &dosh::config::ClientConfig,
args: &Args,
requested: &str,
) -> Result<()> {
let hosts = load_hosts_config(None).unwrap_or_default(); let hosts = load_hosts_config(None).unwrap_or_default();
let host_config = hosts.hosts.get(&requested).cloned().unwrap_or_default(); let host_config = hosts.hosts.get(requested).cloned().unwrap_or_default();
let raw_server = host_config.ssh.clone().unwrap_or_else(|| requested.clone()); let raw_server = host_config
.ssh
.clone()
.unwrap_or_else(|| requested.to_string());
let server = ssh_with_user(&raw_server, host_config.user.as_deref()); let server = ssh_with_user(&raw_server, host_config.user.as_deref());
let ssh_port = args.ssh_port.or(host_config.ssh_port).or(config.ssh_port); let ssh_port = args.ssh_port.or(host_config.ssh_port).or(config.ssh_port);
let dosh_port = args let dosh_port = args
@@ -603,6 +710,20 @@ async fn run_doctor_command(config: &dosh::config::ClientConfig, args: &Args) ->
.or(host_config.port) .or(host_config.port)
.unwrap_or(config.dosh_port); .unwrap_or(config.dosh_port);
println!("Dosh doctor for {requested}"); println!("Dosh doctor for {requested}");
println!(
"[info] config: client={} hosts={} known_hosts={}",
expand_tilde("~/.config/dosh/client.toml").display(),
expand_tilde("~/.config/dosh/hosts.toml").display(),
expand_tilde(&config.known_hosts).display()
);
println!(
"[info] client: auth={} predict={}({}) disconnect_status={} cache_tickets={}",
config.auth_preference,
config.predict,
config.predict_mode,
config.disconnect_status,
config.cache_attach_tickets
);
let parsed_ssh_config = match ssh_config(&server, ssh_port) { let parsed_ssh_config = match ssh_config(&server, ssh_port) {
Ok(parsed) => { Ok(parsed) => {
@@ -652,6 +773,14 @@ async fn run_doctor_command(config: &dosh::config::ClientConfig, args: &Args) ->
.unwrap_or_else(|| ssh_destination_host(&server)) .unwrap_or_else(|| ssh_destination_host(&server))
}); });
println!("[info] native endpoint: {udp_host}:{dosh_port}"); println!("[info] native endpoint: {udp_host}:{dosh_port}");
match resolve_addr(&udp_host, dosh_port) {
Ok(addr) => println!("[ok] udp resolve: {addr}"),
Err(err) => println!("[fail] udp resolve: {err:#}"),
}
println!(
"[info] forwarding requested by config: agent={} default_session={}",
config.forward_agent, config.default_session
);
let socket = UdpSocket::bind("0.0.0.0:0").await?; let socket = UdpSocket::bind("0.0.0.0:0").await?;
match try_native_auth_check( match try_native_auth_check(
&socket, &socket,
@@ -693,6 +822,26 @@ async fn run_doctor_command(config: &dosh::config::ClientConfig, args: &Args) ->
} }
} }
fn ensure_tui_safe_status_overlay(bytes: &[u8]) -> Result<()> {
anyhow::ensure!(
bytes.starts_with(b"\x1b7"),
"status overlay must save cursor"
);
anyhow::ensure!(
bytes.ends_with(b"\x1b8"),
"status overlay must restore cursor"
);
anyhow::ensure!(
!contains_bytes(bytes, b"\n") && !contains_bytes(bytes, b"\r"),
"status overlay must not scroll"
);
anyhow::ensure!(
!contains_bytes(bytes, b"\x1b[?25l") && !contains_bytes(bytes, b"\x1b[?25h"),
"status overlay must not toggle cursor visibility"
);
Ok(())
}
fn ssh_fallback_probe( fn ssh_fallback_probe(
server: &str, server: &str,
ssh_port: Option<u16>, ssh_port: Option<u16>,
@@ -905,6 +1054,63 @@ fn spawn_background_forwarder(args: &Args, forwarding_requested: bool) -> Result
} }
} }
fn rewrite_forward_command(mut args: Args) -> Result<Args> {
let mut tokens = std::mem::take(&mut args.command).into_iter();
let host = tokens.next().ok_or_else(|| {
anyhow!("usage: dosh forward <host> [-L spec] [-R spec] [-D spec] [-A] [-f]")
})?;
let mut trailing_command = Vec::new();
while let Some(token) = tokens.next() {
match token.as_str() {
"-L" | "--local-forward" => {
let spec = tokens
.next()
.ok_or_else(|| anyhow!("dosh forward: {token} requires a forwarding spec"))?;
args.local_forward.push(spec);
}
"-R" | "--remote-forward" => {
let spec = tokens
.next()
.ok_or_else(|| anyhow!("dosh forward: {token} requires a forwarding spec"))?;
args.remote_forward.push(spec);
}
"-D" | "--dynamic-forward" => {
let spec = tokens
.next()
.ok_or_else(|| anyhow!("dosh forward: {token} requires a forwarding spec"))?;
args.dynamic_forward.push(spec);
}
"-A" | "--forward-agent" => args.forward_agent = true,
"-f" | "--background" => args.background = true,
"-N" | "--forward-only" => args.forward_only = true,
"--" => {
trailing_command.extend(tokens);
break;
}
other => return Err(anyhow!("dosh forward: unknown option {other:?}")),
}
}
if !trailing_command.is_empty() {
return Err(anyhow!(
"dosh forward does not run remote commands; remove {:?}",
trailing_command
));
}
if args.local_forward.is_empty()
&& args.remote_forward.is_empty()
&& args.dynamic_forward.is_empty()
&& !args.forward_agent
{
return Err(anyhow!(
"dosh forward requires at least one -L, -R, -D, or -A request"
));
}
args.server = Some(host);
args.command = Vec::new();
args.forward_only = true;
Ok(args)
}
fn signal_background_ready() { fn signal_background_ready() {
let Some(addr) = std::env::var_os("DOSH_BACKGROUND_READY_ADDR") else { let Some(addr) = std::env::var_os("DOSH_BACKGROUND_READY_ADDR") else {
return; return;
@@ -958,12 +1164,20 @@ fn parse_local_forward(raw: &str) -> Result<LocalForward> {
let target_port = target_port let target_port = target_port
.parse::<u16>() .parse::<u16>()
.with_context(|| format!("invalid local forward target port in {raw:?}"))?; .with_context(|| format!("invalid local forward target port in {raw:?}"))?;
if target_host.is_empty() { if !valid_forward_host(target_host) {
return Err(anyhow!("invalid -L {raw:?}; target host cannot be empty")); return Err(anyhow!("invalid -L {raw:?}; target host cannot be empty"));
} }
if bind_host.is_empty() { if !valid_forward_host(&bind_host) {
return Err(anyhow!("invalid -L {raw:?}; bind host cannot be empty")); return Err(anyhow!("invalid -L {raw:?}; bind host cannot be empty"));
} }
anyhow::ensure!(
listen_port != 0,
"invalid -L {raw:?}; listen port cannot be 0"
);
anyhow::ensure!(
target_port != 0,
"invalid -L {raw:?}; target port cannot be 0"
);
Ok(LocalForward { Ok(LocalForward {
bind_host, bind_host,
listen_port, listen_port,
@@ -996,15 +1210,26 @@ fn parse_dynamic_forward(raw: &str) -> Result<DynamicForward> {
let listen_port = listen let listen_port = listen
.parse::<u16>() .parse::<u16>()
.with_context(|| format!("invalid dynamic forward listen port in {raw:?}"))?; .with_context(|| format!("invalid dynamic forward listen port in {raw:?}"))?;
if bind_host.is_empty() { if !valid_forward_host(&bind_host) {
return Err(anyhow!("invalid -D {raw:?}; bind host cannot be empty")); return Err(anyhow!("invalid -D {raw:?}; bind host cannot be empty"));
} }
anyhow::ensure!(
listen_port != 0,
"invalid -D {raw:?}; listen port cannot be 0"
);
Ok(DynamicForward { Ok(DynamicForward {
bind_host, bind_host,
listen_port, listen_port,
}) })
} }
fn valid_forward_host(host: &str) -> bool {
!host.is_empty()
&& !host
.bytes()
.any(|byte| byte == 0 || byte == b'\n' || byte == b'\r' || byte == b'\t')
}
fn forwarding_requests( fn forwarding_requests(
local_forwards: &[LocalForward], local_forwards: &[LocalForward],
remote_forwards: &[RemoteForward], remote_forwards: &[RemoteForward],
@@ -1213,10 +1438,51 @@ fn run_update(config: &dosh::config::ClientConfig, check_only: bool) -> Result<(
Ok(()) Ok(())
} }
#[derive(Debug, Clone, PartialEq, Eq)]
enum ImportAction {
Added,
Skipped,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ImportedHost {
alias: String,
ssh: String,
dosh_host: String,
port: u16,
action: ImportAction,
}
fn run_import_ssh(config: &dosh::config::ClientConfig, aliases: &[String]) -> Result<()> { fn run_import_ssh(config: &dosh::config::ClientConfig, aliases: &[String]) -> Result<()> {
if aliases.is_empty() { if aliases.is_empty() {
return Err(anyhow!("usage: dosh import-ssh <ssh-host> [ssh-host...]")); return Err(anyhow!("usage: dosh import-ssh <ssh-host> [ssh-host...]"));
} }
let imported = import_ssh_aliases(config, aliases)?;
for entry in imported {
match entry.action {
ImportAction::Added => eprintln!(
"dosh import-ssh: added [{}] ssh={} dosh_host={}",
entry.alias, entry.ssh, entry.dosh_host
),
ImportAction::Skipped => {
eprintln!(
"dosh import-ssh: [{}] already exists, skipping",
entry.alias
)
}
}
}
eprintln!(
"dosh import-ssh: wrote {}",
expand_tilde("~/.config/dosh/hosts.toml").display()
);
Ok(())
}
fn import_ssh_aliases(
config: &dosh::config::ClientConfig,
aliases: &[String],
) -> Result<Vec<ImportedHost>> {
let path = expand_tilde("~/.config/dosh/hosts.toml"); let path = expand_tilde("~/.config/dosh/hosts.toml");
let mut raw = if path.exists() { let mut raw = if path.exists() {
fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))? fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?
@@ -1226,6 +1492,7 @@ fn run_import_ssh(config: &dosh::config::ClientConfig, aliases: &[String]) -> Re
if !raw.ends_with('\n') && !raw.is_empty() { if !raw.ends_with('\n') && !raw.is_empty() {
raw.push('\n'); raw.push('\n');
} }
let mut imported = Vec::new();
for alias in aliases { for alias in aliases {
let ssh_config = ssh_config(alias, None) let ssh_config = ssh_config(alias, None)
.with_context(|| format!("read SSH config for {alias} with ssh -G"))?; .with_context(|| format!("read SSH config for {alias} with ssh -G"))?;
@@ -1234,7 +1501,13 @@ fn run_import_ssh(config: &dosh::config::ClientConfig, aliases: &[String]) -> Re
.clone() .clone()
.unwrap_or_else(|| ssh_destination_host(alias)); .unwrap_or_else(|| ssh_destination_host(alias));
if raw_contains_host_table(&raw, alias) { if raw_contains_host_table(&raw, alias) {
eprintln!("dosh import-ssh: [{alias}] already exists, skipping"); imported.push(ImportedHost {
alias: alias.clone(),
ssh: alias.clone(),
dosh_host: udp_host,
port: config.dosh_port,
action: ImportAction::Skipped,
});
continue; continue;
} }
raw.push('\n'); raw.push('\n');
@@ -1251,13 +1524,19 @@ fn run_import_ssh(config: &dosh::config::ClientConfig, aliases: &[String]) -> Re
raw.push_str(&format!("ssh_port = {port}\n")); raw.push_str(&format!("ssh_port = {port}\n"));
} }
raw.push_str("predict = true\n"); raw.push_str("predict = true\n");
imported.push(ImportedHost {
alias: alias.clone(),
ssh: alias.clone(),
dosh_host: udp_host,
port: config.dosh_port,
action: ImportAction::Added,
});
} }
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?; fs::create_dir_all(parent)?;
} }
fs::write(&path, raw).with_context(|| format!("write {}", path.display()))?; fs::write(&path, raw).with_context(|| format!("write {}", path.display()))?;
eprintln!("dosh import-ssh: wrote {}", path.display()); Ok(imported)
Ok(())
} }
fn raw_contains_host_table(raw: &str, alias: &str) -> bool { fn raw_contains_host_table(raw: &str, alias: &str) -> bool {
@@ -4202,10 +4481,12 @@ impl DisconnectStatus {
/// Pure: render the status text for a given elapsed silence. Kept separate /// Pure: render the status text for a given elapsed silence. Kept separate
/// from any I/O so it can be asserted in tests. /// from any I/O so it can be asserted in tests.
fn render_text(elapsed: Duration) -> String { fn render_text(elapsed: Duration) -> String {
format!( let secs = elapsed.as_secs();
"[dosh] last contact {}s ago — reconnecting…", if secs >= 30 {
elapsed.as_secs() format!("[dosh] still reconnecting — last contact {secs}s ago")
) } else {
format!("[dosh] reconnecting — last contact {secs}s ago")
}
} }
/// Pure decision for a status timer tick: given how long the link has been /// Pure decision for a status timer tick: given how long the link has been
@@ -4384,12 +4665,13 @@ const TERMINAL_CLEANUP: &[u8] = concat!(
mod tests { mod tests {
use super::{ use super::{
DisconnectStatus, DynamicForward, FrameBuffer, LocalForward, PredictMode, Predictor, DisconnectStatus, DynamicForward, FrameBuffer, LocalForward, PredictMode, Predictor,
RemoteForward, SshConfig, StatusAction, auth_allows, latest_release_download_url, RemoteForward, SshConfig, StatusAction, auth_allows, ensure_tui_safe_status_overlay,
load_first_native_identity_with_prompt, parse_dynamic_forward, parse_local_forward, latest_release_download_url, load_first_native_identity_with_prompt, parse_dynamic_forward,
parse_remote_forward, parse_ssh_config, raw_contains_host_table, recv_response_until, parse_local_forward, parse_remote_forward, parse_ssh_config, raw_contains_host_table,
render_status_clear, render_status_overlay, requested_env, resolved_startup_command, recv_response_until, render_status_clear, render_status_overlay, requested_env,
ssh_destination_host, ssh_username, ssh_with_user, startup_command, resolved_startup_command, rewrite_forward_command, ssh_destination_host, ssh_username,
toml_bare_key_or_quoted, update_check_requested, ssh_with_user, startup_command, toml_bare_key_or_quoted, update_check_requested,
valid_forward_host,
}; };
use dosh::config::{ClientConfig, CommandExtension, HostConfig}; use dosh::config::{ClientConfig, CommandExtension, HostConfig};
use dosh::native::EnvVar; use dosh::native::EnvVar;
@@ -4397,6 +4679,37 @@ mod tests {
use ed25519_dalek::{SigningKey, VerifyingKey}; use ed25519_dalek::{SigningKey, VerifyingKey};
use std::time::Duration; use std::time::Duration;
fn test_args(server: &str, command: &[&str]) -> super::Args {
super::Args {
server: Some(server.to_string()),
command: command.iter().map(|value| value.to_string()).collect(),
session: None,
new: false,
view_only: false,
local_auth: false,
auth: None,
no_cache: false,
remove: false,
replace: false,
attach_only: false,
local_forward: Vec::new(),
remote_forward: Vec::new(),
dynamic_forward: Vec::new(),
forward_agent: false,
forward_only: false,
background: false,
predict: false,
ssh_port: None,
ssh_auth_command: None,
ssh_key: None,
ssh_known_hosts: None,
ssh_control_path: None,
dosh_port: None,
dosh_host: None,
verbose: 0,
}
}
#[test] #[test]
fn parses_ssh_config_hostname() { fn parses_ssh_config_hostname() {
let raw = "user alice\nhostname server.example.com\nport 22\n"; let raw = "user alice\nhostname server.example.com\nport 22\n";
@@ -5105,6 +5418,40 @@ mod tests {
); );
} }
#[test]
fn forward_command_rewrites_to_forward_only_connection() {
let args = test_args(
"forward",
&["homelab", "-L", "8080:127.0.0.1:80", "-D", "1080", "-f"],
);
let rewritten = rewrite_forward_command(args).unwrap();
assert_eq!(rewritten.server.as_deref(), Some("homelab"));
assert!(rewritten.forward_only);
assert!(rewritten.background);
assert_eq!(rewritten.command, Vec::<String>::new());
assert_eq!(rewritten.local_forward, vec!["8080:127.0.0.1:80"]);
assert_eq!(rewritten.dynamic_forward, vec!["1080"]);
}
#[test]
fn forward_command_requires_a_forward_request() {
let err = rewrite_forward_command(test_args("forward", &["homelab"]))
.unwrap_err()
.to_string();
assert!(err.contains("requires at least one"));
}
#[test]
fn forward_parser_rejects_zero_ports_and_control_chars() {
assert!(parse_local_forward("0:127.0.0.1:80").is_err());
assert!(parse_local_forward("8080:127.0.0.1:0").is_err());
assert!(parse_dynamic_forward("127.0.0.1:0").is_err());
assert!(!valid_forward_host("bad\nhost"));
assert!(!valid_forward_host("bad\thost"));
assert!(valid_forward_host("server.example.com"));
}
// --- Item 1: disconnect status line state machine --- // --- Item 1: disconnect status line state machine ---
/// The status line stays hidden while the link is fresh and only appears once /// The status line stays hidden while the link is fresh and only appears once
@@ -5121,7 +5468,7 @@ mod tests {
let action = status.on_tick(Duration::from_secs(3)); let action = status.on_tick(Duration::from_secs(3));
assert_eq!( assert_eq!(
action, action,
StatusAction::Show("[dosh] last contact 3s ago — reconnecting…".to_string()) StatusAction::Show("[dosh] reconnecting — last contact 3s ago".to_string())
); );
status.applied(&action); status.applied(&action);
assert!(status.shown); assert!(status.shown);
@@ -5140,7 +5487,7 @@ mod tests {
// Next second -> repaint with updated text. // Next second -> repaint with updated text.
assert_eq!( assert_eq!(
status.on_tick(Duration::from_secs(4)), status.on_tick(Duration::from_secs(4)),
StatusAction::Show("[dosh] last contact 4s ago — reconnecting…".to_string()) StatusAction::Show("[dosh] reconnecting — last contact 4s ago".to_string())
); );
} }
@@ -5185,7 +5532,8 @@ mod tests {
/// the application's cursor and full-screen TUIs are never disturbed. /// the application's cursor and full-screen TUIs are never disturbed.
#[test] #[test]
fn disconnect_status_overlay_is_save_restore_bottom_row() { fn disconnect_status_overlay_is_save_restore_bottom_row() {
let bytes = render_status_overlay("[dosh] last contact 3s ago — reconnecting…", 24); let bytes = render_status_overlay("[dosh] reconnecting — last contact 3s ago", 24);
ensure_tui_safe_status_overlay(&bytes).unwrap();
assert!(bytes.starts_with(b"\x1b7"), "must save cursor first"); assert!(bytes.starts_with(b"\x1b7"), "must save cursor first");
assert!(bytes.ends_with(b"\x1b8"), "must restore cursor last"); assert!(bytes.ends_with(b"\x1b8"), "must restore cursor last");
// Positions to the last row (row 24), column 1. // Positions to the last row (row 24), column 1.
@@ -5202,6 +5550,18 @@ mod tests {
assert!(clear.windows(4).any(|w| w == b"\x1b[2K")); assert!(clear.windows(4).any(|w| w == b"\x1b[2K"));
} }
#[test]
fn disconnect_status_text_escalates_after_long_silence() {
assert_eq!(
DisconnectStatus::render_text(Duration::from_secs(29)),
"[dosh] reconnecting — last contact 29s ago"
);
assert_eq!(
DisconnectStatus::render_text(Duration::from_secs(30)),
"[dosh] still reconnecting — last contact 30s ago"
);
}
// --- Item 2: predictor policy re-evaluated on a timer tick --- // --- Item 2: predictor policy re-evaluated on a timer tick ---
/// A latency spike must surface predictions on a *timer tick*, not only on /// A latency spike must surface predictions on a *timer tick*, not only on