Autonomous deploy + free-pod/Premium-email membership (#1)

Deploy automation (idempotent, runs on every deploy):
- .github/workflows/deploy.yml: push to main/master (or dispatch) SSHes to the
  droplet and re-runs setup.sh; deploys the pushed branch; smoke-tests :22.
- scripts/self-update.sh + agentbbs-update.timer: autonomous backstop that
  redeploys only when origin advances.
- setup.sh hardened: flock, fetch+reset (survives force-push), fixed the
  always-skipped arcade asset fetch path.

Membership model:
- Free, email-verified members get their own Docker pod (pod@ paywall removed)
  and a /~name homepage (seeded at join@).
- join@ is now interactive: email -> emailed 6-digit code -> enter code.
- Premium ($10 one-time, lifetime via CoinPay) grants a personal
  <name>@host email (new internal/forwardemail; forwardemail.net aliases) and
  custom domains (domain@ gated to Premium).
- ensurePremium() silently verifies/grants/provisions on hub login, join@, and
  domain@. New-signup details emailed to AGENTBBS_SIGNUP_NOTIFY (subject "bbs").

Store: User.Premium + premium/premium_ref cols, ConfirmEmailCode, GrantPremium.
Tests: store_premium_test.go, forwardemail_test.go. Build/vet/gofmt/test green.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-14 00:59:52 -07:00 committed by GitHub
parent 1086d57a4d
commit 7a85f2dbbb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 900 additions and 101 deletions

87
.github/workflows/deploy.yml vendored Normal file
View file

@ -0,0 +1,87 @@
name: deploy
# Fully autonomous, idempotent deploy. On every push to main (or manual
# dispatch) this SSHes to the bbs.profullstack.com droplet and re-runs the
# idempotent provisioner (setup.sh), which pulls origin, rebuilds the Go
# binaries, and restarts the agentbbs service that answers
# `ssh join@bbs.profullstack.com`. Re-running is always safe.
#
# Required repo secrets (Settings -> Secrets and variables -> Actions):
# DEPLOY_SSH_KEY private key whose public half is in the droplet admin
# user's ~/.ssh/authorized_keys
# DEPLOY_HOST bbs.profullstack.com (or the droplet's public IP)
# Optional (have sensible defaults below):
# DEPLOY_USER admin SSH user (default: root)
# DEPLOY_PORT admin SSH port (default: 2202 — setup.sh moves OpenSSH here)
#
# The admin user needs passwordless sudo (root already does).
on:
push:
branches: [main, master]
workflow_dispatch:
# Never let two deploys overlap; setup.sh also self-locks, this is belt+braces.
concurrency:
group: deploy-production
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Configure SSH
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT || '2202' }}
run: |
test -n "$DEPLOY_SSH_KEY" || { echo "::error::DEPLOY_SSH_KEY secret is not set"; exit 1; }
test -n "$DEPLOY_HOST" || { echo "::error::DEPLOY_HOST secret is not set"; exit 1; }
install -d -m 700 ~/.ssh
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/id_deploy
chmod 600 ~/.ssh/id_deploy
ssh-keyscan -p "$DEPLOY_PORT" -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null
- name: Provision / redeploy (idempotent)
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER || 'root' }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT || '2202' }}
# Deploy whichever branch was pushed (main or master), so a rename
# "just works". For workflow_dispatch this is the chosen branch.
DEPLOY_BRANCH: ${{ github.ref_name }}
run: |
ssh -i ~/.ssh/id_deploy -p "$DEPLOY_PORT" \
-o BatchMode=yes -o StrictHostKeyChecking=yes \
"${DEPLOY_USER}@${DEPLOY_HOST}" \
"sudo -n env BRANCH=$(printf %q "$DEPLOY_BRANCH") bash -s" <<'REMOTE'
set -euo pipefail
REPO=https://github.com/profullstack/agentbbs.git
BRANCH="${BRANCH:-main}"
SRC=/opt/agentbbs
# Bootstrap on a fresh box, then always sync to origin so we run the
# latest setup.sh (it may have changed in this very push).
if [ ! -d "$SRC/.git" ]; then
git clone --depth 1 -b "$BRANCH" "$REPO" "$SRC"
fi
git -C "$SRC" fetch --depth 1 origin "$BRANCH"
git -C "$SRC" reset --hard "origin/$BRANCH"
exec env BRANCH="$BRANCH" "$SRC/setup.sh"
REMOTE
- name: Smoke-test that agentbbs serves :22
if: success()
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
run: |
# Confirm an SSH server answers on :22 WITHOUT authenticating — a real
# join@ connection would register the connecting key as a new account,
# so we never complete a handshake here. Admin OpenSSH lives on the
# admin port, so anything serving :22 is agentbbs answering join@/bbs@.
if timeout 15 ssh-keyscan -T 10 -p 22 "$DEPLOY_HOST" 2>/dev/null | grep -q .; then
echo "::notice::agentbbs is serving SSH on ${DEPLOY_HOST}:22 (join@ is reachable)"
else
echo "::error::nothing is serving SSH on ${DEPLOY_HOST}:22 — agentbbs may be down"
exit 1
fi

View file

@ -5,13 +5,19 @@ by Profullstack, Inc.
```bash
ssh bbs@profullstack.com # the hub: arcade (DOOM, snake), leaderboards — guests welcome
ssh join@profullstack.com # register your SSH key (prints instructions, disconnects)
ssh join@profullstack.com # register + confirm email by code, then the Premium offer
ssh <name>@profullstack.com # the hub as a member — or finger someone else's name
ssh pod@profullstack.com # your own Linux pod — members, $1/mo via CoinPay
ssh pod@profullstack.com # your own Linux pod — FREE for verified members
ssh domain@profullstack.com # point your domain at your homepage (Premium)
ssh video-<code>@profullstack.com # join a PairUX video call as truecolor ASCII
ssh agent@profullstack.com # chat with the operator's AI agent
```
**Membership:** verified-email members are **free** — each gets a Docker pod
(`ssh pod@`) and a homepage at `https://host/~name`. **Premium** ($10 one-time,
lifetime) adds a personal `name@host` email (via forwardemail.net) and custom
domains.
No browser, no install, no client download. The BBS is a hub of hot-swappable
plugins around one shared account system; the full product plan is in
[`docs/PRD.md`](docs/PRD.md), [`docs/pods.md`](docs/pods.md),
@ -61,6 +67,19 @@ Ops:
./agentbbs grant-pod alice 12 # manual pod grant (12 months)
```
## Deploy
The production host (`bbs.profullstack.com`) is provisioned by the idempotent
[`setup.sh`](setup.sh) and stays current automatically:
- **Every push to `main`** runs [`.github/workflows/deploy.yml`](.github/workflows/deploy.yml),
which SSHes to the droplet and re-runs `setup.sh` (pull + rebuild + restart).
- A **self-update systemd timer** (`scripts/self-update.sh`, installed by
`setup.sh`) polls origin every 15 min and redeploys only when it advances, so
the box self-heals even if CI is down.
Full details, required secrets, and ops commands: [`docs/deploy.md`](docs/deploy.md).
## Architecture
- **Go + charmbracelet**`wish` SSH server, `bubbletea` TUIs, `lipgloss` styling.

View file

@ -4,10 +4,10 @@
//
// ssh bbs@host the BBS hub, guests welcome (play@/guest@ are aliases)
// ssh <name>@host the hub as a member/agent (SSH key required)
// ssh join@host onboarding: registers your key, prints instructions,
// and disconnects — no session
// ssh pod@host your personal Linux pod (paid membership, $1/mo via coinpay)
// ssh domain@host point your own domain at your homepage (add/rm/list)
// ssh join@host onboarding: registers your key, confirms your email with an
// emailed code, then offers $10 lifetime Premium (CoinPay)
// ssh pod@host your personal Linux pod — free for verified members
// ssh domain@host point your own domain at your homepage (Premium; add/rm/list)
//
// Subcommands:
//
@ -21,7 +21,7 @@ import (
"bufio"
"context"
"crypto/rand"
"encoding/hex"
"encoding/binary"
"errors"
"fmt"
"net"
@ -46,6 +46,7 @@ import (
"github.com/profullstack/agentbbs/internal/auth"
"github.com/profullstack/agentbbs/internal/calls"
"github.com/profullstack/agentbbs/internal/chat"
"github.com/profullstack/agentbbs/internal/forwardemail"
"github.com/profullstack/agentbbs/internal/hub"
"github.com/profullstack/agentbbs/internal/mail"
"github.com/profullstack/agentbbs/internal/payments"
@ -72,6 +73,7 @@ type app struct {
registry []plugin.Plugin
sandbox *sandbox.Runner
mail mail.Config
fe forwardemail.Config // premium @bbs email provisioning
dataDir string
assets string
host string // public hostname used in user-facing messages
@ -96,13 +98,19 @@ func main() {
return
}
host := env("AGENTBBS_HOST", "profullstack.com")
fe := forwardemail.ConfigFromEnv()
if fe.Domain == "" {
fe.Domain = host // personal addresses live on the BBS host by default
}
a := &app{
st: st,
sandbox: sandbox.New(sandbox.Mode(env("AGENTBBS_SANDBOX", "auto"))),
mail: mail.ConfigFromEnv(),
fe: fe,
dataDir: dataDir,
assets: env("AGENTBBS_ASSETS", "./assets"),
host: env("AGENTBBS_HOST", "profullstack.com"),
host: host,
}
a.registry = []plugin.Plugin{arcade.Plugin{}, about.Plugin{}}
@ -240,6 +248,9 @@ func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
if su.Name != username {
wish.Println(s, "note: this key belongs to "+su.Name+" — signed in as "+su.Name+".")
}
// Catch a premium payment that settled since their last visit (silent;
// provisions their @host email alias on the transition).
a.ensurePremium(&su)
u = auth.User{Name: su.Name, Kind: auth.Kind(su.Kind), PubKeyFP: fp, StoreID: su.ID}
}
@ -258,8 +269,9 @@ func (a *app) teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
return hub.New(u, ctx, a.registry), []tea.ProgramOption{tea.WithAltScreen()}
}
// handleJoin registers the visitor's key, prints instructions, disconnects
// ("it just shows the message and kicks them off the server").
// handleJoin runs onboarding interactively in one SSH session: register the
// visitor's key, confirm their email with a code we email them, then offer the
// $10 lifetime Premium membership (CoinPay). It then disconnects.
func (a *app) handleJoin(s ssh.Session) {
fp := auth.Fingerprint(s.PublicKey())
if fp == "" {
@ -278,68 +290,232 @@ func (a *app) handleJoin(s ssh.Session) {
}
_, _ = a.st.RecordSession(u.ID, s.User(), remoteIP(s), "join")
// Collect an email and send a confirmation link. The connecting key is the
// "uploaded" public key; this prompt adds (or refreshes) the email and
// re-issues verification. Requires an interactive session (ssh join@host).
wish.Print(s, " Email (for account confirmation): ")
line, _ := bufio.NewReader(s).ReadString('\n')
email := strings.TrimSpace(line)
confirm := " confirm no email captured — re-run from an interactive terminal: ssh join@" + a.host
if validEmail(email) {
token := randToken()
if err := a.st.SetEmailVerification(u.ID, email, token); err != nil {
log.Error("set verification", "err", err)
confirm = " confirm error saving email; please retry"
} else {
url := "https://" + a.host + "/verify?token=" + token
switch {
case !a.mail.Configured():
log.Warn("smtp not configured — confirmation link not emailed", "email", email, "url", url)
confirm = " confirm email is not configured on this host yet; an admin must verify you"
case a.mail.Send(email, "Confirm your AgentBBS account", verifyEmailBody(u.Name, url)) != nil:
confirm = " confirm couldn't send the email; please retry or contact an admin"
default:
confirm = " confirm check " + email + " for a confirmation link to activate your account"
}
}
} else if email != "" {
confirm = " confirm that doesn't look like an email — re-run: ssh join@" + a.host
}
ref := payments.Reference("pod", fp)
in := bufio.NewReader(s)
wish.Println(s, "\n"+strings.Join([]string{
"",
" Welcome to AgentBBS — you're registered.",
" Welcome to AgentBBS — let's set up your account.",
"",
" account " + u.Name,
" key " + fp,
confirm,
"",
" BBS hub ssh " + u.Name + "@" + a.host,
" Guest hub ssh bbs@" + a.host,
"",
" Personal pod (" + payments.PodPriceLabel + ", via CoinPay):",
" 1. pay: " + payments.PayCommand(ref),
" 2. enter: ssh pod@" + a.host,
"",
}, "\n"))
// 1) email -> emailed code -> enter code. A verified account is a free
// member: it gets a Docker pod (ssh pod@) and a /~name homepage.
if !u.EmailVerified {
if !a.verifyEmailInteractive(s, in, &u) {
_ = s.Exit(1)
return
}
a.notifySignup(u)
}
// Every verified member gets a homepage at https://<host>/~<name>.
seedHomepage(filepath.Join(a.dataDir, "users", u.Name, "public_html"), u.Name, a.host)
wish.Println(s, "\n"+strings.Join([]string{
" You're in — free membership includes:",
" pod ssh pod@" + a.host + " your own Linux pod",
" hub ssh " + u.Name + "@" + a.host,
" homepage https://" + a.host + "/~" + u.Name,
}, "\n"))
// 2) Premium ($10 lifetime): personal @host email + custom domains.
a.offerPremium(s, &u)
_ = s.Exit(0)
}
// verifyEmailBody is the plain-text confirmation email.
func verifyEmailBody(name, url string) string {
// verifyEmailInteractive collects an email, emails a 6-digit code, and prompts
// the visitor to type it back. It updates *u and returns true once verified.
func (a *app) verifyEmailInteractive(s ssh.Session, in *bufio.Reader, u *store.User) bool {
var email string
for tries := 0; tries < 3; tries++ {
wish.Print(s, "\n Email: ")
line, err := in.ReadString('\n')
if err != nil {
return false
}
if e := strings.TrimSpace(line); validEmail(e) {
email = e
break
}
wish.Println(s, " that doesn't look like an email — try again.")
}
if email == "" {
wish.Println(s, " No valid email — run ssh join@"+a.host+" again when ready.")
return false
}
code := randCode()
if err := a.st.SetEmailVerification(u.ID, email, code); err != nil {
log.Error("set verification", "err", err)
wish.Println(s, " couldn't save your email; please retry.")
return false
}
switch {
case a.mail.Configured():
if err := a.mail.Send(email, "Your AgentBBS confirmation code", verifyCodeEmailBody(u.Name, code)); err != nil {
log.Error("send code", "err", err)
wish.Println(s, " couldn't email the code; please retry or contact an admin.")
return false
}
wish.Println(s, " Sent a 6-digit code to "+email+".")
default:
// No SMTP configured yet: show the code in-session so the box is still
// usable. Set AGENTBBS_SMTP_* in production so codes are emailed instead.
log.Warn("smtp not configured — showing join code in session", "email", email)
wish.Println(s, " (email isn't configured on this host yet — here is your code)")
wish.Println(s, " code: "+code)
}
for tries := 0; tries < 3; tries++ {
wish.Print(s, " Enter the code: ")
line, err := in.ReadString('\n')
if err != nil {
return false
}
vu, ok, err := a.st.ConfirmEmailCode(u.ID, strings.TrimSpace(line))
if err != nil {
log.Error("confirm code", "err", err)
wish.Println(s, " verification error; please retry.")
return false
}
if ok {
*u = vu
wish.Println(s, " Email confirmed ✓")
return true
}
wish.Println(s, " that code didn't match — try again.")
}
wish.Println(s, " Too many attempts — run ssh join@"+a.host+" again for a fresh code.")
return false
}
// ensurePremium upgrades *u to premium if its CoinPay charge has settled,
// provisioning the member's @host email alias on the transition. It is silent
// (no session output) so it is safe to call from the hub. Returns the current
// premium state.
func (a *app) ensurePremium(u *store.User) bool {
if u.Premium {
return true
}
ref := payments.PremiumReference(u.PubKeyFP)
if paid, checked := payments.VerifyPremium(ref); !checked || !paid {
return false
}
if err := a.st.GrantPremium(u.ID, ref); err != nil {
log.Error("grant premium", "err", err)
return false
}
u.Premium = true
// Create their <name>@host alias forwarding to the email they verified.
if a.fe.Configured() && u.Email != "" {
if err := a.fe.CreateAlias(u.Name, u.Email); err != nil {
log.Error("forwardemail alias", "err", err, "alias", a.fe.Address(u.Name))
}
}
return true
}
// showPremiumWelcome prints a premium member's perks: their personal email,
// where it forwards, the webmail URL, and custom domains.
func (a *app) showPremiumWelcome(s ssh.Session, u store.User) {
lines := []string{
"",
" ★ Premium — thanks! Your perks:",
"",
" email " + a.fe.Address(u.Name),
" forwards " + u.Email,
}
if url := a.fe.WebmailURL(); url != "" {
lines = append(lines, " webmail "+url)
}
lines = append(lines,
" domains ssh domain@"+a.host+" add <yourdomain.com>",
"",
)
wish.Println(s, strings.Join(lines, "\n"))
}
// offerPremium pitches the $10 lifetime membership — a personal @host email and
// custom domains. When CoinPay can mint a charge in-session it shows the exact
// amount and deposit address; otherwise it falls back to a pay command.
// Non-blocking: the member pays out of band and perks unlock on their next
// connect (or re-running join@).
func (a *app) offerPremium(s ssh.Session, u *store.User) {
// Maybe they already paid (e.g. re-ran join@ after paying).
if a.ensurePremium(u) {
a.showPremiumWelcome(s, *u)
return
}
ref := payments.PremiumReference(u.PubKeyFP)
lines := []string{
"",
" Upgrade to Premium — " + payments.PremiumPriceLabel + ", one-time:",
" • your own email " + a.fe.Address(u.Name) + " (forwards to you)",
" • custom domains point yourdomain.com at your homepage",
"",
}
if c, ok, err := payments.CreatePremiumCharge(ref); ok && err == nil {
amount := "$" + payments.PremiumAmount() + " " + payments.PremiumCurrency()
if c.CryptoAmount != "" {
cur := c.Currency
if cur == "" {
cur = strings.ToUpper(payments.PremiumBlockchain())
}
amount += " (≈ " + c.CryptoAmount + " " + cur + ")"
}
lines = append(lines,
" amount "+amount,
" send to "+c.Address,
)
if c.QR != "" {
lines = append(lines, " qr "+c.QR)
}
} else {
if err != nil {
log.Error("create premium charge", "err", err)
}
lines = append(lines, " pay: "+payments.PremiumPayCommand(ref))
}
lines = append(lines,
"",
" Perks unlock once payment confirms — then re-run: ssh join@"+a.host,
"",
)
wish.Println(s, strings.Join(lines, "\n"))
}
// notifySignup emails the operator the details of a newly verified signup.
// No-op when SMTP isn't configured. Subject is "bbs" per the operator's filter.
func (a *app) notifySignup(u store.User) {
to := env("AGENTBBS_SIGNUP_NOTIFY", "anthony@profullstack.com")
if !a.mail.Configured() || to == "" {
return
}
body := "New AgentBBS signup\n\n" +
" username: " + u.Name + "\n" +
" email: " + u.Email + "\n" +
" key: " + u.PubKeyFP + "\n" +
" homepage: https://" + a.host + "/~" + u.Name + "\n"
if err := a.mail.Send(to, "bbs", body); err != nil {
log.Error("signup notify", "err", err, "to", to)
}
}
// verifyCodeEmailBody is the plain-text confirmation-code email.
func verifyCodeEmailBody(name, code string) string {
return "Hi " + name + ",\n\n" +
"Confirm your AgentBBS account by opening this link:\n\n" +
" " + url + "\n\n" +
"Your AgentBBS confirmation code is:\n\n" +
" " + code + "\n\n" +
"Enter it in your open ssh join@ session to activate your account.\n" +
"If you didn't request this, you can ignore this email.\n"
}
// randToken returns a 128-bit hex token for email confirmation.
func randToken() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
// randCode returns a 6-digit numeric confirmation code.
func randCode() string {
var b [4]byte
_, _ = rand.Read(b[:])
return fmt.Sprintf("%06d", binary.BigEndian.Uint32(b[:])%1000000)
}
// validEmail is a deliberately loose check: one @, a dotted domain, no spaces.
@ -402,6 +578,18 @@ func (a *app) handleDomain(s ssh.Session) {
_ = s.Exit(1)
return
}
// Custom domains are a Premium perk ($10 lifetime). ensurePremium also
// catches a payment that settled since their last visit.
if !a.ensurePremium(&u) {
wish.Println(s, strings.Join([]string{
"",
" Custom domains are a Premium feature (" + payments.PremiumPriceLabel + ", one-time).",
" Upgrade: ssh join@" + a.host,
"",
}, "\n"))
_ = s.Exit(1)
return
}
if a.sites == nil {
wish.Println(s, "custom domains are temporarily unavailable on this host.")
_ = s.Exit(1)
@ -486,34 +674,15 @@ func (a *app) handlePod(s ssh.Session) {
_ = s.Exit(1)
return
}
// Email must be confirmed before paid features unlock (set
// AGENTBBS_REQUIRE_VERIFIED_EMAIL=0 to disable on a dev host).
// Pods are a FREE member benefit — the only gate is a confirmed email, so
// every registered member gets their own Docker pod (set
// AGENTBBS_REQUIRE_VERIFIED_EMAIL=0 to drop even that on a dev host).
if env("AGENTBBS_REQUIRE_VERIFIED_EMAIL", "1") != "0" && !u.EmailVerified {
wish.Println(s, " Confirm your email first — run: ssh join@"+a.host+" (then open the link we email you).")
wish.Println(s, " Confirm your email first — run: ssh join@"+a.host+" (we email you a code to enter).")
_ = s.Exit(1)
return
}
until, ok, _ := a.st.PodPaidUntil(u.ID)
if !ok || time.Now().After(until) {
// One verification attempt against the coinpay CLI before refusing.
ref := payments.Reference("pod", fp)
if paid, checked := payments.Verify(ref); checked && paid {
_ = a.st.GrantPod(u.ID, time.Now().Add(payments.PodTerm), ref)
} else {
wish.Println(s, strings.Join([]string{
"",
" Pod membership required (" + payments.PodPriceLabel + ").",
"",
" pay: " + payments.PayCommand(ref),
" then: ssh pod@" + a.host,
"",
}, "\n"))
_ = s.Exit(1)
return
}
}
if a.pods == nil {
wish.Println(s, "pods are temporarily unavailable on this host.")
_ = s.Exit(1)

66
docs/deploy.md Normal file
View file

@ -0,0 +1,66 @@
# Autonomous deploy
AgentBBS deploys to a single Ubuntu droplet (`bbs.profullstack.com`). The whole
provisioner — `setup.sh` — is **idempotent**: it pulls the tracked branch,
rebuilds the Go binaries, rewrites the systemd unit / Caddyfile / env, and
restarts the service that answers `ssh join@bbs.profullstack.com`. Re-running it
is always safe, so "deploy" just means "run `setup.sh` again."
Two mechanisms keep the box current, and they cooperate (both go through the
same `flock` in `setup.sh`, so they never race):
## 1. Push-triggered — GitHub Actions (`.github/workflows/deploy.yml`)
On every push to `main` (and via **Run workflow**), CI SSHes to the droplet's
admin port and runs `setup.sh`. This is the "runs on every deploy" path.
Configure these repo secrets — **Settings → Secrets and variables → Actions**:
| Secret | Required | Default | Notes |
| --- | --- | --- | --- |
| `DEPLOY_SSH_KEY` | yes | — | private key; its public half is in the droplet admin user's `~/.ssh/authorized_keys` |
| `DEPLOY_HOST` | yes | — | `bbs.profullstack.com` or the droplet IP |
| `DEPLOY_USER` | no | `root` | admin SSH user (needs passwordless sudo if not root) |
| `DEPLOY_PORT` | no | `2202` | admin OpenSSH port (`setup.sh` moves it off `:22`) |
The job bootstraps a bare box (clones `/opt/agentbbs` if missing), hard-resets to
`origin/main` so it always runs the latest `setup.sh`, then execs it, and finally
smoke-tests that something serves SSH on `:22`.
## 2. Pull-triggered — self-update timer (autonomous backstop)
`setup.sh` also installs `agentbbs-update.timer`, which runs
`scripts/self-update.sh` every `SELF_UPDATE_INTERVAL` (default 15 min). That
script `git fetch`es origin and, **only if the branch advanced or the service is
down**, re-runs `setup.sh`. When nothing changed it costs one fetch and exits, so
the box self-heals and stays current even if CI is unavailable.
Disable it with `SELF_UPDATE=0 ./setup.sh`; change the cadence with
`SELF_UPDATE_INTERVAL=5min ./setup.sh`.
## First-time bootstrap
The droplet is already provisioned. To bring up a fresh box manually:
```sh
git clone https://github.com/profullstack/agentbbs /opt/agentbbs
sudo /opt/agentbbs/setup.sh # DOMAIN/ADMIN_SSH_PORT/etc. overridable via env
```
After that, pushes to `main` deploy automatically.
## Operations
```sh
journalctl -u agentbbs -f # live BBS logs
systemctl status agentbbs # service health
systemctl list-timers agentbbs-update.timer # next self-update
sudo /opt/agentbbs/scripts/self-update.sh --force # force a redeploy now
```
## Note on the logicsrc connector
`@logicsrc/plugin-agentbbs` (in the `logicsrc` monorepo) is a **registry
connector** that talks to this running server over SSH — it is not installed on
the droplet and is not needed for `ssh join@bbs.profullstack.com` to work. The Go
server provisioned here is what serves all SSH routes.

View file

@ -0,0 +1,91 @@
// Package forwardemail provisions members' personal @bbs email addresses by
// creating aliases on forwardemail.net (https://forwardemail.net) via its REST
// API. A premium member gets <username>@<domain> forwarded to the real email
// they verified at join@. When unconfigured (no API key) Configured() reports
// false and callers just display the address without creating it.
//
// Config (env):
//
// AGENTBBS_FORWARDEMAIL_API_KEY forwardemail.net API key (HTTP basic user)
// AGENTBBS_FORWARDEMAIL_DOMAIN alias domain (defaults to the BBS host)
// AGENTBBS_WEBMAIL_URL webmail interface URL shown to members
package forwardemail
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
)
const apiBase = "https://api.forwardemail.net/v1"
// Config holds the forwardemail.net credentials and the alias domain.
type Config struct {
APIKey string
Domain string
Webmail string
}
// ConfigFromEnv reads the forwardemail settings from the environment.
func ConfigFromEnv() Config {
return Config{
APIKey: os.Getenv("AGENTBBS_FORWARDEMAIL_API_KEY"),
Domain: os.Getenv("AGENTBBS_FORWARDEMAIL_DOMAIN"),
Webmail: os.Getenv("AGENTBBS_WEBMAIL_URL"),
}
}
// Configured reports whether aliases can actually be created.
func (c Config) Configured() bool { return c.APIKey != "" && c.Domain != "" }
// WebmailURL is the webmail interface members use to read their mail (may be "").
func (c Config) WebmailURL() string { return c.Webmail }
// Address is the personal email for a username, e.g. alice@bbs.profullstack.com.
func (c Config) Address(localPart string) string { return localPart + "@" + c.Domain }
// CreateAlias creates (or confirms) localPart@Domain forwarding to recipient.
// It is idempotent: an "already exists" response is treated as success.
func (c Config) CreateAlias(localPart, recipient string) error {
if !c.Configured() {
return fmt.Errorf("forwardemail not configured")
}
form := url.Values{
"name": {localPart},
"recipients": {recipient},
"is_enabled": {"true"},
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
endpoint := apiBase + "/domains/" + url.PathEscape(c.Domain) + "/aliases"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint,
strings.NewReader(form.Encode()))
if err != nil {
return err
}
// forwardemail uses HTTP basic auth with the API key as the username and an
// empty password.
req.SetBasicAuth(c.APIKey, "")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
// Re-running for an existing member is normal — don't treat it as an error.
if strings.Contains(strings.ToLower(string(body)), "already exists") {
return nil
}
return fmt.Errorf("forwardemail create alias: %s: %s", resp.Status, strings.TrimSpace(string(body)))
}

View file

@ -0,0 +1,27 @@
package forwardemail
import "testing"
func TestConfiguredAndAddress(t *testing.T) {
var empty Config
if empty.Configured() {
t.Fatal("empty config must not be Configured")
}
if (Config{APIKey: "k"}).Configured() {
t.Fatal("API key without domain must not be Configured")
}
c := Config{APIKey: "k", Domain: "bbs.profullstack.com", Webmail: "https://webmail.example"}
if !c.Configured() {
t.Fatal("API key + domain should be Configured")
}
if got := c.Address("alice"); got != "alice@bbs.profullstack.com" {
t.Fatalf("Address = %q", got)
}
if c.WebmailURL() != "https://webmail.example" {
t.Fatalf("WebmailURL = %q", c.WebmailURL())
}
// Creating an alias without config is a clean error, not a panic.
if err := empty.CreateAlias("alice", "alice@x.com"); err == nil {
t.Fatal("CreateAlias on unconfigured must error")
}
}

View file

@ -16,6 +16,7 @@ import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"os/exec"
@ -29,6 +30,114 @@ const PodPriceLabel = "$1/mo"
// PodTerm is how much access one payment buys.
const PodTerm = 31 * 24 * time.Hour
// PremiumPriceLabel is the human-readable price for the one-time lifetime
// membership offered at join@.
const PremiumPriceLabel = "$10 (lifetime)"
// premium charge defaults — all overridable via env so the CoinPay surface can
// change without a rebuild (mirrors the pod templates above).
func PremiumAmount() string { return envOr("AGENTBBS_PREMIUM_AMOUNT", "10") }
func PremiumCurrency() string { return envOr("AGENTBBS_PREMIUM_CURRENCY", "USD") }
func PremiumBlockchain() string { return envOr("AGENTBBS_PREMIUM_BLOCKCHAIN", "eth") }
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
// Charge is a created CoinPay payment a user must fund: a unique deposit
// address plus the crypto amount (and the fiat amount it settles).
type Charge struct {
Address string `json:"payment_address"`
CryptoAmount string `json:"crypto_amount"`
Currency string `json:"crypto_currency"`
FiatAmount string `json:"amount"`
FiatCurrency string `json:"currency"`
ID string `json:"id"`
QR string `json:"qr_code"`
}
// PremiumReference derives the stable CoinPay memo for a user's lifetime
// membership from their key fingerprint.
func PremiumReference(pubkeyFP string) string { return Reference("premium", pubkeyFP) }
// CreatePremiumCharge shells out to the CoinPay CLI to mint a payment address
// for the $10 lifetime membership and parses the JSON it prints. created is
// false when no create command is configured or the CLI is unavailable, so the
// caller can fall back to PremiumPayCommand. The reference is passed as the
// payment metadata/memo so the eventual settlement reconciles to the account.
//
// AGENTBBS_COINPAY_PREMIUM_CREATE_CMD
// default: coinpay payment create --amount 10 --currency USD --blockchain eth --json --metadata %s
func CreatePremiumCharge(ref string) (Charge, bool, error) {
tmpl := os.Getenv("AGENTBBS_COINPAY_PREMIUM_CREATE_CMD")
if tmpl == "" {
tmpl = "coinpay payment create --amount " + PremiumAmount() +
" --currency " + PremiumCurrency() +
" --blockchain " + PremiumBlockchain() + " --json --metadata %s"
}
line := tmpl
if strings.Contains(tmpl, "%s") {
line = fmt.Sprintf(tmpl, ref)
} else {
line = tmpl + " " + ref
}
parts := strings.Fields(line)
if len(parts) == 0 {
return Charge{}, false, nil
}
if _, err := exec.LookPath(parts[0]); err != nil {
return Charge{}, false, nil // CLI not installed — caller falls back
}
out, err := exec.Command(parts[0], parts[1:]...).Output()
if err != nil {
return Charge{}, false, err
}
var c Charge
if err := json.Unmarshal(out, &c); err != nil {
// Some CLIs wrap the payment under a top-level key, e.g. {"payment":{…}}.
var wrap struct {
Payment Charge `json:"payment"`
}
if json.Unmarshal(out, &wrap) == nil && wrap.Payment.Address != "" {
c = wrap.Payment
} else {
return Charge{}, false, err
}
}
if c.Address == "" {
return Charge{}, false, nil
}
return c, true, nil
}
// PremiumPayCommand is the manual fallback shown when no charge could be minted
// in-session: the command the user can run themselves to pay.
//
// AGENTBBS_COINPAY_PREMIUM_PAY_TMPL
func PremiumPayCommand(ref string) string {
tmpl := os.Getenv("AGENTBBS_COINPAY_PREMIUM_PAY_TMPL")
if tmpl == "" {
tmpl = "coinpay payment create --amount " + PremiumAmount() +
" --currency " + PremiumCurrency() +
" --blockchain " + PremiumBlockchain() + " --metadata %s"
}
if strings.Contains(tmpl, "%s") {
return fmt.Sprintf(tmpl, ref)
}
return tmpl + " " + ref
}
// VerifyPremium checks whether a premium charge has settled, via the CoinPay
// status command. Like Verify, checked is false when unconfigured/unavailable.
//
// AGENTBBS_COINPAY_PREMIUM_STATUS_CMD e.g. "coinpay payment status %s" (exit 0 == paid)
func VerifyPremium(payRef string) (paid bool, checked bool) {
return runVerify(os.Getenv("AGENTBBS_COINPAY_PREMIUM_STATUS_CMD"), payRef)
}
// Reference derives a stable, short payment reference for a user+plan from
// the user's key fingerprint, so CoinPay memos can be reconciled to accounts.
func Reference(plan, pubkeyFP string) string {
@ -54,11 +163,17 @@ func PayCommand(ref string) string {
// (paid, checked): checked is false when no verifier is configured or the
// coinpay binary is unavailable, so callers can fall back to store state.
func Verify(ref string) (paid bool, checked bool) {
tmpl := os.Getenv("AGENTBBS_COINPAY_VERIFY_CMD")
return runVerify(os.Getenv("AGENTBBS_COINPAY_VERIFY_CMD"), ref)
}
// runVerify runs a "%s"-templated verify command and maps its exit status to
// (paid, checked): checked is false when the template is empty or the binary is
// absent, so callers fall back to store state.
func runVerify(tmpl, ref string) (paid bool, checked bool) {
if tmpl == "" {
return false, false
}
var line string
line := tmpl
if strings.Contains(tmpl, "%s") {
line = fmt.Sprintf(tmpl, ref)
} else {
@ -71,8 +186,7 @@ func Verify(ref string) (paid bool, checked bool) {
if _, err := exec.LookPath(parts[0]); err != nil {
return false, false
}
cmd := exec.Command(parts[0], parts[1:]...)
if err := cmd.Run(); err != nil {
if err := exec.Command(parts[0], parts[1:]...).Run(); err != nil {
return false, true
}
return true, true

View file

@ -18,22 +18,24 @@ type User struct {
PubKeyFP string
Email string
EmailVerified bool
Premium bool // paid the one-time lifetime membership
CreatedAt time.Time
}
// userCols is the column list (in struct order) for every user SELECT, kept in
// sync with scanUser.
const userCols = `id, name, kind, pubkey_fp, email, email_verified, created_at`
const userCols = `id, name, kind, pubkey_fp, email, email_verified, premium, created_at`
// scanUser reads one user row selected with userCols.
func scanUser(sc interface{ Scan(...any) error }) (User, error) {
var u User
var verified int
var verified, premium int
var created string
if err := sc.Scan(&u.ID, &u.Name, &u.Kind, &u.PubKeyFP, &u.Email, &verified, &created); err != nil {
if err := sc.Scan(&u.ID, &u.Name, &u.Kind, &u.PubKeyFP, &u.Email, &verified, &premium, &created); err != nil {
return User{}, err
}
u.EmailVerified = verified != 0
u.Premium = premium != 0
u.CreatedAt, _ = time.Parse(time.RFC3339, created)
return u, nil
}
@ -60,11 +62,21 @@ type Store interface {
LastSeen(userID int64) (time.Time, bool, error)
// SetEmailVerification records the account's email and a fresh
// confirmation token, marking it unverified until the token is used.
// confirmation token (a link token or a short code), marking it unverified
// until the token is consumed.
SetEmailVerification(userID int64, email, token string) error
// VerifyEmail consumes a confirmation token: on match it marks the
// account verified, clears the token, and returns the account.
VerifyEmail(token string) (User, bool, error)
// ConfirmEmailCode is the interactive (join@) counterpart to VerifyEmail:
// it matches the code against the one stored for THIS user (codes are
// short and not globally unique), and on match marks the account verified
// and clears the code. Returns ok=false on a wrong/empty code.
ConfirmEmailCode(userID int64, code string) (User, bool, error)
// GrantPremium marks the account as a lifetime premium member (the $10
// one-time membership), recording the CoinPay payment reference. Idempotent.
GrantPremium(userID int64, paymentRef string) error
RecordSession(userID int64, username, remote, route string) (int64, error)
EndSession(sessionID int64) error
@ -140,6 +152,8 @@ func migrate(db *sql.DB) error {
{"email", "email TEXT NOT NULL DEFAULT ''"},
{"email_verified", "email_verified INTEGER NOT NULL DEFAULT 0"},
{"verify_token", "verify_token TEXT NOT NULL DEFAULT ''"},
{"premium", "premium INTEGER NOT NULL DEFAULT 0"},
{"premium_ref", "premium_ref TEXT NOT NULL DEFAULT ''"},
})
}
@ -279,6 +293,30 @@ func (s *sqliteStore) VerifyEmail(token string) (User, bool, error) {
return u, true, nil
}
func (s *sqliteStore) ConfirmEmailCode(userID int64, code string) (User, bool, error) {
if code == "" {
return User{}, false, nil
}
u, err := scanUser(s.db.QueryRow(
`SELECT `+userCols+` FROM users WHERE id = ? AND verify_token = ?`, userID, code))
if errors.Is(err, sql.ErrNoRows) {
return User{}, false, nil
}
if err != nil {
return User{}, false, err
}
if _, err := s.db.Exec(`UPDATE users SET email_verified = 1, verify_token = '' WHERE id = ?`, u.ID); err != nil {
return User{}, false, err
}
u.EmailVerified = true
return u, true, nil
}
func (s *sqliteStore) GrantPremium(userID int64, paymentRef string) error {
_, err := s.db.Exec(`UPDATE users SET premium = 1, premium_ref = ? WHERE id = ?`, paymentRef, userID)
return err
}
func (s *sqliteStore) RecordSession(userID int64, username, remote, route string) (int64, error) {
var uid any
if userID > 0 {

View file

@ -0,0 +1,69 @@
package store
import (
"path/filepath"
"testing"
)
func TestConfirmEmailCode(t *testing.T) {
st, err := Open(filepath.Join(t.TempDir(), "t.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
defer st.Close()
u, err := st.EnsureUser("bob", "member", "SHA256:bbb")
if err != nil {
t.Fatalf("ensure: %v", err)
}
if err := st.SetEmailVerification(u.ID, "bob@example.com", "123456"); err != nil {
t.Fatalf("set: %v", err)
}
// Empty and wrong codes are clean misses.
if _, ok, err := st.ConfirmEmailCode(u.ID, ""); ok || err != nil {
t.Fatalf("empty code: ok=%v err=%v", ok, err)
}
if _, ok, _ := st.ConfirmEmailCode(u.ID, "000000"); ok {
t.Fatal("wrong code should not confirm")
}
// The right code belonging to another user must not confirm (codes are
// scoped per-user since they are short and collide).
other, _ := st.EnsureUser("carol", "member", "SHA256:ccc")
if _, ok, _ := st.ConfirmEmailCode(other.ID, "123456"); ok {
t.Fatal("code must be scoped to its own user")
}
// Correct code for the right user verifies, and is single-use.
vu, ok, err := st.ConfirmEmailCode(u.ID, "123456")
if err != nil || !ok || !vu.EmailVerified {
t.Fatalf("confirm: ok=%v err=%v verified=%v", ok, err, vu.EmailVerified)
}
if _, ok, _ := st.ConfirmEmailCode(u.ID, "123456"); ok {
t.Fatal("code should be consumed after first use")
}
}
func TestGrantPremium(t *testing.T) {
st, err := Open(filepath.Join(t.TempDir(), "t.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
defer st.Close()
u, _ := st.EnsureUser("dave", "member", "SHA256:ddd")
if u.Premium {
t.Fatal("new user must not be premium")
}
if err := st.GrantPremium(u.ID, "abbs-premium-deadbeef"); err != nil {
t.Fatalf("grant: %v", err)
}
got, _, _ := st.UserByFingerprint("SHA256:ddd")
if !got.Premium {
t.Fatalf("user should be premium after grant: %+v", got)
}
// Idempotent.
if err := st.GrantPremium(u.ID, "abbs-premium-deadbeef"); err != nil {
t.Fatalf("re-grant: %v", err)
}
}

43
scripts/self-update.sh Executable file
View file

@ -0,0 +1,43 @@
#!/usr/bin/env bash
#
# self-update.sh — pull the tracked branch and, if it advanced (or the service
# is down, or --force), re-run the idempotent provisioner to redeploy.
#
# Designed to be safe to run on a timer: when origin has not moved and agentbbs
# is healthy it does nothing and exits 0, so it costs one `git fetch` per tick.
# setup.sh holds a flock, so this never races a concurrent CI deploy.
#
# sudo scripts/self-update.sh # redeploy only if origin/<branch> moved
# sudo scripts/self-update.sh --force # redeploy unconditionally
#
set -euo pipefail
REPO="${REPO:-https://github.com/profullstack/agentbbs.git}"
BRANCH="${BRANCH:-main}"
SRC_DIR="${SRC_DIR:-/opt/agentbbs}"
FORCE=0
[ "${1:-}" = "--force" ] && FORCE=1
[ "$(id -u)" -eq 0 ] || { echo "self-update.sh must run as root" >&2; exit 1; }
# First-ever run on a bare box: clone, then always provision.
if [ ! -d "$SRC_DIR/.git" ]; then
git clone --depth 1 -b "$BRANCH" "$REPO" "$SRC_DIR"
exec "$SRC_DIR/setup.sh"
fi
git -C "$SRC_DIR" fetch --depth 1 origin "$BRANCH"
local_rev="$(git -C "$SRC_DIR" rev-parse HEAD)"
remote_rev="$(git -C "$SRC_DIR" rev-parse "origin/${BRANCH}")"
if [ "$FORCE" -eq 0 ] \
&& [ "$local_rev" = "$remote_rev" ] \
&& systemctl is-active --quiet agentbbs; then
echo "agentbbs up to date at ${remote_rev:0:12} and healthy; nothing to do"
exit 0
fi
echo "redeploying: ${local_rev:0:12} -> ${remote_rev:0:12} (force=$FORCE)"
# setup.sh does the reset --hard, rebuild, and restart under its own lock.
exec "$SRC_DIR/setup.sh"

View file

@ -26,6 +26,7 @@ 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)
@ -33,12 +34,23 @@ HTTP_ADDR="${HTTP_ADDR:-127.0.0.1:8088}" # agentbbs /verify endpoint (join@ emai
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
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
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; }
[ "$(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"
@ -113,16 +125,19 @@ 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"
git -C "$SRC_DIR" pull --ff-only
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"
git clone --depth 1 "$REPO" "$SRC_DIR"
log "cloning $REPO ($BRANCH)"
git clone --depth 1 -b "$BRANCH" "$REPO" "$SRC_DIR"
fi
if [ "$FETCH_ASSETS" = "1" ] && [ -x "$SRC_DIR/fetch-assets.sh" ]; then
if [ "$FETCH_ASSETS" = "1" ] && [ -x "$SRC_DIR/scripts/fetch-assets.sh" ]; then
log "fetching arcade assets (set FETCH_ASSETS=0 to skip)"
( cd "$SRC_DIR" && ./fetch-assets.sh ) || warn "asset fetch failed; arcade may be limited"
( cd "$SRC_DIR" && ./scripts/fetch-assets.sh ) || warn "asset fetch failed; arcade may be limited"
fi
log "building binaries"
@ -159,12 +174,33 @@ AGENTBBS_HTTP_ADDR=${HTTP_ADDR}
# AGENTBBS_SMTP_USER=
# AGENTBBS_SMTP_PASS=
# AGENTBBS_SMTP_FROM=bbs@${DOMAIN}
# pod@ requires a verified email; set 0 to disable on a dev host:
# 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
# Pods (CoinPay \$1/mo membership) — required for pod@ to charge/verify:
# AGENTBBS_COINPAY_PAY_TMPL=
# AGENTBBS_COINPAY_VERIFY_CMD=
# Membership model:
# Free verified members get their own Docker pod (ssh pod@) and a homepage
# at https://${DOMAIN}/~<name>.
# Premium \$10 one-time, lifetime — a personal <name>@${DOMAIN} email
# (forwardemail.net) plus custom domains (ssh domain@). Offered at join@.
# Premium payment via the coinpay CLI: join@ mints a charge and shows the amount
# + deposit address; the status command verifies a later settlement. %s is the
# per-account payment reference.
# AGENTBBS_PREMIUM_AMOUNT=10
# AGENTBBS_PREMIUM_CURRENCY=USD
# AGENTBBS_PREMIUM_BLOCKCHAIN=eth
# AGENTBBS_COINPAY_PREMIUM_CREATE_CMD=coinpay payment create --amount 10 --currency USD --blockchain eth --json --metadata %s
# AGENTBBS_COINPAY_PREMIUM_PAY_TMPL=coinpay payment create --amount 10 --currency USD --blockchain eth --metadata %s
# AGENTBBS_COINPAY_PREMIUM_STATUS_CMD=coinpay payment status %s
# Premium email aliases (<name>@${DOMAIN}) auto-created on forwardemail.net.
# Without an API key the address is shown but not created (add it manually).
# AGENTBBS_FORWARDEMAIL_API_KEY=
# AGENTBBS_FORWARDEMAIL_DOMAIN=${DOMAIN}
# AGENTBBS_WEBMAIL_URL=https://webmail.${DOMAIN}
# PairUX video calls rendered as ASCII (video@ / tv@ PairUX sources):
# AGENTBBS_LIVEKIT_URL=
@ -207,6 +243,46 @@ 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
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