#!/usr/bin/env python3 import argparse import shlex import subprocess import sys from dataclasses import dataclass def escape_for_double_quotes(value: str) -> str: return ( value.replace("\\", "\\\\") .replace('"', '\\"') .replace("$", "\\$") .replace("`", "\\`") ) def build_ssh_command( host: str, user: str, password: str, remote_command: str, connect_timeout: int = 5, ) -> list[str]: return [ "sshpass", "-p", password, "ssh", "-F", "/dev/null", "-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null", "-o", "PreferredAuthentications=password", "-o", "PubkeyAuthentication=no", "-o", f"ConnectTimeout={connect_timeout}", f"{user}@{host}", remote_command, ] def build_mysql_command( container: str, database: str, sql: str, mysql_user: str = "root", mysql_password: str = "root", ) -> str: escaped_sql = escape_for_double_quotes(sql) return ( f"docker exec {container} mysql -u{mysql_user} -p{mysql_password} " f"{database} -N -B -e \"{escaped_sql}\"" ) def build_group_query_command(group: str = "docker") -> str: return f"grep '^{group}:' /etc/group || true" def build_group_add_command(username: str, group: str = "docker") -> str: return f"abuild-addgroup {username} {group}" def build_group_cleanup_command( username: str, cleanup_image: str = "alpine:latest", group: str = "docker", ) -> str: script = ( "awk -F: 'BEGIN{OFS=FS} " f"$1==\"{group}\" {{" "n=split($4,a,\",\"); out=\"\"; " f"for(i=1;i<=n;i++) if(a[i] != \"\" && a[i] != \"{username}\") " "out = out (out ? \",\" : \"\") a[i]; " "$4=out} {print}' /host/etc/group > /host/etc/group.tmp " "&& mv /host/etc/group.tmp /host/etc/group" ) return ( f"docker run --rm -v /:/host {shlex.quote(cleanup_image)} " f"sh -c {shlex.quote(script)}" ) def default_command_runner_factory( host: str, user: str, password: str, connect_timeout: int, ): def run_remote(remote_command: str) -> str: command = build_ssh_command( host=host, user=user, password=password, remote_command=remote_command, connect_timeout=connect_timeout, ) completed = subprocess.run( command, capture_output=True, text=True, check=False, ) if completed.returncode != 0: raise RuntimeError( "remote command failed\n" f"command: {remote_command}\n" f"stdout: {completed.stdout.strip()}\n" f"stderr: {completed.stderr.strip()}" ) return completed.stdout.strip() return run_remote @dataclass(eq=True) class UserStatus: user_id: str username: str enabled: str status: str added: str last_access: str parked: str user_class: str @property def is_locked(self) -> bool: return self.enabled.strip().lower() != "yes" def parse_setting_row(output: str) -> tuple[str, str]: line = output.strip() if not line: raise ValueError("empty settings query result") parts = line.split("\t", 1) if len(parts) != 2: raise ValueError(f"unexpected settings query result: {line}") return parts[0], parts[1] def parse_user_status(output: str) -> UserStatus: line = output.strip() if not line: raise ValueError("empty user query result") parts = line.split("\t") if len(parts) != 8: raise ValueError(f"unexpected user query result: {line}") return UserStatus( user_id=parts[0], username=parts[1], enabled=parts[2], status=parts[3], added=parts[4], last_access=parts[5], parked=parts[6], user_class=parts[7], ) def parse_group_members(group_row: str) -> list[str]: line = group_row.strip() if not line: return [] parts = line.split(":", 3) if len(parts) < 4 or not parts[3]: return [] return [member for member in parts[3].split(",") if member] def format_user_status(user: UserStatus) -> str: return ( f"id={user.user_id}, username={user.username}, enabled={user.enabled}, " f"status={user.status}, added={user.added}, last_access={user.last_access}, " f"parked={user.parked}, class={user.user_class}" ) def render_report(result: dict) -> str: lines = [ "docker group (before):", result["docker_group_before"], "", "docker group (after grant):", result["docker_group_after_grant"], "", "docker group (after cleanup):", result["docker_group_after_cleanup"], "", f"docker access granted by script: {'yes' if result['docker_access_granted_by_script'] else 'no'}", f"docker access removed: {'yes' if result['docker_access_removed'] else 'no'}", "", "settings (before):", result["setting_row_before"], "", "settings (after):", result["setting_row_after"], "", "user (before):", format_user_status(result["user_before"]), "", "user (after):", format_user_status(result["user_after"]), "", f"user unlocked: {'yes' if result['user_unlocked'] else 'no'}", "", "Manual verification recommended:", "1. Re-run the SELECT queries in MySQL and confirm the values.", "2. Verify lilix can log in and the account is usable in NexusPHP.", ] return "\n".join(lines) class NexusPhpAdmin: def __init__( self, host: str = "", ssh_user: str = "lilix", ssh_password: str = "bladerunner", container: str = "nexusphp-mysql", database: str = "nexusphp", mysql_user: str = "root", mysql_password: str = "root", docker_group: str = "docker", cleanup_image: str = "alpine:latest", connect_timeout: int = 5, command_runner=None, ): self.host = host self.ssh_user = ssh_user self.ssh_password = ssh_password self.container = container self.database = database self.mysql_user = mysql_user self.mysql_password = mysql_password self.docker_group = docker_group self.cleanup_image = cleanup_image self.connect_timeout = connect_timeout if not host and command_runner is None: raise ValueError("host is required when no command_runner is provided") self.command_runner = command_runner or default_command_runner_factory( host=host, user=ssh_user, password=ssh_password, connect_timeout=connect_timeout, ) def run_mysql(self, sql: str) -> str: command = build_mysql_command( container=self.container, database=self.database, sql=sql, mysql_user=self.mysql_user, mysql_password=self.mysql_password, ) return self.command_runner(command) def get_group_row(self) -> str: return self.command_runner(build_group_query_command(self.docker_group)).strip() def has_docker_access(self, username: str) -> bool: return username in parse_group_members(self.get_group_row()) def grant_docker_access(self, username: str) -> None: self.command_runner(build_group_add_command(username, self.docker_group)) def remove_docker_access(self, username: str) -> None: self.command_runner( build_group_cleanup_command( username=username, cleanup_image=self.cleanup_image, group=self.docker_group, ) ) def get_setting_row(self, name: str) -> str: return self.run_mysql( f"SELECT name, value FROM settings WHERE name = '{name}';" ) def set_setting_value(self, name: str, value: str) -> None: self.run_mysql( f"UPDATE settings SET value = '{value}' WHERE name = '{name}';" ) def get_user_status(self, username: str) -> UserStatus: result = self.run_mysql( "SELECT id, username, enabled, status, added, last_access, parked, class " f"FROM users WHERE username = '{username}';" ) return parse_user_status(result) def unlock_user(self, username: str) -> None: self.run_mysql( f"UPDATE users SET enabled = 'yes' WHERE username = '{username}';" ) def run( self, target_value: str = "60000", setting_name: str = "account.deletenotransfer", username: str = "lilix", ) -> dict: docker_group_before = self.get_group_row() docker_access_granted_by_script = False docker_access_removed = False docker_group_after_grant = docker_group_before docker_group_after_cleanup = docker_group_before had_docker_access = username in parse_group_members(docker_group_before) result = None primary_error = None try: if not had_docker_access: self.grant_docker_access(username) docker_access_granted_by_script = True docker_group_after_grant = self.get_group_row() setting_row_before = self.get_setting_row(setting_name) _, setting_before = parse_setting_row(setting_row_before) self.set_setting_value(setting_name, target_value) setting_row_after = self.get_setting_row(setting_name) _, setting_after = parse_setting_row(setting_row_after) user_before = self.get_user_status(username) user_unlocked = False user_after = user_before if user_before.is_locked: self.unlock_user(username) user_after = self.get_user_status(username) user_unlocked = True result = { "docker_group_before": docker_group_before, "docker_group_after_grant": docker_group_after_grant, "docker_group_after_cleanup": docker_group_after_grant, "docker_access_granted_by_script": docker_access_granted_by_script, "docker_access_removed": docker_access_removed, "setting_before": setting_before, "setting_after": setting_after, "setting_row_before": setting_row_before.strip(), "setting_row_after": setting_row_after.strip(), "user_before": user_before, "user_after": user_after, "user_unlocked": user_unlocked, } except Exception as exc: primary_error = exc finally: cleanup_error = None if docker_access_granted_by_script: try: self.remove_docker_access(username) docker_access_removed = True except Exception as exc: cleanup_error = exc try: docker_group_after_cleanup = self.get_group_row() except Exception as exc: if cleanup_error is None: cleanup_error = exc else: docker_group_after_cleanup = docker_group_before if result is not None: result["docker_group_after_cleanup"] = docker_group_after_cleanup result["docker_access_removed"] = docker_access_removed if cleanup_error and primary_error: raise RuntimeError( f"{primary_error}\ncleanup failed: {cleanup_error}" ) from primary_error if cleanup_error: raise cleanup_error if primary_error: raise primary_error return result def parse_args(argv: list[str]) -> argparse.Namespace: parser = argparse.ArgumentParser( description="Update NexusPHP settings and unlock a user over SSH." ) parser.add_argument("host") parser.add_argument("--ssh-user", default="lilix") parser.add_argument("--ssh-password", default="bladerunner") parser.add_argument("--container", default="nexusphp-mysql") parser.add_argument("--database", default="nexusphp") parser.add_argument("--mysql-user", default="root") parser.add_argument("--mysql-password", default="root") parser.add_argument("--docker-group", default="docker") parser.add_argument("--cleanup-image", default="alpine:latest") parser.add_argument("--connect-timeout", type=int, default=5) parser.add_argument("--setting-name", default="account.deletenotransfer") parser.add_argument("--setting-value", default="60000") parser.add_argument("--username", default="lilix") return parser.parse_args(argv) def main(argv: list[str] | None = None) -> int: args = parse_args(argv or sys.argv[1:]) admin = NexusPhpAdmin( host=args.host, ssh_user=args.ssh_user, ssh_password=args.ssh_password, container=args.container, database=args.database, mysql_user=args.mysql_user, mysql_password=args.mysql_password, docker_group=args.docker_group, cleanup_image=args.cleanup_image, connect_timeout=args.connect_timeout, ) result = admin.run( target_value=args.setting_value, setting_name=args.setting_name, username=args.username, ) print(render_report(result)) return 0 if __name__ == "__main__": raise SystemExit(main())