Serve HTTPS by default with a local CA

The installer generates a CA and a 397-day certificate into
$COBBLE_HOME/certs (in the app image, with the host's addresses), mounts
the certificate, key and CA into the container and serves TLS from Bun on
the published port. --no-https opts out; --https-cert/--https-key use a
real certificate, --https-host adds names and --https-regen replaces the
CA. The installer and the image health check use curl -k when TLS is on.

The CA is served at /ca.crt and as an iOS/macOS profile at
/ca.mobileconfig for one-tap install. Development picks up certs/
automatically, `bun run dev:phone` generates and starts, and a plain-HTTP
listener on PORT + 1 redirects to HTTPS. Port sharing is off, so a stale
dev server fails instead of silently answering on the same port.
This commit is contained in:
2026-10-20 10:20:01 +01:00
parent 535adb6e16
commit e859bab294
26 changed files with 1582 additions and 40 deletions
+326 -16
View File
@@ -8,6 +8,10 @@
# 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.
#
# HTTPS is on by default: a local CA and a certificate for this machine are
# generated into HOME/certs and mounted into the container, which keeps the
# data and the certificate across updates. `--no-https` serves plain HTTP.
set -Eeuo pipefail
CONTAINER="cobble"
@@ -77,6 +81,21 @@ Options:
--purge With --uninstall, also delete the data directory
--help Show this help
HTTPS:
It is on by default. A local CA and a certificate for this machine are
generated into HOME/certs. Trust the CA on each device first:
https://<this machine>:<port>/ca.crt, or HOME/certs/ca.crt.
iPhone and iPad install it in one tap from
https://<this machine>:<port>/ca.mobileconfig.
--no-https Serve plain HTTP (for a proxy or tunnel in front)
--https Serve HTTPS (the default; re-enables after --no-https)
--https-host VALUE A name or IP the certificate covers (repeatable),
e.g. a VPN's DNS name
--https-cert FILE Use your own certificate instead of the local CA
--https-key FILE Its private key (given with --https-cert)
--https-regen Replace the local CA and the certificate
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.
@@ -137,6 +156,11 @@ IMAGE="${COBBLE_IMAGE:-}"
VERSION="${COBBLE_VERSION:-}"
EXTRA_NETWORK="${COBBLE_EXTRA_NETWORK:-}"
DOCKER_SOCKET="${COBBLE_DOCKER_SOCKET:-${DOCKER_SOCKET:-/var/run/docker.sock}}"
# HTTPS is the default. The state file keeps a `--no-https` install off.
HTTPS_ENABLED="${COBBLE_HTTPS:-1}"
HTTPS_HOSTS="${COBBLE_HTTPS_HOSTS:-}"
HTTPS_CERT_SOURCE="${COBBLE_HTTPS_CERT:-}"
HTTPS_KEY_SOURCE="${COBBLE_HTTPS_KEY:-}"
ACTION="install"
FORCE=0
@@ -144,6 +168,14 @@ NO_PULL=0
NO_DOCKER_INSTALL=0
SKIP_BACKUP=0
PURGE=0
HTTPS_DISABLED=0
HTTPS_FORCE=0
HTTPS_HOSTS_GIVEN=0
HTTPS_CERT_GIVEN=0
HTTPS_REGEN=0
HTTPS_MANAGED=1
CERTS_CHANGED=0
CERT_EXPIRY=""
env_get() {
local key="$1"
@@ -191,6 +223,38 @@ while [ $# -gt 0 ]; do
EXTRA_NETWORK="${2:-}"
shift 2
;;
--https)
HTTPS_ENABLED=1
HTTPS_FORCE=1
shift
;;
--no-https)
HTTPS_DISABLED=1
shift
;;
--https-host)
if [ "$HTTPS_HOSTS_GIVEN" = 1 ]; then
HTTPS_HOSTS="$HTTPS_HOSTS ${2:-}"
else
HTTPS_HOSTS="${2:-}"
HTTPS_HOSTS_GIVEN=1
fi
shift 2
;;
--https-cert)
HTTPS_CERT_SOURCE="${2:-}"
HTTPS_CERT_GIVEN=1
shift 2
;;
--https-key)
HTTPS_KEY_SOURCE="${2:-}"
HTTPS_CERT_GIVEN=1
shift 2
;;
--https-regen)
HTTPS_REGEN=1
shift
;;
--force)
FORCE=1
shift
@@ -264,10 +328,45 @@ if [ "$PURGE" = 1 ] && [ "$ACTION" != "uninstall" ]; then
fail "--purge only works with --uninstall"
fi
if [ "$HTTPS_DISABLED" = 1 ]; then
[ "$HTTPS_FORCE" = 1 ] && fail "--https and --no-https cannot both be given"
if [ "$HTTPS_REGEN" = 1 ] || [ "$HTTPS_CERT_GIVEN" = 1 ] || [ "$HTTPS_HOSTS_GIVEN" = 1 ]; then
fail "--no-https cannot be combined with the other --https options"
fi
HTTPS_ENABLED=0
HTTPS_HOSTS=""
HTTPS_CERT_SOURCE=""
HTTPS_KEY_SOURCE=""
else
if [ "$HTTPS_FORCE" = 1 ] || [ "$HTTPS_REGEN" = 1 ] || [ "$HTTPS_CERT_GIVEN" = 1 ] || [ "$HTTPS_HOSTS_GIVEN" = 1 ]; then
HTTPS_ENABLED=1
fi
if [ -n "$HTTPS_CERT_SOURCE" ] || [ -n "$HTTPS_KEY_SOURCE" ]; then
[ -n "$HTTPS_CERT_SOURCE" ] && [ -n "$HTTPS_KEY_SOURCE" ] || fail "--https-cert and --https-key must be given together"
[ "$HTTPS_REGEN" = 1 ] && fail "--https-regen only works with the certificate the installer generates"
[ -f "$HTTPS_CERT_SOURCE" ] || fail "Certificate not found: $HTTPS_CERT_SOURCE"
[ -f "$HTTPS_KEY_SOURCE" ] || fail "Key not found: $HTTPS_KEY_SOURCE"
case "$HTTPS_CERT_SOURCE" in /*) ;; *) HTTPS_CERT_SOURCE="$PWD/$HTTPS_CERT_SOURCE" ;; esac
case "$HTTPS_KEY_SOURCE" in /*) ;; *) HTTPS_KEY_SOURCE="$PWD/$HTTPS_KEY_SOURCE" ;; esac
HTTPS_MANAGED=0
else
HTTPS_MANAGED=1
fi
fi
if [ -n "$HTTPS_HOSTS" ]; then
for https_host in $HTTPS_HOSTS; do
case "$https_host" in
*[!A-Za-z0-9.:_-]*) fail "--https-host is not a hostname or IP address: $https_host" ;;
esac
done
fi
ENV_FILE="$HOME_DIR/.env"
STATE_FILE="$HOME_DIR/install.conf"
DATA_DIR="${DATA_DIR%/}"
BACKUP_DIR="$HOME_DIR/backups"
CERTS_DIR="$HOME_DIR/certs"
# ---------------------------------------------------------------- platform
@@ -298,9 +397,19 @@ fi
ENV_MOUNT="$ENV_FILE:/app/.env:ro"
DATA_MOUNT="$DATA_DIR:/data"
CERTS_MOUNT="$CERTS_DIR:/certs"
# The app only needs the certificate, its key and the CA (for /ca.crt);
# the CA's own key stays out of the container.
CERT_MOUNT_RO="$CERTS_DIR/server.crt:/app/certs/server.crt:ro"
KEY_MOUNT_RO="$CERTS_DIR/server.key:/app/certs/server.key:ro"
CA_MOUNT_RO="$CERTS_DIR/ca.crt:/app/certs/ca.crt:ro"
if [ -n "$SELINUX_SUFFIX" ]; then
ENV_MOUNT="$ENV_FILE:/app/.env:ro,z"
DATA_MOUNT="$DATA_DIR:/data,z"
CERTS_MOUNT="$CERTS_DIR:/certs,z"
CERT_MOUNT_RO="$CERTS_DIR/server.crt:/app/certs/server.crt:ro,z"
KEY_MOUNT_RO="$CERTS_DIR/server.key:/app/certs/server.key:ro,z"
CA_MOUNT_RO="$CERTS_DIR/ca.crt:/app/certs/ca.crt:ro,z"
fi
# ---------------------------------------------------------------- helpers
@@ -332,6 +441,25 @@ ensure_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_scheme() {
if [ "$HTTPS_ENABLED" = 1 ]; then
printf 'https'
else
printf 'http'
fi
}
health_url() {
printf '%s://%s:%s/api/health' "$(health_scheme)" "$(health_host)" "$PORT"
}
set_env() {
local key="$1" value="$2" escaped
escaped="$(printf '%s' "$value" | sed -e 's/[\/&]/\\&/g')"
@@ -371,6 +499,10 @@ 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
@@ -386,6 +518,30 @@ container_matches() {
if [ -n "$EXTRA_NETWORK" ]; then
docker inspect -f '{{range $name, $net := .NetworkSettings.Networks}}{{println $name}}{{end}}' "$CONTAINER" | grep -qx "$EXTRA_NETWORK" || return 1
fi
if [ "$HTTPS_ENABLED" = 1 ]; then
[ "$(mounted_source /app/certs/server.crt)" = "$CERTS_DIR/server.crt" ] || return 1
[ "$(mounted_source /app/certs/server.key)" = "$CERTS_DIR/server.key" ] || return 1
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "TLS_CERT_FILE=/app/certs/server.crt" || return 1
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "TLS_KEY_FILE=/app/certs/server.key" || return 1
if [ "$HTTPS_MANAGED" = 1 ]; then
[ "$(mounted_source /app/certs/ca.crt)" = "$CERTS_DIR/ca.crt" ] || return 1
docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "TLS_CA_FILE=/app/certs/ca.crt" || return 1
else
if [ -n "$(mounted_source /app/certs/ca.crt)" ]; then
return 1
fi
if docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$CONTAINER" | grep -qx "TLS_CA_FILE=/app/certs/ca.crt"; then
return 1
fi
fi
else
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_CERT_FILE="; then
return 1
fi
fi
return 0
}
@@ -398,17 +554,18 @@ health_host() {
host_ips() {
if [ "$OS" = "Darwin" ]; then
ipconfig getifaddr en0 2>/dev/null || true
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 3 || true
hostname -I 2>/dev/null | tr ' ' '\n' | grep -vE '^$|^127\.' | head -n 5 || true
fi
}
wait_for_health() {
local url="http://$(health_host):$PORT/api/health" start=0
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
if curl -fsSk --max-time 3 -o /dev/null "$url" 2>/dev/null; then
ok "Cobble is healthy"
return 0
fi
@@ -423,6 +580,89 @@ wait_for_health() {
fail "Cobble did not become healthy within ${HEALTH_TIMEOUT_SECONDS}s"
}
validate_certs() {
local mount="$CERTS_DIR:/certs:ro"
local script out
if [ -n "$SELINUX_SUFFIX" ]; then
mount="$mount,z"
fi
script='set -e
for file in /certs/server.crt /certs/server.key; do
[ -s "$file" ] || { echo "$file is missing"; exit 1; }
done
cert_public="$(openssl x509 -noout -pubkey -in /certs/server.crt)"
key_public="$(openssl pkey -pubout -in /certs/server.key 2>/dev/null || openssl rsa -pubout -in /certs/server.key 2>/dev/null)"
[ -n "$cert_public" ] && [ "$cert_public" = "$key_public" ] || { echo "the certificate and key do not match"; exit 1; }
openssl x509 -noout -checkend 0 -in /certs/server.crt >/dev/null || { echo "the certificate has expired"; exit 1; }
if [ -f /certs/ca.crt ]; then
openssl verify -CAfile /certs/ca.crt /certs/server.crt >/dev/null || { echo "the certificate is not signed by the local CA"; exit 1; }
fi
openssl x509 -noout -enddate -in /certs/server.crt | sed -e "s/^notAfter=//"'
if ! out="$(docker run --rm -v "$mount" --entrypoint sh "$IMAGE:$VERSION" -c "$script" 2>&1)"; then
fail "The certificate can't be used: $(printf '%s\n' "$out" | tail -n 1)"
fi
CERT_EXPIRY="$(printf '%s\n' "$out" | tail -n 1)"
}
prepare_certs() {
CERTS_CHANGED=0
mkdir -p "$CERTS_DIR"
chmod 700 "$CERTS_DIR"
if [ "$HTTPS_MANAGED" = 0 ]; then
if [ ! -f "$CERTS_DIR/server.crt" ] || [ ! -f "$CERTS_DIR/server.key" ] ||
! cmp -s "$HTTPS_CERT_SOURCE" "$CERTS_DIR/server.crt" ||
! cmp -s "$HTTPS_KEY_SOURCE" "$CERTS_DIR/server.key"; then
CERTS_CHANGED=1
fi
cp -f "$HTTPS_CERT_SOURCE" "$CERTS_DIR/server.crt"
cp -f "$HTTPS_KEY_SOURCE" "$CERTS_DIR/server.key"
chmod 644 "$CERTS_DIR/server.crt"
chmod 600 "$CERTS_DIR/server.key"
rm -f "$CERTS_DIR/ca.crt" "$CERTS_DIR/ca.key" "$CERTS_DIR/ca.srl" "$CERTS_DIR/sans.txt"
validate_certs
ok "Using the certificate at $HTTPS_CERT_SOURCE"
return 0
fi
local args=(--out /certs --no-detect)
local ip name fqdn host out
for ip in $(host_ips); do
args+=(--host "$ip")
done
name="$(hostname 2>/dev/null || true)"
if [ -n "$name" ]; then
args+=(--host "$name" --host "$name.local")
fi
fqdn="$(hostname -f 2>/dev/null || true)"
if [ -n "$fqdn" ] && [ "$fqdn" != "$name" ]; then
args+=(--host "$fqdn")
fi
for host in $HTTPS_HOSTS; do
args+=(--host "$host")
done
if [ "$HTTPS_REGEN" = 1 ]; then
args+=(--regen)
fi
say "Preparing the HTTPS certificate"
if ! out="$(docker run --rm -v "$CERTS_MOUNT" --entrypoint bun "$IMAGE:$VERSION" src/scripts/generate-local-certs.ts "${args[@]}" 2>&1)"; then
printf '%s\n' "$out" >&2
fail "Could not generate the certificate. An older image has no certificate generator: pull the current image (drop --no-pull) and rerun."
fi
printf '%s\n' "$out" | sed -e 's/^/ /'
case "$out" in
*"certs: unchanged"*) CERTS_CHANGED=0 ;;
*) CERTS_CHANGED=1 ;;
esac
validate_certs
}
backup_database() {
local data_source="$DATA_DIR"
if container_exists; then
@@ -505,17 +745,39 @@ connect_extra_network() {
}
run_container() {
local mounts=(
-v "$ENV_MOUNT"
-v "$DATA_MOUNT"
-v "$DOCKER_SOCKET:/var/run/docker.sock"
)
local envs=(
-e MAIN_SERVER=1
-e "PORT=$PORT"
)
if [ "$HTTPS_ENABLED" = 1 ]; then
mounts+=(
-v "$CERT_MOUNT_RO"
-v "$KEY_MOUNT_RO"
)
envs+=(
-e TLS_CERT_FILE=/app/certs/server.crt
-e TLS_KEY_FILE=/app/certs/server.key
)
if [ "$HTTPS_MANAGED" = 1 ]; then
mounts+=(-v "$CA_MOUNT_RO")
envs+=(-e TLS_CA_FILE=/app/certs/ca.crt)
fi
fi
docker run -d \
--name "$CONTAINER" \
--restart unless-stopped \
--init \
--network "$NETWORK" \
-p "$BIND:$PORT:$PORT" \
-v "$ENV_MOUNT" \
-v "$DATA_MOUNT" \
-v "$DOCKER_SOCKET:/var/run/docker.sock" \
-e MAIN_SERVER=1 \
-e PORT="$PORT" \
"${mounts[@]}" \
"${envs[@]}" \
--log-driver json-file \
--log-opt max-size=10m \
--log-opt max-file=3 \
@@ -636,6 +898,20 @@ ensure_secret ENCRYPTION_KEY
ensure_secret ENCRYPTION_SALT
ensure_env PAGE_LASTMOD_FILE "/data/sitemap-lastmod.json"
if [ "$HTTPS_ENABLED" = 1 ]; then
set_env TLS_CERT_FILE /app/certs/server.crt
set_env TLS_KEY_FILE /app/certs/server.key
if [ "$HTTPS_MANAGED" = 1 ]; then
set_env TLS_CA_FILE /app/certs/ca.crt
else
unset_env TLS_CA_FILE
fi
else
unset_env TLS_CERT_FILE
unset_env TLS_KEY_FILE
unset_env TLS_CA_FILE
fi
{
printf 'COBBLE_DATA_DIR=%q\n' "$DATA_DIR"
printf 'COBBLE_PORT=%q\n' "$PORT"
@@ -644,6 +920,10 @@ ensure_env PAGE_LASTMOD_FILE "/data/sitemap-lastmod.json"
printf 'COBBLE_VERSION=%q\n' "$VERSION"
printf 'COBBLE_EXTRA_NETWORK=%q\n' "$EXTRA_NETWORK"
printf 'COBBLE_DOCKER_SOCKET=%q\n' "$DOCKER_SOCKET"
printf 'COBBLE_HTTPS=%q\n' "$HTTPS_ENABLED"
printf 'COBBLE_HTTPS_HOSTS=%q\n' "$HTTPS_HOSTS"
printf 'COBBLE_HTTPS_CERT=%q\n' "$HTTPS_CERT_SOURCE"
printf 'COBBLE_HTTPS_KEY=%q\n' "$HTTPS_KEY_SOURCE"
} >"$STATE_FILE"
chmod 600 "$STATE_FILE"
@@ -665,11 +945,15 @@ 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
if [ "$HTTPS_ENABLED" = 1 ]; then
prepare_certs
fi
if [ "$FORCE" = 0 ] && [ "$CERTS_CHANGED" = 0 ] && container_matches; then
connect_extra_network
if curl -fsS --max-time 3 -o /dev/null "http://$(health_host):$PORT/api/health" 2>/dev/null; then
if curl -fsSk --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"
say "Healthy at $(health_scheme)://$(health_host):$PORT"
exit 0
fi
warn "The container is running but not answering; recreating it"
@@ -689,16 +973,38 @@ connect_extra_network
wait_for_health
ok "Cobble is running"
SCHEME="$(health_scheme)"
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"
[ -n "$ip" ] && printf ' %s://%s:%s\n' "$SCHEME" "$ip" "$PORT"
done < <(host_ips)
fi
printf ' http://127.0.0.1:%s\n' "$PORT"
printf ' %s://127.0.0.1:%s\n' "$SCHEME" "$PORT"
printf '\n'
printf ' First visit: create the super admin account.\n'
if [ "$HTTPS_ENABLED" = 1 ]; then
printf ' Certificate\n'
if [ "$HTTPS_MANAGED" = 1 ]; then
printf ' Signed by a local CA on this machine. Trust it once on\n'
printf ' each device, then reload: the installed app and\n'
printf ' notifications need it.\n'
printf ' %s://<the address above>:%s/ca.crt\n' "$SCHEME" "$PORT"
printf ' %s://<the address above>:%s/ca.mobileconfig (iPhone/iPad)\n' "$SCHEME" "$PORT"
printf ' or copy %s\n' "$CERTS_DIR/ca.crt"
else
printf ' Using your certificate at %s\n' "$HTTPS_CERT_SOURCE"
fi
if [ -n "$CERT_EXPIRY" ]; then
printf ' Expires: %s\n' "$CERT_EXPIRY"
fi
printf '\n'
fi
if [ "$HTTPS_ENABLED" = 1 ] && [ "$HTTPS_MANAGED" = 1 ]; then
printf ' First visit: install the certificate, then create the super admin account.\n'
else
printf ' First visit: create the super admin account.\n'
fi
printf ' Data: %s\n' "$DATA_DIR"
printf ' Settings: %s\n' "$ENV_FILE"
printf ' Backups: %s\n' "$BACKUP_DIR"
@@ -707,5 +1013,9 @@ 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. Put HTTPS in front before exposing it to the internet.%s\n\n' "$C_DIM" "$C_RESET"
if [ "$HTTPS_ENABLED" = 1 ]; then
printf ' %sThis port is reachable on your network. A device that does not trust the certificate shows a warning. Put a proxy or tunnel in front before exposing it to the internet.%s\n\n' "$C_DIM" "$C_RESET"
else
printf ' %sThis port is reachable on your network. Put HTTPS in front before exposing it to the internet.%s\n\n' "$C_DIM" "$C_RESET"
fi
fi