-
-
Notifications
You must be signed in to change notification settings - Fork 2
Tiri Proxy Server API
The proxy server library provides a Tiri HTTP/1.x proxy with flow recording, callback hooks, header interception, and
optional upstream proxy chaining. HTTPS traffic is supported through opaque CONNECT tunnelling: encrypted bytes are
relayed between the client and the target server, but the proxy does not decrypt or modify HTTPS traffic.
Load the library with:
import 'net/proxyserver'The import exposes the proxylib namespace.
import 'net/proxyserver'
proxy = proxylib.start({
port = 8080,
bind = '127.0.0.1',
recordFlows = true
})
processing.sleep()
proxy.stop()Proxy = proxylib.start(Options)
Creates and starts a proxy server. The function returns a proxy object when the server socket and timeout timer have been created successfully. It raises an error if startup fails, for example when the server socket cannot be created, the timeout timer cannot be started, an upstream proxy address is invalid, or MITM certificate loading is requested and fails.
Valid options are:
| Option | Description | Default |
|---|---|---|
port |
Listening port. Values outside 1..65535 are ignored and the default is used. |
8080 |
bind |
Optional local address to bind, such as '127.0.0.1'. When unset, the server listens on all interfaces. |
nil |
verbose |
Enables timestamped diagnostic logging. | false |
timeout |
Connection inactivity timeout in seconds. A value less than or equal to zero disables timeout expiry. | 30 |
upstreamProxy |
Optional upstream proxy in host:port form. |
nil |
recordFlows |
Records request/response metadata in memory. | true |
maxFlows |
Maximum number of flows retained in memory. Older flow IDs are pruned first. | 1000 |
enableMITM |
Present for future MITM support. The current proxy still treats CONNECT as an opaque tunnel. |
false |
caCert |
Certificate path used only by the placeholder MITM certificate loader. | nil |
caKey |
Private key path used only by the placeholder MITM certificate loader. | nil |
caPassword |
Private key password used only by the placeholder MITM certificate loader. | nil |
onConnect |
Callback called as onConnect(ClientSocket) when a client connects. |
nil |
onRequest |
Callback called as onRequest(Request) after request headers are parsed. |
nil |
onResponse |
Callback called as onResponse(Response, Flow) after response headers are parsed or a tunnel is established. |
nil |
onFlowRecord |
Callback called as onFlowRecord(Flow) when a flow is created. |
nil |
onError |
Callback called as onError(Error) when the proxy reports an operational error. |
nil |
HTTP requests can be sent in absolute form, as a browser normally sends to a proxy:
GET http://example.com/path HTTP/1.1
Host: example.comOrigin-form requests with a Host header are also accepted:
GET /path HTTP/1.1
Host: example.comAbsolute-form HTTPS requests such as GET https://example.com/ HTTP/1.1 are rejected. HTTPS clients should use
CONNECT host:port, after which the proxy relays bytes without inspecting the encrypted stream.
Proxy.stop()
Stops the server, removes the timeout timer, closes active upstream connections, deactivates connected clients, and clears the proxy connection tables.
Flows = Proxy.getFlows()
Returns the live in-memory flow table. The returned table is not a defensive copy, so callers should avoid mutating it unless they intentionally want to modify the proxy's retained flow state.
local flows = proxy.getFlows()
for id, flow in pairs(flows) do
print(f'Flow {id}: {flow.request.method} {flow.request.url}')
if flow.response then print(f' HTTP {flow.response.status}') end
endProxy.clearFlows()
Removes all retained flows and resets the next flow ID to 1.
Success = Proxy.exportFlows(Filename)
Writes recorded flows to a JSON file and returns true on success. It returns false when the filename is missing,
the flows cannot be encoded, the file cannot be created, or the write does not complete.
if proxy.exportFlows('temp:proxy-flows.json') then
print('Flows exported')
endSuccess = Proxy.loadFlows(Filename)
Loads flows from a JSON file previously written by exportFlows() and returns true on success. Imported flows are
normalised, pruned to maxFlows, and the next recorded flow ID continues after the highest imported ID. Invalid JSON
or unreadable files return false and leave existing flows unchanged.
Proxy.addRequestInterceptor(Interceptor)
Registers a request interceptor. Interceptors run for non-CONNECT HTTP requests after the flow has been created and
before the request headers are forwarded upstream.
proxy.addRequestInterceptor(function(Request:table, Flow:table):table
Request.headers['user-agent'] = 'Kotuku-Proxy/1.0'
Request.headers['x-flow-id'] = tostring(Flow.id)
return Request
end)Request bodies are streamed and are not buffered into Request.body. Interceptors can safely change the request
method, URL, version, and headers. They should not rely on body inspection or body replacement.
Returning a truthy value replaces the current request and marks the flow as modified. Returning nil or false does
not replace the current request and does not block the request. Because the request table is live, in-place changes
still affect forwarding; return the request table when you want the flow to be marked as modified.
Proxy.addResponseInterceptor(Interceptor)
Registers a response interceptor. Interceptors run for non-CONNECT HTTP responses after response headers are parsed
and before those headers are sent to the client.
proxy.addResponseInterceptor(function(Response:table, Flow:table):table
Response.headers['x-proxy-flow'] = tostring(Flow.id)
Response.headers['cache-control'] = 'no-cache'
return Response
end)Response bodies are streamed and are not buffered into Response.body. Interceptors can modify response headers and
status metadata before the header block is rebuilt. They should not rely on response body inspection or replacement.
As with request interceptors, return the response table when you want the flow to be marked as modified.
Proxy.clearInterceptors()
Removes all request and response interceptors.
Parsed request tables contain:
| Field | Description |
|---|---|
method |
HTTP method from the request line. |
url |
Request target exactly as parsed from the request line. |
version |
HTTP version, usually HTTP/1.1. |
headers |
Header table with lower-case header names. |
body |
Currently nil; bodies are streamed. |
streamed |
true for parsed proxy messages. |
raw |
Raw header block used to parse the request. |
Parsed response tables contain:
| Field | Description |
|---|---|
version |
HTTP version from the status line. |
status |
Numeric HTTP status. |
statusText |
HTTP reason phrase. |
headers |
Header table with lower-case header names. |
body |
Currently nil; bodies are streamed. |
streamed |
true for parsed proxy messages. |
raw |
Raw header block used to parse the response. |
When recordFlows is enabled, the proxy records one flow per parsed request. The flow table is updated as response
headers arrive or a CONNECT tunnel is established.
{
id = 1,
timestamp = '2026-05-10 10:00:00.000',
clientIP = '127.0.0.1',
request = {
method = 'GET',
url = 'http://127.0.0.1:8081/hello',
version = 'HTTP/1.1',
headers = { host = '127.0.0.1:8081' },
streamed = true,
raw = 'GET http://127.0.0.1:8081/hello HTTP/1.1\r\n...'
},
response = {
version = 'HTTP/1.1',
status = 200,
statusText = 'OK',
headers = { ['content-type'] = 'text/plain' },
streamed = true,
raw = 'HTTP/1.1 200 OK\r\n...'
},
duration = 0,
intercepted = true,
modified = false
}The current implementation does not calculate response duration; duration is retained for compatibility and is
currently 0.
For a successful CONNECT, the recorded response is a synthetic HTTP/1.1 200 Connection Established response. The
encrypted traffic inside the tunnel is not recorded as separate HTTP flows.
For normal HTTP requests, the interceptor engine marks intercepted as true when request or response headers pass
through it, even when no interceptor functions are registered. modified is set only when an interceptor returns a
truthy replacement value.
Callback exceptions are caught and reported through onError where possible. A failing onRequest, onResponse,
onConnect, or onFlowRecord callback does not normally stop the request from continuing.
Callbacks receive the live request, response, and flow tables used by the proxy. Use interceptors for intentional
request or response modification, especially when you want modified to be updated consistently. When recordFlows is
disabled, Flow arguments are nil.
local proxy = proxylib.start({
port = 8080,
bind = '127.0.0.1',
recordFlows = true,
onRequest = function(Request:table)
print(f'{Request.method} {Request.url}')
end,
onResponse = function(Response:table, Flow:table)
print(f'Flow {Flow.id}: HTTP {Response.status}')
end,
onError = function(Error:table)
print(f'Proxy error {Error.code}: {Error.message}')
end
})Error tables can contain:
| Field | Description |
|---|---|
code |
Stable error code such as malformed_request, connection_timeout, or upstream_connect_failed. |
message |
Human-readable error message. |
timestamp |
Time the error was reported. |
clientIP |
Client address when a connection state is available. |
phase |
Internal connection phase when available. |
targetHost |
Target host when known. |
targetPort |
Target port when known. |
method |
Request method when known. |
url |
Request URL when known. |
error |
Underlying exception or system error text when available. |
Common error codes include:
| Code | Meaning |
|---|---|
invalid_upstream_proxy |
upstreamProxy was not in valid host:port form. |
malformed_request |
The client sent an invalid HTTP request header block. |
malformed_connect_target |
A CONNECT request target was not in host:port form. |
target_resolution_failed |
The proxy could not determine the origin host for an HTTP request. |
https_without_connect |
The client attempted absolute-form HTTPS relay instead of CONNECT. |
connection_timeout |
A connection was inactive for longer than timeout. |
upstream_connect_failed |
The proxy could not connect to the target or upstream proxy. |
upstream_proxy_connect_failed |
The configured upstream proxy rejected a CONNECT request. |
request_interceptor_failed |
A request interceptor raised an error. |
response_interceptor_failed |
A response interceptor raised an error. |
Set upstreamProxy to forward all target connections through another proxy.
local proxy = proxylib.start({
port = 3128,
bind = '127.0.0.1',
upstreamProxy = 'corporate-proxy.example:8080',
recordFlows = false
})For normal HTTP requests, the downstream proxy forwards absolute-form request lines to the upstream proxy and removes
Proxy-Connection. For CONNECT, it connects to the upstream proxy first, sends CONNECT target:port HTTP/1.1, and
then relays tunnel bytes after the upstream proxy returns a 2xx response.
Proxy authentication is not implemented by this library.
The current implementation does not provide HTTPS man-in-the-middle interception. CONNECT is deliberately handled as
an opaque TCP tunnel so that clients can establish TLS directly with the target server.
The enableMITM, caCert, caKey, and caPassword options exist in the library, but MITM request parsing,
per-host certificate generation, and decrypted HTTPS interception are deferred. Enabling enableMITM only attempts to
load the configured certificate material; it does not change CONNECT tunnel behaviour.
The proxy buffers and parses only HTTP headers. Request and response bodies are streamed.
Request headers larger than the internal header limit return 400 Bad Request. Response headers larger than the limit
are reported through onError; the proxy then switches to raw forwarding for that response when possible.
The proxy forces Connection: close on forwarded HTTP requests. Persistent HTTP connection reuse is not part of the
current behaviour.
For a runnable command-line wrapper around this library, see apps/proximity.tiri.