11 KiB
Writing a happyDomain Plugin
happyDomain supports external check plugins — shared libraries (.so files) that run domain health checks and diagnostics. Plugins are loaded at runtime and integrate seamlessly into happyDomain's domain and service testing UI.
Overview
A plugin is a Go shared library (-buildmode=plugin) that exports a single entry point: NewCheckPlugin. At startup, happyDomain scans its configured plugin directories, loads each .so file it finds, calls NewCheckPlugin, and registers the returned checker under the declared name.
A plugin implements the Checker interface from git.happydns.org/happyDomain/model:
type Checker interface {
ID() string
Name() string
Availability() CheckerAvailability
Options() CheckerOptionsDocumentation
RunCheck(options CheckerOptions, meta map[string]string) (*CheckResult, error)
}
Project Structure
A minimal plugin lives in its own directory with package main:
myplugin/
├── go.mod
├── Makefile
└── plugin.go (or split across multiple .go files)
go.mod
Your plugin must declare the same module path as its source tree and depend on the happyDomain model:
module git.happydns.org/happyDomain/plugins/myplugin
go 1.25
require git.happydns.org/happyDomain v0.0.0
replace git.happydns.org/happyDomain => ../../
The replace directive points to your local happyDomain checkout, ensuring the plugin is compiled against the exact same types.
Important: A Go plugin and the host program must be built with the same Go toolchain version and the same versions of all shared dependencies. Any mismatch will cause a runtime load error.
The Entry Point
Every plugin must export a NewCheckPlugin function with this exact signature:
package main
import "git.happydns.org/happyDomain/model"
func NewCheckPlugin() (string, happydns.Checker, error) {
return "myplugin", &MyPlugin{}, nil
}
The first return value is the unique registration name for the checker. You can use the constructor to perform one-time initialisation (read config files, create HTTP clients, etc.) and return an error if the plugin cannot function.
Implementing the Interface
ID() string
Returns the unique string identifier for the checker. This name is used internally to look up the checker and to store its configuration. Use a short, lowercase, collision-resistant name:
func (p *MyPlugin) ID() string {
return "myplugin"
}
The value returned here should match the name returned by NewCheckPlugin. If two checkers claim the same ID, the second one is silently ignored and a conflict is logged.
Name() string
Returns a human-readable display name for the checker:
func (p *MyPlugin) Name() string {
return "My Plugin"
}
Availability() CheckerAvailability
Declares where the checker applies:
func (p *MyPlugin) Availability() happydns.CheckerAvailability {
return happydns.CheckerAvailability{
ApplyToDomain: true,
ApplyToService: false,
LimitToProviders: []string{}, // empty = all providers
LimitToServices: []string{"abstract.MatrixIM"},
}
}
CheckerAvailability fields:
| Field | Type | Description |
|---|---|---|
ApplyToDomain |
bool |
Checker can be run against a whole domain |
ApplyToService |
bool |
Checker can be run against a specific service |
LimitToProviders |
[]string |
Restrict to certain DNS provider identifiers (empty = no restriction) |
LimitToServices |
[]string |
Restrict to certain service type identifiers, e.g. "abstract.MatrixIM" (empty = no restriction) |
Options() CheckerOptionsDocumentation
Declares all configurable options, grouped by who sets them and at which scope:
func (p *MyPlugin) Options() happydns.CheckerOptionsDocumentation {
return happydns.CheckerOptionsDocumentation{
RunOpts: []happydns.CheckerOptionDocumentation{ /* per-run options */ },
ServiceOpts: []happydns.CheckerOptionDocumentation{ /* per-service options */ },
DomainOpts: []happydns.CheckerOptionDocumentation{ /* per-domain options */ },
UserOpts: []happydns.CheckerOptionDocumentation{ /* per-user options */ },
AdminOpts: []happydns.CheckerOptionDocumentation{ /* admin-only options */ },
}
}
Option scopes
| Field | Who sets it | Typical use |
|---|---|---|
RunOpts |
The user at test time | Test-specific parameters (e.g. domain to test) |
ServiceOpts |
The user, per service | Configuration scoped to a DNS service |
DomainOpts |
The user, per domain | Configuration scoped to a whole domain |
UserOpts |
The user, globally | Personal preferences (e.g. language) |
AdminOpts |
The instance administrator | Backend URLs, API keys shared by all users |
Options from all scopes are merged before RunCheck is called, with more-specific scopes overriding less-specific ones.
CheckerOptionDocumentation fields
Each option is described by a CheckerOptionDocumentation (an alias for Field):
| Field | Type | Description |
|---|---|---|
Id |
string |
Required. Key used in CheckerOptions map |
Type |
string |
Input type: "string", "select", … |
Label |
string |
Human-readable label shown in the UI |
Placeholder |
string |
Input placeholder text |
Default |
any |
Default value pre-filled in the form |
Choices |
[]string |
Available choices for "select" type inputs |
Required |
bool |
Whether the field must be filled before running |
Secret |
bool |
Marks the field as sensitive (e.g. API key) |
Hide |
bool |
Hides the field from the user |
Textarea |
bool |
Displays a multiline text area |
Description |
string |
Help text shown below the field |
AutoFill |
string |
Automatically populate the field from context (see below) |
Auto-fill variables
When a field's AutoFill is set, happyDomain populates it from the test context — the user does not need to fill it in:
| Constant | Value | Filled with |
|---|---|---|
happydns.AutoFillDomainName |
"domain_name" |
The FQDN of the domain under test (e.g. "example.com.") |
happydns.AutoFillSubdomain |
"subdomain" |
Subdomain relative to the zone (service-scoped tests only) |
happydns.AutoFillServiceType |
"service_type" |
Service type identifier (service-scoped tests only) |
{
Id: "domainName",
Type: "string",
Label: "Domain name",
AutoFill: happydns.AutoFillDomainName,
Required: true,
},
RunCheck(options CheckerOptions, meta map[string]string) (*CheckResult, error)
This is where the actual check happens. options is the merged map of all scoped options (keyed by option Id). meta carries additional context provided by the scheduler (currently reserved for future use).
func (p *MyPlugin) RunCheck(options happydns.CheckerOptions, meta map[string]string) (*happydns.CheckResult, error) {
domain, ok := options["domainName"].(string)
if !ok || domain == "" {
return nil, fmt.Errorf("domainName is required")
}
// ... perform the check ...
return &happydns.CheckResult{
Status: happydns.CheckResultStatusOK,
StatusLine: "All good",
Report: myDetailedReport,
}, nil
}
Return a non-nil error for hard failures (network errors, invalid options). Return a CheckResult with a KO status for expected failures (e.g. the DNS check failed).
CheckResult fields set by the plugin
| Field | Type | Description |
|---|---|---|
Status |
CheckResultStatus |
Overall result level |
StatusLine |
string |
Short human-readable summary |
Report |
any |
Arbitrary data (serialised to JSON and stored) |
The remaining fields (Id, CheckerName, ExecutedAt, etc.) are filled in by happyDomain automatically.
CheckResultStatus values (ordered worst → best)
| Constant | Meaning |
|---|---|
CheckResultStatusKO |
Check failed |
CheckResultStatusWarn |
Check passed with warnings |
CheckResultStatusInfo |
Informational result |
CheckResultStatusOK |
Check fully passed |
Full Example
The matrix federation checker plugin (matrix/) illustrates a real-world plugin:
main.go — exports the entry point:
package main
import "git.happydns.org/happyDomain/model"
func NewCheckPlugin() (string, happydns.Checker, error) {
return "matrixim", &MatrixTester{
TesterURI: "https://federationtester.matrix.org/api/report?server_name=%s",
}, nil
}
test.go — implements the interface on a struct:
func (p *MatrixTester) ID() string { return "matrixim" }
func (p *MatrixTester) Name() string { return "Matrix Federation Tester" }
func (p *MatrixTester) Availability() happydns.CheckerAvailability {
return happydns.CheckerAvailability{
ApplyToService: true,
LimitToServices: []string{"abstract.MatrixIM"},
}
}
func (p *MatrixTester) Options() happydns.CheckerOptionsDocumentation { /* ... */ }
func (p *MatrixTester) RunCheck(options happydns.CheckerOptions, meta map[string]string) (*happydns.CheckResult, error) { /* ... */ }
The built-in Zonemaster checker (checks/zonemaster.go) shows a more complex flow: it starts an asynchronous test, polls for completion, and aggregates results across multiple severity levels. Although it is compiled in rather than loaded as a .so, it implements the same Checker interface and is a useful reference.
Building
Use -buildmode=plugin:
go build -buildmode=plugin -o happydomain-plugin-test-myplugin.so \
git.happydns.org/happyDomain/plugins/myplugin
A minimal Makefile:
PLUGIN_NAME=myplugin
TARGET=../happydomain-plugin-test-$(PLUGIN_NAME).so
all: $(TARGET)
$(TARGET): *.go
go build -buildmode=plugin -o $@ git.happydns.org/happyDomain/plugins/$(PLUGIN_NAME)
Naming convention: happyDomain looks for any
.sofile in the plugin directory, but using the prefixhappydomain-plugin-test-makes the purpose clear.
Deployment
1. Copy the .so file to a plugin directory
cp happydomain-plugin-test-myplugin.so /usr/lib/happydomain/plugins/
2. Configure happyDomain to load that directory
In your happydomain.conf:
plugins-directories=/usr/lib/happydomain/plugins
Or via an environment variable:
HAPPYDOMAIN_PLUGINS_DIRECTORIES=/usr/lib/happydomain/plugins
Multiple directories can be specified as a comma-separated list. happyDomain scans each directory at startup and attempts to load every .so file it finds. Loading errors are logged but do not prevent the server from starting.
3. Verify
Check the server logs at startup for a line like:
Plugin myplugin loaded
If a name conflict or load error occurs, a warning is logged with the filename and reason.