Skip to content

Instantly share code, notes, and snippets.

@denisalevi
Created March 31, 2026 12:03
Show Gist options
  • Select an option

  • Save denisalevi/48034dc2eb7005b1b9ad7340af412434 to your computer and use it in GitHub Desktop.

Select an option

Save denisalevi/48034dc2eb7005b1b9ad7340af412434 to your computer and use it in GitHub Desktop.
Install user-space Tailscale SSH on any Linux machine
#!/usr/bin/env bash
# -----------------------------------------------------------------------
# setup-user-tailscale-ssh: Install Tailscale static binaries for a user
# and configure a user-space Tailscale SSH endpoint on the current machine.
# -----------------------------------------------------------------------
#
# What this does:
# - Downloads the latest stable static Tailscale binaries from Tailscale
# - Installs versioned binaries into $HOME/.local/bin
# - Creates a reusable helper: $HOME/.local/bin/user-tailscale-ssh
# - Prompts for a tailnet machine hostname (unless provided)
# - Starts tailscaled in userspace-networking mode as the current user
# - Enables Tailscale SSH on the node
#
# Typical one-shot usage:
# bash setup-user-tailscale-ssh.sh
#
# Non-interactive examples:
# bash setup-user-tailscale-ssh.sh --hostname gpu-box
# TAILSCALE_AUTHKEY=tskey-... bash setup-user-tailscale-ssh.sh --hostname gpu-box
#
# After installation:
# user-tailscale-ssh start
# user-tailscale-ssh status
# user-tailscale-ssh logs
# user-tailscale-ssh stop
#
# Notes:
# - This intentionally uses userspace networking, so it does not require root
# or a working /dev/net/tun.
# - SSH login itself may still require Tailscale browser approval depending on
# your tailnet SSH policy.
# -----------------------------------------------------------------------
set -euo pipefail
INSTALL_DIR="${HOME}/.local/bin"
CONFIG_DIR="${HOME}/.config/user-tailscale-ssh"
TEMP_DIR="/tmp/tailscale-user-setup-${USER}"
PKGS_URL="https://pkgs.tailscale.com/stable/"
DEFAULT_HOSTNAME="$(hostname -s 2>/dev/null || hostname)"
TAILSCALE_AUTHKEY="${TAILSCALE_AUTHKEY:-}"
TS_HOSTNAME="${TS_HOSTNAME:-}"
NO_START=0
NONINTERACTIVE=0
DOWNLOAD_TOOL="${DOWNLOAD_TOOL:-}"
QUIET=0
usage() {
cat <<'EOF'
Usage:
setup-user-tailscale-ssh.sh [options]
Options:
--hostname NAME Tailnet machine hostname to request
--auth-key KEY Optional Tailscale auth key
--no-start Install and configure, but do not start Tailscale
--noninteractive Fail instead of prompting for input
--quiet Reduce log noise
-h, --help Show this help
Environment:
TAILSCALE_AUTHKEY Optional Tailscale auth key
DOWNLOAD_TOOL Force curl or wget
TS_HOSTNAME Same as --hostname
Examples:
bash setup-user-tailscale-ssh.sh
bash setup-user-tailscale-ssh.sh --hostname head071
TAILSCALE_AUTHKEY=tskey-... bash setup-user-tailscale-ssh.sh --hostname gpu-box
EOF
}
log() {
if [[ "$QUIET" -eq 0 ]]; then
printf '%s\n' "$*"
fi
}
die() {
printf 'Error: %s\n' "$*" >&2
exit 1
}
need_cmd() {
command -v "$1" >/dev/null 2>&1 || die "missing required command: $1"
}
pick_downloader() {
if [[ -n "$DOWNLOAD_TOOL" ]]; then
case "$DOWNLOAD_TOOL" in
curl|wget) ;;
*) die "DOWNLOAD_TOOL must be curl or wget" ;;
esac
elif command -v curl >/dev/null 2>&1; then
DOWNLOAD_TOOL="curl"
elif command -v wget >/dev/null 2>&1; then
DOWNLOAD_TOOL="wget"
else
die "neither curl nor wget is installed"
fi
log "Using downloader: $DOWNLOAD_TOOL"
}
download_to() {
local url=$1
local output=$2
case "$DOWNLOAD_TOOL" in
curl)
curl --fail --location --show-error --silent --output "$output" "$url"
;;
wget)
wget --quiet --output-document="$output" "$url"
;;
esac
}
download_text() {
local url=$1
case "$DOWNLOAD_TOOL" in
curl)
curl --fail --location --show-error --silent "$url"
;;
wget)
wget --quiet --output-document=- "$url"
;;
esac
}
map_arch() {
local machine
machine="$(uname -m)"
case "$machine" in
x86_64|amd64) printf 'amd64\n' ;;
aarch64|arm64) printf 'arm64\n' ;;
armv7l|armv7) printf 'arm\n' ;;
i386|i686) printf '386\n' ;;
riscv64) printf 'riscv64\n' ;;
*) die "unsupported architecture: $machine" ;;
esac
}
latest_version_for_arch() {
local arch=$1
local page version
page="$(download_text "$PKGS_URL")"
version="$(printf '%s\n' "$page" | sed -n "s/.*tailscale_\\([0-9][0-9.]*\\)_${arch}\\.tgz.*/\\1/p" | head -n1)"
[[ -n "$version" ]] || die "could not determine latest Tailscale version for arch ${arch}"
printf '%s\n' "$version"
}
prompt_hostname() {
if [[ -n "$TS_HOSTNAME" ]]; then
return
fi
if [[ "$NONINTERACTIVE" -eq 1 ]]; then
die "--hostname or TS_HOSTNAME is required in noninteractive mode"
fi
printf 'Tailnet hostname to request [%s]: ' "$DEFAULT_HOSTNAME"
read -r TS_HOSTNAME
TS_HOSTNAME="${TS_HOSTNAME:-$DEFAULT_HOSTNAME}"
}
validate_hostname() {
[[ -n "$TS_HOSTNAME" ]] || die "hostname must not be empty"
if [[ ! "$TS_HOSTNAME" =~ ^[A-Za-z0-9][A-Za-z0-9-]*$ ]]; then
die "hostname must match [A-Za-z0-9-] and start with an alphanumeric character"
fi
}
install_binaries() {
local arch version archive_url archive_path unpack_dir versioned_tailscale versioned_tailscaled
local extracted_tailscale extracted_tailscaled
arch="$(map_arch)"
version="$(latest_version_for_arch "$arch")"
archive_url="${PKGS_URL}tailscale_${version}_${arch}.tgz"
archive_path="${TEMP_DIR}/tailscale_${version}_${arch}.tgz"
unpack_dir="${TEMP_DIR}/tailscale_${version}_${arch}"
versioned_tailscale="${INSTALL_DIR}/tailscale-${version}"
versioned_tailscaled="${INSTALL_DIR}/tailscaled-${version}"
log "Latest stable Tailscale for ${arch}: ${version}"
mkdir -p "$INSTALL_DIR" "$TEMP_DIR"
if [[ ! -x "$versioned_tailscale" || ! -x "$versioned_tailscaled" ]]; then
log "Downloading ${archive_url}"
rm -rf "$unpack_dir"
mkdir -p "$unpack_dir"
download_to "$archive_url" "$archive_path"
tar -xzf "$archive_path" -C "$unpack_dir"
extracted_tailscale="$(find "$unpack_dir" -type f -name tailscale | head -n1)"
extracted_tailscaled="$(find "$unpack_dir" -type f -name tailscaled | head -n1)"
[[ -n "$extracted_tailscale" ]] || die "downloaded archive did not contain tailscale"
[[ -n "$extracted_tailscaled" ]] || die "downloaded archive did not contain tailscaled"
install -m 0755 "$extracted_tailscale" "$versioned_tailscale"
install -m 0755 "$extracted_tailscaled" "$versioned_tailscaled"
else
log "Version ${version} already installed in ${INSTALL_DIR}"
fi
ln -sfn "$versioned_tailscale" "${INSTALL_DIR}/tailscale"
ln -sfn "$versioned_tailscaled" "${INSTALL_DIR}/tailscaled"
log "Installed:"
log " ${INSTALL_DIR}/tailscale -> tailscale-${version}"
log " ${INSTALL_DIR}/tailscaled -> tailscaled-${version}"
}
write_config() {
mkdir -p "$CONFIG_DIR"
cat > "${CONFIG_DIR}/config" <<EOF
TS_INSTALL_DIR="${INSTALL_DIR}"
TS_HOSTNAME="${TS_HOSTNAME}"
TS_STATE_DIR="\$HOME/.local/share/tailscale-${TS_HOSTNAME}"
TS_RUNTIME_DIR="/tmp/\${USER}-tailscale-${TS_HOSTNAME}"
TS_ENABLE_SSH="true"
TS_ACCEPT_ROUTES="false"
TS_ACCEPT_DNS="false"
EOF
}
write_helper() {
local helper_path="${INSTALL_DIR}/user-tailscale-ssh"
cat > "$helper_path" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
CONFIG_FILE="${HOME}/.config/user-tailscale-ssh/config"
[[ -f "$CONFIG_FILE" ]] || {
echo "Missing config: $CONFIG_FILE" >&2
exit 1
}
source "$CONFIG_FILE"
tailscale_bin="${TS_INSTALL_DIR}/tailscale"
tailscaled_bin="${TS_INSTALL_DIR}/tailscaled"
socket_path="${TS_RUNTIME_DIR}/tailscaled.sock"
pid_path="${TS_RUNTIME_DIR}/tailscaled.pid"
log_path="${TS_RUNTIME_DIR}/tailscaled.log"
usage() {
cat <<'USAGE'
Usage:
user-tailscale-ssh start
user-tailscale-ssh stop
user-tailscale-ssh restart
user-tailscale-ssh status
user-tailscale-ssh logs
user-tailscale-ssh doctor
user-tailscale-ssh ssh-config
USAGE
}
print_auth_key_help() {
cat <<'HELP'
To use an auth key instead of browser login:
1. Open the Tailscale admin console.
2. Go to the Keys page.
3. Generate an auth key.
4. Re-run with:
TAILSCALE_AUTHKEY=tskey-... user-tailscale-ssh restart
If your tailnet uses device approval and this machine is meant to behave like a server,
prefer a pre-approved auth key. If you want the node to carry server-style permissions,
you may also want a tagged auth key.
Reference:
https://tailscale.com/kb/1085/auth-keys/
HELP
}
print_policy_help() {
cat <<'HELP'
If Tailscale SSH is enabled on this machine but SSH access still fails, check your tailnet policy.
For Tailscale SSH to work on a tailnet with custom policy, you typically need both:
1. A network access rule allowing the source device/user to reach the destination on port 22.
2. An SSH rule allowing the source to SSH to the destination as the desired Unix user.
Minimal example with placeholders:
{
"grants": [
{
"src": ["<source-user-or-group>"],
"dst": ["<destination-selector>"],
"ip": ["*"]
}
],
"ssh": [
{
"action": "check",
"src": ["<source-user-or-group>"],
"dst": ["<destination-selector>"],
"users": ["<unix-user>"]
}
]
}
Examples of selectors:
- source user: "user@example.com"
- own devices only: "autogroup:self"
- a tag: "tag:servers"
- Unix user on the machine: "alice"
Notes:
- If you have never customized your tailnet policy, the default policy may already allow SSH to your own devices.
- If your tailnet uses device approval, the machine may also need approval in the Machines page even after login.
- Be careful with "autogroup:nonroot" on shared machines; it can allow SSH as any non-root user on the destination.
References:
https://tailscale.com/docs/features/tailscale-ssh
https://tailscale.com/docs/reference/syntax/policy-file
HELP
}
is_running() {
[[ -f "$pid_path" ]] || return 1
kill -0 "$(cat "$pid_path")" 2>/dev/null
}
ensure_dirs() {
mkdir -p "$TS_STATE_DIR" "$TS_RUNTIME_DIR"
}
start_daemon() {
ensure_dirs
if is_running; then
echo "tailscaled already running (pid $(cat "$pid_path"))"
return 0
fi
rm -f "$socket_path" "$pid_path"
"$tailscaled_bin" \
--state="${TS_STATE_DIR}/tailscaled.state" \
--socket="$socket_path" \
--statedir="$TS_STATE_DIR" \
--tun=userspace-networking \
>"$log_path" 2>&1 &
echo "$!" > "$pid_path"
for _ in $(seq 1 20); do
[[ -S "$socket_path" ]] && return 0
sleep 1
done
echo "tailscaled did not create $socket_path" >&2
[[ -f "$log_path" ]] && sed -n '1,160p' "$log_path" >&2
exit 1
}
run_up() {
local up_output rc up_output_file
local -a up_args
up_args=(
--socket="$socket_path"
up
--hostname="$TS_HOSTNAME"
--accept-routes="$TS_ACCEPT_ROUTES"
--accept-dns="$TS_ACCEPT_DNS"
)
if [[ "${TS_ENABLE_SSH}" == "true" ]]; then
up_args+=(--ssh=true)
fi
if [[ -n "${TAILSCALE_AUTHKEY:-}" ]]; then
up_args+=(--auth-key="${TAILSCALE_AUTHKEY}")
fi
up_output_file="$(mktemp)"
echo "Running tailscale up for hostname '${TS_HOSTNAME}'..."
echo "If this is a fresh machine, watch for a https://login.tailscale.com/... URL below."
echo
set +e
"$tailscale_bin" "${up_args[@]}" 2>&1 | tee "$up_output_file"
rc=$?
set -e
up_output="$(cat "$up_output_file")"
rm -f "$up_output_file"
if printf '%s\n' "$up_output" | grep -q 'https://login.tailscale.com/'; then
echo
echo "Complete the Tailscale browser auth above, then run:"
echo " user-tailscale-ssh status"
echo "If the node has joined your tailnet, SSH to:"
echo " ssh ${USER}@${TS_HOSTNAME}.<your-tailnet>.ts.net"
fi
return "$rc"
}
backend_state() {
"$tailscale_bin" --socket="$socket_path" status --json 2>/dev/null \
| sed -n 's/.*"BackendState":"\([^"]*\)".*/\1/p' \
| head -n1
}
node_fqdn() {
"$tailscale_bin" --socket="$socket_path" status --self=false 2>/dev/null \
| awk 'NR==1 {print $3}'
}
show_join_guidance() {
local state fqdn ip4
state="$(backend_state || true)"
case "$state" in
Running)
fqdn="$(node_fqdn || true)"
ip4="$("$tailscale_bin" --socket="$socket_path" ip -4 2>/dev/null | head -n1 || true)"
echo
echo "This machine has joined the tailnet."
[[ -n "$fqdn" ]] && echo "MagicDNS name: $fqdn"
[[ -n "$ip4" ]] && echo "Tailscale IPv4: $ip4"
echo "Next step:"
if [[ -n "$fqdn" ]]; then
echo " ssh ${USER}@${fqdn}"
else
echo " user-tailscale-ssh status"
fi
echo
print_policy_help
;;
NeedsLogin|"")
echo
echo "This machine has not joined a tailnet yet."
echo "Next step:"
echo " rerun: user-tailscale-ssh start"
echo " and open the https://login.tailscale.com/... URL if one is shown."
echo
print_auth_key_help
echo
print_policy_help
;;
NeedsMachineAuth)
echo
echo "This machine authenticated, but it still needs approval to join the tailnet."
echo "Next step:"
echo " approve the device in the Tailscale admin console Machines page"
echo " or use a pre-approved auth key and rerun:"
echo " TAILSCALE_AUTHKEY=tskey-... user-tailscale-ssh restart"
echo
print_auth_key_help
echo
print_policy_help
;;
*)
echo
echo "Tailscale backend state: $state"
echo "If the node is not visible in your tailnet yet, run:"
echo " user-tailscale-ssh status"
echo " user-tailscale-ssh logs"
echo " user-tailscale-ssh doctor"
echo
print_policy_help
;;
esac
}
status_cmd() {
if ! is_running; then
echo "tailscaled is not running"
exit 1
fi
"$tailscale_bin" --socket="$socket_path" status --self || true
"$tailscale_bin" --socket="$socket_path" ip -4 || true
}
stop_cmd() {
if ! is_running; then
echo "tailscaled is not running"
rm -f "$pid_path" "$socket_path"
return 0
fi
kill "$(cat "$pid_path")" 2>/dev/null || true
rm -f "$pid_path" "$socket_path"
echo "Stopped tailscaled"
}
doctor_cmd() {
echo "== Binary checks =="
command -v "$tailscale_bin" >/dev/null 2>&1 && "$tailscale_bin" version | sed -n '1,2p'
command -v "$tailscaled_bin" >/dev/null 2>&1 && "$tailscaled_bin" --version | sed -n '1p'
echo
echo "== Runtime paths =="
printf 'Config: %s\n' "$CONFIG_FILE"
printf 'State dir: %s\n' "$TS_STATE_DIR"
printf 'Runtime dir: %s\n' "$TS_RUNTIME_DIR"
printf 'Socket: %s\n' "$socket_path"
printf 'Log: %s\n' "$log_path"
echo
echo "== Network prerequisites =="
if command -v curl >/dev/null 2>&1; then
curl -I --max-time 8 https://controlplane.tailscale.com 2>/dev/null | sed -n '1,3p' || echo "Failed to reach controlplane.tailscale.com"
elif command -v wget >/dev/null 2>&1; then
wget --server-response --spider --timeout=8 https://controlplane.tailscale.com 2>&1 | sed -n '1,6p' || echo "Failed to reach controlplane.tailscale.com"
else
echo "No curl/wget available for HTTPS checks"
fi
if [[ -e /dev/net/tun ]]; then
ls -l /dev/net/tun
echo "Note: this setup uses userspace networking even if /dev/net/tun exists."
else
echo "/dev/net/tun is missing. Userspace networking is the right mode here."
fi
echo
echo "== Tailscale status =="
if is_running; then
"$tailscale_bin" --socket="$socket_path" status || true
else
echo "tailscaled is not running"
fi
}
ssh_config_cmd() {
local fqdn
fqdn="$("$tailscale_bin" --socket="$socket_path" status --self 2>/dev/null | awk 'NR==1 {print $3}')"
if [[ -z "$fqdn" ]]; then
fqdn="${TS_HOSTNAME}"
fi
cat <<EOF2
Host ${TS_HOSTNAME}-ts
HostName ${fqdn}
User ${USER}
EOF2
}
cmd="${1:-start}"
case "$cmd" in
start)
start_daemon
run_up || true
echo
echo "Current status:"
status_cmd || true
show_join_guidance
;;
stop)
stop_cmd
;;
restart)
stop_cmd || true
start_daemon
run_up || true
status_cmd || true
show_join_guidance
;;
status)
status_cmd
;;
logs)
[[ -f "$log_path" ]] && tail -n 100 "$log_path" || echo "No log file yet: $log_path"
;;
doctor)
doctor_cmd
;;
ssh-config)
ssh_config_cmd
;;
-h|--help|help)
usage
;;
*)
echo "Unknown command: $cmd" >&2
usage >&2
exit 1
;;
esac
EOF
chmod +x "$helper_path"
}
run_prereq_checks() {
need_cmd tar
need_cmd install
need_cmd sed
need_cmd awk
pick_downloader
log "Checking outbound HTTPS to Tailscale control plane..."
if ! download_text "https://controlplane.tailscale.com" >/dev/null 2>&1; then
die "cannot reach https://controlplane.tailscale.com; outbound HTTPS may be blocked"
fi
if [[ -e /dev/net/tun ]]; then
log "/dev/net/tun exists. This installer still uses userspace networking."
else
log "/dev/net/tun is missing. Userspace networking is expected."
fi
}
start_now() {
local helper_path="${INSTALL_DIR}/user-tailscale-ssh"
log
log "Starting user-space Tailscale SSH..."
"$helper_path" start || true
}
print_next_steps() {
local helper_path="${INSTALL_DIR}/user-tailscale-ssh"
cat <<EOF
Setup complete.
Installed binaries:
${INSTALL_DIR}/tailscale
${INSTALL_DIR}/tailscaled
Helper:
${helper_path}
Do not rerun this installer for normal daily use.
Next time you want to bring the node back online, run:
${helper_path} start
To stop it:
${helper_path} stop
Useful commands:
${helper_path} start
${helper_path} status
${helper_path} logs
${helper_path} doctor
${helper_path} ssh-config
Expected SSH destination after auth:
${TS_HOSTNAME}.<your-tailnet>.ts.net
If SSH later says "requires an additional check", open the Tailscale URL it prints.
If SSH says access is denied by policy, add or adjust a Tailscale SSH rule for this node.
Policy reference:
https://tailscale.com/docs/features/tailscale-ssh
https://tailscale.com/docs/reference/syntax/policy-file
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--hostname)
[[ $# -ge 2 ]] || die "--hostname requires a value"
TS_HOSTNAME=$2
shift 2
;;
--auth-key)
[[ $# -ge 2 ]] || die "--auth-key requires a value"
TAILSCALE_AUTHKEY=$2
shift 2
;;
--no-start)
NO_START=1
shift
;;
--noninteractive)
NONINTERACTIVE=1
shift
;;
--quiet)
QUIET=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
die "unknown argument: $1"
;;
esac
done
mkdir -p "$TEMP_DIR"
trap 'rm -rf "$TEMP_DIR"' EXIT
prompt_hostname
validate_hostname
run_prereq_checks
install_binaries
write_config
write_helper
if [[ "$NO_START" -eq 0 ]]; then
start_now
fi
print_next_steps
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment