diff --git a/jetson/docs/headscale-vpn-setup.md b/jetson/docs/headscale-vpn-setup.md new file mode 100644 index 0000000..6be93b4 --- /dev/null +++ b/jetson/docs/headscale-vpn-setup.md @@ -0,0 +1,154 @@ +# 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 +``` + +### 2. Install Tailscale + +```bash +sudo bash jetson/scripts/setup-tailscale.sh +``` + +### 3. Generate Auth Key + +```bash +sudo bash jetson/scripts/headscale-auth-helper.sh generate +``` + +### 4. Install systemd Services + +```bash +sudo bash jetson/systemd/install_systemd.sh +``` + +### 5. Start the VPN Service + +```bash +sudo systemctl start tailscale-vpn +``` + +## Usage + +### Check VPN Status + +```bash +sudo tailscale status +``` + +### Access via SSH + +```bash +ssh @saltylab-orin.tail12345.ts.net +``` + +### View Logs + +```bash +sudo journalctl -fu tailscale-vpn +``` + +## WiFi Resilience + +Automatic restart after WiFi drops with aggressive restart policies: + +```ini +Restart=always +RestartSec=5s +StartLimitInterval=60s +StartLimitBurst=10 +``` + +## Persistent Storage + +**Auth Key**: `/opt/saltybot/tailscale-auth.key` +**State Directory**: `/var/lib/tailscale/` + +## Troubleshooting + +### Service Won't Start + +```bash +sudo systemctl status tailscale-vpn +sudo journalctl -u tailscale-vpn -n 30 +``` + +### Can't Connect to Headscale + +```bash +ping 8.8.8.8 +nslookup tailscale.vayrette.com +``` + +### Auth Key Expired + +```bash +sudo bash jetson/scripts/headscale-auth-helper.sh revoke +sudo bash jetson/scripts/headscale-auth-helper.sh generate +sudo systemctl restart tailscale-vpn +``` + +## Security + +- Auth key stored in plaintext at `/opt/saltybot/tailscale-auth.key` +- File permissions: `600` (readable only by root) +- State directory restricted: `700` (only root) +- SSH over tailnet with no ACL restrictions by default + +## MQTT Reporting + +VPN status reported to MQTT: + +``` +saltylab/jetson/vpn/status -> online|offline|connecting +saltylab/jetson/vpn/ip -> 100.x.x.x +saltylab/jetson/vpn/hostname -> saltylab-orin.tail12345.ts.net +``` + +## 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..de8d412 --- /dev/null +++ b/jetson/ros2_ws/src/saltybot_cellular/saltybot_cellular/vpn_status_node.py @@ -0,0 +1,214 @@ +#!/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 subprocess +import time +from datetime import datetime +from typing import Optional, Dict, Any + +import rclpy +from rclpy.node import Node + + +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") + 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 + + # Create timer for polling + self.timer = self.create_timer(poll_interval, self._poll_vpn_status) + + self.get_logger().info("Starting VPN status monitoring...") + + def _on_mqtt_connect(self, client, userdata, flags, rc): + """MQTT connection callback.""" + if rc == 0: + self.get_logger().info(f"MQTT connected") + else: + self.get_logger().warn(f"MQTT connection failed: {rc}") + + def _on_mqtt_disconnect(self, client, userdata, rc): + """MQTT disconnection callback.""" + if rc != 0: + self.get_logger().warn(f"MQTT disconnected: {rc}") + + def _poll_vpn_status(self) -> None: + """Poll Tailscale status and report via MQTT.""" + try: + status = self._get_tailscale_status() + 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"VPN status check failed: {e}") + + def _get_tailscale_status(self) -> Dict[str, Any]: + """Get Tailscale status.""" + try: + result = subprocess.run( + ["sudo", "tailscale", "status", "--json"], + capture_output=True, + text=True, + timeout=10, + ) + + if result.returncode != 0: + return {"status": "offline"} + + data = json.loads(result.stdout) + status_data = {"status": "unknown", "ip": None, "hostname": None, "direct": False} + + 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") + + ips = self_info.get("TailscaleIPs", []) + if ips: + status_data["ip"] = ips[0] + + status_data["direct"] = bool(self_info.get("IsOnline")) + return status_data + + except subprocess.TimeoutExpired: + return {"status": "timeout"} + except Exception: + return {"status": "error"} + + def _publish_status(self, status: Dict[str, Any]) -> None: + """Publish status to MQTT.""" + if not self.mqtt_client: + return + + try: + vpn_status = status.get("status", "unknown") + + self.mqtt_client.publish( + f"{self.mqtt_topic_prefix}/status", + vpn_status, + qos=1, + retain=True, + ) + + if status.get("ip"): + self.mqtt_client.publish( + f"{self.mqtt_topic_prefix}/ip", + status["ip"], + qos=1, + retain=True, + ) + + if status.get("hostname"): + self.mqtt_client.publish( + f"{self.mqtt_topic_prefix}/hostname", + status["hostname"], + qos=1, + retain=True, + ) + + self.mqtt_client.publish( + f"{self.mqtt_topic_prefix}/direct", + "true" if status.get("direct") else "false", + qos=1, + retain=True, + ) + + 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: {vpn_status} IP: {status.get('ip', 'N/A')}") + + except Exception as e: + self.get_logger().error(f"MQTT publish failed: {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