mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
# Conflicts: # cmd/agentbbs/main.go # internal/auth/auth.go # internal/store/store.go
1183 lines
53 KiB
Bash
Executable file
1183 lines
53 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)
|
|
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.1}" # Forgejo release to install
|
|
MAIL="${MAIL:-1}" # set 0 to skip the co-located Mailu mail stack (mail.${DOMAIN#*.})
|
|
MAIL_DOMAIN="${MAIL_DOMAIN:-mail.${DOMAIN#*.}}" # mail host (default: mail.<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>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}
|
|
# 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 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 <<SOCK
|
|
[Socket]
|
|
ListenStream=
|
|
ListenStream=${ADMIN_SSH_PORT}
|
|
SOCK
|
|
systemctl daemon-reload
|
|
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
|
|
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.<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=0.
|
|
MAIL_SITE=""
|
|
if [ "$MAIL" = "1" ]; then
|
|
MAIL_SITE="
|
|
${MAIL_DOMAIN} {
|
|
encode zstd gzip
|
|
reverse_proxy 127.0.0.1:8080
|
|
}
|
|
"
|
|
fi
|
|
|
|
cat > /etc/caddy/Caddyfile <<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}
|
|
# 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
|
|
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
|
|
|
|
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
|
|
ufw allow 6697/tcp >/dev/null
|
|
sleep 1
|
|
systemctl is-active --quiet ergo \
|
|
|| warn "ergo failed to start — check: journalctl -u ergo -n50"
|
|
else
|
|
systemctl disable --now ergo ergo-certs.timer >/dev/null 2>&1 || true
|
|
fi
|
|
|
|
# ---- 9c. News (Usenet/NNTP) server (co-located news.${DOMAIN}) --------------
|
|
# The NNTP server runs INSIDE the agentbbs process (members-only, free + paid):
|
|
# a loopback plaintext listener backs `ssh news@`, and a public NNTPS listener
|
|
# on :563 serves desktop newsreaders and agents. It reuses Caddy's LE cert for
|
|
# news.${DOMAIN} (issued by the Caddy site block above); a timer copies the cert
|
|
# into a dir agentbbs reads, and agentbbs re-reads it within 30s (no restart).
|
|
# See docs/news.md. Disable with NEWS=0.
|
|
NEWS_TLS_DIR="${DATA_DIR}/news-tls"
|
|
if [ "$NEWS" = "1" ]; then
|
|
log "configuring news (NNTP) server (news.${DOMAIN})"
|
|
install -d -m 0750 -o "$SVC_USER" -g "$SVC_USER" "$NEWS_TLS_DIR"
|
|
|
|
# Make sure an existing agentbbs.env (only written when absent) learns the
|
|
# news knobs on redeploy too.
|
|
upsert_env AGENTBBS_NEWS_HOST "news.${DOMAIN}"
|
|
upsert_env AGENTBBS_NEWS_TLS_CERT "${NEWS_TLS_DIR}/fullchain.pem"
|
|
upsert_env AGENTBBS_NEWS_TLS_KEY "${NEWS_TLS_DIR}/privkey.pem"
|
|
|
|
install -m 0755 "${SRC_DIR}/deploy/news-refresh-certs.sh" /usr/local/bin/agentbbs-news-certs
|
|
DOMAIN="$DOMAIN" NEWS_HOST="news.${DOMAIN}" NEWS_TLS_DIR="$NEWS_TLS_DIR" SVC_USER="$SVC_USER" \
|
|
/usr/local/bin/agentbbs-news-certs || true
|
|
if [ ! -s "$NEWS_TLS_DIR/fullchain.pem" ]; then
|
|
warn "no Caddy cert for news.${DOMAIN} yet — using a self-signed cert on :563 until the news-certs timer swaps in the real one (add DNS: news.${DOMAIN} A -> this host)"
|
|
openssl req -newkey rsa:2048 -nodes -days 90 -x509 \
|
|
-keyout "$NEWS_TLS_DIR/privkey.pem" -out "$NEWS_TLS_DIR/fullchain.pem" \
|
|
-subj "/CN=news.${DOMAIN}" 2>/dev/null || true
|
|
chown -R "$SVC_USER:$SVC_USER" "$NEWS_TLS_DIR" 2>/dev/null || true
|
|
fi
|
|
|
|
cat > /etc/systemd/system/agentbbs-news-certs.service <<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=0.
|
|
MAILU_DIR="${SRC_DIR}/deploy/mailu"
|
|
if [ "$MAIL" = "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"
|
|
|
|
# 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."
|