Skip to content

Tiri Proxy Server API

Paul Manias edited this page May 12, 2026 · 1 revision

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()

Functions

proxylib.start()

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.com

Origin-form requests with a Host header are also accepted:

GET /path HTTP/1.1
Host: example.com

Absolute-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.

Server Methods

Proxy.stop()

Proxy.stop()

Stops the server, removes the timeout timer, closes active upstream connections, deactivates connected clients, and clears the proxy connection tables.

Proxy.getFlows()

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
end

Proxy.clearFlows()

Proxy.clearFlows()

Removes all retained flows and resets the next flow ID to 1.

Proxy.exportFlows()

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')
end

Proxy.loadFlows()

Success = 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()

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()

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()

Proxy.clearInterceptors()

Removes all request and response interceptors.

Request And Response Tables

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.

Flow Recording

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.

Event Callbacks

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.

Upstream Proxy Chaining

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.

HTTPS And MITM Status

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.

Operational Notes

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.

Clone this wiki locally