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:
parent
e58a585e2f
commit
119b43cb87
5 changed files with 389 additions and 2 deletions
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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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