#!/bin/sh
# PANEELI panel installer. Served by the service at /install.sh; run on the panel as root:
#   curl -fsSL https://paneeli.aimeat.io/install.sh | sudo sh -s -- --code ABCD2345 --thing http://192.168.4.1/
# Installs the device agent to /opt/paneeli, its config to /etc/paneeli/agent.json, and a systemd
# service that runs the agent as user `paneeli`; the agent starts the kiosk. Every file is checked
# against the SHA-256 the service lists before it is used. Tested on Debian 13 (arm64, QEMU).
#
# Kiosk modes (--kiosk):
#   cage      default: the cage compositor with cog's Wayland platform. Works on a Linux console
#             without a desktop; measured on QEMU's virtual GPU, where cog's own drm platform crashes.
#   drm       cog straight on the display (may work on real hardware with a GPU)
#   headless  cog with no display at all, for tests
#   none      no kiosk; open http://127.0.0.1:8090/ui/ in any browser on the panel
# --software-render sets WLR_RENDERER=pixman, for a panel or VM without a usable GPU.
# --theme light|dark opens the kiosk with ?theme=, so the page starts in that theme.
# --agent-from DIR takes the agent's files from DIR (files.txt, device-agent/, shared/) instead of the
#   service, checked against DIR/files.txt the same way: the ready SD image keeps its copy in
#   /opt/paneeli/image, so its first boot needs the network only to enrol.
# Packages already installed are not fetched again (the ready image has them all).
# --allow-screenshot lets the owner ask this panel for a screenshot (off unless given). It needs the
#   cage kiosk: grim reads the screen through cage's Wayland socket. The panel shows a notice each time.
#
# LAN mode (off unless --lan is given; docs/roadmap-3/lan.md): the agent also serves the page and the
# bridge on one LAN address of this box, for a web panel, a tablet or an HMI browser next to it. Only
# browsers that were paired get past the pairing page: the screen shows a code, and you confirm it here
# with `sudo paneeli-agent pair <code>`, or the owner confirms it in the PANEELI app.
#   --lan ADDRESS:PORT   this box's address on the machine network, e.g. 192.168.0.20:8091
#                        (port 80 gives http://192.168.0.20/); a box without a screen adds --kiosk none
#   --lan-host NAME      another name a panel may use for this box (box.local); may be given again
#   --lan-tls            https with a self-signed certificate. Many web panels refuse or warn on one, so
#                        plain http is the default, for a closed machine network.
#   --lan-local-pairing  screens are paired only at this console, never from the owner app
#
# The agent updates its own files from a manifest the service signs. The service's agent key is pinned
# here, once, in /etc/paneeli/agent-key.pem; an update that does not verify against it is never run.
set -eu

SERVICE="https://paneeli.aimeat.io"
CODE=""
THING=""
KIOSK="cage"
SOFTWARE=""
THEME=""
ALLOW_SHOT=""
AGENT_FROM=""
LAN=""
LAN_HOSTS=""
LAN_TLS="false"
LAN_REMOTE="true"

usage() {
  echo "usage: install.sh --code CODE --thing http://<device address>/ [--kiosk cage|drm|headless|none] [--theme light|dark] [--software-render] [--allow-screenshot] [--lan ADDRESS:PORT [--lan-host NAME] [--lan-tls] [--lan-local-pairing]] [--service URL] [--agent-from DIR]" >&2
  exit 2
}

while [ $# -gt 0 ]; do
  case "$1" in
    --code) CODE="$2"; shift 2 ;;
    --thing) THING="$2"; shift 2 ;;
    --kiosk) KIOSK="$2"; shift 2 ;;
    --theme) THEME="$2"; shift 2 ;;
    --software-render) SOFTWARE=1; shift ;;
    --agent-from) AGENT_FROM="$2"; shift 2 ;;
    --allow-screenshot) ALLOW_SHOT=1; shift ;;
    --lan) LAN="$2"; shift 2 ;;
    --lan-host)
      case "$2" in ""|*[!A-Za-z0-9.-]*) usage ;; esac
      LAN_HOSTS="${LAN_HOSTS:+$LAN_HOSTS, }\"$2\""; shift 2 ;;
    --lan-tls) LAN_TLS="true"; shift ;;
    --lan-local-pairing) LAN_REMOTE="false"; shift ;;
    --service) SERVICE="$2"; shift 2 ;;
    *) usage ;;
  esac
done

[ "$(id -u)" = "0" ] || { echo "Run as root (sudo)." >&2; exit 1; }
[ -n "$THING" ] || usage
case "$KIOSK" in cage|drm|headless|none) ;; *) usage ;; esac
case "$THEME" in ""|light|dark) ;; *) usage ;; esac
# --lan-host, --lan-tls and --lan-local-pairing only mean something with --lan.
[ -n "$LAN" ] || { [ -z "$LAN_HOSTS" ] && [ "$LAN_TLS" = "false" ] && [ "$LAN_REMOTE" = "true" ]; } || usage

say() { echo "paneeli: $*"; }

# 1. Packages: Node.js 18 or newer, and the kiosk (cog, plus cage and seatd for the default mode).
need_node=1
if command -v node >/dev/null 2>&1; then
  major=$(node -p 'process.versions.node.split(".")[0]')
  [ "$major" -ge 18 ] && need_node=0
fi
if command -v apt-get >/dev/null 2>&1; then
  pkgs="ca-certificates curl"
  [ "$need_node" = "1" ] && pkgs="$pkgs nodejs"
  # libgles2: without it every page crashes the WPE web process.
  [ "$KIOSK" != "none" ] && pkgs="$pkgs cog libgles2"
  [ "$KIOSK" = "cage" ] && pkgs="$pkgs cage seatd"
  [ "$KIOSK" = "cage" ] && [ -n "$ALLOW_SHOT" ] && pkgs="$pkgs grim"
  missing=""
  for p in $pkgs; do dpkg -s "$p" >/dev/null 2>&1 || missing="$missing $p"; done
  if [ -n "$missing" ]; then
    say "installing:$missing"
    DEBIAN_FRONTEND=noninteractive apt-get update -q >/dev/null
    DEBIAN_FRONTEND=noninteractive apt-get install -y -q --no-install-recommends $missing >/dev/null
  else
    say "packages already installed: $pkgs"
  fi
fi
command -v node >/dev/null 2>&1 || { echo "Node.js 18 or newer is required." >&2; exit 1; }
major=$(node -p 'process.versions.node.split(".")[0]')
[ "$major" -ge 18 ] || { echo "Node.js $major is too old; 18 or newer is required." >&2; exit 1; }
if [ "$KIOSK" != "none" ] && ! command -v cog >/dev/null 2>&1; then
  say "cog (the WPE kiosk) is not available here; the agent runs without a kiosk."
  KIOSK="none"
fi
[ "$KIOSK" = "cage" ] && systemctl enable --now seatd >/dev/null 2>&1 || true

# 2. The agent's files, checked against the list the service gives (or the list next to a local copy).
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
if [ -n "$AGENT_FROM" ]; then
  cp "$AGENT_FROM/files.txt" "$tmp/files.txt"
else
  curl -fsSL "$SERVICE/agent/files.txt" -o "$tmp/files.txt"
fi
while read -r sum path; do
  [ -n "$path" ] || continue
  case "$path" in device-agent/*|shared/*) ;; *) echo "unexpected path $path" >&2; exit 1 ;; esac
  case "$path" in *..*) echo "unexpected path $path" >&2; exit 1 ;; esac
  mkdir -p "$tmp/new/$(dirname "$path")"
  if [ -n "$AGENT_FROM" ]; then
    cp "$AGENT_FROM/$path" "$tmp/new/$path"
  else
    curl -fsSL "$SERVICE/agent/$path" -o "$tmp/new/$path"
  fi
  got=$(sha256sum "$tmp/new/$path" | cut -d' ' -f1)
  [ "$got" = "$sum" ] || { echo "checksum mismatch for $path" >&2; exit 1; }
done < "$tmp/files.txt"
mkdir -p /opt/paneeli
rm -rf /opt/paneeli/device-agent /opt/paneeli/shared
cp -r "$tmp/new/device-agent" "$tmp/new/shared" /opt/paneeli/
say "agent files installed in /opt/paneeli"

# USB updates: a stick is mounted read-only at /run/media/paneeli/<name>, where the agent looks for a
# signed *.paneeli release file.
if [ -d /etc/udev/rules.d ]; then
  node --input-type=module -e "import { USB_UDEV_RULE } from '/opt/paneeli/device-agent/lib/usb-update.mjs'; process.stdout.write(USB_UDEV_RULE);"     > /etc/udev/rules.d/99-paneeli-usb.rules
  chmod 644 /etc/udev/rules.d/99-paneeli-usb.rules
  udevadm control --reload-rules >/dev/null 2>&1 || true
fi
# LAN mode: the address is checked by the agent's own parser now, not when the agent first starts.
lan="null"
if [ -n "$LAN" ]; then
  node --input-type=module -e "import { lanConfig } from '/opt/paneeli/device-agent/lib/lan.mjs'; lanConfig(process.argv[1]);" "$LAN" \
    || { echo "--lan $LAN: give one address of this box and a port, e.g. 192.168.0.20:8091" >&2; exit 2; }
  lan="{ \"listen\": \"$LAN\", \"hosts\": [$LAN_HOSTS], \"tls\": $LAN_TLS, \"pairFromService\": $LAN_REMOTE }"
fi
# The console command for pairing; it runs as the agent's user, which owns the LAN state.
cat > /usr/local/bin/paneeli-agent <<EOF
#!/bin/sh
# PANEELI console commands: pair a screen on the LAN, list and remove paired browsers (paneeli-agent --help).
if [ "\$(id -u)" = "0" ]; then
  exec runuser -u paneeli -- $(command -v node) /opt/paneeli/device-agent/cli.mjs --config /etc/paneeli/agent.json "\$@"
fi
exec $(command -v node) /opt/paneeli/device-agent/cli.mjs --config /etc/paneeli/agent.json "\$@"
EOF
chmod 755 /usr/local/bin/paneeli-agent

# The key that signs the agent's own updates, pinned now. A panel installed again keeps its pin.
mkdir -p /etc/paneeli
if [ ! -f /etc/paneeli/agent-key.pem ]; then
  curl -fsSL "$SERVICE/agent/key.pem" -o "$tmp/agent-key.pem"
  grep -q "BEGIN PUBLIC KEY" "$tmp/agent-key.pem" || { echo "the service gave no agent key" >&2; exit 1; }
  cp "$tmp/agent-key.pem" /etc/paneeli/agent-key.pem
fi
chown root:root /etc/paneeli/agent-key.pem
chmod 644 /etc/paneeli/agent-key.pem

# 3. The user the agent and the kiosk run as, and the config. An existing enrolment is kept.
id paneeli >/dev/null 2>&1 || useradd --system --home /var/lib/paneeli --shell /usr/sbin/nologin paneeli
for g in video render input _seatd seat; do getent group "$g" >/dev/null 2>&1 && usermod -aG "$g" paneeli; done
mkdir -p /etc/paneeli /var/lib/paneeli
chown paneeli:paneeli /var/lib/paneeli
chmod 700 /var/lib/paneeli
URL="http://127.0.0.1:8090/ui/"
[ -n "$THEME" ] && URL="$URL?theme=$THEME"
case "$KIOSK" in
  cage) kiosk="{ \"command\": \"$(command -v cage)\", \"args\": [\"--\", \"$(command -v cog)\", \"-P\", \"wl\", \"$URL\"] }" ;;
  drm) kiosk="{ \"command\": \"$(command -v cog)\", \"args\": [\"-P\", \"drm\", \"$URL\"] }" ;;
  headless) kiosk="{ \"command\": \"$(command -v cog)\", \"args\": [\"-P\", \"headless\", \"$URL\"] }" ;;
  none) kiosk="null" ;;
esac
# A screenshot only when asked for at install, and only from cage's Wayland screen; any other kiosk has
# no command, and the panel answers a request with that reason.
shot="false"
capture="null"
if [ -n "$ALLOW_SHOT" ]; then
  shot="true"
  if [ "$KIOSK" = "cage" ] && command -v grim >/dev/null 2>&1; then
    capture="{ \"command\": \"$(command -v grim)\", \"args\": [\"-\"], \"env\": { \"WAYLAND_DISPLAY\": \"wayland-0\" } }"
  else
    say "--allow-screenshot needs the cage kiosk and grim; this panel will answer a request with the reason"
  fi
fi
if [ -f /var/lib/paneeli/state.json ]; then
  say "this panel is already enrolled; keeping its identity (remove /var/lib/paneeli/state.json to enrol again)"
else
  [ -n "$CODE" ] || { echo "--code is required for a panel that is not enrolled yet." >&2; exit 1; }
fi
cat > /etc/paneeli/agent.json <<EOF
{
  "serviceUrl": "$SERVICE",
  "stateDir": "/var/lib/paneeli",
  "thingUrl": "$THING",
  "enrolCode": "$CODE",
  "bridgePort": 8090,
  "kiosk": $kiosk,
  "allowScreenshot": $shot,
  "screenshot": $capture,
  "lan": $lan,
  "agentKeyFile": "/etc/paneeli/agent-key.pem"
}
EOF
chown root:paneeli /etc/paneeli/agent.json
chmod 640 /etc/paneeli/agent.json

# 4. The service.
NODE_BIN=$(command -v node)
RENDER=""
[ -n "$SOFTWARE" ] && RENDER="Environment=WLR_RENDERER=pixman"
# A LAN port under 1024 (80, 443) needs the one capability to bind it; the agent stays unprivileged.
BIND=""
[ -n "$LAN" ] && [ "${LAN##*:}" -lt 1024 ] && BIND="AmbientCapabilities=CAP_NET_BIND_SERVICE"
cat > /etc/systemd/system/paneeli-agent.service <<EOF
[Unit]
Description=PANEELI device agent (signed interface, bridge, kiosk)
After=network-online.target seatd.service
Wants=network-online.target

[Service]
Type=simple
User=paneeli
Group=paneeli
ExecStart=$NODE_BIN /opt/paneeli/device-agent/launch.mjs --config /etc/paneeli/agent.json
Restart=always
RestartSec=5
RuntimeDirectory=paneeli
Environment=XDG_RUNTIME_DIR=/run/paneeli
Environment=LIBSEAT_BACKEND=seatd
$RENDER
$BIND

[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable paneeli-agent >/dev/null 2>&1
systemctl restart paneeli-agent
sleep 3
if systemctl is-active --quiet paneeli-agent; then
  say "running. Follow it with: journalctl -u paneeli-agent -f"
  if [ -n "$LAN" ]; then
    scheme="http"; [ "$LAN_TLS" = "true" ] && scheme="https"
    say "LAN mode: open $scheme://$LAN/ on the panel's browser; it shows a code. Confirm it here with: sudo paneeli-agent pair <code>"
    [ "$LAN_TLS" = "true" ] && say "the certificate's fingerprint, to compare on the panel: sudo paneeli-agent lan"
  fi
else
  echo "paneeli-agent did not start; see journalctl -u paneeli-agent" >&2
  exit 1
fi
