#!/usr/bin/env bash
# Puts an ice9 server on a machine that has nothing on it.
#
#   curl -fsSL https://ice9.app/install.sh | bash -s -- --host talk.example.org
#
# or, with the repository in hand:
#
#   ./install.sh --host talk.example.org
#
# The address is the one people will type into their app, and it is the one
# thing this script cannot work out for itself: a machine knows its own IP but
# not the name pointed at it. Everything else - the passwords, the server's own
# key, the schema, the configuration - it generates and never asks about.
#
# Nothing is compiled here. The build needs about 3.5 GB for one package alone
# and is killed on a small machine, so a finished image is pulled instead. That
# image carries everything this script needs: the service configuration, the
# database schema, the patches, and the handshake check run at the end.
#
# Safe to run twice. Passwords and the server's key are generated once and kept;
# the data volumes outlive the containers; schema patches are applied only if
# they have not been. At the end it prints a QR code, the address and the
# server's code, which together are how the app finds this server and knows it
# is this one.
set -euo pipefail

IMAGE="${ICE9_IMAGE:-ghcr.io/dsfox-idea/ice9-server:latest}"
DIR="${ICE9_DIR:-/srv/ice9}"
PORT="${ICE9_PORT:-10443}"
HOST="${ICE9_HOST:-}"

usage() {
  sed -n '2,24p' "$0" | sed 's/^# \{0,1\}//'
  exit "${1:-0}"
}

while [ $# -gt 0 ]; do
  case "$1" in
    --host)  HOST="$2"; shift 2 ;;
    --port)  PORT="$2"; shift 2 ;;
    --dir)   DIR="$2"; shift 2 ;;
    --image) IMAGE="$2"; shift 2 ;;
    -h|--help) usage 0 ;;
    *) echo "unknown option: $1" >&2; usage 1 ;;
  esac
done

say() { printf '> %s\n' "$*"; }
die() { printf '! %s\n' "$*" >&2; exit 1; }

# Two different questions, and answering them as one gets it wrong both ways.
# Whether Docker needs root is not whether this directory does: a machine where
# the user is in the docker group needs no sudo for containers and may still
# need it for /srv, and a workstation needs it for neither. Asked properly, and
# separately, below.
SUDO=""
DOCKER="docker"

elevate() {
  [ "$(id -u)" = "0" ] && return 1
  command -v sudo >/dev/null 2>&1
}

# ---------------------------------------------------------------- the address

if [ -z "$HOST" ]; then
  # A guess to offer, not an answer: whoever installs this usually has a name
  # pointed at the machine, and the name is what their people will type.
  guess="$(curl -fsS --max-time 5 https://api.ipify.org 2>/dev/null || true)"
  if [ -t 0 ]; then
    printf 'What address will people type into the app?%s ' \
      "${guess:+ [$guess]}"
    read -r HOST </dev/tty || true
    HOST="${HOST:-$guess}"
  else
    HOST="$guess"
  fi
fi
[ -n "$HOST" ] || die "no address given: pass --host <name or IP>"
case "$HOST" in
  *:*) die "give the name or IP in --host and the port in --port" ;;
esac

# Calls (#14) hand the phones this machine's addresses as the STUN and relay
# endpoint, and a phone wants an IP there, not a name: the public IPv4 as the
# world sees it, and the public IPv6 when the machine has one. The address on
# the interface may differ from the public one (a cloud with 1:1 NAT); then
# the relay is told both, or it relays into an address nobody can reach.
PUBLIC_IP4="$(curl -4 -fsS --max-time 5 https://api.ipify.org 2>/dev/null || true)"
PUBLIC_IP6="$(curl -6 -fsS --max-time 5 https://api64.ipify.org 2>/dev/null || true)"
case "$PUBLIC_IP6" in *:*) ;; *) PUBLIC_IP6="" ;; esac
LOCAL_IP4="$(hostname -I 2>/dev/null | awk '{print $1}' || true)"
case "$PUBLIC_IP4" in
  *.*.*.*) ;;
  *) PUBLIC_IP4="$LOCAL_IP4"; say "the public IPv4 could not be asked for; using $LOCAL_IP4 for calls" ;;
esac
EXTERNAL_IP=""
if [ -n "$LOCAL_IP4" ] && [ "$LOCAL_IP4" != "$PUBLIC_IP4" ]; then
  EXTERNAL_IP="$PUBLIC_IP4/$LOCAL_IP4"
fi

# --------------------------------------------------------------------- docker

if ! command -v docker >/dev/null 2>&1; then
  say "installing Docker"
  if elevate; then curl -fsSL https://get.docker.com | sudo sh >/dev/null
  else curl -fsSL https://get.docker.com | sh >/dev/null; fi
fi
if ! docker info >/dev/null 2>&1; then
  elevate || die "this user cannot reach Docker, and there is no sudo to borrow"
  DOCKER="sudo docker"
  $DOCKER info >/dev/null 2>&1 || die "Docker does not answer even as root"
fi
$DOCKER compose version >/dev/null 2>&1 \
  || die "this Docker has no compose plugin; install docker-compose-plugin"

say "pulling $IMAGE"
# Bounded: a registry that answers and then trickles - seen from a machine
# behind a slow link, 20 KB a second at "Pulling fs layer" for half an hour -
# is otherwise a pull that never ends, and the copy already here (below) is
# never reached. Fifteen minutes is generous for the image on a normal link.
if ! timeout 900 $DOCKER pull -q "$IMAGE" >/dev/null 2>&1; then
  # A machine that already holds the image is not an error: it may have been
  # loaded from a file on a network that cannot reach a registry.
  if ! $DOCKER image inspect "$IMAGE" >/dev/null 2>&1; then
    # The likeliest reason, and the one the registry reports in a way nobody
    # reads: the image is built for x86-64 and this is not an x86-64 machine.
    # "cannot pull" sends people to look at their network for nothing.
    arch="$($DOCKER version --format '{{.Server.Arch}}' 2>/dev/null || uname -m)"
    case "$arch" in
      amd64|x86_64) die "cannot pull $IMAGE, and there is no copy of it here" ;;
      *) die "$IMAGE is built for x86-64 and this machine is $arch" ;;
    esac
  fi
  say "the registry is unreachable; using the copy already here"
fi

# ------------------------------------------------------ what the image brings

if ! mkdir -p "$DIR/secrets" 2>/dev/null; then
  elevate || die "cannot create $DIR"
  SUDO="sudo"
  $SUDO mkdir -p "$DIR/secrets"
fi
[ -w "$DIR" ] || SUDO="sudo"
# And once the directory needs root, so does every docker command that touches
# it: `docker cp` writes the kit into it and `docker compose` reads the .env
# out of it. A person in the docker group on a machine where /srv is root's
# ran this a second time and got "mkdirat sql: permission denied".
[ -n "$SUDO" ] && DOCKER="sudo docker"

kit="$($DOCKER create "$IMAGE")"
trap '$DOCKER rm -f "$kit" >/dev/null 2>&1 || true' EXIT
for part in deploy/sql deploy/mysql-init deploy/sql-patches deploy/check-mtproto.py; do
  $SUDO rm -rf "$DIR/$(basename "$part")"
  $DOCKER cp "$kit:/app/$part" "$DIR/$(basename "$part")"
done

# ------------------------------------------------------------------- secrets

# Generated once. Regenerating them on a second run would leave the database
# holding data nobody has the password to any more.
if [ ! -f "$DIR/.env" ]; then
  say "generating passwords"
  $SUDO tee "$DIR/.env" >/dev/null <<ENV
# Generated by install.sh. Keep this file; the database is behind it.
MYSQL_ROOT_PASSWORD=$(openssl rand -hex 24)
MYSQL_PASSWORD=$(openssl rand -hex 24)
ENV
  $SUDO chmod 600 "$DIR/.env"
fi
# The secret the server mints short-lived relay credentials from, and the
# relay checks them against (#14). Made once, like the passwords; an .env from
# before calls existed gets one now.
if ! $SUDO grep -q '^CALLS_RELAY_SECRET=' "$DIR/.env"; then
  say "generating the relay secret for calls"
  printf 'CALLS_RELAY_SECRET=%s\n' "$(openssl rand -hex 32)" | $SUDO tee -a "$DIR/.env" >/dev/null
fi

# The server's own key (#244), the one the apps check against the server's
# code before their first handshake. Made once and never again: a new key is a
# new code, and every phone that has the old one would refuse the server. The
# handshake reads PKCS#1 ("RSA PRIVATE KEY"); OpenSSL 3 writes that only with
# -traditional, which older ones and LibreSSL do not know and write anyway.
make_server_key() {
  key="$DIR/secrets/server_rsa.key"
  $SUDO test -s "$key" && return 1
  say "making this server's own key"
  made="$(openssl genrsa -traditional 2048 2>/dev/null || openssl genrsa 2048 2>/dev/null || true)"
  case "$made" in
    "-----BEGIN RSA PRIVATE KEY-----"*) ;;
    *) die "openssl made no key the server can read (it wants -----BEGIN RSA PRIVATE KEY-----)" ;;
  esac
  (umask 077; printf '%s\n' "$made" | $SUDO tee "$key" >/dev/null)
  $SUDO chmod 600 "$key"
}
made_key=0
if make_server_key; then made_key=1; fi

# --------------------------------------------------------------- the compose

# Written here rather than fetched, so that this script plus the image is the
# whole delivery. It is the production compose with the mounts that came from a
# repository replaced by what was copied out of the image, and with the address
# handed to the server instead of edited into its config file.
$SUDO tee "$DIR/docker-compose.yml" >/dev/null <<COMPOSE
services:
  mysql:
    image: mysql:8.4
    container_name: ice9-mysql
    environment:
      MYSQL_ROOT_PASSWORD: \${MYSQL_ROOT_PASSWORD:?generated by install.sh}
      MYSQL_DATABASE: teamgram
      MYSQL_USER: teamgram
      MYSQL_PASSWORD: \${MYSQL_PASSWORD:?generated by install.sh}
    volumes:
      - mysql_data:/var/lib/mysql
      - ./sql:/upstream-sql:ro
      - ./sql-patches:/sql-patches:ro
      - ./mysql-init:/docker-entrypoint-initdb.d:ro
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_unicode_ci
      # 8.4 no longer loads mysql_native_password unless asked, and every
      # server made before it keeps its users on that plugin (#201).
      - --mysql-native-password=ON
      # Settings for a modest machine: about 160 MB instead of 450 MB.
      - --innodb-buffer-pool-size=64M
      - --performance-schema=OFF
      - --innodb-log-buffer-size=8M
      - --max-connections=100
      - --table-open-cache=200
    healthcheck:
      test: ["CMD-SHELL", "mysqladmin ping -h localhost -uroot -p\\"\$\$MYSQL_ROOT_PASSWORD\\" || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 30s
    restart: unless-stopped
    logging:
      driver: json-file
      options: {max-size: "50m", max-file: "5"}
    networks: [teamgram_net]

  redis:
    image: redis:7-alpine
    container_name: ice9-redis
    command: redis-server --appendonly yes
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5
    restart: unless-stopped
    logging:
      driver: json-file
      options: {max-size: "50m", max-file: "5"}
    networks: [teamgram_net]

  teamgram:
    image: $IMAGE
    container_name: ice9-teamgram
    volumes:
      - files_data:/app/data/files
      # Apple and Firebase keys, if this server ever gets any. Empty is fine:
      # without them everything works except notifications.
      - ./secrets:/app/secrets:ro
    environment:
      MYSQL_PASSWORD: \${MYSQL_PASSWORD:?generated by install.sh}
      # The address this server hands out in help.getConfig. Clients keep that
      # list and dial it from then on, so it must be this machine and not the
      # one the image was built against.
      ICE9_ADDRESS: "$HOST:$PORT"
      # Notifications go through the relay that holds the Apple and Google
      # keys (#167); the server registers itself with it. Another relay - the
      # same open code, run by somebody with keys of their own - goes here.
      PUSH_RELAY_URL: "\${PUSH_RELAY_URL:-https://push.ice9.app}"
      # Calls (#14): this machine's addresses, handed to the phones as the
      # STUN and relay endpoint, and the secret behind the relay's
      # credentials - the same value starts coturn below.
      CALLS_HOST: "$PUBLIC_IP4"
      CALLS_HOST_V6: "$PUBLIC_IP6"
      CALLS_RELAY_SECRET: \${CALLS_RELAY_SECRET:?generated by install.sh}
      # Eleven processes on a tight machine: 550 MB instead of 785 MB.
      GOGC: "25"
    ports:
      - "$PORT:10443"
      # The client alternates between the two while connecting, and a refused
      # port costs it a whole reconnect cycle.
      - "5222:5222"
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_healthy
    restart: unless-stopped
    logging:
      driver: json-file
      options: {max-size: "50m", max-file: "5"}
    networks: [teamgram_net]

  # STUN and TURN for calls (#14): a phone asks it "what address do you see
  # me from?" and learns its public address, which is how two phones behind
  # NAT find each other and carry the call phone to phone; when no direct
  # path works it relays - ciphertext only, the call key never leaves the
  # two phones. On the host's own network so it sees the phone's real
  # address. Ports 3478/udp and 50000-50999/udp have to be open on the
  # machine's firewall.
  coturn:
    image: coturn/coturn:4.18.0-alpine
    container_name: ice9-coturn
    network_mode: host
    command:
      - --use-auth-secret
      - --static-auth-secret=\${CALLS_RELAY_SECRET:?generated by install.sh}
      - --realm=$HOST
      - --min-port=50000
      - --max-port=50999
      - --user-quota=8
      - --total-quota=200
      - --max-bps=3000000
      - --stale-nonce=600
      - --no-tls
      - --no-tcp
      - --fingerprint
      - --log-file=stdout
      - --no-multicast-peers
      - --denied-peer-ip=10.0.0.0-10.255.255.255
      - --denied-peer-ip=172.16.0.0-172.31.255.255
      - --denied-peer-ip=192.168.0.0-192.168.255.255
      - --denied-peer-ip=127.0.0.0-127.255.255.255
      - --denied-peer-ip=169.254.0.0-169.254.255.255
      - --denied-peer-ip=100.64.0.0-100.127.255.255
      - --denied-peer-ip=::1
      - --denied-peer-ip=fe80::-febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff
      - --denied-peer-ip=fc00::-fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff${EXTERNAL_IP:+
      # The address on the interface is not the public one (1:1 NAT): the
      # relay has to know both, or it hands out an address nobody can reach.
      - --external-ip=$EXTERNAL_IP}
    restart: unless-stopped
    logging:
      driver: json-file
      options: {max-size: "20m", max-file: "3"}

volumes:
  mysql_data:
  redis_data:
  files_data:

networks:
  teamgram_net:
    driver: bridge
COMPOSE

# ------------------------------------------------------------------ bring up

compose() { $DOCKER compose -f "$DIR/docker-compose.yml" --project-directory "$DIR" "$@"; }
# Over TCP on purpose, never the socket. While a new database is being laid
# down MySQL runs a private server that listens on the socket and on no port at
# all; talking to it means racing the schema, and this script did exactly that -
# it re-applied a patch the init had already applied and recorded. Refusing the
# socket makes "the database answers" mean "the init is finished".
sql() { $DOCKER exec -i ice9-mysql mysql --protocol=TCP -h 127.0.0.1 -uroot -p"$root_password" teamgram; }
root_password="$(sed -n 's/^MYSQL_ROOT_PASSWORD=//p' <($SUDO cat "$DIR/.env"))"

# The database first and the server after it, deliberately, rather than all
# three at once. On a new machine MySQL lays the schema down through a server
# it starts privately and then stops; its healthcheck answers during that
# window, so "healthy" arrives before there is a single table. Started
# together, the server comes up against an empty database and this script
# applies patches to tables that do not exist yet - which is exactly what it
# did before this comment was written.
say "starting the database"
compose up -d mysql redis

say "waiting for the schema"
for attempt in $(seq 1 90); do
  tables="$(echo "select count(*) from information_schema.tables where table_schema='teamgram';" \
    | sql 2>/dev/null | tail -1 || true)"
  case "$tables" in
    ''|0|*[!0-9]*) ;;
    *) break ;;
  esac
  [ "$attempt" = 90 ] && die "the database never got its schema: docker logs ice9-mysql"
  sleep 2
done
say "  $tables tables"

# A second run against an older installation finds a database that exists and
# patches it has not seen - the same bookkeeping deploy/apply-patches.sh does,
# in the few lines that need no repository.
say "schema patches"
echo "CREATE TABLE IF NOT EXISTS schema_patches (
        name varchar(191) NOT NULL, applied_at int(11) NOT NULL,
        PRIMARY KEY (name)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;" | sql
applied="$(echo "select name from schema_patches;" | sql | tail -n +2 || true)"
for patch in "$DIR"/sql-patches/*.sql; do
  name="$(basename "$patch")"
  if echo "$applied" | grep -qx "$name"; then
    printf '  %-40s already applied\n' "$name"
    continue
  fi
  printf '  %-40s applying\n' "$name"
  $SUDO cat "$patch" | sql
  echo "insert into schema_patches(name, applied_at) values ('$name', unix_timestamp());" | sql
done

say "starting the server"
compose up -d
# A key made just now is read only at start, and an unchanged image and compose
# file leave the old server running.
if [ "$made_key" = 1 ]; then compose restart teamgram >/dev/null; fi

# ------------------------------------------------------------------- and does it work

# A running container is not a working server: with the wrong database address
# it stays up, answers the port and fails every handshake. So the check is the
# thing a phone does first, run from inside the image because the machine has
# nothing installed on it.
say "handshake"
for attempt in $(seq 1 20); do
  if $DOCKER exec ice9-teamgram python3 /app/deploy/check-mtproto.py 127.0.0.1 >/dev/null 2>&1; then
    break
  fi
  [ "$attempt" = 20 ] && die "the server is up but completes no handshake: docker logs ice9-teamgram"
  sleep 3
done

# The server's code: fetched the way an app fetches it, which also proves the
# server hands its key out (#244). And the same address and code as a link,
# drawn as a QR code for the phone's camera when there is a terminal to draw on.
say "this server's code"
server_code="$($DOCKER exec ice9-teamgram python3 /app/deploy/check-mtproto.py 127.0.0.1 --code 2>/dev/null \
  | sed -n 's/^server code: //p' | tr -d ' ' || true)"
printf '%s' "$server_code" | grep -Eqx '[0-9]{24}' \
  || die "the server answers but hands out no key of its own: docker logs ice9-teamgram 2>&1 | grep 'handshake key'"
shown="$(printf '%s' "$server_code" | sed 's/..../& /g; s/ $//')"
link="https://i.ice9.app/server#$HOST:$PORT/$server_code"
qr=""
if [ -t 1 ]; then
  qr="$($DOCKER exec ice9-teamgram qrencode -t ANSIUTF8 -m 2 "$link" 2>/dev/null || true)"
  if [ -z "$qr" ] && command -v qrencode >/dev/null 2>&1; then
    qr="$(qrencode -t ANSIUTF8 -m 2 "$link" 2>/dev/null || true)"
  fi
fi

# ------------------------------------------------------------ the first person in
# Signing up needs an invitation code, and a server that was installed a minute
# ago has nobody to mint one. Walked in a fresh machine: the address alone left
# its owner in front of a code field with no code. So the first one is minted
# here - for any one number, good for a day - and printed beside the address.
# Every code after it comes from a member's phone, the way invitations work.
say "the first sign-in code"
code="$($DOCKER exec ice9-teamgram /app/bin/invite --anyone --hours 24 --note "first sign-in" 2>/dev/null | tail -1 || true)"
case "$code" in
  [0-9][0-9][0-9][0-9][0-9][0-9]) ;;
  *) code="" ;;
esac
cat <<DONE

  The server is up and answers a handshake.

DONE
if [ -n "$qr" ]; then
cat <<DONE
  Point your phone's camera at this QR code - it opens ice9 with this
  server filled in:

$qr

  Or type these two on the app's first screen:

DONE
else
cat <<DONE
  On the app's first screen, type these two:

DONE
fi
cat <<DONE
      address      $HOST:$PORT
      server code  $shown

  The server code is how the app knows it has reached this server and not
  one posing as it; it stays the same however often you run this script.
  This link carries both - open it on a phone, or send it to the people
  you invite:

      $link

DONE
if [ -n "$code" ]; then
cat <<DONE
  Then your number, and this as the sign-in code (it works once, within a day):

      $code

  Everybody after you is invited from a member's phone, by SMS; send them
  the link above as well.

DONE
else
cat <<DONE
  Then your number and a sign-in code. This image could not mint one; on this
  machine run:  docker exec ice9-teamgram /app/bin/invite --anyone

DONE
fi
cat <<DONE
  Notifications go through push.ice9.app: your server hands it a sealed
  notification that holds none of your messages, and it wakes the phone.
  Set PUSH_RELAY_URL in $DIR/.env to use a relay of your own.

  Calls go phone to phone; this machine only helps them find each other
  ($PUBLIC_IP4${PUBLIC_IP6:+, $PUBLIC_IP6}) and relays when nothing else
  works. For that, open 3478/udp and 50000-50999/udp in the firewall, e.g.
      ufw allow 3478/udp && ufw allow 50000:50999/udp

  It lives in $DIR. Passwords are in $DIR/.env - the database is behind
  them - and this server's own key is $DIR/secrets/server_rsa.key: keep it
  with them. A new key is a new server code, and every phone that has this
  one would refuse the server.
  To see how it is doing:  docker logs -f ice9-teamgram
DONE
