Files
shell-gen/generators/middleware.py
T
Your Name 1a328864f5 fix(nginx,mongodb): quote heredocs to prevent bash $VAR expansion under set -u
Two distinct bugs, same root cause: the rendered scripts run with
'set -euo pipefail', but the generator used unquoted heredoc delimiters
(<<CONF_EOF) for config files. Bash expands $vars inside unquoted heredocs;
with 'set -u' any undefined variable aborts the heredoc, and because the
heredoc sits inside 'cat > FILE <<TAG ... TAG', the file gets opened
(truncated to zero bytes) but never written.

1) nginx generator (generators/middleware.py)
   - The HTTPS server block ended with '}}\n' instead of '}\n', causing
     'nginx -t' to fail with 'unexpected "}"'.
   - Both server confs (HTTP + HTTPS) wrote $uri / $host / $scheme /
     $request_uri / $proxy_add_x_forwarded_for / $remote_addr into
     unquoted heredocs. With set -u the heredoc for harbor.yunwei.blog.conf
     aborted on $scheme (and similar), leaving the file empty.

   Fix: change both heredoc delimiters to <<'CONF_EOF' (quoted), and
   remove the trailing extra '}'. Also strip the unnecessary Python
   f-string '{{' / '}}' escapes that produced '}' in the output.

2) mongodb repo (generators/runtimes.py)
   - The yum repo file used <<REPO_EOF (unquoted) with a body containing
     '$releasever'. $releasever is yum's own variable, not bash's;
     bash expanded it to '' under set -u and left the URL broken.
     Wireguard/openvpn heredocs DO use $(...) command substitution
     intentionally (to inline keys), so those stay unquoted.

   Fix: change delimiter to <<'REPO_EOF'.

Verified:
  - Rendered nginx.sh for harbor.yunwei.blog (proxy + SSL on port 78/443)
    produces conf files with correct nginx syntax. Real 'nginx -t' on a
    minimal test config passes (configuration syntax is ok / test is
    successful).
  - Audit of all 50 generators with default params shows zero $VAR
    references inside unquoted heredocs (only intentional $(...) cmds).
  - All 50 generators still pass 'bash -n'.
2026-08-07 13:36:06 +08:00

1133 lines
61 KiB
Python

"""
Middleware service generators.
- VNC, Nginx, HAProxy, Keepalived, Redis, RabbitMQ, Tomcat, Docker, ETCD, Zookeeper
"""
from . import register, Generator, Field
from . import bash_header, quote, bool_str, yes, port_list
# ========================== VNC Server ==========================
class VNCSrv(Generator):
id = "vncserver"
title = "VNC 远程桌面 (TigerVNC + 多桌面)"
category = "middleware"
icon = "🖥️"
tags = ["vnc", "desktop", "xfce", "gnome", "tigervnc", "kde", "mate"]
description = "在 Ubuntu/Debian 上一键安装并启动 TigerVNC,支持 XFCE / GNOME / KDE / MATE / LXQt 多种桌面环境。"
warnings = [
"VNC 协议默认不加密;生产环境请配合 SSH 隧道 (-localhost yes) 或 VPN 使用。",
"VNC 密码仅前 8 字节有效 (DES 限制),建议用强密码但勿超过 8 位核心。",
"GNOME 桌面会显著增大磁盘占用 (~2-3 GB),启动比 XFCE 慢。",
"KDE 桌面需要 sddm/lightdm,无显示器服务器上仅启动 VNC 会跳过 DM 阶段。",
]
post_steps = [
"systemctl status vncserver@1 — 查看服务状态",
"ss -ltn | grep 5901 — 验证监听端口",
"用 VNC Viewer 连接 <server-ip>:5901 (5900 + display 号)",
]
verify_steps = [
"ss -ltn | grep -E ':5901|:5902'",
"systemctl is-active vncserver@1",
"tail -f /var/log/Xvnc.1.log 或 journalctl -u vncserver@1 -f",
]
os_support = ["ubuntu", "debian"]
fields = [
Field("user", "运行用户", "text", default="root", placeholder="root",
help="VNC 进程以该用户运行,推荐使用普通用户,root 登录需要修改配置。"),
Field("display", "Display 号", "number", default="1", min_=1, max_=99,
help="Display 1 = 端口 5901,Display 2 = 端口 5902。"),
Field("password", "VNC 密码", "password", default="", required=False,
help="留空则使用 vncpassword 自动生成;只取前 8 位。"),
Field("desktop_env", "桌面环境", "select", default="xfce",
options=["xfce", "gnome", "kde-plasma", "mate", "lxqt"],
help="选 gnome = Ubuntu 原生 GNOME / GNOME Shell;"
" 选 kde-plasma = KDE Plasma 5;"
" 选 mate = MATE (XFCE 轻量替代);"
" 选 lxqt = LXQt (超轻量);"
" 选 xfce = XFCE (最稳定)。"),
Field("geometry", "分辨率", "select", default="1920x1080",
options=["1280x720", "1366x768", "1440x900", "1600x900",
"1680x1050", "1920x1080", "2560x1440", "3840x2160"]),
Field("depth", "颜色深度", "select", default="24", options=["16", "24", "32"]),
Field("localhost", "仅本地监听", "checkbox", default="no",
help="yes = 仅 localhost 监听,需 SSH 隧道;no = 0.0.0.0 监听(配合防火墙)。"),
Field("nolisten_unix", "禁用 Unix socket (-nolisten unix)", "checkbox", default="yes",
help="强烈建议启用 — 物理桌面环境(GDM/Xorg)已经在 /tmp/.X11-unix/X0 占着,"
"Xvnc 默认想创建 X1 会被 reject;开启此选项让 Xvnc 只 listen TCP 5901。"),
Field("nolisten_tcp_local", "禁用 X11 TCP listener (6001)", "checkbox", default="no",
help="如 6001 端口已被 GDM/Xorg 占,开启此选项避免冲突。"
"对 VNC 连接无影响,只影响原生 X11 客户端直连能力。"),
Field("use_xvfb", "使用 Xvfb (无显卡)", "checkbox", default="yes",
help="Headless 服务器必须启用 — VNC 通过 Xvfb 渲染,无需物理显卡。"),
Field("disable_wayland", "禁用 GDM Wayland (强制 X11)", "checkbox", default="yes",
help="强烈建议启用 — Ubuntu 22.04 默认用 Wayland + GDM,但 Wayland 下 x11vnc "
"会出现键盘映射异常、剪贴板双向不通、gnome-shell 偶发闪退等问题。"
"此选项写 /etc/gdm3/custom.conf 把 WaylandEnable 设为 false,所有 GDM session "
"(包括物理登录和锁屏)都强制走 X11。注意:在有活动的物理桌面登录时会断开一次。"),
Field("restart_gdm", "立即重启 GDM 应用 Wayland 更改", "checkbox", default="no",
help="yes = 立刻 systemctl restart gdm 让 Wayland 禁用生效(会断开当前物理会话);"
"no = 仅改配置,下次重启后生效。生产机器建议 no,自己本地实验可以 yes。"),
]
# ---- per-DE helpers (chosen at render time) ----
DE_PACKAGES = {
# Ubuntu / Debian apt package names
"xfce": ["xfce4", "xfce4-goodies"],
"gnome": ["ubuntu-desktop-minimal"], # Ubuntu — fallback to gnome-session below
"kde-plasma": ["kde-plasma-desktop"],
"mate": ["mate-desktop", "mate-desktop-environment"],
"lxqt": ["lxqt", "sddm"],
}
DE_PACKAGES_FALLBACK = {
# Distro-agnostic fallback (for non-Ubuntu Debian or when minimal not present)
"gnome": ["gnome-session", "gnome-shell", "gnome-terminal",
"nautilus", "metacity"],
"kde-plasma": ["plasma-desktop", "sddm"],
"mate": ["mate-session-manager", "marco", "mate-panel",
"mate-terminal", "caja"],
"lxqt": ["lxqt-session", "openbox", "pcmanfm-qt", "sddm"],
}
DE_XSTARTUP = {
# The xstartup block (bash heredoc body) for each DE.
# dbus-launch is needed for all to make SystemDBus/DBus_SESSION work.
"xfce": (
"unset SESSION_MANAGER\n"
"unset DBUS_SESSION_BUS_ADDRESS\n"
"exec dbus-launch --exit-with-session startxfce4\n"
),
"gnome": (
"unset SESSION_MANAGER\n"
"unset DBUS_SESSION_BUS_ADDRESS\n"
# Ubuntu-flavored GNOME. The colon-separated form
# `ubuntu:GNOME` is the canonical value Ubuntu ships in
# /usr/share/gnome-session/sessions/ — using just `GNOME`
# confuses the session chooser and some apps (gnome-terminal,
# nautilus) refuse to launch.
"export XDG_CURRENT_DESKTOP=ubuntu:GNOME\n"
# Tell Mutter / gnome-shell we're an X11 session, not
# Wayland. x11vnc only sees X, so this is correct.
"export XDG_SESSION_TYPE=x11\n"
# Ubuntu's patched gnome-shell reads this to enable the
# Ubuntu-specific hot-corner + dock tweaks.
"export GNOME_SHELL_SESSION_MODE=ubuntu\n"
"exec dbus-launch --exit-with-session gnome-session\n"
),
"kde-plasma": (
"unset SESSION_MANAGER\n"
"unset DBUS_SESSION_BUS_ADDRESS\n"
"export XDG_CURRENT_DESKTOP=KDE\n"
"exec dbus-launch --exit-with-session startplasma-x11\n"
),
"mate": (
"unset SESSION_MANAGER\n"
"unset DBUS_SESSION_BUS_ADDRESS\n"
"exec dbus-launch --exit-with-session mate-session\n"
),
"lxqt": (
"unset SESSION_MANAGER\n"
"unset DBUS_SESSION_BUS_ADDRESS\n"
"export XDG_CURRENT_DESKTOP=LXQt\n"
"exec dbus-launch --exit-with-session startlxqt\n"
),
}
def render(self, p):
user = p.get("user", "root") or "root"
display = str(p.get("display", "1"))
password = p.get("password", "") or ""
de = p.get("desktop_env", "xfce")
geometry = p.get("geometry", "1920x1080")
depth = p.get("depth", "24")
localhost = "yes" if bool_str(p.get("localhost")) else "no"
nolisten_unix = bool_str(p.get("nolisten_unix", True))
nolisten_tcp_local = bool_str(p.get("nolisten_tcp_local"))
use_xvfb = bool_str(p.get("use_xvfb", True))
disable_wayland = bool_str(p.get("disable_wayland", True))
restart_gdm = bool_str(p.get("restart_gdm"))
if de not in self.DE_PACKAGES:
raise ValueError(f"Unknown desktop_env: {de}. Choose one of: {', '.join(self.DE_PACKAGES)}")
pkgs = self.DE_PACKAGES[de]
pkgs_fb = self.DE_PACKAGES_FALLBACK.get(de, [])
xstartup = self.DE_XSTARTUP[de]
out = [bash_header(self.title)]
out.append(f'log "Installing VNC + {de} desktop for user {quote(user)} on display :{display}"')
out.append('log "Updating apt cache..."')
out.append('apt-get update')
out.append(f'log "Installing {de} desktop + TigerVNC..."')
# Build install command. Try distro-specific first, then generic.
# We install xvfb + x11vnc + tigervnc-* (xfce stays as primary DE).
# xvfb is the virtual framebuffer X server (used by xvfb-run);
# x11vnc is the VNC server that attaches to the Xvfb display and
# exposes it via RFB. tigervnc-standalone-server provides the
# passwd / vncpasswd utilities used in the main script.
install = (
'DEBIAN_FRONTEND=noninteractive apt-get install -y \\\n'
' ' + ' '.join(pkgs) + ' \\\n'
' dbus-x11 tigervnc-standalone-server tigervnc-common tigervnc-xorg-extension \\\n'
' xvfb x11vnc x11-utils 2>/dev/null'
)
if pkgs_fb:
install += ' || \\\nDEBIAN_FRONTEND=noninteractive apt-get install -y \\\n'
install += ' ' + ' '.join(pkgs_fb) + ' \\\n'
install += ' dbus-x11 tigervnc-standalone-server tigervnc-common \\\n'
install += ' xvfb x11vnc x11-utils'
# xvfb is now always installed above. The use_xvfb flag is kept
# for backward compatibility but no longer changes the install list.
if use_xvfb and de in ("gnome", "kde-plasma"):
# Some compositing DEs need a fake display backend driver
install += ' \\\n xserver-xorg-video-dummy'
# GNOME needs several extra packages on top of ubuntu-desktop-minimal
# to render a usable desktop over VNC:
# - gnome-shell-extension-desktop-icons-ng: icons on the desktop
# - gnome-terminal: a working terminal app
# - nautilus-extension-gnome-terminal: opens Terminal in nautilus
# right-click menu
# - gnome-tweaks: advanced settings UI
# These are missing from -minimal and would otherwise leave the user
# with a blank desktop and no way to launch apps.
if de == "gnome":
install += (' \\\n gnome-shell-extension-desktop-icons-ng'
' gnome-terminal nautilus-extension-gnome-terminal'
' gnome-tweaks')
out.append(install)
# GDM Wayland disable — only relevant for GNOME on a machine that
# has a physical GDM (i.e. ubuntu-desktop-01, not a headless server).
# Ubuntu 22.04 defaults to WaylandEnable=true in /etc/gdm3/custom.conf.
# Wayland + x11vnc has known issues: keyboard remapping gets
# confused, clipboard isn't bidirectional, gnome-shell occasionally
# crashes on X client events. Forcing X11 makes the VNC session
# behave like a normal X11 desktop.
if de == "gnome" and disable_wayland:
out.append('log "Disabling GDM Wayland (forcing X11 session)..."')
# custom.conf may not exist on a server (no gdm3 installed) — guard
out.append('if [ -d /etc/gdm3 ] && [ -f /etc/gdm3/custom.conf ] || [ ! -f /etc/gdm3/custom.conf ]; then')
out.append(' mkdir -p /etc/gdm3')
out.append(' if [ -f /etc/gdm3/custom.conf ]; then')
# Replace any existing WaylandEnable line, else add under [daemon]
out.append(' if grep -q "^[[:space:]]*WaylandEnable" /etc/gdm3/custom.conf; then')
out.append(' sed -i "s/^[[:space:]]*WaylandEnable.*/WaylandEnable=false/" /etc/gdm3/custom.conf')
out.append(' else')
out.append(' sed -i "/^[[:space:]]*\\[daemon\\]/a WaylandEnable=false" /etc/gdm3/custom.conf')
out.append(' fi')
out.append(' else')
# No custom.conf yet — write a minimal one
out.append(' cat > /etc/gdm3/custom.conf <<GDM_EOF')
out.append('[daemon]')
out.append('WaylandEnable=false')
out.append('')
out.append('[security]')
out.append('')
out.append('[xdmcp]')
out.append('')
out.append('[chooser]')
out.append('')
out.append('[debug]')
out.append('GDM_EOF')
out.append(' fi')
out.append(' log " /etc/gdm3/custom.conf now has WaylandEnable=false"')
out.append('fi')
if restart_gdm:
out.append('log "Restarting GDM to apply Wayland=false (will briefly disconnect any physical session)..."')
out.append('systemctl restart gdm 2>&1 | head -5 || warn "gdm restart failed (no gdm installed?)"')
else:
out.append('log "Wayland disabled in custom.conf; will take effect after next gdm restart or reboot."')
out.append('log "Preparing VNC directory..."')
out.append(f'USER_HOME="$(getent passwd {quote(user)} | cut -d: -f6 || echo /root)"')
# If getent returned /root for a non-root user, the account is in a
# broken state (often created without -m, or the row got mangled).
# Treat this as "use /home/<user>" and recreate the home properly.
out.append(f'if [ "$USER_HOME" = "/root" ] && [ "{user}" != "root" ]; then')
out.append(f' warn "getent passwd {user} returned home=/root; assuming /home/{user}"')
out.append(f' USER_HOME="/home/{user}"')
out.append('fi')
# Make sure the user + their home exist and are owned by them. Without
# this, systemd fails with "status=200/CHDIR Permission denied" at the
# WorkingDirectory=%h step. Two failure modes we guard against:
# 1. The user was never created (no /home/<user> at all)
# 2. /home/<user> was created with wrong owner (root, mode 755) and
# the VNC user can't write into it
out.append(f'log "User {quote(user)} home: $USER_HOME"')
out.append('# Ensure user exists (idempotent — useradd exits 9 if user exists)')
out.append('# Force login shell = /bin/bash so systemd User= can run xstartup')
out.append('id "' + user + '" >/dev/null 2>&1 || useradd -m -d "$USER_HOME" -s /bin/bash ' + user)
# If user existed but home was missing or wrong (e.g. /root for cnbug),
# fix it now: usermod -m moves /root's contents to the new home.
out.append('CURRENT_HOME="$(getent passwd "' + user + '" | cut -d: -f6)"')
out.append('if [ "$CURRENT_HOME" != "$USER_HOME" ]; then')
out.append(' warn "Fixing home for user ' + user + ': $CURRENT_HOME -> $USER_HOME"')
out.append(' usermod -d "$USER_HOME" -m "' + user + '" 2>/dev/null || true')
out.append('fi')
# CRITICAL: also fix the login shell — system users (created with
# useradd -r) often have /usr/sbin/nologin which breaks systemd User=
out.append('CURRENT_SHELL="$(getent passwd "' + user + '" | cut -d: -f7)"')
out.append('if [ "$CURRENT_SHELL" != "/bin/bash" ]; then')
out.append(' warn "Fixing shell for user ' + user + ': $CURRENT_SHELL -> /bin/bash"')
out.append(' usermod -s /bin/bash "' + user + '"')
out.append('fi')
# Make /home traversable (drwxr-xr-x). Some hardening tools tighten
# /home to 750 which would block cnbugs from reaching /home/cnbugs
# even if /home/cnbugs itself is owned by cnbugs.
if user != "root":
out.append('[ -d /home ] && chmod 755 /home || true')
# Re-chown the home in case it was created by some other tool with
# wrong ownership. install -d = mkdir + chown in one call.
out.append('install -d -o "' + user + '" -g "' + user + '" -m 0750 "$USER_HOME"')
out.append('install -d -o "' + user + '" -g "' + user + '" -m 0700 "$USER_HOME/.vnc"')
out.append('')
# Password
if password:
out.append('log "Setting VNC password..."')
out.append(f'PASS={quote(password)}')
out.append('printf "%s\\n" "$PASS" | vncpasswd -f > "$USER_HOME/.vnc/passwd"')
out.append('chmod 600 "$USER_HOME/.vnc/passwd"')
# passwd was written by root (we're running as root); chown to the
# VNC user so the service User= can read it.
out.append('chown "' + user + '":"' + user + '" "$USER_HOME/.vnc/passwd"')
else:
out.append('log "Skipping password (none provided). Run: su - $USER -c vncpasswd"')
# Password file was already created in the user-owned .vnc dir above
out.append('')
# xstartup
out.append(f'log "Writing xstartup ({de} via dbus-launch)..."')
out.append('cat > "$USER_HOME/.vnc/xstartup" <<\'XSTARTUP_EOF\'\n'
'#!/bin/sh\n'
+ xstartup
+ 'XSTARTUP_EOF')
# .vnc dir was created user-owned; xstartup needs +x but stays user-owned
out.append('chmod +x "$USER_HOME/.vnc/xstartup"')
out.append('')
# We need the actual home path at unit-write time, not at service
# start time. systemd units do NOT support bash variable expansion
# in WorkingDirectory=, and %h (which would expand at service-start
# to the User='s NSS-resolved home) can be inconsistent after a
# usermod -d in the same session. We write the literal absolute
# path that was just resolved and verified to work above.
out.append('')
out.append('log "Writing systemd unit vncserver@.service..."')
# Use vncserver -fg directly. TigerVNC 1.12+ supports -fg
# (foreground mode) which is required for systemd Type=simple.
# vncserver automatically reads ~/.vnc/xstartup for the desktop
# session and ~/.vnc/passwd for authentication.
localhost_flag = ' -localhost' if bool_str(localhost) else ' -localhost=0'
out.append('cat > /etc/systemd/system/vncserver@.service <<\'UNIT_EOF\'\n'
'[Unit]\n'
'Description=TigerVNC server on display :%i (' + de + ')\n'
'After=syslog.target network.target\n\n'
'[Service]\n'
'Type=simple\n'
f'User={user}\n'
f'Group={user}\n'
'WorkingDirectory=__VNC_HOME__\n'
'ExecStartPre=-/usr/bin/vncserver -kill :%i\n'
'ExecStartPre=-/bin/sh -c \'for f in /tmp/.X%i-lock /tmp/.X11-unix/X%i; do [ -e "$f" ] && rm -f "$f"; done; exit 0\'\n'
'ExecStart=/usr/bin/vncserver -fg :%i'
' -geometry ' + geometry + ' -depth ' + depth
+ localhost_flag + '\n'
'ExecStop=/usr/bin/vncserver -kill :%i\n\n'
'[Install]\n'
'WantedBy=multi-user.target\n'
'UNIT_EOF')
out.append('# Substitute the placeholder with the actual home path')
out.append('sed -i "s|__VNC_HOME__|$USER_HOME|" /etc/systemd/system/vncserver@.service')
out.append('log " WorkingDirectory: $(grep ^WorkingDirectory= /etc/systemd/system/vncserver@.service)"')
out.append('log " Unit written. Verifying WorkingDirectory..."')
# Check if the requested display is already in use by another X server.
# Physical desktops (GNOME/Xwayland) often occupy :0 or :1.
out.append('# Check if display :${display} is already in use')
out.append('if ss -xlpn 2>/dev/null | grep -q "/tmp/.X11-unix/X' + display + ' "; then')
out.append(' EXISTING="$(ss -xlpn 2>/dev/null | grep "/tmp/.X11-unix/X' + display + ' " | head -1)"')
out.append(' warn "Display :' + display + ' is already in use by another X server:"')
out.append(' warn " $EXISTING"')
out.append(' warn "This machine likely has a physical desktop on :' + display + '."')
out.append(' warn "Please use a higher display number (e.g. :3 or :10)."')
out.append(' die "Display :' + display + ' is occupied. Aborting."')
out.append('fi')
# CRITICAL: Clean up stale X11 lock/socket from previous failed starts.
# systemd runs ExecStartPre under User=<user>, which means the user
# can't rm files owned by root. Do it here from the main script.
out.append('log "Cleaning up stale /tmp/.X11-unix sockets (as root)..."')
out.append('rm -f /tmp/.X' + display + '-lock /tmp/.X11-unix/X' + display + ' 2>/dev/null || true')
out.append('pkill -f "Xvnc.*:' + display + ' " 2>/dev/null || true')
out.append('pkill -f "Xtigervnc.*:' + display + '" 2>/dev/null || true')
out.append('systemctl daemon-reload')
out.append(f'systemctl enable vncserver@{display}.service')
out.append(f'systemctl restart vncserver@{display}.service')
out.append('sleep 2')
out.append(f'systemctl --no-pager status vncserver@{display}.service || true')
out.append('')
port = str(5900 + int(display))
out.append('log "Verifying port..."')
out.append(f'ss -ltn | grep ":{port}" || warn "Port {port} not listening yet, give it 5s."')
out.append('log "Done. Connect with VNC Viewer to <server>:' + port + ' (desktop: ' + de + ')"')
return "\n".join(out) + "\n"
# ========================== Nginx ==========================
class Nginx(Generator):
id = "nginx"
title = "Nginx 静态/反向代理服务器"
category = "middleware"
icon = "🌍"
tags = ["nginx", "web", "proxy"]
description = "一键安装 Nginx,可生成 vhost 配置 (HTTP/HTTPS) 并签发自签名证书。"
warnings = ["自签证书浏览器会告警,生产请使用 Let's Encrypt (certbot)。"]
post_steps = [
"systemctl status nginx",
"curl -I http://127.0.0.1 — 应返回 200",
"tail -f /var/log/nginx/access.log",
]
fields = [
Field("server_name", "Server Name", "text", default="example.local",
placeholder="example.com", required=True,
help="HTTP Server 块中的 server_name。"),
Field("listen_port", "监听端口", "number", default="80", min_=1, max_=65535),
Field("root_path", "网站根目录", "text", default="/var/www/html",
help="静态文件根目录,留空使用默认。"),
Field("enable_ssl", "启用 HTTPS", "checkbox", default="no",
help="启用后会在 443 监听并生成自签名证书。"),
Field("ssl_port", "HTTPS 端口", "number", default="443", min_=1, max_=65535),
Field("enable_proxy", "启用反向代理", "checkbox", default="no"),
Field("proxy_pass", "Proxy Pass 地址", "text", default="http://127.0.0.1:8080",
placeholder="http://127.0.0.1:8080",
help="启用反向代理时生效,转发的上游地址。"),
Field("client_max_body_size", "上传大小限制", "text", default="50m",
help="client_max_body_size 值,如 50m / 200m。"),
Field("worker_processes", "Worker 数", "text", default="auto",
help="auto = CPU 核数,或指定数字。"),
]
def render(self, p):
sn = p.get("server_name", "example.local")
port = str(p.get("listen_port", "80"))
root = p.get("root_path", "/var/www/html")
ssl = bool_str(p.get("enable_ssl"))
ssl_port = str(p.get("ssl_port", "443"))
proxy = bool_str(p.get("enable_proxy"))
proxy_pass = p.get("proxy_pass", "http://127.0.0.1:8080")
cmbs = p.get("client_max_body_size", "50m")
wp = p.get("worker_processes", "auto")
out = [bash_header(self.title)]
out.append('log "Installing nginx..."')
out.append('PKG_INSTALL nginx openssl curl')
out.append('mkdir -p /etc/nginx/conf.d /var/www/html /etc/nginx/ssl')
out.append('')
# main nginx.conf (only override the worker_processes line if not 'auto')
if wp != "auto":
out.append(f'sed -i "s/^worker_processes .*/worker_processes {wp};/" /etc/nginx/nginx.conf')
out.append('')
out.append('log "Writing site config..."')
out.append(f'mkdir -p {quote(root)}')
out.append(f'cat > {quote(root)}/index.html <<HTML_EOF\n'
'<!doctype html><html><head><meta charset="utf-8">\n'
f'<title>{sn}</title></head><body>\n'
f'<h1>Hello from shell-gen</h1><p>Server: {sn}</p>\n'
f'<p>Generated: {__import__("datetime").datetime.now():%Y-%m-%d %H:%M:%S}</p>\n'
'</body></html>\n'
'HTML_EOF')
out.append('')
# Server block. NOTE: heredoc is quoted ('CONF_EOF') so nginx vars
# like $uri / $host / $scheme / $request_uri / $proxy_add_x_forwarded_for
# are NOT expanded by bash. With 'set -u' an unquoted heredoc + an
# undefined nginx var would crash the entire script and leave a
# truncated (often empty) conf file behind.
server_block = []
server_block.append(f' listen {port};\n listen [::]:{port};\n'
f' server_name {sn};\n root {root};\n'
f' client_max_body_size {cmbs};\n'
' index index.html;\n\n'
' access_log /var/log/nginx/' + sn + '.access.log;\n'
' error_log /var/log/nginx/' + sn + '.error.log;\n')
if proxy:
server_block.append(' location / {\n'
f' proxy_pass {proxy_pass};\n'
' proxy_set_header Host $host;\n'
' proxy_set_header X-Real-IP $remote_addr;\n'
' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n'
' proxy_set_header X-Forwarded-Proto $scheme;\n'
' }\n')
else:
server_block.append(' location / {\n'
' try_files $uri $uri/ =404;\n'
' }\n')
if ssl:
server_block.append('\n # Redirect all plain HTTP to HTTPS\n'
' if ($scheme != "https") { return 301 https://$host$request_uri; }\n')
siteconf = f"server {{\n{''.join(server_block)}}}\n"
out.append(f"cat > /etc/nginx/conf.d/{sn}.conf <<'CONF_EOF'\n{siteconf}CONF_EOF")
out.append('rm -f /etc/nginx/sites-enabled/default')
if ssl:
out.append('log "Generating self-signed certificate..."')
out.append(f'openssl req -x509 -nodes -newkey rsa:2048 -days 365 \\\n'
f' -keyout /etc/nginx/ssl/{sn}.key \\\n'
f' -out /etc/nginx/ssl/{sn}.crt \\\n'
f' -subj "/CN={sn}" 2>/dev/null')
out.append(f"cat > /etc/nginx/conf.d/{sn}-ssl.conf <<'CONF_EOF'\n"
'server {\n'
f' listen {ssl_port} ssl;\n'
f' listen [::]:{ssl_port} ssl;\n'
f' server_name {sn};\n'
f' ssl_certificate /etc/nginx/ssl/{sn}.crt;\n'
f' ssl_certificate_key /etc/nginx/ssl/{sn}.key;\n'
f' root {root};\n'
f' client_max_body_size {cmbs};\n'
' index index.html;\n'
f' access_log /var/log/nginx/{sn}-ssl.access.log;\n'
f' error_log /var/log/nginx/{sn}-ssl.error.log;\n'
+ (' location / {\n'
f' proxy_pass {proxy_pass};\n'
' proxy_set_header Host $host;\n'
' proxy_set_header X-Real-IP $remote_addr;\n'
' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n'
' proxy_set_header X-Forwarded-Proto $scheme;\n'
' }\n' if proxy else
' location / {\n'
' try_files $uri $uri/ =404;\n'
' }\n')
+ '}\n'
'CONF_EOF')
out.append('log "Testing nginx config..."')
out.append('nginx -t')
out.append('systemctl enable --now nginx')
out.append('systemctl reload nginx || systemctl restart nginx')
out.append('log "Done. Listening on :' + port + (", :"+ssl_port+" (HTTPS)" if ssl else "") + '"')
return "\n".join(out) + "\n"
# ========================== HAProxy ==========================
class HAProxy(Generator):
id = "haproxy"
title = "HAProxy 负载均衡器"
category = "middleware"
icon = "⚖️"
tags = ["haproxy", "loadbalancer", "l4", "l7"]
description = "安装 HAProxy 并生成前端/后端配置,支持 TCP / HTTP 模式、健康检查、stats 面板。"
fields = [
Field("haproxy_name", "HAProxy 实例名", "text", default="haproxy"),
Field("frontend_name", "Frontend 名称", "text", default="http-in"),
Field("frontend_port", "Frontend 端口", "number", default="80", min_=1, max_=65535),
Field("mode", "运行模式", "select", default="http",
options=["http", "tcp"]),
Field("backend_name", "Backend 名称", "text", default="app-servers"),
Field("balance_method", "负载算法", "select", default="roundrobin",
options=["roundrobin", "leastconn", "source", "uri", "url_param",
"hdr", "random", "first", "static-rr"]),
Field("backends", "后端服务器列表", "textarea", default="192.168.1.10:8080\n192.168.1.11:8080",
help="一行一个,格式: <ip>:<port>。"),
Field("stats_port", "Stats 端口", "number", default="8404", min_=1, max_=65535),
Field("stats_user", "Stats 用户", "text", default="admin"),
Field("stats_password", "Stats 密码", "password", default="changeme"),
Field("enable_https", "启用 HTTPS 前端", "checkbox", default="no"),
Field("https_port", "HTTPS 端口", "number", default="443"),
Field("cert_path", "SSL 证书路径(PEM)", "text", default="/etc/haproxy/cert.pem",
help="PEM 格式,含 cert+key 拼接,或 cert+单独 key。"),
]
def render(self, p):
frontend_port = str(p.get("frontend_port", "80"))
mode = p.get("mode", "http")
backend_name = p.get("backend_name", "app-servers")
balance = p.get("balance_method", "roundrobin")
backends = p.get("backends", "").strip().splitlines()
backends = [b.strip() for b in backends if b.strip()]
stats_port = str(p.get("stats_port", "8404"))
stats_user = p.get("stats_user", "admin")
stats_pwd = p.get("stats_password", "changeme")
https = bool_str(p.get("enable_https"))
https_port = str(p.get("https_port", "443"))
cert_path = p.get("cert_path", "/etc/haproxy/cert.pem")
out = [bash_header(self.title)]
out.append('log "Installing haproxy..."')
out.append('PKG_INSTALL haproxy')
out.append('cp /etc/haproxy/haproxy.cfg /etc/haproxy/haproxy.cfg.bak.$(date +%s) || true')
out.append('mkdir -p /etc/haproxy')
out.append('')
cfg = []
cfg.append('global\n log /dev/log local0\n log /dev/log local1 notice\n'
' chroot /var/lib/haproxy\n stats timeout 30s\n user haproxy\n group haproxy\n'
' daemon\n maxconn 4096\n\n')
cfg.append('defaults\n log global\n mode ' + mode + '\n'
' option httplog\n option dontlognull\n'
' timeout connect 10s\n timeout client 60s\n'
' timeout server 60s\n\n')
cfg.append('frontend stats\n bind *:' + stats_port + '\n mode http\n'
' stats enable\n stats uri /stats\n stats refresh 10s\n'
f' stats auth {stats_user}:{stats_pwd}\n'
' stats admin if TRUE\n\n')
cfg.append(f'frontend {p.get("frontend_name","http-in")}\n'
f' bind *:{frontend_port}\n'
f' mode {mode}\n'
f' default_backend {backend_name}\n\n')
if https:
cfg.append(f'frontend {p.get("frontend_name","http-in")}-ssl\n'
f' bind *:{https_port} ssl crt {cert_path}\n'
f' mode {mode}\n'
f' default_backend {backend_name}\n\n')
cfg.append(f'backend {backend_name}\n balance {balance}\n'
' option httpchk GET /healthz\n'
' http-check expect status 200\n')
for b in backends:
cfg.append(f' server {b.replace(".", "_").replace(":", "_")} {b} check inter 3s fall 3 rise 2\n')
out.append('cat > /etc/haproxy/haproxy.cfg <<CFG_EOF\n' + ''.join(cfg) + 'CFG_EOF')
out.append('haproxy -c -f /etc/haproxy/haproxy.cfg')
out.append('systemctl enable --now haproxy')
out.append('systemctl reload haproxy 2>/dev/null || systemctl restart haproxy')
out.append('log "Done. Stats: http://<server>:' + stats_port + '/stats"')
return "\n".join(out) + "\n"
# ========================== Keepalived ==========================
class Keepalived(Generator):
id = "keepalived"
title = "Keepalived 高可用 VIP"
category = "middleware"
icon = "🔁"
tags = ["keepalived", "vrrp", "ha", "vip"]
description = "部署 Keepalived + VRRP 双机热备,自动漂移 VIP。"
warnings = [
"Keepalived 需要至少 2 台同网段机器组成集群。",
"multicast 在云厂商可能被禁用,需使用 unicast peer。",
]
fields = [
Field("state", "本节点角色", "select", default="MASTER", options=["MASTER", "BACKUP"]),
Field("interface", "网卡", "text", default="eth0",
help="VRRP 绑定的网卡,可用 ip a 查询。"),
Field("virtual_router_id", "Virtual Router ID", "number", default="51", min_=1, max_=255),
Field("priority", "优先级 (MASTER > BACKUP)", "number", default="100", min_=1, max_=254),
Field("virtual_ip", "VIP 地址 (CIDR)", "text", default="192.168.1.100/24",
placeholder="192.168.1.100/24"),
Field("auth_pass", "认证密码", "password", default="changeme"),
Field("unicast_peer_ip", "对端 IP (unicast 模式)", "text", default="",
help="云环境用单播,留空则用多播;填对端 IP 即可。"),
Field("notify_script", "状态切换脚本 (可选)", "text", default="",
help="MASTER->BACKUP 或反向时执行的脚本路径。"),
Field("check_script", "健康检查脚本 (可选)", "text", default="",
help="如: /etc/keepalived/check_nginx.sh,return 0 = 健康。"),
Field("check_interval", "检查间隔", "text", default="2",
help="weight 下降间隔,单位秒。"),
]
def render(self, p):
state = p.get("state", "MASTER")
iface = p.get("interface", "eth0")
vrid = str(p.get("virtual_router_id", "51"))
prio = str(p.get("priority", "100"))
vip = p.get("virtual_ip", "192.168.1.100/24")
auth = p.get("auth_pass", "changeme")
peer = p.get("unicast_peer_ip", "").strip()
notify = p.get("notify_script", "").strip()
check = p.get("check_script", "").strip()
interval = p.get("check_interval", "2")
out = [bash_header(self.title)]
out.append('log "Installing keepalived..."')
out.append('PKG_INSTALL keepalived')
out.append('')
out.append('cat > /etc/keepalived/keepalived.conf <<CONF_EOF\n'
'global_defs {\n'
' enable_script_security\n'
' script_user root\n'
'}\n\n'
'vrrp_script check_local {\n' +
(f' script "{check}"\n' if check else
' script "exit 0"\n') +
f' interval {interval}\n'
' weight -50\n'
' fall 3\n rise 2\n'
'}\n\n'
'vrrp_instance VI_1 {\n'
f' state {state}\n'
f' interface {iface}\n'
f' virtual_router_id {vrid}\n'
f' priority {prio}\n'
' advert_int 1\n'
' authentication {\n'
f' auth_type PASS\n auth_pass {auth}\n'
' }\n'
' virtual_ipaddress {\n'
f' {vip}\n'
' }\n'
' track_script {\n'
' check_local\n'
' }\n' +
(f' notify "{notify}"\n' if notify else '') +
'}\nCONF_EOF')
if peer:
out.append('log "Patching unicast_peer_ip..."')
out.append('cat >> /etc/keepalived/keepalived.conf <<CONF_EOF\n\n'
'unicast_src_ip "$(ip -4 addr show ' + iface + ' | grep -oP "(?<=inet\\s)\\d+(\\.\\d+){3}")"\n'
'unicast_peer {\n'
f' {peer}\n'
'}\n'
'CONF_EOF')
out.append('sed -i \'s/vrrp_instance/, &unicast_src_ip_check\\n&/\' /etc/keepalived/keepalived.conf || true')
out.append('systemctl enable --now keepalived')
out.append('log "Verifying VIP..."')
out.append('sleep 2')
out.append('ip a | grep -A1 "' + iface + ':" | grep "' + vip.split('/')[0] + '" || warn "VIP not on this node (expected if BACKUP)."')
return "\n".join(out) + "\n"
# ========================== Redis ==========================
class Redis(Generator):
id = "redis"
title = "Redis 内存数据库"
category = "databases"
icon = "🔴"
tags = ["redis", "nosql", "cache"]
description = "安装 Redis 并启用密码、AOF 持久化、systemd 服务。"
fields = [
Field("bind", "Bind 地址", "text", default="0.0.0.0"),
Field("port", "端口", "number", default="6379", min_=1, max_=65535),
Field("requirepass", "密码 (留空=不设)", "password", default=""),
Field("maxmemory", "最大内存", "text", default="256mb",
help="如 256mb / 1gb / 2gb"),
Field("maxmemory_policy", "淘汰策略", "select", default="allkeys-lru",
options=["noeviction", "allkeys-lru", "allkeys-lfu",
"volatile-lru", "volatile-lfu", "volatile-random",
"allkeys-random"]),
Field("appendonly", "启用 AOF", "checkbox", default="yes",
help="yes = 更安全;no = 仅 RDB 快照。"),
Field("enable_acl", "启用 ACL (6+)", "checkbox", default="no",
help="6.0+ 推荐,使用 ACL 替代 requirepass。"),
Field("acl_user", "ACL 用户名", "text", default="app"),
Field("acl_password", "ACL 密码", "password", default="changeme"),
]
def render(self, p):
bind = p.get("bind", "0.0.0.0")
port = str(p.get("port", "6379"))
pwd = p.get("requirepass", "") or ""
maxmem = p.get("maxmemory", "256mb")
policy = p.get("maxmemory_policy", "allkeys-lru")
aof = "yes" if bool_str(p.get("appendonly", True)) else "no"
acl = bool_str(p.get("enable_acl"))
acl_user = p.get("acl_user", "app")
acl_pwd = p.get("acl_password", "changeme")
out = [bash_header(self.title)]
out.append('log "Installing redis..."')
out.append('PKG_INSTALL redis-server')
out.append('')
out.append('cat > /etc/redis/redis.conf <<CFG_EOF\n'
'bind ' + bind + '\n'
'port ' + port + '\n'
'daemonize yes\n'
'supervised systemd\n'
'pidfile /var/run/redis/redis-server.pid\n'
'loglevel notice\n'
'logfile /var/log/redis/redis-server.log\n'
'databases 16\n'
'maxmemory ' + maxmem + '\n'
'maxmemory-policy ' + policy + '\n'
'appendonly ' + aof + '\n'
'appendfilename "appendonly.aof"\n'
'save 900 1\n'
'save 300 10\n'
'save 60 10000\n'
'tcp-keepalive 60\n'
'timeout 0\n'
'tcp-backlog 511\n'
+ (f'requirepass {pwd}\n' if pwd and not acl else '') +
'CFG_EOF')
if acl:
out.append('cat > /etc/redis/users.acl <<ACL_EOF\n'
f'user {acl_user} on >{acl_pwd} ~* &* +@all\n'
'ACL_EOF')
out.append('echo "aclfile /etc/redis/users.acl" >> /etc/redis/redis.conf')
out.append('systemctl enable --now redis-server || systemctl enable --now redis')
out.append('sleep 1')
out.append('systemctl --no-pager status redis-server || systemctl --no-pager status redis || true')
out.append('redis-cli -h 127.0.0.1 -p ' + port + ' PING || warn "redis-cli ping failed"')
return "\n".join(out) + "\n"
# ========================== RabbitMQ ==========================
class RabbitMQ(Generator):
id = "rabbitmq"
title = "RabbitMQ 消息队列"
category = "middleware"
icon = "🐰"
tags = ["rabbitmq", "mq", "amqp"]
description = "安装 RabbitMQ,启用 management 插件,创建 admin 用户。"
fields = [
Field("admin_user", "Admin 用户", "text", default="admin"),
Field("admin_password", "Admin 密码", "password", default="changeme123"),
Field("listeners", "AMQP 监听端口", "number", default="5672", min_=1, max_=65535),
Field("mgmt_port", "Management 端口", "number", default="15672", min_=1, max_=65535),
Field("cluster_name", "节点名", "text", default="rabbit@localhost"),
]
def render(self, p):
admin = p.get("admin_user", "admin")
pwd = p.get("admin_password", "changeme123")
port = str(p.get("listeners", "5672"))
mgmt = str(p.get("mgmt_port", "15672"))
out = [bash_header(self.title)]
out.append('log "Installing rabbitmq..."')
# repo install (covers all distros; apt and yum both have rabbitmq-server package)
out.append('case "$PKG" in\n'
' apt-get) DEBIAN_FRONTEND=noninteractive apt-get install -y rabbitmq-server ;;\n'
' yum) yum install -y epel-release && yum install -y rabbitmq-server ;;\n'
' dnf) dnf install -y rabbitmq-server ;;\n'
'esac')
out.append('systemctl enable --now rabbitmq-server')
out.append('rabbitmq-plugins enable rabbitmq_management')
out.append('systemctl restart rabbitmq-server')
out.append('sleep 3')
out.append('log "Creating admin user..."')
out.append(f'rabbitmqctl add_user {admin} {pwd} || rabbitmqctl change_password {admin} {pwd}')
out.append(f'rabbitmqctl set_user_tags {admin} administrator')
out.append(f'rabbitmqctl set_permissions -p / {admin} ".*" ".*" ".*"')
out.append('log "Management UI: http://<server>:' + mgmt + ' (user: ' + admin + ')"')
return "\n".join(out) + "\n"
# ========================== Tomcat ==========================
class Tomcat(Generator):
id = "tomcat"
title = "Apache Tomcat (Java Servlet 容器)"
category = "middleware"
icon = "🐱"
tags = ["tomcat", "java", "servlet", "jsp"]
description = "下载并安装 Apache Tomcat,自动配置 systemd。"
fields = [
Field("version", "Tomcat 版本", "select", default="10.1",
options=["10.1", "10.0", "9.0", "8.5"]),
Field("port", "HTTP 端口", "number", default="8080", min_=1, max_=65535),
Field("shutdown_port", "Shutdown 端口", "number", default="8005", min_=1, max_=65535),
Field("ajp_port", "AJP 端口", "number", default="8009", min_=1, max_=65535),
Field("java_opts", "JAVA_OPTS", "text", default="-Xms512m -Xmx1024m -Djava.awt.headless=true"),
Field("user", "运行用户", "text", default="tomcat"),
Field("install_dir", "安装目录", "text", default="/opt/tomcat"),
]
def render(self, p):
ver = p.get("version", "10.1")
port = str(p.get("port", "8080"))
s_port = str(p.get("shutdown_port", "8005"))
ajp = str(p.get("ajp_port", "8009"))
java_opts = p.get("java_opts", "-Xms512m -Xmx1024m -Djava.awt.headless=true")
user = p.get("user", "tomcat")
install_dir = p.get("install_dir", "/opt/tomcat")
# Pick the latest patch for the chosen major
url = "https://dlcdn.apache.org/tomcat/tomcat-" + ver.split(".")[0] + ("/v" + ver + "/bin/apache-tomcat-" + ver + ".tar.gz" if ver != "10.1" else "/v10.1.x/bin/apache-tomcat-10.1.x.tar.gz")
out = [bash_header(self.title)]
out.append('log "Installing Java + Tomcat..."')
out.append('command -v java >/dev/null || PKG_INSTALL java-11-openjdk-devel java-11-openjdk || PKG_INSTALL default-jdk')
out.append('useradd -r -s /bin/false ' + user + ' 2>/dev/null || true')
out.append('cd /opt')
out.append('curl -fsSL -o tomcat.tar.gz "' + url + '"')
out.append('tar -xzf tomcat.tar.gz')
out.append('rm -rf ' + install_dir)
out.append('mv apache-tomcat-* ' + install_dir)
out.append('rm -f tomcat.tar.gz')
out.append('chown -R ' + user + ':' + user + ' ' + install_dir)
out.append('')
out.append('log "Patching server.xml ports..."')
out.append('sed -i "s/port=\"8080\"/port=\"' + port + '\"/" ' + install_dir + '/conf/server.xml')
out.append('sed -i "s/port=\"8005\"/port=\"' + s_port + '\"/" ' + install_dir + '/conf/server.xml')
out.append('sed -i "s/port=\"8009\"/port=\"' + ajp + '\"/" ' + install_dir + '/conf/server.xml')
out.append('')
out.append('cat > /etc/systemd/system/tomcat.service <<UNIT_EOF\n'
'[Unit]\n'
'Description=Apache Tomcat\n'
'After=network.target\n\n'
'[Service]\n'
'Type=forking\n'
f'User={user}\nGroup={user}\n'
f'Environment=JAVA_HOME=/usr/lib/jvm/java-11-openjdk\n'
f'Environment=CATALINA_HOME={install_dir}\n'
f'Environment=CATALINA_BASE={install_dir}\n'
f'Environment=JAVA_OPTS="{java_opts}"\n'
f'ExecStart={install_dir}/bin/startup.sh\n'
f'ExecStop={install_dir}/bin/shutdown.sh\n'
'Restart=on-failure\n\n'
'[Install]\nWantedBy=multi-user.target\n'
'UNIT_EOF')
out.append('systemctl daemon-reload')
out.append('systemctl enable --now tomcat')
out.append('sleep 3')
out.append('ss -ltn | grep :' + port + ' || warn "Tomcat not listening yet"')
return "\n".join(out) + "\n"
# ========================== Docker ==========================
class Docker(Generator):
id = "docker"
title = "Docker Engine + Compose"
category = "middleware"
icon = "🐳"
tags = ["docker", "container", "compose"]
description = "一键安装 Docker Engine、buildx、docker-compose v2、配置 cgroupdriver。"
fields = [
Field("repo_region", "Docker 安装源", "select", default="auto",
options=["auto", "official", "cn"],
help="auto=自动探测;official=官方 download.docker.com;"
"cn=国内镜像(mirrors.tuna.tsinghua.edu.cn)"),
Field("docker_user", "免 sudo 用户", "text", default="root",
help="将该用户加入 docker 组,免 sudo。"),
Field("registry_mirror", "Registry 镜像加速", "text",
default="https://docker.mirrors.ustc.edu.cn",
help="如 https://mirror.ccs.tencentyun.com;"
"留空则不配置镜像。"),
Field("cgroup_driver", "Cgroup Driver", "select", default="systemd",
options=["systemd", "cgroupfs"]),
Field("log_driver", "日志驱动", "select", default="json-file",
options=["json-file", "journald", "syslog", "none"]),
Field("log_max_size", "日志最大尺寸", "text", default="100m"),
Field("log_max_file", "日志最大文件数", "text", default="3"),
Field("enable_buildx", "启用 buildx", "checkbox", default="yes"),
Field("data_root", "Docker 数据目录", "text", default="/var/lib/docker",
help="留空使用默认 /var/lib/docker"),
]
def render(self, p):
usr = p.get("docker_user", "root")
mirror = p.get("registry_mirror", "https://docker.mirrors.ustc.edu.cn")
cg = p.get("cgroup_driver", "systemd")
log_d = p.get("log_driver", "json-file")
log_sz = p.get("log_max_size", "100m")
log_n = p.get("log_max_file", "3")
data_root = p.get("data_root", "/var/lib/docker")
buildx = bool_str(p.get("enable_buildx", True))
region = p.get("repo_region", "auto")
out = [bash_header(self.title)]
out.append('log "Installing Docker..."')
# The auto-detection block. Resolves DOCKER_REPO_BASE at runtime to
# either the official download.docker.com URL or the Tuna mirror.
# We embed the literal base URLs into the script (not env vars at the
# top of the file) so that `bash -n` and readability stay clean.
official_apt = "https://download.docker.com/linux/ubuntu"
tuna_apt = "https://mirrors.tuna.tsinghua.edu.cn/docker-ce/linux/ubuntu"
official_yum = "https://download.docker.com/linux/centos"
tuna_yum = "https://mirrors.tuna.tsinghua.edu.cn/docker-ce/linux/centos"
if region == "auto":
out.append(
'# ---- auto-detect reachable Docker repo ----\n'
'if curl -fsS --max-time 5 -o /dev/null '
f'"{official_apt}/gpg" 2>/dev/null || '
f'curl -fsSI --max-time 5 -o /dev/null "{official_yum}/docker-ce.repo" 2>/dev/null; then\n'
' DOCKER_REPO_APT="' + official_apt + '"\n'
' DOCKER_REPO_YUM="' + official_yum + '"\n'
' log "Repo: official (download.docker.com reachable)"\n'
'else\n'
' DOCKER_REPO_APT="' + tuna_apt + '"\n'
' DOCKER_REPO_YUM="' + tuna_yum + '"\n'
' warn "Official docker repo unreachable — falling back to Tuna mirror"\n'
'fi'
)
elif region == "cn":
out.append(
f'DOCKER_REPO_APT="{tuna_apt}"\n'
f'DOCKER_REPO_YUM="{tuna_yum}"\n'
'log "Repo: cn (Tuna mirror)"'
)
else: # official
out.append(
f'DOCKER_REPO_APT="{official_apt}"\n'
f'DOCKER_REPO_YUM="{official_yum}"\n'
'log "Repo: official (forced)"'
)
out.append('case "$PKG" in\n'
' apt-get)\n'
' PKG_INSTALL ca-certificates curl gnupg lsb-release\n'
' install -m 0755 -d /etc/apt/keyrings\n'
' curl -fsSL "${DOCKER_REPO_APT}/gpg" | gpg --dearmor -o /etc/apt/keyrings/docker.gpg\n'
' chmod a+r /etc/apt/keyrings/docker.gpg\n'
' echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] ${DOCKER_REPO_APT} $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list\n'
' apt-get update\n'
' PKG_INSTALL docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin\n'
' ;;\n'
' yum|dnf)\n'
' PKG_INSTALL yum-utils\n'
' # yum-config-manager writes /etc/yum.repos.d/docker-ce.repo with the\n'
' # official gpgkey URL embedded; dnf will fetch it on first install.\n'
' # We use --save-to-rpmdb-friendly repos for both classic yum and modern dnf.\n'
' if command -v yum-config-manager >/dev/null 2>&1; then\n'
' yum-config-manager --add-repo "${DOCKER_REPO_YUM}/docker-ce.repo"\n'
' else\n'
' dnf config-manager --add-repo "${DOCKER_REPO_YUM}/docker-ce.repo"\n'
' fi\n'
' PKG_INSTALL docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin\n'
' ;;\n'
'esac')
out.append('systemctl enable --now docker')
out.append('')
out.append('mkdir -p /etc/docker')
out.append('cat > /etc/docker/daemon.json <<CFG_EOF\n{\n'
' "registry-mirrors": ["' + mirror + '"],\n'
' "log-driver": "' + log_d + '",\n'
' "log-opts": {\n'
' "max-size": "' + log_sz + '",\n'
' "max-file": "' + log_n + '"\n'
' },\n'
' "storage-driver": "overlay2",\n'
' "data-root": "' + data_root + '"\n'
'}\nCFG_EOF')
out.append('systemctl restart docker')
if usr != "root":
out.append('usermod -aG docker ' + usr)
out.append('log "User ' + usr + ' added to docker group (need to re-login)"')
out.append('docker --version')
if buildx:
out.append('docker buildx version || true')
out.append('docker compose version || true')
return "\n".join(out) + "\n"
# ========================== ETCD ==========================
class ETCD(Generator):
id = "etcd"
title = "etcd 分布式 KV 存储"
category = "middleware"
icon = "🗝️"
tags = ["etcd", "kv", "discovery"]
description = "部署单节点 etcd(可扩展为集群),开启 v2/v3 双协议、http API。"
fields = [
Field("name", "节点名", "text", default="etcd-node1"),
Field("data_dir", "数据目录", "text", default="/var/lib/etcd"),
Field("listen_client", "客户端监听 URL", "text", default="http://0.0.0.0:2379"),
Field("listen_peer", "Peer 监听 URL", "text", default="http://0.0.0.0:2380"),
Field("advertise_client", "广播客户端 URL", "text", default="http://127.0.0.1:2379"),
Field("initial_cluster", "初始集群列表", "text", default="etcd-node1=http://127.0.0.1:2380"),
Field("version", "etcd 版本", "text", default="3.5.13"),
]
def render(self, p):
name = p.get("name", "etcd-node1")
data = p.get("data_dir", "/var/lib/etcd")
lclient = p.get("listen_client", "http://0.0.0.0:2379")
lpeer = p.get("listen_peer", "http://0.0.0.0:2380")
advertise = p.get("advertise_client", "http://127.0.0.1:2379")
cluster = p.get("initial_cluster", "etcd-node1=http://127.0.0.1:2380")
ver = p.get("version", "3.5.13")
out = [bash_header(self.title)]
out.append('log "Installing etcd v' + ver + '..."')
out.append('useradd -r -s /bin/false etcd 2>/dev/null || true')
out.append('mkdir -p ' + data + ' /etc/etcd')
out.append('cd /tmp')
out.append('curl -fsSL -o etcd.tar.gz https://github.com/etcd-io/etcd/releases/download/v' + ver + '/etcd-v' + ver + '-linux-amd64.tar.gz')
out.append('tar -xzf etcd.tar.gz')
out.append('cp etcd-v' + ver + '-linux-amd64/etcd /usr/local/bin/')
out.append('cp etcd-v' + ver + '-linux-amd64/etcdctl /usr/local/bin/')
out.append('rm -rf etcd.tar.gz etcd-v' + ver + '-linux-amd64')
out.append('chown -R etcd:etcd ' + data + ' /etc/etcd')
out.append('')
out.append('cat > /etc/etcd/etcd.conf <<CFG_EOF\n'
'ETCD_NAME="' + name + '"\n'
'ETCD_DATA_DIR="' + data + '"\n'
'ETCD_LISTEN_CLIENT_URLS="' + lclient + '"\n'
'ETCD_LISTEN_PEER_URLS="' + lpeer + '"\n'
'ETCD_ADVERTISE_CLIENT_URLS="' + advertise + '"\n'
'ETCD_INITIAL_ADVERTISE_PEER_URLS="' + lpeer + '"\n'
'ETCD_INITIAL_CLUSTER="' + cluster + '"\n'
'ETCD_INITIAL_CLUSTER_TOKEN="etcd-cluster-1"\n'
'ETCD_INITIAL_CLUSTER_STATE="new"\nCFG_EOF')
out.append('cat > /etc/systemd/system/etcd.service <<UNIT_EOF\n'
'[Unit]\n'
'Description=etcd\n'
'After=network.target\n\n'
'[Service]\n'
'Type=notify\n'
'User=etcd\n'
'Group=etcd\n'
'EnvironmentFile=/etc/etcd/etcd.conf\n'
'ExecStart=/usr/local/bin/etcd\n'
'Restart=on-failure\n'
'LimitNOFILE=65536\n\n'
'[Install]\nWantedBy=multi-user.target\nUNIT_EOF')
out.append('systemctl daemon-reload')
out.append('systemctl enable --now etcd')
out.append('sleep 3')
out.append('etcdctl --endpoints=' + advertise + ' endpoint health || true')
return "\n".join(out) + "\n"
# ========================== Zookeeper ==========================
class Zookeeper(Generator):
id = "zookeeper"
title = "Apache ZooKeeper"
category = "middleware"
icon = "🦓"
tags = ["zookeeper", "zk", "coordination"]
description = "部署单节点 ZooKeeper,可扩展为集群。"
fields = [
Field("client_port", "Client 端口", "number", default="2181", min_=1, max_=65535),
Field("data_dir", "数据目录", "text", default="/var/lib/zookeeper"),
Field("tick_time", "Tick Time (ms)", "number", default="2000"),
Field("init_limit", "Init Limit", "number", default="10"),
Field("sync_limit", "Sync Limit", "number", default="5"),
Field("max_client_cnxns", "最大客户端连接数", "number", default="60"),
Field("version", "ZooKeeper 版本", "text", default="3.9.2"),
]
def render(self, p):
port = str(p.get("client_port", "2181"))
data = p.get("data_dir", "/var/lib/zookeeper")
tick = str(p.get("tick_time", "2000"))
init_l = str(p.get("init_limit", "10"))
sync = str(p.get("sync_limit", "5"))
cnxns = str(p.get("max_client_cnxns", "60"))
ver = p.get("version", "3.9.2")
out = [bash_header(self.title)]
out.append('log "Installing Java + ZooKeeper..."')
out.append('command -v java >/dev/null || PKG_INSTALL java-11-openjdk-devel java-11-openjdk || PKG_INSTALL default-jdk')
out.append('useradd -r -s /bin/false zookeeper 2>/dev/null || true')
out.append('cd /opt')
out.append('curl -fsSL -o zk.tar.gz https://archive.apache.org/dist/zookeeper/zookeeper-' + ver + '/apache-zookeeper-' + ver + '-bin.tar.gz')
out.append('rm -rf /opt/zookeeper')
out.append('tar -xzf zk.tar.gz')
out.append('mv apache-zookeeper-' + ver + '-bin /opt/zookeeper')
out.append('rm -f zk.tar.gz')
out.append('mkdir -p ' + data)
out.append('chown -R zookeeper:zookeeper /opt/zookeeper ' + data)
out.append('')
out.append('cat > /opt/zookeeper/conf/zoo.cfg <<CFG_EOF\n'
'tickTime=' + tick + '\n'
'initLimit=' + init_l + '\n'
'syncLimit=' + sync + '\n'
'dataDir=' + data + '\n'
'clientPort=' + port + '\n'
'maxClientCnxns=' + cnxns + '\n'
'4lw.commands.whitelist=*\\nCFG_EOF')
out.append('echo "' + str(int(__import__('time').time())) + '" > ' + data + '/myid')
out.append('')
out.append('cat > /etc/systemd/system/zookeeper.service <<UNIT_EOF\n'
'[Unit]\n'
'Description=Apache ZooKeeper\n'
'After=network.target\n\n'
'[Service]\n'
'Type=forking\n'
'User=zookeeper\nGroup=zookeeper\n'
'Environment=JAVA_HOME=/usr/lib/jvm/java-11-openjdk\n'
'ExecStart=/opt/zookeeper/bin/zkServer.sh start\n'
'ExecStop=/opt/zookeeper/bin/zkServer.sh stop\n'
'ExecReload=/opt/zookeeper/bin/zkServer.sh restart\n'
'Restart=on-failure\n\n'
'[Install]\nWantedBy=multi-user.target\nUNIT_EOF')
out.append('systemctl daemon-reload')
out.append('systemctl enable --now zookeeper')
out.append('sleep 3')
out.append('/opt/zookeeper/bin/zkServer.sh status || true')
return "\n".join(out) + "\n"
# Register everything in this file
for _g in [VNCSrv, Nginx, HAProxy, Keepalived, Redis, RabbitMQ, Tomcat, Docker, ETCD, Zookeeper]:
register(_g())