providers: load external provider plugins from .so files

Add a NewProviderPlugin loader alongside the existing checker plugin
loader. The factory returns a ProviderCreatorFunc / ProviderInfos pair,
which is registered through internal/provider.RegisterProvider so plugin
providers appear in the registry like the built-in ones.
This commit is contained in:
nemunaire 2026-04-08 03:05:59 +07:00
commit b7421ed973
5 changed files with 353 additions and 3 deletions

View file

@ -0,0 +1,131 @@
# Building a happyDomain Provider Plugin
This page documents how to ship a DNS **provider** as an in-process Go plugin
that happyDomain loads at startup. It mirrors the layout of
[`checker-dummy`](https://git.happydns.org/checker-dummy); read that first if
you've never built a happyDomain plugin before.
For checker and service plugins see [checker-plugin.md](checker-plugin.md)
and [service-plugin.md](service-plugin.md).
> ⚠️ **Security note.** A `.so` plugin is loaded into the happyDomain process
> and runs with the same privileges. happyDomain refuses to load plugins from a
> directory that is group- or world-writable; keep your plugin directory owned
> and writable only by the happyDomain user.
---
## What a provider plugin must export
happyDomain's loader looks for a single exported symbol named
`NewProviderPlugin` with this exact signature:
```go
func NewProviderPlugin() (
happydns.ProviderCreatorFunc,
happydns.ProviderInfos,
error,
)
```
- `ProviderCreatorFunc` is `func() happydns.ProviderBody`. Each call must
return a fresh, zero-value instance of your provider struct so happyDomain
can decode user-supplied configuration into it.
- `ProviderInfos` carries the human-readable name, description, capabilities
and help link displayed in the UI.
- Return a non-nil `error` if your plugin cannot initialise (missing
environment variable, broken cgo dependency, …); the host will log it and
skip the file rather than aborting startup.
### Registration name and collisions
Plugin-registered providers are stored under their **fully qualified Go type
name** (`packagename.TypeName`), not the short type name used by built-in
providers. This is deliberate: two plugins shipping a `Provider` struct in
different packages would otherwise silently overwrite each other in the
global registry.
If your plugin tries to register a name that already exists (because it is
loaded twice, or because it shadows a built-in), the second registration is
**refused with a warning** rather than overwriting the first. The first one
wins; restart with the duplicate removed.
---
## Minimal example (`provider-dummy/plugin/plugin.go`)
```go
// Command plugin is the happyDomain plugin entrypoint for the dummy provider.
//
// Build with:
// go build -buildmode=plugin -o provider-dummy.so ./plugin
package main
import (
"git.happydns.org/happyDomain/model"
)
// Version is overridden at link time:
// go build -buildmode=plugin \
// -ldflags "-X main.Version=$(git describe --tags)" \
// -o provider-dummy.so ./plugin
var Version = "custom-build"
// DummyProvider is the provider body that happyDomain stores and edits.
// Exported fields become the user-facing configuration form.
type DummyProvider struct {
Endpoint string `json:"endpoint"`
Token string `json:"token"`
}
func (d *DummyProvider) InstantiateProvider() (happydns.ProviderActuator, error) {
// Return your real ProviderActuator implementation here.
return nil, nil
}
// NewProviderPlugin is the symbol resolved by happyDomain at startup.
func NewProviderPlugin() (happydns.ProviderCreatorFunc, happydns.ProviderInfos, error) {
creator := func() happydns.ProviderBody { return &DummyProvider{} }
infos := happydns.ProviderInfos{
Name: "Dummy provider (" + Version + ")",
Description: "Example provider plugin, replace with real DNS code.",
HelpLink: "https://example.com/docs/dummy-provider",
}
return creator, infos, nil
}
```
Build and deploy:
```bash
go build -buildmode=plugin -o provider-dummy.so ./plugin
sudo install -m 0644 -o happydomain provider-dummy.so /var/lib/happydomain/plugins/
sudo systemctl restart happydomain
```
happyDomain will log:
```
Registering new provider: main.DummyProvider
Plugin provider "Dummy provider (...)" registered as "main.DummyProvider" (.../provider-dummy.so)
```
---
## Build constraints
The same Go plugin caveats (toolchain version, dependency versions,
`CGO_ENABLED=1`, `GOOS`/`GOARCH`) apply to provider plugins. See
[checker-plugin.md](checker-plugin.md#build-constraints-and-platform-support)
for the full list.
---
## Licensing
Provider plugins import `git.happydns.org/happyDomain/model`, which is part
of happyDomain and licensed under **AGPL-3.0**. A `.so` linked against the
model package is therefore considered a derivative work of happyDomain and
must itself be AGPL-compatible. If you need a permissively-licensed
provider, run it as a separate process behind happyDomain's HTTP API
instead.

View file

@ -30,9 +30,12 @@ import (
"os"
"path/filepath"
"plugin"
"reflect"
sdk "git.happydns.org/checker-sdk-go/checker"
"git.happydns.org/happyDomain/internal/checker"
providerReg "git.happydns.org/happyDomain/internal/provider"
"git.happydns.org/happyDomain/model"
)
// pluginSymbols is the minimal subset of *plugin.Plugin used by the loaders.
@ -69,6 +72,7 @@ func safeCall(symbol string, fname string, fn func() error) (err error) {
// knows about. To support a new plugin type, add a single entry here.
var pluginLoaders = []pluginLoader{
loadCheckerPlugin,
loadProviderPlugin,
}
// loadCheckerPlugin handles the NewCheckerPlugin symbol exported by checkers
@ -109,6 +113,48 @@ func loadCheckerPlugin(p pluginSymbols, fname string) (bool, error) {
return true, nil
}
// loadProviderPlugin handles the NewProviderPlugin symbol exported by DNS
// provider plugins. The factory returns the creator/infos pair that the
// provider registry expects.
func loadProviderPlugin(p pluginSymbols, fname string) (bool, error) {
sym, err := p.Lookup("NewProviderPlugin")
if err != nil {
// Symbol not present in this .so, not an error.
return false, nil
}
factory, ok := sym.(func() (happydns.ProviderCreatorFunc, happydns.ProviderInfos, error))
if !ok {
return true, fmt.Errorf("symbol NewProviderPlugin has unexpected type %T", sym)
}
var (
creator happydns.ProviderCreatorFunc
infos happydns.ProviderInfos
)
if err := safeCall("NewProviderPlugin", fname, func() error {
var ferr error
creator, infos, ferr = factory()
return ferr
}); err != nil {
return true, err
}
if creator == nil {
return true, fmt.Errorf("NewProviderPlugin returned a nil ProviderCreatorFunc")
}
// Plugin-registered providers go through the qualified-name API so that
// two plugins exporting providers with the same struct name (in different
// packages) cannot silently overwrite each other in the global registry.
sample := creator()
baseType := reflect.Indirect(reflect.ValueOf(sample)).Type()
qualified := baseType.String()
providerReg.RegisterProviderAs(qualified, creator, infos)
log.Printf("Plugin provider %q registered as %q (%s)", infos.Name, qualified, fname)
return true, nil
}
// checkPluginDirectoryPermissions refuses to load plugins from a directory
// that any non-owner can write to. Loading a .so file is arbitrary code
// execution as the happyDomain process, so a world- or group-writable

View file

@ -0,0 +1,145 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//go:build linux || darwin || freebsd
package app
import (
"errors"
"plugin"
"strings"
"testing"
providerReg "git.happydns.org/happyDomain/internal/provider"
"git.happydns.org/happyDomain/model"
)
// dummyProviderBody is a minimal happydns.ProviderBody used by the tests
// below; we only care that loadProviderPlugin can register it without
// touching real DNS code.
type dummyProviderBody struct {
Endpoint string
}
func (d *dummyProviderBody) InstantiateProvider() (happydns.ProviderActuator, error) {
return nil, errors.New("not implemented in tests")
}
func newDummyProviderFactory() func() (happydns.ProviderCreatorFunc, happydns.ProviderInfos, error) {
return func() (happydns.ProviderCreatorFunc, happydns.ProviderInfos, error) {
creator := func() happydns.ProviderBody { return &dummyProviderBody{} }
return creator, happydns.ProviderInfos{Name: "Dummy"}, nil
}
}
func TestLoadProviderPlugin_SymbolMissing(t *testing.T) {
found, err := loadProviderPlugin(&fakeSymbols{}, "missing.so")
if found || err != nil {
t.Fatalf("expected (false, nil) when symbol is absent, got (%v, %v)", found, err)
}
}
func TestLoadProviderPlugin_WrongSymbolType(t *testing.T) {
fs := &fakeSymbols{syms: map[string]plugin.Symbol{
"NewProviderPlugin": 42, // not a function
}}
found, err := loadProviderPlugin(fs, "wrongtype.so")
if !found || err == nil {
t.Fatalf("expected (true, err) for wrong symbol type, got (%v, %v)", found, err)
}
if !strings.Contains(err.Error(), "unexpected type") {
t.Errorf("expected error to mention unexpected type, got %v", err)
}
}
func TestLoadProviderPlugin_FactoryError(t *testing.T) {
factory := func() (happydns.ProviderCreatorFunc, happydns.ProviderInfos, error) {
return nil, happydns.ProviderInfos{}, errors.New("boom")
}
fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewProviderPlugin": factory}}
found, err := loadProviderPlugin(fs, "factoryerr.so")
if !found || err == nil || !strings.Contains(err.Error(), "boom") {
t.Fatalf("expected factory error to propagate, got (%v, %v)", found, err)
}
}
func TestLoadProviderPlugin_NilCreator(t *testing.T) {
factory := func() (happydns.ProviderCreatorFunc, happydns.ProviderInfos, error) {
return nil, happydns.ProviderInfos{Name: "Dummy"}, nil
}
fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewProviderPlugin": factory}}
found, err := loadProviderPlugin(fs, "nilcreator.so")
if !found || err == nil || !strings.Contains(err.Error(), "nil ProviderCreatorFunc") {
t.Fatalf("expected nil creator to be rejected, got (%v, %v)", found, err)
}
}
func TestLoadProviderPlugin_FactoryPanics(t *testing.T) {
factory := func() (happydns.ProviderCreatorFunc, happydns.ProviderInfos, error) {
panic("kaboom")
}
fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewProviderPlugin": factory}}
found, err := loadProviderPlugin(fs, "panic.so")
if !found || err == nil {
t.Fatalf("expected panic to be converted to error, got (%v, %v)", found, err)
}
if !strings.Contains(err.Error(), "panicked") || !strings.Contains(err.Error(), "kaboom") {
t.Errorf("expected wrapped panic error, got %v", err)
}
}
func TestLoadProviderPlugin_SuccessAndDuplicate(t *testing.T) {
factory := newDummyProviderFactory()
fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewProviderPlugin": factory}}
// First registration should succeed and use a fully-qualified name
// (package.Type) so it cannot collide with a built-in or another plugin
// shipping a "dummyProviderBody" struct in a different package.
found, err := loadProviderPlugin(fs, "first.so")
if !found || err != nil {
t.Fatalf("expected first load to succeed, got (%v, %v)", found, err)
}
const expectedKey = "app.dummyProviderBody"
if _, ok := providerReg.GetProviders()[expectedKey]; !ok {
t.Fatalf("expected provider to be registered as %q, registry has: %v",
expectedKey, keysOf(providerReg.GetProviders()))
}
// Second registration of the same qualified name must be a no-op (just
// a warning); the existing entry should still be there afterwards.
found, err = loadProviderPlugin(fs, "second.so")
if !found || err != nil {
t.Fatalf("expected second load to be silently ignored, got (%v, %v)", found, err)
}
}
func keysOf[V any](m map[string]V) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
return out
}

View file

@ -68,7 +68,7 @@ func declareFlags(o *happydns.Options) {
flag.StringVar(&o.CaptchaProvider, "captcha-provider", o.CaptchaProvider, "Captcha provider to use for bot protection (altcha, hcaptcha, recaptchav2, turnstile, or empty to disable)")
flag.IntVar(&o.CaptchaLoginThreshold, "captcha-login-threshold", 3, "Number of failed login attempts before captcha is required (0 = always require when provider configured)")
flag.Var(&stringSlice{&o.PluginsDirectories}, "plugins-directory", "Path to a directory containing checker plugins (.so files); may be repeated")
flag.Var(&stringSlice{&o.PluginsDirectories}, "plugins-directory", "Path to a directory containing checker, provider or service plugins (.so files); may be repeated")
// Others flags are declared in some other files likes sources, storages, ... when they need specials configurations
}

View file

@ -31,13 +31,41 @@ import (
)
// providerRegistry stores all existing Provider in happyDNS.
//
// The map is intentionally unguarded: all writes (RegisterProvider /
// RegisterProviderAs) happen from App.initPlugins() at startup, before any
// usecase or HTTP handler can read it (see internal/app/app.go). From that
// point on the registry is read-only for the rest of the process lifetime,
// so concurrent reads are safe without locking. Any future code path that
// needs to mutate it after startup must introduce its own synchronisation.
var providerRegistry = map[string]happydns.ProviderCreator{}
// RegisterProvider registers a provider definition globally.
// RegisterProvider registers a provider definition globally under the
// unqualified Go type name of the value returned by creator(). This is the
// historical entry point used by built-in providers; the persisted
// happydns.Provider.Type field stores this same unqualified name, so
// changing the keying scheme here would break existing data.
func RegisterProvider(creator happydns.ProviderCreatorFunc, infos happydns.ProviderInfos) {
provider := creator()
baseType := reflect.Indirect(reflect.ValueOf(provider)).Type()
name := baseType.Name()
RegisterProviderAs(baseType.Name(), creator, infos)
}
// RegisterProviderAs registers a provider definition globally under the
// caller-supplied name. It exists so that plugin loaders can pick a
// fully-qualified name (typically "package.Type") and avoid silently
// overwriting a built-in or another plugin that happens to expose a
// provider struct with the same short name.
//
// A second registration under an existing name is refused with a loud
// warning rather than overwriting the previous entry: in production this
// almost always indicates a deployment mistake (two plugins shipping the
// same provider, or a plugin shadowing a built-in).
func RegisterProviderAs(name string, creator happydns.ProviderCreatorFunc, infos happydns.ProviderInfos) {
if _, exists := providerRegistry[name]; exists {
log.Printf("Warning: provider %q is already registered; ignoring duplicate registration", name)
return
}
log.Println("Registering new provider:", name)
providerRegistry[name] = happydns.ProviderCreator{