services: load external service plugins from .so files

Add a NewServicePlugin loader that registers the returned creator,
analyzer, infos, weight and aliases through internal/service.RegisterService.
The frontend root layout now refreshes the service specs on load so
plugin-provided services appear in the UI alongside the built-in ones.
This commit is contained in:
nemunaire 2026-04-08 03:07:29 +07:00
commit 99181c591d
5 changed files with 394 additions and 2 deletions

View file

@ -0,0 +1,141 @@
# 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
The same Go plugin caveats (toolchain version, dependency versions,
`CGO_ENABLED=1`, `GOOS`/`GOARCH`) apply to service plugins. See
[checker-plugin.md](checker-plugin.md#build-constraints-and-platform-support)
for the full list.
---
## Licensing
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.
For checker plugins see [checker-plugin.md](checker-plugin.md#licensing),
which uses a separate (Apache-2.0) SDK module and is not subject to these
AGPL constraints.

View file

@ -35,6 +35,7 @@ import (
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"
)
@ -73,6 +74,7 @@ func safeCall(symbol string, fname string, fn func() error) (err error) {
var pluginLoaders = []pluginLoader{
loadCheckerPlugin,
loadProviderPlugin,
loadServicePlugin,
}
// loadCheckerPlugin handles the NewCheckerPlugin symbol exported by checkers
@ -155,6 +157,53 @@ func loadProviderPlugin(p pluginSymbols, fname string) (bool, error) {
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

View file

@ -0,0 +1,143 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//go:build linux || darwin || freebsd
package app
import (
"errors"
"plugin"
"strings"
"testing"
svcs "git.happydns.org/happyDomain/internal/service"
"git.happydns.org/happyDomain/model"
)
// dummyNested is referenced as a struct field by dummyServiceBody to verify
// that loadServicePlugin walks the type tree and registers nested types as
// sub-services, something the built-in walker refuses to do for types that
// live outside the happydomain/services module path.
type dummyNested struct {
Value string
}
type dummyServiceBody struct {
Hostname string
Detail dummyNested
}
func (d *dummyServiceBody) GetNbResources() int { return 1 }
func (d *dummyServiceBody) GenComment() string { return "dummy" }
func (d *dummyServiceBody) GetRecords(domain string, ttl uint32, origin string) ([]happydns.Record, error) {
return nil, nil
}
func newDummyServiceFactory() func() (happydns.ServiceCreator, svcs.ServiceAnalyzer, happydns.ServiceInfos, uint32, []string, error) {
return func() (happydns.ServiceCreator, svcs.ServiceAnalyzer, happydns.ServiceInfos, uint32, []string, error) {
creator := func() happydns.ServiceBody { return &dummyServiceBody{} }
return creator, nil, happydns.ServiceInfos{Name: "Dummy"}, 100, nil, nil
}
}
func TestLoadServicePlugin_SymbolMissing(t *testing.T) {
found, err := loadServicePlugin(&fakeSymbols{}, "missing.so")
if found || err != nil {
t.Fatalf("expected (false, nil), got (%v, %v)", found, err)
}
}
func TestLoadServicePlugin_WrongSymbolType(t *testing.T) {
fs := &fakeSymbols{syms: map[string]plugin.Symbol{
"NewServicePlugin": "not a function",
}}
found, err := loadServicePlugin(fs, "wrongtype.so")
if !found || err == nil || !strings.Contains(err.Error(), "unexpected type") {
t.Fatalf("expected wrong-type error, got (%v, %v)", found, err)
}
}
func TestLoadServicePlugin_FactoryError(t *testing.T) {
factory := func() (happydns.ServiceCreator, svcs.ServiceAnalyzer, happydns.ServiceInfos, uint32, []string, error) {
return nil, nil, happydns.ServiceInfos{}, 0, nil, errors.New("boom")
}
fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewServicePlugin": factory}}
found, err := loadServicePlugin(fs, "factoryerr.so")
if !found || err == nil || !strings.Contains(err.Error(), "boom") {
t.Fatalf("expected factory error to propagate, got (%v, %v)", found, err)
}
}
func TestLoadServicePlugin_NilCreator(t *testing.T) {
factory := func() (happydns.ServiceCreator, svcs.ServiceAnalyzer, happydns.ServiceInfos, uint32, []string, error) {
return nil, nil, happydns.ServiceInfos{Name: "Dummy"}, 0, nil, nil
}
fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewServicePlugin": factory}}
found, err := loadServicePlugin(fs, "nilcreator.so")
if !found || err == nil || !strings.Contains(err.Error(), "nil ServiceCreator") {
t.Fatalf("expected nil-creator error, got (%v, %v)", found, err)
}
}
func TestLoadServicePlugin_FactoryPanics(t *testing.T) {
factory := func() (happydns.ServiceCreator, svcs.ServiceAnalyzer, happydns.ServiceInfos, uint32, []string, error) {
panic("kaboom")
}
fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewServicePlugin": factory}}
found, err := loadServicePlugin(fs, "panic.so")
if !found || err == nil || !strings.Contains(err.Error(), "panicked") {
t.Fatalf("expected wrapped panic error, got (%v, %v)", found, err)
}
}
func TestLoadServicePlugin_SuccessRegistersSubServices(t *testing.T) {
factory := newDummyServiceFactory()
fs := &fakeSymbols{syms: map[string]plugin.Symbol{"NewServicePlugin": factory}}
found, err := loadServicePlugin(fs, "first.so")
if !found || err != nil {
t.Fatalf("expected success, got (%v, %v)", found, err)
}
// The service itself must be reachable through the registry.
const svcKey = "app.dummyServiceBody"
if _, err := svcs.FindService(svcKey); err != nil {
t.Fatalf("expected service %q to be registered: %v", svcKey, err)
}
// And so must the nested struct: this is the regression-prevention test
// for the built-in walker's pathToSvcsModule prefix check, which would
// otherwise refuse to register types from outside happydomain/services.
const nestedKey = "app.dummyNested"
if _, err := svcs.FindSubService(nestedKey); err != nil {
t.Errorf("expected nested type %q to be registered as a sub-service: %v", nestedKey, err)
}
// Loading the same plugin twice must be a no-op (collision warning).
found, err = loadServicePlugin(fs, "second.so")
if !found || err != nil {
t.Fatalf("expected duplicate load to be silently ignored, got (%v, %v)", found, err)
}
}

View file

@ -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,20 @@ 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.
if _, exists := services[name]; exists {
log.Printf("Warning: service %q is already registered; ignoring duplicate registration", name)
return
}
// 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
@ -72,6 +90,10 @@ func RegisterService(creator happydns.ServiceCreator, analyzer ServiceAnalyzer,
// Register aliases
for _, alias := range aliases {
if _, exists := services[alias]; exists {
log.Printf("Warning: service alias %q is already registered; ignoring", alias)
continue
}
services[alias] = svc
}
@ -79,6 +101,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 {

View file

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