From af42ba3589e05a97dd8ab955dee30703caa13f87 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 28 Aug 2026 17:48:06 -0400 Subject: [PATCH] Register the endpoints list without a trailing slash, and stop hiding its errors An endpoint reported telemetry correctly for hours while the Fleet page showed "No endpoints found". The PC detail page displayed its panels the whole time, so ingest was plainly working; only the list was empty. The list route was the one route in 331 paths registered with a trailing slash: @endpoints_bp.route('/', methods=['GET']) -> /api/endpoints/ The browser client calls it without one, so Flask answered a 308 redirect to an ABSOLUTE url. On dev that resolves and nobody notices. Behind the IIS subpath it cannot: with no ProxyFix, Werkzeug builds that Location from the scheme and host it believes it has, so it emitted http:// and dropped the /shopdb prefix. A browser will not follow an https page to http, the request failed, and the page's catch block rendered the empty state. Every other route in the plugin worked throughout, because none of them carries a trailing slash - the detail panels call /endpoints/asset//status and were never redirected. Detail works, list empty is the signature of this bug. Registered as '' now, so the rule is /api/endpoints. That is what CONTRIBUTING.md's API section spells and what every other list route in the product already did. The inventory and the generated spec are updated to match. Also here: - tests/test_core/test_no_redirecting_routes.py fails the build if any /api rule is registered with a trailing slash and strict slashes. Verified by planting the violation: it named the offending route, then passed once reverted. It takes the shared app fixture rather than building its own application - doing that rebinds SQLAlchemy and leaves the NEXT module without tables, which showed up as three unrelated "no such table: settings" failures visible only in a full run. - The Fleet page now renders "Could not load endpoints (HTTP nnn). This is a failed request, not an empty fleet." A list that reports a 403 and an empty fleet identically is what made this take as long as it did. - TRUSTED_PROXY_COUNT (default 0) applies ProxyFix when set. Off by default on purpose: it makes X-Forwarded-* trusted, and a client can send those itself, while the login rate limit and account lockout are keyed on the client address. Set it to 1 on an instance behind IIS. Full suite 2124 passed, vitest 260 passed, naming and docs gates green. --- .env.example | 12 +++++ docs/api-inventory.json | 4 +- docs/openapi.json | 4 +- plugins/endpoints/api/routes.py | 16 ++++++- .../frontend/views/EndpointsList.vue | 25 +++++++++++ shopdb/__init__.py | 22 ++++++++++ shopdb/config.py | 9 ++++ tests/test_core/test_no_redirecting_routes.py | 44 +++++++++++++++++++ 8 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 tests/test_core/test_no_redirecting_routes.py diff --git a/.env.example b/.env.example index 68e9ebbe..891adb9a 100644 --- a/.env.example +++ b/.env.example @@ -36,6 +36,18 @@ DATABASE_URL=mysql+pymysql://shopdb:CHANGE_ME@db:3306/shopdb_flask?charset=utf8m # Wildcard '*' is rejected by ProductionConfig.validate(). CORS_ORIGINS=http://localhost:5173 +# ---- Reverse proxy (optional, default 0 = no proxy) ---- +# Set to the number of proxies actually in front of this instance - 1 when IIS +# or nginx serves it under a subpath such as /shopdb. Without it the app sees +# the proxy's connection instead of the browser's (scheme http, the proxy's +# host and IP), so any absolute URL it builds is wrong at the browser. That is +# what made a redirect on the endpoints list unfollowable behind IIS. +# +# Leave it at 0 when nothing fronts the app. It makes X-Forwarded-* trusted, +# and a client can send those headers itself - the login rate limit and the +# account lockout are keyed on the client address. +TRUSTED_PROXY_COUNT=0 + # ---- JWT lifecycle (optional, defaults shown) ---- JWT_ACCESS_TOKEN_EXPIRES=3600 JWT_REFRESH_TOKEN_EXPIRES=2592000 diff --git a/docs/api-inventory.json b/docs/api-inventory.json index f5b94efb..e162133c 100644 --- a/docs/api-inventory.json +++ b/docs/api-inventory.json @@ -3757,11 +3757,11 @@ "endpoints": [ { "method": "GET", - "path": "/api/endpoints/", + "path": "/api/endpoints", "auth": "endpoints.view", "params": "query: search (hostname substring)", "purpose": "List endpoints reporting through the ShopDB agent, with last-seen and online state", - "example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/" + "example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints" }, { "method": "GET", diff --git a/docs/openapi.json b/docs/openapi.json index 3fc3ff12..42be3046 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -21733,13 +21733,13 @@ ] } }, - "/api/endpoints/": { + "/api/endpoints": { "get": { "tags": [ "plugin-endpoints" ], "summary": "List endpoints reporting through the ShopDB agent, with last-seen and online state", - "description": "List endpoints reporting through the ShopDB agent, with last-seen and online state\n\n**Auth:** endpoints.view\n\n**Params:** query: search (hostname substring)\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints/\n```", + "description": "List endpoints reporting through the ShopDB agent, with last-seen and online state\n\n**Auth:** endpoints.view\n\n**Params:** query: search (hostname substring)\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/endpoints\n```", "security": [ { "bearerAuth": [] diff --git a/plugins/endpoints/api/routes.py b/plugins/endpoints/api/routes.py index 07355173..4dade2c8 100644 --- a/plugins/endpoints/api/routes.py +++ b/plugins/endpoints/api/routes.py @@ -55,11 +55,25 @@ def _format_uptime(seconds): # Endpoint state # ============================================================================= -@endpoints_bp.route('/', methods=['GET']) +@endpoints_bp.route('', methods=['GET']) @require_permission('endpoints.view') def list_endpoints(): """List reported endpoint states. + Registered as '' so the rule is '/api/endpoints', with no trailing slash. + CONTRIBUTING.md's API section spells endpoints that way ('/api/machines'), + and every other plugin's list route follows it - this was the only route in + 331 paths that did not. + + It was not cosmetic. A rule ending in a slash makes Flask answer the + unslashed URL with a 308 to an ABSOLUTE url. On dev that resolves and + nobody notices. Behind the IIS subpath it does not: with no ProxyFix, + Werkzeug builds that Location from the scheme and host it believes it has, + so it emitted http:// and dropped the /shopdb prefix. The browser refuses + to follow an https page to http, the list request failed, and the page + rendered "No endpoints found" - indistinguishable from a fleet with nothing + in it. The agent had been reporting correctly the whole time. + Joins through Computer so each row carries computerid and can deep-link into the existing PC detail page (/pcs/{computerid}). Endpoint state only exists for hostnames resolved to a Computer, so the join is not lossy. diff --git a/plugins/endpoints/frontend/views/EndpointsList.vue b/plugins/endpoints/frontend/views/EndpointsList.vue index 802ccbef..313cbd72 100644 --- a/plugins/endpoints/frontend/views/EndpointsList.vue +++ b/plugins/endpoints/frontend/views/EndpointsList.vue @@ -18,6 +18,10 @@
Loading...
+
+ {{ loadError }} +
+