From 6e72a157e8005c1abaf9afaea1dc6268aefe14d6 Mon Sep 17 00:00:00 2001 From: Cam Proudlock Date: Fri, 28 Aug 2026 17:48:21 -0400 Subject: [PATCH 1/6] Register the endpoints list without a trailing slash, and stop hiding its errors --- .env.example | 12 +++++ 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 +++++++++++++++++++ 7 files changed, 129 insertions(+), 3 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/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 }} +
+ @@ -52,7 +61,7 @@ * because a machine stops without it, and that fact was buried in the generic * relationships card near the bottom of the page. */ -import { ref, computed, onMounted, watch } from 'vue' +import { ref, computed, onMounted, watch, useSlots } from 'vue' import api from '@/api' const props = defineProps({ @@ -67,7 +76,10 @@ const props = defineProps({ const machines = ref([]) +const slots = useSlots() + const hasAnything = computed(() => + Boolean(slots.actions) || Boolean(props.liveness) || machines.value.length > 0 || (props.accessmethods && props.accessmethods.length > 0) || @@ -122,6 +134,14 @@ function collect(edge, other, found) { gap: 0.3rem; } +.triage-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + margin-left: auto; +} + .triage-label { font-size: 0.72rem; letter-spacing: 0.06em; diff --git a/plugins/computers/api/policy_routes.py b/plugins/computers/api/policy_routes.py index e8e749be..a515acc8 100644 --- a/plugins/computers/api/policy_routes.py +++ b/plugins/computers/api/policy_routes.py @@ -397,3 +397,51 @@ def get_effective_policy(asset_id: int): from ..services.policyresolver import effective_policy_for effective = effective_policy_for(asset_id) return success_response(effective.to_dict()) + + +@computers_bp.route('/asset//policypanel', methods=['GET']) +@require_permission('computers.view') +def policy_panel(asset_id: int): + """Keyvalue panel: which policy governs this endpoint, and what it allows. + + Provenance, not a control. It matters because the effective policy already + narrows the command allowlist, so it is the answer to "why is Start greyed + out on this PC" - which is otherwise invisible. + + Returns an EMPTY field list rather than the feature-gate 404 when the + feature is off. The panel contract hides a card with no fields; an error + would render a broken card on every site that has not enabled policies, + which is most of them. + """ + if not policy_flag_enabled(): + return success_response({'fields': []}) + + from ..services.policyresolver import effective_policy_for + effective = effective_policy_for(asset_id) + actions = effective.to_dict() + if not actions: + return success_response({'fields': []}) + + # One endpoint resolves through one winning business policy, but read the + # ids off the actions rather than assuming: a fold can draw from more than + # one configuration policy. + businesspolicyids = {a['businesspolicyid'] for a in actions.values() + if a.get('businesspolicyid')} + configpolicyids = {a['configurationpolicyid'] for a in actions.values() + if a.get('configurationpolicyid')} + + names = [] + if businesspolicyids: + names = [p.name for p in BusinessPolicy.query + .filter(BusinessPolicy.businesspolicyid.in_(businesspolicyids)).all()] + + gated = sum(1 for a in actions.values() if a.get('mode') == ACTION_MODE_GATE) + desired = sum(1 for a in actions.values() if a.get('mode') == ACTION_MODE_DESIRED) + + fields = [] + if names: + fields.append({'label': 'Policy', 'value': ', '.join(sorted(names))}) + fields.append({'label': 'Configuration policies', 'value': str(len(configpolicyids))}) + fields.append({'label': 'Allowed actions', 'value': str(gated)}) + fields.append({'label': 'Desired state', 'value': str(desired)}) + return success_response({'fields': fields}) diff --git a/plugins/computers/frontend/views/PCDetail.vue b/plugins/computers/frontend/views/PCDetail.vue index 45cd3647..3edc8625 100644 --- a/plugins/computers/frontend/views/PCDetail.vue +++ b/plugins/computers/frontend/views/PCDetail.vue @@ -2,9 +2,12 @@
diff --git a/plugins/computers/plugin.py b/plugins/computers/plugin.py index c724fa54..0b3c10cb 100644 --- a/plugins/computers/plugin.py +++ b/plugins/computers/plugin.py @@ -1967,6 +1967,29 @@ class ComputersPlugin(BasePlugin): }, ] + def get_asset_panels(self) -> List[Dict]: + """Policy provenance on the PC detail page. + + The effective policy already narrows which commands an endpoint will + accept, so without this the reason a control is unavailable is not + visible anywhere. Right column, near the services it governs: this is + provenance, not a 6am triage signal. + + The route returns an empty field list when the policy feature is off, + so the card disappears on sites that have not enabled it rather than + rendering an error. + """ + return [ + { + 'id': 'computers-policy', + 'title': 'Policy', + 'assettypes': ['computer'], + 'endpoint': '/api/computers/asset/{assetid}/policypanel', + 'render': 'keyvalue', + 'position': 40, + }, + ] + def get_navigation_items(self) -> List[Dict]: """Return navigation menu items.""" return [ diff --git a/plugins/endpoints/frontend/views/EndpointsList.vue b/plugins/endpoints/frontend/views/EndpointsList.vue index 6cf87bf1..22f08e81 100644 --- a/plugins/endpoints/frontend/views/EndpointsList.vue +++ b/plugins/endpoints/frontend/views/EndpointsList.vue @@ -7,7 +7,7 @@
- + +
@@ -36,6 +41,18 @@ + +