Documentation
¶
Overview ¶
Package plugin is the host side of the OSCTF plugin ABI: discovery, the manifest, launching and supervising plugin processes, the in-flight budget, and the registry wiring. Plugins run as separate processes; the host dials a local gRPC server each serves.
The ABI surface itself — handshake, version, dispense keys, transport bridge — lives in the PUBLIC package plugin/abi, because plugin authors need it too and must not have to link this package (and its server-side dependencies) to get it. The identifiers are re-exported here so host code refers to them unqualified, exactly as before.
See docs/v0.3/02-plugin-abi.md and 03-plugin-loader.md.
Index ¶
- Constants
- Variables
- func CountByType(root string, ptype string, log *slog.Logger) int
- func HostPluginSet() goplugin.PluginSet
- type AuthGRPCPlugin
- type Caller
- type ChallengeTypeGRPCPlugin
- type Config
- type ConfigKey
- type Loader
- type Manifest
- type NotificationGRPCPlugin
- type PluginStatus
- type Registrar
- type ScoringGRPCPlugin
- type ShedError
- type ShedLevel
- type State
Constants ¶
const ( ABIMajor = abi.ABIMajor ABIMinor = abi.ABIMinor ABIString = abi.ABIString PluginConfigEnv = abi.PluginConfigEnv KeyAuth = abi.KeyAuth KeyScoring = abi.KeyScoring KeyNotification = abi.KeyNotification KeyChallengeType = abi.KeyChallengeType )
Variables ¶
var ErrNoSuchPlugin = errors.New("plugin: no such plugin")
ErrNoSuchPlugin is returned by Reload for a name the loader does not track.
var ErrNotReady = errors.New("plugin: not ready")
ErrNotReady is returned by dispatch when a plugin is not in `ready` state (including absent). The type-specific layer above turns it into the per-type answer from the spec: a clear error for auth/challenge-type, an opt-in fallback for scoring, a counted drop for notification — but the plugin PROCESS is never called from a non-ready state.
var Handshake = abi.Handshake
Handshake gates every plugin connection (see abi.Handshake).
Functions ¶
func CountByType ¶
CountByType reports how many valid plugin manifests of the given type are present on disk.
It exists for one boot-time question the loader cannot otherwise answer in time: whether an SSO-only deployment (email login disabled) has any auth plugin at all. Boot is asynchronous by design — the core must serve whether or not plugins come up — so at the moment the boot check runs, no plugin has registered yet. Gating on REGISTRATION would refuse to start on a timing artifact; gating on what is on DISK distinguishes "nothing is configured", which is a real misconfiguration worth refusing, from "the plugin has not finished launching", which is normal.
A plugin that is present but later fails to load leaves the deployment with no login. That is loud rather than silent: the failure is logged and the plugin shows as failed in the admin view.
func HostPluginSet ¶
HostPluginSet is the set the host offers when dialing a plugin (see abi.HostPluginSet).
Types ¶
type AuthGRPCPlugin ¶
type AuthGRPCPlugin = abi.AuthGRPCPlugin
type Caller ¶
type Caller interface {
Call(ctx context.Context, method string, fn func(ctx context.Context, client any) error) error
}
Caller invokes a method on ONE plugin with the loader's readiness gate and in-flight budget applied — the handle a registered provider (a scoring engine, auth provider, challenge-type checker) uses to reach the plugin process. A call to a non-ready plugin returns ErrNotReady without touching the process, which is the fail-closed layer INSIDE every registered provider.
type ChallengeTypeGRPCPlugin ¶
type ChallengeTypeGRPCPlugin = abi.ChallengeTypeGRPCPlugin
type Config ¶
type Config struct {
Enabled bool // false skips discovery entirely (pure-core mode, == v0.2 behaviour)
PluginsDir string // OSCTF_PLUGINS_DIR — scanned one level deep
RuntimeDir string // OSCTF_RUNTIME_DIR — where pidfiles are written / the boot sweep reads
PerPluginCap int // OSCTF_PLUGIN_MAX_INFLIGHT (per plugin)
GlobalCap int // shared fd-accountant grant (across all plugins); 0 = unbounded
QueueWait time.Duration // OSCTF_PLUGIN_QUEUE_WAIT
DrainTimeout time.Duration // OSCTF_PLUGIN_DRAIN_TIMEOUT
StartTimeout time.Duration // handshake wait before a launch is deemed failed
HealthStable time.Duration // OSCTF_PLUGIN_HEALTH_STABLE
MaxAttempts int // OSCTF_PLUGIN_RESTART_CAP
Log *slog.Logger
Registrar Registrar // wires ready plugins into their type registries; nil = no-op (transport only)
}
Config is what the composition root hands the loader at startup. The budget fields come from the shared fd accountant (GlobalCap) + OSCTF_PLUGIN_MAX_INFLIGHT (PerPluginCap); the rest are discovery and supervision knobs.
type ConfigKey ¶
type ConfigKey struct {
Type string `yaml:"type"`
Required bool `yaml:"required"`
Secret bool `yaml:"secret"`
Default string `yaml:"default"`
}
ConfigKey declares one config value's type and resolution rules. Secret keys resolve ONLY from env; a value in the manifest is a validation error (secrets never live in a file the plugin dir might expose).
type Loader ¶
type Loader struct {
// contains filtered or unexported fields
}
Loader discovers, launches, supervises, and routes to plugins. It holds the tracked set under a mutex, dispatches calls only to `ready` plugins, and bounds concurrent host→plugin work with the two-level budget.
func New ¶
New builds a loader from Config, wiring the two-level in-flight budget. Discovery and launch happen in Boot; call it in a goroutine so a slow sweep or a plugin that never handshakes cannot hold up serving.
func (*Loader) Boot ¶
Boot reclaims orphaned plugin children, discovers plugins, and launches a supervisor for each. It NEVER gates serving: the sweep runs before any launch (so this run's fresh pidfiles are not mistaken for orphans), discovery is a bounded one-level scan, and each launch is handed to a supervisor goroutine that does the handshake — so a plugin stuck launching, a binary that never handshakes, or a slow sweep leaves the core answering. Missing or empty OSCTF_PLUGINS_DIR is a silent no-op (a default deployment has no plugins and must behave exactly like v0.2). main runs Boot in a goroutine so even a slow sweep does not delay the HTTP server.
func (*Loader) Reload ¶
Reload hot-reloads one plugin by name: launch a new instance, swap on ready, drain the old. It blocks until the reload resolves — nil once the new instance is serving, or an error if it never became ready, in which case the OLD instance is retained and keeps serving.
This is the operator's way to pick up a changed binary or config without restarting the platform. It is also the only caller of the supervisor's reload path, which existed with no way to reach it: the machinery was built in P3-e and left unreachable, so a documented capability ("hot-reload on config change") was in practice absent.
A quarantined plugin is reloadable on purpose — parking until an operator reload is exactly how a plugin that failed its identity check is meant to be recovered after the binary is fixed.
func (*Loader) Snapshot ¶
func (l *Loader) Snapshot() []PluginStatus
Snapshot returns the current status of every tracked plugin (including those quarantined at load), for the admin plugin view. Ordering is by name for a stable admin display.
func (*Loader) Stop ¶
Stop drains and stops every launched plugin, bounded by ctx. main calls it AFTER the HTTP server has drained, so no in-flight request is left calling a plugin whose registry entry has been removed. The ctx is the SHARED shutdown budget covering both the HTTP drain and this — not a second independent timeout; if it expires, Stop returns and the remaining plugins are left to the OS (their pidfiles let the next boot sweep reclaim them).
type Manifest ¶
type Manifest struct {
Name string `yaml:"name"`
Type string `yaml:"type"`
ABI string `yaml:"abi"`
Version string `yaml:"version"`
Executable string `yaml:"executable"`
Description string `yaml:"description"`
Override bool `yaml:"override"` // opt-in to replace a protected built-in key
Config map[string]ConfigKey `yaml:"config"`
}
Manifest is a plugin's plugin.yaml. Names map to registry keys (auth provider id, scoring mode, challenge-type id), so identity is validated strictly and a collision fails loudly.
type NotificationGRPCPlugin ¶
type NotificationGRPCPlugin = abi.NotificationGRPCPlugin
type PluginStatus ¶
PluginStatus is one plugin's state for the admin view — enough to answer "why isn't my notifier working?" without reading boot logs. Reason is redacted of secret config values.
type Registrar ¶
type Registrar interface {
Register(name, ptype string, caller Caller) error
Deregister(name, ptype string)
}
Registrar wires a ready plugin's provider into its type's registry and reverts it before the plugin dies. The composition root implements it per type (auth/scoring/challenge-type/ notification); the loader calls Register once when a plugin FIRST becomes ready and Deregister once when it terminates — before the process is killed (revert-before-death).
type ScoringGRPCPlugin ¶
type ScoringGRPCPlugin = abi.ScoringGRPCPlugin
type ShedError ¶
ShedError is returned when a call is shed at an in-flight cap after waiting the queue budget. It maps to 503 (errors.Is(err, apperr.ErrUnavailable)) and its message names WHICH cap fired, so the operator log distinguishes the two causes.
type ShedLevel ¶
type ShedLevel int
ShedLevel says which in-flight cap shed a call — the distinction an operator needs, since "one plugin at its own cap" and "all plugins exhausting the shared budget" call for different responses (throttle/replace that plugin vs. raise the global budget / add capacity).
type State ¶
type State string
State is the lifecycle state of a tracked plugin. Every plugin is in exactly one; the full state machine is specified in docs/v0.3/03-plugin-loader.md before the loader is built, because process supervision is a new concurrency surface and the last two releases shipped bugs of exactly this shape.
const ( StateDiscovered State = "discovered" // manifest found + validated; not launched StateLaunching State = "launching" // process started; handshake+Info+Configure in progress StateReady State = "ready" // launched, configured, registered, health passing — SERVES StateUnhealthy State = "unhealthy" // was ready; a health check or call failed; supervisor deciding StateRestarting State = "restarting" // being torn down + relaunched; backoff between attempts StateFailed State = "failed" // restart cap exhausted OR failed to load (e.g. invalid config) → quarantined; not retried automatically StateDraining State = "draining" // reload/shutdown; no new calls, in-flight allowed to finish StateStopped State = "stopped" // process exited, resources reclaimed, entry removed — terminal )
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package plugintest provides the hostile plugin doubles and the build/dial harness they are exercised through.
|
Package plugintest provides the hostile plugin doubles and the build/dial harness they are exercised through. |
|
doubles/configecho
command
Double: reads its config through the PUBLIC sdk.Config() and reflects it in Value — so the host→plugin config path (OSCTF_PLUGIN_CONFIG env → sdk.Config) can be asserted end to end.
|
Double: reads its config through the PUBLIC sdk.Config() and reflects it in Value — so the host→plugin config path (OSCTF_PLUGIN_CONFIG env → sdk.Config) can be asserted end to end. |
|
doubles/crashafter
command
Double: CRASHES AFTER SERVING — handshakes and answers Info, then exits non-zero on the first Value call.
|
Double: CRASHES AFTER SERVING — handshakes and answers Info, then exits non-zero on the first Value call. |
|
doubles/crashlaunch
command
Double: CRASH ON LAUNCH — exits non-zero before serving, every time.
|
Double: CRASH ON LAUNCH — exits non-zero before serving, every time. |
|
doubles/goodscore
command
Double: well-behaved baseline.
|
Double: well-behaved baseline. |
|
doubles/hang
command
Double: HANG — Value never returns (and ignores the context).
|
Double: HANG — Value never returns (and ignores the context). |
|
doubles/ignoreshutdown
command
Double: IGNORES SHUTDOWN — serves correctly but traps and ignores SIGINT/SIGTERM, so a graceful stop does not make it exit.
|
Double: IGNORES SHUTDOWN — serves correctly but traps and ignores SIGINT/SIGTERM, so a graceful stop does not make it exit. |
|
doubles/logecho
command
Double: logs via the PUBLIC sdk.Log() on each call, so the plugin→host log path (over go-plugin's stderr channel into the host Logger) can be asserted end to end.
|
Double: logs via the PUBLIC sdk.Log() on each call, so the plugin→host log path (over go-plugin's stderr channel into the host Logger) can be asserted end to end. |
|
doubles/malformed
command
Double: MALFORMED — serves Info fine but returns a gRPC error status on Value (and an out-of-contract Info name mismatch is available via NAME).
|
Double: MALFORMED — serves Info fine but returns a gRPC error status on Value (and an out-of-contract Info name mismatch is available via NAME). |
|
doubles/nohandshake
command
Double: NEVER HANDSHAKES — a valid executable that starts and then blocks forever without calling plugin.Serve, so the go-plugin handshake never completes and the loader's launch is stuck until its StartTimeout.
|
Double: NEVER HANDSHAKES — a valid executable that starts and then blocks forever without calling plugin.Serve, so the go-plugin handshake never completes and the loader's launch is stuck until its StartTimeout. |
|
doubles/slow
command
Double: SLOW — responds correctly, every time, in 4 seconds, and deliberately IGNORES the request context.
|
Double: SLOW — responds correctly, every time, in 4 seconds, and deliberately IGNORES the request context. |
|
doubles/slowcoop
command
Double: SLOW BUT COOPERATIVE — a long call like `slow`, except it HONORS ctx cancellation: on cancel it returns promptly with codes.Canceled instead of running to completion.
|
Double: SLOW BUT COOPERATIVE — a long call like `slow`, except it HONORS ctx cancellation: on cancel it returns promptly with codes.Canceled instead of running to completion. |
|
doubles/slowshutdown
command
Double: SLOW ONLY ON SHUTDOWN — responds normally, but on a stop signal takes far longer than the 30s drain window to exit.
|
Double: SLOW ONLY ON SHUTDOWN — responds normally, but on a stop signal takes far longer than the 30s drain window to exit. |
|
doubles/wrongabi
command
Double: WRONG ABI MAJOR — serves correctly but with a go-plugin ProtocolVersion the host does not speak.
|
Double: WRONG ABI MAJOR — serves correctly but with a go-plugin ProtocolVersion the host does not speak. |