circuit

package module
v0.0.0-...-fcd971c Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Feb 25, 2026 License: MIT Imports: 14 Imported by: 0

README

circuit

Go Report Card

Circuit is a runtime control surface for Go processes: modify configuration, trigger actions, see changes live. No DB, no infra, no magic.

Stop SSH'ing into servers. Change config and trigger actions safely, in-process, from a simple web interface.

Circuit does not manage users, orchestrate multiple services, or act as a distributed system.


Try the demo (username: admin, password: admin)


The Pitch

You have a Go service running somewhere. You want to tweak a log level, flip a feature flag, or update a rate limit.

Usually, you have two bad options:

  1. The "YOLO" approach: SSH in, vim config.yaml, systemctl restart. Hope it comes back up.
  2. The "Enterprise" approach: Build a full admin API, a React frontend, set up auth, deploy a separate service... and now you have two problems.

Circuit is the third option. It lives inside your binary. It reads your existing config struct. It serves a tiny, safe web UI to control it.

type Config struct {
    Workers  int    `yaml:"workers" circuit:"type:number,min:1,max:100"`
    LogLevel string `yaml:"log_level" circuit:"type:select,options:debug|info|warn|error"`
}

func main() {
    var cfg Config

    ui, _ := circuit.From(&cfg,
        circuit.WithPath("config.yaml"),
        circuit.WithAuth(circuit.NewBasicAuth("admin", "secret")),
        circuit.WithOnChange(func(e circuit.ChangeEvent) {
            pool.Resize(cfg.Workers)       // Apply the change
            logger.SetLevel(cfg.LogLevel)  // Your code, your rules
        }),
    )

    http.ListenAndServe(":9090", ui)
}

Now open http://localhost:9090. Change values. Hit Save. Your app reacts instantly.

It also lets you trigger safe, application-defined actions like restarting a worker or flushing caches.

Why Circuit?

  • In-Process: No sidecars, no agents, no external databases. It's just a library.
  • Minimal: Zero dependencies. No npm, no webpack, no build steps. Just Go.
  • Safe: It validates input based on your struct types. No more typos crashing production.
  • Live: Changes persist to disk and trigger callbacks instantly.

Common Use Cases

Feature flags: Toggle features without redeploying

EnableBeta bool `circuit:"type:checkbox,help:Enable beta features"`

Rate limits: Adjust throttling on the fly

RequestsPerSec int `circuit:"type:number,min:1,max:10000,help:Max requests/sec"`

Worker pools: Scale background workers dynamically

Workers int `circuit:"type:number,min:1,max:100,help:Worker pool size"`

Log levels: Debug production without rebuilding

LogLevel string `circuit:"type:select,options:debug=Debug;info=Info;warn=Warning;error=Error"`

Maintenance mode: Flip a switch to return 503

Maintenance bool `circuit:"type:checkbox,help:Enable maintenance mode"`

Authentication

Three auth modes. Pick what fits your setup.

No auth (local dev or behind a trusted proxy):

ui, _ := circuit.From(&cfg, circuit.WithPath("config.yaml"))

Basic Auth (simple username/password):

// Dev: plaintext
auth := circuit.NewBasicAuth("admin", "secret")

// Production: argon2id hash
auth := circuit.NewBasicAuth("admin", "$argon2id$v=19$m=65536,t=3,p=4$...")

ui, _ := circuit.From(&cfg, circuit.WithAuth(auth))

Forward Auth (OAuth2 Proxy, Traefik, Cloudflare Access):

auth := circuit.NewForwardAuth("X-Forwarded-User", nil)
ui, _ := circuit.From(&cfg, circuit.WithAuth(auth))

Your reverse proxy handles OAuth. Circuit reads the headers.

Quick Start

go get github.com/moq77111113/circuit

1. Tag your config struct

type Config struct {
    Port     int    `yaml:"port" circuit:"type:number,min:1,max:65535,required"`
    LogLevel string `yaml:"log_level" circuit:"type:select,options:debug|info|error"`
    FeatureX bool   `yaml:"feature_x" circuit:"type:checkbox,help:Enable experimental feature"`
}

2. Wire it up

func main() {
    var cfg Config

    ui, _ := circuit.From(&cfg,
        circuit.WithPath("config.yaml"),
        circuit.WithAuth(circuit.NewBasicAuth("admin", "secret")),
        circuit.WithOnChange(func(e circuit.ChangeEvent) {
            // Apply changes to your running app
            logger.SetLevel(cfg.LogLevel)
            server.UpdatePort(cfg.Port)
        }),
    )

    http.ListenAndServe(":9090", ui)
}

3. Done

Open http://localhost:9090. Change values. Hit Save. Your app reacts.

File changes (manual edits to config.yaml) also trigger the callback automatically.

Options

Pass options to From(cfg, options...) to customize behavior:

Option What it does
WithPath(path) Required. Config file path (YAML/JSON/TOML auto-detected)
WithAuth(auth) Enable authentication (Basic or Forward Auth)
WithOnChange(fn) Callback fired after config changes (apply updates here)
WithOnError(fn) Callback for file watch or reload errors
WithTitle(title) Custom page title (default: "Configuration")
WithReadOnly(true) View-only mode (no edits allowed)
WithAutoWatch(false) Disable file watching (manual reload only)
WithAutoApply(false) Preview mode: call handler.Apply() to confirm changes
WithAutoSave(false) Manual save: call handler.Save() to persist
WithSaveFunc(fn) Custom persistence (database, S3, etc.)
WithActions(...) Add action buttons (see below)
WithBrand(false) Hide Circuit footer

Preview mode example (manual apply):

h, _ := circuit.From(&cfg, circuit.WithAutoApply(false))

http.HandleFunc("/config", func(w http.ResponseWriter, r *http.Request) {
    if r.Method == "POST" && r.FormValue("confirm") == "yes" {
        r.ParseForm()
        h.Apply(r.Form) // Confirm the preview
    }
    h.ServeHTTP(w, r)
})

Actions

Add buttons to trigger server-side operations: restart workers, flush caches, run migrations.

// Safe operation - no confirmation needed
flush := circuit.NewAction("flush", "Flush Cache", func(ctx context.Context) error {
    cache.Clear()
    return nil
}).Describe("Clears all cached data")

// Destructive operation - requires confirmation
restart := circuit.NewAction("restart", "Restart Worker", func(ctx context.Context) error {
    return worker.Restart(ctx)
}).Describe("Safely restarts the background worker").Confirm().WithTimeout(10 * time.Second)

ui, _ := circuit.From(&cfg,
    circuit.WithPath("config.yaml"),
    circuit.WithActions(flush, restart),
)

Action methods:

  • .Describe(text) – Help text shown in the UI
  • .Confirm() – Require confirmation dialog (use for destructive ops)
  • .WithTimeout(duration) – Execution timeout (default: 30s)

Actions run server-side with context cancellation. Failures are displayed in the UI.

Struct Tag Reference

Circuit reads circuit tags to generate form fields:

type Config struct {
    // Text input with validation
    Name string `circuit:"type:text,help:Service name,required,minlen:2,maxlen:50,pattern:^[a-z]+$"`

    // Number with range
    Port int `circuit:"type:number,min:1,max:65535,required"`

    // Select dropdown
    LogLevel string `circuit:"type:select,options:debug=Debug;info=Info;error=Error"`

    // Checkbox
    Enabled bool `circuit:"type:checkbox,help:Enable this feature"`

    // Hidden field (not shown in UI)
    Secret string `circuit:"-"`
}

Input types: text, number, checkbox, select, password, email, url, date, time, color

Attributes: help, min, max, step, minlen, maxlen, pattern, options, required, readonly

Hide fields: Use circuit:"-" to exclude sensitive data like API keys.

What Circuit Doesn't Do

Circuit is a single-process control panel. It's not:

  • A service mesh or orchestrator
  • A user management system
  • A distributed config store
  • A shell command executor

If you need multi-service coordination, use Kubernetes ConfigMaps or Consul. Circuit is for the 90% case: one binary, one config file, quick edits.

Security Notes

Use HTTPS in production. Basic Auth sends credentials in base64. Over HTTP, they're trivial to intercept.

Protect the endpoint. Don't expose Circuit on 0.0.0.0:80 without auth. Options:

  • Enable WithAuth() with Basic or Forward Auth
  • Put it behind a reverse proxy (Traefik, Caddy, nginx)
  • Bind to 127.0.0.1 and access via SSH tunnel or VPN
  • Run on a separate admin port and firewall it

Use argon2id for passwords. Never use plaintext passwords in production:

// Bad: plaintext password
auth := circuit.NewBasicAuth("admin", "secret")

// Good: argon2id hash
auth := circuit.NewBasicAuth("admin", "$argon2id$v=19$m=65536,t=3,p=4$...")

Generate hashes with golang.org/x/crypto/argon2.

Contributing

PRs welcome. Keep it minimal. Write tests. No "service" or "manager" files.

License

MIT. Use responsibly.

Documentation

Overview

Package circuit provides an embeddable HTTP UI for viewing and editing application configuration structs with automatic file persistence.

What Circuit Does

Circuit generates a web UI from a Go config struct and provides:

  • Automatic form generation from struct tags
  • File-backed persistence (YAML, JSON, TOML supported)
  • Automatic file watching and in-memory reload
  • Optional authentication (Basic Auth, Forward Auth)
  • Optional executable actions (restart, reload, flush, etc.)

What Circuit Doesn't Do

Circuit does not automatically restart your application or apply changes to running components. Your application is responsible for:

  • Restarting services when config changes
  • Applying new config values to active components
  • Validating config changes before applying them

Use the WithOnChange callback to hook into config updates and trigger your application's reload logic.

Basic Usage

The typical workflow is:

  1. Define a config struct with yaml and circuit tags
  2. Load config from disk (your responsibility)
  3. Create handler via circuit.From(&cfg, options...)
  4. Mount handler on your existing http.ServeMux

Example (minimal embed with net/http):

package main

import (
    "log"
    "net/http"
    "github.com/moq77111113/circuit"
)

type Config struct {
    Host string `yaml:"host" circuit:"type:text,help:Server hostname"`
    Port int    `yaml:"port" circuit:"type:number,help:Server port,required,min:1,max:65535"`
    TLS  bool   `yaml:"tls" circuit:"type:checkbox,help:Enable TLS"`
}

func main() {
    var cfg Config

    // Create handler - Circuit will load initial config from file
    handler, err := circuit.From(&cfg,
        circuit.WithPath("config.yaml"),
        circuit.WithTitle("My App Settings"),
    )
    if err != nil {
        log.Fatal(err)
    }

    // Mount on existing mux
    mux := http.NewServeMux()
    mux.Handle("/config", handler)

    log.Println("Circuit UI available at http://localhost:8080/config")
    http.ListenAndServe(":8080", mux)
}

Struct Tags

Circuit uses the circuit struct tag to configure form fields. Use circuit:"-" to hide sensitive fields like passwords or API keys.

Tag format: circuit:"type:INPUT_TYPE,ATTRIBUTE:VALUE,FLAG,..."

Supported input types:

  • text, password, email, url, tel
  • number, range
  • checkbox
  • select (requires options attribute)
  • date, time, color

Common attributes:

  • help:TEXT - help text shown below the field
  • min:N, max:N, step:N - numeric constraints
  • minlen:N, maxlen:N - string length constraints
  • pattern:REGEX - regex validation pattern
  • options:k1=v1;k2=v2 - select/radio options

Common flags:

  • required - field must not be empty
  • readonly - field cannot be edited

Example (struct tags):

type Config struct {
    // Text input with length constraints
    Name string `yaml:"name" circuit:"type:text,help:Service name,required,minlen:2,maxlen:50"`

    // Number input with range constraints
    Port int `yaml:"port" circuit:"type:number,help:Listen port,required,min:1,max:65535"`

    // Select input with options
    LogLevel string `yaml:"log_level" circuit:"type:select,options:debug=Debug;info=Info;warn=Warning;error=Error"`

    // Hidden field - Circuit will ignore this
    APIKey string `yaml:"api_key" circuit:"-"`
}

Security

Circuit UIs should be protected. Editing config can be dangerous - protect the endpoint with authentication or place it behind a reverse proxy.

Use WithAuth() to enable authentication:

// Development: plaintext password (DO NOT use in production)
auth := circuit.NewBasicAuth("admin", "dev-password")

// Production: argon2id hash
auth := circuit.NewBasicAuth("admin", "$argon2id$v=19$m=65536,t=3,p=4$...")

handler, _ := circuit.From(&cfg,
    circuit.WithPath("config.yaml"),
    circuit.WithAuth(auth),
)

For reverse proxy setups (OAuth2 Proxy, Traefik ForwardAuth, Cloudflare Access):

auth := circuit.NewForwardAuth("X-Forwarded-User", map[string]string{
    "email": "X-Forwarded-Email",
})

Actions

Actions enable operators to trigger safe, application-defined operations like restarting workers or flushing caches.

Actions are registered via WithActions() and appear as buttons in the UI:

restart := circuit.NewAction("restart_worker", "Restart Worker", func(ctx context.Context) error {
    return worker.Restart(ctx)
}).Describe("Safely restarts the background worker").Confirm().WithTimeout(10 * time.Second)

flush := circuit.NewAction("flush_cache", "Flush Cache", func(ctx context.Context) error {
    cache.Clear()
    return nil
}).Describe("Clears all cached data")

h, _ := circuit.From(&cfg, circuit.WithActions(restart, flush))

Safety notes for actions:

  • Actions run server-side code - ensure they are safe by default
  • Use .Confirm() for destructive operations (restarts, deletions)
  • Always use timeouts - default is 30 seconds
  • Avoid shelling out unless necessary (prefer native Go APIs)

File Watching and Hot Reload

Circuit automatically watches the config file and reloads the in-memory struct when changes are detected. Use WithOnChange to be notified:

handler, _ := circuit.From(&cfg,
    circuit.WithPath("config.yaml"),
    circuit.WithOnChange(func(e circuit.ChangeEvent) {
        log.Printf("config reloaded from %s", e.Source)
        // Your responsibility: apply new config to running components
        server.ApplyConfig(cfg)
    }),
)

Change sources:

  • SourceFormSubmit - user submitted the form
  • SourceFileChange - file changed on disk
  • SourceManual - handler.Apply() was called directly

Disable file watching with WithAutoWatch(false) if you want manual reload only.

Index

Examples

Constants

View Source
const (
	// SourceFormSubmit indicates the change came from a user submitting the web form.
	SourceFormSubmit = events.SourceFormSubmit

	// SourceFileChange indicates the change came from the file watcher detecting
	// an external modification to the config file.
	SourceFileChange = events.SourceFileChange

	// SourceManual indicates the change came from calling handler.Apply() directly.
	SourceManual = events.SourceManual
)

Variables

This section is empty.

Functions

This section is empty.

Types

type Action

type Action struct {
	Name                string
	Label               string
	Description         string
	Run                 func(context.Context) error
	Timeout             time.Duration
	RequireConfirmation bool
}

Action defines an executable server-side operation displayed as a button in the Circuit UI.

Actions enable operators to trigger safe, application-defined operations like:

  • Restarting workers or background jobs
  • Flushing caches or clearing queues
  • Running maintenance tasks
  • Triggering manual sync operations

SAFETY REQUIREMENTS:

  • Actions run server-side code - ensure they are safe by default
  • Use .Confirm() for destructive operations (restarts, deletions, flushes)
  • Always use timeouts to prevent hanging (default: 30s, can be customized)
  • Avoid shelling out unless necessary - prefer native Go APIs
  • Never use actions for privileged operations (system updates, user management)
  • Validate inputs if actions accept parameters

Actions are registered via WithActions() and appear in the Actions section of the UI.

Example (creating actions with constructor):

// Safe action - no confirmation needed
flush := circuit.NewAction("flush_cache", "Flush Cache", func(ctx context.Context) error {
    cache.Clear()
    return nil
}).Describe("Clears all cached data")

// Destructive action - requires confirmation
restart := circuit.NewAction("restart_worker", "Restart Worker", func(ctx context.Context) error {
    return worker.Restart(ctx)
}).Describe("Safely restarts the background worker").Confirm().WithTimeout(10 * time.Second)

h, _ := circuit.From(&cfg, circuit.WithActions(flush, restart))

func NewAction

func NewAction(name, label string, run func(context.Context) error) Action

NewAction creates a new action with required fields.

Parameters:

  • name: Unique identifier (used in URLs and logs)
  • label: Display name shown on the button
  • run: Function to execute (receives context for cancellation/timeout)

The run function MUST respect the provided context:

  • Check ctx.Done() for long-running operations
  • Return when context is cancelled
  • Use context-aware APIs (http.NewRequestWithContext, db.QueryContext, etc.)

Default timeout is 30 seconds. Use .WithTimeout() for longer operations.

Optional configuration via fluent builder methods:

action := circuit.NewAction("restart", "Restart Worker", runFunc).
    Describe("Safely restarts the worker").
    Confirm().
    WithTimeout(10 * time.Second)

func (Action) Confirm

func (a Action) Confirm() Action

Confirm enables a confirmation dialog before execution.

Use for destructive or irreversible operations:

  • Restarting services or workers
  • Deleting data or clearing caches
  • Flushing queues or buffers
  • Triggering expensive operations

When enabled, the operator must explicitly click "Confirm" before the action runs. This helps prevent accidental execution of dangerous operations.

Example:

circuit.NewAction("restart", "Restart Service", restartFunc).Confirm()

func (Action) Describe

func (a Action) Describe(desc string) Action

Describe sets the action description shown in the UI. Helps operators understand what the action does before triggering it.

func (Action) WithTimeout

func (a Action) WithTimeout(d time.Duration) Action

WithTimeout sets custom execution timeout. Default is 30 seconds.

Use for long-running operations that need more time:

  • Database migrations or large queries
  • Cache warming operations
  • Batch processing tasks
  • External API calls with slow response times

The timeout is enforced via context cancellation. Your action's run function MUST respect the context and return promptly when ctx.Done() is signaled.

Example:

circuit.NewAction("migrate", "Run Migration", migrateFunc).WithTimeout(5 * time.Minute)

type Authenticator

type Authenticator interface {
	Authenticate(r *http.Request) (*auth.Identity, error)
}

Authenticator validates HTTP requests and returns identity information.

Implementations must be safe for concurrent use.

The Authenticate method is called on EVERY request to the Circuit handler. Return an error to reject the request with 401 Unauthorized.

Built-in implementations:

  • BasicAuth - HTTP Basic Authentication
  • ForwardAuth - Reverse proxy header authentication

type BasicAuth

type BasicAuth struct {
	Username string
	Password string // plaintext or argon2id PHC hash
}

BasicAuth implements HTTP Basic Authentication with support for plaintext and argon2id hashed passwords.

IMPORTANT: For production use, always use argon2id password hashes, not plaintext. Plaintext passwords should ONLY be used in development/testing environments.

Argon2id hashes are automatically detected by the "$argon2id$v=19$" prefix. Circuit supports the PHC string format output by golang.org/x/crypto/argon2.

Operational security recommendations:

  • Store credentials in a separate file (e.g., /etc/myapp/auth.conf)
  • Set file permissions to 0640 or stricter (readable only by app user)
  • Never store credentials in your config struct (they would be editable via Circuit UI)
  • Use environment variables or secret management systems in production

func NewBasicAuth

func NewBasicAuth(username, password string) *BasicAuth

NewBasicAuth creates an authenticator that validates via HTTP Basic Auth.

The password can be:

  • Plaintext (for dev/testing ONLY) - never use in production
  • Argon2id PHC hash (for production) - auto-detected by "$argon2id$v=19$" prefix

Circuit supports argon2id hashes in PHC string format as output by the golang.org/x/crypto/argon2 package. The hash format is:

$argon2id$v=19$m=MEMORY,t=TIME,p=PARALLELISM$SALT$HASH

Example with plaintext (DEVELOPMENT ONLY):

auth := circuit.NewBasicAuth("admin", "dev-password")
ui, _ := circuit.From(&cfg, circuit.WithAuth(auth))

Example with argon2id hash (PRODUCTION):

// Generate hash with: golang.org/x/crypto/argon2
// Example hash:
hash := "$argon2id$v=19$m=65536,t=3,p=4$c29tZXNhbHQ$..."
auth := circuit.NewBasicAuth("admin", hash)
ui, _ := circuit.From(&cfg, circuit.WithAuth(auth))

Operational security:

  • Never store credentials in your app's config struct
  • Store credentials in /etc/myapp/auth.conf with 0640 permissions
  • Use a secret management system (Vault, AWS Secrets Manager, etc.)
  • Rotate passwords regularly

func (*BasicAuth) Authenticate

func (b *BasicAuth) Authenticate(r *http.Request) (*auth.Identity, error)

Authenticate validates Basic Auth credentials.

type ChangeEvent

type ChangeEvent = events.ChangeEvent

ChangeEvent describes a configuration change.

Delivered to OnChange callbacks after the in-memory config has been updated. Your application is responsible for applying the new config to running components.

type ForwardAuth

type ForwardAuth struct {
	SubjectHeader string
	ClaimHeaders  map[string]string
}

ForwardAuth implements authentication via reverse proxy headers. Common with OAuth2 Proxy, Traefik ForwardAuth, Cloudflare Access.

func NewForwardAuth

func NewForwardAuth(subjectHeader string, claimHeaders map[string]string) *ForwardAuth

NewForwardAuth creates an authenticator that validates via reverse proxy headers. Common with OAuth2 Proxy, Traefik ForwardAuth, Cloudflare Access.

The subjectHeader must contain the authenticated user identifier. Optional claimHeaders can map claim names to header names for metadata extraction.

Example:

auth := circuit.NewForwardAuth("X-Forwarded-User", map[string]string{
    "email": "X-Forwarded-Email",
    "role": "X-Auth-Role",
})
ui, _ := circuit.From(&cfg, WithAuth(auth))
Example

ExampleNewForwardAuth demonstrates reverse proxy authentication. Common with OAuth2 Proxy, Traefik ForwardAuth, Cloudflare Access.

package main

import (
	"fmt"
	"log"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"

	"github.com/moq77111113/circuit"
)

func main() {
	type Config struct {
		Setting string `yaml:"setting" circuit:"type:text"`
	}

	tmpDir := os.TempDir()
	configPath := filepath.Join(tmpDir, "example_forward.yaml")
	_ = os.WriteFile(configPath, []byte("setting: value\n"), 0644)
	defer func() {
		if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) {
			log.Printf("failed to remove %s: %v", configPath, err)
		}
	}()

	var cfg Config

	// Configure forward auth to read headers from reverse proxy
	auth := circuit.NewForwardAuth("X-Forwarded-User", map[string]string{
		"email": "X-Forwarded-Email",
		"role":  "X-Auth-Role",
	})

	handler, err := circuit.From(&cfg,
		circuit.WithPath(configPath),
		circuit.WithAuth(auth),
	)
	if err != nil {
		log.Fatal(err)
	}

	server := httptest.NewServer(handler)
	defer server.Close()

	// Request with proxy headers - should succeed
	req, _ := http.NewRequest("GET", server.URL, nil)
	req.Header.Set("X-Forwarded-User", "user@example.com")
	req.Header.Set("X-Forwarded-Email", "user@example.com")
	resp, _ := http.DefaultClient.Do(req)
	fmt.Println("Status:", resp.StatusCode)

}
Output:

Status: 200

func (*ForwardAuth) Authenticate

func (f *ForwardAuth) Authenticate(r *http.Request) (*auth.Identity, error)

Authenticate validates the request via proxy headers.

type Handler

type Handler struct {
	// contains filtered or unexported fields
}

Handler serves the Circuit UI and provides manual control methods.

The handler implements http.Handler and can be mounted on any mux.

Manual control methods are available for advanced workflows:

  • Apply(formData) - manually apply form changes (preview mode with WithAutoApply(false))
  • Save() - manually persist to disk (manual save with WithAutoSave(false))

Example (preview mode):

h, _ := circuit.From(&cfg, circuit.WithAutoApply(false))

http.HandleFunc("/admin", func(w http.ResponseWriter, r *http.Request) {
    if r.Method == "POST" && r.FormValue("confirm") == "yes" {
        r.ParseForm()
        if err := h.Apply(r.Form); err != nil {
            http.Error(w, err.Error(), 500)
            return
        }
    }
    h.ServeHTTP(w, r)
})

func From

func From(cfg any, opts ...Option) (*Handler, error)

From creates and returns a Handler that serves a small web UI for inspecting and editing a YAML-backed configuration value.

From validates that `cfg` is a pointer to a struct (used to extract schema information from struct tags), applies any provided Option values and then attempts to load the initial configuration from the path supplied via `WithPath`. If successful it starts a file watcher that reloads the configuration on changes and returns a handler wired to that loader.

The returned Handler implements http.Handler and exposes manual control methods:

  • Apply(formData) - manually apply form data (for preview mode)
  • Save() - manually save config to disk

Common errors:

  • when `cfg` is not a pointer
  • when no path is provided (use `WithPath`)
  • when schema extraction, initial load, or watcher setup fails
Example (Actions)

ExampleFrom_actions demonstrates executable actions. Actions appear as buttons in the UI and run server-side code.

package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"time"

	"github.com/moq77111113/circuit"
)

func main() {
	type Config struct {
		CacheSize int `yaml:"cache_size" circuit:"type:number"`
	}

	// Create temporary config file
	tmpDir := os.TempDir()
	configPath := filepath.Join(tmpDir, "example_actions.yaml")
	_ = os.WriteFile(configPath, []byte("cache_size: 100\n"), 0644)
	defer func() {
		if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) {
			log.Printf("failed to remove %s: %v", configPath, err)
		}
	}()

	var cfg Config

	// Define safe actions with timeouts
	// NOTE: This is a dummy action for demonstration
	flushCache := circuit.NewAction("flush_cache", "Flush Cache", func(ctx context.Context) error {
		// In real code, this would clear your cache
		time.Sleep(100 * time.Millisecond) // Simulate work
		return nil
	}).Describe("Clears all cached data")

	// Destructive actions should require confirmation
	restart := circuit.NewAction("restart_worker", "Restart Worker", func(ctx context.Context) error {
		// In real code, this would restart your worker
		time.Sleep(100 * time.Millisecond) // Simulate work
		return nil
	}).Describe("Safely restarts the background worker").Confirm().WithTimeout(10 * time.Second)

	handler, err := circuit.From(&cfg,
		circuit.WithPath(configPath),
		circuit.WithActions(flushCache, restart),
	)
	if err != nil {
		log.Fatal(err)
	}

	server := httptest.NewServer(handler)
	defer server.Close()

	resp, _ := http.Get(server.URL)
	fmt.Println("Status:", resp.StatusCode)

}
Output:

Status: 200
Example (Auth)

ExampleFrom_auth demonstrates Basic Auth integration. Shows both plaintext (dev) and argon2id (production) authentication.

package main

import (
	"fmt"
	"log"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"

	"github.com/moq77111113/circuit"
)

func main() {
	type Config struct {
		Host string `yaml:"host" circuit:"type:text"`
		Port int    `yaml:"port" circuit:"type:number"`
	}

	// Create temporary config file
	tmpDir := os.TempDir()
	configPath := filepath.Join(tmpDir, "example_auth.yaml")
	_ = os.WriteFile(configPath, []byte("host: localhost\nport: 8080\n"), 0644)
	defer func() {
		if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) {
			log.Printf("failed to remove %s: %v", configPath, err)
		}
	}()

	var cfg Config

	// Development: plaintext password (DO NOT use in production)
	// auth := circuit.NewBasicAuth("admin", "dev-password")

	// Production: argon2id PHC hash
	// Hash generated with: golang.org/x/crypto/argon2
	// This example uses a plaintext password for testing purposes
	auth := circuit.NewBasicAuth("admin", "secure-password")

	handler, err := circuit.From(&cfg,
		circuit.WithPath(configPath),
		circuit.WithAuth(auth),
	)
	if err != nil {
		log.Fatal(err)
	}

	server := httptest.NewServer(handler)
	defer server.Close()

	// Request without auth - should fail
	resp, _ := http.Get(server.URL)
	fmt.Println("Without auth:", resp.StatusCode)

	// Request with auth - should succeed
	req, _ := http.NewRequest("GET", server.URL, nil)
	req.SetBasicAuth("admin", "secure-password")
	resp, _ = http.DefaultClient.Do(req)
	fmt.Println("With auth:", resp.StatusCode)

}
Output:

Without auth: 401
With auth: 200
Example (Minimal)

ExampleFrom_minimal demonstrates the simplest Circuit integration. Creates a handler for a config struct and mounts it on a standard http.ServeMux.

package main

import (
	"fmt"
	"log"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"

	"github.com/moq77111113/circuit"
)

func main() {
	// Define config struct with circuit tags
	type Config struct {
		Host string `yaml:"host" circuit:"type:text,help:Server hostname"`
		Port int    `yaml:"port" circuit:"type:number,help:Server port,required,min:1,max:65535"`
		TLS  bool   `yaml:"tls" circuit:"type:checkbox,help:Enable TLS"`
	}

	// Create temporary config file
	tmpDir := os.TempDir()
	configPath := filepath.Join(tmpDir, "example_minimal.yaml")
	_ = os.WriteFile(configPath, []byte("host: localhost\nport: 8080\ntls: false\n"), 0644)
	defer func() {
		if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) {
			log.Printf("failed to remove %s: %v", configPath, err)
		}
	}()

	var cfg Config

	// Create Circuit handler
	handler, err := circuit.From(&cfg,
		circuit.WithPath(configPath),
		circuit.WithTitle("Example Config"),
	)
	if err != nil {
		log.Fatal(err)
	}

	// Mount on standard mux
	mux := http.NewServeMux()
	mux.Handle("/config", handler)

	// Test with httptest
	server := httptest.NewServer(mux)
	defer server.Close()

	resp, _ := http.Get(server.URL + "/config")
	fmt.Println("Status:", resp.StatusCode)

}
Output:

Status: 200
Example (OnChange)

ExampleFrom_onChange demonstrates config change notifications. Circuit notifies your app when config changes, allowing you to apply updates.

package main

import (
	"fmt"
	"log"
	"net/http/httptest"
	"os"
	"path/filepath"

	"github.com/moq77111113/circuit"
)

func main() {
	type Config struct {
		Workers int `yaml:"workers" circuit:"type:number"`
	}

	tmpDir := os.TempDir()
	configPath := filepath.Join(tmpDir, "example_onchange.yaml")
	_ = os.WriteFile(configPath, []byte("workers: 4\n"), 0644)
	defer func() {
		if err := os.Remove(configPath); err != nil && !os.IsNotExist(err) {
			log.Printf("failed to remove %s: %v", configPath, err)
		}
	}()

	var cfg Config

	handler, err := circuit.From(&cfg,
		circuit.WithPath(configPath),
		circuit.WithOnChange(func(e circuit.ChangeEvent) {
			fmt.Printf("Config changed from %s\n", e.Source)
			// Your responsibility: apply new config to running components
			// Example: workerPool.Resize(cfg.Workers)
		}),
	)
	if err != nil {
		log.Fatal(err)
	}

	server := httptest.NewServer(handler)
	defer server.Close()

	fmt.Println("Handler created")

}
Output:

Handler created

func (*Handler) Apply

func (h *Handler) Apply(formData url.Values) error

Apply manually applies form data to the in-memory config.

Used in preview mode (WithAutoApply(false)) to confirm changes after user review. The typical workflow:

  1. User submits form (POST)
  2. Circuit renders preview with submitted values (doesn't modify memory yet)
  3. User reviews and confirms
  4. Your code calls Apply(formData) to commit changes

Respects WithAutoSave setting: if enabled, changes are saved to disk after apply. If WithAutoSave(false), call Save() separately to persist.

func (*Handler) Save

func (h *Handler) Save() error

Save manually persists the current in-memory config to disk.

Used in manual save mode (WithAutoSave(false)) to control when persistence happens. Common use cases:

  • Batch multiple changes before saving
  • Add validation or approval workflows before persisting
  • Trigger saves on external events (timers, signals)

Uses custom SaveFunc if provided via WithSaveFunc, otherwise writes YAML to the path specified in WithPath.

func (*Handler) ServeHTTP

func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request)

ServeHTTP implements http.Handler.

type Identity

type Identity = auth.Identity

Identity represents an authenticated user.

type OnChange

type OnChange = events.OnChange

OnChange is called when configuration changes.

The callback is invoked AFTER the in-memory config struct has been updated. Use this to:

  • Apply new config to running services
  • Log config changes
  • Trigger restart of affected components
  • Validate config before applying

Example:

circuit.WithOnChange(func(e circuit.ChangeEvent) {
    log.Printf("config updated from %s", e.Source)
    server.ApplyConfig(cfg)
})

type Option

type Option func(*config)

Option configures behavior passed to `From`.

func WithActions

func WithActions(actions ...Action) Option

WithActions registers executable server-side actions in the UI.

Actions appear as buttons in the Actions section and allow operators to trigger safe, application-defined operations like restarting workers or flushing caches.

Each action is created with NewAction(name, label, run) and can be configured via:

  • .Describe(text) - add help text
  • .Confirm() - require user confirmation before execution
  • .WithTimeout(duration) - set execution timeout (default: 30s)

Example:

restart := circuit.NewAction("restart", "Restart Worker", func(ctx context.Context) error {
    return worker.Restart(ctx)
}).Describe("Safely restarts the worker").Confirm().WithTimeout(10 * time.Second)

circuit.WithActions(restart)

Safety notes:

  • Actions run server-side code - ensure they are safe by default
  • Use .Confirm() for destructive operations (restarts, deletions, flushes)
  • Always use timeouts to prevent hanging operations
  • Avoid shelling out unless necessary (prefer native Go APIs)
  • Action failures are displayed in the UI

func WithAuth

func WithAuth(a Authenticator) Option

WithAuth sets the authenticator for the Circuit UI.

Default: nil (no authentication - UI is publicly accessible).

IMPORTANT: Circuit UIs should always be protected. Editing config can be dangerous. Use authentication or place the handler behind a reverse proxy.

Built-in authenticators:

  • NewBasicAuth(username, password) - HTTP Basic Auth
  • NewForwardAuth(header, claims) - Reverse proxy headers

Example with Basic Auth:

auth := circuit.NewBasicAuth("admin", "$argon2id$v=19$...")
circuit.WithAuth(auth)

Example with Forward Auth (OAuth2 Proxy):

auth := circuit.NewForwardAuth("X-Forwarded-User", nil)
circuit.WithAuth(auth)

The authenticator is called on EVERY request to the handler.

func WithAutoApply

func WithAutoApply(enable bool) Option

WithAutoApply controls whether form submissions automatically update the in-memory config struct.

Default: true (auto-apply enabled).

When true: Form submission (POST) immediately updates the config struct in memory and saves to disk (if WithAutoSave(true)).

When false: Form submission renders a preview with the submitted values but does NOT modify the config in memory. You must call handler.Apply(formData) manually to confirm the changes.

Use preview mode (false) when you want to:

  • Review changes before applying them
  • Add custom validation before updates
  • Implement approval workflows

Example (preview mode):

h, _ := circuit.From(&cfg, circuit.WithAutoApply(false))
http.HandleFunc("/config", func(w http.ResponseWriter, r *http.Request) {
    if r.Method == "POST" && r.FormValue("confirm") == "yes" {
        r.ParseForm()
        h.Apply(r.Form) // Manually confirm
    }
    h.ServeHTTP(w, r)
})

func WithAutoSave

func WithAutoSave(enable bool) Option

WithAutoSave controls whether config changes are automatically persisted to disk.

Default: true (auto-save enabled).

When true: After updating the in-memory config, Circuit automatically writes the new config to the file specified in WithPath.

When false: The in-memory config is updated, but the file is NOT written. You must call handler.Save() manually to persist changes.

Use manual save (false) when you want to:

  • Batch multiple changes before writing to disk
  • Add custom validation before persistence
  • Control when disk I/O happens

Example (manual save):

h, _ := circuit.From(&cfg, circuit.WithAutoSave(false))
// Later, after validating changes:
if err := h.Save(); err != nil {
    log.Printf("failed to save config: %v", err)
}

func WithAutoWatch

func WithAutoWatch(enable bool) Option

WithAutoWatch controls whether file watching and automatic reload are enabled.

Default: true (file watching enabled).

When true: Circuit watches the config file for changes and automatically reloads the in-memory struct when the file is modified.

When false: File watching is disabled. Config is only reloaded when:

  • User submits the web form
  • handler.Apply() is called manually

Disable auto-watch if you want manual control over when config is reloaded, or if you're running in an environment without inotify support.

func WithBrand

func WithBrand(b bool) Option

WithBrand controls whether the Circuit footer/brand is shown in the UI.

Default: true (brand is shown).

Set to false to hide the "Powered by Circuit" footer:

circuit.WithBrand(false)

func WithOnChange

func WithOnChange(fn OnChange) Option

WithOnChange registers a callback for configuration change events.

The callback is invoked AFTER the in-memory config struct has been updated. Your application is responsible for applying the new config to running components.

The ChangeEvent indicates the source of the change:

  • SourceFormSubmit - user submitted the web form
  • SourceFileChange - file changed on disk (file watcher)
  • SourceManual - handler.Apply() was called directly

Example:

circuit.WithOnChange(func(e circuit.ChangeEvent) {
    log.Printf("config updated from %s", e.Source)
    server.ApplyConfig(cfg) // Your responsibility
})

func WithOnError

func WithOnError(fn func(error)) Option

WithOnError registers a callback for errors during file watching or reload.

Common error scenarios:

  • File watcher errors (permissions, inotify limits)
  • Config parse errors (invalid YAML/JSON/TOML)
  • File read errors (deleted file, network mount issues)

The callback is invoked for non-fatal errors. Fatal errors (initial load failure) are returned by From() directly.

Example:

circuit.WithOnError(func(err error) {
    log.Printf("config reload failed: %v", err)
})

func WithPath

func WithPath(path string) Option

WithPath sets the filesystem path to the configuration file.

This option is REQUIRED - From will return an error if no path is provided.

Supported formats are auto-detected by extension:

  • .yaml, .yml - YAML format
  • .json - JSON format
  • .toml - TOML format

Circuit will:

  1. Load the initial config from this file on startup
  2. Watch the file for changes (unless WithAutoWatch(false))
  3. Persist updates to this file (unless WithAutoSave(false))

If the file doesn't exist, From returns an error. Create the file first or use a custom SaveFunc to handle initialization.

func WithReadOnly

func WithReadOnly(enable bool) Option

WithReadOnly makes the UI read-only, preventing all edits.

Default: false (UI is editable).

When true:

  • All input fields are disabled
  • Save button is hidden
  • Add/Remove slice item buttons are hidden
  • Form submission is blocked

Use read-only mode when you want to:

  • Expose config for viewing without allowing edits
  • Provide a "status" page for operators
  • Show config in production environments where edits must go through CI/CD

Example:

circuit.WithReadOnly(true)

func WithSaveFunc

func WithSaveFunc(fn SaveFunc) Option

WithSaveFunc replaces the default file writing with custom persistence logic.

Default: Circuit writes to the file path using the detected format (YAML/JSON/TOML).

Use a custom SaveFunc when you need to:

  • Store config in a database or remote storage
  • Add encryption or compression before writing
  • Validate config before persisting
  • Notify external systems about config changes

The SaveFunc receives the current config value and path. It is called:

  • After form submission (if WithAutoSave(true))
  • When handler.Save() is called manually

Example (custom persistence):

circuit.WithSaveFunc(func(cfg any, path string) error {
    // Validate before saving
    if err := validateConfig(cfg); err != nil {
        return err
    }
    // Write to database instead of file
    return db.SaveConfig(cfg)
})

func WithTitle

func WithTitle(title string) Option

WithTitle sets the title displayed in the UI header.

If not provided, the UI displays "Configuration" as the default title.

Example:

circuit.WithTitle("Production Settings")

type SaveFunc

type SaveFunc func(cfg any, path string) error

SaveFunc is called to persist configuration changes. Receives the current config value and path, returns error if persistence fails.

type Source

type Source = events.Source

Source indicates where a configuration change originated.

Use the Source field in ChangeEvent to determine the origin of a config update.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL