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:
parent
7e0856c907
commit
7e73df9752
9 changed files with 3144 additions and 5 deletions
|
|
@ -18,6 +18,7 @@ import (
|
|||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/KarakuriAgent/clawdroid/pkg/agent"
|
||||
|
|
@ -25,6 +26,7 @@ import (
|
|||
"github.com/KarakuriAgent/clawdroid/pkg/channels"
|
||||
"github.com/KarakuriAgent/clawdroid/pkg/config"
|
||||
"github.com/KarakuriAgent/clawdroid/pkg/cron"
|
||||
"github.com/KarakuriAgent/clawdroid/pkg/gateway"
|
||||
"github.com/KarakuriAgent/clawdroid/pkg/heartbeat"
|
||||
"github.com/KarakuriAgent/clawdroid/pkg/logger"
|
||||
"github.com/KarakuriAgent/clawdroid/pkg/providers"
|
||||
|
|
@ -442,6 +444,7 @@ func gatewayCmd() {
|
|||
}
|
||||
}
|
||||
|
||||
configPath := getConfigPath()
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
fmt.Printf("Error loading config: %v\n", err)
|
||||
|
|
@ -475,6 +478,22 @@ func gatewayCmd() {
|
|||
"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
|
||||
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)
|
||||
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...")
|
||||
cancel()
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer shutdownCancel()
|
||||
gwServer.Stop(shutdownCtx)
|
||||
heartbeatService.Stop()
|
||||
cronService.Stop()
|
||||
agentLoop.Stop()
|
||||
channelManager.StopAll(ctx)
|
||||
channelManager.StopAll(shutdownCtx)
|
||||
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() {
|
||||
|
|
|
|||
|
|
@ -99,7 +99,8 @@
|
|||
},
|
||||
"gateway": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 18790
|
||||
"port": 18790,
|
||||
"api_key": ""
|
||||
},
|
||||
"rate_limits": {
|
||||
"max_tool_calls_per_minute": 30,
|
||||
|
|
|
|||
|
|
@ -138,8 +138,9 @@ type RateLimitsConfig struct {
|
|||
}
|
||||
|
||||
type GatewayConfig struct {
|
||||
Host string `json:"host" env:"CLAWDROID_GATEWAY_HOST"`
|
||||
Port int `json:"port" env:"CLAWDROID_GATEWAY_PORT"`
|
||||
Host string `json:"host" env:"CLAWDROID_GATEWAY_HOST"`
|
||||
Port int `json:"port" env:"CLAWDROID_GATEWAY_PORT"`
|
||||
APIKey string `json:"api_key" env:"CLAWDROID_GATEWAY_API_KEY"`
|
||||
}
|
||||
|
||||
type BraveConfig struct {
|
||||
|
|
@ -323,6 +324,9 @@ func SaveConfig(path string, cfg *Config) error {
|
|||
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 {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
func TestConfig_Complete(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
|
|
|
|||
45
pkg/gateway/auth.go
Normal file
45
pkg/gateway/auth.go
Normal 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
148
pkg/gateway/handlers.go
Normal 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, ¤tMap); 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
196
pkg/gateway/schema.go
Normal 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
58
pkg/gateway/server.go
Normal 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
2640
pkg/gateway/server_test.go
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue