diff --git a/docs/plugins/provider-plugin.md b/docs/plugins/provider-plugin.md new file mode 100644 index 00000000..0a25e50a --- /dev/null +++ b/docs/plugins/provider-plugin.md @@ -0,0 +1,140 @@ +# 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 (Go plugins gotchas) + +Go's `plugin` package is unforgiving: + +- The plugin **must be built with the same Go version** as happyDomain + itself, including the same toolchain patch level. +- It **must use the same versions of every shared dependency**, in particular + `git.happydns.org/happyDomain/model`. Vendor the exact module versions + happyDomain ships, or pin them in your `go.mod` with `replace` directives. +- `CGO_ENABLED=1` is required. +- `GOOS`/`GOARCH` must match the host binary. + +If any of these don't match, `plugin.Open` will fail with a (sometimes +cryptic) error like *"plugin was built with a different version of package +…"*. The host will log it and skip the file. + +--- + +## 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. diff --git a/internal/app/plugins.go b/internal/app/plugins.go index e3e79e4a..e8d95223 100644 --- a/internal/app/plugins.go +++ b/internal/app/plugins.go @@ -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 diff --git a/internal/app/plugins_provider_test.go b/internal/app/plugins_provider_test.go new file mode 100644 index 00000000..80ae374d --- /dev/null +++ b/internal/app/plugins_provider_test.go @@ -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 . +// +// 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 . + +//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 +} diff --git a/internal/config/cli.go b/internal/config/cli.go index fecf0c7d..11689f1f 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -62,7 +62,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 } diff --git a/internal/provider/registry.go b/internal/provider/registry.go index 74e839aa..47627915 100644 --- a/internal/provider/registry.go +++ b/internal/provider/registry.go @@ -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{