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"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"plugin"
|
"plugin"
|
||||||
|
"reflect"
|
||||||
|
|
||||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||||
"git.happydns.org/happyDomain/internal/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.
|
// 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.
|
// knows about. To support a new plugin type, add a single entry here.
|
||||||
var pluginLoaders = []pluginLoader{
|
var pluginLoaders = []pluginLoader{
|
||||||
loadCheckerPlugin,
|
loadCheckerPlugin,
|
||||||
|
loadProviderPlugin,
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadCheckerPlugin handles the NewCheckerPlugin symbol exported by checkers
|
// loadCheckerPlugin handles the NewCheckerPlugin symbol exported by checkers
|
||||||
|
|
@ -109,6 +113,48 @@ func loadCheckerPlugin(p pluginSymbols, fname string) (bool, error) {
|
||||||
return true, nil
|
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
|
// checkPluginDirectoryPermissions refuses to load plugins from a directory
|
||||||
// that any non-owner can write to. Loading a .so file is arbitrary code
|
// 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
|
// 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.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.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
|
// 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.
|
// 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{}
|
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) {
|
func RegisterProvider(creator happydns.ProviderCreatorFunc, infos happydns.ProviderInfos) {
|
||||||
provider := creator()
|
provider := creator()
|
||||||
baseType := reflect.Indirect(reflect.ValueOf(provider)).Type()
|
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)
|
log.Println("Registering new provider:", name)
|
||||||
|
|
||||||
providerRegistry[name] = happydns.ProviderCreator{
|
providerRegistry[name] = happydns.ProviderCreator{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue