Files
gh-human-frontier-labs-inc-…/scripts/tailscale_manager.py
2025-11-29 18:47:40 +08:00

427 lines
10 KiB
Python

#!/usr/bin/env python3
"""
Tailscale manager for Tailscale SSH Sync Agent.
Tailscale-specific operations and status management.
"""
import subprocess
import re
import json
from typing import Dict, List, Optional
from dataclasses import dataclass
import logging
logger = logging.getLogger(__name__)
@dataclass
class TailscalePeer:
"""Represents a Tailscale peer."""
hostname: str
ip: str
online: bool
last_seen: Optional[str] = None
os: Optional[str] = None
relay: Optional[str] = None
def get_tailscale_status() -> Dict:
"""
Get Tailscale network status (all peers).
Returns:
Dict with network status:
{
'connected': bool,
'peers': List[TailscalePeer],
'online_count': int,
'total_count': int,
'self_ip': str
}
Example:
>>> status = get_tailscale_status()
>>> status['online_count']
8
>>> status['peers'][0].hostname
'homelab-1'
"""
try:
# Get status in JSON format
result = subprocess.run(
["tailscale", "status", "--json"],
capture_output=True,
text=True,
timeout=10
)
if result.returncode != 0:
# Try text format if JSON fails
result = subprocess.run(
["tailscale", "status"],
capture_output=True,
text=True,
timeout=10
)
if result.returncode != 0:
return {
'connected': False,
'error': 'Tailscale not running or accessible',
'peers': []
}
# Parse text format
return _parse_text_status(result.stdout)
# Parse JSON format
data = json.loads(result.stdout)
return _parse_json_status(data)
except FileNotFoundError:
return {
'connected': False,
'error': 'Tailscale not installed',
'peers': []
}
except subprocess.TimeoutExpired:
return {
'connected': False,
'error': 'Timeout getting Tailscale status',
'peers': []
}
except Exception as e:
logger.error(f"Error getting Tailscale status: {e}")
return {
'connected': False,
'error': str(e),
'peers': []
}
def _parse_json_status(data: Dict) -> Dict:
"""Parse Tailscale JSON status."""
peers = []
self_data = data.get('Self', {})
self_ip = self_data.get('TailscaleIPs', [''])[0]
for peer_id, peer_data in data.get('Peer', {}).items():
hostname = peer_data.get('HostName', 'unknown')
ips = peer_data.get('TailscaleIPs', [])
ip = ips[0] if ips else 'unknown'
online = peer_data.get('Online', False)
os = peer_data.get('OS', 'unknown')
peers.append(TailscalePeer(
hostname=hostname,
ip=ip,
online=online,
os=os
))
online_count = sum(1 for p in peers if p.online)
return {
'connected': True,
'peers': peers,
'online_count': online_count,
'total_count': len(peers),
'self_ip': self_ip
}
def _parse_text_status(output: str) -> Dict:
"""Parse Tailscale text status output."""
peers = []
self_ip = None
for line in output.strip().split('\n'):
line = line.strip()
if not line:
continue
# Parse format: hostname ip status ...
parts = line.split()
if len(parts) >= 2:
hostname = parts[0]
ip = parts[1] if len(parts) > 1 else 'unknown'
# Check for self (usually marked with *)
if hostname.endswith('-'):
self_ip = ip
continue
# Determine online status from additional fields
online = 'offline' not in line.lower()
peers.append(TailscalePeer(
hostname=hostname,
ip=ip,
online=online
))
online_count = sum(1 for p in peers if p.online)
return {
'connected': True,
'peers': peers,
'online_count': online_count,
'total_count': len(peers),
'self_ip': self_ip or 'unknown'
}
def check_connectivity(host: str, timeout: int = 5) -> bool:
"""
Ping host via Tailscale.
Args:
host: Hostname to ping
timeout: Timeout in seconds
Returns:
True if host responds to ping
Example:
>>> check_connectivity("homelab-1")
True
"""
try:
result = subprocess.run(
["tailscale", "ping", "--timeout", f"{timeout}s", "--c", "1", host],
capture_output=True,
text=True,
timeout=timeout + 2
)
# Check if ping succeeded
return result.returncode == 0 or 'pong' in result.stdout.lower()
except (FileNotFoundError, subprocess.TimeoutExpired):
return False
except Exception as e:
logger.error(f"Error pinging {host}: {e}")
return False
def get_peer_info(hostname: str) -> Optional[TailscalePeer]:
"""
Get detailed info about a specific peer.
Args:
hostname: Peer hostname
Returns:
TailscalePeer object or None if not found
Example:
>>> peer = get_peer_info("homelab-1")
>>> peer.ip
'100.64.1.10'
"""
status = get_tailscale_status()
if not status.get('connected'):
return None
for peer in status.get('peers', []):
if peer.hostname == hostname or hostname in peer.hostname:
return peer
return None
def list_online_machines() -> List[str]:
"""
List all online Tailscale machines.
Returns:
List of online machine hostnames
Example:
>>> machines = list_online_machines()
>>> len(machines)
8
"""
status = get_tailscale_status()
if not status.get('connected'):
return []
return [
peer.hostname
for peer in status.get('peers', [])
if peer.online
]
def get_machine_ip(hostname: str) -> Optional[str]:
"""
Get Tailscale IP for a machine.
Args:
hostname: Machine hostname
Returns:
IP address or None if not found
Example:
>>> ip = get_machine_ip("homelab-1")
>>> ip
'100.64.1.10'
"""
peer = get_peer_info(hostname)
return peer.ip if peer else None
def validate_tailscale_ssh(host: str, timeout: int = 10) -> Dict:
"""
Check if Tailscale SSH is working for a host.
Args:
host: Host to check
timeout: Connection timeout
Returns:
Dict with validation results:
{
'working': bool,
'message': str,
'details': Dict
}
Example:
>>> result = validate_tailscale_ssh("homelab-1")
>>> result['working']
True
"""
# First check if host is in Tailscale network
peer = get_peer_info(host)
if not peer:
return {
'working': False,
'message': f'Host {host} not found in Tailscale network',
'details': {'peer_found': False}
}
if not peer.online:
return {
'working': False,
'message': f'Host {host} is offline in Tailscale',
'details': {'peer_found': True, 'online': False}
}
# Check connectivity
if not check_connectivity(host, timeout=timeout):
return {
'working': False,
'message': f'Cannot ping {host} via Tailscale',
'details': {'peer_found': True, 'online': True, 'ping': False}
}
# Try SSH connection
try:
result = subprocess.run(
["tailscale", "ssh", host, "echo", "test"],
capture_output=True,
text=True,
timeout=timeout
)
if result.returncode == 0:
return {
'working': True,
'message': f'Tailscale SSH to {host} is working',
'details': {
'peer_found': True,
'online': True,
'ping': True,
'ssh': True,
'ip': peer.ip
}
}
else:
return {
'working': False,
'message': f'Tailscale SSH failed: {result.stderr}',
'details': {
'peer_found': True,
'online': True,
'ping': True,
'ssh': False,
'error': result.stderr
}
}
except subprocess.TimeoutExpired:
return {
'working': False,
'message': f'Tailscale SSH timed out after {timeout}s',
'details': {'timeout': True}
}
except Exception as e:
return {
'working': False,
'message': f'Error testing Tailscale SSH: {e}',
'details': {'error': str(e)}
}
def get_network_summary() -> str:
"""
Get human-readable network summary.
Returns:
Formatted summary string
Example:
>>> print(get_network_summary())
Tailscale Network: Connected
Online: 8/10 machines (80%)
Self IP: 100.64.1.5
"""
status = get_tailscale_status()
if not status.get('connected'):
return "Tailscale Network: Not connected\nError: {}".format(
status.get('error', 'Unknown error')
)
lines = [
"Tailscale Network: Connected",
f"Online: {status['online_count']}/{status['total_count']} machines ({status['online_count']/status['total_count']*100:.0f}%)",
f"Self IP: {status.get('self_ip', 'unknown')}"
]
return "\n".join(lines)
def main():
"""Test Tailscale manager functions."""
print("Testing Tailscale manager...\n")
print("1. Get Tailscale status:")
status = get_tailscale_status()
if status.get('connected'):
print(f" ✓ Connected")
print(f" Peers: {status['total_count']} total, {status['online_count']} online")
else:
print(f" ✗ Not connected: {status.get('error', 'Unknown error')}")
print("\n2. List online machines:")
machines = list_online_machines()
print(f" Found {len(machines)} online machines")
for machine in machines[:5]: # Show first 5
print(f" - {machine}")
print("\n3. Network summary:")
print(get_network_summary())
print("\n✅ Tailscale manager tested")
if __name__ == "__main__":
main()