Compare commits

...
9 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
DuProcess 013d653f99 Quiet Gitea prebuilt fallback
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 18:10:15 -04:00
DuProcess 4e7e4cff10 Update release artifact checksums
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 18:09:25 -04:00
DuProcess fbad776441 Resolve Gitea release downloads
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 18:08:13 -04:00
DuProcess 90d9b583c0 Try prebuilt installers by default
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 18:03:11 -04:00
DuProcess f7c4ebaaf7 Add macOS release artifact evidence
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 18:00:56 -04:00
DuProcess 27419f4ca8 Fix Gitea release id parsing
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 17:51:36 -04:00
DuProcess ec2422bc3e Remove personal host aliases from defaults
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 17:50:26 -04:00
12 changed files with 581 additions and 95 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"
+36 -13
View File
@@ -110,7 +110,7 @@ Install the client on macOS:
```bash ```bash
curl -fsSL https://git.palav.dev/Palav/dosh/raw/branch/main/install.sh \ curl -fsSL https://git.palav.dev/Palav/dosh/raw/branch/main/install.sh \
| DOSH_REPO=https://git.palav.dev/Palav/dosh.git DOSH_SERVER=palav DOSH_HOST=git.palav.dev DOSH_PORT=50000 sh -s -- client | DOSH_REPO=https://git.palav.dev/Palav/dosh.git DOSH_PORT=50000 sh -s -- client
``` ```
Update an installed client later: Update an installed client later:
@@ -123,28 +123,43 @@ 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:
```powershell ```powershell
$env:DOSH_REPO="https://git.palav.dev/Palav/dosh.git"; $env:DOSH_SERVER="palav"; $env:DOSH_HOST="git.palav.dev"; $env:DOSH_PORT="50000"; irm https://git.palav.dev/Palav/dosh/raw/branch/main/install.ps1 | iex $env:DOSH_REPO="https://git.palav.dev/Palav/dosh.git"; $env:DOSH_PORT="50000"; irm https://git.palav.dev/Palav/dosh/raw/branch/main/install.ps1 | iex
``` ```
Attach: Attach:
```bash ```bash
dosh palav dosh user@server.example.com
``` ```
Plain `dosh palav` opens a fresh terminal session. Use named sessions when you want First-time setup for an existing SSH alias:
to reattach to the same persistent terminal from multiple clients:
```bash ```bash
dosh --session work palav dosh setup homelab
dosh --session logs palav 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:
```bash
dosh --session work user@server.example.com
dosh --session logs user@server.example.com
``` ```
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.
@@ -164,13 +179,21 @@ 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
in Dosh's host config: in Dosh's host config:
```bash ```bash
dosh import-ssh palav homelab dosh import-ssh homelab
dosh homelab
``` ```
Optional command extensions are just config-side startup shortcuts; Dosh has no Optional command extensions are just config-side startup shortcuts; Dosh has no
@@ -184,12 +207,12 @@ command = "tm {args}"
description = "Open the server-side tmux dashboard" description = "Open the server-side tmux dashboard"
``` ```
Then `dosh palav tm` sends `tm`, and `dosh palav tm dosh` sends `tm 'dosh'`. Then `dosh homelab tm` sends `tm`, and `dosh homelab tm dosh` sends `tm 'dosh'`.
Remove that table to remove the integration. Hosts can override or opt out: Remove that table to remove the integration. Hosts can override or opt out:
```toml ```toml
# ~/.config/dosh/hosts.toml # ~/.config/dosh/hosts.toml
[palav.extensions.tm] [homelab.extensions.tm]
command = "/opt/tm/bin/tm {args}" command = "/opt/tm/bin/tm {args}"
[other-host.extensions.tm] [other-host.extensions.tm]
+4 -4
View File
@@ -95,7 +95,7 @@ Dosh also calls `ssh -G <alias>` to infer the UDP target host when no `dosh_host
configured. To write explicit Dosh host entries from SSH aliases: configured. To write explicit Dosh host entries from SSH aliases:
```bash ```bash
dosh import-ssh palav homelab dosh import-ssh homelab
``` ```
This appends entries to `~/.config/dosh/hosts.toml` without trying to become an This appends entries to `~/.config/dosh/hosts.toml` without trying to become an
@@ -112,12 +112,12 @@ command = "tm {args}"
description = "Open the server-side tmux dashboard" description = "Open the server-side tmux dashboard"
``` ```
With that config, `dosh palav tm` runs `tm` in the remote Dosh shell and With that config, `dosh homelab tm` runs `tm` in the remote Dosh shell and
`dosh palav tm dosh` runs `tm 'dosh'`. Removing the table removes the integration. `dosh homelab tm dosh` runs `tm 'dosh'`. Removing the table removes the integration.
Host config can override a global extension, or disable it: Host config can override a global extension, or disable it:
```toml ```toml
[palav.extensions.tm] [homelab.extensions.tm]
command = "/opt/tm/bin/tm {args}" command = "/opt/tm/bin/tm {args}"
[other-host.extensions.tm] [other-host.extensions.tm]
+8 -3
View File
@@ -1,6 +1,9 @@
# Dosh Release Evidence - 2026-06-20 # Dosh Release Evidence - 2026-06-20
Code benchmarked: `b44ff8e Improve release and benchmark tooling` Runtime code benchmarked: `b44ff8e Improve release and benchmark tooling`
Release docs/default cleanup after that benchmark removed personal host aliases from
public examples and test fixtures; it did not change release runtime paths.
Environment: Environment:
@@ -21,8 +24,10 @@ Artifacts:
| artifact | sha256 | | artifact | sha256 |
| --- | --- | | --- | --- |
| `dosh-linux-x86_64.tar.gz` | `f421ee56aac61892c9e152d2ef018cf95eb35dec2ca1b2b5cc82eeb1fcc12911` | | `dosh-linux-x86_64.tar.gz` | `9f586852a506cca3caf618743e12967265879ee10f5294d179cdcd668db1e808` |
| `dosh-0.1.0-linux-x86_64.tar.gz` | `f421ee56aac61892c9e152d2ef018cf95eb35dec2ca1b2b5cc82eeb1fcc12911` | | `dosh-0.1.0-linux-x86_64.tar.gz` | `9f586852a506cca3caf618743e12967265879ee10f5294d179cdcd668db1e808` |
| `dosh-macos-aarch64.tar.gz` | `dd1151aa2b40be37288dc19a07521b0ec72d1106439eb484cdff01cca3b891c6` |
| `dosh-0.1.0-macos-aarch64.tar.gz` | `dd1151aa2b40be37288dc19a07521b0ec72d1106439eb484cdff01cca3b891c6` |
The installer verifies `<artifact>.sha256` when the sidecar is published. The installer verifies `<artifact>.sha256` when the sidecar is published.
+7 -2
View File
@@ -6,7 +6,7 @@ param(
[string]$DoshHost = $(if ($env:DOSH_HOST) { $env:DOSH_HOST } else { $env:DOSH_DOSH_HOST }), [string]$DoshHost = $(if ($env:DOSH_HOST) { $env:DOSH_HOST } else { $env:DOSH_DOSH_HOST }),
[int]$Port = $(if ($env:DOSH_PORT) { [int]$env:DOSH_PORT } else { 50000 }), [int]$Port = $(if ($env:DOSH_PORT) { [int]$env:DOSH_PORT } else { 50000 }),
[string]$Prefix = $(if ($env:PREFIX) { $env:PREFIX } else { Join-Path $HOME ".local" }), [string]$Prefix = $(if ($env:PREFIX) { $env:PREFIX } else { Join-Path $HOME ".local" }),
[switch]$UsePrebuilt = $($env:DOSH_USE_PREBUILT -and $env:DOSH_USE_PREBUILT -ne "0"), [switch]$UsePrebuilt = $(-not $env:DOSH_USE_PREBUILT -or $env:DOSH_USE_PREBUILT -ne "0"),
[string]$BinaryUrl = $env:DOSH_BINARY_URL, [string]$BinaryUrl = $env:DOSH_BINARY_URL,
[string]$BinaryBase = $env:DOSH_BINARY_BASE, [string]$BinaryBase = $env:DOSH_BINARY_BASE,
[string]$BinaryName = $env:DOSH_BINARY_NAME, [string]$BinaryName = $env:DOSH_BINARY_NAME,
@@ -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."
+39 -9
View File
@@ -12,7 +12,7 @@ start_server=1
force_config=0 force_config=0
update_cache="${DOSH_UPDATE_CACHE:-$HOME/.cache/dosh/source}" update_cache="${DOSH_UPDATE_CACHE:-$HOME/.cache/dosh/source}"
quiet="${DOSH_UPDATE_QUIET:-0}" quiet="${DOSH_UPDATE_QUIET:-0}"
use_prebuilt="${DOSH_USE_PREBUILT:-0}" use_prebuilt="${DOSH_USE_PREBUILT:-1}"
binary_url="${DOSH_BINARY_URL:-}" binary_url="${DOSH_BINARY_URL:-}"
binary_base="${DOSH_BINARY_BASE:-}" binary_base="${DOSH_BINARY_BASE:-}"
binary_name="${DOSH_BINARY_NAME:-}" binary_name="${DOSH_BINARY_NAME:-}"
@@ -38,8 +38,8 @@ Options:
Environment alternatives: Environment alternatives:
DOSH_REPO, DOSH_ROLE, DOSH_SERVER, DOSH_HOST, DOSH_PORT, PREFIX, DOSH_REPO, DOSH_ROLE, DOSH_SERVER, DOSH_HOST, DOSH_PORT, PREFIX,
DOSH_UPDATE_CACHE DOSH_UPDATE_CACHE
DOSH_USE_PREBUILT=1 DOSH_USE_PREBUILT=0
Try a release tarball before building from source Build from source instead of trying a release tarball first
DOSH_BINARY_URL URL DOSH_BINARY_URL URL
Exact release tarball URL to install Exact release tarball URL to install
DOSH_BINARY_BASE URL DOSH_BINARY_BASE URL
@@ -204,6 +204,24 @@ release_download_url() {
fi fi
} }
release_latest_tag_download_url() {
if [ -n "$binary_url" ] || [ -n "$binary_base" ] || [ "$binary_version" != "latest" ] || [ -z "$repo" ]; then
return 1
fi
web_base="$(repo_web_base "$repo")" || return 1
latest_url="$(curl -fsSL -o /dev/null -w '%{url_effective}' "$web_base/releases/latest" 2>/dev/null || true)"
case "$latest_url" in
"$web_base"/releases/tag/*)
tag="${latest_url##"$web_base"/releases/tag/}"
[ -n "$tag" ] || return 1
printf '%s/releases/download/%s/%s\n' "$web_base" "$tag" "$(release_artifact_name)"
;;
*)
return 1
;;
esac
}
find_extracted_binary() { find_extracted_binary() {
find "$1" -type f -name "$2" 2>/dev/null | sed -n '1p' find "$1" -type f -name "$2" 2>/dev/null | sed -n '1p'
} }
@@ -254,10 +272,15 @@ try_install_prebuilt() {
[ "$quiet" = "1" ] && echo "Trying Dosh prebuilt $(release_artifact_name)" [ "$quiet" = "1" ] && echo "Trying Dosh prebuilt $(release_artifact_name)"
need curl need curl
need tar need tar
if ! curl -fL "$download_url" -o "$archive"; then if ! curl -fsL "$download_url" -o "$archive" 2>/dev/null; then
alt_download_url="$(release_latest_tag_download_url || true)"
if [ -z "$alt_download_url" ] || ! curl -fsL "$alt_download_url" -o "$archive"; then
echo "prebuilt unavailable: $download_url" >&2 echo "prebuilt unavailable: $download_url" >&2
[ -z "$alt_download_url" ] || echo "prebuilt unavailable: $alt_download_url" >&2
return 1 return 1
fi fi
download_url="$alt_download_url"
fi
verify_archive_checksum "$download_url" "$archive" "$checksum_file" || return 1 verify_archive_checksum "$download_url" "$archive" "$checksum_file" || return 1
mkdir -p "$tmpdir/extract" mkdir -p "$tmpdir/extract"
if ! tar -xzf "$archive" -C "$tmpdir/extract"; then if ! tar -xzf "$archive" -C "$tmpdir/extract"; then
@@ -482,9 +505,9 @@ EOF
fi fi
cat >"$hosts_config" <<EOF cat >"$hosts_config" <<EOF
# Example: # Example:
# [palav] # [homelab]
# ssh = "palav" # ssh = "homelab"
# dosh_host = "palav.dev" # dosh_host = "server.example.com"
# port = 50000 # port = 50000
# default_command = "tm" # default_command = "tm"
# predict = true # predict = true
@@ -504,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
@@ -171,7 +171,7 @@ dosh doctor homelab # host resolution, trust state, UDP reachability, server
dosh sessions homelab # list live sessions dosh sessions homelab # list live sessions
dosh trust homelab # fetch + pin the Dosh host key (via SSH fallback) dosh trust homelab # fetch + pin the Dosh host key (via SSH fallback)
dosh trust --remove homelab dosh trust --remove homelab
dosh import-ssh palav homelab # write a hosts.toml entry from an SSH alias dosh import-ssh homelab # write a hosts.toml entry from an SSH alias
dosh update # update the installed client dosh update # update the installed client
``` ```
+30 -3
View File
@@ -43,13 +43,22 @@ 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="$(printf '%s\n' "$release_json" | sed -n 's/.*"id":[[:space:]]*\([0-9][0-9]*\).*/\1/p' | sed -n '1p')" release_id=""
if [ -n "$release_json" ] && command -v jq >/dev/null 2>&1; then
release_id="$(printf '%s\n' "$release_json" | jq -r '.id // empty')"
elif [ -n "$release_json" ]; then
release_id="$(printf '%s\n' "$release_json" | sed -n 's/^[[:space:]]*{"id":[[:space:]]*\([0-9][0-9]*\).*/\1/p' | sed -n '1p')"
fi
if [ -z "$release_id" ]; then if [ -z "$release_id" ]; then
payload="$(printf '{"tag_name":"%s","target_commitish":"main","name":"%s","body":"Dosh release %s","draft":false,"prerelease":false}\n' "$tag" "$title" "$tag")" payload="$(printf '{"tag_name":"%s","target_commitish":"main","name":"%s","body":"Dosh release %s","draft":false,"prerelease":false}\n' "$tag" "$title" "$tag")"
release_json="$(curl -fsS -H "$auth_header" -H 'Content-Type: application/json' -X POST "$api/releases" -d "$payload")" release_json="$(curl -fsS -H "$auth_header" -H 'Content-Type: application/json' -X POST "$api/releases" -d "$payload")"
release_id="$(printf '%s\n' "$release_json" | sed -n 's/.*"id":[[:space:]]*\([0-9][0-9]*\).*/\1/p' | sed -n '1p')" if command -v jq >/dev/null 2>&1; then
release_id="$(printf '%s\n' "$release_json" | jq -r '.id // empty')"
else
release_id="$(printf '%s\n' "$release_json" | sed -n 's/^[[:space:]]*{"id":[[:space:]]*\([0-9][0-9]*\).*/\1/p' | sed -n '1p')"
fi
fi fi
if [ -z "$release_id" ]; then if [ -z "$release_id" ]; then
@@ -57,11 +66,29 @@ if [ -z "$release_id" ]; then
exit 1 exit 1
fi fi
delete_existing_asset() {
name="$1"
if ! command -v jq >/dev/null 2>&1; then
return 0
fi
printf '%s\n' "$release_json" \
| jq -r --arg name "$name" '.assets[]? | select(.name == $name) | .id' \
| while IFS= read -r asset_id; do
[ -n "$asset_id" ] || continue
echo "replacing $name"
curl -fsS \
-H "$auth_header" \
-X DELETE \
"$api/releases/$release_id/assets/$asset_id" >/dev/null
done
}
for artifact in "$@"; do for artifact in "$@"; do
if [ ! -f "$artifact" ]; then if [ ! -f "$artifact" ]; then
continue continue
fi fi
name="$(basename "$artifact")" name="$(basename "$artifact")"
delete_existing_asset "$name"
echo "uploading $name" echo "uploading $name"
curl -fsS \ curl -fsS \
-H "$auth_header" \ -H "$auth_header" \
+440 -44
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],
@@ -1110,6 +1335,38 @@ fn latest_release_download_url(repo: &str, artifact: &str) -> Option<String> {
Some(format!("{web}/releases/latest/download/{artifact}")) Some(format!("{web}/releases/latest/download/{artifact}"))
} }
fn latest_release_tag_download_url(repo: &str, artifact: &str) -> Result<Option<String>> {
let web = repo
.strip_suffix(".git")
.unwrap_or(repo)
.trim_end_matches('/');
if !web.starts_with("http://") && !web.starts_with("https://") {
return Ok(None);
}
let output = Command::new("curl")
.arg("-fsSL")
.arg("-o")
.arg("/dev/null")
.arg("-w")
.arg("%{url_effective}")
.arg(format!("{web}/releases/latest"))
.stdin(Stdio::null())
.output()
.with_context(|| format!("resolve latest release for {web}"))?;
if !output.status.success() {
return Ok(None);
}
let effective = String::from_utf8_lossy(&output.stdout);
let prefix = format!("{web}/releases/tag/");
let Some(tag) = effective.strip_prefix(&prefix) else {
return Ok(None);
};
if tag.is_empty() {
return Ok(None);
}
Ok(Some(format!("{web}/releases/download/{tag}/{artifact}")))
}
fn url_reachable(url: &str) -> Result<bool> { fn url_reachable(url: &str) -> Result<bool> {
let status = Command::new("curl") let status = Command::new("curl")
.arg("-fsIL") .arg("-fsIL")
@@ -1130,19 +1387,23 @@ fn run_update(config: &dosh::config::ClientConfig, check_only: bool) -> Result<(
let raw_base = raw_base_from_repo(&repo); let raw_base = raw_base_from_repo(&repo);
let installer = format!("{raw_base}/raw/branch/main/install.sh"); let installer = format!("{raw_base}/raw/branch/main/install.sh");
let artifact = release_artifact_name(); let artifact = release_artifact_name();
let binary_url = latest_release_download_url(&repo, artifact);
if check_only { if check_only {
println!("dosh {}", env!("CARGO_PKG_VERSION")); println!("dosh {}", env!("CARGO_PKG_VERSION"));
println!("repo: {repo}"); println!("repo: {repo}");
println!("installer: {installer}"); println!("installer: {installer}");
if let Some(binary_url) = &binary_url { if let Some(latest_url) = latest_release_download_url(&repo, artifact) {
let status = if url_reachable(binary_url)? { let mut status = "missing";
"available" let mut display_url = latest_url.clone();
} else { if url_reachable(&latest_url)? {
"missing" status = "available";
}; } else if let Some(tag_url) = latest_release_tag_download_url(&repo, artifact)?
&& url_reachable(&tag_url)?
{
status = "available";
display_url = tag_url;
}
println!("prebuilt: {status} ({artifact})"); println!("prebuilt: {status} ({artifact})");
println!("prebuilt_url: {binary_url}"); println!("prebuilt_url: {display_url}");
} else { } else {
println!("prebuilt: unavailable for non-HTTP repo"); println!("prebuilt: unavailable for non-HTTP repo");
} }
@@ -1177,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()))?
@@ -1190,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"))?;
@@ -1198,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');
@@ -1215,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 {
@@ -4166,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
@@ -4348,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;
@@ -4361,12 +4679,43 @@ 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 palav\nhostname palav.dev\nport 22\n"; let raw = "user alice\nhostname server.example.com\nport 22\n";
assert_eq!( assert_eq!(
parse_ssh_config(raw).hostname, parse_ssh_config(raw).hostname,
Some("palav.dev".to_string()) Some("server.example.com".to_string())
); );
} }
@@ -4389,9 +4738,9 @@ mod tests {
#[test] #[test]
fn parses_ssh_config_user_and_identity_files() { fn parses_ssh_config_user_and_identity_files() {
let parsed = parse_ssh_config( let parsed = parse_ssh_config(
"user palav\nidentityfile ~/.ssh/id_ed25519\nidentityfile ~/.ssh/work\n", "user alice\nidentityfile ~/.ssh/id_ed25519\nidentityfile ~/.ssh/work\n",
); );
assert_eq!(parsed.user, Some("palav".to_string())); assert_eq!(parsed.user, Some("alice".to_string()));
assert_eq!( assert_eq!(
parsed.identity_files, parsed.identity_files,
vec!["~/.ssh/id_ed25519".to_string(), "~/.ssh/work".to_string()] vec!["~/.ssh/id_ed25519".to_string(), "~/.ssh/work".to_string()]
@@ -4478,37 +4827,37 @@ mod tests {
#[test] #[test]
fn detects_existing_imported_host_tables() { fn detects_existing_imported_host_tables() {
assert!(raw_contains_host_table( assert!(raw_contains_host_table(
"[palav]\nssh = \"palav\"\n", "[homelab]\nssh = \"homelab\"\n",
"palav" "homelab"
)); ));
assert!(raw_contains_host_table("[\"home box\"]\n", "home box")); 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("homelab"), "homelab");
assert_eq!(toml_bare_key_or_quoted("home box"), "\"home box\""); 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("alice@example.com"), "example.com");
} }
#[test] #[test]
fn parses_user_from_ssh_destination() { fn parses_user_from_ssh_destination() {
assert_eq!(ssh_username("palav@example.com"), Some("palav".to_string())); assert_eq!(ssh_username("alice@example.com"), Some("alice".to_string()));
assert_eq!(ssh_username("example.com"), None); assert_eq!(ssh_username("example.com"), None);
} }
#[test] #[test]
fn applies_host_config_user_to_plain_ssh_destination() { fn applies_host_config_user_to_plain_ssh_destination() {
assert_eq!( assert_eq!(
ssh_with_user("example.com", Some("palav")), ssh_with_user("example.com", Some("alice")),
"palav@example.com" "alice@example.com"
); );
assert_eq!( assert_eq!(
ssh_with_user("root@example.com", Some("palav")), ssh_with_user("root@example.com", Some("alice")),
"root@example.com" "root@example.com"
); );
assert_eq!( assert_eq!(
ssh_with_user("ssh://example.com", Some("palav")), ssh_with_user("ssh://example.com", Some("alice")),
"ssh://example.com" "ssh://example.com"
); );
} }
@@ -5069,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
@@ -5085,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);
@@ -5104,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())
); );
} }
@@ -5149,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.
@@ -5166,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
+10 -10
View File
@@ -1279,16 +1279,16 @@ mod tests {
let second = host_public_key(&SigningKey::from_bytes(&[2u8; 32])); let second = host_public_key(&SigningKey::from_bytes(&[2u8; 32]));
assert_eq!( assert_eq!(
trust_host(&path, "palav", &first, "ssh", false).unwrap(), trust_host(&path, "homelab", &first, "ssh", false).unwrap(),
TrustResult::Trusted TrustResult::Trusted
); );
assert_eq!( assert_eq!(
trust_host(&path, "palav", &first, "ssh", false).unwrap(), trust_host(&path, "homelab", &first, "ssh", false).unwrap(),
TrustResult::AlreadyTrusted TrustResult::AlreadyTrusted
); );
assert!(trust_host(&path, "palav", &second, "ssh", false).is_err()); assert!(trust_host(&path, "homelab", &second, "ssh", false).is_err());
assert_eq!( assert_eq!(
trust_host(&path, "palav", &second, "ssh", true).unwrap(), trust_host(&path, "homelab", &second, "ssh", true).unwrap(),
TrustResult::Trusted TrustResult::Trusted
); );
} }
@@ -1301,16 +1301,16 @@ mod tests {
let second = host_public_key(&SigningKey::from_bytes(&[2u8; 32])); let second = host_public_key(&SigningKey::from_bytes(&[2u8; 32]));
assert_eq!( assert_eq!(
verify_known_host(&path, "palav", &first).unwrap(), verify_known_host(&path, "homelab", &first).unwrap(),
KnownHostStatus::Unknown KnownHostStatus::Unknown
); );
trust_host(&path, "palav", &first, "ssh", false).unwrap(); trust_host(&path, "homelab", &first, "ssh", false).unwrap();
assert_eq!( assert_eq!(
verify_known_host(&path, "palav", &first).unwrap(), verify_known_host(&path, "homelab", &first).unwrap(),
KnownHostStatus::Trusted KnownHostStatus::Trusted
); );
assert!(matches!( assert!(matches!(
verify_known_host(&path, "palav", &second).unwrap(), verify_known_host(&path, "homelab", &second).unwrap(),
KnownHostStatus::Mismatch { .. } KnownHostStatus::Mismatch { .. }
)); ));
} }
@@ -1625,8 +1625,8 @@ mod tests {
protocol_version: NATIVE_PROTOCOL_VERSION, protocol_version: NATIVE_PROTOCOL_VERSION,
client_random: [1u8; 32], client_random: [1u8; 32],
client_ephemeral_public: [2u8; 32], client_ephemeral_public: [2u8; 32],
requested_host: "palav".to_string(), requested_host: "homelab".to_string(),
requested_user: "palav".to_string(), requested_user: "alice".to_string(),
requested_session: "term".to_string(), requested_session: "term".to_string(),
requested_mode: "read-write".to_string(), requested_mode: "read-write".to_string(),
terminal_size: (80, 24), terminal_size: (80, 24),
+2 -2
View File
@@ -291,8 +291,8 @@ mod tests {
protocol_version: crate::native::NATIVE_PROTOCOL_VERSION, protocol_version: crate::native::NATIVE_PROTOCOL_VERSION,
client_random: [1u8; 32], client_random: [1u8; 32],
client_ephemeral_public: [2u8; 32], client_ephemeral_public: [2u8; 32],
requested_host: "palav".to_string(), requested_host: "homelab".to_string(),
requested_user: "palav".to_string(), requested_user: "alice".to_string(),
requested_session: "term".to_string(), requested_session: "term".to_string(),
requested_mode: "read-write".to_string(), requested_mode: "read-write".to_string(),
terminal_size: (80, 24), terminal_size: (80, 24),