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>
This commit is contained in:
38
db/pluginsystem/auth_fields.go
Normal file
38
db/pluginsystem/auth_fields.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package pluginsystem
|
||||
|
||||
const (
|
||||
AuthFieldAccessToken = "accessToken"
|
||||
AuthFieldRefreshToken = "refreshToken"
|
||||
AuthFieldClientSecret = "clientSecret"
|
||||
AuthFieldOAuthState = "oauthState"
|
||||
AuthFieldOAuthCodeVerifier = "oauthCodeVerifier"
|
||||
AuthFieldOAuthRedirectURI = "oauthRedirectURI"
|
||||
)
|
||||
|
||||
func InternalAuthSecretFields() []string {
|
||||
return []string{
|
||||
AuthFieldAccessToken,
|
||||
AuthFieldRefreshToken,
|
||||
AuthFieldClientSecret,
|
||||
AuthFieldOAuthState,
|
||||
AuthFieldOAuthCodeVerifier,
|
||||
}
|
||||
}
|
||||
|
||||
func InternalOAuthTransientFields() []string {
|
||||
return []string{
|
||||
AuthFieldOAuthState,
|
||||
AuthFieldOAuthCodeVerifier,
|
||||
AuthFieldOAuthRedirectURI,
|
||||
}
|
||||
}
|
||||
|
||||
func PluginInputAuthBlockedFields() []string {
|
||||
return []string{
|
||||
AuthFieldRefreshToken,
|
||||
AuthFieldClientSecret,
|
||||
AuthFieldOAuthState,
|
||||
AuthFieldOAuthCodeVerifier,
|
||||
AuthFieldOAuthRedirectURI,
|
||||
}
|
||||
}
|
||||
349
db/pluginsystem/auth_injection.go
Normal file
349
db/pluginsystem/auth_injection.go
Normal file
@@ -0,0 +1,349 @@
|
||||
package pluginsystem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
type AuthInjectionInput struct {
|
||||
App core.App
|
||||
Runtime Runtime
|
||||
Session RuntimeSession
|
||||
Plugin LocalPlugin
|
||||
Instance *core.Record
|
||||
Auth map[string]any
|
||||
Config map[string]any
|
||||
Spec *HostRequestSpec
|
||||
Policy RequestPolicyContext
|
||||
}
|
||||
|
||||
func InjectRequestAuthForContext(manifest Manifest, auth map[string]any, contextName string, req *http.Request) error {
|
||||
if contextName == "" {
|
||||
return nil
|
||||
}
|
||||
if err := ValidateAuthReference(manifest, contextName); err != nil {
|
||||
return err
|
||||
}
|
||||
authContext, ok := manifest.Auth.Contexts[contextName]
|
||||
if !ok {
|
||||
return fmt.Errorf("plugin requested unknown auth context")
|
||||
}
|
||||
switch authContext.Type {
|
||||
case AuthTypeOAuth2:
|
||||
token := StringFromAny(auth[AuthFieldAccessToken])
|
||||
if token == "" {
|
||||
return fmt.Errorf("oauth access token is missing")
|
||||
}
|
||||
scheme := StringFromAny(auth[AuthFieldTokenType])
|
||||
if scheme == "" {
|
||||
scheme = AuthSchemeBearer
|
||||
}
|
||||
req.Header.Set(AuthHeaderAuthorization, scheme+" "+token)
|
||||
case AuthTypeAPIKey:
|
||||
secret := StringFromAny(auth[authContext.SecretField])
|
||||
if secret == "" {
|
||||
return fmt.Errorf("api key is missing")
|
||||
}
|
||||
name := authContext.Name
|
||||
if name == "" {
|
||||
name = authContext.SecretField
|
||||
}
|
||||
if authContext.Placement == AuthPlacementQuery {
|
||||
req.URL.RawQuery = setRawQueryParamOrdered(req.URL.RawQuery, name, secret)
|
||||
} else {
|
||||
req.Header.Set(name, secret)
|
||||
}
|
||||
case AuthTypeBearer:
|
||||
secret := StringFromAny(auth[authContext.SecretField])
|
||||
if secret == "" {
|
||||
return fmt.Errorf("bearer token is missing")
|
||||
}
|
||||
req.Header.Set(AuthHeaderAuthorization, AuthSchemeBearer+" "+secret)
|
||||
default:
|
||||
return fmt.Errorf("auth context %q is not supported for media requests", contextName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func InjectHostRequestAuthFromPolicy(manifest Manifest, auth map[string]any, spec *HostRequestSpec) error {
|
||||
if spec == nil || spec.Auth == "" {
|
||||
return nil
|
||||
}
|
||||
if err := ValidateAuthReference(manifest, spec.Auth); err != nil {
|
||||
return err
|
||||
}
|
||||
authContext, ok := manifest.Auth.Contexts[spec.Auth]
|
||||
if !ok {
|
||||
return fmt.Errorf("plugin requested unknown auth context")
|
||||
}
|
||||
switch authContext.Type {
|
||||
case AuthTypeOAuth2:
|
||||
token := StringFromAny(auth[AuthFieldAccessToken])
|
||||
if token == "" {
|
||||
return fmt.Errorf("oauth access token is missing")
|
||||
}
|
||||
scheme := StringFromAny(auth[AuthFieldTokenType])
|
||||
if scheme == "" {
|
||||
scheme = AuthSchemeBearer
|
||||
}
|
||||
setAuthHeader(spec, scheme+" "+token)
|
||||
case AuthTypeAPIKey:
|
||||
return injectAPIKeyAuth(authContext, auth, spec)
|
||||
case AuthTypeBearer:
|
||||
return injectBearerAuth(authContext, auth, spec)
|
||||
case AuthTypeSession:
|
||||
return fmt.Errorf("session auth requires handler-managed injection")
|
||||
default:
|
||||
return fmt.Errorf("auth context is not supported for host requests")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type pluginSessionResponse struct {
|
||||
Token string `json:"token"`
|
||||
Scheme string `json:"scheme,omitempty"`
|
||||
Expires string `json:"expiresAt,omitempty"`
|
||||
}
|
||||
|
||||
// ValidateAuthContext checks that a manifest auth context contains enough data
|
||||
// for the host to own OAuth/API key/session injection safely.
|
||||
func ValidateAuthContext(name string, context AuthContext) error {
|
||||
switch context.Type {
|
||||
case AuthTypeOAuth2:
|
||||
if context.AuthorizationURL == "" || context.TokenURL == "" {
|
||||
return fmt.Errorf("oauth2 auth context %s requires authorizationUrl and tokenUrl", name)
|
||||
}
|
||||
if _, err := url.ParseRequestURI(context.AuthorizationURL); err != nil {
|
||||
return fmt.Errorf("auth context %s authorizationUrl: %w", name, err)
|
||||
}
|
||||
if _, err := url.ParseRequestURI(context.TokenURL); err != nil {
|
||||
return fmt.Errorf("auth context %s tokenUrl: %w", name, err)
|
||||
}
|
||||
if context.Refresh == nil || context.Refresh.Mode != AuthRefreshModeHost {
|
||||
return fmt.Errorf("oauth2 auth context %s must use host refresh", name)
|
||||
}
|
||||
case AuthTypeAPIKey, AuthTypeBearer:
|
||||
if context.SecretField == "" {
|
||||
return fmt.Errorf("%s auth context %s requires secretField", context.Type, name)
|
||||
}
|
||||
case AuthTypeSession:
|
||||
if context.Refresh == nil || context.Refresh.Mode != AuthRefreshModePlugin || context.Refresh.Function == "" {
|
||||
return fmt.Errorf("session auth context %s requires plugin refresh function", name)
|
||||
}
|
||||
if len(context.SecretFields) == 0 {
|
||||
return fmt.Errorf("session auth context %s requires secretFields", name)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("auth context %s has unsupported type %q", name, context.Type)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InjectHostRequestAuth resolves the auth reference from a HostRequestSpec and
|
||||
// mutates the request with the provider-specific header/query/session token.
|
||||
func InjectHostRequestAuth(ctx context.Context, input AuthInjectionInput) error {
|
||||
if input.Spec == nil {
|
||||
return fmt.Errorf("host request spec is required")
|
||||
}
|
||||
if input.Spec.Auth == "" {
|
||||
return nil
|
||||
}
|
||||
if err := ValidateAuthReference(input.Plugin.Manifest, input.Spec.Auth); err != nil {
|
||||
return err
|
||||
}
|
||||
authContext, ok := input.Plugin.Manifest.Auth.Contexts[input.Spec.Auth]
|
||||
if !ok {
|
||||
return fmt.Errorf("plugin requested unknown auth context")
|
||||
}
|
||||
|
||||
switch authContext.Type {
|
||||
case AuthTypeOAuth2:
|
||||
return injectOAuthAuth(ctx, input, input.Spec.Auth)
|
||||
case AuthTypeAPIKey:
|
||||
return injectAPIKeyAuth(authContext, input.Auth, input.Spec)
|
||||
case AuthTypeBearer:
|
||||
return injectBearerAuth(authContext, input.Auth, input.Spec)
|
||||
case AuthTypeSession:
|
||||
return injectSessionAuth(ctx, input, authContext)
|
||||
default:
|
||||
return fmt.Errorf("auth context is not supported for route sending")
|
||||
}
|
||||
}
|
||||
|
||||
func injectOAuthAuth(ctx context.Context, input AuthInjectionInput, contextName string) error {
|
||||
if input.Instance == nil {
|
||||
return fmt.Errorf("plugin instance is required")
|
||||
}
|
||||
auth := input.Auth
|
||||
if OAuthNeedsRefresh(auth) {
|
||||
refreshed, err := RefreshOAuthToken(ctx, input.App, input.Plugin, input.Instance, auth, contextName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("oauth token refresh failed: %w", err)
|
||||
}
|
||||
auth = refreshed
|
||||
}
|
||||
token := StringFromAny(auth[AuthFieldAccessToken])
|
||||
if token == "" {
|
||||
return fmt.Errorf("oauth access token is missing")
|
||||
}
|
||||
scheme := StringFromAny(auth[AuthFieldTokenType])
|
||||
if scheme == "" {
|
||||
scheme = AuthSchemeBearer
|
||||
}
|
||||
setAuthHeader(input.Spec, scheme+" "+token)
|
||||
return nil
|
||||
}
|
||||
|
||||
func injectAPIKeyAuth(authContext AuthContext, auth map[string]any, spec *HostRequestSpec) error {
|
||||
secret := StringFromAny(auth[authContext.SecretField])
|
||||
if secret == "" {
|
||||
return fmt.Errorf("api key is missing")
|
||||
}
|
||||
if authContext.Placement == AuthPlacementQuery {
|
||||
name := authContext.Name
|
||||
if name == "" {
|
||||
name = authContext.SecretField
|
||||
}
|
||||
query := make([]QueryParam, 0, len(spec.Target.Query)+1)
|
||||
for _, param := range spec.Target.Query {
|
||||
if param.Name != name {
|
||||
query = append(query, param)
|
||||
}
|
||||
}
|
||||
query = append(query, QueryParam{Name: name, Value: secret})
|
||||
spec.Target.Query = query
|
||||
return nil
|
||||
}
|
||||
name := authContext.Name
|
||||
if name == "" {
|
||||
name = AuthHeaderAuthorization
|
||||
}
|
||||
if spec.Headers == nil {
|
||||
spec.Headers = map[string]string{}
|
||||
}
|
||||
spec.Headers[name] = secret
|
||||
return nil
|
||||
}
|
||||
|
||||
func injectBearerAuth(authContext AuthContext, auth map[string]any, spec *HostRequestSpec) error {
|
||||
secret := StringFromAny(auth[authContext.SecretField])
|
||||
if secret == "" {
|
||||
return fmt.Errorf("bearer token is missing")
|
||||
}
|
||||
setAuthHeader(spec, AuthSchemeBearer+" "+secret)
|
||||
return nil
|
||||
}
|
||||
|
||||
func injectSessionAuth(ctx context.Context, input AuthInjectionInput, authContext AuthContext) error {
|
||||
if authContext.Refresh == nil || authContext.Refresh.Mode != AuthRefreshModePlugin {
|
||||
return fmt.Errorf("session auth context is not supported")
|
||||
}
|
||||
if input.Instance == nil {
|
||||
return fmt.Errorf("plugin instance is required")
|
||||
}
|
||||
|
||||
pluginInput := map[string]any{
|
||||
"instance": InstanceRef{
|
||||
ID: input.Instance.Id,
|
||||
PluginID: input.Instance.GetString("plugin_id"),
|
||||
},
|
||||
"auth": AuthForPluginRefresh(input.Auth, authContext),
|
||||
"config": input.Config,
|
||||
}
|
||||
inputBytes, err := json.Marshal(pluginInput)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var output []byte
|
||||
if input.Session != nil {
|
||||
output, err = input.Session.Call(ctx, authContext.Refresh.Function, inputBytes)
|
||||
} else {
|
||||
output, err = input.Runtime.Call(ctx, input.Plugin, authContext.Refresh.Function, inputBytes, input.Policy)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var session pluginSessionResponse
|
||||
if err := validatePluginSessionRefreshOutput(output, &session); err != nil {
|
||||
return err
|
||||
}
|
||||
scheme := session.Scheme
|
||||
if scheme == "" {
|
||||
scheme = AuthSchemeBearer
|
||||
}
|
||||
setAuthHeader(input.Spec, scheme+" "+session.Token)
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidatePluginSessionRefreshOutput(output []byte) error {
|
||||
var session pluginSessionResponse
|
||||
return validatePluginSessionRefreshOutput(output, &session)
|
||||
}
|
||||
|
||||
func validatePluginSessionRefreshOutput(output []byte, session *pluginSessionResponse) error {
|
||||
if err := json.Unmarshal(output, session); err != nil {
|
||||
return fmt.Errorf("plugin returned an invalid session: %w", err)
|
||||
}
|
||||
if session.Token == "" {
|
||||
return fmt.Errorf("plugin returned an empty session token")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setAuthHeader(spec *HostRequestSpec, value string) {
|
||||
if spec.Headers == nil {
|
||||
spec.Headers = map[string]string{}
|
||||
}
|
||||
spec.Headers[AuthHeaderAuthorization] = value
|
||||
}
|
||||
|
||||
func setRawQueryParamOrdered(rawQuery string, name string, value string) string {
|
||||
encoded := url.QueryEscape(name) + "=" + url.QueryEscape(value)
|
||||
if rawQuery == "" {
|
||||
return encoded
|
||||
}
|
||||
parts := strings.Split(rawQuery, "&")
|
||||
kept := make([]string, 0, len(parts)+1)
|
||||
for _, part := range parts {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
rawName := part
|
||||
if idx := strings.Index(rawName, "="); idx >= 0 {
|
||||
rawName = rawName[:idx]
|
||||
}
|
||||
decodedName, err := url.QueryUnescape(rawName)
|
||||
if err == nil && decodedName == name {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, part)
|
||||
}
|
||||
kept = append(kept, encoded)
|
||||
return strings.Join(kept, "&")
|
||||
}
|
||||
|
||||
func AuthForPluginRefresh(auth map[string]any, authContext AuthContext) map[string]any {
|
||||
filtered := map[string]any{}
|
||||
for _, field := range authContext.Fields {
|
||||
if value, ok := auth[field]; ok {
|
||||
filtered[field] = value
|
||||
}
|
||||
}
|
||||
for _, field := range authContext.SecretFields {
|
||||
if value, ok := auth[field]; ok {
|
||||
filtered[field] = value
|
||||
}
|
||||
}
|
||||
if authContext.SecretField != "" {
|
||||
if value, ok := auth[authContext.SecretField]; ok {
|
||||
filtered[authContext.SecretField] = value
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
288
db/pluginsystem/auth_injection_test.go
Normal file
288
db/pluginsystem/auth_injection_test.go
Normal file
@@ -0,0 +1,288 @@
|
||||
package pluginsystem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateAuthContext(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
context AuthContext
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "oauth2",
|
||||
context: AuthContext{
|
||||
Type: AuthTypeOAuth2,
|
||||
AuthorizationURL: "https://example.com/oauth/authorize",
|
||||
TokenURL: "https://example.com/oauth/token",
|
||||
Refresh: &AuthRefresh{Mode: AuthRefreshModeHost},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing bearer secret",
|
||||
context: AuthContext{Type: AuthTypeBearer},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "session",
|
||||
context: AuthContext{
|
||||
Type: AuthTypeSession,
|
||||
SecretFields: []string{"email", "password"},
|
||||
Refresh: &AuthRefresh{Mode: AuthRefreshModePlugin, Function: "refresh_session_v1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unsupported",
|
||||
context: AuthContext{Type: "mtls"},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := ValidateAuthContext("default", test.context)
|
||||
if test.wantErr && err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !test.wantErr && err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectHostRequestAuthWithBearer(t *testing.T) {
|
||||
spec := HostRequestSpec{Auth: "account"}
|
||||
|
||||
err := InjectHostRequestAuth(context.Background(), AuthInjectionInput{
|
||||
Plugin: LocalPlugin{Manifest: Manifest{
|
||||
Auth: AuthManifest{Contexts: map[string]AuthContext{
|
||||
"account": {
|
||||
Type: AuthTypeBearer,
|
||||
SecretField: "token",
|
||||
},
|
||||
}},
|
||||
Permissions: PermissionManifest{Auth: []string{"account"}},
|
||||
}},
|
||||
Auth: map[string]any{"token": "abc123"},
|
||||
Spec: &spec,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := spec.Headers[AuthHeaderAuthorization]; got != AuthSchemeBearer+" abc123" {
|
||||
t.Fatalf("unexpected authorization header: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectHostRequestAuthWithAPIKeyQuery(t *testing.T) {
|
||||
spec := HostRequestSpec{
|
||||
Auth: "account",
|
||||
Target: RequestTarget{
|
||||
Type: "connector",
|
||||
Connector: "api",
|
||||
Path: "/upload",
|
||||
Query: []QueryParam{{Name: "existing", Value: "true"}},
|
||||
},
|
||||
}
|
||||
|
||||
err := InjectHostRequestAuth(context.Background(), AuthInjectionInput{
|
||||
Plugin: LocalPlugin{Manifest: Manifest{
|
||||
Auth: AuthManifest{Contexts: map[string]AuthContext{
|
||||
"account": {
|
||||
Type: AuthTypeAPIKey,
|
||||
SecretField: "apiKey",
|
||||
Placement: AuthPlacementQuery,
|
||||
Name: "key",
|
||||
},
|
||||
}},
|
||||
Permissions: PermissionManifest{Auth: []string{"account"}},
|
||||
}},
|
||||
Auth: map[string]any{"apiKey": "secret"},
|
||||
Spec: &spec,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(spec.Target.Query) != 2 || spec.Target.Query[1].Name != "key" || spec.Target.Query[1].Value != "secret" {
|
||||
t.Fatalf("unexpected query: %#v", spec.Target.Query)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectHostRequestAuthFromPolicyWithBearer(t *testing.T) {
|
||||
spec := HostRequestSpec{
|
||||
Auth: "account",
|
||||
Headers: map[string]string{AuthHeaderAuthorization: "plugin supplied"},
|
||||
}
|
||||
manifest := Manifest{
|
||||
Auth: AuthManifest{Contexts: map[string]AuthContext{
|
||||
"account": {Type: AuthTypeBearer, SecretField: "token"},
|
||||
}},
|
||||
Permissions: PermissionManifest{Auth: []string{"account"}},
|
||||
}
|
||||
|
||||
err := InjectHostRequestAuthFromPolicy(manifest, map[string]any{"token": "host-secret"}, &spec)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := spec.Headers[AuthHeaderAuthorization]; got != AuthSchemeBearer+" host-secret" {
|
||||
t.Fatalf("unexpected authorization header: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectHostRequestAuthFromPolicyFailsWithEmptyAuth(t *testing.T) {
|
||||
spec := HostRequestSpec{
|
||||
Auth: "account",
|
||||
Headers: map[string]string{AuthHeaderAuthorization: "plugin supplied"},
|
||||
}
|
||||
manifest := Manifest{
|
||||
Auth: AuthManifest{Contexts: map[string]AuthContext{
|
||||
"account": {Type: AuthTypeBearer, SecretField: "token"},
|
||||
}},
|
||||
Permissions: PermissionManifest{Auth: []string{"account"}},
|
||||
}
|
||||
|
||||
err := InjectHostRequestAuthFromPolicy(manifest, map[string]any{}, &spec)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if err.Error() != "bearer token is missing" {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := spec.Headers[AuthHeaderAuthorization]; got != "plugin supplied" {
|
||||
t.Fatalf("unexpected authorization header mutation: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectHostRequestAuthFromPolicyRejectsSessionAuth(t *testing.T) {
|
||||
spec := HostRequestSpec{Auth: "account"}
|
||||
manifest := Manifest{
|
||||
Auth: AuthManifest{Contexts: map[string]AuthContext{
|
||||
"account": {
|
||||
Type: AuthTypeSession,
|
||||
SecretFields: []string{"password"},
|
||||
Refresh: &AuthRefresh{Mode: AuthRefreshModePlugin, Function: "refresh_session_v1"},
|
||||
},
|
||||
}},
|
||||
Permissions: PermissionManifest{Auth: []string{"account"}},
|
||||
}
|
||||
|
||||
err := InjectHostRequestAuthFromPolicy(manifest, map[string]any{"password": "secret"}, &spec)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if err.Error() != "session auth requires handler-managed injection" {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectHostRequestAuthFromPolicyWithAPIKeyQuery(t *testing.T) {
|
||||
spec := HostRequestSpec{
|
||||
Auth: "account",
|
||||
Target: RequestTarget{
|
||||
Type: "connector",
|
||||
Path: "/assets",
|
||||
Query: []QueryParam{{Name: "api_key", Value: "plugin"}},
|
||||
},
|
||||
}
|
||||
manifest := Manifest{
|
||||
Auth: AuthManifest{Contexts: map[string]AuthContext{
|
||||
"account": {
|
||||
Type: AuthTypeAPIKey,
|
||||
SecretField: "apiKey",
|
||||
Placement: AuthPlacementQuery,
|
||||
Name: "api_key",
|
||||
},
|
||||
}},
|
||||
Permissions: PermissionManifest{Auth: []string{"account"}},
|
||||
}
|
||||
|
||||
err := InjectHostRequestAuthFromPolicy(manifest, map[string]any{"apiKey": "host-secret"}, &spec)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(spec.Target.Query) != 1 || spec.Target.Query[0].Value != "host-secret" {
|
||||
t.Fatalf("unexpected query: %#v", spec.Target.Query)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectHostRequestAuthRequiresSpec(t *testing.T) {
|
||||
err := InjectHostRequestAuth(context.Background(), AuthInjectionInput{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if err.Error() != "host request spec is required" {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectHostRequestAuthValidatesPermission(t *testing.T) {
|
||||
spec := HostRequestSpec{Auth: "account"}
|
||||
err := InjectHostRequestAuth(context.Background(), AuthInjectionInput{
|
||||
Plugin: LocalPlugin{Manifest: Manifest{
|
||||
Auth: AuthManifest{Contexts: map[string]AuthContext{
|
||||
"account": {Type: AuthTypeBearer, SecretField: "token"},
|
||||
}},
|
||||
}},
|
||||
Auth: map[string]any{"token": "abc123"},
|
||||
Spec: &spec,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if err.Error() != `auth context "account" is not permitted` {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectRequestAuthForContextPreservesQueryOrder(t *testing.T) {
|
||||
req, err := http.NewRequest(http.MethodGet, "https://example.test/media?z=last&api_key=plugin&a=first", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifest := Manifest{
|
||||
Auth: AuthManifest{Contexts: map[string]AuthContext{
|
||||
"account": {
|
||||
Type: AuthTypeAPIKey,
|
||||
SecretField: "apiKey",
|
||||
Placement: AuthPlacementQuery,
|
||||
Name: "api_key",
|
||||
},
|
||||
}},
|
||||
Permissions: PermissionManifest{Auth: []string{"account"}},
|
||||
}
|
||||
|
||||
err = InjectRequestAuthForContext(manifest, map[string]any{"apiKey": "host-secret"}, "account", req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if req.URL.RawQuery != "z=last&a=first&api_key=host-secret" {
|
||||
t.Fatalf("unexpected raw query: %q", req.URL.RawQuery)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthForPluginRefresh(t *testing.T) {
|
||||
filtered := AuthForPluginRefresh(map[string]any{
|
||||
"email": "user@example.com",
|
||||
"password": "secret",
|
||||
"accessToken": "token",
|
||||
}, AuthContext{
|
||||
Fields: []string{"email", "password"},
|
||||
SecretFields: []string{"password"},
|
||||
})
|
||||
|
||||
if len(filtered) != 2 {
|
||||
t.Fatalf("unexpected filtered auth: %#v", filtered)
|
||||
}
|
||||
if filtered["email"] != "user@example.com" || filtered["password"] != "secret" {
|
||||
t.Fatalf("unexpected filtered auth: %#v", filtered)
|
||||
}
|
||||
if _, ok := filtered["accessToken"]; ok {
|
||||
t.Fatalf("unexpected access token in plugin refresh auth: %#v", filtered)
|
||||
}
|
||||
}
|
||||
410
db/pluginsystem/host_http.go
Normal file
410
db/pluginsystem/host_http.go
Normal file
@@ -0,0 +1,410 @@
|
||||
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
|
||||
}
|
||||
407
db/pluginsystem/host_http_test.go
Normal file
407
db/pluginsystem/host_http_test.go
Normal file
@@ -0,0 +1,407 @@
|
||||
package pluginsystem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"pocketbase/util"
|
||||
)
|
||||
|
||||
func TestParseHostLogEntry(t *testing.T) {
|
||||
payload, err := json.Marshal(HostLogEntry{Level: "warn", Message: " slow request "})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry, err := parseHostLogEntry(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if entry.Level != "warn" || entry.Message != "slow request" {
|
||||
t.Fatalf("unexpected structured entry: %#v", entry)
|
||||
}
|
||||
|
||||
if _, err := parseHostLogEntry([]byte(" plain message ")); err == nil {
|
||||
t.Fatal("expected plain log message to fail")
|
||||
}
|
||||
|
||||
if _, err := parseHostLogEntry([]byte(`{"level":"verbose","message":"hello"}`)); err == nil {
|
||||
t.Fatal("expected unsupported log level to fail")
|
||||
}
|
||||
|
||||
if _, err := parseHostLogEntry([]byte(`{"level":"info","message":" "}`)); err == nil {
|
||||
t.Fatal("expected empty log message to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHostLogEntrySanitizesMessage(t *testing.T) {
|
||||
entry, err := parseHostLogEntry([]byte(`{"level":"info","message":"first\nsecond\rthird\tfourth"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if entry.Message != "first second third fourth" {
|
||||
t.Fatalf("unexpected sanitized message: %q", entry.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHostLogEntryRejectsOversizedPayload(t *testing.T) {
|
||||
payload := []byte(`{"level":"info","message":"` + strings.Repeat("x", maxHostLogPayloadBytes) + `"}`)
|
||||
if _, err := parseHostLogEntry(payload); err == nil {
|
||||
t.Fatal("expected oversized log payload to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteHostRequestRejectsRedirectToUndeclaredHost(t *testing.T) {
|
||||
useUnsafeTestHTTPClient(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "https://evil.example.test/v1/upload", http.StatusFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := ExecuteHostRequest(context.Background(), testHostManifest(t, server.URL), testHostPolicy(t, server.URL), HostRequestSpec{
|
||||
Method: "GET",
|
||||
Target: RequestTarget{
|
||||
Type: "connector",
|
||||
Connector: "api",
|
||||
Path: "/v1",
|
||||
},
|
||||
}, HostRequestOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected redirect policy error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteHostRequestRejectsRedirectOutsidePathScope(t *testing.T) {
|
||||
useUnsafeTestHTTPClient(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/admin", http.StatusFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := ExecuteHostRequest(context.Background(), testHostManifest(t, server.URL), testHostPolicy(t, server.URL), HostRequestSpec{
|
||||
Method: "GET",
|
||||
Target: RequestTarget{Type: "connector", Connector: "api", Path: "/v1"},
|
||||
}, HostRequestOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected redirect policy error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteHostRequestEnforcesResponseLimit(t *testing.T) {
|
||||
useUnsafeTestHTTPClient(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"too":"large"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := ExecuteHostRequest(context.Background(), testHostManifest(t, server.URL), testHostPolicy(t, server.URL), HostRequestSpec{
|
||||
Method: "GET",
|
||||
Target: RequestTarget{Type: "connector", Connector: "api", Path: "/v1"},
|
||||
Expect: ResponseExpect{
|
||||
ContentTypes: []string{"application/json"},
|
||||
MaxBytes: 4,
|
||||
},
|
||||
}, HostRequestOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected maxBytes error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteHostRequestAllowsErrorResponseWithoutContentType(t *testing.T) {
|
||||
useUnsafeTestHTTPClient(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`missing credentials`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
resp, err := ExecuteHostRequest(context.Background(), testHostManifest(t, server.URL), testHostPolicy(t, server.URL), HostRequestSpec{
|
||||
Method: "GET",
|
||||
Target: RequestTarget{Type: "connector", Connector: "api", Path: "/v1"},
|
||||
Expect: ResponseExpect{
|
||||
ContentTypes: []string{"application/json"},
|
||||
MaxBytes: 1024,
|
||||
},
|
||||
}, HostRequestOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.Status != http.StatusUnauthorized || string(resp.Body) != "missing credentials" {
|
||||
t.Fatalf("unexpected response: %#v body=%q", resp, string(resp.Body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteHostRequestInjectsAPIKeyQueryBeforeBuildingURL(t *testing.T) {
|
||||
useUnsafeTestHTTPClient(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.URL.Query().Get("api_key"); got != "host-secret" {
|
||||
t.Fatalf("api_key = %q, want host-secret; raw query %q", got, r.URL.RawQuery)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
manifest := testHostManifest(t, server.URL)
|
||||
manifest.Auth = AuthManifest{Contexts: map[string]AuthContext{
|
||||
"account": {
|
||||
Type: AuthTypeAPIKey,
|
||||
SecretField: "apiKey",
|
||||
Placement: AuthPlacementQuery,
|
||||
Name: "api_key",
|
||||
},
|
||||
}}
|
||||
manifest.Permissions.Auth = []string{"account"}
|
||||
manifest.Permissions.Network.Connectors[0].Auth = []string{"account"}
|
||||
policy := testHostPolicy(t, server.URL).WithHostAuth(map[string]any{"apiKey": "host-secret"})
|
||||
policy.Connectors["api"] = ResolvedConnectorTarget{
|
||||
Name: "api",
|
||||
Type: ConnectorTypePublicAPI,
|
||||
BaseURL: policy.Connectors["api"].BaseURL,
|
||||
BasePath: "/",
|
||||
AllowPrivate: true,
|
||||
AllowedPathPrefixes: []string{"/v1"},
|
||||
Auth: []string{"account"},
|
||||
}
|
||||
|
||||
resp, err := ExecuteHostRequest(context.Background(), manifest, policy, HostRequestSpec{
|
||||
Method: "GET",
|
||||
Auth: "account",
|
||||
Target: RequestTarget{
|
||||
Type: "connector",
|
||||
Connector: "api",
|
||||
Path: "/v1",
|
||||
Query: []QueryParam{{Name: "existing", Value: "1"}},
|
||||
},
|
||||
Expect: ResponseExpect{
|
||||
ContentTypes: []string{"application/json"},
|
||||
MaxBytes: 1024,
|
||||
},
|
||||
}, HostRequestOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.Status != http.StatusOK {
|
||||
t.Fatalf("unexpected status %d", resp.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteHostRequestBuildsMultipartTrailSend(t *testing.T) {
|
||||
useUnsafeTestHTTPClient(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if mediaType := strings.Split(r.Header.Get("Content-Type"), ";")[0]; mediaType != "multipart/form-data" {
|
||||
t.Fatalf("unexpected content type %q", r.Header.Get("Content-Type"))
|
||||
}
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
t.Fatalf("expected file part: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
if header.Filename != "My Route.gpx" {
|
||||
t.Fatalf("unexpected filename %q", header.Filename)
|
||||
}
|
||||
data, _ := io.ReadAll(file)
|
||||
if string(data) != "<gpx />" {
|
||||
t.Fatalf("unexpected trail body %q", string(data))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
resp, err := ExecuteHostRequest(context.Background(), testHostManifest(t, server.URL), testHostPolicy(t, server.URL), HostRequestSpec{
|
||||
Method: "POST",
|
||||
Target: RequestTarget{Type: "connector", Connector: "api", Path: "/v1/upload"},
|
||||
Body: &HostRequestBody{
|
||||
Type: HostRequestBodyTypeMultipart,
|
||||
Parts: []MultipartPart{{
|
||||
Name: "file",
|
||||
Source: MultipartSourceTrail,
|
||||
Filename: "My Route.gpx",
|
||||
}},
|
||||
},
|
||||
Expect: ResponseExpect{
|
||||
ContentTypes: []string{"application/json"},
|
||||
MaxBytes: 1024,
|
||||
},
|
||||
}, HostRequestOptions{Trail: []byte("<gpx />")})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.Status != http.StatusOK {
|
||||
t.Fatalf("unexpected status %d", resp.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteHostRequestBuildsFormURLEncodedBody(t *testing.T) {
|
||||
useUnsafeTestHTTPClient(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("Content-Type"); got != "application/x-www-form-urlencoded" {
|
||||
t.Fatalf("unexpected content type %q", got)
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatalf("parse form: %v", err)
|
||||
}
|
||||
if got := r.Form.Get("person[login_identity]"); got != "user@example.test" {
|
||||
t.Fatalf("login_identity = %q", got)
|
||||
}
|
||||
if got := r.Form.Get("person[password]"); got != "secret" {
|
||||
t.Fatalf("password = %q", got)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
manifest := testHostManifest(t, server.URL)
|
||||
manifest.Permissions.Uploads.ContentTypes = append(manifest.Permissions.Uploads.ContentTypes, "application/x-www-form-urlencoded")
|
||||
resp, err := ExecuteHostRequest(context.Background(), manifest, testHostPolicy(t, server.URL), HostRequestSpec{
|
||||
Method: "POST",
|
||||
Target: RequestTarget{Type: "connector", Connector: "api", Path: "/v1/login"},
|
||||
Body: &HostRequestBody{
|
||||
Type: HostRequestBodyTypeForm,
|
||||
Form: []FormField{
|
||||
{Name: "person[login_identity]", Value: "user@example.test"},
|
||||
{Name: "person[password]", Value: "secret"},
|
||||
},
|
||||
},
|
||||
Expect: ResponseExpect{
|
||||
ContentTypes: []string{"application/json"},
|
||||
MaxBytes: 1024,
|
||||
},
|
||||
}, HostRequestOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.Status != http.StatusOK {
|
||||
t.Fatalf("unexpected status %d", resp.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteHostRequestCanReturnRedirectResponse(t *testing.T) {
|
||||
useUnsafeTestHTTPClient(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/v1/next", http.StatusFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
followRedirects := false
|
||||
resp, err := ExecuteHostRequest(context.Background(), testHostManifest(t, server.URL), testHostPolicy(t, server.URL), HostRequestSpec{
|
||||
Method: "GET",
|
||||
Target: RequestTarget{Type: "connector", Connector: "api", Path: "/v1/start"},
|
||||
FollowRedirects: &followRedirects,
|
||||
}, HostRequestOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if resp.Status != http.StatusFound {
|
||||
t.Fatalf("unexpected status %d", resp.Status)
|
||||
}
|
||||
if got := resp.HeaderValues["Location"]; len(got) != 1 || got[0] != "/v1/next" {
|
||||
t.Fatalf("Location = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteHostRequestReturnsMultiValueHeaders(t *testing.T) {
|
||||
useUnsafeTestHTTPClient(t)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("Set-Cookie", "session=abc; Path=/")
|
||||
w.Header().Add("Set-Cookie", "device=full; Path=/")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
resp, err := ExecuteHostRequest(context.Background(), testHostManifest(t, server.URL), testHostPolicy(t, server.URL), HostRequestSpec{
|
||||
Method: "GET",
|
||||
Target: RequestTarget{Type: "connector", Connector: "api", Path: "/v1"},
|
||||
Expect: ResponseExpect{
|
||||
ContentTypes: []string{"application/json"},
|
||||
MaxBytes: 1024,
|
||||
},
|
||||
}, HostRequestOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := resp.HeaderValues["Set-Cookie"]; len(got) != 2 || got[0] != "session=abc; Path=/" || got[1] != "device=full; Path=/" {
|
||||
t.Fatalf("Set-Cookie values = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func useUnsafeTestHTTPClient(t *testing.T) {
|
||||
t.Helper()
|
||||
original := newConnectorHTTPClient
|
||||
newConnectorHTTPClient = func(policy util.ConnectorHTTPPolicy, checkRedirect func(req *http.Request, via []*http.Request) error) (*http.Client, error) {
|
||||
return &http.Client{
|
||||
Timeout: 60 * time.Second,
|
||||
CheckRedirect: checkRedirect,
|
||||
}, nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
newConnectorHTTPClient = original
|
||||
})
|
||||
}
|
||||
|
||||
func testHostManifest(t *testing.T, rawURL string) Manifest {
|
||||
t.Helper()
|
||||
return Manifest{
|
||||
ManifestVersion: ManifestVersion,
|
||||
ID: "test",
|
||||
Type: PluginTypeTrails,
|
||||
Name: "Test",
|
||||
Version: "0.1.0",
|
||||
Runtime: RuntimeManifest{
|
||||
Type: RuntimeWASM,
|
||||
Entrypoint: "plugin.wasm",
|
||||
},
|
||||
Capabilities: []CapabilityManifest{{
|
||||
Name: "test",
|
||||
Version: "v1",
|
||||
Export: "test_v1",
|
||||
}},
|
||||
Permissions: PermissionManifest{
|
||||
Network: NetworkPermissions{
|
||||
Connectors: []ConnectorTargetPermission{{
|
||||
Name: "api",
|
||||
Type: ConnectorTypePublicAPI,
|
||||
FixedBaseURL: rawURL,
|
||||
AllowedPathPrefixes: []string{"/v1"},
|
||||
}},
|
||||
},
|
||||
Downloads: DownloadPermissions{
|
||||
MaxBytes: 1024,
|
||||
ContentTypes: []string{"application/json"},
|
||||
},
|
||||
Uploads: UploadPermissions{
|
||||
MaxBytes: 1024,
|
||||
ContentTypes: []string{"multipart/form-data"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func testHostPolicy(t *testing.T, rawURL string) RequestPolicyContext {
|
||||
t.Helper()
|
||||
parsed, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed.Path = ""
|
||||
return RequestPolicyContext{Connectors: map[string]ResolvedConnectorTarget{
|
||||
"api": {
|
||||
Name: "api",
|
||||
Type: ConnectorTypePublicAPI,
|
||||
BaseURL: parsed.String(),
|
||||
BasePath: "/",
|
||||
AllowPrivate: true,
|
||||
AllowedPathPrefixes: []string{"/v1"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
75
db/pluginsystem/import_types.go
Normal file
75
db/pluginsystem/import_types.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package pluginsystem
|
||||
|
||||
import "time"
|
||||
|
||||
type InstanceRef struct {
|
||||
ID string `json:"id"`
|
||||
PluginID string `json:"pluginId"`
|
||||
}
|
||||
|
||||
type TrailImport struct {
|
||||
Source TrailImportSource `json:"source"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
StartedAt *time.Time `json:"startedAt,omitempty"`
|
||||
ActivityType string `json:"activityType,omitempty"`
|
||||
Privacy *string `json:"privacy,omitempty"`
|
||||
Track Track `json:"track"`
|
||||
Waypoints []Waypoint `json:"waypoints,omitempty"`
|
||||
Photos []Photo `json:"photos,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type TrailSummary struct {
|
||||
Source TrailImportSource `json:"source"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
}
|
||||
|
||||
type TrailImportSource struct {
|
||||
Provider string `json:"provider"`
|
||||
ExternalID string `json:"externalId"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
type Track struct {
|
||||
Format string `json:"format"`
|
||||
ContentBase64 string `json:"contentBase64"`
|
||||
}
|
||||
|
||||
type Waypoint struct {
|
||||
ExternalID string `json:"externalId,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Lat float64 `json:"lat"`
|
||||
Lon float64 `json:"lon"`
|
||||
Ele *float64 `json:"ele,omitempty"`
|
||||
Time *time.Time `json:"time,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Photos []Photo `json:"photos,omitempty"`
|
||||
}
|
||||
|
||||
type Photo struct {
|
||||
ExternalID string `json:"externalId,omitempty"`
|
||||
Filename string `json:"filename,omitempty"`
|
||||
ContentType string `json:"contentType,omitempty"`
|
||||
TakenAt *time.Time `json:"takenAt,omitempty"`
|
||||
Lat *float64 `json:"lat,omitempty"`
|
||||
Lon *float64 `json:"lon,omitempty"`
|
||||
Source MediaSource `json:"source"`
|
||||
}
|
||||
|
||||
type MediaSource struct {
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url,omitempty"`
|
||||
MediaRef *MediaRef `json:"mediaRef,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
|
||||
}
|
||||
|
||||
type MediaRef struct {
|
||||
Connector string `json:"connector"`
|
||||
Auth string `json:"auth,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Query []QueryParam `json:"query,omitempty"`
|
||||
AssetID string `json:"assetId,omitempty"`
|
||||
}
|
||||
98
db/pluginsystem/installed.go
Normal file
98
db/pluginsystem/installed.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package pluginsystem
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// LoadInstalledPlugin resolves one plugin from the installed_plugins cache. If
|
||||
// the cache record is missing or stale, it falls back to the local plugin
|
||||
// directory so newly copied bundles can still be discovered.
|
||||
func LoadInstalledPlugin(app core.App, dir string, pluginID string) (LocalPlugin, error) {
|
||||
if pluginID == "" {
|
||||
return LocalPlugin{}, fmt.Errorf("plugin id is required")
|
||||
}
|
||||
record, _ := app.FindFirstRecordByFilter(
|
||||
"installed_plugins",
|
||||
"plugin_id={:plugin_id}",
|
||||
dbx.Params{"plugin_id": pluginID},
|
||||
)
|
||||
if record != nil {
|
||||
plugin, err := localPluginFromRecord(record)
|
||||
if err == nil {
|
||||
return plugin, nil
|
||||
}
|
||||
}
|
||||
|
||||
if dir == "" {
|
||||
dir = PluginDir()
|
||||
}
|
||||
plugins, err := LoadLocalPlugins(dir)
|
||||
if err != nil {
|
||||
return LocalPlugin{}, err
|
||||
}
|
||||
for _, plugin := range plugins {
|
||||
if plugin.Manifest.ID == pluginID {
|
||||
return plugin, nil
|
||||
}
|
||||
}
|
||||
return LocalPlugin{}, fmt.Errorf("unknown plugin")
|
||||
}
|
||||
|
||||
// LoadInstalledPlugins returns the cached installed plugin manifests used by
|
||||
// request hot paths, with disk discovery as a bootstrap fallback.
|
||||
func LoadInstalledPlugins(app core.App, dir string) ([]LocalPlugin, error) {
|
||||
records, err := app.FindRecordsByFilter("installed_plugins", "", "", -1, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
plugins := make([]LocalPlugin, 0, len(records))
|
||||
for _, record := range records {
|
||||
plugin, err := localPluginFromRecord(record)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
plugins = append(plugins, plugin)
|
||||
}
|
||||
if len(plugins) > 0 {
|
||||
return plugins, nil
|
||||
}
|
||||
if dir == "" {
|
||||
dir = PluginDir()
|
||||
}
|
||||
return LoadLocalPlugins(dir)
|
||||
}
|
||||
|
||||
func localPluginFromRecord(record *core.Record) (LocalPlugin, error) {
|
||||
var manifest Manifest
|
||||
if err := record.UnmarshalJSONField("manifest", &manifest); err != nil {
|
||||
return LocalPlugin{}, err
|
||||
}
|
||||
if err := ValidateManifest(manifest); err != nil {
|
||||
return LocalPlugin{}, err
|
||||
}
|
||||
|
||||
dir := strings.TrimSpace(record.GetString("path"))
|
||||
if dir == "" {
|
||||
return LocalPlugin{}, fmt.Errorf("installed plugin path is empty")
|
||||
}
|
||||
entrypoint := filepath.Clean(manifest.Runtime.Entrypoint)
|
||||
if filepath.IsAbs(entrypoint) || entrypoint == ".." || strings.HasPrefix(entrypoint, ".."+string(filepath.Separator)) {
|
||||
return LocalPlugin{}, fmt.Errorf("runtime entrypoint must be relative to plugin directory")
|
||||
}
|
||||
wasmPath := filepath.Join(dir, entrypoint)
|
||||
if _, err := os.Stat(wasmPath); err != nil {
|
||||
return LocalPlugin{}, fmt.Errorf("runtime entrypoint: %w", err)
|
||||
}
|
||||
return LocalPlugin{
|
||||
Manifest: manifest,
|
||||
Dir: dir,
|
||||
WASMPath: wasmPath,
|
||||
}, nil
|
||||
}
|
||||
70
db/pluginsystem/json.go
Normal file
70
db/pluginsystem/json.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package pluginsystem
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// JSONMapFromRecord reads a PocketBase JSON field into a map. Invalid, empty,
|
||||
// or null values are treated as an empty object because plugin config/state/auth
|
||||
// fields should be tolerant of partially edited records.
|
||||
func JSONMapFromRecord(record *core.Record, field string) map[string]any {
|
||||
if record == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
value := record.GetString(field)
|
||||
if value == "" {
|
||||
return map[string]any{}
|
||||
}
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal([]byte(value), &result); err != nil || result == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// DeepMergeConfig recursively overlays src onto dst and clones JSON-like values
|
||||
// so caller-owned config maps cannot be mutated through shared references.
|
||||
func DeepMergeConfig(dst map[string]any, src map[string]any) {
|
||||
DeepMergeConfigWithReplaceKeys(dst, src, nil)
|
||||
}
|
||||
|
||||
// DeepMergeConfigWithReplaceKeys behaves like DeepMergeConfig, but map values
|
||||
// whose key is listed in replaceKeys replace the destination map instead of
|
||||
// being recursively merged.
|
||||
func DeepMergeConfigWithReplaceKeys(dst map[string]any, src map[string]any, replaceKeys map[string]bool) {
|
||||
for key, value := range src {
|
||||
srcMap, srcIsMap := value.(map[string]any)
|
||||
dstMap, dstIsMap := dst[key].(map[string]any)
|
||||
if srcIsMap && dstIsMap {
|
||||
if replaceKeys[key] {
|
||||
dst[key] = CloneJSONMap(srcMap)
|
||||
continue
|
||||
}
|
||||
DeepMergeConfigWithReplaceKeys(dstMap, srcMap, replaceKeys)
|
||||
continue
|
||||
}
|
||||
dst[key] = CloneJSONValue(value)
|
||||
}
|
||||
}
|
||||
|
||||
func CloneJSONMap(values map[string]any) map[string]any {
|
||||
cloned := make(map[string]any, len(values))
|
||||
for key, value := range values {
|
||||
cloned[key] = CloneJSONValue(value)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func CloneJSONValue(value any) any {
|
||||
data, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return value
|
||||
}
|
||||
var cloned any
|
||||
if err := json.Unmarshal(data, &cloned); err != nil {
|
||||
return value
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
81
db/pluginsystem/json_test.go
Normal file
81
db/pluginsystem/json_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package pluginsystem
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMergePluginConfigEmptyCategoryMappingOverridesDefaultMap(t *testing.T) {
|
||||
dst := map[string]any{
|
||||
"host": map[string]any{
|
||||
"categoryMapping": map[string]any{
|
||||
"hike": "hiking",
|
||||
"bike": "biking",
|
||||
},
|
||||
"privacy": "public",
|
||||
},
|
||||
}
|
||||
src := map[string]any{
|
||||
"host": map[string]any{
|
||||
"categoryMapping": map[string]any{},
|
||||
},
|
||||
}
|
||||
|
||||
MergePluginConfig(dst, src)
|
||||
|
||||
host := dst["host"].(map[string]any)
|
||||
mapping := host["categoryMapping"].(map[string]any)
|
||||
if len(mapping) != 0 {
|
||||
t.Fatalf("expected empty category mapping override, got %#v", mapping)
|
||||
}
|
||||
if host["privacy"] != "public" {
|
||||
t.Fatalf("expected sibling defaults to remain, got %#v", host)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergePluginConfigCategoryMappingReplacesDefaultMap(t *testing.T) {
|
||||
dst := map[string]any{
|
||||
"host": map[string]any{
|
||||
"categoryMapping": map[string]any{
|
||||
"hike": "hiking",
|
||||
"bike": "biking",
|
||||
},
|
||||
},
|
||||
}
|
||||
src := map[string]any{
|
||||
"host": map[string]any{
|
||||
"categoryMapping": map[string]any{
|
||||
"hike": "custom",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
MergePluginConfig(dst, src)
|
||||
|
||||
mapping := dst["host"].(map[string]any)["categoryMapping"].(map[string]any)
|
||||
if len(mapping) != 1 || mapping["hike"] != "custom" {
|
||||
t.Fatalf("expected category mapping to replace defaults, got %#v", mapping)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeepMergeConfigNonEmptyMapStillMergesByDefault(t *testing.T) {
|
||||
dst := map[string]any{
|
||||
"host": map[string]any{
|
||||
"categoryMapping": map[string]any{
|
||||
"hike": "hiking",
|
||||
"bike": "biking",
|
||||
},
|
||||
},
|
||||
}
|
||||
src := map[string]any{
|
||||
"host": map[string]any{
|
||||
"categoryMapping": map[string]any{
|
||||
"hike": "custom",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
DeepMergeConfig(dst, src)
|
||||
|
||||
mapping := dst["host"].(map[string]any)["categoryMapping"].(map[string]any)
|
||||
if mapping["hike"] != "custom" || mapping["bike"] != "biking" {
|
||||
t.Fatalf("expected generic merge to keep sibling defaults, got %#v", mapping)
|
||||
}
|
||||
}
|
||||
419
db/pluginsystem/manager.go
Normal file
419
db/pluginsystem/manager.go
Normal file
@@ -0,0 +1,419 @@
|
||||
package pluginsystem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// Manager coordinates local plugin discovery with the installed_plugins cache.
|
||||
// It is intentionally small: request hot paths should read cached manifests,
|
||||
// while list/cron entrypoints refresh the cache from data/plugins first.
|
||||
type Manager struct {
|
||||
App core.App
|
||||
Dir string
|
||||
}
|
||||
|
||||
// PluginInfo is the UI-facing view of an installed plugin. It combines the
|
||||
// static manifest with runtime availability and embedded icon data.
|
||||
type PluginInfo struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
IconDark string `json:"iconDark,omitempty"`
|
||||
Version string `json:"version"`
|
||||
Runtime string `json:"runtime"`
|
||||
Path string `json:"path"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Manifest Manifest `json:"manifest"`
|
||||
}
|
||||
|
||||
// NewManager creates a manager for the configured plugin directory. Tests can
|
||||
// pass a custom dir; production callers use the resolved runtime plugin
|
||||
// directory.
|
||||
func NewManager(app core.App, dir string) *Manager {
|
||||
if dir == "" {
|
||||
dir = PluginDir()
|
||||
}
|
||||
return &Manager{App: app, Dir: dir}
|
||||
}
|
||||
|
||||
// ListLocalPlugins returns installed plugins in the shape consumed by the
|
||||
// settings UI. It reads from installed_plugins first so listing does not need to
|
||||
// parse every manifest from disk after the cache has been refreshed.
|
||||
func (m *Manager) ListLocalPlugins(context.Context) ([]PluginInfo, error) {
|
||||
plugins, err := LoadInstalledPlugins(m.App, m.Dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
infos := make([]PluginInfo, 0, len(plugins))
|
||||
infoByPath := map[string]int{}
|
||||
for _, plugin := range plugins {
|
||||
status := "available"
|
||||
record, _ := m.App.FindFirstRecordByFilter(
|
||||
"installed_plugins",
|
||||
"plugin_id={:plugin_id}",
|
||||
dbx.Params{"plugin_id": plugin.Manifest.ID},
|
||||
)
|
||||
if record != nil && record.GetString("status") != "" {
|
||||
status = record.GetString("status")
|
||||
}
|
||||
errorMessage := ""
|
||||
if record != nil {
|
||||
errorMessage = record.GetString("error")
|
||||
}
|
||||
icon, iconDark := pluginIcons(plugin)
|
||||
infos = append(infos, PluginInfo{
|
||||
ID: plugin.Manifest.ID,
|
||||
Type: plugin.Manifest.Type,
|
||||
Name: plugin.Manifest.Name,
|
||||
DisplayName: stringMetadata(plugin.Manifest.Metadata, "displayName"),
|
||||
Description: plugin.Manifest.Description,
|
||||
Icon: icon,
|
||||
IconDark: iconDark,
|
||||
Version: plugin.Manifest.Version,
|
||||
Runtime: plugin.Manifest.Runtime.Type,
|
||||
Path: plugin.Dir,
|
||||
Capabilities: capabilityNames(plugin.Manifest.Capabilities),
|
||||
Status: status,
|
||||
Error: errorMessage,
|
||||
Manifest: plugin.Manifest,
|
||||
})
|
||||
infoByPath[filepath.Clean(plugin.Dir)] = len(infos) - 1
|
||||
}
|
||||
|
||||
_, issues, err := DiscoverLocalPlugins(m.Dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, issue := range issues {
|
||||
if index, ok := infoByPath[filepath.Clean(issue.Dir)]; ok {
|
||||
infos[index].Status = "error"
|
||||
infos[index].Error = issue.Error
|
||||
continue
|
||||
}
|
||||
infos = append(infos, PluginInfo{
|
||||
ID: issue.ID,
|
||||
Type: PluginTypeTrails,
|
||||
Name: issue.Name,
|
||||
Path: issue.Dir,
|
||||
Status: "error",
|
||||
Error: issue.Error,
|
||||
Runtime: RuntimeWASM,
|
||||
Manifest: Manifest{
|
||||
ID: issue.ID,
|
||||
Type: PluginTypeTrails,
|
||||
Name: issue.Name,
|
||||
Runtime: RuntimeManifest{
|
||||
Type: RuntimeWASM,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
return infos, nil
|
||||
}
|
||||
|
||||
// pluginIcons embeds optional light/dark icon files from the plugin bundle as
|
||||
// data URLs so the frontend does not need direct filesystem access.
|
||||
func pluginIcons(plugin LocalPlugin) (string, string) {
|
||||
icons, _ := plugin.Manifest.Metadata["icons"].(map[string]any)
|
||||
return pluginIcon(plugin.Dir, stringMetadata(icons, "light")), pluginIcon(plugin.Dir, stringMetadata(icons, "dark"))
|
||||
}
|
||||
|
||||
func stringMetadata(values map[string]any, key string) string {
|
||||
value, _ := values[key].(string)
|
||||
return value
|
||||
}
|
||||
|
||||
func pluginIcon(pluginDir string, iconPath string) string {
|
||||
iconPath = strings.TrimSpace(iconPath)
|
||||
if iconPath == "" {
|
||||
return ""
|
||||
}
|
||||
cleanPath := filepath.Clean(iconPath)
|
||||
if filepath.IsAbs(cleanPath) || cleanPath == ".." || strings.HasPrefix(cleanPath, ".."+string(filepath.Separator)) {
|
||||
return ""
|
||||
}
|
||||
fullPath := filepath.Join(pluginDir, cleanPath)
|
||||
data, err := os.ReadFile(fullPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
contentType := "image/svg+xml"
|
||||
switch strings.ToLower(filepath.Ext(fullPath)) {
|
||||
case ".png":
|
||||
contentType = "image/png"
|
||||
case ".jpg", ".jpeg":
|
||||
contentType = "image/jpeg"
|
||||
case ".webp":
|
||||
contentType = "image/webp"
|
||||
}
|
||||
return "data:" + contentType + ";base64," + base64.StdEncoding.EncodeToString(data)
|
||||
}
|
||||
|
||||
// SyncInstalledPlugins scans the runtime plugin directory and upserts
|
||||
// installed_plugins records.
|
||||
// This keeps the manifest snapshot available even when later code paths should
|
||||
// avoid repeated disk IO.
|
||||
func (m *Manager) SyncInstalledPlugins(ctx context.Context) error {
|
||||
plugins, issues, err := DiscoverLocalPlugins(m.Dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
collection, err := m.App.FindCollectionByNameOrId("installed_plugins")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
activePaths := activePluginPaths(plugins, issues)
|
||||
if err := m.deleteStaleInstalledPlugins(ctx, activePaths); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, issue := range issues {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
m.App.Logger().Warn("plugin setup error", "plugin", issue.ID, "path", issue.Dir, "error", issue.Error)
|
||||
if err := m.savePluginIssue(collection, issue); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, plugin := range plugins {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
record, err := m.findPluginRecord(collection, plugin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
record.Set("plugin_id", plugin.Manifest.ID)
|
||||
record.Set("name", plugin.Manifest.Name)
|
||||
record.Set("type", plugin.Manifest.Type)
|
||||
record.Set("version", plugin.Manifest.Version)
|
||||
record.Set("runtime", plugin.Manifest.Runtime.Type)
|
||||
record.Set("path", plugin.Dir)
|
||||
record.Set("status", "available")
|
||||
record.Set("error", "")
|
||||
manifestJSON, err := marshalManifest(plugin.Manifest)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode installed plugin %s manifest: %w", plugin.Manifest.ID, err)
|
||||
}
|
||||
record.Set("manifest", manifestJSON)
|
||||
record.Set("config", mergeDefaultConfig(defaultConfig(plugin.Manifest), JSONMapFromRecord(record, "config")))
|
||||
if err := m.App.Save(record); err != nil {
|
||||
return fmt.Errorf("save installed plugin %s: %w", plugin.Manifest.ID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func activePluginPaths(plugins []LocalPlugin, issues []LocalPluginIssue) map[string]bool {
|
||||
paths := make(map[string]bool, len(plugins)+len(issues))
|
||||
for _, plugin := range plugins {
|
||||
if plugin.Dir != "" {
|
||||
paths[filepath.Clean(plugin.Dir)] = true
|
||||
}
|
||||
}
|
||||
for _, issue := range issues {
|
||||
if issue.Dir != "" {
|
||||
paths[filepath.Clean(issue.Dir)] = true
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func (m *Manager) deleteStaleInstalledPlugins(ctx context.Context, activePaths map[string]bool) error {
|
||||
records, err := m.App.FindRecordsByFilter("installed_plugins", "", "", -1, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, record := range records {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
path := strings.TrimSpace(record.GetString("path"))
|
||||
if path != "" && activePaths[filepath.Clean(path)] {
|
||||
continue
|
||||
}
|
||||
if err := m.App.Delete(record); err != nil {
|
||||
return fmt.Errorf("delete stale installed plugin %s: %w", record.GetString("plugin_id"), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) findPluginRecord(collection *core.Collection, plugin LocalPlugin) (*core.Record, error) {
|
||||
recordByID, _ := m.App.FindFirstRecordByFilter(
|
||||
"installed_plugins",
|
||||
"plugin_id={:plugin_id}",
|
||||
dbx.Params{"plugin_id": plugin.Manifest.ID},
|
||||
)
|
||||
var recordByPath *core.Record
|
||||
if plugin.Dir != "" {
|
||||
recordByPath, _ = m.App.FindFirstRecordByFilter(
|
||||
"installed_plugins",
|
||||
"path={:path}",
|
||||
dbx.Params{"path": plugin.Dir},
|
||||
)
|
||||
}
|
||||
if recordByID != nil && recordByPath != nil && recordByID.Id != recordByPath.Id {
|
||||
if err := m.App.Delete(recordByPath); err != nil {
|
||||
return nil, fmt.Errorf("delete superseded installed plugin %s: %w", recordByPath.GetString("plugin_id"), err)
|
||||
}
|
||||
}
|
||||
if recordByID != nil {
|
||||
return recordByID, nil
|
||||
}
|
||||
if recordByPath != nil {
|
||||
return recordByPath, nil
|
||||
}
|
||||
return core.NewRecord(collection), nil
|
||||
}
|
||||
|
||||
func (m *Manager) savePluginIssue(collection *core.Collection, issue LocalPluginIssue) error {
|
||||
recordID := pluginIssueRecordID(issue)
|
||||
record, _ := m.App.FindFirstRecordByFilter(
|
||||
"installed_plugins",
|
||||
"plugin_id={:plugin_id}",
|
||||
dbx.Params{"plugin_id": recordID},
|
||||
)
|
||||
if record == nil && issue.Dir != "" {
|
||||
record, _ = m.App.FindFirstRecordByFilter(
|
||||
"installed_plugins",
|
||||
"path={:path}",
|
||||
dbx.Params{"path": issue.Dir},
|
||||
)
|
||||
}
|
||||
if record == nil {
|
||||
record = core.NewRecord(collection)
|
||||
record.Set("plugin_id", recordID)
|
||||
}
|
||||
record.Set("name", issue.Name)
|
||||
record.Set("type", PluginTypeTrails)
|
||||
record.Set("version", "unknown")
|
||||
record.Set("runtime", RuntimeWASM)
|
||||
record.Set("path", issue.Dir)
|
||||
record.Set("manifest", map[string]any{
|
||||
"id": record.GetString("plugin_id"),
|
||||
"type": PluginTypeTrails,
|
||||
"name": issue.Name,
|
||||
})
|
||||
record.Set("status", "error")
|
||||
record.Set("error", issue.Error)
|
||||
if err := m.App.Save(record); err != nil {
|
||||
return fmt.Errorf("save plugin setup error %s: %w", issue.ID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pluginIssueRecordID(issue LocalPluginIssue) string {
|
||||
originalID := strings.TrimSpace(issue.ID)
|
||||
id := strings.ToLower(originalID)
|
||||
var builder strings.Builder
|
||||
for _, r := range id {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
builder.WriteRune(r)
|
||||
case r >= '0' && r <= '9':
|
||||
builder.WriteRune(r)
|
||||
case r == '_' || r == '-':
|
||||
builder.WriteRune(r)
|
||||
default:
|
||||
builder.WriteRune('-')
|
||||
}
|
||||
}
|
||||
result := strings.Trim(builder.String(), "-_")
|
||||
if result == "" {
|
||||
result = "plugin-setup-error"
|
||||
}
|
||||
if originalID != result || !pluginIDPattern.MatchString(result) {
|
||||
result = strings.Trim(result, "-_")
|
||||
if result == "" {
|
||||
result = "plugin-setup-error"
|
||||
}
|
||||
result = result + "-" + pluginIssueHash(issue)
|
||||
}
|
||||
if len(result) > 128 {
|
||||
hash := pluginIssueHash(issue)
|
||||
prefixLength := 128 - len(hash) - 1
|
||||
result = strings.Trim(result[:prefixLength], "-_") + "-" + hash
|
||||
}
|
||||
if pluginIDPattern.MatchString(result) {
|
||||
return result
|
||||
}
|
||||
return "plugin-setup-error-" + pluginIssueHash(issue)
|
||||
}
|
||||
|
||||
func pluginIssueHash(issue LocalPluginIssue) string {
|
||||
hash := fnv.New32a()
|
||||
_, _ = hash.Write([]byte(issue.Dir))
|
||||
_, _ = hash.Write([]byte{0})
|
||||
_, _ = hash.Write([]byte(issue.ID))
|
||||
return fmt.Sprintf("%08x", hash.Sum32())
|
||||
}
|
||||
|
||||
func marshalManifest(manifest Manifest) (map[string]any, error) {
|
||||
data, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result map[string]any
|
||||
if err := json.Unmarshal(data, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func defaultConfig(manifest Manifest) map[string]any {
|
||||
hostConfig, _ := CloneJSONValue(manifest.HostConfig).(map[string]any)
|
||||
if hostConfig == nil {
|
||||
hostConfig = map[string]any{}
|
||||
}
|
||||
config := map[string]any{
|
||||
"host": hostConfig,
|
||||
}
|
||||
pluginConfig := map[string]any{}
|
||||
for _, field := range manifest.ConfigSchema {
|
||||
if field.Key == "" || field.Default == nil {
|
||||
continue
|
||||
}
|
||||
pluginConfig[field.Key] = CloneJSONValue(field.Default)
|
||||
}
|
||||
config["plugin"] = pluginConfig
|
||||
return config
|
||||
}
|
||||
|
||||
func mergeDefaultConfig(defaults map[string]any, current map[string]any) map[string]any {
|
||||
if len(defaults) == 0 {
|
||||
return current
|
||||
}
|
||||
merged := CloneJSONMap(defaults)
|
||||
MergePluginConfig(merged, current)
|
||||
return merged
|
||||
}
|
||||
|
||||
func MergePluginConfig(dst map[string]any, src map[string]any) {
|
||||
DeepMergeConfigWithReplaceKeys(dst, src, map[string]bool{
|
||||
"categoryMapping": true,
|
||||
})
|
||||
}
|
||||
|
||||
func capabilityNames(capabilities []CapabilityManifest) []string {
|
||||
names := make([]string, 0, len(capabilities))
|
||||
for _, capability := range capabilities {
|
||||
names = append(names, capability.Name+"."+capability.Version)
|
||||
}
|
||||
return names
|
||||
}
|
||||
21
db/pluginsystem/manager_test.go
Normal file
21
db/pluginsystem/manager_test.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package pluginsystem
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPluginIssueRecordID(t *testing.T) {
|
||||
valid := pluginIssueRecordID(LocalPluginIssue{ID: "komoot", Dir: "/plugins/komoot"})
|
||||
if valid != "komoot" {
|
||||
t.Fatalf("pluginIssueRecordID(valid) = %q, want komoot", valid)
|
||||
}
|
||||
|
||||
first := pluginIssueRecordID(LocalPluginIssue{ID: "@@@", Dir: "/plugins/@@@"})
|
||||
second := pluginIssueRecordID(LocalPluginIssue{ID: "***", Dir: "/plugins/***"})
|
||||
if first == second {
|
||||
t.Fatalf("invalid plugin issue ids collided: %q", first)
|
||||
}
|
||||
for _, got := range []string{first, second} {
|
||||
if !pluginIDPattern.MatchString(got) {
|
||||
t.Fatalf("pluginIssueRecordID() = %q, not a valid plugin id", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
305
db/pluginsystem/manifest.go
Normal file
305
db/pluginsystem/manifest.go
Normal file
@@ -0,0 +1,305 @@
|
||||
package pluginsystem
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultPluginDir = "/data/plugins"
|
||||
)
|
||||
|
||||
var pluginIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
|
||||
|
||||
var ErrUnsupportedPluginType = errors.New("unsupported plugin type")
|
||||
|
||||
type LocalPlugin struct {
|
||||
Manifest Manifest `json:"manifest"`
|
||||
Dir string `json:"dir"`
|
||||
WASMPath string `json:"wasmPath"`
|
||||
}
|
||||
|
||||
// PluginDir resolves the runtime plugin directory. Production containers mount
|
||||
// plugins at /data/plugins; source checkouts usually stage them at data/plugins
|
||||
// and may start PocketBase either from the repo root or from db/.
|
||||
func PluginDir() string {
|
||||
for _, candidate := range []string{
|
||||
DefaultPluginDir,
|
||||
"data/plugins",
|
||||
filepath.Join("..", "data", "plugins"),
|
||||
} {
|
||||
if info, err := os.Stat(candidate); err == nil && info.IsDir() {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return DefaultPluginDir
|
||||
}
|
||||
|
||||
// LoadLocalPlugins reads direct child directories from the plugin directory and
|
||||
// returns every valid bundle. Invalid direct children are ignored by this
|
||||
// compatibility helper; callers that need UI-visible errors should use
|
||||
// DiscoverLocalPlugins.
|
||||
func LoadLocalPlugins(dir string) ([]LocalPlugin, error) {
|
||||
plugins, _, err := DiscoverLocalPlugins(dir)
|
||||
return plugins, err
|
||||
}
|
||||
|
||||
type LocalPluginIssue struct {
|
||||
ID string
|
||||
Name string
|
||||
Dir string
|
||||
Error string
|
||||
}
|
||||
|
||||
// DiscoverLocalPlugins reads direct child directories from the plugin directory
|
||||
// and returns valid bundles plus per-directory load issues.
|
||||
func DiscoverLocalPlugins(dir string) ([]LocalPlugin, []LocalPluginIssue, error) {
|
||||
if dir == "" {
|
||||
dir = PluginDir()
|
||||
}
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []LocalPlugin{}, nil, nil
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
plugins := make([]LocalPlugin, 0)
|
||||
issues := make([]LocalPluginIssue, 0)
|
||||
seen := map[string]bool{}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
pluginDir := filepath.Join(dir, entry.Name())
|
||||
plugin, err := LoadLocalPlugin(pluginDir)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUnsupportedPluginType) {
|
||||
continue
|
||||
}
|
||||
issues = append(issues, LocalPluginIssue{
|
||||
ID: entry.Name(),
|
||||
Name: entry.Name(),
|
||||
Dir: pluginDir,
|
||||
Error: fmt.Sprintf("%s: %v", entry.Name(), err),
|
||||
})
|
||||
continue
|
||||
}
|
||||
if seen[plugin.Manifest.ID] {
|
||||
continue
|
||||
}
|
||||
seen[plugin.Manifest.ID] = true
|
||||
plugins = append(plugins, *plugin)
|
||||
}
|
||||
|
||||
return plugins, issues, nil
|
||||
}
|
||||
|
||||
// LoadLocalPlugin reads one plugin bundle, validates its manifest, and resolves
|
||||
// the WASM entrypoint relative to the plugin directory.
|
||||
func LoadLocalPlugin(dir string) (*LocalPlugin, error) {
|
||||
manifestPath := filepath.Join(dir, "plugin.json")
|
||||
data, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var manifest Manifest
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
return nil, fmt.Errorf("parse plugin.json: %w", err)
|
||||
}
|
||||
if err := ValidateManifest(manifest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entrypoint := filepath.Clean(manifest.Runtime.Entrypoint)
|
||||
if filepath.IsAbs(entrypoint) || strings.HasPrefix(entrypoint, ".."+string(filepath.Separator)) || entrypoint == ".." {
|
||||
return nil, fmt.Errorf("runtime entrypoint must be relative to plugin directory")
|
||||
}
|
||||
wasmPath := filepath.Join(dir, entrypoint)
|
||||
if _, err := os.Stat(wasmPath); err != nil {
|
||||
return nil, fmt.Errorf("runtime entrypoint: %w", err)
|
||||
}
|
||||
|
||||
return &LocalPlugin{
|
||||
Manifest: manifest,
|
||||
Dir: dir,
|
||||
WASMPath: wasmPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateManifest checks the static contract that is trusted by install,
|
||||
// runtime policy enforcement, auth handling, and the UI.
|
||||
func ValidateManifest(manifest Manifest) error {
|
||||
if manifest.ManifestVersion == "" {
|
||||
return fmt.Errorf("manifestVersion is required")
|
||||
}
|
||||
if majorVersion(manifest.ManifestVersion) != majorVersion(ManifestVersion) {
|
||||
return fmt.Errorf("unsupported manifestVersion %q", manifest.ManifestVersion)
|
||||
}
|
||||
if !pluginIDPattern.MatchString(manifest.ID) {
|
||||
return fmt.Errorf("id must match %s", pluginIDPattern.String())
|
||||
}
|
||||
if manifest.Type != PluginTypeTrails {
|
||||
return fmt.Errorf("%w: type must be %q", ErrUnsupportedPluginType, PluginTypeTrails)
|
||||
}
|
||||
if strings.TrimSpace(manifest.Name) == "" {
|
||||
return fmt.Errorf("name is required")
|
||||
}
|
||||
if strings.TrimSpace(manifest.Version) == "" {
|
||||
return fmt.Errorf("version is required")
|
||||
}
|
||||
if manifest.Runtime.Type != RuntimeWASM {
|
||||
return fmt.Errorf("runtime.type must be %q", RuntimeWASM)
|
||||
}
|
||||
if strings.TrimSpace(manifest.Runtime.Entrypoint) == "" {
|
||||
return fmt.Errorf("runtime.entrypoint is required")
|
||||
}
|
||||
if len(manifest.Capabilities) == 0 {
|
||||
return fmt.Errorf("at least one capability is required")
|
||||
}
|
||||
if err := validateCapabilities(manifest.Capabilities); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateAuth(manifest.Auth); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validatePermissions(manifest.Permissions, manifest.Auth); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCapabilities(capabilities []CapabilityManifest) error {
|
||||
seen := map[string]bool{}
|
||||
for _, capability := range capabilities {
|
||||
if strings.TrimSpace(capability.Name) == "" {
|
||||
return fmt.Errorf("capability name is required")
|
||||
}
|
||||
if strings.TrimSpace(capability.Version) == "" {
|
||||
return fmt.Errorf("capability %s version is required", capability.Name)
|
||||
}
|
||||
if strings.TrimSpace(capability.Export) == "" {
|
||||
return fmt.Errorf("capability %s export is required", capability.Name)
|
||||
}
|
||||
key := capability.Name + "." + capability.Version
|
||||
if seen[key] {
|
||||
return fmt.Errorf("duplicate capability %s", key)
|
||||
}
|
||||
seen[key] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAuth(auth AuthManifest) error {
|
||||
for name, context := range auth.Contexts {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return fmt.Errorf("auth context name is required")
|
||||
}
|
||||
if err := ValidateAuthContext(name, context); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePermissions(permissions PermissionManifest, auth AuthManifest) error {
|
||||
authContexts := map[string]bool{}
|
||||
for name := range auth.Contexts {
|
||||
authContexts[name] = true
|
||||
}
|
||||
for _, authRef := range permissions.Auth {
|
||||
if !authContexts[authRef] {
|
||||
return fmt.Errorf("permission references unknown auth context %q", authRef)
|
||||
}
|
||||
}
|
||||
if err := validateConnectors(permissions.Network.Connectors, authContexts); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, host := range permissions.Network.Redirects.Hosts {
|
||||
if err := validateHost(host); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if permissions.Network.Redirects.Mode != "" && permissions.Network.Redirects.Mode != "declared_hosts_only" {
|
||||
return fmt.Errorf("unsupported redirect mode %q", permissions.Network.Redirects.Mode)
|
||||
}
|
||||
if permissions.Downloads.MaxBytes < 0 || permissions.Uploads.MaxBytes < 0 {
|
||||
return fmt.Errorf("maxBytes must not be negative")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateConnectors(connectors []ConnectorTargetPermission, authContexts map[string]bool) error {
|
||||
seen := map[string]bool{}
|
||||
for _, connector := range connectors {
|
||||
if strings.TrimSpace(connector.Name) == "" {
|
||||
return fmt.Errorf("connector name is required")
|
||||
}
|
||||
if seen[connector.Name] {
|
||||
return fmt.Errorf("duplicate connector %q", connector.Name)
|
||||
}
|
||||
seen[connector.Name] = true
|
||||
switch connector.Type {
|
||||
case ConnectorTypePublicAPI:
|
||||
if strings.TrimSpace(connector.FixedBaseURL) == "" {
|
||||
return fmt.Errorf("public_api connector %q requires fixedBaseURL", connector.Name)
|
||||
}
|
||||
if strings.TrimSpace(connector.ConfigKey) != "" {
|
||||
return fmt.Errorf("public_api connector %q must not declare configKey", connector.Name)
|
||||
}
|
||||
if _, _, err := NormalizeConnectorBase(connector.FixedBaseURL, ""); err != nil {
|
||||
return fmt.Errorf("connector %q fixedBaseURL: %w", connector.Name, err)
|
||||
}
|
||||
case ConnectorTypeConfigured:
|
||||
if strings.TrimSpace(connector.ConfigKey) == "" {
|
||||
return fmt.Errorf("configured connector %q requires configKey", connector.Name)
|
||||
}
|
||||
if strings.TrimSpace(connector.FixedBaseURL) != "" {
|
||||
return fmt.Errorf("configured connector %q must not declare fixedBaseURL", connector.Name)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("connector %q has unsupported type %q", connector.Name, connector.Type)
|
||||
}
|
||||
for _, authRef := range connector.Auth {
|
||||
if !authContexts[authRef] {
|
||||
return fmt.Errorf("connector %q references unknown auth context %q", connector.Name, authRef)
|
||||
}
|
||||
}
|
||||
for _, prefix := range connector.AllowedPathPrefixes {
|
||||
if _, err := CanonicalURLPath(prefix); err != nil {
|
||||
return fmt.Errorf("connector %q path prefix %q: %w", connector.Name, prefix, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateHost(host string) error {
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "" {
|
||||
return fmt.Errorf("network host must not be empty")
|
||||
}
|
||||
if strings.Contains(host, "://") || strings.Contains(host, "/") {
|
||||
return fmt.Errorf("network host %q must be a hostname, not a URL", host)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func majorVersion(version string) string {
|
||||
for i, r := range version {
|
||||
if r == '.' {
|
||||
return version[:i]
|
||||
}
|
||||
}
|
||||
return version
|
||||
}
|
||||
222
db/pluginsystem/manifest_test.go
Normal file
222
db/pluginsystem/manifest_test.go
Normal file
@@ -0,0 +1,222 @@
|
||||
package pluginsystem
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateManifestAcceptsHammerheadShape(t *testing.T) {
|
||||
manifest := hammerheadManifestForTest()
|
||||
if err := ValidateManifest(manifest); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateManifestRejectsUnknownAuthPermission(t *testing.T) {
|
||||
manifest := hammerheadManifestForTest()
|
||||
manifest.Permissions.Auth = []string{"missing"}
|
||||
|
||||
if err := ValidateManifest(manifest); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadLocalPluginRequiresRelativeEntrypoint(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
manifest := hammerheadManifestForTest()
|
||||
manifest.Runtime.Entrypoint = "/tmp/plugin.wasm"
|
||||
writeManifest(t, dir, manifest)
|
||||
|
||||
if _, err := LoadLocalPlugin(dir); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadLocalPluginsSkipsMissingPluginDir(t *testing.T) {
|
||||
plugins, err := LoadLocalPlugins(filepath.Join(t.TempDir(), "missing"))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(plugins) != 0 {
|
||||
t.Fatalf("got %d plugins, want 0", len(plugins))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadLocalPluginsFindsDirectChildPlugins(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writePluginDir(t, root, "hammerhead")
|
||||
writePluginDir(t, root, "komoot")
|
||||
|
||||
plugins, err := LoadLocalPlugins(root)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(plugins) != 2 {
|
||||
t.Fatalf("got %d plugins, want 2", len(plugins))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverLocalPluginsReportsMissingManifest(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
brokenDir := filepath.Join(root, "komoot")
|
||||
if err := os.MkdirAll(brokenDir, 0o700); err != nil {
|
||||
t.Fatalf("mkdir plugin dir: %v", err)
|
||||
}
|
||||
|
||||
plugins, issues, err := DiscoverLocalPlugins(root)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(plugins) != 0 {
|
||||
t.Fatalf("got %d plugins, want 0", len(plugins))
|
||||
}
|
||||
if len(issues) != 1 {
|
||||
t.Fatalf("got %d issues, want 1", len(issues))
|
||||
}
|
||||
if issues[0].ID != "komoot" || issues[0].Name != "komoot" || issues[0].Dir != brokenDir {
|
||||
t.Fatalf("unexpected issue: %#v", issues[0])
|
||||
}
|
||||
if issues[0].Error == "" || !strings.Contains(issues[0].Error, "plugin.json") {
|
||||
t.Fatalf("expected useful plugin.json error, got %#v", issues[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadLocalPluginsIgnoresMissingManifestForCompatibility(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, "komoot"), 0o700); err != nil {
|
||||
t.Fatalf("mkdir plugin dir: %v", err)
|
||||
}
|
||||
|
||||
plugins, err := LoadLocalPlugins(root)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(plugins) != 0 {
|
||||
t.Fatalf("got %d plugins, want 0", len(plugins))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadLocalPluginsSkipsUnsupportedPluginTypes(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writePluginDir(t, root, "hammerhead")
|
||||
|
||||
assetsDir := filepath.Join(root, "immich")
|
||||
if err := os.MkdirAll(assetsDir, 0o700); err != nil {
|
||||
t.Fatalf("mkdir plugin dir: %v", err)
|
||||
}
|
||||
manifest := hammerheadManifestForTest()
|
||||
manifest.ID = "immich"
|
||||
manifest.Name = "Immich"
|
||||
manifest.Type = "assets"
|
||||
writeManifest(t, assetsDir, manifest)
|
||||
if err := os.WriteFile(filepath.Join(assetsDir, "plugin.wasm"), []byte("wasm"), 0o600); err != nil {
|
||||
t.Fatalf("write wasm: %v", err)
|
||||
}
|
||||
|
||||
plugins, err := LoadLocalPlugins(root)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(plugins) != 1 {
|
||||
t.Fatalf("got %d plugins, want 1", len(plugins))
|
||||
}
|
||||
if plugins[0].Manifest.ID != "hammerhead" {
|
||||
t.Fatalf("got plugin %q, want hammerhead", plugins[0].Manifest.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadLocalPluginsDoesNotSearchRecursively(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writePluginDir(t, filepath.Join(root, "nested"), "hammerhead")
|
||||
|
||||
plugins, err := LoadLocalPlugins(root)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(plugins) != 0 {
|
||||
t.Fatalf("got %d plugins, want 0", len(plugins))
|
||||
}
|
||||
}
|
||||
|
||||
func hammerheadManifestForTest() Manifest {
|
||||
return Manifest{
|
||||
ManifestVersion: ManifestVersion,
|
||||
ID: "hammerhead",
|
||||
Type: PluginTypeTrails,
|
||||
Name: "Hammerhead",
|
||||
Version: "0.1.0",
|
||||
Runtime: RuntimeManifest{
|
||||
Type: RuntimeWASM,
|
||||
Entrypoint: "plugin.wasm",
|
||||
},
|
||||
Capabilities: []CapabilityManifest{
|
||||
{Name: "prepare_trail_send", Version: "v1", Export: "prepare_trail_send_v1"},
|
||||
},
|
||||
Auth: AuthManifest{
|
||||
Contexts: map[string]AuthContext{
|
||||
"provider_session": {
|
||||
Type: AuthTypeSession,
|
||||
SecretFields: []string{"email", "password"},
|
||||
Refresh: &AuthRefresh{
|
||||
Mode: AuthRefreshModePlugin,
|
||||
Function: "refresh_session_v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Permissions: PermissionManifest{
|
||||
Network: NetworkPermissions{
|
||||
Connectors: []ConnectorTargetPermission{{
|
||||
Name: "api",
|
||||
Type: ConnectorTypePublicAPI,
|
||||
FixedBaseURL: "https://dashboard.hammerhead.io",
|
||||
AllowedPathPrefixes: []string{"/v1"},
|
||||
Auth: []string{"provider_session"},
|
||||
}},
|
||||
},
|
||||
Auth: []string{"provider_session"},
|
||||
Uploads: UploadPermissions{
|
||||
MaxBytes: 10 << 20,
|
||||
ContentTypes: []string{"application/gpx+xml", "application/xml"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func writeManifest(t *testing.T, dir string, manifest Manifest) {
|
||||
t.Helper()
|
||||
data := []byte(`{
|
||||
"manifestVersion": "1.0",
|
||||
"id": "` + manifest.ID + `",
|
||||
"type": "` + manifest.Type + `",
|
||||
"name": "` + manifest.Name + `",
|
||||
"version": "` + manifest.Version + `",
|
||||
"runtime": {
|
||||
"type": "` + manifest.Runtime.Type + `",
|
||||
"entrypoint": "` + manifest.Runtime.Entrypoint + `"
|
||||
},
|
||||
"capabilities": [
|
||||
{"name": "prepare_trail_send", "version": "v1", "export": "prepare_trail_send_v1"}
|
||||
]
|
||||
}`)
|
||||
if err := os.WriteFile(filepath.Join(dir, "plugin.json"), data, 0o600); err != nil {
|
||||
t.Fatalf("write manifest: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writePluginDir(t *testing.T, root string, id string) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(root, id)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatalf("mkdir plugin dir: %v", err)
|
||||
}
|
||||
manifest := hammerheadManifestForTest()
|
||||
manifest.ID = id
|
||||
manifest.Name = id
|
||||
writeManifest(t, dir, manifest)
|
||||
if err := os.WriteFile(filepath.Join(dir, "plugin.wasm"), []byte("wasm"), 0o600); err != nil {
|
||||
t.Fatalf("write wasm: %v", err)
|
||||
}
|
||||
}
|
||||
335
db/pluginsystem/oauth.go
Normal file
335
db/pluginsystem/oauth.go
Normal file
@@ -0,0 +1,335 @@
|
||||
package pluginsystem
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
const (
|
||||
AuthFieldOAuthContext = "oauthContext"
|
||||
AuthFieldTokenType = "tokenType"
|
||||
AuthFieldExpiresAt = "expiresAt"
|
||||
AuthFieldScope = "scope"
|
||||
)
|
||||
|
||||
type OAuthTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
TokenType string `json:"token_type,omitempty"`
|
||||
ExpiresIn int `json:"expires_in,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Raw json.RawMessage `json:"-"`
|
||||
}
|
||||
|
||||
// OAuthContext selects the OAuth auth context declared by a plugin. When the UI
|
||||
// does not request a specific context, the first context by name is used.
|
||||
func OAuthContext(plugin LocalPlugin, requested string) (string, AuthContext, error) {
|
||||
names := make([]string, 0, len(plugin.Manifest.Auth.Contexts))
|
||||
for name := range plugin.Manifest.Auth.Contexts {
|
||||
names = append(names, name)
|
||||
}
|
||||
slices.Sort(names)
|
||||
for _, name := range names {
|
||||
context := plugin.Manifest.Auth.Contexts[name]
|
||||
if requested != "" && requested != name {
|
||||
continue
|
||||
}
|
||||
if context.Type == AuthTypeOAuth2 {
|
||||
return name, context, nil
|
||||
}
|
||||
}
|
||||
return "", AuthContext{}, fmt.Errorf("plugin has no oauth auth context")
|
||||
}
|
||||
|
||||
// ValidateOAuthRedirectURI accepts only the frontend plugin OAuth callback and,
|
||||
// when ORIGIN is configured, requires the same external origin.
|
||||
func ValidateOAuthRedirectURI(raw string) error {
|
||||
redirectURL, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if redirectURL.Scheme != "http" && redirectURL.Scheme != "https" {
|
||||
return fmt.Errorf("redirect uri scheme must be http or https")
|
||||
}
|
||||
if redirectURL.Host == "" {
|
||||
return fmt.Errorf("redirect uri must be absolute")
|
||||
}
|
||||
if redirectURL.Path != "/settings/plugins/oauth/callback" {
|
||||
return fmt.Errorf("redirect uri path is not allowed")
|
||||
}
|
||||
if origin := strings.TrimRight(os.Getenv("ORIGIN"), "/"); origin != "" {
|
||||
originURL, err := url.Parse(origin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.EqualFold(redirectURL.Scheme, originURL.Scheme) || !strings.EqualFold(redirectURL.Host, originURL.Host) {
|
||||
return fmt.Errorf("redirect uri origin does not match ORIGIN")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewOAuthState(size int) string {
|
||||
return randomURLToken(size)
|
||||
}
|
||||
|
||||
func NewOAuthCodeVerifier(size int) string {
|
||||
return randomURLToken(size)
|
||||
}
|
||||
|
||||
func PKCEChallenge(verifier string) string {
|
||||
hash := sha256.Sum256([]byte(verifier))
|
||||
return base64.RawURLEncoding.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// ExchangeOAuthToken performs the host-owned OAuth token exchange or refresh.
|
||||
// The token endpoint must be allowed by the plugin manifest network policy.
|
||||
func ExchangeOAuthToken(ctx context.Context, manifest Manifest, authContext AuthContext, auth map[string]any, values map[string]string) (*OAuthTokenResponse, error) {
|
||||
tokenURL, err := url.Parse(authContext.TokenURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tokenURL.Scheme != "http" && tokenURL.Scheme != "https" {
|
||||
return nil, fmt.Errorf("oauth token url scheme must be http or https")
|
||||
}
|
||||
if !OAuthTokenURLAllowed(manifest, tokenURL) {
|
||||
return nil, fmt.Errorf("oauth token host %q is not allowed by manifest permissions", tokenURL.Hostname())
|
||||
}
|
||||
|
||||
clientID := StringFromAny(auth["clientId"])
|
||||
clientSecret := StringFromAny(auth[AuthFieldClientSecret])
|
||||
if clientID == "" {
|
||||
return nil, fmt.Errorf("clientId is required")
|
||||
}
|
||||
|
||||
bodyValues := url.Values{}
|
||||
for key, value := range values {
|
||||
if value != "" {
|
||||
bodyValues.Set(key, value)
|
||||
}
|
||||
}
|
||||
bodyValues.Set("client_id", clientID)
|
||||
if authContext.TokenAuth == "" || authContext.TokenAuth == TokenAuthClientSecretPost {
|
||||
if clientSecret != "" {
|
||||
bodyValues.Set("client_secret", clientSecret)
|
||||
}
|
||||
}
|
||||
|
||||
var body []byte
|
||||
contentType := "application/x-www-form-urlencoded"
|
||||
if authContext.TokenRequestFormat == TokenRequestFormatJSON {
|
||||
jsonBody := map[string]string{}
|
||||
for key, value := range bodyValues {
|
||||
if len(value) > 0 {
|
||||
jsonBody[key] = value[0]
|
||||
}
|
||||
}
|
||||
var err error
|
||||
body, err = json.Marshal(jsonBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contentType = "application/json"
|
||||
} else {
|
||||
body = []byte(bodyValues.Encode())
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL.String(), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
if authContext.TokenAuth == TokenAuthClientSecretBasic && clientSecret != "" {
|
||||
req.SetBasicAuth(clientID, clientSecret)
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(respBody)))
|
||||
}
|
||||
var token OAuthTokenResponse
|
||||
token.Raw = append([]byte{}, respBody...)
|
||||
if err := json.Unmarshal(respBody, &token); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if token.AccessToken == "" {
|
||||
return nil, fmt.Errorf("oauth token response has no access_token")
|
||||
}
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
func OAuthTokenURLAllowed(manifest Manifest, tokenURL *url.URL) bool {
|
||||
for _, connector := range manifest.Permissions.Network.Connectors {
|
||||
if connector.Type != ConnectorTypePublicAPI {
|
||||
continue
|
||||
}
|
||||
baseURL, basePath, err := NormalizeConnectorBase(connector.FixedBaseURL, "")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
target := ResolvedConnectorTarget{
|
||||
Name: connector.Name,
|
||||
Type: connector.Type,
|
||||
BaseURL: baseURL,
|
||||
BasePath: basePath,
|
||||
AllowedPathPrefixes: connector.AllowedPathPrefixes,
|
||||
}
|
||||
if err := ValidateConnectorURL(target, tokenURL); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RefreshOAuthToken uses the stored refresh token, persists the refreshed auth
|
||||
// map, and keeps the plugin instance configured when refresh succeeds.
|
||||
func RefreshOAuthToken(ctx context.Context, app core.App, plugin LocalPlugin, instance *core.Record, auth map[string]any, contextName string) (map[string]any, error) {
|
||||
_, authContext, err := OAuthContext(plugin, contextName)
|
||||
if err != nil {
|
||||
return auth, err
|
||||
}
|
||||
grantType := "refresh_token"
|
||||
if authContext.Refresh != nil && authContext.Refresh.GrantType != "" {
|
||||
grantType = authContext.Refresh.GrantType
|
||||
}
|
||||
refreshToken := StringFromAny(auth[AuthFieldRefreshToken])
|
||||
if refreshToken == "" {
|
||||
return auth, fmt.Errorf("refreshToken is missing")
|
||||
}
|
||||
token, err := ExchangeOAuthToken(ctx, plugin.Manifest, authContext, auth, map[string]string{
|
||||
"grant_type": grantType,
|
||||
"refresh_token": refreshToken,
|
||||
})
|
||||
if err != nil {
|
||||
return auth, err
|
||||
}
|
||||
if token.RefreshToken == "" {
|
||||
token.RefreshToken = refreshToken
|
||||
}
|
||||
StoreOAuthToken(auth, contextName, token)
|
||||
instance.Set("auth", auth)
|
||||
instance.Set("status", "configured")
|
||||
if err := app.Save(instance); err != nil {
|
||||
return auth, err
|
||||
}
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
// StoreOAuthToken normalizes provider token responses into the plugin instance
|
||||
// auth map used by host injection and future refreshes.
|
||||
func StoreOAuthToken(auth map[string]any, contextName string, token *OAuthTokenResponse) {
|
||||
auth[AuthFieldOAuthContext] = contextName
|
||||
auth[AuthFieldAccessToken] = token.AccessToken
|
||||
if token.RefreshToken != "" {
|
||||
auth[AuthFieldRefreshToken] = token.RefreshToken
|
||||
}
|
||||
if token.TokenType != "" {
|
||||
auth[AuthFieldTokenType] = token.TokenType
|
||||
}
|
||||
if token.Scope != "" {
|
||||
auth[AuthFieldScope] = token.Scope
|
||||
}
|
||||
if token.ExpiresIn > 0 {
|
||||
auth[AuthFieldExpiresAt] = time.Now().Add(time.Duration(token.ExpiresIn) * time.Second).UTC().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
|
||||
// ClearOAuthToken removes persisted OAuth token material and transient OAuth
|
||||
// flow fields from an auth map.
|
||||
func ClearOAuthToken(auth map[string]any) {
|
||||
for _, key := range []string{
|
||||
AuthFieldAccessToken,
|
||||
AuthFieldRefreshToken,
|
||||
AuthFieldTokenType,
|
||||
AuthFieldExpiresAt,
|
||||
AuthFieldScope,
|
||||
AuthFieldOAuthState,
|
||||
AuthFieldOAuthCodeVerifier,
|
||||
AuthFieldOAuthRedirectURI,
|
||||
} {
|
||||
delete(auth, key)
|
||||
}
|
||||
}
|
||||
|
||||
// PluginInputAuth returns the auth payload visible to plugin exports. OAuth
|
||||
// token material is intentionally removed because provider requests should go
|
||||
// through host auth injection instead.
|
||||
func PluginInputAuth(plugin LocalPlugin, auth map[string]any) map[string]any {
|
||||
out := map[string]any{}
|
||||
for key, value := range auth {
|
||||
out[key] = value
|
||||
}
|
||||
for _, context := range plugin.Manifest.Auth.Contexts {
|
||||
if context.Type == AuthTypeOAuth2 {
|
||||
for _, key := range PluginInputAuthBlockedFields() {
|
||||
delete(out, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RefreshOAuthAuthIfNeeded refreshes host-managed OAuth before a sync run if no
|
||||
// access token exists or the current token is close to expiry.
|
||||
func RefreshOAuthAuthIfNeeded(ctx context.Context, app core.App, plugin LocalPlugin, instance *core.Record, auth map[string]any) (map[string]any, error) {
|
||||
for name, authContext := range plugin.Manifest.Auth.Contexts {
|
||||
if authContext.Type != AuthTypeOAuth2 {
|
||||
continue
|
||||
}
|
||||
if StringFromAny(auth[AuthFieldAccessToken]) == "" || OAuthNeedsRefresh(auth) {
|
||||
return RefreshOAuthToken(ctx, app, plugin, instance, auth, name)
|
||||
}
|
||||
}
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
func OAuthNeedsRefresh(auth map[string]any) bool {
|
||||
expiresAt := StringFromAny(auth[AuthFieldExpiresAt])
|
||||
if expiresAt == "" {
|
||||
return false
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339, expiresAt)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return time.Until(parsed) < time.Minute
|
||||
}
|
||||
|
||||
func StringFromAny(value any) string {
|
||||
text, _ := value.(string)
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
func randomURLToken(size int) string {
|
||||
data := make([]byte, size)
|
||||
if _, err := rand.Read(data); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(data)
|
||||
}
|
||||
417
db/pluginsystem/policy.go
Normal file
417
db/pluginsystem/policy.go
Normal file
@@ -0,0 +1,417 @@
|
||||
package pluginsystem
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
ConnectorTypePublicAPI = "public_api"
|
||||
ConnectorTypeConfigured = "configured"
|
||||
TLSModeSystem = "system"
|
||||
TLSModeCustomCA = "customCA"
|
||||
)
|
||||
|
||||
type RequestPolicyContext struct {
|
||||
Connectors map[string]ResolvedConnectorTarget
|
||||
HostAuth map[string]any
|
||||
}
|
||||
|
||||
func (p RequestPolicyContext) WithHostAuth(auth map[string]any) RequestPolicyContext {
|
||||
p.HostAuth = auth
|
||||
return p
|
||||
}
|
||||
|
||||
type ResolvedConnectorTarget struct {
|
||||
Name string
|
||||
Type string
|
||||
BaseURL string
|
||||
BasePath string
|
||||
AllowPrivate bool
|
||||
TLS ConnectorTLSConfig
|
||||
StorageOrigins map[string]ResolvedConnectorOrigin
|
||||
AllowedPathPrefixes []string
|
||||
Auth []string
|
||||
SupportsMediaAuth bool
|
||||
SupportsStorageRedirects bool
|
||||
SupportsCustomTLS bool
|
||||
}
|
||||
|
||||
type ConnectorTLSConfig struct {
|
||||
Mode string
|
||||
CABundle []byte
|
||||
}
|
||||
|
||||
type ResolvedConnectorOrigin struct {
|
||||
Name string
|
||||
BaseURL string
|
||||
BasePath string
|
||||
AllowPrivate bool
|
||||
TLS ConnectorTLSConfig
|
||||
}
|
||||
|
||||
type ResolvedRequestTarget struct {
|
||||
URL *url.URL
|
||||
Connector ResolvedConnectorTarget
|
||||
}
|
||||
|
||||
// ValidateHostRequestSpec checks the static manifest policy before the host
|
||||
// performs any plugin-controlled HTTP request. Provider traffic must use a
|
||||
// connector target; plugins no longer hand the host absolute API URLs.
|
||||
func ValidateHostRequestSpec(manifest Manifest, spec HostRequestSpec, policy RequestPolicyContext) error {
|
||||
_, err := ValidateAndResolveHostRequestSpec(manifest, spec, policy)
|
||||
return err
|
||||
}
|
||||
|
||||
func ValidateAndResolveHostRequestSpec(manifest Manifest, spec HostRequestSpec, policy RequestPolicyContext) (*ResolvedRequestTarget, error) {
|
||||
if strings.TrimSpace(spec.Method) == "" {
|
||||
return nil, fmt.Errorf("method is required")
|
||||
}
|
||||
resolved, err := ResolveRequestTarget(manifest, spec.Target, policy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if spec.Auth != "" {
|
||||
if err := ValidateAuthReference(manifest, spec.Auth); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resolved.Connector.Auth) > 0 && !slices.Contains(resolved.Connector.Auth, spec.Auth) {
|
||||
return nil, fmt.Errorf("auth context %q is not permitted for connector %q", spec.Auth, resolved.Connector.Name)
|
||||
}
|
||||
}
|
||||
if err := validateExpectedResponse(spec.Expect, manifest.Permissions.Downloads); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
func ValidateAuthReference(manifest Manifest, auth string) error {
|
||||
if _, ok := manifest.Auth.Contexts[auth]; !ok {
|
||||
return fmt.Errorf("auth context %q is not declared", auth)
|
||||
}
|
||||
if !slices.Contains(manifest.Permissions.Auth, auth) {
|
||||
return fmt.Errorf("auth context %q is not permitted", auth)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ResolveRequestTarget(manifest Manifest, target RequestTarget, policy RequestPolicyContext) (*ResolvedRequestTarget, error) {
|
||||
if target.Type != "connector" {
|
||||
return nil, fmt.Errorf("request target type must be connector")
|
||||
}
|
||||
connector, ok := policy.Connectors[target.Connector]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("connector %q is not configured", target.Connector)
|
||||
}
|
||||
manifestConnector, ok := manifestConnector(manifest, target.Connector)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("connector %q is not declared by manifest", target.Connector)
|
||||
}
|
||||
connector.AllowedPathPrefixes = canonicalConnectorPrefixes(manifestConnector.AllowedPathPrefixes)
|
||||
connector.Auth = manifestConnector.Auth
|
||||
connector.SupportsMediaAuth = manifestConnector.SupportsMediaAuth
|
||||
connector.SupportsStorageRedirects = manifestConnector.SupportsStorageRedirects
|
||||
connector.SupportsCustomTLS = manifestConnector.SupportsCustomTLS
|
||||
|
||||
built, err := BuildConnectorURL(connector, target.Path, target.Query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ValidateConnectorURL(connector, built); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ResolvedRequestTarget{URL: built, Connector: connector}, nil
|
||||
}
|
||||
|
||||
func manifestConnector(manifest Manifest, name string) (ConnectorTargetPermission, bool) {
|
||||
for _, connector := range manifest.Permissions.Network.Connectors {
|
||||
if connector.Name == name {
|
||||
return connector, true
|
||||
}
|
||||
}
|
||||
return ConnectorTargetPermission{}, false
|
||||
}
|
||||
|
||||
func BuildConnectorURL(connector ResolvedConnectorTarget, relPath string, query []QueryParam) (*url.URL, error) {
|
||||
base, err := url.Parse(connector.BaseURL)
|
||||
if err != nil || base.Scheme == "" || base.Host == "" {
|
||||
return nil, fmt.Errorf("connector %q has invalid baseURL", connector.Name)
|
||||
}
|
||||
if base.RawQuery != "" || base.Fragment != "" {
|
||||
return nil, fmt.Errorf("connector %q baseURL must not include query or fragment", connector.Name)
|
||||
}
|
||||
if base.Scheme != "http" && base.Scheme != "https" {
|
||||
return nil, fmt.Errorf("connector %q scheme must be http or https", connector.Name)
|
||||
}
|
||||
base.Path = ""
|
||||
base.RawPath = ""
|
||||
|
||||
cleanBase, err := CanonicalURLPath(connector.BasePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connector %q basePath: %w", connector.Name, err)
|
||||
}
|
||||
cleanRel, err := CanonicalRelativeURLPath(relPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fullPath := joinURLPaths(cleanBase, cleanRel)
|
||||
if strings.HasSuffix(cleanRel, "/") && fullPath != "/" {
|
||||
fullPath += "/"
|
||||
}
|
||||
base.Path = fullPath
|
||||
|
||||
encodedQuery := make([]string, 0, len(query))
|
||||
for _, param := range query {
|
||||
if hasControl(param.Name) || hasControl(param.Value) {
|
||||
return nil, fmt.Errorf("query parameters must not contain control characters")
|
||||
}
|
||||
if param.Name == "" {
|
||||
return nil, fmt.Errorf("query parameter name must not be empty")
|
||||
}
|
||||
encodedQuery = append(encodedQuery, url.QueryEscape(param.Name)+"="+url.QueryEscape(param.Value))
|
||||
}
|
||||
base.RawQuery = strings.Join(encodedQuery, "&")
|
||||
return base, nil
|
||||
}
|
||||
|
||||
func ValidateConnectorURL(connector ResolvedConnectorTarget, candidate *url.URL) error {
|
||||
base, err := url.Parse(connector.BaseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.EqualFold(candidate.Scheme, base.Scheme) {
|
||||
return fmt.Errorf("connector request scheme escaped scope")
|
||||
}
|
||||
if !strings.EqualFold(candidate.Hostname(), base.Hostname()) {
|
||||
return fmt.Errorf("connector request host escaped scope")
|
||||
}
|
||||
if effectivePort(candidate) != effectivePort(base) {
|
||||
return fmt.Errorf("connector request port escaped scope")
|
||||
}
|
||||
|
||||
candidatePath, err := CanonicalURLPath(candidate.EscapedPath())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
basePath, err := CanonicalURLPath(connector.BasePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !pathInPrefix(candidatePath, subtreePrefix(basePath)) {
|
||||
return fmt.Errorf("connector request escaped base path")
|
||||
}
|
||||
prefixes := connector.AllowedPathPrefixes
|
||||
if len(prefixes) == 0 {
|
||||
prefixes = []string{"/"}
|
||||
}
|
||||
for _, prefix := range canonicalConnectorPrefixes(prefixes) {
|
||||
fullPrefix := subtreePrefix(joinURLPaths(basePath, prefix))
|
||||
if pathInPrefix(candidatePath, fullPrefix) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("connector request path is not allowed")
|
||||
}
|
||||
|
||||
func ValidateConnectorRedirect(connector ResolvedConnectorTarget, initial *url.URL, redirected *url.URL) error {
|
||||
if initial.Scheme == "https" && redirected.Scheme == "http" {
|
||||
return fmt.Errorf("connector redirect downgrades https to http")
|
||||
}
|
||||
return ValidateConnectorURL(connector, redirected)
|
||||
}
|
||||
|
||||
func ValidateConnectorStorageRedirect(connector ResolvedConnectorTarget, initial *url.URL, redirected *url.URL) error {
|
||||
_, err := ConnectorStorageRedirectOrigin(connector, initial, redirected)
|
||||
return err
|
||||
}
|
||||
|
||||
func ConnectorStorageRedirectOrigin(connector ResolvedConnectorTarget, initial *url.URL, redirected *url.URL) (ResolvedConnectorOrigin, error) {
|
||||
if !connector.SupportsStorageRedirects {
|
||||
return ResolvedConnectorOrigin{}, fmt.Errorf("connector storage redirects are not supported")
|
||||
}
|
||||
if initial.Scheme == "https" && redirected.Scheme == "http" {
|
||||
return ResolvedConnectorOrigin{}, fmt.Errorf("connector storage redirect downgrades https to http")
|
||||
}
|
||||
for _, origin := range connector.StorageOrigins {
|
||||
target := ResolvedConnectorTarget{
|
||||
Name: origin.Name,
|
||||
BaseURL: origin.BaseURL,
|
||||
BasePath: origin.BasePath,
|
||||
AllowPrivate: origin.AllowPrivate,
|
||||
TLS: origin.TLS,
|
||||
AllowedPathPrefixes: []string{"/"},
|
||||
}
|
||||
if err := ValidateConnectorURL(target, redirected); err == nil {
|
||||
return origin, nil
|
||||
}
|
||||
}
|
||||
return ResolvedConnectorOrigin{}, fmt.Errorf("connector storage redirect target is not allowed")
|
||||
}
|
||||
|
||||
func NormalizeConnectorBase(rawURL string, extraBasePath string) (string, string, error) {
|
||||
parsed, err := url.Parse(strings.TrimSpace(rawURL))
|
||||
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||
return "", "", fmt.Errorf("connector baseURL is invalid")
|
||||
}
|
||||
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
||||
return "", "", fmt.Errorf("connector baseURL scheme must be http or https")
|
||||
}
|
||||
if parsed.RawQuery != "" || parsed.Fragment != "" || parsed.User != nil {
|
||||
return "", "", fmt.Errorf("connector baseURL must not include credentials, query, or fragment")
|
||||
}
|
||||
basePath := parsed.EscapedPath()
|
||||
if extraBasePath != "" {
|
||||
basePath = joinURLPaths(basePath, extraBasePath)
|
||||
}
|
||||
cleanPath, err := CanonicalURLPath(basePath)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
parsed.Path = ""
|
||||
parsed.RawPath = ""
|
||||
return parsed.String(), cleanPath, nil
|
||||
}
|
||||
|
||||
func CanonicalRelativeURLPath(rawPath string) (string, error) {
|
||||
if strings.TrimSpace(rawPath) == "" {
|
||||
return "/", nil
|
||||
}
|
||||
if strings.HasPrefix(rawPath, "http://") || strings.HasPrefix(rawPath, "https://") || strings.HasPrefix(rawPath, "//") {
|
||||
return "", fmt.Errorf("connector path must be relative")
|
||||
}
|
||||
cleaned, err := CanonicalURLPath("/" + strings.TrimLeft(rawPath, "/"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.HasSuffix(rawPath, "/") && cleaned != "/" {
|
||||
cleaned += "/"
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
func CanonicalURLPath(rawPath string) (string, error) {
|
||||
if rawPath == "" {
|
||||
rawPath = "/"
|
||||
}
|
||||
if hasControl(rawPath) {
|
||||
return "", fmt.Errorf("path must not contain control characters")
|
||||
}
|
||||
lower := strings.ToLower(rawPath)
|
||||
if strings.Contains(lower, "%2f") || strings.Contains(lower, "%5c") {
|
||||
return "", fmt.Errorf("encoded path separators are not allowed")
|
||||
}
|
||||
decoded, err := url.PathUnescape(rawPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("path has invalid escapes")
|
||||
}
|
||||
if strings.Contains(decoded, "\\") {
|
||||
return "", fmt.Errorf("backslash is not allowed in URL paths")
|
||||
}
|
||||
if hasDangerousSecondEscape(decoded) {
|
||||
return "", fmt.Errorf("ambiguous encoded path is not allowed")
|
||||
}
|
||||
cleaned := path.Clean("/" + strings.TrimLeft(decoded, "/"))
|
||||
if cleaned == "." {
|
||||
cleaned = "/"
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
func canonicalConnectorPrefixes(prefixes []string) []string {
|
||||
if len(prefixes) == 0 {
|
||||
return nil
|
||||
}
|
||||
canonical := make([]string, 0, len(prefixes))
|
||||
for _, prefix := range prefixes {
|
||||
cleaned, err := CanonicalURLPath(prefix)
|
||||
if err == nil {
|
||||
canonical = append(canonical, cleaned)
|
||||
}
|
||||
}
|
||||
return canonical
|
||||
}
|
||||
|
||||
func joinURLPaths(left string, right string) string {
|
||||
if left == "" {
|
||||
left = "/"
|
||||
}
|
||||
if right == "" {
|
||||
right = "/"
|
||||
}
|
||||
joined := path.Join(left, right)
|
||||
if joined == "." {
|
||||
return "/"
|
||||
}
|
||||
if !strings.HasPrefix(joined, "/") {
|
||||
joined = "/" + joined
|
||||
}
|
||||
return joined
|
||||
}
|
||||
|
||||
func subtreePrefix(prefix string) string {
|
||||
if prefix == "/" {
|
||||
return "/"
|
||||
}
|
||||
return strings.TrimRight(prefix, "/") + "/"
|
||||
}
|
||||
|
||||
func pathInPrefix(candidate string, prefix string) bool {
|
||||
if prefix == "/" {
|
||||
return true
|
||||
}
|
||||
candidate = subtreePrefix(candidate)
|
||||
return strings.HasPrefix(candidate, prefix)
|
||||
}
|
||||
|
||||
func effectivePort(u *url.URL) string {
|
||||
if port := u.Port(); port != "" {
|
||||
return port
|
||||
}
|
||||
switch u.Scheme {
|
||||
case "http":
|
||||
return "80"
|
||||
case "https":
|
||||
return "443"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func hasControl(value string) bool {
|
||||
for _, r := range value {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasDangerousSecondEscape(value string) bool {
|
||||
lower := strings.ToLower(value)
|
||||
for _, marker := range []string{"%2f", "%5c", "%2e"} {
|
||||
if strings.Contains(lower, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// validateExpectedResponse lets a plugin request stricter response checks for a
|
||||
// specific call while preventing it from exceeding manifest download limits.
|
||||
func validateExpectedResponse(expect ResponseExpect, permissions DownloadPermissions) error {
|
||||
if expect.MaxBytes < 0 {
|
||||
return fmt.Errorf("expect.maxBytes must not be negative")
|
||||
}
|
||||
if permissions.MaxBytes > 0 && expect.MaxBytes > permissions.MaxBytes {
|
||||
return fmt.Errorf("expect.maxBytes exceeds manifest download limit")
|
||||
}
|
||||
for _, contentType := range expect.ContentTypes {
|
||||
if len(permissions.ContentTypes) > 0 && !slices.Contains(permissions.ContentTypes, contentType) {
|
||||
return fmt.Errorf("content type %q is not allowed by manifest permissions", contentType)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
169
db/pluginsystem/policy_test.go
Normal file
169
db/pluginsystem/policy_test.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package pluginsystem
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateHostRequestSpecAcceptsConnectorAndAuthReference(t *testing.T) {
|
||||
manifest := hammerheadManifestForTest()
|
||||
spec := HostRequestSpec{
|
||||
Method: "POST",
|
||||
Target: RequestTarget{
|
||||
Type: "connector",
|
||||
Connector: "api",
|
||||
Path: "/v1/users/123/routes/import/file",
|
||||
},
|
||||
Auth: "provider_session",
|
||||
Expect: ResponseExpect{
|
||||
ContentTypes: []string{"application/json"},
|
||||
MaxBytes: 1024,
|
||||
},
|
||||
}
|
||||
manifest.Permissions.Downloads.ContentTypes = append(manifest.Permissions.Downloads.ContentTypes, "application/json")
|
||||
manifest.Permissions.Downloads.MaxBytes = 2048
|
||||
|
||||
if err := ValidateHostRequestSpec(manifest, spec, testPolicy()); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateHostRequestSpecRejectsUnknownConnector(t *testing.T) {
|
||||
manifest := hammerheadManifestForTest()
|
||||
spec := HostRequestSpec{
|
||||
Method: "GET",
|
||||
Target: RequestTarget{Type: "connector", Connector: "evil", Path: "/v1"},
|
||||
}
|
||||
|
||||
if err := ValidateHostRequestSpec(manifest, spec, testPolicy()); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateHostRequestSpecRejectsPathScopeEscape(t *testing.T) {
|
||||
manifest := hammerheadManifestForTest()
|
||||
spec := HostRequestSpec{
|
||||
Method: "GET",
|
||||
Target: RequestTarget{Type: "connector", Connector: "api", Path: "/v1-evil"},
|
||||
}
|
||||
|
||||
if err := ValidateHostRequestSpec(manifest, spec, testPolicy()); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateHostRequestSpecRejectsLimitExpansion(t *testing.T) {
|
||||
manifest := hammerheadManifestForTest()
|
||||
manifest.Permissions.Downloads.MaxBytes = 100
|
||||
spec := HostRequestSpec{
|
||||
Method: "GET",
|
||||
Target: RequestTarget{Type: "connector", Connector: "api", Path: "/v1/users"},
|
||||
Expect: ResponseExpect{MaxBytes: 101},
|
||||
}
|
||||
|
||||
if err := ValidateHostRequestSpec(manifest, spec, testPolicy()); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConnectorURLPreservesBasePathAndQueryOrder(t *testing.T) {
|
||||
target := ResolvedConnectorTarget{
|
||||
Name: "immich",
|
||||
BaseURL: "https://photos.example.test:8443",
|
||||
BasePath: "/immich",
|
||||
AllowedPathPrefixes: []string{"/api"},
|
||||
}
|
||||
u, err := BuildConnectorURL(target, "/api/assets/1/original", []QueryParam{
|
||||
{Name: "z", Value: "last"},
|
||||
{Name: "key", Value: "a"},
|
||||
{Name: "key", Value: "b"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if u.String() != "https://photos.example.test:8443/immich/api/assets/1/original?z=last&key=a&key=b" {
|
||||
t.Fatalf("unexpected url: %s", u.String())
|
||||
}
|
||||
if err := ValidateConnectorURL(target, u); err != nil {
|
||||
t.Fatalf("unexpected scope error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildConnectorURLPreservesTrailingSlash(t *testing.T) {
|
||||
target := ResolvedConnectorTarget{
|
||||
Name: "komoot",
|
||||
BaseURL: "https://api.komoot.de",
|
||||
BasePath: "/",
|
||||
AllowedPathPrefixes: []string{"/v006"},
|
||||
}
|
||||
u, err := BuildConnectorURL(target, "/v006/account/email/user%40example.test/", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if u.String() != "https://api.komoot.de/v006/account/email/user@example.test/" {
|
||||
t.Fatalf("unexpected url: %s", u.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectorPathNormalizationRejectsAmbiguousEscapes(t *testing.T) {
|
||||
for _, candidate := range []string{"/api%2fadmin", "/api/%252e%252e/admin", "/api/../admin"} {
|
||||
t.Run(candidate, func(t *testing.T) {
|
||||
target := ResolvedConnectorTarget{
|
||||
Name: "api",
|
||||
BaseURL: "https://example.test",
|
||||
BasePath: "/",
|
||||
AllowedPathPrefixes: []string{"/api"},
|
||||
}
|
||||
u, err := BuildConnectorURL(target, candidate, nil)
|
||||
if err == nil {
|
||||
err = ValidateConnectorURL(target, u)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("expected scope error")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectorStorageRedirectOriginReturnsMatchedOriginPolicy(t *testing.T) {
|
||||
connector := ResolvedConnectorTarget{
|
||||
Name: "immich",
|
||||
BaseURL: "https://photos.example.test",
|
||||
BasePath: "/immich",
|
||||
SupportsStorageRedirects: true,
|
||||
StorageOrigins: map[string]ResolvedConnectorOrigin{
|
||||
"minio": {
|
||||
Name: "minio",
|
||||
BaseURL: "https://storage.example.test:9443",
|
||||
BasePath: "/assets",
|
||||
AllowPrivate: true,
|
||||
TLS: ConnectorTLSConfig{Mode: TLSModeCustomCA, CABundle: []byte("ca")},
|
||||
},
|
||||
},
|
||||
}
|
||||
initial, _ := BuildConnectorURL(connector, "/api/assets/1/original", nil)
|
||||
redirected, _ := url.Parse("https://storage.example.test:9443/assets/bucket/photo.jpg")
|
||||
|
||||
origin, err := ConnectorStorageRedirectOrigin(connector, initial, redirected)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if origin.Name != "minio" || !origin.AllowPrivate || origin.TLS.Mode != TLSModeCustomCA {
|
||||
t.Fatalf("unexpected origin policy: %#v", origin)
|
||||
}
|
||||
}
|
||||
|
||||
func testPolicy() RequestPolicyContext {
|
||||
return RequestPolicyContext{
|
||||
Connectors: map[string]ResolvedConnectorTarget{
|
||||
"api": {
|
||||
Name: "api",
|
||||
Type: ConnectorTypePublicAPI,
|
||||
BaseURL: "https://dashboard.hammerhead.io",
|
||||
BasePath: "/",
|
||||
AllowedPathPrefixes: []string{"/v1"},
|
||||
Auth: []string{"provider_session"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
212
db/pluginsystem/protocol.go
Normal file
212
db/pluginsystem/protocol.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package pluginsystem
|
||||
|
||||
const (
|
||||
ManifestVersion = "1.0"
|
||||
RuntimeWASM = "wasm"
|
||||
|
||||
PluginTypeTrails = "trails"
|
||||
|
||||
AuthTypeOAuth2 = "oauth2"
|
||||
AuthTypeAPIKey = "api_key"
|
||||
AuthTypeBearer = "bearer"
|
||||
AuthTypeSession = "session"
|
||||
|
||||
AuthRefreshModeHost = "host"
|
||||
AuthRefreshModePlugin = "plugin"
|
||||
|
||||
AuthPlacementQuery = "query"
|
||||
AuthHeaderAuthorization = "Authorization"
|
||||
AuthSchemeBearer = "Bearer"
|
||||
TokenRequestFormatJSON = "json"
|
||||
TokenAuthClientSecretPost = "client_secret_post"
|
||||
TokenAuthClientSecretBasic = "client_secret_basic"
|
||||
|
||||
HostRequestBodyTypeJSON = "json"
|
||||
HostRequestBodyTypeForm = "form"
|
||||
HostRequestBodyTypeMultipart = "multipart"
|
||||
MultipartSourceTrail = "trail"
|
||||
MultipartSourceTrailGPX = "trail.gpx"
|
||||
MultipartTrailFilename = "trail.gpx"
|
||||
)
|
||||
|
||||
type Manifest struct {
|
||||
ManifestVersion string `json:"manifestVersion"`
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Version string `json:"version"`
|
||||
Runtime RuntimeManifest `json:"runtime"`
|
||||
Capabilities []CapabilityManifest `json:"capabilities"`
|
||||
Auth AuthManifest `json:"auth,omitempty"`
|
||||
Permissions PermissionManifest `json:"permissions,omitempty"`
|
||||
ConfigSchema []ConfigField `json:"configSchema,omitempty"`
|
||||
HostConfig map[string]any `json:"hostConfig,omitempty"`
|
||||
Metadata map[string]any `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
type RuntimeManifest struct {
|
||||
Type string `json:"type"`
|
||||
Entrypoint string `json:"entrypoint"`
|
||||
}
|
||||
|
||||
type CapabilityManifest struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Export string `json:"export"`
|
||||
RequiredFunctions []string `json:"requiredHostFunctions,omitempty"`
|
||||
Job string `json:"job,omitempty"`
|
||||
}
|
||||
|
||||
type ConfigField struct {
|
||||
Key string `json:"key"`
|
||||
Type string `json:"type"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Descriptions map[string]string `json:"descriptions,omitempty"`
|
||||
Options []ConfigFieldOption `json:"options,omitempty"`
|
||||
Default any `json:"default,omitempty"`
|
||||
Required bool `json:"required,omitempty"`
|
||||
Hidden bool `json:"hidden,omitempty"`
|
||||
}
|
||||
|
||||
type ConfigFieldOption struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
}
|
||||
|
||||
type AuthManifest struct {
|
||||
Contexts map[string]AuthContext `json:"contexts,omitempty"`
|
||||
}
|
||||
|
||||
type AuthContext struct {
|
||||
Type string `json:"type"`
|
||||
Fields []string `json:"fields,omitempty"`
|
||||
AuthorizationURL string `json:"authorizationUrl,omitempty"`
|
||||
TokenURL string `json:"tokenUrl,omitempty"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
ScopeSeparator string `json:"scopeSeparator,omitempty"`
|
||||
PKCE bool `json:"pkce,omitempty"`
|
||||
TokenRequestFormat string `json:"tokenRequestFormat,omitempty"`
|
||||
TokenAuth string `json:"tokenAuth,omitempty"`
|
||||
AuthorizationParams map[string]string `json:"authorizationParams,omitempty"`
|
||||
Refresh *AuthRefresh `json:"refresh,omitempty"`
|
||||
Placement string `json:"placement,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
SecretField string `json:"secretField,omitempty"`
|
||||
SecretFields []string `json:"secretFields,omitempty"`
|
||||
}
|
||||
|
||||
type AuthRefresh struct {
|
||||
Mode string `json:"mode"`
|
||||
GrantType string `json:"grantType,omitempty"`
|
||||
Function string `json:"function,omitempty"`
|
||||
}
|
||||
|
||||
type PermissionManifest struct {
|
||||
Network NetworkPermissions `json:"network,omitempty"`
|
||||
Auth []string `json:"auth,omitempty"`
|
||||
Downloads DownloadPermissions `json:"downloads,omitempty"`
|
||||
Uploads UploadPermissions `json:"uploads,omitempty"`
|
||||
}
|
||||
|
||||
type NetworkPermissions struct {
|
||||
Connectors []ConnectorTargetPermission `json:"connectors,omitempty"`
|
||||
Redirects RedirectPermissions `json:"redirects,omitempty"`
|
||||
}
|
||||
|
||||
type ConnectorTargetPermission struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
FixedBaseURL string `json:"fixedBaseURL,omitempty"`
|
||||
ConfigKey string `json:"configKey,omitempty"`
|
||||
AllowedPathPrefixes []string `json:"allowedPathPrefixes,omitempty"`
|
||||
Auth []string `json:"auth,omitempty"`
|
||||
SupportsMediaAuth bool `json:"supportsMediaAuth,omitempty"`
|
||||
SupportsStorageRedirects bool `json:"supportsStorageRedirects,omitempty"`
|
||||
SupportsCustomTLS bool `json:"supportsCustomTLS,omitempty"`
|
||||
}
|
||||
|
||||
type RedirectPermissions struct {
|
||||
Mode string `json:"mode,omitempty"`
|
||||
Hosts []string `json:"hosts,omitempty"`
|
||||
}
|
||||
|
||||
type DownloadPermissions struct {
|
||||
MaxBytes int64 `json:"maxBytes,omitempty"`
|
||||
ContentTypes []string `json:"contentTypes,omitempty"`
|
||||
}
|
||||
|
||||
type UploadPermissions struct {
|
||||
MaxBytes int64 `json:"maxBytes,omitempty"`
|
||||
ContentTypes []string `json:"contentTypes,omitempty"`
|
||||
}
|
||||
|
||||
type HostRequestSpec struct {
|
||||
Method string `json:"method"`
|
||||
Target RequestTarget `json:"target"`
|
||||
Auth string `json:"auth,omitempty"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
Body *HostRequestBody `json:"body,omitempty"`
|
||||
Expect ResponseExpect `json:"expect,omitempty"`
|
||||
FollowRedirects *bool `json:"followRedirects,omitempty"`
|
||||
}
|
||||
|
||||
type RequestTarget struct {
|
||||
Type string `json:"type"`
|
||||
Connector string `json:"connector,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Query []QueryParam `json:"query,omitempty"`
|
||||
}
|
||||
|
||||
type QueryParam struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type HostRequestBody struct {
|
||||
Type string `json:"type"`
|
||||
JSON any `json:"json,omitempty"`
|
||||
Form []FormField `json:"form,omitempty"`
|
||||
Parts []MultipartPart `json:"parts,omitempty"`
|
||||
}
|
||||
|
||||
type FormField struct {
|
||||
Name string `json:"name"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type MultipartPart struct {
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Filename string `json:"filename,omitempty"`
|
||||
ContentType string `json:"contentType,omitempty"`
|
||||
JSON any `json:"json,omitempty"`
|
||||
}
|
||||
|
||||
type ResponseExpect struct {
|
||||
ContentTypes []string `json:"contentTypes,omitempty"`
|
||||
MaxBytes int64 `json:"maxBytes,omitempty"`
|
||||
}
|
||||
|
||||
type TrackTransferPlan struct {
|
||||
Format string `json:"format"`
|
||||
Transfer HostRequestSpec `json:"transfer"`
|
||||
}
|
||||
|
||||
type TrailSendPlan struct {
|
||||
Request HostRequestSpec `json:"request"`
|
||||
}
|
||||
|
||||
type PluginError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message,omitempty"`
|
||||
RetryAfterSeconds *int `json:"retryAfterSeconds,omitempty"`
|
||||
}
|
||||
|
||||
type HostLogEntry struct {
|
||||
Level string `json:"level"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
64
db/pluginsystem/runtime.go
Normal file
64
db/pluginsystem/runtime.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package pluginsystem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var ErrRuntimeUnavailable = errors.New("plugin runtime is not available")
|
||||
|
||||
type Runtime interface {
|
||||
Call(ctx context.Context, plugin LocalPlugin, export string, input []byte, policy RequestPolicyContext) ([]byte, error)
|
||||
OpenSession(ctx context.Context, plugin LocalPlugin, policy RequestPolicyContext) (RuntimeSession, error)
|
||||
}
|
||||
|
||||
type RuntimeSession interface {
|
||||
Call(ctx context.Context, export string, input []byte) ([]byte, error)
|
||||
Close(ctx context.Context) error
|
||||
}
|
||||
|
||||
type RuntimeRegistry struct {
|
||||
wasm Runtime
|
||||
}
|
||||
|
||||
// NewRuntimeRegistry wires available runtime implementations behind the common
|
||||
// Runtime interface.
|
||||
func NewRuntimeRegistry() *RuntimeRegistry {
|
||||
return &RuntimeRegistry{
|
||||
wasm: NewWorkerRuntime(),
|
||||
}
|
||||
}
|
||||
|
||||
// RuntimeFor selects the runtime declared by a plugin manifest.
|
||||
func (r *RuntimeRegistry) RuntimeFor(plugin LocalPlugin) (Runtime, error) {
|
||||
switch plugin.Manifest.Runtime.Type {
|
||||
case RuntimeWASM:
|
||||
return r.wasm, nil
|
||||
default:
|
||||
return nil, ErrRuntimeUnavailable
|
||||
}
|
||||
}
|
||||
|
||||
type UnavailableRuntime struct{}
|
||||
|
||||
func (UnavailableRuntime) Call(context.Context, LocalPlugin, string, []byte, RequestPolicyContext) ([]byte, error) {
|
||||
return nil, ErrRuntimeUnavailable
|
||||
}
|
||||
|
||||
func (UnavailableRuntime) OpenSession(context.Context, LocalPlugin, RequestPolicyContext) (RuntimeSession, error) {
|
||||
return nil, ErrRuntimeUnavailable
|
||||
}
|
||||
|
||||
type PluginCallError struct {
|
||||
PluginID string
|
||||
Export string
|
||||
PluginError PluginError
|
||||
}
|
||||
|
||||
func (e PluginCallError) Error() string {
|
||||
if e.PluginError.Message == "" {
|
||||
return fmt.Sprintf("call %s.%s: %s", e.PluginID, e.Export, e.PluginError.Code)
|
||||
}
|
||||
return fmt.Sprintf("call %s.%s: %s: %s", e.PluginID, e.Export, e.PluginError.Code, e.PluginError.Message)
|
||||
}
|
||||
89
db/pluginsystem/status.go
Normal file
89
db/pluginsystem/status.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package pluginsystem
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PluginCapabilityError wraps a plugin-reported error returned inside a
|
||||
// successful export response, so status mapping can treat it like runtime
|
||||
// PluginCallError failures.
|
||||
type PluginCapabilityError struct {
|
||||
Err *PluginError
|
||||
}
|
||||
|
||||
func (e PluginCapabilityError) Error() string {
|
||||
if e.Err == nil {
|
||||
return "plugin error"
|
||||
}
|
||||
if e.Err.Message == "" {
|
||||
return fmt.Sprintf("plugin error %s", e.Err.Code)
|
||||
}
|
||||
return fmt.Sprintf("plugin error %s: %s", e.Err.Code, e.Err.Message)
|
||||
}
|
||||
|
||||
// InstanceStatusUpdate contains the normalized status fields that are written
|
||||
// back to plugin_instances after a failed sync.
|
||||
type InstanceStatusUpdate struct {
|
||||
Status string
|
||||
Code string
|
||||
Message string
|
||||
RetryNotBefore *time.Time
|
||||
}
|
||||
|
||||
// InstanceStatusForError converts sync/runtime errors into the persisted
|
||||
// plugin_instances status fields used by the UI and cron backoff logic.
|
||||
func InstanceStatusForError(err error, now time.Time) InstanceStatusUpdate {
|
||||
var capabilityErr PluginCapabilityError
|
||||
var callErr PluginCallError
|
||||
if errors.As(err, &capabilityErr) && capabilityErr.Err != nil {
|
||||
return InstanceStatusForPluginError(*capabilityErr.Err, now)
|
||||
}
|
||||
if errors.As(err, &callErr) {
|
||||
return InstanceStatusForPluginError(callErr.PluginError, now)
|
||||
}
|
||||
return InstanceStatusUpdate{
|
||||
Status: "error",
|
||||
Code: "provider_unavailable",
|
||||
Message: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
// InstanceStatusForPluginError maps the stable plugin error codes from the ABI
|
||||
// to host instance states. retryAfterSeconds wins over default retry windows.
|
||||
func InstanceStatusForPluginError(pluginErr PluginError, now time.Time) InstanceStatusUpdate {
|
||||
code := strings.TrimSpace(pluginErr.Code)
|
||||
if code == "" {
|
||||
code = "provider_unavailable"
|
||||
}
|
||||
message := strings.TrimSpace(pluginErr.Message)
|
||||
if message == "" {
|
||||
message = code
|
||||
}
|
||||
|
||||
status := "error"
|
||||
switch code {
|
||||
case "auth_failed", "invalid_grant", "unauthorized":
|
||||
status = "needs_reauth"
|
||||
case "rate_limited":
|
||||
status = "rate_limited"
|
||||
case "provider_unavailable", "temporary_unavailable":
|
||||
status = "unavailable"
|
||||
}
|
||||
|
||||
update := InstanceStatusUpdate{
|
||||
Status: status,
|
||||
Code: code,
|
||||
Message: message,
|
||||
}
|
||||
if pluginErr.RetryAfterSeconds != nil && *pluginErr.RetryAfterSeconds > 0 {
|
||||
retryNotBefore := now.Add(time.Duration(*pluginErr.RetryAfterSeconds) * time.Second)
|
||||
update.RetryNotBefore = &retryNotBefore
|
||||
} else if code == "rate_limited" {
|
||||
retryNotBefore := now.Add(time.Hour)
|
||||
update.RetryNotBefore = &retryNotBefore
|
||||
}
|
||||
return update
|
||||
}
|
||||
59
db/pluginsystem/status_test.go
Normal file
59
db/pluginsystem/status_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package pluginsystem
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestInstanceStatusForPluginCapabilityError(t *testing.T) {
|
||||
now := time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC)
|
||||
retryAfter := 120
|
||||
|
||||
update := InstanceStatusForError(PluginCapabilityError{Err: &PluginError{
|
||||
Code: "rate_limited",
|
||||
Message: "try later",
|
||||
RetryAfterSeconds: &retryAfter,
|
||||
}}, now)
|
||||
|
||||
if update.Status != "rate_limited" {
|
||||
t.Fatalf("expected status rate_limited, got %q", update.Status)
|
||||
}
|
||||
if update.Code != "rate_limited" || update.Message != "try later" {
|
||||
t.Fatalf("unexpected error fields: %#v", update)
|
||||
}
|
||||
if update.RetryNotBefore == nil || !update.RetryNotBefore.Equal(now.Add(120*time.Second)) {
|
||||
t.Fatalf("unexpected retry time: %#v", update.RetryNotBefore)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceStatusForPluginCallError(t *testing.T) {
|
||||
update := InstanceStatusForError(PluginCallError{
|
||||
PluginID: "strava",
|
||||
Export: "list_activities_v1",
|
||||
PluginError: PluginError{
|
||||
Code: "invalid_grant",
|
||||
},
|
||||
}, time.Now())
|
||||
|
||||
if update.Status != "needs_reauth" {
|
||||
t.Fatalf("expected status needs_reauth, got %q", update.Status)
|
||||
}
|
||||
if update.Code != "invalid_grant" || update.Message != "invalid_grant" {
|
||||
t.Fatalf("unexpected error fields: %#v", update)
|
||||
}
|
||||
if update.RetryNotBefore != nil {
|
||||
t.Fatalf("did not expect retry time: %#v", update.RetryNotBefore)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceStatusForGenericError(t *testing.T) {
|
||||
update := InstanceStatusForError(errors.New("network unavailable"), time.Now())
|
||||
|
||||
if update.Status != "error" {
|
||||
t.Fatalf("expected status error, got %q", update.Status)
|
||||
}
|
||||
if update.Code != "provider_unavailable" || update.Message != "network unavailable" {
|
||||
t.Fatalf("unexpected error fields: %#v", update)
|
||||
}
|
||||
}
|
||||
507
db/pluginsystem/worker.go
Normal file
507
db/pluginsystem/worker.go
Normal file
@@ -0,0 +1,507 @@
|
||||
package pluginsystem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/semaphore"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultWorkerExportTimeout = 2 * time.Minute
|
||||
defaultWorkerSessionTimeout = 15 * time.Minute
|
||||
defaultWorkerSlotAcquireTimeout = 30 * time.Second
|
||||
defaultWorkerCapturedStderrBytes = 64 * 1024
|
||||
)
|
||||
|
||||
var (
|
||||
workerSlotsMu sync.Mutex
|
||||
workerSlots *semaphore.Weighted
|
||||
)
|
||||
|
||||
type WorkerRuntime struct {
|
||||
Executable string
|
||||
}
|
||||
|
||||
type RuntimeSessionFatalError struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e RuntimeSessionFatalError) Error() string {
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
func (e RuntimeSessionFatalError) Unwrap() error {
|
||||
return e.Err
|
||||
}
|
||||
|
||||
func IsRuntimeSessionFatalError(err error) bool {
|
||||
var fatal RuntimeSessionFatalError
|
||||
return errors.As(err, &fatal)
|
||||
}
|
||||
|
||||
func NewWorkerRuntime() WorkerRuntime {
|
||||
return WorkerRuntime{}
|
||||
}
|
||||
|
||||
func (r WorkerRuntime) Call(ctx context.Context, plugin LocalPlugin, export string, input []byte, policy RequestPolicyContext) ([]byte, error) {
|
||||
session, err := r.OpenSession(ctx, plugin, policy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() {
|
||||
_ = session.Close(context.Background())
|
||||
}()
|
||||
return session.Call(ctx, export, input)
|
||||
}
|
||||
|
||||
func (r WorkerRuntime) OpenSession(ctx context.Context, plugin LocalPlugin, policy RequestPolicyContext) (RuntimeSession, error) {
|
||||
slot, err := acquireWorkerSlot(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
releaseSlot := true
|
||||
defer func() {
|
||||
if releaseSlot {
|
||||
slot.Release(1)
|
||||
}
|
||||
}()
|
||||
|
||||
executable := r.Executable
|
||||
if executable == "" {
|
||||
if configured := strings.TrimSpace(os.Getenv("WANDERER_PLUGIN_WORKER_BIN")); configured != "" {
|
||||
executable = configured
|
||||
} else {
|
||||
var err error
|
||||
executable, err = os.Executable()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cmd := exec.Command(executable, "plugin-worker")
|
||||
cmd.Env = childEnvWithout("EXTISM_ENABLE_WASI_OUTPUT")
|
||||
stdin, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stderr := &boundedWorkerBuffer{limit: envInt("WANDERER_PLUGIN_WORKER_STDERR_BYTES", defaultWorkerCapturedStderrBytes)}
|
||||
cmd.Stderr = stderr
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
session := &workerRuntimeSession{
|
||||
plugin: plugin,
|
||||
policy: policy,
|
||||
sessionID: newWorkerSessionID(plugin.Manifest.ID),
|
||||
cmd: cmd,
|
||||
stdin: stdin,
|
||||
stdout: stdout,
|
||||
stderr: stderr,
|
||||
requestMaxBytes: envInt("WANDERER_PLUGIN_WORKER_REQUEST_BYTES", defaultWorkerRequestMaxBytes),
|
||||
responseMaxBytes: envInt("WANDERER_PLUGIN_WORKER_RESPONSE_BYTES", defaultWorkerResponseMaxBytes),
|
||||
exportTimeout: envDuration("WANDERER_PLUGIN_WORKER_EXPORT_TIMEOUT", defaultWorkerExportTimeout),
|
||||
slot: slot,
|
||||
}
|
||||
session.sessionTimer = time.AfterFunc(envDuration("WANDERER_PLUGIN_WORKER_SESSION_TIMEOUT", defaultWorkerSessionTimeout), func() {
|
||||
session.markFatal("worker session timeout")
|
||||
session.kill()
|
||||
})
|
||||
|
||||
releaseSlot = false
|
||||
return session, nil
|
||||
}
|
||||
|
||||
type workerRuntimeSession struct {
|
||||
plugin LocalPlugin
|
||||
policy RequestPolicyContext
|
||||
sessionID string
|
||||
cmd *exec.Cmd
|
||||
stdin io.WriteCloser
|
||||
stdout io.ReadCloser
|
||||
stderr *boundedWorkerBuffer
|
||||
requestMaxBytes int
|
||||
responseMaxBytes int
|
||||
exportTimeout time.Duration
|
||||
sessionTimer *time.Timer
|
||||
slot *semaphore.Weighted
|
||||
|
||||
mu sync.Mutex
|
||||
callMu sync.Mutex
|
||||
waitMu sync.Mutex
|
||||
waited bool
|
||||
waitErr error
|
||||
closed bool
|
||||
fatal bool
|
||||
fatalMsg string
|
||||
}
|
||||
|
||||
func (s *workerRuntimeSession) Call(ctx context.Context, export string, input []byte) ([]byte, error) {
|
||||
s.callMu.Lock()
|
||||
defer s.callMu.Unlock()
|
||||
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
return nil, fmt.Errorf("worker session is closed")
|
||||
}
|
||||
if s.fatal {
|
||||
msg := s.fatalMsg
|
||||
s.mu.Unlock()
|
||||
return nil, RuntimeSessionFatalError{Err: fmt.Errorf("worker session is invalid: %s", msg)}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
callCtx, cancel := context.WithTimeout(ctx, s.exportTimeout)
|
||||
defer cancel()
|
||||
|
||||
result := make(chan workerCallOutcome, 1)
|
||||
go func() {
|
||||
result <- s.call(callCtx, export, input)
|
||||
}()
|
||||
|
||||
select {
|
||||
case outcome := <-result:
|
||||
if outcome.err != nil {
|
||||
return nil, outcome.err
|
||||
}
|
||||
return outcome.output, nil
|
||||
case <-callCtx.Done():
|
||||
s.markFatal("worker export timeout")
|
||||
s.kill()
|
||||
outcome := <-result
|
||||
if outcome.err != nil && !errors.Is(outcome.err, io.EOF) {
|
||||
return nil, RuntimeSessionFatalError{Err: fmt.Errorf("worker export timeout: %w", outcome.err)}
|
||||
}
|
||||
return nil, RuntimeSessionFatalError{Err: callCtx.Err()}
|
||||
}
|
||||
}
|
||||
|
||||
type workerCallOutcome struct {
|
||||
output []byte
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *workerRuntimeSession) call(ctx context.Context, export string, input []byte) workerCallOutcome {
|
||||
msg, err := workerMessageWithData(workerMessageCallExport, workerCallExport{
|
||||
WASMPath: s.plugin.WASMPath,
|
||||
Export: export,
|
||||
InputBase64: encodeWorkerBytes(input),
|
||||
SessionID: s.sessionID,
|
||||
})
|
||||
if err != nil {
|
||||
return workerCallOutcome{err: err}
|
||||
}
|
||||
if err := writeWorkerMessage(s.stdin, s.requestMaxBytes, msg); err != nil {
|
||||
s.markFatal("write worker call_export failed")
|
||||
s.kill()
|
||||
return workerCallOutcome{err: RuntimeSessionFatalError{Err: err}}
|
||||
}
|
||||
|
||||
for {
|
||||
msg, err := readWorkerMessage(s.stdout, s.responseMaxBytes)
|
||||
if err != nil {
|
||||
s.markFatal("read worker message failed")
|
||||
s.kill()
|
||||
return workerCallOutcome{err: RuntimeSessionFatalError{Err: s.withStderr(err)}}
|
||||
}
|
||||
switch msg.Type {
|
||||
case workerMessageHostHTTPRequest:
|
||||
if err := s.handleHostHTTPRequest(ctx, msg); err != nil {
|
||||
s.markFatal("host http rpc failed")
|
||||
s.kill()
|
||||
return workerCallOutcome{err: RuntimeSessionFatalError{Err: s.withStderr(err)}}
|
||||
}
|
||||
case workerMessageHostLog:
|
||||
s.handleHostLog(msg)
|
||||
case workerMessageCallResult:
|
||||
result, err := workerData[workerCallResult](msg)
|
||||
if err != nil {
|
||||
s.markFatal("invalid call_result payload")
|
||||
s.kill()
|
||||
return workerCallOutcome{err: RuntimeSessionFatalError{Err: err}}
|
||||
}
|
||||
if result.PluginError != nil {
|
||||
return workerCallOutcome{err: PluginCallError{
|
||||
PluginID: s.plugin.Manifest.ID,
|
||||
Export: export,
|
||||
PluginError: *result.PluginError,
|
||||
}}
|
||||
}
|
||||
output, err := decodeWorkerBytes(result.OutputBase64)
|
||||
if err != nil {
|
||||
s.markFatal("invalid call_result output")
|
||||
s.kill()
|
||||
return workerCallOutcome{err: RuntimeSessionFatalError{Err: err}}
|
||||
}
|
||||
return workerCallOutcome{output: output}
|
||||
case workerMessageError:
|
||||
payload, _ := workerData[workerError](msg)
|
||||
s.markFatal(payload.Message)
|
||||
s.kill()
|
||||
if payload.Message == "" {
|
||||
payload.Message = "worker returned fatal error"
|
||||
}
|
||||
return workerCallOutcome{err: RuntimeSessionFatalError{Err: s.withStderr(fmt.Errorf("%s", payload.Message))}}
|
||||
default:
|
||||
s.markFatal("unexpected worker message")
|
||||
s.kill()
|
||||
return workerCallOutcome{err: RuntimeSessionFatalError{Err: fmt.Errorf("unexpected worker message %q", msg.Type)}}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *workerRuntimeSession) handleHostLog(msg workerMessage) {
|
||||
entry, err := workerData[workerHostLog](msg)
|
||||
if err != nil {
|
||||
log.Printf("plugin log invalid: session %s: %v", s.sessionID, err)
|
||||
return
|
||||
}
|
||||
if entry.SessionID == "" {
|
||||
entry.SessionID = s.sessionID
|
||||
}
|
||||
level, err := normalizeHostLogLevel(entry.Level)
|
||||
if err != nil {
|
||||
log.Printf("plugin log invalid: session %s: %v", s.sessionID, err)
|
||||
return
|
||||
}
|
||||
message := sanitizeHostLogMessage(entry.Message)
|
||||
if message == "" {
|
||||
log.Printf("plugin log invalid: session %s: log message is required", s.sessionID)
|
||||
return
|
||||
}
|
||||
log.Printf("plugin log [%s]: session %s: %s", level, entry.SessionID, message)
|
||||
}
|
||||
|
||||
func (s *workerRuntimeSession) handleHostHTTPRequest(ctx context.Context, msg workerMessage) error {
|
||||
request, err := workerData[workerHostHTTPRequest](msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
requestBytes, err := decodeWorkerBytes(request.RequestBase64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
response := executeHostHTTPRequest(ctx, s.plugin.Manifest, s.policy, requestBytes)
|
||||
responseBytes, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reply, err := workerMessageWithData(workerMessageHostHTTPResponse, workerHostHTTPResponse{
|
||||
ResponseBase64: encodeWorkerBytes(responseBytes),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeWorkerMessage(s.stdin, s.responseMaxBytes, reply)
|
||||
}
|
||||
|
||||
func (s *workerRuntimeSession) Close(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
s.closed = true
|
||||
fatal := s.fatal
|
||||
s.mu.Unlock()
|
||||
|
||||
if s.sessionTimer != nil {
|
||||
s.sessionTimer.Stop()
|
||||
}
|
||||
if !fatal {
|
||||
_ = writeWorkerMessage(s.stdin, s.requestMaxBytes, workerMessage{Type: workerMessageShutdown})
|
||||
}
|
||||
_ = s.stdin.Close()
|
||||
|
||||
wait := make(chan error, 1)
|
||||
go func() {
|
||||
wait <- s.wait()
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-wait:
|
||||
s.slot.Release(1)
|
||||
if err != nil && !fatal {
|
||||
return s.withStderr(err)
|
||||
}
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
s.kill()
|
||||
err := <-wait
|
||||
s.slot.Release(1)
|
||||
if err != nil {
|
||||
return s.withStderr(err)
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *workerRuntimeSession) wait() error {
|
||||
s.waitMu.Lock()
|
||||
defer s.waitMu.Unlock()
|
||||
if s.waited {
|
||||
return s.waitErr
|
||||
}
|
||||
s.waited = true
|
||||
s.waitErr = s.cmd.Wait()
|
||||
return s.waitErr
|
||||
}
|
||||
|
||||
func (s *workerRuntimeSession) markFatal(msg string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.fatal = true
|
||||
if s.fatalMsg == "" {
|
||||
s.fatalMsg = msg
|
||||
}
|
||||
}
|
||||
|
||||
func (s *workerRuntimeSession) kill() {
|
||||
if s.cmd != nil && s.cmd.Process != nil {
|
||||
_ = s.cmd.Process.Kill()
|
||||
}
|
||||
_ = s.stdin.Close()
|
||||
_ = s.stdout.Close()
|
||||
}
|
||||
|
||||
func (s *workerRuntimeSession) withStderr(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
stderr := strings.TrimSpace(s.stderr.String())
|
||||
if stderr == "" {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%w: worker stderr: %s", err, stderr)
|
||||
}
|
||||
|
||||
func acquireWorkerSlot(ctx context.Context) (*semaphore.Weighted, error) {
|
||||
timeout := envDuration("WANDERER_PLUGIN_WORKER_SLOT_TIMEOUT", defaultWorkerSlotAcquireTimeout)
|
||||
acquireCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
slot := workerSemaphore()
|
||||
if err := slot.Acquire(acquireCtx, 1); err != nil {
|
||||
return nil, fmt.Errorf("acquire plugin worker slot: %w", err)
|
||||
}
|
||||
return slot, nil
|
||||
}
|
||||
|
||||
func workerSemaphore() *semaphore.Weighted {
|
||||
limit := int64(envInt("WANDERER_PLUGIN_WORKER_MAX", maxInt(2, runtime.NumCPU())))
|
||||
workerSlotsMu.Lock()
|
||||
defer workerSlotsMu.Unlock()
|
||||
if workerSlots == nil {
|
||||
workerSlots = semaphore.NewWeighted(limit)
|
||||
}
|
||||
return workerSlots
|
||||
}
|
||||
|
||||
func envInt(key string, fallback int) int {
|
||||
raw := strings.TrimSpace(os.Getenv(key))
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func envDuration(key string, fallback time.Duration) time.Duration {
|
||||
raw := strings.TrimSpace(os.Getenv(key))
|
||||
if raw == "" {
|
||||
return fallback
|
||||
}
|
||||
if value, err := time.ParseDuration(raw); err == nil && value > 0 {
|
||||
return value
|
||||
}
|
||||
seconds, err := strconv.Atoi(raw)
|
||||
if err != nil || seconds <= 0 {
|
||||
return fallback
|
||||
}
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
|
||||
func maxInt(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func childEnvWithout(keys ...string) []string {
|
||||
blocked := map[string]bool{}
|
||||
for _, key := range keys {
|
||||
blocked[key] = true
|
||||
}
|
||||
env := os.Environ()
|
||||
filtered := make([]string, 0, len(env))
|
||||
for _, entry := range env {
|
||||
key := entry
|
||||
if idx := strings.IndexByte(entry, '='); idx >= 0 {
|
||||
key = entry[:idx]
|
||||
}
|
||||
if blocked[key] {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, entry)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func newWorkerSessionID(pluginID string) string {
|
||||
var random [8]byte
|
||||
if _, err := rand.Read(random[:]); err != nil {
|
||||
return pluginID + "-" + strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
}
|
||||
return pluginID + "-" + hex.EncodeToString(random[:])
|
||||
}
|
||||
|
||||
type boundedWorkerBuffer struct {
|
||||
mu sync.Mutex
|
||||
limit int
|
||||
data []byte
|
||||
}
|
||||
|
||||
func (b *boundedWorkerBuffer) Write(p []byte) (int, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.limit <= 0 || len(b.data) >= b.limit {
|
||||
return len(p), nil
|
||||
}
|
||||
remaining := b.limit - len(b.data)
|
||||
if len(p) > remaining {
|
||||
b.data = append(b.data, p[:remaining]...)
|
||||
return len(p), nil
|
||||
}
|
||||
b.data = append(b.data, p...)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (b *boundedWorkerBuffer) String() string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return string(b.data)
|
||||
}
|
||||
285
db/pluginsystem/worker_process.go
Normal file
285
db/pluginsystem/worker_process.go
Normal file
@@ -0,0 +1,285 @@
|
||||
package pluginsystem
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
extism "github.com/extism/go-sdk"
|
||||
)
|
||||
|
||||
// RunPluginWorker runs the stdio worker process. It is called by the main
|
||||
// binary's plugin-worker subcommand before PocketBase is initialized.
|
||||
func RunPluginWorker(ctx context.Context, stdin io.Reader, stdout io.Writer, stderr io.Writer) int {
|
||||
_ = os.Unsetenv("EXTISM_ENABLE_WASI_OUTPUT")
|
||||
|
||||
worker := &pluginWorkerProcess{
|
||||
ctx: ctx,
|
||||
stdin: stdin,
|
||||
stdout: stdout,
|
||||
stderr: stderr,
|
||||
requestMaxBytes: envInt("WANDERER_PLUGIN_WORKER_REQUEST_BYTES", defaultWorkerRequestMaxBytes),
|
||||
responseMaxBytes: envInt("WANDERER_PLUGIN_WORKER_RESPONSE_BYTES", defaultWorkerResponseMaxBytes),
|
||||
}
|
||||
if err := worker.run(); err != nil {
|
||||
_, _ = fmt.Fprintf(stderr, "plugin worker: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type pluginWorkerProcess struct {
|
||||
ctx context.Context
|
||||
stdin io.Reader
|
||||
stdout io.Writer
|
||||
stderr io.Writer
|
||||
requestMaxBytes int
|
||||
responseMaxBytes int
|
||||
wasmPath string
|
||||
sessionID string
|
||||
instance *extism.Plugin
|
||||
fatalErr error
|
||||
}
|
||||
|
||||
func (w *pluginWorkerProcess) run() error {
|
||||
defer func() {
|
||||
if w.instance != nil {
|
||||
_ = w.instance.Close(w.ctx)
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
msg, err := readWorkerMessage(w.stdin, w.requestMaxBytes)
|
||||
if err != nil {
|
||||
// A clean io.EOF means the parent closed stdin without a
|
||||
// shutdown frame (e.g. it crashed); exit quietly. An
|
||||
// io.ErrUnexpectedEOF means stdin was cut mid-frame, which is a
|
||||
// truncated/corrupt frame and should surface as an error.
|
||||
if err == io.EOF {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case workerMessageShutdown:
|
||||
return nil
|
||||
case workerMessageCallExport:
|
||||
if err := w.handleCallExport(msg); err != nil {
|
||||
_ = w.sendError(err.Error())
|
||||
return err
|
||||
}
|
||||
default:
|
||||
err := fmt.Errorf("unexpected worker message %q", msg.Type)
|
||||
_ = w.sendError(err.Error())
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *pluginWorkerProcess) handleCallExport(msg workerMessage) error {
|
||||
call, err := workerData[workerCallExport](msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if call.WASMPath == "" || call.Export == "" {
|
||||
return fmt.Errorf("call_export requires wasmPath and export")
|
||||
}
|
||||
// The session ID is set by the parent once per worker process and reused
|
||||
// for every call. It carries no routing semantics here (a worker serves a
|
||||
// single wasm path) but is threaded into errors so captured stderr can be
|
||||
// tied back to a specific session during diagnosis.
|
||||
w.sessionID = call.SessionID
|
||||
if w.instance == nil {
|
||||
if err := w.openPlugin(call.WASMPath); err != nil {
|
||||
return w.errCtx(call.Export, err)
|
||||
}
|
||||
} else if call.WASMPath != w.wasmPath {
|
||||
return w.errCtx(call.Export, fmt.Errorf("worker session cannot switch wasm path (have %q, got %q)", w.wasmPath, call.WASMPath))
|
||||
}
|
||||
|
||||
input, err := decodeWorkerBytes(call.InputBase64)
|
||||
if err != nil {
|
||||
return w.errCtx(call.Export, fmt.Errorf("decode call input: %w", err))
|
||||
}
|
||||
w.fatalErr = nil
|
||||
code, output, err := w.instance.CallWithContext(w.ctx, call.Export, input)
|
||||
if w.fatalErr != nil {
|
||||
return w.errCtx(call.Export, w.fatalErr)
|
||||
}
|
||||
if err != nil {
|
||||
return w.errCtx(call.Export, fmt.Errorf("call %s: %w", call.Export, err))
|
||||
}
|
||||
if code != 0 {
|
||||
pluginErr := pluginErrorForCode(call.Export, code, w.instance.GetErrorWithContext(w.ctx))
|
||||
return w.sendCallResult(workerCallResult{PluginError: &pluginErr})
|
||||
}
|
||||
return w.sendCallResult(workerCallResult{OutputBase64: encodeWorkerBytes(output)})
|
||||
}
|
||||
|
||||
// pluginErrorForCode maps a non-zero export return code into the PluginError
|
||||
// reported to the parent. It prefers the structured error JSON the plugin set
|
||||
// via the host error API, and falls back to a generic plugin_error when that
|
||||
// payload is missing, malformed, or has no code.
|
||||
func pluginErrorForCode(export string, code uint32, rawErr string) PluginError {
|
||||
var parsed PluginError
|
||||
if rawErr == "" || json.Unmarshal([]byte(rawErr), &parsed) != nil || parsed.Code == "" {
|
||||
return PluginError{
|
||||
Code: "plugin_error",
|
||||
Message: fmt.Sprintf("call %s failed with code %d", export, code),
|
||||
}
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func (w *pluginWorkerProcess) openPlugin(wasmPath string) error {
|
||||
manifest := extism.Manifest{
|
||||
Wasm: []extism.Wasm{
|
||||
extism.WasmFile{Path: wasmPath},
|
||||
},
|
||||
}
|
||||
instance, err := extism.NewPlugin(w.ctx, manifest, extism.PluginConfig{
|
||||
EnableWasi: true,
|
||||
}, w.hostFunctions())
|
||||
if err != nil {
|
||||
return fmt.Errorf("create wasm plugin: %w", err)
|
||||
}
|
||||
w.wasmPath = wasmPath
|
||||
w.instance = instance
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *pluginWorkerProcess) hostFunctions() []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
|
||||
}
|
||||
msg, err := workerMessageWithData(workerMessageHostHTTPRequest, workerHostHTTPRequest{
|
||||
RequestBase64: encodeWorkerBytes(requestBytes),
|
||||
})
|
||||
if err != nil {
|
||||
writeHostHTTPResponse(ctx, plugin, stack, hostHTTPResponse{
|
||||
Error: &PluginError{Code: "internal_error", Message: err.Error()},
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := writeWorkerMessage(w.stdout, w.responseMaxBytes, msg); err != nil {
|
||||
w.failHostRPC(stack, fmt.Errorf("write host http request: %w", err))
|
||||
return
|
||||
}
|
||||
responseMsg, err := readWorkerMessage(w.stdin, w.responseMaxBytes)
|
||||
if err != nil {
|
||||
w.failHostRPC(stack, fmt.Errorf("read host http response: %w", err))
|
||||
return
|
||||
}
|
||||
if responseMsg.Type != workerMessageHostHTTPResponse {
|
||||
w.failHostRPC(stack, fmt.Errorf("unexpected host http response message %q", responseMsg.Type))
|
||||
return
|
||||
}
|
||||
response, err := workerData[workerHostHTTPResponse](responseMsg)
|
||||
if err != nil {
|
||||
w.failHostRPC(stack, fmt.Errorf("decode host http response: %w", err))
|
||||
return
|
||||
}
|
||||
responseBytes, err := decodeWorkerBytes(response.ResponseBase64)
|
||||
if err != nil {
|
||||
w.failHostRPC(stack, fmt.Errorf("decode host http response bytes: %w", err))
|
||||
return
|
||||
}
|
||||
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
|
||||
},
|
||||
[]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 {
|
||||
_, _ = fmt.Fprintf(w.stderr, "plugin log invalid: session %s: %v\n", w.sessionID, err)
|
||||
return
|
||||
}
|
||||
msg, err := workerMessageWithData(workerMessageHostLog, workerHostLog{
|
||||
Level: entry.Level,
|
||||
Message: entry.Message,
|
||||
SessionID: w.sessionID,
|
||||
})
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(w.stderr, "plugin log encode failed: session %s: %v\n", w.sessionID, err)
|
||||
return
|
||||
}
|
||||
if err := writeWorkerMessage(w.stdout, w.responseMaxBytes, msg); err != nil {
|
||||
_, _ = fmt.Fprintf(w.stderr, "plugin log write failed: session %s: %v\n", w.sessionID, err)
|
||||
}
|
||||
_ = ctx
|
||||
},
|
||||
[]extism.ValueType{extism.ValueTypePTR},
|
||||
nil,
|
||||
)
|
||||
logFn.SetNamespace("wanderer")
|
||||
|
||||
return []extism.HostFunction{httpFn, logFn}
|
||||
}
|
||||
|
||||
func (w *pluginWorkerProcess) failHostRPC(stack []uint64, err error) {
|
||||
w.fatalErr = err
|
||||
stack[0] = 0
|
||||
}
|
||||
|
||||
// errCtx annotates a fatal worker error with the active session and export so
|
||||
// the message that the parent captures from stderr can be tied back to a
|
||||
// specific call during diagnosis.
|
||||
func (w *pluginWorkerProcess) errCtx(export string, err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("session %s export %s: %w", w.sessionID, export, err)
|
||||
}
|
||||
|
||||
// Worker error channels follow a strict convention:
|
||||
//
|
||||
// - sendCallResult with a PluginError reports a business-level rejection from
|
||||
// the plugin (a bad call code). The session stays alive and reusable; the
|
||||
// parent surfaces it as a PluginCallError.
|
||||
// - sendError reports a broken protocol or runtime (corrupt frame, host RPC
|
||||
// failure, unexpected message). The parent treats it as fatal and tears the
|
||||
// session down.
|
||||
//
|
||||
// Keep new failure paths on the correct channel: recoverable plugin outcomes
|
||||
// use sendCallResult, anything that invalidates the session uses sendError.
|
||||
func (w *pluginWorkerProcess) sendCallResult(result workerCallResult) error {
|
||||
msg, err := workerMessageWithData(workerMessageCallResult, result)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeWorkerMessage(w.stdout, w.responseMaxBytes, msg)
|
||||
}
|
||||
|
||||
func (w *pluginWorkerProcess) sendError(message string) error {
|
||||
msg, err := workerMessageWithData(workerMessageError, workerError{Message: message})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeWorkerMessage(w.stdout, w.responseMaxBytes, msg)
|
||||
}
|
||||
149
db/pluginsystem/worker_rpc.go
Normal file
149
db/pluginsystem/worker_rpc.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package pluginsystem
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
const (
|
||||
workerMessageCallExport = "call_export"
|
||||
workerMessageShutdown = "shutdown"
|
||||
workerMessageHostHTTPResponse = "host_http_response"
|
||||
workerMessageHostHTTPRequest = "host_http_request"
|
||||
workerMessageHostLog = "host_log"
|
||||
workerMessageCallResult = "call_result"
|
||||
workerMessageError = "error"
|
||||
|
||||
defaultWorkerRequestMaxBytes = 32 * 1024 * 1024
|
||||
defaultWorkerResponseMaxBytes = 64 * 1024 * 1024
|
||||
)
|
||||
|
||||
// workerMessage is one framed RPC message on the worker stdio protocol. The
|
||||
// protocol is strictly synchronous (one call_export in flight at a time, with
|
||||
// host HTTP RPC nested synchronously), so messages carry no correlation ID.
|
||||
type workerMessage struct {
|
||||
Type string `json:"type"`
|
||||
Data json.RawMessage `json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type workerCallExport struct {
|
||||
WASMPath string `json:"wasmPath"`
|
||||
Export string `json:"export"`
|
||||
InputBase64 string `json:"inputBase64,omitempty"`
|
||||
SessionID string `json:"sessionId,omitempty"`
|
||||
}
|
||||
|
||||
type workerCallResult struct {
|
||||
OutputBase64 string `json:"outputBase64,omitempty"`
|
||||
PluginError *PluginError `json:"pluginError,omitempty"`
|
||||
}
|
||||
|
||||
type workerHostHTTPRequest struct {
|
||||
RequestBase64 string `json:"requestBase64"`
|
||||
}
|
||||
|
||||
type workerHostHTTPResponse struct {
|
||||
ResponseBase64 string `json:"responseBase64"`
|
||||
}
|
||||
|
||||
type workerHostLog struct {
|
||||
Level string `json:"level"`
|
||||
Message string `json:"message"`
|
||||
SessionID string `json:"sessionId,omitempty"`
|
||||
}
|
||||
|
||||
type workerError struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func writeWorkerMessage(w io.Writer, maxBytes int, msg workerMessage) error {
|
||||
payload, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(payload) > maxBytes {
|
||||
return fmt.Errorf("worker rpc frame too large: %d > %d", len(payload), maxBytes)
|
||||
}
|
||||
var header [4]byte
|
||||
binary.BigEndian.PutUint32(header[:], uint32(len(payload)))
|
||||
if err := writeAll(w, header[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeAll(w, payload)
|
||||
}
|
||||
|
||||
func readWorkerMessage(r io.Reader, maxBytes int) (workerMessage, error) {
|
||||
var header [4]byte
|
||||
if _, err := io.ReadFull(r, header[:]); err != nil {
|
||||
return workerMessage{}, err
|
||||
}
|
||||
size := binary.BigEndian.Uint32(header[:])
|
||||
if size == 0 {
|
||||
return workerMessage{}, fmt.Errorf("worker rpc frame is empty")
|
||||
}
|
||||
if int(size) > maxBytes {
|
||||
return workerMessage{}, fmt.Errorf("worker rpc frame too large: %d > %d", size, maxBytes)
|
||||
}
|
||||
payload := make([]byte, int(size))
|
||||
if _, err := io.ReadFull(r, payload); err != nil {
|
||||
return workerMessage{}, err
|
||||
}
|
||||
var msg workerMessage
|
||||
if err := json.Unmarshal(payload, &msg); err != nil {
|
||||
return workerMessage{}, err
|
||||
}
|
||||
if msg.Type == "" {
|
||||
return workerMessage{}, fmt.Errorf("worker rpc message type is empty")
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
func encodeWorkerBytes(data []byte) string {
|
||||
if len(data) == 0 {
|
||||
return ""
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(data)
|
||||
}
|
||||
|
||||
func decodeWorkerBytes(encoded string) ([]byte, error) {
|
||||
if encoded == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return base64.StdEncoding.DecodeString(encoded)
|
||||
}
|
||||
|
||||
func workerData[T any](msg workerMessage) (T, error) {
|
||||
var value T
|
||||
if len(msg.Data) == 0 {
|
||||
return value, nil
|
||||
}
|
||||
if err := json.Unmarshal(msg.Data, &value); err != nil {
|
||||
return value, err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func workerMessageWithData[T any](typ string, data T) (workerMessage, error) {
|
||||
raw, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return workerMessage{}, err
|
||||
}
|
||||
return workerMessage{Type: typ, Data: raw}, nil
|
||||
}
|
||||
|
||||
func writeAll(w io.Writer, data []byte) error {
|
||||
for len(data) > 0 {
|
||||
n, err := w.Write(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
data = data[n:]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
369
db/pluginsystem/worker_test.go
Normal file
369
db/pluginsystem/worker_test.go
Normal file
@@ -0,0 +1,369 @@
|
||||
package pluginsystem
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
extism "github.com/extism/go-sdk"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
func TestWorkerRPCFrameRoundTrip(t *testing.T) {
|
||||
msg, err := workerMessageWithData(workerMessageCallExport, workerCallExport{
|
||||
WASMPath: "/tmp/plugin.wasm",
|
||||
Export: "list_routes_v1",
|
||||
InputBase64: encodeWorkerBytes([]byte(`{"ok":true}`)),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := writeWorkerMessage(&buf, 1024, msg); err != nil {
|
||||
t.Fatalf("write message: %v", err)
|
||||
}
|
||||
got, err := readWorkerMessage(&buf, 1024)
|
||||
if err != nil {
|
||||
t.Fatalf("read message: %v", err)
|
||||
}
|
||||
if got.Type != workerMessageCallExport {
|
||||
t.Fatalf("unexpected type: %q", got.Type)
|
||||
}
|
||||
payload, err := workerData[workerCallExport](got)
|
||||
if err != nil {
|
||||
t.Fatalf("decode payload: %v", err)
|
||||
}
|
||||
input, err := decodeWorkerBytes(payload.InputBase64)
|
||||
if err != nil {
|
||||
t.Fatalf("decode input: %v", err)
|
||||
}
|
||||
if string(input) != `{"ok":true}` {
|
||||
t.Fatalf("unexpected input: %s", input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerRPCRejectsOversizedFrameBeforePayloadRead(t *testing.T) {
|
||||
msg, err := workerMessageWithData(workerMessageError, workerError{Message: "too large"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := writeWorkerMessage(&buf, 1024, msg); err != nil {
|
||||
t.Fatalf("write message: %v", err)
|
||||
}
|
||||
|
||||
if _, err := readWorkerMessage(&buf, 4); err == nil {
|
||||
t.Fatal("expected oversized frame error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginWorkerExitsOnStdinEOF(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := RunPluginWorker(context.Background(), bytes.NewReader(nil), &stdout, &stderr)
|
||||
if code != 0 {
|
||||
t.Fatalf("unexpected exit code %d, stderr %q", code, stderr.String())
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("unexpected stdout: %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginWorkerTruncatedFrameReturnsError(t *testing.T) {
|
||||
var header [4]byte
|
||||
binary.BigEndian.PutUint32(header[:], 100)
|
||||
stdin := bytes.NewReader(append(header[:], []byte("partial")...))
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := RunPluginWorker(context.Background(), stdin, &stdout, &stderr)
|
||||
if code == 0 {
|
||||
t.Fatal("expected non-zero exit code for truncated frame")
|
||||
}
|
||||
if stderr.Len() == 0 {
|
||||
t.Fatal("expected truncated frame error on stderr")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginWorkerUnexpectedMessageTypeFails(t *testing.T) {
|
||||
msg, err := workerMessageWithData(workerMessageHostHTTPResponse, workerHostHTTPResponse{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stdin bytes.Buffer
|
||||
if err := writeWorkerMessage(&stdin, defaultWorkerRequestMaxBytes, msg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
code := RunPluginWorker(context.Background(), &stdin, &stdout, &stderr)
|
||||
if code == 0 {
|
||||
t.Fatal("expected non-zero exit code for unexpected message type")
|
||||
}
|
||||
reply, err := readWorkerMessage(&stdout, defaultWorkerResponseMaxBytes)
|
||||
if err != nil {
|
||||
t.Fatalf("read worker reply: %v", err)
|
||||
}
|
||||
if reply.Type != workerMessageError {
|
||||
t.Fatalf("expected error reply, got %q", reply.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCallExportRejectsWasmPathSwitch(t *testing.T) {
|
||||
worker := &pluginWorkerProcess{
|
||||
ctx: context.Background(),
|
||||
instance: &extism.Plugin{},
|
||||
wasmPath: "/plugins/a.wasm",
|
||||
}
|
||||
msg, err := workerMessageWithData(workerMessageCallExport, workerCallExport{
|
||||
WASMPath: "/plugins/b.wasm",
|
||||
Export: "list_routes_v1",
|
||||
SessionID: "sess-1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = worker.handleCallExport(msg)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when switching wasm path")
|
||||
}
|
||||
for _, want := range []string{"sess-1", "list_routes_v1", "/plugins/a.wasm", "/plugins/b.wasm"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("error %q missing %q", err.Error(), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCallExportRejectsInvalidInput(t *testing.T) {
|
||||
worker := &pluginWorkerProcess{
|
||||
ctx: context.Background(),
|
||||
instance: &extism.Plugin{},
|
||||
wasmPath: "/plugins/a.wasm",
|
||||
}
|
||||
msg, err := workerMessageWithData(workerMessageCallExport, workerCallExport{
|
||||
WASMPath: "/plugins/a.wasm",
|
||||
Export: "list_routes_v1",
|
||||
InputBase64: "!!!not-base64!!!",
|
||||
SessionID: "sess-2",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = worker.handleCallExport(msg)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid input base64")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "sess-2") || !strings.Contains(err.Error(), "decode call input") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerHostLogFrameRoundTrip(t *testing.T) {
|
||||
msg, err := workerMessageWithData(workerMessageHostLog, workerHostLog{
|
||||
Level: "info",
|
||||
Message: "detail fetch took 1s",
|
||||
SessionID: "sess-log",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := writeWorkerMessage(&buf, 1024, msg); err != nil {
|
||||
t.Fatalf("write message: %v", err)
|
||||
}
|
||||
got, err := readWorkerMessage(&buf, 1024)
|
||||
if err != nil {
|
||||
t.Fatalf("read worker message: %v", err)
|
||||
}
|
||||
if got.Type != workerMessageHostLog {
|
||||
t.Fatalf("expected host_log, got %q", got.Type)
|
||||
}
|
||||
payload, err := workerData[workerHostLog](got)
|
||||
if err != nil {
|
||||
t.Fatalf("decode host log: %v", err)
|
||||
}
|
||||
if payload.Level != "info" || payload.Message != "detail fetch took 1s" || payload.SessionID != "sess-log" {
|
||||
t.Fatalf("unexpected host log payload: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginErrorForCode(t *testing.T) {
|
||||
t.Run("falls back when raw error is empty", func(t *testing.T) {
|
||||
got := pluginErrorForCode("list_routes_v1", 7, "")
|
||||
if got.Code != "plugin_error" || !strings.Contains(got.Message, "code 7") {
|
||||
t.Fatalf("unexpected fallback error: %#v", got)
|
||||
}
|
||||
})
|
||||
t.Run("falls back when raw error is malformed", func(t *testing.T) {
|
||||
got := pluginErrorForCode("list_routes_v1", 1, "{not json")
|
||||
if got.Code != "plugin_error" {
|
||||
t.Fatalf("expected fallback for malformed json, got %#v", got)
|
||||
}
|
||||
})
|
||||
t.Run("falls back when code is empty", func(t *testing.T) {
|
||||
got := pluginErrorForCode("list_routes_v1", 1, `{"message":"boom"}`)
|
||||
if got.Code != "plugin_error" {
|
||||
t.Fatalf("expected fallback for missing code, got %#v", got)
|
||||
}
|
||||
})
|
||||
t.Run("passes through structured error", func(t *testing.T) {
|
||||
got := pluginErrorForCode("list_routes_v1", 1, `{"code":"rate_limited","message":"slow down"}`)
|
||||
if got.Code != "rate_limited" || got.Message != "slow down" {
|
||||
t.Fatalf("expected structured error, got %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestExecuteHostHTTPRequestRejectsInvalidPayload(t *testing.T) {
|
||||
response := executeHostHTTPRequest(context.Background(), Manifest{}, RequestPolicyContext{}, []byte("not json"))
|
||||
if response.Error == nil || response.Error.Code != "invalid_request" {
|
||||
t.Fatalf("expected invalid_request error, got %#v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginWorkerHostRPCFatalSetsClearError(t *testing.T) {
|
||||
worker := &pluginWorkerProcess{}
|
||||
stack := []uint64{123}
|
||||
|
||||
worker.failHostRPC(stack, errors.New("host RPC read failed"))
|
||||
|
||||
if stack[0] != 0 {
|
||||
t.Fatalf("expected null response pointer, got %d", stack[0])
|
||||
}
|
||||
if worker.fatalErr == nil || worker.fatalErr.Error() != "host RPC read failed" {
|
||||
t.Fatalf("unexpected fatal error: %v", worker.fatalErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeSessionFatalErrorIsDetectableThroughWrapping(t *testing.T) {
|
||||
err := fmt.Errorf("outer: %w", RuntimeSessionFatalError{Err: errors.New("worker died")})
|
||||
if !IsRuntimeSessionFatalError(err) {
|
||||
t.Fatal("expected fatal session error")
|
||||
}
|
||||
if IsRuntimeSessionFatalError(errors.New("plugin error")) {
|
||||
t.Fatal("unexpected fatal session error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestChildEnvWithoutStripsKeys(t *testing.T) {
|
||||
t.Setenv("EXTISM_ENABLE_WASI_OUTPUT", "1")
|
||||
t.Setenv("WANDERER_TEST_KEEP", "yes")
|
||||
|
||||
env := childEnvWithout("EXTISM_ENABLE_WASI_OUTPUT")
|
||||
for _, entry := range env {
|
||||
if entry == "EXTISM_ENABLE_WASI_OUTPUT=1" {
|
||||
t.Fatalf("unexpected stripped env entry in %#v", env)
|
||||
}
|
||||
}
|
||||
if os.Getenv("EXTISM_ENABLE_WASI_OUTPUT") != "1" {
|
||||
t.Fatal("childEnvWithout should not mutate the current process env")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectHostRequestAuthUsesExistingSessionForRefresh(t *testing.T) {
|
||||
spec := HostRequestSpec{Auth: "session"}
|
||||
session := &fakeRuntimeSession{
|
||||
output: []byte(`{"token":"session-token"}`),
|
||||
}
|
||||
err := InjectHostRequestAuth(context.Background(), AuthInjectionInput{
|
||||
Session: session,
|
||||
Plugin: LocalPlugin{Manifest: Manifest{
|
||||
Auth: AuthManifest{Contexts: map[string]AuthContext{
|
||||
"session": {
|
||||
Type: AuthTypeSession,
|
||||
SecretFields: []string{"email", "password"},
|
||||
Refresh: &AuthRefresh{Mode: AuthRefreshModePlugin, Function: "refresh_session_v1"},
|
||||
},
|
||||
}},
|
||||
Permissions: PermissionManifest{Auth: []string{"session"}},
|
||||
}},
|
||||
Instance: testPluginInstance("inst1", "plugin.test"),
|
||||
Auth: map[string]any{"email": "user@example.com", "password": "secret"},
|
||||
Spec: &spec,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if session.export != "refresh_session_v1" {
|
||||
t.Fatalf("unexpected export: %q", session.export)
|
||||
}
|
||||
if got := spec.Headers[AuthHeaderAuthorization]; got != AuthSchemeBearer+" session-token" {
|
||||
t.Fatalf("unexpected auth header: %q", got)
|
||||
}
|
||||
|
||||
var input map[string]any
|
||||
if err := json.Unmarshal(session.input, &input); err != nil {
|
||||
t.Fatalf("invalid refresh input: %v", err)
|
||||
}
|
||||
auth, ok := input["auth"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("missing refresh auth: %#v", input)
|
||||
}
|
||||
if _, ok := auth["accessToken"]; ok {
|
||||
t.Fatalf("refresh auth leaked access token: %#v", auth)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeRuntimeSession struct {
|
||||
export string
|
||||
input []byte
|
||||
output []byte
|
||||
err error
|
||||
}
|
||||
|
||||
func (s *fakeRuntimeSession) Call(_ context.Context, export string, input []byte) ([]byte, error) {
|
||||
s.export = export
|
||||
s.input = append([]byte(nil), input...)
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
return s.output, nil
|
||||
}
|
||||
|
||||
func (s *fakeRuntimeSession) Close(context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestInjectHostRequestAuthDoesNotRequireRuntimeWhenSessionProvided(t *testing.T) {
|
||||
spec := HostRequestSpec{Auth: "session"}
|
||||
session := &fakeRuntimeSession{err: errors.New("session failed")}
|
||||
err := InjectHostRequestAuth(context.Background(), AuthInjectionInput{
|
||||
Session: session,
|
||||
Plugin: LocalPlugin{Manifest: Manifest{
|
||||
Auth: AuthManifest{Contexts: map[string]AuthContext{
|
||||
"session": {
|
||||
Type: AuthTypeSession,
|
||||
SecretFields: []string{"email"},
|
||||
Refresh: &AuthRefresh{Mode: AuthRefreshModePlugin, Function: "refresh_session_v1"},
|
||||
},
|
||||
}},
|
||||
Permissions: PermissionManifest{Auth: []string{"session"}},
|
||||
}},
|
||||
Instance: testPluginInstance("inst1", "plugin.test"),
|
||||
Auth: map[string]any{"email": "user@example.com"},
|
||||
Spec: &spec,
|
||||
})
|
||||
if err == nil || err.Error() != "session failed" {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testPluginInstance(id string, pluginID string) *core.Record {
|
||||
collection := core.NewBaseCollection("plugin_instances")
|
||||
collection.Fields.Add(&core.TextField{Name: "plugin_id"})
|
||||
record := core.NewRecord(collection)
|
||||
record.Id = id
|
||||
record.Set("plugin_id", pluginID)
|
||||
return record
|
||||
}
|
||||
Reference in New Issue
Block a user