Act on the wj architectural review: races, limits, pool and contract gaps
Some checks failed
CI / backend (push) Has been cancelled
CI / naming (push) Has been cancelled
CI / gates (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / lean-build (push) Has been cancelled

Findings F2, F4, F5, F6, F7, F8, F9, F10, F11 and F13 from
wj-architectural-review.md. F1/F1a (the non-sargable LOWER pattern) and F3
(the warranty sync holding a pooled connection) are being handled separately
and are untouched here.

- A collector check-in that raced another for the same PC lost everything:
  the unique index on the application fingerprint raised, nothing caught it,
  and the whole check-in rolled back, discarding state, events and hardware,
  answering the agent with form copy meant for a person. The insert now runs
  in a savepoint, reconciles what the other transaction already wrote, one row
  per savepoint so a second collision skips a row rather than the batch, and
  the check-in commits with a warning. This is the shape the delete side of
  the same function already had.
- Enrollment is deliberately unauthenticated, so it now carries a per-address
  rate limit and a cap on pending requests, and the cap is a setting an admin
  can see and change. A flood can no longer bury the approval queue or grow
  the table without bound.
- The fleet alert poll no longer formats debug strings on every request.
- BusinessUnit is on the contract surface (0.34.0), so the endpoints plugin
  stops reaching into a core table with raw SQL.
- A test walks every asset type descriptor and proves the endpoint it names
  really refuses a caller without the declared permission.
- The plugin settings routes now need a permission; they were telling anyone
  which modules a site runs and where its components live.
- The connection pool is sized explicitly with a short timeout, and the
  arithmetic against the server's connection ceiling is written next to the
  worker count instead of being left to defaults nobody chose.
- Fleet alerts carry both the correct lowercase keys and the old camelCase
  pair. Trays older than 1.7.0 match keys case-sensitively and would have gone
  quiet on every PC not yet upgraded; the old pair goes when they are.
- The dev server proxy points at localhost again, with environment variables
  for the remote-box workflow, and one developer's address is out of the
  server certificate page.
This commit is contained in:
cproudlock
2026-09-16 08:57:04 -04:00
parent 2557d1967d
commit 066a19052b
30 changed files with 1149 additions and 114 deletions

View File

@@ -229,6 +229,26 @@
on record matches. Anything else waits below for an admin.
</span>
</label>
<label class="number-row">
<span>
<strong>Pending request limit.</strong> How many requests may wait
here at once (setting
<code>plugin.endpoints.maxpendingenrollments</code>). At the limit,
a PC we have never seen is told to try later, while PCs already in
this list keep asking and keep being approved. 0 removes the limit.
/enroll takes no login, so this is what stops the queue growing
without bound.
</span>
<input
type="number"
min="0"
step="1"
class="input-maxpending"
v-model.number="pendingCapInput"
:disabled="!isadmin || busy"
@change="onPendingCapChange()"
/>
</label>
</div>
<div class="enroll-tiles">
@@ -645,7 +665,16 @@ function wavePercent(wave) {
// ---- Credentials ------------------------------------------------------
const enrollmentSettings = ref({ sharedkeyallowed: true, autoenroll: true })
const DEFAULT_PENDING_CAP = 500
const enrollmentSettings = ref({
sharedkeyallowed: true,
autoenroll: true,
maxpendingenrollments: DEFAULT_PENDING_CAP
})
// Bound with v-model rather than :value so the field holds what the operator
// typed until it is accepted or rejected; a one-way binding fights the edit.
const pendingCapInput = ref(DEFAULT_PENDING_CAP)
const enrollmentCounts = ref({})
const credentialRows = ref([])
const requests = ref([])
@@ -667,8 +696,12 @@ async function loadEnrollment({ quiet = false } = {}) {
const data = response.data.data || {}
enrollmentSettings.value = {
sharedkeyallowed: data.settings?.sharedkeyallowed !== false,
autoenroll: data.settings?.autoenroll !== false
autoenroll: data.settings?.autoenroll !== false,
maxpendingenrollments: Number.isFinite(Number(data.settings?.maxpendingenrollments))
? Number(data.settings.maxpendingenrollments)
: DEFAULT_PENDING_CAP
}
pendingCapInput.value = enrollmentSettings.value.maxpendingenrollments
enrollmentCounts.value = data.counts || {}
credentialRows.value = data.computers || []
requests.value = data.requests || []
@@ -738,6 +771,16 @@ function onAutoEnrollToggle(event) {
})
}
function onPendingCapChange() {
const wanted = Math.trunc(Number(pendingCapInput.value))
if (!Number.isFinite(wanted) || wanted < 0) {
pendingCapInput.value = enrollmentSettings.value.maxpendingenrollments
toast.error('The pending request limit must be 0 or a whole number')
return
}
return saveSettings({ maxpendingenrollments: wanted })
}
function openApprove(request) {
const options = []
if (request.matchedassetid) {
@@ -1126,6 +1169,18 @@ onBeforeUnmount(() => {
margin: 0.5rem 0;
}
.number-row {
display: flex;
align-items: flex-start;
gap: 0.5rem;
margin: 0.5rem 0;
}
.number-row input {
width: 6rem;
flex: none;
}
.action-cell {
white-space: nowrap;
text-align: right;

View File

@@ -164,10 +164,11 @@
v-model="generateForm.cn"
type="text"
class="form-control"
placeholder="localhost"
placeholder="hostname or IP"
/>
<p class="field-hint">
Hostname or IP that agents use to connect (e.g., shopdb.example.com or 192.168.100.1)
Hostname or IP that agents use to connect, exactly as it appears in
the agent's base URL.
</p>
</div>
@@ -178,10 +179,12 @@
v-model="generateForm.san"
type="text"
class="form-control"
placeholder="DNS:localhost,IP:192.168.100.1"
placeholder="DNS:hostname,IP:address"
/>
<p class="field-hint">
Comma-separated list: DNS:hostname or IP:address (e.g., DNS:localhost,IP:192.168.100.1,DNS:shopdb.local)
Comma-separated list of DNS:hostname and IP:address entries. Include
every name and address a bay may use to reach this server; a name
that is missing here fails certificate validation on the endpoint.
</p>
</div>
@@ -355,9 +358,13 @@ const liveLoading = ref(false)
const thumbprintCopied = ref(false)
const pinCopied = ref(false)
// No baked host or address (ADR-015: no site literals in product code). One
// site's LAN address was shipped here as the default SAN, which every other
// site would have generated a certificate for. The operator types what this
// server is actually reached by; the form refuses to submit empty.
const generateForm = ref({
cn: 'localhost',
san: 'DNS:localhost,IP:192.168.100.1',
cn: '',
san: '',
days: 365
})

View File

@@ -54,7 +54,8 @@ function enrollmentPayload() {
return {
data: {
data: {
settings: { sharedkeyallowed: true, autoenroll: true },
settings: { sharedkeyallowed: true, autoenroll: true,
maxpendingenrollments: 500 },
counts: { enrolled: 40, notenrolled: 58, pendingcomputers: 1, pending: 2 },
computers: [
{ assetid: 21, hostname: 'PC-ENROLLED', serialnumber: 'S1', state: 'enrolled',
@@ -325,4 +326,20 @@ describe('EnrollmentSettings', () => {
expect(api.put).not.toHaveBeenCalled()
expect(toggle.element.checked).toBe(true)
})
it('saves a new pending request limit', async () => {
const wrapper = await render()
await openCredentials(wrapper)
const field = wrapper.find('.input-maxpending')
expect(field.element.value).toBe('500')
await field.setValue(25)
await field.trigger('change')
await flushPromises()
expect(api.put).toHaveBeenCalledWith('/endpoints/enrollment/settings',
{ maxpendingenrollments: 25 })
})
// The negative case is proved server side (tests/test_plugins/
// test_endpoints_enroll_limits.py: PUT with -1 is a 400). Asserting it here
// would only be asserting what vue-test-utils does to a number input.
})