Documentation
¶
Overview ¶
Package plugin is the host side of the OSCTF plugin ABI: the go-plugin handshake, the gRPC transport bridge to the generated stubs (pluginpb), and (in later sub-steps) the loader, lifecycle, and registry wiring. Plugins run as separate processes; the host dials a local gRPC server each serves. See docs/v0.3/02-plugin-abi.md and 03-plugin-loader.md.
Index ¶
Constants ¶
const ( KeyAuth = "auth" KeyScoring = "scoring" KeyNotification = "notification" KeyChallengeType = "challenge_type" )
Dispense keys — one per plugin type. A plugin serves exactly the one its manifest `type` declares; the host dispenses that key and receives the matching gRPC client.
const ABIMajor = 1
ABIMajor is the go-plugin ProtocolVersion — the ABI MAJOR, bumped ONLY on a breaking change. A plugin built against a different major is refused by go-plugin before any call; the loader logs "ABI major mismatch" and skips it (no crash, no partial init).
const ABIMinor = 0
ABIMinor is the host's ABI minor. Minor is forward-compatible: the host may call a plugin advertising an OLDER minor (it won't invoke methods/fields the plugin lacks), and a plugin advertising a NEWER minor is accepted (the host uses only what it knows). Carried per-plugin in the manifest and the Info RPC.
const ABIString = "1.0"
ABIString is the host's advertised "major.minor".
const PluginConfigEnv = "OSCTF_PLUGIN_CONFIG"
PluginConfigEnv is the single environment variable the host sets on the PLUGIN process carrying its resolved config as a JSON object. It is the SHARED definition: the host writes it here and the public SDK's Config() reads it (plugin/sdk imports this const), so the two sides cannot drift — the pairing is one source of truth, not two strings that must agree. One var (not per-key) so a plugin never reconstructs the host's OSCTF_PLUGIN_<NAME>_<KEY> override names — it just asks for a key.
Variables ¶
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 = goplugin.HandshakeConfig{ ProtocolVersion: ABIMajor, MagicCookieKey: "OSCTF_PLUGIN", MagicCookieValue: "osctf-plugin-v1", }
Handshake gates every plugin connection. The magic cookie guards against launching a non-OSCTF binary; ProtocolVersion is the ABI major (a mismatch is refused pre-call).
Functions ¶
func HostPluginSet ¶
HostPluginSet is the set the host offers when dialing a plugin. Each entry's Impl is nil on the host side; Dispense returns the generated gRPC client. The SDK builds the mirror set with Impls set (the plugin author's implementation).
Types ¶
type AuthGRPCPlugin ¶
type AuthGRPCPlugin struct {
goplugin.NetRPCUnsupportedPlugin
Impl pluginpb.AuthServer
}
AuthGRPCPlugin bridges the Auth service.
func (*AuthGRPCPlugin) GRPCClient ¶
func (p *AuthGRPCPlugin) GRPCClient(_ context.Context, _ *goplugin.GRPCBroker, c *grpc.ClientConn) (any, error)
func (*AuthGRPCPlugin) GRPCServer ¶
func (p *AuthGRPCPlugin) GRPCServer(_ *goplugin.GRPCBroker, s *grpc.Server) error
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 struct {
goplugin.NetRPCUnsupportedPlugin
Impl pluginpb.ChallengeTypeServer
}
ChallengeTypeGRPCPlugin bridges the ChallengeType service.
func (*ChallengeTypeGRPCPlugin) GRPCClient ¶
func (p *ChallengeTypeGRPCPlugin) GRPCClient(_ context.Context, _ *goplugin.GRPCBroker, c *grpc.ClientConn) (any, error)
func (*ChallengeTypeGRPCPlugin) GRPCServer ¶
func (p *ChallengeTypeGRPCPlugin) GRPCServer(_ *goplugin.GRPCBroker, s *grpc.Server) error
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) 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 struct {
goplugin.NetRPCUnsupportedPlugin
Impl pluginpb.NotificationServer
}
NotificationGRPCPlugin bridges the Notification service.
func (*NotificationGRPCPlugin) GRPCClient ¶
func (p *NotificationGRPCPlugin) GRPCClient(_ context.Context, _ *goplugin.GRPCBroker, c *grpc.ClientConn) (any, error)
func (*NotificationGRPCPlugin) GRPCServer ¶
func (p *NotificationGRPCPlugin) GRPCServer(_ *goplugin.GRPCBroker, s *grpc.Server) error
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 struct {
goplugin.NetRPCUnsupportedPlugin
Impl pluginpb.ScoringServer
}
ScoringGRPCPlugin bridges the Scoring service.
func (*ScoringGRPCPlugin) GRPCClient ¶
func (p *ScoringGRPCPlugin) GRPCClient(_ context.Context, _ *goplugin.GRPCBroker, c *grpc.ClientConn) (any, error)
func (*ScoringGRPCPlugin) GRPCServer ¶
func (p *ScoringGRPCPlugin) GRPCServer(_ *goplugin.GRPCBroker, s *grpc.Server) error
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. |