Files
kefu/im/deploy/xim_initial_deploy.py
T
2026-09-03 08:38:17 +08:00

407 lines
16 KiB
Python

#!/usr/bin/env python3
"""Initial, isolated BaoTa deployment for xim.bchongw.com.
Run with BaoTa's bundled Python as root. Secrets are generated on the target
host, stored in root-only files, and never printed by this script.
"""
import argparse
import hashlib
import json
import os
import pwd
import secrets
import shlex
import shutil
import sqlite3
import subprocess
import sys
import time
import urllib.request
import zipfile
from pathlib import Path
PANEL = Path('/www/server/panel')
DOMAIN = 'xim.bchongw.com'
PROJECT = 'xim_im'
DATABASE = 'xim'
DATABASE_USER = 'xim_app'
PORT = 18888
SERVER_ROOT = Path('/www/server/xim-im')
WEB_ROOT = Path('/www/wwwroot') / DOMAIN
ADMIN_ROOT = Path('/www/wwwroot/xim-admin')
MEDIA_ROOT = Path('/www/wwwroot/xim-data/uploads')
ENV_FILE = Path('/etc/xim-im.env')
CREDENTIALS_FILE = Path('/root/.xim-initial-credentials')
GO_CONF = PANEL / ('vhost/nginx/go_' + PROJECT + '.conf')
ROUTES = PANEL / ('vhost/nginx/extension/' + PROJECT + '/xim_routes.conf')
def atomic_write(path, content, mode=0o600):
temporary = path.with_name(path.name + '.new-' + secrets.token_hex(4))
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))
os.chmod(str(path), mode)
def digest(path):
result = hashlib.sha256()
with path.open('rb') as source:
while True:
block = source.read(1024 * 1024)
if not block:
return result.hexdigest()
result.update(block)
def run(args, **kwargs):
result = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
universal_newlines=True, **kwargs)
if result.returncode:
raise RuntimeError('{} failed: {}'.format(args[0], result.stderr.strip()))
return result.stdout.strip()
def request(**values):
result = public.dict_obj()
for key, value in values.items():
setattr(result, key, value)
return result
def extract_safe(package, destination):
destination.mkdir(parents=True, exist_ok=False)
with zipfile.ZipFile(str(package)) as archive:
for item in archive.infolist():
name = item.filename.replace('\\', '/')
candidate = (destination / name).resolve()
if os.path.commonpath([str(candidate), str(destination.resolve())]) != str(destination.resolve()):
raise RuntimeError('Unsafe archive member: ' + item.filename)
if item.is_dir() or item.filename.endswith(('/', '\\')):
candidate.mkdir(parents=True, exist_ok=True)
else:
candidate.parent.mkdir(parents=True, exist_ok=True)
with archive.open(item) as source, candidate.open('wb') as target:
shutil.copyfileobj(source, target)
def backup_panel_databases(backup):
backup.mkdir(parents=True, mode=0o700, exist_ok=False)
os.chmod(str(backup), 0o700)
for source in (PANEL / 'data/db/site.db', PANEL / 'data/db/database.db'):
with sqlite3.connect(str(source)) as connection:
with sqlite3.connect(str(backup / source.name)) as target:
connection.backup(target)
def mysql_args(database_password, include_database=True):
executable = Path('/www/server/mysql/bin/mysql')
if not executable.is_file():
executable = Path(shutil.which('mysql') or '')
if not executable.is_file():
raise RuntimeError('MySQL client is unavailable')
args = [str(executable), '--protocol=TCP', '--host=127.0.0.1', '--port=3306',
'--user=' + DATABASE_USER, '--default-character-set=utf8mb4',
'--batch', '--skip-column-names']
if include_database:
args.append('--database=' + DATABASE)
environment = os.environ.copy()
environment['MYSQL_PWD'] = database_password
return args, environment
def mysql_query(database_password, statement):
args, environment = mysql_args(database_password)
result = subprocess.run(args + ['--execute', statement], env=environment,
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
universal_newlines=True)
if result.returncode:
raise RuntimeError('MySQL query failed: ' + result.stderr.strip())
return result.stdout.strip()
def migrate(database_password, migration_root):
mysql_query(database_password, '''
CREATE TABLE IF NOT EXISTS schema_migrations (
version VARCHAR(255) NOT NULL PRIMARY KEY,
checksum CHAR(64) NOT NULL,
applied_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
''')
migrations = sorted(migration_root.glob('*.sql'))
if len(migrations) != 29 or migrations[0].name != '001_users.sql' or migrations[-1].name != '029_admin_create_users.sql':
raise RuntimeError('Expected the complete 001-029 migration set')
args, environment = mysql_args(database_password)
for migration in migrations:
checksum = digest(migration)
stored = mysql_query(database_password,
"SELECT checksum FROM schema_migrations WHERE version='{}'".format(migration.name))
if stored:
if stored != checksum:
raise RuntimeError('Migration checksum mismatch: ' + migration.name)
continue
result = subprocess.run(args, env=environment, stdin=migration.open('r', encoding='utf-8'),
stdout=subprocess.PIPE, stderr=subprocess.PIPE,
universal_newlines=True)
if result.returncode:
raise RuntimeError('Migration {} failed: {}'.format(migration.name, result.stderr.strip()))
mysql_query(database_password,
"INSERT INTO schema_migrations(version, checksum) VALUES('{}','{}')".format(
migration.name, checksum))
applied = int(mysql_query(database_password, 'SELECT COUNT(*) FROM schema_migrations'))
if applied != 29:
raise RuntimeError('Unexpected migration count: ' + str(applied))
def environment_text(values):
return '# BaoTa Go project environment for xim.bchongw.com. Keep private.\n' + ''.join(
'export {}={}\n'.format(key, shlex.quote(value)) for key, value in values.items())
def health(url, host=None, expected='"status":"ok"'):
last_error = None
for _ in range(120):
try:
headers = {'Host': host} if host else {}
with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=4) as response:
body = response.read().decode('utf-8')
if response.status == 200 and expected in body:
return body
except Exception as error:
last_error = error
time.sleep(0.25)
raise RuntimeError('Health check failed for {}: {}'.format(url, last_error))
def main():
parser = argparse.ArgumentParser()
parser.add_argument('bundle')
parser.add_argument('sha256')
args = parser.parse_args()
bundle = Path(args.bundle).resolve()
if os.geteuid() != 0 or not PANEL.is_dir():
raise SystemExit('Run as root on the BaoTa host')
if bundle.parent != Path('/tmp') or not bundle.is_file():
raise SystemExit('Bundle must be a regular file directly under /tmp')
if digest(bundle) != args.sha256.lower():
raise SystemExit('Bundle SHA-256 mismatch')
os.chdir(str(PANEL))
sys.path.insert(0, str(PANEL))
sys.path.insert(0, str(PANEL / 'class'))
global public
import public
import database
from projectModel.goModel import main as GoProject
go = GoProject()
conflicts = {
'project': bool(go.get_project_find(PROJECT)),
'domain': bool(public.M('domain').where('name=?', (DOMAIN,)).count()),
'database': bool(public.M('databases').where('name=?', (DATABASE,)).count()),
'server_root': SERVER_ROOT.exists(),
'web_root': WEB_ROOT.exists(),
'admin_root': ADMIN_ROOT.exists(),
'media_root': MEDIA_ROOT.exists(),
'environment': ENV_FILE.exists(),
'credentials': CREDENTIALS_FILE.exists(),
'nginx': GO_CONF.exists(),
}
if any(conflicts.values()):
raise RuntimeError('Refusing to overwrite an existing target: ' + json.dumps(conflicts, ensure_ascii=False))
run(['/www/server/nginx/sbin/nginx', '-t'])
if run(['bash', '-lc', "ss -H -ltn 'sport = :{}' | wc -l".format(PORT)]) != '0':
raise RuntimeError('Port {} is already in use'.format(PORT))
stamp = time.strftime('%Y%m%d-%H%M%S')
backup = Path('/www/backup/xim-initial-' + stamp)
backup_panel_databases(backup)
stage = Path('/tmp/xim-stage-' + stamp)
extract_safe(bundle, stage)
binary = stage / 'xim-api'
admin_archive = stage / 'admin.zip'
migrations = stage / 'migrations'
if not binary.is_file() or not admin_archive.is_file() or not migrations.is_dir():
raise RuntimeError('Bundle layout is incomplete')
database_password = secrets.token_hex(32)
jwt_secret = secrets.token_hex(48)
encryption_key = secrets.token_hex(48)
admin_password = 'Xa9!' + secrets.token_urlsafe(20)
add_database = database.database().AddDatabase(request(
name=DATABASE, db_user=DATABASE_USER, password=database_password,
address='127.0.0.1', codeing='utf8mb4', ps='星遇 IM · xim.bchongw.com',
sid=0, pid=0,
))
if not add_database.get('status'):
raise RuntimeError('BaoTa database creation failed: ' + str(add_database.get('msg')))
migrate(database_password, migrations)
release = SERVER_ROOT / 'releases' / stamp
admin_release = ADMIN_ROOT / 'releases' / stamp
release.mkdir(parents=True, mode=0o755)
shutil.copy2(str(binary), str(release / 'xim-api'))
os.chmod(str(release / 'xim-api'), 0o755)
shutil.copytree(str(migrations), str(release / 'migrations'))
launcher = '''#!/bin/bash
set -euo pipefail
umask 0027
for attempt in $(seq 1 120); do
if ! /usr/sbin/ss -H -ltn 'sport = :18888' | /usr/bin/grep -q .; then
exec /www/server/xim-im/current/xim-api
fi
sleep 0.25
done
printf '%s\\n' 'Cannot start xim_im: port 18888 is still occupied.' >&2
exit 1
'''
atomic_write(release / 'start.sh', launcher, 0o750)
current = SERVER_ROOT / 'current'
current.symlink_to(release)
ADMIN_ROOT.mkdir(parents=True, mode=0o755)
extract_safe(admin_archive, admin_release)
if not (admin_release / 'index.html').is_file() or not (admin_release / '_app.config.js').is_file():
raise RuntimeError('Admin build is incomplete')
if '/admin/' not in (admin_release / 'index.html').read_text(encoding='utf-8'):
raise RuntimeError('Admin build does not use the /admin/ base')
if '"/admin/v1"' not in (admin_release / '_app.config.js').read_text(encoding='utf-8'):
raise RuntimeError('Admin runtime API path is incorrect')
www = pwd.getpwnam('www')
for parent, directories, files in os.walk(str(admin_release)):
os.chown(parent, www.pw_uid, www.pw_gid)
os.chmod(parent, 0o755)
for filename in files:
path = os.path.join(parent, filename)
os.chown(path, www.pw_uid, www.pw_gid)
os.chmod(path, 0o644)
WEB_ROOT.mkdir(parents=True, mode=0o755)
(WEB_ROOT / 'admin').symlink_to(admin_release)
MEDIA_ROOT.mkdir(parents=True, mode=0o750)
os.chown(str(MEDIA_ROOT), www.pw_uid, www.pw_gid)
values = {
'IM_ENV': 'production',
'IM_HOST': '127.0.0.1',
'IM_PORT': str(PORT),
'IM_DB_DSN': '{}:{}@tcp(127.0.0.1:3306)/{}?charset=utf8mb4&parseTime=True&loc=Local'.format(
DATABASE_USER, database_password, DATABASE),
'IM_JWT_SECRET': jwt_secret,
'IM_CONFIG_ENCRYPTION_KEY': encryption_key,
'IM_ALLOWED_ORIGINS': 'https://' + DOMAIN,
'IM_SEED_DEMO': 'false',
'IM_MEDIA_DIR': str(MEDIA_ROOT),
'IM_BOOTSTRAP_ADMIN_USERNAME': 'admin',
'IM_BOOTSTRAP_ADMIN_PASSWORD': admin_password,
'IM_BOOTSTRAP_ADMIN_REAL_NAME': '平台管理员',
'TZ': 'Asia/Shanghai',
}
atomic_write(ENV_FILE, environment_text(values), 0o640)
os.chown(str(ENV_FILE), 0, www.pw_gid)
atomic_write(CREDENTIALS_FILE,
'URL=https://{}/admin/\nusername=admin\npassword={}\n'.format(DOMAIN, admin_password), 0o600)
routes = '''# xim.bchongw.com admin routes. API and WebSocket use the BaoTa Go proxy.
server_tokens off;
client_max_body_size 16m;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy no-referrer always;
add_header X-Frame-Options DENY always;
location ^~ /admin/v1/ {
proxy_pass http://127.0.0.1:18888;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 10s;
proxy_read_timeout 60s;
}
location = /admin { return 301 /admin/; }
location = /admin/ {
root /www/wwwroot/xim.bchongw.com;
add_header Cache-Control "no-store, no-cache, must-revalidate";
try_files /admin/index.html =404;
}
location = /admin/index.html {
root /www/wwwroot/xim.bchongw.com;
add_header Cache-Control "no-store, no-cache, must-revalidate";
try_files $uri =404;
}
location = /admin/_app.config.js {
root /www/wwwroot/xim.bchongw.com;
add_header Cache-Control "no-store, no-cache, must-revalidate";
try_files $uri =404;
}
location ^~ /admin/ {
root /www/wwwroot/xim.bchongw.com;
expires 7d;
add_header Cache-Control "public, immutable";
try_files $uri $uri/ /admin/index.html;
}
'''
ROUTES.parent.mkdir(parents=True, mode=0o755)
atomic_write(ROUTES, routes, 0o644)
create = go.create_project(request(
project_name=PROJECT, project_exe=str(current / 'xim-api'),
project_ps='星遇 IM · xim.bchongw.com · 管理端 /admin/',
bind_extranet=1, domains=[DOMAIN + ':80'], is_power_on=1,
run_user='www', project_cmd=str(current / 'start.sh'), port=PORT,
env_file=str(ENV_FILE), env_list=[], release_firewall=0,
))
if not create.get('status'):
raise RuntimeError('BaoTa Go project creation failed: ' + str(create.get('msg')))
health('http://127.0.0.1:{}/healthz'.format(PORT))
if not GO_CONF.is_file():
raise RuntimeError('BaoTa did not generate the Go Nginx config')
nginx_text = GO_CONF.read_text(encoding='utf-8')
if 'proxy_set_header X-Forwarded-Proto $scheme;' not in nginx_text:
marker = 'proxy_set_header X-Scheme $scheme;'
if nginx_text.count(marker) != 1:
raise RuntimeError('Cannot safely add X-Forwarded-Proto to the Go gateway')
nginx_text = nginx_text.replace(marker, marker + '\n proxy_set_header X-Forwarded-Proto $scheme;')
atomic_write(GO_CONF, nginx_text, 0o644)
run(['/www/server/nginx/sbin/nginx', '-t'])
run(['/etc/init.d/nginx', 'reload'])
health('http://127.0.0.1/healthz', host=DOMAIN)
health('http://127.0.0.1/admin/', host=DOMAIN, expected='星遇社交运营中心')
if int(mysql_query(database_password, "SELECT COUNT(*) FROM admin_users WHERE username='admin' AND status=1")) != 1:
raise RuntimeError('Bootstrap administrator was not created')
values['IM_BOOTSTRAP_ADMIN_PASSWORD'] = ''
atomic_write(ENV_FILE, environment_text(values), 0o640)
os.chown(str(ENV_FILE), 0, www.pw_gid)
restart = go.restart_project(request(project_name=PROJECT))
if not restart.get('status'):
raise RuntimeError('BaoTa project restart failed: ' + str(restart.get('msg')))
health('http://127.0.0.1:{}/healthz'.format(PORT))
result = {
'status': 'HTTP_DEPLOYMENT_OK',
'domain': DOMAIN,
'project': PROJECT,
'port': PORT,
'database': DATABASE,
'migrations': 29,
'tables': int(mysql_query(database_password,
'SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE()')),
'backend_sha256': digest(release / 'xim-api'),
'admin_release': str(admin_release),
'server_release': str(release),
'credentials_file': str(CREDENTIALS_FILE),
'backup': str(backup),
}
atomic_write(backup / 'result.json', json.dumps(result, ensure_ascii=False, indent=2) + '\n', 0o600)
shutil.rmtree(str(stage))
print(json.dumps(result, ensure_ascii=False))
if __name__ == '__main__':
main()