#!/bin/sh
# Slipway installer. POSIX sh — safe to run under dash/busybox (curl | sh).
# Usage:
#   curl -sSL https://get.slipway.so/install.sh | sh
#   curl -sSL https://get.slipway.so/install.sh | PANEL_DOMAIN=panel.example.com sh
#
# Pinning a specific version channel:
#   curl -sSL https://get.slipway.so/install.sh | SLIPWAY_VERSION=v1.5 sh
#
# Fresh installs pin to the latest "vX.Y" minor channel by default — only
# bugfixes are picked up automatically. Major/minor bumps need to be opted
# into via the Updates panel.
set -eu

INSTALL_DIR="${SLIPWAY_DIR:-/etc/slipway}"
# Public install host (Cloudflare R2 bucket bound to get.slipway.so). The
# panel IMAGE is pulled from GHCR; only these non-secret install assets live
# on R2.
SLIPWAY_GET_URL="${SLIPWAY_GET_URL:-https://get.slipway.so}"
SLIPWAY_REPO="${SLIPWAY_REPO:-slipwaygroup/slipway}"
SLIPWAY_IMAGE="${SLIPWAY_IMAGE:-ghcr.io/${SLIPWAY_REPO}}"
SLIPWAY_UPDATER_IMAGE="${SLIPWAY_UPDATER_IMAGE:-${SLIPWAY_IMAGE}-updater:latest}"
COMPOSE_URL="${SLIPWAY_COMPOSE_URL:-${SLIPWAY_GET_URL}/docker-compose.yml}"
DOCKER_DAEMON_JSON="/etc/docker/daemon.json"

log() { printf '\033[1;36m[slipway]\033[0m %s\n' "$*"; }
err() { printf '\033[1;31m[slipway]\033[0m %s\n' "$*" >&2; }

require_root() {
  if [ "$(id -u)" -ne 0 ]; then
    # Re-exec via sudo only when we're a real file on disk; under `curl | sh`
    # there's no script to re-run, so tell the user to pipe to `sudo sh`.
    if [ -f "$0" ] && command -v sudo >/dev/null 2>&1; then
      log "Re-running with sudo…"
      exec sudo -E sh "$0" "$@"
    fi
    err "Please run as root (e.g. curl -sSL https://get.slipway.so/install.sh | sudo sh)."
    exit 1
  fi
}

# Real nameservers for the Docker daemon to hand its containers, or nothing.
#
# WHY THIS EXISTS: on a systemd-resolved box, /etc/resolv.conf is usually a
# symlink to stub-resolv.conf, whose only entry is `nameserver 127.0.0.53`.
# Docker filters loopback nameservers out when it builds a container's
# resolv.conf, but BuildKit's build sandbox inherits the host file as-is, so
# every `RUN` step queries 127.0.0.53 inside its OWN namespace, where nothing
# is listening. The build then dies on a DNS timeout (`EAI_AGAIN`) at the first
# package fetch, while ordinary app containers keep working, which makes it
# look like a broken Dockerfile rather than a host DNS problem.
#
# We only report the uplinks systemd-resolved already resolved to, so this is a
# statement of the machine's existing configuration, not a new DNS opinion.
real_nameservers() {
  # The non-stub file systemd-resolved maintains alongside the stub. Documented
  # as an alternative /etc/resolv.conf target, so its contents are exactly the
  # uplinks the host is already using.
  if [ -r /run/systemd/resolve/resolv.conf ]; then
    awk '/^nameserver/ && $2 !~ /^127\./ && $2 != "::1" { print $2 }' \
      /run/systemd/resolve/resolv.conf 2>/dev/null | head -3
    return
  fi
  if command -v resolvectl >/dev/null 2>&1; then
    resolvectl dns 2>/dev/null | tr ' ' '\n' \
      | awk '/^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/ && $0 !~ /^127\./ { print }' | head -3
  fi
}

# True when every nameserver the host resolves through is a loopback address,
# which is the condition that breaks builds.
resolv_is_loopback_only() {
  _ns="$(awk '/^nameserver/ { print $2 }' /etc/resolv.conf 2>/dev/null)"
  [ -n "$_ns" ] || return 1
  printf '%s\n' "$_ns" | grep -qvE '^(127\.|::1$)' && return 1
  return 0
}

# Render the "dns" key for daemon.json, or nothing when there is no problem to
# fix or no trustworthy answer to fix it with.
build_dns_json() {
  resolv_is_loopback_only || return 0
  _servers="$(real_nameservers)"
  [ -n "$_servers" ] || return 0
  _list=""
  for _s in $_servers; do
    if [ -z "$_list" ]; then _list="\"$_s\""; else _list="$_list, \"$_s\""; fi
  done
  printf '  "dns": [%s],\n' "$_list"
}

# Docker's json-file log driver is UNCAPPED by default: any chatty container
# grows a log file until the disk is full. Slipway's own containers pin their
# rotation (the `logging:` blocks in docker-compose.yml, and the remote
# Traefik's LogConfig), but the apps and databases the panel DEPLOYS inherit
# the daemon default, so give the daemon a sane one. The same file carries the
# build-DNS fix above, because POSIX sh cannot merge JSON and one writer is the
# only way both keys can land safely.
#
# Deliberately conservative: we only ever CREATE the file. POSIX sh can't merge
# JSON safely, so an existing daemon.json is left exactly as it is and we just
# point out the gap. We also never restart the daemon: on a box that already
# runs containers that's an outage, not an install step.
ensure_log_rotation_default() {
  _dns_json="$(build_dns_json)"

  if [ -f "$DOCKER_DAEMON_JSON" ]; then
    if ! grep -q 'max-size' "$DOCKER_DAEMON_JSON" 2>/dev/null; then
      log "Note: $DOCKER_DAEMON_JSON exists and sets no log rotation, so container"
      log "      logs can grow without limit. To fix, merge these keys into it by hand:"
      log '        "log-driver": "json-file",'
      log '        "log-opts": { "max-size": "10m", "max-file": "3" }'
      log "      then run: systemctl restart docker"
    fi
    if [ -n "$_dns_json" ] && ! grep -q '"dns"' "$DOCKER_DAEMON_JSON" 2>/dev/null; then
      log "Note: this host resolves DNS only through a loopback stub"
      log "      (/etc/resolv.conf -> 127.0.0.53). App containers are fine, but image"
      log "      BUILDS inherit that file and fail on a DNS timeout at the first package"
      log "      fetch. To fix, merge this key into $DOCKER_DAEMON_JSON:"
      log "      $(printf '%s' "$_dns_json" | sed 's/^  //; s/,$//')"
      log "      then run: systemctl restart docker"
    fi
    return
  fi
  # Some distros (and snap packages) start dockerd with --log-driver/--log-opt
  # on the command line. Docker REFUSES to start when the same directive is
  # given both as a flag and in daemon.json, so writing this file would break
  # the daemon on its next restart. Stay out of the way if that's the setup.
  _psargs="$(ps -eo args 2>/dev/null || ps ax 2>/dev/null || true)"
  if printf '%s\n' "$_psargs" | grep -qE '[d]ockerd.*--log-(driver|opt)'; then
    log "Note: dockerd is started with --log-* flags; leaving log defaults alone."
    return
  fi
  mkdir -p "$(dirname "$DOCKER_DAEMON_JSON")"
  # Written as one file because POSIX sh cannot merge JSON. `dns` is present
  # only when this host would otherwise fail every build (see build_dns_json).
  {
    printf '{\n'
    # Command substitution ate build_dns_json's trailing newline, so add it back
    # rather than running the two keys together on one line.
    [ -n "$_dns_json" ] && printf '%s\n' "$_dns_json"
    printf '  "log-driver": "json-file",\n'
    printf '  "log-opts": { "max-size": "10m", "max-file": "3" }\n'
    printf '}\n'
  } > "$DOCKER_DAEMON_JSON"
  log "Set a 10MB x 3 container-log rotation default in $DOCKER_DAEMON_JSON."
  if [ -n "$_dns_json" ]; then
    log "Pinned container DNS to this host's own uplink resolvers, because"
    log "  /etc/resolv.conf points only at a loopback stub and image builds"
    log "  inherit it verbatim, which breaks every build with a DNS timeout."
  fi
  if [ "${1:-}" != "fresh" ]; then
    log "  It takes effect for containers created after the next 'systemctl restart docker'."
  fi
}

ensure_docker() {
  if command -v docker >/dev/null 2>&1; then
    log "Docker is installed ($(docker --version))."
    ensure_log_rotation_default
  else
    # Written before the install so the brand-new daemon starts with it and
    # nothing has to be restarted.
    ensure_log_rotation_default fresh
    log "Installing Docker via the official convenience script…"
    curl -fsSL https://get.docker.com | sh
    systemctl enable --now docker || true
  fi
  if ! docker compose version >/dev/null 2>&1; then
    err "Docker Compose plugin not found. Please install docker-compose-plugin."
    exit 1
  fi
}

# Print the name of the process listening on TCP port $1, or nothing when the
# port is free. Tries ss, then netstat, then lsof; when none of the three
# exist we print nothing, because guessing wrong and aborting someone's
# install is worse than missing a clash.
port_listener() {
  _p="$1"
  _line=""
  _name=""
  if command -v ss >/dev/null 2>&1; then
    _line=$(ss -lntp 2>/dev/null | awk -v p=":$_p" '$4 ~ p "$"' | head -1)
    # ss reports the owner as users:(("nginx",pid=123,fd=6)).
    _name=$(printf '%s' "$_line" | grep -oE '\("[^"]+"' | head -1 | tr -d '("')
  elif command -v netstat >/dev/null 2>&1; then
    _line=$(netstat -lntp 2>/dev/null | awk -v p=":$_p" '$4 ~ p "$"' | head -1)
    # netstat names the owner "<pid>/<program>" in its last column (or "-"
    # without privileges). Matched rather than taken positionally because a
    # program name can contain spaces ("812/nginx: master").
    _name=$(printf '%s' "$_line" | grep -oE '[0-9]+/[^[:space:]]+' | head -1 | sed 's#^[0-9]*/##; s#:$##')
  elif command -v lsof >/dev/null 2>&1; then
    _line=$(lsof -nP -iTCP:"$_p" -sTCP:LISTEN 2>/dev/null | awk 'NR == 2')
    _name=$(printf '%s' "$_line" | awk '{ print $1 }')
  fi
  [ -n "$_line" ] || return 0
  case "$_name" in "" | -*) _name="an unknown process" ;; esac
  printf '%s' "$_name"
}

# True when TCP port $1 is published by a container we manage, i.e. this is a
# re-run of the installer on a box that already has Slipway on :80/:443.
port_owned_by_slipway() {
  command -v docker >/dev/null 2>&1 || return 1
  _names=$(docker ps --filter "publish=$1" --format '{{.Names}}' 2>/dev/null || true)
  # If the daemon is too old for the publish filter (or unreachable), fall back
  # to "is our own Traefik running", which is the only thing we bind here.
  [ -n "$_names" ] || _names=$(docker ps --format '{{.Names}}' 2>/dev/null | grep -i traefik || true)
  printf '%s\n' "$_names" | grep -q '^slipway'
}

# Traefik is the only thing that publishes :80 and :443, and it binds them on
# the host. If something already owns either port, `docker compose up` dies
# with a raw Docker bind error after the installer has already installed
# Docker and written config. Marketplace LAMP / cPanel images ship with a web
# server on :80, so this is common. Check before anything is mutated.
# Opt out with SLIPWAY_SKIP_PORT_CHECK=1.
preflight_ports() {
  [ "${SLIPWAY_SKIP_PORT_CHECK:-0}" = "0" ] || return 0
  _clashes=""
  _container_clash=0
  for _port in 80 443; do
    _who=$(port_listener "$_port")
    [ -n "$_who" ] || continue
    if port_owned_by_slipway "$_port"; then continue; fi
    # docker-proxy on the port means some other container published it.
    case "$_who" in *docker*) _container_clash=1 ;; esac
    _clashes="$_clashes  port $_port is used by $_who
"
  done
  [ -n "$_clashes" ] || return 0
  err "Ports 80 and 443 must be free. Traefik binds them to route your apps."
  err ""
  printf '%s' "$_clashes" >&2
  err ""
  err "Usually this is a web server that came with the image (Apache, nginx,"
  err "lighttpd, or a cPanel/Plesk stack). Stop and disable it, for example:"
  err "    systemctl disable --now apache2      # or nginx, httpd, lighttpd, caddy"
  if [ "$_container_clash" = "1" ]; then
    err "One of those is another Docker container publishing the port. Find it with:"
    err "    docker ps --filter publish=80 --filter publish=443"
  fi
  err "Then re-run this installer."
  err ""
  err "Nothing on this machine has been changed."
  err "(SLIPWAY_SKIP_PORT_CHECK=1 skips this check if you know it is fine.)"
  exit 1
}

random_secret() {
  if command -v openssl >/dev/null 2>&1; then openssl rand -hex 32
  else head -c 32 /dev/urandom | xxd -p -c 64; fi
}

# Read a value from a prior install's .env (empty if absent). Lets re-running
# the installer act as a safe "refresh" that preserves existing config instead
# of regenerating it — critical for SESSION_SECRET (regenerating it would log
# everyone out and change the deterministic template passwords).
existing_env() {
  [ -f "$INSTALL_DIR/.env" ] || return 0
  grep -E "^$1=" "$INSTALL_DIR/.env" 2>/dev/null | head -1 | cut -d= -f2-
}

# Pick the current latest "vX.Y" tag if SLIPWAY_VERSION isn't pinned. We
# fall back to "latest" if the GitHub API isn't reachable.
resolve_default_version() {
  if [ -n "${SLIPWAY_VERSION:-}" ]; then return; fi
  local tag
  tag="$(curl -fsSL --max-time 10 "https://api.github.com/repos/${SLIPWAY_REPO}/releases/latest" 2>/dev/null \
    | grep -oE '"tag_name":\s*"[^"]+"' | head -1 | sed 's/.*"\(v[0-9]*\.[0-9]*\)\.[0-9]*"/\1/')"
  if [ -n "$tag" ]; then
    SLIPWAY_VERSION="$tag"
  else
    SLIPWAY_VERSION="latest"
  fi
}

require_root "$@"
preflight_ports
ensure_docker

log "Slipway will be installed to $INSTALL_DIR"
mkdir -p "$INSTALL_DIR"

# Panel domain + Let's Encrypt are configured in the in-app setup wizard, not
# here, so there are no interactive prompts. They can still be pre-seeded via
# env vars for automated installs (PANEL_DOMAIN=… LETSENCRYPT_EMAIL=…).
# Config precedence on each value: an explicit env var wins, else the value
# from a prior install's .env, else a freshly generated/default one. This makes
# re-running the installer a safe refresh (e.g. to pull infra changes) without
# rotating secrets or blanking the domain.
PANEL_DOMAIN="${PANEL_DOMAIN:-$(existing_env PANEL_DOMAIN)}"
LETSENCRYPT_EMAIL="${LETSENCRYPT_EMAIL:-$(existing_env LETSENCRYPT_EMAIL)}"
SESSION_SECRET="${SESSION_SECRET:-$(existing_env SESSION_SECRET)}"
SESSION_SECRET="${SESSION_SECRET:-$(random_secret)}"
# Shared secret between the panel and the local Traefik for the database-viewer
# HTTP provider URL. Generated once at install; preserved across re-runs.
SLIPWAY_TRAEFIK_TOKEN="${SLIPWAY_TRAEFIK_TOKEN:-$(existing_env SLIPWAY_TRAEFIK_TOKEN)}"
SLIPWAY_TRAEFIK_TOKEN="${SLIPWAY_TRAEFIK_TOKEN:-$(random_secret)}"
# Keep the pinned version on a re-run (the in-UI updater owns version bumps);
# only resolve the latest tag for a genuinely fresh install.
SLIPWAY_VERSION="${SLIPWAY_VERSION:-$(existing_env SLIPWAY_VERSION)}"
resolve_default_version

# Write one KEY=value into .env, replacing the existing KEY= line in place and
# appending when it isn't there. Every other line (comments, keys we don't know
# about) is left untouched. These are exactly the semantics of the panel's own
# setEnvVar() in services/updater.ts, so the installer and the in-app updater
# agree on the file.
#
# This MUST be a merge, not a rewrite: SLIPWAY_VERSION_PREV is written by the
# panel when you apply an update and is the only input to in-panel rollback
# (routes/updates.ts), and LICENSE_PUBLIC_KEY/KID may be set by hand. A
# heredoc that rewrites the whole file silently deletes both on every re-run.
set_env_kv() {
  _file="$INSTALL_DIR/.env"
  [ -f "$_file" ] || : > "$_file"
  # Key and value travel via the environment rather than `awk -v` because -v
  # processes backslash escapes in the value.
  _k="$1" _v="$2" awk '
    BEGIN { key = ENVIRON["_k"]; val = ENVIRON["_v"] }
    index($0, key "=") == 1 { print key "=" val; found = 1; next }
    { print }
    END { if (!found) print key "=" val }
  ' "$_file" > "$_file.tmp"
  # Keep whatever mode the file already had; the rename would otherwise reset a
  # hardened .env back to the default. GNU-only flag, hence the guard.
  chmod --reference="$_file" "$_file.tmp" 2>/dev/null || true
  mv -f "$_file.tmp" "$_file"
}

if [ ! -f "$INSTALL_DIR/.env" ]; then
  cat > "$INSTALL_DIR/.env" <<'EOF'
# Slipway install configuration. The "in-UI updater" rewrites
# SLIPWAY_VERSION when you apply an update; other values are yours to edit.
# Re-running the installer updates the keys it owns and preserves the rest.
EOF
fi

set_env_kv PANEL_DOMAIN "${PANEL_DOMAIN:-}"
set_env_kv LETSENCRYPT_EMAIL "${LETSENCRYPT_EMAIL:-}"
set_env_kv SESSION_SECRET "$SESSION_SECRET"
set_env_kv SLIPWAY_TRAEFIK_TOKEN "$SLIPWAY_TRAEFIK_TOKEN"
set_env_kv SLIPWAY_REPO "$SLIPWAY_REPO"
set_env_kv SLIPWAY_IMAGE "$SLIPWAY_IMAGE"
set_env_kv SLIPWAY_UPDATER_IMAGE "$SLIPWAY_UPDATER_IMAGE"
set_env_kv SLIPWAY_VERSION "$SLIPWAY_VERSION"
set_env_kv SLIPWAY_INSTALL_DIR "$INSTALL_DIR"

# Always (re)download the compose. It's treated as immutable — user
# customisations live in docker-compose.override.yml — so refreshing it is safe
# and is how infra changes (e.g. the Traefik upload-timeout fix) reach existing
# installs when you re-run the installer, no reprovision needed.
log "Downloading docker-compose.yml…"
curl -fsSL "$COMPOSE_URL" -o "$INSTALL_DIR/docker-compose.yml"

# Auto-detect host-mounted drives (extra SSDs, NAS shares, …) and bind-mount
# them into the panel container so the file browser can see them. Only runs
# on a fresh install — never overwrites an existing override.yml.
#
# Detection rules:
#   - Skip pseudo-filesystems (tmpfs, proc, overlay, …).
#   - Skip the root filesystem and system paths (/boot, /var/lib/docker, …).
#   - Skip mountpoints inside the panel's own install/data dirs.
#   - Inside the container the drives appear under /mnt/<basename>, with a
#     numeric suffix on name collisions.
#
# Opt out: SLIPWAY_AUTOMOUNT=0 sh install.sh
detect_host_mounts() {
  if [ "${SLIPWAY_AUTOMOUNT:-1}" = "0" ]; then return; fi
  if [ -f "$INSTALL_DIR/docker-compose.override.yml" ]; then
    log "Override file exists; skipping drive auto-detection."
    return
  fi
  # Pseudo-fs we never want to surface.
  _skip_fs="tmpfs|devtmpfs|proc|sysfs|cgroup|cgroup2|overlay|squashfs|mqueue|devpts|shm|securityfs|pstore|bpf|tracefs|debugfs|configfs|fusectl|hugetlbfs|autofs|binfmt_misc|ramfs|nsfs|rpc_pipefs|efivarfs|selinuxfs"
  # System mountpoints we always skip even if backed by a real fs.
  _skip_path_re="^(/|/boot|/boot/.*|/var/lib/docker.*|/var/lib/containerd.*|/snap.*|/proc.*|/sys.*|/dev.*|/run.*|/etc/.*|$INSTALL_DIR.*)$"

  _candidates=""
  # awk: $1=device $2=mount $3=fs. /proc/mounts uses \040 for spaces — we
  # skip any mountpoint containing one to keep the override file safe.
  while IFS=' ' read -r _dev _mp _fs _rest; do
    [ -n "$_mp" ] || continue
    case "$_mp" in *\\040*) continue ;; esac
    echo "$_fs" | grep -qxE "$_skip_fs" && continue
    echo "$_mp" | grep -qxE "$_skip_path_re" && continue
    [ -d "$_mp" ] || continue
    _candidates="$_candidates
$_mp"
  done < /proc/mounts

  # Dedupe while preserving order, drop empty lines.
  _candidates=$(printf '%s\n' "$_candidates" | awk 'NF && !seen[$0]++')

  if [ -z "$_candidates" ]; then
    log "No extra host drives detected."
    return
  fi

  log "Detected host drives, will mount them into the panel under /mnt:"
  _override_lines=""
  _used_names=""
  for _mp in $_candidates; do
    _base=$(basename "$_mp")
    _name="$_base"
    _i=1
    while echo "$_used_names" | grep -qFx "$_name"; do
      _i=$((_i + 1))
      _name="${_base}-${_i}"
    done
    _used_names="$_used_names
$_name"
    log "  $_mp  ->  /mnt/$_name"
    _override_lines="${_override_lines}      - ${_mp}:/mnt/${_name}
"
  done

  cat > "$INSTALL_DIR/docker-compose.override.yml" <<EOF
# Auto-generated by install.sh on first run. Edit freely — installer won't
# overwrite this file. To regenerate: delete and re-run install.sh.
# To skip on install: SLIPWAY_AUTOMOUNT=0 curl ... | sh
services:
  panel:
    volumes:
${_override_lines}
EOF
  log "Wrote $INSTALL_DIR/docker-compose.override.yml"
}

detect_host_mounts

log "Pulling images…"
(cd "$INSTALL_DIR" && docker compose pull)

log "Starting Slipway…"
(cd "$INSTALL_DIR" && docker compose up -d)

# Where to send the user. On a VPS the public address and the host's own
# address are the same, so there's one URL. Behind NAT (a homelab, which is a
# first-class mode: Slipway works with no domain at all) the public address
# does not resolve to this box, so telling the user to open it is useless. Get
# both and, when they differ, print both clearly labelled with the LAN one
# first, since that is the one that works right now.
LAN_IP="$(ip route get 1.1.1.1 2>/dev/null | sed -n 's/.*[[:space:]]src[[:space:]]\([0-9.]*\).*/\1/p' | head -1)"
[ -n "$LAN_IP" ] || LAN_IP="$(hostname -I 2>/dev/null | awk '{ print $1 }')"
WAN_IP="$(curl -fsS --max-time 5 https://api.ipify.org 2>/dev/null || true)"

log ""
log "✓ Slipway is starting up on $SLIPWAY_VERSION (channel)."
log "  Finish setup in your browser, create your admin account and set your"
log "  panel domain (Slipway then handles SSL):"
log ""
if [ -n "$LAN_IP" ] && [ -n "$WAN_IP" ] && [ "$LAN_IP" != "$WAN_IP" ]; then
  log "    http://$LAN_IP"
  log "      this machine's address on your local network, use this one now"
  log ""
  log "    http://$WAN_IP"
  log "      your public address, reachable only once ports 80 and 443 are"
  log "      forwarded to this machine"
elif [ -n "$LAN_IP" ] || [ -n "$WAN_IP" ]; then
  log "    http://${LAN_IP:-$WAN_IP}"
else
  log "    http://<server-ip>"
fi
log ""
log "  Future updates: Settings → Updates inside the panel."
log "  Logs:  docker compose -f $INSTALL_DIR/docker-compose.yml logs -f"
log "  Stop:  docker compose -f $INSTALL_DIR/docker-compose.yml down"
