Squad Aegis

Native Plugins and Connectors

Squad Aegis native extensions are standalone Go binaries that run as isolated subprocesses. The host launches them via hashicorp/go-plugin with AutoMTLS, communicates over gRPC, and tears them down cleanly on shutdown. Each spawned subprocess gets a freshly minted client certificate, so its IPC channel is mutually authenticated with the host. A crash in your extension cannot corrupt the host.

There are two extension types:

  • Plugin - runs against a specific game server. It subscribes to events (chat, kills, connections, system signals), reacts to them, and calls host APIs such as RCON, logging, rules, admin, database, Discord, and connectors.
  • Connector - a reusable service that exposes a request/response Invoke entrypoint. Plugins call connectors when they need shared logic or access to an external system. Connectors do not receive host APIs or events.

Which should I build?

Build aWhen you needExample
PluginPer-server automation driven by game events or host APIsChat moderation, kick/ban enforcement, Discord relay, player tracking
ConnectorA shared service that multiple plugins (or external tools) can callExternal API wrapper, centralized integration logic, data enrichment service
BothAn integration boundary and per-server behavior that uses itA connector for an external ban list + a plugin that checks it on player join

Both ship as a zip bundle containing a manifest.json and one or more Linux binaries.


Architecture

Key invariants:

  • Process isolation. Each extension runs in its own OS process. The host monitors health and restarts as needed.
  • Manifest vs. definition split. The manifest.json carries identity and trust data (name, version, authors, signature, checksums). The binary's GetDefinition() returns runtime behavior (config schema, events, connector dependencies). The host cross-checks the ID from both - a mismatch rejects the bundle.
  • Plugins call connectors, not the reverse. A plugin reaches a connector through ConnectorAPI.Call(). Connectors never import plugin code.

Extension Lifecycle

* Connectors do not receive hostAPIs.

Both extension types share the same set of status values:

StatusMeaning
stoppedNot running
startingInitializing
runningHealthy and processing
stoppingShutting down
errorFailed - check logs
disabledManually disabled by operator

Quickstart

Working inside this repository:

# Read the checked-in examples
examples/native-plugin-hello/main.go
examples/native-connector-hello/main.go

# Build + package
./scripts/package-example-native-plugin.sh
./scripts/package-example-native-connector.sh

Building an external extension:

mkdir my-aegis-extension && cd my-aegis-extension
go mod init example.com/my-aegis-extension
go get go.codycody31.dev/squad-aegis@latest

Import only the public SDK packages:

  • go.codycody31.dev/squad-aegis/pkg/pluginrpc for plugins
  • go.codycody31.dev/squad-aegis/pkg/connectorrpc for connectors

Building a Plugin

The Plugin Interface

Your binary must implement pluginrpc.Plugin:

type Plugin interface {
    GetDefinition() PluginDefinition
    Initialize(config map[string]interface{}, apis *HostAPIs) error
    Start(ctx context.Context) error
    Stop() error
    HandleEvent(event *PluginEvent) error
    GetStatus() PluginStatus
    GetConfig() map[string]interface{}
    UpdateConfig(config map[string]interface{}) error
    GetCommands() []PluginCommand
    ExecuteCommand(commandID string, params map[string]interface{}) (*CommandResult, error)
    GetCommandExecutionStatus(executionID string) (*CommandExecutionStatus, error)
}
MethodRole
GetDefinitionReturn runtime behavior: config schema, subscribed events, connector dependencies
InitializeReceive config and host API handles. Set up internal state
StartBegin processing. Return immediately unless the plugin is long-running
StopClean up resources and background goroutines
HandleEventReact to a subscribed event. Keep this fast - offload heavy work
GetStatusReport current lifecycle status
GetConfig / UpdateConfigRead and hot-reload configuration
GetCommandsDeclare operator-invocable commands (or return nil)
ExecuteCommandRun a declared command synchronously or start it asynchronously
GetCommandExecutionStatusPoll progress of an async command

Minimal Example

A plugin that responds to a chat command with a private warning message:

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "strings"
    "sync"

    pluginrpc "go.codycody31.dev/squad-aegis/pkg/pluginrpc"
)

type chatMessage struct {
    SteamID    string `json:"steam_id,omitempty"`
    EOSID      string `json:"eos_id,omitempty"`
    PlayerName string `json:"player_name,omitempty"`
    Message    string `json:"message,omitempty"`
}

type helloPlugin struct {
    mu     sync.Mutex
    config map[string]interface{}
    apis   *pluginrpc.HostAPIs
    status pluginrpc.PluginStatus
}

func main() {
    pluginrpc.Serve(&helloPlugin{})
}

func (p *helloPlugin) GetDefinition() pluginrpc.PluginDefinition {
    return pluginrpc.PluginDefinition{
        PluginID: "com.example.plugins.hello",
        ConfigSchema: pluginrpc.ConfigSchema{
            Fields: []pluginrpc.ConfigField{
                {
                    Name:        "trigger",
                    Description: "Chat command that triggers the response.",
                    Type:        pluginrpc.FieldTypeString,
                    Default:     "!hello",
                },
                {
                    Name:        "response",
                    Description: "Message sent back to the player.",
                    Type:        pluginrpc.FieldTypeString,
                    Default:     "Hello from Squad Aegis.",
                },
            },
        },
        Events: []string{"RCON_CHAT_MESSAGE"},
    }
}

func (p *helloPlugin) Initialize(config map[string]interface{}, apis *pluginrpc.HostAPIs) error {
    p.mu.Lock()
    defer p.mu.Unlock()
    p.config = config
    p.apis = apis
    p.status = pluginrpc.PluginStatusStopped
    return nil
}

func (p *helloPlugin) Start(context.Context) error {
    p.mu.Lock()
    defer p.mu.Unlock()
    p.status = pluginrpc.PluginStatusRunning
    return nil
}

func (p *helloPlugin) Stop() error {
    p.mu.Lock()
    defer p.mu.Unlock()
    p.status = pluginrpc.PluginStatusStopped
    return nil
}

func (p *helloPlugin) HandleEvent(event *pluginrpc.PluginEvent) error {
    if event == nil || event.Type != "RCON_CHAT_MESSAGE" {
        return nil
    }

    var msg chatMessage
    if err := json.Unmarshal(event.Data, &msg); err != nil {
        return fmt.Errorf("decode chat event: %w", err)
    }

    p.mu.Lock()
    trigger := fmt.Sprint(p.config["trigger"])
    response := fmt.Sprint(p.config["response"])
    apis := p.apis
    p.mu.Unlock()

    if !strings.EqualFold(strings.TrimSpace(msg.Message), trigger) {
        return nil
    }

    playerID := msg.EOSID
    if playerID == "" {
        playerID = msg.SteamID
    }

    if apis != nil && apis.RconAPI != nil {
        return apis.RconAPI.SendWarningToPlayer(playerID, response)
    }
    return nil
}

func (p *helloPlugin) GetStatus() pluginrpc.PluginStatus {
    p.mu.Lock()
    defer p.mu.Unlock()
    return p.status
}

func (p *helloPlugin) GetConfig() map[string]interface{} {
    p.mu.Lock()
    defer p.mu.Unlock()
    return p.config
}

func (p *helloPlugin) UpdateConfig(c map[string]interface{}) error {
    p.mu.Lock()
    defer p.mu.Unlock()
    p.config = c
    return nil
}

func (p *helloPlugin) GetCommands() []pluginrpc.PluginCommand { return nil }

func (p *helloPlugin) ExecuteCommand(string, map[string]interface{}) (*pluginrpc.CommandResult, error) {
    return nil, fmt.Errorf("no commands")
}

func (p *helloPlugin) GetCommandExecutionStatus(string) (*pluginrpc.CommandExecutionStatus, error) {
    return nil, fmt.Errorf("no commands")
}

Plugin Patterns

Events. PluginEvent.Data is json.RawMessage. Define the payload struct locally in your plugin and unmarshal it yourself - do not import host event types.

type PluginEvent struct {
    ID        string          `json:"id"`
    ServerID  string          `json:"server_id"`
    Source    EventSource     `json:"source"`    // "rcon", "log", "system", "connector", "plugin"
    Type      string          `json:"type"`      // e.g. "RCON_CHAT_MESSAGE"
    Data      json.RawMessage `json:"data,omitempty"`
    Raw       string          `json:"raw,omitempty"`
    Timestamp time.Time       `json:"timestamp"`
}

Long-running plugins. Set LongRunning: true in your definition only when Start maintains a background goroutine (e.g., a polling loop or ticker). Event-driven plugins that only react in HandleEvent should leave this false.

Connector dependencies. If your plugin calls a connector, declare it:

RequiredConnectors: []string{"com.example.connectors.myservice"},
// or
OptionalConnectors: []string{"com.example.connectors.myservice"},

Required connectors must be available before the plugin can start. Optional connectors degrade gracefully - check for errors in ConnectorAPI.Call.

Commands. Plugins can expose operator-invocable commands through GetCommands. Each command declares its parameters using the same ConfigSchema type as plugin config:

type PluginCommand struct {
    ID                  string               `json:"id"`
    Name                string               `json:"name"`
    Description         string               `json:"description,omitempty"`
    Category            string               `json:"category,omitempty"`
    Parameters          ConfigSchema         `json:"parameters,omitempty"`
    ExecutionType       CommandExecutionType `json:"execution_type"` // "sync" or "async"
    RequiredPermissions []string             `json:"required_permissions,omitempty"`
    ConfirmMessage      string               `json:"confirm_message,omitempty"`
}

For sync commands, ExecuteCommand runs to completion and returns a CommandResult. For async commands, return immediately with an ExecutionID and report progress through GetCommandExecutionStatus.

Config Schema

Plugin and connector configuration is declared through ConfigSchema, a list of typed fields:

Field TypeConstantGo value
StringFieldTypeString"string"
IntegerFieldTypeInt"int"
BooleanFieldTypeBool"bool"
ObjectFieldTypeObject"object"
ArrayFieldTypeArray"array"
String arrayFieldTypeArrayString"arraystring"
Int arrayFieldTypeArrayInt"arrayint"
Bool arrayFieldTypeArrayBool"arraybool"
Object arrayFieldTypeArrayObject"arrayobject"

Each ConfigField supports:

  • Required - the UI will block saving until this field is set.
  • Default - pre-populated in the UI.
  • Sensitive - the value is masked in the UI (use for tokens, secrets).
  • Enum - constrains the field to a set of allowed values.
  • Nested - child fields for object, array, and compound array types.

Building a Connector

The Connector Interface

Your binary must implement connectorrpc.Connector:

type Connector interface {
    GetDefinition() ConnectorDefinition
    Initialize(config map[string]interface{}) error
    Start(ctx context.Context) error
    Stop() error
    GetStatus() ConnectorStatus
    GetConfig() map[string]interface{}
    UpdateConfig(config map[string]interface{}) error
    Invoke(ctx context.Context, req *ConnectorInvokeRequest) (*ConnectorInvokeResponse, error)
}
MethodRole
GetDefinitionReturn connector ID and config schema
InitializeReceive config. Set up clients, pools, or state
StartBegin accepting Invoke calls. Listen for ctx.Done() for shutdown
StopTear down resources
GetStatusReport current lifecycle status
GetConfig / UpdateConfigRead and hot-reload configuration
InvokeHandle a request and return a response

Note that Initialize does not receive HostAPIs - a connector is its own boundary.

Minimal Example

A connector that responds to ping with pong:

package main

import (
    "context"
    "fmt"
    "sync"

    connectorrpc "go.codycody31.dev/squad-aegis/pkg/connectorrpc"
)

type helloConnector struct {
    mu     sync.RWMutex
    config map[string]interface{}
    status connectorrpc.ConnectorStatus
}

func main() {
    connectorrpc.Serve(&helloConnector{})
}

func (c *helloConnector) GetDefinition() connectorrpc.ConnectorDefinition {
    return connectorrpc.ConnectorDefinition{
        ConnectorID: "com.example.connectors.hello",
        ConfigSchema: connectorrpc.ConfigSchema{
            Fields: []connectorrpc.ConfigField{},
        },
    }
}

func (c *helloConnector) Initialize(config map[string]interface{}) error {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.config = config
    c.status = connectorrpc.ConnectorStatusStopped
    return nil
}

func (c *helloConnector) Start(ctx context.Context) error {
    c.mu.Lock()
    c.status = connectorrpc.ConnectorStatusRunning
    c.mu.Unlock()
    go func() {
        <-ctx.Done()
        _ = c.Stop()
    }()
    return nil
}

func (c *helloConnector) Stop() error {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.status = connectorrpc.ConnectorStatusStopped
    return nil
}

func (c *helloConnector) GetStatus() connectorrpc.ConnectorStatus {
    c.mu.RLock()
    defer c.mu.RUnlock()
    return c.status
}

func (c *helloConnector) GetConfig() map[string]interface{} {
    c.mu.RLock()
    defer c.mu.RUnlock()
    return c.config
}

func (c *helloConnector) UpdateConfig(config map[string]interface{}) error {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.config = config
    return nil
}

func (c *helloConnector) Invoke(ctx context.Context, req *connectorrpc.ConnectorInvokeRequest) (*connectorrpc.ConnectorInvokeResponse, error) {
    resp := &connectorrpc.ConnectorInvokeResponse{V: "1"}

    if req == nil || req.Data == nil {
        resp.Error = "missing request data"
        return resp, nil
    }

    if req.Data["action"] == "ping" {
        resp.OK = true
        resp.Data = map[string]interface{}{"message": "pong"}
        return resp, nil
    }

    resp.Error = fmt.Sprintf("unknown action: %v", req.Data["action"])
    return resp, nil
}

The Invoke Protocol

Connectors use a versioned request/response envelope:

// Request (sent by plugin or host)
type ConnectorInvokeRequest struct {
    V    string                 `json:"v"`    // Protocol version, currently "1"
    Data map[string]interface{} `json:"data"` // Arbitrary payload
}

// Response (returned by connector)
type ConnectorInvokeResponse struct {
    V     string                 `json:"v"`               // Protocol version
    OK    bool                   `json:"ok"`              // true if the call succeeded
    Data  map[string]interface{} `json:"data,omitempty"`  // Result payload
    Error string                 `json:"error,omitempty"` // Error message on failure
}

Set V to "1" in both request and response. Return errors in the Error field rather than as Go errors - a Go error signals a transport-level failure, while OK: false with an Error string signals an application-level failure.

Calling a Connector from a Plugin

From inside a plugin's HandleEvent or Start, use ConnectorAPI.Call:

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

resp, err := p.apis.ConnectorAPI.Call(ctx, "com.example.connectors.hello", &pluginrpc.ConnectorInvokeRequest{
    V:    "1",
    Data: map[string]interface{}{"action": "ping"},
})
if err != nil {
    // Transport-level failure (connector unreachable, timeout, etc.)
    return fmt.Errorf("connector call failed: %w", err)
}
if !resp.OK {
    // Application-level failure
    return fmt.Errorf("connector returned error: %s", resp.Error)
}
// Use resp.Data

Host API Reference

Plugins receive *pluginrpc.HostAPIs during Initialize. Connectors do not have access to host APIs.

LogAPI

Structured logging through the Aegis log pipeline.

MethodSignature
InfoInfo(message string, fields map[string]interface{})
WarnWarn(message string, fields map[string]interface{})
ErrorError(message string, err error, fields map[string]interface{})
DebugDebug(message string, fields map[string]interface{})

Use LogAPI instead of writing to stdout/stderr. Log output is captured, tagged with the plugin ID, and routed to the Aegis log viewer.

RconAPI

Send RCON commands and take moderation actions on the game server.

MethodSignature
SendCommandSendCommand(command string) (string, error)
BroadcastBroadcast(message string) error
SendWarningToPlayerSendWarningToPlayer(playerID, message string) error
KickPlayerKickPlayer(playerID, reason string) error
BanPlayerBanPlayer(playerID, reason string, duration time.Duration) error
BanWithEvidenceBanWithEvidence(playerID, reason string, duration time.Duration, eventID, eventType string) (string, error)
WarnPlayerWithRuleWarnPlayerWithRule(playerID, message string, ruleID *string) error
KickPlayerWithRuleKickPlayerWithRule(playerID, reason string, ruleID *string) error
BanPlayerWithRuleBanPlayerWithRule(playerID, reason string, duration time.Duration, ruleID *string) error
BanWithEvidenceAndRuleBanWithEvidenceAndRule(playerID, reason string, duration time.Duration, eventID, eventType string, ruleID *string) (string, error)
BanWithEvidenceAndRuleAndMetadataBanWithEvidenceAndRuleAndMetadata(playerID, reason string, duration time.Duration, eventID, eventType string, ruleID *string, metadata map[string]interface{}) (string, error)
RemovePlayerFromSquadRemovePlayerFromSquad(playerID string) error
RemovePlayerFromSquadByIdRemovePlayerFromSquadById(playerID string) error

The *WithRule variants link the action to a server rule for audit purposes. The *WithEvidence variants attach an originating event for traceability.

ServerAPI

Query server and player state.

MethodSignature
GetServerIDGetServerID() (string, error)
GetServerInfoGetServerInfo() (map[string]interface{}, error)
GetPlayersGetPlayers() ([]map[string]interface{}, error)
GetAdminsGetAdmins() ([]map[string]interface{}, error)
GetSquadsGetSquads() ([]map[string]interface{}, error)

DatabaseAPI

Plugin-scoped key-value storage that survives restarts.

MethodSignature
GetPluginDataGetPluginData(key string) (string, error)
SetPluginDataSetPluginData(key, value string) error
DeletePluginDataDeletePluginData(key string) error

Keys are scoped to the plugin instance. Values are strings - serialize complex data as JSON.

RuleAPI

Read server rules and their associated actions.

MethodSignature
ListServerRulesListServerRules(parentRuleID *string) ([]map[string]interface{}, error)
ListServerRuleActionsListServerRuleActions(ruleID string) ([]map[string]interface{}, error)

AdminAPI

Manage temporary admin privileges.

MethodSignature
AddTemporaryAdminAddTemporaryAdmin(playerID, roleName, notes string, expiresAt *time.Time) error
RemoveTemporaryAdminRemoveTemporaryAdmin(playerID, notes string) error
RemoveTemporaryAdminRoleRemoveTemporaryAdminRole(playerID, roleName, notes string) error
GetPlayerAdminStatusGetPlayerAdminStatus(playerID string) (map[string]interface{}, error)
ListTemporaryAdminsListTemporaryAdmins() ([]map[string]interface{}, error)

EventAPI

Publish custom events that other plugins can subscribe to.

MethodSignature
PublishEventPublishEvent(eventType string, data map[string]interface{}, raw string) error

To receive events, declare them in GetDefinition().Events. EventAPI is for publishing, not subscribing.

DiscordAPI

Send messages to Discord channels through the configured Discord connector.

MethodSignature
SendMessageSendMessage(channelID, content string) (string, error)
SendEmbedSendEmbed(channelID string, embed map[string]interface{}) (string, error)

ConnectorAPI

Call a registered connector.

MethodSignature
CallCall(ctx context.Context, connectorID string, req *ConnectorInvokeRequest) (*ConnectorInvokeResponse, error)

See Calling a Connector from a Plugin for usage.


Manifest and Packaging

Bundle Layout

Every bundle is a zip archive:

my-extension.zip
├── manifest.json
├── manifest.signed.json  # signed bundles only - see Bundle Signing
├── manifest.sig          # signed bundles only
├── manifest.pub          # signed bundles only
└── bin/
    ├── linux-amd64/my-extension
    └── linux-arm64/my-extension

The library_path in each manifest target points to the binary Aegis should execute (e.g., bin/linux-amd64/my-extension).

Plugin Manifest

{
  "plugin_id": "com.example.plugins.hello",
  "name": "Hello Plugin",
  "description": "Replies to a chat command with a private message.",
  "version": "0.1.0",
  "authors": [{"name": "Example Team", "contact": "[email protected]"}],
  "license": "MIT",
  "repository": "https://github.com/example/hello-plugin",
  "docs_url": "https://example.com/docs/hello-plugin",
  "official": false,
  "targets": [
    {
      "min_host_api_version": 1,
      "required_capabilities": ["api.rcon", "api.log", "events.rcon"],
      "target_os": "linux",
      "target_arch": "amd64",
      "sha256": "REPLACE_WITH_BINARY_SHA256",
      "library_path": "bin/linux-amd64/hello-plugin"
    }
  ]
}

Connector Manifest

{
  "connector_id": "com.example.connectors.hello",
  "name": "Hello Connector",
  "description": "Responds to ping requests.",
  "version": "0.1.0",
  "authors": [{"name": "Example Team", "contact": "[email protected]"}],
  "license": "MIT",
  "repository": "https://github.com/example/hello-connector",
  "docs_url": "https://example.com/docs/hello-connector",
  "official": false,
  "instance_key": "",
  "legacy_ids": [],
  "targets": [
    {
      "min_host_api_version": 1,
      "required_capabilities": [],
      "target_os": "linux",
      "target_arch": "amd64",
      "sha256": "REPLACE_WITH_BINARY_SHA256",
      "library_path": "bin/linux-amd64/hello-connector"
    }
  ]
}

Connector-specific fields:

  • instance_key - optional. Use when multiple connector IDs should resolve to the same underlying connector instance.
  • legacy_ids - optional. Only needed for migration compatibility when renaming a connector ID.

Manifest Rules

  • Use a stable reverse-DNS identifier: com.yourorg.plugins.name or com.yourorg.connectors.name.
  • sha256 must match the binary bytes exactly. The packaging scripts compute this automatically.
  • library_path points to an executable binary, not a shared object.
  • min_host_api_version should be 1 unless a future Aegis release increments the runtime contract.
  • The manifest ID must match the ID returned by GetDefinition(). A mismatch rejects the bundle.
  • Each target must have a unique combination of target_os, target_arch, min_host_api_version, and required_capabilities.

Capabilities

The host declares which capabilities it supports. Your manifest declares which capabilities your extension requires. At load time, Aegis rejects bundles that request capabilities the host does not provide.

The current capability strings (host API version 1):

entrypoint.get_aegis_plugin
api.rcon
api.server
api.database
api.rule
api.admin
api.discord
api.connector
api.event
api.log
events.rcon
events.log
events.system
events.connector
events.plugin

Common capability profiles:

Extension typeCapabilities
Chat moderatorapi.rcon, api.log, events.rcon
Discord relayapi.rcon, api.discord, events.rcon
Connector consumerapi.connector
System monitorapi.log, events.system
Player data pluginapi.rcon, api.server, api.database, events.rcon
Connector (typical)(none)

Declare only what you use. Unnecessary capabilities make your extension harder to install and create confusing install-time errors.


Building

Native extensions currently target Linux only.

Manual build:

GOOS=linux GOARCH=amd64 go build -o dist/my-plugin .

Using the packaging scripts (from this repository):

./scripts/package-example-native-plugin.sh
./scripts/package-example-native-connector.sh

Multiple targets:

TARGETS=linux/amd64,linux/arm64 ./scripts/package-example-native-plugin.sh
TARGETS=linux/amd64,linux/arm64 ./scripts/package-example-native-connector.sh

The packaging scripts build the binary, compute the SHA256 checksum, generate manifest.json, and create the zip archive.


Bundle Signing

For local development, unsigned bundles work if the host is configured with:

plugins:
  allow_unsafe_sideload: true

For shared or production environments, sign the bundle with an Ed25519 key.

This repository includes signing helpers:

KEY_ID=ops-key-2026-q1 ./scripts/sign-plugin-bundle.sh
KEY_ID=ops-key-2026-q1 ./scripts/sign-connector-bundle.sh

The signing tool expects a base64-encoded Ed25519 private key file plus two operator-supplied flags:

  • -key-id (required) - operator-chosen identifier for the signing key (for example ops-key-2026-q1). Recorded in the signed payload and matched against the host CRL when revoking a leaked key.
  • -valid-for (default 8760h / 1 year) - how long the signature should remain valid. Accepts any Go duration.

It produces three sibling files in the bundle:

  • manifest.signed.json - canonical wrapper carrying the manifest plus key_id, signed_at, and expires_at. This is the byte sequence the signature actually covers.
  • manifest.sig - Ed25519 signature over manifest.signed.json.
  • manifest.pub - the corresponding public key.

manifest.json is left untouched in the bundle for human inspection. At verification time Aegis canonicalizes both manifest.signed.json and manifest.json and rejects the bundle when they disagree, so a tampered display manifest cannot lie to operators.

Trust configuration: The public key must be listed in plugins.trusted_signing_keys on the Aegis host. A valid signature from an unknown key is rejected.

Verification rules. A bundle is signature_verified=true only when:

  1. manifest.pub is in the trust store.
  2. The signature checks out over the canonical manifest.signed.json bytes.
  3. manifest.signed.json.manifest matches manifest.json.
  4. now < expires_at + plugins.signature_clock_skew_seconds.
  5. key_id is not in the host CRL.

When any of these fails, signature_verified flips to false and the host applies the same allow_unsafe_sideload gate it would for an unsigned bundle.

Key Rotation and Revocation

Operators rotate signing keys by issuing new bundles signed under a fresh key_id and adding the new public key to plugins.trusted_signing_keys. The old key can be removed from the trust list once dependent bundles have been re-signed; existing installs that no longer re-verify are quarantined to error state at the next host start.

To revoke a leaked private key without a code release, point the host at a CRL file:

plugins:
  revoked_key_ids_path: /etc/squad-aegis/revoked-key-ids.json
  revoked_key_ids_refresh_seconds: 300

The file format is a flat JSON list:

{
  "revoked_key_ids": [
    "ops-key-2025-q4",
    "ops-key-2026-q1-leaked"
  ]
}

Aegis reads the file at start and re-reads it on the configured interval (minimum 5 seconds, default 300). Any installed plugin or connector whose key_id appears in the list is quarantined the next time loadInstalledPluginPackages/loadInstalledConnectorPackages runs - either at host start or after a fresh upload of the revocation list. Operators must re-sign and re-upload affected bundles under a new key_id to restore service.


Upload and Enable

Plugins:

  1. Upload the bundle at /sudo/plugins.
  2. Wait for the package status to reach ready.
  3. Open the target server's plugins page.
  4. Add the plugin to the server and fill in its config.

Connectors:

  1. Open /connectors.
  2. Upload the connector bundle.
  3. Create or update the connector instance and configure it.

If the UI reports pending restart, restart Aegis before continuing.


  1. Copy the closest example from examples/.
  2. Replace the IDs, config schema, and business logic.
  3. Build a single linux/amd64 target.
  4. Package it unsigned and upload to a local Aegis instance with allow_unsafe_sideload: true.
  5. Verify the full flow end-to-end: events fire, API calls work, config renders in the UI.
  6. Add additional targets and sign the bundle for production.

Troubleshooting

SymptomCause and fix
plugin_id mismatch or connector_id mismatchThe manifest ID does not match the ID returned by GetDefinition(). Make them identical.
checksum mismatchThe sha256 in the manifest does not match the binary bytes. Rebuild and repackage.
No matching target for the hostThe bundle does not contain the current Linux architecture, or min_host_api_version is too high.
Unsupported capabilitiesThe bundle declares capabilities this Aegis build does not expose. Remove unused capabilities from the manifest.
Signed bundle rejectedThe public key in manifest.pub is not listed in plugins.trusted_signing_keys. Add it to the host config.
Plugin never sees eventsThe event type is not listed in GetDefinition().Events, or the manifest is missing the corresponding events.* capability.
Plugin blocks the serverHost API calls are synchronous RPC. Move long-running work to goroutines, not inline in HandleEvent.
Connector calls time outKeep Invoke small and deterministic. Use explicit timeouts and return structured errors in the response envelope.
Plugin status stuck at startingInitialize or Start is blocking. These methods should return promptly.
Config changes not appliedUpdateConfig must replace the stored config. If you use a mutex, ensure the lock is released before returning.

Last updated on