Repository navigation
Router priority flaps non-deterministically for hosts with multiple Targets/paths (map iteration in findMatchingRouter) #113
Description
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] = routerGetMergedConfig 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-configon middleware-manager repeatedly (e.g. every 1–2 s for 30–60 s) while Pangolin's own config stays unchanged. - Diff the
priorityfield 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
matchesdeterministically before selecting (e.g., by rule specificity/length, or at least alphabetically by router name) instead of relying on map iteration order. - More robustly,
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. - In
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
- middleware-manager
v4.5.0(main@7eb1378d826ca203502332c61d14879bb1436f14) - Pangolin ≥1.10.0 (multi-target path routing)
Activity
strausmann commentedon Aug 3, 2026
Hi @hhftechnology — I've opened #114 with a fix for this.
Short version:
findMatchingRoutermatched routers onHost(...)only while iterating an unordered Go map, so for a host served by more than one non-redirect router (Pangolin's multi-target path routing — one resource, several Targets sharing aHost()but differingPathPrefix()), theRouterPriorityoverride could land on the wrong sibling router on any of the 5s config refreshes. That corrupted the sibling's priority and created a priority tie → sporadic misrouting on shared hosts.The PR sorts the matches deterministically and, when a host is genuinely ambiguous (multiple non-redirect routers), reports no match so the override is skipped rather than applied to a guessed router — leaving Pangolin's own priorities untouched, which is strictly safer.
It's validated live against a production Pangolin deployment (17 hosts with multiple Targets: 16 flapping 10–42 priority changes over 6 min → 0 with the fix; 101 single-target hosts unchanged), unit-tested (TDD, red→green), and both automated reviews (Copilot + CodeRabbit) came back clean.
Whenever you get a moment to take a look, that'd be much appreciated — happy to adjust anything. Thanks!
kylepyke commentedon Aug 13, 2026
Hey @hhftechnology, will this request be merged?
Fix: non-deterministic router priority for hosts with multiple Target…