feat(api): implement self-update mechanism for Linux agents
- Added a `maybe_self_update` function in `evofw-firewall.sh` to allow agents to pull the latest version of the sync script from the server, enhancing the agent's ability to stay updated. - Updated the `/v1/agent/sync-script` endpoint to return ETag and script SHA256 headers, enabling efficient caching and conditional requests. - Modified the agent policy response to include `script_sha256`, providing visibility into the current version of the sync script. - Enhanced tests to verify the self-update functionality and ensure correct behavior of the sync script endpoint. These changes improve the maintainability and reliability of Linux agents by enabling automatic updates of critical scripts.
This commit is contained in:
@@ -24,9 +24,81 @@ source "$CONF_FILE"
|
||||
CLIENT_TOKEN="${CLIENT_TOKEN//$'\r'/}"
|
||||
CLIENT_TOKEN="${CLIENT_TOKEN//$'\n'/}"
|
||||
BACKEND="${KERNEL_BACKEND:-auto}"
|
||||
SYNC_SCRIPT=/usr/local/sbin/evofw-firewall.sh
|
||||
|
||||
mkdir -p "$STATE_DIR"
|
||||
|
||||
file_sha256() {
|
||||
local f=$1
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$f" 2>/dev/null | awk '{print $1}'
|
||||
elif command -v openssl >/dev/null 2>&1; then
|
||||
openssl dgst -sha256 "$f" 2>/dev/null | awk '{print $NF}'
|
||||
else
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
# Pull a newer sync script from CP before policy (pending agents too).
|
||||
# Errors must not abort this run — keep the current script.
|
||||
maybe_self_update() {
|
||||
if [[ "${EVOFW_SKIP_SELF_UPDATE:-}" == "1" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if [[ ! -f "$SYNC_SCRIPT" ]]; then
|
||||
return 0
|
||||
fi
|
||||
local local_sha tmp code remote_sha
|
||||
local_sha=$(file_sha256 "$SYNC_SCRIPT")
|
||||
tmp=$(mktemp "${STATE_DIR}/sync-script.XXXXXX") || return 0
|
||||
if [[ -n "$local_sha" ]]; then
|
||||
code=$(curl -sS -o "$tmp" -w '%{http_code}' \
|
||||
-H "If-None-Match: \"${local_sha}\"" \
|
||||
"${EVOFW_CP_URL%/}/v1/agent/sync-script") || code="000"
|
||||
else
|
||||
code=$(curl -sS -o "$tmp" -w '%{http_code}' \
|
||||
"${EVOFW_CP_URL%/}/v1/agent/sync-script") || code="000"
|
||||
fi
|
||||
if [[ "$code" == "304" ]]; then
|
||||
rm -f "$tmp"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$code" != "200" ]]; then
|
||||
log "self-update: sync-script HTTP ${code} — keep current"
|
||||
rm -f "$tmp"
|
||||
return 0
|
||||
fi
|
||||
if ! head -n1 "$tmp" | grep -q '^#!'; then
|
||||
log "self-update: sync-script is not a shell script — keep current"
|
||||
rm -f "$tmp"
|
||||
return 0
|
||||
fi
|
||||
remote_sha=$(file_sha256 "$tmp")
|
||||
if [[ -z "$remote_sha" ]]; then
|
||||
log "self-update: cannot hash download — keep current"
|
||||
rm -f "$tmp"
|
||||
return 0
|
||||
fi
|
||||
if [[ -n "$local_sha" && "$remote_sha" == "$local_sha" ]]; then
|
||||
rm -f "$tmp"
|
||||
return 0
|
||||
fi
|
||||
if ! install -m 755 "$tmp" "$SYNC_SCRIPT"; then
|
||||
log "self-update: install failed — keep current"
|
||||
rm -f "$tmp"
|
||||
return 0
|
||||
fi
|
||||
rm -f "$tmp"
|
||||
rm -f "$HASH_FILE"
|
||||
log "self-update: installed script sha256=${remote_sha} — re-exec"
|
||||
exec env EVOFW_SKIP_SELF_UPDATE=1 "$SYNC_SCRIPT" || {
|
||||
log "self-update: exec failed — continue current"
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
maybe_self_update
|
||||
|
||||
curl_policy() {
|
||||
local dest="$1"
|
||||
local code
|
||||
|
||||
@@ -11,6 +11,10 @@ import { renderMikrotikPolicyRsc } from '../services/policy/mikrotik-rsc.js'
|
||||
import { AppError } from '../plugins/error-handler.js'
|
||||
import { resolveAgentScriptsDir } from '../services/agent-scripts-path.js'
|
||||
import { resolveAndRenderInstall } from '../services/install-links.js'
|
||||
import {
|
||||
getSyncScriptMeta,
|
||||
ifNoneMatchHits,
|
||||
} from '../services/sync-script.js'
|
||||
|
||||
const scriptsDir = resolveAgentScriptsDir()
|
||||
|
||||
@@ -60,9 +64,15 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
},
|
||||
)
|
||||
|
||||
app.get('/v1/agent/sync-script', async (_req, reply) => {
|
||||
const body = readFileSync(join(scriptsDir, 'evofw-firewall.sh'), 'utf-8')
|
||||
return reply.type('text/x-shellscript').send(body)
|
||||
app.get('/v1/agent/sync-script', async (req, reply) => {
|
||||
const meta = getSyncScriptMeta()
|
||||
reply.header('ETag', meta.etag)
|
||||
reply.header('X-Evofw-Script-Sha256', meta.sha256)
|
||||
const inm = req.headers['if-none-match']
|
||||
if (ifNoneMatchHits(inm, meta.etag)) {
|
||||
return reply.code(304).send()
|
||||
}
|
||||
return reply.type('text/x-shellscript').send(meta.body)
|
||||
})
|
||||
|
||||
app.get('/v1/agent/uninstall.sh', async (_req, reply) => {
|
||||
@@ -167,12 +177,14 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
lastSeenIp: req.ip,
|
||||
})
|
||||
const policy = evaluateAgentPolicy(app.db, agentId)
|
||||
const scriptSha = getSyncScriptMeta().sha256
|
||||
return {
|
||||
generation: policy.generation,
|
||||
hash: policy.hash,
|
||||
apply_version: policy.applyVersion,
|
||||
default_action: policy.defaultAction,
|
||||
policy_mode: policy.policyMode,
|
||||
script_sha256: scriptSha,
|
||||
deny_cidrs: policy.denyCidrs,
|
||||
allow_cidrs: policy.allowCidrs,
|
||||
port_rules: policy.portRules.map((r) => ({
|
||||
|
||||
@@ -193,6 +193,7 @@ describe('install-links', () => {
|
||||
policy_mode: string
|
||||
apply_version: number
|
||||
hash: string
|
||||
script_sha256: string
|
||||
}
|
||||
expect(body.deny_cidrs).toEqual([])
|
||||
expect(body.allow_cidrs).toEqual([])
|
||||
@@ -200,6 +201,7 @@ describe('install-links', () => {
|
||||
expect(body.policy_mode).toBe('blacklist')
|
||||
expect(body.apply_version).toBe(3)
|
||||
expect(body.hash).toMatch(/^sha256:/)
|
||||
expect(body.script_sha256).toMatch(/^[a-f0-9]{64}$/)
|
||||
|
||||
const agents = await app.inject({ method: 'GET', url: '/api/v1/agents' })
|
||||
const row = (
|
||||
@@ -259,4 +261,70 @@ describe('install-links', () => {
|
||||
expect(rsc.body).toContain('EVOFW_DENY')
|
||||
expect(rsc.body).toContain('203.0.113.0/24')
|
||||
})
|
||||
|
||||
it('sync-script serves ETag and 304 on If-None-Match', async () => {
|
||||
const app = await appPromise
|
||||
await app.ready()
|
||||
|
||||
const first = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/v1/agent/sync-script',
|
||||
})
|
||||
expect(first.statusCode).toBe(200)
|
||||
expect(first.body.startsWith('#!')).toBe(true)
|
||||
expect(first.body).toContain('maybe_self_update')
|
||||
const etag = String(first.headers.etag ?? '')
|
||||
const sha = String(first.headers['x-evofw-script-sha256'] ?? '')
|
||||
expect(etag).toMatch(/^"[a-f0-9]{64}"$/)
|
||||
expect(sha).toBe(etag.replaceAll('"', ''))
|
||||
|
||||
const cached = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/v1/agent/sync-script',
|
||||
headers: { 'if-none-match': etag },
|
||||
})
|
||||
expect(cached.statusCode).toBe(304)
|
||||
|
||||
const miss = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/v1/agent/sync-script',
|
||||
headers: { 'if-none-match': '"deadbeef"' },
|
||||
})
|
||||
expect(miss.statusCode).toBe(200)
|
||||
expect(miss.body).toBe(first.body)
|
||||
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/install-links',
|
||||
payload: { name: 'script-sha-policy', platform: 'linux' },
|
||||
})
|
||||
const link = created.json() as { id: string; agent_id: string }
|
||||
const token = 'evofw_script_sha_token_abcdefghij'
|
||||
const enroll = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/agent/enroll',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-evofw-seed': 'test-seed',
|
||||
},
|
||||
payload: {
|
||||
name: 'script-sha-policy',
|
||||
platform: 'linux',
|
||||
token,
|
||||
install_link_id: link.id,
|
||||
},
|
||||
})
|
||||
expect(enroll.statusCode).toBe(201)
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/agents/${link.agent_id}/approve`,
|
||||
})
|
||||
const policy = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/v1/agent/policy',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(policy.statusCode).toBe(200)
|
||||
expect((policy.json() as { script_sha256: string }).script_sha256).toBe(sha)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { ifNoneMatchHits, getSyncScriptMeta } from './sync-script.js'
|
||||
|
||||
describe('sync-script meta', () => {
|
||||
it('hashes evofw-firewall.sh and matches ETag', () => {
|
||||
const meta = getSyncScriptMeta()
|
||||
expect(meta.sha256).toMatch(/^[a-f0-9]{64}$/)
|
||||
expect(meta.etag).toBe(`"${meta.sha256}"`)
|
||||
expect(meta.body.subarray(0, 2).toString()).toBe('#!')
|
||||
})
|
||||
|
||||
it('ifNoneMatchHits understands quoted, weak, and star', () => {
|
||||
const etag = '"abc"'
|
||||
expect(ifNoneMatchHits('"abc"', etag)).toBe(true)
|
||||
expect(ifNoneMatchHits('abc', etag)).toBe(true)
|
||||
expect(ifNoneMatchHits('W/"abc"', etag)).toBe(true)
|
||||
expect(ifNoneMatchHits('*', etag)).toBe(true)
|
||||
expect(ifNoneMatchHits('"nope"', etag)).toBe(false)
|
||||
expect(ifNoneMatchHits(undefined, etag)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { join } from 'node:path'
|
||||
import { resolveAgentScriptsDir } from './agent-scripts-path.js'
|
||||
|
||||
export type SyncScriptMeta = {
|
||||
body: Buffer
|
||||
sha256: string
|
||||
etag: string
|
||||
}
|
||||
|
||||
let cache: SyncScriptMeta | null = null
|
||||
|
||||
/** Linux sync agent script + sha256 of file bytes (process-lifetime cache). */
|
||||
export function getSyncScriptMeta(): SyncScriptMeta {
|
||||
if (cache) return cache
|
||||
const dir = resolveAgentScriptsDir()
|
||||
const body = readFileSync(join(dir, 'evofw-firewall.sh'))
|
||||
const sha256 = createHash('sha256').update(body).digest('hex')
|
||||
cache = { body, sha256, etag: `"${sha256}"` }
|
||||
return cache
|
||||
}
|
||||
|
||||
/** Compare If-None-Match with our ETag (`"<sha256>"`). */
|
||||
export function ifNoneMatchHits(
|
||||
header: string | string[] | undefined,
|
||||
etag: string,
|
||||
): boolean {
|
||||
if (!header) return false
|
||||
const raw = Array.isArray(header) ? header.join(',') : header
|
||||
const want = etag.replaceAll('"', '').toLowerCase()
|
||||
for (const part of raw.split(',')) {
|
||||
let token = part.trim()
|
||||
if (token.startsWith('W/')) token = token.slice(2).trim()
|
||||
token = token.replaceAll('"', '')
|
||||
if (token === '*' || token.toLowerCase() === want) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user