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)
This commit is contained in:
cnbug
2026-08-04 00:36:35 +08:00
commit 319f683a16
17 changed files with 4177 additions and 0 deletions
+25
View File
@@ -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
+165
View File
@@ -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://<server>: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
+158
View File
@@ -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/<generator_id>")
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)
+199
View File
@@ -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)
+212
View File
@@ -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 <<UNIT_EOF\n'
'[Unit]\nDescription=Elasticsearch\nAfter=network.target\n\n'
'[Service]\nType=simple\n'
'User=elasticsearch\nGroup=elasticsearch\n'
'LimitNOFILE=65536\n'
'Environment=ES_HOME=/opt/elasticsearch\n'
'Environment=ES_PATH_CONF=/opt/elasticsearch/config\n'
'ExecStart=/opt/elasticsearch/bin/elasticsearch\n'
'Restart=on-failure\n\n'
'[Install]\nWantedBy=multi-user.target\nUNIT_EOF')
out.append('systemctl daemon-reload')
out.append('systemctl enable --now elasticsearch')
out.append('log "Wait ~30s then: curl http://127.0.0.1:' + port + '"')
return "\n".join(out) + "\n"
# ========================== ClickHouse ==========================
class ClickHouse(Generator):
id = "clickhouse"
title = "ClickHouse"
category = "databases"
icon = "🏛️"
tags = ["clickhouse", "olap", "columnar"]
description = "安装 ClickHouse 服务端 + 客户端。"
fields = [
Field("tcp_port", "TCP 端口", "number", default="9000"),
Field("http_port", "HTTP 端口", "number", default="8123"),
Field("listen_host", "Listen host", "text", default="0.0.0.0"),
Field("data_path", "数据目录", "text", default="/var/lib/clickhouse"),
]
def render(self, p):
tcp = str(p.get("tcp_port", "9000"))
http = str(p.get("http_port", "8123"))
lh = p.get("listen_host", "0.0.0.0")
dp = p.get("data_path", "/var/lib/clickhouse")
out = [bash_header(self.title)]
out.append('log "Installing ClickHouse..."')
out.append('$PKG_INSTALL apt-transport-https ca-certificates curl || $PKG_INSTALL ca-certificates curl')
out.append('mkdir -p /etc/apt/keyrings')
out.append('curl -fsSL https://clickhouse.com/keys/clickhouse.asc | gpg --dearmor -o /etc/apt/keyrings/clickhouse.gpg')
out.append('echo "deb [signed-by=/etc/apt/keyrings/clickhouse.gpg] https://packages.clickhouse.com/deb stable main" > /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_port>.*</tcp_port>|<tcp_port>' + tcp + '</tcp_port>|" /etc/clickhouse-server/config.xml')
out.append('sed -i "s|<http_port>.*</http_port>|<http_port>' + http + '</http_port>|" /etc/clickhouse-server/config.xml')
out.append('sed -i "s|<listen_host>.*</listen_host>|<listen_host>' + lh + '</listen_host>|" /etc/clickhouse-server/config.xml')
out.append('sed -i "s|<path>.*</path>|<path>' + dp + '</path>|" /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 <<CFG_EOF\n'
'datacenter = "' + dc + '"\n'
'data_dir = "/var/lib/consul"\n'
'client_addr = "0.0.0.0"\n'
'ui_config { enabled = ' + ('true' if ui else 'false') + ' }\n'
'server = true\n'
'bootstrap_expect = 1\n'
'CFG_EOF')
out.append('cat > /etc/systemd/system/consul.service <<UNIT_EOF\n'
'[Unit]\nDescription=HashiCorp Consul\nAfter=network.target\n\n'
'[Service]\nType=simple\nUser=consul\nGroup=consul\n'
'ExecStart=/usr/local/bin/consul agent -config-dir=/etc/consul.d/\n'
'Restart=on-failure\nLimitNOFILE=65536\n\n'
'[Install]\nWantedBy=multi-user.target\nUNIT_EOF')
out.append('systemctl daemon-reload')
out.append('systemctl enable --now consul')
out.append('sleep 3')
out.append('consul members || true')
return "\n".join(out) + "\n"
for _g in [Memcached, SqliteTools, Elasticsearch, ClickHouse, Consul]:
register(_g())
+984
View File
@@ -0,0 +1,984 @@
"""
Middleware service generators.
- VNC, Nginx, HAProxy, Keepalived, Redis, RabbitMQ, Tomcat, Docker, ETCD, Zookeeper
"""
from . import register, Generator, Field
from . import bash_header, quote, bool_str, yes, port_list
# ========================== VNC Server ==========================
class VNCSrv(Generator):
id = "vncserver"
title = "VNC 远程桌面 (TigerVNC + 多桌面)"
category = "middleware"
icon = "🖥️"
tags = ["vnc", "desktop", "xfce", "gnome", "tigervnc", "kde", "mate"]
description = "在 Ubuntu/Debian 上一键安装并启动 TigerVNC,支持 XFCE / GNOME / KDE / MATE / LXQt 多种桌面环境。"
warnings = [
"VNC 协议默认不加密;生产环境请配合 SSH 隧道 (-localhost yes) 或 VPN 使用。",
"VNC 密码仅前 8 字节有效 (DES 限制),建议用强密码但勿超过 8 位核心。",
"GNOME 桌面会显著增大磁盘占用 (~2-3 GB),启动比 XFCE 慢。",
"KDE 桌面需要 sddm/lightdm,无显示器服务器上仅启动 VNC 会跳过 DM 阶段。",
]
post_steps = [
"systemctl status vncserver@1 — 查看服务状态",
"ss -ltn | grep 5901 — 验证监听端口",
"用 VNC Viewer 连接 <server-ip>:5901 (5900 + display 号)",
]
verify_steps = [
"ss -ltn | grep -E ':5901|:5902'",
"systemctl is-active vncserver@1",
"tail -f /var/log/Xvnc.1.log 或 journalctl -u vncserver@1 -f",
]
os_support = ["ubuntu", "debian"]
fields = [
Field("user", "运行用户", "text", default="root", placeholder="root",
help="VNC 进程以该用户运行,推荐使用普通用户,root 登录需要修改配置。"),
Field("display", "Display 号", "number", default="1", min_=1, max_=99,
help="Display 1 = 端口 5901,Display 2 = 端口 5902。"),
Field("password", "VNC 密码", "password", default="", required=False,
help="留空则使用 vncpassword 自动生成;只取前 8 位。"),
Field("desktop_env", "桌面环境", "select", default="xfce",
options=["xfce", "gnome", "kde-plasma", "mate", "lxqt"],
help="选 gnome = Ubuntu 原生 GNOME / GNOME Shell;"
" 选 kde-plasma = KDE Plasma 5;"
" 选 mate = MATE (XFCE 轻量替代);"
" 选 lxqt = LXQt (超轻量);"
" 选 xfce = XFCE (最稳定)。"),
Field("geometry", "分辨率", "select", default="1920x1080",
options=["1280x720", "1366x768", "1440x900", "1600x900",
"1680x1050", "1920x1080", "2560x1440", "3840x2160"]),
Field("depth", "颜色深度", "select", default="24", options=["16", "24", "32"]),
Field("localhost", "仅本地监听", "checkbox", default="no",
help="yes = 仅 localhost 监听,需 SSH 隧道;no = 0.0.0.0 监听(配合防火墙)。"),
Field("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/<user>" and recreate the home properly.
out.append(f'if [ "$USER_HOME" = "/root" ] && [ "{user}" != "root" ]; then')
out.append(f' warn "getent passwd {user} returned home=/root; assuming /home/{user}"')
out.append(f' USER_HOME="/home/{user}"')
out.append('fi')
# Make sure the user + their home exist and are owned by them. Without
# this, systemd fails with "status=200/CHDIR Permission denied" at the
# WorkingDirectory=%h step. Two failure modes we guard against:
# 1. The user was never created (no /home/<user> at all)
# 2. /home/<user> was created with wrong owner (root, mode 755) and
# the VNC user can't write into it
out.append(f'log "User {quote(user)} home: $USER_HOME"')
out.append('# Ensure user exists (idempotent — useradd exits 9 if user exists)')
out.append('# Force login shell = /bin/bash so systemd User= can run xstartup')
out.append('id "' + user + '" >/dev/null 2>&1 || useradd -m -d "$USER_HOME" -s /bin/bash ' + user)
# If user existed but home was missing or wrong (e.g. /root for cnbug),
# fix it now: usermod -m moves /root's contents to the new home.
out.append('CURRENT_HOME="$(getent passwd "' + user + '" | cut -d: -f6)"')
out.append('if [ "$CURRENT_HOME" != "$USER_HOME" ]; then')
out.append(' warn "Fixing home for user ' + user + ': $CURRENT_HOME -> $USER_HOME"')
out.append(' usermod -d "$USER_HOME" -m "' + user + '" 2>/dev/null || true')
out.append('fi')
# CRITICAL: also fix the login shell — system users (created with
# useradd -r) often have /usr/sbin/nologin which breaks systemd User=
out.append('CURRENT_SHELL="$(getent passwd "' + user + '" | cut -d: -f7)"')
out.append('if [ "$CURRENT_SHELL" != "/bin/bash" ]; then')
out.append(' warn "Fixing shell for user ' + user + ': $CURRENT_SHELL -> /bin/bash"')
out.append(' usermod -s /bin/bash "' + user + '"')
out.append('fi')
# Make /home traversable (drwxr-xr-x). Some hardening tools tighten
# /home to 750 which would block cnbugs from reaching /home/cnbugs
# even if /home/cnbugs itself is owned by cnbugs.
if user != "root":
out.append('[ -d /home ] && chmod 755 /home || true')
# Re-chown the home in case it was created by some other tool with
# wrong ownership. install -d = mkdir + chown in one call.
out.append('install -d -o "' + user + '" -g "' + user + '" -m 0750 "$USER_HOME"')
out.append('install -d -o "' + user + '" -g "' + user + '" -m 0700 "$USER_HOME/.vnc"')
out.append('')
# Password
if password:
out.append('log "Setting VNC password..."')
out.append(f'PASS={quote(password)}')
out.append('printf "%s\\n" "$PASS" | vncpasswd -f > "$USER_HOME/.vnc/passwd"')
out.append('chmod 600 "$USER_HOME/.vnc/passwd"')
# passwd was written by root (we're running as root); chown to the
# VNC user so the service User= can read it.
out.append('chown "' + user + '":"' + user + '" "$USER_HOME/.vnc/passwd"')
else:
out.append('log "Skipping password (none provided). Run: su - $USER -c vncpasswd"')
# Password file was already created in the user-owned .vnc dir above
out.append('')
# xstartup
out.append(f'log "Writing xstartup ({de} via dbus-launch)..."')
out.append('cat > "$USER_HOME/.vnc/xstartup" <<\'XSTARTUP_EOF\'\n'
'#!/bin/sh\n'
+ xstartup
+ 'XSTARTUP_EOF')
# .vnc dir was created user-owned; xstartup needs +x but stays user-owned
out.append('chmod +x "$USER_HOME/.vnc/xstartup"')
out.append('')
# We need the actual home path at unit-write time, not at service
# start time. systemd units do NOT support bash variable expansion
# in WorkingDirectory=, and %h (which would expand at service-start
# to the User='s NSS-resolved home) can be inconsistent after a
# usermod -d in the same session. We write the literal absolute
# path that was just resolved and verified to work above.
out.append('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=<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 <server>:' + port + ' (desktop: ' + de + ')"')
return "\n".join(out) + "\n"
# ========================== Nginx ==========================
class Nginx(Generator):
id = "nginx"
title = "Nginx 静态/反向代理服务器"
category = "middleware"
icon = "🌍"
tags = ["nginx", "web", "proxy"]
description = "一键安装 Nginx,可生成 vhost 配置 (HTTP/HTTPS) 并签发自签名证书。"
warnings = ["自签证书浏览器会告警,生产请使用 Let's Encrypt (certbot)。"]
post_steps = [
"systemctl status nginx",
"curl -I http://127.0.0.1 — 应返回 200",
"tail -f /var/log/nginx/access.log",
]
fields = [
Field("server_name", "Server Name", "text", default="example.local",
placeholder="example.com", required=True,
help="HTTP Server 块中的 server_name。"),
Field("listen_port", "监听端口", "number", default="80", min_=1, max_=65535),
Field("root_path", "网站根目录", "text", default="/var/www/html",
help="静态文件根目录,留空使用默认。"),
Field("enable_ssl", "启用 HTTPS", "checkbox", default="no",
help="启用后会在 443 监听并生成自签名证书。"),
Field("ssl_port", "HTTPS 端口", "number", default="443", min_=1, max_=65535),
Field("enable_proxy", "启用反向代理", "checkbox", default="no"),
Field("proxy_pass", "Proxy Pass 地址", "text", default="http://127.0.0.1:8080",
placeholder="http://127.0.0.1:8080",
help="启用反向代理时生效,转发的上游地址。"),
Field("client_max_body_size", "上传大小限制", "text", default="50m",
help="client_max_body_size 值,如 50m / 200m。"),
Field("worker_processes", "Worker 数", "text", default="auto",
help="auto = CPU 核数,或指定数字。"),
]
def render(self, p):
sn = p.get("server_name", "example.local")
port = str(p.get("listen_port", "80"))
root = p.get("root_path", "/var/www/html")
ssl = bool_str(p.get("enable_ssl"))
ssl_port = str(p.get("ssl_port", "443"))
proxy = bool_str(p.get("enable_proxy"))
proxy_pass = p.get("proxy_pass", "http://127.0.0.1:8080")
cmbs = p.get("client_max_body_size", "50m")
wp = p.get("worker_processes", "auto")
out = [bash_header(self.title)]
out.append('log "Installing nginx..."')
out.append('$PKG_INSTALL nginx openssl curl')
out.append('mkdir -p /etc/nginx/conf.d /var/www/html /etc/nginx/ssl')
out.append('')
# main nginx.conf (only override the worker_processes line if not 'auto')
if wp != "auto":
out.append(f'sed -i "s/^worker_processes .*/worker_processes {wp};/" /etc/nginx/nginx.conf')
out.append('')
out.append('log "Writing site config..."')
out.append(f'mkdir -p {quote(root)}')
out.append(f'cat > {quote(root)}/index.html <<HTML_EOF\n'
'<!doctype html><html><head><meta charset="utf-8">\n'
f'<title>{sn}</title></head><body>\n'
f'<h1>Hello from shell-gen</h1><p>Server: {sn}</p>\n'
f'<p>Generated: {__import__("datetime").datetime.now():%Y-%m-%d %H:%M:%S}</p>\n'
'</body></html>\n'
'HTML_EOF')
out.append('')
# Server block
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 <<CONF_EOF\n{siteconf}CONF_EOF')
out.append('rm -f /etc/nginx/sites-enabled/default')
if ssl:
out.append('log "Generating self-signed certificate..."')
out.append(f'openssl req -x509 -nodes -newkey rsa:2048 -days 365 \\\n'
f' -keyout /etc/nginx/ssl/{sn}.key \\\n'
f' -out /etc/nginx/ssl/{sn}.crt \\\n'
f' -subj "/CN={sn}" 2>/dev/null')
out.append(f'cat > /etc/nginx/conf.d/{sn}-ssl.conf <<CONF_EOF\n'
f'server {{\n'
f' listen {ssl_port} ssl;\n'
f' listen [::]:{ssl_port} ssl;\n'
f' server_name {sn};\n'
f' ssl_certificate /etc/nginx/ssl/{sn}.crt;\n'
f' ssl_certificate_key /etc/nginx/ssl/{sn}.key;\n'
f' root {root};\n'
f' client_max_body_size {cmbs};\n'
f' index index.html;\n'
f' access_log /var/log/nginx/{sn}-ssl.access.log;\n'
f' error_log /var/log/nginx/{sn}-ssl.error.log;\n'
+ (' location / {\n'
f' proxy_pass {proxy_pass};\n'
' proxy_set_header Host $host;\n'
' proxy_set_header X-Real-IP $remote_addr;\n'
' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n'
' proxy_set_header X-Forwarded-Proto $scheme;\n'
' }\n' if proxy else
' location / {\n'
' try_files $uri $uri/ =404;\n'
' }\n')
+ '}}\n'
'CONF_EOF')
out.append('log "Testing nginx config..."')
out.append('nginx -t')
out.append('systemctl enable --now nginx')
out.append('systemctl reload nginx || systemctl restart nginx')
out.append('log "Done. Listening on :' + port + (", :"+ssl_port+" (HTTPS)" if ssl else "") + '"')
return "\n".join(out) + "\n"
# ========================== HAProxy ==========================
class HAProxy(Generator):
id = "haproxy"
title = "HAProxy 负载均衡器"
category = "middleware"
icon = "⚖️"
tags = ["haproxy", "loadbalancer", "l4", "l7"]
description = "安装 HAProxy 并生成前端/后端配置,支持 TCP / HTTP 模式、健康检查、stats 面板。"
fields = [
Field("haproxy_name", "HAProxy 实例名", "text", default="haproxy"),
Field("frontend_name", "Frontend 名称", "text", default="http-in"),
Field("frontend_port", "Frontend 端口", "number", default="80", min_=1, max_=65535),
Field("mode", "运行模式", "select", default="http",
options=["http", "tcp"]),
Field("backend_name", "Backend 名称", "text", default="app-servers"),
Field("balance_method", "负载算法", "select", default="roundrobin",
options=["roundrobin", "leastconn", "source", "uri", "url_param",
"hdr", "random", "first", "static-rr"]),
Field("backends", "后端服务器列表", "textarea", default="192.168.1.10:8080\n192.168.1.11:8080",
help="一行一个,格式: <ip>:<port>。"),
Field("stats_port", "Stats 端口", "number", default="8404", min_=1, max_=65535),
Field("stats_user", "Stats 用户", "text", default="admin"),
Field("stats_password", "Stats 密码", "password", default="changeme"),
Field("enable_https", "启用 HTTPS 前端", "checkbox", default="no"),
Field("https_port", "HTTPS 端口", "number", default="443"),
Field("cert_path", "SSL 证书路径(PEM)", "text", default="/etc/haproxy/cert.pem",
help="PEM 格式,含 cert+key 拼接,或 cert+单独 key。"),
]
def render(self, p):
frontend_port = str(p.get("frontend_port", "80"))
mode = p.get("mode", "http")
backend_name = p.get("backend_name", "app-servers")
balance = p.get("balance_method", "roundrobin")
backends = p.get("backends", "").strip().splitlines()
backends = [b.strip() for b in backends if b.strip()]
stats_port = str(p.get("stats_port", "8404"))
stats_user = p.get("stats_user", "admin")
stats_pwd = p.get("stats_password", "changeme")
https = bool_str(p.get("enable_https"))
https_port = str(p.get("https_port", "443"))
cert_path = p.get("cert_path", "/etc/haproxy/cert.pem")
out = [bash_header(self.title)]
out.append('log "Installing haproxy..."')
out.append('$PKG_INSTALL haproxy')
out.append('cp /etc/haproxy/haproxy.cfg /etc/haproxy/haproxy.cfg.bak.$(date +%s) || true')
out.append('mkdir -p /etc/haproxy')
out.append('')
cfg = []
cfg.append('global\n log /dev/log local0\n log /dev/log local1 notice\n'
' chroot /var/lib/haproxy\n stats timeout 30s\n user haproxy\n group haproxy\n'
' daemon\n maxconn 4096\n\n')
cfg.append('defaults\n log global\n mode ' + mode + '\n'
' option httplog\n option dontlognull\n'
' timeout connect 10s\n timeout client 60s\n'
' timeout server 60s\n\n')
cfg.append('frontend stats\n bind *:' + stats_port + '\n mode http\n'
' stats enable\n stats uri /stats\n stats refresh 10s\n'
f' stats auth {stats_user}:{stats_pwd}\n'
' stats admin if TRUE\n\n')
cfg.append(f'frontend {p.get("frontend_name","http-in")}\n'
f' bind *:{frontend_port}\n'
f' mode {mode}\n'
f' default_backend {backend_name}\n\n')
if https:
cfg.append(f'frontend {p.get("frontend_name","http-in")}-ssl\n'
f' bind *:{https_port} ssl crt {cert_path}\n'
f' mode {mode}\n'
f' default_backend {backend_name}\n\n')
cfg.append(f'backend {backend_name}\n balance {balance}\n'
' option httpchk GET /healthz\n'
' http-check expect status 200\n')
for b in backends:
cfg.append(f' server {b.replace(".", "_").replace(":", "_")} {b} check inter 3s fall 3 rise 2\n')
out.append('cat > /etc/haproxy/haproxy.cfg <<CFG_EOF\n' + ''.join(cfg) + 'CFG_EOF')
out.append('haproxy -c -f /etc/haproxy/haproxy.cfg')
out.append('systemctl enable --now haproxy')
out.append('systemctl reload haproxy 2>/dev/null || systemctl restart haproxy')
out.append('log "Done. Stats: http://<server>:' + stats_port + '/stats"')
return "\n".join(out) + "\n"
# ========================== Keepalived ==========================
class Keepalived(Generator):
id = "keepalived"
title = "Keepalived 高可用 VIP"
category = "middleware"
icon = "🔁"
tags = ["keepalived", "vrrp", "ha", "vip"]
description = "部署 Keepalived + VRRP 双机热备,自动漂移 VIP。"
warnings = [
"Keepalived 需要至少 2 台同网段机器组成集群。",
"multicast 在云厂商可能被禁用,需使用 unicast peer。",
]
fields = [
Field("state", "本节点角色", "select", default="MASTER", options=["MASTER", "BACKUP"]),
Field("interface", "网卡", "text", default="eth0",
help="VRRP 绑定的网卡,可用 ip a 查询。"),
Field("virtual_router_id", "Virtual Router ID", "number", default="51", min_=1, max_=255),
Field("priority", "优先级 (MASTER > BACKUP)", "number", default="100", min_=1, max_=254),
Field("virtual_ip", "VIP 地址 (CIDR)", "text", default="192.168.1.100/24",
placeholder="192.168.1.100/24"),
Field("auth_pass", "认证密码", "password", default="changeme"),
Field("unicast_peer_ip", "对端 IP (unicast 模式)", "text", default="",
help="云环境用单播,留空则用多播;填对端 IP 即可。"),
Field("notify_script", "状态切换脚本 (可选)", "text", default="",
help="MASTER->BACKUP 或反向时执行的脚本路径。"),
Field("check_script", "健康检查脚本 (可选)", "text", default="",
help="如: /etc/keepalived/check_nginx.sh,return 0 = 健康。"),
Field("check_interval", "检查间隔", "text", default="2",
help="weight 下降间隔,单位秒。"),
]
def render(self, p):
state = p.get("state", "MASTER")
iface = p.get("interface", "eth0")
vrid = str(p.get("virtual_router_id", "51"))
prio = str(p.get("priority", "100"))
vip = p.get("virtual_ip", "192.168.1.100/24")
auth = p.get("auth_pass", "changeme")
peer = p.get("unicast_peer_ip", "").strip()
notify = p.get("notify_script", "").strip()
check = p.get("check_script", "").strip()
interval = p.get("check_interval", "2")
out = [bash_header(self.title)]
out.append('log "Installing keepalived..."')
out.append('$PKG_INSTALL keepalived')
out.append('')
out.append('cat > /etc/keepalived/keepalived.conf <<CONF_EOF\n'
'global_defs {\n'
' enable_script_security\n'
' script_user root\n'
'}\n\n'
'vrrp_script check_local {\n' +
(f' script "{check}"\n' if check else
' script "exit 0"\n') +
f' interval {interval}\n'
' weight -50\n'
' fall 3\n rise 2\n'
'}\n\n'
'vrrp_instance VI_1 {\n'
f' state {state}\n'
f' interface {iface}\n'
f' virtual_router_id {vrid}\n'
f' priority {prio}\n'
' advert_int 1\n'
' authentication {\n'
f' auth_type PASS\n auth_pass {auth}\n'
' }\n'
' virtual_ipaddress {\n'
f' {vip}\n'
' }\n'
' track_script {\n'
' check_local\n'
' }\n' +
(f' notify "{notify}"\n' if notify else '') +
'}\nCONF_EOF')
if peer:
out.append('log "Patching unicast_peer_ip..."')
out.append('cat >> /etc/keepalived/keepalived.conf <<CONF_EOF\n\n'
'unicast_src_ip "$(ip -4 addr show ' + iface + ' | grep -oP "(?<=inet\\s)\\d+(\\.\\d+){3}")"\n'
'unicast_peer {\n'
f' {peer}\n'
'}\n'
'CONF_EOF')
out.append('sed -i \'s/vrrp_instance/, &unicast_src_ip_check\\n&/\' /etc/keepalived/keepalived.conf || true')
out.append('systemctl enable --now keepalived')
out.append('log "Verifying VIP..."')
out.append('sleep 2')
out.append('ip a | grep -A1 "' + iface + ':" | grep "' + vip.split('/')[0] + '" || warn "VIP not on this node (expected if BACKUP)."')
return "\n".join(out) + "\n"
# ========================== Redis ==========================
class Redis(Generator):
id = "redis"
title = "Redis 内存数据库"
category = "databases"
icon = "🔴"
tags = ["redis", "nosql", "cache"]
description = "安装 Redis 并启用密码、AOF 持久化、systemd 服务。"
fields = [
Field("bind", "Bind 地址", "text", default="0.0.0.0"),
Field("port", "端口", "number", default="6379", min_=1, max_=65535),
Field("requirepass", "密码 (留空=不设)", "password", default=""),
Field("maxmemory", "最大内存", "text", default="256mb",
help="如 256mb / 1gb / 2gb"),
Field("maxmemory_policy", "淘汰策略", "select", default="allkeys-lru",
options=["noeviction", "allkeys-lru", "allkeys-lfu",
"volatile-lru", "volatile-lfu", "volatile-random",
"allkeys-random"]),
Field("appendonly", "启用 AOF", "checkbox", default="yes",
help="yes = 更安全;no = 仅 RDB 快照。"),
Field("enable_acl", "启用 ACL (6+)", "checkbox", default="no",
help="6.0+ 推荐,使用 ACL 替代 requirepass。"),
Field("acl_user", "ACL 用户名", "text", default="app"),
Field("acl_password", "ACL 密码", "password", default="changeme"),
]
def render(self, p):
bind = p.get("bind", "0.0.0.0")
port = str(p.get("port", "6379"))
pwd = p.get("requirepass", "") or ""
maxmem = p.get("maxmemory", "256mb")
policy = p.get("maxmemory_policy", "allkeys-lru")
aof = "yes" if bool_str(p.get("appendonly", True)) else "no"
acl = bool_str(p.get("enable_acl"))
acl_user = p.get("acl_user", "app")
acl_pwd = p.get("acl_password", "changeme")
out = [bash_header(self.title)]
out.append('log "Installing redis..."')
out.append('$PKG_INSTALL redis-server')
out.append('')
out.append('cat > /etc/redis/redis.conf <<CFG_EOF\n'
'bind ' + bind + '\n'
'port ' + port + '\n'
'daemonize yes\n'
'supervised systemd\n'
'pidfile /var/run/redis/redis-server.pid\n'
'loglevel notice\n'
'logfile /var/log/redis/redis-server.log\n'
'databases 16\n'
'maxmemory ' + maxmem + '\n'
'maxmemory-policy ' + policy + '\n'
'appendonly ' + aof + '\n'
'appendfilename "appendonly.aof"\n'
'save 900 1\n'
'save 300 10\n'
'save 60 10000\n'
'tcp-keepalive 60\n'
'timeout 0\n'
'tcp-backlog 511\n'
+ (f'requirepass {pwd}\n' if pwd and not acl else '') +
'CFG_EOF')
if acl:
out.append('cat > /etc/redis/users.acl <<ACL_EOF\n'
f'user {acl_user} on >{acl_pwd} ~* &* +@all\n'
'ACL_EOF')
out.append('echo "aclfile /etc/redis/users.acl" >> /etc/redis/redis.conf')
out.append('systemctl enable --now redis-server || systemctl enable --now redis')
out.append('sleep 1')
out.append('systemctl --no-pager status redis-server || systemctl --no-pager status redis || true')
out.append('redis-cli -h 127.0.0.1 -p ' + port + ' PING || warn "redis-cli ping failed"')
return "\n".join(out) + "\n"
# ========================== RabbitMQ ==========================
class RabbitMQ(Generator):
id = "rabbitmq"
title = "RabbitMQ 消息队列"
category = "middleware"
icon = "🐰"
tags = ["rabbitmq", "mq", "amqp"]
description = "安装 RabbitMQ,启用 management 插件,创建 admin 用户。"
fields = [
Field("admin_user", "Admin 用户", "text", default="admin"),
Field("admin_password", "Admin 密码", "password", default="changeme123"),
Field("listeners", "AMQP 监听端口", "number", default="5672", min_=1, max_=65535),
Field("mgmt_port", "Management 端口", "number", default="15672", min_=1, max_=65535),
Field("cluster_name", "节点名", "text", default="rabbit@localhost"),
]
def render(self, p):
admin = p.get("admin_user", "admin")
pwd = p.get("admin_password", "changeme123")
port = str(p.get("listeners", "5672"))
mgmt = str(p.get("mgmt_port", "15672"))
out = [bash_header(self.title)]
out.append('log "Installing rabbitmq..."')
# repo install (covers all distros; apt and yum both have rabbitmq-server package)
out.append('case "$PKG" in\n'
' apt-get) DEBIAN_FRONTEND=noninteractive apt-get install -y rabbitmq-server ;;\n'
' yum) yum install -y epel-release && yum install -y rabbitmq-server ;;\n'
' dnf) dnf install -y rabbitmq-server ;;\n'
'esac')
out.append('systemctl enable --now rabbitmq-server')
out.append('rabbitmq-plugins enable rabbitmq_management')
out.append('systemctl restart rabbitmq-server')
out.append('sleep 3')
out.append('log "Creating admin user..."')
out.append(f'rabbitmqctl add_user {admin} {pwd} || rabbitmqctl change_password {admin} {pwd}')
out.append(f'rabbitmqctl set_user_tags {admin} administrator')
out.append(f'rabbitmqctl set_permissions -p / {admin} ".*" ".*" ".*"')
out.append('log "Management UI: http://<server>:' + mgmt + ' (user: ' + admin + ')"')
return "\n".join(out) + "\n"
# ========================== Tomcat ==========================
class Tomcat(Generator):
id = "tomcat"
title = "Apache Tomcat (Java Servlet 容器)"
category = "middleware"
icon = "🐱"
tags = ["tomcat", "java", "servlet", "jsp"]
description = "下载并安装 Apache Tomcat,自动配置 systemd。"
fields = [
Field("version", "Tomcat 版本", "select", default="10.1",
options=["10.1", "10.0", "9.0", "8.5"]),
Field("port", "HTTP 端口", "number", default="8080", min_=1, max_=65535),
Field("shutdown_port", "Shutdown 端口", "number", default="8005", min_=1, max_=65535),
Field("ajp_port", "AJP 端口", "number", default="8009", min_=1, max_=65535),
Field("java_opts", "JAVA_OPTS", "text", default="-Xms512m -Xmx1024m -Djava.awt.headless=true"),
Field("user", "运行用户", "text", default="tomcat"),
Field("install_dir", "安装目录", "text", default="/opt/tomcat"),
]
def render(self, p):
ver = p.get("version", "10.1")
port = str(p.get("port", "8080"))
s_port = str(p.get("shutdown_port", "8005"))
ajp = str(p.get("ajp_port", "8009"))
java_opts = p.get("java_opts", "-Xms512m -Xmx1024m -Djava.awt.headless=true")
user = p.get("user", "tomcat")
install_dir = p.get("install_dir", "/opt/tomcat")
# Pick the latest patch for the chosen major
url = "https://dlcdn.apache.org/tomcat/tomcat-" + ver.split(".")[0] + ("/v" + ver + "/bin/apache-tomcat-" + ver + ".tar.gz" if ver != "10.1" else "/v10.1.x/bin/apache-tomcat-10.1.x.tar.gz")
out = [bash_header(self.title)]
out.append('log "Installing Java + Tomcat..."')
out.append('command -v java >/dev/null || $PKG_INSTALL java-11-openjdk-devel java-11-openjdk || $PKG_INSTALL default-jdk')
out.append('useradd -r -s /bin/false ' + user + ' 2>/dev/null || true')
out.append('cd /opt')
out.append('curl -fsSL -o tomcat.tar.gz "' + url + '"')
out.append('tar -xzf tomcat.tar.gz')
out.append('rm -rf ' + install_dir)
out.append('mv apache-tomcat-* ' + install_dir)
out.append('rm -f tomcat.tar.gz')
out.append('chown -R ' + user + ':' + user + ' ' + install_dir)
out.append('')
out.append('log "Patching server.xml ports..."')
out.append('sed -i "s/port=\"8080\"/port=\"' + port + '\"/" ' + install_dir + '/conf/server.xml')
out.append('sed -i "s/port=\"8005\"/port=\"' + s_port + '\"/" ' + install_dir + '/conf/server.xml')
out.append('sed -i "s/port=\"8009\"/port=\"' + ajp + '\"/" ' + install_dir + '/conf/server.xml')
out.append('')
out.append('cat > /etc/systemd/system/tomcat.service <<UNIT_EOF\n'
'[Unit]\n'
'Description=Apache Tomcat\n'
'After=network.target\n\n'
'[Service]\n'
'Type=forking\n'
f'User={user}\nGroup={user}\n'
f'Environment=JAVA_HOME=/usr/lib/jvm/java-11-openjdk\n'
f'Environment=CATALINA_HOME={install_dir}\n'
f'Environment=CATALINA_BASE={install_dir}\n'
f'Environment=JAVA_OPTS="{java_opts}"\n'
f'ExecStart={install_dir}/bin/startup.sh\n'
f'ExecStop={install_dir}/bin/shutdown.sh\n'
'Restart=on-failure\n\n'
'[Install]\nWantedBy=multi-user.target\n'
'UNIT_EOF')
out.append('systemctl daemon-reload')
out.append('systemctl enable --now tomcat')
out.append('sleep 3')
out.append('ss -ltn | grep :' + port + ' || warn "Tomcat not listening yet"')
return "\n".join(out) + "\n"
# ========================== Docker ==========================
class Docker(Generator):
id = "docker"
title = "Docker Engine + Compose"
category = "middleware"
icon = "🐳"
tags = ["docker", "container", "compose"]
description = "一键安装 Docker Engine、buildx、docker-compose v2、配置 cgroupdriver。"
fields = [
Field("docker_user", "免 sudo 用户", "text", default="root",
help="将该用户加入 docker 组,免 sudo。"),
Field("registry_mirror", "Registry 镜像加速", "text",
default="https://mirror.ccs.tencentyun.com",
help="如 https://docker.mirrors.ustc.edu.cn"),
Field("cgroup_driver", "Cgroup Driver", "select", default="systemd",
options=["systemd", "cgroupfs"]),
Field("log_driver", "日志驱动", "select", default="json-file",
options=["json-file", "journald", "syslog", "none"]),
Field("log_max_size", "日志最大尺寸", "text", default="100m"),
Field("log_max_file", "日志最大文件数", "text", default="3"),
Field("enable_buildx", "启用 buildx", "checkbox", default="yes"),
Field("data_root", "Docker 数据目录", "text", default="/var/lib/docker",
help="留空使用默认 /var/lib/docker"),
]
def render(self, p):
usr = p.get("docker_user", "root")
mirror = p.get("registry_mirror", "https://mirror.ccs.tencentyun.com")
cg = p.get("cgroup_driver", "systemd")
log_d = p.get("log_driver", "json-file")
log_sz = p.get("log_max_size", "100m")
log_n = p.get("log_max_file", "3")
data_root = p.get("data_root", "/var/lib/docker")
buildx = bool_str(p.get("enable_buildx", True))
out = [bash_header(self.title)]
out.append('log "Installing Docker..."')
out.append('case "$PKG" in\n'
' apt-get)\n'
' $PKG_INSTALL ca-certificates curl gnupg lsb-release\n'
' install -m 0755 -d /etc/apt/keyrings\n'
' curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg\n'
' chmod a+r /etc/apt/keyrings/docker.gpg\n'
' echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list\n'
' apt-get update\n'
' $PKG_INSTALL docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin\n'
' ;;\n'
' yum|dnf)\n'
' $PKG_INSTALL yum-utils\n'
' yum-config-manager --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 <<CFG_EOF\n{\n'
' "registry-mirrors": ["' + mirror + '"],\n'
' "log-driver": "' + log_d + '",\n'
' "log-opts": {\n'
' "max-size": "' + log_sz + '",\n'
' "max-file": "' + log_n + '"\n'
' },\n'
' "storage-driver": "overlay2",\n'
' "data-root": "' + data_root + '"\n'
'}\nCFG_EOF')
out.append('systemctl restart docker')
if usr != "root":
out.append('usermod -aG docker ' + usr)
out.append('log "User ' + usr + ' added to docker group (need to re-login)"')
out.append('docker --version')
if buildx:
out.append('docker buildx version || true')
out.append('docker compose version || true')
return "\n".join(out) + "\n"
# ========================== ETCD ==========================
class ETCD(Generator):
id = "etcd"
title = "etcd 分布式 KV 存储"
category = "middleware"
icon = "🗝️"
tags = ["etcd", "kv", "discovery"]
description = "部署单节点 etcd(可扩展为集群),开启 v2/v3 双协议、http API。"
fields = [
Field("name", "节点名", "text", default="etcd-node1"),
Field("data_dir", "数据目录", "text", default="/var/lib/etcd"),
Field("listen_client", "客户端监听 URL", "text", default="http://0.0.0.0:2379"),
Field("listen_peer", "Peer 监听 URL", "text", default="http://0.0.0.0:2380"),
Field("advertise_client", "广播客户端 URL", "text", default="http://127.0.0.1:2379"),
Field("initial_cluster", "初始集群列表", "text", default="etcd-node1=http://127.0.0.1:2380"),
Field("version", "etcd 版本", "text", default="3.5.13"),
]
def render(self, p):
name = p.get("name", "etcd-node1")
data = p.get("data_dir", "/var/lib/etcd")
lclient = p.get("listen_client", "http://0.0.0.0:2379")
lpeer = p.get("listen_peer", "http://0.0.0.0:2380")
advertise = p.get("advertise_client", "http://127.0.0.1:2379")
cluster = p.get("initial_cluster", "etcd-node1=http://127.0.0.1:2380")
ver = p.get("version", "3.5.13")
out = [bash_header(self.title)]
out.append('log "Installing etcd v' + ver + '..."')
out.append('useradd -r -s /bin/false etcd 2>/dev/null || true')
out.append('mkdir -p ' + data + ' /etc/etcd')
out.append('cd /tmp')
out.append('curl -fsSL -o etcd.tar.gz https://github.com/etcd-io/etcd/releases/download/v' + ver + '/etcd-v' + ver + '-linux-amd64.tar.gz')
out.append('tar -xzf etcd.tar.gz')
out.append('cp etcd-v' + ver + '-linux-amd64/etcd /usr/local/bin/')
out.append('cp etcd-v' + ver + '-linux-amd64/etcdctl /usr/local/bin/')
out.append('rm -rf etcd.tar.gz etcd-v' + ver + '-linux-amd64')
out.append('chown -R etcd:etcd ' + data + ' /etc/etcd')
out.append('')
out.append('cat > /etc/etcd/etcd.conf <<CFG_EOF\n'
'ETCD_NAME="' + name + '"\n'
'ETCD_DATA_DIR="' + data + '"\n'
'ETCD_LISTEN_CLIENT_URLS="' + lclient + '"\n'
'ETCD_LISTEN_PEER_URLS="' + lpeer + '"\n'
'ETCD_ADVERTISE_CLIENT_URLS="' + advertise + '"\n'
'ETCD_INITIAL_ADVERTISE_PEER_URLS="' + lpeer + '"\n'
'ETCD_INITIAL_CLUSTER="' + cluster + '"\n'
'ETCD_INITIAL_CLUSTER_TOKEN="etcd-cluster-1"\n'
'ETCD_INITIAL_CLUSTER_STATE="new"\nCFG_EOF')
out.append('cat > /etc/systemd/system/etcd.service <<UNIT_EOF\n'
'[Unit]\n'
'Description=etcd\n'
'After=network.target\n\n'
'[Service]\n'
'Type=notify\n'
'User=etcd\n'
'Group=etcd\n'
'EnvironmentFile=/etc/etcd/etcd.conf\n'
'ExecStart=/usr/local/bin/etcd\n'
'Restart=on-failure\n'
'LimitNOFILE=65536\n\n'
'[Install]\nWantedBy=multi-user.target\nUNIT_EOF')
out.append('systemctl daemon-reload')
out.append('systemctl enable --now etcd')
out.append('sleep 3')
out.append('etcdctl --endpoints=' + advertise + ' endpoint health || true')
return "\n".join(out) + "\n"
# ========================== Zookeeper ==========================
class Zookeeper(Generator):
id = "zookeeper"
title = "Apache ZooKeeper"
category = "middleware"
icon = "🦓"
tags = ["zookeeper", "zk", "coordination"]
description = "部署单节点 ZooKeeper,可扩展为集群。"
fields = [
Field("client_port", "Client 端口", "number", default="2181", min_=1, max_=65535),
Field("data_dir", "数据目录", "text", default="/var/lib/zookeeper"),
Field("tick_time", "Tick Time (ms)", "number", default="2000"),
Field("init_limit", "Init Limit", "number", default="10"),
Field("sync_limit", "Sync Limit", "number", default="5"),
Field("max_client_cnxns", "最大客户端连接数", "number", default="60"),
Field("version", "ZooKeeper 版本", "text", default="3.9.2"),
]
def render(self, p):
port = str(p.get("client_port", "2181"))
data = p.get("data_dir", "/var/lib/zookeeper")
tick = str(p.get("tick_time", "2000"))
init_l = str(p.get("init_limit", "10"))
sync = str(p.get("sync_limit", "5"))
cnxns = str(p.get("max_client_cnxns", "60"))
ver = p.get("version", "3.9.2")
out = [bash_header(self.title)]
out.append('log "Installing Java + ZooKeeper..."')
out.append('command -v java >/dev/null || $PKG_INSTALL java-11-openjdk-devel java-11-openjdk || $PKG_INSTALL default-jdk')
out.append('useradd -r -s /bin/false zookeeper 2>/dev/null || true')
out.append('cd /opt')
out.append('curl -fsSL -o zk.tar.gz https://archive.apache.org/dist/zookeeper/zookeeper-' + ver + '/apache-zookeeper-' + ver + '-bin.tar.gz')
out.append('rm -rf /opt/zookeeper')
out.append('tar -xzf zk.tar.gz')
out.append('mv apache-zookeeper-' + ver + '-bin /opt/zookeeper')
out.append('rm -f zk.tar.gz')
out.append('mkdir -p ' + data)
out.append('chown -R zookeeper:zookeeper /opt/zookeeper ' + data)
out.append('')
out.append('cat > /opt/zookeeper/conf/zoo.cfg <<CFG_EOF\n'
'tickTime=' + tick + '\n'
'initLimit=' + init_l + '\n'
'syncLimit=' + sync + '\n'
'dataDir=' + data + '\n'
'clientPort=' + port + '\n'
'maxClientCnxns=' + cnxns + '\n'
'4lw.commands.whitelist=*\\nCFG_EOF')
out.append('echo "' + str(int(__import__('time').time())) + '" > ' + data + '/myid')
out.append('')
out.append('cat > /etc/systemd/system/zookeeper.service <<UNIT_EOF\n'
'[Unit]\n'
'Description=Apache ZooKeeper\n'
'After=network.target\n\n'
'[Service]\n'
'Type=forking\n'
'User=zookeeper\nGroup=zookeeper\n'
'Environment=JAVA_HOME=/usr/lib/jvm/java-11-openjdk\n'
'ExecStart=/opt/zookeeper/bin/zkServer.sh start\n'
'ExecStop=/opt/zookeeper/bin/zkServer.sh stop\n'
'ExecReload=/opt/zookeeper/bin/zkServer.sh restart\n'
'Restart=on-failure\n\n'
'[Install]\nWantedBy=multi-user.target\nUNIT_EOF')
out.append('systemctl daemon-reload')
out.append('systemctl enable --now zookeeper')
out.append('sleep 3')
out.append('/opt/zookeeper/bin/zkServer.sh status || true')
return "\n".join(out) + "\n"
# Register everything in this file
for _g in [VNCSrv, Nginx, HAProxy, Keepalived, Redis, RabbitMQ, Tomcat, Docker, ETCD, Zookeeper]:
register(_g())
+226
View File
@@ -0,0 +1,226 @@
"""
Monitoring & alerting.
- Prometheus Node Exporter
- Prometheus server
- Grafana
- node_exporter + alertmanager bundle
- logrotate helper
"""
from . import register, Generator, Field
from . import bash_header, quote, bool_str, yes
# ========================== Node Exporter ==========================
class NodeExporter(Generator):
id = "node_exporter"
title = "Prometheus Node Exporter"
category = "monitoring"
icon = "📊"
tags = ["prometheus", "node_exporter", "monitoring"]
description = "安装 Prometheus node_exporter,默认监听 9100。"
fields = [
Field("version", "版本", "text", default="1.8.2"),
Field("port", "监听端口", "number", default="9100", min_=1, max_=65535),
Field("extra_args", "额外参数", "text",
default="--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)"),
]
def render(self, p):
ver = p.get("version", "1.8.2")
port = str(p.get("port", "9100"))
extra = p.get("extra_args", "")
out = [bash_header(self.title)]
out.append('log "Installing node_exporter ' + ver + '..."')
out.append('useradd -r -s /bin/false node_exporter 2>/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 <<UNIT_EOF\n'
'[Unit]\nDescription=Prometheus Node Exporter\n'
'After=network.target\n\n'
'[Service]\n'
'User=node_exporter\n'
'ExecStart=/usr/local/bin/node_exporter --web.listen-address=:' + port + ' ' + extra + '\n'
'Restart=on-failure\n\n'
'[Install]\nWantedBy=multi-user.target\nUNIT_EOF')
out.append('systemctl daemon-reload')
out.append('systemctl enable --now node_exporter')
out.append('sleep 1')
out.append('curl -s http://127.0.0.1:' + port + '/metrics | head -5')
return "\n".join(out) + "\n"
# ========================== Prometheus Server ==========================
class Prometheus(Generator):
id = "prometheus"
title = "Prometheus 服务端"
category = "monitoring"
icon = "🔥"
tags = ["prometheus", "monitoring", "metrics"]
description = "安装 Prometheus,带可配置 scrape jobs。"
fields = [
Field("version", "版本", "text", default="2.53.0"),
Field("port", "监听端口", "number", default="9090", min_=1, max_=65535),
Field("retention", "保留时间", "text", default="30d"),
Field("scrape_jobs", "Scrape 目标", "textarea",
default="node-exporter: ['localhost:9100']\nmyapp: ['localhost:8000']",
help="每行格式: <job_name>: ['host:port', ...]"),
]
def render(self, p):
ver = p.get("version", "2.53.0")
port = str(p.get("port", "9090"))
ret = p.get("retention", "30d")
lines = p.get("scrape_jobs", "").splitlines()
out = [bash_header(self.title)]
out.append('log "Installing Prometheus ' + ver + '..."')
out.append('useradd -r -s /bin/false prometheus 2>/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 <<CFG_EOF\n'
+ '\n'.join(cfg) + '\nCFG_EOF')
out.append('cat > /etc/systemd/system/prometheus.service <<UNIT_EOF\n'
'[Unit]\nDescription=Prometheus\nAfter=network.target\n\n'
'[Service]\nUser=prometheus\nGroup=prometheus\n'
'Type=simple\n'
'ExecStart=/usr/local/bin/prometheus \\\n'
' --config.file=/etc/prometheus/prometheus.yml \\\n'
' --storage.tsdb.path=/var/lib/prometheus/ \\\n'
' --web.console.libraries=/etc/prometheus/console_libraries \\\n'
' --web.console.templates=/etc/prometheus/consoles \\\n'
' --web.listen-address=:' + port + ' \\\n'
' --storage.tsdb.retention.time=' + ret + '\n'
'Restart=on-failure\n\n'
'[Install]\nWantedBy=multi-user.target\nUNIT_EOF')
out.append('systemctl daemon-reload')
out.append('systemctl enable --now prometheus')
out.append('sleep 2')
out.append('curl -s http://127.0.0.1:' + port + '/-/ready')
return "\n".join(out) + "\n"
# ========================== Grafana ==========================
class Grafana(Generator):
id = "grafana"
title = "Grafana"
category = "monitoring"
icon = "📈"
tags = ["grafana", "dashboard", "monitoring"]
description = "安装 Grafana OSS,配置 admin 密码。"
fields = [
Field("admin_user", "Admin 用户", "text", default="admin"),
Field("admin_password", "Admin 密码", "password", default="admin"),
Field("port", "端口", "number", default="3000", min_=1, max_=65535),
Field("prom_url", "Prometheus 数据源 URL", "text", default="http://localhost:9090"),
Field("root_url", "Grafana 公开 URL", "text", default="http://localhost:3000"),
]
def render(self, p):
user = p.get("admin_user", "admin")
pwd = p.get("admin_password", "admin")
port = str(p.get("port", "3000"))
prom = p.get("prom_url", "http://localhost:9090")
root = p.get("root_url", "http://localhost:3000")
out = [bash_header(self.title)]
out.append('log "Installing Grafana..."')
out.append('case "$PKG" in\n'
' apt-get)\n'
' $PKG_INSTALL -y software-properties-common\n'
' mkdir -p /etc/apt/keyrings\n'
' wget -q -O - https://apt.grafana.com/gpg.key | gpg --dearmor | tee /etc/apt/keyrings/grafana.gpg > /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 <<REPO_EOF\n'
'[grafana]\nname=grafana\nbaseurl=https://rpm.grafana.com\nrepo=gpgkey\nenabled=1\ngpgcheck=1\ngpgkey=https://rpm.grafana.com/gpg.key\nREPO_EOF\n'
' $PKG_INSTALL grafana\n'
' ;;\n'
'esac')
out.append('systemctl enable --now grafana-server')
out.append('sleep 3')
out.append('log "Setting admin password..."')
out.append('grafana-cli admin reset-admin-password ' + pwd + ' || warn "reset failed, use UI"')
out.append('log "Adding Prometheus datasource..."')
out.append('cat > /etc/grafana/provisioning/datasources/prometheus.yaml <<YAML_EOF\n'
'apiVersion: 1\ndatasources:\n - name: Prometheus\n'
' type: prometheus\n access: proxy\n url: ' + prom + '\n'
' isDefault: true\nYAML_EOF')
out.append('systemctl restart grafana-server')
out.append('log "Open: ' + root + ' user: ' + user + ' pass: ' + pwd + '"')
return "\n".join(out) + "\n"
# ========================== Logrotate (generic) ==========================
class Logrotate(Generator):
id = "logrotate"
title = "Logrotate 日志轮转"
category = "monitoring"
icon = "🔄"
tags = ["logrotate", "log"]
description = "为指定日志文件生成 logrotate 配置。"
fields = [
Field("paths", "日志路径 (空格分隔)", "textarea", default="/var/log/myapp/*.log"),
Field("rotate_count", "保留份数", "number", default="14", min_=1, max_=365),
Field("size", "超过该大小就轮转", "text", default="100M",
help="如 100M / 1G,留空 = 仅按时间。"),
Field("compress", "启用压缩", "checkbox", default="yes"),
Field("user", "轮转用户", "text", default="root"),
Field("group", "轮转组", "text", default="root"),
Field("postrotate", "Postrotate 脚本", "text", default=""),
]
def render(self, p):
paths = p.get("paths", "/var/log/myapp/*.log").replace("\n", " ")
n = str(p.get("rotate_count", "14"))
sz = p.get("size", "100M")
comp = bool_str(p.get("compress", True))
u = p.get("user", "root")
g = p.get("group", "root")
pr = p.get("postrotate", "").strip()
out = [bash_header(self.title)]
logrot = [paths + ' {', ' daily', ' missingok', ' rotate ' + n]
if sz:
logrot.append(' size ' + sz)
logrot.append(' compress' if comp else ' nocompress')
if comp:
logrot.append(' delaycompress')
logrot.append(' notifempty')
logrot.append(' create 0640 ' + u + ' ' + g)
logrot.append(' sharedscripts')
if pr:
logrot.append(' postrotate\n ' + pr + '\n endscript')
logrot.append('}')
out.append('cat > /etc/logrotate.d/99-shellgen <<CFG_EOF\n' + '\n'.join(logrot) + '\nCFG_EOF')
out.append('logrotate -d /etc/logrotate.d/99-shellgen')
return "\n".join(out) + "\n"
for _g in [NodeExporter, Prometheus, Grafana, Logrotate]:
register(_g())
+270
View File
@@ -0,0 +1,270 @@
"""
Network tools.
- Wireguard VPN
- OpenVPN
- Tailscale client
- DHCP server (isc-dhcp-server / kea)
- DNS forwarder (unbound / dnsmasq)
"""
from . import register, Generator, Field
from . import bash_header, quote, bool_str, yes
# ========================== WireGuard ==========================
class WireGuard(Generator):
id = "wireguard"
title = "WireGuard VPN"
category = "network"
icon = "🔒"
tags = ["wireguard", "vpn", "wg"]
description = "部署 WireGuard VPN server,可生成客户端配置。"
fields = [
Field("server_private_key", "Server Private Key", "password", default="",
help="留空将自动生成 (使用 wg genkey)。"),
Field("server_public_key", "Server Public Key", "text", default="",
help="留空将自动从 private key 派生。"),
Field("listen_port", "Listen 端口", "number", default="51820", min_=1, max_=65535),
Field("interface", "网卡接口", "text", default="eth0"),
Field("vpn_subnet", "VPN 子网 CIDR", "text", default="10.0.0.0/24"),
Field("server_vpn_ip", "Server VPN IP", "text", default="10.0.0.1/24"),
Field("peers", "客户端 Peer 列表", "textarea", default="client1\nclient2",
help="每行一个 client name,会为每个生成密钥对。"),
Field("peer_dns", "客户端 DNS", "text", default="1.1.1.1, 8.8.8.8"),
]
def render(self, p):
sk = p.get("server_private_key", "").strip()
pk = p.get("server_public_key", "").strip()
port = str(p.get("listen_port", "51820"))
iface = p.get("interface", "eth0")
subnet = p.get("vpn_subnet", "10.0.0.0/24")
sip = p.get("server_vpn_ip", "10.0.0.1/24")
peers = [p_.strip() for p_ in p.get("peers", "").splitlines() if p_.strip()]
dns = p.get("peer_dns", "1.1.1.1, 8.8.8.8")
out = [bash_header(self.title)]
out.append('log "Installing WireGuard..."')
out.append('$PKG_INSTALL wireguard qrencode')
out.append('')
# server keypair
if not sk:
out.append('wg genkey | tee /etc/wireguard/server.key | wg pubkey > /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 <<WG_EOF\n'
'[Interface]\n'
'Address = ' + sip + '\n'
'ListenPort = ' + port + '\n'
'PrivateKey = ' + (sk if sk else '$(cat /etc/wireguard/server.key)') + '\n'
'PostUp = iptables -A FORWARD -i %i -j ACCEPT; iptables -A FORWARD -o %i -j ACCEPT; iptables -t nat -A POSTROUTING -o ' + iface + ' -j MASQUERADE\n'
'PostDown = iptables -D FORWARD -i %i -j ACCEPT; iptables -D FORWARD -o %i -j ACCEPT; iptables -t nat -D POSTROUTING -o ' + iface + ' -j MASQUERADE\n\n')
for i, name in enumerate(peers, start=2):
out.append('[Peer]\n'
f'# {name}\n'
f'PublicKey = $(cat /etc/wireguard/clients/{name}.pub)\n'
f'AllowedIPs = 10.0.0.{i}/32\n\n')
out.append('WG_EOF')
out.append('chmod 600 /etc/wireguard/wg0.conf')
out.append('echo "net.ipv4.ip_forward=1" >> /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 <<CLI_EOF\n'
'[Interface]\n'
'Address = ' + ip + '\n'
'DNS = ' + dns + '\n'
'PrivateKey = ${CLIENT_PRIV}\n\n'
'[Peer]\n'
'PublicKey = ${SERVER_PUB}\n'
'Endpoint = YOUR_SERVER_IP:' + port + '\n'
'AllowedIPs = 0.0.0.0/0, ::/0\n'
'PersistentKeepalive = 25\n'
'CLI_EOF')
out.append('qrencode -t ansiutf8 < /etc/wireguard/clients/' + name + '.conf || true')
return "\n".join(out) + "\n"
# ========================== Tailscale ==========================
class Tailscale(Generator):
id = "tailscale"
title = "Tailscale 客户端"
category = "network"
icon = "🦀"
tags = ["tailscale", "vpn", "mesh"]
description = "安装并启动 Tailscale,可指定自定义控制服务器。"
fields = [
Field("login_server", "控制服务器", "text", default="https://controlplane.tailscale.com",
help="Headscale 自建时填入,如 https://hs.example.com。"),
Field("accept_routes", "接受子网路由", "checkbox", default="no"),
Field("advertise_exit", "作为 Exit Node", "checkbox", default="no"),
Field("auth_key", "Auth Key (Headscale/preauth)", "password", default="",
help="留空则用 sudo tailscale up 手动登录。"),
Field("hostname", "Hostname", "text", default="$(hostname)"),
]
def render(self, p):
ls = p.get("login_server", "https://controlplane.tailscale.com")
ar = bool_str(p.get("accept_routes"))
ex = bool_str(p.get("advertise_exit"))
ak = p.get("auth_key", "").strip()
hn = p.get("hostname", "$(hostname)")
out = [bash_header(self.title)]
out.append('log "Installing Tailscale..."')
out.append('curl -fsSL https://tailscale.com/install.sh | sh')
out.append('systemctl enable --now tailscaled')
out.append('sleep 2')
flags = []
if ls:
flags.append('--login-server=' + ls)
if ar:
flags.append('--accept-routes')
if ex:
flags.append('--advertise-exit-node')
if hn:
flags.append('--hostname=' + hn)
flag_str = " ".join(flags)
if ak:
out.append('tailscale up ' + flag_str + ' --authkey=' + ak)
else:
out.append('tailscale up ' + flag_str)
out.append('log "Visit the URL printed above to authenticate."')
out.append('tailscale status')
return "\n".join(out) + "\n"
# ========================== DNSMASQ ==========================
class Dnsmasq(Generator):
id = "dnsmasq"
title = "Dnsmasq (DHCP + DNS 缓存)"
category = "network"
icon = "📡"
tags = ["dns", "dhcp", "dnsmasq"]
description = "轻量级 DNS 转发 + DHCP server。"
fields = [
Field("enable_dhcp", "启用 DHCP", "checkbox", default="yes"),
Field("dhcp_range_start", "DHCP 起始 IP", "text", default="192.168.1.100"),
Field("dhcp_range_end", "DHCP 结束 IP", "text", default="192.168.1.200"),
Field("dhcp_lease", "租约时间", "text", default="12h"),
Field("dhcp_gateway", "DHCP 网关", "text", default="192.168.1.1"),
Field("dhcp_dns", "DHCP 推送的 DNS", "text", default="1.1.1.1, 8.8.8.8"),
Field("upstream_dns", "上游 DNS", "textarea", default="1.1.1.1\n8.8.8.8"),
Field("local_domain", "本地域名", "text", default="lan"),
Field("listen_address", "Listen IP", "text", default="0.0.0.0"),
]
def render(self, p):
ed = bool_str(p.get("enable_dhcp", True))
rstart = p.get("dhcp_range_start", "192.168.1.100")
rend = p.get("dhcp_range_end", "192.168.1.200")
lease = p.get("dhcp_lease", "12h")
gw = p.get("dhcp_gateway", "192.168.1.1")
dns = p.get("dhcp_dns", "1.1.1.1, 8.8.8.8")
up = [u.strip() for u in p.get("upstream_dns", "").splitlines() if u.strip()]
domain = p.get("local_domain", "lan")
listen = p.get("listen_address", "0.0.0.0")
out = [bash_header(self.title)]
out.append('log "Installing dnsmasq..."')
out.append('$PKG_INSTALL dnsmasq')
out.append('cp /etc/dnsmasq.conf /etc/dnsmasq.conf.bak.$(date +%s) || true')
cfg = []
cfg.append('listen-address=' + listen)
cfg.append('bind-interfaces')
cfg.append('domain=' + domain)
cfg.append('local=/' + domain + '/')
cfg.append('cache-size=1000')
for u in up:
cfg.append('server=' + u)
if ed:
cfg.append('dhcp-range=' + rstart + ',' + rend + ',' + lease)
cfg.append('dhcp-option=3,' + gw)
cfg.append('dhcp-option=6,' + dns)
out.append('cat > /etc/dnsmasq.conf <<CFG_EOF\n' + '\n'.join(cfg) + '\nCFG_EOF')
out.append('systemctl enable --now dnsmasq')
out.append('ss -ltnup | grep :53 || warn "port 53 not listening yet"')
return "\n".join(out) + "\n"
# ========================== OpenVPN ==========================
class OpenVPN(Generator):
id = "openvpn"
title = "OpenVPN 简化部署",
category = "network"
icon = "🛡️"
tags = ["openvpn", "vpn"]
description = "安装 openvpn + easy-rsa,生成服务端 CA/密钥/客户端配置。"
warnings = ["生产环境请先生成正式 PKI,本脚本为快速原型。"]
fields = [
Field("proto", "协议", "select", default="udp", options=["udp", "tcp"]),
Field("port", "端口", "number", default="1194", min_=1, max_=65535),
Field("subnet", "VPN 子网", "text", default="10.8.0.0/24"),
Field("dns_servers", "推送的 DNS", "text", default="1.1.1.1, 8.8.8.8"),
Field("cipher", "加密算法", "select", default="AES-256-GCM",
options=["AES-256-GCM", "AES-256-CBC", "CHACHA20-POLY1305"]),
Field("clients", "生成客户端数", "number", default="1", min_=1, max_=20),
]
def render(self, p):
proto = p.get("proto", "udp")
port = str(p.get("port", "1194"))
subnet = p.get("subnet", "10.8.0.0/24")
dns = p.get("dns_servers", "1.1.1.1, 8.8.8.8")
cipher = p.get("cipher", "AES-256-GCM")
n = int(p.get("clients", "1"))
out = [bash_header(self.title)]
out.append('log "Installing openvpn + easy-rsa..."')
out.append('$PKG_INSTALL openvpn easy-rsa')
out.append('make-cadir /etc/openvpn/easy-rsa')
out.append('cd /etc/openvpn/easy-rsa')
out.append('./easyrsa init-pki')
out.append('./easyrsa build-ca nopass')
out.append('./easyrsa gen-req server nopass')
out.append('./easyrsa sign-req server server <<< "yes"')
out.append('./easyrsa gen-dh')
out.append('openvpn --genkey secret /etc/openvpn/ta.key')
out.append('cp pki/ca.crt pki/private/server.key pki/issued/server.crt pki/dh.pem /etc/openvpn/')
out.append('cp /etc/openvpn/easy-rsa/ta.key /etc/openvpn/')
out.append('')
out.append('cat > /etc/openvpn/server.conf <<CFG_EOF\n'
'port ' + port + '\n'
'proto ' + proto + '\n'
'dev tun\n'
'ca ca.crt\ncert server.crt\nkey server.key\ndh dh.pem\n'
'auth SHA256\ncipher ' + cipher + '\n'
'tls-auth ta.key 0\n'
'topology subnet\n'
'server ' + subnet + '\n'
'push "redirect-gateway def1 bypass-dhcp"\n'
'push "dhcp-option DNS ' + dns.split(',')[0].strip() + '"\n'
'keepalive 10 120\npersist-key\npersist-tun\nstatus openvpn-status.log\nverb 3\n'
'CFG_EOF')
out.append('systemctl enable --now openvpn@server')
out.append('echo "net.ipv4.ip_forward=1" >> /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 <<OVPN_EOF\n'
'client\ndev tun\nproto ' + proto + '\n'
'remote YOUR_SERVER_IP ' + port + '\n'
'resolv-retry infinite\nnobind\n'
'persist-key\npersist-tun\nremote-cert-tls server\nauth SHA256\ncipher ' + cipher + '\n'
'verb 3\n<ca>\n$(cat /etc/openvpn/ca.crt)\n</ca>\n<cert>\n$(cat /etc/openvpn/easy-rsa/pki/issued/' + cn + '.crt)\n</cert>\n<key>\n$(cat /etc/openvpn/easy-rsa/pki/private/' + cn + '.key)\n</key>\n<tls-auth>\n$(cat /etc/openvpn/ta.key)\n</tls-auth>\nkey-direction 1\nOVPN_EOF')
return "\n".join(out) + "\n"
for _g in [WireGuard, Tailscale, Dnsmasq, OpenVPN]:
register(_g())
+651
View File
@@ -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-<ver>。"
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/<ver>"),
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 <<REPO_EOF\n'
'[mongodb-org-' + ver + ']\n'
'name=MongoDB Repository\n'
'baseurl=https://repo.mongodb.org/yum/redhat/$releasever/mongodb-org/' + ver + '/x86_64/\n'
'gpgcheck=1\n'
'enabled=1\n'
'gpgkey=https://www.mongodb.org/static/pgp/server-' + ver + '.asc\n'
'REPO_EOF\n'
' $PKG_INSTALL mongodb-org\n'
' ;;\n'
'esac')
out.append('systemctl enable --now mongod')
out.append('sleep 3')
if auth:
out.append('log "Creating root user..."')
out.append('mongosh --quiet --eval \''
'db.getSiblingDB("admin").createUser({user:"' + user + '",pwd:"' + pwd + '",roles:[{role:"root",db:"admin"}]})\' || warn "auth may already be set"')
out.append('sed -i "s/^#security:/{ security: { authorization: \"enabled\" },/g" /etc/mongod.conf')
out.append('sed -i "s/^ bindIp:.*/ bindIp: ' + bind + '/" /etc/mongod.conf')
out.append('grep -q "^ port:" /etc/mongod.conf || sed -i "/^ bindIp:/a\\ port: ' + port + '" /etc/mongod.conf')
if repl:
out.append('grep -q "^replication:" /etc/mongod.conf || cat >> /etc/mongod.conf <<EOF\\nreplication:\\n replSetName: ' + repl + '\\nEOF')
out.append('systemctl restart mongod')
return "\n".join(out) + "\n"
# Register all
for _g in [Python, NodeJS, GCC, Make, CMake, GoLang, Java, PHP, Ruby, RustLang,
PostgreSQL, MySQL, MongoDB]:
register(_g())
+414
View File
@@ -0,0 +1,414 @@
"""
System / OS-level tools.
- Firewall (iptables / firewalld / ufw)
- Swap setup
- Time sync (chrony)
- SSH hardening
- Hostname & hosts
- Disk / fstab
- Limits (ulimit)
- Sysctl tuning
"""
from . import register, Generator, Field
from . import bash_header, quote, bool_str, yes
# ========================== Firewall ==========================
class Firewall(Generator):
id = "firewall"
title = "防火墙 (iptables / firewalld / ufw)"
category = "system"
icon = "🧱"
tags = ["firewall", "iptables", "firewalld", "ufw"]
description = "根据发行版自动选择 firewalld (RHEL) 或 ufw (Debian),开放指定端口。"
warnings = ["修改防火墙可能断连,建议在带外有 console 的情况下执行。"]
fields = [
Field("ports", "要开放的端口 (TCP)", "text", default="22,80,443",
help="逗号或空格分隔,如 22 80 443 或 22,80,443。"),
Field("udp_ports", "UDP 端口", "text", default=""),
Field("trusted_ip", "完全信任的 IP/CIDR", "text", default="",
help="填 192.168.1.0/24 可对该网段放行所有端口。"),
Field("ssh_port", "SSH 端口", "number", default="22", min_=1, max_=65535),
Field("deny_incoming", "默认拒绝入站", "checkbox", default="yes"),
Field("allow_outgoing", "允许所有出站", "checkbox", default="yes"),
]
def render(self, p):
ports = p.get("ports", "22,80,443").replace(",", " ").split()
udps = p.get("udp_ports", "").replace(",", " ").split() if p.get("udp_ports") else []
trusted = p.get("trusted_ip", "").strip()
ssh = str(p.get("ssh_port", "22"))
deny_in = bool_str(p.get("deny_incoming", True))
allow_out = bool_str(p.get("allow_outgoing", True))
ports_str = " ".join([quote(p_) for p_ in ports])
udps_str = " ".join([quote(p_) for p_ in udps]) if udps else ""
out = [bash_header(self.title)]
out.append('log "Configuring firewall..."')
# Build the case/esac block as a list of lines then join — avoids the
# "implicit string concatenation" gotcha when mixing `+` with bare strings.
fw = []
fw.append('case "$DISTRO_ID" in')
fw.append(' ubuntu|debian)')
fw.append(' $PKG_INSTALL ufw')
fw.append(' ufw --force reset')
fw.append(' ufw default ' + ('deny' if deny_in else 'allow') + ' incoming')
fw.append(' ufw default ' + ('allow' if allow_out else 'deny') + ' outgoing')
for p_ in ports:
if p_ != ssh:
fw.append(f' ufw allow {p_}/tcp')
for p_ in udps:
fw.append(f' ufw allow {p_}/udp')
if trusted:
fw.append(f' ufw allow from {trusted} to any port 0:65535')
fw.append(' ufw allow ' + ssh + '/tcp comment "ssh"')
fw.append(' ufw --force enable')
fw.append(' ;;')
fw.append(' centos|rhel|rocky|almalinux|ol|fedora)')
fw.append(' $PKG_INSTALL firewalld')
fw.append(' systemctl enable --now firewalld')
for p_ in ports:
fw.append(f' firewall-cmd --permanent --add-port={p_}/tcp')
for p_ in udps:
fw.append(f' firewall-cmd --permanent --add-port={p_}/udp')
if trusted:
fw.append(f' firewall-cmd --permanent --add-source={trusted}')
fw.append(' firewall-cmd --permanent --add-port=0-65535/tcp')
fw.append(' firewall-cmd --permanent --add-port=0-65535/udp')
fw.append(' firewall-cmd --reload')
fw.append(' ;;')
fw.append(' *)')
fw.append(' warn "Auto-firewall not supported on $DISTRO_ID, falling back to iptables"')
fw.append(' $PKG_INSTALL iptables-persistent || $PKG_INSTALL iptables-services')
fw.append(' iptables -P INPUT ' + ('DROP' if deny_in else 'ACCEPT'))
fw.append(' iptables -A INPUT -i lo -j ACCEPT')
fw.append(' iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT')
for p_ in ports:
fw.append(f' iptables -A INPUT -p tcp --dport {p_} -j ACCEPT')
if trusted:
fw.append(f' iptables -A INPUT -s {trusted} -j ACCEPT')
fw.append(' netfilter-persistent save || service iptables save')
fw.append(' ;;')
fw.append('esac')
out.append('\n'.join(fw))
out.append('log "Done."')
return "\n".join(out) + "\n"
# ========================== Swap ==========================
class Swap(Generator):
id = "swap"
title = "Swap 交换分区/文件"
category = "system"
icon = "💾"
tags = ["swap"]
description = "创建 swap 文件并启用。"
fields = [
Field("size_gb", "大小 (GB)", "number", default="2", min_=1, max_=128),
Field("path", "Swap 文件路径", "text", default="/swapfile"),
Field("swappiness", "Swappiness", "number", default="10", min_=0, max_=100),
]
def render(self, p):
sz = str(p.get("size_gb", "2"))
path = p.get("path", "/swapfile")
sw = str(p.get("swappiness", "10"))
out = [bash_header(self.title)]
out.append('log "Creating ' + sz + 'GB swap at ' + path + '..."')
out.append('if [ -f "' + path + '" ]; then swapoff ' + path + ' && rm -f ' + path + '; fi')
out.append('fallocate -l ' + sz + 'G ' + path + ' || dd if=/dev/zero of=' + path + ' bs=1M count=$(((' + sz + '*1024))) status=none')
out.append('chmod 600 ' + path)
out.append('mkswap ' + path)
out.append('swapon ' + path)
out.append('grep -q "' + path + ' " /etc/fstab || echo "' + path + ' none swap sw 0 0" >> /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 <<CFG_EOF\n'
'pool ntp.ubuntu.org maxsources 4\n'
'pool 0.pool.ntp.org maxsources 1\n'
'pool 1.pool.ntp.org maxsources 1\n'
'pool 2.pool.ntp.org maxsources 1\n'
'makestep ' + ms + '\n'
'rtcsync\n'
'logdir /var/log/chrony\n'
+ ('allow ' + allow + '\n' if allow else 'allow 127.0.0.1\n') +
'CFG_EOF')
for s in srvs:
out.append('echo "pool ' + s + ' iburst maxsources 4" >> /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 <<CFG_EOF\n' + '\n'.join(sshd_lines) + '\nCFG_EOF')
if banner:
out.append('cat > /etc/ssh/banner <<BANNER_EOF\n' + banner + '\nBANNER_EOF')
out.append('echo "Banner /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 <<SYSCTL_EOF\n'
'fs.file-max = ' + fmax + '\n'
'vm.max_map_count = ' + mmc + '\n'
'net.core.somaxconn = ' + somaxconn + '\n'
'net.core.netdev_max_backlog = 16384\n'
'net.ipv4.tcp_max_syn_backlog = 8192\n'
'net.ipv4.tcp_slow_start_after_idle = 0\n'
'net.ipv4.tcp_tw_reuse = ' + ('1' if tw else '0') + '\n'
'net.ipv4.tcp_fin_timeout = 15\n'
'net.ipv4.tcp_keepalive_time = 600\n'
'net.ipv4.tcp_keepalive_intvl = 30\n'
'net.ipv4.tcp_keepalive_probes = 3\n'
'net.ipv4.tcp_fastopen = ' + ('3' if tfo else '0') + '\n'
'net.ipv4.ip_forward = ' + ('1' if fwd else '0') + '\n'
'net.ipv4.conf.all.rp_filter = 1\n'
'net.ipv4.conf.default.rp_filter = 1\n'
'net.ipv4.conf.all.accept_source_route = 0\n'
'net.ipv4.conf.default.accept_source_route = 0\n'
'net.ipv4.icmp_echo_ignore_broadcasts = 1\n'
'net.ipv4.conf.all.send_redirects = 0\n'
'net.ipv4.conf.all.accept_redirects = 0\n'
'net.ipv4.conf.all.secure_redirects = 0\n'
'net.ipv6.conf.all.accept_redirects = 0\n'
'SYSCTL_EOF')
if bbr:
out.append('cat >> /etc/sysctl.d/99-shellgen.conf <<SYSCTL_EOF\n'
'net.core.default_qdisc = fq\n'
'net.ipv4.tcp_congestion_control = bbr\n'
'SYSCTL_EOF')
if apply:
out.append('sysctl -p /etc/sysctl.d/99-shellgen.conf')
return "\n".join(out) + "\n"
# ========================== ulimit / limits.conf ==========================
class Ulimit(Generator):
id = "ulimit"
title = "limits.conf 资源限制"
category = "system"
icon = "📊"
tags = ["ulimit", "limits"]
description = "为指定用户/组写 /etc/security/limits.d/*.conf。"
fields = [
Field("target", "对象 (user 或 @group)", "text", default="@app",
help="@ 表示组;不带前缀为用户。"),
Field("nofile", "nofile (打开文件数)", "number", default="65536"),
Field("nproc", "nproc (进程数)", "number", default="65536"),
Field("memlock", "memlock (KB, 无限 = unlimited)", "text", default="unlimited"),
Field("cpu", "cpu (分钟, 无限 = unlimited)", "text", default="unlimited"),
Field("data", "data (KB)", "text", default="unlimited"),
]
def render(self, p):
t = p.get("target", "@app")
nofile = str(p.get("nofile", "65536"))
nproc = str(p.get("nproc", "65536"))
memlock = p.get("memlock", "unlimited")
cpu = p.get("cpu", "unlimited")
data = p.get("data", "unlimited")
out = [bash_header(self.title)]
out.append('cat > /etc/security/limits.d/99-shellgen.conf <<LIM_EOF\n'
+ t + ' soft nofile ' + nofile + '\n'
+ t + ' hard nofile ' + nofile + '\n'
+ t + ' soft nproc ' + nproc + '\n'
+ t + ' hard nproc ' + nproc + '\n'
+ t + ' soft memlock ' + memlock + '\n'
+ t + ' hard memlock ' + memlock + '\n'
+ t + ' soft cpu ' + cpu + '\n'
+ t + ' hard cpu ' + cpu + '\n'
+ t + ' soft data ' + data + '\n'
+ t + ' hard data ' + data + '\n'
'LIM_EOF')
out.append('log "Re-login required for new limits to take effect."')
return "\n".join(out) + "\n"
# ========================== Hostname / hosts ==========================
class Hostname(Generator):
id = "hostname"
title = "Hostname 与 /etc/hosts"
category = "system"
icon = "🏷️"
tags = ["hostname", "hosts"]
description = "修改主机名并更新 /etc/hosts。"
fields = [
Field("hostname", "新主机名", "text", default="server01",
help="FQDN 格式: server01.example.com, 短名会自动取 . 前部分。"),
Field("extra_hosts", "额外 hosts 行", "textarea", default="",
help="每行: <ip> <hostname> [alias...]"),
]
def render(self, p):
h = p.get("hostname", "server01")
extras = [e for e in p.get("extra_hosts", "").splitlines() if e.strip()]
out = [bash_header(self.title)]
out.append('hostnamectl set-hostname ' + h)
out.append('cp /etc/hosts /etc/hosts.bak.$(date +%s)')
out.append('# Ensure 127.0.0.1 contains this hostname')
out.append('grep -q "' + h + '" /etc/hosts || sed -i "s/^127.0.0.1\\(\\s\\+\\)localhost.*/& ' + h + '/" /etc/hosts')
for e in extras:
out.append('echo "' + e + '" >> /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())
+234
View File
@@ -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/<name>.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/<name>", "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 <<UNIT_EOF\n' + '\n'.join(unit) + '\nUNIT_EOF')
out.append('systemctl daemon-reload')
if auto:
out.append('systemctl enable --now ' + name)
out.append('sleep 1')
out.append('systemctl --no-pager status ' + name + ' || true')
else:
out.append('log "Unit installed but not started. Run: systemctl enable --now ' + name + '"')
out.append('log "Useful commands:')
out.append(' systemctl status ' + name)
out.append(' journalctl -u ' + name + ' -f')
out.append(' systemctl restart ' + name + '"')
return "\n".join(out) + "\n"
# A few presets that wrap the same engine with sensible defaults
def _make_preset(id_, title, icon, description, defaults, tags=()):
class G(SystemdGeneric):
pass
G.id = id_
G.title = title
G.icon = icon
G.description = description
G.tags = list(tags)
G.fields = [] # CRITICAL: avoid mutating the parent's shared list
for f in SystemdGeneric.fields:
f2 = Field(f.name, f.label, f.type, f.default, f.placeholder, f.help,
f.options, f.required, f.min, f.max, f.step, f.pattern, f.group)
if f.name in defaults:
f2.default = defaults[f.name]
G.fields.append(f2)
return G
# preset: Python uvicorn/gunicorn web app
GunicornSvc = _make_preset(
id_="svc-gunicorn",
title="Gunicorn (Python) systemd 服务",
icon="🐍",
description="为 gunicorn 应用生成 systemd unit,自动设置 GUNICORN_CMD_ARGS、日志目录。",
defaults={
"service_name": "myapp",
"description": "My Python web app (gunicorn)",
"exec_start": "/opt/myapp/venv/bin/gunicorn --workers 3 --bind 0.0.0.0:8000 app:app",
"type": "simple",
"user": "app",
"working_dir": "/opt/myapp",
"env_vars": "PYTHONUNBUFFERED=1\nLOG_LEVEL=info",
},
tags=("python", "gunicorn", "flask", "django", "fastapi"),
)
register(GunicornSvc())
# preset: Node.js pm2-style systemd
NodeSvc = _make_preset(
id_="svc-nodejs",
title="Node.js (npm start) systemd 服务",
icon="🟢",
description="为 Node.js 应用生成 systemd unit,支持 npm start / node 直接启动。",
defaults={
"service_name": "nodeapp",
"description": "Node.js application",
"exec_start": "/usr/bin/node /opt/nodeapp/server.js",
"user": "app",
"working_dir": "/opt/nodeapp",
"env_vars": "NODE_ENV=production\nPORT=3000",
},
tags=("nodejs", "node", "express", "next"),
)
register(NodeSvc())
# preset: Java -jar
JavaSvc = _make_preset(
id_="svc-java",
title="Java -jar systemd 服务",
icon="",
description="为 SpringBoot/任意 Java -jar 应用生成 systemd unit,自动设置 JAVA_HOME。",
defaults={
"service_name": "javaapp",
"description": "Java application",
"exec_start": "/usr/bin/java -Xms512m -Xmx1024m -jar /opt/javaapp/app.jar --server.port=8080",
"user": "app",
"working_dir": "/opt/javaapp",
"env_vars": "JAVA_HOME=/usr/lib/jvm/java-17-openjdk",
},
tags=("java", "springboot", "jar"),
)
register(JavaSvc())
# preset: Go binary
GoSvc = _make_preset(
id_="svc-go",
title="Go binary systemd 服务",
icon="🐹",
description="为编译后的 Go 二进制生成 systemd unit。",
defaults={
"service_name": "goapp",
"description": "Go application",
"exec_start": "/opt/goapp/bin/server -config /etc/goapp/server.yaml",
"user": "app",
"working_dir": "/opt/goapp",
},
tags=("go", "golang"),
)
register(GoSvc())
# preset: shell script
ShellSvc = _make_preset(
id_="svc-shell",
title="Shell 脚本 systemd 服务",
icon="📜",
description="为任意 shell 脚本生成 systemd unit,适合定时/常驻脚本。",
defaults={
"service_name": "myscript",
"description": "Custom shell script service",
"exec_start": "/opt/myscript/run.sh",
"user": "root",
"working_dir": "/opt/myscript",
},
tags=("bash", "script"),
)
register(ShellSvc())
# Register the generic one as well
register(SystemdGeneric())
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=shell-gen - Linux shell script generator
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/root/shell-gen
ExecStart=/usr/bin/python3 /root/shell-gen/app.py
Restart=on-failure
RestartSec=3
Environment=PORT=5099
[Install]
WantedBy=multi-user.target
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# shell-gen - Linux shell script generator web UI
# Run on host as a foreground process (development) or via systemd (production).
set -euo pipefail
cd "$(dirname "$0")"
export PORT="${PORT:-5099}"
export FLASK_ENV="${FLASK_ENV:-production}"
echo "Starting shell-gen on port $PORT (PID $$)..."
exec python3 app.py
+257
View File
@@ -0,0 +1,257 @@
/* shell-gen — dark theme */
:root {
--bg: #0b0e14;
--bg-elev: #131720;
--bg-elev-2: #1a1f2c;
--border: #2a3142;
--border-strong: #3b4357;
--text: #e4e7ee;
--text-muted: #9ca3af;
--text-dim: #6b7280;
--accent: #818cf8;
--accent-hover: #6366f1;
--accent-fg: #ffffff;
--success: #10b981;
--warn: #f59e0b;
--danger: #ef4444;
--code-bg: #0a0d12;
--code-fg: #cbd5e0;
--shadow: 0 4px 20px rgba(0,0,0,.5);
}
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "PingFang SC", "Microsoft YaHei", sans-serif;
font-size: 14px; line-height: 1.5; }
a { color: var(--accent); text-decoration: none; }
a:hover { color: var(--accent-hover); }
.topbar {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 24px;
background: var(--bg-elev);
border-bottom: 1px solid var(--border);
position: sticky; top: 0; z-index: 50;
}
.brand { display: flex; align-items: center; gap: 10px; }
.logo { font-size: 22px; }
.brand .title { font-weight: 700; font-size: 16px; color: var(--text); }
.brand .subtitle { color: var(--text-muted); font-size: 13px; }
.nav a { color: var(--text-muted); margin-right: 8px; }
.nav a:hover { color: var(--accent); }
.nav .sep { color: var(--text-dim); margin: 0 4px; }
.count { color: var(--text-muted); font-size: 13px; }
.footer {
text-align: center; padding: 24px; color: var(--text-dim); font-size: 12px;
border-top: 1px solid var(--border); margin-top: 48px;
}
.footer a { color: var(--text-muted); }
.footer span { margin: 0 6px; }
.container { max-width: 1320px; margin: 0 auto; padding: 24px; }
.hero { padding: 32px 0 16px; }
.hero h1 { font-size: 32px; margin: 0 0 8px; color: var(--text); }
.lead { color: var(--text-muted); max-width: 720px; line-height: 1.7; margin: 0 0 24px; }
.stats { display: flex; gap: 24px; }
.stat .num { font-size: 32px; font-weight: 700; color: var(--accent); }
.stat .lbl { color: var(--text-muted); font-size: 13px; }
.cat-section { margin: 32px 0; }
.cat-section h2 {
margin: 0 0 16px; font-size: 20px;
display: flex; align-items: center; gap: 8px;
padding-bottom: 8px; border-bottom: 1px solid var(--border);
}
.cat-icon { font-size: 22px; }
.cat-label { color: var(--text-muted); font-weight: 400; font-size: 14px; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px;
}
.card {
display: block; background: var(--bg-elev);
border: 1px solid var(--border); border-radius: 10px;
padding: 16px; text-decoration: none; color: var(--text);
transition: all .15s;
}
.card:hover {
background: var(--bg-elev-2);
border-color: var(--accent);
transform: translateY(-1px);
box-shadow: var(--shadow);
}
.card-icon { font-size: 32px; margin-bottom: 8px; }
.card-title { font-weight: 600; font-size: 15px; margin-bottom: 6px; }
.card-desc { color: var(--text-muted); font-size: 12px; line-height: 1.5; min-height: 36px; }
.card-meta { margin-top: 10px; display: flex; flex-direction: column; gap: 6px; }
.tags, .oss { display: flex; flex-wrap: wrap; gap: 4px; }
.tag { background: rgba(129,140,248,.12); color: var(--accent);
padding: 2px 8px; border-radius: 4px; font-size: 11px; }
.os { background: var(--bg-elev-2); color: var(--text-muted);
padding: 2px 6px; border-radius: 4px; font-size: 11px; }
.dangerous { background: rgba(239,68,68,.15); color: var(--danger);
padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
/* ----- generator page ----- */
.breadcrumb { display: flex; align-items: center; gap: 8px;
margin-bottom: 16px; color: var(--text-muted); font-size: 13px; }
.breadcrumb a { color: var(--text-muted); }
.breadcrumb a:hover { color: var(--accent); }
.gen-header { display: flex; gap: 20px; margin-bottom: 24px; }
.gen-icon { font-size: 56px; line-height: 1; }
.gen-meta h1 { margin: 0 0 8px; font-size: 28px; }
.gen-meta p { color: var(--text-muted); margin: 0 0 12px; }
.gen-tags { display: flex; flex-wrap: wrap; gap: 6px; }
.warnings {
background: rgba(245,158,11,.08);
border: 1px solid rgba(245,158,11,.3);
border-radius: 8px;
padding: 14px 18px;
margin-bottom: 20px;
}
.warnings h3 { margin: 0 0 8px; color: var(--warn); font-size: 15px; }
.warnings ul { margin: 0; padding-left: 20px; color: var(--text-muted); }
.warnings li { margin: 4px 0; }
.grid-2col {
display: grid;
grid-template-columns: 460px 1fr;
gap: 20px;
margin-top: 20px;
}
@media (max-width: 1080px) {
.grid-2col { grid-template-columns: 1fr; }
}
.form-card, .preview-card {
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: 10px;
padding: 18px;
}
.form-card h2, .preview-card h2 { margin: 0 0 14px; font-size: 16px; }
.field { margin-bottom: 14px; }
.field label { display: block; margin-bottom: 5px; font-size: 13px; color: var(--text); font-weight: 500; }
.field label .req { color: var(--danger); }
.field input[type=text], .field input[type=number], .field input[type=password],
.field select, .field textarea {
width: 100%;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border-strong);
border-radius: 6px;
padding: 8px 10px;
font-size: 13px;
font-family: inherit;
transition: border .15s;
}
.field input:focus, .field select:focus, .field textarea:focus {
outline: none; border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(129,140,248,.15);
}
.field textarea { font-family: "SF Mono", Menlo, Consolas, monospace; resize: vertical; }
.checkbox { display: flex; align-items: center; gap: 6px; cursor: pointer; }
.checkbox input { width: 16px; height: 16px; cursor: pointer; }
.help { font-size: 11px; color: var(--text-dim); margin: 4px 0 0; line-height: 1.4; }
.form-actions { display: flex; gap: 8px; margin-top: 16px; }
.btn {
background: var(--bg-elev-2);
color: var(--text);
border: 1px solid var(--border-strong);
border-radius: 6px;
padding: 8px 14px;
font-size: 13px;
cursor: pointer;
font-family: inherit;
transition: all .15s;
}
.btn:hover { background: var(--border); }
.btn-primary { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
.btn-secondary { background: transparent; color: var(--accent); border-color: var(--accent); }
.btn-small { padding: 4px 10px; font-size: 12px; }
.preview-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.preview-actions { display: flex; align-items: center; gap: 8px; }
.muted { color: var(--text-muted); font-size: 12px; }
#script-pre {
background: var(--code-bg);
color: var(--code-fg);
border: 1px solid var(--border);
border-radius: 6px;
padding: 14px;
overflow: auto;
max-height: 600px;
margin: 0;
font-family: "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace;
font-size: 12.5px;
line-height: 1.55;
white-space: pre;
}
#script-pre code { font-family: inherit; }
.copy-toast {
position: fixed; bottom: 24px; right: 24px;
background: var(--success); color: #fff;
padding: 8px 16px; border-radius: 6px;
opacity: 0; transition: opacity .2s; pointer-events: none;
}
.copy-toast.show { opacity: 1; }
.err-box {
margin-top: 10px;
background: rgba(239,68,68,.1);
border: 1px solid rgba(239,68,68,.3);
border-radius: 6px;
padding: 10px 14px;
color: var(--danger);
font-size: 12px;
font-family: "SF Mono", Menlo, Consolas, monospace;
white-space: pre-wrap;
}
.hidden { display: none; }
.after { margin-top: 24px; }
.after h3 { color: var(--text); margin: 14px 0 8px; font-size: 15px; }
.post-steps, .verify-steps { color: var(--text-muted); padding-left: 20px; }
.post-steps li, .verify-steps li { margin: 4px 0; }
.post-steps code, .verify-steps code {
background: var(--bg-elev); padding: 2px 6px; border-radius: 4px;
font-size: 12px; color: var(--accent);
}
/* Bundle drawer */
.bundle-drawer {
position: fixed; right: 20px; bottom: 20px;
width: 380px; max-height: 70vh;
background: var(--bg-elev); border: 1px solid var(--border-strong);
border-radius: 10px; box-shadow: var(--shadow);
z-index: 100;
display: flex; flex-direction: column;
}
.bundle-header {
display: flex; align-items: center; gap: 8px;
padding: 12px 14px; border-bottom: 1px solid var(--border);
}
.bundle-header h3 { margin: 0; font-size: 14px; flex: 0 0 auto; }
.bundle-list {
list-style: none; margin: 0; padding: 8px 0;
overflow: auto; flex: 1;
}
.bundle-list li {
display: flex; align-items: center; gap: 8px;
padding: 6px 14px; font-size: 13px;
}
.bundle-list li:hover { background: var(--bg-elev-2); }
.bundle-list .b-id { color: var(--accent); font-family: "SF Mono", monospace; font-size: 12px; }
.bundle-list .b-title { color: var(--text-muted); flex: 1; }
.bundle-list .empty { color: var(--text-dim); text-align: center; font-size: 12px; padding: 20px; }
+33
View File
@@ -0,0 +1,33 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{% block title %}Shell-Gen · Linux 部署脚本生成器{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="logo">⚙️</span>
<span class="title">shell-gen</span>
<span class="subtitle">Linux 一键部署脚本生成器</span>
</div>
<nav class="nav">
<a href="{{ url_for('index') }}">首页</a>
<span class="sep">·</span>
<span class="count">{{ total }} 个生成器</span>
</nav>
</header>
{% block body %}{% endblock %}
<footer class="footer">
<span>Generated by shell-gen · {{ total }} generators</span>
<span>·</span>
<a href="/healthz">/healthz</a>
<span>·</span>
<span>Compatible with Ubuntu / Debian / CentOS / RHEL / Rocky / Alma</span>
</footer>
</body>
</html>
+253
View File
@@ -0,0 +1,253 @@
{% extends "base.html" %}
{% block title %}{{ gen.title }} · Shell-Gen{% endblock %}
{% block body %}
<main class="container">
<nav class="breadcrumb">
<a href="{{ url_for('index') }}">← 返回首页</a>
<span class="cat-icon">{{ category_icons.get(gen.category, '📦') }}</span>
<span class="cat-label">{{ category_labels.get(gen.category, gen.category) }}</span>
</nav>
<header class="gen-header">
<div class="gen-icon">{{ gen.icon }}</div>
<div class="gen-meta">
<h1>{{ gen.title }}</h1>
<p>{{ gen.description }}</p>
<div class="gen-tags">
{% for t in gen.tags %}<span class="tag">#{{ t }}</span>{% endfor %}
{% for o in gen.os_support %}<span class="os">{{ o }}</span>{% endfor %}
{% if gen.dangerous %}<span class="dangerous">⚠️ 危险操作</span>{% endif %}
</div>
</div>
</header>
{% if gen.warnings %}
<div class="warnings">
<h3>⚠️ 注意事项</h3>
<ul>
{% for w in gen.warnings %}<li>{{ w }}</li>{% endfor %}
</ul>
</div>
{% endif %}
<div class="grid-2col">
<section class="form-card">
<h2>📝 参数</h2>
<form id="gen-form" data-gen="{{ gen.id }}">
{% for f in gen.fields %}
<div class="field" data-group="{{ f.group or '' }}">
<label for="f-{{ f.name }}">
{{ f.label }}
{% if f.required %}<span class="req">*</span>{% endif %}
</label>
{% if f.type == 'select' %}
<select id="f-{{ f.name }}" name="{{ f.name }}" {% if f.required %}required{% endif %}>
{% for opt in f.options %}<option value="{{ opt }}" {% if opt == f.default %}selected{% endif %}>{{ opt }}</option>{% endfor %}
</select>
{% elif f.type == 'textarea' %}
<textarea id="f-{{ f.name }}" name="{{ f.name }}" rows="4" placeholder="{{ f.placeholder }}" {% if f.required %}required{% endif %}>{{ f.default }}</textarea>
{% elif f.type == 'checkbox' %}
<label class="checkbox">
<input type="checkbox" id="f-{{ f.name }}" name="{{ f.name }}" value="yes"
{% if f.default in ('yes', 'true', '1', 'on') %}checked{% endif %}>
<span>启用</span>
</label>
{% elif f.type == 'number' %}
<input type="number" id="f-{{ f.name }}" name="{{ f.name }}"
value="{{ f.default }}" placeholder="{{ f.placeholder }}"
min="{{ f.min or '' }}" max="{{ f.max or '' }}" step="{{ f.step or '' }}"
{% if f.required %}required{% endif %}>
{% elif f.type == 'password' %}
<input type="password" id="f-{{ f.name }}" name="{{ f.name }}"
value="{{ f.default }}" placeholder="{{ f.placeholder }}"
autocomplete="new-password">
{% else %}
<input type="text" id="f-{{ f.name }}" name="{{ f.name }}"
value="{{ f.default }}" placeholder="{{ f.placeholder }}"
{% if f.required %}required{% endif %}>
{% endif %}
{% if f.help %}<p class="help">{{ f.help }}</p>{% endif %}
</div>
{% endfor %}
<div class="form-actions">
<button type="submit" class="btn btn-primary" id="btn-preview">👁 预览</button>
<button type="button" class="btn" id="btn-download">💾 下载 .sh</button>
<button type="button" class="btn btn-secondary" id="btn-add-bundle">📦 加入套餐</button>
</div>
</form>
</section>
<section class="preview-card">
<div class="preview-header">
<h2>📄 脚本预览</h2>
<div class="preview-actions">
<button type="button" class="btn btn-small" id="btn-copy">📋 复制</button>
<span class="muted" id="line-count">0 行</span>
</div>
</div>
<pre id="script-pre"><code>// 点击「👁 预览」生成脚本</code></pre>
<div class="copy-toast" id="copy-toast">已复制</div>
<div id="err-box" class="err-box hidden"></div>
</section>
</div>
{% if gen.post_steps or gen.verify_steps %}
<section class="after">
{% if gen.post_steps %}
<h3>🛠 安装后操作</h3>
<ol class="post-steps">
{% for s in gen.post_steps %}<li><code>{{ s }}</code></li>{% endfor %}
</ol>
{% endif %}
{% if gen.verify_steps %}
<h3>✅ 验证</h3>
<ol class="verify-steps">
{% for s in gen.verify_steps %}<li><code>{{ s }}</code></li>{% endfor %}
</ol>
{% endif %}
</section>
{% endif %}
</main>
<div class="bundle-drawer" id="bundle-drawer" hidden>
<div class="bundle-header">
<h3>📦 套餐 (multi-script bundle)</h3>
<span id="bundle-count" class="muted">0 项</span>
<button class="btn btn-small" id="btn-bundle-clear">清空</button>
<button class="btn btn-small btn-primary" id="btn-bundle-download">下载 .zip</button>
<button class="btn btn-small" id="btn-bundle-close"></button>
</div>
<ul id="bundle-list" class="bundle-list"></ul>
</div>
<script>
const GEN_ID = {{ gen.id | tojson }};
const BUNDLE_KEY = "shell-gen-bundle";
function collectForm() {
const form = document.getElementById("gen-form");
const fd = new FormData(form);
const params = {};
for (const [k, v] of fd.entries()) {
if (v !== "" || document.querySelector(`[name="${k}"]`).type === "checkbox") {
params[k] = v;
}
}
// unchecked checkboxes won't be in fd
for (const el of form.querySelectorAll('input[type="checkbox"]')) {
params[el.name] = el.checked ? "yes" : "no";
}
return params;
}
async function preview() {
const params = collectForm();
const r = await fetch("/api/preview", {
method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify({generator: GEN_ID, params})
});
const d = await r.json();
const pre = document.getElementById("script-pre");
const err = document.getElementById("err-box");
if (!d.ok) {
err.textContent = d.error;
err.classList.remove("hidden");
pre.textContent = "// 错误: " + d.error;
return;
}
err.classList.add("hidden");
pre.innerHTML = "<code>" + escapeHtml(d.script) + "</code>";
document.getElementById("line-count").textContent = d.script.split("\n").length + " 行";
}
document.getElementById("gen-form").addEventListener("submit", e => { e.preventDefault(); preview(); });
document.getElementById("btn-download").addEventListener("click", async () => {
const params = collectForm();
const r = await fetch("/api/download", {
method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify({generator: GEN_ID, params})
});
if (!r.ok) { alert("下载失败"); return; }
const blob = await r.blob();
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = GEN_ID + ".sh";
a.click();
URL.revokeObjectURL(a.href);
});
document.getElementById("btn-copy").addEventListener("click", () => {
const txt = document.getElementById("script-pre").innerText;
navigator.clipboard.writeText(txt).then(() => {
const t = document.getElementById("copy-toast");
t.classList.add("show");
setTimeout(() => t.classList.remove("show"), 1500);
});
});
// Bundle drawer
function loadBundle() {
try { return JSON.parse(localStorage.getItem(BUNDLE_KEY) || "[]"); } catch { return []; }
}
function saveBundle(b) { localStorage.setItem(BUNDLE_KEY, JSON.stringify(b)); renderBundle(); }
function renderBundle() {
const list = loadBundle();
document.getElementById("bundle-count").textContent = list.length + " 项";
const ul = document.getElementById("bundle-list");
if (list.length === 0) {
ul.innerHTML = '<li class="empty">还没有添加任何生成器,点击「📦 加入套餐」加入。</li>';
return;
}
ul.innerHTML = list.map((it, i) => `
<li>
<span class="b-id">${escapeHtml(it.generator)}</span>
<span class="b-title">${escapeHtml(it.title || '')}</span>
<button class="btn btn-small" data-rm="${i}">移除</button>
</li>
`).join("");
ul.querySelectorAll("[data-rm]").forEach(b => {
b.addEventListener("click", () => {
const idx = parseInt(b.dataset.rm, 10);
const items = loadBundle();
items.splice(idx, 1);
saveBundle(items);
});
});
}
document.getElementById("btn-add-bundle").addEventListener("click", () => {
const items = loadBundle();
if (items.some(it => it.generator === GEN_ID)) return alert("已在套餐中");
const titleMatch = document.querySelector(".gen-header h1")?.textContent || GEN_ID;
items.push({generator: GEN_ID, params: collectForm(), title: titleMatch});
saveBundle(items);
const drawer = document.getElementById("bundle-drawer");
drawer.hidden = false;
});
document.getElementById("btn-bundle-close").addEventListener("click", () => document.getElementById("bundle-drawer").hidden = true);
document.getElementById("btn-bundle-clear").addEventListener("click", () => saveBundle([]));
document.getElementById("btn-bundle-download").addEventListener("click", async () => {
const items = loadBundle();
if (items.length === 0) return alert("套餐为空");
const r = await fetch("/api/bundle", {
method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify({items})
});
if (!r.ok) return alert("打包失败");
const blob = await r.blob();
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "shell-gen-bundle.zip";
a.click();
URL.revokeObjectURL(a.href);
});
renderBundle();
// initial preview
preview();
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({
'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
}[c]));
}
</script>
{% endblock %}
+72
View File
@@ -0,0 +1,72 @@
{% extends "base.html" %}
{% block title %}Shell-Gen · 首页{% endblock %}
{% block body %}
<main class="container">
<section class="hero">
<h1>选择需要部署的服务</h1>
<p class="lead">填写参数,实时生成可立即执行的 bash 脚本。所有脚本会先做 distro 检测
(Ubuntu/Debian/CentOS/RHEL/Rocky/Alma) 再安装,不会破坏现有服务。</p>
<div class="stats">
<div class="stat"><div class="num">{{ total }}</div><div class="lbl">生成器</div></div>
{% for cat in all_cats %}
{% set n = all_cats|length %}
{% endfor %}
<div class="stat"><div class="num">{{ categories|length }}</div><div class="lbl">分类</div></div>
<div class="stat"><div class="num">100%</div><div class="lbl">开源</div></div>
</div>
</section>
{% for cat in categories %}
<section class="cat-section" id="cat-{{ cat }}">
<h2><span class="cat-icon">{{ category_icons.get(cat, '📦') }}</span> {{ category_labels.get(cat, cat) }}</h2>
<div class="grid">
{% 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 %}
</div>
<div class="grid" data-cat="{{ cat }}">
{# Render in JS to keep templating simple #}
</div>
</section>
{% endfor %}
<section class="cat-section">
<h2>📚 全部生成器</h2>
<div class="grid" id="all-grid"></div>
</section>
</main>
<script>
fetch("/api/categories").then(r => r.json()).then(d => {
// populate per-category sections
for (const cat of d.categories) {
const target = document.querySelector(`#cat-${cat.id} .grid[data-cat]`);
if (target) target.innerHTML = cat.items.map(renderCard).join('');
}
// populate "全部生成器"
const all = d.categories.flatMap(c => c.items);
document.getElementById("all-grid").innerHTML = all.map(renderCard).join('');
});
function renderCard(g) {
const tags = (g.tags || []).slice(0, 4).map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('');
const os = (g.os || []).slice(0, 3).map(t => `<span class="os">${escapeHtml(t)}</span>`).join('');
return `
<a class="card" href="/gen/${g.id}">
<div class="card-icon">${g.icon || '📦'}</div>
<div class="card-title">${escapeHtml(g.title)}</div>
<div class="card-desc">${escapeHtml(g.description)}</div>
<div class="card-meta">
<div class="tags">${tags}</div>
<div class="oss">${os}</div>
</div>
</a>`;
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({
'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'
}[c]));
}
</script>
{% endblock %}