Files
picoclaw/web/backend/api/config.go
T
wenjie 4b76196e2c refactor(web): secure Pico websocket access behind launcher auth
- stop exposing the raw Pico token to the frontend
- add /api/pico/info for non-secret Pico connection metadata
- proxy /pico/ws through the launcher with same-origin and dashboard auth checks
- inject the upstream Pico websocket protocol server-side
- update frontend chat connection flow and Vite websocket proxy path
- refresh related docs and tests
2026-04-20 10:11:03 +08:00

568 lines
16 KiB
Go

package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"reflect"
"regexp"
"strings"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
)
// registerConfigRoutes binds configuration management endpoints to the ServeMux.
func (h *Handler) registerConfigRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/config", h.handleGetConfig)
mux.HandleFunc("PUT /api/config", h.handleUpdateConfig)
mux.HandleFunc("PATCH /api/config", h.handlePatchConfig)
mux.HandleFunc("POST /api/config/test-command-patterns", h.handleTestCommandPatterns)
}
func (h *Handler) applyRuntimeLogLevel() {
if h.debug {
logger.SetLevel(logger.DEBUG)
return
}
logger.SetLevelFromString(config.ResolveGatewayLogLevel(h.configPath))
}
// handleGetConfig returns the complete system configuration.
//
// GET /api/config
func (h *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) {
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(cfg); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
}
}
// handleUpdateConfig updates the complete system configuration.
//
// PUT /api/config
func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var cfg config.Config
if err = json.Unmarshal(body, &cfg); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
var raw map[string]any
if err = json.Unmarshal(body, &raw); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
if execAllowRemoteOmitted(body) {
cfg.Tools.Exec.AllowRemote = config.DefaultConfig().Tools.Exec.AllowRemote
}
// Load existing config and copy security credentials before validation,
// so that security-managed fields (e.g. pico token) are available.
err = cfg.SecurityCopyFrom(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to apply security config: %v", err), http.StatusInternalServerError)
return
}
applyConfigSecretsFromMap(&cfg, raw)
if errs := validateConfig(&cfg); len(errs) > 0 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]any{
"status": "validation_error",
"errors": errs,
})
return
}
if err := config.SaveConfig(h.configPath, &cfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
return
}
h.applyRuntimeLogLevel()
logger.Infof("configuration updated successfully")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
func execAllowRemoteOmitted(body []byte) bool {
var raw struct {
Tools *struct {
Exec *struct {
AllowRemote *bool `json:"allow_remote"`
} `json:"exec"`
} `json:"tools"`
}
if err := json.Unmarshal(body, &raw); err != nil {
return false
}
return raw.Tools == nil || raw.Tools.Exec == nil || raw.Tools.Exec.AllowRemote == nil
}
// handlePatchConfig partially updates the system configuration using JSON Merge Patch (RFC 7396).
// Only the fields present in the request body will be updated; all other fields remain unchanged.
//
// PATCH /api/config
func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
patchBody, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
// Validate the patch is valid JSON
var patch map[string]any
if err = json.Unmarshal(patchBody, &patch); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
// Load existing config and marshal to a map for merging
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
existing, err := json.Marshal(cfg)
if err != nil {
http.Error(w, "Failed to serialize current config", http.StatusInternalServerError)
return
}
var base map[string]any
if err = json.Unmarshal(existing, &base); err != nil {
http.Error(w, "Failed to parse current config", http.StatusInternalServerError)
return
}
// Recursively merge patch into base
mergeMap(base, patch)
// Convert merged map back to Config struct
merged, err := json.Marshal(base)
if err != nil {
http.Error(w, "Failed to serialize merged config", http.StatusInternalServerError)
return
}
var newCfg config.Config
if err = json.Unmarshal(merged, &newCfg); err != nil {
http.Error(w, fmt.Sprintf("Merged config is invalid: %v", err), http.StatusBadRequest)
return
}
// Restore security fields (tokens/keys) from the loaded config before validation,
// because private fields are lost during JSON round-trip.
if err = newCfg.SecurityCopyFrom(h.configPath); err != nil {
http.Error(w, fmt.Sprintf("Failed to apply security config: %v", err), http.StatusInternalServerError)
return
}
applyConfigSecretsFromMap(&newCfg, base)
if errs := validateConfig(&newCfg); len(errs) > 0 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]any{
"status": "validation_error",
"errors": errs,
})
return
}
if err := config.SaveConfig(h.configPath, &newCfg); err != nil {
http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError)
return
}
h.applyRuntimeLogLevel()
logger.Infof("configuration updated successfully")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}
// handleTestCommandPatterns tests a command against whitelist and blacklist patterns.
//
// POST /api/config/test-command-patterns
func (h *Handler) handleTestCommandPatterns(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "Failed to read request body", http.StatusBadRequest)
return
}
defer r.Body.Close()
var req struct {
AllowPatterns []string `json:"allow_patterns"`
DenyPatterns []string `json:"deny_patterns"`
Command string `json:"command"`
}
if err := json.Unmarshal(body, &req); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
lower := strings.ToLower(strings.TrimSpace(req.Command))
type result struct {
Allowed bool `json:"allowed"`
Blocked bool `json:"blocked"`
MatchedWhitelist *string `json:"matched_whitelist,omitempty"`
MatchedBlacklist *string `json:"matched_blacklist,omitempty"`
}
resp := result{Allowed: false, Blocked: false}
// Check whitelist first
for _, pattern := range req.AllowPatterns {
re, err := regexp.Compile(pattern)
if err != nil {
continue // skip invalid patterns
}
if re.MatchString(lower) {
resp.Allowed = true
resp.MatchedWhitelist = &pattern
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
return
}
}
// Check blacklist
for _, pattern := range req.DenyPatterns {
re, err := regexp.Compile(pattern)
if err != nil {
continue
}
if re.MatchString(lower) {
resp.Blocked = true
resp.MatchedBlacklist = &pattern
break
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
// validateConfig checks the config for common errors before saving.
// Returns a list of human-readable error strings; empty means valid.
func validateConfig(cfg *config.Config) []string {
var errs []string
// Validate model_list entries
if err := cfg.ValidateModelList(); err != nil {
errs = append(errs, err.Error())
}
// Gateway port range
if cfg.Gateway.Port != 0 && (cfg.Gateway.Port < 1 || cfg.Gateway.Port > 65535) {
errs = append(errs, fmt.Sprintf("gateway.port %d is out of valid range (1-65535)", cfg.Gateway.Port))
}
// Pico channel: token required when enabled
{
bc := cfg.Channels.GetByType(config.ChannelPico)
if bc != nil && bc.Enabled {
if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
if c, ok := decoded.(*config.PicoSettings); ok && c.Token.String() == "" {
errs = append(errs, "channels.pico.token is required when pico channel is enabled")
}
}
}
}
// Telegram: token required when enabled
{
bc := cfg.Channels.GetByType(config.ChannelTelegram)
if bc != nil && bc.Enabled {
if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
if c, ok := decoded.(*config.TelegramSettings); ok && c.Token.String() == "" {
errs = append(errs, "channels.telegram.token is required when telegram channel is enabled")
}
}
}
}
// Discord: token required when enabled
{
bc := cfg.Channels.GetByType(config.ChannelDiscord)
if bc != nil && bc.Enabled {
if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
if c, ok := decoded.(*config.DiscordSettings); ok && c.Token.String() == "" {
errs = append(errs, "channels.discord.token is required when discord channel is enabled")
}
}
}
}
{
bc := cfg.Channels.GetByType(config.ChannelWeCom)
if bc != nil && bc.Enabled {
if decoded, err := bc.GetDecoded(); err == nil && decoded != nil {
if c, ok := decoded.(*config.WeComSettings); ok {
if c.BotID == "" {
errs = append(errs, "channels.wecom.bot_id is required when wecom channel is enabled")
}
if c.Secret.String() == "" {
errs = append(errs, "channels.wecom.secret is required when wecom channel is enabled")
}
}
}
}
}
if cfg.Tools.Exec.Enabled {
if cfg.Tools.Exec.EnableDenyPatterns {
errs = append(
errs,
validateRegexPatterns("tools.exec.custom_deny_patterns", cfg.Tools.Exec.CustomDenyPatterns)...)
}
errs = append(
errs,
validateRegexPatterns("tools.exec.custom_allow_patterns", cfg.Tools.Exec.CustomAllowPatterns)...)
}
return errs
}
func validateRegexPatterns(field string, patterns []string) []string {
var errs []string
for index, pattern := range patterns {
if _, err := regexp.Compile(pattern); err != nil {
errs = append(errs, fmt.Sprintf("%s[%d] is not a valid regular expression: %v", field, index, err))
}
}
return errs
}
// mergeMap recursively merges src into dst (JSON Merge Patch semantics).
// - If a key in src has a null value, it is deleted from dst.
// - If both dst and src have a nested object for the same key, merge recursively.
// - Otherwise the value from src overwrites dst.
func mergeMap(dst, src map[string]any) {
for key, srcVal := range src {
if srcVal == nil {
delete(dst, key)
continue
}
srcMap, srcIsMap := srcVal.(map[string]any)
dstMap, dstIsMap := dst[key].(map[string]any)
if srcIsMap && dstIsMap {
mergeMap(dstMap, srcMap)
} else {
dst[key] = srcVal
}
}
}
func asMapField(value map[string]any, key string) (map[string]any, bool) {
raw, exists := value[key]
if !exists {
return nil, false
}
m, isMap := raw.(map[string]any)
return m, isMap
}
func getSecretString(m map[string]any, key string) (string, bool) {
if raw, exists := m[key]; exists {
s, isString := raw.(string)
if isString {
return s, true
}
}
if raw, exists := m["_"+key]; exists {
s, isString := raw.(string)
if isString {
return s, true
}
}
return "", false
}
func applyConfigSecretsFromMap(cfg *config.Config, raw map[string]any) {
channelsMap, hasChannels := asMapField(raw, "channel_list")
if !hasChannels {
return
}
for chName, chData := range channelsMap {
chMap, ok := chData.(map[string]any)
if !ok {
continue
}
bc := cfg.Channels.Get(chName)
if bc == nil {
continue
}
decoded, err := bc.GetDecoded()
if err != nil || decoded == nil {
continue
}
rv := reflect.ValueOf(decoded)
if rv.Kind() == reflect.Ptr {
rv = rv.Elem()
}
if rv.Kind() != reflect.Struct {
continue
}
// Channel-specific settings live under the "settings" key in the raw map
settingsMap := chMap
if sm, hasSettings := asMapField(chMap, "settings"); hasSettings {
settingsMap = sm
}
applySecureStringsToStruct(rv, settingsMap)
}
// Handle tools secrets
tools, hasTools := asMapField(raw, "tools")
if !hasTools {
return
}
skills, hasSkills := asMapField(tools, "skills")
if !hasSkills {
return
}
if github, hasGithub := asMapField(skills, "github"); hasGithub {
if token, hasToken := getSecretString(github, "token"); hasToken {
cfg.Tools.Skills.Github.Token.Set(token)
}
}
if registries, hasRegistries := asMapField(skills, "registries"); hasRegistries {
for registryName, rawRegistry := range registries {
registryMap, ok := rawRegistry.(map[string]any)
if !ok {
continue
}
if authToken, hasAuthToken := getSecretString(registryMap, "auth_token"); hasAuthToken {
registryCfg, _ := cfg.Tools.Skills.Registries.Get(registryName)
registryCfg.AuthToken.Set(authToken)
cfg.Tools.Skills.Registries.Set(registryName, registryCfg)
}
}
return
}
registriesList, hasRegistries := skills["registries"].([]any)
if !hasRegistries {
return
}
for _, rawRegistry := range registriesList {
registryMap, ok := rawRegistry.(map[string]any)
if !ok {
continue
}
name, _ := registryMap["name"].(string)
if name == "" {
continue
}
if authToken, hasAuthToken := getSecretString(registryMap, "auth_token"); hasAuthToken {
registryCfg, _ := cfg.Tools.Skills.Registries.Get(name)
registryCfg.AuthToken.Set(authToken)
cfg.Tools.Skills.Registries.Set(name, registryCfg)
}
}
}
// applySecureStringsToStruct walks a struct and applies SecureString fields
// from the matching keys in rawMap. It recurses into nested maps and slices.
func applySecureStringsToStruct(rv reflect.Value, rawMap map[string]any) {
rt := rv.Type()
for jsonKey, rawVal := range rawMap {
for i := range rt.NumField() {
f := rt.Field(i)
if !f.IsExported() {
continue
}
tag := f.Tag.Get("json")
name := strings.Split(tag, ",")[0]
if name != jsonKey {
continue
}
sf := rv.Field(i)
if !sf.CanSet() {
continue
}
// Direct SecureString field
if s, ok := rawVal.(string); ok {
if f.Type == reflect.TypeOf(config.SecureString{}) {
sf.Set(reflect.ValueOf(*config.NewSecureString(s)))
} else if f.Type == reflect.TypeOf(&config.SecureString{}) {
sf.Set(reflect.ValueOf(config.NewSecureString(s)))
}
continue
}
// Recurse into nested struct
if sf.Kind() == reflect.Struct {
if nested, ok := rawVal.(map[string]any); ok {
applySecureStringsToStruct(sf, nested)
}
continue
}
// Recurse into map fields (e.g., map[string]SomeStruct)
if sf.Kind() == reflect.Map && sf.Type().Elem().Kind() == reflect.Struct {
if nestedMap, ok := rawVal.(map[string]any); ok {
for mapKey, mapVal := range nestedMap {
nested, ok := mapVal.(map[string]any)
if !ok {
continue
}
elemType := sf.Type().Elem()
// Get existing element or create a new zero value
var elem reflect.Value
existing := sf.MapIndex(reflect.ValueOf(mapKey))
if existing.IsValid() {
if existing.Kind() == reflect.Interface {
existing = existing.Elem()
}
if existing.Kind() == reflect.Ptr && !existing.IsNil() {
elem = reflect.New(elemType)
elem.Elem().Set(existing.Elem())
} else if existing.Kind() == reflect.Struct {
elem = reflect.New(elemType)
elem.Elem().Set(existing)
}
}
if !elem.IsValid() {
elem = reflect.New(elemType)
}
applySecureStringsToStruct(elem.Elem(), nested)
sf.SetMapIndex(reflect.ValueOf(mapKey), elem.Elem())
}
}
continue
}
// Recurse into slice elements that are structs
if sf.Kind() == reflect.Slice && sf.Type().Elem().Kind() == reflect.Struct {
if sliceRaw, ok := rawVal.([]any); ok {
for idx, elemRaw := range sliceRaw {
if nested, ok := elemRaw.(map[string]any); ok {
if idx < sf.Len() {
applySecureStringsToStruct(sf.Index(idx), nested)
}
}
}
}
}
}
}
}