|
#!/bin/sh |
|
|
|
# Tailsman: Tailscale Manager for AsusWRT-Merlin |
|
# A single script to install, manage, and maintain Tailscale. |
|
|
|
TAILSCALE_DIR="/jffs/tailscale" |
|
TAILSCALED_BIN="${TAILSCALE_DIR}/tailscaled" |
|
STATE_FILE="${TAILSCALE_DIR}/tailscaled.state" |
|
TAILSCALE_TMP_BIN="/tmp/home/root/tailscale" |
|
ARCH="arm" |
|
SCRIPT_PATH="/jffs/tailscale/tailsman.sh" |
|
TAILSCALE_IF="tailscale0" |
|
|
|
# --- Bootstrap Network Configuration --- |
|
BOOTSTRAP_DNS_SERVER="1.1.1.1" |
|
TAILSCALE_PKGS_HOST="pkgs.tailscale.com" |
|
BOOTSTRAP_NTP_SERVER="time.cloudflare.com" # NTP server for initial time sync |
|
# Global variable to cache the resolved IP address within a single script run |
|
TAILSCALE_PKGS_IP="" |
|
# Global flag to control DNS behavior |
|
# 0 = Use system DNS (fast, for 'tailscale' command) |
|
# 1 = Use bootstrap DNS (resilient, for 'install', 'upgrade', or --manual-resolve) |
|
TAILSMAN_USE_BOOTSTRAP_DNS=0 |
|
|
|
log() { |
|
logger -t "tailsman" "$1" -p user.notice |
|
echo "tailsman: $1" >&2 |
|
} |
|
|
|
is_tailscaled_running() { |
|
if [ -n "$(pidof tailscaled)" ]; then |
|
return 0 |
|
else |
|
return 1 |
|
fi |
|
} |
|
|
|
apply_tailscale_firewall_rules() { |
|
# Idempotent: only insert if not already present |
|
if iptables -C INPUT -i "${TAILSCALE_IF}" -j ACCEPT 2>/dev/null; then |
|
log "Firewall rules for ${TAILSCALE_IF} already present; skipping." |
|
return |
|
fi |
|
|
|
log "Applying firewall rules for ${TAILSCALE_IF}..." |
|
iptables -I INPUT 1 -i "${TAILSCALE_IF}" -j ACCEPT |
|
iptables -I FORWARD 1 -i "${TAILSCALE_IF}" -j ACCEPT |
|
iptables -I FORWARD 1 -o "${TAILSCALE_IF}" -j ACCEPT |
|
} |
|
|
|
bootstrap_time_sync() { |
|
# This function *always* uses bootstrap DNS, as it's meant to fix a broken state. |
|
if [ "$(date +%Y)" -lt 2025 ]; then |
|
log "System time is incorrect. Attempting bootstrap NTP sync using bootstrap DNS ${BOOTSTRAP_DNS_SERVER}..." |
|
local ntp_server_ip |
|
ntp_server_ip=$(nslookup "$BOOTSTRAP_NTP_SERVER" "$BOOTSTRAP_DNS_SERVER" | grep Address | head -n 2 | tail -n 1 | cut -d: -f2 | awk '{print $1}') |
|
|
|
if [ -n "$ntp_server_ip" ]; then |
|
log "Resolved ${BOOTSTRAP_NTP_SERVER} to ${ntp_server_ip}. Forcing time sync..." |
|
ntpd -q -n -p "$ntp_server_ip" |
|
sleep 2 # Give a moment for the clock to settle |
|
|
|
if [ "$(date +%Y)" -lt 2025 ]; then |
|
log "FATAL: Bootstrap NTP sync FAILED. System time is still incorrect." |
|
return 1 |
|
else |
|
log "System time successfully synchronized: $(date)" |
|
nvram set ntp_ready=1 |
|
nvram commit |
|
return 2 # Signifies a successful sync occurred |
|
fi |
|
else |
|
log "FATAL: Could not resolve bootstrap NTP server ${BOOTSTRAP_NTP_SERVER}." |
|
return 1 |
|
fi |
|
fi |
|
# Time is already correct |
|
return 0 |
|
} |
|
|
|
# Internal helper function to resolve the package host IP address *if* in bootstrap mode. |
|
resolve_pkgs_host() { |
|
# If we are not using bootstrap DNS, we do nothing. |
|
# We let curl/nslookup use the system resolver. |
|
if [ "$TAILSMAN_USE_BOOTSTRAP_DNS" -eq 0 ]; then |
|
# Quick check to see if system DNS is likely working |
|
if ! nslookup -timeout=2 "$TAILSCALE_PKGS_HOST" >/dev/null 2>&1; then |
|
log "Warning: System DNS may be unable to resolve ${TAILSCALE_PKGS_HOST}." |
|
log "If downloads fail, try '$0 tailscale --manual-resolve ...'" |
|
fi |
|
return 0 # Success (we're letting the system handle it) |
|
fi |
|
|
|
# --- Using Bootstrap DNS --- |
|
|
|
# If the IP is already resolved and cached, do nothing. |
|
if [ -n "$TAILSCALE_PKGS_IP" ]; then |
|
return 0 |
|
fi |
|
|
|
log "Resolving ${TAILSCALE_PKGS_HOST} using bootstrap DNS ${BOOTSTRAP_DNS_SERVER}..." |
|
TAILSCALE_PKGS_IP=$(nslookup "$TAILSCALE_PKGS_HOST" "$BOOTSTRAP_DNS_SERVER" | grep Address | head -n 2 | tail -n 1 | cut -d: -f2 | awk '{print $1}') |
|
|
|
if [ -z "$TAILSCALE_PKGS_IP" ]; then |
|
log "Error: Could not resolve \"${TAILSCALE_PKGS_HOST}\" using bootstrap DNS." |
|
return 1 |
|
else |
|
log "Resolved ${TAILSCALE_PKGS_HOST} to ${TAILSCALE_PKGS_IP}." |
|
return 0 |
|
fi |
|
} |
|
|
|
get_latest_version() { |
|
if ! resolve_pkgs_host; then |
|
# Fallback to prevent a failed "upgrade" to an empty version string. |
|
get_installed_version "$TAILSCALED_BIN" |
|
return |
|
fi |
|
|
|
local resolve_args="" |
|
if [ "$TAILSMAN_USE_BOOTSTRAP_DNS" -eq 1 ] && [ -n "$TAILSCALE_PKGS_IP" ]; then |
|
resolve_args="--resolve ${TAILSCALE_PKGS_HOST}:443:${TAILSCALE_PKGS_IP}" |
|
fi |
|
|
|
curl --silent --show-error \ |
|
$resolve_args \ |
|
"https://pkgs.tailscale.com/stable/" | \ |
|
grep "tailscale_" | grep "_${ARCH}.tgz" | head -1 | cut -d'_' -f 2 |
|
} |
|
|
|
get_installed_version() { |
|
local binary_path="$1" |
|
if [ ! -f "$binary_path" ]; then |
|
echo "0.0.0" |
|
return |
|
fi |
|
"$binary_path" --version | head -n1 | cut -d' ' -f1 |
|
} |
|
|
|
download_and_install() { |
|
local component="$1" |
|
local version="$2" |
|
local install_dir="$3" |
|
local binary_path="${install_dir}/${component}" |
|
|
|
if [ -z "$version" ]; then |
|
log "Error: Version for download is empty. Aborting installation." |
|
return 1 |
|
fi |
|
|
|
log "Downloading Tailscale ${component} version ${version} for ${ARCH}..." |
|
local version_arch="${version}_${ARCH}" |
|
local tarball_name="tailscale_${version_arch}.tgz" |
|
local tarball_path="/tmp/${tarball_name}" |
|
local extract_list_path="/tmp/extract_list.txt" |
|
local extracted_dir_path="${install_dir}/tailscale_${version_arch}" |
|
local extracted_binary_path="${extracted_dir_path}/${component}" # Full path to the new binary |
|
|
|
# --- Pre-cleanup of old temp files from a previous *failed* run --- |
|
rm -f "$tarball_path" |
|
rm -f "$extract_list_path" |
|
rm -rf "$extracted_dir_path" |
|
# ------------------------------------------------------------------ |
|
|
|
if ! resolve_pkgs_host; then |
|
return 1 |
|
fi |
|
|
|
local resolve_args="" |
|
if [ "$TAILSMAN_USE_BOOTSTRAP_DNS" -eq 1 ] && [ -n "$TAILSCALE_PKGS_IP" ]; then |
|
resolve_args="--resolve ${TAILSCALE_PKGS_HOST}:443:${TAILSCALE_PKGS_IP}" |
|
fi |
|
|
|
curl --silent --show-error --fail -o "$tarball_path" \ |
|
$resolve_args \ |
|
"https://pkgs.tailscale.com/stable/${tarball_name}" |
|
|
|
if [ $? -ne 0 ]; then |
|
log "Error: Failed to download ${tarball_name}. It might not exist." |
|
rm -f "$tarball_path" |
|
return 1 |
|
fi |
|
|
|
echo "tailscale_${version_arch}/${component}" > "$extract_list_path" |
|
|
|
log "Extracting ${component} from ${tarball_name}..." |
|
tar x -zvf "$tarball_path" -C "$install_dir" -T "$extract_list_path" |
|
|
|
# --- ADDED ERROR CHECK 1 --- |
|
# Check if tar failed OR if the file it was supposed to create doesn't exist |
|
if [ $? -ne 0 ] || [ ! -f "$extracted_binary_path" ]; then |
|
log "Error: Failed to extract ${component}." |
|
log "This could be due to a corrupt download or lack of space." |
|
# Clean up all temporary files |
|
rm -f "$tarball_path" |
|
rm -f "$extract_list_path" |
|
rm -rf "$extracted_dir_path" |
|
return 1 |
|
fi |
|
|
|
log "Moving new binary into place at ${binary_path}..." |
|
mv "$extracted_binary_path" "$binary_path" |
|
|
|
# --- ADDED ERROR CHECK 2 --- |
|
# Check if the move command failed |
|
if [ $? -ne 0 ]; then |
|
log "Error: Failed to move ${extracted_binary_path} to ${binary_path}." |
|
log "Check permissions and free space on /jffs." |
|
# Clean up all temporary files |
|
rm -f "$tarball_path" |
|
rm -f "$extract_list_path" |
|
rm -rf "$extracted_dir_path" |
|
return 1 |
|
fi |
|
|
|
# Clean up successfully |
|
rm -rf "$extracted_dir_path" |
|
rm -f "$tarball_path" |
|
rm -f "$extract_list_path" |
|
|
|
chmod +x "$binary_path" |
|
log "${component} successfully installed at ${binary_path}" |
|
return 0 |
|
} |
|
|
|
do_start() { |
|
# First, ensure system time is correct. Abort if it fails. |
|
bootstrap_time_sync |
|
local sync_result=$? |
|
if [ "$sync_result" -eq 1 ]; then # Abort only on hard failure (1) |
|
log "Aborting start due to time sync failure." |
|
return 1 |
|
fi |
|
|
|
if is_tailscaled_running; then |
|
echo "tailscaled is already running (PID: $(pidof tailscaled))." >&2 |
|
return |
|
fi |
|
|
|
if [ ! -f "$TAILSCALED_BIN" ]; then |
|
log "Error: tailscaled binary not found at ${TAILSCALED_BIN}." |
|
log "Please run '$0 install latest' to install it." |
|
return 1 |
|
fi |
|
|
|
log "Starting tailscaled..." |
|
modprobe tun |
|
|
|
nohup env GODEBUG=asyncpreemptoff=1 GOMAXPROCS=1 \ |
|
"$TAILSCALED_BIN" --no-logs-no-support --state="$STATE_FILE" --statedir="$TAILSCALE_DIR" >/dev/null 2>&1 & |
|
|
|
sleep 2 |
|
|
|
apply_tailscale_firewall_rules |
|
|
|
log "tailscaled started." |
|
service restart_dnsmasq >/dev/null 2>&1 |
|
} |
|
|
|
do_stop() { |
|
if ! is_tailscaled_running; then |
|
echo "tailscaled is not running." >&2 |
|
return |
|
fi |
|
|
|
log "Stopping tailscaled..." |
|
kill "$(pidof tailscaled)" |
|
log "tailscaled stopped. Firewall rules will be cleaned up automatically." |
|
} |
|
|
|
do_tailscale_cmd() { |
|
TAILSMAN_USE_BOOTSTRAP_DNS=0 # Default to system DNS for speed |
|
|
|
if [ "$1" = "--manual-resolve" ]; then |
|
log "Manual resolve requested. Using bootstrap DNS." |
|
TAILSMAN_USE_BOOTSTRAP_DNS=1 |
|
TAILSCALE_PKGS_IP="" # Clear the cache |
|
shift # Remove the flag from the argument list |
|
fi |
|
|
|
local daemon_version |
|
local client_version |
|
local target_version |
|
|
|
daemon_version=$(get_installed_version "$TAILSCALED_BIN") |
|
client_version=$(get_installed_version "$TAILSCALE_TMP_BIN") |
|
|
|
if [ "$daemon_version" != "0.0.0" ]; then |
|
target_version="$daemon_version" |
|
else |
|
log "tailscaled not found. Will fetch *latest* client as fallback." |
|
target_version=$(get_latest_version) # This will call resolve_pkgs_host |
|
if [ -z "$target_version" ] || [ "$target_version" = "0.0.0" ]; then |
|
log "Error: Could not determine latest version. Aborting." |
|
return 1 |
|
fi |
|
log "Latest client version is ${target_version}." |
|
fi |
|
|
|
if [ "$client_version" != "$target_version" ] || [ ! -f "$TAILSCALE_TMP_BIN" ]; then |
|
log "Client version ($client_version) does not match target ($target_version). Downloading matching client..." |
|
if ! download_and_install "tailscale" "$target_version" "/tmp/home/root"; then # This will also call resolve_pkgs_host |
|
log "Error: Failed to download client. Aborting command." |
|
return 1 |
|
fi |
|
fi |
|
|
|
"$TAILSCALE_TMP_BIN" "$@" |
|
} |
|
|
|
do_install() { |
|
TAILSMAN_USE_BOOTSTRAP_DNS=1 # Use resilient bootstrap DNS for installs |
|
|
|
local version_to_install="$1" |
|
if [ -z "$version_to_install" ]; then |
|
log "Error: You must specify a version or 'latest'." |
|
echo "Usage: $0 install <version|latest>" >&2 |
|
return 1 |
|
fi |
|
|
|
if [ "$version_to_install" = "latest" ]; then |
|
log "Checking for latest version..." |
|
version_to_install=$(get_latest_version) |
|
if [ -z "$version_to_install" ]; then |
|
log "Error: Could not determine latest version." |
|
return 1 |
|
fi |
|
log "Latest version is ${version_to_install}." |
|
fi |
|
|
|
local installed_version |
|
installed_version=$(get_installed_version "$TAILSCALED_BIN") |
|
if [ "$installed_version" = "$version_to_install" ]; then |
|
log "Already at version ${version_to_install}. No change made." |
|
# Ensure it's running |
|
if ! is_tailscaled_running; then |
|
do_start |
|
fi |
|
return 0 |
|
fi |
|
|
|
log "Installing tailscaled version ${version_to_install} (from ${installed_version})..." |
|
do_stop |
|
|
|
if ! download_and_install "tailscaled" "$version_to_install" "$TAILSCALE_DIR"; then |
|
log "Error: Failed to install version ${version_to_install}. Service remains stopped." |
|
return 1 |
|
fi |
|
|
|
log "Installation of ${version_to_install} complete. Starting service..." |
|
do_start |
|
} |
|
|
|
do_upgrade() { |
|
TAILSMAN_USE_BOOTSTRAP_DNS=1 # Use resilient bootstrap DNS for upgrades |
|
log "Checking for upgrades..." |
|
do_install "latest" |
|
} |
|
|
|
|
|
do_enable() { |
|
local auto_upgrade_flag="$1" |
|
log "Enabling automation: setting up system hooks and cron jobs..." |
|
ensure_script_entry() { |
|
local script_path="$1" |
|
local entry_to_add="$2" |
|
local script_dir |
|
script_dir=$(dirname "$script_path") |
|
|
|
mkdir -p "$script_dir" |
|
if [ ! -f "$script_path" ]; then |
|
printf '#!/bin/sh\n\n' > "$script_path" |
|
chmod +x "$script_path" |
|
fi |
|
if ! grep -qF "$entry_to_add" "$script_path"; then |
|
printf '%s\n' "$entry_to_add" >> "$script_path" |
|
log "Added entry \"${entry_to_add}\" to $script_path" |
|
else |
|
log "Entry \"${entry_to_add}\" already exists in $script_path" |
|
fi |
|
} |
|
|
|
local services_start_script="/jffs/scripts/services-start" |
|
|
|
# First, remove any existing 'enable' entry to avoid duplicates |
|
if [ -f "$services_start_script" ]; then |
|
sed -i "\#${SCRIPT_PATH} enable#d" "$services_start_script" |
|
log "Cleaned old 'enable' entries from $services_start_script" |
|
fi |
|
|
|
# Now add the correct entries |
|
ensure_script_entry "$services_start_script" "/bin/sh ${SCRIPT_PATH} start" |
|
ensure_script_entry "$services_start_script" "/bin/sh ${SCRIPT_PATH} enable $auto_upgrade_flag" |
|
|
|
if ! cru l | grep -q "#tailsman-sentinel#"; then |
|
cru a tailsman-sentinel "* * * * *" "/bin/sh ${SCRIPT_PATH} sentinel" |
|
log "Added sentinel cron job." |
|
else |
|
log "Sentinel cron job already exists." |
|
fi |
|
|
|
if [ "$auto_upgrade_flag" = "--with-auto-upgrade" ]; then |
|
if ! cru l | grep -q "#tailsman-auto-upgrade#"; then |
|
cru a tailsman-auto-upgrade "6 4 * * *" "/bin/sh ${SCRIPT_PATH} upgrade" |
|
log "Added auto-upgrade cron job (runs daily at 4:06 AM)." |
|
else |
|
log "Auto-upgrade cron job already exists." |
|
fi |
|
else |
|
log "Auto-upgrade not requested. Removing any existing auto-upgrade cron job." |
|
if cru l | grep -q "#tailsman-auto-upgrade#"; then |
|
cru d tailsman-auto-upgrade |
|
log "Removed existing auto-upgrade cron job." |
|
fi |
|
fi |
|
|
|
# --- Ensure firewall rules for tailscale0 survive Merlin firewall reloads --- |
|
local firewall_start_script="/jffs/scripts/firewall-start" |
|
|
|
# These rules treat the Tailscale interface as trusted, so traffic doesn't get |
|
# dropped by Merlin's final DROP rules when the firewall is rebuilt. |
|
ensure_script_entry "$firewall_start_script" "iptables -I INPUT 1 -i ${TAILSCALE_IF} -j ACCEPT" |
|
ensure_script_entry "$firewall_start_script" "iptables -I FORWARD 1 -i ${TAILSCALE_IF} -j ACCEPT" |
|
ensure_script_entry "$firewall_start_script" "iptables -I FORWARD 1 -o ${TAILSCALE_IF} -j ACCEPT" |
|
|
|
log "Automation has been enabled (including persistent firewall rules for ${TAILSCALE_IF})." |
|
} |
|
|
|
do_disable() { |
|
log "Disabling automation: removing system hooks, cron jobs, and firewall rules..." |
|
|
|
if is_tailscaled_running; then |
|
do_stop |
|
fi |
|
|
|
# Remove cron jobs |
|
if cru l | grep -q "#tailsman-sentinel#"; then |
|
cru d tailsman-sentinel |
|
log "Removed sentinel cron job." |
|
fi |
|
if cru l | grep -q "#tailsman-auto-upgrade#"; then |
|
cru d tailsman-auto-upgrade |
|
log "Removed auto-upgrade cron job." |
|
fi |
|
|
|
# Remove services-start entries |
|
local services_start_script="/jffs/scripts/services-start" |
|
if [ -f "$services_start_script" ]; then |
|
log "Removing entries from ${services_start_script}..." |
|
sed -i "\#${SCRIPT_PATH} start#d" "$services_start_script" |
|
sed -i "\#${SCRIPT_PATH} enable#d" "$services_start_script" |
|
fi |
|
|
|
# NEW: remove persistent firewall rules for tailscale0 from firewall-start |
|
local firewall_start_script="/jffs/scripts/firewall-start" |
|
if [ -f "$firewall_start_script" ]; then |
|
log "Removing firewall rules for ${TAILSCALE_IF} from ${firewall_start_script}..." |
|
sed -i "\#iptables -I INPUT 1 -i ${TAILSCALE_IF} -j ACCEPT#d" "$firewall_start_script" |
|
sed -i "\#iptables -I FORWARD 1 -i ${TAILSCALE_IF} -j ACCEPT#d" "$firewall_start_script" |
|
sed -i "\#iptables -I FORWARD 1 -o ${TAILSCALE_IF} -j ACCEPT#d" "$firewall_start_script" |
|
fi |
|
|
|
# OPTIONAL: remove the currently loaded iptables rules (best effort) |
|
iptables -D INPUT -i "${TAILSCALE_IF}" -j ACCEPT 2>/dev/null || true |
|
iptables -D FORWARD -i "${TAILSCALE_IF}" -j ACCEPT 2>/dev/null || true |
|
iptables -D FORWARD -o "${TAILSCALE_IF}" -j ACCEPT 2>/dev/null || true |
|
|
|
log "Automation and firewall hooks have been disabled." |
|
} |
|
|
|
|
|
do_help() { |
|
echo "Tailsman: Tailscale Manager for AsusWRT-Merlin" |
|
echo "" |
|
echo "Usage: $0 <command> [options]" |
|
echo "" |
|
echo "Service Commands:" |
|
echo " start Starts the tailscaled daemon. Fails if not installed." |
|
echo " stop Stops the running tailscaled daemon." |
|
echo " restart Restarts the tailscaled daemon." |
|
echo " install <ver|latest> Installs a specific version or the latest version of tailscaled." |
|
echo " upgrade Alias for 'install latest'. Checks for and installs the latest version." |
|
echo " tailscale [...] Passes arguments to the tailscale CLI (e.g., '$0 tailscale status')." |
|
echo " --manual-resolve (As first argument) Forces use of bootstrap DNS for this command." |
|
echo "" |
|
echo "Automation Control:" |
|
echo " enable [--with-auto-upgrade] Installs startup scripts and (optionally) a cron job for auto-upgrades." |
|
echo " disable Removes all startup scripts and cron jobs." |
|
echo " sentinel Used by cron; ensures time is correct and tailscaled is running." |
|
echo "" |
|
echo "Other Commands:" |
|
echo " help Shows this help message." |
|
} |
|
|
|
|
|
COMMAND="$1" |
|
|
|
if [ -z "$COMMAND" ]; then |
|
do_help |
|
exit 1 |
|
fi |
|
|
|
shift # Remove the command from the argument list, "$@" now holds the rest |
|
|
|
case "$COMMAND" in |
|
start) do_start "$@" ;; |
|
stop) do_stop ;; |
|
restart) |
|
do_stop |
|
sleep 2 |
|
do_start "$@" |
|
;; |
|
tailscale) |
|
do_tailscale_cmd "$@" |
|
;; |
|
sentinel) |
|
TAILSMAN_USE_BOOTSTRAP_DNS=1 # Sentinel must be resilient |
|
# Proactively ensure system time is correct on every run. |
|
bootstrap_time_sync |
|
sync_result=$? # Capture the return code immediately |
|
|
|
if [ "$sync_result" -eq 2 ]; then |
|
# A successful time sync just occurred. Restart tailscaled to apply. |
|
log "Sentinel: Time was corrected. Restarting tailscaled to ensure connection health." |
|
do_stop |
|
sleep 2 |
|
do_start |
|
elif [ "$sync_result" -eq 0 ]; then |
|
# Time was already correct. Check if the service is running. |
|
if ! is_tailscaled_running; then |
|
log "Sentinel: tailscaled not running. Attempting to restart..." |
|
do_start |
|
fi |
|
fi |
|
# If sync_result is 1 (failure), we do nothing further. The failure is already logged. |
|
;; |
|
install) do_install "$@" ;; |
|
upgrade) do_upgrade ;; |
|
enable) do_enable "$@" ;; |
|
disable) do_disable ;; |
|
help) do_help ;; |
|
*) |
|
echo "Error: Unknown command '$COMMAND'" >&2 |
|
echo "" >&2 |
|
do_help |
|
exit 1 |
|
;; |
|
esac |
|
|
|
exit 0 |