mirror of
https://github.com/profullstack/agentbbs.git
synced 2026-10-02 03:53:54 +00:00
AgentBBS: M0 core hub, M1 arcade, pods with CoinPay membership
A modern BBS over SSH for humans and AI agents (docs/PRD.md), plus the
pods addendum (docs/pods.md). Go + charmbracelet (wish/bubbletea).
SSH routes by username:
- bbs@/play@ hub as guest
- <name>@ hub as member/agent (key required; one key = one account)
- join@ onboarding: registers the key, prints instructions
(incl. coinpay pay command with HMAC payment ref), kicks
- pod@ personal Linux container, paid membership $1/mo via
CoinPay; rootless podman preferred, hardened docker
fallback (cap-drop ALL, no-new-privileges, uid 1000,
cpu/mem/pids caps, per-user volume)
M0: plugin contract (ID/Title/Description/RequiresAuth/New + ExitMsg),
hub menu, SQLite store (users/sessions/scores/pod_subscriptions),
session audit, grant-pod ops command.
M1 arcade: doom-ascii + Freedoom via scripts/fetch-assets.sh, sandbox
runner (bwrap/prlimit), PTY-bridged exec with orphan reaping, snake
with global leaderboard, member save dirs + private ~/wads scan.
Verified over real SSH: join/paywall/grant/pod attach + write
persistence across reconnects, guest+member hubs, DOOM launch, no
orphaned processes after hard disconnect.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
commit
f3b085a08f
21 changed files with 2405 additions and 0 deletions
63
internal/auth/auth.go
Normal file
63
internal/auth/auth.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
// Package auth resolves SSH connections into AgentBBS identities (PRD §4.4).
|
||||
package auth
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/ssh"
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// Kind classifies an identity.
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
Guest Kind = "guest"
|
||||
Member Kind = "member"
|
||||
Agent Kind = "agent"
|
||||
)
|
||||
|
||||
// User is the resolved identity for one session.
|
||||
type User struct {
|
||||
Name string
|
||||
Kind Kind
|
||||
PubKeyFP string // SHA256 fingerprint, empty for guests without a key
|
||||
StoreID int64 // 0 for guests
|
||||
}
|
||||
|
||||
// GuestNames are usernames that always map to an anonymous guest hub session.
|
||||
var GuestNames = map[string]bool{"bbs": true, "play": true, "guest": true}
|
||||
|
||||
// PodNames are usernames that route to a personal pod instead of the hub.
|
||||
// Pod access requires an active paid membership (PRD pods addendum).
|
||||
var PodNames = map[string]bool{"pod": true}
|
||||
|
||||
// JoinNames are usernames that trigger the onboarding flow: register the
|
||||
// visitor's public key, print instructions, and disconnect.
|
||||
var JoinNames = map[string]bool{"join": true, "signup": true, "register": true}
|
||||
|
||||
// IsGuestName reports whether the SSH username requests anonymous hub access.
|
||||
func IsGuestName(u string) bool { return GuestNames[strings.ToLower(u)] }
|
||||
|
||||
// IsPodName reports whether the SSH username requests the pod route.
|
||||
func IsPodName(u string) bool { return PodNames[strings.ToLower(u)] }
|
||||
|
||||
// IsJoinName reports whether the SSH username requests onboarding.
|
||||
func IsJoinName(u string) bool { return JoinNames[strings.ToLower(u)] }
|
||||
|
||||
// KindFor infers the identity kind from a (non-guest) username.
|
||||
// Usernames prefixed "agent-" are automated clients (PRD §3).
|
||||
func KindFor(username string) Kind {
|
||||
if strings.HasPrefix(strings.ToLower(username), "agent-") {
|
||||
return Agent
|
||||
}
|
||||
return Member
|
||||
}
|
||||
|
||||
// Fingerprint returns the SHA256 fingerprint for a session public key, or "".
|
||||
func Fingerprint(key ssh.PublicKey) string {
|
||||
if key == nil {
|
||||
return ""
|
||||
}
|
||||
return gossh.FingerprintSHA256(key)
|
||||
}
|
||||
116
internal/hub/hub.go
Normal file
116
internal/hub/hub.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
// Package hub is the Bubble Tea model every BBS session lands in (PRD §4.1):
|
||||
// it lists registered plugins and routes the session to the selection,
|
||||
// reclaiming it when the plugin emits ExitMsg.
|
||||
package hub
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"github.com/profullstack/agentbbs/internal/auth"
|
||||
"github.com/profullstack/agentbbs/internal/plugin"
|
||||
)
|
||||
|
||||
var (
|
||||
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#4ade80"))
|
||||
dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241"))
|
||||
cursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#4ade80"))
|
||||
lockStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("203"))
|
||||
frameStyle = lipgloss.NewStyle().Padding(1, 2)
|
||||
)
|
||||
|
||||
// Model is the hub menu.
|
||||
type Model struct {
|
||||
user auth.User
|
||||
ctx plugin.Context
|
||||
plugins []plugin.Plugin
|
||||
|
||||
cursor int
|
||||
active tea.Model
|
||||
width int
|
||||
height int
|
||||
note string
|
||||
}
|
||||
|
||||
// New builds a hub for one session.
|
||||
func New(user auth.User, ctx plugin.Context, plugins []plugin.Plugin) Model {
|
||||
return Model{user: user, ctx: ctx, plugins: plugins}
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd { return nil }
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// Window size is shared with whichever model is active.
|
||||
if ws, ok := msg.(tea.WindowSizeMsg); ok {
|
||||
m.width, m.height = ws.Width, ws.Height
|
||||
}
|
||||
|
||||
// A plugin owns the session until it emits ExitMsg (PRD §4.3).
|
||||
if m.active != nil {
|
||||
if _, ok := msg.(plugin.ExitMsg); ok {
|
||||
m.active = nil
|
||||
return m, nil
|
||||
}
|
||||
next, cmd := m.active.Update(msg)
|
||||
m.active = next
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
m.note = ""
|
||||
switch msg.String() {
|
||||
case "q", "ctrl+c", "esc":
|
||||
return m, tea.Quit
|
||||
case "up", "k":
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
}
|
||||
case "down", "j":
|
||||
if m.cursor < len(m.plugins)-1 {
|
||||
m.cursor++
|
||||
}
|
||||
case "enter":
|
||||
p := m.plugins[m.cursor]
|
||||
if p.RequiresAuth() && m.user.Kind == auth.Guest {
|
||||
m.note = "members only — ssh join@ to register"
|
||||
return m, nil
|
||||
}
|
||||
m.active = p.New(m.user, m.ctx)
|
||||
cmds := []tea.Cmd{m.active.Init()}
|
||||
if m.width > 0 {
|
||||
next, cmd := m.active.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
|
||||
m.active = next
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
return m, tea.Batch(cmds...)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) View() string {
|
||||
if m.active != nil {
|
||||
return m.active.View()
|
||||
}
|
||||
who := fmt.Sprintf("%s (%s)", m.user.Name, m.user.Kind)
|
||||
s := titleStyle.Render("AgentBBS") + dimStyle.Render(" · "+who) + "\n\n"
|
||||
for i, p := range m.plugins {
|
||||
cur := " "
|
||||
if i == m.cursor {
|
||||
cur = cursorStyle.Render("> ")
|
||||
}
|
||||
label := p.Title()
|
||||
if p.RequiresAuth() && m.user.Kind == auth.Guest {
|
||||
label += lockStyle.Render(" [members]")
|
||||
}
|
||||
s += fmt.Sprintf("%s%s\n %s\n", cur, label, dimStyle.Render(p.Description()))
|
||||
}
|
||||
s += "\n" + dimStyle.Render("↑/↓ move · enter select · q quit")
|
||||
if m.note != "" {
|
||||
s += "\n" + lockStyle.Render(m.note)
|
||||
}
|
||||
return frameStyle.Render(s)
|
||||
}
|
||||
79
internal/payments/payments.go
Normal file
79
internal/payments/payments.go
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
// Package payments gates paid features (the pod subscription, $1/mo) on
|
||||
// CoinPay — the default LogicSRC payment/DID/wallet plugin.
|
||||
//
|
||||
// v1 integration is CLI-shaped: join@ hands the user a `coinpay` command
|
||||
// carrying a unique payment reference, and verification shells out to the
|
||||
// coinpay CLI. The exact command templates are env-configurable so the
|
||||
// deployed CoinPay surface can evolve without a rebuild:
|
||||
//
|
||||
// AGENTBBS_COINPAY_PAY_TMPL e.g. "coinpay pay --to profullstack --amount 1 --currency USDC --memo %s"
|
||||
// AGENTBBS_COINPAY_VERIFY_CMD e.g. "coinpay verify --memo %s" (exit 0 == paid)
|
||||
//
|
||||
// Operators can also grant manually: `agentbbs grant-pod <user> --months N`.
|
||||
package payments
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PodPriceLabel is the human-readable price for the pod membership.
|
||||
const PodPriceLabel = "$1/mo"
|
||||
|
||||
// PodTerm is how much access one payment buys.
|
||||
const PodTerm = 31 * 24 * time.Hour
|
||||
|
||||
// Reference derives a stable, short payment reference for a user+plan from
|
||||
// the user's key fingerprint, so CoinPay memos can be reconciled to accounts.
|
||||
func Reference(plan, pubkeyFP string) string {
|
||||
mac := hmac.New(sha256.New, []byte("agentbbs."+plan))
|
||||
mac.Write([]byte(pubkeyFP))
|
||||
return "abbs-" + plan + "-" + hex.EncodeToString(mac.Sum(nil))[:12]
|
||||
}
|
||||
|
||||
// PayCommand renders the coinpay command a user should run, with the payment
|
||||
// reference substituted.
|
||||
func PayCommand(ref string) string {
|
||||
tmpl := os.Getenv("AGENTBBS_COINPAY_PAY_TMPL")
|
||||
if tmpl == "" {
|
||||
tmpl = "coinpay pay --to profullstack --amount 1 --currency USDC --memo %s"
|
||||
}
|
||||
if strings.Contains(tmpl, "%s") {
|
||||
return fmt.Sprintf(tmpl, ref)
|
||||
}
|
||||
return tmpl + " " + ref
|
||||
}
|
||||
|
||||
// Verify checks a payment reference against the coinpay CLI. It returns
|
||||
// (paid, checked): checked is false when no verifier is configured or the
|
||||
// coinpay binary is unavailable, so callers can fall back to store state.
|
||||
func Verify(ref string) (paid bool, checked bool) {
|
||||
tmpl := os.Getenv("AGENTBBS_COINPAY_VERIFY_CMD")
|
||||
if tmpl == "" {
|
||||
return false, false
|
||||
}
|
||||
var line string
|
||||
if strings.Contains(tmpl, "%s") {
|
||||
line = fmt.Sprintf(tmpl, ref)
|
||||
} else {
|
||||
line = tmpl + " " + ref
|
||||
}
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) == 0 {
|
||||
return false, false
|
||||
}
|
||||
if _, err := exec.LookPath(parts[0]); err != nil {
|
||||
return false, false
|
||||
}
|
||||
cmd := exec.Command(parts[0], parts[1:]...)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return false, true
|
||||
}
|
||||
return true, true
|
||||
}
|
||||
45
internal/plugin/plugin.go
Normal file
45
internal/plugin/plugin.go
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
// Package plugin defines the AgentBBS plugin contract (PRD §4.3).
|
||||
//
|
||||
// A plugin is one interface implementation plus one registration in the hub.
|
||||
// Plugins return control to the hub by emitting ExitMsg, never by quitting
|
||||
// the session.
|
||||
package plugin
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/profullstack/agentbbs/internal/auth"
|
||||
"github.com/profullstack/agentbbs/internal/sandbox"
|
||||
"github.com/profullstack/agentbbs/internal/store"
|
||||
)
|
||||
|
||||
// Context carries the shared services a plugin may use.
|
||||
type Context struct {
|
||||
Store store.Store
|
||||
Sandbox *sandbox.Runner
|
||||
// DataDir is the per-user persistent directory (members/agents only;
|
||||
// empty for guests).
|
||||
DataDir string
|
||||
// AssetsDir is the read-only platform assets tree (wads, binaries).
|
||||
AssetsDir string
|
||||
}
|
||||
|
||||
// Plugin is the only integration point between a feature and the hub.
|
||||
type Plugin interface {
|
||||
// ID is a stable unique identifier, e.g. "arcade".
|
||||
ID() string
|
||||
// Title is the hub menu label.
|
||||
Title() string
|
||||
// Description is a one-line summary shown in the menu.
|
||||
Description() string
|
||||
// RequiresAuth reports whether guests are admitted.
|
||||
RequiresAuth() bool
|
||||
// New returns a fresh Bubble Tea model for one session.
|
||||
New(user auth.User, ctx Context) tea.Model
|
||||
}
|
||||
|
||||
// ExitMsg is emitted by a plugin model to hand the session back to the hub.
|
||||
type ExitMsg struct{}
|
||||
|
||||
// Exit is a convenience command for plugins.
|
||||
func Exit() tea.Msg { return ExitMsg{} }
|
||||
170
internal/pods/pods.go
Normal file
170
internal/pods/pods.go
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
// Package pods gives paid members a personal Linux container over SSH
|
||||
// (`ssh pod@host`) — "run shit in a docker-like" without root on the host.
|
||||
//
|
||||
// Engine preference: rootless Podman (daemonless; container root maps to an
|
||||
// unprivileged host uid via user namespaces), falling back to Docker with a
|
||||
// hardened profile (cap-drop ALL, no-new-privileges, non-root user, cpu/mem/
|
||||
// pids caps). Either way the SSH user never touches the host OS.
|
||||
package pods
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/charmbracelet/ssh"
|
||||
"github.com/creack/pty"
|
||||
)
|
||||
|
||||
// Manager provisions and attaches per-user pods.
|
||||
type Manager struct {
|
||||
engine string // "podman" or "docker"
|
||||
image string
|
||||
|
||||
mu sync.Mutex
|
||||
attached map[string]int // container name -> live session count
|
||||
}
|
||||
|
||||
// Detect picks the best available engine. Returns an error if neither
|
||||
// podman nor docker is present.
|
||||
func Detect() (*Manager, error) {
|
||||
image := os.Getenv("AGENTBBS_POD_IMAGE")
|
||||
if image == "" {
|
||||
image = "debian:stable-slim"
|
||||
}
|
||||
for _, eng := range []string{"podman", "docker"} {
|
||||
if _, err := exec.LookPath(eng); err == nil {
|
||||
return &Manager{engine: eng, image: image, attached: map[string]int{}}, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("pods: neither podman nor docker found")
|
||||
}
|
||||
|
||||
// Engine reports the active container engine.
|
||||
func (m *Manager) Engine() string { return m.engine }
|
||||
|
||||
var unsafeName = regexp.MustCompile(`[^a-zA-Z0-9_.-]`)
|
||||
|
||||
func (m *Manager) containerName(user string) string {
|
||||
return "agentbbs-pod-" + unsafeName.ReplaceAllString(strings.ToLower(user), "-")
|
||||
}
|
||||
|
||||
// ensure creates (or starts) the user's container and returns its name.
|
||||
func (m *Manager) ensure(user string) (string, error) {
|
||||
name := m.containerName(user)
|
||||
if m.engine == "docker" {
|
||||
// Under docker the pod runs as uid 1000 (never container root), so
|
||||
// the named home volume must be owned by 1000. A trusted one-shot
|
||||
// init container enforces that on every ensure — volumes can predate
|
||||
// the container or survive recreation. Rootless podman doesn't need
|
||||
// this: container root maps to the unprivileged host user.
|
||||
init := exec.Command(m.engine, "run", "--rm",
|
||||
"-v", name+"-home:/home/dev", m.image,
|
||||
"sh", "-c", "chown 1000:1000 /home/dev")
|
||||
if out, err := init.CombinedOutput(); err != nil {
|
||||
return "", fmt.Errorf("pods: volume init failed: %v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
}
|
||||
// Already exists?
|
||||
if err := exec.Command(m.engine, "container", "inspect", name).Run(); err == nil {
|
||||
_ = exec.Command(m.engine, "start", name).Run() // no-op if running
|
||||
return name, nil
|
||||
}
|
||||
args := []string{
|
||||
"run", "-d",
|
||||
"--name", name,
|
||||
"--hostname", "pod-" + unsafeName.ReplaceAllString(user, "-"),
|
||||
"--memory", env("AGENTBBS_POD_MEM", "512m"),
|
||||
"--cpus", env("AGENTBBS_POD_CPUS", "1"),
|
||||
"--pids-limit", "256",
|
||||
"--cap-drop", "ALL",
|
||||
"--security-opt", "no-new-privileges",
|
||||
"--restart", "unless-stopped",
|
||||
"-v", name + "-home:/home/dev",
|
||||
"-w", "/home/dev",
|
||||
"-e", "HOME=/home/dev",
|
||||
}
|
||||
if m.engine == "docker" {
|
||||
// Rootless podman user-ns maps container root safely; under docker,
|
||||
// refuse to hand out container root at all.
|
||||
args = append(args, "--user", "1000:1000")
|
||||
}
|
||||
args = append(args, m.image, "sleep", "infinity")
|
||||
out, err := exec.Command(m.engine, args...).CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("pods: create failed: %v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// Attach provisions the pod and wires the SSH session to a shell inside it.
|
||||
// Blocks until the shell exits or the session closes.
|
||||
func (m *Manager) Attach(s ssh.Session, user string) error {
|
||||
ptyReq, winCh, hasPty := s.Pty()
|
||||
if !hasPty {
|
||||
return fmt.Errorf("pods: a PTY is required (ssh -t)")
|
||||
}
|
||||
name, err := m.ensure(user)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
shell := env("AGENTBBS_POD_SHELL", "/bin/bash")
|
||||
cmd := exec.Command(m.engine, "exec", "-it",
|
||||
"-e", "TERM="+ptyReq.Term,
|
||||
name, shell, "-l")
|
||||
f, err := pty.Start(cmd)
|
||||
if err != nil {
|
||||
// busybox-ish images may lack bash
|
||||
cmd = exec.Command(m.engine, "exec", "-it", "-e", "TERM="+ptyReq.Term, name, "/bin/sh", "-l")
|
||||
f, err = pty.Start(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pods: attach failed: %w", err)
|
||||
}
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
m.ref(name, +1)
|
||||
defer m.deref(name)
|
||||
|
||||
_ = pty.Setsize(f, &pty.Winsize{Rows: uint16(ptyReq.Window.Height), Cols: uint16(ptyReq.Window.Width)})
|
||||
go func() {
|
||||
for w := range winCh {
|
||||
_ = pty.Setsize(f, &pty.Winsize{Rows: uint16(w.Height), Cols: uint16(w.Width)})
|
||||
}
|
||||
}()
|
||||
|
||||
go func() { _, _ = io.Copy(f, s) }() // ssh -> pod
|
||||
_, _ = io.Copy(s, f) // pod -> ssh
|
||||
_ = cmd.Wait()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) ref(name string, d int) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.attached[name] += d
|
||||
}
|
||||
|
||||
// deref stops the container shortly after the last session detaches, unless
|
||||
// AGENTBBS_POD_KEEP=1 keeps pods running between visits.
|
||||
func (m *Manager) deref(name string) {
|
||||
m.mu.Lock()
|
||||
m.attached[name]--
|
||||
last := m.attached[name] <= 0
|
||||
m.mu.Unlock()
|
||||
if last && os.Getenv("AGENTBBS_POD_KEEP") != "1" {
|
||||
go func() { _ = exec.Command(m.engine, "stop", "-t", "2", name).Run() }()
|
||||
}
|
||||
}
|
||||
|
||||
func env(k, def string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
105
internal/sandbox/sandbox.go
Normal file
105
internal/sandbox/sandbox.go
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
// Package sandbox wraps game/agent subprocesses with per-session isolation
|
||||
// and resource limits (PRD §7 S2). It prefers bubblewrap, falls back to
|
||||
// prlimit, and degrades to a plain exec with a warning.
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// Mode selects the isolation technology.
|
||||
type Mode string
|
||||
|
||||
const (
|
||||
ModeAuto Mode = "auto"
|
||||
ModeBwrap Mode = "bwrap"
|
||||
ModePrlimit Mode = "prlimit"
|
||||
ModeNone Mode = "none"
|
||||
)
|
||||
|
||||
// Limits are per-process resource caps.
|
||||
type Limits struct {
|
||||
CPUSeconds int // hard CPU-time cap (fork-bomb/runaway protection)
|
||||
MemoryMB int
|
||||
MaxProcs int
|
||||
}
|
||||
|
||||
// DefaultLimits suit a single interactive game session.
|
||||
var DefaultLimits = Limits{CPUSeconds: 3600, MemoryMB: 512, MaxProcs: 64}
|
||||
|
||||
// Runner builds sandboxed exec.Cmds.
|
||||
type Runner struct {
|
||||
mode Mode
|
||||
}
|
||||
|
||||
// New picks the best available mode when ModeAuto is requested.
|
||||
func New(mode Mode) *Runner {
|
||||
if mode == "" || mode == ModeAuto {
|
||||
switch {
|
||||
case have("bwrap"):
|
||||
mode = ModeBwrap
|
||||
case have("prlimit"):
|
||||
mode = ModePrlimit
|
||||
default:
|
||||
mode = ModeNone
|
||||
}
|
||||
}
|
||||
return &Runner{mode: mode}
|
||||
}
|
||||
|
||||
// Mode reports the active isolation mode.
|
||||
func (r *Runner) Mode() Mode { return r.mode }
|
||||
|
||||
func have(bin string) bool { _, err := exec.LookPath(bin); return err == nil }
|
||||
|
||||
// Command wraps program+args in the runner's sandbox. workDir is the only
|
||||
// writable path (savegames land there); everything else is read-only.
|
||||
func (r *Runner) Command(workDir, program string, args ...string) *exec.Cmd {
|
||||
lim := DefaultLimits
|
||||
switch r.mode {
|
||||
case ModeBwrap:
|
||||
bw := []string{
|
||||
"--ro-bind", "/", "/",
|
||||
"--dev", "/dev",
|
||||
"--proc", "/proc",
|
||||
"--tmpfs", "/tmp",
|
||||
"--bind", workDir, workDir,
|
||||
"--unshare-net",
|
||||
"--unshare-pid",
|
||||
"--die-with-parent",
|
||||
"--chdir", workDir,
|
||||
}
|
||||
// Resource caps still come from prlimit when available.
|
||||
if have("prlimit") {
|
||||
pl := prlimitArgs(lim)
|
||||
full := append(pl, "bwrap")
|
||||
full = append(full, bw...)
|
||||
full = append(full, "--", program)
|
||||
full = append(full, args...)
|
||||
return exec.Command("prlimit", full...)
|
||||
}
|
||||
full := append(bw, "--", program)
|
||||
full = append(full, args...)
|
||||
return exec.Command("bwrap", full...)
|
||||
case ModePrlimit:
|
||||
full := append(prlimitArgs(lim), program)
|
||||
full = append(full, args...)
|
||||
cmd := exec.Command("prlimit", full...)
|
||||
cmd.Dir = workDir
|
||||
return cmd
|
||||
default:
|
||||
cmd := exec.Command(program, args...)
|
||||
cmd.Dir = workDir
|
||||
return cmd
|
||||
}
|
||||
}
|
||||
|
||||
func prlimitArgs(l Limits) []string {
|
||||
return []string{
|
||||
fmt.Sprintf("--cpu=%d", l.CPUSeconds),
|
||||
fmt.Sprintf("--as=%d", l.MemoryMB*1024*1024),
|
||||
fmt.Sprintf("--nproc=%d", l.MaxProcs),
|
||||
"--",
|
||||
}
|
||||
}
|
||||
214
internal/store/store.go
Normal file
214
internal/store/store.go
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
// Package store is the persistence layer (PRD §4.2): SQLite behind a Store
|
||||
// interface so a move to Postgres is a driver swap, not a rewrite.
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// User is a persisted account (member or agent; guests are never stored).
|
||||
type User struct {
|
||||
ID int64
|
||||
Name string
|
||||
Kind string
|
||||
PubKeyFP string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Score is one leaderboard entry.
|
||||
type Score struct {
|
||||
User string
|
||||
Game string
|
||||
Score int64
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// Store is the persistence contract shared by all plugins.
|
||||
type Store interface {
|
||||
// EnsureUser returns the user with this name, creating it with the given
|
||||
// kind and key fingerprint on first sight. If the name exists with a
|
||||
// different fingerprint, ErrKeyMismatch is returned.
|
||||
EnsureUser(name, kind, pubkeyFP string) (User, error)
|
||||
// UserByFingerprint finds an account by SSH key fingerprint.
|
||||
UserByFingerprint(fp string) (User, bool, error)
|
||||
|
||||
RecordSession(userID int64, username, remote, route string) (int64, error)
|
||||
EndSession(sessionID int64) error
|
||||
|
||||
AddScore(userID int64, game string, score int64) error
|
||||
TopScores(game string, n int) ([]Score, error)
|
||||
|
||||
// Pod subscription (paid membership, e.g. $1/mo via CoinPay).
|
||||
PodPaidUntil(userID int64) (time.Time, bool, error)
|
||||
GrantPod(userID int64, until time.Time, paymentRef string) error
|
||||
|
||||
Close() error
|
||||
}
|
||||
|
||||
// ErrKeyMismatch means a username is already registered with another key.
|
||||
var ErrKeyMismatch = errors.New("username registered with a different key")
|
||||
|
||||
type sqliteStore struct{ db *sql.DB }
|
||||
|
||||
// Open opens (and migrates) the SQLite store at path.
|
||||
func Open(path string) (Store, error) {
|
||||
db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &sqliteStore{db: db}, nil
|
||||
}
|
||||
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
kind TEXT NOT NULL,
|
||||
pubkey_fp TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER,
|
||||
username TEXT NOT NULL,
|
||||
remote_addr TEXT NOT NULL,
|
||||
route TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
ended_at TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS scores (
|
||||
id INTEGER PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
game TEXT NOT NULL,
|
||||
score INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_scores_game ON scores(game, score DESC);
|
||||
CREATE TABLE IF NOT EXISTS pod_subscriptions (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id),
|
||||
paid_until TEXT NOT NULL,
|
||||
payment_ref TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
`
|
||||
|
||||
func (s *sqliteStore) EnsureUser(name, kind, fp string) (User, error) {
|
||||
var u User
|
||||
var created string
|
||||
err := s.db.QueryRow(`SELECT id, name, kind, pubkey_fp, created_at FROM users WHERE name = ?`, name).
|
||||
Scan(&u.ID, &u.Name, &u.Kind, &u.PubKeyFP, &created)
|
||||
switch {
|
||||
case err == sql.ErrNoRows:
|
||||
res, err := s.db.Exec(`INSERT INTO users (name, kind, pubkey_fp) VALUES (?,?,?)`, name, kind, fp)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
return User{ID: id, Name: name, Kind: kind, PubKeyFP: fp, CreatedAt: time.Now().UTC()}, nil
|
||||
case err != nil:
|
||||
return User{}, err
|
||||
}
|
||||
if u.PubKeyFP != "" && fp != "" && u.PubKeyFP != fp {
|
||||
return User{}, ErrKeyMismatch
|
||||
}
|
||||
u.CreatedAt, _ = time.Parse(time.RFC3339, created)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *sqliteStore) UserByFingerprint(fp string) (User, bool, error) {
|
||||
if fp == "" {
|
||||
return User{}, false, nil
|
||||
}
|
||||
var u User
|
||||
var created string
|
||||
err := s.db.QueryRow(`SELECT id, name, kind, pubkey_fp, created_at FROM users WHERE pubkey_fp = ?`, fp).
|
||||
Scan(&u.ID, &u.Name, &u.Kind, &u.PubKeyFP, &created)
|
||||
if err == sql.ErrNoRows {
|
||||
return User{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return User{}, false, err
|
||||
}
|
||||
u.CreatedAt, _ = time.Parse(time.RFC3339, created)
|
||||
return u, true, nil
|
||||
}
|
||||
|
||||
func (s *sqliteStore) RecordSession(userID int64, username, remote, route string) (int64, error) {
|
||||
var uid any
|
||||
if userID > 0 {
|
||||
uid = userID
|
||||
}
|
||||
res, err := s.db.Exec(`INSERT INTO sessions (user_id, username, remote_addr, route) VALUES (?,?,?,?)`,
|
||||
uid, username, remote, route)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
func (s *sqliteStore) EndSession(id int64) error {
|
||||
_, err := s.db.Exec(`UPDATE sessions SET ended_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) AddScore(userID int64, game string, score int64) error {
|
||||
_, err := s.db.Exec(`INSERT INTO scores (user_id, game, score) VALUES (?,?,?)`, userID, game, score)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) TopScores(game string, n int) ([]Score, error) {
|
||||
rows, err := s.db.Query(`
|
||||
SELECT u.name, s.game, s.score, s.created_at
|
||||
FROM scores s JOIN users u ON u.id = s.user_id
|
||||
WHERE s.game = ? ORDER BY s.score DESC LIMIT ?`, game, n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Score
|
||||
for rows.Next() {
|
||||
var sc Score
|
||||
var at string
|
||||
if err := rows.Scan(&sc.User, &sc.Game, &sc.Score, &at); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sc.At, _ = time.Parse(time.RFC3339, at)
|
||||
out = append(out, sc)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *sqliteStore) PodPaidUntil(userID int64) (time.Time, bool, error) {
|
||||
var until string
|
||||
err := s.db.QueryRow(`SELECT paid_until FROM pod_subscriptions WHERE user_id = ?`, userID).Scan(&until)
|
||||
if err == sql.ErrNoRows {
|
||||
return time.Time{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return time.Time{}, false, err
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, until)
|
||||
return t, err == nil, err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) GrantPod(userID int64, until time.Time, ref string) error {
|
||||
_, err := s.db.Exec(`
|
||||
INSERT INTO pod_subscriptions (user_id, paid_until, payment_ref)
|
||||
VALUES (?,?,?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET
|
||||
paid_until = excluded.paid_until,
|
||||
payment_ref = excluded.payment_ref,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')`,
|
||||
userID, until.UTC().Format(time.RFC3339), ref)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *sqliteStore) Close() error { return s.db.Close() }
|
||||
Loading…
Add table
Add a link
Reference in a new issue