mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-08-13 22:37:28 +00:00
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:
parent
68899180a7
commit
6dc94bd784
18 changed files with 2223 additions and 27 deletions
87
internal/files/e2e_test.go
Normal file
87
internal/files/e2e_test.go
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/pkg/sftp"
|
||||
)
|
||||
|
||||
// startE2E wires a real sftp client to the request server for one member, over
|
||||
// an in-memory pipe (no SSH transport).
|
||||
func startE2E(t *testing.T) *sftp.Client {
|
||||
t.Helper()
|
||||
svc, _, u := newTestService(t)
|
||||
sess, err := svc.newSession(u)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srvConn, cliConn := net.Pipe()
|
||||
handlers := sftp.Handlers{FileGet: sess, FilePut: sess, FileCmd: sess, FileList: sess}
|
||||
server := sftp.NewRequestServer(srvConn, handlers)
|
||||
go func() { _ = server.Serve() }()
|
||||
client, err := sftp.NewClientPipe(cliConn, cliConn)
|
||||
if err != nil {
|
||||
t.Fatalf("NewClientPipe: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = client.Close(); _ = server.Close() })
|
||||
return client
|
||||
}
|
||||
|
||||
func TestE2E_RootListing(t *testing.T) {
|
||||
client := startE2E(t)
|
||||
infos, err := client.ReadDir("/")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDir /: %v", err)
|
||||
}
|
||||
var names []string
|
||||
for _, fi := range infos {
|
||||
names = append(names, fi.Name())
|
||||
}
|
||||
sort.Strings(names)
|
||||
if len(names) != 2 || names[0] != "me" || names[1] != "public" {
|
||||
t.Errorf("root listing = %v, want [me public]", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2E_UploadDownloadPrivate(t *testing.T) {
|
||||
client := startE2E(t)
|
||||
f, err := client.Create("/me/hello.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if _, err := f.Write([]byte("hello bbs")); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
|
||||
rf, err := client.Open("/me/hello.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
defer rf.Close()
|
||||
got, err := io.ReadAll(rf)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAll: %v", err)
|
||||
}
|
||||
if string(got) != "hello bbs" {
|
||||
t.Errorf("read = %q, want %q", got, "hello bbs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2E_TraversalDenied(t *testing.T) {
|
||||
client := startE2E(t)
|
||||
// A normalizing client may collapse "/me/../.." to "/" before sending, so
|
||||
// the guarantee we assert is the negative one: no escape ever yields a
|
||||
// handle onto a system path.
|
||||
if _, err := client.Open("/me/../../../../etc/passwd"); err == nil {
|
||||
t.Error("opening an escaping path should fail")
|
||||
}
|
||||
if _, err := client.Open("/etc/passwd"); err == nil {
|
||||
t.Error("opening an unknown area should fail")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue