#!/bin/sh # Installs a Teicho Gate on this server and joins it to the team's Gate pool: # # curl -fsSL https://get.teicho.io | sudo sh -s -- --join-token tcj_... # # It checks the server, adds Teicho's signed apt or dnf repository, installs teicho-gate, writes the # join token, removes what an earlier Gate on this server left (only where no Gate has joined, or # where the Teicho service says the Gate that joined was removed and a new join token is given), # starts the teicho-node service and waits for the Gate's first report. Every failure is one # sentence and its fix. Running it again repairs what is missing and never restarts a Gate that # already joined and runs. set -eu # Stamped when the script is published (stamp.sh): the repository and its key's fingerprint. repository='https://pkg.teicho.dev/' key_fingerprint='C303AF787BFC8CC90C0E456BE4C417FE27ED01C3' api=https://api.teicho.io token= egress= timeout=120 settings=/etc/default/teicho-node state=/var/lib/teicho-gate keyring=/usr/share/keyrings/teicho-archive.asc rpm_key=/etc/pki/rpm-gpg/teicho-archive.asc say() { printf '%s\n' "$*"; } fail() { printf 'Error: %s\nFix: %s\n' "$1" "$2" >&2 exit 1 } usage() { cat <<'EOF' Usage: install.sh --join-token tcj_... [--api URL] [--egress-ipv4 ADDRESS] [--timeout SECONDS] --join-token the join token from the console (Gates, Add a Gate) --api the Teicho service address (default https://api.teicho.io) --egress-ipv4 the public IPv4 address this server sends from, when it has several --timeout seconds to wait for the Gate's first report (default 120) EOF } while [ $# -gt 0 ]; do case $1 in --join-token) token=${2:-}; shift ;; --join-token=*) token=${1#*=} ;; --api) api=${2:-}; shift ;; --api=*) api=${1#*=} ;; --egress-ipv4) egress=${2:-}; shift ;; --egress-ipv4=*) egress=${1#*=} ;; --timeout) timeout=${2:-}; shift ;; --timeout=*) timeout=${1#*=} ;; -h | --help) usage; exit 0 ;; *) usage >&2; fail "Unknown option: $1." "Use the command shown in the console." ;; esac shift done case $repository in @*) fail "This copy of the install script wasn't published by Teicho." "Use the command from the console: curl -fsSL https://get.teicho.io | sudo sh -s -- --join-token ..." ;; esac case $token in '' | tcj_*) ;; *) fail "The join token must start with tcj_." "Copy the whole command again from the console." ;; esac case $timeout in '' | *[!0-9]*) fail "--timeout must be a number of seconds." "Leave it out to wait 120 seconds." ;; esac case $api in http://* | https://*) api=${api%/} ;; *) fail "--api must be an https:// address." "Leave it out to use https://api.teicho.io." ;; esac # The server: root, a supported distribution and architecture, systemd. if [ "$(id -u)" != 0 ]; then fail "This needs root." "Run it with sudo: curl -fsSL https://get.teicho.io | sudo sh -s -- --join-token ..." fi supported="Debian 12 or later, Ubuntu 22.04 or later, RHEL, Rocky Linux, AlmaLinux, CentOS Stream or Oracle Linux 9 or later, or Fedora 40 or later" id='' version_id='' pretty='an unknown system' if [ -r /etc/os-release ]; then # shellcheck disable=SC1091 . /etc/os-release id=${ID:-} version_id=${VERSION_ID:-} pretty=${PRETTY_NAME:-$id} fi major=${version_id%%.*} case $major in '' | *[!0-9]*) major=0 ;; esac family= case $id in debian) [ "$major" -ge 12 ] && family=deb ;; ubuntu) [ "$major" -ge 22 ] && family=deb ;; rhel | rocky | almalinux | centos | ol) [ "$major" -ge 9 ] && family=rpm ;; fedora) [ "$major" -ge 40 ] && family=rpm ;; esac [ -n "$family" ] || fail "This server runs $pretty, which the Gate doesn't support." "Use a server with $supported." machine=$(uname -m) case $machine in x86_64 | amd64 | aarch64 | arm64) ;; *) fail "This server's architecture, $machine, isn't supported." "Use a server with an x86_64 (amd64) or aarch64 (arm64) processor." ;; esac [ -d /run/systemd/system ] || fail "This server doesn't run systemd, which the Gate's service needs." "Use a server that boots with systemd, as every supported distribution does." pkg_install() { if [ "$family" = deb ]; then DEBIAN_FRONTEND=noninteractive apt-get install -y -q --no-install-recommends "$@" >/tmp/teicho-install.log 2>&1 else dnf install -y -q "$@" >/tmp/teicho-install.log 2>&1 fi } need() { command -v "$1" >/dev/null 2>&1 && return say "Installing $2..." if [ "$family" = deb ]; then DEBIAN_FRONTEND=noninteractive apt-get update -q >/tmp/teicho-install.log 2>&1 || true fi pkg_install "$2" || fail "Can't install $2: $(tail -n 1 /tmp/teicho-install.log)" "Fix the package manager's error (/tmp/teicho-install.log), then run this again." } need curl curl if [ "$family" = deb ]; then need gpg gpg; else need gpg gnupg2; fi # Teicho's repository, its key checked against the fingerprint above. say "Adding Teicho's package repository..." host=${repository#*://} host=${host%%/*} work=$(mktemp -d) trap 'rm -rf "$work"' EXIT curl -fsS --max-time 30 -o "$work/key.asc" "${repository}keys/teicho-archive.asc" 2>"$work/curl.err" || fail "Can't reach $repository over HTTPS ($(tail -n 1 "$work/curl.err"))." "Allow outbound TCP 443 from this server to $host and ${api#*://}, through any firewall or proxy on the way." got=$(gpg --batch --quiet --show-keys --with-colons "$work/key.asc" 2>/dev/null | awk -F: '$1 == "fpr" { print $10; exit }') [ "$got" = "$key_fingerprint" ] || fail "The repository key from $host isn't Teicho's (fingerprint ${got:-none}, expected $key_fingerprint)." "Something between this server and $host changed it: don't install, and tell Teicho at hello@teicho.io." if [ "$family" = deb ]; then install -m 644 "$work/key.asc" "$keyring" sources=/etc/apt/sources.list.d/teicho.sources if [ ! -e "$sources" ] || ! grep -q "^Signed-By: $keyring" "$sources"; then printf 'Types: deb\nURIs: %s\nSuites: latest\nComponents: main\nSigned-By: %s\n' "$repository" "$keyring" >"$sources" fi DEBIAN_FRONTEND=noninteractive apt-get update -q >/tmp/teicho-install.log 2>&1 || fail "apt-get update failed: $(grep -E '^(E|W):' /tmp/teicho-install.log | tail -n 1)" "Fix that repository's error (/tmp/teicho-install.log), then run this again." else mkdir -p "$(dirname "$rpm_key")" install -m 644 "$work/key.asc" "$rpm_key" repo=/etc/yum.repos.d/teicho.repo if [ ! -e "$repo" ]; then printf '[teicho]\nname=Teicho\nbaseurl=%srpm/latest\nenabled=1\nrepo_gpgcheck=1\ngpgcheck=0\ngpgkey=file://%s\n' "$repository" "$rpm_key" >"$repo" fi fi say "Installing teicho-gate..." # strongSwan, which the Gate runs while its group has IKEv2 on, is only recommended by the package. if [ "$family" = deb ]; then pkg_install teicho-gate strongswan-charon strongswan-swanctl libstrongswan-standard-plugins else pkg_install --setopt=install_weak_deps=True teicho-gate fi || fail "Can't install teicho-gate: $(grep -Ev '^\s*$' /tmp/teicho-install.log | tail -n 1)" "Fix the package manager's error (/tmp/teicho-install.log), then run this again." # The settings the service reads. set_setting() { if grep -q "^$1=" "$settings" 2>/dev/null; then sed -i "s|^$1=.*|$1=$2|" "$settings" else printf '%s=%s\n' "$1" "$2" >>"$settings" fi } [ -e "$settings" ] || install -m 600 /dev/null "$settings" configured=$(sed -n 's/^TEICHO_API_URL=//p' "$settings" | tail -n 1) configured=${configured%/} joined=false [ -e "$state/identity.json" ] && joined=true # A join token on a server whose Gate joined: the service that Gate belongs to says whether it was # removed. Only then are its identity and what it made discarded, so the server joins as a new Gate; # a Gate it still knows is kept, and no answer changes nothing. forget_removed() { status=0 teicho-node gate --forget-removed --join-token "$token" --api "${configured:-$api}" >"$work/forget" 2>&1 || status=$? } if [ "$joined" = true ] && [ -n "$token" ]; then forget_removed if [ "$status" = 3 ]; then systemctl stop teicho-node.service forget_removed fi if [ "$status" != 0 ]; then cat "$work/forget" >&2 exit 1 fi cat "$work/forget" [ -e "$state/identity.json" ] || joined=false fi if [ "$joined" = true ] && [ -n "$configured" ] && [ "$configured" != "$api" ]; then fail "This server's Gate belongs to $configured, not $api." "Run the command again with --api $configured. To move this server to $api, remove its Gate in the console first, then run this again with a new join token." fi set_setting TEICHO_API_URL "$api" [ -z "$egress" ] || set_setting TEICHO_EGRESS_IPV4 "$egress" if [ "$joined" = false ]; then if [ -n "$token" ]; then set_setting TEICHO_JOIN_TOKEN "$token" elif ! grep -q '^TEICHO_JOIN_TOKEN=tcj_' "$settings"; then fail "No join token was given." "Run the command from the console again: it carries --join-token." fi fi health=$(sed -n 's/^TEICHO_HEALTH_LISTEN=//p' "$settings" | tail -n 1) health_port=${health##*:} health_port=${health_port:-9100} # A Gate that never joined here runs nothing: what an earlier Gate on this server left (its # WireGuard interface holds the port) is removed first. A joined Gate keeps what it uses. if [ "$joined" = false ] && ! systemctl is-active --quiet teicho-node.service; then teicho-node gate --cleanup >"$work/cleanup" 2>&1 || true if grep -q '^Removed ' "$work/cleanup"; then say "An earlier Gate left things on this server; removed:" sed -n 's/^Removed / Removed /p' "$work/cleanup" fi fi # The same checks the Gate makes: anything that would stop it, with the fix. say "Checking this server..." if ! TEICHO_HEALTH_LISTEN="127.0.0.1:$health_port" teicho-node gate --check --api "$api" >"$work/check" 2>&1; then grep -E '^(FAIL| +Fix:)' "$work/check" | sed -e 's/^FAIL /Error: /' -e 's/^ *Fix: /Fix: /' >&2 exit 1 fi sed -n 's/^note /Note: /p' "$work/check" # The service, then the Gate's first report. systemctl enable teicho-node.service >/dev/null 2>&1 if [ "$joined" = true ] && systemctl is-active --quiet teicho-node.service; then say "The Gate is already running." else systemctl reset-failed teicho-node.service >/dev/null 2>&1 || true systemctl restart teicho-node.service fi say "Waiting for the Gate's first report (up to $timeout seconds)..." waited=0 while :; do if curl -fsS --max-time 3 -o /dev/null "http://127.0.0.1:$health_port/healthz" 2>/dev/null; then say "The Gate has joined and is running. It shows in the console as healthy within a minute." exit 0 fi status=$(systemctl show -p ExecMainStatus --value teicho-node.service 2>/dev/null || true) if [ "$status" = 78 ] && ! systemctl is-active --quiet teicho-node.service; then reason=$(journalctl -u teicho-node.service -n 30 -o cat --no-pager 2>/dev/null | grep -i 'join token' | tail -n 1) case $reason in *"already used"*) fail "This join token was already used." "Create a new join token in the console (Gates, Add a Gate) and run this again with it." ;; *expired*) fail "This join token expired: join tokens are valid for 1 hour." "Create a new join token in the console (Gates, Add a Gate) and run this again with it." ;; *"isn't valid"*) fail "This join token isn't valid." "Copy the command again from the console, or create a new join token." ;; *) fail "The Gate couldn't join: ${reason:-see journalctl -u teicho-node}." "Create a new join token in the console and run this again with it." ;; esac fi if [ "$waited" -ge "$timeout" ]; then fail "The Gate didn't report within $timeout seconds." "Run sudo teicho-node gate --check, and see journalctl -u teicho-node for what it is waiting for; it keeps trying meanwhile." fi sleep 2 waited=$((waited + 2)) done