feat(api, web): enhance agent traffic statistics and update installation scripts
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 2m26s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped

- Improved the collection and reporting of agent traffic statistics, including total packets dropped and accepted, to provide a more comprehensive view of agent performance.
- Updated the `evofw-firewall.sh` script to capture and report traffic statistics before chain recreation, ensuring accurate data retention.
- Enhanced the installation script to support updates on already-installed agents, allowing for script and timer refresh without re-enrollment, while preserving existing credentials.
- Refactored UI components to utilize new traffic statistics, improving clarity and user experience in displaying agent performance metrics.

These changes enhance the overall functionality and usability of the agent management system, providing better insights and easier updates for users.
This commit is contained in:
Denozordec
2026-07-23 19:47:17 +07:00
parent 1b7d301153
commit 69e903aa1b
14 changed files with 293 additions and 140 deletions
+23 -7
View File
@@ -118,12 +118,19 @@ nft_add_chunk() {
collect_nft_stats() { collect_nft_stats() {
PACKETS_DROPPED=0; PACKETS_ACCEPTED=0 PACKETS_DROPPED=0; PACKETS_ACCEPTED=0
local line local line n
while IFS= read -r line; do while IFS= read -r line; do
if [[ "$line" == *drop* && "$line" =~ packets[[:space:]]+([0-9]+) ]]; then [[ "$line" =~ packets[[:space:]]+([0-9]+) ]] || continue
PACKETS_DROPPED="${BASH_REMATCH[1]}" n="${BASH_REMATCH[1]}"
elif [[ "$line" == *accept* && "$line" =~ packets[[:space:]]+([0-9]+) ]]; then # Policy set hits only (ignore lo / established noise)
PACKETS_ACCEPTED="${BASH_REMATCH[1]}" if [[ "$line" == *@deny_v4* ]]; then
PACKETS_DROPPED=$((PACKETS_DROPPED + n))
elif [[ "$line" == *@allow_v4* ]]; then
PACKETS_ACCEPTED=$((PACKETS_ACCEPTED + n))
elif [[ "$line" == *" counter drop"* && "$line" != *@* ]]; then
PACKETS_DROPPED=$((PACKETS_DROPPED + n))
elif [[ "$line" == *" counter accept"* && "$line" != *@* && "$line" != *established* && "$line" != *"iif \"lo\""* && "$line" != *"iif lo"* ]]; then
PACKETS_ACCEPTED=$((PACKETS_ACCEPTED + n))
fi fi
done < <(nft list chain inet evofw input 2>/dev/null || true) done < <(nft list chain inet evofw input 2>/dev/null || true)
} }
@@ -197,8 +204,11 @@ apply_ipset() {
} }
send_report() { send_report() {
if [[ "$KERNEL_METHOD" == "nft" ]] || command -v nft >/dev/null 2>&1; then # If caller already collected (pre-apply), keep those values.
collect_nft_stats if [[ -z "${STATS_CAPTURED:-}" ]]; then
if [[ "$KERNEL_METHOD" == "nft" ]] || command -v nft >/dev/null 2>&1; then
collect_nft_stats
fi
fi fi
local report local report
report=$(printf '{"status":"ok","prefix_count":%s,"packets_dropped":%s,"packets_accepted":%s,"kernel_method":"%s","source":"agent"}' \ report=$(printf '{"status":"ok","prefix_count":%s,"packets_dropped":%s,"packets_accepted":%s,"kernel_method":"%s","source":"agent"}' \
@@ -220,6 +230,12 @@ if [[ -f "$HASH_FILE" && "$(tr -d '\r\n' <"$HASH_FILE")" == "$HASH" && -n "$HASH
exit 0 exit 0
fi fi
# Capture counters BEFORE recreate (nft delete chain zeroes them).
if command -v nft >/dev/null 2>&1; then
collect_nft_stats
STATS_CAPTURED=1
fi
case "$BACKEND" in case "$BACKEND" in
nft|auto) nft|auto)
if command -v nft >/dev/null 2>&1; then apply_nft if command -v nft >/dev/null 2>&1; then apply_nft
+139 -91
View File
@@ -1,5 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# EvoFirewall Linux install one-liner # EvoFirewall Linux install one-liner
# Re-run on an already-installed host updates scripts/timer and keeps credentials
# (unless EVOFW_INSTALL_FORCE=1 → full re-enroll).
set -euo pipefail set -euo pipefail
if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then
@@ -114,12 +116,19 @@ fi
CONF_DIR=/etc/evofw CONF_DIR=/etc/evofw
CONF_FILE="${CONF_DIR}/agent.conf" CONF_FILE="${CONF_DIR}/agent.conf"
SYNC_SCRIPT=/usr/local/sbin/evofw-firewall.sh SYNC_SCRIPT=/usr/local/sbin/evofw-firewall.sh
UNINSTALL_SCRIPT=/usr/local/sbin/evofw-uninstall.sh
PLATFORM="${EVOFW_PLATFORM:-linux}" PLATFORM="${EVOFW_PLATFORM:-linux}"
# Capture CP from install-link / env before any `source` of agent.conf.
CP_URL="${EVOFW_CP_URL%/}"
LINK_CP_URL="$CP_URL"
CLIENT_NAME_FROM_LINK="$EVOFW_CLIENT_NAME"
if [[ -f "$CONF_FILE" && "${EVOFW_INSTALL_FORCE:-}" != "1" ]]; then # Always single-quote values so names with spaces are safe under `source`.
echo "Already installed ($CONF_FILE). Set EVOFW_INSTALL_FORCE=1 to reinstall." >&2 shell_quote() {
exit 1 local s=$1
fi s=${s//\'/\'\\\'\'}
printf "'%s'" "$s"
}
gen_token() { gen_token() {
if command -v openssl >/dev/null 2>&1; then if command -v openssl >/dev/null 2>&1; then
@@ -129,23 +138,132 @@ gen_token() {
fi fi
} }
CLIENT_TOKEN="$(gen_token)" detect_backend() {
HOSTNAME="$(hostname -f 2>/dev/null || hostname)" if command -v nft >/dev/null 2>&1; then
CP_URL="${EVOFW_CP_URL%/}" echo nft
elif command -v ipset >/dev/null 2>&1 && command -v iptables >/dev/null 2>&1; then
echo ipset
elif command -v iptables >/dev/null 2>&1; then
echo iptables
else
echo ""
fi
}
write_conf() {
local client_id=$1 client_token=$2 client_name=$3 backend=$4
mkdir -p "$CONF_DIR"
chmod 700 "$CONF_DIR"
{
printf 'EVOFW_CP_URL=%s\n' "$(shell_quote "$CP_URL")"
printf 'CLIENT_ID=%s\n' "$(shell_quote "$client_id")"
printf 'CLIENT_TOKEN=%s\n' "$(shell_quote "$client_token")"
printf 'CLIENT_NAME=%s\n' "$(shell_quote "$client_name")"
printf 'KERNEL_BACKEND=%s\n' "$(shell_quote "$backend")"
} >"$CONF_FILE"
chmod 600 "$CONF_FILE"
}
install_sync_and_uninstall() {
local sync_tmp=$1
install -m 755 "$sync_tmp" "$SYNC_SCRIPT"
if curl -fsSL "${CP_URL}/v1/agent/uninstall.sh" -o "$UNINSTALL_SCRIPT" 2>/dev/null; then
chmod 755 "$UNINSTALL_SCRIPT"
else
echo "evofw install: warning — could not download uninstall.sh (optional)" >&2
fi
}
enable_scheduler_and_run() {
local interval="${EVOFW_SYNC_INTERVAL:-1min}"
if [[ "$HAS_SYSTEMD" -eq 1 ]]; then
cat >/etc/systemd/system/evofw-firewall.service <<'UNIT'
[Unit]
Description=EvoFirewall sync
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/evofw-firewall.sh
UNIT
cat >/etc/systemd/system/evofw-firewall.timer <<UNIT
[Unit]
Description=EvoFirewall sync timer
[Timer]
OnBootSec=30s
OnUnitActiveSec=${interval}
AccuracySec=5s
Persistent=true
Unit=evofw-firewall.service
[Install]
WantedBy=timers.target
UNIT
systemctl daemon-reload
systemctl enable --now evofw-firewall.timer
# First/updated 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 || true; echo "*/1 * * * * $SYNC_SCRIPT") | crontab -
"$SYNC_SCRIPT" || true
fi
}
download_sync_script() {
local out=$1
if ! curl -fsSL "${CP_URL}/v1/agent/sync-script" -o "$out"; then
echo "failed to download sync script from ${CP_URL}/v1/agent/sync-script" >&2
return 1
fi
if ! head -n1 "$out" | grep -q '^#!'; then
echo "sync script is not a shell script (CP returned unexpected body)" >&2
return 1
fi
}
# Fail fast: pull sync script before enroll so we never leave a DB agent without a local agent.
SYNC_TMP=$(mktemp) SYNC_TMP=$(mktemp)
ENROLL_TMP=$(mktemp) ENROLL_TMP=$(mktemp)
trap 'rm -f "$SYNC_TMP" "$ENROLL_TMP"' EXIT 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 # --- Update path: agent already installed ---
exit 1 if [[ -f "$CONF_FILE" && "${EVOFW_INSTALL_FORCE:-}" != "1" ]]; then
fi echo "evofw update: existing install at $CONF_FILE — refreshing scripts (credentials kept)"
if ! head -n1 "$SYNC_TMP" | grep -q '^#!'; then # shellcheck disable=SC1090
echo "sync script is not a shell script (CP returned unexpected body)" >&2 set -a
exit 1 # shellcheck source=/dev/null
source "$CONF_FILE"
set +a
if [[ -z "${CLIENT_ID:-}" || -z "${CLIENT_TOKEN:-}" ]]; then
echo "evofw update: $CONF_FILE incomplete (need CLIENT_ID + CLIENT_TOKEN). Set EVOFW_INSTALL_FORCE=1 to re-enroll." >&2
exit 1
fi
# Prefer CP URL from this install link / env (stashed before source).
CP_URL="${LINK_CP_URL%/}"
CLIENT_NAME="${CLIENT_NAME_FROM_LINK:-${CLIENT_NAME:-unknown}}"
BACKEND="$(detect_backend)"
if [[ -z "$BACKEND" ]]; then
echo "no supported firewall backend" >&2
exit 1
fi
download_sync_script "$SYNC_TMP" || exit 1
write_conf "$CLIENT_ID" "$CLIENT_TOKEN" "$CLIENT_NAME" "$BACKEND"
install_sync_and_uninstall "$SYNC_TMP"
enable_scheduler_and_run
echo "Updated. Client id=${CLIENT_ID}. Sync script + timer refreshed."
echo "Force sync: $SYNC_SCRIPT"
echo "Uninstall: $UNINSTALL_SCRIPT (or: curl -fsSL ${CP_URL}/v1/agent/uninstall.sh | bash)"
exit 0
fi fi
# --- Fresh install (or EVOFW_INSTALL_FORCE=1) ---
CLIENT_TOKEN="$(gen_token)"
HOSTNAME="$(hostname -f 2>/dev/null || hostname)"
# Fail fast: pull sync script before enroll so we never leave a DB agent without a local agent.
download_sync_script "$SYNC_TMP" || exit 1
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")
@@ -176,87 +294,17 @@ if [[ -z "$CLIENT_ID" || "$CLIENT_ID" == "null" ]]; then
exit 1 exit 1
fi fi
mkdir -p "$CONF_DIR" BACKEND="$(detect_backend)"
chmod 700 "$CONF_DIR" if [[ -z "$BACKEND" ]]; then
# Always single-quote values so names with spaces are safe under `source`.
shell_quote() {
local s=$1
s=${s//\'/\'\\\'\'}
printf "'%s'" "$s"
}
{
printf 'EVOFW_CP_URL=%s\n' "$(shell_quote "$CP_URL")"
printf 'CLIENT_ID=%s\n' "$(shell_quote "$CLIENT_ID")"
printf 'CLIENT_TOKEN=%s\n' "$(shell_quote "$CLIENT_TOKEN")"
printf 'CLIENT_NAME=%s\n' "$(shell_quote "$EVOFW_CLIENT_NAME")"
printf 'KERNEL_BACKEND=%s\n' "$(shell_quote "auto")"
} >"$CONF_FILE"
chmod 600 "$CONF_FILE"
install -m 755 "$SYNC_TMP" "$SYNC_SCRIPT"
UNINSTALL_SCRIPT=/usr/local/sbin/evofw-uninstall.sh
if curl -fsSL "${CP_URL}/v1/agent/uninstall.sh" -o "$UNINSTALL_SCRIPT" 2>/dev/null; then
chmod 755 "$UNINSTALL_SCRIPT"
else
echo "evofw install: warning — could not download uninstall.sh (optional)" >&2
fi
if command -v nft >/dev/null 2>&1; then
BACKEND=nft
elif command -v ipset >/dev/null 2>&1 && command -v iptables >/dev/null 2>&1; then
BACKEND=ipset
elif command -v iptables >/dev/null 2>&1; then
BACKEND=iptables
else
echo "no supported firewall backend" >&2 echo "no supported firewall backend" >&2
exit 1 exit 1
fi fi
# Replace KERNEL_BACKEND line without breaking other quoted values.
if grep -q '^KERNEL_BACKEND=' "$CONF_FILE"; then
grep -v '^KERNEL_BACKEND=' "$CONF_FILE" >"${CONF_FILE}.tmp"
printf 'KERNEL_BACKEND=%s\n' "$(shell_quote "$BACKEND")" >>"${CONF_FILE}.tmp"
mv "${CONF_FILE}.tmp" "$CONF_FILE"
chmod 600 "$CONF_FILE"
else
printf 'KERNEL_BACKEND=%s\n' "$(shell_quote "$BACKEND")" >>"$CONF_FILE"
fi
INTERVAL="${EVOFW_SYNC_INTERVAL:-1min}" write_conf "$CLIENT_ID" "$CLIENT_TOKEN" "$EVOFW_CLIENT_NAME" "$BACKEND"
if [[ "$HAS_SYSTEMD" -eq 1 ]]; then install_sync_and_uninstall "$SYNC_TMP"
cat >/etc/systemd/system/evofw-firewall.service <<'UNIT' enable_scheduler_and_run
[Unit]
Description=EvoFirewall sync
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/evofw-firewall.sh
UNIT
cat >/etc/systemd/system/evofw-firewall.timer <<UNIT
[Unit]
Description=EvoFirewall sync timer
[Timer]
OnBootSec=30s
OnUnitActiveSec=${INTERVAL}
AccuracySec=5s
Persistent=true
Unit=evofw-firewall.service
[Install]
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 || true; echo "*/1 * * * * $SYNC_SCRIPT") | crontab -
"$SYNC_SCRIPT" || true
fi
echo "Installed. Client id=${CLIENT_ID}. Approve in EvoFirewall UI (rules optional — can assign later)." echo "Installed. Client id=${CLIENT_ID}. Approve in EvoFirewall UI (rules optional — can assign later)."
echo "If still offline after Approve, run: $SYNC_SCRIPT" echo "If still offline after Approve, run: $SYNC_SCRIPT"
echo "Re-run the same install URL to update scripts without re-enroll."
echo "Uninstall: $UNINSTALL_SCRIPT (or: curl -fsSL ${CP_URL}/v1/agent/uninstall.sh | bash)" echo "Uninstall: $UNINSTALL_SCRIPT (or: curl -fsSL ${CP_URL}/v1/agent/uninstall.sh | bash)"
+38 -19
View File
@@ -2,6 +2,8 @@
# Short-link sets EvofwCpUrl / EvofwSeed / EvofwName / EvofwInstallLinkId before body. # Short-link sets EvofwCpUrl / EvofwSeed / EvofwName / EvofwInstallLinkId before body.
# Legacy: set globals, then /import file-name=mikrotik-install.rsc # Legacy: set globals, then /import file-name=mikrotik-install.rsc
# #
# Re-import on an already-enrolled router: skips enroll, refreshes sync + scheduler (token kept).
#
# Blacklist: drop EVOFW_DENY on input+forward # Blacklist: drop EVOFW_DENY on input+forward
# Whitelist: accept EVOFW_ALLOW + drop others on forward only (input stays open for Winbox/SSH) # Whitelist: accept EVOFW_ALLOW + drop others on forward only (input stays open for Winbox/SSH)
@@ -9,31 +11,44 @@
:global EvofwSeed :global EvofwSeed
:global EvofwName :global EvofwName
:global EvofwInstallLinkId :global EvofwInstallLinkId
:global EvofwToken
:if ([:typeof $EvofwCpUrl] = "nothing" || [:len $EvofwCpUrl] = 0) do={ :error "EvofwCpUrl required" } :if ([:typeof $EvofwCpUrl] = "nothing" || [:len $EvofwCpUrl] = 0) do={ :error "EvofwCpUrl required" }
:if ([:typeof $EvofwSeed] = "nothing" || [:len $EvofwSeed] = 0) do={ :error "EvofwSeed required" } :if ([:typeof $EvofwSeed] = "nothing" || [:len $EvofwSeed] = 0) do={ :error "EvofwSeed required" }
:if ([:typeof $EvofwName] = "nothing" || [:len $EvofwName] = 0) do={ :set EvofwName [/system identity get name] } :if ([:typeof $EvofwName] = "nothing" || [:len $EvofwName] = 0) do={ :set EvofwName [/system identity get name] }
:local token ("evofw_" . [/certificate scep-server nonce generate]) :local already 0
:if ([:len $token] < 20) do={
:set token ("evofw_" . [:tostr [/system clock get time]] . [:tostr [/system resource get cpu-load]] . [:tostr [/system resource get free-memory]])
}
:local body ("{\"name\":\"" . $EvofwName . "\",\"hostname\":\"" . [/system identity get name] . "\",\"platform\":\"mikrotik\",\"token\":\"" . $token . "\",\"client_version\":\"rsc/1\"")
:if ([:typeof $EvofwInstallLinkId] != "nothing" && [:len $EvofwInstallLinkId] > 0) do={
:set body ($body . ",\"install_link_id\":\"" . $EvofwInstallLinkId . "\"")
}
:set body ($body . "}")
:do { :do {
/tool fetch url=($EvofwCpUrl . "/v1/agent/enroll") http-method=post http-header-field=("Content-Type: application/json,X-EvoFW-Seed: " . $EvofwSeed) http-data=$body keep-result=no /system script run evofw-env
} on-error={ :if ([:typeof $EvofwToken] != "nothing" && [:len $EvofwToken] > 0) do={ :set already 1 }
:error "evofw: enroll failed — check EvofwCpUrl / EvofwSeed / connectivity" } on-error={}
}
# Persist credentials :if ($already = 0) do={
:do { /system script remove [find name="evofw-env"] } on-error={} :local token ("evofw_" . [/certificate scep-server nonce generate])
/system script add name=evofw-env policy=read,write,policy,test source=(" :global EvofwCpUrl \"" . $EvofwCpUrl . "\"; :global EvofwToken \"" . $token . "\" ") :if ([:len $token] < 20) do={
:set token ("evofw_" . [:tostr [/system clock get time]] . [:tostr [/system resource get cpu-load]] . [:tostr [/system resource get free-memory]])
}
:local body ("{\"name\":\"" . $EvofwName . "\",\"hostname\":\"" . [/system identity get name] . "\",\"platform\":\"mikrotik\",\"token\":\"" . $token . "\",\"client_version\":\"rsc/1\"")
:if ([:typeof $EvofwInstallLinkId] != "nothing" && [:len $EvofwInstallLinkId] > 0) do={
:set body ($body . ",\"install_link_id\":\"" . $EvofwInstallLinkId . "\"")
}
:set body ($body . "}")
:do {
/tool fetch url=($EvofwCpUrl . "/v1/agent/enroll") http-method=post http-header-field=("Content-Type: application/json,X-EvoFW-Seed: " . $EvofwSeed) http-data=$body keep-result=no
} on-error={
:error "evofw: enroll failed — check EvofwCpUrl / EvofwSeed / connectivity"
}
:do { /system script remove [find name="evofw-env"] } on-error={}
/system script add name=evofw-env policy=read,write,policy,test source=(" :global EvofwCpUrl \"" . $EvofwCpUrl . "\"; :global EvofwToken \"" . $token . "\" ")
:set EvofwToken $token
} else={
:put "evofw: already enrolled — updating sync script (token kept)"
:do { /system script remove [find name="evofw-env"] } on-error={}
/system script add name=evofw-env policy=read,write,policy,test source=(" :global EvofwCpUrl \"" . $EvofwCpUrl . "\"; :global EvofwToken \"" . $EvofwToken . "\" ")
}
# Filter rules (idempotent by comment) # Filter rules (idempotent by comment)
:do { /ip firewall filter remove [find comment~"^evofw-"] } on-error={} :do { /ip firewall filter remove [find comment~"^evofw-"] } on-error={}
@@ -81,4 +96,8 @@
:log info "evofw: initial sync skipped (approve agent in UI)" :log info "evofw: initial sync skipped (approve agent in UI)"
} }
:put ("EvoFirewall enrolled as " . $EvofwName . " — approve in UI; scheduler evofw-sync every 1m") :if ($already = 1) do={
:put ("EvoFirewall updated for " . $EvofwName . " — sync script + scheduler refreshed")
} else={
:put ("EvoFirewall enrolled as " . $EvofwName . " — approve in UI; scheduler evofw-sync every 1m")
}
+24 -4
View File
@@ -181,13 +181,33 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
const agentId = req.agentId! const agentId = req.agentId!
const body = applyReportBodySchema.parse(req.body) const body = applyReportBodySchema.parse(req.body)
const now = new Date().toISOString() const now = new Date().toISOString()
const prev = repos.getAgent(app.db, agentId)
const reportedDropped = body.packets_dropped ?? 0
const reportedAccepted = body.packets_accepted ?? 0
const prevDropped = prev?.lastApplyPacketsDropped ?? 0
const prevAccepted = prev?.lastApplyPacketsAccepted ?? 0
// Absolute-since-chain-create from agent. If counters reset (re-apply),
// treat the new absolute as the delta; else add the increase.
const deltaDropped =
reportedDropped >= prevDropped
? reportedDropped - prevDropped
: reportedDropped
const deltaAccepted =
reportedAccepted >= prevAccepted
? reportedAccepted - prevAccepted
: reportedAccepted
const totalDropped = (prev?.totalPacketsDropped ?? 0) + deltaDropped
const totalAccepted = (prev?.totalPacketsAccepted ?? 0) + deltaAccepted
repos.updateAgent(app.db, agentId, { repos.updateAgent(app.db, agentId, {
lastApplyAt: now, lastApplyAt: now,
lastApplyStatus: body.status, lastApplyStatus: body.status,
lastApplyError: body.error ?? null, lastApplyError: body.error ?? null,
lastApplyPrefixCount: body.prefix_count ?? 0, lastApplyPrefixCount: body.prefix_count ?? 0,
lastApplyPacketsDropped: body.packets_dropped ?? 0, lastApplyPacketsDropped: reportedDropped,
lastApplyPacketsAccepted: body.packets_accepted ?? 0, lastApplyPacketsAccepted: reportedAccepted,
totalPacketsDropped: totalDropped,
totalPacketsAccepted: totalAccepted,
lastApplyKernelMethod: body.kernel_method ?? null, lastApplyKernelMethod: body.kernel_method ?? null,
lastSeenAt: now, lastSeenAt: now,
lastSeenIp: req.ip, lastSeenIp: req.ip,
@@ -195,8 +215,8 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
repos.insertStatsSample(app.db, { repos.insertStatsSample(app.db, {
id: crypto.randomUUID(), id: crypto.randomUUID(),
agentId, agentId,
packetsDropped: body.packets_dropped ?? 0, packetsDropped: reportedDropped,
packetsAccepted: body.packets_accepted ?? 0, packetsAccepted: reportedAccepted,
prefixCount: body.prefix_count ?? 0, prefixCount: body.prefix_count ?? 0,
kernelMethod: body.kernel_method ?? null, kernelMethod: body.kernel_method ?? null,
recordedAt: now, recordedAt: now,
+6 -2
View File
@@ -61,6 +61,8 @@ function mapAgent(
last_apply_prefix_count: a.lastApplyPrefixCount, last_apply_prefix_count: a.lastApplyPrefixCount,
last_apply_packets_dropped: a.lastApplyPacketsDropped, last_apply_packets_dropped: a.lastApplyPacketsDropped,
last_apply_packets_accepted: a.lastApplyPacketsAccepted, last_apply_packets_accepted: a.lastApplyPacketsAccepted,
total_packets_dropped: a.totalPacketsDropped ?? 0,
total_packets_accepted: a.totalPacketsAccepted ?? 0,
last_apply_kernel_method: a.lastApplyKernelMethod, last_apply_kernel_method: a.lastApplyKernelMethod,
client_version: a.clientVersion, client_version: a.clientVersion,
created_at: a.createdAt, created_at: a.createdAt,
@@ -128,11 +130,11 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
agents_online: online.length, agents_online: online.length,
agents_pending: all.filter((a) => a.status === 'pending').length, agents_pending: all.filter((a) => a.status === 'pending').length,
packets_dropped: all.reduce( packets_dropped: all.reduce(
(s, a) => s + (a.lastApplyPacketsDropped ?? 0), (s, a) => s + (a.totalPacketsDropped ?? a.lastApplyPacketsDropped ?? 0),
0, 0,
), ),
packets_accepted: all.reduce( packets_accepted: all.reduce(
(s, a) => s + (a.lastApplyPacketsAccepted ?? 0), (s, a) => s + (a.totalPacketsAccepted ?? a.lastApplyPacketsAccepted ?? 0),
0, 0,
), ),
lists_total: repos.listIpLists(app.db).length, lists_total: repos.listIpLists(app.db).length,
@@ -976,6 +978,8 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
const updated = repos.updateAgent(app.db, agent.id, { const updated = repos.updateAgent(app.db, agent.id, {
lastApplyPacketsDropped: 0, lastApplyPacketsDropped: 0,
lastApplyPacketsAccepted: 0, lastApplyPacketsAccepted: 0,
totalPacketsDropped: 0,
totalPacketsAccepted: 0,
}) })
repos.deleteStatsSamplesForAgent(app.db, agent.id) repos.deleteStatsSamplesForAgent(app.db, agent.id)
auditMutation(app, config, req, { auditMutation(app, config, req, {
@@ -3,6 +3,11 @@ import {
AgentPlatformIcon, AgentPlatformIcon,
platformLabel, platformLabel,
} from '@/components/agents/agent-platform-icon' } from '@/components/agents/agent-platform-icon'
import {
agentHasTrafficSample,
agentTrafficAccepted,
agentTrafficDropped,
} from '@/components/agents/agent-traffic'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { Frame, FramePanel } from '@/components/reui/frame' import { Frame, FramePanel } from '@/components/reui/frame'
@@ -52,9 +57,9 @@ type AgentCardProps = {
} }
export function AgentCard({ agent, selected, onSelect }: AgentCardProps) { export function AgentCard({ agent, selected, onSelect }: AgentCardProps) {
const hasApply = Boolean(agent.last_apply_at || agent.last_apply_status) const hasApply = agentHasTrafficSample(agent)
const dropped = formatPackets(agent.last_apply_packets_dropped, hasApply) const dropped = formatPackets(agentTrafficDropped(agent), hasApply)
const accepted = formatPackets(agent.last_apply_packets_accepted, hasApply) const accepted = formatPackets(agentTrafficAccepted(agent), hasApply)
const traffic = const traffic =
dropped === '—' && accepted === '—' dropped === '—' && accepted === '—'
? '—' ? '—'
@@ -24,6 +24,10 @@ import {
AgentPlatformIcon, AgentPlatformIcon,
platformLabel, platformLabel,
} from '@/components/agents/agent-platform-icon' } from '@/components/agents/agent-platform-icon'
import {
agentTrafficAccepted,
agentTrafficDropped,
} from '@/components/agents/agent-traffic'
import { AgentPolicySetsSortable } from '@/components/agents/agent-policy-sets-sortable' import { AgentPolicySetsSortable } from '@/components/agents/agent-policy-sets-sortable'
import { AgentPolicyTrace } from '@/components/agents/agent-policy-trace' import { AgentPolicyTrace } from '@/components/agents/agent-policy-trace'
import { AgentFactsPanel } from '@/components/agents/agent-facts-panel' import { AgentFactsPanel } from '@/components/agents/agent-facts-panel'
@@ -246,8 +250,8 @@ export function AgentDetailView({ agentId }: AgentDetailViewProps) {
icon: <ActivityIcon aria-hidden />, icon: <ActivityIcon aria-hidden />,
iconClassName: 'text-warning', iconClassName: 'text-warning',
label: 'Traffic', label: 'Traffic',
description: `${a.last_apply_packets_dropped ?? 0} · ↑${a.last_apply_packets_accepted ?? 0}`, description: `${agentTrafficDropped(a)} · ↑${agentTrafficAccepted(a)}`,
hint: 'сумма counters', hint: 'накопительно',
variant: 'warning', variant: 'warning',
footer: ( footer: (
<Button <Button
@@ -15,6 +15,11 @@ import {
AgentPlatformIcon, AgentPlatformIcon,
platformLabel, platformLabel,
} from '@/components/agents/agent-platform-icon' } from '@/components/agents/agent-platform-icon'
import {
agentHasTrafficSample,
agentTrafficAccepted,
agentTrafficDropped,
} from '@/components/agents/agent-traffic'
import { Button } from '@evofw/ui/components/button' import { Button } from '@evofw/ui/components/button'
import { import {
Tooltip, Tooltip,
@@ -217,22 +222,15 @@ export function AgentFleetDataGrid({
minSize: 110, minSize: 110,
maxSize: 160, maxSize: 160,
accessorFn: (row) => accessorFn: (row) =>
(row.last_apply_packets_dropped ?? 0) + agentTrafficDropped(row) + agentTrafficAccepted(row),
(row.last_apply_packets_accepted ?? 0),
header: ({ column }) => ( header: ({ column }) => (
<DataGridColumnHeader column={column} title="Traffic" /> <DataGridColumnHeader column={column} title="Traffic" />
), ),
cell: ({ row }) => { cell: ({ row }) => {
const a = row.original const a = row.original
const hasApply = Boolean(a.last_apply_at || a.last_apply_status) const hasApply = agentHasTrafficSample(a)
const dropped = formatPackets( const dropped = formatPackets(agentTrafficDropped(a), hasApply)
a.last_apply_packets_dropped, const accepted = formatPackets(agentTrafficAccepted(a), hasApply)
hasApply,
)
const accepted = formatPackets(
a.last_apply_packets_accepted,
hasApply,
)
if (dropped === '—' && accepted === '—') { if (dropped === '—' && accepted === '—') {
return <DataGridMutedCell></DataGridMutedCell> return <DataGridMutedCell></DataGridMutedCell>
} }
@@ -0,0 +1,19 @@
import type { Agent } from '@evofw/shared'
/** Cumulative Traffic counters (fallback to last apply snapshot). */
export function agentTrafficDropped(agent: Agent): number {
return agent.total_packets_dropped ?? agent.last_apply_packets_dropped ?? 0
}
export function agentTrafficAccepted(agent: Agent): number {
return agent.total_packets_accepted ?? agent.last_apply_packets_accepted ?? 0
}
export function agentHasTrafficSample(agent: Agent): boolean {
return Boolean(
agent.last_apply_at ||
agent.last_apply_status ||
(agent.total_packets_dropped ?? 0) > 0 ||
(agent.total_packets_accepted ?? 0) > 0,
)
}
+2 -1
View File
@@ -33,6 +33,7 @@ import {
agentsQueryOptions, agentsQueryOptions,
recentStatsQueryOptions, recentStatsQueryOptions,
} from '@/queries' } from '@/queries'
import { agentTrafficDropped } from '@/components/agents/agent-traffic'
import type { Agent } from '@evofw/shared' import type { Agent } from '@evofw/shared'
export const Route = createFileRoute('/_auth/')({ export const Route = createFileRoute('/_auth/')({
@@ -182,7 +183,7 @@ function DashboardPage() {
{a.status} {a.status}
</Badge> </Badge>
<span className="text-muted-foreground text-xs tabular-nums"> <span className="text-muted-foreground text-xs tabular-nums">
{a.last_apply_packets_dropped ?? 0} {agentTrafficDropped(a)}
</span> </span>
</div> </div>
</ItemContent> </ItemContent>
+4
View File
@@ -20,6 +20,8 @@ curl -fsSL https://<cp>/agent-install/<id> | bash
3. После enroll статус станет **Pending** — одобрите агента (Approve). 3. После enroll статус станет **Pending** — одобрите агента (Approve).
4. **Approved** — агент синхронизирует политику. 4. **Approved** — агент синхронизирует политику.
**Повторный запуск той же install-ссылки** на хосте, где агент уже стоит: обновляет sync-скрипт / timer (или MikroTik scheduler), **без** повторного enroll — `CLIENT_ID`/`token` сохраняются. Полный переустановки с новым токеном: `EVOFW_INSTALL_FORCE=1` (Linux).
API (auth): `POST /api/v1/install-links` `{ "name": "web-01", "platform": "linux" | "mikrotik" }`. API (auth): `POST /api/v1/install-links` `{ "name": "web-01", "platform": "linux" | "mikrotik" }`.
## Linux (legacy one-liner) ## Linux (legacy one-liner)
@@ -38,6 +40,8 @@ curl -fsSL https://<cp>/v1/agent/install.sh | \
Install сам ставит зависимости через apt/dnf/yum/apk: `curl`, `jq` (или `python3`), `nftables`/`iptables`(+`ipset`). Планировщик: **systemd timer** если есть `/run/systemd/system`, иначе ставит `cron`/`cronie` и пишет crontab. Значения в `agent.conf` всегда в single quotes (имена с пробелами безопасны). Sync при статусе pending завершается с exit 0 (`pending approval`), чтобы systemd timer не был failed. Install сам ставит зависимости через apt/dnf/yum/apk: `curl`, `jq` (или `python3`), `nftables`/`iptables`(+`ipset`). Планировщик: **systemd timer** если есть `/run/systemd/system`, иначе ставит `cron`/`cronie` и пишет crontab. Значения в `agent.conf` всегда в single quotes (имена с пробелами безопасны). Sync при статусе pending завершается с exit 0 (`pending approval`), чтобы systemd timer не был failed.
Если `/etc/evofw/agent.conf` уже есть — install переходит в **update**: скачивает свежий `sync-script` + `uninstall.sh`, перезаписывает unit/timer, оставляет токен. `EVOFW_INSTALL_FORCE=1` — полный re-enroll (новый токен; для уже Approved install-link обычно не сработает).
**Uninstall (Linux):** **Uninstall (Linux):**
```bash ```bash
curl -fsSL https://<cp>/v1/agent/uninstall.sh | bash curl -fsSL https://<cp>/v1/agent/uninstall.sh | bash
@@ -0,0 +1,9 @@
-- Cumulative packet counters (survive policy re-apply / nft chain recreate).
ALTER TABLE agents ADD COLUMN total_packets_dropped INTEGER NOT NULL DEFAULT 0;
ALTER TABLE agents ADD COLUMN total_packets_accepted INTEGER NOT NULL DEFAULT 0;
-- Seed from last snapshot so existing fleet doesn't jump to zero.
UPDATE agents
SET
total_packets_dropped = COALESCE(last_apply_packets_dropped, 0),
total_packets_accepted = COALESCE(last_apply_packets_accepted, 0);
+3
View File
@@ -30,6 +30,9 @@ export const agents = sqliteTable(
lastApplyPrefixCount: integer('last_apply_prefix_count').default(0), lastApplyPrefixCount: integer('last_apply_prefix_count').default(0),
lastApplyPacketsDropped: integer('last_apply_packets_dropped').notNull().default(0), lastApplyPacketsDropped: integer('last_apply_packets_dropped').notNull().default(0),
lastApplyPacketsAccepted: integer('last_apply_packets_accepted').notNull().default(0), lastApplyPacketsAccepted: integer('last_apply_packets_accepted').notNull().default(0),
/** Lifetime counters until UI/API reset (accumulate across applies). */
totalPacketsDropped: integer('total_packets_dropped').notNull().default(0),
totalPacketsAccepted: integer('total_packets_accepted').notNull().default(0),
lastApplyKernelMethod: text('last_apply_kernel_method'), lastApplyKernelMethod: text('last_apply_kernel_method'),
clientVersion: text('client_version'), clientVersion: text('client_version'),
settingsJson: text('settings_json').notNull().default('{}'), settingsJson: text('settings_json').notNull().default('{}'),
+3
View File
@@ -56,6 +56,9 @@ export const agentSchema = z.object({
last_apply_prefix_count: z.number().int().nullable().optional(), last_apply_prefix_count: z.number().int().nullable().optional(),
last_apply_packets_dropped: z.number().int().optional(), last_apply_packets_dropped: z.number().int().optional(),
last_apply_packets_accepted: z.number().int().optional(), last_apply_packets_accepted: z.number().int().optional(),
/** Cumulative until reset (UI Traffic). */
total_packets_dropped: z.number().int().optional(),
total_packets_accepted: z.number().int().optional(),
last_apply_kernel_method: z.string().nullable().optional(), last_apply_kernel_method: z.string().nullable().optional(),
client_version: z.string().nullable().optional(), client_version: z.string().nullable().optional(),
settings_json: z.string().optional(), settings_json: z.string().optional(),