Summary
When a Pangolin resource uses multi-target path-based routing (one host, multiple Targets each with a different PathPrefix, producing multiple Traefik routers that all share the same Host(...) in their rule), middleware-manager's /api/v1/traefik-config endpoint returns a different priority value for the same router on different requests, even though the upstream Pangolin config is byte-identical between requests. This happens roughly once per 5-second cache cycle (matching ConfigProxy's cacheDuration).
Because Traefik polls this endpoint continuously and rebuilds its router table on any content change, this causes:
- Constant, unnecessary router-table rebuilds (content changes every ~5s even when nothing changed upstream).
- A real routing bug: when the catch-all router's priority is corrupted to match the more specific router's priority, a priority tie between the two routers is possible, and Traefik's tie-break can transiently route requests meant for the specific path to the catch-all router instead.
Root cause (source-verified against main @ 7eb1378d826ca203502332c61d14879bb1436f14 / v4.5.0)
services/config_proxy.go, function findMatchingRouter (~line 989):
func (cp *ConfigProxy) findMatchingRouter(routers map[string]interface{}, host string) (string, map[string]interface{}) {
...
for routerName, routerConfig := range routers { // unordered Go map iteration
...
if len(hostMatches) > 1 && hostMatches[1] == host {
matches = append(matches, matchedRouter{name: routerName, router: router})
}
}
...
for _, m := range matches { // order == iteration order above == random per call
if !strings.HasSuffix(m.name, "-redirect") {
...
return m.name, m.router // first match wins
}
}
return matches[0].name, matches[0].router
}
The host-matching regex only checks Host(...), not PathPrefix(...) or any other rule predicate. When two routers share the same host (multi-target routing), both end up in matches, and the function returns whichever one Go's randomized map iteration happens to visit first — which changes from call to call.
This function is reached from applyResourceOverrides (~line 488) only as a fallback when the direct pangolin_router_id lookup (findRouterByPangolinID, a deterministic map-key lookup) fails:
routerKey, router := cp.findRouterByPangolinID(config.HTTP.Routers, resource.PangolinRouterID)
if routerKey == "" {
routerKey, router = cp.findMatchingRouter(config.HTTP.Routers, resource.Host) // non-deterministic path
}
...
if resource.RouterPriority != 100 {
router["priority"] = resource.RouterPriority // mutates whichever router was (randomly) matched
}
...
config.HTTP.Routers[routerKey] = router
GetMergedConfig re-fetches and re-deserializes the full Pangolin config into a new Go map every time its 5-second cache expires (cacheDuration: 5 * time.Second), so this non-deterministic path gets a fresh random iteration order roughly every 5 seconds — matching the observed churn interval exactly.
A likely contributing factor: services/resource_watcher.go, updateOrCreateResource step 2 (SELECT id, status FROM resources WHERE host = ? AND status = 'active') has no disambiguation for multiple active resources sharing the same host, so it can rebind an existing resource row's pangolin_router_id to the wrong router when two routers share a host — increasing how often the direct-ID lookup in findRouterByPangolinID fails and the non-deterministic host-fallback above gets triggered.
Reproduction
- Configure a Pangolin resource with two targets on the same host, one matching
/ (catch-all, lower priority, e.g. 10) and one matching a specific path like /app (higher priority, e.g. 200) — Pangolin's multi-target path routing (supported since Pangolin 1.10.0).
- Poll
GET /api/v1/traefik-config on middleware-manager repeatedly (e.g. every 1–2 s for 30–60 s) while Pangolin's own config stays unchanged.
- Diff the
priority field of the catch-all router across responses.
Expected: priority for the catch-all router stays constant (matching Pangolin's own value, e.g. 10) across all polls, since nothing changed upstream.
Actual: priority for the catch-all router intermittently flips to the specific router's value (e.g. 200), then back to 10, with no change in Pangolin's own config in between.
Impact
- Traefik rebuilds its router table far more often than necessary (every ~5s instead of only on real config changes), since the returned JSON differs even when nothing meaningful changed.
- When the catch-all router's priority collides with the specific router's priority, a priority tie exists between two routers for the same host — Traefik's tie-break behavior then determines which router wins, which can transiently misroute requests intended for the specific path to the catch-all router.
Suggested fix
- Sort
matches deterministically before selecting (e.g., by rule specificity/length, or at least alphabetically by router name) instead of relying on map iteration order.
- More robustly,
findMatchingRouter should also compare the non-Host part of the rule (PathPrefix, etc.) so that a resource is only ever matched to the router that actually corresponds to it, not just "any router with this host" — this fallback should really only be a safety net for a genuinely changed router ID, not a mechanism to arbitrate between multiple routers sharing a host.
- In
resource_watcher.go, the host-based fallback in updateOrCreateResource should not rebind an existing resource row's pangolin_router_id when multiple active rows share the same host without additional disambiguation (e.g., matching on rule/path too).
Environment
- middleware-manager
v4.5.0 (main @ 7eb1378d826ca203502332c61d14879bb1436f14)
- Pangolin ≥1.10.0 (multi-target path routing)
Summary
When a Pangolin resource uses multi-target path-based routing (one host, multiple Targets each with a different
PathPrefix, producing multiple Traefik routers that all share the sameHost(...)in their rule),middleware-manager's/api/v1/traefik-configendpoint returns a differentpriorityvalue for the same router on different requests, even though the upstream Pangolin config is byte-identical between requests. This happens roughly once per 5-second cache cycle (matchingConfigProxy'scacheDuration).Because Traefik polls this endpoint continuously and rebuilds its router table on any content change, this causes:
Root cause (source-verified against
main@7eb1378d826ca203502332c61d14879bb1436f14/v4.5.0)services/config_proxy.go, functionfindMatchingRouter(~line 989):The host-matching regex only checks
Host(...), notPathPrefix(...)or any other rule predicate. When two routers share the same host (multi-target routing), both end up inmatches, and the function returns whichever one Go's randomized map iteration happens to visit first — which changes from call to call.This function is reached from
applyResourceOverrides(~line 488) only as a fallback when the directpangolin_router_idlookup (findRouterByPangolinID, a deterministic map-key lookup) fails:GetMergedConfigre-fetches and re-deserializes the full Pangolin config into a new Go map every time its 5-second cache expires (cacheDuration: 5 * time.Second), so this non-deterministic path gets a fresh random iteration order roughly every 5 seconds — matching the observed churn interval exactly.A likely contributing factor:
services/resource_watcher.go,updateOrCreateResourcestep 2 (SELECT id, status FROM resources WHERE host = ? AND status = 'active') has no disambiguation for multiple active resources sharing the same host, so it can rebind an existing resource row'spangolin_router_idto the wrong router when two routers share a host — increasing how often the direct-ID lookup infindRouterByPangolinIDfails and the non-deterministic host-fallback above gets triggered.Reproduction
/(catch-all, lower priority, e.g.10) and one matching a specific path like/app(higher priority, e.g.200) — Pangolin's multi-target path routing (supported since Pangolin 1.10.0).GET /api/v1/traefik-configon middleware-manager repeatedly (e.g. every 1–2 s for 30–60 s) while Pangolin's own config stays unchanged.priorityfield of the catch-all router across responses.Expected:
priorityfor the catch-all router stays constant (matching Pangolin's own value, e.g.10) across all polls, since nothing changed upstream.Actual:
priorityfor the catch-all router intermittently flips to the specific router's value (e.g.200), then back to10, with no change in Pangolin's own config in between.Impact
Suggested fix
matchesdeterministically before selecting (e.g., by rule specificity/length, or at least alphabetically by router name) instead of relying on map iteration order.findMatchingRoutershould also compare the non-Hostpart of the rule (PathPrefix, etc.) so that a resource is only ever matched to the router that actually corresponds to it, not just "any router with this host" — this fallback should really only be a safety net for a genuinely changed router ID, not a mechanism to arbitrate between multiple routers sharing a host.resource_watcher.go, the host-based fallback inupdateOrCreateResourceshould not rebind an existing resource row'spangolin_router_idwhen multiple active rows share the same host without additional disambiguation (e.g., matching on rule/path too).Environment
v4.5.0(main@7eb1378d826ca203502332c61d14879bb1436f14)