Go SDK¶
The Go SDK (github.com/dortanes/prox/sdk) provides a callback-based API for building plugins. It handles all transport details — stdin/stdout JSON messaging, Unix socket msgpack framing, and the ready handshake.
Windows
OnConfigure and push APIs can run over stdin/stdout on Windows. OnRequest, OnResponse, OnConnect, OnDisconnect, and OnUsage require Unix domain sockets and are supported on Linux and macOS.
Installation¶
Quick Start¶
Auth Plugin¶
package main
import "github.com/dortanes/prox/sdk"
func main() {
p := sdk.New()
p.OnRequest(func(req *sdk.Request) *sdk.Response {
token := req.Header("Authorization")
if token == "" {
return sdk.Deny(401, "Unauthorized")
}
return sdk.Allow(sdk.WithHeader("X-User-ID", "123"))
})
p.Run()
}
Target Discovery Plugin¶
package main
import "github.com/dortanes/prox/sdk"
func main() {
p := sdk.New()
p.OnConfigure(func(route sdk.Route) {
go func() {
targets := discoverTargets(route.Domain)
p.SetTargets(route.ID, targets)
}()
})
p.Run()
}
Hooks¶
OnConfigure(func(route Route))¶
Called when the plugin receives route configuration (on startup and reload).
Route fields:
| Field | Type | Description |
|---|---|---|
ID |
string |
Stable identifier (service:routeIndex) |
Domain |
string |
Domain pattern from config |
Path |
string |
Path pattern from config |
OnRequest(func(req *Request) *Response) — L7¶
Called for every HTTP request on the route. Returns a verdict:
sdk.Allow(opts...)— approve the request, optionally inject headers.sdk.Deny(status, body, opts...)— reject with an HTTP error response.sdk.Drop()— silently close the connection (no HTTP response).
p.OnRequest(func(req *sdk.Request) *sdk.Response {
// req.Method, req.Path, req.Domain, req.RemoteAddr
// req.Query, req.Host, req.Proto, req.ContentLength
// req.Header("Authorization"), req.Headers, req.Body
// req.QueryParam("token")
// req.MatchDomain, req.MatchGlob, req.MatchPath, req.Vars, req.Target
return sdk.Allow(sdk.WithHeader("X-Verified", "true"))
})
OnResponse(func(req *Request, resp *UpstreamResponse) *ResponseMod) — L7¶
Called after the upstream responds, before headers are sent to the client. Modify or remove headers, or override the status code.
p.OnResponse(func(req *sdk.Request, resp *sdk.UpstreamResponse) *sdk.ResponseMod {
return sdk.ModifyResponse(
sdk.WithResponseHeader("X-Frame-Options", "DENY"),
sdk.RemoveResponseHeader("Server"),
sdk.WithResponseStatus(200),
)
})
OnConnect(func(conn *ConnRequest) *ConnResponse) — L4¶
Called for raw TCP connections on pass routes (before TLS relay). Only SNI domain and remote address are available.
p.OnConnect(func(conn *sdk.ConnRequest) *sdk.ConnResponse {
if isBlacklisted(conn.RemoteAddr) {
return sdk.RejectConn()
}
return sdk.AcceptConn()
})
OnDisconnect(func(event *DisconnectEvent)) — Fire-and-Forget¶
Called after a connection ends (handler returns). Receives connection statistics. Unlike other hooks, this is fire-and-forget — no response is sent back to prox.
p.OnDisconnect(func(event *sdk.DisconnectEvent) {
log.Printf("route=%s target=%s bytes_rx=%d bytes_tx=%d duration=%dms",
event.RouteID, event.Target, event.BytesRx, event.BytesTx, event.DurationMs)
})
DisconnectEvent fields:
| Field | Type | Description |
|---|---|---|
RouteID |
string |
Route identifier (service:routeIndex) |
Target |
string |
Backend target host (SNI/domain) |
RemoteAddr |
string |
Client IP:port |
BytesRx |
int64 |
Bytes received from client (upload) |
BytesTx |
int64 |
Bytes transmitted to client (download) |
DurationMs |
int64 |
Connection duration in milliseconds |
Session |
string |
Session key, empty when none was set |
OnDisconnect fires for HTTP requests and streams. L4 pass connections are reported through OnUsage only.
OnUsage(func(report *UsageReport)) — Fire-and-Forget¶
Called every usage_interval with the bytes each session moved on the plugin's routes since the previous report, including connections that are still open. See Sessions and usage.
p.OnUsage(func(r *sdk.UsageReport) {
for _, u := range r.Sessions {
billing.Add(u.Session, u.BytesRx+u.BytesTx)
}
})
UsageReport fields:
| Field | Type | Description |
|---|---|---|
TimestampMs |
int64 |
Report time in Unix milliseconds |
Sessions |
[]SessionUsage |
One entry per session and route |
SessionUsage fields:
| Field | Type | Description |
|---|---|---|
RouteID |
string |
Route identifier (service:routeIndex) |
Session |
string |
Session key |
BytesRx |
int64 |
Bytes received from the client since last time |
BytesTx |
int64 |
Bytes sent to the client since last time |
Request Fields¶
| Field | Type | Description |
|---|---|---|
RouteID |
string |
Route identifier (service:routeIndex) |
Method |
string |
HTTP method (GET, POST, etc.) |
Path |
string |
Request path (/api/users) |
Query |
string |
Raw query string (foo=bar&baz=1) |
Domain |
string |
Request host without port |
Host |
string |
Full Host header including port |
Proto |
string |
HTTP protocol (HTTP/1.1, HTTP/2.0) |
RemoteAddr |
string |
Client IP:port |
ContentLength |
int64 |
Request body size (-1 if unknown) |
Headers |
map[string]string |
All request headers (first value only) |
Body |
[]byte |
Request body (capped at 64KB) |
MatchDomain |
string |
Captured * wildcard value(s) |
MatchGlob |
string |
Captured ** glob suffix |
MatchPath |
string |
Path pattern from config |
Vars |
map[string]string |
Route-level set variables |
Target |
string |
Backend target selected by balancer |
Helper Methods¶
| Method | Description |
|---|---|
req.Header(key) |
Returns the value of a request header |
req.QueryParam(key) |
Returns the value of a query parameter |
Response Helpers¶
Request Verdicts¶
sdk.Allow(opts...) // Approve request
sdk.Deny(status, body, opts...) // Reject with HTTP response
sdk.Fallback(opts...) // Route to the fallback action
sdk.Drop() // Silently close connection
sdk.WithHeader(key, value) // Inject header (allow: into request, deny: into response)
sdk.WithSpeedLimit(down, up) // Per-connection bandwidth cap (Mbps)
sdk.WithSpeedLimit(down, up, groupKey)// Grouped bandwidth cap (shared by connections with same key)
sdk.WithCleanQuery() // Remove query string from upstream request
sdk.WithRewritePath(path) // Override upstream request path
sdk.WithGroup(group) // Pick the balancer target from a named group
sdk.WithTarget(target) // Pin one exact target (wins over WithGroup)
sdk.WithSession(key) // Tag the stream with a session key
Response Modifications¶
sdk.ModifyResponse(opts...) // Modify upstream response
sdk.NoResponseMod() // Pass through unchanged
sdk.WithResponseHeader(key, value) // Add or override response header
sdk.RemoveResponseHeader(key) // Remove response header
sdk.WithResponseStatus(status) // Override status code
L4 Connection Verdicts¶
sdk.AcceptConn() // Allow TCP connection
sdk.RejectConn() // Close TCP connection
sdk.WithConnGroup(group) // Pick the balancer target from a named group
sdk.WithConnTarget(target) // Pin one exact target (wins over WithConnGroup)
sdk.WithConnSession(key) // Tag the connection with a session key
Sessions and Usage¶
A session key is an opaque string, such as an account ID, that a plugin attaches to a connection from OnRequest with sdk.WithSession or from OnConnect with sdk.WithConnSession. prox keeps it until that connection or stream ends:
- an HTTP request, including a forward-proxy request;
- a WebSocket or any other HTTP/1.1 upgrade, for the life of the upgraded connection;
- a CONNECT tunnel over HTTP/1.1 or HTTP/2;
- one HTTP/2 stream (other streams on the same connection carry their own keys);
- an L4
passconnection.
The key is separate from the group key of sdk.WithSpeedLimit; pass the same value to both to limit and meter one account together.
p.OnRequest(func(req *sdk.Request) *sdk.Response {
acct := lookupAccount(req.Header("Authorization"))
if acct == "" {
return sdk.Deny(401, "Unauthorized")
}
return sdk.Allow(sdk.WithSession(acct), sdk.WithSpeedLimit(50, 10, acct))
})
What is counted¶
BytesRx counts bytes from the client to prox and BytesTx bytes from prox to the client:
| Connection | Counted bytes |
|---|---|
| HTTP request or HTTP/2 stream, CONNECT over HTTP/2 | Request and response bodies |
| WebSocket, other upgrades, CONNECT over HTTP/1.1 | Every byte after the request headers, including the response that switches protocols |
L4 pass |
Every TCP byte, TLS records included |
Connections without a session key are left out of usage reports. prox counts bytes only on routes where a bound plugin subscribes to OnUsage or OnDisconnect.
Accounting model¶
Usage reports are the billing source. Every byte of a session appears in exactly one report: a report holds the bytes moved since the previous one, and the last bytes of a closed connection arrive in the first report after it closes. A plugin that sums the reports per session gets the exact total, with no reconciliation.
DisconnectEvent repeats the totals of a stream that already went into usage reports. Treat it as informational and never add its bytes to the sum.
A report the plugin cannot receive, for example while it restarts, is kept and its bytes are sent with the next report. When prox shuts down or the plugin is removed from the config, prox sends a final report first.
The interval is set per plugin in the prox config:
Dropping sessions¶
p.DropSessions(keys...) closes every connection and stream on this prox instance that carries one of the keys, upstream side included. It returns at once; prox closes the connections in the background. Keys with no live connections are ignored.
An HTTP/1.1 client sees its connection close. On HTTP/2 only the tagged stream is reset. New requests from the same client go through OnRequest again, so deny them there once access is revoked.
Push API¶
Target discovery and speed limiting use push methods over stdin/stdout (no socket required).
Target Updates¶
// By route ID:
p.SetTargets(routeID, []string{"10.0.1.1:8080", "10.0.1.2:8080"})
// By action name — updates all routes using the given action:
p.SetActionTargets("dynamic_proxy", []string{"10.0.1.1:8080", "10.0.1.2:8080"})
// Wildcard — updates all routes with balancers:
p.SetTargets("*", []string{"10.0.1.1:8080"})
Grouped Targets¶
p.SetGroupedTargets(routeID, map[string][]string{
"de": {"de-1:8080", "de-2:8080"},
"us": {"us-1:8080"},
})
p.SetActionGroupedTargets("dynamic_proxy", map[string][]string{
"de": {"de-1:8080", "de-2:8080"},
"us": {"us-1:8080"},
})
With domain pattern *.**, a request to de.example.com captures de — the balancer picks from the "de" group only. Each group gets its own sub-balancer using the route's strategy.
Per-Request Group Selection¶
sdk.WithGroup overrides the group for a single request, so the choice can come from anything the on_request hook sees — a header, a token, a database lookup — instead of the domain:
p.OnRequest(func(req *sdk.Request) *sdk.Response {
if tier := lookupTier(req.Header("Authorization")); tier != "" {
return sdk.Allow(sdk.WithGroup(tier))
}
return sdk.Allow()
})
The target reserved during route matching is released before the new one is picked, so leastconn counters stay accurate. If the named group has no available target and the route has no fallback, the request is routed to the action's fallback handler, or answered with 502 when none is configured. Routes without a balancer ignore the group and log a warning.
L4 pass routes work the same way through sdk.WithConnGroup, which picks the group for the whole TCP connection:
p.OnConnect(func(conn *sdk.ConnRequest) *sdk.ConnResponse {
return sdk.AcceptConn(sdk.WithConnGroup(regionFor(conn.RemoteAddr)))
})
Here the target is chosen after the hook returns, so nothing needs releasing. A group with no available target closes the connection — L4 has no fallback action.
Per-Request Target Pinning¶
sdk.WithGroup narrows the choice to a pool; sdk.WithTarget removes the choice entirely and names the upstream to use. It takes precedence over sdk.WithGroup, and the address may be any target the plugin has published — a member of any group, not only the one the domain resolves to:
p.SetGroupedTargets("*", map[string][]string{
"de": {"de-1:8080", "de-2:8080"},
"us": {"us-1:8080"},
})
p.OnRequest(func(req *sdk.Request) *sdk.Response {
if pinned := sessionServer(req.Header("Cookie")); pinned != "" {
return sdk.Allow(sdk.WithTarget(pinned)) // e.g. "de-2:8080"
}
return sdk.Allow(sdk.WithGroup("de"))
})
A pinned target that belongs to the balancer is reserved in the sub-pool that owns it, so leastconn counts it like any other request and releases it when the request finishes. A target outside every pool is still used — the plugin is trusted — but no strategy can account for it, so prox logs a warning. Either way the target picked during route matching is released first.
L4 pass routes use sdk.WithConnTarget with the same precedence and the same pool lookup:
p.OnConnect(func(conn *sdk.ConnRequest) *sdk.ConnResponse {
return sdk.AcceptConn(sdk.WithConnTarget("de-2:443"))
})
Routes whose upstream has no {target} placeholder ignore a pinned target, exactly as they ignore a group.
Speed Limiting¶
// By route ID:
p.SetSpeedLimit(routeID, sdk.SpeedLimit{DownloadMbps: 50, UploadMbps: 10})
// By action name:
p.SetActionSpeedLimit("proxy", sdk.SpeedLimit{DownloadMbps: 100})
// Wildcard — all routes:
p.SetSpeedLimit("*", sdk.SpeedLimit{DownloadMbps: 25})
When GroupKey is set, the push updates the rate for active group buckets with that key:
When multiple limits apply (config, push, response), the most restrictive value wins.
Method Reference¶
| Method | Description |
|---|---|
SetTargets(routeID, targets) |
Push flat targets for a specific route (or "*" for all) |
SetGroupedTargets(routeID, groups) |
Push grouped targets for a specific route (or "*" for all) |
SetActionTargets(action, targets) |
Push flat targets for all routes using the given action |
SetActionGroupedTargets(action, groups) |
Push grouped targets for all routes using the given action |
SetSpeedLimit(routeID, limit) |
Push speed limit for a specific route (or "*" for all) |
SetActionSpeedLimit(action, limit) |
Push speed limit for all routes using the given action |
DropSessions(keys...) |
Close every connection carrying one of the session keys |