Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ plugins around one shared account system; the full product plan is in
| M3 — AgentGames (`game@` + WebSocket; TTT/C4, ELO ladder, replays) | ✅ |
| IRC (`irc.bbs.profullstack.com` — Ergo network for humans + agents) | ✅ |
| News (`news.profullstack.com` — members-only Usenet/NNTP for humans + agents) | ✅ |
| M4 — Files (cl1.tech SFTP workspaces) | |
| M4 — Files (SFTP: private workspaces + shared public area, mgmt TUI) | |
| M5 — AgentAd marketplace (built on the AgentAd standard in logicsrc) | ⬜ |

## Run it
Expand Down Expand Up @@ -67,11 +67,22 @@ Configuration (env):
| `AGENTBBS_GAME_MOVE_TIMEOUT` | `15` | AgentGames per-move deadline (s) — see [docs/agentgames.md](docs/agentgames.md) |
| `AGENTBBS_GAME_QUEUE_WAIT` | `120` | how long a lone agent waits for an opponent (s) |
| `AGENTBBS_GAME_WS_ADDR` | `127.0.0.1:8090` | AgentGames WebSocket endpoint (loopback; Caddy proxies `/play`) |
| `AGENTBBS_FILES` | `1` | member SFTP storage subsystem + Files plugin (`0` disables) — see [docs/files.md](docs/files.md) |
| `AGENTBBS_FILES_QUOTA_MB` | `1024` | default per-user workspace quota (MB) |

Ops:

```bash
./agentbbs grant-pod alice 12 # manual pod grant (12 months)

# (re)email verified members their git + mailbox creds/links — preview first,
# then --send. Git: resets each Forgejo account to a fresh one-time password and
# emails the web login link; mailbox: ensures the @mail alias and emails the
# address + webmail link. Needs AGENTBBS_SMTP_*, _FORGEJO_*, _FORWARDEMAIL_* set.
./agentbbs notify-creds # preview, all verified members
./agentbbs notify-creds --send # really send git + mailbox to everyone
./agentbbs notify-creds --git --send # git creds only
./agentbbs notify-creds --user alice --mail --send
```

## Deploy
Expand Down Expand Up @@ -144,6 +155,24 @@ news.profullstack.com:563 # implicit TLS; login = your BBS member name
Set `NEWS=0` to skip it. Needs a DNS record `news.profullstack.com A -> host`.
Full details: [`docs/news.md`](docs/news.md).

### Files (SFTP)

Every member gets file storage over **SFTP**, on the same `:22` listener and the
same SSH key they log in with (`internal/files`, a virtual Go SFTP server — no OS
users). Two areas: a **private, quota-limited** `/me` workspace and a single
**shared public file area** `/public` (old-school BBS file area; members-only
write by default). `scp` and `rsync` ride the same endpoint:

```bash
sftp files@bbs.profullstack.com # username is conventional; your key is your identity
scp file.pdf files@bbs.profullstack.com:/me/
```

There's also an in-hub **Files** browser and an operator management TUI
(`ssh sftp@bbs.profullstack.com`, operators only) for sessions, quotas, and
moderating the public area. Set `AGENTBBS_FILES=0` to disable. Full details:
[`docs/files.md`](docs/files.md).

## Architecture

- **Go + charmbracelet** — `wish` SSH server, `bubbletea` TUIs, `lipgloss` styling.
Expand Down
27 changes: 27 additions & 0 deletions cmd/agentbbs/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/profullstack/agentbbs/internal/admin"
"github.com/profullstack/agentbbs/internal/auth"
"github.com/profullstack/agentbbs/internal/calls"
"github.com/profullstack/agentbbs/internal/files"
"github.com/profullstack/agentbbs/internal/plugin"
)

Expand Down Expand Up @@ -155,6 +156,32 @@ func (a *app) adminTeaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
return m, []tea.ProgramOption{tea.WithAltScreen()}
}

// filesAdminTeaHandler launches the SFTP server management TUI. Like admin@ it
// is gated by the operator allowlist and re-checked here so a direct hit is
// safe. Members transfer files via the sftp subsystem, not this route.
func (a *app) filesAdminTeaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
if a.files == nil {
wish.Println(s, "the Files service is disabled (AGENTBBS_FILES=0).")
_ = s.Exit(1)
return nil, nil
}
fp := auth.Fingerprint(s.PublicKey())
var name string
if fp != "" {
if u, found, _ := a.st.UserByFingerprint(fp); found {
name = u.Name
}
}
if name == "" || !auth.IsAdmin(name) {
wish.Println(s, "the SFTP management console is restricted to operators.")
_ = s.Exit(1)
return nil, nil
}
sessID, _ := a.st.RecordSession(0, s.User(), remoteIP(s), "sftp-admin")
go func() { <-s.Context().Done(); _ = a.st.EndSession(sessID) }()
return files.NewAdminModel(a.files), []tea.ProgramOption{tea.WithAltScreen()}
}

func sortedKeys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
Expand Down
88 changes: 82 additions & 6 deletions cmd/agentbbs/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
// agentbbs mint-token NAME issue a WebSocket API token for NAME
// agentbbs qrypt-invite NAME mint a qrypt.chat anonymous invite for NAME
// agentbbs qrypt-issuer-keygen print a fresh qrypt issuer seed + public key
// agentbbs notify-creds [flags] (re)email verified members their git +
// mailbox creds/links (preview unless --send)
package main

import (
Expand Down Expand Up @@ -59,6 +61,7 @@ import (
"github.com/profullstack/agentbbs/internal/brand"
"github.com/profullstack/agentbbs/internal/calls"
"github.com/profullstack/agentbbs/internal/chat"
"github.com/profullstack/agentbbs/internal/files"
"github.com/profullstack/agentbbs/internal/forgejo"
"github.com/profullstack/agentbbs/internal/games"
"github.com/profullstack/agentbbs/internal/hub"
Expand Down Expand Up @@ -111,6 +114,7 @@ type app struct {
webmailURL string // webmail (Roundcube) URL shown to members
forgejo forgejo.Config // AgentGit git.profullstack.com account provisioning
live *liveReg // in-memory live-session registry (admin console)
files *files.Service // SFTP file storage (nil when AGENTBBS_FILES=0)
gamesReg *games.Registry // AgentGames catalog
mm *games.Matchmaker // AgentGames matchmaker (agent-vs-agent)
dataDir string
Expand Down Expand Up @@ -153,6 +157,10 @@ func main() {
qryptInviteCmd(st, os.Args[2:])
return
}
if len(os.Args) > 1 && os.Args[1] == "notify-creds" {
notifyCreds(st, os.Args[2:])
return
}
if len(os.Args) > 1 && os.Args[1] == "qrypt-issuer-keygen" {
qryptIssuerKeygen()
return
Expand Down Expand Up @@ -188,6 +196,22 @@ func main() {
time.Duration(envInt("AGENTBBS_GAME_QUEUE_WAIT", 120))*time.Second)
a.registry = []plugin.Plugin{arcade.Plugin{}, agentgames.New(a.gamesReg), members.Plugin{}, qryptinviteplugin.Plugin{}, about.Plugin{}, hello.Plugin{}}

// Files (SFTP): per-user workspaces + a shared public area, reached over the
// :22 listener via `sftp files@<host>` (docs/files.md). Disable with
// AGENTBBS_FILES=0. The in-BBS browser is a hub plugin; the operator
// management TUI is the sftp@ route.
if env("AGENTBBS_FILES", "1") == "1" {
fsvc, err := files.New(a.st, files.Config{
Root: filepath.Join(dataDir, "files"),
DefaultQuota: int64(envInt("AGENTBBS_FILES_QUOTA_MB", 1024)) << 20,
})
if err != nil {
log.Fatal("files", "err", err)
}
a.files = fsvc
a.registry = append(a.registry, files.NewPlugin(fsvc))
}

// Custom domains: maintain the symlink farm Caddy serves and answer its
// on-demand-TLS "ask" query so certs are only issued for mapped domains.
if sm, err := sites.NewManager(st, dataDir); err != nil {
Expand Down Expand Up @@ -265,21 +289,27 @@ func main() {
}

addr := env("AGENTBBS_ADDR", ":2222")
srv, err := wish.NewServer(
opts := []ssh.Option{
wish.WithAddress(addr),
wish.WithHostKeyPath(filepath.Join(dataDir, "ssh", "host_ed25519")),
// Keys are always accepted at the transport layer; identity and
// authorization are resolved per-route in the session handler.
wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool { return true }),
// Keyless interactive auth admits guests (bbs@/play@) only.
wish.WithKeyboardInteractiveAuth(func(ctx ssh.Context, _ gossh.KeyboardInteractiveChallenge) bool { return true }),
wish.WithIdleTimeout(30*time.Minute),
wish.WithIdleTimeout(30 * time.Minute),
wish.WithMiddleware(
a.router(),
a.track(), // register every session for the admin console
logging.Middleware(),
),
)
}
// SFTP rides the same :22 listener as a subsystem; identity is the SSH key,
// so `sftp files@<host>` works with the member's login key.
if a.files != nil {
opts = append(opts, wish.WithSubsystem("sftp", a.files.Subsystem()))
}
srv, err := wish.NewServer(opts...)
if err != nil {
log.Fatal("server", "err", err)
}
Expand All @@ -306,9 +336,11 @@ func main() {
func (a *app) router() wish.Middleware {
btMw := bm.Middleware(a.teaHandler)
adminMw := bm.Middleware(a.adminTeaHandler)
filesAdminMw := bm.Middleware(a.filesAdminTeaHandler)
return func(next ssh.Handler) ssh.Handler {
hubHandler := activeterm.Middleware()(btMw(next))
adminHandler := activeterm.Middleware()(adminMw(next))
filesAdminHandler := activeterm.Middleware()(filesAdminMw(next))
return func(s ssh.Session) {
user := strings.ToLower(s.User())
code, isVideo := calls.RouteCode(user)
Expand All @@ -333,6 +365,8 @@ func (a *app) router() wish.Middleware {
a.handleNews(s)
case auth.IsMailName(user):
a.handleMail(s)
case auth.IsFilesAdminName(user):
filesAdminHandler(s)
case auth.IsMsgName(user):
a.handleMsg(s)
case isVideo:
Expand Down Expand Up @@ -852,6 +886,22 @@ func (a *app) ensurePremium(u *store.User) bool {
return true
}

// mailWelcomeEmailBody is the plain-text email sent when a member's @host
// mailbox alias is provisioned: their new address and the webmail link. Used by
// the `notify-creds` backfill command (see notifycreds.go).
func mailWelcomeEmailBody(name, address, webmail string) string {
b := "Hi " + name + ",\n\n" +
"Your member mailbox is live:\n\n" +
" " + address + "\n\n" +
"Mail sent there forwards to this address.\n"
if webmail != "" {
b += "\nRead and send from the webmail interface here:\n\n" +
" " + webmail + "\n"
}
b += "\nIf you didn't request this, you can ignore this email.\n"
return b
}

// showPremiumWelcome prints a premium member's perks: custom domains and the
// in-hub Tor shell. (Email is free for all members — see the join@ summary.)
func (a *app) showPremiumWelcome(s ssh.Session, u store.User) {
Expand Down Expand Up @@ -1000,14 +1050,15 @@ func (a *app) provisionGit(u *store.User, pubKey string) {
if u == nil || !a.forgejo.Configured() || u.Name == "" || u.Email == "" {
return
}
created, err := a.forgejo.EnsureUser(u.Name, u.Email)
created, password, err := a.forgejo.EnsureUser(u.Name, u.Email)
if err != nil {
log.Error("forgejo provision", "user", u.Name, "err", err)
return
}
if created {
log.Info("provisioned git account", "user", u.Name, "host", a.forgejo.BaseURL)
if !created {
return
}
log.Info("provisioned git account", "user", u.Name, "host", a.forgejo.BaseURL)
// Register the BBS SSH key so the member can push with the same key they sign
// in with. No-op when called without a session key (e.g. the web verify flow).
if pubKey != "" {
Expand All @@ -1017,6 +1068,31 @@ func (a *app) provisionGit(u *store.User, pubKey string) {
log.Info("registered git ssh key", "user", u.Name)
}
}
// Email the verified address their web sign-in link + one-time password so
// they can log in to the Forgejo UI and create repositories. Best-effort:
// the account already exists, so a mail failure must not block anything.
if a.mail.Configured() {
if err := a.mail.Send(u.Email, "Your git.profullstack.com account is ready",
gitWelcomeEmailBody(u.Name, password, a.forgejo.LoginURL())); err != nil {
log.Error("git welcome email", "user", u.Name, "err", err)
}
}
}

// gitWelcomeEmailBody is the plain-text email sent when a member's AgentGit
// (Forgejo) account is created or reset — on provisioning and by the
// notify-creds ops command: web login link, username, and the one-time password
// they must change on first sign-in.
func gitWelcomeEmailBody(name, password, loginURL string) string {
return "Hi " + name + ",\n\n" +
"Your git account is ready. Sign in to the web interface here:\n\n" +
" " + loginURL + "\n\n" +
" username: " + name + "\n" +
" password: " + password + "\n\n" +
"You'll be asked to set a new password the first time you sign in.\n" +
"After that, click the \"+\" (top right) → \"New Repository\" to create repos.\n\n" +
"Pushing over git uses your registered SSH key — no password needed.\n\n" +
"If you didn't request this, you can ignore this email.\n"
}

// authorizedKey renders the session's public key as a single authorized_keys
Expand Down
Loading
Loading