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
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
This commit is contained in:
Generated
+1
-1
@@ -436,7 +436,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dosh"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "dosh"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
|
||||
|
||||
@@ -123,9 +123,10 @@ dosh --version
|
||||
|
||||
`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
|
||||
Gitea/GitHub release. If that asset is not published yet, it falls back to the
|
||||
source build path. If the release also publishes `<artifact>.sha256`, the installer
|
||||
verifies the archive before installing it.
|
||||
Gitea/GitHub release. Gitea's concrete tag download route is detected when
|
||||
`/releases/latest/download/...` is not supported. If that asset is not published
|
||||
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:
|
||||
|
||||
@@ -139,6 +140,19 @@ Attach:
|
||||
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
|
||||
sessions when you want to reattach to the same persistent terminal from multiple
|
||||
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
|
||||
```
|
||||
|
||||
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,
|
||||
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
|
||||
|
||||
+6
-1
@@ -200,7 +200,12 @@ Write-Host "Installed Dosh client to $bindir"
|
||||
Write-Host "Configured UDP port $Port"
|
||||
Write-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 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 "Open a new terminal for PATH changes to apply."
|
||||
|
||||
+9
-2
@@ -527,10 +527,17 @@ Configured UDP port $port
|
||||
EOF
|
||||
|
||||
if [ "$role" = "client" ] || [ "$role" = "both" ]; then
|
||||
next_server="${server:-user@host}"
|
||||
cat <<EOF
|
||||
|
||||
Client command:
|
||||
$bindir/dosh ${server:-user@host}
|
||||
Client commands:
|
||||
$bindir/dosh $next_server
|
||||
$bindir/dosh setup <ssh-alias>
|
||||
$bindir/dosh update --check
|
||||
|
||||
Client config:
|
||||
$config_dir/client.toml
|
||||
$config_dir/hosts.toml
|
||||
EOF
|
||||
fi
|
||||
|
||||
|
||||
+383
-23
@@ -66,7 +66,7 @@ fn terminal_size() -> (u16, u16) {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[derive(Debug, Clone, Parser)]
|
||||
#[command(name = "dosh-client", version)]
|
||||
struct Args {
|
||||
#[arg()]
|
||||
@@ -189,11 +189,20 @@ struct PendingStreamChunk {
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
let mut args = Args::parse();
|
||||
let config = load_client_config(None).unwrap_or_default();
|
||||
if args.server.as_deref() == Some("update") {
|
||||
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") {
|
||||
return run_import_ssh(&config, &args.command);
|
||||
}
|
||||
@@ -587,15 +596,113 @@ fn run_trust_command(config: &dosh::config::ClientConfig, args: &Args) -> Result
|
||||
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<()> {
|
||||
let requested = args
|
||||
.command
|
||||
.first()
|
||||
.cloned()
|
||||
.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 host_config = hosts.hosts.get(&requested).cloned().unwrap_or_default();
|
||||
let raw_server = host_config.ssh.clone().unwrap_or_else(|| requested.clone());
|
||||
let host_config = hosts.hosts.get(requested).cloned().unwrap_or_default();
|
||||
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 ssh_port = args.ssh_port.or(host_config.ssh_port).or(config.ssh_port);
|
||||
let dosh_port = args
|
||||
@@ -603,6 +710,20 @@ async fn run_doctor_command(config: &dosh::config::ClientConfig, args: &Args) ->
|
||||
.or(host_config.port)
|
||||
.unwrap_or(config.dosh_port);
|
||||
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) {
|
||||
Ok(parsed) => {
|
||||
@@ -652,6 +773,14 @@ async fn run_doctor_command(config: &dosh::config::ClientConfig, args: &Args) ->
|
||||
.unwrap_or_else(|| ssh_destination_host(&server))
|
||||
});
|
||||
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?;
|
||||
match try_native_auth_check(
|
||||
&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(
|
||||
server: &str,
|
||||
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() {
|
||||
let Some(addr) = std::env::var_os("DOSH_BACKGROUND_READY_ADDR") else {
|
||||
return;
|
||||
@@ -958,12 +1164,20 @@ fn parse_local_forward(raw: &str) -> Result<LocalForward> {
|
||||
let target_port = target_port
|
||||
.parse::<u16>()
|
||||
.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"));
|
||||
}
|
||||
if bind_host.is_empty() {
|
||||
if !valid_forward_host(&bind_host) {
|
||||
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 {
|
||||
bind_host,
|
||||
listen_port,
|
||||
@@ -996,15 +1210,26 @@ fn parse_dynamic_forward(raw: &str) -> Result<DynamicForward> {
|
||||
let listen_port = listen
|
||||
.parse::<u16>()
|
||||
.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"));
|
||||
}
|
||||
anyhow::ensure!(
|
||||
listen_port != 0,
|
||||
"invalid -D {raw:?}; listen port cannot be 0"
|
||||
);
|
||||
Ok(DynamicForward {
|
||||
bind_host,
|
||||
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(
|
||||
local_forwards: &[LocalForward],
|
||||
remote_forwards: &[RemoteForward],
|
||||
@@ -1213,10 +1438,51 @@ fn run_update(config: &dosh::config::ClientConfig, check_only: bool) -> Result<(
|
||||
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<()> {
|
||||
if aliases.is_empty() {
|
||||
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 mut raw = if path.exists() {
|
||||
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() {
|
||||
raw.push('\n');
|
||||
}
|
||||
let mut imported = Vec::new();
|
||||
for alias in aliases {
|
||||
let ssh_config = ssh_config(alias, None)
|
||||
.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()
|
||||
.unwrap_or_else(|| ssh_destination_host(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;
|
||||
}
|
||||
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("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() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(&path, raw).with_context(|| format!("write {}", path.display()))?;
|
||||
eprintln!("dosh import-ssh: wrote {}", path.display());
|
||||
Ok(())
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
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
|
||||
/// from any I/O so it can be asserted in tests.
|
||||
fn render_text(elapsed: Duration) -> String {
|
||||
format!(
|
||||
"[dosh] last contact {}s ago — reconnecting…",
|
||||
elapsed.as_secs()
|
||||
)
|
||||
let secs = elapsed.as_secs();
|
||||
if secs >= 30 {
|
||||
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
|
||||
@@ -4384,12 +4665,13 @@ const TERMINAL_CLEANUP: &[u8] = concat!(
|
||||
mod tests {
|
||||
use super::{
|
||||
DisconnectStatus, DynamicForward, FrameBuffer, LocalForward, PredictMode, Predictor,
|
||||
RemoteForward, SshConfig, StatusAction, auth_allows, latest_release_download_url,
|
||||
load_first_native_identity_with_prompt, parse_dynamic_forward, parse_local_forward,
|
||||
parse_remote_forward, parse_ssh_config, raw_contains_host_table, recv_response_until,
|
||||
render_status_clear, render_status_overlay, requested_env, resolved_startup_command,
|
||||
ssh_destination_host, ssh_username, ssh_with_user, startup_command,
|
||||
toml_bare_key_or_quoted, update_check_requested,
|
||||
RemoteForward, SshConfig, StatusAction, auth_allows, ensure_tui_safe_status_overlay,
|
||||
latest_release_download_url, load_first_native_identity_with_prompt, parse_dynamic_forward,
|
||||
parse_local_forward, parse_remote_forward, parse_ssh_config, raw_contains_host_table,
|
||||
recv_response_until, render_status_clear, render_status_overlay, requested_env,
|
||||
resolved_startup_command, rewrite_forward_command, ssh_destination_host, ssh_username,
|
||||
ssh_with_user, startup_command, toml_bare_key_or_quoted, update_check_requested,
|
||||
valid_forward_host,
|
||||
};
|
||||
use dosh::config::{ClientConfig, CommandExtension, HostConfig};
|
||||
use dosh::native::EnvVar;
|
||||
@@ -4397,6 +4679,37 @@ mod tests {
|
||||
use ed25519_dalek::{SigningKey, VerifyingKey};
|
||||
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]
|
||||
fn parses_ssh_config_hostname() {
|
||||
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 ---
|
||||
|
||||
/// 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));
|
||||
assert_eq!(
|
||||
action,
|
||||
StatusAction::Show("[dosh] last contact 3s ago — reconnecting…".to_string())
|
||||
StatusAction::Show("[dosh] reconnecting — last contact 3s ago".to_string())
|
||||
);
|
||||
status.applied(&action);
|
||||
assert!(status.shown);
|
||||
@@ -5140,7 +5487,7 @@ mod tests {
|
||||
// Next second -> repaint with updated text.
|
||||
assert_eq!(
|
||||
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.
|
||||
#[test]
|
||||
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.ends_with(b"\x1b8"), "must restore cursor last");
|
||||
// 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"));
|
||||
}
|
||||
|
||||
#[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 ---
|
||||
|
||||
/// A latency spike must surface predictions on a *timer tick*, not only on
|
||||
|
||||
Reference in New Issue
Block a user