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:
parent
80ee9ece87
commit
9a7737e7a9
340
jetson/docs/headscale-vpn-setup.md
Normal file
340
jetson/docs/headscale-vpn-setup.md
Normal file
@ -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 <username>@saltylab-orin.tail12345.ts.net
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the IP directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh <username>@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 <device-ip>
|
||||||
|
|
||||||
|
# SSH with verbose output:
|
||||||
|
ssh -vv <username>@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)
|
||||||
259
jetson/ros2_ws/src/saltybot_cellular/saltybot_cellular/vpn_status_node.py
Executable file
259
jetson/ros2_ws/src/saltybot_cellular/saltybot_cellular/vpn_status_node.py
Executable file
@ -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()
|
||||||
131
jetson/scripts/headscale-auth-helper.sh
Executable file
131
jetson/scripts/headscale-auth-helper.sh
Executable 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
|
||||||
@ -198,10 +198,18 @@ echo "=== Setup complete ==="
|
|||||||
echo "Please log out and back in for group membership to take effect."
|
echo "Please log out and back in for group membership to take effect."
|
||||||
echo ""
|
echo ""
|
||||||
echo "Next steps:"
|
echo "Next steps:"
|
||||||
echo " 1. cd jetson/"
|
echo " 1. Install Tailscale VPN for Headscale:"
|
||||||
echo " 2. docker compose build"
|
echo " sudo bash scripts/setup-tailscale.sh"
|
||||||
echo " 3. docker compose up -d"
|
echo " 2. Configure auth key:"
|
||||||
echo " 4. docker compose logs -f"
|
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 ""
|
||||||
echo "Monitor power: sudo jtop"
|
echo "Monitor power: sudo jtop"
|
||||||
echo "Check cameras: v4l2-ctl --list-devices"
|
echo "Check cameras: v4l2-ctl --list-devices"
|
||||||
|
echo "Check VPN status: sudo tailscale status"
|
||||||
|
|||||||
104
jetson/scripts/setup-tailscale.sh
Executable file
104
jetson/scripts/setup-tailscale.sh
Executable 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 ""
|
||||||
@ -22,12 +22,16 @@ rsync -a --exclude='.git' --exclude='__pycache__' \
|
|||||||
log "Installing systemd units..."
|
log "Installing systemd units..."
|
||||||
cp "${SCRIPT_DIR}/saltybot.target" "${SYSTEMD_DIR}/"
|
cp "${SCRIPT_DIR}/saltybot.target" "${SYSTEMD_DIR}/"
|
||||||
cp "${SCRIPT_DIR}/saltybot-social.service" "${SYSTEMD_DIR}/"
|
cp "${SCRIPT_DIR}/saltybot-social.service" "${SYSTEMD_DIR}/"
|
||||||
|
cp "${SCRIPT_DIR}/tailscale-vpn.service" "${SYSTEMD_DIR}/"
|
||||||
|
|
||||||
# Reload and enable
|
# Reload and enable
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable saltybot.target
|
systemctl enable saltybot.target
|
||||||
systemctl enable saltybot-social.service
|
systemctl enable saltybot-social.service
|
||||||
|
systemctl enable tailscale-vpn.service
|
||||||
|
|
||||||
log "Services installed. Start with:"
|
log "Services installed. Start with:"
|
||||||
log " systemctl start saltybot-social"
|
log " systemctl start saltybot-social"
|
||||||
|
log " systemctl start tailscale-vpn"
|
||||||
log " journalctl -fu saltybot-social"
|
log " journalctl -fu saltybot-social"
|
||||||
|
log " journalctl -fu tailscale-vpn"
|
||||||
|
|||||||
77
jetson/systemd/tailscale-vpn.service
Normal file
77
jetson/systemd/tailscale-vpn.service
Normal 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
|
||||||
Loading…
x
Reference in New Issue
Block a user