HTTP/HTTPS proxy with access control and WebUI, designed for small containerized installations to serve as a security gateway for AI agents. Single-machine deployment with simple admin-only WebUI.
- Terminate all HTTP/HTTPS connections
- Allow access to known [method, URL] combinations via whitelist
- Block requests based on blacklist
- Global and per-rule rate control
- Collect and persist statistics across restarts
- Interactive access control with live rule addition and pending request handling
- Language: Go (idiomatic code, hundreds of concurrent connections)
- Proxy Engine: goproxy
- Configuration: go-flags (
github.com/jessevdk/go-flags) - struct-based CLI flags + env vars - Logging: slog
- WebUI: templ + HTML + htmx (real-time updates)
- Container: Podman (no daemon mode required)
- TLS: Self-signed certificate generation + TLS bumping
- Algorithm: ECDSA P-256 (elliptic curve cryptography)
- Validity: 10 years (balance between operational simplicity and security)
- Key Size: 256-bit (equivalent to RSA 3072, secure beyond 2030)
- Storage: JSON files (simple, no external dependencies)
- HTTP/HTTPS proxy server (goproxy-based)
- TLS interception (HTTPS bumping)
- Certificate management:
- Auto-generation of CA cert on first run if missing (files, not directory)
- Certificates stored at the configured
--tls-certand--tls-keypaths - WebUI endpoint to download CA certificate
- Loading Modes:
- Separate files (default): Certificate and key in different files
- Combined file: Both certificate and key in single PEM file
- Detection: If
--tls-certand--tls-keypaths are identical, load as combined file - Combined file format: Certificate block first, then private key block
- Auto-generation: Always creates separate files (combined mode only for loading)
- Algorithm: ECDSA P-256 (faster than RSA, smaller keys, modern)
- Validity Period: 10 years from generation
- X.509 Extensions:
BasicConstraints: CA:TRUE, pathlen:0 (CRITICAL) - can sign certs but not sub-CAsKeyUsage: Certificate Sign, CRL Sign (CRITICAL) - required for CASubjectKeyIdentifier: Auto-generated by Go from public key hash
- Subject Naming: Organization="AIProxy CA", CommonName="AIProxy Self-Signed CA"
- File Permissions: Private key (ca-key.pem): 0600, Certificate (ca-cert.pem): 0644
- Validation: Strict by default (expiration, CA constraints, key usage, key pair matching, file permissions)
- Security: Optional
--insecure-certsflag allows validation errors to become warnings
- Request/response inspection
- Connection termination and forwarding
- No proxy authentication (network-isolated deployment)
- CONNECT method blocking (anti-tunneling):
- Blocks CONNECT to non-443 ports (prevents arbitrary TCP tunnels)
- Allows CONNECT to port 443 for HTTPS/TLS bumping
- 1-second delay before returning error (rate-limits scanner behavior)
- Returns HTTP 403 Forbidden with JSON error
- Configurable delay via
connectBlockDelayconstant - No CLI flag to disable (security by design, test-only override)
- Works in combination with TLS bumping (not mutually exclusive)
- Localhost IP protection (SSRF prevention):
- Blocks requests targeting 127.0.0.0/8 or ::1
- 1-second delay before returning error (rate-limits scanner behavior)
- Returns HTTP 403 Forbidden with JSON error
- Configurable delay via
localhostBlockDelayconstant
- Request Flow:
- Check blacklist → Reject immediately (HTTP 403)
- Check whitelist → Allow
- Unknown request → Hold as pending (deduplicated)
- Pending timeout: 120 seconds (configurable)
- Expired pending → Reject as blacklisted
- Rule Matching: Rich JSON rule objects matched against method, scheme, host, path, and port
- Example:
{"id": "allow-openai-get", "method": "GET", "scheme": "https", "host": "api.openai.com", "path": "/**"} - Example:
{"id": "allow-openai-post", "method": "POST", "scheme": "https", "host": "api.openai.com"}
- Example:
- Blacklist Behavior:
- Rules loaded from
--blacklist-rulesfile at startup - Checked AFTER localhost blocking, BEFORE global rate limiting (Request Flow step 6)
- First matching rule blocks the request immediately (HTTP 403)
- No delay before rejection (unlike SSRF/CONNECT blockers which rate-limit scanners)
- Logged at WARN level with structured attributes (request_id, method, url, matched_rule, remote_addr)
- Missing file is not an error: proxy starts with empty blacklist (allow all by blacklist)
- Static rules only in v1; runtime rules via WebUI added in Phase 6 (
--rt-blacklist-rules)
- Rules loaded from
- Pending Request Management:
- Unlimited queue size (until OOM)
- Deduplication of identical pending requests
- Persistence across restarts (pending.json)
- Keep expired/timed-out requests for later admin review
- Admin can create whitelist/blacklist rules from pending requests
- Rule Storage (see Storage section)
- Simple Interval-Based Rate Limiting:
- If limit is 10 req/min: interval = 60s / 10 = 6 seconds between requests
- First request → Process immediately
- Subsequent request → Hold for remaining interval time (if needed)
- Example: 10 rpm = minimum 6 seconds between requests to same endpoint
- Global Rate Limit: Default req/min (configurable in config.json)
- Per-Rule Rate Limit: Optional override in whitelist rules
- Rate Limiting Scope:
- Track per-rule (matched whitelist rule determines rate limit)
- Stats granularity follows rule granularity
- Rate Limit State:
- In-memory tracking (reset on restart acceptable)
- Track: last request timestamp per rule
- Calculate delay:
max(0, interval - time_since_last_request)
- WebUI Rate Control Viewer:
- Separate htmx page showing requests currently held by rate control
- Display: request details, matched rule, hold time remaining
- Real-time updates via SSE
- Per-Rule Stats (persisted to stats.json):
- Stats granularity matches rule definitions
- Track stats per matched rule (not per unique URL)
- Total request count per rule
- First request timestamp
- Last request timestamp
- Example: Rule
GET https://api.openai.com/**tracks all matching requests as one stat
- Application Log (slog, text format):
- Default: stdout
- Optional file output with rotation via lumberjack:
--log-file - Rotation: size-based with configurable max size, max age, max backups
- Configurable:
--log-max-size(default 10 MB),--log-max-backups(default 3),--log-max-age(default 0 = no limit)
- Access Log (Apache/Nginx-style, TODO):
- Fixed hardcoded format (no customization needed)
- Log rotation: size-based, shares lumberjack rotation with application log (max size, max age, max backups)
- Log format:
{timestamp} {client_ip} {method} {url} {status} {response_time_ms} {action} {matched_rule} - Action values:
allowed,blocked_blacklist,blocked_timeout,rate_limited - Text-based format (not JSON) for easy grepping
- Example line:
2026-03-27T10:15:30Z 10.0.0.5 GET https://api.openai.com/v1/chat 200 450ms allowed rule:whitelist[5]
- Real-Time Stats for WebUI:
- Total requests (allowed, blocked, rate-limited, pending)
- Requests by status code
- Current pending count
- Current rate-limited count
- Authentication:
- Admin-only access (no RBAC)
- Secret-based login (secret provided as command-line argument)
- Simple session cookie after successful login
- Dashboard:
- Key metrics (total requests, blocked, pending count, rate-limited count)
- Statistics visualization (per-rule stats from stats.json)
- Pending Requests Management:
- Real-time table showing pending requests (SSE updates)
- Deduplication: Single entry for identical requests, show "waiters count"
- Countdown timer for each pending request
- Actions: Add to whitelist, Add to blacklist, Ignore
- Review timed-out/expired pending requests (historical view)
- Rate-Limited Requests Viewer:
- Separate htmx page with SSE updates
- Show requests currently being held by rate control
- Display: method, URL, matched rule, delay remaining, client IP
- Auto-remove from view when request completes
- Rule Management:
- View whitelist.json / blacklist.json (read-only, user-managed)
- View/edit whitelist2.json / blacklist2.json (runtime rules)
- Add/edit/delete rules in whitelist2/blacklist2
- Rule format: method (glob), pattern (URL glob), rpm (optional rate limit req/min)
- Rules take effect immediately (no restart)
- Access Log Viewer:
- Paginated text viewer (all rotated log files)
- Simple search/filter (grep-like)
- Tail mode (show last N lines, auto-refresh)
- Certificate Download:
- Public endpoint
/download-cert(no authentication required) - Returns CA certificate PEM file
- Public endpoint
- CLI Flags + Environment Variables (no config file):
- Implementation:
go-flagslibrary provides struct-based configuration - Single struct definition with tags for both flags and env vars
--listen/AIPROXY_LISTEN- Proxy listen address (default:localhost:0)--webui-listen/AIPROXY_WEBUI_LISTEN- WebUI listen address (default:""= disabled; specify an address to enable WebUI in both daemon and wrapper modes)--tls-cert/AIPROXY_TLS_CERT- TLS certificate path (optional, auto-generate if missing); relative paths are resolved to absolute at startup--tls-key/AIPROXY_TLS_KEY- TLS key path (optional, auto-generate if missing); relative paths are resolved to absolute at startup- Combined file usage: Set both
--tls-certand--tls-keyto the same path (e.g.,--tls-cert ./certs/combined.pem --tls-key ./certs/combined.pem) --admin-secret/AIPROXY_ADMIN_SECRET- Admin authentication secret (optional; WebUI login disabled if empty)--blacklist-rules/AIPROXY_BLACKLIST_RULES- Blacklist rules file (default:rules/blacklist.json)--whitelist-rules/AIPROXY_WHITELIST_RULES- Whitelist rules file (default:rules/whitelist.json)--rt-blacklist-rules/AIPROXY_RT_BLACKLIST_RULES- Runtime blacklist rules file, WebUI-managed (default:data/blacklist2.json) (Phase 6 feature)--rt-whitelist-rules/AIPROXY_RT_WHITELIST_RULES- Runtime whitelist rules file, WebUI-managed (default:data/whitelist2.json) (Phase 6 feature)--pending-timeout/AIPROXY_PENDING_TIMEOUT- Pending request timeout (default:120s)--global-rate-limit/AIPROXY_GLOBAL_RATE_LIMIT- Global rate limit in req/min (default:0= unlimited)--log-level/AIPROXY_LOG_LEVEL- Log level: debug, info, warn, error (default:info)--log-file/AIPROXY_LOG_FILE- Log file path (empty = stdout) (default:"")--log-max-size/AIPROXY_LOG_MAX_SIZE- Max log file size in MB before rotation (default:10)--log-max-age/AIPROXY_LOG_MAX_AGE- Max days to retain old log files, 0 = no limit (default:0)--log-max-backups/AIPROXY_LOG_MAX_BACKUPS- Max number of old log files to retain (default:3)--connection-timeout/AIPROXY_CONNECTION_TIMEOUT- Connection timeout (default:30s)--request-timeout/AIPROXY_REQUEST_TIMEOUT- Request timeout (default:300s)--insecure-certs/AIPROXY_INSECURE_CERTS- Allow insecure certificates (validation errors become warnings) (default:false)
- Implementation:
- Configuration Priority: Flags override environment variables
- Validation: Required fields enforced, log-level choices validated by go-flags
- Auto-generated help:
--helpflag automatically displays all options - No hot-reload: Configuration changes require restart
- Streamable Request/Response Processing:
- Use io.Copy and streaming for large payloads
- Minimal memory buffering (no full request/response in memory)
- No artificial size limits (rely on timeouts for protection)
Purpose: Simplify testing and one-off proxy usage by wrapping commands with automatic proxy setup.
Syntax:
./aiproxy [proxy-flags] -- <command> [command-args]Behavior:
- Parse everything after
--delimiter as command to execute - Start proxy server in background and wait until ready (listener bound)
- Set environment variables for command (proxy URLs, CA cert paths)
- Execute command with proxy environment
- Forward stdin/stdout/stderr directly to/from command
- Log command start and completion with exit code
- When command exits, shut down proxy gracefully
- Exit with command's exit code
Environment Variables Set:
HTTP_PROXY,HTTPS_PROXY,http_proxy,https_proxy- Proxy address (e.g.,http://localhost:12345)SSL_CERT_FILE- CA certificate path (OpenSSL, curl, Ruby); always an absolute pathCURL_CA_BUNDLE- CA certificate path (curl); always an absolute pathREQUESTS_CA_BUNDLE- CA certificate path (Python requests library); always an absolute pathNODE_EXTRA_CA_CERTS- CA certificate path (Node.js); always an absolute path- All parent process environment variables are inherited
Examples:
# Wrap curl - simplest usage
./aiproxy -- curl https://api.github.com
# Wrap with proxy flags
./aiproxy --global-rate-limit 10 -- curl https://api.openai.com/v1/models
# Wrap git clone
./aiproxy -- git clone https://github.com/golang/go /tmp/test
# Wrap Python script
./aiproxy -- python3 my_script.py
# Wrap with complex command arguments
./aiproxy -- git log --since yesterday -- file.txt
# Without wrapper (daemon mode - current behavior)
./aiproxy # No "--" delimiterExit Codes:
0- Command exited successfully (wrapper mode) OR daemon clean exitN- Command's exit code (wrapper mode, where N is command's actual exit code)1- Proxy runtime error OR command execution failure (command not found)2- Configuration error OR empty command after--delimiter
Mode Detection:
- No
--delimiter in arguments: Daemon mode (foreground proxy server, existing behavior) - With
--delimiter: Wrapper mode (execute command, then exit)
Edge Cases:
- Empty command after
--: Configuration error, exit code 2 - Command not found: Execution error, exit code 1
- Proxy fails to start before command runs: Runtime error, exit code 1
- Command with
--in its arguments: Works correctly (only first--is delimiter) - IPv6 proxy address: Works correctly (brackets preserved in URL)
- Long-running command: Proxy runs until command exits (by design)
- Ctrl+C (SIGINT): Context cancellation propagates to both proxy and command
Implementation:
- CLI parsing: Manual
os.Argsparsing beforego-flags(detect--delimiter) - Command execution:
internal/runnerpackage withRun()function - Main orchestration: Simple piping code in
cmd/aiproxy/main.go - Context cancellation: Graceful proxy shutdown when command exits
- Logging: Structured logging with slog for command lifecycle
1. Client request arrives at proxy
2. TLS bumping (if HTTPS CONNECT to :443)
3. Extract: method, URL, headers, client IP
4. Check CONNECT METHOD to non-443 ports (anti-tunneling protection)
└─> CONNECT to non-443 port? → Sleep 1s → Reject with HTTP 403 + JSON error + Log at WARN
└─> CONNECT to :443? → TLS bump (MITM) → Continue to step 5
5. Check LOCALHOST IPs (DNS resolution)
└─> Resolves to 127.0.0.0/8 or ::1? → Sleep 1s → Reject with HTTP 403 + JSON error + Log at ERROR
6. Check BLACKLIST (glob match)
└─> Match? → Reject with HTTP 403 + JSON {"error": "forbidden", "reason": "blacklisted", "request_id": "req_N"} + Log at WARN
7. Check WHITELIST (glob match)
└─> Match? → Check rate limit (interval-based) → Forward (streaming) → Log stats
8. Unknown request (not in whitelist/blacklist):
└─> Add to pending queue (deduplicated by method+URL)
└─> Hold connection silently for timeout (default 120s)
└─> Notify WebUI via SSE (new pending request)
└─> If approved by admin → Add to whitelist2 → Process request
└─> If denied by admin → Add to blacklist2 → Reject request
└─> If timeout expires → Reject with HTTP 403 + Keep in pending.json for review
9. Rate limiting (if rule has rpm):
└─> Calculate interval: 60 / rpm seconds
└─> Check time since last request to this rule
└─> If interval not elapsed → Sleep for remaining time
└─> Update last request timestamp
10. Forward allowed requests to upstream (streaming, no buffering)
11. Stream response back to client
12. Update stats.json, append to access.log
rules/ # User-managed rule files (read-only by proxy)
├── whitelist.json # User-defined whitelist rules (--whitelist-rules)
└── blacklist.json # User-defined blacklist rules (--blacklist-rules)
data/ # Runtime state files (created by proxy)
├── whitelist2.json # Runtime whitelist rules (--rt-whitelist-rules, via WebUI)
├── blacklist2.json # Runtime blacklist rules (--rt-blacklist-rules, via WebUI)
├── pending.json # Pending requests queue (created by proxy)
└── stats.json # Per-rule statistics (created by proxy)
TLS certificate files are stored at the configured `--tls-cert` and `--tls-key` paths.
whitelist.json / blacklist.json (--whitelist-rules / --blacklist-rules files):
- Array of rule objects with the following fields:
id(string, required): Unique identifier within the file. Rules are sorted and matched by ID (lexicographic order).comment(string, optional): Human-readable description, ignored during matching.method(string, optional): HTTP method to match (e.g.,"GET","POST"). Omit to match any method.scheme(string, optional): URL scheme to match (e.g.,"http","https"). Omit to match any scheme.host(string, optional): Hostname glob pattern (e.g.,"api.openai.com","*.example.com"). MatchesURL.Hostname()(no port). Omit to match any host.path(string, optional): URL path glob pattern (e.g.,"/v1/*","/**"). Omit to match any path.port(int, optional): Exact port to match. Mutually exclusive withport_rangeandport_ranges.port_range([2]int, optional): Port range[low, high](inclusive). Both values must be > 0 and low ≤ high. Mutually exclusive withportandport_ranges.port_ranges([][2]int, optional): Array of port ranges. Each element follows same rules asport_range. Omit (not[]) to match any port. Mutually exclusive withportandport_range.rpm(int, optional): Per-rule rate limit in requests per minute. Field stored but enforcement is a future phase (v1: field accepted, not enforced).
- All present (non-empty/non-zero) fields must match (AND logic); absent fields match anything.
- Rules are sorted lexicographically by
idbefore matching; file order does not affect behavior. - Missing file is not an error (proxy starts with empty rule set).
- Example:
[ {"id": "allow-openai-chat", "comment": "Allow ChatGPT API", "method": "POST", "scheme": "https", "host": "api.openai.com", "path": "/v1/chat/**"}, {"id": "allow-openai-get", "method": "GET", "scheme": "https", "host": "api.openai.com"}, {"id": "block-admin", "scheme": "https", "host": "*.example.com", "path": "/admin/**"} ]
blacklist.json (--blacklist-rules file):
- Same object-array format as whitelist.json.
whitelist.json (--whitelist-rules file):
- Same object-array format as whitelist.json described above.
whitelist2.json / blacklist2.json (--rt-whitelist-rules / --rt-blacklist-rules files):
- Same schema as whitelist/blacklist
- User can manually merge into static rule files when proxy stopped
pending.json (data/ directory):
- Schema:
{id, method, url, headers_sample, client_ip, timestamp, status, waiters_count} - Status:
pending,expired,approved,denied - Deduplication: Single entry per unique method+URL, multiple waiters counted
stats.json (data/ directory):
- Schema:
{rule_id: {rule_pattern, count, first_seen, last_seen}} - Stats match rule granularity (not per unique URL)
access.log (TODO - access log feature not yet implemented):
- Text format (Apache/Nginx-style), fixed hardcoded format
- Format:
{timestamp} {client_ip} {method} {url} {status} {response_time_ms} {action} {matched_rule_id} - Rotation: size-based, configurable via
--log-max-size,--log-max-age,--log-max-backups
- Uses
internal/reqrulespackage for whitelist/blacklist rule storage and matching - Glob pattern matching via
bmatcuk/doublestar(forhostandpathfields) - Whitelist and blacklist loaded into memory at startup
- Runtime rules (whitelist2/blacklist2) merged with user rules
- Rule reload: WebUI changes trigger in-memory rule update (no file re-read needed)
- Rules stored and matched in lexicographic order by
id - Match logic: all present (non-empty/non-zero) fields must match (AND); absent = wildcard
hostfield matchesURL.Hostname()(no port);pathmatchesURL.Path- Port matching:
port(exact),port_range([low,high] inclusive),port_ranges(list of ranges); at most one may be set
- Simple interval calculation per rule
- Algorithm:
interval_seconds = 60.0 / rpm time_since_last = now() - last_request_time[rule_id] if time_since_last < interval_seconds: sleep(interval_seconds - time_since_last) last_request_time[rule_id] = now() forward_request() - In-memory state:
map[rule_id]last_request_timestamp - No token bucket, no sliding window - just simple intervals
- State resets on proxy restart (acceptable)
- Global rate limit applied if no per-rule limit specified
- Track currently rate-limited requests for WebUI display
- In-memory queue + persistence to pending.json
- Deduplication key:
method + url(exact match) - Single entry for identical requests, track waiters count
- Each unique pending request spawns one goroutine
- Goroutine holds client connection, waits for:
- Admin approval → Proceed with request
- Admin denial → Return HTTP 403
- Timeout expiration → Return HTTP 403, mark as expired in pending.json
- SSE channel for WebUI notifications (new pending, approved, denied, expired)
- Admin actions from WebUI:
- Approve → Add rule to whitelist2.json → Process all waiting requests
- Deny → Add rule to blacklist2.json → Reject all waiting requests
- Ignore → Do nothing, let timeout handle it
- Persist state changes immediately to pending.json
- WebUI authentication via admin secret (command-line arg)
- Simple session cookie after login (httpOnly, secure)
- Input validation for all WebUI inputs:
- URL patterns (prevent glob DoS like
**/**/**/**) - Rate limits (positive integers only)
- Method patterns (limited charset)
- URL patterns (prevent glob DoS like
- CONNECT method protection (anti-tunneling):
- CONNECT to non-443 ports blocked (prevents arbitrary TCP tunnel establishment)
- CONNECT to port 443 allowed for HTTPS/TLS bumping (MITM inspection)
- Logged at WARN level (security-relevant but less critical than SSRF)
- 1-second delay before rejection (rate-limits scanning behavior)
- Test-only disable flag (DisableConnectBlocking in proxy.Config)
- No production bypass mechanism (secure by default, no CLI flag)
- Implemented via conditional goproxy handlers (Not(ReqHostMatches(":443$")))
- Localhost IP protection (SSRF prevention):
- Blocks requests targeting 127.0.0.0/8 or ::1
- Logged at ERROR level (potential SSRF attempt)
- 1-second delay before rejection (rate-limits scanning behavior)
- Test-only disable flag (DisableLocalhostBlocking in proxy.Config)
- No production bypass mechanism (secure by default, no CLI flag)
- Certificate private key permissions (0600)
- Certificate validation (strict by default):
- Expiration check (error if expired or not yet valid, warn if expiring within 30 days)
- CA constraints validation (BasicConstraintsValid && IsCA must be true)
- KeyUsage validation (must include KeyUsageCertSign)
- Public/private key pair matching (verify keys correspond)
- File permission checks (warn if private key is not 0600)
- Optional
--insecure-certsflag downgrades validation errors to warnings
- Upstream HTTPS certificate validation:
- Proxy validates upstream server certificates using system CA trust store
- Invalid certificate response: HTTP 502 Bad Gateway with generic JSON error
- Security constraint: NO certificate details exposed to client (prevents information disclosure to untrusted AI agents)
- Certificate error details logged at ERROR level for operator debugging only
- Validation failures include: expired certificates, invalid CA signature, hostname mismatch, revoked certificates (if CRL/OCSP available)
- Client authentication: Not supported in v1 (no mTLS requirement for clients connecting to proxy)
- No sensitive data in access logs (do not log full headers)
- Error responses include minimal information:
{ "error": "forbidden", "reason": "not in whitelist", "request_id": "abc123" } - Secure defaults:
- Default deny for unknown requests (pending → timeout → reject)
- Reasonable timeouts (connection: 30s, request: 300s)
- Streaming request/response (no memory exhaustion from large payloads)
- Certificate download endpoint public (CA cert alone is not sensitive)
- Containerfile (Podman/Docker compatible)
- Multi-stage build (build + runtime)
- Volume mounts:
/rules- User rule files (whitelist.json, blacklist.json)/data- Runtime state (whitelist2, blacklist2, pending, stats)
- TLS certificate storage directory (path determined by
--tls-cert/--tls-key; can be empty, certs auto-generated if missing) - Port exposure:
- Proxy port (default 8080)
- WebUI port (default 8081)
- Command-line args passed to container:
--admin-secret(required, can be set via env var)
- No graceful shutdown requirement (just must not crash)
- Run as non-root user inside container
aiproxy/
├── cmd/
│ └── aiproxy/
│ └── main.go # Entry point
├── internal/
│ ├── proxy/
│ │ ├── proxy.go # goproxy setup, TLS bumping
│ │ └── handler.go # Request handling logic, streaming
│ ├── rules/
│ │ ├── matcher.go # Glob matching engine
│ │ ├── whitelist.go # Whitelist management
│ │ ├── blacklist.go # Blacklist management
│ │ └── loader.go # Load rules from JSON (whitelist + whitelist2, etc.)
│ ├── pending/
│ │ ├── queue.go # Pending request queue, deduplication
│ │ └── persistence.go # pending.json I/O
│ ├── ratelimit/
│ │ └── limiter.go # Interval-based rate limiting
│ ├── stats/
│ │ ├── collector.go # Statistics collection (per-rule)
│ │ ├── persistence.go # stats.json I/O
│ │ └── accesslog.go # Access log rotation (text format)
│ ├── certs/
│ │ └── manager.go # Certificate generation/loading
│ ├── config/
│ │ └── config.go # Configuration loading (CLI flags + env vars, `--` parsing)
│ ├── runner/
│ │ └── runner.go # Command execution wrapper (Run, buildEnvironment)
│ └── webui/
│ ├── server.go # HTTP server, SSE endpoints
│ ├── auth.go # Authentication middleware (session cookies)
│ ├── handlers/
│ │ ├── dashboard.go # Dashboard page
│ │ ├── pending.go # Pending requests API + SSE
│ │ ├── ratelimit.go # Rate-limited requests viewer + SSE
│ │ ├── rules.go # Rule management API
│ │ ├── logs.go # Access log viewer
│ │ └── certs.go # Certificate download (public)
│ ├── static/ # Embedded static assets (embed.FS)
│ │ ├── htmx.min.js # htmx v4.0.0-beta1 (vendored)
│ │ ├── hx-sse.min.js # htmx v4 SSE extension (vendored)
│ │ └── pico.min.css # Pico CSS (vendored)
│ └── templates/ # templ files
│ ├── layout.templ
│ ├── dashboard.templ
│ ├── pending.templ
│ ├── ratelimit.templ
│ └── rules.templ
├── rules/ # Volume mount (not in git)
│ ├── whitelist.json # User-managed
│ └── blacklist.json # User-managed
├── data/ # Volume mount (not in git)
│ ├── whitelist2.json # Proxy-managed (runtime rules)
│ ├── blacklist2.json # Proxy-managed (runtime rules)
│ ├── pending.json # Proxy-managed
│ └── stats.json # Proxy-managed
├── certs/ # Example certificate directory (not in git)
│ ├── ca-cert.pem
│ └── ca-key.pem
├── scripts/ # Manual testing scripts
│ └── manual_cert_tests.sh
├── Containerfile
├── go.mod
├── go.sum
├── IDEA.md
├── TODO.md
└── README.md
- Project setup (go.mod, directory structure)
- Configuration loading (CLI flags + environment variables)
- Certificate management (generation + loading)
- Basic goproxy setup with TLS bumping
- Request logging (slog)
- Glob pattern matching
- Whitelist/blacklist loading (JSON)
- Request filtering (blacklist → whitelist → unknown)
- HTTP 403 responses for blocked requests
- Pending request queue (in-memory)
- Request holding mechanism (goroutines + timeout)
- Deduplication logic
- Persistence (pending.json)
- Interval-based rate limiter (simple sleep/hold implementation)
- Global rate limit
- Per-rule rate limit
- Request delay logic
- Stats collector (per-rule, not per-URL)
- Stats persistence (stats.json)
- Access log writer (Apache/Nginx-style text format)
- Access log rotation (configurable lines and file count)
- Basic HTTP server + authentication (session cookies)
- SSE infrastructure for real-time updates
- templ setup + htmx integration
- Dashboard (read-only stats display)
- Pending requests viewer (real-time via SSE, deduplication display)
- Rate-limited requests viewer (separate page, real-time via SSE)
- Rule management UI (add/edit/delete whitelist2/blacklist2)
- Access log viewer (tail mode, search/filter)
- Certificate download endpoint (public, no auth)
- Containerfile (multi-stage build)
- Example config.json, whitelist.json, blacklist.json
- Integration testing (manual testing workflow)
- Documentation (README.md with usage examples)
github.com/elazarl/goproxy- HTTP/HTTPS proxy with MITM supportgithub.com/bmatcuk/doublestar/v4- Glob pattern matching (supports**)github.com/a-h/templ- Type-safe HTML templates- Standard library:
net/http,log/slog,encoding/json,crypto/tls,crypto/x509,io - No external rate limiting library (simple interval-based implementation)
- No external database (JSON files only)
- Admin Secret: Provided via
--admin-secretcommand-line argument (optional)- If empty/not provided: WebUI login disabled (authentication always fails)
- Warning logged at startup when admin secret is empty
- Certificate download endpoint remains public (no auth required)
- Secure by default: no secret = no admin access
- WebUI Auth: Simple session cookie after login, no complex RBAC
- Certificate Download: Public endpoint (no auth), CA cert alone is not sensitive
- Error Responses: Minimal JSON format to simplify testing and debugging
- Pending Deduplication: Single entry for identical requests, multiple waiters counted
- Hold Behavior: Hold connection silently (no feedback to client until timeout/approval)
- Default Policy: Deny unknown requests after timeout (secure by default)
- Rule Matching: Glob patterns for
hostandpathfields; exact match formethod,scheme,port; all present fields ANDed
- Algorithm: Simple interval-based (60 / rpm = seconds between requests)
- Behavior: Hold/sleep request for remaining interval time (no rejection)
- Scope: Per-rule (not per-client or per-URL)
- Middleware Architecture: Native goproxy chaining via
OnRequest().DoFunc()— each rate limiter is a separate middleware registered in order. If global rate limit is 0, the middleware is not registered at all. - Delayed Request Entity:
DelayedRequeststruct holds*http.Requestdirectly (no field duplication),RequestID(dedicateduint64type withreq_Nstring format),Delay(hold duration), andStatus(dedicated enum type withString()). SharedDelayedRequestStoreis used by both global and per-rule rate limiters. - Delayed Request Logging: When a delayed request is sent after the sleep, a separate log entry is generated with message "Delayed request sent" containing the same fields as the initial request log plus an additional
delayparameter. The initial request log is unchanged.
- Stats Granularity: Per-rule (matches rule definitions, not unique URLs)
- Access Log Format: Fixed Apache/Nginx-style text format (not JSON)
- Log Rotation: Size-based via lumberjack (
gopkg.in/natefinch/lumberjack.v2), configurable max size (MB), max age (days), max backups
- Technology: Server-Sent Events (SSE) for pending/rate-limited request updates
- Efficiency: SSE works well with htmx, provides instant updates
- Public Dashboard:
/,/api/dashboard/stream, and/download-certare public endpoints (no authentication required). They expose only safe operational status — no access-control configuration, no whitelist/blacklist rules, no rule counts. - Dashboard Content: Uptime, CA cert subject + expiry + download link, live counters (total processed requests, pending requests, rate-limited requests). Global rate-limit setting shown as static info. Nothing that reveals the proxy's access-control configuration.
- WebUI Stack: templ (type-safe HTML templates) + htmx v4.0.0-beta1 + Pico CSS. All static assets vendored under
internal/webui/static/and embedded into the binary viaembed.FS(//go:embed static) — no external files required at runtime (single self-contained binary). - htmx v4 SSE Pattern: Extension registers globally on script load (no
hx-ext="sse"needed). Connect withhx-sse:connect="/url". Unnamed SSE messages (noevent:line in server response) auto-swap the element's content viahx-swap. Named events are dispatched as DOM events, not auto-swapped. - SSE Push Interval: 1 seconds. On client disconnect the server goroutine exits via
r.Context().Done(). - Static Asset Serving:
http.FileServerFS(staticFiles)(Go 1.22+) serves the embedded FS under/static/. Nofs.Subneeded;embed.FSpath stripping is handled by the stdlib handler. - ProxyMetrics Interface: Defined in
internal/webui/server.go(consumer side — idiomatic Go "accept interfaces, return structs").*proxy.Proxysatisfies it via three new methods:RequestCount() uint64,RateLimitedCount() int,PendingCount() int. - Cert Download Safety:
/download-certre-encodes*x509.Certificateto PEM from memory (never reads a file). This guarantees the endpoint never accidentally serves a combined cert+key file even if the proxy was started with one. - Merged Status Block: The two previously separate dashboard blocks ("Status" and "Live Stats") are merged into a single "Status" block. The "● Running" badge is removed: the WebUI is not detachable from the proxy process, so if the page is reachable the proxy is always running — the badge is always true and carries no information. The
StatsFragmenttemplate is renamedStatusFragmentand extended to include live uptime as the first row of the<dl>alongside the request counters. The SSE push interval is 1 seconds for responsive uptime and counter updates. The SSE endpoint pushes the full status (uptime + counters) on every tick, keeping the entire block live-updated. The#live-statsSSE target element is unchanged; only the fragment content expands.
- Login Form: Password Only: The
/loginpage renders a single<input type="password">field with label "Admin password" and a submit button. No username field. Page title: "Login". - 1-Second Minimum Response Time: Every
POST /loginhandler recordsstart := time.Now()as its first statement. Before writing any response it callstime.Sleep(time.Until(start.Add(time.Second))). Applies to all outcomes: wrong password, correct password, disabled auth. A timing oracle is impossible. - Constant-Time Secret Comparison: Password compared using
crypto/subtle.ConstantTimeCompare([]byte(submitted), []byte(secret)) == 1. Length differences that would short-circuitConstantTimeCompareare mitigated by the hard 1-second floor (Decision 38). - Session Token Format: 32 bytes from
crypto/rand, encoded as lowercase hex (64 chars). Stored in cookie namedaiproxy_sessionwith flags:HttpOnly,SameSite=Strict,Path=/.Secureflag is not set (WebUI is container-local, typically accessed over plain HTTP). MaxAge = 86400 (24 hours). - Single-Session Store: Only one session may be active at any time.
SessionStoreholds a single*Session(nil when no session) protected bysync.RWMutex.Create()atomically replaces any existing session — a new login immediately invalidates the previous session.Validate()returns aValidateResultenum with three values:SessionValid,SessionInvalid(expired, no session, or server restart),SessionKicked(a different non-expired session is currently active — intrusion signal).SessionKickedis only returned when the existing session is still active but has a different token, allowing the UI to display "Session expired or logged out from another location." Sessions are not persisted — a restart invalidates all sessions. No background goroutines or cleanup needed. API:Create() (string, error),Validate(token string) ValidateResult,Delete(token string). - Auth Middleware:
authMiddleware(store *SessionStore, next http.Handler) http.Handler. Readsaiproxy_sessioncookie; callsstore.Validate(token). OnSessionValid: call next handler. OnSessionKicked: redirect to/login?msg=kicked(intrusion signal — a different session is active). OnSessionInvalid: redirect to/login. No?next=parameter — successful login always redirects to/. - Public vs Protected Routes:
- Public (no auth):
GET /,GET /api/dashboard/stream,GET /download-cert,GET /static/,GET /login,POST /login - Protected (auth required):
GET /logout,GET /pending,GET /api/pending/stream
- Public (no auth):
- Empty Admin Secret Behaviour: When
--admin-secretis empty,POST /loginalways returns failure after the mandatory 1-second delay with message "Authentication disabled — no admin secret configured".GET /loginshows a notice: "Admin access is disabled. Start the proxy with --admin-secret to enable login." - Login Redirect: Successful
POST /loginalways redirects to/. No?nextparameter — the redirect target is unconditional. - Logout:
GET /logout(wrapped by authMiddleware) removes token from session store, clears cookie (MaxAge=-1), redirects to/. - Navigation Bar:
Baselayout signature changes fromBase(title string)toBase(title string, nav NavData)whereNavDataisstruct { IsAuthenticated bool; AuthEnabled bool }(AuthEnabled=--admin-secret != ""). Nav links in<nav>inside<header>: Dashboard (→/, always shown), Pending (→/pending, always shown), Login (→/login, shown when!IsAuthenticated && AuthEnabled), Logout (→/logout, shown whenIsAuthenticated).
- Pending Page Route:
GET /pending(auth required). Full page usingBaselayout, title "Pending Requests". Contains a<table>with<thead>and<tbody>as the SSE live-update target. - PendingSource Interface: Defined in
internal/webui/handlers/pending.go(consumer side).handlersimportsinternal/pendingdirectly — no import cycle exists (pending→ nothing;proxy→pending;handlers→pending;main→proxy,webui). The interface uses*pending.Entrydirectly, eliminating a duplicate DTO type:import "github.com/cloudcopper/aiproxy/internal/pending" type PendingSource interface { PendingItems() []*pending.Entry }
*proxy.Proxysatisfies this interface via itsPendingItems() []*pending.Entrymethod. No intermediate DTO struct or adapter is needed. - Pending Table Columns: Method | URL | Waiters | Elapsed | Remaining. Elapsed =
time.Since(item.Since)rounded to seconds. Remaining =item.Timeout - time.Since(item.Since), show "expired" when ≤ 0. No action buttons in this phase — read-only display. - Pending SSE Pattern:
GET /api/pending/stream(auth required). Same tick-and-push pattern as dashboard SSE: 1-second interval, push full<tbody>inner HTML as an unnamed SSE event. Target:<tbody id="pending-rows" hx-sse:connect="/api/pending/stream" hx-swap="innerHTML">. WhenPendingItems()returns empty: renders single<tr><td colspan="5">No pending requests</td></tr>. - New Files for Auth and Pending View:
internal/webui/auth/session.go—SessionStoreand its methodsinternal/webui/handlers/login.go—LoginHandler,LogoutHandler,authMiddlewareinternal/webui/handlers/pending.go—PendingPageHandler,PendingSSEHandlerinternal/webui/templates/login.templ— login page templateinternal/webui/templates/pending.templ— pending page + tbody fragment templatesinternal/webui/templates/layout.templ— updated to acceptNavDataparameterinternal/webui/server.go— updated to wire new routes, auth middleware,AdminSecretandPendingSourceadded toServerConfig
-
Phase 3 Scope (Timeout-Only, No WebUI, No Persistence): Phase 3 implements the hold-and-timeout path only. Admin approval/denial (adding to whitelist2/blacklist2), SSE notifications, and persistence to
pending.jsonare deferred to later phases. Unknown requests — those not matched by the blacklist and not matched by the whitelist — are held silently in memory until--pending-timeout(default 120s) expires, then rejected with HTTP 403. The pending queue is always created regardless of whitelist configuration or--pending-timeoutvalue.--pending-timeout 0means immediate rejection (zero wait) — NOT pass-through. There is no configuration that makes the proxy pass unclassified requests to the upstream; unclassified always means pending → rejected. State is lost on proxy restart (acceptable for Phase 3). -
internal/pendingPackage API:EntryStatusenum starts at1(zero value = invalid/unknown):StatusPending = 1,StatusExpired = 2Entrystruct exported fields:ID string,Method string,URL string,Since time.Time,Timeout time.Duration; unexported:done chan struct{}(closed when resolved). NoClientIP— the entry represents N deduplicated callers from potentially N different IPs; storing any one IP would be arbitrary and misleading.Queuestruct:mu sync.Mutex(protectsactive),timeout time.Duration,active map[string]*Entry(key = dedup key),nextID atomic.Uint64NewQueue(timeout time.Duration) *Queue— creates an empty in-memory queue; no I/OQueue.Hold(ctx context.Context, method, url string) bool— blocks until entry is resolved or client disconnects; always returnsfalse; caller must issue 403Queue.ActiveCount() int— length ofactivemap, thread-safeQueue.ActiveEntries() []*Entry— snapshot copy of active entries (for Phase 6 WebUI)
-
Deduplication Key:
method + " " + url(single space, exact match, no URL normalization). Two requests are considered identical when their method and URL string are equal. -
Hold()Concurrency Model:- Acquire
q.mu; look up entry by key (method + " " + url); if absent, create a newEntry(allocatedonechannel, setSince = time.Now(), generateID, add toactivemap, start timeout goroutine — goroutine started while holding the lock so it cannot fire before the entry is published); releaseq.mu - Caller goroutine blocks:
select { case <-entry.done: return false; case <-ctx.Done(): return false } - On
ctx.Done()(client disconnected): simply returnfalse; the timeout goroutine continues running — the entry stays alive until its deadline, allowing subsequent identical requests to join the same entry - Timeout goroutine uses
time.NewTimer(q.timeout)— nevertime.After(avoids timer leak) - On timeout: acquire
q.mu;delete(q.active, key); release lock; then closeentry.done(outside the lock — never hold mutex while unblocking waiters) - All waiters on the same entry unblock simultaneously when
entry.doneis closed
- Acquire
-
Entry ID Format:
"pnd_N"where N is a sequentialuint64fromq.nextID.Add(1), consistent with theRequestID"req_N"style. ID assigned once when entry is first created. -
Proxy Integration:
- Add
queue *pending.QueuetoProxystruct (nil when queue disabled) - Add
PendingTimeout time.Durationtoproxy.Config(no file path — no persistence this phase) NewProxy()queue-and-whitelist wiring:- Always create queue —
p.queue = pending.NewQueue(cfg.PendingTimeout)—PendingTimeout == 0means immediate rejection, not disabled. - If
whitelist.Count() > 0: registerallowWhitelisthandler (match → forward, no match →holdPending). - If
whitelist.Count() == 0: registerholdPendingdirectly as a catch-all — every non-blacklisted request is unclassified and goes straight to the pending queue. There is no code path that passes an unclassified request to the upstream."not_whitelisted"reason string is removed; all pending-queue rejections use"blacklisted".
- Always create queue —
- New file
internal/proxy/pending.go:holdPending(req *http.Request, ctx *goproxy.ProxyCtx) (*http.Request, *http.Response)callsp.queue.Hold(req.Context(), req.Method, req.URL.String())(blocks), then logs at WARN withreason: "pending_timeout"slog attribute and returnserrorResponse(req, 403, "forbidden", "blacklisted", id)— identical error body to blacklist rejection Proxy.PendingCount(): returnsp.queue.ActiveCount()whenp.queue != nil, else 0 (replaces stub)Proxy.PendingItems(): convertsp.queue.ActiveEntries()to[]handlers.PendingItemwhenp.queue != nil, else nil (replaces stub)
- Add
-
main.goWiring: passcfg.PendingTimeoutintoproxy.Config.PendingTimeout. No new CLI flags or config fields needed —--pending-timeout/AIPROXY_PENDING_TIMEOUTalready exists inconfig.Config. -
Timeout Error Response: HTTP 403 with JSON body
{"error": "forbidden", "reason": "blacklisted", "request_id": "req_N"}— identical to the blacklist rejection response. From the client's perspective, an expired pending request is indistinguishable from a blacklisted one. The slog WARN log entry uses"reason": "pending_timeout"as a structured attribute so operators can distinguish the two cases in logs.
-
WaitersCount Tracking in
Entry: Add unexportedwaiters atomic.Int64toEntry. Export viaEntry.Waiters() int(returnsint(e.waiters.Load())). InHold(), immediately after the lock is released and before the blocking select: callentry.waiters.Add(1)anddefer entry.waiters.Add(-1). The decrement fires atomically whenHold()returns for any reason (timeout or context cancellation). This gives an accurate real-time count of goroutines currently blocked inHold()for a given entry. -
Proxy.PendingItems()Method:*proxy.ProxyexposesPendingItems() []*pending.Entryininternal/proxy/proxy.goalongside the existingPendingCount()method. Returnsp.queue.ActiveEntries()whenp.queue != nil, elsenil. Return type is[]*pending.Entry— a type the proxy package already owns — soproxyhas no dependency on webui packages.*proxy.Proxystructurally satisfieshandlers.PendingSourcewithout any adapter. -
Direct wiring in
main.go:webuiCfg.Pending = proxyServer—*proxy.Proxydirectly satisfieshandlers.PendingSourcevia structural typing.main.godoes not importinternal/webui/handlers. The assignment itself acts as a compile-time interface satisfaction check: the build fails there if*proxy.Proxyno longer implements the interface. NopendingAdaptertype. -
Tests for WaitersCount: Add to
internal/pending/queue_hold_test.go:TestHold_waitersCount: single goroutine →Waiters()is 1 while blocked, two goroutines →Waiters()is 2; both drop to 0 after timeout.TestHold_waitersCountDecrement_onContextCancel: two goroutines on same entry, cancel one context →Waiters()drops from 2 to 1; entry stays alive; after timeout → 0. Both tests usesynctest.Testfor deterministic timer behavior.
-
Testing:
- Unit tests in
internal/pending/(same package, white-box):queue_hold_test.go:Hold()returnsfalseafter timeout;Hold()returnsfalseon context cancel; dedup — two concurrent goroutines with identical method+URL share oneEntryand both unblock simultaneously on timeout
- Use
testing/synctest(Go 1.25synctest.Test) for deterministic timer-based tests - Use
goleak.VerifyTestMainto detect goroutine leaks in the pending package - Integration test
internal/integration_tests/proxy_pending_test.go(build tagintegration): start real proxy with a whitelist rule that does NOT match the test request; verify response is HTTP 403 with{"error":"forbidden","reason":"blacklisted",...}after the configured timeout; verify dedup (two concurrent clients, same request, share one queue entry, both receive 403)
- Unit tests in
D-REEVALUATE-1. Problem: When a rule is added to the whitelist or blacklist at runtime (via WebUI or p.Whitelist().Add() / p.Blacklist().Add()), pending requests that match the new rule continue waiting in the queue until their timeout fires. This is wrong: a whitelist addition should immediately forward matching pending requests; a blacklist addition should immediately reject them.
D-REEVALUATE-2. Resolution type in pending package: Hold() return type changes from bool (always false) to Resolution, a named type with four values:
ResolutionTimeout— timer fired (zero value)ResolutionApproved— resolved via whitelist matchResolutionDenied— resolved via blacklist matchResolutionDisconnected— client context cancelled
D-REEVALUATE-3. Entry.resolution atomic.Int32 field: Set before closing entry.done. runTimeout stores ResolutionTimeout; Resolve() stores the caller-provided value. Hold() reads it after <-entry.done and returns it.
D-REEVALUATE-4. Queue.Resolve(method, url string, r Resolution) bool: Acquires lock, finds entry by key, deletes from active map, releases lock, stores resolution, closes entry.done. Returns false if entry not found (already resolved or never existed). Race-safe: runTimeout also deletes from the active map under the lock first — whichever of Resolve or runTimeout runs first wins the deletion; the other finds the key absent and becomes a no-op. The done channel is never closed twice.
D-REEVALUATE-5. runTimeout race fix: Before closing done, runTimeout must verify the entry is still in the active map (i.e., has not been resolved by Resolve). Pattern:
q.mu.Lock()
_, stillActive := q.active[key]
if stillActive {
delete(q.active, key)
}
q.mu.Unlock()
if stillActive {
entry.resolution.Store(int32(ResolutionTimeout))
close(entry.done)
}D-REEVALUATE-6. holdPending change: After q.Hold() returns, switch on the Resolution:
ResolutionApproved→ log INFO "pending request approved", return(req, nil)(goproxy forwards to upstream)ResolutionDeniedorResolutionTimeout→ log WARN, return(req, errorResponse(403, "forbidden", "blacklisted", id))ResolutionDisconnected→ log DEBUG "pending request client disconnected", return(req, errorResponse(403, "forbidden", "blacklisted", id))
D-REEVALUATE-7. Proxy.ReevaluatePending() method: Iterates p.queue.ActiveEntries(), checks each entry against the live blacklist (first) and whitelist (second), calls p.queue.Resolve() for entries that match. Entries matched by blacklist → ResolutionDenied; entries matched by whitelist → ResolutionApproved; unmatched entries → left alone. This method is called by the WebUI after adding a rule to either list. To construct an *http.Request for matching, use http.NewRequest(e.Method, e.URL, nil) (ignore the error — URL was already parsed when the original request arrived).
D-REEVALUATE-8. Caller responsibility: Proxy.ReevaluatePending() must be called after any rule addition (whitelist or blacklist). In the WebUI, the add-rule HTTP handler calls p.ReevaluatePending() after store.Add(rule). In integration tests, the test calls it directly after adding a rule to the store. There is no automatic/background re-evaluation loop — re-evaluation is explicitly triggered.
D-REEVALUATE-9. Unit tests in internal/pending/: Add test TestResolve_approved and TestResolve_denied (using synctest.Test) to verify that Resolve unblocks waiters immediately and Hold returns the correct resolution. Add TestResolve_notFound verifying Resolve returns false for an unknown key. Update all existing tests that check Hold() == false to compare against ResolutionTimeout (or keep as-is if the Resolution type has a helper method).
D-REEVALUATE-10. Integration tests in internal/integration_tests/proxy_pending_reevaluate_test.go:
TestProxy_PendingApprovedByWhitelist: no-rules proxy, client request held in pending, add whitelist rule + callReevaluatePending(), request completes with HTTP 200, pending queue is empty.TestProxy_PendingDeniedByBlacklist: no-rules proxy, client request held in pending, add blacklist rule + callReevaluatePending(), request completes with HTTP 403 (reason: "blacklisted"), pending queue is empty.
-
RulesSourceInterface: Defined ininternal/webui/handlers/rules.go(consumer side). Returns the live merged stores directly so handlers can share a single implementation for both whitelist and blacklist:type RulesSource interface { Whitelist() *reqrules.ReqRules Blacklist() *reqrules.ReqRules }
*proxy.Proxysatisfies this via two new methodsWhitelist()andBlacklist()that returnp.whitelistandp.blacklist. Defined ininternal/proxy/proxy.go.webui.ServerConfiggains aRules RulesSourcefield (nil →nullRulesSource{}stub with empty stores). -
No Persistence (in-memory only): Add/delete operations mutate the live
*reqrules.ReqRulesin memory only. Changes are lost on restart. Persistence (rules.Save) is deferred to TODO. Static rule files (whitelist.json,blacklist.json) are never modified by the WebUI. -
Single
/rulesPage, Two Sections: One page with two<article>blocks rendered in processing order — Blacklist Rules first, Whitelist Rules second — matching the proxy request flow (blacklist checked before whitelist). Routes:GET /rules(full page, auth required),POST /api/rules/whitelist,DELETE /api/rules/whitelist/{id},POST /api/rules/blacklist,DELETE /api/rules/blacklist/{id}(all auth required). -
htmx Partial Tbody Swaps (No SSE): Rules change only on explicit admin action — no SSE needed. Each table section has a
<tbody id="{listType}-rows">. Add and delete handlers return a fresh<tbody>fragment (hx-swap="outerHTML"). The fragment always includes all current rule rows plus the empty add-form row. -
User-Provided Rule ID (Mandatory): The add-form row has an
idtext input that starts empty. Validation: non-empty, passesreqrules.Rule.Validate(), no duplicate ID in the store. Server returns HTTP 422 with the tbody fragment (form row pre-populated with submitted values + inline error message) when validation fails. -
Add-Form Row Inside
<tbody>: The add-row<tr id="{listType}-add-row">lives inside the<tbody>. It contains the add form with fields:id(text, required),method(select: blank + GET/POST/PUT/DELETE/PATCH/HEAD/OPTIONS),scheme(select: blank + http/https),host(text),path(text),comment(text). Blank selects = omit field (match any). Submit button label: "Add". The form posts to/api/rules/{listType}withhx-target="#{listType}-rows" hx-swap="outerHTML". -
Vanilla JS Row Repositioning: A single
<script>block on the rules page attaches a delegatedinputlistener todocument. When theidinput inside an add-row changes, the listener moves the<tr>to its correct sorted position among rows withdata-rule-idattributes. If the typed ID is empty, the add-row stays at the bottom. After htmx swaps the tbody (add/delete), the add-row is reset (included fresh in the returned fragment) and the listener automatically applies to the new DOM because it is delegated ondocument. -
Delete with
hx-confirm: Runtime rule rows (Rule.Runtime == true) render a Delete button withhx-delete="/api/rules/{listType}/{id}" hx-confirm="Delete rule '{id}'?" hx-target="#{listType}-rows" hx-swap="outerHTML". Static rule rows (Rule.Runtime == false) render a read-only badge ("static") instead of a delete button — they cannot be deleted via WebUI. -
Rules Nav Link:
<li><a href="/rules">Rules</a></li>added to the nav bar between "Pending" and the Login/Logout block. Always shown (same as Dashboard and Pending). Clicking while unauthenticated redirects to/login?next=/rules. -
New Files for Rules UI:
internal/webui/handlers/rules.go—RulesSourceinterface,RulesConfig,NewRulesPageHandler,NewRulesAddHandler,NewRulesDeleteHandler; sharedrulesTableBodyData(store *reqrules.ReqRules) []RuleRowDatahelper used by both whitelist and blacklist handlersinternal/webui/templates/rules.templ—RulesPage(data RulesPageData),RulesSectionFragment(section RulesSectionData)(returns<tbody>),RuleRow(r RuleRowData, listType string),RulesAddRow(listType string, vals RuleFormValues, errMsg string)internal/webui/templates/layout.templ— add Rules nav linkinternal/webui/server.go— addRules RulesSourcetoServerConfig; register 5 new routes; wirenullRulesSource{}fallbackinternal/proxy/proxy.go— addWhitelist() *reqrules.ReqRulesandBlacklist() *reqrules.ReqRulesmethodscmd/aiproxy/main.go—webuiCfg.Rules = proxyServer
-
Rule.Priority intField: Added toreqrules.Rulewithjson:"priority,omitempty". Default0. Validation:priority >= 0(negative values rejected). Sort order inReqRuleschanges from(id ASC)to(priority ASC, id ASC)— lower number = higher priority = checked first; ties broken by ID lexicographic order. Backward compatible: existing JSON files withoutpriorityfield get0, preserving current ID-only ordering for all rules that do not set it. The field appears in the add-rule form (number input, min 0, default 0) and in the rule table as a column. The inline JS repositioning for the add-row updates to sort by(data-rule-priority ASC, data-rule-id ASC)and fires on changes to either the ID input or the priority input (both carrydata-reposition). -
Edit Rule UI — in-place JS transform: Runtime rule rows support in-place editing with no
/editendpoint — onlyPUT /api/rules/{listType}/{id}. Each runtime<tr>carries all field values asdata-rule-*attributes (data-rule-priority,data-rule-method,data-rule-scheme,data-rule-host,data-rule-path,data-rule-comment,data-list-type). Entering edit mode:startEdit(btn)caches the actions cell HTML intr.dataset.savedActions, replaces each<td>content with an appropriate input/select pre-filled fromdata-*, and adds class.editing. Mutual exclusion:startEditcancels any open.editingrow first. Cancelling:cancelEdit(btn)restores cells fromdata-*attributes, restores actions cell fromsavedActions, callshtmx.process(cells[7])to re-register delete-button htmx attributes. Saving:saveEdit(btn)gathers all named inputs viaURLSearchParams, callsfetch()PUT. 200 OK → server returns full<tbody>(correct sort order); JS doestbody.outerHTML = htmlthenhtmx.process(newTbody). 422 → server returns plain-text error; JS appends inline<small class="edit-error">to the ID cell (always in viewport). All edit JS (delegated click listener,startEdit,cancelEdit,saveEdit, helperssel,esc) lives in one<script>block inRulesPage. ID is read-only during edit. New routes:PUT /api/rules/whitelist/{id}andPUT /api/rules/blacklist/{id}(auth required).
- Request/Response Processing: Streaming with io.Copy, minimal buffering
- Size Limits: None (rely on timeouts for protection, not artificial limits)
- Missing Files: Not critical - proxy continues without user-managed JSON files (secure default: deny all)
- Auto-Generation: Only certificate files auto-generated if missing
- Library Choice:
go-flagsover Cobra+Viper for minimal dependencies - Rationale: See BEST_PRACTICES.md "CLI Configuration Library Choice" section for detailed rationale and trade-offs
--versionflag: Handled ininit()incmd/aiproxy/version.gobefore go-flags runs.init()scansos.Argsfor--version, prints version info, and callsos.Exit(0). A dummyVersion boolfield withlong:"version"is added to theConfigstruct solely so go-flags includes--versionin--helpoutput — it is never actually read. Build-time values (Version,Commit,BuildDate) are injected via-ldflagsbymake build; defaults are"dev"/"unknown"for plaingo build.
D1. Rule.Runtime bool: Exported field, tagged json:"-" (never serialized to
file). false on static rules; true on runtime rules. Used by the WebUI to render
editable vs read-only rows — caller iterates via Range and reads r.Runtime directly.
No new methods needed on ReqRules for this purpose.
D2. Single Load function: LoadWhitelist and LoadBlacklist are identical
implementations. Both are replaced by a single rules.Load(filePath string, opts ...LoadOption) (*reqrules.ReqRules, error). Call-site intent is expressed via the
file path and options, not the function name. Matches the Save function signature
pattern.
D3. WithRuntime() load option: Sets Runtime=true on every rule loaded from
the file. Used to load whitelist2.json / blacklist2.json. Static rule loads pass no
options (default Runtime=false).
D4. Single merged store: Static and runtime rules are merged into the same
*reqrules.ReqRules instance. ReqRules.Add() already re-sorts by ID on every
insertion, so lexicographic order across both sources is preserved automatically.
The proxy's existing allowWhitelist and blockBlacklist handlers need no changes.
D5. Merge order — RO always wins: Static (read-only) rules are loaded after
runtime rules. Because ReqRules.Add() is last-writer-wins by ID, static rules silently
override any runtime rule with the same ID. This is the security guarantee: a compromised
or misconfigured runtime file can never shadow a hardened static rule. It also supports
the normal admin workflow where a runtime rule is copied (and optionally edited) into the
static file — the static version takes effect on the next restart without needing to
remove the rule from the runtime file first. An INFO is logged for each override so the
admin knows a rule has been promoted and the runtime copy can be cleaned up.
D6. Save function: rules.Save(store *reqrules.ReqRules, filePath string, opts ...SaveOption) error. Collects rules from the store via Range, applies options, writes
atomically (temp file in same directory + os.Rename). The directory must already exist
(per DONT.md: never auto-create operational directories).
D7. WithRuntimeOnly() save option: Filters the Range output to only rules where
r.Runtime == true. Used to persist runtime rule changes back to whitelist2.json /
blacklist2.json without including static rules.
D8. Missing runtime file: Not an error — Load returns an empty store. Same policy
as static rule files.
D9. Invalid runtime file: Fatal startup error — Load returns an error, caller logs
and exits with code 1. Same policy as static rule files.
D10. No ReqRules method additions: Del is used directly for runtime rule
deletion (the WebUI handler guards editability by checking rule.Runtime before calling
Del — business logic stays in the handler, not in ReqRules). No DeleteRuntime,
no RuntimeRules methods added to ReqRules.
D12. Load2 helper: Repeated load-static + load-runtime + merge pattern in main.go is
extracted into rules.Load2(staticPath, rtPath string) (*reqrules.ReqRules, error).
Load order: RT rules first, static rules second. Because ReqRules.Add() replaces by
ID, static rules loaded second always win over any RT rule with the same ID (D5).
An INFO is logged for each override. main.go no longer imports reqrules directly.
D11. No proxy changes: proxy.Config and proxy.Proxy are unchanged. The merged
*reqrules.ReqRules is passed to NewProxy exactly as before. Runtime rule management
(add/delete at runtime) is wired to the WebUI in feature #3.
D-PERSIST-1. Filename on ReqRules: reqrules.ReqRules gains an unexported
filename string field and two methods: SetFilename(path string) and Filename() string.
These carry the backing file path as metadata — no file I/O lives in reqrules (the
package stays pure in-memory; all I/O remains in the rules package, consistent with
how loading already works). The mutex already present on ReqRules protects filename
for safe concurrent reads via Filename().
D-PERSIST-2. Load2 sets the filename: After merging static and runtime rules,
rules.Load2 calls store.SetFilename(rtPath) before returning. From that point on
any caller with access to the store can persist runtime rules without knowing the file
path — the path travels with the store, not through config structs.
D-PERSIST-3. Simplified Save signature: rules.Save(store *reqrules.ReqRules) error
replaces the previous Save(store, filePath, opts...) form. The function reads
store.Filename() internally and returns nil (no-op) when the filename is empty
(e.g., stores created with reqrules.New() in tests). It always writes only
Runtime==true rules — the dedicated write target is always the runtime file, never
the static file. SaveOption, WithRuntimeOnly(), and saveConfig are removed.
D-PERSIST-4. Handler call sites: The three WebUI rule mutation handlers (Add, Edit,
Delete in internal/webui/handlers/rules.go) call rules.Save(store) immediately
after mutating the in-memory store. A save failure is non-fatal: the in-memory change
is already live and the proxy continues operating correctly; the error is logged at
ERROR level with slog so operators are aware persistence was lost.
- Algorithm Choice: ECDSA P-256 over RSA 2048/4096
- Rationale: Faster handshakes, smaller keys, equivalent security to RSA 3072, modern standard
- Validity Period: 10 years for operational simplicity in containerized environments
- Validation Strategy: Strict by default (secure by default), optional
--insecure-certsfor relaxed mode - File Permissions: 0600 for private key (critical security), 0644 for certificate (public data)
# Basic usage with defaults
./aiproxy --admin-secret "my-secure-secret-123"
# Without admin secret (WebUI login disabled, but certificate download still works)
./aiproxy
# Custom configuration
./aiproxy \
--admin-secret "my-secure-secret-123" \
--listen ":9090" \
--webui-listen ":9091" \
--blacklist-rules "/etc/aiproxy/blacklist.json" \
--whitelist-rules "/etc/aiproxy/whitelist.json" \
--rt-blacklist-rules "/var/lib/aiproxy/blacklist2.json" \
--rt-whitelist-rules "/var/lib/aiproxy/whitelist2.json" \
--log-level "debug" \
--log-file "/var/log/aiproxy/aiproxy.log" \
--log-max-size 50 \
--log-max-backups 5 \
--global-rate-limit 100 \
--pending-timeout "180s"
# Using combined certificate and key file
./aiproxy \
--admin-secret "my-secure-secret-123" \
--tls-cert "./certs/combined.pem" \
--tls-key "./certs/combined.pem"# Set configuration via environment variables
export AIPROXY_ADMIN_SECRET="my-secure-secret-123"
export AIPROXY_LISTEN=":8080"
export AIPROXY_WEBUI_LISTEN=":8081"
export AIPROXY_BLACKLIST_RULES="/etc/aiproxy/rules/blacklist.json"
export AIPROXY_WHITELIST_RULES="/etc/aiproxy/rules/whitelist.json"
export AIPROXY_RT_BLACKLIST_RULES="/var/lib/aiproxy/data/blacklist2.json"
export AIPROXY_RT_WHITELIST_RULES="/var/lib/aiproxy/data/whitelist2.json"
export AIPROXY_LOG_LEVEL="info"
export AIPROXY_LOG_FILE="/var/log/aiproxy/aiproxy.log"
export AIPROXY_LOG_MAX_SIZE="100"
export AIPROXY_LOG_MAX_BACKUPS="3"
export AIPROXY_GLOBAL_RATE_LIMIT="60"
export AIPROXY_PENDING_TIMEOUT="120s"
# Run with environment variables
./aiproxy# Docker/Podman with environment variables and volume mounts
# Required mounts: rules, data. Certs directory depends on TLS cert/key paths.
podman run -d \
-p 8080:8080 \
-p 8081:8081 \
-v ./rules:/etc/aiproxy/rules:Z \
-v ./data:/var/lib/aiproxy/data:Z \
-v ./certs:/certs:Z \
-e AIPROXY_ADMIN_SECRET="my-secure-secret-123" \
-e AIPROXY_LOG_LEVEL="info" \
-e AIPROXY_GLOBAL_RATE_LIMIT="60" \
aiproxy:latest
# Container with combined certificate file
podman run -d \
-p 8080:8080 \
-p 8081:8081 \
-v ./rules:/etc/aiproxy/rules:Z \
-v ./data:/var/lib/aiproxy/data:Z \
-v ./certs:/certs:Z \
-e AIPROXY_TLS_CERT="/certs/combined.pem" \
-e AIPROXY_TLS_KEY="/certs/combined.pem" \
-e AIPROXY_ADMIN_SECRET="my-secure-secret-123" \
aiproxy:latest[
{
"method": "GET",
"pattern": "https://api.openai.com/v1/**",
"rpm": 10,
"comment": "OpenAI API - limited to 10 req/min"
},
{
"method": "POST",
"pattern": "https://api.anthropic.com/v1/messages",
"rpm": 5,
"comment": "Anthropic Claude API"
},
{
"method": "*",
"pattern": "https://api.github.com/**",
"comment": "GitHub API - uses global rate limit"
}
][
"https://malicious.example.com/**",
"POST https://*/admin/**"
]2026-03-27T10:15:30.123Z 10.0.0.5 GET https://api.openai.com/v1/chat/completions 200 1250ms allowed whitelist[0]
2026-03-27T10:15:31.456Z 10.0.0.5 POST https://malicious.example.com/api 403 2ms blocked_blacklist blacklist[0]
2026-03-27T10:15:35.789Z 10.0.0.6 GET https://unknown-api.com/endpoint 403 120015ms blocked_timeout pending[timeout]
2026-03-27T10:16:42.012Z 10.0.0.5 GET https://api.openai.com/v1/models 200 350ms rate_limited:5.5s whitelist[0]
GET /login- Login pagePOST /login- Submit admin secretGET /logout- Logout
GET /- Main dashboard (public)GET /api/dashboard/stream- SSE live stats stream (public)GET /api/stats- JSON stats summary
GET /pending- Pending requests page (requires auth)GET /api/pending/stream- SSE stream for real-time updatesPOST /api/pending/:id/approve- Approve pending request (add to whitelist2)POST /api/pending/:id/deny- Deny pending request (add to blacklist2)
GET /ratelimit- Rate-limited requests viewer page (requires auth)GET /api/ratelimit/stream- SSE stream for real-time updates
GET /rules- Rules management page (requires auth)GET /api/rules/whitelist- Get all whitelist rules (merged whitelist + whitelist2)GET /api/rules/blacklist- Get all blacklist rules (merged blacklist + blacklist2)POST /api/rules/whitelist- Add rule to whitelist2DELETE /api/rules/whitelist/:id- Delete rule from whitelist2 (cannot delete from whitelist)POST /api/rules/blacklist- Add rule to blacklist2DELETE /api/rules/blacklist/:id- Delete rule from blacklist2
GET /logs- Access log viewer page (requires auth)GET /api/logs- Access log content (with offset/limit)GET /download-cert- Download CA certificate (public, no auth)
All proxy errors return JSON:
{
"error": "forbidden",
"reason": "not in whitelist",
"request_id": "req_abc123def456"
}Error types:
connect_blocked- CONNECT method not allowed (anti-tunneling protection)localhost_blocked- Request targets localhost IP (SSRF protection)forbidden- Blacklisted or pending timeouttimeout- Request timeout exceededinternal_error- Proxy internal errorbad_gateway- Upstream connection/certificate error
HTTP Status Codes:
200- Success403- Forbidden (localhost blocked, blacklist, pending timeout, not in whitelist after timeout)500- Internal proxy error502- Bad Gateway (upstream connection error, invalid/expired upstream certificate)504- Gateway Timeout
- No Daemon Mode: Application runs in foreground (container-first design)
- Idiomatic Go: Clean, readable code over premature optimization
- Streaming-First: Use io.Copy, never buffer entire request/response bodies
- Simple Interval Rate Limiting: No complex token bucket, just
sleep(remaining_interval) - SSE for Real-Time: Use Server-Sent Events, not WebSockets or polling
- Glob Patterns Only: No regex support in v1 (glob covers most use cases)
- Per-Rule Stats: Track statistics at rule level, not per unique URL
- Text Access Logs: Fixed Apache-style format, not JSON (easier to grep)
- Wrapper Mode: Command execution uses
os/exec.CommandContextfor clean cancellation; all parent environment variables passed through plus proxy-specific vars; stdin/stdout/stderr forwarded directly; proxy shutdown via context cancellation when command exits
- Each pending request holds a goroutine (acceptable for hundreds of requests)
- Rate limiting uses simple mutexes per rule (not distributed, single instance only)
- Stats updates are synchronized (mutex or atomic operations)
- SSE connections: one goroutine per WebUI client (expected: 1-5 clients max)
- Localhost IP resolution is per-request (no shared state, thread-safe via Go's net package)
- Proxy listener synchronization: Channel-based (no mutex) -
Start()assignslistenerthen closeslistenerReadychannel;Addr()blocks on channel receive, guaranteeing safe read via Go memory model happens-before semantics
- Network Isolation: Proxy runs in isolated container, WebUI not exposed to public internet
- Trusted Admin: Single admin user, no RBAC needed
- AI Agent Clients: No client authentication (rely on network isolation)
- CA Certificate Trust: Admin manually installs CA cert on AI agent machines
- Concurrent Connections: Hundreds (not thousands)
- Request Throughput: ~100-500 req/sec (sufficient for AI agent workloads)
- Memory: ~50-200MB typical usage (no memory pooling)
- Pending Queue: Unlimited until OOM (acceptable for small installations)
- No graceful shutdown - connections may be dropped on container stop
- No distributed state - single instance only
- Rate limit state resets on restart
- No request/response body inspection (streaming means we don't buffer bodies)
- No fine-grained URL normalization (track per exact URL matched by rule)
- No client authentication (rely on network isolation)
- No concurrent pending request limit (could OOM with thousands of pendings)
When implementing v2+ features from TODO.md:
- Stats schema is extensible (add new fields without breaking existing)
- Rule format supports adding new fields (e.g.,
priority,expires_at) - Access log format is append-only (safe to add new tools parsing it)
- WebUI API is versioned (can add
/api/v2/endpoints later)