#!/usr/bin/env bash # # setup.sh — one-shot provisioner for an AgentBBS host (Ubuntu droplet). # # Brings a fresh box to: agentbbs on :22 (so `ssh join@bbs.profullstack.com` # works with no -p), the admin OpenSSH moved to :2202, rootless podman for # pods, a persistent SSH host key + sqlite store, and a Caddy front end serving # https://bbs.profullstack.com plus tilde.town-style /~user homepages. # # It is idempotent — safe to re-run to update (it pulls + rebuilds + restarts). # # sudo ./setup.sh # # Override any default via env, e.g.: # sudo DOMAIN=bbs.example.com ADMIN_SSH_PORT=2222 ./setup.sh # # ⚠️ The admin OpenSSH port changes to ADMIN_SSH_PORT. The script verifies the # new port is listening BEFORE handing :22 to agentbbs and never drops your # current session — but open a SECOND terminal and confirm # `ssh -p @` works before you log out. set -euo pipefail # ---- config (override via env) --------------------------------------------- DOMAIN="${DOMAIN:-bbs.profullstack.com}" ADMIN_SSH_PORT="${ADMIN_SSH_PORT:-2202}" ACME_EMAIL="${ACME_EMAIL:-admin@profullstack.com}" SVC_USER="${SVC_USER:-agentbbs}" REPO="${REPO:-https://github.com/profullstack/agentbbs.git}" BRANCH="${BRANCH:-main}" SRC_DIR="${SRC_DIR:-/opt/agentbbs}" DATA_DIR="${DATA_DIR:-/var/lib/agentbbs}" ASK_ADDR="${ASK_ADDR:-127.0.0.1:8081}" # agentbbs on-demand-TLS ask endpoint (must match agentbbs.env) HTTP_ADDR="${HTTP_ADDR:-127.0.0.1:8088}" # agentbbs /verify endpoint (join@ email confirmation links) GO_VERSION="${GO_VERSION:-1.26.4}" POD_IMAGE="${POD_IMAGE:-docker.io/library/ubuntu:24.04}" FETCH_ASSETS="${FETCH_ASSETS:-1}" # set 0 to skip the DOOM/Freedoom arcade assets SKIP_BUILD="${SKIP_BUILD:-0}" # set 1 to use prebuilt /usr/local/bin/{agentbbs,ascii-live} (tiny droplets can't compile) SWAP_SIZE="${SWAP_SIZE:-3G}" # swapfile size added on low-RAM hosts (set 0 to skip) SELF_UPDATE="${SELF_UPDATE:-1}" # set 0 to skip the autonomous self-update systemd timer SELF_UPDATE_INTERVAL="${SELF_UPDATE_INTERVAL:-15min}" # how often the box polls origin for new commits IRC="${IRC:-1}" # set 0 to skip the co-located Ergo IRC server (${IRC_DOMAIN}) IRC_DOMAIN="${IRC_DOMAIN:-irc.${DOMAIN#*.}}" # IRC host (default: irc., e.g. irc.profullstack.com) NEWS="${NEWS:-1}" # set 0 to skip the co-located Usenet/NNTP server (news.${DOMAIN}) ERGO_VERSION="${ERGO_VERSION:-2.18.0}" # Ergo IRCd release to install IRC_NETWORK="${IRC_NETWORK:-ProfullstackBBS}" # IRC network name shown to clients ERGO_DATA="${ERGO_DATA:-/var/lib/ergo}" # Ergo state dir (ircd.db, tls/) FORGEJO="${FORGEJO:-1}" # set 0 to skip the AgentGit Forgejo backend (git.${DOMAIN#*.}) GIT_DOMAIN="${GIT_DOMAIN:-git.${DOMAIN#*.}}" # AgentGit host (default: git., e.g. git.profullstack.com) FORGEJO_VERSION="${FORGEJO_VERSION:-11.0.1}" # Forgejo release to install MAIL="${MAIL:-1}" # set 0 to skip the co-located Mailu mail stack (mail.${DOMAIN#*.}) MAIL_DOMAIN="${MAIL_DOMAIN:-mail.${DOMAIN#*.}}" # mail host (default: mail., e.g. mail.profullstack.com) FORGEJO_HTTP_ADDR="${FORGEJO_HTTP_ADDR:-127.0.0.1:3000}" # Forgejo loopback HTTP (Caddy fronts it) FORGEJO_DATA="${FORGEJO_DATA:-/var/lib/forgejo}" # Forgejo state dir (repos, db) FORGEJO_ADMIN_USER="${FORGEJO_ADMIN_USER:-agentgit-admin}" # Forgejo admin used to provision members log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; } warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*" >&2; } die() { printf '\033[1;31m[fail]\033[0m %s\n' "$*" >&2; exit 1; } # ensure_swap adds a swapfile on low-RAM hosts so the Go build (and runtime) # aren't OOM-killed. No-op when swap already exists, RAM is ample, or SWAP_SIZE=0. ensure_swap() { [ "${SWAP_SIZE:-0}" = "0" ] && return 0 [ "$(swapon --show=NAME --noheadings 2>/dev/null | wc -l)" -gt 0 ] && return 0 local kb; kb=$(awk '/^MemTotal:/{print $2}' /proc/meminfo) [ "${kb:-0}" -ge 3000000 ] && return 0 # >= ~3GB RAM: skip log "low RAM ($(( ${kb:-0}/1024 ))MB) — adding ${SWAP_SIZE} swap at /swapfile" if [ ! -f /swapfile ]; then fallocate -l "$SWAP_SIZE" /swapfile 2>/dev/null \ || dd if=/dev/zero of=/swapfile bs=1M count="$(numfmt --from=iec "$SWAP_SIZE" | awk '{print int($1/1048576)}')" status=none chmod 600 /swapfile mkswap /swapfile >/dev/null fi swapon /swapfile 2>/dev/null || true grep -q '^/swapfile ' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab } [ "$(id -u)" -eq 0 ] || die "run as root (sudo ./setup.sh)" # Serialize runs. A CI deploy (ssh -> setup.sh) and the self-update timer can # fire close together; two concurrent git-reset + go-build runs would corrupt # each other. Hold an exclusive lock for the whole run (wait up to 5 min). if command -v flock >/dev/null; then exec 9>/var/lock/agentbbs-setup.lock flock -w 300 9 || die "another setup.sh run is in progress (lock held >5m)" fi . /etc/os-release 2>/dev/null || true [ "${ID:-}" = "ubuntu" ] || warn "tested on Ubuntu; ${ID:-unknown} may differ" case "$(uname -m)" in x86_64|amd64) GOARCH=amd64 ;; aarch64|arm64) GOARCH=arm64 ;; *) die "unsupported arch $(uname -m)" ;; esac # ---- 1. packages ----------------------------------------------------------- log "installing packages" export DEBIAN_FRONTEND=noninteractive apt-get update -qq apt-get install -y -qq \ git ca-certificates curl ufw ffmpeg unzip jq \ podman uidmap slirp4netns fuse-overlayfs \ tor torsocks \ debian-keyring debian-archive-keyring apt-transport-https >/dev/null # Tor SOCKS proxy for the tor-url@/tor@/tor-irc@ routes. Ships listening on # 127.0.0.1:9050 by default; keep it loopback-only (never expose it). log "enabling tor (SOCKS 127.0.0.1:9050)" systemctl enable --now tor >/dev/null 2>&1 || warn "tor service not enabled — tor-url@ will be unavailable" # yt-dlp from pip is fresher than apt; fall back to apt if pip is unavailable. if ! command -v yt-dlp >/dev/null; then log "installing yt-dlp" apt-get install -y -qq python3-pip >/dev/null pip3 install --quiet --break-system-packages -U yt-dlp 2>/dev/null \ || apt-get install -y -qq yt-dlp >/dev/null \ || warn "yt-dlp not installed — YouTube sources will fail until it is" fi # ---- 2. Go toolchain (system go is too old; pin GO_VERSION) ----------------- # Skipped entirely when SKIP_BUILD=1: the CI deploy builds the binaries on the # runner and ships them, so the droplet needs no Go toolchain at all. GO_ROOT="/usr/local/go" if [ "$SKIP_BUILD" != "1" ] && \ [ "$("$GO_ROOT/bin/go" version 2>/dev/null | awk '{print $3}')" != "go${GO_VERSION}" ]; then log "installing Go ${GO_VERSION}" tmp="$(mktemp -d)" curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-${GOARCH}.tar.gz" -o "$tmp/go.tgz" \ || die "could not download Go ${GO_VERSION}" rm -rf "$GO_ROOT" tar -C /usr/local -xzf "$tmp/go.tgz" rm -rf "$tmp" fi export PATH="$GO_ROOT/bin:$PATH" # Persistent, root-owned Go caches. Two reasons this matters on this box: # 1. The self-update timer runs setup.sh as a systemd oneshot with no $HOME, so # a bare `go build` aborts with "module cache not found: neither GOMODCACHE # nor GOPATH is set" before it compiles a thing. Setting these explicitly # (not relying on $HOME) fixes timer-driven redeploys. # 2. A warm cache means an incremental redeploy recompiles only what changed, so # the 458MB droplet stops OOM-killing the compiler on every build. Combined # with GOMAXPROCS=1 + `go build -p=1`, peak memory stays within RAM+swap. export HOME="${HOME:-/root}" export GOPATH="${GOPATH:-/var/cache/agentbbs/go}" export GOMODCACHE="${GOMODCACHE:-$GOPATH/pkg/mod}" export GOCACHE="${GOCACHE:-/var/cache/agentbbs/go-build}" export GOMAXPROCS=1 mkdir -p "$GOPATH" "$GOCACHE" # ---- 3. service user + rootless podman prerequisites ----------------------- if ! id "$SVC_USER" >/dev/null 2>&1; then log "creating service user $SVC_USER" useradd --system --create-home --home-dir "/home/$SVC_USER" --shell /usr/sbin/nologin "$SVC_USER" fi SVC_UID="$(id -u "$SVC_USER")" # subuid/subgid ranges let rootless podman build user namespaces for pods. grep -q "^${SVC_USER}:" /etc/subuid || usermod --add-subuids 100000-165535 "$SVC_USER" grep -q "^${SVC_USER}:" /etc/subgid || usermod --add-subgids 100000-165535 "$SVC_USER" # linger keeps /run/user/$UID alive so podman works from a system service with # no interactive login. loginctl enable-linger "$SVC_USER" >/dev/null 2>&1 || true # ---- 4. persistent data dir (host key + sqlite + per-user public_html) ------ log "preparing $DATA_DIR" install -d -o "$SVC_USER" -g "$SVC_USER" -m 0751 "$DATA_DIR" # others may traverse, not list install -d -o "$SVC_USER" -g "$SVC_USER" -m 0700 "$DATA_DIR/ssh" # host key stays private install -d -o "$SVC_USER" -g "$SVC_USER" -m 0755 "$DATA_DIR/users" # tilde homepages live here install -d -o "$SVC_USER" -g "$SVC_USER" -m 0755 "$DATA_DIR/web" # site root install -d -o "$SVC_USER" -g "$SVC_USER" -m 0755 "$DATA_DIR/domains" # symlink farm: custom domain -> users//public_html [ -f "$DATA_DIR/web/index.html" ] || cat > "$DATA_DIR/web/index.html" <AgentBBS

AgentBBS

A BBS over SSH for humans and AI agents.

  ssh join@${DOMAIN}     # register your key, get started
  ssh bbs@${DOMAIN}      # look around as a guest
  ssh pod@${DOMAIN}      # your personal Linux pod (\$1/mo)

User homepages live at /~name — and members can point their own domain at one (ssh domain@${DOMAIN} add yourdomain.com).

HTML chown "$SVC_USER:$SVC_USER" "$DATA_DIR/web/index.html" # ---- 5. clone/update + build agentbbs -------------------------------------- if [ -d "$SRC_DIR/.git" ]; then log "updating source in $SRC_DIR to origin/$BRANCH" # Hard reset (not pull --ff-only) so an automated deploy survives a force-push # or any local drift on the box — the box always matches origin exactly. git -C "$SRC_DIR" fetch --depth 1 origin "$BRANCH" git -C "$SRC_DIR" reset --hard "origin/$BRANCH" else log "cloning $REPO ($BRANCH)" git clone --depth 1 -b "$BRANCH" "$REPO" "$SRC_DIR" fi if [ "$FETCH_ASSETS" = "1" ] && [ -x "$SRC_DIR/scripts/fetch-assets.sh" ]; then log "fetching arcade assets (set FETCH_ASSETS=0 to skip)" ( cd "$SRC_DIR" && ./scripts/fetch-assets.sh ) || warn "asset fetch failed; arcade may be limited" fi # Add swap on tiny droplets before the build (and for runtime headroom). ensure_swap if [ "$SKIP_BUILD" = "1" ]; then log "SKIP_BUILD=1 — using prebuilt /usr/local/bin/{agentbbs,ascii-live}" [ -x /usr/local/bin/agentbbs ] || die "SKIP_BUILD=1 but /usr/local/bin/agentbbs is missing — copy it first" else log "building binaries (go build -p=1 to cap peak memory)" ( cd "$SRC_DIR" && go build -p=1 -o /usr/local/bin/agentbbs ./cmd/agentbbs ) ( cd "$SRC_DIR" && go build -p=1 -o /usr/local/bin/ascii-live ./cmd/ascii-live ) fi # Pre-pull the pod base image as the service user so first pod launch is fast. sudo -u "$SVC_USER" XDG_RUNTIME_DIR="/run/user/$SVC_UID" \ podman pull -q "$POD_IMAGE" >/dev/null 2>&1 || warn "could not pre-pull $POD_IMAGE (pods will pull on first use)" # ---- 6. environment file --------------------------------------------------- ENV_DIR=/etc/agentbbs install -d -m 0750 "$ENV_DIR" if [ ! -f "$ENV_DIR/agentbbs.env" ]; then log "writing $ENV_DIR/agentbbs.env (fill in CoinPay/LiveKit before relying on pods/video)" cat > "$ENV_DIR/agentbbs.env" <. # Premium \$10 one-time, lifetime — a personal @${DOMAIN} email # (forwardemail.net) plus custom domains (ssh domain@). Offered at join@. # Premium payments hit the CoinPay REST API directly (no coinpay CLI needed): # join@ creates a charge and shows the amount + deposit address; a later connect # verifies settlement by payment id. COINPAY_API_KEY + the merchant id are # injected by the CI deploy from GitHub secrets (COINPAY_API_KEY / # COINPAY_MERCHANT_ID); set them here directly when provisioning by hand: # COINPAY_API_KEY=cp_live_xxx # AGENTBBS_COINPAY_MERCHANT_ID= # AGENTBBS_COINPAY_API_URL=https://coinpayportal.com/api # AGENTBBS_PREMIUM_AMOUNT=10 # AGENTBBS_PREMIUM_CURRENCY=USD # AGENTBBS_PREMIUM_BLOCKCHAIN=eth # Premium email aliases (@${DOMAIN}) auto-created on forwardemail.net. # Without an API key the address is shown but not created (add it manually). # AGENTBBS_FORWARDEMAIL_API_KEY= # AGENTBBS_FORWARDEMAIL_DOMAIN=${DOMAIN} # AGENTBBS_WEBMAIL_URL=https://webmail.${DOMAIN} # AgentGit (git.profullstack.com): every verified member — free and paid alike — # is provisioned a Forgejo account when they confirm their email. The admin token # is generated and filled in by setup.sh's Forgejo section (§9d). Without it, # provisioning is a silent no-op. See docs (logicsrc plugins/agentgit). AGENTBBS_FORGEJO_URL=https://${GIT_DOMAIN} AGENTBBS_FORGEJO_ADMIN_TOKEN= # PairUX video calls rendered as ASCII (video@ / tv@ PairUX sources): # AGENTBBS_LIVEKIT_URL= # AGENTBBS_LIVEKIT_KEY= # AGENTBBS_LIVEKIT_SECRET= # Agent chat backend (agent@), stdin->stdout, e.g. "claude -p": # AGENTBBS_AGENT_CMD= # qrypt.chat anonymous-invite issuer (docs/qrypt-invites.md). Members mint a # signed single-use token here that qrypt.chat redeems into an anon account. # Run \`agentbbs qrypt-issuer-keygen\` once: paste the seed below, register the # public key in qrypt.chat's invite_issuers row. Without the key, minting is off. # AGENTBBS_QRYPT_ISSUER_KEY= # AGENTBBS_QRYPT_ISSUER_ID=agentbbs # AGENTBBS_QRYPT_INVITE_TTL=168h # AGENTBBS_QRYPT_REDEEM_URL=https://qrypt.chat/anon?invite= # AGENTBBS_QRYPT_INVITE_QUOTA=5 # News (Usenet/NNTP) server (docs/news.md). Members-only — free and paid alike, # like the IRC network. The in-BBS reader is \`ssh news@${DOMAIN}\`; external # newsreaders and agents connect over NNTPS at news.${DOMAIN}:563. The TLS cert # paths below are populated by setup.sh from Caddy's news.${DOMAIN} cert; the # server re-reads them within 30s of renewal (no restart needed). Set AGENTBBS_NEWS=0 # to disable. AGENTBBS_NEWS_GROUPS seeds groups ("name:desc" comma-separated); # empty seeds pfs.announce/general/agents/support. AGENTBBS_NEWS_HOST=news.${DOMAIN} AGENTBBS_NEWS_TLS_CERT=${DATA_DIR}/news-tls/fullchain.pem AGENTBBS_NEWS_TLS_KEY=${DATA_DIR}/news-tls/privkey.pem # AGENTBBS_NEWS=1 # AGENTBBS_NEWS_ADDR=127.0.0.1:1119 # AGENTBBS_NEWS_TLS_ADDR=:563 # AGENTBBS_NEWS_GROUPS=pfs.announce:Announcements,pfs.general:General,pfs.agents:Agents ENV chmod 0640 "$ENV_DIR/agentbbs.env" fi # Idempotently upsert secrets passed in the environment (e.g. by the CI deploy # from GitHub Actions secrets) into agentbbs.env, preserving everything else. # Secrets are never committed — they live only here and in encrypted CI storage. upsert_env() { # KEY VALUE — skips when VALUE is empty local key="$1" val="$2" file="$ENV_DIR/agentbbs.env" [ -n "$val" ] || return 0 touch "$file" if grep -qE "^${key}=" "$file"; then # Replace in place (| delimiter avoids clashes with / or & in the value). sed -i "s|^${key}=.*|${key}=${val}|" "$file" else printf '%s=%s\n' "$key" "$val" >> "$file" fi chmod 0640 "$file" } # CoinPay: API key (read by the coinpay CLI) + merchant/business id. upsert_env COINPAY_API_KEY "${COINPAY_API_KEY:-}" upsert_env AGENTBBS_COINPAY_MERCHANT_ID "${COINPAY_MERCHANT_ID:-${AGENTBBS_COINPAY_MERCHANT_ID:-}}" upsert_env COINPAY_BUSINESS_ID "${COINPAY_MERCHANT_ID:-${AGENTBBS_COINPAY_MERCHANT_ID:-}}" # qrypt.chat invite issuer: Ed25519 seed (minting is disabled without it). upsert_env AGENTBBS_QRYPT_ISSUER_KEY "${AGENTBBS_QRYPT_ISSUER_KEY:-}" # ---- 7. systemd unit (runs as $SVC_USER, binds :22 via ambient capability) -- log "installing agentbbs.service" cat > /etc/systemd/system/agentbbs.service < /etc/systemd/system/agentbbs-update.service < /etc/systemd/system/agentbbs-update.timer </dev/null 2>&1 || true else systemctl disable --now agentbbs-update.timer >/dev/null 2>&1 || true rm -f /etc/systemd/system/agentbbs-update.service /etc/systemd/system/agentbbs-update.timer systemctl daemon-reload fi # ---- 8. move admin OpenSSH to ADMIN_SSH_PORT (before agentbbs takes :22) ----- log "moving admin OpenSSH to :${ADMIN_SSH_PORT}" install -d -m 0755 /etc/ssh/sshd_config.d cat > /etc/ssh/sshd_config.d/10-agentbbs-admin.conf <@host Port ${ADMIN_SSH_PORT} SSHD # Open the new admin port FIRST so the upcoming firewall enable can't lock us out. ufw allow "${ADMIN_SSH_PORT}/tcp" >/dev/null sshd -t || die "sshd config test failed; not restarting (you are not locked out)" # Ubuntu 22.10+/24.04 socket-activate sshd via ssh.socket, which OWNS the listen # port and ignores sshd_config's Port. Override the socket's ListenStream so the # admin port actually moves; otherwise (classic sshd) just restart the service. if systemctl cat ssh.socket >/dev/null 2>&1; then log "ssh is socket-activated (Ubuntu 24.04) — moving the socket to :${ADMIN_SSH_PORT}" install -d -m 0755 /etc/systemd/system/ssh.socket.d cat > /etc/systemd/system/ssh.socket.d/10-agentbbs-port.conf </dev/null || systemctl restart sshd fi # Verify the admin port is actually listening before we free :22. for _ in 1 2 3 4 5 6 7 8; do ss -tlnp 2>/dev/null | grep -q ":${ADMIN_SSH_PORT} " && break sleep 1 done ss -tlnp 2>/dev/null | grep -q ":${ADMIN_SSH_PORT} " \ || die "admin sshd is NOT listening on ${ADMIN_SSH_PORT} — aborting before touching :22. Your current session is still up; fix sshd and re-run." # ---- 9. Caddy front end (HTTPS + tilde /~user homepages) -------------------- if ! command -v caddy >/dev/null; then log "installing Caddy" curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \ | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \ > /etc/apt/sources.list.d/caddy-stable.list apt-get update -qq && apt-get install -y -qq caddy >/dev/null fi # Let Caddy (user 'caddy') read the per-user public_html trees. usermod -aG "$SVC_USER" caddy 2>/dev/null || true log "writing Caddyfile" # Caddy indexes host labels from the right (com=0, profullstack=1, bbs=2, …); # the user subdomain is the next label left, i.e. index = DOMAIN's label count. USER_LABEL_IDX=$(printf '%s' "$DOMAIN" | awk -F. '{print NF}') # A dedicated site for news.${DOMAIN} so Caddy obtains a real LE cert for that # hostname (the agentbbs NNTP server reuses it for NNTPS on :563 — see §9c). # Needs a DNS A record news.${DOMAIN} -> this host. The site itself just shows a # connect hint; the Usenet protocol is on :563, not HTTP. Omitted when NEWS=0. # AgentGit: front the loopback Forgejo backend at https://${GIT_DOMAIN}. Needs a # DNS A record git. -> this host. Omitted when FORGEJO=0. Forgejo enforces # its own members-only access; agentbbs provisions the accounts (§9d). GIT_SITE="" if [ "$FORGEJO" = "1" ]; then GIT_SITE=" ${GIT_DOMAIN} { encode zstd gzip reverse_proxy http://${FORGEJO_HTTP_ADDR} } " fi NEWS_SITE="" if [ "$NEWS" = "1" ]; then NEWS_SITE=" news.${DOMAIN} { encode zstd gzip header Content-Type \"text/plain; charset=utf-8\" respond \"AgentBBS Usenet (members-only). Point a newsreader at news.${DOMAIN}:563 over NNTPS and AUTHINFO USER (any password). Or from the BBS: ssh -t news@${DOMAIN}\" } " fi # IRC site (${IRC_DOMAIN}). Caddy serving this host means it obtains a Let's # Encrypt cert for it — which ergo-refresh-certs copies into Ergo for 6697 TLS. # It also fronts the same loopback WebSocket, so wss://${IRC_DOMAIN}/irc works # (alongside wss://${DOMAIN}/irc). Needs an A record ${IRC_DOMAIN} -> this host. IRC_SITE="" if [ "$IRC" = "1" ]; then IRC_SITE=" ${IRC_DOMAIN} { encode zstd gzip handle /irc { reverse_proxy 127.0.0.1:8097 } handle { header Content-Type \"text/plain; charset=utf-8\" respond \"AgentBBS IRC (members-only). Native: ${IRC_DOMAIN}:6697 (TLS), SASL as your BBS name (any password). Web/agents: wss://${IRC_DOMAIN}/irc\" } } " fi # Mail site (${MAIL_DOMAIN}): Caddy serving this host obtains the LE cert that # Mailu reuses for SMTP/IMAP TLS (deploy/mailu/refresh-certs.sh copies it). It # also fronts the loopback Roundcube webmail. Needs A record ${MAIL_DOMAIN} -> # this host. Webmail is the only member-facing mail surface. Omitted when MAIL=0. MAIL_SITE="" if [ "$MAIL" = "1" ]; then MAIL_SITE=" ${MAIL_DOMAIN} { encode zstd gzip reverse_proxy 127.0.0.1:8080 } " fi cat > /etc/caddy/Caddyfile < users/name/public_html/path @tilde path_regexp tilde ^/~([^/]+)(/.*)?\$ handle @tilde { root * ${DATA_DIR}/users rewrite * /{re.tilde.1}/public_html{re.tilde.2} try_files {path} {path}/index.html file_server browse } # site root handle { root * ${DATA_DIR}/web file_server } } ${GIT_SITE}${NEWS_SITE}${IRC_SITE}${MAIL_SITE} # Free per-user homepages at .${DOMAIN} (needs wildcard DNS # *.${DOMAIN} -> this host). On-demand TLS mints a cert only when agentbbs's # ask endpoint confirms is a registered member, so random subdomains # can't trigger certificate issuance. *.${DOMAIN} { encode zstd gzip tls { on_demand } # Only registered members get an on-demand cert (the ask endpoint gates it), # so {labels.${USER_LABEL_IDX}} is always a real user's public_html. root * ${DATA_DIR}/users/{http.request.host.labels.${USER_LABEL_IDX}}/public_html try_files {path} {path}/index.html file_server browse } # Custom domains a member pointed at this host (ssh domain@${DOMAIN} add ...). # The symlink farm in domains/ maps each host to its owner's public_html, so # {host} resolves to the right tree; unmapped hosts 404 (and never got a cert). https:// { encode zstd gzip tls { on_demand } root * ${DATA_DIR}/domains/{host} try_files {path} {path}/index.html file_server } CADDY ufw allow 80/tcp >/dev/null ufw allow 443/tcp >/dev/null systemctl reload caddy 2>/dev/null || systemctl restart caddy # ---- 9b. Ergo IRC server (co-located ${IRC_DOMAIN}; humans + agents) -------- # A lightweight single-binary IRC network on its own ports. Native clients hit # ${IRC_DOMAIN}:6697 (TLS, using Caddy's Let's Encrypt cert for ${IRC_DOMAIN}); # web clients and agents hit wss://${DOMAIN}/irc or wss://${IRC_DOMAIN}/irc # (Caddy fronts Ergo's loopback WebSocket). See docs/irc.md. Disable with IRC=0. if [ "$IRC" = "1" ]; then log "installing Ergo IRC server v${ERGO_VERSION} (${IRC_DOMAIN})" case "$GOARCH" in amd64) ERGO_ARCH=x86_64 ;; arm64) ERGO_ARCH=arm64 ;; *) ERGO_ARCH="$GOARCH" ;; esac id ergo >/dev/null 2>&1 || useradd --system --home-dir "$ERGO_DATA" --shell /usr/sbin/nologin ergo install -d -m 0755 /opt/ergo "$ERGO_DATA" "$ERGO_DATA/tls" /etc/ergo # Install/upgrade the binary + bundled languages (idempotent: only on version change). if [ "$(/opt/ergo/ergo --version 2>/dev/null)" != "ergo-${ERGO_VERSION}" ]; then tmp="$(mktemp -d)" curl -fsSL "https://github.com/ergochat/ergo/releases/download/v${ERGO_VERSION}/ergo-${ERGO_VERSION}-linux-${ERGO_ARCH}.tar.gz" -o "$tmp/ergo.tgz" \ || die "could not download Ergo ${ERGO_VERSION}" tar -C "$tmp" -xzf "$tmp/ergo.tgz" d="$tmp/ergo-${ERGO_VERSION}-linux-${ERGO_ARCH}" install -m 0755 "$d/ergo" /opt/ergo/ergo rm -rf /opt/ergo/languages && cp -r "$d/languages" /opt/ergo/languages rm -rf "$tmp" fi # Operator password: generate once, keep the plaintext root-only, embed only the hash. if [ ! -f "$ENV_DIR/ergo-oper.txt" ]; then OPER_PASS="$(head -c18 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c24)" printf '%s\n' "$OPER_PASS" > "$ENV_DIR/ergo-oper.txt" chmod 600 "$ENV_DIR/ergo-oper.txt" fi OPER_PASS="$(cat "$ENV_DIR/ergo-oper.txt")" OPER_HASH="$(printf '%s\n%s\n' "$OPER_PASS" "$OPER_PASS" | /opt/ergo/ergo genpasswd 2>/dev/null | tail -1)" # Render the config template from the repo (the __TOKENS__ become real values). sed -e "s|__NETWORK__|${IRC_NETWORK}|g" \ -e "s|__DOMAIN__|${DOMAIN}|g" \ -e "s|__IRC_DOMAIN__|${IRC_DOMAIN}|g" \ -e "s|__DATA__|${ERGO_DATA}|g" \ -e "s|__TLS_DIR__|${ERGO_DATA}/tls|g" \ -e "s|__LANG_DIR__|/opt/ergo/languages|g" \ -e "s|__OPER_PASSWORD_HASH__|${OPER_HASH}|g" \ -e "s|__IRC_AUTH_URL__|http://${HTTP_ADDR}/irc-auth|g" \ "${SRC_DIR}/deploy/ergo/ircd.yaml" > /etc/ergo/ircd.yaml chmod 640 /etc/ergo/ircd.yaml # IRC is members-only: this auth-script approves a SASL login only if the BBS # user store says the account is a member — it queries agentbbs's loopback # /irc-auth endpoint (the single user-level source of truth). Needs jq + curl. install -m 0755 "${SRC_DIR}/deploy/ergo/auth-script.sh" /usr/local/bin/ergo-auth-member # TLS for 6697: reuse Caddy's Let's Encrypt cert for ${IRC_DOMAIN}; self-signed # fallback on first run before Caddy has issued it (the timer swaps it in). install -m 0755 "${SRC_DIR}/deploy/ergo/refresh-certs.sh" /usr/local/bin/ergo-refresh-certs DOMAIN="$IRC_DOMAIN" ERGO_DATA="$ERGO_DATA" /usr/local/bin/ergo-refresh-certs || true if [ ! -s "$ERGO_DATA/tls/fullchain.pem" ]; then warn "no Caddy cert for ${IRC_DOMAIN} yet (is its A record pointed here?) — using a self-signed cert on 6697 until the ergo-certs timer swaps in the real one" ( cd /etc/ergo && /opt/ergo/ergo mkcerts --conf /etc/ergo/ircd.yaml --quiet 2>/dev/null ) \ || openssl req -newkey rsa:2048 -nodes -days 90 -x509 \ -keyout "$ERGO_DATA/tls/privkey.pem" -out "$ERGO_DATA/tls/fullchain.pem" \ -subj "/CN=${IRC_DOMAIN}" 2>/dev/null fi chown -R ergo:ergo "$ERGO_DATA" /etc/ergo # Initialize the datastore once. [ -f "$ERGO_DATA/ircd.db" ] || sudo -u ergo /opt/ergo/ergo initdb --conf /etc/ergo/ircd.yaml --quiet log "installing ergo.service" cat > /etc/systemd/system/ergo.service < /etc/systemd/system/ergo-certs.service < /etc/systemd/system/ergo-certs.timer </dev/null 2>&1 || true systemctl restart ergo systemctl enable --now ergo-certs.timer >/dev/null 2>&1 || true ufw allow 6697/tcp >/dev/null sleep 1 systemctl is-active --quiet ergo \ || warn "ergo failed to start — check: journalctl -u ergo -n50" else systemctl disable --now ergo ergo-certs.timer >/dev/null 2>&1 || true fi # ---- 9c. News (Usenet/NNTP) server (co-located news.${DOMAIN}) -------------- # The NNTP server runs INSIDE the agentbbs process (members-only, free + paid): # a loopback plaintext listener backs `ssh news@`, and a public NNTPS listener # on :563 serves desktop newsreaders and agents. It reuses Caddy's LE cert for # news.${DOMAIN} (issued by the Caddy site block above); a timer copies the cert # into a dir agentbbs reads, and agentbbs re-reads it within 30s (no restart). # See docs/news.md. Disable with NEWS=0. NEWS_TLS_DIR="${DATA_DIR}/news-tls" if [ "$NEWS" = "1" ]; then log "configuring news (NNTP) server (news.${DOMAIN})" install -d -m 0750 -o "$SVC_USER" -g "$SVC_USER" "$NEWS_TLS_DIR" # Make sure an existing agentbbs.env (only written when absent) learns the # news knobs on redeploy too. upsert_env AGENTBBS_NEWS_HOST "news.${DOMAIN}" upsert_env AGENTBBS_NEWS_TLS_CERT "${NEWS_TLS_DIR}/fullchain.pem" upsert_env AGENTBBS_NEWS_TLS_KEY "${NEWS_TLS_DIR}/privkey.pem" install -m 0755 "${SRC_DIR}/deploy/news-refresh-certs.sh" /usr/local/bin/agentbbs-news-certs DOMAIN="$DOMAIN" NEWS_HOST="news.${DOMAIN}" NEWS_TLS_DIR="$NEWS_TLS_DIR" SVC_USER="$SVC_USER" \ /usr/local/bin/agentbbs-news-certs || true if [ ! -s "$NEWS_TLS_DIR/fullchain.pem" ]; then warn "no Caddy cert for news.${DOMAIN} yet — using a self-signed cert on :563 until the news-certs timer swaps in the real one (add DNS: news.${DOMAIN} A -> this host)" openssl req -newkey rsa:2048 -nodes -days 90 -x509 \ -keyout "$NEWS_TLS_DIR/privkey.pem" -out "$NEWS_TLS_DIR/fullchain.pem" \ -subj "/CN=news.${DOMAIN}" 2>/dev/null || true chown -R "$SVC_USER:$SVC_USER" "$NEWS_TLS_DIR" 2>/dev/null || true fi cat > /etc/systemd/system/agentbbs-news-certs.service < /etc/systemd/system/agentbbs-news-certs.timer </dev/null 2>&1 || true ufw allow 563/tcp >/dev/null else upsert_env AGENTBBS_NEWS "0" systemctl disable --now agentbbs-news-certs.timer >/dev/null 2>&1 || true fi # ---- 9d. AgentGit: Forgejo backend (https://${GIT_DOMAIN}) ------------------ # Self-hosted Forgejo that powers AgentGit. It listens on a loopback HTTP port # that Caddy fronts at https://${GIT_DOMAIN} (site block in §9). Members-only: # open registration is disabled and sign-in is required to view, so the only way # in is the account agentbbs provisions for every verified member (free + paid) # using the admin token captured below. See logicsrc plugins/agentgit. FORGEJO=0 # disables it. FORGEJO_CONF=/etc/forgejo/app.ini if [ "$FORGEJO" = "1" ]; then log "installing Forgejo (AgentGit backend, ${GIT_DOMAIN})" id -u forgejo >/dev/null 2>&1 \ || useradd --system --shell /usr/sbin/nologin --home-dir "$FORGEJO_DATA" --create-home forgejo install -d -m 0750 -o forgejo -g forgejo \ "$FORGEJO_DATA" "$FORGEJO_DATA/data" "$FORGEJO_DATA/log" "$FORGEJO_DATA/repos" /etc/forgejo if [ ! -x /usr/local/bin/forgejo ] || ! /usr/local/bin/forgejo --version 2>/dev/null | grep -q "$FORGEJO_VERSION"; then case "$(uname -m)" in x86_64|amd64) FJ_ARCH=amd64 ;; aarch64|arm64) FJ_ARCH=arm64 ;; *) FJ_ARCH="" ; warn "unknown arch $(uname -m) for Forgejo; skipping download" ;; esac if [ -n "$FJ_ARCH" ]; then log "downloading forgejo ${FORGEJO_VERSION} (${FJ_ARCH})" curl -fsSL "https://codeberg.org/forgejo/forgejo/releases/download/v${FORGEJO_VERSION}/forgejo-${FORGEJO_VERSION}-linux-${FJ_ARCH}" \ -o /usr/local/bin/forgejo && chmod 0755 /usr/local/bin/forgejo \ || warn "forgejo download failed — backend will be unavailable" fi fi # app.ini is written once so Forgejo-managed secrets survive redeploys. if [ ! -f "$FORGEJO_CONF" ] && [ -x /usr/local/bin/forgejo ]; then FJ_SECRET_KEY=$(sudo -u forgejo /usr/local/bin/forgejo generate secret SECRET_KEY) FJ_INTERNAL_TOKEN=$(sudo -u forgejo /usr/local/bin/forgejo generate secret INTERNAL_TOKEN) cat > "$FORGEJO_CONF" < /etc/systemd/system/forgejo.service </dev/null 2>&1 || true systemctl restart forgejo sleep 2 systemctl is-active --quiet forgejo \ || warn "forgejo failed to start — check: journalctl -u forgejo -n50" # First-run: create the admin agentbbs uses to mint member accounts, and store # an admin-scoped token in agentbbs.env. Guarded on the token being empty so # reruns never create duplicate tokens. if ! grep -qE '^AGENTBBS_FORGEJO_ADMIN_TOKEN=.+' "$ENV_DIR/agentbbs.env" 2>/dev/null; then FJ_ADMIN_PW=$(head -c32 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c24) sudo -u forgejo GITEA_WORK_DIR="$FORGEJO_DATA" /usr/local/bin/forgejo admin user create \ --admin --username "$FORGEJO_ADMIN_USER" --email "agentgit@${GIT_DOMAIN}" \ --password "$FJ_ADMIN_PW" --must-change-password=false --config "$FORGEJO_CONF" >/dev/null 2>&1 \ || true FJ_TOKEN=$(sudo -u forgejo GITEA_WORK_DIR="$FORGEJO_DATA" /usr/local/bin/forgejo admin user generate-access-token \ --username "$FORGEJO_ADMIN_USER" --token-name "agentbbs-$(date +%s)" --scopes write:admin \ --config "$FORGEJO_CONF" 2>/dev/null | grep -oE '[0-9a-f]{40}' | head -1) if [ -n "$FJ_TOKEN" ]; then upsert_env AGENTBBS_FORGEJO_URL "https://${GIT_DOMAIN}" upsert_env AGENTBBS_FORGEJO_ADMIN_TOKEN "$FJ_TOKEN" log "Forgejo admin token provisioned into agentbbs.env" else warn "could not mint Forgejo admin token — set AGENTBBS_FORGEJO_ADMIN_TOKEN by hand (journalctl -u forgejo)" fi fi else systemctl disable --now forgejo >/dev/null 2>&1 || true fi # ---- 9e. Mailu mail stack (co-located mail.${DOMAIN#*.}) -------------------- # Self-hosted Postfix+Dovecot+Roundcube+rspamd via Docker Compose. Mailu owns # the mail ports; Caddy fronts the loopback webmail and supplies the TLS cert # (TLS_FLAVOR=mail). agentbbs reads/sends on behalf of paid members. Full setup, # DNS, and the gateway master user: docs/mail.md. Disable with MAIL=0. MAILU_DIR="${SRC_DIR}/deploy/mailu" if [ "$MAIL" = "1" ]; then log "configuring Mailu mail stack (${MAIL_DOMAIN})" # Tell agentbbs how to reach the mailbox backend (master user/pass are secrets # the operator sets; see docs/mail.md). upsert_env AGENTBBS_MAIL_DOMAIN "${MAIL_DOMAIN}" upsert_env AGENTBBS_MAIL_IMAP_ADDR "${MAIL_DOMAIN}:993" upsert_env AGENTBBS_MAIL_SMTP_ADDR "127.0.0.1:25" # Cert refresher: copy Caddy's mail cert into Mailu on renewal (like news/IRC). install -m 0755 "${MAILU_DIR}/refresh-certs.sh" /usr/local/bin/agentbbs-mailu-certs cat > /etc/systemd/system/agentbbs-mailu-certs.service < /etc/systemd/system/agentbbs-mailu-certs.timer </dev/null 2>&1 || true # Open the mail ports; bring Mailu up only once the operator has created # mailu.env (it carries SECRET_KEY + admin password — never auto-generated). for p in 25 465 587 993 995; do ufw allow "${p}/tcp" >/dev/null; done if command -v docker >/dev/null && [ -f "${MAILU_DIR}/mailu.env" ]; then ( cd "$MAILU_DIR" && docker compose up -d ) || warn "mailu: docker compose up failed — check ${MAILU_DIR}" else warn "Mailu not started yet: create ${MAILU_DIR}/mailu.env (cp mailu.env.example) and run 'docker compose up -d' — see docs/mail.md" fi else upsert_env AGENTBBS_MAIL_DOMAIN "" systemctl disable --now agentbbs-mailu-certs.timer >/dev/null 2>&1 || true fi # ---- 10. firewall + start agentbbs on :22 ---------------------------------- log "configuring firewall + starting agentbbs" ufw allow 22/tcp >/dev/null ufw --force enable >/dev/null # enable + restart (not just `enable --now`): a redeploy rebuilds the binary, and # `enable --now` would leave the OLD process running, so the new build wouldn't load. systemctl enable agentbbs >/dev/null 2>&1 || true systemctl restart agentbbs sleep 1 systemctl is-active --quiet agentbbs \ || die "agentbbs failed to start — check: journalctl -u agentbbs -n50" # ---- done ------------------------------------------------------------------ log "AgentBBS is up." cat <@${DOMAIN} (your old key still works) Users ssh join@${DOMAIN} register ssh bbs@${DOMAIN} guest hub ssh pod@${DOMAIN} personal pod ssh domain@${DOMAIN} add point your own domain at your homepage Web https://${DOMAIN}/ site root https://${DOMAIN}/~ a member's homepage https:// a member's homepage on a custom domain (auto-HTTPS) IRC ${IRC_DOMAIN}:6697 (TLS) external clients (DNS: ${IRC_DOMAIN} A -> host) ${IRC:+(set IRC=0 to disable)} wss://${DOMAIN}/irc web clients + agents over WebSocket (SASL: your BBS name) /OPER admin oper password in ${ENV_DIR}/ergo-oper.txt News news.${DOMAIN}:563 (NNTPS) newsreaders + agents ${NEWS:+(set NEWS=0 to disable)} ssh -t news@${DOMAIN} the in-BBS newsreader (DNS: news.${DOMAIN} A -> host) AgentGit https://${GIT_DOMAIN} git for members (auto-account on email verify) ${FORGEJO:+(set FORGEJO=0 to disable)} DNS: ${GIT_DOMAIN} A -> this host Config ${ENV_DIR}/agentbbs.env (set CoinPay + LiveKit, then: systemctl restart agentbbs) Logs journalctl -u agentbbs -f (IRC: journalctl -u ergo -f) Update re-run this script (git pull + rebuild + restart) DONE warn "Before you log out: open a new terminal and confirm ssh -p ${ADMIN_SSH_PORT} @${DOMAIN} works." warn "If you attached a DigitalOcean Cloud Firewall, also allow ${ADMIN_SSH_PORT}, 22, 80, 443 there."