Compare commits
17
Commits
f160992d94
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a55dc676b9 | ||
|
|
4fedf83078 | ||
|
|
9121cbe842 | ||
|
|
ecaa22ed97 | ||
|
|
6dc5fb5f3b | ||
|
|
1afa07053a | ||
|
|
a2ad637a38 | ||
|
|
d552f4f326 | ||
|
|
ef2a333430 | ||
|
|
bfaed511bd | ||
|
|
43f5ac2525 | ||
|
|
c5069fbdaf | ||
|
|
402182195f | ||
|
|
3815575799 | ||
|
|
ee3429b747 | ||
|
|
4ee78032c4 | ||
|
|
9fd7ddb26c |
No files matched your search
@@ -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 `668fb463eb`.** 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,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).
|
||||||
@@ -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 20 ReUI building blocks: `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`. 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
|
||||||
|
|
||||||
@@ -312,6 +311,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,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
|
||||||
|
|||||||
@@ -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,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 `668fb463eb`.** 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,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).
|
||||||
@@ -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 20 ReUI building blocks: `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`. 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
|
||||||
|
|
||||||
@@ -312,6 +311,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,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
|
||||||
|
|||||||
@@ -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)).
|
||||||
|
|||||||
@@ -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` |
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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`, …
|
||||||
|
|
||||||
|
|||||||
@@ -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 `668fb463eb`.** 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,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).
|
||||||
@@ -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 20 ReUI building blocks: `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`. 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
|
||||||
|
|
||||||
@@ -312,6 +311,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,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
|
||||||
|
|||||||
@@ -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)).
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Build context = repository root (deploy/docker/docker-bake.hcl).
|
||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.github
|
||||||
|
.cursor
|
||||||
|
.claude
|
||||||
|
.codegraph
|
||||||
|
.agents
|
||||||
|
memory-bank
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
|
||||||
|
**/.DS_Store
|
||||||
|
**/Thumbs.db
|
||||||
|
**/.env
|
||||||
|
**/.env.*
|
||||||
|
!**/.env.example
|
||||||
|
!**/.env.*.example
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
**/dist
|
||||||
|
apps/web/src/routeTree.gen.ts
|
||||||
|
apps/web/playwright-report
|
||||||
|
apps/web/test-results
|
||||||
|
|
||||||
|
*.md
|
||||||
|
AGENTS.md
|
||||||
|
CONTRIBUTING.md
|
||||||
|
LICENSE
|
||||||
|
docs
|
||||||
|
|
||||||
|
.pre-commit-config.yaml
|
||||||
|
.releaserc.json
|
||||||
|
.commitlintrc.*
|
||||||
|
commitlint.config.cjs
|
||||||
|
redocly.yaml
|
||||||
|
package-lock.json
|
||||||
|
|
||||||
|
data
|
||||||
|
*.exe
|
||||||
|
**/*.test.ts
|
||||||
|
**/*.spec.ts
|
||||||
|
coverage
|
||||||
|
.coverage
|
||||||
|
*.out
|
||||||
|
.release-version
|
||||||
|
CHANGELOG.md
|
||||||
|
deploy/docker/docker-bake.override.hcl
|
||||||
|
deploy/compose/runtime-logs
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Gitea Actions
|
||||||
|
|
||||||
|
| Workflow | Когда | Что |
|
||||||
|
|----------|--------|-----|
|
||||||
|
| [workflows/ci.yaml](workflows/ci.yaml) | pull request в main/master | quality gates + commitlint |
|
||||||
|
| [workflows/cd.yaml](workflows/cd.yaml) | push в main/master | quality gates + semantic-release + docker push |
|
||||||
|
| [workflows/quality.yaml](workflows/quality.yaml) | reusable (`workflow_call`) | changes, openapi, web, api, commitlint, docker-check |
|
||||||
|
|
||||||
|
Подробнее: [docs/releasing.md](../docs/releasing.md).
|
||||||
|
|
||||||
|
## CI (quality gates)
|
||||||
|
|
||||||
|
Job **changes** вычисляет флаги по путям в diff. Полный прогон: `.gitea/workflows/*`, `scripts/*`, корневой `package.json` / `pnpm-lock.yaml` / `.releaserc.json`. Правки `.cursor/`, `.claude/`, `*.md` (кроме `docs/openapi.yaml`) quality jobs не запускают.
|
||||||
|
|
||||||
|
На **pull request** — **commitlint**. При изменении `deploy/docker/**` / `.dockerignore` — job **docker-check** (`bake --print`, bake без `--push` если есть доступ к registry).
|
||||||
|
|
||||||
|
Кэш зависимостей — нативный `actions/cache` (cache server act_runner), ключ `sha256sum` lockfile (не `hashFiles`). Пути **абсолютные** (`$HOME/.pnpm-store`): тильда `~` на Gitea часто не раскрывается и даёт вечный miss.
|
||||||
|
|
||||||
|
Кэшируется целиком: pnpm store + `node_modules` + corepack. При hit: `pnpm install --offline`.
|
||||||
|
|
||||||
|
Если restore пишет `connect ECONNREFUSED` / `cache server not configured` — на runner включите cache server (см. ниже). Иначе каждый job снова качает пакеты (~минуты).
|
||||||
|
|
||||||
|
Runner: `ubuntu-latest`, Docker для **docker-check** (PR) и **publish** (CD).
|
||||||
|
|
||||||
|
## CD (job publish)
|
||||||
|
|
||||||
|
После успешных quality gates на **push в main** job **publish**:
|
||||||
|
|
||||||
|
1. `pnpm exec semantic-release` — тег `vX.Y.Z` на **текущий commit** (без дополнительного commit в main).
|
||||||
|
2. Gitea Release + `CHANGELOG.md` как attachment (не в git).
|
||||||
|
3. Зеркало base-образов в `evofw-buildcache:base-*` (`deploy/docker/mirror-base-images.sh`; skip существующих тегов, `linux/amd64`, retry при 429).
|
||||||
|
4. `docker buildx bake default --push` с `VERSION=X.Y.Z`, `pull=false`, named builder `evofw` (`cleanup: false`).
|
||||||
|
|
||||||
|
Если releasable-коммитов нет — semantic-release no-op, образы не публикуются.
|
||||||
|
|
||||||
|
Повтор упавшего **publish** (тег уже есть, bake нет): detect берёт `v*` на `HEAD` и всё равно пушит образы. Подробнее: [docs/releasing.md](../docs/releasing.md#перезапуск-упавшего-job-publish).
|
||||||
|
|
||||||
|
### Секреты
|
||||||
|
|
||||||
|
**`ACTIONS_PAT`**: push tags, releases, Container Registry. Для git tag fallback: `github.token`. Push OCI — **только PAT** (у `GITEA_TOKEN` нет права packages).
|
||||||
|
|
||||||
|
### Теги образов
|
||||||
|
|
||||||
|
```text
|
||||||
|
git.shx.one/<owner>/evofw:latest
|
||||||
|
git.shx.one/<owner>/evofw:v1.2.3
|
||||||
|
git.shx.one/<owner>/evofw:1.2.3
|
||||||
|
git.shx.one/<owner>/evofw:<short-sha>
|
||||||
|
git.shx.one/<owner>/evofw:sha-<full-sha>
|
||||||
|
```
|
||||||
|
|
||||||
|
Тот же манифест публикуется как `evofirewall` (drop-in для старого compose).
|
||||||
|
|
||||||
|
Кэш сборки: `evofw-buildcache:node-buildcache` и `evofw-buildcache:base-*`.
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull git.shx.one/denozord/evofw:1.2.3
|
||||||
|
```
|
||||||
|
|
||||||
|
См. [deploy/docker/README.md](../deploy/docker/README.md).
|
||||||
|
|
||||||
|
## act_runner: cache server
|
||||||
|
|
||||||
|
`actions/cache` ходит в **встроенный cache server** runner (не GitHub `type=gha`). Кэш локален для этого runner.
|
||||||
|
|
||||||
|
В `config.yaml` runner:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
cache:
|
||||||
|
enabled: true
|
||||||
|
dir: "" # по умолчанию $HOME/.cache/actcache
|
||||||
|
host: "" # IP, доступный из job-контейнера (не 0.0.0.0)
|
||||||
|
port: 8088
|
||||||
|
```
|
||||||
|
|
||||||
|
Если runner в Docker, а jobs — отдельные контейнеры: пробросьте порт и задайте `host` (LAN IP хоста) или `external_server: "http://<host>:8088/"`. Иначе restore — timeout/ECONNREFUSED и пакеты качаются снова.
|
||||||
|
|
||||||
|
Не делайте `docker system prune -a` по cron: сотрётся и Docker-кэш FROM, и пользы от `cleanup: false` у buildx не будет.
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
name: CD
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
quality:
|
||||||
|
uses: ./.gitea/workflows/quality.yaml
|
||||||
|
with:
|
||||||
|
is_pull_request: false
|
||||||
|
before_sha: ${{ github.event.before }}
|
||||||
|
head_sha: ${{ github.sha }}
|
||||||
|
allow_registry_login: false
|
||||||
|
secrets:
|
||||||
|
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
|
||||||
|
|
||||||
|
publish:
|
||||||
|
needs: [quality]
|
||||||
|
if: >-
|
||||||
|
always() &&
|
||||||
|
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/master') &&
|
||||||
|
needs.quality.result == 'success'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
packages: write
|
||||||
|
releases: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
fetch-tags: true
|
||||||
|
token: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
||||||
|
persist-credentials: true
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
- name: Export cache paths
|
||||||
|
run: sh scripts/ci/export-cache-env.sh
|
||||||
|
- id: pnpm-hash
|
||||||
|
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||||
|
- id: pnpm-cache
|
||||||
|
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ env.PNPM_STORE_DIR }}
|
||||||
|
${{ env.COREPACK_HOME }}
|
||||||
|
node_modules
|
||||||
|
apps/web/node_modules
|
||||||
|
apps/api/node_modules
|
||||||
|
packages/ui/node_modules
|
||||||
|
packages/shared/node_modules
|
||||||
|
packages/db/node_modules
|
||||||
|
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||||
|
restore-keys: |
|
||||||
|
pnpm-${{ runner.os }}-
|
||||||
|
- name: Install release tooling
|
||||||
|
env:
|
||||||
|
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||||
|
run: sh scripts/ci/pnpm-ci.sh
|
||||||
|
- name: Verify releasable commit messages
|
||||||
|
run: pnpm exec node scripts/commit/verify-release-commits.mjs
|
||||||
|
- name: Semantic release
|
||||||
|
run: pnpm exec semantic-release
|
||||||
|
env:
|
||||||
|
GITEA_URL: https://git.shx.one
|
||||||
|
GITEA_TOKEN: ${{ secrets.ACTIONS_PAT || gitea.token }}
|
||||||
|
- name: Detect new release
|
||||||
|
id: rel
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
version=""
|
||||||
|
if [ -f .release-version ]; then
|
||||||
|
version="$(tr -d '[:space:]' < .release-version)"
|
||||||
|
echo "New release from semantic-release: $version"
|
||||||
|
else
|
||||||
|
git fetch --tags --force origin || true
|
||||||
|
tag="$(git tag --points-at HEAD --list 'v*.*.*' | sort -V | tail -n1 || true)"
|
||||||
|
if [ -n "${tag:-}" ]; then
|
||||||
|
version="${tag#v}"
|
||||||
|
echo "Reuse existing tag $tag on HEAD (release retry)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ -n "${version:-}" ]; then
|
||||||
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "released=true" >> "$GITHUB_OUTPUT"
|
||||||
|
else
|
||||||
|
echo "released=false" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "No releasable commits — skipping image publish"
|
||||||
|
fi
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
if: steps.rel.outputs.released == 'true'
|
||||||
|
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||||
|
with:
|
||||||
|
name: evofw
|
||||||
|
driver: docker-container
|
||||||
|
cleanup: false
|
||||||
|
- name: Prepare image metadata
|
||||||
|
if: steps.rel.outputs.released == 'true'
|
||||||
|
id: meta
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
echo "version=${{ steps.rel.outputs.version }}" >> "$GITHUB_OUTPUT"
|
||||||
|
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
||||||
|
echo "owner_lc=$owner_lc" >> "$GITHUB_OUTPUT"
|
||||||
|
short_sha="$(echo '${{ github.sha }}' | cut -c1-7)"
|
||||||
|
echo "short_sha=$short_sha" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: Log in to Gitea Registry
|
||||||
|
if: steps.rel.outputs.released == 'true'
|
||||||
|
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||||
|
with:
|
||||||
|
registry: git.shx.one
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.ACTIONS_PAT }}
|
||||||
|
- name: Mirror base images into buildcache
|
||||||
|
if: steps.rel.outputs.released == 'true'
|
||||||
|
env:
|
||||||
|
REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
|
||||||
|
MIRROR_ENV_FILE: ${{ runner.temp }}/mirror-base.env
|
||||||
|
run: sh deploy/docker/mirror-base-images.sh
|
||||||
|
- name: Build and push images (bake)
|
||||||
|
if: steps.rel.outputs.released == 'true'
|
||||||
|
env:
|
||||||
|
REGISTRY: git.shx.one/${{ steps.meta.outputs.owner_lc }}
|
||||||
|
IMAGE_TAG: latest
|
||||||
|
VERSION: ${{ steps.meta.outputs.version }}
|
||||||
|
SHORT_SHA: ${{ steps.meta.outputs.short_sha }}
|
||||||
|
SHA_FULL: ${{ github.sha }}
|
||||||
|
BUILD_TIME: ${{ steps.meta.outputs.build_time }}
|
||||||
|
CACHE_REF_NODE: git.shx.one/${{ steps.meta.outputs.owner_lc }}/evofw-buildcache:node-buildcache
|
||||||
|
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||||
|
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||||
|
MIRROR_ENV_FILE: ${{ runner.temp }}/mirror-base.env
|
||||||
|
working-directory: deploy/docker
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
if [ -f "${MIRROR_ENV_FILE}" ]; then
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
. "${MIRROR_ENV_FILE}"
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
docker buildx bake --allow=fs.read="${{ github.workspace }}" \
|
||||||
|
-f docker-bake.hcl default --push
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main, master]
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
quality:
|
||||||
|
uses: ./.gitea/workflows/quality.yaml
|
||||||
|
with:
|
||||||
|
is_pull_request: true
|
||||||
|
base_sha: ${{ github.event.pull_request.base.sha }}
|
||||||
|
head_sha: ${{ github.event.pull_request.head.sha }}
|
||||||
|
allow_registry_login: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
|
||||||
|
secrets:
|
||||||
|
ACTIONS_PAT: ${{ secrets.ACTIONS_PAT }}
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
name: Build and Push EvoFirewall Docker Image
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main, develop, 'feature/**', 'release/**', 'hotfix/**']
|
|
||||||
tags: ['v*']
|
|
||||||
paths: ['**']
|
|
||||||
pull_request:
|
|
||||||
branches: [main, develop]
|
|
||||||
paths: ['**']
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-push:
|
|
||||||
if: startsWith(gitea.ref, 'refs/tags/v') || (gitea.ref_name == 'main' && gitea.event_name == 'push')
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Login to Gitea Registry
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: git.shts.su
|
|
||||||
username: ${{ gitea.actor }}
|
|
||||||
password: ${{ secrets.ACTIONS_PAT }}
|
|
||||||
|
|
||||||
- name: Create version file
|
|
||||||
run: |
|
|
||||||
VERSION=$(cat VERSION)
|
|
||||||
BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
|
|
||||||
BRANCH="${{ gitea.ref_name }}"
|
|
||||||
echo "APP_VERSION=${VERSION}" > ./version.txt
|
|
||||||
echo "BUILD_DATE=${BUILD_DATE}" >> ./version.txt
|
|
||||||
echo "GIT_BRANCH=${BRANCH}" >> ./version.txt
|
|
||||||
echo "GIT_COMMIT=${{ gitea.sha }}" >> ./version.txt
|
|
||||||
echo "GIT_COMMIT_SHORT=$(echo ${{ gitea.sha }} | cut -c1-7)" >> ./version.txt
|
|
||||||
echo "BUILD_TIMESTAMP=$(date -u +%s)" >> ./version.txt
|
|
||||||
|
|
||||||
# Docker registry refs must be lowercase (repo name is EvoFirewall).
|
|
||||||
- name: Lowercase image repository
|
|
||||||
id: image
|
|
||||||
run: echo "repo=$(echo '${{ gitea.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Docker meta
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: git.shts.su/${{ steps.image.outputs.repo }}
|
|
||||||
tags: |
|
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=raw,value=latest,enable=${{ gitea.ref_name == 'main' }}
|
|
||||||
type=sha,prefix={{date 'YYYYMMDD'}}-,enable=${{ gitea.ref_name == 'main' }}
|
|
||||||
|
|
||||||
- name: Build and push Docker image
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./Dockerfile
|
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
build-args: |
|
|
||||||
NODE_VERSION=22.23.0-bookworm-slim
|
|
||||||
cache-from: type=registry,ref=git.shts.su/${{ steps.image.outputs.repo }}:buildcache
|
|
||||||
cache-to: type=registry,ref=git.shts.su/${{ steps.image.outputs.repo }}:buildcache,mode=max
|
|
||||||
provenance: true
|
|
||||||
|
|
||||||
create-release:
|
|
||||||
needs: build-and-push
|
|
||||||
if: startsWith(gitea.ref, 'refs/tags/v')
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Generate changelog
|
|
||||||
id: changelog
|
|
||||||
run: |
|
|
||||||
LAST_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
|
||||||
if [[ -n "$LAST_TAG" ]]; then
|
|
||||||
CHANGELOG=$(git log --pretty=format:"- %s (%h)" ${LAST_TAG}..HEAD)
|
|
||||||
else
|
|
||||||
CHANGELOG=$(git log --pretty=format:"- %s (%h)" -n 20)
|
|
||||||
fi
|
|
||||||
echo "CHANGELOG<<EOF" >> $GITHUB_OUTPUT
|
|
||||||
echo "$CHANGELOG" >> $GITHUB_OUTPUT
|
|
||||||
echo "EOF" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Create Gitea Release
|
|
||||||
run: |
|
|
||||||
curl -X POST \
|
|
||||||
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"https://git.shts.su/api/v1/repos/${{ gitea.repository }}/releases" \
|
|
||||||
-d "{\"tag_name\":\"${{ gitea.ref_name }}\",\"name\":\"${{ gitea.ref_name }}\",\"body\":$(echo '${{ steps.changelog.outputs.CHANGELOG }}' | jq -Rs .)}"
|
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
# Quality gates (reusable). Callers: ci.yaml (PR), cd.yaml (push main).
|
||||||
|
name: quality
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
is_pull_request:
|
||||||
|
type: boolean
|
||||||
|
required: true
|
||||||
|
base_sha:
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
head_sha:
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
before_sha:
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
allow_registry_login:
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
secrets:
|
||||||
|
ACTIONS_PAT:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
changes:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
openapi: ${{ steps.detect.outputs.openapi }}
|
||||||
|
web: ${{ steps.detect.outputs.web }}
|
||||||
|
api: ${{ steps.detect.outputs.api }}
|
||||||
|
docker: ${{ steps.detect.outputs.docker }}
|
||||||
|
steps:
|
||||||
|
- if: ${{ inputs.is_pull_request }}
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- if: ${{ inputs.is_pull_request == false }}
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
- id: detect
|
||||||
|
name: Detect changed paths per module
|
||||||
|
env:
|
||||||
|
IS_PR: ${{ inputs.is_pull_request }}
|
||||||
|
BASE_SHA: ${{ inputs.base_sha }}
|
||||||
|
HEAD_SHA: ${{ inputs.head_sha }}
|
||||||
|
BEFORE_SHA: ${{ inputs.before_sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
openapi=false
|
||||||
|
web=false
|
||||||
|
api=false
|
||||||
|
docker=false
|
||||||
|
|
||||||
|
set_all_flags_true() {
|
||||||
|
openapi=true
|
||||||
|
web=true
|
||||||
|
api=true
|
||||||
|
docker=true
|
||||||
|
}
|
||||||
|
|
||||||
|
write_outputs() {
|
||||||
|
for v in openapi web api docker; do
|
||||||
|
eval "echo \"\$v=\$$v\"" >> "$GITHUB_OUTPUT"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$IS_PR" = "true" ]; then
|
||||||
|
FILES="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")"
|
||||||
|
else
|
||||||
|
after="${HEAD_SHA:-$(git rev-parse HEAD)}"
|
||||||
|
before="$BEFORE_SHA"
|
||||||
|
if [ -n "$before" ] && [ "$before" != "0000000000000000000000000000000000000000" ]; then
|
||||||
|
FILES="$(git diff --name-only "$before" "$after")"
|
||||||
|
elif git rev-parse --verify HEAD~1 >/dev/null 2>&1; then
|
||||||
|
FILES="$(git diff --name-only HEAD~1 HEAD)"
|
||||||
|
else
|
||||||
|
set_all_flags_true
|
||||||
|
write_outputs
|
||||||
|
echo "No parent commit — full pipeline (all modules)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$(printf '%s' "$FILES" | tr -d '[:space:]')" ]; then
|
||||||
|
set_all_flags_true
|
||||||
|
write_outputs
|
||||||
|
echo "Empty diff — full pipeline fallback"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
full_pipeline=false
|
||||||
|
|
||||||
|
while IFS= read -r f || [ -n "${f:-}" ]; do
|
||||||
|
[ -z "${f:-}" ] && continue
|
||||||
|
case "$f" in
|
||||||
|
.gitea/workflows/*|scripts/*)
|
||||||
|
full_pipeline=true
|
||||||
|
;;
|
||||||
|
docs/openapi.yaml|redocly.yaml)
|
||||||
|
openapi=true
|
||||||
|
;;
|
||||||
|
.cursor/*|.claude/*|.codegraph/*|.agents/*)
|
||||||
|
;;
|
||||||
|
*.md|AGENTS.md)
|
||||||
|
;;
|
||||||
|
apps/web/README.md|apps/web/components.json|packages/ui/components.json)
|
||||||
|
;;
|
||||||
|
apps/web/*|packages/ui/*)
|
||||||
|
web=true
|
||||||
|
;;
|
||||||
|
apps/api/*|packages/db/*)
|
||||||
|
api=true
|
||||||
|
;;
|
||||||
|
packages/shared/*)
|
||||||
|
web=true
|
||||||
|
api=true
|
||||||
|
;;
|
||||||
|
deploy/compose/*|deploy/docker/*|.dockerignore)
|
||||||
|
docker=true
|
||||||
|
;;
|
||||||
|
docs/*)
|
||||||
|
;;
|
||||||
|
package.json|pnpm-lock.yaml|pnpm-workspace.yaml|turbo.json|.releaserc.json|commitlint.config.cjs)
|
||||||
|
full_pipeline=true
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done <<< "$FILES"
|
||||||
|
|
||||||
|
if $full_pipeline; then
|
||||||
|
set_all_flags_true
|
||||||
|
fi
|
||||||
|
|
||||||
|
write_outputs
|
||||||
|
|
||||||
|
echo "Changed files (first 30):"
|
||||||
|
printf '%s\n' "$FILES" | head -n 30
|
||||||
|
echo "--- flags ---"
|
||||||
|
echo "openapi=$openapi web=$web api=$api docker=$docker full_pipeline=$full_pipeline"
|
||||||
|
|
||||||
|
openapi:
|
||||||
|
needs: [changes]
|
||||||
|
if: needs.changes.outputs.openapi == 'true' || needs.changes.outputs.web == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
- name: Export cache paths
|
||||||
|
run: sh scripts/ci/export-cache-env.sh
|
||||||
|
- id: pnpm-hash
|
||||||
|
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||||
|
- id: pnpm-cache
|
||||||
|
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ env.PNPM_STORE_DIR }}
|
||||||
|
${{ env.COREPACK_HOME }}
|
||||||
|
node_modules
|
||||||
|
apps/web/node_modules
|
||||||
|
apps/api/node_modules
|
||||||
|
packages/ui/node_modules
|
||||||
|
packages/shared/node_modules
|
||||||
|
packages/db/node_modules
|
||||||
|
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||||
|
restore-keys: |
|
||||||
|
pnpm-${{ runner.os }}-
|
||||||
|
- name: pnpm install, Redocly
|
||||||
|
env:
|
||||||
|
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
sh scripts/ci/pnpm-ci.sh
|
||||||
|
pnpm exec redocly lint docs/openapi.yaml
|
||||||
|
|
||||||
|
web:
|
||||||
|
needs: [changes]
|
||||||
|
if: needs.changes.outputs.web == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
- name: Export cache paths
|
||||||
|
run: sh scripts/ci/export-cache-env.sh
|
||||||
|
- id: pnpm-hash
|
||||||
|
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||||
|
- id: pnpm-cache
|
||||||
|
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ env.PNPM_STORE_DIR }}
|
||||||
|
${{ env.COREPACK_HOME }}
|
||||||
|
node_modules
|
||||||
|
apps/web/node_modules
|
||||||
|
apps/api/node_modules
|
||||||
|
packages/ui/node_modules
|
||||||
|
packages/shared/node_modules
|
||||||
|
packages/db/node_modules
|
||||||
|
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||||
|
restore-keys: |
|
||||||
|
pnpm-${{ runner.os }}-
|
||||||
|
- name: pnpm install, typecheck, build
|
||||||
|
env:
|
||||||
|
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
sh scripts/ci/pnpm-ci.sh
|
||||||
|
pnpm --filter @evofw/web run typecheck
|
||||||
|
pnpm --filter @evofw/web run build
|
||||||
|
|
||||||
|
api:
|
||||||
|
needs: [changes]
|
||||||
|
if: needs.changes.outputs.api == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
- name: Export cache paths
|
||||||
|
run: sh scripts/ci/export-cache-env.sh
|
||||||
|
- id: pnpm-hash
|
||||||
|
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||||
|
- id: pnpm-cache
|
||||||
|
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ env.PNPM_STORE_DIR }}
|
||||||
|
${{ env.COREPACK_HOME }}
|
||||||
|
node_modules
|
||||||
|
apps/web/node_modules
|
||||||
|
apps/api/node_modules
|
||||||
|
packages/ui/node_modules
|
||||||
|
packages/shared/node_modules
|
||||||
|
packages/db/node_modules
|
||||||
|
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||||
|
restore-keys: |
|
||||||
|
pnpm-${{ runner.os }}-
|
||||||
|
- name: pnpm install, test, build
|
||||||
|
env:
|
||||||
|
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
sh scripts/ci/pnpm-ci.sh
|
||||||
|
pnpm exec turbo run test --filter=@evofw/api
|
||||||
|
pnpm exec turbo run build --filter=@evofw/api
|
||||||
|
|
||||||
|
commitlint:
|
||||||
|
if: inputs.is_pull_request
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
- name: Export cache paths
|
||||||
|
run: sh scripts/ci/export-cache-env.sh
|
||||||
|
- id: pnpm-hash
|
||||||
|
run: echo "key=$(sha256sum pnpm-lock.yaml | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||||
|
- id: pnpm-cache
|
||||||
|
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ env.PNPM_STORE_DIR }}
|
||||||
|
${{ env.COREPACK_HOME }}
|
||||||
|
node_modules
|
||||||
|
apps/web/node_modules
|
||||||
|
apps/api/node_modules
|
||||||
|
packages/ui/node_modules
|
||||||
|
packages/shared/node_modules
|
||||||
|
packages/db/node_modules
|
||||||
|
key: pnpm-${{ runner.os }}-${{ steps.pnpm-hash.outputs.key }}
|
||||||
|
restore-keys: |
|
||||||
|
pnpm-${{ runner.os }}-
|
||||||
|
- name: Lint commit messages
|
||||||
|
env:
|
||||||
|
BASE_SHA: ${{ inputs.base_sha }}
|
||||||
|
HEAD_SHA: ${{ inputs.head_sha }}
|
||||||
|
PNPM_CACHE_HIT: ${{ steps.pnpm-cache.outputs.cache-hit }}
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
sh scripts/ci/pnpm-ci.sh
|
||||||
|
pnpm exec commitlint --from "$BASE_SHA" --to "$HEAD_SHA"
|
||||||
|
|
||||||
|
docker-check:
|
||||||
|
needs: [changes]
|
||||||
|
if: inputs.is_pull_request && needs.changes.outputs.docker == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||||
|
with:
|
||||||
|
name: evofw
|
||||||
|
driver: docker-container
|
||||||
|
cleanup: false
|
||||||
|
- name: Log in to Gitea Registry
|
||||||
|
if: inputs.allow_registry_login
|
||||||
|
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||||
|
with:
|
||||||
|
registry: git.shx.one
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.ACTIONS_PAT }}
|
||||||
|
- name: bake --print
|
||||||
|
working-directory: deploy/docker
|
||||||
|
env:
|
||||||
|
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||||
|
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||||
|
run: docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl --print default
|
||||||
|
- name: bake (no push)
|
||||||
|
if: inputs.allow_registry_login
|
||||||
|
working-directory: deploy/docker
|
||||||
|
env:
|
||||||
|
BUILDX_BAKE_ENTITLEMENTS_FS: "0"
|
||||||
|
BUILDX_BAKE_FILE_RELATIVE_PATHS: "1"
|
||||||
|
run: |
|
||||||
|
set -euxo pipefail
|
||||||
|
owner_lc="$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')"
|
||||||
|
export CACHE_REF_NODE="git.shx.one/${owner_lc}/evofw-buildcache:node-buildcache"
|
||||||
|
docker buildx bake --allow=fs.read="${{ github.workspace }}" -f docker-bake.hcl default
|
||||||
@@ -31,6 +31,9 @@ Thumbs.db
|
|||||||
|
|
||||||
# Build
|
# 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,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 `668fb463eb`.** 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,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).
|
||||||
@@ -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 20 ReUI building blocks: `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`. 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
|
||||||
|
|
||||||
@@ -312,6 +311,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,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
|
||||||
|
|||||||
@@ -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)).
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
@@ -1,53 +0,0 @@
|
|||||||
# syntax=docker/dockerfile:1
|
|
||||||
|
|
||||||
ARG NODE_VERSION=22.23.0-bookworm-slim
|
|
||||||
|
|
||||||
FROM node:${NODE_VERSION} AS build
|
|
||||||
WORKDIR /app
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends python3 make g++ \
|
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
|
||||||
&& corepack enable
|
|
||||||
|
|
||||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml turbo.json ./
|
|
||||||
COPY apps/web/package.json apps/web/
|
|
||||||
COPY apps/api/package.json apps/api/
|
|
||||||
COPY packages/ui/package.json packages/ui/
|
|
||||||
COPY packages/shared/package.json packages/shared/
|
|
||||||
COPY packages/db/package.json packages/db/
|
|
||||||
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
|
|
||||||
pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
COPY apps/web apps/web
|
|
||||||
COPY apps/api apps/api
|
|
||||||
COPY packages/ui packages/ui
|
|
||||||
COPY packages/shared packages/shared
|
|
||||||
COPY packages/db packages/db
|
|
||||||
|
|
||||||
RUN pnpm turbo build --filter=@evofw/web --filter=@evofw/api \
|
|
||||||
&& pnpm --filter @evofw/api deploy --prod /out \
|
|
||||||
&& cp -r apps/web/dist /out/static \
|
|
||||||
&& mkdir -p /out/dist/agent-scripts \
|
|
||||||
&& cp -r apps/api/src/agent-scripts/* /out/dist/agent-scripts/ \
|
|
||||||
&& rm -rf /out/src /out/test /out/.turbo \
|
|
||||||
&& rm -rf /out/node_modules/@evofw/db/src /out/node_modules/@evofw/db/.turbo \
|
|
||||||
&& rm -rf /out/node_modules/@evofw/shared/src /out/node_modules/@evofw/shared/.turbo
|
|
||||||
|
|
||||||
FROM node:${NODE_VERSION}
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
ENV NODE_ENV=production \
|
|
||||||
STATIC_DIR=/app/static \
|
|
||||||
DATABASE_URL=sqlite:/data/app.db \
|
|
||||||
SERVER_PORT=8080
|
|
||||||
|
|
||||||
COPY --from=build /out ./
|
|
||||||
|
|
||||||
EXPOSE 8080
|
|
||||||
VOLUME ["/data"]
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
|
||||||
CMD ["node", "-e", "fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
|
||||||
CMD ["node", "dist/server.js"]
|
|
||||||
@@ -42,6 +42,15 @@ curl -fsSL http://localhost:8080/v1/agent/install.sh | \
|
|||||||
|
|
||||||
Затем Approve в UI `/agents`.
|
Затем 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
|
||||||
```
|
```
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { createHash } 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,33 @@ 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()
|
||||||
|
|
||||||
|
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 +64,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) => {
|
||||||
@@ -146,14 +177,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 +240,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 +269,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 }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,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 +88,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,
|
||||||
|
})),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,364 @@
|
|||||||
|
import type { FastifyPluginAsync } from 'fastify'
|
||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import { repos } from '@evofw/db'
|
||||||
|
import {
|
||||||
|
createAgentPortRuleBodySchema,
|
||||||
|
importAgentPortRulesBodySchema,
|
||||||
|
updateAgentPortRuleBodySchema,
|
||||||
|
} from '@evofw/shared'
|
||||||
|
import { AppError } from '../plugins/error-handler.js'
|
||||||
|
import type { AppConfig } from '../config.js'
|
||||||
|
import { auditMutation } from '../services/audit.js'
|
||||||
|
|
||||||
|
function mapPortRule(
|
||||||
|
row: NonNullable<ReturnType<typeof repos.getAgentPortRule>>,
|
||||||
|
listName?: string | null,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
agent_id: row.agentId,
|
||||||
|
action: row.action,
|
||||||
|
protocol: row.protocol,
|
||||||
|
port_start: row.portStart,
|
||||||
|
port_end: row.portEnd,
|
||||||
|
src_kind: row.srcKind,
|
||||||
|
src_cidr: row.srcCidr,
|
||||||
|
list_id: row.listId,
|
||||||
|
list_name: listName ?? null,
|
||||||
|
enabled: row.enabled === 1,
|
||||||
|
comment: row.comment,
|
||||||
|
priority: row.priority,
|
||||||
|
created_at: row.createdAt,
|
||||||
|
updated_at: row.updatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSrc(
|
||||||
|
srcKind: string,
|
||||||
|
srcCidr: string | null | undefined,
|
||||||
|
listId: string | null | undefined,
|
||||||
|
db: Parameters<typeof repos.getIpList>[0],
|
||||||
|
) {
|
||||||
|
if (srcKind === 'cidr' && !srcCidr?.trim()) {
|
||||||
|
throw new AppError('VALIDATION_ERROR', 'src_cidr required', 400)
|
||||||
|
}
|
||||||
|
if (srcKind === 'list') {
|
||||||
|
if (!listId?.trim()) {
|
||||||
|
throw new AppError('VALIDATION_ERROR', 'list_id required', 400)
|
||||||
|
}
|
||||||
|
if (!repos.getIpList(db, listId)) {
|
||||||
|
throw new AppError('NOT_FOUND', 'IP list not found', 404)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const portAclRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
||||||
|
app,
|
||||||
|
opts,
|
||||||
|
) => {
|
||||||
|
const { config } = opts
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string } }>(
|
||||||
|
'/agents/:id/port-rules',
|
||||||
|
async (req) => {
|
||||||
|
const agent = repos.getAgent(app.db, req.params.id)
|
||||||
|
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||||
|
const items = repos.listAgentPortRules(app.db, agent.id).map((row) => {
|
||||||
|
const listName = row.listId
|
||||||
|
? repos.getIpList(app.db, row.listId)?.name
|
||||||
|
: null
|
||||||
|
return mapPortRule(row, listName)
|
||||||
|
})
|
||||||
|
return { items }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string } }>(
|
||||||
|
'/agents/:id/port-rules',
|
||||||
|
async (req) => {
|
||||||
|
const agent = repos.getAgent(app.db, req.params.id)
|
||||||
|
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||||
|
if (agent.platform !== 'linux') {
|
||||||
|
throw new AppError(
|
||||||
|
'VALIDATION_ERROR',
|
||||||
|
'Port ACL is only supported on Linux agents',
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const body = createAgentPortRuleBodySchema.parse(req.body)
|
||||||
|
const portEnd = body.port_end ?? body.port_start
|
||||||
|
const srcKind = body.src_kind
|
||||||
|
const srcCidr = srcKind === 'cidr' ? body.src_cidr!.trim() : null
|
||||||
|
const listId = srcKind === 'list' ? body.list_id! : null
|
||||||
|
validateSrc(srcKind, srcCidr, listId, app.db)
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const row = repos.insertAgentPortRule(app.db, {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
agentId: agent.id,
|
||||||
|
action: body.action,
|
||||||
|
protocol: body.protocol,
|
||||||
|
portStart: body.port_start,
|
||||||
|
portEnd,
|
||||||
|
srcKind,
|
||||||
|
srcCidr,
|
||||||
|
listId,
|
||||||
|
enabled: body.enabled === false ? 0 : 1,
|
||||||
|
comment: body.comment ?? null,
|
||||||
|
priority: body.priority ?? 100,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
repos.bumpAgentGeneration(app.db, agent.id)
|
||||||
|
auditMutation(app, config, req, {
|
||||||
|
action: 'port_rule.create',
|
||||||
|
targetType: 'app_resource',
|
||||||
|
targetId: row!.id,
|
||||||
|
summary: `Port ACL ${body.action} ${body.protocol}/${body.port_start} для ${agent.name}`,
|
||||||
|
details: {
|
||||||
|
agent_id: agent.id,
|
||||||
|
rule_id: row!.id,
|
||||||
|
action: body.action,
|
||||||
|
protocol: body.protocol,
|
||||||
|
port_start: body.port_start,
|
||||||
|
port_end: portEnd,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const listName = row!.listId
|
||||||
|
? repos.getIpList(app.db, row!.listId)?.name
|
||||||
|
: null
|
||||||
|
return mapPortRule(row!, listName)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
app.patch<{ Params: { id: string; ruleId: string } }>(
|
||||||
|
'/agents/:id/port-rules/:ruleId',
|
||||||
|
async (req) => {
|
||||||
|
const agent = repos.getAgent(app.db, req.params.id)
|
||||||
|
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||||
|
const existing = repos.getAgentPortRule(app.db, req.params.ruleId)
|
||||||
|
if (!existing || existing.agentId !== agent.id) {
|
||||||
|
throw new AppError('NOT_FOUND', 'Port rule not found', 404)
|
||||||
|
}
|
||||||
|
const body = updateAgentPortRuleBodySchema.parse(req.body)
|
||||||
|
const nextSrcKind = body.src_kind ?? existing.srcKind
|
||||||
|
const nextSrcCidr =
|
||||||
|
body.src_cidr !== undefined
|
||||||
|
? body.src_cidr
|
||||||
|
: existing.srcCidr
|
||||||
|
const nextListId =
|
||||||
|
body.list_id !== undefined ? body.list_id : existing.listId
|
||||||
|
validateSrc(nextSrcKind, nextSrcCidr, nextListId, app.db)
|
||||||
|
|
||||||
|
const portStart = body.port_start ?? existing.portStart
|
||||||
|
const portEnd = body.port_end ?? existing.portEnd
|
||||||
|
if (portEnd < portStart) {
|
||||||
|
throw new AppError(
|
||||||
|
'VALIDATION_ERROR',
|
||||||
|
'port_end must be >= port_start',
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = repos.updateAgentPortRule(app.db, existing.id, {
|
||||||
|
...(body.action !== undefined ? { action: body.action } : {}),
|
||||||
|
...(body.protocol !== undefined ? { protocol: body.protocol } : {}),
|
||||||
|
portStart,
|
||||||
|
portEnd,
|
||||||
|
srcKind: nextSrcKind,
|
||||||
|
srcCidr: nextSrcKind === 'cidr' ? nextSrcCidr : null,
|
||||||
|
listId: nextSrcKind === 'list' ? nextListId : null,
|
||||||
|
...(body.enabled !== undefined
|
||||||
|
? { enabled: body.enabled ? 1 : 0 }
|
||||||
|
: {}),
|
||||||
|
...(body.comment !== undefined ? { comment: body.comment } : {}),
|
||||||
|
...(body.priority !== undefined ? { priority: body.priority } : {}),
|
||||||
|
})
|
||||||
|
repos.bumpAgentGeneration(app.db, agent.id)
|
||||||
|
auditMutation(app, config, req, {
|
||||||
|
action: 'port_rule.update',
|
||||||
|
targetType: 'app_resource',
|
||||||
|
targetId: existing.id,
|
||||||
|
summary: `Port ACL обновлён у ${agent.name}`,
|
||||||
|
details: { agent_id: agent.id, rule_id: existing.id },
|
||||||
|
})
|
||||||
|
const listName = row!.listId
|
||||||
|
? repos.getIpList(app.db, row!.listId)?.name
|
||||||
|
: null
|
||||||
|
return mapPortRule(row!, listName)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string; ruleId: string } }>(
|
||||||
|
'/agents/:id/port-rules/:ruleId',
|
||||||
|
async (req) => {
|
||||||
|
const agent = repos.getAgent(app.db, req.params.id)
|
||||||
|
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||||
|
const existing = repos.getAgentPortRule(app.db, req.params.ruleId)
|
||||||
|
if (!existing || existing.agentId !== agent.id) {
|
||||||
|
throw new AppError('NOT_FOUND', 'Port rule not found', 404)
|
||||||
|
}
|
||||||
|
repos.deleteAgentPortRule(app.db, existing.id)
|
||||||
|
repos.bumpAgentGeneration(app.db, agent.id)
|
||||||
|
auditMutation(app, config, req, {
|
||||||
|
action: 'port_rule.delete',
|
||||||
|
severity: 'warning',
|
||||||
|
targetType: 'app_resource',
|
||||||
|
targetId: existing.id,
|
||||||
|
summary: `Port ACL удалён у ${agent.name}`,
|
||||||
|
details: { agent_id: agent.id, rule_id: existing.id },
|
||||||
|
})
|
||||||
|
return { ok: true }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string } }>(
|
||||||
|
'/agents/:id/port-rules/import',
|
||||||
|
async (req) => {
|
||||||
|
const agent = repos.getAgent(app.db, req.params.id)
|
||||||
|
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||||
|
if (agent.platform !== 'linux') {
|
||||||
|
throw new AppError(
|
||||||
|
'VALIDATION_ERROR',
|
||||||
|
'Port ACL is only supported on Linux agents',
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const body = importAgentPortRulesBodySchema.parse(req.body)
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const created: ReturnType<typeof mapPortRule>[] = []
|
||||||
|
|
||||||
|
if (body.from === 'list') {
|
||||||
|
const list = repos.getIpList(app.db, body.list_id!)
|
||||||
|
if (!list) throw new AppError('NOT_FOUND', 'IP list not found', 404)
|
||||||
|
for (const p of body.ports) {
|
||||||
|
const portEnd = p.port_end ?? p.port_start
|
||||||
|
const row = repos.insertAgentPortRule(app.db, {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
agentId: agent.id,
|
||||||
|
action: body.action,
|
||||||
|
protocol: body.protocol,
|
||||||
|
portStart: p.port_start,
|
||||||
|
portEnd,
|
||||||
|
srcKind: 'list',
|
||||||
|
srcCidr: null,
|
||||||
|
listId: list.id,
|
||||||
|
enabled: body.enabled === false ? 0 : 1,
|
||||||
|
comment: body.comment ?? `import list:${list.name}`,
|
||||||
|
priority: 100,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
created.push(mapPortRule(row!, list.name))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const set = repos.getPolicySet(app.db, body.set_id!)
|
||||||
|
if (!set) throw new AppError('NOT_FOUND', 'Policy set not found', 404)
|
||||||
|
const rules = repos.listPolicyRules(app.db, set.id)
|
||||||
|
const listIds = new Set<string>()
|
||||||
|
const cidrs = new Set<string>()
|
||||||
|
for (const r of rules) {
|
||||||
|
if (r.listId) listIds.add(r.listId)
|
||||||
|
if (r.cidr?.trim()) cidrs.add(r.cidr.trim())
|
||||||
|
}
|
||||||
|
if (!listIds.size && !cidrs.size) {
|
||||||
|
throw new AppError(
|
||||||
|
'VALIDATION_ERROR',
|
||||||
|
'Policy set has no list/cidr sources to import',
|
||||||
|
400,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for (const p of body.ports) {
|
||||||
|
const portEnd = p.port_end ?? p.port_start
|
||||||
|
for (const listId of listIds) {
|
||||||
|
const list = repos.getIpList(app.db, listId)
|
||||||
|
const row = repos.insertAgentPortRule(app.db, {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
agentId: agent.id,
|
||||||
|
action: body.action,
|
||||||
|
protocol: body.protocol,
|
||||||
|
portStart: p.port_start,
|
||||||
|
portEnd,
|
||||||
|
srcKind: 'list',
|
||||||
|
srcCidr: null,
|
||||||
|
listId,
|
||||||
|
enabled: body.enabled === false ? 0 : 1,
|
||||||
|
comment:
|
||||||
|
body.comment ?? `import set:${set.name} list:${list?.name ?? listId}`,
|
||||||
|
priority: 100,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
created.push(mapPortRule(row!, list?.name ?? null))
|
||||||
|
}
|
||||||
|
for (const cidr of cidrs) {
|
||||||
|
const row = repos.insertAgentPortRule(app.db, {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
agentId: agent.id,
|
||||||
|
action: body.action,
|
||||||
|
protocol: body.protocol,
|
||||||
|
portStart: p.port_start,
|
||||||
|
portEnd,
|
||||||
|
srcKind: 'cidr',
|
||||||
|
srcCidr: cidr,
|
||||||
|
listId: null,
|
||||||
|
enabled: body.enabled === false ? 0 : 1,
|
||||||
|
comment: body.comment ?? `import set:${set.name} cidr:${cidr}`,
|
||||||
|
priority: 100,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
created.push(mapPortRule(row!, null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repos.bumpAgentGeneration(app.db, agent.id)
|
||||||
|
auditMutation(app, config, req, {
|
||||||
|
action: 'port_rule.import',
|
||||||
|
targetType: 'app_resource',
|
||||||
|
targetId: agent.id,
|
||||||
|
summary: `Импорт ${created.length} Port ACL для ${agent.name}`,
|
||||||
|
details: {
|
||||||
|
agent_id: agent.id,
|
||||||
|
from: body.from,
|
||||||
|
count: created.length,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return { items: created }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string } }>(
|
||||||
|
'/agents/:id/host-firewall',
|
||||||
|
async (req) => {
|
||||||
|
const agent = repos.getAgent(app.db, req.params.id)
|
||||||
|
if (!agent) throw new AppError('NOT_FOUND', 'Agent not found', 404)
|
||||||
|
const snap = repos.getHostFirewallSnapshot(app.db, agent.id)
|
||||||
|
if (!snap) {
|
||||||
|
return {
|
||||||
|
collected_at: null,
|
||||||
|
raw_digest: null,
|
||||||
|
rules: [],
|
||||||
|
listeners: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let payload: { rules?: unknown[]; listeners?: unknown[] } = {}
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(snap.payloadJson) as typeof payload
|
||||||
|
} catch {
|
||||||
|
payload = {}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
collected_at: snap.collectedAt,
|
||||||
|
raw_digest: snap.rawDigest,
|
||||||
|
rules: payload.rules ?? [],
|
||||||
|
listeners: payload.listeners ?? [],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function digestHostFirewallPayload(json: string): string {
|
||||||
|
return createHash('sha256').update(json).digest('hex').slice(0, 16)
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -193,13 +193,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 +261,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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -0,0 +1,518 @@
|
|||||||
|
import { describe, it, expect, afterAll } from 'vitest'
|
||||||
|
import { buildApp } from '../app.js'
|
||||||
|
import type { AppConfig } from '../config.js'
|
||||||
|
|
||||||
|
const testConfig: AppConfig = {
|
||||||
|
databaseUrl: 'sqlite::memory:',
|
||||||
|
jwtSecret: 'test',
|
||||||
|
jwtTtlHours: 24,
|
||||||
|
serverPort: 8080,
|
||||||
|
staticDir: null,
|
||||||
|
logLevel: 'error',
|
||||||
|
authRequired: false,
|
||||||
|
authIssuer: 'https://auth.test',
|
||||||
|
authPortalUrl: 'http://localhost:5175',
|
||||||
|
publicBaseUrl: 'https://fw.example.com',
|
||||||
|
enrollSeed: 'test-seed',
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enrollApprovedLinux(
|
||||||
|
app: Awaited<ReturnType<typeof buildApp>>,
|
||||||
|
name: string,
|
||||||
|
token: string,
|
||||||
|
) {
|
||||||
|
const created = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/install-links',
|
||||||
|
payload: { name, platform: 'linux' },
|
||||||
|
})
|
||||||
|
expect(created.statusCode).toBe(201)
|
||||||
|
const link = created.json() as { id: string; agent_id: string }
|
||||||
|
|
||||||
|
const enroll = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/agent/enroll',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'x-evofw-seed': 'test-seed',
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
name,
|
||||||
|
platform: 'linux',
|
||||||
|
token,
|
||||||
|
install_link_id: link.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(enroll.statusCode).toBe(201)
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/v1/agents/${link.agent_id}/approve`,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { agentId: link.agent_id, token }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,6 +50,7 @@ export type EvaluatedPolicy = {
|
|||||||
cidrsAllow: number
|
cidrsAllow: number
|
||||||
overrides: number
|
overrides: number
|
||||||
conflictsDropped: number
|
conflictsDropped: number
|
||||||
|
portRules: number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +101,54 @@ function sourceMeta(
|
|||||||
return { kind: 'list', label: name || listId || 'list' }
|
return { kind: 'list', label: name || listId || 'list' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function expandPortSrcCidrs(
|
||||||
|
db: Db,
|
||||||
|
row: {
|
||||||
|
srcKind: string
|
||||||
|
srcCidr: string | null
|
||||||
|
listId: string | null
|
||||||
|
},
|
||||||
|
): string[] {
|
||||||
|
if (row.srcKind === 'all') return ['0.0.0.0/0']
|
||||||
|
if (row.srcKind === 'cidr' && row.srcCidr?.trim()) {
|
||||||
|
return [row.srcCidr.trim()]
|
||||||
|
}
|
||||||
|
if (row.srcKind === 'list') {
|
||||||
|
const cidrs = expandList(db, row.listId)
|
||||||
|
return cidrs.length ? uniqCidrs(cidrs) : []
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandPortRules(db: Db, agentId: string): EvaluatedPortRule[] {
|
||||||
|
const rows = repos.listEnabledAgentPortRules(db, agentId)
|
||||||
|
const out: EvaluatedPortRule[] = []
|
||||||
|
for (const row of rows) {
|
||||||
|
const action = row.action === 'close' ? 'close' : 'open'
|
||||||
|
const srcCidrs = expandPortSrcCidrs(db, row)
|
||||||
|
if (!srcCidrs.length) continue
|
||||||
|
const portStart = Math.max(1, Math.min(65535, row.portStart))
|
||||||
|
const portEnd = Math.max(portStart, Math.min(65535, row.portEnd))
|
||||||
|
const protocols: Array<'tcp' | 'udp'> =
|
||||||
|
row.protocol === 'udp'
|
||||||
|
? ['udp']
|
||||||
|
: row.protocol === 'both'
|
||||||
|
? ['tcp', 'udp']
|
||||||
|
: ['tcp']
|
||||||
|
for (const protocol of protocols) {
|
||||||
|
out.push({
|
||||||
|
id: row.id,
|
||||||
|
action,
|
||||||
|
protocol,
|
||||||
|
portStart,
|
||||||
|
portEnd,
|
||||||
|
srcCidrs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
/** Evaluate allow/deny sets for an agent from assigned policy sets. */
|
/** 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)
|
||||||
@@ -155,6 +214,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(db, agentId)
|
||||||
|
|
||||||
const payload = JSON.stringify({
|
const payload = JSON.stringify({
|
||||||
apply_version: POLICY_APPLY_VERSION,
|
apply_version: POLICY_APPLY_VERSION,
|
||||||
@@ -162,6 +222,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 +237,7 @@ export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy {
|
|||||||
policyMode,
|
policyMode,
|
||||||
denyCidrs,
|
denyCidrs,
|
||||||
allowCidrs,
|
allowCidrs,
|
||||||
|
portRules,
|
||||||
conflictsDropped,
|
conflictsDropped,
|
||||||
syncIntervalSec,
|
syncIntervalSec,
|
||||||
chain,
|
chain,
|
||||||
@@ -187,6 +249,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')
|
||||||
|
|||||||
@@ -100,7 +100,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')
|
||||||
|
|||||||
@@ -0,0 +1,415 @@
|
|||||||
|
import { describe, it, expect, afterAll } from 'vitest'
|
||||||
|
import { buildApp } from '../app.js'
|
||||||
|
import type { AppConfig } from '../config.js'
|
||||||
|
|
||||||
|
const testConfig: AppConfig = {
|
||||||
|
databaseUrl: 'sqlite::memory:',
|
||||||
|
jwtSecret: 'test',
|
||||||
|
jwtTtlHours: 24,
|
||||||
|
serverPort: 8080,
|
||||||
|
staticDir: null,
|
||||||
|
logLevel: 'error',
|
||||||
|
authRequired: false,
|
||||||
|
authIssuer: 'https://auth.test',
|
||||||
|
authPortalUrl: 'http://localhost:5175',
|
||||||
|
publicBaseUrl: 'https://fw.example.com',
|
||||||
|
enrollSeed: 'test-seed',
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enrollApprovedLinux(
|
||||||
|
app: Awaited<ReturnType<typeof buildApp>>,
|
||||||
|
name: string,
|
||||||
|
token: string,
|
||||||
|
) {
|
||||||
|
const created = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/install-links',
|
||||||
|
payload: { name, platform: 'linux' },
|
||||||
|
})
|
||||||
|
expect(created.statusCode).toBe(201)
|
||||||
|
const link = created.json() as { id: string; agent_id: string }
|
||||||
|
|
||||||
|
const enroll = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/agent/enroll',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'x-evofw-seed': 'test-seed',
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
name,
|
||||||
|
platform: 'linux',
|
||||||
|
token,
|
||||||
|
install_link_id: link.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(enroll.statusCode).toBe(201)
|
||||||
|
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/v1/agents/${link.agent_id}/approve`,
|
||||||
|
})
|
||||||
|
|
||||||
|
return { agentId: link.agent_id, token }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('port ACL + host firewall snapshot', () => {
|
||||||
|
const appPromise = buildApp({ memory: true, config: testConfig })
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const app = await appPromise
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('CRUD port-rules bumps generation and expands in policy', async () => {
|
||||||
|
const app = await appPromise
|
||||||
|
await app.ready()
|
||||||
|
|
||||||
|
const { agentId, token } = await enrollApprovedLinux(
|
||||||
|
app,
|
||||||
|
'port-acl-01',
|
||||||
|
'evofw_port_acl_token_abcdefghij',
|
||||||
|
)
|
||||||
|
|
||||||
|
const before = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/v1/agents/${agentId}`,
|
||||||
|
})
|
||||||
|
const genBefore = (before.json() as { policy_generation: number })
|
||||||
|
.policy_generation
|
||||||
|
|
||||||
|
const create = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/v1/agents/${agentId}/port-rules`,
|
||||||
|
payload: {
|
||||||
|
action: 'open',
|
||||||
|
protocol: 'tcp',
|
||||||
|
port_start: 443,
|
||||||
|
src_kind: 'cidr',
|
||||||
|
src_cidr: '10.0.0.0/8',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(create.statusCode).toBe(200)
|
||||||
|
const rule = create.json() as { id: string; action: string; port_start: number }
|
||||||
|
expect(rule.action).toBe('open')
|
||||||
|
expect(rule.port_start).toBe(443)
|
||||||
|
|
||||||
|
const after = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/v1/agents/${agentId}`,
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
(after.json() as { policy_generation: number }).policy_generation,
|
||||||
|
).toBe(genBefore + 1)
|
||||||
|
|
||||||
|
const list = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/v1/agents/${agentId}/port-rules`,
|
||||||
|
})
|
||||||
|
expect(list.statusCode).toBe(200)
|
||||||
|
expect((list.json() as { items: unknown[] }).items).toHaveLength(1)
|
||||||
|
|
||||||
|
const policy = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/v1/agent/policy',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
expect(policy.statusCode).toBe(200)
|
||||||
|
const body = policy.json() as {
|
||||||
|
apply_version: number
|
||||||
|
port_rules: {
|
||||||
|
action: string
|
||||||
|
protocol: string
|
||||||
|
port_start: number
|
||||||
|
src_cidrs: string[]
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
expect(body.apply_version).toBe(3)
|
||||||
|
expect(body.port_rules).toHaveLength(1)
|
||||||
|
expect(body.port_rules[0]?.src_cidrs).toEqual(['10.0.0.0/8'])
|
||||||
|
expect(body.port_rules[0]?.protocol).toBe('tcp')
|
||||||
|
|
||||||
|
const patch = await app.inject({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/api/v1/agents/${agentId}/port-rules/${rule.id}`,
|
||||||
|
payload: { enabled: false },
|
||||||
|
})
|
||||||
|
expect(patch.statusCode).toBe(200)
|
||||||
|
expect((patch.json() as { enabled: boolean }).enabled).toBe(false)
|
||||||
|
|
||||||
|
const policyOff = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: '/v1/agent/policy',
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
(policyOff.json() as { port_rules: unknown[] }).port_rules,
|
||||||
|
).toHaveLength(0)
|
||||||
|
|
||||||
|
const del = await app.inject({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `/api/v1/agents/${agentId}/port-rules/${rule.id}`,
|
||||||
|
})
|
||||||
|
expect(del.statusCode).toBe(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('imports port-rules from IP list', async () => {
|
||||||
|
const app = await appPromise
|
||||||
|
await app.ready()
|
||||||
|
|
||||||
|
const { agentId } = await enrollApprovedLinux(
|
||||||
|
app,
|
||||||
|
'port-acl-import',
|
||||||
|
'evofw_port_import_token_abcdefgh',
|
||||||
|
)
|
||||||
|
|
||||||
|
const listRes = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/lists',
|
||||||
|
payload: {
|
||||||
|
name: 'port-src-list',
|
||||||
|
type: 'static',
|
||||||
|
entries: ['203.0.113.0/24'],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(listRes.statusCode).toBe(200)
|
||||||
|
const listId = (listRes.json() as { id: string }).id
|
||||||
|
|
||||||
|
const before = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/v1/agents/${agentId}`,
|
||||||
|
})
|
||||||
|
const genBefore = (before.json() as { policy_generation: number })
|
||||||
|
.policy_generation
|
||||||
|
|
||||||
|
const imp = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/v1/agents/${agentId}/port-rules/import`,
|
||||||
|
payload: {
|
||||||
|
from: 'list',
|
||||||
|
list_id: listId,
|
||||||
|
action: 'close',
|
||||||
|
protocol: 'both',
|
||||||
|
ports: [{ port_start: 22 }, { port_start: 80, port_end: 81 }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(imp.statusCode).toBe(200)
|
||||||
|
const items = (imp.json() as { items: { src_kind: string; list_id: string }[] })
|
||||||
|
.items
|
||||||
|
expect(items).toHaveLength(2)
|
||||||
|
expect(items.every((i) => i.src_kind === 'list' && i.list_id === listId)).toBe(
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
|
||||||
|
const after = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/v1/agents/${agentId}`,
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
(after.json() as { policy_generation: number }).policy_generation,
|
||||||
|
).toBe(genBefore + 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('upserts host_firewall snapshot from apply-report', async () => {
|
||||||
|
const app = await appPromise
|
||||||
|
await app.ready()
|
||||||
|
|
||||||
|
const { agentId, token } = await enrollApprovedLinux(
|
||||||
|
app,
|
||||||
|
'host-fw-snap',
|
||||||
|
'evofw_host_fw_token_abcdefghij',
|
||||||
|
)
|
||||||
|
|
||||||
|
const report = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/agent/apply-report',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
status: 'ok',
|
||||||
|
kernel_method: 'nft',
|
||||||
|
host_firewall: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
ownership: 'evofw',
|
||||||
|
backend: 'nft',
|
||||||
|
table: 'evofw',
|
||||||
|
chain: 'input',
|
||||||
|
action: 'drop',
|
||||||
|
protocol: 'tcp',
|
||||||
|
dport: '22',
|
||||||
|
raw: 'tcp dport 22 drop comment "evofw-port-x"',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ownership: 'foreign',
|
||||||
|
backend: 'iptables',
|
||||||
|
chain: 'INPUT',
|
||||||
|
action: 'ACCEPT',
|
||||||
|
// Agent historically sent JSON null for missing fields — must accept.
|
||||||
|
protocol: null,
|
||||||
|
dport: null,
|
||||||
|
saddr: null,
|
||||||
|
raw: '-A INPUT -p tcp --dport 80 -j ACCEPT',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
listeners: [
|
||||||
|
{ protocol: 'tcp', port: 22, address: '0.0.0.0' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(report.statusCode).toBe(200)
|
||||||
|
|
||||||
|
const snap = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/v1/agents/${agentId}/host-firewall`,
|
||||||
|
})
|
||||||
|
expect(snap.statusCode).toBe(200)
|
||||||
|
const body = snap.json() as {
|
||||||
|
collected_at: string | null
|
||||||
|
rules: { ownership: string }[]
|
||||||
|
listeners: { port: number }[]
|
||||||
|
}
|
||||||
|
expect(body.collected_at).toBeTruthy()
|
||||||
|
expect(body.rules).toHaveLength(2)
|
||||||
|
expect(body.rules.some((r) => r.ownership === 'evofw')).toBe(true)
|
||||||
|
expect(body.listeners[0]?.port).toBe(22)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not wipe host_firewall snapshot with empty apply-report payload', async () => {
|
||||||
|
const app = await appPromise
|
||||||
|
await app.ready()
|
||||||
|
|
||||||
|
const { agentId, token } = await enrollApprovedLinux(
|
||||||
|
app,
|
||||||
|
'host-fw-empty',
|
||||||
|
'evofw_host_fw_empty_token_ab',
|
||||||
|
)
|
||||||
|
|
||||||
|
const filled = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/agent/apply-report',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
status: 'ok',
|
||||||
|
host_firewall: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
ownership: 'evofw',
|
||||||
|
backend: 'nft',
|
||||||
|
action: 'drop',
|
||||||
|
raw: 'ip saddr @deny_v4 drop',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
listeners: [{ protocol: 'tcp', port: 22, address: '0.0.0.0' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(filled.statusCode).toBe(200)
|
||||||
|
|
||||||
|
const empty = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/agent/apply-report',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
status: 'ok',
|
||||||
|
host_firewall: { rules: [], listeners: [] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(empty.statusCode).toBe(200)
|
||||||
|
|
||||||
|
const snap = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/v1/agents/${agentId}/host-firewall`,
|
||||||
|
})
|
||||||
|
const body = snap.json() as {
|
||||||
|
rules: unknown[]
|
||||||
|
listeners: unknown[]
|
||||||
|
}
|
||||||
|
expect(body.rules).toHaveLength(1)
|
||||||
|
expect(body.listeners).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters invalid host_firewall rules without failing apply-report', async () => {
|
||||||
|
const app = await appPromise
|
||||||
|
await app.ready()
|
||||||
|
|
||||||
|
const { agentId, token } = await enrollApprovedLinux(
|
||||||
|
app,
|
||||||
|
'host-fw-filter',
|
||||||
|
'evofw_host_fw_filter_token_a',
|
||||||
|
)
|
||||||
|
|
||||||
|
const report = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/agent/apply-report',
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
status: 'ok',
|
||||||
|
host_firewall: {
|
||||||
|
rules: [
|
||||||
|
{ ownership: 'evofw', backend: 'nft', raw: 'ok rule' },
|
||||||
|
{ ownership: 'nope', backend: 'nft', raw: 'bad ownership' },
|
||||||
|
{ not: 'a rule' },
|
||||||
|
],
|
||||||
|
listeners: [
|
||||||
|
{ protocol: 'tcp', port: 443, address: '::' },
|
||||||
|
{ protocol: 'tcp', port: 99999, address: '1.1.1.1' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(report.statusCode).toBe(200)
|
||||||
|
|
||||||
|
const snap = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/api/v1/agents/${agentId}/host-firewall`,
|
||||||
|
})
|
||||||
|
const body = snap.json() as {
|
||||||
|
rules: unknown[]
|
||||||
|
listeners: { port: number }[]
|
||||||
|
}
|
||||||
|
expect(body.rules).toHaveLength(1)
|
||||||
|
expect(body.listeners).toHaveLength(1)
|
||||||
|
expect(body.listeners[0]?.port).toBe(443)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects port ACL on non-linux agents', async () => {
|
||||||
|
const app = await appPromise
|
||||||
|
await app.ready()
|
||||||
|
|
||||||
|
const created = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/install-links',
|
||||||
|
payload: { name: 'mt-no-acl', platform: 'mikrotik' },
|
||||||
|
})
|
||||||
|
const agentId = (created.json() as { agent_id: string }).agent_id
|
||||||
|
await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/v1/agents/${agentId}/approve`,
|
||||||
|
})
|
||||||
|
|
||||||
|
const create = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/v1/agents/${agentId}/port-rules`,
|
||||||
|
payload: {
|
||||||
|
action: 'open',
|
||||||
|
protocol: 'tcp',
|
||||||
|
port_start: 443,
|
||||||
|
src_kind: 'all',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(create.statusCode).toBe(400)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { ifNoneMatchHits, getSyncScriptMeta } from './sync-script.js'
|
||||||
|
|
||||||
|
describe('sync-script meta', () => {
|
||||||
|
it('hashes evofw-firewall.sh and matches ETag', () => {
|
||||||
|
const meta = getSyncScriptMeta()
|
||||||
|
expect(meta.sha256).toMatch(/^[a-f0-9]{64}$/)
|
||||||
|
expect(meta.etag).toBe(`"${meta.sha256}"`)
|
||||||
|
expect(meta.body.subarray(0, 2).toString()).toBe('#!')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ifNoneMatchHits understands quoted, weak, and star', () => {
|
||||||
|
const etag = '"abc"'
|
||||||
|
expect(ifNoneMatchHits('"abc"', etag)).toBe(true)
|
||||||
|
expect(ifNoneMatchHits('abc', etag)).toBe(true)
|
||||||
|
expect(ifNoneMatchHits('W/"abc"', etag)).toBe(true)
|
||||||
|
expect(ifNoneMatchHits('*', etag)).toBe(true)
|
||||||
|
expect(ifNoneMatchHits('"nope"', etag)).toBe(false)
|
||||||
|
expect(ifNoneMatchHits(undefined, etag)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { resolveAgentScriptsDir } from './agent-scripts-path.js'
|
||||||
|
|
||||||
|
export type SyncScriptMeta = {
|
||||||
|
body: Buffer
|
||||||
|
sha256: string
|
||||||
|
etag: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let cache: SyncScriptMeta | null = null
|
||||||
|
|
||||||
|
/** Linux sync agent script + sha256 of file bytes (process-lifetime cache). */
|
||||||
|
export function getSyncScriptMeta(): SyncScriptMeta {
|
||||||
|
if (cache) return cache
|
||||||
|
const dir = resolveAgentScriptsDir()
|
||||||
|
const body = readFileSync(join(dir, 'evofw-firewall.sh'))
|
||||||
|
const sha256 = createHash('sha256').update(body).digest('hex')
|
||||||
|
cache = { body, sha256, etag: `"${sha256}"` }
|
||||||
|
return cache
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compare If-None-Match with our ETag (`"<sha256>"`). */
|
||||||
|
export function ifNoneMatchHits(
|
||||||
|
header: string | string[] | undefined,
|
||||||
|
etag: string,
|
||||||
|
): boolean {
|
||||||
|
if (!header) return false
|
||||||
|
const raw = Array.isArray(header) ? header.join(',') : header
|
||||||
|
const want = etag.replaceAll('"', '').toLowerCase()
|
||||||
|
for (const part of raw.split(',')) {
|
||||||
|
let token = part.trim()
|
||||||
|
if (token.startsWith('W/')) token = token.slice(2).trim()
|
||||||
|
token = token.replaceAll('"', '')
|
||||||
|
if (token === '*' || token.toLowerCase() === want) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -169,7 +169,9 @@ export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) {
|
|||||||
title="Добавить агента"
|
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,192 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
|
|
||||||
|
const packetFmt = new Intl.NumberFormat('ru-RU')
|
||||||
|
const seenFmt = new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatSeen(iso: string): string {
|
||||||
|
const t = Date.parse(iso)
|
||||||
|
if (Number.isNaN(t)) return '—'
|
||||||
|
return seenFmt.format(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
{packetFmt.format(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">
|
||||||
|
{formatSeen(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,169 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
type ColumnDef,
|
||||||
|
} from '@tanstack/react-table'
|
||||||
|
import { NetworkIcon } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||||||
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
|
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||||
|
import { EmptyState } from '@/components/empty-state'
|
||||||
|
import { agentBlockedPortsQueryOptions } from '@/queries'
|
||||||
|
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregate destination ports hit by denied sources (Linux nft).
|
||||||
|
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
||||||
|
* · https://reui.io/preview/base/empty-state-12
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type BlockedPortRow = {
|
||||||
|
port: number
|
||||||
|
protocol: string
|
||||||
|
packets: number
|
||||||
|
last_seen_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type AgentBlockedPortsProps = {
|
||||||
|
agentId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const packetFmt = new Intl.NumberFormat('ru-RU')
|
||||||
|
const seenFmt = new Intl.DateTimeFormat('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatSeen(iso: string): string {
|
||||||
|
const t = Date.parse(iso)
|
||||||
|
if (Number.isNaN(t)) return '—'
|
||||||
|
return seenFmt.format(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentBlockedPorts({ agentId }: AgentBlockedPortsProps) {
|
||||||
|
const q = useQuery(agentBlockedPortsQueryOptions(agentId))
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnDef<BlockedPortRow>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'port',
|
||||||
|
id: 'port',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Port" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-xs tabular-nums">
|
||||||
|
{row.original.port}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: { headerTitle: 'Port' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'protocol',
|
||||||
|
id: 'protocol',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Proto" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-xs uppercase">
|
||||||
|
{row.original.protocol}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: { headerTitle: 'Proto' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'packets',
|
||||||
|
id: 'packets',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Packets" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{packetFmt.format(row.original.packets)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: { headerTitle: 'Packets' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'last_seen_at',
|
||||||
|
id: 'last_seen_at',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Last seen" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-muted-foreground text-xs tabular-nums">
|
||||||
|
{formatSeen(row.original.last_seen_at)}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
meta: { headerTitle: 'Last seen' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const data = q.data?.items ?? []
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getRowId: (r) => `${r.protocol}/${r.port}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Frame dense spacing="sm">
|
||||||
|
<FrameHeader>
|
||||||
|
<FrameTitle>Top ports</FrameTitle>
|
||||||
|
<FrameDescription>
|
||||||
|
Destination ports (tcp/udp), в которые слали запросы blocked IP. nft
|
||||||
|
dynamic set deny_port_hits.
|
||||||
|
</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel className="p-0">
|
||||||
|
{q.isLoading ? (
|
||||||
|
<div className="flex flex-col gap-2 p-4">
|
||||||
|
<Skeleton className="h-8 w-full" />
|
||||||
|
<Skeleton className="h-8 w-full" />
|
||||||
|
<Skeleton className="h-8 w-2/3" />
|
||||||
|
</div>
|
||||||
|
) : q.isError ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={NetworkIcon}
|
||||||
|
title="Не удалось загрузить"
|
||||||
|
description={q.error?.message ?? 'Ошибка API'}
|
||||||
|
centered={false}
|
||||||
|
className="py-8"
|
||||||
|
/>
|
||||||
|
) : data.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={NetworkIcon}
|
||||||
|
title="Пока нет port hit’ов"
|
||||||
|
description="Нужен nft + deny_port_hits. После drop с deny появятся tcp/udp dport. Re-run install-ссылки обновляет правила."
|
||||||
|
centered={false}
|
||||||
|
className="py-8"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DataGrid
|
||||||
|
table={table}
|
||||||
|
recordCount={data.length}
|
||||||
|
tableLayout={{ dense: true }}
|
||||||
|
>
|
||||||
|
<DataGridTable />
|
||||||
|
</DataGrid>
|
||||||
|
)}
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,329 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import {
|
||||||
|
getCoreRowModel,
|
||||||
|
useReactTable,
|
||||||
|
type ColumnDef,
|
||||||
|
} from '@tanstack/react-table'
|
||||||
|
import { ShieldIcon } from 'lucide-react'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||||||
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
|
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { EmptyState } from '@/components/empty-state'
|
||||||
|
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||||
|
import {
|
||||||
|
agentHostFirewallQueryOptions,
|
||||||
|
type HostFwRuleDto,
|
||||||
|
type HostListenerDto,
|
||||||
|
} from '@/queries'
|
||||||
|
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@evofw/ui/components/select'
|
||||||
|
import { TabsContent } from '@evofw/ui/components/tabs'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observed host firewall + listeners (Linux).
|
||||||
|
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
||||||
|
* · https://reui.io/preview/base/empty-state-12
|
||||||
|
*/
|
||||||
|
|
||||||
|
type AgentHostFirewallProps = {
|
||||||
|
agentId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentHostFirewall({ agentId }: AgentHostFirewallProps) {
|
||||||
|
const q = useQuery(agentHostFirewallQueryOptions(agentId))
|
||||||
|
const [tab, setTab] = useState('rules')
|
||||||
|
const [ownership, setOwnership] = useState<'all' | 'evofw' | 'foreign'>('all')
|
||||||
|
const [backend, setBackend] = useState<string>('all')
|
||||||
|
|
||||||
|
const rules = useMemo(() => {
|
||||||
|
let items = q.data?.rules ?? []
|
||||||
|
if (ownership !== 'all') {
|
||||||
|
items = items.filter((r) => r.ownership === ownership)
|
||||||
|
}
|
||||||
|
if (backend !== 'all') {
|
||||||
|
items = items.filter((r) => r.backend === backend)
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}, [q.data?.rules, ownership, backend])
|
||||||
|
|
||||||
|
const listeners = q.data?.listeners ?? []
|
||||||
|
const backends = useMemo(() => {
|
||||||
|
const s = new Set((q.data?.rules ?? []).map((r) => r.backend))
|
||||||
|
return Array.from(s).sort()
|
||||||
|
}, [q.data?.rules])
|
||||||
|
|
||||||
|
const ruleCols = useMemo<ColumnDef<HostFwRuleDto>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: 'ownership',
|
||||||
|
accessorKey: 'ownership',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Owner" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.ownership === 'evofw' ? (
|
||||||
|
<Badge variant="success" size="sm">
|
||||||
|
EvoFW
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary" size="sm">
|
||||||
|
foreign
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
meta: { headerTitle: 'Owner' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'backend',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Backend" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-xs">{row.original.backend}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'chain',
|
||||||
|
accessorFn: (r) => r.chain || r.table || '—',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Chain" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-muted-foreground text-xs">
|
||||||
|
{row.original.chain || row.original.table || '—'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'action',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Action" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-xs">{row.original.action || '—'}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ports',
|
||||||
|
accessorFn: (r) =>
|
||||||
|
[r.protocol, r.dport].filter(Boolean).join('/') || '—',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Proto/Port" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-xs tabular-nums">
|
||||||
|
{[row.original.protocol, row.original.dport]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('/') || '—'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'saddr',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Src" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-xs">
|
||||||
|
{row.original.saddr || '—'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'raw',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Raw" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span
|
||||||
|
className="text-muted-foreground block max-w-[280px] truncate font-mono text-[11px]"
|
||||||
|
title={row.original.raw}
|
||||||
|
>
|
||||||
|
{row.original.raw}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const listenerCols = useMemo<ColumnDef<HostListenerDto>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'protocol',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Proto" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-xs uppercase">
|
||||||
|
{row.original.protocol}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'port',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Port" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-xs tabular-nums">
|
||||||
|
{row.original.port}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'address',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Address" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="font-mono text-xs">{row.original.address}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'process',
|
||||||
|
header: ({ column }) => (
|
||||||
|
<DataGridColumnHeader column={column} title="Process" />
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{row.original.process || '—'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const rulesTable = useReactTable({
|
||||||
|
data: rules,
|
||||||
|
columns: ruleCols,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getRowId: (r, i) => `${r.backend}-${r.chain}-${i}-${r.raw.slice(0, 40)}`,
|
||||||
|
})
|
||||||
|
const listenersTable = useReactTable({
|
||||||
|
data: listeners,
|
||||||
|
columns: listenerCols,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getRowId: (r, i) => `${r.protocol}-${r.address}-${r.port}-${i}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Frame dense spacing="sm">
|
||||||
|
<FrameHeader>
|
||||||
|
<FrameTitle>Host firewall</FrameTitle>
|
||||||
|
<FrameDescription>
|
||||||
|
Снимок nft/iptables/ufw/firewalld + listeners. EvoFW vs foreign.
|
||||||
|
{q.data?.collected_at
|
||||||
|
? ` Обновлено: ${new Date(q.data.collected_at).toLocaleString('ru-RU')}`
|
||||||
|
: ' Пока нет снимка — дождитесь sync агента.'}
|
||||||
|
</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel className="flex flex-col gap-3 p-4">
|
||||||
|
<CountedLineTabs
|
||||||
|
value={tab}
|
||||||
|
onValueChange={setTab}
|
||||||
|
tabs={[
|
||||||
|
{ id: 'rules', label: 'Rules', count: rules.length },
|
||||||
|
{ id: 'listeners', label: 'Listeners', count: listeners.length },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<TabsContent value="rules" className="mt-3 flex flex-col gap-3">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Select
|
||||||
|
value={ownership}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (v) setOwnership(v as typeof ownership)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="Owner" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All owners</SelectItem>
|
||||||
|
<SelectItem value="evofw">EvoFW</SelectItem>
|
||||||
|
<SelectItem value="foreign">Foreign</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
value={backend}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (v) setBackend(v)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[140px]">
|
||||||
|
<SelectValue placeholder="Backend" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All backends</SelectItem>
|
||||||
|
{backends.map((b) => (
|
||||||
|
<SelectItem key={b} value={b}>
|
||||||
|
{b}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{q.isLoading ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Skeleton className="h-8 w-full" />
|
||||||
|
<Skeleton className="h-8 w-full" />
|
||||||
|
</div>
|
||||||
|
) : rules.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={ShieldIcon}
|
||||||
|
title="Нет правил в снимке"
|
||||||
|
description="После sync Linux-агент пришлёт host_firewall."
|
||||||
|
centered={false}
|
||||||
|
className="py-6"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DataGrid
|
||||||
|
table={rulesTable}
|
||||||
|
recordCount={rules.length}
|
||||||
|
tableLayout={{ dense: true }}
|
||||||
|
>
|
||||||
|
<DataGridTable />
|
||||||
|
</DataGrid>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="listeners" className="mt-3">
|
||||||
|
{q.isLoading ? (
|
||||||
|
<Skeleton className="h-8 w-full" />
|
||||||
|
) : listeners.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={ShieldIcon}
|
||||||
|
title="Нет listeners"
|
||||||
|
description="ss -lntu не вернул сокеты или снимок пуст."
|
||||||
|
centered={false}
|
||||||
|
className="py-6"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DataGrid
|
||||||
|
table={listenersTable}
|
||||||
|
recordCount={listeners.length}
|
||||||
|
tableLayout={{ dense: true }}
|
||||||
|
>
|
||||||
|
<DataGridTable />
|
||||||
|
</DataGrid>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</CountedLineTabs>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Router, Terminal } from 'lucide-react'
|
import { 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
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -35,7 +35,11 @@ export function QueryState({
|
|||||||
children,
|
children,
|
||||||
}: QueryStateProps) {
|
}: QueryStateProps) {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return skeleton ?? <DefaultSkeleton />
|
return (
|
||||||
|
<div role="status" aria-live="polite" aria-busy="true">
|
||||||
|
{skeleton ?? <DefaultSkeleton />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isError) {
|
if (isError) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Frame, FramePanel } from '@/components/reui/frame'
|
|||||||
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'
|
||||||
import { kpiCols } from './kpi-cols'
|
import { kpiCols } from './kpi-cols'
|
||||||
import { Item, ItemMedia } from '@evofw/ui/components/item'
|
import { IconTile } from '@/components/reui/icon-tile'
|
||||||
import { Skeleton } from '@evofw/ui/components/skeleton'
|
import { Skeleton } from '@evofw/ui/components/skeleton'
|
||||||
|
|
||||||
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
|
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
|
||||||
@@ -83,16 +83,13 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
|||||||
return (
|
return (
|
||||||
<div className="relative z-10 flex h-full items-start gap-3">
|
<div className="relative z-10 flex h-full items-start gap-3">
|
||||||
{item.icon ? (
|
{item.icon ? (
|
||||||
<Item
|
<IconTile
|
||||||
className={cn(
|
variant="elevated"
|
||||||
'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',
|
aria-hidden="true"
|
||||||
item.iconClassName ?? DEFAULT_ICON_CLASS,
|
className={cn('size-10.5', item.iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<ItemMedia variant="icon" className="size-auto">
|
{item.icon}
|
||||||
{item.icon}
|
</IconTile>
|
||||||
</ItemMedia>
|
|
||||||
</Item>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
FrameTitle,
|
FrameTitle,
|
||||||
} from '@/components/reui/frame'
|
} from '@/components/reui/frame'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { Item, ItemMedia } from '@evofw/ui/components/item'
|
import { IconTile } from '@/components/reui/icon-tile'
|
||||||
import { cn } from '@evofw/ui/lib/utils'
|
import { cn } from '@evofw/ui/lib/utils'
|
||||||
import { kpiCols } from './kpi-cols'
|
import { kpiCols } from './kpi-cols'
|
||||||
|
|
||||||
@@ -39,16 +39,13 @@ function QuickActionBody({ action }: { action: QuickActionItem }) {
|
|||||||
return (
|
return (
|
||||||
<div className="relative z-10 flex h-full items-start gap-3">
|
<div className="relative z-10 flex h-full items-start gap-3">
|
||||||
{action.icon ? (
|
{action.icon ? (
|
||||||
<Item
|
<IconTile
|
||||||
className={cn(
|
variant="elevated"
|
||||||
'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',
|
aria-hidden="true"
|
||||||
action.iconClassName ?? DEFAULT_ICON_CLASS,
|
className={cn('size-10.5', action.iconClassName ?? DEFAULT_ICON_CLASS)}
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<ItemMedia variant="icon" className="size-auto">
|
{action.icon}
|
||||||
{action.icon}
|
</IconTile>
|
||||||
</ItemMedia>
|
|
||||||
</Item>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@evofw/ui/lib/utils"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSS variable architecture:
|
||||||
|
*
|
||||||
|
* The root owns four variables so every part of the tile stays in proportion
|
||||||
|
* and stays overridable from a single `className`:
|
||||||
|
*
|
||||||
|
* --icon-tile-size tile width/height
|
||||||
|
* --icon-tile-icon-size glyph size applied to child svgs
|
||||||
|
* --icon-tile-radius corner radius (also drives the nested inner card)
|
||||||
|
* --icon-tile-inset gap between the outer ring and the inner card
|
||||||
|
*
|
||||||
|
* The `frame` and `soft` variants paint their inner card with an `::after`
|
||||||
|
* pseudo element instead of a wrapper node. `isolate` makes the root a stacking
|
||||||
|
* context, so the negative z-index pseudo paints above the root background but
|
||||||
|
* below the in-flow icon - no extra DOM, and `render` composition keeps working.
|
||||||
|
*
|
||||||
|
* Tone: `soft` and `solid` derive every fill and border from `currentColor`, so
|
||||||
|
* a single text color class (e.g. `text-success`) retints the whole tile. They
|
||||||
|
* default to `text-primary`; override it to recolor without touching internals.
|
||||||
|
*/
|
||||||
|
const iconTileVariants = cva(
|
||||||
|
[
|
||||||
|
"relative inline-flex shrink-0 items-center justify-center align-middle",
|
||||||
|
"size-(--icon-tile-size) rounded-(--icon-tile-radius)",
|
||||||
|
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-(--icon-tile-icon-size)",
|
||||||
|
],
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
/** Plain bordered surface. The quiet default for list rows and toolbars. */
|
||||||
|
outline: "border border-border bg-background dark:bg-input/32",
|
||||||
|
/** Raised muted fill with a background-colored ring. Reads as a physical chip. */
|
||||||
|
elevated:
|
||||||
|
"border-2 border-background bg-muted text-accent-foreground shadow-[0_1px_3px_0_rgb(0_0_0/0.14)] dark:border",
|
||||||
|
/**
|
||||||
|
* Tinted double container: an opacity-filled outer ring with no border
|
||||||
|
* around a bordered inner card, all derived from `currentColor`. The
|
||||||
|
* quiet, colorful sibling of `frame`. Retint with a text color class.
|
||||||
|
*/
|
||||||
|
soft: [
|
||||||
|
"isolate p-(--icon-tile-inset) text-primary bg-current/10",
|
||||||
|
"after:absolute after:-z-10 after:inset-(--icon-tile-inset)",
|
||||||
|
"after:rounded-[calc(var(--icon-tile-radius)-var(--icon-tile-inset))]",
|
||||||
|
"after:border after:border-current/20 after:bg-current/5",
|
||||||
|
],
|
||||||
|
/** Filled tone with a contrasting glyph. Retint with `bg-*` + a text color. */
|
||||||
|
solid: "bg-primary text-primary-foreground",
|
||||||
|
/** Double container - a muted ring around an inset card, matching Frame. */
|
||||||
|
frame: [
|
||||||
|
"isolate border border-border bg-muted/50 p-(--icon-tile-inset)",
|
||||||
|
"after:absolute after:-z-10 after:inset-(--icon-tile-inset)",
|
||||||
|
"after:rounded-[calc(var(--icon-tile-radius)-var(--icon-tile-inset))]",
|
||||||
|
"after:border after:border-border after:bg-card after:shadow-xs",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
xs: "[--icon-tile-size:--spacing(6)] [--icon-tile-icon-size:--spacing(3.5)] [--icon-tile-inset:--spacing(0.5)]",
|
||||||
|
sm: "[--icon-tile-size:--spacing(8)] [--icon-tile-icon-size:--spacing(4)] [--icon-tile-inset:--spacing(0.5)]",
|
||||||
|
default:
|
||||||
|
"[--icon-tile-size:--spacing(10)] [--icon-tile-icon-size:--spacing(4.5)] [--icon-tile-inset:--spacing(0.75)]",
|
||||||
|
lg: "[--icon-tile-size:--spacing(12)] [--icon-tile-icon-size:--spacing(5.5)] [--icon-tile-inset:--spacing(0.75)]",
|
||||||
|
xl: "[--icon-tile-size:--spacing(14)] [--icon-tile-icon-size:--spacing(7)] [--icon-tile-inset:--spacing(1)]",
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* `default`: active style radius. `full`: circular.
|
||||||
|
* The plain value is the fallback outside a style scope; the `style-*`
|
||||||
|
* tokens win by specificity inside one, and survive the registry
|
||||||
|
* transform as the single resolved value per generated style.
|
||||||
|
*
|
||||||
|
* Each style token is clamped to a fraction of the tile. A flat radius
|
||||||
|
* is a circle once it reaches half the box, so the soft styles used to
|
||||||
|
* render `xs` (24px) and `sm` (32px) as plain circles and swallow the
|
||||||
|
* `full` variant's meaning. Clamping keeps one corner ratio at every
|
||||||
|
* size instead, so a tile still reads as the same shape when it scales.
|
||||||
|
* Luma and Rhea take the gentler quarter ratio; Maia stays at a third.
|
||||||
|
*/
|
||||||
|
radius: {
|
||||||
|
default:
|
||||||
|
"[--icon-tile-radius:min(var(--radius-md),calc(var(--icon-tile-size)/3))] [--icon-tile-radius:min(var(--radius-md),calc(var(--icon-tile-size)/3))]",
|
||||||
|
full: "[--icon-tile-radius:calc(infinity*1px)]",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "outline",
|
||||||
|
size: "default",
|
||||||
|
radius: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
interface IconTileProps extends useRender.ComponentProps<"span"> {
|
||||||
|
variant?: VariantProps<typeof iconTileVariants>["variant"]
|
||||||
|
size?: VariantProps<typeof iconTileVariants>["size"]
|
||||||
|
radius?: VariantProps<typeof iconTileVariants>["radius"]
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconTile({
|
||||||
|
className,
|
||||||
|
variant = "outline",
|
||||||
|
size = "default",
|
||||||
|
radius = "default",
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: IconTileProps) {
|
||||||
|
const defaultProps = {
|
||||||
|
"data-slot": "icon-tile",
|
||||||
|
"data-variant": variant,
|
||||||
|
"data-size": size,
|
||||||
|
className: cn(iconTileVariants({ variant, size, radius, className })),
|
||||||
|
}
|
||||||
|
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "span",
|
||||||
|
render,
|
||||||
|
props: mergeProps<"span">(defaultProps, props),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export { IconTile, iconTileVariants, type IconTileProps }
|
||||||
@@ -1,2 +1,6 @@
|
|||||||
/** Project surface lock — ops screens use ReUI Frame only. */
|
/** Project surface lock — ops screens use ReUI Frame only. */
|
||||||
export const UI_SURFACE = 'frame' as const
|
export const UI_SURFACE = 'frame' as const
|
||||||
|
|
||||||
|
/** Skip link — keyboard: first Tab. */
|
||||||
|
export const SKIP_TO_CONTENT_CLASS =
|
||||||
|
'bg-background text-foreground ring-ring sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:rounded-md focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-sm focus:ring-2'
|
||||||
@@ -154,7 +154,7 @@ export const settingsQueryOptions = () =>
|
|||||||
|
|
||||||
export const agentStatsQueryOptions = (id: string) =>
|
export const agentStatsQueryOptions = (id: string) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ['agent-stats', id],
|
queryKey: ['agents', id, 'stats'],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
apiFetch<{
|
apiFetch<{
|
||||||
items: {
|
items: {
|
||||||
@@ -165,6 +165,101 @@ export const agentStatsQueryOptions = (id: string) =>
|
|||||||
}>(`/api/v1/agents/${id}/stats`),
|
}>(`/api/v1/agents/${id}/stats`),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const agentBlockedIpsQueryOptions = (id: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ['agents', id, 'blocked-ips'],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<{
|
||||||
|
items: {
|
||||||
|
ip: string
|
||||||
|
packets: number
|
||||||
|
first_seen_at: string
|
||||||
|
last_seen_at: string
|
||||||
|
ports?: {
|
||||||
|
port: number
|
||||||
|
protocol: string
|
||||||
|
packets: number
|
||||||
|
}[]
|
||||||
|
}[]
|
||||||
|
}>(`/api/v1/agents/${id}/blocked-ips`),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const agentBlockedPortsQueryOptions = (id: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ['agents', id, 'blocked-ports'],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<{
|
||||||
|
items: {
|
||||||
|
port: number
|
||||||
|
protocol: string
|
||||||
|
packets: number
|
||||||
|
last_seen_at: string
|
||||||
|
}[]
|
||||||
|
}>(`/api/v1/agents/${id}/blocked-ports`),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type AgentPortRuleDto = {
|
||||||
|
id: string
|
||||||
|
agent_id: string
|
||||||
|
action: 'open' | 'close'
|
||||||
|
protocol: 'tcp' | 'udp' | 'both'
|
||||||
|
port_start: number
|
||||||
|
port_end: number
|
||||||
|
src_kind: 'all' | 'cidr' | 'list'
|
||||||
|
src_cidr?: string | null
|
||||||
|
list_id?: string | null
|
||||||
|
list_name?: string | null
|
||||||
|
enabled: boolean
|
||||||
|
comment?: string | null
|
||||||
|
priority: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const agentPortRulesQueryOptions = (id: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ['agents', id, 'port-rules'],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<{ items: AgentPortRuleDto[] }>(
|
||||||
|
`/api/v1/agents/${id}/port-rules`,
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type HostFwRuleDto = {
|
||||||
|
ownership: 'evofw' | 'foreign'
|
||||||
|
backend: string
|
||||||
|
table?: string
|
||||||
|
chain?: string
|
||||||
|
action?: string
|
||||||
|
protocol?: string
|
||||||
|
dport?: string
|
||||||
|
sport?: string
|
||||||
|
saddr?: string
|
||||||
|
daddr?: string
|
||||||
|
comment?: string
|
||||||
|
raw: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HostListenerDto = {
|
||||||
|
protocol: string
|
||||||
|
port: number
|
||||||
|
address: string
|
||||||
|
process?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const agentHostFirewallQueryOptions = (id: string) =>
|
||||||
|
queryOptions({
|
||||||
|
queryKey: ['agents', id, 'host-firewall'],
|
||||||
|
queryFn: () =>
|
||||||
|
apiFetch<{
|
||||||
|
collected_at: string | null
|
||||||
|
raw_digest: string | null
|
||||||
|
rules: HostFwRuleDto[]
|
||||||
|
listeners: HostListenerDto[]
|
||||||
|
}>(`/api/v1/agents/${id}/host-firewall`),
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
})
|
||||||
|
|
||||||
export const recentStatsQueryOptions = () =>
|
export const recentStatsQueryOptions = () =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: ['stats-recent'],
|
queryKey: ['stats-recent'],
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ import { Input } from '@evofw/ui/components/input'
|
|||||||
import { Switch } from '@evofw/ui/components/switch'
|
import { Switch } from '@evofw/ui/components/switch'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Control plane settings — Frame + SettingRow.
|
* Control plane settings — single page: PageShell + Frame + SettingRow.
|
||||||
* Preview: https://reui.io/preview/base/settings-16 · settings-3
|
* SettingsShell (reui-kit) — только при 2+ секциях.
|
||||||
|
* Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-3
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/settings')({
|
export const Route = createFileRoute('/_auth/settings')({
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/** @type {import('@commitlint/types').UserConfig} */
|
||||||
|
module.exports = {
|
||||||
|
extends: ['@commitlint/config-conventional'],
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
rules: {
|
||||||
|
'scope-no-commas': ({ scope }) => {
|
||||||
|
if (scope && scope.includes(',')) {
|
||||||
|
return [
|
||||||
|
false,
|
||||||
|
'scope must not contain commas (semantic-release will not parse the commit type)'
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [true];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
// Subject после «:» на русском — не английский sentence-case.
|
||||||
|
'subject-case': [0],
|
||||||
|
'scope-no-commas': [2, 'always']
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
services:
|
services:
|
||||||
evofirewall:
|
evofirewall:
|
||||||
image: git.shts.su/denozord/evofirewall:latest
|
# evofw и evofirewall — один образ (алиас для drop-in).
|
||||||
|
image: git.shx.one/denozord/evofw:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: sqlite:/data/app.db
|
DATABASE_URL: sqlite:/data/app.db
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Docker-образы EvoFirewall
|
||||||
|
|
||||||
|
## CI (Gitea Actions)
|
||||||
|
|
||||||
|
Сборка образов — **`docker buildx bake`** ([docker-bake.hcl](docker-bake.hcl)), не отдельные `docker build`.
|
||||||
|
|
||||||
|
**Publish:** push в `main` после quality gates — workflow [CD](../../.gitea/workflows/cd.yaml) job **publish**: semantic-release + зеркало base-образов + bake с `VERSION` из релиза.
|
||||||
|
|
||||||
|
Bake читает переменные из **окружения** (`REGISTRY`, `IMAGE_TAG`, `CACHE_REF_NODE`, `BASE_NODE`). Скрипт [write-bake-override.sh](write-bake-override.sh) — опциональный helper для локальной отладки.
|
||||||
|
|
||||||
|
BuildKit кэширует pnpm store через `RUN --mount=type=cache`. На CI mounts живут, пока named builder `evofw` не удаляют (`cleanup: false`).
|
||||||
|
|
||||||
|
### Слои и runtime
|
||||||
|
|
||||||
|
| Образ | Runtime base | Заметка |
|
||||||
|
|-------|----------------|---------|
|
||||||
|
| `evofw`, `evofirewall` | `node:22-alpine` | один процесс: API + SPA + SQLite; `better-sqlite3` собирается в alpine на stage `build` |
|
||||||
|
|
||||||
|
`python3` / `make` / `g++` только в `deps`/`build`. Context режется корневым [.dockerignore](../../.dockerignore).
|
||||||
|
|
||||||
|
Теги `evofw` и `evofirewall` — один и тот же образ (`evofirewall` — drop-in для старого compose).
|
||||||
|
|
||||||
|
### Кэш registry
|
||||||
|
|
||||||
|
- `git.shx.one/<owner>/evofw-buildcache:node-buildcache` — **запись** только из `node-build`
|
||||||
|
- `git.shx.one/<owner>/evofw-buildcache:base-node-22-alpine` — зеркало FROM с Docker Hub. Только `linux/amd64`; skip если тег уже в Gitea. Неуспешный copy не валит CD — bake берёт Docker Hub FROM.
|
||||||
|
|
||||||
|
`pull = false` в bake: не перекачивать FROM, если слой уже в builder. Не запускайте `docker system prune -a` на runner.
|
||||||
|
|
||||||
|
Параллельный `cache-to` в один ref ломает manifest (`content descriptor … not found`).
|
||||||
|
|
||||||
|
Если CI падает на «not found» после смены схемы кэша — один раз удалите теги `evofw-buildcache:*` в registry и пересоберите.
|
||||||
|
|
||||||
|
## Локальная сборка
|
||||||
|
|
||||||
|
Из корня репозитория:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd deploy/docker
|
||||||
|
export REGISTRY=git.shx.one/<owner>
|
||||||
|
export IMAGE_TAG=latest
|
||||||
|
export SHORT_SHA=$(git rev-parse --short HEAD)
|
||||||
|
export VERSION=dev
|
||||||
|
export BUILD_TIME=
|
||||||
|
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl default
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверка манифеста без сборки:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker buildx bake --allow=fs.read=../.. -f docker-bake.hcl --print default
|
||||||
|
```
|
||||||
|
|
||||||
|
В `docker-bake.hcl`: `context = "../.."` резолвится **от cwd** (каталог `deploy/docker/`). `dockerfile` — путь от этого context (корень репо). Из корня репо не вызывать bake с `-f deploy/docker/docker-bake.hcl`: получится `lstat ../../deploy`. Можно задать `BUILDX_BAKE_FILE_RELATIVE_PATHS=1`.
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Единая сборка образа EvoFirewall (buildx bake: -f deploy/docker/docker-bake.hcl).
|
||||||
|
# context = "../.." — корень репо. Bake резолвит context от cwd (не от HCL),
|
||||||
|
# поэтому вызов из deploy/docker/ либо BUILDX_BAKE_FILE_RELATIVE_PATHS=1.
|
||||||
|
|
||||||
|
variable "REGISTRY" {
|
||||||
|
default = "git.shx.one/denozord"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "IMAGE_TAG" {
|
||||||
|
default = "latest"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "SHORT_SHA" {
|
||||||
|
default = "dev"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "SHA_FULL" {
|
||||||
|
default = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "VERSION" {
|
||||||
|
default = "dev"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "BUILD_TIME" {
|
||||||
|
default = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "CACHE_REF_NODE" {
|
||||||
|
default = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "BASE_NODE" {
|
||||||
|
default = "docker.io/library/node:22-alpine"
|
||||||
|
}
|
||||||
|
|
||||||
|
function "node-cache-from" {
|
||||||
|
params = []
|
||||||
|
result = notequal("", CACHE_REF_NODE) ? ["type=registry,ref=${CACHE_REF_NODE}"] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Экспорт кэша — один writer на ref (параллельный cache-to в один ref ломает manifest).
|
||||||
|
function "node-cache-to-export" {
|
||||||
|
params = []
|
||||||
|
result = notequal("", CACHE_REF_NODE) ? ["type=registry,ref=${CACHE_REF_NODE},mode=max"] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
function "image-tags" {
|
||||||
|
params = [name]
|
||||||
|
result = concat(
|
||||||
|
notequal("", SHA_FULL) ? [
|
||||||
|
"${REGISTRY}/${name}:${IMAGE_TAG}",
|
||||||
|
"${REGISTRY}/${name}:${SHORT_SHA}",
|
||||||
|
"${REGISTRY}/${name}:sha-${SHA_FULL}",
|
||||||
|
] : [
|
||||||
|
"${REGISTRY}/${name}:${IMAGE_TAG}",
|
||||||
|
"${REGISTRY}/${name}:${SHORT_SHA}",
|
||||||
|
],
|
||||||
|
notequal(VERSION, "dev") && notequal(VERSION, "") ? [
|
||||||
|
"${REGISTRY}/${name}:v${VERSION}",
|
||||||
|
"${REGISTRY}/${name}:${VERSION}",
|
||||||
|
] : []
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
group "default" {
|
||||||
|
targets = ["evofw"]
|
||||||
|
}
|
||||||
|
|
||||||
|
target "_common" {
|
||||||
|
platforms = ["linux/amd64"]
|
||||||
|
pull = false
|
||||||
|
}
|
||||||
|
|
||||||
|
target "node-deps" {
|
||||||
|
inherits = ["_common"]
|
||||||
|
context = "../.."
|
||||||
|
dockerfile = "deploy/docker/evofw/Dockerfile"
|
||||||
|
target = "deps"
|
||||||
|
args = {
|
||||||
|
BASE_NODE = BASE_NODE
|
||||||
|
}
|
||||||
|
cache-from = node-cache-from()
|
||||||
|
}
|
||||||
|
|
||||||
|
target "node-build" {
|
||||||
|
inherits = ["_common"]
|
||||||
|
context = "../.."
|
||||||
|
dockerfile = "deploy/docker/evofw/Dockerfile"
|
||||||
|
target = "build"
|
||||||
|
args = {
|
||||||
|
BASE_NODE = BASE_NODE
|
||||||
|
}
|
||||||
|
contexts = {
|
||||||
|
deps = "target:node-deps"
|
||||||
|
}
|
||||||
|
cache-from = node-cache-from()
|
||||||
|
cache-to = node-cache-to-export()
|
||||||
|
}
|
||||||
|
|
||||||
|
target "evofw" {
|
||||||
|
inherits = ["_common"]
|
||||||
|
context = "../.."
|
||||||
|
dockerfile = "deploy/docker/evofw/Dockerfile"
|
||||||
|
target = "runtime"
|
||||||
|
args = {
|
||||||
|
BASE_NODE = BASE_NODE
|
||||||
|
VERSION = VERSION
|
||||||
|
GIT_SHA = notequal("", SHA_FULL) ? SHA_FULL : SHORT_SHA
|
||||||
|
BUILD_TIME = BUILD_TIME
|
||||||
|
}
|
||||||
|
contexts = {
|
||||||
|
build = "target:node-build"
|
||||||
|
}
|
||||||
|
cache-from = node-cache-from()
|
||||||
|
tags = concat(image-tags("evofw"), image-tags("evofirewall"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
# All-in-one EvoFirewall (Fastify + SPA + SQLite). Analog evobgp-all.
|
||||||
|
# Install slugs GET /{slug} stay on the same process — do not split nginx.
|
||||||
|
ARG BASE_NODE=docker.io/library/node:22-alpine
|
||||||
|
|
||||||
|
FROM ${BASE_NODE} AS deps
|
||||||
|
WORKDIR /repo
|
||||||
|
RUN apk add --no-cache python3 make g++ \
|
||||||
|
&& corepack enable && corepack prepare pnpm@10.12.1 --activate
|
||||||
|
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml turbo.json ./
|
||||||
|
COPY apps/web/package.json apps/web/
|
||||||
|
COPY apps/api/package.json apps/api/
|
||||||
|
COPY packages/ui/package.json packages/ui/
|
||||||
|
COPY packages/shared/package.json packages/shared/
|
||||||
|
COPY packages/db/package.json packages/db/
|
||||||
|
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
FROM deps AS build
|
||||||
|
COPY apps/web apps/web
|
||||||
|
COPY apps/api apps/api
|
||||||
|
COPY packages/ui packages/ui
|
||||||
|
COPY packages/shared packages/shared
|
||||||
|
COPY packages/db packages/db
|
||||||
|
RUN --mount=type=cache,target=/root/.local/share/pnpm/store,sharing=locked \
|
||||||
|
pnpm turbo build --filter=@evofw/web --filter=@evofw/api \
|
||||||
|
&& pnpm --filter @evofw/api deploy --prod /out \
|
||||||
|
&& cp -r apps/web/dist /out/static \
|
||||||
|
&& mkdir -p /out/dist/agent-scripts \
|
||||||
|
&& cp -r apps/api/src/agent-scripts/* /out/dist/agent-scripts/ \
|
||||||
|
&& rm -rf /out/src /out/test /out/.turbo \
|
||||||
|
&& rm -rf /out/node_modules/@evofw/db/src /out/node_modules/@evofw/db/.turbo \
|
||||||
|
&& rm -rf /out/node_modules/@evofw/shared/src /out/node_modules/@evofw/shared/.turbo \
|
||||||
|
&& find /out -name '*.map' -delete
|
||||||
|
|
||||||
|
FROM ${BASE_NODE} AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apk add --no-cache ca-certificates
|
||||||
|
ARG VERSION=dev
|
||||||
|
ARG GIT_SHA=unknown
|
||||||
|
ARG BUILD_TIME=
|
||||||
|
ENV NODE_ENV=production \
|
||||||
|
STATIC_DIR=/app/static \
|
||||||
|
DATABASE_URL=sqlite:/data/app.db \
|
||||||
|
SERVER_PORT=8080 \
|
||||||
|
APP_VERSION=${VERSION} \
|
||||||
|
GIT_SHA=${GIT_SHA} \
|
||||||
|
BUILD_TIME=${BUILD_TIME}
|
||||||
|
COPY --from=build /out ./
|
||||||
|
EXPOSE 8080
|
||||||
|
VOLUME ["/data"]
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
|
CMD ["node", "-e", "fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||||
|
CMD ["node", "dist/server.js"]
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
# Copy-by-digest зеркало базовых образов в Gitea Container Registry (без rebuild).
|
||||||
|
# Требует: docker login в REGISTRY host, docker buildx.
|
||||||
|
# REGISTRY=git.shx.one/<owner> (без суффикса /evofw-buildcache)
|
||||||
|
#
|
||||||
|
# Источник library-образов — Docker Hub (docker.io).
|
||||||
|
# Если тег уже есть в Gitea — skip (без повторного pull).
|
||||||
|
# Копируем только linux/amd64 — bake platforms совпадает.
|
||||||
|
# Шаг CD не должен падать: неуспешный copy оставляет bake на Docker Hub FROM.
|
||||||
|
set -eu
|
||||||
|
REGISTRY="${REGISTRY:?REGISTRY required (git.shx.one/<owner>)}"
|
||||||
|
CACHE_REPO="${REGISTRY}/evofw-buildcache"
|
||||||
|
ENV_FILE="${MIRROR_ENV_FILE:-}"
|
||||||
|
PLATFORM="${MIRROR_PLATFORM:-linux/amd64}"
|
||||||
|
RETRIES="${MIRROR_RETRIES:-4}"
|
||||||
|
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
cleanup() { rm -f "$tmp"; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
dest_exists() {
|
||||||
|
docker buildx imagetools inspect "$1" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
copy_retry() {
|
||||||
|
src="$1"
|
||||||
|
dest="$2"
|
||||||
|
n=0
|
||||||
|
while [ "$n" -lt "$RETRIES" ]; do
|
||||||
|
n=$((n + 1))
|
||||||
|
if docker buildx imagetools create --platform "$PLATFORM" --tag "$dest" "$src"; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
delay=$((n * 25))
|
||||||
|
echo "mirror retry ${n}/${RETRIES}, sleep ${delay}s: ${dest}"
|
||||||
|
sleep "$delay"
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
mirror() {
|
||||||
|
src="$1"
|
||||||
|
tag="$2"
|
||||||
|
var="$3"
|
||||||
|
dest="${CACHE_REPO}:${tag}"
|
||||||
|
if dest_exists "$dest"; then
|
||||||
|
echo "skip (already in registry): ${dest}"
|
||||||
|
printf '%s=%s\n' "$var" "$dest" >>"$tmp"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "mirror ${src} -> ${dest} (${PLATFORM})"
|
||||||
|
if copy_retry "$src" "$dest"; then
|
||||||
|
printf '%s=%s\n' "$var" "$dest" >>"$tmp"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "warn: ${dest} not mirrored — bake uses Docker Hub FROM for ${var}"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
mirror "docker.io/library/node:22-alpine" "base-node-22-alpine" BASE_NODE
|
||||||
|
|
||||||
|
echo "----- mirrored BASE_* -----"
|
||||||
|
cat "$tmp"
|
||||||
|
if [ -n "$ENV_FILE" ]; then
|
||||||
|
env_dir="$(dirname "$ENV_FILE")"
|
||||||
|
if [ -d "$env_dir" ]; then
|
||||||
|
cp "$tmp" "$ENV_FILE"
|
||||||
|
echo "wrote ${ENV_FILE}"
|
||||||
|
else
|
||||||
|
echo "warn: MIRROR_ENV_FILE dir missing (${env_dir}) — skip write"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
# Optional helper for local bake when env-based Bake variables are inconvenient.
|
||||||
|
# CI задаёт те же имена переменных в окружении и вызывает bake без этого файла.
|
||||||
|
set -eu
|
||||||
|
OUT="${1:-docker-bake.override.hcl}"
|
||||||
|
REGISTRY="${REGISTRY:?REGISTRY required}"
|
||||||
|
IMAGE_TAG="${IMAGE_TAG:-latest}"
|
||||||
|
SHORT_SHA="${SHORT_SHA:-dev}"
|
||||||
|
SHA_FULL="${SHA_FULL:-}"
|
||||||
|
VERSION="${VERSION:-dev}"
|
||||||
|
BUILD_TIME="${BUILD_TIME:-}"
|
||||||
|
CACHE_REF_NODE="${CACHE_REF_NODE:-}"
|
||||||
|
BASE_NODE="${BASE_NODE:-}"
|
||||||
|
cat >"$OUT" <<EOF
|
||||||
|
# Generated by deploy/docker/write-bake-override.sh — do not commit.
|
||||||
|
variable "REGISTRY" { default = "${REGISTRY}" }
|
||||||
|
variable "IMAGE_TAG" { default = "${IMAGE_TAG}" }
|
||||||
|
variable "SHORT_SHA" { default = "${SHORT_SHA}" }
|
||||||
|
variable "SHA_FULL" { default = "${SHA_FULL}" }
|
||||||
|
variable "VERSION" { default = "${VERSION}" }
|
||||||
|
variable "BUILD_TIME" { default = "${BUILD_TIME}" }
|
||||||
|
variable "CACHE_REF_NODE" { default = "${CACHE_REF_NODE}" }
|
||||||
|
EOF
|
||||||
|
if [ -n "$BASE_NODE" ]; then printf 'variable "BASE_NODE" { default = "%s" }\n' "$BASE_NODE" >>"$OUT"; fi
|
||||||
+2
-2
@@ -6,8 +6,8 @@
|
|||||||
- [Agents](agents.md)
|
- [Agents](agents.md)
|
||||||
- [UI design contract](ui-design-contract.md)
|
- [UI design contract](ui-design-contract.md)
|
||||||
- [OpenAPI](openapi.yaml) — **HTTP SoT** (пути/операции). Живая валидация request bodies — Zod в `@evofw/shared`.
|
- [OpenAPI](openapi.yaml) — **HTTP SoT** (пути/операции). Живая валидация request bodies — Zod в `@evofw/shared`.
|
||||||
|
- [Релизы / CI](releasing.md) — Gitea Actions, semantic-release, docker bake.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Lint OpenAPI (requires npx)
|
pnpm openapi:lint
|
||||||
npx --yes @redocly/cli lint docs/openapi.yaml
|
|
||||||
```
|
```
|
||||||
+84
-5
@@ -20,7 +20,7 @@ curl -fsSL https://<cp>/agent-install/<id> | bash
|
|||||||
3. После enroll статус станет **Pending** — одобрите агента (Approve).
|
3. После enroll статус станет **Pending** — одобрите агента (Approve).
|
||||||
4. **Approved** — агент синхронизирует политику.
|
4. **Approved** — агент синхронизирует политику.
|
||||||
|
|
||||||
**Повторный запуск той же install-ссылки** на хосте, где агент уже стоит: обновляет sync-скрипт / timer (или MikroTik scheduler), **без** повторного enroll — `CLIENT_ID`/`token` сохраняются. Полный переустановки с новым токеном: `EVOFW_INSTALL_FORCE=1` (Linux).
|
**Повторный запуск той же install-ссылки** на хосте, где агент уже стоит: обновляет sync-скрипт / timer (или MikroTik scheduler) **и пересоздаёт nft rules** (в т.ч. `deny_port_hits`), **без** повторного enroll — `CLIENT_ID`/`token` сохраняются. Сбрасывается `last_hash`, затем сразу force sync. Полный переустановки с новым токеном: `EVOFW_INSTALL_FORCE=1` (Linux).
|
||||||
|
|
||||||
API (auth): `POST /api/v1/install-links` `{ "name": "web-01", "platform": "linux" | "mikrotik" }`.
|
API (auth): `POST /api/v1/install-links` `{ "name": "web-01", "platform": "linux" | "mikrotik" }`.
|
||||||
|
|
||||||
@@ -42,6 +42,21 @@ Install сам ставит зависимости через apt/dnf/yum/apk: `
|
|||||||
|
|
||||||
Если `/etc/evofw/agent.conf` уже есть — install переходит в **update**: скачивает свежий `sync-script` + `uninstall.sh`, перезаписывает unit/timer, оставляет токен. `EVOFW_INSTALL_FORCE=1` — полный re-enroll (новый токен; для уже Approved install-link обычно не сработает).
|
Если `/etc/evofw/agent.conf` уже есть — install переходит в **update**: скачивает свежий `sync-script` + `uninstall.sh`, перезаписывает unit/timer, оставляет токен. `EVOFW_INSTALL_FORCE=1` — полный re-enroll (новый токен; для уже Approved install-link обычно не сработает).
|
||||||
|
|
||||||
|
### Linux self-update (sync-script)
|
||||||
|
|
||||||
|
После того как на хосте стоит скрипт с `maybe_self_update`, агент **сам** подтягивает новую версию при каждом timer (~1 мин), **до** `GET /v1/agent/policy` (pending тоже обновляются):
|
||||||
|
|
||||||
|
1. sha256 локального `/usr/local/sbin/evofw-firewall.sh`
|
||||||
|
2. `GET /v1/agent/sync-script` с `If-None-Match: "<sha256>"` → **304** = без изменений
|
||||||
|
3. **200**: проверка shebang + sha256 тела → `install -m 755`, сброс `last_hash`, `exec EVOFW_SKIP_SELF_UPDATE=1` новой копии
|
||||||
|
4. Ошибка download/verify — лог и продолжение **текущим** скриптом
|
||||||
|
|
||||||
|
`GET /v1/agent/policy` содержит `script_sha256` (не входит в `policy.hash`).
|
||||||
|
|
||||||
|
**Bootstrap:** агенты без `maybe_self_update` не умеют самообновляться. После деплоя API — **один** re-run install-ссылки (или ручная замена `sync-script`). Дальше curl не нужен.
|
||||||
|
|
||||||
|
MikroTik scheduler **не** обновляется этим путём — только повторный import install `.rsc`.
|
||||||
|
|
||||||
**Uninstall (Linux):**
|
**Uninstall (Linux):**
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://<cp>/v1/agent/uninstall.sh | bash
|
curl -fsSL https://<cp>/v1/agent/uninstall.sh | bash
|
||||||
@@ -53,6 +68,59 @@ Backend auto-detect: nft → ipset → iptables.
|
|||||||
|
|
||||||
Whitelist: nft chain policy drop + allow set. Blacklist: policy accept + deny set.
|
Whitelist: nft chain policy drop + allow set. Blacklist: policy accept + deny set.
|
||||||
|
|
||||||
|
## Per-IP blocked stats (Linux)
|
||||||
|
|
||||||
|
Linux agent reports optional `ip_hits` in `POST /v1/agent/apply-report`:
|
||||||
|
|
||||||
|
- **nft:** tries set `deny_v4` with `flags interval; counter;`. If the kernel rejects counters on interval sets, falls back to plain interval (aggregate Traffic ↓ still works; per-IP empty).
|
||||||
|
- Upgrade path: on install-link re-run, `last_hash` is cleared once so sets/chain can be recreated (chain deleted before set replace) — **обновляет и port-hit правила**.
|
||||||
|
- **ipset:** prefers `hash:net … counters` on create; existing sets without counters are left as-is.
|
||||||
|
- Payload: only entries with `packets > 0`, **top 200** by packets.
|
||||||
|
- Control plane: `agent_ip_block_stats`, accumulates **deltas** of absolute kernel counters (как Traffic ↓). После flush set/chain (policy apply) CP сбрасывает per-IP baseline (`last_reported`), иначе вторая эпоха счётчиков теряется (Traffic растёт, Blocked IPs — нет).
|
||||||
|
- `GET /api/v1/agents/:id/blocked-ips`, reset via `POST …/stats/reset`.
|
||||||
|
- UI: agent detail → **Blocked IPs**. Sum of Blocked IPs ≈ Traffic ↓ для deny (при default accept); default-drop / allow в Traffic считаются отдельно.
|
||||||
|
|
||||||
|
IPv6 skipped.
|
||||||
|
|
||||||
|
## Destination ports (Linux nft)
|
||||||
|
|
||||||
|
На **nft** агент ведёт dynamic set `deny_port_hits` (`ipv4_addr . inet_proto . inet_service`, timeout 1h, counter):
|
||||||
|
|
||||||
|
- Deny rule для tcp/udp: `update @deny_port_hits { ip saddr . meta l4proto . th dport }` + drop; прочие протоколы — plain drop.
|
||||||
|
- В `apply-report`: optional `port_hits` top **500** `{ ip, port, protocol, packets }`.
|
||||||
|
- CP: `agent_port_block_stats` (absolute deltas). Set **не** flush’ится на каждый policy apply (элементы живут по timeout) — baseline не сбрасывается при Traffic flush.
|
||||||
|
- `GET /api/v1/agents/:id/blocked-ports` — aggregate top 50 портов; в `blocked-ips` у каждого IP — `ports` top 5.
|
||||||
|
- UI: **Top ports** + колонка Ports в Blocked IPs (только `platform=linux`).
|
||||||
|
- **ipset/iptables:** `port_hits: []`. MikroTik — без port hits.
|
||||||
|
- Агенты с self-update подтянут nft-правила сами; без него — **один** re-run install one-liner (см. выше).
|
||||||
|
|
||||||
|
IPv6 skipped.
|
||||||
|
|
||||||
|
## Host firewall snapshot + Port ACL (Linux)
|
||||||
|
|
||||||
|
### Observed (Host firewall)
|
||||||
|
|
||||||
|
Каждый sync агент собирает best-effort снимок и шлёт в `apply-report.host_firewall`:
|
||||||
|
|
||||||
|
- `nft list ruleset`, `iptables-save`, optional `ufw` / `firewall-cmd`, `ss -lntu`
|
||||||
|
- Каждое правило: `ownership: evofw | foreign` (метка по имени table/chain/comment `evofw`)
|
||||||
|
- CP: `agent_host_firewall_snapshots`; `GET /api/v1/agents/:id/host-firewall`
|
||||||
|
- UI agent detail → tab **Host firewall** (Rules / Listeners)
|
||||||
|
|
||||||
|
Foreign правила **только отображаются** — с CP не редактируются.
|
||||||
|
|
||||||
|
### Desired (Port ACL)
|
||||||
|
|
||||||
|
Per-agent таблица `agent_port_rules`: `open|close`, `tcp|udp|both`, port range, `src_kind: all|cidr|list`.
|
||||||
|
|
||||||
|
- API: CRUD `/api/v1/agents/:id/port-rules`, import `/port-rules/import` (from list или policy set sources)
|
||||||
|
- Policy `apply_version: 3` → `port_rules[]` с expanded `src_cidrs`
|
||||||
|
- nft apply: close drop → open accept (`comment "evofw-port-<id>"`) → **implicit drop** для каждого `(proto, dport)` с хотя бы одним `open` (`comment "evofw-port-implicit-…"`). **Только входящие к сервисам** (не клиентский egress): **prerouting** `fib daddr type local` (публичный порт до Docker NAT) + **input** + **forward** `ct status dnat` (Docker `-p`). Порт с open = whitelist входящих: src из правила accept, остальные внешние drop. Исходящий curl/контейнер на 80/443 не матчится. `lo` и `ct established,related` выше по цепочке. prerouting **без** terminal default drop.
|
||||||
|
- UI: tab **Port ACL** (DataGrid + Sheet create/edit + Import). Owner: **EvoFW** (desired) и **system** (listeners + foreign allow с хоста). Переопределённые system-строки скрыты в All. Системный порт можно переопределить → desired `open` (список/CIDR); ufw/iptables/Docker-цепочки не меняются.
|
||||||
|
- ipset / MikroTik: без L4 apply; секции скрыты для non-linux
|
||||||
|
|
||||||
|
Мутация Port ACL бампит `policy_generation` → agent re-apply. Нужен self-update скрипта или re-run install-ссылки.
|
||||||
|
|
||||||
## MikroTik (RouterOS 7.21+)
|
## MikroTik (RouterOS 7.21+)
|
||||||
|
|
||||||
В UI `/agents` → **Добавить агента** → platform **MikroTik**. Скопируйте one-liner:
|
В UI `/agents` → **Добавить агента** → platform **MikroTik**. Скопируйте one-liner:
|
||||||
@@ -66,18 +134,29 @@ Whitelist: nft chain policy drop + allow set. Blacklist: policy accept + deny se
|
|||||||
Install RSC:
|
Install RSC:
|
||||||
|
|
||||||
1. Enroll (с `install_link_id` → агент Invited → Pending).
|
1. Enroll (с `install_link_id` → агент Invited → Pending).
|
||||||
2. Создаёт filter-правила `evofw-*` и address-list `EVOFW_DENY` / `EVOFW_ALLOW`.
|
2. Создаёт filter-правила `evofw-*` **в начале** цепочек `input`/`forward` (`place-before`) и address-list `EVOFW_DENY` / `EVOFW_ALLOW` / dynamic **`EVOFW_HITS`**.
|
||||||
3. Scheduler `evofw-sync` каждую минуту: `GET /v1/agent/policy` (JSON) → rebuild address-list + toggle default. Не использует `/import` огромного `.rsc` (на больших списках часто падает молча).
|
3. Scheduler `evofw-sync` каждую минуту: `GET /v1/agent/policy` (JSON) → rebuild deny/allow + report (+ `ip_hits` из HITS). Не использует `/import` огромного `.rsc`.
|
||||||
|
|
||||||
Лог: `/log print where message~"evofw"`. Ручной sync: `/system script run evofw-sync`.
|
Лог: `/log print where message~"evofw"`. Ручной sync: `/system script run evofw-sync`.
|
||||||
Traffic ↓/↑ в UI — сумма `packets` с filter-правил `evofw-deny-*` / `evofw-allow-*` / `evofw-default-drop-*` (накопительно, пока правила не пересозданы re-install).
|
Traffic ↓/↑ в UI — сумма `packets` с `evofw-deny-drop-*` / `evofw-allow-*` / `evofw-default-drop-*` (накопительно, пока правила не пересозданы re-install).
|
||||||
|
|
||||||
|
### Per-IP / Blocked IPs (MikroTik)
|
||||||
|
|
||||||
|
Цепочка deny: **hit → drop** (семантика drop/allow/default как раньше):
|
||||||
|
|
||||||
|
1. `evofw-deny-hit-input/forward` — `add-src-to-address-list` → `EVOFW_HITS`, `address-list-timeout=1h` (passthrough).
|
||||||
|
2. `evofw-deny-drop-input/forward` — `drop` по `EVOFW_DENY`.
|
||||||
|
|
||||||
|
В `EVOFW_HITS` попадают реальные src **/32**. Policy rebuild **не** чистит HITS (только DENY/ALLOW). Sync шлёт top-200 в `ip_hits`; CP mode **presence**: `last_seen` обновляется, пока IP в HITS; `packets` (Hits) увеличивается только при первом появлении или **повторном входе** после исчезновения из списка (~>2.5 мин без report), а не на каждый sync.
|
||||||
|
|
||||||
|
UI: agent detail → **Blocked IPs** (колонка Hits).
|
||||||
|
|
||||||
**Default action** задаётся на **агенте** (`default_action: accept | drop`):
|
**Default action** задаётся на **агенте** (`default_action: accept | drop`):
|
||||||
|
|
||||||
- **accept** — пакет вне deny/allow пропускается
|
- **accept** — пакет вне deny/allow пропускается
|
||||||
- **drop** — пакет вне deny/allow отбрасывается (forward)
|
- **drop** — пакет вне deny/allow отбрасывается (forward)
|
||||||
|
|
||||||
Цепочка всегда: deny-drop → allow-accept → default. Наборы несут только правила deny/allow, без exclusive mode.
|
Цепочка: deny-hit → deny-drop → allow-accept → default. Наборы несут только правила deny/allow, без exclusive mode.
|
||||||
|
|
||||||
## Force sync
|
## Force sync
|
||||||
|
|
||||||
|
|||||||
+10
-4
@@ -17,9 +17,10 @@
|
|||||||
|
|
||||||
1. **Enroll** — `POST /v1/agent/enroll` + `X-EvoFW-Seed` → pending agent
|
1. **Enroll** — `POST /v1/agent/enroll` + `X-EvoFW-Seed` → pending agent
|
||||||
2. **Approve** — UI/API → status approved
|
2. **Approve** — UI/API → status approved
|
||||||
3. **Policy** — `GET /v1/agent/policy` → deny/allow CIDRs + `default_action` + hash (`apply_version: 2`)
|
3. **Policy** — `GET /v1/agent/policy` → deny/allow CIDRs + `default_action` + optional `port_rules` + hash (`apply_version: 3`) + `script_sha256` (Linux; не в `policy.hash`)
|
||||||
4. **Apply** — agent пишет kernel rules, `POST /v1/agent/apply-report` + stats sample
|
4. **Linux self-update** — timer: `GET /v1/agent/sync-script` (`ETag` / `If-None-Match`) → при новой версии заменить `/usr/local/sbin/evofw-firewall.sh` и `exec` до policy
|
||||||
5. **Lists refresh** — cron каждые 5 мин (json_url / domains / evobgp_community)
|
5. **Apply** — agent пишет kernel rules (L3 + L4 port ACL на nft), `POST /v1/agent/apply-report` + stats + optional `host_firewall` snapshot
|
||||||
|
6. **Lists refresh** — cron каждые 5 мин (json_url / domains / evobgp_community)
|
||||||
|
|
||||||
## Политика
|
## Политика
|
||||||
|
|
||||||
@@ -27,10 +28,15 @@
|
|||||||
- Правило в наборе: `action: deny | allow` + ровно один источник — IP-список (`list_id`), CIDR или DNS-имя (`hostname` → A/AAAA, кэш в `policy_rule_resolved`)
|
- Правило в наборе: `action: deny | allow` + ровно один источник — IP-список (`list_id`), CIDR или DNS-имя (`hostname` → A/AAAA, кэш в `policy_rule_resolved`)
|
||||||
- Evaluate: правила всех назначенных enabled-наборов (sort + priority) + `ip_overrides`
|
- Evaluate: правила всех назначенных enabled-наборов (sort + priority) + `ip_overrides`
|
||||||
- Цепочка ядра **всегда**: deny → allow → `default_action` (`accept` | `drop` на агенте)
|
- Цепочка ядра **всегда**: deny → allow → `default_action` (`accept` | `drop` на агенте)
|
||||||
|
- На Linux nft: deny → **Port ACL** (`close` / `open` / implicit drop для портов с open) → allow → `default_action` на hooks **prerouting** (priority mangle, `fib daddr type local`, до Docker DNAT), **input** и **forward** (`ct status dnat`). `open` по списку = **входящие** на публичный порт только с разрешённых src (хост и Docker `-p`); исходящий клиентский 80/443 не режется.
|
||||||
- Exact overlap: `allow \ deny` (`conflicts_dropped`); deny wins
|
- Exact overlap: `allow \ deny` (`conflicts_dropped`); deny wins
|
||||||
- Overrides, смена наборов, `default_action` и refresh DNS/lists бампят `policy_generation`
|
- Overrides, смена наборов, `default_action`, Port ACL и refresh DNS/lists бампят `policy_generation`
|
||||||
|
|
||||||
## Auth
|
## Auth
|
||||||
|
|
||||||
- Portal SSO app id **`fw`**, permissions `fw:*`
|
- Portal SSO app id **`fw`**, permissions `fw:*`
|
||||||
- Agent bearer token (sha256 hash в БД)
|
- Agent bearer token (sha256 hash в БД)
|
||||||
|
|
||||||
|
## Деплой
|
||||||
|
|
||||||
|
Один alpine-контейнер (`evofw` / алиас `evofirewall`): Fastify отдаёт API, SPA и install-ссылки `GET /{slug}`. Сборка — `docker buildx bake` из [`deploy/docker`](../deploy/docker/README.md). Релизы: [`releasing.md`](releasing.md).
|
||||||
+401
-2
@@ -373,6 +373,245 @@ paths:
|
|||||||
'200':
|
'200':
|
||||||
description: Samples
|
description: Samples
|
||||||
|
|
||||||
|
/api/v1/agents/{id}/stats:
|
||||||
|
get:
|
||||||
|
summary: Apply samples for one agent
|
||||||
|
tags: [ops]
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/Id'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Samples
|
||||||
|
|
||||||
|
/api/v1/agents/{id}/stats/reset:
|
||||||
|
post:
|
||||||
|
summary: Reset packet totals, samples, and per-IP block stats
|
||||||
|
tags: [ops]
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/Id'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Agent after reset
|
||||||
|
|
||||||
|
/api/v1/agents/{id}/blocked-ips:
|
||||||
|
get:
|
||||||
|
summary: Per-IP/CIDR drop counters (Linux nft/ipset; MikroTik HITS)
|
||||||
|
tags: [ops]
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/Id'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Top blocked IPs by accumulated packets (+ optional top ports per IP on Linux)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
required: [ip, packets, first_seen_at, last_seen_at]
|
||||||
|
properties:
|
||||||
|
ip: { type: string }
|
||||||
|
packets: { type: integer }
|
||||||
|
first_seen_at: { type: string, format: date-time }
|
||||||
|
last_seen_at: { type: string, format: date-time }
|
||||||
|
ports:
|
||||||
|
type: array
|
||||||
|
description: Top destination ports for this IP (Linux nft)
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
required: [port, protocol, packets]
|
||||||
|
properties:
|
||||||
|
port: { type: integer, minimum: 1, maximum: 65535 }
|
||||||
|
protocol: { type: string, enum: [tcp, udp] }
|
||||||
|
packets: { type: integer }
|
||||||
|
|
||||||
|
/api/v1/agents/{id}/blocked-ports:
|
||||||
|
get:
|
||||||
|
summary: Aggregate destination ports hit by denied sources (Linux nft)
|
||||||
|
tags: [ops]
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/Id'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Top ports by accumulated packets across all blocked IPs
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
required: [port, protocol, packets, last_seen_at]
|
||||||
|
properties:
|
||||||
|
port: { type: integer, minimum: 1, maximum: 65535 }
|
||||||
|
protocol: { type: string, enum: [tcp, udp] }
|
||||||
|
packets: { type: integer }
|
||||||
|
last_seen_at: { type: string, format: date-time }
|
||||||
|
|
||||||
|
/api/v1/agents/{id}/port-rules:
|
||||||
|
get:
|
||||||
|
summary: Desired Port ACL rules (Linux)
|
||||||
|
tags: [ops]
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/Id'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Port ACL rules
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/AgentPortRule'
|
||||||
|
post:
|
||||||
|
summary: Create Port ACL rule
|
||||||
|
tags: [ops]
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/Id'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/CreateAgentPortRule'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Created rule
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/AgentPortRule'
|
||||||
|
'400':
|
||||||
|
description: Validation / non-linux
|
||||||
|
|
||||||
|
/api/v1/agents/{id}/port-rules/import:
|
||||||
|
post:
|
||||||
|
summary: Import Port ACL from IP list or policy set sources
|
||||||
|
tags: [ops]
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/Id'
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [from, action, ports]
|
||||||
|
properties:
|
||||||
|
from: { type: string, enum: [list, set] }
|
||||||
|
list_id: { type: string }
|
||||||
|
set_id: { type: string }
|
||||||
|
action: { type: string, enum: [open, close] }
|
||||||
|
protocol: { type: string, enum: [tcp, udp, both], default: tcp }
|
||||||
|
ports:
|
||||||
|
type: array
|
||||||
|
minItems: 1
|
||||||
|
maxItems: 50
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
required: [port_start]
|
||||||
|
properties:
|
||||||
|
port_start: { type: integer, minimum: 1, maximum: 65535 }
|
||||||
|
port_end: { type: integer, minimum: 1, maximum: 65535 }
|
||||||
|
enabled: { type: boolean, default: true }
|
||||||
|
comment: { type: string, maxLength: 500 }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Created rules
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/AgentPortRule'
|
||||||
|
|
||||||
|
/api/v1/agents/{id}/port-rules/{ruleId}:
|
||||||
|
patch:
|
||||||
|
summary: Update Port ACL rule
|
||||||
|
tags: [ops]
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/Id'
|
||||||
|
- name: ruleId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string }
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/UpdateAgentPortRule'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Updated rule
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/AgentPortRule'
|
||||||
|
delete:
|
||||||
|
summary: Delete Port ACL rule
|
||||||
|
tags: [ops]
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/Id'
|
||||||
|
- name: ruleId
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema: { type: string }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Deleted
|
||||||
|
|
||||||
|
/api/v1/agents/{id}/host-firewall:
|
||||||
|
get:
|
||||||
|
summary: Last observed host firewall snapshot (Linux)
|
||||||
|
tags: [ops]
|
||||||
|
security: [{ bearerAuth: [] }]
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/components/parameters/Id'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Snapshot (empty if never reported)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
collected_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
nullable: true
|
||||||
|
raw_digest:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
rules:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/HostFwRule'
|
||||||
|
listeners:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/HostListener'
|
||||||
|
|
||||||
/api/v1/integrations/evobgp/communities:
|
/api/v1/integrations/evobgp/communities:
|
||||||
get:
|
get:
|
||||||
summary: Proxy EvoBGP communities
|
summary: Proxy EvoBGP communities
|
||||||
@@ -436,6 +675,28 @@ paths:
|
|||||||
'200':
|
'200':
|
||||||
description: Shell script
|
description: Shell script
|
||||||
|
|
||||||
|
/v1/agent/sync-script:
|
||||||
|
get:
|
||||||
|
summary: Linux sync agent (evofw-firewall.sh)
|
||||||
|
tags: [agent-public]
|
||||||
|
parameters:
|
||||||
|
- name: If-None-Match
|
||||||
|
in: header
|
||||||
|
schema: { type: string }
|
||||||
|
description: ETag from a previous GET (`"<sha256>"`)
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Shell script
|
||||||
|
headers:
|
||||||
|
ETag:
|
||||||
|
schema: { type: string }
|
||||||
|
description: '"<sha256 of file bytes>"'
|
||||||
|
X-Evofw-Script-Sha256:
|
||||||
|
schema: { type: string }
|
||||||
|
description: Hex sha256 of the script body
|
||||||
|
'304':
|
||||||
|
description: Script unchanged
|
||||||
|
|
||||||
/v1/agent/enroll:
|
/v1/agent/enroll:
|
||||||
post:
|
post:
|
||||||
summary: Enroll agent (X-EvoFW-Seed)
|
summary: Enroll agent (X-EvoFW-Seed)
|
||||||
@@ -451,7 +712,7 @@ paths:
|
|||||||
security: [{ agentToken: [] }]
|
security: [{ agentToken: [] }]
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Policy
|
description: Policy (includes script_sha256 for Linux self-update; not part of policy.hash)
|
||||||
|
|
||||||
/v1/agent/policy.rsc:
|
/v1/agent/policy.rsc:
|
||||||
get:
|
get:
|
||||||
@@ -473,9 +734,48 @@ paths:
|
|||||||
|
|
||||||
/v1/agent/apply-report:
|
/v1/agent/apply-report:
|
||||||
post:
|
post:
|
||||||
summary: Apply report + packet stats
|
summary: Apply report + packet stats (+ optional ip_hits / port_hits / host_firewall)
|
||||||
tags: [agent]
|
tags: [agent]
|
||||||
security: [{ agentToken: [] }]
|
security: [{ agentToken: [] }]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [status]
|
||||||
|
properties:
|
||||||
|
status: { type: string }
|
||||||
|
prefix_count: { type: integer }
|
||||||
|
packets_dropped: { type: integer }
|
||||||
|
packets_accepted: { type: integer }
|
||||||
|
kernel_method: { type: string }
|
||||||
|
error: { type: string }
|
||||||
|
source: { type: string }
|
||||||
|
ip_hits:
|
||||||
|
type: array
|
||||||
|
maxItems: 200
|
||||||
|
description: Linux per-element drop counters (packets > 0)
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
required: [ip, packets]
|
||||||
|
properties:
|
||||||
|
ip: { type: string, maxLength: 64 }
|
||||||
|
packets: { type: integer, minimum: 0 }
|
||||||
|
port_hits:
|
||||||
|
type: array
|
||||||
|
maxItems: 500
|
||||||
|
description: Linux nft deny_port_hits (ip × proto × dport, packets > 0)
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
required: [ip, port, protocol, packets]
|
||||||
|
properties:
|
||||||
|
ip: { type: string, maxLength: 64 }
|
||||||
|
port: { type: integer, minimum: 1, maximum: 65535 }
|
||||||
|
protocol: { type: string, enum: [tcp, udp] }
|
||||||
|
packets: { type: integer, minimum: 0 }
|
||||||
|
host_firewall:
|
||||||
|
$ref: '#/components/schemas/HostFirewallPayload'
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: OK
|
description: OK
|
||||||
@@ -487,6 +787,105 @@ components:
|
|||||||
in: path
|
in: path
|
||||||
required: true
|
required: true
|
||||||
schema: { type: string }
|
schema: { type: string }
|
||||||
|
schemas:
|
||||||
|
AgentPortRule:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
agent_id,
|
||||||
|
action,
|
||||||
|
protocol,
|
||||||
|
port_start,
|
||||||
|
port_end,
|
||||||
|
src_kind,
|
||||||
|
enabled,
|
||||||
|
priority,
|
||||||
|
created_at,
|
||||||
|
updated_at,
|
||||||
|
]
|
||||||
|
properties:
|
||||||
|
id: { type: string }
|
||||||
|
agent_id: { type: string }
|
||||||
|
action: { type: string, enum: [open, close] }
|
||||||
|
protocol: { type: string, enum: [tcp, udp, both] }
|
||||||
|
port_start: { type: integer, minimum: 1, maximum: 65535 }
|
||||||
|
port_end: { type: integer, minimum: 1, maximum: 65535 }
|
||||||
|
src_kind: { type: string, enum: [all, cidr, list] }
|
||||||
|
src_cidr: { type: string, nullable: true }
|
||||||
|
list_id: { type: string, nullable: true }
|
||||||
|
list_name: { type: string, nullable: true }
|
||||||
|
enabled: { type: boolean }
|
||||||
|
comment: { type: string, nullable: true }
|
||||||
|
priority: { type: integer }
|
||||||
|
created_at: { type: string, format: date-time }
|
||||||
|
updated_at: { type: string, format: date-time }
|
||||||
|
CreateAgentPortRule:
|
||||||
|
type: object
|
||||||
|
required: [action, port_start]
|
||||||
|
properties:
|
||||||
|
action: { type: string, enum: [open, close] }
|
||||||
|
protocol: { type: string, enum: [tcp, udp, both], default: tcp }
|
||||||
|
port_start: { type: integer, minimum: 1, maximum: 65535 }
|
||||||
|
port_end: { type: integer, minimum: 1, maximum: 65535 }
|
||||||
|
src_kind: { type: string, enum: [all, cidr, list], default: all }
|
||||||
|
src_cidr: { type: string }
|
||||||
|
list_id: { type: string }
|
||||||
|
enabled: { type: boolean, default: true }
|
||||||
|
comment: { type: string, maxLength: 500 }
|
||||||
|
priority: { type: integer, default: 100 }
|
||||||
|
UpdateAgentPortRule:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
action: { type: string, enum: [open, close] }
|
||||||
|
protocol: { type: string, enum: [tcp, udp, both] }
|
||||||
|
port_start: { type: integer, minimum: 1, maximum: 65535 }
|
||||||
|
port_end: { type: integer, minimum: 1, maximum: 65535 }
|
||||||
|
src_kind: { type: string, enum: [all, cidr, list] }
|
||||||
|
src_cidr: { type: string, nullable: true }
|
||||||
|
list_id: { type: string, nullable: true }
|
||||||
|
enabled: { type: boolean }
|
||||||
|
comment: { type: string, maxLength: 500, nullable: true }
|
||||||
|
priority: { type: integer }
|
||||||
|
HostFwRule:
|
||||||
|
type: object
|
||||||
|
required: [ownership, backend, raw]
|
||||||
|
properties:
|
||||||
|
ownership: { type: string, enum: [evofw, foreign] }
|
||||||
|
backend:
|
||||||
|
type: string
|
||||||
|
enum: [nft, iptables, ufw, firewalld, listener]
|
||||||
|
table: { type: string }
|
||||||
|
chain: { type: string }
|
||||||
|
action: { type: string }
|
||||||
|
protocol: { type: string }
|
||||||
|
dport: { type: string }
|
||||||
|
sport: { type: string }
|
||||||
|
saddr: { type: string }
|
||||||
|
daddr: { type: string }
|
||||||
|
comment: { type: string }
|
||||||
|
raw: { type: string, maxLength: 512 }
|
||||||
|
HostListener:
|
||||||
|
type: object
|
||||||
|
required: [protocol, port, address]
|
||||||
|
properties:
|
||||||
|
protocol: { type: string }
|
||||||
|
port: { type: integer, minimum: 0, maximum: 65535 }
|
||||||
|
address: { type: string }
|
||||||
|
process: { type: string }
|
||||||
|
HostFirewallPayload:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
rules:
|
||||||
|
type: array
|
||||||
|
maxItems: 500
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/HostFwRule'
|
||||||
|
listeners:
|
||||||
|
type: array
|
||||||
|
maxItems: 200
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/HostListener'
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
bearerAuth:
|
bearerAuth:
|
||||||
type: http
|
type: http
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Релизы и версионирование EvoFirewall
|
||||||
|
|
||||||
|
EvoFirewall использует [Conventional Commits](https://www.conventionalcommits.org/) и [semantic-release](https://semantic-release.gitbook.io/) для полностью автоматических релизов на Gitea (`git.shx.one`). Ручное повышение версии в коде не требуется.
|
||||||
|
|
||||||
|
Заголовок после `тип(scope):` — **на русском** (см. `.cursor/rules/commit-messages-ru.mdc`). Analyzer смотрит только префикс `feat` / `fix` / …
|
||||||
|
|
||||||
|
## Как определяется версия
|
||||||
|
|
||||||
|
| Тип коммита | Bump |
|
||||||
|
|-------------|------|
|
||||||
|
| `feat` | minor (1.0.0 → 1.1.0) |
|
||||||
|
| `fix`, `perf`, `ci`, `refactor` | patch (1.5.1 → 1.5.2) |
|
||||||
|
| `feat!`, `fix!` или `BREAKING CHANGE:` в теле | major (1.0.0 → 2.0.0) |
|
||||||
|
| `docs`, `chore`, `test` | без релиза |
|
||||||
|
|
||||||
|
**Scope:** один идентификатор **без запятых** (`web`, `api`, `ci`). Заголовок `refactor(a, b): …` **не парсится** semantic-release → релиз не создаётся (commitlint на PR это тоже отклонит).
|
||||||
|
|
||||||
|
`refactor` — patch без новых функций: перестройка кода/UI при том же поведении для пользователя.
|
||||||
|
|
||||||
|
Первый релиз при отсутствии git-тегов — **1.0.0**, если есть releasable-коммиты.
|
||||||
|
|
||||||
|
## CI-пайплайн (push в main)
|
||||||
|
|
||||||
|
```text
|
||||||
|
push/merge в main
|
||||||
|
→ workflow CD: quality (openapi, web, api)
|
||||||
|
→ job publish:
|
||||||
|
→ semantic-release: git tag vX.Y.Z на текущий commit (без доп. commit)
|
||||||
|
→ Gitea Release + CHANGELOG.md как attachment
|
||||||
|
→ зеркало base-образов в evofw-buildcache:base-*
|
||||||
|
→ docker buildx bake с VERSION=X.Y.Z (pull=false, named builder evofw)
|
||||||
|
→ образы: latest, vX.Y.Z, X.Y.Z, sha-*, короткий SHA
|
||||||
|
```
|
||||||
|
|
||||||
|
Pull request: workflow **CI** — quality gates + commitlint; релиз и образы **не** публикуются.
|
||||||
|
|
||||||
|
Workflows: [.gitea/workflows/ci.yaml](../.gitea/workflows/ci.yaml), [.gitea/workflows/cd.yaml](../.gitea/workflows/cd.yaml), reusable [.gitea/workflows/quality.yaml](../.gitea/workflows/quality.yaml).
|
||||||
|
|
||||||
|
Конфиг semantic-release: [.releaserc.json](../.releaserc.json) — без `@semantic-release/git` (CHANGELOG не коммитится в репозиторий).
|
||||||
|
|
||||||
|
## Секреты Gitea
|
||||||
|
|
||||||
|
Один PAT — **`ACTIONS_PAT`** (Settings → Actions → Secrets).
|
||||||
|
|
||||||
|
| Право PAT | Зачем |
|
||||||
|
|-----------|--------|
|
||||||
|
| push tags | git tag `vX.Y.Z` на commit merge |
|
||||||
|
| releases | Gitea Release + notes |
|
||||||
|
| packages (Container Registry) | push образов |
|
||||||
|
|
||||||
|
Fallback для **git tag**: `github.token`, если PAT недоступен. Push образов в Container Registry — **только `ACTIONS_PAT`** (у job token Gitea нет права packages).
|
||||||
|
|
||||||
|
## Источник правды для версии
|
||||||
|
|
||||||
|
Semver из git-тега пробрасывается в образ как `APP_VERSION` / `GIT_SHA` / `BUILD_TIME` при bake (`deploy/docker/evofw/Dockerfile`).
|
||||||
|
|
||||||
|
## Scope и semantic-release
|
||||||
|
|
||||||
|
Парсер conventional-commits **не понимает запятые в scope**. Правило: **один scope** (`web`, `api`, `ci`, `docker`).
|
||||||
|
|
||||||
|
На push в `main` job **publish** запускает `scripts/commit/verify-release-commits.mjs` — в логе будут предупреждения о непарсящихся коммитах.
|
||||||
|
|
||||||
|
Если релиз «не создался», а CI зелёный: смотрите лог release — часто `No releasable commits`. Исправление: новый коммит с корректным заголовком (например `fix(web): …`).
|
||||||
|
|
||||||
|
## Перезапуск упавшего job publish
|
||||||
|
|
||||||
|
semantic-release пишет `.release-version` только в `successCmd` при **новом** релизе. Если тег `vX.Y.Z` уже создан, а `docker buildx bake` упал, повторный run того же SHA делает semantic-release no-op (файла нет). Job **publish** тогда берёт версию из git-тега на `HEAD` и публикует образы.
|
||||||
|
|
||||||
|
Перезапускать нужно **весь job publish**, не отдельный шаг bake: checkout + semantic-release + detect + bake идут подряд.
|
||||||
|
|
||||||
|
## CHANGELOG
|
||||||
|
|
||||||
|
Release notes — в Gitea Release; файл `CHANGELOG.md` генерируется в CI и прикрепляется как asset, **не** попадает в git history.
|
||||||
|
|
||||||
|
## Проверка после релиза
|
||||||
|
|
||||||
|
1. Один run workflow **CD** на push в main: job **publish** зелёный.
|
||||||
|
2. Gitea: тег `vX.Y.Z` на том же commit, что и merge; Release с notes.
|
||||||
|
3. Container Registry: `evofw:vX.Y.Z`, `evofw:X.Y.Z`, `evofw:latest` и алиас `evofirewall` с теми же тегами.
|
||||||
|
4. `curl http://localhost:8080/health` → `"status":"ok"`.
|
||||||
|
|
||||||
|
## Первый релиз (bootstrap)
|
||||||
|
|
||||||
|
Merge в `main` с `feat(release): …` → **v1.0.0** в том же CI run.
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
Surface: **ReUI Frame**. Kit: `apps/web/src/components/reui-kit/`.
|
Surface: **ReUI Frame**. Kit: `apps/web/src/components/reui-kit/`.
|
||||||
Иерархия: **ReUI PRO > shadcn primitives**.
|
Иерархия: **ReUI PRO > shadcn primitives**.
|
||||||
|
|
||||||
Карта: [llms.txt](https://reui.io/llms.txt) · [Styling](https://reui.io/docs/styling) · [License](https://reui.io/docs/license-setup) · [Blocks](https://reui.io/blocks) · [MCP](https://reui.io/docs/mcp)
|
Карта: [docs](https://reui.io/docs) · [llms.txt](https://reui.io/llms.txt) · [Get Started](https://reui.io/docs/get-started) · [Styling](https://reui.io/docs/styling) · [Registry](https://reui.io/docs/registry) · [License](https://reui.io/docs/license-setup) · [Blocks](https://reui.io/blocks) · [MCP](https://reui.io/docs/mcp) · [Agent Skills](https://reui.io/docs/agent-skills)
|
||||||
|
|
||||||
## Surface
|
## Surface
|
||||||
|
|
||||||
@@ -28,7 +28,7 @@ Settings: секции через Frame + `gap` (без hairline `Separator` п
|
|||||||
| Lists | `data-grid-filtering-2` | https://reui.io/preview/base/data-grid-filtering-2 |
|
| Lists | `data-grid-filtering-2` | https://reui.io/preview/base/data-grid-filtering-2 |
|
||||||
| Settings | `settings-16` + SettingRow | https://reui.io/preview/base/settings-16 |
|
| Settings | `settings-16` + SettingRow | https://reui.io/preview/base/settings-16 |
|
||||||
| Auth | `auth-13` | https://reui.io/preview/base/auth-13 |
|
| Auth | `auth-13` | https://reui.io/preview/base/auth-13 |
|
||||||
| Empty | `empty-state-12` | https://reui.io/preview/base/empty-state-12 |
|
| Empty | `empty-state-14` (prefer) / `empty-state-12` | https://reui.io/preview/base/empty-state-14 · https://reui.io/preview/base/empty-state-12 |
|
||||||
| Forms | `form-7` → Sheet/Drawer | https://reui.io/preview/base/form-7 |
|
| Forms | `form-7` → Sheet/Drawer | https://reui.io/preview/base/form-7 |
|
||||||
|
|
||||||
## Kit API (`reui-kit/`)
|
## Kit API (`reui-kit/`)
|
||||||
@@ -39,7 +39,7 @@ Settings: секции через Frame + `gap` (без hairline `Separator` п
|
|||||||
| `KpiStatGrid` | EvoBGP hybrid KPI tiles (`items`/`cards`, `variant`, Badge) |
|
| `KpiStatGrid` | EvoBGP hybrid KPI tiles (`items`/`cards`, `variant`, Badge) |
|
||||||
| `QuickActionGrid` | KPI-like quick action tiles (gated by `showQuickActions`) |
|
| `QuickActionGrid` | KPI-like quick action tiles (gated by `showQuickActions`) |
|
||||||
| `OpsDashboard` | KPI + optional `afterKpi` + charts + attention queue |
|
| `OpsDashboard` | KPI + optional `afterKpi` + charts + attention queue |
|
||||||
| `SettingsShell` | settings nav + Outlet |
|
| `SettingsShell` | settings nav + Outlet (**только 2+ секции**; текущий `/settings` — PageShell + Frame) |
|
||||||
| `DetailPanel` | detail Frame sections |
|
| `DetailPanel` | detail Frame sections |
|
||||||
| `filter-utils` | apply/clear ReUI Filters |
|
| `filter-utils` | apply/clear ReUI Filters |
|
||||||
|
|
||||||
@@ -75,9 +75,9 @@ Gating: DB `show_quick_actions` / `showQuickActions` / `ui_show_quick_actions` (
|
|||||||
|
|
||||||
NavUser (footer): avatar + name/email; dropdown — Настройки / Тема (segmented) / Выйти. Preview: [app-shell-1](https://reui.io/preview/base/app-shell-1).
|
NavUser (footer): avatar + name/email; dropdown — Настройки / Тема (segmented) / Выйти. Preview: [app-shell-1](https://reui.io/preview/base/app-shell-1).
|
||||||
|
|
||||||
App Switcher: auth-portal `GET /api/v1/app-switcher`. Ids: `cfdm` · `vps` · `bgp` · `fw`. Admin: portal `/admin/apps`.
|
App Switcher: auth-portal `GET /api/v1/app-switcher`. Ids: `cfdm` · `vps` · `bgp` · `fw` · `dns`. Admin: portal `/admin/apps`.
|
||||||
|
|
||||||
QuickActionGrid / KPI icons: только semantic **text** (`text-info` / `text-primary` / …) на kit `bg-muted` — без solid fills.
|
QuickActionGrid / KPI icons: только semantic **text** (`text-info` / `text-primary` / …) на `IconTile` `elevated` — без solid fills.
|
||||||
|
|
||||||
## System monitor
|
## System monitor
|
||||||
|
|
||||||
|
|||||||
+11
-1
@@ -7,9 +7,19 @@
|
|||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"test": "turbo run test",
|
"test": "turbo run test",
|
||||||
"lint": "turbo run lint",
|
"lint": "turbo run lint",
|
||||||
"openapi:lint": "npx --yes @redocly/cli lint docs/openapi.yaml"
|
"openapi:lint": "redocly lint docs/openapi.yaml"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@commitlint/cli": "^19.8.1",
|
||||||
|
"@commitlint/config-conventional": "^19.8.1",
|
||||||
|
"@markwylde/semantic-release-gitea": "^2.2.0",
|
||||||
|
"@redocly/cli": "1.34.5",
|
||||||
|
"@semantic-release/changelog": "^6.0.3",
|
||||||
|
"@semantic-release/commit-analyzer": "^13.0.1",
|
||||||
|
"@semantic-release/exec": "^7.0.0",
|
||||||
|
"@semantic-release/release-notes-generator": "^14.0.3",
|
||||||
|
"conventional-commits-parser": "^6.4.0",
|
||||||
|
"semantic-release": "^25.0.2",
|
||||||
"turbo": "^2.5.4"
|
"turbo": "^2.5.4"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.12.1",
|
"packageManager": "pnpm@10.12.1",
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Per-IP/CIDR drop counters from Linux agent nft/ipset element counters.
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_ip_block_stats (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||||
|
ip TEXT NOT NULL,
|
||||||
|
packets INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_reported_packets INTEGER NOT NULL DEFAULT 0,
|
||||||
|
first_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
last_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_ip_block_stats_agent_ip
|
||||||
|
ON agent_ip_block_stats(agent_id, ip);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_ip_block_stats_agent_packets
|
||||||
|
ON agent_ip_block_stats(agent_id, packets);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- Per-(ip, protocol, port) deny drop counters from Linux nft dynamic set.
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_port_block_stats (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||||
|
ip TEXT NOT NULL,
|
||||||
|
port INTEGER NOT NULL,
|
||||||
|
protocol TEXT NOT NULL,
|
||||||
|
packets INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_reported_packets INTEGER NOT NULL DEFAULT 0,
|
||||||
|
first_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
last_seen_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_port_block_stats_agent_ip_port_proto
|
||||||
|
ON agent_port_block_stats(agent_id, ip, port, protocol);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_port_block_stats_agent_packets
|
||||||
|
ON agent_port_block_stats(agent_id, packets);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_port_block_stats_agent_port_proto
|
||||||
|
ON agent_port_block_stats(agent_id, port, protocol);
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
-- Per-agent L4 port ACL (desired state) + host firewall snapshot (observed).
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_port_rules (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
protocol TEXT NOT NULL,
|
||||||
|
port_start INTEGER NOT NULL,
|
||||||
|
port_end INTEGER NOT NULL,
|
||||||
|
src_kind TEXT NOT NULL,
|
||||||
|
src_cidr TEXT,
|
||||||
|
list_id TEXT REFERENCES ip_lists(id) ON DELETE SET NULL,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
comment TEXT,
|
||||||
|
priority INTEGER NOT NULL DEFAULT 100,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_port_rules_agent_priority
|
||||||
|
ON agent_port_rules(agent_id, priority);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_agent_port_rules_agent_enabled
|
||||||
|
ON agent_port_rules(agent_id, enabled);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_host_firewall_snapshots (
|
||||||
|
agent_id TEXT PRIMARY KEY NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
|
||||||
|
collected_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
payload_json TEXT NOT NULL,
|
||||||
|
raw_digest TEXT
|
||||||
|
);
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
|
"development": "./src/index.ts",
|
||||||
"import": "./dist/index.js"
|
"import": "./dist/index.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -57,7 +57,37 @@ export {
|
|||||||
listStatsSamples,
|
listStatsSamples,
|
||||||
listRecentStats,
|
listRecentStats,
|
||||||
deleteStatsSamplesForAgent,
|
deleteStatsSamplesForAgent,
|
||||||
|
upsertIpBlockStats,
|
||||||
|
listIpBlockStats,
|
||||||
|
deleteIpBlockStatsForAgent,
|
||||||
|
resetIpBlockStatsBaselines,
|
||||||
|
upsertPortBlockStats,
|
||||||
|
listPortBlockStatsAggregate,
|
||||||
|
listPortBlockStatsForIps,
|
||||||
|
mapTopPortsByIp,
|
||||||
|
deletePortBlockStatsForAgent,
|
||||||
|
resetPortBlockStatsBaselines,
|
||||||
|
PRESENCE_REHIT_STALE_MS,
|
||||||
} from './stats.js'
|
} from './stats.js'
|
||||||
|
export type {
|
||||||
|
UpsertIpBlockStatsOptions,
|
||||||
|
IpHitInput,
|
||||||
|
PortHitInput,
|
||||||
|
PortBlockAggregateRow,
|
||||||
|
PortBlockPerIpRow,
|
||||||
|
} from './stats.js'
|
||||||
|
|
||||||
|
export {
|
||||||
|
listAgentPortRules,
|
||||||
|
listEnabledAgentPortRules,
|
||||||
|
getAgentPortRule,
|
||||||
|
insertAgentPortRule,
|
||||||
|
updateAgentPortRule,
|
||||||
|
deleteAgentPortRule,
|
||||||
|
upsertHostFirewallSnapshot,
|
||||||
|
getHostFirewallSnapshot,
|
||||||
|
} from './port-acl.js'
|
||||||
|
export type { AgentPortRuleRow, AgentPortRuleInsert } from './port-acl.js'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
getSetting,
|
getSetting,
|
||||||
@@ -132,7 +162,27 @@ import {
|
|||||||
listStatsSamples,
|
listStatsSamples,
|
||||||
listRecentStats,
|
listRecentStats,
|
||||||
deleteStatsSamplesForAgent,
|
deleteStatsSamplesForAgent,
|
||||||
|
upsertIpBlockStats,
|
||||||
|
listIpBlockStats,
|
||||||
|
deleteIpBlockStatsForAgent,
|
||||||
|
resetIpBlockStatsBaselines,
|
||||||
|
upsertPortBlockStats,
|
||||||
|
listPortBlockStatsAggregate,
|
||||||
|
listPortBlockStatsForIps,
|
||||||
|
mapTopPortsByIp,
|
||||||
|
deletePortBlockStatsForAgent,
|
||||||
|
resetPortBlockStatsBaselines,
|
||||||
} from './stats.js'
|
} from './stats.js'
|
||||||
|
import {
|
||||||
|
listAgentPortRules,
|
||||||
|
listEnabledAgentPortRules,
|
||||||
|
getAgentPortRule,
|
||||||
|
insertAgentPortRule,
|
||||||
|
updateAgentPortRule,
|
||||||
|
deleteAgentPortRule,
|
||||||
|
upsertHostFirewallSnapshot,
|
||||||
|
getHostFirewallSnapshot,
|
||||||
|
} from './port-acl.js'
|
||||||
import {
|
import {
|
||||||
getSetting,
|
getSetting,
|
||||||
setSetting,
|
setSetting,
|
||||||
@@ -198,6 +248,24 @@ export const repos = {
|
|||||||
listStatsSamples,
|
listStatsSamples,
|
||||||
listRecentStats,
|
listRecentStats,
|
||||||
deleteStatsSamplesForAgent,
|
deleteStatsSamplesForAgent,
|
||||||
|
upsertIpBlockStats,
|
||||||
|
listIpBlockStats,
|
||||||
|
deleteIpBlockStatsForAgent,
|
||||||
|
resetIpBlockStatsBaselines,
|
||||||
|
upsertPortBlockStats,
|
||||||
|
listPortBlockStatsAggregate,
|
||||||
|
listPortBlockStatsForIps,
|
||||||
|
mapTopPortsByIp,
|
||||||
|
deletePortBlockStatsForAgent,
|
||||||
|
resetPortBlockStatsBaselines,
|
||||||
|
listAgentPortRules,
|
||||||
|
listEnabledAgentPortRules,
|
||||||
|
getAgentPortRule,
|
||||||
|
insertAgentPortRule,
|
||||||
|
updateAgentPortRule,
|
||||||
|
deleteAgentPortRule,
|
||||||
|
upsertHostFirewallSnapshot,
|
||||||
|
getHostFirewallSnapshot,
|
||||||
getSetting,
|
getSetting,
|
||||||
setSetting,
|
setSetting,
|
||||||
listSettings,
|
listSettings,
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { and, asc, eq } from 'drizzle-orm'
|
||||||
|
import type { Db } from '../client.js'
|
||||||
|
import { agentHostFirewallSnapshots, agentPortRules } from '../schema.js'
|
||||||
|
|
||||||
|
export type AgentPortRuleRow = typeof agentPortRules.$inferSelect
|
||||||
|
export type AgentPortRuleInsert = typeof agentPortRules.$inferInsert
|
||||||
|
|
||||||
|
export function listAgentPortRules(db: Db, agentId: string) {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(agentPortRules)
|
||||||
|
.where(eq(agentPortRules.agentId, agentId))
|
||||||
|
.orderBy(asc(agentPortRules.priority), asc(agentPortRules.createdAt))
|
||||||
|
.all()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listEnabledAgentPortRules(db: Db, agentId: string) {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(agentPortRules)
|
||||||
|
.where(
|
||||||
|
and(eq(agentPortRules.agentId, agentId), eq(agentPortRules.enabled, 1)),
|
||||||
|
)
|
||||||
|
.orderBy(asc(agentPortRules.priority), asc(agentPortRules.createdAt))
|
||||||
|
.all()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAgentPortRule(db: Db, id: string) {
|
||||||
|
return db.select().from(agentPortRules).where(eq(agentPortRules.id, id)).get()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertAgentPortRule(db: Db, row: AgentPortRuleInsert) {
|
||||||
|
db.insert(agentPortRules).values(row).run()
|
||||||
|
return getAgentPortRule(db, row.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateAgentPortRule(
|
||||||
|
db: Db,
|
||||||
|
id: string,
|
||||||
|
patch: Partial<
|
||||||
|
Omit<AgentPortRuleInsert, 'id' | 'agentId' | 'createdAt'>
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
db.update(agentPortRules)
|
||||||
|
.set({
|
||||||
|
...patch,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.where(eq(agentPortRules.id, id))
|
||||||
|
.run()
|
||||||
|
return getAgentPortRule(db, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteAgentPortRule(db: Db, id: string) {
|
||||||
|
db.delete(agentPortRules).where(eq(agentPortRules.id, id)).run()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function upsertHostFirewallSnapshot(
|
||||||
|
db: Db,
|
||||||
|
agentId: string,
|
||||||
|
payloadJson: string,
|
||||||
|
collectedAt = new Date().toISOString(),
|
||||||
|
rawDigest: string | null = null,
|
||||||
|
) {
|
||||||
|
const existing = db
|
||||||
|
.select()
|
||||||
|
.from(agentHostFirewallSnapshots)
|
||||||
|
.where(eq(agentHostFirewallSnapshots.agentId, agentId))
|
||||||
|
.get()
|
||||||
|
if (existing) {
|
||||||
|
db.update(agentHostFirewallSnapshots)
|
||||||
|
.set({ payloadJson, collectedAt, rawDigest })
|
||||||
|
.where(eq(agentHostFirewallSnapshots.agentId, agentId))
|
||||||
|
.run()
|
||||||
|
} else {
|
||||||
|
db.insert(agentHostFirewallSnapshots)
|
||||||
|
.values({ agentId, payloadJson, collectedAt, rawDigest })
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(agentHostFirewallSnapshots)
|
||||||
|
.where(eq(agentHostFirewallSnapshots.agentId, agentId))
|
||||||
|
.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHostFirewallSnapshot(db: Db, agentId: string) {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(agentHostFirewallSnapshots)
|
||||||
|
.where(eq(agentHostFirewallSnapshots.agentId, agentId))
|
||||||
|
.get()
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { eq, desc } from 'drizzle-orm'
|
import { and, eq, desc, sql, inArray } from 'drizzle-orm'
|
||||||
import type { Db } from '../client.js'
|
import type { Db } from '../client.js'
|
||||||
import { agentStatsSamples } from '../schema.js'
|
import {
|
||||||
|
agentIpBlockStats,
|
||||||
|
agentPortBlockStats,
|
||||||
|
agentStatsSamples,
|
||||||
|
} from '../schema.js'
|
||||||
|
|
||||||
export function insertStatsSample(
|
export function insertStatsSample(
|
||||||
db: Db,
|
db: Db,
|
||||||
@@ -33,3 +37,308 @@ export function deleteStatsSamplesForAgent(db: Db, agentId: string) {
|
|||||||
.where(eq(agentStatsSamples.agentId, agentId))
|
.where(eq(agentStatsSamples.agentId, agentId))
|
||||||
.run()
|
.run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type IpHitInput = { ip: string; packets: number }
|
||||||
|
|
||||||
|
/** Gap after which a new presence report counts as a re-hit (left EVOFW_HITS). */
|
||||||
|
export const PRESENCE_REHIT_STALE_MS = 150_000
|
||||||
|
|
||||||
|
export type UpsertIpBlockStatsOptions = {
|
||||||
|
/**
|
||||||
|
* MikroTik EVOFW_HITS presence: refresh last_seen every report while IP is listed;
|
||||||
|
* increment packets only on first see or re-hit after stale gap (left the list).
|
||||||
|
* Linux keeps absolute counter deltas (default).
|
||||||
|
*/
|
||||||
|
mode?: 'absolute' | 'presence'
|
||||||
|
/** Override re-hit gap (tests). Default PRESENCE_REHIT_STALE_MS. */
|
||||||
|
presenceStaleMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert per-IP drop counters.
|
||||||
|
* - absolute (Linux): agent reports kernel counters; CP accumulates deltas.
|
||||||
|
* - presence (MikroTik): last_seen while in HITS; packets = entries into HITS (not per-sync).
|
||||||
|
*/
|
||||||
|
export function upsertIpBlockStats(
|
||||||
|
db: Db,
|
||||||
|
agentId: string,
|
||||||
|
hits: IpHitInput[],
|
||||||
|
now = new Date().toISOString(),
|
||||||
|
opts: UpsertIpBlockStatsOptions = {},
|
||||||
|
) {
|
||||||
|
const mode = opts.mode ?? 'absolute'
|
||||||
|
const staleMs = opts.presenceStaleMs ?? PRESENCE_REHIT_STALE_MS
|
||||||
|
const nowMs = Date.parse(now)
|
||||||
|
|
||||||
|
for (const hit of hits) {
|
||||||
|
const ip = hit.ip.trim()
|
||||||
|
if (!ip) continue
|
||||||
|
const reported = Math.max(0, Math.floor(hit.packets))
|
||||||
|
const existing = db
|
||||||
|
.select()
|
||||||
|
.from(agentIpBlockStats)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(agentIpBlockStats.agentId, agentId),
|
||||||
|
eq(agentIpBlockStats.ip, ip),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get()
|
||||||
|
|
||||||
|
if (mode === 'presence') {
|
||||||
|
if (!existing) {
|
||||||
|
db.insert(agentIpBlockStats)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
agentId,
|
||||||
|
ip,
|
||||||
|
packets: 1,
|
||||||
|
lastReportedPackets: 1,
|
||||||
|
firstSeenAt: now,
|
||||||
|
lastSeenAt: now,
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const prevSeen = Date.parse(existing.lastSeenAt)
|
||||||
|
const gapMs = Number.isFinite(prevSeen) ? nowMs - prevSeen : staleMs + 1
|
||||||
|
const isRehit = gapMs > staleMs
|
||||||
|
db.update(agentIpBlockStats)
|
||||||
|
.set({
|
||||||
|
lastSeenAt: now,
|
||||||
|
...(isRehit
|
||||||
|
? {
|
||||||
|
packets: (existing.packets ?? 0) + 1,
|
||||||
|
lastReportedPackets: (existing.lastReportedPackets ?? 0) + 1,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
})
|
||||||
|
.where(eq(agentIpBlockStats.id, existing.id))
|
||||||
|
.run()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
db.insert(agentIpBlockStats)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
agentId,
|
||||||
|
ip,
|
||||||
|
packets: reported,
|
||||||
|
lastReportedPackets: reported,
|
||||||
|
firstSeenAt: now,
|
||||||
|
lastSeenAt: now,
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevReported = existing.lastReportedPackets ?? 0
|
||||||
|
const delta =
|
||||||
|
reported >= prevReported ? reported - prevReported : reported
|
||||||
|
const packets = (existing.packets ?? 0) + delta
|
||||||
|
db.update(agentIpBlockStats)
|
||||||
|
.set({
|
||||||
|
packets,
|
||||||
|
lastReportedPackets: reported,
|
||||||
|
...(delta > 0 ? { lastSeenAt: now } : {}),
|
||||||
|
})
|
||||||
|
.where(eq(agentIpBlockStats.id, existing.id))
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listIpBlockStats(db: Db, agentId: string, limit = 200) {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(agentIpBlockStats)
|
||||||
|
.where(eq(agentIpBlockStats.agentId, agentId))
|
||||||
|
.orderBy(desc(agentIpBlockStats.packets))
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteIpBlockStatsForAgent(db: Db, agentId: string) {
|
||||||
|
db.delete(agentIpBlockStats)
|
||||||
|
.where(eq(agentIpBlockStats.agentId, agentId))
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After nft/ipset counter flush (policy apply), kernel absolutes restart at 0.
|
||||||
|
* Traffic detects this via packets_dropped drop; per-IP baselines must reset too,
|
||||||
|
* otherwise ip_hits with packets=0 are omitted from the report and lastReported
|
||||||
|
* stays high → second epoch deltas are lost (Traffic 14 vs Blocked IPs 7).
|
||||||
|
*/
|
||||||
|
export function resetIpBlockStatsBaselines(db: Db, agentId: string) {
|
||||||
|
db.update(agentIpBlockStats)
|
||||||
|
.set({ lastReportedPackets: 0 })
|
||||||
|
.where(eq(agentIpBlockStats.agentId, agentId))
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PortHitInput = {
|
||||||
|
ip: string
|
||||||
|
port: number
|
||||||
|
protocol: 'tcp' | 'udp' | string
|
||||||
|
packets: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert per-(ip, proto, port) deny counters (Linux nft absolute deltas).
|
||||||
|
* deny_port_hits is not flushed on policy apply (timeout ages elements), so
|
||||||
|
* baselines are not auto-reset on Traffic flush — only on stats/reset delete.
|
||||||
|
*/
|
||||||
|
export function upsertPortBlockStats(
|
||||||
|
db: Db,
|
||||||
|
agentId: string,
|
||||||
|
hits: PortHitInput[],
|
||||||
|
now = new Date().toISOString(),
|
||||||
|
) {
|
||||||
|
for (const hit of hits) {
|
||||||
|
const ip = hit.ip.trim()
|
||||||
|
const protocol = String(hit.protocol).toLowerCase()
|
||||||
|
if (!ip || (protocol !== 'tcp' && protocol !== 'udp')) continue
|
||||||
|
const port = Math.floor(hit.port)
|
||||||
|
if (port < 1 || port > 65535) continue
|
||||||
|
const reported = Math.max(0, Math.floor(hit.packets))
|
||||||
|
const existing = db
|
||||||
|
.select()
|
||||||
|
.from(agentPortBlockStats)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(agentPortBlockStats.agentId, agentId),
|
||||||
|
eq(agentPortBlockStats.ip, ip),
|
||||||
|
eq(agentPortBlockStats.port, port),
|
||||||
|
eq(agentPortBlockStats.protocol, protocol),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.get()
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
db.insert(agentPortBlockStats)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
agentId,
|
||||||
|
ip,
|
||||||
|
port,
|
||||||
|
protocol,
|
||||||
|
packets: reported,
|
||||||
|
lastReportedPackets: reported,
|
||||||
|
firstSeenAt: now,
|
||||||
|
lastSeenAt: now,
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevReported = existing.lastReportedPackets ?? 0
|
||||||
|
const delta =
|
||||||
|
reported >= prevReported ? reported - prevReported : reported
|
||||||
|
const packets = (existing.packets ?? 0) + delta
|
||||||
|
db.update(agentPortBlockStats)
|
||||||
|
.set({
|
||||||
|
packets,
|
||||||
|
lastReportedPackets: reported,
|
||||||
|
...(delta > 0 ? { lastSeenAt: now } : {}),
|
||||||
|
})
|
||||||
|
.where(eq(agentPortBlockStats.id, existing.id))
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PortBlockAggregateRow = {
|
||||||
|
port: number
|
||||||
|
protocol: string
|
||||||
|
packets: number
|
||||||
|
lastSeenAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listPortBlockStatsAggregate(
|
||||||
|
db: Db,
|
||||||
|
agentId: string,
|
||||||
|
limit = 50,
|
||||||
|
): PortBlockAggregateRow[] {
|
||||||
|
const rows = db
|
||||||
|
.select({
|
||||||
|
port: agentPortBlockStats.port,
|
||||||
|
protocol: agentPortBlockStats.protocol,
|
||||||
|
packets: sql<number>`sum(${agentPortBlockStats.packets})`.mapWith(Number),
|
||||||
|
lastSeenAt: sql<string>`max(${agentPortBlockStats.lastSeenAt})`,
|
||||||
|
})
|
||||||
|
.from(agentPortBlockStats)
|
||||||
|
.where(eq(agentPortBlockStats.agentId, agentId))
|
||||||
|
.groupBy(agentPortBlockStats.port, agentPortBlockStats.protocol)
|
||||||
|
.orderBy(sql`sum(${agentPortBlockStats.packets}) desc`)
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
return rows.map((r) => ({
|
||||||
|
port: r.port,
|
||||||
|
protocol: r.protocol,
|
||||||
|
packets: r.packets ?? 0,
|
||||||
|
lastSeenAt: r.lastSeenAt,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PortBlockPerIpRow = {
|
||||||
|
ip: string
|
||||||
|
port: number
|
||||||
|
protocol: string
|
||||||
|
packets: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raw rows for a set of IPs (caller picks top-N per IP). */
|
||||||
|
export function listPortBlockStatsForIps(
|
||||||
|
db: Db,
|
||||||
|
agentId: string,
|
||||||
|
ips: string[],
|
||||||
|
): PortBlockPerIpRow[] {
|
||||||
|
if (ips.length === 0) return []
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
ip: agentPortBlockStats.ip,
|
||||||
|
port: agentPortBlockStats.port,
|
||||||
|
protocol: agentPortBlockStats.protocol,
|
||||||
|
packets: agentPortBlockStats.packets,
|
||||||
|
})
|
||||||
|
.from(agentPortBlockStats)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(agentPortBlockStats.agentId, agentId),
|
||||||
|
inArray(agentPortBlockStats.ip, ips),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(agentPortBlockStats.packets))
|
||||||
|
.all()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Top `perIpLimit` ports per IP, keyed by IP. */
|
||||||
|
export function mapTopPortsByIp(
|
||||||
|
db: Db,
|
||||||
|
agentId: string,
|
||||||
|
ips: string[],
|
||||||
|
perIpLimit = 5,
|
||||||
|
): Map<string, PortBlockPerIpRow[]> {
|
||||||
|
const all = listPortBlockStatsForIps(db, agentId, ips)
|
||||||
|
const map = new Map<string, PortBlockPerIpRow[]>()
|
||||||
|
for (const row of all) {
|
||||||
|
const list = map.get(row.ip) ?? []
|
||||||
|
if (list.length >= perIpLimit) continue
|
||||||
|
list.push(row)
|
||||||
|
map.set(row.ip, list)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deletePortBlockStatsForAgent(db: Db, agentId: string) {
|
||||||
|
db.delete(agentPortBlockStats)
|
||||||
|
.where(eq(agentPortBlockStats.agentId, agentId))
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetPortBlockStatsBaselines(db: Db, agentId: string) {
|
||||||
|
db.update(agentPortBlockStats)
|
||||||
|
.set({ lastReportedPackets: 0 })
|
||||||
|
.where(eq(agentPortBlockStats.agentId, agentId))
|
||||||
|
.run()
|
||||||
|
}
|
||||||
@@ -201,6 +201,123 @@ export const agentStatsSamples = sqliteTable(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Per-IP/CIDR drop counters reported by Linux agents (nft/ipset element counters). */
|
||||||
|
export const agentIpBlockStats = sqliteTable(
|
||||||
|
'agent_ip_block_stats',
|
||||||
|
{
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
agentId: text('agent_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => agents.id, { onDelete: 'cascade' }),
|
||||||
|
ip: text('ip').notNull(),
|
||||||
|
packets: integer('packets').notNull().default(0),
|
||||||
|
lastReportedPackets: integer('last_reported_packets').notNull().default(0),
|
||||||
|
firstSeenAt: text('first_seen_at')
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
|
||||||
|
lastSeenAt: text('last_seen_at')
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
agentIp: uniqueIndex('idx_agent_ip_block_stats_agent_ip').on(t.agentId, t.ip),
|
||||||
|
agentPackets: index('idx_agent_ip_block_stats_agent_packets').on(
|
||||||
|
t.agentId,
|
||||||
|
t.packets,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Per-(ip, proto, dport) deny hits from Linux nft dynamic concat set. */
|
||||||
|
export const agentPortBlockStats = sqliteTable(
|
||||||
|
'agent_port_block_stats',
|
||||||
|
{
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
agentId: text('agent_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => agents.id, { onDelete: 'cascade' }),
|
||||||
|
ip: text('ip').notNull(),
|
||||||
|
port: integer('port').notNull(),
|
||||||
|
protocol: text('protocol').notNull(), // tcp | udp
|
||||||
|
packets: integer('packets').notNull().default(0),
|
||||||
|
lastReportedPackets: integer('last_reported_packets').notNull().default(0),
|
||||||
|
firstSeenAt: text('first_seen_at')
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
|
||||||
|
lastSeenAt: text('last_seen_at')
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
agentIpPortProto: uniqueIndex(
|
||||||
|
'idx_agent_port_block_stats_agent_ip_port_proto',
|
||||||
|
).on(t.agentId, t.ip, t.port, t.protocol),
|
||||||
|
agentPackets: index('idx_agent_port_block_stats_agent_packets').on(
|
||||||
|
t.agentId,
|
||||||
|
t.packets,
|
||||||
|
),
|
||||||
|
agentPortProto: index('idx_agent_port_block_stats_agent_port_proto').on(
|
||||||
|
t.agentId,
|
||||||
|
t.port,
|
||||||
|
t.protocol,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Desired L4 port ACL per Linux agent (open/close). */
|
||||||
|
export const agentPortRules = sqliteTable(
|
||||||
|
'agent_port_rules',
|
||||||
|
{
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
agentId: text('agent_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => agents.id, { onDelete: 'cascade' }),
|
||||||
|
action: text('action').notNull(), // open | close
|
||||||
|
protocol: text('protocol').notNull(), // tcp | udp | both
|
||||||
|
portStart: integer('port_start').notNull(),
|
||||||
|
portEnd: integer('port_end').notNull(),
|
||||||
|
srcKind: text('src_kind').notNull(), // all | cidr | list
|
||||||
|
srcCidr: text('src_cidr'),
|
||||||
|
listId: text('list_id').references(() => ipLists.id, {
|
||||||
|
onDelete: 'set null',
|
||||||
|
}),
|
||||||
|
enabled: integer('enabled').notNull().default(1),
|
||||||
|
comment: text('comment'),
|
||||||
|
priority: integer('priority').notNull().default(100),
|
||||||
|
createdAt: text('created_at')
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
|
||||||
|
updatedAt: text('updated_at')
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
agentPriority: index('idx_agent_port_rules_agent_priority').on(
|
||||||
|
t.agentId,
|
||||||
|
t.priority,
|
||||||
|
),
|
||||||
|
agentEnabled: index('idx_agent_port_rules_agent_enabled').on(
|
||||||
|
t.agentId,
|
||||||
|
t.enabled,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Latest observed host firewall + listeners snapshot from Linux agent. */
|
||||||
|
export const agentHostFirewallSnapshots = sqliteTable(
|
||||||
|
'agent_host_firewall_snapshots',
|
||||||
|
{
|
||||||
|
agentId: text('agent_id')
|
||||||
|
.primaryKey()
|
||||||
|
.references(() => agents.id, { onDelete: 'cascade' }),
|
||||||
|
collectedAt: text('collected_at')
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
|
||||||
|
payloadJson: text('payload_json').notNull(),
|
||||||
|
rawDigest: text('raw_digest'),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
/** Short install invite links (`/agent-install/:id` and `/:slug`). */
|
/** Short install invite links (`/agent-install/:id` and `/:slug`). */
|
||||||
export const agentInstallLinks = sqliteTable(
|
export const agentInstallLinks = sqliteTable(
|
||||||
'agent_install_links',
|
'agent_install_links',
|
||||||
@@ -263,6 +380,10 @@ export const schema = {
|
|||||||
policyRuleResolved,
|
policyRuleResolved,
|
||||||
ipOverrides,
|
ipOverrides,
|
||||||
agentStatsSamples,
|
agentStatsSamples,
|
||||||
|
agentIpBlockStats,
|
||||||
|
agentPortBlockStats,
|
||||||
|
agentPortRules,
|
||||||
|
agentHostFirewallSnapshots,
|
||||||
agentInstallLinks,
|
agentInstallLinks,
|
||||||
auditLog,
|
auditLog,
|
||||||
}
|
}
|
||||||
@@ -221,6 +221,60 @@ export const enrollBodySchema = z.object({
|
|||||||
install_link_id: z.string().optional(),
|
install_link_id: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const applyReportIpHitSchema = z.object({
|
||||||
|
ip: z.string().min(1).max(64),
|
||||||
|
packets: z.number().int().nonnegative(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const applyReportPortHitSchema = z.object({
|
||||||
|
ip: z.string().min(1).max(64),
|
||||||
|
port: z.number().int().min(1).max(65535),
|
||||||
|
protocol: z.enum(['tcp', 'udp']),
|
||||||
|
packets: z.number().int().nonnegative(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const hostFwOwnershipSchema = z.enum(['evofw', 'foreign'])
|
||||||
|
export const hostFwBackendSchema = z.enum([
|
||||||
|
'nft',
|
||||||
|
'iptables',
|
||||||
|
'ufw',
|
||||||
|
'firewalld',
|
||||||
|
'listener',
|
||||||
|
])
|
||||||
|
|
||||||
|
export const hostFwRuleSchema = z.object({
|
||||||
|
ownership: hostFwOwnershipSchema,
|
||||||
|
backend: hostFwBackendSchema,
|
||||||
|
table: z.string().max(128).nullish(),
|
||||||
|
chain: z.string().max(128).nullish(),
|
||||||
|
action: z.string().max(64).nullish(),
|
||||||
|
protocol: z.string().max(16).nullish(),
|
||||||
|
dport: z.string().max(64).nullish(),
|
||||||
|
sport: z.string().max(64).nullish(),
|
||||||
|
saddr: z.string().max(128).nullish(),
|
||||||
|
daddr: z.string().max(128).nullish(),
|
||||||
|
comment: z.string().max(256).nullish(),
|
||||||
|
raw: z.string().max(512),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const hostListenerSchema = z.object({
|
||||||
|
protocol: z.string().max(16),
|
||||||
|
port: z.number().int().min(0).max(65535),
|
||||||
|
address: z.string().max(128),
|
||||||
|
process: z.string().max(128).nullish(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const hostFirewallPayloadSchema = z.object({
|
||||||
|
rules: z.array(hostFwRuleSchema).max(500).default([]),
|
||||||
|
listeners: z.array(hostListenerSchema).max(200).default([]),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Wire format for apply-report — unknown elements filtered in route (safeParse). */
|
||||||
|
export const applyReportHostFirewallSchema = z.object({
|
||||||
|
rules: z.array(z.unknown()).max(500).optional().default([]),
|
||||||
|
listeners: z.array(z.unknown()).max(200).optional().default([]),
|
||||||
|
})
|
||||||
|
|
||||||
export const applyReportBodySchema = z.object({
|
export const applyReportBodySchema = z.object({
|
||||||
status: z.string(),
|
status: z.string(),
|
||||||
prefix_count: z.number().int().optional(),
|
prefix_count: z.number().int().optional(),
|
||||||
@@ -229,6 +283,154 @@ export const applyReportBodySchema = z.object({
|
|||||||
kernel_method: z.string().optional(),
|
kernel_method: z.string().optional(),
|
||||||
error: z.string().optional(),
|
error: z.string().optional(),
|
||||||
source: z.string().optional(),
|
source: z.string().optional(),
|
||||||
|
/** Linux nft/ipset per-element drop counters (top-N, packets > 0). */
|
||||||
|
ip_hits: z.array(applyReportIpHitSchema).max(200).optional(),
|
||||||
|
/** Linux nft dynamic set per-(ip, proto, dport) deny hits (top-N). */
|
||||||
|
port_hits: z.array(applyReportPortHitSchema).max(500).optional(),
|
||||||
|
/** Observed host firewall + listeners (Linux). */
|
||||||
|
host_firewall: applyReportHostFirewallSchema.optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const agentPortRuleActionSchema = z.enum(['open', 'close'])
|
||||||
|
export const agentPortRuleProtocolSchema = z.enum(['tcp', 'udp', 'both'])
|
||||||
|
export const agentPortRuleSrcKindSchema = z.enum(['all', 'cidr', 'list'])
|
||||||
|
|
||||||
|
export const agentPortRuleSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
agent_id: z.string(),
|
||||||
|
action: agentPortRuleActionSchema,
|
||||||
|
protocol: agentPortRuleProtocolSchema,
|
||||||
|
port_start: z.number().int().min(1).max(65535),
|
||||||
|
port_end: z.number().int().min(1).max(65535),
|
||||||
|
src_kind: agentPortRuleSrcKindSchema,
|
||||||
|
src_cidr: z.string().nullable().optional(),
|
||||||
|
list_id: z.string().nullable().optional(),
|
||||||
|
list_name: z.string().nullable().optional(),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
comment: z.string().nullable().optional(),
|
||||||
|
priority: z.number().int(),
|
||||||
|
created_at: z.string(),
|
||||||
|
updated_at: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const createAgentPortRuleBodySchema = z
|
||||||
|
.object({
|
||||||
|
action: agentPortRuleActionSchema,
|
||||||
|
protocol: agentPortRuleProtocolSchema.default('tcp'),
|
||||||
|
port_start: z.number().int().min(1).max(65535),
|
||||||
|
port_end: z.number().int().min(1).max(65535).optional(),
|
||||||
|
src_kind: agentPortRuleSrcKindSchema.default('all'),
|
||||||
|
src_cidr: z.string().min(1).max(64).optional(),
|
||||||
|
list_id: z.string().min(1).optional(),
|
||||||
|
enabled: z.boolean().optional().default(true),
|
||||||
|
comment: z.string().max(500).optional(),
|
||||||
|
priority: z.number().int().optional().default(100),
|
||||||
|
})
|
||||||
|
.superRefine((v, ctx) => {
|
||||||
|
const end = v.port_end ?? v.port_start
|
||||||
|
if (end < v.port_start) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
message: 'port_end must be >= port_start',
|
||||||
|
path: ['port_end'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (v.src_kind === 'cidr' && !v.src_cidr?.trim()) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
message: 'src_cidr required when src_kind=cidr',
|
||||||
|
path: ['src_cidr'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (v.src_kind === 'list' && !v.list_id?.trim()) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
message: 'list_id required when src_kind=list',
|
||||||
|
path: ['list_id'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const updateAgentPortRuleBodySchema = z
|
||||||
|
.object({
|
||||||
|
action: agentPortRuleActionSchema.optional(),
|
||||||
|
protocol: agentPortRuleProtocolSchema.optional(),
|
||||||
|
port_start: z.number().int().min(1).max(65535).optional(),
|
||||||
|
port_end: z.number().int().min(1).max(65535).optional(),
|
||||||
|
src_kind: agentPortRuleSrcKindSchema.optional(),
|
||||||
|
src_cidr: z.string().min(1).max(64).nullable().optional(),
|
||||||
|
list_id: z.string().min(1).nullable().optional(),
|
||||||
|
enabled: z.boolean().optional(),
|
||||||
|
comment: z.string().max(500).nullable().optional(),
|
||||||
|
priority: z.number().int().optional(),
|
||||||
|
})
|
||||||
|
.refine((o) => Object.keys(o).length > 0, { message: 'empty update' })
|
||||||
|
|
||||||
|
export const importAgentPortRulesBodySchema = z
|
||||||
|
.object({
|
||||||
|
from: z.enum(['list', 'set']),
|
||||||
|
list_id: z.string().min(1).optional(),
|
||||||
|
set_id: z.string().min(1).optional(),
|
||||||
|
action: agentPortRuleActionSchema,
|
||||||
|
protocol: agentPortRuleProtocolSchema.default('tcp'),
|
||||||
|
ports: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
port_start: z.number().int().min(1).max(65535),
|
||||||
|
port_end: z.number().int().min(1).max(65535).optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1)
|
||||||
|
.max(50),
|
||||||
|
enabled: z.boolean().optional().default(true),
|
||||||
|
comment: z.string().max(500).optional(),
|
||||||
|
})
|
||||||
|
.superRefine((v, ctx) => {
|
||||||
|
if (v.from === 'list' && !v.list_id?.trim()) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
message: 'list_id required when from=list',
|
||||||
|
path: ['list_id'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (v.from === 'set' && !v.set_id?.trim()) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
message: 'set_id required when from=set',
|
||||||
|
path: ['set_id'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Expanded port rule for agent policy apply_version >= 3. */
|
||||||
|
export const agentPolicyPortRuleSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
action: agentPortRuleActionSchema,
|
||||||
|
protocol: z.enum(['tcp', 'udp']),
|
||||||
|
port_start: z.number().int(),
|
||||||
|
port_end: z.number().int(),
|
||||||
|
src_cidrs: z.array(z.string()),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const agentIpPortStatSchema = z.object({
|
||||||
|
port: z.number().int(),
|
||||||
|
protocol: z.enum(['tcp', 'udp']),
|
||||||
|
packets: z.number().int(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const agentIpBlockStatSchema = z.object({
|
||||||
|
ip: z.string(),
|
||||||
|
packets: z.number().int(),
|
||||||
|
first_seen_at: z.string(),
|
||||||
|
last_seen_at: z.string(),
|
||||||
|
ports: z.array(agentIpPortStatSchema).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const agentPortBlockStatSchema = z.object({
|
||||||
|
port: z.number().int(),
|
||||||
|
protocol: z.enum(['tcp', 'udp']),
|
||||||
|
packets: z.number().int(),
|
||||||
|
last_seen_at: z.string(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const agentPolicySchema = z.object({
|
export const agentPolicySchema = z.object({
|
||||||
@@ -240,7 +442,10 @@ export const agentPolicySchema = z.object({
|
|||||||
policy_mode: policyModeSchema.optional(),
|
policy_mode: policyModeSchema.optional(),
|
||||||
deny_cidrs: z.array(z.string()),
|
deny_cidrs: z.array(z.string()),
|
||||||
allow_cidrs: z.array(z.string()),
|
allow_cidrs: z.array(z.string()),
|
||||||
|
port_rules: z.array(agentPolicyPortRuleSchema).optional(),
|
||||||
sync_interval_sec: z.number().int(),
|
sync_interval_sec: z.number().int(),
|
||||||
|
/** sha256 of GET /v1/agent/sync-script (Linux self-update). Not part of policy.hash. */
|
||||||
|
script_sha256: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const agentPolicyPreviewSchema = z.object({
|
export const agentPolicyPreviewSchema = z.object({
|
||||||
@@ -248,7 +453,7 @@ export const agentPolicyPreviewSchema = z.object({
|
|||||||
hash: z.string(),
|
hash: z.string(),
|
||||||
generation: z.number().int(),
|
generation: z.number().int(),
|
||||||
sync_interval_sec: z.number().int(),
|
sync_interval_sec: z.number().int(),
|
||||||
apply_version: z.literal(2),
|
apply_version: z.literal(3),
|
||||||
summary: z.object({
|
summary: z.object({
|
||||||
sets: z.number().int(),
|
sets: z.number().int(),
|
||||||
rules_deny: z.number().int(),
|
rules_deny: z.number().int(),
|
||||||
@@ -257,6 +462,7 @@ export const agentPolicyPreviewSchema = z.object({
|
|||||||
cidrs_allow: z.number().int(),
|
cidrs_allow: z.number().int(),
|
||||||
overrides: z.number().int(),
|
overrides: z.number().int(),
|
||||||
conflicts_dropped: z.number().int(),
|
conflicts_dropped: z.number().int(),
|
||||||
|
port_rules: z.number().int().optional(),
|
||||||
}),
|
}),
|
||||||
chain: z.array(
|
chain: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -273,6 +479,7 @@ export const agentPolicyPreviewSchema = z.object({
|
|||||||
allow_cidrs: z.array(z.string()),
|
allow_cidrs: z.array(z.string()),
|
||||||
deny_cidrs_total: z.number().int(),
|
deny_cidrs_total: z.number().int(),
|
||||||
allow_cidrs_total: z.number().int(),
|
allow_cidrs_total: z.number().int(),
|
||||||
|
port_rules: z.array(agentPolicyPortRuleSchema).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const dashboardStatsSchema = z.object({
|
export const dashboardStatsSchema = z.object({
|
||||||
@@ -350,6 +557,11 @@ export type PolicySet = z.infer<typeof policySetSchema>
|
|||||||
export type IpOverride = z.infer<typeof ipOverrideSchema>
|
export type IpOverride = z.infer<typeof ipOverrideSchema>
|
||||||
export type AgentPolicy = z.infer<typeof agentPolicySchema>
|
export type AgentPolicy = z.infer<typeof agentPolicySchema>
|
||||||
export type AgentPolicyPreview = z.infer<typeof agentPolicyPreviewSchema>
|
export type AgentPolicyPreview = z.infer<typeof agentPolicyPreviewSchema>
|
||||||
|
export type AgentPortRule = z.infer<typeof agentPortRuleSchema>
|
||||||
|
export type CreateAgentPortRuleBody = z.infer<typeof createAgentPortRuleBodySchema>
|
||||||
|
export type UpdateAgentPortRuleBody = z.infer<typeof updateAgentPortRuleBodySchema>
|
||||||
|
export type ImportAgentPortRulesBody = z.infer<typeof importAgentPortRulesBodySchema>
|
||||||
|
export type HostFirewallPayload = z.infer<typeof hostFirewallPayloadSchema>
|
||||||
export type DashboardStats = z.infer<typeof dashboardStatsSchema>
|
export type DashboardStats = z.infer<typeof dashboardStatsSchema>
|
||||||
export type InstallLink = z.infer<typeof installLinkSchema>
|
export type InstallLink = z.infer<typeof installLinkSchema>
|
||||||
export type EvobgpCommunity = z.infer<typeof evobgpCommunitySchema>
|
export type EvobgpCommunity = z.infer<typeof evobgpCommunitySchema>
|
||||||
|
|||||||
@@ -70,9 +70,8 @@ function SheetContent({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<XIcon
|
<XIcon aria-hidden="true" />
|
||||||
/>
|
<span className="sr-only">Закрыть</span>
|
||||||
<span className="sr-only">Close</span>
|
|
||||||
</SheetPrimitive.Close>
|
</SheetPrimitive.Close>
|
||||||
)}
|
)}
|
||||||
</SheetPrimitive.Popup>
|
</SheetPrimitive.Popup>
|
||||||
|
|||||||
@@ -194,8 +194,8 @@ function Sidebar({
|
|||||||
side={side}
|
side={side}
|
||||||
>
|
>
|
||||||
<SheetHeader className="sr-only">
|
<SheetHeader className="sr-only">
|
||||||
<SheetTitle>Sidebar</SheetTitle>
|
<SheetTitle>Боковое меню</SheetTitle>
|
||||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
<SheetDescription>Навигация по приложению</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div className="flex h-full w-full flex-col">{children}</div>
|
<div className="flex h-full w-full flex-col">{children}</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
@@ -254,7 +254,7 @@ function SidebarTrigger({
|
|||||||
onClick,
|
onClick,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof Button>) {
|
}: React.ComponentProps<typeof Button>) {
|
||||||
const { toggleSidebar } = useSidebar()
|
const { toggleSidebar, open, openMobile, isMobile } = useSidebar()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -262,6 +262,9 @@ function SidebarTrigger({
|
|||||||
data-slot="sidebar-trigger"
|
data-slot="sidebar-trigger"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
|
type="button"
|
||||||
|
aria-label="Боковое меню"
|
||||||
|
aria-expanded={isMobile ? openMobile : open}
|
||||||
className={cn(className)}
|
className={cn(className)}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
onClick?.(event)
|
onClick?.(event)
|
||||||
@@ -269,8 +272,8 @@ function SidebarTrigger({
|
|||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<PanelLeftIcon />
|
<PanelLeftIcon aria-hidden="true" />
|
||||||
<span className="sr-only">Toggle Sidebar</span>
|
<span className="sr-only">Боковое меню</span>
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -282,10 +285,10 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
|||||||
<button
|
<button
|
||||||
data-sidebar="rail"
|
data-sidebar="rail"
|
||||||
data-slot="sidebar-rail"
|
data-slot="sidebar-rail"
|
||||||
aria-label="Toggle Sidebar"
|
aria-label="Боковое меню"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
onClick={toggleSidebar}
|
onClick={toggleSidebar}
|
||||||
title="Toggle Sidebar"
|
title="Боковое меню"
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
|
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
|
||||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||||
|
|||||||
Generated
+4362
-2
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
# Абсолютные пути для actions/cache: на act_runner «~» часто не раскрывается → cache miss.
|
||||||
|
set -eu
|
||||||
|
: "${GITHUB_ENV:?GITHUB_ENV required (Gitea/GitHub Actions)}"
|
||||||
|
|
||||||
|
HOME_DIR="${HOME:-}"
|
||||||
|
if [ -z "$HOME_DIR" ]; then
|
||||||
|
HOME_DIR="$(getent passwd "$(id -u)" 2>/dev/null | cut -d: -f6 || true)"
|
||||||
|
fi
|
||||||
|
HOME_DIR="${HOME_DIR:-/root}"
|
||||||
|
|
||||||
|
PNPM_STORE_DIR="${HOME_DIR}/.pnpm-store"
|
||||||
|
COREPACK_HOME="${HOME_DIR}/.cache/node/corepack"
|
||||||
|
|
||||||
|
mkdir -p "$PNPM_STORE_DIR" "$COREPACK_HOME"
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "HOME_DIR=${HOME_DIR}"
|
||||||
|
echo "PNPM_STORE_DIR=${PNPM_STORE_DIR}"
|
||||||
|
echo "COREPACK_HOME=${COREPACK_HOME}"
|
||||||
|
} >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
echo "cache-env HOME_DIR=${HOME_DIR}"
|
||||||
|
echo "cache-env PNPM_STORE_DIR=${PNPM_STORE_DIR}"
|
||||||
|
echo "cache-env COREPACK_HOME=${COREPACK_HOME}"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
# pnpm install из кэша Gitea (store + node_modules). Без повторного download при hit.
|
||||||
|
set -eu
|
||||||
|
: "${PNPM_STORE_DIR:?PNPM_STORE_DIR required — run scripts/ci/export-cache-env.sh first}"
|
||||||
|
|
||||||
|
export COREPACK_HOME="${COREPACK_HOME:-${HOME:-/root}/.cache/node/corepack}"
|
||||||
|
mkdir -p "$PNPM_STORE_DIR" "$COREPACK_HOME"
|
||||||
|
corepack enable
|
||||||
|
pnpm config set store-dir "$PNPM_STORE_DIR"
|
||||||
|
|
||||||
|
echo "pnpm store: $(pnpm store path)"
|
||||||
|
echo "PNPM_CACHE_HIT=${PNPM_CACHE_HIT:-}"
|
||||||
|
|
||||||
|
if [ "${PNPM_CACHE_HIT:-}" = "true" ]; then
|
||||||
|
if pnpm install --frozen-lockfile --offline; then
|
||||||
|
echo "pnpm install --offline (cache hit)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "offline install failed — prefer-offline"
|
||||||
|
fi
|
||||||
|
pnpm install --frozen-lockfile --prefer-offline
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Warns about commits since the last tag that semantic-release cannot parse.
|
||||||
|
* Exit 0 always — semantic-release still decides release/no-op.
|
||||||
|
*/
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
import { CommitParser } from 'conventional-commits-parser';
|
||||||
|
|
||||||
|
const parser = new CommitParser();
|
||||||
|
|
||||||
|
const RELEASABLE = new Set(['feat', 'fix', 'perf', 'ci', 'refactor']);
|
||||||
|
|
||||||
|
function lastTag() {
|
||||||
|
try {
|
||||||
|
return execSync('git describe --tags --abbrev=0', { encoding: 'utf8' }).trim();
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitsSince(ref) {
|
||||||
|
const range = ref ? `${ref}..HEAD` : 'HEAD';
|
||||||
|
const out = execSync(`git log ${range} --format=%H%x09%s`, { encoding: 'utf8' }).trim();
|
||||||
|
if (!out) return [];
|
||||||
|
return out.split('\n').map((line) => {
|
||||||
|
const [hash, subject] = line.split('\t');
|
||||||
|
return { hash: hash.trim(), subject: subject.trim() };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = lastTag();
|
||||||
|
const commits = commitsSince(tag);
|
||||||
|
const unparseable = [];
|
||||||
|
const releasable = [];
|
||||||
|
|
||||||
|
for (const { hash, subject } of commits) {
|
||||||
|
let parsed;
|
||||||
|
try {
|
||||||
|
parsed = parser.parse(subject);
|
||||||
|
} catch {
|
||||||
|
unparseable.push({ hash: hash.slice(0, 7), subject });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!parsed?.type) {
|
||||||
|
unparseable.push({ hash: hash.slice(0, 7), subject });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (RELEASABLE.has(parsed.type)) {
|
||||||
|
releasable.push({ hash: hash.slice(0, 7), subject, type: parsed.type });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commits.length === 0) {
|
||||||
|
console.log(`No new commits since ${tag || 'initial'}.`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unparseable.length > 0) {
|
||||||
|
console.warn('::warning:: Commits not parseable by semantic-release (no version bump):');
|
||||||
|
for (const b of unparseable) {
|
||||||
|
console.warn(` ${b.hash} ${b.subject}`);
|
||||||
|
}
|
||||||
|
console.warn('Fix: single scope without commas, e.g. refactor(web): summary');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (releasable.length > 0) {
|
||||||
|
console.log(`Releasable since ${tag}: ${releasable.length} commit(s).`);
|
||||||
|
} else {
|
||||||
|
console.warn('::warning:: No releasable commits since last tag — release job will no-op.');
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
Reference in new issue
Block a user