# -*- coding: utf-8 -*- r'''在一台服务器上加一个新网站(nginx 虚拟主机 + 证书)。 == 为什么需要这个 == 一台服务器上跑多个网站是常态。做法是:**一个 nginx 占 80/443, 每个域名一个 server 块**,nginx 按 Host 头分流到各自的本地端口。 手写这套配置每次都要复制粘贴改半天,还容易漏: * 忘了签证书 -> 浏览器报不安全 * 后端端口写错 -> 502 * 忘了把后端绑到 127.0.0.1 -> 服务直接暴露在公网 所以做成一键的。 == 用法 == python _build/add_site.py 域名 后端地址 [--php] [--root 目录] 例: # 反代到一个本地服务 python _build/add_site.py blog.xxmxxs.top 127.0.0.1:9000 # WordPress 那种 PHP 站(走 php-fpm 的 unix socket 或端口) python _build/add_site.py xxmxxs.top 127.0.0.1:9000 --php --root /opt/1panel/www/sites/xxmxxs.top/index == 设计上的几个注意 == 1. **默认只回源 127.0.0.1** 后端服务必须只绑本地。写 0.0.0.0 的话,别人直接打 IP:端口 就绕过 nginx 和 Cloudflare 了 —— 那正是用户不想要的。 2. **证书用 certbot 的 nginx 插件签** 它自己改配置、自己加 443。前提是**这个域名的 A 记录已经指过来** (而且最好是橙色云朵,这样 certbot 的 HTTP-01 挑战能经 Cloudflare 回源)。 3. **不动别的站点** 只新建 /etc/nginx/sites-available/<域名>,再链到 sites-enabled。 已有的站点(比如遥控中转 ykxmz)一个字都不碰。 4. **默认 server 的坑** 如果有人用 IP 直接访问,nginx 会用**第一个** server 块回应。 所以配置文件开头有个显式的 default server 把它们挡掉 —— 这样 IP:port 扫不到任何真实站点。 ''' from __future__ import annotations import argparse import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT / "_build")) from lssh import run, upload # noqa: E402 TEMPLATE = """# {domain} —— 由 _build/add_site.py 生成 # # 后端: {backend} # 说明: nginx 按 server_name 分流;后端只应该监听 127.0.0.1, # 这样外面只能通过域名(也就是经过 nginx + Cloudflare)访问。 server {{ listen 80; listen [::]:80; server_name {domain}; access_log /var/log/nginx/{safe}_access.log; error_log /var/log/nginx/{safe}_error.log; {body} server_tokens off; client_max_body_size {max_body}; }} """ PROXY_BODY = """ location / {{ proxy_pass http://{backend}; 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_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 300s; proxy_connect_timeout 30s; }} """ PHP_BODY = """ root {root}; index index.php index.html; location / {{ try_files $uri $uri/ /index.php?$args; }} location ~ \\.php$ {{ include snippets/fastcgi-php.conf; fastcgi_pass {backend}; fastcgi_read_timeout 300s; }} location ~ /\\.ht {{ deny all; }} """ def main() -> int: ap = argparse.ArgumentParser(description="加一个网站") ap.add_argument("domain", help="域名,例如 blog.xxmxxs.top") ap.add_argument("backend", help="后端地址,例如 127.0.0.1:9000") ap.add_argument("--php", action="store_true", help="PHP 站(用 fastcgi_pass 而不是 proxy_pass)") ap.add_argument("--root", default="", help="PHP 站的根目录") ap.add_argument("--max-body", default="64m", help="上传大小上限,默认 64m") ap.add_argument("--no-cert", action="store_true", help="只配 80,先不签证书") args = ap.parse_args() domain = args.domain.strip() backend = args.backend.strip() safe = domain.replace(".", "_").replace("-", "_") # ⚠ 后端必须是本地地址。写 0.0.0.0 或公网地址的话, # 等于把后端服务直接暴露出去,nginx 和 Cloudflare 都白搭。 if not (backend.startswith("127.0.0.1:") or backend.startswith("localhost:") or backend.startswith("unix:")): print(" ⚠ 后端 %r 不是本地地址。" % backend) print(" 后端必须只监听 127.0.0.1 —— 否则别人直接打 " "IP:端口就绕过了 nginx 和 Cloudflare。") print(" 要继续的话请显式确认(现在中止)。") return 1 if args.php and not args.root: print(" ✗ --php 需要同时给 --root(网站文件目录)") return 1 body = (PHP_BODY.format(root=args.root, backend=backend) if args.php else PROXY_BODY.format(backend=backend)) conf = TEMPLATE.format(domain=domain, backend=backend, safe=safe, body=body, max_body=args.max_body) print("=" * 58) print(" 加站点 %s -> %s" % (domain, backend)) print("=" * 58) print() print(" ── 1. 写配置 ──") import tempfile tmp = Path(tempfile.gettempdir()) / ("nginx-%s.conf" % safe) tmp.write_text(conf, encoding="utf-8", newline="\n") upload(str(tmp), "/tmp/%s.conf" % safe) out = run( "sudo mv /tmp/{s}.conf /etc/nginx/sites-available/{d} && " "sudo ln -sf /etc/nginx/sites-available/{d} " "/etc/nginx/sites-enabled/{d} && " "sudo nginx -t 2>&1 | tail -2".format(s=safe, d=domain), timeout=180, quiet=True) for ln in out[1].strip().splitlines(): print(" " + ln) if "successful" not in out[1]: print(" ✗ nginx 配置检查没过,撤回") run("sudo rm -f /etc/nginx/sites-enabled/%s" % domain, timeout=60, quiet=True) return 1 print() print(" ── 2. 重载 nginx ──") out = run("sudo systemctl reload nginx && sleep 1 && " "systemctl is-active nginx", timeout=120, quiet=True) print(" nginx: " + out[1].strip()) if not args.no_cert: print() print(" ── 3. 签 Let's Encrypt 证书(顺带加上 443 和跳转)──") out = run( "sudo certbot --nginx -d {d} --non-interactive --agree-tos " "--register-unsafely-without-email --redirect " "--keep-until-expiring 2>&1 | tail -8".format(d=domain), timeout=600, quiet=True) for ln in out[1].strip().splitlines(): print(" " + ln) print() print(" ── 4. 复核 ──") # ⚠ 这里**不能用 .format()** —— 远端命令里有 curl 的 `%{http_code}`, # 而 .format() 会把 `{http_code}` 当占位符,抛 # "KeyError/IndexError: http_code"。 # 和之前用 % 格式化踩的是**同一类坑**(内容里有格式符)。 # 凡是把域名等变量拼进含 `{...}` 的远端命令,一律用 replace。 probe = ( 'printf " %-34s %s\\n" "本机带 Host 头" ' '"$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 10 ' '-H \'Host: __D__\' http://127.0.0.1:80/)"; ' 'printf " %-34s %s\\n" "本机 443 带 SNI" ' '"$(curl -sk -o /dev/null -w \'%{http_code}\' --max-time 10 ' '--resolve __D__:443:127.0.0.1 https://__D__/)"; ' 'printf " %-34s %s\\n" "公网 443" ' '"$(curl -s -o /dev/null -w \'%{http_code}\' --max-time 20 ' 'https://__D__/)"' ).replace("__D__", domain) out = run(probe, timeout=180, quiet=True) for ln in out[1].strip().splitlines(): print(ln) print() print(" 已加好。注意:") print(" * 这个域名的 DNS 要指向这台服务器(Cloudflare 橙色云朵)") print(" * 后端 %s 必须只监听 127.0.0.1" % backend) print(" * 不要动 /etc/nginx/sites-available/ykxmz(那是遥控中转)", ) return 0 if __name__ == "__main__": sys.exit(main())