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 <[email protected]>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user