plugin

package
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 24, 2026 License: Apache-2.0 Imports: 30 Imported by: 0

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

View Source
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

View Source
var ErrNoSuchPlugin = errors.New("plugin: no such plugin")

ErrNoSuchPlugin is returned by Reload for a name the loader does not track.

View Source
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.

View Source
var Handshake = abi.Handshake

Handshake gates every plugin connection (see abi.Handshake).

Functions

func CountByType

func CountByType(root string, ptype string, log *slog.Logger) int

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

func HostPluginSet() goplugin.PluginSet

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

func New(cfg Config) *Loader

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

func (l *Loader) Boot(ctx context.Context)

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

func (l *Loader) Reload(ctx context.Context, name string) error

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

func (l *Loader) Stop(ctx context.Context)

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

type PluginStatus struct {
	Name   string
	Type   string
	State  string
	Reason string
}

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

type ShedError struct {
	Plugin     string
	Level      ShedLevel
	RetryAfter time.Duration
}

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.

func (*ShedError) Error

func (e *ShedError) Error() string

func (*ShedError) Is

func (e *ShedError) Is(target error) bool

Is lets the existing 503 mapping treat a shed as Unavailable without importing this package.

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).

const (
	ShedPerPlugin ShedLevel = iota // one plugin hit OSCTF_PLUGIN_MAX_INFLIGHT
	ShedGlobal                     // the shared budget (all plugins) is exhausted
)

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
)

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.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL