Gate the USB kiosk the same way, and make an unreachable setting a test failure

The USB kiosk had a feature flag and nothing else. A flag is a decision, not a
credential: with the kiosk on, anyone who could reach the app could move a drive
between checked-out and checked-in and sign it with a badge they guessed. Same
reasoning that closed the parts kiosk, smaller blast radius - which is why
Darkwing did not raise it and why it was worth asking about anyway.

Both kiosks now share one mechanism. The device token moved out of the
printedparts api wrapper into a request interceptor that attaches X-Kiosk-Token
by URL prefix, with one localStorage key per kiosk - they are separate screens
in separate rooms, and a shared token would mean provisioning one grants the
other. claimKioskToken() is shared too, so both views take the token from a
?token= URL once and strip it from the address bar identically.

Then the part that matters more than either fix. I told you to set the new
settings in the UI, and that was impossible: every plugin settings page carries
a hardcoded key list, and nothing had added them. printedparts_kiosk_enabled,
printedparts_kiosk_token and usb_kiosk_token were declared, seeded and read
correctly, and rendered by no page - changeable only by writing SQL.

tests/test_plugin_settings_reachable.py now fails when any declared setting
appears nowhere in its plugin's frontend. It found seven MORE that were already
in that state, all pre-existing:

  collector_corporate_ranges          computers_configpolicies_enabled
  computers_machinelink_alerts        computers_quietreporthours
  geenforce_reportstaleminutes        printers_dashboardpercent
  warranty_expiringdays

All seven now have fields. I first parked them in a KNOWN_GAPS ratchet, then
fixed them and deleted the ratchet - a backlog list in a new check is a way of
agreeing the check does not apply yet. There is no backlog; the check is
absolute.

Two of my own mistakes on the way, both caught by tests rather than by reading:
the blanket rename of the USB guard also rewrote the call inside _kiosk_open, so
it called itself until the stack ran out; and the first version of the reachable
check skipped all 17 plugins because it looked for get_settings() when the
method is get_settings_defaults() - it passed by testing nothing, which is the
exact shape it exists to catch.

Suite 2336, frontend 316, build clean.

Scope: product
This commit is contained in:
cproudlock
2026-09-02 18:08:34 -04:00
parent e4db112296
commit 7f6aaf2e64
13 changed files with 493 additions and 65 deletions

View File

@@ -10,6 +10,50 @@
is nothing in it to find and edit.
</p>
<div class="card form-card">
<h3>Collector behaviour</h3>
<div class="form-group">
<label>Corporate ranges (CIDR)</label>
<input v-model="extra.collector_corporate_ranges" type="text"
class="form-control" placeholder="10.0.0.0/8, 192.168.0.0/16" />
<p class="field-hint">
Comma-separated CIDRs naming this site's routable networks. The server
picks a PC's primary address from these when the agent reports several.
</p>
</div>
<div class="form-group">
<label>Quiet report hours</label>
<input v-model.number="extra.computers_quietreporthours" type="number"
min="1" class="form-control" />
<p class="field-hint">
Hours without a collector report before a PC counts as silent on the
dashboard.
</p>
</div>
<div class="form-group">
<label class="check-row">
<input type="checkbox" v-model="machinelinkAlerts" />
<span>Alert when a PC takes over another PC's machine</span>
</label>
</div>
<div class="form-group">
<label class="check-row">
<input type="checkbox" v-model="configPoliciesEnabled" />
<span>Configuration and business policies</span>
</label>
<p class="field-hint">
Master switch for the RMM-style policy pages. Off by default.
</p>
</div>
<button class="btn btn-primary" :disabled="savingExtra" @click="saveExtra">
{{ savingExtra ? 'Saving...' : 'Save' }}
</button>
</div>
<div class="card form-card">
<div v-if="message" class="settings-success">{{ message }}</div>
<div v-if="error" class="error-message">{{ error }}</div>
@@ -61,13 +105,57 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { settingsApi } from '@/api'
import api from '@/api'
const RANGES_KEY = 'computers_routableranges'
const ranges = ref('')
// The rest of the plugin's settings. They were declared and seeded but rendered
// nowhere, so a site could only change them with SQL - see
// tests/test_plugin_settings_reachable.py, written after that shipped twice.
const EXTRA_KEYS = [
'collector_corporate_ranges',
'computers_quietreporthours',
'computers_machinelink_alerts',
'computers_configpolicies_enabled',
]
const extra = ref({
collector_corporate_ranges: '',
computers_quietreporthours: 24,
computers_machinelink_alerts: 'false',
computers_configpolicies_enabled: 'false',
})
const savingExtra = ref(false)
// Settings store strings; bridge the checkboxes rather than letting a boolean
// round-trip into the string 'True'.
function boolSetting(key) {
return computed({
get: () => String(extra.value[key]).toLowerCase() === 'true',
set: (on) => { extra.value[key] = on ? 'true' : 'false' },
})
}
const machinelinkAlerts = boolSetting('computers_machinelink_alerts')
const configPoliciesEnabled = boolSetting('computers_configpolicies_enabled')
async function saveExtra() {
savingExtra.value = true
error.value = ''
message.value = ''
try {
for (const key of EXTRA_KEYS) {
await settingsApi.update(key, String(extra.value[key]))
}
message.value = 'Saved.'
} catch (saveError) {
error.value = saveError?.response?.data?.data?.error?.message || 'Save failed.'
} finally {
savingExtra.value = false
}
}
const saving = ref(false)
const downloading = ref(false)
const digest = ref('')
@@ -79,6 +167,9 @@ onMounted(async () => {
const response = await settingsApi.list({ category: 'computers' })
const row = (response.data.data || []).find(entry => entry.key === RANGES_KEY)
if (row) ranges.value = row.value || ''
for (const entry of response.data.data || []) {
if (EXTRA_KEYS.includes(entry.key)) extra.value[entry.key] = entry.value
}
} catch (loadError) {
error.value = 'Could not load settings'
console.error(loadError)

View File

@@ -1,5 +1,18 @@
<template>
<div class="geenforce-settings">
<h2>Reporting</h2>
<p class="muted">
How long a bay may go without posting an enforcement report before the
fleet view calls it stale.
</p>
<label class="field-label" for="stale">Stale after (minutes)</label>
<input id="stale" type="number" min="1" class="cidr-input"
v-model.number="staleminutes" @blur="saveStale">
<small class="input-hint">
Default 30. A bay enforces on its own cycle, so set this above the cycle
length or every bay reads as stale between runs.
</small>
<h2>Client Access</h2>
<p class="muted">
Fleet PCs on a trusted network can reach the GE-Enforce client endpoints
@@ -40,9 +53,12 @@
<script setup>
import { ref, onMounted } from 'vue'
import api from '@/api'
import api, { settingsApi } from '@/api'
const cidrs = ref('')
// Held here rather than in /geenforce/config: that endpoint owns the CIDR
// allowlist, and this is an ordinary site setting.
const staleminutes = ref(30)
const saving = ref(false)
const message = ref('')
const ok = ref(false)
@@ -57,6 +73,23 @@ async function load() {
message.value = 'Could not load config.'
ok.value = false
}
try {
const setting = unwrap(await settingsApi.get('geenforce_reportstaleminutes'))
staleminutes.value = Number(setting?.value ?? 30) || 30
} catch (e) {
staleminutes.value = 30
}
}
async function saveStale() {
try {
await settingsApi.update('geenforce_reportstaleminutes', String(staleminutes.value))
ok.value = true
message.value = 'Saved.'
} catch (e) {
ok.value = false
message.value = e?.response?.data?.data?.error?.message || 'Save failed.'
}
}
async function save() {

View File

@@ -92,7 +92,7 @@
<script setup>
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { printedpartsApi, settingsApi, KIOSK_TOKEN_KEY } from '@/api'
import { printedpartsApi, settingsApi, claimKioskToken, KIOSK_TOKEN_KEYS } from '@/api'
import { withBase } from '@/utils/basePath'
import TouchKeypad from '@/components/TouchKeypad.vue'
@@ -116,28 +116,8 @@ const scannedRevision = ref(null)
const entryInput = ref(null)
let resetTimer = null
// Claim the device token, once, from the URL an admin used to set this screen
// up: /parts-kiosk?token=<value>. It is kept in THIS browser and stripped from
// the address bar immediately, so it does not sit in the URL for a shoulder to
// read, and does not travel in a bookmark or a screenshot. Every kiosk call
// then carries it as X-Kiosk-Token; without it the routes answer 404.
function claimTokenFromUrl() {
try {
const url = new URL(window.location.href)
const token = (url.searchParams.get('token') || '').trim()
if (!token) return
localStorage.setItem(KIOSK_TOKEN_KEY, token)
url.searchParams.delete('token')
window.history.replaceState({}, '', url.pathname + url.search + url.hash)
} catch {
// A browser refusing localStorage is a browser this kiosk cannot be
// provisioned in. Nothing to recover here; the routes will 404 and the
// operator sees the standard error.
}
}
onMounted(async () => {
claimTokenFromUrl()
claimKioskToken(KIOSK_TOKEN_KEYS['/printedparts/kiosk/'])
// A kiosk left running for weeks reads this once at load, which is fine: the
// label format is a property of the site's physical tags, not something that
// changes during a shift.

View File

@@ -36,6 +36,38 @@
<p class="field-hint">Seed value for new items; each item can override.</p>
</div>
<div class="form-group">
<label>
<input type="checkbox" v-model="kioskEnabled" />
Serve the parts kiosk
</label>
<p class="field-hint">
Off by default. The kiosk routes are the only ones that write without
a login, so a site opts in.
</p>
</div>
<div class="form-group">
<label>Kiosk device token</label>
<div class="token-row">
<input v-model="values.printedparts_kiosk_token" type="text"
class="form-control" placeholder="not set" />
<button type="button" class="btn btn-secondary" @click="generateToken">
Generate
</button>
</div>
<p class="field-hint">
Each kiosk screen is provisioned once by opening
<code>/parts-kiosk?token=&lt;this value&gt;</code> on it. The browser
keeps it; the address bar does not. A screen without it gets a 404,
and so does anyone else who finds the route.
</p>
<p v-if="tokenJustGenerated" class="field-hint token-warn">
Copy this now and save. It is masked once stored, so this is the only
time it is shown.
</p>
</div>
<div class="form-group">
<label>Unknown badge at the kiosk</label>
<select v-model="values.printedparts_unknown_badge" class="form-control">
@@ -109,13 +141,15 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { settingsApi, usersApi, supportteamsApi } from '@/api'
const KEYS = [
'printedparts_code_prefix',
'printedparts_label_prefix',
'printedparts_default_threshold',
'printedparts_kiosk_enabled',
'printedparts_kiosk_token',
'printedparts_unknown_badge',
'printedparts_alert_email',
'printedparts_alert_userids',
@@ -127,12 +161,33 @@ const values = ref({
printedparts_code_prefix: '3DP',
printedparts_label_prefix: '',
printedparts_default_threshold: 5,
printedparts_kiosk_enabled: 'false',
printedparts_kiosk_token: '',
printedparts_unknown_badge: 'deny',
printedparts_alert_email: '',
printedparts_alert_userids: '',
printedparts_alert_roleids: '',
printedparts_alert_supportteamid: ''
})
// Settings are stored as strings. Bridge the checkbox to that rather than
// letting a boolean reach the API and come back as the string 'True'.
const kioskEnabled = computed({
get: () => String(values.value.printedparts_kiosk_enabled).toLowerCase() === 'true',
set: (on) => { values.value.printedparts_kiosk_enabled = on ? 'true' : 'false' },
})
const tokenJustGenerated = ref(false)
// A token an operator would not guess and does not have to invent. Generated in
// the browser so it never travels anywhere it did not need to.
function generateToken() {
const bytes = new Uint8Array(24)
crypto.getRandomValues(bytes)
values.value.printedparts_kiosk_token =
Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
tokenJustGenerated.value = true
}
const supportTeams = ref([])
const users = ref([])
const selectedUserids = ref([])
@@ -188,6 +243,10 @@ async function save() {
</script>
<style scoped>
.token-row { display: flex; gap: 8px; align-items: center; }
.token-row .form-control { flex: 1 1 auto; }
.token-warn { color: var(--warning-dark); }
.field-hint { color: var(--text-light); font-size: 0.85rem; margin-top: 0.25rem; }
.user-picker {
max-height: 12rem;

View File

@@ -26,6 +26,16 @@
<input v-model.number="values.printers_alert_critical_threshold"
type="number" min="0" max="100" class="form-control" />
</div>
<div class="form-group">
<label>Dashboard threshold (% remaining)</label>
<input v-model.number="values.printers_dashboardpercent"
type="number" min="0" max="100" class="form-control" />
<p class="field-hint">
At or below this, a printer's supply shows on the dashboard. Separate
from the alert thresholds above: this one only decides what is
listed, it sends nothing.
</p>
</div>
</div>
<div class="form-group">
@@ -101,7 +111,8 @@ const KEYS = [
'printers_alert_roleids',
'printers_alert_supportteamid',
'printers_alert_warning_threshold',
'printers_alert_critical_threshold'
'printers_alert_critical_threshold',
'printers_dashboardpercent'
]
const values = ref({
@@ -110,6 +121,7 @@ const values = ref({
printers_alert_roleids: '',
printers_alert_supportteamid: '',
printers_alert_warning_threshold: 5,
printers_dashboardpercent: 5,
printers_alert_critical_threshold: 0
})
const supportTeams = ref([])

View File

@@ -24,6 +24,8 @@ import logging
import re
from datetime import datetime, timedelta
import hmac
from flask import Blueprint, request, Response
from flask_jwt_extended import jwt_required
@@ -741,6 +743,31 @@ def _kiosk_enabled():
return _kiosk_setting('usb_kiosk_enabled', False)
def _kiosk_open():
"""True when the kiosk is on for this site AND the caller has its token.
The flag alone was the whole gate, and a flag is a decision, not a
credential: with the kiosk on, anyone who could reach the app could move a
device between checked-out and checked-in and have it recorded against a
badge they guessed. A badge is a short numeric SSO, so it is attribution,
never authorization - the same reasoning that closed the parts kiosk.
The token is per-DEVICE. A kiosk is a browser on a screen by the cabinet, so
a token served inside the page would be readable by anyone who could load
it. An admin opens the screen once as /usb-kiosk?token=..., the page keeps
it in that browser and sends it as X-Kiosk-Token.
"""
if not _kiosk_enabled():
return False
expected = (Setting.get('usb_kiosk_token') or '').strip()
presented = (request.headers.get('X-Kiosk-Token') or '').strip()
if not expected or not presented:
return False
# compare_digest, not ==: == leaks the token's length and matching prefix
# through timing.
return hmac.compare_digest(presented, expected)
def _kiosk_disabled_response():
# 404, not 403: when the kiosk is off these routes should not read as
# "there is a door here and you may not use it".
@@ -765,7 +792,7 @@ def kiosk_device(device_id):
the person standing there cannot tell whether they are doing the right
thing. No badge number, no history, no contact details.
"""
if not _kiosk_enabled():
if not _kiosk_open():
return _kiosk_disabled_response()
if _usb_selfhosted():
return selfhosted.kiosk_device(device_id)
@@ -825,7 +852,7 @@ def kiosk_session():
COUNT of everything else - names for those are a separate, deliberate step,
so a passer-by does not read the whole cabinet's holders off a wall screen.
"""
if not _kiosk_enabled():
if not _kiosk_open():
return _kiosk_disabled_response()
data = request.get_json() or {}
if _usb_selfhosted():
@@ -881,7 +908,7 @@ def kiosk_others():
who is on leave - but the names that makes visible should not be the first
thing on a shopfloor screen.
"""
if not _kiosk_enabled():
if not _kiosk_open():
return _kiosk_disabled_response()
if not _kiosk_setting('usb_kiosk_allow_return_for_others', True):
return _kiosk_disabled_response()
@@ -925,7 +952,7 @@ def kiosk_others():
@usb_bp.route('/kiosk/checkout', methods=['POST'])
def kiosk_checkout():
"""Check a device out at the kiosk. Body: {device_id, badge}."""
if not _kiosk_enabled():
if not _kiosk_open():
return _kiosk_disabled_response()
data = request.get_json() or {}
if _usb_selfhosted():
@@ -998,7 +1025,7 @@ def kiosk_checkin():
handed the device back, which is the honest record and needs no change to a
schema this app does not own.
"""
if not _kiosk_enabled():
if not _kiosk_open():
return _kiosk_disabled_response()
data = request.get_json() or {}
if _usb_selfhosted():

View File

@@ -133,7 +133,7 @@
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import api, { settingsApi } from '@/api'
import api, { settingsApi, claimKioskToken, KIOSK_TOKEN_KEYS } from '@/api'
import TouchKeypad from '@/components/TouchKeypad.vue'
import { filterdrives } from '../drivefilter'
@@ -150,6 +150,7 @@ const entryInput = ref(null)
let resetTimer = null
onMounted(async () => {
claimKioskToken(KIOSK_TOKEN_KEYS['/usb/kiosk/'])
// The kiosk ships off. Reading the flag turns "everything 404s" into a screen
// that says so - a kiosk merely switched off must not look broken.
try {

View File

@@ -33,6 +33,28 @@
</p>
</div>
<div class="form-group">
<label>Kiosk device token</label>
<div class="token-row">
<input v-model="values.usb_kiosk_token" type="text"
class="form-control" placeholder="not set" />
<button type="button" class="btn btn-secondary" @click="generateToken">
Generate
</button>
</div>
<p class="field-hint">
Provision each screen once by opening
<code>/usb-kiosk?token=&lt;this value&gt;</code> on it. That browser
keeps it; the address bar does not. Turning the kiosk on is a
decision, not a credential - without this token the routes answer 404,
so nobody who merely finds the URL can move a drive and sign it with a
badge they guessed.
</p>
<p v-if="tokenJustGenerated" class="field-hint token-warn">
Copy this now and save. It is masked once stored.
</p>
</div>
<div class="form-group">
<label class="check-row">
<input type="checkbox" v-model="values.usb_kiosk_allow_return_for_others"
@@ -61,12 +83,25 @@ import { ref, onMounted } from 'vue'
import { settingsApi } from '@/api'
import { withBase } from '@/utils/basePath'
const KEYS = ['usb_kiosk_enabled', 'usb_kiosk_allow_return_for_others']
const KEYS = ['usb_kiosk_enabled', 'usb_kiosk_allow_return_for_others',
'usb_kiosk_token']
const values = ref({
usb_kiosk_enabled: false,
usb_kiosk_allow_return_for_others: true,
usb_kiosk_token: '',
})
const tokenJustGenerated = ref(false)
// Generated in the browser so it never travels anywhere it did not need to.
function generateToken() {
const bytes = new Uint8Array(24)
crypto.getRandomValues(bytes)
values.value.usb_kiosk_token =
Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
tokenJustGenerated.value = true
}
const saving = ref(false)
const message = ref('')
const error = ref('')
@@ -106,6 +141,10 @@ onMounted(load)
</script>
<style scoped>
.token-row { display: flex; gap: 8px; align-items: center; }
.token-row .form-control { flex: 1 1 auto; }
.token-warn { color: var(--warning-dark); }
.field-hint { color: var(--text-light); font-size: 0.85rem; margin-top: 0.25rem; }
.check-row { display: flex; align-items: center; gap: 0.5rem; font-weight: 600; }
.check-row input { width: auto; }

View File

@@ -138,6 +138,18 @@ class USBPlugin(BasePlugin):
'a device between checked-out and checked-in - it '
'cannot add, edit, retire or list devices.',
},
{
'key': 'usb_kiosk_token',
'value': '',
# string: the column documents string/boolean/integer/json.
# Masking keys on the NAME containing 'token'.
'valuetype': 'string',
'category': 'usb',
'description': 'Device token the USB kiosk sends as '
'X-Kiosk-Token. Generate one, then open the '
'kiosk once as /usb-kiosk?token=<value> on each '
'screen',
},
{
'key': 'usb_kiosk_allow_return_for_others',
'value': 'true',

View File

@@ -4,6 +4,23 @@
<h2>Dell Warranty Lookup</h2>
</div>
<div class="section-card">
<div class="setting-group">
<p class="setting-description">
How far ahead the dashboard counts a warranty as expiring.
</p>
<div class="setting-row">
<label>
<span>Expiring window (days)</span>
<input type="number" min="1"
v-model.number="settings.warranty_expiringdays"
@blur="saveSetting('warranty_expiringdays', settings.warranty_expiringdays)"
:disabled="saving">
</label>
</div>
</div>
</div>
<div class="section-card">
<div class="setting-group">
<p class="setting-description">