Gate both kiosks behind a device token, and make an unreachable setting a test failure

This commit is contained in:
2026-09-02 18:10:30 -04:00
parent 1466f9c3b0
commit 088648011f
14 changed files with 498 additions and 66 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

@@ -162,7 +162,11 @@ class PrintedpartsPlugin(BasePlugin):
{
'key': 'printedparts_kiosk_token',
'value': '',
'valuetype': 'secret',
# string, not 'secret': the column documents string/boolean/
# integer/json only. Masking is keyed on the NAME containing
# 'token' (settings.py::_is_secret), so this is still hidden in
# the API and the audit log.
'valuetype': 'string',
'category': 'printedparts',
'description': 'Device token the kiosk sends as X-Kiosk-Token. '
'Generate one, then open the kiosk once as '

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">