Admit kiosk callers by network position, not only by device token
The device token assumes a handful of fixed screens an administrator provisions once. That is not how these kiosks are used: most people open the page on their own PC, and there is no login, so per-browser provisioning would mean touching every PC on the floor and losing it again on the next reimage or cleared profile. A screen in that state did not look refused either - the USB kiosk rendered a working page whose every call answered 404. Both kiosks now admit a caller on either ground, the same shape the GE-Enforce client endpoints already use: the per-device token, or a source address inside an allowlist. Wall screens keep the token and are not confined to the allowlist; everyone else is admitted by where they are on the network. Still gated on the site's enable flag, so nothing opens by itself, and both allowlists ship empty - token-only until a site enters its ranges. - shopdb.api gains ip_in_cidrs and request_ip_allowlisted (contract 0.35.0). On the surface because both plugins need the identical gate, and a CIDR parser copied into two of them is where a fail-open creeps in later. They fail closed on an empty setting, an unparseable caller address and a malformed entry, and they read remote_addr rather than raw X-Forwarded-For - so the IIS rewrite rule is what makes them meaningful, which the module says at length. - Separate settings per kiosk, usb_kiosk_allowed_cidrs and printedparts_kiosk_allowed_cidrs. One must not grant the other, for the same reason the tokens are separate. - Both settings pages get a picker listing the network plugin's subnets with their VLANs. It stores resolved CIDR strings, not subnet ids: the setting has to work at a site that does not run that plugin and must not break when an inventory row is deleted. Entries matching no subnet are shown rather than hidden, since a stale one left by an inventory edit looks exactly like a hand-typed range. - USBKiosk says it was not admitted instead of pretending to work, which PartsKiosk already did. - The USB settings page keyed text-vs-boolean off the token alone. Adding a second text setting to the boolean path would have saved it as the word 'true' - the bug that replaced the kiosk token and 404'd every provisioned screen. Both load and save now key off a list, with a spec on it. The trade, stated plainly in the admin UI: anyone on an allowed network can check a drive in or out against a badge number they guess, and the record carries that person's name. Badges are attribution, not authorization. Keeping the ranges narrow is what bounds it; login is the alternative and was ruled out because these users do not sign in.
This commit is contained in:
129
frontend/src/components/NetworkAllowlistPicker.vue
Normal file
129
frontend/src/components/NetworkAllowlistPicker.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div class="allowlist-picker">
|
||||
<div v-if="loading" class="picker-note">Loading networks...</div>
|
||||
|
||||
<div v-else-if="subnets.length" class="subnet-list">
|
||||
<label v-for="subnet in subnets" :key="subnet.subnetid" class="subnet-row">
|
||||
<input type="checkbox" :value="subnet.cidr"
|
||||
:checked="isSelected(subnet.cidr)"
|
||||
@change="toggle(subnet.cidr)" />
|
||||
<span class="subnet-cidr">{{ subnet.cidr }}</span>
|
||||
<span class="subnet-name">{{ subnet.name }}</span>
|
||||
<span v-if="subnet.vlannumber" class="subnet-vlan">
|
||||
VLAN {{ subnet.vlannumber }}<template v-if="subnet.vlanname"> - {{ subnet.vlanname }}</template>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p v-else class="picker-note">
|
||||
{{ unavailableMessage }}
|
||||
</p>
|
||||
|
||||
<label class="manual-label">Allowed networks (CIDR, comma separated)</label>
|
||||
<input :value="modelValue" type="text" class="form-control"
|
||||
placeholder="empty = device token only"
|
||||
@input="$emit('update:modelValue', $event.target.value)" />
|
||||
|
||||
<p v-if="extraEntries.length" class="picker-note">
|
||||
Not from the network inventory: {{ extraEntries.join(', ') }}
|
||||
</p>
|
||||
<p v-if="invalidEntries.length" class="picker-warn">
|
||||
Not valid CIDR and will never match: {{ invalidEntries.join(', ') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// Picks networks for a kiosk allowlist from the network plugin's subnet
|
||||
// inventory, and stays usable without it.
|
||||
//
|
||||
// Stores resolved CIDR STRINGS, not subnet ids. The setting has to keep working
|
||||
// at a site that does not run the network plugin, and must not break when an
|
||||
// inventory row is edited or deleted. The trade is that a CIDR edited in the
|
||||
// inventory leaves a stale copy here - which is why entries that match no
|
||||
// subnet are listed back to the administrator rather than hidden.
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { fetchAllPages } from '@/api'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const subnets = ref([])
|
||||
const loading = ref(true)
|
||||
const unavailableMessage = ref('No subnets found. Enter networks by hand below.')
|
||||
|
||||
function entries (raw) {
|
||||
return String(raw || '').split(',').map(part => part.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
const selected = computed(() => entries(props.modelValue))
|
||||
|
||||
function isSelected (cidr) {
|
||||
return selected.value.includes(cidr)
|
||||
}
|
||||
|
||||
function toggle (cidr) {
|
||||
const current = selected.value
|
||||
const next = current.includes(cidr)
|
||||
? current.filter(entry => entry !== cidr)
|
||||
: [...current, cidr]
|
||||
emit('update:modelValue', next.join(', '))
|
||||
}
|
||||
|
||||
// Entries the administrator typed that no subnet record matches. Not an error -
|
||||
// a range may simply not be inventoried - but worth showing, because a stale
|
||||
// entry left behind by an inventory edit looks exactly like this.
|
||||
const extraEntries = computed(() => {
|
||||
const known = new Set(subnets.value.map(subnet => subnet.cidr))
|
||||
return selected.value.filter(entry => !known.has(entry))
|
||||
})
|
||||
|
||||
// Shape check only, mirroring the server: anything the server cannot parse is
|
||||
// skipped there, so it silently admits nobody. Better to say so here.
|
||||
const CIDR_SHAPE = /^\d{1,3}(\.\d{1,3}){3}\/\d{1,2}$/
|
||||
const invalidEntries = computed(
|
||||
() => selected.value.filter(entry => !CIDR_SHAPE.test(entry) && !entry.includes(':')))
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
subnets.value = await fetchAllPages('/network/subnets')
|
||||
} catch (error) {
|
||||
// The network plugin may be disabled at this site, or the signed-in user
|
||||
// may not hold network.view. Neither stops an administrator configuring
|
||||
// the allowlist by hand, so degrade instead of failing.
|
||||
subnets.value = []
|
||||
unavailableMessage.value =
|
||||
'Network inventory unavailable. Enter networks by hand below.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.subnet-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border-color, #ccc);
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.subnet-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
.subnet-cidr { font-family: monospace; min-width: 12rem; }
|
||||
.subnet-name { font-weight: 600; }
|
||||
.subnet-vlan { color: var(--text-light); font-size: 0.85rem; }
|
||||
.manual-label { display: block; font-weight: 600; margin-top: 0.5rem; }
|
||||
.picker-note { color: var(--text-light); font-size: 0.85rem; margin: 0.25rem 0; }
|
||||
.picker-warn { color: var(--warning-dark); font-size: 0.85rem; margin: 0.25rem 0; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user