Create Your First Go Plugin
This tutorial builds a small pre-auth environment source. It demonstrates the minimum public contract without importing any package below server/.
Prerequisites
You need:
- a platform supported by Go's
pluginbuild mode; - the exact Go toolchain and
GOEXPERIMENTused by the Nauthilus host; - the exact Nauthilus module version or commit used by that host;
- matching build tags and shared dependency versions;
- an operator-controlled directory from which Nauthilus may load
.sofiles.
Native Go plugins are release-specific binaries. A successful compile against pluginapi/v1 is necessary but does not, by itself, prove that plugin.Open can load the artifact into a differently built server.
Create a module
mkdir customer-risk-plugin
cd customer-risk-plugin
go mod init example.com/customer-risk-plugin
# Replace <host-tag-or-commit> with the exact Nauthilus source used by the server.
go get github.com/croessner/nauthilus/v3@<host-tag-or-commit>
Use only the public packages when writing the plugin:
github.com/croessner/nauthilus/v3/pluginapi/v1
github.com/croessner/nauthilus/v3/pluginapi/v1/exchange
github.com/croessner/nauthilus/v3/pluginapi/v1/helpers
github.com/croessner/nauthilus/v3/pluginapi/v1/password
Do not import github.com/croessner/nauthilus/v3/server/.... Those packages are host internals and are neither a public source contract nor a safe shared-library boundary.
Implement the factory and plugin
Create main.go:
package main
import (
"context"
"fmt"
"time"
pluginapi "github.com/croessner/nauthilus/v3/pluginapi/v1"
)
const riskFact = "plugin.environment.customer_risk.suspicious"
var (
_ pluginapi.Plugin = (*Plugin)(nil)
_ pluginapi.EnvironmentSource = riskSource{}
)
// NauthilusPlugin returns one fresh instance for one configured module.
func NauthilusPlugin() (pluginapi.Plugin, error) {
return &Plugin{}, nil
}
type Plugin struct{}
func (p *Plugin) Metadata() pluginapi.Metadata {
return pluginapi.Metadata{
Name: "customer_risk",
Version: "1.0.0",
APIVersion: pluginapi.APIVersion,
Description: "Example pre-auth risk signal.",
Features: []pluginapi.Feature{"environment"},
}
}
func (p *Plugin) Register(registrar pluginapi.Registrar) error {
if registrar == nil {
return fmt.Errorf("registrar is nil")
}
if err := registrar.RegisterPolicyAttribute(pluginapi.AttributeDefinition{
ID: riskFact,
Description: "Reports whether the example considered the client suspicious.",
Stage: pluginapi.PolicyStagePreAuth,
Operations: []pluginapi.PolicyOperation{
pluginapi.PolicyOperationAuthenticate,
},
ProducerTypes: []string{"plugin.environment"},
Category: pluginapi.AttributeCategoryEnvironment,
Type: pluginapi.AttributeTypeBool,
}); err != nil {
return err
}
return registrar.RegisterEnvironmentSource(riskSource{})
}
type riskSource struct{}
func (riskSource) Descriptor() pluginapi.SourceDescriptor {
return pluginapi.SourceDescriptor{
Name: "risk",
Timeout: 100 * time.Millisecond,
AbortPolicy: pluginapi.AbortPolicyNone,
}
}
func (riskSource) Evaluate(
_ context.Context,
request pluginapi.EnvironmentRequest,
) (pluginapi.EnvironmentResult, error) {
suspicious := request.Snapshot.ClientNet == "203.0.113.0/24"
return pluginapi.EnvironmentResult{
Triggered: suspicious,
Facts: []pluginapi.PolicyFact{
{Attribute: riskFact, Value: suspicious},
},
RuntimeDelta: pluginapi.RuntimeDelta{
Set: map[string]any{
"plugin.environment.customer_risk": map[string]any{
"suspicious": suspicious,
},
},
},
}, nil
}
The compile-time assertions make interface drift visible during a normal build. NauthilusPlugin returns a new pointer because Nauthilus calls the factory independently for every configured module instance.
Understand the returned values
The environment source returns three different concepts:
Triggeredis an execution diagnostic indicating that the source matched.Factssupplies typed evidence to the policy engine. Every fact must have been registered before the active policy snapshot is compiled.RuntimeDeltapublishes request-local data for later extension components. It does not directly decide whether authentication succeeds.
Policy facts and runtime values are deliberately separate. Facts are validated decision evidence; runtime values are safe, JSON-compatible coordination data.
Build the shared object
Use the same experiment, tags, and path-shaping flags as the host build:
GOEXPERIMENT=runtimesecret \
go build \
-tags=netgo \
-trimpath \
-buildmode=plugin \
-o build/customer-risk.so \
.
If the host is built with additional build tags, the plugin build must use the same set. If your release process vendors modules, vendor the plugin's dependency graph and add -mod=vendor to match that process.
Configure the module
Copy the artifact into an operator-owned allowlisted directory, calculate its SHA-256 digest, and configure it:
plugins:
verification_policy: checksum_required
allowed_dirs:
- /usr/local/lib/nauthilus/plugins
modules:
- name: customer_risk
type: go
path: /usr/local/lib/nauthilus/plugins/customer-risk.so
checksum: sha256:replace-with-the-artifact-digest
optional: false
The configured module name, customer_risk, is the runtime namespace. The source's local name, risk, becomes the qualified component name customer_risk.risk. Metadata().Name remains product metadata and does not replace the configured module name.
See Configure native Go plugins for signature verification, optional modules, capabilities, discovery, and restart rules.
Use the fact in policy
The plugin registers the attribute contract, but an active policy check still determines where the source runs and which policy consumes its output. A representative check uses the native environment producer and the fully qualified component:
auth:
policy:
checks:
- name: customer_risk_check
type: plugin.environment
stage: pre_auth
operations: [authenticate]
config_ref: plugins.modules.customer_risk.environment
The environment check selects the configured module-level environment stage, so
its config_ref always ends in .environment; it does not use the source's
local name. Reference the registered
plugin.environment.customer_risk.suspicious attribute from the relevant
policy expression. Keep the check name and component name conceptually separate:
a ProducerCheck refers to the compiled policy check name, while
customer_risk.risk identifies the registered source component.
This sample is intended as one configured module instance. Its fixed riskFact
ID is global in the policy registry, so a second instance would collide unless
the plugin deliberately gives all global registrations and service names an
instance-safe namespace.
Next steps
- Read Lifecycle and registration before allocating resources or starting workers.
- Read Requests, runtime values, and effects before adding policy or authentication behavior.
- Use the API reference for exact field and method contracts.
- Study the GeoIP/ASN reference plugin for a complete lifecycle implementation.
The Nauthilus source tree includes pluginapi/v1/testdata/sampleplugin. It implements the public interfaces without importing server/* and is the smallest authoritative example for compile-time API compatibility.