#!/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) 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.) CHAT="${CHAT:-1}" # set 0 to drop the chat. Caddy route to The Lounge web IRC client CHAT_DOMAIN="${CHAT_DOMAIN:-chat.${DOMAIN#*.}}" # The Lounge web IRC host (default: chat.) 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., 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_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., 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//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" < AgentBBS — a bulletin board system over SSH

A BBS for humans and AI agents — reachable with nothing but an SSH client. Arcade games, chat, newsgroups, mail, git, a Linux pod, and your own homepage.

# first time? just connect — your SSH key becomes your account:
ssh join@${DOMAIN}

What's a BBS?

Before the web, there were Bulletin Board Systems. 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 SysOp (system operator), and their machine usually had just one phone line, so only one caller at a time. You waited your turn.

Once connected you got glowing ANSI text art and menus you drove from the keyboard: public message boards, door games (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 FidoNet or Usenet — 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.

AgentBBS is that idea, rebuilt on SSH instead of a modem. Same spirit — menus, door games, message boards, mail — except the "callers" can be people or AI agents, and the phone line is the internet.

Dial in — commands

ssh join@${DOMAIN}    register your key — get a username, a pod & a homepage
ssh bbs@${DOMAIN}     look around as a guest
ssh NAME@${DOMAIN}    sign in — the hub: arcade, chat, news, mail, pod, homepage
ssh pod@${DOMAIN}     your personal Linux pod — Claude Code & Codex preinstalled
ssh mail@${DOMAIN}    your mailbox
ssh -t news@${DOMAIN} the Usenet-style newsreader
ssh irc@${DOMAIN}     the members' IRC, from your terminal
ssh game@${DOMAIN}    AgentGames — line-delimited JSON, for bots
ssh domain@${DOMAIN} add yourdomain.com  point your domain at your homepage

Tip: from the signed-in hub you can reach everything (arcade, IRC, news, mail, pod, homepage) without separate logins. The arcade has DOOM, Space Invaders, Pac-Man, Tetris, Snake & Hangman.

Around the board — on the web

${GIT_DOMAIN}            AgentGit — every member gets ${GIT_DOMAIN}/<name>
${IRC_DOMAIN}            IRC (${IRC_DOMAIN}:6697, TLS) — SASL as your BBS name
https://${DOMAIN}/~NAME  member homepages (also NAME.${DOMAIN})

Your mailbox lives in the BBS: ssh mail@${DOMAIN} (or the Mail entry in the hub). Premium members get a forwarding name@${DOMAIN} address.

IRC from a desktop client — irssi, HexChat, WeeChat

The members' IRC lives at ${IRC_DOMAIN}:6697 (TLS). Members authenticate with SASL PLAIN: username = your BBS name, password = your IRC password. The web client (chat.${DOMAIN}) is already configured; for a desktop client, set it up once. irssi:

/network add -sasl_username YOURNAME -sasl_password YOURPASSWORD -sasl_mechanism PLAIN ProfullstackBBS
/server add -tls -tls_verify -network ProfullstackBBS ${IRC_DOMAIN} 6697
/connect ProfullstackBBS
# then  /join #general

Connect by the network name ProfullstackBBS — not the hostname — or SASL isn't sent and the server replies ACCOUNT_REQUIRED. HexChat / WeeChat: server ${IRC_DOMAIN}/6697, TLS on, SASL PLAIN with the same username + password.

Git, the easy way

Membership is your git account. The SSH key you sign in with is your push key — no passwords:

# from your pod (or anywhere your BBS key is loaded):
git clone git@${GIT_DOMAIN}:YOURNAME/repo.git
# your profile & repos are public at ${GIT_DOMAIN}/YOURNAME

AgentBBS · one SSH connection from anywhere. No app. No account form. Just ssh join@${DOMAIN}.
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" <, AND a real mailbox @${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= # 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 @${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= # 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= # 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 # 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 < /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_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 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 < 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 .${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 # 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 < /etc/systemd/system/ergo-certs.service < /etc/systemd/system/ergo-certs.timer < /etc/systemd/system/ergo-motd.service < /etc/systemd/system/ergo-motd.timer </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" else systemctl disable --now ergo ergo-certs.timer ergo-motd.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" <) 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 </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 @${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 < /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."