Make the one-line install survive Defender, and approval leave a PC that works
All three found testing the bootstrap installer on Windows. - The published command used the irm-pipe-iex form, which Microsoft Defender detects as Trojan:Win32/Commando.A!ml and kills before it runs, every time, on a default install. The command now downloads the script to a file, runs it, and removes it in a finally block, which Defender does not object to. The UI also offers the script as a plain download for a tech who would rather save it. - Approving a request only linked the asset, so a PC whose pending reason was "unknown hostname" - which is every PC installed this way - kept getting 403 on every call afterwards, because its computer record had no hostname to match the credential against. Approval now stamps the hostname when the record has none, refuses with a 409 naming both when the record has a different one, and overwrites only when the admin confirms. The Credentials tab offers that confirmation. - A hostname that resolves to no record now says so instead of reading as an impostor, while still refusing the request. - While waiting for approval the agent retried on the 15-minute telemetry cadence, so a PC approved immediately enrolled a quarter of an hour later and the installer always ended on "still pending". It now retries about every minute for the first ten minutes, then every five to thirty minutes, then every fifteen, with jitter. A rate limit or server error still uses the slower backoff.
This commit is contained in:
@@ -458,6 +458,18 @@
|
||||
{{ bootstrapcopied ? 'Copied' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="bootstrapscripturl" class="field-hint">
|
||||
Or save the script and run it on the PC instead:
|
||||
<a
|
||||
class="bootstrap-download"
|
||||
:href="bootstrapscripturl"
|
||||
download="shopdb-bootstrap.ps1"
|
||||
>
|
||||
Download .ps1
|
||||
</a>
|
||||
(the same token; it carries the secret, so treat the file the
|
||||
way you treat the command).
|
||||
</p>
|
||||
<p v-if="bootstraptoken" class="field-hint">
|
||||
Expires {{ formatDate(bootstraptoken.expiresat) }}.
|
||||
{{ bootstraptoken.remaining }} of
|
||||
@@ -745,6 +757,7 @@ const bootstraphours = ref(24)
|
||||
const bootstrapmaxuses = ref(25)
|
||||
const bootstrapbusy = ref(false)
|
||||
const bootstrapcommand = ref('')
|
||||
const bootstrapscripturl = ref('')
|
||||
const bootstraptoken = ref(null)
|
||||
const bootstrapcopied = ref(false)
|
||||
const bootstraptokens = ref([])
|
||||
@@ -880,6 +893,7 @@ async function generateBootstrapToken() {
|
||||
bootstrapmessage.value = ''
|
||||
bootstraperror.value = ''
|
||||
bootstrapcommand.value = ''
|
||||
bootstrapscripturl.value = ''
|
||||
bootstraptoken.value = null
|
||||
bootstrapcopied.value = false
|
||||
try {
|
||||
@@ -889,6 +903,7 @@ async function generateBootstrapToken() {
|
||||
})
|
||||
const data = response.data.data || {}
|
||||
bootstrapcommand.value = data.command || ''
|
||||
bootstrapscripturl.value = data.scripturl || ''
|
||||
bootstraptoken.value = data.token || null
|
||||
bootstrapmessage.value = 'Install command generated.'
|
||||
await loadBootstrapTokens()
|
||||
|
||||
@@ -330,9 +330,13 @@
|
||||
</button>
|
||||
</div>
|
||||
<p class="field-hint">
|
||||
The PC receives its credential the next time it asks,
|
||||
which is within 15 minutes. Only the PC that made this
|
||||
request can collect it.
|
||||
The PC receives its credential the next time it asks:
|
||||
about a minute for one that started asking recently,
|
||||
slowing to 15 minutes once it has been pending a while.
|
||||
Only the PC that made this request can collect it.
|
||||
If the computer record has no hostname, approving
|
||||
records the one the PC reports, which it needs to
|
||||
authenticate afterwards.
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -775,6 +779,28 @@ async function searchAssets() {
|
||||
}
|
||||
}
|
||||
|
||||
// The approval also stamps the request's hostname onto the computer when that
|
||||
// record has none - without it the PC collects its credential and then 403s on
|
||||
// every call, because the server resolves the hostname it sends to nothing. A
|
||||
// computer already carrying a DIFFERENT hostname comes back as a 409 naming
|
||||
// both, and only an explicit second answer renames it.
|
||||
async function postApprove(current, sethostname) {
|
||||
const body = { assetid: current.assetid }
|
||||
if (sethostname) body.sethostname = true
|
||||
const response = await api.post(
|
||||
`/endpoints/enrollment/requests/${current.requestid}/approve`, body)
|
||||
const result = response?.data?.data?.hostnameresult
|
||||
if (result === 'set') {
|
||||
toast.success(`Approved ${current.hostname}; hostname recorded on that PC`)
|
||||
} else if (result === 'overwritten') {
|
||||
toast.success(`Approved ${current.hostname}; that PC was renamed to it`)
|
||||
} else {
|
||||
toast.success(`Approved ${current.hostname}`)
|
||||
}
|
||||
approving.value = null
|
||||
await loadEnrollment({ quiet: true })
|
||||
}
|
||||
|
||||
async function approve() {
|
||||
const current = approving.value
|
||||
if (!current || !current.assetid) return
|
||||
@@ -783,13 +809,24 @@ async function approve() {
|
||||
+ `${option ? option.label : 'asset ' + current.assetid}?`)) return
|
||||
busy.value = true
|
||||
try {
|
||||
await api.post(`/endpoints/enrollment/requests/${current.requestid}/approve`,
|
||||
{ assetid: current.assetid })
|
||||
toast.success(`Approved ${current.hostname}`)
|
||||
approving.value = null
|
||||
await loadEnrollment({ quiet: true })
|
||||
await postApprove(current, false)
|
||||
} catch (error) {
|
||||
toast.error(apiError(error, 'Could not approve the request'))
|
||||
const details = error?.response?.data?.data?.error?.details
|
||||
const message = apiError(error, 'Could not approve the request')
|
||||
if (details?.hostnameconflict) {
|
||||
if (confirm(`${message}\n\nSet the hostname on this PC?`)) {
|
||||
try {
|
||||
await postApprove(current, true)
|
||||
return
|
||||
} catch (retryError) {
|
||||
toast.error(apiError(retryError, 'Could not approve the request'))
|
||||
return
|
||||
}
|
||||
}
|
||||
toast.error(message)
|
||||
return
|
||||
}
|
||||
toast.error(message)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
|
||||
@@ -19,9 +19,14 @@ import AgentKeySettings from './AgentKeySettings.vue'
|
||||
|
||||
const TOKENS_URL = '/endpoints/siteagent/bootstrap/tokens'
|
||||
const SECRET = 'sdbb_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG'
|
||||
const SCRIPT_URL =
|
||||
`https://shopdb.test/shopdb/api/endpoints/siteagent/bootstrap.ps1?t=${SECRET}`
|
||||
// Download then run: Defender kills the `irm ... | iex` shape before it runs.
|
||||
const TEMP = 'C:\\Windows\\Temp\\shopdb-bootstrap.ps1'
|
||||
const COMMAND =
|
||||
'powershell -ExecutionPolicy Bypass -c "irm https://shopdb.test/shopdb'
|
||||
+ `/api/endpoints/siteagent/bootstrap.ps1?t=${SECRET} | iex"`
|
||||
'powershell -ExecutionPolicy Bypass -c "try { Invoke-WebRequest '
|
||||
+ `-UseBasicParsing '${SCRIPT_URL}' -OutFile '${TEMP}'; & '${TEMP}' }`
|
||||
+ ` finally { Remove-Item '${TEMP}' -Force -ErrorAction SilentlyContinue }"`
|
||||
|
||||
const MINTED_TOKEN = {
|
||||
tokenid: 7,
|
||||
@@ -81,7 +86,7 @@ describe('AgentKeySettings one-line install', () => {
|
||||
|
||||
it('generate posts the hours and maximum uses', async () => {
|
||||
api.post.mockResolvedValue({
|
||||
data: { data: { secret: SECRET, command: COMMAND, token: MINTED_TOKEN } }
|
||||
data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } }
|
||||
})
|
||||
const wrapper = await render()
|
||||
|
||||
@@ -95,7 +100,7 @@ describe('AgentKeySettings one-line install', () => {
|
||||
|
||||
it('defaults to 24 hours and 25 uses', async () => {
|
||||
api.post.mockResolvedValue({
|
||||
data: { data: { secret: SECRET, command: COMMAND, token: MINTED_TOKEN } }
|
||||
data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } }
|
||||
})
|
||||
const wrapper = await render()
|
||||
|
||||
@@ -109,7 +114,7 @@ describe('AgentKeySettings one-line install', () => {
|
||||
|
||||
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 } }
|
||||
data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } }
|
||||
})
|
||||
const wrapper = await render()
|
||||
await generateButton(wrapper).trigger('click')
|
||||
@@ -126,11 +131,27 @@ describe('AgentKeySettings one-line install', () => {
|
||||
expect(block).toContain('uses remaining')
|
||||
})
|
||||
|
||||
it('offers the same token as a Download .ps1 link', async () => {
|
||||
api.post.mockResolvedValue({
|
||||
data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } }
|
||||
})
|
||||
const wrapper = await render()
|
||||
expect(wrapper.find('.bootstrap-download').exists()).toBe(false)
|
||||
|
||||
await generateButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const link = wrapper.find('.bootstrap-download')
|
||||
expect(link.attributes('href')).toBe(SCRIPT_URL)
|
||||
expect(link.attributes('download')).toBe('shopdb-bootstrap.ps1')
|
||||
expect(wrapper.find('#bootstrap-command').element.value).not.toContain(' | iex')
|
||||
})
|
||||
|
||||
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 } }
|
||||
data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } }
|
||||
})
|
||||
const wrapper = await render()
|
||||
await generateButton(wrapper).trigger('click')
|
||||
@@ -171,7 +192,7 @@ describe('AgentKeySettings one-line install', () => {
|
||||
|
||||
it('does not show the secret again after a reload', async () => {
|
||||
api.post.mockResolvedValue({
|
||||
data: { data: { secret: SECRET, command: COMMAND, token: MINTED_TOKEN } }
|
||||
data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } }
|
||||
})
|
||||
const first = await render()
|
||||
await generateButton(first).trigger('click')
|
||||
|
||||
@@ -189,6 +189,69 @@ describe('EnrollmentSettings', () => {
|
||||
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/requests/45/approve', { assetid: 77 })
|
||||
})
|
||||
|
||||
// Approving a one-off PC onto a computer recorded under another hostname is
|
||||
// refused by the server, because the PC authenticates by hostname afterwards.
|
||||
// The page has to show that message and let the admin answer it, not swallow
|
||||
// it as "could not approve".
|
||||
function hostnameConflict() {
|
||||
return {
|
||||
response: {
|
||||
status: 409,
|
||||
data: {
|
||||
data: {
|
||||
error: {
|
||||
code: 'CONFLICT',
|
||||
message: 'That computer is recorded as PC-OLD, but the request comes from PC-NEW.',
|
||||
details: {
|
||||
hostnameconflict: true,
|
||||
computerhostname: 'PC-OLD',
|
||||
requesthostname: 'PC-NEW'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('offers to set the hostname when approve comes back 409, then re-posts', async () => {
|
||||
const confirmSpy = vi.fn(() => true)
|
||||
vi.stubGlobal('confirm', confirmSpy)
|
||||
const wrapper = await render()
|
||||
await openCredentials(wrapper)
|
||||
api.post.mockReset()
|
||||
api.post.mockRejectedValueOnce(hostnameConflict())
|
||||
api.post.mockResolvedValue({ data: { data: { hostnameresult: 'overwritten' } } })
|
||||
|
||||
await wrapper.find('.approve-open').trigger('click')
|
||||
await wrapper.find('.approve-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(api.post.mock.calls).toEqual([
|
||||
['/endpoints/enrollment/requests/45/approve', { assetid: 77 }],
|
||||
['/endpoints/enrollment/requests/45/approve', { assetid: 77, sethostname: true }]
|
||||
])
|
||||
const asked = confirmSpy.mock.calls.at(-1)[0]
|
||||
expect(asked).toContain('recorded as PC-OLD')
|
||||
expect(asked).toContain('Set the hostname on this PC?')
|
||||
})
|
||||
|
||||
it('sends nothing more when the hostname question is declined', async () => {
|
||||
vi.stubGlobal('confirm', vi.fn().mockReturnValueOnce(true).mockReturnValueOnce(false))
|
||||
const wrapper = await render()
|
||||
await openCredentials(wrapper)
|
||||
api.post.mockReset()
|
||||
api.post.mockRejectedValue(hostnameConflict())
|
||||
|
||||
await wrapper.find('.approve-open').trigger('click')
|
||||
await wrapper.find('.approve-confirm').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(api.post).toHaveBeenCalledTimes(1)
|
||||
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/requests/45/approve',
|
||||
{ assetid: 77 })
|
||||
})
|
||||
|
||||
it('rejects a request after confirm', async () => {
|
||||
vi.stubGlobal('confirm', vi.fn(() => true))
|
||||
const wrapper = await render()
|
||||
|
||||
Reference in New Issue
Block a user