mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
Autonomous deploy + free-pod/Premium-email membership
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
3230807421
11 changed files with 900 additions and 101 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue