862 lines
30 KiB
Bash
Executable File
862 lines
30 KiB
Bash
Executable File
#!/bin/sh
|
|
# =============================================================================
|
|
# install-seafile.sh - install Seafile Server (Community Edition) on (almost)
|
|
# any Linux distribution and register it as a service that starts on boot.
|
|
# =============================================================================
|
|
# Seafile 13.x Community Edition is deployed with Docker, which is also the one
|
|
# method that behaves identically on every distribution. This script:
|
|
#
|
|
# 1. installs Docker Engine + the Compose v2 plugin if they are missing,
|
|
# using whatever fits your distro (apt, dnf, yum, zypper, pacman, apk,
|
|
# xbps; static-binary fallback for the Compose plugin),
|
|
# 2. downloads Seafile's official compose files for the chosen release and
|
|
# generates <dir>/.env with random secrets,
|
|
# 3. installs a service called "seafile" (systemd unit, or OpenRC script)
|
|
# so the stack starts at boot and is managed with systemctl/rc-service,
|
|
# 4. starts everything and waits until the web UI is up.
|
|
#
|
|
# Usage: sudo sh install-seafile.sh [install|upgrade|uninstall] [options]
|
|
# sudo sh install-seafile.sh --help
|
|
#
|
|
# POSIX sh only (works with dash, ash/busybox, bash) - no bashisms.
|
|
# =============================================================================
|
|
|
|
set -eu
|
|
umask 022
|
|
|
|
PROG=${0##*/}
|
|
|
|
# ------------------------------- defaults ------------------------------------
|
|
CMD=install
|
|
RELEASE=${SEAFILE_RELEASE:-13.0}
|
|
INSTALL_DIR=${SEAFILE_DIR:-/opt/seafile}
|
|
DATA_DIR=${SEAFILE_DATA_DIR:-/opt}
|
|
SF_HOST=${SEAFILE_HOSTNAME:-}
|
|
PROTOCOL=${SEAFILE_PROTOCOL:-}
|
|
ADMIN_EMAIL=${SEAFILE_ADMIN_EMAIL:-}
|
|
ADMIN_PASSWORD=${SEAFILE_ADMIN_PASSWORD:-}
|
|
TZ_NAME=${SEAFILE_TIMEZONE:-}
|
|
WAIT_TIMEOUT=${SEAFILE_WAIT_TIMEOUT:-900}
|
|
WITH_SEADOC=1
|
|
START_NOW=1
|
|
AUTO_DOCKER=1
|
|
ASSUME_YES=0
|
|
SKIP_CHECKS=0
|
|
SERVICE=seafile
|
|
UNIT_DIR=${SEAFILE_UNIT_DIR:-/etc/systemd/system}
|
|
INITD_DIR=${SEAFILE_INITD_DIR:-/etc/init.d}
|
|
NL='
|
|
'
|
|
PM=
|
|
INIT=none
|
|
ARCH=
|
|
OS_ID=unknown
|
|
OS_NAME=Linux
|
|
INTERACTIVE=0
|
|
FRESH=1
|
|
PASSWORD_GENERATED=0
|
|
ENV_FINAL=
|
|
ENV_FILE=
|
|
DOCKER_BIN=
|
|
|
|
# ------------------------------- helpers -------------------------------------
|
|
if [ -t 1 ]; then
|
|
C_RED=$(printf '\033[31m'); C_YEL=$(printf '\033[33m')
|
|
C_GRN=$(printf '\033[32m'); C_OFF=$(printf '\033[0m')
|
|
else
|
|
C_RED=; C_YEL=; C_GRN=; C_OFF=
|
|
fi
|
|
log() { printf '%s==>%s %s\n' "$C_GRN" "$C_OFF" "$*"; }
|
|
warn() { printf '%sWARNING:%s %s\n' "$C_YEL" "$C_OFF" "$*" >&2; }
|
|
die() { printf '%sERROR:%s %s\n' "$C_RED" "$C_OFF" "$*" >&2; exit 1; }
|
|
have() { command -v "$1" >/dev/null 2>&1; }
|
|
try() { "$@" >/dev/null 2>&1; }
|
|
|
|
usage() {
|
|
cat <<EOF
|
|
Usage: sudo sh $PROG [command] [options]
|
|
|
|
Commands:
|
|
install Install Docker if needed, deploy Seafile, enable the service (default)
|
|
upgrade Pull the newest images of the installed release line and recreate containers
|
|
uninstall Stop Seafile and remove the service (your data is kept)
|
|
|
|
Options for install:
|
|
--hostname HOST Domain name or IP address users will connect to
|
|
(default: ask, else this machine's primary IP)
|
|
--protocol http|https Default: https for public-looking domain names, http otherwise.
|
|
With https, Caddy obtains a Let's Encrypt certificate
|
|
(DNS must point here; ports 80 and 443 must be reachable).
|
|
--admin-email EMAIL First admin account (default: admin@<domain> or admin@seafile.local)
|
|
--admin-password PASS First admin password (default: random). Prefer the
|
|
SEAFILE_ADMIN_PASSWORD variable - arguments show up in 'ps'.
|
|
--release X.Y Seafile release line to deploy (default: $RELEASE)
|
|
--dir PATH Where the compose files and .env live (default: $INSTALL_DIR)
|
|
--data-dir PATH Base directory for persistent data (default: $DATA_DIR);
|
|
creates seafile-data, seafile-mysql, seafile-caddy, seadoc-data
|
|
--timezone TZ e.g. Europe/Berlin (default: this machine's time zone)
|
|
--no-seadoc Do not deploy the SeaDoc collaborative editor
|
|
--no-start Prepare everything but do not start the stack
|
|
--no-docker-install Fail instead of installing Docker automatically
|
|
--skip-checks Skip the port / RAM / container-name preflight checks
|
|
-y, --yes Never prompt; use defaults for whatever is not given
|
|
-h, --help Show this help
|
|
|
|
Environment variables: SEAFILE_HOSTNAME, SEAFILE_PROTOCOL, SEAFILE_ADMIN_EMAIL,
|
|
SEAFILE_ADMIN_PASSWORD, SEAFILE_RELEASE, SEAFILE_DIR, SEAFILE_DATA_DIR,
|
|
SEAFILE_TIMEZONE (same meaning as the options above).
|
|
EOF
|
|
}
|
|
|
|
set_opt() { # option value
|
|
case $1 in
|
|
--hostname) SF_HOST=$2 ;;
|
|
--protocol) PROTOCOL=$2 ;;
|
|
--admin-email) ADMIN_EMAIL=$2 ;;
|
|
--admin-password) ADMIN_PASSWORD=$2 ;;
|
|
--release) RELEASE=$2 ;;
|
|
--dir) INSTALL_DIR=$2 ;;
|
|
--data-dir) DATA_DIR=$2 ;;
|
|
--timezone) TZ_NAME=$2 ;;
|
|
esac
|
|
}
|
|
|
|
parse_args() {
|
|
while [ $# -gt 0 ]; do
|
|
case $1 in
|
|
install|upgrade|uninstall) CMD=$1 ;;
|
|
--hostname|--protocol|--admin-email|--admin-password|--release|--dir|--data-dir|--timezone)
|
|
[ $# -ge 2 ] || die "Option $1 needs a value."
|
|
set_opt "$1" "$2"
|
|
shift ;;
|
|
--hostname=*|--protocol=*|--admin-email=*|--admin-password=*|--release=*|--dir=*|--data-dir=*|--timezone=*)
|
|
set_opt "${1%%=*}" "${1#*=}" ;;
|
|
--no-seadoc) WITH_SEADOC=0 ;;
|
|
--no-start) START_NOW=0 ;;
|
|
--no-docker-install) AUTO_DOCKER=0 ;;
|
|
--skip-checks) SKIP_CHECKS=1 ;;
|
|
-y|--yes) ASSUME_YES=1 ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
*) die "Unknown argument: $1 (see --help)" ;;
|
|
esac
|
|
shift
|
|
done
|
|
}
|
|
|
|
validate_inputs() {
|
|
case $RELEASE in
|
|
latest) ;;
|
|
*) printf '%s' "$RELEASE" | grep -Eq '^[0-9]+\.[0-9]+$' \
|
|
|| die "--release must look like 13.0 (or be 'latest')." ;;
|
|
esac
|
|
case $PROTOCOL in
|
|
''|http|https) ;;
|
|
*) die "--protocol must be http or https." ;;
|
|
esac
|
|
INSTALL_DIR=${INSTALL_DIR%/}
|
|
DATA_DIR=${DATA_DIR%/}
|
|
for _p in "$INSTALL_DIR" "$DATA_DIR"; do
|
|
case $_p in
|
|
/*) ;;
|
|
*) die "Paths must be absolute: '$_p'" ;;
|
|
esac
|
|
case $_p in
|
|
*[!A-Za-z0-9_./-]*) die "Unsupported characters in path '$_p' (allowed: letters, digits, _ . / -)." ;;
|
|
esac
|
|
done
|
|
if [ -n "$SF_HOST" ]; then
|
|
printf '%s' "$SF_HOST" | grep -Eq '^[A-Za-z0-9.:-]+$' || die "Invalid --hostname '$SF_HOST'."
|
|
fi
|
|
if [ -n "$ADMIN_EMAIL" ]; then
|
|
printf '%s' "$ADMIN_EMAIL" | grep -Eq '^[^@[:space:]]+@[^@[:space:]]+\.[^@[:space:]]+$' \
|
|
|| die "Invalid --admin-email '$ADMIN_EMAIL'."
|
|
fi
|
|
case $ADMIN_PASSWORD in
|
|
*"'"*|*"$NL"*) die "The admin password must not contain a single quote or a newline." ;;
|
|
esac
|
|
ENV_FINAL=$INSTALL_DIR/.env
|
|
ENV_FILE=$ENV_FINAL
|
|
}
|
|
|
|
require_root() {
|
|
if [ "$(id -u)" -eq 0 ]; then return 0; fi
|
|
if have sudo && [ -f "$0" ]; then
|
|
log "Not running as root - re-executing with sudo."
|
|
exec sudo -E sh "$0" "$@"
|
|
fi
|
|
die "Run this script as root (for example: sudo sh $PROG)."
|
|
}
|
|
|
|
# ------------------------- small utility functions ---------------------------
|
|
fetch() { # url destination
|
|
if have curl; then
|
|
curl -fsSL --retry 3 --connect-timeout 20 -o "$2" "$1"
|
|
elif have wget; then
|
|
wget -q -O "$2" "$1"
|
|
else
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
rand() { # N random alphanumeric characters
|
|
LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c "$1"
|
|
}
|
|
|
|
is_ip() { printf '%s' "$1" | grep -Eq '^[0-9]{1,3}(\.[0-9]{1,3}){3}$|:'; }
|
|
|
|
wants_https() { # true if $1 looks like a public DNS name
|
|
case $1 in
|
|
localhost|*.local|*.lan|*.localdomain|*.internal|*.home.arpa|*.test|*.invalid) return 1 ;;
|
|
*.*) if is_ip "$1"; then return 1; fi; return 0 ;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
|
|
detect_ip() {
|
|
_ip=
|
|
if have ip; then
|
|
_ip=$(ip -4 route get 1.1.1.1 2>/dev/null \
|
|
| awk '{ for (i = 1; i < NF; i++) if ($i == "src") { print $(i + 1); exit } }')
|
|
fi
|
|
if [ -z "$_ip" ] && have hostname; then
|
|
_ip=$(hostname -I 2>/dev/null | awk '{ print $1 }')
|
|
fi
|
|
printf '%s' "${_ip:-localhost}"
|
|
}
|
|
|
|
detect_tz() {
|
|
_tz=
|
|
if [ -r /etc/timezone ]; then
|
|
_tz=$(head -n 1 /etc/timezone | tr -d '[:space:]')
|
|
fi
|
|
if [ -z "$_tz" ] && [ -L /etc/localtime ]; then
|
|
_tz=$(readlink /etc/localtime | sed 's|.*/zoneinfo/||')
|
|
fi
|
|
if [ -z "$_tz" ] && have timedatectl; then
|
|
_tz=$(timedatectl show -p Timezone --value 2>/dev/null || true)
|
|
fi
|
|
case $_tz in
|
|
''|*[!A-Za-z0-9_/+-]*) _tz=Etc/UTC ;;
|
|
esac
|
|
if [ -d /usr/share/zoneinfo ] && [ ! -e "/usr/share/zoneinfo/$_tz" ]; then
|
|
_tz=Etc/UTC
|
|
fi
|
|
printf '%s' "$_tz"
|
|
}
|
|
|
|
ask() { # prompt default -> answer on stdout (prompt goes to stderr)
|
|
printf '%s [%s]: ' "$1" "$2" >&2
|
|
_a=
|
|
read -r _a || _a=
|
|
printf '%s' "${_a:-$2}"
|
|
}
|
|
|
|
confirm() {
|
|
printf '%s [Y/n]: ' "$1" >&2
|
|
_a=
|
|
read -r _a || _a=
|
|
case $_a in n|N|no|NO|No) return 1 ;; esac
|
|
return 0
|
|
}
|
|
|
|
port_in_use() { # true if something listens on TCP port $1 (reads /proc: no ss/netstat needed)
|
|
_hex=$(printf '%04X' "$1")
|
|
for _f in /proc/net/tcp /proc/net/tcp6; do
|
|
[ -r "$_f" ] || continue
|
|
if awk -v h="$_hex" 'NR > 1 && $4 == "0A" { n = split($2, a, ":"); if (toupper(a[n]) == h) f = 1 } END { exit !f }' "$_f"; then
|
|
return 0
|
|
fi
|
|
done
|
|
return 1
|
|
}
|
|
|
|
# --- .env editing (values are single-quoted only when they need to be) ---
|
|
env_quote() {
|
|
case $1 in
|
|
*[!A-Za-z0-9_.,:@/+=-]*) printf "'%s'" "$1" ;;
|
|
*) printf '%s' "$1" ;;
|
|
esac
|
|
}
|
|
|
|
set_env() { # key value : replace KEY=... in $ENV_FILE, or append it
|
|
_v=$(env_quote "$2")
|
|
_tmp=$(mktemp "$ENV_FILE.XXXXXX")
|
|
SE_KEY=$1 SE_VAL=$_v awk '
|
|
BEGIN { k = ENVIRON["SE_KEY"]; v = ENVIRON["SE_VAL"]; done = 0 }
|
|
{
|
|
i = index($0, "=")
|
|
if (!done && i > 1 && substr($0, 1, i - 1) == k) { print k "=" v; done = 1 }
|
|
else print
|
|
}
|
|
END { if (!done) print k "=" v }
|
|
' "$ENV_FILE" > "$_tmp"
|
|
cat "$_tmp" > "$ENV_FILE"
|
|
rm -f "$_tmp"
|
|
}
|
|
|
|
get_env() { # key -> value (quotes stripped) from $ENV_FILE
|
|
sed -n "s/^$1=//p" "$ENV_FILE" | head -n 1 | tr -d "'\""
|
|
}
|
|
|
|
# ------------------------- platform detection --------------------------------
|
|
osr() { sed -n "s/^$1=//p" /etc/os-release 2>/dev/null | head -n 1 | tr -d "\"'"; }
|
|
|
|
detect_init() {
|
|
case ${SEAFILE_INIT_SYSTEM:-} in
|
|
systemd|openrc|none) INIT=$SEAFILE_INIT_SYSTEM; return 0 ;;
|
|
esac
|
|
if [ -d /run/systemd/system ] && have systemctl; then
|
|
INIT=systemd
|
|
elif have rc-service && have rc-update; then
|
|
INIT=openrc
|
|
else
|
|
INIT=none
|
|
fi
|
|
}
|
|
|
|
detect_platform() {
|
|
[ "$(uname -s)" = Linux ] || die "This installer supports Linux only."
|
|
case $(uname -m) in
|
|
x86_64|amd64) ARCH=x86_64 ;;
|
|
aarch64|arm64) ARCH=aarch64 ;;
|
|
*) die "Unsupported CPU architecture '$(uname -m)': Seafile's images exist for x86_64 and arm64 only." ;;
|
|
esac
|
|
if [ -r /etc/os-release ]; then
|
|
OS_ID=$(osr ID); OS_NAME=$(osr PRETTY_NAME)
|
|
fi
|
|
if [ -z "$OS_ID" ]; then OS_ID=unknown; fi
|
|
if [ -z "$OS_NAME" ]; then OS_NAME=Linux; fi
|
|
for _p in apt-get dnf yum zypper pacman apk xbps-install; do
|
|
if have "$_p"; then PM=$_p; break; fi
|
|
done
|
|
detect_init
|
|
}
|
|
|
|
# --------------------------- package manager glue ----------------------------
|
|
pm_refresh() {
|
|
case $PM in
|
|
apt-get) apt-get update ;;
|
|
zypper) zypper --non-interactive refresh ;;
|
|
*) return 0 ;;
|
|
esac
|
|
}
|
|
|
|
pm_install() { # packages...
|
|
case $PM in
|
|
apt-get) DEBIAN_FRONTEND=noninteractive apt-get install -y "$@" ;;
|
|
dnf) dnf install -y "$@" ;;
|
|
yum) yum install -y "$@" ;;
|
|
zypper) zypper --non-interactive install --no-recommends "$@" ;;
|
|
pacman) pacman -S --needed --noconfirm "$@" ;;
|
|
apk) apk add --no-cache "$@" ;;
|
|
xbps-install) xbps-install -Sy "$@" ;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
|
|
ensure_downloader() {
|
|
if have curl || have wget; then return 0; fi
|
|
[ -n "$PM" ] || die "Neither curl nor wget is installed, and no supported package manager was found."
|
|
log "Installing curl..."
|
|
pm_refresh || true
|
|
pm_install curl ca-certificates || die "Could not install curl; please install curl or wget and re-run."
|
|
}
|
|
|
|
# ------------------------------- Docker --------------------------------------
|
|
docker_up() { have docker && docker info >/dev/null 2>&1; }
|
|
compose_ok() { have docker && docker compose version >/dev/null 2>&1; }
|
|
|
|
docker_convenience_script() {
|
|
log "Running Docker's official convenience script (https://get.docker.com)..."
|
|
_s=$(mktemp)
|
|
if fetch https://get.docker.com "$_s" && sh "$_s"; then
|
|
rm -f "$_s"
|
|
return 0
|
|
fi
|
|
rm -f "$_s"
|
|
return 1
|
|
}
|
|
|
|
docker_distro_packages() {
|
|
warn "Docker's own installer did not work on $OS_NAME; trying the distribution's Docker package instead."
|
|
case $PM in
|
|
apt-get) pm_refresh && pm_install docker.io ;;
|
|
dnf|yum) pm_install docker || pm_install moby-engine ;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
|
|
install_docker_engine() {
|
|
case $PM in
|
|
pacman) pm_install docker docker-compose || pm_install docker ;;
|
|
apk) pm_install docker docker-cli-compose || pm_install docker ;;
|
|
xbps-install) pm_install docker docker-compose || pm_install docker ;;
|
|
zypper) pm_refresh || true
|
|
pm_install docker docker-compose || pm_install docker || docker_convenience_script ;;
|
|
apt-get|dnf|yum)
|
|
if [ "$OS_ID" = amzn ]; then
|
|
pm_install docker
|
|
else
|
|
docker_convenience_script || docker_distro_packages
|
|
fi ;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
|
|
start_docker_service() {
|
|
case $INIT in
|
|
systemd)
|
|
try systemctl enable --now docker || try systemctl start docker || true ;;
|
|
openrc)
|
|
try rc-update add docker default || true
|
|
try rc-service docker start || true ;;
|
|
*)
|
|
if [ -d /etc/sv/docker ] && [ -d /var/service ]; then
|
|
ln -sf /etc/sv/docker /var/service/docker
|
|
elif have service; then
|
|
try service docker start || true
|
|
elif [ -x /etc/init.d/docker ]; then
|
|
try /etc/init.d/docker start || true
|
|
fi ;;
|
|
esac
|
|
_i=0
|
|
while [ "$_i" -lt 60 ]; do
|
|
if docker info >/dev/null 2>&1; then return 0; fi
|
|
sleep 1
|
|
_i=$((_i + 1))
|
|
done
|
|
return 1
|
|
}
|
|
|
|
install_compose_plugin() {
|
|
log "Docker Compose v2 plugin not found - installing it..."
|
|
case $PM in
|
|
apt-get) try pm_install docker-compose-v2 || try pm_install docker-compose-plugin || true ;;
|
|
dnf|yum) try pm_install docker-compose-plugin || true ;;
|
|
zypper) try pm_install docker-compose || true ;;
|
|
esac
|
|
if compose_ok; then return 0; fi
|
|
|
|
# Fallback that works on every distro: the official static binary from GitHub.
|
|
_dest=/usr/local/lib/docker/cli-plugins
|
|
_url=https://github.com/docker/compose/releases/latest/download/docker-compose-linux-$ARCH
|
|
_tmp=$(mktemp)
|
|
mkdir -p "$_dest"
|
|
if ! fetch "$_url" "$_tmp"; then
|
|
rm -f "$_tmp"
|
|
die "Could not download the Compose plugin from $_url"
|
|
fi
|
|
if have sha256sum && fetch "$_url.sha256" "$_tmp.sha256" 2>/dev/null; then
|
|
_want=$(awk '{ print $1; exit }' "$_tmp.sha256")
|
|
_got=$(sha256sum "$_tmp" | awk '{ print $1 }')
|
|
if printf '%s' "$_want" | grep -Eq '^[0-9a-f]{64}$' && [ "$_want" != "$_got" ]; then
|
|
rm -f "$_tmp" "$_tmp.sha256"
|
|
die "Checksum mismatch for the downloaded Compose plugin - refusing to install it."
|
|
fi
|
|
fi
|
|
rm -f "$_tmp.sha256"
|
|
cp "$_tmp" "$_dest/docker-compose"
|
|
chmod 755 "$_dest/docker-compose"
|
|
rm -f "$_tmp"
|
|
}
|
|
|
|
ensure_docker() {
|
|
if docker_up && compose_ok; then
|
|
log "Docker and Compose are already available: $(docker --version)"
|
|
return 0
|
|
fi
|
|
if ! have docker; then
|
|
[ "$AUTO_DOCKER" -eq 1 ] || die "Docker is not installed (re-run without --no-docker-install to install it automatically)."
|
|
[ -n "$PM" ] || die "No supported package manager found. Install Docker Engine + Compose v2 manually (https://docs.docker.com/engine/install/) and re-run."
|
|
ensure_downloader
|
|
log "Installing Docker Engine on $OS_NAME (package manager: $PM)..."
|
|
install_docker_engine || die "Automatic Docker installation failed on $OS_NAME (on Arch, run 'pacman -Syu' and reboot first if packages are not found). Install Docker Engine + the Compose v2 plugin manually and re-run."
|
|
fi
|
|
start_docker_service || die "The Docker daemon is not running and could not be started."
|
|
if ! compose_ok; then
|
|
[ "$AUTO_DOCKER" -eq 1 ] || die "The Docker Compose v2 plugin is missing (re-run without --no-docker-install)."
|
|
ensure_downloader
|
|
install_compose_plugin
|
|
fi
|
|
compose_ok || die "'docker compose' still does not work. Please install the Docker Compose v2 plugin manually."
|
|
log "Docker is ready: $(docker --version)"
|
|
}
|
|
|
|
# ------------------------- preflight checks ----------------------------------
|
|
preflight() {
|
|
if [ "$SKIP_CHECKS" -eq 1 ]; then return 0; fi
|
|
_mem=$(awk '/^MemTotal:/ { print int($2 / 1024) }' /proc/meminfo 2>/dev/null || true)
|
|
if [ -n "$_mem" ] && [ "$_mem" -lt 1800 ]; then
|
|
warn "Only ${_mem} MB of RAM detected. MariaDB, Redis, Seafile, Caddy and SeaDoc together may be slow or get OOM-killed. Consider more RAM/swap, or --no-seadoc."
|
|
fi
|
|
if [ "$FRESH" -eq 1 ]; then
|
|
for _port in 80 443; do
|
|
if port_in_use "$_port"; then
|
|
die "TCP port $_port is already in use (Seafile's Caddy proxy needs ports 80 and 443). Stop the other service first, or use --skip-checks."
|
|
fi
|
|
done
|
|
fi
|
|
if [ "$ARCH" != x86_64 ]; then
|
|
warn "Running on $ARCH: Seafile publishes arm64 images, but 'docker compose pull' will tell you if one is missing."
|
|
fi
|
|
}
|
|
|
|
check_name_conflicts() {
|
|
if [ "$SKIP_CHECKS" -eq 1 ] || [ "$FRESH" -eq 0 ]; then return 0; fi
|
|
_existing=$(docker ps -a --format '{{.Names}}' 2>/dev/null || true)
|
|
for _n in seafile seafile-mysql seafile-redis seafile-caddy seadoc; do
|
|
if printf '%s\n' "$_existing" | grep -qx "$_n"; then
|
|
die "A Docker container named '$_n' already exists. Remove/rename it first (docker rm -f $_n) or use --skip-checks."
|
|
fi
|
|
done
|
|
}
|
|
|
|
# ------------------------- settings & configuration --------------------------
|
|
resolve_settings() {
|
|
if [ -z "$SF_HOST" ]; then
|
|
_def=$(detect_ip)
|
|
if [ "$INTERACTIVE" -eq 1 ]; then
|
|
SF_HOST=$(ask "Domain name or IP address for Seafile" "$_def")
|
|
else
|
|
SF_HOST=$_def
|
|
fi
|
|
fi
|
|
printf '%s' "$SF_HOST" | grep -Eq '^[A-Za-z0-9.:-]+$' || die "Invalid hostname '$SF_HOST'."
|
|
if [ -z "$PROTOCOL" ]; then
|
|
if wants_https "$SF_HOST"; then PROTOCOL=https; else PROTOCOL=http; fi
|
|
fi
|
|
if [ -z "$ADMIN_EMAIL" ]; then
|
|
if [ "$PROTOCOL" = https ] && ! is_ip "$SF_HOST"; then
|
|
_def=admin@$SF_HOST
|
|
else
|
|
_def=admin@seafile.local
|
|
fi
|
|
if [ "$INTERACTIVE" -eq 1 ]; then
|
|
ADMIN_EMAIL=$(ask "Admin e-mail address (used as login name)" "$_def")
|
|
else
|
|
ADMIN_EMAIL=$_def
|
|
fi
|
|
printf '%s' "$ADMIN_EMAIL" | grep -Eq '^[^@[:space:]]+@[^@[:space:]]+\.[^@[:space:]]+$' \
|
|
|| die "Invalid admin e-mail '$ADMIN_EMAIL'."
|
|
fi
|
|
if [ -z "$ADMIN_PASSWORD" ]; then
|
|
ADMIN_PASSWORD=$(rand 20)
|
|
PASSWORD_GENERATED=1
|
|
elif [ "${#ADMIN_PASSWORD}" -lt 8 ]; then
|
|
warn "The admin password is shorter than 8 characters - please change it after the first login."
|
|
fi
|
|
if [ -z "$TZ_NAME" ]; then TZ_NAME=$(detect_tz); fi
|
|
}
|
|
|
|
DOC_BASE=
|
|
GH_BASE=
|
|
get_remote() { # path-relative-to-docker-dir destination
|
|
fetch "$DOC_BASE/$1" "$2" 2>/dev/null && return 0
|
|
fetch "$GH_BASE/$1" "$2" 2>/dev/null && return 0
|
|
return 1
|
|
}
|
|
|
|
download_files() {
|
|
_ref=$RELEASE
|
|
if [ "$RELEASE" = latest ]; then _ref=master; fi
|
|
DOC_BASE=https://manual.seafile.com/$RELEASE/repo/docker
|
|
GH_BASE=https://raw.githubusercontent.com/haiwen/seafile-admin-docs/$_ref/manual/repo/docker
|
|
log "Downloading Seafile $RELEASE compose files..."
|
|
get_remote ce/env "$ENV_FILE" \
|
|
|| die "Could not download the .env template for Seafile $RELEASE (is --release valid? is the network up?)."
|
|
get_remote ce/seafile-server.yml "$INSTALL_DIR/seafile-server.yml" \
|
|
|| die "Could not download seafile-server.yml for Seafile $RELEASE."
|
|
get_remote caddy.yml "$INSTALL_DIR/caddy.yml" \
|
|
|| die "Could not download caddy.yml for Seafile $RELEASE."
|
|
if [ "$WITH_SEADOC" -eq 1 ]; then
|
|
get_remote seadoc.yml "$INSTALL_DIR/seadoc.yml" \
|
|
|| die "Could not download seadoc.yml for Seafile $RELEASE."
|
|
fi
|
|
grep -q '^JWT_PRIVATE_KEY=' "$ENV_FILE" || die "The downloaded .env template looks wrong (no JWT_PRIVATE_KEY)."
|
|
grep -q '^services:' "$INSTALL_DIR/seafile-server.yml" || die "The downloaded seafile-server.yml looks wrong."
|
|
}
|
|
|
|
write_env() {
|
|
chmod 600 "$ENV_FILE"
|
|
set_env SEAFILE_SERVER_HOSTNAME "$SF_HOST"
|
|
set_env SEAFILE_SERVER_PROTOCOL "$PROTOCOL"
|
|
set_env TIME_ZONE "$TZ_NAME"
|
|
set_env BASIC_STORAGE_PATH "$DATA_DIR"
|
|
set_env JWT_PRIVATE_KEY "$(rand 48)"
|
|
set_env SEAFILE_MYSQL_DB_PASSWORD "$(rand 24)"
|
|
set_env INIT_SEAFILE_MYSQL_ROOT_PASSWORD "$(rand 24)"
|
|
set_env INIT_SEAFILE_ADMIN_EMAIL "$ADMIN_EMAIL"
|
|
set_env INIT_SEAFILE_ADMIN_PASSWORD "$ADMIN_PASSWORD"
|
|
if [ "$WITH_SEADOC" -eq 0 ]; then
|
|
set_env COMPOSE_FILE "seafile-server.yml,caddy.yml"
|
|
set_env ENABLE_SEADOC false
|
|
fi
|
|
}
|
|
|
|
make_dirs() {
|
|
mkdir -p "$INSTALL_DIR" "$DATA_DIR/seafile-data" "$DATA_DIR/seafile-mysql/db" "$DATA_DIR/seafile-caddy"
|
|
if [ "$WITH_SEADOC" -eq 1 ]; then mkdir -p "$DATA_DIR/seadoc-data"; fi
|
|
}
|
|
|
|
# ------------------------- the service (the "daemon") ------------------------
|
|
install_service() {
|
|
DOCKER_BIN=$(command -v docker) || die "Cannot locate the docker binary."
|
|
case $INIT in
|
|
systemd)
|
|
mkdir -p "$UNIT_DIR"
|
|
_dep=
|
|
if try systemctl cat docker.service; then _dep="Requires=docker.service"; fi
|
|
cat > "$UNIT_DIR/$SERVICE.service" <<EOF
|
|
[Unit]
|
|
Description=Seafile server (Docker Compose stack in $INSTALL_DIR)
|
|
Documentation=https://manual.seafile.com/
|
|
$_dep
|
|
After=docker.service network-online.target
|
|
Wants=network-online.target
|
|
|
|
[Service]
|
|
Type=oneshot
|
|
RemainAfterExit=yes
|
|
WorkingDirectory=$INSTALL_DIR
|
|
Environment=HOME=/root
|
|
ExecStart=$DOCKER_BIN compose up -d --remove-orphans
|
|
ExecStop=$DOCKER_BIN compose stop --timeout 120
|
|
TimeoutStartSec=0
|
|
TimeoutStopSec=300
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
EOF
|
|
systemctl daemon-reload
|
|
try systemctl enable "$SERVICE" || warn "Could not enable the $SERVICE service at boot."
|
|
log "Installed systemd unit $UNIT_DIR/$SERVICE.service (enabled at boot)."
|
|
;;
|
|
openrc)
|
|
mkdir -p "$INITD_DIR"
|
|
cat > "$INITD_DIR/$SERVICE" <<EOF
|
|
#!/sbin/openrc-run
|
|
# Generated by $PROG
|
|
description="Seafile server (Docker Compose stack in $INSTALL_DIR)"
|
|
|
|
depend() {
|
|
need docker
|
|
after net
|
|
}
|
|
|
|
start() {
|
|
ebegin "Starting Seafile"
|
|
cd "$INSTALL_DIR" && "$DOCKER_BIN" compose up -d --remove-orphans
|
|
eend \$?
|
|
}
|
|
|
|
stop() {
|
|
ebegin "Stopping Seafile"
|
|
cd "$INSTALL_DIR" && "$DOCKER_BIN" compose stop --timeout 120
|
|
eend \$?
|
|
}
|
|
|
|
status() {
|
|
cd "$INSTALL_DIR" && "$DOCKER_BIN" compose ps
|
|
}
|
|
EOF
|
|
chmod 755 "$INITD_DIR/$SERVICE"
|
|
try rc-update add "$SERVICE" default || warn "Could not add $SERVICE to the default runlevel."
|
|
log "Installed OpenRC service $INITD_DIR/$SERVICE (added to the default runlevel)."
|
|
;;
|
|
*)
|
|
warn "No systemd or OpenRC detected, so no service file was created."
|
|
warn "The containers use 'restart: unless-stopped', so they come back by themselves whenever the Docker daemon starts at boot - make sure Docker is enabled at boot on this system."
|
|
return 1 ;;
|
|
esac
|
|
}
|
|
|
|
compose_in_dir() { (cd "$INSTALL_DIR" && docker compose "$@"); }
|
|
|
|
start_stack() {
|
|
log "Pulling container images (this can take a few minutes)..."
|
|
compose_in_dir pull || die "Pulling the images failed. Check the network connection (and Docker Hub rate limits)."
|
|
log "Starting Seafile..."
|
|
case $INIT in
|
|
systemd)
|
|
if ! systemctl start "$SERVICE"; then
|
|
systemctl status "$SERVICE" --no-pager -l || true
|
|
die "The $SERVICE service failed to start (see: journalctl -u $SERVICE)."
|
|
fi ;;
|
|
openrc)
|
|
rc-service "$SERVICE" start || die "The $SERVICE service failed to start." ;;
|
|
*)
|
|
compose_in_dir up -d --remove-orphans || die "'docker compose up' failed." ;;
|
|
esac
|
|
}
|
|
|
|
wait_ready() {
|
|
log "Waiting for Seafile to finish its first-time setup (can take several minutes)..."
|
|
_t=0
|
|
while [ "$_t" -lt "$WAIT_TIMEOUT" ]; do
|
|
_h=$(docker inspect -f '{{.State.Health.Status}}' seafile 2>/dev/null || true)
|
|
if [ "$_h" = healthy ]; then return 0; fi
|
|
if docker logs --tail 300 seafile 2>&1 | grep -q 'Seahub is started'; then return 0; fi
|
|
sleep 5
|
|
_t=$((_t + 5))
|
|
if [ $((_t % 30)) -eq 0 ]; then log " ...still starting (${_t}s)"; fi
|
|
done
|
|
return 1
|
|
}
|
|
|
|
row() { printf ' %-46s %s\n' "$1" "$2"; }
|
|
|
|
print_manage_help() {
|
|
printf '\nUseful commands:\n'
|
|
case $INIT in
|
|
systemd)
|
|
row "systemctl status|start|stop|restart $SERVICE" "(control the stack)"
|
|
row "journalctl -u $SERVICE" "(service log)" ;;
|
|
openrc)
|
|
row "rc-service $SERVICE status|start|stop|restart" "(control the stack)" ;;
|
|
*)
|
|
row "cd $INSTALL_DIR && docker compose ps|stop|up -d" "(control the stack)" ;;
|
|
esac
|
|
row "docker logs -f seafile" "(Seafile application log)"
|
|
row "sudo sh $PROG upgrade" "(pull newer images of this release line)"
|
|
}
|
|
|
|
print_summary() {
|
|
_url=$(get_env SEAFILE_SERVER_PROTOCOL)://$(get_env SEAFILE_SERVER_HOSTNAME)
|
|
_data=$(get_env BASIC_STORAGE_PATH)
|
|
printf '\n URL %s\n' "$_url"
|
|
printf ' Admin %s\n' "$(get_env INIT_SEAFILE_ADMIN_EMAIL)"
|
|
if [ "$PASSWORD_GENERATED" -eq 1 ]; then
|
|
printf ' Password %s (generated - change it after logging in)\n' "$ADMIN_PASSWORD"
|
|
elif [ "$FRESH" -eq 1 ]; then
|
|
printf ' Password (the one you supplied)\n'
|
|
else
|
|
printf ' Password see INIT_SEAFILE_ADMIN_PASSWORD in %s (valid only until you change it in the web UI)\n' "$ENV_FINAL"
|
|
fi
|
|
printf ' Settings %s (contains secrets, mode 600)\n' "$ENV_FINAL"
|
|
printf ' Data %s/seafile-data, %s/seafile-mysql\n' "${_data:-$DATA_DIR}" "${_data:-$DATA_DIR}"
|
|
if [ "$(get_env SEAFILE_SERVER_PROTOCOL)" = https ]; then
|
|
printf '\nHTTPS: Caddy requests a Let'"'"'s Encrypt certificate on first start, so the DNS record\nfor %s must point to this machine and ports 80/443 must be reachable.\n' "$(get_env SEAFILE_SERVER_HOSTNAME)"
|
|
fi
|
|
print_manage_help
|
|
}
|
|
|
|
# ------------------------------ commands -------------------------------------
|
|
do_install() {
|
|
if [ -f "$ENV_FINAL" ]; then FRESH=0; fi
|
|
if [ "$FRESH" -eq 1 ]; then resolve_settings; fi
|
|
log "System: $OS_NAME ($ARCH) | package manager: ${PM:-none} | init system: $INIT"
|
|
|
|
if [ "$FRESH" -eq 1 ] && [ "$INTERACTIVE" -eq 1 ]; then
|
|
printf '\nSeafile %s will be installed with:\n address %s://%s\n admin %s\n files %s (compose + .env) and %s (data)\n SeaDoc %s\n\n' \
|
|
"$RELEASE" "$PROTOCOL" "$SF_HOST" "$ADMIN_EMAIL" "$INSTALL_DIR" "$DATA_DIR" \
|
|
"$(if [ "$WITH_SEADOC" -eq 1 ]; then echo yes; else echo no; fi)"
|
|
confirm "Continue?" || die "Aborted by user."
|
|
fi
|
|
|
|
preflight
|
|
ensure_docker
|
|
check_name_conflicts
|
|
|
|
if [ "$FRESH" -eq 1 ]; then
|
|
make_dirs
|
|
ENV_FILE=$INSTALL_DIR/.env.new
|
|
download_files
|
|
write_env
|
|
mv -f "$ENV_FILE" "$ENV_FINAL"
|
|
ENV_FILE=$ENV_FINAL
|
|
else
|
|
log "Existing configuration found in $INSTALL_DIR - keeping it (secrets are not regenerated)."
|
|
if [ -n "$SF_HOST$PROTOCOL$ADMIN_EMAIL$ADMIN_PASSWORD$TZ_NAME" ]; then
|
|
warn "Settings given on the command line are ignored for an existing installation - edit $ENV_FINAL and restart the service instead."
|
|
fi
|
|
fi
|
|
|
|
compose_in_dir config -q \
|
|
|| die "The compose configuration is invalid. Fix $ENV_FINAL (or delete it and re-run to regenerate it)."
|
|
|
|
_have_service=1
|
|
install_service || _have_service=0
|
|
|
|
if [ "$START_NOW" -eq 0 ]; then
|
|
if [ "$_have_service" -eq 0 ]; then
|
|
_how="cd $INSTALL_DIR && docker compose up -d"
|
|
elif [ "$INIT" = openrc ]; then
|
|
_how="rc-service $SERVICE start"
|
|
else
|
|
_how="systemctl start $SERVICE"
|
|
fi
|
|
log "Prepared but not started (--no-start). Start it with: $_how"
|
|
print_summary
|
|
return 0
|
|
fi
|
|
|
|
start_stack
|
|
if wait_ready; then
|
|
log "Seafile is up and running."
|
|
print_summary
|
|
else
|
|
warn "Seafile did not report 'ready' within ${WAIT_TIMEOUT}s. The first start can be slow on small machines."
|
|
warn "Check progress with: docker logs -f seafile"
|
|
print_summary
|
|
exit 2
|
|
fi
|
|
}
|
|
|
|
do_upgrade() {
|
|
[ -f "$ENV_FINAL" ] || die "No Seafile installation found in $INSTALL_DIR (nothing to upgrade)."
|
|
if ! docker_up || ! compose_ok; then
|
|
die "Docker (with the Compose plugin) is not available or not running."
|
|
fi
|
|
log "Pulling the newest images for the installed release line..."
|
|
compose_in_dir pull || die "Pulling the images failed."
|
|
compose_in_dir up -d --remove-orphans || die "Recreating the containers failed."
|
|
if wait_ready; then
|
|
log "Upgrade finished and Seafile is healthy."
|
|
else
|
|
warn "Seafile did not report 'ready' in time - check: docker logs -f seafile"
|
|
exit 2
|
|
fi
|
|
log "Note: this follows the pinned tag (e.g. 13.0-latest). For a new major/minor line such as 14.0, read Seafile's upgrade notes first and back up your data."
|
|
}
|
|
|
|
do_uninstall() {
|
|
log "Removing the $SERVICE service and containers (your data is NOT deleted)..."
|
|
case $INIT in
|
|
systemd)
|
|
try systemctl disable --now "$SERVICE" || true
|
|
rm -f "$UNIT_DIR/$SERVICE.service"
|
|
try systemctl daemon-reload || true ;;
|
|
openrc)
|
|
try rc-service "$SERVICE" stop || true
|
|
try rc-update del "$SERVICE" default || true
|
|
rm -f "$INITD_DIR/$SERVICE" ;;
|
|
esac
|
|
if [ -f "$ENV_FINAL" ] && docker_up && compose_ok; then
|
|
compose_in_dir down || warn "'docker compose down' reported a problem."
|
|
fi
|
|
_data=$DATA_DIR
|
|
if [ -f "$ENV_FINAL" ]; then _data=$(get_env BASIC_STORAGE_PATH); fi
|
|
log "Done. Kept: $INSTALL_DIR (compose files + .env) and the data under ${_data:-$DATA_DIR}/ (seafile-data, seafile-mysql, seafile-caddy, seadoc-data)."
|
|
log "Delete those directories yourself only if you really want to erase everything."
|
|
}
|
|
|
|
# -------------------------------- main ---------------------------------------
|
|
main() {
|
|
for _a in "$@"; do
|
|
case $_a in -h|--help) usage; exit 0 ;; esac
|
|
done
|
|
require_root "$@"
|
|
parse_args "$@"
|
|
validate_inputs
|
|
if [ "$ASSUME_YES" -eq 0 ] && [ -t 0 ]; then INTERACTIVE=1; fi
|
|
detect_platform
|
|
case $CMD in
|
|
install) do_install ;;
|
|
upgrade) do_upgrade ;;
|
|
uninstall) do_uninstall ;;
|
|
esac
|
|
}
|
|
|
|
main "$@"
|
|
|