feat(api): enhance agent scripts and enrollment process
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m43s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

- 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 <[email protected]>
This commit is contained in:
Denozordec
2026-07-21 19:25:34 +07:00
co-authored by Cursor
parent 08e7c4d755
commit d516a9a093
4 changed files with 99 additions and 12 deletions
+7 -4
View File
@@ -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
+26 -5
View File
@@ -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"
+9 -3
View File
@@ -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))
})
@@ -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()