Runbook — Adding a Router Transport Plugin
Sprint reference: SPRINT_20260501_005 (audit finding B5; sprint now archived).
The gateway transport plugin loader requires a signed transports.signed.json manifest and per-DLL Authenticode (or side-car) signatures against the Stella-internal CA bundle. This runbook covers building, signing, and deploying a new disk-resident router transport plugin.
Scope. This runbook only governs disk-resident drop-in transport plugins under
plugins/router/transports/. Transport plugins built into the gateway image (TCP, TLS, UDP, RabbitMq, Messaging, InMemory) ride the gateway image signature, load viaLoadFromAssembly, and do not appear in the manifest.
Gateway configuration keys
The loader binds from Gateway:TransportPlugins:* (see StellaOps.Gateway.WebService/Program.cs and RouterTransportPolicyOptions):
| Key | Default | Purpose |
|---|---|---|
Gateway:TransportPlugins:Directory | <AppContext.BaseDirectory>/plugins/router/transports | Drop directory scanned for the manifest + DLLs. |
Gateway:TransportPlugins:SearchPattern | StellaOps.Router.Transport.*.dll | Glob for the extra-DLL fail-closed check; pinned file names must match it. |
Gateway:TransportPlugins:TrustRootDirectory | falls back to Plugins:TrustRootDirectory, then <BaseDirectory>/trust-roots/plugins | PEM trust-root bundle the manifest signer chain must validate against. |
Gateway:TransportPlugins:AllowedSignerThumbprints | empty (any chain-valid signer) | Optional uppercase-hex SHA-1 allowlist; when non-empty the verified signer MUST appear here. |
Gateway:TransportPlugins:RequireSignedManifest | true | When false (honoured only under IHostEnvironment.IsDevelopment()) no disk transports load at all; in any non-dev environment a false is logged as critical and forced back to true. |
Prerequisites
- Stella-internal CA leaf certificate for code signing (request via the release-engineering Issuer Directory). The leaf must chain to a root in the offline-kit PEM bundle distributed to gateways (PSV-05).
- Access to the build host that produces the transport DLL.
- A staging gateway you can restart to pick up the new manifest.
1. Build the transport DLL
Implement IRouterTransportPlugin (in StellaOps.Router.Common.Plugins). The plugin exposes TransportName, DisplayName, IsAvailable(IServiceProvider), and a Register(...) method that wires the transport’s ITransportServer / ITransportClient implementations into DI via the supplied RouterTransportRegistrationContext. Build against the same net10.0 target as the gateway:
dotnet build src/Router/__Libraries/StellaOps.Router.Transport.MyTransport \
--configuration Release
The output should be StellaOps.Router.Transport.MyTransport.dll under bin/Release/net10.0/. The directory loader consumes the loose DLL (not a NuGet package), so a plain dotnet build is sufficient. The file name MUST be a bare DLL name — no path separators, no globs — and MUST match Gateway:TransportPlugins:SearchPattern (default StellaOps.Router.Transport.*.dll).
2. Sign each DLL
Apply Authenticode (Windows) or the side-car signature scheme used by the platform plugin host (PSV-03). The leaf certificate’s SHA-1 thumbprint becomes the signerThumbprint for that DLL in the manifest.
3. Compute SHA-256 digests
sha256sum plugins/router/transports/StellaOps.Router.Transport.MyTransport.dll
The output (lower-case hex, no separators) goes into the manifest’s transports[].sha256.
4. Generate the signed manifest
The manifest schema is stellaops.router.transports/v1. A worked example appears in RouterTransportManifest and the test fixture under src/Router/__Tests/StellaOps.Router.Common.Tests/Plugins/Trust/RouterTransportSignedFixture.cs.
The signing tool (release engineering owns the production tool — see docs/release/runbooks/sign-router-transport.md once published) produces:
{
"schema": "stellaops.router.transports/v1",
"transports": [
{
"fileName": "StellaOps.Router.Transport.MyTransport.dll",
"sha256": "<lower-case hex>",
"signerThumbprint": "<UPPERCASE-HEX-SHA1-NO-SEP>",
"minPlatformVersion": "1.0.0"
}
],
"signedAt": "2026-05-01T00:00:00Z",
"signerCertificate": "<base64 DER of leaf>",
"certificateChain": ["<base64 DER of intermediate(s), root excluded>"],
"signatureAlgorithm": "RS256",
"signature": "<base64 signature over canonical bytes>"
}
Required fields (the strict parser rejects the manifest otherwise): schema, signerCertificate, signatureAlgorithm, and signature at the top level, plus fileName, sha256, and signerThumbprint on every transports[] entry. signatureAlgorithm is a JOSE-style token resolved by SignatureAlgorithmName.FromString(...) — RS256, PS256, or ES256 for the default verifier. EdDSA is a recognised token but not verifiable by the default DefaultAsymmetricSignatureVerifier (its VerifyEd25519 returns false), so an EdDSA-signed manifest parses yet fails signature verification (ManifestSignatureInvalid) unless a regional crypto plugin that replaces the verifier is loaded. certificateChain (base64 DER of any intermediates, root excluded) and signedAt are optional, and minPlatformVersion is informational (not yet enforced). signerThumbprint is the uppercase hex SHA-1 thumbprint (no separators) of the leaf, which is what the X.509 validator surfaces (leaf.Thumbprint).
The signature covers the canonical encoding produced by RouterTransportManifestCanonicalBytes.ComputeFor(...):
schema (UTF-8) || 0x0A
fileName₁ 0x1F sha256₁ 0x1F signerThumbprint₁ 0x1F minPlatformVersion₁ 0x0A
fileName₂ 0x1F sha256₂ 0x1F signerThumbprint₂ 0x1F minPlatformVersion₂ 0x0A
...
Entries MUST be sorted ordinal by fileName before encoding so reordering the manifest array does not change the signed bytes.
The manifest signature is verified with the same X.509-pinned validator as the platform plugin pipeline (X509PinnedSignatureValidator from PSV-03, shared from StellaOps.Plugin.Host); the canonical bytes above are wrapped in a synthetic PluginSignatureManifest envelope with manifestSha256 = SHA256(canonicalBytes) and an empty assemblies list. The manifest’s signerCertificate, certificateChain, signatureAlgorithm, signature, and signedAt are copied verbatim into that envelope, and the validator is invoked unchanged (see RouterTransportTrustPipeline.EvaluateAsync).
5. Stage and pre-flight verify
Place the DLL(s) and the new transports.signed.json in plugins/router/transports/:
plugins/router/transports/
├── StellaOps.Router.Transport.MyTransport.dll
└── transports.signed.json
Verify before restarting the gateway:
stella router transports verify \
--manifest plugins/router/transports/ \
--trust-root /etc/stellaops/trust-roots/plugins
Expected output:
[OK] Router transports verification
manifest signer: <thumbprint>
per-DLL results:
STATUS FILE SHA-256 (expected) REASON
OK StellaOps.Router.Transport.MyTransport.dll <pinned-sha256>
Exit code 0 means every fail-closed check passed; 1 means at least one failed and the gateway will refuse to load the directory.
6. Distribute via the offline kit
Add the signed DLL and manifest to the offline kit bundle published for the target distribution. The kit ships:
- The signed DLLs themselves
- The signed manifest
- The PEM trust-root bundle the gateway expects under
Gateway:TransportPlugins:TrustRootDirectory
The same trust-root bundle is also consumed by the platform plugin host (PSV-05). Rotating it requires a kit refresh + a restart of every gateway and platform host.
7. Restart the gateway
docker compose -f devops/compose/docker-compose.stella-ops.yml \
restart router-gateway
On rejection the gateway fails to start: bootstrap throws Router transport plugins directory '<dir>' rejected: <Reason> - <detail> (the loader also emits a structured LogError of the form Router transport directory '<dir>' rejected: reason=<Reason> detail=<detail>).
Note. The
X509PinnedSignatureValidatorwrites aPlugin signature verified: subject='...' thumbprint='...'info line, but on the gateway boot path it is constructed with a null logger, so that line does not appear in gateway startup logs on success. Use the pre-flightstella router transports verify(step 5) to confirm the signer thumbprint instead — that is the authoritative success check. A clean boot simply does not throw the rejection above.
Recovery — manifest rotation
When rotating the manifest signer:
- Issue the new leaf certificate from the Stella-internal CA.
- Re-sign every DLL in the directory with the new leaf.
- Generate a new
transports.signed.jsonwhosesignerCertificateis the new leaf and whosetransports[].signerThumbprintis the new thumbprint. - Add the new leaf thumbprint to
Gateway:TransportPlugins:AllowedSignerThumbprintsbefore the restart. - Pre-flight with
stella router transports verify. - Roll out via offline-kit refresh + gateway restart.
The extra-DLL fail-closed behavior means partial rotations cannot leave the directory in a half-signed state: an unsigned DLL alongside a manifest that does not pin it triggers ExtraneousDll and refuses to load anything.
Failure modes
All rejection reasons below are members of the RouterTransportTrustRejection enum (listed in pipeline-detection order).
Boot-path gating. On gateway startup the trust pipeline only runs when
TransportPluginDirectoryProbe.HasSignedManifestLoadCandidate(...)finds a manifest or a DLL matchingSearchPatternin the directory. If the directory is missing or empty, the probe returnsfalse, the gateway logs an info line (No disk-resident router transport plugins found under '<dir>'. Using AppDomain-resident transport plugins only.) and boots normally with the built-in (AppDomain-resident) transports. ConsequentlyDirectoryMissingcan never fail gateway boot — a missing directory simply means “no disk transports” — and is only ever surfaced by the pre-flight CLIstella router transports verify(which calls the pipeline directly).ManifestMissing, by contrast, does fail boot when aSearchPattern-matching DLL is present without atransports.signed.jsonalongside it (the DLL makes the probe returntrue). Once any load candidate is present, every reason below fails boot.
| Symptom | Likely cause | Action |
|---|---|---|
DirectoryMissing (CLI verify only — see note) | The configured transports directory does not exist | Create the directory (or fix Gateway:TransportPlugins:Directory); redeploy via offline kit |
ManifestMissing | transports.signed.json not in directory | Restore manifest; redeploy via offline kit |
ManifestMalformed | Manifest fails schema-shape checks (bad schema, missing signerCertificate/signatureAlgorithm/signature, empty/duplicate/path-bearing fileName) | Regenerate the manifest with the signing tool; ensure all required fields are present |
ManifestSignatureInvalid | Leaf chain fails to validate against trust-root bundle | Refresh PEM bundle; verify CA expiry |
ManifestSignerNotAllowed | Verified signer thumbprint not in operator allowlist | Add the new thumbprint to AllowedSignerThumbprints, then restart |
ExtraneousDll | A DLL is in the directory that the manifest does not pin | Either remove the unpinned DLL or re-issue the manifest with it |
ListedDllMissing | Manifest references a DLL that is not on disk | Restore the missing DLL or re-issue the manifest without it |
ListedDllDigestMismatch | DLL on disk does not match manifest pin (corrupted, modified, or wrong build) | Replace with the original signed binary; verify SHA-256 matches before rolling out |
