#!/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 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 are generated once and kept; the data volumes # outlive the containers; schema patches are applied only if they have not been. 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,20p' "$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 " case "$HOST" in *:*) die "give the name or IP in --host and the port in --port" ;; esac # --------------------------------------------------------------------- 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 </dev/null </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 # ------------------------------------------------------------------- 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 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 <