Files
jigongtou/install.sh
T
2026-05-14 21:01:48 +00:00

7513 lines
372 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <<EOF
# 由鸡公头面板自动生成
# tunnel: UUID (这一行由cloudflared自动管理,请勿手动修改)
# credentials-file: /root/.cloudflared/UUID.json (这一行也由cloudflared自动管理)
ingress:
- service: http_status:404
EOF
echo -e "${GREEN}✅ Cloudflare Tunnel 服务安装成功!${NC}"
echo -e "${YELLOW}正在启动服务...${NC}"
sudo systemctl start cloudflared
sleep 2
if systemctl is-active --quiet cloudflared; then
echo -e "${GREEN}✅ Tunnel 服务已成功启动!现在您可以部署其他应用了。${NC}"
else
echo -e "${RED}❌ Tunnel 服务启动失败!请检查日志排查问题。${NC}"
echo -e "${RED} 您可以尝试执行 'sudo journalctl -u cloudflared -f' 来查看实时日志。${NC}"
fi
else
echo -e "${RED}❌ Tunnel 服务安装失败!请检查您的 Token 是否正确,或查看 cloudflared 的错误输出。${NC}"
fi
echo -e "\n${GREEN}按任意键返回主菜单...${NC}"; read -n 1 -s
}
# --- 前置检查 ---
check_tunnel_installed() {
if ! systemctl is-active --quiet cloudflared 2>/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 <<EOF
[Unit]
Description=Rclone Mount Service for ${rclone_remote_name} -> ${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 <<EOF
services:
onlyoffice:
image: onlyoffice/documentserver:latest
container_name: onlyoffice_app
restart: always
ports:
- "127.0.0.1:8889:80"
volumes:
- './data:/var/www/onlyoffice/Data'
- './logs:/var/log/onlyoffice'
environment:
JWT_ENABLED: 'true'
JWT_SECRET: ${ONLYOFFICE_JWT_SECRET}
EOF
_compose_up "OnlyOffice" "/root/onlyoffice_data" || { read -n 1 -s -r -p "按任意键返回..."; return 1; }
echo -e "${GREEN}✅ OnlyOffice 已启动,等待服务稳定...${NC}"; sleep 5
echo -e "\n## OnlyOffice 凭证 (部署于: $(date))" >> ${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 <<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
# ==================== 【关键修复开始】 ====================
# 在容器启动前,预先创建配置文件,并添加信任代理的设置
echo -e "${YELLOW}正在为 Home Assistant 预配置受信任的代理...${NC}"
cat > /root/home_assistant_data/config/configuration.yaml <<EOF
# 由鸡公头面板自动添加,用于信任 Cloudflare Tunnel
default_config:
http:
use_x_forwarded_for: true
trusted_proxies:
- 127.0.0.1 # 信任来自本机的请求
- 172.16.0.0/12 # 信任 Docker 的默认网段
- 10.0.0.0/8 # 信任其他常见的内部网段
- 192.168.0.0/16
EOF
# ==================== 【关键修复结束】 ====================
echo -e "${YELLOW}正在启动 Home Assistant 服务... (首次启动可能需要几分钟时间初始化,请稍候...)${NC}"
if _compose_up "Home Assistant" "/root/home_assistant_data"; then
echo -e "${GREEN}✅ Home Assistant 已启动,正在进行初始化...${NC}"; sleep 10
echo -e "\n## Home Assistant 凭证 (部署于: $(date))" >> ${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 <<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
if _compose_up "WordPress" "/root/wordpress_data"; then
echo -e "${GREEN}✅ WordPress 已启动,等待服务稳定...${NC}"; sleep 5
echo -e "\n## WordPress 凭证 (部署于: $(date))" >> ${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 <<EOF
services:
ollama:
image: ollama/ollama
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'
depends_on:
- ollama
EOF
if _compose_up "AI 大脑" "/root/ai_stack"; then
echo -e "${GREEN}✅ AI 核心已启动,等待服务稳定...${NC}"; sleep 5
echo -e "\n## AI 核心凭证 (部署于: $(date))" >> ${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 <<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
echo -e "${CYAN}提示:Jellyfin 已挂载整个 /mnt,添加媒体库时路径填 /mnt/Movies 或 /mnt/Frankfurt/Movies 等即可。${NC}"
if _compose_up "Jellyfin" "/root/jellyfin_data"; then
echo -e "${GREEN}✅ Jellyfin 已启动,等待服务稳定...${NC}"; sleep 5
echo -e "\n## Jellyfin 凭证 (部署于: $(date))" >> ${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 <<EOF
services:
navidrome:
image: deluan/navidrome:latest
container_name: navidrome_app
restart: unless-stopped
ports:
- "127.0.0.1:4533:4533"
volumes:
- '${ND_MUSIC_PATH}:/music'
- './data:/data'
environment:
- 'PUID=1000'
- 'PGID=1000'
- 'TZ=Asia/Shanghai'
EOF
if _compose_up "Navidrome" "/root/navidrome_data"; then
echo -e "${GREEN}✅ Navidrome 已启动,等待服务稳定...${NC}"; sleep 5
echo -e "\n## Navidrome 凭证 (部署于: $(date))" >> ${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 <<EOF
services:
db:
image: postgres:15-alpine
container_name: miniflux_db
restart: unless-stopped
environment:
POSTGRES_USER: miniflux
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: miniflux
volumes:
- ./db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "miniflux"]
interval: 10s
start_period: 30s
app:
image: miniflux/miniflux:latest
container_name: miniflux_app
restart: unless-stopped
ports:
- "127.0.0.1:8091:8080"
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgres://miniflux:${DB_PASSWORD}@db/miniflux?sslmode=disable
RUN_MIGRATIONS: "1"
CREATE_ADMIN: "1"
ADMIN_USERNAME: admin
ADMIN_PASSWORD: ${ADMIN_PASSWORD_INITIAL}
BASE_URL: https://${MINIFLUX_DOMAIN}
EOF
if _compose_up "Miniflux" "/root/miniflux_data"; then
echo -e "${GREEN}✅ Miniflux 已启动,等待服务稳定...${NC}"; sleep 10
echo -e "\n## Miniflux 凭证 (部署于: $(date))" >> ${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 <<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
if _compose_up "Gitea" "/root/gitea_data"; then
echo -e "${GREEN}✅ Gitea 已启动,等待服务稳定...${NC}"; sleep 5
echo -e "\n## Gitea 凭证 (部署于: $(date))" >> ${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 TunnelWeb + 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 <<EOF
services:
qbittorrent:
image: lscr.io/linuxserver/qbittorrent:latest
container_name: qbittorrent_app
restart: unless-stopped
ports:
- "127.0.0.1:8080:8080"
- "127.0.0.1:6881:6881"
- "127.0.0.1:6881:6881/udp"
environment:
- 'PUID=1000'
- 'PGID=1000'
- 'TZ=Asia/Shanghai'
- 'WEBUI_PORT=8080'
volumes:
- './config:/config'
- '/mnt/Downloads:/downloads'
EOF
if _compose_up "qBittorrent" "/root/qbittorrent_data"; then
echo -e "${GREEN}✅ qBittorrent 已启动,等待服务稳定...${NC}"; sleep 5
echo -e "\n## qBittorrent 凭证 (部署于: $(date))" >> ${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 <<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=${JDOWNLOADER_PASS}'
EOF
if _compose_up "JDownloader" "/root/jdownloader_data"; then
echo -e "${GREEN}✅ JDownloader 已启动,等待服务稳定...${NC}"; sleep 5
echo -e "\n## JDownloader 凭证 (部署于: $(date))" >> ${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. 用 SFTPWindTerm/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:优先 aptUbuntu 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
}
# Chromiumapt 多包名回退 → 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 <<EOF
services:
vless:
image: teddysun/xray:latest
container_name: vless_app
restart: always
ports:
- "127.0.0.1:40001:40001"
command: xray -c /etc/xray/config.json
volumes:
- ./config.json:/etc/xray/config.json
EOF
cat > /root/vless_data/config.json <<EOF
{
"log": { "loglevel": "warning" },
"inbounds": [
{
"listen": "0.0.0.0",
"port": 40001,
"protocol": "vless",
"settings": {
"clients": [
{ "id": "${UUID}", "level": 0 }
],
"decryption": "none"
},
"streamSettings": { "network": "ws" }
}
],
"outbounds": [ { "protocol": "freedom" } ]
}
EOF
if _compose_up "Vless" "/root/vless_data"; then
echo -e "${GREEN}✅ VLESS 节点已启动,等待服务稳定...${NC}"; sleep 5
update_tunnel_config "${VLESS_DOMAIN}" "http://127.0.0.1:40001" "VLESS Node"
local VLESS_LINK="vless://${UUID}@${VLESS_DOMAIN}:443?encryption=none&security=tls&type=ws&host=${VLESS_DOMAIN}&path=%2F#VPS-Tunnel-VLESS"
echo -e "\n## “科学上网” VLESS 节点 (部署于: $(date))" >> ${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 <<EOF
services:
socks5:
# 【建材升级】已从 serjs/go-socks5-proxy 替换为维护更积极的 dockage/socks5
image: dockage/socks5:latest
container_name: socks5_proxy_app
restart: unless-stopped
ports:
- "127.0.0.1:1080:1080"
EOF
if (cd /root/socks5_proxy_data && sudo docker compose up -d); then
echo -e "${GREEN}✅ Socks5 代理服務已啟動,等待服務穩定...${NC}"; sleep 5
echo -e "\n## Socks5 代理憑證 (部署于: $(date))" >> ${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 <<EOF > /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 <<EOF > "$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 <<EOF > "$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 <<EOF
defaults
auth on
tls on
tls_starttls on
tls_trust_file /etc/ssl/certs/ca-certificates.crt
logfile ~/.msmtp.log
account default
host $mail_server
port $mail_port
from $mail_user
user $mail_user
password $mail_pass
EOF
chmod 600 /etc/msmtprc; echo -e "${GREEN}✅ 邮件发送服务 (msmtp) 配置完毕。${NC}"; echo "-------------------------------------------------"
echo -e "${CYAN}正在任命新的邮件管家 (msmtp)...${NC}"
# [修复] 预先创建文件,消除 grep 找不到文件的报错警告
touch /etc/mail.rc
if ! grep -q "set mta=/usr/bin/msmtp" "/etc/mail.rc"; then echo "set mta=/usr/bin/msmtp" >> "/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="<html><body><h2 style='color:#2c3e50;'>服务器每日管家报告</h2><p><b>主机名:</b> $HOSTNAME</p><p><b>报告时间:</b> $CURRENT_TIME</p><hr><h3>核心状态摘要:</h3><ul><li><b>已持续运行:</b> $UPTIME</li><li><b>最近启动于:</b> $LAST_REBOOT</li><li><b>SSH 登录失败次数 (今日):</b> <strong style='color:red;'>$FAILED_LOGINS 次</strong></li></ul><hr><h3>今日网络流量报告 (由 vnstat 提供):</h3><pre style='background-color:#f5f5f5; padding:10px; border-radius:5px; font-family:monospace;'>$TRAFFIC_INFO</pre><p style='font-size:12px; color:#7f8c8d;'>提示: 如果 vnstat 报告显示 'Not enough data available yet',这是正常的,请等待24小时后它才能收集到完整数据。</p></body></html>"
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 '/<gui/a \ <insecureSkipHostcheck>true</insecureSkipHostcheck>' ~/.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 <<EOF
[Unit]
Description=Syncthing - Open Source Continuous File Synchronization for %i
Documentation=man:syncthing(1)
After=network.target
[Service]
User=%i
ExecStart=/usr/bin/syncthing --no-browser --no-restart --logflags=0
Restart=on-failure
SuccessExitStatus=3 4
RestartForceExitStatus=3 4
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now syncthing@root.service
echo -e "\n${GREEN}✅ Syncthing 部署成功!${NC}"
echo -e "${CYAN}访问域名: https://${SYNC_DOMAIN}${NC}"
echo -e "${YELLOW}提示:请立即在网页中 [操作->设置->图形用户界面] 设置用户名和密码!${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 ""
# ── 前置检查 1Cloudflare 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','<Tunnel-ID>'))
except:
print('<Tunnel-ID>')
" 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 <mp> " 精确匹配挂载点字段,避免路径被当成 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-dataNextcloud 可直接读写)${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 <<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:
- ${IMMICH_UPLOAD_PATH}:/usr/src/app/upload
- /etc/localtime:/etc/localtime:ro
environment:
DB_HOSTNAME: immich_postgres
DB_USERNAME: immich
DB_PASSWORD: ${DB_PASSWORD}
DB_DATABASE_NAME: immich
REDIS_HOSTNAME: immich_redis
JWT_SECRET: ${JWT_SECRET}
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
environment:
TZ: Asia/Shanghai
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: ${DB_PASSWORD}
POSTGRES_DB: immich
volumes:
- immich_pgdata:/var/lib/postgresql/data
volumes:
immich_model_cache:
immich_pgdata:
EOF
echo -e "${YELLOW}[1/3] 正在拉取镜像并启动(首次需要几分钟)...${NC}"
if (cd /root/immich_data && sudo docker compose up -d); then
echo -e "${GREEN}✅ Immich 已启动,等待服务稳定...${NC}"; sleep 10
echo -e "${YELLOW}[2/3] 正在配置 Cloudflare Tunnel...${NC}"
update_tunnel_config "${IMMICH_DOMAIN}" "http://127.0.0.1:2283" "Immich 相册"
echo -e "${YELLOW}[3/3] 保存凭证...${NC}"
echo -e "\n## Immich 凭证 (部署于: $(date))" >> ${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 <<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'
- '${CW_BOOKS_PATH}:/books'
environment:
- PUID=0
- PGID=0
- TZ=Asia/Shanghai
- DOCKER_MODS=linuxserver/mods:universal-calibre
EOF
if (cd /root/calibre_web_data && sudo docker compose up -d); then
echo -e "${GREEN}✅ Calibre-Web 已启动...${NC}"; sleep 5
update_tunnel_config "${CW_DOMAIN}" "http://127.0.0.1:8083" "Calibre-Web"
echo -e "\n## Calibre-Web 凭证 (部署于: $(date))" >> ${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 <<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'
- '${KV_BOOKS_PATH}:/manga'
environment:
- TZ=Asia/Shanghai
EOF
if (cd /root/kavita_data && sudo docker compose up -d); then
echo -e "${GREEN}✅ Kavita 已启动...${NC}"; sleep 5
update_tunnel_config "${KV_DOMAIN}" "http://127.0.0.1:5000" "Kavita"
echo -e "\n## Kavita 凭证 (部署于: $(date))" >> ${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 <<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
if (cd /root/gotify_data && sudo docker compose up -d); then
echo -e "${GREEN}✅ Gotify 已启动...${NC}"; sleep 3
update_tunnel_config "${GT_DOMAIN}" "http://127.0.0.1:8085" "Gotify"
echo -e "\n## Gotify 凭证 (部署于: $(date))" >> ${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 <<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
if (cd /root/actual_budget_data && sudo docker compose up -d); then
echo -e "${GREEN}✅ Actual Budget 已启动...${NC}"; sleep 3
update_tunnel_config "${AB_DOMAIN}" "http://127.0.0.1:5006" "Actual Budget"
echo -e "\n## Actual Budget 凭证 (部署于: $(date))" >> ${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 <<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
if (cd /root/excalidraw_data && sudo docker compose up -d); then
echo -e "${GREEN}✅ Excalidraw 已启动...${NC}"; sleep 3
update_tunnel_config "${ED_DOMAIN}" "http://127.0.0.1:8086" "Excalidraw"
echo -e "\n## Excalidraw 凭证 (部署于: $(date))" >> ${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 <<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=${JP_MAILER_ENABLED}
- 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
if (cd /root/joplin_data && sudo docker compose up -d); then
echo -e "${GREEN}✅ Joplin Server 已启动,等待数据库初始化...${NC}"; sleep 8
update_tunnel_config "${JP_DOMAIN}" "http://127.0.0.1:${PORT_JOPLIN}" "Joplin Server"
echo -e "\n## Joplin Server 凭证 (部署于: $(date))" >> ${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 <<EOF
services:
trilium:
image: ${IMG_TRILIUM}
container_name: trilium_app
restart: unless-stopped
ports:
- "127.0.0.1:${PORT_TRILIUM}:8080"
volumes:
- /root/trilium_data:/home/node/trilium-data
environment:
- TZ=${TZ_DEFAULT}
EOF
if (cd /root/trilium_data && sudo docker compose up -d); then
echo -e "${GREEN}✅ Trilium Notes 已启动,等待服务就绪...${NC}"; sleep 5
update_tunnel_config "${TN_DOMAIN}" "http://127.0.0.1:${PORT_TRILIUM}" "Trilium Notes"
echo -e "\n## Trilium Notes 凭证 (部署于: $(date))" >> ${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 <<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
if (cd /root/grafana_data && sudo docker compose up -d); then
echo -e "${GREEN}✅ Grafana 已启动,等待服务稳定...${NC}"; sleep 8
update_tunnel_config "${GF_DOMAIN}" "http://127.0.0.1:3003" "Grafana"
echo -e "\n## Grafana 凭证 (部署于: $(date))" >> ${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} ✅ 预览图尺寸:256x256JPEG质量: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 "$@"