chore: clean up code comments
This commit is contained in:
+13
-28
@@ -14,14 +14,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// --- Artifact store ---
|
||||
//
|
||||
// Uploaded logs and posters are the same on-disk artifact store: a flat
|
||||
// directory of `<id><ext>` files whose mtime carries the creation time, written
|
||||
// through a temp file, capped by a quota and swept for expiry. Files that
|
||||
// cannot be deleted become pending debt so a failed removal never silently
|
||||
// frees quota. artifactStore implements all of that once; the two flavours
|
||||
// differ only in the policy fields below.
|
||||
|
||||
type artifactRemovalError struct {
|
||||
err error
|
||||
@@ -117,8 +109,8 @@ func validID(id string, length int) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// pendingRemoval is an artifact file that could not be deleted yet. Its size is
|
||||
// unknown when the file could not be stat'ed or is not a regular file.
|
||||
// pendingRemoval tracks an artifact that could not be deleted. Size is unknown
|
||||
// when stat fails or the path is not a regular file.
|
||||
type pendingRemoval struct {
|
||||
size int64
|
||||
sizeKnown bool
|
||||
@@ -140,27 +132,22 @@ type artifactStore struct {
|
||||
maxAge time.Duration
|
||||
removeFile func(string) error
|
||||
|
||||
// Policy. generateID is a field so tests can force ID collisions.
|
||||
// Policy hooks and quota behavior.
|
||||
generateID func() string
|
||||
idFromFilename func(filename string) (string, bool)
|
||||
// acceptLoaded reports whether a file found on disk is a usable artifact
|
||||
// and returns the content type recorded for it.
|
||||
// acceptLoaded returns the content type for a usable on-disk artifact.
|
||||
acceptLoaded func(filename string, size int64) (string, bool)
|
||||
// limit caps accountedLocked, measured in the units cost returns:
|
||||
// one per artifact for logs, bytes for posters.
|
||||
// limit uses cost units: one per log, bytes per poster.
|
||||
limit int64
|
||||
cost func(size int64) int64
|
||||
pendingCost func(pending pendingRemoval) int64
|
||||
// evictToFit admits a new artifact by evicting the oldest live ones;
|
||||
// stores that leave it false reject the upload with errFull instead.
|
||||
// evictToFit evicts oldest entries instead of rejecting when full.
|
||||
evictToFit bool
|
||||
// retryKnownDebtOnPut retries only debt whose size is accounted, leaving
|
||||
// unknown debt to periodic cleanup.
|
||||
// retryKnownDebtOnPut retries only size-accounted pending removals.
|
||||
retryKnownDebtOnPut bool
|
||||
errFull error
|
||||
|
||||
// Pending removals whose cost is not accounted (pendingCost returns 0) are
|
||||
// tracked only by their pendingRemovals entry; no separate counter exists.
|
||||
// Unaccounted pending removals are tracked only in pendingRemovals.
|
||||
used int64 // accounted cost of live entries
|
||||
pendingDebt int64 // accounted cost of pending removals
|
||||
startupErr error
|
||||
@@ -170,11 +157,11 @@ type artifactStore struct {
|
||||
func (as *artifactStore) filePath(filename string) string {
|
||||
return filepath.Join(as.dir, filename)
|
||||
}
|
||||
|
||||
func (as *artifactStore) accountedLocked() int64 {
|
||||
return as.used + as.pendingDebt
|
||||
}
|
||||
|
||||
|
||||
func (as *artifactStore) loadExisting(now time.Time) error {
|
||||
as.mu.Lock()
|
||||
defer as.mu.Unlock()
|
||||
@@ -230,7 +217,7 @@ func (as *artifactStore) loadExisting(now time.Time) error {
|
||||
return removalErr
|
||||
}
|
||||
|
||||
// put writes data as `<id><ext>` once the quota allows it.
|
||||
// put writes data to a quota-approved `<id><ext>` file.
|
||||
func (as *artifactStore) put(data []byte, ext, contentType string, now time.Time) (string, artifactEntry, error) {
|
||||
as.mu.Lock()
|
||||
defer as.mu.Unlock()
|
||||
@@ -287,9 +274,7 @@ func (as *artifactStore) put(data []byte, ext, contentType string, now time.Time
|
||||
return id, entry, nil
|
||||
}
|
||||
|
||||
// lookupEntry returns the live entry for id, dropping it when it has expired.
|
||||
// match, when set, rejects entries the caller did not ask for before expiry is
|
||||
// considered, so a mismatched request never triggers a removal.
|
||||
// lookupEntry returns a live matching entry, deleting it if expired.
|
||||
func (as *artifactStore) lookupEntry(
|
||||
id string,
|
||||
now time.Time,
|
||||
@@ -363,8 +348,8 @@ func (as *artifactStore) deleteEntryLocked(id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeUntrackedLocked deletes a file the index does not own, recording it as
|
||||
// pending debt when the removal fails.
|
||||
// removeUntrackedLocked deletes an unindexed file and records failed removal
|
||||
// as pending debt.
|
||||
func (as *artifactStore) removeUntrackedLocked(filename string, size int64, sizeKnown bool) error {
|
||||
if err := removeArtifact(as.removeFile, as.dir, as.filePath(filename)); err != nil {
|
||||
as.addPendingLocked(filename, size, sizeKnown)
|
||||
|
||||
+35
-72
@@ -39,7 +39,7 @@ const (
|
||||
httpResponseWriteTimeout = oauthResultWait + httpResponseWriteMargin
|
||||
pongWait = 60 * time.Second
|
||||
pingInterval = 30 * time.Second
|
||||
maxLogSize = 1 * 1024 * 1024 // 1MB
|
||||
maxLogSize = 1 * 1024 * 1024
|
||||
logMaxAge = 3 * 24 * time.Hour
|
||||
logIDLength = 5
|
||||
logRateInterval = 1 * time.Minute
|
||||
@@ -49,7 +49,7 @@ const (
|
||||
maxFailedLogLookupSources = 4096
|
||||
maxConcurrentLogLookups = 32
|
||||
maxHTTPHeaderBytes = 64 * 1024
|
||||
maxPosterSize = 5 * 1024 * 1024 // 5MB
|
||||
maxPosterSize = 5 * 1024 * 1024
|
||||
maxPosterStoreSize = int64(1 * 1024 * 1024 * 1024)
|
||||
posterMaxAge = 3 * time.Hour
|
||||
posterIDLength = 16
|
||||
@@ -78,7 +78,6 @@ var upgrader = websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool { return true },
|
||||
}
|
||||
|
||||
// --- Messages ---
|
||||
|
||||
type clientMsg struct {
|
||||
Type string `json:"type"`
|
||||
@@ -104,7 +103,6 @@ type serverMsg struct {
|
||||
Payload json.RawMessage `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
// --- Client (serializes writes to a single goroutine) ---
|
||||
|
||||
type outboundFrame struct {
|
||||
data []byte
|
||||
@@ -216,7 +214,6 @@ func (c *Client) close() {
|
||||
})
|
||||
}
|
||||
|
||||
// --- Room ---
|
||||
|
||||
type reconnectVerifier [sha256.Size]byte
|
||||
|
||||
@@ -241,11 +238,8 @@ type Room struct {
|
||||
LastActivityAt time.Time
|
||||
}
|
||||
|
||||
// --- Snapshot types (on-disk JSON format) ---
|
||||
|
||||
// Nanosecond timestamps preserve exact absence state without the expansion of
|
||||
// RFC3339 strings at the maximum admitted reservation count. Zero means the
|
||||
// peer was connected when the snapshot was captured.
|
||||
// Nanosecond timestamps preserve exact absence state; zero means connected.
|
||||
type peerReservationSnapshot struct {
|
||||
Verifier string `json:"verifier"`
|
||||
AbsentSinceUnixNano int64 `json:"absentSince,omitempty"`
|
||||
@@ -305,8 +299,8 @@ func reconnectVerifierMatches(expected, presented reconnectVerifier) bool {
|
||||
return subtle.ConstantTimeCompare(expected[:], presented[:]) == 1
|
||||
}
|
||||
|
||||
// pruneExpiredPeerReservationsLocked removes only expired, disconnected guest
|
||||
// reservations. The caller must hold room.mu.
|
||||
// pruneExpiredPeerReservationsLocked removes expired, disconnected guest
|
||||
// reservations; the caller must hold room.mu.
|
||||
func pruneExpiredPeerReservationsLocked(room *Room, now time.Time) bool {
|
||||
changed := false
|
||||
for peerID, reservation := range room.peerReservations {
|
||||
@@ -417,14 +411,12 @@ func (r *Room) sendFrom(senderID string, sender *Client, targetID string, msg se
|
||||
return directedTargetFound
|
||||
}
|
||||
|
||||
// --- Log store ---
|
||||
|
||||
const logFileExt = ".log"
|
||||
|
||||
var errLogStoreFull = errors.New("log store full")
|
||||
|
||||
// logStore keeps diagnostic uploads capped by artifact count; a full store
|
||||
// rejects new uploads rather than evicting logs someone may still be reading.
|
||||
// logStore rejects uploads when its artifact-count quota is full.
|
||||
type logStore struct {
|
||||
artifactStore
|
||||
rateLimit map[string]time.Time // IP -> last upload time
|
||||
@@ -521,12 +513,10 @@ func (ls *logStore) cleanup(now time.Time) error {
|
||||
return removalErr
|
||||
}
|
||||
|
||||
// --- Poster store ---
|
||||
|
||||
var errPosterStoreFull = errors.New("poster store full")
|
||||
|
||||
// posterStore caps shared posters by accounted bytes and evicts the oldest to
|
||||
// admit a new upload.
|
||||
// posterStore evicts oldest artifacts to stay within its byte quota.
|
||||
type posterStore struct {
|
||||
artifactStore
|
||||
}
|
||||
@@ -649,7 +639,6 @@ func (ps *posterStore) lookup(filename string, now time.Time) (artifactEntry, bo
|
||||
})
|
||||
}
|
||||
|
||||
// --- Snapshotter (single-writer, debounced, atomic disk persistence) ---
|
||||
|
||||
var errSnapshotterStopped = errors.New("snapshot writer is stopped")
|
||||
|
||||
@@ -726,9 +715,8 @@ func newSnapshotter(path string, build func() stateSnapshot) *snapshotter {
|
||||
return sn
|
||||
}
|
||||
|
||||
// recordMutation publishes a protected identity, membership, or reservation
|
||||
// mutation to the single writer. Callers record after changing state and before
|
||||
// releasing the lock that made the mutation visible.
|
||||
// recordMutation publishes a mutation after the caller changes state and before
|
||||
// releasing the lock that made it visible.
|
||||
func (sn *snapshotter) recordMutation() uint64 {
|
||||
sn.stateMu.Lock()
|
||||
if sn.stopped {
|
||||
@@ -742,9 +730,7 @@ func (sn *snapshotter) recordMutation() uint64 {
|
||||
return seq
|
||||
}
|
||||
|
||||
// recordTerminalMutation atomically publishes a protected mutation together
|
||||
// with its outcome channel. A buffered result retains even an immediate write
|
||||
// failure until the handler begins waiting.
|
||||
// recordTerminalMutation publishes a mutation and its buffered outcome channel.
|
||||
func (sn *snapshotter) recordTerminalMutation(
|
||||
complete func(error) terminalMutationOutcome,
|
||||
) *terminalMutationTicket {
|
||||
@@ -755,9 +741,7 @@ func (sn *snapshotter) recordTerminalMutation(
|
||||
if complete == nil {
|
||||
result <- terminalMutationOutcome{err: errSnapshotterStopped, deliver: true}
|
||||
} else {
|
||||
// The caller still holds the protected state lock. Run the
|
||||
// rollback continuation asynchronously so it can acquire the
|
||||
// normal s.mu -> room.mu order after the caller unlocks.
|
||||
// Run rollback asynchronously after the caller releases its state lock.
|
||||
go func() {
|
||||
result <- complete(errSnapshotterStopped)
|
||||
}()
|
||||
@@ -848,8 +832,7 @@ func (sn *snapshotter) run() {
|
||||
default:
|
||||
}
|
||||
}
|
||||
// Drain tokens queued before capture. A mutation recorded after
|
||||
// capture re-arms the channels and therefore requires a later write.
|
||||
// Drain pre-capture tokens; later mutations re-arm the channels.
|
||||
select {
|
||||
case <-sn.trigger:
|
||||
default:
|
||||
@@ -870,9 +853,8 @@ func (sn *snapshotter) run() {
|
||||
}
|
||||
}
|
||||
|
||||
// write is the narrowly serialized storage entry retained for atomic-storage
|
||||
// tests. Production mutations use writeNextGeneration so generation outcomes
|
||||
// cannot bypass the single writer.
|
||||
// write is retained for synchronous storage tests; production uses the single
|
||||
// writer's writeNextGeneration.
|
||||
func (sn *snapshotter) write() error {
|
||||
sn.writeMu.Lock()
|
||||
defer sn.writeMu.Unlock()
|
||||
@@ -922,9 +904,7 @@ func (sn *snapshotter) writeNextGeneration() (bool, error) {
|
||||
}
|
||||
sn.stateMu.Unlock()
|
||||
|
||||
// Continuations are part of the writer barrier. In particular, a failed
|
||||
// staged release rolls back and records its corrective generation before
|
||||
// this writer can capture any queued later mutation.
|
||||
// Continuations roll back failed releases before later mutations are captured.
|
||||
for _, terminal := range covered {
|
||||
outcome := terminalMutationOutcome{err: err, deliver: true}
|
||||
if terminal.complete != nil {
|
||||
@@ -1004,9 +984,8 @@ func (sn *snapshotter) persistAtomic(data []byte) error {
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
// Rename is the commit boundary: the replacement is file-synced and
|
||||
// non-torn. Parent-directory sync adds crash durability where supported,
|
||||
// but its post-commit failure must not report the mutation as uncommitted.
|
||||
// Rename commits the file-synced replacement. Directory-sync failure is
|
||||
// warning-only after that boundary.
|
||||
if err := sn.syncDir(sn.dir); err != nil {
|
||||
sn.logDirSyncErr(err)
|
||||
}
|
||||
@@ -1038,7 +1017,7 @@ func (sn *snapshotter) flushAndStop(timeout time.Duration) error {
|
||||
return sn.stopErr
|
||||
}
|
||||
|
||||
// logWriteErr throttles pre-commit snapshot-write error spam to once per hour.
|
||||
// logWriteErr throttles pre-commit snapshot errors to once per hour.
|
||||
func (sn *snapshotter) logWriteErr(err error) {
|
||||
sn.errMu.Lock()
|
||||
defer sn.errMu.Unlock()
|
||||
@@ -1049,8 +1028,7 @@ func (sn *snapshotter) logWriteErr(err error) {
|
||||
log.Printf("snapshot: write failed before rename commit: %v", err)
|
||||
}
|
||||
|
||||
// logDirSyncErr has an independent throttle so a degraded post-rename warning
|
||||
// cannot suppress a later pre-commit persistence error.
|
||||
// logDirSyncErr independently throttles post-rename directory-sync warnings.
|
||||
func (sn *snapshotter) logDirSyncErr(err error) {
|
||||
sn.dirErrMu.Lock()
|
||||
defer sn.dirErrMu.Unlock()
|
||||
@@ -1061,7 +1039,6 @@ func (sn *snapshotter) logDirSyncErr(err error) {
|
||||
log.Printf("snapshot: parent directory sync failed after rename commit: %v", err)
|
||||
}
|
||||
|
||||
// --- Server ---
|
||||
type removalErrorThrottle struct {
|
||||
mu sync.Mutex
|
||||
lastLog map[string]time.Time
|
||||
@@ -1157,9 +1134,8 @@ func newServer(logDir, stateFile, posterDir string, clientIPs clientIPResolver)
|
||||
return s
|
||||
}
|
||||
|
||||
// removeRoomLocked removes room only while it is still the authoritative map
|
||||
// entry. The caller must hold s.mu. A current-process quota reservation follows
|
||||
// the retained room and is returned exactly once by successful removal.
|
||||
// removeRoomLocked removes only the authoritative entry and releases its
|
||||
// current-process quota reservation once.
|
||||
func (s *Server) removeRoomLocked(sessionID string, room *Room) bool {
|
||||
if s.rooms[sessionID] != room {
|
||||
return false
|
||||
@@ -1171,18 +1147,14 @@ func (s *Server) removeRoomLocked(sessionID string, room *Room) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// buildSnapshot is the synchronous storage-test entry. Production capture uses
|
||||
// captureSnapshot so the copied state and its covered generation share one
|
||||
// ordering boundary.
|
||||
// buildSnapshot is the synchronous test entry; production uses captureSnapshot.
|
||||
func (s *Server) buildSnapshot() stateSnapshot {
|
||||
snapshot, _ := s.captureSnapshot(func() uint64 { return 0 })
|
||||
return snapshot
|
||||
}
|
||||
|
||||
// captureSnapshot freezes every durable room mutation under the established
|
||||
// s.mu -> room.mu order, then captures the covered sequence while those locks
|
||||
// remain held. A mutation is therefore either both present and covered, or
|
||||
// neither present nor covered. Locks are released before marshal or disk I/O.
|
||||
// captureSnapshot holds s.mu -> room.mu while copying state and its covered
|
||||
// generation, then releases locks before marshal or I/O.
|
||||
func (s *Server) captureSnapshot(captureSequence func() uint64) (stateSnapshot, uint64) {
|
||||
s.mu.RLock()
|
||||
rooms := make([]*Room, 0, len(s.rooms))
|
||||
@@ -1235,9 +1207,8 @@ func (s *Server) captureSnapshot(captureSequence func() uint64) (stateSnapshot,
|
||||
return snapshot, targetSeq
|
||||
}
|
||||
|
||||
// loadSnapshot restores rooms from disk on startup. The returned rewrite flag
|
||||
// reports reservation migration, initialization, or pruning that must be
|
||||
// persisted before serving. Missing/corrupt files still allow startup.
|
||||
// loadSnapshot restores rooms. Missing or corrupt files allow startup; the
|
||||
// rewrite flag requests persistence of migration or pruning.
|
||||
func (s *Server) loadSnapshot(path string) (bool, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
@@ -1699,7 +1670,7 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Invalid client address", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// Retained-room ownership uses the same canonical source key as connection admission.
|
||||
// Retained-room ownership uses the admission source key.
|
||||
quotaOwnerKey := ip
|
||||
|
||||
if !s.conns.tryConnect(ip) {
|
||||
@@ -1740,9 +1711,7 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Cleanup on disconnect only when this client is still authoritative. A
|
||||
// displaced client's defer must neither remove the replacement nor start
|
||||
// its reservation's absence clock.
|
||||
// Only the authoritative client may remove the room or start its absence clock.
|
||||
defer func() {
|
||||
if currentRoom != nil && currentPeerID != "" {
|
||||
currentRoom.mu.Lock()
|
||||
@@ -1883,10 +1852,8 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
continue
|
||||
}
|
||||
// A room nobody is connected to is an abandoned code, not property.
|
||||
// Whoever asks for it next takes it, so a host that restarted with a
|
||||
// fresh reconnect token can reuse its own code instead of waiting out
|
||||
// the cleanup sweep. An occupied room still belongs to its peers.
|
||||
// An empty room code is abandoned and may be reclaimed; occupied rooms
|
||||
// remain owned by their peers.
|
||||
reclaimable := len(existing.Peers) == 0 && !existing.closing
|
||||
existing.mu.Unlock()
|
||||
if !reclaimable {
|
||||
@@ -2038,15 +2005,14 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
|
||||
!occupied &&
|
||||
room.quotaOwnerKey != "" &&
|
||||
room.quotaOwnerKey == quotaOwnerKey:
|
||||
// Tokenless host reconnect is retained only for unversioned rooms,
|
||||
// only within this process, and only from the creating source.
|
||||
// Tokenless host reconnect is limited to unversioned local rooms
|
||||
// from the creating source.
|
||||
authorized = true
|
||||
responseToken = ""
|
||||
responseVerifier = room.hostVerifier
|
||||
}
|
||||
} else {
|
||||
// Legacy guests have no durable proof. Never let one replace a live
|
||||
// identity; disconnected identity reuse remains confined to legacy rooms.
|
||||
// Legacy guests lack durable proof, so identity reuse is legacy-only.
|
||||
authorized = !occupied
|
||||
}
|
||||
|
||||
@@ -2189,8 +2155,7 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
|
||||
if room.LastActivityAt.Equal(leaveActivity) {
|
||||
room.LastActivityAt = previousActivity
|
||||
}
|
||||
// The failed attempt captured the pending omission. Record
|
||||
// the restored reservation before a queued later capture.
|
||||
// Record the restored reservation before a queued later capture.
|
||||
s.snap.recordMutation()
|
||||
}
|
||||
room.mu.Unlock()
|
||||
@@ -2258,9 +2223,7 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) {
|
||||
room.mu.Unlock()
|
||||
s.mu.Unlock()
|
||||
|
||||
// A successful outcome means the file-synced atomic rename committed.
|
||||
// Supported filesystems also complete parent-directory sync before
|
||||
// this barrier; post-rename sync degradation is warning-only.
|
||||
// Atomic rename committed; post-rename directory-sync degradation is warning-only.
|
||||
outcome := s.snap.waitForDurable(ticket)
|
||||
if outcome.err != nil {
|
||||
client.sendJSON(serverMsg{Type: relayTypeError, Code: relayErrorInvalidMessage, Message: "Unable to persist ended room"})
|
||||
|
||||
+27
-87
@@ -49,9 +49,7 @@ func TestGeneratedRelayProtocolVersionsMatchSpec(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// newTestServer builds a Server wired for tests: no goroutines and no network.
|
||||
// Its snapshotter is not started; storage tests drive the narrow synchronous
|
||||
// write entry directly.
|
||||
// newTestServer builds a goroutine-free, network-free test server.
|
||||
func newTestServer(t *testing.T, stateFile string) *Server {
|
||||
t.Helper()
|
||||
s := &Server{
|
||||
@@ -135,7 +133,7 @@ func TestSnapshotRoundTrip(t *testing.T) {
|
||||
t.Fatalf("snapshot persisted process-local client identity: %s", data)
|
||||
}
|
||||
|
||||
// Reconstruct into a fresh Server and verify identity.
|
||||
// Reload from disk into a fresh server.
|
||||
s2 := newTestServer(t, path)
|
||||
if _, err := s2.loadSnapshot(path); err != nil {
|
||||
t.Fatalf("loadSnapshot: %v", err)
|
||||
@@ -229,7 +227,7 @@ func TestLoadHandlesCorrupt(t *testing.T) {
|
||||
if len(s.rooms) != 0 {
|
||||
t.Fatalf("expected empty rooms after corrupt load, got %d", len(s.rooms))
|
||||
}
|
||||
// File should be preserved for debugging.
|
||||
// Corrupt snapshots remain available for diagnosis.
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("corrupt file should NOT be deleted: %v", err)
|
||||
}
|
||||
@@ -270,7 +268,7 @@ func TestCleanupUsesIdleNotAge(t *testing.T) {
|
||||
s := newTestServer(t, filepath.Join(t.TempDir(), "rooms.json"))
|
||||
now := time.Now()
|
||||
|
||||
// 2h-old room that has activity 1min ago — must NOT be cleaned up.
|
||||
// Recent activity keeps this old room.
|
||||
s.rooms["KEEP"] = &Room{
|
||||
SessionID: "KEEP",
|
||||
HostPeerID: "h",
|
||||
@@ -278,7 +276,7 @@ func TestCleanupUsesIdleNotAge(t *testing.T) {
|
||||
CreatedAt: now.Add(-2 * time.Hour),
|
||||
LastActivityAt: now.Add(-1 * time.Minute),
|
||||
}
|
||||
// 2h-old room that emptied 10min ago — MUST be cleaned up.
|
||||
// Idle rooms are removed.
|
||||
s.rooms["GONE"] = &Room{
|
||||
SessionID: "GONE",
|
||||
HostPeerID: "h",
|
||||
@@ -286,11 +284,11 @@ func TestCleanupUsesIdleNotAge(t *testing.T) {
|
||||
CreatedAt: now.Add(-2 * time.Hour),
|
||||
LastActivityAt: now.Add(-10 * time.Minute),
|
||||
}
|
||||
// 25h-old room — absolute TTL nukes it even if recently active.
|
||||
// The absolute TTL removes this room despite recent activity.
|
||||
s.rooms["OLD"] = &Room{
|
||||
SessionID: "OLD",
|
||||
HostPeerID: "h",
|
||||
Peers: map[string]*Client{}, // empty anyway
|
||||
Peers: map[string]*Client{},
|
||||
CreatedAt: now.Add(-25 * time.Hour),
|
||||
LastActivityAt: now.Add(-10 * time.Second),
|
||||
}
|
||||
@@ -352,7 +350,7 @@ func TestSnapshotAtomicWriteSurvivesRenameFailure(t *testing.T) {
|
||||
path := filepath.Join(dir, "rooms.json")
|
||||
s := newTestServer(t, path)
|
||||
|
||||
// Seed a valid snapshot on disk.
|
||||
// Seed the on-disk snapshot.
|
||||
s.rooms["ORIG"] = &Room{
|
||||
SessionID: "ORIG",
|
||||
HostPeerID: "h",
|
||||
@@ -368,8 +366,7 @@ func TestSnapshotAtomicWriteSurvivesRenameFailure(t *testing.T) {
|
||||
t.Fatalf("read orig: %v", err)
|
||||
}
|
||||
|
||||
// Block the temporary-file open with a directory at the same path. This
|
||||
// deterministically fails before rename on every supported platform.
|
||||
// A directory at the temporary path makes the open fail before rename.
|
||||
if err := os.Mkdir(path+".tmp", 0755); err != nil {
|
||||
t.Fatalf("create blocking temporary directory: %v", err)
|
||||
}
|
||||
@@ -598,7 +595,7 @@ func TestSnapshotDebounceCoalesces(t *testing.T) {
|
||||
go sn.run()
|
||||
t.Cleanup(func() { _ = sn.flushAndStop(time.Second) })
|
||||
|
||||
// Fire a burst — should collapse into one write due to debounce.
|
||||
// A burst should collapse into one debounced write.
|
||||
for i := 0; i < 20; i++ {
|
||||
sn.recordMutation()
|
||||
}
|
||||
@@ -1132,11 +1129,6 @@ func TestSnapshotDirectorySyncWarningIsThrottled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// Integration harness — boots a real Server behind httptest with the full
|
||||
// HTTP mux. Each dial sets X-Forwarded-For so tests control the perceived
|
||||
// client IP independently of the rate limiters.
|
||||
// ======================================================================
|
||||
|
||||
type relayHarness struct {
|
||||
srv *Server
|
||||
@@ -1171,8 +1163,7 @@ func newRelayHarnessNoTrust(t *testing.T) *relayHarness {
|
||||
)
|
||||
}
|
||||
|
||||
// newRelayHarnessAt lets a test control the stateFile path so two harnesses
|
||||
// can share a snapshot across a simulated restart.
|
||||
// newRelayHarnessAt allows two harnesses to share a snapshot across restarts.
|
||||
func newRelayHarnessAt(t *testing.T, logDir, stateFile string) *relayHarness {
|
||||
t.Helper()
|
||||
return newRelayHarnessAtWithResolver(t, logDir, stateFile, mustClientIPResolver(t, "127.0.0.0/8"))
|
||||
@@ -1501,8 +1492,7 @@ func (c *testConn) expectAuthority(typ, hostPeerID string) serverMsg {
|
||||
return message
|
||||
}
|
||||
|
||||
// recvNothing asserts no message arrives within the given window. Used to
|
||||
// verify silent paths (sender not receiving own broadcast, stale-peer skip).
|
||||
// recvNothing asserts that no frame arrives within the window.
|
||||
func (c *testConn) recvNothing(within time.Duration) {
|
||||
c.t.Helper()
|
||||
c.conn.SetReadDeadline(time.Now().Add(within))
|
||||
@@ -1515,8 +1505,7 @@ func (c *testConn) recvNothing(within time.Duration) {
|
||||
}
|
||||
}
|
||||
|
||||
// recvUntilClosed consumes any frames already queued on the wire and requires
|
||||
// a permanent terminal read error before the absolute deadline.
|
||||
// recvUntilClosed drains queued frames and waits for terminal closure.
|
||||
func (c *testConn) recvUntilClosed(within time.Duration) ([]serverMsg, error) {
|
||||
c.t.Helper()
|
||||
if err := c.conn.SetReadDeadline(time.Now().Add(within)); err != nil {
|
||||
@@ -1681,9 +1670,6 @@ func TestClientWriteFailureClosesConnection(t *testing.T) {
|
||||
client.close()
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// Unit tests — pure logic
|
||||
// ======================================================================
|
||||
|
||||
func TestRateLimiterBurstExhausts(t *testing.T) {
|
||||
rl := newRateLimiter(5, 10)
|
||||
@@ -1698,7 +1684,7 @@ func TestRateLimiterBurstExhausts(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRateLimiterRefillsOverTime(t *testing.T) {
|
||||
rl := newRateLimiter(5, 10) // 10 tokens/sec
|
||||
rl := newRateLimiter(5, 10)
|
||||
for i := 0; i < 5; i++ {
|
||||
rl.allow()
|
||||
}
|
||||
@@ -1734,8 +1720,7 @@ func TestRateLimiterAllowRace(t *testing.T) {
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
// Real assertion is that -race finds no data race. Spot-check the
|
||||
// result is within plausible bounds.
|
||||
// Spot-check the result bounds; -race checks synchronization.
|
||||
if got := successes.Load(); got <= 0 || got > 500 {
|
||||
t.Fatalf("unexpected successes count %d (want 1..500)", got)
|
||||
}
|
||||
@@ -1775,9 +1760,6 @@ func TestCleanupRateWindowsUsesWindowBoundary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// connTracker unit tests
|
||||
// ======================================================================
|
||||
|
||||
func TestConnTrackerPerIPLimit(t *testing.T) {
|
||||
ct := newConnTracker()
|
||||
@@ -1821,7 +1803,7 @@ func TestConnTrackerDisconnectFrees(t *testing.T) {
|
||||
t.Errorf("globalCount=%d, want 0", ct.globalCount)
|
||||
}
|
||||
ct.mu.Unlock()
|
||||
// Extra disconnect is a no-op (doesn't panic).
|
||||
// Extra disconnect is a no-op.
|
||||
ct.disconnect(ip)
|
||||
}
|
||||
|
||||
@@ -1881,9 +1863,7 @@ func TestConnTrackerConnectRateLimit(t *testing.T) {
|
||||
t.Fatalf("warmup tryConnect %d: expected true", i)
|
||||
}
|
||||
}
|
||||
// Free one slot so the perIP check won't be what rejects us.
|
||||
ct.disconnect(ip)
|
||||
// Rate-limit bucket is empty now; this should be the denial path.
|
||||
// Free a slot so the next denial comes from the rate limiter.
|
||||
if ct.tryConnect(ip) {
|
||||
t.Fatal("expected false from rate-limit bucket, not per-IP cap")
|
||||
}
|
||||
@@ -1990,9 +1970,6 @@ func TestPosterUploadLimiterAdmissionPolicy(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// clientIPResolver unit tests
|
||||
// ======================================================================
|
||||
|
||||
func TestClientIPResolverTrustChains(t *testing.T) {
|
||||
tests := []struct {
|
||||
@@ -2088,12 +2065,8 @@ func TestParseTrustedProxyCIDRs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// generateLogID
|
||||
// ======================================================================
|
||||
|
||||
// Random ids may legitimately repeat, so shape is the only contract here;
|
||||
// collision retry is covered deterministically by
|
||||
// Random IDs may repeat; this test checks shape. Collision retry is covered by
|
||||
// TestLogStorePersistsAcrossRestartAndAvoidsIDCollisions.
|
||||
func TestGenerateLogIDShape(t *testing.T) {
|
||||
for range 200 {
|
||||
@@ -2109,9 +2082,6 @@ func TestGenerateLogIDShape(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// handleWS — create case
|
||||
// ======================================================================
|
||||
|
||||
func TestCreateSucceeds(t *testing.T) {
|
||||
h := newRelayHarness(t)
|
||||
@@ -2144,7 +2114,7 @@ func TestCreateDuplicateReturnsRoomExists(t *testing.T) {
|
||||
c1.send(clientMsg{Type: "create", SessionID: "SAME", PeerID: "host-1"})
|
||||
c1.expect("created")
|
||||
|
||||
// Different IP to avoid the per-IP rooms quota interfering.
|
||||
// Use a different IP so the rooms quota does not interfere.
|
||||
c2 := h.dial(t, "1.1.1.5")
|
||||
c2.send(clientMsg{Type: "create", SessionID: "SAME", PeerID: "host-2"})
|
||||
c2.expectError("room_exists")
|
||||
@@ -2308,8 +2278,7 @@ func TestCreateReclaimsAbandonedEmptyRoom(t *testing.T) {
|
||||
t.Fatal("abandoned room identity survived the reclaim")
|
||||
}
|
||||
|
||||
// The previous owner's capability died with the room it belonged to, and
|
||||
// the live replacement is not reclaimable by anyone, owner included.
|
||||
// The former capability cannot reclaim the live replacement.
|
||||
former := h.dial(t, "1.1.1.60")
|
||||
former.send(clientMsg{
|
||||
Type: relayTypeCreate,
|
||||
@@ -2321,9 +2290,7 @@ func TestCreateReclaimsAbandonedEmptyRoom(t *testing.T) {
|
||||
former.expectError(relayErrorRoomExists)
|
||||
}
|
||||
|
||||
// The recent-rooms flow: a host restarts its app, so it presents a fresh
|
||||
// reconnect capability for a code the relay still holds. The abandoned code
|
||||
// must come back as a hosted room instead of a ghost room with no host.
|
||||
// A restarted host presents a fresh capability for an abandoned room code.
|
||||
func TestAbandonedCodeIsRecreatableByARestartedHost(t *testing.T) {
|
||||
h := newRelayHarness(t)
|
||||
firstToken, _ := mustReconnectToken(t)
|
||||
@@ -2339,8 +2306,7 @@ func TestAbandonedCodeIsRecreatableByARestartedHost(t *testing.T) {
|
||||
host.conn.Close()
|
||||
h.waitRoomPeers(t, "REUSE", 0)
|
||||
|
||||
// A restarted app mints a new capability, so it cannot prove the previous
|
||||
// ownership even when it reuses its own peer ID.
|
||||
// A fresh capability cannot prove previous ownership, even with the same peer ID.
|
||||
restartToken, _ := mustReconnectToken(t)
|
||||
restarted := h.dial(t, "6.4.0.2")
|
||||
restarted.send(clientMsg{
|
||||
@@ -2423,7 +2389,7 @@ func TestCreateHitsRoomsPerIPLimit(t *testing.T) {
|
||||
c.send(clientMsg{Type: "create", SessionID: fmt.Sprintf("R%d", i), PeerID: "host"})
|
||||
c.expect("created")
|
||||
}
|
||||
// 4th create from same IP exceeds the quota.
|
||||
// The fourth room from this IP exceeds the quota.
|
||||
c := h.dial(t, ip)
|
||||
c.send(clientMsg{Type: "create", SessionID: "ROVERFLOW", PeerID: "host"})
|
||||
c.expectError("rate_limited")
|
||||
@@ -2782,9 +2748,6 @@ func TestConnectionCannotRetainMultipleRoomMemberships(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// handleWS — join case
|
||||
// ======================================================================
|
||||
|
||||
func TestJoinSucceedsAndBroadcastsPeerJoined(t *testing.T) {
|
||||
h := newRelayHarness(t)
|
||||
@@ -3345,9 +3308,6 @@ func TestJoinAdmissionIsAtomicWithReservedRoomCreate(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// handleWS — broadcast / sendTo
|
||||
// ======================================================================
|
||||
|
||||
func TestBroadcastDeliversToOthersNotSender(t *testing.T) {
|
||||
h := newRelayHarness(t)
|
||||
@@ -3381,7 +3341,7 @@ func TestBroadcastDeliversToOthersNotSender(t *testing.T) {
|
||||
t.Errorf("g2 From=%q want G1", g2Msg.From)
|
||||
}
|
||||
|
||||
// Sender should not receive its own broadcast.
|
||||
// Broadcasts exclude the sender.
|
||||
g1.recvNothing(200 * time.Millisecond)
|
||||
}
|
||||
|
||||
@@ -3490,9 +3450,6 @@ func TestSendToNotInRoomRejected(t *testing.T) {
|
||||
c.expectError("not_in_room")
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// handleWS — ping / misc / rate limits
|
||||
// ======================================================================
|
||||
|
||||
func TestPingReturnsPong(t *testing.T) {
|
||||
h := newRelayHarness(t)
|
||||
@@ -3521,7 +3478,7 @@ func TestPerConnectionMessageRateLimit(t *testing.T) {
|
||||
c.send(clientMsg{Type: "create", SessionID: "RL", PeerID: "H"})
|
||||
c.expect("created")
|
||||
|
||||
// The per-connection bucket is rateBurst=30. After ~30 pings we start seeing rate_limited.
|
||||
// Exceed the per-connection bucket and observe rate limiting.
|
||||
sawRateLimit := false
|
||||
for i := 0; i < rateBurst+10; i++ {
|
||||
c.send(clientMsg{Type: "ping"})
|
||||
@@ -3538,9 +3495,6 @@ func TestPerConnectionMessageRateLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// handleWS — disconnect lifecycle
|
||||
// ======================================================================
|
||||
|
||||
func TestDisconnectBroadcastsPeerLeft(t *testing.T) {
|
||||
h := newRelayHarness(t)
|
||||
@@ -5364,9 +5318,7 @@ func TestHostEndDeliversEndedAfterConcurrentGuestTraffic(t *testing.T) {
|
||||
t.Fatal("ending room remained discoverable before terminal delivery")
|
||||
}
|
||||
|
||||
// WebSocket frames are processed in order. Receiving pong proves the
|
||||
// preceding membership-sensitive traffic was handled while ended delivery
|
||||
// was blocked, without closing the guest as a stale client.
|
||||
// Ordered frames prove membership traffic completed while ended delivery waited.
|
||||
guest.send(clientMsg{
|
||||
Type: relayTypeBroadcast,
|
||||
Payload: json.RawMessage(`{"during":"end"}`),
|
||||
@@ -5543,9 +5495,6 @@ func TestCleanupDisconnectsPeersBeforeRemovingExpiredOccupiedRoom(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// Logs endpoints
|
||||
// ======================================================================
|
||||
|
||||
func postLog(t *testing.T, baseURL, ip string, body []byte) *http.Response {
|
||||
t.Helper()
|
||||
@@ -5579,8 +5528,6 @@ func getLog(t *testing.T, baseURL, ip, id string) *http.Response {
|
||||
return resp
|
||||
}
|
||||
|
||||
// postLogAndGetID uploads a log and returns the generated id, asserting the
|
||||
// POST succeeded.
|
||||
func postLogAndGetID(t *testing.T, baseURL, ip string, body []byte) string {
|
||||
t.Helper()
|
||||
resp := postLog(t, baseURL, ip, body)
|
||||
@@ -6082,7 +6029,7 @@ func TestLogsUploadDoesNotWriteCapabilityToOperationalLog(t *testing.T) {
|
||||
func TestLogStoreRetiresLegacyCapabilitiesOnStartup(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
now := time.Now().Add(-time.Minute)
|
||||
legacyID := strings.Repeat("a", 25) // capability shape used before ids went back to logIDLength
|
||||
legacyID := strings.Repeat("a", 25) // legacy capability length
|
||||
currentID := strings.Repeat("a", logIDLength)
|
||||
legacyPath := filepath.Join(dir, legacyID+".log")
|
||||
currentPath := filepath.Join(dir, currentID+".log")
|
||||
@@ -6365,7 +6312,6 @@ func TestLogsGetExpiredIs404(t *testing.T) {
|
||||
h := newRelayHarness(t)
|
||||
id := postLogAndGetID(t, h.baseURL, "7.3.0.1", []byte("temp"))
|
||||
|
||||
// Poison the entry's ExpiresAt into the past.
|
||||
h.srv.logs.mu.Lock()
|
||||
entry := h.srv.logs.entries[id]
|
||||
entry.ExpiresAt = time.Now().Add(-time.Minute)
|
||||
@@ -6394,9 +6340,6 @@ func TestLogsMethodNotAllowed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// Poster endpoints
|
||||
// ======================================================================
|
||||
|
||||
var minimalPNG = []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a, 0x01, 0x02, 0x03}
|
||||
|
||||
@@ -7687,9 +7630,6 @@ func TestRemovalFailureLogDoesNotExposeCapabilityPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// End-to-end: rooms survive a process restart
|
||||
// ======================================================================
|
||||
|
||||
func TestSnapshotSurvivesRestartWithHostAuthority(t *testing.T) {
|
||||
stateFile := filepath.Join(t.TempDir(), "rooms.json")
|
||||
|
||||
+25
-42
@@ -1,10 +1,7 @@
|
||||
package main
|
||||
|
||||
// OAuth proxy: relays MAL + AniList authorization-code flows for devices that
|
||||
// can't listen on localhost (TVs) or lack a browser (headless set-top boxes
|
||||
// pair via a phone QR scan). Sessions live in memory for 10 minutes; AniList's
|
||||
// client secret lives only in env vars. Access tokens transit the server
|
||||
// briefly during code→token exchange and are never logged or persisted.
|
||||
// OAuth proxy for TV/headless clients. Sessions remain in memory for 10 minutes;
|
||||
// client secrets are environment-only and tokens are never logged or persisted.
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -31,14 +28,13 @@ const (
|
||||
oauthMaxSessions = 5000
|
||||
oauthStartBurst = 3
|
||||
oauthStartRateSustained = 1
|
||||
oauthBrowserStateBytes = 18 // 144 bits → 24 base64url chars
|
||||
oauthPollSecretBytes = 18 // Independently generated device capability.
|
||||
oauthBrowserStateBytes = 18 // 144 bits, 24 base64url characters
|
||||
oauthPollSecretBytes = 18 // independent device capability
|
||||
oauthPKCEVerifierLen = 64
|
||||
oauthUpstreamTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
// oauthServiceConfig describes a single upstream OAuth provider. Populated from
|
||||
// env vars in oauthConfigFromEnv. A service with an empty ClientID is disabled.
|
||||
// oauthServiceConfig describes one configured upstream provider.
|
||||
type oauthServiceConfig struct {
|
||||
ClientID string
|
||||
ClientSecret string // empty ⇒ provider doesn't issue/require one (MAL w/ PKCE)
|
||||
@@ -56,10 +52,8 @@ type oauthTokenResult struct {
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// oauthSession is created by /auth/start and lives until its result is claimed
|
||||
// or it is removed by cleanup. browserState crosses the browser/provider trust
|
||||
// boundary. Only the SHA-256 digest of the device-only poll secret is retained.
|
||||
// When both locks are needed, oauthProxy.mu must be acquired before s.mu.
|
||||
// oauthSession links browser state to a device-only poll capability. Only the
|
||||
// poll secret digest is retained; acquire oauthProxy.mu before s.mu.
|
||||
type oauthSession struct {
|
||||
browserState string
|
||||
pollDigest [sha256.Size]byte
|
||||
@@ -72,7 +66,7 @@ type oauthSession struct {
|
||||
completed bool
|
||||
result *oauthTokenResult
|
||||
|
||||
// Test seam used to deterministically seat concurrent result waiters.
|
||||
// Test seam for deterministic concurrent waiter tests.
|
||||
waitStarted func()
|
||||
}
|
||||
|
||||
@@ -88,8 +82,8 @@ func (s *oauthSession) completeLocked(r oauthTokenResult) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// wait blocks only until the session is ready or ctx is cancelled. Result
|
||||
// ownership is transferred separately by oauthProxy.claimResult.
|
||||
// wait blocks until the session is ready or ctx is cancelled. Result ownership
|
||||
// transfers separately through oauthProxy.claimResult.
|
||||
func (s *oauthSession) wait(ctx context.Context) error {
|
||||
if s.waitStarted != nil {
|
||||
s.waitStarted()
|
||||
@@ -109,7 +103,7 @@ func (s *oauthSession) pkceVerifier() string {
|
||||
}
|
||||
|
||||
type oauthProxy struct {
|
||||
baseURL string // e.g. https://ice.plezy.app
|
||||
baseURL string
|
||||
services map[string]oauthServiceConfig
|
||||
client *http.Client
|
||||
clientIPs clientIPResolver
|
||||
@@ -134,9 +128,7 @@ func newOAuthProxy(baseURL string, services map[string]oauthServiceConfig, clien
|
||||
}
|
||||
}
|
||||
|
||||
// oauthConfigFromEnv reads the public base URL and per-service creds from the
|
||||
// environment. Returns (nil, false) if OAUTH_BASE_URL is unset — the caller
|
||||
// wires this as "OAuth disabled, endpoints return 503".
|
||||
// oauthConfigFromEnv returns disabled when OAUTH_BASE_URL is unset.
|
||||
func oauthConfigFromEnv(clientIPs clientIPResolver) (*oauthProxy, bool) {
|
||||
base := os.Getenv("OAUTH_BASE_URL")
|
||||
if base == "" {
|
||||
@@ -163,8 +155,7 @@ func oauthConfigFromEnv(clientIPs clientIPResolver) (*oauthProxy, bool) {
|
||||
return newOAuthProxy(base, services, clientIPs), true
|
||||
}
|
||||
|
||||
// registerOAuthRoutes registers all /auth/* handlers. If p is nil (no env
|
||||
// config), all paths 503 so the integration page clearly says "not configured".
|
||||
// registerOAuthRoutes mounts /auth/*; a nil proxy returns 503.
|
||||
func registerOAuthRoutes(mux *http.ServeMux, p *oauthProxy) {
|
||||
if p == nil {
|
||||
mux.HandleFunc("/auth/", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -178,8 +169,7 @@ func registerOAuthRoutes(mux *http.ServeMux, p *oauthProxy) {
|
||||
mux.HandleFunc("/auth/", p.handleAuthRoot)
|
||||
}
|
||||
|
||||
// handleAuthRoot dispatches /auth/... paths that aren't served by their own
|
||||
// registered handler. Covers /auth/:service and /auth/:service/callback.
|
||||
// handleAuthRoot dispatches /auth/:service and /auth/:service/callback.
|
||||
func (p *oauthProxy) handleAuthRoot(w http.ResponseWriter, r *http.Request) {
|
||||
rest := strings.TrimPrefix(r.URL.Path, "/auth/")
|
||||
parts := strings.SplitN(rest, "/", 2)
|
||||
@@ -199,8 +189,7 @@ func (p *oauthProxy) handleAuthRoot(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
// POST /auth/start body={"service":"mal"|"anilist"}
|
||||
// Response: {"session":"device-only poll capability","url":"https://.../auth/:service?state=...","expiresIn":600}
|
||||
// POST /auth/start returns a device poll capability and authorization URL.
|
||||
func (p *oauthProxy) handleStart(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store, private")
|
||||
if r.Method != http.MethodPost {
|
||||
@@ -230,8 +219,7 @@ func (p *oauthProxy) handleStart(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Generate independent trust-domain values outside the map lock —
|
||||
// crypto/rand syscalls must not serialize concurrent /auth/start calls.
|
||||
// Generate trust-domain values outside the map lock; crypto/rand can block.
|
||||
var pollSecret string
|
||||
var sess *oauthSession
|
||||
for {
|
||||
@@ -270,7 +258,7 @@ func (p *oauthProxy) handleStart(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GET /auth/:service?state=X → 302 upstream authorize URL
|
||||
// GET /auth/:service?state=X redirects to the upstream authorize URL.
|
||||
func (p *oauthProxy) handleAuthorize(w http.ResponseWriter, r *http.Request, service string) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
@@ -306,7 +294,7 @@ func (p *oauthProxy) handleAuthorize(w http.ResponseWriter, r *http.Request, ser
|
||||
http.Redirect(w, r, cfg.AuthorizeURL+"?"+q.Encode(), http.StatusFound)
|
||||
}
|
||||
|
||||
// GET /auth/:service/callback?code=...&state=... → exchange, park, render page
|
||||
// GET /auth/:service/callback exchanges the code and renders a result page.
|
||||
func (p *oauthProxy) handleCallback(w http.ResponseWriter, r *http.Request, service string) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
@@ -368,7 +356,7 @@ func (p *oauthProxy) handleCallback(w http.ResponseWriter, r *http.Request, serv
|
||||
renderSuccessPage(w)
|
||||
}
|
||||
|
||||
// GET /auth/result?session=X → long-poll, returns one terminal result.
|
||||
// GET /auth/result?session=X long-polls for one terminal result.
|
||||
func (p *oauthProxy) handleResult(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store, private")
|
||||
if r.Method != http.MethodGet {
|
||||
@@ -403,7 +391,7 @@ func (p *oauthProxy) handleResult(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// GET /auth/done — static success page (Simkl's redirect target).
|
||||
// GET /auth/done renders the static OAuth success page.
|
||||
func (p *oauthProxy) handleDone(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
@@ -464,15 +452,13 @@ func (p *oauthProxy) redirectURI(service string) string {
|
||||
return fmt.Sprintf("%s/auth/%s/callback", p.baseURL, service)
|
||||
}
|
||||
|
||||
// addSessionLocked installs both independently generated keys as one logical
|
||||
// session. The caller has already verified that neither key is live.
|
||||
// addSessionLocked installs both keys for one session after collision checks.
|
||||
func (p *oauthProxy) addSessionLocked(sess *oauthSession) {
|
||||
p.browserStates[sess.browserState] = sess
|
||||
p.pollDigests[sess.pollDigest] = sess
|
||||
}
|
||||
|
||||
// removeSessionLocked removes only entries still owned by sess, so a stale
|
||||
// callback or waiter cannot remove a replacement.
|
||||
// removeSessionLocked removes only entries still owned by sess.
|
||||
func (p *oauthProxy) removeSessionLocked(sess *oauthSession) {
|
||||
if p.browserStates[sess.browserState] == sess {
|
||||
delete(p.browserStates, sess.browserState)
|
||||
@@ -510,7 +496,7 @@ func (p *oauthProxy) claimResult(digest [sha256.Size]byte, sess *oauthSession) (
|
||||
return result, true
|
||||
}
|
||||
|
||||
// cleanup drops sessions past oauthSessionTTL. Called by the main cleanup loop.
|
||||
// cleanup drops sessions past oauthSessionTTL.
|
||||
func (p *oauthProxy) cleanup() {
|
||||
now := time.Now()
|
||||
p.mu.Lock()
|
||||
@@ -539,7 +525,7 @@ func (p *oauthProxy) ipAllow(ip string) bool {
|
||||
|
||||
const successPageHTML = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Signed in</title><style>html,body{margin:0;height:100%}body{display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:-apple-system,system-ui,sans-serif;background:#fff;color:#1a1a1a;text-align:center;padding:1em;box-sizing:border-box}@media(prefers-color-scheme:dark){body{background:#0f0f0f;color:#f5f5f5}}.check{width:72px;height:72px;margin-bottom:20px}h2{margin:0 0 8px;font-weight:600;font-size:1.25rem}p{margin:0;opacity:.7;font-size:.95rem}</style><body><svg class="check" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="#22c55e"/><path d="M7 12.5l3 3 7-7" stroke="#fff" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg><h2>Signed in to Plezy</h2><p>You can close this tab and return to the app.</p></body>`
|
||||
|
||||
// Split around the message so CSS `%` literals don't collide with Fprintf verbs.
|
||||
// Keep CSS percent literals outside Fprintf's format string.
|
||||
const errorPagePrefix = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Sign-in failed</title><style>html,body{margin:0;height:100%}body{display:flex;flex-direction:column;align-items:center;justify-content:center;font-family:-apple-system,system-ui,sans-serif;background:#fff;color:#1a1a1a;text-align:center;padding:1em;box-sizing:border-box}@media(prefers-color-scheme:dark){body{background:#0f0f0f;color:#f5f5f5}}.x{width:72px;height:72px;margin-bottom:20px}h2{margin:0 0 8px;font-weight:600;font-size:1.25rem}p{margin:0;opacity:.7;font-size:.95rem}</style><body><svg class="x" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" fill="#ef4444"/><path d="M8 8l8 8M16 8l-8 8" stroke="#fff" stroke-width="2" fill="none" stroke-linecap="round"/></svg><h2>Sign-in failed</h2><p>`
|
||||
const errorPageSuffix = `</p></body>`
|
||||
|
||||
@@ -570,15 +556,12 @@ func digestPollSecret(secret string) [sha256.Size]byte {
|
||||
func randToken(numBytes int) string {
|
||||
b := make([]byte, numBytes)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// crypto/rand failing is catastrophic; log.Fatalf matches the style
|
||||
// in newLogStore for similar unrecoverable init failures.
|
||||
log.Fatalf("crypto/rand: %v", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// randPKCEVerifier returns a 64-char string from MAL's required alphabet
|
||||
// (RFC 7636 §4.1 unreserved set).
|
||||
// randPKCEVerifier returns MAL's RFC 7636 §4.1 verifier alphabet.
|
||||
func randPKCEVerifier() string {
|
||||
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||
b := make([]byte, oauthPKCEVerifierLen)
|
||||
|
||||
+8
-23
@@ -15,9 +15,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// mockUpstream runs an httptest server that impersonates MAL/AniList. It
|
||||
// records the last token-exchange form submission and returns a canned
|
||||
// access_token/refresh_token response.
|
||||
// mockUpstream records token exchanges and returns a canned response.
|
||||
type mockUpstream struct {
|
||||
srv *httptest.Server
|
||||
mu sync.Mutex
|
||||
@@ -26,9 +24,7 @@ type mockUpstream struct {
|
||||
tokenCode int
|
||||
}
|
||||
|
||||
// httpGet / httpPost / httpDo wrap the stdlib calls to fail the test on error.
|
||||
// Keeps test bodies one-liner without tripping `go vet`'s
|
||||
// "using resp before checking errors" rule.
|
||||
// Request helpers fail tests on client errors.
|
||||
func httpGet(t *testing.T, url string) *http.Response {
|
||||
t.Helper()
|
||||
resp, err := http.Get(url)
|
||||
@@ -65,7 +61,7 @@ func newMockUpstream(t *testing.T) *mockUpstream {
|
||||
m.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/oauth/authorize":
|
||||
// Unused in tests — we assert on the 302 Location from our proxy.
|
||||
// Authorization is asserted on the proxy's redirect.
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case "/oauth/token":
|
||||
if err := r.ParseForm(); err != nil {
|
||||
@@ -101,8 +97,7 @@ func (m *mockUpstream) form() url.Values {
|
||||
return m.lastForm
|
||||
}
|
||||
|
||||
// newOAuthHarness boots a relay-less httptest server that mounts /auth/* only,
|
||||
// with `mal` and `anilist` services pointed at a shared mock upstream.
|
||||
// newOAuthHarness mounts /auth/* against shared mock providers.
|
||||
type oauthHarness struct {
|
||||
proxy *oauthProxy
|
||||
srv *httptest.Server
|
||||
@@ -137,7 +132,7 @@ func newOAuthHarnessWithResolver(t *testing.T, clientIPs clientIPResolver) *oaut
|
||||
registerOAuthRoutes(mux, proxy)
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
// Rewire baseURL to the real httptest URL so redirect_uri computes correctly.
|
||||
// Use the real server URL when constructing redirect_uri.
|
||||
proxy.baseURL = srv.URL
|
||||
return &oauthHarness{proxy: proxy, srv: srv, base: srv.URL, upstream: up}
|
||||
}
|
||||
@@ -197,7 +192,6 @@ func postOAuthStart(t *testing.T, h *oauthHarness, service, xff string) *http.Re
|
||||
return httpDo(t, req)
|
||||
}
|
||||
|
||||
// ====== /auth/start ======
|
||||
|
||||
func TestOAuthStartSeparatesDeviceCapabilityFromBrowserState(t *testing.T) {
|
||||
h := newOAuthHarness(t)
|
||||
@@ -247,7 +241,7 @@ func TestOAuthStartRateLimitedPerIP(t *testing.T) {
|
||||
h := newOAuthHarness(t)
|
||||
ip := "5.5.5.5"
|
||||
for range oauthStartBurst {
|
||||
_, _, _ = h.startSession(t, "mal", ip) // should all succeed
|
||||
_, _, _ = h.startSession(t, "mal", ip)
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{"service": "mal"})
|
||||
req, err := http.NewRequest(http.MethodPost, h.base+"/auth/start", bytes.NewReader(body))
|
||||
@@ -364,7 +358,6 @@ func TestOAuthStartMethodNotAllowed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ====== /auth/:service (authorize redirect) ======
|
||||
|
||||
func TestOAuthAuthorizeMALRedirectIncludesPKCE(t *testing.T) {
|
||||
h := newOAuthHarness(t)
|
||||
@@ -439,7 +432,7 @@ func TestOAuthAuthorizeUnknownSessionRendersError(t *testing.T) {
|
||||
func TestOAuthAuthorizeWrongServiceRejected(t *testing.T) {
|
||||
h := newOAuthHarness(t)
|
||||
_, browserState, _ := h.startSession(t, "mal", "1.1.1.3")
|
||||
// Try to use the MAL browser state against the AniList authorize endpoint.
|
||||
// A browser state is valid only for its original service.
|
||||
resp := httpGet(t, h.base+"/auth/anilist?state="+url.QueryEscape(browserState))
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
@@ -447,7 +440,6 @@ func TestOAuthAuthorizeWrongServiceRejected(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ====== /auth/:service/callback + /auth/result ======
|
||||
|
||||
type oauthResultResponse struct {
|
||||
status int
|
||||
@@ -693,7 +685,6 @@ func TestOAuthCallbackUnknownSessionIgnored(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ====== Cleanup ======
|
||||
|
||||
func TestOAuthCleanupRemovesBothIndexesAndSuppressesStaleCompletion(t *testing.T) {
|
||||
h := newOAuthHarness(t)
|
||||
@@ -725,7 +716,6 @@ func TestOAuthCleanupRemovesBothIndexesAndSuppressesStaleCompletion(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
// ====== /auth/done ======
|
||||
|
||||
func TestOAuthDoneRendersSuccessPage(t *testing.T) {
|
||||
h := newOAuthHarness(t)
|
||||
@@ -740,7 +730,6 @@ func TestOAuthDoneRendersSuccessPage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ====== Disabled proxy returns 503 ======
|
||||
|
||||
func TestOAuthRoutesReturn503WhenDisabled(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
@@ -755,7 +744,6 @@ func TestOAuthRoutesReturn503WhenDisabled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ====== Path dispatch ======
|
||||
|
||||
func TestOAuthAuthRootRejectsBadPaths(t *testing.T) {
|
||||
h := newOAuthHarness(t)
|
||||
@@ -768,12 +756,9 @@ func TestOAuthAuthRootRejectsBadPaths(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ====== Long-poll timeout ======
|
||||
|
||||
func TestOAuthResultBlocksUntilCancel(t *testing.T) {
|
||||
// Pending sessions must NOT respond immediately; the long-poll contract is
|
||||
// that /auth/result blocks until the session completes or the client
|
||||
// cancels. The 204-after-server-timeout path takes 50s so isn't asserted.
|
||||
// Pending sessions must block until completion or client cancellation.
|
||||
h := newOAuthHarness(t)
|
||||
sess, _, _ := h.startSession(t, "mal", "5.5.5.1")
|
||||
|
||||
|
||||
+5
-13
@@ -5,7 +5,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// --- Rate limiter (token bucket) ---
|
||||
|
||||
type rateLimiter struct {
|
||||
tokens float64
|
||||
@@ -65,8 +64,7 @@ func (rl *rateLimiter) refillAtLocked(now time.Time) {
|
||||
}
|
||||
}
|
||||
|
||||
// reclaimable reports whether discarding this limiter would preserve its
|
||||
// behavior: enough idle time has passed for the bucket to be full again.
|
||||
// reclaimable is true when the bucket has refilled completely.
|
||||
func (rl *rateLimiter) reclaimable(now time.Time) bool {
|
||||
rl.mu.Lock()
|
||||
defer rl.mu.Unlock()
|
||||
@@ -90,7 +88,6 @@ func cleanupRateWindows(windows map[string]time.Time, now time.Time, duration ti
|
||||
}
|
||||
}
|
||||
|
||||
// --- Poster upload admission (global, per-IP, and concurrency) ---
|
||||
|
||||
type posterUploadLimiter struct {
|
||||
mu sync.Mutex
|
||||
@@ -152,7 +149,6 @@ func (pl *posterUploadLimiter) cleanup(now time.Time) {
|
||||
cleanupRateLimiters(pl.perIP, now, nil)
|
||||
}
|
||||
|
||||
// --- Connection tracker (per-IP limits) ---
|
||||
|
||||
type connTracker struct {
|
||||
mu sync.Mutex
|
||||
@@ -186,8 +182,7 @@ func (ct *connTracker) tryConnect(ip string) bool {
|
||||
rl = newRateLimiter(connRateBurst, connRateSustained)
|
||||
ct.ipRate[ip] = rl
|
||||
}
|
||||
// Unlock ct.mu before calling rl.allow() would be cleaner,
|
||||
// but since rl has its own mutex this is safe (no deadlock).
|
||||
// rl has its own mutex, so holding ct.mu here cannot deadlock.
|
||||
if !rl.allow() {
|
||||
return false
|
||||
}
|
||||
@@ -210,9 +205,7 @@ func (ct *connTracker) disconnect(ip string) {
|
||||
}
|
||||
}
|
||||
|
||||
// tryCreateRoom reserves capacity for a retained room created in this process.
|
||||
// The reservation survives creator disconnect and is released only when the
|
||||
// authoritative room is removed from Server.rooms.
|
||||
// tryCreateRoom reserves capacity until the retained room is removed.
|
||||
func (ct *connTracker) tryCreateRoom(ip string) bool {
|
||||
ct.mu.Lock()
|
||||
defer ct.mu.Unlock()
|
||||
@@ -223,9 +216,8 @@ func (ct *connTracker) tryCreateRoom(ip string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// tryCreateRoomReplacing reserves a room while accounting for the reservation
|
||||
// that removeRoomLocked will immediately release from an empty same-ID room.
|
||||
// Server.mu serializes this paired reservation/removal transaction.
|
||||
// tryCreateRoomReplacing accounts for removal of an empty same-ID room.
|
||||
// Server.mu serializes the reservation/removal transaction.
|
||||
func (ct *connTracker) tryCreateRoomReplacing(ip, replacedOwnerKey string) bool {
|
||||
ct.mu.Lock()
|
||||
defer ct.mu.Unlock()
|
||||
|
||||
Reference in New Issue
Block a user