From 1272a3fce6b8d2cff2bfc7b2aa4129cc8e1fc783 Mon Sep 17 00:00:00 2001 From: lr00rl Date: Tue, 14 Jul 2026 01:53:43 -0700 Subject: [PATCH] feat: make plugin service backing an explicit signed declaration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A plugin need not carry its own engine. The nftables renderer and the WireGuard key/config engine deliberately stay in core so the trust base stays small (ADR-001 D5), and what the plugin owns is the UI, the validation, and the workflow intent. That arrangement is legitimate. What was not legitimate was leaving it implicit. The gateway routed a v2 call to core whenever core happened to own the service and the manifest's publisher string was "latticenet" — so three official plugins could declare interface methods their own artifacts cannot serve, core quietly answered them, and neither an operator nor an auditor could tell the difference. A manifest that declares a method core secretly answers is a contract that lies. Backing is now a per-service field inside the signed manifest, and dispatch follows it exactly: - core: a core provider owned by this plugin must exist, or the call fails. Confined to system plugins; every v2 manifest already requires a trusted publisher signature, so the claim is signed by construction. - runtime: the artifact serves it and core never answers in its place. This is what closes the silent-fallback hole. - undeclared: manifests signed before the field existed. The old inference (publisher + core ownership) still resolves them so nothing breaks, but each one is now logged by name so the remaining set is visible rather than silent. Backing is omitempty, so an already-signed manifest serializes to identical bytes and keeps its signature valid — the publisher seed is operator-held, and breaking parity would strand every deployed plugin. A test pins that parity, and pins that DECLARING backing does change the payload, so the claim cannot be swapped in after signing. Disable now stops the backend, not just the UI. In-core providers are wired at boot and never unregistered, so a disabled plugin kept serving — to the gateway, and to any consumer still holding a granted RPC edge. The bus now consults the owning plugin's lifecycle before every dispatch, so a service is servable exactly while its owner is active, whichever side the engine lives on. Tests: go vet clean; internal/plugin and internal/server green under -race. --- internal/plugin/contributions.go | 79 +++++++++- internal/plugin/manifest_v2_test.go | 73 ++++++++++ internal/plugin/rpc.go | 68 ++++++--- internal/server/server.go | 9 ++ internal/server/server_plugin_grants.go | 14 +- internal/server/server_plugin_invoke.go | 96 +++++++++++-- internal/server/server_plugin_invoke_test.go | 144 +++++++++++++++++++ internal/server/server_vpncore_test.go | 15 ++ 8 files changed, 466 insertions(+), 32 deletions(-) diff --git a/internal/plugin/contributions.go b/internal/plugin/contributions.go index 67a2204..c4f8ece 100644 --- a/internal/plugin/contributions.go +++ b/internal/plugin/contributions.go @@ -92,33 +92,84 @@ type ManifestUI struct { Views []ViewContribution `json:"views,omitempty"` } +// Backing names who actually serves an interface's methods. +// +// A plugin is not required to carry its own engine. Some domain engines — the +// nftables renderer, the WireGuard key/config engine — deliberately stay in core so +// the trust base stays small (ADR-001 D5). What the plugin owns in that case is the +// UI, the validation, and the workflow intent; core owns the engine. +// +// That arrangement is legitimate. What is not legitimate is leaving it implicit: a +// manifest that declares a method core secretly answers is a contract that lies, and +// no operator or auditor can see the difference. Backing makes the split an explicit, +// signed, per-service declaration. +const ( + // BackingRuntime: the plugin's own artifact serves the method. The default. + BackingRuntime = "runtime" + // BackingCore: a core-registered provider owned by this plugin serves the method. + // Host-risk by nature — only a system plugin may declare it, and every v2 manifest + // already requires a trusted-publisher signature. + BackingCore = "core" +) + // InterfaceContract declares an interface the plugin exposes (service + methods), // callable through the dashboard->plugin gateway under the given scopes. type InterfaceContract struct { Service string `json:"service"` // Methods remains the normalized name list so v1 callers keep their source // contract. MethodSpecs carries the signed v2 effect and method-level scopes. - Methods []string `json:"-"` - MethodSpecs []InterfaceMethod `json:"-"` - Scopes []string `json:"scopes,omitempty"` + Methods []string `json:"-"` + MethodSpecs []InterfaceMethod `json:"-"` + Scopes []string `json:"scopes,omitempty"` + // Backing is empty on manifests signed before the field existed. Empty stays + // omitted from the signing payload, so those signatures remain byte-identical + // and valid; the gateway resolves them through a logged legacy path until they + // are re-signed with an explicit declaration. + Backing string `json:"backing,omitempty"` typedMethods bool } +// InterfaceFor returns the contract the manifest declares for a service. +func (m Manifest) InterfaceFor(service string) (InterfaceContract, bool) { + for _, contract := range m.Interfaces { + if contract.Service == service { + return contract, true + } + } + return InterfaceContract{}, false +} + +// EffectiveBacking resolves the declared backing, defaulting to runtime. +func (c InterfaceContract) EffectiveBacking() string { + if c.Backing == "" { + return BackingRuntime + } + return c.Backing +} + +// DeclaresBacking reports whether the manifest said who serves this service, rather +// than leaving the host to infer it. +func (c InterfaceContract) DeclaresBacking() bool { + return c.Backing != "" +} + func (c InterfaceContract) MarshalJSON() ([]byte, error) { type stringMethods struct { Service string `json:"service"` Methods []string `json:"methods"` Scopes []string `json:"scopes,omitempty"` + Backing string `json:"backing,omitempty"` } type typedMethods struct { Service string `json:"service"` Methods []InterfaceMethod `json:"methods"` Scopes []string `json:"scopes,omitempty"` + Backing string `json:"backing,omitempty"` } if c.typedMethods || len(c.MethodSpecs) > 0 { - return json.Marshal(typedMethods{Service: c.Service, Methods: c.MethodSpecs, Scopes: c.Scopes}) + return json.Marshal(typedMethods{Service: c.Service, Methods: c.MethodSpecs, Scopes: c.Scopes, Backing: c.Backing}) } - return json.Marshal(stringMethods{Service: c.Service, Methods: c.Methods, Scopes: c.Scopes}) + return json.Marshal(stringMethods{Service: c.Service, Methods: c.Methods, Scopes: c.Scopes, Backing: c.Backing}) } func (c *InterfaceContract) UnmarshalJSON(data []byte) error { @@ -126,6 +177,7 @@ func (c *InterfaceContract) UnmarshalJSON(data []byte) error { Service string `json:"service"` Methods json.RawMessage `json:"methods"` Scopes []string `json:"scopes,omitempty"` + Backing string `json:"backing,omitempty"` } dec := json.NewDecoder(bytes.NewReader(data)) dec.DisallowUnknownFields() @@ -135,7 +187,7 @@ func (c *InterfaceContract) UnmarshalJSON(data []byte) error { if err := ensureNoTrailingJSON(dec); err != nil { return err } - *c = InterfaceContract{Service: raw.Service, Scopes: raw.Scopes} + *c = InterfaceContract{Service: raw.Service, Scopes: raw.Scopes, Backing: raw.Backing} if len(raw.Methods) == 0 { return nil } @@ -236,6 +288,21 @@ func validateContributions(m Manifest) error { if m.Schema == "" && (c.typedMethods || len(c.MethodSpecs) > 0) { return fmt.Errorf("interface %q typed method objects require manifest schema v2", c.Service) } + switch c.Backing { + case "", BackingRuntime, BackingCore: + default: + return fmt.Errorf("interface %q has invalid backing %q (want %q or %q)", + c.Service, c.Backing, BackingRuntime, BackingCore) + } + if c.Backing != "" && m.Schema != ManifestSchemaV2 { + return fmt.Errorf("interface %q backing requires manifest schema v2", c.Service) + } + // Declaring that core serves a method is a claim on the host's own trust base, + // so it is confined to system plugins. Every v2 manifest already requires a + // trusted-publisher signature, so the declaration is signed by construction. + if c.Backing == BackingCore && m.Type != TypeSystem { + return fmt.Errorf("interface %q backing %q requires a system plugin", c.Service, BackingCore) + } for _, s := range c.Scopes { if !scopeAllowedInManifest(s) { return fmt.Errorf("interface %q invalid scope %q", c.Service, s) diff --git a/internal/plugin/manifest_v2_test.go b/internal/plugin/manifest_v2_test.go index fd2985b..5809727 100644 --- a/internal/plugin/manifest_v2_test.go +++ b/internal/plugin/manifest_v2_test.go @@ -327,3 +327,76 @@ func cloneManifestV2(t *testing.T, in Manifest) Manifest { } return out } + +// Backing is omitempty, so a manifest signed before the field existed must serialize to +// exactly the same bytes and keep its signature valid. The publisher seed is operator- +// held: if this parity broke, every deployed plugin would need re-signing before it +// could load again. +func TestBackingOmittedKeepsSigningPayloadByteIdentical(t *testing.T) { + base := Manifest{ + Schema: ManifestSchemaV2, ID: "latticenet.example", Name: "Example", Type: TypeSystem, + Version: "0.2.1-alpha.1", Publisher: "latticenet", Capabilities: []string{"kv:read"}, + Bundle: &BundleSpec{Format: BundleFormatTarGzip, DigestSHA256: strings.Repeat("a", 64)}, + Runtime: &RuntimeSpec{Protocol: RuntimeProtocolStdioJSONV1, Entrypoints: map[string]string{"linux/amd64": "bin/linux-amd64/plugin"}}, + Compatibility: &CompatibilitySpec{ + Server: ">=0.2.1", DashboardHost: ">=1", RuntimeProtocol: ">=1", + }, + Interfaces: []InterfaceContract{{ + Service: "latticenet.example/items", + MethodSpecs: []InterfaceMethod{{Name: "list", Effect: InterfaceEffectRead, Scopes: []string{"proxy:read"}}}, + }}, + } + + undeclared := SigningPayload(base) + if strings.Contains(string(undeclared), "backing") { + t.Fatalf("an undeclared backing must not appear in the signing payload: %s", undeclared) + } + + // Declaring backing MUST change the payload — it is a security-relevant claim and + // has to be covered by the signature, not swappable after signing. + declared := base + declared.Interfaces = []InterfaceContract{{ + Service: "latticenet.example/items", + Backing: BackingCore, + MethodSpecs: []InterfaceMethod{{Name: "list", Effect: InterfaceEffectRead, Scopes: []string{"proxy:read"}}}, + }} + signed := SigningPayload(declared) + if string(undeclared) == string(signed) { + t.Fatal("declaring backing must change the signing payload, or it could be swapped after signing") + } + if !strings.Contains(string(signed), `"backing":"core"`) { + t.Fatalf("declared backing missing from signing payload: %s", signed) + } +} + +func TestBackingValidation(t *testing.T) { + newManifest := func(pluginType, backing string) Manifest { + return Manifest{ + Schema: ManifestSchemaV2, ID: "latticenet.example", Name: "Example", Type: pluginType, + Version: "0.2.1-alpha.1", Publisher: "latticenet", Capabilities: []string{"kv:read"}, + Bundle: &BundleSpec{Format: BundleFormatTarGzip, DigestSHA256: strings.Repeat("a", 64)}, + Runtime: &RuntimeSpec{Protocol: RuntimeProtocolStdioJSONV1, Entrypoints: map[string]string{"linux/amd64": "bin/linux-amd64/plugin"}}, + Compatibility: &CompatibilitySpec{ + Server: ">=0.2.1", DashboardHost: ">=1", RuntimeProtocol: ">=1", + }, + Interfaces: []InterfaceContract{{ + Service: "latticenet.example/items", + Backing: backing, + MethodSpecs: []InterfaceMethod{{Name: "list", Effect: InterfaceEffectRead, Scopes: []string{"proxy:read"}}}, + }}, + } + } + + for _, valid := range []string{"", BackingRuntime, BackingCore} { + if err := ValidateManifest(newManifest(TypeSystem, valid)); err != nil { + t.Fatalf("backing %q should be valid for a system plugin: %v", valid, err) + } + } + if err := ValidateManifest(newManifest(TypeSystem, "wasm")); err == nil { + t.Fatal("an unknown backing must be rejected") + } + // Claiming core is a claim on the host's own trust base. + if err := ValidateManifest(newManifest(TypeWasm, BackingCore)); err == nil { + t.Fatal("a non-system plugin must not declare core backing") + } +} diff --git a/internal/plugin/rpc.go b/internal/plugin/rpc.go index fee991a..6b7ac32 100644 --- a/internal/plugin/rpc.go +++ b/internal/plugin/rpc.go @@ -24,8 +24,16 @@ var ( ErrRPCDenied = errors.New("rpc call denied") // ErrRPCInvalid is returned for a malformed service registration. ErrRPCInvalid = errors.New("invalid rpc registration") + // ErrRPCOwnerInactive is returned when the plugin owning a service is not active. + // Disable must stop the backend, not merely hide the UI: core-registered providers + // are wired at boot and never unregistered, so without this gate a disabled plugin + // kept serving — to the gateway, and to any consumer still holding a granted edge. + ErrRPCOwnerInactive = errors.New("rpc service owner is not active") ) +// OwnerActiveFunc reports whether the plugin owning a service is currently active. +type OwnerActiveFunc func(pluginID string) bool + // RPCHandler serves one inter-plugin RPC method: it receives the method name and // raw request bytes and returns raw response bytes. Implementations must be safe // for concurrent use; the registry invokes them WITHOUT holding its lock. @@ -50,9 +58,10 @@ type rpcService struct { // RPCRegistry is the server-owned inter-plugin RPC bus. It is safe for concurrent // use and implements the broker's RPCHost interface. type RPCRegistry struct { - mu sync.RWMutex - services map[string]*rpcService - grants map[string]map[string]map[string]struct{} // service -> caller -> allowed methods ("*" grants all) + mu sync.RWMutex + services map[string]*rpcService + grants map[string]map[string]map[string]struct{} // service -> caller -> allowed methods ("*" grants all) + ownerActive OwnerActiveFunc } // NewRPCRegistry returns an empty registry. @@ -63,6 +72,32 @@ func NewRPCRegistry() *RPCRegistry { } } +// SetOwnerActive installs the lifecycle predicate consulted before every dispatch. +// Until it is set the registry serves any registered service, which is the correct +// default for a bus with no lifecycle to consult (tests, boot). +func (r *RPCRegistry) SetOwnerActive(fn OwnerActiveFunc) { + r.mu.Lock() + r.ownerActive = fn + r.mu.Unlock() +} + +// serviceIfActive resolves a service and refuses it when its owning plugin is not +// active. Returns ErrRPCNoService when unregistered so a disabled plugin and an +// absent one are indistinguishable to a caller probing for services. +func (r *RPCRegistry) serviceIfActive(service string) (*rpcService, error) { + r.mu.RLock() + svc := r.services[service] + active := r.ownerActive + r.mu.RUnlock() + if svc == nil { + return nil, fmt.Errorf("%w: %s", ErrRPCNoService, service) + } + if active != nil && !active(svc.owner) { + return nil, fmt.Errorf("%w: %s (owner %s)", ErrRPCOwnerInactive, service, svc.owner) + } + return svc, nil +} + // Register adds (or replaces) a service exposed by ownerPluginID. service is the // fully-qualified id (e.g. "latticenet.vpn-core/nodes"); it must carry >=1 // non-empty method and a non-nil handler. Re-registering the same id replaces it @@ -164,11 +199,9 @@ func (r *RPCRegistry) Owns(ownerPluginID, service string) bool { // HTTP layer has already enforced the interface's declared RBAC scopes + audit. // Service/method-not-found are still errors. The handler runs OUTSIDE the lock. func (r *RPCRegistry) CallOperator(ctx context.Context, service, method string, request []byte) ([]byte, error) { - r.mu.RLock() - svc := r.services[service] - r.mu.RUnlock() - if svc == nil { - return nil, fmt.Errorf("%w: %s", ErrRPCNoService, service) + svc, err := r.serviceIfActive(service) + if err != nil { + return nil, err } if _, ok := svc.methods[method]; !ok { return nil, fmt.Errorf("%w: %s/%s", ErrRPCNoMethod, service, method) @@ -180,24 +213,25 @@ func (r *RPCRegistry) CallOperator(ctx context.Context, service, method string, // (the owner may always self-call), check the method, then dispatch to the // handler OUTSIDE the lock so a slow or re-entrant handler cannot block the bus. func (r *RPCRegistry) Call(ctx context.Context, caller, service, method string, request []byte) ([]byte, error) { + // A granted edge does not outlive its provider: a consumer holding rpc:call on a + // disabled plugin's service is refused here, not served by a backend that only + // looks alive because core registered it at boot. + svc, err := r.serviceIfActive(service) + if err != nil { + return nil, err + } + r.mu.RLock() - svc := r.services[service] - allowed := false - if svc != nil { + allowed := caller == svc.owner + if !allowed { if methods := r.grants[service][caller]; methods != nil { _, wildcard := methods["*"] _, exact := methods[method] allowed = wildcard || exact } - if caller == svc.owner { - allowed = true - } } r.mu.RUnlock() - if svc == nil { - return nil, fmt.Errorf("%w: %s", ErrRPCNoService, service) - } if !allowed { return nil, fmt.Errorf("%w: %s -> %s", ErrRPCDenied, caller, service) } diff --git a/internal/server/server.go b/internal/server/server.go index 0bb64e9..2827cce 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -178,6 +178,10 @@ type Server struct { // party in-core providers register services on it; plugins reach it through // the capability-scoped broker (HostServices.RPC). pluginRPC *plugin.RPCRegistry + + // undeclaredBackingOnce tracks which legacy services have already been reported + // as core-backed-by-inference, so the warning names each one exactly once. + undeclaredBackingOnce sync.Map // pluginTrust is the operator policy used by both startup loading and // pre-install verification endpoints. It is intentionally not client supplied. pluginTrust plugin.TrustPolicy @@ -388,6 +392,11 @@ func New(opts Options) (*Server, error) { } s.emitNotify = s.notifyEvent s.pluginRPC = plugin.NewRPCRegistry() + // In-core providers are wired once at boot and never unregistered, so without a + // lifecycle predicate a disabled plugin's backend kept serving — disable would only + // hide the UI. A service is servable exactly while its owning plugin is active, + // whether the engine behind it lives in core or in the plugin's own artifact. + s.pluginRPC.SetOwnerActive(s.pluginIsActive) s.registerVPNCoreRPC() s.registerNetworkPluginRPC() // Derive vpn-core identities (VpnUser) from legacy ProxyUsers. Idempotent and diff --git a/internal/server/server_plugin_grants.go b/internal/server/server_plugin_grants.go index ef55644..ed4f2a0 100644 --- a/internal/server/server_plugin_grants.go +++ b/internal/server/server_plugin_grants.go @@ -1,6 +1,18 @@ package server -import "github.com/LatticeNet/lattice-server/internal/plugin" +import ( + "github.com/LatticeNet/lattice-sdk/model" + "github.com/LatticeNet/lattice-server/internal/plugin" +) + +// pluginIsActive is the lifecycle predicate the RPC bus consults before dispatching +// any service. It is the single answer to "may this plugin's backend run right now", +// and it applies whether the engine behind the service lives in the plugin's artifact +// or in core. +func (s *Server) pluginIsActive(pluginID string) bool { + installation, ok := s.store.PluginInstallation(pluginID) + return ok && installation.Status == model.PluginStatusActive +} // applyPluginHostAccess materializes signed, method-bounded dependencies only // for an active plugin runtime. The manifest remains the sole owner of these diff --git a/internal/server/server_plugin_invoke.go b/internal/server/server_plugin_invoke.go index 7191015..ed7db28 100644 --- a/internal/server/server_plugin_invoke.go +++ b/internal/server/server_plugin_invoke.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "log" "net/http" "net/url" "strconv" @@ -234,15 +235,12 @@ func (s *Server) handlePluginCall(w http.ResponseWriter, r *http.Request, p prin loaded, loadedOK := s.loadedPlugin(req.ID) var out []byte err = nil - if loadedOK && loaded.Manifest.Schema == plugin.ManifestSchemaV2 && - loaded.Manifest.Publisher == "latticenet" && s.pluginRPC != nil && - s.pluginRPC.Owns(req.ID, req.Service) { - out, err = s.pluginRPC.CallOperator(ctx, req.Service, req.Method, []byte(req.Payload)) - } else if loadedOK && loaded.Manifest.Schema == plugin.ManifestSchemaV2 { - out, err = s.callRuntimePluginService(ctx, req.ID, req.Service, req.Method, req.Payload, operatorTargets) - } else if s.pluginRPC == nil { + switch { + case loadedOK && loaded.Manifest.Schema == plugin.ManifestSchemaV2: + out, err = s.dispatchV2PluginCall(ctx, loaded, req.ID, req.Service, req.Method, req.Payload, operatorTargets) + case s.pluginRPC == nil: err = errors.New("plugin rpc bus unavailable") - } else { + default: out, err = s.pluginRPC.CallOperator(ctx, req.Service, req.Method, []byte(req.Payload)) if errors.Is(err, plugin.ErrRPCNoService) { out, err = s.callRuntimePluginService(ctx, req.ID, req.Service, req.Method, req.Payload, nil) @@ -270,6 +268,83 @@ func (s *Server) handlePluginCall(w http.ResponseWriter, r *http.Request, p prin _, _ = w.Write(out) } +// dispatchV2PluginCall routes a v2 call to whoever the SIGNED MANIFEST says serves it. +// +// A plugin need not carry its own engine — the nftables renderer and the WireGuard +// key/config engine deliberately stay in core so the trust base stays small. What is +// not acceptable is inferring that from the publisher's name: routing used to send any +// service that core happened to own and whose publisher string was "latticenet" to the +// in-core handler, so a manifest could declare methods its own artifact could not serve +// and no operator could see the difference. +// +// Backing is now a per-service declaration inside the signed manifest, and dispatch +// follows it exactly: +// +// - backing "core": a core provider owned by this plugin must exist. If it does not, +// the call fails — a manifest cannot name core as its backend and have the host +// quietly find something else. +// - backing "runtime": the artifact serves it, and core never answers in its place. +// This is what closes the silent-fallback hole. +// - undeclared: manifests signed before the field existed. Resolved through the legacy +// inference, logged once per service so the remaining ones are visible, and refused +// the moment a re-signed manifest declares its backing. +func (s *Server) dispatchV2PluginCall( + ctx context.Context, + loaded plugin.Loaded, + pluginID, service, method string, + payload json.RawMessage, + operatorTargets []string, +) ([]byte, error) { + contract, ok := loaded.Manifest.InterfaceFor(service) + if !ok { + return nil, fmt.Errorf("plugin %q does not declare service %q", pluginID, service) + } + coreOwns := s.pluginRPC != nil && s.pluginRPC.Owns(pluginID, service) + + if !contract.DeclaresBacking() { + // Reproduce the pre-backing inference exactly, publisher constraint included, so + // an already-signed manifest keeps working unchanged. A third party still cannot + // reach a core provider by naming a service core happens to own. + if coreOwns && loaded.Manifest.Publisher == trustedCorePublisher { + s.warnUndeclaredBacking(pluginID, service) + return s.pluginRPC.CallOperator(ctx, service, method, []byte(payload)) + } + return s.callRuntimePluginService(ctx, pluginID, service, method, payload, operatorTargets) + } + + switch contract.EffectiveBacking() { + case plugin.BackingCore: + if !coreOwns { + return nil, fmt.Errorf("plugin %q declares service %q as core-backed, but no core provider owns it", + pluginID, service) + } + return s.pluginRPC.CallOperator(ctx, service, method, []byte(payload)) + default: + if coreOwns { + // A runtime-backed service shadowed by a core provider is exactly the + // ambiguity backing exists to remove. Refuse rather than pick one. + return nil, fmt.Errorf("plugin %q declares service %q as runtime-backed, but a core provider also owns it", + pluginID, service) + } + return s.callRuntimePluginService(ctx, pluginID, service, method, payload, operatorTargets) + } +} + +// warnUndeclaredBacking logs each legacy core-backed service once, so the set of +// manifests still relying on inference is visible rather than silent. +func (s *Server) warnUndeclaredBacking(pluginID, service string) { + if _, alreadyWarned := s.undeclaredBackingOnce.LoadOrStore(pluginID+"/"+service, true); alreadyWarned { + return + } + const format = "plugin gateway: %s does not declare backing for %q; core is answering it by inference. " + + "Re-sign the manifest with \"backing\":\"core\" — inference will be removed." + if s.logger != nil { + s.logger.Printf(format, pluginID, service) + return + } + log.Printf(format, pluginID, service) +} + func (s *Server) callRuntimePluginService(ctx context.Context, pluginID, service, method string, payload json.RawMessage, operatorTargets []string) ([]byte, error) { if s.pluginRuntime == nil { return nil, errors.New("plugin runtime unavailable") @@ -459,6 +534,11 @@ var diagnosticPluginActions = map[string]bool{ "health": true, } +// trustedCorePublisher is the publisher whose manifests may be inferred as core-backed +// while they predate the explicit `backing` declaration. It gates only the legacy path; +// a declared backing is authorized by the manifest signature itself. +const trustedCorePublisher = "latticenet" + // handlePluginInvoke runs one DIAGNOSTIC action on an ACTIVE plugin via the // runtime (the Tier-2 system runner execs the artifact's {action,payload}-> // {ok,result} protocol). It exists so an operator can interrogate a staged diff --git a/internal/server/server_plugin_invoke_test.go b/internal/server/server_plugin_invoke_test.go index b83ca4c..aafe9e8 100644 --- a/internal/server/server_plugin_invoke_test.go +++ b/internal/server/server_plugin_invoke_test.go @@ -522,3 +522,147 @@ func TestOperatorTargetErrorRedactsSecret(t *testing.T) { t.Fatalf("operator target secret leaked into the error: %q", err) } } + +// A manifest that declares a service as runtime-backed must never be answered by core. +// Silent core fallback is the exact ambiguity the backing declaration exists to remove: +// it let a plugin ship methods its own artifact could not serve while core quietly +// answered them, with no way for an operator to tell the difference. +func TestPluginCallRuntimeBackedServiceIsNeverAnsweredByCore(t *testing.T) { + st, err := store.Open("") + if err != nil { + t.Fatal(err) + } + manifest := plugin.Manifest{ + Schema: plugin.ManifestSchemaV2, ID: "test.v2-runtime", Name: "V2 runtime", Type: plugin.TypeSystem, + Publisher: "latticenet", + Interfaces: []plugin.InterfaceContract{{ + Service: "test.v2-runtime/items", + Backing: plugin.BackingRuntime, + MethodSpecs: []plugin.InterfaceMethod{{ + Name: "list", Effect: plugin.InterfaceEffectRead, Scopes: []string{"proxy:read"}, + }}, + }}, + } + if err := st.UpsertPluginInstallation(model.PluginInstallation{ + ID: manifest.ID, Name: manifest.Name, Type: manifest.Type, Status: model.PluginStatusActive, + }); err != nil { + t.Fatal(err) + } + srv := &Server{store: st, plugins: []plugin.Loaded{{Manifest: manifest}}, pluginRPC: plugin.NewRPCRegistry()} + // Core owns a provider that shadows the runtime-backed service. + if err := srv.pluginRPC.Register(manifest.ID, "test.v2-runtime/items", "v1", []string{"list"}, + func(_ context.Context, _ string, _ []byte) ([]byte, error) { + return []byte(`{"rows":[{"id":"from-core"}]}`), nil + }); err != nil { + t.Fatal(err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/plugins/call", strings.NewReader( + `{"id":"test.v2-runtime","service":"test.v2-runtime/items","method":"list"}`, + )) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.handlePluginCall(rec, req, principal{Principal: rbac.Principal{Scopes: []string{"proxy:read"}}}) + + if strings.Contains(rec.Body.String(), "from-core") { + t.Fatalf("a runtime-backed service was answered by core: %s", rec.Body.String()) + } + if rec.Code == http.StatusOK { + t.Fatalf("a core provider shadowing a runtime-backed service must fail closed, got 200: %s", rec.Body.String()) + } +} + +// A core-backed declaration is honoured: the plugin owns the UI and the workflow, core +// owns the engine, and the manifest says so out loud. +func TestPluginCallCoreBackedServiceDispatchesToCore(t *testing.T) { + st, err := store.Open("") + if err != nil { + t.Fatal(err) + } + manifest := plugin.Manifest{ + Schema: plugin.ManifestSchemaV2, ID: "test.v2-core", Name: "V2 core", Type: plugin.TypeSystem, + Publisher: "latticenet", + Interfaces: []plugin.InterfaceContract{{ + Service: "test.v2-core/items", + Backing: plugin.BackingCore, + MethodSpecs: []plugin.InterfaceMethod{{ + Name: "list", Effect: plugin.InterfaceEffectRead, Scopes: []string{"proxy:read"}, + }}, + }}, + } + if err := st.UpsertPluginInstallation(model.PluginInstallation{ + ID: manifest.ID, Name: manifest.Name, Type: manifest.Type, Status: model.PluginStatusActive, + }); err != nil { + t.Fatal(err) + } + srv := &Server{store: st, plugins: []plugin.Loaded{{Manifest: manifest}}, pluginRPC: plugin.NewRPCRegistry()} + srv.pluginRPC.SetOwnerActive(srv.pluginIsActive) + if err := srv.pluginRPC.Register(manifest.ID, "test.v2-core/items", "v1", []string{"list"}, + func(_ context.Context, _ string, _ []byte) ([]byte, error) { + return []byte(`{"rows":[{"id":"from-core"}]}`), nil + }); err != nil { + t.Fatal(err) + } + + call := func() *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/api/plugins/call", strings.NewReader( + `{"id":"test.v2-core","service":"test.v2-core/items","method":"list"}`, + )) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.handlePluginCall(rec, req, principal{Principal: rbac.Principal{Scopes: []string{"proxy:read"}}}) + return rec + } + + rec := call() + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "from-core") { + t.Fatalf("core-backed call did not reach core: %d %s", rec.Code, rec.Body.String()) + } + + // Disable must stop the BACKEND, not merely hide the UI. The core provider is wired + // at boot and never unregistered, so without a lifecycle gate it would keep serving. + if err := st.UpsertPluginInstallation(model.PluginInstallation{ + ID: manifest.ID, Name: manifest.Name, Type: manifest.Type, Status: model.PluginStatusDisabled, + }); err != nil { + t.Fatal(err) + } + if rec := call(); rec.Code == http.StatusOK { + t.Fatalf("a disabled plugin's core-backed service kept serving: %d %s", rec.Code, rec.Body.String()) + } +} + +// A manifest cannot name core as its backend and have the host quietly find something +// else to answer with. +func TestPluginCallCoreBackedWithoutProviderFailsClosed(t *testing.T) { + st, err := store.Open("") + if err != nil { + t.Fatal(err) + } + manifest := plugin.Manifest{ + Schema: plugin.ManifestSchemaV2, ID: "test.v2-orphan", Name: "V2 orphan", Type: plugin.TypeSystem, + Publisher: "latticenet", + Interfaces: []plugin.InterfaceContract{{ + Service: "test.v2-orphan/items", + Backing: plugin.BackingCore, + MethodSpecs: []plugin.InterfaceMethod{{ + Name: "list", Effect: plugin.InterfaceEffectRead, Scopes: []string{"proxy:read"}, + }}, + }}, + } + if err := st.UpsertPluginInstallation(model.PluginInstallation{ + ID: manifest.ID, Name: manifest.Name, Type: manifest.Type, Status: model.PluginStatusActive, + }); err != nil { + t.Fatal(err) + } + srv := &Server{store: st, plugins: []plugin.Loaded{{Manifest: manifest}}, pluginRPC: plugin.NewRPCRegistry()} + + req := httptest.NewRequest(http.MethodPost, "/api/plugins/call", strings.NewReader( + `{"id":"test.v2-orphan","service":"test.v2-orphan/items","method":"list"}`, + )) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + srv.handlePluginCall(rec, req, principal{Principal: rbac.Principal{Scopes: []string{"proxy:read"}}}) + if rec.Code == http.StatusOK { + t.Fatalf("core-backed service with no core provider must fail closed, got 200: %s", rec.Body.String()) + } +} diff --git a/internal/server/server_vpncore_test.go b/internal/server/server_vpncore_test.go index 6bfebb2..8e25364 100644 --- a/internal/server/server_vpncore_test.go +++ b/internal/server/server_vpncore_test.go @@ -9,6 +9,19 @@ import ( "github.com/LatticeNet/lattice-server/internal/store" ) +// activateCorePlugin marks a plugin active so its services are servable. A service is +// only served while its owning plugin is active, whether the engine behind it lives in +// core or in the plugin's artifact — so even an in-core provider needs its plugin +// installed and active, exactly as in a real deployment. +func activateCorePlugin(t *testing.T, st *store.Store, pluginID string) { + t.Helper() + if err := st.UpsertPluginInstallation(model.PluginInstallation{ + ID: pluginID, Name: pluginID, Type: "system", Status: model.PluginStatusActive, + }); err != nil { + t.Fatal(err) + } +} + func TestVPNCoreExportIncludesDiscoveredNodes(t *testing.T) { st, err := store.Open("") if err != nil { @@ -18,6 +31,7 @@ func TestVPNCoreExportIncludesDiscoveredNodes(t *testing.T) { if err != nil { t.Fatalf("New: %v", err) } + activateCorePlugin(t, st, vpnCorePluginID) // The nodes must exist (the export filters discovery to live nodes), as they // would when a real agent reports. for _, id := range []string{"node-a", "node-b"} { @@ -94,6 +108,7 @@ func TestVPNCoreNodesRPCRegisteredAndExports(t *testing.T) { if err != nil { t.Fatalf("New: %v", err) } + activateCorePlugin(t, st, vpnCorePluginID) // The in-core vpn-core/nodes service is registered with the export + list // methods (list backs the design-10 plugin-contributed Nodes table; Services()