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
187
internal/files/files_test.go
Normal file
187
internal/files/files_test.go
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pkg/sftp"
|
||||
"github.com/profullstack/agentbbs/internal/store"
|
||||
)
|
||||
|
||||
func newTestService(t *testing.T) (*Service, store.Store, store.User) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
st, err := store.Open(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
svc, err := New(st, Config{Root: filepath.Join(dir, "files"), DefaultQuota: 1 << 20})
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
u, err := st.EnsureUser("alice", "member", "SHA256:alicekey")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureUser: %v", err)
|
||||
}
|
||||
return svc, st, u
|
||||
}
|
||||
|
||||
func TestResolveConfinement(t *testing.T) {
|
||||
svc, _, u := newTestService(t)
|
||||
sess, err := svc.newSession(u)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
priv := svc.privRoot(u.Name)
|
||||
pub := svc.pubRoot()
|
||||
|
||||
// These must never resolve to a path outside their area root. Some are
|
||||
// expected to error outright; for the rest, assert containment.
|
||||
escapes := []string{
|
||||
"/me/../../../etc/passwd",
|
||||
"/me/../../etc",
|
||||
"/me/sub/../../../../etc/shadow",
|
||||
"/public/../me/secret",
|
||||
"/public/../../etc",
|
||||
"/../etc/passwd",
|
||||
"/me/./../../public/../../root",
|
||||
}
|
||||
for _, p := range escapes {
|
||||
res, err := sess.resolve(p)
|
||||
if err != nil {
|
||||
continue // rejected outright — fine
|
||||
}
|
||||
if res.root {
|
||||
continue // collapsed to the synthetic root — fine
|
||||
}
|
||||
ok := within(priv, res.real) || within(pub, res.real)
|
||||
if !ok {
|
||||
t.Errorf("resolve(%q) escaped: %q (priv=%q pub=%q)", p, res.real, priv, pub)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAreas(t *testing.T) {
|
||||
svc, _, u := newTestService(t)
|
||||
sess, _ := svc.newSession(u)
|
||||
|
||||
root, _ := sess.resolve("/")
|
||||
if !root.root {
|
||||
t.Error("/ should be the synthetic root")
|
||||
}
|
||||
me, err := sess.resolve("/me/notes.txt")
|
||||
if err != nil || me.area != areaMe || !me.writable {
|
||||
t.Errorf("/me should be writable me-area: %+v err=%v", me, err)
|
||||
}
|
||||
if !within(svc.privRoot(u.Name), me.real) {
|
||||
t.Errorf("/me path %q not under priv root", me.real)
|
||||
}
|
||||
pub, err := sess.resolve("/public/shared.txt")
|
||||
if err != nil || pub.area != areaPublic {
|
||||
t.Errorf("/public should be public area: %+v err=%v", pub, err)
|
||||
}
|
||||
if _, err := sess.resolve("/etc/passwd"); err == nil {
|
||||
t.Error("unknown top-level area should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSymlinkEscapeBlocked(t *testing.T) {
|
||||
svc, _, u := newTestService(t)
|
||||
sess, _ := svc.newSession(u)
|
||||
priv := svc.privRoot(u.Name)
|
||||
|
||||
// Plant a symlink inside the workspace pointing at the system root.
|
||||
link := filepath.Join(priv, "escape")
|
||||
if err := os.Symlink("/etc", link); err != nil {
|
||||
t.Skipf("symlink unsupported: %v", err)
|
||||
}
|
||||
if _, err := sess.resolve("/me/escape/passwd"); err == nil {
|
||||
t.Error("path through an escaping symlink must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicWriteACL(t *testing.T) {
|
||||
svc, st, u := newTestService(t)
|
||||
|
||||
// Default: members may write to the public area.
|
||||
sess, _ := svc.newSession(u)
|
||||
r := sftp.NewRequest("Mkdir", "/public/uploads")
|
||||
if err := sess.Filecmd(r); err != nil {
|
||||
t.Fatalf("public mkdir should succeed by default: %v", err)
|
||||
}
|
||||
|
||||
// Turn public write off → writes denied, reads still fine.
|
||||
if err := svc.SetPublicWrite(false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = st
|
||||
sess2, _ := svc.newSession(u)
|
||||
if err := sess2.Filecmd(sftp.NewRequest("Mkdir", "/public/more")); err != sftp.ErrSSHFxPermissionDenied {
|
||||
t.Errorf("public write should be denied when off, got %v", err)
|
||||
}
|
||||
res, err := sess2.resolve("/public/uploads")
|
||||
if err != nil || res.writable {
|
||||
t.Errorf("public should resolve read-only when write is off: %+v err=%v", res, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuotaEnforced(t *testing.T) {
|
||||
svc, _, u := newTestService(t)
|
||||
sess, _ := svc.newSession(u)
|
||||
sess.quota = 100 // tiny
|
||||
|
||||
f, err := os.Create(filepath.Join(svc.privRoot(u.Name), "big"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
w := "aWriter{f: f, sess: sess}
|
||||
|
||||
if _, err := w.WriteAt(make([]byte, 80), 0); err != nil {
|
||||
t.Fatalf("write within quota failed: %v", err)
|
||||
}
|
||||
if _, err := w.WriteAt(make([]byte, 80), 80); err != sftp.ErrSSHFxFailure {
|
||||
t.Errorf("write over quota should fail, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsage(t *testing.T) {
|
||||
svc, _, u := newTestService(t)
|
||||
if err := svc.ensureWorkspace(u.Name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []byte(strings.Repeat("x", 512))
|
||||
if err := os.WriteFile(filepath.Join(svc.privRoot(u.Name), "a.txt"), want, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
usage, err := svc.Usage(u)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if usage.Bytes != 512 {
|
||||
t.Errorf("usage = %d, want 512", usage.Bytes)
|
||||
}
|
||||
if usage.Quota != 1<<20 {
|
||||
t.Errorf("quota = %d, want default", usage.Quota)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeBlocksAndQuotaOverride(t *testing.T) {
|
||||
svc, st, u := newTestService(t)
|
||||
if err := st.SetFilesQuota(u.ID, 4096); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := svc.quotaFor(u.ID); got != 4096 {
|
||||
t.Errorf("quotaFor = %d, want 4096 override", got)
|
||||
}
|
||||
if err := st.SetFilesRevoked(u.ID, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fa, err := st.FilesAccess(u.ID)
|
||||
if err != nil || !fa.Revoked {
|
||||
t.Errorf("FilesAccess revoked = %+v err=%v", fa, err)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue