Compare commits

...

5 commits

Author SHA1 Message Date
266ebeea1f services: validate aliases atomically on registration
Some checks are pending
continuous-integration/drone/push Build is running
Check primary name and all aliases before mutating the registry so a
collision cannot leave a service half-registered. Also reject self-aliases
and duplicate aliases within the same registration.
2026-04-10 16:53:52 +07:00
ef55622022 services: load external service plugins from .so files
Add a NewServicePlugin loader that registers the returned creator,
analyzer, infos, weight and aliases through internal/service.RegisterService.
The frontend root layout now refreshes the service specs on load so
plugin-provided services appear in the UI alongside the built-in ones.
2026-04-10 16:53:52 +07:00
968dec0f1b 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.
2026-04-10 16:53:52 +07:00
ee127aa3a9 checkers: load external checker plugins from .so files
Scan -plugins-directory paths at startup, open each .so via plugin.Open,
look up the NewCheckerPlugin symbol from checker-sdk-go, and register the
returned definition and observation provider in the global checker
registries. A pluginLoader indirection keeps the door open for future
plugin kinds.
2026-04-10 16:53:52 +07:00
33702d3ee4 checkers: introduce checker subsystem foundation
Add the checker-sdk-go dependency and build the core checker
infrastructure:
- Domain model types: CheckTarget, CheckPlan, Execution,
  CheckEvaluation, CheckerDefinition, CheckerOptions,
  ObservationSnapshot, and associated interfaces
- Observation collection engine with concurrent per-key gathering
- Checker and observation provider registries (wrapping checker-sdk-go)
- WorstStatusAggregator for combining rule evaluation results
- AutoFill constants for context-driven option resolution
- Config option and CLI flag for max observation concurrency
- Revised error types for the checker domain
2026-04-10 16:53:52 +07:00
26 changed files with 2318 additions and 18 deletions

View file

@ -0,0 +1,146 @@
# Building a happyDomain Checker Plugin
This page documents how to ship a **checker** as an in-process Go plugin
that happyDomain loads at startup. Checker plugins extend happyDomain with
new automated diagnostics on zones, domains, services or users.
If you've never built a happyDomain plugin before, read
[`checker-dummy`](https://git.happydns.org/checker-dummy) first — it is the
reference implementation that this page mirrors.
> ⚠️ **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 checker plugin must export
happyDomain's loader looks for a single exported symbol named
`NewCheckerPlugin` with this exact signature:
```go
func NewCheckerPlugin() (
*checker.CheckerDefinition,
checker.ObservationProvider,
error,
)
```
where `checker` is `git.happydns.org/checker-sdk-go/checker` (see
[Licensing](#licensing) below for why the SDK lives in a separate module).
- `*CheckerDefinition` describes the checker: ID, name, version, options
documentation, rules, optional aggregator, scheduling interval, and
whether the checker exposes HTML reports or metrics. The `ID` field is
the persistent key — pick something stable and namespaced
(`com.example.dnssec-freshness`, not `dnssec`).
- `ObservationProvider` is the data-collection half of the checker. It
exposes a `Key()` (the observation key the rules will look up) and a
`Collect(ctx, opts)` method that returns the raw observation payload.
happyDomain serialises the result to JSON and caches it per
`ObservationContext`.
- 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.
A checker plugin that panics inside its factory is also caught: the panic is
converted to an error and only that one `.so` is skipped.
### Registration and collisions
The loader calls `RegisterExternalizableChecker` and
`RegisterObservationProvider` on the SDK registry. Pick globally unique
identifiers — if your checker ID or observation key collides with a built-in
or another plugin, the duplicate is ignored.
The same `.so` may export both `NewCheckerPlugin` and (e.g.)
`NewProviderPlugin`. The loader runs every known plugin loader against
every file, so a single binary can ship a checker, a provider and a service
at once.
---
## Minimal example
```go
// Command plugin is the happyDomain plugin entrypoint for the dummy checker.
//
// Build with:
// go build -buildmode=plugin -o checker-dummy.so ./plugin
package main
import (
"context"
sdk "git.happydns.org/checker-sdk-go/checker"
)
type dummyProvider struct{}
func (dummyProvider) Key() sdk.ObservationKey { return "dummy.observation" }
func (dummyProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
return map[string]string{"hello": "world"}, nil
}
// NewCheckerPlugin is the symbol resolved by happyDomain at startup.
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
def := &sdk.CheckerDefinition{
ID: "com.example.dummy",
Name: "Dummy checker",
Version: "0.1.0",
ObservationKeys: []sdk.ObservationKey{"dummy.observation"},
// Add Rules / Aggregator / Options here in a real plugin.
}
return def, dummyProvider{}, nil
}
```
Build and deploy:
```bash
go build -buildmode=plugin -o checker-dummy.so ./plugin
sudo install -m 0644 -o happydomain checker-dummy.so /var/lib/happydomain/plugins/
sudo systemctl restart happydomain
```
happyDomain will log:
```
Plugin com.example.dummy (.../checker-dummy.so) loaded
```
---
## Build constraints and platform support
The same Go plugin caveats apply as for provider plugins — matching
toolchain version, dependency versions, `CGO_ENABLED=1`, matching
`GOOS`/`GOARCH`. See
[provider-plugin.md](provider-plugin.md#build-constraints-go-plugins-gotchas)
for details.
Go's `plugin` package only works on **linux**, **darwin** and **freebsd**.
On other platforms (Windows, plan9, …) happyDomain is built without plugin
support and `--plugins-directory` is silently ignored apart from a warning
log line at startup.
---
## Licensing
Unlike provider and service plugins, checker plugins import only
`git.happydns.org/checker-sdk-go/checker`, which is licensed under
**Apache-2.0**. This is intentional: the checker SDK is a small, stable
public API for third-party checkers, deliberately split out of the
AGPL-3.0 happyDomain core so that permissively-licensed checker plugins are
possible.
You may therefore distribute your checker `.so` under any license compatible
with Apache-2.0. Note that this only covers checker plugins; provider and
service plugins still link against AGPL code and remain subject to the
AGPL-3.0 reciprocity rules described in
[provider-plugin.md](provider-plugin.md#licensing) and
[service-plugin.md](service-plugin.md).

View file

@ -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.

View file

@ -0,0 +1,136 @@
# Building a happyDomain Service Plugin
This page documents how to ship a happyDomain **service** (a high-level
abstraction over a set of DNS records — e.g. "Mailbox", "Web server",
"Matrix homeserver") as an in-process Go plugin. Read the
[provider plugin guide](provider-plugin.md) first if you've never built a
happyDomain plugin.
> ⚠️ **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.
---
## What a service plugin must export
happyDomain's loader looks for a single exported symbol named
`NewServicePlugin` with this exact signature:
```go
func NewServicePlugin() (
happydns.ServiceCreator,
svcs.ServiceAnalyzer,
happydns.ServiceInfos,
uint32, // weight (analyzer priority — lower runs first)
[]string, // optional aliases
error,
)
```
- `ServiceCreator` is `func() happydns.ServiceBody`. Each call must return a
fresh, zero-value instance of your service struct.
- `ServiceAnalyzer` is the optional analyzer that recognises this service in
an existing zone (`nil` is allowed for "manual-only" services).
- `aliases` lets a single struct be reachable under several legacy names; the
loader will refuse to register an alias that collides with an existing one.
### Sub-services and the `pathToSvcsModule` filter
happyDomain's built-in service registry walks each registered struct and
records every nested struct type as a *sub-service* so the storage layer can
(de)serialise polymorphic payloads later. To avoid registering random types
pulled in from third-party libraries, that walk is restricted to types whose
package path starts with `git.happydns.org/happyDomain/services`.
Plugin services live in a completely different module path. The plugin loader
calls a dedicated walker (`svcs.RegisterPluginSubServices`) on every plugin
service so that nested types declared by the plugin **are** registered. You
get the same nested-struct support as a built-in service — there is nothing
to do on your side. The only constraint is that nested types must be **named
struct types** (anonymous structs cannot be looked up by name later).
### Collisions
If your plugin tries to register a service or alias whose name already
exists, the registration is **refused with a warning** rather than
overwriting the previous entry. The first one wins.
---
## Minimal example (`service-dummy/plugin/plugin.go`)
```go
// Build with:
// go build -buildmode=plugin -o service-dummy.so ./plugin
package main
import (
svcs "git.happydns.org/happyDomain/internal/service"
"git.happydns.org/happyDomain/model"
)
type DummyDetail struct {
Note string `json:"note"`
}
type DummyService struct {
Hostname string `json:"hostname"`
Detail DummyDetail `json:"detail"`
}
func (d *DummyService) GetNbResources() int { return 1 }
func (d *DummyService) GenComment() string { return d.Hostname }
func (d *DummyService) GetRecords(domain string, ttl uint32, origin string) ([]happydns.Record, error) {
return nil, nil
}
func NewServicePlugin() (
happydns.ServiceCreator,
svcs.ServiceAnalyzer,
happydns.ServiceInfos,
uint32,
[]string,
error,
) {
creator := func() happydns.ServiceBody { return &DummyService{} }
infos := happydns.ServiceInfos{
Name: "Dummy service",
Description: "Example service plugin — replace with real logic.",
}
return creator, nil, infos, 100, nil, nil
}
```
Build and deploy:
```bash
go build -buildmode=plugin -o service-dummy.so ./plugin
sudo install -m 0644 -o happydomain service-dummy.so /var/lib/happydomain/plugins/
sudo systemctl restart happydomain
```
happyDomain will log:
```
Registering new service: main.DummyService
Registering new plugin subservice: main.DummyDetail
Plugin service "Dummy service" (.../service-dummy.so) loaded
```
---
## Build constraints and licensing
The same Go plugin caveats apply as for provider plugins (matching toolchain
version, dependency versions, CGO, GOOS/GOARCH); see
[provider-plugin.md](provider-plugin.md#build-constraints-go-plugins-gotchas).
For checker plugins see [checker-plugin.md](checker-plugin.md), which uses a
separate (Apache-2.0) SDK module and is not subject to the AGPL constraints
described below.
Service plugins import both `git.happydns.org/happyDomain/model` **and**
`git.happydns.org/happyDomain/internal/service`, both of which are AGPL-3.0.
A `.so` linked against them is therefore considered a derivative work of
happyDomain and must itself be AGPL-compatible.

1
go.mod
View file

@ -5,6 +5,7 @@ go 1.25.0
toolchain go1.26.1
require (
git.happydns.org/checker-sdk-go v0.2.0
github.com/StackExchange/dnscontrol/v4 v4.34.0
github.com/altcha-org/altcha-lib-go v1.0.0
github.com/coreos/go-oidc/v3 v3.17.0

6
go.sum
View file

@ -8,6 +8,8 @@ cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCB
codeberg.org/miekg/dns v0.6.67 h1:vsVNsqAOE9uYscJHIHNtoCxiEySQn/B9BEvAUYI5Zmc=
codeberg.org/miekg/dns v0.6.67/go.mod h1:58Y3ZTg6Z5ZEm/ZAAwHehbZfrD4u5mE4RByHoPEMyKk=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
git.happydns.org/checker-sdk-go v0.2.0 h1:Hg0GTcoEUgrkiUevgtgJ0kK04CnDM2f7VtFQiz4MmFc=
git.happydns.org/checker-sdk-go v0.2.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
@ -386,10 +388,6 @@ github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.12.0 h1:mC1zeiNamwKBecjHarAr26c/+d8V5w/u4J0I/yASbJo=
github.com/lib/pq v1.12.0/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/lib/pq v1.12.2 h1:ajJNv84limnK3aPbDIhLtcjrUbqAw/5XNdkuI6KNe/Q=
github.com/lib/pq v1.12.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/libdns/ionos v1.2.0 h1:FQ2xQTBfsjc7aMArRBBCs9l48Squt76GHXbxDsqOKgw=

View file

@ -93,6 +93,9 @@ func NewApp(cfg *happydns.Options) *App {
app.initStorageEngine()
app.initNewsletter()
app.initInsights()
if err := app.initPlugins(); err != nil {
log.Fatalf("Plugin initialization error: %s", err)
}
app.initUsecases()
app.initCaptcha()
app.setupRouter()
@ -108,6 +111,9 @@ func NewAppWithStorage(cfg *happydns.Options, store storage.Storage) *App {
app.initMailer()
app.initNewsletter()
if err := app.initPlugins(); err != nil {
log.Fatalf("Plugin initialization error: %s", err)
}
app.initUsecases()
app.initCaptcha()
app.setupRouter()

299
internal/app/plugins.go Normal file
View file

@ -0,0 +1,299 @@
// 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"
"fmt"
"log"
"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"
svcs "git.happydns.org/happyDomain/internal/service"
"git.happydns.org/happyDomain/model"
)
// pluginSymbols is the minimal subset of *plugin.Plugin used by the loaders.
// It exists so that loaders can be unit-tested with a fake instead of
// requiring a real .so file built via `go build -buildmode=plugin`.
type pluginSymbols interface {
Lookup(symName string) (plugin.Symbol, error)
}
// pluginLoader attempts to find and register one specific kind of plugin
// symbol from an already-opened .so file.
//
// It returns (true, nil) when the symbol was found and registration
// succeeded, (true, err) when the symbol was found but something went wrong,
// and (false, nil) when the symbol simply isn't present in that file (which
// is not considered an error — a single .so may implement only a subset of
// the known plugin types).
type pluginLoader func(p pluginSymbols, fname string) (found bool, err error)
// safeCall invokes fn while recovering from any panic raised by plugin code.
// A panicking factory must not take the whole server down at startup; the
// recovered value is converted to an error so the caller can log/skip the
// offending plugin like any other failure.
func safeCall(symbol string, fname string, fn func() error) (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("plugin %q panicked in %s: %v", fname, symbol, r)
}
}()
return fn()
}
// pluginLoaders is the authoritative list of plugin types that happyDomain
// knows about. To support a new plugin type, add a single entry here.
var pluginLoaders = []pluginLoader{
loadCheckerPlugin,
loadProviderPlugin,
loadServicePlugin,
}
// loadCheckerPlugin handles the NewCheckerPlugin symbol exported by checkers
// built against checker-sdk-go (see ../../checker-dummy/README.md).
func loadCheckerPlugin(p pluginSymbols, fname string) (bool, error) {
sym, err := p.Lookup("NewCheckerPlugin")
if err != nil {
// Symbol not present in this .so — not an error.
return false, nil
}
factory, ok := sym.(func() (*sdk.CheckerDefinition, sdk.ObservationProvider, error))
if !ok {
return true, fmt.Errorf("symbol NewCheckerPlugin has unexpected type %T", sym)
}
var (
def *sdk.CheckerDefinition
provider sdk.ObservationProvider
)
if err := safeCall("NewCheckerPlugin", fname, func() error {
var ferr error
def, provider, ferr = factory()
return ferr
}); err != nil {
return true, err
}
if def == nil {
return true, fmt.Errorf("NewCheckerPlugin returned a nil CheckerDefinition")
}
if provider == nil {
return true, fmt.Errorf("NewCheckerPlugin returned a nil ObservationProvider")
}
checker.RegisterObservationProvider(provider)
checker.RegisterExternalizableChecker(def)
log.Printf("Plugin %s (%s) loaded", def.ID, fname)
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
}
// loadServicePlugin handles the NewServicePlugin symbol exported by service
// plugins. The factory returns the creator/analyzer/infos triple along with
// the analyzer weight and any aliases the service should be reachable under.
func loadServicePlugin(p pluginSymbols, fname string) (bool, error) {
sym, err := p.Lookup("NewServicePlugin")
if err != nil {
// Symbol not present in this .so — not an error.
return false, nil
}
factory, ok := sym.(func() (happydns.ServiceCreator, svcs.ServiceAnalyzer, happydns.ServiceInfos, uint32, []string, error))
if !ok {
return true, fmt.Errorf("symbol NewServicePlugin has unexpected type %T", sym)
}
var (
creator happydns.ServiceCreator
analyzer svcs.ServiceAnalyzer
infos happydns.ServiceInfos
weight uint32
aliases []string
)
if err := safeCall("NewServicePlugin", fname, func() error {
var ferr error
creator, analyzer, infos, weight, aliases, ferr = factory()
return ferr
}); err != nil {
return true, err
}
if creator == nil {
return true, fmt.Errorf("NewServicePlugin returned a nil ServiceCreator")
}
svcs.RegisterService(creator, analyzer, infos, weight, aliases...)
// The built-in sub-service walker only descends into types whose package
// path lives under git.happydns.org/happyDomain/services. Plugin services
// live elsewhere, so we must explicitly walk their type tree to register
// any nested struct types as sub-services — otherwise (de)serialisation
// of plugin payloads breaks for anything more than a flat struct.
baseType := reflect.Indirect(reflect.ValueOf(creator())).Type()
svcs.RegisterPluginSubServices(baseType)
log.Printf("Plugin service %q (%s) loaded", infos.Name, 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
// plugin directory is treated as a fatal misconfiguration: any local user
// (or any process sharing the group) able to drop a file there could take
// over the server. Operators who genuinely need shared deployment should
// stage plugins elsewhere and rsync them into a directory owned and
// writable only by the happyDomain user.
func checkPluginDirectoryPermissions(directory string) error {
info, err := os.Stat(directory)
if err != nil {
return fmt.Errorf("unable to stat plugins directory %q: %s", directory, err)
}
if !info.IsDir() {
return fmt.Errorf("plugins path %q is not a directory", directory)
}
mode := info.Mode().Perm()
if mode&0o022 != 0 {
return fmt.Errorf("plugins directory %q is group- or world-writable (mode %#o); refusing to load plugins from it", directory, mode)
}
return nil
}
// initPlugins scans each directory listed in cfg.PluginsDirectories and loads
// every .so file found as a Go plugin. A directory that cannot be read is a
// fatal configuration error; individual plugin failures are logged and
// skipped so that one bad .so does not prevent the others from loading.
func (a *App) initPlugins() error {
for _, directory := range a.cfg.PluginsDirectories {
if err := checkPluginDirectoryPermissions(directory); err != nil {
return err
}
files, err := os.ReadDir(directory)
if err != nil {
return fmt.Errorf("unable to read plugins directory %q: %s", directory, err)
}
for _, file := range files {
if file.IsDir() {
continue
}
// Only attempt to load shared-object files.
if filepath.Ext(file.Name()) != ".so" {
continue
}
fname := filepath.Join(directory, file.Name())
if err := loadPlugin(fname); err != nil {
log.Printf("Unable to load plugin %q: %s", fname, err)
}
}
}
return nil
}
// loadPlugin opens the .so file at fname and runs every registered
// pluginLoader against it. A loader that does not find its symbol is silently
// skipped. If no loader recognises any symbol in the file a warning is
// logged, because the file might be a valid plugin for a future version of
// happyDomain. Loader errors for one plugin kind do not prevent the other
// kinds in the same .so from being attempted: a single .so is allowed to
// expose more than one plugin type, and a failure to register (e.g.) the
// service half should not silently drop the checker half. All loader errors
// encountered are joined and returned together.
func loadPlugin(fname string) error {
p, err := plugin.Open(fname)
if err != nil {
return err
}
var (
anyFound bool
errs []error
)
for _, loader := range pluginLoaders {
found, err := loader(p, fname)
if found {
anyFound = true
}
if err != nil {
errs = append(errs, err)
}
}
if !anyFound && len(errs) == 0 {
log.Printf("Warning: plugin %q exports no recognised symbols", fname)
}
return errors.Join(errs...)
}

View file

@ -0,0 +1,143 @@
// 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 (
"context"
"errors"
"plugin"
"strings"
"testing"
sdk "git.happydns.org/checker-sdk-go/checker"
"git.happydns.org/happyDomain/internal/checker"
"git.happydns.org/happyDomain/model"
)
// dummyCheckerProvider is a minimal ObservationProvider used by the tests
// below. It is intentionally trivial — the loader tests only care that
// registration succeeds, not what the provider actually collects.
type dummyCheckerProvider struct {
key happydns.ObservationKey
}
func (d *dummyCheckerProvider) Key() happydns.ObservationKey { return d.key }
func (d *dummyCheckerProvider) Collect(ctx context.Context, _ happydns.CheckerOptions) (any, error) {
return nil, nil
}
func newDummyCheckerFactory(id string) func() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
return func() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
def := &sdk.CheckerDefinition{
ID: id,
Name: "Dummy checker",
}
return def, &dummyCheckerProvider{key: happydns.ObservationKey("dummy-" + id)}, nil
}
}
func TestLoadCheckerPlugin_SymbolMissing(t *testing.T) {
found, err := loadCheckerPlugin(&fakeSymbols{}, "missing.so")
if found || err != nil {
t.Fatalf("expected (false, nil) when symbol is absent, got (%v, %v)", found, err)
}
}
func TestLoadCheckerPlugin_WrongSymbolType(t *testing.T) {
fs := &fakeSymbols{syms: map[string]plugin.Symbol{
"NewCheckerPlugin": 42, // not a function
}}
found, err := loadCheckerPlugin(fs, "wrongtype.so")
if !found || err == nil || !strings.Contains(err.Error(), "unexpected type") {
t.Fatalf("expected wrong-type error, got (%v, %v)", found, err)
}
}
func TestLoadCheckerPlugin_FactoryError(t *testing.T) {
factory := func() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
return nil, nil, errors.New("boom")
}
fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewCheckerPlugin": factory}}
found, err := loadCheckerPlugin(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 TestLoadCheckerPlugin_NilDefinition(t *testing.T) {
factory := func() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
return nil, &dummyCheckerProvider{key: "k"}, nil
}
fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewCheckerPlugin": factory}}
found, err := loadCheckerPlugin(fs, "nildef.so")
if !found || err == nil || !strings.Contains(err.Error(), "nil CheckerDefinition") {
t.Fatalf("expected nil-definition error, got (%v, %v)", found, err)
}
}
func TestLoadCheckerPlugin_NilProvider(t *testing.T) {
factory := func() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
return &sdk.CheckerDefinition{ID: "x"}, nil, nil
}
fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewCheckerPlugin": factory}}
found, err := loadCheckerPlugin(fs, "nilprov.so")
if !found || err == nil || !strings.Contains(err.Error(), "nil ObservationProvider") {
t.Fatalf("expected nil-provider error, got (%v, %v)", found, err)
}
}
func TestLoadCheckerPlugin_FactoryPanics(t *testing.T) {
factory := func() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
panic("kaboom")
}
fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewCheckerPlugin": factory}}
found, err := loadCheckerPlugin(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 TestLoadCheckerPlugin_Success(t *testing.T) {
factory := newDummyCheckerFactory("dummy-success")
fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewCheckerPlugin": factory}}
found, err := loadCheckerPlugin(fs, "first.so")
if !found || err != nil {
t.Fatalf("expected success, got (%v, %v)", found, err)
}
if got := checker.FindChecker("dummy-success"); got == nil {
t.Errorf("expected checker %q to be registered", "dummy-success")
}
if got := checker.GetObservationProvider(happydns.ObservationKey("dummy-dummy-success")); got == nil {
t.Errorf("expected observation provider %q to be registered", "dummy-dummy-success")
}
}

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

@ -0,0 +1,143 @@
// 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"
svcs "git.happydns.org/happyDomain/internal/service"
"git.happydns.org/happyDomain/model"
)
// dummyNested is referenced as a struct field by dummyServiceBody to verify
// that loadServicePlugin walks the type tree and registers nested types as
// sub-services — something the built-in walker refuses to do for types that
// live outside the happydomain/services module path.
type dummyNested struct {
Value string
}
type dummyServiceBody struct {
Hostname string
Detail dummyNested
}
func (d *dummyServiceBody) GetNbResources() int { return 1 }
func (d *dummyServiceBody) GenComment() string { return "dummy" }
func (d *dummyServiceBody) GetRecords(domain string, ttl uint32, origin string) ([]happydns.Record, error) {
return nil, nil
}
func newDummyServiceFactory() func() (happydns.ServiceCreator, svcs.ServiceAnalyzer, happydns.ServiceInfos, uint32, []string, error) {
return func() (happydns.ServiceCreator, svcs.ServiceAnalyzer, happydns.ServiceInfos, uint32, []string, error) {
creator := func() happydns.ServiceBody { return &dummyServiceBody{} }
return creator, nil, happydns.ServiceInfos{Name: "Dummy"}, 100, nil, nil
}
}
func TestLoadServicePlugin_SymbolMissing(t *testing.T) {
found, err := loadServicePlugin(&fakeSymbols{}, "missing.so")
if found || err != nil {
t.Fatalf("expected (false, nil), got (%v, %v)", found, err)
}
}
func TestLoadServicePlugin_WrongSymbolType(t *testing.T) {
fs := &fakeSymbols{syms: map[string]plugin.Symbol{
"NewServicePlugin": "not a function",
}}
found, err := loadServicePlugin(fs, "wrongtype.so")
if !found || err == nil || !strings.Contains(err.Error(), "unexpected type") {
t.Fatalf("expected wrong-type error, got (%v, %v)", found, err)
}
}
func TestLoadServicePlugin_FactoryError(t *testing.T) {
factory := func() (happydns.ServiceCreator, svcs.ServiceAnalyzer, happydns.ServiceInfos, uint32, []string, error) {
return nil, nil, happydns.ServiceInfos{}, 0, nil, errors.New("boom")
}
fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewServicePlugin": factory}}
found, err := loadServicePlugin(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 TestLoadServicePlugin_NilCreator(t *testing.T) {
factory := func() (happydns.ServiceCreator, svcs.ServiceAnalyzer, happydns.ServiceInfos, uint32, []string, error) {
return nil, nil, happydns.ServiceInfos{Name: "Dummy"}, 0, nil, nil
}
fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewServicePlugin": factory}}
found, err := loadServicePlugin(fs, "nilcreator.so")
if !found || err == nil || !strings.Contains(err.Error(), "nil ServiceCreator") {
t.Fatalf("expected nil-creator error, got (%v, %v)", found, err)
}
}
func TestLoadServicePlugin_FactoryPanics(t *testing.T) {
factory := func() (happydns.ServiceCreator, svcs.ServiceAnalyzer, happydns.ServiceInfos, uint32, []string, error) {
panic("kaboom")
}
fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewServicePlugin": factory}}
found, err := loadServicePlugin(fs, "panic.so")
if !found || err == nil || !strings.Contains(err.Error(), "panicked") {
t.Fatalf("expected wrapped panic error, got (%v, %v)", found, err)
}
}
func TestLoadServicePlugin_SuccessRegistersSubServices(t *testing.T) {
factory := newDummyServiceFactory()
fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewServicePlugin": factory}}
found, err := loadServicePlugin(fs, "first.so")
if !found || err != nil {
t.Fatalf("expected success, got (%v, %v)", found, err)
}
// The service itself must be reachable through the registry.
const svcKey = "app.dummyServiceBody"
if _, err := svcs.FindService(svcKey); err != nil {
t.Fatalf("expected service %q to be registered: %v", svcKey, err)
}
// And so must the nested struct: this is the regression-prevention test
// for the built-in walker's pathToSvcsModule prefix check, which would
// otherwise refuse to register types from outside happydomain/services.
const nestedKey = "app.dummyNested"
if _, err := svcs.FindSubService(nestedKey); err != nil {
t.Errorf("expected nested type %q to be registered as a sub-service: %v", nestedKey, err)
}
// Loading the same plugin twice must be a no-op (collision warning).
found, err = loadServicePlugin(fs, "second.so")
if !found || err != nil {
t.Fatalf("expected duplicate load to be silently ignored, got (%v, %v)", found, err)
}
}

View file

@ -0,0 +1,37 @@
// 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 "log"
// initPlugins is a no-op on platforms where Go's plugin package is not
// supported (Windows, plan9, …). If the operator configured plugin
// directories anyway we log a clear warning rather than silently ignoring
// them, so the misconfiguration is visible at startup.
func (a *App) initPlugins() error {
if len(a.cfg.PluginsDirectories) > 0 {
log.Printf("Warning: plugin loading is not supported on this platform; ignoring %d configured plugin director(y/ies)", len(a.cfg.PluginsDirectories))
}
return nil
}

View file

@ -0,0 +1,98 @@
// 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 (
"fmt"
"os"
"path/filepath"
"plugin"
"testing"
)
// fakeSymbols is a pluginSymbols implementation backed by a static map. It
// lets the loader tests exercise their behaviour without having to compile a
// real .so file via `go build -buildmode=plugin`.
type fakeSymbols struct {
syms map[string]plugin.Symbol
}
func (f *fakeSymbols) Lookup(name string) (plugin.Symbol, error) {
if s, ok := f.syms[name]; ok {
return s, nil
}
return nil, fmt.Errorf("symbol %q not found", name)
}
// TestLoadPlugin_NoRecognisedSymbols verifies that when a .so file exports
// none of the known plugin symbols, every loader returns (false, nil) — i.e.
// the file is silently skipped rather than reported as an error. loadPlugin
// itself logs a warning in that situation; we exercise the inner loop here
// because the outer call requires plugin.Open and a real .so file.
func TestLoadPlugin_NoRecognisedSymbols(t *testing.T) {
fs := &fakeSymbols{}
for _, loader := range pluginLoaders {
found, err := loader(fs, "empty.so")
if found || err != nil {
t.Fatalf("loader returned (%v, %v) for empty symbol set, expected (false, nil)", found, err)
}
}
}
func TestCheckPluginDirectoryPermissions(t *testing.T) {
dir := t.TempDir()
// A freshly-created TempDir is owner-only on every platform we run on,
// so this must be accepted.
if err := os.Chmod(dir, 0o750); err != nil {
t.Fatalf("chmod 0750: %v", err)
}
if err := checkPluginDirectoryPermissions(dir); err != nil {
t.Errorf("expected 0750 directory to be accepted, got %v", err)
}
// World-writable: must be refused.
if err := os.Chmod(dir, 0o777); err != nil {
t.Fatalf("chmod 0777: %v", err)
}
if err := checkPluginDirectoryPermissions(dir); err == nil {
t.Errorf("expected 0777 directory to be refused")
}
// Group-writable: must also be refused.
if err := os.Chmod(dir, 0o770); err != nil {
t.Fatalf("chmod 0770: %v", err)
}
if err := checkPluginDirectoryPermissions(dir); err == nil {
t.Errorf("expected 0770 directory to be refused")
}
// Restore permissions so t.TempDir cleanup can remove the directory.
_ = os.Chmod(dir, 0o700)
// Non-existent path: must be refused.
if err := checkPluginDirectoryPermissions(filepath.Join(dir, "does-not-exist")); err == nil {
t.Errorf("expected missing directory to be refused")
}
}

View file

@ -0,0 +1,48 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 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/>.
package checker
import (
"strings"
"git.happydns.org/happyDomain/model"
)
// WorstStatusAggregator aggregates check states by taking the worst status.
type WorstStatusAggregator struct{}
func (a WorstStatusAggregator) Aggregate(states []happydns.CheckState) happydns.CheckState {
worst := happydns.StatusUnknown
var messages []string
for _, s := range states {
if s.Status > worst {
worst = s.Status
}
if s.Message != "" {
messages = append(messages, s.Message)
}
}
return happydns.CheckState{
Status: worst,
Message: strings.Join(messages, "; "),
}
}

View file

@ -0,0 +1,284 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 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/>.
package checker
import (
"context"
"encoding/json"
"errors"
"fmt"
"sync"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
"git.happydns.org/happyDomain/model"
)
// The observation provider registry lives in the Apache-2.0 licensed
// checker-sdk-go module, so external plugins can register themselves
// without depending on AGPL code. These wrappers preserve the existing
// happyDomain call sites.
// RegisterObservationProvider registers an observation provider globally.
func RegisterObservationProvider(p happydns.ObservationProvider) {
sdk.RegisterObservationProvider(p)
}
// GetObservationProvider returns the provider for the given key, or nil.
func GetObservationProvider(key happydns.ObservationKey) happydns.ObservationProvider {
return sdk.FindObservationProvider(key)
}
// GetObservationProviders returns all registered observation providers.
func GetObservationProviders() map[happydns.ObservationKey]happydns.ObservationProvider {
return sdk.GetObservationProviders()
}
// ObservationCacheLookup resolves a cached observation for a target+key.
// Returns the raw data and collection time, or an error if not cached.
type ObservationCacheLookup func(target happydns.CheckTarget, key happydns.ObservationKey) (json.RawMessage, time.Time, error)
// ObservationContext provides lazy-loading, cached, thread-safe access to observation data.
// Collected data is serialized to json.RawMessage immediately after collection.
type ObservationContext struct {
target happydns.CheckTarget
opts happydns.CheckerOptions
cache map[happydns.ObservationKey]json.RawMessage
errors map[happydns.ObservationKey]error
mu sync.RWMutex
cacheLookup ObservationCacheLookup // nil = no DB cache
freshness time.Duration // 0 = always collect
providerOverride map[happydns.ObservationKey]happydns.ObservationProvider
}
// NewObservationContext creates a new ObservationContext for the given target and options.
// cacheLookup and freshness enable cross-checker observation reuse from stored snapshots.
// Pass nil and 0 to disable DB-based caching.
func NewObservationContext(target happydns.CheckTarget, opts happydns.CheckerOptions, cacheLookup ObservationCacheLookup, freshness time.Duration) *ObservationContext {
return &ObservationContext{
target: target,
opts: opts,
cache: make(map[happydns.ObservationKey]json.RawMessage),
errors: make(map[happydns.ObservationKey]error),
cacheLookup: cacheLookup,
freshness: freshness,
}
}
// SetProviderOverride registers a per-context provider that takes precedence
// over the global registry for the given observation key. This is used to
// substitute local providers with HTTP-backed ones when an endpoint is configured.
func (oc *ObservationContext) SetProviderOverride(key happydns.ObservationKey, p happydns.ObservationProvider) {
if oc.providerOverride == nil {
oc.providerOverride = make(map[happydns.ObservationKey]happydns.ObservationProvider)
}
oc.providerOverride[key] = p
}
// getProvider returns the observation provider for the given key, checking
// per-context overrides first, then falling back to the global registry.
func (oc *ObservationContext) getProvider(key happydns.ObservationKey) happydns.ObservationProvider {
if oc.providerOverride != nil {
if p, ok := oc.providerOverride[key]; ok {
return p
}
}
return GetObservationProvider(key)
}
// Get collects observation data for the given key (lazily) and unmarshals it into dest.
// Thread-safe: concurrent calls for the same key will only trigger one collection.
func (oc *ObservationContext) Get(ctx context.Context, key happydns.ObservationKey, dest any) error {
// Fast path: check cache under read lock.
oc.mu.RLock()
if raw, ok := oc.cache[key]; ok {
oc.mu.RUnlock()
return json.Unmarshal(raw, dest)
}
if err, ok := oc.errors[key]; ok {
oc.mu.RUnlock()
return err
}
oc.mu.RUnlock()
// Slow path: acquire write lock and collect.
oc.mu.Lock()
defer oc.mu.Unlock()
// Double-check after acquiring write lock.
if raw, ok := oc.cache[key]; ok {
return json.Unmarshal(raw, dest)
}
if err, ok := oc.errors[key]; ok {
return err
}
// Try DB cache before collecting fresh data.
if oc.cacheLookup != nil && oc.freshness > 0 {
if raw, collectedAt, err := oc.cacheLookup(oc.target, key); err == nil {
if time.Since(collectedAt) < oc.freshness {
oc.cache[key] = raw
return json.Unmarshal(raw, dest)
}
}
}
provider := oc.getProvider(key)
if provider == nil {
err := fmt.Errorf("no observation provider registered for key %q", key)
oc.errors[key] = err
return err
}
val, err := provider.Collect(ctx, oc.opts)
if err != nil {
oc.errors[key] = err
return err
}
raw, err := json.Marshal(val)
if err != nil {
err = fmt.Errorf("observation %q: marshal failed: %w", key, err)
oc.errors[key] = err
return err
}
oc.cache[key] = json.RawMessage(raw)
return json.Unmarshal(raw, dest)
}
// Data returns all cached observation data as pre-serialized JSON.
func (oc *ObservationContext) Data() map[happydns.ObservationKey]json.RawMessage {
oc.mu.RLock()
defer oc.mu.RUnlock()
data := make(map[happydns.ObservationKey]json.RawMessage, len(oc.cache))
for k, v := range oc.cache {
data[k] = v
}
return data
}
// Provider registration is startup-only (see comments on the registries in
// internal/service/registry.go and internal/provider/registry.go), so the
// "any provider implements X reporter" question has a fixed answer for the
// process lifetime. We compute it once on first call and cache it.
var (
htmlReporterOnce sync.Once
htmlReporterCached bool
metricsReporterOnce sync.Once
metricsReporterCached bool
)
// HasHTMLReporter returns true if any registered observation provider implements CheckerHTMLReporter.
func HasHTMLReporter() bool {
htmlReporterOnce.Do(func() {
for _, p := range sdk.GetObservationProviders() {
if _, ok := p.(happydns.CheckerHTMLReporter); ok {
htmlReporterCached = true
return
}
}
})
return htmlReporterCached
}
// GetHTMLReport renders an HTML report for the given observation key and raw JSON data.
// Returns (html, true, nil) if the provider supports HTML reports, or ("", false, nil) if not.
func GetHTMLReport(key happydns.ObservationKey, raw json.RawMessage) (string, bool, error) {
return getHTMLReport(GetObservationProvider(key), key, raw)
}
// GetHTMLReportCtx is like GetHTMLReport but resolves the provider through
// the ObservationContext, respecting per-context overrides.
func (oc *ObservationContext) GetHTMLReportCtx(key happydns.ObservationKey, raw json.RawMessage) (string, bool, error) {
return getHTMLReport(oc.getProvider(key), key, raw)
}
func getHTMLReport(provider happydns.ObservationProvider, key happydns.ObservationKey, raw json.RawMessage) (string, bool, error) {
if provider == nil {
return "", false, fmt.Errorf("no observation provider registered for key %q", key)
}
hr, ok := provider.(happydns.CheckerHTMLReporter)
if !ok {
return "", false, nil
}
html, err := hr.GetHTMLReport(raw)
return html, true, err
}
// HasMetricsReporter returns true if any registered observation provider implements CheckerMetricsReporter.
func HasMetricsReporter() bool {
metricsReporterOnce.Do(func() {
for _, p := range sdk.GetObservationProviders() {
if _, ok := p.(happydns.CheckerMetricsReporter); ok {
metricsReporterCached = true
return
}
}
})
return metricsReporterCached
}
// GetMetrics extracts metrics for the given observation key and raw JSON data.
// Returns (metrics, true, nil) if the provider supports metrics, or (nil, false, nil) if not.
func GetMetrics(key happydns.ObservationKey, raw json.RawMessage, collectedAt time.Time) ([]happydns.CheckMetric, bool, error) {
return getMetrics(GetObservationProvider(key), key, raw, collectedAt)
}
// GetMetricsCtx is like GetMetrics but resolves the provider through
// the ObservationContext, respecting per-context overrides.
func (oc *ObservationContext) GetMetricsCtx(key happydns.ObservationKey, raw json.RawMessage, collectedAt time.Time) ([]happydns.CheckMetric, bool, error) {
return getMetrics(oc.getProvider(key), key, raw, collectedAt)
}
func getMetrics(provider happydns.ObservationProvider, key happydns.ObservationKey, raw json.RawMessage, collectedAt time.Time) ([]happydns.CheckMetric, bool, error) {
if provider == nil {
return nil, false, fmt.Errorf("no observation provider registered for key %q", key)
}
mr, ok := provider.(happydns.CheckerMetricsReporter)
if !ok {
return nil, false, nil
}
metrics, err := mr.ExtractMetrics(raw, collectedAt)
return metrics, true, err
}
// GetAllMetrics extracts metrics from all observation keys in a snapshot.
func GetAllMetrics(snap *happydns.ObservationSnapshot) ([]happydns.CheckMetric, error) {
var allMetrics []happydns.CheckMetric
var errs []error
for key, raw := range snap.Data {
metrics, supported, err := GetMetrics(key, raw, snap.CollectedAt)
if err != nil {
errs = append(errs, fmt.Errorf("observation %q: %w", key, err))
continue
}
if !supported {
continue
}
allMetrics = append(allMetrics, metrics...)
}
return allMetrics, errors.Join(errs...)
}

View file

@ -0,0 +1,180 @@
// 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/>.
package checker
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
"git.happydns.org/happyDomain/model"
)
// blockingProvider is an ObservationProvider whose Collect blocks on the
// release channel until the test signals it. It records how many concurrent
// Collect calls are in flight at any moment.
type blockingProvider struct {
key happydns.ObservationKey
release chan struct{}
calls int32
maxCalls int32
}
func (b *blockingProvider) Key() happydns.ObservationKey { return b.key }
func (b *blockingProvider) Collect(ctx context.Context, _ happydns.CheckerOptions) (any, error) {
atomic.AddInt32(&b.calls, 1)
defer atomic.AddInt32(&b.calls, -1)
for {
current := atomic.LoadInt32(&b.calls)
max := atomic.LoadInt32(&b.maxCalls)
if current > max {
if atomic.CompareAndSwapInt32(&b.maxCalls, max, current) {
break
}
continue
}
break
}
select {
case <-b.release:
return map[string]string{string(b.key): "ok"}, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
// TestObservationContext_ConcurrentDifferentKeys verifies that two Get calls
// for distinct observation keys can run their Collect concurrently — i.e.
// the per-context lock is not held across provider.Collect.
func TestObservationContext_ConcurrentDifferentKeys(t *testing.T) {
release := make(chan struct{})
defer close(release)
pa := &blockingProvider{key: happydns.ObservationKey("test-a"), release: release}
pb := &blockingProvider{key: happydns.ObservationKey("test-b"), release: release}
oc := NewObservationContext(happydns.CheckTarget{}, happydns.CheckerOptions{}, nil, 0)
oc.SetProviderOverride(pa.key, pa)
oc.SetProviderOverride(pb.key, pb)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var wg sync.WaitGroup
results := make([]error, 2)
for i, key := range []happydns.ObservationKey{pa.key, pb.key} {
wg.Add(1)
go func(idx int, k happydns.ObservationKey) {
defer wg.Done()
var dst map[string]string
results[idx] = oc.Get(ctx, k, &dst)
}(i, key)
}
// Wait until both providers are blocked inside Collect simultaneously.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if atomic.LoadInt32(&pa.calls) == 1 && atomic.LoadInt32(&pb.calls) == 1 {
break
}
time.Sleep(5 * time.Millisecond)
}
if a, b := atomic.LoadInt32(&pa.calls), atomic.LoadInt32(&pb.calls); a != 1 || b != 1 {
t.Fatalf("expected both providers to be collecting in parallel, got a=%d b=%d", a, b)
}
// Release both Collects and wait for the Get calls to return.
release <- struct{}{}
release <- struct{}{}
wg.Wait()
for i, err := range results {
if err != nil {
t.Errorf("Get %d returned error: %v", i, err)
}
}
}
// TestObservationContext_DedupesSameKey verifies that concurrent Get calls
// for the *same* key only invoke provider.Collect once.
func TestObservationContext_DedupesSameKey(t *testing.T) {
release := make(chan struct{})
var collectCount int32
prov := &countingProvider{
key: happydns.ObservationKey("test-dedup"),
release: release,
count: &collectCount,
}
oc := NewObservationContext(happydns.CheckTarget{}, happydns.CheckerOptions{}, nil, 0)
oc.SetProviderOverride(prov.key, prov)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
const N = 8
var wg sync.WaitGroup
wg.Add(N)
for i := 0; i < N; i++ {
go func() {
defer wg.Done()
var dst map[string]string
if err := oc.Get(ctx, prov.key, &dst); err != nil {
t.Errorf("Get error: %v", err)
}
}()
}
// Wait for at least one collect to be in flight, then release it.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) && atomic.LoadInt32(&collectCount) == 0 {
time.Sleep(5 * time.Millisecond)
}
close(release)
wg.Wait()
if got := atomic.LoadInt32(&collectCount); got != 1 {
t.Errorf("expected exactly 1 Collect call, got %d", got)
}
}
type countingProvider struct {
key happydns.ObservationKey
release chan struct{}
count *int32
}
func (c *countingProvider) Key() happydns.ObservationKey { return c.key }
func (c *countingProvider) Collect(ctx context.Context, _ happydns.CheckerOptions) (any, error) {
atomic.AddInt32(c.count, 1)
select {
case <-c.release:
return map[string]string{"k": "v"}, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}

View file

@ -0,0 +1,55 @@
// 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/>.
package checker
import (
sdk "git.happydns.org/checker-sdk-go/checker"
"git.happydns.org/happyDomain/model"
)
// The checker definition registry lives in the Apache-2.0 licensed
// checker-sdk-go module, so external plugins can register themselves
// without depending on AGPL code. These wrappers preserve the existing
// happyDomain call sites.
// RegisterChecker registers a checker definition globally.
func RegisterChecker(c *happydns.CheckerDefinition) {
sdk.RegisterChecker(c)
}
// RegisterExternalizableChecker registers a checker that supports being
// delegated to a remote HTTP endpoint. It appends an "endpoint" AdminOpt
// so the administrator can optionally configure a remote URL.
// When the endpoint is left empty, the checker runs locally as usual.
func RegisterExternalizableChecker(c *happydns.CheckerDefinition) {
sdk.RegisterExternalizableChecker(c)
}
// GetCheckers returns all registered checker definitions.
func GetCheckers() map[string]*happydns.CheckerDefinition {
return sdk.GetCheckers()
}
// FindChecker returns the checker definition with the given ID, or nil.
func FindChecker(id string) *happydns.CheckerDefinition {
return sdk.FindChecker(id)
}

View file

@ -24,6 +24,7 @@ package config // import "git.happydns.org/happyDomain/config"
import (
"flag"
"fmt"
"runtime"
"git.happydns.org/happyDomain/internal/storage"
"git.happydns.org/happyDomain/model"
@ -45,6 +46,7 @@ func declareFlags(o *happydns.Options) {
flag.Var(&JWTSecretKey{&o.JWTSecretKey}, "jwt-secret-key", "Secret key used to verify JWT authentication tokens (a random secret is used if undefined)")
flag.Var(&URL{&o.ExternalAuth}, "external-auth", "Base URL to use for login and registration (use embedded forms if left empty)")
flag.BoolVar(&o.OptOutInsights, "opt-out-insights", false, "Disable the anonymous usage statistics report. If you care about this project and don't participate in discussions, don't opt-out.")
flag.IntVar(&o.CheckerMaxConcurrency, "checker-max-concurrency", runtime.NumCPU(), "Maximum number of checker jobs that can run simultaneously")
flag.Var(&URL{&o.ListmonkURL}, "newsletter-server-url", "Base URL of the listmonk newsletter server")
flag.IntVar(&o.ListmonkID, "newsletter-id", 1, "Listmonk identifier of the list receiving the new user")
@ -60,6 +62,8 @@ 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, 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

@ -25,8 +25,27 @@ import (
"encoding/base64"
"net/mail"
"net/url"
"strings"
)
// stringSlice is a flag.Value that accumulates string values across repeated
// invocations of the same flag (e.g. -plugins-directory a -plugins-directory b).
type stringSlice struct {
Values *[]string
}
func (s *stringSlice) String() string {
if s.Values == nil {
return ""
}
return strings.Join(*s.Values, ",")
}
func (s *stringSlice) Set(value string) error {
*s.Values = append(*s.Values, value)
return nil
}
type JWTSecretKey struct {
Secret *[]byte
}

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{

View file

@ -44,6 +44,15 @@ func (a ByWeight) Len() int { return len(a) }
func (a ByWeight) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a ByWeight) Less(i, j int) bool { return a[i].Weight < a[j].Weight }
// The service and sub-service registries below are intentionally unguarded.
// All writes (RegisterService, RegisterPluginSubServices, RegisterSubServices)
// happen from App.initPlugins() at startup, *before* App.initUsecases() and
// before any goroutine that could read them — see internal/app/app.go. From
// that point on the maps are read-only for the rest of the process lifetime,
// so concurrent reads are safe without locking. Any future code path that
// needs to mutate these maps after startup must introduce its own
// synchronisation (sync.RWMutex around services, subServices and
// ordered_services together).
var (
services map[string]*Svc = map[string]*Svc{}
subServices map[string]happydns.SubServiceCreator = map[string]happydns.SubServiceCreator{}
@ -52,11 +61,44 @@ var (
)
func RegisterService(creator happydns.ServiceCreator, analyzer ServiceAnalyzer, infos happydns.ServiceInfos, weight uint32, aliases ...string) {
baseType := reflect.Indirect(reflect.ValueOf(creator())).Type()
name := baseType.String()
// A second registration of the same name almost always means a plugin is
// shadowing a built-in (or another plugin) by accident. Log loudly and
// keep the existing entry rather than silently overwriting it.
//
// We validate the primary name *and* every alias up-front, before
// touching the registry, so that a collision on an alias cannot leave
// the service half-registered (primary in, some aliases in, the rest
// rejected). Either the whole set lands or none of it does.
if _, exists := services[name]; exists {
log.Printf("Warning: service %q is already registered; ignoring duplicate registration", name)
return
}
for _, alias := range aliases {
if alias == name {
log.Printf("Warning: service %q lists its own primary name as an alias; ignoring registration", name)
return
}
if _, exists := services[alias]; exists {
log.Printf("Warning: service %q cannot be registered: alias %q is already taken; ignoring registration", name, alias)
return
}
}
// Catch duplicates *within* the alias list itself.
seen := make(map[string]struct{}, len(aliases))
for _, alias := range aliases {
if _, dup := seen[alias]; dup {
log.Printf("Warning: service %q lists alias %q twice; ignoring registration", name, alias)
return
}
seen[alias] = struct{}{}
}
// Invalidate ordered_services, which serve as cache
ordered_services = nil
baseType := reflect.Indirect(reflect.ValueOf(creator())).Type()
name := baseType.String()
log.Println("Registering new service:", name)
// Override given parameters by true one
@ -70,7 +112,6 @@ func RegisterService(creator happydns.ServiceCreator, analyzer ServiceAnalyzer,
}
services[name] = svc
// Register aliases
for _, alias := range aliases {
services[alias] = svc
}
@ -79,6 +120,40 @@ func RegisterService(creator happydns.ServiceCreator, analyzer ServiceAnalyzer,
RegisterSubServices(baseType)
}
// RegisterPluginSubServices walks the type tree rooted at t and registers
// every nested struct type as a sub-service, regardless of its package path.
//
// The built-in RegisterSubServices intentionally restricts itself to types
// declared under git.happydns.org/happyDomain/services to avoid registering
// random struct types pulled in from third-party libraries by built-in
// services. Plugin services live in a completely different module path, so
// that filter would skip every nested type they declare and break
// (de)serialisation of any non-flat plugin payload. The plugin loader calls
// this function explicitly to opt the plugin's own types into the registry.
func RegisterPluginSubServices(t reflect.Type) {
switch t.Kind() {
case reflect.Ptr, reflect.Slice, reflect.Array, reflect.Map:
RegisterPluginSubServices(t.Elem())
return
case reflect.Struct:
// Anonymous structs have no name and cannot be looked up later.
if t.Name() == "" {
return
}
key := t.String()
if _, ok := subServices[key]; ok {
return
}
log.Println("Registering new plugin subservice:", key)
subServices[key] = func() any {
return reflect.New(t).Interface()
}
for i := 0; i < t.NumField(); i++ {
RegisterPluginSubServices(t.Field(i).Type)
}
}
}
func RegisterSubServices(t reflect.Type) {
if t.Kind() == reflect.Struct && strings.HasPrefix(t.PkgPath(), pathToSvcsModule) {
if _, ok := subServices[t.String()]; !ok {

287
model/checker.go Normal file
View file

@ -0,0 +1,287 @@
// 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/>.
package happydns
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
sdk "git.happydns.org/checker-sdk-go/checker"
)
// The types and helpers needed by external checker plugins live in the
// Apache-2.0 licensed checker-sdk-go module. They are re-exported here as
// aliases so the rest of the happyDomain codebase keeps working unchanged.
//
// Host-only types (Execution, CheckPlan, CheckEvaluation, …) remain
// defined in this file because they describe orchestration state that is
// internal to the happyDomain server and never crosses the plugin boundary.
// --- Re-exports from checker-sdk-go ---
type CheckScopeType = sdk.CheckScopeType
const (
CheckScopeAdmin = sdk.CheckScopeAdmin
CheckScopeUser = sdk.CheckScopeUser
CheckScopeDomain = sdk.CheckScopeDomain
CheckScopeZone = sdk.CheckScopeZone
CheckScopeService = sdk.CheckScopeService
)
const (
AutoFillDomainName = sdk.AutoFillDomainName
AutoFillSubdomain = sdk.AutoFillSubdomain
AutoFillZone = sdk.AutoFillZone
AutoFillServiceType = sdk.AutoFillServiceType
AutoFillService = sdk.AutoFillService
)
type (
CheckTarget = sdk.CheckTarget
CheckerAvailability = sdk.CheckerAvailability
CheckerOptions = sdk.CheckerOptions
CheckerOptionDocumentation = sdk.CheckerOptionDocumentation
CheckerOptionsDocumentation = sdk.CheckerOptionsDocumentation
Status = sdk.Status
CheckState = sdk.CheckState
CheckMetric = sdk.CheckMetric
ObservationKey = sdk.ObservationKey
CheckIntervalSpec = sdk.CheckIntervalSpec
ObservationProvider = sdk.ObservationProvider
CheckRuleInfo = sdk.CheckRuleInfo
CheckRule = sdk.CheckRule
CheckRuleWithOptions = sdk.CheckRuleWithOptions
ObservationGetter = sdk.ObservationGetter
CheckAggregator = sdk.CheckAggregator
CheckerHTMLReporter = sdk.CheckerHTMLReporter
CheckerMetricsReporter = sdk.CheckerMetricsReporter
CheckerDefinitionProvider = sdk.CheckerDefinitionProvider
CheckerDefinition = sdk.CheckerDefinition
OptionsValidator = sdk.OptionsValidator
ExternalCollectRequest = sdk.ExternalCollectRequest
ExternalCollectResponse = sdk.ExternalCollectResponse
ExternalEvaluateRequest = sdk.ExternalEvaluateRequest
ExternalEvaluateResponse = sdk.ExternalEvaluateResponse
ExternalReportRequest = sdk.ExternalReportRequest
)
const (
StatusUnknown = sdk.StatusUnknown
StatusOK = sdk.StatusOK
StatusInfo = sdk.StatusInfo
StatusWarn = sdk.StatusWarn
StatusCrit = sdk.StatusCrit
StatusError = sdk.StatusError
)
// --- Helpers for converting between target identifier strings and *Identifier ---
// TargetIdentifier parses a target identifier string into an *Identifier.
// Returns (nil, nil) if the string is empty, or (nil, err) if it cannot be parsed.
func TargetIdentifier(s string) (*Identifier, error) {
if s == "" {
return nil, nil
}
id, err := NewIdentifierFromString(s)
if err != nil {
return nil, fmt.Errorf("invalid target identifier %q: %w", s, err)
}
return &id, nil
}
// FormatIdentifier returns the string representation of id, or "" if nil.
func FormatIdentifier(id *Identifier) string {
if id == nil {
return ""
}
return id.String()
}
// --- Host-only types (orchestration state) ---
// CheckerRunRequest is the JSON body for manually triggering a checker.
type CheckerRunRequest struct {
Options CheckerOptions `json:"options,omitempty"`
EnabledRules map[string]bool `json:"enabledRules,omitempty"`
}
// CheckerOptionsPositional stores options with their positional key components.
type CheckerOptionsPositional struct {
CheckName string `json:"checkName"`
UserId *Identifier `json:"userId,omitempty"`
DomainId *Identifier `json:"domainId,omitempty"`
ServiceId *Identifier `json:"serviceId,omitempty"`
Options CheckerOptions `json:"options"`
}
// CheckPlan is an optional user override for a checker on a specific target.
type CheckPlan struct {
Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"`
CheckerID string `json:"checkerId" binding:"required" readonly:"true"`
Target CheckTarget `json:"target" binding:"required" readonly:"true"`
Interval *time.Duration `json:"interval,omitempty" swaggertype:"integer"`
Enabled map[string]bool `json:"enabled,omitempty"`
}
// IsFullyDisabled returns true if the enabled map is non-empty and every entry is false.
func (p *CheckPlan) IsFullyDisabled() bool {
if len(p.Enabled) == 0 {
return false
}
for _, v := range p.Enabled {
if v {
return false
}
}
return true
}
// IsRuleEnabled returns whether a specific rule is enabled.
// A nil or empty map means all rules are enabled. A missing key means enabled.
func (p *CheckPlan) IsRuleEnabled(ruleName string) bool {
if len(p.Enabled) == 0 {
return true
}
v, ok := p.Enabled[ruleName]
if !ok {
return true
}
return v
}
// CheckerStatus combines a checker definition with its latest execution and plan for a target.
type CheckerStatus struct {
*CheckerDefinition
LatestExecution *Execution `json:"latestExecution,omitempty"`
Plan *CheckPlan `json:"plan,omitempty"`
Enabled bool `json:"enabled"`
EnabledRules map[string]bool `json:"enabledRules"`
}
// CheckEvaluation is the result of running a checker on observed data.
type CheckEvaluation struct {
Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"`
PlanID *Identifier `json:"planId,omitempty" swaggertype:"string"`
CheckerID string `json:"checkerId" binding:"required"`
Target CheckTarget `json:"target" binding:"required"`
SnapshotID Identifier `json:"snapshotId" swaggertype:"string" binding:"required" readonly:"true"`
EvaluatedAt time.Time `json:"evaluatedAt" binding:"required" readonly:"true" format:"date-time"`
States []CheckState `json:"states" binding:"required" readonly:"true"`
}
// ObservationSnapshot holds data collected during an execution.
type ObservationSnapshot struct {
Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"`
Target CheckTarget `json:"target" binding:"required" readonly:"true"`
CollectedAt time.Time `json:"collectedAt" binding:"required" readonly:"true" format:"date-time"`
Data map[ObservationKey]json.RawMessage `json:"data" binding:"required" readonly:"true" swaggertype:"object,object"`
}
// ObservationCacheEntry is a lightweight pointer to cached observation data in a snapshot.
type ObservationCacheEntry struct {
SnapshotID Identifier `json:"snapshotId"`
CollectedAt time.Time `json:"collectedAt"`
}
// ExecutionStatus represents the lifecycle state of an execution.
type ExecutionStatus int
const (
ExecutionPending ExecutionStatus = iota
ExecutionRunning
ExecutionDone
ExecutionFailed
)
// TriggerType represents what initiated an execution.
type TriggerType int
const (
TriggerManual TriggerType = iota
TriggerSchedule
)
// TriggerInfo describes the trigger for an execution.
type TriggerInfo struct {
Type TriggerType `json:"type"`
PlanID *Identifier `json:"planId,omitempty" swaggertype:"string"`
}
// Execution represents a single run of a checker pipeline.
type Execution struct {
Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"`
CheckerID string `json:"checkerId" binding:"required" readonly:"true"`
PlanID *Identifier `json:"planId,omitempty" swaggertype:"string" readonly:"true"`
Target CheckTarget `json:"target" binding:"required" readonly:"true"`
Trigger TriggerInfo `json:"trigger" binding:"required" readonly:"true"`
StartedAt time.Time `json:"startedAt" binding:"required" readonly:"true" format:"date-time"`
EndedAt *time.Time `json:"endedAt,omitempty" readonly:"true" format:"date-time"`
Status ExecutionStatus `json:"status" binding:"required" readonly:"true"`
Error string `json:"error,omitempty" readonly:"true"`
Result CheckState `json:"result" readonly:"true"`
EvaluationID *Identifier `json:"evaluationId,omitempty" swaggertype:"string" readonly:"true"`
}
// CheckerEngine orchestrates the full checker pipeline.
type CheckerEngine interface {
CreateExecution(checkerID string, target CheckTarget, plan *CheckPlan) (*Execution, error)
RunExecution(ctx context.Context, exec *Execution, plan *CheckPlan, runOpts CheckerOptions) (*CheckEvaluation, error)
}
// CheckerOptionsKey builds the positional KV key for checker options.
// Format: chckrcfg-{checkerName}|{userId}|{domainId}|{serviceId}
func CheckerOptionsKey(checkerName string, userId *Identifier, domainId *Identifier, serviceId *Identifier) string {
return fmt.Sprintf("chckrcfg-%s|%s|%s|%s", checkerName,
FormatIdentifier(userId), FormatIdentifier(domainId), FormatIdentifier(serviceId))
}
// ParseCheckerOptionsKey extracts the positional components from a KV key.
func ParseCheckerOptionsKey(key string) (checkerName string, userId *Identifier, domainId *Identifier, serviceId *Identifier) {
trimmed := strings.TrimPrefix(key, "chckrcfg-")
parts := strings.SplitN(trimmed, "|", 4)
if len(parts) < 4 {
return trimmed, nil, nil, nil
}
checkerName = parts[0]
if parts[1] != "" {
if id, err := NewIdentifierFromString(parts[1]); err == nil {
userId = &id
}
}
if parts[2] != "" {
if id, err := NewIdentifierFromString(parts[2]); err == nil {
domainId = &id
}
}
if parts[3] != "" {
if id, err := NewIdentifierFromString(parts[3]); err == nil {
serviceId = &id
}
}
return
}

View file

@ -93,12 +93,20 @@ type Options struct {
OIDCClients []OIDCSettings
// CheckerMaxConcurrency is the maximum number of checker jobs that can
// run simultaneously. Defaults to runtime.NumCPU().
CheckerMaxConcurrency int
// CaptchaProvider selects the captcha provider ("hcaptcha", "recaptchav2", "turnstile", or "").
CaptchaProvider string
// CaptchaLoginThreshold is the number of consecutive login failures before captcha is required.
// 0 means always require captcha at login (when provider is configured).
CaptchaLoginThreshold int
// PluginsDirectories lists filesystem paths scanned at startup for
// checker plugins (.so files).
PluginsDirectories []string
}
// GetBaseURL returns the full url to the absolute ExternalURL, including BaseURL.

View file

@ -27,15 +27,20 @@ import (
)
var (
ErrAuthUserNotFound = errors.New("user not found")
ErrDomainNotFound = errors.New("domain not found")
ErrDomainLogNotFound = errors.New("domain log not found")
ErrProviderNotFound = errors.New("provider not found")
ErrSessionNotFound = errors.New("session not found")
ErrUserNotFound = errors.New("user not found")
ErrUserAlreadyExist = errors.New("user already exists")
ErrZoneNotFound = errors.New("zone not found")
ErrNotFound = errors.New("not found")
ErrAuthUserNotFound = errors.New("user not found")
ErrDomainNotFound = errors.New("domain not found")
ErrDomainLogNotFound = errors.New("domain log not found")
ErrProviderNotFound = errors.New("provider not found")
ErrSessionNotFound = errors.New("session not found")
ErrUserNotFound = errors.New("user not found")
ErrUserAlreadyExist = errors.New("user already exists")
ErrZoneNotFound = errors.New("zone not found")
ErrCheckerNotFound = errors.New("checker not found")
ErrCheckPlanNotFound = errors.New("check plan not found")
ErrCheckEvaluationNotFound = errors.New("check evaluation not found")
ErrExecutionNotFound = errors.New("execution not found")
ErrSnapshotNotFound = errors.New("snapshot not found")
ErrNotFound = errors.New("not found")
)
const TryAgainErr = "Sorry, we are currently unable to sent email validation link. Please try again later."

View file

@ -104,6 +104,10 @@ type Field struct {
// Description stores an helpfull sentence describing the field.
Description string `json:"description,omitempty"`
// AutoFill indicates that this field is automatically filled by the system
// based on execution context (e.g. domain name, zone, service type).
AutoFill string `json:"autoFill,omitempty"`
}
type FormState struct {

View file

@ -154,6 +154,14 @@ type ZoneServices struct {
Services []*Service `json:"services"`
}
// ZoneWithServicesCheckStatus wraps a Zone with the worst check status for each service.
type ZoneWithServicesCheckStatus struct {
*Zone
// ServicesCheckStatus holds the worst check status for each service,
// keyed by service identifier string. Nil/absent if no results exist yet.
ServicesCheckStatus map[string]*Status `json:"services_check_status,omitempty"`
}
type ZoneUsecase interface {
AddRecord(*Zone, string, Record) error
CreateZone(*Zone) error

View file

@ -22,6 +22,7 @@
import { redirect, type Load } from "@sveltejs/kit";
import { get } from "svelte/store";
import { refreshServicesSpecs } from "$lib/stores/services";
import { toasts } from "$lib/stores/toasts";
import { refreshUserSession } from "$lib/stores/usersession";
import { config as tsConfig, locale, loadTranslations, t } from "$lib/translations";
@ -111,6 +112,8 @@ export const load: Load = async ({ route, url }) => {
}
}
refreshServicesSpecs();
return {
sw_state,
};