#!/bin/sh # MausBot on a server of your own: checks the server, then installs the MausBot server with HTTPS on your domain. # Running it again repairs or updates the install and never touches your data. # # curl -fsSL https://raw.githubusercontent.com/milind-soni/OpenMausBot/main/deploy/install.sh | sudo sh -s -- --domain maus.example.com # ... | sudo sh -s -- --check --domain maus.example.com only checks the server; changes nothing # ... | sudo sh -s -- --uninstall stops the server; keeps your data # ... | sudo sh -s -- --domain maus.example.com also connects the server to MausBot with a one-time code # # Nothing runs until the last line calls main, so a download that is cut off part-way runs nothing. It sends no # telemetry. The addresses it contacts are the ones the checks name: the image registries, Let's Encrypt, GitHub's # release list and this repository's files, and cloudflare.com/cdn-cgi/trace to learn this server's public address. set -eu settings() { SYSROOT="${OMB_INSTALL_SYSROOT:-}" INSTALL_DIR="${OMB_INSTALL_DIR:-/opt/openmausbot}" IMAGE_REPO=milind-soni/openmausbot RELEASES_URL=https://api.github.com/repos/milind-soni/OpenMausBot/releases/latest ADMIN_URL="${MAUSBOT_ADMIN_URL:-https://cloud.openmausbot.com}" SCRIPT_URL=https://raw.githubusercontent.com/milind-soni/OpenMausBot/main/deploy/install.sh # Where the files this installer places come from: this repository at the tag of the version it installs, and at # main when that tag does not have them. OMB_INSTALL_SOURCE names one copy to use instead (https://… or file:///…). REPOSITORY_FILES=https://raw.githubusercontent.com/milind-soni/OpenMausBot SOURCE_ASKED="${OMB_INSTALL_SOURCE:-}" SOURCE="" SOURCE_NAME="" # WARN-ONLY SANDBOX CHECKS. The self-check's results that do not stop an install when they fail: none of them lets a # bot reach what it must not, or stops bots working. Every other check, and any check this list has never heard # of, stops it. A result that carries its own "severity" says so itself and this list is not asked. SANDBOX_WARN_ONLY="keyring-denied hostname-private cpu-list-readable fly-api-hidden egress-public-allowed" SECCOMP=seccomp-omb-home.json APPARMOR_PROFILE=omb-home-narrow PROJECT=openmausbot MIN_DOCKER=24 MIN_CPUS=2 MIN_MEMORY_MB=3500 MIN_DISK_GB=20 MAX_CLOCK_SKEW_S=300 HEALTH_WAIT_S="${OMB_INSTALL_HEALTH_WAIT_S:-240}" HTTPS_WAIT_S="${OMB_INSTALL_HTTPS_WAIT_S:-180}" MODE=install DOMAIN="" EMAIL="" CODE="" INSTALL_DOCKER="${OMB_INSTALL_DOCKER:-}" SANDBOX=yes FAILED=0 DOCKER_STATE=unknown DOCKER_PROBLEM="" IMAGE="" PUBLIC_IP="" REGISTRY_TOKEN="" REGISTRY_STATUS=000 } say() { printf '%s\n' "$*"; } fail() { printf '\n%s\n' "$*" >&2 exit 1 } ok() { printf ' ok %s\n' "$*"; } note() { printf ' note %s\n' "$*"; } fix() { printf ' FIX %s\n' "$*" FAILED=$((FAILED + 1)) } command_line() { # what to run again, as the person ran it printf 'curl -fsSL %s | sudo sh -s -- %s' "$SCRIPT_URL" "$*" } usage() { say "Usage: $(command_line '--domain maus.example.com')" say " $(command_line '--check --domain maus.example.com') only checks this server; changes nothing" say " $(command_line '--uninstall') stops the server; keeps your data" say "" say " --domain NAME the name people will open, already pointing at this server" say " --email ADDRESS the first admin's email, so they can sign in with an emailed code" say " --install-docker install Docker with Docker's official script when it is missing, without asking" say " --dir PATH where the install lives (default /opt/openmausbot)" say " --no-sandbox do not prepare this server for the bots' sandbox (only for a server you alone sign in to)" say " the one-time code MausBot shows, to also connect this server to MausBot" } parse() { while [ $# -gt 0 ]; do case "$1" in --check) MODE=check ;; --uninstall) MODE=uninstall ;; --install-docker) INSTALL_DOCKER=1 ;; --no-sandbox) SANDBOX=no ;; --domain | --email | --dir) [ $# -ge 2 ] || fail "$1 needs a value. $(usage)" case "$1" in --domain) DOMAIN=$2 ;; --email) EMAIL=$2 ;; --dir) INSTALL_DIR=$2 ;; esac shift ;; --domain=*) DOMAIN=${1#--domain=} ;; --email=*) EMAIL=${1#--email=} ;; --dir=*) INSTALL_DIR=${1#--dir=} ;; -h | --help) usage exit 0 ;; -*) fail "Unknown option $1. $(usage)" ;; *) CODE=$1 ;; esac shift done DOMAIN=$(printf '%s' "$DOMAIN" | tr '[:upper:]' '[:lower:]') case "$DOMAIN" in "") ;; *[!a-z0-9.-]* | .* | *. | -* | *..*) fail "$DOMAIN is not a domain name. Give the name alone, like maus.example.com, without https:// or a path." ;; *.*) ;; *) fail "$DOMAIN is not a full domain name. Give a name like maus.example.com." ;; esac case "$EMAIL" in "") ;; *[!A-Za-z0-9@._+-]* | *@*@*) fail "That email address doesn't look right. Give one address, like you@example.com." ;; ?*@?*.?*) ;; *) fail "That email address doesn't look right. Give one address, like you@example.com." ;; esac case "$CODE" in *[!A-Za-z0-9-]*) fail "That code doesn't look right. Copy the install command from MausBot again." ;; esac case "$INSTALL_DIR" in /?*) ;; *) fail "--dir needs a full path, like /opt/openmausbot." ;; esac case "$INSTALL_DIR" in *[!A-Za-z0-9/._-]*) fail "--dir may hold only letters, digits, / . _ and -." ;; esac } is_root() { [ "$(id -u)" = 0 ]; } need_root() { is_root || fail "Please run this as root: put sudo before sh, like this: $(command_line "$@")" } tty_available() { [ -t 1 ] || return 1 (exec /dev/null } # From the install's folder, as a person and update.sh run it: Compose reads .env there, COMPOSE_FILE included. compose() { (cd "$INSTALL_DIR" && docker compose "$@") } # Compose's own progress lines (Pulling, Creating, Started) go to a file, and reach the terminal only when it fails. compose_quietly() { compose_log=$(mktemp) if compose "$@" >"$compose_log" 2>&1; then rm -f "$compose_log" return 0 fi cat "$compose_log" >&2 rm -f "$compose_log" return 1 } env_value() { # name: its value in the install's .env, or nothing [ -r "$INSTALL_DIR/.env" ] || return 0 sed -n "s/^$1=//p" "$INSTALL_DIR/.env" | tail -n 1 } reachable() { # url: any HTTP answer counts curl -sS -o /dev/null --max-time 15 "$1" >/dev/null 2>&1 } host_of() { printf '%s' "$1" | sed -e 's#^[a-z]*://##' -e 's#/.*##'; } # The checks. Each one that fails prints one sentence saying what to fix, and none of them changes anything. check_tools() { command -v curl >/dev/null 2>&1 || fix "curl isn't installed. Run: sudo apt-get install -y curl and then run this again." } check_os() { release="$SYSROOT/etc/os-release" if [ ! -r "$release" ]; then fix "This server's operating system can't be identified. MausBot's installer runs on Ubuntu 22.04 or 24.04: pick one of those." return fi field() { sed -n "s/^$1=//p" "$release" | head -n 1 | tr -d '"'; } os_id=$(field ID) os_version=$(field VERSION_ID) os_name=$(field PRETTY_NAME) [ -n "$os_name" ] || os_name="$os_id $os_version" case "$os_id $os_version" in "ubuntu 22.04" | "ubuntu 24.04") ok "operating system: $os_name" ;; *) if [ "${OMB_INSTALL_ALLOW_UNTESTED_OS:-}" = 1 ]; then note "This server runs $os_name, which the installer is not tested on; going on as asked." else fix "This server runs $os_name. MausBot's installer runs on Ubuntu 22.04 or 24.04: pick one of those, or run again with OMB_INSTALL_ALLOW_UNTESTED_OS=1 to try anyway." fi ;; esac } check_arch() { arch=$(uname -m) if [ "$arch" = x86_64 ]; then ok "processor: x86-64" else fix "This server's processor is $arch. The installer supports Intel and AMD (x86-64) servers for now: pick an x86-64 server." fi } check_cpus() { cpus=$(nproc 2>/dev/null || echo 0) case "$cpus" in '' | *[!0-9]*) cpus=0 ;; esac if [ "$cpus" -ge "$MIN_CPUS" ]; then ok "processor cores: $cpus" else fix "This server has $cpus processor core. MausBot needs at least $MIN_CPUS: pick a bigger server." fi } check_memory() { memory_mb=$(awk '/^MemTotal:/ { print int($2 / 1024) }' "$SYSROOT/proc/meminfo" 2>/dev/null || true) case "$memory_mb" in '' | *[!0-9]*) memory_mb=0 ;; esac memory_gb=$(awk -v m="$memory_mb" 'BEGIN { printf "%.1f", m / 1024 }') if [ "$memory_mb" -lt "$MIN_MEMORY_MB" ]; then fix "This server has $memory_gb GB of memory. MausBot needs at least 4 GB: pick a bigger server." return fi ok "memory: $memory_gb GB" if [ "$memory_mb" -lt 7500 ]; then note "With $memory_gb GB of memory, bots will be slow when several work at once. We suggest at least 8 GB."; fi } existing_parent() { path=$1 while [ ! -d "$path" ]; do path=$(dirname "$path"); done printf '%s' "$path" } free_gb() { df -Pk "$1" 2>/dev/null | awk 'NR == 2 { print int($4 / 1048576) }'; } check_disk() { places=$(existing_parent "$INSTALL_DIR") if [ "$DOCKER_STATE" = ready ]; then docker_root=$(docker info --format '{{.DockerRootDir}}' 2>/dev/null || true) if [ -n "$docker_root" ] && [ -d "$docker_root" ]; then places="$places $docker_root"; fi fi least="" for place in $places; do free=$(free_gb "$place") case "$free" in '' | *[!0-9]*) free=0 ;; esac if [ -z "$least" ] || [ "$free" -lt "$least" ]; then least=$free least_place=$place fi done if [ "$least" -ge "$MIN_DISK_GB" ]; then ok "free disk: $least GB" else fix "This server has only $least GB free at $least_place. MausBot needs at least $MIN_DISK_GB GB: free some space or add a disk." fi } # DOCKER_STATE: ready, or missing (the one thing the installer offers to install), or broken (the person fixes it). check_docker() { DOCKER_PROBLEM="" if ! command -v docker >/dev/null 2>&1; then DOCKER_STATE=missing DOCKER_PROBLEM="Docker isn't installed." return fi DOCKER_STATE=broken server=$(docker version --format '{{.Server.Version}}' 2>/dev/null || true) if [ -z "$server" ]; then if is_root; then fix "Docker is installed but not running. Start it with: sudo systemctl enable --now docker and then run this again." else fix "Docker did not answer. Run this with sudo, or start Docker with: sudo systemctl enable --now docker" fi return fi major=${server%%.*} case "$major" in '' | *[!0-9]*) major=0 ;; esac if [ "$major" -lt "$MIN_DOCKER" ]; then fix "Docker $server is too old; MausBot needs Docker $MIN_DOCKER or newer. Update it with: curl -fsSL https://get.docker.com | sudo sh and then run this again." return fi security=$(docker info --format '{{json .SecurityOptions}}' 2>/dev/null || true) case "$security" in *rootless*) fix "Docker runs in rootless mode here. MausBot needs the standard Docker service: run curl -fsSL https://get.docker.com | sudo sh and then run this again." return ;; esac if ! docker compose version >/dev/null 2>&1; then fix "Docker is here without its Compose plugin. Run: sudo apt-get install -y docker-compose-plugin and then run this again." return fi DOCKER_STATE=ready ok "Docker: $server, with Compose" } report_docker_missing() { if [ "$DOCKER_STATE" = missing ]; then fix "$DOCKER_PROBLEM Run this again with --install-docker to install it with Docker's official script, or install it yourself with: curl -fsSL https://get.docker.com | sudo sh" fi } listener_on() { # port: the listening line, or nothing ss -Hltnp "sport = :$1" 2>/dev/null | head -n 1 } check_ports() { if ! command -v ss >/dev/null 2>&1; then note "Ports 80 and 443 could not be checked because the ss tool is missing; if something else uses them, the install will say so." return fi busy="" own="" for port in 80 443; do line=$(listener_on "$port") [ -n "$line" ] || continue if [ "$DOCKER_STATE" = ready ] && [ -n "$(docker ps -q --filter "label=com.docker.compose.project=$PROJECT" --filter "publish=$port" 2>/dev/null)" ]; then own=yes continue fi owner=$(printf '%s' "$line" | sed -n 's/.*users:(("\([^"]*\)".*/\1/p') if [ "$owner" = docker-proxy ]; then fix "Port $port is already in use by another Docker container. Find it with: sudo docker ps and stop it, then run this again." else fix "Port $port is already in use${owner:+ by $owner}. Stop what uses it${owner:+ (for example: sudo systemctl disable --now $owner)}, then run this again." fi busy=yes done if [ -n "$busy" ]; then return; fi if [ -n "$own" ]; then ok "ports 80 and 443: held by this install"; else ok "ports 80 and 443: free"; fi } public_address() { curl -4 -fsS --max-time 10 https://cloudflare.com/cdn-cgi/trace 2>/dev/null | sed -n 's/^ip=//p' | head -n 1 } local_addresses() { ip -o -4 addr show scope global 2>/dev/null | awk '{ print $4 }' | cut -d/ -f1 } check_dns() { if [ -z "$DOMAIN" ]; then note "No --domain was given, so its DNS record was not checked." return fi if [ "${OMB_INSTALL_SKIP_DNS:-}" = 1 ]; then note "The DNS record of $DOMAIN was not checked, as asked." return fi PUBLIC_IP=$(public_address || true) resolved=$(getent ahostsv4 "$DOMAIN" 2>/dev/null | awk '{ print $1 }' | sort -u | tr '\n' ' ' || true) resolved=${resolved% } target=${PUBLIC_IP:-this server\'s public address} if [ -z "$resolved" ]; then fix "$DOMAIN does not resolve yet. Add an A record for it pointing at $target at your DNS provider, wait a few minutes, then run this again." return fi for address in $resolved; do for mine in $PUBLIC_IP $(local_addresses); do if [ "$address" = "$mine" ]; then ok "DNS: $DOMAIN points at this server ($address)" return fi done done fix "$DOMAIN points at $resolved, not at this server ($target). Change its A record to $target (on Cloudflare, set the record to DNS only), then run this again; or set OMB_INSTALL_SKIP_DNS=1 if a load balancer forwards ports 80 and 443 here." } check_outbound() { targets="https://ghcr.io/v2/ https://pkg-containers.githubusercontent.com/ https://registry-1.docker.io/v2/ https://auth.docker.io/ https://production.cloudflare.docker.com/ https://acme-v02.api.letsencrypt.org/directory" if [ "$DOCKER_STATE" = missing ]; then targets="$targets https://get.docker.com/ https://download.docker.com/"; fi if [ -n "$CODE" ]; then targets="$targets $ADMIN_URL/"; fi case "${SOURCE_ASKED:-$REPOSITORY_FILES}" in https://*) targets="$targets https://$(host_of "${SOURCE_ASKED:-$REPOSITORY_FILES}")/" ;; esac blocked="" for url in $targets; do reachable "$url" || blocked="$blocked $(host_of "$url")" done if [ -n "$blocked" ]; then if [ -n "${https_proxy:-}${HTTPS_PROXY:-}" ]; then fix "This server reaches the internet only through a web proxy, which the installer can't use. It needs direct outbound HTTPS (port 443) to:$blocked" else fix "This server can't reach$blocked over HTTPS (port 443). Allow outbound HTTPS to them in your firewall, then run this again." fi return fi ok "outbound: the image registries and Let's Encrypt are reachable" if ! reachable https://accounts.openmausbot.com/; then note "accounts.openmausbot.com is not reachable, so signing in with an emailed code needs a mail server of your own (OMB_SIGNIN_SMTP_URL). Pairing codes work without it." fi } check_userns() { max=$(cat "$SYSROOT/proc/sys/user/max_user_namespaces" 2>/dev/null || echo 1) clone=$(cat "$SYSROOT/proc/sys/kernel/unprivileged_userns_clone" 2>/dev/null || echo 1) if [ "$max" = 0 ] || [ "$clone" != 1 ]; then fix "This server switches off user namespaces, which the bots' sandbox needs. Run: printf 'user.max_user_namespaces=63000\\nkernel.unprivileged_userns_clone=1\\n' | sudo tee /etc/sysctl.d/90-mausbot.conf && sudo sysctl --system and then run this again." else ok "user namespaces: on" fi } # The bots' sandbox needs two profiles on the host, which the install puts there. This only looks. check_sandbox() { if [ "$SANDBOX" != yes ]; then note "This server will not be prepared for the bots' sandbox, as asked. Do this only on a server you alone sign in to." return fi enabled=$(cat "$SYSROOT/sys/module/apparmor/parameters/enabled" 2>/dev/null || true) if [ "$enabled" != Y ] || ! command -v apparmor_parser >/dev/null 2>&1; then fix "This server is missing AppArmor or its tools, which the bots' sandbox needs. Run: sudo apt-get install -y apparmor then restart the server and run this again; or add --no-sandbox to install without the sandbox, on a server you alone sign in to." return fi ok "bots' sandbox: AppArmor is on, so this server can be prepared for it" } check_clock() { now=$(date -u +%s) theirs="" header=$(curl -sSI --max-time 15 https://ghcr.io/v2/ 2>/dev/null | tr -d '\r' | sed -n 's/^[Dd]ate: //p' | head -n 1 || true) if [ -n "$header" ]; then theirs=$(date -u -d "$header" +%s 2>/dev/null || true); fi case "$theirs" in '' | *[!0-9]*) theirs="" ;; esac if [ -z "$theirs" ]; then if [ "$(date -u +%Y)" -lt 2025 ]; then fix "This server's clock says $(date -u +%Y), so HTTPS certificates will be refused. Run: sudo timedatectl set-ntp true and then run this again." else note "The clock could not be compared with the internet's; it says $(date -u '+%Y-%m-%d %H:%M') UTC." fi return fi skew=$((now - theirs)) [ "$skew" -ge 0 ] || skew=$((-skew)) if [ "$skew" -gt "$MAX_CLOCK_SKEW_S" ]; then fix "This server's clock is $((skew / 60)) minutes off, so HTTPS certificates will be refused. Run: sudo timedatectl set-ntp true and then run this again." return fi ok "clock: right" if command -v timedatectl >/dev/null 2>&1 && [ "$(timedatectl show -p NTPSynchronized --value 2>/dev/null || echo yes)" != yes ]; then note "This server's clock is not kept in time. Run: sudo timedatectl set-ntp true" fi } registry_token() { curl -fsS --max-time 15 "https://ghcr.io/token?scope=repository:$IMAGE_REPO:pull" 2>/dev/null | sed -n 's/.*"token":"\([^"]*\)".*/\1/p' | head -n 1 } # REGISTRY_STATUS: the registry's HTTP answer for a tag (200 it has it, 404 it does not), or 000 when it gave none. registry_ask() { # tag REGISTRY_STATUS=000 for _ in 1 2 3; do if [ -z "$REGISTRY_TOKEN" ]; then REGISTRY_TOKEN=$(registry_token || true); fi if [ -n "$REGISTRY_TOKEN" ]; then # The pull token goes to curl on its standard input, never on a command line. REGISTRY_STATUS=$(printf 'header = "Authorization: Bearer %s"\n' "$REGISTRY_TOKEN" | curl -K - -s -o /dev/null -w '%{http_code}' --max-time 15 -I \ -H "Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json" \ "https://ghcr.io/v2/$IMAGE_REPO/manifests/$1" 2>/dev/null || true) case "$REGISTRY_STATUS" in 200 | 404) return 0 ;; 401 | 403) REGISTRY_TOKEN="" ;; esac fi sleep 2 done } registry_get() { # path-under-the-repository media-types: the registry's answer, the pull token on curl's standard input printf 'header = "Authorization: Bearer %s"\n' "$REGISTRY_TOKEN" | curl -K - -fsSL --max-time 20 -H "Accept: $2" "https://ghcr.io/v2/$IMAGE_REPO/$1" 2>/dev/null } # Whether the image a tag or digest names runs the bots' sandbox (OMB_IMAGE=server in its settings), read from the # registry without downloading it: yes, no, or nothing when the registry did not say. registry_sandbox() { # tag or sha256:digest [ -n "$REGISTRY_TOKEN" ] || REGISTRY_TOKEN=$(registry_token || true) [ -n "$REGISTRY_TOKEN" ] || return 0 case "$(uname -m)" in aarch64 | arm64) platform=arm64 ;; *) platform=amd64 ;; esac answer=$(registry_get "manifests/$1" "application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json" | tr -d ' \n\r\t') || return 0 case "$answer" in *'"manifests":['*) # An index: one manifest per platform. Each entry ends with its platform, so the entries split at "},{". digest=$(printf '%s' "$answer" | sed 's/},{/}\ {/g' | grep "\"architecture\":\"$platform\"" | grep '"os":"linux"' | sed -n 's/.*"digest":"\(sha256:[0-9a-f]\{64\}\)".*/\1/p' | head -n 1) [ -n "$digest" ] || return 0 answer=$(registry_get "manifests/$digest" "application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json" | tr -d ' \n\r\t') || return 0 ;; esac config=$(printf '%s' "$answer" | sed -n 's/.*"config":{[^}]*"digest":"\(sha256:[0-9a-f]\{64\}\)".*/\1/p') [ -n "$config" ] || return 0 case "$(registry_get "blobs/$config" "application/vnd.oci.image.config.v1+json, application/vnd.docker.container.image.v1+json")" in '') ;; *'"OMB_IMAGE=server"'*) echo yes ;; *) echo no ;; esac } # Says, before anything is downloaded, when the version chosen would run without the bots' sandbox. note_sandbox() { # tag or sha256:digest [ "$SANDBOX" = yes ] || return 0 if [ "$(registry_sandbox "$1" || true)" = no ]; then note "This version has no bots' sandbox, so bots would run beside the server, able to read its settings, keys and each other's files. That is only for a server you alone sign in to; run this again once a version with the sandbox is out." fi } newest_release() { curl -fsS --max-time 15 "$RELEASES_URL" 2>/dev/null | sed -n 's/.*"tag_name": *"\(v[0-9][0-9.]*\)".*/\1/p' | head -n 1 } # The stable channel when it is published, else the newest release. Never `latest`, which follows every commit. check_version() { chosen="${OMB_INSTALL_IMAGE:-}" if [ -n "$chosen" ]; then case "$chosen" in *[!A-Za-z0-9/:._@-]*) fix "OMB_INSTALL_IMAGE is not an image name." return ;; esac case "${chosen##*/}" in *:latest) fix "OMB_INSTALL_IMAGE names the latest tag, which follows every commit. Name a release, like ghcr.io/$IMAGE_REPO:v0.1.151." ;; *:?*) IMAGE=$chosen ok "version: $IMAGE, as asked" ;; *) fix "OMB_INSTALL_IMAGE needs a release tag, like ghcr.io/$IMAGE_REPO:v0.1.151." ;; esac return fi pinned=$(env_value OMB_IMAGE) case "$pinned" in *@sha256:*) IMAGE=$pinned ok "version: the one update.sh pinned here; it stays until update.sh changes it" return ;; esac registry_ask stable if [ "$REGISTRY_STATUS" = 200 ]; then IMAGE="ghcr.io/$IMAGE_REPO:stable" ok "version: the stable channel" note_sandbox stable return fi if [ "$REGISTRY_STATUS" != 404 ]; then fix "The image registry ghcr.io did not say which versions exist (it answered $REGISTRY_STATUS). Check this server's outbound HTTPS, then run this again." return fi release=$(newest_release || true) if [ -z "$release" ]; then fix "The newest MausBot release could not be read from api.github.com. Check this server's outbound HTTPS, then run this again in a few minutes." return fi registry_ask "$release" if [ "$REGISTRY_STATUS" = 404 ]; then fix "MausBot $release has no server image yet. Run this again in a few minutes." return fi if [ "$REGISTRY_STATUS" != 200 ]; then fix "The image registry ghcr.io did not say whether it has MausBot $release (it answered $REGISTRY_STATUS). Run this again in a few minutes." return fi IMAGE="ghcr.io/$IMAGE_REPO:$release" ok "version: $release, the newest release" note_sandbox "$release" } preflight() { say "Checking this server..." check_tools check_os check_arch check_cpus check_memory check_docker check_disk check_ports check_userns check_sandbox if ! command -v curl >/dev/null 2>&1; then note "The domain, outbound reach, the clock and the version were not checked, because they need curl." return fi check_dns check_outbound check_clock check_version } install_docker() { say "Installing Docker with Docker's official script (get.docker.com)..." tmp_docker=$(mktemp) curl -fsSL https://get.docker.com -o "$tmp_docker" || fail "Docker's install script could not be downloaded. Check that this server can reach https://get.docker.com, then run this again." sh "$tmp_docker" || fail "Docker's install script failed (its output is above). Fix that, then run this again." rm -f "$tmp_docker" if command -v systemctl >/dev/null 2>&1; then systemctl enable --now docker >/dev/null 2>&1 || true; fi } # Docker is the one thing the installer installs for you, and only when you say so: the flag, or yes at the prompt. offer_docker() { if [ "$INSTALL_DOCKER" != 1 ]; then answer=n if tty_available; then printf "%s Install the current Docker now with Docker's official script (get.docker.com)? [Y/n] " "$DOCKER_PROBLEM" >/dev/tty read -r answer "$tmp_file" chmod "${2:-644}" "$tmp_file" mv "$tmp_file" "$1" } write_compose() { write_file "$INSTALL_DIR/compose.yaml" <<'COMPOSE' # Written by MausBot's installer (deploy/install.sh), and written again each time it runs: put your own settings in # .env beside this file, which the installer keeps. The server only listens on loopback inside its container, and on # two Unix sockets in the `edge` volume, which Caddy, in its own container, reaches it through. A request on a socket is # always a remote one to the server. Caddy does not share the server's network: a server that restarted on its own came # back in a new one, and left Caddy refusing HTTPS in the old one. name: openmausbot services: omb: image: ${OMB_IMAGE:?run the installer again} restart: unless-stopped init: true shm_size: 512m volumes: - ./data:/data - edge:/run/openmausbot environment: OMB_PORT: "8799" OMB_WEBHOOK_PORT: "8800" OMB_TUNNEL_SOCKET: /run/openmausbot/http.sock OMB_WEBHOOK_SOCKET: /run/openmausbot/hooks.sock OMB_WEBHOOK_PUBLIC_URL: https://${DOMAIN} OMB_PUBLIC_URL: https://${DOMAIN} OMB_SERVER_NAME: ${OMB_SERVER_NAME:-${DOMAIN}} # Passed only when .env sets them: an empty one would still be set, and Settings' own lists are used when not. OMB_SIGNIN_EMAILS: OMB_SIGNIN_MEMBER_EMAILS: OMB_SIGNIN_SMTP_URL: OMB_SIGNIN_SMTP_FROM: # "1": phones pair by QR through MausBot Relay. Sign in once: docker compose exec omb openmausbot login --relay OMB_PHONE_RELAY: healthcheck: test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8799/api/health | grep -q openmausbot"] interval: 30s timeout: 5s retries: 3 caddy: image: caddy:2 restart: unless-stopped ports: - "80:80" - "443:443" # Started with the server, not after it is healthy: Caddy reaches it over the socket # whenever it answers, so `up` never waits on the server's health. depends_on: omb: condition: service_started environment: DOMAIN: ${DOMAIN} volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - edge:/run/openmausbot - ./caddy/data:/data - ./caddy/config:/config volumes: # The two sockets: in memory, made afresh by the server at each start. Not under /data, which every bot's sandbox # sees; the sandbox has its own /run. edge: driver_opts: type: tmpfs device: tmpfs o: mode=1777,size=1m COMPOSE } write_caddyfile() { write_file "$INSTALL_DIR/Caddyfile" <<'CADDY' {$DOMAIN} { encode zstd gzip # Webhook ingress is designed to be reachable by third parties (it has its # own per-hook secrets), so it sits outside any login. The path is passed # through unchanged: the receiver routes on /hooks/. handle /hooks/* { reverse_proxy unix//run/openmausbot/hooks.sock } handle { # Optional second wall in front of pairing (a shared password): # basic_auth { # {$BASIC_AUTH_USER} {$BASIC_AUTH_HASH} # } # and set both variables in .env (hash with `caddy hash-password`, # every `$` doubled for Compose). reverse_proxy unix//run/openmausbot/http.sock { # SSE: stream events as they arrive flush_interval -1 # Compression is `encode`'s job above; the server would gzip its # JSON on its socket otherwise, as for the phone tunnel. header_up -Accept-Encoding } } } CADDY } set_env() { # name value: that one line of .env, leaving every other line as the person wrote it env_file="$INSTALL_DIR/.env" { if [ -f "$env_file" ]; then grep -v "^$1=" "$env_file" || true; fi printf '%s=%s\n' "$1" "$2" } | write_file "$env_file" 600 } unset_env() { # name env_file="$INSTALL_DIR/.env" if grep -q "^$1=" "$env_file" 2>/dev/null; then { grep -v "^$1=" "$env_file" || true; } | write_file "$env_file" 600 fi } write_settings() { if [ ! -f "$INSTALL_DIR/.env" ]; then write_file "$INSTALL_DIR/.env" 600 <<'ENV' # MausBot's settings on this server. The installer sets DOMAIN, OMB_IMAGE and COMPOSE_FILE and keeps every other line. # Who may sign in with an emailed code: OMB_SIGNIN_EMAILS (admins) and OMB_SIGNIN_MEMBER_EMAILS, comma-separated. ENV fi set_env DOMAIN "$DOMAIN" set_env OMB_IMAGE "$IMAGE" if [ -n "$EMAIL" ]; then current=$(env_value OMB_SIGNIN_EMAILS) case ",$(printf '%s' "$current" | tr -d ' ')," in *",$EMAIL,"*) ;; *) set_env OMB_SIGNIN_EMAILS "${current:+$current,}$EMAIL" ;; esac fi } fetch() { # path-in-the-repository destination [mode]: 1 when SOURCE does not have it tmp_fetch=$(mktemp "$2.XXXXXX") if curl -fsSL --max-time 60 "$SOURCE/$1" -o "$tmp_fetch" 2>/dev/null && [ -s "$tmp_fetch" ]; then chmod "${3:-644}" "$tmp_fetch" mv "$tmp_fetch" "$2" return 0 fi rm -f "$tmp_fetch" return 1 } # The version the downloaded image says it is, as the repository tags it (v1.2.3), or nothing. image_version() { docker image inspect -f '{{index .Config.Labels "org.opencontainers.image.version"}}' "$IMAGE" 2>/dev/null | sed -n 's/^v\{0,1\}\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)$/v\1/p' | head -n 1 } # Tries each place the repository's files may come from, in order: the copy that was asked # for and nothing else; or the tag of the installed version, then main. Leaves SOURCE_NAME saying which one had them. from_repository() { # what-to-fetch if [ -n "$SOURCE_ASKED" ]; then SOURCE=$SOURCE_ASKED SOURCE_NAME=$SOURCE_ASKED "$1" return fi version=$(image_version || true) if [ -n "$version" ]; then SOURCE="$REPOSITORY_FILES/$version" SOURCE_NAME="MausBot $version's own" if "$1"; then return 0; fi fi SOURCE="$REPOSITORY_FILES/main" SOURCE_NAME="the newest in the repository${version:+ ($version has none)}" "$1" } fetch_update() { fetch deploy/update.sh "$INSTALL_DIR/update.sh" 755 } # update.sh (deploy/update.sh) updates with a backup and a way back; it is this repository's file, placed as it is. place_update() { if from_repository fetch_update; then note "update.sh is $SOURCE_NAME."; fi } # The sandbox's host preparation and Compose settings are this repository's own (deploy/box, connector/profiles), # fetched and used as they are, all four from the same place. fetch_sandbox() { mkdir -p "$INSTALL_DIR/box/profiles" fetch deploy/box/prepare-host.sh "$INSTALL_DIR/box/prepare-host.sh" 755 && fetch deploy/box/compose.box.yaml "$INSTALL_DIR/compose.box.yaml" && fetch "connector/profiles/$SECCOMP" "$INSTALL_DIR/box/profiles/$SECCOMP" && fetch "connector/profiles/$APPARMOR_PROFILE.apparmor" "$INSTALL_DIR/box/profiles/$APPARMOR_PROFILE.apparmor" } # An image that runs the hardened profile says so in its settings (OMB_IMAGE=server). image_has_sandbox() { case "$(docker image inspect -f '{{json .Config.Env}}' "$IMAGE" 2>/dev/null || true)" in *'"OMB_IMAGE=server"'*) return 0 ;; esac return 1 } # Sets SANDBOX to on when the server will start with the sandbox's settings, and says why when it will not. prepare_sandbox() { if [ "$SANDBOX" != yes ]; then unset_env COMPOSE_FILE return fi if ! image_has_sandbox; then unset_env COMPOSE_FILE SANDBOX=absent note "This MausBot version has no bots' sandbox yet. Run this command again after updating to a version that has it." return fi if ! from_repository fetch_sandbox; then fail "The files that prepare this server for the bots' sandbox could not be downloaded from ${SOURCE_ASKED:-$REPOSITORY_FILES}. Check this server's outbound HTTPS and run this again; or add --no-sandbox to install without the sandbox, on a server you alone sign in to." fi say "Preparing this server for the bots' sandbox..." note "The sandbox's files are $SOURCE_NAME." MAUSBOT_PROFILES_DIR="$INSTALL_DIR/box/profiles" sh "$INSTALL_DIR/box/prepare-host.sh" >/dev/null || fail "This server could not be prepared for the bots' sandbox (the reason is above). Fix that and run this again; or add --no-sandbox to install without the sandbox, on a server you alone sign in to." set_env COMPOSE_FILE compose.yaml:compose.box.yaml SANDBOX=on } sandbox_field() { # result name: that field of one result of the self-check printf '%s' "$1" | sed -n "s/.*\"$2\":\"\([^\"]*\)\".*/\1/p" } # The server's own self-check of the sandbox, as a bot meets it. A failed check stops the install unless it is one of # SANDBOX_WARN_ONLY (or says "severity":"warn" itself); a self-check that gives no answer stops it too. check_sandbox_runs() { [ "$SANDBOX" = on ] || return 0 say "Checking that the bots' sandbox holds..." see="See it with: cd $INSTALL_DIR && sudo docker compose exec omb node dist-server/bot-box/box-check.js --public=none and send us what it prints." report=$(compose exec -T omb node dist-server/bot-box/box-check.js --public=none 2>/dev/null | tr -d '\n' || true) case "$report" in *'"box":"bwrap"'*'"results":[{'*) ;; *) fail "MausBot is running, but its bots' sandbox gave no answer to its self-check, so bots may not be separated from the server. $see" ;; esac case "$report" in *'"id":"config-hidden","ok":true'*) ;; *) fail "MausBot is running, but its sandbox self-check did not show that a bot cannot read the server's settings (config-hidden), so bots are not safely separated from the server. $see" ;; esac stopping="" set -f old_ifs=$IFS IFS=' ' for result in $(printf '%s' "$report" | sed 's/},{/}\ {/g' | grep '"ok":false' || true); do IFS=$old_ifs id=$(sandbox_field "$result" id) detail=$(sandbox_field "$result" detail) severity=$(sandbox_field "$result" severity) if [ -z "$severity" ]; then severity=must case " $SANDBOX_WARN_ONLY " in *" $id "*) [ -z "$id" ] || severity=warn ;; esac fi if [ "$severity" = warn ]; then note "The sandbox's self-check did not pass $id${detail:+ ($detail)}. Bots are still kept from the server's settings and from each other; tell us about it." else stopping="$stopping ${id:-an unnamed check}${detail:+ ($detail)}," fi done IFS=$old_ifs set +f if [ -z "$stopping" ]; then case "$report" in *'"failed":[]'*) ;; *) case "$report" in *'"ok":false'*) ;; *) stopping=" checks it did not name," ;; esac ;; esac fi [ -z "$stopping" ] || fail "MausBot is running, but its bots' sandbox failed its self-check:${stopping%,}. Bots are not safely separated from the server. $see" say "The bots' sandbox is on: a bot cannot read the server's settings, keys or other bots' files." } # A new data folder is handed to the user the image runs as. A folder that already exists is never touched. prepare_data() { if [ -d "$INSTALL_DIR/data" ]; then return; fi owner=$(docker run --rm --network none --entrypoint id "$IMAGE" -u 2>/dev/null || true) group=$(docker run --rm --network none --entrypoint id "$IMAGE" -g 2>/dev/null || true) case "$owner:$group" in *[!0-9:]* | : | :* | *:) fail "The MausBot image did not say which user it runs as. Run this again; if it still fails, send us this line." ;; esac mkdir -m 700 "$INSTALL_DIR/data" chown "$owner:$group" "$INSTALL_DIR/data" } wait_healthy() { waited=0 while [ "$waited" -lt "$HEALTH_WAIT_S" ]; do container=$(compose ps -q omb 2>/dev/null | head -n 1 || true) if [ -n "$container" ] && [ "$(docker inspect --format '{{.State.Health.Status}}' "$container" 2>/dev/null || true)" = healthy ]; then return 0; fi sleep 5 waited=$((waited + 5)) done return 1 } https_answers() { curl -fsS --max-time 10 --resolve "$DOMAIN:443:127.0.0.1" "https://$DOMAIN/api/health" 2>/dev/null | grep -q openmausbot } wait_https() { waited=0 while [ "$waited" -lt "$HTTPS_WAIT_S" ]; do if https_answers; then return 0; fi sleep 5 waited=$((waited + 5)) done return 1 } install_server() { say "" say "Installing MausBot in $INSTALL_DIR..." mkdir -p "$INSTALL_DIR" "$INSTALL_DIR/caddy/data" "$INSTALL_DIR/caddy/config" write_compose write_caddyfile write_settings say "Downloading MausBot..." compose_quietly pull -q || fail "MausBot could not be downloaded (Docker's message is above). Check this server's outbound HTTPS, then run this again." place_update prepare_sandbox prepare_data say "Starting MausBot..." compose_quietly up -d --remove-orphans || fail "MausBot could not be started (Docker's message is above). Fix that, then run this again." wait_healthy || fail "MausBot started but did not report healthy within $HEALTH_WAIT_S seconds. Its log: cd $INSTALL_DIR && sudo docker compose logs omb --tail 50" check_sandbox_runs say "Waiting for the HTTPS certificate of $DOMAIN..." wait_https || fail "MausBot is running, but https://$DOMAIN did not answer within $HTTPS_WAIT_S seconds, usually because the certificate could not be issued. Check that $DOMAIN points at this server and that ports 80 and 443 are open in your provider's firewall, then run this again. Caddy's reason: cd $INSTALL_DIR && sudo docker compose logs caddy --tail 30" } # The connector has its own installer, which MausBot serves with its address and the connector's image filled in. # It is run as it is, with the code. connect_server() { say "" say "Connecting this server to MausBot..." connector_script="${MAUSBOT_CONNECTOR_INSTALLER:-}" fetched="" if [ -z "$connector_script" ]; then fetched=$(mktemp) connector_script=$fetched if ! curl -fsSL --max-time 60 "$ADMIN_URL/install.sh" -o "$connector_script"; then rm -f "$fetched" fail "MausBot is installed and running here, but the connector's installer could not be downloaded from $ADMIN_URL. Nothing needs reinstalling: run this again with your code once that address answers." fi fi if ! grep -q 'mausbot-connector' "$connector_script" 2>/dev/null; then [ -z "$fetched" ] || rm -f "$fetched" fail "MausBot is installed and running here, but what $ADMIN_URL sent is not the connector's installer. Nothing needs reinstalling: copy the install command from MausBot again." fi # Always customer-run, on this install's folder (--dir included): the connector attaches to the server installed here # and never starts a second one beside it. A connector installer that does not know --customer-run refuses it, and # nothing is connected. status=0 if [ "$INSTALL_DOCKER" = 1 ]; then MAUSBOT_ADMIN_URL="$ADMIN_URL" MAUSBOT_INSTALL_DIR="$INSTALL_DIR" sh "$connector_script" "$CODE" --customer-run --install-docker || status=$? else MAUSBOT_ADMIN_URL="$ADMIN_URL" MAUSBOT_INSTALL_DIR="$INSTALL_DIR" sh "$connector_script" "$CODE" --customer-run || status=$? fi [ -z "$fetched" ] || rm -f "$fetched" [ "$status" = 0 ] || fail "MausBot is installed and running here, but connecting it did not finish (the reason is above). Nothing needs reinstalling: fix that and run this again with a new code." } # The first admin's one-time sign-in link, as `openmausbot pair` prints it: its code line (until when it works) and the # link. Nothing when the server would not make one. first_admin_link() { made=$(compose exec -T omb openmausbot pair --label "First admin" 2>/dev/null) || return 1 lines=$(printf '%s\n' "$made" | grep -E '^(Pairing code|Link) ' || true) case "$lines" in *"Link "*"https://$DOMAIN/pair#code="*) printf '%s\n' "$lines" | sed 's/^/ /' ;; *) return 1 ;; esac } finish() { say "" say "MausBot is running at https://$DOMAIN" say "" pair_command="cd $INSTALL_DIR && sudo docker compose exec omb openmausbot pair --label \"First admin\"" # Only to a terminal: in a log, the link would let whoever reads it sign in as the admin until it is used or expires. if [ -t 1 ] && link=$(first_admin_link); then say "To sign in as the first admin, open this one-time link in your browser. Anyone who has it can use it, once:" say "$link" say "To make another: $pair_command" else say "To sign in as the first admin, make a one-time link on this server and open it in your browser:" say " $pair_command" [ -t 1 ] || say "(The link is not made here because this output is not a terminal: a log must not hold a link that signs in as the admin.)" fi admins=$(env_value OMB_SIGNIN_EMAILS) if [ -n "$admins" ]; then say "Or open https://$DOMAIN/pair and sign in with an emailed code as: $admins" fi say "" if [ -x "$INSTALL_DIR/update.sh" ]; then say "To update later, with a backup first and a way back: sudo $INSTALL_DIR/update.sh" else say "To update later, run this command again." fi say "" say "Settings: $INSTALL_DIR/.env Your data: $INSTALL_DIR/data Run this command again to repair." } run_install() { need_root "--domain ${DOMAIN:-maus.example.com}" if [ -z "$DOMAIN" ]; then DOMAIN=$(env_value DOMAIN); fi [ -n "$DOMAIN" ] || fail "Say which name people will open, like this: $(command_line '--domain maus.example.com')" preflight if [ "$FAILED" != 0 ]; then report_docker_missing fail "This server is not ready: fix the $FAILED line(s) marked FIX above, then run this again. Nothing was changed." fi if [ "$DOCKER_STATE" = missing ]; then offer_docker; fi install_server if [ -n "$CODE" ]; then connect_server; fi finish } run_check() { if [ -z "$DOMAIN" ]; then DOMAIN=$(env_value DOMAIN); fi preflight report_docker_missing if [ "$FAILED" != 0 ]; then fail "This server is not ready: fix the $FAILED line(s) marked FIX above. Nothing was changed." fi say "" say "This server is ready for MausBot. Nothing was changed." } # Stops the server and removes its containers. The data, the settings and the certificates stay where they are. run_uninstall() { need_root --uninstall [ -f "$INSTALL_DIR/compose.yaml" ] || fail "MausBot isn't installed in $INSTALL_DIR, so there is nothing to stop." command -v docker >/dev/null 2>&1 || fail "Docker isn't installed here, so MausBot isn't running. Your data is in $INSTALL_DIR/data." say "Stopping MausBot..." compose_quietly down --remove-orphans || fail "MausBot could not be stopped (Docker's message is above)." say "MausBot is stopped and its containers are removed." say "Your data is kept in $INSTALL_DIR/data, your settings in $INSTALL_DIR/.env and the HTTPS certificates in $INSTALL_DIR/caddy." say "To start it again: $(command_line "--domain $(env_value DOMAIN)")" say "This command never deletes your data. To delete it yourself: sudo rm -rf $INSTALL_DIR" if [ -f "$SYSROOT/etc/mausbot/$SECCOMP" ]; then say "The sandbox's two profiles stay on this server: /etc/mausbot/$SECCOMP and /etc/apparmor.d/$APPARMOR_PROFILE." fi if [ -n "$(docker ps -aq --filter name=mausbot-connector 2>/dev/null || true)" ]; then say "This server is still connected to MausBot. To disconnect it, run the uninstall command MausBot shows for the connector." fi } main() { settings parse "$@" case "$MODE" in check) run_check ;; uninstall) run_uninstall ;; *) run_install ;; esac } main "$@"