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 <noreply@anthropic.com>
This commit is contained in:
sl-jetson 2026-03-06 10:25:04 -05:00
parent 767f377120
commit 062c05cac0
7 changed files with 696 additions and 4 deletions

View File

@ -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 <username>@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)

View File

@ -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()

View File

@ -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 <command>
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

View File

@ -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"

104
jetson/scripts/setup-tailscale.sh Executable file
View File

@ -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 ""

View File

@ -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"

View File

@ -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