Writing a happyDomain Plugin

happyDomain supports external test plugins — shared libraries (.so files) that add domain or service health checks to a running instance. Plugins are loaded at startup without recompiling the server; the operator simply drops a .so file into a configured directory.

How it works

A plugin receives a set of options assembled from several configuration scopes, runs a check (HTTP call, DNS query, …), and returns a result with a status level and an optional detailed report. Results are stored and displayed in the happyDomain UI alongside the domain or service they concern.

When happyDomain starts it scans every directory listed in the plugins-directories configuration option. For each file it finds, it:

  1. Opens the shared library.
  2. Looks up the exported symbol NewTestPlugin.
  3. Calls NewTestPlugin() to obtain a plugin value.
  4. Registers the plugin under each name returned by PluginEnvName().

If the file is not a valid Go plugin, if NewTestPlugin is missing, or if it returns an error, a warning is logged and the file is skipped. The server always starts regardless of individual plugin load failures.


The TestPlugin interface

Every plugin must implement four methods:

type TestPlugin interface {
    PluginEnvName() []string
    Version()          PluginVersionInfo
    AvailableOptions() PluginOptionsDocumentation
    RunTest(PluginOptions, map[string]string) (*PluginResult, error)
}

Project structure

A plugin is a standalone Go module compiled with -buildmode=plugin. It must be in package main and export exactly one symbol:

func NewTestPlugin() (happydns.TestPlugin, error)

Recommended layout:

myplugin/
├── go.mod
├── Makefile
└── plugin.go       # (or split across multiple .go files)

go.mod

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 as the server.

Warning
A Go plugin and the host process share the same runtime. They **must** be compiled with the same Go toolchain version and the same versions of every shared dependency. Any mismatch produces a hard error at load time.

Entry point

package main

import "git.happydns.org/happyDomain/model"

func NewTestPlugin() (happydns.TestPlugin, error) {
    return &MyPlugin{}, nil
}

The constructor is a good place to perform one-time initialisation (open config files, create an HTTP client, …). Return an error if the plugin cannot function.


Naming — PluginEnvName()

Returns one or more short, lowercase identifiers. These names are used to look up the plugin via the API and to key its stored configuration.

func (p *MyPlugin) PluginEnvName() []string {
    return []string{"myplugin"}
}

Choose names that are unlikely to collide (e.g. "zonemaster", "matrixim") and keep them stable across versions because they are persisted alongside user configuration. If two loaded plugins claim the same name, the second one is skipped and a conflict is logged.


Version and availability — Version()

Describes the plugin and controls where it appears in the UI:

func (p *MyPlugin) Version() happydns.PluginVersionInfo {
    return happydns.PluginVersionInfo{
        Name:    "My Plugin",
        Version: "1.0",
        AvailableOn: happydns.PluginAvailability{
            ApplyToDomain:    true,
            ApplyToService:   false,
            LimitToProviders: nil,  // nil or empty = all providers
            LimitToServices:  []string{"abstract.MatrixIM"},
        },
    }
}
Field Type Description
ApplyToDomain bool Plugin can be run against a whole domain
ApplyToService bool Plugin can be run against a specific DNS 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)

Both ApplyToDomain and ApplyToService may be true simultaneously.


Options — AvailableOptions()

Options are key/value pairs (map[string]any) that configure each test run. They are declared grouped by scope, i.e. who sets them and how long they persist:

func (p *MyPlugin) AvailableOptions() happydns.PluginOptionsDocumentation {
    return happydns.PluginOptionsDocumentation{
        RunOpts:     []happydns.PluginOptionDocumentation{ /* … */ },
        ServiceOpts: []happydns.PluginOptionDocumentation{ /* … */ },
        DomainOpts:  []happydns.PluginOptionDocumentation{ /* … */ },
        UserOpts:    []happydns.PluginOptionDocumentation{ /* … */ },
        AdminOpts:   []happydns.PluginOptionDocumentation{ /* … */ },
    }
}

Option scopes

Scope Who sets it Storage key Typical use
RunOpts User, at test time (transient) Per-invocation parameters
ServiceOpts User plugin + user + domain + service Service-level configuration
DomainOpts User plugin + user + domain Domain-level configuration
UserOpts User plugin + user Personal preferences (e.g. language)
AdminOpts Administrator plugin Instance-wide settings, shared credentials

Before RunTest is called, happyDomain merges all scoped values from least specific (admin) to most specific (run-time). More-specific values silently override less-specific ones. RunTest always receives a single flat map and does not need to know which scope each value came from.

Option fields

Each option is a PluginOptionDocumentation (an alias for Field):

Field Type Description
Id string Required. Key used in the PluginOptions map inside RunTest
Type string Input type: "string", "select"
Label string Human-readable label shown in the UI
Placeholder string Placeholder text for the input field
Default any Default value pre-filled in the form
Choices []string Options for "select" inputs
Required bool Whether the field must be filled before running
Secret bool Marks the field as sensitive (e.g. an API key)
Hide bool Hides the field from the user entirely
Textarea bool Renders a multiline text area
Description string Help text displayed below the field
AutoFill string Populate the field automatically from context (see below)

Auto-fill

When AutoFill is set, happyDomain populates the field from the test context; the user is not prompted:

Constant String value Populated with
happydns.AutoFillDomainName "domain_name" FQDN of the domain under test, e.g. "example.com."
happydns.AutoFillSubdomain "subdomain" Subdomain relative to the zone, e.g. "www" — service-scoped tests only
happydns.AutoFillServiceType "service_type" Service type identifier, e.g. "abstract.MatrixIM" — service-scoped tests only
{
    Id:       "domainName",
    Type:     "string",
    Label:    "Domain name",
    AutoFill: happydns.AutoFillDomainName,
    Required: true,
}

Running the check — RunTest()

RunTest receives the merged option map and a metadata map (reserved for future use), performs the check, and returns a PluginResult.

Always assert option values to a concrete type before use — the map holds any:

func (p *MyPlugin) RunTest(opts happydns.PluginOptions, _ map[string]string) (*happydns.PluginResult, error) {
    domain, ok := opts["domainName"].(string)
    if !ok || domain == "" {
        return nil, fmt.Errorf("domainName option is required")
    }

    // … perform the check …

    return &happydns.PluginResult{
        Status:     happydns.PluginResultStatusOK,
        StatusLine: "All good",
        Report:     myStructuredReport,
    }, nil
}

Return a non-nil error only for unexpected failures (network errors, invalid configuration). For expected check failures — the monitored service is down, DNS records are wrong — return a PluginResult with an appropriate status and a human-readable StatusLine.

Result fields

Field Type Description
Status PluginResultStatus Overall result level (see below)
StatusLine string Short summary displayed in the UI
Report any Any JSON-serialisable value stored as structured diagnostic data

Status levels (worst → best)

Constant Meaning
PluginResultStatusKO Check failed
PluginResultStatusWarn Check passed with warnings
PluginResultStatusInfo Informational, no action required
PluginResultStatusOK Check fully passed

Building

go build -buildmode=plugin -o happydomain-plugin-test-myplugin.so \
    git.happydns.org/happyDomain/plugins/myplugin

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)

The prefix happydomain-plugin-test- is a convention; happyDomain loads every file in the plugin directories regardless of its name.


Deployment

1. Copy the .so file

cp happydomain-plugin-test-myplugin.so /usr/lib/happydomain/plugins/

2. Point happyDomain at the directory

happydomain.conf:

plugins-directories=/usr/lib/happydomain/plugins

Environment variable:

HAPPYDOMAIN_PLUGINS_DIRECTORIES=/usr/lib/happydomain/plugins

Multiple directories may be listed as a comma-separated value.

3. Check the logs

On a successful load:

Plugin My Plugin loaded (version 1.0)

On a name conflict or load error a warning is logged with the filename and reason.


Reference implementations

Two plugins are bundled in this directory:

  • matrix/ — queries the Matrix federation tester API. Demonstrates ApplyToService with LimitToServices and AdminOpts for the backend URL.
  • zonemaster/ — drives the Zonemaster JSON-RPC API, polls for completion, and maps results to severity levels. Demonstrates AutoFillDomainName, UserOpts for language selection, and multi-level status mapping.