mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 14:27:27 +00:00
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:
parent
1086d57a4d
commit
7a85f2dbbb
11 changed files with 900 additions and 101 deletions
87
.github/workflows/deploy.yml
vendored
Normal file
87
.github/workflows/deploy.yml
vendored
Normal 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
|
||||
23
README.md
23
README.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
66
docs/deploy.md
Normal 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.
|
||||
91
internal/forwardemail/forwardemail.go
Normal file
91
internal/forwardemail/forwardemail.go
Normal 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)))
|
||||
}
|
||||
27
internal/forwardemail/forwardemail_test.go
Normal file
27
internal/forwardemail/forwardemail_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
69
internal/store/store_premium_test.go
Normal file
69
internal/store/store_premium_test.go
Normal 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
43
scripts/self-update.sh
Executable 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"
|
||||
96
setup.sh
96
setup.sh
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue