The three configuration singletons (C_globalConfig, C_botUserConfig, C_botNodeConfig) were plain variables: LoadGlobalConfig and friends assigned them from the goroutine handling a settings update, while bot pipes, the node tracker and the HTTP handlers read them from their own goroutines. That is an unsynchronized read of a concurrently written variable — a data race the race detector reports, and one that already existed before any hot-reload work because /api/webhook/add reloads the configuration while the bots run. Replace them with atomic.Pointer values behind Current(), BotUsers() and BotNodes(). Each reload builds a fresh value and publishes it atomically, so a reader either sees the previous configuration or the new one, never a partial one. Callers read through the accessor on every use instead of caching it. Two spots that read several fields of one guard now snapshot once per call, so a reload cannot split a combined check mid-flight: - qq_napcat.handleNapcatEvent, which evaluates the debug guards per event - the komari task-echo guard, now behind taskEchoEnabled() The NapCat HTTP methods keep logging on showNapcatAction alone (without requiring debugMode), matching their existing behaviour; that inconsistency with the WebSocket path is preserved, not introduced, and is called out in actionLogEnabled. Also adds TestConcurrentReloadAndRead, which drives every accessor from four reader goroutines while two writers reload the configuration, and sanitises the member IDs used as test fixtures in config/settings_test.go. Co-Authored-By: Claude Code <noreply@anthropic.com>
201 lines
6.6 KiB
Go
201 lines
6.6 KiB
Go
package config
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// Errors reported by the incoming webhook endpoint helpers. The HTTP layer maps
|
|
// them onto statuses: exists -> 409, not found -> 404, invalid -> 400.
|
|
var (
|
|
// ErrWebhookEndpointExists is returned by AddWebhookEndpoint when the name
|
|
// is already configured.
|
|
ErrWebhookEndpointExists = errors.New("webhook endpoint already exists")
|
|
|
|
// ErrWebhookEndpointNotFound is returned when the named endpoint is not
|
|
// configured.
|
|
ErrWebhookEndpointNotFound = errors.New("webhook endpoint not found")
|
|
|
|
// ErrWebhookEndpointInvalid is returned when a name or field supplied for an
|
|
// endpoint cannot be stored.
|
|
ErrWebhookEndpointInvalid = errors.New("invalid webhook endpoint")
|
|
)
|
|
|
|
// webhookEndpointFields are the endpoint keys a client may set. A field that is
|
|
// absent from an update is left untouched; a field that is present but not
|
|
// listed here is rejected rather than written, so a typo cannot leave an
|
|
// endpoint silently ignoring a setting.
|
|
var webhookEndpointFields = map[string]func(interface{}) bool{
|
|
"enabled": isJSONBool,
|
|
"token": isJSONString,
|
|
"notifyPipes": isJSONStringArray,
|
|
}
|
|
|
|
// WebhookEndpoints returns the configured incoming webhook endpoints keyed by
|
|
// name, as a copy: changing the result does not change the loaded
|
|
// configuration.
|
|
func WebhookEndpoints() map[string]WebhookEndpointConfig {
|
|
endpoints := map[string]WebhookEndpointConfig{}
|
|
cfg := Current()
|
|
if cfg == nil {
|
|
return endpoints
|
|
}
|
|
for name, endpoint := range cfg.Webhook.Endpoints {
|
|
endpoints[name] = endpoint
|
|
}
|
|
return endpoints
|
|
}
|
|
|
|
// AddWebhookEndpoint registers a new incoming webhook endpoint under name. Only
|
|
// the fields present in fields are set, so an endpoint can be created with
|
|
// default values and completed later by ModifyWebhookEndpoint. Unlike
|
|
// ModifyWebhookEndpoint it refuses to touch an endpoint that already exists.
|
|
func AddWebhookEndpoint(name string, fields map[string]interface{}) error {
|
|
if err := validateWebhookEndpointName(name); err != nil {
|
|
return err
|
|
}
|
|
patch, err := webhookEndpointPatch(fields)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
settingsLock.Lock()
|
|
defer settingsLock.Unlock()
|
|
|
|
if _, exists := webhookEndpoint(name); exists {
|
|
return fmt.Errorf("%w: %s", ErrWebhookEndpointExists, name)
|
|
}
|
|
return updateSettingsLocked(SettingGlobal, webhookEndpointsPatch(name, patch))
|
|
}
|
|
|
|
// ModifyWebhookEndpoint updates an existing incoming webhook endpoint. Only the
|
|
// fields present in fields are changed; every other field keeps its configured
|
|
// value.
|
|
func ModifyWebhookEndpoint(name string, fields map[string]interface{}) error {
|
|
if err := validateWebhookEndpointName(name); err != nil {
|
|
return err
|
|
}
|
|
patch, err := webhookEndpointPatch(fields)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(patch) == 0 {
|
|
return fmt.Errorf("%w: no fields to update", ErrWebhookEndpointInvalid)
|
|
}
|
|
|
|
settingsLock.Lock()
|
|
defer settingsLock.Unlock()
|
|
|
|
if _, exists := webhookEndpoint(name); !exists {
|
|
return fmt.Errorf("%w: %s", ErrWebhookEndpointNotFound, name)
|
|
}
|
|
return updateSettingsLocked(SettingGlobal, webhookEndpointsPatch(name, patch))
|
|
}
|
|
|
|
// DeleteWebhookEndpoint removes the incoming webhook endpoint registered under
|
|
// name. The endpoint stops accepting requests as soon as the configuration is
|
|
// reloaded.
|
|
func DeleteWebhookEndpoint(name string) error {
|
|
settingsLock.Lock()
|
|
defer settingsLock.Unlock()
|
|
|
|
if _, exists := webhookEndpoint(name); !exists {
|
|
return fmt.Errorf("%w: %s", ErrWebhookEndpointNotFound, name)
|
|
}
|
|
|
|
return updateSettingsLocked(SettingGlobal, webhookEndpointDeletePatch(name))
|
|
}
|
|
|
|
// webhookEndpoint returns the named endpoint held by the loaded configuration.
|
|
// No extra lock is needed to read it: a reload replaces the whole configuration
|
|
// rather than mutating it in place, and Current publishes the replacement
|
|
// atomically, so the value read is always from one complete version.
|
|
func webhookEndpoint(name string) (WebhookEndpointConfig, bool) {
|
|
cfg := Current()
|
|
if cfg == nil {
|
|
return WebhookEndpointConfig{}, false
|
|
}
|
|
endpoint, exists := cfg.Webhook.Endpoints[name]
|
|
return endpoint, exists
|
|
}
|
|
|
|
// webhookEndpointsPatch wraps the fields of one endpoint into the nested patch
|
|
// the settings merge expects for webhook.endpoints.<name>.
|
|
func webhookEndpointsPatch(name string, fields map[string]interface{}) map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"webhook": map[string]interface{}{
|
|
"endpoints": map[string]interface{}{name: fields},
|
|
},
|
|
}
|
|
}
|
|
|
|
// webhookEndpointDeletePatch is the patch that removes an endpoint. The value is
|
|
// a null, which the settings merge reads as "delete this key". It must be an
|
|
// untyped nil: a nil map of type map[string]interface{} would be merged as an
|
|
// empty object instead, leaving the endpoint in the configuration.
|
|
func webhookEndpointDeletePatch(name string) map[string]interface{} {
|
|
return map[string]interface{}{
|
|
"webhook": map[string]interface{}{
|
|
"endpoints": map[string]interface{}{name: nil},
|
|
},
|
|
}
|
|
}
|
|
|
|
// webhookEndpointPatch validates the fields of one endpoint and returns them as
|
|
// the value to merge. Fields not accepted for an endpoint are rejected instead
|
|
// of being written to the configuration file.
|
|
func webhookEndpointPatch(fields map[string]interface{}) (map[string]interface{}, error) {
|
|
patch := make(map[string]interface{}, len(fields))
|
|
for key, value := range fields {
|
|
accepts, known := webhookEndpointFields[key]
|
|
if !known {
|
|
return nil, fmt.Errorf("%w: unknown field %q", ErrWebhookEndpointInvalid, key)
|
|
}
|
|
if !accepts(value) {
|
|
return nil, fmt.Errorf("%w: field %q has the wrong type", ErrWebhookEndpointInvalid, key)
|
|
}
|
|
patch[key] = value
|
|
}
|
|
return patch, nil
|
|
}
|
|
|
|
// validateWebhookEndpointName checks that a name can address an endpoint. The
|
|
// name is the last segment of the endpoint URL, so a name containing a slash
|
|
// could never be reached.
|
|
func validateWebhookEndpointName(name string) error {
|
|
if name == "" {
|
|
return fmt.Errorf("%w: name must not be empty", ErrWebhookEndpointInvalid)
|
|
}
|
|
if strings.Contains(name, "/") {
|
|
return fmt.Errorf("%w: name must not contain %q", ErrWebhookEndpointInvalid, "/")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// The predicates below accept the decoded JSON types a field may carry. Numbers
|
|
// decoded with UseNumber stay json.Number, so a JSON true/false is the only
|
|
// value accepted for a boolean field.
|
|
func isJSONBool(value interface{}) bool {
|
|
_, ok := value.(bool)
|
|
return ok
|
|
}
|
|
|
|
func isJSONString(value interface{}) bool {
|
|
_, ok := value.(string)
|
|
return ok
|
|
}
|
|
|
|
func isJSONStringArray(value interface{}) bool {
|
|
items, ok := value.([]interface{})
|
|
if !ok {
|
|
return false
|
|
}
|
|
for _, item := range items {
|
|
if _, ok := item.(string); !ok {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|