Skip to content

Conversation

@LaurenceJJones
Copy link
Member

No description provided.

- Add SIGHUP signal handler to reload host configuration without restart
- Implement Reload() method in HostManager to update hosts from config
- Preserve existing host objects during reload to maintain session state
- Add SetContext() to tie host goroutines to service lifecycle
- Update systemd service to support reload via systemctl reload

This allows updating host configurations (captcha, ban, appsec) without
losing active sessions or requiring a full service restart.
The context was previously used for captcha/appsec goroutines, but since
sessions are now managed globally and Init() methods no longer take context,
the host manager no longer needs to store or manage context.
DRY up the code by consolidating captcha, ban, and appsec initialization
into a single method on the Host struct. This makes the code more
maintainable and ensures consistent initialization behavior.
Remove the complex logic to preserve existing host objects during reload.
Since sessions are now managed globally and persist independently of host
objects, we can simply replace all hosts with new ones. This simplifies
the code significantly and makes reload behavior more predictable.
…nager

- Fix race condition in MatchFirstHost: properly release RLock before
  acquiring Lock for cache writes
- Extract duplicate host loading logic from SetHosts and Reload into
  loadHostsFromSources() helper function
- Improve error handling with continueOnError flag for reload resilience

This makes the code thread-safe, more maintainable, and eliminates
code duplication.
@LaurenceJJones LaurenceJJones added this to the 0.2.1 milestone Nov 20, 2025
Replace boolean control flag with string parameter to avoid control
coupling. Extract directory loading logic into separate function for
better separation of concerns.
Replace control flag pattern with error collection. Functions now return
a slice of errors, allowing callers to decide how to handle them:
- SetHosts (startup): returns first error (fail-fast)
- Reload: logs all errors but continues

This follows Go best practices and eliminates control coupling.
- Use concise error format: just file path and wrapped error
- Add summary log line with error count before individual errors
- Use shorter log message to avoid duplication with error content

This makes logs cleaner and easier to read, especially when multiple
files fail to load during reload.
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR adds support for hot-reloading host configurations via systemctl reload without requiring a full service restart. The implementation removes the channel-based host operation system in favor of a simpler synchronous reload mechanism triggered by SIGHUP signals.

Key Changes:

  • Replaced async channel-based host management (Chan, OpAdd, OpRemove, OpPatch) with direct synchronous methods (SetHosts, Reload)
  • Added SIGHUP signal handler to trigger configuration reload for both config file hosts and hosts_dir YAML files
  • Implemented ExecReload directive in systemd service file to send SIGHUP to the main process

Reviewed Changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
pkg/host/root.go Replaced channel-based host operations with synchronous SetHosts/Reload methods; improved MatchFirstHost locking granularity; added InitComponents helper method; removed context import
pkg/host/README.md Added comprehensive documentation for reload functionality, including usage examples, behavior description, and limitations
config/crowdsec-spoa-bouncer.service Added ExecReload directive to send SIGHUP signal for graceful reload
cmd/root.go Added SIGHUP signal handler goroutine; replaced host channel operations with SetHosts call; reload re-reads config file and calls HostManager.Reload()
README.md Added brief reload feature summary with reference to detailed documentation

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +157 to +160
// Then, add config hosts (they override directory hosts)
for _, host := range configHosts {
allHosts[host.Host] = host
}
Copy link

Copilot AI Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Duplicate host handling could lead to unexpected behavior. When merging hosts from directory and config, if the same host.Host value exists in multiple files or in both sources, only the last one processed will be kept (line 159 overwrites). This could silently ignore duplicate configurations without warning the user.

Consider:

  1. Logging a warning when a duplicate host is detected
  2. Documenting this precedence behavior more clearly
  3. Validating for duplicates within the directory files themselves

Copilot uses AI. Check for mistakes.
Comment on lines +109 to +111
h.Lock()
h.cache[toMatch] = matchedHost
h.Unlock()
Copy link

Copilot AI Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential race condition: The cache write after releasing the read lock could result in stale data. Between releasing RUnlock() at line 101 and acquiring Lock() at line 109, another goroutine could reload hosts (via Reload() or SetHosts()), which clears the cache and replaces the host slice. This could lead to caching a stale host reference that no longer exists in the active host list.

Consider moving the cache update to occur while still holding the read lock (upgrade to write lock), or accept that the cache might contain stale entries and add validation when retrieving from cache.

Copilot uses AI. Check for mistakes.
Comment on lines +124 to +125
// Store hostsDir for future reloads
h.hostsDir = hostsDir
Copy link

Copilot AI Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hostsDir field is being written without proper synchronization. This field is written in loadHostsFromDirectory() which is called without holding the Manager's lock. Multiple concurrent calls to Reload() could race on this field update.

Consider moving the h.hostsDir = hostsDir assignment to occur under the Manager's lock (in SetHosts() or Reload()), or use atomic operations if this field needs to be accessed concurrently.

Suggested change
// Store hostsDir for future reloads
h.hostsDir = hostsDir
// Store hostsDir for future reloads
h.Lock()
h.hostsDir = hostsDir
h.Unlock()

Copilot uses AI. Check for mistakes.
Comment on lines 180 to +211
g.Go(func() error {
HostManager.Run(ctx)
return nil
})
for {
select {
case <-reloadChan:
log.Info("Received SIGHUP, reloading host configuration")

// Re-read config file to get updated hosts
var reloadConfigHosts []*host.Host
if *configPath != "" {
configMerged, err := cfg.MergedConfig(*configPath)
if err != nil {
log.WithError(err).Error("Failed to read config file during reload")
} else {
configExpanded := csstring.StrictExpand(string(configMerged), os.LookupEnv)
reloadConfig, err := cfg.NewConfig(strings.NewReader(configExpanded))
if err != nil {
log.WithError(err).Error("Failed to parse config file during reload")
} else {
reloadConfigHosts = reloadConfig.Hosts
}
}
}

for _, h := range config.Hosts {
HostManager.Chan <- host.HostOp{
Host: h,
Op: host.OpAdd,
if err := HostManager.Reload(reloadConfigHosts); err != nil {
log.WithError(err).Error("Failed to reload host configuration")
// Don't return error, just log it so the service continues running
}
case <-ctx.Done():
return nil
}
}
}
})
Copy link

Copilot AI Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing validation that HostManager.Reload() is not called before SetHosts(). The Reload() method relies on h.hostsDir being set, but if SIGHUP is received before SetHosts() is called (lines 213-216), h.hostsDir will be empty and the reload will silently skip loading from the directory.

Consider either:

  1. Adding a check in Reload() to ensure initialization has completed
  2. Deferring SIGHUP handler registration until after SetHosts() completes
  3. Documenting this initialization order requirement

Copilot uses AI. Check for mistakes.
@LaurenceJJones
Copy link
Member Author

due to #115 , this makes reloading hosts less of a risk for sessions and since reload only handles host configuration it doesnt seem wise to implement this.

closing for now till a better reason comes to implement such a system.

@LaurenceJJones LaurenceJJones deleted the feat/systemctl-reload-support branch December 9, 2025 16:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants