feat(files): SFTP member storage — private workspaces + shared public area + mgmt TUI

Implements M4 (Files). A fully virtual Go SFTP server (pkg/sftp + crypto/ssh,
no OS users) wired as an "sftp" subsystem on the existing :22 wish listener, so
members reach their files with their login key:

    sftp files@bbs.profullstack.com     # scp/rsync ride the same endpoint

Identity is the SSH key (the username is conventional/ignored). Two areas per
session: a private, quota-limited /me workspace and a single shared public file
area /public (old-school BBS file area; world-read, members-only write by
default, operator-moderated). This reverses the old NG1 "no sharing" boundary in
favour of one sanctioned, inspectable sharing surface (PRD §9.3 amended).

internal/files:
- backend.go  service, layout, quota/usage, live-session registry, operator API
- fs.go       per-session virtual FS; resolve() is the single security
              chokepoint (area confinement + symlink-escape guard) + pkg/sftp
              request handlers
- server.go   subsystem handler: key auth -> member session -> request server,
              with byte metering and force-disconnect
- tui.go      in-BBS member browser (hub plugin "Files")
- admin.go    operator management TUI: sessions, workspaces/quotas, public area

Operator console: ssh sftp@<host> (allowlist-gated; sftpadmin@/filesadmin@
aliases) — list/disconnect sessions, set per-user quotas, revoke SFTP access,
toggle public write, moderate the public area.

store: files_access (per-user quota override + revoked) and files_settings
(public-write mode) tables + methods. main.go wiring guarded by AGENTBBS_FILES
(+ AGENTBBS_FILES_QUOTA_MB, default 1 GiB). Route names reserved.

Tests (incl -race): path traversal/confinement, symlink-escape rejection,
public-write ACL, quota enforcement, usage accounting, and an end-to-end run
against a real SFTP client. Docs: docs/files.md; PRD §5.3/§5.3.1/§9.3 + README
updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Anthony Ettinger 2026-06-23 09:35:04 +00:00
parent 68899180a7
commit 6dc94bd784
18 changed files with 2223 additions and 27 deletions

View file

@ -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"
)
@ -154,6 +155,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 {

View file

@ -55,6 +55,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/forwardemail"
"github.com/profullstack/agentbbs/internal/games"
@ -102,6 +103,7 @@ type app struct {
fe forwardemail.Config // premium @bbs email provisioning
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
@ -173,6 +175,22 @@ func main() {
time.Duration(envInt("AGENTBBS_GAME_QUEUE_WAIT", 120))*time.Second)
a.registry = []plugin.Plugin{arcade.Plugin{}, agentgames.New(a.gamesReg), qryptinviteplugin.Plugin{}, about.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 {
@ -250,7 +268,7 @@ 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
@ -258,13 +276,19 @@ func main() {
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)
}
@ -291,9 +315,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)
@ -318,6 +344,8 @@ func (a *app) router() wish.Middleware {
a.handleNews(s)
case auth.IsMailName(user):
a.handleMail(s)
case auth.IsFilesAdminName(user):
filesAdminHandler(s)
case isVideo:
a.handleVideo(s, code)
case user == "agent":