Files
radiantwave-legacy/packaging/radiantwave-updater.py
T
NeilClack 312f5e2a90
build-prod.yml / build (push) Failing after 2s
build-dev.yml / build (push) Failing after 2s
Rename rhemawave to radiantwave throughout
2026-08-08 01:20:11 -05:00

565 lines
17 KiB
Python
Executable File

#!/usr/bin/env python3
"""
radiantwave-updater.py
Auto-updater for RadiantWave kiosk application.
Uses apt to check for and install updates from the configured repository.
USAGE:
Run as kiosk user:
python3 /usr/local/bin/radiantwave-updater.py
"""
import sys
import subprocess
import json
import socket
import urllib.request
from pathlib import Path
from datetime import datetime
# Configuration - templated by build script
PACKAGE_NAME = "__CHANNEL__"
HEADSCALE_URL = "https://headscale.radiantwavetech.com"
HEADSCALE_AUTHKEY = "803130d5b2e38bffa9fa5b6921a3d6403100e2e30fea1bd5"
# Paths
LOG_FILE = Path("/home/kiosk/radiantwave-updater.log")
LICENSE_KEY_PATH = Path.home() / ".local/share/radiantwave/license_key"
class UpdaterLogger:
"""Simple logger that writes to both stdout and file"""
def __init__(self, log_file):
self.log_file = log_file
log_file.parent.mkdir(parents=True, exist_ok=True)
log_file.touch(exist_ok=True)
def log(self, message):
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
formatted = f"[{timestamp}] {message}"
print(formatted)
try:
with open(self.log_file, 'a') as f:
f.write(formatted + '\n')
except IOError:
pass
def error(self, message):
self.log(f"ERROR: {message}")
def debug(self, message):
self.log(f"DEBUG: {message}")
def success(self, message):
self.log(f"✓ {message}")
logger = UpdaterLogger(LOG_FILE)
def check_network(timeout=2):
"""Quick check for internet connectivity."""
logger.log("Checking network connectivity...")
try:
socket.create_connection(("1.1.1.1", 53), timeout=timeout)
logger.success("Network check passed (1.1.1.1:53 reachable)")
return True
except OSError as e:
logger.error(f"Network check failed: {e}")
return False
def run_command(cmd, description, use_pkexec=False):
"""Run a command and return success status"""
if use_pkexec:
cmd = ["pkexec"] + cmd
logger.log(f"{description}...")
logger.debug(f"Command: {' '.join(cmd)}")
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=300
)
logger.debug(f"Return code: {result.returncode}")
if result.stdout:
for line in result.stdout.splitlines():
logger.log(f" {line}")
if result.returncode != 0:
logger.error(f"{description} failed with return code {result.returncode}")
if result.stderr:
for line in result.stderr.splitlines():
logger.error(f" {line}")
return False
logger.success(f"{description} completed successfully")
return True
except subprocess.TimeoutExpired:
logger.error(f"{description} timed out")
return False
except Exception as e:
logger.error(f"{description} failed: {e}")
return False
def configure_dpkg():
logger.log("Checking for dpkg errors")
if not run_command(
["dpkg", "--configure", "-a"],
'Check for dpkg configuration errors and resolve them before performing updates',
use_pkexec=True):
logger.error("Unable to reconfigure system, please reach out to support!")
return
def is_tailscale_installed():
"""Check if Tailscale is installed."""
logger.log("Checking if Tailscale is installed...")
try:
result = subprocess.run(
["tailscale", "version"],
capture_output=True,
text=True,
check=True
)
version = result.stdout.strip()
logger.success(f"Tailscale is installed: {version}")
return True
except FileNotFoundError:
logger.log("Tailscale binary not found")
return False
except subprocess.CalledProcessError as e:
logger.error(f"Tailscale version check failed: {e}")
return False
def install_tailscale():
"""Install Tailscale."""
logger.log("Installing Tailscale...")
try:
logger.debug("Running: pkexec sh -c 'curl -fsSL https://tailscale.com/install.sh | sh'")
result = subprocess.run(
["pkexec", "sh", "-c", "curl -fsSL https://tailscale.com/install.sh | sh"],
capture_output=True,
text=True,
timeout=120
)
logger.debug(f"Install return code: {result.returncode}")
if result.stdout:
for line in result.stdout.splitlines():
logger.log(f" {line}")
if result.stderr:
for line in result.stderr.splitlines():
logger.log(f" [stderr] {line}")
if result.returncode != 0:
logger.error("Tailscale installation failed")
return False
logger.success("Tailscale installed successfully")
return True
except subprocess.TimeoutExpired:
logger.error("Tailscale installation timed out")
return False
except subprocess.CalledProcessError as e:
logger.error(f"Tailscale installation failed: {e}")
if e.stderr:
logger.error(f"stderr: {e.stderr}")
return False
def set_tailscale_operator():
"""Set Tailscale operator to kiosk."""
logger.log("Setting Tailscale operator to 'kiosk'...")
try:
result = subprocess.run(
["pkexec", "tailscale", "set", "--operator=kiosk"],
capture_output=True,
text=True,
timeout=30
)
logger.debug(f"Return code: {result.returncode}")
if result.stdout:
logger.debug(f"stdout: {result.stdout}")
if result.stderr:
logger.debug(f"stderr: {result.stderr}")
if result.returncode != 0:
logger.error(f"Failed to set operator: {result.stderr}")
return False
logger.success("Tailscale operator set to 'kiosk'")
return True
except Exception as e:
logger.error(f"Failed to set Tailscale operator: {e}")
return False
def get_tailscale_status():
"""Get full Tailscale status as dict."""
logger.log("Getting Tailscale status...")
try:
result = subprocess.run(
["tailscale", "status", "--json"],
capture_output=True,
text=True,
timeout=10
)
logger.debug(f"Return code: {result.returncode}")
if result.returncode != 0:
logger.error(f"tailscale status failed: {result.stderr}")
return None
status = json.loads(result.stdout)
# Log key status info
backend_state = status.get("BackendState", "unknown")
logger.log(f" BackendState: {backend_state}")
self_info = status.get("Self", {})
if self_info:
logger.log(f" HostName: {self_info.get('HostName', 'unknown')}")
logger.log(f" DNSName: {self_info.get('DNSName', 'unknown')}")
logger.log(f" Online: {self_info.get('Online', 'unknown')}")
tailscale_ips = self_info.get("TailscaleIPs", [])
logger.log(f" TailscaleIPs: {tailscale_ips}")
# Check for health issues
health = status.get("Health", [])
if health:
logger.log(" Health issues:")
for issue in health:
logger.log(f" - {issue}")
return status
except json.JSONDecodeError as e:
logger.error(f"Failed to parse Tailscale status JSON: {e}")
return None
except subprocess.TimeoutExpired:
logger.error("Tailscale status timed out")
return None
except Exception as e:
logger.error(f"Failed to get Tailscale status: {e}")
return None
def is_tailscale_logged_in(status=None):
"""Check if Tailscale is logged in."""
if status is None:
status = get_tailscale_status()
if status is None:
logger.error("Cannot determine login status - no status available")
return False
backend_state = status.get("BackendState", "")
logged_in = backend_state == "Running"
if logged_in:
logger.success("Tailscale is logged in (BackendState=Running)")
else:
logger.log(f"Tailscale is NOT logged in (BackendState={backend_state})")
return logged_in
def tailscale_login(hostname=None):
"""Log in to Tailscale/Headscale."""
logger.log("Logging in to Headscale...")
logger.debug(f" Server: {HEADSCALE_URL}")
logger.debug(f" AuthKey: {HEADSCALE_AUTHKEY[:8]}...{HEADSCALE_AUTHKEY[-4:]}")
if hostname:
logger.debug(f" Hostname: {hostname}")
cmd = [
"tailscale", "up",
"--login-server", HEADSCALE_URL,
"--authkey", HEADSCALE_AUTHKEY,
"--force-reauth"
]
if hostname:
cmd.extend(["--hostname", hostname])
logger.debug(f"Command: {' '.join(cmd)}")
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=30
)
logger.debug(f"Return code: {result.returncode}")
if result.stdout:
logger.log(f" stdout: {result.stdout}")
if result.stderr:
logger.log(f" stderr: {result.stderr}")
if result.returncode != 0:
logger.error(f"Tailscale login failed with code {result.returncode}")
return False
logger.success("Tailscale login successful")
return True
except subprocess.TimeoutExpired:
logger.error("Tailscale login timed out")
return False
except Exception as e:
logger.error(f"Tailscale login failed: {e}")
return False
def get_current_tailscale_hostname(status=None):
"""Get the hostname we're currently advertising."""
if status is None:
status = get_tailscale_status()
if status is None:
return None
hostname = status.get("Self", {}).get("HostName")
logger.debug(f"Current Tailscale hostname: {hostname}")
return hostname
def get_license_key():
"""Read license key from plain text file."""
logger.log("Fetching license key...")
logger.debug(f" Path: {LICENSE_KEY_PATH}")
if not LICENSE_KEY_PATH.exists():
logger.log(" License key file does not exist")
return None
try:
license_key = LICENSE_KEY_PATH.read_text().strip()
if not license_key:
logger.log(" License key file is empty")
return None
logger.success(f"License key found: {license_key}")
return license_key
except Exception as e:
logger.error(f"Failed to read license key: {e}")
return None
def ensure_tailscale_installed():
"""Ensure Tailscale is installed, install if needed."""
logger.log("=== Ensuring Tailscale is installed ===")
if is_tailscale_installed():
return True
logger.log("Tailscale not installed, attempting installation...")
if not install_tailscale():
return False
# Verify installation
if not is_tailscale_installed():
logger.error("Tailscale installation verification failed")
return False
# Set operator
if not set_tailscale_operator():
logger.error("Failed to set Tailscale operator, continuing anyway...")
return True
def ensure_tailscale_connected():
"""Ensure Tailscale is logged in and connected."""
logger.log("=== Ensuring Tailscale is connected ===")
status = get_tailscale_status()
if not is_tailscale_logged_in(status):
logger.log("Tailscale not logged in, attempting login...")
# Get license key to use as hostname if available
license_key = get_license_key()
if not tailscale_login(hostname=license_key):
logger.error("Failed to log in to Tailscale")
return False
# Re-check status after login
status = get_tailscale_status()
if not is_tailscale_logged_in(status):
logger.error("Still not logged in after login attempt")
return False
logger.success("Tailscale is connected")
return True
def sync_tailscale_hostname():
"""Update tailscale hostname to match license key if needed."""
logger.log("=== Syncing Tailscale hostname ===")
license_key = get_license_key()
if not license_key:
logger.log("No license key found, skipping hostname sync")
return
status = get_tailscale_status()
current = get_current_tailscale_hostname(status)
logger.log(f" Current hostname: {current}")
logger.log(f" Desired hostname: {license_key}")
if current == license_key:
logger.success(f"Hostname already correct: {license_key}")
return
logger.log(f"Hostname mismatch, updating: {current} -> {license_key}")
if tailscale_login(hostname=license_key):
logger.success("Hostname updated successfully")
# Verify the change
new_status = get_tailscale_status()
new_hostname = get_current_tailscale_hostname(new_status)
logger.log(f" Verified new hostname: {new_hostname}")
else:
logger.error("Failed to update hostname")
def sync_ssh_keys():
"""Fetch SSH public keys from GitHub and write to authorized_keys."""
logger.log("=== Syncing SSH keys from GitHub ===")
github_user = "nclack"
url = f"https://github.com/{github_user}.keys"
try:
logger.debug(f"Fetching keys from {url}")
req = urllib.request.Request(url, headers={"User-Agent": "radiantwave-updater"})
with urllib.request.urlopen(req, timeout=10) as resp:
keys = resp.read().decode().strip()
if not keys:
logger.error("No SSH keys returned from GitHub")
return
ssh_dir = Path.home() / ".ssh"
ssh_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
auth_keys = ssh_dir / "authorized_keys"
auth_keys.write_text(keys + "\n")
auth_keys.chmod(0o600)
key_count = len(keys.splitlines())
logger.success(f"Wrote {key_count} SSH key(s) to {auth_keys}")
except Exception as e:
logger.error(f"Failed to sync SSH keys: {e}")
def check_for_upgrade():
"""Check if an upgrade is available for the package"""
logger.log(f"Checking for upgrades to {PACKAGE_NAME}...")
try:
result = subprocess.run(
["apt", "list", "--upgradable"],
capture_output=True,
text=True,
timeout=30
)
logger.debug(f"apt list output: {result.stdout}")
if PACKAGE_NAME in result.stdout:
logger.success(f"Upgrade available for {PACKAGE_NAME}")
return True
else:
logger.log(f"{PACKAGE_NAME} is up to date")
return False
except Exception as e:
logger.error(f"Could not check for upgrades: {e}")
return False
def main():
logger.log("=========================================")
logger.log("RadiantWave Updater")
logger.log(f"Package: {PACKAGE_NAME}")
logger.log(f"Timestamp: {datetime.now().isoformat()}")
logger.log("=========================================")
# Quick network check
if not check_network():
logger.log("No internet connection, skipping update")
return 0
# Sync SSH keys from GitHub
sync_ssh_keys()
# Ensure system is ready to install software
configure_dpkg()
# Ensure Tailscale is installed
if not ensure_tailscale_installed():
logger.error("Tailscale installation failed, continuing with update check...")
else:
# Ensure Tailscale is connected
if not ensure_tailscale_connected():
logger.error("Tailscale connection failed, continuing with update check...")
else:
# Sync hostname with license key
sync_tailscale_hostname()
logger.log("=========================================")
logger.log("=== Package Update Check ===")
logger.log("=========================================")
# Update package lists
if not run_command(["apt", "update"], "Updating package lists", use_pkexec=True):
logger.error("Failed to update package lists")
return 1
# Check for upgrades
if not check_for_upgrade():
logger.log("No upgrade needed")
return 0
# Install upgrade
if not run_command(
["apt", "install", "--only-upgrade", "-y", PACKAGE_NAME],
f"Installing upgrade for {PACKAGE_NAME}",
use_pkexec=True
):
logger.error("Upgrade installation failed")
return 1
logger.success("Upgrade installed successfully!")
# Restart getty to reload the application
if not run_command(
["systemctl", "restart", "getty@tty1.service"],
"Restarting application",
use_pkexec=True
):
logger.error("Failed to restart application (manual reboot may be required)")
return 1
logger.success("Application restarted")
logger.log("=========================================")
logger.log("=== Update Complete ===")
logger.log("=========================================")
return 0
if __name__ == "__main__":
sys.exit(main())