Compare commits
5 commits
c0b249801b
...
266ebeea1f
| Author | SHA1 | Date | |
|---|---|---|---|
| 266ebeea1f | |||
| ef55622022 | |||
| 968dec0f1b | |||
| ee127aa3a9 | |||
| 33702d3ee4 |
26 changed files with 2318 additions and 18 deletions
146
docs/plugins/checker-plugin.md
Normal file
146
docs/plugins/checker-plugin.md
Normal 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).
|
||||
140
docs/plugins/provider-plugin.md
Normal file
140
docs/plugins/provider-plugin.md
Normal 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.
|
||||
136
docs/plugins/service-plugin.md
Normal file
136
docs/plugins/service-plugin.md
Normal 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
1
go.mod
|
|
@ -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
6
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
299
internal/app/plugins.go
Normal 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...)
|
||||
}
|
||||
143
internal/app/plugins_checker_test.go
Normal file
143
internal/app/plugins_checker_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
143
internal/app/plugins_service_test.go
Normal file
143
internal/app/plugins_service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
37
internal/app/plugins_stub.go
Normal file
37
internal/app/plugins_stub.go
Normal 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
|
||||
}
|
||||
98
internal/app/plugins_test.go
Normal file
98
internal/app/plugins_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
48
internal/checker/aggregator.go
Normal file
48
internal/checker/aggregator.go
Normal 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, "; "),
|
||||
}
|
||||
}
|
||||
284
internal/checker/observation.go
Normal file
284
internal/checker/observation.go
Normal 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...)
|
||||
}
|
||||
180
internal/checker/observation_test.go
Normal file
180
internal/checker/observation_test.go
Normal 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()
|
||||
}
|
||||
}
|
||||
55
internal/checker/registry.go
Normal file
55
internal/checker/registry.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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
287
model/checker.go
Normal 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
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue