Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 73 additions & 6 deletions internal/plugin/contributions.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,40 +92,92 @@ 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 {
var raw struct {
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()
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down
73 changes: 73 additions & 0 deletions internal/plugin/manifest_v2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
68 changes: 51 additions & 17 deletions internal/plugin/rpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
Expand Down
9 changes: 9 additions & 0 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion internal/server/server_plugin_grants.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading
Loading