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=()
|
HASH=""; MODE=blacklist; DENY=(); ALLOW=()
|
||||||
parse_policy "$POLICY_FILE"
|
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"
|
log "mode=$MODE deny=${#DENY[@]} allow=${#ALLOW[@]} hash=$HASH"
|
||||||
|
|
||||||
PACKETS_DROPPED=0
|
PACKETS_DROPPED=0
|
||||||
@@ -116,8 +119,8 @@ collect_nft_stats() {
|
|||||||
apply_nft() {
|
apply_nft() {
|
||||||
local table=inet name=evofw
|
local table=inet name=evofw
|
||||||
local deny_v4=() allow_v4=() p
|
local deny_v4=() allow_v4=() p
|
||||||
for p in "${DENY[@]}"; do [[ "$p" == *:* ]] && continue; deny_v4+=("$p"); done
|
for p in "${DENY[@]+"${DENY[@]}"}"; do [[ "$p" == *:* ]] && continue; deny_v4+=("$p"); done
|
||||||
for p in "${ALLOW[@]}"; do [[ "$p" == *:* ]] && continue; allow_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 table "$table" "$name" >/dev/null 2>&1 || nft add table "$table" "$name"
|
||||||
nft list set "$table" "$name" deny_v4 >/dev/null 2>&1 || \
|
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 list "$aset" >/dev/null 2>&1 || ipset create "$aset" hash:net family inet
|
||||||
ipset flush "$dset"; ipset flush "$aset"
|
ipset flush "$dset"; ipset flush "$aset"
|
||||||
local p n=0
|
local p n=0
|
||||||
for p in "${DENY[@]}"; do [[ "$p" == *:* ]] && continue; ipset add "$dset" "$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[@]}"; do [[ "$p" == *:* ]] && continue; ipset add "$aset" "$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 "$dset" src -j DROP 2>/dev/null || true
|
||||||
iptables -D INPUT -m set --match-set "$aset" src -j ACCEPT 2>/dev/null || true
|
iptables -D INPUT -m set --match-set "$aset" src -j ACCEPT 2>/dev/null || true
|
||||||
if [[ "$MODE" == "whitelist" ]]; then
|
if [[ "$MODE" == "whitelist" ]]; then
|
||||||
|
|||||||
@@ -43,6 +43,19 @@ CLIENT_TOKEN="$(gen_token)"
|
|||||||
HOSTNAME="$(hostname -f 2>/dev/null || hostname)"
|
HOSTNAME="$(hostname -f 2>/dev/null || hostname)"
|
||||||
CP_URL="${EVOFW_CP_URL%/}"
|
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
|
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"}' \
|
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")
|
"$EVOFW_CLIENT_NAME" "$HOSTNAME" "$PLATFORM" "$CLIENT_TOKEN" "$EVOFW_INSTALL_LINK_ID")
|
||||||
@@ -51,8 +64,6 @@ else
|
|||||||
"$EVOFW_CLIENT_NAME" "$HOSTNAME" "$PLATFORM" "$CLIENT_TOKEN")
|
"$EVOFW_CLIENT_NAME" "$HOSTNAME" "$PLATFORM" "$CLIENT_TOKEN")
|
||||||
fi
|
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" \
|
ENROLL_CODE=$(curl -sS -o "$ENROLL_TMP" -w "%{http_code}" -X POST "${CP_URL}/v1/agent/enroll" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-H "X-EvoFW-Seed: ${EVOFW_SEED}" \
|
-H "X-EvoFW-Seed: ${EVOFW_SEED}" \
|
||||||
@@ -69,6 +80,11 @@ if command -v jq >/dev/null 2>&1; then
|
|||||||
else
|
else
|
||||||
CLIENT_ID=$(echo "$RESP" | sed -n 's/.*"client_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
|
CLIENT_ID=$(echo "$RESP" | sed -n 's/.*"client_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
|
||||||
fi
|
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"
|
mkdir -p "$CONF_DIR"
|
||||||
chmod 700 "$CONF_DIR"
|
chmod 700 "$CONF_DIR"
|
||||||
@@ -81,8 +97,7 @@ KERNEL_BACKEND=auto
|
|||||||
EOF
|
EOF
|
||||||
chmod 600 "$CONF_FILE"
|
chmod 600 "$CONF_FILE"
|
||||||
|
|
||||||
curl -fsSL "${CP_URL}/v1/agent/sync-script" -o "$SYNC_SCRIPT"
|
install -m 755 "$SYNC_TMP" "$SYNC_SCRIPT"
|
||||||
chmod 755 "$SYNC_SCRIPT"
|
|
||||||
|
|
||||||
if command -v nft >/dev/null 2>&1; then
|
if command -v nft >/dev/null 2>&1; then
|
||||||
BACKEND=nft
|
BACKEND=nft
|
||||||
@@ -103,6 +118,7 @@ if command -v systemctl >/dev/null 2>&1; then
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=EvoFirewall sync
|
Description=EvoFirewall sync
|
||||||
After=network-online.target
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
@@ -116,6 +132,7 @@ Description=EvoFirewall sync timer
|
|||||||
OnBootSec=30s
|
OnBootSec=30s
|
||||||
OnUnitActiveSec=${INTERVAL}
|
OnUnitActiveSec=${INTERVAL}
|
||||||
AccuracySec=5s
|
AccuracySec=5s
|
||||||
|
Persistent=true
|
||||||
Unit=evofw-firewall.service
|
Unit=evofw-firewall.service
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
@@ -123,8 +140,12 @@ WantedBy=timers.target
|
|||||||
UNIT
|
UNIT
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable --now evofw-firewall.timer
|
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
|
else
|
||||||
(crontab -l 2>/dev/null | grep -v evofw-firewall; echo "*/1 * * * * $SYNC_SCRIPT") | crontab -
|
(crontab -l 2>/dev/null | grep -v evofw-firewall; echo "*/1 * * * * $SYNC_SCRIPT") | crontab -
|
||||||
|
"$SYNC_SCRIPT" || true
|
||||||
fi
|
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,
|
tokenHash,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
clientVersion: body.client_version ?? null,
|
clientVersion: body.client_version ?? null,
|
||||||
|
lastSeenAt: new Date().toISOString(),
|
||||||
|
lastSeenIp: req.ip,
|
||||||
})
|
})
|
||||||
return reply.code(201).send({
|
return reply.code(201).send({
|
||||||
client_id: agent!.id,
|
client_id: agent!.id,
|
||||||
@@ -106,6 +108,7 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const id = crypto.randomUUID()
|
const id = crypto.randomUUID()
|
||||||
|
const now = new Date().toISOString()
|
||||||
const agent = repos.insertAgent(app.db, {
|
const agent = repos.insertAgent(app.db, {
|
||||||
id,
|
id,
|
||||||
name: body.name,
|
name: body.name,
|
||||||
@@ -118,7 +121,9 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
policyGeneration: 1,
|
policyGeneration: 1,
|
||||||
clientVersion: body.client_version ?? null,
|
clientVersion: body.client_version ?? null,
|
||||||
settingsJson: '{}',
|
settingsJson: '{}',
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: now,
|
||||||
|
lastSeenAt: now,
|
||||||
|
lastSeenIp: req.ip,
|
||||||
})
|
})
|
||||||
return reply.code(201).send({
|
return reply.code(201).send({
|
||||||
client_id: agent!.id,
|
client_id: agent!.id,
|
||||||
@@ -130,11 +135,12 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
|
|
||||||
app.get('/v1/agent/policy', async (req) => {
|
app.get('/v1/agent/policy', async (req) => {
|
||||||
const agentId = req.agentId!
|
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, {
|
repos.updateAgent(app.db, agentId, {
|
||||||
lastSeenAt: new Date().toISOString(),
|
lastSeenAt: new Date().toISOString(),
|
||||||
lastSeenIp: req.ip,
|
lastSeenIp: req.ip,
|
||||||
})
|
})
|
||||||
|
const policy = evaluateAgentPolicy(app.db, agentId)
|
||||||
return {
|
return {
|
||||||
generation: policy.generation,
|
generation: policy.generation,
|
||||||
hash: policy.hash,
|
hash: policy.hash,
|
||||||
@@ -156,11 +162,11 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
|
|
||||||
app.get('/v1/agent/policy.rsc', async (req, reply) => {
|
app.get('/v1/agent/policy.rsc', async (req, reply) => {
|
||||||
const agentId = req.agentId!
|
const agentId = req.agentId!
|
||||||
const policy = evaluateAgentPolicy(app.db, agentId)
|
|
||||||
repos.updateAgent(app.db, agentId, {
|
repos.updateAgent(app.db, agentId, {
|
||||||
lastSeenAt: new Date().toISOString(),
|
lastSeenAt: new Date().toISOString(),
|
||||||
lastSeenIp: req.ip,
|
lastSeenIp: req.ip,
|
||||||
})
|
})
|
||||||
|
const policy = evaluateAgentPolicy(app.db, agentId)
|
||||||
return reply.type('text/plain').send(renderMikrotikPolicyRsc(policy))
|
return reply.type('text/plain').send(renderMikrotikPolicyRsc(policy))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -144,6 +144,63 @@ describe('install-links', () => {
|
|||||||
expect(byId.body).toContain('/v1/agent/policy.rsc')
|
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 () => {
|
it('approved agent can fetch policy.rsc with address-list commands', async () => {
|
||||||
const app = await appPromise
|
const app = await appPromise
|
||||||
await app.ready()
|
await app.ready()
|
||||||
|
|||||||
Reference in New Issue
Block a user