Compare commits
15
Commits
4ee78032c4
..
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a55dc676b9 | ||
|
|
4fedf83078 | ||
|
|
9121cbe842 | ||
|
|
ecaa22ed97 | ||
|
|
6dc5fb5f3b | ||
|
|
1afa07053a | ||
|
|
a2ad637a38 | ||
|
|
d552f4f326 | ||
|
|
ef2a333430 | ||
|
|
bfaed511bd | ||
|
|
43f5ac2525 | ||
|
|
c5069fbdaf | ||
|
|
402182195f | ||
|
|
3815575799 | ||
|
|
ee3429b747 |
No files matched your search
@@ -72,7 +72,7 @@ apps/web/src/components/ ← shared + domain + layout
|
||||
| Settings | PageShell + Frame + `SettingRow` (или `SettingsShell` при 2+ секциях) | — |
|
||||
| Dashboard KPI | `OpsDashboard` / `KpiStatGrid` | ReUI Frame [stats-12](https://reui.io/preview/base/stats-12) hybrid |
|
||||
| Quick Actions | `QuickActionGrid` (gated `show_quick_actions`) | Frame tiles + Badge «Перейти» |
|
||||
| Empty | `EmptyState` | `Empty` |
|
||||
| Empty | `EmptyState` | `Empty` + `IconStack` ([empty-state-14](https://reui.io/preview/base/empty-state-14)) |
|
||||
| Loading / Error | `QueryState` / kit skeletons | `Skeleton`, `Alert` |
|
||||
| Status | `StatusBadge` | ReUI `Badge` (`success`/`info`/`warning`) |
|
||||
| Create/Edit | `FormSheet` + `*-edit-sheet.tsx` | `Sheet`, `Field` |
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
description: Только hybrid KPI — KpiStatGrid / row tile DNA (stats-12). Запрет SectionCards и hand-roll.
|
||||
description: Только hybrid KPI — KpiStatGrid / row tile DNA (stats-12 + IconTile). Запрет SectionCards и hand-roll.
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# KPI hybrid — только kit (stats-12 DNA)
|
||||
# KPI hybrid — только kit (stats-12 + IconTile)
|
||||
|
||||
Preview: [stats-12](https://reui.io/preview/base/stats-12). SoT DNA = EvoBGP. Markup в проекте: `apps/web/src/components/reui-kit/kpi-stat-grid.tsx`.
|
||||
Preview: [stats-12](https://reui.io/preview/base/stats-12). Primitive: [icon-tile](https://reui.io/docs/components/base/icon-tile). SoT DNA = EvoBGP. Markup: `apps/web/src/components/reui-kit/kpi-stat-grid.tsx`.
|
||||
|
||||
Связанные: [`reui-mcp.mdc`](reui-mcp.mdc), [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc).
|
||||
|
||||
@@ -14,16 +14,17 @@ Preview: [stats-12](https://reui.io/preview/base/stats-12). SoT DNA = EvoBGP. Ma
|
||||
| Зона | Компонент / DNA |
|
||||
|------|-----------------|
|
||||
| KPI-полосы / dashboard metrics | только `reui-kit/KpiStatGrid` (через `OpsDashboard` / `DetailPanel.Metrics` при наличии) |
|
||||
| Markup | horizontal compact hybrid: icon left `Item` `size-10.5` `bg-muted` + `border-background` + shadow + `ItemMedia` + label/Badge + value ± `variant` |
|
||||
| Row icon tiles (data-grid) | та же DNA (`AgentPlatformIcon` и клоны) — semantic `text-*` на `bg-muted` |
|
||||
| Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA) |
|
||||
| Markup | horizontal compact hybrid: icon left `IconTile` `variant="elevated"` `className="size-10.5"` + label/Badge + value ± `variant` |
|
||||
| Row icon tiles (data-grid) | та же DNA (`AgentPlatformIcon` и клоны) — semantic `text-*` на IconTile elevated |
|
||||
| Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA, IconTile elevated) |
|
||||
|
||||
Импорты UI: `@evofw/ui/components/*`.
|
||||
Импорты UI: `@evofw/ui/components/*`. IconTile: `@/components/reui/icon-tile`.
|
||||
|
||||
## NEVER
|
||||
|
||||
- SectionCards / vertical-only KPI / hand-roll Frame/Card KPI
|
||||
- Другой size / radius / solid brand fill вместо `bg-muted`
|
||||
- `Item` `size-10.5` `bg-muted` hybrid вместо IconTile
|
||||
- Другой size / radius / solid brand fill вместо `elevated`
|
||||
- `card-35` как замена stats-12 hybrid KPI
|
||||
- Копипаст ReUI block в route — adapt через `reui-kit/`
|
||||
- Голый lucide `size-4` в name-cell без hybrid tile
|
||||
- Голый lucide `size-4` в name-cell без IconTile
|
||||
@@ -75,9 +75,9 @@ alwaysApply: true
|
||||
|
||||
## Установленные ReUI (apps/web)
|
||||
|
||||
**Components:** `frame`, `data-grid/*`, `filters`, `kanban`, `badge`, `alert`, `autocomplete`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack`
|
||||
**Components:** `frame`, `data-grid/*`, `filters`, `badge`, `alert`, `autocomplete`, `timeline`, `icon-stack`, `icon-tile`, `sortable`, `stepper`
|
||||
|
||||
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `DetailPanel`, `SettingsShell`
|
||||
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `DetailPanel`, `SettingsShell` (только 2+ секции; `/settings` = PageShell + Frame)
|
||||
|
||||
**Blocks (reference):** `stats-12`, `card-35`, `auth-13`, `app-shell-12`, `settings-16`, `settings-8`, `empty-state-12`, `form-7`, `data-grid-filtering-2`, `dashboard-1`, …
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Build context = repository root (deploy/docker/docker-bake.hcl).
|
||||
.git
|
||||
.gitea
|
||||
.github
|
||||
.cursor
|
||||
.claude
|
||||
.codegraph
|
||||
.agents
|
||||
memory-bank
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
**/.DS_Store
|
||||
**/Thumbs.db
|
||||
**/.env
|
||||
**/.env.*
|
||||
!**/.env.example
|
||||
!**/.env.*.example
|
||||
|
||||
node_modules
|
||||
**/node_modules
|
||||
**/dist
|
||||
apps/web/src/routeTree.gen.ts
|
||||
apps/web/playwright-report
|
||||
apps/web/test-results
|
||||
|
||||
*.md
|
||||
AGENTS.md
|
||||
CONTRIBUTING.md
|
||||
LICENSE
|
||||
docs
|
||||
|
||||
.pre-commit-config.yaml
|
||||
.releaserc.json
|
||||
.commitlintrc.*
|
||||
commitlint.config.cjs
|
||||
redocly.yaml
|
||||
package-lock.json
|
||||
|
||||
data
|
||||
*.exe
|
||||
**/*.test.ts
|
||||
**/*.spec.ts
|
||||
coverage
|
||||
.coverage
|
||||
*.out
|
||||
.release-version
|
||||
CHANGELOG.md
|
||||
deploy/docker/docker-bake.override.hcl
|
||||
deploy/compose/runtime-logs
|
||||
@@ -0,0 +1,80 @@
|
||||
# Gitea Actions
|
||||
|
||||
| Workflow | Когда | Что |
|
||||
|----------|--------|-----|
|
||||
| [workflows/ci.yaml](workflows/ci.yaml) | pull request в main/master | quality gates + commitlint |
|
||||
| [workflows/cd.yaml](workflows/cd.yaml) | push в main/master | quality gates + semantic-release + docker push |
|
||||
| [workflows/quality.yaml](workflows/quality.yaml) | reusable (`workflow_call`) | changes, openapi, web, api, commitlint, docker-check |
|
||||
|
||||
Подробнее: [docs/releasing.md](../docs/releasing.md).
|
||||
|
||||
## CI (quality gates)
|
||||
|
||||
Job **changes** вычисляет флаги по путям в diff. Полный прогон: `.gitea/workflows/*`, `scripts/*`, корневой `package.json` / `pnpm-lock.yaml` / `.releaserc.json`. Правки `.cursor/`, `.claude/`, `*.md` (кроме `docs/openapi.yaml`) quality jobs не запускают.
|
||||
|
||||
На **pull request** — **commitlint**. При изменении `deploy/docker/**` / `.dockerignore` — job **docker-check** (`bake --print`, bake без `--push` если есть доступ к registry).
|
||||
|
||||
Кэш зависимостей — нативный `actions/cache` (cache server act_runner), ключ `sha256sum` lockfile (не `hashFiles`). Пути **абсолютные** (`$HOME/.pnpm-store`): тильда `~` на Gitea часто не раскрывается и даёт вечный miss.
|
||||
|
||||
Кэшируется целиком: pnpm store + `node_modules` + corepack. При hit: `pnpm install --offline`.
|
||||
|
||||
Если restore пишет `connect ECONNREFUSED` / `cache server not configured` — на runner включите cache server (см. ниже). Иначе каждый job снова качает пакеты (~минуты).
|
||||
|
||||
Runner: `ubuntu-latest`, Docker для **docker-check** (PR) и **publish** (CD).
|
||||
|
||||
## CD (job publish)
|
||||
|
||||
После успешных quality gates на **push в main** job **publish**:
|
||||
|
||||
1. `pnpm exec semantic-release` — тег `vX.Y.Z` на **текущий commit** (без дополнительного commit в main).
|
||||
2. Gitea Release + `CHANGELOG.md` как attachment (не в git).
|
||||
3. Зеркало base-образов в `evofw-buildcache:base-*` (`deploy/docker/mirror-base-images.sh`; skip существующих тегов, `linux/amd64`, retry при 429).
|
||||
4. `docker buildx bake default --push` с `VERSION=X.Y.Z`, `pull=false`, named builder `evofw` (`cleanup: false`).
|
||||
|
||||
Если releasable-коммитов нет — semantic-release no-op, образы не публикуются.
|
||||
|
||||
Повтор упавшего **publish** (тег уже есть, bake нет): detect берёт `v*` на `HEAD` и всё равно пушит образы. Подробнее: [docs/releasing.md](../docs/releasing.md#перезапуск-упавшего-job-publish).
|
||||
|
||||
### Секреты
|
||||
|
||||
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Для git tag fallback: `github.token`. Push OCI — **только PAT** (у `GITEA_TOKEN` нет права packages).
|
||||
|
||||
### Теги образов
|
||||
|
||||
```text
|
||||
git.shx.one/<owner>/evofw:latest
|
||||
git.shx.one/<owner>/evofw:v1.2.3
|
||||
git.shx.one/<owner>/evofw:1.2.3
|
||||
git.shx.one/<owner>/evofw:<short-sha>
|
||||
git.shx.one/<owner>/evofw:sha-<full-sha>
|
||||
```
|
||||
|
||||
Тот же манифест публикуется как `evofirewall` (drop-in для старого compose).
|
||||
|
||||
Кэш сборки: `evofw-buildcache:node-buildcache` и `evofw-buildcache:base-*`.
|
||||
|
||||
Пример:
|
||||
|
||||
```bash
|
||||
docker pull git.shx.one/denozord/evofw:1.2.3
|
||||
```
|
||||
|
||||
См. [deploy/docker/README.md](../deploy/docker/README.md).
|
||||
|
||||
## act_runner: cache server
|
||||
|
||||
`actions/cache` ходит в **встроенный cache server** runner (не GitHub `type=gha`). Кэш локален для этого runner.
|
||||
|
||||
В `config.yaml` runner:
|
||||
|
||||
```yaml
|
||||
cache:
|
||||
enabled: true
|
||||
dir: "" # по умолчанию $HOME/.cache/actcache
|
||||
host: "" # IP, доступный из job-контейнера (не 0.0.0.0)
|
||||
port: 8088
|
||||
```
|
||||
|
||||
Если runner в Docker, а jobs — отдельные контейнеры: пробросьте порт и задайте `host` (LAN IP хоста) или `external_server: "http://<host>:8088/"`. Иначе restore — timeout/ECONNREFUSED и пакеты качаются снова.
|
||||
|
||||
Не делайте `docker system prune -a` по cron: сотрётся и Docker-кэш FROM, и пользы от `cleanup: false` у buildx не будет.
|
||||
@@ -0,0 +1,149 @@
|
||||
name: CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
uses: ./.gitea/workflows/quality.yaml
|
||||
with:
|
||||
is_pull_request: false
|
||||
before_sha: ${{ github.event.before }}
|
||||
head_sha: ${{ github.sha }}
|
||||
allow_registry_login: false
|
||||
secrets:
|
||||
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
|
||||
|
||||
publish:
|
||||
needs: [quality]
|
||||
if: >-
|
||||
always() &&
|
||||
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') &&
|
||||
needs.quality.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
releases: write
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
token: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
||||
persist-credentials: true
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Export cache paths
|
||||
run: sh scripts/ci/export-cache-env.sh
|
||||
- id: pnpm-hash
|
||||
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||
- id: pnpm-cache
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: |
|
||||
${{ env.PNPM_STORE_DIR }}
|
||||
${{ env.COREPACK_HOME }}
|
||||
node_modules
|
||||
apps/web/node_modules
|
||||
apps/api/node_modules
|
||||
packages/ui/node_modules
|
||||
packages/shared/node_modules
|
||||
packages/db/node_modules
|
||||
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||
restore-keys: |
|
||||
pnpm-${{ runner.os }}-
|
||||
- name: Install release tooling
|
||||
env:
|
||||
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||
run: sh scripts/ci/pnpm-ci.sh
|
||||
- name: Verify releasable commit messages
|
||||
run: pnpm exec node scripts/commit/verify-release-commits.mjs
|
||||
- name: Semantic release
|
||||
run: pnpm exec semantic-release
|
||||
env:
|
||||
GITEA_URL: https://git.shx.one
|
||||
GITEA_TOKEN: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
||||
- name: Detect new release
|
||||
id: rel
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version=""
|
||||
if [ -f .release-version ]; then
|
||||
version="$(tr -d '[:space:]' < .release-version)"
|
||||
echo "New release from semantic-release: $version"
|
||||
else
|
||||
git fetch --tags --force origin || true
|
||||
tag="$(git tag --points-at HEAD --list 'v*.*.*' | sort -V | tail -n1 || true)"
|
||||
if [ -n "${tag:-}" ]; then
|
||||
version="${tag#v}"
|
||||
echo "Reuse existing tag $tag on HEAD (release retry)"
|
||||
fi
|
||||
fi
|
||||
if [ -n "${version:-}" ]; then
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
echo "released=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "released=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No releasable commits — skipping image publish"
|
||||
fi
|
||||
- name: Set up Docker Buildx
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
with:
|
||||
name: evofw
|
||||
driver: docker-container
|
||||
cleanup: false
|
||||
- name: Prepare image metadata
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
id: meta
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "version=${{ steps.rel.outputs.version }}" >> "$GITHUB_OUTPUT"
|
||||
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
||||
echo "owner_lc=$owner_lc" >> "$GITHUB_OUTPUT"
|
||||
short_sha="$(echo '${{ github.sha }}' | cut -c1-7)"
|
||||
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
|
||||
echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||
- name: Log in to Gitea Registry
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
with:
|
||||
registry: git.shx.one
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.ACTIONS_PAT }}
|
||||
- name: Mirror base images into buildcache
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
env:
|
||||
REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
|
||||
MIRROR_ENV_FILE: ${{ runner.temp }}/mirror-base.env
|
||||
run: sh deploy/docker/mirror-base-images.sh
|
||||
- name: Build and push images (bake)
|
||||
if: steps.rel.outputs.released == 'true'
|
||||
env:
|
||||
REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
|
||||
IMAGE_TAG: latest
|
||||
VERSION: ${{ steps.meta.outputs.version }}
|
||||
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
|
||||
SHA_FULL: ${{ github.sha }}
|
||||
BUILD_TIME: ${{ steps.meta.outputs.build_time }}
|
||||
CACHE_REF_NODE: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evofw-buildcache:node-buildcache
|
||||
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||
MIRROR_ENV_FILE: ${{ runner.temp }}/mirror-base.env
|
||||
working-directory: deploy/docker
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
if [ -f "${MIRROR_ENV_FILE}" ]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "${MIRROR_ENV_FILE}"
|
||||
set +a
|
||||
fi
|
||||
docker buildx bake --allow=fs.read="${{ github.workspace }}" \
|
||||
-f docker-bake.hcl default --push
|
||||
@@ -0,0 +1,23 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
uses: ./.gitea/workflows/quality.yaml
|
||||
with:
|
||||
is_pull_request: true
|
||||
base_sha: ${{ github.event.pull_request.base.sha }}
|
||||
head_sha: ${{ github.event.pull_request.head.sha }}
|
||||
allow_registry_login: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
|
||||
secrets:
|
||||
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
|
||||
@@ -1,100 +0,0 @@
|
||||
name: Build and Push EvoFirewall Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop, 'feature/**', 'release/**', 'hotfix/**']
|
||||
tags: ['v*']
|
||||
paths: ['**']
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
paths: ['**']
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
if: startsWith(gitea.ref, 'refs/tags/v') || (gitea.ref_name == 'main' && gitea.event_name == 'push')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to Gitea Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.shts.su
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.ACTIONS_PAT }}
|
||||
|
||||
- name: Create version file
|
||||
run: |
|
||||
VERSION=$(cat VERSION)
|
||||
BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
|
||||
BRANCH="${{ gitea.ref_name }}"
|
||||
echo "APP_VERSION=${VERSION}" > ./version.txt
|
||||
echo "BUILD_DATE=${BUILD_DATE}" >> ./version.txt
|
||||
echo "GIT_BRANCH=${BRANCH}" >> ./version.txt
|
||||
echo "GIT_COMMIT=${{ gitea.sha }}" >> ./version.txt
|
||||
echo "GIT_COMMIT_SHORT=$(echo ${{ gitea.sha }} | cut -c1-7)" >> ./version.txt
|
||||
echo "BUILD_TIMESTAMP=$(date -u +%s)" >> ./version.txt
|
||||
|
||||
# Docker registry refs must be lowercase (repo name is EvoFirewall).
|
||||
- name: Lowercase image repository
|
||||
id: image
|
||||
run: echo "repo=$(echo '${{ gitea.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: git.shts.su/${{ steps.image.outputs.repo }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=raw,value=latest,enable=${{ gitea.ref_name == 'main' }}
|
||||
type=sha,prefix={{date 'YYYYMMDD'}}-,enable=${{ gitea.ref_name == 'main' }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
NODE_VERSION=22.23.0-bookworm-slim
|
||||
cache-from: type=registry,ref=git.shts.su/${{ steps.image.outputs.repo }}:buildcache
|
||||
cache-to: type=registry,ref=git.shts.su/${{ steps.image.outputs.repo }}:buildcache,mode=max
|
||||
provenance: true
|
||||
|
||||
create-release:
|
||||
needs: build-and-push
|
||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate changelog
|
||||
id: changelog
|
||||
run: |
|
||||
LAST_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
||||
if [[ -n "$LAST_TAG" ]]; then
|
||||
CHANGELOG=$(git log --pretty=format:"- %s (%h)" ${LAST_TAG}..HEAD)
|
||||
else
|
||||
CHANGELOG=$(git log --pretty=format:"- %s (%h)" -n 20)
|
||||
fi
|
||||
echo "CHANGELOG<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CHANGELOG" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create Gitea Release
|
||||
run: |
|
||||
curl -X POST \
|
||||
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"https://git.shts.su/api/v1/repos/${{ gitea.repository }}/releases" \
|
||||
-d "{\"tag_name\":\"${{ gitea.ref_name }}\",\"name\":\"${{ gitea.ref_name }}\",\"body\":$(echo '${{ steps.changelog.outputs.CHANGELOG }}' | jq -Rs .)}"
|
||||
@@ -0,0 +1,336 @@
|
||||
# Quality gates (reusable). Callers: ci.yaml (PR), cd.yaml (push main).
|
||||
name: quality
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
is_pull_request:
|
||||
type: boolean
|
||||
required: true
|
||||
base_sha:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
head_sha:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
before_sha:
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
allow_registry_login:
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
secrets:
|
||||
ACTIONS_PAT:
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
openapi: ${{ steps.detect.outputs.openapi }}
|
||||
web: ${{ steps.detect.outputs.web }}
|
||||
api: ${{ steps.detect.outputs.api }}
|
||||
docker: ${{ steps.detect.outputs.docker }}
|
||||
steps:
|
||||
- if: ${{ inputs.is_pull_request }}
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- if: ${{ inputs.is_pull_request == false }}
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 2
|
||||
- id: detect
|
||||
name: Detect changed paths per module
|
||||
env:
|
||||
IS_PR: ${{ inputs.is_pull_request }}
|
||||
BASE_SHA: ${{ inputs.base_sha }}
|
||||
HEAD_SHA: ${{ inputs.head_sha }}
|
||||
BEFORE_SHA: ${{ inputs.before_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
openapi=false
|
||||
web=false
|
||||
api=false
|
||||
docker=false
|
||||
|
||||
set_all_flags_true() {
|
||||
openapi=true
|
||||
web=true
|
||||
api=true
|
||||
docker=true
|
||||
}
|
||||
|
||||
write_outputs() {
|
||||
for v in openapi web api docker; do
|
||||
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
|
||||
done
|
||||
}
|
||||
|
||||
if [ "$IS_PR" = "true" ]; then
|
||||
FILES="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")"
|
||||
else
|
||||
after="${HEAD_SHA:-$(git rev-parse HEAD)}"
|
||||
before="$BEFORE_SHA"
|
||||
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
|
||||
FILES="$(git diff --name-only "$before" "$after")"
|
||||
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
|
||||
FILES="$(git diff --name-only HEAD~1 HEAD)"
|
||||
else
|
||||
set_all_flags_true
|
||||
write_outputs
|
||||
echo "No parent commit — full pipeline (all modules)"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "$(printf '%s' "$FILES" | tr -d '[:space:]')" ]; then
|
||||
set_all_flags_true
|
||||
write_outputs
|
||||
echo "Empty diff — full pipeline fallback"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
full_pipeline=false
|
||||
|
||||
while IFS= read -r f || [ -n "${f:-}" ]; do
|
||||
[ -z "${f:-}" ] && continue
|
||||
case "$f" in
|
||||
.gitea/workflows/*|scripts/*)
|
||||
full_pipeline=true
|
||||
;;
|
||||
docs/openapi.yaml|redocly.yaml)
|
||||
openapi=true
|
||||
;;
|
||||
.cursor/*|.claude/*|.codegraph/*|.agents/*)
|
||||
;;
|
||||
*.md|AGENTS.md)
|
||||
;;
|
||||
apps/web/README.md|apps/web/components.json|packages/ui/components.json)
|
||||
;;
|
||||
apps/web/*|packages/ui/*)
|
||||
web=true
|
||||
;;
|
||||
apps/api/*|packages/db/*)
|
||||
api=true
|
||||
;;
|
||||
packages/shared/*)
|
||||
web=true
|
||||
api=true
|
||||
;;
|
||||
deploy/compose/*|deploy/docker/*|.dockerignore)
|
||||
docker=true
|
||||
;;
|
||||
docs/*)
|
||||
;;
|
||||
package.json|pnpm-lock.yaml|pnpm-workspace.yaml|turbo.json|.releaserc.json|commitlint.config.cjs)
|
||||
full_pipeline=true
|
||||
;;
|
||||
*)
|
||||
;;
|
||||
esac
|
||||
done <<< "$FILES"
|
||||
|
||||
if $full_pipeline; then
|
||||
set_all_flags_true
|
||||
fi
|
||||
|
||||
write_outputs
|
||||
|
||||
echo "Changed files (first 30):"
|
||||
printf '%s\n' "$FILES" | head -n 30
|
||||
echo "--- flags ---"
|
||||
echo "openapi=$openapi web=$web api=$api docker=$docker full_pipeline=$full_pipeline"
|
||||
|
||||
openapi:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.openapi == 'true' || needs.changes.outputs.web == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Export cache paths
|
||||
run: sh scripts/ci/export-cache-env.sh
|
||||
- id: pnpm-hash
|
||||
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||
- id: pnpm-cache
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: |
|
||||
${{ env.PNPM_STORE_DIR }}
|
||||
${{ env.COREPACK_HOME }}
|
||||
node_modules
|
||||
apps/web/node_modules
|
||||
apps/api/node_modules
|
||||
packages/ui/node_modules
|
||||
packages/shared/node_modules
|
||||
packages/db/node_modules
|
||||
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||
restore-keys: |
|
||||
pnpm-${{ runner.os }}-
|
||||
- name: pnpm install, Redocly
|
||||
env:
|
||||
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
sh scripts/ci/pnpm-ci.sh
|
||||
pnpm exec redocly lint docs/openapi.yaml
|
||||
|
||||
web:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.web == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Export cache paths
|
||||
run: sh scripts/ci/export-cache-env.sh
|
||||
- id: pnpm-hash
|
||||
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||
- id: pnpm-cache
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: |
|
||||
${{ env.PNPM_STORE_DIR }}
|
||||
${{ env.COREPACK_HOME }}
|
||||
node_modules
|
||||
apps/web/node_modules
|
||||
apps/api/node_modules
|
||||
packages/ui/node_modules
|
||||
packages/shared/node_modules
|
||||
packages/db/node_modules
|
||||
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||
restore-keys: |
|
||||
pnpm-${{ runner.os }}-
|
||||
- name: pnpm install, typecheck, build
|
||||
env:
|
||||
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
sh scripts/ci/pnpm-ci.sh
|
||||
pnpm --filter @evofw/web run typecheck
|
||||
pnpm --filter @evofw/web run build
|
||||
|
||||
api:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.api == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Export cache paths
|
||||
run: sh scripts/ci/export-cache-env.sh
|
||||
- id: pnpm-hash
|
||||
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||
- id: pnpm-cache
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: |
|
||||
${{ env.PNPM_STORE_DIR }}
|
||||
${{ env.COREPACK_HOME }}
|
||||
node_modules
|
||||
apps/web/node_modules
|
||||
apps/api/node_modules
|
||||
packages/ui/node_modules
|
||||
packages/shared/node_modules
|
||||
packages/db/node_modules
|
||||
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||
restore-keys: |
|
||||
pnpm-${{ runner.os }}-
|
||||
- name: pnpm install, test, build
|
||||
env:
|
||||
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
sh scripts/ci/pnpm-ci.sh
|
||||
pnpm exec turbo run test --filter=@evofw/api
|
||||
pnpm exec turbo run build --filter=@evofw/api
|
||||
|
||||
commitlint:
|
||||
if: inputs.is_pull_request
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
- name: Export cache paths
|
||||
run: sh scripts/ci/export-cache-env.sh
|
||||
- id: pnpm-hash
|
||||
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||
- id: pnpm-cache
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||
with:
|
||||
path: |
|
||||
${{ env.PNPM_STORE_DIR }}
|
||||
${{ env.COREPACK_HOME }}
|
||||
node_modules
|
||||
apps/web/node_modules
|
||||
apps/api/node_modules
|
||||
packages/ui/node_modules
|
||||
packages/shared/node_modules
|
||||
packages/db/node_modules
|
||||
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||
restore-keys: |
|
||||
pnpm-${{ runner.os }}-
|
||||
- name: Lint commit messages
|
||||
env:
|
||||
BASE_SHA: ${{ inputs.base_sha }}
|
||||
HEAD_SHA: ${{ inputs.head_sha }}
|
||||
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
sh scripts/ci/pnpm-ci.sh
|
||||
pnpm exec commitlint --from "$BASE_SHA" --to "$HEAD_SHA"
|
||||
|
||||
docker-check:
|
||||
needs: [changes]
|
||||
if: inputs.is_pull_request && needs.changes.outputs.docker == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
with:
|
||||
name: evofw
|
||||
driver: docker-container
|
||||
cleanup: false
|
||||
- name: Log in to Gitea Registry
|
||||
if: inputs.allow_registry_login
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
with:
|
||||
registry: git.shx.one
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.ACTIONS_PAT }}
|
||||
- name: bake --print
|
||||
working-directory: deploy/docker
|
||||
env:
|
||||
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||
run: docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl --print default
|
||||
- name: bake (no push)
|
||||
if: inputs.allow_registry_login
|
||||
working-directory: deploy/docker
|
||||
env:
|
||||
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
||||
export CACHE_REF_NODE="git.shx.one/${owner_lc}/evofw-buildcache:node-buildcache"
|
||||
docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl default
|
||||
@@ -31,6 +31,9 @@ Thumbs.db
|
||||
|
||||
# Build
|
||||
version.txt
|
||||
.release-version
|
||||
CHANGELOG.md
|
||||
deploy/docker/docker-bake.override.hcl
|
||||
*.tsbuildinfo
|
||||
# Local MCP configs (may contain REUI license Bearer)
|
||||
.cursor/mcp.json
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"branches": ["main", "master"],
|
||||
"tagFormat": "v${version}",
|
||||
"plugins": [
|
||||
[
|
||||
"@semantic-release/commit-analyzer",
|
||||
{
|
||||
"preset": "conventionalcommits",
|
||||
"releaseRules": [
|
||||
{ "type": "feat", "release": "minor" },
|
||||
{ "type": "fix", "release": "patch" },
|
||||
{ "type": "perf", "release": "patch" },
|
||||
{ "type": "ci", "release": "patch" },
|
||||
{ "type": "refactor", "release": "patch" },
|
||||
{ "breaking": true, "release": "major" }
|
||||
]
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/release-notes-generator",
|
||||
{
|
||||
"preset": "conventionalcommits"
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/changelog",
|
||||
{
|
||||
"changelogFile": "CHANGELOG.md"
|
||||
}
|
||||
],
|
||||
[
|
||||
"@semantic-release/exec",
|
||||
{
|
||||
"successCmd": "echo ${nextRelease.version} > .release-version"
|
||||
}
|
||||
],
|
||||
[
|
||||
"@markwylde/semantic-release-gitea",
|
||||
{
|
||||
"giteaUrl": "https://git.shx.one",
|
||||
"assets": [
|
||||
{
|
||||
"path": "CHANGELOG.md",
|
||||
"label": "Changelog"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
@@ -32,8 +32,12 @@ Skill: `.claude/skills/reui` (`668fb463eb`).
|
||||
|
||||
## Docs
|
||||
|
||||
`docs/architecture.md`, `docs/agents.md`, `docs/integrate-auth-portal.md`, `docs/integrate-evobgp.md`.
|
||||
`docs/architecture.md`, `docs/agents.md`, `docs/integrate-auth-portal.md`, `docs/integrate-evobgp.md`, `docs/releasing.md`.
|
||||
|
||||
## CI / Docker
|
||||
|
||||
Gitea: `.gitea/workflows/{ci,cd,quality}.yaml`. Образы: `deploy/docker` (`node:22-alpine`, bake + registry cache). Не корневой `Dockerfile`.
|
||||
|
||||
## Git
|
||||
|
||||
`origin` → https://git.shts.su/denozord/EvoFirewall.git
|
||||
`origin` → https://git.shx.one/denozord/EvoFirewall.git
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
ARG NODE_VERSION=22.23.0-bookworm-slim
|
||||
|
||||
FROM node:${NODE_VERSION} AS build
|
||||
WORKDIR /app
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& corepack enable
|
||||
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml turbo.json ./
|
||||
COPY apps/web/package.json apps/web/
|
||||
COPY apps/api/package.json apps/api/
|
||||
COPY packages/ui/package.json packages/ui/
|
||||
COPY packages/shared/package.json packages/shared/
|
||||
COPY packages/db/package.json packages/db/
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
COPY apps/web apps/web
|
||||
COPY apps/api apps/api
|
||||
COPY packages/ui packages/ui
|
||||
COPY packages/shared packages/shared
|
||||
COPY packages/db packages/db
|
||||
|
||||
RUN pnpm turbo build --filter=@evofw/web --filter=@evofw/api \
|
||||
&& pnpm --filter @evofw/api deploy --prod /out \
|
||||
&& cp -r apps/web/dist /out/static \
|
||||
&& mkdir -p /out/dist/agent-scripts \
|
||||
&& cp -r apps/api/src/agent-scripts/* /out/dist/agent-scripts/ \
|
||||
&& rm -rf /out/src /out/test /out/.turbo \
|
||||
&& rm -rf /out/node_modules/@evofw/db/src /out/node_modules/@evofw/db/.turbo \
|
||||
&& rm -rf /out/node_modules/@evofw/shared/src /out/node_modules/@evofw/shared/.turbo
|
||||
|
||||
FROM node:${NODE_VERSION}
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production \
|
||||
STATIC_DIR=/app/static \
|
||||
DATABASE_URL=sqlite:/data/app.db \
|
||||
SERVER_PORT=8080
|
||||
|
||||
COPY --from=build /out ./
|
||||
|
||||
EXPOSE 8080
|
||||
VOLUME ["/data"]
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD ["node", "-e", "fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||
CMD ["node", "dist/server.js"]
|
||||
@@ -42,6 +42,15 @@ curl -fsSL http://localhost:8080/v1/agent/install.sh | \
|
||||
|
||||
Затем Approve в UI `/agents`.
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
cd deploy/docker
|
||||
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl --print default
|
||||
```
|
||||
|
||||
Compose: [`deploy/compose/docker-compose.example.yaml`](deploy/compose/docker-compose.example.yaml). Образ: `git.shx.one/<owner>/evofw` (алиас `evofirewall`). CI/релизы: [`docs/releasing.md`](docs/releasing.md).
|
||||
|
||||
## Docs
|
||||
|
||||
См. [`docs/README.md`](docs/README.md).
|
||||
@@ -49,5 +58,5 @@ curl -fsSL http://localhost:8080/v1/agent/install.sh | \
|
||||
## Git
|
||||
|
||||
```
|
||||
origin https://git.shts.su/denozord/EvoFirewall.git
|
||||
origin https://git.shx.one/denozord/EvoFirewall.git
|
||||
```
|
||||
@@ -8,6 +8,7 @@ STATE_DIR=/var/lib/evofw
|
||||
HASH_FILE="${STATE_DIR}/last_hash"
|
||||
POLICY_FILE="${STATE_DIR}/last_policy.json"
|
||||
IP_HITS_TOP=200
|
||||
PORT_HITS_TOP=500
|
||||
|
||||
log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $*" | tee -a "$LOG_FILE"; }
|
||||
|
||||
@@ -23,9 +24,81 @@ source "$CONF_FILE"
|
||||
CLIENT_TOKEN="${CLIENT_TOKEN//$'\r'/}"
|
||||
CLIENT_TOKEN="${CLIENT_TOKEN//$'\n'/}"
|
||||
BACKEND="${KERNEL_BACKEND:-auto}"
|
||||
SYNC_SCRIPT=/usr/local/sbin/evofw-firewall.sh
|
||||
|
||||
mkdir -p "$STATE_DIR"
|
||||
|
||||
file_sha256() {
|
||||
local f=$1
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$f" 2>/dev/null | awk '{print $1}'
|
||||
elif command -v openssl >/dev/null 2>&1; then
|
||||
openssl dgst -sha256 "$f" 2>/dev/null | awk '{print $NF}'
|
||||
else
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
# Pull a newer sync script from CP before policy (pending agents too).
|
||||
# Errors must not abort this run — keep the current script.
|
||||
maybe_self_update() {
|
||||
if [[ "${EVOFW_SKIP_SELF_UPDATE:-}" == "1" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if [[ ! -f "$SYNC_SCRIPT" ]]; then
|
||||
return 0
|
||||
fi
|
||||
local local_sha tmp code remote_sha
|
||||
local_sha=$(file_sha256 "$SYNC_SCRIPT")
|
||||
tmp=$(mktemp "${STATE_DIR}/sync-script.XXXXXX") || return 0
|
||||
if [[ -n "$local_sha" ]]; then
|
||||
code=$(curl -sS -o "$tmp" -w '%{http_code}' \
|
||||
-H "If-None-Match: \"${local_sha}\"" \
|
||||
"${EVOFW_CP_URL%/}/v1/agent/sync-script") || code="000"
|
||||
else
|
||||
code=$(curl -sS -o "$tmp" -w '%{http_code}' \
|
||||
"${EVOFW_CP_URL%/}/v1/agent/sync-script") || code="000"
|
||||
fi
|
||||
if [[ "$code" == "304" ]]; then
|
||||
rm -f "$tmp"
|
||||
return 0
|
||||
fi
|
||||
if [[ "$code" != "200" ]]; then
|
||||
log "self-update: sync-script HTTP ${code} — keep current"
|
||||
rm -f "$tmp"
|
||||
return 0
|
||||
fi
|
||||
if ! head -n1 "$tmp" | grep -q '^#!'; then
|
||||
log "self-update: sync-script is not a shell script — keep current"
|
||||
rm -f "$tmp"
|
||||
return 0
|
||||
fi
|
||||
remote_sha=$(file_sha256 "$tmp")
|
||||
if [[ -z "$remote_sha" ]]; then
|
||||
log "self-update: cannot hash download — keep current"
|
||||
rm -f "$tmp"
|
||||
return 0
|
||||
fi
|
||||
if [[ -n "$local_sha" && "$remote_sha" == "$local_sha" ]]; then
|
||||
rm -f "$tmp"
|
||||
return 0
|
||||
fi
|
||||
if ! install -m 755 "$tmp" "$SYNC_SCRIPT"; then
|
||||
log "self-update: install failed — keep current"
|
||||
rm -f "$tmp"
|
||||
return 0
|
||||
fi
|
||||
rm -f "$tmp"
|
||||
rm -f "$HASH_FILE"
|
||||
log "self-update: installed script sha256=${remote_sha} — re-exec"
|
||||
exec env EVOFW_SKIP_SELF_UPDATE=1 "$SYNC_SCRIPT" || {
|
||||
log "self-update: exec failed — continue current"
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
maybe_self_update
|
||||
|
||||
curl_policy() {
|
||||
local dest="$1"
|
||||
local code
|
||||
@@ -56,6 +129,7 @@ fi
|
||||
|
||||
parse_policy() {
|
||||
local f="$1"
|
||||
PORT_RULES_FILE="${STATE_DIR}/last_port_rules.json"
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
HASH=$(jq -r '.hash // empty' "$f")
|
||||
DEFAULT_ACTION=$(jq -r '.default_action // empty' "$f")
|
||||
@@ -66,10 +140,11 @@ parse_policy() {
|
||||
fi
|
||||
mapfile -t DENY < <(jq -r '.deny_cidrs[]? // empty' "$f")
|
||||
mapfile -t ALLOW < <(jq -r '.allow_cidrs[]? // empty' "$f")
|
||||
jq -c '.port_rules // []' "$f" >"$PORT_RULES_FILE" 2>/dev/null || echo '[]' >"$PORT_RULES_FILE"
|
||||
return 0
|
||||
fi
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
eval "$(python3 - "$f" <<'PY'
|
||||
eval "$(python3 - "$f" "$PORT_RULES_FILE" <<'PY'
|
||||
import json,sys
|
||||
d=json.load(open(sys.argv[1],encoding="utf-8"))
|
||||
print(f'HASH={d.get("hash") or ""}')
|
||||
@@ -79,6 +154,7 @@ if not da:
|
||||
print(f'DEFAULT_ACTION={da}')
|
||||
print("DENY=("+" ".join(json.dumps(x) for x in (d.get("deny_cidrs") or []))+")")
|
||||
print("ALLOW=("+" ".join(json.dumps(x) for x in (d.get("allow_cidrs") or []))+")")
|
||||
open(sys.argv[2],"w",encoding="utf-8").write(json.dumps(d.get("port_rules") or []))
|
||||
PY
|
||||
)"
|
||||
return 0
|
||||
@@ -88,17 +164,31 @@ PY
|
||||
}
|
||||
|
||||
HASH=""; DEFAULT_ACTION=accept; DENY=(); ALLOW=()
|
||||
PORT_RULES_FILE="${STATE_DIR}/last_port_rules.json"
|
||||
parse_policy "$POLICY_FILE"
|
||||
# Empty deny/allow is valid — agent may have no rule sets yet.
|
||||
DENY=("${DENY[@]+"${DENY[@]}"}")
|
||||
ALLOW=("${ALLOW[@]+"${ALLOW[@]}"}")
|
||||
log "default_action=$DEFAULT_ACTION deny=${#DENY[@]} allow=${#ALLOW[@]} hash=$HASH"
|
||||
[[ -f "$PORT_RULES_FILE" ]] || echo '[]' >"$PORT_RULES_FILE"
|
||||
PORT_RULES_COUNT=0
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
PORT_RULES_COUNT=$(python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1]))))' "$PORT_RULES_FILE" 2>/dev/null || echo 0)
|
||||
fi
|
||||
log "default_action=$DEFAULT_ACTION deny=${#DENY[@]} allow=${#ALLOW[@]} port_rules=$PORT_RULES_COUNT hash=$HASH"
|
||||
|
||||
PACKETS_DROPPED=0
|
||||
PACKETS_ACCEPTED=0
|
||||
KERNEL_METHOD=""
|
||||
APPLIED=0
|
||||
IP_HITS_JSON="[]"
|
||||
# Snapshot / hits live on disk — never via bash ${var:-{...}} (} truncates expansion).
|
||||
HOST_FW_FILE="${STATE_DIR}/host_firewall.json"
|
||||
IP_HITS_FILE="${STATE_DIR}/ip_hits.json"
|
||||
PORT_HITS_FILE="${STATE_DIR}/port_hits.json"
|
||||
printf '%s' '{"rules":[],"listeners":[]}' >"$HOST_FW_FILE"
|
||||
printf '%s' '[]' >"$IP_HITS_FILE"
|
||||
printf '%s' '[]' >"$PORT_HITS_FILE"
|
||||
# 1 when deny_port_hits dynamic set is available for this apply.
|
||||
PORT_HITS_ENABLED=0
|
||||
|
||||
nft_join() {
|
||||
local out="" p
|
||||
@@ -118,86 +208,105 @@ nft_add_chunk() {
|
||||
}
|
||||
}
|
||||
|
||||
# Ensure inet set exists with interval + counter (recreate if missing counter).
|
||||
# Ensure inet set exists. Prefer per-element counters; many kernels reject
|
||||
# `counter` on interval sets — fall back to plain interval (no per-IP hits).
|
||||
# Caller must delete referencing chains before recreating a set.
|
||||
ensure_nft_set() {
|
||||
local table=$1 name=$2 setname=$3
|
||||
local def
|
||||
def=$(nft -a list set "$table" "$name" "$setname" 2>/dev/null || true)
|
||||
def=$(nft list set "$table" "$name" "$setname" 2>/dev/null || true)
|
||||
if [[ -n "$def" ]] && [[ "$def" == *"counter"* ]]; then
|
||||
return 0
|
||||
fi
|
||||
if [[ -n "$def" ]]; then
|
||||
# Upgrade path: drop old set without counters (chain must already be gone).
|
||||
nft delete set "$table" "$name" "$setname" 2>>"$LOG_FILE" || true
|
||||
fi
|
||||
nft add set "$table" "$name" "$setname" '{ type ipv4_addr; flags interval; counter; }' 2>>"$LOG_FILE"
|
||||
if nft add set "$table" "$name" "$setname" '{ type ipv4_addr; flags interval; counter; }' 2>>"$LOG_FILE"; then
|
||||
return 0
|
||||
fi
|
||||
# Set may still exist if delete failed — try plain create only if missing.
|
||||
if nft list set "$table" "$name" "$setname" >/dev/null 2>&1; then
|
||||
log "nft: keep existing set $setname (no per-element counter)"
|
||||
return 0
|
||||
fi
|
||||
if nft add set "$table" "$name" "$setname" '{ type ipv4_addr; flags interval; }' 2>>"$LOG_FILE"; then
|
||||
log "nft: set $setname without counter (interval+counter unsupported)"
|
||||
return 0
|
||||
fi
|
||||
log "nft: failed to create set $setname"
|
||||
return 1
|
||||
}
|
||||
|
||||
collect_nft_stats() {
|
||||
PACKETS_DROPPED=0; PACKETS_ACCEPTED=0
|
||||
local line n
|
||||
while IFS= read -r line; do
|
||||
[[ "$line" =~ packets[[:space:]]+([0-9]+) ]] || continue
|
||||
n="${BASH_REMATCH[1]}"
|
||||
# Policy set hits only (ignore lo / established noise)
|
||||
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
|
||||
done < <(nft list chain inet evofw input 2>/dev/null || true)
|
||||
local line n chain
|
||||
for chain in input forward prerouting; do
|
||||
while IFS= read -r line; do
|
||||
[[ "$line" =~ packets[[:space:]]+([0-9]+) ]] || continue
|
||||
n="${BASH_REMATCH[1]}"
|
||||
# Policy set hits only (ignore lo / established noise)
|
||||
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
|
||||
done < <(nft list chain inet evofw "$chain" 2>/dev/null || true)
|
||||
done
|
||||
}
|
||||
|
||||
# Parse nft set / ipset listing → top-N JSON [{"ip":"...","packets":N},...]
|
||||
build_ip_hits_json() {
|
||||
local text="$1"
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
IP_HITS_JSON=$(IP_HITS_TOP="$IP_HITS_TOP" python3 -c '
|
||||
import json, os, re, sys
|
||||
text = sys.stdin.read()
|
||||
# Parse nft/ipset listing from file → IP_HITS_FILE (top-N JSON).
|
||||
build_ip_hits_from_file() {
|
||||
local src="$1"
|
||||
printf '%s' '[]' >"$IP_HITS_FILE"
|
||||
[[ -f "$src" ]] || return 0
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
IP_HITS_TOP="$IP_HITS_TOP" IN_F="$src" OUT_F="$IP_HITS_FILE" python3 - <<'PY' 2>>"$LOG_FILE" || printf '%s' '[]' >"$IP_HITS_FILE"
|
||||
import json, os, re
|
||||
path = os.environ["IN_F"]
|
||||
top = int(os.environ.get("IP_HITS_TOP", "200"))
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||
text = f.read()
|
||||
except OSError:
|
||||
text = ""
|
||||
hits = {}
|
||||
# nft: "1.2.3.4 counter packets 10 bytes 100" or "1.2.3.0/24 packets 5 bytes 20"
|
||||
for m in re.finditer(r"([0-9]{1,3}(?:\.[0-9]{1,3}){3}(?:/[0-9]{1,2})?)\s+(?:counter\s+)?packets\s+(\d+)", text):
|
||||
ip, pkts = m.group(1), int(m.group(2))
|
||||
if pkts > 0:
|
||||
hits[ip] = max(hits.get(ip, 0), pkts)
|
||||
# ipset list Members: "1.2.3.4 packets 10 bytes 100"
|
||||
for m in re.finditer(r"^([0-9]{1,3}(?:\.[0-9]{1,3}){3}(?:/[0-9]{1,2})?)\s+packets\s+(\d+)", text, re.M):
|
||||
ip, pkts = m.group(1), int(m.group(2))
|
||||
if pkts > 0:
|
||||
hits[ip] = max(hits.get(ip, 0), pkts)
|
||||
items = [{"ip": k, "packets": v} for k, v in hits.items()]
|
||||
items.sort(key=lambda x: x["packets"], reverse=True)
|
||||
print(json.dumps(items[:top], separators=(",", ":")))
|
||||
' <<<"$text" 2>/dev/null) || IP_HITS_JSON="[]"
|
||||
return
|
||||
fi
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
# Fallback without python: empty (jq alone cannot easily top-N from free text)
|
||||
IP_HITS_JSON="[]"
|
||||
return
|
||||
fi
|
||||
IP_HITS_JSON="[]"
|
||||
with open(os.environ["OUT_F"], "w", encoding="utf-8") as f:
|
||||
json.dump(items[:top], f, separators=(",", ":"))
|
||||
print(len(items[:top]))
|
||||
PY
|
||||
}
|
||||
|
||||
collect_nft_ip_hits() {
|
||||
local text
|
||||
text=$(nft list set inet evofw deny_v4 2>/dev/null || true)
|
||||
build_ip_hits_json "$text"
|
||||
local dump="${STATE_DIR}/nft_deny_v4.txt"
|
||||
nft list set inet evofw deny_v4 >"$dump" 2>/dev/null || : >"$dump"
|
||||
build_ip_hits_from_file "$dump"
|
||||
}
|
||||
|
||||
collect_ipset_ip_hits() {
|
||||
local text
|
||||
text=$(ipset list evofw_deny_v4 2>/dev/null || true)
|
||||
build_ip_hits_json "$text"
|
||||
local dump="${STATE_DIR}/ipset_deny_v4.txt"
|
||||
ipset list evofw_deny_v4 >"$dump" 2>/dev/null || : >"$dump"
|
||||
build_ip_hits_from_file "$dump"
|
||||
}
|
||||
|
||||
collect_ip_hits() {
|
||||
IP_HITS_JSON="[]"
|
||||
printf '%s' '[]' >"$IP_HITS_FILE"
|
||||
if [[ "${KERNEL_METHOD:-}" == "nft" ]] || { [[ -z "${KERNEL_METHOD:-}" || "${KERNEL_METHOD:-}" == "auto" ]] && command -v nft >/dev/null 2>&1 && nft list set inet evofw deny_v4 >/dev/null 2>&1; }; then
|
||||
collect_nft_ip_hits
|
||||
return
|
||||
@@ -207,17 +316,118 @@ collect_ip_hits() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Parse nft dynamic concat set from file → PORT_HITS_FILE.
|
||||
build_port_hits_from_file() {
|
||||
local src="$1"
|
||||
printf '%s' '[]' >"$PORT_HITS_FILE"
|
||||
[[ -f "$src" && -s "$src" ]] || return 0
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
PORT_HITS_TOP="$PORT_HITS_TOP" IN_F="$src" OUT_F="$PORT_HITS_FILE" python3 - <<'PY' 2>>"$LOG_FILE" || printf '%s' '[]' >"$PORT_HITS_FILE"
|
||||
import json, os, re
|
||||
path = os.environ["IN_F"]
|
||||
top = int(os.environ.get("PORT_HITS_TOP", "500"))
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||
text = f.read()
|
||||
except OSError:
|
||||
text = ""
|
||||
hits = {}
|
||||
proto_map = {"6": "tcp", "17": "udp", "tcp": "tcp", "udp": "udp"}
|
||||
pat = re.compile(
|
||||
r"([0-9]{1,3}(?:\.[0-9]{1,3}){3})\s*\.\s*([A-Za-z0-9]+)\s*\.\s*(\d+)\s+"
|
||||
r"(?:timeout\s+\S+\s+)?(?:counter\s+)?packets\s+(\d+)",
|
||||
re.I,
|
||||
)
|
||||
for m in pat.finditer(text):
|
||||
ip, raw_proto, port_s, pkts_s = m.group(1), m.group(2).lower(), m.group(3), m.group(4)
|
||||
proto = proto_map.get(raw_proto)
|
||||
if not proto:
|
||||
continue
|
||||
pkts = int(pkts_s)
|
||||
if pkts <= 0:
|
||||
continue
|
||||
port = int(port_s)
|
||||
if port < 1 or port > 65535:
|
||||
continue
|
||||
key = (ip, port, proto)
|
||||
hits[key] = max(hits.get(key, 0), pkts)
|
||||
items = [
|
||||
{"ip": ip, "port": port, "protocol": proto, "packets": pkts}
|
||||
for (ip, port, proto), pkts in hits.items()
|
||||
]
|
||||
items.sort(key=lambda x: x["packets"], reverse=True)
|
||||
with open(os.environ["OUT_F"], "w", encoding="utf-8") as f:
|
||||
json.dump(items[:top], f, separators=(",", ":"))
|
||||
print(len(items[:top]))
|
||||
PY
|
||||
}
|
||||
|
||||
collect_nft_port_hits() {
|
||||
local dump="${STATE_DIR}/nft_deny_port_hits.txt"
|
||||
printf '%s' '[]' >"$PORT_HITS_FILE"
|
||||
nft list set inet evofw deny_port_hits >"$dump" 2>/dev/null || : >"$dump"
|
||||
[[ -s "$dump" ]] || return 0
|
||||
build_port_hits_from_file "$dump"
|
||||
}
|
||||
|
||||
collect_port_hits() {
|
||||
printf '%s' '[]' >"$PORT_HITS_FILE"
|
||||
if [[ "${KERNEL_METHOD:-}" == "nft" ]] || { [[ -z "${KERNEL_METHOD:-}" || "${KERNEL_METHOD:-}" == "auto" ]] && command -v nft >/dev/null 2>&1 && nft list set inet evofw deny_port_hits >/dev/null 2>&1; }; then
|
||||
collect_nft_port_hits
|
||||
fi
|
||||
}
|
||||
|
||||
# Dynamic concat set for per-(ip, proto, dport) deny hits. Returns 0 if usable.
|
||||
ensure_nft_port_hits_set() {
|
||||
local table=$1 name=$2
|
||||
local setname=deny_port_hits
|
||||
local def
|
||||
def=$(nft list set "$table" "$name" "$setname" 2>/dev/null || true)
|
||||
if [[ -n "$def" ]] && { [[ "$def" == *"dynamic"* ]] || [[ "$def" == *"timeout"* ]]; }; then
|
||||
# Keep existing; elements age out via timeout — do not flush on every apply
|
||||
# (counters survive policy CIDR refresh when chain is recreated).
|
||||
return 0
|
||||
fi
|
||||
if [[ -n "$def" ]]; then
|
||||
nft delete set "$table" "$name" "$setname" 2>>"$LOG_FILE" || true
|
||||
fi
|
||||
if nft add set "$table" "$name" "$setname" \
|
||||
'{ type ipv4_addr . inet_proto . inet_service; flags dynamic,timeout; timeout 1h; counter; }' \
|
||||
2>>"$LOG_FILE"; then
|
||||
return 0
|
||||
fi
|
||||
# Older kernels may need slightly different flag spelling.
|
||||
if nft add set "$table" "$name" "$setname" \
|
||||
'{ type ipv4_addr . inet_proto . inet_service; flags dynamic; timeout 1h; counter; }' \
|
||||
2>>"$LOG_FILE"; then
|
||||
return 0
|
||||
fi
|
||||
log "nft: deny_port_hits unsupported — port hits disabled"
|
||||
return 1
|
||||
}
|
||||
|
||||
apply_nft() {
|
||||
local table=inet name=evofw
|
||||
local deny_v4=() allow_v4=() p
|
||||
PORT_HITS_ENABLED=0
|
||||
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"
|
||||
# Drop chains first so sets can be deleted/recreated (upgrade to counters / port hits).
|
||||
# Stats were already captured by the caller before apply_nft.
|
||||
nft delete chain "$table" "$name" input 2>/dev/null || true
|
||||
nft delete chain "$table" "$name" forward 2>/dev/null || true
|
||||
nft delete chain "$table" "$name" prerouting 2>/dev/null || true
|
||||
ensure_nft_set "$table" "$name" deny_v4
|
||||
ensure_nft_set "$table" "$name" allow_v4
|
||||
nft flush set "$table" "$name" deny_v4
|
||||
nft flush set "$table" "$name" allow_v4
|
||||
if ensure_nft_port_hits_set "$table" "$name"; then
|
||||
PORT_HITS_ENABLED=1
|
||||
fi
|
||||
nft flush set "$table" "$name" deny_v4 2>>"$LOG_FILE" || true
|
||||
nft flush set "$table" "$name" allow_v4 2>>"$LOG_FILE" || true
|
||||
|
||||
local batch=() chunk=64
|
||||
for p in "${deny_v4[@]}"; do
|
||||
@@ -232,39 +442,418 @@ apply_nft() {
|
||||
done
|
||||
((${#batch[@]})) && nft_add_chunk "$table" "$name" allow_v4 "${batch[@]}"
|
||||
|
||||
nft delete chain "$table" "$name" input 2>/dev/null || true
|
||||
# Unified chain: deny → allow → default_action
|
||||
# input + forward: deny → Port ACL → allow → default.
|
||||
# prerouting (mangle, before Docker NAT): established → deny → Port ACL inbound only
|
||||
# (fib daddr type local; no default drop — other traffic continues to host/Docker).
|
||||
if [[ "$DEFAULT_ACTION" == "drop" ]]; then
|
||||
nft add chain "$table" "$name" input '{ type filter hook input priority 0; policy drop; }'
|
||||
nft add chain "$table" "$name" forward '{ type filter hook forward priority 0; policy drop; }'
|
||||
else
|
||||
nft add chain "$table" "$name" input '{ type filter hook input priority 0; policy accept; }'
|
||||
nft add chain "$table" "$name" forward '{ type filter hook forward priority 0; policy accept; }'
|
||||
fi
|
||||
nft add chain "$table" "$name" prerouting '{ type filter hook prerouting priority mangle; policy accept; }'
|
||||
|
||||
nft add rule "$table" "$name" input ct state established,related counter accept
|
||||
nft add rule "$table" "$name" input iif lo counter accept
|
||||
nft add rule "$table" "$name" input ip saddr @deny_v4 counter drop
|
||||
nft add rule "$table" "$name" forward ct state established,related counter accept
|
||||
nft add rule "$table" "$name" prerouting ct state established,related counter accept
|
||||
|
||||
nft_add_deny_on_chain "$table" "$name" input
|
||||
nft_add_deny_on_chain "$table" "$name" forward
|
||||
nft_add_deny_on_chain "$table" "$name" prerouting
|
||||
|
||||
apply_nft_port_acl "$table" "$name"
|
||||
|
||||
nft add rule "$table" "$name" input ip saddr @allow_v4 counter accept
|
||||
nft add rule "$table" "$name" forward ip saddr @allow_v4 counter accept
|
||||
if [[ "$DEFAULT_ACTION" == "drop" ]]; then
|
||||
nft add rule "$table" "$name" input counter drop
|
||||
nft add rule "$table" "$name" forward counter drop
|
||||
else
|
||||
nft add rule "$table" "$name" input counter accept
|
||||
nft add rule "$table" "$name" forward counter accept
|
||||
fi
|
||||
KERNEL_METHOD=nft
|
||||
APPLIED=$((${#deny_v4[@]} + ${#allow_v4[@]}))
|
||||
}
|
||||
|
||||
# Deny set (+ optional port-hits) on a filter chain. Mutates PORT_HITS_ENABLED on fallback.
|
||||
nft_add_deny_on_chain() {
|
||||
local table=$1 name=$2 chain=$3
|
||||
if [[ "$PORT_HITS_ENABLED" -eq 1 ]]; then
|
||||
if ! nft add rule "$table" "$name" "$chain" \
|
||||
ip saddr @deny_v4 meta l4proto '{ tcp, udp }' \
|
||||
update @deny_port_hits '{ ip saddr . meta l4proto . th dport }' \
|
||||
counter drop 2>>"$LOG_FILE"; then
|
||||
log "nft: port-hit deny on $chain failed — fallback to plain deny drop"
|
||||
PORT_HITS_ENABLED=0
|
||||
nft add rule "$table" "$name" "$chain" ip saddr @deny_v4 counter drop
|
||||
else
|
||||
nft add rule "$table" "$name" "$chain" ip saddr @deny_v4 counter drop
|
||||
fi
|
||||
else
|
||||
nft add rule "$table" "$name" "$chain" ip saddr @deny_v4 counter drop
|
||||
fi
|
||||
}
|
||||
|
||||
# Apply desired L4 port open/close rules from PORT_RULES_FILE (apply_version 3).
|
||||
apply_nft_port_acl() {
|
||||
local table=$1 name=$2
|
||||
[[ -f "$PORT_RULES_FILE" ]] || return 0
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
log "nft port ACL skipped — need python3"
|
||||
return 0
|
||||
fi
|
||||
# Delete prior per-rule src sets (name prefix port_src_)
|
||||
local setline setname
|
||||
while IFS= read -r setline; do
|
||||
setname=$(echo "$setline" | sed -n 's/.*set \(port_src_[a-zA-Z0-9_-]*\).*/\1/p')
|
||||
[[ -n "$setname" ]] || continue
|
||||
nft delete set "$table" "$name" "$setname" 2>/dev/null || true
|
||||
done < <(nft list table "$table" "$name" 2>/dev/null | grep -E 'set port_src_' || true)
|
||||
|
||||
local cmds_file
|
||||
cmds_file=$(mktemp)
|
||||
python3 - "$PORT_RULES_FILE" >"$cmds_file" <<'PY'
|
||||
import json, sys, re
|
||||
path = sys.argv[1]
|
||||
try:
|
||||
rules = json.load(open(path, encoding="utf-8"))
|
||||
except Exception:
|
||||
rules = []
|
||||
safe_id = re.compile(r"[^a-zA-Z0-9_]")
|
||||
CHAINS = ("prerouting", "input", "forward")
|
||||
|
||||
def parse_rule(r):
|
||||
rid = safe_id.sub("_", str(r.get("id") or "x"))[:40]
|
||||
action = r.get("action") or "open"
|
||||
proto = r.get("protocol") or "tcp"
|
||||
if proto not in ("tcp", "udp"):
|
||||
return None
|
||||
ps = int(r.get("port_start") or 0)
|
||||
pe = int(r.get("port_end") or ps)
|
||||
if ps < 1 or pe > 65535 or pe < ps:
|
||||
return None
|
||||
cidrs = [c for c in (r.get("src_cidrs") or []) if c and ":" not in c]
|
||||
if not cidrs:
|
||||
return None
|
||||
dport = f"{ps}" if ps == pe else f"{ps}-{pe}"
|
||||
return {
|
||||
"rid": rid,
|
||||
"action": action,
|
||||
"proto": proto,
|
||||
"dport": dport,
|
||||
"cidrs": cidrs,
|
||||
"is_all": any(c in ("0.0.0.0/0", "0.0.0.0") for c in cidrs),
|
||||
}
|
||||
|
||||
def emit_set(p):
|
||||
if p["is_all"]:
|
||||
return
|
||||
setname = f"port_src_{p['rid']}_{p['proto']}"
|
||||
print(f"nft add set inet evofw {setname} '{{ type ipv4_addr; flags interval; }}'")
|
||||
chunk = []
|
||||
for c in p["cidrs"]:
|
||||
chunk.append(c)
|
||||
if len(chunk) >= 32:
|
||||
print(f"nft add element inet evofw {setname} '{{ {', '.join(chunk)} }}'")
|
||||
chunk = []
|
||||
if chunk:
|
||||
print(f"nft add element inet evofw {setname} '{{ {', '.join(chunk)} }}'")
|
||||
|
||||
def chain_match(chain):
|
||||
if chain == "prerouting":
|
||||
return "fib daddr type local "
|
||||
if chain == "forward":
|
||||
return "ct status dnat "
|
||||
return ""
|
||||
|
||||
def emit_rule(p, chain):
|
||||
verdict = "drop" if p["action"] == "close" else "accept"
|
||||
comment = f"evofw-port-{p['rid']}"
|
||||
proto, dport = p["proto"], p["dport"]
|
||||
prefix = chain_match(chain)
|
||||
if p["is_all"]:
|
||||
print(
|
||||
f'nft add rule inet evofw {chain} {prefix}{proto} dport {dport} counter {verdict} comment "{comment}"'
|
||||
)
|
||||
return
|
||||
setname = f"port_src_{p['rid']}_{p['proto']}"
|
||||
print(
|
||||
f'nft add rule inet evofw {chain} {prefix}ip saddr @{setname} {proto} dport {dport} counter {verdict} comment "{comment}"'
|
||||
)
|
||||
|
||||
parsed = [p for p in (parse_rule(r) for r in rules) if p]
|
||||
closes = [p for p in parsed if p["action"] == "close"]
|
||||
opens = [p for p in parsed if p["action"] != "close"]
|
||||
seen_sets = set()
|
||||
for p in closes + opens:
|
||||
key = (p["rid"], p["proto"])
|
||||
if key in seen_sets:
|
||||
continue
|
||||
seen_sets.add(key)
|
||||
emit_set(p)
|
||||
for chain in CHAINS:
|
||||
for p in closes:
|
||||
emit_rule(p, chain)
|
||||
for p in opens:
|
||||
emit_rule(p, chain)
|
||||
seen = set()
|
||||
for p in opens:
|
||||
key = (p["proto"], p["dport"])
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
comment = f"evofw-port-implicit-{p['proto']}-{p['dport']}"
|
||||
prefix = chain_match(chain)
|
||||
print(
|
||||
f'nft add rule inet evofw {chain} {prefix}{p["proto"]} dport {p["dport"]} counter drop comment "{comment}"'
|
||||
)
|
||||
PY
|
||||
local cmd
|
||||
while IFS= read -r cmd; do
|
||||
[[ -n "$cmd" ]] || continue
|
||||
# shellcheck disable=SC2086
|
||||
eval "$cmd" 2>>"$LOG_FILE" || log "nft port ACL cmd failed: $cmd"
|
||||
done <"$cmds_file"
|
||||
rm -f "$cmds_file"
|
||||
}
|
||||
|
||||
# Collect observed host firewall rules + listeners (best-effort).
|
||||
collect_host_firewall() {
|
||||
printf '%s' '{"rules":[],"listeners":[]}' >"$HOST_FW_FILE"
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
log "host_firewall: python3 missing — empty snapshot"
|
||||
return 0
|
||||
fi
|
||||
local tmpdir nft_f ipt_f ufw_f fwd_f ss_f out_f
|
||||
tmpdir=$(mktemp -d "${STATE_DIR}/hostfw.XXXXXX") || return 0
|
||||
nft_f="$tmpdir/nft.txt"
|
||||
ipt_f="$tmpdir/ipt.txt"
|
||||
ufw_f="$tmpdir/ufw.txt"
|
||||
fwd_f="$tmpdir/fwd.txt"
|
||||
ss_f="$tmpdir/ss.txt"
|
||||
out_f="$tmpdir/out.json"
|
||||
# Dump to files — env vars blow ARG_MAX on large nft rulesets.
|
||||
nft list ruleset >"$nft_f" 2>/dev/null || true
|
||||
iptables-save >"$ipt_f" 2>/dev/null || true
|
||||
if command -v ufw >/dev/null 2>&1; then
|
||||
ufw status verbose >"$ufw_f" 2>/dev/null || true
|
||||
else
|
||||
: >"$ufw_f"
|
||||
fi
|
||||
if command -v firewall-cmd >/dev/null 2>&1; then
|
||||
firewall-cmd --list-all >"$fwd_f" 2>/dev/null || true
|
||||
else
|
||||
: >"$fwd_f"
|
||||
fi
|
||||
ss -lntu >"$ss_f" 2>/dev/null || true
|
||||
|
||||
if NFT_F="$nft_f" IPT_F="$ipt_f" UFW_F="$ufw_f" FWD_F="$fwd_f" SS_F="$ss_f" OUT_F="$out_f" python3 - <<'PY' >/tmp/evofw-hostfw-counts.txt 2>>"$LOG_FILE"
|
||||
import json, os, re
|
||||
|
||||
def read(path: str) -> str:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||
return f.read()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
def ownership_of(text: str) -> str:
|
||||
t = text.lower()
|
||||
if "evofw" in t:
|
||||
return "evofw"
|
||||
return "foreign"
|
||||
|
||||
def rule(**kwargs):
|
||||
# Omit null/empty optional fields — Zod optional rejects JSON null.
|
||||
out = {
|
||||
"ownership": kwargs["ownership"],
|
||||
"backend": kwargs["backend"],
|
||||
"raw": kwargs["raw"][:512],
|
||||
}
|
||||
for k in ("table", "chain", "action", "protocol", "dport", "sport", "saddr", "daddr", "comment"):
|
||||
v = kwargs.get(k)
|
||||
if v is not None and v != "":
|
||||
out[k] = v if not isinstance(v, str) else v[:128 if k in ("table", "chain", "saddr", "daddr") else (64 if k in ("action", "dport", "sport", "protocol") else 256)]
|
||||
return out
|
||||
|
||||
rules = []
|
||||
listeners = []
|
||||
|
||||
nft = read(os.environ["NFT_F"])
|
||||
cur_table = ""
|
||||
cur_chain = ""
|
||||
for line in nft.splitlines():
|
||||
ls = line.strip()
|
||||
if ls.startswith("table "):
|
||||
cur_table = ls
|
||||
cur_chain = ""
|
||||
continue
|
||||
m = re.match(r"chain\s+(\S+)", ls)
|
||||
if m:
|
||||
cur_chain = m.group(1)
|
||||
continue
|
||||
if not ls or ls.startswith("type ") or ls.startswith("policy ") or ls.startswith("set ") or ls.startswith("map "):
|
||||
continue
|
||||
if "accept" in ls or "drop" in ls or "reject" in ls or "jump " in ls or "goto " in ls:
|
||||
act = "accept" if " accept" in f" {ls}" or ls.endswith("accept") else (
|
||||
"drop" if " drop" in f" {ls}" or ls.endswith("drop") else (
|
||||
"reject" if "reject" in ls else "other"
|
||||
)
|
||||
)
|
||||
proto = ""
|
||||
if " tcp " in f" {ls}" or ls.startswith("tcp "):
|
||||
proto = "tcp"
|
||||
elif " udp " in f" {ls}" or ls.startswith("udp "):
|
||||
proto = "udp"
|
||||
dport = ""
|
||||
m = re.search(r"dport\s+(\S+)", ls)
|
||||
if m:
|
||||
dport = m.group(1)
|
||||
saddr = ""
|
||||
m = re.search(r"saddr\s+(\S+)", ls)
|
||||
if m:
|
||||
saddr = m.group(1).lstrip("@")
|
||||
rules.append(rule(
|
||||
ownership=ownership_of(cur_table + " " + cur_chain + " " + ls),
|
||||
backend="nft",
|
||||
table=cur_table[:120],
|
||||
chain=cur_chain[:120],
|
||||
action=act,
|
||||
protocol=proto or None,
|
||||
dport=dport or None,
|
||||
saddr=saddr or None,
|
||||
raw=ls[:500],
|
||||
))
|
||||
|
||||
ipt = read(os.environ["IPT_F"])
|
||||
cur_chain = ""
|
||||
for line in ipt.splitlines():
|
||||
if line.startswith(":"):
|
||||
cur_chain = line[1:].split()[0] if line[1:] else ""
|
||||
continue
|
||||
if not line.startswith("-A "):
|
||||
continue
|
||||
parts = line.split(None, 2)
|
||||
chain = parts[1] if len(parts) > 1 else ""
|
||||
act = "DROP" if " -j DROP" in line else (
|
||||
"ACCEPT" if " -j ACCEPT" in line else (
|
||||
"REJECT" if " -j REJECT" in line else "other"
|
||||
)
|
||||
)
|
||||
proto = ""
|
||||
m = re.search(r"-p\s+(\w+)", line)
|
||||
if m:
|
||||
proto = m.group(1)
|
||||
dport = ""
|
||||
m = re.search(r"--dport(?:s)?\s+(\S+)", line)
|
||||
if m:
|
||||
dport = m.group(1)
|
||||
saddr = ""
|
||||
m = re.search(r"-s\s+(\S+)", line)
|
||||
if m:
|
||||
saddr = m.group(1)
|
||||
rules.append(rule(
|
||||
ownership=ownership_of(line),
|
||||
backend="iptables",
|
||||
chain=chain[:120],
|
||||
action=act.lower() if isinstance(act, str) else act,
|
||||
protocol=proto or None,
|
||||
dport=dport or None,
|
||||
saddr=saddr or None,
|
||||
raw=line[:500],
|
||||
))
|
||||
|
||||
ufw = read(os.environ["UFW_F"])
|
||||
for line in ufw.splitlines():
|
||||
ls = line.strip()
|
||||
if not ls or ls.startswith("Status") or ls.startswith("Logging") or ls.startswith("Default") or ls.startswith("To") or ls.startswith("--"):
|
||||
continue
|
||||
if "ALLOW" in ls or "DENY" in ls or "REJECT" in ls:
|
||||
rules.append(rule(
|
||||
ownership=ownership_of(ls),
|
||||
backend="ufw",
|
||||
action="allow" if "ALLOW" in ls else ("deny" if "DENY" in ls else "reject"),
|
||||
raw=ls[:500],
|
||||
))
|
||||
|
||||
fwd = read(os.environ["FWD_F"])
|
||||
for line in fwd.splitlines():
|
||||
ls = line.strip()
|
||||
if not ls:
|
||||
continue
|
||||
if ls.startswith("ports:") or ls.startswith("services:") or ":" in ls:
|
||||
rules.append(rule(
|
||||
ownership="foreign",
|
||||
backend="firewalld",
|
||||
raw=ls[:500],
|
||||
))
|
||||
|
||||
ss = read(os.environ["SS_F"])
|
||||
for line in ss.splitlines()[1:]:
|
||||
parts = line.split()
|
||||
if len(parts) < 5:
|
||||
continue
|
||||
proto = parts[0]
|
||||
local = parts[4]
|
||||
m = re.search(r"([^:]+):(\d+)$", local)
|
||||
if not m:
|
||||
m = re.search(r"\[([^\]]+)\]:(\d+)$", local)
|
||||
if not m:
|
||||
continue
|
||||
addr, port_s = m.group(1), m.group(2)
|
||||
else:
|
||||
addr, port_s = m.group(1), m.group(2)
|
||||
try:
|
||||
port = int(port_s)
|
||||
except ValueError:
|
||||
continue
|
||||
listeners.append({
|
||||
"protocol": "tcp" if proto.startswith("tcp") else ("udp" if proto.startswith("udp") else proto),
|
||||
"port": port,
|
||||
"address": addr,
|
||||
})
|
||||
|
||||
rules = rules[:500]
|
||||
listeners = listeners[:200]
|
||||
with open(os.environ["OUT_F"], "w", encoding="utf-8") as f:
|
||||
json.dump({"rules": rules, "listeners": listeners}, f, separators=(",", ":"))
|
||||
print(f"{len(rules)} {len(listeners)}")
|
||||
PY
|
||||
then
|
||||
if [[ -f "$out_f" ]]; then
|
||||
cp "$out_f" "$HOST_FW_FILE"
|
||||
log "host_firewall collected $(tr -d '\r\n' </tmp/evofw-hostfw-counts.txt 2>/dev/null || echo '?')"
|
||||
fi
|
||||
else
|
||||
log "host_firewall: collect failed — empty snapshot"
|
||||
printf '%s' '{"rules":[],"listeners":[]}' >"$HOST_FW_FILE"
|
||||
fi
|
||||
rm -rf "$tmpdir" 2>/dev/null || true
|
||||
}
|
||||
|
||||
ensure_ipset_counters() {
|
||||
local name=$1
|
||||
if ! ipset list "$name" >/dev/null 2>&1; then
|
||||
ipset create "$name" hash:net family inet counters
|
||||
return
|
||||
if ipset create "$name" hash:net family inet counters 2>>"$LOG_FILE"; then
|
||||
return 0
|
||||
fi
|
||||
ipset create "$name" hash:net family inet 2>>"$LOG_FILE" || {
|
||||
log "ipset: failed to create $name"
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
fi
|
||||
# Recreate once if set has no packet counters (Header lacks "counters").
|
||||
local header
|
||||
header=$(ipset list "$name" 2>/dev/null | head -n 5 || true)
|
||||
if [[ "$header" != *"counters"* && "$header" != *"packet"* ]]; then
|
||||
ipset destroy "$name" 2>>"$LOG_FILE" || true
|
||||
ipset create "$name" hash:net family inet counters
|
||||
if [[ "$header" == *"counters"* ]]; then
|
||||
return 0
|
||||
fi
|
||||
# Cannot safely destroy while iptables may reference the set — leave as-is.
|
||||
log "ipset: $name has no counters (leave existing; per-IP hits unavailable)"
|
||||
}
|
||||
|
||||
apply_ipset() {
|
||||
@@ -298,13 +887,76 @@ send_report() {
|
||||
if [[ -z "${IP_HITS_CAPTURED:-}" ]]; then
|
||||
collect_ip_hits
|
||||
fi
|
||||
local report
|
||||
report=$(printf '{"status":"ok","prefix_count":%s,"packets_dropped":%s,"packets_accepted":%s,"kernel_method":"%s","source":"agent","ip_hits":%s}' \
|
||||
"${APPLIED:-0}" "${PACKETS_DROPPED:-0}" "${PACKETS_ACCEPTED:-0}" "${KERNEL_METHOD:-$BACKEND}" "${IP_HITS_JSON:-[]}")
|
||||
curl -fsS -X POST "${EVOFW_CP_URL%/}/v1/agent/apply-report" \
|
||||
if [[ -z "${PORT_HITS_CAPTURED:-}" ]]; then
|
||||
collect_port_hits
|
||||
fi
|
||||
if [[ -z "${HOST_FW_CAPTURED:-}" ]]; then
|
||||
collect_host_firewall
|
||||
fi
|
||||
local report_file http_code counts n_rules n_listen n_ip n_port
|
||||
report_file=$(mktemp "${STATE_DIR}/report.XXXXXX")
|
||||
# Compose report via files only — never bash ${var:-{...}} (} closes expansion).
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
[[ -f "$HOST_FW_FILE" ]] || printf '%s' '{"rules":[],"listeners":[]}' >"$HOST_FW_FILE"
|
||||
[[ -f "$IP_HITS_FILE" ]] || printf '%s' '[]' >"$IP_HITS_FILE"
|
||||
[[ -f "$PORT_HITS_FILE" ]] || printf '%s' '[]' >"$PORT_HITS_FILE"
|
||||
counts=$(APPLIED="${APPLIED:-0}" DROPPED="${PACKETS_DROPPED:-0}" ACCEPTED="${PACKETS_ACCEPTED:-0}" \
|
||||
METHOD="${KERNEL_METHOD:-$BACKEND}" HOST_FW_F="$HOST_FW_FILE" IP_HITS_F="$IP_HITS_FILE" \
|
||||
PORT_HITS_F="$PORT_HITS_FILE" OUT_F="$report_file" python3 - <<'PY' 2>>"$LOG_FILE"
|
||||
import json, os
|
||||
|
||||
def load(path, default):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
print(f"load_error {path}: {e}", file=__import__("sys").stderr)
|
||||
return default
|
||||
|
||||
payload = {
|
||||
"status": "ok",
|
||||
"prefix_count": int(os.environ.get("APPLIED") or 0),
|
||||
"packets_dropped": int(os.environ.get("DROPPED") or 0),
|
||||
"packets_accepted": int(os.environ.get("ACCEPTED") or 0),
|
||||
"kernel_method": os.environ.get("METHOD") or "auto",
|
||||
"source": "agent",
|
||||
"ip_hits": load(os.environ["IP_HITS_F"], []),
|
||||
"port_hits": load(os.environ["PORT_HITS_F"], []),
|
||||
"host_firewall": load(os.environ["HOST_FW_F"], {"rules": [], "listeners": []}),
|
||||
}
|
||||
with open(os.environ["OUT_F"], "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, separators=(",", ":"))
|
||||
n_rules = len(payload["host_firewall"].get("rules") or [])
|
||||
n_listen = len(payload["host_firewall"].get("listeners") or [])
|
||||
n_ip = len(payload["ip_hits"] or [])
|
||||
n_port = len(payload["port_hits"] or [])
|
||||
print(f"{n_rules} {n_listen} {n_ip} {n_port}")
|
||||
PY
|
||||
) || counts="0 0 0 0"
|
||||
# shellcheck disable=SC2086
|
||||
set -- $counts
|
||||
n_rules=${1:-0}
|
||||
n_listen=${2:-0}
|
||||
n_ip=${3:-0}
|
||||
n_port=${4:-0}
|
||||
else
|
||||
printf '{"status":"ok","prefix_count":%s,"packets_dropped":%s,"packets_accepted":%s,"kernel_method":"%s","source":"agent","ip_hits":[],"port_hits":[]}' \
|
||||
"${APPLIED:-0}" "${PACKETS_DROPPED:-0}" "${PACKETS_ACCEPTED:-0}" "${KERNEL_METHOD:-$BACKEND}" \
|
||||
>"$report_file"
|
||||
n_rules=0
|
||||
n_listen=0
|
||||
n_ip=0
|
||||
n_port=0
|
||||
fi
|
||||
http_code=$(curl -sS -o /tmp/evofw-apply-report.out -w '%{http_code}' -X POST "${EVOFW_CP_URL%/}/v1/agent/apply-report" \
|
||||
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$report" >/dev/null 2>&1 || true
|
||||
--data-binary @"$report_file" 2>/dev/null || echo "000")
|
||||
log "apply-report http=${http_code} host_fw=${n_rules}/${n_listen} ip_hits=${n_ip} port_hits=${n_port}"
|
||||
if [[ "$http_code" != "200" ]]; then
|
||||
log "apply-report failed body=$(head -c 200 /tmp/evofw-apply-report.out 2>/dev/null || true)"
|
||||
fi
|
||||
rm -f "$report_file" 2>/dev/null || true
|
||||
curl -fsS -X POST "${EVOFW_CP_URL%/}/v1/agent/heartbeat" \
|
||||
-H "Authorization: Bearer ${CLIENT_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
@@ -324,12 +976,14 @@ if [[ -f "$HASH_FILE" && "$(tr -d '\r\n' <"$HASH_FILE")" == "$HASH" && -n "$HASH
|
||||
if [[ "$KERNEL_METHOD" == "nft" ]]; then
|
||||
APPLIED=$(nft list set inet evofw deny_v4 2>/dev/null | grep -cE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' || true)
|
||||
local_allow=$(nft list set inet evofw allow_v4 2>/dev/null | grep -cE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' || true)
|
||||
APPLIED=$((APPLIED + local_allow))
|
||||
APPLIED=$((${APPLIED:-0} + ${local_allow:-0}))
|
||||
elif [[ "$KERNEL_METHOD" == "ipset" ]]; then
|
||||
APPLIED=$(ipset list evofw_deny_v4 2>/dev/null | awk '/^[0-9]/{c++} END{print c+0}')
|
||||
local_allow=$(ipset list evofw_allow_v4 2>/dev/null | awk '/^[0-9]/{c++} END{print c+0}')
|
||||
APPLIED=$((APPLIED + local_allow))
|
||||
APPLIED=$((${APPLIED:-0} + ${local_allow:-0}))
|
||||
fi
|
||||
collect_host_firewall
|
||||
HOST_FW_CAPTURED=1
|
||||
send_report
|
||||
exit 0
|
||||
fi
|
||||
@@ -338,12 +992,18 @@ fi
|
||||
if command -v nft >/dev/null 2>&1 && nft list table inet evofw >/dev/null 2>&1; then
|
||||
collect_nft_stats
|
||||
collect_nft_ip_hits
|
||||
collect_nft_port_hits
|
||||
STATS_CAPTURED=1
|
||||
IP_HITS_CAPTURED=1
|
||||
PORT_HITS_CAPTURED=1
|
||||
elif command -v ipset >/dev/null 2>&1 && ipset list evofw_deny_v4 >/dev/null 2>&1; then
|
||||
collect_ipset_ip_hits
|
||||
IP_HITS_CAPTURED=1
|
||||
printf '%s' '[]' >"$PORT_HITS_FILE"
|
||||
PORT_HITS_CAPTURED=1
|
||||
fi
|
||||
collect_host_firewall
|
||||
HOST_FW_CAPTURED=1
|
||||
|
||||
case "$BACKEND" in
|
||||
nft|auto)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# EvoFirewall Linux install one-liner
|
||||
# Re-run on an already-installed host updates scripts/timer and keeps credentials
|
||||
# Re-run on an already-installed host updates scripts/timer **and forces nft
|
||||
# rule re-apply** (clears last_hash), keeping credentials
|
||||
# (unless EVOFW_INSTALL_FORCE=1 → full re-enroll).
|
||||
set -euo pipefail
|
||||
|
||||
@@ -250,8 +251,10 @@ if [[ -f "$CONF_FILE" && "${EVOFW_INSTALL_FORCE:-}" != "1" ]]; then
|
||||
download_sync_script "$SYNC_TMP" || exit 1
|
||||
write_conf "$CLIENT_ID" "$CLIENT_TOKEN" "$CLIENT_NAME" "$BACKEND"
|
||||
install_sync_and_uninstall "$SYNC_TMP"
|
||||
# Force one apply after script refresh (nft set upgrade, counters, port hits, etc.).
|
||||
rm -f /var/lib/evofw/last_hash
|
||||
enable_scheduler_and_run
|
||||
echo "Updated. Client id=${CLIENT_ID}. Sync script + timer refreshed."
|
||||
echo "Updated. Client id=${CLIENT_ID}. Sync script + timer refreshed; nft rules re-applied."
|
||||
echo "Force sync: $SYNC_SCRIPT"
|
||||
echo "Uninstall: $UNINSTALL_SCRIPT (or: curl -fsSL ${CP_URL}/v1/agent/uninstall.sh | bash)"
|
||||
exit 0
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
:set token ("evofw_" . [:tostr [/system clock get time]] . [:tostr [/system resource get cpu-load]] . [:tostr [/system resource get free-memory]] . [:tostr [:rndnum from=100000 to=999999]])
|
||||
}
|
||||
|
||||
:local body ("{\"name\":\"" . $EvofwName . "\",\"hostname\":\"" . [/system identity get name] . "\",\"platform\":\"mikrotik\",\"token\":\"" . $token . "\",\"client_version\":\"rsc/4\"")
|
||||
:local body ("{\"name\":\"" . $EvofwName . "\",\"hostname\":\"" . [/system identity get name] . "\",\"platform\":\"mikrotik\",\"token\":\"" . $token . "\",\"client_version\":\"rsc/6\"")
|
||||
:if ([:typeof $EvofwInstallLinkId] != "nothing" && [:len $EvofwInstallLinkId] > 0) do={
|
||||
:set body ($body . ",\"install_link_id\":\"" . $EvofwInstallLinkId . "\"")
|
||||
}
|
||||
@@ -67,12 +67,38 @@
|
||||
|
||||
:set EvofwLastHash ""
|
||||
|
||||
# Filter rules (idempotent by comment)
|
||||
# Filter rules (idempotent by comment) — always at the top of each chain.
|
||||
# Deny path: hit (add-src → EVOFW_HITS) then drop. Allow/default unchanged.
|
||||
:do { /ip firewall filter remove [find comment~"^evofw-"] } on-error={}
|
||||
/ip firewall filter add chain=input action=drop src-address-list=EVOFW_DENY comment=evofw-deny-drop-input disabled=no
|
||||
/ip firewall filter add chain=forward action=drop src-address-list=EVOFW_DENY comment=evofw-deny-drop-forward disabled=no
|
||||
/ip firewall filter add chain=forward action=accept src-address-list=EVOFW_ALLOW comment=evofw-allow-accept-forward disabled=no
|
||||
/ip firewall filter add chain=forward action=drop comment=evofw-default-drop-forward disabled=yes
|
||||
|
||||
:local inputFirst [/ip firewall filter find where chain=input]
|
||||
:local inputPb ""
|
||||
:if ([:len $inputFirst] > 0) do={ :set inputPb ($inputFirst->0) }
|
||||
|
||||
:local forwardFirst [/ip firewall filter find where chain=forward]
|
||||
:local forwardPb ""
|
||||
:if ([:len $forwardFirst] > 0) do={ :set forwardPb ($forwardFirst->0) }
|
||||
|
||||
# place-before same anchor repeatedly → earlier adds stay above later ones.
|
||||
:if ([:len $inputPb] > 0) do={
|
||||
/ip firewall filter add chain=input action=add-src-to-address-list address-list=EVOFW_HITS address-list-timeout=1h src-address-list=EVOFW_DENY comment=evofw-deny-hit-input disabled=no place-before=$inputPb
|
||||
/ip firewall filter add chain=input action=drop src-address-list=EVOFW_DENY comment=evofw-deny-drop-input disabled=no place-before=$inputPb
|
||||
} else={
|
||||
/ip firewall filter add chain=input action=add-src-to-address-list address-list=EVOFW_HITS address-list-timeout=1h src-address-list=EVOFW_DENY comment=evofw-deny-hit-input disabled=no
|
||||
/ip firewall filter add chain=input action=drop src-address-list=EVOFW_DENY comment=evofw-deny-drop-input disabled=no
|
||||
}
|
||||
|
||||
:if ([:len $forwardPb] > 0) do={
|
||||
/ip firewall filter add chain=forward action=add-src-to-address-list address-list=EVOFW_HITS address-list-timeout=1h src-address-list=EVOFW_DENY comment=evofw-deny-hit-forward disabled=no place-before=$forwardPb
|
||||
/ip firewall filter add chain=forward action=drop src-address-list=EVOFW_DENY comment=evofw-deny-drop-forward disabled=no place-before=$forwardPb
|
||||
/ip firewall filter add chain=forward action=accept src-address-list=EVOFW_ALLOW comment=evofw-allow-accept-forward disabled=no place-before=$forwardPb
|
||||
/ip firewall filter add chain=forward action=drop comment=evofw-default-drop-forward disabled=yes place-before=$forwardPb
|
||||
} else={
|
||||
/ip firewall filter add chain=forward action=add-src-to-address-list address-list=EVOFW_HITS address-list-timeout=1h src-address-list=EVOFW_DENY comment=evofw-deny-hit-forward disabled=no
|
||||
/ip firewall filter add chain=forward action=drop src-address-list=EVOFW_DENY comment=evofw-deny-drop-forward disabled=no
|
||||
/ip firewall filter add chain=forward action=accept src-address-list=EVOFW_ALLOW comment=evofw-allow-accept-forward disabled=no
|
||||
/ip firewall filter add chain=forward action=drop comment=evofw-default-drop-forward disabled=yes
|
||||
}
|
||||
|
||||
# Build sync body as a template script, then prepend baked credentials → one script
|
||||
:do { /system script remove [find name="evofw-sync-body"] } on-error={}
|
||||
@@ -114,7 +140,9 @@
|
||||
:do { /ip firewall address-list add list=EVOFW_ALLOW address=$a comment=evofw } on-error={}
|
||||
}
|
||||
:local da ($p->"default_action")
|
||||
:do { /ip firewall filter set [find comment=evofw-deny-hit-input] disabled=no } on-error={}
|
||||
:do { /ip firewall filter set [find comment=evofw-deny-drop-input] disabled=no } on-error={}
|
||||
:do { /ip firewall filter set [find comment=evofw-deny-hit-forward] disabled=no } on-error={}
|
||||
:do { /ip firewall filter set [find comment=evofw-deny-drop-forward] disabled=no } on-error={}
|
||||
:do { /ip firewall filter set [find comment=evofw-allow-accept-forward] disabled=no } on-error={}
|
||||
:if ($da = "drop") do={
|
||||
@@ -134,11 +162,11 @@
|
||||
:local denyCnt [:len [/ip firewall address-list find list=EVOFW_DENY]]
|
||||
:local allowCnt [:len [/ip firewall address-list find list=EVOFW_ALLOW]]
|
||||
:local cnt ($denyCnt + $allowCnt)
|
||||
# Cumulative counters from permanent filter rules (survive address-list rebuild).
|
||||
# Cumulative counters from permanent drop/accept rules (not hit passthrough).
|
||||
:local dropped 0
|
||||
:local accepted 0
|
||||
:do {
|
||||
:foreach i in=[/ip firewall filter find where comment~"^evofw-deny-"] do={
|
||||
:foreach i in=[/ip firewall filter find where comment~"^evofw-deny-drop-"] do={
|
||||
:set dropped ($dropped + [/ip firewall filter get $i packets])
|
||||
}
|
||||
} on-error={}
|
||||
@@ -152,14 +180,30 @@
|
||||
:set accepted ($accepted + [/ip firewall filter get $i packets])
|
||||
}
|
||||
} on-error={}
|
||||
:local report ("{\"status\":\"ok\",\"prefix_count\":" . $cnt . ",\"packets_dropped\":" . $dropped . ",\"packets_accepted\":" . $accepted . ",\"kernel_method\":\"address-list\",\"source\":\"mikrotik\"}")
|
||||
# EVOFW_HITS: real src /32 that matched deny (timeout 1h). Do not wipe on policy rebuild.
|
||||
:local hits "["
|
||||
:local hitN 0
|
||||
:do {
|
||||
:foreach i in=[/ip firewall address-list find list=EVOFW_HITS] do={
|
||||
:if ($hitN < 200) do={
|
||||
:local a [/ip firewall address-list get $i address]
|
||||
:if ([:len $a] > 0) do={
|
||||
:if ($hitN > 0) do={ :set hits ($hits . ",") }
|
||||
:set hits ($hits . "{\"ip\":\"" . $a . "\",\"packets\":1}")
|
||||
:set hitN ($hitN + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
} on-error={}
|
||||
:set hits ($hits . "]")
|
||||
:local report ("{\"status\":\"ok\",\"prefix_count\":" . $cnt . ",\"packets_dropped\":" . $dropped . ",\"packets_accepted\":" . $accepted . ",\"kernel_method\":\"address-list\",\"source\":\"mikrotik\",\"ip_hits\":" . $hits . "}")
|
||||
:do {
|
||||
/tool fetch url=($EvofwCpUrl . "/v1/agent/apply-report") http-method=post http-header-field=("Authorization: Bearer " . $EvofwToken . ",Content-Type: application/json") http-data=$report keep-result=no
|
||||
} on-error={}
|
||||
:do {
|
||||
/tool fetch url=($EvofwCpUrl . "/v1/agent/heartbeat") http-method=post http-header-field=("Authorization: Bearer " . $EvofwToken . ",Content-Type: application/json") http-data="{\"source\":\"mikrotik\"}" keep-result=no
|
||||
} on-error={}
|
||||
:log info ("evofw: sync done deny=" . $denyCnt . " allow=" . $allowCnt . " dropPkts=" . $dropped . " acceptPkts=" . $accepted)
|
||||
:log info ("evofw: sync done deny=" . $denyCnt . " allow=" . $allowCnt . " hits=" . $hitN . " dropPkts=" . $dropped . " acceptPkts=" . $accepted)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { join } from 'node:path'
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { repos } from '@evofw/db'
|
||||
import { enrollBodySchema, applyReportBodySchema } from '@evofw/shared'
|
||||
import { enrollBodySchema, applyReportBodySchema, hostFwRuleSchema, hostListenerSchema } from '@evofw/shared'
|
||||
import type { AppConfig } from '../config.js'
|
||||
import { hashToken } from '../plugins/auth.js'
|
||||
import { evaluateAgentPolicy } from '../services/policy/evaluate.js'
|
||||
@@ -10,9 +11,33 @@ import { renderMikrotikPolicyRsc } from '../services/policy/mikrotik-rsc.js'
|
||||
import { AppError } from '../plugins/error-handler.js'
|
||||
import { resolveAgentScriptsDir } from '../services/agent-scripts-path.js'
|
||||
import { resolveAndRenderInstall } from '../services/install-links.js'
|
||||
import {
|
||||
getSyncScriptMeta,
|
||||
ifNoneMatchHits,
|
||||
} from '../services/sync-script.js'
|
||||
|
||||
const scriptsDir = resolveAgentScriptsDir()
|
||||
|
||||
function sanitizeHostFirewall(raw: {
|
||||
rules?: unknown[]
|
||||
listeners?: unknown[]
|
||||
}): { rules: unknown[]; listeners: unknown[] } {
|
||||
const rules: unknown[] = []
|
||||
for (const item of raw.rules ?? []) {
|
||||
const parsed = hostFwRuleSchema.safeParse(item)
|
||||
if (parsed.success) rules.push(parsed.data)
|
||||
}
|
||||
const listeners: unknown[] = []
|
||||
for (const item of raw.listeners ?? []) {
|
||||
const parsed = hostListenerSchema.safeParse(item)
|
||||
if (parsed.success) listeners.push(parsed.data)
|
||||
}
|
||||
return {
|
||||
rules: rules.slice(0, 500),
|
||||
listeners: listeners.slice(0, 200),
|
||||
}
|
||||
}
|
||||
|
||||
export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
app,
|
||||
opts,
|
||||
@@ -39,9 +64,15 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
},
|
||||
)
|
||||
|
||||
app.get('/v1/agent/sync-script', async (_req, reply) => {
|
||||
const body = readFileSync(join(scriptsDir, 'evofw-firewall.sh'), 'utf-8')
|
||||
return reply.type('text/x-shellscript').send(body)
|
||||
app.get('/v1/agent/sync-script', async (req, reply) => {
|
||||
const meta = getSyncScriptMeta()
|
||||
reply.header('ETag', meta.etag)
|
||||
reply.header('X-Evofw-Script-Sha256', meta.sha256)
|
||||
const inm = req.headers['if-none-match']
|
||||
if (ifNoneMatchHits(inm, meta.etag)) {
|
||||
return reply.code(304).send()
|
||||
}
|
||||
return reply.type('text/x-shellscript').send(meta.body)
|
||||
})
|
||||
|
||||
app.get('/v1/agent/uninstall.sh', async (_req, reply) => {
|
||||
@@ -146,14 +177,24 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
lastSeenIp: req.ip,
|
||||
})
|
||||
const policy = evaluateAgentPolicy(app.db, agentId)
|
||||
const scriptSha = getSyncScriptMeta().sha256
|
||||
return {
|
||||
generation: policy.generation,
|
||||
hash: policy.hash,
|
||||
apply_version: policy.applyVersion,
|
||||
default_action: policy.defaultAction,
|
||||
policy_mode: policy.policyMode,
|
||||
script_sha256: scriptSha,
|
||||
deny_cidrs: policy.denyCidrs,
|
||||
allow_cidrs: policy.allowCidrs,
|
||||
port_rules: policy.portRules.map((r) => ({
|
||||
id: r.id,
|
||||
action: r.action,
|
||||
protocol: r.protocol,
|
||||
port_start: r.portStart,
|
||||
port_end: r.portEnd,
|
||||
src_cidrs: r.srcCidrs,
|
||||
})),
|
||||
sync_interval_sec: policy.syncIntervalSec,
|
||||
// compat: prefixes = deny when default accept, else allow (legacy single-bag clients)
|
||||
prefixes:
|
||||
@@ -199,6 +240,13 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
const totalDropped = (prev?.totalPacketsDropped ?? 0) + deltaDropped
|
||||
const totalAccepted = (prev?.totalPacketsAccepted ?? 0) + deltaAccepted
|
||||
|
||||
// Chain/set counters were flushed (policy apply). Zero per-IP baselines so
|
||||
// the next epoch of element counters accumulates (zeros are omitted from ip_hits).
|
||||
// Port hits use a separate dynamic set that is not flushed on apply — leave baselines.
|
||||
if (reportedDropped < prevDropped) {
|
||||
repos.resetIpBlockStatsBaselines(app.db, agentId)
|
||||
}
|
||||
|
||||
repos.updateAgent(app.db, agentId, {
|
||||
lastApplyAt: now,
|
||||
lastApplyStatus: body.status,
|
||||
@@ -222,7 +270,41 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
recordedAt: now,
|
||||
})
|
||||
if (body.ip_hits?.length) {
|
||||
repos.upsertIpBlockStats(app.db, agentId, body.ip_hits, now)
|
||||
const presence = body.source === 'mikrotik'
|
||||
repos.upsertIpBlockStats(app.db, agentId, body.ip_hits, now, {
|
||||
mode: presence ? 'presence' : 'absolute',
|
||||
})
|
||||
}
|
||||
if (body.port_hits?.length && body.source !== 'mikrotik') {
|
||||
repos.upsertPortBlockStats(app.db, agentId, body.port_hits, now)
|
||||
}
|
||||
if (body.host_firewall && body.source !== 'mikrotik') {
|
||||
const sanitized = sanitizeHostFirewall(body.host_firewall)
|
||||
// Empty snapshot must not wipe a previous good collect (agent bash bugs / race).
|
||||
if (
|
||||
sanitized.rules.length === 0 &&
|
||||
sanitized.listeners.length === 0
|
||||
) {
|
||||
const existing = repos.getHostFirewallSnapshot(app.db, agentId)
|
||||
if (existing) {
|
||||
return { ok: true }
|
||||
}
|
||||
}
|
||||
const payloadJson = JSON.stringify({
|
||||
rules: sanitized.rules,
|
||||
listeners: sanitized.listeners,
|
||||
})
|
||||
const rawDigest = createHash('sha256')
|
||||
.update(payloadJson)
|
||||
.digest('hex')
|
||||
.slice(0, 16)
|
||||
repos.upsertHostFirewallSnapshot(
|
||||
app.db,
|
||||
agentId,
|
||||
payloadJson,
|
||||
now,
|
||||
rawDigest,
|
||||
)
|
||||
}
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
@@ -73,6 +73,7 @@ export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
cidrs_allow: policy.summary.cidrsAllow,
|
||||
overrides: policy.summary.overrides,
|
||||
conflicts_dropped: policy.summary.conflictsDropped,
|
||||
port_rules: policy.summary.portRules,
|
||||
},
|
||||
chain: policy.chain.map((s) => ({
|
||||
set_id: s.setId,
|
||||
@@ -87,6 +88,14 @@ export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
allow_cidrs: truncateCidrs(policy.allowCidrs, limit),
|
||||
deny_cidrs_total: policy.denyCidrs.length,
|
||||
allow_cidrs_total: policy.allowCidrs.length,
|
||||
port_rules: policy.portRules.map((r) => ({
|
||||
id: r.id,
|
||||
action: r.action,
|
||||
protocol: r.protocol,
|
||||
port_start: r.portStart,
|
||||
port_end: r.portEnd,
|
||||
src_cidrs: r.srcCidrs,
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { listsRoutes } from './lists.js'
|
||||
import { policySetsRoutes } from './policy-sets.js'
|
||||
import { rulesRoutes } from './rules.js'
|
||||
import { statsRoutes } from './stats.js'
|
||||
import { portAclRoutes } from './port-acl.js'
|
||||
import { integrationsEvobgpRoutes } from './integrations-evobgp.js'
|
||||
import { settingsRoutes } from './settings.js'
|
||||
|
||||
@@ -23,6 +24,7 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
await app.register(policySetsRoutes, { config })
|
||||
await app.register(rulesRoutes, { config })
|
||||
await app.register(statsRoutes, { config })
|
||||
await app.register(portAclRoutes, { config })
|
||||
await app.register(integrationsEvobgpRoutes)
|
||||
await app.register(settingsRoutes, { config })
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { repos } from '@evofw/db'
|
||||
import {
|
||||
createAgentPortRuleBodySchema,
|
||||
importAgentPortRulesBodySchema,
|
||||
updateAgentPortRuleBodySchema,
|
||||
} from '@evofw/shared'
|
||||
import { AppError } from '../plugins/error-handler.js'
|
||||
import type { AppConfig } from '../config.js'
|
||||
import { auditMutation } from '../services/audit.js'
|
||||
|
||||
function mapPortRule(
|
||||
row: NonNullable<ReturnType<typeof repos.getAgentPortRule>>,
|
||||
listName?: string | null,
|
||||
) {
|
||||
return {
|
||||
id: row.id,
|
||||
agent_id: row.agentId,
|
||||
action: row.action,
|
||||
protocol: row.protocol,
|
||||
port_start: row.portStart,
|
||||
port_end: row.portEnd,
|
||||
src_kind: row.srcKind,
|
||||
src_cidr: row.srcCidr,
|
||||
list_id: row.listId,
|
||||
list_name: listName ?? null,
|
||||
enabled: row.enabled === 1,
|
||||
comment: row.comment,
|
||||
priority: row.priority,
|
||||
created_at: row.createdAt,
|
||||
updated_at: row.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
function validateSrc(
|
||||
srcKind: string,
|
||||
srcCidr: string | null | undefined,
|
||||
listId: string | null | undefined,
|
||||
db: Parameters<typeof repos.getIpList>[0],
|
||||
) {
|
||||
if (srcKind === 'cidr' && !srcCidr?.trim()) {
|
||||
throw new AppError('VALIDATION_ERROR', 'src_cidr required', 400)
|
||||
}
|
||||
if (srcKind === 'list') {
|
||||
if (!listId?.trim()) {
|
||||
throw new AppError('VALIDATION_ERROR', 'list_id required', 400)
|
||||
}
|
||||
if (!repos.getIpList(db, listId)) {
|
||||
throw new AppError('NOT_FOUND', 'IP list not found', 404)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const portAclRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
app,
|
||||
opts,
|
||||
) => {
|
||||
const { config } = opts
|
||||
|
||||
app.get<{ Params: { id: string } }>(
|
||||
'/agents/:id/port-rules',
|
||||
async (req) => {
|
||||
const agent = repos.getAgent(app.db, req.params.id)
|
||||
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||
const items = repos.listAgentPortRules(app.db, agent.id).map((row) => {
|
||||
const listName = row.listId
|
||||
? repos.getIpList(app.db, row.listId)?.name
|
||||
: null
|
||||
return mapPortRule(row, listName)
|
||||
})
|
||||
return { items }
|
||||
},
|
||||
)
|
||||
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/agents/:id/port-rules',
|
||||
async (req) => {
|
||||
const agent = repos.getAgent(app.db, req.params.id)
|
||||
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||
if (agent.platform !== 'linux') {
|
||||
throw new AppError(
|
||||
'VALIDATION_ERROR',
|
||||
'Port ACL is only supported on Linux agents',
|
||||
400,
|
||||
)
|
||||
}
|
||||
const body = createAgentPortRuleBodySchema.parse(req.body)
|
||||
const portEnd = body.port_end ?? body.port_start
|
||||
const srcKind = body.src_kind
|
||||
const srcCidr = srcKind === 'cidr' ? body.src_cidr!.trim() : null
|
||||
const listId = srcKind === 'list' ? body.list_id! : null
|
||||
validateSrc(srcKind, srcCidr, listId, app.db)
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const row = repos.insertAgentPortRule(app.db, {
|
||||
id: crypto.randomUUID(),
|
||||
agentId: agent.id,
|
||||
action: body.action,
|
||||
protocol: body.protocol,
|
||||
portStart: body.port_start,
|
||||
portEnd,
|
||||
srcKind,
|
||||
srcCidr,
|
||||
listId,
|
||||
enabled: body.enabled === false ? 0 : 1,
|
||||
comment: body.comment ?? null,
|
||||
priority: body.priority ?? 100,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
repos.bumpAgentGeneration(app.db, agent.id)
|
||||
auditMutation(app, config, req, {
|
||||
action: 'port_rule.create',
|
||||
targetType: 'app_resource',
|
||||
targetId: row!.id,
|
||||
summary: `Port ACL ${body.action} ${body.protocol}/${body.port_start} для ${agent.name}`,
|
||||
details: {
|
||||
agent_id: agent.id,
|
||||
rule_id: row!.id,
|
||||
action: body.action,
|
||||
protocol: body.protocol,
|
||||
port_start: body.port_start,
|
||||
port_end: portEnd,
|
||||
},
|
||||
})
|
||||
const listName = row!.listId
|
||||
? repos.getIpList(app.db, row!.listId)?.name
|
||||
: null
|
||||
return mapPortRule(row!, listName)
|
||||
},
|
||||
)
|
||||
|
||||
app.patch<{ Params: { id: string; ruleId: string } }>(
|
||||
'/agents/:id/port-rules/:ruleId',
|
||||
async (req) => {
|
||||
const agent = repos.getAgent(app.db, req.params.id)
|
||||
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||
const existing = repos.getAgentPortRule(app.db, req.params.ruleId)
|
||||
if (!existing || existing.agentId !== agent.id) {
|
||||
throw new AppError('NOT_FOUND', 'Port rule not found', 404)
|
||||
}
|
||||
const body = updateAgentPortRuleBodySchema.parse(req.body)
|
||||
const nextSrcKind = body.src_kind ?? existing.srcKind
|
||||
const nextSrcCidr =
|
||||
body.src_cidr !== undefined
|
||||
? body.src_cidr
|
||||
: existing.srcCidr
|
||||
const nextListId =
|
||||
body.list_id !== undefined ? body.list_id : existing.listId
|
||||
validateSrc(nextSrcKind, nextSrcCidr, nextListId, app.db)
|
||||
|
||||
const portStart = body.port_start ?? existing.portStart
|
||||
const portEnd = body.port_end ?? existing.portEnd
|
||||
if (portEnd < portStart) {
|
||||
throw new AppError(
|
||||
'VALIDATION_ERROR',
|
||||
'port_end must be >= port_start',
|
||||
400,
|
||||
)
|
||||
}
|
||||
|
||||
const row = repos.updateAgentPortRule(app.db, existing.id, {
|
||||
...(body.action !== undefined ? { action: body.action } : {}),
|
||||
...(body.protocol !== undefined ? { protocol: body.protocol } : {}),
|
||||
portStart,
|
||||
portEnd,
|
||||
srcKind: nextSrcKind,
|
||||
srcCidr: nextSrcKind === 'cidr' ? nextSrcCidr : null,
|
||||
listId: nextSrcKind === 'list' ? nextListId : null,
|
||||
...(body.enabled !== undefined
|
||||
? { enabled: body.enabled ? 1 : 0 }
|
||||
: {}),
|
||||
...(body.comment !== undefined ? { comment: body.comment } : {}),
|
||||
...(body.priority !== undefined ? { priority: body.priority } : {}),
|
||||
})
|
||||
repos.bumpAgentGeneration(app.db, agent.id)
|
||||
auditMutation(app, config, req, {
|
||||
action: 'port_rule.update',
|
||||
targetType: 'app_resource',
|
||||
targetId: existing.id,
|
||||
summary: `Port ACL обновлён у ${agent.name}`,
|
||||
details: { agent_id: agent.id, rule_id: existing.id },
|
||||
})
|
||||
const listName = row!.listId
|
||||
? repos.getIpList(app.db, row!.listId)?.name
|
||||
: null
|
||||
return mapPortRule(row!, listName)
|
||||
},
|
||||
)
|
||||
|
||||
app.delete<{ Params: { id: string; ruleId: string } }>(
|
||||
'/agents/:id/port-rules/:ruleId',
|
||||
async (req) => {
|
||||
const agent = repos.getAgent(app.db, req.params.id)
|
||||
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||
const existing = repos.getAgentPortRule(app.db, req.params.ruleId)
|
||||
if (!existing || existing.agentId !== agent.id) {
|
||||
throw new AppError('NOT_FOUND', 'Port rule not found', 404)
|
||||
}
|
||||
repos.deleteAgentPortRule(app.db, existing.id)
|
||||
repos.bumpAgentGeneration(app.db, agent.id)
|
||||
auditMutation(app, config, req, {
|
||||
action: 'port_rule.delete',
|
||||
severity: 'warning',
|
||||
targetType: 'app_resource',
|
||||
targetId: existing.id,
|
||||
summary: `Port ACL удалён у ${agent.name}`,
|
||||
details: { agent_id: agent.id, rule_id: existing.id },
|
||||
})
|
||||
return { ok: true }
|
||||
},
|
||||
)
|
||||
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/agents/:id/port-rules/import',
|
||||
async (req) => {
|
||||
const agent = repos.getAgent(app.db, req.params.id)
|
||||
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||
if (agent.platform !== 'linux') {
|
||||
throw new AppError(
|
||||
'VALIDATION_ERROR',
|
||||
'Port ACL is only supported on Linux agents',
|
||||
400,
|
||||
)
|
||||
}
|
||||
const body = importAgentPortRulesBodySchema.parse(req.body)
|
||||
const now = new Date().toISOString()
|
||||
const created: ReturnType<typeof mapPortRule>[] = []
|
||||
|
||||
if (body.from === 'list') {
|
||||
const list = repos.getIpList(app.db, body.list_id!)
|
||||
if (!list) throw new AppError('NOT_FOUND', 'IP list not found', 404)
|
||||
for (const p of body.ports) {
|
||||
const portEnd = p.port_end ?? p.port_start
|
||||
const row = repos.insertAgentPortRule(app.db, {
|
||||
id: crypto.randomUUID(),
|
||||
agentId: agent.id,
|
||||
action: body.action,
|
||||
protocol: body.protocol,
|
||||
portStart: p.port_start,
|
||||
portEnd,
|
||||
srcKind: 'list',
|
||||
srcCidr: null,
|
||||
listId: list.id,
|
||||
enabled: body.enabled === false ? 0 : 1,
|
||||
comment: body.comment ?? `import list:${list.name}`,
|
||||
priority: 100,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
created.push(mapPortRule(row!, list.name))
|
||||
}
|
||||
} else {
|
||||
const set = repos.getPolicySet(app.db, body.set_id!)
|
||||
if (!set) throw new AppError('NOT_FOUND', 'Policy set not found', 404)
|
||||
const rules = repos.listPolicyRules(app.db, set.id)
|
||||
const listIds = new Set<string>()
|
||||
const cidrs = new Set<string>()
|
||||
for (const r of rules) {
|
||||
if (r.listId) listIds.add(r.listId)
|
||||
if (r.cidr?.trim()) cidrs.add(r.cidr.trim())
|
||||
}
|
||||
if (!listIds.size && !cidrs.size) {
|
||||
throw new AppError(
|
||||
'VALIDATION_ERROR',
|
||||
'Policy set has no list/cidr sources to import',
|
||||
400,
|
||||
)
|
||||
}
|
||||
for (const p of body.ports) {
|
||||
const portEnd = p.port_end ?? p.port_start
|
||||
for (const listId of listIds) {
|
||||
const list = repos.getIpList(app.db, listId)
|
||||
const row = repos.insertAgentPortRule(app.db, {
|
||||
id: crypto.randomUUID(),
|
||||
agentId: agent.id,
|
||||
action: body.action,
|
||||
protocol: body.protocol,
|
||||
portStart: p.port_start,
|
||||
portEnd,
|
||||
srcKind: 'list',
|
||||
srcCidr: null,
|
||||
listId,
|
||||
enabled: body.enabled === false ? 0 : 1,
|
||||
comment:
|
||||
body.comment ?? `import set:${set.name} list:${list?.name ?? listId}`,
|
||||
priority: 100,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
created.push(mapPortRule(row!, list?.name ?? null))
|
||||
}
|
||||
for (const cidr of cidrs) {
|
||||
const row = repos.insertAgentPortRule(app.db, {
|
||||
id: crypto.randomUUID(),
|
||||
agentId: agent.id,
|
||||
action: body.action,
|
||||
protocol: body.protocol,
|
||||
portStart: p.port_start,
|
||||
portEnd,
|
||||
srcKind: 'cidr',
|
||||
srcCidr: cidr,
|
||||
listId: null,
|
||||
enabled: body.enabled === false ? 0 : 1,
|
||||
comment: body.comment ?? `import set:${set.name} cidr:${cidr}`,
|
||||
priority: 100,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
created.push(mapPortRule(row!, null))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repos.bumpAgentGeneration(app.db, agent.id)
|
||||
auditMutation(app, config, req, {
|
||||
action: 'port_rule.import',
|
||||
targetType: 'app_resource',
|
||||
targetId: agent.id,
|
||||
summary: `Импорт ${created.length} Port ACL для ${agent.name}`,
|
||||
details: {
|
||||
agent_id: agent.id,
|
||||
from: body.from,
|
||||
count: created.length,
|
||||
},
|
||||
})
|
||||
return { items: created }
|
||||
},
|
||||
)
|
||||
|
||||
app.get<{ Params: { id: string } }>(
|
||||
'/agents/:id/host-firewall',
|
||||
async (req) => {
|
||||
const agent = repos.getAgent(app.db, req.params.id)
|
||||
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||
const snap = repos.getHostFirewallSnapshot(app.db, agent.id)
|
||||
if (!snap) {
|
||||
return {
|
||||
collected_at: null,
|
||||
raw_digest: null,
|
||||
rules: [],
|
||||
listeners: [],
|
||||
}
|
||||
}
|
||||
let payload: { rules?: unknown[]; listeners?: unknown[] } = {}
|
||||
try {
|
||||
payload = JSON.parse(snap.payloadJson) as typeof payload
|
||||
} catch {
|
||||
payload = {}
|
||||
}
|
||||
return {
|
||||
collected_at: snap.collectedAt,
|
||||
raw_digest: snap.rawDigest,
|
||||
rules: payload.rules ?? [],
|
||||
listeners: payload.listeners ?? [],
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export function digestHostFirewallPayload(json: string): string {
|
||||
return createHash('sha256').update(json).digest('hex').slice(0, 16)
|
||||
}
|
||||
@@ -28,12 +28,40 @@ export const statsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
async (req) => {
|
||||
const agent = repos.getAgent(app.db, req.params.id)
|
||||
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||
const items = repos.listIpBlockStats(app.db, agent.id)
|
||||
const portsByIp = repos.mapTopPortsByIp(
|
||||
app.db,
|
||||
agent.id,
|
||||
items.map((s) => s.ip),
|
||||
5,
|
||||
)
|
||||
return {
|
||||
items: repos.listIpBlockStats(app.db, agent.id).map((s) => ({
|
||||
items: items.map((s) => ({
|
||||
ip: s.ip,
|
||||
packets: s.packets,
|
||||
first_seen_at: s.firstSeenAt,
|
||||
last_seen_at: s.lastSeenAt,
|
||||
ports: (portsByIp.get(s.ip) ?? []).map((p) => ({
|
||||
port: p.port,
|
||||
protocol: p.protocol,
|
||||
packets: p.packets,
|
||||
})),
|
||||
})),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
app.get<{ Params: { id: string } }>(
|
||||
'/agents/:id/blocked-ports',
|
||||
async (req) => {
|
||||
const agent = repos.getAgent(app.db, req.params.id)
|
||||
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||
return {
|
||||
items: repos.listPortBlockStatsAggregate(app.db, agent.id, 50).map((s) => ({
|
||||
port: s.port,
|
||||
protocol: s.protocol,
|
||||
packets: s.packets,
|
||||
last_seen_at: s.lastSeenAt,
|
||||
})),
|
||||
}
|
||||
},
|
||||
@@ -52,6 +80,7 @@ export const statsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||
})
|
||||
repos.deleteStatsSamplesForAgent(app.db, agent.id)
|
||||
repos.deleteIpBlockStatsForAgent(app.db, agent.id)
|
||||
repos.deletePortBlockStatsForAgent(app.db, agent.id)
|
||||
auditMutation(app, config, req, {
|
||||
action: 'agent.stats_reset',
|
||||
severity: 'info',
|
||||
|
||||
@@ -193,13 +193,15 @@ describe('install-links', () => {
|
||||
policy_mode: string
|
||||
apply_version: number
|
||||
hash: string
|
||||
script_sha256: string
|
||||
}
|
||||
expect(body.deny_cidrs).toEqual([])
|
||||
expect(body.allow_cidrs).toEqual([])
|
||||
expect(body.default_action).toBe('accept')
|
||||
expect(body.policy_mode).toBe('blacklist')
|
||||
expect(body.apply_version).toBe(2)
|
||||
expect(body.apply_version).toBe(3)
|
||||
expect(body.hash).toMatch(/^sha256:/)
|
||||
expect(body.script_sha256).toMatch(/^[a-f0-9]{64}$/)
|
||||
|
||||
const agents = await app.inject({ method: 'GET', url: '/api/v1/agents' })
|
||||
const row = (
|
||||
@@ -259,4 +261,70 @@ describe('install-links', () => {
|
||||
expect(rsc.body).toContain('EVOFW_DENY')
|
||||
expect(rsc.body).toContain('203.0.113.0/24')
|
||||
})
|
||||
|
||||
it('sync-script serves ETag and 304 on If-None-Match', async () => {
|
||||
const app = await appPromise
|
||||
await app.ready()
|
||||
|
||||
const first = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/v1/agent/sync-script',
|
||||
})
|
||||
expect(first.statusCode).toBe(200)
|
||||
expect(first.body.startsWith('#!')).toBe(true)
|
||||
expect(first.body).toContain('maybe_self_update')
|
||||
const etag = String(first.headers.etag ?? '')
|
||||
const sha = String(first.headers['x-evofw-script-sha256'] ?? '')
|
||||
expect(etag).toMatch(/^"[a-f0-9]{64}"$/)
|
||||
expect(sha).toBe(etag.replaceAll('"', ''))
|
||||
|
||||
const cached = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/v1/agent/sync-script',
|
||||
headers: { 'if-none-match': etag },
|
||||
})
|
||||
expect(cached.statusCode).toBe(304)
|
||||
|
||||
const miss = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/v1/agent/sync-script',
|
||||
headers: { 'if-none-match': '"deadbeef"' },
|
||||
})
|
||||
expect(miss.statusCode).toBe(200)
|
||||
expect(miss.body).toBe(first.body)
|
||||
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/install-links',
|
||||
payload: { name: 'script-sha-policy', platform: 'linux' },
|
||||
})
|
||||
const link = created.json() as { id: string; agent_id: string }
|
||||
const token = 'evofw_script_sha_token_abcdefghij'
|
||||
const enroll = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/agent/enroll',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-evofw-seed': 'test-seed',
|
||||
},
|
||||
payload: {
|
||||
name: 'script-sha-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)
|
||||
expect((policy.json() as { script_sha256: string }).script_sha256).toBe(sha)
|
||||
})
|
||||
})
|
||||
@@ -140,6 +140,73 @@ describe('apply-report ip_hits / blocked-ips', () => {
|
||||
expect(body2.items.find((i) => i.ip === '203.0.113.10')?.packets).toBe(18)
|
||||
expect(body2.items.find((i) => i.ip === '198.51.100.0/24')?.packets).toBe(5)
|
||||
|
||||
// Simulate nft flush: Traffic absolute drops; ip_hits omit zeros → baselines must reset
|
||||
const reportReset = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/agent/apply-report',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
payload: {
|
||||
status: 'ok',
|
||||
prefix_count: 2,
|
||||
packets_dropped: 0,
|
||||
packets_accepted: 0,
|
||||
kernel_method: 'nft',
|
||||
source: 'agent',
|
||||
ip_hits: [],
|
||||
},
|
||||
})
|
||||
expect(reportReset.statusCode).toBe(200)
|
||||
|
||||
const report3 = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/agent/apply-report',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
payload: {
|
||||
status: 'ok',
|
||||
prefix_count: 2,
|
||||
packets_dropped: 7,
|
||||
packets_accepted: 0,
|
||||
kernel_method: 'nft',
|
||||
source: 'agent',
|
||||
ip_hits: [
|
||||
{ ip: '203.0.113.10', packets: 4 },
|
||||
{ ip: '198.51.100.0/24', packets: 3 },
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(report3.statusCode).toBe(200)
|
||||
|
||||
const listAfterFlush = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${agentId}/blocked-ips`,
|
||||
})
|
||||
const bodyFlush = listAfterFlush.json() as {
|
||||
items: { ip: string; packets: number }[]
|
||||
}
|
||||
// First epoch 18+5 plus second epoch 4+3
|
||||
expect(bodyFlush.items.find((i) => i.ip === '203.0.113.10')?.packets).toBe(
|
||||
22,
|
||||
)
|
||||
expect(
|
||||
bodyFlush.items.find((i) => i.ip === '198.51.100.0/24')?.packets,
|
||||
).toBe(8)
|
||||
|
||||
const agentAfter = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${agentId}`,
|
||||
})
|
||||
const agentBody = agentAfter.json() as {
|
||||
total_packets_dropped?: number
|
||||
}
|
||||
// Traffic: 25 + 0 + 7 = 32
|
||||
expect(agentBody.total_packets_dropped).toBe(32)
|
||||
|
||||
const reset = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/agents/${agentId}/stats/reset`,
|
||||
@@ -185,4 +252,267 @@ describe('apply-report ip_hits / blocked-ips', () => {
|
||||
})
|
||||
expect(report.statusCode).toBeGreaterThanOrEqual(400)
|
||||
})
|
||||
|
||||
it('mikrotik presence: continuous sync refreshes last_seen without +1; rehit after stale gap', async () => {
|
||||
const app = await appPromise
|
||||
await app.ready()
|
||||
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/install-links',
|
||||
payload: { name: 'mt-hits', platform: 'mikrotik' },
|
||||
})
|
||||
const link = created.json() as { id: string; agent_id: string }
|
||||
const token = 'evofw_mt_hits_token_abcdefghijk'
|
||||
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/agent/enroll',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-evofw-seed': 'test-seed',
|
||||
},
|
||||
payload: {
|
||||
name: 'mt-hits',
|
||||
platform: 'mikrotik',
|
||||
token,
|
||||
install_link_id: link.id,
|
||||
},
|
||||
})
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/agents/${link.agent_id}/approve`,
|
||||
})
|
||||
|
||||
const reportPayload = (dropped: number) => ({
|
||||
status: 'ok',
|
||||
packets_dropped: dropped,
|
||||
kernel_method: 'address-list',
|
||||
source: 'mikrotik',
|
||||
ip_hits: [{ ip: '203.0.113.50', packets: 1 }],
|
||||
})
|
||||
|
||||
expect(
|
||||
(
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/agent/apply-report',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
payload: reportPayload(3),
|
||||
})
|
||||
).statusCode,
|
||||
).toBe(200)
|
||||
|
||||
const list1 = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${link.agent_id}/blocked-ips`,
|
||||
})
|
||||
const body1 = list1.json() as {
|
||||
items: { ip: string; packets: number; last_seen_at: string }[]
|
||||
}
|
||||
expect(body1.items).toHaveLength(1)
|
||||
expect(body1.items[0]?.packets).toBe(1)
|
||||
const firstSeen = body1.items[0]!.last_seen_at
|
||||
|
||||
await new Promise((r) => setTimeout(r, 5))
|
||||
|
||||
expect(
|
||||
(
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/agent/apply-report',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
payload: reportPayload(5),
|
||||
})
|
||||
).statusCode,
|
||||
).toBe(200)
|
||||
|
||||
const list2 = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${link.agent_id}/blocked-ips`,
|
||||
})
|
||||
const body2 = list2.json() as {
|
||||
items: { ip: string; packets: number; last_seen_at: string }[]
|
||||
}
|
||||
expect(body2.items[0]?.packets).toBe(1)
|
||||
expect(body2.items[0]!.last_seen_at >= firstSeen).toBe(true)
|
||||
|
||||
// Re-entry after stale gap (> PRESENCE_REHIT_STALE_MS): simulate via future `now`
|
||||
const { repos } = await import('@evofw/db')
|
||||
repos.upsertIpBlockStats(
|
||||
app.db,
|
||||
link.agent_id,
|
||||
[{ ip: '203.0.113.50', packets: 1 }],
|
||||
new Date(Date.now() + 200_000).toISOString(),
|
||||
{ mode: 'presence' },
|
||||
)
|
||||
|
||||
const list3 = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${link.agent_id}/blocked-ips`,
|
||||
})
|
||||
expect(
|
||||
(list3.json() as { items: { packets: number }[] }).items[0]?.packets,
|
||||
).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('apply-report port_hits / blocked-ports', () => {
|
||||
const appPromise = buildApp({ memory: true, config: testConfig })
|
||||
|
||||
afterAll(async () => {
|
||||
const app = await appPromise
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('upserts port_hits with delta accumulation, aggregate, per-IP ports, reset', async () => {
|
||||
const app = await appPromise
|
||||
await app.ready()
|
||||
|
||||
const { agentId, token } = await enrollApprovedLinux(
|
||||
app,
|
||||
'port-hits-01',
|
||||
'evofw_port_hits_token_abcdefg',
|
||||
)
|
||||
|
||||
const report1 = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/agent/apply-report',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
payload: {
|
||||
status: 'ok',
|
||||
prefix_count: 1,
|
||||
packets_dropped: 30,
|
||||
packets_accepted: 0,
|
||||
kernel_method: 'nft',
|
||||
source: 'agent',
|
||||
ip_hits: [{ ip: '203.0.113.10', packets: 20 }],
|
||||
port_hits: [
|
||||
{ ip: '203.0.113.10', port: 22, protocol: 'tcp', packets: 12 },
|
||||
{ ip: '203.0.113.10', port: 53, protocol: 'udp', packets: 8 },
|
||||
{ ip: '198.51.100.7', port: 22, protocol: 'tcp', packets: 5 },
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(report1.statusCode).toBe(200)
|
||||
|
||||
const ports1 = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${agentId}/blocked-ports`,
|
||||
})
|
||||
expect(ports1.statusCode).toBe(200)
|
||||
const agg1 = ports1.json() as {
|
||||
items: {
|
||||
port: number
|
||||
protocol: string
|
||||
packets: number
|
||||
last_seen_at: string
|
||||
}[]
|
||||
}
|
||||
expect(agg1.items[0]?.port).toBe(22)
|
||||
expect(agg1.items[0]?.protocol).toBe('tcp')
|
||||
expect(agg1.items[0]?.packets).toBe(17) // 12+5
|
||||
expect(agg1.items.find((i) => i.port === 53)?.packets).toBe(8)
|
||||
|
||||
const ips1 = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${agentId}/blocked-ips`,
|
||||
})
|
||||
const ipBody = ips1.json() as {
|
||||
items: {
|
||||
ip: string
|
||||
ports?: { port: number; protocol: string; packets: number }[]
|
||||
}[]
|
||||
}
|
||||
const row10 = ipBody.items.find((i) => i.ip === '203.0.113.10')
|
||||
expect(row10?.ports?.map((p) => `${p.protocol}/${p.port}`)).toEqual([
|
||||
'tcp/22',
|
||||
'udp/53',
|
||||
])
|
||||
|
||||
const report2 = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/agent/apply-report',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
payload: {
|
||||
status: 'ok',
|
||||
packets_dropped: 40,
|
||||
kernel_method: 'nft',
|
||||
source: 'agent',
|
||||
port_hits: [
|
||||
{ ip: '203.0.113.10', port: 22, protocol: 'tcp', packets: 15 },
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(report2.statusCode).toBe(200)
|
||||
|
||||
const ports2 = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${agentId}/blocked-ports`,
|
||||
})
|
||||
const agg2 = ports2.json() as {
|
||||
items: { port: number; protocol: string; packets: number }[]
|
||||
}
|
||||
// tcp/22: 17 + (15-12) = 20
|
||||
expect(
|
||||
agg2.items.find((i) => i.port === 22 && i.protocol === 'tcp')?.packets,
|
||||
).toBe(20)
|
||||
|
||||
const reset = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/agents/${agentId}/stats/reset`,
|
||||
})
|
||||
expect(reset.statusCode).toBe(200)
|
||||
|
||||
const ports3 = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${agentId}/blocked-ports`,
|
||||
})
|
||||
expect((ports3.json() as { items: unknown[] }).items).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects port_hits longer than 500', async () => {
|
||||
const app = await appPromise
|
||||
await app.ready()
|
||||
|
||||
const { token } = await enrollApprovedLinux(
|
||||
app,
|
||||
'port-hits-max',
|
||||
'evofw_port_hits_max_token_abc',
|
||||
)
|
||||
|
||||
const hits = Array.from({ length: 501 }, (_, i) => ({
|
||||
ip: `203.0.113.${(i % 254) + 1}`,
|
||||
port: (i % 65535) + 1,
|
||||
protocol: i % 2 === 0 ? 'tcp' : 'udp',
|
||||
packets: 1,
|
||||
}))
|
||||
|
||||
const report = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/agent/apply-report',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
payload: {
|
||||
status: 'ok',
|
||||
packets_dropped: 501,
|
||||
port_hits: hits,
|
||||
},
|
||||
})
|
||||
expect(report.statusCode).toBeGreaterThanOrEqual(400)
|
||||
})
|
||||
})
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '@evofw/shared'
|
||||
import { uniqCidrs } from '../uniq.js'
|
||||
|
||||
export const POLICY_APPLY_VERSION = 2 as const
|
||||
export const POLICY_APPLY_VERSION = 3 as const
|
||||
|
||||
export type PolicyChainStep = {
|
||||
setId: string | null
|
||||
@@ -20,6 +20,15 @@ export type PolicyChainStep = {
|
||||
cidrCount: number
|
||||
}
|
||||
|
||||
export type EvaluatedPortRule = {
|
||||
id: string
|
||||
action: 'open' | 'close'
|
||||
protocol: 'tcp' | 'udp'
|
||||
portStart: number
|
||||
portEnd: number
|
||||
srcCidrs: string[]
|
||||
}
|
||||
|
||||
export type EvaluatedPolicy = {
|
||||
generation: number
|
||||
hash: string
|
||||
@@ -29,6 +38,7 @@ export type EvaluatedPolicy = {
|
||||
policyMode: 'blacklist' | 'whitelist'
|
||||
denyCidrs: string[]
|
||||
allowCidrs: string[]
|
||||
portRules: EvaluatedPortRule[]
|
||||
conflictsDropped: number
|
||||
syncIntervalSec: number
|
||||
chain: PolicyChainStep[]
|
||||
@@ -40,6 +50,7 @@ export type EvaluatedPolicy = {
|
||||
cidrsAllow: number
|
||||
overrides: number
|
||||
conflictsDropped: number
|
||||
portRules: number
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +101,54 @@ function sourceMeta(
|
||||
return { kind: 'list', label: name || listId || 'list' }
|
||||
}
|
||||
|
||||
function expandPortSrcCidrs(
|
||||
db: Db,
|
||||
row: {
|
||||
srcKind: string
|
||||
srcCidr: string | null
|
||||
listId: string | null
|
||||
},
|
||||
): string[] {
|
||||
if (row.srcKind === 'all') return ['0.0.0.0/0']
|
||||
if (row.srcKind === 'cidr' && row.srcCidr?.trim()) {
|
||||
return [row.srcCidr.trim()]
|
||||
}
|
||||
if (row.srcKind === 'list') {
|
||||
const cidrs = expandList(db, row.listId)
|
||||
return cidrs.length ? uniqCidrs(cidrs) : []
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function expandPortRules(db: Db, agentId: string): EvaluatedPortRule[] {
|
||||
const rows = repos.listEnabledAgentPortRules(db, agentId)
|
||||
const out: EvaluatedPortRule[] = []
|
||||
for (const row of rows) {
|
||||
const action = row.action === 'close' ? 'close' : 'open'
|
||||
const srcCidrs = expandPortSrcCidrs(db, row)
|
||||
if (!srcCidrs.length) continue
|
||||
const portStart = Math.max(1, Math.min(65535, row.portStart))
|
||||
const portEnd = Math.max(portStart, Math.min(65535, row.portEnd))
|
||||
const protocols: Array<'tcp' | 'udp'> =
|
||||
row.protocol === 'udp'
|
||||
? ['udp']
|
||||
: row.protocol === 'both'
|
||||
? ['tcp', 'udp']
|
||||
: ['tcp']
|
||||
for (const protocol of protocols) {
|
||||
out.push({
|
||||
id: row.id,
|
||||
action,
|
||||
protocol,
|
||||
portStart,
|
||||
portEnd,
|
||||
srcCidrs,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Evaluate allow/deny sets for an agent from assigned policy sets. */
|
||||
export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
|
||||
const agent = repos.getAgent(db, agentId)
|
||||
@@ -155,6 +214,7 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
|
||||
const conflictsDropped = allowRaw.length - allowCidrs.length
|
||||
const defaultAction = resolveDefaultAction(agent.defaultAction)
|
||||
const policyMode = legacyModeFromDefaultAction(defaultAction)
|
||||
const portRules = expandPortRules(db, agentId)
|
||||
|
||||
const payload = JSON.stringify({
|
||||
apply_version: POLICY_APPLY_VERSION,
|
||||
@@ -162,6 +222,7 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
|
||||
defaultAction,
|
||||
denyCidrs,
|
||||
allowCidrs,
|
||||
portRules,
|
||||
})
|
||||
const hash = `sha256:${createHash('sha256').update(payload).digest('hex')}`
|
||||
|
||||
@@ -176,6 +237,7 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
|
||||
policyMode,
|
||||
denyCidrs,
|
||||
allowCidrs,
|
||||
portRules,
|
||||
conflictsDropped,
|
||||
syncIntervalSec,
|
||||
chain,
|
||||
@@ -187,6 +249,7 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
|
||||
cidrsAllow: allowCidrs.length,
|
||||
overrides: overrides.length,
|
||||
conflictsDropped,
|
||||
portRules: portRules.length,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ function basePolicy(
|
||||
policyMode: 'blacklist',
|
||||
denyCidrs: ['1.2.3.0/24', '2001:db8::/32', '10.0.0.1/32'],
|
||||
allowCidrs: ['8.8.8.8/32', 'fe80::1/128'],
|
||||
portRules: [],
|
||||
conflictsDropped: 0,
|
||||
syncIntervalSec: 60,
|
||||
chain: [],
|
||||
@@ -27,6 +28,7 @@ function basePolicy(
|
||||
cidrsAllow: 1,
|
||||
overrides: 0,
|
||||
conflictsDropped: 0,
|
||||
portRules: 0,
|
||||
},
|
||||
...patch,
|
||||
}
|
||||
@@ -36,7 +38,7 @@ describe('renderMikrotikPolicyRsc', () => {
|
||||
it('renders accept default: lists + default-drop disabled', () => {
|
||||
const rsc = renderMikrotikPolicyRsc(basePolicy())
|
||||
expect(rsc).toContain(
|
||||
'# evofw hash=sha256:abc default_action=accept apply_version=2 gen=3',
|
||||
`# evofw hash=sha256:abc default_action=accept apply_version=${POLICY_APPLY_VERSION} gen=3`,
|
||||
)
|
||||
expect(rsc).toContain('list=EVOFW_DENY')
|
||||
expect(rsc).toContain('list=EVOFW_ALLOW')
|
||||
|
||||
@@ -100,7 +100,7 @@ describe('classic policy default_action', () => {
|
||||
chain: unknown[]
|
||||
}
|
||||
expect(body.default_action).toBe('drop')
|
||||
expect(body.apply_version).toBe(2)
|
||||
expect(body.apply_version).toBe(3)
|
||||
expect(body.deny_cidrs).toContain('10.0.0.1/32')
|
||||
expect(body.allow_cidrs).not.toContain('10.0.0.1/32')
|
||||
expect(body.allow_cidrs).toContain('10.0.0.2/32')
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
import { describe, it, expect, afterAll } from 'vitest'
|
||||
import { buildApp } from '../app.js'
|
||||
import type { AppConfig } from '../config.js'
|
||||
|
||||
const testConfig: AppConfig = {
|
||||
databaseUrl: 'sqlite::memory:',
|
||||
jwtSecret: 'test',
|
||||
jwtTtlHours: 24,
|
||||
serverPort: 8080,
|
||||
staticDir: null,
|
||||
logLevel: 'error',
|
||||
authRequired: false,
|
||||
authIssuer: 'https://auth.test',
|
||||
authPortalUrl: 'http://localhost:5175',
|
||||
publicBaseUrl: 'https://fw.example.com',
|
||||
enrollSeed: 'test-seed',
|
||||
}
|
||||
|
||||
async function enrollApprovedLinux(
|
||||
app: Awaited<ReturnType<typeof buildApp>>,
|
||||
name: string,
|
||||
token: string,
|
||||
) {
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/install-links',
|
||||
payload: { name, platform: 'linux' },
|
||||
})
|
||||
expect(created.statusCode).toBe(201)
|
||||
const link = created.json() as { id: string; agent_id: string }
|
||||
|
||||
const enroll = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/agent/enroll',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-evofw-seed': 'test-seed',
|
||||
},
|
||||
payload: {
|
||||
name,
|
||||
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`,
|
||||
})
|
||||
|
||||
return { agentId: link.agent_id, token }
|
||||
}
|
||||
|
||||
describe('port ACL + host firewall snapshot', () => {
|
||||
const appPromise = buildApp({ memory: true, config: testConfig })
|
||||
|
||||
afterAll(async () => {
|
||||
const app = await appPromise
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('CRUD port-rules bumps generation and expands in policy', async () => {
|
||||
const app = await appPromise
|
||||
await app.ready()
|
||||
|
||||
const { agentId, token } = await enrollApprovedLinux(
|
||||
app,
|
||||
'port-acl-01',
|
||||
'evofw_port_acl_token_abcdefghij',
|
||||
)
|
||||
|
||||
const before = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${agentId}`,
|
||||
})
|
||||
const genBefore = (before.json() as { policy_generation: number })
|
||||
.policy_generation
|
||||
|
||||
const create = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/agents/${agentId}/port-rules`,
|
||||
payload: {
|
||||
action: 'open',
|
||||
protocol: 'tcp',
|
||||
port_start: 443,
|
||||
src_kind: 'cidr',
|
||||
src_cidr: '10.0.0.0/8',
|
||||
},
|
||||
})
|
||||
expect(create.statusCode).toBe(200)
|
||||
const rule = create.json() as { id: string; action: string; port_start: number }
|
||||
expect(rule.action).toBe('open')
|
||||
expect(rule.port_start).toBe(443)
|
||||
|
||||
const after = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${agentId}`,
|
||||
})
|
||||
expect(
|
||||
(after.json() as { policy_generation: number }).policy_generation,
|
||||
).toBe(genBefore + 1)
|
||||
|
||||
const list = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${agentId}/port-rules`,
|
||||
})
|
||||
expect(list.statusCode).toBe(200)
|
||||
expect((list.json() as { items: unknown[] }).items).toHaveLength(1)
|
||||
|
||||
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 {
|
||||
apply_version: number
|
||||
port_rules: {
|
||||
action: string
|
||||
protocol: string
|
||||
port_start: number
|
||||
src_cidrs: string[]
|
||||
}[]
|
||||
}
|
||||
expect(body.apply_version).toBe(3)
|
||||
expect(body.port_rules).toHaveLength(1)
|
||||
expect(body.port_rules[0]?.src_cidrs).toEqual(['10.0.0.0/8'])
|
||||
expect(body.port_rules[0]?.protocol).toBe('tcp')
|
||||
|
||||
const patch = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/v1/agents/${agentId}/port-rules/${rule.id}`,
|
||||
payload: { enabled: false },
|
||||
})
|
||||
expect(patch.statusCode).toBe(200)
|
||||
expect((patch.json() as { enabled: boolean }).enabled).toBe(false)
|
||||
|
||||
const policyOff = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/v1/agent/policy',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
expect(
|
||||
(policyOff.json() as { port_rules: unknown[] }).port_rules,
|
||||
).toHaveLength(0)
|
||||
|
||||
const del = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/agents/${agentId}/port-rules/${rule.id}`,
|
||||
})
|
||||
expect(del.statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it('imports port-rules from IP list', async () => {
|
||||
const app = await appPromise
|
||||
await app.ready()
|
||||
|
||||
const { agentId } = await enrollApprovedLinux(
|
||||
app,
|
||||
'port-acl-import',
|
||||
'evofw_port_import_token_abcdefgh',
|
||||
)
|
||||
|
||||
const listRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/lists',
|
||||
payload: {
|
||||
name: 'port-src-list',
|
||||
type: 'static',
|
||||
entries: ['203.0.113.0/24'],
|
||||
},
|
||||
})
|
||||
expect(listRes.statusCode).toBe(200)
|
||||
const listId = (listRes.json() as { id: string }).id
|
||||
|
||||
const before = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${agentId}`,
|
||||
})
|
||||
const genBefore = (before.json() as { policy_generation: number })
|
||||
.policy_generation
|
||||
|
||||
const imp = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/agents/${agentId}/port-rules/import`,
|
||||
payload: {
|
||||
from: 'list',
|
||||
list_id: listId,
|
||||
action: 'close',
|
||||
protocol: 'both',
|
||||
ports: [{ port_start: 22 }, { port_start: 80, port_end: 81 }],
|
||||
},
|
||||
})
|
||||
expect(imp.statusCode).toBe(200)
|
||||
const items = (imp.json() as { items: { src_kind: string; list_id: string }[] })
|
||||
.items
|
||||
expect(items).toHaveLength(2)
|
||||
expect(items.every((i) => i.src_kind === 'list' && i.list_id === listId)).toBe(
|
||||
true,
|
||||
)
|
||||
|
||||
const after = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${agentId}`,
|
||||
})
|
||||
expect(
|
||||
(after.json() as { policy_generation: number }).policy_generation,
|
||||
).toBe(genBefore + 1)
|
||||
})
|
||||
|
||||
it('upserts host_firewall snapshot from apply-report', async () => {
|
||||
const app = await appPromise
|
||||
await app.ready()
|
||||
|
||||
const { agentId, token } = await enrollApprovedLinux(
|
||||
app,
|
||||
'host-fw-snap',
|
||||
'evofw_host_fw_token_abcdefghij',
|
||||
)
|
||||
|
||||
const report = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/agent/apply-report',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
payload: {
|
||||
status: 'ok',
|
||||
kernel_method: 'nft',
|
||||
host_firewall: {
|
||||
rules: [
|
||||
{
|
||||
ownership: 'evofw',
|
||||
backend: 'nft',
|
||||
table: 'evofw',
|
||||
chain: 'input',
|
||||
action: 'drop',
|
||||
protocol: 'tcp',
|
||||
dport: '22',
|
||||
raw: 'tcp dport 22 drop comment "evofw-port-x"',
|
||||
},
|
||||
{
|
||||
ownership: 'foreign',
|
||||
backend: 'iptables',
|
||||
chain: 'INPUT',
|
||||
action: 'ACCEPT',
|
||||
// Agent historically sent JSON null for missing fields — must accept.
|
||||
protocol: null,
|
||||
dport: null,
|
||||
saddr: null,
|
||||
raw: '-A INPUT -p tcp --dport 80 -j ACCEPT',
|
||||
},
|
||||
],
|
||||
listeners: [
|
||||
{ protocol: 'tcp', port: 22, address: '0.0.0.0' },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(report.statusCode).toBe(200)
|
||||
|
||||
const snap = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${agentId}/host-firewall`,
|
||||
})
|
||||
expect(snap.statusCode).toBe(200)
|
||||
const body = snap.json() as {
|
||||
collected_at: string | null
|
||||
rules: { ownership: string }[]
|
||||
listeners: { port: number }[]
|
||||
}
|
||||
expect(body.collected_at).toBeTruthy()
|
||||
expect(body.rules).toHaveLength(2)
|
||||
expect(body.rules.some((r) => r.ownership === 'evofw')).toBe(true)
|
||||
expect(body.listeners[0]?.port).toBe(22)
|
||||
})
|
||||
|
||||
it('does not wipe host_firewall snapshot with empty apply-report payload', async () => {
|
||||
const app = await appPromise
|
||||
await app.ready()
|
||||
|
||||
const { agentId, token } = await enrollApprovedLinux(
|
||||
app,
|
||||
'host-fw-empty',
|
||||
'evofw_host_fw_empty_token_ab',
|
||||
)
|
||||
|
||||
const filled = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/agent/apply-report',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
payload: {
|
||||
status: 'ok',
|
||||
host_firewall: {
|
||||
rules: [
|
||||
{
|
||||
ownership: 'evofw',
|
||||
backend: 'nft',
|
||||
action: 'drop',
|
||||
raw: 'ip saddr @deny_v4 drop',
|
||||
},
|
||||
],
|
||||
listeners: [{ protocol: 'tcp', port: 22, address: '0.0.0.0' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(filled.statusCode).toBe(200)
|
||||
|
||||
const empty = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/agent/apply-report',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
payload: {
|
||||
status: 'ok',
|
||||
host_firewall: { rules: [], listeners: [] },
|
||||
},
|
||||
})
|
||||
expect(empty.statusCode).toBe(200)
|
||||
|
||||
const snap = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${agentId}/host-firewall`,
|
||||
})
|
||||
const body = snap.json() as {
|
||||
rules: unknown[]
|
||||
listeners: unknown[]
|
||||
}
|
||||
expect(body.rules).toHaveLength(1)
|
||||
expect(body.listeners).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('filters invalid host_firewall rules without failing apply-report', async () => {
|
||||
const app = await appPromise
|
||||
await app.ready()
|
||||
|
||||
const { agentId, token } = await enrollApprovedLinux(
|
||||
app,
|
||||
'host-fw-filter',
|
||||
'evofw_host_fw_filter_token_a',
|
||||
)
|
||||
|
||||
const report = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/agent/apply-report',
|
||||
headers: {
|
||||
authorization: `Bearer ${token}`,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
payload: {
|
||||
status: 'ok',
|
||||
host_firewall: {
|
||||
rules: [
|
||||
{ ownership: 'evofw', backend: 'nft', raw: 'ok rule' },
|
||||
{ ownership: 'nope', backend: 'nft', raw: 'bad ownership' },
|
||||
{ not: 'a rule' },
|
||||
],
|
||||
listeners: [
|
||||
{ protocol: 'tcp', port: 443, address: '::' },
|
||||
{ protocol: 'tcp', port: 99999, address: '1.1.1.1' },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(report.statusCode).toBe(200)
|
||||
|
||||
const snap = await app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/v1/agents/${agentId}/host-firewall`,
|
||||
})
|
||||
const body = snap.json() as {
|
||||
rules: unknown[]
|
||||
listeners: { port: number }[]
|
||||
}
|
||||
expect(body.rules).toHaveLength(1)
|
||||
expect(body.listeners).toHaveLength(1)
|
||||
expect(body.listeners[0]?.port).toBe(443)
|
||||
})
|
||||
|
||||
it('rejects port ACL on non-linux agents', async () => {
|
||||
const app = await appPromise
|
||||
await app.ready()
|
||||
|
||||
const created = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/v1/install-links',
|
||||
payload: { name: 'mt-no-acl', platform: 'mikrotik' },
|
||||
})
|
||||
const agentId = (created.json() as { agent_id: string }).agent_id
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/agents/${agentId}/approve`,
|
||||
})
|
||||
|
||||
const create = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/v1/agents/${agentId}/port-rules`,
|
||||
payload: {
|
||||
action: 'open',
|
||||
protocol: 'tcp',
|
||||
port_start: 443,
|
||||
src_kind: 'all',
|
||||
},
|
||||
})
|
||||
expect(create.statusCode).toBe(400)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { ifNoneMatchHits, getSyncScriptMeta } from './sync-script.js'
|
||||
|
||||
describe('sync-script meta', () => {
|
||||
it('hashes evofw-firewall.sh and matches ETag', () => {
|
||||
const meta = getSyncScriptMeta()
|
||||
expect(meta.sha256).toMatch(/^[a-f0-9]{64}$/)
|
||||
expect(meta.etag).toBe(`"${meta.sha256}"`)
|
||||
expect(meta.body.subarray(0, 2).toString()).toBe('#!')
|
||||
})
|
||||
|
||||
it('ifNoneMatchHits understands quoted, weak, and star', () => {
|
||||
const etag = '"abc"'
|
||||
expect(ifNoneMatchHits('"abc"', etag)).toBe(true)
|
||||
expect(ifNoneMatchHits('abc', etag)).toBe(true)
|
||||
expect(ifNoneMatchHits('W/"abc"', etag)).toBe(true)
|
||||
expect(ifNoneMatchHits('*', etag)).toBe(true)
|
||||
expect(ifNoneMatchHits('"nope"', etag)).toBe(false)
|
||||
expect(ifNoneMatchHits(undefined, etag)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { join } from 'node:path'
|
||||
import { resolveAgentScriptsDir } from './agent-scripts-path.js'
|
||||
|
||||
export type SyncScriptMeta = {
|
||||
body: Buffer
|
||||
sha256: string
|
||||
etag: string
|
||||
}
|
||||
|
||||
let cache: SyncScriptMeta | null = null
|
||||
|
||||
/** Linux sync agent script + sha256 of file bytes (process-lifetime cache). */
|
||||
export function getSyncScriptMeta(): SyncScriptMeta {
|
||||
if (cache) return cache
|
||||
const dir = resolveAgentScriptsDir()
|
||||
const body = readFileSync(join(dir, 'evofw-firewall.sh'))
|
||||
const sha256 = createHash('sha256').update(body).digest('hex')
|
||||
cache = { body, sha256, etag: `"${sha256}"` }
|
||||
return cache
|
||||
}
|
||||
|
||||
/** Compare If-None-Match with our ETag (`"<sha256>"`). */
|
||||
export function ifNoneMatchHits(
|
||||
header: string | string[] | undefined,
|
||||
etag: string,
|
||||
): boolean {
|
||||
if (!header) return false
|
||||
const raw = Array.isArray(header) ? header.join(',') : header
|
||||
const want = etag.replaceAll('"', '').toLowerCase()
|
||||
for (const part of raw.split(',')) {
|
||||
let token = part.trim()
|
||||
if (token.startsWith('W/')) token = token.slice(2).trim()
|
||||
token = token.replaceAll('"', '')
|
||||
if (token === '*' || token.toLowerCase() === want) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -169,7 +169,9 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) {
|
||||
title="Добавить агента"
|
||||
description="Создайте агента и короткую install-ссылку."
|
||||
form={form}
|
||||
onSubmit={(values) => create.mutateAsync(values)}
|
||||
onSubmit={async (values) => {
|
||||
await create.mutateAsync(values)
|
||||
}}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
} from '@tanstack/react-table'
|
||||
import { BanIcon, RouterIcon } from 'lucide-react'
|
||||
import { BanIcon } from 'lucide-react'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
@@ -21,17 +21,23 @@ import { agentBlockedIpsQueryOptions } from '@/queries'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
|
||||
/**
|
||||
* Per-IP/CIDR drop counters from Linux nft/ipset.
|
||||
* Per-IP blocked stats — Linux nft/ipset counters or MikroTik EVOFW_HITS.
|
||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
||||
* · https://reui.io/preview/base/empty-state-12
|
||||
* MikroTik: address-list has no per-entry counters — empty hint only.
|
||||
*/
|
||||
|
||||
export type BlockedIpPort = {
|
||||
port: number
|
||||
protocol: string
|
||||
packets: number
|
||||
}
|
||||
|
||||
export type BlockedIpRow = {
|
||||
ip: string
|
||||
packets: number
|
||||
first_seen_at: string
|
||||
last_seen_at: string
|
||||
ports?: BlockedIpPort[]
|
||||
}
|
||||
|
||||
type AgentBlockedIpsProps = {
|
||||
@@ -55,15 +61,25 @@ function formatSeen(iso: string): string {
|
||||
return seenFmt.format(t)
|
||||
}
|
||||
|
||||
function formatPorts(ports: BlockedIpPort[] | undefined): string {
|
||||
if (!ports?.length) return '—'
|
||||
return ports
|
||||
.map((p) => `${p.protocol}/${p.port}`)
|
||||
.join(', ')
|
||||
}
|
||||
|
||||
export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) {
|
||||
const isMikrotik = platform === 'mikrotik'
|
||||
const q = useQuery({
|
||||
...agentBlockedIpsQueryOptions(agentId),
|
||||
enabled: !isMikrotik,
|
||||
})
|
||||
const showPorts = !isMikrotik
|
||||
const q = useQuery(agentBlockedIpsQueryOptions(agentId))
|
||||
|
||||
const columns = useMemo<ColumnDef<BlockedIpRow>[]>(
|
||||
() => [
|
||||
const packetsTitle = isMikrotik ? 'Hits' : 'Packets'
|
||||
const description = isMikrotik
|
||||
? 'Src /32 из EVOFW_HITS (add-src при deny, timeout 1h). Hits — входы в список (не каждый sync); Last seen обновляется, пока IP в hits.'
|
||||
: 'Drop-пакеты по записям deny (nft/ipset). Top по накопленным packets. Ports — top-5 dport (nft).'
|
||||
|
||||
const columns = useMemo<ColumnDef<BlockedIpRow>[]>(() => {
|
||||
const cols: ColumnDef<BlockedIpRow>[] = [
|
||||
{
|
||||
accessorKey: 'ip',
|
||||
id: 'ip',
|
||||
@@ -79,31 +95,46 @@ export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) {
|
||||
accessorKey: 'packets',
|
||||
id: 'packets',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Packets" />
|
||||
<DataGridColumnHeader column={column} title={packetsTitle} />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">
|
||||
{packetFmt.format(row.original.packets)}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Packets' },
|
||||
meta: { headerTitle: packetsTitle },
|
||||
},
|
||||
{
|
||||
accessorKey: 'last_seen_at',
|
||||
id: 'last_seen_at',
|
||||
]
|
||||
if (showPorts) {
|
||||
cols.push({
|
||||
id: 'ports',
|
||||
accessorFn: (row) => formatPorts(row.ports),
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Last seen" />
|
||||
<DataGridColumnHeader column={column} title="Ports" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatSeen(row.original.last_seen_at)}
|
||||
<span className="font-mono text-muted-foreground text-xs">
|
||||
{formatPorts(row.original.ports)}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Last seen' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
meta: { headerTitle: 'Ports' },
|
||||
})
|
||||
}
|
||||
cols.push({
|
||||
accessorKey: 'last_seen_at',
|
||||
id: 'last_seen_at',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Last seen" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatSeen(row.original.last_seen_at)}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Last seen' },
|
||||
})
|
||||
return cols
|
||||
}, [packetsTitle, showPorts])
|
||||
|
||||
const data = q.data?.items ?? []
|
||||
const table = useReactTable({
|
||||
@@ -117,20 +148,10 @@ export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) {
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Blocked IPs</FrameTitle>
|
||||
<FrameDescription>
|
||||
Drop-пакеты по записям deny (nft/ipset). Top по накопленным packets.
|
||||
</FrameDescription>
|
||||
<FrameDescription>{description}</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
{isMikrotik ? (
|
||||
<EmptyState
|
||||
icon={RouterIcon}
|
||||
title="Per-IP недоступен на MikroTik"
|
||||
description="У address-list в RouterOS нет counters по записи. Доступны только суммарные Traffic ↓/↑ с filter-правил."
|
||||
centered={false}
|
||||
className="py-8"
|
||||
/>
|
||||
) : q.isLoading ? (
|
||||
{q.isLoading ? (
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
@@ -148,7 +169,11 @@ export function AgentBlockedIps({ agentId, platform }: AgentBlockedIpsProps) {
|
||||
<EmptyState
|
||||
icon={BanIcon}
|
||||
title="Пока нет hit’ов"
|
||||
description="Когда deny-префиксы начнут дропать пакеты, здесь появятся IP/CIDR с counters."
|
||||
description={
|
||||
isMikrotik
|
||||
? 'Когда трафик попадёт в EVOFW_DENY, src появится в EVOFW_HITS и здесь после sync.'
|
||||
: 'Когда deny-префиксы начнут дропать пакеты, здесь появятся IP/CIDR с counters.'
|
||||
}
|
||||
centered={false}
|
||||
className="py-8"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
} from '@tanstack/react-table'
|
||||
import { NetworkIcon } from 'lucide-react'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { agentBlockedPortsQueryOptions } from '@/queries'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
|
||||
/**
|
||||
* Aggregate destination ports hit by denied sources (Linux nft).
|
||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
||||
* · https://reui.io/preview/base/empty-state-12
|
||||
*/
|
||||
|
||||
export type BlockedPortRow = {
|
||||
port: number
|
||||
protocol: string
|
||||
packets: number
|
||||
last_seen_at: string
|
||||
}
|
||||
|
||||
type AgentBlockedPortsProps = {
|
||||
agentId: string
|
||||
}
|
||||
|
||||
const packetFmt = new Intl.NumberFormat('ru-RU')
|
||||
const seenFmt = new Intl.DateTimeFormat('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
|
||||
function formatSeen(iso: string): string {
|
||||
const t = Date.parse(iso)
|
||||
if (Number.isNaN(t)) return '—'
|
||||
return seenFmt.format(t)
|
||||
}
|
||||
|
||||
export function AgentBlockedPorts({ agentId }: AgentBlockedPortsProps) {
|
||||
const q = useQuery(agentBlockedPortsQueryOptions(agentId))
|
||||
|
||||
const columns = useMemo<ColumnDef<BlockedPortRow>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'port',
|
||||
id: 'port',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Port" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs tabular-nums">
|
||||
{row.original.port}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Port' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'protocol',
|
||||
id: 'protocol',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Proto" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs uppercase">
|
||||
{row.original.protocol}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Proto' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'packets',
|
||||
id: 'packets',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Packets" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="tabular-nums">
|
||||
{packetFmt.format(row.original.packets)}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Packets' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'last_seen_at',
|
||||
id: 'last_seen_at',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Last seen" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs tabular-nums">
|
||||
{formatSeen(row.original.last_seen_at)}
|
||||
</span>
|
||||
),
|
||||
meta: { headerTitle: 'Last seen' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const data = q.data?.items ?? []
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: (r) => `${r.protocol}/${r.port}`,
|
||||
})
|
||||
|
||||
return (
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Top ports</FrameTitle>
|
||||
<FrameDescription>
|
||||
Destination ports (tcp/udp), в которые слали запросы blocked IP. nft
|
||||
dynamic set deny_port_hits.
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="p-0">
|
||||
{q.isLoading ? (
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-2/3" />
|
||||
</div>
|
||||
) : q.isError ? (
|
||||
<EmptyState
|
||||
icon={NetworkIcon}
|
||||
title="Не удалось загрузить"
|
||||
description={q.error?.message ?? 'Ошибка API'}
|
||||
centered={false}
|
||||
className="py-8"
|
||||
/>
|
||||
) : data.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={NetworkIcon}
|
||||
title="Пока нет port hit’ов"
|
||||
description="Нужен nft + deny_port_hits. После drop с deny появятся tcp/udp dport. Re-run install-ссылки обновляет правила."
|
||||
centered={false}
|
||||
className="py-8"
|
||||
/>
|
||||
) : (
|
||||
<DataGrid
|
||||
table={table}
|
||||
recordCount={data.length}
|
||||
tableLayout={{ dense: true }}
|
||||
>
|
||||
<DataGridTable />
|
||||
</DataGrid>
|
||||
)}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -34,6 +34,10 @@ import { AgentPolicyTrace } from '@/components/agents/agent-policy-trace'
|
||||
import { AgentFactsPanel } from '@/components/agents/agent-facts-panel'
|
||||
import { AgentEffectiveCidrs } from '@/components/agents/agent-effective-cidrs'
|
||||
import { AgentBlockedIps } from '@/components/agents/agent-blocked-ips'
|
||||
import { AgentBlockedPorts } from '@/components/agents/agent-blocked-ports'
|
||||
import { AgentHostFirewall } from '@/components/agents/agent-host-firewall'
|
||||
import { AgentPortAcl } from '@/components/agents/agent-port-acl'
|
||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||
import {
|
||||
AgentCloneSetsSheet,
|
||||
AgentOverrideSheet,
|
||||
@@ -46,6 +50,7 @@ import { apiFetch } from '@/lib/api'
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
||||
import { Button } from '@evofw/ui/components/button'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
import { TabsContent } from '@evofw/ui/components/tabs'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -74,6 +79,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
const installRef = useRef<HTMLDivElement>(null)
|
||||
const [overrideOpen, setOverrideOpen] = useState(false)
|
||||
const [cloneOpen, setCloneOpen] = useState(false)
|
||||
const [fwTab, setFwTab] = useState('host')
|
||||
|
||||
const revoke = useMutation({
|
||||
mutationFn: () =>
|
||||
@@ -104,6 +110,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'stats'] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'blocked-ips'] })
|
||||
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'blocked-ports'] })
|
||||
void qc.invalidateQueries({ queryKey: ['stats'] })
|
||||
void qc.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
},
|
||||
@@ -320,7 +327,38 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
|
||||
isLoading={previewQ.isLoading}
|
||||
/>
|
||||
|
||||
<AgentBlockedIps agentId={agentId} platform={a.platform} />
|
||||
{a.platform === 'linux' ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<CountedLineTabs
|
||||
value={fwTab}
|
||||
onValueChange={setFwTab}
|
||||
tabs={[
|
||||
{ id: 'host', label: 'Host firewall' },
|
||||
{ id: 'acl', label: 'Port ACL' },
|
||||
{ id: 'hits', label: 'Blocked' },
|
||||
]}
|
||||
>
|
||||
<TabsContent value="host" className="mt-3">
|
||||
<AgentHostFirewall agentId={agentId} />
|
||||
</TabsContent>
|
||||
<TabsContent value="acl" className="mt-3">
|
||||
<AgentPortAcl agentId={agentId} />
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="hits"
|
||||
className="mt-3 flex flex-col gap-4"
|
||||
>
|
||||
<AgentBlockedPorts agentId={agentId} />
|
||||
<AgentBlockedIps
|
||||
agentId={agentId}
|
||||
platform={a.platform}
|
||||
/>
|
||||
</TabsContent>
|
||||
</CountedLineTabs>
|
||||
</div>
|
||||
) : (
|
||||
<AgentBlockedIps agentId={agentId} platform={a.platform} />
|
||||
)}
|
||||
</div>
|
||||
</DetailPanel.Section>
|
||||
</DetailPanel>
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
type ColumnDef,
|
||||
} from '@tanstack/react-table'
|
||||
import { ShieldIcon } from 'lucide-react'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { EmptyState } from '@/components/empty-state'
|
||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||
import {
|
||||
agentHostFirewallQueryOptions,
|
||||
type HostFwRuleDto,
|
||||
type HostListenerDto,
|
||||
} from '@/queries'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@evofw/ui/components/select'
|
||||
import { TabsContent } from '@evofw/ui/components/tabs'
|
||||
|
||||
/**
|
||||
* Observed host firewall + listeners (Linux).
|
||||
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
||||
* · https://reui.io/preview/base/empty-state-12
|
||||
*/
|
||||
|
||||
type AgentHostFirewallProps = {
|
||||
agentId: string
|
||||
}
|
||||
|
||||
export function AgentHostFirewall({ agentId }: AgentHostFirewallProps) {
|
||||
const q = useQuery(agentHostFirewallQueryOptions(agentId))
|
||||
const [tab, setTab] = useState('rules')
|
||||
const [ownership, setOwnership] = useState<'all' | 'evofw' | 'foreign'>('all')
|
||||
const [backend, setBackend] = useState<string>('all')
|
||||
|
||||
const rules = useMemo(() => {
|
||||
let items = q.data?.rules ?? []
|
||||
if (ownership !== 'all') {
|
||||
items = items.filter((r) => r.ownership === ownership)
|
||||
}
|
||||
if (backend !== 'all') {
|
||||
items = items.filter((r) => r.backend === backend)
|
||||
}
|
||||
return items
|
||||
}, [q.data?.rules, ownership, backend])
|
||||
|
||||
const listeners = q.data?.listeners ?? []
|
||||
const backends = useMemo(() => {
|
||||
const s = new Set((q.data?.rules ?? []).map((r) => r.backend))
|
||||
return Array.from(s).sort()
|
||||
}, [q.data?.rules])
|
||||
|
||||
const ruleCols = useMemo<ColumnDef<HostFwRuleDto>[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'ownership',
|
||||
accessorKey: 'ownership',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Owner" />
|
||||
),
|
||||
cell: ({ row }) =>
|
||||
row.original.ownership === 'evofw' ? (
|
||||
<Badge variant="success" size="sm">
|
||||
EvoFW
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" size="sm">
|
||||
foreign
|
||||
</Badge>
|
||||
),
|
||||
meta: { headerTitle: 'Owner' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'backend',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Backend" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs">{row.original.backend}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'chain',
|
||||
accessorFn: (r) => r.chain || r.table || '—',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Chain" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-muted-foreground text-xs">
|
||||
{row.original.chain || row.original.table || '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'action',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Action" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-xs">{row.original.action || '—'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'ports',
|
||||
accessorFn: (r) =>
|
||||
[r.protocol, r.dport].filter(Boolean).join('/') || '—',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Proto/Port" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs tabular-nums">
|
||||
{[row.original.protocol, row.original.dport]
|
||||
.filter(Boolean)
|
||||
.join('/') || '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'saddr',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Src" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs">
|
||||
{row.original.saddr || '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'raw',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Raw" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span
|
||||
className="text-muted-foreground block max-w-[280px] truncate font-mono text-[11px]"
|
||||
title={row.original.raw}
|
||||
>
|
||||
{row.original.raw}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const listenerCols = useMemo<ColumnDef<HostListenerDto>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'protocol',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Proto" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs uppercase">
|
||||
{row.original.protocol}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'port',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Port" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs tabular-nums">
|
||||
{row.original.port}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'address',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Address" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs">{row.original.address}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'process',
|
||||
header: ({ column }) => (
|
||||
<DataGridColumnHeader column={column} title="Process" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{row.original.process || '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const rulesTable = useReactTable({
|
||||
data: rules,
|
||||
columns: ruleCols,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: (r, i) => `${r.backend}-${r.chain}-${i}-${r.raw.slice(0, 40)}`,
|
||||
})
|
||||
const listenersTable = useReactTable({
|
||||
data: listeners,
|
||||
columns: listenerCols,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: (r, i) => `${r.protocol}-${r.address}-${r.port}-${i}`,
|
||||
})
|
||||
|
||||
return (
|
||||
<Frame dense spacing="sm">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Host firewall</FrameTitle>
|
||||
<FrameDescription>
|
||||
Снимок nft/iptables/ufw/firewalld + listeners. EvoFW vs foreign.
|
||||
{q.data?.collected_at
|
||||
? ` Обновлено: ${new Date(q.data.collected_at).toLocaleString('ru-RU')}`
|
||||
: ' Пока нет снимка — дождитесь sync агента.'}
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="flex flex-col gap-3 p-4">
|
||||
<CountedLineTabs
|
||||
value={tab}
|
||||
onValueChange={setTab}
|
||||
tabs={[
|
||||
{ id: 'rules', label: 'Rules', count: rules.length },
|
||||
{ id: 'listeners', label: 'Listeners', count: listeners.length },
|
||||
]}
|
||||
>
|
||||
<TabsContent value="rules" className="mt-3 flex flex-col gap-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Select
|
||||
value={ownership}
|
||||
onValueChange={(v) => {
|
||||
if (v) setOwnership(v as typeof ownership)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Owner" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All owners</SelectItem>
|
||||
<SelectItem value="evofw">EvoFW</SelectItem>
|
||||
<SelectItem value="foreign">Foreign</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={backend}
|
||||
onValueChange={(v) => {
|
||||
if (v) setBackend(v)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Backend" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All backends</SelectItem>
|
||||
{backends.map((b) => (
|
||||
<SelectItem key={b} value={b}>
|
||||
{b}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{q.isLoading ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
) : rules.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={ShieldIcon}
|
||||
title="Нет правил в снимке"
|
||||
description="После sync Linux-агент пришлёт host_firewall."
|
||||
centered={false}
|
||||
className="py-6"
|
||||
/>
|
||||
) : (
|
||||
<DataGrid
|
||||
table={rulesTable}
|
||||
recordCount={rules.length}
|
||||
tableLayout={{ dense: true }}
|
||||
>
|
||||
<DataGridTable />
|
||||
</DataGrid>
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="listeners" className="mt-3">
|
||||
{q.isLoading ? (
|
||||
<Skeleton className="h-8 w-full" />
|
||||
) : listeners.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={ShieldIcon}
|
||||
title="Нет listeners"
|
||||
description="ss -lntu не вернул сокеты или снимок пуст."
|
||||
centered={false}
|
||||
className="py-6"
|
||||
/>
|
||||
) : (
|
||||
<DataGrid
|
||||
table={listenersTable}
|
||||
recordCount={listeners.length}
|
||||
tableLayout={{ dense: true }}
|
||||
>
|
||||
<DataGridTable />
|
||||
</DataGrid>
|
||||
)}
|
||||
</TabsContent>
|
||||
</CountedLineTabs>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Router, Terminal } from 'lucide-react'
|
||||
import { cn } from '@evofw/ui/lib/utils'
|
||||
import { Item, ItemMedia } from '@evofw/ui/components/item'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
|
||||
type AgentPlatformIconProps = {
|
||||
platform: string
|
||||
@@ -9,7 +9,8 @@ type AgentPlatformIconProps = {
|
||||
|
||||
/**
|
||||
* KPI-style platform tile for agent rows.
|
||||
* Preview DNA: https://reui.io/preview/base/stats-12 · settings-14
|
||||
* Preview DNA: https://reui.io/preview/base/stats-12
|
||||
* Primitive: https://reui.io/docs/components/base/icon-tile
|
||||
*/
|
||||
export function AgentPlatformIcon({
|
||||
platform,
|
||||
@@ -18,18 +19,17 @@ export function AgentPlatformIcon({
|
||||
const isMt = platform === 'mikrotik'
|
||||
const Icon = isMt ? Router : Terminal
|
||||
return (
|
||||
<Item
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
aria-label={isMt ? 'MikroTik' : 'Linux'}
|
||||
className={cn(
|
||||
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
||||
'size-10.5',
|
||||
isMt ? 'text-info' : 'text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
aria-label={isMt ? 'MikroTik' : 'Linux'}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
<Icon aria-hidden />
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<Icon aria-hidden />
|
||||
</IconTile>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -27,8 +27,8 @@ interface EmptyStateProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* ReUI Empty + IconStack — adapted from empty-state-12.
|
||||
* @see https://reui.io/preview/base/empty-state-12
|
||||
* ReUI Empty + IconStack — Frame-friendly empty-state-14 / empty-state-12.
|
||||
* Preview: https://reui.io/preview/base/empty-state-14 · https://reui.io/preview/base/empty-state-12
|
||||
*/
|
||||
export function EmptyState({
|
||||
icon: Icon = InboxIcon,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { SiteHeader } from '@/components/layout/site-header'
|
||||
import { SearchMenu } from '@/components/layout/search-menu'
|
||||
import { TooltipProvider } from '@evofw/ui/components/tooltip'
|
||||
import { SidebarInset, SidebarProvider } from '@evofw/ui/components/sidebar'
|
||||
import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface'
|
||||
|
||||
/** Shared ops chrome — etalon EvoBGP. @see docs/ui-design-contract.md */
|
||||
export function AppShell({ children }: { children: ReactNode }) {
|
||||
@@ -16,12 +17,15 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
|
||||
К содержимому
|
||||
</a>
|
||||
<AppSidebar />
|
||||
<SidebarInset>
|
||||
<SidebarInset id="main-content">
|
||||
<SiteHeader />
|
||||
<main className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||||
<div className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
<SearchMenu hotkeyOnly />
|
||||
</SidebarProvider>
|
||||
|
||||
@@ -35,7 +35,11 @@ export function QueryState({
|
||||
children,
|
||||
}: QueryStateProps) {
|
||||
if (isLoading) {
|
||||
return skeleton ?? <DefaultSkeleton />
|
||||
return (
|
||||
<div role="status" aria-live="polite" aria-busy="true">
|
||||
{skeleton ?? <DefaultSkeleton />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Frame, FramePanel } from '@/components/reui/frame'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { cn } from '@evofw/ui/lib/utils'
|
||||
import { kpiCols } from './kpi-cols'
|
||||
import { Item, ItemMedia } from '@evofw/ui/components/item'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||
|
||||
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
|
||||
@@ -83,16 +83,13 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
||||
return (
|
||||
<div className="relative z-10 flex h-full items-start gap-3">
|
||||
{item.icon ? (
|
||||
<Item
|
||||
className={cn(
|
||||
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
||||
item.iconClassName ?? DEFAULT_ICON_CLASS,
|
||||
)}
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
aria-hidden="true"
|
||||
className={cn('size-10.5', item.iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{item.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
{item.icon}
|
||||
</IconTile>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { Item, ItemMedia } from '@evofw/ui/components/item'
|
||||
import { IconTile } from '@/components/reui/icon-tile'
|
||||
import { cn } from '@evofw/ui/lib/utils'
|
||||
import { kpiCols } from './kpi-cols'
|
||||
|
||||
@@ -39,16 +39,13 @@ function QuickActionBody({ action }: { action: QuickActionItem }) {
|
||||
return (
|
||||
<div className="relative z-10 flex h-full items-start gap-3">
|
||||
{action.icon ? (
|
||||
<Item
|
||||
className={cn(
|
||||
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
||||
action.iconClassName ?? DEFAULT_ICON_CLASS,
|
||||
)}
|
||||
<IconTile
|
||||
variant="elevated"
|
||||
aria-hidden="true"
|
||||
className={cn('size-10.5', action.iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{action.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
{action.icon}
|
||||
</IconTile>
|
||||
) : null}
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@evofw/ui/lib/utils"
|
||||
|
||||
/**
|
||||
* CSS variable architecture:
|
||||
*
|
||||
* The root owns four variables so every part of the tile stays in proportion
|
||||
* and stays overridable from a single `className`:
|
||||
*
|
||||
* --icon-tile-size tile width/height
|
||||
* --icon-tile-icon-size glyph size applied to child svgs
|
||||
* --icon-tile-radius corner radius (also drives the nested inner card)
|
||||
* --icon-tile-inset gap between the outer ring and the inner card
|
||||
*
|
||||
* The `frame` and `soft` variants paint their inner card with an `::after`
|
||||
* pseudo element instead of a wrapper node. `isolate` makes the root a stacking
|
||||
* context, so the negative z-index pseudo paints above the root background but
|
||||
* below the in-flow icon - no extra DOM, and `render` composition keeps working.
|
||||
*
|
||||
* Tone: `soft` and `solid` derive every fill and border from `currentColor`, so
|
||||
* a single text color class (e.g. `text-success`) retints the whole tile. They
|
||||
* default to `text-primary`; override it to recolor without touching internals.
|
||||
*/
|
||||
const iconTileVariants = cva(
|
||||
[
|
||||
"relative inline-flex shrink-0 items-center justify-center align-middle",
|
||||
"size-(--icon-tile-size) rounded-(--icon-tile-radius)",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-(--icon-tile-icon-size)",
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
/** Plain bordered surface. The quiet default for list rows and toolbars. */
|
||||
outline: "border border-border bg-background dark:bg-input/32",
|
||||
/** Raised muted fill with a background-colored ring. Reads as a physical chip. */
|
||||
elevated:
|
||||
"border-2 border-background bg-muted text-accent-foreground shadow-[0_1px_3px_0_rgb(0_0_0/0.14)] dark:border",
|
||||
/**
|
||||
* Tinted double container: an opacity-filled outer ring with no border
|
||||
* around a bordered inner card, all derived from `currentColor`. The
|
||||
* quiet, colorful sibling of `frame`. Retint with a text color class.
|
||||
*/
|
||||
soft: [
|
||||
"isolate p-(--icon-tile-inset) text-primary bg-current/10",
|
||||
"after:absolute after:-z-10 after:inset-(--icon-tile-inset)",
|
||||
"after:rounded-[calc(var(--icon-tile-radius)-var(--icon-tile-inset))]",
|
||||
"after:border after:border-current/20 after:bg-current/5",
|
||||
],
|
||||
/** Filled tone with a contrasting glyph. Retint with `bg-*` + a text color. */
|
||||
solid: "bg-primary text-primary-foreground",
|
||||
/** Double container - a muted ring around an inset card, matching Frame. */
|
||||
frame: [
|
||||
"isolate border border-border bg-muted/50 p-(--icon-tile-inset)",
|
||||
"after:absolute after:-z-10 after:inset-(--icon-tile-inset)",
|
||||
"after:rounded-[calc(var(--icon-tile-radius)-var(--icon-tile-inset))]",
|
||||
"after:border after:border-border after:bg-card after:shadow-xs",
|
||||
],
|
||||
},
|
||||
size: {
|
||||
xs: "[--icon-tile-size:--spacing(6)] [--icon-tile-icon-size:--spacing(3.5)] [--icon-tile-inset:--spacing(0.5)]",
|
||||
sm: "[--icon-tile-size:--spacing(8)] [--icon-tile-icon-size:--spacing(4)] [--icon-tile-inset:--spacing(0.5)]",
|
||||
default:
|
||||
"[--icon-tile-size:--spacing(10)] [--icon-tile-icon-size:--spacing(4.5)] [--icon-tile-inset:--spacing(0.75)]",
|
||||
lg: "[--icon-tile-size:--spacing(12)] [--icon-tile-icon-size:--spacing(5.5)] [--icon-tile-inset:--spacing(0.75)]",
|
||||
xl: "[--icon-tile-size:--spacing(14)] [--icon-tile-icon-size:--spacing(7)] [--icon-tile-inset:--spacing(1)]",
|
||||
},
|
||||
/**
|
||||
* `default`: active style radius. `full`: circular.
|
||||
* The plain value is the fallback outside a style scope; the `style-*`
|
||||
* tokens win by specificity inside one, and survive the registry
|
||||
* transform as the single resolved value per generated style.
|
||||
*
|
||||
* Each style token is clamped to a fraction of the tile. A flat radius
|
||||
* is a circle once it reaches half the box, so the soft styles used to
|
||||
* render `xs` (24px) and `sm` (32px) as plain circles and swallow the
|
||||
* `full` variant's meaning. Clamping keeps one corner ratio at every
|
||||
* size instead, so a tile still reads as the same shape when it scales.
|
||||
* Luma and Rhea take the gentler quarter ratio; Maia stays at a third.
|
||||
*/
|
||||
radius: {
|
||||
default:
|
||||
"[--icon-tile-radius:min(var(--radius-md),calc(var(--icon-tile-size)/3))] [--icon-tile-radius:min(var(--radius-md),calc(var(--icon-tile-size)/3))]",
|
||||
full: "[--icon-tile-radius:calc(infinity*1px)]",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "outline",
|
||||
size: "default",
|
||||
radius: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface IconTileProps extends useRender.ComponentProps<"span"> {
|
||||
variant?: VariantProps<typeof iconTileVariants>["variant"]
|
||||
size?: VariantProps<typeof iconTileVariants>["size"]
|
||||
radius?: VariantProps<typeof iconTileVariants>["radius"]
|
||||
}
|
||||
|
||||
function IconTile({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "default",
|
||||
radius = "default",
|
||||
render,
|
||||
...props
|
||||
}: IconTileProps) {
|
||||
const defaultProps = {
|
||||
"data-slot": "icon-tile",
|
||||
"data-variant": variant,
|
||||
"data-size": size,
|
||||
className: cn(iconTileVariants({ variant, size, radius, className })),
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
render,
|
||||
props: mergeProps<"span">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
export { IconTile, iconTileVariants, type IconTileProps }
|
||||
@@ -1,2 +1,6 @@
|
||||
/** Project surface lock — ops screens use ReUI Frame only. */
|
||||
export const UI_SURFACE = 'frame' as const
|
||||
|
||||
/** Skip link — keyboard: first Tab. */
|
||||
export const SKIP_TO_CONTENT_CLASS =
|
||||
'bg-background text-foreground ring-ring sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:rounded-md focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-sm focus:ring-2'
|
||||
@@ -175,10 +175,91 @@ export const agentBlockedIpsQueryOptions = (id: string) =>
|
||||
packets: number
|
||||
first_seen_at: string
|
||||
last_seen_at: string
|
||||
ports?: {
|
||||
port: number
|
||||
protocol: string
|
||||
packets: number
|
||||
}[]
|
||||
}[]
|
||||
}>(`/api/v1/agents/${id}/blocked-ips`),
|
||||
})
|
||||
|
||||
export const agentBlockedPortsQueryOptions = (id: string) =>
|
||||
queryOptions({
|
||||
queryKey: ['agents', id, 'blocked-ports'],
|
||||
queryFn: () =>
|
||||
apiFetch<{
|
||||
items: {
|
||||
port: number
|
||||
protocol: string
|
||||
packets: number
|
||||
last_seen_at: string
|
||||
}[]
|
||||
}>(`/api/v1/agents/${id}/blocked-ports`),
|
||||
})
|
||||
|
||||
export type AgentPortRuleDto = {
|
||||
id: string
|
||||
agent_id: string
|
||||
action: 'open' | 'close'
|
||||
protocol: 'tcp' | 'udp' | 'both'
|
||||
port_start: number
|
||||
port_end: number
|
||||
src_kind: 'all' | 'cidr' | 'list'
|
||||
src_cidr?: string | null
|
||||
list_id?: string | null
|
||||
list_name?: string | null
|
||||
enabled: boolean
|
||||
comment?: string | null
|
||||
priority: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export const agentPortRulesQueryOptions = (id: string) =>
|
||||
queryOptions({
|
||||
queryKey: ['agents', id, 'port-rules'],
|
||||
queryFn: () =>
|
||||
apiFetch<{ items: AgentPortRuleDto[] }>(
|
||||
`/api/v1/agents/${id}/port-rules`,
|
||||
),
|
||||
})
|
||||
|
||||
export type HostFwRuleDto = {
|
||||
ownership: 'evofw' | 'foreign'
|
||||
backend: string
|
||||
table?: string
|
||||
chain?: string
|
||||
action?: string
|
||||
protocol?: string
|
||||
dport?: string
|
||||
sport?: string
|
||||
saddr?: string
|
||||
daddr?: string
|
||||
comment?: string
|
||||
raw: string
|
||||
}
|
||||
|
||||
export type HostListenerDto = {
|
||||
protocol: string
|
||||
port: number
|
||||
address: string
|
||||
process?: string
|
||||
}
|
||||
|
||||
export const agentHostFirewallQueryOptions = (id: string) =>
|
||||
queryOptions({
|
||||
queryKey: ['agents', id, 'host-firewall'],
|
||||
queryFn: () =>
|
||||
apiFetch<{
|
||||
collected_at: string | null
|
||||
raw_digest: string | null
|
||||
rules: HostFwRuleDto[]
|
||||
listeners: HostListenerDto[]
|
||||
}>(`/api/v1/agents/${id}/host-firewall`),
|
||||
refetchInterval: 60_000,
|
||||
})
|
||||
|
||||
export const recentStatsQueryOptions = () =>
|
||||
queryOptions({
|
||||
queryKey: ['stats-recent'],
|
||||
|
||||
@@ -18,8 +18,9 @@ import { Input } from '@evofw/ui/components/input'
|
||||
import { Switch } from '@evofw/ui/components/switch'
|
||||
|
||||
/**
|
||||
* Control plane settings — Frame + SettingRow.
|
||||
* Preview: https://reui.io/preview/base/settings-16 · settings-3
|
||||
* Control plane settings — single page: PageShell + Frame + SettingRow.
|
||||
* SettingsShell (reui-kit) — только при 2+ секциях.
|
||||
* Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-3
|
||||
*/
|
||||
|
||||
export const Route = createFileRoute('/_auth/settings')({
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/** @type {import('@commitlint/types').UserConfig} */
|
||||
module.exports = {
|
||||
extends: ['@commitlint/config-conventional'],
|
||||
plugins: [
|
||||
{
|
||||
rules: {
|
||||
'scope-no-commas': ({ scope }) => {
|
||||
if (scope && scope.includes(',')) {
|
||||
return [
|
||||
false,
|
||||
'scope must not contain commas (semantic-release will not parse the commit type)'
|
||||
];
|
||||
}
|
||||
return [true];
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
rules: {
|
||||
// Subject после «:» на русском — не английский sentence-case.
|
||||
'subject-case': [0],
|
||||
'scope-no-commas': [2, 'always']
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
services:
|
||||
evofirewall:
|
||||
image: git.shts.su/denozord/evofirewall:latest
|
||||
# evofw и evofirewall — один образ (алиас для drop-in).
|
||||
image: git.shx.one/denozord/evofw:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DATABASE_URL: sqlite:/data/app.db
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Docker-образы EvoFirewall
|
||||
|
||||
## CI (Gitea Actions)
|
||||
|
||||
Сборка образов — **`docker buildx bake`** ([docker-bake.hcl](docker-bake.hcl)), не отдельные `docker build`.
|
||||
|
||||
**Publish:** push в `main` после quality gates — workflow [CD](../../.gitea/workflows/cd.yaml) job **publish**: semantic-release + зеркало base-образов + bake с `VERSION` из релиза.
|
||||
|
||||
Bake читает переменные из **окружения** (`REGISTRY`, `IMAGE_TAG`, `CACHE_REF_NODE`, `BASE_NODE`). Скрипт [write-bake-override.sh](write-bake-override.sh) — опциональный helper для локальной отладки.
|
||||
|
||||
BuildKit кэширует pnpm store через `RUN --mount=type=cache`. На CI mounts живут, пока named builder `evofw` не удаляют (`cleanup: false`).
|
||||
|
||||
### Слои и runtime
|
||||
|
||||
| Образ | Runtime base | Заметка |
|
||||
|-------|----------------|---------|
|
||||
| `evofw`, `evofirewall` | `node:22-alpine` | один процесс: API + SPA + SQLite; `better-sqlite3` собирается в alpine на stage `build` |
|
||||
|
||||
`python3` / `make` / `g++` только в `deps`/`build`. Context режется корневым [.dockerignore](../../.dockerignore).
|
||||
|
||||
Теги `evofw` и `evofirewall` — один и тот же образ (`evofirewall` — drop-in для старого compose).
|
||||
|
||||
### Кэш registry
|
||||
|
||||
- `git.shx.one/<owner>/evofw-buildcache:node-buildcache` — **запись** только из `node-build`
|
||||
- `git.shx.one/<owner>/evofw-buildcache:base-node-22-alpine` — зеркало FROM с Docker Hub. Только `linux/amd64`; skip если тег уже в Gitea. Неуспешный copy не валит CD — bake берёт Docker Hub FROM.
|
||||
|
||||
`pull = false` в bake: не перекачивать FROM, если слой уже в builder. Не запускайте `docker system prune -a` на runner.
|
||||
|
||||
Параллельный `cache-to` в один ref ломает manifest (`content descriptor … not found`).
|
||||
|
||||
Если CI падает на «not found» после смены схемы кэша — один раз удалите теги `evofw-buildcache:*` в registry и пересоберите.
|
||||
|
||||
## Локальная сборка
|
||||
|
||||
Из корня репозитория:
|
||||
|
||||
```bash
|
||||
cd deploy/docker
|
||||
export REGISTRY=git.shx.one/<owner>
|
||||
export IMAGE_TAG=latest
|
||||
export SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
export VERSION=dev
|
||||
export BUILD_TIME=
|
||||
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl default
|
||||
```
|
||||
|
||||
Проверка манифеста без сборки:
|
||||
|
||||
```bash
|
||||
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl --print default
|
||||
```
|
||||
|
||||
В `docker-bake.hcl`: `context = "../.."` резолвится **от cwd** (каталог `deploy/docker/`). `dockerfile` — путь от этого context (корень репо). Из корня репо не вызывать bake с `-f deploy/docker/docker-bake.hcl`: получится `lstat ../../deploy`. Можно задать `BUILDX_BAKE_FILE_RELATIVE_PATHS=1`.
|
||||
@@ -0,0 +1,117 @@
|
||||
# Единая сборка образа EvoFirewall (buildx bake: -f deploy/docker/docker-bake.hcl).
|
||||
# context = "../.." — корень репо. Bake резолвит context от cwd (не от HCL),
|
||||
# поэтому вызов из deploy/docker/ либо BUILDX_BAKE_FILE_RELATIVE_PATHS=1.
|
||||
|
||||
variable "REGISTRY" {
|
||||
default = "git.shx.one/denozord"
|
||||
}
|
||||
|
||||
variable "IMAGE_TAG" {
|
||||
default = "latest"
|
||||
}
|
||||
|
||||
variable "SHORT_SHA" {
|
||||
default = "dev"
|
||||
}
|
||||
|
||||
variable "SHA_FULL" {
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "VERSION" {
|
||||
default = "dev"
|
||||
}
|
||||
|
||||
variable "BUILD_TIME" {
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "CACHE_REF_NODE" {
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "BASE_NODE" {
|
||||
default = "docker.io/library/node:22-alpine"
|
||||
}
|
||||
|
||||
function "node-cache-from" {
|
||||
params = []
|
||||
result = notequal("", CACHE_REF_NODE) ? ["type=registry,ref=${CACHE_REF_NODE}"] : []
|
||||
}
|
||||
|
||||
# Экспорт кэша — один writer на ref (параллельный cache-to в один ref ломает manifest).
|
||||
function "node-cache-to-export" {
|
||||
params = []
|
||||
result = notequal("", CACHE_REF_NODE) ? ["type=registry,ref=${CACHE_REF_NODE},mode=max"] : []
|
||||
}
|
||||
|
||||
function "image-tags" {
|
||||
params = [name]
|
||||
result = concat(
|
||||
notequal("", SHA_FULL) ? [
|
||||
"${REGISTRY}/${name}:${IMAGE_TAG}",
|
||||
"${REGISTRY}/${name}:${SHORT_SHA}",
|
||||
"${REGISTRY}/${name}:sha-${SHA_FULL}",
|
||||
] : [
|
||||
"${REGISTRY}/${name}:${IMAGE_TAG}",
|
||||
"${REGISTRY}/${name}:${SHORT_SHA}",
|
||||
],
|
||||
notequal(VERSION, "dev") && notequal(VERSION, "") ? [
|
||||
"${REGISTRY}/${name}:v${VERSION}",
|
||||
"${REGISTRY}/${name}:${VERSION}",
|
||||
] : []
|
||||
)
|
||||
}
|
||||
|
||||
group "default" {
|
||||
targets = ["evofw"]
|
||||
}
|
||||
|
||||
target "_common" {
|
||||
platforms = ["linux/amd64"]
|
||||
pull = false
|
||||
}
|
||||
|
||||
target "node-deps" {
|
||||
inherits = ["_common"]
|
||||
context = "../.."
|
||||
dockerfile = "deploy/docker/evofw/Dockerfile"
|
||||
target = "deps"
|
||||
args = {
|
||||
BASE_NODE = BASE_NODE
|
||||
}
|
||||
cache-from = node-cache-from()
|
||||
}
|
||||
|
||||
target "node-build" {
|
||||
inherits = ["_common"]
|
||||
context = "../.."
|
||||
dockerfile = "deploy/docker/evofw/Dockerfile"
|
||||
target = "build"
|
||||
args = {
|
||||
BASE_NODE = BASE_NODE
|
||||
}
|
||||
contexts = {
|
||||
deps = "target:node-deps"
|
||||
}
|
||||
cache-from = node-cache-from()
|
||||
cache-to = node-cache-to-export()
|
||||
}
|
||||
|
||||
target "evofw" {
|
||||
inherits = ["_common"]
|
||||
context = "../.."
|
||||
dockerfile = "deploy/docker/evofw/Dockerfile"
|
||||
target = "runtime"
|
||||
args = {
|
||||
BASE_NODE = BASE_NODE
|
||||
VERSION = VERSION
|
||||
GIT_SHA = notequal("", SHA_FULL) ? SHA_FULL : SHORT_SHA
|
||||
BUILD_TIME = BUILD_TIME
|
||||
}
|
||||
contexts = {
|
||||
build = "target:node-build"
|
||||
}
|
||||
cache-from = node-cache-from()
|
||||
tags = concat(image-tags("evofw"), image-tags("evofirewall"))
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
# All-in-one EvoFirewall (Fastify + SPA + SQLite). Analog evobgp-all.
|
||||
# Install slugs GET /{slug} stay on the same process — do not split nginx.
|
||||
ARG BASE_NODE=docker.io/library/node:22-alpine
|
||||
|
||||
FROM ${BASE_NODE} AS deps
|
||||
WORKDIR /repo
|
||||
RUN apk add --no-cache python3 make g++ \
|
||||
&& corepack enable && corepack prepare pnpm@10.12.1 --activate
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml turbo.json ./
|
||||
COPY apps/web/package.json apps/web/
|
||||
COPY apps/api/package.json apps/api/
|
||||
COPY packages/ui/package.json packages/ui/
|
||||
COPY packages/shared/package.json packages/shared/
|
||||
COPY packages/db/package.json packages/db/
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
FROM deps AS build
|
||||
COPY apps/web apps/web
|
||||
COPY apps/api apps/api
|
||||
COPY packages/ui packages/ui
|
||||
COPY packages/shared packages/shared
|
||||
COPY packages/db packages/db
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \
|
||||
pnpm turbo build --filter=@evofw/web --filter=@evofw/api \
|
||||
&& pnpm --filter @evofw/api deploy --prod /out \
|
||||
&& cp -r apps/web/dist /out/static \
|
||||
&& mkdir -p /out/dist/agent-scripts \
|
||||
&& cp -r apps/api/src/agent-scripts/* /out/dist/agent-scripts/ \
|
||||
&& rm -rf /out/src /out/test /out/.turbo \
|
||||
&& rm -rf /out/node_modules/@evofw/db/src /out/node_modules/@evofw/db/.turbo \
|
||||
&& rm -rf /out/node_modules/@evofw/shared/src /out/node_modules/@evofw/shared/.turbo \
|
||||
&& find /out -name '*.map' -delete
|
||||
|
||||
FROM ${BASE_NODE} AS runtime
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache ca-certificates
|
||||
ARG VERSION=dev
|
||||
ARG GIT_SHA=unknown
|
||||
ARG BUILD_TIME=
|
||||
ENV NODE_ENV=production \
|
||||
STATIC_DIR=/app/static \
|
||||
DATABASE_URL=sqlite:/data/app.db \
|
||||
SERVER_PORT=8080 \
|
||||
APP_VERSION=${VERSION} \
|
||||
GIT_SHA=${GIT_SHA} \
|
||||
BUILD_TIME=${BUILD_TIME}
|
||||
COPY --from=build /out ./
|
||||
EXPOSE 8080
|
||||
VOLUME ["/data"]
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD ["node", "-e", "fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||
CMD ["node", "dist/server.js"]
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env sh
|
||||
# Copy-by-digest зеркало базовых образов в Gitea Container Registry (без rebuild).
|
||||
# Требует: docker login в REGISTRY host, docker buildx.
|
||||
# REGISTRY=git.shx.one/<owner> (без суффикса /evofw-buildcache)
|
||||
#
|
||||
# Источник library-образов — Docker Hub (docker.io).
|
||||
# Если тег уже есть в Gitea — skip (без повторного pull).
|
||||
# Копируем только linux/amd64 — bake platforms совпадает.
|
||||
# Шаг CD не должен падать: неуспешный copy оставляет bake на Docker Hub FROM.
|
||||
set -eu
|
||||
REGISTRY="${REGISTRY:?REGISTRY required (git.shx.one/<owner>)}"
|
||||
CACHE_REPO="${REGISTRY}/evofw-buildcache"
|
||||
ENV_FILE="${MIRROR_ENV_FILE:-}"
|
||||
PLATFORM="${MIRROR_PLATFORM:-linux/amd64}"
|
||||
RETRIES="${MIRROR_RETRIES:-4}"
|
||||
|
||||
tmp="$(mktemp)"
|
||||
cleanup() { rm -f "$tmp"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
dest_exists() {
|
||||
docker buildx imagetools inspect "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
copy_retry() {
|
||||
src="$1"
|
||||
dest="$2"
|
||||
n=0
|
||||
while [ "$n" -lt "$RETRIES" ]; do
|
||||
n=$((n + 1))
|
||||
if docker buildx imagetools create --platform "$PLATFORM" --tag "$dest" "$src"; then
|
||||
return 0
|
||||
fi
|
||||
delay=$((n * 25))
|
||||
echo "mirror retry ${n}/${RETRIES}, sleep ${delay}s: ${dest}"
|
||||
sleep "$delay"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
mirror() {
|
||||
src="$1"
|
||||
tag="$2"
|
||||
var="$3"
|
||||
dest="${CACHE_REPO}:${tag}"
|
||||
if dest_exists "$dest"; then
|
||||
echo "skip (already in registry): ${dest}"
|
||||
printf '%s=%s\n' "$var" "$dest" >>"$tmp"
|
||||
return 0
|
||||
fi
|
||||
echo "mirror ${src} -> ${dest} (${PLATFORM})"
|
||||
if copy_retry "$src" "$dest"; then
|
||||
printf '%s=%s\n' "$var" "$dest" >>"$tmp"
|
||||
return 0
|
||||
fi
|
||||
echo "warn: ${dest} not mirrored — bake uses Docker Hub FROM for ${var}"
|
||||
return 0
|
||||
}
|
||||
|
||||
mirror "docker.io/library/node:22-alpine" "base-node-22-alpine" BASE_NODE
|
||||
|
||||
echo "----- mirrored BASE_* -----"
|
||||
cat "$tmp"
|
||||
if [ -n "$ENV_FILE" ]; then
|
||||
env_dir="$(dirname "$ENV_FILE")"
|
||||
if [ -d "$env_dir" ]; then
|
||||
cp "$tmp" "$ENV_FILE"
|
||||
echo "wrote ${ENV_FILE}"
|
||||
else
|
||||
echo "warn: MIRROR_ENV_FILE dir missing (${env_dir}) — skip write"
|
||||
fi
|
||||
fi
|
||||
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env sh
|
||||
# Optional helper for local bake when env-based Bake variables are inconvenient.
|
||||
# CI задаёт те же имена переменных в окружении и вызывает bake без этого файла.
|
||||
set -eu
|
||||
OUT="${1:-docker-bake.override.hcl}"
|
||||
REGISTRY="${REGISTRY:?REGISTRY required}"
|
||||
IMAGE_TAG="${IMAGE_TAG:-latest}"
|
||||
SHORT_SHA="${SHORT_SHA:-dev}"
|
||||
SHA_FULL="${SHA_FULL:-}"
|
||||
VERSION="${VERSION:-dev}"
|
||||
BUILD_TIME="${BUILD_TIME:-}"
|
||||
CACHE_REF_NODE="${CACHE_REF_NODE:-}"
|
||||
BASE_NODE="${BASE_NODE:-}"
|
||||
cat >"$OUT" <<EOF
|
||||
# Generated by deploy/docker/write-bake-override.sh — do not commit.
|
||||
variable "REGISTRY" { default = "${REGISTRY}" }
|
||||
variable "IMAGE_TAG" { default = "${IMAGE_TAG}" }
|
||||
variable "SHORT_SHA" { default = "${SHORT_SHA}" }
|
||||
variable "SHA_FULL" { default = "${SHA_FULL}" }
|
||||
variable "VERSION" { default = "${VERSION}" }
|
||||
variable "BUILD_TIME" { default = "${BUILD_TIME}" }
|
||||
variable "CACHE_REF_NODE" { default = "${CACHE_REF_NODE}" }
|
||||
EOF
|
||||
if [ -n "$BASE_NODE" ]; then printf 'variable "BASE_NODE" { default = "%s" }\n' "$BASE_NODE" >>"$OUT"; fi
|
||||
+2
-2
@@ -6,8 +6,8 @@
|
||||
- [Agents](agents.md)
|
||||
- [UI design contract](ui-design-contract.md)
|
||||
- [OpenAPI](openapi.yaml) — **HTTP SoT** (пути/операции). Живая валидация request bodies — Zod в `@evofw/shared`.
|
||||
- [Релизы / CI](releasing.md) — Gitea Actions, semantic-release, docker bake.
|
||||
|
||||
```bash
|
||||
# Lint OpenAPI (requires npx)
|
||||
npx --yes @redocly/cli lint docs/openapi.yaml
|
||||
pnpm openapi:lint
|
||||
```
|
||||
+76
-11
@@ -20,7 +20,7 @@ curl -fsSL https://<cp>/agent-install/<id> | bash
|
||||
3. После enroll статус станет **Pending** — одобрите агента (Approve).
|
||||
4. **Approved** — агент синхронизирует политику.
|
||||
|
||||
**Повторный запуск той же install-ссылки** на хосте, где агент уже стоит: обновляет sync-скрипт / timer (или MikroTik scheduler), **без** повторного enroll — `CLIENT_ID`/`token` сохраняются. Полный переустановки с новым токеном: `EVOFW_INSTALL_FORCE=1` (Linux).
|
||||
**Повторный запуск той же install-ссылки** на хосте, где агент уже стоит: обновляет sync-скрипт / timer (или MikroTik scheduler) **и пересоздаёт nft rules** (в т.ч. `deny_port_hits`), **без** повторного enroll — `CLIENT_ID`/`token` сохраняются. Сбрасывается `last_hash`, затем сразу force sync. Полный переустановки с новым токеном: `EVOFW_INSTALL_FORCE=1` (Linux).
|
||||
|
||||
API (auth): `POST /api/v1/install-links` `{ "name": "web-01", "platform": "linux" | "mikrotik" }`.
|
||||
|
||||
@@ -42,6 +42,21 @@ Install сам ставит зависимости через apt/dnf/yum/apk: `
|
||||
|
||||
Если `/etc/evofw/agent.conf` уже есть — install переходит в **update**: скачивает свежий `sync-script` + `uninstall.sh`, перезаписывает unit/timer, оставляет токен. `EVOFW_INSTALL_FORCE=1` — полный re-enroll (новый токен; для уже Approved install-link обычно не сработает).
|
||||
|
||||
### Linux self-update (sync-script)
|
||||
|
||||
После того как на хосте стоит скрипт с `maybe_self_update`, агент **сам** подтягивает новую версию при каждом timer (~1 мин), **до** `GET /v1/agent/policy` (pending тоже обновляются):
|
||||
|
||||
1. sha256 локального `/usr/local/sbin/evofw-firewall.sh`
|
||||
2. `GET /v1/agent/sync-script` с `If-None-Match: "<sha256>"` → **304** = без изменений
|
||||
3. **200**: проверка shebang + sha256 тела → `install -m 755`, сброс `last_hash`, `exec EVOFW_SKIP_SELF_UPDATE=1` новой копии
|
||||
4. Ошибка download/verify — лог и продолжение **текущим** скриптом
|
||||
|
||||
`GET /v1/agent/policy` содержит `script_sha256` (не входит в `policy.hash`).
|
||||
|
||||
**Bootstrap:** агенты без `maybe_self_update` не умеют самообновляться. После деплоя API — **один** re-run install-ссылки (или ручная замена `sync-script`). Дальше curl не нужен.
|
||||
|
||||
MikroTik scheduler **не** обновляется этим путём — только повторный import install `.rsc`.
|
||||
|
||||
**Uninstall (Linux):**
|
||||
```bash
|
||||
curl -fsSL https://<cp>/v1/agent/uninstall.sh | bash
|
||||
@@ -57,13 +72,54 @@ Whitelist: nft chain policy drop + allow set. Blacklist: policy accept + deny se
|
||||
|
||||
Linux agent reports optional `ip_hits` in `POST /v1/agent/apply-report`:
|
||||
|
||||
- **nft:** set `deny_v4` with `flags interval; counter;` — per-element packets; collected **before** flush/recreate and on unchanged-hash sync.
|
||||
- **ipset:** `hash:net … counters` — same idea from `ipset list`.
|
||||
- **nft:** tries set `deny_v4` with `flags interval; counter;`. If the kernel rejects counters on interval sets, falls back to plain interval (aggregate Traffic ↓ still works; per-IP empty).
|
||||
- Upgrade path: on install-link re-run, `last_hash` is cleared once so sets/chain can be recreated (chain deleted before set replace) — **обновляет и port-hit правила**.
|
||||
- **ipset:** prefers `hash:net … counters` on create; existing sets without counters are left as-is.
|
||||
- Payload: only entries with `packets > 0`, **top 200** by packets.
|
||||
- Control plane stores cumulative totals in `agent_ip_block_stats` (delta vs last absolute report). `GET /api/v1/agents/:id/blocked-ips`. Reset via `POST …/stats/reset`.
|
||||
- UI: agent detail → **Blocked IPs** (Frame + DataGrid).
|
||||
- Control plane: `agent_ip_block_stats`, accumulates **deltas** of absolute kernel counters (как Traffic ↓). После flush set/chain (policy apply) CP сбрасывает per-IP baseline (`last_reported`), иначе вторая эпоха счётчиков теряется (Traffic растёт, Blocked IPs — нет).
|
||||
- `GET /api/v1/agents/:id/blocked-ips`, reset via `POST …/stats/reset`.
|
||||
- UI: agent detail → **Blocked IPs**. Sum of Blocked IPs ≈ Traffic ↓ для deny (при default accept); default-drop / allow в Traffic считаются отдельно.
|
||||
|
||||
IPv6 skipped (as in apply). MikroTik: see below — no per-IP in v1.
|
||||
IPv6 skipped.
|
||||
|
||||
## Destination ports (Linux nft)
|
||||
|
||||
На **nft** агент ведёт dynamic set `deny_port_hits` (`ipv4_addr . inet_proto . inet_service`, timeout 1h, counter):
|
||||
|
||||
- Deny rule для tcp/udp: `update @deny_port_hits { ip saddr . meta l4proto . th dport }` + drop; прочие протоколы — plain drop.
|
||||
- В `apply-report`: optional `port_hits` top **500** `{ ip, port, protocol, packets }`.
|
||||
- CP: `agent_port_block_stats` (absolute deltas). Set **не** flush’ится на каждый policy apply (элементы живут по timeout) — baseline не сбрасывается при Traffic flush.
|
||||
- `GET /api/v1/agents/:id/blocked-ports` — aggregate top 50 портов; в `blocked-ips` у каждого IP — `ports` top 5.
|
||||
- UI: **Top ports** + колонка Ports в Blocked IPs (только `platform=linux`).
|
||||
- **ipset/iptables:** `port_hits: []`. MikroTik — без port hits.
|
||||
- Агенты с self-update подтянут nft-правила сами; без него — **один** re-run install one-liner (см. выше).
|
||||
|
||||
IPv6 skipped.
|
||||
|
||||
## Host firewall snapshot + Port ACL (Linux)
|
||||
|
||||
### Observed (Host firewall)
|
||||
|
||||
Каждый sync агент собирает best-effort снимок и шлёт в `apply-report.host_firewall`:
|
||||
|
||||
- `nft list ruleset`, `iptables-save`, optional `ufw` / `firewall-cmd`, `ss -lntu`
|
||||
- Каждое правило: `ownership: evofw | foreign` (метка по имени table/chain/comment `evofw`)
|
||||
- CP: `agent_host_firewall_snapshots`; `GET /api/v1/agents/:id/host-firewall`
|
||||
- UI agent detail → tab **Host firewall** (Rules / Listeners)
|
||||
|
||||
Foreign правила **только отображаются** — с CP не редактируются.
|
||||
|
||||
### Desired (Port ACL)
|
||||
|
||||
Per-agent таблица `agent_port_rules`: `open|close`, `tcp|udp|both`, port range, `src_kind: all|cidr|list`.
|
||||
|
||||
- API: CRUD `/api/v1/agents/:id/port-rules`, import `/port-rules/import` (from list или policy set sources)
|
||||
- Policy `apply_version: 3` → `port_rules[]` с expanded `src_cidrs`
|
||||
- nft apply: close drop → open accept (`comment "evofw-port-<id>"`) → **implicit drop** для каждого `(proto, dport)` с хотя бы одним `open` (`comment "evofw-port-implicit-…"`). **Только входящие к сервисам** (не клиентский egress): **prerouting** `fib daddr type local` (публичный порт до Docker NAT) + **input** + **forward** `ct status dnat` (Docker `-p`). Порт с open = whitelist входящих: src из правила accept, остальные внешние drop. Исходящий curl/контейнер на 80/443 не матчится. `lo` и `ct established,related` выше по цепочке. prerouting **без** terminal default drop.
|
||||
- UI: tab **Port ACL** (DataGrid + Sheet create/edit + Import). Owner: **EvoFW** (desired) и **system** (listeners + foreign allow с хоста). Переопределённые system-строки скрыты в All. Системный порт можно переопределить → desired `open` (список/CIDR); ufw/iptables/Docker-цепочки не меняются.
|
||||
- ipset / MikroTik: без L4 apply; секции скрыты для non-linux
|
||||
|
||||
Мутация Port ACL бампит `policy_generation` → agent re-apply. Нужен self-update скрипта или re-run install-ссылки.
|
||||
|
||||
## MikroTik (RouterOS 7.21+)
|
||||
|
||||
@@ -78,20 +134,29 @@ IPv6 skipped (as in apply). MikroTik: see below — no per-IP in v1.
|
||||
Install RSC:
|
||||
|
||||
1. Enroll (с `install_link_id` → агент Invited → Pending).
|
||||
2. Создаёт filter-правила `evofw-*` и address-list `EVOFW_DENY` / `EVOFW_ALLOW`.
|
||||
3. Scheduler `evofw-sync` каждую минуту: `GET /v1/agent/policy` (JSON) → rebuild address-list + toggle default. Не использует `/import` огромного `.rsc` (на больших списках часто падает молча).
|
||||
2. Создаёт filter-правила `evofw-*` **в начале** цепочек `input`/`forward` (`place-before`) и address-list `EVOFW_DENY` / `EVOFW_ALLOW` / dynamic **`EVOFW_HITS`**.
|
||||
3. Scheduler `evofw-sync` каждую минуту: `GET /v1/agent/policy` (JSON) → rebuild deny/allow + report (+ `ip_hits` из HITS). Не использует `/import` огромного `.rsc`.
|
||||
|
||||
Лог: `/log print where message~"evofw"`. Ручной sync: `/system script run evofw-sync`.
|
||||
Traffic ↓/↑ в UI — сумма `packets` с filter-правил `evofw-deny-*` / `evofw-allow-*` / `evofw-default-drop-*` (накопительно, пока правила не пересозданы re-install).
|
||||
Traffic ↓/↑ в UI — сумма `packets` с `evofw-deny-drop-*` / `evofw-allow-*` / `evofw-default-drop-*` (накопительно, пока правила не пересозданы re-install).
|
||||
|
||||
**Per-IP / blocked IPs:** на MikroTik **нет**. У `/ip firewall address-list` в ROS 7 нет `packets`/`bytes` на записи — только суммарные counters filter-правил. В карточке агента секция Blocked IPs показывает пояснение.
|
||||
### Per-IP / Blocked IPs (MikroTik)
|
||||
|
||||
Цепочка deny: **hit → drop** (семантика drop/allow/default как раньше):
|
||||
|
||||
1. `evofw-deny-hit-input/forward` — `add-src-to-address-list` → `EVOFW_HITS`, `address-list-timeout=1h` (passthrough).
|
||||
2. `evofw-deny-drop-input/forward` — `drop` по `EVOFW_DENY`.
|
||||
|
||||
В `EVOFW_HITS` попадают реальные src **/32**. Policy rebuild **не** чистит HITS (только DENY/ALLOW). Sync шлёт top-200 в `ip_hits`; CP mode **presence**: `last_seen` обновляется, пока IP в HITS; `packets` (Hits) увеличивается только при первом появлении или **повторном входе** после исчезновения из списка (~>2.5 мин без report), а не на каждый sync.
|
||||
|
||||
UI: agent detail → **Blocked IPs** (колонка Hits).
|
||||
|
||||
**Default action** задаётся на **агенте** (`default_action: accept | drop`):
|
||||
|
||||
- **accept** — пакет вне deny/allow пропускается
|
||||
- **drop** — пакет вне deny/allow отбрасывается (forward)
|
||||
|
||||
Цепочка всегда: deny-drop → allow-accept → default. Наборы несут только правила deny/allow, без exclusive mode.
|
||||
Цепочка: deny-hit → deny-drop → allow-accept → default. Наборы несут только правила deny/allow, без exclusive mode.
|
||||
|
||||
## Force sync
|
||||
|
||||
|
||||
+10
-4
@@ -17,9 +17,10 @@
|
||||
|
||||
1. **Enroll** — `POST /v1/agent/enroll` + `X-EvoFW-Seed` → pending agent
|
||||
2. **Approve** — UI/API → status approved
|
||||
3. **Policy** — `GET /v1/agent/policy` → deny/allow CIDRs + `default_action` + hash (`apply_version: 2`)
|
||||
4. **Apply** — agent пишет kernel rules, `POST /v1/agent/apply-report` + stats sample
|
||||
5. **Lists refresh** — cron каждые 5 мин (json_url / domains / evobgp_community)
|
||||
3. **Policy** — `GET /v1/agent/policy` → deny/allow CIDRs + `default_action` + optional `port_rules` + hash (`apply_version: 3`) + `script_sha256` (Linux; не в `policy.hash`)
|
||||
4. **Linux self-update** — timer: `GET /v1/agent/sync-script` (`ETag` / `If-None-Match`) → при новой версии заменить `/usr/local/sbin/evofw-firewall.sh` и `exec` до policy
|
||||
5. **Apply** — agent пишет kernel rules (L3 + L4 port ACL на nft), `POST /v1/agent/apply-report` + stats + optional `host_firewall` snapshot
|
||||
6. **Lists refresh** — cron каждые 5 мин (json_url / domains / evobgp_community)
|
||||
|
||||
## Политика
|
||||
|
||||
@@ -27,10 +28,15 @@
|
||||
- Правило в наборе: `action: deny | allow` + ровно один источник — IP-список (`list_id`), CIDR или DNS-имя (`hostname` → A/AAAA, кэш в `policy_rule_resolved`)
|
||||
- Evaluate: правила всех назначенных enabled-наборов (sort + priority) + `ip_overrides`
|
||||
- Цепочка ядра **всегда**: deny → allow → `default_action` (`accept` | `drop` на агенте)
|
||||
- На Linux nft: deny → **Port ACL** (`close` / `open` / implicit drop для портов с open) → allow → `default_action` на hooks **prerouting** (priority mangle, `fib daddr type local`, до Docker DNAT), **input** и **forward** (`ct status dnat`). `open` по списку = **входящие** на публичный порт только с разрешённых src (хост и Docker `-p`); исходящий клиентский 80/443 не режется.
|
||||
- Exact overlap: `allow \ deny` (`conflicts_dropped`); deny wins
|
||||
- Overrides, смена наборов, `default_action` и refresh DNS/lists бампят `policy_generation`
|
||||
- Overrides, смена наборов, `default_action`, Port ACL и refresh DNS/lists бампят `policy_generation`
|
||||
|
||||
## Auth
|
||||
|
||||
- Portal SSO app id **`fw`**, permissions `fw:*`
|
||||
- Agent bearer token (sha256 hash в БД)
|
||||
|
||||
## Деплой
|
||||
|
||||
Один alpine-контейнер (`evofw` / алиас `evofirewall`): Fastify отдаёт API, SPA и install-ссылки `GET /{slug}`. Сборка — `docker buildx bake` из [`deploy/docker`](../deploy/docker/README.md). Релизы: [`releasing.md`](releasing.md).
|
||||
+331
-4
@@ -397,14 +397,14 @@ paths:
|
||||
|
||||
/api/v1/agents/{id}/blocked-ips:
|
||||
get:
|
||||
summary: Per-IP/CIDR drop counters (Linux nft/ipset)
|
||||
summary: Per-IP/CIDR drop counters (Linux nft/ipset; MikroTik HITS)
|
||||
tags: [ops]
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Id'
|
||||
responses:
|
||||
'200':
|
||||
description: Top blocked IPs by accumulated packets
|
||||
description: Top blocked IPs by accumulated packets (+ optional top ports per IP on Linux)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -420,6 +420,198 @@ paths:
|
||||
packets: { type: integer }
|
||||
first_seen_at: { type: string, format: date-time }
|
||||
last_seen_at: { type: string, format: date-time }
|
||||
ports:
|
||||
type: array
|
||||
description: Top destination ports for this IP (Linux nft)
|
||||
items:
|
||||
type: object
|
||||
required: [port, protocol, packets]
|
||||
properties:
|
||||
port: { type: integer, minimum: 1, maximum: 65535 }
|
||||
protocol: { type: string, enum: [tcp, udp] }
|
||||
packets: { type: integer }
|
||||
|
||||
/api/v1/agents/{id}/blocked-ports:
|
||||
get:
|
||||
summary: Aggregate destination ports hit by denied sources (Linux nft)
|
||||
tags: [ops]
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Id'
|
||||
responses:
|
||||
'200':
|
||||
description: Top ports by accumulated packets across all blocked IPs
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [port, protocol, packets, last_seen_at]
|
||||
properties:
|
||||
port: { type: integer, minimum: 1, maximum: 65535 }
|
||||
protocol: { type: string, enum: [tcp, udp] }
|
||||
packets: { type: integer }
|
||||
last_seen_at: { type: string, format: date-time }
|
||||
|
||||
/api/v1/agents/{id}/port-rules:
|
||||
get:
|
||||
summary: Desired Port ACL rules (Linux)
|
||||
tags: [ops]
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Id'
|
||||
responses:
|
||||
'200':
|
||||
description: Port ACL rules
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/AgentPortRule'
|
||||
post:
|
||||
summary: Create Port ACL rule
|
||||
tags: [ops]
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Id'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/CreateAgentPortRule'
|
||||
responses:
|
||||
'200':
|
||||
description: Created rule
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AgentPortRule'
|
||||
'400':
|
||||
description: Validation / non-linux
|
||||
|
||||
/api/v1/agents/{id}/port-rules/import:
|
||||
post:
|
||||
summary: Import Port ACL from IP list or policy set sources
|
||||
tags: [ops]
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Id'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [from, action, ports]
|
||||
properties:
|
||||
from: { type: string, enum: [list, set] }
|
||||
list_id: { type: string }
|
||||
set_id: { type: string }
|
||||
action: { type: string, enum: [open, close] }
|
||||
protocol: { type: string, enum: [tcp, udp, both], default: tcp }
|
||||
ports:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 50
|
||||
items:
|
||||
type: object
|
||||
required: [port_start]
|
||||
properties:
|
||||
port_start: { type: integer, minimum: 1, maximum: 65535 }
|
||||
port_end: { type: integer, minimum: 1, maximum: 65535 }
|
||||
enabled: { type: boolean, default: true }
|
||||
comment: { type: string, maxLength: 500 }
|
||||
responses:
|
||||
'200':
|
||||
description: Created rules
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/AgentPortRule'
|
||||
|
||||
/api/v1/agents/{id}/port-rules/{ruleId}:
|
||||
patch:
|
||||
summary: Update Port ACL rule
|
||||
tags: [ops]
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Id'
|
||||
- name: ruleId
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UpdateAgentPortRule'
|
||||
responses:
|
||||
'200':
|
||||
description: Updated rule
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AgentPortRule'
|
||||
delete:
|
||||
summary: Delete Port ACL rule
|
||||
tags: [ops]
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Id'
|
||||
- name: ruleId
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
'200':
|
||||
description: Deleted
|
||||
|
||||
/api/v1/agents/{id}/host-firewall:
|
||||
get:
|
||||
summary: Last observed host firewall snapshot (Linux)
|
||||
tags: [ops]
|
||||
security: [{ bearerAuth: [] }]
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/Id'
|
||||
responses:
|
||||
'200':
|
||||
description: Snapshot (empty if never reported)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
collected_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
raw_digest:
|
||||
type: string
|
||||
nullable: true
|
||||
rules:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/HostFwRule'
|
||||
listeners:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/HostListener'
|
||||
|
||||
/api/v1/integrations/evobgp/communities:
|
||||
get:
|
||||
summary: Proxy EvoBGP communities
|
||||
@@ -483,6 +675,28 @@ paths:
|
||||
'200':
|
||||
description: Shell script
|
||||
|
||||
/v1/agent/sync-script:
|
||||
get:
|
||||
summary: Linux sync agent (evofw-firewall.sh)
|
||||
tags: [agent-public]
|
||||
parameters:
|
||||
- name: If-None-Match
|
||||
in: header
|
||||
schema: { type: string }
|
||||
description: ETag from a previous GET (`"<sha256>"`)
|
||||
responses:
|
||||
'200':
|
||||
description: Shell script
|
||||
headers:
|
||||
ETag:
|
||||
schema: { type: string }
|
||||
description: '"<sha256 of file bytes>"'
|
||||
X-Evofw-Script-Sha256:
|
||||
schema: { type: string }
|
||||
description: Hex sha256 of the script body
|
||||
'304':
|
||||
description: Script unchanged
|
||||
|
||||
/v1/agent/enroll:
|
||||
post:
|
||||
summary: Enroll agent (X-EvoFW-Seed)
|
||||
@@ -498,7 +712,7 @@ paths:
|
||||
security: [{ agentToken: [] }]
|
||||
responses:
|
||||
'200':
|
||||
description: Policy
|
||||
description: Policy (includes script_sha256 for Linux self-update; not part of policy.hash)
|
||||
|
||||
/v1/agent/policy.rsc:
|
||||
get:
|
||||
@@ -520,7 +734,7 @@ paths:
|
||||
|
||||
/v1/agent/apply-report:
|
||||
post:
|
||||
summary: Apply report + packet stats (+ optional ip_hits)
|
||||
summary: Apply report + packet stats (+ optional ip_hits / port_hits / host_firewall)
|
||||
tags: [agent]
|
||||
security: [{ agentToken: [] }]
|
||||
requestBody:
|
||||
@@ -548,6 +762,20 @@ paths:
|
||||
properties:
|
||||
ip: { type: string, maxLength: 64 }
|
||||
packets: { type: integer, minimum: 0 }
|
||||
port_hits:
|
||||
type: array
|
||||
maxItems: 500
|
||||
description: Linux nft deny_port_hits (ip × proto × dport, packets > 0)
|
||||
items:
|
||||
type: object
|
||||
required: [ip, port, protocol, packets]
|
||||
properties:
|
||||
ip: { type: string, maxLength: 64 }
|
||||
port: { type: integer, minimum: 1, maximum: 65535 }
|
||||
protocol: { type: string, enum: [tcp, udp] }
|
||||
packets: { type: integer, minimum: 0 }
|
||||
host_firewall:
|
||||
$ref: '#/components/schemas/HostFirewallPayload'
|
||||
responses:
|
||||
'200':
|
||||
description: OK
|
||||
@@ -559,6 +787,105 @@ components:
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
schemas:
|
||||
AgentPortRule:
|
||||
type: object
|
||||
required:
|
||||
[
|
||||
id,
|
||||
agent_id,
|
||||
action,
|
||||
protocol,
|
||||
port_start,
|
||||
port_end,
|
||||
src_kind,
|
||||
enabled,
|
||||
priority,
|
||||
created_at,
|
||||
updated_at,
|
||||
]
|
||||
properties:
|
||||
id: { type: string }
|
||||
agent_id: { type: string }
|
||||
action: { type: string, enum: [open, close] }
|
||||
protocol: { type: string, enum: [tcp, udp, both] }
|
||||
port_start: { type: integer, minimum: 1, maximum: 65535 }
|
||||
port_end: { type: integer, minimum: 1, maximum: 65535 }
|
||||
src_kind: { type: string, enum: [all, cidr, list] }
|
||||
src_cidr: { type: string, nullable: true }
|
||||
list_id: { type: string, nullable: true }
|
||||
list_name: { type: string, nullable: true }
|
||||
enabled: { type: boolean }
|
||||
comment: { type: string, nullable: true }
|
||||
priority: { type: integer }
|
||||
created_at: { type: string, format: date-time }
|
||||
updated_at: { type: string, format: date-time }
|
||||
CreateAgentPortRule:
|
||||
type: object
|
||||
required: [action, port_start]
|
||||
properties:
|
||||
action: { type: string, enum: [open, close] }
|
||||
protocol: { type: string, enum: [tcp, udp, both], default: tcp }
|
||||
port_start: { type: integer, minimum: 1, maximum: 65535 }
|
||||
port_end: { type: integer, minimum: 1, maximum: 65535 }
|
||||
src_kind: { type: string, enum: [all, cidr, list], default: all }
|
||||
src_cidr: { type: string }
|
||||
list_id: { type: string }
|
||||
enabled: { type: boolean, default: true }
|
||||
comment: { type: string, maxLength: 500 }
|
||||
priority: { type: integer, default: 100 }
|
||||
UpdateAgentPortRule:
|
||||
type: object
|
||||
properties:
|
||||
action: { type: string, enum: [open, close] }
|
||||
protocol: { type: string, enum: [tcp, udp, both] }
|
||||
port_start: { type: integer, minimum: 1, maximum: 65535 }
|
||||
port_end: { type: integer, minimum: 1, maximum: 65535 }
|
||||
src_kind: { type: string, enum: [all, cidr, list] }
|
||||
src_cidr: { type: string, nullable: true }
|
||||
list_id: { type: string, nullable: true }
|
||||
enabled: { type: boolean }
|
||||
comment: { type: string, maxLength: 500, nullable: true }
|
||||
priority: { type: integer }
|
||||
HostFwRule:
|
||||
type: object
|
||||
required: [ownership, backend, raw]
|
||||
properties:
|
||||
ownership: { type: string, enum: [evofw, foreign] }
|
||||
backend:
|
||||
type: string
|
||||
enum: [nft, iptables, ufw, firewalld, listener]
|
||||
table: { type: string }
|
||||
chain: { type: string }
|
||||
action: { type: string }
|
||||
protocol: { type: string }
|
||||
dport: { type: string }
|
||||
sport: { type: string }
|
||||
saddr: { type: string }
|
||||
daddr: { type: string }
|
||||
comment: { type: string }
|
||||
raw: { type: string, maxLength: 512 }
|
||||
HostListener:
|
||||
type: object
|
||||
required: [protocol, port, address]
|
||||
properties:
|
||||
protocol: { type: string }
|
||||
port: { type: integer, minimum: 0, maximum: 65535 }
|
||||
address: { type: string }
|
||||
process: { type: string }
|
||||
HostFirewallPayload:
|
||||
type: object
|
||||
properties:
|
||||
rules:
|
||||
type: array
|
||||
maxItems: 500
|
||||
items:
|
||||
$ref: '#/components/schemas/HostFwRule'
|
||||
listeners:
|
||||
type: array
|
||||
maxItems: 200
|
||||
items:
|
||||
$ref: '#/components/schemas/HostListener'
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
type: http
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Релизы и версионирование EvoFirewall
|
||||
|
||||
EvoFirewall использует [Conventional Commits](https://www.conventionalcommits.org/) и [semantic-release](https://semantic-release.gitbook.io/) для полностью автоматических релизов на Gitea (`git.shx.one`). Ручное повышение версии в коде не требуется.
|
||||
|
||||
Заголовок после `тип(scope):` — **на русском** (см. `.cursor/rules/commit-messages-ru.mdc`). Analyzer смотрит только префикс `feat` / `fix` / …
|
||||
|
||||
## Как определяется версия
|
||||
|
||||
| Тип коммита | Bump |
|
||||
|-------------|------|
|
||||
| `feat` | minor (1.0.0 → 1.1.0) |
|
||||
| `fix`, `perf`, `ci`, `refactor` | patch (1.5.1 → 1.5.2) |
|
||||
| `feat!`, `fix!` или `BREAKING CHANGE:` в теле | major (1.0.0 → 2.0.0) |
|
||||
| `docs`, `chore`, `test` | без релиза |
|
||||
|
||||
**Scope:** один идентификатор **без запятых** (`web`, `api`, `ci`). Заголовок `refactor(a, b): …` **не парсится** semantic-release → релиз не создаётся (commitlint на PR это тоже отклонит).
|
||||
|
||||
`refactor` — patch без новых функций: перестройка кода/UI при том же поведении для пользователя.
|
||||
|
||||
Первый релиз при отсутствии git-тегов — **1.0.0**, если есть releasable-коммиты.
|
||||
|
||||
## CI-пайплайн (push в main)
|
||||
|
||||
```text
|
||||
push/merge в main
|
||||
→ workflow CD: quality (openapi, web, api)
|
||||
→ job publish:
|
||||
→ semantic-release: git tag vX.Y.Z на текущий commit (без доп. commit)
|
||||
→ Gitea Release + CHANGELOG.md как attachment
|
||||
→ зеркало base-образов в evofw-buildcache:base-*
|
||||
→ docker buildx bake с VERSION=X.Y.Z (pull=false, named builder evofw)
|
||||
→ образы: latest, vX.Y.Z, X.Y.Z, sha-*, короткий SHA
|
||||
```
|
||||
|
||||
Pull request: workflow **CI** — quality gates + commitlint; релиз и образы **не** публикуются.
|
||||
|
||||
Workflows: [.gitea/workflows/ci.yaml](../.gitea/workflows/ci.yaml), [.gitea/workflows/cd.yaml](../.gitea/workflows/cd.yaml), reusable [.gitea/workflows/quality.yaml](../.gitea/workflows/quality.yaml).
|
||||
|
||||
Конфиг semantic-release: [.releaserc.json](../.releaserc.json) — без `@semantic-release/git` (CHANGELOG не коммитится в репозиторий).
|
||||
|
||||
## Секреты Gitea
|
||||
|
||||
Один PAT — **`ACTIONS_PAT`** (Settings → Actions → Secrets).
|
||||
|
||||
| Право PAT | Зачем |
|
||||
|-----------|--------|
|
||||
| push tags | git tag `vX.Y.Z` на commit merge |
|
||||
| releases | Gitea Release + notes |
|
||||
| packages (Container Registry) | push образов |
|
||||
|
||||
Fallback для **git tag**: `github.token`, если PAT недоступен. Push образов в Container Registry — **только `ACTIONS_PAT`** (у job token Gitea нет права packages).
|
||||
|
||||
## Источник правды для версии
|
||||
|
||||
Semver из git-тега пробрасывается в образ как `APP_VERSION` / `GIT_SHA` / `BUILD_TIME` при bake (`deploy/docker/evofw/Dockerfile`).
|
||||
|
||||
## Scope и semantic-release
|
||||
|
||||
Парсер conventional-commits **не понимает запятые в scope**. Правило: **один scope** (`web`, `api`, `ci`, `docker`).
|
||||
|
||||
На push в `main` job **publish** запускает `scripts/commit/verify-release-commits.mjs` — в логе будут предупреждения о непарсящихся коммитах.
|
||||
|
||||
Если релиз «не создался», а CI зелёный: смотрите лог release — часто `No releasable commits`. Исправление: новый коммит с корректным заголовком (например `fix(web): …`).
|
||||
|
||||
## Перезапуск упавшего job publish
|
||||
|
||||
semantic-release пишет `.release-version` только в `successCmd` при **новом** релизе. Если тег `vX.Y.Z` уже создан, а `docker buildx bake` упал, повторный run того же SHA делает semantic-release no-op (файла нет). Job **publish** тогда берёт версию из git-тега на `HEAD` и публикует образы.
|
||||
|
||||
Перезапускать нужно **весь job publish**, не отдельный шаг bake: checkout + semantic-release + detect + bake идут подряд.
|
||||
|
||||
## CHANGELOG
|
||||
|
||||
Release notes — в Gitea Release; файл `CHANGELOG.md` генерируется в CI и прикрепляется как asset, **не** попадает в git history.
|
||||
|
||||
## Проверка после релиза
|
||||
|
||||
1. Один run workflow **CD** на push в main: job **publish** зелёный.
|
||||
2. Gitea: тег `vX.Y.Z` на том же commit, что и merge; Release с notes.
|
||||
3. Container Registry: `evofw:vX.Y.Z`, `evofw:X.Y.Z`, `evofw:latest` и алиас `evofirewall` с теми же тегами.
|
||||
4. `curl http://localhost:8080/health` → `"status":"ok"`.
|
||||
|
||||
## Первый релиз (bootstrap)
|
||||
|
||||
Merge в `main` с `feat(release): …` → **v1.0.0** в том же CI run.
|
||||
@@ -28,7 +28,7 @@ Settings: секции через Frame + `gap` (без hairline `Separator` п
|
||||
| Lists | `data-grid-filtering-2` | https://reui.io/preview/base/data-grid-filtering-2 |
|
||||
| Settings | `settings-16` + SettingRow | https://reui.io/preview/base/settings-16 |
|
||||
| Auth | `auth-13` | https://reui.io/preview/base/auth-13 |
|
||||
| Empty | `empty-state-12` | https://reui.io/preview/base/empty-state-12 |
|
||||
| Empty | `empty-state-14` (prefer) / `empty-state-12` | https://reui.io/preview/base/empty-state-14 · https://reui.io/preview/base/empty-state-12 |
|
||||
| Forms | `form-7` → Sheet/Drawer | https://reui.io/preview/base/form-7 |
|
||||
|
||||
## Kit API (`reui-kit/`)
|
||||
@@ -39,7 +39,7 @@ Settings: секции через Frame + `gap` (без hairline `Separator` п
|
||||
| `KpiStatGrid` | EvoBGP hybrid KPI tiles (`items`/`cards`, `variant`, Badge) |
|
||||
| `QuickActionGrid` | KPI-like quick action tiles (gated by `showQuickActions`) |
|
||||
| `OpsDashboard` | KPI + optional `afterKpi` + charts + attention queue |
|
||||
| `SettingsShell` | settings nav + Outlet |
|
||||
| `SettingsShell` | settings nav + Outlet (**только 2+ секции**; текущий `/settings` — PageShell + Frame) |
|
||||
| `DetailPanel` | detail Frame sections |
|
||||
| `filter-utils` | apply/clear ReUI Filters |
|
||||
|
||||
@@ -75,9 +75,9 @@ Gating: DB `show_quick_actions` / `showQuickActions` / `ui_show_quick_actions` (
|
||||
|
||||
NavUser (footer): avatar + name/email; dropdown — Настройки / Тема (segmented) / Выйти. Preview: [app-shell-1](https://reui.io/preview/base/app-shell-1).
|
||||
|
||||
App Switcher: auth-portal `GET /api/v1/app-switcher`. Ids: `cfdm` · `vps` · `bgp` · `fw`. Admin: portal `/admin/apps`.
|
||||
App Switcher: auth-portal `GET /api/v1/app-switcher`. Ids: `cfdm` · `vps` · `bgp` · `fw` · `dns`. Admin: portal `/admin/apps`.
|
||||
|
||||
QuickActionGrid / KPI icons: только semantic **text** (`text-info` / `text-primary` / …) на kit `bg-muted` — без solid fills.
|
||||
QuickActionGrid / KPI icons: только semantic **text** (`text-info` / `text-primary` / …) на `IconTile` `elevated` — без solid fills.
|
||||
|
||||
## System monitor
|
||||
|
||||
|
||||
+11
-1
@@ -7,9 +7,19 @@
|
||||
"build": "turbo run build",
|
||||
"test": "turbo run test",
|
||||
"lint": "turbo run lint",
|
||||
"openapi:lint": "npx --yes @redocly/cli lint docs/openapi.yaml"
|
||||
"openapi:lint": "redocly lint docs/openapi.yaml"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^19.8.1",
|
||||
"@commitlint/config-conventional": "^19.8.1",
|
||||
"@markwylde/semantic-release-gitea": "^2.2.0",
|
||||
"@redocly/cli": "1.34.5",
|
||||
"@semantic-release/changelog": "^6.0.3",
|
||||
"@semantic-release/commit-analyzer": "^13.0.1",
|
||||
"@semantic-release/exec": "^7.0.0",
|
||||
"@semantic-release/release-notes-generator": "^14.0.3",
|
||||
"conventional-commits-parser": "^6.4.0",
|
||||
"semantic-release": "^25.0.2",
|
||||
"turbo": "^2.5.4"
|
||||
},
|
||||
"packageManager": "pnpm@10.12.1",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Per-(ip, protocol, port) deny drop counters from Linux nft dynamic set.
|
||||
CREATE TABLE IF NOT EXISTS agent_port_block_stats (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||
ip TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
protocol TEXT NOT NULL,
|
||||
packets INTEGER NOT NULL DEFAULT 0,
|
||||
last_reported_packets INTEGER NOT NULL DEFAULT 0,
|
||||
first_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
last_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_port_block_stats_agent_ip_port_proto
|
||||
ON agent_port_block_stats(agent_id, ip, port, protocol);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_port_block_stats_agent_packets
|
||||
ON agent_port_block_stats(agent_id, packets);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_port_block_stats_agent_port_proto
|
||||
ON agent_port_block_stats(agent_id, port, protocol);
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Per-agent L4 port ACL (desired state) + host firewall snapshot (observed).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_port_rules (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||
action TEXT NOT NULL,
|
||||
protocol TEXT NOT NULL,
|
||||
port_start INTEGER NOT NULL,
|
||||
port_end INTEGER NOT NULL,
|
||||
src_kind TEXT NOT NULL,
|
||||
src_cidr TEXT,
|
||||
list_id TEXT REFERENCES ip_lists(id) ON DELETE SET NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
comment TEXT,
|
||||
priority INTEGER NOT NULL DEFAULT 100,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_port_rules_agent_priority
|
||||
ON agent_port_rules(agent_id, priority);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_agent_port_rules_agent_enabled
|
||||
ON agent_port_rules(agent_id, enabled);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agent_host_firewall_snapshots (
|
||||
agent_id TEXT PRIMARY KEY NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||
collected_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||
payload_json TEXT NOT NULL,
|
||||
raw_digest TEXT
|
||||
);
|
||||
@@ -7,6 +7,7 @@
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"development": "./src/index.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -60,7 +60,34 @@ export {
|
||||
upsertIpBlockStats,
|
||||
listIpBlockStats,
|
||||
deleteIpBlockStatsForAgent,
|
||||
resetIpBlockStatsBaselines,
|
||||
upsertPortBlockStats,
|
||||
listPortBlockStatsAggregate,
|
||||
listPortBlockStatsForIps,
|
||||
mapTopPortsByIp,
|
||||
deletePortBlockStatsForAgent,
|
||||
resetPortBlockStatsBaselines,
|
||||
PRESENCE_REHIT_STALE_MS,
|
||||
} from './stats.js'
|
||||
export type {
|
||||
UpsertIpBlockStatsOptions,
|
||||
IpHitInput,
|
||||
PortHitInput,
|
||||
PortBlockAggregateRow,
|
||||
PortBlockPerIpRow,
|
||||
} from './stats.js'
|
||||
|
||||
export {
|
||||
listAgentPortRules,
|
||||
listEnabledAgentPortRules,
|
||||
getAgentPortRule,
|
||||
insertAgentPortRule,
|
||||
updateAgentPortRule,
|
||||
deleteAgentPortRule,
|
||||
upsertHostFirewallSnapshot,
|
||||
getHostFirewallSnapshot,
|
||||
} from './port-acl.js'
|
||||
export type { AgentPortRuleRow, AgentPortRuleInsert } from './port-acl.js'
|
||||
|
||||
export {
|
||||
getSetting,
|
||||
@@ -138,7 +165,24 @@ import {
|
||||
upsertIpBlockStats,
|
||||
listIpBlockStats,
|
||||
deleteIpBlockStatsForAgent,
|
||||
resetIpBlockStatsBaselines,
|
||||
upsertPortBlockStats,
|
||||
listPortBlockStatsAggregate,
|
||||
listPortBlockStatsForIps,
|
||||
mapTopPortsByIp,
|
||||
deletePortBlockStatsForAgent,
|
||||
resetPortBlockStatsBaselines,
|
||||
} from './stats.js'
|
||||
import {
|
||||
listAgentPortRules,
|
||||
listEnabledAgentPortRules,
|
||||
getAgentPortRule,
|
||||
insertAgentPortRule,
|
||||
updateAgentPortRule,
|
||||
deleteAgentPortRule,
|
||||
upsertHostFirewallSnapshot,
|
||||
getHostFirewallSnapshot,
|
||||
} from './port-acl.js'
|
||||
import {
|
||||
getSetting,
|
||||
setSetting,
|
||||
@@ -207,6 +251,21 @@ export const repos = {
|
||||
upsertIpBlockStats,
|
||||
listIpBlockStats,
|
||||
deleteIpBlockStatsForAgent,
|
||||
resetIpBlockStatsBaselines,
|
||||
upsertPortBlockStats,
|
||||
listPortBlockStatsAggregate,
|
||||
listPortBlockStatsForIps,
|
||||
mapTopPortsByIp,
|
||||
deletePortBlockStatsForAgent,
|
||||
resetPortBlockStatsBaselines,
|
||||
listAgentPortRules,
|
||||
listEnabledAgentPortRules,
|
||||
getAgentPortRule,
|
||||
insertAgentPortRule,
|
||||
updateAgentPortRule,
|
||||
deleteAgentPortRule,
|
||||
upsertHostFirewallSnapshot,
|
||||
getHostFirewallSnapshot,
|
||||
getSetting,
|
||||
setSetting,
|
||||
listSettings,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { and, asc, eq } from 'drizzle-orm'
|
||||
import type { Db } from '../client.js'
|
||||
import { agentHostFirewallSnapshots, agentPortRules } from '../schema.js'
|
||||
|
||||
export type AgentPortRuleRow = typeof agentPortRules.$inferSelect
|
||||
export type AgentPortRuleInsert = typeof agentPortRules.$inferInsert
|
||||
|
||||
export function listAgentPortRules(db: Db, agentId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(agentPortRules)
|
||||
.where(eq(agentPortRules.agentId, agentId))
|
||||
.orderBy(asc(agentPortRules.priority), asc(agentPortRules.createdAt))
|
||||
.all()
|
||||
}
|
||||
|
||||
export function listEnabledAgentPortRules(db: Db, agentId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(agentPortRules)
|
||||
.where(
|
||||
and(eq(agentPortRules.agentId, agentId), eq(agentPortRules.enabled, 1)),
|
||||
)
|
||||
.orderBy(asc(agentPortRules.priority), asc(agentPortRules.createdAt))
|
||||
.all()
|
||||
}
|
||||
|
||||
export function getAgentPortRule(db: Db, id: string) {
|
||||
return db.select().from(agentPortRules).where(eq(agentPortRules.id, id)).get()
|
||||
}
|
||||
|
||||
export function insertAgentPortRule(db: Db, row: AgentPortRuleInsert) {
|
||||
db.insert(agentPortRules).values(row).run()
|
||||
return getAgentPortRule(db, row.id)
|
||||
}
|
||||
|
||||
export function updateAgentPortRule(
|
||||
db: Db,
|
||||
id: string,
|
||||
patch: Partial<
|
||||
Omit<AgentPortRuleInsert, 'id' | 'agentId' | 'createdAt'>
|
||||
>,
|
||||
) {
|
||||
db.update(agentPortRules)
|
||||
.set({
|
||||
...patch,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(agentPortRules.id, id))
|
||||
.run()
|
||||
return getAgentPortRule(db, id)
|
||||
}
|
||||
|
||||
export function deleteAgentPortRule(db: Db, id: string) {
|
||||
db.delete(agentPortRules).where(eq(agentPortRules.id, id)).run()
|
||||
}
|
||||
|
||||
export function upsertHostFirewallSnapshot(
|
||||
db: Db,
|
||||
agentId: string,
|
||||
payloadJson: string,
|
||||
collectedAt = new Date().toISOString(),
|
||||
rawDigest: string | null = null,
|
||||
) {
|
||||
const existing = db
|
||||
.select()
|
||||
.from(agentHostFirewallSnapshots)
|
||||
.where(eq(agentHostFirewallSnapshots.agentId, agentId))
|
||||
.get()
|
||||
if (existing) {
|
||||
db.update(agentHostFirewallSnapshots)
|
||||
.set({ payloadJson, collectedAt, rawDigest })
|
||||
.where(eq(agentHostFirewallSnapshots.agentId, agentId))
|
||||
.run()
|
||||
} else {
|
||||
db.insert(agentHostFirewallSnapshots)
|
||||
.values({ agentId, payloadJson, collectedAt, rawDigest })
|
||||
.run()
|
||||
}
|
||||
return db
|
||||
.select()
|
||||
.from(agentHostFirewallSnapshots)
|
||||
.where(eq(agentHostFirewallSnapshots.agentId, agentId))
|
||||
.get()
|
||||
}
|
||||
|
||||
export function getHostFirewallSnapshot(db: Db, agentId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(agentHostFirewallSnapshots)
|
||||
.where(eq(agentHostFirewallSnapshots.agentId, agentId))
|
||||
.get()
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
import { and, eq, desc } from 'drizzle-orm'
|
||||
import { and, eq, desc, sql, inArray } from 'drizzle-orm'
|
||||
import type { Db } from '../client.js'
|
||||
import { agentIpBlockStats, agentStatsSamples } from '../schema.js'
|
||||
import {
|
||||
agentIpBlockStats,
|
||||
agentPortBlockStats,
|
||||
agentStatsSamples,
|
||||
} from '../schema.js'
|
||||
|
||||
export function insertStatsSample(
|
||||
db: Db,
|
||||
@@ -36,16 +40,36 @@ export function deleteStatsSamplesForAgent(db: Db, agentId: string) {
|
||||
|
||||
export type IpHitInput = { ip: string; packets: number }
|
||||
|
||||
/** Gap after which a new presence report counts as a re-hit (left EVOFW_HITS). */
|
||||
export const PRESENCE_REHIT_STALE_MS = 150_000
|
||||
|
||||
export type UpsertIpBlockStatsOptions = {
|
||||
/**
|
||||
* MikroTik EVOFW_HITS presence: refresh last_seen every report while IP is listed;
|
||||
* increment packets only on first see or re-hit after stale gap (left the list).
|
||||
* Linux keeps absolute counter deltas (default).
|
||||
*/
|
||||
mode?: 'absolute' | 'presence'
|
||||
/** Override re-hit gap (tests). Default PRESENCE_REHIT_STALE_MS. */
|
||||
presenceStaleMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert per-IP drop counters. Agent reports absolute kernel counters;
|
||||
* CP accumulates deltas (mirrors totalPacketsDropped logic).
|
||||
* Upsert per-IP drop counters.
|
||||
* - absolute (Linux): agent reports kernel counters; CP accumulates deltas.
|
||||
* - presence (MikroTik): last_seen while in HITS; packets = entries into HITS (not per-sync).
|
||||
*/
|
||||
export function upsertIpBlockStats(
|
||||
db: Db,
|
||||
agentId: string,
|
||||
hits: IpHitInput[],
|
||||
now = new Date().toISOString(),
|
||||
opts: UpsertIpBlockStatsOptions = {},
|
||||
) {
|
||||
const mode = opts.mode ?? 'absolute'
|
||||
const staleMs = opts.presenceStaleMs ?? PRESENCE_REHIT_STALE_MS
|
||||
const nowMs = Date.parse(now)
|
||||
|
||||
for (const hit of hits) {
|
||||
const ip = hit.ip.trim()
|
||||
if (!ip) continue
|
||||
@@ -61,6 +85,39 @@ export function upsertIpBlockStats(
|
||||
)
|
||||
.get()
|
||||
|
||||
if (mode === 'presence') {
|
||||
if (!existing) {
|
||||
db.insert(agentIpBlockStats)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
agentId,
|
||||
ip,
|
||||
packets: 1,
|
||||
lastReportedPackets: 1,
|
||||
firstSeenAt: now,
|
||||
lastSeenAt: now,
|
||||
})
|
||||
.run()
|
||||
continue
|
||||
}
|
||||
const prevSeen = Date.parse(existing.lastSeenAt)
|
||||
const gapMs = Number.isFinite(prevSeen) ? nowMs - prevSeen : staleMs + 1
|
||||
const isRehit = gapMs > staleMs
|
||||
db.update(agentIpBlockStats)
|
||||
.set({
|
||||
lastSeenAt: now,
|
||||
...(isRehit
|
||||
? {
|
||||
packets: (existing.packets ?? 0) + 1,
|
||||
lastReportedPackets: (existing.lastReportedPackets ?? 0) + 1,
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
.where(eq(agentIpBlockStats.id, existing.id))
|
||||
.run()
|
||||
continue
|
||||
}
|
||||
|
||||
if (!existing) {
|
||||
db.insert(agentIpBlockStats)
|
||||
.values({
|
||||
@@ -106,3 +163,182 @@ export function deleteIpBlockStatsForAgent(db: Db, agentId: string) {
|
||||
.where(eq(agentIpBlockStats.agentId, agentId))
|
||||
.run()
|
||||
}
|
||||
|
||||
/**
|
||||
* After nft/ipset counter flush (policy apply), kernel absolutes restart at 0.
|
||||
* Traffic detects this via packets_dropped drop; per-IP baselines must reset too,
|
||||
* otherwise ip_hits with packets=0 are omitted from the report and lastReported
|
||||
* stays high → second epoch deltas are lost (Traffic 14 vs Blocked IPs 7).
|
||||
*/
|
||||
export function resetIpBlockStatsBaselines(db: Db, agentId: string) {
|
||||
db.update(agentIpBlockStats)
|
||||
.set({ lastReportedPackets: 0 })
|
||||
.where(eq(agentIpBlockStats.agentId, agentId))
|
||||
.run()
|
||||
}
|
||||
|
||||
export type PortHitInput = {
|
||||
ip: string
|
||||
port: number
|
||||
protocol: 'tcp' | 'udp' | string
|
||||
packets: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert per-(ip, proto, port) deny counters (Linux nft absolute deltas).
|
||||
* deny_port_hits is not flushed on policy apply (timeout ages elements), so
|
||||
* baselines are not auto-reset on Traffic flush — only on stats/reset delete.
|
||||
*/
|
||||
export function upsertPortBlockStats(
|
||||
db: Db,
|
||||
agentId: string,
|
||||
hits: PortHitInput[],
|
||||
now = new Date().toISOString(),
|
||||
) {
|
||||
for (const hit of hits) {
|
||||
const ip = hit.ip.trim()
|
||||
const protocol = String(hit.protocol).toLowerCase()
|
||||
if (!ip || (protocol !== 'tcp' && protocol !== 'udp')) continue
|
||||
const port = Math.floor(hit.port)
|
||||
if (port < 1 || port > 65535) continue
|
||||
const reported = Math.max(0, Math.floor(hit.packets))
|
||||
const existing = db
|
||||
.select()
|
||||
.from(agentPortBlockStats)
|
||||
.where(
|
||||
and(
|
||||
eq(agentPortBlockStats.agentId, agentId),
|
||||
eq(agentPortBlockStats.ip, ip),
|
||||
eq(agentPortBlockStats.port, port),
|
||||
eq(agentPortBlockStats.protocol, protocol),
|
||||
),
|
||||
)
|
||||
.get()
|
||||
|
||||
if (!existing) {
|
||||
db.insert(agentPortBlockStats)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
agentId,
|
||||
ip,
|
||||
port,
|
||||
protocol,
|
||||
packets: reported,
|
||||
lastReportedPackets: reported,
|
||||
firstSeenAt: now,
|
||||
lastSeenAt: now,
|
||||
})
|
||||
.run()
|
||||
continue
|
||||
}
|
||||
|
||||
const prevReported = existing.lastReportedPackets ?? 0
|
||||
const delta =
|
||||
reported >= prevReported ? reported - prevReported : reported
|
||||
const packets = (existing.packets ?? 0) + delta
|
||||
db.update(agentPortBlockStats)
|
||||
.set({
|
||||
packets,
|
||||
lastReportedPackets: reported,
|
||||
...(delta > 0 ? { lastSeenAt: now } : {}),
|
||||
})
|
||||
.where(eq(agentPortBlockStats.id, existing.id))
|
||||
.run()
|
||||
}
|
||||
}
|
||||
|
||||
export type PortBlockAggregateRow = {
|
||||
port: number
|
||||
protocol: string
|
||||
packets: number
|
||||
lastSeenAt: string
|
||||
}
|
||||
|
||||
export function listPortBlockStatsAggregate(
|
||||
db: Db,
|
||||
agentId: string,
|
||||
limit = 50,
|
||||
): PortBlockAggregateRow[] {
|
||||
const rows = db
|
||||
.select({
|
||||
port: agentPortBlockStats.port,
|
||||
protocol: agentPortBlockStats.protocol,
|
||||
packets: sql<number>`sum(${agentPortBlockStats.packets})`.mapWith(Number),
|
||||
lastSeenAt: sql<string>`max(${agentPortBlockStats.lastSeenAt})`,
|
||||
})
|
||||
.from(agentPortBlockStats)
|
||||
.where(eq(agentPortBlockStats.agentId, agentId))
|
||||
.groupBy(agentPortBlockStats.port, agentPortBlockStats.protocol)
|
||||
.orderBy(sql`sum(${agentPortBlockStats.packets}) desc`)
|
||||
.limit(limit)
|
||||
.all()
|
||||
return rows.map((r) => ({
|
||||
port: r.port,
|
||||
protocol: r.protocol,
|
||||
packets: r.packets ?? 0,
|
||||
lastSeenAt: r.lastSeenAt,
|
||||
}))
|
||||
}
|
||||
|
||||
export type PortBlockPerIpRow = {
|
||||
ip: string
|
||||
port: number
|
||||
protocol: string
|
||||
packets: number
|
||||
}
|
||||
|
||||
/** Raw rows for a set of IPs (caller picks top-N per IP). */
|
||||
export function listPortBlockStatsForIps(
|
||||
db: Db,
|
||||
agentId: string,
|
||||
ips: string[],
|
||||
): PortBlockPerIpRow[] {
|
||||
if (ips.length === 0) return []
|
||||
return db
|
||||
.select({
|
||||
ip: agentPortBlockStats.ip,
|
||||
port: agentPortBlockStats.port,
|
||||
protocol: agentPortBlockStats.protocol,
|
||||
packets: agentPortBlockStats.packets,
|
||||
})
|
||||
.from(agentPortBlockStats)
|
||||
.where(
|
||||
and(
|
||||
eq(agentPortBlockStats.agentId, agentId),
|
||||
inArray(agentPortBlockStats.ip, ips),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(agentPortBlockStats.packets))
|
||||
.all()
|
||||
}
|
||||
|
||||
/** Top `perIpLimit` ports per IP, keyed by IP. */
|
||||
export function mapTopPortsByIp(
|
||||
db: Db,
|
||||
agentId: string,
|
||||
ips: string[],
|
||||
perIpLimit = 5,
|
||||
): Map<string, PortBlockPerIpRow[]> {
|
||||
const all = listPortBlockStatsForIps(db, agentId, ips)
|
||||
const map = new Map<string, PortBlockPerIpRow[]>()
|
||||
for (const row of all) {
|
||||
const list = map.get(row.ip) ?? []
|
||||
if (list.length >= perIpLimit) continue
|
||||
list.push(row)
|
||||
map.set(row.ip, list)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
export function deletePortBlockStatsForAgent(db: Db, agentId: string) {
|
||||
db.delete(agentPortBlockStats)
|
||||
.where(eq(agentPortBlockStats.agentId, agentId))
|
||||
.run()
|
||||
}
|
||||
|
||||
export function resetPortBlockStatsBaselines(db: Db, agentId: string) {
|
||||
db.update(agentPortBlockStats)
|
||||
.set({ lastReportedPackets: 0 })
|
||||
.where(eq(agentPortBlockStats.agentId, agentId))
|
||||
.run()
|
||||
}
|
||||
@@ -228,6 +228,96 @@ export const agentIpBlockStats = sqliteTable(
|
||||
}),
|
||||
)
|
||||
|
||||
/** Per-(ip, proto, dport) deny hits from Linux nft dynamic concat set. */
|
||||
export const agentPortBlockStats = sqliteTable(
|
||||
'agent_port_block_stats',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
agentId: text('agent_id')
|
||||
.notNull()
|
||||
.references(() => agents.id, { onDelete: 'cascade' }),
|
||||
ip: text('ip').notNull(),
|
||||
port: integer('port').notNull(),
|
||||
protocol: text('protocol').notNull(), // tcp | udp
|
||||
packets: integer('packets').notNull().default(0),
|
||||
lastReportedPackets: integer('last_reported_packets').notNull().default(0),
|
||||
firstSeenAt: text('first_seen_at')
|
||||
.notNull()
|
||||
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
|
||||
lastSeenAt: text('last_seen_at')
|
||||
.notNull()
|
||||
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
|
||||
},
|
||||
(t) => ({
|
||||
agentIpPortProto: uniqueIndex(
|
||||
'idx_agent_port_block_stats_agent_ip_port_proto',
|
||||
).on(t.agentId, t.ip, t.port, t.protocol),
|
||||
agentPackets: index('idx_agent_port_block_stats_agent_packets').on(
|
||||
t.agentId,
|
||||
t.packets,
|
||||
),
|
||||
agentPortProto: index('idx_agent_port_block_stats_agent_port_proto').on(
|
||||
t.agentId,
|
||||
t.port,
|
||||
t.protocol,
|
||||
),
|
||||
}),
|
||||
)
|
||||
|
||||
/** Desired L4 port ACL per Linux agent (open/close). */
|
||||
export const agentPortRules = sqliteTable(
|
||||
'agent_port_rules',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
agentId: text('agent_id')
|
||||
.notNull()
|
||||
.references(() => agents.id, { onDelete: 'cascade' }),
|
||||
action: text('action').notNull(), // open | close
|
||||
protocol: text('protocol').notNull(), // tcp | udp | both
|
||||
portStart: integer('port_start').notNull(),
|
||||
portEnd: integer('port_end').notNull(),
|
||||
srcKind: text('src_kind').notNull(), // all | cidr | list
|
||||
srcCidr: text('src_cidr'),
|
||||
listId: text('list_id').references(() => ipLists.id, {
|
||||
onDelete: 'set null',
|
||||
}),
|
||||
enabled: integer('enabled').notNull().default(1),
|
||||
comment: text('comment'),
|
||||
priority: integer('priority').notNull().default(100),
|
||||
createdAt: text('created_at')
|
||||
.notNull()
|
||||
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
|
||||
updatedAt: text('updated_at')
|
||||
.notNull()
|
||||
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
|
||||
},
|
||||
(t) => ({
|
||||
agentPriority: index('idx_agent_port_rules_agent_priority').on(
|
||||
t.agentId,
|
||||
t.priority,
|
||||
),
|
||||
agentEnabled: index('idx_agent_port_rules_agent_enabled').on(
|
||||
t.agentId,
|
||||
t.enabled,
|
||||
),
|
||||
}),
|
||||
)
|
||||
|
||||
/** Latest observed host firewall + listeners snapshot from Linux agent. */
|
||||
export const agentHostFirewallSnapshots = sqliteTable(
|
||||
'agent_host_firewall_snapshots',
|
||||
{
|
||||
agentId: text('agent_id')
|
||||
.primaryKey()
|
||||
.references(() => agents.id, { onDelete: 'cascade' }),
|
||||
collectedAt: text('collected_at')
|
||||
.notNull()
|
||||
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
|
||||
payloadJson: text('payload_json').notNull(),
|
||||
rawDigest: text('raw_digest'),
|
||||
},
|
||||
)
|
||||
|
||||
/** Short install invite links (`/agent-install/:id` and `/:slug`). */
|
||||
export const agentInstallLinks = sqliteTable(
|
||||
'agent_install_links',
|
||||
@@ -291,6 +381,9 @@ export const schema = {
|
||||
ipOverrides,
|
||||
agentStatsSamples,
|
||||
agentIpBlockStats,
|
||||
agentPortBlockStats,
|
||||
agentPortRules,
|
||||
agentHostFirewallSnapshots,
|
||||
agentInstallLinks,
|
||||
auditLog,
|
||||
}
|
||||
@@ -226,6 +226,55 @@ export const applyReportIpHitSchema = z.object({
|
||||
packets: z.number().int().nonnegative(),
|
||||
})
|
||||
|
||||
export const applyReportPortHitSchema = z.object({
|
||||
ip: z.string().min(1).max(64),
|
||||
port: z.number().int().min(1).max(65535),
|
||||
protocol: z.enum(['tcp', 'udp']),
|
||||
packets: z.number().int().nonnegative(),
|
||||
})
|
||||
|
||||
export const hostFwOwnershipSchema = z.enum(['evofw', 'foreign'])
|
||||
export const hostFwBackendSchema = z.enum([
|
||||
'nft',
|
||||
'iptables',
|
||||
'ufw',
|
||||
'firewalld',
|
||||
'listener',
|
||||
])
|
||||
|
||||
export const hostFwRuleSchema = z.object({
|
||||
ownership: hostFwOwnershipSchema,
|
||||
backend: hostFwBackendSchema,
|
||||
table: z.string().max(128).nullish(),
|
||||
chain: z.string().max(128).nullish(),
|
||||
action: z.string().max(64).nullish(),
|
||||
protocol: z.string().max(16).nullish(),
|
||||
dport: z.string().max(64).nullish(),
|
||||
sport: z.string().max(64).nullish(),
|
||||
saddr: z.string().max(128).nullish(),
|
||||
daddr: z.string().max(128).nullish(),
|
||||
comment: z.string().max(256).nullish(),
|
||||
raw: z.string().max(512),
|
||||
})
|
||||
|
||||
export const hostListenerSchema = z.object({
|
||||
protocol: z.string().max(16),
|
||||
port: z.number().int().min(0).max(65535),
|
||||
address: z.string().max(128),
|
||||
process: z.string().max(128).nullish(),
|
||||
})
|
||||
|
||||
export const hostFirewallPayloadSchema = z.object({
|
||||
rules: z.array(hostFwRuleSchema).max(500).default([]),
|
||||
listeners: z.array(hostListenerSchema).max(200).default([]),
|
||||
})
|
||||
|
||||
/** Wire format for apply-report — unknown elements filtered in route (safeParse). */
|
||||
export const applyReportHostFirewallSchema = z.object({
|
||||
rules: z.array(z.unknown()).max(500).optional().default([]),
|
||||
listeners: z.array(z.unknown()).max(200).optional().default([]),
|
||||
})
|
||||
|
||||
export const applyReportBodySchema = z.object({
|
||||
status: z.string(),
|
||||
prefix_count: z.number().int().optional(),
|
||||
@@ -236,6 +285,137 @@ export const applyReportBodySchema = z.object({
|
||||
source: z.string().optional(),
|
||||
/** Linux nft/ipset per-element drop counters (top-N, packets > 0). */
|
||||
ip_hits: z.array(applyReportIpHitSchema).max(200).optional(),
|
||||
/** Linux nft dynamic set per-(ip, proto, dport) deny hits (top-N). */
|
||||
port_hits: z.array(applyReportPortHitSchema).max(500).optional(),
|
||||
/** Observed host firewall + listeners (Linux). */
|
||||
host_firewall: applyReportHostFirewallSchema.optional(),
|
||||
})
|
||||
|
||||
export const agentPortRuleActionSchema = z.enum(['open', 'close'])
|
||||
export const agentPortRuleProtocolSchema = z.enum(['tcp', 'udp', 'both'])
|
||||
export const agentPortRuleSrcKindSchema = z.enum(['all', 'cidr', 'list'])
|
||||
|
||||
export const agentPortRuleSchema = z.object({
|
||||
id: z.string(),
|
||||
agent_id: z.string(),
|
||||
action: agentPortRuleActionSchema,
|
||||
protocol: agentPortRuleProtocolSchema,
|
||||
port_start: z.number().int().min(1).max(65535),
|
||||
port_end: z.number().int().min(1).max(65535),
|
||||
src_kind: agentPortRuleSrcKindSchema,
|
||||
src_cidr: z.string().nullable().optional(),
|
||||
list_id: z.string().nullable().optional(),
|
||||
list_name: z.string().nullable().optional(),
|
||||
enabled: z.boolean(),
|
||||
comment: z.string().nullable().optional(),
|
||||
priority: z.number().int(),
|
||||
created_at: z.string(),
|
||||
updated_at: z.string(),
|
||||
})
|
||||
|
||||
export const createAgentPortRuleBodySchema = z
|
||||
.object({
|
||||
action: agentPortRuleActionSchema,
|
||||
protocol: agentPortRuleProtocolSchema.default('tcp'),
|
||||
port_start: z.number().int().min(1).max(65535),
|
||||
port_end: z.number().int().min(1).max(65535).optional(),
|
||||
src_kind: agentPortRuleSrcKindSchema.default('all'),
|
||||
src_cidr: z.string().min(1).max(64).optional(),
|
||||
list_id: z.string().min(1).optional(),
|
||||
enabled: z.boolean().optional().default(true),
|
||||
comment: z.string().max(500).optional(),
|
||||
priority: z.number().int().optional().default(100),
|
||||
})
|
||||
.superRefine((v, ctx) => {
|
||||
const end = v.port_end ?? v.port_start
|
||||
if (end < v.port_start) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'port_end must be >= port_start',
|
||||
path: ['port_end'],
|
||||
})
|
||||
}
|
||||
if (v.src_kind === 'cidr' && !v.src_cidr?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'src_cidr required when src_kind=cidr',
|
||||
path: ['src_cidr'],
|
||||
})
|
||||
}
|
||||
if (v.src_kind === 'list' && !v.list_id?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'list_id required when src_kind=list',
|
||||
path: ['list_id'],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
export const updateAgentPortRuleBodySchema = z
|
||||
.object({
|
||||
action: agentPortRuleActionSchema.optional(),
|
||||
protocol: agentPortRuleProtocolSchema.optional(),
|
||||
port_start: z.number().int().min(1).max(65535).optional(),
|
||||
port_end: z.number().int().min(1).max(65535).optional(),
|
||||
src_kind: agentPortRuleSrcKindSchema.optional(),
|
||||
src_cidr: z.string().min(1).max(64).nullable().optional(),
|
||||
list_id: z.string().min(1).nullable().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
comment: z.string().max(500).nullable().optional(),
|
||||
priority: z.number().int().optional(),
|
||||
})
|
||||
.refine((o) => Object.keys(o).length > 0, { message: 'empty update' })
|
||||
|
||||
export const importAgentPortRulesBodySchema = z
|
||||
.object({
|
||||
from: z.enum(['list', 'set']),
|
||||
list_id: z.string().min(1).optional(),
|
||||
set_id: z.string().min(1).optional(),
|
||||
action: agentPortRuleActionSchema,
|
||||
protocol: agentPortRuleProtocolSchema.default('tcp'),
|
||||
ports: z
|
||||
.array(
|
||||
z.object({
|
||||
port_start: z.number().int().min(1).max(65535),
|
||||
port_end: z.number().int().min(1).max(65535).optional(),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(50),
|
||||
enabled: z.boolean().optional().default(true),
|
||||
comment: z.string().max(500).optional(),
|
||||
})
|
||||
.superRefine((v, ctx) => {
|
||||
if (v.from === 'list' && !v.list_id?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'list_id required when from=list',
|
||||
path: ['list_id'],
|
||||
})
|
||||
}
|
||||
if (v.from === 'set' && !v.set_id?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
message: 'set_id required when from=set',
|
||||
path: ['set_id'],
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/** Expanded port rule for agent policy apply_version >= 3. */
|
||||
export const agentPolicyPortRuleSchema = z.object({
|
||||
id: z.string(),
|
||||
action: agentPortRuleActionSchema,
|
||||
protocol: z.enum(['tcp', 'udp']),
|
||||
port_start: z.number().int(),
|
||||
port_end: z.number().int(),
|
||||
src_cidrs: z.array(z.string()),
|
||||
})
|
||||
|
||||
export const agentIpPortStatSchema = z.object({
|
||||
port: z.number().int(),
|
||||
protocol: z.enum(['tcp', 'udp']),
|
||||
packets: z.number().int(),
|
||||
})
|
||||
|
||||
export const agentIpBlockStatSchema = z.object({
|
||||
@@ -243,6 +423,14 @@ export const agentIpBlockStatSchema = z.object({
|
||||
packets: z.number().int(),
|
||||
first_seen_at: z.string(),
|
||||
last_seen_at: z.string(),
|
||||
ports: z.array(agentIpPortStatSchema).optional(),
|
||||
})
|
||||
|
||||
export const agentPortBlockStatSchema = z.object({
|
||||
port: z.number().int(),
|
||||
protocol: z.enum(['tcp', 'udp']),
|
||||
packets: z.number().int(),
|
||||
last_seen_at: z.string(),
|
||||
})
|
||||
|
||||
export const agentPolicySchema = z.object({
|
||||
@@ -254,7 +442,10 @@ export const agentPolicySchema = z.object({
|
||||
policy_mode: policyModeSchema.optional(),
|
||||
deny_cidrs: z.array(z.string()),
|
||||
allow_cidrs: z.array(z.string()),
|
||||
port_rules: z.array(agentPolicyPortRuleSchema).optional(),
|
||||
sync_interval_sec: z.number().int(),
|
||||
/** sha256 of GET /v1/agent/sync-script (Linux self-update). Not part of policy.hash. */
|
||||
script_sha256: z.string().optional(),
|
||||
})
|
||||
|
||||
export const agentPolicyPreviewSchema = z.object({
|
||||
@@ -262,7 +453,7 @@ export const agentPolicyPreviewSchema = z.object({
|
||||
hash: z.string(),
|
||||
generation: z.number().int(),
|
||||
sync_interval_sec: z.number().int(),
|
||||
apply_version: z.literal(2),
|
||||
apply_version: z.literal(3),
|
||||
summary: z.object({
|
||||
sets: z.number().int(),
|
||||
rules_deny: z.number().int(),
|
||||
@@ -271,6 +462,7 @@ export const agentPolicyPreviewSchema = z.object({
|
||||
cidrs_allow: z.number().int(),
|
||||
overrides: z.number().int(),
|
||||
conflicts_dropped: z.number().int(),
|
||||
port_rules: z.number().int().optional(),
|
||||
}),
|
||||
chain: z.array(
|
||||
z.object({
|
||||
@@ -287,6 +479,7 @@ export const agentPolicyPreviewSchema = z.object({
|
||||
allow_cidrs: z.array(z.string()),
|
||||
deny_cidrs_total: z.number().int(),
|
||||
allow_cidrs_total: z.number().int(),
|
||||
port_rules: z.array(agentPolicyPortRuleSchema).optional(),
|
||||
})
|
||||
|
||||
export const dashboardStatsSchema = z.object({
|
||||
@@ -364,6 +557,11 @@ export type PolicySet = z.infer<typeof policySetSchema>
|
||||
export type IpOverride = z.infer<typeof ipOverrideSchema>
|
||||
export type AgentPolicy = z.infer<typeof agentPolicySchema>
|
||||
export type AgentPolicyPreview = z.infer<typeof agentPolicyPreviewSchema>
|
||||
export type AgentPortRule = z.infer<typeof agentPortRuleSchema>
|
||||
export type CreateAgentPortRuleBody = z.infer<typeof createAgentPortRuleBodySchema>
|
||||
export type UpdateAgentPortRuleBody = z.infer<typeof updateAgentPortRuleBodySchema>
|
||||
export type ImportAgentPortRulesBody = z.infer<typeof importAgentPortRulesBodySchema>
|
||||
export type HostFirewallPayload = z.infer<typeof hostFirewallPayloadSchema>
|
||||
export type DashboardStats = z.infer<typeof dashboardStatsSchema>
|
||||
export type InstallLink = z.infer<typeof installLinkSchema>
|
||||
export type EvobgpCommunity = z.infer<typeof evobgpCommunitySchema>
|
||||
|
||||
@@ -70,9 +70,8 @@ function SheetContent({
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
<XIcon aria-hidden="true" />
|
||||
<span className="sr-only">Закрыть</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Popup>
|
||||
|
||||
@@ -194,8 +194,8 @@ function Sidebar({
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
<SheetTitle>Боковое меню</SheetTitle>
|
||||
<SheetDescription>Навигация по приложению</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
@@ -254,7 +254,7 @@ function SidebarTrigger({
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
const { toggleSidebar, open, openMobile, isMobile } = useSidebar()
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -262,6 +262,9 @@ function SidebarTrigger({
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
aria-label="Боковое меню"
|
||||
aria-expanded={isMobile ? openMobile : open}
|
||||
className={cn(className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
@@ -269,8 +272,8 @@ function SidebarTrigger({
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
<PanelLeftIcon aria-hidden="true" />
|
||||
<span className="sr-only">Боковое меню</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -282,10 +285,10 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
aria-label="Боковое меню"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
title="Боковое меню"
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
|
||||
Generated
+4362
-2
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env sh
|
||||
# Абсолютные пути для actions/cache: на act_runner «~» часто не раскрывается → cache miss.
|
||||
set -eu
|
||||
: "${GITHUB_ENV:?GITHUB_ENV required (Gitea/GitHub Actions)}"
|
||||
|
||||
HOME_DIR="${HOME:-}"
|
||||
if [ -z "$HOME_DIR" ]; then
|
||||
HOME_DIR="$(getent passwd "$(id -u)" 2>/dev/null | cut -d: -f6 || true)"
|
||||
fi
|
||||
HOME_DIR="${HOME_DIR:-/root}"
|
||||
|
||||
PNPM_STORE_DIR="${HOME_DIR}/.pnpm-store"
|
||||
COREPACK_HOME="${HOME_DIR}/.cache/node/corepack"
|
||||
|
||||
mkdir -p "$PNPM_STORE_DIR" "$COREPACK_HOME"
|
||||
|
||||
{
|
||||
echo "HOME_DIR=${HOME_DIR}"
|
||||
echo "PNPM_STORE_DIR=${PNPM_STORE_DIR}"
|
||||
echo "COREPACK_HOME=${COREPACK_HOME}"
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
echo "cache-env HOME_DIR=${HOME_DIR}"
|
||||
echo "cache-env PNPM_STORE_DIR=${PNPM_STORE_DIR}"
|
||||
echo "cache-env COREPACK_HOME=${COREPACK_HOME}"
|
||||
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env sh
|
||||
# pnpm install из кэша Gitea (store + node_modules). Без повторного download при hit.
|
||||
set -eu
|
||||
: "${PNPM_STORE_DIR:?PNPM_STORE_DIR required — run scripts/ci/export-cache-env.sh first}"
|
||||
|
||||
export COREPACK_HOME="${COREPACK_HOME:-${HOME:-/root}/.cache/node/corepack}"
|
||||
mkdir -p "$PNPM_STORE_DIR" "$COREPACK_HOME"
|
||||
corepack enable
|
||||
pnpm config set store-dir "$PNPM_STORE_DIR"
|
||||
|
||||
echo "pnpm store: $(pnpm store path)"
|
||||
echo "PNPM_CACHE_HIT=${PNPM_CACHE_HIT:-}"
|
||||
|
||||
if [ "${PNPM_CACHE_HIT:-}" = "true" ]; then
|
||||
if pnpm install --frozen-lockfile --offline; then
|
||||
echo "pnpm install --offline (cache hit)"
|
||||
exit 0
|
||||
fi
|
||||
echo "offline install failed — prefer-offline"
|
||||
fi
|
||||
pnpm install --frozen-lockfile --prefer-offline
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Warns about commits since the last tag that semantic-release cannot parse.
|
||||
* Exit 0 always — semantic-release still decides release/no-op.
|
||||
*/
|
||||
import { execSync } from 'node:child_process';
|
||||
import { CommitParser } from 'conventional-commits-parser';
|
||||
|
||||
const parser = new CommitParser();
|
||||
|
||||
const RELEASABLE = new Set(['feat', 'fix', 'perf', 'ci', 'refactor']);
|
||||
|
||||
function lastTag() {
|
||||
try {
|
||||
return execSync('git describe --tags --abbrev=0', { encoding: 'utf8' }).trim();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function commitsSince(ref) {
|
||||
const range = ref ? `${ref}..HEAD` : 'HEAD';
|
||||
const out = execSync(`git log ${range} --format=%H%x09%s`, { encoding: 'utf8' }).trim();
|
||||
if (!out) return [];
|
||||
return out.split('\n').map((line) => {
|
||||
const [hash, subject] = line.split('\t');
|
||||
return { hash: hash.trim(), subject: subject.trim() };
|
||||
});
|
||||
}
|
||||
|
||||
const tag = lastTag();
|
||||
const commits = commitsSince(tag);
|
||||
const unparseable = [];
|
||||
const releasable = [];
|
||||
|
||||
for (const { hash, subject } of commits) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parser.parse(subject);
|
||||
} catch {
|
||||
unparseable.push({ hash: hash.slice(0, 7), subject });
|
||||
continue;
|
||||
}
|
||||
if (!parsed?.type) {
|
||||
unparseable.push({ hash: hash.slice(0, 7), subject });
|
||||
continue;
|
||||
}
|
||||
if (RELEASABLE.has(parsed.type)) {
|
||||
releasable.push({ hash: hash.slice(0, 7), subject, type: parsed.type });
|
||||
}
|
||||
}
|
||||
|
||||
if (commits.length === 0) {
|
||||
console.log(`No new commits since ${tag || 'initial'}.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (unparseable.length > 0) {
|
||||
console.warn('::warning:: Commits not parseable by semantic-release (no version bump):');
|
||||
for (const b of unparseable) {
|
||||
console.warn(` ${b.hash} ${b.subject}`);
|
||||
}
|
||||
console.warn('Fix: single scope without commas, e.g. refactor(web): summary');
|
||||
}
|
||||
|
||||
if (releasable.length > 0) {
|
||||
console.log(`Releasable since ${tag}: ${releasable.length} commit(s).`);
|
||||
} else {
|
||||
console.warn('::warning:: No releasable commits since last tag — release job will no-op.');
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
Reference in new issue
Block a user