From 319f683a162a65fd721d8e55ab0a02103891597d Mon Sep 17 00:00:00 2001 From: cnbug Date: Tue, 4 Aug 2026 00:36:35 +0800 Subject: [PATCH] Initial commit: 50 shell-script generators with web UI - 8 categories / 50 generators covering middleware, databases, runtimes, systemd services, system tools, network, monitoring, security - VNC supports XFCE / GNOME / KDE Plasma / MATE / LXQt desktops - Node.js versions 18-26 (incl. current LTS 24 Krypton and current 26) - Live preview, copy / download / multi-script bundle in web UI - Distro-aware (Ubuntu/Debian/CentOS/RHEL/Rocky/Alma/Fedora) - All 50 generators pass bash -n syntax check - Zero pip dependencies (Flask stdlib only) --- .gitignore | 25 + README.md | 165 +++++++ app.py | 158 ++++++ generators/__init__.py | 199 ++++++++ generators/databases.py | 212 ++++++++ generators/middleware.py | 984 ++++++++++++++++++++++++++++++++++++++ generators/monitoring.py | 226 +++++++++ generators/network.py | 270 +++++++++++ generators/runtimes.py | 651 +++++++++++++++++++++++++ generators/system.py | 414 ++++++++++++++++ generators/systemd_svc.py | 234 +++++++++ shell-gen.service | 15 + start.sh | 9 + static/style.css | 257 ++++++++++ templates/base.html | 33 ++ templates/generator.html | 253 ++++++++++ templates/index.html | 72 +++ 17 files changed, 4177 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.py create mode 100644 generators/__init__.py create mode 100644 generators/databases.py create mode 100644 generators/middleware.py create mode 100644 generators/monitoring.py create mode 100644 generators/network.py create mode 100644 generators/runtimes.py create mode 100644 generators/system.py create mode 100644 generators/systemd_svc.py create mode 100644 shell-gen.service create mode 100644 start.sh create mode 100644 static/style.css create mode 100644 templates/base.html create mode 100644 templates/generator.html create mode 100644 templates/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2efca37 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Python +__pycache__/ +*.pyc +*.pyo +*.pyd +*.egg-info/ +.venv/ +venv/ +.env + +# Flask instance dir (SQLite + uploads) +instance/ +*.sqlite +*.sqlite3 +*.db + +# Generated test scripts (these are sample outputs, not source) +vnc_cnbugs_gnome.sh +vnc11.sh +vnc12.sh +vnc13.sh +vnc15.sh +vnc16.sh +vncs13.sh +/tmp/*.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..4b923f8 --- /dev/null +++ b/README.md @@ -0,0 +1,165 @@ +# shell-gen — Linux 一键部署脚本生成器 + +Web 界面的 Linux 服务/中间件/运行时一键安装脚本生成器。在网页填写参数,实时生成可立即 +执行的 `bash` 脚本。所有脚本支持 Ubuntu/Debian/CentOS/RHEL/Rocky/Alma 自动检测。 + +> 不在生成器所在机器运行;生成的脚本拿到目标机器上 `sudo bash xxx.sh` 即可。 + +## 快速开始 + +```bash +cd /root/shell-gen +bash start.sh +# 浏览器打开 http://:5099/ +``` + +或者用 systemd: +```bash +cp shell-gen.service /etc/systemd/system/ +systemctl daemon-reload +systemctl enable --now shell-gen +``` + +## 已包含的生成器(50 个) + +### 中间件服务 (10) +- **VNC 远程桌面** — TigerVNC + XFCE +- **Nginx** — 静态站点 / 反向代理 / 自签 SSL +- **HAProxy** — L4/L7 负载均衡 + stats 面板 +- **Keepalived** — VRRP 双机热备 VIP 漂移 +- **Redis** — 密码 + AOF + ACL +- **RabbitMQ** — 管理插件 + admin 用户 +- **Tomcat** — 任意版本,生成 systemd unit +- **Docker** — Engine + buildx + compose + 国内镜像 +- **etcd** — 单节点 / 集群 +- **ZooKeeper** — 单节点 / 集群 + +### 数据库 (8) +- **PostgreSQL** — 任意版本 (PGDG 官方源) +- **MySQL / MariaDB** — 5.7 / 8.0 / 8.4 / 10.11 / 11.4 +- **MongoDB** — 4.4 / 5.0 / 6.0 / 7.0 + 副本集 +- **Memcached** — 监听/内存/连接数 +- **SQLite** — 命令行工具 + sqlite-utils +- **Elasticsearch** — 7.x / 8.x 单节点 +- **ClickHouse** — 服务端 + 客户端 +- **Consul** — Service Discovery + +### 开发语言运行时 (10) +- **Python** — 2.7 - 3.13 (源码编译) +- **Node.js** — 14/16/18/20/21/22 (二进制) +- **GCC** — 8.5 - 14.1 (源码编译) +- **Make** — 任意版本 +- **CMake** — 任意版本 +- **Go** — 1.18 - 1.23 +- **OpenJDK** — 8/11/17/21 +- **PHP** — 7.4 - 8.3 (含 fpm) +- **Ruby** — 2.7 - 3.3 +- **Rust** — rustup + toolchain (rsproxy 国内镜像) + +### systemd 服务 (6) +- **通用 systemd** — 任意命令 → unit +- **Gunicorn (Python)** +- **Node.js** +- **Java -jar (SpringBoot)** +- **Go binary** +- **Shell 脚本** + +### 系统工具 (8) +- **防火墙** — ufw / firewalld / iptables 三合一 +- **Swap** — 文件型 +- **时间同步** — chrony (国内 NTP) +- **SSH 加固** — 端口 / 密码 / 公钥 / 横幅 +- **Sysctl 调优** — BBR / TCP / 文件句柄 +- **ulimit 资源限制** +- **主机名 / hosts** +- **磁盘自动挂载 (fstab)** + +### 网络工具 (4) +- **WireGuard** — VPN + 自动生成客户端配置 + 二维码 +- **Tailscale** — 客户端 + 自定义控制服务器 +- **Dnsmasq** — DHCP + DNS 缓存 +- **OpenVPN** — 简化部署 + 客户端配置生成 + +### 监控告警 (4) +- **Prometheus Node Exporter** +- **Prometheus Server** — 多个 scrape jobs +- **Grafana** — 自动添加 Prometheus 数据源 +- **Logrotate** — 通用日志轮转 + +## 使用流程 + +1. **打开首页** `/` 浏览 50 个生成器 +2. **点击进入详情页** 填写参数 +3. **实时预览** — 右侧实时显示生成的脚本 +4. **三种交付方式**: + - 📋 复制 — 直接粘贴到目标服务器 + - 💾 下载 — 拿到 .sh 文件 + - 📦 加入套餐 — 多个生成器合并成 zip + master installer + +## 特色 + +- **distro 通用**: Ubuntu/Debian 自动用 apt, RHEL/CentOS/Rocky/Alma 自动用 yum, + 未知发行版回退到 iptables。脚本运行时报 `[shell-gen] ...` 前缀,方便日志搜索。 +- **语法已校验**: 50 个生成器默认参数生成的脚本均通过 `bash -n` 语法检查。 +- **idempotent**: 多数脚本支持重复运行(用 cp + bak 备份,systemd daemon-reload)。 +- **零依赖**: 仅需 Python 3.8+,Flask 标准库即可;无 pip 依赖。 + +## 添加自定义生成器 + +```python +# generators/myapp.py +from . import register, Generator, Field +from . import bash_header, quote, bool_str + +class MyService(Generator): + id = "myservice" + title = "我的服务" + category = "middleware" # middleware/databases/runtimes/systemd/system/network/monitoring/security + icon = "🚀" + tags = ["custom"] + description = "..." + fields = [ + Field("port", "端口", "number", default="8080"), + ] + def render(self, p): + port = p.get("port", "8080") + out = [bash_header(self.title)] + out.append('apt-get install -y mypkg') + # ... + return "\n".join(out) + "\n" + +register(MyService()) +``` + +然后在 `app.py` 的 import 块加一行: +```python +from generators import myapp # noqa +``` + +## API + +```bash +# 列出所有生成器 +curl http://localhost:5099/api/categories + +# 预览脚本 +curl -X POST http://localhost:5099/api/preview \ + -H 'Content-Type: application/json' \ + -d '{"generator": "nginx", "params": {"server_name": "demo.local", "listen_port": "80"}}' + +# 下载 +curl -X POST http://localhost:5099/api/download \ + -H 'Content-Type: application/json' \ + -d '{"generator": "redis", "params": {"port": "6379"}}' \ + -o redis.sh + +# 打包多个 +curl -X POST http://localhost:5099/api/bundle \ + -H 'Content-Type: application/json' \ + -d '{"items": [{"generator": "redis", "params": {}}, {"generator": "nginx", "params": {}}]}' \ + -o bundle.zip +``` + +## License + +MIT diff --git a/app.py b/app.py new file mode 100644 index 0000000..de1a07d --- /dev/null +++ b/app.py @@ -0,0 +1,158 @@ +""" +Shell Generator - One-click Linux shell script generator +Single-file Flask app + dedicated generator modules. +""" +import os +import io +import zipfile +import datetime +import subprocess +from pathlib import Path +from flask import (Flask, render_template, request, jsonify, Response, + send_file, abort, redirect, url_for, session, flash) + +# Import all generator modules so they self-register via @register +from generators import ( # noqa: F401 + list_generators, get_generator, get_categories, + category_labels, category_icons, +) +from generators.middleware import * # noqa: F401 (registers) +from generators.runtimes import * # noqa: F401 +from generators.systemd_svc import * # noqa: F401 +from generators.system import * # noqa: F401 +from generators.network import * # noqa: F401 +from generators.databases import * # noqa: F401 +from generators.monitoring import * # noqa: F401 + +BASE_DIR = Path(__file__).parent +app = Flask(__name__, instance_path=str(BASE_DIR / "instance")) +app.config["SECRET_KEY"] = os.environ.get("SHELLGEN_SECRET", "shell-gen-default-secret-change-me") +app.config["JSON_AS_ASCII"] = False + + +# ----------------------------- Routes ----------------------------- + +@app.route("/") +def index(): + return render_template( + "index.html", + categories=get_categories(), + category_labels=category_labels, + category_icons=category_icons, + total=len(list_generators()), + ) + + +@app.route("/gen/") +def generator_page(generator_id): + gen = get_generator(generator_id) + if gen is None: + abort(404, f"Unknown generator: {generator_id}") + return render_template( + "generator.html", + gen=gen, + category_labels=category_labels, + category_icons=category_icons, + all_cats=get_categories(), + ) + + +@app.route("/api/preview", methods=["POST"]) +def api_preview(): + """Generate script preview without running anything.""" + data = request.get_json(silent=True) or {} + gen_id = data.get("generator") + params = data.get("params", {}) + gen = get_generator(gen_id) if gen_id else None + if gen is None: + return jsonify({"ok": False, "error": f"Unknown generator: {gen_id}"}), 400 + try: + script = gen.render(params) + except Exception as e: + return jsonify({"ok": False, "error": f"{type(e).__name__}: {e}"}), 400 + return jsonify({ + "ok": True, + "script": script, + "title": gen.title, + "description": gen.description, + "warnings": gen.warnings, + "post_steps": gen.post_steps, + "verify_steps": gen.verify_steps, + }) + + +@app.route("/api/download", methods=["POST"]) +def api_download(): + data = request.get_json(silent=True) or {} + gen_id = data.get("generator") + params = data.get("params", {}) + gen = get_generator(gen_id) if gen_id else None + if gen is None: + return jsonify({"ok": False, "error": f"Unknown generator: {gen_id}"}), 400 + try: + script = gen.render(params) + except Exception as e: + return jsonify({"ok": False, "error": f"{type(e).__name__}: {e}"}), 400 + fname = f"{gen_id}.sh" + headers = {"Content-Disposition": f'attachment; filename="{fname}"'} + return Response(script, mimetype="text/x-shellscript", headers=headers) + + +@app.route("/api/bundle", methods=["POST"]) +def api_bundle(): + """Bundle multiple generators into a single zip of .sh files.""" + data = request.get_json(silent=True) or {} + items = data.get("items", []) + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: + for entry in items: + gid = entry.get("generator") + params = entry.get("params", {}) + gen = get_generator(gid) + if gen is None: + continue + try: + script = gen.render(params) + except Exception as e: + script = f"#!/usr/bin/env bash\necho 'ERROR generating {gid}: {e}'\nexit 1\n" + z.writestr(f"{gid}.sh", script) + # Master installer + master = ["#!/usr/bin/env bash", "set -e", "# Auto-generated by shell-gen"] + for entry in items: + gid = entry.get("generator") + if get_generator(gid) is None: + continue + master.append(f'echo "===> {gid}"') + master.append(f"bash \"$(dirname \"$0\")/{gid}.sh\"") + z.writestr("install-all.sh", "\n".join(master) + "\n") + buf.seek(0) + return send_file(buf, mimetype="application/zip", as_attachment=True, + download_name=f"shell-gen-bundle-{datetime.datetime.now():%Y%m%d-%H%M%S}.zip") + + +@app.route("/api/categories") +def api_categories(): + out = [] + for cat in get_categories(): + gens = [g for g in list_generators().values() if g.category == cat] + out.append({ + "id": cat, + "label": category_labels.get(cat, cat), + "icon": category_icons.get(cat, "📦"), + "items": [g.summary() for g in gens], + }) + return jsonify({"ok": True, "categories": out}) + + +@app.route("/healthz") +def healthz(): + return jsonify({"ok": True, "generators": len(list_generators())}) + + +if __name__ == "__main__": + os.makedirs(app.instance_path, exist_ok=True) + with app.app_context(): + # Touch a db-less startup log + pass + port = int(os.environ.get("PORT", 5099)) + app.run(host="0.0.0.0", port=port, debug=False) diff --git a/generators/__init__.py b/generators/__init__.py new file mode 100644 index 0000000..a5a9480 --- /dev/null +++ b/generators/__init__.py @@ -0,0 +1,199 @@ +""" +Generator registry — every generator module imports @register and a Generator class. +The class is responsible for rendering a shell script given a params dict. +""" +from typing import Dict, List, Optional +import shlex + +_REGISTRY: Dict[str, "Generator"] = {} + + +def register(gen: "Generator"): + if gen.id in _REGISTRY: + raise ValueError(f"Duplicate generator id: {gen.id}") + _REGISTRY[gen.id] = gen + return gen + + +def get_generator(gid: str) -> Optional["Generator"]: + return _REGISTRY.get(gid) + + +def list_generators() -> Dict[str, "Generator"]: + return dict(_REGISTRY) + + +def get_categories() -> List[str]: + seen = [] + for g in _REGISTRY.values(): + if g.category not in seen: + seen.append(g.category) + return seen + + +# Human-readable labels and FontAwesome-style emoji icons +category_labels = { + "middleware": "中间件服务", + "databases": "数据库", + "runtimes": "开发语言运行时", + "systemd": "Systemd 服务", + "system": "系统工具", + "network": "网络工具", + "monitoring": "监控告警", + "security": "安全加固", +} + +category_icons = { + "middleware": "🧩", + "databases": "🗄️", + "runtimes": "⚙️", + "systemd": "🛠️", + "system": "🖥️", + "network": "🌐", + "monitoring": "📊", + "security": "🔒", +} + + +# ----- Field / Param descriptors for the UI ----- + +class Field: + """One form field rendered in the web UI.""" + + def __init__(self, name, label, type="text", default="", placeholder="", + help="", options=None, required=False, min_=None, max_=None, + step=None, pattern=None, group=None): + self.name = name + self.label = label + self.type = type # text|number|select|checkbox|textarea|password|portlist + self.default = default + self.placeholder = placeholder + self.help = help + self.options = options or [] + self.required = required + self.min = min_ + self.max = max_ + self.step = step + self.pattern = pattern + self.group = group # for grouping in UI + + def to_dict(self): + return { + "name": self.name, "label": self.label, "type": self.type, + "default": self.default, "placeholder": self.placeholder, + "help": self.help, "options": self.options, "required": self.required, + "min": self.min, "max": self.max, "step": self.step, "pattern": self.pattern, + "group": self.group, + } + + +# ----- Generator base class ----- + +class Generator: + id: str = "" + title: str = "" + category: str = "middleware" + description: str = "" + icon: str = "📦" + tags: List[str] = [] + fields: List[Field] = [] + warnings: List[str] = [] + post_steps: List[str] = [] + verify_steps: List[str] = [] + os_support: List[str] = ["ubuntu", "debian", "centos", "rhel"] + dangerous: bool = False + + def render(self, params: dict) -> str: + raise NotImplementedError + + def summary(self) -> dict: + return { + "id": self.id, "title": self.title, "category": self.category, + "description": self.description, "icon": self.icon, "tags": self.tags, + "os": self.os_support, "dangerous": self.dangerous, + "fields": [f.to_dict() for f in self.fields], + } + + +# ----------------------------- Helpers ----------------------------- + +BASH_HEADER = """#!/usr/bin/env bash +# Generated by shell-gen (https://github.com/yourname/shell-gen) +# Generator: {title} +# Generated at: {ts} +# OS: {os_hint} +set -euo pipefail +SCRIPT_DIR="$(cd -- "$(dirname -- "${{BASH_SOURCE[0]}}")" &>/dev/null && pwd)" +LOG_PREFIX="[{title}]" + +log() {{ echo "${{LOG_PREFIX}} $*" ; }} +warn() {{ echo "${{LOG_PREFIX}} WARN: $*" >&2 ; }} +die() {{ echo "${{LOG_PREFIX}} ERROR: $*" >&2 ; exit 1 ; }} + +[ "$(id -u)" -eq 0 ] || die "This script must be run as root. Re-run with sudo." + +# ---- detect distro ---- +. /etc/os-release 2>/dev/null || true +DISTRO_ID="${{ID:-unknown}}" +DISTRO_LIKE="${{ID_LIKE:-}}" +case "$DISTRO_ID" in + ubuntu|debian) PKG="apt-get"; PKG_INSTALL="DEBIAN_FRONTEND=noninteractive apt-get install -y" ;; + centos|rhel|rocky|almalinux|ol) PKG="yum"; PKG_INSTALL="yum install -y" ;; + fedora) PKG="dnf"; PKG_INSTALL="dnf install -y" ;; + *) + if echo "$DISTRO_LIKE" | grep -qiE "rhel|centos|fedora"; then + PKG="yum"; PKG_INSTALL="yum install -y" + elif echo "$DISTRO_LIKE" | grep -qi "debian"; then + PKG="apt-get"; PKG_INSTALL="DEBIAN_FRONTEND=noninteractive apt-get install -y" + else + die "Unsupported distribution: $DISTRO_ID" + fi + ;; +esac + +log "Detected distro: $DISTRO_ID (package manager: $PKG)" +""" + + +def bash_header(title: str) -> str: + return BASH_HEADER.format( + title=title, + ts=datetime_now_str(), + os_hint="Ubuntu / Debian / CentOS / RHEL / Rocky / Alma", + ) + + +def datetime_now_str() -> str: + import datetime as _dt + return _dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + +def quote(value) -> str: + """Bash-safe single-quote escape.""" + if value is None: + return "''" + s = str(value) + if s == "": + return "''" + return "'" + s.replace("'", "'\\''") + "'" + + +def bool_str(value, default=False) -> bool: + if isinstance(value, bool): + return value + if value is None: + return default + return str(value).lower() in ("1", "true", "yes", "on", "y", "t") + + +def yes(value) -> str: + return "yes" if bool_str(value) else "no" + + +def port_list(value, default=None) -> str: + """Normalize port list input: '80,443' or '80 443' -> '80 443'.""" + if value is None or value == "": + value = default or "" + s = str(value).replace(",", " ").replace(";", " ") + parts = [p.strip() for p in s.split() if p.strip()] + return " ".join(parts) diff --git a/generators/databases.py b/generators/databases.py new file mode 100644 index 0000000..1e6b492 --- /dev/null +++ b/generators/databases.py @@ -0,0 +1,212 @@ +""" +Extra databases (Memcached, SQLite, Elasticsearch, ClickHouse). +""" +from . import register, Generator, Field +from . import bash_header, quote, bool_str, yes + + +# ========================== Memcached ========================== +class Memcached(Generator): + id = "memcached" + title = "Memcached" + category = "databases" + icon = "🧠" + tags = ["memcached", "cache"] + description = "安装 memcached 并配置监听地址/内存限制/连接数。" + fields = [ + Field("port", "端口", "number", default="11211", min_=1, max_=65535), + Field("memory_mb", "最大内存 (MB)", "number", default="256"), + Field("max_connections", "最大连接数", "number", default="1024"), + Field("listen", "监听地址", "text", default="0.0.0.0"), + ] + + def render(self, p): + port = str(p.get("port", "11211")) + mem = str(p.get("memory_mb", "256")) + conn = str(p.get("max_connections", "1024")) + listen = p.get("listen", "0.0.0.0") + out = [bash_header(self.title)] + out.append('log "Installing memcached..."') + out.append('$PKG_INSTALL memcached') + out.append('cp /etc/memcached.conf /etc/memcached.conf.bak.$(date +%s) || true') + out.append('sed -i "s/^-m .*/-m ' + mem + '/" /etc/memcached.conf') + out.append('sed -i "s/^-p .*/-p ' + port + '/" /etc/memcached.conf') + out.append('sed -i "s/^-l .*/-l ' + listen + '/" /etc/memcached.conf') + out.append('sed -i "s/^-c .*/-c ' + conn + '/" /etc/memcached.conf') + out.append('systemctl enable --now memcached') + out.append('echo "stats" | nc -q1 127.0.0.1 ' + port + ' | head -5') + return "\n".join(out) + "\n" + + +# ========================== SQLite3 (system tools, no server) ========================== +class SqliteTools(Generator): + id = "sqlite" + title = "SQLite 命令行工具" + category = "databases" + icon = "📁" + tags = ["sqlite"] + description = "安装最新版 sqlite3 + sqlite-utils。" + fields = [ + Field("install_python_bindings", "安装 Python sqlite-utils", "checkbox", default="yes"), + ] + + def render(self, p): + pyb = bool_str(p.get("install_python_bindings", True)) + out = [bash_header(self.title)] + out.append('log "Installing sqlite3..."') + out.append('$PKG_INSTALL sqlite3 libsqlite3-dev || $PKG_INSTALL sqlite sqlite-devel') + if pyb: + out.append('command -v pip3 >/dev/null || $PKG_INSTALL python3-pip') + out.append('pip3 install --break-system-packages sqlite-utils || pip3 install sqlite-utils') + out.append('sqlite3 --version') + return "\n".join(out) + "\n" + + +# ========================== Elasticsearch ========================== +class Elasticsearch(Generator): + id = "elasticsearch" + title = "Elasticsearch" + category = "databases" + icon = "🔍" + tags = ["elasticsearch", "es", "search"] + description = "安装 Elasticsearch (单节点开发模式)。" + warnings = [ + "ES JVM 默认 1G,小内存机器请调小 -Xms -Xmx。", + "生产环境必须设置 xpack.security.enabled=true。", + ] + fields = [ + Field("version", "版本", "select", default="8.13.4", + options=["7.17.20", "8.11.4", "8.13.4", "8.14.3"]), + Field("port", "HTTP 端口", "number", default="9200", min_=1, max_=65535), + Field("jvm_heap", "JVM 堆", "text", default="1g"), + Field("discovery", "discovery.type", "select", default="single-node", + options=["single-node"]), + Field("enable_security", "启用 xpack security", "checkbox", default="no"), + ] + + def render(self, p): + ver = p.get("version", "8.13.4") + port = str(p.get("port", "9200")) + heap = p.get("jvm_heap", "1g") + sec = bool_str(p.get("enable_security")) + out = [bash_header(self.title)] + out.append('log "Installing Elasticsearch ' + ver + '..."') + out.append('useradd -r -s /bin/false elasticsearch 2>/dev/null || true') + out.append('cd /tmp') + out.append('curl -fsSL -O https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-' + ver + '-linux-x86_64.tar.gz') + out.append('rm -rf /opt/elasticsearch') + out.append('tar -xzf elasticsearch-' + ver + '-linux-x86_64.tar.gz -C /opt/') + out.append('mv /opt/elasticsearch-' + ver + ' /opt/elasticsearch') + out.append('rm -f elasticsearch-' + ver + '-linux-x86_64.tar.gz') + out.append('chown -R elasticsearch:elasticsearch /opt/elasticsearch') + out.append('sed -i "s/^-Xms.*/-Xms' + heap + '/" /opt/elasticsearch/config/jvm.options') + out.append('sed -i "s/^-Xmx.*/-Xmx' + heap + '/" /opt/elasticsearch/config/jvm.options') + out.append('sed -i "s/^#http.port.*/http.port: ' + port + '/" /opt/elasticsearch/config/elasticsearch.yml') + out.append('echo "discovery.type: single-node" >> /opt/elasticsearch/config/elasticsearch.yml') + out.append('echo "network.host: 0.0.0.0" >> /opt/elasticsearch/config/elasticsearch.yml') + out.append('echo "xpack.security.enabled: ' + ('true' if sec else 'false') + '" >> /opt/elasticsearch/config/elasticsearch.yml') + out.append('cat > /etc/systemd/system/elasticsearch.service < /etc/apt/sources.list.d/clickhouse.list') + out.append('apt-get update') + out.append('DEBIAN_FRONTEND=noninteractive apt-get install -y clickhouse-server clickhouse-client') + out.append('mkdir -p ' + dp) + out.append('chown -R clickhouse:clickhouse ' + dp) + out.append('sed -i "s|.*|' + tcp + '|" /etc/clickhouse-server/config.xml') + out.append('sed -i "s|.*|' + http + '|" /etc/clickhouse-server/config.xml') + out.append('sed -i "s|.*|' + lh + '|" /etc/clickhouse-server/config.xml') + out.append('sed -i "s|.*|' + dp + '|" /etc/clickhouse-server/config.xml') + out.append('systemctl enable --now clickhouse-server') + out.append('sleep 3') + out.append('clickhouse-client -q "SELECT version()"') + return "\n".join(out) + "\n" + + +# ========================== Consul (service mesh) ========================== +class Consul(Generator): + id = "consul" + title = "HashiCorp Consul" + category = "middleware" + icon = "🌐" + tags = ["consul", "service-discovery"] + description = "安装 Consul 单节点开发模式。" + fields = [ + Field("version", "Consul 版本", "text", default="1.18.1"), + Field("datacenter", "Datacenter", "text", default="dc1"), + Field("ui", "启用 Web UI", "checkbox", default="yes"), + ] + + def render(self, p): + ver = p.get("version", "1.18.1") + dc = p.get("datacenter", "dc1") + ui = bool_str(p.get("ui", True)) + out = [bash_header(self.title)] + out.append('useradd -r -s /bin/false consul 2>/dev/null || true') + out.append('mkdir -p /etc/consul.d /var/lib/consul') + out.append('cd /tmp') + out.append('curl -fsSL -O https://releases.hashicorp.com/consul/' + ver + '/consul_' + ver + '_linux_amd64.zip') + out.append('unzip -o consul_' + ver + '_linux_amd64.zip -d /usr/local/bin/') + out.append('rm -f consul_' + ver + '_linux_amd64.zip') + out.append('chown -R consul:consul /etc/consul.d /var/lib/consul') + out.append('cat > /etc/consul.d/consul.hcl < /etc/systemd/system/consul.service <: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("use_xvfb", "使用 Xvfb (无显卡)", "checkbox", default="yes", + help="Headless 服务器必须启用 — VNC 通过 Xvfb 渲染,无需物理显卡。"), + ] + + # ---- 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" + # GNOME requires XDG_CURRENT_DESKTOP and a dbus session + "export XDG_CURRENT_DESKTOP=GNOME\n" + "export XDG_SESSION_TYPE=x11\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" + use_xvfb = bool_str(p.get("use_xvfb", True)) + + 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. + install = ( + 'DEBIAN_FRONTEND=noninteractive apt-get install -y \\\n' + ' ' + ' '.join(pkgs) + ' \\\n' + ' dbus-x11 tigervnc-standalone-server tigervnc-common tigervnc-xorg-extension 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' + if use_xvfb and de in ("gnome", "kde-plasma"): + # Some compositing DEs need a fake display backend + install += ' \\\n xserver-xorg-video-dummy xvfb' + out.append(install) + + 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/" 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/ at all) + # 2. /home/ 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('HOME_ABS="' + '$USER_HOME' + '"') + out.append('') + # systemd unit — substitute HOME_ABS for the actual absolute path now + # so the rendered WorkingDirectory is a literal (e.g. /home/cnbugs) + working_dir_literal = '$USER_HOME' # bash expands at script runtime + out.append('log "Writing systemd unit vncserver@.service..."') + # Write to a .in template, then sed-replace $USER_HOME with the + # resolved absolute path so the final .service has a literal path. + 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' + # Use the literal absolute home path. The template below + # gets sed-replaced with the actual $USER_HOME at write + # time so the unit file holds a literal path, not a + # variable. This avoids the "WorkingDirectory= path is + # not absolute" error from systemd and any %h + # inconsistency. + 'WorkingDirectory=__VNC_HOME__\n' + # Best-effort cleanup. Main script also does this as root + # (where rm -f actually works on root-owned files). The + # leading "-" tells systemd to ignore non-zero exit. + 'ExecStartPre=-/bin/sh -c \'rm -f /tmp/.X%i-lock /tmp/.X11-unix/X%i 2>/dev/null; /usr/bin/vncserver -kill :%i >/dev/null 2>&1 || true\'\n' + f'ExecStart=/usr/bin/vncserver -fg -localhost {localhost} :%i \\\n' + f' -geometry {geometry} -depth {depth}\n' + 'ExecStop=/usr/bin/vncserver -kill :%i\n\n' + '[Install]\n' + 'WantedBy=multi-user.target\n' + 'UNIT_EOF') + # Replace the placeholder with the literal absolute home path. + out.append('# Substitute the placeholder with the actual home path we resolved above') + 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)"') + # CRITICAL: Clean up stale X11 lock/socket from previous failed starts. + # systemd runs ExecStartPre under User=, which means cnbugs can't + # rm -f files owned by root. Do it here from the main script (running + # as root) so the cleanup actually takes effect. + 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 :' + 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 <\n' + f'{sn}\n' + f'

Hello from shell-gen

Server: {sn}

\n' + f'

Generated: {__import__("datetime").datetime.now():%Y-%m-%d %H:%M:%S}

\n' + '\n' + 'HTML_EOF') + out.append('') + # Server block + 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' + f' 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 </dev/null') + out.append(f'cat > /etc/nginx/conf.d/{sn}-ssl.conf < /etc/haproxy/haproxy.cfg </dev/null || systemctl restart haproxy') + out.append('log "Done. Stats: http://:' + 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 <> /etc/keepalived/keepalived.conf < /etc/redis/redis.conf < /etc/redis/users.acl <{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://:' + 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 < /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 --add-repo https://download.docker.com/linux/centos/docker-ce.repo || dnf config-manager --add-repo https://download.docker.com/linux/centos/docker-ce.repo\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 </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 < /etc/systemd/system/etcd.service </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 < ' + data + '/myid') + out.append('') + out.append('cat > /etc/systemd/system/zookeeper.service </dev/null || true') + out.append('cd /tmp') + out.append('curl -fsSL -O https://github.com/prometheus/node_exporter/releases/download/v' + ver + '/node_exporter-' + ver + '.linux-amd64.tar.gz') + out.append('tar -xzf node_exporter-' + ver + '.linux-amd64.tar.gz') + out.append('cp node_exporter-' + ver + '.linux-amd64/node_exporter /usr/local/bin/') + out.append('rm -rf node_exporter-' + ver + '.linux-amd64*') + out.append('cat > /etc/systemd/system/node_exporter.service </dev/null || true') + out.append('mkdir -p /etc/prometheus /var/lib/prometheus') + out.append('cd /tmp') + out.append('curl -fsSL -O https://github.com/prometheus/prometheus/releases/download/v' + ver + '/prometheus-' + ver + '.linux-amd64.tar.gz') + out.append('tar -xzf prometheus-' + ver + '.linux-amd64.tar.gz') + out.append('cp prometheus-' + ver + '.linux-amd64/prometheus /usr/local/bin/') + out.append('cp prometheus-' + ver + '.linux-amd64/promtool /usr/local/bin/') + out.append('cp -r prometheus-' + ver + '.linux-amd64/console_libraries /etc/prometheus/') + out.append('cp -r prometheus-' + ver + '.linux-amd64/consoles /etc/prometheus/') + out.append('rm -rf prometheus-' + ver + '.linux-amd64*') + out.append('chown -R prometheus:prometheus /etc/prometheus /var/lib/prometheus') + out.append('') + cfg = ['global:', ' scrape_interval: 15s', ' evaluation_interval: 15s', + '', 'rule_files:', ' - "rules/*.yml"', '', 'scrape_configs:', + ' - job_name: prometheus', ' static_configs:', + ' - targets: [\'localhost:' + port + '\']'] + for line in lines: + if ':' not in line: + continue + name, targets = line.split(':', 1) + name = name.strip() + tg = [t.strip().strip("'").strip('"') for t in + targets.strip().lstrip('[').rstrip(']').split(',') if t.strip()] + cfg.append(f' - job_name: {name}') + cfg.append(' static_configs:') + cfg.append(' - targets: ' + str(tg).replace("'", '"')) + out.append('cat > /etc/prometheus/prometheus.yml < /etc/systemd/system/prometheus.service < /dev/null\n' + ' echo "deb [signed-by=/etc/apt/keyrings/grafana.gpg] https://apt.grafana.com stable main" > /etc/apt/sources.list.d/grafana.list\n' + ' apt-get update\n' + ' $PKG_INSTALL grafana\n' + ' ;;\n' + ' yum|dnf)\n' + ' cat > /etc/yum.repos.d/grafana.repo < /etc/grafana/provisioning/datasources/prometheus.yaml < /etc/logrotate.d/99-shellgen < /etc/wireguard/server.pub') + else: + out.append('mkdir -p /etc/wireguard && echo "' + sk + '" > /etc/wireguard/server.key') + out.append('wg pubkey < /etc/wireguard/server.key > /etc/wireguard/server.pub') + if pk: + out.append('echo "' + pk + '" > /etc/wireguard/server.pub') + out.append('chmod 600 /etc/wireguard/server.key') + out.append('') + # generate peer configs + out.append('mkdir -p /etc/wireguard/clients') + for i, name in enumerate(peers, start=2): + ip = f'10.0.0.{i}/32' + out.append('wg genkey | tee /etc/wireguard/clients/' + name + '.key | wg pubkey > /etc/wireguard/clients/' + name + '.pub') + out.append('chmod 600 /etc/wireguard/clients/' + name + '.key') + # server conf + out.append('cat > /etc/wireguard/wg0.conf <> /etc/sysctl.d/99-wireguard.conf && sysctl -p /etc/sysctl.d/99-wireguard.conf') + out.append('systemctl enable --now wg-quick@wg0') + # build client confs + for i, name in enumerate(peers, start=2): + ip = f'10.0.0.{i}/32' + out.append('SERVER_PUB=$(cat /etc/wireguard/server.pub)') + out.append('CLIENT_PRIV=$(cat /etc/wireguard/clients/' + name + '.key)') + out.append('cat > /etc/wireguard/clients/' + name + '.conf < /etc/dnsmasq.conf < /etc/openvpn/server.conf <> /etc/sysctl.d/99-openvpn.conf && sysctl -p') + for i in range(1, n + 1): + cn = f'client{i}' + out.append('cd /etc/openvpn/easy-rsa && ./easyrsa gen-req ' + cn + ' nopass') + out.append('cd /etc/openvpn/easy-rsa && ./easyrsa sign-req client ' + cn + ' <<< "yes"') + out.append('cat > /etc/openvpn/' + cn + '.ovpn <\n$(cat /etc/openvpn/ca.crt)\n\n\n$(cat /etc/openvpn/easy-rsa/pki/issued/' + cn + '.crt)\n\n\n$(cat /etc/openvpn/easy-rsa/pki/private/' + cn + '.key)\n\n\n$(cat /etc/openvpn/ta.key)\n\nkey-direction 1\nOVPN_EOF') + return "\n".join(out) + "\n" + + +for _g in [WireGuard, Tailscale, Dnsmasq, OpenVPN]: + register(_g()) diff --git a/generators/runtimes.py b/generators/runtimes.py new file mode 100644 index 0000000..abbc5f9 --- /dev/null +++ b/generators/runtimes.py @@ -0,0 +1,651 @@ +""" +Runtime / language / compiler version installers. +Each generator supports multiple versions and uses source build for exotic versions +when no distro package is available. +""" +from . import register, Generator, Field +from . import bash_header, quote, bool_str, yes + + +# ========================== Python (pyenv-style from source) ========================== +class Python(Generator): + id = "python" + title = "Python 任意版本 (源码编译)" + category = "runtimes" + icon = "🐍" + tags = ["python", "pyenv", "source"] + description = "从 python.org 下载源码编译,支持 2.7 / 3.6 - 3.13 任意版本,启用 SSL/zlib/sqlite3。" + warnings = [ + "源码编译会下载 ~25MB,编译 5-15 分钟,需要 build-essential。", + "建议先运行 gcc 编译器安装脚本(同分类)以保证编译依赖。", + ] + fields = [ + Field("version", "Python 版本", "select", default="3.12.6", + options=["2.7.18", "3.6.15", "3.7.17", "3.8.20", "3.9.20", + "3.10.14", "3.11.11", "3.12.6", "3.13.0", "3.13.1"]), + Field("install_dir", "安装目录", "text", default="/opt/python", + help="最终路径 = $install_dir/$version (如 /opt/python/3.12.6)"), + Field("enable_optimizations", "启用 PGO/LTO (慢但快 ~10%)", "checkbox", default="yes"), + Field("shared", "编译为 .so 共享库", "checkbox", default="no", + help="如果其他工具(如 mod_wsgi)需要动态链接则开启。"), + Field("install_pip", "安装 pip", "checkbox", default="yes"), + Field("symlink_bin", "软链 bin 目录到 PATH", "checkbox", default="no", + help="yes = 软链 /opt/python/$version/bin/* 到 /usr/local/bin/"), + Field("ssl_backend", "SSL 后端", "select", default="openssl", + options=["openssl", "libressl"]), + ] + + def render(self, p): + ver = p.get("version", "3.12.6") + d = p.get("install_dir", "/opt/python") + opt = " --enable-optimizations" if bool_str(p.get("enable_optimizations", True)) else "" + shared = " --enable-shared" if bool_str(p.get("shared")) else "" + ssl = p.get("ssl_backend", "openssl") + sym = bool_str(p.get("symlink_bin")) + pip = bool_str(p.get("install_pip", True)) + + out = [bash_header(self.title)] + out.append('log "Installing Python ' + ver + ' from source..."') + out.append('command -v gcc >/dev/null || $PKG_INSTALL gcc make build-essential \\\n' + ' libssl-dev zlib1g-dev libbz2-dev libreadline-dev libsqlite3-dev \\\n' + ' libncursesw5-dev xz-utils tk-dev libxml2-dev libxmlsec1-dev libffi-dev liblzma-dev \\\n' + ' || $PKG_INSTALL gcc make openssl-devel bzip2-devel libffi-devel zlib-devel \\\n' + ' readline-devel sqlite-devel ncurses-devel xz-devel') + out.append('cd /tmp') + out.append('curl -fsSL -o Python.tgz https://www.python.org/ftp/python/' + ver + '/Python-' + ver + '.tgz') + out.append('tar -xzf Python.tgz') + out.append('cd Python-' + ver) + cfg = ('./configure --prefix=' + d + '/' + ver + + ' --with-ensurepip=install' if pip else './configure --prefix=' + d + '/' + ver + ' --without-ensurepip') + cfg += opt + shared + if ssl == "libressl": + cfg += ' --with-openssl=/usr/local' + out.append(cfg) + out.append('make -j"$(nproc)"') + out.append('make altinstall') + out.append('cd /tmp && rm -rf Python-' + ver + ' Python.tgz') + out.append('') + out.append('PYBIN=' + d + '/' + ver + '/bin/python' + '.'.join(ver.split('.')[:2])) + if sym: + out.append('log "Symlinking binaries to /usr/local/bin/..."') + out.append('for b in ' + d + '/' + ver + '/bin/*; do\n' + ' ln -sf "$b" /usr/local/bin/"$(basename "$b")" || true\n' + 'done') + out.append('log "Verifying..."') + out.append('$PYBIN --version') + if pip: + out.append('$PYBIN -m pip --version || $PYBIN -m ensurepip --upgrade') + return "\n".join(out) + "\n" + + +# ========================== Node.js (nvm-style from binary) ========================== +class NodeJS(Generator): + id = "nodejs" + title = "Node.js 任意版本 (官方二进制)" + category = "runtimes" + icon = "🟢" + tags = ["nodejs", "node", "npm"] + description = "从 nodejs.org 下载预编译二进制,可选装 yarn / pnpm / nvm。" + fields = [ + Field("version", "Node 版本", "select", default="24", + options=["18", "20", "22", "24", "26"]), + Field("install_dir", "安装目录", "text", default="/opt/nodejs"), + Field("symlink_bin", "软链 bin 目录", "checkbox", default="yes"), + Field("install_yarn", "安装 yarn", "checkbox", default="yes"), + Field("install_pnpm", "安装 pnpm", "checkbox", default="yes"), + Field("npm_registry", "NPM Registry", "text", + default="https://registry.npmmirror.com", + help="默认走国内镜像,留空 = 官方。"), + ] + + def render(self, p): + ver = p.get("version", "20") + d = p.get("install_dir", "/opt/nodejs") + sym = bool_str(p.get("symlink_bin", True)) + yarn = bool_str(p.get("install_yarn", True)) + pnpm = bool_str(p.get("install_pnpm", True)) + reg = p.get("npm_registry", "https://registry.npmmirror.com") + + out = [bash_header(self.title)] + out.append('log "Installing Node.js v' + ver + '..."') + out.append('cd /tmp') + out.append('curl -fsSL -o node.tar.xz https://nodejs.org/dist/v' + ver + '.x/node-v' + ver + '.x-linux-x64.tar.xz') + out.append('rm -rf ' + d) + out.append('mkdir -p ' + d) + out.append('tar -xJf node.tar.xz --strip-components=1 -C ' + d) + out.append('rm -f node.tar.xz') + if sym: + out.append('for b in ' + d + '/bin/*; do ln -sf "$b" /usr/local/bin/"$(basename "$b")"; done') + if reg: + out.append('npm config set registry ' + quote(reg)) + if yarn: + out.append('npm install -g yarn') + if pnpm: + out.append('npm install -g pnpm') + out.append('node --version') + out.append('npm --version') + return "\n".join(out) + "\n" + + +# ========================== GCC (from source) ========================== +class GCC(Generator): + id = "gcc" + title = "GCC 任意版本 (源码编译)" + category = "runtimes" + icon = "🛠️" + tags = ["gcc", "compiler", "source"] + description = "从 gcc.gnu.org 下载源码,编译并安装到 /opt/gcc-。" + warnings = [ + "GCC 12+ 编译需 30-60 分钟,需要约 5GB 磁盘。", + "建议另装 'system gcc' (apt/yum) 留作系统默认,本工具不替换系统 gcc。", + ] + fields = [ + Field("version", "GCC 版本", "select", default="13.2.0", + options=["8.5.0", "9.5.0", "10.5.0", "11.4.0", "12.3.0", + "13.2.0", "14.1.0"]), + Field("languages", "支持语言", "text", default="c,c++,fortran", + help="逗号分隔,如 c,c++,objc,fortran,go"), + Field("install_dir", "安装目录", "text", default="/opt/gcc", + help="最终 = $install_dir/"), + Field("enable_lto", "启用 LTO (慢但快 ~10%)", "checkbox", default="no"), + Field("enable_libsan", "启用 Sanitizers (ASan/TSan)", "checkbox", default="no"), + Field("make_jobs", "make -j 任务数", "text", default="$(nproc)", + help="默认 = CPU 核数,改小可降低内存峰值。"), + ] + + def render(self, p): + ver = p.get("version", "13.2.0") + langs = p.get("languages", "c,c++,fortran") + d = p.get("install_dir", "/opt/gcc") + lto = " --enable-lto" if bool_str(p.get("enable_lto")) else "" + libsan = " --enable-libsanitizer" if bool_str(p.get("enable_libsan")) else "" + jobs = p.get("make_jobs", "$(nproc)") + + out = [bash_header(self.title)] + out.append('log "Installing GCC ' + ver + ' from source..."') + out.append('$PKG_INSTALL wget gcc gcc-c++ make texinfo bison flex') + out.append('cd /tmp') + out.append('curl -fsSL -O https://ftp.gnu.org/gnu/gcc/gcc-' + ver + '/gcc-' + ver + '.tar.xz') + out.append('tar -xJf gcc-' + ver + '.tar.xz') + out.append('cd gcc-' + ver) + out.append('./contrib/download_prerequisites') + out.append('mkdir -p build && cd build') + out.append('../configure --prefix=' + d + '/' + ver + + ' --enable-languages=' + langs + + ' --disable-multilib --with-system-zlib' + lto + libsan) + out.append('make -j' + jobs) + out.append('make install') + out.append('cd /tmp && rm -rf gcc-' + ver + ' gcc-' + ver + '.tar.xz build') + out.append('log "Verifying..."') + out.append(d + '/' + ver + '/bin/gcc --version') + out.append('echo "Add to PATH: export PATH=' + d + '/' + ver + '/bin:$PATH"') + return "\n".join(out) + "\n" + + +# ========================== Make ========================== +class Make(Generator): + id = "make" + title = "GNU Make 任意版本 (源码编译)" + category = "runtimes" + icon = "🔨" + tags = ["make", "build", "gnu"] + description = "从 gnu.org 下载 make 源码并安装到 /opt。" + fields = [ + Field("version", "Make 版本", "select", default="4.4.1", + options=["4.2.1", "4.3", "4.4", "4.4.1"]), + Field("install_dir", "安装目录", "text", default="/opt/make"), + ] + + def render(self, p): + ver = p.get("version", "4.4.1") + d = p.get("install_dir", "/opt/make") + out = [bash_header(self.title)] + out.append('log "Installing make ' + ver + '..."') + out.append('$PKG_INSTALL gcc make') + out.append('cd /tmp') + out.append('curl -fsSL -O https://ftp.gnu.org/gnu/make/make-' + ver + '.tar.gz') + out.append('tar -xzf make-' + ver + '.tar.gz') + out.append('cd make-' + ver) + out.append('./configure --prefix=' + d + '/' + ver) + out.append('make -j"$(nproc)"') + out.append('make install') + out.append('cd /tmp && rm -rf make-' + ver + ' make-' + ver + '.tar.gz') + out.append(d + '/' + ver + '/bin/make --version') + return "\n".join(out) + "\n" + + +# ========================== CMake ========================== +class CMake(Generator): + id = "cmake" + title = "CMake 任意版本 (官方脚本安装)" + category = "runtimes" + icon = "🧱" + tags = ["cmake", "build", "makefile"] + description = "从 cmake.org 下载官方二进制,安装到 /opt。" + fields = [ + Field("version", "CMake 版本", "select", default="3.30.0", + options=["3.20.0", "3.22.0", "3.24.0", "3.25.0", "3.26.0", + "3.27.0", "3.28.0", "3.29.0", "3.30.0"]), + Field("install_dir", "安装目录", "text", default="/opt/cmake"), + Field("symlink_bin", "软链到 /usr/local/bin", "checkbox", default="yes"), + ] + + def render(self, p): + ver = p.get("version", "3.30.0") + d = p.get("install_dir", "/opt/cmake") + sym = bool_str(p.get("symlink_bin", True)) + out = [bash_header(self.title)] + out.append('log "Installing CMake ' + ver + '..."') + out.append('cd /tmp') + out.append('curl -fsSL -O https://github.com/Kitware/CMake/releases/download/v' + ver + '/cmake-' + ver + '-linux-x86_64.tar.gz') + out.append('rm -rf ' + d) + out.append('mkdir -p ' + d) + out.append('tar -xzf cmake-' + ver + '-linux-x86_64.tar.gz --strip-components=1 -C ' + d) + out.append('rm -f cmake-' + ver + '-linux-x86_64.tar.gz') + if sym: + out.append('for b in ' + d + '/bin/*; do ln -sf "$b" /usr/local/bin/"$(basename "$b")"; done') + out.append('cmake --version') + return "\n".join(out) + "\n" + + +# ========================== Go ========================== +class GoLang(Generator): + id = "golang" + title = "Go 任意版本 (官方二进制)" + category = "runtimes" + icon = "🐹" + tags = ["go", "golang"] + description = "从 go.dev 下载 Go 官方二进制,设置 GOPROXY 国内镜像。" + fields = [ + Field("version", "Go 版本", "select", default="1.22.5", + options=["1.18.10", "1.19.13", "1.20.14", "1.21.12", "1.22.5", "1.23.0"]), + Field("install_dir", "安装目录", "text", default="/opt/go"), + Field("goproxy", "GOPROXY", "text", default="https://goproxy.cn,direct", + help="默认 goproxy.cn,留空 = GOPROXY=off"), + Field("symlink_bin", "软链到 /usr/local/bin", "checkbox", default="yes"), + ] + + def render(self, p): + ver = p.get("version", "1.22.5") + d = p.get("install_dir", "/opt/go") + proxy = p.get("goproxy", "https://goproxy.cn,direct") + sym = bool_str(p.get("symlink_bin", True)) + + out = [bash_header(self.title)] + out.append('log "Installing Go ' + ver + '..."') + out.append('cd /tmp') + out.append('curl -fsSL -O https://go.dev/dl/go' + ver + '.linux-amd64.tar.gz') + out.append('rm -rf ' + d) + out.append('tar -C ' + d.rsplit('/', 1)[0] + ' -xzf go' + ver + '.linux-amd64.tar.gz') + out.append('mv ' + d.rsplit('/', 1)[0] + '/go ' + d) + out.append('rm -f go' + ver + '.linux-amd64.tar.gz') + if sym: + out.append('for b in ' + d + '/bin/*; do ln -sf "$b" /usr/local/bin/"$(basename "$b")"; done') + if proxy: + out.append('go env -w GOPROXY=' + quote(proxy)) + out.append('go env -w GOSUMDB=sum.golang.google.cn') + out.append('go version') + out.append('echo "Add to PATH: export PATH=' + d + '/bin:$PATH"') + return "\n".join(out) + "\n" + + +# ========================== OpenJDK ========================== +class Java(Generator): + id = "java" + title = "OpenJDK 任意版本 (官方二进制)" + category = "runtimes" + icon = "☕" + tags = ["java", "jdk", "openjdk"] + description = "从 Adoptium 仓库下载 OpenJDK 预编译版本,无需 apt 仓库。" + fields = [ + Field("version", "JDK 版本", "select", default="21", + options=["8", "11", "17", "21"]), + Field("install_dir", "安装目录", "text", default="/opt/jdk"), + Field("symlink_bin", "软链到 /usr/local/bin", "checkbox", default="yes"), + ] + + def render(self, p): + ver = p.get("version", "21") + d = p.get("install_dir", "/opt/jdk") + sym = bool_str(p.get("symlink_bin", True)) + out = [bash_header(self.title)] + out.append('log "Installing OpenJDK ' + ver + '..."') + out.append('cd /tmp') + out.append('curl -fsSL -o jdk.tar.gz "https://download.java.net/java/GA/jdk' + ver + '.0.2/f2283984656d49d69e91c558476027ac/13/GPL/openjdk-' + ver + '.0.2_linux-x64_bin.tar.gz"') + out.append('rm -rf ' + d) + out.append('mkdir -p ' + d) + out.append('tar -xzf jdk.tar.gz --strip-components=1 -C ' + d) + out.append('rm -f jdk.tar.gz') + if sym: + out.append('update-alternatives --install /usr/bin/java java ' + d + '/bin/java 9999 || true') + out.append('update-alternatives --install /usr/bin/javac javac ' + d + '/bin/javac 9999 || true') + out.append('for b in ' + d + '/bin/*; do ln -sf "$b" /usr/local/bin/"$(basename "$b")"; done') + out.append('export JAVA_HOME=' + d) + out.append(d + '/bin/java --version') + out.append('echo "Add to PATH: export PATH=' + d + '/bin:$PATH"') + return "\n".join(out) + "\n" + + +# ========================== PHP ========================== +class PHP(Generator): + id = "php" + title = "PHP 任意版本 (源码编译)" + category = "runtimes" + icon = "🐘" + tags = ["php", "apache", "nginx", "fpm"] + description = "从 php.net 编译 PHP,启用 fpm、常用扩展。" + fields = [ + Field("version", "PHP 版本", "select", default="8.3.10", + options=["7.4.33", "8.0.30", "8.1.29", "8.2.22", "8.3.10"]), + Field("install_dir", "安装目录", "text", default="/opt/php"), + Field("enable_fpm", "启用 PHP-FPM", "checkbox", default="yes"), + Field("extensions", "编译扩展", "text", + default="mysqli,pdo,pdo_mysql,gd,mbstring,curl,xml,zip,intl,opcache,bcmath"), + ] + + def render(self, p): + ver = p.get("version", "8.3.10") + d = p.get("install_dir", "/opt/php") + fpm = bool_str(p.get("enable_fpm", True)) + exts = p.get("extensions", "mysqli,pdo,pdo_mysql,gd,mbstring,curl,xml,zip,intl,opcache,bcmath") + out = [bash_header(self.title)] + out.append('log "Installing PHP ' + ver + ' from source..."') + out.append('$PKG_INSTALL gcc make libxml2-dev libssl-dev libcurl4-openssl-dev \\\n' + ' libonig-dev libzip-dev libicu-dev libsqlite3-dev libpng-dev libjpeg-dev \\\n' + ' libfreetype6-dev libwebp-dev libxslt1-dev libreadline-dev 2>/dev/null \\\n' + ' || $PKG_INSTALL gcc make libxml2-devel openssl-devel libcurl-devel \\\n' + ' libonig-devel libzip-devel libicu-devel sqlite-devel libpng-devel \\\n' + ' libjpeg-devel freetype-devel libwebp-devel libxslt-devel readline-devel') + out.append('cd /tmp') + out.append('curl -fsSL -O https://www.php.net/distributions/php-' + ver + '.tar.gz') + out.append('tar -xzf php-' + ver + '.tar.gz') + out.append('cd php-' + ver) + cfg = './configure --prefix=' + d + '/' + ver + cfg += ' --with-config-file-path=' + d + '/' + ver + '/etc' + cfg += ' --with-config-file-scan-dir=' + d + '/' + ver + '/etc/php.d' + cfg += ' --enable-mbstring --enable-fpm' if fpm else '' + cfg += ' --with-curl --with-openssl --with-zip --with-zlib' + cfg += ' --with-pdo-mysql --with-mysqli' + cfg += ' --enable-opcache' + cfg += ' --enable-bcmath --enable-intl --enable-pcntl --enable-sockets' + for e in exts.split(','): + e = e.strip() + if not e: + continue + if e in ("mysqli", "pdo_mysql", "pdo", "gd", "mbstring", "curl", "xml", + "zip", "intl", "opcache", "bcmath", "fpm"): + continue + out.append(cfg) + out.append('make -j"$(nproc)"') + out.append('make install') + out.append('mkdir -p ' + d + '/' + ver + '/etc/php.d') + out.append('cp php.ini-production ' + d + '/' + ver + '/etc/php.ini') + out.append('cp sapi/fpm/php-fpm.conf ' + d + '/' + ver + '/etc/ || true') + out.append('cp sapi/fpm/www.conf.default ' + d + '/' + ver + '/etc/www.conf || true') + out.append('cd /tmp && rm -rf php-' + ver + ' php-' + ver + '.tar.gz') + out.append(d + '/' + ver + '/bin/php --version') + return "\n".join(out) + "\n" + + +# ========================== Ruby ========================== +class Ruby(Generator): + id = "ruby" + title = "Ruby 任意版本 (源码编译)" + category = "runtimes" + icon = "💎" + tags = ["ruby", "rails"] + description = "从 cache.ruby-lang.org 编译 Ruby,启用 readline/openssl/zlib。" + fields = [ + Field("version", "Ruby 版本", "select", default="3.3.3", + options=["2.7.8", "3.0.7", "3.1.6", "3.2.4", "3.3.3"]), + Field("install_dir", "安装目录", "text", default="/opt/ruby"), + ] + + def render(self, p): + ver = p.get("version", "3.3.3") + d = p.get("install_dir", "/opt/ruby") + out = [bash_header(self.title)] + out.append('log "Installing Ruby ' + ver + '..."') + out.append('$PKG_INSTALL gcc make build-essential libssl-dev libreadline-dev zlib1g-dev \\\n' + ' libyaml-dev libgmp-dev libffi-dev libgdbm-dev libdb-dev libncurses5-dev 2>/dev/null \\\n' + ' || $PKG_INSTALL gcc make openssl-devel readline-devel zlib-devel \\\n' + ' libyaml-devel gmp-devel libffi-devel gdbm-devel ncurses-devel') + out.append('cd /tmp') + out.append('curl -fsSL -O https://cache.ruby-lang.org/pub/ruby/' + ver.split('.')[0] + '.' + ver.split('.')[1] + '/ruby-' + ver + '.tar.gz') + out.append('tar -xzf ruby-' + ver + '.tar.gz') + out.append('cd ruby-' + ver) + out.append('./configure --prefix=' + d + '/' + ver + ' --enable-shared --disable-install-doc') + out.append('make -j"$(nproc)"') + out.append('make install') + out.append('cd /tmp && rm -rf ruby-' + ver + ' ruby-' + ver + '.tar.gz') + out.append(d + '/' + ver + '/bin/ruby --version') + return "\n".join(out) + "\n" + + +# ========================== Rust ========================== +class RustLang(Generator): + id = "rust" + title = "Rust 工具链 (rustup)" + category = "runtimes" + icon = "🦀" + tags = ["rust", "rustup", "cargo"] + description = "通过 rustup 安装 Rust,可指定 nightly / stable 及 toolchain 路径。" + fields = [ + Field("channel", "Channel", "select", default="stable", + options=["stable", "beta", "nightly"]), + Field("install_dir", "安装目录", "text", default="/opt/rust"), + Field("default_toolchain", "默认 toolchain", "text", default="stable"), + Field("install_components", "组件列表", "text", + default="rustfmt,clippy,rust-src,rust-analyzer"), + Field("mirror", "rsproxy 国内镜像", "checkbox", default="yes", + help="启用后会写 RUSTUP_DIST_SERVER 环境变量。"), + ] + + def render(self, p): + ch = p.get("channel", "stable") + d = p.get("install_dir", "/opt/rust") + tool = p.get("default_toolchain", "stable") + comps = p.get("install_components", "rustfmt,clippy,rust-src,rust-analyzer") + mirror = bool_str(p.get("mirror", True)) + + out = [bash_header(self.title)] + out.append('log "Installing rustup + ' + ch + '..."') + out.append('command -v gcc >/dev/null || $PKG_INSTALL gcc make build-essential \\\n' + ' || $PKG_INSTALL gcc make') + if mirror: + out.append('export RUSTUP_DIST_SERVER=https://rsproxy.cn') + out.append('export RUSTUP_UPDATE_ROOT=https://rsproxy.cn/rustup') + out.append('curl --proto =https --tlsv1.2 -sSf https://sh.rustup.rs | ' + 'sh -s -- -y --default-toolchain none --no-modify-path --prefix=' + d) + out.append('. ' + d + '/env') + out.append('rustup default ' + tool) + out.append('rustup toolchain install ' + ch) + for c in comps.split(','): + c = c.strip() + if c: + out.append('rustup component add ' + c + ' --toolchain ' + ch) + out.append('rustc --version') + out.append('cargo --version') + return "\n".join(out) + "\n" + + +# ========================== PostgreSQL ========================== +class PostgreSQL(Generator): + id = "postgresql" + title = "PostgreSQL 任意版本 (官方 apt/yum 源)" + category = "databases" + icon = "🐘" + tags = ["postgresql", "pg", "database"] + description = "从 PostgreSQL Global Development Group 官方源安装任意版本。" + fields = [ + Field("version", "PG 版本", "select", default="16", + options=["12", "13", "14", "15", "16"]), + Field("port", "端口", "number", default="5432", min_=1, max_=65535), + Field("listen_addresses", "listen_addresses", "text", default="*"), + Field("admin_user", "超级用户", "text", default="postgres"), + Field("admin_password", "超级用户密码", "password", default="changeme"), + Field("data_dir", "数据目录", "text", default="/var/lib/pgsql/data"), + ] + + def render(self, p): + ver = p.get("version", "16") + port = str(p.get("port", "5432")) + listen = p.get("listen_addresses", "*") + user = p.get("admin_user", "postgres") + pwd = p.get("admin_password", "changeme") + out = [bash_header(self.title)] + out.append('log "Installing PostgreSQL ' + ver + '..."') + out.append('case "$PKG" in\n' + ' apt-get)\n' + ' $PKG_INSTALL wget gnupg lsb-release ca-certificates\n' + ' curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /usr/share/keyrings/pgdg.gpg\n' + ' echo "deb [signed-by=/usr.share/keyrings/pgdg.gpg] http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list\n' + ' apt-get update\n' + ' $PKG_INSTALL postgresql-' + ver + ' postgresql-client-' + ver + ' postgresql-contrib-' + ver + '\n' + ' ;;\n' + ' yum|dnf)\n' + ' $PKG_INSTALL https://download.postgresql.org/pub/repos/yum/reporpms/EL-$(rpm -E %{rhel})-x86_64/pgdg-redhat-repo-latest.noarch.rpm\n' + ' $PKG_INSTALL postgresql' + ver + '-server postgresql' + ver + '-contrib\n' + ' /usr/pgsql-' + ver + '/bin/postgresql-' + ver + '-setup initdb\n' + ' ;;\n' + 'esac') + out.append('systemctl enable --now postgresql') + out.append('sleep 2') + out.append('sudo -u postgres psql -c "ALTER USER ' + user + ' WITH PASSWORD \'' + pwd + '\';"') + out.append('echo "host all all 0.0.0.0/0 md5" >> /etc/postgresql/' + ver + '/main/pg_hba.conf 2>/dev/null || true') + out.append('echo "host all all 0.0.0.0/0 md5" >> /var/lib/pgsql/data/pg_hba.conf 2>/dev/null || true') + out.append('sed -i "s/^#listen_addresses.*/listen_addresses = \'' + listen + '\'/" /etc/postgresql/' + ver + '/main/postgresql.conf 2>/dev/null || true') + out.append('sed -i "s/^#listen_addresses.*/listen_addresses = \'' + listen + '\'/" /var/lib/pgsql/data/postgresql.conf 2>/dev/null || true') + out.append('sed -i "s/^port.*/port = ' + port + '/" /etc/postgresql/' + ver + '/main/postgresql.conf 2>/dev/null || true') + out.append('sed -i "s/^port.*/port = ' + port + '/" /var/lib/pgsql/data/postgresql.conf 2>/dev/null || true') + out.append('systemctl restart postgresql') + return "\n".join(out) + "\n" + + +# ========================== MySQL ========================== +class MySQL(Generator): + id = "mysql" + title = "MySQL / MariaDB 任意版本" + category = "databases" + icon = "🐬" + tags = ["mysql", "mariadb", "database"] + description = "支持 MySQL 5.7/8.0/8.4 或 MariaDB 10.x,自动初始化 root 密码。" + fields = [ + Field("variant", "变体", "select", default="mysql", + options=["mysql", "mariadb"]), + Field("version", "版本", "select", default="8.0", + options=["5.7", "8.0", "8.4", "10.11", "11.4"]), + Field("port", "端口", "number", default="3306", min_=1, max_=65535), + Field("root_password", "root 密码", "password", default="changeme"), + Field("bind_address", "Bind 地址", "text", default="0.0.0.0"), + ] + + def render(self, p): + var = p.get("variant", "mysql") + ver = p.get("version", "8.0") + port = str(p.get("port", "3306")) + pwd = p.get("root_password", "changeme") + bind = p.get("bind_address", "0.0.0.0") + out = [bash_header(self.title)] + if var == "mysql": + out.append('log "Installing MySQL ' + ver + '..."') + out.append('case "$PKG" in\n' + ' apt-get)\n' + ' $PKG_INSTALL wget gnupg lsb-release\n' + ' wget -c https://dev.mysql.com/get/mysql-apt-config_0.8.29-1_all.deb\n' + ' DEBIAN_FRONTEND=noninteractive dpkg -i mysql-apt-config_0.8.29-1_all.deb || true\n' + ' apt-get update\n' + ' $PKG_INSTALL mysql-server mysql-client\n' + ' ;;\n' + ' yum|dnf)\n' + ' $PKG_INSTALL https://dev.mysql.com/get/mysql80-community-release-el$(rpm -E %{rhel})-1.noarch.rpm\n' + ' $PKG_INSTALL mysql-community-server mysql-community-client\n' + ' ;;\n' + 'esac') + else: + out.append('log "Installing MariaDB ' + ver + '..."') + out.append('case "$PKG" in\n' + ' apt-get) $PKG_INSTALL mariadb-server mariadb-client ;;\n' + ' yum|dnf) $PKG_INSTALL mariadb-server mariadb ;;\n' + 'esac') + out.append('systemctl enable --now mysql || systemctl enable --now mariadb') + out.append('sleep 3') + out.append('mysql -u root -e "ALTER USER \'root\'@\'localhost\' IDENTIFIED BY \'' + pwd + '\';" 2>/dev/null || true') + out.append('mysql -u root -p\'' + pwd + '\' -e "CREATE USER IF NOT EXISTS \'root\'@\'%\' IDENTIFIED BY \'' + pwd + '\';" 2>/dev/null || true') + out.append('mysql -u root -p\'' + pwd + '\' -e "GRANT ALL ON *.* TO \'root\'@\'%\' WITH GRANT OPTION; FLUSH PRIVILEGES;" 2>/dev/null || true') + out.append('sed -i "s/^bind-address.*/bind-address = ' + bind + '/" /etc/mysql/mysql.conf.d/mysqld.cnf 2>/dev/null || true') + out.append('sed -i "s/^bind-address.*/bind-address = ' + bind + '/" /etc/my.cnf 2>/dev/null || true') + out.append('sed -i "s/^port.*/port = ' + port + '/" /etc/mysql/mysql.conf.d/mysqld.cnf 2>/dev/null || true') + out.append('sed -i "s/^port.*/port = ' + port + '/" /etc/my.cnf 2>/dev/null || true') + out.append('systemctl restart mysql || systemctl restart mariadb') + return "\n".join(out) + "\n" + + +# ========================== MongoDB ========================== +class MongoDB(Generator): + id = "mongodb" + title = "MongoDB 任意版本" + category = "databases" + icon = "🍃" + tags = ["mongodb", "nosql", "document"] + description = "从 MongoDB 官方源安装任意版本,启用 replica set。" + fields = [ + Field("version", "MongoDB 版本", "select", default="7.0", + options=["4.4", "5.0", "6.0", "7.0"]), + Field("port", "端口", "number", default="27017", min_=1, max_=65535), + Field("bind_ip", "Bind IP", "text", default="0.0.0.0"), + Field("enable_auth", "启用鉴权", "checkbox", default="yes"), + Field("root_user", "root 用户", "text", default="root"), + Field("root_password", "root 密码", "password", default="changeme"), + Field("repl_set", "副本集名 (留空=单点)", "text", default=""), + ] + + def render(self, p): + ver = p.get("version", "7.0") + port = str(p.get("port", "27017")) + bind = p.get("bind_ip", "0.0.0.0") + auth = bool_str(p.get("enable_auth", True)) + user = p.get("root_user", "root") + pwd = p.get("root_password", "changeme") + repl = p.get("repl_set", "").strip() + out = [bash_header(self.title)] + out.append('log "Installing MongoDB ' + ver + '..."') + out.append('case "$PKG" in\n' + ' apt-get)\n' + ' $PKG_INSTALL wget gnupg curl\n' + ' curl -fsSL https://www.mongodb.org/static/pgp/server-' + ver + '.asc | gpg --dearmor -o /usr/share/keyrings/mongodb.gpg\n' + ' echo "deb [signed-by=/usr/share/keyrings/mongodb.gpg] https://repo.mongodb.org/apt/ubuntu $(grep VERSION_CODENAME /etc/os-release | cut -d= -f2)/mongodb-org/' + ver + ' multiverse" > /etc/apt/sources.list.d/mongodb-org-' + ver + '.list\n' + ' apt-get update\n' + ' $PKG_INSTALL mongodb-org\n' + ' ;;\n' + ' yum|dnf)\n' + ' cat > /etc/yum.repos.d/mongodb-org-' + ver + '.repo <> /etc/mongod.conf <> /etc/fstab') + out.append('sysctl -w vm.swappiness=' + sw) + out.append('grep -q "vm.swappiness" /etc/sysctl.conf || echo "vm.swappiness=' + sw + '" >> /etc/sysctl.conf') + out.append('swapon --show') + return "\n".join(out) + "\n" + + +# ========================== Time sync (chrony) ========================== +class Chrony(Generator): + id = "chrony" + title = "时间同步 (chrony)" + category = "system" + icon = "🕐" + tags = ["time", "ntp", "chrony"] + description = "安装并配置 chrony,使用国内 NTP 源。" + fields = [ + Field("servers", "NTP 服务器", "textarea", + default="ntp.aliyun.com\nntp1.aliyun.com\nntp2.aliyun.com\ntime.cloudflare.com", + help="一行一个。"), + Field("makestep_threshold", "makestep 阈值 (秒)", "text", default="1.0 3"), + Field("allow_subnet", "允许同步的子网", "text", default="192.168.0.0/16", + help="仅本机同步: 留空 = 127.0.0.0/8"), + ] + + def render(self, p): + srvs = [s.strip() for s in p.get("servers", "").splitlines() if s.strip()] + ms = p.get("makestep_threshold", "1.0 3") + allow = p.get("allow_subnet", "192.168.0.0/16").strip() + out = [bash_header(self.title)] + out.append('log "Installing chrony..."') + out.append('$PKG_INSTALL chrony') + out.append('cp /etc/chrony/chrony.conf /etc/chrony/chrony.conf.bak.$(date +%s) || true') + out.append('cat > /etc/chrony/chrony.conf <> /etc/chrony/chrony.conf') + out.append('systemctl enable --now chrony') + out.append('sleep 2') + out.append('chronyc tracking') + out.append('chronyc sources -v') + return "\n".join(out) + "\n" + + +# ========================== SSH Hardening ========================== +class SSHHardening(Generator): + id = "ssh-hardening" + title = "SSH 服务加固" + category = "security" + icon = "🔐" + tags = ["ssh", "security", "hardening"] + description = "修改 sshd_config:禁 root 登录、改端口、密钥认证、登录横幅。" + warnings = [ + "修改前请确保有可登录的用户和密钥,否则会锁死!", + "建议保留当前 SSH 会话,新开一个窗口测试登录成功后再关闭。", + ] + fields = [ + Field("port", "新 SSH 端口", "number", default="2222", min_=1, max_=65535), + Field("permit_root_login", "允许 root 登录", "select", default="no", + options=["yes", "no", "prohibit-password", "without-password", "forced-commands-only"]), + Field("password_auth", "允许密码认证", "checkbox", default="no"), + Field("pubkey_auth", "公钥认证", "checkbox", default="yes"), + Field("allow_users", "允许登录的用户 (逗号分隔)", "text", default=""), + Field("allow_groups", "允许登录的组", "text", default=""), + Field("max_auth_tries", "最大认证尝试", "number", default="3"), + Field("max_sessions", "最大会话数", "number", default="5"), + Field("client_alive_interval", "心跳间隔", "number", default="300"), + Field("client_alive_count_max", "心跳失败次数", "number", default="2"), + Field("banner_text", "登录横幅", "textarea", default="Authorised access only. All activity is logged."), + ] + + def render(self, p): + port = str(p.get("port", "2222")) + proot = p.get("permit_root_login", "no") + passw = bool_str(p.get("password_auth")) + pubkey = bool_str(p.get("pubkey_auth", True)) + allow_users = p.get("allow_users", "").strip() + allow_groups = p.get("allow_groups", "").strip() + mt = str(p.get("max_auth_tries", "3")) + ms = str(p.get("max_sessions", "5")) + cai = str(p.get("client_alive_interval", "300")) + cacm = str(p.get("client_alive_count_max", "2")) + banner = p.get("banner_text", "").strip() + out = [bash_header(self.title)] + out.append('log "Backing up /etc/ssh/sshd_config..."') + out.append('cp /etc/ssh/sshd_config /etc/ssh/sshd_config.bak.$(date +%s)') + out.append('log "Patching sshd_config..."') + sshd_lines = [ + 'Port ' + port, + 'PermitRootLogin ' + proot, + 'PasswordAuthentication ' + ('yes' if passw else 'no'), + 'PubkeyAuthentication ' + ('yes' if pubkey else 'no'), + 'MaxAuthTries ' + mt, + 'MaxSessions ' + ms, + 'ClientAliveInterval ' + cai, + 'ClientAliveCountMax ' + cacm, + 'X11Forwarding no', + 'AllowTcpForwarding no', + 'PermitEmptyPasswords no', + 'UsePAM yes', + ] + if allow_users: + sshd_lines.append('AllowUsers ' + allow_users) + if allow_groups: + sshd_lines.append('AllowGroups ' + allow_groups) + out.append('cat > /etc/ssh/sshd_config.d/00-shellgen.conf < /etc/ssh/banner <> /etc/ssh/sshd_config.d/00-shellgen.conf') + out.append('sshd -t') + out.append('systemctl reload sshd || systemctl restart sshd') + out.append('log "Now listening on :' + port + ' — keep your session open and test from another window!"') + return "\n".join(out) + "\n" + + +# ========================== Sysctl tuning ========================== +class SysctlTuning(Generator): + id = "sysctl-tuning" + title = "Sysctl 内核参数调优" + category = "system" + icon = "🧬" + tags = ["sysctl", "kernel", "tuning"] + description = "常用 sysctl 参数:BBR 拥塞控制、TCP 缓冲区、文件句柄、IPv4 转发。" + fields = [ + Field("enable_bbr", "启用 BBR", "checkbox", default="yes"), + Field("tcp_tw_reuse", "TCP TIME_WAIT 复用", "checkbox", default="yes"), + Field("tcp_fastopen", "TCP Fast Open", "checkbox", default="yes"), + Field("ip_forward", "IPv4 转发", "checkbox", default="no"), + Field("net_core_somaxconn", "SOMAXCONN", "number", default="4096"), + Field("file_max", "fs.file-max", "number", default="2097152"), + Field("max_map_count", "vm.max_map_count (ES 需 262144)", "number", default="262144"), + Field("apply_immediately", "立即 sysctl -p", "checkbox", default="yes"), + ] + + def render(self, p): + bbr = bool_str(p.get("enable_bbr", True)) + tw = bool_str(p.get("tcp_tw_reuse", True)) + tfo = bool_str(p.get("tcp_fastopen", True)) + fwd = bool_str(p.get("ip_forward")) + somaxconn = str(p.get("net_core_somaxconn", "4096")) + fmax = str(p.get("file_max", "2097152")) + mmc = str(p.get("max_map_count", "262144")) + apply = bool_str(p.get("apply_immediately", True)) + out = [bash_header(self.title)] + out.append('log "Writing /etc/sysctl.d/99-shellgen.conf..."') + out.append('cat > /etc/sysctl.d/99-shellgen.conf <> /etc/sysctl.d/99-shellgen.conf < /etc/security/limits.d/99-shellgen.conf <> /etc/hosts') + out.append('hostname -f') + return "\n".join(out) + "\n" + + +# ========================== Disk auto-mount via fstab ========================== +class AutoMount(Generator): + id = "automount" + title = "磁盘自动挂载 (fstab)" + category = "system" + icon = "💽" + tags = ["mount", "fstab", "disk"] + description = "为指定磁盘创建 fstab 条目并挂载。" + fields = [ + Field("device", "设备 (UUID=/dev/sda1 或 LABEL=...)", "text", default="UUID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", + help="可用 lsblk -f 或 blkid 查询。"), + Field("mount_point", "挂载点", "text", default="/data"), + Field("fstype", "文件系统", "select", default="ext4", + options=["ext4", "ext3", "xfs", "btrfs", "ntfs", "vfat", "nfs", "cifs"]), + Field("mount_options", "挂载选项", "text", default="defaults,nofail"), + Field("dump", "dump 字段", "number", default="0"), + Field("pass", "fsck 顺序", "number", default="2"), + ] + + def render(self, p): + dev = p.get("device", "UUID=xxx") + mp = p.get("mount_point", "/data") + fs = p.get("fstype", "ext4") + opts = p.get("mount_options", "defaults,nofail") + dump = str(p.get("dump", "0")) + pss = str(p.get("pass", "2")) + out = [bash_header(self.title)] + out.append('mkdir -p ' + mp) + out.append('echo "' + dev + ' ' + mp + ' ' + fs + ' ' + opts + ' ' + dump + ' ' + pss + '" >> /etc/fstab') + out.append('mount -a') + out.append('df -h ' + mp) + return "\n".join(out) + "\n" + + +for _g in [Firewall, Swap, Chrony, SSHHardening, SysctlTuning, Ulimit, Hostname, AutoMount]: + register(_g()) diff --git a/generators/systemd_svc.py b/generators/systemd_svc.py new file mode 100644 index 0000000..a3111ec --- /dev/null +++ b/generators/systemd_svc.py @@ -0,0 +1,234 @@ +""" +Systemd service generator — a single, generic Generator that produces a fully-configured +systemd unit for arbitrary user-provided commands. Plus presets for common cases. +""" +from . import register, Generator, Field +from . import bash_header, quote, bool_str, yes + + +class SystemdGeneric(Generator): + id = "systemd-generic" + title = "通用 systemd 服务" + category = "systemd" + icon = "🛠️" + tags = ["systemd", "service", "unit"] + description = "为任意可执行命令生成 systemd unit 文件,支持开机自启、自动重启、日志轮转。" + fields = [ + Field("service_name", "服务名", "text", default="myapp", + help="将创建 /etc/systemd/system/.service"), + Field("description", "Description", "text", default="My custom app"), + Field("exec_start", "ExecStart 命令", "textarea", required=True, + default="/opt/myapp/bin/server --config /etc/myapp/server.conf", + help="要执行的命令,可以有多行(用 ; 串接)或用绝对路径。"), + Field("exec_stop", "ExecStop 命令", "text", default="", + help="留空则发 SIGTERM;填入完整命令。"), + Field("exec_reload", "ExecReload 命令", "text", default="", + help="通常是 'kill -HUP $MAINPID' 或 nginx -s reload。"), + Field("type", "Type", "select", default="simple", + options=["simple", "forking", "oneshot", "notify", "exec"]), + Field("user", "运行用户", "text", default="root"), + Field("group", "运行组", "text", default="root"), + Field("working_dir", "WorkingDirectory", "text", default="/"), + Field("env_vars", "环境变量", "textarea", default="NODE_ENV=production\nLOG_LEVEL=info", + help="每行 KEY=VALUE。"), + Field("restart", "Restart 策略", "select", default="on-failure", + options=["no", "always", "on-success", "on-failure", "on-abnormal", + "on-abort", "on-watchdog"]), + Field("restart_sec", "RestartSec (秒)", "number", default="5"), + Field("limit_nofile", "LimitNOFILE", "number", default="65536"), + Field("limit_nproc", "LimitNPROC", "number", default="65536"), + Field("kill_mode", "KillMode", "select", default="mixed", + options=["control-group", "process", "mixed", "none"]), + Field("enable_after_network", "After=network.target", "checkbox", default="yes"), + Field("enable_log_dir", "创建日志目录 /var/log/", "checkbox", default="yes"), + Field("auto_start", "脚本执行完自动 enable+start", "checkbox", default="yes"), + ] + + def render(self, p): + name = p.get("service_name", "myapp").replace(" ", "-") + desc = p.get("description", "My custom app") + exec_start = p.get("exec_start", "").strip() + if not exec_start: + raise ValueError("exec_start 不能为空") + exec_stop = p.get("exec_stop", "").strip() + exec_reload = p.get("exec_reload", "").strip() + type_ = p.get("type", "simple") + user = p.get("user", "root") + group = p.get("group", "root") + wd = p.get("working_dir", "/") + envs = [e.strip() for e in (p.get("env_vars", "") or "").splitlines() if e.strip() and "=" in e] + restart = p.get("restart", "on-failure") + restart_sec = str(p.get("restart_sec", "5")) + nofile = str(p.get("limit_nofile", "65536")) + nproc = str(p.get("limit_nproc", "65536")) + kill = p.get("kill_mode", "mixed") + after_nw = bool_str(p.get("enable_after_network", True)) + log_dir = bool_str(p.get("enable_log_dir", True)) + auto = bool_str(p.get("auto_start", True)) + + out = [bash_header(self.title)] + out.append('log "Writing unit file /etc/systemd/system/' + name + '.service"') + out.append('mkdir -p /etc/systemd/system') + if log_dir: + out.append('mkdir -p /var/log/' + name) + unit = ['[Unit]'] + unit.append('Description=' + desc) + if after_nw: + unit.append('After=network.target network-online.target') + unit.append('Wants=network-online.target') + unit.append('') + unit.append('[Service]') + unit.append('Type=' + type_) + unit.append('User=' + user) + unit.append('Group=' + group) + unit.append('WorkingDirectory=' + wd) + for env in envs: + unit.append('Environment="' + env.replace('"', '\\"') + '"') + unit.append('ExecStart=' + exec_start.replace('\n', ' && ')) + if exec_stop: + unit.append('ExecStop=' + exec_stop) + if exec_reload: + unit.append('ExecReload=' + exec_reload) + unit.append('Restart=' + restart) + unit.append('RestartSec=' + restart_sec) + unit.append('LimitNOFILE=' + nofile) + unit.append('LimitNPROC=' + nproc) + unit.append('KillMode=' + kill) + unit.append('StandardOutput=journal') + unit.append('StandardError=journal') + if log_dir: + unit.append('StandardOutput=append:/var/log/' + name + '/stdout.log') + unit.append('StandardError=append:/var/log/' + name + '/stderr.log') + unit.append('') + unit.append('[Install]') + unit.append('WantedBy=multi-user.target') + out.append('cat > /etc/systemd/system/' + name + '.service < + + + + +{% block title %}Shell-Gen · Linux 部署脚本生成器{% endblock %} + + + +
+
+ + shell-gen + Linux 一键部署脚本生成器 +
+ +
+ +{% block body %}{% endblock %} + +
+ Generated by shell-gen · {{ total }} generators + · + /healthz + · + Compatible with Ubuntu / Debian / CentOS / RHEL / Rocky / Alma +
+ + diff --git a/templates/generator.html b/templates/generator.html new file mode 100644 index 0000000..c00771e --- /dev/null +++ b/templates/generator.html @@ -0,0 +1,253 @@ +{% extends "base.html" %} +{% block title %}{{ gen.title }} · Shell-Gen{% endblock %} +{% block body %} +
+ + +
+
{{ gen.icon }}
+
+

{{ gen.title }}

+

{{ gen.description }}

+
+ {% for t in gen.tags %}#{{ t }}{% endfor %} + {% for o in gen.os_support %}{{ o }}{% endfor %} + {% if gen.dangerous %}⚠️ 危险操作{% endif %} +
+
+
+ + {% if gen.warnings %} +
+

⚠️ 注意事项

+
    + {% for w in gen.warnings %}
  • {{ w }}
  • {% endfor %} +
+
+ {% endif %} + +
+
+

📝 参数

+
+ {% for f in gen.fields %} +
+ + {% if f.type == 'select' %} + + {% elif f.type == 'textarea' %} + + {% elif f.type == 'checkbox' %} + + {% elif f.type == 'number' %} + + {% elif f.type == 'password' %} + + {% else %} + + {% endif %} + {% if f.help %}

{{ f.help }}

{% endif %} +
+ {% endfor %} +
+ + + +
+
+
+ +
+
+

📄 脚本预览

+
+ + 0 行 +
+
+
// 点击「👁 预览」生成脚本
+
已复制
+ +
+
+ + {% if gen.post_steps or gen.verify_steps %} +
+ {% if gen.post_steps %} +

🛠 安装后操作

+
    + {% for s in gen.post_steps %}
  1. {{ s }}
  2. {% endfor %} +
+ {% endif %} + {% if gen.verify_steps %} +

✅ 验证

+
    + {% for s in gen.verify_steps %}
  1. {{ s }}
  2. {% endfor %} +
+ {% endif %} +
+ {% endif %} +
+ + + + +{% endblock %} diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..1472890 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,72 @@ +{% extends "base.html" %} +{% block title %}Shell-Gen · 首页{% endblock %} +{% block body %} +
+
+

选择需要部署的服务

+

填写参数,实时生成可立即执行的 bash 脚本。所有脚本会先做 distro 检测 + (Ubuntu/Debian/CentOS/RHEL/Rocky/Alma) 再安装,不会破坏现有服务。

+
+
{{ total }}
生成器
+ {% for cat in all_cats %} + {% set n = all_cats|length %} + {% endfor %} +
{{ categories|length }}
分类
+
100%
开源
+
+
+ + {% for cat in categories %} +
+

{{ category_icons.get(cat, '📦') }} {{ category_labels.get(cat, cat) }}

+
+ {% for gid, gen in (request.application_generator_items if false else []) %}{% endfor %} + {# Render via Python loop using all generators grouped by cat #} + {% for gid, gen in [] %}{% endfor %} +
+
+ {# Render in JS to keep templating simple #} +
+
+ {% endfor %} + +
+

📚 全部生成器

+
+
+
+ + +{% endblock %}