deploy/install.sh
Rama main · réplica de lectura del repositorio original.
#!/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 <code> 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 " <code> 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/tty) 2>/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 </dev/tty || answer=n
fi
case "$answer" in
"" | y | Y | yes | Yes) ;;
*) fail "$DOCKER_PROBLEM Install it with: curl -fsSL https://get.docker.com | sudo sh and then run this again, or run this again with --install-docker." ;;
esac
fi
install_docker
FAILED=0
check_docker
report_docker_missing
[ "$FAILED" = 0 ] || fail "Docker still isn't usable after its install. Fix the line above, then run this again."
}
write_file() { # path, with the content on standard input: replaced in one step
tmp_file=$(mktemp "$1.XXXXXX")
cat >"$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/<id>.
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 "$@"