feat(api): implement self-update mechanism for Linux agents
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m45s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

- 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:
Denozordec
2026-08-15 16:56:00 +07:00
parent ef2a333430
commit d552f4f326
9 changed files with 260 additions and 8 deletions
@@ -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
+15 -3
View File
@@ -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)
})
})
+21
View File
@@ -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)
})
})
+39
View File
@@ -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
}
+16 -1
View File
@@ -42,6 +42,21 @@ Install сам ставит зависимости через apt/dnf/yum/apk: `
Если `/etc/evofw/agent.conf` уже есть — install переходит в **update**: скачивает свежий `sync-script` + `uninstall.sh`, перезаписывает unit/timer, оставляет токен. `EVOFW_INSTALL_FORCE=1` — полный re-enroll (новый токен; для уже Approved install-link обычно не сработает).
### Linux self-update (sync-script)
После того как на хосте стоит скрипт с `maybe_self_update`, агент **сам** подтягивает новую версию при каждом timer (~1 мин), **до** `GET /v1/agent/policy` (pending тоже обновляются):
1. sha256 локального `/usr/local/sbin/evofw-firewall.sh`
2. `GET /v1/agent/sync-script` с `If-None-Match: "<sha256>"`**304** = без изменений
3. **200**: проверка shebang + sha256 тела → `install -m 755`, сброс `last_hash`, `exec EVOFW_SKIP_SELF_UPDATE=1` новой копии
4. Ошибка download/verify — лог и продолжение **текущим** скриптом
`GET /v1/agent/policy` содержит `script_sha256` (не входит в `policy.hash`).
**Bootstrap:** агенты без `maybe_self_update` не умеют самообновляться. После деплоя API — **один** re-run install-ссылки (или ручная замена `sync-script`). Дальше curl не нужен.
MikroTik scheduler **не** обновляется этим путём — только повторный import install `.rsc`.
**Uninstall (Linux):**
```bash
curl -fsSL https://<cp>/v1/agent/uninstall.sh | bash
@@ -77,7 +92,7 @@ IPv6 skipped.
- `GET /api/v1/agents/:id/blocked-ports` — aggregate top 50 портов; в `blocked-ips` у каждого IP — `ports` top 5.
- UI: **Top ports** + колонка Ports в Blocked IPs (только `platform=linux`).
- **ipset/iptables:** `port_hits: []`. MikroTik — без port hits.
- Чтобы подтянуть правила на уже установленном агенте: **re-run install one-liner** (см. выше).
- Агенты с self-update подтянут nft-правила сами; без него — **один** re-run install one-liner (см. выше).
IPv6 skipped.
+4 -3
View File
@@ -17,9 +17,10 @@
1. **Enroll**`POST /v1/agent/enroll` + `X-EvoFW-Seed` → pending agent
2. **Approve** — UI/API → status approved
3. **Policy**`GET /v1/agent/policy` → deny/allow CIDRs + `default_action` + optional `port_rules` + hash (`apply_version: 3`)
4. **Apply** — agent пишет kernel rules (L3 + L4 port ACL на nft), `POST /v1/agent/apply-report` + stats + optional `host_firewall` snapshot
5. **Lists refresh** — cron каждые 5 мин (json_url / domains / evobgp_community)
3. **Policy**`GET /v1/agent/policy` → deny/allow CIDRs + `default_action` + optional `port_rules` + hash (`apply_version: 3`) + `script_sha256` (Linux; не в `policy.hash`)
4. **Linux self-update** — timer: `GET /v1/agent/sync-script` (`ETag` / `If-None-Match`) → при новой версии заменить `/usr/local/sbin/evofw-firewall.sh` и `exec` до policy
5. **Apply** — agent пишет kernel rules (L3 + L4 port ACL на nft), `POST /v1/agent/apply-report` + stats + optional `host_firewall` snapshot
6. **Lists refresh** — cron каждые 5 мин (json_url / domains / evobgp_community)
## Политика
+23 -1
View File
@@ -675,6 +675,28 @@ paths:
'200':
description: Shell script
/v1/agent/sync-script:
get:
summary: Linux sync agent (evofw-firewall.sh)
tags: [agent-public]
parameters:
- name: If-None-Match
in: header
schema: { type: string }
description: ETag from a previous GET (`"<sha256>"`)
responses:
'200':
description: Shell script
headers:
ETag:
schema: { type: string }
description: '"<sha256 of file bytes>"'
X-Evofw-Script-Sha256:
schema: { type: string }
description: Hex sha256 of the script body
'304':
description: Script unchanged
/v1/agent/enroll:
post:
summary: Enroll agent (X-EvoFW-Seed)
@@ -690,7 +712,7 @@ paths:
security: [{ agentToken: [] }]
responses:
'200':
description: Policy
description: Policy (includes script_sha256 for Linux self-update; not part of policy.hash)
/v1/agent/policy.rsc:
get:
+2
View File
@@ -444,6 +444,8 @@ export const agentPolicySchema = z.object({
allow_cidrs: z.array(z.string()),
port_rules: z.array(agentPolicyPortRuleSchema).optional(),
sync_interval_sec: z.number().int(),
/** sha256 of GET /v1/agent/sync-script (Linux self-update). Not part of policy.hash. */
script_sha256: z.string().optional(),
})
export const agentPolicyPreviewSchema = z.object({