Files
wanderer/db/pluginsystem/host_http.go
slothful-vassal 485ec53f6d feat: add plugin system (#1034)
* feat: add plugin system

* fix db docker build

* fix hammerhead readme, add strava subscription news to docs

* fixes and sdk improvements

* fix: reduce Meilisearch load, debounce federation sync (#1012)

* optimize meili trail index

* several fixes

---------

Co-authored-by: Flomp <Flomp@users.noreply.github.com>

* Bump svelte from 5.55.5 to 5.56.0 in /docs (#1032)

Bumps [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) from 5.55.5 to 5.56.0.
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.56.0/packages/svelte)

---
updated-dependencies:
- dependency-name: svelte
  dependency-version: 5.56.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Flomp <Flomp@users.noreply.github.com>

* Release v0.19.2 (#1035)

* chore: release v0.19.2

* add changelog

---------

Co-authored-by: Flomp <26000991+Flomp@users.noreply.github.com>
Co-authored-by: Christian Beutel <>

* speed up plugin sync and several small fixes

* concepts for security improvements and process stability

* improve concept

* security concept implemented

* remove insecure TLS

* worker concept implemented

* fixes and cleanup

* fixes

* docu

* mermaid, namings

* WASM plugin host improvements, plugin logging

* fix db migration

* Improve plugin config and category mapping UI

* fixes

* further fixes

* remove manual test sync

* fix db migration and strava mapping

* type added, UI improvements

* fix plugin card toggle clickable area

* optimize synch status card layout

* plugin type 'trails' instead of 'integration'

* session auth validation in UI

* fix komoot date and waypoints

* improve category mapping

* fix send to hammerhead: trail name

* plugin setup error handling improved

* fix review findings

* re-mapping added

* rename remote_category

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Flomp <Flomp@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Flomp <26000991+Flomp@users.noreply.github.com>
2026-06-22 15:00:44 +02:00

411 lines
12 KiB
Go

package pluginsystem
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"mime"
"mime/multipart"
"net/http"
"net/url"
"strings"
"pocketbase/util"
extism "github.com/extism/go-sdk"
)
type hostHTTPResponse struct {
Status int `json:"status"`
HeaderValues map[string][]string `json:"headerValues,omitempty"`
BodyBase64 string `json:"bodyBase64,omitempty"`
Error *PluginError `json:"error,omitempty"`
}
type HostRequestOptions struct {
Trail []byte
}
type HostResponse struct {
Status int
HeaderValues map[string][]string
Body []byte
}
var newConnectorHTTPClient = util.ConnectorHTTPClient
const maxHostLogPayloadBytes = 8 * 1024
// extismHostFunctions exposes the host APIs that WASM plugins may call. Each
// function must delegate to the same policy-controlled host implementation that
// backend handlers use.
func extismHostFunctions(manifest Manifest, policy RequestPolicyContext) []extism.HostFunction {
httpFn := extism.NewHostFunctionWithStack(
"http_request",
func(ctx context.Context, plugin *extism.CurrentPlugin, stack []uint64) {
requestBytes, err := plugin.ReadBytes(stack[0])
if err != nil {
writeHostHTTPResponse(ctx, plugin, stack, hostHTTPResponse{
Error: &PluginError{Code: "invalid_request", Message: err.Error()},
})
return
}
response := executeHostHTTPRequest(ctx, manifest, policy, requestBytes)
writeHostHTTPResponse(ctx, plugin, stack, response)
},
[]extism.ValueType{extism.ValueTypePTR},
[]extism.ValueType{extism.ValueTypePTR},
)
httpFn.SetNamespace("wanderer")
logFn := extism.NewHostFunctionWithStack(
"log",
func(ctx context.Context, plugin *extism.CurrentPlugin, stack []uint64) {
message, err := readBoundedHostLogPayload(plugin, stack[0])
if err != nil {
plugin.Log(extism.LogLevelError, "read host log message: "+err.Error())
return
}
entry, err := parseHostLogEntry(message)
if err != nil {
plugin.Log(extism.LogLevelError, "invalid host log message: "+err.Error())
return
}
log.Printf("plugin log [%s]: %s", entry.Level, entry.Message)
_ = ctx
},
[]extism.ValueType{extism.ValueTypePTR},
nil,
)
logFn.SetNamespace("wanderer")
return []extism.HostFunction{httpFn, logFn}
}
func readBoundedHostLogPayload(plugin *extism.CurrentPlugin, offset uint64) ([]byte, error) {
length, err := plugin.Length(offset)
if err != nil {
return nil, err
}
if length > maxHostLogPayloadBytes {
return nil, fmt.Errorf("log message exceeds maximum size")
}
return plugin.ReadBytes(offset)
}
func parseHostLogEntry(message []byte) (HostLogEntry, error) {
if len(message) > maxHostLogPayloadBytes {
return HostLogEntry{}, fmt.Errorf("log message exceeds maximum size")
}
var entry HostLogEntry
if err := json.Unmarshal(message, &entry); err != nil {
return HostLogEntry{}, fmt.Errorf("decode log entry: %w", err)
}
level, err := normalizeHostLogLevel(entry.Level)
if err != nil {
return HostLogEntry{}, err
}
entry.Level = level
entry.Message = sanitizeHostLogMessage(entry.Message)
if entry.Message == "" {
return HostLogEntry{}, fmt.Errorf("log message is required")
}
return entry, nil
}
func sanitizeHostLogMessage(message string) string {
return strings.TrimSpace(strings.Map(func(r rune) rune {
if r < 0x20 || r == 0x7f {
return ' '
}
return r
}, message))
}
func normalizeHostLogLevel(level string) (string, error) {
switch strings.ToLower(strings.TrimSpace(level)) {
case "debug":
return "debug", nil
case "info":
return "info", nil
case "warn":
return "warn", nil
case "error":
return "error", nil
default:
return "", fmt.Errorf("unsupported log level %q", level)
}
}
// executeHostHTTPRequest turns a raw plugin http_request payload into the
// hostHTTPResponse that the plugin reads back. It is the single source of truth
// for the request/response contract shared by the in-process runtime
// (extismHostFunctions) and the worker process (handleHostHTTPRequest), so the
// two paths cannot drift on error codes or response shape.
func executeHostHTTPRequest(ctx context.Context, manifest Manifest, policy RequestPolicyContext, requestBytes []byte) hostHTTPResponse {
var spec HostRequestSpec
if err := json.Unmarshal(requestBytes, &spec); err != nil {
return hostHTTPResponse{
Error: &PluginError{Code: "invalid_request", Message: "invalid host request: " + err.Error()},
}
}
executed, err := ExecuteHostRequest(ctx, manifest, policy, spec, HostRequestOptions{})
if err != nil {
return hostHTTPResponse{
Error: &PluginError{Code: "provider_unavailable", Message: err.Error()},
}
}
return hostHTTPResponse{
Status: executed.Status,
HeaderValues: executed.HeaderValues,
BodyBase64: base64.StdEncoding.EncodeToString(executed.Body),
}
}
func writeHostHTTPResponse(ctx context.Context, plugin *extism.CurrentPlugin, stack []uint64, response hostHTTPResponse) {
responseBytes, err := json.Marshal(response)
if err != nil {
responseBytes, _ = json.Marshal(hostHTTPResponse{
Error: &PluginError{Code: "internal_error", Message: err.Error()},
})
}
offset, err := plugin.WriteBytes(responseBytes)
if err != nil {
plugin.Log(extism.LogLevelError, "write host http response: "+err.Error())
stack[0] = 0
return
}
stack[0] = offset
_ = ctx
}
// ExecuteHostRequest is the single network chokepoint for plugin-controlled
// HTTP. It validates manifest policy, builds optional request bodies, enforces
// upload/response limits, follows only permitted redirects, and returns the
// bounded provider response.
func ExecuteHostRequest(ctx context.Context, manifest Manifest, policy RequestPolicyContext, spec HostRequestSpec, options HostRequestOptions) (HostResponse, error) {
if err := InjectHostRequestAuthFromPolicy(manifest, policy.HostAuth, &spec); err != nil {
return HostResponse{}, err
}
resolved, err := ValidateAndResolveHostRequestSpec(manifest, spec, policy)
if err != nil {
return HostResponse{}, err
}
body, contentType, bodySize, err := hostRequestBody(spec, options)
if err != nil {
return HostResponse{}, err
}
if err := validateHostRequestUpload(manifest, spec, contentType, bodySize); err != nil {
return HostResponse{}, err
}
req, err := http.NewRequestWithContext(ctx, spec.Method, resolved.URL.String(), body)
if err != nil {
return HostResponse{}, err
}
for key, value := range spec.Headers {
req.Header.Set(key, value)
}
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
if req.Header.Get("Accept") == "" {
req.Header.Set("Accept", "application/json")
}
client, err := newConnectorHTTPClient(util.ConnectorHTTPPolicy{
BaseURL: resolved.Connector.BaseURL,
AllowPrivate: resolved.Connector.AllowPrivate,
TLSMode: resolved.Connector.TLS.Mode,
TLSCABundle: resolved.Connector.TLS.CABundle,
}, func(req *http.Request, via []*http.Request) error {
if spec.FollowRedirects != nil && !*spec.FollowRedirects {
return http.ErrUseLastResponse
}
if len(via) >= 10 {
return fmt.Errorf("too many redirects")
}
previous := resolved.URL
if len(via) > 0 {
previous = via[len(via)-1].URL
}
return ValidateConnectorRedirect(resolved.Connector, previous, req.URL)
})
if err != nil {
return HostResponse{}, err
}
resp, err := client.Do(req)
if err != nil {
return HostResponse{}, err
}
defer resp.Body.Close()
if err := validateHostHTTPResponse(manifest, spec, resp); err != nil {
return HostResponse{}, err
}
maxBytes := effectiveResponseMaxBytes(manifest, spec)
limit := maxBytes
if limit <= 0 {
limit = 1 << 20
}
bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
if err != nil {
return HostResponse{}, err
}
if maxBytes > 0 && int64(len(bodyBytes)) > maxBytes {
return HostResponse{}, fmt.Errorf("provider response exceeds maximum size")
}
if maxBytes <= 0 && int64(len(bodyBytes)) > limit {
return HostResponse{}, fmt.Errorf("provider response exceeds default maximum size")
}
headerValues := map[string][]string{}
for key, values := range resp.Header {
if len(values) > 0 {
headerValues[key] = append([]string{}, values...)
}
}
return HostResponse{
Status: resp.StatusCode,
HeaderValues: headerValues,
Body: bodyBytes,
}, nil
}
func hostRequestBody(spec HostRequestSpec, options HostRequestOptions) (io.Reader, string, int64, error) {
if spec.Body == nil {
return nil, "", 0, nil
}
switch spec.Body.Type {
case HostRequestBodyTypeJSON:
body, err := json.Marshal(spec.Body.JSON)
if err != nil {
return nil, "", 0, err
}
return bytes.NewReader(body), "application/json", int64(len(body)), nil
case HostRequestBodyTypeForm:
body, err := formURLEncodedBody(spec.Body.Form)
if err != nil {
return nil, "", 0, err
}
return strings.NewReader(body), "application/x-www-form-urlencoded", int64(len(body)), nil
case HostRequestBodyTypeMultipart:
var body bytes.Buffer
writer := multipart.NewWriter(&body)
for _, part := range spec.Body.Parts {
if part.Source == MultipartSourceTrail || part.Source == MultipartSourceTrailGPX {
if len(options.Trail) == 0 {
return nil, "", 0, fmt.Errorf("multipart part %q requires trail content", part.Name)
}
filename := part.Filename
if filename == "" {
filename = MultipartTrailFilename
}
partWriter, err := writer.CreateFormFile(part.Name, filename)
if err != nil {
return nil, "", 0, err
}
if _, err := partWriter.Write(options.Trail); err != nil {
return nil, "", 0, err
}
continue
}
if part.JSON != nil {
data, err := json.Marshal(part.JSON)
if err != nil {
return nil, "", 0, err
}
if err := writer.WriteField(part.Name, string(data)); err != nil {
return nil, "", 0, err
}
}
}
if err := writer.Close(); err != nil {
return nil, "", 0, err
}
return &body, writer.FormDataContentType(), int64(body.Len()), nil
default:
return nil, "", 0, fmt.Errorf("unsupported host request body type %q", spec.Body.Type)
}
}
func formURLEncodedBody(fields []FormField) (string, error) {
encoded := make([]string, 0, len(fields))
for _, field := range fields {
if field.Name == "" {
return "", fmt.Errorf("form field name must not be empty")
}
if hasControl(field.Name) || hasControl(field.Value) {
return "", fmt.Errorf("form fields must not contain control characters")
}
encoded = append(encoded, url.QueryEscape(field.Name)+"="+url.QueryEscape(field.Value))
}
return strings.Join(encoded, "&"), nil
}
func validateHostRequestUpload(manifest Manifest, spec HostRequestSpec, contentType string, bodySize int64) error {
if spec.Body == nil {
return nil
}
if manifest.Permissions.Uploads.MaxBytes > 0 && bodySize > manifest.Permissions.Uploads.MaxBytes {
return fmt.Errorf("host request upload exceeds manifest upload limit")
}
if contentType == "" || len(manifest.Permissions.Uploads.ContentTypes) == 0 {
return nil
}
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil {
return fmt.Errorf("host request upload has invalid content type")
}
for _, allowed := range manifest.Permissions.Uploads.ContentTypes {
if strings.EqualFold(mediaType, allowed) {
return nil
}
}
return fmt.Errorf("host request upload content type %q is not allowed", mediaType)
}
func validateHostHTTPResponse(manifest Manifest, spec HostRequestSpec, resp *http.Response) error {
allowedContentTypes := effectiveResponseContentTypes(manifest, spec)
if resp.StatusCode >= 200 && resp.StatusCode < 300 && len(allowedContentTypes) > 0 {
contentType := resp.Header.Get("Content-Type")
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil || mediaType == "" {
return fmt.Errorf("provider response has invalid content type")
}
allowed := false
for _, expected := range allowedContentTypes {
if strings.EqualFold(mediaType, expected) {
allowed = true
break
}
}
if !allowed {
return fmt.Errorf("provider response content type %q is not allowed", mediaType)
}
}
maxBytes := effectiveResponseMaxBytes(manifest, spec)
if maxBytes > 0 && resp.ContentLength > maxBytes {
return fmt.Errorf("provider response exceeds maximum size")
}
return nil
}
func effectiveResponseContentTypes(manifest Manifest, spec HostRequestSpec) []string {
if len(spec.Expect.ContentTypes) > 0 {
return spec.Expect.ContentTypes
}
return manifest.Permissions.Downloads.ContentTypes
}
func effectiveResponseMaxBytes(manifest Manifest, spec HostRequestSpec) int64 {
if spec.Expect.MaxBytes > 0 {
return spec.Expect.MaxBytes
}
return manifest.Permissions.Downloads.MaxBytes
}