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 ```bash
ssh bbs@profullstack.com # the hub: arcade (DOOM, snake), leaderboards — guests welcome 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 <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 video-<code>@profullstack.com # join a PairUX video call as truecolor ASCII
ssh agent@profullstack.com # chat with the operator's AI agent 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 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 plugins around one shared account system; the full product plan is in
[`docs/PRD.md`](docs/PRD.md), [`docs/pods.md`](docs/pods.md), [`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) ./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 ## Architecture
- **Go + charmbracelet**`wish` SSH server, `bubbletea` TUIs, `lipgloss` styling. - **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 bbs@host the BBS hub, guests welcome (play@/guest@ are aliases)
// ssh <name>@host the hub as a member/agent (SSH key required) // ssh <name>@host the hub as a member/agent (SSH key required)
// ssh join@host onboarding: registers your key, prints instructions, // ssh join@host onboarding: registers your key, confirms your email with an
// and disconnects — no session // emailed code, then offers $10 lifetime Premium (CoinPay)
// ssh pod@host your personal Linux pod (paid membership, $1/mo via coinpay) // ssh pod@host your personal Linux pod — free for verified members
// ssh domain@host point your own domain at your homepage (add/rm/list) // ssh domain@host point your own domain at your homepage (Premium; add/rm/list)
// //
// Subcommands: // Subcommands:
// //
@ -21,7 +21,7 @@ import (
"bufio" "bufio"
"context" "context"
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/binary"
"errors" "errors"
"fmt" "fmt"
"net" "net"
@ -46,6 +46,7 @@ import (
"github.com/profullstack/agentbbs/internal/auth" "github.com/profullstack/agentbbs/internal/auth"
"github.com/profullstack/agentbbs/internal/calls" "github.com/profullstack/agentbbs/internal/calls"
"github.com/profullstack/agentbbs/internal/chat" "github.com/profullstack/agentbbs/internal/chat"
"github.com/profullstack/agentbbs/internal/forwardemail"
"github.com/profullstack/agentbbs/internal/hub" "github.com/profullstack/agentbbs/internal/hub"
"github.com/profullstack/agentbbs/internal/mail" "github.com/profullstack/agentbbs/internal/mail"
"github.com/profullstack/agentbbs/internal/payments" "github.com/profullstack/agentbbs/internal/payments"
@ -72,6 +73,7 @@ type app struct {
registry []plugin.Plugin registry []plugin.Plugin
sandbox *sandbox.Runner sandbox *sandbox.Runner
mail mail.Config mail mail.Config
fe forwardemail.Config // premium @bbs email provisioning
dataDir string dataDir string
assets string assets string
host string // public hostname used in user-facing messages host string // public hostname used in user-facing messages
@ -96,13 +98,19 @@ func main() {
return 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{ a := &app{
st: st, st: st,
sandbox: sandbox.New(sandbox.Mode(env("AGENTBBS_SANDBOX", "auto"))), sandbox: sandbox.New(sandbox.Mode(env("AGENTBBS_SANDBOX", "auto"))),
mail: mail.ConfigFromEnv(), mail: mail.ConfigFromEnv(),
fe: fe,
dataDir: dataDir, dataDir: dataDir,
assets: env("AGENTBBS_ASSETS", "./assets"), assets: env("AGENTBBS_ASSETS", "./assets"),
host: env("AGENTBBS_HOST", "profullstack.com"), host: host,
} }
a.registry = []plugin.Plugin{arcade.Plugin{}, about.Plugin{}} 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 { if su.Name != username {
wish.Println(s, "note: this key belongs to "+su.Name+" — signed in as "+su.Name+".") 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} 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()} return hub.New(u, ctx, a.registry), []tea.ProgramOption{tea.WithAltScreen()}
} }
// handleJoin registers the visitor's key, prints instructions, disconnects // handleJoin runs onboarding interactively in one SSH session: register the
// ("it just shows the message and kicks them off the server"). // 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) { func (a *app) handleJoin(s ssh.Session) {
fp := auth.Fingerprint(s.PublicKey()) fp := auth.Fingerprint(s.PublicKey())
if fp == "" { if fp == "" {
@ -278,68 +290,232 @@ func (a *app) handleJoin(s ssh.Session) {
} }
_, _ = a.st.RecordSession(u.ID, s.User(), remoteIP(s), "join") _, _ = a.st.RecordSession(u.ID, s.User(), remoteIP(s), "join")
// Collect an email and send a confirmation link. The connecting key is the in := bufio.NewReader(s)
// "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)
wish.Println(s, "\n"+strings.Join([]string{ wish.Println(s, "\n"+strings.Join([]string{
"", " Welcome to AgentBBS — let's set up your account.",
" Welcome to AgentBBS — you're registered.",
"", "",
" account " + u.Name, " account " + u.Name,
" key " + fp, " 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")) }, "\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) _ = s.Exit(0)
} }
// verifyEmailBody is the plain-text confirmation email. // verifyEmailInteractive collects an email, emails a 6-digit code, and prompts
func verifyEmailBody(name, url string) string { // 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" + return "Hi " + name + ",\n\n" +
"Confirm your AgentBBS account by opening this link:\n\n" + "Your AgentBBS confirmation code is:\n\n" +
" " + url + "\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" "If you didn't request this, you can ignore this email.\n"
} }
// randToken returns a 128-bit hex token for email confirmation. // randCode returns a 6-digit numeric confirmation code.
func randToken() string { func randCode() string {
b := make([]byte, 16) var b [4]byte
_, _ = rand.Read(b) _, _ = rand.Read(b[:])
return hex.EncodeToString(b) return fmt.Sprintf("%06d", binary.BigEndian.Uint32(b[:])%1000000)
} }
// validEmail is a deliberately loose check: one @, a dotted domain, no spaces. // 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) _ = s.Exit(1)
return 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 { if a.sites == nil {
wish.Println(s, "custom domains are temporarily unavailable on this host.") wish.Println(s, "custom domains are temporarily unavailable on this host.")
_ = s.Exit(1) _ = s.Exit(1)
@ -486,34 +674,15 @@ func (a *app) handlePod(s ssh.Session) {
_ = s.Exit(1) _ = s.Exit(1)
return return
} }
// Email must be confirmed before paid features unlock (set // Pods are a FREE member benefit — the only gate is a confirmed email, so
// AGENTBBS_REQUIRE_VERIFIED_EMAIL=0 to disable on a dev host). // 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 { 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) _ = s.Exit(1)
return 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 { if a.pods == nil {
wish.Println(s, "pods are temporarily unavailable on this host.") wish.Println(s, "pods are temporarily unavailable on this host.")
_ = s.Exit(1) _ = 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/hmac"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
@ -29,6 +30,114 @@ const PodPriceLabel = "$1/mo"
// PodTerm is how much access one payment buys. // PodTerm is how much access one payment buys.
const PodTerm = 31 * 24 * time.Hour 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 // 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. // the user's key fingerprint, so CoinPay memos can be reconciled to accounts.
func Reference(plan, pubkeyFP string) string { 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 // (paid, checked): checked is false when no verifier is configured or the
// coinpay binary is unavailable, so callers can fall back to store state. // coinpay binary is unavailable, so callers can fall back to store state.
func Verify(ref string) (paid bool, checked bool) { 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 == "" { if tmpl == "" {
return false, false return false, false
} }
var line string line := tmpl
if strings.Contains(tmpl, "%s") { if strings.Contains(tmpl, "%s") {
line = fmt.Sprintf(tmpl, ref) line = fmt.Sprintf(tmpl, ref)
} else { } else {
@ -71,8 +186,7 @@ func Verify(ref string) (paid bool, checked bool) {
if _, err := exec.LookPath(parts[0]); err != nil { if _, err := exec.LookPath(parts[0]); err != nil {
return false, false return false, false
} }
cmd := exec.Command(parts[0], parts[1:]...) if err := exec.Command(parts[0], parts[1:]...).Run(); err != nil {
if err := cmd.Run(); err != nil {
return false, true return false, true
} }
return true, true return true, true

View file

@ -18,22 +18,24 @@ type User struct {
PubKeyFP string PubKeyFP string
Email string Email string
EmailVerified bool EmailVerified bool
Premium bool // paid the one-time lifetime membership
CreatedAt time.Time CreatedAt time.Time
} }
// userCols is the column list (in struct order) for every user SELECT, kept in // userCols is the column list (in struct order) for every user SELECT, kept in
// sync with scanUser. // 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. // scanUser reads one user row selected with userCols.
func scanUser(sc interface{ Scan(...any) error }) (User, error) { func scanUser(sc interface{ Scan(...any) error }) (User, error) {
var u User var u User
var verified int var verified, premium int
var created string 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 return User{}, err
} }
u.EmailVerified = verified != 0 u.EmailVerified = verified != 0
u.Premium = premium != 0
u.CreatedAt, _ = time.Parse(time.RFC3339, created) u.CreatedAt, _ = time.Parse(time.RFC3339, created)
return u, nil return u, nil
} }
@ -60,11 +62,21 @@ type Store interface {
LastSeen(userID int64) (time.Time, bool, error) LastSeen(userID int64) (time.Time, bool, error)
// SetEmailVerification records the account's email and a fresh // 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 SetEmailVerification(userID int64, email, token string) error
// VerifyEmail consumes a confirmation token: on match it marks the // VerifyEmail consumes a confirmation token: on match it marks the
// account verified, clears the token, and returns the account. // account verified, clears the token, and returns the account.
VerifyEmail(token string) (User, bool, error) 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) RecordSession(userID int64, username, remote, route string) (int64, error)
EndSession(sessionID int64) error EndSession(sessionID int64) error
@ -140,6 +152,8 @@ func migrate(db *sql.DB) error {
{"email", "email TEXT NOT NULL DEFAULT ''"}, {"email", "email TEXT NOT NULL DEFAULT ''"},
{"email_verified", "email_verified INTEGER NOT NULL DEFAULT 0"}, {"email_verified", "email_verified INTEGER NOT NULL DEFAULT 0"},
{"verify_token", "verify_token TEXT NOT NULL DEFAULT ''"}, {"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 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) { func (s *sqliteStore) RecordSession(userID int64, username, remote, route string) (int64, error) {
var uid any var uid any
if userID > 0 { 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}" ACME_EMAIL="${ACME_EMAIL:-admin@profullstack.com}"
SVC_USER="${SVC_USER:-agentbbs}" SVC_USER="${SVC_USER:-agentbbs}"
REPO="${REPO:-https://github.com/profullstack/agentbbs.git}" REPO="${REPO:-https://github.com/profullstack/agentbbs.git}"
BRANCH="${BRANCH:-main}"
SRC_DIR="${SRC_DIR:-/opt/agentbbs}" SRC_DIR="${SRC_DIR:-/opt/agentbbs}"
DATA_DIR="${DATA_DIR:-/var/lib/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) 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}" GO_VERSION="${GO_VERSION:-1.26.4}"
POD_IMAGE="${POD_IMAGE:-docker.io/library/ubuntu:24.04}" 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_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' "$*"; } log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*" >&2; } warn() { printf '\033[1;33m[warn]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31m[fail]\033[0m %s\n' "$*" >&2; exit 1; } die() { printf '\033[1;31m[fail]\033[0m %s\n' "$*" >&2; exit 1; }
[ "$(id -u)" -eq 0 ] || die "run as root (sudo ./setup.sh)" [ "$(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 . /etc/os-release 2>/dev/null || true
[ "${ID:-}" = "ubuntu" ] || warn "tested on Ubuntu; ${ID:-unknown} may differ" [ "${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 -------------------------------------- # ---- 5. clone/update + build agentbbs --------------------------------------
if [ -d "$SRC_DIR/.git" ]; then if [ -d "$SRC_DIR/.git" ]; then
log "updating source in $SRC_DIR" log "updating source in $SRC_DIR to origin/$BRANCH"
git -C "$SRC_DIR" pull --ff-only # 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 else
log "cloning $REPO" log "cloning $REPO ($BRANCH)"
git clone --depth 1 "$REPO" "$SRC_DIR" git clone --depth 1 -b "$BRANCH" "$REPO" "$SRC_DIR"
fi 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)" 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 fi
log "building binaries" log "building binaries"
@ -159,12 +174,33 @@ AGENTBBS_HTTP_ADDR=${HTTP_ADDR}
# AGENTBBS_SMTP_USER= # AGENTBBS_SMTP_USER=
# AGENTBBS_SMTP_PASS= # AGENTBBS_SMTP_PASS=
# AGENTBBS_SMTP_FROM=bbs@${DOMAIN} # 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 # 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: # Membership model:
# AGENTBBS_COINPAY_PAY_TMPL= # Free verified members get their own Docker pod (ssh pod@) and a homepage
# AGENTBBS_COINPAY_VERIFY_CMD= # 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): # PairUX video calls rendered as ASCII (video@ / tv@ PairUX sources):
# AGENTBBS_LIVEKIT_URL= # AGENTBBS_LIVEKIT_URL=
@ -207,6 +243,46 @@ WantedBy=multi-user.target
UNIT UNIT
systemctl daemon-reload 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) ----- # ---- 8. move admin OpenSSH to ADMIN_SSH_PORT (before agentbbs takes :22) -----
log "moving admin OpenSSH to :${ADMIN_SSH_PORT}" log "moving admin OpenSSH to :${ADMIN_SSH_PORT}"
install -d -m 0755 /etc/ssh/sshd_config.d install -d -m 0755 /etc/ssh/sshd_config.d