# Serveo self-hosted plugin protocol Self-hosted Serveo can start one trusted, long-running plugin command: ```sh serveo --plugin-command="python3 /opt/serveo/plugin.py" ``` The command runs as the same operating-system user as Serveo. The configured string is interpreted by the platform shell, so it must be treated as trusted configuration. Serveo inherits the plugin's stderr for ordinary diagnostic output. The plugin must reserve stdout for protocol messages. ## Transport Serveo and the plugin exchange JSON-RPC 2.0 messages over the child process's stdin and stdout. Each message is one complete JSON object followed by a newline. Messages larger than 64 KiB are rejected. Either side may have multiple requests in flight, and responses may arrive in a different order. Serveo bounds all queues and pending requests. A plugin must keep reading stdin even while it is handling a request, and it should avoid blocking its stdout writer. Tunnel cleanup and observation notifications never wait for a plugin. ## Initialization and discovery Immediately after starting the command, Serveo calls `plugin.initialize`: ```json {"jsonrpc":"2.0","id":1,"method":"plugin.initialize","params":{"protocol_version":1,"auth_mode":"auto","events":["auth.check","tunnel.check","private-tcp.connect.check","tunnel.started","tunnel.stopped"],"methods":["serveo.status","serveo.tunnels.list","serveo.tunnels.disconnect","serveo.identities.disconnect","serveo.config.reload"]}} ``` The plugin responds with its identity and subscriptions: ```json {"jsonrpc":"2.0","id":1,"result":{"protocol_version":1,"name":"example-policy","version":"1.0.0","subscriptions":[{"event":"auth.check","timeout_ms":1000,"on_error":"deny"},{"event":"tunnel.check","timeout_ms":1000,"on_error":"deny"},{"event":"private-tcp.connect.check","timeout_ms":1000,"on_error":"deny"},{"event":"tunnel.started"},{"event":"tunnel.stopped"}]}} ``` `timeout_ms` must be between 100 and 5000 for decision events. `on_error` is either `allow` or `deny` and determines the result when the plugin is unavailable, times out, returns an error, or returns a malformed decision. Observation events ignore these fields. Events without a subscription are not sent; unsubscribed decisions allow by default. In the initialization result, add `"auth_mode":"plugin"` to declare that the plugin owns authentication. Serveo then ignores `authorized_keys` entirely and requires `auth.check` with `on_error: deny`. Without that declaration (or with `"auth_mode":"keys"`), the hook runs before file authorization and may tighten it but not admit unlisted keys. The initialize request reports the operator override: `auto` by default, or explicit `keys`/`plugin` from the CLI. Explicit `-auth-mode=keys` forces file authorization even if the plugin declares ownership. Explicit `-auth-mode=plugin` forces plugin ownership. Initialization must complete within five seconds. An invalid manifest prevents Serveo from starting. `SIGHUP` reloads file-backed Serveo configuration and starts a replacement plugin. The old process continues serving in-flight calls until the replacement has initialized, then Serveo atomically swaps runtimes and gives the old process up to five seconds to finish in-flight calls before terminating it. If replacement fails, the last working plugin remains active. Changing effective auth ownership is rejected on reload; restart Serveo for that change so a reload cannot accidentally broaden authorization. ## Events from Serveo All event params include `event_id` and `timestamp` (UTC). Auth and private TCP consumer checks have a top-level `identity`; tunnel events have `tunnel.identity`. An identity contains `id`, `fingerprint`, and `label`. Initially the ID is `standalone:` and the label is the key comment (or fingerprint). Network strings and labels are bounded and control characters are removed. ### `plugin.auth.check` A decision request made after SSH has cryptographically verified possession of the offered public key. With an auth hook, file authorization happens after the hook; it is skipped entirely when the plugin owns auth. Serveo releases the global SSH handshake slot before calling the plugin, so a slow policy service cannot exhaust handshake capacity. The connection cannot create sessions or forwards until the decision allows it. Params additionally include `remote_ip`, `ssh_user`, `client_version`, `public_key` (OpenSSH public key text), and `authorized_key` (file membership). `authorized_key` is a current-file hint, not a fallback for authoritative plugins: it is false and the file is not read when the plugin owns auth. In file mode, membership is rechecked after the hook in case the file changed during the call. The SSH username is client-supplied, not proof of account ownership. Use the verified fingerprint/public key to query your external account directory. An allow result may map a key to a stable account and impose a quota: ```json {"jsonrpc":"2.0","id":2,"result":{"allow":true,"identity_id":"team-42","label":"Build team","max_tunnels":2}} ``` The resulting ID is `plugin:team-42`; subsequent events and control methods use that prefixed ID. The original signing-key fingerprint stays attached. `identity_id` must be a non-empty, control-free string of at most 200 characters when supplied. `max_tunnels` is an integer from 0 to 1000000; omitted or zero means no additional identity cap, not denial. Return `allow: false` to disable access. The instance license limit still applies. Quota reservation and release are atomic across connections/keys sharing the same identity. Return consistent limits for the same account: grants are captured at authentication, not polled. To change an existing account's grant, update policy then disconnect its sessions. Auth result identity/quota fields have no effect on other decision methods. ### `plugin.tunnel.check` A decision request made after intrinsic validation has normalized the requested route and after a license-capacity slot is reserved, but before Serveo accepts and publishes it. A denial or failed handler releases the slot. Params include the following `tunnel` object (also used for observations and list results): ```json {"id":"opaque-tunnel-id","identity":{"id":"plugin:team-42","fingerprint":"SHA256:...","label":"Build team"},"protocol":"http","endpoint":"https://demo.example.com","requested_address":"demo","requested_port":80,"resolved_address":"demo.example.com","resolved_port":80,"remote_ip":"192.0.2.1","ssh_user":"alice","client_version":"SSH-2.0-OpenSSH_...","started_at":"2026-09-14T12:00:00Z","proxy":{"host_header":"localhost:3000"}} ``` `protocol` is `http`, `tcp`, or `private-tcp`. `proxy` contains optional `host_header`, `https_only`, `request_header_names`, and `removed_request_headers`, never request-header values. It is a snapshot, not a policy boundary: clients can set mutable exec options before or after a forward. This hook cannot enforce future HTTP headers or authenticate public visitors. For a public TCP request that asks the operating system to select port `0`, the check sees port `0`; the subsequent `tunnel.started` notification contains the allocated port. ### `plugin.private-tcp.connect.check` A decision before an authenticated client opens a `direct-tcpip` channel to an existing private alias. Params have the consumer's top-level `identity`, `remote_ip`, `ssh_user`, and `client_version`, plus the target `tunnel` with its owner's identity. Compare the IDs to enforce same-account access, or consult an ACL. `public_key` and `authorized_key` are not populated on this event; use the identity established by `auth.check`. This does not add a forward/quota slot. Without this subscription, authenticated users can access one another's aliases. ### `plugin.tunnel.started` A non-blocking notification emitted after the forwarding request is accepted and its route or listener is installed. ### `plugin.tunnel.stopped` A non-blocking notification emitted once during the route's cleanup. It includes the tunnel duration. Observation notifications are best effort. A plugin that starts or reloads while tunnels are active should use the paginated `serveo.tunnels.list` method to reconcile current state instead of assuming it observed every start event. Decision methods return: ```json {"jsonrpc":"2.0","id":2,"result":{"allow":false,"reason":"identity is disabled"}} ``` `allow` is required. A denial reason is optional, bounded to 200 characters, and stripped of control characters before it is logged or shown to a client. ## Methods from the plugin The plugin may call a deliberately small control surface on the same stream: - `serveo.status` returns health, active tunnel count, and effective license capacity. - `serveo.tunnels.list` returns active tunnel IDs, identities, protocols, endpoints, and start times. It accepts an opaque cursor and a limit from 1 to 10; responses include `next_cursor` when another page exists. - `serveo.tunnels.disconnect` cancels one tunnel by `tunnel_id`. - `serveo.identities.disconnect` closes currently registered parent SSH sessions and cancels tunnels for one `identity_id`. Returns `connections_disconnected` and `disconnected` (tunnel cancellations). Update the auth policy first to prevent reconnection. It is not a persistent ban or a barrier against an already in-flight authentication; also deny new forwards in `tunnel.check` and repeat disconnection if your external policy update races a prior grant. - `serveo.config.reload` validates file configuration and reloads the optional license. It does not restart the calling plugin. Keys already refresh on each connection, so changing keys needs no RPC or signal. Missing/malformed key files fail closed; stale permissions are not retained. CLI flags, including reserved names and TLS paths, require a restart to change. Request example: ```json {"jsonrpc":"2.0","id":"plugin-1","method":"serveo.identities.disconnect","params":{"identity_id":"plugin:team-42"}} ``` Representative request/result pairs (IDs are examples): ```json {"jsonrpc":"2.0","id":"status-1","method":"serveo.status","params":{}} {"jsonrpc":"2.0","id":"status-1","result":{"ok":true,"active_tunnels":0,"tunnel_limit":3,"license_id":""}} {"jsonrpc":"2.0","id":"list-1","method":"serveo.tunnels.list","params":{"limit":10,"cursor":""}} {"jsonrpc":"2.0","id":"list-1","result":{"tunnels":[],"next_cursor":""}} {"jsonrpc":"2.0","id":"stop-1","method":"serveo.tunnels.disconnect","params":{"tunnel_id":"tunnel-123"}} {"jsonrpc":"2.0","id":"stop-1","result":{"disconnected":true}} {"jsonrpc":"2.0","id":"plugin-1","result":{"connections_disconnected":1,"disconnected":1}} {"jsonrpc":"2.0","id":"reload-1","method":"serveo.config.reload","params":{}} {"jsonrpc":"2.0","id":"reload-1","result":{"reloaded":true}} ``` List entries use the tunnel schema above. An unknown tunnel returns `disconnected: false`; an identity with no active sessions returns zero counts. Disconnection counts indicate cancellation requests, not a synchronous guarantee that every network byte has drained. Startup may return an error such as `{"jsonrpc":"2.0","id":"status-1","error":{"code":-32010,"message":"standalone configuration is unavailable"}}`. Use unique IDs per direction and match replies by ID, not arrival order. The control API may return `-32010` while initial configuration is loading; retry after initialization, without treating the response as a permanent failure. The status/list snapshots are not atomic quota-reservation APIs; use the auth grant's `max_tunnels` for race-free caps. Listing is not a durable audit log: observations can be lost, and pagination may change while tunnels start/stop. Reconcile and deduplicate by tunnel ID. Persist billing/audit data outside Serveo if required. Serveo limits concurrent plugin-initiated calls and returns standard JSON-RPC errors for malformed requests, unsupported methods, invalid params, and busy workers. Plugins cannot create tunnels, inject traffic, execute commands, or make non-persistent in-memory policy changes. ## Failure behavior If the child exits, emits malformed JSON, or emits a line over 64 KiB, Serveo invalidates the whole protocol stream and marks the plugin unavailable; it does not try to skip a bad frame and resynchronize. Existing tunnels remain active. Decision events follow their last discovered `on_error` policy, while observations are dropped and counted. Plugin availability, calls, errors, and dropped messages are exported on the configured monitoring endpoint. Serveo does not automatically restart a failed plugin in protocol version 1; fix the plugin and send `SIGHUP`. No plugin configuration leaves existing standalone behavior unchanged. ## Practical policies - External account auth: map a verified fingerprint to an enabled account in your database; deny missing/disabled accounts and database timeouts. - Team quotas: return the same account ID and limit for every key in a team. Combine with `tunnel.check` for allowed names, protocols, and port ranges. - Private network ACL: compare consumer and target identities in `private-tcp.connect.check`; allow only owners or explicitly shared accounts. - Immediate offboarding: deny auth, forward creation, and private access in your policy, then call `serveo.identities.disconnect` for active sessions. - Observability: subscribe to lifecycle events, periodically reconcile list results, and export to your logging/metrics service with bounded queues. The examples are small synchronous policy skeletons. For slow network calls, use a separate reader and bounded workers so the process can keep servicing responses and notifications. Never wait for a Serveo RPC reply in the same blocking stdin handler that must read that reply. Plugins are trusted code running with the server's privileges, not sandboxed tenant code.