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
470
internal/files/fs.go
Normal file
470
internal/files/fs.go
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
package files
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/sftp"
|
||||
"github.com/profullstack/agentbbs/internal/store"
|
||||
)
|
||||
|
||||
// area names exposed at the virtual root.
|
||||
const (
|
||||
areaMe = "me"
|
||||
areaPublic = "public"
|
||||
)
|
||||
|
||||
// errEscape is returned when a resolved path would leave its area root. It maps
|
||||
// to an SFTP permission-denied; it must never reach the client as a real path.
|
||||
var errEscape = errors.New("files: path escapes its area")
|
||||
|
||||
// session is one member's view of the filesystem for the life of an SFTP
|
||||
// connection. It implements the pkg/sftp request handlers and enforces area
|
||||
// confinement, the public-area ACL, and the per-user quota.
|
||||
type session struct {
|
||||
svc *Service
|
||||
user store.User
|
||||
pubWrite bool
|
||||
quota int64
|
||||
used atomic.Int64 // live private-workspace usage, for quota checks
|
||||
}
|
||||
|
||||
func (s *Service) newSession(u store.User) (*session, error) {
|
||||
if err := s.ensureWorkspace(u.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
used, err := dirSize(s.privRoot(u.Name))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sess := &session{svc: s, user: u, pubWrite: s.publicWritable(), quota: s.quotaFor(u.ID)}
|
||||
sess.used.Store(used)
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
// resolved is the outcome of mapping a client (virtual) path to disk.
|
||||
type resolved struct {
|
||||
real string // absolute on-disk path ("" for the synthetic root)
|
||||
area string // "", areaMe, or areaPublic
|
||||
root bool // the synthetic "/" listing me + public
|
||||
writable bool // whether this area accepts writes for this member
|
||||
}
|
||||
|
||||
// resolve maps a client path to disk, confined to its area root. It rejects any
|
||||
// path that escapes (lexically or via an existing symlink). This is the single
|
||||
// security chokepoint — every handler goes through it.
|
||||
func (s *session) resolve(p string) (resolved, error) {
|
||||
clean := path.Clean("/" + strings.TrimSpace(p))
|
||||
if clean == "/" || clean == "." {
|
||||
return resolved{root: true}, nil
|
||||
}
|
||||
seg := strings.SplitN(strings.TrimPrefix(clean, "/"), "/", 2)
|
||||
rest := ""
|
||||
if len(seg) == 2 {
|
||||
rest = seg[1]
|
||||
}
|
||||
var areaRoot, area string
|
||||
writable := false
|
||||
switch seg[0] {
|
||||
case areaMe:
|
||||
areaRoot, area, writable = s.svc.privRoot(s.user.Name), areaMe, true
|
||||
case areaPublic:
|
||||
areaRoot, area, writable = s.svc.pubRoot(), areaPublic, s.pubWrite
|
||||
default:
|
||||
return resolved{}, os.ErrNotExist
|
||||
}
|
||||
real, err := safeJoin(areaRoot, rest)
|
||||
if err != nil {
|
||||
return resolved{}, err
|
||||
}
|
||||
return resolved{real: real, area: area, writable: writable}, nil
|
||||
}
|
||||
|
||||
// safeJoin joins rel onto root and verifies the result stays within root, both
|
||||
// lexically and after resolving any symlinks that already exist on the path.
|
||||
func safeJoin(root, rel string) (string, error) {
|
||||
full := filepath.Join(root, filepath.FromSlash(rel))
|
||||
full = filepath.Clean(full)
|
||||
if !within(root, full) {
|
||||
return "", errEscape
|
||||
}
|
||||
// Symlink guard: resolve the longest existing prefix and re-check. This
|
||||
// catches a symlink (created out-of-band) that points outside the area.
|
||||
probe := full
|
||||
for {
|
||||
if resolvedPath, err := filepath.EvalSymlinks(probe); err == nil {
|
||||
if !within(root, resolvedPath) {
|
||||
return "", errEscape
|
||||
}
|
||||
break
|
||||
}
|
||||
parent := filepath.Dir(probe)
|
||||
if parent == probe {
|
||||
break
|
||||
}
|
||||
probe = parent
|
||||
}
|
||||
return full, nil
|
||||
}
|
||||
|
||||
// within reports whether p is root itself or lives under it.
|
||||
func within(root, p string) bool {
|
||||
if p == root {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(p, root+string(os.PathSeparator))
|
||||
}
|
||||
|
||||
// OpenFor builds a member session by account name, for the in-BBS browser. It
|
||||
// returns the resolved store user too.
|
||||
func (s *Service) OpenFor(name string) (*session, store.User, error) {
|
||||
u, ok, err := s.st.UserByName(name)
|
||||
if err != nil {
|
||||
return nil, store.User{}, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, store.User{}, os.ErrNotExist
|
||||
}
|
||||
sess, err := s.newSession(u)
|
||||
return sess, u, err
|
||||
}
|
||||
|
||||
// Entry is a directory entry for the in-BBS browser.
|
||||
type Entry struct {
|
||||
Name string
|
||||
IsDir bool
|
||||
Size int64
|
||||
ModTime time.Time
|
||||
}
|
||||
|
||||
// entries lists a virtual directory for the browser, with the synthetic root
|
||||
// showing the two areas. Directories sort before files, then by name.
|
||||
func (s *session) entries(vpath string) ([]Entry, error) {
|
||||
res, err := s.resolve(vpath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.root {
|
||||
return []Entry{{Name: areaMe, IsDir: true}, {Name: areaPublic, IsDir: true}}, nil
|
||||
}
|
||||
des, err := os.ReadDir(res.real)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]Entry, 0, len(des))
|
||||
for _, de := range des {
|
||||
fi, err := de.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, Entry{Name: de.Name(), IsDir: de.IsDir(), Size: fi.Size(), ModTime: fi.ModTime()})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].IsDir != out[j].IsDir {
|
||||
return out[i].IsDir
|
||||
}
|
||||
return out[i].Name < out[j].Name
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// canWrite reports whether the area containing vpath accepts writes.
|
||||
func (s *session) canWrite(vpath string) bool {
|
||||
res, err := s.resolve(vpath)
|
||||
return err == nil && !res.root && res.writable
|
||||
}
|
||||
|
||||
func (s *session) mkdir(vpath string) error {
|
||||
res, err := s.resolve(vpath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.root || !res.writable {
|
||||
return os.ErrPermission
|
||||
}
|
||||
return os.Mkdir(res.real, 0o755)
|
||||
}
|
||||
|
||||
// remove deletes a file or directory (recursively) within a writable area.
|
||||
func (s *session) remove(vpath string) error {
|
||||
res, err := s.resolve(vpath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if res.root || !res.writable {
|
||||
return os.ErrPermission
|
||||
}
|
||||
return os.RemoveAll(res.real)
|
||||
}
|
||||
|
||||
// rename moves within a single writable area (no cross-area moves).
|
||||
func (s *session) rename(oldv, newv string) error {
|
||||
src, err := s.resolve(oldv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dst, err := s.resolve(newv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if src.root || dst.root || !src.writable || !dst.writable || src.area != dst.area {
|
||||
return os.ErrPermission
|
||||
}
|
||||
return os.Rename(src.real, dst.real)
|
||||
}
|
||||
|
||||
// readFile returns up to max bytes of a file for in-TUI viewing; truncated is
|
||||
// true if the file was longer.
|
||||
func (s *session) readFile(vpath string, max int64) (data []byte, truncated bool, err error) {
|
||||
res, err := s.resolve(vpath)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if res.root {
|
||||
return nil, false, os.ErrInvalid
|
||||
}
|
||||
f, err := os.Open(res.real)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
defer f.Close()
|
||||
buf := make([]byte, max+1)
|
||||
n, err := io.ReadFull(f, buf)
|
||||
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
|
||||
return nil, false, err
|
||||
}
|
||||
if int64(n) > max {
|
||||
return buf[:max], true, nil
|
||||
}
|
||||
return buf[:n], false, nil
|
||||
}
|
||||
|
||||
// --- pkg/sftp request handlers ----------------------------------------------
|
||||
|
||||
// Fileread serves downloads.
|
||||
func (s *session) Fileread(r *sftp.Request) (io.ReaderAt, error) {
|
||||
res, err := s.resolve(r.Filepath)
|
||||
if err != nil {
|
||||
return nil, sftpErr(err)
|
||||
}
|
||||
if res.root {
|
||||
return nil, sftp.ErrSSHFxOpUnsupported
|
||||
}
|
||||
f, err := os.Open(res.real)
|
||||
if err != nil {
|
||||
return nil, sftpErr(err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// Filewrite serves uploads, enforcing the per-user quota in the private area.
|
||||
func (s *session) Filewrite(r *sftp.Request) (io.WriterAt, error) {
|
||||
res, err := s.resolve(r.Filepath)
|
||||
if err != nil {
|
||||
return nil, sftpErr(err)
|
||||
}
|
||||
if res.root || !res.writable {
|
||||
return nil, sftp.ErrSSHFxPermissionDenied
|
||||
}
|
||||
var startSize int64
|
||||
if fi, err := os.Stat(res.real); err == nil {
|
||||
startSize = fi.Size()
|
||||
}
|
||||
f, err := os.OpenFile(res.real, fileFlags(r.Pflags()), 0o644)
|
||||
if err != nil {
|
||||
return nil, sftpErr(err)
|
||||
}
|
||||
// The public area is operator-managed (no per-user quota); the private
|
||||
// workspace is metered.
|
||||
if res.area != areaMe {
|
||||
return f, nil
|
||||
}
|
||||
return "aWriter{f: f, sess: s, tracked: startSize}, nil
|
||||
}
|
||||
|
||||
// Filecmd handles mutating operations other than read/write.
|
||||
func (s *session) Filecmd(r *sftp.Request) error {
|
||||
res, err := s.resolve(r.Filepath)
|
||||
if err != nil {
|
||||
return sftpErr(err)
|
||||
}
|
||||
if res.root || !res.writable {
|
||||
return sftp.ErrSSHFxPermissionDenied
|
||||
}
|
||||
switch r.Method {
|
||||
case "Mkdir":
|
||||
return sftpErr(os.Mkdir(res.real, 0o755))
|
||||
case "Rmdir":
|
||||
return sftpErr(os.Remove(res.real))
|
||||
case "Remove":
|
||||
return sftpErr(os.Remove(res.real))
|
||||
case "Setstat":
|
||||
return s.setstat(res.real, r)
|
||||
case "Rename":
|
||||
dst, err := s.resolve(r.Target)
|
||||
if err != nil {
|
||||
return sftpErr(err)
|
||||
}
|
||||
if dst.root || !dst.writable || dst.area != res.area {
|
||||
// Renames stay within one writable area — no cross-area (and so no
|
||||
// workspace-to-workspace) moves.
|
||||
return sftp.ErrSSHFxPermissionDenied
|
||||
}
|
||||
return sftpErr(os.Rename(res.real, dst.real))
|
||||
case "Symlink", "Link":
|
||||
// No symlinks/hardlinks: they are an escape vector and have no place in
|
||||
// a metered virtual workspace.
|
||||
return sftp.ErrSSHFxOpUnsupported
|
||||
default:
|
||||
return sftp.ErrSSHFxOpUnsupported
|
||||
}
|
||||
}
|
||||
|
||||
func (s *session) setstat(real string, r *sftp.Request) error {
|
||||
a := r.Attributes()
|
||||
if a.Size != 0 || r.AttrFlags().Size {
|
||||
if err := os.Truncate(real, int64(a.Size)); err != nil {
|
||||
return sftpErr(err)
|
||||
}
|
||||
}
|
||||
if r.AttrFlags().Permissions {
|
||||
if err := os.Chmod(real, a.FileMode().Perm()); err != nil {
|
||||
return sftpErr(err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Filelist handles List (readdir), Stat, and Readlink.
|
||||
func (s *session) Filelist(r *sftp.Request) (sftp.ListerAt, error) {
|
||||
res, err := s.resolve(r.Filepath)
|
||||
if err != nil {
|
||||
return nil, sftpErr(err)
|
||||
}
|
||||
switch r.Method {
|
||||
case "List":
|
||||
if res.root {
|
||||
return listerAt{dirInfo(areaMe), dirInfo(areaPublic)}, nil
|
||||
}
|
||||
entries, err := os.ReadDir(res.real)
|
||||
if err != nil {
|
||||
return nil, sftpErr(err)
|
||||
}
|
||||
infos := make([]os.FileInfo, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
fi, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
infos = append(infos, fi)
|
||||
}
|
||||
return listerAt(infos), nil
|
||||
case "Stat":
|
||||
if res.root {
|
||||
return listerAt{dirInfo("/")}, nil
|
||||
}
|
||||
fi, err := os.Stat(res.real)
|
||||
if err != nil {
|
||||
return nil, sftpErr(err)
|
||||
}
|
||||
return listerAt{fi}, nil
|
||||
case "Readlink":
|
||||
return nil, sftp.ErrSSHFxOpUnsupported
|
||||
default:
|
||||
return nil, sftp.ErrSSHFxOpUnsupported
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ----------------------------------------------------------------
|
||||
|
||||
// quotaWriter wraps an *os.File and fails writes that would push the member's
|
||||
// private workspace over quota. It is conservative: it counts the high-water
|
||||
// mark of each file and never decrements live usage (recomputed next session).
|
||||
type quotaWriter struct {
|
||||
f *os.File
|
||||
sess *session
|
||||
tracked int64 // current accounted size of this file
|
||||
}
|
||||
|
||||
func (w *quotaWriter) WriteAt(p []byte, off int64) (int, error) {
|
||||
end := off + int64(len(p))
|
||||
if end > w.tracked {
|
||||
delta := end - w.tracked
|
||||
if w.sess.used.Add(delta) > w.sess.quota {
|
||||
w.sess.used.Add(-delta)
|
||||
return 0, sftp.ErrSSHFxFailure // "quota exceeded"
|
||||
}
|
||||
w.tracked = end
|
||||
}
|
||||
return w.f.WriteAt(p, off)
|
||||
}
|
||||
|
||||
func (w *quotaWriter) Close() error { return w.f.Close() }
|
||||
|
||||
// fileFlags maps SFTP open flags to os.OpenFile flags for writes.
|
||||
func fileFlags(f sftp.FileOpenFlags) int {
|
||||
flags := os.O_WRONLY
|
||||
if f.Creat {
|
||||
flags |= os.O_CREATE
|
||||
}
|
||||
if f.Trunc {
|
||||
flags |= os.O_TRUNC
|
||||
}
|
||||
if f.Append {
|
||||
flags |= os.O_APPEND
|
||||
}
|
||||
if f.Excl {
|
||||
flags |= os.O_EXCL
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
// sftpErr translates an OS/internal error into the SFTP status a client should
|
||||
// see, without leaking real paths. nil passes through.
|
||||
func sftpErr(err error) error {
|
||||
switch {
|
||||
case err == nil:
|
||||
return nil
|
||||
case errors.Is(err, errEscape):
|
||||
return sftp.ErrSSHFxPermissionDenied
|
||||
case errors.Is(err, os.ErrNotExist):
|
||||
return sftp.ErrSSHFxNoSuchFile
|
||||
case errors.Is(err, os.ErrPermission):
|
||||
return sftp.ErrSSHFxPermissionDenied
|
||||
case errors.Is(err, os.ErrExist):
|
||||
return sftp.ErrSSHFxFailure
|
||||
default:
|
||||
return sftp.ErrSSHFxFailure
|
||||
}
|
||||
}
|
||||
|
||||
// listerAt adapts a slice of FileInfo to sftp.ListerAt.
|
||||
type listerAt []os.FileInfo
|
||||
|
||||
func (l listerAt) ListAt(dst []os.FileInfo, off int64) (int, error) {
|
||||
if off >= int64(len(l)) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n := copy(dst, l[off:])
|
||||
if int(off)+n >= len(l) {
|
||||
return n, io.EOF
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// dirInfo is a synthetic directory FileInfo for the virtual-root entries.
|
||||
type dirInfo string
|
||||
|
||||
func (d dirInfo) Name() string { return string(d) }
|
||||
func (dirInfo) Size() int64 { return 0 }
|
||||
func (dirInfo) Mode() os.FileMode { return os.ModeDir | 0o555 }
|
||||
func (dirInfo) ModTime() time.Time { return time.Time{} }
|
||||
func (dirInfo) IsDir() bool { return true }
|
||||
func (dirInfo) Sys() any { return nil }
|
||||
Loading…
Add table
Add a link
Reference in a new issue