565 lines
17 KiB
Python
Executable File
565 lines
17 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
rhemawave-updater.py
|
|
|
|
Auto-updater for RhemaWave kiosk application.
|
|
Uses apt to check for and install updates from the configured repository.
|
|
|
|
USAGE:
|
|
Run as kiosk user:
|
|
python3 /usr/local/bin/rhemawave-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.rhemawavetech.com"
|
|
HEADSCALE_AUTHKEY = "803130d5b2e38bffa9fa5b6921a3d6403100e2e30fea1bd5"
|
|
|
|
# Paths
|
|
LOG_FILE = Path("/home/kiosk/rhemawave-updater.log")
|
|
LICENSE_KEY_PATH = Path.home() / ".local/share/rhemawave/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": "rhemawave-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("RhemaWave 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())
|