Install an agent on a one-off PC from a single command
An admin generates a short-lived token in Settings > Endpoints > Endpoints and
copies the command it produces. A tech runs that one line in an elevated
PowerShell on a PC with no agent:
powershell -ExecutionPolicy Bypass -c "irm https://<server>/shopdb/api/
endpoints/siteagent/bootstrap.ps1?t=<token> | iex"
The script refuses to run unelevated, refuses a PC that already has the agent
(a managed bay gets it from GE-Enforce), downloads the package the existing
builder produces, checks both binaries against the hashes the server baked in,
installs with NO shared key, and reports what happened. The PC then asks to
enroll and waits for an admin to approve it, which the script watches for
locally for two minutes before exiting.
- New endpointbootstraptokens table and migration
endpoints0024bootstraptokens: hash only, expiry, use count, revoke.
- Admin routes to generate, list and revoke tokens; the secret is shown once.
- Two token-only routes: the script, and the package (which spends a use).
Anything wrong with the token answers 404 rather than confirming it exists,
both are rate limited per address and both are audited with the caller.
- The package builder is now shared rather than duplicated, and a bootstrap
package can never carry a collector key.
This commit is contained in:
@@ -380,6 +380,134 @@
|
||||
<p v-if="agentwarning" class="field-warning">{{ agentwarning }}</p>
|
||||
<p v-if="agenterror" class="field-error">{{ agenterror }}</p>
|
||||
|
||||
<!-- ================================================================
|
||||
One-line install. An admin mints a short-lived token and hands a
|
||||
tech one command; the token only fetches the installer. The
|
||||
package it serves never carries the shared key, so the PC waits
|
||||
in Pending until an admin approves its enrollment. -->
|
||||
<div class="card bootstrap-block">
|
||||
<h4>One-line install</h4>
|
||||
<p class="field-hint">
|
||||
Generate a short-lived token and give a technician the command.
|
||||
On a PC with no agent, run it from an <strong>elevated</strong>
|
||||
PowerShell. The token only fetches the installer: it carries no
|
||||
shared key and provisions nothing on its own. A managed bay should
|
||||
keep getting the agent from GE-Enforce instead.
|
||||
</p>
|
||||
|
||||
<div class="bootstrap-inputs">
|
||||
<div>
|
||||
<label class="field-label" for="bootstrap-hours">
|
||||
Valid for (hours)
|
||||
</label>
|
||||
<input
|
||||
id="bootstrap-hours"
|
||||
v-model.number="bootstraphours"
|
||||
class="form-control"
|
||||
type="number"
|
||||
min="1"
|
||||
max="168"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="field-label" for="bootstrap-maxuses">
|
||||
Maximum uses
|
||||
</label>
|
||||
<input
|
||||
id="bootstrap-maxuses"
|
||||
v-model.number="bootstrapmaxuses"
|
||||
class="form-control"
|
||||
type="number"
|
||||
min="1"
|
||||
max="500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="button-row">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
type="button"
|
||||
:disabled="bootstrapbusy"
|
||||
@click="generateBootstrapToken"
|
||||
>
|
||||
{{ bootstrapbusy ? 'Generating...' : 'Generate install command' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-if="bootstrapcommand">
|
||||
<p class="field-warning">
|
||||
Copy this now. The token is shown once and cannot be recovered.
|
||||
</p>
|
||||
<label class="field-label" for="bootstrap-command">
|
||||
Install command
|
||||
</label>
|
||||
<div class="bootstrap-command-row">
|
||||
<input
|
||||
id="bootstrap-command"
|
||||
class="form-control mono"
|
||||
type="text"
|
||||
readonly
|
||||
:value="bootstrapcommand"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-secondary"
|
||||
type="button"
|
||||
@click="copyBootstrapCommand"
|
||||
>
|
||||
{{ bootstrapcopied ? 'Copied' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="bootstraptoken" class="field-hint">
|
||||
Expires {{ formatDate(bootstraptoken.expiresat) }}.
|
||||
{{ bootstraptoken.remaining }} of
|
||||
{{ bootstraptoken.maxuses }} uses remaining.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<p class="field-hint">
|
||||
The PC installs with no shared key and then waits for approval in
|
||||
Settings > Endpoints > Enrollment.
|
||||
</p>
|
||||
|
||||
<table v-if="bootstraptokens.length" class="versions-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Token</th>
|
||||
<th>State</th>
|
||||
<th>Expires</th>
|
||||
<th>Uses</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="entry in bootstraptokens" :key="entry.tokenid">
|
||||
<td class="mono">{{ entry.tokenprefix }}</td>
|
||||
<td>{{ entry.state }}</td>
|
||||
<td>{{ formatDate(entry.expiresat) }}</td>
|
||||
<td>{{ entry.usecount }} / {{ entry.maxuses }}</td>
|
||||
<td>
|
||||
<button
|
||||
v-if="entry.state === 'active'"
|
||||
class="btn-link"
|
||||
type="button"
|
||||
:disabled="bootstrapbusy"
|
||||
@click="revokeBootstrapToken(entry.tokenid)"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="field-hint">No install tokens yet.</p>
|
||||
|
||||
<p v-if="bootstrapmessage" class="field-hint">
|
||||
{{ bootstrapmessage }}
|
||||
</p>
|
||||
<p v-if="bootstraperror" class="field-error">{{ bootstraperror }}</p>
|
||||
</div>
|
||||
|
||||
<!-- ================================================================
|
||||
Agent binary versions: upload a new build, keep the last few,
|
||||
and roll the current pointer back to a retained version. Staging
|
||||
@@ -610,6 +738,19 @@ const agentincludesharedkey = ref(true)
|
||||
const agentnextserverpin = ref('')
|
||||
const agentserverpin = ref('')
|
||||
|
||||
// One-line install tokens. bootstrapcommand holds the secret ONLY for the
|
||||
// response that minted it: the list read never carries one, so a reload leaves
|
||||
// the field empty rather than re-showing a token nobody can recover.
|
||||
const bootstraphours = ref(24)
|
||||
const bootstrapmaxuses = ref(25)
|
||||
const bootstrapbusy = ref(false)
|
||||
const bootstrapcommand = ref('')
|
||||
const bootstraptoken = ref(null)
|
||||
const bootstrapcopied = ref(false)
|
||||
const bootstraptokens = ref([])
|
||||
const bootstrapmessage = ref('')
|
||||
const bootstraperror = ref('')
|
||||
|
||||
function agentnextpininvalid() {
|
||||
const pin = agentnextserverpin.value
|
||||
return !!pin && !/^[0-9a-f]{64}$/.test(pin)
|
||||
@@ -708,6 +849,7 @@ onMounted(async () => {
|
||||
loading.value = false
|
||||
}
|
||||
loadEnrollment()
|
||||
loadBootstrapTokens()
|
||||
try {
|
||||
const response = await api.get('/endpoints/siteagent')
|
||||
const status = response.data.data || {}
|
||||
@@ -723,6 +865,68 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
async function loadBootstrapTokens() {
|
||||
try {
|
||||
const response = await api.get('/endpoints/siteagent/bootstrap/tokens')
|
||||
bootstraptokens.value = response.data.data?.tokens || []
|
||||
} catch (loadError) {
|
||||
bootstraptokens.value = []
|
||||
console.error(loadError)
|
||||
}
|
||||
}
|
||||
|
||||
async function generateBootstrapToken() {
|
||||
bootstrapbusy.value = true
|
||||
bootstrapmessage.value = ''
|
||||
bootstraperror.value = ''
|
||||
bootstrapcommand.value = ''
|
||||
bootstraptoken.value = null
|
||||
bootstrapcopied.value = false
|
||||
try {
|
||||
const response = await api.post('/endpoints/siteagent/bootstrap/tokens', {
|
||||
hours: bootstraphours.value,
|
||||
maxuses: bootstrapmaxuses.value
|
||||
})
|
||||
const data = response.data.data || {}
|
||||
bootstrapcommand.value = data.command || ''
|
||||
bootstraptoken.value = data.token || null
|
||||
bootstrapmessage.value = 'Install command generated.'
|
||||
await loadBootstrapTokens()
|
||||
} catch (generateError) {
|
||||
bootstraperror.value = generateError.response?.data?.data?.error?.message
|
||||
|| 'Could not generate an install token'
|
||||
console.error(generateError)
|
||||
} finally {
|
||||
bootstrapbusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function copyBootstrapCommand() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(bootstrapcommand.value)
|
||||
bootstrapcopied.value = true
|
||||
} catch (copyError) {
|
||||
console.error(copyError)
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeBootstrapToken(tokenid) {
|
||||
bootstrapbusy.value = true
|
||||
bootstrapmessage.value = ''
|
||||
bootstraperror.value = ''
|
||||
try {
|
||||
await api.post(`/endpoints/siteagent/bootstrap/tokens/${tokenid}/revoke`)
|
||||
bootstrapmessage.value = 'Install token revoked.'
|
||||
await loadBootstrapTokens()
|
||||
} catch (revokeError) {
|
||||
bootstraperror.value = revokeError.response?.data?.data?.error?.message
|
||||
|| 'Could not revoke the install token'
|
||||
console.error(revokeError)
|
||||
} finally {
|
||||
bootstrapbusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function applyBinaryStatus(binkey, current, versions) {
|
||||
const binary = binaryFor(binkey)
|
||||
if (!binary) return
|
||||
@@ -1131,6 +1335,23 @@ function editConfiguration() {
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-bottom: 1px solid var(--color-border, #333);
|
||||
}
|
||||
.bootstrap-block {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
.bootstrap-inputs {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.bootstrap-inputs .form-control { max-width: 9rem; }
|
||||
.bootstrap-command-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.bootstrap-command-row .form-control { flex: 1; }
|
||||
.current-tag {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-success, #4caf50);
|
||||
|
||||
189
plugins/endpoints/frontend/views/agentBootstrapInstall.spec.js
Normal file
189
plugins/endpoints/frontend/views/agentBootstrapInstall.spec.js
Normal file
@@ -0,0 +1,189 @@
|
||||
/**
|
||||
* One-line install block on the endpoints agent key page.
|
||||
*
|
||||
* The token is shown exactly once, so the two things worth pinning are that the
|
||||
* command rendered is the one the server returned, and that reloading the page
|
||||
* does NOT put a token back on screen - the list read carries no secret, and a
|
||||
* field that repopulated from it would be lying about what is recoverable.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount, flushPromises, RouterLinkStub } from '@vue/test-utils'
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() }
|
||||
}))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
|
||||
|
||||
import api from '@/api'
|
||||
import AgentKeySettings from './AgentKeySettings.vue'
|
||||
|
||||
const TOKENS_URL = '/endpoints/siteagent/bootstrap/tokens'
|
||||
const SECRET = 'sdbb_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG'
|
||||
const COMMAND =
|
||||
'powershell -ExecutionPolicy Bypass -c "irm https://shopdb.test/shopdb'
|
||||
+ `/api/endpoints/siteagent/bootstrap.ps1?t=${SECRET} | iex"`
|
||||
|
||||
const MINTED_TOKEN = {
|
||||
tokenid: 7,
|
||||
tokenprefix: 'sdbb_012345',
|
||||
createdat: '2026-09-15T10:00:00Z',
|
||||
expiresat: '2026-09-16T10:00:00Z',
|
||||
maxuses: 25,
|
||||
usecount: 0,
|
||||
remaining: 25,
|
||||
revokedat: null,
|
||||
state: 'active'
|
||||
}
|
||||
|
||||
function listResponse(tokens) {
|
||||
return { data: { data: { tokens } } }
|
||||
}
|
||||
|
||||
async function render(tokens = []) {
|
||||
api.get.mockImplementation((path) => {
|
||||
if (path === TOKENS_URL) return Promise.resolve(listResponse(tokens))
|
||||
if (path === '/endpoints/agentkey') {
|
||||
return Promise.resolve({ data: { data: { key: null, envkeyfallback: false } } })
|
||||
}
|
||||
return Promise.resolve({ data: { data: {} } })
|
||||
})
|
||||
const wrapper = mount(AgentKeySettings, {
|
||||
global: { stubs: { RouterLink: RouterLinkStub } }
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function generateButton(wrapper) {
|
||||
return wrapper.findAll('button')
|
||||
.find((button) => button.text() === 'Generate install command')
|
||||
}
|
||||
|
||||
describe('AgentKeySettings one-line install', () => {
|
||||
beforeEach(() => {
|
||||
api.get.mockReset()
|
||||
api.post.mockReset()
|
||||
api.delete.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('says the PC waits for approval and that managed bays use GE-Enforce', async () => {
|
||||
const wrapper = await render()
|
||||
const block = wrapper.find('.bootstrap-block').text()
|
||||
expect(block).toContain('One-line install')
|
||||
expect(block).toMatch(/waits for approval in\s+Settings > Endpoints > Enrollment/)
|
||||
expect(block).toContain('GE-Enforce')
|
||||
expect(block).toContain('elevated')
|
||||
})
|
||||
|
||||
it('generate posts the hours and maximum uses', async () => {
|
||||
api.post.mockResolvedValue({
|
||||
data: { data: { secret: SECRET, command: COMMAND, token: MINTED_TOKEN } }
|
||||
})
|
||||
const wrapper = await render()
|
||||
|
||||
await wrapper.find('#bootstrap-hours').setValue(48)
|
||||
await wrapper.find('#bootstrap-maxuses').setValue(5)
|
||||
await generateButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(api.post).toHaveBeenCalledWith(TOKENS_URL, { hours: 48, maxuses: 5 })
|
||||
})
|
||||
|
||||
it('defaults to 24 hours and 25 uses', async () => {
|
||||
api.post.mockResolvedValue({
|
||||
data: { data: { secret: SECRET, command: COMMAND, token: MINTED_TOKEN } }
|
||||
})
|
||||
const wrapper = await render()
|
||||
|
||||
expect(wrapper.find('#bootstrap-hours').element.value).toBe('24')
|
||||
expect(wrapper.find('#bootstrap-maxuses').element.value).toBe('25')
|
||||
|
||||
await generateButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
expect(api.post).toHaveBeenCalledWith(TOKENS_URL, { hours: 24, maxuses: 25 })
|
||||
})
|
||||
|
||||
it('renders the returned command, the once-only warning and the expiry', async () => {
|
||||
api.post.mockResolvedValue({
|
||||
data: { data: { secret: SECRET, command: COMMAND, token: MINTED_TOKEN } }
|
||||
})
|
||||
const wrapper = await render()
|
||||
await generateButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const field = wrapper.find('#bootstrap-command')
|
||||
expect(field.exists()).toBe(true)
|
||||
expect(field.element.value).toBe(COMMAND)
|
||||
expect(field.element.value).toContain(SECRET)
|
||||
expect(field.attributes('readonly')).toBeDefined()
|
||||
const block = wrapper.find('.bootstrap-block').text()
|
||||
expect(block).toContain('The token is shown once and cannot be recovered')
|
||||
expect(block).toContain('25 of')
|
||||
expect(block).toContain('uses remaining')
|
||||
})
|
||||
|
||||
it('copies the command to the clipboard', async () => {
|
||||
const writeText = vi.fn(() => Promise.resolve())
|
||||
vi.stubGlobal('navigator', { clipboard: { writeText } })
|
||||
api.post.mockResolvedValue({
|
||||
data: { data: { secret: SECRET, command: COMMAND, token: MINTED_TOKEN } }
|
||||
})
|
||||
const wrapper = await render()
|
||||
await generateButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const copy = wrapper.findAll('button').find((button) => button.text() === 'Copy')
|
||||
await copy.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(COMMAND)
|
||||
expect(wrapper.find('.bootstrap-block').text()).toContain('Copied')
|
||||
})
|
||||
|
||||
it('lists live tokens and revoke posts to that token', async () => {
|
||||
api.post.mockResolvedValue({ data: { data: { token: MINTED_TOKEN } } })
|
||||
const wrapper = await render([MINTED_TOKEN])
|
||||
|
||||
expect(api.get).toHaveBeenCalledWith(TOKENS_URL)
|
||||
const rows = wrapper.findAll('.bootstrap-block tbody tr')
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0].text()).toContain('sdbb_012345')
|
||||
expect(rows[0].text()).toContain('0 / 25')
|
||||
|
||||
const revoke = wrapper.findAll('button').find((button) => button.text() === 'Revoke')
|
||||
await revoke.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(api.post).toHaveBeenCalledWith(`${TOKENS_URL}/7/revoke`)
|
||||
})
|
||||
|
||||
it('offers no Revoke on a token that is already spent', async () => {
|
||||
const wrapper = await render([
|
||||
{ ...MINTED_TOKEN, state: 'revoked', usecount: 2 }
|
||||
])
|
||||
expect(wrapper.findAll('button').find((button) => button.text() === 'Revoke'))
|
||||
.toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not show the secret again after a reload', async () => {
|
||||
api.post.mockResolvedValue({
|
||||
data: { data: { secret: SECRET, command: COMMAND, token: MINTED_TOKEN } }
|
||||
})
|
||||
const first = await render()
|
||||
await generateButton(first).trigger('click')
|
||||
await flushPromises()
|
||||
expect(first.find('#bootstrap-command').exists()).toBe(true)
|
||||
|
||||
// The reload sees the token in the list read, which carries no secret.
|
||||
const reloaded = await render([MINTED_TOKEN])
|
||||
expect(reloaded.find('#bootstrap-command').exists()).toBe(false)
|
||||
expect(reloaded.text()).not.toContain(SECRET)
|
||||
expect(reloaded.text()).not.toContain('The token is shown once')
|
||||
// The row is still there, by prefix only.
|
||||
expect(reloaded.find('.bootstrap-block tbody tr').text()).toContain('sdbb_012345')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user