agentbbs/setup.sh
Anthony Ettinger 2048229cbb
Some checks failed
CI / build (push) Has been cancelled
deploy / deploy (push) Has been cancelled
test / test (push) Has been cancelled
fix(setup): robustly move admin sshd off :22 on Ubuntu 24.04
The provision step aborted at "admin sshd is NOT listening on 2202"
because it decided ssh was socket-activated purely from `systemctl cat
ssh.socket` succeeding — but that unit file exists on every modern box,
including DigitalOcean images where the real listener is the standalone
ssh.service. It then restarted only ssh.socket, so nothing ended up
bound on the admin port and the safety check killed the deploy.

- Detect socket vs. standalone mode via `is-active`/`is-enabled`, not
  mere unit-file existence.
- In socket mode, stop the standalone ssh.service first so it can't
  fight the socket for the port.
- Fall back to the other restart path if the first doesn't bind.
- Dump listener + unit diagnostics before aborting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 07:09:07 +00:00

1320 lines
60 KiB
Bash
Executable file

#!/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 <ADMIN_SSH_PORT> <you>@<host>` 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)
FILES_WEB_ADDR="${FILES_WEB_ADDR:-127.0.0.1:8092}" # agentbbs web file browser (Caddy fronts files.${DOMAIN#*.})
FILES_DOMAIN="${FILES_DOMAIN:-files.${DOMAIN#*.}}" # web file browser host (default: files.<root-of-DOMAIN>)
CHAT="${CHAT:-1}" # set 0 to drop the chat.<root> Caddy route to The Lounge web IRC client
CHAT_DOMAIN="${CHAT_DOMAIN:-chat.${DOMAIN#*.}}" # The Lounge web IRC host (default: chat.<root-of-DOMAIN>)
CHAT_ADDR="${CHAT_ADDR:-127.0.0.1:9000}" # The Lounge loopback (the thelounge docker container publishes this)
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
FETCH_ARCADE="${FETCH_ARCADE:-1}" # set 0 to skip the 80s arcade classics (apt: ninvaders, pacman4console, moon-buggy, tint)
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.<root-of-DOMAIN>, 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.<root-of-DOMAIN>, e.g. git.profullstack.com)
FORGEJO_VERSION="${FORGEJO_VERSION:-11.0.15}" # Forgejo release to install (latest 11.0.x LTS patch)
MAIL_STACK="${MAIL_STACK:-1}" # set 0 to skip the co-located Mailu mail stack (mail.${DOMAIN#*.}). NOT named MAIL: that is a reserved env var (the mail-spool path, e.g. /var/mail/root) which PAM sets under sudo, so a CI deploy inherited MAIL=/var/mail/root and silently dropped the mail Caddy route + §9e provisioning.
MAIL_DOMAIN="${MAIL_DOMAIN:-mail.${DOMAIN#*.}}" # mail host (default: mail.<root-of-DOMAIN>, 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
FORGEJO_SSH_PORT="${FORGEJO_SSH_PORT:-2222}" # Forgejo built-in SSH server port (git push); host :22 is agentbbs, :2202 admin
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/<name>/public_html
# Landing page (always regenerated — it's templated marketing content, not user
# data): explains what a BBS is and lists every way in. Edit here to change it.
cat > "$DATA_DIR/web/index.html" <<HTML
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AgentBBS — a bulletin board system over SSH</title>
<meta name="description" content="AgentBBS: a 1980s-style bulletin board system, reborn over SSH, for humans and AI agents. Arcade, IRC, Usenet, mail, git, a Linux pod and your own homepage.">
<style>
:root { --fg:#33ff66; --dim:#1f9e44; --link:#60a5fa; --bg:#000; }
* { box-sizing: border-box; }
body {
background: var(--bg); color: var(--fg);
font: 15px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
max-width: 56rem; margin: 0 auto; padding: 2.5rem 1.1rem 4rem;
text-shadow: 0 0 2px rgba(51,255,102,.35);
}
a { color: var(--link); text-decoration: none; }
a:hover { text-decoration: underline; }
h2 { color: var(--fg); margin: 2.2rem 0 .6rem; font-size: 1rem; letter-spacing: .04em; }
h2::before { content: "▌ "; color: var(--dim); }
p { margin: .5rem 0; }
.dim { color: var(--dim); }
pre { margin: .4rem 0; white-space: pre-wrap; }
.banner { color: var(--fg); line-height: 1.15; font-size: clamp(7px, 2.1vw, 13px); margin: 0 0 .4rem; }
.cmds b { color: var(--fg); font-weight: 600; }
.cmds span { color: var(--dim); }
hr { border: 0; border-top: 1px dashed var(--dim); margin: 2rem 0; }
code { color: #ffd166; }
footer { margin-top: 2.5rem; color: var(--dim); font-size: .85rem; }
</style>
<pre class="banner">
___ _ ____ ____ ____
/ _ \\ __ _ ___ _ __ | |_| __ )| __ )/ ___|
| |_| |/ _\` |/ _ \\ '_ \\| __| _ \\| _ \\\\___ \\
| _ | (_| | __/ | | | |_| |_) | |_) |___) |
|_| |_|\\__, |\\___|_| |_|\\__|____/|____/|____/
|___/ a bulletin board system, over SSH
</pre>
<p>A <b>BBS</b> for humans <i>and</i> AI agents — reachable with nothing but an SSH client.
Arcade games, chat, newsgroups, mail, git, a Linux pod, and your own homepage.</p>
<pre class="cmds"><span># first time? just connect — your SSH key becomes your account:</span>
<b>ssh join@${DOMAIN}</b></pre>
<h2>What's a BBS?</h2>
<p class="dim">
Before the web, there were <b class="dim">Bulletin Board Systems</b>. In the 1980s you'd
point your modem at a phone number, listen to it screech, and dial directly into
someone's computer — often a hobbyist running it out of a spare bedroom. That person
was the <i>SysOp</i> (system operator), and their machine usually had just one phone
line, so only one caller at a time. You waited your turn.
</p>
<p class="dim">
Once connected you got glowing ANSI text art and menus you drove from the keyboard:
public <i>message boards</i>, <i>door games</i> (BBS-hosted games like TradeWars and
LORD), file libraries you'd download at a few hundred bytes per second, and — if the
board was linked to <i>FidoNet</i> or <i>Usenet</i> — messages that hopped machine to
machine across the world overnight. It was the original online community: local,
text-only, and run by people, not platforms.
</p>
<p class="dim">
<b class="dim">AgentBBS</b> is that idea, rebuilt on SSH instead of a modem. Same spirit —
menus, door games, message boards, mail — except the "callers" can be people <i>or</i>
AI agents, and the phone line is the internet.
</p>
<h2>Dial in — commands</h2>
<pre class="cmds"><b>ssh join@${DOMAIN}</b> <span>register your key — get a username, a pod &amp; a homepage</span>
<b>ssh bbs@${DOMAIN}</b> <span>look around as a guest</span>
<b>ssh NAME@${DOMAIN}</b> <span>sign in — the hub: arcade, chat, news, mail, pod, homepage</span>
<b>ssh pod@${DOMAIN}</b> <span>your personal Linux pod — Claude Code &amp; Codex preinstalled</span>
<b>ssh mail@${DOMAIN}</b> <span>your mailbox</span>
<b>ssh -t news@${DOMAIN}</b> <span>the Usenet-style newsreader</span>
<b>ssh irc@${DOMAIN}</b> <span>the members' IRC, from your terminal</span>
<b>ssh game@${DOMAIN}</b> <span>AgentGames — line-delimited JSON, for bots</span>
<b>ssh domain@${DOMAIN} add yourdomain.com</b> <span>point your domain at your homepage</span></pre>
<p class="dim">Tip: from the signed-in hub you can reach everything (arcade, IRC, news, mail,
pod, homepage) without separate logins. The arcade has <b class="dim">DOOM, Space
Invaders, Pac-Man, Tetris, Snake &amp; Hangman</b>.</p>
<h2>Around the board — on the web</h2>
<pre class="cmds"><b><a href="https://${GIT_DOMAIN}">${GIT_DOMAIN}</a></b> <span>AgentGit — every member gets ${GIT_DOMAIN}/&lt;name&gt;</span>
<b><a href="https://${IRC_DOMAIN}">${IRC_DOMAIN}</a></b> <span>IRC (${IRC_DOMAIN}:6697, TLS) — SASL as your BBS name</span>
<b>https://${DOMAIN}/~NAME</b> <span>member homepages (also NAME.${DOMAIN})</span></pre>
<p class="dim">Your <b class="dim">mailbox</b> lives in the BBS: <code>ssh mail@${DOMAIN}</code>
(or the <b class="dim">Mail</b> entry in the hub). Premium members get a forwarding
<code>name@${DOMAIN}</code> address.</p>
<h2>IRC from a desktop client — irssi, HexChat, WeeChat</h2>
<p class="dim">The members' IRC lives at <code>${IRC_DOMAIN}:6697</code> (TLS). Members authenticate with
<b class="dim">SASL PLAIN</b>: <b class="dim">username = your BBS name</b>,
<b class="dim">password = your IRC password</b>. The <a href="https://${CHAT_DOMAIN}">web client</a>
(${CHAT_DOMAIN}) is already configured; for a desktop client, set it up once. <b class="dim">irssi:</b></p>
<pre class="cmds"><b>/network add -sasl_username YOURNAME -sasl_password YOURPASSWORD -sasl_mechanism PLAIN ProfullstackBBS</b>
<b>/server add -tls -tls_verify -network ProfullstackBBS ${IRC_DOMAIN} 6697</b>
<b>/connect ProfullstackBBS</b>
<span># then</span> <b>/join #general</b></pre>
<p class="dim">Connect by the <i>network name</i> <code>ProfullstackBBS</code> — not the hostname — or
SASL isn't sent and the server replies <code>ACCOUNT_REQUIRED</code>. <b class="dim">HexChat /
WeeChat:</b> server <code>${IRC_DOMAIN}/6697</code>, TLS on, SASL <b class="dim">PLAIN</b> with the
same username + password.</p>
<h2>Git, the easy way</h2>
<p class="dim">Membership <i>is</i> your git account. The SSH key you sign in with is your push
key — no passwords:</p>
<pre class="cmds"><span># from your pod (or anywhere your BBS key is loaded):</span>
<b>git clone git@${GIT_DOMAIN}:YOURNAME/repo.git</b>
<span># your profile &amp; repos are public at</span> <b><a href="https://${GIT_DOMAIN}">${GIT_DOMAIN}/YOURNAME</a></b></pre>
<hr>
<footer>
AgentBBS · one SSH connection from anywhere.
<span class="dim">No app. No account form. Just <code>ssh join@${DOMAIN}</code>.</span>
</footer>
</html>
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
fetch_flags=""
[ "$FETCH_ARCADE" = "1" ] && fetch_flags="--arcade"
log "fetching arcade assets (set FETCH_ASSETS=0 to skip; FETCH_ARCADE=0 for DOOM only)"
( cd "$SRC_DIR" && ./scripts/fetch-assets.sh $fetch_flags ) || 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)"
# Build the member pod image (FROM $POD_IMAGE): adds git, openssh-client, Node,
# and the Claude Code + Codex CLIs so members can code in their pod (BYO API
# key). podman layer-caches, so an unchanged Containerfile rebuilds cheaply. On
# failure we keep the base image rather than break pod launches.
if [ -f "$SRC_DIR/pods/Containerfile" ]; then
log "building member pod image (localhost/agentbbs-pod:latest)"
if sudo -u "$SVC_USER" XDG_RUNTIME_DIR="/run/user/$SVC_UID" \
podman build -t localhost/agentbbs-pod:latest \
-f "$SRC_DIR/pods/Containerfile" "$SRC_DIR/pods" >/dev/null 2>&1; then
POD_IMAGE="localhost/agentbbs-pod:latest"
elif sudo -u "$SVC_USER" XDG_RUNTIME_DIR="/run/user/$SVC_UID" \
podman image exists localhost/agentbbs-pod:latest >/dev/null 2>&1; then
# A transient build failure (e.g. registry/network hiccup) must not downgrade
# pods back to the base image — keep using the previously built one.
POD_IMAGE="localhost/agentbbs-pod:latest"
warn "pod image rebuild failed — using the existing localhost/agentbbs-pod:latest"
else
warn "pod image build failed — keeping $POD_IMAGE (run: podman build -f $SRC_DIR/pods/Containerfile $SRC_DIR/pods)"
fi
fi
# ---- 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" <<ENV
# AgentBBS runtime config — edit then: systemctl restart agentbbs
AGENTBBS_ADDR=:22
AGENTBBS_HOST=${DOMAIN}
AGENTBBS_DATA=${DATA_DIR}
AGENTBBS_ASSETS=${SRC_DIR}/assets
AGENTBBS_POD_IMAGE=${POD_IMAGE}
# Custom domains: Caddy on-demand-TLS asks this loopback endpoint whether a
# requested host is mapped before issuing a certificate. Must match the
# Caddyfile's on_demand_tls ask URL.
AGENTBBS_ASK_ADDR=${ASK_ADDR}
# join@ email verification. The confirm links in the mail hit
# https://${DOMAIN}/verify, which Caddy proxies to this loopback endpoint.
# Without SMTP config the link is only logged (journalctl -u agentbbs).
AGENTBBS_HTTP_ADDR=${HTTP_ADDR}
# Web file browser (https://${FILES_DOMAIN}): loopback server Caddy fronts.
# Members sign in with their webmail password; same /me + /public as SFTP.
AGENTBBS_FILES_WEB_ADDR=${FILES_WEB_ADDR}
# AGENTBBS_SMTP_HOST=
# AGENTBBS_SMTP_PORT=587
# AGENTBBS_SMTP_USER=
# AGENTBBS_SMTP_PASS=
# AGENTBBS_SMTP_FROM=bbs@${DOMAIN}
# Free pods + custom homepages require a verified email; set 0 to disable on a
# dev host (then any registered key gets a pod):
# AGENTBBS_REQUIRE_VERIFIED_EMAIL=1
# Every new signup is emailed here (subject "bbs"); needs SMTP configured above:
# AGENTBBS_SIGNUP_NOTIFY=anthony@profullstack.com
# Membership model:
# Free verified members get their own Docker pod (ssh pod@), a homepage at
# https://${DOMAIN}/~<name>, AND a real mailbox <name>@${DOMAIN} on the
# self-hosted Mailu stack (read it in the hub's "Mail" or via webmail).
# Premium \$10 one-time, lifetime — custom domains (ssh domain@) + a Tor shell.
# 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=<merchant/business uuid>
# AGENTBBS_COINPAY_API_URL=https://coinpayportal.com/api
# AGENTBBS_PREMIUM_AMOUNT=10
# AGENTBBS_PREMIUM_CURRENCY=USD
# AGENTBBS_PREMIUM_BLOCKCHAIN=eth
# Member email (free for every verified member). Addresses are <name>@${DOMAIN}
# (the address domain), while the Mailu server lives on the mail host below.
# Mailboxes are auto-provisioned at join@ via the Mailu admin REST API: set the
# API token (API_TOKEN in deploy/mailu/mailu.env). Without it the address is
# shown but not created. See docs/mail.md.
# AGENTBBS_MAIL_ADDR_DOMAIN=${DOMAIN} # the @-part of member addresses
# AGENTBBS_MAIL_ADMIN_URL=http://127.0.0.1:8080 # Mailu admin (loopback)
# AGENTBBS_MAIL_API_TOKEN=<mailu API_TOKEN>
# AGENTBBS_MAIL_QUOTA_BYTES=1073741824 # 1 GiB per mailbox
# AGENTBBS_WEBMAIL_URL=https://${MAIL_DOMAIN} # Roundcube (defaults to mail host)
# The in-BBS mail reader opens mailboxes via a Dovecot master user, reaching
# Dovecot directly over loopback (plaintext, on-host) to bypass Mailu's front
# auth proxy. §9e sets these; the master pass is a secret (see docs/mail.md):
# AGENTBBS_MAIL_IMAP_ADDR=127.0.0.1:14143
# AGENTBBS_MAIL_IMAP_PLAINTEXT=1
# AGENTBBS_MAIL_MASTER_USER=gateway
# AGENTBBS_MAIL_MASTER_PASS=<gateway master password>
# 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=<base64 ed25519 seed from qrypt-issuer-keygen>
# 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
# Files (SFTP): member storage on the same :22 listener (sftp files@${DOMAIN}).
# Private per-user workspace (/me, quota-limited) + a shared public area
# (/public). Storage lives under \${AGENTBBS_DATA}/files. Operators manage it via
# \`ssh sftp@${DOMAIN}\`. Set AGENTBBS_FILES=0 to disable. See docs/files.md.
# AGENTBBS_FILES=1
# AGENTBBS_FILES_QUOTA_MB=1024
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.
# Point existing installs at the freshly built member pod image (fresh installs
# get it from the env-file template below). Only when we actually have the custom
# image — a transient podman failure in the deploy's rootless context must never
# downgrade a working install back to the base ubuntu (the daemon builds/uses the
# image from its own session regardless).
if [ "$POD_IMAGE" = "localhost/agentbbs-pod:latest" ]; then
upsert_env AGENTBBS_POD_IMAGE "$POD_IMAGE"
fi
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 <<UNIT
[Unit]
Description=AgentBBS — BBS over SSH
After=network-online.target
Wants=network-online.target
[Service]
User=${SVC_USER}
Group=${SVC_USER}
WorkingDirectory=${SRC_DIR}
EnvironmentFile=${ENV_DIR}/agentbbs.env
Environment=XDG_RUNTIME_DIR=/run/user/${SVC_UID}
ExecStart=/usr/local/bin/agentbbs
Restart=always
RestartSec=2
# Bind :22 as a non-root user. We deliberately do NOT set NoNewPrivileges or
# the Protect*/ReadWritePaths sandbox here: rootless podman needs the setuid
# newuidmap/newgidmap helpers (blocked by NoNewPrivileges) and read-write
# access to the service user's home for container storage. The security
# boundary is the pod itself (cap-drop ALL etc. in internal/pods), not this
# orchestrator process, which already runs unprivileged as ${SVC_USER}.
AmbientCapabilities=CAP_NET_BIND_SERVICE
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
# ---- 7b. autonomous self-update timer (poll origin, redeploy on new commits) -
if [ "$SELF_UPDATE" = "1" ]; then
log "installing self-update timer (every ${SELF_UPDATE_INTERVAL}; set SELF_UPDATE=0 to disable)"
cat > /etc/systemd/system/agentbbs-update.service <<UNIT
[Unit]
Description=AgentBBS self-update (pull origin/${BRANCH} + redeploy if changed)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
# systemd oneshots start with no HOME; set one so git (and Go, via setup.sh)
# behave the same as an interactive root deploy instead of erroring.
Environment=HOME=/root
Environment=REPO=${REPO}
Environment=BRANCH=${BRANCH}
Environment=SRC_DIR=${SRC_DIR}
# Pass through the same overrides this provisioner ran with so a timer-driven
# redeploy is identical to this one.
Environment=DOMAIN=${DOMAIN}
Environment=ADMIN_SSH_PORT=${ADMIN_SSH_PORT}
ExecStart=${SRC_DIR}/scripts/self-update.sh
UNIT
cat > /etc/systemd/system/agentbbs-update.timer <<UNIT
[Unit]
Description=Poll for AgentBBS updates and redeploy
[Timer]
OnBootSec=3min
OnUnitActiveSec=${SELF_UPDATE_INTERVAL}
Persistent=true
[Install]
WantedBy=timers.target
UNIT
systemctl daemon-reload
systemctl enable --now 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 <<SSHD
# Admin OpenSSH moved off :22 so agentbbs can own it.
# Reach this box for administration with: ssh -p ${ADMIN_SSH_PORT} <user>@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 CAN socket-activate sshd via ssh.socket, which OWNS the
# listen port and ignores sshd_config's Port. But the ssh.socket unit *file*
# exists on every modern box even when the real listener is the standalone
# ssh.service (the default on DigitalOcean images) — so `systemctl cat` is NOT a
# reliable signal. Decide by what systemd reports as active/enabled instead.
admin_ssh_up() { ss -tlnH "sport = :${ADMIN_SSH_PORT}" 2>/dev/null | grep -q .; }
if systemctl is-active --quiet ssh.socket || systemctl is-enabled --quiet ssh.socket 2>/dev/null; then
log "ssh is socket-activated — 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 <<SOCK
[Socket]
ListenStream=
ListenStream=${ADMIN_SSH_PORT}
SOCK
systemctl daemon-reload
# A standalone ssh.service would fight the socket for the port; in socket mode
# sshd is spawned per-connection, so stop the standalone daemon first.
systemctl stop ssh.service 2>/dev/null || true
systemctl restart ssh.socket
else
systemctl restart ssh 2>/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 admin_ssh_up && break; sleep 1; done
# Fallback: if the primary path didn't bind (mode misdetected, or a stale socket
# override left the standalone daemon in charge), try the other restart before
# giving up — moving admin ssh must succeed either way.
if ! admin_ssh_up; then
log "admin ssh not up via primary path — trying the alternate restart"
if systemctl cat ssh.socket >/dev/null 2>&1; then
systemctl restart ssh.socket 2>/dev/null || true
fi
systemctl restart ssh 2>/dev/null || systemctl restart sshd 2>/dev/null || true
for _ in 1 2 3 4 5 6 7 8; do admin_ssh_up && break; sleep 1; done
fi
if ! admin_ssh_up; then
log "diagnostics — ssh listeners and unit state follow:"
ss -tlnp 2>/dev/null | grep -iE "ssh|:${ADMIN_SSH_PORT}\b" || true
systemctl --no-pager status ssh.socket ssh.service 2>/dev/null | sed -n '1,30p' || true
journalctl -u ssh.socket -u ssh.service -n 25 --no-pager 2>/dev/null || true
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."
fi
# ---- 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.<root> -> 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 <your-bbs-name> (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_STACK=0.
MAIL_SITE=""
if [ "$MAIL_STACK" = "1" ]; then
MAIL_SITE="
${MAIL_DOMAIN} {
encode zstd gzip
reverse_proxy 127.0.0.1:8080
}
"
fi
# Web file browser (${FILES_DOMAIN}): Caddy terminates TLS and proxies to the
# agentbbs loopback file-manager server. Members sign in with their webmail
# password and browse the same /me + /public areas as SFTP. Needs A record
# ${FILES_DOMAIN} -> this host. Disabled when AGENTBBS_FILES=0 (server not up).
FILES_SITE="
${FILES_DOMAIN} {
encode zstd gzip
# A pure file server (NOT a website host — member homepages live on the BBS
# at https://${DOMAIN}/~<name>). The agentbbs file-manager on loopback serves:
# / directory of all members (links to each one's BBS site
# and their public files here) — no auth
# /~<name>/public[/] a member's own public files — anon read-only browse
# (signed in) the member's two areas: private /me and public /public
# A member has two SEPARATE areas: /me (private) and /public (their own public
# files, served at ~<name>/public). Clean URLs map 1:1 to the SFTP paths:
# scp index.html files@${FILES_DOMAIN}:/public/
# -> https://${FILES_DOMAIN}/~<name>/public/index.html
# The anon surface only ever exposes ~name/public, never a member's private
# /me (see internal/files web tests); it is read-only.
reverse_proxy http://${FILES_WEB_ADDR}
}
"
# Web IRC client (${CHAT_DOMAIN}): Caddy fronts The Lounge, which runs as a
# separately-provisioned docker container (thelounge/thelounge) publishing
# ${CHAT_ADDR}. setup.sh does NOT manage the container — only this route, so a
# deploy that regenerates the Caddyfile no longer drops chat (it used to). Needs
# A record ${CHAT_DOMAIN} -> this host. Returns 502 if the container isn't up.
CHAT_SITE=""
if [ "$CHAT" = "1" ]; then
CHAT_SITE="
${CHAT_DOMAIN} {
encode zstd gzip
reverse_proxy ${CHAT_ADDR}
}
"
fi
cat > /etc/caddy/Caddyfile <<CADDY
{
email ${ACME_EMAIL}
# Custom user domains get certificates on demand, but only for hosts the
# BBS has actually mapped — agentbbs answers this ask query (200/404) so
# this is not an open certificate relay.
on_demand_tls {
ask http://${ASK_ADDR}/check
}
}
${DOMAIN} {
encode zstd gzip
# join@ email confirmation links (agentbbs loopback /verify endpoint)
handle /verify {
reverse_proxy http://${HTTP_ADDR}
}
# IRC over WebSocket: Caddy terminates TLS and proxies to Ergo's loopback
# WebSocket listener, so web clients hit wss://${DOMAIN}/irc and agents get a
# WebSocket transport without exposing another public port. (No-op if IRC=0;
# Ergo just isn't listening on 8097, so /irc returns 502.)
handle /irc {
reverse_proxy 127.0.0.1:8097
}
# tilde.town-style homepages: /~name[/path] -> 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}${FILES_SITE}${CHAT_SITE}
# Free per-user homepages at <name>.${DOMAIN} (needs wildcard DNS
# *.${DOMAIN} -> this host). On-demand TLS mints a cert only when agentbbs's
# ask endpoint confirms <name> 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
# MOTD: pull the shared Message of the Day from profullstack.com into Ergo's
# MOTD file (shown on IRC connect / via /MOTD). The ergo-motd.timer keeps it
# fresh; Ergo reads it on start, so write it before the service comes up.
install -m 0755 "${SRC_DIR}/deploy/ergo/refresh-motd.sh" /usr/local/bin/ergo-refresh-motd
ERGO_CONF_DIR=/etc/ergo /usr/local/bin/ergo-refresh-motd || true
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 <<UNIT
[Unit]
Description=Ergo IRC server (AgentBBS — ${IRC_DOMAIN})
After=network-online.target
Wants=network-online.target
[Service]
User=ergo
Group=ergo
WorkingDirectory=/opt/ergo
ExecStart=/opt/ergo/ergo run --conf /etc/ergo/ircd.yaml
# Ergo rehashes config + reloads TLS certs on SIGHUP.
ExecReload=/bin/kill -HUP \$MAINPID
Restart=always
RestartSec=2
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=${ERGO_DATA} /etc/ergo
[Install]
WantedBy=multi-user.target
UNIT
# Daily cert refresh from Caddy (tracks auto-renewals).
cat > /etc/systemd/system/ergo-certs.service <<UNIT
[Unit]
Description=Refresh Ergo TLS cert from Caddy for ${IRC_DOMAIN}
[Service]
Type=oneshot
Environment=DOMAIN=${IRC_DOMAIN}
Environment=ERGO_DATA=${ERGO_DATA}
ExecStart=/usr/local/bin/ergo-refresh-certs
UNIT
cat > /etc/systemd/system/ergo-certs.timer <<UNIT
[Unit]
Description=Periodic Ergo TLS cert refresh from Caddy
[Timer]
OnBootSec=5min
OnUnitActiveSec=12h
Persistent=true
[Install]
WantedBy=timers.target
UNIT
# Hourly MOTD refresh from profullstack.com/motd (shared across all properties).
cat > /etc/systemd/system/ergo-motd.service <<UNIT
[Unit]
Description=Refresh Ergo MOTD from profullstack.com for ${IRC_DOMAIN}
[Service]
Type=oneshot
Environment=ERGO_CONF_DIR=/etc/ergo
ExecStart=/usr/local/bin/ergo-refresh-motd
UNIT
cat > /etc/systemd/system/ergo-motd.timer <<UNIT
[Unit]
Description=Periodic Ergo MOTD refresh from profullstack.com
[Timer]
OnBootSec=5min
OnUnitActiveSec=1h
Persistent=true
[Install]
WantedBy=timers.target
UNIT
systemctl daemon-reload
systemctl enable ergo >/dev/null 2>&1 || true
systemctl restart ergo
systemctl enable --now ergo-certs.timer >/dev/null 2>&1 || true
systemctl enable --now ergo-motd.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"
# ---- chat password bridge for the BBS passwd@ flow ----------------------
# The Ergo password store + The Lounge user files are root-owned, but the BBS
# runs as ${SVC_USER}. Install set-irc-password.sh to a stable path and grant
# ${SVC_USER} a narrow NOPASSWD sudo rule for exactly that command, so a member
# running `ssh passwd@` can set their chat password alongside git + mail. The
# password travels on stdin (the "-" form), so it never appears in sudo's log.
install -m 0755 -o root -g root \
"${SRC_DIR}/scripts/set-irc-password.sh" /usr/local/sbin/agentbbs-set-irc-password
cat > /etc/sudoers.d/agentbbs-ircpass <<SUDO
${SVC_USER} ALL=(root) NOPASSWD: /usr/local/sbin/agentbbs-set-irc-password
SUDO
chmod 0440 /etc/sudoers.d/agentbbs-ircpass
if visudo -cf /etc/sudoers.d/agentbbs-ircpass >/dev/null 2>&1; then
upsert_env AGENTBBS_SET_IRC_PASSWD /usr/local/sbin/agentbbs-set-irc-password
else
warn "sudoers rule for the chat password bridge is invalid — removing it (passwd@ will skip chat)"
rm -f /etc/sudoers.d/agentbbs-ircpass
fi
else
systemctl disable --now ergo ergo-certs.timer ergo-motd.timer >/dev/null 2>&1 || true
rm -f /etc/sudoers.d/agentbbs-ircpass /usr/local/sbin/agentbbs-set-irc-password
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 <<UNIT
[Unit]
Description=Refresh AgentBBS news (NNTPS) TLS cert from Caddy for news.${DOMAIN}
[Service]
Type=oneshot
Environment=DOMAIN=${DOMAIN}
Environment=NEWS_HOST=news.${DOMAIN}
Environment=NEWS_TLS_DIR=${NEWS_TLS_DIR}
Environment=SVC_USER=${SVC_USER}
ExecStart=/usr/local/bin/agentbbs-news-certs
UNIT
cat > /etc/systemd/system/agentbbs-news-certs.timer <<UNIT
[Unit]
Description=Periodic AgentBBS news TLS cert refresh from Caddy
[Timer]
OnBootSec=5min
OnUnitActiveSec=12h
Persistent=true
[Install]
WantedBy=timers.target
UNIT
systemctl daemon-reload
systemctl enable --now 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" <<FJ
APP_NAME = AgentGit
RUN_USER = forgejo
RUN_MODE = prod
[server]
PROTOCOL = http
HTTP_ADDR = ${FORGEJO_HTTP_ADDR%%:*}
HTTP_PORT = ${FORGEJO_HTTP_ADDR##*:}
DOMAIN = ${GIT_DOMAIN}
ROOT_URL = https://${GIT_DOMAIN}/
SSH_DOMAIN = ${GIT_DOMAIN}
# Built-in SSH server (in-process, runs as the forgejo user) so members can push
# with the same key they use for the BBS. Host :22 is agentbbs and :2202 is the
# admin OpenSSH, so Forgejo gets its own port; clones use ssh://git@host:PORT/.
DISABLE_SSH = false
START_SSH_SERVER = true
SSH_USER = git
SSH_PORT = ${FORGEJO_SSH_PORT}
SSH_LISTEN_PORT = ${FORGEJO_SSH_PORT}
[database]
DB_TYPE = sqlite3
PATH = ${FORGEJO_DATA}/data/forgejo.db
[repository]
ROOT = ${FORGEJO_DATA}/repos
[service]
DISABLE_REGISTRATION = true
# Public read: member profiles (git.${DOMAIN#*.}/<name>) and public repos are
# viewable without signing in; private repos stay private. Accounts are created
# only by agentbbs (DISABLE_REGISTRATION), never self-serve.
REQUIRE_SIGNIN_VIEW = false
DEFAULT_KEEP_EMAIL_PRIVATE = true
[security]
INSTALL_LOCK = true
SECRET_KEY = ${FJ_SECRET_KEY}
INTERNAL_TOKEN = ${FJ_INTERNAL_TOKEN}
[log]
ROOT_PATH = ${FORGEJO_DATA}/log
FJ
chown forgejo:forgejo "$FORGEJO_CONF"
chmod 0640 "$FORGEJO_CONF"
fi
log "installing forgejo.service"
cat > /etc/systemd/system/forgejo.service <<UNIT
[Unit]
Description=Forgejo (AgentGit backend — ${GIT_DOMAIN})
After=network-online.target
Wants=network-online.target
[Service]
User=forgejo
Group=forgejo
WorkingDirectory=${FORGEJO_DATA}
Environment=GITEA_WORK_DIR=${FORGEJO_DATA}
ExecStart=/usr/local/bin/forgejo web --config ${FORGEJO_CONF} --work-path ${FORGEJO_DATA}
Restart=always
RestartSec=2
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=${FORGEJO_DATA} /etc/forgejo
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable forgejo >/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"
# Open the Forgejo SSH port so members can push (git@${GIT_DOMAIN}:${FORGEJO_SSH_PORT}).
ufw allow "${FORGEJO_SSH_PORT}/tcp" >/dev/null 2>&1 || true
# 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,read:user,write:user \
--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 (server on ${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 EVERY verified member
# (free + paid) — addresses are <name>@${DOMAIN}, the server is ${MAIL_DOMAIN}.
# Full setup, DNS, and the gateway master user: docs/mail.md. Disable with MAIL_STACK=0.
MAILU_DIR="${SRC_DIR}/deploy/mailu"
if [ "$MAIL_STACK" = "1" ]; then
log "configuring Mailu mail stack (server ${MAIL_DOMAIN}, addresses @${DOMAIN})"
# Tell agentbbs how to reach the mailbox backend (master user/pass + the Mailu
# API token are secrets the operator sets; see docs/mail.md).
upsert_env AGENTBBS_MAIL_DOMAIN "${MAIL_DOMAIN}"
upsert_env AGENTBBS_MAIL_ADDR_DOMAIN "${DOMAIN}"
# The gateway reads Dovecot DIRECTLY over loopback (docker-compose.override.yml
# publishes it on 127.0.0.1:14143), bypassing Mailu's front nginx auth proxy so
# the master-user login works. Plaintext is safe — it never leaves the host.
# See docs/mail.md ("Why the gateway talks to Dovecot directly").
upsert_env AGENTBBS_MAIL_IMAP_ADDR "127.0.0.1:14143"
upsert_env AGENTBBS_MAIL_IMAP_PLAINTEXT "1"
upsert_env AGENTBBS_MAIL_SMTP_ADDR "127.0.0.1:25"
# Dial the loopback relay but verify its STARTTLS cert against the mail host
# (its cert is for ${MAIL_DOMAIN}, never 127.0.0.1) — no /etc/hosts hack needed.
upsert_env AGENTBBS_MAIL_SMTP_SERVERNAME "${MAIL_DOMAIN}"
# 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 <<UNIT
[Unit]
Description=Refresh Mailu TLS cert from Caddy for ${MAIL_DOMAIN}
[Service]
Type=oneshot
Environment=DOMAIN=${DOMAIN#*.}
Environment=MAIL_HOST=${MAIL_DOMAIN}
Environment=MAILU_DIR=${MAILU_DIR}
ExecStart=/usr/local/bin/agentbbs-mailu-certs
UNIT
cat > /etc/systemd/system/agentbbs-mailu-certs.timer <<UNIT
[Unit]
Description=Periodic Mailu TLS cert refresh from Caddy
[Timer]
OnBootSec=5min
OnUnitActiveSec=12h
Persistent=true
[Install]
WantedBy=timers.target
UNIT
systemctl daemon-reload
systemctl enable --now 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 <<DONE
DNS point ${DOMAIN} (A record) at this droplet's public IP.
Admin SSH ssh -p ${ADMIN_SSH_PORT} <you>@${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 <domain> point your own domain at your homepage
Web https://${DOMAIN}/ site root
https://${DOMAIN}/~<name> a member's homepage
https://<your-domain> 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 <pw> 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} <you>@${DOMAIN} works."
warn "If you attached a DigitalOcean Cloud Firewall, also allow ${ADMIN_SSH_PORT}, 22, 80, 443 there."