mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +00:00
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>
1320 lines
60 KiB
Bash
Executable file
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 & 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 & 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 & 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}/<name></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 & 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."
|