Files
tben 05af559adb Keep SELinux mount options out of the mount destination
Appending `,z` to a mount destination (`$DATA_DIR:/data,z`) makes docker
mount a directory literally named `/data,z`. The app then writes its
database into its own container layer instead of the host volume,
`container_matches` never matches so every update recreates the container
without its data, `backup_database` silently skips, and `resolveHostPath`
refuses to provision computers (`/data/computers/1 isn't on a volume`).

Build each spec as `source:destination[:options]` instead, with the `z`
and `ro` options comma separated in the third field and the destination a
plain absolute path. `backup_database` uses the same helper.

`rescue_container_data` runs before a container whose /data is not the
host's directory is replaced: it stops the app, copies `CONTAINER:/data`
out with `docker cp`, moves a non-empty host directory aside and writes
the copy in, so repairing an install from the broken form does not lose
the database, the media or the computers' home directories.

`install.test.ts` lifts the marked `selinux-detection` and `mount-specs`
blocks out of install.sh, runs them in bash and parses the specs the way
docker does, so the two forms can't drift back.
2026-10-22 16:43:10 +01:00

906 lines
29 KiB
Bash

#!/usr/bin/env bash
# Cobble installer and updater.
#
# curl -fsSL https://url.moduletrace.com/cobble | sudo bash
#
# The script is idempotent: running it again updates Cobble in place and
# keeps the data, the database and the generated secrets. It installs
# Docker when it is missing, creates the cobble network and starts the app
# container with the docker socket mounted, so the app can create the
# computers it runs.
#
# The app serves plain HTTP: put a reverse proxy or tunnel in front of it
# for HTTPS.
set -Eeuo pipefail
CONTAINER="cobble"
NETWORK="cobble"
DEFAULT_PORT="7227"
DEFAULT_BIND="0.0.0.0"
# The app container's own limits (the computers' sandboxes are limited
# separately, per computer).
DEFAULT_CPUS="1"
DEFAULT_MEMORY_MB="2048"
DEFAULT_IMAGE="ghcr.io/moduletrace/cobble"
DEFAULT_VERSION="latest"
INSTALL_URL="https://url.moduletrace.com/cobble"
HEALTH_TIMEOUT_SECONDS="${COBBLE_HEALTH_TIMEOUT:-900}"
BACKUPS_KEPT=5
if [ -t 1 ]; then
C_RESET=$'\033[0m'
C_BOLD=$'\033[1m'
C_DIM=$'\033[2m'
C_RED=$'\033[31m'
C_GREEN=$'\033[32m'
C_YELLOW=$'\033[33m'
C_BLUE=$'\033[34m'
else
C_RESET=""
C_BOLD=""
C_DIM=""
C_RED=""
C_GREEN=""
C_YELLOW=""
C_BLUE=""
fi
say() { printf '%s==>%s %s\n' "$C_BLUE" "$C_RESET" "$*"; }
ok() { printf '%s ok%s %s\n' "$C_GREEN" "$C_RESET" "$*"; }
warn() { printf '%swarn%s %s\n' "$C_YELLOW" "$C_RESET" "$*" >&2; }
fail() {
printf '%sfail%s %s\n' "$C_RED" "$C_RESET" "$*" >&2
exit 1
}
on_error() {
local code=$?
printf '%sfail%s line %s exited with status %s\n' "$C_RED" "$C_RESET" "${BASH_LINENO[0]:-?}" "$code" >&2
exit "$code"
}
trap on_error ERR
usage() {
cat <<EOF
Cobble installer and updater
Usage:
curl -fsSL $INSTALL_URL | sudo bash
curl -fsSL $INSTALL_URL | sudo bash -s -- [options]
Options:
--home DIR Install directory (default /opt/cobble)
--data-dir DIR Data directory, mounted at /data (default HOME/data)
--port PORT Host port (default 7227)
--bind ADDRESS Host address to publish on (default 0.0.0.0)
--cpus N CPU cores the app container may use (default 1)
--memory MB Memory the app container may use (default 2048)
--image IMAGE Container image (default $DEFAULT_IMAGE)
--version TAG Image tag (default latest)
--no-pull Use the image already on this machine
--extra-network NAME Join another docker network, e.g. a tunnel's
--force Recreate the container even when it is current
--skip-backup Do not back up the database before an update
--no-docker-install Fail instead of installing Docker when it is missing
--yes, -y Accept the defaults without asking
--uninstall Stop and remove the app container
--purge With --uninstall, also delete the data directory
--help Show this help
HTTPS:
The app serves plain HTTP on the published port. Put a reverse proxy or
tunnel in front of it for HTTPS, and point that proxy at
http://<this machine>:<port>. Phones need HTTPS to install the app and
receive notifications, so expose Cobble through the proxy rather than
its bare port.
The data directory holds the database, media and every computer's home
directory. It is kept when the container is removed. Settings live in
HOME/.env; restart the container after changing them.
EOF
}
# ---------------------------------------------------------------- arguments
HOME_DIR=""
for ((i = 1; i <= $#; i++)); do
case "${!i}" in
--home)
next=$((i + 1))
[ "$next" -le $# ] || fail "--home needs a value"
HOME_DIR="${!next}"
;;
--help | -h)
usage
exit 0
;;
esac
done
if [ -z "$HOME_DIR" ]; then
HOME_DIR="${COBBLE_HOME:-}"
fi
if [ -z "$HOME_DIR" ] && [ "$(uname -s)" = "Darwin" ] && [ -n "${SUDO_USER:-}" ]; then
user_home="$(dscl . -read "/Users/$SUDO_USER" NFSHomeDirectory 2>/dev/null | awk '{print $2}')"
[ -n "$user_home" ] && HOME_DIR="$user_home/.cobble"
fi
if [ -z "$HOME_DIR" ]; then
if [ "$(uname -s)" = "Darwin" ]; then
HOME_DIR="$HOME/.cobble"
else
HOME_DIR="/opt/cobble"
fi
fi
case "$HOME_DIR" in
/*) ;;
*) fail "--home must be an absolute path" ;;
esac
ENV_FILE="$HOME_DIR/.env"
STATE_FILE="$HOME_DIR/install.conf"
if [ -f "$STATE_FILE" ]; then
# shellcheck disable=SC1090
. "$STATE_FILE"
fi
DATA_DIR="${COBBLE_DATA_DIR:-}"
PORT="${COBBLE_PORT:-}"
BIND="${COBBLE_BIND:-}"
CPUS="${COBBLE_CPUS:-}"
MEMORY_MB="${COBBLE_MEMORY_MB:-}"
IMAGE="${COBBLE_IMAGE:-}"
VERSION="${COBBLE_VERSION:-}"
EXTRA_NETWORK="${COBBLE_EXTRA_NETWORK:-}"
DOCKER_SOCKET="${COBBLE_DOCKER_SOCKET:-${DOCKER_SOCKET:-/var/run/docker.sock}}"
ACTION="install"
FORCE=0
NO_PULL=0
NO_DOCKER_INSTALL=0
SKIP_BACKUP=0
PURGE=0
ASSUME_YES=0
env_get() {
local key="$1"
[ -f "$ENV_FILE" ] || return 0
sed -nE "s/^[[:space:]]*${key}=(.*)$/\1/p" "$ENV_FILE" | tail -n 1 | sed -e 's/^"//' -e 's/"$//'
}
if [ ! -f "$STATE_FILE" ] && [ -f "$ENV_FILE" ] && [ -z "$PORT" ]; then
PORT="$(env_get PORT)"
fi
DATA_DIR="${DATA_DIR:-$HOME_DIR/data}"
PORT="${PORT:-$DEFAULT_PORT}"
BIND="${BIND:-$DEFAULT_BIND}"
CPUS="${CPUS:-$DEFAULT_CPUS}"
MEMORY_MB="${MEMORY_MB:-$DEFAULT_MEMORY_MB}"
IMAGE="${IMAGE:-$DEFAULT_IMAGE}"
VERSION="${VERSION:-$DEFAULT_VERSION}"
while [ $# -gt 0 ]; do
case "$1" in
--home)
HOME_DIR="${2:-}"
shift 2
;;
--data-dir)
DATA_DIR="${2:-}"
shift 2
;;
--port)
PORT="${2:-}"
shift 2
;;
--bind)
BIND="${2:-}"
shift 2
;;
--cpus)
CPUS="${2:-}"
shift 2
;;
--memory)
MEMORY_MB="${2:-}"
shift 2
;;
--image)
IMAGE="${2:-}"
shift 2
;;
--version)
VERSION="${2:-}"
shift 2
;;
--extra-network)
EXTRA_NETWORK="${2:-}"
shift 2
;;
# Accepted from older scripts and docs, then ignored: HTTPS is the
# proxy's job now, and a value that still pointed at a certificate
# should not be dropped silently.
--https | --no-https | --https-regen)
warn "$1 is no longer needed: Cobble serves plain HTTP. Put a proxy or tunnel in front for HTTPS."
shift
;;
--https-host | --https-cert | --https-key)
warn "$1 is no longer needed: Cobble serves plain HTTP. Put a proxy or tunnel in front for HTTPS."
shift 2
;;
--yes | -y)
ASSUME_YES=1
shift
;;
--force)
FORCE=1
shift
;;
--no-pull)
NO_PULL=1
shift
;;
--skip-backup)
SKIP_BACKUP=1
shift
;;
--no-docker-install)
NO_DOCKER_INSTALL=1
shift
;;
--uninstall)
ACTION="uninstall"
shift
;;
--purge)
PURGE=1
shift
;;
--help | -h)
usage
exit 0
;;
*)
fail "Unknown option: $1. Use --help."
;;
esac
done
[ -n "$HOME_DIR" ] || fail "--home needs a value"
[ -n "$DATA_DIR" ] || fail "--data-dir needs a value"
[ -n "$PORT" ] || fail "--port needs a value"
[ -n "$BIND" ] || fail "--bind needs a value"
[ -n "$CPUS" ] || fail "--cpus needs a value"
[ -n "$MEMORY_MB" ] || fail "--memory needs a value"
[ -n "$IMAGE" ] || fail "--image needs a value"
[ -n "$VERSION" ] || fail "--version needs a value"
case "$HOME_DIR" in
/*) ;;
*) fail "--home must be an absolute path" ;;
esac
case "$DATA_DIR" in
/*) ;;
*) fail "--data-dir must be an absolute path" ;;
esac
case "$PORT" in
'' | *[!0-9]*) fail "--port must be a number" ;;
esac
[ "$PORT" -ge 1 ] && [ "$PORT" -le 65535 ] || fail "--port must be between 1 and 65535"
case "$BIND" in
'' | *[!0-9a-fA-F:.]*) fail "--bind must be an IP address" ;;
esac
case "$CPUS" in
'' | *[!0-9.]*) fail "--cpus must be a number" ;;
esac
[ "$(awk -v value="$CPUS" 'BEGIN { print (value > 0) ? 1 : 0 }')" = 1 ] || fail "--cpus must be greater than 0"
case "$MEMORY_MB" in
'' | *[!0-9]*) fail "--memory must be a number of megabytes" ;;
esac
[ "$MEMORY_MB" -ge 512 ] || fail "--memory must be at least 512"
case "$IMAGE" in
'' | *[!A-Za-z0-9._/-]*) fail "--image may only contain letters, numbers, dot, dash, underscore and slash" ;;
esac
case "$VERSION" in
'' | *[!A-Za-z0-9._-]*) fail "--version may only contain letters, numbers, dot, dash and underscore" ;;
esac
if [ -n "$EXTRA_NETWORK" ]; then
case "$EXTRA_NETWORK" in
*[!A-Za-z0-9_.-]*) fail "--extra-network is not a valid network name" ;;
esac
fi
if [ "$PURGE" = 1 ] && [ "$ACTION" != "uninstall" ]; then
fail "--purge only works with --uninstall"
fi
ENV_FILE="$HOME_DIR/.env"
STATE_FILE="$HOME_DIR/install.conf"
DATA_DIR="${DATA_DIR%/}"
BACKUP_DIR="$HOME_DIR/backups"
# Whole-data archives made in the app (Settings > Backups). They live
# outside DATA_DIR, so an archive never contains another one.
BACKUP_ARCHIVE_DIR_HOST="$HOME_DIR/backups-archives"
# ---------------------------------------------------------------- platform
OS="$(uname -s)"
case "$OS" in
Linux | Darwin) ;;
*) fail "Unsupported operating system: $OS. Cobble runs on Linux and macOS with Docker." ;;
esac
case "$(uname -m)" in
x86_64 | amd64) ARCH="amd64" ;;
aarch64 | arm64) ARCH="arm64" ;;
*) fail "Unsupported architecture: $(uname -m)" ;;
esac
if [ "$OS" = "Linux" ] && [ "$(id -u)" -ne 0 ]; then
fail "Run the installer as root: curl -fsSL $INSTALL_URL | sudo bash"
fi
# SELinux hosts need the `z` mount option so docker relabels the bind
# mount. It is a third colon-separated field (`src:/data:z`), never a
# suffix on the destination: docker reads `src:/data,z` as the directory
# "/data,z", which silently leaves the app writing to its own container
# layer, breaks every update (the container is recreated without its data)
# and makes computer provisioning fail. Never append to the destination.
# MARK:selinux-detection
selinux_enforcing() {
if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce 2>/dev/null || true)" != "Disabled" ]; then
return 0
fi
[ -e /sys/fs/selinux/enforce ]
}
MOUNT_OPTS=""
if [ "$OS" = "Linux" ] && selinux_enforcing; then
MOUNT_OPTS="z"
fi
# END:selinux-detection
# MARK:mount-specs
# A spec is `source:destination[:options]`. Options are comma separated and
# only count in that third field, so each spec below either opens the field
# with a colon or adds to one that is already there. A destination is
# always a plain absolute path.
ENV_MOUNT="$ENV_FILE:/app/.env:ro${MOUNT_OPTS:+,$MOUNT_OPTS}"
DATA_MOUNT="$DATA_DIR:/data${MOUNT_OPTS:+:$MOUNT_OPTS}"
BACKUP_ARCHIVE_MOUNT="$BACKUP_ARCHIVE_DIR_HOST:/backups-archives${MOUNT_OPTS:+:$MOUNT_OPTS}"
# END:mount-specs
# ---------------------------------------------------------------- helpers
gen_secret() {
if command -v openssl >/dev/null 2>&1; then
openssl rand -hex 32
else
od -An -N32 -tx1 /dev/urandom | tr -d ' \n'
fi
}
env_has() {
[ -f "$ENV_FILE" ] && grep -qE "^[[:space:]]*$1=" "$ENV_FILE"
}
ensure_env() {
local key="$1" value="$2"
if ! env_has "$key"; then
printf '%s=%s\n' "$key" "$value" >>"$ENV_FILE"
fi
}
ensure_secret() {
local key="$1" value
value="$(env_get "$key")"
if [ -z "$value" ]; then
set_env "$key" "$(gen_secret)"
fi
}
unset_env() {
local key="$1"
[ -f "$ENV_FILE" ] || return 0
sed -i.bak -E "/^[[:space:]]*${key}=.*$/d" "$ENV_FILE"
rm -f "$ENV_FILE.bak"
}
health_url() {
printf 'http://%s:%s/api/health' "$(health_host)" "$PORT"
}
set_env() {
local key="$1" value="$2" escaped
escaped="$(printf '%s' "$value" | sed -e 's/[\/&]/\\&/g')"
if env_has "$key"; then
sed -i.bak -E "s|^[[:space:]]*${key}=.*$|${key}=${escaped}|" "$ENV_FILE"
rm -f "$ENV_FILE.bak"
else
printf '%s=%s\n' "$key" "$value" >>"$ENV_FILE"
fi
}
container_exists() {
docker container inspect "$CONTAINER" >/dev/null 2>&1
}
container_running() {
[ "$(docker inspect -f '{{.State.Running}}' "$CONTAINER" 2>/dev/null || true)" = "true" ]
}
container_image_id() {
docker inspect -f '{{.Image}}' "$CONTAINER"
}
port_in_use() {
if [ "$OS" = "Darwin" ]; then
lsof -nP -iTCP:"$PORT" -sTCP:LISTEN >/dev/null 2>&1
elif command -v ss >/dev/null 2>&1; then
ss -ltnH "sport = :$PORT" 2>/dev/null | grep -q .
elif command -v netstat >/dev/null 2>&1; then
netstat -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]$PORT$"
else
return 1
fi
}
docker_socket_mounted() {
docker inspect -f '{{range .Mounts}}{{if eq .Destination "/var/run/docker.sock"}}{{.Source}}{{end}}{{end}}' "$CONTAINER"
}
mounted_source() {
docker inspect -f '{{range .Mounts}}{{if eq .Destination "'"$1"'"}}{{.Source}}{{end}}{{end}}' "$CONTAINER"
}
container_matches() {
container_running || return 1
[ "$(container_image_id)" = "$TARGET_IMAGE_ID" ] || return 1
[ "$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Source}}{{end}}{{end}}' "$CONTAINER")" = "$DATA_DIR" ] || return 1
[ "$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/app/.env"}}{{.Source}}{{end}}{{end}}' "$CONTAINER")" = "$ENV_FILE" ] || return 1
[ "$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/backups-archives"}}{{.Source}}{{end}}{{end}}' "$CONTAINER")" = "$BACKUP_ARCHIVE_DIR_HOST" ] || return 1
[ "$(docker_socket_mounted)" = "$DOCKER_SOCKET" ] || return 1
[ "$(docker inspect -f '{{with index .HostConfig.PortBindings "'"$PORT"'/tcp"}}{{range .}}{{.HostIp}}:{{.HostPort}}{{end}}{{end}}' "$CONTAINER")" = "$BIND:$PORT" ] || return 1
[ "$(docker inspect -f '{{.HostConfig.RestartPolicy.Name}}' "$CONTAINER")" = "unless-stopped" ] || return 1
[ "$(docker inspect -f '{{.HostConfig.Init}}' "$CONTAINER")" = "true" ] || return 1
[ "$(docker inspect -f '{{.HostConfig.NanoCpus}}' "$CONTAINER")" = "$(awk -v cpus="$CPUS" 'BEGIN { printf "%d", cpus * 1000000000 }')" ] || return 1
[ "$(docker inspect -f '{{.HostConfig.Memory}}' "$CONTAINER")" = "$((MEMORY_MB * 1024 * 1024))" ] || return 1
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "MAIN_SERVER=1" || return 1
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "PORT=$PORT" || return 1
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "BACKUP_ARCHIVE_DIR=/backups-archives" || return 1
docker inspect -f '{{range $name, $net := .NetworkSettings.Networks}}{{println $name}}{{end}}' "$CONTAINER" | grep -qx "$NETWORK" || return 1
if [ -n "$EXTRA_NETWORK" ]; then
docker inspect -f '{{range $name, $net := .NetworkSettings.Networks}}{{println $name}}{{end}}' "$CONTAINER" | grep -qx "$EXTRA_NETWORK" || return 1
fi
# An install from before TLS was removed: its container holds the old
# certificate mounts and envs, so recreate it rather than report it
# current.
if [ -n "$(mounted_source /app/certs/server.crt)" ] ||
[ -n "$(mounted_source /app/certs/server.key)" ] ||
[ -n "$(mounted_source /app/certs/ca.crt)" ]; then
return 1
fi
if docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -q "^TLS_"; then
return 1
fi
return 0
}
health_host() {
case "$BIND" in
0.0.0.0 | :: | "") printf '127.0.0.1' ;;
*) printf '%s' "$BIND" ;;
esac
}
host_ips() {
if [ "$OS" = "Darwin" ]; then
ifconfig 2>/dev/null | awk '/^[[:space:]]+inet / && $2 != "127.0.0.1" { print $2 }' | head -n 5 || true
elif command -v hostname >/dev/null 2>&1; then
hostname -I 2>/dev/null | tr ' ' '\n' | grep -vE '^$|^127\.' | head -n 5 || true
fi
}
wait_for_health() {
local url start=0
url="$(health_url)"
say "Waiting for Cobble to answer at $url"
while [ "$start" -lt "$HEALTH_TIMEOUT_SECONDS" ]; do
if curl -fsS --max-time 3 -o /dev/null "$url" 2>/dev/null; then
ok "Cobble is healthy"
return 0
fi
if ! container_running; then
docker logs --tail 80 "$CONTAINER" 2>&1 || true
fail "The Cobble container stopped during startup"
fi
sleep 3
start=$((start + 3))
done
docker logs --tail 120 "$CONTAINER" 2>&1 || true
fail "Cobble did not become healthy within ${HEALTH_TIMEOUT_SECONDS}s"
}
backup_database() {
local data_source="$DATA_DIR"
if container_exists; then
local mounted
mounted="$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Source}}{{end}}{{end}}' "$CONTAINER" 2>/dev/null || true)"
[ -n "$mounted" ] && data_source="$mounted"
fi
[ -f "$data_source/db/cobble" ] || return 0
mkdir -p "$BACKUP_DIR"
say "Backing up the database"
local backup_mount="$data_source:/data${MOUNT_OPTS:+:$MOUNT_OPTS}"
local image_id=""
if container_running; then
if ! docker exec "$CONTAINER" bunx bun-sqlite backup >/dev/null 2>&1; then
[ "$SKIP_BACKUP" = 1 ] && {
warn "Could not back up the database, continuing because of --skip-backup"
return 0
}
fail "Could not back up the database. Use --skip-backup to update anyway."
fi
elif container_exists; then
image_id="$(container_image_id)"
if ! docker run --rm --entrypoint bunx \
-v "$backup_mount" -e DB_DIR=/data/db \
"$image_id" bun-sqlite backup >/dev/null 2>&1; then
[ "$SKIP_BACKUP" = 1 ] && {
warn "Could not back up the database, continuing because of --skip-backup"
return 0
}
fail "Could not back up the database. Use --skip-backup to update anyway."
fi
else
return 0
fi
local newest
newest="$(ls -1t "$data_source"/db/.backups/cobble-* 2>/dev/null | head -n 1 || true)"
if [ -z "$newest" ]; then
warn "No database backup file was written"
return 0
fi
cp -a "$newest" "$BACKUP_DIR/$(basename "$newest")" 2>/dev/null || true
ok "Backup: $BACKUP_DIR/$(basename "$newest")"
local old
old="$(ls -1t "$BACKUP_DIR"/cobble-* 2>/dev/null | tail -n +$((BACKUPS_KEPT + 1)) || true)"
if [ -n "$old" ]; then
printf '%s\n' "$old" | while IFS= read -r file; do
rm -f "$file"
done
fi
}
# Whether the container's /data is really the host's data directory.
# Older installers wrote the SELinux option onto the destination
# (`$DATA_DIR:/data,z`), so docker mounted a directory named "/data,z"
# and the app kept its database in the container's own writable layer.
# Such a container must have that data lifted out before it is replaced.
data_mount_broken() {
container_exists || return 1
local mounted
mounted="$(docker inspect -f '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Source}}{{end}}{{end}}' "$CONTAINER" 2>/dev/null || true)"
[ "$mounted" != "$DATA_DIR" ]
}
# Copies a container's unmounted /data back onto the host before the
# container is recreated, so a broken mount is repaired without losing the
# database, the media or the computers' home directories. Runs only when
# the container's /data is not the host's directory, and refuses to
# overwrite a host directory that already holds a database.
rescue_container_data() {
data_mount_broken || return 0
# Stop the app first: the copy must see a database nothing is writing
# to, and the container is about to be replaced anyway.
if container_running; then
say "Stopping Cobble to copy its data out"
docker stop -t 30 "$CONTAINER" >/dev/null 2>&1 || true
fi
local staged
staged="$(mktemp -d)"
if ! docker cp "$CONTAINER:/data/." "$staged/" >/dev/null 2>&1; then
rm -rf "$staged"
fail "Could not read the container's data. Its database is still in $CONTAINER; copy it out before updating."
fi
# A container that never wrote anything has nothing to rescue: the app
# is about to start fresh on the host directory.
if [ -z "$(ls -A "$staged" 2>/dev/null)" ]; then
rm -rf "$staged"
return 0
fi
say "This install's data was inside its container (a broken mount); moving it to $DATA_DIR"
if [ -n "$(ls -A "$DATA_DIR" 2>/dev/null)" ]; then
local aside
aside="$DATA_DIR.before-rescue-$(date +%s)"
warn "$DATA_DIR was not empty; the copy is kept at $aside"
mv "$DATA_DIR" "$aside" || fail "Could not move $DATA_DIR aside"
fi
mkdir -p "$DATA_DIR"
cp -a "$staged/." "$DATA_DIR/" || {
rm -rf "$staged"
fail "Could not copy the rescued data into $DATA_DIR"
}
rm -rf "$staged"
if [ -f "$DATA_DIR/db/cobble" ]; then
ok "Rescued the database and data into $DATA_DIR"
else
warn "Rescued data has no database file; the install will start empty"
fi
}
safe_remove_dir() {
local dir="$1"
case "$dir" in
/ | /opt | /home | /root | /var | /usr | /etc | /data | /mnt | /srv | /tmp)
fail "Refusing to delete $dir"
;;
esac
case "$dir" in
/*/*) ;;
*) fail "Refusing to delete $dir" ;;
esac
rm -rf "$dir"
}
connect_extra_network() {
[ -n "$EXTRA_NETWORK" ] || return 0
docker network inspect "$EXTRA_NETWORK" >/dev/null 2>&1 || fail "Network '$EXTRA_NETWORK' does not exist"
docker network connect "$EXTRA_NETWORK" "$CONTAINER" >/dev/null 2>&1 || true
}
run_container() {
local mounts=(
-v "$ENV_MOUNT"
-v "$DATA_MOUNT"
-v "$BACKUP_ARCHIVE_MOUNT"
-v "$DOCKER_SOCKET:/var/run/docker.sock"
)
local envs=(
-e MAIN_SERVER=1
-e "PORT=$PORT"
-e BACKUP_ARCHIVE_DIR=/backups-archives
)
docker run -d \
--name "$CONTAINER" \
--restart unless-stopped \
--init \
--network "$NETWORK" \
-p "$BIND:$PORT:$PORT" \
--cpus "$CPUS" \
--memory "${MEMORY_MB}m" \
"${mounts[@]}" \
"${envs[@]}" \
--log-driver json-file \
--log-opt max-size=10m \
--log-opt max-file=3 \
"$IMAGE:$VERSION" >/dev/null
}
# ---------------------------------------------------------------- docker
ensure_docker() {
if ! command -v docker >/dev/null 2>&1; then
if [ "$NO_DOCKER_INSTALL" = 1 ]; then
fail "Docker is not installed. Install it and rerun, or drop --no-docker-install."
fi
if [ "$OS" != "Linux" ]; then
fail "Docker is not installed. Install Docker Desktop, then rerun."
fi
say "Installing Docker"
local script
script="$(mktemp)"
curl -fsSL https://get.docker.com -o "$script"
sh "$script"
rm -f "$script"
fi
if ! docker info >/dev/null 2>&1; then
if [ "$OS" = "Darwin" ]; then
fail "Docker Desktop is not running. Start it, then rerun."
fi
if command -v systemctl >/dev/null 2>&1; then
systemctl enable --now docker >/dev/null 2>&1 || true
elif command -v service >/dev/null 2>&1; then
service docker start >/dev/null 2>&1 || true
fi
fi
docker info >/dev/null 2>&1 || fail "Docker is installed but not running"
if [ ! -S "$DOCKER_SOCKET" ]; then
fail "No docker socket at $DOCKER_SOCKET"
fi
}
# ---------------------------------------------------------------- uninstall
uninstall() {
if container_exists; then
say "Stopping Cobble"
docker stop -t 30 "$CONTAINER" >/dev/null 2>&1 || true
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
fi
local computers
computers="$(docker ps -aq --filter 'label=cobble.computer_id' 2>/dev/null || true)"
if [ -n "$computers" ]; then
say "Removing computer containers"
while IFS= read -r id; do
[ -n "$id" ] && docker rm -f "$id" >/dev/null 2>&1 || true
done <<<"$computers"
fi
if docker network inspect "$NETWORK" >/dev/null 2>&1; then
local attached
attached="$(docker network inspect -f '{{len .Containers}}' "$NETWORK" 2>/dev/null || echo 1)"
if [ "$attached" = "0" ]; then
docker network rm "$NETWORK" >/dev/null 2>&1 || true
fi
fi
if [ "$PURGE" = 1 ]; then
safe_remove_dir "$HOME_DIR"
if [ "$DATA_DIR" != "$HOME_DIR" ] && [ "$DATA_DIR" != "$HOME_DIR/data" ]; then
safe_remove_dir "$DATA_DIR"
fi
ok "Removed Cobble and its data"
else
ok "Cobble stopped. Data kept in $HOME_DIR"
fi
exit 0
}
# ---------------------------------------------------------------- main
command -v curl >/dev/null 2>&1 || fail "curl is required"
ensure_docker
if [ "$ACTION" = "uninstall" ]; then
uninstall
fi
mkdir -p "$HOME_DIR" "$DATA_DIR" "$BACKUP_DIR" "$BACKUP_ARCHIVE_DIR_HOST"
chmod 755 "$HOME_DIR" "$DATA_DIR" "$BACKUP_DIR" "$BACKUP_ARCHIVE_DIR_HOST"
if ! docker network inspect "$NETWORK" >/dev/null 2>&1; then
say "Creating the $NETWORK network"
docker network create "$NETWORK" >/dev/null
fi
if [ -n "$EXTRA_NETWORK" ]; then
docker network inspect "$EXTRA_NETWORK" >/dev/null 2>&1 || fail "Network '$EXTRA_NETWORK' does not exist"
fi
if [ ! -f "$ENV_FILE" ]; then
(umask 077
cat >"$ENV_FILE" <<EOF
# Cobble settings. Restart the container after changing them:
# docker restart $CONTAINER
PORT=$PORT
ENCRYPTION_KEY=$(gen_secret)
ENCRYPTION_SALT=$(gen_secret)
PAGE_LASTMOD_FILE=/data/sitemap-lastmod.json
EOF
)
fi
chmod 600 "$ENV_FILE"
set_env PORT "$PORT"
ensure_secret ENCRYPTION_KEY
ensure_secret ENCRYPTION_SALT
ensure_env PAGE_LASTMOD_FILE "/data/sitemap-lastmod.json"
# Installs from before TLS was removed may still carry these; the app
# ignores them, so drop them rather than leave a stale decoration.
unset_env TLS_CERT_FILE
unset_env TLS_KEY_FILE
unset_env TLS_CA_FILE
unset_env TLS_DISABLED
unset_env HTTP_REDIRECT_PORT
{
printf 'COBBLE_DATA_DIR=%q\n' "$DATA_DIR"
printf 'COBBLE_BACKUP_ARCHIVE_DIR=%q\n' "$BACKUP_ARCHIVE_DIR_HOST"
printf 'COBBLE_PORT=%q\n' "$PORT"
printf 'COBBLE_BIND=%q\n' "$BIND"
printf 'COBBLE_CPUS=%q\n' "$CPUS"
printf 'COBBLE_MEMORY_MB=%q\n' "$MEMORY_MB"
printf 'COBBLE_IMAGE=%q\n' "$IMAGE"
printf 'COBBLE_VERSION=%q\n' "$VERSION"
printf 'COBBLE_EXTRA_NETWORK=%q\n' "$EXTRA_NETWORK"
printf 'COBBLE_DOCKER_SOCKET=%q\n' "$DOCKER_SOCKET"
} >"$STATE_FILE"
chmod 600 "$STATE_FILE"
if ! container_exists && port_in_use; then
fail "Port $PORT is already in use. Pass --port with a free port."
fi
if [ "$NO_PULL" = 1 ]; then
docker image inspect "$IMAGE:$VERSION" >/dev/null 2>&1 || fail "Image $IMAGE:$VERSION is not available locally"
else
say "Pulling $IMAGE:$VERSION"
if ! docker pull "$IMAGE:$VERSION" >/dev/null; then
fail "Could not pull $IMAGE:$VERSION"
fi
fi
TARGET_IMAGE_ID="$(docker image inspect -f '{{.Id}}' "$IMAGE:$VERSION")"
IMAGE_ARCH="$(docker image inspect -f '{{.Architecture}}' "$IMAGE:$VERSION")"
if [ "$IMAGE_ARCH" != "$ARCH" ]; then
fail "Image $IMAGE:$VERSION is $IMAGE_ARCH but this machine is $ARCH. Pull the multi-arch image instead of --no-pull."
fi
if [ "$FORCE" = 0 ] && container_matches; then
connect_extra_network
if curl -fsS --max-time 3 -o /dev/null "$(health_url)" 2>/dev/null; then
ok "Cobble is already up to date"
say "Healthy at http://$(health_host):$PORT"
exit 0
fi
warn "The container is running but not answering; recreating it"
fi
if container_exists; then
# A container whose /data was never mounted keeps the database in its
# own layer, so lift it out before anything replaces the container.
rescue_container_data
backup_database
say "Replacing the Cobble container"
docker stop -t 30 "$CONTAINER" >/dev/null 2>&1 || true
docker rm -f "$CONTAINER" >/dev/null 2>&1 || true
fi
say "Starting Cobble"
run_container
connect_extra_network
wait_for_health
ok "Cobble is running"
printf '\n'
printf ' %s%s%s\n' "$C_BOLD" "Open Cobble" "$C_RESET"
if [ "$BIND" = "0.0.0.0" ]; then
while IFS= read -r ip; do
[ -n "$ip" ] && printf ' http://%s:%s\n' "$ip" "$PORT"
done < <(host_ips)
fi
printf ' http://127.0.0.1:%s\n' "$PORT"
printf '\n'
printf ' Put a reverse proxy or tunnel in front for HTTPS, and open that\n'
printf ' address instead. The installed app and notifications need it.\n'
printf '\n'
printf ' First visit: create the super admin account.\n'
printf ' Data: %s\n' "$DATA_DIR"
printf ' Settings: %s\n' "$ENV_FILE"
printf ' Backups: %s\n' "$BACKUP_DIR"
printf ' Archives: %s\n' "$BACKUP_ARCHIVE_DIR_HOST"
printf ' Logs: docker logs -f %s\n' "$CONTAINER"
printf ' Update: curl -fsSL %s | sudo bash\n' "$INSTALL_URL"
printf ' Uninstall: curl -fsSL %s | sudo bash -s -- --uninstall\n' "$INSTALL_URL"
printf '\n'
if [ "$BIND" = "0.0.0.0" ]; then
printf ' %sThis port is reachable on your network, without encryption. Put a proxy or tunnel in front of it for HTTPS.%s\n\n' "$C_DIM" "$C_RESET"
fi