Subshell Docs
Develop

Writing a Network Plugin

Connect Subshell to a network you reach through, under the rule that plugins describe and the host executes.

A network plugin connects the control-plane host to one network and publishes Subshell on it, so the address an operator was told about through a 403 becomes a trusted sign-in origin the moment the plugin's record earns it. Four ship built in (Tailscale, Headscale, NetBird, Cloudflare Tunnel), and their operator pages cover what an admin sees. This page is for the author of the fifth: the interface, the manifest, and the discipline every line of it is written under.

The rule: you describe, the host executes

A network plugin returns argv, parses output, and names a secret. It never spawns a process, never writes a file, never touches the server's configuration, and never reads a credential back. Every effect goes through a PluginHost member the host owns, and that is what makes third-party code defensible here: the host's spawn is admin-gated, bounded by a deadline and an output cap, and runs under an environment allowlist (the server's own environment holds the auth secret and the database path, and is never handed over). If you spawned your own child, none of that would be true of it.

interface NetworkPlugin {
  capabilities(): PluginCapability[];
  status(ctx: NetworkContext): Promise<NetworkStatus>;
  join(input: JoinInput, ctx: NetworkContext): Promise<JoinOutcome>;
  leave(ctx: NetworkContext): Promise<void>;

  publish?(ctx): Promise<PublishOutcome | PublishRefusal>;
  unpublish?(ctx): Promise<void>;
  supervisedProcess?(ctx): SupervisedProcessSpec | null | Promise<SupervisedProcessSpec | null>;
  requestGuard?(ctx): RequestGuardSpec | null;
  settingsFields?(): SettingsField[];
  validateSettings?(values): PresetValidationIssue[];
}

type: "network" picks which members the loader requires: a network plugin that returns a harness-shaped object is refused at load with the members it is missing named, rather than loaded and left to fail at the first call. The capability set follows the type both ways: declaring resume as a network plugin is refused by name, just like a harness declaring publish.

Three interface habits worth internalizing before you write any of it:

  • status is your only reporting surface, and it runs on every page load and before every act. Make it cheap; never throw from it. An unreachable daemon is a daemon-down status with a hint, not a rejection. Likewise a PublishRefusal is an answer, not an error; it carries a hint to render.
  • You hold no state. NetworkContext carries the server's port, your stored non-secret settings, and which of your secrets exist on every call. Do not remember the port you published on; the host re-asks supervisedProcess() and requestGuard() at every boot, so a rotated credential or a moved port takes effect on the next spawn with no one re-publishing.
  • Secrets are write-only. host.secrets has set / has / delete and deliberately no get, because anything that can read a credential can put it in an argv (visible in ps), a log line, or a hint string that renders in someone's browser. Every legitimate consumer is a process the host spawns, so you name the secret and the host hydrates it into a 0600 file named by a flag (secretFileArgs) or an env var (secretEnv), never back to you. A short-lived join key does not belong in secrets at all: pass it once in argv from join and let the vendor's daemon own the identity afterward.

The manifest block

Required when type is network and refused on any other type; every byte of it is data a host reads without importing your code, which is the point:

"subshell": {
  "id": "mynet",
  "type": "network",
  "entry": "dist/index.js",
  "detect": { "binaryName": "mynet", "envOverride": "MYNET_PATH", "knownPaths": [] },
  "network": {
    "platforms": ["darwin", "linux"],
    "exposure": "private",
    "interactiveLogin": true,
    "privileged": {
      "darwin": [{ "label": "Install the daemon", "command": "sudo mynet service install" }]
    }
  }
}
  • platforms is not a hint. A host refuses every act on a platform absent from the list, so a page can say "not available on this OS" before your CLI, or your code, is anywhere on the machine.
  • exposure is never defaulted. private means a network only invited machines are on; public-with-gate means the open internet with an identity check in front, and every surface says so before the publish button. The Cloudflare Tunnel plugin is the built-in example: it refuses to publish until a pre-flight confirms an Access application covers the hostname, and the server verifies assertions itself on every request.
  • privileged is the copy-only channel; install.command is the runnable one, and it may not be privileged. Every mesh daemon needs one root install, and the server has no terminal to answer a password prompt. So the manifest parser refuses an install.command starting with sudo, and host.run throws on an argv[0] whose basename is sudo, doas, or pkexec. Put anything privileged under network.privileged, where a page prints it for a human to run. Steps sharing a group render as one sequence under one heading, different groups as alternatives; the macOS Tailscale manifest is that shape: the app and the command-line daemon are each a route, not both a requirement.

Anything in a docsUrl renders as the href of an anchor on an admin's page. Only http: and https: are accepted, and the two halves fail differently on purpose: a bad docsUrl in your manifest is static data (a plugin defect), and the parser refuses to load you; a bad one on a hint reported at runtime is dropped while the hint's sentence is kept. That asymmetry exists because a runtime URL is often not yours: you read it off a vendor CLI, which read it off its control server, and on a self-hosted deployment (think Tailscale's AuthURL under --login-server) that server is not the vendor's. Validate URLs where they enter your plugin; the host's drop is a backstop, not your input validation.

supervise and guard, briefly

Two capabilities exist for the case where the plugin's daemon is the thing keeping the server reachable. supervisedProcess() describes a long-running process (absolute command from host.findBinary, args, and how to hydrate a secret into it), and the host spawns and supervises it (the Cloudflare Tunnel's cloudflared tunnel run is the first real use). requestGuard() installs a check the server runs before answering for an origin, never a proxy header trusted, but the real assertion verified at the edge, failing closed. The host promotes a supervised plugin's joined record to published from its own supervisor state, because a plugin whose daemon is that child has no honest way to observe it.

See also

Edit on GitHub

Last updated on

On this page