Files
github-action/actions/connect-tailscale/connect-tailscale.sh
T
Tirsvad 7a30560a74 Fix the review findings in connect-tailscale
tailscaled is waited for before tailscale up, a tailscaled that already runs on
the runner is not taken over and down leaves a daemon it did not start, sudo is
used only when the job is not root, an exact version is required to install
(with an optional sha256 of the archive), and the guide states when the node is
ephemeral and calls @main until the release.

Refs #185
Task: MIL-012#4
2026-10-19 12:01:06 +08:00

197 lines
7.0 KiB
Bash

#!/usr/bin/env bash
# Steps of the composite action actions/connect-tailscale/action.yml.
#
# connect-tailscale.sh mask print ::add-mask:: for every secret input
# connect-tailscale.sh up install tailscale if needed, start tailscaled and join the tailnet
# connect-tailscale.sh down log the node out and stop tailscaled
#
# Settings come from the environment, never from interpolated expressions:
# TS_AUTHKEY, TS_OAUTH_SECRET, TS_TAGS, TS_HOSTNAME, TS_VERSION, TS_USERSPACE,
# TS_SHA256, TS_ARGS, TS_TIMEOUT, TS_HEALTH_CHECK_HOST. TS_TAILSCALE,
# TS_TAILSCALED, TS_SUDO, TS_STATE_DIR, TS_START_WAIT (tries) and
# TS_WAIT_INTERVAL (seconds) name the commands, the work folder and the waits,
# for the tests.
# The auth key is passed to tailscale in a file of mode 600, never on a command
# line. Every failure is printed as ::error::[category] message.
TS_TAILSCALE="${TS_TAILSCALE:-tailscale}"
TS_TAILSCALED="${TS_TAILSCALED:-tailscaled}"
# sudo is used only when the job is not root: a job container runs as root and
# often has no sudo. TS_SUDO overrides it (empty for none).
if [ -n "${TS_SUDO+x}" ]; then
SUDO="$TS_SUDO"
elif [ "$(id -u)" = "0" ]; then
SUDO=""
else
SUDO="sudo"
fi
STATE_DIR="${TS_STATE_DIR:-${RUNNER_TEMP:-/tmp}/tailscale}"
fail() {
echo "::error::[$1] $2"
exit 1
}
mask_secrets() {
local value
for value in "${TS_AUTHKEY:-}" "${TS_OAUTH_SECRET:-}"; do
[ -n "$value" ] && echo "::add-mask::$value"
done
return 0
}
validate_inputs() {
if [ -n "${TS_AUTHKEY:-}" ] && [ -n "${TS_OAUTH_SECRET:-}" ]; then
fail configuration "give authkey or oauth-secret, not both."
fi
if [ -z "${TS_AUTHKEY:-}" ] && [ -z "${TS_OAUTH_SECRET:-}" ]; then
fail configuration "give authkey or oauth-secret."
fi
if [ -n "${TS_OAUTH_SECRET:-}" ] && [ -z "${TS_TAGS:-}" ]; then
fail configuration "tags is required with oauth-secret (for example tag:ci)."
fi
if [ -n "${TS_TAGS:-}" ]; then
[[ "$TS_TAGS" =~ ^tag:[a-zA-Z0-9-]+(,tag:[a-zA-Z0-9-]+)*$ ]] ||
fail configuration "tags must look like tag:ci or tag:ci,tag:deploy."
fi
[[ "$TS_TIMEOUT" =~ ^[0-9]+$ ]] ||
fail configuration "timeout must be a number of seconds."
[[ "$TS_VERSION" =~ ^([0-9]+\.[0-9]+\.[0-9]+)?$ ]] ||
fail configuration "version must be an exact version like 1.80.2."
[[ "${TS_SHA256:-}" =~ ^([0-9a-fA-F]{64})?$ ]] ||
fail configuration "sha256 must be 64 hexadecimal digits."
case "$TS_USERSPACE" in
true | false) ;;
*) fail configuration "userspace-networking must be true or false." ;;
esac
[[ "${TS_HOSTNAME:-}" =~ ^[a-zA-Z0-9-]*$ ]] ||
fail configuration "hostname may hold letters, digits and hyphens only."
}
machine_arch() {
case "$(uname -m)" in
x86_64 | amd64) echo amd64 ;;
aarch64 | arm64) echo arm64 ;;
*) fail environment "no Tailscale build for $(uname -m)." ;;
esac
}
install_tools() {
command -v "$TS_TAILSCALE" >/dev/null 2>&1 && command -v "$TS_TAILSCALED" >/dev/null 2>&1 && return 0
[ -n "$TS_VERSION" ] ||
fail configuration "tailscale is not on the runner; set version (for example 1.80.2) so it can be installed."
local arch url tmp
arch="$(machine_arch)"
url="https://pkgs.tailscale.com/stable/tailscale_${TS_VERSION}_${arch}.tgz"
echo "Installing Tailscale $TS_VERSION"
tmp="$(mktemp -d)"
curl -fsSL "$url" -o "$tmp/tailscale.tgz" ||
fail environment "could not download $url."
if [ -n "${TS_SHA256:-}" ]; then
echo "${TS_SHA256} $tmp/tailscale.tgz" | sha256sum -c --quiet - ||
fail environment "the downloaded archive does not match sha256."
fi
tar -xzf "$tmp/tailscale.tgz" -C "$tmp" --strip-components=1 ||
fail environment "could not unpack the Tailscale archive."
$SUDO install -m 755 "$tmp/tailscale" "$tmp/tailscaled" /usr/local/bin/ ||
fail environment "could not install tailscale (root or sudo is needed)."
rm -rf "$tmp"
}
daemon_answers() {
"$TS_TAILSCALE" version --daemon >/dev/null 2>&1
}
start_daemon() {
# a daemon that already runs belongs to the host; this action would log it out
if daemon_answers; then
fail environment "a tailscaled already runs on this runner; the action starts its own and must not take over the host's. Use it on a job container."
fi
local args=(--state=mem:)
if [ "$TS_USERSPACE" = "true" ]; then
args+=(--tun=userspace-networking --socks5-server=localhost:1055)
fi
mkdir -p "$STATE_DIR"
$SUDO nohup "$TS_TAILSCALED" "${args[@]}" >"$STATE_DIR/tailscaled.log" 2>&1 &
: >"$STATE_DIR/started-by-action"
wait_for_daemon
}
# wait_for_daemon: tailscaled needs a moment to create its socket
wait_for_daemon() {
local tries="${TS_START_WAIT:-30}" i
for ((i = 0; i < tries; i++)); do
daemon_answers && return 0
sleep "${TS_WAIT_INTERVAL:-1}"
done
fail connection "tailscaled did not answer in $tries tries; the last lines of its log: $(tail -n 5 "$STATE_DIR/tailscaled.log" 2>/dev/null | tr '
' ' ')"
}
# write_key_file: the auth key in a file of mode 600; prints its path
write_key_file() {
local file="$STATE_DIR/authkey" key
if [ -n "${TS_OAUTH_SECRET:-}" ]; then
key="${TS_OAUTH_SECRET}?ephemeral=true&preauthorized=true"
else
key="$TS_AUTHKEY"
fi
mkdir -p "$STATE_DIR"
(umask 077 && printf '%s' "$key" >"$file")
echo "$file"
}
tailnet_up() {
validate_inputs
install_tools
start_daemon
local keyfile up_args extra
keyfile="$(write_key_file)"
up_args=("--auth-key=file:$keyfile" "--timeout=${TS_TIMEOUT}s")
[ -z "${TS_HOSTNAME:-}" ] || up_args+=("--hostname=$TS_HOSTNAME")
[ -z "${TS_TAGS:-}" ] || up_args+=("--advertise-tags=$TS_TAGS")
# TS_ARGS is split on spaces on purpose: it holds flags, never a secret
# shellcheck disable=SC2206
extra=(${TS_ARGS:-})
if ! $SUDO "$TS_TAILSCALE" up "${up_args[@]}" "${extra[@]}"; then
rm -f "$keyfile"
fail connection "tailscale up failed; the last lines of tailscaled: $(tail -n 5 "$STATE_DIR/tailscaled.log" 2>/dev/null | tr '\n' ' ')"
fi
rm -f "$keyfile"
if [ -n "${TS_HEALTH_CHECK_HOST:-}" ]; then
"$TS_TAILSCALE" ping --c 3 --timeout 10s "$TS_HEALTH_CHECK_HOST" >/dev/null 2>&1 ||
fail connection "health-check-host '$TS_HEALTH_CHECK_HOST' does not answer on the tailnet."
fi
echo "Tailscale is up."
}
tailnet_down() {
# only a daemon this action started is logged out and stopped
if [ ! -f "$STATE_DIR/started-by-action" ]; then
echo "This action did not start tailscaled on this runner; nothing to do."
return 0
fi
$SUDO "$TS_TAILSCALE" logout 2>/dev/null || echo "The node was not logged in."
$SUDO "$TS_TAILSCALE" down 2>/dev/null || true
$SUDO pkill -x "$(basename "$TS_TAILSCALED")" 2>/dev/null || true
rm -rf "$STATE_DIR"
echo "Tailscale is down."
}
main() {
set -uo pipefail
TS_USERSPACE="${TS_USERSPACE:-false}"
TS_TIMEOUT="${TS_TIMEOUT:-60}"
TS_VERSION="${TS_VERSION:-}"
case "${1:-}" in
mask) mask_secrets ;;
up) tailnet_up ;;
down) tailnet_down ;;
*) fail configuration "unknown command '${1:-}'; use mask, up or down." ;;
esac
}
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
main "$@"
fi