feat(install): production-hardening for public self-hosting
- inject the git commit at build time so /api/instance/info advertises the exact source version on the public clone->install.sh path (AGPL \xc2\xa713), passed as --build-arg so it survives the sudo/non-sudo split - pass INSTANCE_NAME through the container env instead of interpolating it into node -e source; names with quotes/spaces/$ are stored verbatim and can't break or inject into the program - add a post-install, NAT-hairpin-safe HTTPS reachability check (curl --resolve to the local Caddy with full cert verification) and report an honest 'Live' vs 'Not live yet' status instead of always claiming success - list ALL required ports in the summary (80/443 + voice) with explicit host-firewall vs router port-forwarding guidance and the auto-detected LAN IP as the forward target; note 7880 must NOT be forwarded - document the non-interactive env vars (DOMAIN/ENABLE_VOICE/INSTANCE_NAME)
This commit is contained in:
+93
-15
@@ -7,7 +7,8 @@
|
|||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# ./install.sh Interactive setup
|
# ./install.sh Interactive setup
|
||||||
# DOMAIN=chat.example.com ./install.sh Non-interactive
|
# Non-interactive — set any/all of these to skip the matching prompt:
|
||||||
|
# DOMAIN=chat.example.com ENABLE_VOICE=true INSTANCE_NAME="My Chat" ./install.sh
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -365,8 +366,18 @@ fi
|
|||||||
|
|
||||||
step "Deploying Backspace"
|
step "Deploying Backspace"
|
||||||
|
|
||||||
info "Building Backspace image (this may take a few minutes on first run)..."
|
# AGPL-3.0 § 13 source offer: bake the running build's git commit into the image
|
||||||
$COMPOSE build --quiet
|
# so GET /api/instance/info advertises the exact source version. Passed straight
|
||||||
|
# to the build as --build-arg (survives the sudo/non-sudo $COMPOSE split, unlike
|
||||||
|
# an exported env var). Empty when this isn't a git checkout (e.g. tarball
|
||||||
|
# install) or git is unavailable → the server treats the commit as null.
|
||||||
|
BUILD_COMMIT="$(git rev-parse --short HEAD 2>/dev/null || echo '')"
|
||||||
|
if [[ -n "$BUILD_COMMIT" ]]; then
|
||||||
|
info "Building Backspace image from commit ${BUILD_COMMIT} (this may take a few minutes on first run)..."
|
||||||
|
else
|
||||||
|
info "Building Backspace image (this may take a few minutes on first run)..."
|
||||||
|
fi
|
||||||
|
$COMPOSE build --quiet --build-arg BACKSPACE_COMMIT="$BUILD_COMMIT"
|
||||||
|
|
||||||
info "Starting services..."
|
info "Starting services..."
|
||||||
$COMPOSE up -d
|
$COMPOSE up -d
|
||||||
@@ -392,13 +403,52 @@ fi
|
|||||||
# ── Phase 7: Set instance name ──────────────────────────────
|
# ── Phase 7: Set instance name ──────────────────────────────
|
||||||
|
|
||||||
if [[ "$healthy" == true && -n "$INSTANCE_NAME" && "$INSTANCE_NAME" != "Backspace" ]]; then
|
if [[ "$healthy" == true && -n "$INSTANCE_NAME" && "$INSTANCE_NAME" != "Backspace" ]]; then
|
||||||
$DOCKER exec -w /app/packages/server backspace node -e "
|
# Pass the name through the container environment (never string-interpolated
|
||||||
const Database = require('better-sqlite3');
|
# into the JS source) so names with quotes/spaces/'$' are stored verbatim and
|
||||||
const db = new Database('/app/data/backspace.db');
|
# can't break or inject into the node -e program.
|
||||||
const changes = db.prepare('UPDATE instance_settings SET instance_name = ? WHERE id = 1').run('${INSTANCE_NAME}').changes;
|
$DOCKER exec -e BS_INSTANCE_NAME="$INSTANCE_NAME" -w /app/packages/server backspace node -e '
|
||||||
|
const Database = require("better-sqlite3");
|
||||||
|
const db = new Database("/app/data/backspace.db");
|
||||||
|
const changes = db.prepare("UPDATE instance_settings SET instance_name = ? WHERE id = 1").run(process.env.BS_INSTANCE_NAME).changes;
|
||||||
db.close();
|
db.close();
|
||||||
if (changes === 0) { console.error('No rows updated'); process.exit(1); }
|
if (changes === 0) { console.error("No rows updated"); process.exit(1); }
|
||||||
" 2>/dev/null && success "Instance name set to: ${INSTANCE_NAME}" || warn "Could not set instance name (set it manually in admin settings)"
|
' 2>/dev/null && success "Instance name set to: ${INSTANCE_NAME}" || warn "Could not set instance name (set it manually in admin settings)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Phase 7.5: Verify HTTPS reachability ───────────────────
|
||||||
|
# The internal healthcheck only proves the app is up *inside* Docker. What the
|
||||||
|
# operator actually cares about is whether https://DOMAIN works — which needs
|
||||||
|
# Caddy to have obtained a publicly-trusted TLS certificate, and that only
|
||||||
|
# happens once DNS points here AND ports 80/443 are reachable from the internet.
|
||||||
|
#
|
||||||
|
# We test this hairpin-safely with `curl --resolve DOMAIN:443:127.0.0.1`: it
|
||||||
|
# connects to the LOCAL Caddy but presents the real SNI/Host and performs full
|
||||||
|
# certificate verification. Success means Caddy is serving a valid, publicly-
|
||||||
|
# trusted certificate for DOMAIN *and* the app answers over it — which is only
|
||||||
|
# possible once issuance has succeeded. Crucially this avoids a false negative
|
||||||
|
# on self-hosted boxes that can't reach their own public address (router NAT
|
||||||
|
# hairpin), where a plain external request would time out even though the site
|
||||||
|
# is perfectly reachable for everyone else.
|
||||||
|
|
||||||
|
https_status="skipped"
|
||||||
|
if [[ "$healthy" == true ]]; then
|
||||||
|
step "Verifying HTTPS"
|
||||||
|
https_status="pending"
|
||||||
|
|
||||||
|
info "Checking for a valid TLS certificate on ${DOMAIN} (Caddy issues it on first start)..."
|
||||||
|
for i in $(seq 1 15); do
|
||||||
|
if curl -fsS --max-time 6 --resolve "${DOMAIN}:443:127.0.0.1" "https://${DOMAIN}/api/health" >/dev/null 2>&1; then
|
||||||
|
https_status="live"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ "$https_status" == "live" ]]; then
|
||||||
|
success "HTTPS is live — a valid TLS certificate is installed and Backspace is serving over it."
|
||||||
|
else
|
||||||
|
warn "HTTPS is not live yet — Caddy hasn't obtained a publicly-trusted certificate."
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Phase 8: Summary ───────────────────────────────────────
|
# ── Phase 8: Summary ───────────────────────────────────────
|
||||||
@@ -408,8 +458,23 @@ step "Backspace is running"
|
|||||||
echo -e " ${BOLD}URL:${NC} https://${DOMAIN}"
|
echo -e " ${BOLD}URL:${NC} https://${DOMAIN}"
|
||||||
echo -e " ${BOLD}Instance:${NC} ${INSTANCE_NAME}"
|
echo -e " ${BOLD}Instance:${NC} ${INSTANCE_NAME}"
|
||||||
echo -e " ${BOLD}Voice:${NC} $(if [[ "$ENABLE_VOICE" == true ]]; then echo 'Enabled'; else echo 'Disabled'; fi)"
|
echo -e " ${BOLD}Voice:${NC} $(if [[ "$ENABLE_VOICE" == true ]]; then echo 'Enabled'; else echo 'Disabled'; fi)"
|
||||||
|
case "$https_status" in
|
||||||
|
live) echo -e " ${BOLD}HTTPS:${NC} ${GREEN}Live${NC}" ;;
|
||||||
|
pending) echo -e " ${BOLD}HTTPS:${NC} ${YELLOW}Not live yet${NC}" ;;
|
||||||
|
esac
|
||||||
echo ""
|
echo ""
|
||||||
echo -e " ${YELLOW}Open https://${DOMAIN} and create the first account — it becomes the instance admin.${NC}"
|
|
||||||
|
if [[ "$https_status" == "pending" ]]; then
|
||||||
|
echo -e " ${YELLOW}The app is up, but HTTPS isn't live yet — Caddy is still trying to get a certificate.${NC}"
|
||||||
|
echo -e " ${YELLOW}This is normal right after install; it comes up automatically once BOTH are true:${NC}"
|
||||||
|
echo " 1. ${DOMAIN} resolves to THIS host's public IP"
|
||||||
|
echo " 2. Ports 80 and 443 are open and forwarded to this host from the internet"
|
||||||
|
echo -e " Watch progress: ${BOLD}docker compose logs -f caddy${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e " ${YELLOW}Then open https://${DOMAIN} and create the first account — it becomes the instance admin.${NC}"
|
||||||
|
else
|
||||||
|
echo -e " ${YELLOW}Open https://${DOMAIN} and create the first account — it becomes the instance admin.${NC}"
|
||||||
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
echo -e " ${BOLD}Commands:${NC}"
|
echo -e " ${BOLD}Commands:${NC}"
|
||||||
echo " docker compose logs -f # Watch logs"
|
echo " docker compose logs -f # Watch logs"
|
||||||
@@ -417,12 +482,25 @@ echo " docker compose restart # Restart all services"
|
|||||||
echo " docker compose down # Stop everything"
|
echo " docker compose down # Stop everything"
|
||||||
echo " docker compose up -d --build # Rebuild after code changes"
|
echo " docker compose up -d --build # Rebuild after code changes"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
# Best-effort primary LAN IP (the address a router would port-forward to).
|
||||||
|
LAN_IP=$(ip route get 1.1.1.1 2>/dev/null | grep -oP 'src \K[0-9.]+' | head -1)
|
||||||
|
echo -e " ${BOLD}Ports to open${NC} — on this host's firewall (ufw / firewalld / cloud"
|
||||||
|
echo -e " security group)${BOLD} and,${NC} if the host is behind a router, also"
|
||||||
|
echo -e " port-forward them to this host${LAN_IP:+ (${LAN_IP})}:"
|
||||||
|
echo ""
|
||||||
|
echo " 80/TCP HTTP — cert challenge + HTTP→HTTPS redirect (required)"
|
||||||
|
echo " 443/TCP HTTPS — web app, API, WebSocket, LiveKit signal (required)"
|
||||||
if [[ "$ENABLE_VOICE" == true ]]; then
|
if [[ "$ENABLE_VOICE" == true ]]; then
|
||||||
echo ""
|
echo " 3478/UDP TURN — WebRTC NAT traversal (voice)"
|
||||||
echo -e " ${BOLD}Firewall — open these ports for voice/video:${NC}"
|
echo " 7881/TCP WebRTC TCP fallback (voice)"
|
||||||
echo " 3478/UDP TURN (NAT traversal)"
|
echo " 50000-60000/UDP WebRTC media — voice / video / screen-share (voice)"
|
||||||
echo " 7881/TCP WebRTC TCP fallback"
|
|
||||||
echo " 50000-60000/UDP WebRTC media"
|
|
||||||
fi
|
fi
|
||||||
|
echo ""
|
||||||
|
echo -e " ${YELLOW}80 and 443 must be reachable from the internet before HTTPS can come up.${NC}"
|
||||||
|
if [[ "$ENABLE_VOICE" == true ]]; then
|
||||||
|
echo -e " ${YELLOW}Voice/video won't connect until the voice ports above are reachable too.${NC}"
|
||||||
|
fi
|
||||||
|
echo -e " LiveKit's own signaling port (7880) stays internal — do ${BOLD}not${NC} forward it."
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
Reference in New Issue
Block a user