proxy

package
v0.4.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 18, 2026 License: MIT Imports: 33 Imported by: 0

Documentation

Overview

Package proxy provides an HTTPS reverse proxy with subdomain-based routing.

Package proxy provides HTTP/HTTPS reverse proxy with subdomain-based routing. It allows mapping subdomains to local ports (e.g., app.local.dev:6789 → localhost:3000).

Index

Constants

This section is empty.

Variables

View Source
var ErrPortInUse = errors.New("port already in use")

ErrPortInUse is returned when a proxy port is already bound by another process.

Functions

func CaptureAllowedDirs added in v0.2.0

func CaptureAllowedDirs(primaryDir string) []string

CaptureAllowedDirs builds the FilePath allowlist passed to LoadCapturedBody: primaryDir (the caller's own capture dir, skipped when empty) plus the shared daemon capture dir under the user's home (skipped when the home dir cannot be resolved). Centralizing keeps the "the daemon capture dir is always allowed" policy in one place rather than duplicated across the api and tui callers.

func FinalizeRequestBody added in v0.2.0

func FinalizeRequestBody(rc io.ReadCloser)

FinalizeRequestBody forces finalization of a request body previously wrapped by CaptureRequest. Idempotent; a non-wrapped body is a no-op. Proxy handlers call this after the reverse proxy returns and BEFORE recording, so the CapturedBody snapshot is complete when the record is published (SSE subscribers serialize records at notify time). Without it, a canceled request's transport goroutine may still be draining the body, and its later Close-triggered finalize would race the serialization; after this call that finalize is a no-op and late writes are discarded.

func GenerateRequestID added in v0.2.0

func GenerateRequestID(timestamp time.Time, method, url string) string

GenerateRequestID creates a short hash ID (12 chars, git-style) from request data plus a per-process counter, so IDs are unique within a process even for simultaneous identical requests. (Truncating the hash to 48 bits keeps the birthday-collision residual across a DefaultProxyRequestBufferSize-record ring negligible, unlike the 28 bits of a 7-char ID whose ~0.2%-per-full-ring collision odds could overwrite capture files.) Exported so the shared daemon can generate a request ID before proxying (needed for capture file naming).

func LoadCapturedBody added in v0.2.0

func LoadCapturedBody(body *CapturedBody, allowedDirs []string) ([]byte, error)

LoadCapturedBody returns a captured body's raw retained bytes, reading from disk when the body was spilled to a FilePath.

  • A nil body yields (nil, nil).
  • A body marked Evicted (its data was dropped when the record left the ring's detail window, D9b) yields an os.ErrNotExist-wrapped error, so it travels the same "evicted" path as a body whose spilled file is gone rather than masquerading as an empty body.
  • An inline body returns a copy of Data (callers must not mutate the record).
  • A FilePath body MUST resolve within one of allowedDirs; a path that escapes every allowed directory is rejected with an error rather than read. This prevents a socket-supplied path from exfiltrating arbitrary files through the project API.
  • os.ReadFile errors (missing/evicted file) propagate to the caller.

Types

type CaptureManager

type CaptureManager struct {
	// contains filtered or unexported fields
}

CaptureManager handles request/response body capture with hybrid memory/disk storage.

func NewCaptureManager

func NewCaptureManager(cfg *config.CaptureConfig, workDir string) (*CaptureManager, error)

NewCaptureManager creates a new capture manager. If cfg is nil or capture is not enabled, returns a manager that does nothing.

This constructor treats workDir as a WORK directory: the capture directory is derived as workDir/.prox/capture. Callers that already hold an exact capture directory (e.g. the shared daemon, whose capture dir is ~/.prox/capture) must use NewCaptureManagerAt instead to avoid a doubled ".prox/capture" suffix.

func NewCaptureManagerAt added in v0.2.0

func NewCaptureManagerAt(captureDir string, maxBodySize int64) (*CaptureManager, error)

NewCaptureManagerAt creates an enabled capture manager rooted at an EXACT capture directory (no ".prox/capture" suffix is appended). It is the shared setup that NewCaptureManager delegates to once it has resolved the capture directory and body-size limit. Any existing files under captureDir are removed (previous-run cleanup) and the directory is created.

func (*CaptureManager) CaptureDir added in v0.2.0

func (cm *CaptureManager) CaptureDir() string

CaptureDir returns the directory where captured body files are stored, or the empty string when capture is disabled. Used by consumers building the LoadCapturedBody allowlist.

func (*CaptureManager) CaptureRequest

func (cm *CaptureManager) CaptureRequest(requestID string, r *http.Request, maxBodySize int64) (*CapturedBody, io.ReadCloser, http.Header)

CaptureRequest captures the request body using a TeeReader bounded by maxBodySize (D13, #49) and returns the cloned request headers: the daemon passes the matched route's cap so each project honors its own limit through the one shared capture dir; the standalone proxy passes 0, which falls back to the manager's configured cap (see effectiveLimit).

Even a bodyless request (every GET/HEAD, whose headers still reach the stored Details) has its headers cloned and returned here.

Returns the captured body info and a new ReadCloser to use in place of the original body; reading the returned ReadCloser also captures the data.

func (*CaptureManager) Cleanup

func (cm *CaptureManager) Cleanup() error

Cleanup removes the entire capture directory. It takes the accountant lock so the whole-dir removal and the accounting reset are atomic against concurrent spills (#69): a store() that raced in first is fully accounted, and one that races AFTER this fails its write with ENOENT (the dir is gone) → the failed-write inline fallback, never a tracked FilePath into a directory that no longer exists. groups/diskUsed are reset to zero so DiskUsed() does not go stale after cleanup.

func (*CaptureManager) CleanupRequest

func (cm *CaptureManager) CleanupRequest(requestID string)

CleanupRequest removes disk files associated with a specific request, routing through the accountant (#69) so the freed bytes are subtracted from capture_disk_used. Idempotent — a record already budget-evicted is a safe no-op — so a ring eviction or PurgeByProject following a budget eviction does not double-count.

func (*CaptureManager) DiskBudget added in v0.2.3

func (cm *CaptureManager) DiskBudget() int64

DiskBudget returns the current effective capture disk budget in bytes (#69).

func (*CaptureManager) DiskStats added in v0.2.3

func (cm *CaptureManager) DiskStats() (used, budget int64)

DiskStats returns capture_disk_used and capture_disk_budget read under ONE accountant lock (#69), so a consumer never publishes a used/budget pair that never coexisted. The daemon /status handler uses this rather than separate DiskUsed()/DiskBudget() calls.

func (*CaptureManager) DiskUsed added in v0.2.3

func (cm *CaptureManager) DiskUsed() int64

DiskUsed returns the total logical bytes of spilled capture body files currently accounted (capture_disk_used, #69).

func (*CaptureManager) Enabled

func (cm *CaptureManager) Enabled() bool

Enabled returns whether capture is enabled.

func (*CaptureManager) FinalizeResponse added in v0.2.0

func (cm *CaptureManager) FinalizeResponse(requestID string, crw *CaptureResponseWriter) (*CapturedBody, http.Header)

FinalizeResponse captures the response body from a CaptureResponseWriter and returns the cloned response headers. Should be called after the response has been fully written. A disabled manager stores nothing but still returns the header clone.

func (*CaptureManager) LoadBody

func (cm *CaptureManager) LoadBody(body *CapturedBody) ([]byte, error)

LoadBody loads a captured body's data, reading from disk if necessary. Returns a copy of the data to prevent callers from modifying the original. FilePath bodies are constrained to the manager's own capture directory via LoadCapturedBody's allowlist.

func (*CaptureManager) SetDiskBudget added in v0.2.3

func (cm *CaptureManager) SetDiskBudget(budget int64)

SetDiskBudget updates the capture disk budget and enforces it immediately (#69): a budget-lowering re-register (or standalone construction) evicts oldest record groups until the total spilled bytes fit, without waiting for the next spill. A non-positive budget resets to DefaultCaptureDiskBudget.

func (*CaptureManager) WrapResponseWriter added in v0.2.0

func (cm *CaptureManager) WrapResponseWriter(w http.ResponseWriter, maxBodySize int64) *CaptureResponseWriter

WrapResponseWriter wraps w in a CaptureResponseWriter that records up to maxBodySize bytes (D13, #49) while forwarding all writes downstream: the daemon passes the matched route's cap so each project honors its own quota, and a maxBodySize of 0 falls back to the manager's configured cap (see effectiveLimit). The returned writer preserves http.Flusher/Hijacker/Pusher/Unwrap behavior.

type CaptureResponseWriter added in v0.2.0

type CaptureResponseWriter struct {
	http.ResponseWriter
	// contains filtered or unexported fields
}

CaptureResponseWriter wraps an http.ResponseWriter to capture the response body. It intercepts writes to capture up to maxBodySize bytes while still forwarding all data to the underlying ResponseWriter. It also implements http.Flusher, http.Hijacker, and http.Pusher for compatibility with streaming and WebSocket connections.

func (*CaptureResponseWriter) CapturedBody added in v0.2.0

func (crw *CaptureResponseWriter) CapturedBody() []byte

CapturedBody returns the captured response body.

func (*CaptureResponseWriter) Flush added in v0.2.0

func (crw *CaptureResponseWriter) Flush()

Flush implements http.Flusher for streaming responses (SSE).

func (*CaptureResponseWriter) Hijack added in v0.2.0

func (crw *CaptureResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error)

Hijack implements http.Hijacker for WebSocket support.

func (*CaptureResponseWriter) Hijacked added in v0.2.0

func (crw *CaptureResponseWriter) Hijacked() bool

Hijacked reports whether the connection was taken over (WebSocket upgrade). After a hijack all traffic bypasses this writer, so the captured status/body do not describe the response — callers should record metadata only rather than finalize garbage Details. Single-goroutine access per the http.ResponseWriter contract.

func (*CaptureResponseWriter) Push added in v0.2.0

func (crw *CaptureResponseWriter) Push(target string, opts *http.PushOptions) error

Push implements http.Pusher for HTTP/2 server push.

func (*CaptureResponseWriter) SetFirstResponseCallback added in v0.2.0

func (h *CaptureResponseWriter) SetFirstResponseCallback(fn func(statusCode int))

SetFirstResponseCallback registers a callback that fires exactly once at the first FINAL response event (see fireFirstResponse).

func (*CaptureResponseWriter) StatusCode added in v0.2.0

func (crw *CaptureResponseWriter) StatusCode() int

StatusCode returns the captured status code.

func (*CaptureResponseWriter) TotalSeen added in v0.2.0

func (crw *CaptureResponseWriter) TotalSeen() int64

TotalSeen returns the total number of bytes observed by Write, counting bytes that were not retained after truncation.

func (*CaptureResponseWriter) Truncated added in v0.2.0

func (crw *CaptureResponseWriter) Truncated() bool

Truncated returns whether the body was truncated.

func (*CaptureResponseWriter) Unwrap added in v0.2.0

Unwrap returns the underlying ResponseWriter for Go 1.20+ http.ResponseController compatibility.

func (*CaptureResponseWriter) Write added in v0.2.0

func (crw *CaptureResponseWriter) Write(p []byte) (int, error)

func (*CaptureResponseWriter) WriteHeader added in v0.2.0

func (crw *CaptureResponseWriter) WriteHeader(code int)

type CapturedBody

type CapturedBody struct {
	// Size is the total bytes observed by the capture wrapper, including
	// bytes discarded past the truncation cap (not Content-Length, not
	// decoded size).
	Size            int64  `json:"size"`
	CapturedSize    int64  `json:"captured_size"`              // Bytes actually retained after truncation
	Truncated       bool   `json:"truncated"`                  // True if body was truncated due to size limit
	ContentType     string `json:"content_type"`               // Content-Type header value
	ContentEncoding string `json:"content_encoding,omitempty"` // Content-Encoding header value (raw wire bytes; not decoded here)
	IsBinary        bool   `json:"is_binary"`                  // True if body appears to be binary data
	Data            []byte `json:"data"`                       // Inline data for small bodies
	FilePath        string `json:"file_path"`                  // Disk path for large bodies (Data is nil when set)

	// Evicted marks a body whose DATA is gone but whose metadata (and the
	// record's headers) is retained: the record fell outside the ring's
	// captured-body detail window (constants.ProxyRequestDetailWindow, D9b), so
	// Data was dropped and any spilled file unlinked. LoadCapturedBody turns
	// this into an os.ErrNotExist-wrapped error so it flows through the SAME
	// path as a disk-budget-evicted file and reports unavailable_reason
	// "evicted" — callers need no new case. It crosses the daemon→forwarder
	// wire so a backfilled record arrives already marked.
	Evicted bool `json:"evicted,omitempty"`
}

CapturedBody represents a captured request or response body.

type DecodedBody added in v0.2.0

type DecodedBody struct {
	// Data is the bytes to serve. For a supported, successful decode these are
	// the decoded bytes; otherwise they are the raw retained bytes.
	Data []byte
	// IsBinary reflects the SERVED bytes (post-decode). It may legitimately
	// diverge from CapturedBody.IsBinary, which describes the raw wire bytes.
	IsBinary bool
	// ContentEncoding is the stored (lowercased/trimmed) content-encoding token,
	// empty for identity/unencoded bodies.
	ContentEncoding string
	// Available is false when the body could not be loaded (e.g. its disk file
	// was evicted). When false, Data is nil and UnavailableReason explains why.
	Available bool
	// UnavailableReason is a short machine-readable reason set only when
	// Available is false (e.g. "evicted").
	UnavailableReason string
}

DecodedBody is the structured result of loading and content-decoding a captured body. It carries decoded (serve-ready) bytes plus the semantics a caller needs to render them safely.

func DecodeCapturedBody added in v0.2.0

func DecodeCapturedBody(body *CapturedBody, raw []byte) DecodedBody

DecodeCapturedBody content-decodes raw (per body.ContentEncoding) and returns serve-ready bytes with post-decode binary semantics.

Supported encodings are gzip/x-gzip, deflate (zlib-wrapped per RFC 9110, with a fallback to raw deflate for servers that send it unwrapped), zstd, and br (case-insensitive, surrounding whitespace tolerated); identity/empty means no decode. Any other token (chained values like "gzip, br"), a truncated body, a decode failure, or a decoded size exceeding the cap all fall back to serving the raw bytes with IsBinary=true so a JSON string conversion cannot mangle them.

func LoadDecodedBody added in v0.2.0

func LoadDecodedBody(body *CapturedBody, allowedDirs []string) (DecodedBody, error)

LoadDecodedBody composes LoadCapturedBody + DecodeCapturedBody.

A nil body yields an unavailable result. A body whose data is simply gone — a missing capture file (the record is still valid, but its FilePath body was evicted/removed) or a body the ring marked Evicted on leaving the detail window (D9b) — is treated as a benign condition (D7): the result is marked unavailable with reason "evicted" and a nil error, so the caller returns HTTP 200 with no data rather than failing the request. Both cases are detected as errors.Is(err, fs.ErrNotExist) — not os.IsNotExist, which does not unwrap %w-wrapped sentinels. Any other load failure (e.g. an out-of-allowlist path or an I/O error) is marked unavailable with reason "unavailable" and returned together with the underlying error so callers can log it.

type EvictionCallback

type EvictionCallback func(id string)

EvictionCallback is called when a request is evicted from the ring buffer. It receives the request ID for cleanup purposes.

type PortConflictError

type PortConflictError struct {
	Port     int
	Protocol string // "HTTP" or "HTTPS"
	Cause    error  // original net.Listen error
}

PortConflictError carries metadata about which port and protocol conflicted. It wraps both the ErrPortInUse sentinel and the original OS error so that errors.Is works for either (Go 1.20+ multi-unwrap).

func (*PortConflictError) Error

func (e *PortConflictError) Error() string

func (*PortConflictError) Unwrap

func (e *PortConflictError) Unwrap() []error

type RequestDetails

type RequestDetails struct {
	RequestHeaders  map[string][]string `json:"request_headers,omitempty"`
	ResponseHeaders map[string][]string `json:"response_headers,omitempty"`
	RequestBody     *CapturedBody       `json:"request_body,omitempty"`
	ResponseBody    *CapturedBody       `json:"response_body,omitempty"`
}

RequestDetails contains captured request/response headers and bodies.

type RequestFilter

type RequestFilter struct {
	Subdomain string
	Hostnames []string // match if record.Hostname is in this list (empty = match all)

	// URLContains matches if record.URL contains this substring
	// (case-insensitive). URL is path+query only (no scheme/host, matching
	// the TUI 's' filter's reference behavior), since RequestRecord.URL is
	// populated from r.URL.String() on the server side. Empty = match all.
	URLContains string
	ProjectDir  string // match if record.ProjectDir equals this exactly (empty = match all)
	Method      string
	MinStatus   int
	MaxStatus   int
	Since       time.Time
	Limit       int

	// BeforeID anchors RecentPage (and, transitively, Recent) at the ring
	// position of the record with this ID: only records strictly OLDER than
	// the anchor, BY RING POSITION, are considered (D12, #50). This is
	// arrival order, not time order. Backfill interleavings and
	// completion-after-eviction re-appends (see Upsert: an in-flight record
	// whose original slot was evicted re-enters the ring at the newest
	// position when its completion arrives) mean a record's ring position
	// can diverge from its Timestamp — a page can legitimately contain a
	// record with a newer Timestamp than the anchor. Empty = no anchor
	// (start at the newest record, i.e. today's Recent behavior).
	BeforeID string
}

RequestFilter specifies criteria for filtering requests.

type RequestManager

type RequestManager struct {
	// contains filtered or unexported fields
}

RequestManager tracks proxied requests in a ring buffer and supports subscriptions.

func NewReplicaRequestManager added in v0.3.0

func NewReplicaRequestManager(capacity int) *RequestManager

NewReplicaRequestManager creates a request manager for a ring that REPLICATES records captured elsewhere (the forwarder-fed project-local ring in shared mode) rather than owning capture itself. It runs the timestamp-ordered body window (bodyWindow) instead of the position-ordered detail window, bounding retained INLINE body data at constants.ProxyRequestDetailWindow records — parity with a capture-owning ring's inline worst case.

A replica needs its own bound because upstream stripping does not reach it: the daemon's detail window publishes no event when it drops a body, so a record forwarded LIVE arrives with its body and keeps it locally forever, long after the daemon has evicted it. Only BACKFILL-delivered records past the daemon's window arrive already marked CapturedBody.Evicted.

The window is ordered by the daemon-supplied Timestamp, not by ring position, because position on a replica is not recency: the forwarder's backfill deliberately races the live event stream and Upsert treats a final record as terminal, so a record delivered live BEFORE its backfill copy sinks toward the ring's oldest position while still being one of the newest requests. A position window would strip its body while the daemon still serves it; timestamp order ranks it correctly, because Timestamp is set once daemon-side and rides the wire verbatim. That is a bounded approximation of daemon truth, not a mirror of it — a clock step can change WHICH record is stripped, never HOW MANY retain data.

Spilled (FilePath) bodies are exempt: they cost this ring no memory and the daemon's disk truth is authoritative on load. See the bodyWindow field.

func NewRequestManager

func NewRequestManager(capacity int) *RequestManager

NewRequestManager creates a new request manager with the specified buffer capacity and the default captured-body detail window (constants.ProxyRequestDetailWindow).

func (*RequestManager) Close

func (m *RequestManager) Close()

Close closes all subscription channels and cleans up resources. It latches: subsequent Subscribe calls receive an already-closed channel, so a stream request racing a shutdown-time Close cannot re-subscribe and pin the API server open. Idempotent.

func (*RequestManager) Count

func (m *RequestManager) Count() int

Count returns the number of requests currently in the buffer.

func (*RequestManager) DroppedEvents added in v0.2.1

func (m *RequestManager) DroppedEvents() int64

DroppedEvents returns the manager-wide number of subscriber notifications dropped because a subscription's channel was full (D9). It is monotonic for the life of the manager.

func (*RequestManager) GetByID

func (m *RequestManager) GetByID(id string) (RequestRecord, bool)

GetByID returns a request record by its ID. Returns the record and true if found, or an empty record and false if not found.

func (*RequestManager) PurgeByProject added in v0.2.0

func (m *RequestManager) PurgeByProject(projectDir string)

PurgeByProject removes all records owned by the given project from the ring buffer and calls the eviction callback for each purged record that carried captured Details (so its on-disk body files get cleaned up). It compacts the buffer to preserve the contiguous ring invariant. Scoping by project (not hostname) ensures two projects sharing a hostname on different ports don't purge each other's records.

The compaction needs no detail-window work (D9b): it preserves arrival order and only removes records, so a surviving record's newest→oldest offset can only shrink. Records can move INTO the window (they stay stripped — evicted bodies are gone for good) but never out of it, so no new violation is created. The replica's timestamp-ordered body window is keyed by SLOT rather than by offset, so compaction DOES invalidate it; it is rebuilt below.

func (*RequestManager) Recent

func (m *RequestManager) Recent(filter RequestFilter) []RequestRecord

Recent returns the most recent requests matching the filter. It is RecentPage without the cursor metadata, kept as the simple signature for the many callers (CLI, daemon-internal endpoints, tests) that don't page.

func (*RequestManager) RecentPage added in v0.2.1

func (m *RequestManager) RecentPage(filter RequestFilter) (records []RequestRecord, nextBeforeID string, anchorFound bool)

RecentPage returns up to filter.Limit matching records, newest first, optionally anchored at filter.BeforeID (ring-position cursor pagination, D12/#50). It is the shared scan behind Recent.

nextBeforeID is the ID of the OLDEST SCANNED record in this call's scan window — not the oldest RETURNED record. The scan continues past non-matching records (up to the ring's oldest record) until it collects filter.Limit matches, so a page whose filter excludes everything remaining still advances all the way through the ring and reports where it stopped, rather than stalling a poller that keeps re-requesting the same cursor. nextBeforeID is empty when the scan reached the ring's oldest record (no older records remain: this is the last page).

anchorFound is true when filter.BeforeID is empty (no anchor requested) or names a record that both exists in the ring and matches the rest of filter. An anchor that is unknown, evicted, or excluded by scope (e.g. a different filter.ProjectDir than the anchor's) reports anchorFound=false with no records — callers must treat both cases identically (410 Gone) so an out-of-scope anchor can't be distinguished from a nonexistent one and leak the other scope's record existence.

func (*RequestManager) Record

func (m *RequestManager) Record(record RequestRecord) bool

Record adds a new request record to the buffer and notifies subscribers. If the record doesn't have an ID, one is generated. It reports whether the record was accepted: false means the manager was already Closed (see writesClosed) and the caller owns any capture-file cleanup for the record.

func (*RequestManager) SetEvictionCallback

func (m *RequestManager) SetEvictionCallback(fn EvictionCallback)

SetEvictionCallback sets the callback to be invoked when requests are evicted.

func (*RequestManager) Subscribe

func (m *RequestManager) Subscribe(filter RequestFilter) *RequestSubscription

Subscribe creates a subscription for real-time request updates. After Close, the returned subscription's channel is already closed, so an SSE handler that races the shutdown-time Close observes end-of-stream immediately instead of blocking on a channel nothing will ever close.

func (*RequestManager) Unsubscribe

func (m *RequestManager) Unsubscribe(id string)

Unsubscribe removes a subscription.

func (*RequestManager) Upsert added in v0.2.0

func (m *RequestManager) Upsert(record RequestRecord) bool

Upsert applies a record as a monotonic two-state transition keyed by ID:

existing absent               → append (Record's eviction logic) + notify
existing in-flight, incoming in-flight → no-op (duplicate delivery)
existing in-flight, incoming final     → replace in place + notify
existing final                → no-op (final is terminal)

The no-op rows make concurrent interleavings safe: replaying a snapshot while live stream events apply converges to the final record in any order, with no duplicate or regressed notifications. Replace-in-place keeps the ring slot (no head/count change, no ring eviction) — safe because in-flight records carry no Details, so the transition only ever adds capture state. The one exception is the captured-body windows: a completion landing in a slot that has already aged out of the POSITION window (D9b) has its bodies stripped before it is stored, so a slow request cannot reintroduce body data outside the window, and on a replica the completion joins the timestamp window (bodyWindow) and may be stripped by it — including by its own arrival. Either way the record NOTIFIED is the record stored (see the case below).

Unlike Record, subscribers are notified INSIDE the ring critical section: same-ID notifications can never be observed out of transition order. notifySubscribers only performs non-blocking channel sends (plus, on overflow, a subscription removal) under subMu, and no path acquires mu while holding subMu, so this cannot block or deadlock. The eviction callback (disk IO) still runs after unlock. Upsert reports whether the record was accepted; false means the manager was already Closed (writesClosed) and the caller owns capture-file cleanup.

type RequestRecord

type RequestRecord struct {
	// ID is a 12-character hash generated from timestamp, method, and URL.
	ID         string        `json:"id"`
	Timestamp  time.Time     `json:"timestamp"`
	Method     string        `json:"method"`
	URL        string        `json:"url"`
	Subdomain  string        `json:"subdomain"`
	Hostname   string        `json:"hostname,omitempty"` // full hostname (e.g., api.local.dev)
	StatusCode int           `json:"status_code"`
	Duration   time.Duration `json:"duration"`
	RemoteAddr string        `json:"remote_addr"`

	// ProjectDir identifies the project that owns the route this request was
	// proxied for. Set daemon-side so records/purges are scoped to a project
	// even when two projects own the same hostname on different ports.
	ProjectDir string `json:"project_dir,omitempty"`

	// InFlight marks a record published at response-header time, before the
	// response body finished. Such records carry the header-time status but
	// zero Duration and nil Details; a completion update (same ID, InFlight
	// false) replaces them via Upsert. Completed records are terminal:
	// omitempty keeps their JSON identical to the pre-in-flight wire format.
	InFlight bool `json:"in_flight,omitempty"`

	// Details contains captured headers and bodies (nil when capture is disabled)
	Details *RequestDetails `json:"details,omitempty"`
}

RequestRecord represents a single proxied request.

func (RequestRecord) StaleAt added in v0.2.1

func (r RequestRecord) StaleAt(now time.Time) bool

StaleAt reports whether this record is stale as of now (D8, #53): still marked in-flight, but running longer than constants.InFlightStaleAfter. "Stale" means completion-unknown, not broken — see the constant's doc comment. Final (non-in-flight) records are never stale: their completion, whatever it was, is already known. This is the single staleness check; every consumer (API response conversion, CLI rendering, TUI rendering) calls it rather than re-deriving the condition.

type RequestSubscription

type RequestSubscription struct {
	ID     string
	Filter RequestFilter
	Ch     chan RequestRecord
	// contains filtered or unexported fields
}

RequestSubscription represents a subscription to request updates.

dropped counts events this subscription lost because its channel was full when notifySubscribers tried to deliver (D9). It is an atomic so notifySubscribers can bump it under the manager's read lock; the first drop (0→1 transition) logs once so a slow subscriber is visible without spamming.

closed latches the channel close so the several paths that can end a subscription — Unsubscribe, manager Close, and the overflow drop (C6) — can never double-close it.

type Service

type Service struct {
	// contains filtered or unexported fields
}

Service manages the HTTP/HTTPS reverse proxy servers.

func NewService

func NewService(cfg *config.ProxyConfig, services map[string]config.ServiceConfig, certsCfg *config.CertsConfig, logger *slog.Logger, workDir string) (*Service, error)

NewService creates a new proxy service. Returns an error if cfg is nil when proxy is expected to be enabled. workDir is used for storing captured request/response bodies on disk.

func (*Service) CaptureManager

func (s *Service) CaptureManager() *CaptureManager

CaptureManager returns the capture manager for loading captured bodies.

func (*Service) RequestManager

func (s *Service) RequestManager() *RequestManager

RequestManager returns the request manager for tracking proxy requests.

func (*Service) Shutdown

func (s *Service) Shutdown(ctx context.Context) error

Shutdown gracefully stops the proxy servers.

func (*Service) Start

func (s *Service) Start(ctx context.Context) error

Start starts the HTTP and/or HTTPS reverse proxy servers.

Directories

Path Synopsis
Package certs provides certificate management for the HTTPS reverse proxy.
Package certs provides certificate management for the HTTPS reverse proxy.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL