Skip to content

Fix: Configuration auto-refresh in-memory sync and SSE event integration#98

Merged
Dumbris merged 1 commit into
mainfrom
fix/config-auto-refresh-sync
Oct 22, 2025
Merged

Fix: Configuration auto-refresh in-memory sync and SSE event integration#98
Dumbris merged 1 commit into
mainfrom
fix/config-auto-refresh-sync

Conversation

@Dumbris

@Dumbris Dumbris commented Oct 22, 2025

Copy link
Copy Markdown
Member

Summary

Fixes critical bugs in the configuration auto-refresh feature that prevented real-time updates in the Web UI Configuration page.

Problem: When adding servers via Web UI, the configuration file was updated but the /api/v1/config endpoint returned stale data, causing Monaco editor to not show new servers until manual reload.

Root Cause: In SaveConfiguration(), the in-memory config update only happened in the legacy else branch, not in the configSvc path, leading to API responses with outdated data.

Changes

Backend Fixes (internal/runtime/)

  • lifecycle.go (lines 476-522):
    • ✅ Fixed in-memory config sync by moving r.cfg.Servers = latestServers outside the if/else block
    • ✅ Added comprehensive debug logging (before/after disk save, old vs new server counts)
    • ✅ Added emitConfigSaved() call to broadcast config.saved SSE events
  • event_bus.go: Added emitConfigSaved() method for SSE integration
  • events.go: Added EventTypeConfigSaved constant for config save events
  • event_bus_test.go: Unit tests for event bus subscription and emission

Frontend Integration (frontend/src/)

  • stores/system.ts (lines 143-154):
    • ✅ Added SSE listener for config.saved events
    • ✅ Dispatches CustomEvent mcpproxy:config-saved to notify Settings view
  • views/Settings.vue (lines 334-353):
    • ✅ Added handleConfigSaved() event listener
    • ✅ Calls loadConfig() when config.saved event received
    • ✅ Proper cleanup in onUnmounted() hook

Testing (internal/)

  • testutil/binary.go: Added GetConfigPath() helper for E2E tests
  • server/e2e_config_auto_refresh_test.go: Integration test structure for config auto-refresh

Test Plan

Manual Testing with Playwright ✅

  1. Started mcpproxy server with debug logging
  2. Opened Web UI and added test server "test-fix-verification"
  3. Verified config.saved SSE event received in console: {payload: Object, timestamp: 1761143174}
  4. Navigated to Configuration page
  5. Confirmed Monaco editor contains new server immediately

Results:

  • ✅ Server saved to disk config file
  • config.saved SSE event broadcast
  • ✅ Frontend listener received event
  • ✅ Monaco editor auto-reloaded with fresh data
  • ✅ API returns updated configuration

Event Flow Verification

User adds server → SaveConfiguration() → Disk write success → 
In-memory update (r.cfg.Servers) → emitConfigSaved() → 
SSE broadcast "config.saved" → Frontend listener → loadConfig() → 
Monaco editor refreshed

Affected Components

  • Configuration persistence (BBolt → disk → in-memory)
  • SSE event system (runtime → httpapi → frontend)
  • Web UI Configuration page (Settings.vue)
  • API endpoints (/api/v1/config)

Breaking Changes

None. This is a bug fix that ensures existing functionality works as designed.

Backwards Compatibility

✅ Fully backwards compatible

  • Legacy config save path still supported
  • No API contract changes
  • No configuration schema changes

Performance Impact

Minimal overhead:

  • One additional mutex lock/unlock per config save (~microseconds)
  • One SSE event broadcast per config save
  • Frontend event listener with automatic cleanup

🤖 Generated with Claude Code

This PR fixes critical bugs in the configuration auto-refresh feature that prevented real-time updates in the Web UI Configuration page.

## Changes

### Backend (internal/runtime/)
- **lifecycle.go**: Fixed in-memory config sync bug where configSvc path didn't update r.cfg.Servers
  - Moved mutex-protected update outside if/else to apply to both configSvc and legacy paths
  - Added comprehensive debug logging (before/after disk save, old vs new server counts)
  - Added emitConfigSaved() call to broadcast config.saved events
- **event_bus.go**: Added emitConfigSaved() method for SSE integration
- **events.go**: Added EventTypeConfigSaved constant
- **event_bus_test.go**: Unit tests for event bus subscription and emission

### Frontend (frontend/src/)
- **stores/system.ts**: Added SSE listener for config.saved events
  - Dispatches CustomEvent 'mcpproxy:config-saved' to notify Settings view
- **views/Settings.vue**: Added event listener and cleanup
  - Calls loadConfig() when config.saved event received
  - Proper onMounted/onUnmounted lifecycle hooks

### Testing (internal/)
- **testutil/binary.go**: Added GetConfigPath() helper for E2E tests
- **server/e2e_config_auto_refresh_test.go**: Integration test structure for config auto-refresh

## Bug Fix Details

**Problem**: When adding servers via Web UI, the configuration file was updated but the /api/v1/config endpoint returned stale data, causing Monaco editor to not show new servers.

**Root Cause**: In SaveConfiguration(), the in-memory config update only happened in the legacy else branch, not in the configSvc path.

**Solution**: Moved the in-memory update outside the conditional to ensure both paths update r.cfg.Servers.

## Verification

Tested with Playwright E2E:
- ✅ Server added via Web UI appears in config file
- ✅ config.saved SSE event received in browser console
- ✅ Monaco editor automatically reloads and displays new server
- ✅ API endpoint returns fresh data immediately

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@Dumbris Dumbris merged commit 55eb4b9 into main Oct 22, 2025
34 checks passed
@Dumbris Dumbris deleted the fix/config-auto-refresh-sync branch October 22, 2025 14:51
Dumbris added a commit that referenced this pull request Jun 22, 2026
…1076) (#745)

* feat(registries): harden registry fetches against SSRF (CWE-918, MCP-1076)

Registry server-list fetches use a user-supplied URL (`registry add-source`),
which CodeQL flags as go/request-forgery (#97/#98): a malicious or typo'd
registry source could point the daemon at internal/metadata endpoints
(169.254.169.254, RFC1918 ranges).

Add an SSRF guard centered on a single `isBlockedIP` predicate (loopback,
RFC1918, RFC6598 CGNAT, link-local incl. the cloud-metadata endpoint,
IPv6 unique-local, unspecified, multicast; fails closed), applied at three
layers:

- dial time: a net.Dialer Control hook on the shared registry HTTP client
  rejects the actual resolved IP before connect — covers hostnames that resolve
  into blocked ranges and closes the DNS-rebinding TOCTOU window a parse-time
  check alone cannot.
- fetch pre-flight: validateRegistryURL rejects a literal-IP host (defense in
  depth + an explicit barrier at the http.NewRequest sink).
- add-source/edit-source: literal-IP fail-fast (no DNS, stays pure/offline) so a
  bad source is refused up front with the stable invalid_registry_url code.

Opt out with a new top-level `allow_private_registry_fetch` config flag
(default false) for operators who run a trusted registry mirror on an
internal/private address.

TDD: table tests for every blocked/allowed range (incl. CGNAT and RFC1918
boundaries), an end-to-end loopback dial block, add-source literal-IP rejection,
and the two registry-add E2E daemon tests opt in via the new flag.

* test(server): opt loopback registry fetch tests past the SSRF guard

The in-process server tests that fetch a registry served on a loopback httptest
server (consistency_official, mcp_add_from_registry's startTestRegistry helper)
now hit the new SSRF dial guard, because the registries-package test bypass does
not apply to the server package's test binary. Set AllowPrivateRegistryFetch on
their SetRegistriesFromConfig configs — exactly how an operator opts in for a
trusted internal mirror — so the fetch is permitted while the production guard
stays active for the add-source rejection test.

Fixes the linux/arm64 Build Binaries leg (TestCrossSurfaceConsistency_*,
TestHandleUpstreamServers_AddFromRegistry_*).

* fix(registries): close SSRF proxy-bypass with app-layer host resolution

CodexReviewer (round 2) found a real gap: the registry client clones
http.DefaultTransport, which keeps Proxy: ProxyFromEnvironment. With
HTTP_PROXY/HTTPS_PROXY set, the dialer connects to the PROXY as the first hop,
so registryDialControl only ever validates the proxy's IP — never the real
target. A hostname resolving to loopback/private/link-local (incl.
169.254.169.254) could still be fetched through a proxy.

Add an application-layer guard (guardRegistryTargetHost) that resolves the
target host and rejects the fetch if ANY resolved IP is in a blocked range. It
runs before the request in registryGet, independent of the transport/proxy, so
it holds in the proxy case; the dial-time Control stays as defense-in-depth for
the direct path and DNS-rebind TOCTOU. Fail-open on resolver error (a flaky
resolver must not break every fetch); literal IPs already covered pre-flight;
relaxed by the allow_private_registry_fetch opt-in.

Regression coverage: a public-looking hostname resolving to 169.254.169.254 is
refused with HTTP(S)_PROXY set; plus direct predicate tests (mixed
public+private resolution blocked, all-public allowed, resolver-error fail-open,
public literal allowed). Resolver is injectable via a package var for
deterministic, network-free tests.
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.

1 participant