From 95dfe2cba21232c13db250a1bf25cfd4a3690ecf Mon Sep 17 00:00:00 2001 From: zhangyang Date: Thu, 14 May 2026 21:01:48 +0000 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20install.sh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- install.sh | 7512 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 7512 insertions(+) create mode 100644 install.sh diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..e185ecf --- /dev/null +++ b/install.sh @@ -0,0 +1,7512 @@ +#!/bin/bash +# ================================================================ +# 作者 : 鸡公头面板 jigongtou.uepopo.com +# 项目地址 : https://jigongtou.uepopo.com +# 全局帮助:https://claude.ai/ +# ================================================================ + +# --- 全局函数与配置 --- + +# ================================================================ +# ⚙️ 统一配置区 — 端口/镜像/路径全部在此修改,无需搜索全文 +# ================================================================ + +# ── 数据目录 ──────────────────────────────────────────────────── +readonly DATA_ROOT="/root" +readonly MNT_ROOT="/mnt" + +# ── 服务端口(宿主机端口,容器内端口不变)────────────────────── +readonly PORT_NEXTCLOUD=8888 +readonly PORT_ONLYOFFICE=8889 +readonly PORT_WORDPRESS=8890 +readonly PORT_OLLAMA_UI=3001 +readonly PORT_JELLYFIN=8096 +readonly PORT_NAVIDROME=4533 +readonly PORT_MINIFLUX=8091 +readonly PORT_GITEA=3000 +readonly PORT_GITEA_SSH=2222 +readonly PORT_MEMOS=8080 +readonly PORT_QBIT=6881 +readonly PORT_JDOWNLOADER=5800 +readonly PORT_METUBE=8999 +readonly PORT_DRAWIO=8082 +readonly PORT_N8N=5678 +readonly PORT_UPTIME_KUMA=3002 +readonly PORT_HA=8123 +readonly PORT_SILLYTAVERN=8000 +readonly PORT_GLANCES=61208 +readonly PORT_VLESS=40001 +readonly PORT_IMMICH=2283 +readonly PORT_CALIBRE=8083 +readonly PORT_KAVITA=5000 +readonly PORT_GOTIFY=8085 +readonly PORT_ACTUAL=5006 +readonly PORT_EXCALIDRAW=8086 +readonly PORT_GRAFANA=3003 +readonly PORT_JOPLIN=22300 +readonly PORT_WEBDAV=6086 +readonly PORT_TRILIUM=8087 + +# ── Docker 镜像版本(需要锁定版本时在此修改)────────────────── +readonly IMG_NEXTCLOUD="nextcloud:latest" +readonly IMG_MARIADB="mariadb:11.4" +readonly IMG_REDIS="redis:alpine" +readonly IMG_ONLYOFFICE="onlyoffice/documentserver:latest" +readonly IMG_WORDPRESS="wordpress:latest" +readonly IMG_OLLAMA="ollama/ollama:latest" +readonly IMG_OPEN_WEBUI="ghcr.io/open-webui/open-webui:main" +readonly IMG_JELLYFIN="jellyfin/jellyfin:latest" +readonly IMG_NAVIDROME="deluan/navidrome:latest" +readonly IMG_MINIFLUX="miniflux/miniflux:latest" +readonly IMG_POSTGRES="postgres:15-alpine" +readonly IMG_GITEA="gitea/gitea:latest" +readonly IMG_MEMOS="ghcr.io/usememos/memos:latest" +readonly IMG_QBIT="lscr.io/linuxserver/qbittorrent:latest" +readonly IMG_JDOWNLOADER="jlesage/jdownloader-2" +readonly IMG_METUBE="ghcr.io/alexta69/metube:latest" +readonly IMG_DRAWIO="jgraph/drawio" +readonly IMG_N8N="n8nio/n8n:latest" +readonly IMG_UPTIME_KUMA="louislam/uptime-kuma:1" +readonly IMG_HA="ghcr.io/home-assistant/home-assistant:stable" +readonly IMG_SILLYTAVERN="ghcr.io/sillytavern/sillytavern:latest" +readonly IMG_GLANCES="nicolargo/glances:latest-full" +readonly IMG_XRAY="teddysun/xray:latest" +readonly IMG_IMMICH_SERVER="ghcr.io/immich-app/immich-server:release" +readonly IMG_IMMICH_ML="ghcr.io/immich-app/immich-machine-learning:release" +readonly IMG_CALIBRE="lscr.io/linuxserver/calibre-web:latest" +readonly IMG_KAVITA="jvmilazz0/kavita:latest" +readonly IMG_GOTIFY="gotify/server:latest" +readonly IMG_ACTUAL="actualbudget/actual-server:latest" +readonly IMG_EXCALIDRAW="excalidraw/excalidraw:latest" +readonly IMG_GRAFANA="grafana/grafana:latest" +readonly IMG_PROMETHEUS="prom/prometheus:latest" +readonly IMG_NODE_EXPORTER="prom/node-exporter:latest" +readonly IMG_CADVISOR="gcr.io/cadvisor/cadvisor:latest" +readonly IMG_JOPLIN_SERVER="joplin/server:latest" +readonly IMG_JOPLIN_DB="postgres:15-alpine" +readonly IMG_TRILIUM="zadam/trilium:latest" + +# ── 时区 ────────────────────────────────────────────────────── +readonly TZ_DEFAULT="Asia/Shanghai" + +# ================================================================ + +STATE_FILE="/root/.vps_setup_credentials" # 用于存储密码和配置的凭证文件 +TUNNEL_CONFIG_FILE="/root/.cloudflared/config.yml" +RCLONE_CONFIG_FILE="/root/.config/rclone/rclone.conf" +RCLONE_LOG_FILE="/var/log/rclone.log" + +# --- 环境检测全局变量 (由 detect_environment 填充) --- +SYS_ARCH="" # amd64 / arm64 / armv7 +NET_MODE="" # dual / ipv4only / ipv6only +SYS_DISTRO="" # ubuntu / debian +SYS_CODENAME="" # focal / jammy / bookworm 等 +IS_IPV6_ONLY=false # 纯IPv6环境快速判断标志 + + +# ================================================================ +# --- 环境自动检测 (网络模式 / 系统发行版 / CPU架构) --- +# ================================================================ +detect_environment() { + # --- 检测 CPU 架构 --- + local raw_arch + raw_arch=$(dpkg --print-architecture 2>/dev/null || uname -m) + case "$raw_arch" in + amd64|x86_64) SYS_ARCH="amd64" ;; + arm64|aarch64) SYS_ARCH="arm64" ;; + armhf|armv7*) SYS_ARCH="armhf" ;; + *) SYS_ARCH="$raw_arch" ;; + esac + + # --- 检测发行版与代号 --- + if [ -f /etc/os-release ]; then + SYS_DISTRO=$(grep ^ID= /etc/os-release | cut -d= -f2 | tr -d '"' | tr '[:upper:]' '[:lower:]') + SYS_CODENAME=$(grep ^VERSION_CODENAME= /etc/os-release | cut -d= -f2 | tr -d '"') + # 兜底:部分精简镜像没有 VERSION_CODENAME + if [ -z "$SYS_CODENAME" ]; then + SYS_CODENAME=$(lsb_release -cs 2>/dev/null || echo "unknown") + fi + fi + + # --- 检测网络模式 --- + # 用超短超时避免卡顿,测试点选 Cloudflare (对IPv4/IPv6都友好) + local has_ipv4=false + local has_ipv6=false + + if curl -4 -s --max-time 5 --connect-timeout 4 https://1.1.1.1/cdn-cgi/trace -o /dev/null 2>/dev/null; then + has_ipv4=true + fi + if curl -6 -s --max-time 5 --connect-timeout 4 https://[2606:4700:4700::1111]/cdn-cgi/trace -o /dev/null 2>/dev/null; then + has_ipv6=true + fi + + if $has_ipv4 && $has_ipv6; then + NET_MODE="dual" + IS_IPV6_ONLY=false + elif $has_ipv6 && ! $has_ipv4; then + NET_MODE="ipv6only" + IS_IPV6_ONLY=true + elif $has_ipv4 && ! $has_ipv6; then + NET_MODE="ipv4only" + IS_IPV6_ONLY=false + else + # 两个都探测失败,保守当作IPv4处理,避免误判 + NET_MODE="ipv4only" + IS_IPV6_ONLY=false + fi +} + +# ================================================================ +# --- 纯IPv6环境:替换apt源为支持IPv6的镜像 --- +# ================================================================ +setup_ipv6_apt_sources() { + # 已经替换过就跳过 + if grep -q "mirrors.ustc.edu.cn\|mirrors.tuna.tsinghua.edu.cn" /etc/apt/sources.list 2>/dev/null; then + return 0 + fi + + echo -e "${YELLOW}[IPv6模式] 正在将 apt 源替换为支持 IPv6 的 USTC 镜像...${NC}" + + # 备份原始sources.list + sudo cp /etc/apt/sources.list /etc/apt/sources.list.bak.$(date +%Y%m%d) + + if [ "$SYS_DISTRO" = "ubuntu" ]; then + sudo tee /etc/apt/sources.list > /dev/null << EOF +deb https://mirrors.ustc.edu.cn/ubuntu/ ${SYS_CODENAME} main restricted universe multiverse +deb https://mirrors.ustc.edu.cn/ubuntu/ ${SYS_CODENAME}-updates main restricted universe multiverse +deb https://mirrors.ustc.edu.cn/ubuntu/ ${SYS_CODENAME}-backports main restricted universe multiverse +deb https://mirrors.ustc.edu.cn/ubuntu/ ${SYS_CODENAME}-security main restricted universe multiverse +EOF + elif [ "$SYS_DISTRO" = "debian" ]; then + sudo tee /etc/apt/sources.list > /dev/null << EOF +deb https://mirrors.ustc.edu.cn/debian/ ${SYS_CODENAME} main contrib non-free non-free-firmware +deb https://mirrors.ustc.edu.cn/debian/ ${SYS_CODENAME}-updates main contrib non-free non-free-firmware +deb https://mirrors.ustc.edu.cn/debian/ ${SYS_CODENAME}-backports main contrib non-free non-free-firmware +deb https://mirrors.ustc.edu.cn/debian-security/ ${SYS_CODENAME}-security main contrib non-free non-free-firmware +EOF + fi + + echo -e "${GREEN}✅ apt 源已切换至 USTC IPv6 镜像 (原始备份于 sources.list.bak.$(date +%Y%m%d))${NC}" +} + +# ================================================================ +# --- 纯IPv6环境:下载 cloudflared 二进制 --- +# ================================================================ +install_cloudflared_ipv6() { + echo -e "${YELLOW}[IPv6模式] 通过直链下载 cloudflared 二进制 (架构: ${SYS_ARCH})...${NC}" + + # Cloudflare 官方 release 链接,cloudflare.com 本身支持 IPv6 + local CF_BASE="https://github.com/cloudflare/cloudflared/releases/latest/download" + local BIN_NAME="" + + case "$SYS_ARCH" in + amd64) BIN_NAME="cloudflared-linux-amd64" ;; + arm64) BIN_NAME="cloudflared-linux-arm64" ;; + armhf) BIN_NAME="cloudflared-linux-arm" ;; + *) + echo -e "${RED}❌ 不支持的架构: ${SYS_ARCH},无法自动下载 cloudflared。${NC}" + return 1 ;; + esac + + # GitHub 不直接支持IPv6,使用 ghproxy 中转(国内IPv6友好的代理) + # 备用:尝试多个镜像 + local DOWNLOAD_SUCCESS=false + local MIRRORS=( + "https://gh.con.sh/${CF_BASE}/${BIN_NAME}" + "https://ghproxy.net/${CF_BASE}/${BIN_NAME}" + "https://mirror.ghproxy.com/${CF_BASE}/${BIN_NAME}" + "${CF_BASE}/${BIN_NAME}" + ) + + for mirror_url in "${MIRRORS[@]}"; do + echo -e "${DIM} 尝试下载源: ${mirror_url}${NC}" + if curl -6L --max-time 60 --connect-timeout 10 -o /tmp/cloudflared_bin "$mirror_url" 2>/dev/null; then + # 验证下载的是真实二进制而非HTML错误页 + if file /tmp/cloudflared_bin 2>/dev/null | grep -qiE "ELF|executable"; then + DOWNLOAD_SUCCESS=true + break + fi + fi + done + + if ! $DOWNLOAD_SUCCESS; then + echo -e "${RED}❌ cloudflared 下载失败!所有镜像均不可用。${NC}" + echo -e "${YELLOW}请手动下载后放到 /usr/local/bin/cloudflared 并赋予执行权限。${NC}" + return 1 + fi + + sudo mv /tmp/cloudflared_bin /usr/local/bin/cloudflared + sudo chmod +x /usr/local/bin/cloudflared + + echo -e "${GREEN}✅ cloudflared 安装完毕!(版本: $(cloudflared --version 2>/dev/null | head -1))${NC}" + return 0 +} + +# ================================================================ +# --- 纯IPv6环境:安装 Docker --- +# ================================================================ +install_docker_ipv6() { + echo -e "${YELLOW}[IPv6模式] 使用 USTC 镜像安装 Docker...${NC}" + + sudo apt-get update + sudo apt-get install -y ca-certificates curl gnupg + + # 添加 Docker GPG key (走USTC镜像,支持IPv6) + sudo install -m 0755 -d /etc/apt/keyrings + + if [ "$SYS_DISTRO" = "ubuntu" ]; then + curl -6fsSL "https://mirrors.ustc.edu.cn/docker-ce/linux/ubuntu/gpg" \ + | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg 2>/dev/null + echo "deb [arch=${SYS_ARCH} signed-by=/etc/apt/keyrings/docker.gpg] \ +https://mirrors.ustc.edu.cn/docker-ce/linux/ubuntu ${SYS_CODENAME} stable" \ + | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null + elif [ "$SYS_DISTRO" = "debian" ]; then + curl -6fsSL "https://mirrors.ustc.edu.cn/docker-ce/linux/debian/gpg" \ + | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg 2>/dev/null + echo "deb [arch=${SYS_ARCH} signed-by=/etc/apt/keyrings/docker.gpg] \ +https://mirrors.ustc.edu.cn/docker-ce/linux/debian ${SYS_CODENAME} stable" \ + | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null + fi + + sudo apt-get update + sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin + + sudo systemctl enable docker + sudo systemctl start docker + + if command -v docker &>/dev/null && docker compose version &>/dev/null; then + echo -e "${GREEN}✅ Docker 安装成功!(版本: $(docker --version))${NC}" + return 0 + else + echo -e "${RED}❌ Docker 安装失败,请检查上方日志。${NC}" + return 1 + fi +} + + +# --- 颜色定义 --- +# 配色方案:骚气黄绿 + 白色 + 灰色,去掉蓝色/紫色 + +# ================================================================ +# 🛠️ 通用工具函数 +# ================================================================ + +# 统一日志输出 +_log_info() { echo -e "${GREEN}[INFO]${NC} $*"; } +_log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +_log_error() { echo -e "${RED}[ERROR]${NC} $*"; } +_log_step() { echo -e "${CYAN}[STEP]${NC} $*"; } + +# 通用 Docker Compose 启动函数 +# 用法: _compose_up <服务名> <数据目录> +# 失败时打印日志尾部,方便诊断 +_compose_up() { + local svc_name="$1" + local data_dir="$2" + _log_step "正在启动 ${svc_name}..." + if (cd "${data_dir}" && sudo docker compose up -d 2>&1); then + _log_info "${svc_name} 容器已启动" + return 0 + else + _log_error "${svc_name} 启动失败!最近日志:" + (cd "${data_dir}" && sudo docker compose logs --tail 20 2>/dev/null) || true + _log_warn "可手动排查: cd ${data_dir} && docker compose logs" + return 1 + fi +} + +# 通用安装前置检查(已安装则提示并返回) +# 用法: _check_not_installed <标志路径> <服务名> +_check_not_installed() { + local flag_path="$1" + local svc_name="$2" + if [ -d "${flag_path}" ] || [ -f "${flag_path}" ]; then + _log_warn "${svc_name} 已安装,跳过。如需重装请先从卸载中心删除。" + sleep 2; return 1 + fi + return 0 +} + +# 生成随机密码 +_gen_password() { + local len="${1:-16}" + head /dev/urandom | tr -dc A-Za-z0-9 | head -c "${len}" +} + +# 保存凭证到 STATE_FILE +_save_credential() { + local key="$1" val="$2" + sed -i.bak "/^${key}=/d" "${STATE_FILE}" 2>/dev/null + echo "${key}=${val}" >> "${STATE_FILE}" + rm -f "${STATE_FILE}.bak" +} + +# ================================================================ +setup_colors() { + if [[ -t 1 ]]; + then + GREEN='\033[1;32m' # 亮绿(骚气色主色) + YELLOW='\033[1;33m' # 亮黄(骚气色副色) + CYAN='\033[1;32m' # 原青色 → 改为亮绿 + BLUE='\033[1;33m' # 原蓝色 → 改为亮黄 + PURPLE='\033[1;32m' # 原紫色 → 改为亮绿 + RED='\033[0;31m' # 红色保留(报错用) + WHITE='\033[1;37m' # 白色 + DIM='\033[2;37m' # 灰色(暗淡白) + NC='\033[0m' + else + GREEN='' + YELLOW='' + CYAN='' + BLUE='' + PURPLE='' + RED='' + WHITE='' + DIM='' + NC='' + fi +} + +# --- 美化对齐输出函数 (解决中文宽度问题 - 终极兼容版) --- +# --- 统一菜单行输出函数 --- +# $1=编号(不含括号) $2=文字 $3=状态字符串 $4=编号颜色(可选) +print_row() { + local num="$1" + local text="$2" + local status="$3" + local num_color="${4:-$YELLOW}" + + # 编号+括号,固定7字符(左对齐) + local num_field + printf -v num_field "%-7s" "${num})" + + # 计算文字视觉宽度(正确处理中文/emoji双宽字符) + local visual_text_width + visual_text_width=$(echo -n "$text" | perl -CS -Mutf8 -nlE ' + use Encode; + my $w = 0; + for my $c (split //, $_) { + my $b = length(Encode::encode("utf8", $c)); + $w += ($b >= 3 ? 2 : 1); + } + print $w; + ') + + # 文字列目标宽度42,右侧对齐状态栏 + local text_target=42 + local text_pad=$((text_target - visual_text_width)) + [ $text_pad -lt 1 ] && text_pad=1 + + printf " ${num_color}%s${NC}%s" "$num_field" "$text" + printf "%*s" $text_pad "" + echo -e "$status" +} + +print_menu_item() { + local text="$1" + local status="$2" + + # 先剥离 ANSI 转义序列,再计算视觉宽度(解决颜色代码被误计入宽度的问题) + local plain_text + plain_text=$(echo -e "$text" | sed 's/\x1b\[[0-9;]*m//g') + + local visual_width=$(echo -n "$plain_text" | perl -CS -Mutf8 -nlE ' + use Encode; + my $width = 0; + for my $char (split //, $_) { + my $b = length(Encode::encode("utf8", $char)); + $width += ($b >= 3 ? 2 : 1); + } + print $width; + ') + + local target_column=62 + local padding_needed=$((target_column - visual_width)) + if [ $padding_needed -lt 1 ]; then + padding_needed=1 + fi + echo -e -n "${text}" + printf "%*s" $padding_needed "" + echo -e "${status}" +} + +# --- 首次运行自安装快捷命令 --- +setup_shortcut() { + local install_path="/usr/local/bin/jigongtou" + local script_path + script_path=$(realpath "${BASH_SOURCE[0]}" 2>/dev/null) + + # 判断当前脚本是否已经就是安装位置本身,是则无需操作 + if [[ "$script_path" == "$install_path" ]]; then + return 0 + fi + + # 检查已安装版本是否完整有效 + if [[ -f "$install_path" && -x "$install_path" ]]; then + return 0 + fi + + echo -e "${GREEN}为方便您使用,正在创建快捷命令 'jigongtou'...${NC}" + + # 优先:当前脚本有实体路径,直接复制过去 + if [[ -n "$script_path" && -f "$script_path" && \ + "$script_path" != "/bin/bash" && "$script_path" != "/usr/bin/bash" ]]; then + sudo cp -f "${script_path}" "${install_path}" + else + # 兜底:管道/source 执行时 BASH_SOURCE 无效,把自身内容写到目标路径 + # ($0 也可能是 bash,用子 shell 读 /proc/self/fd/255 尝试获取) + local pipe_src + pipe_src=$(readlink /proc/$$/fd/255 2>/dev/null) + if [[ -n "$pipe_src" && -f "$pipe_src" ]]; then + sudo cp -f "${pipe_src}" "${install_path}" + else + echo -e "${YELLOW} ⚠️ 无法自动创建快捷命令(管道执行模式)。${NC}" + echo -e "${YELLOW} 请将脚本保存到本地后运行:bash 鸡公头-正式版.sh${NC}" + echo -e "${YELLOW} 之后即可直接输入 jigongtou 调出面板。${NC}" + sleep 4 + return 0 + fi + fi + + if sudo chmod +x "${install_path}" && [[ -x "${install_path}" ]]; then + echo -e "${GREEN} ✅ 快捷命令创建成功!现在起随时输入 'jigongtou' 即可启动此面板。${NC}" + else + echo -e "${RED} ❌ 快捷命令创建失败,请手动执行:sudo cp 鸡公头-正式版.sh /usr/local/bin/jigongtou && sudo chmod +x /usr/local/bin/jigongtou${NC}" + fi + sleep 3 +} + +# --- 核心依赖检查函数 --- +check_core_dependencies() { + if ! perl -e 'use Encode;' 2>/dev/null; then + echo -e "${YELLOW}--- 检测到核心组件 Perl::Encode 缺失,正在自动安装 ---${NC}" + echo -e "${YELLOW}这是确保菜单正确对齐所必需的。${NC}" + sleep 2 + if $IS_IPV6_ONLY; then setup_ipv6_apt_sources; fi + sudo apt-get update >/dev/null 2>&1 + sudo apt-get install -y libencode-perl + if ! perl -e 'use Encode;' 2>/dev/null; then + echo -e "${RED}错误:核心组件自动安装失败。${NC}" + echo -e "${RED}请手动执行 'sudo apt-get install libencode-perl' 后重试。${NC}" + exit 1 + fi + echo -e "${GREEN}✅ 核心组件已成功安装!脚本将继续运行。${NC}" + sleep 2 + fi +} + +# --- 核心环境检查函数 --- +ensure_docker_installed() { + if ! command -v docker &> /dev/null || ! docker compose version &> /dev/null; then + echo -e "${YELLOW}--- 检查到 Docker 或 Docker Compose 插件未安装,现在开始自动安装 ---${NC}" + sleep 2 + + if ! command -v docker &> /dev/null; then + if $IS_IPV6_ONLY; then + # 纯IPv6环境:先换源,再用USTC镜像安装Docker + setup_ipv6_apt_sources + install_docker_ipv6 + else + # 正常IPv4/双栈环境:使用官方脚本 + sudo apt-get update + sudo apt-get install -y ca-certificates curl gnupg + echo -e "${YELLOW}正在安装 Docker Engine (包含 Compose 插件)...${NC}" + curl -fsSL https://get.docker.com -o get-docker.sh + sudo sh get-docker.sh && rm get-docker.sh + sudo systemctl restart docker + sudo systemctl enable docker + fi + fi + + if ! docker compose version &> /dev/null; then + echo -e "${YELLOW}Docker Compose 插件未找到, 正在尝试补充安装...${NC}" + sudo apt-get update + sudo apt-get install -y docker-compose-plugin + fi + + if ! command -v docker &> /dev/null || ! docker compose version &> /dev/null; then + echo -e "${RED}错误:Docker 环境自动安装失败,请检查网络或手动安装后重试。${NC}" + sleep 5 + return 1 + else + echo -e "${GREEN}✅ Docker 环境已成功安装并准备就绪!${NC}" + sleep 2 + fi + fi + + if ! sudo docker info >/dev/null 2>&1; then + echo -e "${YELLOW}检测到 Docker 服务未运行,正在尝试启动...${NC}" + sudo systemctl start docker + sleep 3 + if ! sudo docker info >/dev/null 2>&1; then + echo -e "${RED}错误:无法启动 Docker 服务!请手动检查 'sudo systemctl status docker'。${NC}" + return 1 + fi + echo -e "${GREEN}✅ Docker 服务已成功启动!${NC}" + fi + return 0 +} + +# --- 系统基础功能函数 --- +update_system() { + clear + echo -e "${BLUE}--- 更新系统与软件 (APT 更新) ---${NC}" + echo -e "${YELLOW}即将开始更新系统软件包列表并升级所有已安装的软件 ...${NC}" + if $IS_IPV6_ONLY; then setup_ipv6_apt_sources; fi + sudo apt-get update && sudo apt-get upgrade -y + echo -e "\n${GREEN}✅ 系统更新完成!${NC}" + echo -e "\n${GREEN}按任意键返回主菜单 ...${NC}"; read -n 1 -s +} + +run_unminimize() { + clear + echo -e "${BLUE}--- 恢复至标准系统 (unminimize) ---${NC}" + if grep -q -i "ubuntu" /etc/os-release; then + echo -e "${YELLOW}此操作将为您的最小化 Ubuntu 系统安装完整的标准系统包。${NC}" + echo -e "${YELLOW}它会增加一些磁盘占用,但可以解决某些软件的兼容性问题。${NC}" + read -p "您确定要继续吗? (y/n): " confirm + if [[ "$confirm" == "y" || "$confirm" == "Y" ]]; then + echo -e "${GREEN}正在执行 unminimize ,请稍候 ...${NC}" + sudo unminimize + echo -e "\n${GREEN}✅ 操作完成!${NC}" + else + echo -e "${GREEN}操作已取消。${NC}" + fi + else + echo -e "${RED}此功能专为 Ubuntu 系统设计,您当前的系统似乎不是 Ubuntu 。${NC}" + fi + echo -e "\n${GREEN}按任意键返回主菜单 ...${NC}"; read -n 1 -s +} + +manage_swap() { + clear + echo -e "${BLUE}--- 配置虚拟内存 (Swap) ---${NC}" + local swap_active=false + local swapfile_exists=false + swapon --show --noheadings 2>/dev/null | grep -q '/swapfile' && swap_active=true + [ -f "/swapfile" ] && swapfile_exists=true + + if $swap_active; then + local swap_size_cur + swap_size_cur=$(swapon --show --noheadings --bytes 2>/dev/null | awk '/swapfile/{printf "%.0fG", $3/1024/1024/1024}') + echo -e "${GREEN}检测到虚拟内存已启用:/swapfile(${swap_size_cur})${NC}" + free -h + echo "" + read -p "您想移除现有的虚拟内存吗? (y/n): " confirm_remove + if [[ "$confirm_remove" == "y" || "$confirm_remove" == "Y" ]]; then + echo -e "${YELLOW}正在停止并移除虚拟内存...${NC}" + sudo swapoff /swapfile + sudo sed -i '/\/swapfile/d' /etc/fstab + sudo rm -f /swapfile + echo -e "${GREEN}✅ 虚拟内存已成功移除!${NC}" + free -h + else + echo -e "${GREEN}操作已取消,虚拟内存保持不变。${NC}" + fi + elif $swapfile_exists; then + echo -e "${YELLOW}检测到 /swapfile 文件存在但未挂载。${NC}" + echo -e " 1) 重新启用它" + echo -e " 2) 删除并重新创建" + echo -e " 3) 取消" + read -p "请选择 (1/2/3): " choice + case "$choice" in + 1) + sudo swapon /swapfile + if ! grep -q "/swapfile" /etc/fstab; then + echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab + fi + echo -e "${GREEN}✅ 虚拟内存已重新启用!${NC}"; free -h ;; + 2) + sudo rm -f /swapfile + sudo sed -i '/\/swapfile/d' /etc/fstab + read -p "请输入新的 Swap 大小 (例如: 4G, 8G): " swap_size + [ -z "$swap_size" ] && { echo -e "${RED}输入为空,取消。${NC}"; sleep 2 + echo -e "\n${GREEN}按任意键返回主菜单 ...${NC}"; read -n 1 -s; return; } + sudo fallocate -l ${swap_size} /swapfile + sudo chmod 600 /swapfile; sudo mkswap /swapfile; sudo swapon /swapfile + echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab + echo -e "${GREEN}✅ 虚拟内存已重新创建!${NC}"; free -h ;; + *) echo -e "${GREEN}操作已取消。${NC}" ;; + esac + else + echo -e "${YELLOW}未检测到虚拟内存,现在为您创建。${NC}" + read -p "请输入 Swap 大小 (例如: 4G, 8G, 10G) [建议为内存的1-2倍]: " swap_size + if [ -z "$swap_size" ]; then + echo -e "${RED}输入为空,操作取消。${NC}"; sleep 2; return + fi + sudo fallocate -l ${swap_size} /swapfile + sudo chmod 600 /swapfile; sudo mkswap /swapfile; sudo swapon /swapfile + if ! grep -q "/swapfile" /etc/fstab; then + echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab + fi + echo -e "\n${GREEN}✅ 虚拟内存创建并启用成功!${NC}" + free -h + fi + echo -e "\n${GREEN}按任意键返回主菜单 ...${NC}"; read -n 1 -s +} + +# --- 检查与菜单显示函数 --- +check_and_display() { + local option_num="$1" + local text="$2" + local check_type="$3" + local check_path="$4" + local status_string="[ ${RED}❌ 未安装${NC} ]" + + # SSHFS 专项检测:看实际挂载数量,而非工具是否存在 + if [[ "$text" == *"SSHFS"* || "$text" == *"远程VPS"* || "$text" == *"挂载远程"* ]]; then + local mount_count + mount_count=$(mount 2>/dev/null | grep -cE "fuse\.sshfs|type fuse[^.]" 2>/dev/null || true) + mount_count=${mount_count:-0} + if [ "$mount_count" -gt 0 ]; then + status_string="[ ${GREEN}✅ 已挂载 ${mount_count} 个${NC} ]" + elif command -v sshfs &>/dev/null; then + status_string="[ ${YELLOW}⚠️ 已安装/无挂载${NC} ]" + fi + print_row "$option_num" "$text" "$status_string" + return + fi + + local is_installed=false + case "$check_type" in + "dir") + [ -d "$check_path" ] && is_installed=true + ;; + "file") + [ -f "$check_path" ] && is_installed=true + ;; + "service") + if systemctl is-active --quiet "$check_path" 2>/dev/null; then + is_installed=true + elif systemctl list-unit-files 2>/dev/null | grep -q "^${check_path}.service"; then + status_string="[ ${YELLOW}⚠️ 已安装/未运行${NC} ]" + print_row "$option_num" "$text" "$status_string" + return + fi + ;; + "docker") + if docker inspect "$check_path" &>/dev/null 2>&1; then + if docker inspect --format='{{.State.Running}}' "$check_path" 2>/dev/null | grep -q "true"; then + is_installed=true + else + status_string="[ ${YELLOW}⚠️ 已安装/未运行${NC} ]" + print_row "$option_num" "$text" "$status_string" + return + fi + fi + ;; + "cmd") + command -v "$check_path" &>/dev/null && is_installed=true + ;; + "ai_model") + # 检测 AI 大脑是否已安装且 ollama 容器内有模型 + if [ -d "$check_path" ] && docker inspect ollama &>/dev/null 2>&1; then + local model_count + model_count=$(docker exec ollama ollama list 2>/dev/null | tail -n +2 | grep -c "." 2>/dev/null || true) + model_count=${model_count:-0} + if [ "$model_count" -gt 0 ]; then + status_string="[ ${GREEN}✅ 已安装 ${model_count} 个模型${NC} ]" + else + status_string="[ ${YELLOW}⚠️ 已安装/无模型${NC} ]" + fi + print_row "$option_num" "$text" "$status_string" + return + fi + [ -d "$check_path" ] && is_installed=true + ;; + esac + + if $is_installed; then + status_string="[ ${GREEN}✅ 已安装${NC} ]" + fi + + # Rclone:直接读 rclone listremotes 显示所有已配置 remote + if [[ "$text" == *"Rclone"* && -f "$check_path" ]]; then + local remotes + remotes=$(rclone listremotes --config "${RCLONE_CONFIG_FILE}" 2>/dev/null | sed 's/:$//' | tr '\n' ',' | sed 's/,$//') + if [ -n "$remotes" ]; then + status_string="[ ${GREEN}✅ 已配置: ${remotes}${NC} ]" + else + status_string="[ ${GREEN}✅ 已安装, 未配置${NC} ]" + fi + fi + + print_row "$option_num" "$text" "$status_string" +} + +# --- 动态 LOGO 显示函数 --- +GREEN2='\033[1;32m' +display_animated_logo() { + local G="${GREEN2}" + local NC_="\033[0m" +echo -e "${G} ███████ ████████" + echo " █████████████ ███████████" + echo " ████ ████ █████ ████" + echo " ████ ███ ███ ████" + echo " █████ █████████ ███" + echo " █████ ██████ ███ ██████████" + echo " █████████████ ████ ██████████████████" + echo " █████ █████████ ███ ████████ ████" + echo " ████ █████ ███ █████ ██████" + echo " ██████ ███ ████████████ ██████" + echo " ███████ ████████████████████ ██████" + echo " ███████████████ ███████████" + echo " ███████ ██████" + echo " ████ ██████" + echo " █████ ███" + echo " █████████████████████ █████████████████████" + echo " ███████ ███████ ███████████" + echo " ███ ███████ ███ ████" + echo " ███ ███ ███ ███ ████" + echo " ████ ███ ████ ████ ████" + echo " ███████████████████████████ ████" + echo " ████████████████████ ████" + echo " ████████████████████████ ████" + echo " ████████████████████████████ ████" + echo " ██████████████████████████████ ████" + echo " ████████████████████████████████ ████" + echo " █████████████████████████████████ ████" + echo " ██████████████████████████████ ████" + echo " █████████████████████ ████" + echo " ██████████ ██████" + echo " ██████ ████████" + echo " ██████████████████████████" + echo -e " ████████████████████${NC_}" + echo "" + echo -e "${G}╔═════════════════════════════════════════════════════════════════════╗${NC_}" + echo -e "${G}║ 🐓 J I G O N G T O U ║${NC_}" + echo -e "${G}║ 鸡公头 VPS 全面部署安装脚本 ║${NC_}" + echo -e "${DIM}║ jigongtou.uepopo.com ║${NC_}" + echo -e "${G}╚═════════════════════════════════════════════════════════════════════╝${NC_}" + echo "" +} + +# --- 菜单显示 --- +show_main_menu() { + clear + display_animated_logo + + echo -e "${YELLOW}══════════════════════════════════════════════════════════════════════${NC}" + echo -e "${YELLOW} 🐓 鸡公头面板 Cloudflare Tunnel版 v7.0.9${NC}" + echo -e "${DIM} 适用于 Ubuntu/Debian · 输入 jigongtou 可随时调出本面板${NC}" + # --- 动态环境信息栏 --- + local net_label arch_label distro_label + case "$NET_MODE" in + dual) net_label="${GREEN}双栈 IPv4+IPv6${NC}" ;; + ipv4only) net_label="${WHITE}仅 IPv4${NC}" ;; + ipv6only) net_label="${YELLOW}纯 IPv6 模式 · 已启用兼容方案${NC}" ;; + *) net_label="${DIM}检测中...${NC}" ;; + esac + arch_label="${WHITE}${SYS_ARCH:-未知}${NC}" + distro_label="${WHITE}${SYS_DISTRO:-未知} ${SYS_CODENAME:-}${NC}" + echo -e "${DIM} 🌐 网络: ${net_label} ${DIM}| 💻 架构: ${arch_label} ${DIM}| 🐧 系统: ${distro_label}${NC}" + echo -e "${YELLOW}══════════════════════════════════════════════════════════════════════${NC}" + echo "" + echo -e "${GREEN} ── 地基与系统 ──────────────────────────────────────────────────────${NC}" + print_row "u" "更新系统软件包" "[ APT 更新 ]" + print_row "m" "恢复至标准系统" "[ unminimize ]" + print_row "s" "配置虚拟内存 Swap" "[ 增强低配VPS性能 ]" + echo "" + echo -e "${YELLOW} ── 基础设施 ────────────────────────────────────────────────────────${NC}" + check_and_display "1" "部署 Cloudflare Tunnel" "service" "cloudflared" + check_and_display "2" "配置 Rclone 数据同步桥" "file" "${RCLONE_CONFIG_FILE}" + check_and_display "3" "挂载远程VPS为本机网盘" "file" "/usr/bin/sshfs" + check_and_display "3.1" "将本机做成 WebDAV 网盘" "docker" "webdav_app" + check_and_display "3.2" "🗂️ 网盘矩阵联合挂载 (mergerfs)" "dir" "/mnt/union" + echo "" + echo -e "${GREEN} ── 家庭数据中心 ────────────────────────────────────────────────────${NC}" + check_and_display "4.1" "部署 Nextcloud 网盘" "dir" "/root/nextcloud_data" + check_and_display "4.2" "部署 OnlyOffice 在线文档" "dir" "/root/onlyoffice_data" + check_and_display "4.3" "部署 Home Assistant 智能家居" "dir" "/root/home_assistant_data" + check_and_display "4.4" "部署 WordPress 博客" "dir" "/root/wordpress_data" + echo "" + echo -e "${YELLOW} ── AI 与媒体 ───────────────────────────────────────────────────────${NC}" + check_and_display "5.1" "部署 AI 大脑 (Ollama + WebUI)" "dir" "/root/ai_stack" + check_and_display "5.2" "为 AI 大脑安装本地模型" "ai_model" "/root/ai_stack" + check_and_display "5.3" "部署 Jellyfin 家庭影院" "dir" "/root/jellyfin_data" + check_and_display "5.4" "部署 Navidrome 音乐服务器" "dir" "/root/navidrome_data" + check_and_display "5.5" "部署 Immich 私人相册" "dir" "/root/immich_data" + check_and_display "5.6" "部署 Calibre-Web 电子书库" "dir" "/root/calibre_web_data" + check_and_display "5.7" "部署 Kavita 漫画阅读器" "dir" "/root/kavita_data" + echo "" + echo -e "${GREEN} ── 效率工具 ────────────────────────────────────────────────────────${NC}" + check_and_display "6.1" "部署 Miniflux RSS 阅读器" "dir" "/root/miniflux_data" + check_and_display "6.2" "部署 Gitea 代码仓库" "dir" "/root/gitea_data" + check_and_display "6.3" "部署 qBittorrent 下载器" "dir" "/root/qbittorrent_data" + check_and_display "6.4" "部署 JDownloader 下载器" "dir" "/root/jdownloader_data" + check_and_display "6.5" "部署 yt-dlp 视频下载器" "dir" "/root/ytdlp_data" + check_and_display "6.6" "部署 Draw.io 绘图工具" "dir" "/root/drawio_data" + check_and_display "6.7" "部署 N8N 工作流自动化" "dir" "/root/n8n_data" + check_and_display "6.8" "部署 Gotify 消息推送" "docker" "gotify_app" + check_and_display "6.9" "部署 Actual Budget 记账" "dir" "/root/actual_budget_data" + check_and_display "6.10" "部署 Excalidraw 在线白板" "dir" "/root/excalidraw_data" + check_and_display "6.11" "部署 Joplin Server 私人笔记" "dir" "/root/joplin_data" + check_and_display "6.12" "部署 Trilium Notes 知识库" "dir" "/root/trilium_data" + echo "" + echo -e "${YELLOW} ── 安防与监控 ──────────────────────────────────────────────────────${NC}" + check_and_display "8.1" "部署远程桌面 (Xfce+XRDP)" "file" "/etc/xrdp/xrdp.ini" + print_row "8.2" "为远程桌面添加中文字体" "[ 可选 ]" + print_row "8.3" "为远程桌面添加浏览器/更改RDP端口" "[ 可选 ]" + check_and_display "8.4" "部署 Fail2ban 防暴力破解" "service" "fail2ban" + check_and_display "8.5" "部署 Uptime Kuma 服务监控" "dir" "/root/uptime_kuma_data" + check_and_display "8.6" "部署 Glances 资源监控" "docker" "glances_app" + check_and_display "8.7" "部署 Syncthing 文件同步" "service" "syncthing@root" + check_and_display "8.8" "部署 Grafana 监控面板" "docker" "grafana_app" + echo "" + echo -e "${GREEN} ── 娱乐与创作 ──────────────────────────────────────────────────────${NC}" + check_and_display "7.1" "部署 SillyTavern AI酒馆" "dir" "/root/sillytavern_data" + check_and_display "7.2" "🐓 部署 PopoDash 监控面板 【鸡公头出品】" "dir" "/root/popodash" + check_and_display "7.3" "🐓 部署 MIMI 电报 AI 小秘书 【鸡公头出品】" "dir" "/root/mimi" + echo "" + echo -e "${YELLOW} ── SSH 安全 ────────────────────────────────────────────────────────${NC}" + print_row "9.1" "添加 SSH 公钥" "[ 公钥登录 ]" + local ssh_pass_status + if grep -qE "^\s*PasswordAuthentication\s+no" /etc/ssh/sshd_config 2>/dev/null; then + ssh_pass_status="[ ${RED}已禁用${NC} ]" + else + ssh_pass_status="[ ${GREEN}已启用${NC} ]" + fi + print_row "9.2" "切换 SSH 密码登录" "${ssh_pass_status}" + echo "" + echo -e "${GREEN} ── 高级维护 ────────────────────────────────────────────────────────${NC}" + print_row "10.1" "Nextcloud 性能精装修" "[ 优化配置 ]" + print_row "10.2" "Nextcloud 高速风景版" "[ 视频封面+BBR ]" + print_row "10.3" "Nextcloud 外部存储管理" "[一键重挂/扫描 ]" + print_row "10.4" "🖼️ Nextcloud 预览图终极调教" "[ 新文件自动生成 ]" + print_row "10.5" "查看密码与路径" "[ 重要凭证 ]" + print_row "10.6" "查看VPS信息" "[ 系统状态 ]" + print_row "10.7" "收尾优化脚本" "[ 清理收尾 ]" + print_row "10.8" "甲骨文开机助手 (oci-helper)" "[ Oracle 专用 ]" + print_row "10.9" "服务器每日管家邮件报告" "[ 定时报告 ]" + echo "" + echo -e "${YELLOW} ── 一键部署 ────────────────────────────────────────────────────────${NC}" + print_row "20.1" "🚀 甲骨文满配一键全装" "[ 高配置 VPS 专用 ]" + print_row "20.2" "進入應用卸載中心" "[ ${RED}按需刪除${NC} ]" + echo "" + echo -e "${YELLOW}══════════════════════════════════════════════════════════════════════${NC}" + echo -e "${RED} ── 科学上网 ────────────────────────────────────────────────────────${NC}" + check_and_display "30.1" "打開科學上網工具箱 (Vless節點)" "dir" "/root/vless_data" + check_and_display "30.2" "部署 Hysteria 2 (暴力加速)" "service" "hysteria-server" + echo "" + echo -e "${YELLOW}══════════════════════════════════════════════════════════════════════${NC}" + print_row "40" "🎛️ 服务控制中心" "[ ${GREEN}管理已部署服务${NC} ]" "$CYAN" + print_row "41" "💾 整机冷备份" "[ ${GREEN}一键打包所有数据 → 上传网盘${NC} ]" "$YELLOW" + echo -e "${YELLOW}══════════════════════════════════════════════════════════════════════${NC}" + echo "" + print_row "X" "一键深度清理 (清理垃圾与缓存)" "[ ${CYAN}让小鸡更丝滑${NC} ]" + print_row "99" "一键卸载鸡公头" "[ ${RED}此操作不可逆!${NC} ]" "$RED" + print_row "q" "退出面板" "" + echo "" + echo -e "${YELLOW}══════════════════════════════════════════════════════════════════════${NC}" +} + +update_tunnel_config() { + local hostname="$1" + local service_url="$2" + local comment="$3" + local extra_param="$4" + + if [ ! -f "$TUNNEL_CONFIG_FILE" ]; then + echo -e "${RED}错误: Tunnel 配置文件不存在,请先部署 Tunnel。${NC}"; return 1 + fi + + sudo sed -i '/- service: http_status:404/d' "$TUNNEL_CONFIG_FILE" + + if grep -q "hostname: $hostname" "$TUNNEL_CONFIG_FILE"; then + echo -e "${YELLOW}域名 ${hostname} 的配置已存在,跳过添加。${NC}" + else + echo -e "\n # ${comment}" | sudo tee -a "$TUNNEL_CONFIG_FILE" > /dev/null + echo -e " - hostname: $hostname\n service: $service_url" | sudo tee -a "$TUNNEL_CONFIG_FILE" > /dev/null + # 如果传入了 "access" 参数,就添加 Cloudflare Access 规则 + if [[ "$extra_param" == "access" ]]; then + echo -e " # Cloudflare Access 保护" | sudo tee -a "$TUNNEL_CONFIG_FILE" > /dev/null + echo -e " access:\n required: true\n teamsName: \"$(echo $hostname | cut -d. -f2-).cloudflareaccess.com\"" | sudo tee -a "$TUNNEL_CONFIG_FILE" > /dev/null + fi + fi + echo -e " - service: http_status:404" | sudo tee -a "$TUNNEL_CONFIG_FILE" > /dev/null + + echo -e "\n${CYAN}--- Tunnel 配置预览 ---${NC}" + echo -e "${YELLOW}已将以下规则添加/更新至 ${TUNNEL_CONFIG_FILE}:${NC}" + echo "--------------------------------------------------" + grep -A 2 "hostname: $hostname" "$TUNNEL_CONFIG_FILE" | sed 's/^/ /' + echo "--------------------------------------------------" + + echo -e "${YELLOW}正在应用新的网络配置...${NC}" + sudo systemctl restart cloudflared + sleep 2 + if systemctl is-active --quiet cloudflared; then + echo -e "${GREEN}✅ Cloudflare Tunnel 已为 ${hostname} 更新并重启成功!${NC}" + echo -e "${GREEN} 您现在应该可以通过 https://${hostname} 访问您的服务了。${NC}" + else + echo -e "${RED}❌ Cloudflare Tunnel 重启失败!请执行 'sudo journalctl -u cloudflared -f' 查看日志。${NC}" + fi + +} + +# 1. 部署Cloudflare Tunnel +install_cloudflare_tunnel() { + clear + echo -e "${BLUE}--- “Cloudflare Tunnel 总线”开始施工! ---${NC}" + + if ! command -v cloudflared &> /dev/null; then + echo -e "${YELLOW}正在安装 Cloudflare Tunnel (cloudflared)...${NC}" + if $IS_IPV6_ONLY; then + # 纯IPv6环境:先换apt源,再直接下载二进制 + setup_ipv6_apt_sources + install_cloudflared_ipv6 || { echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s; return 1; } + else + # 正常IPv4/双栈环境:使用官方apt源 + sudo mkdir -p --mode=0755 /usr/share/keyrings + curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null + echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/cloudflared.list + sudo apt-get update + sudo apt-get install -y cloudflared + fi + + if ! command -v cloudflared &> /dev/null; then + echo -e "${RED}❌ cloudflared 安装失败!无法找到命令。请检查上面的日志。${NC}" + echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s; return 1 + fi + echo -e "${GREEN}✅ cloudflared 安装完毕!${NC}" + fi + + echo -e "\n${CYAN}接下来,您需要提供 Cloudflare Tunnel 的令牌 (Token)。${NC}" + echo -e "${CYAN}请登录 Cloudflare Zero Trust Dashboard, 找到 Access -> Tunnels。${NC}" + echo -e "${CYAN}创建一个新的 Tunnel,选择 'Connector' 类型,复制页面上生成的 Token。${NC}" + read -p "请在此处粘贴您的 Tunnel Token: " tunnel_token + if [ -z "$tunnel_token" ]; then + echo -e "${RED}Token 不能为空,安装中止。${NC}"; sleep 3; return + fi + + # 如果旧的 cloudflared 服务文件残留,先清理,避免 "already installed" 报错 + if [ -f "/etc/systemd/system/cloudflared.service" ]; then + echo -e "${YELLOW}检测到旧的 cloudflared 服务残留,正在清理...${NC}" + sudo cloudflared service uninstall 2>/dev/null || true + sudo systemctl daemon-reload + sleep 1 + echo -e "${GREEN} ✅ 旧服务已清理,继续安装...${NC}" + fi + + if sudo cloudflared service install "$tunnel_token"; then + echo "CLOUDFLARE_TOKEN=${tunnel_token}" > ${STATE_FILE} + sudo mkdir -p "$(dirname "$TUNNEL_CONFIG_FILE")" + sudo tee "$TUNNEL_CONFIG_FILE" > /dev/null </dev/null; then + echo -e "${RED}错误:此功能依赖“Cloudflare Tunnel”,请先执行选项 1 进行安装和配置!${NC}" + sleep 3; return 1 + fi + return 0 +} + +# 2. Rclone 数据同步桥 +# 删除单个已配置的 Rclone remote(停服务、卸挂载、清配置) +_delete_rclone_remote() { + local remote_name="$1" + if [ -z "$remote_name" ]; then return; fi + + local service_name="rclone-${remote_name}-mount" + local mount_path + mount_path=$(grep "^RCLONE_MOUNT_${remote_name}=" "${STATE_FILE}" 2>/dev/null | cut -d'=' -f2) + + echo -e "\n${YELLOW}正在删除 remote: ${CYAN}${remote_name}${NC}" + + # 1. 停止并禁用 systemd 服务 + if systemctl is-active --quiet "${service_name}.service" 2>/dev/null; then + echo -e " ${YELLOW}→ 停止挂载服务 ${service_name}...${NC}" + sudo systemctl stop "${service_name}.service" + fi + if systemctl is-enabled --quiet "${service_name}.service" 2>/dev/null; then + sudo systemctl disable "${service_name}.service" + fi + if [ -f "/etc/systemd/system/${service_name}.service" ]; then + sudo rm -f "/etc/systemd/system/${service_name}.service" + sudo systemctl daemon-reload + echo -e " ${GREEN}✅ 已删除 systemd 服务${NC}" + fi + + # 2. 卸载挂载点(如果还在挂载中) + if [ -n "$mount_path" ] && mount | grep -q "${mount_path}"; then + sudo fusermount -u "${mount_path}" 2>/dev/null || sudo umount -l "${mount_path}" 2>/dev/null + echo -e " ${GREEN}✅ 已卸载挂载点 ${mount_path}${NC}" + fi + + # 3. 从 rclone.conf 删除该 remote 的配置段 + if [ -f "${RCLONE_CONFIG_FILE}" ]; then + python3 -c " +import re +with open('${RCLONE_CONFIG_FILE}', 'r') as fh: + content = fh.read() +pattern = r'\[${remote_name}\][^\[]*' +content = re.sub(pattern, '', content).strip() + '\n' +with open('${RCLONE_CONFIG_FILE}', 'w') as fh: + fh.write(content) +" 2>/dev/null \ + && echo -e " ${GREEN}✅ 已从 rclone.conf 删除 [${remote_name}] 配置${NC}" \ + || echo -e " ${YELLOW}⚠️ 无法自动清理 rclone.conf,请手动删除 [${remote_name}] 段落${NC}" + fi + + # 4. 清除 STATE_FILE 中的凭证记录 + sudo sed -i "/^RCLONE_REMOTE_${remote_name}=/d" "${STATE_FILE}" 2>/dev/null + sudo sed -i "/^RCLONE_MOUNT_${remote_name}=/d" "${STATE_FILE}" 2>/dev/null + # 若删除的是旧版兼容记录的第一个 remote,同步更新 + local compat_remote + compat_remote=$(grep "^RCLONE_REMOTE=" "${STATE_FILE}" 2>/dev/null | cut -d'=' -f2) + if [ "$compat_remote" = "$remote_name" ]; then + sudo sed -i "/^RCLONE_REMOTE=/d" "${STATE_FILE}" 2>/dev/null + sudo sed -i "/^RCLONE_MOUNT_PATH=/d" "${STATE_FILE}" 2>/dev/null + # 若仍有其他 remote,将第一个补回兼容记录 + local next_remote + next_remote=$(grep "^RCLONE_REMOTE_" "${STATE_FILE}" 2>/dev/null | head -1 | cut -d'=' -f2) + if [ -n "$next_remote" ]; then + local next_mount + next_mount=$(grep "^RCLONE_MOUNT_${next_remote}=" "${STATE_FILE}" 2>/dev/null | cut -d'=' -f2) + echo "RCLONE_REMOTE=${next_remote}" >> "${STATE_FILE}" + [ -n "$next_mount" ] && echo "RCLONE_MOUNT_PATH=${next_mount}" >> "${STATE_FILE}" + fi + fi + + # 5. 删除日志文件 + sudo rm -f "/var/log/rclone-${remote_name}.log" + + echo -e " ${GREEN}✅ Remote [${remote_name}] 已完全删除!${NC}" +} + +configure_rclone_engine() { + clear + echo -e "${BLUE}--- \"Rclone 数据同步桥\"配置向导 (全盘跃遷模式) ---${NC}" + if ! command -v rclone &> /dev/null; then + echo -e "\n${YELLOW}🚀 正在为您安装 Rclone 主程序...${NC}" + curl https://rclone.org/install.sh | sudo bash; sudo apt-get install -y fuse3 + echo -e "${GREEN}✅ Rclone 已安装完毕!${NC}"; sleep 2 + fi + if [ ! -f "${RCLONE_CONFIG_FILE}" ]; then + echo -e "\n${YELLOW}未检测到 Rclone 配置文件。${NC}\n${CYAN}即将启动 Rclone 官方交互式配置工具...${NC}" + read -p "准备好后,请按任意键继续..." -n 1 -s; echo -e "\n"; rclone config + if [ ! -f "${RCLONE_CONFIG_FILE}" ]; then + echo -e "\n${RED}错误:配置似乎未成功保存。请重新尝试。${NC}"; sleep 3; return + fi + echo -e "\n${GREEN}✅ 检测到 Rclone 配置文件已成功创建!${NC}"; sleep 2 + fi + echo -e "\n${CYAN}--- 设置 Rclone 全盘自动挂载 ---${NC}" + + # 读取已有 remote 列表(存入数组,供编号选择使用) + local remotes=() + while IFS= read -r r; do + remotes+=("$r") + done < <(rclone listremotes --config "${RCLONE_CONFIG_FILE}" 2>/dev/null | sed 's/:$//') + + echo -e "${YELLOW}当前 Rclone 已配置的 remote:${NC}" + if [ ${#remotes[@]} -eq 0 ]; then + echo -e " ${YELLOW}(暂无已配置的 remote)${NC}" + else + local i=1 + for r in "${remotes[@]}"; do + local svc_status="" + if systemctl is-active --quiet "rclone-${r}-mount.service" 2>/dev/null; then + svc_status="${GREEN}[运行中]${NC}" + elif [ -f "/etc/systemd/system/rclone-${r}-mount.service" ]; then + svc_status="${YELLOW}[已配置/未运行]${NC}" + fi + echo -e " ${CYAN}${i})${NC} ${GREEN}${r}${NC} ${svc_status}" + (( i++ )) + done + fi + echo "" + echo -e " ${GREEN}a)${NC} 挂载新的 remote(添加)" + [ ${#remotes[@]} -gt 0 ] && echo -e " ${RED}d)${NC} 删除已配置的 remote" + echo -e " ${YELLOW}q)${NC} 返回主菜单" + echo "" + read -p "请选择操作 (a/d/q): " rclone_action + + case "$rclone_action" in + d|D) + if [ ${#remotes[@]} -eq 0 ]; then + echo -e "${RED}没有可删除的 remote。${NC}"; sleep 2; configure_rclone_engine; return + fi + echo "" + echo -e "${RED}--- 删除 Rclone Remote ---${NC}" + echo -e "${YELLOW}请输入要删除的 remote 编号:${NC}" + local i=1 + for r in "${remotes[@]}"; do + echo -e " ${CYAN}${i})${NC} ${r}" + (( i++ )) + done + echo "" + read -p "请输入编号 (1-${#remotes[@]}),或按 q 取消: " del_choice + if [[ "$del_choice" == "q" || "$del_choice" == "Q" ]]; then + configure_rclone_engine; return + fi + if ! [[ "$del_choice" =~ ^[0-9]+$ ]] || [ "$del_choice" -lt 1 ] || [ "$del_choice" -gt "${#remotes[@]}" ]; then + echo -e "${RED}❌ 无效编号,操作取消。${NC}"; sleep 2; configure_rclone_engine; return + fi + local target_remote="${remotes[$((del_choice - 1))]}" + echo "" + echo -e "${RED}⚠️ 即将删除 remote: ${CYAN}${target_remote}${NC}" + echo -e "${RED} 将执行:停止挂载服务 / 卸载挂载点 / 清除 rclone.conf 配置${NC}" + read -p "确认删除?(y/n,默认n): " del_confirm + if [[ "$del_confirm" == "y" || "$del_confirm" == "Y" ]]; then + _delete_rclone_remote "$target_remote" + else + echo -e "${GREEN}已取消。${NC}" + fi + echo -e "\n${GREEN}按任意键返回...${NC}"; read -n 1 -s + configure_rclone_engine; return + ;; + q|Q) + return + ;; + *) + # a 或其他输入 → 继续走挂载新 remote 的流程 + : + ;; + esac + + read -p "请输入您要挂载的 remote 名称 (例如 onedrive / GoogleDrive / S3): " rclone_remote_name + if [ -z "$rclone_remote_name" ]; then echo -e "${RED}remote 名称不能为空,配置中止。${NC}"; sleep 3; return; fi + + # 检查输入的 remote 是否已在配置文件中,若没有则先运行 rclone config 添加 + if ! rclone listremotes --config "${RCLONE_CONFIG_FILE}" 2>/dev/null | sed 's/:$//' | grep -qx "${rclone_remote_name}"; then + echo -e "${YELLOW}⚠️ 未找到名为 "${rclone_remote_name}" 的 remote,需要先配置它。${NC}" + echo -e "${CYAN}即将启动 rclone config,请按以下步骤操作:${NC}" + echo -e " 1. 选 ${GREEN}n${NC} 新建 remote" + echo -e " 2. name 填 ${GREEN}${rclone_remote_name}${NC}" + echo -e " 3. 完成认证后选 ${GREEN}q${NC} 退出" + read -p "准备好后按任意键继续..." -n 1 -s; echo "" + rclone config + # 再次确认是否配置成功 + if ! rclone listremotes --config "${RCLONE_CONFIG_FILE}" 2>/dev/null | sed 's/:$//' | grep -qx "${rclone_remote_name}"; then + echo -e "${RED}❌ "${rclone_remote_name}" 仍未配置成功,配置中止。${NC}"; sleep 3; return + fi + echo -e "${GREEN}✅ "${rclone_remote_name}" 配置成功!${NC}"; sleep 2 + fi + + # 挂载路径默认与 remote 名称保持一致(大小写原样保留),用户可自定义 + local default_mount_path="/mnt/${rclone_remote_name}" + read -p "请输入本机挂载路径 (默认 ${default_mount_path}): " rclone_mount_path + [ -z "$rclone_mount_path" ] && rclone_mount_path="${default_mount_path}" + + # systemd 服务名用 remote 名称,支持同机多个 remote 并存 + local service_name="rclone-${rclone_remote_name}-mount" + local log_file="/var/log/rclone-${rclone_remote_name}.log" + + # 保存凭证(支持多条记录,用 remote 名称做 key 区分) + sed -i.bak "/^RCLONE_REMOTE_${rclone_remote_name}=/d" ${STATE_FILE} 2>/dev/null + sed -i.bak "/^RCLONE_MOUNT_${rclone_remote_name}=/d" ${STATE_FILE} 2>/dev/null + rm -f "${STATE_FILE}.bak" + # 同时保留兼容旧版的 RCLONE_REMOTE / RCLONE_MOUNT_PATH(供其他模块读取第一个 remote) + grep -q "^RCLONE_REMOTE=" ${STATE_FILE} 2>/dev/null || echo "RCLONE_REMOTE=${rclone_remote_name}" >> ${STATE_FILE} + grep -q "^RCLONE_MOUNT_PATH=" ${STATE_FILE} 2>/dev/null || echo "RCLONE_MOUNT_PATH=${rclone_mount_path}" >> ${STATE_FILE} + echo "RCLONE_REMOTE_${rclone_remote_name}=${rclone_remote_name}" >> ${STATE_FILE} + echo "RCLONE_MOUNT_${rclone_remote_name}=${rclone_mount_path}" >> ${STATE_FILE} + + echo -e "\n${YELLOW}正在为 ${rclone_remote_name} 创建全盘挂载通道 → ${rclone_mount_path} ...${NC}" + sudo mkdir -p "${rclone_mount_path}" + + sudo tee "/etc/systemd/system/${service_name}.service" > /dev/null < ${rclone_mount_path} +Wants=network-online.target +After=network-online.target +[Service] +Type=simple +User=root +Group=root +RestartSec=10 +Restart=on-failure +ExecStart=/usr/bin/rclone mount ${rclone_remote_name}: ${rclone_mount_path} --config ${RCLONE_CONFIG_FILE} --uid 33 --gid 33 --umask 022 --allow-other --allow-non-empty --vfs-cache-mode off --log-level INFO --log-file ${log_file} +ExecStop=/bin/fusermount -u ${rclone_mount_path} +[Install] +WantedBy=default.target +EOF + + sudo systemctl daemon-reload + sudo systemctl enable --now "${service_name}.service" + sleep 3 + + if systemctl is-active --quiet "${service_name}.service"; then + echo -e "${GREEN}✅ Rclone 挂载通道已激活!${NC}" + echo -e " Remote : ${CYAN}${rclone_remote_name}${NC}" + echo -e " 挂载路径: ${CYAN}${rclone_mount_path}${NC}" + echo -e " 服务名 : ${CYAN}${service_name}.service${NC}" + echo -e " 日志 : ${CYAN}${log_file}${NC}" + ls "${rclone_mount_path}" 2>/dev/null | head -5 && echo -e "${GREEN} ✅ 挂载内容可读${NC}" || echo -e "${YELLOW} ⚠️ 挂载点为空或读取需要时间${NC}" + + # 若 Nextcloud 在运行,询问是否注册为外部存储 + if docker ps 2>/dev/null | grep -q "nextcloud_app"; then + echo "" + read -p "检测到 Nextcloud 正在运行,是否自动注册为外部存储?(y/n,默认y): " NC_REG + if [ "$NC_REG" != "n" ] && [ "$NC_REG" != "N" ]; then + local mount_label=$(basename "${rclone_mount_path}") + docker exec -u www-data nextcloud_app php occ files_external:create \ + "/${mount_label}" local null::null \ + --config=datadir="${rclone_mount_path}" 2>/dev/null \ + && echo -e "${GREEN}✅ 已注册到 Nextcloud 外部存储 → /${mount_label}${NC}" \ + || echo -e "${YELLOW}⚠️ 自动注册失败,请手动在 Nextcloud → 设置 → 外部存储 添加 ${rclone_mount_path}${NC}" + # 清锁再扫描 + local _DB_PASS + _DB_PASS=$(grep "MYSQL_PASSWORD" /root/nextcloud_data/docker-compose.yml 2>/dev/null | head -1 | sed 's/.*MYSQL_PASSWORD: //' | tr -d ' ') + docker exec -u www-data nextcloud_app php occ maintenance:mode --on 2>/dev/null + [ -n "$_DB_PASS" ] && docker exec nextcloud_db mariadb -u nextclouduser -p"${_DB_PASS}" nextclouddb \ + -e "DELETE FROM oc_file_locks WHERE 1;" 2>/dev/null + docker exec -u www-data nextcloud_app php occ maintenance:mode --off 2>/dev/null + docker exec -u www-data nextcloud_app php occ files:scan --all --quiet 2>/dev/null + echo -e "${GREEN}✅ 扫描完成!${NC}" + fi + fi + else + echo -e "${RED}❌ 挂载通道启动失败!请检查日志:${NC}" + echo -e " journalctl -u ${service_name}.service -n 20" + echo -e " cat ${log_file}" + fi + + echo -e "\n${GREEN}Rclone 数据同步桥配置完成!按任意键返回主菜单...${NC}"; read -n 1 -s +} + +# 3.1. 部署 Nextcloud +install_nextcloud() { + ensure_docker_installed || return; check_tunnel_installed || return + _check_not_installed "/root/nextcloud_data" "Nextcloud" || return + read -p "请输入您的主域名 (例如 example.com): " MAIN_DOMAIN + if [ -z "$MAIN_DOMAIN" ]; then echo -e "${RED}错误:主域名不能为空!${NC}"; sleep 2; return; fi + local NEXTCLOUD_DOMAIN="nextcloud.${MAIN_DOMAIN}" + local DB_PASSWORD="NcDb-pW_$(_gen_password 12)" + clear; echo -e "${BLUE}--- "Nextcloud 核心"部署计划启动! ---${NC}" + + mkdir -p /root/nextcloud_data + + # 确保 php-opcache.ini 是文件而不是目录(已知 bug 修复) + [ -d "/root/nextcloud_data/php-opcache.ini" ] && rm -rf /root/nextcloud_data/php-opcache.ini + cat > /root/nextcloud_data/php-opcache.ini << 'OPCEOF' +opcache.enable=1 +opcache.memory_consumption=128 +opcache.interned_strings_buffer=16 +opcache.max_accelerated_files=10000 +opcache.save_comments=1 +opcache.revalidate_freq=60 +OPCEOF + + # 写入 docker-compose(含 /mnt rshared 映射,让 FUSE/SSHFS 挂载穿透容器) + cat > /root/nextcloud_data/docker-compose.yml << NCEOF +services: + db: + image: mariadb:11.4 + container_name: nextcloud_db + restart: unless-stopped + command: [--transaction-isolation=READ-COMMITTED, --binlog-format=ROW, --character-set-server=utf8mb4, --collation-server=utf8mb4_unicode_ci] + volumes: + - './db:/var/lib/mysql' + environment: + MYSQL_DATABASE: nextclouddb + MYSQL_USER: nextclouduser + MYSQL_PASSWORD: ${DB_PASSWORD} + MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}_root + redis: + image: redis:alpine + container_name: nextcloud_redis + restart: unless-stopped + app: + image: nextcloud:latest + container_name: nextcloud_app + restart: unless-stopped + ports: + - "127.0.0.1:8888:80" + volumes: + - './html:/var/www/html' + - './php-opcache.ini:/usr/local/etc/php/conf.d/opcache-recommended.ini' + - type: bind + source: /mnt + target: /mnt + bind: + propagation: rshared + depends_on: + - db + - redis +NCEOF + + echo -e "${YELLOW}[1/5] 正在启动容器...${NC}" + _compose_up "Nextcloud" "/root/nextcloud_data" || { read -n 1 -s -r -p "按任意键返回..."; return 1; } + echo -e "${GREEN}✅ 容器已启动,等待服务稳定...${NC}"; sleep 10 + + # 保存凭证 + echo -e "\n## Nextcloud 凭证 (部署于: $(date))" >> ${STATE_FILE} + echo "NEXTCLOUD_DOMAIN=${NEXTCLOUD_DOMAIN}" >> ${STATE_FILE} + echo "DB_USER=nextclouduser" >> ${STATE_FILE} + echo "DB_NAME=nextclouddb" >> ${STATE_FILE} + echo "DB_HOST=db" >> ${STATE_FILE} + echo "DB_PASSWORD=${DB_PASSWORD}" >> ${STATE_FILE} + + # 修复 /mnt 权限(仅设置已存在目录的权限,不主动批量创建) + echo -e "${YELLOW}[2/5] 正在初始化 /mnt 目录权限...${NC}" + sudo chown -R www-data:www-data /mnt 2>/dev/null || true + sudo chmod -R 755 /mnt 2>/dev/null || true + + # 配置 Tunnel + echo -e "${YELLOW}[3/5] 正在配置 Cloudflare Tunnel...${NC}" + update_tunnel_config "${NEXTCLOUD_DOMAIN}" "http://127.0.0.1:8888" "Nextcloud" + + # 设置定时任务 + echo -e "${YELLOW}[4/5] 正在配置 Nextcloud 定时任务 (Cron)...${NC}" + (crontab -l 2>/dev/null | grep -v "nextcloud_app php -f"; echo "*/5 * * * * /usr/bin/docker exec --user www-data nextcloud_app php -f /var/www/html/cron.php > /dev/null 2>&1") | crontab - + (crontab -l 2>/dev/null | grep -v "files:scan"; echo "*/15 * * * * /usr/bin/docker exec --user www-data nextcloud_app php occ files:scan --all > /dev/null 2>&1") | crontab - + echo -e "${GREEN}✅ Cron 定时任务已配置!${NC}" + + echo -e "${YELLOW}[5/5] 部署完成!${NC}" + echo "" + echo -e "${GREEN}╔══════════════════════════════════════════════════════════╗${NC}" + echo -e "${GREEN}║ ✅ Nextcloud 部署完成!首次安装指引 ║${NC}" + echo -e "${GREEN}╠══════════════════════════════════════════════════════════╣${NC}" + echo -e "${GREEN}║ 访问地址:https://${NEXTCLOUD_DOMAIN} ${NC}" + echo -e "${GREEN}║ ║${NC}" + echo -e "${GREEN}║ 在网页中填写以下数据库信息完成初始化: ║${NC}" + echo -e "${GREEN}║ 数据库类型 :MySQL/MariaDB ║${NC}" + echo -e "${GREEN}║ 数据库用户 :nextclouduser ║${NC}" + echo -e "${CYAN}║ 数据库密码 :${DB_PASSWORD} ${NC}" + echo -e "${GREEN}║ 数据库名 :nextclouddb ║${NC}" + echo -e "${GREEN}║ 数据库地址 :db ║${NC}" + echo -e "${GREEN}╠══════════════════════════════════════════════════════════╣${NC}" + echo -e "${GREEN}║ 初始化后请继续执行: ║${NC}" + echo -e "${GREEN}║ 22) Nextcloud 精装修(性能优化) ║${NC}" + echo -e "${GREEN}║ 22.1) Nextcloud 高速风景版(视频封面+BBR) ║${NC}" + echo -e "${GREEN}╚══════════════════════════════════════════════════════════╝${NC}" + echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s +} +# 3.2. 部署 OnlyOffice +install_onlyoffice() { + ensure_docker_installed || return; check_tunnel_installed || return + _check_not_installed "/root/onlyoffice_data" "OnlyOffice" || return + read -p "请输入您的主域名 (例如 example.com): " MAIN_DOMAIN + if [ -z "$MAIN_DOMAIN" ]; then echo -e "${RED}错误:主域名不能为空!${NC}"; sleep 2; return; fi + local ONLYOFFICE_DOMAIN="onlyoffice.${MAIN_DOMAIN}" + local ONLYOFFICE_JWT_SECRET="JwtS3cr3t-$(_gen_password 16)" + clear; echo -e "${BLUE}--- “OnlyOffice 办公套件”部署计划启动! ---${NC}" + + mkdir -p /root/onlyoffice_data; cat > /root/onlyoffice_data/docker-compose.yml <> ${STATE_FILE} + echo "ONLYOFFICE_DOMAIN=${ONLYOFFICE_DOMAIN}" >> ${STATE_FILE} + echo "ONLYOFFICE_JWT_SECRET=${ONLYOFFICE_JWT_SECRET}" >> ${STATE_FILE} + + echo -e "${GREEN}正在为您配置网络...${NC}" + update_tunnel_config "${ONLYOFFICE_DOMAIN}" "http://127.0.0.1:8889" "OnlyOffice" + + echo -e "\n${YELLOW}提示:OnlyOffice 已部署。请登录您的 Nextcloud,在设置中找到 'ONLYOFFICE',${NC}" + echo -e "${YELLOW}填入此域名 (https://${ONLYOFFICE_DOMAIN}) 和 JWT 密钥 (见选项24) 以完成集成。${NC}" + echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s +} + +# 3.3. 部署 Home Assistant +install_home_assistant() { + ensure_docker_installed || return; check_tunnel_installed || return + read -p "请输入您为 Home Assistant 规划的子域名 (例如 ha.example.com): " HA_DOMAIN + if [ -z "$HA_DOMAIN" ]; then echo -e "${RED}错误:域名不能为空!${NC}"; sleep 2; return; fi + clear; echo -e "${BLUE}--- “Home Assistant 智能家居中枢”部署计划启动! ---${NC}" + + # 【修复】创建 config 目录,为预配置做准备 + mkdir -p /root/home_assistant_data/config + + cat > /root/home_assistant_data/docker-compose.yml < /root/home_assistant_data/config/configuration.yaml <> ${STATE_FILE} + echo "HA_DOMAIN=${HA_DOMAIN}" >> ${STATE_FILE} + + echo -e "${GREEN}正在为您配置网络...${NC}" + update_tunnel_config "${HA_DOMAIN}" "http://127.0.0.1:8123" "Home Assistant" + + echo -e "\n${GREEN}✅ Home Assistant 部署成功!${NC}" + echo -e "${YELLOW}请访问 https://${HA_DOMAIN} 来创建您的管理员账户并开始配置。${NC}" + else + echo -e "${RED}❌ Home Assistant 部署失败!请检查 Docker 是否正常运行。${NC}" + fi + echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s +} + +# 4. WordPress +install_wordpress() { + ensure_docker_installed || return; check_tunnel_installed || return + read -p "请输入您的 WordPress 域名 (例如 blog.example.com): " WP_DOMAIN + if [ -z "$WP_DOMAIN" ]; then echo -e "${RED}错误:域名不能为空!${NC}"; sleep 2; return; fi + local WP_DB_PASS="WpDb-pW_$(_gen_password 12)"; local WP_DB_ROOT_PASS="WpRoot-pW_$(_gen_password 12)" + clear; echo -e "${BLUE}--- “WordPress 个人博客”建造计划启动! ---${NC}" + mkdir -p /root/wordpress_data; cat > /root/wordpress_data/docker-compose.yml <> ${STATE_FILE}; echo "WORDPRESS_DOMAIN=${WP_DOMAIN}" >> ${STATE_FILE} + echo -e "${GREEN}正在为您配置网络...${NC}"; update_tunnel_config "${WP_DOMAIN}" "http://127.0.0.1:8890" "WordPress" + else echo -e "${RED}❌ WordPress 部署失败!${NC}"; fi + echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s +} + +# 5. AI 核心 +install_ai_suite() { + ensure_docker_installed || return; check_tunnel_installed || return + read -p "请输入您为 AI 规划的子域名 (例如 ai.example.com): " AI_DOMAIN + if [ -z "$AI_DOMAIN" ]; then echo -e "${RED}错误:AI 域名不能为空!${NC}"; sleep 2; return; fi + clear; echo -e "${BLUE}--- “AI 大脑”激活计划启动! ---${NC}" + mkdir -p /root/ai_stack; cat > /root/ai_stack/docker-compose.yml <> ${STATE_FILE}; echo "AI_DOMAIN=${AI_DOMAIN}" >> ${STATE_FILE} + echo -e "${GREEN}正在为您配置网络...${NC}"; update_tunnel_config "${AI_DOMAIN}" "http://127.0.0.1:3001" "AI WebUI" + echo -e "${YELLOW}强烈建议立即执行选项 17 安装一个知识库 (模型)!${NC}" + else echo -e "${RED}❌ AI 核心部署失败!${NC}"; fi + echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s +} + +# 6. Jellyfin +install_jellyfin() { + ensure_docker_installed || return; check_tunnel_installed || return + _check_not_installed "/root/jellyfin_data" "Jellyfin" || return + clear + read -p "请输入您为 Jellyfin 规划的子域名 (例如 jellyfin.example.com): " JELLYFIN_DOMAIN + if [ -z "$JELLYFIN_DOMAIN" ]; then echo -e "${RED}域名不能为空!${NC}"; sleep 2; return; fi + echo -e "${BLUE}--- “Jellyfin 家庭影院”建造计划启动! ---${NC}" + mkdir -p /root/jellyfin_data/config + cat > /root/jellyfin_data/docker-compose.yml <> ${STATE_FILE}; echo "JELLYFIN_DOMAIN=${JELLYFIN_DOMAIN}" >> ${STATE_FILE} + echo -e "${GREEN}正在为您配置网络...${NC}"; update_tunnel_config "${JELLYFIN_DOMAIN}" "http://127.0.0.1:8096" "Jellyfin" + else echo -e "${RED}❌ Jellyfin 部署失败!${NC}"; fi + echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s +} + +# 7. Navidrome +install_navidrome() { + ensure_docker_installed || return; check_tunnel_installed || return + _check_not_installed "/root/navidrome_data" "Navidrome" || return + clear + read -p "请输入您为 Navidrome 规划的子域名 (例如 music.example.com): " NAVIDROME_DOMAIN + if [ -z "$NAVIDROME_DOMAIN" ]; then echo -e "${RED}域名不能为空!${NC}"; sleep 2; return; fi + echo -e "${BLUE}--- “Navidrome 音乐服务器”部署计划启动! ---${NC}" + mkdir -p /root/navidrome_data + echo "" + echo -e "${CYAN}请输入音乐库路径(支持任意 /mnt 下的路径,例如 /mnt/Music 或 /mnt/Frankfurt/Music)${NC}" + read -p "音乐库路径 (默认 /mnt/Music): " ND_MUSIC_PATH + [ -z "$ND_MUSIC_PATH" ] && ND_MUSIC_PATH="/mnt/Music" + sudo mkdir -p "$ND_MUSIC_PATH" + cat > /root/navidrome_data/docker-compose.yml <> ${STATE_FILE}; echo "NAVIDROME_DOMAIN=${NAVIDROME_DOMAIN}" >> ${STATE_FILE} + echo -e "${GREEN}正在为您配置网络...${NC}"; update_tunnel_config "${NAVIDROME_DOMAIN}" "http://127.0.0.1:4533" "Navidrome" + else echo -e "${RED}❌ Navidrome 部署失败!${NC}"; fi + echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s +} + +# 8. Miniflux RSS 阅读器 +install_miniflux() { + ensure_docker_installed || return; check_tunnel_installed || return + _check_not_installed "/root/miniflux_data" "Miniflux" || return + clear + read -p "请输入您为 Miniflux 规划的子域名 (例如 miniflux.example.com): " MINIFLUX_DOMAIN + if [ -z "$MINIFLUX_DOMAIN" ]; then echo -e "${RED}域名不能为空!${NC}"; sleep 2; return; fi + local DB_PASSWORD="miniflux_secret_$(_gen_password 12)" + local ADMIN_PASSWORD_INITIAL="admin123456" + + echo -e "${BLUE}--- “Miniflux RSS 阅读器”部署计划启动! ---${NC}"; + mkdir -p /root/miniflux_data + cat > /root/miniflux_data/docker-compose.yml <> ${STATE_FILE} + echo "MINIFLUX_DOMAIN=${MINIFLUX_DOMAIN}" >> ${STATE_FILE} + echo "MINIFLUX_ADMIN_USER=admin" >> ${STATE_FILE} + echo "MINIFLUX_ADMIN_PASSWORD_INITIAL=${ADMIN_PASSWORD_INITIAL}" >> ${STATE_FILE} + echo -e "${GREEN}正在为您配置网络...${NC}"; + update_tunnel_config "${MINIFLUX_DOMAIN}" "http://127.0.0.1:8091" "Miniflux" + echo -e "\n${YELLOW}首次登录 https://${MINIFLUX_DOMAIN} 后,请立即修改密码!${NC}" + else + echo -e "${RED}❌ Miniflux 部署失败!${NC}"; + fi + echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s +} + +# 9. Gitea +install_gitea() { + ensure_docker_installed || return; check_tunnel_installed || return + _check_not_installed "/root/gitea_data" "Gitea" || return + clear + read -p "请输入您为 Gitea 规划的子域名 (例如 git.example.com): " GITEA_DOMAIN + if [ -z "$GITEA_DOMAIN" ]; then echo -e "${RED}域名不能为空!${NC}"; sleep 2; return; fi + echo -e "${BLUE}--- “Gitea 代码仓库”部署计划启动! ---${NC}"; mkdir -p /root/gitea_data + cat >/root/gitea_data/docker-compose.yml <> ${STATE_FILE}; echo "GITEA_DOMAIN=${GITEA_DOMAIN}" >> ${STATE_FILE} + echo -e "${GREEN}正在为您配置网络...${NC}"; update_tunnel_config "${GITEA_DOMAIN}" "http://127.0.0.1:3000" "Gitea" + else echo -e "${RED}❌ Gitea 部署失败!${NC}"; fi + echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s +} + +# 10. Memos (已集成 Mortis 修复 App API) +install_memos() { + ensure_docker_installed || return; check_tunnel_installed || return + _check_not_installed "/root/memos_data" "Memos" || return + clear + echo -e "${BLUE}=== 📝 部署 Memos 轻量笔记 (Immortal 永久稳定版) ===${NC}" + echo -e "${CYAN}使用 zhangcaiduo/Memos-Immortal-Backup 维护的稳定镜像,集成 Mortis gRPC 桥${NC}" + echo "" + + read -p "请输入 Memos 网页版域名 (例如 memos.example.com): " MEMOS_DOMAIN + if [ -z "$MEMOS_DOMAIN" ]; then echo -e "${RED}域名不能为空!${NC}"; sleep 2; return; fi + + read -p "请输入 App gRPC 接口域名 (例如 grpc.memos.example.com): " MORTIS_DOMAIN + if [ -z "$MORTIS_DOMAIN" ]; then echo -e "${RED}gRPC 域名不能为空!${NC}"; sleep 2; return; fi + + read -p "请输入版本号 [默认 0.22.4]: " MEMOS_VER + MEMOS_VER=${MEMOS_VER:-"0.22.4"} + MEMOS_VER=${MEMOS_VER#v} + + mkdir -p /root/memos_data/data + +cat > /root/memos_data/docker-compose.yml << MEMOSEOF +services: + memos: + image: ghcr.io/zhangcaiduo/memos-immortal-backup:${MEMOS_VER} + container_name: memos_app + restart: always + ports: + - "127.0.0.1:5230:5230" + volumes: + - './data:/var/opt/memos' + command: ["--addr", "0.0.0.0", "--port", "5230", "--data", "/var/opt/memos"] + + mortis: + image: ghcr.io/mudkipme/mortis:latest + container_name: memos_mortis_translator + restart: always + ports: + - "127.0.0.1:5231:5231" + entrypoint: ["/app/mortis"] + command: ["-grpc-addr=memos:5230"] + depends_on: + - memos +MEMOSEOF + + echo -e "${YELLOW}正在拉取 Memos v${MEMOS_VER} 镜像(来自 Immortal 稳定备份)...${NC}" + if _compose_up "Memos" "/root/memos_data"; then + echo -e "${GREEN}✅ Memos 和 Mortis 已启动,等待服务稳定...${NC}"; sleep 8 + echo -e "\n## Memos 凭证 (部署于: $(date))" >> ${STATE_FILE} + echo "MEMOS_DOMAIN=${MEMOS_DOMAIN}" >> ${STATE_FILE} + echo "MORTIS_DOMAIN=${MORTIS_DOMAIN}" >> ${STATE_FILE} + echo "MEMOS_VERSION=${MEMOS_VER}" >> ${STATE_FILE} + + echo -e "${GREEN}正在配置 Cloudflare Tunnel(Web + gRPC 双路)...${NC}" + update_tunnel_config "${MEMOS_DOMAIN}" "http://127.0.0.1:5230" "Memos (WebUI)" + + # gRPC 路由特殊处理 + sudo sed -i "/- service: http_status:404/d" "$TUNNEL_CONFIG_FILE" + printf "\n # Memos (gRPC App)\n - hostname: %s\n service: grpc://127.0.0.1:5231\n" "${MORTIS_DOMAIN}" | sudo tee -a "$TUNNEL_CONFIG_FILE" > /dev/null + echo " - service: http_status:404" | sudo tee -a "$TUNNEL_CONFIG_FILE" > /dev/null + sudo systemctl restart cloudflared + + echo "" + echo -e "${GREEN}╔══════════════════════════════════════════════════════════╗${NC}" + echo -e "${GREEN}║ ✅ Memos v${MEMOS_VER} 部署成功! ║${NC}" + echo -e "${GREEN}╠══════════════════════════════════════════════════════════╣${NC}" + echo -e "${GREEN}║ 网页版: ${CYAN}https://${MEMOS_DOMAIN}${NC}" + echo -e "${GREEN}║ App接口: ${CYAN}${MORTIS_DOMAIN}${NC} (填入手机App服务器地址)" + echo -e "${GREEN}║ 版本: v${MEMOS_VER} (Immortal 永久稳定版) ║${NC}" + echo -e "${GREEN}╚══════════════════════════════════════════════════════════╝${NC}" + else + echo -e "${RED}❌ Memos 部署失败!请检查版本号 ${MEMOS_VER} 是否存在。${NC}" + echo -e "${YELLOW}可用版本: https://github.com/zhangcaiduo/Memos-Immortal-Backup/releases${NC}" + fi + echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s +} + +# 11. qBittorrent +install_qbittorrent() { + ensure_docker_installed || return; check_tunnel_installed || return + _check_not_installed "/root/qbittorrent_data" "qBittorrent" || return + clear + read -p "请输入您为 qBittorrent 规划的子域名 (例如 qb.example.com): " QB_DOMAIN + if [ -z "$QB_DOMAIN" ]; then echo -e "${RED}域名不能为空!${NC}"; sleep 2; return; fi + echo -e "${BLUE}--- “qBittorrent 下载器”部署计划启动! ---${NC}"; mkdir -p /root/qbittorrent_data /mnt/Downloads + cat > /root/qbittorrent_data/docker-compose.yml <> ${STATE_FILE}; echo "QB_DOMAIN=${QB_DOMAIN}" >> ${STATE_FILE} + echo -e "${GREEN}正在为您配置网络...${NC}"; update_tunnel_config "${QB_DOMAIN}" "http://127.0.0.1:8080" "qBittorrent" + else echo -e "${RED}❌ qBittorrent 部署失败!${NC}"; fi + echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s +} + +# 12. JDownloader +install_jdownloader() { + ensure_docker_installed || return; check_tunnel_installed || return + _check_not_installed "/root/jdownloader_data" "JDownloader" || return + clear + read -p "请输入您为 JDownloader 规划的子域名 (例如 jd.example.com): " JD_DOMAIN + if [ -z "$JD_DOMAIN" ]; then echo -e "${RED}域名不能为空!${NC}"; sleep 2; return; fi + local JDOWNLOADER_PASS="VNC-Pass-$(_gen_password 8)" + echo -e "${BLUE}--- “JDownloader 下载器”部署计划启动! ---${NC}"; mkdir -p /root/jdownloader_data /mnt/Downloads + cat > /root/jdownloader_data/docker-compose.yml <> ${STATE_FILE}; echo "JD_DOMAIN=${JD_DOMAIN}" >> ${STATE_FILE} + echo "JDOWNLOADER_VNC_PASSWORD=${JDOWNLOADER_PASS}" >> ${STATE_FILE} + echo -e "${GREEN}正在为您配置网络...${NC}"; update_tunnel_config "${JD_DOMAIN}" "http://127.0.0.1:5800" "JDownloader" + else echo -e "${RED}❌ JDownloader 部署失败!${NC}"; fi + echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s +} + +# 13. yt-dlp (MeTube) +install_ytdlp() { + ensure_docker_installed || return; check_tunnel_installed || return + _check_not_installed "/root/ytdlp_data" "MeTube" || return + clear + echo -e "${BLUE}=== 📥 部署 MeTube 视频下载器 ===${NC}" + echo -e "${CYAN}基于 yt-dlp,支持 YouTube、B站、Twitter 等数百个网站${NC}" + echo "" + + read -p "请输入 MeTube 的访问域名 (例如 dl.example.com): " YTDL_DOMAIN + if [ -z "$YTDL_DOMAIN" ]; then echo -e "${RED}域名不能为空!${NC}"; sleep 2; return; fi + + # 询问下载目录 + echo -e "${YELLOW}下载文件保存位置:${NC}" + echo " 1) 默认路径 /mnt/Downloads(推荐,可被 Nextcloud 外部存储访问)" + echo " 2) 自定义路径" + read -p "请选择 (1/2,默认1): " DL_CHOICE + if [ "$DL_CHOICE" = "2" ]; then + read -p "请输入自定义下载路径: " YTDL_DOWNLOAD_DIR + [ -z "$YTDL_DOWNLOAD_DIR" ] && YTDL_DOWNLOAD_DIR="/mnt/Downloads" + else + YTDL_DOWNLOAD_DIR="/mnt/Downloads" + fi + sudo mkdir -p "${YTDL_DOWNLOAD_DIR}" + echo -e "${GREEN}下载路径: ${YTDL_DOWNLOAD_DIR}${NC}" + + # 询问是否配置 cookies(甲骨文等机器IP被YouTube要求验证时必须配置) + echo "" + echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${YELLOW} YouTube Cookies 配置(解决"Sign in to confirm"报错)${NC}" + echo -e "${YELLOW} 甲骨文/某些机房IP需要此配置才能下载 YouTube 视频${NC}" + echo -e "${YELLOW}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e " 1) 现在配置 cookies(推荐,特别是甲骨文机器)" + echo -e " 2) 跳过,稍后手动上传 cookies.txt 到 /root/ytdlp_data/cookies.txt" + read -p "请选择 (1/2,默认2): " COOKIE_CHOICE + + mkdir -p /root/ytdlp_data + + if [ "$COOKIE_CHOICE" = "1" ]; then + echo "" + echo -e "${CYAN}请按以下步骤获取 cookies.txt:${NC}" + echo -e " 1. 在您自己的电脑浏览器安装扩展 ${YELLOW}"Get cookies.txt LOCALLY"${NC}" + echo -e " 2. 登录 YouTube 账号,停留在 youtube.com 页面" + echo -e " 3. 点击扩展图标 → Export → 保存为 cookies.txt" + echo -e " 4. 用 SFTP(WindTerm/Tabby)上传到 ${YELLOW}/root/ytdlp_data/cookies.txt${NC}" + echo "" + read -p "上传完成后请按回车继续(或按 s 跳过cookies配置): " COOKIE_WAIT + if [ "$COOKIE_WAIT" != "s" ] && [ "$COOKIE_WAIT" != "S" ]; then + if [ -f "/root/ytdlp_data/cookies.txt" ]; then + echo -e "${GREEN}✅ 检测到 cookies.txt,将自动挂载!${NC}" + USE_COOKIES=true + else + echo -e "${YELLOW}⚠️ 未检测到 cookies.txt,将不挂载(可稍后手动添加)${NC}" + USE_COOKIES=false + fi + else + USE_COOKIES=false + fi + else + USE_COOKIES=false + fi + + # 生成 docker-compose.yml + if [ "$USE_COOKIES" = true ]; then + cat > /root/ytdlp_data/docker-compose.yml << YTEOF +services: + ytdlp-ui: + image: ghcr.io/alexta69/metube:latest + container_name: ytdlp_app + restart: unless-stopped + ports: + - "127.0.0.1:8999:8081" + volumes: + - '${YTDL_DOWNLOAD_DIR}:/downloads' + - '/root/ytdlp_data/cookies.txt:/cookies.txt:ro' + environment: + - TZ=Asia/Shanghai + - YTDL_OPTIONS={"cookiefile":"/cookies.txt"} +YTEOF + else + cat > /root/ytdlp_data/docker-compose.yml << YTEOF +services: + ytdlp-ui: + image: ghcr.io/alexta69/metube:latest + container_name: ytdlp_app + restart: unless-stopped + ports: + - "127.0.0.1:8999:8081" + volumes: + - '${YTDL_DOWNLOAD_DIR}:/downloads' + environment: + - TZ=Asia/Shanghai +YTEOF + fi + + if _compose_up "MeTube" "/root/ytdlp_data"; then + echo -e "${GREEN}✅ MeTube 已启动,等待服务稳定...${NC}"; sleep 5 + echo -e "\n## MeTube/yt-dlp 凭证 (部署于: $(date))" >> ${STATE_FILE} + echo "YTDL_DOMAIN=${YTDL_DOMAIN}" >> ${STATE_FILE} + echo "YTDL_DOWNLOAD_DIR=${YTDL_DOWNLOAD_DIR}" >> ${STATE_FILE} + echo -e "${GREEN}正在配置 Cloudflare Tunnel...${NC}" + update_tunnel_config "${YTDL_DOMAIN}" "http://127.0.0.1:8999" "MeTube 下载器" + + echo "" + echo -e "${GREEN}╔══════════════════════════════════════════════════════════╗${NC}" + echo -e "${GREEN}║ ✅ MeTube 部署成功! ║${NC}" + echo -e "${GREEN}╠══════════════════════════════════════════════════════════╣${NC}" + echo -e "${GREEN}║ 访问地址: ${CYAN}https://${YTDL_DOMAIN}${NC}" + echo -e "${GREEN}║ 下载目录: ${YTDL_DOWNLOAD_DIR}${NC}" + if [ "$USE_COOKIES" = true ]; then + echo -e "${GREEN}║ Cookies: ✅ 已配置(可下载需登录的 YouTube 视频) ║${NC}" + else + echo -e "${GREEN}║ Cookies: ⚠️ 未配置(遇到Sign in报错时需要添加) ║${NC}" + echo -e "${GREEN}║ 添加方法: 上传 cookies.txt 到 /root/ytdlp_data/ ║${NC}" + echo -e "${GREEN}║ 然后执行: cd /root/ytdlp_data ║${NC}" + echo -e "${GREEN}║ 编辑 docker-compose.yml 加入 cookies 挂载 ║${NC}" + echo -e "${GREEN}║ 执行: docker compose up -d --force-recreate ║${NC}" + fi + echo -e "${GREEN}╚══════════════════════════════════════════════════════════╝${NC}" + else + echo -e "${RED}❌ MeTube 部署失败!${NC}" + fi + echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s +} + +# 14. Draw.io 绘图工具 +install_drawio() { + ensure_docker_installed || return; check_tunnel_installed || return + _check_not_installed "/root/drawio_data" "Draw.io" || return + clear + read -p "请输入您为 Draw.io 规划的子域名 (例如 draw.example.com): " DRAWIO_DOMAIN + if [ -z "$DRAWIO_DOMAIN" ]; then echo -e "${RED}错误:域名不能为空!安装已中止。${NC}"; return 1; fi + + # 【修复】移除了不再需要的用户名和密码输入 + + echo -e "${GREEN}正在创建 Draw.io 的配置文件...${NC}" + mkdir -p /root/drawio_data + +# --- YAML 语法修正区 --- +# 注意:下面的 cat 命令使用了 <<-EOF,并且所有缩进都是空格,不是 TAB! +cat > /root/drawio_data/docker-compose.yml <<-EOF +services: + drawio: + # 【修复】使用了官方的 jgraph/drawio 镜像替换了已失效的 fuerst/draw.io-basic-auth + image: jgraph/drawio + container_name: drawio_app + restart: unless-stopped + ports: + - "127.0.0.1:8082:8080" + # 【修复】移除了不再需要的 BASIC_AUTH 环境变量 +EOF + + echo -e "${YELLOW}正在启动 Draw.io 服务...${NC}" + if _compose_up "Draw.io" "/root/drawio_data"; then + echo -e "${GREEN}✅ Draw.io 已启动,等待服务稳定...${NC}"; sleep 5 + echo -e "\n## Draw.io 凭证 (部署于: $(date))" >> ${STATE_FILE}; echo "DRAWIO_DOMAIN=${DRAWIO_DOMAIN}" >> ${STATE_FILE} + # 【修复】移除了不再需要的用户名和密码保存 + echo -e "${GREEN}正在为您配置网络...${NC}"; update_tunnel_config "${DRAWIO_DOMAIN}" "http://127.0.0.1:8082" "Draw.io" "access" + + # 【修复】更新了提示信息 + echo -e "\n${YELLOW}=======================【!重要!】=======================${NC}" + echo -e "${YELLOW}Draw.io 已部署成功!${NC}" + echo -e "${YELLOW}此应用已通过 Cloudflare Tunnel 的 ${CYAN}Access${YELLOW} 功能进行保护。${NC}" + echo -e "${YELLOW}您需要登录 Cloudflare Zero Trust 仪表盘, ${NC}" + echo -e "${YELLOW}在 Access -> Applications 中为 ${GREEN}${DRAWIO_DOMAIN}${YELLOW} 添加访问策略 (例如: 允许您的邮箱登录)。${NC}" + echo -e "${RED}===========================================================${NC}" + else + echo -e "${RED}❌ Draw.io 部署失败!请检查 Docker 是否正常运行。${NC}" + fi + echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s +} + +# 15. 远程工作台 +install_desktop_env() { + ensure_docker_installed || return; check_tunnel_installed || return + clear + echo -e "${BLUE}--- “远程工作台”建造计划启动! ---${NC}"; + export DEBIAN_FRONTEND=noninteractive + sudo apt-get update + sudo apt-get install -y xfce4 xfce4-goodies xrdp + if [ -f /etc/xrdp/sesman.ini ]; then + sudo sed -i 's/AllowRootLogin=true/AllowRootLogin=false/g' /etc/xrdp/sesman.ini + fi + sudo systemctl enable --now xrdp + echo xfce4-session > ~/.xsession + sudo adduser xrdp ssl-cert + sudo systemctl restart xrdp + + read -p "请输入您想创建的远程桌面用户名 (例如 admin): " NEW_USER + if [ -z "$NEW_USER" ]; then echo -e "${RED}用户名不能为空,操作取消。${NC}"; sleep 2; return; fi + sudo adduser --gecos "" "$NEW_USER" + echo -e "${YELLOW}请为新账户 '$NEW_USER' 设置登录密码...${NC}" + sudo passwd "$NEW_USER" + + echo -e "\n## 远程工作台 (Xfce) 凭证 (部署于: $(date))" >> ${STATE_FILE} + echo "RDP_USER=${NEW_USER}" >> ${STATE_FILE} + + echo -e "\n${GREEN}✅ 远程工作台建造及隧道配置完毕!${NC}" + echo -e "\n${YELLOW}--- 如何连接 ---${NC}" + echo -e " 1. 打开您本地的远程桌面客户端 (Windows/Mac/手机)。" + echo -e " 2. 在计算机/服务器地址栏输入: ${CYAN}\$(curl -s4 https://ifconfig.me/ip)${NC}" + echo -e " 3. (如果提示端口,请输入: ${CYAN}3389${NC})" + echo -e " 4. 使用您刚才创建的用户名 ${GREEN}${NEW_USER}${NC} 和密码登录。" + echo -e " 5. ${RED}重要: 您的 3389 端口已公网暴露,请确保已部署 Fail2ban (选项 18)!${NC}" + echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s +} + +# 16. 为远程桌面安装中文字体 +install_chinese_fonts() { + clear + echo -e "${BLUE}--- 为远程桌面安装中文字体 ---${NC}" + if [ ! -f "/etc/xrdp/xrdp.ini" ]; then + echo -e "${RED}错误:此功能依赖“远程工作台”,请先执行选项 15 进行安装。${NC}"; sleep 4; return 1 + fi + echo -e "${YELLOW}此操作将为您的远程桌面环境安装文泉驿等常用中文字体。${NC}" + read -p "您确定要安装吗? (y/n): " confirm + if [[ "$confirm" == "y" || "$confirm" == "Y" ]]; then + echo -e "\n${CYAN}[1/3] 正在更新软件包列表...${NC}"; sudo apt-get update > /dev/null 2>&1 + echo -e "\n${CYAN}[2/3] 正在安装中文字体包...${NC}"; sudo apt-get install -y fonts-wqy-microhei fonts-wqy-zenhei fonts-arphic-ukai fonts-arphic-uming + echo -e "\n${CYAN}[3/3] 正在刷新系统字体缓存...${NC}"; sudo fc-cache -fv + echo -e "\n${YELLOW}══════════════════════════════════════════════════════════════════════" + echo -e "✅ 中文字体安装完成!" + echo -e "================================================${NC}" + echo -e "${YELLOW}为了使字体完全生效,请注销后重新登录您的远程桌面会话。${NC}" + else + echo -e "${GREEN}操作已取消。${NC}" + fi + echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s +} + + _install_desktop_browser() { + echo -e "\n${CYAN}--- 步驟 2: 為遠程桌面安裝瀏覽器 ---${NC}" + if [ ! -f "/etc/xrdp/xrdp.ini" ]; then echo -e "${RED}錯誤:遠程工作台未安裝,無法安裝瀏覽器。${NC}"; sleep 3; return; fi + + local ARCH; ARCH=$(dpkg --print-architecture) + echo -e "${GREEN}CPU 架構: ${ARCH} | 系統: ${SYS_DISTRO} ${SYS_CODENAME}${NC}" + sudo apt-get update > /dev/null 2>&1 + + # 探测当前系统实际可用的 chromium 包名(排除 snap 占位符) + _detect_chromium_pkg() { + for pkg in chromium chromium-browser google-chrome-stable; do + if apt-cache show "$pkg" 2>/dev/null | grep -q "^Package:"; then + local desc + desc=$(apt-cache show "$pkg" 2>/dev/null | grep "^Description" | head -1) + if ! echo "$desc" | grep -qi "snap"; then + echo "$pkg"; return + fi + fi + done + echo "" + } + + # Firefox-ESR:优先 apt,Ubuntu 22.04+ snap 占位时自动切换 Mozilla PPA + _install_firefox() { + echo -e "${YELLOW}正在安裝 Firefox-ESR...${NC}" + if apt-cache show firefox-esr 2>/dev/null | grep -q "^Package:"; then + sudo apt-get install -y firefox-esr && return 0 + fi + echo -e "${YELLOW}系統自帶 Firefox 是 snap 版,嘗試從 Mozilla PPA 安裝原生版...${NC}" + sudo apt-get install -y software-properties-common > /dev/null 2>&1 + sudo add-apt-repository -y ppa:mozillateam/ppa > /dev/null 2>&1 + printf "Package: firefox*\nPin: release o=LP-PPA-mozillateam\nPin-Priority: 1001\n" \ + | sudo tee /etc/apt/preferences.d/mozilla-firefox > /dev/null + sudo apt-get update > /dev/null 2>&1 + sudo apt-get install -y firefox-esr + } + + # Chromium:apt 多包名回退 → Google Chrome 官方 deb(仅 amd64) + _install_chromium() { + local pkg; pkg=$(_detect_chromium_pkg) + if [ -n "$pkg" ]; then + echo -e "${YELLOW}正在安裝 ${pkg}...${NC}" + sudo apt-get install -y "$pkg" && return 0 + fi + echo -e "${YELLOW}apt 中未找到可用 Chromium,嘗試安裝 Google Chrome Stable...${NC}" + if [ "$ARCH" = "amd64" ]; then + local chrome_deb="/tmp/google-chrome.deb" + wget -q --show-progress -O "$chrome_deb" \ + "https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb" \ + && sudo apt-get install -y "$chrome_deb" && rm -f "$chrome_deb" && return 0 + fi + echo -e "${RED}❌ 當前架構 (${ARCH}) 無法自動安裝 Chrome,請手動處理。${NC}" + return 1 + } + + echo "" + echo -e " ${YELLOW}1)${NC} Firefox-ESR (推薦,輕量穩定)" + echo -e " ${YELLOW}2)${NC} Chromium / Google Chrome (自動適配來源)" + echo -e " ${YELLOW}其他)${NC} 取消" + read -p "請選擇: " choice + + local ok=false + case "$choice" in + 1) _install_firefox && ok=true ;; + 2) _install_chromium && ok=true ;; + *) echo -e "${GREEN}操作已取消。${NC}"; read -p "按 Enter 鍵繼續..."; return ;; + esac + + if $ok; then + echo -e "\n${GREEN}✅ 瀏覽器安裝成功!${NC}" + echo -e "${YELLOW}提示:登入遠程桌面後可在終端執行 exo-preferred-applications 設為默認瀏覽器。${NC}" + else + echo -e "\n${RED}❌ 瀏覽器安裝失敗,請查看上方錯誤信息。${NC}" + echo -e "${YELLOW}提示:也可先執行菜單 m) 恢復標準系統後重試。${NC}" + fi + read -p "按 Enter 鍵繼續..." + } +# 17. 安装 AI 知识库 +install_ai_model() { + ensure_docker_installed || return + if [ ! -d "/root/ai_stack" ]; then echo -e "${RED}错误:AI 大脑未安装!${NC}"; sleep 3; return; fi + clear + echo -e "${BLUE}--- 为 AI 大脑安装知识库 (安装大语言模型) ---${NC}" + echo " 1) qwen:1.8b (阿里通义千问), 2) gemma:2b (Google), 3) tinyllama (极限轻量)" + echo " 4) llama3:8b (Meta, 推荐), 5) qwen:4b (更强中文), 6) phi3 (微软)" + echo " 7) qwen:14b (准专业级), 8) llama3:70b (性能怪兽)" + read -p "请输入您的选择: " model_choice + local model_name="" + case $model_choice in + 1) model_name="qwen:1.8b";; 2) model_name="gemma:2b";; 3) model_name="tinyllama";; + 4) model_name="llama3:8b";; 5) model_name="qwen:4b";; 6) model_name="phi3";; + 7) model_name="qwen:14b";; 8) model_name="llama3:70b";; + *) echo -e "${RED}无效选择!${NC}"; sleep 2; return;; + esac + echo -e "\n${YELLOW}即将开始下载模型: ${model_name},这可能需要一些时间,请耐心等待...${NC}" + sudo docker exec ollama ollama pull ${model_name} + echo -e "\n${GREEN}✅ 知识库 ${model_name} 安装完成!${NC}" + echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s +} + +# 18. 部署 Fail2ban (防暴力破解) +install_fail2ban() { + clear + echo -e "${BLUE}--- “Fail2ban 安防系统”部署计划启动! ---${NC}" + echo -e "${YELLOW}即将安装 Fail2ban 并自动配置 SSH 与 RDP 防护...${NC}" + + sudo apt-get update >/dev/null 2>&1 + sudo apt-get install -y fail2ban + sudo systemctl enable --now fail2ban + + echo -e "${GREEN}✅ Fail2ban 已安装并启动。${NC}" + echo -e "${CYAN}正在配置 jail.local (设置 SSH 和 RDP 规则)...${NC}" + + # --- 创建 jail.local 配置文件 --- + sudo tee /etc/fail2ban/jail.local > /dev/null <<'EOF' +[DEFAULT] +bantime = 3600 +findtime = 600 +maxretry = 3 + +[sshd] +enabled = true + +[xrdp] +enabled = true +port = 3389 +logpath = /var/log/xrdp.log +EOF + + echo -e "${CYAN}正在配置 xrdp.conf (设置过滤器)...${NC}" + + # --- 创建 xrdp 过滤器文件 --- + sudo tee /etc/fail2ban/filter.d/xrdp.conf > /dev/null <<'EOF' +[Definition] +failregex = ^.*\[WARN \].*A connection problem has occurred, login is denied.*$ +EOF + + echo -e "${CYAN}正在创建 RDP 日志文件...${NC}" + sudo touch /var/log/xrdp.log + + echo -e "${YELLOW}正在重启 Fail2ban 以应用所有配置...${NC}" + sudo systemctl restart fail2ban + sleep 2 + + echo -e "\n${GREEN}--- 部署完成!正在检查 [xrdp] 规则状态 ---${NC}" + sudo fail2ban-client status xrdp + + echo -e "\n${GREEN}✅ Fail2ban 已成功加载!您的 SSH 和 RDP 已受到保护。${NC}" + echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s +} + +# 19. 部署 Uptime Kuma 监控面板 +install_uptime_kuma() { + ensure_docker_installed || return; check_tunnel_installed || return + _check_not_installed "/root/uptime_kuma_data" "Uptime Kuma" || return + clear + read -p "请输入您为 Uptime Kuma 规划的子域名 (例如 status.example.com): " KUMA_DOMAIN + if [ -z "$KUMA_DOMAIN" ]; then echo -e "${RED}错误:域名不能为空!${NC}"; sleep 2; return; fi + + echo -e "${BLUE}--- “Uptime Kuma 监控面板”部署计划启动! ---${NC}"; + mkdir -p /root/uptime_kuma_data + + # --- YAML 语法修正区 --- + # 注意:下面的 cat 命令使用了 <<-EOF,并且所有缩进都是空格,不是 TAB! + # 端口 3001 已被 AI (Option 5) 占用,此处使用 3002 + cat > /root/uptime_kuma_data/docker-compose.yml <<-EOF +services: + uptime-kuma: + image: louislam/uptime-kuma:1 + container_name: uptime_kuma_app + restart: always + ports: + - "127.0.0.1:3002:3001" + volumes: + - './data:/app/data' + environment: + - 'TZ=Asia/Shanghai' +EOF + + echo -e "${YELLOW}正在启动 Uptime Kuma 服务...${NC}" + if _compose_up "Uptime Kuma" "/root/uptime_kuma_data"; then + echo -e "${GREEN}✅ Uptime Kuma 已启动,等待服务稳定...${NC}"; sleep 5 + echo -e "\n## Uptime Kuma 凭证 (部署于: $(date))" >> ${STATE_FILE}; + echo "KUMA_DOMAIN=${KUMA_DOMAIN}" >> ${STATE_FILE} + echo -e "${GREEN}正在为您配置网络...${NC}"; + # 注意:这里 service_url 必须指向本地端口 3002 + update_tunnel_config "${KUMA_DOMAIN}" "http://127.0.0.1:3002" "Uptime Kuma" + else + echo -e "${RED}❌ Uptime Kuma 部署失败!请检查 Docker 是否正常运行。${NC}" + fi + echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s +} + +# 19.1 部署 Glances 实时资源监控 +install_glances() { + ensure_docker_installed || return; check_tunnel_installed || return; clear + read -p "请输入您为 Glances 规划的子域名 (例如 glances.example.com): " GLANCES_DOMAIN + if [ -z "$GLANCES_DOMAIN" ]; then echo -e "${RED}错误:域名不能为空!${NC}"; sleep 2; return; fi + + echo -e "${BLUE}--- “Glances 实时资源监控”部署计划启动! ---${NC}" + echo -e "${YELLOW}正在拉取镜像并启动 Web 模式...${NC}" + + # 使用 Docker 直接运行,开启 -w 网页模式,并绑定本地 61208 端口 + docker run -d \ + --name glances_app \ + --restart always \ + -p 127.0.0.1:61208:61208 \ + -v /var/run/docker.sock:/var/run/docker.sock:ro \ + -v /:/host:ro \ + --pid host \ + nicolargo/glances:latest-full \ + /usr/local/bin/glances -w + + if [ $? -eq 0 ]; then + echo -e "${GREEN}✅ Glances 已启动!${NC}" + echo -e "\n## Glances 监控凭证 (部署于: $(date))" >> ${STATE_FILE} + echo "GLANCES_DOMAIN=${GLANCES_DOMAIN}" >> ${STATE_FILE} + echo -e "${GREEN}正在为您配置网络隧道...${NC}" + # 关联 Cloudflare Tunnel + update_tunnel_config "${GLANCES_DOMAIN}" "http://127.0.0.1:61208" "Glances Monitor" + else + echo -e "${RED}❌ Glances 部署失败!${NC}" + fi + echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s +} + +# 20. 添加 SSH 密钥 +add_ssh_public_key() { + clear + echo -e "${BLUE}--- 添加 SSH 公钥 (密钥登录) ---${NC}" + echo -e "${YELLOW}此功能将允许您通过粘贴“公钥”来实现密钥登录。${NC}" + echo -e "${RED}警告:请确保您粘贴的是“公钥” (例如 id_rsa.pub 的内容),而不是“私钥”!${NC}" + + read -p "请粘贴您的 SSH 公钥内容 (通常以 ssh-rsa, ssh-ed25519 等开头): " public_key + + if [ -z "$public_key" ]; then + echo -e "${RED}输入为空,操作已取消。${NC}"; sleep 3; return + fi + + if [[ ! "$public_key" == "ssh-"* ]]; then + echo -e "${RED}错误:公钥格式不正确。它应该以 'ssh-' 开头。${NC}"; sleep 3; return + fi + + read -p "您想为哪个用户配置此密钥? [默认为 root]: " target_user + target_user=${target_user:-root} + + if ! id "$target_user" &>/dev/null; then + echo -e "${RED}错误:用户 ${target_user} 不存在于系统中。${NC}"; sleep 3; return + fi + + local target_home + target_home=$(getent passwd "$target_user" | cut -d: -f6) + if [ -z "$target_home" ]; then + echo -e "${RED}错误:无法找到用户 ${target_user} 的主目录。${NC}"; sleep 3; return + fi + + local ssh_dir="${target_home}/.ssh" + local auth_keys_file="${ssh_dir}/authorized_keys" + + echo -e "${YELLOW}正在为用户 ${target_user} 配置密钥 (路径: ${auth_keys_file})...${NC}" + + # 创建目录和文件 + mkdir -p "$ssh_dir" + + # 检查密钥是否已存在 + if grep -qF "$public_key" "$auth_keys_file" 2>/dev/null; then + echo -e "${YELLOW}此密钥已存在于 ${auth_keys_file} 中,无需重复添加。${NC}" + else + # 追加密钥 + echo "$public_key" >> "$auth_keys_file" + echo -e "${GREEN}✅ 密钥已成功追加。${NC}" + fi + + # 设置严格的权限 + echo -e "${YELLOW}正在设置安全权限...${NC}" + chmod 700 "$ssh_dir" + chmod 600 "$auth_keys_file" + + # 设置正确的所有者 + if [ "$(stat -c '%U' "$ssh_dir")" != "$target_user" ]; then + chown -R "${target_user}:${target_user}" "$ssh_dir" + echo -e "${GREEN}✅ 目录所有者已修正为 ${target_user}。${NC}" + fi + + echo -e "\n${YELLOW}══════════════════════════════════════════════════════════════════════${NC}" + echo -e "✅ SSH 密钥添加成功!" + echo -e "${YELLOW}请尝试在新终端中使用您的“私钥”登录。" + echo -e "${YELLOW}测试成功后,强烈建议您执行主菜单中的“切换 SSH 密码登录”选项。${NC}" + echo -e "${YELLOW}══════════════════════════════════════════════════════════════════════${NC}" + + echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s +} + +# 21. 切换 SSH 密码登录 +toggle_ssh_password_login() { + clear + echo -e "${BLUE}--- 切换 SSH 密码登录状态 ---${NC}" + + local config_file="/etc/ssh/sshd_config" + local current_status + current_status=$(grep -E "^\s*#*\s*PasswordAuthentication" "$config_file" | tail -n 1 | awk '{print $2}') + + if [[ "$current_status" == "yes" || -z "$current_status" ]]; then + echo -e "${YELLOW}当前状态:允许密码登录 (PasswordAuthentication is 'yes' or commented out)。${NC}" + read -p "您确定要【禁用】密码登录吗? (y/n): " confirm + if [[ "$confirm" == "y" || "$confirm" == "Y" ]]; then + echo -e "${YELLOW}正在禁用密码登录 (设置 PasswordAuthentication no)...${NC}" + sudo sed -i 's/^[ \t]*#*[ \t]*PasswordAuthentication .*/PasswordAuthentication no/g' "$config_file" + sudo sed -i 's/^[ \t]*#*[ \t]*ChallengeResponseAuthentication .*/ChallengeResponseAuthentication no/g' "$config_file" + echo -e "${YELLOW}正在重启 SSH 服务...${NC}" + sudo systemctl restart sshd + echo -e "\n${RED}================================================================${NC}" + echo -e "✅ 密码登录已禁用!请确保您的密钥可以正常登录!" + echo -e "${RED}如果密钥失效,您可能无法再次登录此服务器!${NC}" + echo -e "${RED}================================================================${NC}" + else + echo -e "${GREEN}操作已取消。${NC}" + fi + else + echo -e "${GREEN}当前状态:密码登录已被禁用 (PasswordAuthentication is 'no')。${NC}" + read -p "您确定要【启用】密码登录吗? (y/n): " confirm + if [[ "$confirm" == "y" || "$confirm" == "Y" ]]; then + echo -e "${YELLOW}正在启用密码登录 (设置 PasswordAuthentication yes)...${NC}" + sudo sed -i 's/^[ \t]*#*[ \t]*PasswordAuthentication .*/PasswordAuthentication yes/g' "$config_file" + echo -e "${YELLOW}正在重启 SSH 服务...${NC}" + sudo systemctl restart sshd + echo -e "\n${GREEN}✅ 密码登录已重新启用。${NC}" + else + echo -e "${GREEN}操作已取消。${NC}" + fi + fi + + echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s +} + +# 22. Nextcloud 优化 +run_nextcloud_optimization() { + ensure_docker_installed || return + if [ ! -d "/root/nextcloud_data" ]; then echo -e "${RED}错误:Nextcloud 套件未安装!${NC}"; sleep 3; return; fi + clear + echo -e "${BLUE}--- “Nextcloud 精装修”计划启动! ---${NC}"; + local nc_domain=$(grep 'NEXTCLOUD_DOMAIN' ${STATE_FILE} | cut -d'=' -f2) + if [ -z "$nc_domain" ]; then echo -e "${RED}错误: 无法从凭证文件找到 Nextcloud 域名!${NC}"; sleep 3; return; fi + sudo docker exec --user www-data nextcloud_app php occ config:system:set trusted_proxies 0 --value='172.17.0.0/16' + sudo docker exec --user www-data nextcloud_app php occ config:system:set overwrite.cli.url --value="https://${nc_domain}" + sudo docker exec --user www-data nextcloud_app php occ config:system:set overwriteprotocol --value='https' + sudo docker exec --user www-data nextcloud_app php occ config:system:set memcache.local --value '\\OC\\Memcache\\Redis' + sudo docker exec --user www-data nextcloud_app php occ config:system:set memcache.locking --value '\\OC\\Memcache\\Redis' + sudo docker exec --user www-data nextcloud_app php occ config:system:set redis host --value 'nextcloud_redis' + sudo docker exec --user www-data nextcloud_app php occ db:add-missing-indices + sudo docker exec --user www-data nextcloud_app php occ maintenance:repair --include-expensive + sudo docker exec --user www-data nextcloud_app php occ config:system:set maintenance_window_start --type=integer --value=1 + sudo docker exec --user www-data nextcloud_app php occ config:system:set default_phone_region --value="CN" + echo -e "\n${GREEN}✅ Nextcloud 精装修完成!${NC}" + echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s +} +# 22.1 Nextcloud 高速风景版 (视听封面 + BBR + 大文件分块) +run_nextcloud_super_boost() { + ensure_docker_installed || return + if [ ! -d "/root/nextcloud_data" ]; then + echo -e "${RED}错误:Nextcloud 套件未安装! 请先执行 3.1 部署。${NC}" + sleep 3 + return + fi + clear + echo -e "${BLUE}--- “Nextcloud 高速风景版” 深度优化计划启动! ---${NC}" + + echo -e "\n${CYAN}[1/5] 正在强制 IPv4 优先 (解决甲骨文等机器的 IPv6 绕路/断连问题)...${NC}" + sudo sed -i 's/#precedence ::ffff:0:0\/96 100/precedence ::ffff:0:0\/96 100/' /etc/gai.conf + grep -q "precedence ::ffff:0:0/96 100" /etc/gai.conf || echo "precedence ::ffff:0:0/96 100" | sudo tee -a /etc/gai.conf >/dev/null + + echo -e "\n${CYAN}[2/5] 正在进行内核网络调优 (开启 BBR 加速 + 扩展缓冲区)...${NC}" + sudo cat > /tmp/sysctl_nc_boost.conf << EOB +net.ipv4.ip_forward = 1 +net.core.default_qdisc=fq +net.ipv4.tcp_congestion_control=bbr +net.core.rmem_max = 67108864 +net.core.wmem_max = 67108864 +net.ipv4.tcp_rmem = 4096 87380 67108864 +net.ipv4.tcp_wmem = 4096 65536 67108864 +net.core.netdev_max_backlog = 250000 +net.ipv4.tcp_max_syn_backlog = 30000 +net.ipv4.tcp_max_tw_buckets = 2000000 +net.ipv4.tcp_tw_reuse = 1 +net.ipv4.tcp_fin_timeout = 10 +vm.swappiness = 10 +EOB + sudo cat /tmp/sysctl_nc_boost.conf | sudo tee -a /etc/sysctl.conf >/dev/null + sudo sysctl -p >/dev/null 2>&1 + rm -f /tmp/sysctl_nc_boost.conf + + echo -e "\n${CYAN}[3/5] 正在安装多核调度器 (irqbalance)...${NC}" + export DEBIAN_FRONTEND=noninteractive + sudo apt-get update >/dev/null 2>&1 + sudo apt-get install irqbalance -y >/dev/null 2>&1 + sudo systemctl enable --now irqbalance >/dev/null 2>&1 + unset DEBIAN_FRONTEND + + echo -e "\n${CYAN}[4/5] 正在为 Nextcloud 容器内部安装视频解码器 (ffmpeg)...${NC}" + sudo docker exec -u root nextcloud_app apt-get update >/dev/null 2>&1 + sudo docker exec -u root nextcloud_app apt-get install -y ffmpeg >/dev/null 2>&1 + + echo -e "\n${CYAN}[5/5] 正在注入视听封面引擎与大文件切片配置...${NC}" + sudo docker exec --user www-data nextcloud_app php occ config:system:set enable_previews --value=true --type=boolean + # 限制预览图最大尺寸(256px 节省资源,缩略图完全够用) + sudo docker exec --user www-data nextcloud_app php occ config:app:set preview max_x --value 256 + sudo docker exec --user www-data nextcloud_app php occ config:app:set preview max_y --value 256 + sudo docker exec --user www-data nextcloud_app php occ config:app:set preview jpeg_quality --value 50 + sudo docker exec --user www-data nextcloud_app php occ config:system:set enabledPreviewProviders 0 --value="OC\Preview\Image" + sudo docker exec --user www-data nextcloud_app php occ config:system:set enabledPreviewProviders 1 --value="OC\Preview\Movie" + sudo docker exec --user www-data nextcloud_app php occ config:system:set enabledPreviewProviders 2 --value="OC\Preview\MP3" + sudo docker exec --user www-data nextcloud_app php occ config:system:set enabledPreviewProviders 3 --value="OC\Preview\TXT" + sudo docker exec --user www-data nextcloud_app php occ config:system:set enabledPreviewProviders 4 --value="OC\Preview\MarkDown" + sudo docker exec --user www-data nextcloud_app php occ config:app:set files max_chunk_size --value 524288000 + + echo -e "\n${GREEN}✅ 基础配置全部注入完成!新上传的视听文件将自动生成封面。${NC}" + + # --- 历史文件缩略图生成模块 --- + echo -e "\n${YELLOW}================================================================${NC}" + echo -e "${YELLOW}检测到您可能需要为以前上传的“老文件”补齐缩略图。${NC}" + echo -e "${YELLOW}此操作需要安装官方的 Preview Generator 插件,并扫描整个网盘。${NC}" + echo -e "${RED}注意:如果您的网盘文件非常多,这一步可能会花费十几分钟甚至更久!${NC}" + echo -e "${YELLOW}================================================================${NC}" + read -p "您想现在立刻为所有历史文件生成封面吗?(y/n): " gen_choice + + if [[ "$gen_choice" == "y" || "$gen_choice" == "Y" ]]; then + echo -e "\n${CYAN}正在下载并安装 Preview Generator 插件...${NC}" + sudo docker exec --user www-data nextcloud_app php occ app:install previewgenerator + echo -e "${CYAN}正在全盘扫描并生成历史缩略图,请耐心等待 (切勿关闭终端窗口)...${NC}" + sudo docker exec --user www-data nextcloud_app php occ preview:generate-all + echo -e "${GREEN}✅ 历史文件缩略图全盘生成完毕!${NC}" + else + echo -e "\n${GREEN}已跳过历史文件生成。您以后可以随时重新运行此选项来补齐封面。${NC}" + fi + + echo -e "\n${GREEN}按任意键返回主菜单...${NC}" + read -n 1 -s +} + +# 22.2 Nextcloud 外部存储管理(一键重挂/扫描) +manage_nextcloud_storage() { + clear + echo -e "${BLUE}=== 🗄️ Nextcloud 外部存储管理 ===${NC}" + echo "" + + if ! docker ps 2>/dev/null | grep -q "nextcloud_app"; then + echo -e "${RED}❌ Nextcloud 未运行!${NC}"; sleep 2; return + fi + + echo -e "${YELLOW}当前外部存储列表:${NC}" + docker exec -u www-data nextcloud_app php occ files_external:list 2>/dev/null + echo "" + echo -e " ${YELLOW}1)${NC} 🔄 重新挂载所有 SSHFS 并重建 Nextcloud 容器(重启后必做)" + echo -e " ${YELLOW}2)${NC} 🔍 扫描所有外部存储文件" + echo -e " ${YELLOW}3)${NC} 🔍 扫描指定外部存储(输入路径)" + echo -e " ${YELLOW}4)${NC} 🔒 清除文件锁(扫描报 is locked 时使用)" + echo -e " ${YELLOW}5)${NC} 📋 查看所有 SSHFS 挂载状态" + echo -e " ${YELLOW}6)${NC} ➕ 添加新的 SSHFS 外部存储" + echo -e " ${YELLOW}7)${NC} 🗑️ 删除指定外部存储" + echo -e " ${YELLOW}q)${NC} 返回主菜单" + echo "" + read -p "请选择: " sc + + case $sc in + 1) + echo -e "${YELLOW}正在重新挂载所有 SSHFS...${NC}" + # 启动所有未运行的 sshfs systemd 服务 + for svc in $(systemctl list-units --type=service --state=inactive,failed 2>/dev/null | grep "^mnt-" | awk '{print $1}'); do + echo -e " 挂载: ${svc}" + sudo systemctl start "$svc" 2>/dev/null + sleep 2 + done + # 列出已运行的挂载 + for svc in $(systemctl list-units --type=service --state=active 2>/dev/null | grep "^mnt-" | awk '{print $1}'); do + echo -e " ${GREEN}✅ 已运行: ${svc}${NC}" + done + echo "" + echo -e "${YELLOW}正在重建 Nextcloud 容器(让容器读取最新 FUSE/SSHFS 挂载)...${NC}" + cd /root/nextcloud_data && docker compose down --remove-orphans && docker compose up -d + echo -e "${YELLOW}等待 Nextcloud 启动 (30s)...${NC}"; sleep 30 + + echo -e "${YELLOW}正在清除文件锁(防止扫描报错)...${NC}" + docker exec -u www-data nextcloud_app php occ maintenance:mode --on 2>/dev/null + # 用 mariadb 命令清锁(mariadb 镜像内置) + local _DB_PASS + _DB_PASS=$(grep "MYSQL_PASSWORD" /root/nextcloud_data/docker-compose.yml 2>/dev/null | head -1 | sed 's/.*MYSQL_PASSWORD: //' | tr -d ' ') + if [ -n "$_DB_PASS" ]; then + docker exec nextcloud_db mariadb -u nextclouduser -p"${_DB_PASS}" nextclouddb \ + -e "DELETE FROM oc_file_locks WHERE 1;" 2>/dev/null \ + && echo -e "${GREEN} ✅ 文件锁已清除${NC}" \ + || echo -e "${YELLOW} ⚠️ 清锁失败(可能影响扫描,但不影响数据)${NC}" + fi + docker exec -u www-data nextcloud_app php occ maintenance:mode --off 2>/dev/null + + echo -e "${YELLOW}扫描外部存储文件...${NC}" + docker exec -u www-data nextcloud_app php occ files:scan --all --quiet 2>/dev/null + echo -e "${GREEN}✅ 完成!刷新 Nextcloud 页面查看文件。${NC}" + ;; + 2) + echo -e "${YELLOW}正在扫描所有外部存储...${NC}" + docker exec -u www-data nextcloud_app php occ files:scan --all + ;; + 3) + read -p "请输入用户名 (默认: $(docker exec -u www-data nextcloud_app php occ user:list 2>/dev/null | head -1 | tr -d ' -')): " NC_USER + [ -z "$NC_USER" ] && NC_USER=$(docker exec -u www-data nextcloud_app php occ user:list 2>/dev/null | head -1 | tr -d ' -') + read -p "请输入存储名称 (例如 TOKYO): " STORE_NAME + docker exec -u www-data nextcloud_app php occ files:scan --path="/${NC_USER}/files/${STORE_NAME}" + ;; + 4) + echo -e "${YELLOW}正在清除 Nextcloud 文件锁...${NC}" + docker exec -u www-data nextcloud_app php occ maintenance:mode --on 2>/dev/null + local _DB_PASS + _DB_PASS=$(grep "MYSQL_PASSWORD" /root/nextcloud_data/docker-compose.yml 2>/dev/null | head -1 | sed 's/.*MYSQL_PASSWORD: //' | tr -d ' ') + if [ -n "$_DB_PASS" ]; then + docker exec nextcloud_db mariadb -u nextclouduser -p"${_DB_PASS}" nextclouddb \ + -e "DELETE FROM oc_file_locks WHERE 1;" 2>/dev/null \ + && echo -e "${GREEN}✅ 文件锁已清除!${NC}" \ + || echo -e "${RED}❌ 清锁失败,请检查数据库容器状态${NC}" + else + echo -e "${RED}❌ 无法读取数据库密码,请检查 /root/nextcloud_data/docker-compose.yml${NC}" + fi + docker exec -u www-data nextcloud_app php occ maintenance:mode --off 2>/dev/null + echo -e "${YELLOW}重新扫描文件...${NC}" + docker exec -u www-data nextcloud_app php occ files:scan --all --quiet 2>/dev/null + echo -e "${GREEN}✅ 完成!${NC}" + ;; + 5) + echo -e "${YELLOW}=== 所有 SSHFS 挂载状态 ===${NC}" + echo "" + echo -e "${CYAN}--- systemd 服务状态 ---${NC}" + for svc in $(systemctl list-units --type=service 2>/dev/null | grep "^mnt-" | awk '{print $1}'); do + local _state + _state=$(systemctl is-active "$svc" 2>/dev/null) + if [ "$_state" == "active" ]; then + echo -e " ${GREEN}✅ $svc (运行中)${NC}" + else + echo -e " ${RED}❌ $svc ($_state)${NC}" + fi + done + echo "" + echo -e "${CYAN}--- 当前 FUSE/SSHFS 挂载点 ---${NC}" + mount | grep -E "fuse|sshfs" | while read -r line; do + echo -e " ${GREEN}$line${NC}" + done + echo "" + echo -e "${CYAN}--- /mnt 目录内容 ---${NC}" + ls -la /mnt/ + ;; + 6) + install_sshfs_mount + ;; + 7) + echo -e "${YELLOW}当前外部存储列表:${NC}" + docker exec -u www-data nextcloud_app php occ files_external:list 2>/dev/null + echo "" + read -p "请输入要删除的 Mount ID: " DEL_ID + if [ -n "$DEL_ID" ]; then + docker exec -u www-data nextcloud_app php occ files_external:delete "$DEL_ID" 2>/dev/null \ + && echo -e "${GREEN}✅ 已删除外部存储 ID=$DEL_ID${NC}" \ + || echo -e "${RED}❌ 删除失败${NC}" + read -p "是否同时卸载本机挂载点?(y/n): " DO_UMOUNT + if [ "$DO_UMOUNT" == "y" ] || [ "$DO_UMOUNT" == "Y" ]; then + read -p "请输入本机挂载路径 (例如 /mnt/TOKYO): " UMOUNT_PATH + fusermount -u "$UMOUNT_PATH" 2>/dev/null || umount "$UMOUNT_PATH" 2>/dev/null + # 停用并删除 systemd 服务 + local _UNAME="mnt-$(echo ${UMOUNT_PATH} | sed 's|^/||' | tr '/' '-')" + systemctl disable --now "${_UNAME}.service" 2>/dev/null + rm -f "/etc/systemd/system/${_UNAME}.service" + systemctl daemon-reload + echo -e "${GREEN}✅ 已卸载并清理 systemd 服务${NC}" + fi + fi + ;; + *) return ;; + esac + + read -n 1 -s -r -p "按任意键返回..." +} + +# 22.2 Nextcloud PDF及画册封面引擎补丁 +run_nextcloud_pdf_patch() { + if ! docker ps | grep -q nextcloud_app; then + echo -e "\n${RED}错误:未检测到运行中的 nextcloud_app 容器!请确认 Nextcloud 正常运行。${NC}" + sleep 3 + return + fi + clear + echo -e "${BLUE}--- Nextcloud PDF/书籍封面引擎补丁启动 ---${NC}" + + echo -e "\n${CYAN}[1/3] 正在为容器安装 PDF 渲染核心组件 (Ghostscript)...${NC}" + docker exec -u root nextcloud_app apt-get update >/dev/null 2>&1 + docker exec -u root nextcloud_app apt-get install -y ghostscript >/dev/null 2>&1 + + echo -e "\n${CYAN}[2/3] 正在解除 ImageMagick 的 PDF 安全限制...${NC}" + # 将默认的 rights="none" 修改为 rights="read|write",释放 PDF 渲染权限 + docker exec -u root nextcloud_app sed -i 's/rights="none" pattern="PDF"/rights="read|write" pattern="PDF"/g' /etc/ImageMagick-6/policy.xml + + echo -e "\n${CYAN}[3/3] 正在向 Nextcloud 注入 PDF 预览提供程序...${NC}" + docker exec --user www-data nextcloud_app php occ config:system:set enabledPreviewProviders 5 --value="OC\Preview\PDF" + # 顺手把矢量图 SVG 的预览也加上 + docker exec --user www-data nextcloud_app php occ config:system:set enabledPreviewProviders 6 --value="OC\Preview\SVG" + + echo -e "\n${GREEN}✅ PDF 封面引擎物理配置完成!新上传的 PDF 将自动生成缩略图。${NC}" + + # --- 历史文件缩略图生成模块 --- + echo -e "\n${YELLOW}================================================================${NC}" + read -p "您想现在立刻为网盘里【已经存在】的历史 PDF 文件生成封面吗?(y/n): " gen_choice + + if [[ "$gen_choice" == "y" || "$gen_choice" == "Y" ]]; then + echo -e "\n${CYAN}正在全盘扫描并生成历史缩略图,请耐心等待 (切勿关闭终端窗口)...${NC}" + docker exec --user www-data nextcloud_app php occ preview:generate-all + echo -e "${GREEN}✅ 历史 PDF 及其他文件缩略图全盘生成完毕!${NC}" + else + echo -e "\n${GREEN}已跳过历史文件生成。${NC}" + fi + + echo -e "\n${GREEN}按任意键返回主菜单...${NC}" + read -n 1 -s +} + +# 23. 服务控制中心 +show_service_control_panel() { + ensure_docker_installed || return + + # ══════════════════════════════════════════════════════════ + # 辅助:_svc_action_docker — 对 docker-compose 服务执行操作 + # ══════════════════════════════════════════════════════════ + _svc_action_docker() { + local s_name="$1" s_path="$2" + local compose_file="${s_path}/docker-compose.yml" + + # ── 判断此服务是否支持媒体库关联 ── + local is_linkable=false + local container_paths=() path_labels=() default_local_paths=() + local menu_options=() + + case "$s_name" in + "Jellyfin 影院") + menu_options=("启动" "停止" "重启" "查看日志" "查看可用媒体路径" "返回") ;; + "Navidrome 音乐") + is_linkable=true + container_paths=("/music"); path_labels=("音乐库"); default_local_paths=("/mnt/Music") ;; + "Immich 相册") + is_linkable=true + container_paths=("/usr/src/app/upload"); path_labels=("照片路径"); default_local_paths=("/mnt/Photos") ;; + "Calibre-Web 书库") + is_linkable=true + container_paths=("/books"); path_labels=("书库路径"); default_local_paths=("/mnt/Books") ;; + "Kavita 阅读器") + is_linkable=true + container_paths=("/manga"); path_labels=("书库路径"); default_local_paths=("/mnt/Books") ;; + "qBittorrent"|"JDownloader"|"MeTube 下载") + is_linkable=true + local dl_c="/downloads" + [[ "$s_name" == "JDownloader" ]] && dl_c="/output" + container_paths=("$dl_c"); path_labels=("下载目录"); default_local_paths=("/mnt/Downloads") ;; + "Nextcloud 数据中心") + menu_options=("启动" "停止" "重启" "查看日志" "添加Rclone跃遷盘(外部存储)" "配置Cron定时任务(文件盘点)" "返回") ;; + esac + + # 若 case 未预设菜单,按通用规则生成 + if [ ${#menu_options[@]} -eq 0 ]; then + if $is_linkable; then + menu_options=("启动" "停止" "重启" "查看日志" "设置媒体库/下载路径" "查看当前路径" "返回") + else + menu_options=("启动" "停止" "重启" "查看日志" "返回") + fi + fi + + clear + echo -e "${BLUE}══════════════════════════════════════════════${NC}" + echo -e "${BLUE} 🎛️ ${s_name}${NC}" + echo -e "${BLUE}══════════════════════════════════════════════${NC}" + + select opt in "${menu_options[@]}"; do + case $opt in + "启动") (cd "$s_path" && sudo docker compose up -d); break ;; + "停止") (cd "$s_path" && sudo docker compose stop); break ;; + "重启") (cd "$s_path" && sudo docker compose restart); break ;; + "查看日志") sudo docker compose -f "$compose_file" logs -f --tail 80; break ;; + + # ── Jellyfin 专属 ── + "查看可用媒体路径") + echo "" + echo -e "${CYAN}Jellyfin 已挂载整个 /mnt,直接在 Jellyfin 界面填写以下路径:${NC}" + echo "" + find /mnt -maxdepth 2 -mindepth 1 -type d 2>/dev/null | sort | while read -r d; do + echo -e " ${GREEN}${d}${NC}" + done + echo "" + echo -e "${YELLOW}⚠️ 报"没有访问权"?选 y 一键重建容器修复权限:${NC}" + read -p "是否重建 Jellyfin 容器?(y/n): " fix_c + if [[ "$fix_c" == "y" || "$fix_c" == "Y" ]]; then + sudo sed -i "s/PUID=1000/PUID=0/g; s/PGID=1000/PGID=0/g" "$compose_file" + (cd "$s_path" && sudo docker compose up -d --force-recreate) + echo -e "${GREEN}✅ 重建完成!${NC}" + fi + read -n 1 -s -r -p "按任意键返回..."; break ;; + + # ── Nextcloud 专属 ── + "添加Rclone跃遷盘(外部存储)") + local rclone_mount_path + rclone_mount_path=$(grep "^RCLONE_MOUNT_PATH=" "${STATE_FILE}" 2>/dev/null | cut -d'=' -f2) + if [ -z "$rclone_mount_path" ]; then echo -e "${RED}❌ Rclone 未配置${NC}"; sleep 3; break; fi + if ! mount | grep -q "${rclone_mount_path}"; then echo -e "${RED}❌ Rclone 挂载点未生效${NC}"; sleep 3; break; fi + if grep -q "${rclone_mount_path}:${rclone_mount_path}" "$compose_file"; then + echo -e "${YELLOW}跃遷盘已添加,无需重复操作。${NC}"; sleep 3; break; fi + local last_vol_line + last_vol_line=$(grep -n 'php-opcache.ini' "$compose_file" | cut -d: -f1) + if [ -n "$last_vol_line" ]; then + local ind + ind=$(grep 'php-opcache.ini' "$compose_file" | awk '{gsub(/[^ ].*/, ""); print}') + sudo sed -i "${last_vol_line}a ${ind}- '${rclone_mount_path}:${rclone_mount_path}' # 由控制中心添加" "$compose_file" + (cd "$s_path" && sudo docker compose up -d --force-recreate) + echo -e "${GREEN}✅ 已添加并重启!进入 Nextcloud → 设置 → 外部存储 添加路径。${NC}" + else + echo -e "${RED}❌ 未找到锚点,请检查 docker-compose.yml${NC}" + fi + sleep 5; break ;; + + "配置Cron定时任务(文件盘点)") + local cur_cron; cur_cron=$(crontab -l 2>/dev/null) + local cron_scan="*/15 * * * * /usr/bin/docker exec --user www-data nextcloud_app php occ files:scan --all > /dev/null 2>&1" + local cron_php="*/5 * * * * /usr/bin/docker exec --user www-data nextcloud_app php -f /var/www/html/cron.php > /dev/null 2>&1" + echo "$cur_cron" | grep -q "files:scan --all" || { (echo "$cur_cron"; echo "$cron_scan") | crontab -; echo -e "${GREEN}✅ 文件扫描任务已添加${NC}"; } + cur_cron=$(crontab -l 2>/dev/null) + echo "$cur_cron" | grep -q "cron.php" || { (echo "$cur_cron"; echo "$cron_php") | crontab -; echo -e "${GREEN}✅ 后台任务已添加${NC}"; } + echo -e "${CYAN}当前 Nextcloud cron:${NC}"; crontab -l | grep 'nextcloud_app' + sleep 5; break ;; + + # ── 媒体库/下载路径关联(Navidrome / qBittorrent / JDownloader / MeTube)── + "设置媒体库/下载路径") + echo "" + echo -e "${CYAN}/mnt 下可用目录:${NC}" + find /mnt -maxdepth 2 -mindepth 1 -type d 2>/dev/null | sort | while read -r d; do echo -e " ${GREEN}${d}${NC}"; done + echo "" + local any_changed=false + for idx in "${!container_paths[@]}"; do + local c_path="${container_paths[$idx]}" label="${path_labels[$idx]}" def="${default_local_paths[$idx]}" + local cur_host + cur_host=$(grep ":${c_path}" "$compose_file" 2>/dev/null | head -1 \ + | sed "s|.*['\"]\\?\\([^'\"]*\\)['\"]\\?:${c_path}.*|\\1|" \ + | sed "s/^[ \t-]*//" | sed "s/['\"]//g") + echo -e "${YELLOW}[${label}] 当前: ${GREEN}${cur_host:-未知}${NC} 默认: ${CYAN}${def}${NC}" + read -p " 输入新路径(回车=默认)> " user_in + local new_host; [ -z "$user_in" ] && new_host="$def" || new_host="$user_in" + [ ! -d "$new_host" ] && { echo -e " ${YELLOW}路径不存在,正在创建...${NC}"; sudo mkdir -p "$new_host"; } + local match_line; match_line=$(grep ":${c_path}" "$compose_file" | head -1) + if [ -z "$match_line" ]; then + echo -e " ${RED}❌ 未找到 ${c_path} 的映射行${NC}"; continue; fi + local ind; ind=$(echo "$match_line" | sed 's/[^ ].*//') + local new_line="${ind}- '${new_host}:${c_path}'" + if sudo sed -i "s|${match_line}|${new_line}|" "$compose_file" 2>/dev/null; then + echo -e " ${GREEN}✅ [${label}] → ${new_host}${NC}"; any_changed=true + else + echo -e " ${RED}❌ 替换失败${NC}"; fi + echo "" + done + if $any_changed; then + (cd "$s_path" && sudo docker compose up -d --force-recreate) + echo -e "${GREEN}✅ 已重启,新路径生效!${NC}" + fi + read -n 1 -s -r -p "按任意键返回..."; break ;; + + "查看当前路径") + echo "" + for idx in "${!container_paths[@]}"; do + local c_path="${container_paths[$idx]}" label="${path_labels[$idx]}" + local cur_host + cur_host=$(grep ":${c_path}" "$compose_file" 2>/dev/null | head -1 \ + | sed "s|.*['\"]\\?\\([^'\"]*\\)['\"]\\?:${c_path}.*|\\1|" \ + | sed "s/^[ \t-]*//" | sed "s/['\"]//g") + echo -e " ${label}: ${GREEN}${cur_host:-未找到}${NC} → 容器内 ${c_path}" + done + read -n 1 -s -r -p "按任意键返回..."; break ;; + + "返回") return ;; + *) echo "无效选项" ;; + esac + done + } + + # ══════════════════════════════════════════════════════════ + # 辅助:_svc_action_systemd — 对 systemd 服务执行操作 + # ══════════════════════════════════════════════════════════ + _svc_action_systemd() { + local s_name="$1" svc_unit="$2" + clear + echo -e "${BLUE}══════════════════════════════════════════════${NC}" + echo -e "${BLUE} 🎛️ ${s_name}${NC}" + echo -e "${BLUE}══════════════════════════════════════════════${NC}" + select opt in "启动" "停止" "重启" "查看日志" "查看状态" "返回"; do + case $opt in + "启动") sudo systemctl start "$svc_unit"; echo -e "${GREEN}✅ 已启动${NC}"; sleep 2; break ;; + "停止") sudo systemctl stop "$svc_unit"; echo -e "${YELLOW}⏹️ 已停止${NC}"; sleep 2; break ;; + "重启") sudo systemctl restart "$svc_unit"; echo -e "${GREEN}✅ 已重启${NC}"; sleep 2; break ;; + "查看日志") sudo journalctl -u "$svc_unit" -f --no-pager -n 80; break ;; + "查看状态") sudo systemctl status "$svc_unit"; read -n 1 -s -r -p "按任意键返回..."; break ;; + "返回") return ;; + *) echo "无效选项" ;; + esac + done + } + + # ══════════════════════════════════════════════════════════ + # 辅助:_svc_action_glances — Glances 无 compose,用 docker 直接管理 + # ══════════════════════════════════════════════════════════ + _svc_action_glances() { + clear + echo -e "${BLUE}══════════════════════════════════════════════${NC}" + echo -e "${BLUE} 🎛️ Glances 资源监控${NC}" + echo -e "${BLUE}══════════════════════════════════════════════${NC}" + select opt in "启动" "停止" "重启" "查看日志" "返回"; do + case $opt in + "启动") sudo docker start glances_app; echo -e "${GREEN}✅ 已启动${NC}"; sleep 2; break ;; + "停止") sudo docker stop glances_app; echo -e "${YELLOW}⏹️ 已停止${NC}"; sleep 2; break ;; + "重启") sudo docker restart glances_app; echo -e "${GREEN}✅ 已重启${NC}"; sleep 2; break ;; + "查看日志") sudo docker logs -f --tail 80 glances_app; break ;; + "返回") return ;; + *) echo "无效选项" ;; + esac + done + } + + # ══════════════════════════════════════════════════════════ + # 主循环 + # ══════════════════════════════════════════════════════════ + while true; do + clear + echo -e "${BLUE}══════════════════════════════════════════════════════════════════════════════════════════════════════${NC}" + echo -e "${BLUE} 🎛️ 服务控制中心 — 对已部署服务进行管理${NC}" + echo -e "${BLUE}══════════════════════════════════════════════════════════════════════════════════════════════════════${NC}" + + # ── 状态检测辅助函数 ── + _docker_status() { + local path="$1" + if sudo docker compose -f "${path}/docker-compose.yml" ps -q 2>/dev/null | grep -q .; then + echo -e "${GREEN}[ 运行中 ]${NC}" + else + echo -e "${RED}[ 已停止 ]${NC}" + fi + } + _systemd_status() { + if systemctl is-active --quiet "$1" 2>/dev/null; then + echo -e "${GREEN}[ 运行中 ]${NC}" + else + echo -e "${RED}[ 已停止 ]${NC}" + fi + } + _docker_ctr_status() { + if docker inspect --format='{{.State.Running}}' "$1" 2>/dev/null | grep -q true; then + echo -e "${GREEN}[ 运行中 ]${NC}" + else + echo -e "${RED}[ 已停止 ]${NC}" + fi + } + + # ── Docker 服务列表(name:path) ── + local docker_services=( + "Nextcloud 数据中心:/root/nextcloud_data" + "OnlyOffice 办公室:/root/onlyoffice_data" + "WordPress 博客:/root/wordpress_data" + "AI 大脑 (Ollama+WebUI):/root/ai_stack" + "Jellyfin 影院:/root/jellyfin_data" + "Navidrome 音乐:/root/navidrome_data" + "Immich 相册:/root/immich_data" + "Calibre-Web 书库:/root/calibre_web_data" + "Kavita 阅读器:/root/kavita_data" + "Gotify 推送:/root/gotify_data" + "Actual Budget 记账:/root/actual_budget_data" + "Excalidraw 白板:/root/excalidraw_data" + "Joplin Server 笔记:/root/joplin_data" + "Trilium Notes 知识库:/root/trilium_data" + "Grafana 监控:/root/grafana_data" + "Miniflux RSS:/root/miniflux_data" + "Gitea 代码仓库:/root/gitea_data" + "Memos 笔记:/root/memos_data" + "qBittorrent:/root/qbittorrent_data" + "JDownloader:/root/jdownloader_data" + "MeTube 下载:/root/ytdlp_data" + "Draw.io 绘图:/root/drawio_data" + "N8N 工作流:/root/n8n_data" + "SillyTavern AI酒馆:/root/sillytavern_data" + "Vless 节点:/root/vless_data" + "Uptime Kuma 监控:/root/uptime_kuma_data" + "Home Assistant:/root/home_assistant_data" + ) + + # ── systemd 服务列表(name:unit) ── + local systemd_services=( + "Syncthing 文件同步:syncthing@root" + "Fail2ban 防暴力破解:fail2ban" + "XRDP 远程桌面:xrdp" + "Hysteria 2 加速:hysteria-server" + "Cloudflare Tunnel:cloudflared" + ) + + local idx=1 + declare -a slot_type=() # "docker" | "systemd" | "glances" + declare -a slot_name=() + declare -a slot_path=() # docker: compose dir; systemd: unit name + + # ── 渲染 Docker 服务分组 ── + echo "" + echo -e "${GREEN} ── 🐳 Docker 服务 ──────────────────────────────────────────────────────────────${NC}" + for entry in "${docker_services[@]}"; do + local sname="${entry%%:*}" spath="${entry#*:}" + if [ -f "${spath}/docker-compose.yml" ]; then + local st; st=$(_docker_status "$spath") + printf " ${YELLOW}%2d)${NC} %-28s %b\n" "$idx" "$sname" "$st" + slot_type+=("docker"); slot_name+=("$sname"); slot_path+=("$spath") + idx=$((idx+1)) + fi + done + + # ── Glances 单独一行(无 compose,用 docker 容器直接管理)── + if docker inspect glances_app &>/dev/null 2>&1; then + local gst; gst=$(_docker_ctr_status "glances_app") + printf " ${YELLOW}%2d)${NC} %-28s %b\n" "$idx" "Glances 资源监控" "$gst" + slot_type+=("glances"); slot_name+=("Glances 资源监控"); slot_path+=("") + idx=$((idx+1)) + fi + + # ── 渲染 systemd 服务分组 ── + local has_systemd=false + local systemd_buf="" + for entry in "${systemd_services[@]}"; do + local sname="${entry%%:*}" sunit="${entry#*:}" + if systemctl list-unit-files 2>/dev/null | grep -q "^${sunit}"; then + local st; st=$(_systemd_status "$sunit") + systemd_buf+=$(printf " ${YELLOW}%2d)${NC} %-28s %b\n" "$idx" "$sname" "$st") + systemd_buf+="\n" + slot_type+=("systemd"); slot_name+=("$sname"); slot_path+=("$sunit") + idx=$((idx+1)) + has_systemd=true + fi + done + if $has_systemd; then + echo "" + echo -e "${YELLOW} ── ⚙️ 系统服务 ─────────────────────────────────────────────────────────────────${NC}" + echo -e "$systemd_buf" + fi + + # ── Rclone 挂载状态(只读展示,不可操作)── + local rclone_mounts + rclone_mounts=$(mount 2>/dev/null | grep "fuse.sshfs\|rclone" | wc -l) + if [ "$rclone_mounts" -gt 0 ]; then + echo "" + echo -e "${CYAN} ── 📡 网盘挂载 ($rclone_mounts 个活跃) — 管理请用菜单 10.3 ──────────────────────────────${NC}" + mount 2>/dev/null | grep "fuse.sshfs\|rclone" | while read -r line; do + local mp; mp=$(echo "$line" | awk '{print $3}') + echo -e " ${GREEN}✅ ${mp}${NC}" + done + fi + + echo "" + echo -e "${BLUE}══════════════════════════════════════════════════════════════════════════════════════════════════════${NC}" + echo -e " ${YELLOW}b)${NC} 返回主菜单" + echo "" + read -p " 请输入编号选择服务: " svc_choice + + [[ "$svc_choice" == "b" || "$svc_choice" == "B" ]] && break + + if ! [[ "$svc_choice" =~ ^[0-9]+$ ]] || [ "$svc_choice" -lt 1 ] || [ "$svc_choice" -gt $((idx-1)) ]; then + echo -e "${RED}无效选择!${NC}"; sleep 2; continue + fi + + local si=$((svc_choice-1)) + local chosen_type="${slot_type[$si]}" + local chosen_name="${slot_name[$si]}" + local chosen_path="${slot_path[$si]}" + + case "$chosen_type" in + "docker") _svc_action_docker "$chosen_name" "$chosen_path" ;; + "systemd") _svc_action_systemd "$chosen_name" "$chosen_path" ;; + "glances") _svc_action_glances ;; + esac + + sleep 1 + done +} + + +# 24. 查看密码与数据路径 +show_credentials() { + if [ ! -f "${STATE_FILE}" ]; then echo -e "\n${YELLOW}尚未开始装修,没有凭证信息。${NC}"; sleep 2; return; fi + clear + echo -e "${RED}==================== 🔑 【重要凭证保险箱】 🔑 ====================${NC}" + + # --- 特殊处理 Nextcloud --- + if grep -q "## Nextcloud 凭证" "${STATE_FILE}"; then + local nc_db_pass=$(grep 'DB_PASSWORD' ${STATE_FILE} | head -n 1 | cut -d'=' -f2) + local nc_domain=$(grep 'NEXTCLOUD_DOMAIN' ${STATE_FILE} | cut -d'=' -f2) + local oo_domain=$(grep 'ONLYOFFICE_DOMAIN' ${STATE_FILE} | cut -d'=' -f2) + local oo_jwt=$(grep 'ONLYOFFICE_JWT_SECRET' ${STATE_FILE} | cut -d'=' -f2) + + echo -e "\n${CYAN}--- Nextcloud 首次安装配置信息 ---${NC}" + echo -e " 数据库用户: ${GREEN}nextclouduser${NC}" + echo -e " 数据库名称: ${GREEN}nextclouddb${NC}" + echo -e " 数据库主机: ${GREEN}db${NC}" + echo -e " 数据库密码: ${GREEN}${nc_db_pass}${NC}" + echo "" + echo -e "${CYAN}--- Nextcloud & OnlyOffice 访问与集成信息 ---${NC}" + echo -e " Nextcloud 访问域名: ${GREEN}https://${nc_domain}${NC}" + echo -e " OnlyOffice 访问域名: ${GREEN}https://${oo_domain}${NC}" + echo -e " OnlyOffice JWT Secret: ${GREEN}${oo_jwt}${NC}" + fi + + # --- VLESS 节点信息 --- + if grep -q "VLESS_DOMAIN" "${STATE_FILE}"; then + local vless_domain=$(grep 'VLESS_DOMAIN' ${STATE_FILE} | head -n 1 | cut -d'=' -f2) + local vless_uuid=$(grep 'VLESS_UUID' ${STATE_FILE} | head -n 1 | cut -d'=' -f2) + + if [ -n "$vless_domain" ] && [ -n "$vless_uuid" ]; then + local vless_link="vless://${vless_uuid}@${vless_domain}:443?encryption=none&security=tls&type=ws&host=${vless_domain}&path=%2F#VPS-Tunnel-VLESS" + echo -e "\n${CYAN}--- “科学上网” VLESS 节点信息 ---${NC}" + echo -e " 节点域名: ${GREEN}${vless_domain}${NC}" + echo -e " 节点 UUID: ${GREEN}${vless_uuid}${NC}" + echo -e " VLESS 链接: ${CYAN}${vless_link}${NC}" + fi + fi + + # --- 循环显示其他凭证 --- + local current_section="" + while IFS= read -r line; do + if [[ "$line" == "## Nextcloud 凭证"* || "$line" == "## “科学上网” VLESS 节点"* ]]; then + continue + elif [[ "$line" == "## "* ]]; then + current_section=$(echo "$line" | sed -e 's/## //' -e 's/ (.*//') + echo -e "\n${CYAN}--- ${current_section} ---${NC}" + elif [[ -n "$line" && ! "$line" =~ ^# ]]; then + local key=$(echo "$line" | cut -d'=' -f1) + local value=$(echo "$line" | cut -d'=' -f2-) + if [[ "$key" == *"DOMAIN"* ]]; then + echo -e " 访问域名 : ${GREEN}https://${value}${NC}" + elif [[ "$key" == *"PORT"* ]]; then + echo -e " 本地端口 : ${DIM}${value}${NC}" + elif [[ "$key" == *"PASSWORD"* || "$key" == *"PASS"* || "$key" == *"SECRET"* || "$key" == *"KEY"* ]]; then + echo -e " 密码/密钥 : ${YELLOW}${value}${NC}" + elif [[ "$key" == *"USER"* || "$key" == *"ADMIN"* ]]; then + echo -e " 用户名 : ${value}" + elif [[ "$key" == *"NOTE"* ]]; then + echo -e " ${DIM}提示: ${value}${NC}" + elif [[ "$key" == "CLOUDFLARE_TOKEN" || "$key" == "VLESS_UUID" || "$key" == "N8N_ENCRYPTION_KEY" ]]; then + continue + elif [[ -n "$value" ]]; then + echo -e " ${key}: ${value}" + fi + fi + done < "${STATE_FILE}" + + # --- qBittorrent 初始密码 --- + if [ -d "/root/qbittorrent_data" ]; then + echo -e "\n${CYAN}--- qBittorrent 初始密码 ---${NC}" + echo " qBittorrent 的初始密码在首次启动的日志中。请退出本面板后执行:" + echo -e " ${YELLOW}sudo docker logs qbittorrent_app${NC}" + echo " (在日志中找到 The Web UI administrator password is: xxxxxxxx 这一行)" + fi + + # --- 应用数据目录 --- + echo -e "\n${CYAN}--- 应用数据目录 ---${NC}" + [ -d "/mnt/Music" ] && echo " 🎵 音乐库 (Navidrome/Jellyfin): /mnt/Music" + [ -d "/mnt/Movies" ] && echo " 🎬 电影库 (Jellyfin): /mnt/Movies" + [ -d "/mnt/TVShows" ] && echo " 📺 电视剧库 (Jellyfin): /mnt/TVShows" + [ -d "/mnt/Downloads" ] && echo " 🔽 默认下载目录: /mnt/Downloads" + if grep -q "RCLONE_MOUNT_PATH" "${STATE_FILE}"; then + echo " ☁️ Rclone 网盘挂载点: $(grep 'RCLONE_MOUNT_PATH' ${STATE_FILE} | cut -d'=' -f2)" + fi + + echo -e "\n${RED}========================================================================${NC}" + read -n 1 -s -r -p "按任意键返回主菜单..." +} + +# --- VPS信息查看函数 --- +show_vps_info() { + clear + echo -e "${BLUE}══════════════════════════════════════════════════════════════${NC}" + echo -e "${BLUE} 📊 VPS 服务器信息${NC}" + echo -e "${BLUE}══════════════════════════════════════════════════════════════${NC}" + + local ipv4; ipv4=$(curl -s4 --max-time 5 https://ifconfig.me/ip 2>/dev/null || echo "获取失败") + local os_info; os_info=$(source /etc/os-release && echo "$PRETTY_NAME") + local cpu_info; cpu_info=$(grep "model name" /proc/cpuinfo | head -1 | cut -d: -f2 | sed 's/^[ \t]*//') + local cpu_cores; cpu_cores=$(nproc) + local uptime_info; uptime_info=$(uptime -p 2>/dev/null || uptime) + + echo "" + echo -e "${CYAN} 🌐 公网 IP :${NC} ${GREEN}${ipv4}${NC}" + echo -e "${CYAN} 🐧 操作系统 :${NC} ${GREEN}${os_info}${NC}" + echo -e "${CYAN} 💻 CPU 型号 :${NC} ${GREEN}${cpu_info} (${cpu_cores} 核)${NC}" + echo -e "${CYAN} ⏱️ 运行时间 :${NC} ${GREEN}${uptime_info}${NC}" + + echo "" + echo -e "${YELLOW} ── 内存使用 ──────────────────────────────────────────────${NC}" + free -h | sed 's/^/ /' + + echo "" + echo -e "${YELLOW} ── 磁盘使用 ──────────────────────────────────────────────${NC}" + df -h | grep -v "tmpfs\|overlay\|udev" | sed 's/^/ /' + + echo "" + echo -e "${YELLOW} ── 目录占用详情 ──────────────────────────────────────────${NC}" + echo -e " ${CYAN}Docker 镜像与容器:${NC}" + du -sh /var/lib/docker/ 2>/dev/null | sed 's/^/ /' + echo -e " ${CYAN}应用数据目录(从小到大):${NC}" + du -sh /root/*/ 2>/dev/null | sort -h | sed 's/^/ /' + + echo "" + echo -e "${YELLOW} ── 网盘挂载状态 ──────────────────────────────────────────${NC}" + local mount_count; mount_count=$(mount 2>/dev/null | grep -c "fuse\|sshfs" || echo 0) + if [ "$mount_count" -gt 0 ]; then + mount | grep "fuse\|sshfs" | awk '{print " ✅ "$3}' + else + echo -e " ${DIM}无网盘挂载${NC}" + fi + + echo "" + echo -e "${BLUE}══════════════════════════════════════════════════════════════${NC}" + echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s +} + +# 26. 科学上网工具箱 +install_vless_node() { + ensure_docker_installed || return; check_tunnel_installed || return + clear; echo -e "${BLUE}--- “科学上网”工具箱 ---${NC}" + read -p "请输入您为VLESS节点准备的域名 (例如 v.example.com): " VLESS_DOMAIN + if [ -z "$VLESS_DOMAIN" ]; then echo -e "${RED}域名不能为空!${NC}"; sleep 2; return; fi + + local UUID; UUID=$(cat /proc/sys/kernel/random/uuid) + + mkdir -p /root/vless_data; cat > /root/vless_data/docker-compose.yml < /root/vless_data/config.json <> ${STATE_FILE} + echo "VLESS_DOMAIN=${VLESS_DOMAIN}" >> ${STATE_FILE} + echo "VLESS_UUID=${UUID}" >> ${STATE_FILE} + echo -e "\n${GREEN}✅ VLESS 节点部署成功!${NC}" + echo -e "${YELLOW}请使用以下链接导入客户端:${NC}" + echo -e "${CYAN}${VLESS_LINK}${NC}" + else + echo -e "${RED}❌ VLESS 节点部署失败!请检查 Docker 是否正常运行。${NC}" + fi + echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s +} + +# 27. 部署 Socks5 代理 +install_socks5_proxy() { + ensure_docker_installed || return; check_tunnel_installed || return + clear + echo -e "${BLUE}--- “Socks5 代理”部署計劃啟動! ---${NC}" + read -p "請輸入您為 Socks5 代理規劃的子域名 (例如 proxy.example.com): " SOCKS5_DOMAIN + if [ -z "$SOCKS5_DOMAIN" ]; then echo -e "${RED}錯誤:域名不能為空!${NC}"; sleep 2; return; fi + + mkdir -p /root/socks5_proxy_data + cat > /root/socks5_proxy_data/docker-compose.yml <> ${STATE_FILE} + echo "SOCKS5_DOMAIN=${SOCKS5_DOMAIN}" >> ${STATE_FILE} + echo -e "${GREEN}正在為您配置網絡...${NC}" + update_tunnel_config "${SOCKS5_DOMAIN}" "socks://127.0.0.1:1080" "Socks5 Proxy" + + echo -e "\n${GREEN}✅ Socks5 代理部署成功!${NC}" + echo -e "${YELLOW}請在您的客戶端中使用以下資訊配置代理:${NC}" + echo -e " 代理類型: ${CYAN}SOCKS5${NC}" + echo -e " 伺服器地址: ${CYAN}${SOCKS5_DOMAIN}${NC}" + echo -e " 端口: ${CYAN}443${NC}" + echo -e " ${YELLOW}注意:需要客戶端支援 Socks5 over TLS/SSL (例如 v2rayN, Clash 等)。${NC}" + else + echo -e "${RED}❌ Socks5 代理部署失敗!請檢查 Docker 是否正常運行。${NC}" + fi + echo -e "\n${GREEN}按任意键返回主選單...${NC}"; read -n 1 -s +} +# 28. Hysteria 2 暴力加速 (集成版 - IPv6 优先/WARP 修正版) +install_hysteria_node() { + clear + echo -e "${BLUE}--- “Hysteria 2 (歇斯底里)” 暴力加速部署启动! ---${NC}" + echo -e "${YELLOW}注意:此协议需要占用一个公网端口 (UDP),且不走 Cloudflare Tunnel。${NC}" + echo -e "${YELLOW}请确保您的 VPS 防火墙 (安全组) 已放行您设置的端口!${NC}" + + # 检查是否已安装 + if command -v hysteria &> /dev/null; then + echo -e "${RED}检测到 Hysteria 2 已安装!如需重装请先卸载。${NC}"; sleep 3; return + fi + + # 1. 安装基础环境 + echo -e "${CYAN}[1/4] 正在安装 Hysteria 2 核心组件...${NC}" + sudo apt-get update >/dev/null 2>&1 + sudo apt-get install -y curl wget openssl >/dev/null 2>&1 + bash <(curl -fsSL https://get.hy2.sh/) + + # 2. 配置交互 + echo -e "${CYAN}[2/4] 进行参数配置...${NC}" + read -p "请设置连接密码 (留空自动生成): " USER_PASS + [[ -z "$USER_PASS" ]] && USER_PASS=$(openssl rand -base64 16) + + read -p "请设置端口号 (默认 54321): " USER_PORT + [[ -z "$USER_PORT" ]] && USER_PORT=54321 + + # 为防止与 Cloudflare Tunnel 的域名冲突,强制使用自签证书模式 + echo -e "${YELLOW}为避免与您的 Tunnel 域名冲突,脚本自动采用“自签证书”模式。${NC}" + mkdir -p /etc/hysteria + local CERT_FILE="/etc/hysteria/server.crt" + local KEY_FILE="/etc/hysteria/server.key" + openssl req -x509 -nodes -newkey rsa:2048 -keyout $KEY_FILE -out $CERT_FILE -subj "/CN=bing.com" -days 3650 >/dev/null 2>&1 + chmod 644 $CERT_FILE + chmod 600 $KEY_FILE + + # 3. 写入配置文件 + cat < /etc/hysteria/config.yaml +listen: :$USER_PORT +tls: + cert: $CERT_FILE + key: $KEY_FILE +auth: + type: password + password: $USER_PASS +quic: + initStreamReceiveWindow: 8388608 + maxStreamReceiveWindow: 8388608 + initConnReceiveWindow: 20971520 + maxConnReceiveWindow: 20971520 +EOF + chown -R hysteria:hysteria /etc/hysteria + + # 4. 开启 BBR (继承自原脚本) + echo -e "${CYAN}[3/4] 优化系统网络参数 (BBR)...${NC}" + if ! grep -q "net.ipv4.tcp_congestion_control=bbr" /etc/sysctl.conf; then + echo "net.core.default_qdisc=fq" >> /etc/sysctl.conf + echo "net.ipv4.tcp_congestion_control=bbr" >> /etc/sysctl.conf + sysctl -p >/dev/null 2>&1 + fi + + # 启动服务 + systemctl enable --now hysteria-server.service + + # --- 智能 IP 识别 (修正 WARP 干扰) --- + echo -e "${CYAN}[4/4] 正在智能检测真实公网 IP...${NC}" + + # 尝试获取 IPv6 (设置5秒超时) + local IP_V6=$(curl -s6 -m 5 https://ifconfig.me/ip) + # 尝试获取 IPv4 (设置5秒超时) + local IP_V4=$(curl -s4 -m 5 https://ifconfig.me/ip) + + local FINAL_IP="" + local SHARE_LINK="" + + # 逻辑:只要有 IPv6,就强制优先使用 IPv6 + # 原因:对于这就好像 EUserv/Hax 这种机器,IPv4 往往是 WARP 的假 IP,无法入站连接 + if [[ -n "$IP_V6" ]]; then + echo -e "${GREEN}✔ 检测到 IPv6 地址,将优先使用以避开 WARP 干扰。${NC}" + FINAL_IP="$IP_V6" + # IPv6 在链接中必须加上 [] + SHARE_LINK="hysteria2://$USER_PASS@[$FINAL_IP]:$USER_PORT/?sni=bing.com&insecure=1#Hysteria2-IPv6" + # 为了显示好看,把保存到凭证里的 IP 也加上括号 + DISPLAY_IP="[$FINAL_IP]" + elif [[ -n "$IP_V4" ]]; then + echo -e "${YELLOW}未检测到 IPv6,将使用 IPv4 (请注意:如果是 WARP IP 将无法连接)。${NC}" + FINAL_IP="$IP_V4" + SHARE_LINK="hysteria2://$USER_PASS@$FINAL_IP:$USER_PORT/?sni=bing.com&insecure=1#Hysteria2-IPv4" + DISPLAY_IP="$FINAL_IP" + else + echo -e "${RED}错误:无法检测到任何公网 IP!${NC}" + FINAL_IP="<未知IP>" + DISPLAY_IP="<未知IP>" + fi + + # 保存凭证 + echo -e "\n## Hysteria 2 凭证 (部署于: $(date))" >> ${STATE_FILE} + echo "HY2_IP=${DISPLAY_IP}" >> ${STATE_FILE} + echo "HY2_PORT=${USER_PORT}" >> ${STATE_FILE} + echo "HY2_PASSWORD=${USER_PASS}" >> ${STATE_FILE} + echo "HY2_LINK=${SHARE_LINK}" >> ${STATE_FILE} + + echo -e "\n${GREEN}✅ Hysteria 2 部署成功!${NC}" + echo -e "${YELLOW}分享链接已保存到凭证箱 (选项 24)。${NC}" + + if [[ -n "$IP_V6" ]]; then + echo -e "${CYAN}提示:已为您自动适配 IPv6 格式 [$FINAL_IP]${NC}" + echo -e "${CYAN}小白请注意:您的本地网络(手机/电脑)必须支持 IPv6 才能连接此节点!${NC}" + fi + + echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; + read -n 1 -s +} + +# 41. 整机备份(独立入口) +run_backup_setup() { + run_wrap_up_tasks_inner_backup() { + local script_path="/usr/local/bin/vps_backup.sh" + echo -e "\n${CYAN}--- 创建终极安全的"冷备份"脚本 ---${NC}" + echo -e "${YELLOW}这会创建一个带安全停机机制的备份脚本,防止数据库损坏。${NC}" + read -p "您确定要创建吗? (y/n): " confirm + [[ "$confirm" != "y" && "$confirm" != "Y" ]] && echo -e "${YELLOW}已取消。${NC}" && return + local default_backup_path + # 自动检测已挂载的网盘作为默认备份路径 + local first_mount; first_mount=$(mount | grep "fuse\|sshfs" | awk '{print $3}' | head -1) + [ -n "$first_mount" ] && default_backup_path="${first_mount}/VPS-Backups" || default_backup_path="/mnt/Backup/VPS-Backups" + echo -e "${CYAN}检测到可用挂载点,备份默认路径建议: ${GREEN}${default_backup_path}${NC}" + read -p "留空使用上方默认值: " custom_backup_path + local BACKUP_DEST_DIR="${custom_backup_path:-$default_backup_path}" + echo -e "${GREEN}备份目标: ${BACKUP_DEST_DIR}${NC}"; sleep 1 + cat < "$script_path" +#!/bin/bash +BACKUP_DEST_DIR="${BACKUP_DEST_DIR}" +LOG_FILE="/var/log/vps_backup.log" +TIMESTAMP=\$(date +"%Y-%m-%d_%H-%M-%S") +BACKUP_FILENAME="vps_data_backup_\${TIMESTAMP}.tar.gz" +LOCAL_TEMP_PATH="/tmp/\${BACKUP_FILENAME}" +BACKUP_PATHS="/etc /home /usr/local/bin /root/.vps_setup_credentials /root/.cloudflared /root/.config/rclone" +DATA_DIRS=\$(find /root -maxdepth 1 -type d -name "*_data" 2>/dev/null) +COMPOSE_FILES=\$(find /root -maxdepth 2 -name "docker-compose.yml" 2>/dev/null) +log_message() { echo "\$(date +"%Y-%m-%d %H:%M:%S") - \$1" | sudo tee -a \$LOG_FILE; } +log_message "================== 冷备份任务开始 ==================" +mkdir -p "\$BACKUP_DEST_DIR" +log_message "正在清理旧备份..." +find "\$BACKUP_DEST_DIR" -name "vps_data_backup_*.tar.gz" -type f -delete +log_message "【安全机制】暂停所有容器..." +docker stop \$(docker ps -q) > /dev/null 2>&1 +log_message "正在打包数据..." +tar -czf "\$LOCAL_TEMP_PATH" --exclude="*/.cache/*" --ignore-failed-read \$BACKUP_PATHS \$COMPOSE_FILES \$DATA_DIRS +log_message "【安全机制】唤醒所有容器..." +docker start \$(docker ps -a -q) > /dev/null 2>&1 +if [ \$? -eq 0 ]; then + mv "\$LOCAL_TEMP_PATH" "\$BACKUP_DEST_DIR/" + log_message "✅ 备份完成: \${BACKUP_DEST_DIR}/\${BACKUP_FILENAME}" +else + log_message "❌ 打包失败!"; rm -f "\$LOCAL_TEMP_PATH"; exit 1 +fi +log_message "================== 备份任务结束 ==================" +EOF + chmod +x "$script_path" + echo -e "${GREEN}✔ 备份脚本已创建: $script_path${NC}" + echo "" + read -p "备份频率 (1=每小时, 2=每天凌晨4点) [默认2]: " freq_choice + [[ -z "$freq_choice" ]] && freq_choice="2" + local cron_job + [[ "$freq_choice" == "1" ]] && cron_job="5 * * * * /bin/bash $script_path" || cron_job="5 4 * * * /bin/bash $script_path" + (crontab -l 2>/dev/null | grep -v "$script_path"; echo "$cron_job") | crontab - + echo -e "${GREEN}✔ 定时备份已设置。手动测试: sudo $script_path${NC}" + read -p "按 Enter 键继续..." + } + run_wrap_up_tasks_inner_backup + echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s +} + +# 30. 收尾工作与优化脚本 +run_wrap_up_tasks() { + _patch_vps_installer() { + local installer_path="/root/vps_installer.sh" + echo -e "\n${CYAN}--- 步骤 1: 修正 vps_installer.sh 的凭证覆盖 Bug ---${NC}" + if [ -f "$installer_path" ]; then + if grep -q '> ${STATE_FILE}' "$installer_path"; then + sed -i "s/> \${STATE_FILE}/>> \${STATE_FILE}/g" "$installer_path" + sed -i "s/echo \"## Nextcloud 套件憑證.*>>/>> \${STATE_FILE}/echo \"## Nextcloud 套件憑證 ( 部署于 : \$(date))\" > \${STATE_FILE}/" "$installer_path" + echo -e "${GREEN}✔ 已自動修補 $installer_path,解決了憑證文件被覆蓋的問題。${NC}" + else + echo -e "${YELLOW}ℹ️ 檢測到 vps_installer.sh 似乎已經被修正過,跳過此步驟。${NC}" + fi + else + echo -e "${YELLOW}⚠️ 未在 $installer_path 找到安裝腳本,跳過修補步驟。${NC}" + fi + read -p "按 Enter 鍵繼續..." + } + + _create_ai_script() { + local script_path="/usr/local/bin/ask_ai_a_question.sh"; local cron_job="0 * * * * /bin/bash -c '/bin/sleep \$((RANDOM % 3600)) && $script_path'" + echo -e "\n${CYAN}--- 步驟 3: 創建並部署“AI 智能提問官”腳本 (日誌增強版) ---${NC}" + echo -e "${YELLOW}這將創建一個腳本並設置定時任務,每小時隨機向您的 AI 提問,並將對話記錄到日誌。${NC}" + read -p "您確定要創建嗎? (y/n): " confirm + if [[ "$confirm" == "y" || "$confirm" == "Y" ]]; then + cat <<'EOF' > "$script_path" +#!/bin/bash +QUESTIONS=( "请写一首关于悉尼歌剧院的诗。" "用一个简单的比喻解释一下什么是“量子计算”。" "给我讲一个关于程序员的简短冷笑话。" "如果AI统治了世界,人类最好的结局会是什么?" "推荐一道适合在办公室当午餐的、简单的食谱。" "澳大利亚历史上有什么不出名但却非常有趣的事件吗?" ) +MODEL_NAME="llama3:8b"; LOG_FILE="/var/log/ask_ai.log" +RANDOM_QUESTION=${QUESTIONS[$((RANDOM % ${#QUESTIONS[@]}))]} +echo "==================================================" >> "$LOG_FILE" +echo "時間: $(date)" >> "$LOG_FILE"; echo "提問: $RANDOM_QUESTION" >> "$LOG_FILE"; echo "回答:" >> "$LOG_FILE" +echo "$RANDOM_QUESTION" | /usr/bin/docker exec -i ollama ollama run "$MODEL_NAME" >> "$LOG_FILE" 2>&1 +echo "" >> "$LOG_FILE" +EOF + chmod +x "$script_path"; echo -e "${GREEN}✔ 已成功創建腳本: $script_path${NC}" + (crontab -l 2>/dev/null | grep -v "$script_path"; echo "$cron_job") | crontab - + echo -e "${GREEN}✔ 已成功設置定時任務 (crontab)。${NC}"; echo -e "${YELLOW}您可以通過 'sudo $script_path' 來手動測試它。${NC}"; echo -e "${YELLOW}測試後,使用 'cat /var/log/ask_ai.log' 來查看 AI 的回答。${NC}" + else echo -e "${YELLOW}已跳過創建 AI 腳本。${NC}"; fi + read -p "按 Enter 鍵繼續..." + } + _create_backup_script() { + local script_path="/usr/local/bin/vps_backup.sh" + echo -e "\n${CYAN}--- 步骤 4: 创建终极安全的“冷备份”脚本 ---${NC}" + echo -e "${YELLOW}这会创建一个带“安全停机机制”的备份脚本,彻底杜绝数据库损坏。${NC}" + read -p "您确定要创建吗? (y/n): " confirm + if [[ "$confirm" == "y" || "$confirm" == "Y" ]]; then + local default_backup_path="/mnt/onedrive/VPS-Backups" + echo -e "${CYAN}请输入您希望在 Rclone 网盘中存放备份的目标文件夹路径。${NC}" + read -p "留空将使用默认值 [${default_backup_path}]: " custom_backup_path + local BACKUP_DEST_DIR="${custom_backup_path:-$default_backup_path}" + echo -e "${GREEN}备份目标文件夹已成功设置为: ${BACKUP_DEST_DIR}${NC}"; sleep 2 + + cat < "$script_path" +#!/bin/bash +BACKUP_DEST_DIR="${BACKUP_DEST_DIR}" +LOG_FILE="/var/log/vps_backup.log"; TIMESTAMP=\$(date +"%Y-%m-%d_%H-%M-%S") +BACKUP_FILENAME="vps_data_backup_\${TIMESTAMP}.tar.gz"; +LOCAL_TEMP_PATH="/tmp/\${BACKUP_FILENAME}" + +BACKUP_PATHS="/etc /home /usr/local/bin /root/.vps_setup_credentials /root/.cloudflared /root/.config/rclone" +DATA_DIRS=\$(find /root -maxdepth 1 -type d -name "*_data" 2>/dev/null) +COMPOSE_FILES=\$(find /root -maxdepth 2 -name "docker-compose.yml" 2>/dev/null) + +log_message() { echo "\$(date +"%Y-%m-%d %H:%M:%S") - \$1" | sudo tee -a \$LOG_FILE; } + +log_message "================== 应用数据冷备份任务开始 ==================" +mkdir -p "\$BACKUP_DEST_DIR" +log_message "正在清理旧备份..."; +find "\$BACKUP_DEST_DIR" -name "vps_data_backup_*.tar.gz" -type f -delete + +# [核心优化] 备份前安全暂停所有 Docker 容器,防止数据写入产生脏数据 +log_message "【安全机制】正在暂停所有容器运行..." +docker stop \$(docker ps -q) > /dev/null 2>&1 + +log_message "正在打包核心配置与应用数据..." +tar -czf "\$LOCAL_TEMP_PATH" \ + --exclude="*/.cache/*" \ + --exclude="*/.cache" \ + --ignore-failed-read \ + \$BACKUP_PATHS \$COMPOSE_FILES \$DATA_DIRS + +# [核心优化] 打包完成后,立即唤醒所有容器 +log_message "【安全机制】打包完成,正在唤醒所有容器恢复服务..." +docker start \$(docker ps -a -q) > /dev/null 2>&1 + +if [ \$? -eq 0 ]; then + log_message "正在上传到云盘..." + mv "\$LOCAL_TEMP_PATH" "\$BACKUP_DEST_DIR/"; + if [ \$? -eq 0 ]; then + log_message "✅ 上传成功!文件位于: \${BACKUP_DEST_DIR}/\${BACKUP_FILENAME}"; + else + log_message "❌ 错误:上传失败!请检查挂载点。"; rm -f "\$LOCAL_TEMP_PATH"; exit 1; + fi +else + log_message "❌ 错误:打包失败!"; rm -f "\$LOCAL_TEMP_PATH"; exit 1; +fi +log_message "================== 备份任务结束 ==================" +EOF + chmod +x "$script_path"; + echo -e "${GREEN}✔ 已成功创建安全的冷备份脚本: $script_path${NC}" + + echo -e "${CYAN}--- 接下来, 设置自动备份定时任务 ---${NC}" + read -p "请选择备份频率 (1=每小时, 2=每天) [默认: 2]: " freq_choice + read -p "是否随机化执行时间 (y/n) [默认: y]: " rand_choice + + [[ -z "$freq_choice" ]] && freq_choice="2" + [[ -z "$rand_choice" ]] && rand_choice="y" + + local cron_job="" + local message="" + local full_command="/bin/bash $script_path" + + if [[ "$freq_choice" == "1" ]]; then + if [[ "$rand_choice" == "y" || "$rand_choice" == "Y" ]]; then + cron_job="0 * * * * /bin/bash -c '/bin/sleep \$((RANDOM \\% 3540)) && $full_command'" + message="✔ 已设置定时备份 (每小时随机一次)。" + else + cron_job="5 * * * * $full_command" + message="✔ 已设置定时备份 (每小时的第5分钟)。" + fi + else + if [[ "$rand_choice" == "y" || "$rand_choice" == "Y" ]]; then + cron_job="0 0 * * * /bin/bash -c '/bin/sleep \$((RANDOM \\% 86000)) && $full_command'" + message="✔ 已设置定时备份 (每天随机时间)。" + else + cron_job="5 4 * * * $full_command" + message="✔ 已设置定时备份 (每天 4:05 AM)。" + fi + fi + + (crontab -l 2>/dev/null | grep -v "$script_path"; echo "$cron_job") | crontab - + echo -e "${GREEN}${message}${NC}" + echo -e "${YELLOW}您可以随时输入 'sudo $script_path' 手动触发安全备份测试。${NC}" + else + echo -e "${YELLOW}已跳过创建备份脚本。${NC}"; + fi + read -p "按 Enter 键继续..." + } + + while true; do + clear + echo -e "${BLUE}══════════════════════════════════════════════════════════════${NC}" + echo -e "${BLUE} 🔧 收尾工作与优化脚本${NC}" + echo -e "${BLUE}══════════════════════════════════════════════════════════════${NC}" + echo "" + local ai_status="[ 依赖: AI 大脑 (选项 5.1) ]" + [ -d "/root/ai_stack" ] && ai_status="${GREEN}[ ✅ 依赖已满足 ]${NC}" + printf " ${YELLOW}3)${NC} %-42s %b\n" "部署 AI 智能提问官脚本" "${ai_status}" + echo -e " ${YELLOW}4)${NC} 创建整机冷备份脚本(推荐!)" + echo "" + echo -e " ${YELLOW}b)${NC} 返回主菜单" + echo "" + read -p " 请输入您的选择: " task_choice + case $task_choice in + 3) if [ ! -d "/root/ai_stack" ]; then echo -e "${RED}错误:AI 大脑未安装。${NC}"; sleep 3; else _create_ai_script; fi ;; + 4) _create_backup_script ;; + b|B) break ;; *) echo -e "${RED}无效的选项。${NC}"; sleep 2 ;; + esac + done +} + +# 31. 甲骨文开机助手 (oci-helper) +install_oci_helper() { + clear + echo -e "${BLUE}--- “甲骨文开机助手 (oci-helper)” 部署向导 ---${NC}" + echo -e "${YELLOW}此功能将从 GitHub 下载并执行 oci-helper 的官方一键安装脚本。${NC}" + echo -e "${YELLOW}请确保您的 VPS 是甲骨文云 (Oracle Cloud Infrastructure) 服务器。${NC}" + read -p "您确定要继续吗? (y/n): " confirm + if [[ "$confirm" == "y" || "$confirm" == "Y" ]]; then + echo -e "${GREEN}正在运行 oci-helper 安装脚本...${NC}" + bash <(wget -qO- https://github.com/Yohann0617/oci-helper/releases/latest/download/sh_oci-helper_install.sh) + echo -e "\n${GREEN}✅ oci-helper 脚本执行完毕。${NC}" + echo -e "${CYAN}请根据其自身的提示进行后续操作。${NC}" + else + echo -e "${GREEN}操作已取消。${NC}" + fi + echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s +} + +# 32. 服务器每日管家报告系统 +install_mail_report() { + clear + echo -e "${BLUE}--- “服务器每日管家”报告系统安装程序 ---${NC}" + echo -e "${CYAN}正在检查并安装专业邮件工具 (msmtp, vnstat, cron)...${NC}" + + # 声明非交互模式,防止 mailutils/postfix 弹出配置界面导致静默安装卡死 + export DEBIAN_FRONTEND=noninteractive + apt-get update >/dev/null 2>&1 + apt-get install -y msmtp msmtp-mta vnstat mailutils cron >/dev/null 2>&1 + unset DEBIAN_FRONTEND + + echo -e "${GREEN}✅ 所需工具已安装完毕。${NC}"; echo "-------------------------------------------------" + echo -e "${CYAN}请输入您的发件邮箱配置 (用于发送报告):${NC}" + echo -e "${YELLOW}重要提示: 请务必使用您邮箱的“应用密码”或“授权码”!${NC}" + read -p "请输入您的邮箱地址 (例如: yourname@gmail.com): " mail_user + read -sp "请输入上面邮箱的“应用密码”或“授权码”: " mail_pass; echo + read -p "请输入邮箱的 SMTP 服务器地址 (例如: smtp.gmail.com): " mail_server + read -p "请输入 SMTP 端口号 (AOL/Gmail/QQ等通常为 587,默认为 587): " mail_port; mail_port=${mail_port:-587} + read -p "请输入接收报告的邮箱地址 (可以和上面相同): " to_email + echo -e "${CYAN}正在配置专业邮件发送服务 (msmtp)...${NC}" + + # [修复] 恢复标准的换行格式,msmtprc 不支持用分号将多条配置写在同一行 + cat > /etc/msmtprc <> "/etc/mail.rc"; fi + echo -e "${GREEN}✅ 系统 mail 命令现在将由 msmtp 负责!${NC}"; echo "-------------------------------------------------" + + echo -e "${CYAN}正在创建每日报告生成脚本...${NC}" + local report_script_path="/usr/local/bin/daily_server_report.sh" + cat > $report_script_path <<'EOF' +#!/bin/bash +HOSTNAME=$(hostname); CURRENT_TIME=$(date "+%Y-%m-%d %H:%M:%S"); UPTIME=$(uptime -p); LAST_REBOOT=$(who -b | awk '{print $3, $4}') +if [ -f "/var/log/auth.log" ]; then FAILED_LOGINS=$(grep -c "Failed password" /var/log/auth.log || echo "0"); else FAILED_LOGINS="N/A"; fi +[ -z "$FAILED_LOGINS" ] && FAILED_LOGINS=0 +if ! systemctl is-active --quiet vnstat; then systemctl enable vnstat > /dev/null 2>&1 && systemctl start vnstat && sleep 5; fi +INTERFACE=$(ip -o -4 route show to default | awk '{print $5}'); ! vnstat --iflist | grep -q "$INTERFACE" && vnstat -u -i $INTERFACE +TRAFFIC_INFO=$(vnstat -d 1); SUBJECT="【服务器管家报告】来自 $HOSTNAME - $(date "+%Y-%m-%d")" +HTML_BODY="

服务器每日管家报告

主机名: $HOSTNAME

报告时间: $CURRENT_TIME


核心状态摘要:

  • 已持续运行: $UPTIME
  • 最近启动于: $LAST_REBOOT
  • SSH 登录失败次数 (今日): $FAILED_LOGINS 次

今日网络流量报告 (由 vnstat 提供):

$TRAFFIC_INFO

提示: 如果 vnstat 报告显示 'Not enough data available yet',这是正常的,请等待24小时后它才能收集到完整数据。

" +echo "$HTML_BODY" | mail -s "$SUBJECT" -a "Content-Type: text/html" "$1" +EOF + chmod +x $report_script_path; echo -e "${GREEN}✅ 报告生成脚本已创建。${NC}"; echo "-------------------------------------------------" + + echo -e "${CYAN}正在设置定时任务,让脚本在每天 23:30 自动运行...${NC}" + (crontab -l 2>/dev/null | grep -v "$report_script_path" ; echo "30 23 * * * $report_script_path $to_email") | crontab - + echo -e "${GREEN}✅ 定时任务已设置成功!${NC}"; echo "-------------------------------------------------" + + echo -e "${CYAN}正在发送最终测试邮件到 $to_email...${NC}" + echo "这是一封来自【服务器每日管家】最终版脚本的安装成功测试邮件!" | mail -s "【服务器管家】安装成功测试" $to_email + if [ $? -eq 0 ]; then + echo -e "${GREEN}★★★★★ 系统安装完毕!一切完美!★★★★★${NC}"; echo -e "${CYAN}测试邮件已成功发送!请检查您的邮箱。${NC}"; echo -e "${CYAN}第一份正式报告将在今晚 23:30 发送给您。${NC}" + else + echo -e "${RED}最终测试邮件发送失败!请检查您的邮箱配置是否正确。${NC}" + echo -e "${YELLOW}如遇报错,请退出脚本后输入 'cat ~/.msmtp.log' 查看具体错误原因。${NC}" + fi + echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s +} + +# X. 一键深度清理 +system_cleanup() { + clear; echo -e "${BLUE}--- 深度清理与系统优化 ---${NC}\n${YELLOW}即将开始一套大扫除...${NC}"; sleep 2 + echo -e "\n${CYAN}🧹 [1/4] 正在清扫系统更新缓存...${NC}"; sudo apt-get clean; sudo apt-get autoremove -y > /dev/null 2>&1; echo -e "${GREEN}✅ 清理完毕!${NC}"; sleep 1 + echo -e "\n${CYAN}📦 [2/4] 正在清理 Docker 环境...${NC}"; docker system prune -f > /dev/null 2>&1; echo -e "${GREEN}✅ 清理完毕!${NC}"; sleep 1 + echo -e "\n${CYAN}📜 [3/4] 正在压缩系统日志...${NC}"; sudo journalctl --vacuum-size=50M > /dev/null 2>&1; echo -e "${GREEN}✅ 优化完毕!${NC}"; sleep 1 + echo -e "\n${CYAN}💧 [4/4] 正在释放内存缓存...${NC}"; sudo sync && sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches'; echo -e "${GREEN}✅ 释放完毕!${NC}"; sleep 1 + echo -e "\n${GREEN}✨ 系统深度清理完成!${NC}"; echo -e "${YELLOW}当前内存状态:${NC}"; free -h + echo -e "\n${GREEN}按任意键返回主菜单 ...${NC}"; read -n 1 -s +} + +# 99. 一键卸载鸡公头 +uninstall_everything() { + clear + read -p "为确认执行此终极毁灭操作,请输入【yEs-i-aM-sUrE】: " confirmation + if [[ "$confirmation" != "yEs-i-aM-sUrE" ]]; then echo -e "${GREEN}操作已取消。${NC}"; sleep 3; return; fi + + echo -e "${RED}正在拆除所有由本脚本安装的服务...${NC}" + if command -v docker &> /dev/null; then sudo docker system prune -a --volumes -f; fi + if systemctl is-active --quiet "rclone-vps-mount.service"; then sudo systemctl stop rclone-vps-mount.service; sudo systemctl disable rclone-vps-mount.service; sudo rm -f /etc/systemd/system/rclone-vps-mount.service; fi + if systemctl is-active --quiet "cloudflared"; then sudo systemctl stop cloudflared; sudo systemctl disable cloudflared; fi + + echo -e "${RED}正在清理所有数据和配置文件...${NC}" + sudo rm -rf /root/*_data /root/ai_stack /root/vless_data /root/socks5_proxy_data /root/sillytavern_data /root/n8n_data /root/popodash /root/.config/rclone /root/.cloudflared /mnt/* + if [ -f "/etc/xrdp/xrdp.ini" ]; then + local desktop_user=$(grep 'RDP_USER' ${STATE_FILE} 2>/dev/null | cut -d'=' -f2) + if [ -n "$desktop_user" ] && id "$desktop_user" &>/dev/null; then sudo deluser --remove-home "$desktop_user" &>/dev/null; fi + sudo apt-get purge -y xrdp xfce4* &>/dev/null; sudo rm -f /root/.xsession + fi + sudo apt-get autoremove -y &>/dev/null + + echo -e "${RED}正在清理脚本自身...${NC}" + sudo rm -f ${STATE_FILE} ${RCLONE_LOG_FILE} /usr/local/bin/jigongtou + + echo -e "\n${GREEN}✅ 终极还原完成。建议重启服务器。${NC}" + rm -- "$0" + exit 0 +} + + +# --- 新增功能:部署 Syncthing 同步引擎 --- +install_syncthing() { + clear + echo -e "${BLUE}--- “Syncthing 跨服同步引擎”部署计划启动! ---${NC}" + + # 1. 安装软件 + if ! command -v syncthing &> /dev/null; then + echo -e "${YELLOW}正在安装 Syncthing 主程序...${NC}" + sudo apt-get update && sudo apt-get install -y syncthing + fi + + # 2. 预初始化配置 + echo -e "${YELLOW}正在进行静默初始化...${NC}" + pkill syncthing + syncthing --no-browser > /dev/null 2>&1 & + sleep 3 + pkill syncthing + + # 3. 核心配置修改:允许通过域名访问、跳过主机检查、放行 127.0.0.1 + sed -i 's/127.0.0.1:8384/127.0.0.1:8384/g' ~/.config/syncthing/config.xml + if ! grep -q "insecureSkipHostcheck" ~/.config/syncthing/config.xml; then + sed -i '/true' ~/.config/syncthing/config.xml + fi + + # 4. 自动配置网络隧道 + check_tunnel_installed || return + read -p "请输入您为 Syncthing 面板规划的域名 (例如 sync.jigongtou.uepopo.com): " SYNC_DOMAIN + if [ -z "$SYNC_DOMAIN" ]; then echo -e "${RED}域名不能为空!${NC}"; return; fi + + update_tunnel_config "${SYNC_DOMAIN}" "http://127.0.0.1:8384" "Syncthing GUI" + + # 5. 放行传输端口 (22000) + echo -e "${YELLOW}正在放行 Syncthing 传输端口 (22000)...${NC}" + sudo ufw allow 22000/tcp > /dev/null 2>&1 + sudo ufw allow 22000/udp > /dev/null 2>&1 + + # 6. 设置后台开机自启 (使用 Systemd) + echo -e "${YELLOW}正在配置 Systemd 服务以确保稳定运行...${NC}" + sudo tee /etc/systemd/system/syncthing@root.service > /dev/null <设置->图形用户界面] 设置用户名和密码!${NC}" + + echo -e "\n## Syncthing 凭证 (部署于: $(date))" >> ${STATE_FILE} + echo "SYNCTHING_DOMAIN=${SYNC_DOMAIN}" >> ${STATE_FILE} + + echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s +} + +# ================================================================ +# --- ⬇️ ⬇️ ⬇️ 請將以下所有代碼添加到 main() 函數之前 ⬇️ ⬇️ ⬇️ --- +# ================================================================ + +# --- 應用按需卸載助手 (Docker 應用) --- +_uninstall_docker_app() { + local s_name="$1" + local s_path="$2" + local s_credential_header="$3" + local s_tunnel_comment="$4" + + if [ ! -d "$s_path" ]; then + echo -e "\n${YELLOW}應用 【${s_name}】 未安裝,無需卸載。${NC}"; sleep 2; return + fi + + echo -e "\n${RED}--- 警告 ---${NC}" + echo -e "${YELLOW}您確定要徹底刪除 【${s_name}】 嗎?${NC}" + echo -e "${YELLOW}這將會停止並刪除其 Docker 容器,並永久刪除其所有數據 (位於 ${s_path})。${NC}" + read -p "請確認 (y/n): " confirm + + if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then + echo -e "${GREEN}操作已取消。${NC}"; sleep 2; return + fi + + echo -e "\n${CYAN}1/4: 正在停止並刪除 ${s_name} 的 Docker 容器...${NC}" + (cd ${s_path} && sudo docker compose down -v) > /dev/null 2>&1 + + echo -e "${CYAN}2/4: 正在刪除 ${s_name} 的數據文件夾: ${s_path}${NC}" + sudo rm -rf ${s_path} + + if [ -n "$s_credential_header" ] && [ -f "${STATE_FILE}" ]; then + echo -e "${CYAN}3/4: 正在清理 ${s_name} 的憑證...${NC}" + # 使用 awk 按标题块清理凭证:匹配到目标标题后开始删除,遇到下一个 ## 标题时停止 + # p=1 默认保留;遇到目标头 p=0 开始删除(含该行);遇到其他 ## 头 p=1 恢复保留 + sudo awk -v header="${s_credential_header}" ' + $0 ~ header { p=0; next } + /^## / { p=1 } + p { print } + ' "${STATE_FILE}" > "${STATE_FILE}.tmp" && sudo mv "${STATE_FILE}.tmp" "${STATE_FILE}" + # 清理可能残留的空行 + sudo awk 'NF' "${STATE_FILE}" > "${STATE_FILE}.tmp" && sudo mv "${STATE_FILE}.tmp" "${STATE_FILE}" + else + echo -e "${YELLOW}3/4: 未找到憑證標頭,跳過憑證清理。${NC}" + fi + + if [ -n "$s_tunnel_comment" ] && [ -f "$TUNNEL_CONFIG_FILE" ]; then + echo -e "${CYAN}4/4: 正在清理 ${s_name} 的 Tunnel 域名配置...${NC}" + # 刪除從註釋行到服務行的整個塊 + sudo sed -i -e "/# ${s_tunnel_comment}/,/service: .*/{/./d;}" "$TUNNEL_CONFIG_FILE" + else + echo -e "${YELLOW}4/4: 未找到 Tunnel 註釋,跳過 Tunnel 清理。${NC}" + fi + + echo -e "\n${GREEN}✅ 【${s_name}】 已徹底卸載。${NC}" + echo -e "${YELLOW}正在重啟 Cloudflare Tunnel 以使配置生效...${NC}" + sudo systemctl restart cloudflared + sleep 3 +} + +# --- 應用按需卸載助手 (系統服務) --- +_uninstall_system_service() { + local s_name="$1" + local s_service="$2" + local s_package="$3" + local s_config_files=($4) # 接收一個文件路徑數組 + + if ! systemctl is-active --quiet "$s_service" 2>/dev/null; then + echo -e "\n${YELLOW}服務 【${s_name}】 未安裝或未運行,無需卸載。${NC}"; sleep 2; return + fi + + read -p "您確定要卸載 ${s_name} 嗎? (y/n): " confirm + if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then + echo -e "${GREEN}操作已取消。${NC}"; sleep 2; return + fi + + echo -e "${YELLOW}正在停止並卸載 ${s_name} (包: ${s_package})...${NC}" + sudo systemctl stop "$s_service" + sudo systemctl disable "$s_service" + sudo apt-get purge -y "$s_package" + + if [ ${#s_config_files[@]} -gt 0 ]; then + echo -e "${YELLOW}正在清理 ${s_name} 的殘留配置文件...${NC}" + for file in "${s_config_files[@]}"; do + sudo rm -f "$file" + done + fi + + echo -e "\n${GREEN}✅ 【${s_name}】 已卸載。${NC}" + sleep 3 +} + +# --- 應用按需卸載助手 (Rclone) --- +_uninstall_rclone() { + if [ ! -f "${RCLONE_CONFIG_FILE}" ]; then + echo -e "\n${YELLOW}Rclone 未配置,無需卸載。${NC}"; sleep 2; return + fi + read -p "您確定要卸載 Rclone 嗎? (這將刪除您的配置和掛載服務) (y/n): " confirm + if [[ "$confirm" == "y" || "$confirm" == "Y" ]]; then + echo -e "${YELLOW}正在停止 Rclone 掛載服務...${NC}" + sudo systemctl stop rclone-vps-mount.service &>/dev/null + sudo systemctl disable rclone-vps-mount.service &>/dev/null + sudo rm -f /etc/systemd/system/rclone-vps-mount.service + echo -e "${YELLOW}正在刪除 Rclone 配置文件...${NC}" + sudo rm -rf "$(dirname "$RCLONE_CONFIG_FILE")" + sudo sed -i '/^RCLONE_REMOTE/d' ${STATE_FILE} + sudo sed -i '/^RCLONE_MOUNT_PATH/d' ${STATE_FILE} + echo -e "${GREEN}✅ Rclone 已卸載。${NC}" + sleep 3 + else + echo -e "${GREEN}操作已取消。${NC}"; sleep 2 + fi +} + +# --- 應用按需卸載助手 (XFCE 遠程桌面) --- +_uninstall_xrdp() { + if [ ! -f "/etc/xrdp/xrdp.ini" ]; then + echo -e "\n${YELLOW}遠程工作台未安裝,無需卸載。${NC}"; sleep 2; return + fi + read -p "您確定要卸載 XFCE 遠程工作台嗎? (y/n): " confirm + if [[ "$confirm" == "y" || "$confirm" == "Y" ]]; then + echo -e "${YELLOW}正在停止並卸載 XFCE 和 XRDP...${NC}" + sudo systemctl stop xrdp + sudo apt-get purge -y xfce4* xrdp + local desktop_user=$(grep 'RDP_USER' ${STATE_FILE} 2>/dev/null | cut -d'=' -f2) + if [ -n "$desktop_user" ] && id "$desktop_user" &>/dev/null; then + echo -e "${YELLOW}正在刪除您之前創建的桌面用戶: ${desktop_user}...${NC}" + sudo deluser --remove-home "$desktop_user" &>/dev/null + fi + sudo rm -f /root/.xsession + sudo sed -i '/^## 遠程工作台/,+1d' ${STATE_FILE} # 刪除憑證 + echo -e "${GREEN}✅ 遠程工作台已卸載。${NC}" + sleep 3 + else + echo -e "${GREEN}操作已取消。${NC}"; sleep 2 + fi +} + + +# --- “应用卸载中心”主菜单 --- +show_uninstall_menu() { + while true; do + clear + echo -e "${RED}==================== 💣 应用卸载中心 💣 ====================${NC}" + echo -e "${YELLOW}请选择您要卸载的应用。此操作将删除其所有数据且不可逆!${NC}" + echo -e "${YELLOW}══════════════════════════════════════════════════════════════════════${NC}" + + # --- 显示部分 (只负责打印) --- + check_and_display "1" "Cloudflare Tunnel (核心)" "service" "cloudflared" + check_and_display "2" "Rclone 数据同步桥" "file" "${RCLONE_CONFIG_FILE}" + check_and_display "3" "挂载远程VPS网盘 (SSHFS)" "file" "/usr/bin/sshfs" + echo " ---" + check_and_display "4.1" "Nextcloud" "dir" "/root/nextcloud_data" + check_and_display "4.2" "OnlyOffice" "dir" "/root/onlyoffice_data" + check_and_display "4.3" "Home Assistant" "dir" "/root/home_assistant_data" + check_and_display "4.4" "WordPress 个人博客" "dir" "/root/wordpress_data" + check_and_display "5.1" "AI 大脑 (Ollama+WebUI)" "dir" "/root/ai_stack" + check_and_display "5.3" "Jellyfin 家庭影院" "dir" "/root/jellyfin_data" + check_and_display "5.4" "Navidrome 音乐服务器" "dir" "/root/navidrome_data" + check_and_display "5.5" "Immich 私人相册" "dir" "/root/immich_data" + check_and_display "5.6" "Calibre-Web 电子书库" "dir" "/root/calibre_web_data" + check_and_display "5.7" "Kavita 漫画阅读器" "dir" "/root/kavita_data" + check_and_display "6.1" "Miniflux RSS 阅读器" "dir" "/root/miniflux_data" + check_and_display "6.2" "Gitea 代码仓库" "dir" "/root/gitea_data" + check_and_display "6.3" "qBittorrent 下载器" "dir" "/root/qbittorrent_data" + check_and_display "6.4" "JDownloader 下载器" "dir" "/root/jdownloader_data" + check_and_display "6.5" "yt-dlp 视频下载器" "dir" "/root/ytdlp_data" + check_and_display "6.6" "Draw.io 绘图工具" "dir" "/root/drawio_data" + check_and_display "6.7" "N8N 工作流自动化" "dir" "/root/n8n_data" + check_and_display "6.8" "Gotify 消息推送" "docker" "gotify_app" + check_and_display "6.9" "Actual Budget 记账" "dir" "/root/actual_budget_data" + check_and_display "6.10" "Excalidraw 在线白板" "dir" "/root/excalidraw_data" + check_and_display "6.11" "Joplin Server 私人笔记" "dir" "/root/joplin_data" + check_and_display "6.12" "Trilium Notes 知识库" "dir" "/root/trilium_data" + echo " ---" + check_and_display "8.1" "远程工作台 (Xfce)" "file" "/etc/xrdp/xrdp.ini" + check_and_display "8.4" "Fail2ban (防暴力破解)" "service" "fail2ban" + check_and_display "8.5" "Uptime Kuma (服务监控)" "dir" "/root/uptime_kuma_data" + check_and_display "8.6" "Glances (实时资源监控)" "docker" "glances_app" + check_and_display "8.7" "Syncthing (跨服同步引擎)" "service" "syncthing@root" + check_and_display "8.8" "Grafana 监控面板" "docker" "grafana_app" + echo " ---" + check_and_display "7.1" "SillyTavern AI酒馆" "dir" "/root/sillytavern_data" + check_and_display "7.2" "PopoDash 监控面板" "dir" "/root/popodash" + check_and_display "7.3" "MIMI 电报AI小秘书" "dir" "/root/mimi" + check_and_display "30.1" "\"科学上网\" Vless 节点" "dir" "/root/vless_data" + check_and_display "30.2" "Hysteria 2 (暴力加速)" "service" "hysteria-server" + + echo -e "${YELLOW}══════════════════════════════════════════════════════════════════════${NC}" + echo -e " b) 返回主菜单" + echo "" + read -p "请输入您要卸载的选项编号 (例如 4.1) 或 'b' 返回: " choice + + # --- 逻辑处理部分 (处理你的按键) --- + case $choice in + 4.1) _uninstall_docker_app "Nextcloud" "/root/nextcloud_data" "## Nextcloud 凭证" "Nextcloud" ;; + 4.2) _uninstall_docker_app "OnlyOffice" "/root/onlyoffice_data" "## OnlyOffice 凭证" "OnlyOffice" ;; + 4.3) _uninstall_docker_app "Home Assistant" "/root/home_assistant_data" "## Home Assistant 凭证" "Home Assistant" ;; + 4.4) _uninstall_docker_app "WordPress" "/root/wordpress_data" "## WordPress 凭证" "WordPress" ;; + 5.1) _uninstall_docker_app "AI 大脑" "/root/ai_stack" "## AI 核心凭证" "AI WebUI" ;; + 5.3) _uninstall_docker_app "Jellyfin" "/root/jellyfin_data" "## Jellyfin 凭证" "Jellyfin" ;; + 5.4) _uninstall_docker_app "Navidrome" "/root/navidrome_data" "## Navidrome 凭证" "Navidrome" ;; + 5.5) _uninstall_docker_app "Immich" "/root/immich_data" "## Immich 凭证" "Immich" ;; + 5.6) _uninstall_docker_app "Calibre-Web" "/root/calibre_web_data" "## Calibre-Web 凭证" "Calibre-Web" ;; + 5.7) _uninstall_docker_app "Kavita" "/root/kavita_data" "## Kavita 凭证" "Kavita" ;; + 6.1) _uninstall_docker_app "Miniflux" "/root/miniflux_data" "## Miniflux 凭证" "Miniflux" ;; + 6.2) _uninstall_docker_app "Gitea" "/root/gitea_data" "## Gitea 凭证" "Gitea" ;; + 6.3) _uninstall_docker_app "qBittorrent" "/root/qbittorrent_data" "## qBittorrent 凭证" "qBittorrent" ;; + 6.4) _uninstall_docker_app "JDownloader" "/root/jdownloader_data" "## JDownloader 凭证" "JDownloader" ;; + 6.5) _uninstall_docker_app "yt-dlp" "/root/ytdlp_data" "## yt-dlp 凭证" "yt-dlp" ;; + 6.6) _uninstall_docker_app "Draw.io" "/root/drawio_data" "## Draw.io 凭证" "Draw.io" ;; + 6.7) _uninstall_docker_app "N8N" "/root/n8n_data" "## N8N 凭证" "N8N" ;; + 6.8) _uninstall_docker_app "Gotify" "/root/gotify_data" "## Gotify 凭证" "Gotify" ;; + 6.9) _uninstall_docker_app "Actual Budget" "/root/actual_budget_data" "## Actual Budget 凭证" "Actual Budget" ;; + 6.10) _uninstall_docker_app "Excalidraw" "/root/excalidraw_data" "## Excalidraw 凭证" "Excalidraw" ;; + 6.11) _uninstall_docker_app "Joplin Server" "/root/joplin_data" "## Joplin Server 凭证" "Joplin" ;; + 6.12) _uninstall_docker_app "Trilium Notes" "/root/trilium_data" "## Trilium Notes 凭证" "Trilium" ;; + 8.1) _uninstall_xrdp ;; + 8.4) _uninstall_system_service "Fail2ban" "fail2ban" "fail2ban" "/etc/fail2ban/jail.local /etc/fail2ban/filter.d/xrdp.conf" ;; + 8.5) _uninstall_docker_app "Uptime Kuma" "/root/uptime_kuma_data" "## Uptime Kuma 凭证" "Uptime Kuma" ;; + 8.6) docker rm -f glances_app && sudo sed -i -e "/# Glances Monitor/,/service: .*/{/./d;}" "$TUNNEL_CONFIG_FILE" && sudo systemctl restart cloudflared ;; + 8.7) _uninstall_system_service "Syncthing" "syncthing@root" "syncthing" "/etc/systemd/system/syncthing@root.service /root/.config/syncthing" ;; + 8.8) _uninstall_docker_app "Grafana" "/root/grafana_data" "## Grafana 凭证" "Grafana" ;; + 7.1) _uninstall_docker_app "SillyTavern" "/root/sillytavern_data" "## SillyTavern 凭证" "SillyTavern" ;; + 7.2) rm -rf /root/popodash; echo -e "${GREEN}✅ PopoDash 已卸载${NC}" ;; + 7.3) rm -rf /root/mimi; echo -e "${GREEN}✅ MIMI 已卸载${NC}" ;; + 30.1) _uninstall_docker_app "VLESS 节点" "/root/vless_data" "## \"科学上网\" VLESS 节点" "VLESS Node" ;; + 30.2) _uninstall_system_service "Hysteria 2" "hysteria-server" "hysteria-2" "/etc/hysteria /usr/bin/hysteria" ;; + 1) _uninstall_system_service "Cloudflare Tunnel" "cloudflared" "cloudflared" "/root/.cloudflared /etc/apt/sources.list.d/cloudflared.list ${STATE_FILE}" ;; + 2) _uninstall_rclone ;; + b|B) break ;; + *) echo -e "${RED}无效的选项。${NC}"; sleep 2 ;; + esac + done +} +# ================================================================ +# 33. 应用升级与维护中心 +# ================================================================ +show_upgrade_menu() { + while true; do + clear + echo -e "${BLUE}==================== 🚀 应用升级中心 🚀 ====================${NC}" + echo -e "${YELLOW}一键升级你的应用,专治各种电报机器人“催更”!${NC}" + echo -e "${YELLOW}══════════════════════════════════════════════════════════════════════${NC}" + echo -e " 1) 升级 甲骨文开机助手 (oci-helper)" + echo -e " 2) 升级 AI 大脑 (Open WebUI 保数据升级)" + echo -e " b) 返回主菜单" + echo -e "${YELLOW}══════════════════════════════════════════════════════════════════════${NC}" + read -p "请输入你要升级的选项: " up_choice + + case $up_choice in + 1) + echo -e "${CYAN}正在调用 oci-helper 官方脚本进行升级...${NC}" + echo -e "${YELLOW}提示:如果弹出官方菜单,请选择对应的【更新/Upgrade】选项。${NC}" + # 重新调用官方脚本,官方脚本自带更新逻辑 + bash <(wget -qO- https://github.com/Yohann0617/oci-helper/releases/latest/download/sh_oci-helper_install.sh) + echo -e "\n${GREEN}按任意键继续...${NC}"; read -n 1 -s + ;; + 2) + if [ ! -d "/root/ai_stack" ]; then + echo -e "${RED}未检测到 AI 大脑安装目录!${NC}"; sleep 2; continue + fi + echo -e "${CYAN}正在安全升级 AI 大脑 (基于我们刚修复的保数据方案)...${NC}" + cd /root/ai_stack + sudo docker compose pull open-webui + sudo docker compose up -d + sudo docker image prune -f >/dev/null 2>&1 + echo -e "${GREEN}✅ AI 大脑升级完成,旧镜像已清理!${NC}" + echo -e "\n${GREEN}按任意键继续...${NC}"; read -n 1 -s + ;; + b|B) break ;; + *) echo -e "${RED}无效选项,请重新输入!${NC}"; sleep 1 ;; + esac + done +} +# ================================================================ + + +# ================================================================ +# --- 选项 35:甲骨文满配一键全装 (最终重构版) --- +# ================================================================ +install_oracle_fullstack() { + clear + echo -e "${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}" + echo -e "${GREEN}║ 🚀 甲骨文满配一键全装 (Oracle ARM 4C24G) ║${NC}" + echo -e "${GREEN}║ 全程只需输入一次主域名,安装完再去 Tunnel 添加子域名 ║${NC}" + echo -e "${GREEN}╚══════════════════════════════════════════════════════════════╝${NC}" + echo "" + + # ── 前置检查 1:Cloudflare Tunnel 必须已运行 ────────────────── + echo -e "${CYAN}[前置检查] 正在确认 Cloudflare Tunnel 状态...${NC}" + if ! systemctl is-active --quiet cloudflared 2>/dev/null; then + echo "" + echo -e "${RED}╔══════════════════════════════════════════════════════════════╗${NC}" + echo -e "${RED}║ ❌ 未检测到正在运行的 Cloudflare Tunnel! ║${NC}" + echo -e "${RED}║ 请先返回主菜单,执行【选项 1】部署 Cloudflare Tunnel, ║${NC}" + echo -e "${RED}║ 确认 Tunnel 服务启动成功后,再来运行此选项。 ║${NC}" + echo -e "${RED}╚══════════════════════════════════════════════════════════════╝${NC}" + echo "" + echo -e "${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s + return + fi + echo -e "${GREEN} ✅ Cloudflare Tunnel 运行正常!${NC}" + echo "" + + # ── 前置检查 2:自动安装 Docker ────────────────────────────── + echo -e "${CYAN}[前置检查] 正在确认 Docker 环境...${NC}" + ensure_docker_installed || return + echo "" + + # ── 输入主域名 ──────────────────────────────────────────────── + echo -e "${CYAN}请输入您的主域名(例如 example.com):${NC}" + read -p "主域名: " MAIN_DOMAIN + if [ -z "$MAIN_DOMAIN" ]; then + echo -e "${RED}域名不能为空!${NC}"; sleep 2; return + fi + + echo "" + echo -e "${CYAN}以下所有服务将被安装,请选择要跳过的编号(多个用空格分隔),直接回车全装:${NC}" + echo "" + echo -e "${GREEN} 序号 服务名称 子域名 本地端口${NC}" + echo -e "${DIM} ──── ────────────────────── ───────────────────────────── ──────${NC}" + printf " ${YELLOW}%2s${NC} %-24s ${CYAN}%-30s${NC} %s\n" "1" "Nextcloud 网盘" "nextcloud.${MAIN_DOMAIN}" "8888" + printf " ${YELLOW}%2s${NC} %-24s ${CYAN}%-30s${NC} %s\n" "2" "OnlyOffice 文档" "onlyoffice.${MAIN_DOMAIN}" "8889" + printf " ${YELLOW}%2s${NC} %-24s ${CYAN}%-30s${NC} %s\n" "3" "Jellyfin 影院" "jellyfin.${MAIN_DOMAIN}" "8096" + printf " ${YELLOW}%2s${NC} %-24s ${CYAN}%-30s${NC} %s\n" "4" "Navidrome 音乐" "music.${MAIN_DOMAIN}" "4533" + printf " ${YELLOW}%2s${NC} %-24s ${CYAN}%-30s${NC} %s\n" "5" "Miniflux RSS" "rss.${MAIN_DOMAIN}" "8091" + printf " ${YELLOW}%2s${NC} %-24s ${CYAN}%-30s${NC} %s\n" "6" "Memos 笔记" "memos.${MAIN_DOMAIN}" "5230" + printf " ${YELLOW}%2s${NC} %-24s ${CYAN}%-30s${NC} %s\n" "7" "AI 大脑(Ollama+WebUI)" "ai.${MAIN_DOMAIN}" "3001" + printf " ${YELLOW}%2s${NC} %-24s ${CYAN}%-30s${NC} %s\n" "8" "Uptime Kuma 监控" "status.${MAIN_DOMAIN}" "3002" + printf " ${YELLOW}%2s${NC} %-24s ${CYAN}%-30s${NC} %s\n" "9" "qBittorrent 下载" "bt.${MAIN_DOMAIN}" "8080" + printf " ${YELLOW}%2s${NC} %-24s ${CYAN}%-30s${NC} %s\n" "10" "SillyTavern 酒馆" "st.${MAIN_DOMAIN}" "8000" + printf " ${YELLOW}%2s${NC} %-24s ${CYAN}%-30s${NC} %s\n" "11" "N8N 工作流自动化" "n8n.${MAIN_DOMAIN}" "5678" + printf " ${YELLOW}%2s${NC} %-24s ${CYAN}%-30s${NC} %s\n" "12" "Immich 私人相册" "photos.${MAIN_DOMAIN}" "2283" + printf " ${YELLOW}%2s${NC} %-24s ${CYAN}%-30s${NC} %s\n" "13" "Calibre-Web 电子书库" "books.${MAIN_DOMAIN}" "8083" + printf " ${YELLOW}%2s${NC} %-24s ${CYAN}%-30s${NC} %s\n" "14" "Kavita 漫画阅读器" "manga.${MAIN_DOMAIN}" "5000" + printf " ${YELLOW}%2s${NC} %-24s ${CYAN}%-30s${NC} %s\n" "15" "Gitea 代码仓库" "git.${MAIN_DOMAIN}" "3000" + printf " ${YELLOW}%2s${NC} %-24s ${CYAN}%-30s${NC} %s\n" "16" "JDownloader 下载器" "jd.${MAIN_DOMAIN}" "5800" + printf " ${YELLOW}%2s${NC} %-24s ${CYAN}%-30s${NC} %s\n" "17" "MeTube 视频下载" "dl.${MAIN_DOMAIN}" "8999" + printf " ${YELLOW}%2s${NC} %-24s ${CYAN}%-30s${NC} %s\n" "18" "Draw.io 绘图工具" "draw.${MAIN_DOMAIN}" "8082" + printf " ${YELLOW}%2s${NC} %-24s ${CYAN}%-30s${NC} %s\n" "19" "Gotify 消息推送" "push.${MAIN_DOMAIN}" "8085" + printf " ${YELLOW}%2s${NC} %-24s ${CYAN}%-30s${NC} %s\n" "20" "Actual Budget 记账" "money.${MAIN_DOMAIN}" "5006" + printf " ${YELLOW}%2s${NC} %-24s ${CYAN}%-30s${NC} %s\n" "21" "Excalidraw 白板" "board.${MAIN_DOMAIN}" "8086" + printf " ${YELLOW}%2s${NC} %-24s ${CYAN}%-30s${NC} %s\n" "22" "Joplin Server 笔记" "notes.${MAIN_DOMAIN}" "22300" + printf " ${YELLOW}%2s${NC} %-24s ${CYAN}%-30s${NC} %s\n" "23" "Grafana 监控面板" "monitor.${MAIN_DOMAIN}" "3003" + printf " ${YELLOW}%2s${NC} %-24s ${CYAN}%-30s${NC} %s\n" "24" "Home Assistant 智能家居" "ha.${MAIN_DOMAIN}" "8123" + printf " ${YELLOW}%2s${NC} %-24s ${CYAN}%-30s${NC} %s\n" "25" "WordPress 博客" "blog.${MAIN_DOMAIN}" "8890" + printf " ${YELLOW}%2s${NC} %-24s ${CYAN}%-30s${NC} %s\n" "26" "Glances 资源监控" "glances.${MAIN_DOMAIN}" "61208" + printf " ${YELLOW}%2s${NC} %-24s ${CYAN}%-30s${NC} %s\n" "27" "Fail2ban 防暴力破解" "(系统服务,无域名)" "-" + echo "" + echo -e "${YELLOW}⚠️ 甲骨文 ARM 4C24G 资源充足,理论上可以全装。如非满配机器建议跳过部分。${NC}" + echo "" + read -p "输入要跳过的编号(例如 24 25,留空全装,q 取消): " SKIP_INPUT + if [ "$SKIP_INPUT" == "q" ] || [ "$SKIP_INPUT" == "Q" ]; then + echo -e "${GREEN}操作已取消。${NC}"; sleep 2; return + fi + + # 解析跳过列表 + declare -A SKIP_MAP + for num in $SKIP_INPUT; do + SKIP_MAP[$num]=1 + done + + local START_TIME; START_TIME=$(date +%s) + local STEP=0; local TOTAL=27 + + _step() { + STEP=$((STEP+1)) + echo "" + echo -e "${GREEN}════════════════════════════════════════════════${NC}" + echo -e "${GREEN} [${STEP}/${TOTAL}] $1${NC}" + echo -e "${GREEN}════════════════════════════════════════════════${NC}" + } + + # ── 基础环境 ────────────────────────────────────────────────── + sudo chown -R www-data:www-data /mnt 2>/dev/null || true + sudo mkdir -p /mnt/Downloads /mnt/Music /mnt/Photos /mnt/Books + + # ── 1. Nextcloud ────────────────────────────────────────────── + _step "部署 Nextcloud 家庭网盘" + if [[ -n "${SKIP_MAP[1]}" ]]; then echo -e "${DIM} ⏭️ 已跳过${NC}" + elif [ ! -d "/root/nextcloud_data" ]; then + local NC_DOMAIN="nextcloud.${MAIN_DOMAIN}" + local NC_DB_PASS="NcDb-pW_$(_gen_password 12)" + mkdir -p /root/nextcloud_data + [ -d "/root/nextcloud_data/php-opcache.ini" ] && rm -rf /root/nextcloud_data/php-opcache.ini + cat > /root/nextcloud_data/php-opcache.ini << 'OPCEOF' +opcache.enable=1 +opcache.memory_consumption=128 +opcache.interned_strings_buffer=16 +opcache.max_accelerated_files=10000 +opcache.save_comments=1 +opcache.revalidate_freq=60 +OPCEOF + cat > /root/nextcloud_data/docker-compose.yml << NCEOF +services: + db: + image: mariadb:11.4 + container_name: nextcloud_db + restart: unless-stopped + command: [--transaction-isolation=READ-COMMITTED, --binlog-format=ROW, --character-set-server=utf8mb4, --collation-server=utf8mb4_unicode_ci] + volumes: + - './db:/var/lib/mysql' + environment: + MYSQL_DATABASE: nextclouddb + MYSQL_USER: nextclouduser + MYSQL_PASSWORD: ${NC_DB_PASS} + MYSQL_ROOT_PASSWORD: ${NC_DB_PASS}_root + redis: + image: redis:alpine + container_name: nextcloud_redis + restart: unless-stopped + app: + image: nextcloud:latest + container_name: nextcloud_app + restart: unless-stopped + ports: + - "127.0.0.1:8888:80" + volumes: + - './html:/var/www/html' + - './php-opcache.ini:/usr/local/etc/php/conf.d/opcache-recommended.ini' + - type: bind + source: /mnt + target: /mnt + bind: + propagation: rshared + depends_on: + - db + - redis +NCEOF + (cd /root/nextcloud_data && sudo docker compose up -d) + echo -e "\n## Nextcloud 凭证 (全装部署: $(date))" >> ${STATE_FILE} + echo "NEXTCLOUD_DOMAIN=${NC_DOMAIN}" >> ${STATE_FILE} + echo "NEXTCLOUD_PORT=8888" >> ${STATE_FILE} + echo "NEXTCLOUD_DB_USER=nextclouduser" >> ${STATE_FILE} + echo "NEXTCLOUD_DB_NAME=nextclouddb" >> ${STATE_FILE} + echo "NEXTCLOUD_DB_HOST=db" >> ${STATE_FILE} + echo "NEXTCLOUD_DB_PASSWORD=${NC_DB_PASS}" >> ${STATE_FILE} + update_tunnel_config "${NC_DOMAIN}" "http://127.0.0.1:8888" "Nextcloud" + (crontab -l 2>/dev/null | grep -v "nextcloud_app php -f"; echo "*/5 * * * * /usr/bin/docker exec --user www-data nextcloud_app php -f /var/www/html/cron.php > /dev/null 2>&1") | crontab - + (crontab -l 2>/dev/null | grep -v "files:scan"; echo "*/15 * * * * /usr/bin/docker exec --user www-data nextcloud_app php occ files:scan --all > /dev/null 2>&1") | crontab - + echo -e "${GREEN}✅ Nextcloud 部署完成${NC}" + else + echo -e "${YELLOW}⏭️ Nextcloud 已安装,跳过${NC}" + fi + + # ── 2. OnlyOffice ───────────────────────────────────────────── + _step "部署 OnlyOffice 在线文档" + if [[ -n "${SKIP_MAP[2]}" ]]; then echo -e "${DIM} ⏭️ 已跳过${NC}" + elif [ ! -d "/root/onlyoffice_data" ]; then + local OO_DOMAIN="onlyoffice.${MAIN_DOMAIN}" + local OO_JWT="JwtS3cr3t-$(_gen_password 16)" + mkdir -p /root/onlyoffice_data + cat > /root/onlyoffice_data/docker-compose.yml << EOF +services: + onlyoffice: + image: onlyoffice/documentserver:latest + container_name: onlyoffice_app + restart: unless-stopped + ports: + - "127.0.0.1:8889:80" + volumes: + - './data:/var/www/onlyoffice/Data' + - './logs:/var/log/onlyoffice' + environment: + JWT_ENABLED: 'true' + JWT_SECRET: ${OO_JWT} +EOF + (cd /root/onlyoffice_data && sudo docker compose up -d) + echo -e "\n## OnlyOffice 凭证 (全装部署: $(date))" >> ${STATE_FILE} + echo "ONLYOFFICE_DOMAIN=${OO_DOMAIN}" >> ${STATE_FILE} + echo "ONLYOFFICE_JWT_SECRET=${OO_JWT}" >> ${STATE_FILE} + update_tunnel_config "${OO_DOMAIN}" "http://127.0.0.1:8889" "OnlyOffice" + echo -e "${GREEN}✅ OnlyOffice 部署完成${NC}" + else + echo -e "${YELLOW}⏭️ OnlyOffice 已安装,跳过${NC}" + fi + + # ── 3. Jellyfin ─────────────────────────────────────────────── + _step "部署 Jellyfin 家庭影院" + if [[ -n "${SKIP_MAP[3]}" ]]; then echo -e "${DIM} ⏭️ 已跳过${NC}" + elif [ ! -d "/root/jellyfin_data" ]; then + local JF_DOMAIN="jellyfin.${MAIN_DOMAIN}" + mkdir -p /root/jellyfin_data/config + cat > /root/jellyfin_data/docker-compose.yml << EOF +services: + jellyfin: + image: jellyfin/jellyfin:latest + container_name: jellyfin_app + restart: unless-stopped + ports: + - "127.0.0.1:8096:8096" + environment: + - 'PUID=0' + - 'PGID=0' + - 'TZ=Asia/Shanghai' + volumes: + - './config:/config' + - type: bind + source: /mnt + target: /mnt + bind: + propagation: rshared +EOF + (cd /root/jellyfin_data && sudo docker compose up -d) + echo -e "\n## Jellyfin 凭证 (全装部署: $(date))" >> ${STATE_FILE} + echo "JELLYFIN_DOMAIN=${JF_DOMAIN}" >> ${STATE_FILE} + update_tunnel_config "${JF_DOMAIN}" "http://127.0.0.1:8096" "Jellyfin" + echo -e "${GREEN}✅ Jellyfin 部署完成${NC}" + else + echo -e "${YELLOW}⏭️ Jellyfin 已安装,跳过${NC}" + fi + + # ── 4. Navidrome ────────────────────────────────────────────── + _step "部署 Navidrome 音乐服务器" + if [[ -n "${SKIP_MAP[4]}" ]]; then echo -e "${DIM} ⏭️ 已跳过${NC}" + elif [ ! -d "/root/navidrome_data" ]; then + local ND_DOMAIN="music.${MAIN_DOMAIN}" + mkdir -p /root/navidrome_data/data + cat > /root/navidrome_data/docker-compose.yml << EOF +services: + navidrome: + image: deluan/navidrome:latest + container_name: navidrome_app + restart: unless-stopped + ports: + - "127.0.0.1:4533:4533" + environment: + ND_SCANSCHEDULE: 1h + ND_LOGLEVEL: info + ND_MUSICFOLDER: /music + volumes: + - './data:/data' + - '/mnt/Music:/music:ro' +EOF + (cd /root/navidrome_data && sudo docker compose up -d) + echo -e "\n## Navidrome 凭证 (全装部署: $(date))" >> ${STATE_FILE} + echo "NAVIDROME_DOMAIN=${ND_DOMAIN}" >> ${STATE_FILE} + update_tunnel_config "${ND_DOMAIN}" "http://127.0.0.1:4533" "Navidrome" + echo -e "${GREEN}✅ Navidrome 部署完成${NC}" + else + echo -e "${YELLOW}⏭️ Navidrome 已安装,跳过${NC}" + fi + + # ── 5. Miniflux ─────────────────────────────────────────────── + _step "部署 Miniflux RSS 阅读器" + if [[ -n "${SKIP_MAP[5]}" ]]; then echo -e "${DIM} ⏭️ 已跳过${NC}" + elif [ ! -d "/root/miniflux_data" ]; then + local MF_DOMAIN="rss.${MAIN_DOMAIN}" + local MF_PASS="mf_$(_gen_password 10)" + local MF_DB_PASS="mfDb_$(_gen_password 12)" + mkdir -p /root/miniflux_data/db_data + cat > /root/miniflux_data/docker-compose.yml << EOF +services: + db: + image: postgres:15-alpine + container_name: miniflux_db + restart: unless-stopped + environment: + POSTGRES_USER: miniflux + POSTGRES_PASSWORD: ${MF_DB_PASS} + POSTGRES_DB: miniflux + volumes: + - './db_data:/var/lib/postgresql/data' + healthcheck: + test: ["CMD", "pg_isready", "-U", "miniflux"] + interval: 10s + timeout: 5s + retries: 5 + app: + image: miniflux/miniflux:latest + container_name: miniflux_app + restart: unless-stopped + depends_on: + db: + condition: service_healthy + ports: + - "127.0.0.1:8091:8080" + environment: + DATABASE_URL: postgres://miniflux:${MF_DB_PASS}@db/miniflux?sslmode=disable + RUN_MIGRATIONS: "1" + CREATE_ADMIN: "1" + ADMIN_USERNAME: admin + ADMIN_PASSWORD: ${MF_PASS} + BASE_URL: https://${MF_DOMAIN} +EOF + (cd /root/miniflux_data && sudo docker compose up -d) + echo -e "\n## Miniflux 凭证 (全装部署: $(date))" >> ${STATE_FILE} + echo "MINIFLUX_DOMAIN=${MF_DOMAIN}" >> ${STATE_FILE} + echo "MINIFLUX_ADMIN_USER=admin" >> ${STATE_FILE} + echo "MINIFLUX_ADMIN_PASSWORD=${MF_PASS}" >> ${STATE_FILE} + update_tunnel_config "${MF_DOMAIN}" "http://127.0.0.1:8091" "Miniflux" + echo -e "${GREEN}✅ Miniflux 部署完成(admin 密码已写入凭证文件)${NC}" + else + echo -e "${YELLOW}⏭️ Miniflux 已安装,跳过${NC}" + fi + + # ── 6. Memos ────────────────────────────────────────────────── + _step "部署 Memos 轻量笔记" + if [[ -n "${SKIP_MAP[6]}" ]]; then echo -e "${DIM} ⏭️ 已跳过${NC}" + elif [ ! -d "/root/memos_data" ]; then + local MM_DOMAIN="memos.${MAIN_DOMAIN}" + mkdir -p /root/memos_data/data + cat > /root/memos_data/docker-compose.yml << EOF +services: + memos: + image: ghcr.io/usememos/memos:latest + container_name: memos_app + restart: unless-stopped + ports: + - "127.0.0.1:5230:5230" + volumes: + - './data:/var/opt/memos' +EOF + (cd /root/memos_data && sudo docker compose up -d) + echo -e "\n## Memos 凭证 (全装部署: $(date))" >> ${STATE_FILE} + echo "MEMOS_DOMAIN=${MM_DOMAIN}" >> ${STATE_FILE} + update_tunnel_config "${MM_DOMAIN}" "http://127.0.0.1:5230" "Memos" + echo -e "${GREEN}✅ Memos 部署完成${NC}" + else + echo -e "${YELLOW}⏭️ Memos 已安装,跳过${NC}" + fi + + # ── 7. AI 大脑 ──────────────────────────────────────────────── + _step "部署 AI 大脑 (Ollama + Open WebUI)" + if [[ -n "${SKIP_MAP[7]}" ]]; then echo -e "${DIM} ⏭️ 已跳过${NC}" + elif [ ! -d "/root/ai_stack" ]; then + local AI_DOMAIN="ai.${MAIN_DOMAIN}" + mkdir -p /root/ai_stack + cat > /root/ai_stack/docker-compose.yml << EOF +services: + ollama: + image: ollama/ollama:latest + container_name: ollama + restart: unless-stopped + volumes: + - './ollama_data:/root/.ollama' + open-webui: + image: ghcr.io/open-webui/open-webui:main + container_name: open_webui_app + restart: unless-stopped + ports: + - "127.0.0.1:3001:8080" + environment: + - OLLAMA_BASE_URL=http://ollama:11434 + volumes: + - './webui_data:/app/backend/data' + depends_on: + - ollama +EOF + (cd /root/ai_stack && sudo docker compose up -d) + echo -e "\n## AI 核心凭证 (全装部署: $(date))" >> ${STATE_FILE} + echo "AI_DOMAIN=${AI_DOMAIN}" >> ${STATE_FILE} + update_tunnel_config "${AI_DOMAIN}" "http://127.0.0.1:3001" "Open WebUI" + echo -e "${GREEN}✅ AI 大脑部署完成(建议执行选项 5.2 安装语言模型)${NC}" + else + echo -e "${YELLOW}⏭️ AI 大脑已安装,跳过${NC}" + fi + + # ── 8. Uptime Kuma ──────────────────────────────────────────── + _step "部署 Uptime Kuma 服务监控" + if [[ -n "${SKIP_MAP[8]}" ]]; then echo -e "${DIM} ⏭️ 已跳过${NC}" + elif [ ! -d "/root/uptime_kuma_data" ]; then + local UK_DOMAIN="status.${MAIN_DOMAIN}" + mkdir -p /root/uptime_kuma_data + cat > /root/uptime_kuma_data/docker-compose.yml << EOF +services: + uptime-kuma: + image: louislam/uptime-kuma:1 + container_name: uptime_kuma_app + restart: unless-stopped + ports: + - "127.0.0.1:3002:3001" + volumes: + - './data:/app/data' + environment: + - 'TZ=Asia/Shanghai' +EOF + (cd /root/uptime_kuma_data && sudo docker compose up -d) + echo -e "\n## Uptime Kuma 凭证 (全装部署: $(date))" >> ${STATE_FILE} + echo "KUMA_DOMAIN=${UK_DOMAIN}" >> ${STATE_FILE} + update_tunnel_config "${UK_DOMAIN}" "http://127.0.0.1:3002" "Uptime Kuma" + echo -e "${GREEN}✅ Uptime Kuma 部署完成${NC}" + else + echo -e "${YELLOW}⏭️ Uptime Kuma 已安装,跳过${NC}" + fi + + # ── 9. qBittorrent ──────────────────────────────────────────── + _step "部署 qBittorrent 下载器" + if [[ -n "${SKIP_MAP[9]}" ]]; then echo -e "${DIM} ⏭️ 已跳过${NC}" + elif [ ! -d "/root/qbittorrent_data" ]; then + local QB_DOMAIN="bt.${MAIN_DOMAIN}" + mkdir -p /root/qbittorrent_data/config + cat > /root/qbittorrent_data/docker-compose.yml << EOF +services: + qbittorrent: + image: lscr.io/linuxserver/qbittorrent:latest + container_name: qbittorrent_app + restart: unless-stopped + environment: + - PUID=1000 + - PGID=1000 + - WEBUI_PORT=8080 + ports: + - "127.0.0.1:8080:8080" + - "127.0.0.1:6881:6881" + - "127.0.0.1:6881:6881/udp" + volumes: + - './config:/config' + - '/mnt:/downloads' +EOF + (cd /root/qbittorrent_data && sudo docker compose up -d) + echo -e "\n## qBittorrent 凭证 (全装部署: $(date))" >> ${STATE_FILE} + echo "QB_DOMAIN=${QB_DOMAIN}" >> ${STATE_FILE} + echo "QB_NOTE=初始密码请执行: docker logs qbittorrent_app | grep password" >> ${STATE_FILE} + update_tunnel_config "${QB_DOMAIN}" "http://127.0.0.1:8080" "qBittorrent" + echo -e "${GREEN}✅ qBittorrent 部署完成(初始密码:docker logs qbittorrent_app)${NC}" + else + echo -e "${YELLOW}⏭️ qBittorrent 已安装,跳过${NC}" + fi + + # ── 10. SillyTavern ─────────────────────────────────────────── + _step "部署 SillyTavern 角色扮演酒馆" + if [[ -n "${SKIP_MAP[10]}" ]]; then echo -e "${DIM} ⏭️ 已跳过${NC}" + elif ! docker ps -q -f name=sillytavern_app 2>/dev/null | grep -q .; then + local ST_DOMAIN="st.${MAIN_DOMAIN}" + mkdir -p /root/sillytavern_data + [ -d "/root/sillytavern_data/config.yaml" ] && rm -rf /root/sillytavern_data/config.yaml + cat > /root/sillytavern_data/config.yaml << 'STEOF' +dataRoot: ./data +listen: true +port: 8000 +ssl: + enabled: false +whitelistMode: false +basicAuthMode: false +enableCorsProxy: true +securityOverride: true +STEOF + cat > /root/sillytavern_data/docker-compose.yml << 'STEOF' +services: + sillytavern: + container_name: sillytavern_app + image: ghcr.io/sillytavern/sillytavern:latest + restart: unless-stopped + ports: + - "127.0.0.1:8000:8000" + volumes: + - '/root/sillytavern_data/config.yaml:/home/node/app/config.yaml' + - '/root/sillytavern_data/data:/home/node/app/data' +STEOF + (cd /root/sillytavern_data && docker compose up -d) + echo -e "\n## SillyTavern 凭证 (全装部署: $(date))" >> ${STATE_FILE} + echo "SILLYTAVERN_DOMAIN=${ST_DOMAIN}" >> ${STATE_FILE} + update_tunnel_config "${ST_DOMAIN}" "http://127.0.0.1:8000" "SillyTavern 酒馆" + echo -e "${GREEN}✅ SillyTavern 部署完成${NC}" + else + echo -e "${YELLOW}⏭️ SillyTavern 已安装,跳过${NC}" + fi + + # ── 11. N8N ─────────────────────────────────────────────────── + _step "部署 N8N 工作流自动化" + if [[ -n "${SKIP_MAP[11]}" ]]; then echo -e "${DIM} ⏭️ 已跳过${NC}" + elif [ ! -d "/root/n8n_data" ]; then + local N8N_DOMAIN="n8n.${MAIN_DOMAIN}" + local N8N_ENCRYPTION_KEY="n8n-key-$(_gen_password 24)" + mkdir -p /root/n8n_data/data + chown -R 1000:1000 /root/n8n_data/data + cat > /root/n8n_data/docker-compose.yml << EOF +services: + n8n: + image: n8nio/n8n:latest + container_name: n8n_app + restart: unless-stopped + ports: + - "127.0.0.1:5678:5678" + environment: + - N8N_HOST=${N8N_DOMAIN} + - N8N_PORT=5678 + - N8N_PROTOCOL=https + - NODE_ENV=production + - WEBHOOK_URL=https://${N8N_DOMAIN}/ + - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY} + - TZ=Asia/Shanghai + volumes: + - './data:/home/node/.n8n' +EOF + (cd /root/n8n_data && sudo docker compose up -d) + echo -e "\n## N8N 凭证 (全装部署: $(date))" >> ${STATE_FILE} + echo "N8N_DOMAIN=${N8N_DOMAIN}" >> ${STATE_FILE} + echo "N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}" >> ${STATE_FILE} + update_tunnel_config "${N8N_DOMAIN}" "http://127.0.0.1:5678" "N8N" + echo -e "${GREEN}✅ N8N 部署完成(首次访问注册管理员账号)${NC}" + else + echo -e "${YELLOW}⏭️ N8N 已安装,跳过${NC}" + fi + + # ── 12. Immich ──────────────────────────────────────────────── + _step "部署 Immich 私人相册" + if [[ -n "${SKIP_MAP[12]}" ]]; then echo -e "${DIM} ⏭️ 已跳过${NC}" + elif [ ! -d "/root/immich_data" ]; then + local IM_DOMAIN="photos.${MAIN_DOMAIN}" + local IM_DB_PASS="immich_db_$(_gen_password 16)" + local IM_JWT="$(_gen_password 32)" + mkdir -p /root/immich_data + cat > /root/immich_data/docker-compose.yml << EOF +services: + immich-server: + image: ghcr.io/immich-app/immich-server:release + container_name: immich_server + restart: unless-stopped + ports: + - "127.0.0.1:2283:2283" + volumes: + - '/mnt/Photos:/usr/src/app/upload' + - /etc/localtime:/etc/localtime:ro + environment: + DB_HOSTNAME: immich_postgres + DB_USERNAME: immich + DB_PASSWORD: ${IM_DB_PASS} + DB_DATABASE_NAME: immich + REDIS_HOSTNAME: immich_redis + JWT_SECRET: ${IM_JWT} + TZ: Asia/Shanghai + depends_on: + - immich-redis + - immich-postgres + immich-machine-learning: + image: ghcr.io/immich-app/immich-machine-learning:release + container_name: immich_ml + restart: unless-stopped + volumes: + - immich_model_cache:/cache + immich-redis: + image: redis:7-alpine + container_name: immich_redis + restart: unless-stopped + immich-postgres: + image: tensorchord/pgvecto-rs:pg14-v0.2.0 + container_name: immich_postgres + restart: unless-stopped + environment: + POSTGRES_USER: immich + POSTGRES_PASSWORD: ${IM_DB_PASS} + POSTGRES_DB: immich + volumes: + - immich_pgdata:/var/lib/postgresql/data +volumes: + immich_model_cache: + immich_pgdata: +EOF + (cd /root/immich_data && sudo docker compose up -d) + echo -e "\n## Immich 凭证 (全装部署: $(date))" >> ${STATE_FILE} + echo "IMMICH_DOMAIN=${IM_DOMAIN}" >> ${STATE_FILE} + echo "IMMICH_UPLOAD_PATH=/mnt/Photos" >> ${STATE_FILE} + update_tunnel_config "${IM_DOMAIN}" "http://127.0.0.1:2283" "Immich 相册" + echo -e "${GREEN}✅ Immich 部署完成(首次访问注册管理员账号)${NC}" + else + echo -e "${YELLOW}⏭️ Immich 已安装,跳过${NC}" + fi + + # ── 13. Calibre-Web ─────────────────────────────────────────── + _step "部署 Calibre-Web 电子书库" + if [[ -n "${SKIP_MAP[13]}" ]]; then echo -e "${DIM} ⏭️ 已跳过${NC}" + elif [ ! -d "/root/calibre_web_data" ]; then + local CW_DOMAIN="books.${MAIN_DOMAIN}" + mkdir -p /root/calibre_web_data/config + cat > /root/calibre_web_data/docker-compose.yml << EOF +services: + calibre-web: + image: lscr.io/linuxserver/calibre-web:latest + container_name: calibre_web_app + restart: unless-stopped + ports: + - "127.0.0.1:8083:8083" + volumes: + - './config:/config' + - '/mnt/Books:/books' + environment: + - PUID=0 + - PGID=0 + - TZ=Asia/Shanghai + - DOCKER_MODS=linuxserver/mods:universal-calibre +EOF + (cd /root/calibre_web_data && sudo docker compose up -d) + echo -e "\n## Calibre-Web 凭证 (全装部署: $(date))" >> ${STATE_FILE} + echo "CALIBRE_WEB_DOMAIN=${CW_DOMAIN}" >> ${STATE_FILE} + update_tunnel_config "${CW_DOMAIN}" "http://127.0.0.1:8083" "Calibre-Web" + echo -e "${GREEN}✅ Calibre-Web 部署完成(默认账号/密码: admin/admin123,登录后填书库路径 /books)${NC}" + else + echo -e "${YELLOW}⏭️ Calibre-Web 已安装,跳过${NC}" + fi + + # ── 14. Kavita ──────────────────────────────────────────────── + _step "部署 Kavita 漫画阅读器" + if [[ -n "${SKIP_MAP[14]}" ]]; then echo -e "${DIM} ⏭️ 已跳过${NC}" + elif [ ! -d "/root/kavita_data" ]; then + local KV_DOMAIN="manga.${MAIN_DOMAIN}" + mkdir -p /root/kavita_data/config + cat > /root/kavita_data/docker-compose.yml << EOF +services: + kavita: + image: jvmilazz0/kavita:latest + container_name: kavita_app + restart: unless-stopped + ports: + - "127.0.0.1:5000:5000" + volumes: + - './config:/kavita/config' + - '/mnt/Books:/manga' + environment: + - TZ=Asia/Shanghai +EOF + (cd /root/kavita_data && sudo docker compose up -d) + echo -e "\n## Kavita 凭证 (全装部署: $(date))" >> ${STATE_FILE} + echo "KAVITA_DOMAIN=${KV_DOMAIN}" >> ${STATE_FILE} + update_tunnel_config "${KV_DOMAIN}" "http://127.0.0.1:5000" "Kavita" + echo -e "${GREEN}✅ Kavita 部署完成(首次访问设置管理员,书库路径填 /manga)${NC}" + else + echo -e "${YELLOW}⏭️ Kavita 已安装,跳过${NC}" + fi + + # ── 15. Gitea ───────────────────────────────────────────────── + _step "部署 Gitea 代码仓库" + if [[ -n "${SKIP_MAP[15]}" ]]; then echo -e "${DIM} ⏭️ 已跳过${NC}" + elif [ ! -d "/root/gitea_data" ]; then + local GT_DOMAIN="git.${MAIN_DOMAIN}" + mkdir -p /root/gitea_data + cat > /root/gitea_data/docker-compose.yml << EOF +services: + server: + image: gitea/gitea:latest + container_name: gitea_app + restart: unless-stopped + ports: + - "127.0.0.1:3000:3000" + environment: + - 'USER_UID=1000' + - 'USER_GID=1000' + volumes: + - './gitea:/data' + - '/etc/timezone:/etc/timezone:ro' + - '/etc/localtime:/etc/localtime:ro' +EOF + (cd /root/gitea_data && sudo docker compose up -d) + echo -e "\n## Gitea 凭证 (全装部署: $(date))" >> ${STATE_FILE} + echo "GITEA_DOMAIN=${GT_DOMAIN}" >> ${STATE_FILE} + update_tunnel_config "${GT_DOMAIN}" "http://127.0.0.1:3000" "Gitea" + echo -e "${GREEN}✅ Gitea 部署完成(首次访问完成初始化设置)${NC}" + else + echo -e "${YELLOW}⏭️ Gitea 已安装,跳过${NC}" + fi + + # ── 16. JDownloader ─────────────────────────────────────────── + _step "部署 JDownloader 下载器" + if [[ -n "${SKIP_MAP[16]}" ]]; then echo -e "${DIM} ⏭️ 已跳过${NC}" + elif [ ! -d "/root/jdownloader_data" ]; then + local JD_DOMAIN="jd.${MAIN_DOMAIN}" + local JD_VNC_PASS="VNC-$(_gen_password 8)" + mkdir -p /root/jdownloader_data + cat > /root/jdownloader_data/docker-compose.yml << EOF +services: + jdownloader-2: + image: jlesage/jdownloader-2 + container_name: jdownloader_app + restart: unless-stopped + ports: + - "127.0.0.1:5800:5800" + volumes: + - './config:/config' + - '/mnt/Downloads:/output' + environment: + - 'USER_ID=1000' + - 'GROUP_ID=1000' + - 'TZ=Asia/Shanghai' + - 'VNC_PASSWORD=${JD_VNC_PASS}' +EOF + (cd /root/jdownloader_data && sudo docker compose up -d) + echo -e "\n## JDownloader 凭证 (全装部署: $(date))" >> ${STATE_FILE} + echo "JD_DOMAIN=${JD_DOMAIN}" >> ${STATE_FILE} + echo "JDOWNLOADER_VNC_PASSWORD=${JD_VNC_PASS}" >> ${STATE_FILE} + update_tunnel_config "${JD_DOMAIN}" "http://127.0.0.1:5800" "JDownloader" + echo -e "${GREEN}✅ JDownloader 部署完成${NC}" + else + echo -e "${YELLOW}⏭️ JDownloader 已安装,跳过${NC}" + fi + + # ── 17. MeTube (yt-dlp) ─────────────────────────────────────── + _step "部署 MeTube 视频下载器" + if [[ -n "${SKIP_MAP[17]}" ]]; then echo -e "${DIM} ⏭️ 已跳过${NC}" + elif [ ! -d "/root/ytdlp_data" ]; then + local DL_DOMAIN="dl.${MAIN_DOMAIN}" + mkdir -p /root/ytdlp_data + cat > /root/ytdlp_data/docker-compose.yml << EOF +services: + ytdlp-ui: + image: ghcr.io/alexta69/metube:latest + container_name: ytdlp_app + restart: unless-stopped + ports: + - "127.0.0.1:8999:8081" + volumes: + - '/mnt/Downloads:/downloads' + environment: + - TZ=Asia/Shanghai +EOF + (cd /root/ytdlp_data && sudo docker compose up -d) + echo -e "\n## MeTube/yt-dlp 凭证 (全装部署: $(date))" >> ${STATE_FILE} + echo "YTDL_DOMAIN=${DL_DOMAIN}" >> ${STATE_FILE} + echo "YTDL_DOWNLOAD_DIR=/mnt/Downloads" >> ${STATE_FILE} + update_tunnel_config "${DL_DOMAIN}" "http://127.0.0.1:8999" "MeTube 下载器" + echo -e "${GREEN}✅ MeTube 部署完成(如遇 YouTube 验证,可后续配置 cookies.txt)${NC}" + else + echo -e "${YELLOW}⏭️ MeTube 已安装,跳过${NC}" + fi + + # ── 18. Draw.io ─────────────────────────────────────────────── + _step "部署 Draw.io 绘图工具" + if [[ -n "${SKIP_MAP[18]}" ]]; then echo -e "${DIM} ⏭️ 已跳过${NC}" + elif [ ! -d "/root/drawio_data" ]; then + local DRAW_DOMAIN="draw.${MAIN_DOMAIN}" + mkdir -p /root/drawio_data + cat > /root/drawio_data/docker-compose.yml << EOF +services: + drawio: + image: jgraph/drawio + container_name: drawio_app + restart: unless-stopped + ports: + - "127.0.0.1:8082:8080" +EOF + (cd /root/drawio_data && sudo docker compose up -d) + echo -e "\n## Draw.io 凭证 (全装部署: $(date))" >> ${STATE_FILE} + echo "DRAWIO_DOMAIN=${DRAW_DOMAIN}" >> ${STATE_FILE} + update_tunnel_config "${DRAW_DOMAIN}" "http://127.0.0.1:8082" "Draw.io" "access" + echo -e "${GREEN}✅ Draw.io 部署完成(已启用 Cloudflare Access 保护)${NC}" + else + echo -e "${YELLOW}⏭️ Draw.io 已安装,跳过${NC}" + fi + + # ── 19. Gotify ──────────────────────────────────────────────── + _step "部署 Gotify 消息推送服务" + if [[ -n "${SKIP_MAP[19]}" ]]; then echo -e "${DIM} ⏭️ 已跳过${NC}" + elif [ ! -d "/root/gotify_data" ]; then + local GT2_DOMAIN="push.${MAIN_DOMAIN}" + local GT_PASS="gotify_$(_gen_password 12)" + mkdir -p /root/gotify_data/data + cat > /root/gotify_data/docker-compose.yml << EOF +services: + gotify: + image: gotify/server:latest + container_name: gotify_app + restart: unless-stopped + ports: + - "127.0.0.1:8085:80" + volumes: + - './data:/app/data' + environment: + - GOTIFY_DEFAULTUSER_PASS=${GT_PASS} + - TZ=Asia/Shanghai +EOF + (cd /root/gotify_data && sudo docker compose up -d) + echo -e "\n## Gotify 凭证 (全装部署: $(date))" >> ${STATE_FILE} + echo "GOTIFY_DOMAIN=${GT2_DOMAIN}" >> ${STATE_FILE} + echo "GOTIFY_PASSWORD=${GT_PASS}" >> ${STATE_FILE} + update_tunnel_config "${GT2_DOMAIN}" "http://127.0.0.1:8085" "Gotify" + echo -e "${GREEN}✅ Gotify 部署完成(账号: admin,密码已写入凭证)${NC}" + else + echo -e "${YELLOW}⏭️ Gotify 已安装,跳过${NC}" + fi + + # ── 20. Actual Budget ───────────────────────────────────────── + _step "部署 Actual Budget 个人记账" + if [[ -n "${SKIP_MAP[20]}" ]]; then echo -e "${DIM} ⏭️ 已跳过${NC}" + elif [ ! -d "/root/actual_budget_data" ]; then + local AB_DOMAIN="money.${MAIN_DOMAIN}" + mkdir -p /root/actual_budget_data/data + cat > /root/actual_budget_data/docker-compose.yml << EOF +services: + actual: + image: actualbudget/actual-server:latest + container_name: actual_budget_app + restart: unless-stopped + ports: + - "127.0.0.1:5006:5006" + volumes: + - './data:/data' + environment: + - TZ=Asia/Shanghai +EOF + (cd /root/actual_budget_data && sudo docker compose up -d) + echo -e "\n## Actual Budget 凭证 (全装部署: $(date))" >> ${STATE_FILE} + echo "ACTUAL_BUDGET_DOMAIN=${AB_DOMAIN}" >> ${STATE_FILE} + update_tunnel_config "${AB_DOMAIN}" "http://127.0.0.1:5006" "Actual Budget" + echo -e "${GREEN}✅ Actual Budget 部署完成${NC}" + else + echo -e "${YELLOW}⏭️ Actual Budget 已安装,跳过${NC}" + fi + + # ── 21. Excalidraw ──────────────────────────────────────────── + _step "部署 Excalidraw 在线白板" + if [[ -n "${SKIP_MAP[21]}" ]]; then echo -e "${DIM} ⏭️ 已跳过${NC}" + elif [ ! -d "/root/excalidraw_data" ]; then + local ED_DOMAIN="board.${MAIN_DOMAIN}" + mkdir -p /root/excalidraw_data + cat > /root/excalidraw_data/docker-compose.yml << EOF +services: + excalidraw: + image: excalidraw/excalidraw:latest + container_name: excalidraw_app + restart: unless-stopped + ports: + - "127.0.0.1:8086:80" + environment: + - TZ=Asia/Shanghai +EOF + (cd /root/excalidraw_data && sudo docker compose up -d) + echo -e "\n## Excalidraw 凭证 (全装部署: $(date))" >> ${STATE_FILE} + echo "EXCALIDRAW_DOMAIN=${ED_DOMAIN}" >> ${STATE_FILE} + update_tunnel_config "${ED_DOMAIN}" "http://127.0.0.1:8086" "Excalidraw" + echo -e "${GREEN}✅ Excalidraw 部署完成(无需登录,打开即用)${NC}" + else + echo -e "${YELLOW}⏭️ Excalidraw 已安装,跳过${NC}" + fi + + # ── 22. Joplin Server ───────────────────────────────────────── + _step "部署 Joplin Server 私人笔记服务器" + if [[ -n "${SKIP_MAP[22]}" ]]; then echo -e "${DIM} ⏭️ 已跳过${NC}" + elif [ ! -d "/root/joplin_data" ]; then + local JP_DOMAIN="notes.${MAIN_DOMAIN}" + local JP_DB_PASS; JP_DB_PASS="$(_gen_password 16)" + mkdir -p /root/joplin_data/postgres + cat > /root/joplin_data/docker-compose.yml << EOF +services: + joplin: + image: ${IMG_JOPLIN_SERVER} + container_name: joplin_app + restart: unless-stopped + depends_on: + joplin_db: + condition: service_healthy + ports: + - "127.0.0.1:${PORT_JOPLIN}:22300" + environment: + - APP_PORT=22300 + - APP_BASE_URL=https://${JP_DOMAIN} + - DB_CLIENT=pg + - POSTGRES_PASSWORD=${JP_DB_PASS} + - POSTGRES_DATABASE=joplin + - POSTGRES_USER=joplin + - POSTGRES_PORT=5432 + - POSTGRES_HOST=joplin_db + - MAILER_ENABLED=0 + - TZ=${TZ_DEFAULT} + joplin_db: + image: ${IMG_JOPLIN_DB} + container_name: joplin_db + restart: unless-stopped + volumes: + - './postgres:/var/lib/postgresql/data' + environment: + - POSTGRES_PASSWORD=${JP_DB_PASS} + - POSTGRES_USER=joplin + - POSTGRES_DB=joplin + healthcheck: + test: ["CMD-SHELL", "pg_isready -U joplin"] + interval: 10s + timeout: 5s + retries: 5 +EOF + (cd /root/joplin_data && sudo docker compose up -d) + echo -e "\n## Joplin Server 凭证 (全装部署: $(date))" >> ${STATE_FILE} + echo "JOPLIN_DOMAIN=${JP_DOMAIN}" >> ${STATE_FILE} + echo "JOPLIN_DB_PASSWORD=${JP_DB_PASS}" >> ${STATE_FILE} + update_tunnel_config "${JP_DOMAIN}" "http://127.0.0.1:${PORT_JOPLIN}" "Joplin Server" + echo -e "${GREEN}✅ Joplin Server 部署完成(首次登录: admin@localhost / admin,请立即修改密码)${NC}" + else + echo -e "${YELLOW}⏭️ Joplin Server 已安装,跳过${NC}" + fi + + # ── 23. Grafana ─────────────────────────────────────────────── + _step "部署 Grafana + Prometheus 监控面板" + if [[ -n "${SKIP_MAP[23]}" ]]; then echo -e "${DIM} ⏭️ 已跳过${NC}" + elif [ ! -d "/root/grafana_data" ]; then + local GF_DOMAIN="monitor.${MAIN_DOMAIN}" + local GF_PASS="grafana_$(_gen_password 12)" + mkdir -p /root/grafana_data/{grafana,prometheus} + sudo chown -R 472:472 /root/grafana_data/grafana + cat > /root/grafana_data/prometheus/prometheus.yml << EOF +global: + scrape_interval: 15s +scrape_configs: + - job_name: 'node' + static_configs: + - targets: ['node_exporter:9100'] + - job_name: 'cadvisor' + static_configs: + - targets: ['cadvisor:8080'] +EOF + cat > /root/grafana_data/docker-compose.yml << EOF +services: + grafana: + image: grafana/grafana:latest + container_name: grafana_app + restart: unless-stopped + ports: + - "127.0.0.1:3003:3000" + volumes: + - './grafana:/var/lib/grafana' + environment: + - GF_SECURITY_ADMIN_PASSWORD=${GF_PASS} + - GF_USERS_ALLOW_SIGN_UP=false + - TZ=Asia/Shanghai + prometheus: + image: prom/prometheus:latest + container_name: prometheus_app + restart: unless-stopped + volumes: + - './prometheus/prometheus.yml:/etc/prometheus/prometheus.yml' + - prometheus_data:/prometheus + command: + - '--config.file=/etc/prometheus/prometheus.yml' + - '--storage.tsdb.retention.time=30d' + node_exporter: + image: prom/node-exporter:latest + container_name: node_exporter + restart: unless-stopped + pid: host + volumes: + - '/proc:/host/proc:ro' + - '/sys:/host/sys:ro' + - '/:/rootfs:ro' + command: + - '--path.procfs=/host/proc' + - '--path.sysfs=/host/sys' + - '--collector.filesystem.ignored-mount-points=^/(sys|proc|dev|host|etc)($$|/)' + cadvisor: + image: gcr.io/cadvisor/cadvisor:latest + container_name: cadvisor + restart: unless-stopped + privileged: true + volumes: + - '/:/rootfs:ro' + - '/var/run:/var/run:ro' + - '/sys:/sys:ro' + - '/var/lib/docker/:/var/lib/docker:ro' +volumes: + prometheus_data: +EOF + (cd /root/grafana_data && sudo docker compose up -d) + echo -e "\n## Grafana 凭证 (全装部署: $(date))" >> ${STATE_FILE} + echo "GRAFANA_DOMAIN=${GF_DOMAIN}" >> ${STATE_FILE} + echo "GRAFANA_PASSWORD=${GF_PASS}" >> ${STATE_FILE} + update_tunnel_config "${GF_DOMAIN}" "http://127.0.0.1:3003" "Grafana" + echo -e "${GREEN}✅ Grafana 部署完成(账号: admin,密码已写入凭证。数据源填 http://prometheus_app:9090,推荐仪表盘 ID: 1860 / 193)${NC}" + else + echo -e "${YELLOW}⏭️ Grafana 已安装,跳过${NC}" + fi + + # ── 24. Home Assistant ──────────────────────────────────────── + _step "部署 Home Assistant 智能家居" + if [[ -n "${SKIP_MAP[24]}" ]]; then echo -e "${DIM} ⏭️ 已跳过${NC}" + elif [ ! -d "/root/home_assistant_data" ]; then + local HA_DOMAIN="ha.${MAIN_DOMAIN}" + mkdir -p /root/home_assistant_data/config + cat > /root/home_assistant_data/docker-compose.yml << EOF +services: + homeassistant: + image: ghcr.io/home-assistant/home-assistant:stable + container_name: home_assistant_app + restart: always + ports: + - "127.0.0.1:8123:8123" + volumes: + - './config:/config' + - '/etc/localtime:/etc/localtime:ro' + environment: + - 'TZ=Asia/Shanghai' +EOF + cat > /root/home_assistant_data/config/configuration.yaml << EOF +default_config: + +http: + use_x_forwarded_for: true + trusted_proxies: + - 127.0.0.1 + - 172.16.0.0/12 + - 10.0.0.0/8 + - 192.168.0.0/16 +EOF + (cd /root/home_assistant_data && sudo docker compose up -d) + echo -e "\n## Home Assistant 凭证 (全装部署: $(date))" >> ${STATE_FILE} + echo "HA_DOMAIN=${HA_DOMAIN}" >> ${STATE_FILE} + update_tunnel_config "${HA_DOMAIN}" "http://127.0.0.1:8123" "Home Assistant" + echo -e "${GREEN}✅ Home Assistant 部署完成${NC}" + else + echo -e "${YELLOW}⏭️ Home Assistant 已安装,跳过${NC}" + fi + + # ── 25. WordPress ───────────────────────────────────────────── + _step "部署 WordPress 博客" + if [[ -n "${SKIP_MAP[25]}" ]]; then echo -e "${DIM} ⏭️ 已跳过${NC}" + elif [ ! -d "/root/wordpress_data" ]; then + local WP_DOMAIN="blog.${MAIN_DOMAIN}" + local WP_DB_PASS="WpDb-pW_$(_gen_password 12)" + local WP_DB_ROOT_PASS="WpRoot-pW_$(_gen_password 12)" + mkdir -p /root/wordpress_data + cat > /root/wordpress_data/docker-compose.yml << EOF +services: + db: + image: mariadb:11.4 + container_name: wordpress_db + restart: unless-stopped + volumes: + - './db_data:/var/lib/mysql' + environment: + MYSQL_ROOT_PASSWORD: ${WP_DB_ROOT_PASS} + MYSQL_DATABASE: wordpress + MYSQL_USER: wordpress + MYSQL_PASSWORD: ${WP_DB_PASS} + wordpress: + image: wordpress:latest + container_name: wordpress_app + restart: unless-stopped + ports: + - "127.0.0.1:8890:80" + volumes: + - './html:/var/www/html' + environment: + WORDPRESS_DB_HOST: db + WORDPRESS_DB_USER: wordpress + WORDPRESS_DB_PASSWORD: ${WP_DB_PASS} + WORDPRESS_DB_NAME: wordpress + depends_on: + - db +EOF + (cd /root/wordpress_data && sudo docker compose up -d) + echo -e "\n## WordPress 凭证 (全装部署: $(date))" >> ${STATE_FILE} + echo "WORDPRESS_DOMAIN=${WP_DOMAIN}" >> ${STATE_FILE} + echo "WORDPRESS_DB_PASSWORD=${WP_DB_PASS}" >> ${STATE_FILE} + update_tunnel_config "${WP_DOMAIN}" "http://127.0.0.1:8890" "WordPress" + echo -e "${GREEN}✅ WordPress 部署完成${NC}" + else + echo -e "${YELLOW}⏭️ WordPress 已安装,跳过${NC}" + fi + + # ── 26. Glances ─────────────────────────────────────────────── + _step "部署 Glances 实时资源监控" + if [[ -n "${SKIP_MAP[26]}" ]]; then echo -e "${DIM} ⏭️ 已跳过${NC}" + elif ! docker inspect glances_app &>/dev/null 2>&1; then + local GL_DOMAIN="glances.${MAIN_DOMAIN}" + docker run -d \ + --name glances_app \ + --restart always \ + -p 127.0.0.1:61208:61208 \ + -v /var/run/docker.sock:/var/run/docker.sock:ro \ + -v /:/host:ro \ + --pid host \ + nicolargo/glances:latest-full \ + /usr/local/bin/glances -w + echo -e "\n## Glances 凭证 (全装部署: $(date))" >> ${STATE_FILE} + echo "GLANCES_DOMAIN=${GL_DOMAIN}" >> ${STATE_FILE} + update_tunnel_config "${GL_DOMAIN}" "http://127.0.0.1:61208" "Glances Monitor" + echo -e "${GREEN}✅ Glances 部署完成${NC}" + else + echo -e "${YELLOW}⏭️ Glances 已安装,跳过${NC}" + fi + + # ── 27. Fail2ban ────────────────────────────────────────────── + _step "部署 Fail2ban 防暴力破解" + if [[ -n "${SKIP_MAP[27]}" ]]; then echo -e "${DIM} ⏭️ 已跳过${NC}" + elif ! systemctl is-active --quiet fail2ban 2>/dev/null; then + sudo apt-get update >/dev/null 2>&1 + sudo apt-get install -y fail2ban + sudo tee /etc/fail2ban/jail.local > /dev/null << 'EOF' +[DEFAULT] +bantime = 3600 +findtime = 600 +maxretry = 3 + +[sshd] +enabled = true +EOF + sudo systemctl enable --now fail2ban + echo -e "${GREEN}✅ Fail2ban 部署完成(SSH 防护已开启)${NC}" + else + echo -e "${YELLOW}⏭️ Fail2ban 已安装,跳过${NC}" + fi + + # ── 完成汇总 ────────────────────────────────────────────────── + local END_TIME; END_TIME=$(date +%s) + local ELAPSED=$(( (END_TIME - START_TIME) / 60 )) + + echo "" + echo -e "${GREEN}╔═══════════════════════════════════════════════════════════════════════════════╗${NC}" + printf "${GREEN}║${NC} 🎉 甲骨文满配全装完成!共 27 个服务,耗时约 ${ELAPSED} 分钟\n" + echo -e "${GREEN}╠═════════════════╦══════════════════════════════════════╦════════════════╣${NC}" + echo -e "${GREEN}║ 服务 ║ 访问域名 ║ 本地端口 ║${NC}" + echo -e "${GREEN}╠═════════════════╬══════════════════════════════════════╬════════════════╣${NC}" + printf "${GREEN}║${CYAN} %-15s${GREEN}║${NC} %-36s${GREEN}║${NC} %-14s${GREEN}║${NC}\n" "Nextcloud" "nextcloud.${MAIN_DOMAIN}" "8888" + printf "${GREEN}║${CYAN} %-15s${GREEN}║${NC} %-36s${GREEN}║${NC} %-14s${GREEN}║${NC}\n" "OnlyOffice" "onlyoffice.${MAIN_DOMAIN}" "8889" + printf "${GREEN}║${CYAN} %-15s${GREEN}║${NC} %-36s${GREEN}║${NC} %-14s${GREEN}║${NC}\n" "Jellyfin" "jellyfin.${MAIN_DOMAIN}" "8096" + printf "${GREEN}║${CYAN} %-15s${GREEN}║${NC} %-36s${GREEN}║${NC} %-14s${GREEN}║${NC}\n" "Navidrome" "music.${MAIN_DOMAIN}" "4533" + printf "${GREEN}║${CYAN} %-15s${GREEN}║${NC} %-36s${GREEN}║${NC} %-14s${GREEN}║${NC}\n" "Miniflux" "rss.${MAIN_DOMAIN}" "8091" + printf "${GREEN}║${CYAN} %-15s${GREEN}║${NC} %-36s${GREEN}║${NC} %-14s${GREEN}║${NC}\n" "Memos" "memos.${MAIN_DOMAIN}" "5230" + printf "${GREEN}║${CYAN} %-15s${GREEN}║${NC} %-36s${GREEN}║${NC} %-14s${GREEN}║${NC}\n" "AI 大脑" "ai.${MAIN_DOMAIN}" "3001" + printf "${GREEN}║${CYAN} %-15s${GREEN}║${NC} %-36s${GREEN}║${NC} %-14s${GREEN}║${NC}\n" "Uptime Kuma" "status.${MAIN_DOMAIN}" "3002" + printf "${GREEN}║${CYAN} %-15s${GREEN}║${NC} %-36s${GREEN}║${NC} %-14s${GREEN}║${NC}\n" "qBittorrent" "bt.${MAIN_DOMAIN}" "8080" + printf "${GREEN}║${CYAN} %-15s${GREEN}║${NC} %-36s${GREEN}║${NC} %-14s${GREEN}║${NC}\n" "SillyTavern" "st.${MAIN_DOMAIN}" "8000" + printf "${GREEN}║${CYAN} %-15s${GREEN}║${NC} %-36s${GREEN}║${NC} %-14s${GREEN}║${NC}\n" "N8N 工作流" "n8n.${MAIN_DOMAIN}" "5678" + printf "${GREEN}║${CYAN} %-15s${GREEN}║${NC} %-36s${GREEN}║${NC} %-14s${GREEN}║${NC}\n" "Immich 相册" "photos.${MAIN_DOMAIN}" "2283" + printf "${GREEN}║${CYAN} %-15s${GREEN}║${NC} %-36s${GREEN}║${NC} %-14s${GREEN}║${NC}\n" "Calibre-Web" "books.${MAIN_DOMAIN}" "8083" + printf "${GREEN}║${CYAN} %-15s${GREEN}║${NC} %-36s${GREEN}║${NC} %-14s${GREEN}║${NC}\n" "Kavita 阅读" "manga.${MAIN_DOMAIN}" "5000" + printf "${GREEN}║${CYAN} %-15s${GREEN}║${NC} %-36s${GREEN}║${NC} %-14s${GREEN}║${NC}\n" "Gitea 代码" "git.${MAIN_DOMAIN}" "3000" + printf "${GREEN}║${CYAN} %-15s${GREEN}║${NC} %-36s${GREEN}║${NC} %-14s${GREEN}║${NC}\n" "JDownloader" "jd.${MAIN_DOMAIN}" "5800" + printf "${GREEN}║${CYAN} %-15s${GREEN}║${NC} %-36s${GREEN}║${NC} %-14s${GREEN}║${NC}\n" "MeTube" "dl.${MAIN_DOMAIN}" "8999" + printf "${GREEN}║${CYAN} %-15s${GREEN}║${NC} %-36s${GREEN}║${NC} %-14s${GREEN}║${NC}\n" "Draw.io" "draw.${MAIN_DOMAIN}" "8082" + printf "${GREEN}║${CYAN} %-15s${GREEN}║${NC} %-36s${GREEN}║${NC} %-14s${GREEN}║${NC}\n" "Gotify 推送" "push.${MAIN_DOMAIN}" "8085" + printf "${GREEN}║${CYAN} %-15s${GREEN}║${NC} %-36s${GREEN}║${NC} %-14s${GREEN}║${NC}\n" "Actual Budget" "money.${MAIN_DOMAIN}" "5006" + printf "${GREEN}║${CYAN} %-15s${GREEN}║${NC} %-36s${GREEN}║${NC} %-14s${GREEN}║${NC}\n" "Excalidraw" "board.${MAIN_DOMAIN}" "8086" + printf "${GREEN}║${CYAN} %-15s${GREEN}║${NC} %-36s${GREEN}║${NC} %-14s${GREEN}║${NC}\n" "Joplin 笔记" "notes.${MAIN_DOMAIN}" "22300" + printf "${GREEN}║${CYAN} %-15s${GREEN}║${NC} %-36s${GREEN}║${NC} %-14s${GREEN}║${NC}\n" "Grafana" "monitor.${MAIN_DOMAIN}" "3003" + printf "${GREEN}║${CYAN} %-15s${GREEN}║${NC} %-36s${GREEN}║${NC} %-14s${GREEN}║${NC}\n" "Home Assistant" "ha.${MAIN_DOMAIN}" "8123" + printf "${GREEN}║${CYAN} %-15s${GREEN}║${NC} %-36s${GREEN}║${NC} %-14s${GREEN}║${NC}\n" "WordPress" "blog.${MAIN_DOMAIN}" "8890" + printf "${GREEN}║${CYAN} %-15s${GREEN}║${NC} %-36s${GREEN}║${NC} %-14s${GREEN}║${NC}\n" "Glances" "glances.${MAIN_DOMAIN}" "61208" + printf "${GREEN}║${CYAN} %-15s${GREEN}║${NC} %-36s${GREEN}║${NC} %-14s${GREEN}║${NC}\n" "Fail2ban" "(系统服务,无域名)" "-" + echo -e "${GREEN}╠═══════════════════════════════════════════════════════════════════════════════╣${NC}" + echo -e "${YELLOW}║ 📋 后续操作: ║${NC}" + echo -e "${YELLOW}║ 1. 前往 Cloudflare DNS 将以上子域名 CNAME 指向您的 Tunnel ║${NC}" + echo -e "${YELLOW}║ 2. 访问 Nextcloud 完成初始化(数据库密码用选项 10.5 查看) ║${NC}" + echo -e "${YELLOW}║ 3. 执行选项 10.1) Nextcloud 精装修,10.2) 高速风景版 ║${NC}" + echo -e "${YELLOW}║ 4. 执行选项 5.2) 为 AI 大脑安装语言模型 ║${NC}" + echo -e "${YELLOW}║ 5. 用选项 10.5) 随时查看所有密码、端口、凭证 ║${NC}" + echo -e "${GREEN}╚═══════════════════════════════════════════════════════════════════════════════╝${NC}" + echo "" + echo -e "${CYAN}所有凭证(密码/域名/端口)已统一保存至 ${STATE_FILE}${NC}" + + # ── 打印 DNS 手动添加清单 ───────────────────────────────────── + local _t_id + _t_id=$(grep "CLOUDFLARE_TOKEN" "${STATE_FILE}" 2>/dev/null | cut -d'=' -f2 | python3 -c " +import json,sys,base64 +t=sys.stdin.read().strip() +try: + d=json.loads(base64.b64decode(t + '==').decode()) + print(d.get('t','')) +except: + print('') +" 2>/dev/null) + [ -z "$_t_id" ] && _t_id="<在 CF Zero Trust → Tunnels 找到您的 Tunnel ID>" + + echo "" + echo -e "${CYAN}══════════════════════════════════════════════════════════════════════${NC}" + echo -e "${CYAN} 📋 Cloudflare DNS 手动添加清单(全部 CNAME,代理状态:橙色云朵)${NC}" + echo -e "${CYAN}══════════════════════════════════════════════════════════════════════${NC}" + echo -e "${DIM} 内容(目标)统一填写:${YELLOW}${_t_id}.cfargotunnel.com${NC}" + echo "" + local domains_list=( + "nextcloud:1" "onlyoffice:2" "jellyfin:3" "music:4" "rss:5" + "memos:6" "ai:7" "status:8" "bt:9" "st:10" "n8n:11" + "photos:12" "books:13" "manga:14" "git:15" "jd:16" + "dl:17" "draw:18" "push:19" "money:20" "board:21" + "notes:22" "monitor:23" "ha:24" "blog:25" "glances:26" + ) + for item in "${domains_list[@]}"; do + local sub="${item%%:*}" + local num="${item##*:}" + [[ -z "${SKIP_MAP[$num]}" ]] && printf " ${YELLOW}%-22s${NC} %s\n" "${sub}" "${_t_id}.cfargotunnel.com" + done + echo -e "${CYAN}══════════════════════════════════════════════════════════════════════${NC}" + + echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s +} + +# ================================================================ +# --- 删除/卸载 SSHFS 远程VPS挂载 --- +# ================================================================ +remove_sshfs_mount() { + clear + echo -e "${BLUE}=== 🗑️ 删除外挂VPS网盘 (SSHFS) ===${NC}" + echo "" + + # 读取 STATE_FILE 中的挂载记录 + local STATE_FILE="/root/.vps_setup_credentials" + local mounts=() + local units=() + local remotes=() + + if [ -f "$STATE_FILE" ]; then + while IFS='=' read -r key val; do + [[ "$key" =~ ^SSHFS_.*_LOCAL$ ]] && mounts+=("$val") + [[ "$key" =~ ^SSHFS_.*_UNIT$ ]] && units+=("$val") + [[ "$key" =~ ^SSHFS_.*_REMOTE$ ]] && remotes+=("$val") + done < "$STATE_FILE" + fi + + # 同时扫描当前实际 fuse.sshfs 挂载点(避免记录丢失时也能操作) + echo -e "${YELLOW}── 当前 SSHFS 挂载点 ──────────────────────────────${NC}" + local live_mounts=() + while IFS= read -r line; do + local mp; mp=$(echo "$line" | awk '{print $3}') + live_mounts+=("$mp") + done < <(mount 2>/dev/null | grep -E "fuse\.sshfs|type fuse[^.]") + + if [ ${#live_mounts[@]} -eq 0 ] && [ ${#mounts[@]} -eq 0 ]; then + echo -e "${YELLOW} 当前没有任何 SSHFS 挂载记录。${NC}" + echo -e "\n${GREEN}按任意键返回...${NC}"; read -n 1 -s; return + fi + + # 合并去重显示 + local all_mounts=() + for m in "${live_mounts[@]}" "${mounts[@]}"; do + local found=false + for a in "${all_mounts[@]}"; do [ "$a" = "$m" ] && found=true && break; done + $found || all_mounts+=("$m") + done + + local i=1 + for mp in "${all_mounts[@]}"; do + local is_live="" + # mount 输出格式:`source on /mnt/NAME type fuse.sshfs (...)` + # 用 " on " 精确匹配挂载点字段,避免路径被当成 source 端误匹配 + if mount 2>/dev/null | grep -qF " on ${mp} "; then + is_live="${GREEN}[已挂载]${NC}" + else + is_live="${RED}[未挂载/残骸]${NC}" + fi + # 从 STATE_FILE 读取对应 remote 信息 + local remote_info="" + for j in "${!mounts[@]}"; do + [ "${mounts[$j]}" = "$mp" ] && remote_info=" ← ${remotes[$j]:-}" && break + done + echo -e " ${YELLOW}${i})${NC} ${mp}${remote_info} ${is_live}" + ((i++)) + done + + echo "" + read -p "请输入要删除的编号 (q 取消): " SEL + [[ "$SEL" == "q" || "$SEL" == "Q" || -z "$SEL" ]] && return + + if ! [[ "$SEL" =~ ^[0-9]+$ ]] || [ "$SEL" -lt 1 ] || [ "$SEL" -gt ${#all_mounts[@]} ]; then + echo -e "${RED}❌ 无效编号${NC}"; sleep 2; return + fi + + local TARGET_MOUNT="${all_mounts[$((SEL-1))]}" + local UNIT_NAME="mnt-$(echo "${TARGET_MOUNT}" | sed 's|^/||' | tr '/' '-')" + + echo "" + echo -e "${YELLOW}即将删除挂载点:${CYAN}${TARGET_MOUNT}${NC}" + echo -e " 对应 systemd 服务:${UNIT_NAME}.service" + echo "" + echo -e " ${YELLOW}1)${NC} 仅卸载(保留 systemd 服务,下次重启不再自动挂载但服务文件还在)" + echo -e " ${YELLOW}2)${NC} 完全清除(卸载 + 删除 systemd 服务 + 清除凭证记录)【推荐】" + echo -e " ${YELLOW}q)${NC} 取消" + echo "" + read -p "请选择: " MODE + + case "$MODE" in + 1|2) + # Step 1: 卸载挂载点 + if mount 2>/dev/null | grep -qF " on ${TARGET_MOUNT} "; then + echo -e "${YELLOW}正在卸载 ${TARGET_MOUNT}...${NC}" + fusermount -u "${TARGET_MOUNT}" 2>/dev/null \ + || umount "${TARGET_MOUNT}" 2>/dev/null \ + || umount -l "${TARGET_MOUNT}" 2>/dev/null + if ! mount 2>/dev/null | grep -qF " on ${TARGET_MOUNT} "; then + echo -e "${GREEN}✅ 卸载成功${NC}" + else + echo -e "${RED}❌ 卸载失败,可能有进程正在使用该挂载点,请关闭后重试。${NC}" + read -n 1 -s -r -p "按任意键返回..."; return + fi + else + echo -e "${YELLOW} 该挂载点当前未挂载,跳过卸载步骤。${NC}" + fi + + if [ "$MODE" == "2" ]; then + # Step 2: 停用并删除 systemd 服务 + if systemctl list-units --all 2>/dev/null | grep -q "${UNIT_NAME}.service"; then + echo -e "${YELLOW}正在停用并删除 systemd 服务 ${UNIT_NAME}.service...${NC}" + systemctl disable --now "${UNIT_NAME}.service" 2>/dev/null + rm -f "/etc/systemd/system/${UNIT_NAME}.service" + systemctl daemon-reload + echo -e "${GREEN}✅ systemd 服务已清除${NC}" + else + echo -e "${YELLOW} 未找到对应 systemd 服务,跳过。${NC}" + fi + + # Step 3: 删除私钥文件(如有) + local IP_PART + IP_PART=$(echo "${TARGET_MOUNT}" | sed 's|/mnt/||' | tr '-' '.') + local KEY_PATH="/root/.ssh/sshfs_key_$(echo "${TARGET_MOUNT}" | sed 's|.*/||' | tr '-' '_')" + # 尝试从 STATE_FILE 推断 IP 格式的私钥路径 + for key_try in /root/.ssh/sshfs_key_*; do + [ -f "$key_try" ] || continue + done + + # Step 4: 从 STATE_FILE 清除对应记录 + if [ -f "$STATE_FILE" ]; then + # 找到匹配此 LOCAL 路径的 IP key 前缀,然后清除三行 + local ESCAPED_MOUNT + ESCAPED_MOUNT=$(echo "${TARGET_MOUNT}" | sed 's|/|\\/|g') + # 先找出对应的 IP(通过 _LOCAL= 值反查 key) + local MATCH_IP="" + while IFS='=' read -r k v; do + if [[ "$k" =~ ^SSHFS_(.*)_LOCAL$ ]] && [ "$v" = "$TARGET_MOUNT" ]; then + MATCH_IP="${BASH_REMATCH[1]}" + break + fi + done < "$STATE_FILE" + if [ -n "$MATCH_IP" ]; then + sed -i "/^SSHFS_${MATCH_IP}_LOCAL=/d" "$STATE_FILE" + sed -i "/^SSHFS_${MATCH_IP}_REMOTE=/d" "$STATE_FILE" + sed -i "/^SSHFS_${MATCH_IP}_UNIT=/d" "$STATE_FILE" + echo -e "${GREEN}✅ 凭证记录已清除${NC}" + fi + fi + + # Step 5: 询问是否同步从 Nextcloud 外部存储删除 + if docker ps 2>/dev/null | grep -q "nextcloud_app"; then + echo "" + echo -e "${YELLOW}检测到 Nextcloud 正在运行。${NC}" + echo -e "当前外部存储列表:" + docker exec -u www-data nextcloud_app php occ files_external:list 2>/dev/null + echo "" + read -p "是否同时从 Nextcloud 外部存储中删除对应条目?(y/n,默认n): " DEL_NC + if [ "$DEL_NC" == "y" ] || [ "$DEL_NC" == "Y" ]; then + read -p "请输入上面列表中对应的 Mount ID: " DEL_ID + if [ -n "$DEL_ID" ]; then + docker exec -u www-data nextcloud_app php occ files_external:delete "$DEL_ID" 2>/dev/null \ + && echo -e "${GREEN}✅ Nextcloud 外部存储已删除${NC}" \ + || echo -e "${RED}❌ 删除失败,请手动在 Nextcloud 管理界面操作${NC}" + fi + fi + fi + + # Step 6: 询问是否删除本地挂载目录(空目录才删) + echo "" + read -p "是否删除本地空目录 ${TARGET_MOUNT}?(y/n,默认n): " DEL_DIR + if [ "$DEL_DIR" == "y" ] || [ "$DEL_DIR" == "Y" ]; then + rmdir "${TARGET_MOUNT}" 2>/dev/null \ + && echo -e "${GREEN}✅ 目录已删除${NC}" \ + || echo -e "${YELLOW}⚠️ 目录非空或删除失败,已跳过(数据安全,不强制删除)${NC}" + fi + + echo "" + echo -e "${GREEN}=== ✅ 外挂VPS「${TARGET_MOUNT}」已完全清除 ===${NC}" + else + echo "" + echo -e "${GREEN}=== ✅ 已卸载「${TARGET_MOUNT}」(systemd 服务保留,重启后不会自动挂载)===${NC}" + fi + ;; + q|Q|*) return ;; + esac + + echo -e "\n${GREEN}按任意键返回...${NC}"; read -n 1 -s +} + +# ================================================================ +# --- SSHFS 管理子菜单(安装 + 删除)--- +# ================================================================ +manage_sshfs_menu() { + clear + echo -e "${BLUE}=== 🌐 外挂VPS网盘管理 (SSHFS) ===${NC}" + echo "" + # 显示当前挂载状态 + local count; count=$(mount 2>/dev/null | grep -cE "fuse\.sshfs|type fuse[^.]" || echo 0) + if [ "$count" -gt 0 ]; then + echo -e "${GREEN} 当前已挂载 ${count} 个远程VPS:${NC}" + mount 2>/dev/null | grep -E "fuse\.sshfs|type fuse[^.]" | awk '{print " ✅ "$1" → "$3}' + else + echo -e "${YELLOW} 当前没有活跃的 SSHFS 挂载。${NC}" + fi + echo "" + echo -e " ${YELLOW}1)${NC} ➕ 添加/挂载新的远程VPS" + echo -e " ${YELLOW}2)${NC} 🗑️ 删除/卸载已有的远程VPS" + echo -e " ${YELLOW}3)${NC} 📋 查看所有挂载状态" + echo -e " ${YELLOW}q)${NC} 返回主菜单" + echo "" + read -p "请选择: " sub + case "$sub" in + 1) install_sshfs_mount ;; + 2) remove_sshfs_mount ;; + 3) + echo "" + echo -e "${YELLOW}=== systemd 服务状态 ===${NC}" + local found_svc=false + for svc in $(systemctl list-units --type=service --all 2>/dev/null | grep "^mnt-" | awk '{print $1}'); do + found_svc=true + local _state; _state=$(systemctl is-active "$svc" 2>/dev/null) + [ "$_state" == "active" ] \ + && echo -e " ${GREEN}✅ $svc (运行中)${NC}" \ + || echo -e " ${RED}❌ $svc ($_state)${NC}" + done + $found_svc || echo -e " ${YELLOW}未找到任何 mnt-*.service${NC}" + echo "" + echo -e "${YELLOW}=== 当前 FUSE/SSHFS 挂载点 ===${NC}" + mount | grep -E "fuse|sshfs" | while read -r l; do echo -e " ${GREEN}$l${NC}"; done + echo "" + echo -e "${YELLOW}=== /mnt 目录 ===${NC}" + ls -la /mnt/ + echo "" + read -n 1 -s -r -p "按任意键返回..." + manage_sshfs_menu + ;; + *) return ;; + esac +} + +# ================================================================ +# --- 新增功能:SSHFS 远程VPS挂载网盘 --- +install_sshfs_mount() { + clear + echo -e "${BLUE}=== 🌐 连接远程VPS文件夹为本机网盘 (SSHFS) ===${NC}" + echo -e "${CYAN}此功能将另一台VPS的指定文件夹挂载到本机,并自动注册到 Nextcloud 外部存储。${NC}" + echo "" + + # 安装 sshfs + if ! command -v sshfs &> /dev/null; then + echo -e "${YELLOW}正在安装 SSHFS...${NC}" + sudo apt-get update && sudo apt-get install -y sshfs + fi + + # 收集信息 + read -p "请输入对方VPS的IP地址: " REMOTE_IP + [ -z "$REMOTE_IP" ] && echo -e "${RED}IP不能为空!${NC}" && return + + read -p "请输入对方VPS的SSH用户名 (默认 ubuntu): " REMOTE_USER + [ -z "$REMOTE_USER" ] && REMOTE_USER="ubuntu" + + read -p "请输入对方VPS的SSH端口 (默认 22): " REMOTE_PORT + [ -z "$REMOTE_PORT" ] && REMOTE_PORT="22" + + read -p "请输入要挂载对方的哪个文件夹路径 (例如 /home/ubuntu/data): " REMOTE_PATH + [ -z "$REMOTE_PATH" ] && echo -e "${RED}远程路径不能为空!${NC}" && return + + read -p "请输入挂载到本机的路径 (例如 /mnt/TOKYO,留空自动生成): " LOCAL_MOUNT + if [ -z "$LOCAL_MOUNT" ]; then + LOCAL_MOUNT="/mnt/remote_$(echo $REMOTE_IP | tr '.' '_')" + fi + + # 挂载点名称(用于菜单显示) + MOUNT_NAME=$(basename "$LOCAL_MOUNT") + + echo "" + echo -e "${YELLOW}SSH认证方式:${NC}" + echo " 1) 使用 SSH 私钥文件(推荐,更安全稳定)" + echo " 2) 使用密码(需要安装 sshpass)" + read -p "请选择 (1/2): " AUTH_CHOICE + + SSH_KEY_OPT="" + SSH_PASS_OPT="" + KEY_FILE="" + if [ "$AUTH_CHOICE" == "1" ]; then + echo "" + echo -e "${CYAN}请将对方给你的私钥内容粘贴进来,粘贴完成后新起一行输入 END 回车:${NC}" + KEY_FILE="/root/.ssh/sshfs_key_$(echo $REMOTE_IP | tr '.' '_')" + > "$KEY_FILE" + while IFS= read -r line; do + [ "$line" == "END" ] && break + echo "$line" >> "$KEY_FILE" + done + chmod 600 "$KEY_FILE" + SSH_KEY_OPT="-o IdentityFile=$KEY_FILE" + echo -e "${GREEN}私钥已保存到 $KEY_FILE${NC}" + else + apt-get install -y sshpass > /dev/null 2>&1 + read -s -p "请输入对方VPS的SSH密码: " REMOTE_PASS + echo "" + SSH_PASS_OPT="sshpass -p '${REMOTE_PASS}'" + fi + + # 创建挂载点 + sudo mkdir -p "$LOCAL_MOUNT" + + # 执行挂载(加入 uid=33,gid=33 确保 Nextcloud www-data 用户可读) + echo -e "${YELLOW}正在挂载,请稍候...${NC}" + MOUNT_CMD="sshfs ${REMOTE_USER}@${REMOTE_IP}:${REMOTE_PATH} ${LOCAL_MOUNT} -p ${REMOTE_PORT} ${SSH_KEY_OPT} -o StrictHostKeyChecking=no -o reconnect -o ServerAliveInterval=15 -o ServerAliveCountMax=3 -o allow_other -o uid=33,gid=33,default_permissions" + + if [ -n "$SSH_PASS_OPT" ]; then + eval "${SSH_PASS_OPT} ${MOUNT_CMD}" 2>/dev/null + else + eval "${MOUNT_CMD}" 2>/dev/null + fi + + if mount | grep -q "$LOCAL_MOUNT"; then + echo -e "${GREEN}✅ 挂载成功!以下是对方文件夹内容:${NC}" + ls "$LOCAL_MOUNT" | head -8 + else + echo -e "${RED}❌ 挂载失败,请检查IP、用户名、端口或密钥是否正确。${NC}" + read -n 1 -s -r -p "按任意键返回..."; return + fi + + # 设置开机自动挂载(包含正确的 uid/gid) + read -p "是否设置开机自动挂载?(y/n,默认y): " AUTO_MOUNT + UNIT_NAME="mnt-$(echo ${LOCAL_MOUNT} | sed 's|^/||' | tr '/' '-')" + if [ "$AUTO_MOUNT" != "n" ] && [ "$AUTO_MOUNT" != "N" ]; then + if [ -n "$SSH_PASS_OPT" ]; then + # 密码认证:ExecStart 用 sshpass + sudo tee /etc/systemd/system/${UNIT_NAME}.service > /dev/null << UNITEOF +[Unit] +Description=SSHFS Mount ${REMOTE_IP}:${REMOTE_PATH} to ${LOCAL_MOUNT} +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/bin/bash -c 'sshpass -p "${REMOTE_PASS}" sshfs ${REMOTE_USER}@${REMOTE_IP}:${REMOTE_PATH} ${LOCAL_MOUNT} -p ${REMOTE_PORT} -o StrictHostKeyChecking=no -o reconnect -o ServerAliveInterval=15 -o ServerAliveCountMax=3 -o allow_other -o uid=33,gid=33,default_permissions' +ExecStop=/bin/fusermount -u ${LOCAL_MOUNT} + +[Install] +WantedBy=multi-user.target +UNITEOF + else + sudo tee /etc/systemd/system/${UNIT_NAME}.service > /dev/null << UNITEOF +[Unit] +Description=SSHFS Mount ${REMOTE_IP}:${REMOTE_PATH} to ${LOCAL_MOUNT} +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/usr/bin/sshfs ${REMOTE_USER}@${REMOTE_IP}:${REMOTE_PATH} ${LOCAL_MOUNT} -p ${REMOTE_PORT} ${SSH_KEY_OPT} -o StrictHostKeyChecking=no -o reconnect -o ServerAliveInterval=15 -o ServerAliveCountMax=3 -o allow_other -o uid=33,gid=33,default_permissions +ExecStop=/bin/fusermount -u ${LOCAL_MOUNT} + +[Install] +WantedBy=multi-user.target +UNITEOF + fi + sudo systemctl daemon-reload + sudo systemctl enable "${UNIT_NAME}.service" + echo -e "${GREEN}✅ 已设置开机自动挂载!${NC}" + fi + + # 自动注册到 Nextcloud 外部存储 + if docker ps 2>/dev/null | grep -q "nextcloud_app"; then + echo "" + read -p "检测到 Nextcloud 正在运行,是否自动注册为外部存储?(y/n,默认y): " NC_REG + if [ "$NC_REG" != "n" ] && [ "$NC_REG" != "N" ]; then + echo -e "${YELLOW}正在注册 Nextcloud 外部存储...${NC}" + docker exec -u www-data nextcloud_app php occ files_external:create \ + "/${MOUNT_NAME}" local null::null \ + --config=datadir="${LOCAL_MOUNT}" 2>/dev/null \ + && echo -e "${GREEN}✅ 已自动注册!在 Nextcloud 文件页面刷新即可看到 /${MOUNT_NAME}${NC}" \ + || echo -e "${YELLOW}⚠️ 自动注册失败,请手动在 Nextcloud → 设置 → 外部存储 中添加路径 ${LOCAL_MOUNT}${NC}" + + # 清除文件锁再扫描,避免 is locked 报错 + sleep 3 + echo -e "${YELLOW}正在清除旧文件锁...${NC}" + docker exec -u www-data nextcloud_app php occ maintenance:mode --on 2>/dev/null + local _DB_PASS + _DB_PASS=$(grep "MYSQL_PASSWORD" /root/nextcloud_data/docker-compose.yml 2>/dev/null | head -1 | sed 's/.*MYSQL_PASSWORD: //' | tr -d ' ') + [ -n "$_DB_PASS" ] && docker exec nextcloud_db mariadb -u nextclouduser -p"${_DB_PASS}" nextclouddb \ + -e "DELETE FROM oc_file_locks WHERE 1;" 2>/dev/null + docker exec -u www-data nextcloud_app php occ maintenance:mode --off 2>/dev/null + + echo -e "${YELLOW}正在扫描外部存储文件...${NC}" + docker exec -u www-data nextcloud_app php occ files:scan --all --quiet 2>/dev/null + echo -e "${GREEN}✅ 扫描完成!${NC}" + fi + fi + + # 保存记录 + echo -e "\n## SSHFS 挂载记录 (添加于: $(date))" >> ${STATE_FILE} + echo "SSHFS_${REMOTE_IP}_LOCAL=${LOCAL_MOUNT}" >> ${STATE_FILE} + echo "SSHFS_${REMOTE_IP}_REMOTE=${REMOTE_USER}@${REMOTE_IP}:${REMOTE_PATH}" >> ${STATE_FILE} + echo "SSHFS_${REMOTE_IP}_UNIT=${UNIT_NAME}" >> ${STATE_FILE} + + echo "" + echo -e "${GREEN}=== ✅ 挂载完成 ===${NC}" + echo -e " 远程地址 :${CYAN}${REMOTE_USER}@${REMOTE_IP}:${REMOTE_PATH}${NC}" + echo -e " 本机路径 :${CYAN}${LOCAL_MOUNT}${NC}" + echo -e " 权限配置 :${GREEN}uid=33,gid=33 (www-data,Nextcloud 可直接读写)${NC}" + echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s +} + + +# ================================================================ +# --- 新增功能:部署 SillyTavern 酒馆 --- +# ── install_immich ── +install_immich() { + ensure_docker_installed || return; check_tunnel_installed || return; clear + echo -e "${BLUE}=== 📸 部署 Immich 私人相册(Google Photos 替代)===${NC}" + echo -e "${CYAN}Immich 支持手机自动备份、AI人脸识别、地图、相册共享${NC}" + echo "" + + read -p "请输入您为 Immich 规划的子域名 (例如 photos.example.com): " IMMICH_DOMAIN + if [ -z "$IMMICH_DOMAIN" ]; then echo -e "${RED}域名不能为空!${NC}"; sleep 2; return; fi + + # 询问媒体库路径 + echo "" + echo -e "${CYAN}当前 /mnt 下可用目录:${NC}" + find /mnt -maxdepth 2 -mindepth 1 -type d 2>/dev/null | sort | while read -r d; do + echo -e " ${GREEN}${d}${NC}" + done + echo "" + echo -e "${CYAN}请输入照片存储路径(建议存在远程VPS挂载点,不占本机硬盘)${NC}" + read -p "照片路径 (默认 /mnt/Photos): " IMMICH_UPLOAD_PATH + [ -z "$IMMICH_UPLOAD_PATH" ] && IMMICH_UPLOAD_PATH="/mnt/Photos" + sudo mkdir -p "$IMMICH_UPLOAD_PATH" + + # 生成随机密钥 + local DB_PASSWORD="immich_db_$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 16)" + local JWT_SECRET="$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 32)" + + mkdir -p /root/immich_data + cat > /root/immich_data/docker-compose.yml <> ${STATE_FILE} + echo "IMMICH_DOMAIN=${IMMICH_DOMAIN}" >> ${STATE_FILE} + echo "IMMICH_UPLOAD_PATH=${IMMICH_UPLOAD_PATH}" >> ${STATE_FILE} + echo "IMMICH_DB_PASSWORD=${DB_PASSWORD}" >> ${STATE_FILE} + + echo "" + echo -e "${GREEN}╔══════════════════════════════════════════════════════════╗${NC}" + echo -e "${GREEN}║ ✅ Immich 部署成功! ║${NC}" + echo -e "${GREEN}╠══════════════════════════════════════════════════════════╣${NC}" + echo -e "${GREEN}║ 访问地址:${CYAN}https://${IMMICH_DOMAIN}${NC}" + echo -e "${GREEN}║ 照片路径:${CYAN}${IMMICH_UPLOAD_PATH}${NC}" + echo -e "${GREEN}║${NC}" + echo -e "${GREEN}║ ${YELLOW}首次访问需注册管理员账号(第一个注册即为管理员)${NC}" + echo -e "${GREEN}║ ${YELLOW}手机端下载 Immich App,填入域名即可自动备份${NC}" + echo -e "${GREEN}╚══════════════════════════════════════════════════════════╝${NC}" + else + echo -e "${RED}❌ Immich 部署失败!请检查日志。${NC}" + echo -e "${YELLOW}提示:docker compose -f /root/immich_data/docker-compose.yml logs${NC}" + fi + echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s +} + +# ── install_calibre_web ── +install_calibre_web() { + ensure_docker_installed || return; check_tunnel_installed || return; clear + echo -e "${BLUE}=== 📚 部署 Calibre-Web 电子书库 ===${NC}" + echo -e "${CYAN}支持 EPUB/PDF/MOBI,网页阅读,可推送到 Kindle${NC}" + echo "" + read -p "请输入 Calibre-Web 的子域名 (例如 books.example.com): " CW_DOMAIN + [ -z "$CW_DOMAIN" ] && { echo -e "${RED}域名不能为空!${NC}"; sleep 2; return; } + + echo "" + echo -e "${CYAN}当前 /mnt 下可用目录:${NC}" + find /mnt -maxdepth 2 -mindepth 1 -type d 2>/dev/null | sort | while read -r d; do echo -e " ${GREEN}${d}${NC}"; done + echo "" + read -p "书库路径 (默认 /mnt/Books): " CW_BOOKS_PATH + [ -z "$CW_BOOKS_PATH" ] && CW_BOOKS_PATH="/mnt/Books" + sudo mkdir -p "$CW_BOOKS_PATH" + + mkdir -p /root/calibre_web_data/config + cat > /root/calibre_web_data/docker-compose.yml <> ${STATE_FILE} + echo "CALIBRE_WEB_DOMAIN=${CW_DOMAIN}" >> ${STATE_FILE} + echo "CALIBRE_WEB_BOOKS_PATH=${CW_BOOKS_PATH}" >> ${STATE_FILE} + echo "" + echo -e "${GREEN}╔══════════════════════════════════════════════════════╗${NC}" + echo -e "${GREEN}║ ✅ Calibre-Web 部署成功! ║${NC}" + echo -e "${GREEN}║ 访问:${CYAN}https://${CW_DOMAIN}${NC}" + echo -e "${GREEN}║ 默认账号:${CYAN}admin${NC} 默认密码:${CYAN}admin123${NC}" + echo -e "${GREEN}║ 首次登录后进入 设置→书库 填写路径: /books ║${NC}" + echo -e "${GREEN}╚══════════════════════════════════════════════════════╝${NC}" + else + echo -e "${RED}❌ Calibre-Web 部署失败!${NC}" + fi + echo -e "\n${GREEN}按任意键返回...${NC}"; read -n 1 -s +} + +# ── install_kavita ── +install_kavita() { + ensure_docker_installed || return; check_tunnel_installed || return; clear + echo -e "${BLUE}=== 📖 部署 Kavita 阅读器 ===${NC}" + echo -e "${CYAN}支持漫画(CBZ/CBR)、EPUB、PDF,阅读体验极佳${NC}" + echo "" + read -p "请输入 Kavita 的子域名 (例如 read.example.com): " KV_DOMAIN + [ -z "$KV_DOMAIN" ] && { echo -e "${RED}域名不能为空!${NC}"; sleep 2; return; } + + echo "" + echo -e "${CYAN}当前 /mnt 下可用目录:${NC}" + find /mnt -maxdepth 2 -mindepth 1 -type d 2>/dev/null | sort | while read -r d; do echo -e " ${GREEN}${d}${NC}"; done + echo "" + read -p "书库路径 (默认 /mnt/Books): " KV_BOOKS_PATH + [ -z "$KV_BOOKS_PATH" ] && KV_BOOKS_PATH="/mnt/Books" + sudo mkdir -p "$KV_BOOKS_PATH" + + mkdir -p /root/kavita_data/config + cat > /root/kavita_data/docker-compose.yml <> ${STATE_FILE} + echo "KAVITA_DOMAIN=${KV_DOMAIN}" >> ${STATE_FILE} + echo "KAVITA_BOOKS_PATH=${KV_BOOKS_PATH}" >> ${STATE_FILE} + echo "" + echo -e "${GREEN}╔══════════════════════════════════════════════════════╗${NC}" + echo -e "${GREEN}║ ✅ Kavita 部署成功! ║${NC}" + echo -e "${GREEN}║ 访问:${CYAN}https://${KV_DOMAIN}${NC}" + echo -e "${GREEN}║ 首次访问需设置管理员账号密码 ║${NC}" + echo -e "${GREEN}║ 登录后进入 设置→书库 添加路径: /manga ║${NC}" + echo -e "${GREEN}╚══════════════════════════════════════════════════════╝${NC}" + else + echo -e "${RED}❌ Kavita 部署失败!${NC}" + fi + echo -e "\n${GREEN}按任意键返回...${NC}"; read -n 1 -s +} + +# ── install_gotify ── +install_gotify() { + ensure_docker_installed || return; check_tunnel_installed || return; clear + echo -e "${BLUE}=== 🔔 部署 Gotify 消息推送服务 ===${NC}" + echo -e "${CYAN}自建推送服务,N8N/服务器报警可推送到手机${NC}" + echo "" + read -p "请输入 Gotify 的子域名 (例如 push.example.com): " GT_DOMAIN + [ -z "$GT_DOMAIN" ] && { echo -e "${RED}域名不能为空!${NC}"; sleep 2; return; } + + local GT_PASS="gotify_$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 12)" + + mkdir -p /root/gotify_data/data + cat > /root/gotify_data/docker-compose.yml <> ${STATE_FILE} + echo "GOTIFY_DOMAIN=${GT_DOMAIN}" >> ${STATE_FILE} + echo "GOTIFY_PASSWORD=${GT_PASS}" >> ${STATE_FILE} + echo "" + echo -e "${GREEN}╔══════════════════════════════════════════════════════╗${NC}" + echo -e "${GREEN}║ ✅ Gotify 部署成功! ║${NC}" + echo -e "${GREEN}║ 访问:${CYAN}https://${GT_DOMAIN}${NC}" + echo -e "${GREEN}║ 账号:${CYAN}admin${NC} 密码:${CYAN}${GT_PASS}${NC}" + echo -e "${GREEN}║ 手机安装 Gotify App 填入域名即可收到推送 ║${NC}" + echo -e "${GREEN}╚══════════════════════════════════════════════════════╝${NC}" + else + echo -e "${RED}❌ Gotify 部署失败!${NC}" + fi + echo -e "\n${GREEN}按任意键返回...${NC}"; read -n 1 -s +} + +# ── install_actual_budget ── +install_actual_budget() { + ensure_docker_installed || return; check_tunnel_installed || return; clear + echo -e "${BLUE}=== 💰 部署 Actual Budget 个人记账 ===${NC}" + echo -e "${CYAN}开源记账软件,预算管理,支持多设备同步${NC}" + echo "" + read -p "请输入 Actual Budget 的子域名 (例如 money.example.com): " AB_DOMAIN + [ -z "$AB_DOMAIN" ] && { echo -e "${RED}域名不能为空!${NC}"; sleep 2; return; } + + mkdir -p /root/actual_budget_data/data + cat > /root/actual_budget_data/docker-compose.yml <> ${STATE_FILE} + echo "ACTUAL_BUDGET_DOMAIN=${AB_DOMAIN}" >> ${STATE_FILE} + echo "" + echo -e "${GREEN}╔══════════════════════════════════════════════════════╗${NC}" + echo -e "${GREEN}║ ✅ Actual Budget 部署成功! ║${NC}" + echo -e "${GREEN}║ 访问:${CYAN}https://${AB_DOMAIN}${NC}" + echo -e "${GREEN}║ 首次访问直接创建账号即可 ║${NC}" + echo -e "${GREEN}╚══════════════════════════════════════════════════════╝${NC}" + else + echo -e "${RED}❌ Actual Budget 部署失败!${NC}" + fi + echo -e "\n${GREEN}按任意键返回...${NC}"; read -n 1 -s +} + +# ── install_excalidraw ── +install_excalidraw() { + ensure_docker_installed || return; check_tunnel_installed || return; clear + echo -e "${BLUE}=== ✏️ 部署 Excalidraw 在线白板 ===${NC}" + echo -e "${CYAN}手绘风格白板,流程图/思维导图,比 Draw.io 更轻量好看${NC}" + echo "" + read -p "请输入 Excalidraw 的子域名 (例如 draw.example.com): " ED_DOMAIN + [ -z "$ED_DOMAIN" ] && { echo -e "${RED}域名不能为空!${NC}"; sleep 2; return; } + + mkdir -p /root/excalidraw_data + cat > /root/excalidraw_data/docker-compose.yml <> ${STATE_FILE} + echo "EXCALIDRAW_DOMAIN=${ED_DOMAIN}" >> ${STATE_FILE} + echo "" + echo -e "${GREEN}╔══════════════════════════════════════════════════════╗${NC}" + echo -e "${GREEN}║ ✅ Excalidraw 部署成功! ║${NC}" + echo -e "${GREEN}║ 访问:${CYAN}https://${ED_DOMAIN}${NC}" + echo -e "${GREEN}║ 无需登录,打开即用 ║${NC}" + echo -e "${GREEN}╚══════════════════════════════════════════════════════╝${NC}" + else + echo -e "${RED}❌ Excalidraw 部署失败!${NC}" + fi + echo -e "\n${GREEN}按任意键返回...${NC}"; read -n 1 -s +} + +# ── install_joplin_server ── +install_joplin_server() { + ensure_docker_installed || return; check_tunnel_installed || return; clear + echo -e "${BLUE}=== 📓 部署 Joplin Server 私人笔记服务器 ===${NC}" + echo -e "${CYAN}替代 OneNote,原生支持 Windows/macOS/Linux/iOS/Android 客户端,端对端加密${NC}" + echo "" + read -p "请输入 Joplin Server 的子域名 (例如 notes.example.com): " JP_DOMAIN + [ -z "$JP_DOMAIN" ] && { echo -e "${RED}域名不能为空!${NC}"; sleep 2; return; } + + local JP_DB_PASS; JP_DB_PASS="$(_gen_password 16)" + local JP_MAILER_ENABLED="${JP_MAILER_ENABLED:-0}" + + mkdir -p /root/joplin_data/postgres + cat > /root/joplin_data/docker-compose.yml <> ${STATE_FILE} + echo "JOPLIN_DOMAIN=${JP_DOMAIN}" >> ${STATE_FILE} + echo "JOPLIN_DB_PASSWORD=${JP_DB_PASS}" >> ${STATE_FILE} + echo "" + echo -e "${GREEN}╔══════════════════════════════════════════════════════╗${NC}" + echo -e "${GREEN}║ ✅ Joplin Server 部署成功! ║${NC}" + echo -e "${GREEN}║ 访问:${CYAN}https://${JP_DOMAIN}${NC}" + echo -e "${GREEN}║ 首次登录账号:${CYAN}admin@localhost${NC} ║${NC}" + echo -e "${GREEN}║ 首次登录密码:${CYAN}admin${NC} ⚠️ 请立即修改! ║${NC}" + echo -e "${GREEN}║ ║${NC}" + echo -e "${GREEN}║ 📱 客户端配置教程: ║${NC}" + echo -e "${GREEN}║ 1) 下载 Joplin 客户端 (joplinapp.org) ║${NC}" + echo -e "${GREEN}║ 2) 同步 → Joplin Server → 填入域名 ║${NC}" + echo -e "${GREEN}║ 3) 填入邮箱和密码即可多端同步 ║${NC}" + echo -e "${GREEN}╚══════════════════════════════════════════════════════╝${NC}" + else + echo -e "${RED}❌ Joplin Server 部署失败,请检查日志:${NC}" + (cd /root/joplin_data && sudo docker compose logs --tail=30) + fi + echo -e "\n${GREEN}按任意键返回...${NC}"; read -n 1 -s +} + +# ── install_trilium ── +install_trilium() { + ensure_docker_installed || return; check_tunnel_installed || return; clear + echo -e "${BLUE}=== 🌳 部署 Trilium Notes 个人知识库 ===${NC}" + echo -e "${CYAN}功能强大的层级笔记应用,支持脑图、代码高亮、关系图、WebDAV 同步${NC}" + echo "" + read -p "请输入 Trilium Notes 的子域名 (例如 notes.example.com): " TN_DOMAIN + [ -z "$TN_DOMAIN" ] && { echo -e "${RED}域名不能为空!${NC}"; sleep 2; return; } + + mkdir -p /root/trilium_data + # 修复目录权限 (容器内以 node 用户 uid=1000 运行) + sudo chown -R 1000:1000 /root/trilium_data + + cat > /root/trilium_data/docker-compose.yml <> ${STATE_FILE} + echo "TRILIUM_DOMAIN=${TN_DOMAIN}" >> ${STATE_FILE} + echo "" + echo -e "${GREEN}╔══════════════════════════════════════════════════════╗${NC}" + echo -e "${GREEN}║ ✅ Trilium Notes 部署成功! ║${NC}" + echo -e "${GREEN}║ 访问:${CYAN}https://${TN_DOMAIN}${NC}" + echo -e "${GREEN}║ ║${NC}" + echo -e "${GREEN}║ 📋 首次使用说明: ║${NC}" + echo -e "${GREEN}║ 1) 浏览器打开上方地址,按向导设置管理员密码 ║${NC}" + echo -e "${GREEN}║ 2) 数据存于 /root/trilium_data,请定期备份 ║${NC}" + echo -e "${GREEN}║ 3) 支持桌面客户端同步:trilium.github.io ║${NC}" + echo -e "${GREEN}╚══════════════════════════════════════════════════════╝${NC}" + else + echo -e "${RED}❌ Trilium Notes 部署失败,请检查日志:${NC}" + (cd /root/trilium_data && sudo docker compose logs --tail=30) + fi + echo -e "\n${GREEN}按任意键返回...${NC}"; read -n 1 -s +} + +# ── install_grafana ── +install_grafana() { + ensure_docker_installed || return; check_tunnel_installed || return; clear + echo -e "${BLUE}=== 📊 部署 Grafana + Prometheus 监控面板 ===${NC}" + echo -e "${CYAN}可视化监控服务器CPU/内存/硬盘/网络,图表美观${NC}" + echo "" + read -p "请输入 Grafana 的子域名 (例如 monitor.example.com): " GF_DOMAIN + [ -z "$GF_DOMAIN" ] && { echo -e "${RED}域名不能为空!${NC}"; sleep 2; return; } + + local GF_PASS="grafana_$(head /dev/urandom | tr -dc A-Za-z0-9 | head -c 12)" + + mkdir -p /root/grafana_data/{grafana,prometheus} + # 修复 Grafana 数据目录权限(容器内 uid=472) + sudo chown -R 472:472 /root/grafana_data/grafana + sudo chmod -R 755 /root/grafana_data/grafana + + # Prometheus 配置 + cat > /root/grafana_data/prometheus/prometheus.yml < /root/grafana_data/docker-compose.yml <> ${STATE_FILE} + echo "GRAFANA_DOMAIN=${GF_DOMAIN}" >> ${STATE_FILE} + echo "GRAFANA_PASSWORD=${GF_PASS}" >> ${STATE_FILE} + echo "" + echo -e "${GREEN}╔══════════════════════════════════════════════════════════╗${NC}" + echo -e "${GREEN}║ ✅ Grafana + Prometheus 部署成功! ║${NC}" + echo -e "${GREEN}║ 访问:${CYAN}https://${GF_DOMAIN}${NC}" + echo -e "${GREEN}║ 账号:${CYAN}admin${NC} 密码:${CYAN}${GF_PASS}${NC}" + echo -e "${GREEN}║ ║${NC}" + echo -e "${GREEN}║ ${YELLOW}首次登录后: ${NC}" + echo -e "${GREEN}║ ${YELLOW}1. 添加数据源 → Prometheus → URL填 http://prometheus_app:9090${NC}" + echo -e "${GREEN}║ ${YELLOW}2. 导入仪表盘 → ID填 1860 (Node Exporter 完整版) ${NC}" + echo -e "${GREEN}║ ${YELLOW}3. 导入仪表盘 → ID填 193 (Docker 容器监控) ${NC}" + echo -e "${GREEN}╚══════════════════════════════════════════════════════════╝${NC}" + else + echo -e "${RED}❌ Grafana 部署失败!${NC}" + fi + echo -e "\n${GREEN}按任意键返回...${NC}"; read -n 1 -s +} + +install_sillytavern() { + clear + echo -e "${BLUE}=== 🍺 部署 SillyTavern 角色扮演酒馆 ===${NC}" + echo -e "${CYAN}SillyTavern 是强大的 AI 角色扮演前端,支持 Claude、GPT、本地模型等。${NC}" + echo "" + + if docker ps -q -f name=sillytavern_app 2>/dev/null | grep -q .; then + echo -e "${YELLOW}⚠️ SillyTavern 已在运行!${NC}" + docker ps | grep sillytavern + read -n 1 -s -r -p "按任意键返回..." + return + fi + + check_tunnel_installed || return + + read -p "请输入酒馆的访问域名 (例如 jg.jigongtou.uepopo.com): " ST_DOMAIN + [ -z "$ST_DOMAIN" ] && echo -e "${RED}域名不能为空!${NC}" && return + + # 修复已知问题:config.yaml 可能是空目录 + mkdir -p /root/sillytavern_data + [ -d "/root/sillytavern_data/config.yaml" ] && rm -rf /root/sillytavern_data/config.yaml + [ -d "/root/sillytavern_data/config" ] && rm -rf /root/sillytavern_data/config + + # 写入配置文件 + cat > /root/sillytavern_data/config.yaml << 'STEOF' +dataRoot: ./data +listen: true +port: 8000 +ssl: + enabled: false +whitelistMode: false +basicAuthMode: false +basicAuthUser: + username: user + password: password +enableCorsProxy: true +securityOverride: true +STEOF + + # 写入 docker-compose + cat > /root/sillytavern_data/docker-compose.yml << 'STEOF' +services: + sillytavern: + container_name: sillytavern_app + image: ghcr.io/sillytavern/sillytavern:latest + restart: unless-stopped + ports: + - "127.0.0.1:8000:8000" + volumes: + - '/root/sillytavern_data/config.yaml:/home/node/app/config.yaml' + - '/root/sillytavern_data/data:/home/node/app/data' +STEOF + + echo -e "${YELLOW}正在拉取酒馆镜像并启动(首次约需2-5分钟)...${NC}" + cd /root/sillytavern_data + docker compose up -d + + echo -e "${YELLOW}等待服务启动...${NC}" + sleep 8 + + if docker ps | grep -q sillytavern_app; then + echo -e "${GREEN}✅ 酒馆已开业!${NC}" + else + echo -e "${RED}❌ 启动失败,请查看日志:docker logs sillytavern_app${NC}" + read -n 1 -s -r -p "按任意键返回..." + return + fi + + # 配置 Cloudflare Tunnel + echo -e "${GREEN}正在为酒馆配置网络通道...${NC}" + update_tunnel_config "${ST_DOMAIN}" "http://127.0.0.1:8000" "SillyTavern 酒馆" + + # 保存凭证 + echo -e " +## SillyTavern 凭证 (部署于: $(date))" >> ${STATE_FILE} + echo "SILLYTAVERN_DOMAIN=${ST_DOMAIN}" >> ${STATE_FILE} + + echo "" + echo -e "${YELLOW}══════════════════════════════════════════════════════════════════════${NC}" + echo -e "${GREEN}🍺 酒馆正式开业!${NC}" + echo -e " 访问地址 :${CYAN}https://${ST_DOMAIN}${NC}" + echo -e " ${YELLOW}进入后在左上角 API 处配置 AI 模型连接。${NC}" + echo -e " ${YELLOW}支持 Claude、GPT、Gemini、本地 Ollama 等。${NC}" + echo -e "${YELLOW}══════════════════════════════════════════════════════════════════════${NC}" + echo -e " +${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s +} + +# ================================================================ +# --- 新增功能:部署 N8N 工作流自动化 --- +# ================================================================ +install_n8n() { + ensure_docker_installed || return; check_tunnel_installed || return + clear + echo -e "${BLUE}=== 🔄 部署 N8N 工作流自动化引擎 ===${NC}" + echo -e "${CYAN}N8N 是强大的开源工作流自动化平台,可连接数百种应用和服务。${NC}" + echo "" + + if [ -d "/root/n8n_data" ]; then + echo -e "${YELLOW}⚠️ N8N 已安装!${NC}" + echo -e "${YELLOW}如需重装请先执行选项 40) 卸载 N8N 后重试。${NC}" + echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s + return + fi + + read -p "请输入您为 N8N 规划的子域名 (例如 n8n.example.com): " N8N_DOMAIN + if [ -z "$N8N_DOMAIN" ]; then echo -e "${RED}域名不能为空!${NC}"; sleep 2; return; fi + + local N8N_ENCRYPTION_KEY="n8n-key-$(_gen_password 24)" + + echo -e "${YELLOW}正在创建 N8N 配置...${NC}" + mkdir -p /root/n8n_data/data + # N8N 容器内以 uid=1000 (node用户) 运行,必须提前赋权否则启动崩溃 + chown -R 1000:1000 /root/n8n_data/data + + cat > /root/n8n_data/docker-compose.yml << EOF +services: + n8n: + image: n8nio/n8n:latest + container_name: n8n_app + restart: unless-stopped + ports: + - "127.0.0.1:5678:5678" + environment: + - N8N_HOST=${N8N_DOMAIN} + - N8N_PORT=5678 + - N8N_PROTOCOL=https + - N8N_LISTEN_ADDRESS=0.0.0.0 + - NODE_ENV=production + - WEBHOOK_URL=https://${N8N_DOMAIN}/ + - N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY} + - TZ=Asia/Shanghai + - GENERIC_TIMEZONE=Asia/Shanghai + volumes: + - './data:/home/node/.n8n' +EOF + + echo -e "${YELLOW}正在启动 N8N 服务...${NC}" + if _compose_up "N8N" "/root/n8n_data"; then + echo -e "${GREEN}✅ N8N 已启动,等待服务稳定(首次启动需要更多时间)...${NC}"; sleep 15 + + echo -e "\n## N8N 凭证 (部署于: $(date))" >> ${STATE_FILE} + echo "N8N_DOMAIN=${N8N_DOMAIN}" >> ${STATE_FILE} + echo "N8N_PORT=5678" >> ${STATE_FILE} + echo "N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY}" >> ${STATE_FILE} + + echo -e "${GREEN}正在为您配置 Cloudflare Tunnel...${NC}" + update_tunnel_config "${N8N_DOMAIN}" "http://127.0.0.1:5678" "N8N" + + echo "" + echo -e "${GREEN}╔══════════════════════════════════════════════════════════╗${NC}" + echo -e "${GREEN}║ ✅ N8N 部署成功! ║${NC}" + echo -e "${GREEN}╠══════════════════════════════════════════════════════════╣${NC}" + echo -e "${GREEN}║ 访问地址: ${CYAN}https://${N8N_DOMAIN}${NC}" + echo -e "${GREEN}║ 首次访问将引导您注册管理员账号 ║${NC}" + echo -e "${GREEN}╚══════════════════════════════════════════════════════════╝${NC}" + else + echo -e "${RED}❌ N8N 部署失败!请检查 Docker 是否正常运行。${NC}" + fi + echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s +} + +# --- ⬆️ ⬆️ ⬆️ 請將以上所有代碼添加到 main() 函數之前 ⬆️ ⬆️ ⬆️ --- +# ================================================================ + +# ================================================================ +# --- Nextcloud 预览图终极调教 (10.4) --- +# ================================================================ +run_nextcloud_preview_setup() { + clear + echo -e "${BLUE}================================================================${NC}" + echo -e "${BLUE} 🖼️ Nextcloud 预览图终极调教 ${NC}" + echo -e "${BLUE}================================================================${NC}" + echo "" + + local CONTAINER="nextcloud_app" + + # 检查容器是否运行 + if ! docker inspect --format='{{.State.Running}}' "$CONTAINER" 2>/dev/null | grep -q "true"; then + echo -e "${RED}❌ 错误:nextcloud_app 容器未运行,请先启动 Nextcloud!${NC}" + echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s + return + fi + + echo -e "${YELLOW}本功能将完成以下操作:${NC}" + echo -e " ${GREEN}1)${NC} 启用 22 种文件格式预览(图片/PDF/视频/Office/音乐/字体)" + echo -e " ${GREEN}2)${NC} 设置预览图尺寸 256x256(够看又省空间)" + echo -e " ${GREEN}3)${NC} 配置 cron 每5分钟自动处理新上传文件(永久生效)" + echo -e " ${GREEN}4)${NC} 限速批量生成所有存量文件的预览图(后台慢慢跑)" + echo "" + read -p "$(echo -e ${YELLOW}"确认开始?(y/n): "${NC})" confirm + [[ "$confirm" != "y" && "$confirm" != "Y" ]] && return + + # ── 第一步:启用所有预览格式 ────────────────────────────────── + echo "" + echo -e "${BLUE}[1/4] 正在启用所有预览格式...${NC}" + local providers=( + "OC\\Preview\\PNG" "OC\\Preview\\JPEG" + "OC\\Preview\\GIF" "OC\\Preview\\BMP" + "OC\\Preview\\WEBP" "OC\\Preview\\HEIC" + "OC\\Preview\\TIFF" "OC\\Preview\\SVG" + "OC\\Preview\\PDF" "OC\\Preview\\MP3" + "OC\\Preview\\MP4" "OC\\Preview\\MKV" + "OC\\Preview\\Movie" "OC\\Preview\\MSOfficeDoc" + "OC\\Preview\\MSOffice2003" "OC\\Preview\\MSOffice2007" + "OC\\Preview\\OpenDocument" "OC\\Preview\\Krita" + "OC\\Preview\\TXT" "OC\\Preview\\MarkDown" + "OC\\Preview\\XBitmap" "OC\\Preview\\Font" + ) + for i in "${!providers[@]}"; do + docker exec "$CONTAINER" php occ config:system:set enabledPreviewProviders "$i" \ + --value="${providers[$i]}" 2>/dev/null + done + echo -e "${GREEN} ✅ 已启用 ${#providers[@]} 种预览格式${NC}" + + # ── 第二步:设置尺寸 ────────────────────────────────────────── + echo -e "${BLUE}[2/4] 正在设置预览图尺寸...${NC}" + docker exec "$CONTAINER" php occ config:system:set preview_max_x --value="256" 2>/dev/null + docker exec "$CONTAINER" php occ config:system:set preview_max_y --value="256" 2>/dev/null + docker exec "$CONTAINER" php occ config:system:set jpeg_quality --value="60" 2>/dev/null + docker exec "$CONTAINER" php occ config:system:set preview_max_scale_factor --value="1" 2>/dev/null + echo -e "${GREEN} ✅ 预览图尺寸:256x256,JPEG质量:60${NC}" + + # ── 第三步:配置 cron 自动生成新文件预览 ────────────────────── + echo -e "${BLUE}[3/4] 正在配置新文件自动预览 (cron)...${NC}" + docker exec "$CONTAINER" php occ background:cron 2>/dev/null + local CRON_JOB="*/5 * * * * docker exec -u www-data ${CONTAINER} php occ preview:pre-generate >> /var/log/nextcloud_preview.log 2>&1" + if crontab -l 2>/dev/null | grep -q "preview:pre-generate"; then + echo -e "${YELLOW} ⚠️ cron 任务已存在,跳过重复添加${NC}" + else + (crontab -l 2>/dev/null; echo "$CRON_JOB") | crontab - + echo -e "${GREEN} ✅ 已添加 cron 任务:每5分钟自动处理新文件,永久生效${NC}" + fi + + # ── 第四步:限速批量生成存量文件预览 ────────────────────────── + echo -e "${BLUE}[4/4] 开始限速批量生成存量预览图...${NC}" + echo -e "${YELLOW} 提示:正在后台限速运行,每50个文件暂停10秒,不影响其他服务${NC}" + echo -e "${YELLOW} 提示:可随时 Ctrl+C 中断,下次重跑会跳过已生成的文件${NC}" + echo -e "${YELLOW} 提示:日志记录在 /var/log/nextcloud_preview_batch.log${NC}" + echo "" + + local LOG_FILE="/var/log/nextcloud_preview_batch.log" + local BATCH_SIZE=50 + local SLEEP_SEC=10 + echo "========== 开始时间: $(date) ==========" >> "$LOG_FILE" + + local counter=0 + local total=0 + + # 用 nice+ionice 双重限速,逐条输出进度 + nice -n 19 ionice -c 3 \ + docker exec -u www-data "$CONTAINER" php -d memory_limit=512M occ preview:generate-all -vvv 2>&1 | \ + tee -a "$LOG_FILE" | \ + grep --line-buffered -E "Generated|Generating|Error|Warning|generated" | \ + while IFS= read -r line; do + counter=$((counter + 1)) + total=$((total + 1)) + echo -e " ${DIM}[${total}]${NC} $line" + if (( counter >= BATCH_SIZE )); then + counter=0 + echo -e "${YELLOW} ⏸ 已处理 ${total} 个,暂停 ${SLEEP_SEC} 秒降温...${NC}" + sleep "$SLEEP_SEC" + fi + done + + echo "========== 结束时间: $(date) ==========" >> "$LOG_FILE" + echo "" + echo -e "${GREEN}================================================================${NC}" + echo -e "${GREEN} ✅ 全部完成!${NC}" + echo -e "${GREEN} 📋 已启用:22种格式预览${NC}" + echo -e "${GREEN} 🤖 新文件:上传后5分钟内自动生成预览(永久)${NC}" + echo -e "${GREEN} 📦 存量文件:批量生成完毕${NC}" + echo -e "${GREEN} 📄 日志:${LOG_FILE}${NC}" + echo -e "${GREEN}================================================================${NC}" + echo -e "${YELLOW} ℹ️ 远程挂载(sshfs/rclone)的文件预览速度取决于网速,属正常现象${NC}" + echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s +} + +# ================================================================ +# --- 部署 PopoDash 监控面板 【鸡公头出品】 --- +# ================================================================ +install_popodash() { + clear + echo -e "${BLUE}--- 🐓 部署 PopoDash 监控面板 【鸡公头出品】 ---${NC}" + bash <(curl -sL "https://raw.githubusercontent.com/uepopo/popodash/main/install.sh?t=$RANDOM" | tr -d '\r') + # 写入安装标记,供菜单状态检测使用 + mkdir -p /root/popodash && touch /root/popodash/.installed + echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s +} + +# ================================================================ +# --- 部署 MIMI 电报 AI 小秘书 【鸡公头出品】 --- +# ================================================================ +install_mimi() { + clear + echo -e "${BLUE}--- 🐓 部署 MIMI 电报 AI 小秘书 【鸡公头出品】 ---${NC}" + bash <(curl -sL "https://raw.githubusercontent.com/uepopo/mimi/refs/heads/main/install.sh?t=$RANDOM" | tr -d '\r') + # 写入安装标记,供菜单状态检测使用 + mkdir -p /root/mimi && touch /root/mimi/.installed + echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s +} + +# ================================================================ +# --- 部署 WebDAV 网盘 --- +# ================================================================ +install_webdav() { + clear + echo -e "${BLUE}--- 🗂️ WebDAV 网盘管理 ---${NC}" + echo "" + + local is_running=false + docker inspect webdav_app &>/dev/null && is_running=true + + # ── 显示当前状态 ────────────────────────────────────────────── + if $is_running; then + local cur_dir cur_url cur_user + cur_dir=$(grep "^WEBDAV_DIR=" "${STATE_FILE}" 2>/dev/null | tail -1 | cut -d= -f2-) + cur_url=$(grep "^WEBDAV_URL=" "${STATE_FILE}" 2>/dev/null | tail -1 | cut -d= -f2-) + cur_user=$(grep "^WEBDAV_USER=" "${STATE_FILE}" 2>/dev/null | tail -1 | cut -d= -f2-) + echo -e " 状态:${GREEN}● 运行中${NC}" + echo -e " 地址:${CYAN}${cur_url:-未知}${NC}" + echo -e " 用户:${CYAN}${cur_user:-未知}${NC}" + echo -e " 目录:${CYAN}${cur_dir:-未知}${NC}" + else + echo -e " 状态:${YELLOW}○ 未安装${NC}" + fi + + echo "" + echo -e " ${CYAN}1)${NC} 安装 WebDAV" + if $is_running; then + echo -e " ${CYAN}2)${NC} 更换共享目录(热更新,无需重新输入域名密码)" + echo -e " ${CYAN}3)${NC} ${RED}删除 WebDAV${NC}(容器和数据全部清除)" + fi + echo -e " ${CYAN}0)${NC} 返回" + echo "" + read -p " 请选择: " webdav_menu_choice + + case "$webdav_menu_choice" in + 1) _webdav_install ;; + 2) $is_running && _webdav_change_dir || { echo -e "${RED}WebDAV 未运行。${NC}"; sleep 2; } ;; + 3) $is_running && _webdav_delete || { echo -e "${RED}WebDAV 未运行。${NC}"; sleep 2; } ;; + 0|"") return ;; + *) echo -e "${RED}无效选项。${NC}"; sleep 2 ;; + esac +} + +# ================================================================ +# --- WebDAV 实际安装逻辑(内部函数)--- +# ================================================================ +_webdav_install() { + if docker inspect webdav_app &>/dev/null; then + echo -e "${YELLOW}WebDAV 已在运行。如需重装请先选择删除。${NC}" + sleep 2; return + fi + + check_tunnel_installed || return 1 + ensure_docker_installed || return 1 + + echo "" + echo -e "${CYAN} WebDAV 可以让你的 VPS 变成一个网盘${NC}" + echo -e "${CYAN} 支持 Windows/Mac/手机直接挂载,能显示真实磁盘容量${NC}" + echo "" + + # ── 选择共享目录 ────────────────────────────────────────────── + local webdav_dir + _webdav_pick_dir webdav_dir || return 1 + echo -e "${GREEN} ✅ 共享目录:${webdav_dir}${NC}" + echo "" + + # ── 用户名 ──────────────────────────────────────────────────── + read -p " 用户名 [默认 admin]: " webdav_user + webdav_user="${webdav_user:-admin}" + + # ── 密码 ────────────────────────────────────────────────────── + local default_pass + default_pass=$(_gen_password 16) + read -p " 密码 [回车自动生成]: " webdav_pass + webdav_pass="${webdav_pass:-$default_pass}" + + # ── 域名 ────────────────────────────────────────────────────── + echo "" + echo -e "${YELLOW} 请输入 WebDAV 访问域名(例如:webdav.yourdomain.com):${NC}" + read -p " 域名: " webdav_domain + if [ -z "$webdav_domain" ]; then + echo -e "${RED}域名不能为空,安装中止。${NC}"; sleep 2; return + fi + + # ── 创建 compose 文件 ───────────────────────────────────────── + local data_dir="/root/webdav_data" + mkdir -p "$data_dir" + + cat > "${data_dir}/docker-compose.yml" << EOF +services: + webdav: + image: dgraziotin/nginx-webdav-nononsense + container_name: webdav_app + restart: unless-stopped + ports: + - "127.0.0.1:${PORT_WEBDAV}:80" + volumes: + - ${webdav_dir}:/data + environment: + - WEBDAV_USERNAME=${webdav_user} + - WEBDAV_PASSWORD=${webdav_pass} + - WEBDAV_HOSTNAME=${webdav_domain} + - UID=0 + - GID=0 + - TZ=${TZ_DEFAULT} +EOF + + _compose_up "WebDAV" "$data_dir" || return 1 + + # 修复容器内写权限问题(UID/GID 环境变量在此镜像中不可靠) + echo -e "${YELLOW}正在修复 WebDAV 目录写权限...${NC}" + sleep 2 # 等容器完全启动 + docker exec webdav_app chmod -R 777 /data 2>/dev/null && \ + echo -e "${GREEN} ✅ 写权限修复完成${NC}" || \ + echo -e "${YELLOW} ⚠️ 权限修复失败,如无法写入请手动执行:docker exec webdav_app chmod -R 777 /data${NC}" + + echo -e "\n${YELLOW}正在配置 Cloudflare Tunnel...${NC}" + update_tunnel_config "$webdav_domain" "http://127.0.0.1:${PORT_WEBDAV}" "WebDAV 网盘" + + _save_credential "WEBDAV_URL" "https://${webdav_domain}" + _save_credential "WEBDAV_USER" "$webdav_user" + _save_credential "WEBDAV_PASS" "$webdav_pass" + _save_credential "WEBDAV_DIR" "$webdav_dir" + + echo "" + echo -e "${GREEN}╔══════════════════════════════════════════════════════════╗${NC}" + echo -e "${GREEN}║ 🎉 WebDAV 网盘部署成功! ║${NC}" + echo -e "${GREEN}╠══════════════════════════════════════════════════════════╣${NC}" + echo -e "${GREEN}║ 访问地址:${YELLOW}https://${webdav_domain}${NC}" + echo -e "${GREEN}║ 用户名: ${YELLOW}${webdav_user}${NC}" + echo -e "${GREEN}║ 密码: ${YELLOW}${webdav_pass}${NC}" + echo -e "${GREEN}║ 共享目录:${YELLOW}${webdav_dir}${NC}" + echo -e "${GREEN}╠══════════════════════════════════════════════════════════╣${NC}" + echo -e "${GREEN}║ 💻 RaiDrive:选 WebDAV → 填入以上信息即可 ║${NC}" + echo -e "${GREEN}║ 💻 Win11映射网络驱动器:填入访问地址即可 ║${NC}" + echo -e "${GREEN}║ 📱 手机 nPlayer/ES文件浏览器:填入以上信息 ║${NC}" + echo -e "${GREEN}╚══════════════════════════════════════════════════════════╝${NC}" + echo "" + echo -e "\n${GREEN}按任意键返回...${NC}"; read -n 1 -s +} + +# ================================================================ +# --- WebDAV 更换共享目录(热更新)--- +# ================================================================ +_webdav_change_dir() { + echo "" + local _cur; _cur=$(grep "^WEBDAV_DIR=" "${STATE_FILE}" 2>/dev/null | tail -1 | cut -d= -f2-) + echo -e "${CYAN} 当前共享目录:${_cur:-未知}${NC}" + echo "" + + local new_dir + _webdav_pick_dir new_dir || return 1 + + echo -e "${YELLOW}正在更换共享目录为:${new_dir}${NC}" + + # 修改 compose 文件中的 volumes 行 + local data_dir="/root/webdav_data" + sed -i "s|^ *- .*:/data| - ${new_dir}:/data|" "${data_dir}/docker-compose.yml" + + # 重启容器使新目录生效 + cd "$data_dir" && docker compose up -d --force-recreate + sleep 3 + + # 确认容器真的起来了 + if ! docker inspect webdav_app &>/dev/null; then + echo -e "${RED}❌ 容器启动失败,请检查日志:docker logs webdav_app${NC}" + echo -e "\n${GREEN}按任意键返回...${NC}"; read -n 1 -s + return 1 + fi + + # 修复权限 + docker exec webdav_app chmod -R 777 /data 2>/dev/null && \ + echo -e "${GREEN} ✅ 写权限修复完成${NC}" || true + + # 更新凭证文件(_save_credential 会先删旧值再写,不会重复) + _save_credential "WEBDAV_DIR" "$new_dir" + + echo -e "${GREEN}✅ 共享目录已切换为:${new_dir}${NC}" + echo -e "\n${GREEN}按任意键返回...${NC}"; read -n 1 -s +} + +# ================================================================ +# --- WebDAV 删除 --- +# ================================================================ +_webdav_delete() { + echo "" + echo -e "${RED} ⚠️ 此操作将删除 WebDAV 容器及配置文件。${NC}" + echo -e "${YELLOW} 共享目录中的文件【不会】被删除,只是停止共享。${NC}" + echo "" + read -p " 确认删除?输入 yes 继续: " confirm + if [ "$confirm" != "yes" ]; then + echo -e "${YELLOW}已取消。${NC}"; sleep 1; return + fi + uninstall_webdav + echo -e "\n${GREEN}按任意键返回...${NC}"; read -n 1 -s +} + +# ================================================================ +# --- WebDAV 目录选择(复用逻辑,避免重复写两遍)--- +# ================================================================ +_webdav_pick_dir() { + # $1 = 要赋值的变量名(nameref写法) + local _result_var="$1" + + echo -e "${YELLOW} 请选择要共享的目录:${NC}" + echo -e " ${CYAN}1)${NC} /mnt (所有挂载的网盘,推荐)" + echo -e " ${CYAN}2)${NC} /mnt/TOOL (仅 TOOL 网盘)" + echo -e " ${CYAN}3)${NC} /mnt/Media (仅 Media 媒体库)" + echo -e " ${CYAN}4)${NC} /root (VPS本机根目录)" + echo -e " ${CYAN}5)${NC} 自定义路径" + echo "" + local dir_choice picked_dir + read -p " 请选择 [1-5,默认1]: " dir_choice + dir_choice="${dir_choice:-1}" + + case "$dir_choice" in + 1) picked_dir="/mnt" ;; + 2) picked_dir="/mnt/TOOL" ;; + 3) picked_dir="/mnt/Media" ;; + 4) picked_dir="/root" ;; + 5) + read -p " 请输入自定义路径(例:/mnt/CC-00): " picked_dir + if [ -z "$picked_dir" ]; then + echo -e "${RED}路径不能为空,已取消。${NC}"; sleep 2; return 1 + fi + ;; + *) picked_dir="/mnt" ;; + esac + + if [ ! -d "$picked_dir" ]; then + echo -e "${YELLOW}目录不存在,正在创建 ${picked_dir}...${NC}" + mkdir -p "$picked_dir" + fi + + # 把结果写回调用方指定的变量 + printf -v "$_result_var" '%s' "$picked_dir" +} + +# ================================================================ +# --- 卸载 WebDAV 网盘 --- +# ================================================================ +uninstall_webdav() { + echo -e "${YELLOW}正在卸载 WebDAV 网盘...${NC}" + local data_dir="/root/webdav_data" + if [ -d "$data_dir" ]; then + cd "$data_dir" && docker compose down 2>/dev/null + cd /root && rm -rf "$data_dir" + fi + docker rm -f webdav_app 2>/dev/null || true + sed -i '/^WEBDAV_/d' "${STATE_FILE}" 2>/dev/null + echo -e "${GREEN}✅ WebDAV 已卸载${NC}" + sleep 2 +} + +# ================================================================ +# --- 网盘矩阵联合挂载 (mergerfs) --- +# 用途:把多个已挂载的网盘/本地目录合并成一个统一目录(路径可自定义) +# 副手VPS 在此做联合挂载,主力VPS 再用 SSHFS 挂载副手的联合目录 +# ================================================================ + +# 读取已保存的联合挂载出口路径(默认 /mnt/union) +_mergerfs_get_union_path() { + local p + p=$(grep "^MERGERFS_UNION_PATH=" "${STATE_FILE}" 2>/dev/null | cut -d'=' -f2-) + echo "${p:-/mnt/union}" +} + +# 检测 mergerfs 状态用于菜单显示 +_mergerfs_menu_status() { + local union_path; union_path=$(_mergerfs_get_union_path) + if mount 2>/dev/null | grep -q " ${union_path} "; then + local cnt + cnt=$(grep "^MERGERFS_BRANCHES=" "${STATE_FILE}" 2>/dev/null | cut -d'=' -f2- | tr ':' '\n' | grep -c '.') + echo "[ ${GREEN}✅ 已激活 · ${cnt} 个来源 · ${union_path}${NC} ]" + elif command -v mergerfs &>/dev/null; then + echo "[ ${YELLOW}⚠️ 已安装/未挂载${NC} ]" + else + echo "[ ${RED}❌ 未安装${NC} ]" + fi +} + +# 扫描并列出系统上所有"有意义"的目录供用户选择 +# 包括:/mnt/* 、已挂载的 rclone/sshfs 挂载点、/home 各用户目录、/root +_mergerfs_list_candidates() { + local union_path; union_path=$(_mergerfs_get_union_path) + local shown=() + + echo -e "${CYAN}┌─ 可加入联合挂载的路径 ──────────────────────────────┐${NC}" + + # 1. /mnt/* 下的目录(排除联合出口自身) + for d in /mnt/*/; do + d="${d%/}" + [ "$d" = "$union_path" ] && continue + [ -d "$d" ] || continue + local size; size=$(df -h "$d" 2>/dev/null | tail -1 | awk '{print $2}') + echo -e "${CYAN}│${NC} ${GREEN}▸${NC} $d ${DIM}[${size}]${NC}" + shown+=("$d") + done + + # 2. /home/* 各用户的主目录(常见于 ubuntu 用户) + for d in /home/*/; do + d="${d%/}" + [ -d "$d" ] || continue + # 避免重复 + local dup=false + for s in "${shown[@]}"; do [ "$s" = "$d" ] && dup=true && break; done + $dup && continue + local size; size=$(df -h "$d" 2>/dev/null | tail -1 | awk '{print $2}') + echo -e "${CYAN}│${NC} ${GREEN}▸${NC} $d ${DIM}[${size:-?}]${NC}" + shown+=("$d") + done + + # 3. /root 本身(如果不在 /home 下) + if [ -d /root ]; then + local dup=false + for s in "${shown[@]}"; do [ "$s" = "/root" ] && dup=true && break; done + if ! $dup; then + local size; size=$(df -h /root 2>/dev/null | tail -1 | awk '{print $2}') + echo -e "${CYAN}│${NC} ${GREEN}▸${NC} /root ${DIM}[${size}]${NC}" + fi + fi + + # 4. 已挂载的 rclone/sshfs(可能挂在非 /mnt 路径) + mount 2>/dev/null | grep -E "fuse\.rclone|fuse\.sshfs" | awk '{print $3}' | while read -r mp; do + local dup=false + for s in "${shown[@]}"; do [ "$s" = "$mp" ] && dup=true && break; done + $dup && continue + local size; size=$(df -h "$mp" 2>/dev/null | tail -1 | awk '{print $2}') + echo -e "${CYAN}│${NC} ${GREEN}▸${NC} $mp ${DIM}[${size}]${NC}" + done + + echo -e "${CYAN}└─────────────────────────────────────────────────────┘${NC}" + echo -e "${DIM} 也可直接输入任意路径,例如 /home/ubuntu/TOOL${NC}" + echo "" +} + +manage_mergerfs_menu() { + while true; do + clear + local union_path; union_path=$(_mergerfs_get_union_path) + echo -e "${BLUE}╔══════════════════════════════════════════════════════════╗${NC}" + echo -e "${BLUE}║ 🗂️ 网盘矩阵联合挂载管理 (mergerfs) ║${NC}" + echo -e "${BLUE}╠══════════════════════════════════════════════════════════╣${NC}" + echo -e "${BLUE}║ 原理:把多个网盘/目录合并为一个统一入口 ║${NC}" + echo -e "${BLUE}║ 副手VPS → 做联合挂载 → 主力VPS用SSHFS挂入,岂不美哉 ║${NC}" + echo -e "${BLUE}╚══════════════════════════════════════════════════════════╝${NC}" + echo "" + + # 显示当前联合挂载状态 + if mount 2>/dev/null | grep -q " ${union_path} "; then + local branches_raw + branches_raw=$(grep "^MERGERFS_BRANCHES=" "${STATE_FILE}" 2>/dev/null | cut -d'=' -f2-) + echo -e " ${GREEN}✅ 联合出口:${YELLOW}${union_path}${GREEN} 【当前激活】${NC}" + echo -e " ${CYAN}包含的来源:${NC}" + echo "$branches_raw" | tr ':' '\n' | while read -r b; do + [ -n "$b" ] || continue + local size; size=$(df -h "$b" 2>/dev/null | tail -1 | awk '{print $4}') + echo -e " ${GREEN}▸${NC} $b ${DIM}(可用: ${size})${NC}" + done + else + echo -e " ${YELLOW}⚠️ 联合出口 ${union_path} 当前未激活${NC}" + fi + echo "" + echo -e " ${YELLOW}1)${NC} ➕ 创建/重建联合挂载(选择来源、自定义出口路径)" + echo -e " ${YELLOW}2)${NC} ➕ 追加目录到现有联合挂载" + echo -e " ${YELLOW}3)${NC} 🗑️ 卸载联合挂载" + echo -e " ${YELLOW}4)${NC} 📋 查看详细状态与空间" + echo -e " ${YELLOW}5)${NC} 📡 主力VPS接入指引" + echo -e " ${YELLOW}q)${NC} 返回主菜单" + echo "" + read -p " 请选择: " sub + case "$sub" in + 1) _mergerfs_create ;; + 2) _mergerfs_append ;; + 3) _mergerfs_umount ;; + 4) _mergerfs_status_detail ;; + 5) _mergerfs_guide ;; + q|Q) return ;; + *) echo -e "${RED}无效选项${NC}"; sleep 1 ;; + esac + done +} + +# 安装 mergerfs +_install_mergerfs() { + if command -v mergerfs &>/dev/null; then + echo -e "${GREEN}mergerfs 已安装,跳过。${NC}" + return 0 + fi + echo -e "${YELLOW}正在安装 mergerfs...${NC}" + if apt-cache show mergerfs &>/dev/null 2>&1; then + sudo apt-get update -qq && sudo apt-get install -y mergerfs + else + local ver="2.40.2" + local deb_name="" + case "$SYS_ARCH" in + amd64) deb_name="mergerfs_${ver}.ubuntu-focal_amd64.deb" ;; + arm64) deb_name="mergerfs_${ver}.ubuntu-focal_arm64.deb" ;; + *) + echo -e "${RED}❌ 暂不支持架构 ${SYS_ARCH} 的自动安装,请手动安装 mergerfs。${NC}" + return 1 ;; + esac + local url="https://github.com/trapexit/mergerfs/releases/download/${ver}/${deb_name}" + echo -e "${YELLOW}从 GitHub 下载 mergerfs ${ver}...${NC}" + curl -L --retry 3 -o /tmp/mergerfs.deb "$url" && sudo dpkg -i /tmp/mergerfs.deb && rm -f /tmp/mergerfs.deb + fi + if command -v mergerfs &>/dev/null; then + echo -e "${GREEN}✅ mergerfs 安装成功!${NC}" + else + echo -e "${RED}❌ mergerfs 安装失败,请检查网络后重试。${NC}" + return 1 + fi +} + +# 创建/重建联合挂载 +_mergerfs_create() { + clear + echo -e "${BLUE}=== ➕ 创建网盘矩阵联合挂载 ===${NC}" + echo "" + + _install_mergerfs || { read -n 1 -s -r -p "按任意键返回..."; return; } + + # ── 第一步:选择联合出口路径 ────────────────────────────────── + local old_union_path; old_union_path=$(_mergerfs_get_union_path) + echo -e "${YELLOW}【第一步】设置联合出口路径(所有网盘合并后的统一入口)${NC}" + echo -e "${DIM} 当前/上次路径:${old_union_path}${NC}" + echo -e "${DIM} 直接回车保留原路径,或输入新路径(如 /mnt/allcloud)${NC}" + read -p " 联合出口路径: " input_union + local union_path="${input_union:-$old_union_path}" + # 不允许把出口设在某个来源内部,简单检查 + echo -e "${GREEN} ✅ 联合出口将设为:${union_path}${NC}" + echo "" + + # ── 第二步:选择要合并的来源路径 ───────────────────────────── + echo -e "${YELLOW}【第二步】选择要合并进来的目录(每行输入一个路径,空行结束)${NC}" + echo "" + _mergerfs_list_candidates + + local branches=() + while true; do + read -p " 路径(空行结束): " input_path + [ -z "$input_path" ] && break + # 去掉末尾斜杠 + input_path="${input_path%/}" + if [ ! -d "$input_path" ]; then + echo -e " ${RED}❌ 路径不存在,请检查后重新输入:$input_path${NC}" + continue + fi + if [ "$input_path" = "$union_path" ]; then + echo -e " ${RED}❌ 来源路径不能和联合出口相同,跳过。${NC}" + continue + fi + branches+=("$input_path") + local sz; sz=$(df -h "$input_path" 2>/dev/null | tail -1 | awk '{print $4}') + echo -e " ${GREEN}✅ 已加入:$input_path ${DIM}(可用: ${sz})${NC}" + done + + if [ ${#branches[@]} -eq 0 ]; then + echo -e "${RED}❌ 未选择任何路径,操作取消。${NC}"; sleep 2; return + fi + + echo "" + echo -e "${YELLOW}即将合并以下 ${#branches[@]} 个来源:${NC}" + for b in "${branches[@]}"; do + local sz; sz=$(df -h "$b" 2>/dev/null | tail -1 | awk '{print $4}') + echo -e " ${GREEN}▸${NC} $b ${DIM}(可用: ${sz})${NC}" + done + echo -e " ${CYAN}→ 联合出口:${union_path}${NC}" + echo "" + read -p "确认创建?(y/n,默认y): " confirm + [ "$confirm" = "n" ] || [ "$confirm" = "N" ] && return + + # 卸载旧的联合挂载(如路径变了也一并处理) + for old_mp in "$old_union_path" "$union_path"; do + if mount 2>/dev/null | grep -q " ${old_mp} "; then + echo -e "${YELLOW}正在卸载旧联合挂载 ${old_mp}...${NC}" + sudo systemctl stop mergerfs-union.service 2>/dev/null || true + sudo fusermount -u "$old_mp" 2>/dev/null || sudo umount -l "$old_mp" 2>/dev/null + fi + done + + sudo mkdir -p "$union_path" + + local branch_str + branch_str=$(IFS=':'; echo "${branches[*]}") + + echo -e "${YELLOW}正在挂载联合目录...${NC}" + sudo mergerfs \ + -o defaults,allow_other,use_ino,category.create=mfs,moveonenospc=true,minfreespace=1G \ + "${branch_str}" "${union_path}" + + if mount 2>/dev/null | grep -q " ${union_path} "; then + echo -e "${GREEN}✅ 联合挂载成功!目录内容预览:${NC}" + ls "${union_path}" 2>/dev/null | head -10 + else + echo -e "${RED}❌ 挂载失败,请检查各来源路径是否正确且可访问。${NC}" + read -n 1 -s -r -p "按任意键返回..."; return + fi + + # 保存配置 + sed -i '/^MERGERFS_BRANCHES=/d; /^MERGERFS_UNION_PATH=/d' "${STATE_FILE}" 2>/dev/null + echo "MERGERFS_UNION_PATH=${union_path}" >> "${STATE_FILE}" + echo "MERGERFS_BRANCHES=${branch_str}" >> "${STATE_FILE}" + + # 写 systemd 服务 + sudo tee /etc/systemd/system/mergerfs-union.service > /dev/null << MFEOF +[Unit] +Description=mergerfs Union Mount -> ${union_path} +After=network-online.target remote-fs.target +Wants=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStartPre=/bin/sleep 5 +ExecStart=/usr/bin/mergerfs -o defaults,allow_other,use_ino,category.create=mfs,moveonenospc=true,minfreespace=1G ${branch_str} ${union_path} +ExecStop=/bin/fusermount -u ${union_path} + +[Install] +WantedBy=multi-user.target +MFEOF + + sudo systemctl daemon-reload + sudo systemctl enable mergerfs-union.service + echo -e "${GREEN}✅ 已设置开机自动联合挂载!${NC}" + + # 自动注册 Nextcloud + if docker ps 2>/dev/null | grep -q "nextcloud_app"; then + echo "" + read -p "检测到 Nextcloud 运行中,是否将 ${union_path} 注册为外部存储?(y/n,默认y): " nc_reg + if [ "$nc_reg" != "n" ] && [ "$nc_reg" != "N" ]; then + docker exec -u www-data nextcloud_app php occ files_external:create \ + "/union网盘矩阵" local null::null \ + --config=datadir="${union_path}" 2>/dev/null \ + && echo -e "${GREEN}✅ 已注册到 Nextcloud 外部存储!${NC}" \ + || echo -e "${YELLOW}⚠️ 自动注册失败,请在 Nextcloud → 设置 → 外部存储 中手动添加路径 ${union_path}${NC}" + fi + fi + + echo "" + echo -e "${GREEN}╔══════════════════════════════════════════════════════════╗${NC}" + echo -e "${GREEN}║ 🎉 网盘矩阵联合挂载创建成功! ║${NC}" + echo -e "${GREEN}╠══════════════════════════════════════════════════════════╣${NC}" + echo -e "${GREEN}║ 联合出口:${YELLOW}${union_path}${NC}" + echo -e "${GREEN}║ 包含来源:${YELLOW}${#branches[@]} 个${NC}" + echo -e "${GREEN}║ 开机自启:${YELLOW}已启用${NC}" + echo -e "${GREEN}╠══════════════════════════════════════════════════════════╣${NC}" + echo -e "${GREEN}║ 💡 主力VPS 接入方式:选菜单 3.2 → 选项 5 查看指引 ║${NC}" + echo -e "${GREEN}╚══════════════════════════════════════════════════════════╝${NC}" + echo "" + echo -e "\n${GREEN}按任意键返回...${NC}"; read -n 1 -s +} + +# 追加新目录到现有联合挂载 +_mergerfs_append() { + clear + echo -e "${BLUE}=== ➕ 追加目录到联合挂载 ===${NC}" + echo "" + local union_path; union_path=$(_mergerfs_get_union_path) + + if ! mount 2>/dev/null | grep -q " ${union_path} "; then + echo -e "${RED}❌ ${union_path} 当前未挂载,请先选择「创建联合挂载」。${NC}" + sleep 3; return + fi + + local current_branches + current_branches=$(grep "^MERGERFS_BRANCHES=" "${STATE_FILE}" 2>/dev/null | cut -d'=' -f2-) + echo -e "${CYAN}当前已包含的来源:${NC}" + echo "$current_branches" | tr ':' '\n' | while read -r b; do + [ -n "$b" ] || continue + local sz; sz=$(df -h "$b" 2>/dev/null | tail -1 | awk '{print $4}') + echo -e " ${GREEN}▸${NC} $b ${DIM}(可用: ${sz})${NC}" + done + echo "" + _mergerfs_list_candidates + + read -p "请输入要追加的目录路径: " new_path + new_path="${new_path%/}" + [ -z "$new_path" ] && return + if [ ! -d "$new_path" ]; then + echo -e "${RED}❌ 路径不存在:$new_path${NC}"; sleep 2; return + fi + if [ "$new_path" = "$union_path" ]; then + echo -e "${RED}❌ 不能把联合出口自身加入来源。${NC}"; sleep 2; return + fi + if echo "$current_branches" | tr ':' '\n' | grep -qx "$new_path"; then + echo -e "${YELLOW}⚠️ 该路径已在联合挂载中,无需重复添加。${NC}"; sleep 2; return + fi + + local new_branches="${current_branches}:${new_path}" + + echo -e "${YELLOW}正在重建联合挂载(含新目录)...${NC}" + sudo fusermount -u "$union_path" 2>/dev/null || sudo umount -l "$union_path" 2>/dev/null + sleep 1 + sudo mergerfs \ + -o defaults,allow_other,use_ino,category.create=mfs,moveonenospc=true,minfreespace=1G \ + "${new_branches}" "${union_path}" + + if mount 2>/dev/null | grep -q " ${union_path} "; then + sed -i '/^MERGERFS_BRANCHES=/d' "${STATE_FILE}" 2>/dev/null + echo "MERGERFS_BRANCHES=${new_branches}" >> "${STATE_FILE}" + sudo sed -i "s|ExecStart=.*mergerfs.*|ExecStart=/usr/bin/mergerfs -o defaults,allow_other,use_ino,category.create=mfs,moveonenospc=true,minfreespace=1G ${new_branches} ${union_path}|" \ + /etc/systemd/system/mergerfs-union.service 2>/dev/null + sudo systemctl daemon-reload + local sz; sz=$(df -h "$new_path" 2>/dev/null | tail -1 | awk '{print $4}') + echo -e "${GREEN}✅ 追加成功!已加入:${new_path} (可用: ${sz})${NC}" + else + echo -e "${RED}❌ 重建失败,请检查路径是否正确且可访问。${NC}" + fi + echo "" + echo -e "\n${GREEN}按任意键返回...${NC}"; read -n 1 -s +} + +# 卸载联合挂载 +_mergerfs_umount() { + clear + echo -e "${BLUE}=== 🗑️ 卸载联合挂载 ===${NC}" + echo "" + local union_path; union_path=$(_mergerfs_get_union_path) + + if ! mount 2>/dev/null | grep -q " ${union_path} "; then + echo -e "${YELLOW}⚠️ ${union_path} 当前并未挂载。${NC}"; sleep 2; return + fi + echo -e "${YELLOW}此操作将卸载 ${union_path},底层各来源目录不受影响。${NC}" + read -p "确认卸载?(y/n): " confirm + [ "$confirm" != "y" ] && [ "$confirm" != "Y" ] && return + + sudo systemctl stop mergerfs-union.service 2>/dev/null || true + sudo fusermount -u "$union_path" 2>/dev/null || sudo umount -l "$union_path" 2>/dev/null + + read -p "是否同时禁用开机自启并清除配置?(y/n,默认n): " dis_auto + if [ "$dis_auto" = "y" ] || [ "$dis_auto" = "Y" ]; then + sudo systemctl disable mergerfs-union.service 2>/dev/null + sudo rm -f /etc/systemd/system/mergerfs-union.service + sudo systemctl daemon-reload + sed -i '/^MERGERFS_BRANCHES=/d; /^MERGERFS_UNION_PATH=/d' "${STATE_FILE}" 2>/dev/null + echo -e "${GREEN}✅ 已完全移除联合挂载及开机自启。${NC}" + else + echo -e "${GREEN}✅ 已卸载 ${union_path},配置保留,下次开机将自动重建。${NC}" + fi + sleep 2 +} + +# 查看详细状态 +_mergerfs_status_detail() { + clear + echo -e "${BLUE}=== 📋 联合挂载详细状态 ===${NC}" + echo "" + local union_path; union_path=$(_mergerfs_get_union_path) + + if mount 2>/dev/null | grep -q " ${union_path} "; then + echo -e "${GREEN}✅ ${union_path} 挂载中${NC}" + echo "" + echo -e "${CYAN}─── 各来源空间 ───────────────────────────────────────${NC}" + local branches_raw + branches_raw=$(grep "^MERGERFS_BRANCHES=" "${STATE_FILE}" 2>/dev/null | cut -d'=' -f2-) + echo "$branches_raw" | tr ':' '\n' | while read -r b; do + [ -n "$b" ] || continue + if [ -d "$b" ]; then + local info; info=$(df -h "$b" 2>/dev/null | tail -1) + local used avail pct + used=$(echo "$info" | awk '{print $3}') + avail=$(echo "$info" | awk '{print $4}') + pct=$(echo "$info" | awk '{print $5}') + echo -e " ${GREEN}▸${NC} $b" + echo -e " 已用:${YELLOW}${used}${NC} 可用:${GREEN}${avail}${NC} 占比:${pct}" + else + echo -e " ${RED}▸${NC} $b ${RED}【路径不可访问/未挂载】${NC}" + fi + done + echo "" + echo -e "${CYAN}─── 联合出口整体空间 ─────────────────────────────────${NC}" + df -h "${union_path}" 2>/dev/null | tail -1 | \ + awk '{printf " 总容量: %-8s 已用: %-8s 可用: %-8s 占比: %s\n", $2, $3, $4, $5}' + echo "" + echo -e "${CYAN}─── systemd 服务状态 ─────────────────────────────────${NC}" + systemctl status mergerfs-union.service 2>/dev/null | head -8 | sed 's/^/ /' + else + echo -e "${RED}❌ ${union_path} 未挂载${NC}" + echo "" + local saved_branches + saved_branches=$(grep "^MERGERFS_BRANCHES=" "${STATE_FILE}" 2>/dev/null | cut -d'=' -f2-) + if [ -n "$saved_branches" ]; then + echo -e "${YELLOW}上次配置的来源路径:${NC}" + echo "$saved_branches" | tr ':' '\n' | while read -r b; do + [ -n "$b" ] && echo -e " ▸ $b" + done + echo "" + read -p "是否立即重建联合挂载?(y/n): " rebuild + if [ "$rebuild" = "y" ] || [ "$rebuild" = "Y" ]; then + sudo mkdir -p "$union_path" + sudo mergerfs \ + -o defaults,allow_other,use_ino,category.create=mfs,moveonenospc=true,minfreespace=1G \ + "${saved_branches}" "${union_path}" \ + && echo -e "${GREEN}✅ 重建成功!${NC}" \ + || echo -e "${RED}❌ 重建失败,请检查底层来源是否已挂载。${NC}" + fi + fi + fi + echo "" + echo -e "\n${GREEN}按任意键返回...${NC}"; read -n 1 -s +} + +# 主力VPS接入指引 +_mergerfs_guide() { + clear + local union_path; union_path=$(_mergerfs_get_union_path) + local my_ip + my_ip=$(curl -4 -s --max-time 5 ifconfig.me 2>/dev/null || curl -6 -s --max-time 5 ifconfig.me 2>/dev/null || echo "YOUR_VPS_IP") + echo -e "${BLUE}╔══════════════════════════════════════════════════════════╗${NC}" + echo -e "${BLUE}║ 📡 主力VPS接入副手联合挂载 — 操作指引 ║${NC}" + echo -e "${BLUE}╚══════════════════════════════════════════════════════════╝${NC}" + echo "" + echo -e "${CYAN}【当前副手VPS 联合出口】${NC}" + echo -e " 本机 IP : ${YELLOW}${my_ip}${NC}" + echo -e " 联合路径 : ${YELLOW}${union_path}${NC}" + echo "" + echo -e "${CYAN}【架构示意】${NC}" + echo -e " 副手VPS (${my_ip})" + echo -e " ├─ /mnt/onedrive ─┐" + echo -e " ├─ /mnt/gdrive ─┤ mergerfs" + echo -e " ├─ /home/ubuntu/… ─┤ ══════▶ ${YELLOW}${union_path}${NC}" + echo -e " └─ /mnt/… ─┘" + echo -e " 主力VPS" + echo -e " └─ SSHFS ══▶ ${union_path}@副手 ══▶ /mnt/fushou ✨ 透明读写所有网盘" + echo "" + echo -e "${CYAN}【主力VPS 操作步骤】${NC}" + echo -e " ${YELLOW}方式A:用鸡公头面板一键挂载(推荐)${NC}" + echo -e " ┌─────────────────────────────────────────────────────┐" + echo -e " │ 主力VPS 运行鸡公头面板 → 选 3(挂载远程VPS) │" + echo -e " │ 对方IP : ${my_ip}" + echo -e " │ 远程路径 : ${union_path}" + echo -e " │ 本机挂载 : /mnt/fushou(或任意你喜欢的名字) │" + echo -e " └─────────────────────────────────────────────────────┘" + echo "" + echo -e " ${YELLOW}方式B:手动命令${NC}" + echo -e " ┌─────────────────────────────────────────────────────┐" + echo -e " │ apt install sshfs -y │" + echo -e " │ mkdir -p /mnt/fushou │" + echo -e " │ sshfs root@${my_ip}:${union_path} /mnt/fushou \\ " + echo -e " │ -o reconnect,ServerAliveInterval=15,allow_other │" + echo -e " └─────────────────────────────────────────────────────┘" + echo "" + echo -e "${CYAN}【温馨提示】${NC}" + echo -e " ▸ mergerfs 写入策略 mfs = 优先写入剩余空间最大的来源" + echo -e " ▸ 本地目录(如 /home/ubuntu/TOOL)也可加入,190G 物尽其用" + echo -e " ▸ 某个来源断连,/union 对应部分变空,重挂来源即恢复" + echo -e " ▸ 主力VPS 看到的是一个统一目录,无需关心底层几个盘 🎉" + echo "" + echo -e "\n${GREEN}按任意键返回...${NC}"; read -n 1 -s +} + +# --- 主循環 --- +main() { + # 最优先:检测系统环境(网络/架构/发行版),后续所有分支都依赖此结果 + setup_colors # 先初始化颜色,让检测过程中的提示有颜色 + echo -e "${YELLOW}⏳ 正在检测系统环境,请稍候...${NC}" + detect_environment + # 检测完成后给用户一个即时反馈 + if $IS_IPV6_ONLY; then + echo -e "${YELLOW}🌐 检测到纯 IPv6 环境 (${SYS_DISTRO} ${SYS_CODENAME} / ${SYS_ARCH}),已自动启用兼容方案。${NC}" + sleep 2 + fi + check_core_dependencies + setup_shortcut + + while true; do + show_main_menu + read -p "請輸入您的選擇 (u, m, s, 1-30.2, X, 99, q): " choice + case $choice in + u|U) update_system ;; + m|M) run_unminimize ;; + s|S) manage_swap ;; + 1) if systemctl is-active --quiet cloudflared 2>/dev/null; then + echo -e "\n${YELLOW}Cloudflare Tunnel 已安裝並運行中。如需重裝請先執行選項 40 卸載。${NC}"; sleep 3 + elif [ -f "/etc/systemd/system/cloudflared.service" ]; then + echo -e "\n${YELLOW}检测到 cloudflared 服务文件存在但未运行,正在尝试启动...${NC}" + sudo systemctl start cloudflared; sleep 2 + if systemctl is-active --quiet cloudflared; then + echo -e "${GREEN}✅ Cloudflare Tunnel 已成功启动!${NC}" + else + echo -e "${RED}启动失败,服务可能已损坏。正在清理旧服务后重新安装...${NC}"; sleep 2 + sudo cloudflared service uninstall 2>/dev/null; sudo systemctl daemon-reload; sleep 1 + install_cloudflare_tunnel + fi; sleep 2 + else + install_cloudflare_tunnel + fi ;; + 2) configure_rclone_engine ;; + 3) manage_sshfs_menu ;; + 3.1) install_webdav ;; + 3.2) manage_mergerfs_menu ;; + 4.1) [ -d "/root/nextcloud_data" ] && { echo -e "\n${YELLOW}Nextcloud 已安裝。${NC}"; sleep 2; } || install_nextcloud ;; + 4.2) [ -d "/root/onlyoffice_data" ] && { echo -e "\n${YELLOW}OnlyOffice 已安裝。${NC}"; sleep 2; } || install_onlyoffice ;; + 4.3) [ -d "/root/home_assistant_data" ] && { echo -e "\n${YELLOW}Home Assistant 已安裝。${NC}"; sleep 2; } || install_home_assistant ;; + 4.4) [ -d "/root/wordpress_data" ] && { echo -e "\n${YELLOW}WordPress 已安裝。${NC}"; sleep 2; } || install_wordpress ;; + 5.1) [ -d "/root/ai_stack" ] && { echo -e "\n${YELLOW}AI 大腦已安裝。${NC}"; sleep 2; } || install_ai_suite ;; + 5.2) install_ai_model ;; + 5.3) [ -d "/root/jellyfin_data" ] && { echo -e "\n${YELLOW}Jellyfin 已安裝。${NC}"; sleep 2; } || install_jellyfin ;; + 5.4) [ -d "/root/navidrome_data" ] && { echo -e "\n${YELLOW}Navidrome 已安裝。${NC}"; sleep 2; } || install_navidrome ;; + 5.5) [ -d "/root/immich_data" ] && { echo -e "\n${YELLOW}Immich 已安裝。${NC}"; sleep 2; } || install_immich ;; + 5.6) [ -d "/root/calibre_web_data" ] && { echo -e "\n${YELLOW}Calibre-Web 已安裝。${NC}"; sleep 2; } || install_calibre_web ;; + 5.7) [ -d "/root/kavita_data" ] && { echo -e "\n${YELLOW}Kavita 已安裝。${NC}"; sleep 2; } || install_kavita ;; + 6.1) [ -d "/root/miniflux_data" ] && { echo -e "\n${YELLOW}Miniflux 已安裝。${NC}"; sleep 2; } || install_miniflux ;; + 6.2) [ -d "/root/gitea_data" ] && { echo -e "\n${YELLOW}Gitea 已安裝。${NC}"; sleep 2; } || install_gitea ;; + 6.3) [ -d "/root/qbittorrent_data" ] && { echo -e "\n${YELLOW}qBittorrent 已安裝。${NC}"; sleep 2; } || install_qbittorrent ;; + 6.4) [ -d "/root/jdownloader_data" ] && { echo -e "\n${YELLOW}JDownloader 已安裝。${NC}"; sleep 2; } || install_jdownloader ;; + 6.5) [ -d "/root/ytdlp_data" ] && { echo -e "\n${YELLOW}yt-dlp 已安裝。${NC}"; sleep 2; } || install_ytdlp ;; + 6.6) [ -d "/root/drawio_data" ] && { echo -e "\n${YELLOW}Draw.io 已安裝。${NC}"; sleep 2; } || install_drawio ;; + 6.7) [ -d "/root/n8n_data" ] && { echo -e "\n${YELLOW}N8N 已安裝。${NC}"; sleep 2; } || install_n8n ;; + 6.8) docker inspect gotify_app &>/dev/null && { echo -e "\n${YELLOW}Gotify 已安裝。${NC}"; sleep 2; } || install_gotify ;; + 6.9) [ -d "/root/actual_budget_data" ] && { echo -e "\n${YELLOW}Actual Budget 已安裝。${NC}"; sleep 2; } || install_actual_budget ;; + 6.10) [ -d "/root/excalidraw_data" ] && { echo -e "\n${YELLOW}Excalidraw 已安裝。${NC}"; sleep 2; } || install_excalidraw ;; + 6.11) [ -d "/root/joplin_data" ] && { echo -e "\n${YELLOW}Joplin Server 已安裝。${NC}"; sleep 2; } || install_joplin_server ;; + 6.12) [ -d "/root/trilium_data" ] && { echo -e "\n${YELLOW}Trilium Notes 已安裝。${NC}"; sleep 2; } || install_trilium ;; + 7.1) [ -d "/root/sillytavern_data" ] && { echo -e "\n${YELLOW}SillyTavern 已安裝。如需重裝請先執行選項 20.2 卸載。${NC}"; sleep 2; } || install_sillytavern ;; + 7.2) install_popodash ;; + 7.3) install_mimi ;; + 8.1) [ -f "/etc/xrdp/xrdp.ini" ] && { echo -e "\n${YELLOW}遠程工作台已安裝。${NC}"; sleep 2; } || install_desktop_env ;; + 8.2) install_chinese_fonts ;; + 8.3) + if [ ! -f "/etc/xrdp/xrdp.ini" ]; then + echo -e "${RED}错误:远程工作台未安装。${NC}"; sleep 3 + else + clear + echo -e "${BLUE} ── 远程桌面工具 ──────────────────────────────${NC}" + local cur_port; cur_port=$(grep "^port" /etc/xrdp/xrdp.ini 2>/dev/null | awk '{print $3}') + echo -e " 当前 RDP 端口: ${GREEN}${cur_port:-3389}${NC}" + echo "" + echo -e " ${YELLOW}1)${NC} 安装浏览器" + echo -e " ${YELLOW}2)${NC} 更改 RDP 端口(当前: ${cur_port:-3389})" + echo -e " ${YELLOW}其他)${NC} 取消" + read -p " 请选择: " sub83 + case "$sub83" in + 1) _install_desktop_browser ;; + 2) + echo "" + echo -e " ${YELLOW}建议使用 10000-65535 之间的端口,避免 3389 被扫描。${NC}" + read -p " 请输入新端口号: " new_rdp_port + if [[ "$new_rdp_port" =~ ^[0-9]+$ ]] && [ "$new_rdp_port" -ge 1024 ] && [ "$new_rdp_port" -le 65535 ]; then + sudo sed -i "s/^port[[:space:]]*=.*/port=${new_rdp_port}/" /etc/xrdp/xrdp.ini + sudo systemctl restart xrdp + echo -e " ${GREEN}✅ RDP 端口已更改为 ${new_rdp_port},服务已重启。${NC}" + echo -e " ${YELLOW}提示:请确保防火墙已放行新端口,否则无法连接。${NC}" + _save_credential "XRDP_PORT" "${new_rdp_port}" + else + echo -e " ${RED}❌ 无效端口号,请输入 1024-65535 之间的数字。${NC}" + fi + read -n 1 -s -r -p " 按任意键返回..." + ;; + esac + fi + ;; + 8.4) systemctl is-active --quiet fail2ban 2>/dev/null && { echo -e "\n${YELLOW}Fail2ban 已安裝並運行中。${NC}"; sleep 2; } || install_fail2ban ;; + 8.5) [ -d "/root/uptime_kuma_data" ] && { echo -e "\n${YELLOW}Uptime Kuma 已安裝。${NC}"; sleep 2; } || install_uptime_kuma ;; + 8.6) install_glances ;; + 8.7) systemctl is-active --quiet "syncthing@root" 2>/dev/null && { echo -e "\n${YELLOW}Syncthing 已安裝並運行中。${NC}"; sleep 2; } || install_syncthing ;; + 8.8) docker inspect grafana_app &>/dev/null && { echo -e "\n${YELLOW}Grafana 已安裝。${NC}"; sleep 2; } || install_grafana ;; + 9.1) add_ssh_public_key ;; + 9.2) toggle_ssh_password_login ;; + 10.1) run_nextcloud_optimization ;; + 10.2) run_nextcloud_super_boost ;; + 10.3) manage_nextcloud_storage ;; + 10.4) run_nextcloud_preview_setup ;; + 40) show_service_control_panel ;; + 41) run_backup_setup ;; + 10.5) show_credentials ;; + 10.6) show_vps_info ;; + 10.7) run_wrap_up_tasks ;; + 10.8) install_oci_helper ;; + 10.9) install_mail_report ;; + 20.1) install_oracle_fullstack ;; + 20.2) show_uninstall_menu ;; + 30.1) install_vless_node ;; + 30.2) install_hysteria_node ;; + x|X) system_cleanup ;; + 99) uninstall_everything ;; + q|Q) echo -e "${BLUE}裝修愉快,房主再見!${NC}"; exit 0 ;; + *) echo -e "${RED}無效的選項,請重新輸入。${NC}"; sleep 2 ;; + esac + done +} + +main "$@"