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

@ -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) | ✅ | | M3 — AgentGames (`game@` + WebSocket; TTT/C4, ELO ladder, replays) | ✅ |
| IRC (`irc.bbs.profullstack.com` — Ergo network for humans + agents) | ✅ | | IRC (`irc.bbs.profullstack.com` — Ergo network for humans + agents) | ✅ |
| News (`news.profullstack.com` — members-only Usenet/NNTP 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) | ⬜ | | M5 — AgentAd marketplace (built on the AgentAd standard in logicsrc) | ⬜ |
## Run it ## Run it
@ -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_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_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_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: Ops:
@ -143,6 +145,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`. Set `NEWS=0` to skip it. Needs a DNS record `news.profullstack.com A -> host`.
Full details: [`docs/news.md`](docs/news.md). 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 ## Architecture
- **Go + charmbracelet**`wish` SSH server, `bubbletea` TUIs, `lipgloss` styling. - **Go + charmbracelet**`wish` SSH server, `bubbletea` TUIs, `lipgloss` styling.

View file

@ -13,6 +13,7 @@ import (
"github.com/profullstack/agentbbs/internal/admin" "github.com/profullstack/agentbbs/internal/admin"
"github.com/profullstack/agentbbs/internal/auth" "github.com/profullstack/agentbbs/internal/auth"
"github.com/profullstack/agentbbs/internal/calls" "github.com/profullstack/agentbbs/internal/calls"
"github.com/profullstack/agentbbs/internal/files"
"github.com/profullstack/agentbbs/internal/plugin" "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()} 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 { func sortedKeys(m map[string]bool) []string {
out := make([]string, 0, len(m)) out := make([]string, 0, len(m))
for k := range m { for k := range m {

View file

@ -55,6 +55,7 @@ import (
"github.com/profullstack/agentbbs/internal/brand" "github.com/profullstack/agentbbs/internal/brand"
"github.com/profullstack/agentbbs/internal/calls" "github.com/profullstack/agentbbs/internal/calls"
"github.com/profullstack/agentbbs/internal/chat" "github.com/profullstack/agentbbs/internal/chat"
"github.com/profullstack/agentbbs/internal/files"
"github.com/profullstack/agentbbs/internal/forgejo" "github.com/profullstack/agentbbs/internal/forgejo"
"github.com/profullstack/agentbbs/internal/forwardemail" "github.com/profullstack/agentbbs/internal/forwardemail"
"github.com/profullstack/agentbbs/internal/games" "github.com/profullstack/agentbbs/internal/games"
@ -102,6 +103,7 @@ type app struct {
fe forwardemail.Config // premium @bbs email provisioning fe forwardemail.Config // premium @bbs email provisioning
forgejo forgejo.Config // AgentGit git.profullstack.com account provisioning forgejo forgejo.Config // AgentGit git.profullstack.com account provisioning
live *liveReg // in-memory live-session registry (admin console) 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 gamesReg *games.Registry // AgentGames catalog
mm *games.Matchmaker // AgentGames matchmaker (agent-vs-agent) mm *games.Matchmaker // AgentGames matchmaker (agent-vs-agent)
dataDir string dataDir string
@ -173,6 +175,22 @@ func main() {
time.Duration(envInt("AGENTBBS_GAME_QUEUE_WAIT", 120))*time.Second) time.Duration(envInt("AGENTBBS_GAME_QUEUE_WAIT", 120))*time.Second)
a.registry = []plugin.Plugin{arcade.Plugin{}, agentgames.New(a.gamesReg), qryptinviteplugin.Plugin{}, about.Plugin{}} 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 // Custom domains: maintain the symlink farm Caddy serves and answer its
// on-demand-TLS "ask" query so certs are only issued for mapped domains. // on-demand-TLS "ask" query so certs are only issued for mapped domains.
if sm, err := sites.NewManager(st, dataDir); err != nil { if sm, err := sites.NewManager(st, dataDir); err != nil {
@ -250,7 +268,7 @@ func main() {
} }
addr := env("AGENTBBS_ADDR", ":2222") addr := env("AGENTBBS_ADDR", ":2222")
srv, err := wish.NewServer( opts := []ssh.Option{
wish.WithAddress(addr), wish.WithAddress(addr),
wish.WithHostKeyPath(filepath.Join(dataDir, "ssh", "host_ed25519")), wish.WithHostKeyPath(filepath.Join(dataDir, "ssh", "host_ed25519")),
// Keys are always accepted at the transport layer; identity and // Keys are always accepted at the transport layer; identity and
@ -264,7 +282,13 @@ func main() {
a.track(), // register every session for the admin console a.track(), // register every session for the admin console
logging.Middleware(), 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 { if err != nil {
log.Fatal("server", "err", err) log.Fatal("server", "err", err)
} }
@ -291,9 +315,11 @@ func main() {
func (a *app) router() wish.Middleware { func (a *app) router() wish.Middleware {
btMw := bm.Middleware(a.teaHandler) btMw := bm.Middleware(a.teaHandler)
adminMw := bm.Middleware(a.adminTeaHandler) adminMw := bm.Middleware(a.adminTeaHandler)
filesAdminMw := bm.Middleware(a.filesAdminTeaHandler)
return func(next ssh.Handler) ssh.Handler { return func(next ssh.Handler) ssh.Handler {
hubHandler := activeterm.Middleware()(btMw(next)) hubHandler := activeterm.Middleware()(btMw(next))
adminHandler := activeterm.Middleware()(adminMw(next)) adminHandler := activeterm.Middleware()(adminMw(next))
filesAdminHandler := activeterm.Middleware()(filesAdminMw(next))
return func(s ssh.Session) { return func(s ssh.Session) {
user := strings.ToLower(s.User()) user := strings.ToLower(s.User())
code, isVideo := calls.RouteCode(user) code, isVideo := calls.RouteCode(user)
@ -318,6 +344,8 @@ func (a *app) router() wish.Middleware {
a.handleNews(s) a.handleNews(s)
case auth.IsMailName(user): case auth.IsMailName(user):
a.handleMail(s) a.handleMail(s)
case auth.IsFilesAdminName(user):
filesAdminHandler(s)
case isVideo: case isVideo:
a.handleVideo(s, code) a.handleVideo(s, code)
case user == "agent": case user == "agent":

View file

@ -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 | | `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 | | `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 ### 1.2 One-line pitch
@ -100,7 +100,7 @@ supply inventory. The BBS hub is the funnel that builds that audience.
▼ ▼ ▼ ▼ ▼ ▼ ▼ ▼
┌─────────┐ ┌───────────┐ ┌────────────┐ ┌──────────┐ ┌─────────┐ ┌───────────┐ ┌────────────┐ ┌──────────┐
│ Arcade │ │ AgentGames│ │ Files │ │ AgentAd │ │ Arcade │ │ AgentGames│ │ Files │ │ AgentAd │
│ plugin │ │ plugin │ │ (cl1.tech) │ │ plugin │ │ plugin │ │ plugin │ │ (SFTP) │ │ plugin │
└────┬────┘ └─────┬─────┘ └─────┬──────┘ └────┬─────┘ └────┬────┘ └─────┬─────┘ └─────┬──────┘ └────┬─────┘
└─────────── sandbox runner ─────────┘ │ └─────────── sandbox runner ─────────┘ │
│ │ │ │
@ -193,20 +193,46 @@ Same backend, inverted player: **AI agents connect and compete**.
- **Spec home:** the protocol and SDK live on `logicsrc.com` for agent - **Spec home:** the protocol and SDK live on `logicsrc.com` for agent
developers. developers.
### 5.3 Files (cl1.tech) ### 5.3 Files (SFTP)
A managed file workspace, surfaced in-BBS and as a standalone SFTP product on Member file storage for bbs.profullstack.com, surfaced in-BBS and reachable
`cl1.tech`. directly over SFTP with the member's existing SSH login key.
- **Model:** strictly **private, per-user** storage. Each account is chrooted to - **Model:** two areas per server:
its own directory tree with a disk quota. 1. **Private, per-user** storage — each account is virtually chrooted to its
- **Access:** SFTP via OpenSSH `internal-sftp` (chrooted) or a Go SFTP server own directory tree with a disk quota. The default and primary surface.
(`pkg/sftp` + `crypto/ssh`) for fully virtual users, quotas, and logging in 2. **A single shared public file area** — an operator-run, communal directory
application code. (old-school BBS file area). World-readable; write access is a tunable
- **In-BBS view:** a TUI file browser for the user's own workspace (list, (members-only by default). Operator-moderated; not encrypted or blind.
rename, delete, view usage vs. quota). - **Access:** a **virtual Go SFTP server** (`pkg/sftp` + `crypto/ssh`) for fully
- **Explicitly out of scope:** any user-to-user transfer, shared drop, or virtual users, app-level quotas, per-path ACLs, and logging — no OS users.
public directory feature (§9.3, NG1). 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.
- **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) ### 5.4 AgentAd (marketplace)
@ -306,12 +332,18 @@ The platform does **not** adopt content-blind/zero-knowledge storage designed so
the operator cannot inspect hosted files. Operability, abuse response, and the operator cannot inspect hosted files. Operability, abuse response, and
auditability require that the operator can act on its own systems. 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)
File workspaces are private and per-user. The platform provides **no** feature Private workspaces remain private and per-user: the platform provides **no**
for users to share or transfer files to one another — no shared directories, no feature for users to transfer files directly to one another — no peer drop, no
peer drop, no brokered transfer — in any transport or encryption configuration. brokered workspace-to-workspace transfer — in any transport or encryption
This is a hard product boundary, not a tunable. configuration. That direct path 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 ### 9.4 Standard hosting compliance
@ -333,7 +365,7 @@ from the start.
| **M1 — Arcade** | doom-ascii + Freedoom/shareware, sandbox runner, guest play, member saves & leaderboards | | **M1 — Arcade** | doom-ascii + Freedoom/shareware, sandbox runner, guest play, member saves & leaderboards |
| **M2 — Admin** | plugin enable/disable, user moderation, session audit | | **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 | | **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 | | **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 | | **M6 — Hardening & scale** | rate limits, fail2ban, metrics, Postgres migration path, web buyer dashboard |
@ -345,8 +377,10 @@ from the start.
websocket/API endpoint? (Affects how non-interactive agents authenticate.) websocket/API endpoint? (Affects how non-interactive agents authenticate.)
2. **Sandbox technology:** Docker/Podman per session vs. `systemd-run` transient 2. **Sandbox technology:** Docker/Podman per session vs. `systemd-run` transient
scopes — which fits the target VPS footprint best? scopes — which fits the target VPS footprint best?
3. **Account model for the Files plugin:** real chrooted system users via 3. ~~**Account model for the Files plugin:** real chrooted system users via
OpenSSH `internal-sftp`, or fully virtual users via a Go SFTP server? 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 4. **AgentAd inventory mix:** which surfaces ship first (interstitials vs. hub
banners vs. sponsored ladder slots)? banners vs. sponsored ladder slots)?
5. **Web footprint:** does the AgentAd buyer dashboard warrant a web app in v1, 5. **Web footprint:** does the AgentAd buyer dashboard warrant a web app in v1,

95
docs/files.md Normal file
View file

@ -0,0 +1,95 @@
# Files (SFTP) — member storage
AgentBBS gives every verified member file storage over **SFTP**, reachable with
the same SSH key they log in with. It rides the existing `:22` listener as an
SSH *subsystem*, so there is no new port and no separate account:
```bash
# interactive
sftp files@bbs.profullstack.com
# one-shot copies (same endpoint, same key)
scp report.pdf files@bbs.profullstack.com:/me/
rsync -avz ./site/ -e ssh files@bbs.profullstack.com:/me/site/
```
The username (`files`) is conventional and ignored — **identity is your SSH
key** (one key = one account, like the rest of the BBS). `scp`/`rsync` work
because they tunnel over the same SSH transport.
## Two areas
When you connect you see a virtual root with two directories:
| Path | What it is | Access |
|---|---|---|
| `/me` | Your **private** per-user workspace | read/write, quota-limited |
| `/public` | The single **shared public file area** (old-school BBS file area) | world-read; members-only write by default |
There is **no** path from one member's `/me` to another's — the only sharing
surface is the one public area (PRD §9.3, amended). Both areas are confined: a
path that tries to escape its root (`../`, an absolute path, or a planted
symlink) is rejected.
## Quotas
Each private workspace has a byte quota (default **1 GiB**, set by
`AGENTBBS_FILES_QUOTA_MB`). Writes that would exceed it fail. Operators can set a
per-user override in the management TUI. The public area is operator-managed and
not metered per user.
## In-BBS browser
Inside the hub, the **Files** entry opens a TUI browser for your workspace and
the public area: navigate, view text files, make directories, rename, and
delete, with a live usage gauge. Actual transfers happen over SFTP/scp/rsync (a
PTY can't move file bytes).
## Operator management TUI
Operators (the `$AGENTBBS_ADMINS` allowlist) reach the SFTP management console
with:
```bash
ssh sftp@bbs.profullstack.com # aliases: sftpadmin@, filesadmin@
```
Panes (Tab to switch):
- **Sessions** — live SFTP connections (user, remote, rx/tx, idle); `x`
force-disconnects.
- **Workspaces** — every member's usage vs. quota; `Q` sets a per-user quota,
`x` revokes/restores SFTP access (the BBS login is unaffected).
- **Public area**`t` toggles members' write access; `x` removes an entry
(moderation).
Per PRD §9.2 the operator can inspect and act on hosted files; storage is not
content-blind.
## Configuration
| Var | Default | Meaning |
|---|---|---|
| `AGENTBBS_FILES` | `1` | enable the SFTP subsystem + Files plugin (`0` disables) |
| `AGENTBBS_FILES_QUOTA_MB` | `1024` | default per-user workspace quota (MB) |
| `AGENTBBS_DATA` | `./data` | storage lives under `<data>/files/{users,public}` |
## Implementation
- `internal/files` — a fully virtual Go SFTP server (`github.com/pkg/sftp` +
`crypto/ssh`); no OS users.
- `backend.go` — service, layout, quota/usage, live-session registry, operator
surface.
- `fs.go` — per-session virtual filesystem: `resolve()` is the single security
chokepoint (area confinement + symlink-escape guard) and the pkg/sftp
request handlers.
- `server.go` — the `wish.WithSubsystem("sftp", …)` handler: key auth → member
session → request server, with byte metering and force-disconnect.
- `tui.go` — the in-BBS member browser plugin.
- `admin.go` — the operator management TUI.
- Storage: `files_access` (per-user quota override + revoked flag) and
`files_settings` (e.g. public-write mode) in the shared SQLite store.
Security is covered by `internal/files/*_test.go`: path-traversal/confinement,
symlink-escape rejection, the public-write ACL, quota enforcement, and an
end-to-end run against a real SFTP client.

2
go.mod
View file

@ -16,6 +16,7 @@ require (
github.com/livekit/protocol v1.46.0 github.com/livekit/protocol v1.46.0
github.com/livekit/server-sdk-go/v2 v2.16.6 github.com/livekit/server-sdk-go/v2 v2.16.6
github.com/pion/webrtc/v4 v4.2.15 github.com/pion/webrtc/v4 v4.2.15
github.com/pkg/sftp v1.13.10
golang.org/x/crypto v0.50.0 golang.org/x/crypto v0.50.0
golang.org/x/term v0.42.0 golang.org/x/term v0.42.0
modernc.org/sqlite v1.52.0 modernc.org/sqlite v1.52.0
@ -62,6 +63,7 @@ require (
github.com/jxskiss/base62 v1.1.0 // indirect github.com/jxskiss/base62 v1.1.0 // indirect
github.com/klauspost/compress v1.18.4 // indirect github.com/klauspost/compress v1.18.4 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/lithammer/shortuuid/v4 v4.2.0 // indirect github.com/lithammer/shortuuid/v4 v4.2.0 // indirect
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 // indirect github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 // indirect
github.com/livekit/mediatransportutil v0.0.0-20260521165806-8004f10ad0c5 // indirect github.com/livekit/mediatransportutil v0.0.0-20260521165806-8004f10ad0c5 // indirect

4
go.sum
View file

@ -149,6 +149,8 @@ github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@ -243,6 +245,8 @@ github.com/pion/webrtc/v4 v4.2.15 h1:Ir/MauNFCfg+kgyBYPQLiGdVWFlzEcLxqtuzAkYkky0
github.com/pion/webrtc/v4 v4.2.15/go.mod h1:CPTcyLfIzC4scOkQ4UY4pj6WvbUGhcNLIpK28cP5h6M= github.com/pion/webrtc/v4 v4.2.15/go.mod h1:CPTcyLfIzC4scOkQ4UY4pj6WvbUGhcNLIpK28cP5h6M=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU=
github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=

View file

@ -69,6 +69,11 @@ var NewsNames = map[string]bool{"news": true}
// an interactive TUI with a PTY, or a JSON bot mode with a command/no PTY. // an interactive TUI with a PTY, or a JSON bot mode with a command/no PTY.
var MailNames = map[string]bool{"mail": true} var MailNames = map[string]bool{"mail": true}
// FilesAdminNames route an operator into the SFTP server management TUI. Members
// transfer files via the "sftp" subsystem (sftp files@<host>); this interactive
// route is the operator console and is gated by the admin allowlist.
var FilesAdminNames = map[string]bool{"sftp": true, "sftpadmin": true, "filesadmin": true}
// GameNames are usernames that route to AgentGames: the line-delimited-JSON // GameNames are usernames that route to AgentGames: the line-delimited-JSON
// agent-vs-agent match protocol (PRD §5.2). `play@` stays a guest hub alias. // agent-vs-agent match protocol (PRD §5.2). `play@` stays a guest hub alias.
var GameNames = map[string]bool{"game": true, "games": true} var GameNames = map[string]bool{"game": true, "games": true}
@ -103,6 +108,10 @@ func IsNewsName(u string) bool { return NewsNames[strings.ToLower(u)] }
// IsMailName reports whether the SSH username requests the AgentMail client. // IsMailName reports whether the SSH username requests the AgentMail client.
func IsMailName(u string) bool { return MailNames[strings.ToLower(u)] } func IsMailName(u string) bool { return MailNames[strings.ToLower(u)] }
// IsFilesAdminName reports whether the SSH username requests the SFTP server
// management TUI (operator-gated).
func IsFilesAdminName(u string) bool { return FilesAdminNames[strings.ToLower(u)] }
// systemReserved are names that don't drive an SSH route but would still // systemReserved are names that don't drive an SSH route but would still
// collide with a per-user subdomain (<name>.<host>), the agent route, or common // collide with a per-user subdomain (<name>.<host>), the agent route, or common
// infra hostnames — so members may not claim them as account names. // infra hostnames — so members may not claim them as account names.
@ -119,7 +128,8 @@ var systemReserved = map[string]bool{
func IsReservedName(name string) bool { func IsReservedName(name string) bool {
n := strings.ToLower(name) n := strings.ToLower(name)
if GuestNames[n] || PodNames[n] || JoinNames[n] || DomainNames[n] || AdminNames[n] || if GuestNames[n] || PodNames[n] || JoinNames[n] || DomainNames[n] || AdminNames[n] ||
TorURLNames[n] || TorIRCNames[n] || TorNames[n] || IRCNames[n] || NewsNames[n] || systemReserved[n] { TorURLNames[n] || TorIRCNames[n] || TorNames[n] || IRCNames[n] || NewsNames[n] ||
MailNames[n] || FilesAdminNames[n] || systemReserved[n] {
return true return true
} }
return strings.HasPrefix(n, "video-") // video-<code> call routes return strings.HasPrefix(n, "video-") // video-<code> call routes

View file

@ -85,3 +85,24 @@ func TestIsReservedName(t *testing.T) {
} }
} }
} }
func TestIsFilesAdminName(t *testing.T) {
for _, name := range []string{"sftp", "SFTP", "sftpadmin", "filesadmin"} {
if !IsFilesAdminName(name) {
t.Errorf("IsFilesAdminName(%q) = false, want true", name)
}
}
for _, name := range []string{"files", "bbs", "anthony", ""} {
if IsFilesAdminName(name) {
t.Errorf("IsFilesAdminName(%q) = true, want false", name)
}
}
}
func TestFilesAdminNamesReserved(t *testing.T) {
for _, name := range []string{"sftp", "sftpadmin", "filesadmin", "mail"} {
if !IsReservedName(name) {
t.Errorf("IsReservedName(%q) = false, want true (route name)", name)
}
}
}

339
internal/files/admin.go Normal file
View file

@ -0,0 +1,339 @@
package files
import (
"fmt"
"strconv"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/profullstack/agentbbs/internal/store"
)
// NewAdminModel returns the operator management TUI for the SFTP server. Gate
// the route that launches it on the operator allowlist (auth.IsAdmin).
func NewAdminModel(svc *Service) tea.Model {
m := admin{svc: svc}
m.reload()
return m
}
type adminTab int
const (
tabSessions adminTab = iota
tabWorkspaces
tabPublic
numTabs
)
var tabNames = []string{"Sessions", "Workspaces", "Public area"}
type admin struct {
svc *Service
tab adminTab
sel int
msg string
sessions []Conn
users []store.User
public []Entry
input bool
prompt string
buf string
onInput func(string)
}
func (m *admin) reload() {
m.sessions = m.svc.Sessions()
m.users, _ = m.svc.Users()
m.public, _ = m.svc.PublicList()
if m.sel < 0 {
m.sel = 0
}
}
func (m admin) Init() tea.Cmd { return nil }
func (m admin) rows() int {
switch m.tab {
case tabSessions:
return len(m.sessions)
case tabWorkspaces:
return len(m.users)
case tabPublic:
return len(m.public)
}
return 0
}
func (m admin) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
key, ok := msg.(tea.KeyMsg)
if !ok {
return m, nil
}
if m.input {
return m.updateInput(key)
}
switch key.String() {
case "q", "esc":
return m, tea.Quit
case "tab", "right", "l":
m.tab = (m.tab + 1) % numTabs
m.sel, m.msg = 0, ""
case "shift+tab", "left", "h":
m.tab = (m.tab + numTabs - 1) % numTabs
m.sel, m.msg = 0, ""
case "up", "k":
if m.sel > 0 {
m.sel--
}
case "down", "j":
if m.sel < m.rows()-1 {
m.sel++
}
case "g":
m.reload()
m.msg = "refreshed"
default:
return m.action(key)
}
return m, nil
}
func (m admin) action(key tea.KeyMsg) (tea.Model, tea.Cmd) {
switch m.tab {
case tabSessions:
if key.String() == "x" {
if c := m.curSession(); c != nil {
if m.svc.Kick(c.ID) {
m.msg = "disconnected " + c.User
}
m.reload()
}
}
case tabWorkspaces:
switch key.String() {
case "Q":
if u := m.curUser(); u != nil {
uid := u.ID
m.startInput("quota MB for "+u.Name+" (0 = default): ", func(s string) {
mb, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
if err != nil || mb < 0 {
m.msg = warnStyle.Render("invalid number")
return
}
if err := m.svc.SetQuota(uid, mb<<20); err != nil {
m.msg = warnStyle.Render(err.Error())
return
}
m.msg = fmt.Sprintf("quota set to %d MB", mb)
})
}
case "x":
if u := m.curUser(); u != nil {
fa, _ := m.svc.Access(u.ID)
if err := m.svc.SetRevoked(u.ID, !fa.Revoked); err != nil {
m.msg = warnStyle.Render(err.Error())
} else if fa.Revoked {
m.msg = "restored SFTP for " + u.Name
} else {
m.msg = "revoked SFTP for " + u.Name
}
}
}
case tabPublic:
switch key.String() {
case "t":
on := !m.svc.PublicWritable()
if err := m.svc.SetPublicWrite(on); err != nil {
m.msg = warnStyle.Render(err.Error())
} else if on {
m.msg = "public area is now writable (members)"
} else {
m.msg = "public area is now read-only"
}
case "x":
if e := m.curPublic(); e != nil {
name := e.Name
if err := m.svc.PublicRemove(name); err != nil {
m.msg = warnStyle.Render(err.Error())
} else {
m.msg = "removed " + name
}
m.reload()
}
}
}
return m, nil
}
func (m admin) updateInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
switch key.Type {
case tea.KeyEnter:
m.input = false
if m.onInput != nil {
m.onInput(m.buf)
}
m.buf = ""
m.reload()
case tea.KeyEsc:
m.input, m.buf = false, ""
case tea.KeyBackspace:
if m.buf != "" {
m.buf = m.buf[:len(m.buf)-1]
}
case tea.KeyRunes, tea.KeySpace:
m.buf += string(key.Runes)
}
return m, nil
}
func (m *admin) startInput(prompt string, cb func(string)) {
m.input, m.prompt, m.buf, m.onInput = true, prompt, "", cb
}
func (m admin) curSession() *Conn {
if m.tab != tabSessions || m.sel >= len(m.sessions) {
return nil
}
return &m.sessions[m.sel]
}
func (m admin) curUser() *store.User {
if m.tab != tabWorkspaces || m.sel >= len(m.users) {
return nil
}
return &m.users[m.sel]
}
func (m admin) curPublic() *Entry {
if m.tab != tabPublic || m.sel >= len(m.public) {
return nil
}
return &m.public[m.sel]
}
func (m admin) View() string {
var b strings.Builder
b.WriteString(titleStyle.Render("SFTP server — management") + "\n")
var tabs []string
for i, name := range tabNames {
if adminTab(i) == m.tab {
tabs = append(tabs, selStyle.Render(" "+name+" "))
} else {
tabs = append(tabs, dimStyle.Render(" "+name+" "))
}
}
b.WriteString(strings.Join(tabs, " ") + "\n\n")
switch m.tab {
case tabSessions:
b.WriteString(m.viewSessions())
case tabWorkspaces:
b.WriteString(m.viewWorkspaces())
case tabPublic:
b.WriteString(m.viewPublic())
}
b.WriteString("\n")
if m.input {
b.WriteString(titleStyle.Render(m.prompt) + m.buf + "_\n")
} else {
if m.msg != "" {
b.WriteString(m.msg + "\n")
}
b.WriteString(dimStyle.Render(m.help()))
}
return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
}
func (m admin) help() string {
common := "tab switch · ↑/↓ move · g refresh · q quit"
switch m.tab {
case tabSessions:
return "x disconnect · " + common
case tabWorkspaces:
return "Q set quota · x revoke/restore · " + common
case tabPublic:
return "t toggle write · x remove entry · " + common
}
return common
}
func (m admin) viewSessions() string {
if len(m.sessions) == 0 {
return dimStyle.Render(" no live SFTP connections")
}
var b strings.Builder
b.WriteString(dimStyle.Render(fmt.Sprintf(" %-16s %-22s %10s %10s %8s\n", "user", "remote", "rx", "tx", "idle")))
for i, c := range m.sessions {
line := fmt.Sprintf(" %-16s %-22s %10s %10s %8s",
c.User, c.Remote, humanBytes(c.RX), humanBytes(c.TX), since(c.Started))
b.WriteString(rowStyle(i == m.sel).Render(line) + "\n")
}
return b.String()
}
func (m admin) viewWorkspaces() string {
if len(m.users) == 0 {
return dimStyle.Render(" no members")
}
var b strings.Builder
b.WriteString(dimStyle.Render(fmt.Sprintf(" %-16s %10s %10s %6s %s\n", "user", "used", "quota", "use%", "access")))
for i, u := range m.users {
usage, _ := m.svc.Usage(u)
fa, _ := m.svc.Access(u.ID)
access := "ok"
if fa.Revoked {
access = warnStyle.Render("revoked")
}
line := fmt.Sprintf(" %-16s %10s %10s %5d%% %s",
u.Name, humanBytes(usage.Bytes), humanBytes(usage.Quota), pct(usage.Bytes, usage.Quota), access)
b.WriteString(rowStyle(i == m.sel).Render(line) + "\n")
}
return b.String()
}
func (m admin) viewPublic() string {
state := "writable (members)"
if !m.svc.PublicWritable() {
state = "read-only"
}
var b strings.Builder
b.WriteString(dimStyle.Render(" public-area write: ") + state + "\n\n")
if len(m.public) == 0 {
b.WriteString(dimStyle.Render(" (empty)"))
return b.String()
}
for i, e := range m.public {
name := e.Name
meta := humanBytes(e.Size)
if e.IsDir {
name += "/"
meta = "dir"
}
b.WriteString(rowStyle(i == m.sel).Render(fmt.Sprintf(" %-28s %8s", name, meta)) + "\n")
}
return b.String()
}
func rowStyle(selected bool) lipgloss.Style {
if selected {
return selStyle
}
return lipgloss.NewStyle()
}
func since(t time.Time) string {
d := time.Since(t).Round(time.Second)
if d < time.Minute {
return fmt.Sprintf("%ds", int(d.Seconds()))
}
if d < time.Hour {
return fmt.Sprintf("%dm", int(d.Minutes()))
}
return fmt.Sprintf("%dh", int(d.Hours()))
}

317
internal/files/backend.go Normal file
View file

@ -0,0 +1,317 @@
// Package files implements member file storage for AgentBBS, reachable over
// SFTP with the member's existing SSH login key (docs/files.md). It is wired as
// an "sftp" subsystem on the shared wish server (port 22), so a member runs
//
// sftp files@bbs.profullstack.com
//
// and lands in a virtual filesystem with two areas:
//
// /me — their private, per-user workspace (quota-limited)
// /public — a single shared file area (old-school BBS file area); world-read,
// members-only write by default, operator-moderated.
//
// Identity is the SSH public key (one key = one account, like the rest of the
// BBS); the SFTP username is conventional ("files") and ignored. The server is a
// fully virtual Go SFTP server (github.com/pkg/sftp) — there are no OS users.
//
// Security: every path the client supplies is resolved through resolve() in
// fs.go, which confines it to its area root (no traversal, no symlink escape);
// see the path-traversal tests. Per §9.2 the operator can inspect and act on
// hosted files (the management TUI), and per §9.3 (amended) the only sharing
// surface is the single public area — there is no workspace-to-workspace
// transfer.
package files
import (
"io/fs"
"os"
"path/filepath"
"sync"
"sync/atomic"
"time"
"github.com/profullstack/agentbbs/internal/store"
)
// Setting keys persisted in files_settings.
const (
// settingPublicWrite is "members" (default) or "off". When "off", the
// shared public area is read-only for everyone (operators moderate via the
// management TUI / filesystem).
settingPublicWrite = "public_write"
)
// DefaultQuota is the per-user workspace quota when none is configured.
const DefaultQuota int64 = 1 << 30 // 1 GiB
// FilesStore is the slice of the store the Files service needs.
type FilesStore interface {
UserByFingerprint(fp string) (store.User, bool, error)
UserByName(name string) (store.User, bool, error)
ListUsers(limit int) ([]store.User, error)
FilesAccess(userID int64) (store.FilesAccess, error)
SetFilesQuota(userID, bytes int64) error
SetFilesRevoked(userID int64, revoked bool) error
FilesSetting(key string) (string, bool, error)
SetFilesSetting(key, value string) error
}
// Config configures the Files service.
type Config struct {
// Root is the storage root, e.g. <dataDir>/files. The service owns
// <Root>/users/<name> (private) and <Root>/public (shared).
Root string
// DefaultQuota is the per-user workspace quota in bytes (0 → DefaultQuota).
DefaultQuota int64
}
// Service is the shared Files engine: one instance backs the SFTP subsystem,
// the in-BBS browser, and the operator management TUI.
type Service struct {
st FilesStore
cfg Config
reg *registry
}
// New builds a Files service and ensures the storage layout exists.
func New(st FilesStore, cfg Config) (*Service, error) {
if cfg.DefaultQuota <= 0 {
cfg.DefaultQuota = DefaultQuota
}
cfg.Root = filepath.Clean(cfg.Root)
for _, d := range []string{cfg.Root, filepath.Join(cfg.Root, "users"), filepath.Join(cfg.Root, "public")} {
if err := os.MkdirAll(d, 0o755); err != nil {
return nil, err
}
}
return &Service{st: st, cfg: cfg, reg: newRegistry()}, nil
}
// privRoot is the absolute private-workspace directory for a member.
func (s *Service) privRoot(user string) string {
return filepath.Join(s.cfg.Root, "users", user)
}
// pubRoot is the absolute shared public-area directory.
func (s *Service) pubRoot() string { return filepath.Join(s.cfg.Root, "public") }
// ensureWorkspace creates a member's private workspace if absent.
func (s *Service) ensureWorkspace(user string) error {
return os.MkdirAll(s.privRoot(user), 0o700)
}
// quotaFor returns the effective quota (bytes) for a user: their per-user
// override if set, else the server default.
func (s *Service) quotaFor(userID int64) int64 {
if fa, err := s.st.FilesAccess(userID); err == nil && fa.QuotaBytes > 0 {
return fa.QuotaBytes
}
return s.cfg.DefaultQuota
}
// publicWritable reports whether members may write to the shared area. The
// persisted files_settings value wins; default is true (members-only write).
func (s *Service) publicWritable() bool {
if v, ok, err := s.st.FilesSetting(settingPublicWrite); err == nil && ok {
return v != "off"
}
return true
}
// SetPublicWrite toggles members' write access to the shared public area.
func (s *Service) SetPublicWrite(on bool) error {
v := "off"
if on {
v = "members"
}
return s.st.SetFilesSetting(settingPublicWrite, v)
}
// dirSize returns the total bytes used under root (regular files only; symlinks
// are not followed). A missing root counts as 0.
func dirSize(root string) (int64, error) {
var total int64
err := filepath.WalkDir(root, func(_ string, d fs.DirEntry, err error) error {
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
if d.Type().IsRegular() {
info, err := d.Info()
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
total += info.Size()
}
return nil
})
if os.IsNotExist(err) {
return 0, nil
}
return total, err
}
// Usage is a member's workspace usage snapshot.
type Usage struct {
Bytes int64
Quota int64
}
// Free reports remaining bytes (never negative).
func (u Usage) Free() int64 {
if u.Quota <= u.Bytes {
return 0
}
return u.Quota - u.Bytes
}
// Usage computes a member's private-workspace usage against their quota.
func (s *Service) Usage(u store.User) (Usage, error) {
used, err := dirSize(s.privRoot(u.Name))
if err != nil {
return Usage{}, err
}
return Usage{Bytes: used, Quota: s.quotaFor(u.ID)}, nil
}
// --- live session registry (for the management TUI's Sessions pane) ----------
// Conn is a snapshot of a live SFTP connection, as shown by the management TUI.
type Conn struct {
ID int64
User string
Key string // SSH key fingerprint
Remote string
Started time.Time
RX int64 // bytes received from the client (uploads)
TX int64 // bytes sent to the client (downloads)
}
// liveConn is the registry's mutable view of an active connection. Its atomic
// counters are updated by countingRWC; snapshots copy out plain Conn values.
type liveConn struct {
id int64
user string
key string
remote string
started time.Time
rxBytes atomic.Int64
txBytes atomic.Int64
closer func() error
}
type registry struct {
mu sync.Mutex
next int64
live map[int64]*liveConn
}
func newRegistry() *registry { return &registry{live: map[int64]*liveConn{}} }
func (r *registry) add(user, key, remote string, closer func() error) *liveConn {
r.mu.Lock()
defer r.mu.Unlock()
r.next++
c := &liveConn{id: r.next, user: user, key: key, remote: remote, started: time.Now(), closer: closer}
r.live[c.id] = c
return c
}
func (r *registry) remove(id int64) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.live, id)
}
func (r *registry) snapshot() []Conn {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]Conn, 0, len(r.live))
for _, c := range r.live {
out = append(out, Conn{
ID: c.id, User: c.user, Key: c.key, Remote: c.remote, Started: c.started,
RX: c.rxBytes.Load(), TX: c.txBytes.Load(),
})
}
return out
}
// Sessions returns a snapshot of live SFTP connections, newest first.
func (s *Service) Sessions() []Conn {
cs := s.reg.snapshot()
for i, j := 0, len(cs)-1; i < j; i, j = i+1, j-1 {
cs[i], cs[j] = cs[j], cs[i]
}
return cs
}
// --- operator / management surface ------------------------------------------
// Users lists accounts for the management TUI (newest first).
func (s *Service) Users() ([]store.User, error) { return s.st.ListUsers(10000) }
// Access returns a user's SFTP access record (quota override + revoked flag).
func (s *Service) Access(userID int64) (store.FilesAccess, error) {
return s.st.FilesAccess(userID)
}
// SetQuota sets a per-user quota override (bytes; 0 = server default).
func (s *Service) SetQuota(userID, bytes int64) error { return s.st.SetFilesQuota(userID, bytes) }
// SetRevoked revokes/restores a user's SFTP access (BBS login is unaffected).
func (s *Service) SetRevoked(userID int64, revoked bool) error {
return s.st.SetFilesRevoked(userID, revoked)
}
// PublicWritable reports whether members may currently write to the public area.
func (s *Service) PublicWritable() bool { return s.publicWritable() }
// PublicList lists the top level of the shared public area for moderation.
func (s *Service) PublicList() ([]Entry, error) {
des, err := os.ReadDir(s.pubRoot())
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()})
}
return out, nil
}
// PublicRemove deletes a top-level entry from the public area (moderation). name
// is treated as a single segment; traversal is rejected.
func (s *Service) PublicRemove(name string) error {
base := filepath.Base(filepath.Clean("/" + name))
if base == "." || base == "/" || base == ".." {
return os.ErrInvalid
}
target := filepath.Join(s.pubRoot(), base)
if !within(s.pubRoot(), target) {
return os.ErrPermission
}
return os.RemoveAll(target)
}
// Kick force-disconnects a live SFTP connection by id. Returns false if unknown.
func (s *Service) Kick(id int64) bool {
s.reg.mu.Lock()
c, ok := s.reg.live[id]
s.reg.mu.Unlock()
if !ok {
return false
}
if c.closer != nil {
_ = c.closer()
}
return true
}

View 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")
}
}

View 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 := &quotaWriter{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)
}
}

470
internal/files/fs.go Normal file
View 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 &quotaWriter{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 }

94
internal/files/server.go Normal file
View file

@ -0,0 +1,94 @@
package files
import (
"io"
"github.com/charmbracelet/ssh"
"github.com/pkg/sftp"
"github.com/profullstack/agentbbs/internal/auth"
)
// Subsystem returns the wish/ssh "sftp" subsystem handler. Wire it with
//
// wish.WithSubsystem("sftp", svc.Subsystem())
//
// so members reach their files over the existing :22 listener. Identity is the
// connecting SSH key (the username is ignored); access is refused for non-members,
// banned accounts, and members whose SFTP access an operator has revoked.
func (s *Service) Subsystem() ssh.SubsystemHandler {
return func(sess ssh.Session) {
fp := auth.Fingerprint(sess.PublicKey())
if fp == "" {
io.WriteString(sess.Stderr(), "files: an SSH key is required (try: sftp -i ~/.ssh/id_ed25519 ...)\n")
_ = sess.Exit(1)
return
}
u, ok, err := s.st.UserByFingerprint(fp)
if err != nil {
io.WriteString(sess.Stderr(), "files: account lookup failed\n")
_ = sess.Exit(1)
return
}
if !ok {
io.WriteString(sess.Stderr(), "files: this key isn't a member — register first: ssh join@\n")
_ = sess.Exit(1)
return
}
if u.Banned {
io.WriteString(sess.Stderr(), "files: this account is suspended\n")
_ = sess.Exit(1)
return
}
if fa, err := s.st.FilesAccess(u.ID); err == nil && fa.Revoked {
io.WriteString(sess.Stderr(), "files: SFTP access has been revoked for this account\n")
_ = sess.Exit(1)
return
}
fsSess, err := s.newSession(u)
if err != nil {
io.WriteString(sess.Stderr(), "files: could not open your workspace\n")
_ = sess.Exit(1)
return
}
rw := &countingRWC{inner: sess}
conn := s.reg.add(u.Name, fp, sess.RemoteAddr().String(), rw.Close)
rw.conn = conn
defer s.reg.remove(conn.id)
handlers := sftp.Handlers{FileGet: fsSess, FilePut: fsSess, FileCmd: fsSess, FileList: fsSess}
srv := sftp.NewRequestServer(rw, handlers)
defer srv.Close()
if err := srv.Serve(); err != nil && err != io.EOF {
io.WriteString(sess.Stderr(), "files: session ended\n")
}
}
}
// countingRWC wraps the SSH channel to meter bytes for the management TUI and to
// expose Close for force-disconnect. Read = bytes from the client (uploads);
// Write = bytes to the client (downloads).
type countingRWC struct {
inner io.ReadWriteCloser
conn *liveConn
}
func (c *countingRWC) Read(p []byte) (int, error) {
n, err := c.inner.Read(p)
if c.conn != nil {
c.conn.rxBytes.Add(int64(n))
}
return n, err
}
func (c *countingRWC) Write(p []byte) (int, error) {
n, err := c.inner.Write(p)
if c.conn != nil {
c.conn.txBytes.Add(int64(n))
}
return n, err
}
func (c *countingRWC) Close() error { return c.inner.Close() }

358
internal/files/tui.go Normal file
View file

@ -0,0 +1,358 @@
package files
import (
"fmt"
"path"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/profullstack/agentbbs/internal/auth"
"github.com/profullstack/agentbbs/internal/plugin"
"github.com/profullstack/agentbbs/internal/store"
)
// viewMax caps how much of a file the in-BBS viewer loads.
const viewMax = 64 << 10
var (
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80"))
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
dirStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#60a5fa")).Bold(true)
selStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("0")).Background(lipgloss.Color("#4ade80"))
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#f87171"))
)
// NewPlugin returns the in-BBS file browser plugin bound to svc.
func NewPlugin(svc *Service) plugin.Plugin { return browserPlugin{svc: svc} }
type browserPlugin struct{ svc *Service }
func (browserPlugin) ID() string { return "files" }
func (browserPlugin) Title() string { return "Files" }
func (browserPlugin) Description() string { return "Your SFTP workspace + the shared public area" }
func (browserPlugin) RequiresAuth() bool { return true }
func (p browserPlugin) New(user auth.User, _ plugin.Context) tea.Model {
sess, su, err := p.svc.OpenFor(user.Name)
m := browser{svc: p.svc, sess: sess, user: su, cwd: "/", host: "bbs.profullstack.com"}
if err != nil {
m.fatal = "could not open your workspace: " + err.Error()
return m
}
m.reload()
return m
}
type mode int
const (
modeBrowse mode = iota
modeView
modeInput
modeConfirm
)
type browser struct {
svc *Service
sess *session
user store.User
host string
cwd string
items []Entry
sel int
msg string
fatal string
mode mode
prompt string // input prompt label
input string // typed text
onInput func(string) // committed-input callback
confirm string // confirmation question
onYes func() // confirmed-action callback
viewName string
viewBody string
}
func (m *browser) reload() {
items, err := m.sess.entries(m.cwd)
if err != nil {
m.msg = warnStyle.Render("error: " + err.Error())
m.items = nil
return
}
m.items = items
if m.sel >= len(items) {
m.sel = len(items) - 1
}
if m.sel < 0 {
m.sel = 0
}
}
func (m browser) Init() tea.Cmd { return nil }
func (m browser) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
key, ok := msg.(tea.KeyMsg)
if !ok {
return m, nil
}
if m.fatal != "" {
return m, plugin.Exit
}
switch m.mode {
case modeView:
m.mode = modeBrowse
return m, nil
case modeInput:
return m.updateInput(key)
case modeConfirm:
return m.updateConfirm(key)
default:
return m.updateBrowse(key)
}
}
func (m browser) updateBrowse(key tea.KeyMsg) (tea.Model, tea.Cmd) {
switch key.String() {
case "q", "esc":
return m, plugin.Exit
case "up", "k":
if m.sel > 0 {
m.sel--
}
case "down", "j":
if m.sel < len(m.items)-1 {
m.sel++
}
case "left", "h", "backspace":
if m.cwd != "/" {
m.cwd = path.Dir(strings.TrimRight(m.cwd, "/"))
m.sel = 0
m.msg = ""
m.reload()
}
case "enter", "right", "l":
return m.open()
case "n":
if m.sess.canWrite(m.childPath("x")) {
m.startInput("new directory name: ", func(name string) {
if name = clean(name); name == "" {
return
}
if err := m.sess.mkdir(m.childPath(name)); err != nil {
m.msg = warnStyle.Render("mkdir: " + err.Error())
} else {
m.msg = "created " + name + "/"
}
m.reload()
})
} else {
m.msg = warnStyle.Render("this area is read-only")
}
case "r":
if cur := m.current(); cur != nil && m.sess.canWrite(m.childPath(cur.Name)) {
old := cur.Name
m.startInput("rename "+old+" to: ", func(name string) {
if name = clean(name); name == "" {
return
}
if err := m.sess.rename(m.childPath(old), m.childPath(name)); err != nil {
m.msg = warnStyle.Render("rename: " + err.Error())
} else {
m.msg = "renamed to " + name
}
m.reload()
})
} else {
m.msg = warnStyle.Render("nothing to rename here")
}
case "d":
if cur := m.current(); cur != nil && m.sess.canWrite(m.childPath(cur.Name)) {
name := cur.Name
m.startConfirm("delete "+name+"? (y/n)", func() {
if err := m.sess.remove(m.childPath(name)); err != nil {
m.msg = warnStyle.Render("delete: " + err.Error())
} else {
m.msg = "deleted " + name
}
m.reload()
})
} else {
m.msg = warnStyle.Render("nothing to delete here")
}
}
return m, nil
}
func (m browser) open() (tea.Model, tea.Cmd) {
cur := m.current()
if cur == nil {
return m, nil
}
target := m.childPath(cur.Name)
if cur.IsDir {
m.cwd = target
m.sel = 0
m.msg = ""
m.reload()
return m, nil
}
body, truncated, err := m.sess.readFile(target, viewMax)
if err != nil {
m.msg = warnStyle.Render("open: " + err.Error())
return m, nil
}
m.viewName = cur.Name
m.viewBody = string(body)
if truncated {
m.viewBody += "\n\n" + dimStyle.Render("… (truncated; download the full file over SFTP)")
}
m.mode = modeView
return m, nil
}
func (m browser) updateInput(key tea.KeyMsg) (tea.Model, tea.Cmd) {
switch key.Type {
case tea.KeyEnter:
m.mode = modeBrowse
if m.onInput != nil {
m.onInput(m.input)
}
m.input = ""
case tea.KeyEsc:
m.mode = modeBrowse
m.input = ""
case tea.KeyBackspace:
if m.input != "" {
m.input = m.input[:len(m.input)-1]
}
case tea.KeyRunes, tea.KeySpace:
m.input += string(key.Runes)
}
return m, nil
}
func (m browser) updateConfirm(key tea.KeyMsg) (tea.Model, tea.Cmd) {
switch key.String() {
case "y", "Y":
m.mode = modeBrowse
if m.onYes != nil {
m.onYes()
}
case "n", "N", "esc":
m.mode = modeBrowse
m.msg = "cancelled"
}
return m, nil
}
func (m *browser) startInput(prompt string, cb func(string)) {
m.mode = modeInput
m.prompt = prompt
m.input = ""
m.onInput = cb
}
func (m *browser) startConfirm(q string, cb func()) {
m.mode = modeConfirm
m.confirm = q
m.onYes = cb
}
func (m browser) current() *Entry {
if m.sel < 0 || m.sel >= len(m.items) {
return nil
}
return &m.items[m.sel]
}
func (m browser) childPath(name string) string {
return path.Join(m.cwd, name)
}
func (m browser) View() string {
if m.fatal != "" {
return lipgloss.NewStyle().Padding(1, 2).Render(warnStyle.Render(m.fatal) + "\n\npress any key to return")
}
if m.mode == modeView {
return lipgloss.NewStyle().Padding(1, 2).Render(
titleStyle.Render("Files · "+m.viewName) + "\n\n" + m.viewBody +
"\n\n" + dimStyle.Render("press any key to go back"))
}
var b strings.Builder
usage, _ := m.svc.Usage(m.user)
b.WriteString(titleStyle.Render("Files") + " " + dimStyle.Render(m.cwd) + "\n")
b.WriteString(dimStyle.Render(fmt.Sprintf("workspace: %s / %s (%d%% used) · transfer: sftp files@%s\n\n",
humanBytes(usage.Bytes), humanBytes(usage.Quota), pct(usage.Bytes, usage.Quota), m.host)))
if len(m.items) == 0 {
b.WriteString(dimStyle.Render(" (empty)\n"))
}
for i, e := range m.items {
name := e.Name
meta := humanBytes(e.Size)
if e.IsDir {
name += "/"
meta = "dir"
}
line := fmt.Sprintf(" %-28s %8s", name, meta)
switch {
case i == m.sel:
b.WriteString(selStyle.Render(line))
case e.IsDir:
b.WriteString(dirStyle.Render(line))
default:
b.WriteString(line)
}
b.WriteString("\n")
}
b.WriteString("\n")
switch m.mode {
case modeInput:
b.WriteString(titleStyle.Render(m.prompt) + m.input + "_\n")
case modeConfirm:
b.WriteString(warnStyle.Render(m.confirm) + "\n")
default:
if m.msg != "" {
b.WriteString(m.msg + "\n")
}
b.WriteString(dimStyle.Render("↑/↓ move · enter open · n new dir · r rename · d delete · ←/bksp up · q quit"))
}
return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
}
// clean trims a user-typed file/dir name to a single safe path segment.
func clean(s string) string {
s = strings.TrimSpace(s)
s = strings.ReplaceAll(s, "/", "")
if s == "." || s == ".." {
return ""
}
return s
}
func pct(used, quota int64) int {
if quota <= 0 {
return 0
}
return int(used * 100 / quota)
}
// humanBytes formats a byte count compactly (e.g. 1.5G).
func humanBytes(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%dB", n)
}
div, exp := int64(unit), 0
for x := n / unit; x >= unit; x /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f%c", float64(n)/float64(div), "KMGTPE"[exp])
}

View file

@ -182,6 +182,23 @@ type Store interface {
// per-group number, and returns the stored row (with its number). // per-group number, and returns the stored row (with its number).
InsertNewsArticle(a NewsArticle) (NewsArticle, error) InsertNewsArticle(a NewsArticle) (NewsArticle, error)
// Files (SFTP) — per-user workspaces + the shared public area (docs/files.md).
// FilesAccess returns a user's SFTP access record (per-user quota override
// and revoked flag). A user with no row reports the zero value
// (QuotaBytes 0 = use the server default, Revoked false).
FilesAccess(userID int64) (FilesAccess, error)
// SetFilesQuota sets a per-user quota override in bytes (0 clears the
// override, falling back to the server default). Idempotent upsert.
SetFilesQuota(userID, bytes int64) error
// SetFilesRevoked revokes (or restores) a user's SFTP access without
// touching their BBS login. Idempotent upsert.
SetFilesRevoked(userID int64, revoked bool) error
// FilesSetting reads a Files service setting (e.g. the public-write mode).
FilesSetting(key string) (string, bool, error)
// SetFilesSetting writes a Files service setting. Idempotent upsert.
SetFilesSetting(key, value string) error
Close() error Close() error
} }
@ -213,6 +230,14 @@ type NewsArticle struct {
CreatedAt time.Time CreatedAt time.Time
} }
// FilesAccess is a user's SFTP access record. QuotaBytes is a per-user override
// (0 means "use the server default"); Revoked blocks SFTP without affecting the
// BBS login.
type FilesAccess struct {
QuotaBytes int64
Revoked bool
}
// RatingRow is one ladder entry. // RatingRow is one ladder entry.
type RatingRow struct { type RatingRow struct {
User string User string
@ -471,6 +496,16 @@ CREATE TABLE IF NOT EXISTS news_articles (
); );
CREATE INDEX IF NOT EXISTS idx_news_articles_grp ON news_articles(grp, num); CREATE INDEX IF NOT EXISTS idx_news_articles_grp ON news_articles(grp, num);
CREATE INDEX IF NOT EXISTS idx_news_articles_msgid ON news_articles(msg_id); CREATE INDEX IF NOT EXISTS idx_news_articles_msgid ON news_articles(msg_id);
CREATE TABLE IF NOT EXISTS files_access (
user_id INTEGER PRIMARY KEY,
quota_bytes INTEGER NOT NULL DEFAULT 0,
revoked INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
);
CREATE TABLE IF NOT EXISTS files_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT ''
);
` `
func (s *sqliteStore) EnsureUser(name, kind, fp string) (User, error) { func (s *sqliteStore) EnsureUser(name, kind, fp string) (User, error) {
@ -929,4 +964,65 @@ func (s *sqliteStore) RecordQryptInvite(username, jti string, quota int) error {
return tx.Commit() return tx.Commit()
} }
// --- Files (SFTP) ------------------------------------------------------------
func (s *sqliteStore) FilesAccess(userID int64) (FilesAccess, error) {
var fa FilesAccess
var revoked int
err := s.db.QueryRow(
`SELECT quota_bytes, revoked FROM files_access WHERE user_id = ?`, userID,
).Scan(&fa.QuotaBytes, &revoked)
if errors.Is(err, sql.ErrNoRows) {
return FilesAccess{}, nil
}
if err != nil {
return FilesAccess{}, err
}
fa.Revoked = revoked != 0
return fa, nil
}
func (s *sqliteStore) SetFilesQuota(userID, bytes int64) error {
_, err := s.db.Exec(`
INSERT INTO files_access (user_id, quota_bytes, updated_at)
VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'))
ON CONFLICT(user_id) DO UPDATE SET
quota_bytes = excluded.quota_bytes,
updated_at = excluded.updated_at`, userID, bytes)
return err
}
func (s *sqliteStore) SetFilesRevoked(userID int64, revoked bool) error {
r := 0
if revoked {
r = 1
}
_, err := s.db.Exec(`
INSERT INTO files_access (user_id, revoked, updated_at)
VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ','now'))
ON CONFLICT(user_id) DO UPDATE SET
revoked = excluded.revoked,
updated_at = excluded.updated_at`, userID, r)
return err
}
func (s *sqliteStore) FilesSetting(key string) (string, bool, error) {
var v string
err := s.db.QueryRow(`SELECT value FROM files_settings WHERE key = ?`, key).Scan(&v)
if errors.Is(err, sql.ErrNoRows) {
return "", false, nil
}
if err != nil {
return "", false, err
}
return v, true, nil
}
func (s *sqliteStore) SetFilesSetting(key, value string) error {
_, err := s.db.Exec(`
INSERT INTO files_settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value)
return err
}
func (s *sqliteStore) Close() error { return s.db.Close() } func (s *sqliteStore) Close() error { return s.db.Close() }

View file

@ -306,6 +306,13 @@ AGENTBBS_NEWS_TLS_KEY=${DATA_DIR}/news-tls/privkey.pem
# AGENTBBS_NEWS_ADDR=127.0.0.1:1119 # AGENTBBS_NEWS_ADDR=127.0.0.1:1119
# AGENTBBS_NEWS_TLS_ADDR=:563 # AGENTBBS_NEWS_TLS_ADDR=:563
# AGENTBBS_NEWS_GROUPS=pfs.announce:Announcements,pfs.general:General,pfs.agents:Agents # AGENTBBS_NEWS_GROUPS=pfs.announce:Announcements,pfs.general:General,pfs.agents:Agents
# Files (SFTP): member storage on the same :22 listener (sftp files@${DOMAIN}).
# Private per-user workspace (/me, quota-limited) + a shared public area
# (/public). Storage lives under \${AGENTBBS_DATA}/files. Operators manage it via
# \`ssh sftp@${DOMAIN}\`. Set AGENTBBS_FILES=0 to disable. See docs/files.md.
# AGENTBBS_FILES=1
# AGENTBBS_FILES_QUOTA_MB=1024
ENV ENV
chmod 0640 "$ENV_DIR/agentbbs.env" chmod 0640 "$ENV_DIR/agentbbs.env"
fi fi