Extensibility & plugins — add CAs and connectors without trusting their code
What it is
trstctl can't ship a built-in integration for every CA and every deployment target in the world, so it lets you add them as plugins. The plugin SDK runs third-party plugin code as WebAssembly (WASM) inside a sandbox that grants it only the narrow capabilities it explicitly needs — so a plugin, even a malicious one, can never reach your database, your keys, or the network beyond what it was allowed.
The mental model: a plugin is a contractor you let into the building. Instead of handing them a master key, you give them a single keycard that opens exactly one supply closet and nothing else, and a guard checks the card at every door. If the contractor turns out to be hostile, the worst they can do is rummage in that one closet.
Why it exists
Extensibility usually means running someone else's code with your privileges — which is a security disaster waiting to happen, especially for a system that holds the keys to your infrastructure. The whole point of the plugin model is to make extension safe by construction: the host process holds no privileged handle a plugin could grab, the plugin gets a least-privilege capability grant, and signature verification checks its publisher before any guest code runs. That's what lets trstctl have an open ecosystem of CA and connector plugins without widening its attack surface.
How it works
The plugin host runs each plugin in its own WASM runtime (using wazero), so one plugin's fault or state can't infect another's. At load time the host builds the plugin's environment with only the host functions its grant permits — any import the plugin declares that wasn't granted causes instantiation to fail, so the plugin's reach is closed by construction. Every gated call (write a file, dial a host) checks the capability grant first, including path/host prefix matching, and denials are counted.
The ABI is five functions, each returning 0 (ok), 1 (denied by the grant) or 2
(error). Arguments are offsets and lengths into the plugin's own exported memory:
(import "env" "cap_read" (func (param i32 i32 i32 i32 i32) (result i32))) ;; path, out, cap, out_len
(import "env" "cap_write" (func (param i32 i32 i32 i32) (result i32))) ;; path, data
(import "env" "cap_dial" (func (param i32 i32) (result i32))) ;; host:port
(import "env" "input_read" (func (param i32 i32 i32 i32 i32) (result i32))) ;; name, out, cap, out_len
(import "env" "cap_http" (func (param i32 i32 i32 i32 i32) (result i32))) ;; request, out, cap, out_len
Inputs and HTTP calls the host makes (relay connectors). A connector plugin
running on a relay reads its deploy's inputs with input_read (grant input.read):
certificate_pem, fingerprint, target and target_config. It never reads the
private key or a credential. It calls the appliance with cap_http (grant
net.http, constrained to host:port authorities like net.dial): the request is
JSON (method, url, headers, and one of body, body_input or json; auth
basic, bearer or header with auth_header), and the response written back is
JSON (status, headers, body, truncated). The relay makes the request itself.
It adds the target's credential (basic uses the target's username and the
redeemed password_ref; bearer and header use token_ref), splices the
certificate or private key into the body where the request names it
({"$input": "private_key_pem"} in a json body, or body_input), sends it
through the agent's egress policy, and removes every redeemed value from the
response before the plugin reads it. A plugin may not set Authorization,
Proxy-Authorization, Cookie, or the auth_header it asked the relay to set.
So a plugin deploys as the appliance's administrator without ever holding the
password or the key. plugins/connectors/reference/reference-http-connector.wat
is the reference.
A test entry point under a read-only grant. A connector plugin that exports
test answers a connector test (POST /api/v1/connectors/targets/{id}/test): the
relay redeems the attempt's credentials and invokes test with filesystem writes
refused and HTTP limited to GET and HEAD. A request that would change the target
is refused before it is sent and fails the test, as an operation outside the grant.
A plugin without test is not invoked and no credential is redeemed.
Two things enforce a path grant, not one. The grant check is lexical: it cleans
both sides and requires the match to land on a separator boundary, so /etc/nginx/certs
does not permit /etc/nginx/certs-evil/x and ../ is resolved before it is compared.
On its own that is not enough — a symlink inside a granted directory pointing outside
it would satisfy any purely textual check. So the sandbox never opens a path by name:
it performs every filesystem operation through a directory handle opened at the granted
prefix (Go's os.Root, i.e. openat with an escape check), and the kernel-facing layer
refuses a component that leaves the root. A filesystem capability granted with no
path prefix is refused outright rather than silently degrading to unrestricted access,
because there is no root to contain it under.
cap_dial matches the granted host[:port] exactly — not by prefix or suffix, which
would let example.com.attacker.example or evil-example.com through a grant for
example.com. A constraint naming a port requires that port; one without accepts any.
Three properties make this trustworthy:
- The host holds no privileged handle. A source-level test asserts the plugin host reaches neither the datastore nor the signer — so there is structurally no database pool or signing key in its address space for a guest to reach. This is the same containment that keeps private-key operations in a separate, isolated signing service, never in reach of plugin code.
- Bounded execution. Plugin invocations run in a shared bounded lane; a slow or runaway plugin is rejected fast rather than starving other subsystems.
- Verified admission and author-side checks. Production callers use
LoadVerifiedorLoadVerifiedWithProvenance. They verify the detached signature, trusted key and optional content pin before creating the runtime or executing guest start code. RawLoad,ConformanceandConformanceUnderGrantmethods are available only with thetrstctl_conformancebuild tag or in package tests. Conformance checks exports and execution under an empty or explicitly supplied grant; passing does not establish publisher trust or prove all future behavior. Run these author checks against disposable resources because guest code can use the supplied grant.
This same capability model is what governs the deployment connectors and DNS providers — they declare a grant and run sandboxed.
Plugins live under plugins/ca/, plugins/connectors/, and plugins/dns/.
Use it
Enable the served plugin surface by pointing trstctl at signed plugin directories:
[plugins]
enabled = true
ca_dir = "/etc/trstctl/plugins/ca"
connector_dir = "/etc/trstctl/plugins/connectors"
dns_dir = "/etc/trstctl/plugins/dns"
trusted_key_files = ["/etc/trstctl/plugin-signing.pub.pem"]
capabilities = ["fs.write"]
CA plugins loaded from ca_dir appear in GET /api/v1/external-cas with type
wasm-ca, and issuance goes through POST /api/v1/external-cas/{id}/issue.
Connector plugins loaded from connector_dir handle matching connector.deploy
work from the served outbox. The legacy plugins.dir key remains a connector-plugin
directory alias for older deployments. DNS provider plugins loaded from dns_dir
appear in GET /api/v1/acme/dns-01/providers with kind=plugin, admission state,
provenance, conformance, and capability-grant evidence; tenant DNS-01 provider
configs can select them, and the ACME DNS-01 outbox worker activates their
present_txt() and cleanup_txt() entrypoints at order time.
A plugin is granted exactly what it needs and nothing more:
h := pluginhost.New()
// grant: may write only under /data, nothing else
grant := pluginhost.NewGrant(pluginhost.CapFSWrite).
WithPathPrefix(pluginhost.CapFSWrite, "/data")
// trustPolicy comes from the operator's trusted public keys and optional pins.
// signature is the detached signature of these exact wasmBytes.
p, err := h.LoadVerified(ctx, wasmBytes, signature, trustPolicy, grant)
if err != nil {
return err
}
defer p.Close(ctx)
out, err := h.Invoke(ctx, p, "run")
To build a CA or connector plugin, follow the plugin authoring guide (and the connector guide for deployment targets).
Pitfalls & limits
- Status: the plugin host is wired into the served binary for signed CA plugins and signed connector plugins. The broader plugin marketplace experience is still maturing — see Current limitations.
- Grants are deny-by-default. If a plugin "does nothing," it probably lacked the capability for the operation it attempted — that's the sandbox working.
- WASM constrains what plugins can do (no arbitrary syscalls); plugins integrate through the host functions their grant exposes, not by reaching out directly.
- Keep production builds free of
trstctl_conformance. The tagged API executes unverified bytes for author tests. Ordinary andtrstctl_corebuilds refuse calls to those methods at compile time. Publish conformance results separately from the release signature.
Reference
- Production host:
Host.LoadVerified(ctx, wasm, signature, trustPolicy, grant),Host.LoadVerifiedWithProvenance(...),Host.Invoke(ctx, plugin, fn). - Author tests only:
Host.Load,Host.ConformanceandHost.ConformanceUnderGrantwith-tags=trstctl_conformance. - Served paths:
GET /api/v1/external-cas,POST /api/v1/external-cas/{id}/issue, andconnector.deploydelivery. - Capabilities:
CapFSRead,CapFSWrite,CapNetDial(andprocess.execfor connectors), each path/host prefix-constrainable viaGrant.WithPathPrefix. - Isolation: one wazero runtime per plugin; bounded invocation pool; host imports no store/signer (asserted by test).
- Plugin trees:
plugins/ca/,plugins/connectors/. - Guides: Plugin authoring, Connector authoring.
See also
Deployment connectors (the same sandbox model) · ACME & DNS (DNS-provider plugins) · Plugin authoring guide · Product threat model · glossary: plugin / WASM sandbox, bulkhead
Covers: F20