Go SDK for writing NGINX UI plugins.
A plugin is an ordinary executable. The host starts it, speaks bidirectional
JSON-RPC 2.0 framed as NDJSON over the plugin's stdin and stdout, and
stops it again. stdout carries protocol traffic only; every human readable
line must go to stderr, which the SDK logger does for you.
go get github.com/nginxui/plugin-sdk-gopackage main
import (
"context"
"fmt"
sdk "github.com/nginxui/plugin-sdk-go"
"github.com/nginxui/plugin-sdk-go/protocol"
)
type provider struct{}
// Present publishes the challenge TXT record.
func (provider) Present(ctx context.Context, req sdk.DNS01Request) error {
token := req.Config["MY_API_TOKEN"]
if token == "" {
return sdk.InvalidConfig("MY_API_TOKEN", "the API token is required")
}
sdk.Infof("publishing %s for %s (token %s)", req.EffectiveFQDN, req.Domain, sdk.Redact(token))
// Talk to the vendor API here.
return nil
}
// CleanUp removes what Present published.
func (provider) CleanUp(ctx context.Context, req sdk.DNS01Request) error {
sdk.Infof("removing %s", req.EffectiveFQDN)
return nil
}
// Options is optional: report the propagation timings to the host.
func (provider) Options(context.Context, protocol.DNS01OptionsParams) (protocol.DNS01OptionsResult, error) {
return protocol.DNS01OptionsResult{PropagationTimeoutSeconds: 120, PollingIntervalSeconds: 2}, nil
}
func main() {
sdk.Serve(sdk.Plugin{
DNS01: provider{},
Configure: func(ctx context.Context, settings map[string]any) error {
// Called on plugin.configure and whenever the user saves settings.
if host := sdk.HostFromContext(ctx); host != nil {
return host.Notify(ctx, "info", "Reconfigured", fmt.Sprint(len(settings), " settings"), nil)
}
return nil
},
})
}sdk.Serve blocks: it registers the lifecycle methods, wires the capability
methods your handler implements, and exits on plugin.exit, on end of stdin
or on SIGINT/SIGTERM. Use sdk.Run(ctx, plugin, r, w) in tests to drive the
same wiring over an in-memory pipe. Both accept options, see
Transports.
Each capability is one field of sdk.Plugin. Setting it wires the methods of
the capability and adds its name to the capabilities the plugin reports in
the handshake, which must match the manifest (Plugin.Capabilities overrides
the derived list). The manifest block of each capability is described in the
developer guide.
| Field | Capability | Methods | Handler |
|---|---|---|---|
DNS01 |
dns01 |
dns01.present, dns01.cleanup, optional dns01.validate, dns01.options, dns01.check |
DNS01Handler, plus DNS01Validator, DNS01OptionsProvider, DNS01Checker |
HTTP |
http |
none, a listener the host proxies to | http.Handler |
Notify |
notify |
notify.send, optional notify.validate |
NotifyHandler, plus NotifyValidator |
Probe |
probe |
probe.check |
ProbeHandler |
MCP |
mcp |
mcp.call |
MCPHandler, or the ready-made MCPTools map |
Storage |
storage |
storage.put, storage.get, storage.list, storage.delete, optional storage.validate |
StorageHandler, plus StorageValidator |
Deploy |
cert.deploy |
deploy.push, optional deploy.validate |
DeployHandler, plus DeployValidator |
Blocklist |
security.blocklist |
blocklist.fetch |
BlocklistHandler |
Discovery |
upstream.discovery |
discovery.resolve |
DiscoveryHandler |
LogSink |
log.sink |
the log.push stream, gRPC only |
LogSinkHandler |
An optional method the handler does not implement answers -32002
(Unsupported), and the host falls back or treats it as "no opinion".
A plugin that serves pages or an API declares the http capability with
"http": {"listen": "unix"} in its manifest and sets Plugin.HTTP to any
http.Handler. NGINX UI proxies /api/plugins/<id>/http/... to it after its
own authentication, with WebSocket upgrades and streamed responses passing
through.
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /hello", func(w http.ResponseWriter, r *http.Request) {
user := sdk.UserFromRequest(r)
fmt.Fprintf(w, "hello %s", user.Name)
})
sdk.Serve(sdk.Plugin{HTTP: mux})
}The SDK owns the listener, so the plugin does not open sockets itself:
- On Linux and macOS it listens on the Unix socket
$NGINX_UI_PLUGIN_DATA_DIR/http.sockwith mode0600and replaces a socket left behind by a process that did not exit cleanly. The host looks for the file at exactly this path, so unlike the gRPC socket there is no fallback for a data directory whose path is too long (103 bytes on macOS and the BSDs, 107 on Linux). - On Windows it creates a named pipe under a random name that only the user
of the plugin may open, refusing remote clients, and reports it as
http_pipein theplugin.initializereply, which is where the host reads it.
The listener is open before the plugin.initialize reply is sent. When it
cannot be opened the reply is an internal error and the handshake fails, so the
host shows the plugin as broken instead of proxying into nothing.
On plugin.shutdown the server stops accepting connections at once, runs
Plugin.Shutdown (use it to end long lived streams and WebSockets, which the
server does not track), then waits up to three seconds for the requests still
running and closes what is left. Plugin.Capabilities need not list http
when Plugin.HTTP is set.
The host removes the Authorization and Cookie headers and sets
Nginx-UI-User and Nginx-UI-User-ID, which sdk.UserFromRequest reads.
Every request also has to carry a secret. The host generates a random one for
each process start, hands it over in NGINX_UI_PLUGIN_HTTP_SECRET and sends it
in the header Nginx-UI-Plugin-Secret of every proxied request, on the Unix
socket and on the Windows named pipe alike. The SDK reads the variable once
at start and removes it from the environment, so child processes do not inherit
it. It answers 401 to a request without the matching value (compared in
constant time, WebSocket upgrades included) and takes the header off the
request before your handler sees it. Because of that a handler can trust the
user headers on every platform, even though other local processes may reach
the listener. Never log the secret. When the variable is missing the
handshake fails with an error that names it: the host always sets it.
The host offers every channel of the manifest's notify block next to its
built-in notification channels and calls Send whenever a notification is
routed to one. req.Config holds the values of the channel form, req.Title
and req.Content are already translated plain text, and req.Severity is
info, success, warning or error.
type chat struct{}
func (chat) Send(ctx context.Context, req sdk.NotifyRequest) error {
hook := req.Config["webhook_url"]
if hook == "" {
return sdk.InvalidConfig("webhook_url", "webhook_url is required")
}
// Post req.Title and req.Content to the vendor here.
return nil
}
// Validate is optional. It must not send anything.
func (chat) Validate(ctx context.Context, channel string, config map[string]string) error {
if !strings.HasPrefix(config["webhook_url"], "https://") {
return sdk.InvalidConfig("webhook_url", "webhook_url must be an https URL")
}
return nil
}The host offers every kind of the manifest's probe block as a check method
of a site's health check and calls Check on its schedule. An unhealthy or
unreachable target is a result, not an error: return sdk.ProbeDown with a
message, and an error only when the check itself could not run. The context
expires after req.TimeoutSeconds.
type banner struct{}
func (banner) Check(ctx context.Context, req sdk.ProbeRequest) (sdk.ProbeResult, error) {
started := time.Now()
u, err := url.Parse(req.Target)
if err != nil {
return sdk.ProbeResult{}, sdk.InvalidConfig("target", "target is not a URL")
}
conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", net.JoinHostPort(u.Hostname(), req.Config["port"]))
if err != nil {
return sdk.ProbeDown(time.Since(started), err.Error()), nil
}
defer conn.Close()
return sdk.ProbeUp(time.Since(started)), nil
}The host publishes every tool of the manifest's mcp block on its Model
Context Protocol server under the name <plugin id with dots replaced by underscores>__<tool name> and forwards each call with the unprefixed name.
The manifest must request the mcp permission. Arguments come from an AI
assistant: validate them before use. Return sdk.MCPError for a tool that
ran and failed, so the assistant sees why; MCPTools answers an unknown tool
with -32602.
sdk.Serve(sdk.Plugin{MCP: sdk.MCPTools{
"purge_cache": func(ctx context.Context, args map[string]any) (sdk.MCPResult, error) {
zone, _ := args["zone"].(string)
if zone == "" {
return sdk.MCPError("zone is required"), nil
}
return sdk.MCPText("purged " + zone), nil
},
}})The host offers every backend of the manifest's storage block next to its
built-in storage, for example as a destination of automatic backups. File
contents never travel in a message: for Put the host has placed the file at
req.SourcePath, for Get you write the object to req.TargetPath, both
inside <data dir>/exchange/, and the host removes them after your reply.
Keys are relative / separated paths; the SDK answers a malformed key with
-32602 before your handler runs, so building a vendor path from it is safe.
Delete of a missing object must succeed, and List takes a plain string
prefix.
type dav struct{}
func (dav) Put(ctx context.Context, req sdk.StoragePutRequest) (int64, error) {
f, err := os.Open(req.SourcePath)
if err != nil {
return 0, err
}
defer f.Close()
// Upload f to req.Config["url"] + "/" + req.Key here.
info, err := f.Stat()
if err != nil {
return 0, err
}
return info.Size(), nil
}
func (dav) Get(ctx context.Context, req sdk.StorageGetRequest) (int64, error) {
// Download req.Key into a new file at req.TargetPath here.
return 0, sdk.Internal("not implemented")
}
func (dav) List(ctx context.Context, req sdk.StorageListRequest) ([]sdk.StorageObject, error) {
// Return sdk.StoredObject(key, size, modified) for every key with req.Prefix.
return nil, nil
}
func (dav) Delete(ctx context.Context, req sdk.StorageDeleteRequest) error {
return nil
}The host pushes a certificate to every target a person bound to it after
each issuance or renewal, and on demand. req.Certificate carries the leaf,
its chain and its private key as PEM (sdk.FullChainPEM joins leaf and
chain); req.DryRun asks you to check the target without changing anything.
Because the request carries the private key, the manifest must request the
cert.deploy permission, and the key must never reach a log line or an
error. A push must be idempotent: the host retries failures.
type cdn struct{}
func (cdn) Push(ctx context.Context, req sdk.DeployRequest) (string, error) {
zone := req.Config["zone_id"]
if zone == "" {
return "", sdk.InvalidConfig("zone_id", "zone_id is required")
}
if req.DryRun {
// Read-only checks against the CDN here.
return "zone " + zone + " is reachable", nil
}
// Upload sdk.FullChainPEM(req.Certificate) and req.Certificate.PrivateKeyPEM here.
return "certificate bound to zone " + zone, nil
}The host fetches every source a person configured from the manifest's
blocklist block on its refresh interval and writes the entries as nginx
deny rules to a file the person includes where the list should apply.
Return the complete list every time, as addresses or CIDR networks
(sdk.Deny builds an entry); the host validates each one and drops the
rest. An empty list denies nothing, so when the source cannot be read return
an error and the host keeps the list it has. TTLSeconds asks for an
earlier refresh. The manifest must request the network permission.
type feed struct{}
func (feed) Fetch(ctx context.Context, req sdk.BlocklistRequest) (sdk.BlocklistResult, error) {
key := req.Config["api_key"]
if key == "" {
return sdk.BlocklistResult{}, sdk.InvalidConfig("api_key", "api_key is required")
}
// Download the list with key here.
return sdk.BlocklistResult{
Entries: []sdk.BlocklistEntry{sdk.Deny("203.0.113.0/24", "botnet")},
TTLSeconds: 900,
}, nil
}The host resolves every upstream a person bound to a service of one of the
manifest's discovery providers on its refresh interval and writes the
servers as an nginx upstream block. Return every server of req.Service
(sdk.Target builds one with weight 1): an IP address or a host name, a port
and a weight. Return sdk.UnknownService for a service the provider does
not know and any other error when the provider cannot be reached; the host
then keeps the servers it has. The manifest must request the network
permission.
type registry struct{}
func (registry) Resolve(ctx context.Context, req sdk.DiscoveryRequest) (sdk.DiscoveryResult, error) {
if req.Config["address"] == "" {
return sdk.DiscoveryResult{}, sdk.InvalidConfig("address", "address is required")
}
// Look req.Service up in the registry here.
return sdk.DiscoveryResult{Targets: []sdk.DiscoveryTarget{sdk.Target("10.0.1.12", 8080)}}, nil
}The host streams the nginx access log lines it reads to a log.sink plugin
while nginx writes them, in batches of at most log_sink.batch_size
entries (256 by default). Push gets one batch, every entry with its
LogPath and the parsed fields (RemoteAddr, RequestURI, Status,
BodyBytesSent, RequestTime, ...), and returns how many entries it kept;
the rest counts as rejected. entry.Parsed() is false for a line the host
could not parse, which carries only Raw and Timestamp. Answer quickly and
buffer towards a slow destination: the host queues at most 8192 lines per
plugin and drops the rest. An error loses the whole batch. The manifest must
request the log.read permission.
type shipper struct{ out chan<- sdk.LogEntry }
func (s shipper) Push(ctx context.Context, batch []sdk.LogEntry) (int, error) {
accepted := 0
for _, entry := range batch {
select {
case s.out <- entry:
accepted++
default:
// The buffer is full, the entry counts as rejected.
}
}
return accepted, nil
}The lines travel as a client stream on the gRPC transport only:
log.push has no stdio form and answers -32601 there. Setting
LogSink therefore keeps gRPC on even when WithoutGRPC or
NGINX_UI_PLUGIN_DISABLE_GRPC=1 asked for stdio only.
Config templates and translation files need no process and no SDK: declare
them in the manifest's content block and ship the files in the package.
See Templates and Translations.
stdio is always served. On top of it the SDK serves the same handlers over
gRPC by default and advertises it in the plugin.initialize reply
(transports: ["stdio", "grpc"]), so the host can send capability calls
(dns01.*, http.handle, notify.*, probe.check, mcp.call,
storage.*, deploy.*, blocklist.fetch, discovery.resolve) there, and
streams (log.push), which exist on gRPC only. Lifecycle methods, host.*
calls, host log lines and notifications stay on stdio. Nothing changes for your handlers: a
gRPC call is decoded into the same JSON params and runs the same handler, and
a returned *protocol.Error reaches the host with the same code, message and
data on either transport.
- On Linux and macOS the server listens on the Unix socket
$NGINX_UI_PLUGIN_DATA_DIR/rpc.sock. When that path is longer than the platform allows (103 bytes on macOS and the BSDs, 107 on Linux) or the data directory is unusable, the SDK uses a private directory under the system temp dir instead. The path is always reported inrpc_socket, and the socket is removed when the plugin exits. - On Windows it listens on a named pipe under a random name, reported in
rpc_pipetogether with a randomrpc_token. Calls without the headerauthorization: Bearer <rpc_token>are rejected.
To stay on stdio only, pass sdk.WithoutGRPC() to Serve or Run, or set
NGINX_UI_PLUGIN_DISABLE_GRPC=1 in the plugin environment (a plugin with a
LogSink keeps gRPC regardless):
sdk.Serve(sdk.Plugin{DNS01: provider{}}, sdk.WithoutGRPC())When the listener cannot be opened the SDK logs a warning and advertises stdio only; the host never depends on gRPC being present.
| Package | Contents |
|---|---|
sdk (root) |
Plugin, Serve, Run, the capability handler interfaces and helpers, the Host client, errors and the logger |
sdk/protocol |
The wire types, method names, capability, permission and error-code constants. Mirrors internal/plugin/protocol of nginx-ui |
sdk/jsonrpc |
The bidirectional NDJSON JSON-RPC 2.0 peer, usable on its own |
sdk/pb |
Generated protobuf and gRPC bindings of the contract (package pluginv1), copied from the spec repository |
The wire contract is defined in proto, in
plugin-spec
under proto/nginxui/plugin/v1. A JSON-RPC method is the rpc's rpc_name
option and params / result are the protobuf JSON mapping of its messages
with proto field names, so the JSON the SDK exchanges is exactly what the
proto describes.
pb is a verbatim copy of the spec repository's generated gen/go package:
message types such as pluginv1.DNS01PresentRequest, the rpc_name,
notification and streaming options, and gRPC clients and servers for the
Plugin, Host, DNS01, HTTP, Notify, Probe, MCP, Storage,
Deploy, Blocklist, Discovery, LogSink and Events services. The plugin runtime in this
module keeps using the hand-written protocol types; pb is there for
reflection, for gRPC and for code that prefers generated types. The gRPC
transport resolves every call through the descriptors in pb, so a new rpc
in the contract is served as soon as pb is updated and a handler exists; a
client streaming rpc is detected from the descriptors, read until the end of
the stream and handed to its stream handler.
protocol/alignment_test.go fails when a protocol type drifts from its
proto message, when a method constant has no rpc, or when an error code
differs from the ErrorCode enum.
To pick up a contract change, run make generate in the spec repository,
then pb/regen.sh here (it expects the spec checkout next to this
repository, or SPEC_DIR), then update protocol until go test ./...
passes. Do not link pb into one binary together with another copy of the
same generated package: the protobuf runtime rejects duplicate registrations
of nginxui.plugin.v1.
| Method | Direction | Meaning |
|---|---|---|
plugin.initialize |
host → plugin | Handshake. The reply carries api_version and the implemented capabilities |
plugin.initialized |
host → plugin | Notification. host.* calls are allowed from here on |
plugin.configure |
host → plugin | New settings map |
plugin.ping |
host → plugin | Liveness probe, replies {} |
plugin.shutdown |
host → plugin | Finish in-flight work, then reply {} |
plugin.exit |
host → plugin | Notification. Exit now |
Requests are matched by id and may be concurrent. Notifications carry no id and are never answered. A message larger than 4 MiB is rejected.
| Code | Helper | Meaning |
|---|---|---|
-32601 |
— | Unknown method |
-32602 |
sdk.InvalidParams, sdk.UnknownTool |
Malformed params, an MCP tool the plugin does not serve, or a malformed storage key |
-32000 |
sdk.Internal |
Internal failure |
-32002 |
sdk.Unsupported |
Capability method the plugin does not implement |
-32003 |
sdk.InvalidConfig |
Bad credential or setting, data.field names it |
Returning any other Go error from a handler becomes -32000.
Once plugin.initialized arrived, sdk.HostFromContext(ctx) (or
sdk.CurrentHost()) returns a client for the host.* side of the protocol:
Log, KVGet / KVSet / KVDelete / KVList, SettingsGet, Locale,
CredentialsGet, CronRegister / CronUnregister, Notify,
MetricsSnapshot, LogsList, ActivitySet (Activity wraps it in a
function that clears the entry), NginxSnippetPut / NginxSnippetDelete /
NginxSnippetList, NginxConfigList / NginxConfigGet, SitesList and
CertsList. Each call needs the matching manifest
permission; without it the host answers -32001. Settings() returns the latest settings map and
Info() the plugin id, data directory and host information.
LogsList returns the nginx log files the host allows the plugin to read,
each with Path, Type (protocol.LogTypeAccess or protocol.LogTypeError),
Source and ConfigFile. It needs the log.files permission
(protocol.PermissionLogFiles). Rotated files are not listed: read them next
to a listed path. Subscribe to log.paths_changed in the manifest events
and list again when it arrives:
sdk.Serve(sdk.Plugin{
Events: map[string]sdk.EventHandler{
protocol.EventLogPathsChanged: func(ctx context.Context, _ protocol.EventNotification) {
logs, _ := sdk.HostFromContext(ctx).LogsList(ctx)
rescan(logs)
},
},
})Plugin.Events takes over events.on and ignores the types it has no handler
for. ActivitySet(ctx, key, label, active) shows a background task in the host
processing indicator. label is an English source string; the browser bundle
translates it with registerTranslations.
With the nginx.snippet permission a plugin keeps nginx configuration of its
own. NginxSnippetPut(ctx, name, content) writes the snippet, and the host
tests the whole configuration and reloads nginx. When nginx rejects it, the
previous snippet stays and the call fails with -32602, carrying what nginx
said. The call returns the include directive a person adds where the snippet
should apply. A snippet that is still included cannot be deleted.
changed, include, err := sdk.HostFromContext(ctx).NginxSnippetPut(ctx, "static", "expires 7d;\n")NginxConfigList and NginxConfigGet (nginx.config.read) read the
configuration files, SitesList (sites.read) lists the sites and
CertsList (certs.read) the certificates, never with their private keys.
A cron entry, from the manifest or from CronRegister, names a method of the
plugin. When it fires, the host calls that method as an ordinary request with
params {"type": "<cron id>", "ts": <unix seconds>} and waits for the reply,
so register the handler under Plugin.Methods. Cron invocations do not go
through events.on.
| Variable | Meaning |
|---|---|
NGINX_UI_PLUGIN_ID |
The plugin id from the manifest |
NGINX_UI_PLUGIN_API_VERSION |
The protocol version the host speaks |
NGINX_UI_PLUGIN_DATA_DIR |
The only directory the plugin may write to |
NGINX_UI_VERSION |
The host version |
NGINX_UI_PLUGIN_HTTP_SECRET |
Per process secret for the http capability, read and removed by the SDK, see HTTP API |
HTTP_PROXY, HTTPS_PROXY, NO_PROXY (and lowercase) |
Set only for a plugin that holds the network permission, and only when the host has a proxy configured. http.ProxyFromEnvironment and the default http.Transport pick them up |
NGINX_UI_PLUGIN_DISABLE_GRPC |
Set to 1 to serve stdio only, like sdk.WithoutGRPC() |
AGPL-3.0. See LICENSE.