Skip to content

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

go get github.com/dortanes/prox/sdk@latest

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 pass connection.

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:

plugins: {
  auth: { path: "./plugins/auth", usage_interval: "30s" }  // default: 15s
}

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.

p.DropSessions("acct-1842")

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:

p.SetSpeedLimit(routeID, sdk.SpeedLimit{DownloadMbps: 50, GroupKey: userID})

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