Add endpoint software delivery: run_script and update_app push

Adds the two delivery actions to the endpoints command allowlist, each gated on a new endpoints.deploy permission ON TOP OF endpoints.command so pushing software is a heavier grant than a routine toggle. Enqueue validates update_app appversionid ownership and installer presence before queueing. New collector-authenticated download routes let the agent fetch installer packages and script bodies by digest rather than pre-staging them or sending bodies over the command wire. Wires the scriptsApi client, the siteagent generator routes, and a temporary dev-only Delivery test bench (view/route/nav) for exercising the path while the production UI is built.
This commit is contained in:
benjamin.baker
2026-08-30 14:40:51 -04:00
parent 0c2b816a46
commit 188296ef58
11 changed files with 1494 additions and 12 deletions

View File

@@ -14,6 +14,16 @@
================================================================= -->
<section class="settings-section">
<h3>Key management</h3>
<p class="key-warning">
STORE YOUR KEY SECURELY. IT IS SHOWN ONLY ONCE AND CANNOT BE RECOVERED.
</p>
<p class="field-hint">
Before you generate a key, open the <strong>ShopDB Secure Send</strong>
tool in a second window. As soon as the key appears, paste it into
Secure Send, generate a secure link, and send that link to yourself in
Teams so the site leader has a one-time, self-destructing backup. Never
paste the raw key into a chat message or a plain file.
</p>
<p class="field-hint">
The endpoint agent reports with a single managed key scoped to endpoint
telemetry only. It is separate from every other collector, so rotating
@@ -75,8 +85,19 @@
<div v-if="secret" class="card form-card secret-card">
<h4>Save this key now</h4>
<p class="field-hint">
It will not be shown again. Configure it as the agent
<code>X-API-Key</code> header value.
Anyone with this key can report as an endpoint agent for this site.
Treat it like a password. If it is lost you must regenerate, which
revokes the old key and forces every agent to be reconfigured.
</p>
<p class="field-hint">
<strong>Recommended:</strong> before you leave this page, open the
<strong>ShopDB Secure Send</strong> tool in a second window, paste
this key, generate a secure link, and send that link to yourself in
Teams. That gives the site leader a one-time, self-destructing copy to
fall back on instead of pasting the raw key into a chat or a file.
</p>
<p class="field-hint">
Configure it as the agent <code>X-API-Key</code> header value.
</p>
<p class="mono secret-value">{{ secret }}</p>
<button class="btn btn-secondary" @click="copySecret">
@@ -196,18 +217,92 @@
<h3>Build site agent</h3>
<div class="card form-card">
<p class="field-hint">
Produce an instance-locked agent executable stamped with this server's
Produce an instance-locked agent package stamped with this server's
URL, the endpoint agent key, and the reporting cadence, so it deploys
with no manual configuration on the client.
with no manual configuration on the client. The download includes the
agent, a non-secret <code>appsettings.json</code>, and an
<code>install.ps1</code> that writes the credentials to the registry
(<code>HKLM\SOFTWARE\GE\ShopDB</code>) - the key never lands in
appsettings.
</p>
<p v-if="!agentstaged" class="field-hint">
<strong>No agent binary is staged on this server.</strong> The package
will still build with config and installer, but you must drop
<code>shopdbagent.exe</code> beside them before deploying. Stage a
prebuilt agent under <code>instance/siteagent/</code> to have it ship
in the download.
</p>
<label class="field-label" for="agent-baseurl">Server base URL</label>
<input
id="agent-baseurl"
v-model="agentbaseurl"
class="form-control"
type="text"
placeholder="https://shopdb.example"
/>
<label class="field-label" for="agent-key">Endpoint agent key</label>
<input
id="agent-key"
v-model="agentcollectorkey"
class="form-control mono"
type="text"
placeholder="Paste the endpoint agent key generated above"
/>
<label class="field-label" for="agent-cadence">
Reporting cadence (seconds)
</label>
<input
id="agent-cadence"
v-model.number="agentcadenceseconds"
class="form-control"
type="number"
min="60"
/>
<label v-if="certavailable" class="checkbox-row">
<input v-model="agentbundlecert" type="checkbox" />
<span>
Bundle this server's TLS certificate and trust it on the endpoint.
Enable only for self-signed or internal-CA servers; leave off when
the server uses a publicly trusted certificate.
</span>
</label>
<label class="field-label" for="agent-cert">
Server TLS certificate (optional)
</label>
<input
id="agent-cert"
ref="agentcertinput"
class="form-control"
type="file"
accept=".crt,.pem,.cer"
@change="onAgentCertChange"
/>
<p class="field-hint">
<strong>Coming soon.</strong> The build pipeline is not wired up yet -
the C# agent runtime is still under development. Generate a key above
and configure it manually as the agent <code>X-API-Key</code> for now.
For a reverse-proxy or internal-CA server this app cannot see, upload
the server's public certificate (or issuing CA) in PEM format. It is
validated, bundled with the package, and imported into the endpoint's
Trusted Root store by <code>install.ps1</code>. Leave empty when the
server uses a publicly trusted certificate. An upload takes precedence
over the bundled server certificate above.
</p>
<button class="btn btn-secondary" type="button" disabled>
Build agent (coming soon)
</button>
<div class="button-row">
<button
class="btn btn-primary"
type="button"
:disabled="agentbusy || !agentbaseurl || !agentcollectorkey"
@click="buildSiteAgent"
>
{{ agentbusy ? 'Building...' : 'Build site agent' }}
</button>
</div>
<p v-if="agentmessage" class="field-hint">{{ agentmessage }}</p>
<p v-if="agenterror" class="field-error">{{ agenterror }}</p>
</div>
</section>
@@ -255,6 +350,19 @@ const adoptbusy = ref(false)
const adoptmessage = ref('')
const adopterror = ref('')
// Site agent generator: build an instance-locked, resident endpoint agent.
const agentbaseurl = ref('')
const agentcollectorkey = ref('')
const agentcadenceseconds = ref(900)
const agentstaged = ref(false)
const certavailable = ref(false)
const agentbundlecert = ref(false)
const agentcertinput = ref(null)
const agentcertfile = ref(null)
const agentbusy = ref(false)
const agentmessage = ref('')
const agenterror = ref('')
function formatDate(value) {
if (!value) return ''
const parsed = new Date(value)
@@ -268,6 +376,7 @@ function applyStatus(data) {
onMounted(async () => {
adoptbaseurl.value = window.location.origin
agentbaseurl.value = window.location.origin
try {
const response = await api.get('/endpoints/agentkey')
applyStatus(response.data.data || {})
@@ -279,6 +388,15 @@ onMounted(async () => {
} finally {
loading.value = false
}
try {
const response = await api.get('/endpoints/siteagent')
const status = response.data.data || {}
agentstaged.value = !!status.agentstaged
certavailable.value = !!status.certavailable
agentbundlecert.value = !!status.certavailable
} catch (statusError) {
console.error(statusError)
}
})
async function regenerate() {
@@ -299,6 +417,7 @@ async function regenerate() {
secret.value = data.secret || ''
if (data.secret) {
adoptcollectorkey.value = data.secret
agentcollectorkey.value = data.secret
}
message.value = 'Endpoint agent key generated.'
} catch (regenError) {
@@ -366,6 +485,61 @@ async function downloadAdoptionTool() {
adoptbusy.value = false
}
}
function onAgentCertChange(event) {
const files = event.target.files
agentcertfile.value = files && files.length ? files[0] : null
}
async function readBlobError(blob) {
try {
const parsed = JSON.parse(await blob.text())
return parsed.data?.error?.message || parsed.message
|| 'Could not build the site agent'
} catch (parseError) {
return 'Could not build the site agent'
}
}
async function buildSiteAgent() {
agentbusy.value = true
agentmessage.value = ''
agenterror.value = ''
try {
const form = new FormData()
form.append('baseurl', agentbaseurl.value)
form.append('collectorkey', agentcollectorkey.value)
form.append('cadenceseconds', agentcadenceseconds.value)
form.append('bundlecert', agentbundlecert.value ? 'true' : 'false')
if (agentcertfile.value) {
form.append('certfile', agentcertfile.value)
}
const response = await api.post(
'/endpoints/siteagent/package',
form,
{ responseType: 'blob' })
const url = window.URL.createObjectURL(response.data)
const link = document.createElement('a')
link.href = url
link.download = 'shopdb-endpoint-agent.zip'
document.body.appendChild(link)
link.click()
link.remove()
window.URL.revokeObjectURL(url)
agentmessage.value = 'Site agent built.'
} catch (buildError) {
if (buildError.response?.status === 400 && buildError.response?.data) {
agenterror.value = await readBlobError(buildError.response.data)
} else {
agenterror.value = buildError.response?.status === 403
? 'Only an administrator can build the site agent'
: 'Could not build the site agent'
}
console.error(buildError)
} finally {
agentbusy.value = false
}
}
</script>
<style scoped>
@@ -374,7 +548,27 @@ async function downloadAdoptionTool() {
.form-card h4 { margin-top: 1rem; margin-bottom: 0.25rem; }
.form-card h4:first-child { margin-top: 0; }
.button-row { display: flex; gap: 0.5rem; }
.checkbox-row {
display: flex;
gap: 0.5rem;
align-items: flex-start;
margin: 0.75rem 0;
font-size: 0.9rem;
}
.checkbox-row input { margin-top: 0.2rem; }
.secret-card { border: 1px solid var(--color-warning, #b8860b); }
.key-warning {
margin: 0 0 0.75rem;
padding: 0.6rem 0.75rem;
border: 2px solid #ff0000;
border-radius: 4px;
background: rgba(255, 0, 0, 0.12);
color: #ff2d2d;
font-size: 1.25rem;
font-weight: 800;
letter-spacing: 0.02em;
text-align: center;
}
.secret-value { padding: 0.5rem; background: var(--color-surface-2, #1e1e1e); }
.settings-section { margin-bottom: 2rem; }
.settings-section > h3 { margin-bottom: 0.25rem; }

View File

@@ -0,0 +1,327 @@
<template>
<div>
<div class="page-header">
<h2>Delivery Test Bench</h2>
</div>
<!--
TEMPORARY / DEV ONLY. This view exists to exercise the update_app and
run_script delivery path end to end and to take notes for the real UI
(owned by Cameron). It is gated on endpoints.deploy. Remove it once the
production delivery UI lands. Nothing here is a supported operator surface.
-->
<div class="card dev-note">
<strong>Development test vehicle.</strong>
This bench pushes an allowlisted delivery command to one endpoint and reads
back the result. The agent only runs a script name it already holds in its
local allowlist, and only installs a ShopDB-hosted package pinned by digest.
Both actions require the <code>endpoints.deploy</code> permission.
</div>
<div v-if="!candeploy" class="card load-error">
Your account lacks the <code>endpoints.deploy</code> permission, so no
delivery command can be queued. Ask an administrator to grant it.
</div>
<template v-else>
<!-- 1. Pick the target endpoint -->
<div class="card bench-section">
<h3>1. Target endpoint</h3>
<div class="bench-row">
<input
v-model="endpointSearch"
type="text"
class="form-control"
placeholder="Search hostname or asset..."
@keyup.enter="loadEndpoints"
/>
<button class="btn btn-secondary btn-sm" :disabled="busy" @click="loadEndpoints">Search</button>
</div>
<select v-model="selectedAssetId" class="form-control" :disabled="busy">
<option value="">Select an endpoint...</option>
<option v-for="row in endpoints" :key="row.assetid" :value="row.assetid">
{{ row.hostname }} (asset {{ row.assetid }})
</option>
</select>
</div>
<!-- 2. Run a ShopDB-hosted script from the library -->
<div class="card bench-section">
<h3>2. Run script</h3>
<p class="bench-hint">
Pick a script from the ShopDB script library. The agent fetches the
script body by digest with its collector credential and runs it in the
script's configured run context (system or the interactive user).
</p>
<div class="bench-row">
<select v-model="selectedScriptId" class="form-control" :disabled="busy">
<option value="">Select script...</option>
<option v-for="script in scripts" :key="script.id" :value="script.id">
{{ script.scriptname }} ({{ script.runcontext }})
<template v-if="!script.sha256"> (no body - upload first)</template>
</option>
</select>
<button
class="btn btn-warning btn-sm"
:disabled="busy || !selectedAssetId || !selectedScriptId"
@click="pushScript"
>
Push run_script
</button>
</div>
</div>
<!-- 3. Install/update an application -->
<div class="card bench-section">
<h3>3. Install/update application</h3>
<p class="bench-hint">
Pick an application, then the app-level install source (the installer
uploaded on the application itself). The agent fetches the pinned
package by digest with its collector credential and runs it silently.
</p>
<div class="bench-row">
<select v-model="selectedAppId" class="form-control" :disabled="busy" @change="loadSources">
<option value="">Select application...</option>
<option v-for="app in applications" :key="app.appid" :value="app.appid">
{{ app.appname }}
</option>
</select>
<select v-model="selectedSource" class="form-control" :disabled="busy || !selectedAppId">
<option value="">Select install source...</option>
<option v-for="source in installSources" :key="source.value" :value="source.value">
{{ source.label }}
</option>
</select>
<button
class="btn btn-warning btn-sm"
:disabled="busy || !selectedAssetId || !selectedSource"
@click="pushUpdateApp"
>
Push update_app
</button>
</div>
</div>
<!-- 4. Command results (the Web UI log pane) -->
<div class="card bench-section">
<div class="bench-row bench-row-between">
<h3>4. Command log (Web UI side)</h3>
<button class="btn btn-secondary btn-sm" :disabled="busy || !selectedAssetId" @click="loadCommands">
Refresh
</button>
</div>
<p v-if="!selectedAssetId" class="bench-hint">Select an endpoint to see its recent commands.</p>
<table v-else-if="commands.length" class="table bench-table">
<thead>
<tr>
<th>When</th>
<th>Action</th>
<th>Target</th>
<th>Status</th>
<th>Result</th>
</tr>
</thead>
<tbody>
<tr v-for="command in commands" :key="command.commandid">
<td>{{ formatWhen(command.createdat) }}</td>
<td>{{ command.action }}</td>
<td>{{ command.target || '-' }}</td>
<td><span class="status-badge" :class="statusClass(command.status)">{{ command.status }}</span></td>
<td class="result-cell">{{ command.resultmessage || '-' }}</td>
</tr>
</tbody>
</table>
<p v-else class="bench-hint">No commands recorded for this endpoint yet.</p>
</div>
</template>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { endpointsApi, applicationsApi, scriptsApi } from '@/api'
import { useAuthStore } from '@/stores/auth'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
const authStore = useAuthStore()
const toast = useToast()
// Gated in the UI for convenience only; every enqueue is re-checked server-side.
const candeploy = computed(() => authStore.hasPermission('endpoints.deploy'))
const busy = ref(false)
const endpointSearch = ref('')
const endpoints = ref([])
const selectedAssetId = ref('')
const scripts = ref([])
const selectedScriptId = ref('')
const applications = ref([])
const selectedAppId = ref('')
const installSources = ref([])
const selectedSource = ref('')
const commands = ref([])
onMounted(() => {
if (!candeploy.value) return
loadEndpoints()
loadApplications()
loadScripts()
})
async function loadEndpoints() {
busy.value = true
try {
const params = { perpage: 100 }
if (endpointSearch.value) params.search = endpointSearch.value
const response = await endpointsApi.list(params)
endpoints.value = response.data.data || []
} catch (error) {
toast.error(apiError(error, 'Could not load endpoints'))
endpoints.value = []
} finally {
busy.value = false
}
}
async function loadApplications() {
try {
applications.value = await applicationsApi.listAll()
} catch (error) {
applications.value = []
}
}
async function loadScripts() {
try {
scripts.value = await scriptsApi.listAll()
} catch (error) {
scripts.value = []
}
}
// Build the install-source options for the chosen application: the app-level
// source (the installer uploaded on the application itself). The wire target is
// 'app:<id>', which the server resolves into a pinned delivery descriptor.
async function loadSources() {
selectedSource.value = ''
installSources.value = []
if (!selectedAppId.value) return
const sources = []
const app = applications.value.find(row => String(row.appid) === String(selectedAppId.value))
if (app && app.installpath) {
sources.push({
value: `app:${app.appid}`,
label: app.installersha256
? 'App-level installer'
: 'App-level installer (no digest - upload package first)',
})
}
installSources.value = sources
}
async function pushScript() {
await enqueue('run_script', `script:${selectedScriptId.value}`, 'run_script')
}
async function pushUpdateApp() {
await enqueue('update_app', selectedSource.value, 'update_app')
}
async function enqueue(action, target, label) {
if (!selectedAssetId.value) return
busy.value = true
try {
await endpointsApi.enqueueCommand(selectedAssetId.value, action, target)
toast.success(`${label} queued`)
await loadCommands()
} catch (error) {
// 409 = a command is already in flight for this endpoint (at-most-one queue).
const status = error.response?.status
if (status === 409) {
toast.info('A command is already in flight for this endpoint')
} else {
toast.error(apiError(error, `${label} failed`))
}
} finally {
busy.value = false
}
}
async function loadCommands() {
if (!selectedAssetId.value) return
try {
const response = await endpointsApi.listCommands(selectedAssetId.value)
commands.value = response.data.data || []
} catch (error) {
commands.value = []
}
}
function formatWhen(iso) {
if (!iso) return '-'
const date = new Date(iso)
return Number.isNaN(date.getTime()) ? iso : date.toLocaleString()
}
function statusClass(status) {
if (status === 'succeeded') return 'ok'
if (status === 'failed') return 'bad'
if (status === 'expired') return 'bad'
if (status === 'running') return 'warn'
return ''
}
</script>
<style scoped>
.dev-note {
border-left: 3px solid var(--warning, #d6a012);
}
.bench-section h3 {
margin-top: 0;
}
.bench-row {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
align-items: center;
}
.bench-row-between {
justify-content: space-between;
}
.bench-hint {
color: var(--text-light);
font-size: 0.9rem;
}
.bench-table {
width: 100%;
}
.result-cell {
max-width: 32rem;
white-space: pre-wrap;
word-break: break-word;
}
.status-badge {
padding: 0.1rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.85rem;
}
.status-badge.ok {
color: var(--success, #2e7d32);
}
.status-badge.bad {
color: var(--danger, #c62828);
}
.status-badge.warn {
color: var(--warning, #d6a012);
}
.load-error {
color: var(--danger, #c62828);
}
</style>