Files
dosh/install.sh
T
DuProcess c0b9e17214
ci / test (push) Waiting to run
ci / fuzz-smoke (push) Waiting to run
ci / windows-client (push) Waiting to run
ci / package-release (linux-x86_64, ubuntu-latest) (push) Waiting to run
ci / package-release (macos-aarch64, macos-14) (push) Waiting to run
ci / package-release (macos-x86_64, macos-13) (push) Waiting to run
ci / package-release (windows-x86_64, windows-latest) (push) Waiting to run
ci / publish-gitea-release (push) Blocked by required conditions
ci / remote-bench (push) Waiting to run
Harden Unix update source cache path
2026-07-12 22:35:16 -04:00

739 lines
19 KiB
Bash
Executable File

#!/usr/bin/env sh
set -eu
role="${DOSH_ROLE:-both}"
repo="${DOSH_REPO:-}"
server="${DOSH_SERVER:-}"
dosh_host="${DOSH_HOST:-${DOSH_DOSH_HOST:-}}"
port="${DOSH_PORT:-50000}"
prefix="${PREFIX:-$HOME/.local}"
from_current=0
start_server=1
force_config=0
update_cache="${DOSH_UPDATE_CACHE:-$HOME/.cache/dosh/source}"
quiet="${DOSH_UPDATE_QUIET:-0}"
use_prebuilt="${DOSH_USE_PREBUILT:-1}"
binary_url="${DOSH_BINARY_URL:-}"
binary_base="${DOSH_BINARY_BASE:-}"
binary_name="${DOSH_BINARY_NAME:-}"
binary_version="${DOSH_BINARY_VERSION:-latest}"
binary_required="${DOSH_BINARY_REQUIRED:-0}"
usage() {
cat <<'EOF'
Usage:
install.sh [server|client|both] [options]
Options:
--repo URL Git repository to clone when not run from a checkout
--server HOST Default SSH target for client config, for example user@host
--dosh-host HOST UDP host for Dosh packets when different from SSH target
--port PORT Dosh UDP port; default 50000
--prefix DIR Install prefix; default ~/.local
--update-cache DIR
Persistent source checkout for incremental updates
--no-start Install server but do not start it
--force-config Rewrite existing ~/.config/dosh/*.toml files
Environment alternatives:
DOSH_REPO, DOSH_ROLE, DOSH_SERVER, DOSH_HOST, DOSH_PORT, PREFIX,
DOSH_UPDATE_CACHE
DOSH_USE_PREBUILT=0
Build from source instead of trying a release tarball first
DOSH_BINARY_URL URL
Exact release tarball URL to install
DOSH_BINARY_BASE URL
Release download base; otherwise derived from the latest tag
DOSH_BINARY_NAME NAME
Release tarball name; defaults to dosh-OS-ARCH.tar.gz
DOSH_BINARY_VERSION TAG
Release tag when deriving DOSH_BINARY_BASE; default latest
DOSH_BINARY_REQUIRED=1
Fail instead of falling back to source when binary install fails
EOF
}
while [ "$#" -gt 0 ]; do
case "$1" in
server|client|both)
role="$1"
;;
--repo)
repo="$2"
shift
;;
--server)
server="$2"
shift
;;
--dosh-host)
dosh_host="$2"
shift
;;
--port)
port="$2"
shift
;;
--prefix)
prefix="$2"
shift
;;
--update-cache)
update_cache="$2"
shift
;;
--from-current)
from_current=1
;;
--no-start)
start_server=0
;;
--force-config)
force_config=1
;;
-h|--help)
usage
exit 0
;;
*)
echo "unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
shift
done
case "$role" in
server|client|both) ;;
*)
echo "role must be server, client, or both" >&2
exit 2
;;
esac
need() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "missing required command: $1" >&2
exit 1
fi
}
normalize_update_cache_path() {
case "$1" in
/*) printf '%s\n' "$1" ;;
*) printf '%s\n' "$(pwd)/$1" ;;
esac | sed 's#//*#/#g; s#/$##'
}
safe_update_cache_path() {
raw="$1"
if [ -z "$raw" ]; then
echo "refusing unsafe update cache path: $raw" >&2
exit 2
fi
case "$raw" in
"."|".."|./*|../*|*/.|*/..|*/./*|*/../*)
echo "refusing unsafe update cache path: $raw" >&2
exit 2
;;
esac
normalized="$(normalize_update_cache_path "$raw")"
home_norm="$(normalize_update_cache_path "$HOME")"
home_cache_norm="$(normalize_update_cache_path "$HOME/.cache")"
case "$normalized" in
""|"/"|"$home_norm"|"$home_cache_norm")
echo "refusing unsafe update cache path: $raw" >&2
exit 2
;;
esac
printf '%s\n' "$normalized"
}
ensure_cargo() {
if command -v cargo >/dev/null 2>&1; then
return
fi
need curl
echo "cargo not found; installing Rust toolchain with rustup" >&2
curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs | sh -s -- -y
# shellcheck disable=SC1091
. "$HOME/.cargo/env"
}
cleanup() {
if [ -n "${tmpdir:-}" ]; then
rm -rf "$tmpdir"
fi
}
trap cleanup EXIT INT TERM
bindir="$prefix/bin"
config_dir="$HOME/.config/dosh"
data_dir="$HOME/.local/share/dosh"
systemd_user_dir="$HOME/.config/systemd/user"
src_dir=""
mkdir -p "$bindir" "$config_dir" "$data_dir"
normalize_os() {
case "$(uname -s)" in
Darwin) printf '%s\n' macos ;;
Linux) printf '%s\n' linux ;;
FreeBSD) printf '%s\n' freebsd ;;
*) uname -s | tr '[:upper:]' '[:lower:]' ;;
esac
}
normalize_arch() {
case "$(uname -m)" in
x86_64|amd64) printf '%s\n' x86_64 ;;
arm64|aarch64) printf '%s\n' aarch64 ;;
armv7l) printf '%s\n' armv7 ;;
*) uname -m | tr '[:upper:]' '[:lower:]' ;;
esac
}
repo_web_base() {
repo_base="$1"
case "$repo_base" in
http://*|https://*)
repo_base="${repo_base%.git}"
printf '%s\n' "${repo_base%/}"
;;
*)
return 1
;;
esac
}
release_artifact_name() {
if [ -n "$binary_name" ]; then
printf '%s\n' "$binary_name"
else
printf 'dosh-%s-%s.tar.gz\n' "$(normalize_os)" "$(normalize_arch)"
fi
}
release_download_url() {
if [ -n "$binary_url" ]; then
printf '%s\n' "$binary_url"
return 0
fi
if [ -n "$binary_base" ]; then
printf '%s/%s\n' "${binary_base%/}" "$(release_artifact_name)"
return 0
fi
if [ -z "$repo" ]; then
return 1
fi
web_base="$(repo_web_base "$repo")" || return 1
if [ "$binary_version" = "latest" ]; then
printf '%s/releases/latest/download/%s\n' "$web_base" "$(release_artifact_name)"
else
printf '%s/releases/download/%s/%s\n' "$web_base" "$binary_version" "$(release_artifact_name)"
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
}
version_core() {
value="${1#v}"
value="${value%%+*}"
printf '%s\n' "${value%%-*}"
}
version_prerelease() {
value="${1#v}"
value="${value%%+*}"
case "$value" in
*-*) printf '%s\n' "${value#*-}" ;;
*) printf '\n' ;;
esac
}
version_core_part() {
version="$1"
index="$2"
version_core "$version" \
| sed 's/[^0-9][^0-9]*/ /g' \
| awk -v index="$index" '{ value=$index; if (value == "") value=0; print value }'
}
prerelease_less_than() {
left="$1"
right="$2"
if [ -z "$left" ] && [ -z "$right" ]; then
return 1
fi
if [ -z "$left" ]; then
return 1
fi
if [ -z "$right" ]; then
return 0
fi
if [ "$left" = "$right" ]; then
return 1
fi
left_rc="$(printf '%s\n' "$left" | sed -n 's/^[Rr][Cc]\([0-9][0-9]*\)$/\1/p')"
right_rc="$(printf '%s\n' "$right" | sed -n 's/^[Rr][Cc]\([0-9][0-9]*\)$/\1/p')"
if [ -n "$left_rc" ] && [ -n "$right_rc" ]; then
[ "$left_rc" -lt "$right_rc" ]
return
fi
first="$(printf '%s\n%s\n' "$left" "$right" | LC_ALL=C sort | sed -n '1p')"
[ "$first" = "$left" ]
}
version_less_than() {
left="$1"
right="$2"
for index in 1 2 3 4 5 6 7 8; do
lpart="$(version_core_part "$left" "$index")"
rpart="$(version_core_part "$right" "$index")"
if [ "$lpart" -lt "$rpart" ]; then
return 0
fi
if [ "$lpart" -gt "$rpart" ]; then
return 1
fi
done
prerelease_less_than "$(version_prerelease "$left")" "$(version_prerelease "$right")"
}
current_source_version() {
if [ -f Cargo.toml ]; then
sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | sed -n '1p'
return 0
fi
[ -n "$repo" ] || return 1
web_base="$(repo_web_base "$repo")" || return 1
curl -fsSL "$web_base/raw/branch/main/Cargo.toml" 2>/dev/null \
| sed -n 's/^version = "\(.*\)"/\1/p' \
| sed -n '1p'
}
latest_release_tag() {
[ -n "$repo" ] || return 1
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\n' "$tag"
;;
*)
return 1
;;
esac
}
latest_release_is_stale() {
if [ -n "$binary_url" ] || [ -n "$binary_base" ] || [ "$binary_version" != "latest" ]; then
return 1
fi
latest="$(latest_release_tag || true)"
current="$(current_source_version || true)"
[ -n "$latest" ] && [ -n "$current" ] || return 1
latest="${latest#v}"
if version_less_than "$latest" "$current"; then
echo "latest release $latest is older than source $current; skipping stale prebuilt" >&2
return 0
fi
return 1
}
find_extracted_binary() {
find "$1" -type f -name "$2" 2>/dev/null | sed -n '1p'
}
install_binary() {
src="$1"
dst="$2"
dst_dir="$(dirname "$dst")"
dst_base="$(basename "$dst")"
tmp="$dst_dir/.$dst_base.tmp.$$"
install -m 0755 "$src" "$tmp"
if ! mv -f "$tmp" "$dst"; then
rm -f "$tmp"
return 1
fi
}
install_extracted_binary() {
found="$(find_extracted_binary "$1" "$2")"
if [ -z "$found" ]; then
echo "prebuilt archive missing $2" >&2
return 1
fi
install_binary "$found" "$3"
}
sha256_file() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | awk '{print $1}'
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" | awk '{print $1}'
else
return 1
fi
}
verify_archive_checksum() {
url="$1"
archive="$2"
checksum_file="$3"
if ! curl -fsL "$url.sha256" -o "$checksum_file"; then
echo "prebuilt checksum unavailable; refusing unverified binary for $url" >&2
return 1
fi
expected="$(awk '{print $1}' "$checksum_file" | sed -n '1p')"
actual="$(sha256_file "$archive")" || {
echo "sha256sum/shasum not found for checksum verification" >&2
return 1
}
if [ "$expected" != "$actual" ]; then
echo "prebuilt checksum mismatch for $url" >&2
return 1
fi
}
expected_archive_version() {
url="$1"
case "$url" in
*/releases/download/v*/*)
tag="${url#*/releases/download/v}"
tag="${tag%%/*}"
printf '%s\n' "$tag"
return 0
;;
esac
if [ -z "$binary_url" ] && [ -z "$binary_base" ] && [ "$binary_version" != "latest" ]; then
printf '%s\n' "${binary_version#v}"
return 0
fi
return 1
}
verify_archive_version() {
extract_dir="$1"
url="$2"
expected="$(expected_archive_version "$url" || true)"
[ -n "$expected" ] || return 0
version_file="$(find "$extract_dir" -type f -name VERSION 2>/dev/null | sed -n '1p')"
if [ -z "$version_file" ]; then
echo "prebuilt archive missing VERSION for $url" >&2
return 1
fi
actual="$(tr -d '\r\n' <"$version_file")"
if [ "$actual" != "$expected" ]; then
echo "prebuilt archive version mismatch for $url: expected $expected, got $actual" >&2
return 1
fi
}
try_install_prebuilt() {
if latest_release_is_stale; then
return 1
fi
download_url="$(release_latest_tag_download_url || release_download_url)" || return 1
tmpdir="$(mktemp -d)"
archive="$tmpdir/$(release_artifact_name)"
checksum_file="$archive.sha256"
[ "$quiet" = "1" ] && echo "Trying Dosh prebuilt $(release_artifact_name)"
need curl
need tar
if ! curl -fsL "$download_url" -o "$archive" 2>/dev/null; then
alt_download_url="$(release_download_url || true)"
if [ -z "$alt_download_url" ] || ! curl -fsL "$alt_download_url" -o "$archive"; then
echo "prebuilt unavailable: $download_url" >&2
[ -z "$alt_download_url" ] || echo "prebuilt unavailable: $alt_download_url" >&2
return 1
fi
download_url="$alt_download_url"
fi
verify_archive_checksum "$download_url" "$archive" "$checksum_file" || return 1
mkdir -p "$tmpdir/extract"
if ! tar -xzf "$archive" -C "$tmpdir/extract"; then
echo "prebuilt archive could not be extracted: $download_url" >&2
return 1
fi
verify_archive_version "$tmpdir/extract" "$download_url" || return 1
install_extracted_binary "$tmpdir/extract" dosh-client "$bindir/dosh-client" || return 1
ln -sf dosh-client "$bindir/dosh"
if [ "$role" = "server" ] || [ "$role" = "both" ]; then
install_extracted_binary "$tmpdir/extract" dosh-server "$bindir/dosh-server" || return 1
install_extracted_binary "$tmpdir/extract" dosh-auth "$bindir/dosh-auth" || return 1
fi
if found_bench="$(find_extracted_binary "$tmpdir/extract" dosh-bench)" && [ -n "$found_bench" ]; then
install_binary "$found_bench" "$bindir/dosh-bench"
fi
return 0
}
install_from_source() {
ensure_cargo
if [ "$from_current" -eq 1 ] || [ -f Cargo.toml ]; then
src_dir="$(pwd)"
else
if [ -z "$repo" ]; then
echo "DOSH_REPO or --repo is required when running the installer from curl" >&2
exit 2
fi
update_cache="$(safe_update_cache_path "$update_cache")"
need git
mkdir -p "$(dirname "$update_cache")"
if [ -d "$update_cache/.git" ]; then
[ "$quiet" = "1" ] && echo "Updating Dosh source"
git -C "$update_cache" remote set-url origin "$repo"
if [ "$quiet" = "1" ]; then
git -C "$update_cache" fetch -q --depth 1 origin main
else
git -C "$update_cache" fetch --depth 1 origin main
fi
git -C "$update_cache" checkout -q -B main FETCH_HEAD
else
[ "$quiet" = "1" ] && echo "Downloading Dosh source"
rm -rf "$update_cache"
if [ "$quiet" = "1" ]; then
git clone -q --depth 1 --branch main "$repo" "$update_cache" >/dev/null
else
git clone --depth 1 --branch main "$repo" "$update_cache" >/dev/null
fi
fi
src_dir="$update_cache"
fi
cd "$src_dir"
[ "$quiet" = "1" ] && echo "Building Dosh $role"
if [ "$role" = "client" ]; then
if [ "$quiet" = "1" ]; then
cargo build -q --release --bin dosh-client
else
cargo build --release --bin dosh-client
fi
else
if [ "$quiet" = "1" ]; then
cargo build -q --release
else
cargo build --release
fi
fi
install_binary target/release/dosh-client "$bindir/dosh-client"
ln -sf dosh-client "$bindir/dosh"
if [ "$role" = "server" ] || [ "$role" = "both" ]; then
install_binary target/release/dosh-server "$bindir/dosh-server"
install_binary target/release/dosh-auth "$bindir/dosh-auth"
fi
if [ -f target/release/dosh-bench ]; then
install_binary target/release/dosh-bench "$bindir/dosh-bench"
fi
}
write_systemd_service() {
if [ -n "$src_dir" ] && [ -f "$src_dir/packaging/systemd/dosh-server.service" ]; then
sed "s#ExecStart=%h/.local/bin/dosh-server serve#ExecStart=$bindir/dosh-server serve#" \
"$src_dir/packaging/systemd/dosh-server.service" >"$systemd_user_dir/dosh-server.service"
else
cat >"$systemd_user_dir/dosh-server.service" <<EOF
[Unit]
Description=dosh dormant shell server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=$bindir/dosh-server serve
Restart=always
RestartSec=1
KillMode=process
[Install]
WantedBy=default.target
EOF
fi
}
if [ "$use_prebuilt" != "0" ]; then
if ! try_install_prebuilt; then
if [ "$binary_required" = "1" ]; then
exit 1
fi
[ "$quiet" = "1" ] && echo "Falling back to source build"
install_from_source
fi
else
install_from_source
fi
if [ "$role" = "server" ] || [ "$role" = "both" ]; then
server_config="$config_dir/server.toml"
if [ "$force_config" -eq 1 ] || [ ! -f "$server_config" ]; then
login_shell="$(
if command -v getent >/dev/null 2>&1; then
getent passwd "$(id -un)" | awk -F: '{print $7}'
fi
)"
login_shell="${login_shell:-${SHELL:-/bin/sh}}"
cat >"$server_config" <<EOF
port = $port
bind = "0.0.0.0"
scrollback = 5000
auth_ttl_secs = 30
attach_ticket_ttl_secs = 3600
allow_attach_tickets = true
client_timeout_secs = 2592000
retransmit_window = 256
output_frame_interval_ms = 16
default_input_mode = "read-write"
prewarm_sessions = ["default"]
create_on_attach = true
shell = "$login_shell"
sessions_dir = "~/.local/share/dosh/sessions"
secret_path = "~/.config/dosh/secret"
native_auth = true
host_key = "~/.config/dosh/host_key"
authorized_keys = ["~/.ssh/authorized_keys", "~/.config/dosh/authorized_keys"]
native_auth_rate_limit_per_minute = 30
allow_tcp_forwarding = true
allow_remote_forwarding = false
allow_agent_forwarding = false
persist_sessions = true
EOF
fi
if command -v systemctl >/dev/null 2>&1; then
mkdir -p "$systemd_user_dir"
write_systemd_service
if [ "$start_server" -eq 1 ] && systemctl --user daemon-reload >/dev/null 2>&1; then
systemctl --user enable dosh-server.service
systemctl --user restart dosh-server.service
fi
elif [ "$start_server" -eq 1 ]; then
nohup "$bindir/dosh-server" serve >"$data_dir/dosh-server.log" 2>&1 &
fi
fi
if [ "$role" = "client" ] || [ "$role" = "both" ]; then
case ":$PATH:" in
*":$bindir:"*) ;;
*)
if [ "$(uname -s)" = "Darwin" ] || [ "$(basename "${SHELL:-}")" = "zsh" ]; then
shell_rc="$HOME/.zshrc"
else
shell_rc="$HOME/.profile"
fi
mkdir -p "$(dirname "$shell_rc")"
if [ ! -f "$shell_rc" ] || ! grep -F "$bindir" "$shell_rc" >/dev/null 2>&1; then
printf '\nexport PATH="%s:$PATH"\n' "$bindir" >>"$shell_rc"
fi
;;
esac
client_config="$config_dir/client.toml"
if [ "$force_config" -eq 1 ] || [ ! -f "$client_config" ]; then
default_server="${server:-user@example.com}"
update_repo="${repo:-https://git.palav.dev/Palav/dosh.git}"
if [ -n "$dosh_host" ]; then
dosh_host_line="dosh_host = \"$dosh_host\""
else
dosh_host_line="# dosh_host = \"public.example.com\""
fi
cat >"$client_config" <<EOF
update_repo = "$update_repo"
update_port = $port
server = "$default_server"
$dosh_host_line
ssh_auth_command = "~/.local/bin/dosh-auth"
# ssh_port = 22
dosh_port = $port
default_session = "new"
reconnect_timeout_secs = 5
view_only = false
predict = true
predict_mode = "experimental"
cache_attach_tickets = true
credential_cache = "~/.local/share/dosh/credentials"
auth_preference = "native,ssh"
trust_on_first_use = false
native_auth_timeout_ms = 700
known_hosts = "~/.config/dosh/known_hosts"
identity_files = ["~/.ssh/id_ed25519"]
use_ssh_agent = true
forward_agent = false
escape_key = "^]"
EOF
fi
hosts_config="$config_dir/hosts.toml"
if [ "$force_config" -eq 1 ] || [ ! -f "$hosts_config" ]; then
default_server="${server:-user@example.com}"
if [ -n "$dosh_host" ]; then
host_udp="$dosh_host"
else
host_udp="$default_server"
fi
cat >"$hosts_config" <<EOF
# Example:
# [server]
# ssh = "server"
# dosh_host = "server.example.com"
# port = 50000
# default_command = "tm"
# predict = true
[default]
ssh = "$default_server"
dosh_host = "$host_udp"
port = $port
predict = true
EOF
fi
fi
cat <<EOF
Installed Dosh to $bindir
Configured UDP port $port
EOF
if [ "$role" = "client" ] || [ "$role" = "both" ]; then
next_server="${server:-user@host}"
cat <<EOF
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
if [ "$role" = "server" ] || [ "$role" = "both" ]; then
cat <<EOF
Server command:
$bindir/dosh-server serve
EOF
fi