407 lines
16 KiB
Python
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()
|