Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:47:40 +08:00
commit 14c678ceac
22 changed files with 7501 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
"""
Validators package for Tailscale SSH Sync Agent.
"""
from .parameter_validator import (
ValidationError,
validate_host,
validate_group,
validate_path_exists,
validate_timeout,
validate_command
)
from .host_validator import (
validate_ssh_config,
validate_host_reachable,
validate_group_members,
get_invalid_hosts
)
from .connection_validator import (
validate_ssh_connection,
validate_tailscale_connection,
validate_ssh_key,
get_connection_diagnostics
)
__all__ = [
'ValidationError',
'validate_host',
'validate_group',
'validate_path_exists',
'validate_timeout',
'validate_command',
'validate_ssh_config',
'validate_host_reachable',
'validate_group_members',
'get_invalid_hosts',
'validate_ssh_connection',
'validate_tailscale_connection',
'validate_ssh_key',
'get_connection_diagnostics',
]

View File

@@ -0,0 +1,275 @@
#!/usr/bin/env python3
"""
Connection validators for Tailscale SSH Sync Agent.
Validates SSH and Tailscale connections.
"""
import subprocess
from typing import Dict, Optional
import logging
from .parameter_validator import ValidationError
logger = logging.getLogger(__name__)
def validate_ssh_connection(host: str, timeout: int = 10) -> bool:
"""
Test SSH connection works.
Args:
host: Host to connect to
timeout: Connection timeout in seconds
Returns:
True if SSH connection successful
Raises:
ValidationError: If connection fails
Example:
>>> validate_ssh_connection("web-01")
True
"""
try:
# Try to execute a simple command via SSH
result = subprocess.run(
["ssh", "-o", "ConnectTimeout={}".format(timeout),
"-o", "BatchMode=yes",
"-o", "StrictHostKeyChecking=no",
host, "echo", "test"],
capture_output=True,
text=True,
timeout=timeout + 5
)
if result.returncode == 0:
return True
else:
# Parse error message
error_msg = result.stderr.strip()
if "Permission denied" in error_msg:
raise ValidationError(
f"SSH authentication failed for '{host}'\n"
"Check:\n"
"1. SSH key is added: ssh-add -l\n"
"2. Public key is on remote: cat ~/.ssh/authorized_keys\n"
"3. User/key in SSH config is correct"
)
elif "Connection refused" in error_msg:
raise ValidationError(
f"SSH connection refused for '{host}'\n"
"Check:\n"
"1. SSH server is running on remote\n"
"2. Port 22 is not blocked by firewall"
)
elif "Connection timed out" in error_msg or "timeout" in error_msg.lower():
raise ValidationError(
f"SSH connection timed out for '{host}'\n"
"Check:\n"
"1. Host is reachable (ping test)\n"
"2. Tailscale is connected\n"
"3. Network connectivity"
)
else:
raise ValidationError(
f"SSH connection failed for '{host}': {error_msg}"
)
except subprocess.TimeoutExpired:
raise ValidationError(
f"SSH connection timed out for '{host}' (>{timeout}s)"
)
except Exception as e:
raise ValidationError(f"Error testing SSH connection to '{host}': {e}")
def validate_tailscale_connection(host: str) -> bool:
"""
Test Tailscale connectivity to host.
Args:
host: Host to check
Returns:
True if Tailscale connection active
Raises:
ValidationError: If Tailscale not connected
Example:
>>> validate_tailscale_connection("web-01")
True
"""
try:
# Check if tailscale is running
result = subprocess.run(
["tailscale", "status"],
capture_output=True,
text=True,
timeout=5
)
if result.returncode != 0:
raise ValidationError(
"Tailscale is not running\n"
"Start Tailscale: sudo tailscale up"
)
# Check if specific host is in the network
if host in result.stdout or host.replace('-', '.') in result.stdout:
return True
else:
raise ValidationError(
f"Host '{host}' not found in Tailscale network\n"
"Ensure host is:\n"
"1. Connected to Tailscale\n"
"2. In the same tailnet\n"
"3. Not expired/offline"
)
except FileNotFoundError:
raise ValidationError(
"Tailscale not installed\n"
"Install: https://tailscale.com/download"
)
except subprocess.TimeoutExpired:
raise ValidationError("Timeout checking Tailscale status")
except Exception as e:
raise ValidationError(f"Error checking Tailscale connection: {e}")
def validate_ssh_key(host: str) -> bool:
"""
Check SSH key authentication is working.
Args:
host: Host to check
Returns:
True if SSH key auth works
Raises:
ValidationError: If key auth fails
Example:
>>> validate_ssh_key("web-01")
True
"""
try:
# Test connection with explicit key-only auth
result = subprocess.run(
["ssh", "-o", "BatchMode=yes",
"-o", "PasswordAuthentication=no",
"-o", "ConnectTimeout=5",
host, "echo", "test"],
capture_output=True,
text=True,
timeout=10
)
if result.returncode == 0:
return True
else:
error_msg = result.stderr.strip()
if "Permission denied" in error_msg:
raise ValidationError(
f"SSH key authentication failed for '{host}'\n"
"Fix:\n"
"1. Add your SSH key: ssh-add ~/.ssh/id_ed25519\n"
"2. Copy public key to remote: ssh-copy-id {}\n"
"3. Verify: ssh -v {} 2>&1 | grep -i 'offering public key'".format(host, host)
)
else:
raise ValidationError(
f"SSH key validation failed for '{host}': {error_msg}"
)
except subprocess.TimeoutExpired:
raise ValidationError(f"Timeout validating SSH key for '{host}'")
except Exception as e:
raise ValidationError(f"Error validating SSH key for '{host}': {e}")
def get_connection_diagnostics(host: str) -> Dict[str, any]:
"""
Comprehensive connection testing.
Args:
host: Host to diagnose
Returns:
Dict with diagnostic results:
{
'ping': {'success': bool, 'message': str},
'ssh': {'success': bool, 'message': str},
'tailscale': {'success': bool, 'message': str},
'ssh_key': {'success': bool, 'message': str}
}
Example:
>>> diag = get_connection_diagnostics("web-01")
>>> diag['ssh']['success']
True
"""
diagnostics = {}
# Test 1: Ping
try:
result = subprocess.run(
["ping", "-c", "1", "-W", "2", host],
capture_output=True,
timeout=3
)
diagnostics['ping'] = {
'success': result.returncode == 0,
'message': 'Host is reachable' if result.returncode == 0 else 'Host not reachable'
}
except Exception as e:
diagnostics['ping'] = {'success': False, 'message': str(e)}
# Test 2: SSH connection
try:
validate_ssh_connection(host, timeout=5)
diagnostics['ssh'] = {'success': True, 'message': 'SSH connection works'}
except ValidationError as e:
diagnostics['ssh'] = {'success': False, 'message': str(e).split('\n')[0]}
# Test 3: Tailscale
try:
validate_tailscale_connection(host)
diagnostics['tailscale'] = {'success': True, 'message': 'Tailscale connected'}
except ValidationError as e:
diagnostics['tailscale'] = {'success': False, 'message': str(e).split('\n')[0]}
# Test 4: SSH key
try:
validate_ssh_key(host)
diagnostics['ssh_key'] = {'success': True, 'message': 'SSH key authentication works'}
except ValidationError as e:
diagnostics['ssh_key'] = {'success': False, 'message': str(e).split('\n')[0]}
return diagnostics
def main():
"""Test connection validators."""
print("Testing connection validators...\n")
print("1. Testing connection diagnostics:")
try:
diag = get_connection_diagnostics("localhost")
print(" Results:")
for test, result in diag.items():
status = "" if result['success'] else ""
print(f" {status} {test}: {result['message']}")
except Exception as e:
print(f" Error: {e}")
print("\n✅ Connection validators tested")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,232 @@
#!/usr/bin/env python3
"""
Host validators for Tailscale SSH Sync Agent.
Validates host configuration and availability.
"""
import subprocess
from typing import List, Dict, Optional
from pathlib import Path
import logging
from .parameter_validator import ValidationError
logger = logging.getLogger(__name__)
def validate_ssh_config(host: str, config_path: Optional[Path] = None) -> bool:
"""
Check if host has SSH config entry.
Args:
host: Host name to check
config_path: Path to SSH config (default: ~/.ssh/config)
Returns:
True if host is in SSH config
Raises:
ValidationError: If host not found in config
Example:
>>> validate_ssh_config("web-01")
True
"""
if config_path is None:
config_path = Path.home() / '.ssh' / 'config'
if not config_path.exists():
raise ValidationError(
f"SSH config file not found: {config_path}\n"
"Create ~/.ssh/config with your host definitions"
)
# Parse SSH config for this host
host_found = False
try:
with open(config_path, 'r') as f:
for line in f:
line = line.strip()
if line.lower().startswith('host ') and host in line:
host_found = True
break
if not host_found:
raise ValidationError(
f"Host '{host}' not found in SSH config: {config_path}\n"
"Add host to SSH config:\n"
f"Host {host}\n"
f" HostName <IP_ADDRESS>\n"
f" User <USERNAME>"
)
return True
except IOError as e:
raise ValidationError(f"Error reading SSH config: {e}")
def validate_host_reachable(host: str, timeout: int = 5) -> bool:
"""
Check if host is reachable via ping.
Args:
host: Host name to check
timeout: Timeout in seconds
Returns:
True if host is reachable
Raises:
ValidationError: If host is not reachable
Example:
>>> validate_host_reachable("web-01", timeout=5)
True
"""
try:
# Try to resolve via SSH config first
result = subprocess.run(
["ssh", "-G", host],
capture_output=True,
text=True,
timeout=2
)
if result.returncode == 0:
# Extract hostname from SSH config
for line in result.stdout.split('\n'):
if line.startswith('hostname '):
actual_host = line.split()[1]
break
else:
actual_host = host
else:
actual_host = host
# Ping the host
ping_result = subprocess.run(
["ping", "-c", "1", "-W", str(timeout), actual_host],
capture_output=True,
text=True,
timeout=timeout + 1
)
if ping_result.returncode == 0:
return True
else:
raise ValidationError(
f"Host '{host}' ({actual_host}) is not reachable\n"
"Check:\n"
"1. Host is powered on\n"
"2. Tailscale is connected\n"
"3. Network connectivity"
)
except subprocess.TimeoutExpired:
raise ValidationError(f"Timeout checking host '{host}' (>{timeout}s)")
except Exception as e:
raise ValidationError(f"Error checking host '{host}': {e}")
def validate_group_members(group: str, groups_config: Dict[str, List[str]]) -> List[str]:
"""
Ensure group has valid members.
Args:
group: Group name
groups_config: Groups configuration dict
Returns:
List of valid hosts in group
Raises:
ValidationError: If group is empty or has no valid members
Example:
>>> groups = {'production': ['web-01', 'db-01']}
>>> validate_group_members('production', groups)
['web-01', 'db-01']
"""
if group not in groups_config:
raise ValidationError(
f"Group '{group}' not found in configuration\n"
f"Available groups: {', '.join(groups_config.keys())}"
)
members = groups_config[group]
if not members:
raise ValidationError(
f"Group '{group}' has no members\n"
f"Add hosts to group with: sshsync gadd {group}"
)
if not isinstance(members, list):
raise ValidationError(
f"Invalid group configuration for '{group}': members must be a list"
)
return members
def get_invalid_hosts(hosts: List[str], config_path: Optional[Path] = None) -> List[str]:
"""
Find hosts without valid SSH config.
Args:
hosts: List of host names
config_path: Path to SSH config
Returns:
List of hosts without valid config
Example:
>>> get_invalid_hosts(["web-01", "nonexistent"])
["nonexistent"]
"""
if config_path is None:
config_path = Path.home() / '.ssh' / 'config'
if not config_path.exists():
return hosts # All invalid if no config
# Parse SSH config
valid_hosts = set()
try:
with open(config_path, 'r') as f:
for line in f:
line = line.strip()
if line.lower().startswith('host '):
host_alias = line.split(maxsplit=1)[1]
if '*' not in host_alias and '?' not in host_alias:
valid_hosts.add(host_alias)
except IOError:
return hosts
# Find invalid hosts
return [h for h in hosts if h not in valid_hosts]
def main():
"""Test host validators."""
print("Testing host validators...\n")
print("1. Testing validate_ssh_config():")
try:
validate_ssh_config("localhost")
print(" ✓ localhost has SSH config")
except ValidationError as e:
print(f" Note: {e.args[0].split(chr(10))[0]}")
print("\n2. Testing get_invalid_hosts():")
test_hosts = ["localhost", "nonexistent-host-12345"]
invalid = get_invalid_hosts(test_hosts)
print(f" Invalid hosts: {invalid}")
print("\n✅ Host validators tested")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,363 @@
#!/usr/bin/env python3
"""
Parameter validators for Tailscale SSH Sync Agent.
Validates user inputs before making operations.
"""
from typing import List, Optional
from pathlib import Path
import re
import logging
logger = logging.getLogger(__name__)
class ValidationError(Exception):
"""Raised when validation fails."""
pass
def validate_host(host: str, valid_hosts: Optional[List[str]] = None) -> str:
"""
Validate host parameter.
Args:
host: Host name or alias
valid_hosts: List of valid hosts (None to skip check)
Returns:
str: Validated and normalized host name
Raises:
ValidationError: If host is invalid
Example:
>>> validate_host("web-01")
"web-01"
>>> validate_host("web-01", ["web-01", "web-02"])
"web-01"
"""
if not host:
raise ValidationError("Host cannot be empty")
if not isinstance(host, str):
raise ValidationError(f"Host must be string, got {type(host)}")
# Normalize (strip whitespace, lowercase for comparison)
host = host.strip()
# Basic validation: alphanumeric, dash, underscore, dot
if not re.match(r'^[a-zA-Z0-9._-]+$', host):
raise ValidationError(
f"Invalid host name format: {host}\n"
"Host names must contain only letters, numbers, dots, dashes, and underscores"
)
# Check if valid (if list provided)
if valid_hosts:
# Try exact match first
if host in valid_hosts:
return host
# Try case-insensitive match
for valid_host in valid_hosts:
if host.lower() == valid_host.lower():
return valid_host
# Not found - provide suggestions
suggestions = [h for h in valid_hosts if host[:3].lower() in h.lower()]
raise ValidationError(
f"Invalid host: {host}\n"
f"Valid options: {', '.join(valid_hosts[:10])}\n"
+ (f"Did you mean: {', '.join(suggestions[:3])}?" if suggestions else "")
)
return host
def validate_group(group: str, valid_groups: Optional[List[str]] = None) -> str:
"""
Validate group parameter.
Args:
group: Group name
valid_groups: List of valid groups (None to skip check)
Returns:
str: Validated group name
Raises:
ValidationError: If group is invalid
Example:
>>> validate_group("production")
"production"
>>> validate_group("prod", ["production", "development"])
ValidationError: Invalid group: prod
"""
if not group:
raise ValidationError("Group cannot be empty")
if not isinstance(group, str):
raise ValidationError(f"Group must be string, got {type(group)}")
# Normalize
group = group.strip().lower()
# Basic validation
if not re.match(r'^[a-z0-9_-]+$', group):
raise ValidationError(
f"Invalid group name format: {group}\n"
"Group names must contain only lowercase letters, numbers, dashes, and underscores"
)
# Check if valid (if list provided)
if valid_groups:
if group not in valid_groups:
suggestions = [g for g in valid_groups if group[:3] in g]
raise ValidationError(
f"Invalid group: {group}\n"
f"Valid groups: {', '.join(valid_groups)}\n"
+ (f"Did you mean: {', '.join(suggestions[:3])}?" if suggestions else "")
)
return group
def validate_path_exists(path: str, must_be_file: bool = False,
must_be_dir: bool = False) -> Path:
"""
Validate path exists and is accessible.
Args:
path: Path to validate
must_be_file: If True, path must be a file
must_be_dir: If True, path must be a directory
Returns:
Path: Validated Path object
Raises:
ValidationError: If path is invalid
Example:
>>> validate_path_exists("/tmp", must_be_dir=True)
Path('/tmp')
>>> validate_path_exists("/nonexistent")
ValidationError: Path does not exist: /nonexistent
"""
if not path:
raise ValidationError("Path cannot be empty")
p = Path(path).expanduser().resolve()
if not p.exists():
raise ValidationError(
f"Path does not exist: {path}\n"
f"Resolved to: {p}"
)
if must_be_file and not p.is_file():
raise ValidationError(f"Path must be a file: {path}")
if must_be_dir and not p.is_dir():
raise ValidationError(f"Path must be a directory: {path}")
return p
def validate_timeout(timeout: int, min_timeout: int = 1,
max_timeout: int = 600) -> int:
"""
Validate timeout parameter.
Args:
timeout: Timeout in seconds
min_timeout: Minimum allowed timeout
max_timeout: Maximum allowed timeout
Returns:
int: Validated timeout
Raises:
ValidationError: If timeout is invalid
Example:
>>> validate_timeout(10)
10
>>> validate_timeout(0)
ValidationError: Timeout must be between 1 and 600 seconds
"""
if not isinstance(timeout, int):
raise ValidationError(f"Timeout must be integer, got {type(timeout)}")
if timeout < min_timeout:
raise ValidationError(
f"Timeout too low: {timeout}s (minimum: {min_timeout}s)"
)
if timeout > max_timeout:
raise ValidationError(
f"Timeout too high: {timeout}s (maximum: {max_timeout}s)"
)
return timeout
def validate_command(command: str, allow_dangerous: bool = False) -> str:
"""
Basic command safety validation.
Args:
command: Command to validate
allow_dangerous: If False, block potentially dangerous commands
Returns:
str: Validated command
Raises:
ValidationError: If command is invalid or dangerous
Example:
>>> validate_command("ls -la")
"ls -la"
>>> validate_command("rm -rf /", allow_dangerous=False)
ValidationError: Potentially dangerous command blocked: rm -rf
"""
if not command:
raise ValidationError("Command cannot be empty")
if not isinstance(command, str):
raise ValidationError(f"Command must be string, got {type(command)}")
command = command.strip()
if not allow_dangerous:
# Check for dangerous patterns
dangerous_patterns = [
(r'\brm\s+-rf\s+/', "rm -rf on root directory"),
(r'\bmkfs\.', "filesystem formatting"),
(r'\bdd\s+.*of=/dev/', "disk writing with dd"),
(r':(){:|:&};:', "fork bomb"),
(r'>\s*/dev/sd[a-z]', "direct disk writing"),
]
for pattern, description in dangerous_patterns:
if re.search(pattern, command, re.IGNORECASE):
raise ValidationError(
f"Potentially dangerous command blocked: {description}\n"
f"Command: {command}\n"
"Use allow_dangerous=True if you really want to execute this"
)
return command
def validate_hosts_list(hosts: List[str], valid_hosts: Optional[List[str]] = None) -> List[str]:
"""
Validate a list of hosts.
Args:
hosts: List of host names
valid_hosts: List of valid hosts (None to skip check)
Returns:
List[str]: Validated host names
Raises:
ValidationError: If any host is invalid
Example:
>>> validate_hosts_list(["web-01", "web-02"])
["web-01", "web-02"]
"""
if not hosts:
raise ValidationError("Hosts list cannot be empty")
if not isinstance(hosts, list):
raise ValidationError(f"Hosts must be list, got {type(hosts)}")
validated = []
errors = []
for host in hosts:
try:
validated.append(validate_host(host, valid_hosts))
except ValidationError as e:
errors.append(str(e))
if errors:
raise ValidationError(
f"Invalid hosts in list:\n" + "\n".join(errors)
)
return validated
def main():
"""Test validators."""
print("Testing parameter validators...\n")
# Test host validation
print("1. Testing validate_host():")
try:
host = validate_host("web-01", ["web-01", "web-02", "db-01"])
print(f" ✓ Valid host: {host}")
except ValidationError as e:
print(f" ✗ Error: {e}")
try:
host = validate_host("invalid-host", ["web-01", "web-02"])
print(f" ✗ Should have failed!")
except ValidationError as e:
print(f" ✓ Correctly rejected: {e.args[0].split(chr(10))[0]}")
# Test group validation
print("\n2. Testing validate_group():")
try:
group = validate_group("production", ["production", "development"])
print(f" ✓ Valid group: {group}")
except ValidationError as e:
print(f" ✗ Error: {e}")
# Test path validation
print("\n3. Testing validate_path_exists():")
try:
path = validate_path_exists("/tmp", must_be_dir=True)
print(f" ✓ Valid path: {path}")
except ValidationError as e:
print(f" ✗ Error: {e}")
# Test timeout validation
print("\n4. Testing validate_timeout():")
try:
timeout = validate_timeout(10)
print(f" ✓ Valid timeout: {timeout}s")
except ValidationError as e:
print(f" ✗ Error: {e}")
try:
timeout = validate_timeout(0)
print(f" ✗ Should have failed!")
except ValidationError as e:
print(f" ✓ Correctly rejected: {e.args[0].split(chr(10))[0]}")
# Test command validation
print("\n5. Testing validate_command():")
try:
cmd = validate_command("ls -la")
print(f" ✓ Safe command: {cmd}")
except ValidationError as e:
print(f" ✗ Error: {e}")
try:
cmd = validate_command("rm -rf /", allow_dangerous=False)
print(f" ✗ Should have failed!")
except ValidationError as e:
print(f" ✓ Correctly blocked: {e.args[0].split(chr(10))[0]}")
print("\n✅ All parameter validators tested")
if __name__ == "__main__":
main()