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
committed by cproudlock
parent 7ac1772881
commit 4590156f30
11 changed files with 1493 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; }