diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..bb97795 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -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 diff --git a/README.md b/README.md index 893a72a..ca087d0 100644 --- a/README.md +++ b/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 @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-@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. diff --git a/cmd/agentbbs/main.go b/cmd/agentbbs/main.go index f6b1ae3..27f3c04 100644 --- a/cmd/agentbbs/main.go +++ b/cmd/agentbbs/main.go @@ -4,10 +4,10 @@ // // ssh bbs@host the BBS hub, guests welcome (play@/guest@ are aliases) // ssh @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:///~. + 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 @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 ", + "", + ) + 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) diff --git a/docs/deploy.md b/docs/deploy.md new file mode 100644 index 0000000..fea9d1d --- /dev/null +++ b/docs/deploy.md @@ -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. diff --git a/internal/forwardemail/forwardemail.go b/internal/forwardemail/forwardemail.go new file mode 100644 index 0000000..d02d731 --- /dev/null +++ b/internal/forwardemail/forwardemail.go @@ -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 @ 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))) +} diff --git a/internal/forwardemail/forwardemail_test.go b/internal/forwardemail/forwardemail_test.go new file mode 100644 index 0000000..03c04df --- /dev/null +++ b/internal/forwardemail/forwardemail_test.go @@ -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") + } +} diff --git a/internal/payments/payments.go b/internal/payments/payments.go index 0a4181a..4f5dfff 100644 --- a/internal/payments/payments.go +++ b/internal/payments/payments.go @@ -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 diff --git a/internal/store/store.go b/internal/store/store.go index 5848bd7..dc7379f 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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 { diff --git a/internal/store/store_premium_test.go b/internal/store/store_premium_test.go new file mode 100644 index 0000000..8c30b19 --- /dev/null +++ b/internal/store/store_premium_test.go @@ -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) + } +} diff --git a/scripts/self-update.sh b/scripts/self-update.sh new file mode 100755 index 0000000..35a6cfb --- /dev/null +++ b/scripts/self-update.sh @@ -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/ 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" diff --git a/setup.sh b/setup.sh index e1cc278..376f225 100755 --- a/setup.sh +++ b/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}/~. +# Premium \$10 one-time, lifetime — a personal @${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 (@${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 < /etc/systemd/system/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