Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions internal/serve/api/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@ import (
"errors"
"io/fs"
"log/slog"
"math"
"net"
"net/http"
"regexp"
"strconv"
"strings"

"github.com/RandomCodeSpace/ctm/internal/serve/auth"
Expand Down Expand Up @@ -103,14 +106,29 @@ func AuthSignup(store *auth.Store) http.HandlerFunc {
}
}

// AuthLogin returns POST /api/auth/login.
func AuthLogin(store *auth.Store) http.HandlerFunc {
// AuthLogin returns POST /api/auth/login. The limiter protects the
// argon2id verify path from brute-force/DoS; a successful login
// resets the IP's window so legitimate users aren't locked out
// after a typo.
func AuthLogin(store *auth.Store, limiter *auth.Limiter) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
w.Header().Set("Allow", http.MethodPost)
writeInputErr(w, http.StatusMethodNotAllowed, "method_not_allowed", "POST only")
return
}
ip := clientIP(r)
if ok, retryAfter := limiter.Allow(ip); !ok {
secs := int(math.Ceil(retryAfter.Seconds()))
if secs < 1 {
secs = 1
}
w.Header().Set("Retry-After", strconv.Itoa(secs))
slog.Info("auth login reject", "reason", "rate_limited", "ip", ip)
writeInputErr(w, http.StatusTooManyRequests, "rate_limited",
"too many login attempts; try again later")
return
}
var body authCredsBody
if err := decodeAuthBody(r, w, &body); err != nil {
return
Expand All @@ -133,6 +151,7 @@ func AuthLogin(store *auth.Store) http.HandlerFunc {
"username or password does not match")
return
}
limiter.Reset(ip)
tok, err := store.Create(u.Username)
if err != nil {
writeInputErr(w, http.StatusInternalServerError, "session_failed", err.Error())
Expand All @@ -147,6 +166,18 @@ func AuthLogin(store *auth.Store) http.HandlerFunc {
}
}

// clientIP returns the host portion of r.RemoteAddr. We deliberately
// do NOT honour X-Forwarded-For: behind the reverse proxy the real
// source IP should reach us via RemoteAddr, and trusting XFF blindly
// would let any client spoof the rate-limit key.
func clientIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}

// AuthLogout returns POST /api/auth/logout.
func AuthLogout(store *auth.Store) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
Expand Down
51 changes: 47 additions & 4 deletions internal/serve/api/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,18 @@ import (
"path/filepath"
"strings"
"testing"
"time"

"github.com/RandomCodeSpace/ctm/internal/serve/api"
"github.com/RandomCodeSpace/ctm/internal/serve/auth"
)

// testLimiter returns a generously-sized limiter so tests that aren't
// specifically exercising rate-limiting are never throttled.
func testLimiter() *auth.Limiter {
return auth.NewLimiter(1000, time.Second)
}

// ---------- helpers --------------------------------------------------------

func authTempHome(t *testing.T) string {
Expand Down Expand Up @@ -156,7 +163,7 @@ func TestLogin_HappyPath(t *testing.T) {
enc, _ := auth.Hash("password123")
_ = auth.Save(auth.User{Username: "alice@example.com", Password: enc})
store := auth.NewStore()
h := api.AuthLogin(store)
h := api.AuthLogin(store, testLimiter())
rec := httptest.NewRecorder()
h(rec, authJSONReq(t, http.MethodPost, "/api/auth/login",
map[string]string{"username": "alice@example.com", "password": "password123"}))
Expand All @@ -175,7 +182,7 @@ func TestLogin_BadPassword(t *testing.T) {
enc, _ := auth.Hash("password123")
_ = auth.Save(auth.User{Username: "alice@example.com", Password: enc})
store := auth.NewStore()
h := api.AuthLogin(store)
h := api.AuthLogin(store, testLimiter())
rec := httptest.NewRecorder()
h(rec, authJSONReq(t, http.MethodPost, "/api/auth/login",
map[string]string{"username": "alice@example.com", "password": "wrong"}))
Expand All @@ -192,7 +199,7 @@ func TestLogin_UnknownUsername(t *testing.T) {
enc, _ := auth.Hash("password123")
_ = auth.Save(auth.User{Username: "alice@example.com", Password: enc})
store := auth.NewStore()
h := api.AuthLogin(store)
h := api.AuthLogin(store, testLimiter())
rec := httptest.NewRecorder()
h(rec, authJSONReq(t, http.MethodPost, "/api/auth/login",
map[string]string{"username": "mallory@example.com", "password": "password123"}))
Expand All @@ -204,7 +211,7 @@ func TestLogin_UnknownUsername(t *testing.T) {
func TestLogin_NotRegistered(t *testing.T) {
authTempHome(t)
store := auth.NewStore()
h := api.AuthLogin(store)
h := api.AuthLogin(store, testLimiter())
rec := httptest.NewRecorder()
h(rec, authJSONReq(t, http.MethodPost, "/api/auth/login",
map[string]string{"username": "alice@example.com", "password": "password123"}))
Expand All @@ -216,6 +223,42 @@ func TestLogin_NotRegistered(t *testing.T) {
}
}

func TestLogin_RateLimited(t *testing.T) {
authTempHome(t)
enc, _ := auth.Hash("password123")
_ = auth.Save(auth.User{Username: "alice@example.com", Password: enc})
store := auth.NewStore()

// Injected clock stays fixed so all 6 attempts fall in the window.
clock := func() time.Time { return time.Unix(1_700_000_000, 0) }
lim := auth.NewLimiterWithClock(5, 60*time.Second, clock)
h := api.AuthLogin(store, lim)

// 5 bad attempts — all should reach the handler and return 401.
for i := 0; i < 5; i++ {
rec := httptest.NewRecorder()
h(rec, authJSONReq(t, http.MethodPost, "/api/auth/login",
map[string]string{"username": "alice@example.com", "password": "wrong"}))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("attempt %d status = %d, want 401 (%s)", i+1, rec.Code, rec.Body.String())
}
}

// 6th attempt must be rate-limited.
rec := httptest.NewRecorder()
h(rec, authJSONReq(t, http.MethodPost, "/api/auth/login",
map[string]string{"username": "alice@example.com", "password": "wrong"}))
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("6th attempt status = %d, want 429", rec.Code)
}
if ra := rec.Result().Header.Get("Retry-After"); ra == "" {
t.Fatal("Retry-After header missing on 429")
}
if !strings.Contains(rec.Body.String(), "rate_limited") {
t.Fatalf("body = %q, want rate_limited code", rec.Body.String())
}
}

// ---------- logout ---------------------------------------------------------

func TestLogout_RevokesToken(t *testing.T) {
Expand Down
31 changes: 28 additions & 3 deletions internal/serve/api/feed_history.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,13 +166,38 @@ func FeedHistory(logDir string, resolver UUIDNameResolver) http.HandlerFunc {
}
}

// resolveNameToUUID scans logDir for *.jsonl files and asks the
// UUID→name resolver for each until one matches `name`. Returns the
// matching UUID or ("", false).
// nameToUUIDResolver is the optional direct name→uuid lookup. When a
// UUIDNameResolver also implements this, resolveNameToUUID consults it
// first so the authoritative sessions.json mapping wins over the log-
// directory scan. Without this, sessions that had an older claude
// session_id (a dead log file still sitting in logDir) would race with
// the live one and could shadow it when filenames sort before the live
// UUID. See resolveNameToUUID below.
type nameToUUIDResolver interface {
ResolveName(name string) (uuid string, ok bool)
}

// resolveNameToUUID returns the log UUID for a human session name.
//
// Order of resolution:
// 1. If resolver implements nameToUUIDResolver (production: the
// projection-backed logsUUIDResolver), use that directly. This is
// the authoritative path and handles the multi-historical-log-
// file case where a session has cycled through several claude
// session_ids.
// 2. Fallback: scan logDir for *.jsonl files and reverse-map each
// via ResolveUUID. Preserves behaviour for orphan UUIDs whose
// session isn't in the projection (tests, migration, manual
// overrides).
func resolveNameToUUID(resolver UUIDNameResolver, logDir, name string) (string, bool) {
if resolver == nil {
return "", false
}
if nr, ok := resolver.(nameToUUIDResolver); ok {
if uuid, ok := nr.ResolveName(name); ok {
return uuid, true
}
}
entries, err := os.ReadDir(logDir)
if err != nil {
return "", false
Expand Down
63 changes: 63 additions & 0 deletions internal/serve/api/feed_history_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,69 @@ func (h historyResolver) ResolveUUID(u string) (string, bool) {
return "", false
}

// projectionResolver implements both ResolveUUID (workdir-fallback
// semantics: every uuid reverse-maps to `name`) and ResolveName (the
// authoritative direct lookup). Used by TestResolveNameToUUID_Prefers
// ProjectionOverLexicalScan to reproduce the codeiq-style bug where
// a lexically-earlier dead log file shadowed the live one.
type projectionResolver struct {
liveUUID string
name string
}

func (p projectionResolver) ResolveUUID(u string) (string, bool) { return p.name, true }
func (p projectionResolver) ResolveName(n string) (string, bool) {
if n == p.name {
return p.liveUUID, true
}
return "", false
}

func TestResolveNameToUUID_PrefersProjectionOverLexicalScan(t *testing.T) {
// Two log files under logDir. deadUUID sorts lexically before
// liveUUID; both reverse-map to "codeiq" via the workdir fallback
// (projectionResolver.ResolveUUID returns "codeiq" for any input).
// Without the direct-name lookup, resolveNameToUUID would return
// deadUUID and callers (Subagents, Teams, FeedHistory) would open
// the wrong file.
const (
deadUUID = "11111111-0000-0000-0000-000000000000"
liveUUID = "99999999-0000-0000-0000-000000000000"
)
dir := t.TempDir()
for _, u := range []string{deadUUID, liveUUID} {
if err := os.WriteFile(filepath.Join(dir, u+".jsonl"), []byte{}, 0o600); err != nil {
t.Fatalf("create %s: %v", u, err)
}
}

got, ok := resolveNameToUUID(projectionResolver{liveUUID: liveUUID, name: "codeiq"}, dir, "codeiq")
if !ok {
t.Fatalf("resolveNameToUUID: ok=false, want true")
}
if got != liveUUID {
t.Errorf("resolveNameToUUID = %q, want %q (projection/live uuid, not the lexically-earlier dead file)", got, liveUUID)
}
}

func TestResolveNameToUUID_FallsBackToScanWhenNoDirectLookup(t *testing.T) {
// historyResolver only implements ResolveUUID — no direct name
// lookup — so the scan path must still work for orphan UUIDs /
// legacy callers.
const uuid = "aaaaaaaa-0000-0000-0000-000000000001"
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, uuid+".jsonl"), []byte{}, 0o600); err != nil {
t.Fatalf("create: %v", err)
}
got, ok := resolveNameToUUID(historyResolver{uuid: uuid, name: "alpha"}, dir, "alpha")
if !ok {
t.Fatalf("resolveNameToUUID: ok=false, want true")
}
if got != uuid {
t.Errorf("resolveNameToUUID = %q, want %q", got, uuid)
}
}

func TestFeedHistory_BeforeInMiddleReturnsOlder(t *testing.T) {
dir := t.TempDir()
const uuid = "aaaaaaaa-0000-0000-0000-000000000001"
Expand Down
41 changes: 33 additions & 8 deletions internal/serve/api/pane.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,35 @@ import (
"encoding/json"
"io"
"net/http"
"strconv"
"time"
)

// paneTick is the capture cadence. 1 Hz matches the design brief —
// a shell prompt feels live without hammering tmux / the browser.
const paneTick = 1 * time.Second

// Default and upper bound for the scrollback window captured above
// the visible pane area. Detached tmux sessions often collapse to a
// small geometry (e.g. 55×28); without scrollback the viewer shows
// only the last ~28 rows no matter how much output has been
// produced. 500 lines is a generous debugging window; 10 000 caps a
// pathological `?history=` query from shipping megabytes per tick.
const (
defaultPaneScrollback = 500
maxPaneScrollback = 10_000
)

// TmuxPaneCapturer is the narrow slice of *tmux.Client this handler
// needs. A package-local interface keeps the api package decoupled
// from internal/tmux (which would otherwise pull os/exec into every
// api test binary) and makes the handler trivially faked.
type TmuxPaneCapturer interface {
// CapturePane returns the raw output of
// tmux capture-pane -e -p -t <name>
// -e preserves SGR escape sequences (colour); -p prints to stdout.
CapturePane(name string) (string, error)
// CapturePaneHistory returns the raw output of
// tmux capture-pane -e -p -J -t <name> -S -<scrollback>
// scrollback lines above the visible pane, with -e preserving
// SGR, -p writing to stdout, and -J joining wrapped lines.
CapturePaneHistory(name string, scrollback int) (string, error)
}

// PaneStream returns a GET /events/session/{name}/pane handler that
Expand All @@ -40,8 +53,10 @@ type TmuxPaneCapturer interface {
// - Emits a single initial frame on connect so the UI has something
// to render immediately (no 1s blank state).
// - Exits cleanly when the client disconnects (r.Context().Done())
// or when the pane disappears (CapturePane returns an error twice
// in a row — we tolerate one transient miss).
// or when the pane disappears (CapturePaneHistory returns an
// error twice in a row — we tolerate one transient miss).
// - `?history=<N>` query param overrides the default scrollback
// window. Clamped to [0, maxPaneScrollback]. 0 = visible-only.
func PaneStream(tmux TmuxPaneCapturer) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
Expand All @@ -50,6 +65,16 @@ func PaneStream(tmux TmuxPaneCapturer) http.HandlerFunc {
return
}

scrollback := defaultPaneScrollback
if raw := r.URL.Query().Get("history"); raw != "" {
if n, err := strconv.Atoi(raw); err == nil && n >= 0 {
if n > maxPaneScrollback {
n = maxPaneScrollback
}
scrollback = n
}
}

flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
Expand Down Expand Up @@ -103,7 +128,7 @@ func PaneStream(tmux TmuxPaneCapturer) http.HandlerFunc {

// Initial capture + emission — so the UI has a first frame
// without waiting 1s.
if out, err := tmux.CapturePane(name); err == nil {
if out, err := tmux.CapturePaneHistory(name, scrollback); err == nil {
if !emit(out) {
return
}
Expand All @@ -116,7 +141,7 @@ func PaneStream(tmux TmuxPaneCapturer) http.HandlerFunc {
case <-ctx.Done():
return
case <-ticker.C:
out, err := tmux.CapturePane(name)
out, err := tmux.CapturePaneHistory(name, scrollback)
if err != nil {
consecutiveErrs++
if consecutiveErrs >= 2 {
Expand Down
Loading
Loading