Skip to content

Plugin Development Guide

This guide covers how to develop custom plugins for Shield CLI to support new protocols or service types.

Concept

A Shield plugin is an independent executable with a simple responsibility:

  1. Receive startup configuration from stdin (JSON)
  2. Start a local Web service
  3. Return ready status and Web port to stdout
  4. Keep running until a stop signal is received

Shield handles everything else:

  • Starting and stopping the plugin process
  • Exposing the plugin's Web port via encrypted tunnel
  • Providing a unified CLI experience

Communication Protocol

Shield and plugins communicate via stdin/stdout single-line JSON. There are only three message types.

1. Start Request (stdin)

After launching the plugin, Shield sends a single JSON line via stdin:

json
{"action":"start","config":{"host":"127.0.0.1","port":3306,"user":"root","pass":"xxx","database":"mydb"}}

Fields:

FieldTypeDescription
actionstringAlways "start"
config.hoststringTarget service IP
config.portintTarget service port
config.userstringUsername (may be empty)
config.passstringPassword (may be empty)
config.databasestringDatabase name (may be empty)

2. Ready Response (stdout)

When ready, the plugin writes a single JSON line to stdout:

Success:

json
{"status":"ready","web_port":19876,"name":"My Plugin","version":"0.1.0"}

Failure:

json
{"status":"error","message":"cannot connect to service: connection refused"}
FieldTypeDescription
statusstring"ready" or "error"
web_portintPlugin Web service port (required when status=ready)
namestringPlugin display name
versionstringPlugin version
messagestringError message (when status=error)

3. Stop Request (stdin)

When Shield exits, it sends via stdin:

json
{"action":"stop"}

The plugin should exit gracefully. If it doesn't exit within 5 seconds, Shield will force-kill the process.

Timeout

Shield waits up to 15 seconds for the ready response. If the plugin doesn't respond, it will be terminated.

Project Template

Here's a complete plugin project structure:

shield-plugin-example/
├── main.go              # Entry point, reads stdin, starts web server
├── handler.go           # HTTP handlers (business logic)
├── static/
│   └── index.html       # Web UI (embedded via embed.FS)
├── go.mod
├── go.sum
├── Makefile
└── .goreleaser.yml      # Multi-platform release config

main.go Template

go
package main

import (
    "embed"
    "encoding/json"
    "io/fs"
    "net"
    "net/http"
    "os"
    "os/signal"
    "syscall"
)

//go:embed static/*
var staticFS embed.FS

type StartRequest struct {
    Action string       `json:"action"`
    Config PluginConfig `json:"config,omitempty"`
}

type PluginConfig struct {
    Host     string `json:"host"`
    Port     int    `json:"port"`
    User     string `json:"user,omitempty"`
    Pass     string `json:"pass,omitempty"`
    Database string `json:"database,omitempty"`
}

type StartResponse struct {
    Status  string `json:"status"`
    WebPort int    `json:"web_port,omitempty"`
    Name    string `json:"name,omitempty"`
    Version string `json:"version,omitempty"`
    Message string `json:"message,omitempty"`
}

func main() {
    decoder := json.NewDecoder(os.Stdin)

    for {
        var req StartRequest
        if err := decoder.Decode(&req); err != nil {
            return // stdin closed
        }

        switch req.Action {
        case "start":
            handleStart(req.Config)
        case "stop":
            os.Exit(0)
        }
    }
}

func respond(resp StartResponse) {
    json.NewEncoder(os.Stdout).Encode(resp)
}

func handleStart(cfg PluginConfig) {
    // 1. Connect to target service and verify availability
    // conn, err := connectToService(cfg)
    // if err != nil {
    //     respond(StartResponse{Status: "error", Message: err.Error()})
    //     return
    // }

    // 2. Find an available port
    listener, err := net.Listen("tcp", "127.0.0.1:0")
    if err != nil {
        respond(StartResponse{Status: "error", Message: err.Error()})
        return
    }
    webPort := listener.Addr().(*net.TCPAddr).Port

    // 3. Setup HTTP routes
    mux := http.NewServeMux()
    // mux.HandleFunc("/api/...", yourHandler)
    staticSub, _ := fs.Sub(staticFS, "static")
    mux.Handle("/", http.FileServer(http.FS(staticSub)))

    // 4. Return ready response
    respond(StartResponse{
        Status:  "ready",
        WebPort: webPort,
        Name:    "Example Plugin",
        Version: "0.1.0",
    })

    // 5. Start HTTP server
    go http.Serve(listener, mux)

    // 6. Wait for stop signal
    sigCh := make(chan os.Signal, 1)
    signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
    <-sigCh
}

Key Points

  1. stdout is only for protocol messages — do not print logs to stdout, use stderr
  2. Auto-assign ports — use net.Listen("tcp", "127.0.0.1:0") for system-assigned ports
  3. embed.FS for static files — Web UI is packaged into the binary, no external files needed
  4. Connect before responding — verify the target service is reachable before returning ready

Registering a Plugin

After development, register the plugin in Shield's plugin/install.go:

go
var KnownPlugins = map[string]PluginInfo{
    "example": {
        Name:        "example",
        Source:      "your-org/shield-plugin-example",
        Protocols:   []string{"example"},
        DefaultPort: 9999,
    },
}

Or use local installation for testing:

bash
# Build the plugin
go build -o shield-plugin-example .

# Install locally
shield plugin add example --from ./shield-plugin-example

# Test
shield example 127.0.0.1:9999

Publishing a Plugin

GoReleaser Configuration

yaml
# .goreleaser.yml
project_name: shield-plugin-example

builds:
  - env:
      - CGO_ENABLED=0
    goos:
      - linux
      - darwin
      - windows
    goarch:
      - amd64
      - arm64
    binary: shield-plugin-example

archives:
  - format: tar.gz
    name_template: "shield-plugin-example_{{ .Os }}_{{ .Arch }}"
    format_overrides:
      - goos: windows
        format: zip

Release Workflow

bash
# Tag
git tag v0.1.0
git push origin v0.1.0

# GoReleaser builds and publishes to GitHub Releases
goreleaser release

Naming Convention

ItemConvention
Repositoryshield-plugin-<name>
Binaryshield-plugin-<name>
Release assetshield-plugin-<name>_<os>_<arch>.tar.gz

Shield downloads the platform-specific binary from GitHub Releases using this naming convention.

Testing a Plugin

Manual Protocol Testing

Test the stdin/stdout protocol directly without Shield:

bash
echo '{"action":"start","config":{"host":"127.0.0.1","port":3306,"user":"root","pass":"root"}}' | ./shield-plugin-mysql
# Expected: {"status":"ready","web_port":xxxxx,"name":"MySQL Web Client","version":"0.1.0"}

End-to-End Testing

bash
# 1. Install locally
shield plugin add mysql --from ./shield-plugin-mysql

# 2. Start (connect to local MySQL)
shield mysql 127.0.0.1:3306 --db-user root --db-pass root

# 3. Verify the Web interface is accessible
# 4. Verify it's also accessible via the public URL

Web UI Development Tips

For Shield plugin Web interfaces, a pure frontend approach (no framework dependencies) works best:

  • HTML + Vanilla JS — lightest weight, suitable for simple interfaces
  • Single HTML file — all CSS/JS inline, embedded via embed.FS
  • Avoid npm build toolchains — adds complexity with limited benefit for plugins

Design Principles

  1. Lightweight — single HTML file, under 50KB gzipped
  2. Responsive — support narrow viewports (may open in embedded browsers)
  3. Safe defaults — read-only mode by default, write operations require manual unlock
  4. Clear feedback — loading states, success/failure notifications for all operations

Reference Implementation

  • shield-plugin-mysql — in-repo implementation at plugins/mysql/, serves as a reference for developing new plugins

Next Steps