#!/usr/bin/env bash
# Infrality Agent install script
# Source: https://www.infrality.io/agent/install.sh
# Review before running: https://www.infrality.io/agent/install.sh
set -euo pipefail

# A mirror (on-premises, or offline behind a proxy) sets INFRALITY_RELEASES_URL;
# the manifest is still verified against the key below, wherever it came from.
RELEASES_URL="${INFRALITY_RELEASES_URL:-https://www.infrality.io/releases}"
MANIFEST_URL="${RELEASES_URL%/}/latest.json"
BINARY="infrality-agent"
INSTALL_DIR="/usr/local/bin"
SERVICE_DIR="/etc/systemd/system"

# ── Release signing key (SUP-01) ─────────────────────────────────────────────
# The manifest lists the SHA-256 of every artifact, and it is served from the
# same host as the artifacts themselves. That makes a checksum alone proof
# against a corrupted download and no proof at all against a replaced one:
# whoever can rewrite the binary rewrites the manifest in the same motion.
#
# So the manifest is signed in CI with a key that never touches the server, and
# verified here against the public half below. Only then are the checksums
# inside it worth anything. Verification uses openssl, which is already on
# essentially every machine that can run this script — no new dependency in the
# install path.
#
# This is the public half. It is meant to be public; read it, and compare it
# against the copy in the repository at scripts/install.sh if you want.
RELEASE_PUBKEY="-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwpIXrQUP1+nHtRj8veP0bgL2SktX
xi2XSlkIGGnF5e57Xv8e63RI6IamR7Ie9odn8PLcu1U9hSj5kO9Sja2mYQ==
-----END PUBLIC KEY-----"

# Detect OS
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
case "$OS" in
  linux|darwin) ;;
  *) echo "Unsupported OS: $OS" >&2; exit 1 ;;
esac

# Detect architecture
ARCH=$(uname -m)
case "$ARCH" in
  x86_64)        ARCH="amd64" ;;
  aarch64|arm64) ARCH="arm64" ;;
  *) echo "Unsupported architecture: $ARCH" >&2; exit 1 ;;
esac

TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT

# Fetch release manifest
echo "Fetching latest release info..."
curl -fsSL "$MANIFEST_URL" -o "${TMPDIR}/latest.json" || {
  echo "Failed to fetch release manifest from ${MANIFEST_URL}" >&2
  exit 1
}
[ -s "${TMPDIR}/latest.json" ] || {
  echo "Release manifest is empty." >&2
  exit 1
}

# ── Verify the manifest signature (SUP-01) ───────────────────────────────────
if ! command -v openssl >/dev/null 2>&1; then
  echo "openssl is required to verify the release signature, and is not installed." >&2
  echo "Install it and re-run — this script will not install an unverified binary." >&2
  exit 1
fi

case "$RELEASE_PUBKEY" in
  *REPLACE_WITH_RELEASE_PUBLIC_KEY*)
    echo "This installer has no release public key compiled into it." >&2
    echo "It cannot verify anything it downloads, so it will not install." >&2
    echo "See docs/RELEASE_SIGNING.md in the Agent repository." >&2
    exit 1
    ;;
esac

curl -fsSL "${MANIFEST_URL}.sig" -o "${TMPDIR}/latest.json.sig" || {
  echo "No signature published for the release manifest." >&2
  echo "Refusing to install: an unsigned manifest cannot be told from a tampered one." >&2
  exit 1
}

printf '%s\n' "$RELEASE_PUBKEY" > "${TMPDIR}/release.pub"
if openssl dgst -sha256 -verify "${TMPDIR}/release.pub" \
     -signature "${TMPDIR}/latest.json.sig" "${TMPDIR}/latest.json" >/dev/null 2>&1; then
  echo "Release manifest signature verified."
else
  echo "SIGNATURE VERIFICATION FAILED for the release manifest." >&2
  echo "Someone has changed it, or it was signed with a different key." >&2
  echo "Nothing has been installed." >&2
  exit 1
fi

MANIFEST=$(cat "${TMPDIR}/latest.json")

VERSION=$(echo "$MANIFEST" | grep '"version"' | head -1 | cut -d'"' -f4)
if [ -z "$VERSION" ]; then
  echo "Could not parse version from manifest." >&2
  exit 1
fi

echo "Installing Infrality Agent ${VERSION} (${OS}/${ARCH})..."

# Map OS to release naming convention (darwin → macos)
RELEASE_OS="$OS"
[ "$OS" = "darwin" ] && RELEASE_OS="macos"

ARCHIVE="${BINARY}_${VERSION}_${RELEASE_OS}_${ARCH}.tar.gz"
# Extract base URL from first asset's browser_download_url (strip filename)
BASE_URL=$(echo "$MANIFEST" | grep '"browser_download_url"' | head -1 | cut -d'"' -f4 | sed 's|/[^/]*$||')

if [ -z "$BASE_URL" ]; then
  echo "Could not determine download base URL from manifest." >&2
  exit 1
fi

# Download archive
curl -fsSL --progress-bar "${BASE_URL}/${ARCHIVE}" -o "${TMPDIR}/${ARCHIVE}"

# Verify SHA-256 checksum from manifest
EXPECTED=$(echo "$MANIFEST" | python3 -c "
import sys, json
data = json.load(sys.stdin)
cs = data.get('checksums', {})
print(cs.get('${ARCHIVE}', ''))
" 2>/dev/null || true)

if [ -z "$EXPECTED" ]; then
  echo "Checksum not found in manifest for ${ARCHIVE}" >&2
  exit 1
fi

if command -v sha256sum >/dev/null 2>&1; then
  ACTUAL=$(sha256sum "${TMPDIR}/${ARCHIVE}" | awk '{print $1}')
elif command -v shasum >/dev/null 2>&1; then
  ACTUAL=$(shasum -a 256 "${TMPDIR}/${ARCHIVE}" | awk '{print $1}')
else
  # AGT-01. This used to assign EXPECTED to ACTUAL, which made the comparison
  # below pass by construction: the check reported success while verifying
  # nothing. A minimal container or a trimmed BusyBox image reaches this branch
  # routinely, so it was not a theoretical path.
  echo "Neither sha256sum nor shasum is available — cannot verify the download." >&2
  echo "Install coreutils (or perl) and re-run." >&2
  exit 1
fi

if [ "$ACTUAL" != "$EXPECTED" ]; then
  echo "Checksum mismatch for ${ARCHIVE}!" >&2
  echo "  Expected: ${EXPECTED}" >&2
  echo "  Got:      ${ACTUAL}" >&2
  exit 1
fi
echo "  Checksum verified (SHA-256 OK)"

# Extract and install binary
tar -xzf "${TMPDIR}/${ARCHIVE}" -C "$TMPDIR"
install -m 755 "${TMPDIR}/${BINARY}" "${INSTALL_DIR}/${BINARY}"
echo "  Installed ${INSTALL_DIR}/${BINARY}"

if [ "$OS" = "linux" ]; then
  # AGT-02. The agent needs exactly one privilege: CAP_NET_RAW, to open the
  # raw ICMP socket it pings with. Everything else it does is ordinary.
  #
  # setcap puts that one capability on the binary, so it can be run directly by
  # an unprivileged user — no sudo, no service manager, nothing installed. That
  # is the portable path, and it is why the binary stays self-contained.
  if command -v setcap >/dev/null 2>&1; then
    setcap cap_net_raw+ep "${INSTALL_DIR}/${BINARY}"
    echo "  Capability set on the binary (cap_net_raw)"
  else
    echo "  Note: setcap not found — running the binary directly will need root."
    echo "        The systemd service below does not depend on it."
  fi

  # Install systemd service if available
  if command -v systemctl >/dev/null 2>&1; then
    # One service account, which is what running a daemon without root means on
    # Linux. No login, no home, no shell — it exists only to own the process.
    # This is the single change made to the host; nothing else is touched.
    if ! id infrality-agent >/dev/null 2>&1; then
      useradd --system --no-create-home --shell /usr/sbin/nologin infrality-agent \
        2>/dev/null || useradd --system --no-create-home --shell /sbin/nologin infrality-agent \
        2>/dev/null || true
    fi

    # Agents installed under the previous name (netscope-agent) keep working
    # across the upgrade, as they do through the .deb and .rpm. Two agents on
    # one network would both sweep and both report, so the old service is
    # stopped and disabled; if it was running, its replacement is started below
    # so the agent does not drop off the dashboard. The old config is copied
    # over only when this install has no API key yet, and the old directory is
    # left untouched.
    OLD_WAS_ACTIVE=no
    if systemctl list-unit-files netscope-agent.service 2>/dev/null | grep -q '^netscope-agent.service'; then
      if systemctl is-active --quiet netscope-agent.service; then
        OLD_WAS_ACTIVE=yes
      fi
      systemctl stop netscope-agent.service || true
      systemctl disable netscope-agent.service || true
      echo "  Disabled netscope-agent.service (replaced by infrality-agent.service)"
    fi
    LEGACY_CONFIG=/etc/netscope-agent/config.yaml
    if [ -f "$LEGACY_CONFIG" ]; then
      # A packaged default carries the placeholder nk_your_api_key_here.
      if ! grep -E '^[[:space:]]*api_key:' /etc/infrality-agent/config.yaml 2>/dev/null \
          | grep -Eqv 'nk_your_api_key_here|api_key:[[:space:]]*(""|'"''"')?[[:space:]]*(#.*)?$'; then
        mkdir -p /etc/infrality-agent
        cp "$LEGACY_CONFIG" /etc/infrality-agent/config.yaml
        echo "  Migrated $LEGACY_CONFIG to /etc/infrality-agent/config.yaml"
      fi
    fi

    # The config holds an API key, so it belongs to the account that reads it
    # and to nobody else. It was world-readable and owned by root.
    if [ -f /etc/infrality-agent/config.yaml ] && id infrality-agent >/dev/null 2>&1; then
      chown infrality-agent:infrality-agent /etc/infrality-agent/config.yaml
      chmod 600 /etc/infrality-agent/config.yaml
    fi
    # The directory as well: the agent writes the dashboard's settings back
    # through a temp file and a rename beside the config, which a root-owned
    # directory refuses. On a fresh install it does not exist yet, and
    # `infrality-agent config` hands it over when it creates it.
    if [ -d /etc/infrality-agent ] && id infrality-agent >/dev/null 2>&1; then
      chown infrality-agent:infrality-agent /etc/infrality-agent
      chmod 750 /etc/infrality-agent
    fi

    SERVICE_USER=""
    id infrality-agent >/dev/null 2>&1 && SERVICE_USER="infrality-agent"

    cat > "${SERVICE_DIR}/infrality-agent.service" << SYSTEMD
[Unit]
Description=Infrality Agent
Documentation=https://www.infrality.io/agent
After=network-online.target
Wants=network-online.target

[Service]
ExecStart=/usr/local/bin/infrality-agent daemon
Restart=on-failure
RestartSec=10
StandardOutput=journal
StandardError=journal

# ── Privilege ────────────────────────────────────────────────────────────
# The unit used to declare no User=, so it ran as root and the setcap above
# bought nothing — a network scanner holding full root on the machine it is
# monitoring. It now runs as an unprivileged account holding exactly one
# capability.
#
# AmbientCapabilities grants CAP_NET_RAW at exec rather than relying on the
# file capability, which NoNewPrivileges would otherwise ignore. Bounding the
# set means the process cannot acquire any other capability, ever.
${SERVICE_USER:+User=${SERVICE_USER}}
${SERVICE_USER:+Group=${SERVICE_USER}}
AmbientCapabilities=CAP_NET_RAW
CapabilityBoundingSet=CAP_NET_RAW
NoNewPrivileges=true

# ── Sandbox ──────────────────────────────────────────────────────────────
# Standard systemd confinement. None of it changes the host: these are
# per-process restrictions applied at start, not system configuration.
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictNamespaces=true
RestrictSUIDSGID=true
RestrictRealtime=true
LockPersonality=true
MemoryDenyWriteExecute=true
# ICMP and the HTTPS calls to the API. AF_NETLINK is how the agent enumerates
# local interfaces to work out which network it is on. AF_PACKET is the socket
# the active ARP sweep sends from; without it the sweep falls back to the
# kernel's neighbour table and a host that answers only ARP is never found.
RestrictAddressFamilies=AF_INET AF_INET6 AF_NETLINK AF_PACKET

# ProtectSystem=strict makes the filesystem read-only, and the agent writes its
# topology at the end of every scan. systemd creates and owns this directory,
# and WorkingDirectory puts the relative default (topology.json) inside it.
StateDirectory=infrality-agent
WorkingDirectory=/var/lib/infrality-agent
# The settings chosen on the dashboard are written back into config.yaml, and
# the pause is the one that has to survive a restart with no route to the API.
# Read-only here, that write failed, and a paused agent that restarted without
# reaching the API went back to sweeping.
ReadWritePaths=-/etc/infrality-agent

[Install]
WantedBy=multi-user.target
SYSTEMD

    if [ -n "$SERVICE_USER" ]; then
      echo "  Service will run as ${SERVICE_USER} with CAP_NET_RAW only."
    else
      echo "  Warning: could not create the infrality-agent account."
      echo "           The service will run as root — see docs/PRIVILEGES.md."
    fi
    systemctl daemon-reload
    echo "  Systemd service installed"
    if [ "$OLD_WAS_ACTIVE" = yes ]; then
      systemctl enable infrality-agent || true
      systemctl start infrality-agent || true
      echo "  Started infrality-agent (the old service was running)"
    fi
  fi
fi

echo ""
echo "Infrality Agent ${VERSION} installed successfully."
echo ""
echo "Next steps:"
echo "  1. Generate a config file:  sudo ${BINARY} config"
echo "  2. Edit the config:         sudo nano /etc/infrality-agent/config.yaml"
echo "     (set your API key and project ID from app.infrality.io)"
if [ "$OS" = "linux" ] && command -v systemctl >/dev/null 2>&1; then
  echo "  3. Start the service:       sudo systemctl enable --now infrality-agent"
else
  echo "  3. Run the agent:           sudo ${BINARY} run"
fi
echo ""
# API keys are minted per organisation in the dashboard, not on the marketing
# site. /register is where you create an account, which is a different thing and
# a dead end if you already have one.
echo "API keys:      https://app.infrality.io  ->  your organisation  ->  API keys"
echo "No account:    https://www.infrality.io/register"
