218 lines
10 KiB
Python
218 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""Move this site's domain and HTTPS gateway into its actual BaoTa Go project.
|
|
|
|
This migrates only panel metadata/configuration. It never deletes site files,
|
|
restarts the Go process, or touches the application's MySQL database.
|
|
"""
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
PANEL = Path('/www/server/panel')
|
|
DOMAIN = 'im.bchongw.com'
|
|
PROJECT = 'xingyu_im'
|
|
OLD_CONF = PANEL / 'vhost/nginx/html_im.bchongw.com.conf'
|
|
GO_CONF = PANEL / 'vhost/nginx/go_xingyu_im.conf'
|
|
ROUTES = PANEL / 'vhost/nginx/extension/xingyu_im/xingyu_routes.conf'
|
|
CERT = PANEL / 'vhost/cert/xingyu_im'
|
|
SITE_DB = PANEL / 'data/db/site.db'
|
|
|
|
|
|
def run(*args):
|
|
result = subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)
|
|
if result.returncode:
|
|
raise RuntimeError('{} failed: {}'.format(args[0], result.stderr.strip()))
|
|
|
|
|
|
def write_private(path, text, mode=0o600):
|
|
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, mode)
|
|
with os.fdopen(fd, 'w', encoding='utf-8') as target:
|
|
target.write(text)
|
|
|
|
|
|
def check_public():
|
|
for path, content in (('/healthz', '"status":"ok"'), ('/admin/', '星遇社交运营中心'),
|
|
('/app/', '星遇社交'), ('/app/static/favicon.svg', '<svg')):
|
|
for attempt in range(20):
|
|
try:
|
|
with urllib.request.urlopen('https://' + DOMAIN + path, timeout=5) as response:
|
|
assert response.status == 200
|
|
assert content in response.read().decode('utf-8')
|
|
break
|
|
except Exception:
|
|
if attempt == 19:
|
|
raise RuntimeError('Public verification failed: ' + path)
|
|
time.sleep(0.25)
|
|
|
|
|
|
def verify():
|
|
model = GoProject()
|
|
get = public.dict_obj()
|
|
get.project_name = PROJECT
|
|
project = model.get_project_find(PROJECT)
|
|
domains = model.project_get_domain(get)
|
|
assert any(item['name'] == DOMAIN and int(item['port']) == 80 for item in domains)
|
|
assert int(project['project_config']['bind_extranet']) == 1
|
|
assert model.get_project_run_state(project_name=PROJECT)
|
|
ssl_info = model.get_ssl_end_date(PROJECT)
|
|
assert isinstance(ssl_info, dict) and DOMAIN in ssl_info.get('dns', [])
|
|
ssl_get = public.dict_obj()
|
|
ssl_get.siteName = PROJECT
|
|
ssl_state = panelSite.panelSite().GetSSL(ssl_get)
|
|
assert ssl_state['status'] and ssl_state['httpTohttps']
|
|
assert ROUTES.is_file() and str(ROUTES.parent) in GO_CONF.read_text()
|
|
check_public()
|
|
print(json.dumps({
|
|
'project': PROJECT, 'domains': [{'name': d['name'], 'port': d['port']} for d in domains],
|
|
'external_mapping': True, 'running': True, 'ssl_enabled': ssl_state['status'],
|
|
'force_https': ssl_state['httpTohttps'], 'certificate_expires': ssl_info['notAfter'],
|
|
'nginx_config': str(GO_CONF), 'routes_config': str(ROUTES),
|
|
}, ensure_ascii=False), flush=True)
|
|
|
|
|
|
def migrate():
|
|
model = GoProject()
|
|
project = model.get_project_find(PROJECT)
|
|
assert project and not project['project_config']['domains']
|
|
assert OLD_CONF.is_file() and not GO_CONF.exists() and not ROUTES.exists() and not CERT.exists()
|
|
html_site = public.M('sites').where('name=? AND project_type=?', (DOMAIN, 'html')).find()
|
|
assert html_site and html_site['path'] == '/www/wwwroot/im.bchongw.com'
|
|
domain_rows = public.M('domain').where('name=?', (DOMAIN,)).select()
|
|
assert len(domain_rows) == 1 and domain_rows[0]['pid'] == html_site['id'] and int(domain_rows[0]['port']) == 80
|
|
assert public.M('domain').where('pid=?', (html_site['id'],)).count() == 1
|
|
check_public()
|
|
backup = Path('/www/backup/xingyu-go-domain-' + time.strftime('%Y%m%d-%H%M%S'))
|
|
backup.mkdir(mode=0o700, parents=True, exist_ok=False)
|
|
os.chmod(str(backup), 0o700)
|
|
shutil.copy2(str(OLD_CONF), str(backup / OLD_CONF.name))
|
|
with sqlite3.connect(str(SITE_DB)) as db:
|
|
with sqlite3.connect(str(backup / 'site.db')) as out:
|
|
db.backup(out)
|
|
original_go = public.M('sites').where('id=?', (project['id'],)).find()
|
|
write_private(backup / 'original-records.json', json.dumps({
|
|
'html_site': html_site, 'go_site': original_go, 'domains': domain_rows,
|
|
}, ensure_ascii=False))
|
|
|
|
# Custom routes live in BaoTa's supported per-project extension directory;
|
|
# saving/rebuilding the Go project's main Nginx file retains this include.
|
|
old_text = OLD_CONF.read_text()
|
|
start = old_text.index(' # XINGYU_H5_START')
|
|
end = old_text.index('\n location / {', start)
|
|
routes = '''# Xingyu frontend routes and WebSocket gateway. Keep in this extension.
|
|
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 ^~ /.well-known/acme-challenge/ {
|
|
root /www/wwwroot/im.bchongw.com;
|
|
default_type text/plain;
|
|
try_files $uri =404;
|
|
}
|
|
|
|
''' + old_text[start:end] + '\n'
|
|
ROUTES.parent.mkdir(parents=True, exist_ok=True)
|
|
write_private(ROUTES, routes, 0o644)
|
|
CERT.mkdir(mode=0o700)
|
|
for filename in ('fullchain.pem', 'privkey.pem'):
|
|
source = Path('/etc/letsencrypt/live') / DOMAIN / filename
|
|
assert source.is_file()
|
|
os.symlink(str(source), str(CERT / filename))
|
|
rewrite = PANEL / 'vhost/rewrite/go_xingyu_im.conf'
|
|
if not rewrite.exists():
|
|
write_private(rewrite, '# Project-specific rewrite rules; frontend routes are in the extension directory.\n', 0o644)
|
|
well_known = PANEL / 'vhost/nginx/well-known/xingyu_im.conf'
|
|
if not well_known.exists():
|
|
write_private(well_known, '# ACME routes are in extension/xingyu_im/xingyu_routes.conf.\n', 0o644)
|
|
|
|
ssl = '''ssl_certificate /www/server/panel/vhost/cert/xingyu_im/fullchain.pem;
|
|
ssl_certificate_key /www/server/panel/vhost/cert/xingyu_im/privkey.pem;
|
|
ssl_protocols TLSv1.2 TLSv1.3;
|
|
ssl_session_cache shared:IMSSL:10m;
|
|
ssl_session_timeout 1d;
|
|
ssl_session_tickets off;
|
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
|
error_page 497 https://$host$request_uri;
|
|
#HTTP_TO_HTTPS_START
|
|
if ($server_port !~ 443) {
|
|
return 301 https://$host$request_uri;
|
|
}
|
|
#HTTP_TO_HTTPS_END'''
|
|
template = (PANEL / 'vhost/template/nginx/go_http.conf').read_text()
|
|
body = template.format(
|
|
listen_ports='listen 80;\n listen 443 ssl;\n http2 on;', domains=DOMAIN,
|
|
site_path=project['path'], project_name=PROJECT, panel_path=str(PANEL),
|
|
log_path='/www/wwwlogs', url='http://127.0.0.1:18888', host='$host', ssl_config=ssl,
|
|
)
|
|
body = body.replace('proxy_set_header X-Scheme $scheme;',
|
|
'proxy_set_header X-Scheme $scheme;\n proxy_set_header X-Forwarded-Proto $scheme;')
|
|
from mod.base.web_conf import ng_ext
|
|
body = ng_ext.set_extension_by_config(PROJECT, body)
|
|
stage = GO_CONF.with_name(GO_CONF.name + '.prepared')
|
|
write_private(stage, body, 0o644)
|
|
metadata_changed = False
|
|
files_switched = False
|
|
try:
|
|
os.rename(str(OLD_CONF), str(backup / 'original-html-active.conf'))
|
|
files_switched = True
|
|
os.rename(str(stage), str(GO_CONF))
|
|
run('/www/server/nginx/sbin/nginx', '-t')
|
|
new_config = dict(project['project_config'])
|
|
new_config['domains'] = [DOMAIN + ':80']
|
|
new_config['bind_extranet'] = 1
|
|
with sqlite3.connect(str(SITE_DB), timeout=10) as db:
|
|
db.execute('BEGIN IMMEDIATE')
|
|
assert db.execute('SELECT pid FROM domain WHERE id=?', (domain_rows[0]['id'],)).fetchone()[0] == html_site['id']
|
|
db.execute('UPDATE domain SET pid=? WHERE id=? AND pid=?',
|
|
(project['id'], domain_rows[0]['id'], html_site['id']))
|
|
db.execute('UPDATE sites SET project_config=?, ps=? WHERE id=? AND project_type=?',
|
|
(json.dumps(new_config), '星遇 IM · im.bchongw.com · 管理端 /admin/ · H5 /app/', project['id'], 'Go'))
|
|
# Merge the now-redundant HTML project record only. All its files,
|
|
# release symlinks, certificate links, and backups remain intact.
|
|
db.execute('DELETE FROM sites WHERE id=? AND name=? AND project_type=?',
|
|
(html_site['id'], DOMAIN, 'html'))
|
|
metadata_changed = True
|
|
run('/etc/init.d/nginx', 'reload')
|
|
verify()
|
|
public.WriteLog('项目管理', '域名 im.bchongw.com、HTTPS、管理端和 H5 网关已合并至 Go 项目 xingyu_im;备份:' + str(backup))
|
|
print('DOMAIN_MIGRATION_BACKUP=' + str(backup), flush=True)
|
|
except Exception:
|
|
if metadata_changed:
|
|
with sqlite3.connect(str(SITE_DB), timeout=10) as db:
|
|
db.execute('BEGIN IMMEDIATE')
|
|
db.execute('UPDATE domain SET pid=? WHERE id=?', (html_site['id'], domain_rows[0]['id']))
|
|
db.execute('UPDATE sites SET project_config=?, ps=? WHERE id=?',
|
|
(original_go['project_config'], original_go['ps'], project['id']))
|
|
keys = list(html_site)
|
|
db.execute('INSERT INTO sites ({}) VALUES ({})'.format(','.join(keys), ','.join('?' for _ in keys)),
|
|
[html_site[k] for k in keys])
|
|
if files_switched:
|
|
if GO_CONF.exists():
|
|
os.rename(str(GO_CONF), str(backup / 'failed-go.conf'))
|
|
os.rename(str(backup / 'original-html-active.conf'), str(OLD_CONF))
|
|
run('/www/server/nginx/sbin/nginx', '-t')
|
|
run('/etc/init.d/nginx', 'reload')
|
|
print('ROLLED_BACK; backup=' + str(backup), flush=True)
|
|
raise
|
|
|
|
|
|
if __name__ == '__main__':
|
|
assert os.geteuid() == 0 and PANEL.is_dir()
|
|
os.chdir(str(PANEL))
|
|
sys.path.insert(0, str(PANEL))
|
|
sys.path.insert(0, str(PANEL / 'class'))
|
|
import public
|
|
import panelSite
|
|
from projectModel.goModel import main as GoProject
|
|
action = sys.argv[1] if len(sys.argv) > 1 else 'verify'
|
|
if action not in ('migrate', 'verify'):
|
|
raise SystemExit('Usage: baota_bind_go_domain.py [migrate|verify]')
|
|
{'migrate': migrate, 'verify': verify}[action]()
|