feat: add Gateway HTTP server with Config API (Step 1)

Add pkg/gateway package implementing Config API endpoints:
- GET /api/config/schema — dynamic schema via reflection
- GET /api/config — config with secret masking
- PUT /api/config — save + restart trigger
- Bearer token auth middleware (skip when api_key empty)

Add GatewayConfig.APIKey field and RLock/RUnlock methods to Config.
Integrate gateway server into cmd/clawdroid with config-triggered
process restart via syscall.Exec.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kohei 2026-02-26 17:11:46 +09:00
parent 7e0856c907
commit 7e73df9752
9 changed files with 3144 additions and 5 deletions

View file

@ -18,6 +18,7 @@ import (
"path/filepath" "path/filepath"
"runtime" "runtime"
"strings" "strings"
"syscall"
"time" "time"
"github.com/KarakuriAgent/clawdroid/pkg/agent" "github.com/KarakuriAgent/clawdroid/pkg/agent"
@ -25,6 +26,7 @@ import (
"github.com/KarakuriAgent/clawdroid/pkg/channels" "github.com/KarakuriAgent/clawdroid/pkg/channels"
"github.com/KarakuriAgent/clawdroid/pkg/config" "github.com/KarakuriAgent/clawdroid/pkg/config"
"github.com/KarakuriAgent/clawdroid/pkg/cron" "github.com/KarakuriAgent/clawdroid/pkg/cron"
"github.com/KarakuriAgent/clawdroid/pkg/gateway"
"github.com/KarakuriAgent/clawdroid/pkg/heartbeat" "github.com/KarakuriAgent/clawdroid/pkg/heartbeat"
"github.com/KarakuriAgent/clawdroid/pkg/logger" "github.com/KarakuriAgent/clawdroid/pkg/logger"
"github.com/KarakuriAgent/clawdroid/pkg/providers" "github.com/KarakuriAgent/clawdroid/pkg/providers"
@ -442,6 +444,7 @@ func gatewayCmd() {
} }
} }
configPath := getConfigPath()
cfg, err := loadConfig() cfg, err := loadConfig()
if err != nil { if err != nil {
fmt.Printf("Error loading config: %v\n", err) fmt.Printf("Error loading config: %v\n", err)
@ -475,6 +478,22 @@ func gatewayCmd() {
"skills_available": skillsInfo["available"], "skills_available": skillsInfo["available"],
}) })
// Restart channel for config-triggered restarts
restartCh := make(chan struct{}, 1)
// Start Gateway HTTP server (Config API)
gwServer := gateway.NewServer(cfg, configPath, func() {
select {
case restartCh <- struct{}{}:
default:
}
})
if err := gwServer.Start(); err != nil {
fmt.Printf("Error starting gateway HTTP server: %v\n", err)
os.Exit(1)
}
fmt.Printf("✓ Config API started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
// Setup cron tool and service // Setup cron tool and service
cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.DataPath(), cfg.Agents.Defaults.RestrictToWorkspace, cfg.Tools.Exec.Enabled) cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.DataPath(), cfg.Agents.Defaults.RestrictToWorkspace, cfg.Tools.Exec.Enabled)
@ -544,15 +563,35 @@ func gatewayCmd() {
sigChan := make(chan os.Signal, 1) sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt) signal.Notify(sigChan, os.Interrupt)
<-sigChan
restart := false
select {
case <-sigChan:
case <-restartCh:
restart = true
fmt.Println("\nRestarting due to config change...")
}
// Graceful shutdown
fmt.Println("\nShutting down...") fmt.Println("\nShutting down...")
cancel() cancel()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
gwServer.Stop(shutdownCtx)
heartbeatService.Stop() heartbeatService.Stop()
cronService.Stop() cronService.Stop()
agentLoop.Stop() agentLoop.Stop()
channelManager.StopAll(ctx) channelManager.StopAll(shutdownCtx)
fmt.Println("✓ Gateway stopped") fmt.Println("✓ Gateway stopped")
if restart {
exe, err := os.Executable()
if err != nil {
fmt.Printf("Error finding executable: %v\n", err)
os.Exit(1)
}
syscall.Exec(exe, os.Args, os.Environ())
}
} }
func statusCmd() { func statusCmd() {

View file

@ -99,7 +99,8 @@
}, },
"gateway": { "gateway": {
"host": "127.0.0.1", "host": "127.0.0.1",
"port": 18790 "port": 18790,
"api_key": ""
}, },
"rate_limits": { "rate_limits": {
"max_tool_calls_per_minute": 30, "max_tool_calls_per_minute": 30,

View file

@ -138,8 +138,9 @@ type RateLimitsConfig struct {
} }
type GatewayConfig struct { type GatewayConfig struct {
Host string `json:"host" env:"CLAWDROID_GATEWAY_HOST"` Host string `json:"host" env:"CLAWDROID_GATEWAY_HOST"`
Port int `json:"port" env:"CLAWDROID_GATEWAY_PORT"` Port int `json:"port" env:"CLAWDROID_GATEWAY_PORT"`
APIKey string `json:"api_key" env:"CLAWDROID_GATEWAY_API_KEY"`
} }
type BraveConfig struct { type BraveConfig struct {
@ -323,6 +324,9 @@ func SaveConfig(path string, cfg *Config) error {
return os.WriteFile(path, data, 0600) return os.WriteFile(path, data, 0600)
} }
func (c *Config) RLock() { c.mu.RLock() }
func (c *Config) RUnlock() { c.mu.RUnlock() }
func (c *Config) WorkspacePath() string { func (c *Config) WorkspacePath() string {
c.mu.RLock() c.mu.RLock()
defer c.mu.RUnlock() defer c.mu.RUnlock()

View file

@ -185,6 +185,14 @@ func TestConfig_DataPath(t *testing.T) {
} }
} }
// TestDefaultConfig_GatewayAPIKey verifies Gateway APIKey is empty by default
func TestDefaultConfig_GatewayAPIKey(t *testing.T) {
cfg := DefaultConfig()
if cfg.Gateway.APIKey != "" {
t.Error("Gateway APIKey should be empty by default")
}
}
// TestConfig_Complete verifies all config fields are set // TestConfig_Complete verifies all config fields are set
func TestConfig_Complete(t *testing.T) { func TestConfig_Complete(t *testing.T) {
cfg := DefaultConfig() cfg := DefaultConfig()

45
pkg/gateway/auth.go Normal file
View file

@ -0,0 +1,45 @@
package gateway
import (
"encoding/json"
"net/http"
"strings"
)
// authMiddleware wraps a handler with Bearer token authentication.
// If cfg.Gateway.APIKey is empty, authentication is skipped.
func (s *Server) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
apiKey := s.cfg.Gateway.APIKey
if apiKey == "" {
next(w, r)
return
}
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
writeJSONError(w, http.StatusUnauthorized, "missing Authorization header")
return
}
const prefix = "Bearer "
if !strings.HasPrefix(authHeader, prefix) {
writeJSONError(w, http.StatusUnauthorized, "invalid Authorization format")
return
}
token := authHeader[len(prefix):]
if token != apiKey {
writeJSONError(w, http.StatusForbidden, "invalid token")
return
}
next(w, r)
}
}
func writeJSONError(w http.ResponseWriter, code int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{"error": message})
}

148
pkg/gateway/handlers.go Normal file
View file

@ -0,0 +1,148 @@
package gateway
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/KarakuriAgent/clawdroid/pkg/config"
)
// handleGetSchema returns the configuration schema.
func (s *Server) handleGetSchema(w http.ResponseWriter, r *http.Request) {
schema := BuildSchema(config.DefaultConfig())
writeJSON(w, http.StatusOK, schema)
}
// handleGetConfig returns the current configuration with secrets masked.
func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) {
s.cfg.RLock()
data, err := json.Marshal(s.cfg)
s.cfg.RUnlock()
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "failed to marshal config")
return
}
var raw map[string]interface{}
if err := json.Unmarshal(data, &raw); err != nil {
writeJSONError(w, http.StatusInternalServerError, "failed to process config")
return
}
maskSecrets(raw)
writeJSON(w, http.StatusOK, raw)
}
// handlePutConfig updates the configuration.
func (s *Server) handlePutConfig(w http.ResponseWriter, r *http.Request) {
// Read current config as a map for secret preservation
s.cfg.RLock()
currentData, err := json.Marshal(s.cfg)
s.cfg.RUnlock()
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "failed to read current config")
return
}
var currentMap map[string]interface{}
if err := json.Unmarshal(currentData, &currentMap); err != nil {
writeJSONError(w, http.StatusInternalServerError, "failed to process current config")
return
}
// Decode request body into a map to inspect raw values
var incoming map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&incoming); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
// Restore masked secrets: replace "****" with current values
restoreSecrets(incoming, currentMap)
// Marshal merged map, then unmarshal onto a deep copy of current config (partial update)
mergedData, err := json.Marshal(incoming)
if err != nil {
writeJSONError(w, http.StatusInternalServerError, "failed to prepare config")
return
}
// Deep copy current config via JSON round-trip to avoid shared map/slice references
var newCfg config.Config
if err := json.Unmarshal(currentData, &newCfg); err != nil {
writeJSONError(w, http.StatusInternalServerError, "failed to copy config")
return
}
if err := json.Unmarshal(mergedData, &newCfg); err != nil {
writeJSONError(w, http.StatusBadRequest, "invalid config: "+err.Error())
return
}
if err := config.SaveConfig(s.configPath, &newCfg); err != nil {
writeJSONError(w, http.StatusInternalServerError, "failed to save config: "+err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "ok",
"restart": true,
})
// Trigger restart after response is sent
if s.onRestart != nil {
go func() {
time.Sleep(100 * time.Millisecond)
s.onRestart()
}()
}
}
// maskSecrets replaces non-empty secret values with "****".
func maskSecrets(m map[string]interface{}) {
for k, v := range m {
switch val := v.(type) {
case map[string]interface{}:
maskSecrets(val)
case string:
if secretKeys[k] && val != "" {
m[k] = "****"
}
}
}
}
// restoreSecrets replaces "****" values in incoming with the corresponding current values.
func restoreSecrets(incoming, current map[string]interface{}) {
for k, v := range incoming {
switch val := v.(type) {
case map[string]interface{}:
if curSub, ok := current[k].(map[string]interface{}); ok {
restoreSecrets(val, curSub)
}
case string:
if secretKeys[k] && val == "****" {
if curVal, ok := current[k]; ok {
incoming[k] = curVal
}
}
}
}
}
func writeJSON(w http.ResponseWriter, code int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(v)
}
// isSecretKey returns true if the given JSON key should be treated as a secret.
// This is used by the handlers to check whether to mask a value, and is also
// exposed for testing purposes.
func isSecretKey(key string) bool {
// Check the leaf key (last segment after dots)
parts := strings.SplitN(key, ".", -1)
leaf := parts[len(parts)-1]
return secretKeys[leaf]
}

196
pkg/gateway/schema.go Normal file
View file

@ -0,0 +1,196 @@
package gateway
import (
"reflect"
"strings"
"github.com/KarakuriAgent/clawdroid/pkg/config"
)
// SchemaField describes a single configuration field.
type SchemaField struct {
Key string `json:"key"`
Label string `json:"label"`
Type string `json:"type"`
Secret bool `json:"secret"`
Default interface{} `json:"default"`
}
// SchemaSection describes a top-level configuration section.
type SchemaSection struct {
Key string `json:"key"`
Label string `json:"label"`
Fields []SchemaField `json:"fields"`
}
// SchemaResponse is the top-level schema response.
type SchemaResponse struct {
Sections []SchemaSection `json:"sections"`
}
// acronyms maps lowercase abbreviations to their uppercase forms for label generation.
var acronyms = map[string]string{
"api": "API", "llm": "LLM", "url": "URL", "ws": "WS",
"id": "ID", "mcp": "MCP",
}
// secretKeys lists JSON keys that contain sensitive values.
var secretKeys = map[string]bool{
"api_key": true,
"token": true,
"bot_token": true,
"app_token": true,
"channel_secret": true,
"channel_access_token": true,
}
// BuildSchema generates a SchemaResponse by reflecting over a default Config.
func BuildSchema(defaultCfg *config.Config) SchemaResponse {
var sections []SchemaSection
cfgType := reflect.TypeOf(*defaultCfg)
cfgVal := reflect.ValueOf(*defaultCfg)
for i := 0; i < cfgType.NumField(); i++ {
field := cfgType.Field(i)
if !field.IsExported() {
continue
}
jsonTag := jsonKey(field)
if jsonTag == "" || jsonTag == "-" {
continue
}
section := SchemaSection{
Key: jsonTag,
Label: snakeToTitle(jsonTag),
}
fieldVal := cfgVal.Field(i)
section.Fields = buildFields(field.Type, fieldVal, "")
sections = append(sections, section)
}
return SchemaResponse{Sections: sections}
}
// buildFields recursively collects fields from a struct type, flattening nested structs
// with dot-separated key prefixes.
func buildFields(t reflect.Type, v reflect.Value, prefix string) []SchemaField {
var fields []SchemaField
if t.Kind() == reflect.Ptr {
t = t.Elem()
if v.IsValid() && !v.IsNil() {
v = v.Elem()
}
}
if t.Kind() != reflect.Struct {
return fields
}
for i := 0; i < t.NumField(); i++ {
sf := t.Field(i)
if !sf.IsExported() {
continue
}
jk := jsonKey(sf)
if jk == "" || jk == "-" {
continue
}
fullKey := jk
if prefix != "" {
fullKey = prefix + "." + jk
}
fieldVal := v.Field(i)
ft := sf.Type
// Dereference pointer types
if ft.Kind() == reflect.Ptr {
ft = ft.Elem()
if fieldVal.IsValid() && !fieldVal.IsNil() {
fieldVal = fieldVal.Elem()
}
}
schemaType := goTypeToSchema(ft)
if schemaType == "object" {
// Nested struct: recurse and flatten
fields = append(fields, buildFields(ft, fieldVal, fullKey)...)
continue
}
var defVal interface{}
if fieldVal.IsValid() {
defVal = fieldVal.Interface()
}
fields = append(fields, SchemaField{
Key: fullKey,
Label: snakeToTitle(jk),
Type: schemaType,
Secret: secretKeys[jk],
Default: defVal,
})
}
return fields
}
// goTypeToSchema maps a Go reflect.Type to a schema type string.
func goTypeToSchema(t reflect.Type) string {
// Check for FlexibleStringSlice by name
if t.Name() == "FlexibleStringSlice" {
return "[]string"
}
switch t.Kind() {
case reflect.String:
return "string"
case reflect.Bool:
return "bool"
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return "int"
case reflect.Float32, reflect.Float64:
return "float"
case reflect.Slice:
if t.Elem().Kind() == reflect.String {
return "[]string"
}
return "[]any"
case reflect.Map:
return "map"
case reflect.Struct:
return "object"
default:
return "any"
}
}
// jsonKey extracts the JSON field name from a struct field's tag.
func jsonKey(f reflect.StructField) string {
tag := f.Tag.Get("json")
if tag == "" {
return ""
}
parts := strings.SplitN(tag, ",", 2)
return parts[0]
}
// snakeToTitle converts a snake_case string to Title Case, applying acronym rules.
func snakeToTitle(s string) string {
parts := strings.Split(s, "_")
for i, p := range parts {
if upper, ok := acronyms[strings.ToLower(p)]; ok {
parts[i] = upper
} else if len(p) > 0 {
parts[i] = strings.ToUpper(p[:1]) + p[1:]
}
}
return strings.Join(parts, " ")
}

58
pkg/gateway/server.go Normal file
View file

@ -0,0 +1,58 @@
package gateway
import (
"context"
"fmt"
"net/http"
"github.com/KarakuriAgent/clawdroid/pkg/config"
"github.com/KarakuriAgent/clawdroid/pkg/logger"
)
// Server is the Gateway HTTP server that exposes the Config API.
type Server struct {
cfg *config.Config
configPath string
server *http.Server
onRestart func()
}
// NewServer creates a new Gateway HTTP server.
func NewServer(cfg *config.Config, configPath string, onRestart func()) *Server {
return &Server{
cfg: cfg,
configPath: configPath,
onRestart: onRestart,
}
}
// Start begins listening for HTTP requests on the configured host:port.
func (s *Server) Start() error {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/config/schema", s.authMiddleware(s.handleGetSchema))
mux.HandleFunc("GET /api/config", s.authMiddleware(s.handleGetConfig))
mux.HandleFunc("PUT /api/config", s.authMiddleware(s.handlePutConfig))
addr := fmt.Sprintf("%s:%d", s.cfg.Gateway.Host, s.cfg.Gateway.Port)
s.server = &http.Server{
Addr: addr,
Handler: mux,
}
go func() {
logger.InfoCF("gateway", "HTTP server starting", map[string]interface{}{"addr": addr})
if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.ErrorCF("gateway", "HTTP server error", map[string]interface{}{"error": err.Error()})
}
}()
return nil
}
// Stop gracefully shuts down the HTTP server.
func (s *Server) Stop(ctx context.Context) error {
if s.server == nil {
return nil
}
return s.server.Shutdown(ctx)
}

2640
pkg/gateway/server_test.go Normal file

File diff suppressed because it is too large Load diff