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:
parent
2d9390589e
commit
b7421ed973
5 changed files with 353 additions and 3 deletions
131
docs/plugins/provider-plugin.md
Normal file
131
docs/plugins/provider-plugin.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
145
internal/app/plugins_provider_test.go
Normal file
145
internal/app/plugins_provider_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue