From d552f4f326069c292b682f8930fcfb36c51ec6b3 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 15 Aug 2026 16:56:00 +0700 Subject: [PATCH] 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. --- apps/api/src/agent-scripts/evofw-firewall.sh | 72 ++++++++++++++++++++ apps/api/src/routes/agent.ts | 18 ++++- apps/api/src/services/install-links.test.ts | 68 ++++++++++++++++++ apps/api/src/services/sync-script.test.ts | 21 ++++++ apps/api/src/services/sync-script.ts | 39 +++++++++++ docs/agents.md | 17 ++++- docs/architecture.md | 7 +- docs/openapi.yaml | 24 ++++++- packages/shared/src/contracts.ts | 2 + 9 files changed, 260 insertions(+), 8 deletions(-) create mode 100644 apps/api/src/services/sync-script.test.ts create mode 100644 apps/api/src/services/sync-script.ts diff --git a/apps/api/src/agent-scripts/evofw-firewall.sh b/apps/api/src/agent-scripts/evofw-firewall.sh index ff6ffa5..86309a4 100644 --- a/apps/api/src/agent-scripts/evofw-firewall.sh +++ b/apps/api/src/agent-scripts/evofw-firewall.sh @@ -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 diff --git a/apps/api/src/routes/agent.ts b/apps/api/src/routes/agent.ts index f9ad3f5..7f82a61 100644 --- a/apps/api/src/routes/agent.ts +++ b/apps/api/src/routes/agent.ts @@ -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) => ({ diff --git a/apps/api/src/services/install-links.test.ts b/apps/api/src/services/install-links.test.ts index bf4a3be..21368f8 100644 --- a/apps/api/src/services/install-links.test.ts +++ b/apps/api/src/services/install-links.test.ts @@ -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) + }) }) diff --git a/apps/api/src/services/sync-script.test.ts b/apps/api/src/services/sync-script.test.ts new file mode 100644 index 0000000..17c7d11 --- /dev/null +++ b/apps/api/src/services/sync-script.test.ts @@ -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) + }) +}) diff --git a/apps/api/src/services/sync-script.ts b/apps/api/src/services/sync-script.ts new file mode 100644 index 0000000..c927317 --- /dev/null +++ b/apps/api/src/services/sync-script.ts @@ -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 (`""`). */ +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 +} diff --git a/docs/agents.md b/docs/agents.md index d6cdb0a..0c6ea6e 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -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: ""` → **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:///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. diff --git a/docs/architecture.md b/docs/architecture.md index 618b43f..4a981c2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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) ## Политика diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 443e98d..9dcd712 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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 (`""`) + responses: + '200': + description: Shell script + headers: + ETag: + schema: { type: string } + description: '""' + 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: diff --git a/packages/shared/src/contracts.ts b/packages/shared/src/contracts.ts index 172552f..fe5c071 100644 --- a/packages/shared/src/contracts.ts +++ b/packages/shared/src/contracts.ts @@ -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({