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()