#!/usr/bin/env bash # OneFrog one-line installer. # # curl -fsSL https://raw.githubusercontent.com/llc370219/onefrog/main/install.sh | sudo bash # wget -qO- https://raw.githubusercontent.com/llc370219/onefrog/main/install.sh | sudo bash # without curl # # Supported: Debian 12/13, Ubuntu 22.04/24.04 and newer, RHEL-compatible 9/10 # (Rocky, AlmaLinux, CentOS Stream), Fedora; x86_64 and arm64. # # Options (environment variables, pass them after sudo): # OF_CHANNEL=stable release channel: stable (vX.Y.Z, image tag "latest") or # beta (test builds vX.Y.Z-beta.N from the dev branch, image # tag "beta"). Saved on first install; re-runs keep it. # OF_VERSION= image tag to install, e.g. v0.1.1. Defaults to the # channel's tag ("latest" or "beta"). # OF_PUBLISH_PORT=8080 host port # OF_BIND_ADDR=0.0.0.0 host address to bind; use 127.0.0.1 behind a reverse proxy # OF_PUBLIC_URL= link printed at the end, e.g. https://ops.example.com # (if unset, the public IPv4 is looked up via https://ifconfig.me; # set OF_PUBLIC_URL to skip that external request) # OF_ADMIN_USER=admin initial username (must be changed on first login) # OF_APPROVAL_MODE= preset the command approval mode for headless installs: # off | smart | manual | plan. Applies only while no mode has # been chosen; otherwise the first login asks you to choose. # OF_UPDATE_CHECK=1 set to 0 to stop the panel from checking GitHub for new releases # OF_INSTALL_DIR=/opt/onefrog # OF_PROJECT= docker compose project name. Defaults to "onefrog" for # /opt/onefrog and "onefrog-" for other install dirs, so # several instances can run side by side. Saved on first install. # OF_ALLOW_UNSUPPORTED_OS=0 set to 1 to try anyway on an end-of-life / unsupported OS # OF_INSTALL_DOCKER=1 set to 0 to abort instead of installing Docker # OF_BUILD_FROM_SOURCE=0 set to 1 to build the image on this server instead of pulling it # OF_REPO=https://github.com/llc370219/onefrog.git source repo used with OF_BUILD_FROM_SOURCE=1 # OF_IMAGE=ghcr.io/llc370219/onefrog image name # # Re-running the same command upgrades in place: the generated account, settings # and the data volume are kept; only the image and docker-compose.yml are replaced. # It also installs a host-side trigger so the panel can ask for that same upgrade # without mounting the Docker socket into the container. # # The whole script is wrapped in main() so a partially downloaded file never runs. set -euo pipefail main() { local install_dir="${OF_INSTALL_DIR:-/opt/onefrog}" local image="${OF_IMAGE:-ghcr.io/llc370219/onefrog}" local version="${OF_VERSION:-}" local repo="${OF_REPO:-https://github.com/llc370219/onefrog.git}" local build="${OF_BUILD_FROM_SOURCE:-0}" local env_file="${install_dir}/.install.env" local compose_file="${install_dir}/docker-compose.yml" say() { printf '%s\n' "$*"; } die() { printf '错误: %s\n' "$*" >&2; exit 1; } [ "$(uname -s)" = "Linux" ] || die "只支持 Linux 服务器。" [ "$(id -u)" -eq 0 ] || die "请用 root 运行,例如: curl -fsSL https://raw.githubusercontent.com/llc370219/onefrog/main/install.sh | sudo bash" # ---- operating system ---- # End-of-life releases usually cannot install Docker any more (their package # mirrors are archived), so stop early with a clear message. local os_id="" os_ver="" os_name="" eol="" if [ -r /etc/os-release ]; then # shellcheck disable=SC1091 os_id=$(. /etc/os-release && printf '%s' "${ID:-}") # shellcheck disable=SC1091 os_ver=$(. /etc/os-release && printf '%s' "${VERSION_ID:-}") # shellcheck disable=SC1091 os_name=$(. /etc/os-release && printf '%s' "${PRETTY_NAME:-${ID:-}}") fi case "${os_id}:${os_ver%%.*}" in debian:[0-9]|debian:10|debian:11) eol=1 ;; ubuntu:1[0-9]|ubuntu:20|ubuntu:21) eol=1 ;; centos:[0-8]|rhel:[0-7]|rocky:[0-7]|almalinux:[0-7]|ol:[0-7]) eol=1 ;; esac if [ -n "$eol" ]; then say "检测到系统: ${os_name}。这个版本已经停止维护(EOL),软件源可能已下线,安装 Docker 很可能失败。" say "支持的系统: Debian 12/13、Ubuntu 22.04/24.04 及更新版本、Rocky / AlmaLinux / CentOS Stream 9/10、Fedora。" if [ "${OF_ALLOW_UNSUPPORTED_OS:-0}" != "1" ] && ! command -v docker >/dev/null 2>&1; then die "请升级系统或换用受支持的系统后再安装。已经装好 Docker 的话可以直接重试;确实要继续尝试,带上 OF_ALLOW_UNSUPPORTED_OS=1 重新运行。" fi say "继续安装(已有 Docker 或设置了 OF_ALLOW_UNSUPPORTED_OS=1)。" fi if ! command -v curl >/dev/null 2>&1; then say "缺少 curl,正在用系统包管理器安装..." if command -v apt-get >/dev/null 2>&1; then { apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq curl ca-certificates; } || true elif command -v dnf >/dev/null 2>&1; then dnf install -y -q curl || true elif command -v yum >/dev/null 2>&1; then yum install -y -q curl || true fi command -v curl >/dev/null 2>&1 || die "缺少 curl,自动安装失败。请先修复软件源并安装 curl(apt install curl / dnf install curl)后重试。系统已停止维护时软件源可能已下线,见 https://github.com/llc370219/onefrog/blob/main/docs/安装.md#支持的系统" fi # ---- Docker ---- if ! command -v docker >/dev/null 2>&1; then if [ "${OF_INSTALL_DOCKER:-1}" = "0" ]; then die "没有检测到 Docker。你设置了 OF_INSTALL_DOCKER=0,请先自行安装 Docker 和 compose 插件后再运行。" fi say "没有检测到 Docker。" say "接下来会用 Docker 官方脚本 https://get.docker.com 安装 Docker。" say "注意: Docker 会自行添加 iptables/nftables 规则,发布的端口可能绕过你现有的防火墙规则。" say "如不希望自动安装,请按 Ctrl+C 取消,或带上 OF_INSTALL_DOCKER=0 重新运行。10 秒后开始..." sleep 10 if ! curl -fsSL https://get.docker.com | sh; then die "Docker 安装失败(${os_name:-未知系统})。常见原因:系统已停止维护、软件源(apt/yum)不可用或网络无法访问 download.docker.com。 请先修复软件源(例如 apt-get update 能正常完成),或参考 https://docs.docker.com/engine/install/ 手动安装 Docker 和 compose 插件,再重新运行本命令。" fi command -v docker >/dev/null 2>&1 || die "Docker 安装脚本已结束,但仍找不到 docker 命令。请手动安装 Docker 后重试。" fi docker compose version >/dev/null 2>&1 || die "Docker 已安装,但没有 compose 插件(docker compose)。请安装 docker-compose-plugin。" case "$install_dir" in /*) ;; *) die "OF_INSTALL_DIR 必须是绝对路径。" ;; esac install_dir="${install_dir%/}" [ -n "$install_dir" ] || die "OF_INSTALL_DIR 不能是 /。" env_file="${install_dir}/.install.env" compose_file="${install_dir}/docker-compose.yml" mkdir -p "$install_dir" chmod 700 "$install_dir" read_env() { local key=$1 line while IFS= read -r line || [ -n "$line" ]; do case $line in "$key"=*) printf '%s\n' "${line#"$key"=}"; return ;; esac done < "$env_file" } # Settings saved by an earlier run, or empty on a fresh install. saved() { if [ -f "$env_file" ]; then read_env "$1"; fi; } # ---- compose project (one per install dir) ---- local project project=$(saved OF_PROJECT) local save_project=0 if [ -z "$project" ]; then save_project=1 if [ -n "${OF_PROJECT:-}" ]; then project="$OF_PROJECT" elif [ -f "$env_file" ] || [ "$install_dir" = "/opt/onefrog" ]; then # v0.1.1 and earlier always used "onefrog"; keep it so the data volume is reused. project=onefrog else project="onefrog-$(basename "$install_dir" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9_-' '-' | sed 's/^-*//; s/-*$//')" [ "$project" != "onefrog-" ] || project="onefrog-$(printf '%s' "$install_dir" | cksum | cut -d ' ' -f 1)" fi printf '%s' "$project" | grep -Eq '^[a-z0-9][a-z0-9_-]*$' || die "OF_PROJECT 只能包含小写字母、数字、- 和 _,并以字母或数字开头。" elif [ -n "${OF_PROJECT:-}" ] && [ "$OF_PROJECT" != "$project" ]; then die "这个安装目录已经使用项目名 ${project},不能改成 ${OF_PROJECT}(会换成一个新的空数据卷)。" fi # Refuse to take over containers that belong to another install directory. local other_dir other_dir=$(docker ps -a --filter "label=com.docker.compose.project=${project}" --format '{{ .Label "com.docker.compose.project.working_dir" }}' 2>/dev/null | head -n 1 || true) if [ -n "$other_dir" ] && [ "${other_dir%/}" != "$install_dir" ]; then die "项目名 ${project} 已经被另一个安装目录 ${other_dir} 使用。继续会接管并重建那边的容器。 要在这台机器上再装一个实例,请换一个安装目录并指定新的项目名和端口,例如: OF_INSTALL_DIR=/opt/onefrog-2 OF_PROJECT=onefrog-2 OF_PUBLISH_PORT=8081" fi # ---- port conflict ---- local want_port own_port="" users="" want_port="${OF_PUBLISH_PORT:-$(saved OF_PUBLISH_PORT)}"; want_port="${want_port:-8080}" own_port=$(docker ps --filter "label=com.docker.compose.project=${project}" --filter "publish=${want_port}" -q 2>/dev/null || true) if [ -z "$own_port" ]; then users=$(docker ps --filter "publish=${want_port}" --format '{{.Names}}' 2>/dev/null | tr '\n' ' ' || true) if [ -z "$users" ] && command -v ss >/dev/null 2>&1; then users=$(ss -Hltn "sport = :${want_port}" 2>/dev/null | head -n 1 || true) fi if [ -n "$users" ]; then die "端口 ${want_port} 已被占用(${users% })。请换一个端口,例如带上 OF_PUBLISH_PORT=8081 重新运行。" fi fi # Version currently running, to tell "upgraded" from "already up to date". local previous="" cid cid=$(docker ps -a --filter "label=com.docker.compose.project=${project}" --filter "label=com.docker.compose.service=app" -q 2>/dev/null | head -n 1 || true) if [ -n "$cid" ]; then previous=$(docker inspect --format '{{ index .Config.Labels "org.opencontainers.image.version" }}' "$cid" 2>/dev/null || true) [ "$previous" != "" ] || previous="" fi case "${OF_APPROVAL_MODE:-}" in ""|off|smart|manual|plan) ;; *) die "OF_APPROVAL_MODE 只能是 off、smart、manual 或 plan。" ;; esac case "${OF_UPDATE_CHECK:-}" in ""|0|1) ;; *) die "OF_UPDATE_CHECK 只能是 0 或 1。" ;; esac # ---- release channel (saved, so a plain re-run stays on it) ---- local channel="${OF_CHANNEL:-$(saved OF_CHANNEL)}" case "$channel" in ""|stable|latest) channel=stable ;; beta|test) channel=beta ;; *) die "OF_CHANNEL 只能是 stable(正式版)或 beta(测试版)。" ;; esac if [ -z "$version" ]; then if [ "$channel" = "beta" ]; then version=beta; else version=latest; fi fi # ---- credentials (reused on re-run) ---- local fresh=0 if [ ! -f "$env_file" ]; then fresh=1 local port="${OF_PUBLISH_PORT:-8080}" local bind="${OF_BIND_ADDR:-0.0.0.0}" local admin_user="${OF_ADMIN_USER:-admin}" local public ip secure=0 pass if [ -n "${OF_PUBLIC_URL:-}" ]; then public="${OF_PUBLIC_URL%/}" else # External lookup of this server's public IPv4, only to print a working link. ip=$(curl -4 -fsS --max-time 5 https://ifconfig.me 2>/dev/null || true) [ -n "$ip" ] || ip=$(hostname -I 2>/dev/null | cut -d ' ' -f 1 || true) [ -n "$ip" ] || ip=127.0.0.1 if [ "$bind" = "127.0.0.1" ]; then ip=127.0.0.1; fi if [ "$port" = "80" ]; then public="http://${ip}"; else public="http://${ip}:${port}"; fi fi case "$public" in https:*) secure=1 ;; esac pass=$(head -c 64 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | cut -c1-20) [ "${#pass}" -eq 20 ] || die "无法生成初始密码。" ( umask 077 cat > "$env_file" <> "$env_file"; fi if [ -n "${OF_UPDATE_CHECK:-}" ]; then echo "OF_UPDATE_CHECK=${OF_UPDATE_CHECK}" >> "$env_file"; fi ) chmod 600 "$env_file" say "已生成初始账号,保存在 ${env_file}(权限 600)。" else say "检测到已有安装,将原地升级:沿用 ${env_file} 和数据卷,不会重置账号、设置或数据。" # Allow changing port / bind address on re-run without touching credentials. local k for k in OF_PUBLISH_PORT OF_BIND_ADDR OF_PUBLIC_URL OF_APPROVAL_MODE OF_UPDATE_CHECK; do if [ -n "${!k:-}" ]; then local v="${!k}" [ "$k" = "OF_PUBLIC_URL" ] && v="${v%/}" grep -v "^${k}=" "$env_file" > "${env_file}.tmp" || true printf '%s=%s\n' "$k" "$v" >> "${env_file}.tmp" if [ "$k" = "OF_PUBLIC_URL" ]; then grep -v '^OF_COOKIE_SECURE=' "${env_file}.tmp" > "${env_file}.tmp2" || true case "$v" in https:*) echo 'OF_COOKIE_SECURE=1' ;; *) echo 'OF_COOKIE_SECURE=0' ;; esac >> "${env_file}.tmp2" mv "${env_file}.tmp2" "${env_file}.tmp" fi chmod 600 "${env_file}.tmp" mv "${env_file}.tmp" "$env_file" say "已更新 ${k}。" fi done fi if [ "$save_project" = "1" ]; then printf 'OF_PROJECT=%s\n' "$project" >> "$env_file" fi if [ "$(read_env OF_CHANNEL)" != "$channel" ]; then grep -v '^OF_CHANNEL=' "$env_file" > "${env_file}.tmp" || true printf 'OF_CHANNEL=%s\n' "$channel" >> "${env_file}.tmp" chmod 600 "${env_file}.tmp" mv "${env_file}.tmp" "$env_file" fi local pub_port bind_addr public admin_user pass pub_port=$(read_env OF_PUBLISH_PORT); pub_port=${pub_port:-8080} bind_addr=$(read_env OF_BIND_ADDR); bind_addr=${bind_addr:-0.0.0.0} public=$(read_env OF_PUBLIC_URL) admin_user=$(read_env OF_ADMIN_USER) pass=$(read_env OF_ADMIN_PASSWORD) # ---- image: pull prebuilt, or build from source ---- local ref="${image}:${version}" if [ "$build" = "1" ]; then command -v git >/dev/null 2>&1 || die "OF_BUILD_FROM_SOURCE=1 需要 git,请先安装 git。" local src="${install_dir}/src" if [ -d "${src}/.git" ]; then say "更新源码 ${src} ..." git -C "$src" fetch --depth 1 origin && git -C "$src" reset --hard FETCH_HEAD else say "克隆源码 ${repo} 到 ${src} ..." rm -rf "$src" git clone --depth 1 "$repo" "$src" fi ref="onefrog:local" say "正在本机构建镜像(需要较多内存和磁盘,首次可能要 5–10 分钟)..." docker build -t "$ref" "$src" else say "拉取镜像 ${ref} ..." if ! docker pull "$ref"; then die "拉取镜像失败: ${ref} 如果还没有发布镜像,可以改为在本机构建: curl -fsSL https://raw.githubusercontent.com/llc370219/onefrog/main/install.sh | sudo OF_BUILD_FROM_SOURCE=1 bash" fi fi # Panel "立即升级" writes a release tag into install_dir/run. A root helper # on the host pulls the image already pinned in docker-compose.yml. The tag # is checked and then discarded; it is never executed and never selects a # different image. Paths outside this safe set skip the helper. local click_upgrade=0 upgrade_env="" upgrade_vol="" if printf '%s' "$install_dir" | grep -Eq '^/[A-Za-z0-9._/-]+$' && ! printf '%s' "$install_dir" | grep -q '\.\.'; then click_upgrade=1 upgrade_env=' OF_UPGRADE_DIR: /run/onefrog-upgrade' upgrade_vol=" - ${install_dir}/run:/run/onefrog-upgrade" fi install_click_upgrade() { local install_dir="$1" project="$2" mkdir -p "${install_dir}/run" chmod 755 "${install_dir}/run" rm -f "${install_dir}/run/upgrade" cat > "${install_dir}/upgrade.sh" </dev/null 2>&1; then exec 9>"\$lock" flock -n 9 || exit 0 fi [ -f "\$trigger" ] || exit 0 tag=\$(head -n 1 "\$trigger" | tr -d '\r\n ') rm -f "\$trigger" printf '%s' "\$tag" | grep -Eq '^v[0-9]{1,8}(\.[0-9]{1,8}){2}(-beta\.[0-9]{1,8})?\$' || exit 0 [ "\${#tag}" -le 40 ] || exit 0 docker compose --project-directory "\$dir" -f "\$dir/docker-compose.yml" -p "\$project" pull docker compose --project-directory "\$dir" -f "\$dir/docker-compose.yml" -p "\$project" up -d --remove-orphans EOF chown 0:0 "${install_dir}/upgrade.sh" chmod 755 "${install_dir}/upgrade.sh" if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then cat > "/etc/systemd/system/onefrog-upgrade-${project}.service" < "/etc/systemd/system/onefrog-upgrade-${project}.path" < "$compose_file" </dev/null 2>&1; then ready=1; break fi i=$((i + 1)); sleep 2 done local installed installed=$(docker image inspect --format '{{ index .Config.Labels "org.opencontainers.image.version" }}' "$ref" 2>/dev/null || true) [ -n "$installed" ] && [ "$installed" != "" ] || installed="$ref" local shown="$installed" if [ "$channel" = "beta" ]; then shown="${installed} · 测试版"; fi say "" say "========================================" if [ "$fresh" = "1" ]; then say "OneFrog 已安装(${shown})" say "链接: ${public}" say "用户名: ${admin_user}" say "初始密码: ${pass}" say "========================================" say "第一次登录必须修改用户名和密码,改完之前不能使用面板里的其他功能。" say "初始密码也保存在 ${env_file}(仅 root 可读)。" else if [ -n "$previous" ] && [ "$previous" = "$installed" ]; then say "OneFrog 已是最新版本(${shown})" elif [ -n "$previous" ]; then say "OneFrog 已从 ${previous} 升级到 ${shown}" else say "OneFrog 已更新到 ${shown}" fi say "链接: ${public}" say "========================================" say "账号、设置和数据都已保留,请用你自己设置的用户名和密码登录。" fi say "" case "${bind_addr}|${public}" in 127.0.0.1\|*) ;; *\|http:*) say "!!! 警告: 面板正以明文 HTTP 对所有网络地址开放(${bind_addr}:${pub_port})。" say "!!! 登录密码和会话 Cookie 可能被窃听。建议配置 HTTPS 反向代理后用" say "!!! OF_BIND_ADDR=127.0.0.1 OF_PUBLIC_URL=https://你的域名 重新运行本安装命令。" say "" ;; esac say "安全提示:" say "- 面板会保存你服务器的 SSH 凭据,请尽快放到 HTTPS 反向代理(Caddy/Nginx)后面使用,不要长期用明文 http 登录。" say "- Docker 发布的端口会绕过 ufw/firewalld 等主机防火墙。只想通过反向代理访问时,用 OF_BIND_ADDR=127.0.0.1 重新安装。" say "- 云服务器的安全组需要放行端口 ${pub_port}(如果你直接访问它)。" say "" say "常用命令(先 cd ${install_dir}):" say " docker compose -p ${project} ps # 查看状态" say " docker compose -p ${project} logs -f # 查看日志" say " docker compose -p ${project} restart # 重启" if [ "$ready" -ne 1 ]; then printf '服务还没有通过健康检查。查看日志: cd %s && docker compose -p %s logs\n' "$install_dir" "$project" >&2 exit 1 fi } main "$@"