#!/bin/sh
# File Transfers Pro agent installer for Linux and macOS.
#
#   curl -fsSL https://get.filetransferspro.com | sh -s -- --token ftpk_…
#
# Piping a script to a shell deserves scrutiny, so read it first — that is a
# reasonable thing to do and the documentation says so rather than pretending
# otherwise:
#
#   curl -fsSL https://get.filetransferspro.com | less
#
# POSIX sh, not bash: the target fleet includes minimal container images and
# Alpine, where /bin/bash is not present.

set -eu

VERSION="${FTPRO_VERSION:-latest}"
CONTROL_PLANE="${FTPRO_CONTROL_PLANE:-https://app.filetransferspro.com}"
# Where releases live.
#
# This host does not serve yet, and until it does no customer can install the
# agent with this script. That is the honest state and it is worth stating here
# rather than in a document, because this is the file somebody reads when the
# download fails.
#
# GitHub Releases was tried as a way around it and does not work: this
# repository is private, so an unauthenticated request for a release asset gets
# a 404 — which is every customer, every time. A private repository's releases
# are not a distribution channel.
#
# The release workflow publishes to GitHub Releases anyway, as the durable
# internal record of what was built, and syncs to this host when the credentials
# for it exist. Nothing else changes on the day it does: the layout is the same
# BASE/<version>/<asset> either way.
DOWNLOAD_BASE="${FTPRO_DOWNLOAD_BASE:-https://get.filetransferspro.com}"
TOKEN="${FTPRO_TOKEN:-}"
SKIP_ENROLL=0

BIN_DIR=/usr/local/bin
CONFIG_DIR=/etc/ftpro
STATE_DIR=/var/lib/ftpro
SERVICE_USER=ftpro

usage() {
  cat <<'EOF'
File Transfers Pro agent installer

  --token TOKEN          Enrollment token (or set FTPRO_TOKEN)
  --control-plane URL    Control plane base URL
  --version VERSION      Agent version to install (default: latest)
  (tags come from the enrollment token, not from here - see below)
  --skip-enroll          Install the binary and service without enrolling
  --help                 Show this message
EOF
}

log()  { printf '  %s\n' "$*"; }
ok()   { printf '\033[32m✓\033[0m %s\n' "$*"; }
warn() { printf '\033[33m!\033[0m %s\n' "$*" >&2; }
die()  { printf '\033[31m✗\033[0m %s\n' "$*" >&2; exit 1; }

while [ $# -gt 0 ]; do
  case "$1" in
    --token)          TOKEN="$2"; shift 2 ;;
    --control-plane)  CONTROL_PLANE="$2"; shift 2 ;;
    --version)        VERSION="$2"; shift 2 ;;
    --skip-enroll)    SKIP_ENROLL=1; shift ;;
    --help|-h)        usage; exit 0 ;;
    *)                die "unknown option: $1" ;;
  esac
done

# ─────────────────────────── Preflight ───────────────────────────

[ "$(id -u)" -eq 0 ] || die "this installer must run as root (try: curl … | sudo sh -s -- --token …)"

OS="$(uname -s)"
ARCH="$(uname -m)"

case "$OS" in
  Linux)  PLATFORM=linux ;;
  Darwin) PLATFORM=darwin ;;
  *)      die "unsupported operating system: $OS" ;;
esac

case "$ARCH" in
  x86_64|amd64)  GOARCH=amd64 ;;
  aarch64|arm64) GOARCH=arm64 ;;
  *)             die "unsupported architecture: $ARCH" ;;
esac

if command -v curl >/dev/null 2>&1; then
  FETCH="curl -fsSL"
elif command -v wget >/dev/null 2>&1; then
  FETCH="wget -qO-"
else
  die "neither curl nor wget is available"
fi

if command -v sha256sum >/dev/null 2>&1; then
  SHA_CMD="sha256sum"
elif command -v shasum >/dev/null 2>&1; then
  SHA_CMD="shasum -a 256"
else
  die "no sha256 tool available; cannot verify the download"
fi

log "Detected: $PLATFORM/$GOARCH"

# ─────────────────────────── Download ───────────────────────────

BINARY="ftpro-agent-${PLATFORM}-${GOARCH}"
URL="${DOWNLOAD_BASE}/${VERSION}/${BINARY}"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT INT TERM

log "Downloading $URL"
$FETCH "$URL" > "$TMP/$BINARY" || die "download failed"
[ -s "$TMP/$BINARY" ] || die "downloaded file is empty"

# Verify against the published checksums. The binary and the checksum file
# travel over the same TLS connection, so this catches corruption and a
# truncated download rather than a determined attacker: whoever controls the
# download host controls both halves.
#
# That is an acceptable trade here and only here. A manual install is somebody
# choosing to trust a host whose name they typed; an automatic update is a
# machine trusting whatever that host serves it, for ever, with nobody present.
# So the agent's own updater refuses without an Ed25519 signature over these
# checksums, verified against a key compiled into the binary — see
# internal/updater. No certificate has been procured, so no build carries a key
# and no host updates itself; the refusal is in the code rather than in a note
# somebody has to remember.
log "Verifying checksum"
$FETCH "${DOWNLOAD_BASE}/${VERSION}/SHA256SUMS" > "$TMP/SHA256SUMS" || die "could not fetch checksums"
EXPECTED="$(grep " ${BINARY}\$" "$TMP/SHA256SUMS" | awk '{print $1}')"
[ -n "$EXPECTED" ] || die "no checksum published for $BINARY"
ACTUAL="$($SHA_CMD "$TMP/$BINARY" | awk '{print $1}')"
[ "$EXPECTED" = "$ACTUAL" ] || die "checksum mismatch — refusing to install
    expected $EXPECTED
    actual   $ACTUAL"
ok "Checksum verified"

# ─────────────────────────── Install ───────────────────────────

# A dedicated account with no login shell. The agent runs as a service; if it is
# ever compromised, the blast radius should not include an interactive session.
if ! id "$SERVICE_USER" >/dev/null 2>&1; then
  if [ "$PLATFORM" = linux ]; then
    useradd --system --no-create-home --shell /usr/sbin/nologin "$SERVICE_USER" 2>/dev/null \
      || adduser --system --no-create-home --shell /sbin/nologin "$SERVICE_USER" 2>/dev/null \
      || warn "could not create the $SERVICE_USER user; the agent will run as root"
  fi
  # macOS service accounts need dscl and are a different shape; launchd runs the
  # agent as root there, which is documented rather than silently different.
fi
id "$SERVICE_USER" >/dev/null 2>&1 && ok "Service account '$SERVICE_USER' ready"

install -m 0755 "$TMP/$BINARY" "$BIN_DIR/ftpro-agent"
ok "Installed $BIN_DIR/ftpro-agent"

mkdir -p "$CONFIG_DIR" "$STATE_DIR"
chmod 0700 "$STATE_DIR"
id "$SERVICE_USER" >/dev/null 2>&1 && chown -R "$SERVICE_USER" "$STATE_DIR"

# The default config permits NOTHING. An agent installed but not configured
# should be able to do nothing at all, not everything — the operator opts in to
# each path deliberately.
CONFIG_FILE="$CONFIG_DIR/agent.conf"
LEGACY_FILE="$CONFIG_DIR/agent.yaml"

# Written by the agent, not by a here-string in this script.
#
# The configuration surface is defined once, in the agent's option table, and
# the file it generates carries every setting with its prose, its range and its
# real default. A template maintained here would be a second copy of that
# surface, and the copy that is not exercised by anything is the one that goes
# stale: a setting added to the agent would silently never appear in what this
# installer writes.
if [ -f "$CONFIG_FILE" ]; then
  log "Keeping existing $CONFIG_FILE"
elif [ -f "$LEGACY_FILE" ]; then
  # A host installed before the .conf format existed. Its settings are carried
  # across rather than reset, and the old file is left in place so a rollback
  # has something to roll back to.
  log "Converting $LEGACY_FILE to $CONFIG_FILE"
  "$BIN_DIR/ftpro-agent" config init --config "$CONFIG_FILE" --from "$LEGACY_FILE" >/dev/null
  ok "Converted; $LEGACY_FILE left in place"
else
  "$BIN_DIR/ftpro-agent" config init --config "$CONFIG_FILE" >/dev/null
  ok "Wrote $CONFIG_FILE (no paths permitted yet)"
fi
chmod 0600 "$CONFIG_FILE"

# ─────────────────────────── Enroll ───────────────────────────

# An already-enrolled host is left alone, and that is what makes this script
# safe to run twice.
#
# install.ps1 has claimed for a long time to be copying "the same guard
# install.sh has". There was no such guard here. Re-running this script on an
# enrolled machine went straight to `enroll`, which refuses without --force, so
# the run died - and a script that fails on its second run is one no
# configuration-management tool can own. That is the Ansible blocker in
# docs/99, and this is half of it; the other half was `config init
# --if-missing`.
#
# Checking for the identity here rather than letting `enroll` refuse is
# deliberate, and matches what the MSI does. `enroll` should keep refusing:
# somebody typing it wants to be told. An installer converging a host wants to
# do nothing and succeed.
STATE_FILE="$STATE_DIR/agent.state"
if [ "$SKIP_ENROLL" -eq 0 ] && [ -f "$STATE_FILE" ]; then
  log "Already enrolled ($STATE_FILE exists); leaving the enrolment alone."
  log "To enrol again as a new machine, remove that file first - the old entry"
  log "will need revoking in the console."
  SKIP_ENROLL=1
fi

if [ "$SKIP_ENROLL" -eq 0 ]; then
  [ -n "$TOKEN" ] || die "an enrollment token is required (--token, or FTPRO_TOKEN)"
  log "Enrolling with $CONTROL_PLANE"
  # Passed through the environment rather than argv so the token does not appear
  # in the process table for every local user to read.
  # Tags are not passed, and the option to pass them has been removed.
  #
  # It was accepted and parsed into $TAGS, which nothing then read: an operator
  # could write --tags env=prod, watch the install succeed, and end up with a
  # machine carrying no env tag and nothing anywhere saying why. The Windows
  # installer had the same option and at least failed loudly, because the agent
  # has no such flag to pass it to.
  #
  # Not reimplemented, because it should not exist. Tags belong to the
  # enrollment token: a host that can label itself env=prod can put itself
  # inside a job's selector, which is the one thing the targeting model must not
  # allow. Mint a token per group and the labels arrive with it.
  FTPRO_TOKEN="$TOKEN" "$BIN_DIR/ftpro-agent" enroll \
    --control-plane "$CONTROL_PLANE" \
    --config "$CONFIG_FILE" \
    || die "enrollment failed"
fi

# ────────────────────── Hand the files to the service user ──────────────────────
#
# After enrolment, not before. The chown of $STATE_DIR further up runs while the
# directory is still empty, and `enroll` then writes the state file as root with
# mode 0600 — so the service, which runs as $SERVICE_USER, could not read its own
# certificate. systemd's Restart=always turned that into a permanent crash loop
# on a host the installer had just reported as successfully installed.
#
# The configuration file has the same problem for a quieter reason: it is written 0600 and
# root-owned, so the agent starts, cannot read its configuration, warns that it
# will refuse every transfer, and sits there looking online.
if id "$SERVICE_USER" >/dev/null 2>&1; then
  chown -R "$SERVICE_USER" "$STATE_DIR"
  chown "$SERVICE_USER" "$CONFIG_FILE"
  ok "Gave $SERVICE_USER ownership of its state and configuration"
fi

# ─────────────────────────── Service ───────────────────────────

# Which service manager took, recorded rather than guessed again later. The
# closing message used to re-derive it from `uname`, which is not the same
# question: a Linux host without systemd - Alpine, or anything on OpenRC or
# runit - was told to run `systemctl restart ftpro-agent`, a command it does
# not have, immediately after being warned that no service manager was found.
SERVICE_KIND=none

if [ "$PLATFORM" = linux ] && command -v systemctl >/dev/null 2>&1; then
  SERVICE_KIND=systemd
  cat > /etc/systemd/system/ftpro-agent.service <<EOF
[Unit]
Description=File Transfers Pro agent
Documentation=https://filetransferspro.com/docs
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
ExecStart=$BIN_DIR/ftpro-agent run --config $CONFIG_FILE
Restart=always
RestartSec=5s

# A crash loop has to end. Without a start limit, an agent that cannot run -
# an unreadable configuration, a revoked certificate, a permission that was
# tightened - restarts every five seconds for ever, filling the journal and
# looking from the outside like a service that is up. Five attempts in five
# minutes and then systemd leaves it failed, which is a state a person notices
# and a monitoring system can alert on.
StartLimitIntervalSec=300
StartLimitBurst=5
User=$(id "$SERVICE_USER" >/dev/null 2>&1 && echo "$SERVICE_USER" || echo root)

# Hardening. The agent needs to read and write the paths its config permits and
# nothing else; these directives make that the kernel's opinion rather than the
# agent's own.
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=read-only
PrivateTmp=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
ReadWritePaths=$STATE_DIR

[Install]
WantedBy=multi-user.target
EOF
  systemctl daemon-reload

  # Enabled always; started only when there is an identity to start with.
  #
  # `enable --now` used to be unconditional, and on a host that had not enrolled
  # yet that started an agent which exits immediately with "this agent is not
  # enrolled" - straight into the Restart=always loop, on a machine the
  # installer had just reported as successfully installed. Both Windows
  # installers already refused to do this; only here did nobody notice, because
  # nobody had run it.
  systemctl enable ftpro-agent >/dev/null 2>&1
  if [ -f "$STATE_FILE" ]; then
    systemctl start ftpro-agent >/dev/null 2>&1
    ok "systemd service installed and started"
  else
    ok "systemd service installed, and enabled for the next boot"
    log "Not started: this host is not enrolled yet, and an unenrolled agent"
    log "exits immediately. It starts on its own once enrolment has happened."
  fi

elif [ "$PLATFORM" = darwin ]; then
  SERVICE_KIND=launchd
  cat > /Library/LaunchDaemons/com.filetransferspro.agent.plist <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key><string>com.filetransferspro.agent</string>
  <key>ProgramArguments</key>
  <array>
    <string>$BIN_DIR/ftpro-agent</string>
    <string>run</string>
    <string>--config</string>
    <string>$CONFIG_FILE</string>
  </array>
  <key>RunAtLoad</key><true/>
  <key>KeepAlive</key><true/>
  <key>StandardErrorPath</key><string>/var/log/ftpro-agent.log</string>
</dict>
</plist>
EOF
  launchctl load -w /Library/LaunchDaemons/com.filetransferspro.agent.plist 2>/dev/null || true
  ok "launchd daemon installed and started"

else
  warn "no supported service manager found; start the agent with: ftpro-agent run"
fi

printf '\n'
ok "Agent is installed."
printf '\n'
log "It cannot transfer anything yet. Two steps left:"
printf '\n'
log "  1. Add the folders this host may read or write. Open"
log "       $CONFIG_FILE"
log "     and add an AllowPath line for each one:"
log ""
log "       AllowPath=/srv/ftpro/outbox/**"
log ""
log "     The trailing /** means the folder and everything under it."
log "     The agent refuses every transfer until you add at least one."
log "     Every setting is documented in that file, beside the setting."
case "$SERVICE_KIND" in
  systemd)
    log "  2. Restart the service so it picks the change up, then check it:"
    log "       sudo systemctl restart ftpro-agent"
    ;;
  launchd)
    log "  2. Restart the service so it picks the change up, then check it:"
    log "       sudo launchctl kickstart -k system/com.filetransferspro.agent"
    ;;
  *)
    log "  2. Start the agent. This host has no service manager the installer"
    log "     recognises, so nothing is running it for you and nothing will"
    log "     start it after a reboot:"
    log "       ftpro-agent run"
    ;;
esac
log "       ftpro-agent config check"
printf '\n'
# Step 2 used to be absent, and its absence was silent in the worst way: the
# agent loads its configuration once at startup, so editing the file and running
# `config check` printed "Configuration OK" with the new paths listed while the
# running process still held the empty policy and refused everything. The
# product confirmed the fix had worked when it had not.
printf '\n'
