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
22 changes: 21 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,6 +67,8 @@ 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:

Expand Down Expand Up @@ -144,6 +146,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
49 changes: 46 additions & 3 deletions cmd/agentbbs/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,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 +112,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 @@ -188,6 +190,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 +283,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 +330,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 +359,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 @@ -1019,6 +1047,21 @@ func (a *app) provisionGit(u *store.User, pubKey string) {
}
}

// gitWelcomeEmailBody is the plain-text email sent by the notify-creds ops
// command when a member's AgentGit (Forgejo) account is created or reset: web
// login link, username, and the one-time password to 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
// line, or "" when the session has no key (guests / keyboard-interactive).
func authorizedKey(s ssh.Session) string {
Expand Down
86 changes: 62 additions & 24 deletions docs/PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ supply inventory. The BBS hub is the funnel that builds that audience.
|---|---|
| `profullstack.com` | Primary BBS host — `ssh play@profullstack.com` (guest) and member access |
| `logicsrc.com` | Home of the AgentGames spec and developer/agent-facing docs |
| `cl1.tech` | Managed file-transfer service (SFTP product), surfaced in-BBS as a plugin |
| `bbs.profullstack.com` | Member file storage over SFTP — `sftp files@bbs.profullstack.com` (same SSH key as login) |

### 1.2 One-line pitch

Expand Down Expand Up @@ -100,7 +100,7 @@ supply inventory. The BBS hub is the funnel that builds that audience.
▼ ▼ ▼ ▼
┌─────────┐ ┌───────────┐ ┌────────────┐ ┌──────────┐
│ Arcade │ │ AgentGames│ │ Files │ │ AgentAd │
│ plugin │ │ plugin │ │ (cl1.tech) │ │ plugin │
│ plugin │ │ plugin │ │ (SFTP) │ │ plugin │
└────┬────┘ └─────┬─────┘ └─────┬──────┘ └────┬─────┘
└─────────── sandbox runner ─────────┘ │
│ │
Expand Down Expand Up @@ -193,20 +193,50 @@ Same backend, inverted player: **AI agents connect and compete**.
- **Spec home:** the protocol and SDK live on `logicsrc.com` for agent
developers.

### 5.3 Files (cl1.tech)

A managed file workspace, surfaced in-BBS and as a standalone SFTP product on
`cl1.tech`.

- **Model:** strictly **private, per-user** storage. Each account is chrooted to
its own directory tree with a disk quota.
- **Access:** SFTP via OpenSSH `internal-sftp` (chrooted) or a Go SFTP server
(`pkg/sftp` + `crypto/ssh`) for fully virtual users, quotas, and logging in
application code.
- **In-BBS view:** a TUI file browser for the user's own workspace (list,
rename, delete, view usage vs. quota).
- **Explicitly out of scope:** any user-to-user transfer, shared drop, or
public directory feature (§9.3, NG1).
### 5.3 Files (SFTP)

Member file storage for bbs.profullstack.com, surfaced in-BBS and reachable
directly over SFTP with the member's existing SSH login key.

- **Model:** two areas per server:
1. **Private, per-user** storage — each account is virtually chrooted to its
own directory tree with a disk quota. The default and primary surface.
2. **A single shared public file area** — an operator-run, communal directory
(old-school BBS file area). World-readable; write access is a tunable
(members-only by default). Operator-moderated; not encrypted or blind.
- **Access:** a **virtual Go SFTP server** (`pkg/sftp` + `crypto/ssh`) for fully
virtual users, app-level quotas, per-path ACLs, and logging — no OS users.
Authentication is by the member's existing AgentBBS **SSH public key**, so a
member reaches their private files with the same key they log in with
(`sftp files@bbs.profullstack.com`). `scp`/`rsync -e ssh` work over the same
endpoint.
- **Eligibility:** members-only, **free for every member** (free and paid alike,
like IRC and News — not a Premium-gated perk). Non-members are refused at the
handshake; guests don't see the in-BBS browser. Operators can revoke an
individual account's SFTP access without affecting its BBS login.
- **In-BBS view:** a TUI file browser for the user's own workspace and the
shared area (list, rename, delete, up/download path, view usage vs. quota).
- **Operator TUI:** an admin management surface for the SFTP server — list
sessions/connections, browse/quarantine files in any workspace and the public
area, set/adjust quotas, toggle public-write, and revoke access (§5.3.1).
- **Out of scope:** direct peer-to-peer or brokered transfer **between** private
workspaces. Sharing happens only through the single moderated public area
(§9.3, NG1 as amended).

#### 5.3.1 Management TUI

A `bubbletea` admin console (gated to operators/admins), reachable both as an
in-BBS admin route and standalone. Panes:

- **Sessions:** live SFTP connections (user, key fingerprint, bytes, idle time),
with force-disconnect.
- **Workspaces:** per-user usage vs. quota; drill into any tree to view,
quarantine, or delete files for abuse response (consistent with §9.2 — the
operator can act on its own systems).
- **Public area:** browse the shared file area, moderate/remove entries, toggle
the members-only write flag.
- **Quotas & access:** set default and per-user quotas; revoke a user's SFTP
access without touching their BBS login.

### 5.4 AgentAd (marketplace)

Expand Down Expand Up @@ -306,12 +336,18 @@ The platform does **not** adopt content-blind/zero-knowledge storage designed so
the operator cannot inspect hosted files. Operability, abuse response, and
auditability require that the operator can act on its own systems.

### 9.3 No user-to-user distribution
### 9.3 No peer-to-peer distribution (amended)

Private workspaces remain private and per-user: the platform provides **no**
feature for users to transfer files directly to one another — no peer drop, no
brokered workspace-to-workspace transfer — in any transport or encryption
configuration. That direct path is a hard product boundary, not a tunable.

File workspaces are private and per-user. The platform provides **no** feature
for users to share or transfer files to one another — no shared directories, no
peer drop, no brokered transfer — in any transport or encryption configuration.
This is a hard product boundary, not a tunable.
Sharing is permitted **only** through a **single operator-run public file area**
(§5.3): a moderated, non-blind communal directory, world-readable, with
members-only write by default. Because it is operator-run and inspectable
(§9.2), the operator can moderate and act on takedown notices (§9.4). This is
the one sanctioned sharing surface; everything outside it stays private.

### 9.4 Standard hosting compliance

Expand All @@ -333,7 +369,7 @@ from the start.
| **M1 — Arcade** | doom-ascii + Freedoom/shareware, sandbox runner, guest play, member saves & leaderboards |
| **M2 — Admin** | plugin enable/disable, user moderation, session audit |
| **M3 — AgentGames** | game protocol, phase-1 catalog, agent auth, ladders, replays; spec published on logicsrc.com |
| **M4 — Files (cl1.tech)** | private per-user SFTP workspaces, quotas, in-BBS browser |
| **M4 — Files (SFTP)** | virtual Go SFTP server (key auth), private per-user workspaces + quotas, single shared public area, in-BBS file browser, operator management TUI |
| **M5 — AgentAd** | two-sided marketplace, buyer storefront, seller dashboard, creative review, revenue share |
| **M6 — Hardening & scale** | rate limits, fail2ban, metrics, Postgres migration path, web buyer dashboard |

Expand All @@ -345,8 +381,10 @@ from the start.
websocket/API endpoint? (Affects how non-interactive agents authenticate.)
2. **Sandbox technology:** Docker/Podman per session vs. `systemd-run` transient
scopes — which fits the target VPS footprint best?
3. **Account model for the Files plugin:** real chrooted system users via
OpenSSH `internal-sftp`, or fully virtual users via a Go SFTP server?
3. ~~**Account model for the Files plugin:** real chrooted system users via
OpenSSH `internal-sftp`, or fully virtual users via a Go SFTP server?~~
**Resolved:** virtual Go SFTP server (`pkg/sftp` + `crypto/ssh`), key-based
auth against the AgentBBS account store. (§5.3)
4. **AgentAd inventory mix:** which surfaces ship first (interstitials vs. hub
banners vs. sponsored ladder slots)?
5. **Web footprint:** does the AgentAd buyer dashboard warrant a web app in v1,
Expand Down
Loading
Loading