From d516a9a093329d2c1cbe7efc6b739c62c1bfca80 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 21 Jul 2026 19:25:34 +0700 Subject: [PATCH] feat(api): enhance agent scripts and enrollment process - Updated `evofw-firewall.sh` to handle empty deny/allow rule sets, allowing agents without rules to function correctly. - Improved `install.sh` to ensure the sync script is downloaded before enrollment, with added validation for the script's content. - Modified agent route to record `lastSeenAt` and `lastSeenIp` during enrollment and policy fetching, ensuring accurate tracking of agent status. - Added tests to verify that approved agents can fetch an empty policy without rule sets. Co-authored-by: Cursor --- apps/api/src/agent-scripts/evofw-firewall.sh | 11 ++-- apps/api/src/agent-scripts/install.sh | 31 +++++++++-- apps/api/src/routes/agent.ts | 12 +++-- apps/api/src/services/install-links.test.ts | 57 ++++++++++++++++++++ 4 files changed, 99 insertions(+), 12 deletions(-) diff --git a/apps/api/src/agent-scripts/evofw-firewall.sh b/apps/api/src/agent-scripts/evofw-firewall.sh index 3df5017..68e660f 100644 --- a/apps/api/src/agent-scripts/evofw-firewall.sh +++ b/apps/api/src/agent-scripts/evofw-firewall.sh @@ -76,6 +76,9 @@ PY HASH=""; MODE=blacklist; DENY=(); ALLOW=() parse_policy "$POLICY_FILE" +# Empty deny/allow is valid — agent may have no rule sets yet. +DENY=("${DENY[@]+"${DENY[@]}"}") +ALLOW=("${ALLOW[@]+"${ALLOW[@]}"}") log "mode=$MODE deny=${#DENY[@]} allow=${#ALLOW[@]} hash=$HASH" PACKETS_DROPPED=0 @@ -116,8 +119,8 @@ collect_nft_stats() { apply_nft() { local table=inet name=evofw local deny_v4=() allow_v4=() p - for p in "${DENY[@]}"; do [[ "$p" == *:* ]] && continue; deny_v4+=("$p"); done - for p in "${ALLOW[@]}"; do [[ "$p" == *:* ]] && continue; allow_v4+=("$p"); done + for p in "${DENY[@]+"${DENY[@]}"}"; do [[ "$p" == *:* ]] && continue; deny_v4+=("$p"); done + for p in "${ALLOW[@]+"${ALLOW[@]}"}"; do [[ "$p" == *:* ]] && continue; allow_v4+=("$p"); done nft list table "$table" "$name" >/dev/null 2>&1 || nft add table "$table" "$name" nft list set "$table" "$name" deny_v4 >/dev/null 2>&1 || \ @@ -162,8 +165,8 @@ apply_ipset() { ipset list "$aset" >/dev/null 2>&1 || ipset create "$aset" hash:net family inet ipset flush "$dset"; ipset flush "$aset" local p n=0 - for p in "${DENY[@]}"; do [[ "$p" == *:* ]] && continue; ipset add "$dset" "$p" -exist; n=$((n+1)); done - for p in "${ALLOW[@]}"; do [[ "$p" == *:* ]] && continue; ipset add "$aset" "$p" -exist; n=$((n+1)); done + for p in "${DENY[@]+"${DENY[@]}"}"; do [[ "$p" == *:* ]] && continue; ipset add "$dset" "$p" -exist; n=$((n+1)); done + for p in "${ALLOW[@]+"${ALLOW[@]}"}"; do [[ "$p" == *:* ]] && continue; ipset add "$aset" "$p" -exist; n=$((n+1)); done iptables -D INPUT -m set --match-set "$dset" src -j DROP 2>/dev/null || true iptables -D INPUT -m set --match-set "$aset" src -j ACCEPT 2>/dev/null || true if [[ "$MODE" == "whitelist" ]]; then diff --git a/apps/api/src/agent-scripts/install.sh b/apps/api/src/agent-scripts/install.sh index 79cfbbf..8627164 100644 --- a/apps/api/src/agent-scripts/install.sh +++ b/apps/api/src/agent-scripts/install.sh @@ -43,6 +43,19 @@ CLIENT_TOKEN="$(gen_token)" HOSTNAME="$(hostname -f 2>/dev/null || hostname)" CP_URL="${EVOFW_CP_URL%/}" +# Fail fast: pull sync script before enroll so we never leave a DB agent without a local agent. +SYNC_TMP=$(mktemp) +ENROLL_TMP=$(mktemp) +trap 'rm -f "$SYNC_TMP" "$ENROLL_TMP"' EXIT +if ! curl -fsSL "${CP_URL}/v1/agent/sync-script" -o "$SYNC_TMP"; then + echo "failed to download sync script from ${CP_URL}/v1/agent/sync-script" >&2 + exit 1 +fi +if ! head -n1 "$SYNC_TMP" | grep -q '^#!'; then + echo "sync script is not a shell script (CP returned unexpected body)" >&2 + exit 1 +fi + if [[ -n "${EVOFW_INSTALL_LINK_ID:-}" ]]; then ENROLL_BODY=$(printf '{"name":"%s","hostname":"%s","platform":"%s","token":"%s","client_version":"install.sh/1","install_link_id":"%s"}' \ "$EVOFW_CLIENT_NAME" "$HOSTNAME" "$PLATFORM" "$CLIENT_TOKEN" "$EVOFW_INSTALL_LINK_ID") @@ -51,8 +64,6 @@ else "$EVOFW_CLIENT_NAME" "$HOSTNAME" "$PLATFORM" "$CLIENT_TOKEN") fi -ENROLL_TMP=$(mktemp) -trap 'rm -f "$ENROLL_TMP"' EXIT ENROLL_CODE=$(curl -sS -o "$ENROLL_TMP" -w "%{http_code}" -X POST "${CP_URL}/v1/agent/enroll" \ -H "Content-Type: application/json" \ -H "X-EvoFW-Seed: ${EVOFW_SEED}" \ @@ -69,6 +80,11 @@ if command -v jq >/dev/null 2>&1; then else CLIENT_ID=$(echo "$RESP" | sed -n 's/.*"client_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') fi +if [[ -z "$CLIENT_ID" || "$CLIENT_ID" == "null" ]]; then + echo "enroll response missing client_id" >&2 + cat "$ENROLL_TMP" >&2 + exit 1 +fi mkdir -p "$CONF_DIR" chmod 700 "$CONF_DIR" @@ -81,8 +97,7 @@ KERNEL_BACKEND=auto EOF chmod 600 "$CONF_FILE" -curl -fsSL "${CP_URL}/v1/agent/sync-script" -o "$SYNC_SCRIPT" -chmod 755 "$SYNC_SCRIPT" +install -m 755 "$SYNC_TMP" "$SYNC_SCRIPT" if command -v nft >/dev/null 2>&1; then BACKEND=nft @@ -103,6 +118,7 @@ if command -v systemctl >/dev/null 2>&1; then [Unit] Description=EvoFirewall sync After=network-online.target +Wants=network-online.target [Service] Type=oneshot @@ -116,6 +132,7 @@ Description=EvoFirewall sync timer OnBootSec=30s OnUnitActiveSec=${INTERVAL} AccuracySec=5s +Persistent=true Unit=evofw-firewall.service [Install] @@ -123,8 +140,12 @@ WantedBy=timers.target UNIT systemctl daemon-reload systemctl enable --now evofw-firewall.timer + # First run now (pending → log "pending approval"; after Approve → empty policy is OK). + systemctl start evofw-firewall.service || true else (crontab -l 2>/dev/null | grep -v evofw-firewall; echo "*/1 * * * * $SYNC_SCRIPT") | crontab - + "$SYNC_SCRIPT" || true fi -echo "Installed. Client id=${CLIENT_ID}. Approve in EvoFirewall UI, then: $SYNC_SCRIPT" +echo "Installed. Client id=${CLIENT_ID}. Approve in EvoFirewall UI (rules optional — can assign later)." +echo "If Still offline after Approve, run: $SYNC_SCRIPT" diff --git a/apps/api/src/routes/agent.ts b/apps/api/src/routes/agent.ts index e4c5569..b8cd66d 100644 --- a/apps/api/src/routes/agent.ts +++ b/apps/api/src/routes/agent.ts @@ -96,6 +96,8 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( tokenHash, status: 'pending', clientVersion: body.client_version ?? null, + lastSeenAt: new Date().toISOString(), + lastSeenIp: req.ip, }) return reply.code(201).send({ client_id: agent!.id, @@ -106,6 +108,7 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( } const id = crypto.randomUUID() + const now = new Date().toISOString() const agent = repos.insertAgent(app.db, { id, name: body.name, @@ -118,7 +121,9 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( policyGeneration: 1, clientVersion: body.client_version ?? null, settingsJson: '{}', - createdAt: new Date().toISOString(), + createdAt: now, + lastSeenAt: now, + lastSeenIp: req.ip, }) return reply.code(201).send({ client_id: agent!.id, @@ -130,11 +135,12 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( app.get('/v1/agent/policy', async (req) => { const agentId = req.agentId! - const policy = evaluateAgentPolicy(app.db, agentId) + // Record contact first — empty policy (no rule sets) is a valid online state. repos.updateAgent(app.db, agentId, { lastSeenAt: new Date().toISOString(), lastSeenIp: req.ip, }) + const policy = evaluateAgentPolicy(app.db, agentId) return { generation: policy.generation, hash: policy.hash, @@ -156,11 +162,11 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( app.get('/v1/agent/policy.rsc', async (req, reply) => { const agentId = req.agentId! - const policy = evaluateAgentPolicy(app.db, agentId) repos.updateAgent(app.db, agentId, { lastSeenAt: new Date().toISOString(), lastSeenIp: req.ip, }) + const policy = evaluateAgentPolicy(app.db, agentId) return reply.type('text/plain').send(renderMikrotikPolicyRsc(policy)) }) diff --git a/apps/api/src/services/install-links.test.ts b/apps/api/src/services/install-links.test.ts index 1bcf7d5..2d17cfb 100644 --- a/apps/api/src/services/install-links.test.ts +++ b/apps/api/src/services/install-links.test.ts @@ -144,6 +144,63 @@ describe('install-links', () => { expect(byId.body).toContain('/v1/agent/policy.rsc') }) + it('approved agent can fetch empty policy without rule sets', async () => { + const app = await appPromise + await app.ready() + + const created = await app.inject({ + method: 'POST', + url: '/api/v1/install-links', + payload: { name: 'empty-policy', platform: 'linux' }, + }) + const link = created.json() as { id: string; agent_id: string } + const token = 'evofw_empty_policy_token_abcdefgh' + + const enroll = await app.inject({ + method: 'POST', + url: '/v1/agent/enroll', + headers: { + 'content-type': 'application/json', + 'x-evofw-seed': 'test-seed', + }, + payload: { + name: 'empty-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) + const body = policy.json() as { + deny_cidrs: string[] + allow_cidrs: string[] + policy_mode: string + hash: string + } + expect(body.deny_cidrs).toEqual([]) + expect(body.allow_cidrs).toEqual([]) + expect(body.policy_mode).toBe('blacklist') + expect(body.hash).toMatch(/^sha256:/) + + const agents = await app.inject({ method: 'GET', url: '/api/v1/agents' }) + const row = ( + agents.json() as { items: { id: string; last_seen_at: string | null }[] } + ).items.find((a) => a.id === link.agent_id) + expect(row?.last_seen_at).toBeTruthy() + }) + it('approved agent can fetch policy.rsc with address-list commands', async () => { const app = await appPromise await app.ready()