#!/usr/bin/env python3 """One-time, reversible takeover by the installed BaoTa project manager. Run as root using /www/server/panel/pyenv/bin/python on the target host. Secrets are read only on the server and are never printed. """ import argparse import hashlib import json import os import pwd import shlex import shutil import sqlite3 import subprocess import sys import time import urllib.request from pathlib import Path PANEL = Path('/www/server/panel') NAME = 'xingyu_im' DOMAIN = 'im.bchongw.com' EXECUTABLE = Path('/www/server/xingyu-im/bt/xingyu-api') ENV_FILE = Path('/etc/xingyu-im-bt.env') OLD_NGINX = PANEL / 'vhost/nginx/im.bchongw.com.conf' HTML_NGINX = PANEL / 'vhost/nginx/html_im.bchongw.com.conf' GO_NGINX = PANEL / 'vhost/nginx/go_xingyu_im.conf' def command(*args): result = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True) if result.returncode: raise RuntimeError('Command failed: {}: {}'.format(args[0], result.stderr.strip())) return result.stdout.strip() def atomic_write(path, content, mode=0o600): temporary = path.with_name(path.name + '.takeover-new') descriptor = os.open(str(temporary), os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode) with os.fdopen(descriptor, 'w', encoding='utf-8') as target: target.write(content) os.replace(str(temporary), str(path)) def env_values(): values = {} for line in Path('/etc/xingyu-im.env').read_text().splitlines(): if not line.strip() or line.lstrip().startswith('#'): continue key, value = line.split('=', 1) parts = shlex.split(value) if len(parts) > 1: raise RuntimeError('Unexpected environment syntax for ' + key) values[key] = parts[0] if parts else '' assert values['IM_HOST'] == '127.0.0.1' assert values['IM_PORT'] == '18888' assert values['IM_ENV'] == 'production' return values def health(url='http://127.0.0.1:18888/healthz'): for _ in range(140): try: with urllib.request.urlopen(url, timeout=3) as response: data = json.load(response) if data.get('code') == 0 and data.get('data', {}).get('status') == 'ok': return True except Exception: time.sleep(0.25) raise RuntimeError('Health check failed: ' + url) def request(**values): obj = public.dict_obj() for key, value in values.items(): setattr(obj, key, value) return obj def status(): go = GoProject() project = go.get_project_find(NAME) domain = public.M('domain').where('name=? AND port=?', (DOMAIN, 80)).find() site = public.M('sites').where('id=?', (domain['pid'],)).find() if domain else {} print(json.dumps({ 'go_project': NAME if project else None, 'running': go.get_project_run_state(project_name=NAME) if project else False, 'auto_start': project['project_config']['is_power_on'] if project else None, 'environment_file': project['project_config']['env_file'] if project else None, 'web_project_type': site.get('project_type'), 'web_project_name': site.get('name'), 'web_root': site.get('path'), 'domains': project['project_config']['domains'] if project else [], 'external_mapping': bool(project['project_config']['bind_extranet']) if project else False, 'gateway_nginx_config': str(GO_NGINX if GO_NGINX.is_file() else HTML_NGINX), }, ensure_ascii=False)) def takeover(): go = GoProject() if go.get_project_find(NAME): raise RuntimeError('The BaoTa project already exists; use status/verify instead.') site = public.M('sites').where('name=?', (DOMAIN,)).find() assert site and site['project_type'] == 'PHP' assert site['path'] == '/www/wwwroot/im.bchongw.com' assert OLD_NGINX.is_file() and not HTML_NGINX.exists() assert not EXECUTABLE.parent.exists() and not ENV_FILE.exists() health() values = env_values() backup = Path('/www/backup/xingyu-panel-takeover-' + time.strftime('%Y%m%d-%H%M%S')) backup.mkdir(mode=0o700, parents=True, exist_ok=False) os.chmod(str(backup), 0o700) for source in (OLD_NGINX, Path('/etc/systemd/system/xingyu-im.service'), Path('/etc/xingyu-im.env')): shutil.copy2(str(source), str(backup / source.name)) for source in (PANEL / 'data/db/site.db', PANEL / 'data/default.db'): with sqlite3.connect(str(source)) as connection: with sqlite3.connect(str(backup / source.name)) as destination: connection.backup(destination) atomic_write(backup / 'original-site.json', json.dumps(site, ensure_ascii=False)) shutil.copytree('/www/server/xingyu-im/current', str(EXECUTABLE.parent)) launcher = Path(__file__).with_name('baota-go-start.sh') assert launcher.is_file(), 'Upload baota-go-start.sh alongside this script.' shutil.copy2(str(launcher), str(EXECUTABLE.parent / 'start.sh')) os.chmod(str(EXECUTABLE.parent / 'start.sh'), 0o750) original_hash = hashlib.sha256(Path('/www/server/xingyu-im/current/xingyu-api').read_bytes()).hexdigest() assert hashlib.sha256(EXECUTABLE.read_bytes()).hexdigest() == original_hash atomic_write(ENV_FILE, '# BaoTa Go project environment. Keep this file private.\n' + ''.join( 'export {}={}\n'.format(key, shlex.quote(value)) for key, value in values.items())) os.chown(str(ENV_FILE), 0, pwd.getpwnam('www').pw_gid) os.chmod(str(ENV_FILE), 0o640) print('BACKUP=' + str(backup), flush=True) old_stopped = False site_changed = False try: command('systemctl', 'stop', 'xingyu-im') old_stopped = True result = go.create_project(request( project_name=NAME, project_exe=str(EXECUTABLE), project_ps='星遇 IM 后端 · im.bchongw.com · 内网 18888', bind_extranet=0, domains=[], is_power_on=1, run_user='www', project_cmd=str(EXECUTABLE.parent / 'start.sh'), port=18888, env_file=str(ENV_FILE), env_list=[], release_firewall=0, )) if not result.get('status'): raise RuntimeError('BaoTa create_project failed: ' + str(result.get('msg'))) health() assert go.get_project_run_state(project_name=NAME) os.rename(str(OLD_NGINX), str(HTML_NGINX)) site_changed = True public.M('sites').where('id=? AND name=?', (site['id'], DOMAIN)).update({ 'project_type': 'html', 'project_config': site.get('project_config') or '{}', 'ps': '星遇管理端 /admin/ · IM HTTPS/WSS 网关', }) command('/www/server/nginx/sbin/nginx', '-t') command('/etc/init.d/nginx', 'reload') health('https://im.bchongw.com/healthz') command('systemctl', 'disable', 'xingyu-im') public.WriteLog('项目管理', '星遇服务已由 systemd 接管至 Go 项目 xingyu_im;域名网页转为 HTML 项目;备份:' + str(backup)) atomic_write(backup / 'result.json', json.dumps({ 'go_project': NAME, 'site_id': site['id'], 'executable_sha256': original_hash, 'html_config': str(HTML_NGINX), 'previous_systemd': 'xingyu-im.service', }, ensure_ascii=False)) print('TAKEOVER_OK', flush=True) status() except Exception: # Restore only this project's settings; never replace the full panel database. if site_changed: if HTML_NGINX.exists(): os.rename(str(HTML_NGINX), str(OLD_NGINX)) public.M('sites').where('id=?', (site['id'],)).update({ 'project_type': site['project_type'], 'project_config': site.get('project_config'), 'ps': site['ps'], }) command('/www/server/nginx/sbin/nginx', '-t') command('/etc/init.d/nginx', 'reload') project = go.get_project_find(NAME) if project: if go.get_project_run_state(project_name=NAME): go.stop_project(request(project_name=NAME)) time.sleep(1) config = project['project_config'] config['is_power_on'] = 0 public.M('sites').where('id=?', (project['id'],)).setField('project_config', json.dumps(config)) if old_stopped: command('systemctl', 'enable', '--now', 'xingyu-im') health() print('ROLLED_BACK_TO_SYSTEMD; backup=' + str(backup), flush=True) raise def verify(): go = GoProject() project = go.get_project_find(NAME) assert project and project['project_config']['is_power_on'] == 1 before_pid = go.get_pid_by_command(NAME) result = go.restart_project(request(project_name=NAME)) if not result.get('status'): time.sleep(2) recovered = go.start_project(request(project_name=NAME)) health() raise RuntimeError('BaoTa restart failed; service recovery status: ' + str(recovered.get('status'))) health() after_pid = go.get_pid_by_command(NAME) assert after_pid and after_pid != before_pid health('https://im.bchongw.com/healthz') domain = public.M('domain').where('name=? AND port=?', (DOMAIN, 80)).find() assert domain if project['project_config']['bind_extranet']: assert domain['pid'] == project['id'] and GO_NGINX.is_file() else: assert public.M('sites').where('id=?', (domain['pid'],)).getField('project_type') == 'html' assert command('systemctl', 'is-active', 'xingyu-mysql8') == 'active' old = subprocess.run(['systemctl', 'is-active', 'xingyu-im'], stdout=subprocess.PIPE, universal_newlines=True) assert old.stdout.strip() == 'inactive' with urllib.request.urlopen('https://im.bchongw.com/admin/', timeout=10) as response: assert '星遇社交运营中心' in response.read().decode('utf-8') data = go.get_project_list(request(p=1, limit=20, search=NAME)) assert any(row['name'] == NAME and row['run'] for row in data['data']) print('PANEL_RESTART_OK; PUBLIC_API_OK; ADMIN_WEB_OK; MYSQL_UNCHANGED; GO_LIST_RUNNING', flush=True) status() if __name__ == '__main__': parser = argparse.ArgumentParser() parser.add_argument('action', choices=('status', 'takeover', 'verify')) args = parser.parse_args() if os.geteuid() != 0 or not PANEL.is_dir(): raise SystemExit('Run on the BaoTa host as root.') os.chdir(str(PANEL)) sys.path.insert(0, str(PANEL)) sys.path.insert(0, str(PANEL / 'class')) import public from projectModel.goModel import main as GoProject {'status': status, 'takeover': takeover, 'verify': verify}[args.action]()