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:22:30 -05:00
parent 80ee9ece87
commit 9a7737e7a9
7 changed files with 927 additions and 4 deletions

View 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)

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

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