server/internal/handlers/admin_config.go
Rama main · réplica de lectura del repositorio original.
package handlers
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"log"
"net/http"
"net/url"
"strings"
"time"
"blackbox/server/internal/incidents"
mcppkg "blackbox/server/internal/mcp"
"blackbox/server/internal/models"
"gorm.io/gorm"
)
const aiProviderKey = "ai_provider"
const aiURLKey = "ai_url"
const aiModelKey = "ai_model"
const aiAPIKeyKey = "ai_api_key"
const aiModeKey = "ai_mode"
const baseURLKey = "base_url"
const mcpEnabledKey = "mcp_enabled"
const mcpAuthTokenKey = "mcp_auth_token"
const mcpMigrationWarningAcknowledgedKey = "mcp_migration_warning_acknowledged"
const legacyMCPPortKey = "mcp_port"
// Legacy keys — read-only fallback for existing installs
const legacyOllamaURLKey = "ollama_url"
const legacyOllamaModelKey = "ollama_model"
const legacyOllamaModeKey = "ollama_mode"
type aiSettingsRequest struct {
Provider string `json:"ai_provider"`
URL string `json:"ai_url"`
Model string `json:"ai_model"`
APIKey string `json:"ai_api_key"`
ClearAPIKey bool `json:"ai_clear_api_key"`
Mode string `json:"ai_mode"`
}
type normalizedAISettings struct {
provider string
url string
model string
apiKey string
mode string
}
func AdminConfig(db *gorm.DB, webhookSecret string, mcpMgr *mcppkg.MCPManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
redactSecrets, err := getFileWatcherRedactSecrets(db)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load admin config")
return
}
ai, err := getAISettings(db)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load admin config")
return
}
var baseURLSetting models.AppSetting
baseURL := ""
if err := db.First(&baseURLSetting, "key = ?", baseURLKey).Error; err == nil {
baseURL = strings.TrimSpace(baseURLSetting.Value)
}
mcpSettings, err := getMCPSettings(db)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load admin config")
return
}
mcpMigrationWarning, err := shouldShowMCPMigrationWarning(db)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load admin config")
return
}
mcpRunning := false
if mcpMgr != nil {
mcpRunning = mcpMgr.IsRunning()
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store, no-cache, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
if err := json.NewEncoder(w).Encode(map[string]any{
"webhook_secret": webhookSecret,
"file_watcher_redact_secrets": redactSecrets,
"ai_provider": ai.provider,
"ai_url": ai.url,
"ai_model": ai.model,
"ai_api_key_set": ai.apiKeySet,
"ai_mode": ai.mode,
"base_url": baseURL,
"mcp_enabled": mcpSettings.enabled,
"mcp_auth_token_set": mcpSettings.tokenSet,
"mcp_auth_token_suffix": mcpSettings.tokenSuffix,
"mcp_running": mcpRunning,
"mcp_endpoint_url": mcpEndpointURL(baseURL),
"mcp_migration_warning": mcpMigrationWarning,
}); err != nil {
log.Printf("AdminConfig encode: %v", err)
}
}
}
type mcpSettingsRequest struct {
Enabled *bool `json:"mcp_enabled"`
AcknowledgeMCPMigrationWarning bool `json:"acknowledge_mcp_migration_warning"`
}
func UpdateMCPSettings(db *gorm.DB, mcpMgr *mcppkg.MCPManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req mcpSettingsRequest
if !decodeJSONBody(w, r, 1<<20, &req) {
return
}
if req.Enabled == nil && req.AcknowledgeMCPMigrationWarning {
if err := db.Save(&models.AppSetting{
Key: mcpMigrationWarningAcknowledgedKey,
Value: "true",
UpdatedAt: time.Now(),
}).Error; err != nil {
writeError(w, http.StatusInternalServerError, "failed to save mcp settings")
return
}
w.WriteHeader(http.StatusNoContent)
return
}
if req.Enabled == nil {
writeError(w, http.StatusBadRequest, "mcp_enabled is required unless acknowledging the migration warning")
return
}
existing, err := getMCPSettings(db)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load mcp settings")
return
}
enabled := *req.Enabled
token := existing.token
if enabled && token == "" {
token, err = generateMCPToken()
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to generate token")
return
}
}
if enabled && token == "" {
writeError(w, http.StatusBadRequest, "mcp_auth_token is required")
return
}
// DB is the source of truth: write first, apply second.
now := time.Now()
settings := []models.AppSetting{
{Key: mcpEnabledKey, Value: boolToString(enabled), UpdatedAt: now},
}
if token != "" {
settings = append(settings, models.AppSetting{Key: mcpAuthTokenKey, Value: token, UpdatedAt: now})
}
if req.AcknowledgeMCPMigrationWarning {
settings = append(settings, models.AppSetting{
Key: mcpMigrationWarningAcknowledgedKey,
Value: "true",
UpdatedAt: now,
})
}
if err := db.Transaction(func(tx *gorm.DB) error {
for _, s := range settings {
if err := tx.Save(&s).Error; err != nil {
return err
}
}
return nil
}); err != nil {
writeError(w, http.StatusInternalServerError, "failed to save mcp settings")
return
}
// Apply live — DB is already consistent; a failure here is a soft error.
// The next Blackbox restart will self-correct from DB.
if mcpMgr != nil {
if err := mcpMgr.ApplySettings(enabled, token); err != nil {
log.Printf("mcp: ApplySettings failed after DB write: %v", err)
writeError(w, http.StatusInternalServerError,
"settings saved but failed to apply: "+err.Error()+" — restart Blackbox to apply")
return
}
}
w.WriteHeader(http.StatusNoContent)
}
}
func RegenerateMCPToken(db *gorm.DB, mcpMgr *mcppkg.MCPManager) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
newToken, err := generateMCPToken()
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to generate token")
return
}
if err := db.Save(&models.AppSetting{Key: mcpAuthTokenKey, Value: newToken, UpdatedAt: time.Now()}).Error; err != nil {
writeError(w, http.StatusInternalServerError, "failed to save token")
return
}
mcpCfg, err := getMCPSettings(db)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load mcp settings")
return
}
if mcpCfg.enabled && mcpMgr != nil {
if err := mcpMgr.ApplySettings(true, newToken); err != nil {
writeError(w, http.StatusInternalServerError, "failed to restart mcp server with new token")
return
}
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]string{"mcp_auth_token_suffix": tokenSuffix(newToken)}); err != nil {
log.Printf("RegenerateMCPToken encode: %v", err)
}
}
}
func UpdateBaseURLSetting(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
BaseURL string `json:"base_url"`
}
if !decodeJSONBody(w, r, 4<<10, &req) {
return
}
value := normalizeBaseURL(req.BaseURL)
if strings.TrimSpace(req.BaseURL) != "" && value == "" {
writeError(w, http.StatusBadRequest, "base_url must be a valid absolute URL or empty")
return
}
setting := models.AppSetting{Key: baseURLKey, Value: value, UpdatedAt: time.Now()}
if err := db.Save(&setting).Error; err != nil {
writeError(w, http.StatusInternalServerError, "failed to save base_url")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func UpdateAISettings(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req aiSettingsRequest
if !decodeJSONBody(w, r, 1<<20, &req) {
return
}
normalized, err := normalizeAISettingsRequest(db, req)
if err != nil {
if errors.Is(err, errLoadExistingAPIKey) {
writeError(w, http.StatusInternalServerError, err.Error())
} else {
writeError(w, http.StatusBadRequest, err.Error())
}
return
}
now := time.Now()
appSettings := []models.AppSetting{
{Key: aiProviderKey, Value: normalized.provider, UpdatedAt: now},
{Key: aiURLKey, Value: normalized.url, UpdatedAt: now},
{Key: aiModelKey, Value: normalized.model, UpdatedAt: now},
{Key: aiAPIKeyKey, Value: normalized.apiKey, UpdatedAt: now},
{Key: aiModeKey, Value: normalized.mode, UpdatedAt: now},
}
if err := db.Transaction(func(tx *gorm.DB) error {
for _, s := range appSettings {
if err := tx.Save(&s).Error; err != nil {
return err
}
}
return nil
}); err != nil {
writeError(w, http.StatusInternalServerError, "failed to save setting")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func TestAISettings(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req aiSettingsRequest
if !decodeJSONBody(w, r, 1<<20, &req) {
return
}
settings, err := normalizeAISettingsRequest(db, req)
if err != nil {
if errors.Is(err, errLoadExistingAPIKey) {
writeError(w, http.StatusInternalServerError, err.Error())
} else {
writeError(w, http.StatusBadRequest, err.Error())
}
return
}
if settings.url == "" {
writeError(w, http.StatusBadRequest, "ai_url is required")
return
}
if settings.model == "" {
writeError(w, http.StatusBadRequest, "ai_model is required")
return
}
result, err := incidents.GenerateWithConfig(
r.Context(),
settings.provider,
settings.url,
settings.model,
settings.apiKey,
"Reply with the single word OK.",
15*time.Second,
)
if err != nil {
writeError(w, http.StatusBadGateway, err.Error())
return
}
result = strings.TrimSpace(result)
resultRunes := []rune(result)
if len(resultRunes) > 200 {
result = string(resultRunes[:200]) + "..."
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
if err := json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"response": result,
}); err != nil {
log.Printf("TestAISettings encode: %v", err)
}
}
}
// UpdateOllamaSettingsLegacy handles PUT /api/admin/settings/ollama (deprecated).
// Translates legacy ollama_* field names to ai_* and saves using the new schema,
// always preserving any stored API key since the old endpoint had no key concept.
func UpdateOllamaSettingsLegacy(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
OllamaURL string `json:"ollama_url"`
OllamaModel string `json:"ollama_model"`
OllamaMode string `json:"ollama_mode"`
}
if !decodeJSONBody(w, r, 1<<20, &req) {
return
}
aiURL := strings.TrimSpace(req.OllamaURL)
if aiURL != "" {
parsed, err := url.ParseRequestURI(aiURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
writeError(w, http.StatusBadRequest, "ollama_url must be a valid absolute URL")
return
}
}
mode := strings.TrimSpace(req.OllamaMode)
if mode != "" && mode != "analysis" && mode != "enhanced" {
writeError(w, http.StatusBadRequest, "ollama_mode must be 'analysis' or 'enhanced'")
return
}
if mode == "" {
mode = "analysis"
}
// Preserve any existing API key — legacy endpoint has no key field.
var apiKey string
var existing models.AppSetting
if err := db.First(&existing, "key = ?", aiAPIKeyKey).Error; err == nil {
apiKey = existing.Value
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
writeError(w, http.StatusInternalServerError, "failed to load existing api key")
return
}
now := time.Now()
settings := []models.AppSetting{
{Key: aiProviderKey, Value: "ollama", UpdatedAt: now},
{Key: aiURLKey, Value: aiURL, UpdatedAt: now},
{Key: aiModelKey, Value: strings.TrimSpace(req.OllamaModel), UpdatedAt: now},
{Key: aiAPIKeyKey, Value: apiKey, UpdatedAt: now},
{Key: aiModeKey, Value: mode, UpdatedAt: now},
}
if err := db.Transaction(func(tx *gorm.DB) error {
for _, s := range settings {
if err := tx.Save(&s).Error; err != nil {
return err
}
}
return nil
}); err != nil {
writeError(w, http.StatusInternalServerError, "failed to save setting")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
type aiSettingsResult struct {
provider string
url string
model string
apiKeySet bool
mode string
}
type mcpSettingsResult struct {
enabled bool
tokenSet bool
tokenSuffix string
token string
}
func getMCPSettings(db *gorm.DB) (mcpSettingsResult, error) {
keys := []string{mcpEnabledKey, mcpAuthTokenKey}
var settings []models.AppSetting
if err := db.Where("key IN ?", keys).Find(&settings).Error; err != nil {
return mcpSettingsResult{}, err
}
m := make(map[string]string, len(settings))
for _, s := range settings {
m[s.Key] = strings.TrimSpace(s.Value)
}
result := mcpSettingsResult{
enabled: m[mcpEnabledKey] == "true",
token: m[mcpAuthTokenKey],
}
result.tokenSet = result.token != ""
result.tokenSuffix = tokenSuffix(result.token)
return result, nil
}
func shouldShowMCPMigrationWarning(db *gorm.DB) (bool, error) {
var legacyPortCount int64
if err := db.Model(&models.AppSetting{}).Where("key = ?", legacyMCPPortKey).Count(&legacyPortCount).Error; err != nil {
return false, err
}
if legacyPortCount == 0 {
return false, nil
}
var ackSetting models.AppSetting
if err := db.First(&ackSetting, "key = ?", mcpMigrationWarningAcknowledgedKey).Error; err == nil {
return strings.TrimSpace(ackSetting.Value) != "true", nil
} else if errors.Is(err, gorm.ErrRecordNotFound) {
return true, nil
} else {
return false, err
}
}
func mcpEndpointURL(baseURL string) string {
baseURL = normalizeBaseURL(baseURL)
if baseURL == "" {
return ""
}
return baseURL + "/mcp"
}
func normalizeBaseURL(baseURL string) string {
baseURL = strings.TrimSpace(baseURL)
if baseURL == "" {
return ""
}
parsed, err := url.ParseRequestURI(baseURL)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") ||
parsed.RawQuery != "" || parsed.Fragment != "" {
return ""
}
return strings.TrimRight(baseURL, "/")
}
func boolToString(v bool) string {
if v {
return "true"
}
return "false"
}
func generateMCPToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func tokenSuffix(token string) string {
if len(token) < 8 {
return ""
}
return token[len(token)-8:]
}
func getAISettings(db *gorm.DB) (aiSettingsResult, error) {
keys := []string{aiProviderKey, aiURLKey, aiModelKey, aiAPIKeyKey, aiModeKey, legacyOllamaURLKey, legacyOllamaModelKey, legacyOllamaModeKey}
var settings []models.AppSetting
if err := db.Where("key IN ?", keys).Find(&settings).Error; err != nil {
return aiSettingsResult{}, err
}
m := make(map[string]string, len(settings))
for _, s := range settings {
m[s.Key] = strings.TrimSpace(s.Value)
}
result := aiSettingsResult{mode: "analysis"}
result.provider = m[aiProviderKey]
if result.provider == "" {
result.provider = "ollama"
}
if url, ok := m[aiURLKey]; ok {
result.url = url
} else {
result.url = m[legacyOllamaURLKey]
}
if model, ok := m[aiModelKey]; ok {
result.model = model
} else {
result.model = m[legacyOllamaModelKey]
}
result.apiKeySet = m[aiAPIKeyKey] != ""
if _, ok := m[aiModeKey]; ok {
if v := m[aiModeKey]; v != "" {
result.mode = v
}
} else if v := m[legacyOllamaModeKey]; v != "" {
result.mode = v
}
return result, nil
}
var errLoadExistingAPIKey = errors.New("failed to load existing api key")
func normalizeAISettingsRequest(db *gorm.DB, req aiSettingsRequest) (normalizedAISettings, error) {
provider := strings.TrimSpace(req.Provider)
if provider == "" {
provider = "ollama"
}
if provider != "ollama" && provider != "openai_compat" {
return normalizedAISettings{}, errors.New("ai_provider must be 'ollama' or 'openai_compat'")
}
aiURL := strings.TrimSpace(req.URL)
if aiURL != "" {
parsed, err := url.ParseRequestURI(aiURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return normalizedAISettings{}, errors.New("ai_url must be a valid absolute URL")
}
}
mode := strings.TrimSpace(req.Mode)
if mode != "" && mode != "analysis" && mode != "enhanced" {
return normalizedAISettings{}, errors.New("ai_mode must be 'analysis' or 'enhanced'")
}
if mode == "" {
mode = "analysis"
}
apiKey := strings.TrimSpace(req.APIKey)
if apiKey == "" && !req.ClearAPIKey {
var existing models.AppSetting
if err := db.First(&existing, "key = ?", aiAPIKeyKey).Error; err == nil {
apiKey = existing.Value
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return normalizedAISettings{}, errLoadExistingAPIKey
}
}
return normalizedAISettings{
provider: provider,
url: aiURL,
model: strings.TrimSpace(req.Model),
apiKey: apiKey,
mode: mode,
}, nil
}