Files
2026-10-06 22:40:59 -03:00

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 "$@"