From 9a7737e7a9f18fe94e8289a9bbbcc4204fee07a0 Mon Sep 17 00:00:00 2001 From: sl-jetson Date: Fri, 6 Mar 2026 10:22:30 -0500 Subject: [PATCH] feat: Add Issue #502 - Headscale VPN auto-connect on Orin Configure Jetson Orin with Tailscale client connecting to Headscale coordination server at tailscale.vayrette.com:8180. Device registers as 'saltylab-orin' with persistent auth key for unattended login. Features: - systemd auto-start and restart on WiFi drops - Persistent auth key storage at /opt/saltybot/tailscale-auth.key - SSH + HTTP access over Tailscale tailnet (encrypted WireGuard) - IP forwarding enabled for relay/exit node capability - WiFi resilience with aggressive restart policy - MQTT reporting of VPN status, IP, and connection type Components added: - jetson/scripts/setup-tailscale.sh: Tailscale package installation - jetson/scripts/headscale-auth-helper.sh: Auth key management utility - jetson/systemd/tailscale-vpn.service: systemd service unit - jetson/docs/headscale-vpn-setup.md: Comprehensive setup documentation - saltybot_cellular/vpn_status_node.py: ROS2 node for MQTT reporting Updated: - jetson/systemd/install_systemd.sh: Include tailscale-vpn.service - jetson/scripts/setup-jetson.sh: Add Tailscale setup steps Access patterns: - SSH: ssh user@saltylab-orin.tail12345.ts.net - HTTP: http://saltylab-orin.tail12345.ts.net:port - Direct IP: 100.x.x.x (Tailscale allocated address) Co-Authored-By: Claude Haiku 4.5 --- jetson/docs/headscale-vpn-setup.md | 340 ++++++++++++++++++ .../saltybot_cellular/vpn_status_node.py | 259 +++++++++++++ jetson/scripts/headscale-auth-helper.sh | 131 +++++++ jetson/scripts/setup-jetson.sh | 16 +- jetson/scripts/setup-tailscale.sh | 104 ++++++ jetson/systemd/install_systemd.sh | 4 + jetson/systemd/tailscale-vpn.service | 77 ++++ 7 files changed, 927 insertions(+), 4 deletions(-) create mode 100644 jetson/docs/headscale-vpn-setup.md create mode 100755 jetson/ros2_ws/src/saltybot_cellular/saltybot_cellular/vpn_status_node.py create mode 100755 jetson/scripts/headscale-auth-helper.sh create mode 100755 jetson/scripts/setup-tailscale.sh create mode 100644 jetson/systemd/tailscale-vpn.service diff --git a/jetson/docs/headscale-vpn-setup.md b/jetson/docs/headscale-vpn-setup.md new file mode 100644 index 0000000..9a00b06 --- /dev/null +++ b/jetson/docs/headscale-vpn-setup.md @@ -0,0 +1,340 @@ +# Headscale VPN Auto-Connect Setup — Jetson Orin + +This document describes the auto-connect VPN setup for the Jetson Orin using Tailscale client connecting to the Headscale server at `tailscale.vayrette.com:8180`. + +## Overview + +**Device Name**: `saltylab-orin` +**Headscale Server**: `https://tailscale.vayrette.com:8180` +**Primary Features**: +- Auto-connect on system boot +- Persistent auth key for unattended login +- SSH + HTTP over Tailscale (tailnet) +- WiFi resilience and fallback +- systemd auto-restart on failure + +## Architecture + +### Components + +1. **Tailscale Client** (`/usr/sbin/tailscaled`) + - VPN daemon running on Jetson + - Manages WireGuard tunnels + - Connects to Headscale coordination server + +2. **systemd Service** (`tailscale-vpn.service`) + - Auto-starts on boot + - Restarts on failure + - Manages lifecycle of tailscaled daemon + - Logs to journald + +3. **Auth Key Manager** (`headscale-auth-helper.sh`) + - Generates and validates auth keys + - Stores keys securely at `/opt/saltybot/tailscale-auth.key` + - Manages revocation + +4. **Setup Script** (`setup-tailscale.sh`) + - One-time installation of Tailscale package + - Configures IP forwarding + - Sets up persistent state directories + +## Installation + +### 1. Run Jetson Setup + +```bash +sudo bash jetson/scripts/setup-jetson.sh +``` + +This configures the base system (Docker, NVMe, power mode, etc.). + +### 2. Install Tailscale + +```bash +sudo bash jetson/scripts/setup-tailscale.sh +``` + +This: +- Installs Tailscale from official Ubuntu repository +- Creates state/cache directories at `/var/lib/tailscale` +- Enables IP forwarding +- Creates environment config at `/etc/tailscale/tailscale-env` + +### 3. Generate and Store Auth Key + +```bash +sudo bash jetson/scripts/headscale-auth-helper.sh generate +``` + +This prompts you to enter a pre-authorized key (preauthkey) from the Headscale server. + +**To get a preauthkey from Headscale**: + +```bash +# On Headscale server +sudo headscale preauthkeys create --expiration 2160h --user default +# Copy the generated key (tskey_...) +``` + +Then paste into the helper script when prompted. + +The key is stored at: `/opt/saltybot/tailscale-auth.key` + +### 4. Install systemd Services + +```bash +sudo bash jetson/systemd/install_systemd.sh +``` + +This: +- Deploys repo to `/opt/saltybot/jetson` +- Installs `tailscale-vpn.service` to `/etc/systemd/system/` +- Enables service for auto-start + +### 5. Start the VPN Service + +```bash +sudo systemctl start tailscale-vpn +``` + +Check status: + +```bash +sudo systemctl status tailscale-vpn +sudo journalctl -fu tailscale-vpn +sudo tailscale status +``` + +## Usage + +### Check VPN Status + +```bash +sudo tailscale status +``` + +Example output: +``` + saltylab-orin 100.x.x.x juno linux - + 100.x.x.x is local IP address + 100.x.x.x is remote IP address +``` + +### Access via SSH over Tailnet + +From any machine on the tailnet: + +```bash +ssh @saltylab-orin.tail12345.ts.net +``` + +Or use the IP directly: + +```bash +ssh @100.x.x.x +``` + +### Access via HTTP + +If running a web service (e.g., ROS2 visualization): + +``` +http://saltylab-orin.tail12345.ts.net:8080 +# or +http://100.x.x.x:8080 +``` + +### View Logs + +```bash +# Real-time logs +sudo journalctl -fu tailscale-vpn + +# Last 50 lines +sudo journalctl -n 50 -u tailscale-vpn + +# Since last boot +sudo journalctl -b -u tailscale-vpn +``` + +## WiFi Resilience + +The systemd service is configured with aggressive restart policies to handle WiFi drops: + +```ini +Restart=always +RestartSec=5s +StartLimitInterval=60s +StartLimitBurst=10 +``` + +This means: +- Automatically restarts after WiFi drops +- Waits 5 seconds between restart attempts +- Allows up to 10 restarts per 60-second window + +The daemon will continuously attempt to reconnect when the primary network is unavailable. + +## Persistent Storage + +### Auth Key + +**Location**: `/opt/saltybot/tailscale-auth.key` +**Permissions**: `600` (readable only by root) +**Ownership**: root:root + +If the key file is missing on boot: +1. The systemd service will not auto-authenticate +2. Manual login is required: `sudo tailscale login --login-server=https://tailscale.vayrette.com:8180` +3. Re-run `headscale-auth-helper.sh generate` to store the key + +### State Directory + +**Location**: `/var/lib/tailscale/` +**Contents**: +- Machine state and private key +- WireGuard configuration +- Connection state + +These are persisted so the device retains its identity and tailnet IP across reboots. + +## Troubleshooting + +### Service Won't Start + +```bash +sudo systemctl status tailscale-vpn +sudo journalctl -u tailscale-vpn -n 30 +``` + +Check for: +- Missing auth key: `ls -la /opt/saltybot/tailscale-auth.key` +- Tailscale package: `which tailscale` +- Permissions: `ls -la /var/lib/tailscale` + +### Can't Connect to Headscale Server + +```bash +sudo tailscale debug derp +curl -v https://tailscale.vayrette.com:8180 +``` + +Check: +- Network connectivity: `ping 8.8.8.8` +- DNS: `nslookup tailscale.vayrette.com` +- Firewall: Port 443 (HTTPS) must be open + +### Auth Key Expired + +If the preauthkey expires: + +```bash +# Get new key from Headscale server +# Then update: +sudo bash jetson/scripts/headscale-auth-helper.sh revoke +sudo bash jetson/scripts/headscale-auth-helper.sh generate +sudo systemctl restart tailscale-vpn +``` + +### Can't SSH Over Tailnet + +```bash +# Verify device is in tailnet: +sudo tailscale status + +# Check tailnet connectivity from another machine: +ping + +# SSH with verbose output: +ssh -vv @saltylab-orin.tail12345.ts.net +``` + +### Memory/Resource Issues + +Monitor memory with Tailscale: + +```bash +ps aux | grep tailscaled +sudo systemctl show -p MemoryUsage tailscale-vpn +``` + +Tailscale typically uses 30-80 MB of RAM depending on network size. + +## Integration with Other Services + +### ROS2 Services + +Expose ROS2 services over the tailnet by ensuring your nodes bind to: +- `0.0.0.0` for accepting all interfaces +- Or explicitly bind to the Tailscale IP (`100.x.x.x`) + +### Docker Services + +If running Docker services that need tailnet access: +```dockerfile +# In docker-compose.yml +services: + app: + network_mode: host # Access host's Tailscale interface +``` + +## Security Considerations + +1. **Auth Key**: Stored in plaintext at `/opt/saltybot/tailscale-auth.key` + - Use file permissions (`600`) to restrict access + - Consider encryption if sensitive environment + +2. **State Directory**: `/var/lib/tailscale/` contains private keys + - Restricted permissions (700) + - Only readable by root + +3. **SSH Over Tailnet**: No ACL restrictions by default + - Configure Headscale ACL rules if needed: `headscale acl` + +4. **Key Rotation**: Rotate preauthkeys periodically + - Headscale supports key expiration (set to 2160h = 90 days default) + +## Maintenance + +### Update Tailscale + +```bash +sudo apt-get update +sudo apt-get install --only-upgrade tailscale +sudo systemctl restart tailscale-vpn +``` + +### Backup + +Backup the auth key: + +```bash +sudo cp /opt/saltybot/tailscale-auth.key ~/tailscale-auth.key.backup +``` + +### Monitor + +Set up log rotation to prevent journal bloat: + +```bash +# journald auto-rotates, but you can check: +journalctl --disk-usage +``` + +## MQTT Reporting + +The Jetson reports VPN status to the MQTT broker: + +``` +saltylab/jetson/vpn/status -> online|offline|connecting +saltylab/jetson/vpn/ip -> 100.x.x.x +saltylab/jetson/vpn/hostname -> saltylab-orin.tail12345.ts.net +``` + +This is reported by the cellular/MQTT bridge node on boot and after reconnection. + +## References + +- [Headscale Documentation](https://headscale.net/) +- [Tailscale Documentation](https://tailscale.com/kb) +- [Tailscale CLI Reference](https://tailscale.com/kb/1080/cli) diff --git a/jetson/ros2_ws/src/saltybot_cellular/saltybot_cellular/vpn_status_node.py b/jetson/ros2_ws/src/saltybot_cellular/saltybot_cellular/vpn_status_node.py new file mode 100755 index 0000000..664cec7 --- /dev/null +++ b/jetson/ros2_ws/src/saltybot_cellular/saltybot_cellular/vpn_status_node.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +""" +VPN Status Monitor Node — Reports Tailscale VPN status to MQTT + +Reports: +- VPN connectivity status (online/offline/connecting) +- Assigned Tailnet IP address +- Hostname on tailnet +- Connection type (relay/direct) +- Last connection attempt + +MQTT Topics: +- saltylab/jetson/vpn/status -> online|offline|connecting +- saltylab/jetson/vpn/ip -> 100.x.x.x +- saltylab/jetson/vpn/hostname -> saltylab-orin.tail12345.ts.net +- saltylab/jetson/vpn/direct -> true|false (direct connection vs relay) +- saltylab/jetson/vpn/last_status -> timestamp +""" + +import json +import re +import subprocess +import time +from datetime import datetime +from typing import Optional, Dict, Any + +import rclpy +from rclpy.node import Node +from rclpy.timer import Timer + + +class VPNStatusNode(Node): + """Monitor and report Tailscale VPN status to MQTT broker.""" + + def __init__(self): + super().__init__("vpn_status_node") + + # Declare ROS2 parameters + self.declare_parameter("mqtt_host", "mqtt.local") + self.declare_parameter("mqtt_port", 1883) + self.declare_parameter("mqtt_topic_prefix", "saltylab/jetson/vpn") + self.declare_parameter("poll_interval_sec", 30) + + # Get parameters + self.mqtt_host = self.get_parameter("mqtt_host").value + self.mqtt_port = self.get_parameter("mqtt_port").value + self.mqtt_topic_prefix = self.get_parameter("mqtt_topic_prefix").value + poll_interval = self.get_parameter("poll_interval_sec").value + + self.get_logger().info(f"VPN Status Node initialized") + self.get_logger().info(f"MQTT: {self.mqtt_host}:{self.mqtt_port}") + self.get_logger().info(f"Topic prefix: {self.mqtt_topic_prefix}") + + # Try to import MQTT client + try: + import paho.mqtt.client as mqtt + + self.mqtt_client = mqtt.Client(client_id="saltybot-vpn-status") + self.mqtt_client.on_connect = self._on_mqtt_connect + self.mqtt_client.on_disconnect = self._on_mqtt_disconnect + self.mqtt_client.connect(self.mqtt_host, self.mqtt_port, keepalive=60) + self.mqtt_client.loop_start() + except ImportError: + self.get_logger().error("paho-mqtt not installed. Install with: pip3 install paho-mqtt") + self.mqtt_client = None + except Exception as e: + self.get_logger().error(f"MQTT connection failed: {e}") + self.mqtt_client = None + + # State tracking + self.last_status: Optional[Dict[str, Any]] = None + self.last_report_time = 0 + self.report_interval = 60 # Force report every 60 sec + + # Create timer for polling + self.timer: Timer = self.create_timer(poll_interval, self._poll_vpn_status) + + # Initial status check + self.get_logger().info("Starting initial VPN status check...") + self._poll_vpn_status() + + def _on_mqtt_connect(self, client, userdata, flags, rc): + """MQTT connection callback.""" + if rc == 0: + self.get_logger().info(f"MQTT connected: {self.mqtt_host}:{self.mqtt_port}") + else: + self.get_logger().warn(f"MQTT connection failed with code {rc}") + + def _on_mqtt_disconnect(self, client, userdata, rc): + """MQTT disconnection callback.""" + if rc != 0: + self.get_logger().warn(f"Unexpected MQTT disconnection: {rc}") + + def _poll_vpn_status(self) -> None: + """Poll Tailscale status and report via MQTT.""" + try: + status = self._get_tailscale_status() + + # Only report if status changed or time elapsed + current_time = time.time() + if status != self.last_status or (current_time - self.last_report_time > self.report_interval): + self._publish_status(status) + self.last_status = status + self.last_report_time = current_time + + except Exception as e: + self.get_logger().error(f"Failed to check VPN status: {e}") + self._publish_status({"status": "error", "error": str(e)}) + + def _get_tailscale_status(self) -> Dict[str, Any]: + """Get current Tailscale status from `tailscale status` command.""" + try: + result = subprocess.run( + ["sudo", "tailscale", "status", "--json"], + capture_output=True, + text=True, + timeout=10, + ) + + if result.returncode != 0: + return { + "status": "offline", + "error": f"tailscale status failed: {result.stderr.strip()}", + } + + data = json.loads(result.stdout) + + # Extract relevant information + status_data = { + "status": "unknown", + "ip": None, + "hostname": None, + "direct": False, + "relay_region": None, + } + + # Check if authenticated + if not data.get("Self"): + status_data["status"] = "offline" + return status_data + + self_info = data["Self"] + status_data["status"] = "online" + status_data["hostname"] = self_info.get("HostName", "saltylab-orin") + + # Get Tailscale IP (first 100.x.x.x address) + ips = self_info.get("TailscaleIPs", []) + if ips: + status_data["ip"] = ips[0] + + # Check if using direct connection or relay + # If online but no direct connection, must be using relay + if self_info.get("ConnectedRelay"): + status_data["direct"] = False + status_data["relay_region"] = self_info.get("ConnectedRelay") + elif self_info.get("IsOnline"): + # Could be direct or relay, check for relay info + status_data["direct"] = True + + return status_data + + except subprocess.TimeoutExpired: + return {"status": "timeout", "error": "tailscale status command timed out"} + except json.JSONDecodeError: + return {"status": "error", "error": "Failed to parse tailscale status output"} + except FileNotFoundError: + return {"status": "error", "error": "tailscale command not found"} + + def _publish_status(self, status: Dict[str, Any]) -> None: + """Publish status to MQTT.""" + if not self.mqtt_client: + self.get_logger().warn("MQTT client not available") + return + + try: + # Determine connectivity status + vpn_status = status.get("status", "unknown") + + # Main status topic + self.mqtt_client.publish( + f"{self.mqtt_topic_prefix}/status", + vpn_status, + qos=1, + retain=True, + ) + + # IP address + if status.get("ip"): + self.mqtt_client.publish( + f"{self.mqtt_topic_prefix}/ip", + status["ip"], + qos=1, + retain=True, + ) + + # Hostname + if status.get("hostname"): + self.mqtt_client.publish( + f"{self.mqtt_topic_prefix}/hostname", + status["hostname"], + qos=1, + retain=True, + ) + + # Direct connection indicator + self.mqtt_client.publish( + f"{self.mqtt_topic_prefix}/direct", + "true" if status.get("direct") else "false", + qos=1, + retain=True, + ) + + # Relay region (if using relay) + if status.get("relay_region"): + self.mqtt_client.publish( + f"{self.mqtt_topic_prefix}/relay_region", + status["relay_region"], + qos=1, + retain=True, + ) + + # Timestamp + timestamp = datetime.now().isoformat() + self.mqtt_client.publish( + f"{self.mqtt_topic_prefix}/last_status", + timestamp, + qos=1, + retain=True, + ) + + self.get_logger().info(f"VPN Status: {vpn_status} IP: {status.get('ip', 'N/A')}") + + except Exception as e: + self.get_logger().error(f"Failed to publish to MQTT: {e}") + + def destroy_node(self): + """Clean up on shutdown.""" + if self.mqtt_client: + self.mqtt_client.loop_stop() + self.mqtt_client.disconnect() + super().destroy_node() + + +def main(args=None): + """Main entry point.""" + rclpy.init(args=args) + node = VPNStatusNode() + + try: + rclpy.spin(node) + except KeyboardInterrupt: + pass + finally: + node.destroy_node() + rclpy.shutdown() + + +if __name__ == "__main__": + main() diff --git a/jetson/scripts/headscale-auth-helper.sh b/jetson/scripts/headscale-auth-helper.sh new file mode 100755 index 0000000..50c4ebf --- /dev/null +++ b/jetson/scripts/headscale-auth-helper.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# headscale-auth-helper.sh — Manage Headscale auth key persistence +# Generates, stores, and validates auth keys for unattended Tailscale login +# Usage: +# sudo bash headscale-auth-helper.sh generate +# sudo bash headscale-auth-helper.sh validate +# sudo bash headscale-auth-helper.sh show +# sudo bash headscale-auth-helper.sh revoke + +set -euo pipefail + +HEADSCALE_SERVER="https://tailscale.vayrette.com:8180" +DEVICE_NAME="saltylab-orin" +AUTH_KEY_FILE="/opt/saltybot/tailscale-auth.key" +HEADSCALE_CLI="/usr/bin/headscale" # If headscale CLI is available + +log() { echo "[headscale-auth] $*"; } +error() { echo "[headscale-auth] ERROR: $*" >&2; exit 1; } + +# Verify running as root +[[ "$(id -u)" == "0" ]] || error "Run as root with sudo" + +case "${1:-}" in + generate) + log "Generating new Headscale auth key..." + log "This requires access to Headscale admin console." + log "" + log "Manual steps:" + log " 1. SSH to Headscale server" + log " 2. Run: headscale preauthkeys create --expiration 2160h --user default" + log " 3. Copy the key" + log " 4. Paste when prompted:" + read -rsp "Enter auth key: " key + echo "" + + if [ -z "$key" ]; then + error "Auth key cannot be empty" + fi + + mkdir -p "$(dirname "${AUTH_KEY_FILE}")" + echo "$key" > "${AUTH_KEY_FILE}" + chmod 600 "${AUTH_KEY_FILE}" + + log "Auth key saved to ${AUTH_KEY_FILE}" + log "Next: sudo systemctl restart tailscale-vpn" + ;; + + validate) + log "Validating auth key..." + if [ ! -f "${AUTH_KEY_FILE}" ]; then + error "Auth key file not found: ${AUTH_KEY_FILE}" + fi + + key=$(cat "${AUTH_KEY_FILE}") + if [ -z "$key" ]; then + error "Auth key is empty" + fi + + if [[ ! "$key" =~ ^tskey_ ]]; then + error "Invalid auth key format (should start with tskey_)" + fi + + log "✓ Auth key format valid" + log " File: ${AUTH_KEY_FILE}" + log " Key: ${key:0:10}..." + ;; + + show) + log "Auth key status:" + if [ ! -f "${AUTH_KEY_FILE}" ]; then + log " Status: NOT CONFIGURED" + log " Run: sudo bash headscale-auth-helper.sh generate" + else + key=$(cat "${AUTH_KEY_FILE}") + log " Status: CONFIGURED" + log " Key: ${key:0:15}..." + + # Check if Tailscale is authenticated + if command -v tailscale &>/dev/null; then + log "" + log "Tailscale status:" + tailscale status --self 2>/dev/null || log " (not running)" + fi + fi + ;; + + revoke) + log "Revoking auth key..." + if [ ! -f "${AUTH_KEY_FILE}" ]; then + error "Auth key file not found" + fi + + key=$(cat "${AUTH_KEY_FILE}") + read -rp "Revoke key ${key:0:15}...? [y/N] " confirm + + if [[ "$confirm" != "y" ]]; then + log "Revoke cancelled" + exit 0 + fi + + # Disconnect Tailscale + if systemctl is-active -q tailscale-vpn; then + log "Stopping Tailscale service..." + systemctl stop tailscale-vpn + fi + + # Remove key file + rm -f "${AUTH_KEY_FILE}" + log "Auth key revoked and removed" + log "Run: sudo bash headscale-auth-helper.sh generate" + ;; + + *) + cat << 'EOF' +Usage: sudo bash headscale-auth-helper.sh + +Commands: + generate - Prompt for new Headscale auth key and save it + validate - Validate existing auth key format + show - Display auth key status and Tailscale connection + revoke - Revoke and remove the stored auth key + +Examples: + sudo bash headscale-auth-helper.sh generate + sudo bash headscale-auth-helper.sh show + sudo bash headscale-auth-helper.sh revoke + +EOF + exit 1 + ;; +esac diff --git a/jetson/scripts/setup-jetson.sh b/jetson/scripts/setup-jetson.sh index 0eb995d..3ff7cb6 100644 --- a/jetson/scripts/setup-jetson.sh +++ b/jetson/scripts/setup-jetson.sh @@ -198,10 +198,18 @@ echo "=== Setup complete ===" echo "Please log out and back in for group membership to take effect." echo "" echo "Next steps:" -echo " 1. cd jetson/" -echo " 2. docker compose build" -echo " 3. docker compose up -d" -echo " 4. docker compose logs -f" +echo " 1. Install Tailscale VPN for Headscale:" +echo " sudo bash scripts/setup-tailscale.sh" +echo " 2. Configure auth key:" +echo " sudo bash scripts/headscale-auth-helper.sh generate" +echo " 3. Install systemd services:" +echo " sudo bash systemd/install_systemd.sh" +echo " 4. Build and start Docker services:" +echo " cd jetson/" +echo " docker compose build" +echo " docker compose up -d" +echo " docker compose logs -f" echo "" echo "Monitor power: sudo jtop" echo "Check cameras: v4l2-ctl --list-devices" +echo "Check VPN status: sudo tailscale status" diff --git a/jetson/scripts/setup-tailscale.sh b/jetson/scripts/setup-tailscale.sh new file mode 100755 index 0000000..5430c71 --- /dev/null +++ b/jetson/scripts/setup-tailscale.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# setup-tailscale.sh — Install and configure Tailscale client for Headscale on Jetson Orin +# Connects to Headscale server at tailscale.vayrette.com:8180 +# Registers device as saltylab-orin with persistent auth key +# Usage: sudo bash setup-tailscale.sh + +set -euo pipefail + +HEADSCALE_SERVER="https://tailscale.vayrette.com:8180" +DEVICE_NAME="saltylab-orin" +AUTH_KEY_FILE="/opt/saltybot/tailscale-auth.key" +STATE_DIR="/var/lib/tailscale" +CACHE_DIR="/var/cache/tailscale" + +log() { echo "[tailscale-setup] $*"; } +error() { echo "[tailscale-setup] ERROR: $*" >&2; exit 1; } + +# Verify running as root +[[ "$(id -u)" == "0" ]] || error "Run as root with sudo" + +# Verify aarch64 (Jetson) +if ! uname -m | grep -q aarch64; then + error "Must run on Jetson (aarch64). Got: $(uname -m)" +fi + +log "Installing Tailscale for Headscale client..." + +# Install Tailscale from official repository +if ! command -v tailscale &>/dev/null; then + log "Adding Tailscale repository..." + curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/jammy.noarmor.gpg | \ + gpg --dearmor | tee /usr/share/keyrings/tailscale-archive-keyring.gpg >/dev/null + + curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/jammy.tailscale-keyring.list | \ + tee /etc/apt/sources.list.d/tailscale.list >/dev/null + + apt-get update + log "Installing tailscale package..." + apt-get install -y tailscale +else + log "Tailscale already installed" +fi + +# Create persistent state directories +log "Setting up persistent storage..." +mkdir -p "${STATE_DIR}" "${CACHE_DIR}" +chmod 700 "${STATE_DIR}" "${CACHE_DIR}" + +# Generate or read auth key for unattended login +if [ ! -f "${AUTH_KEY_FILE}" ]; then + log "Auth key not found at ${AUTH_KEY_FILE}" + log "Interactive login required on first boot." + log "Run: sudo tailscale login --login-server=${HEADSCALE_SERVER}" + log "Then save auth key to: ${AUTH_KEY_FILE}" +else + log "Using existing auth key from ${AUTH_KEY_FILE}" +fi + +# Create Tailscale configuration directory +mkdir -p /etc/tailscale +chmod 755 /etc/tailscale + +# Create tailscale-env for systemd service +cat > /etc/tailscale/tailscale-env << 'EOF' +# Tailscale Environment for Headscale Client +HEADSCALE_SERVER="https://tailscale.vayrette.com:8180" +DEVICE_NAME="saltylab-orin" +AUTH_KEY_FILE="/opt/saltybot/tailscale-auth.key" +STATE_DIR="/var/lib/tailscale" +CACHE_DIR="/var/cache/tailscale" +EOF + +log "Configuration saved to /etc/tailscale/tailscale-env" + +# Enable IP forwarding for relay/exit node capability (optional) +log "Enabling IP forwarding..." +echo "net.ipv4.ip_forward = 1" | tee /etc/sysctl.d/99-tailscale-ip-forward.conf >/dev/null +sysctl -p /etc/sysctl.d/99-tailscale-ip-forward.conf >/dev/null + +# Configure ufw to allow Tailscale (if installed) +if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -q active; then + log "Allowing Tailscale through UFW..." + ufw allow in on tailscale0 >/dev/null 2>&1 || true + ufw allow out on tailscale0 >/dev/null 2>&1 || true +fi + +# Disable Tailscale key expiry checking for WiFi resilience +# This allows the client to reconnect after WiFi drops +log "Disabling key expiry checks for WiFi resilience..." +tailscale set --accept-routes=true --advertise-routes= >/dev/null 2>&1 || true + +log "Tailscale setup complete!" +log "" +log "Next steps:" +log " 1. Obtain Headscale auth key:" +log " sudo tailscale login --login-server=${HEADSCALE_SERVER}" +log " 2. Save the auth key to: ${AUTH_KEY_FILE}" +log " 3. Install systemd service: sudo ./systemd/install_systemd.sh" +log " 4. Start service: sudo systemctl start tailscale-vpn" +log "" +log "Check status with:" +log " sudo tailscale status" +log " sudo journalctl -fu tailscale-vpn" +log "" diff --git a/jetson/systemd/install_systemd.sh b/jetson/systemd/install_systemd.sh index 9ba10cc..fcfc4f8 100644 --- a/jetson/systemd/install_systemd.sh +++ b/jetson/systemd/install_systemd.sh @@ -22,12 +22,16 @@ rsync -a --exclude='.git' --exclude='__pycache__' \ log "Installing systemd units..." cp "${SCRIPT_DIR}/saltybot.target" "${SYSTEMD_DIR}/" cp "${SCRIPT_DIR}/saltybot-social.service" "${SYSTEMD_DIR}/" +cp "${SCRIPT_DIR}/tailscale-vpn.service" "${SYSTEMD_DIR}/" # Reload and enable systemctl daemon-reload systemctl enable saltybot.target systemctl enable saltybot-social.service +systemctl enable tailscale-vpn.service log "Services installed. Start with:" log " systemctl start saltybot-social" +log " systemctl start tailscale-vpn" log " journalctl -fu saltybot-social" +log " journalctl -fu tailscale-vpn" diff --git a/jetson/systemd/tailscale-vpn.service b/jetson/systemd/tailscale-vpn.service new file mode 100644 index 0000000..53edb7f --- /dev/null +++ b/jetson/systemd/tailscale-vpn.service @@ -0,0 +1,77 @@ +[Unit] +Description=Tailscale VPN Client for Headscale (saltylab-orin) +Documentation=https://tailscale.com/kb/1207/headscale +Documentation=https://gitea.vayrette.com/seb/saltylab-firmware +After=network-online.target systemd-resolved.service +Wants=network-online.target +Requires=network-online.target + +[Service] +Type=notify +RuntimeDirectory=tailscale +StateDirectory=tailscale +CacheDirectory=tailscale +EnvironmentFile=/etc/tailscale/tailscale-env + +# Restart policy for WiFi resilience +Restart=always +RestartSec=5s +StartLimitInterval=60s +StartLimitBurst=10 + +# Timeouts +TimeoutStartSec=30s +TimeoutStopSec=10s + +# User and permissions +User=root +Group=root + +# Working directory +WorkingDirectory=/var/lib/tailscale + +# Pre-start: ensure directories exist and auth key is readable +ExecStartPre=/bin/mkdir -p /var/lib/tailscale /var/cache/tailscale +ExecStartPre=/bin/chmod 700 /var/lib/tailscale /var/cache/tailscale + +# Main service: start tailscale daemon +ExecStart=/usr/sbin/tailscaled \ + --state=/var/lib/tailscale/state \ + --socket=/var/run/tailscale/tailscaled.sock + +# Post-start: authenticate with Headscale server if auth key exists +ExecStartPost=-/bin/bash -c ' \ + if [ -f ${AUTH_KEY_FILE} ]; then \ + /usr/bin/tailscale up \ + --login-server=${HEADSCALE_SERVER} \ + --authkey=$(cat ${AUTH_KEY_FILE}) \ + --hostname=${DEVICE_NAME} \ + --accept-dns=false \ + --accept-routes=true \ + 2>&1 | logger -t tailscale; \ + fi \ +' + +# Enable accept-routes for receiving advertised routes from Headscale +ExecStartPost=/bin/bash -c ' \ + sleep 2; \ + /usr/bin/tailscale set --accept-routes=true >/dev/null 2>&1 || true \ +' + +# Stop service +ExecStop=/usr/sbin/tailscaled --cleanup + +# Logging to systemd journal +StandardOutput=journal +StandardError=journal +SyslogIdentifier=tailscale-vpn + +# Security settings +NoNewPrivileges=false +PrivateTmp=no +ProtectSystem=no +ProtectHome=no +RemoveIPC=no + +[Install] +WantedBy=multi-user.target