This document describes the OpenCtrl master process: startup configuration, HTTP resources, event streaming, state behavior, and runtime process contract.
- Model
- Command Line
- Master URL
- API Conventions
- Authentication
- Data Types
- Master Info
- Instances
- Events
- TCP Ping
- State
- Logging
- Security Notes
- Runtime Process Contract
- Client Patterns
- Implementation Limits
- Release Build
OpenCtrl runs one master process. The master stores instance definitions and starts one child process for each running instance:
<runtime-binary> <instance-url>
The master owns:
- the HTTP API;
- API-key authentication;
- instance lifecycle state;
- child process start, stop, restart, and delete operations;
- Server-Sent Events;
- gob state persistence;
- TLS termination for the API;
- process-level system information.
Managed child processes own their own URL validation and runtime behavior. A child should remain in the foreground while the instance is active and should exit when the master asks it to stop.
openctrl <configuration-url>
openctrl help
openctrl versionConfiguration URLs use the master scheme:
openctrl 'master://127.0.0.1:8080'
openctrl 'master://0.0.0.0:8443/admin?tls=1'
openctrl 'master://0.0.0.0:8443?tls=2&crt=/etc/openctrl/fullchain.pem&key=/etc/openctrl/privkey.pem'Unknown URL schemes are rejected before the process starts.
master://<host>:<port>[/prefix][?<query>]
The URL host and port become the HTTP listen address. The path configures the
API prefix. If the path is empty or /, OpenCtrl uses /api. The API version
is appended automatically.
| Master URL | API base |
|---|---|
master://127.0.0.1:8080 |
http://127.0.0.1:8080/api/v2 |
master://127.0.0.1:8080/control |
http://127.0.0.1:8080/control/v2 |
master://127.0.0.1:8443?tls=1 |
https://127.0.0.1:8443/api/v2 |
| Parameter | Values | Default | Description |
|---|---|---|---|
tls |
0, 1, 2 |
0 |
0 serves HTTP, 1 serves HTTPS with an in-memory self-signed certificate, 2 serves HTTPS with crt and key. |
crt |
file path | empty | Certificate file used when tls=2. |
key |
file path | empty | Private key file used when tls=2. |
bin |
file path | current executable | Runtime binary used to launch managed instances. |
TLS mode 2 reloads the configured certificate and key at most once per hour
when a new TLS handshake asks for a certificate. All TLS-enabled modes require
TLS 1.3 or newer.
All endpoints are relative to the API base. With the default prefix, the base is:
/api/v2
Requests that carry JSON bodies should send:
Content-Type: application/json
Responses are plain JSON resources. There is no success envelope.
| Operation | Success shape |
|---|---|
GET /info |
master info object |
POST /info |
master info object |
GET /instances |
instance array |
POST /instances |
instance object |
GET /instances/{id} |
instance object |
PATCH /instances/{id} |
instance object |
PUT /instances/{id} |
instance object |
DELETE /instances/{id} |
empty body |
GET /tcping |
TCP ping result object |
Errors use:
{
"error": "message"
}| Status | Meaning |
|---|---|
200 OK |
Request succeeded. |
201 Created |
Instance was accepted and stored. |
204 No Content |
Instance was deleted. |
400 Bad Request |
Body, URL, action, or query parameter is invalid. |
401 Unauthorized |
API key is missing or invalid. |
403 Forbidden |
Operation is not allowed on the internal API-key instance. |
404 Not Found |
Instance ID does not exist. |
405 Method Not Allowed |
Route exists, but the HTTP method is unsupported. |
409 Conflict |
Instance ID collision or unchanged replacement URL. |
Connection failures in GET /tcping are returned as 200 OK with
connected: false and a string error value.
Protected routes also handle OPTIONS preflight requests. Responses include:
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, PATCH, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization, X-API-Key, Cache-Control
Plan deployments as if any origin can attempt to call the API. The API key and network boundary remain the access-control mechanism.
| Method | Path | Description |
|---|---|---|
GET |
/info |
Return master identity, runtime configuration, uptime, and system metrics. |
POST |
/info |
Set the master alias. |
GET |
/instances |
Return all instances, including the internal API-key instance. |
POST |
/instances |
Create an instance and start it asynchronously. |
GET |
/instances/{id} |
Return one instance. |
PATCH |
/instances/{id} |
Update alias, restart policy, metadata, or lifecycle action. |
PUT |
/instances/{id} |
Replace the instance URL and restart the instance. |
DELETE |
/instances/{id} |
Stop and delete the instance. |
GET |
/events |
Open the Server-Sent Events stream. |
GET |
/tcping?target=host:port |
Test TCP reachability from the master process. |
OpenCtrl does not expose a generated API schema route.
GET /instances is backed by a concurrent map. Treat list ordering as
undefined and sort by id, alias, or another client-side key when a stable
display order is required.
Lifecycle operations may continue after the HTTP response:
POST /instancesstores the instance, starts it in a goroutine, and returns a snapshot.PATCH /instances/{id}returns after recording metadata and policy changes;start,stop, andrestartrun in goroutines.PUT /instances/{id}stops the old process, stores the new URL, starts the replacement, and then returns a snapshot.
Use the event stream as the source of live transitions and periodically refresh
from GET /instances if missing an event would be unacceptable.
Protected endpoints require:
X-API-Key: <api-key>
On the first successful start, OpenCtrl generates an API key and prints:
Master.run: API key created: <api-key>
On later starts, it prints:
Master.run: API key loaded: <api-key>
The key is persisted in the internal instance with ID ********. Its url
field stores the API key, and its config field stores the master ID.
Regenerate the key:
curl -X PATCH "${BASE}/instances/********" \
-H "X-API-Key: ${API_KEY}" \
-H "Content-Type: application/json" \
-d '{"action":"restart"}'The regenerated key is printed to stdout. Existing SSE clients are sent a
shutdown event or are closed, and subsequent requests must use the new key.
The internal API-key instance is visible in GET /instances. Avoid rendering
or storing its url value outside trusted configuration.
The following TypeScript declarations mirror the JSON resources exposed by the API.
export type InstanceStatus = "stopped" | "running" | "error";
export type InstanceAction = "start" | "stop" | "restart" | "reset";
export type TlsMode = "0" | "1" | "2";
export interface PeerMeta {
sid: string;
type: string;
alias: string;
}
export interface InstanceMeta {
peer: PeerMeta;
tags: Record<string, string>;
}
export interface Instance {
id: string;
alias: string;
type: string;
status: InstanceStatus;
url: string;
config: string;
restart: boolean;
meta: InstanceMeta;
mode: number;
ping: number;
pool: number;
tcps: number;
udps: number;
tcprx: number;
tcptx: number;
udprx: number;
udptx: number;
}
export interface MasterInfo {
mid: string;
alias: string;
os: string;
arch: string;
noc: number;
cpu: number;
mem_total: number;
mem_used: number;
swap_total: number;
swap_used: number;
netrx: number;
nettx: number;
diskr: number;
diskw: number;
sysup: number;
ver: string;
name: string;
uptime: number;
log: string;
tls: TlsMode;
crt: string;
key: string;
}
export interface InstanceEvent {
type: "initial" | "create" | "update" | "delete" | "log" | "shutdown";
time: string;
instance: Instance | null;
logs: string;
}
export interface TcpPingResult {
target: string;
connected: boolean;
latency: number;
error: string | null;
}
export interface ApiError {
error: string;
}Numeric byte counters are JSON numbers. Preserve them as numbers unless your client environment cannot safely represent large counters; in that case, keep the latest raw JSON and display rounded values.
Read master info:
curl -H "X-API-Key: ${API_KEY}" "${BASE}/info"Set master alias:
curl -X POST "${BASE}/info" \
-H "X-API-Key: ${API_KEY}" \
-H "Content-Type: application/json" \
-d '{"alias":"prod-master-1"}'The alias is limited to 256 characters and is persisted through the internal API-key instance.
Example response:
{
"mid": "1a2b3c4d5e6f7890",
"alias": "prod-master-1",
"os": "linux",
"arch": "amd64",
"noc": 8,
"cpu": 12,
"mem_total": 16777216000,
"mem_used": 7340032000,
"swap_total": 0,
"swap_used": 0,
"netrx": 123456,
"nettx": 654321,
"diskr": 1048576,
"diskw": 2097152,
"sysup": 86400,
"ver": "dev",
"name": "127.0.0.1",
"uptime": 42,
"log": "default",
"tls": "0",
"crt": "",
"key": ""
}| Field | Unit or meaning |
|---|---|
mid |
16-character hexadecimal master ID. |
alias |
Operator-defined master name. |
os, arch |
Go runtime operating system and architecture. |
noc |
CPU count from the Go runtime. |
cpu |
Linux CPU utilization percentage over a short sample; -1 elsewhere. |
mem_total, mem_used |
Bytes from /proc/meminfo on Linux. |
swap_total, swap_used |
Bytes from /proc/meminfo on Linux. |
netrx, nettx |
Bytes across non-loopback, non-container interfaces on Linux. |
diskr, diskw |
Disk bytes from /proc/diskstats on Linux. |
sysup |
System uptime in seconds on Linux. |
ver |
Build version from main.version. |
name |
Hostname from the listen address or certificate server name. |
uptime |
Master process uptime in seconds. |
log |
Compatibility field retained for older clients; currently empty. |
tls, crt, key |
Effective startup TLS configuration values. |
Linux hosts return platform metrics from /proc. Other operating systems
return cpu: -1 and zero-valued platform metrics.
{
"id": "d34db33f",
"alias": "edge-a",
"type": "managed",
"status": "running",
"url": "managed://edge-a",
"config": "",
"restart": true,
"meta": {
"peer": {
"sid": "site-1",
"type": "edge",
"alias": "Site 1 Edge"
},
"tags": {
"region": "us-east"
}
},
"mode": 0,
"ping": 12,
"pool": 4,
"tcps": 10,
"udps": 2,
"tcprx": 123456,
"tcptx": 654321,
"udprx": 2048,
"udptx": 4096
}| Field | Description |
|---|---|
id |
Random 8-character hexadecimal instance ID. |
alias |
Operator-defined display name. |
type |
URL scheme from the instance URL. |
status |
stopped, running, or error. |
url |
Normalized instance URL. |
config |
Reserved runtime configuration field. The internal API-key instance stores the master ID here. |
restart |
Whether the periodic task should restart the instance when it is in error. |
meta.peer |
Optional peer metadata with sid, type, and alias. |
meta.tags |
Optional string map for operator metadata. |
mode, ping, pool |
Runtime metrics parsed from checkpoints. |
tcps, udps |
Active TCP and UDP counts parsed from checkpoints. |
tcprx, tcptx, udprx, udptx |
Byte counters parsed from checkpoints. |
Status meanings:
| Status | Meaning |
|---|---|
stopped |
No child process is active. |
running |
The child process has started and is being monitored. |
error |
Start failed, the child exited with an error, a log line contained ERROR, or checkpoint reporting timed out after previously reporting. |
curl -H "X-API-Key: ${API_KEY}" "${BASE}/instances"The response is an array of instance objects. It includes the internal
******** instance.
Client-side handling recommendations:
- key local state by
id; - sort the array before displaying it;
- redact or hide the internal
********instance unless explicitly managing API credentials; - treat
urlas sensitive when it may contain credentials; - keep a bounded log buffer per instance rather than storing unlimited log events.
curl -X POST "${BASE}/instances" \
-H "X-API-Key: ${API_KEY}" \
-H "Content-Type: application/json" \
-d '{"alias":"edge-a","url":"managed://edge-a"}'Request body:
| Field | Required | Description |
|---|---|---|
url |
yes | Instance URL. It must have a scheme and must not use master://. |
alias |
no | Initial display name. |
Creation rules:
- the instance ID is generated from four random bytes;
- the URL scheme becomes
type; restartdefaults totrue;meta.tagsstarts as an empty map;- the child process starts asynchronously after storage.
The response status is 201 Created with the created instance snapshot. The
snapshot may still be stopped while the child process is starting; consume
update events or refresh the instance before presenting it as fully running.
curl -H "X-API-Key: ${API_KEY}" "${BASE}/instances/${ID}"Use this endpoint to reconcile one row after a missed event, a failed local transition, or a reconnect.
curl -X PATCH "${BASE}/instances/${ID}" \
-H "X-API-Key: ${API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"alias": "edge-b",
"action": "restart",
"restart": true,
"meta": {
"peer": {
"sid": "site-1",
"type": "edge",
"alias": "Site 1 Edge"
},
"tags": {
"region": "us-east",
"env": "prod"
}
}
}'Patch fields:
| Field | Description |
|---|---|
alias |
Updates the alias when non-empty. Maximum length is 256 characters. |
action |
One of start, stop, restart, or reset. |
restart |
Sets the auto-restart policy. |
meta.peer |
Replaces peer metadata fields. Each field is limited to 256 characters. |
meta.tags |
Replaces the full tags map. Keys and values are limited to 256 characters. |
Actions:
| Action | Effect |
|---|---|
start |
Starts the child if the instance is stopped. |
stop |
Stops the child if it is active. |
restart |
Stops the child, then starts it. |
reset |
Resets byte counters while preserving counter bases for future checkpoints. |
Lifecycle actions run asynchronously except reset, which is applied before
the response is returned. If a control button or automation triggers start,
stop, or restart, treat the response as an accepted command and wait for an
update event or a follow-up GET.
For the internal ******** instance, only {"action":"restart"} has a special
effect: it regenerates the API key. Other PATCH bodies return the current
snapshot without changing it.
meta.tags is a full replacement, not a merge. Preserve existing tags when
updating one key:
async function setTag(api: OpenCtrlClient, id: string, key: string, value: string) {
const instance = await api.getInstance(id);
await api.patchInstance(id, {
meta: {
peer: instance.meta.peer,
tags: { ...instance.meta.tags, [key]: value },
},
});
}Use tags for filtering, grouping, ownership, environment, region, or any other operator-defined labels. Keep keys stable and values short; the server enforces the 256-character limit only on PATCH.
curl -X PUT "${BASE}/instances/${ID}" \
-H "X-API-Key: ${API_KEY}" \
-H "Content-Type: application/json" \
-d '{"url":"managed://edge-b"}'The URL must have a non-master scheme. If the normalized URL is unchanged,
OpenCtrl returns 409 Conflict. Otherwise, the master stops the current child,
stores the new URL and type, and starts the instance again.
The internal ******** instance cannot be updated with PUT.
curl -X DELETE \
-H "X-API-Key: ${API_KEY}" \
"${BASE}/instances/${ID}"The master marks the instance deleted, stops the child, removes it from memory,
persists state, and emits a delete event. The internal ******** instance
cannot be deleted.
After a successful delete, remove local state immediately. A later delete
event for the same ID should be idempotent.
The event stream is easiest to consume with an ID-keyed reducer:
type InstanceMap = Map<string, Instance>;
export function applyInstanceEvent(state: InstanceMap, event: InstanceEvent) {
if (event.type === "shutdown") {
return;
}
const instance = event.instance;
if (!instance) {
return;
}
if (event.type === "delete") {
state.delete(instance.id);
return;
}
state.set(instance.id, instance);
}Keep logs outside the instance map:
export function appendBoundedLog(
logs: Map<string, string[]>,
event: InstanceEvent,
limit = 500,
) {
if (event.type !== "log" || !event.instance || event.logs === "") {
return;
}
const lines = logs.get(event.instance.id) ?? [];
lines.push(event.logs);
if (lines.length > limit) {
lines.splice(0, lines.length - limit);
}
logs.set(event.instance.id, lines);
}Open the stream:
curl -N -H "X-API-Key: ${API_KEY}" "${BASE}/events"SSE messages use the event name instance:
event: instance
data: {"type":"update","time":"2026-06-08T12:00:00Z","instance":{...},"logs":""}
The stream starts with:
retry: 3000
Then it sends one initial event for each stored instance.
Event payload:
{
"type": "update",
"time": "2026-06-08T12:00:00Z",
"instance": {
"id": "d34db33f"
},
"logs": ""
}Event types:
| Type | Description |
|---|---|
initial |
Sent once per current instance when an SSE client connects. |
create |
Sent after an instance is created. |
update |
Sent when lifecycle state, metadata, counters, or API-key state changes. |
delete |
Sent after an instance is removed. |
log |
Sent for non-checkpoint child output. The logs field contains the line. |
shutdown |
Sent when the master is shutting down or when API key regeneration closes SSE clients. |
Subscriber channels are bounded. If a subscriber or the global event dispatcher falls behind, events may be dropped instead of blocking the master.
Some SSE clients cannot attach custom request headers. Use a client that can
send X-API-Key, or read the stream with a normal HTTP request:
export async function readEvents(
base: string,
apiKey: string,
onEvent: (event: InstanceEvent) => void,
signal?: AbortSignal,
) {
const response = await fetch(`${base}/events`, {
headers: {
"X-API-Key": apiKey,
"Cache-Control": "no-cache",
},
signal,
});
if (!response.ok || !response.body) {
throw new Error(`events request failed: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
for (;;) {
const { value, done } = await reader.read();
if (done) {
return;
}
buffer += decoder.decode(value, { stream: true });
const frames = buffer.split("\n\n");
buffer = frames.pop() ?? "";
for (const frame of frames) {
const eventLine = frame.split("\n").find((line) => line.startsWith("event: "));
const dataLine = frame.split("\n").find((line) => line.startsWith("data: "));
if (eventLine === "event: instance" && dataLine) {
onEvent(JSON.parse(dataLine.slice("data: ".length)) as InstanceEvent);
}
}
}
}Use the retry: 3000 hint as the minimum reconnect delay. Add a small backoff
when the connection fails repeatedly.
Recommended sequence:
- Open
/events. - Apply all
initialevents. - Apply live
create,update,delete, andlogevents. - On disconnect, reconnect after a delay.
- After reconnect, reconcile with
GET /instancesif local state may have missed changes.
If a shutdown event arrives during API-key regeneration, the next request with
the old key will fail. Load the new key from the operator-controlled channel
where startup logs are collected.
curl -H "X-API-Key: ${API_KEY}" "${BASE}/tcping?target=example.com:443"Response:
{
"target": "example.com:443",
"connected": true,
"latency": 38,
"error": null
}The master allows up to 10 concurrent TCP ping requests. If the semaphore is
not acquired within one second, the response is successful JSON with
connected: false and error: "too many requests".
Connection attempts use a 5-second timeout. A failed connection is not an HTTP
error; check connected and error.
State is stored beside the executable, not beside the current working directory:
<executable-directory>/gob/openctrl.gob
<executable-directory>/gob/openctrl.gob.backup
The state file is a Go gob-encoded map of instance IDs to instance objects.
Writes use a temporary file in the same directory and then rename it into
place. Temporary gob-*.tmp files in the state directory are removed at load
time.
On startup, stored non-internal instances are reset to stopped. Instances with
restart: true are auto-started. The internal ******** instance is loaded as
the source of API key, master ID, and master alias.
The periodic task runs every 5 seconds. On each tick it:
- writes
openctrl.gob.backup; - restarts non-internal instances whose status is
errorand whoserestartpolicy istrue.
During shutdown, OpenCtrl stops active child processes, saves state, emits SSE shutdown events, and shuts down the HTTP server with a 5-second context deadline.
The master writes operational logs with Go's standard log package:
2026/06/09 12:00:00 Master.run: started: http://127.0.0.1:8080/api/v2
Child process output is handled separately by the runtime process contract. It
is forwarded to stdout with the instance ID appended and is also sent to SSE
subscribers as log events.
OpenCtrl relies on the API key and the deployment environment for access control:
- protect the API key printed by the master;
- protect the state directory because
openctrl.gobstores the API key; - use
tls=2with a trusted certificate outside local-only deployments; - use a trusted
binpath because managed instances execute that binary; - treat instance URLs as secrets when they contain credentials, keys, or tokens;
- account for permissive CORS headers when exposing the API across origins.
The master supervises runtime binaries through a simple process contract. This
contract applies when the master is started with ?bin=/path/to/runtime, and
it also applies when bin is omitted and the executable launches itself.
<bin-or-current-executable> <instance-url>
The child process receives the full instance URL as its first positional argument. The child runs as the same operating-system user as the master. Its stdout and stderr are both captured by the master.
Runtime binaries should follow these practices:
- accept the instance URL as the first positional argument;
- reject invalid configuration before starting long-running work;
- keep the managed process in the foreground;
- avoid interactive prompts;
- write newline-delimited logs to stdout or stderr;
- redact secrets before printing URLs, headers, keys, or tokens;
- handle termination signals and exit within the master's 5-second grace period when possible;
- return a non-zero exit code when startup or runtime failure should put the
instance in
error; - emit checkpoints after the service is ready when metrics are available;
- keep byte counters monotonic for the life of the process.
On Unix-like systems, the master sends SIGTERM. On Windows, it sends an
interrupt signal. It waits up to 5 seconds and then kills the process if it has
not exited.
After a stop, OpenCtrl clears the process handle and live metrics, then marks
the instance stopped.
If the child exits cleanly while it was not being stopped, OpenCtrl resets the
instance to stopped. If the child exits with an error, OpenCtrl marks the
instance error.
If the instance has restart: true, the periodic task will restart it while it
remains in error.
Every non-checkpoint line from stdout or stderr is:
- written to the master's stdout with the instance ID appended;
- sent to SSE subscribers as a
logevent.
If a non-checkpoint line contains the substring ERROR, the instance is marked
error and live counters are cleared. Use another word for recoverable
warnings that should not change master state.
Runtime metrics are reported by printing checkpoint lines:
CHECK_POINT|MODE=<n>|PING=<n>ms|POOL=<n>|TCPS=<n>|UDPS=<n>|TCPRX=<bytes>|TCPTX=<bytes>|UDPRX=<bytes>|UDPTX=<bytes>
The parser expects this field order and base-10 numeric values. PING must use
the ms suffix. The implementation uses a regular expression search, so the
checkpoint can appear inside a larger line, but emitting it alone on a line is
the stable format for runtime authors.
Fields:
| Field | Description |
|---|---|
MODE |
Runtime-defined mode value. |
PING |
Runtime-defined latency in milliseconds. |
POOL |
Runtime-defined pool or worker count. |
TCPS |
Active TCP count. |
UDPS |
Active UDP count. |
TCPRX |
TCP bytes received by the runtime. |
TCPTX |
TCP bytes transmitted by the runtime. |
UDPRX |
UDP bytes received by the runtime. |
UDPTX |
UDP bytes transmitted by the runtime. |
When a checkpoint is parsed:
mode,ping,pool,tcps, andudpsare updated directly;- byte counters are adjusted against reset/base offsets;
lastCheckpointis refreshed;- an instance in
errorreturns torunning; - an
updateSSE event is emitted.
If an instance has emitted at least one checkpoint and later goes more than
15 seconds without another checkpoint while still running, OpenCtrl marks it
error. Instances that never emit checkpoints are not timed out by this rule.
export class OpenCtrlClient {
constructor(
private readonly base: string,
private readonly apiKey: string,
) {}
private async request<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
headers.set("X-API-Key", this.apiKey);
if (init.body && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
const response = await fetch(`${this.base}${path}`, {
...init,
headers,
});
if (response.status === 204) {
return undefined as T;
}
const text = await response.text();
const data = text ? JSON.parse(text) : undefined;
if (!response.ok) {
const message =
data && typeof data.error === "string"
? data.error
: `request failed: ${response.status}`;
throw new Error(message);
}
return data as T;
}
info() {
return this.request<MasterInfo>("/info");
}
setAlias(alias: string) {
return this.request<MasterInfo>("/info", {
method: "POST",
body: JSON.stringify({ alias }),
});
}
listInstances() {
return this.request<Instance[]>("/instances");
}
getInstance(id: string) {
return this.request<Instance>(`/instances/${encodeURIComponent(id)}`);
}
createInstance(input: { alias?: string; url: string }) {
return this.request<Instance>("/instances", {
method: "POST",
body: JSON.stringify(input),
});
}
patchInstance(
id: string,
input: {
alias?: string;
action?: InstanceAction;
restart?: boolean;
meta?: Partial<InstanceMeta>;
},
) {
return this.request<Instance>(`/instances/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(input),
});
}
replaceInstanceURL(id: string, url: string) {
return this.request<Instance>(`/instances/${encodeURIComponent(id)}`, {
method: "PUT",
body: JSON.stringify({ url }),
});
}
async deleteInstance(id: string) {
await this.request<void>(`/instances/${encodeURIComponent(id)}`, {
method: "DELETE",
});
}
tcping(target: string) {
return this.request<TcpPingResult>(
`/tcping?target=${encodeURIComponent(target)}`,
);
}
}Use one path for initial state and one path for live changes:
const api = new OpenCtrlClient("http://127.0.0.1:8080/api/v2", apiKey);
const instances = new Map<string, Instance>();
for (const instance of await api.listInstances()) {
instances.set(instance.id, instance);
}
const controller = new AbortController();
readEvents(
"http://127.0.0.1:8080/api/v2",
apiKey,
(event) => {
applyInstanceEvent(instances, event);
appendBoundedLog(instanceLogs, event);
},
controller.signal,
);If the event stream starts before the list call, apply initial events first
and then reconcile with the list. If the list call starts first, accept that an
event can arrive between list completion and stream connection, then reconcile
after connection. The simplest robust pattern is to refresh GET /instances
after any reconnect.
Commands such as start, stop, and restart are accepted quickly and then
complete through process state changes. Keep a separate local pending state if
the caller needs to show an in-progress operation:
const pending = new Set<string>();
async function restart(api: OpenCtrlClient, id: string) {
pending.add(id);
try {
await api.patchInstance(id, { action: "restart" });
} finally {
setTimeout(() => pending.delete(id), 10_000);
}
}
function onInstanceEvent(event: InstanceEvent) {
if (event.instance) {
pending.delete(event.instance.id);
}
}Use server state as authoritative. Local pending flags should expire even if no event arrives.
Use different retry behavior for different failure classes:
| Failure | Suggested behavior |
|---|---|
| Network error | Retry idempotent reads after a delay. |
401 |
Stop retrying until a valid API key is available. |
400 |
Surface the validation message and let the caller change input. |
404 on detail |
Remove or refresh local state for that ID. |
409 on URL replacement |
Treat as unchanged input. |
TCP ping connected: false |
Show the error value; do not treat it as request failure. |
| SSE disconnect | Reconnect and refresh GET /instances. |
For commands that change process state, avoid automatic unbounded retries. A
network timeout can leave the command accepted but the response lost. Prefer a
follow-up GET /instances/{id} or event-stream reconciliation before retrying.
Useful presentation rules:
- show
aliaswhen non-empty, otherwise showid; - hide or redact
urlby default when it can contain credentials; - render
statusdirectly from the server instead of deriving it from metrics; - use
tcpsandudpsas live counts, not cumulative counters; - use
tcprx,tcptx,udprx, andudptxas cumulative counters since the last reset and persisted base; - keep empty
meta.tagsas{}rather thannull; - treat missing logs as an empty string;
- keep log storage bounded.
URLs, certificate paths, and API keys can be sensitive. A simple redactor should cover the common cases before values are copied into logs:
export function redactOpenCtrlValue(value: string) {
return value
.replace(/([?&](?:key|token|secret|password)=)[^&]+/gi, "$1<redacted>")
.replace(/(X-API-Key:\s*)[0-9a-f]+/gi, "$1<redacted>")
.replace(/\/\/([^/@]+)@/g, "//<redacted>@");
}| Limit | Value |
|---|---|
| API key ID | ******** |
| Instance ID length | 8 lowercase hexadecimal characters |
| Master ID length | 16 lowercase hexadecimal characters |
| API key length | 32 lowercase hexadecimal characters |
| Alias, peer field, tag key, tag value | 256 characters |
| TCP ping concurrency | 10 |
| TCP ping dial timeout | 5 seconds |
| Stop grace period | 5 seconds |
| Periodic task interval | 5 seconds |
| Checkpoint timeout | 15 seconds after the first checkpoint |
| SSE retry hint | 3000 milliseconds |
| Global event channel buffer | 1024 |
| Per-subscriber event channel buffer | 10 |
| Certificate reload interval | 1 hour |
Tags matching v*.*.* trigger release builds for:
- Linux
amd64andarm64; - macOS
amd64andarm64; - FreeBSD
amd64andarm64; - Windows
amd64andarm64.
The release workflow sets main.version to the tag name.