From ebadf70e2b6942c4716982bf08eb9af83ec112d7 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Mon, 20 Jul 2026 19:50:54 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20EvoFirewall=20V1=20control=20p?= =?UTF-8?q?lane?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API, UI, Linux/MikroTik agents, IP lists, политики, stats, CI и интеграция с auth-portal/EvoBGP. Co-authored-by: Cursor --- .env.example | 3 +- .gitea/workflows/docker.yml | 95 + AGENTS.md | 41 +- Dockerfile | 53 + README.md | 63 +- VERSION | 1 + apps/api/package.json | 33 +- apps/api/src/agent-scripts/evofw-firewall.sh | 215 + apps/api/src/agent-scripts/install.sh | 125 + .../src/agent-scripts/mikrotik-install.rsc | 46 + apps/api/src/app.ts | 96 + apps/api/src/config.ts | 55 + apps/api/src/plugins/auth.ts | 217 + apps/api/src/plugins/cors.ts | 8 + apps/api/src/plugins/db.ts | 38 + apps/api/src/plugins/error-handler.ts | 38 + apps/api/src/routes/agent.ts | 136 + apps/api/src/routes/control.ts | 413 ++ apps/api/src/routes/health.ts | 14 + apps/api/src/server.ts | 40 + apps/api/src/services/lists/refresh.ts | 174 + apps/api/src/services/policy/evaluate.ts | 85 + apps/api/tsconfig.json | 14 + apps/web/index.html | 12 + apps/web/package.json | 40 +- apps/web/src/components/layout/.gitkeep | 0 apps/web/src/components/layout/app-shell.tsx | 112 + apps/web/src/components/reui-kit/.gitkeep | 0 apps/web/src/components/reui-kit/index.tsx | 104 + apps/web/src/components/reui/.gitkeep | 0 apps/web/src/components/reui/frame.tsx | 59 + apps/web/src/lib/api.ts | 34 + apps/web/src/lib/auth.ts | 126 + apps/web/src/main.tsx | 39 + apps/web/src/queries/index.ts | 82 + apps/web/src/routeTree.gen.ts | 244 + apps/web/src/routes/.gitkeep | 0 apps/web/src/routes/__root.tsx | 25 + apps/web/src/routes/_auth.tsx | 10 + apps/web/src/routes/_auth/agents.$id.tsx | 236 + apps/web/src/routes/_auth/agents.tsx | 231 + apps/web/src/routes/_auth/index.tsx | 133 + apps/web/src/routes/_auth/lists.tsx | 210 + apps/web/src/routes/_auth/rules.tsx | 182 + apps/web/src/routes/_auth/settings.tsx | 100 + apps/web/src/routes/_auth/stats.tsx | 135 + apps/web/src/routes/auth.callback.tsx | 25 + apps/web/tsconfig.json | 26 + apps/web/vite.config.ts | 41 + deploy/compose/docker-compose.example.yaml | 21 + docs/README.md | 9 +- docs/agents.md | 28 + docs/architecture.md | 33 + docs/integrate-auth-portal.md | 44 + docs/integrate-evobgp.md | 21 + docs/openapi.yaml | 80 + packages/db/drizzle.config.ts | 8 + packages/db/migrations/.gitkeep | 0 packages/db/migrations/001_initial.sql | 109 + packages/db/package.json | 25 +- packages/db/src/client.ts | 73 + packages/db/src/index.ts | 4 +- packages/db/src/repositories/index.ts | 289 + packages/db/src/schema.ts | 152 + packages/db/tsconfig.json | 14 + packages/shared/package.json | 23 +- packages/shared/src/contracts.ts | 147 + packages/shared/src/index.ts | 3 +- packages/shared/src/permissions.ts | 57 + packages/shared/tsconfig.json | 14 + packages/ui/package.json | 37 +- packages/ui/src/components/.gitkeep | 0 packages/ui/src/components/alert-dialog.tsx | 187 + packages/ui/src/components/avatar.tsx | 109 + packages/ui/src/components/badge.tsx | 54 + packages/ui/src/components/breadcrumb.tsx | 125 + packages/ui/src/components/button-group.tsx | 87 + packages/ui/src/components/button.tsx | 58 + packages/ui/src/components/card.tsx | 103 + packages/ui/src/components/chart.tsx | 400 ++ packages/ui/src/components/checkbox.tsx | 27 + packages/ui/src/components/collapsible.tsx | 21 + packages/ui/src/components/dialog.tsx | 160 + packages/ui/src/components/dropdown-menu.tsx | 266 + packages/ui/src/components/empty.tsx | 104 + packages/ui/src/components/field.tsx | 238 + packages/ui/src/components/input-group.tsx | 156 + packages/ui/src/components/input.tsx | 20 + packages/ui/src/components/label.tsx | 18 + packages/ui/src/components/popover.tsx | 88 + packages/ui/src/components/scroll-area.tsx | 52 + packages/ui/src/components/select.tsx | 201 + packages/ui/src/components/separator.tsx | 23 + packages/ui/src/components/sheet.tsx | 136 + packages/ui/src/components/sidebar.tsx | 721 ++ packages/ui/src/components/skeleton.tsx | 13 + packages/ui/src/components/sonner.tsx | 47 + packages/ui/src/components/spinner.tsx | 10 + packages/ui/src/components/switch.tsx | 30 + packages/ui/src/components/table.tsx | 114 + packages/ui/src/components/tabs.tsx | 89 + packages/ui/src/components/textarea.tsx | 18 + packages/ui/src/components/tooltip.tsx | 66 + packages/ui/src/hooks/use-mobile.ts | 19 + packages/ui/src/lib/utils.ts | 6 + packages/ui/src/styles/globals.css | 157 +- pnpm-lock.yaml | 5972 +++++++++++++++++ 107 files changed, 15196 insertions(+), 99 deletions(-) create mode 100644 .gitea/workflows/docker.yml create mode 100644 Dockerfile create mode 100644 VERSION create mode 100644 apps/api/src/agent-scripts/evofw-firewall.sh create mode 100644 apps/api/src/agent-scripts/install.sh create mode 100644 apps/api/src/agent-scripts/mikrotik-install.rsc create mode 100644 apps/api/src/app.ts create mode 100644 apps/api/src/config.ts create mode 100644 apps/api/src/plugins/auth.ts create mode 100644 apps/api/src/plugins/cors.ts create mode 100644 apps/api/src/plugins/db.ts create mode 100644 apps/api/src/plugins/error-handler.ts create mode 100644 apps/api/src/routes/agent.ts create mode 100644 apps/api/src/routes/control.ts create mode 100644 apps/api/src/routes/health.ts create mode 100644 apps/api/src/server.ts create mode 100644 apps/api/src/services/lists/refresh.ts create mode 100644 apps/api/src/services/policy/evaluate.ts create mode 100644 apps/api/tsconfig.json create mode 100644 apps/web/index.html delete mode 100644 apps/web/src/components/layout/.gitkeep create mode 100644 apps/web/src/components/layout/app-shell.tsx delete mode 100644 apps/web/src/components/reui-kit/.gitkeep create mode 100644 apps/web/src/components/reui-kit/index.tsx delete mode 100644 apps/web/src/components/reui/.gitkeep create mode 100644 apps/web/src/components/reui/frame.tsx create mode 100644 apps/web/src/lib/api.ts create mode 100644 apps/web/src/lib/auth.ts create mode 100644 apps/web/src/main.tsx create mode 100644 apps/web/src/queries/index.ts create mode 100644 apps/web/src/routeTree.gen.ts delete mode 100644 apps/web/src/routes/.gitkeep create mode 100644 apps/web/src/routes/__root.tsx create mode 100644 apps/web/src/routes/_auth.tsx create mode 100644 apps/web/src/routes/_auth/agents.$id.tsx create mode 100644 apps/web/src/routes/_auth/agents.tsx create mode 100644 apps/web/src/routes/_auth/index.tsx create mode 100644 apps/web/src/routes/_auth/lists.tsx create mode 100644 apps/web/src/routes/_auth/rules.tsx create mode 100644 apps/web/src/routes/_auth/settings.tsx create mode 100644 apps/web/src/routes/_auth/stats.tsx create mode 100644 apps/web/src/routes/auth.callback.tsx create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/vite.config.ts create mode 100644 deploy/compose/docker-compose.example.yaml create mode 100644 docs/agents.md create mode 100644 docs/architecture.md create mode 100644 docs/integrate-auth-portal.md create mode 100644 docs/integrate-evobgp.md create mode 100644 docs/openapi.yaml create mode 100644 packages/db/drizzle.config.ts delete mode 100644 packages/db/migrations/.gitkeep create mode 100644 packages/db/migrations/001_initial.sql create mode 100644 packages/db/src/client.ts create mode 100644 packages/db/src/repositories/index.ts create mode 100644 packages/db/src/schema.ts create mode 100644 packages/db/tsconfig.json create mode 100644 packages/shared/src/contracts.ts create mode 100644 packages/shared/src/permissions.ts create mode 100644 packages/shared/tsconfig.json delete mode 100644 packages/ui/src/components/.gitkeep create mode 100644 packages/ui/src/components/alert-dialog.tsx create mode 100644 packages/ui/src/components/avatar.tsx create mode 100644 packages/ui/src/components/badge.tsx create mode 100644 packages/ui/src/components/breadcrumb.tsx create mode 100644 packages/ui/src/components/button-group.tsx create mode 100644 packages/ui/src/components/button.tsx create mode 100644 packages/ui/src/components/card.tsx create mode 100644 packages/ui/src/components/chart.tsx create mode 100644 packages/ui/src/components/checkbox.tsx create mode 100644 packages/ui/src/components/collapsible.tsx create mode 100644 packages/ui/src/components/dialog.tsx create mode 100644 packages/ui/src/components/dropdown-menu.tsx create mode 100644 packages/ui/src/components/empty.tsx create mode 100644 packages/ui/src/components/field.tsx create mode 100644 packages/ui/src/components/input-group.tsx create mode 100644 packages/ui/src/components/input.tsx create mode 100644 packages/ui/src/components/label.tsx create mode 100644 packages/ui/src/components/popover.tsx create mode 100644 packages/ui/src/components/scroll-area.tsx create mode 100644 packages/ui/src/components/select.tsx create mode 100644 packages/ui/src/components/separator.tsx create mode 100644 packages/ui/src/components/sheet.tsx create mode 100644 packages/ui/src/components/sidebar.tsx create mode 100644 packages/ui/src/components/skeleton.tsx create mode 100644 packages/ui/src/components/sonner.tsx create mode 100644 packages/ui/src/components/spinner.tsx create mode 100644 packages/ui/src/components/switch.tsx create mode 100644 packages/ui/src/components/table.tsx create mode 100644 packages/ui/src/components/tabs.tsx create mode 100644 packages/ui/src/components/textarea.tsx create mode 100644 packages/ui/src/components/tooltip.tsx create mode 100644 packages/ui/src/hooks/use-mobile.ts create mode 100644 packages/ui/src/lib/utils.ts create mode 100644 pnpm-lock.yaml diff --git a/.env.example b/.env.example index 4ef5dc9..6fcf862 100644 --- a/.env.example +++ b/.env.example @@ -8,13 +8,14 @@ AUTH_JWT_SECRET=dev-secret-change-me AUTH_ISSUER=https://auth.shnt.top AUTH_PORTAL_URL=http://localhost:5175 JWT_TTL_HOURS=24 +PUBLIC_BASE_URL=http://localhost:8080 +EVOFW_ENROLL_SEED=dev-enroll-seed-change-me # Frontend (Vite) — apps/web/.env.local # VITE_AUTH_ENABLED=true # VITE_AUTH_PORTAL_URL=http://localhost:5175 # ReUI PRO (apps/web/components.json → @reui Authorization) -# Ключ: https://reui.io/docs/license-setup — класть в apps/web/.env.local (gitignored) REUI_LICENSE_KEY= # EvoBGP integration (source of prefix lists) diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml new file mode 100644 index 0000000..0d4eda9 --- /dev/null +++ b/.gitea/workflows/docker.yml @@ -0,0 +1,95 @@ +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 + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: git.shts.su/${{ gitea.repository }} + 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/${{ gitea.repository }}:buildcache + cache-to: type=registry,ref=git.shts.su/${{ gitea.repository }}: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<> $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 .)}" diff --git a/AGENTS.md b/AGENTS.md index 3385b58..4151e60 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,36 +4,31 @@ EvoFirewall — централизованный firewall controller (Linux / MikroTik agents, blocklists, статистика). Интеграции: **auth-portal** (app id `fw`), **EvoBGP** (источник префиксов). -## Стек (план / scaffold) +## Стек - Monorepo: pnpm + turbo (`apps/*`, `packages/*`) -- `apps/web` — Vite + React + TanStack + shadcn **base-nova** + ReUI `@reui` +- `apps/web` — Vite + React + TanStack + shadcn **base-nova** + ReUI Frame - `apps/api` — Fastify + Drizzle + SQLite - Packages: `@evofw/ui`, `@evofw/shared`, `@evofw/db` -## ReUI PRO (обязательно) - -| Что | Где | -|-----|-----| -| MCP | `user-reui` → https://mcp.reui.io/api/mcp (см. `.mcp.json`, `.cursor/mcp.json`) | -| Skill | `.agents/skills/reui` (v`42d70dcc3d`) — update: `curl -fsSL https://mcp.reui.io/install \| node -` | -| shadcn skill | `.agents/skills/shadcn` | -| Cursor rules | `.cursor/rules/reui-mcp.mdc`, `reui.mdc`, `shadcn-mcp.mdc`, `shadcn-ui-production.mdc`, `frontend-*.mdc` | -| Design contract | [`docs/ui-design-contract.md`](docs/ui-design-contract.md) — **surface: frame** | -| License | `apps/web/.env.local` → `REUI_LICENSE_KEY` | -| Registry | `apps/web/components.json` → `@reui` + Bearer | - -Docs: [llms.txt](https://reui.io/llms.txt) · [MCP](https://reui.io/docs/mcp) · [License](https://reui.io/docs/license-setup) · [Agent skills](https://reui.io/docs/agent-skills) - -UI workflow: `user-reui` search/compose → cite `previewUrl` → CLI из `apps/web` → adapt в `reui-kit` → `validate_usage`. - -## Структура +## Команды +```bash +pnpm install +pnpm --filter @evofw/shared build +pnpm --filter @evofw/db build +pnpm --filter @evofw/api dev +pnpm --filter @evofw/web dev +pnpm --filter @evofw/web build ``` -apps/web/src/components/reui/ # CLI @reui/* -apps/web/src/components/reui-kit/ # ResourcePage, KpiStatGrid, … -packages/ui/ # shadcn primitives only -``` + +## ReUI PRO + +Surface: **frame**. Contract: `docs/ui-design-contract.md`. MCP `user-reui` + CLI из `apps/web`. + +## Docs + +`docs/architecture.md`, `docs/agents.md`, `docs/integrate-auth-portal.md`, `docs/integrate-evobgp.md`. ## Git diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..57461b7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,53 @@ +# 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"] diff --git a/README.md b/README.md index 94d3486..a4a9975 100644 --- a/README.md +++ b/README.md @@ -1,67 +1,42 @@ # EvoFirewall -Централизованный firewall controller: Linux / MikroTik agents, blocklists, статистика. +Централизованный firewall controller: Linux / MikroTik agents, IP lists, allow/deny политики, статистика. -Интеграции (план): **auth-portal** (SSO, app id `fw`), **EvoBGP** (источник префиксов). +Интеграции: **auth-portal** (SSO, app id `fw`), **EvoBGP** (источник префиксов). -## Стек (как CFDM / VPS Tracker) +## Стек - Monorepo: pnpm workspaces + turbo -- `apps/web` — Vite + React + TanStack Router/Query + shadcn/ui + ReUI -- `apps/api` — Fastify + Drizzle + SQLite -- `packages/ui` — `@evofw/ui` (shadcn primitives) -- `packages/shared` — Zod-контракты -- `packages/db` — схема и репозитории - -## Структура - -``` -apps/ - web/ # SPA - api/ # HTTP API -packages/ - ui/ # shadcn CLI output - shared/ # contracts - db/ # drizzle -docs/ -deploy/ -scripts/ -data/ # SQLite (gitignored) -``` +- `apps/web` — Vite + React + TanStack + shadcn/ui + ReUI Frame +- `apps/api` — Fastify 5 + Drizzle + SQLite +- Packages: `@evofw/ui`, `@evofw/shared`, `@evofw/db` ## Быстрый старт ```bash pnpm install cp .env.example .env -# ReUI PRO: apps/web/.env.local уже с ключом (из CFDM) или скопируйте: -# copy ..\cloudflare-domain-manager\apps\web\.env.local apps\web\.env.local +pnpm --filter @evofw/db build +pnpm --filter @evofw/shared build +pnpm --filter @evofw/api dev # :8080 +pnpm --filter @evofw/web dev # :5177 ``` -Scaffold пока без Vite/Fastify — следующий шаг: init web/api по образцу CFDM. - -## ReUI PRO - -Skill + MCP установлены официальным инсталлером (`https://mcp.reui.io/install`): - -- Skill: `.agents/skills/reui` (версия `42d70dcc3d`) -- MCP: `.mcp.json` / `.cursor/mcp.json` → `https://mcp.reui.io/api/mcp` -- Rules: `.cursor/rules/reui-mcp.mdc`, `reui.mdc`, `shadcn-*.mdc`, `frontend-*.mdc` -- Contract: [`docs/ui-design-contract.md`](docs/ui-design-contract.md) — surface **frame** +Linux agent: ```bash -cd apps/web -# нужен REUI_LICENSE_KEY в .env.local (уже из CFDM) -pnpm dlx shadcn@latest add @reui/frame --yes +curl -fsSL http://localhost:8080/v1/agent/install.sh | \ + EVOFW_CP_URL=http://localhost:8080 \ + EVOFW_SEED=dev-enroll-seed-change-me \ + EVOFW_CLIENT_NAME="web-01" \ + bash ``` -Обновить skill/MCP: +Затем Approve в UI `/agents`. -```bash -curl -fsSL https://mcp.reui.io/install | node - -``` +## Docs -Docs: https://reui.io/docs/license-setup · https://reui.io/llms.txt · https://reui.io/docs/mcp · https://reui.io/docs/agent-skills +См. [`docs/README.md`](docs/README.md). ## Git diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..6c6aa7c --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 \ No newline at end of file diff --git a/apps/api/package.json b/apps/api/package.json index d14d1dd..822b773 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,12 +1,35 @@ { "name": "@evofw/api", - "private": true, "version": "0.0.0", + "private": true, "type": "module", "scripts": { - "dev": "echo \"api: scaffold — Fastify app not initialized yet\"", - "build": "echo \"api: scaffold — skip\"", - "lint": "echo \"api: scaffold — skip\"", - "test": "echo \"api: scaffold — skip\"" + "dev": "tsx watch src/server.ts", + "build": "tsup src/server.ts --format esm --dts --publicDir src/agent-scripts && node -e \"const fs=require('fs');const p='dist/agent-scripts';fs.mkdirSync(p,{recursive:true});for(const f of fs.readdirSync('src/agent-scripts'))fs.copyFileSync('src/agent-scripts/'+f,p+'/'+f)\"", + "start": "node dist/server.js", + "test": "vitest run" + }, + "dependencies": { + "@evofw/db": "workspace:*", + "@evofw/shared": "workspace:*", + "@fastify/cors": "^11.0.1", + "@fastify/helmet": "^13.0.1", + "@fastify/jwt": "^9.1.0", + "@fastify/rate-limit": "^10.3.0", + "@fastify/schedule": "^6.0.0", + "@fastify/sensible": "^6.0.3", + "@fastify/static": "^8.2.0", + "@fastify/type-provider-zod": "^1.0.0", + "fastify": "^5.4.0", + "fastify-plugin": "^5.0.1", + "toad-scheduler": "^3.0.1", + "zod": "^4.0.0" + }, + "devDependencies": { + "@types/node": "^22.15.32", + "tsup": "^8.5.0", + "tsx": "^4.20.3", + "typescript": "^5.8.3", + "vitest": "^3.2.4" } } diff --git a/apps/api/src/agent-scripts/evofw-firewall.sh b/apps/api/src/agent-scripts/evofw-firewall.sh new file mode 100644 index 0000000..3df5017 --- /dev/null +++ b/apps/api/src/agent-scripts/evofw-firewall.sh @@ -0,0 +1,215 @@ +#!/usr/bin/env bash +# EvoFirewall Linux sync agent — nft / ipset / iptables +set -euo pipefail + +CONF_FILE=/etc/evofw/agent.conf +LOG_FILE=/var/log/evofw-firewall.log +STATE_DIR=/var/lib/evofw +HASH_FILE="${STATE_DIR}/last_hash" +POLICY_FILE="${STATE_DIR}/last_policy.json" + +log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $*" | tee -a "$LOG_FILE"; } + +if [[ ! -f "$CONF_FILE" ]]; then + log "missing $CONF_FILE" + exit 1 +fi +# shellcheck disable=SC1090 +source "$CONF_FILE" + +: "${EVOFW_CP_URL:?}" +: "${CLIENT_TOKEN:?}" +CLIENT_TOKEN="${CLIENT_TOKEN//$'\r'/}" +CLIENT_TOKEN="${CLIENT_TOKEN//$'\n'/}" +BACKEND="${KERNEL_BACKEND:-auto}" + +mkdir -p "$STATE_DIR" + +curl_policy() { + local dest="$1" + local code + code=$(curl -sS -o "$dest" -w "%{http_code}" \ + -H "Authorization: Bearer ${CLIENT_TOKEN}" \ + -H "Accept: application/json" \ + "${EVOFW_CP_URL%/}/v1/agent/policy") || return 1 + if [[ "$code" == "403" ]]; then + log "pending approval" + return 2 + fi + if [[ "$code" != "200" ]]; then + log "policy HTTP $code" + return 1 + fi + return 0 +} + +if ! curl_policy "$POLICY_FILE"; then + rc=$? + [[ "$rc" == "2" ]] && exit 0 + exit 1 +fi + +parse_policy() { + local f="$1" + if command -v jq >/dev/null 2>&1; then + HASH=$(jq -r '.hash // empty' "$f") + MODE=$(jq -r '.policy_mode // "blacklist"' "$f") + mapfile -t DENY < <(jq -r '.deny_cidrs[]? // empty' "$f") + mapfile -t ALLOW < <(jq -r '.allow_cidrs[]? // empty' "$f") + return 0 + fi + if command -v python3 >/dev/null 2>&1; then + eval "$(python3 - "$f" <<'PY' +import json,sys +d=json.load(open(sys.argv[1],encoding="utf-8")) +print(f'HASH={d.get("hash") or ""}') +print(f'MODE={d.get("policy_mode") or "blacklist"}') +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 []))+")") +PY +)" + return 0 + fi + log "need jq or python3" + exit 1 +} + +HASH=""; MODE=blacklist; DENY=(); ALLOW=() +parse_policy "$POLICY_FILE" +log "mode=$MODE deny=${#DENY[@]} allow=${#ALLOW[@]} hash=$HASH" + +PACKETS_DROPPED=0 +PACKETS_ACCEPTED=0 +KERNEL_METHOD="" +APPLIED=0 + +nft_join() { + local out="" p + for p in "$@"; do + [[ -n "$out" ]] && out+=", " + out+="$p" + done + printf '%s' "$out" +} + +nft_add_chunk() { + local table=$1 name=$2 setname=$3 + shift 3 + local joined; joined=$(nft_join "$@") + nft add element "$table" "$name" "$setname" "{ ${joined} }" 2>>"$LOG_FILE" || { + for p in "$@"; do nft add element "$table" "$name" "$setname" "{ $p }" 2>>"$LOG_FILE" || true; done + } +} + +collect_nft_stats() { + PACKETS_DROPPED=0; PACKETS_ACCEPTED=0 + local line + while IFS= read -r line; do + if [[ "$line" == *drop* && "$line" =~ packets[[:space:]]+([0-9]+) ]]; then + PACKETS_DROPPED="${BASH_REMATCH[1]}" + elif [[ "$line" == *accept* && "$line" =~ packets[[:space:]]+([0-9]+) ]]; then + PACKETS_ACCEPTED="${BASH_REMATCH[1]}" + fi + done < <(nft list chain inet evofw input 2>/dev/null || true) +} + +apply_nft() { + local table=inet name=evofw + local deny_v4=() allow_v4=() p + for p in "${DENY[@]}"; do [[ "$p" == *:* ]] && continue; deny_v4+=("$p"); done + for p in "${ALLOW[@]}"; do [[ "$p" == *:* ]] && continue; allow_v4+=("$p"); done + + nft list table "$table" "$name" >/dev/null 2>&1 || nft add table "$table" "$name" + nft list set "$table" "$name" deny_v4 >/dev/null 2>&1 || \ + nft add set "$table" "$name" deny_v4 '{ type ipv4_addr; flags interval; }' + nft list set "$table" "$name" allow_v4 >/dev/null 2>&1 || \ + nft add set "$table" "$name" allow_v4 '{ type ipv4_addr; flags interval; }' + nft flush set "$table" "$name" deny_v4 + nft flush set "$table" "$name" allow_v4 + + local batch=() chunk=64 + for p in "${deny_v4[@]}"; do + batch+=("$p") + if ((${#batch[@]} >= chunk)); then nft_add_chunk "$table" "$name" deny_v4 "${batch[@]}"; batch=(); fi + done + ((${#batch[@]})) && nft_add_chunk "$table" "$name" deny_v4 "${batch[@]}" + batch=() + for p in "${allow_v4[@]}"; do + batch+=("$p") + if ((${#batch[@]} >= chunk)); then nft_add_chunk "$table" "$name" allow_v4 "${batch[@]}"; batch=(); fi + done + ((${#batch[@]})) && nft_add_chunk "$table" "$name" allow_v4 "${batch[@]}" + + nft delete chain "$table" "$name" input 2>/dev/null || true + if [[ "$MODE" == "whitelist" ]]; then + nft add chain "$table" "$name" input '{ type filter hook input priority 0; policy drop; }' + nft add rule "$table" "$name" input ct state established,related counter accept + nft add rule "$table" "$name" input iif lo counter accept + nft add rule "$table" "$name" input ip saddr @allow_v4 counter accept + nft add rule "$table" "$name" input counter drop + else + nft add chain "$table" "$name" input '{ type filter hook input priority 0; policy accept; }' + nft add rule "$table" "$name" input ip saddr @deny_v4 counter drop + nft add rule "$table" "$name" input counter accept + fi + KERNEL_METHOD=nft + APPLIED=$((${#deny_v4[@]} + ${#allow_v4[@]})) +} + +apply_ipset() { + local dset=evofw_deny_v4 aset=evofw_allow_v4 + ipset list "$dset" >/dev/null 2>&1 || ipset create "$dset" hash:net family inet + ipset list "$aset" >/dev/null 2>&1 || ipset create "$aset" hash:net family inet + ipset flush "$dset"; ipset flush "$aset" + local p n=0 + for p in "${DENY[@]}"; do [[ "$p" == *:* ]] && continue; ipset add "$dset" "$p" -exist; n=$((n+1)); done + for p in "${ALLOW[@]}"; do [[ "$p" == *:* ]] && continue; ipset add "$aset" "$p" -exist; n=$((n+1)); done + iptables -D INPUT -m set --match-set "$dset" src -j DROP 2>/dev/null || true + iptables -D INPUT -m set --match-set "$aset" src -j ACCEPT 2>/dev/null || true + if [[ "$MODE" == "whitelist" ]]; then + iptables -I INPUT -m set --match-set "$aset" src -j ACCEPT + iptables -A INPUT -j DROP 2>/dev/null || true + else + iptables -I INPUT -m set --match-set "$dset" src -j DROP + fi + KERNEL_METHOD=ipset + APPLIED=$n +} + +send_report() { + if [[ "$KERNEL_METHOD" == "nft" ]] || command -v nft >/dev/null 2>&1; then + collect_nft_stats + fi + local report + report=$(printf '{"status":"ok","prefix_count":%s,"packets_dropped":%s,"packets_accepted":%s,"kernel_method":"%s","source":"agent"}' \ + "${APPLIED:-0}" "${PACKETS_DROPPED:-0}" "${PACKETS_ACCEPTED:-0}" "${KERNEL_METHOD:-$BACKEND}") + curl -fsS -X POST "${EVOFW_CP_URL%/}/v1/agent/apply-report" \ + -H "Authorization: Bearer ${CLIENT_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "$report" >/dev/null 2>&1 || true + curl -fsS -X POST "${EVOFW_CP_URL%/}/v1/agent/heartbeat" \ + -H "Authorization: Bearer ${CLIENT_TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{"source":"agent"}' >/dev/null 2>&1 || true +} + +if [[ -f "$HASH_FILE" && "$(tr -d '\r\n' <"$HASH_FILE")" == "$HASH" && -n "$HASH" ]]; then + log "unchanged hash $HASH — skip apply" + KERNEL_METHOD="${BACKEND}" + send_report + exit 0 +fi + +case "$BACKEND" in + nft|auto) + if command -v nft >/dev/null 2>&1; then apply_nft + elif command -v ipset >/dev/null 2>&1; then apply_ipset + else log "no backend"; exit 1; fi + ;; + ipset) apply_ipset ;; + *) apply_nft ;; +esac + +echo "$HASH" >"$HASH_FILE" +log "applied mode=$MODE count=$APPLIED method=$KERNEL_METHOD" +send_report diff --git a/apps/api/src/agent-scripts/install.sh b/apps/api/src/agent-scripts/install.sh new file mode 100644 index 0000000..286dab4 --- /dev/null +++ b/apps/api/src/agent-scripts/install.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# EvoFirewall Linux install one-liner +set -euo pipefail + +if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then + echo "evofw install: run as root" >&2 + exit 1 +fi + +for cmd in curl bash; do + command -v "$cmd" >/dev/null 2>&1 || { echo "missing $cmd" >&2; exit 1; } +done + +if ! command -v jq >/dev/null 2>&1 && ! command -v python3 >/dev/null 2>&1; then + if command -v apt-get >/dev/null 2>&1; then + apt-get update -qq && apt-get install -y -qq jq || true + fi +fi + +: "${EVOFW_CP_URL:?EVOFW_CP_URL required}" +: "${EVOFW_SEED:?EVOFW_SEED required}" +: "${EVOFW_CLIENT_NAME:?EVOFW_CLIENT_NAME required}" + +CONF_DIR=/etc/evofw +CONF_FILE="${CONF_DIR}/agent.conf" +SYNC_SCRIPT=/usr/local/sbin/evofw-firewall.sh +PLATFORM="${EVOFW_PLATFORM:-linux}" + +if [[ -f "$CONF_FILE" && "${EVOFW_INSTALL_FORCE:-}" != "1" ]]; then + echo "Already installed ($CONF_FILE). Set EVOFW_INSTALL_FORCE=1 to reinstall." >&2 + exit 1 +fi + +gen_token() { + if command -v openssl >/dev/null 2>&1; then + echo -n "evofw_$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=')" + else + echo -n "evofw_$(head -c 32 /dev/urandom | base64 | tr '+/' '-_' | tr -d '=\n')" + fi +} + +CLIENT_TOKEN="$(gen_token)" +HOSTNAME="$(hostname -f 2>/dev/null || hostname)" +CP_URL="${EVOFW_CP_URL%/}" + +ENROLL_BODY=$(printf '{"name":"%s","hostname":"%s","platform":"%s","token":"%s","client_version":"install.sh/1"}' \ + "$EVOFW_CLIENT_NAME" "$HOSTNAME" "$PLATFORM" "$CLIENT_TOKEN") + +ENROLL_TMP=$(mktemp) +trap 'rm -f "$ENROLL_TMP"' EXIT +ENROLL_CODE=$(curl -sS -o "$ENROLL_TMP" -w "%{http_code}" -X POST "${CP_URL}/v1/agent/enroll" \ + -H "Content-Type: application/json" \ + -H "X-EvoFW-Seed: ${EVOFW_SEED}" \ + -d "$ENROLL_BODY") +if [[ "$ENROLL_CODE" != "201" && "$ENROLL_CODE" != "200" ]]; then + echo "enroll failed: HTTP ${ENROLL_CODE}" >&2 + cat "$ENROLL_TMP" >&2 + exit 1 +fi +RESP=$(cat "$ENROLL_TMP") +CLIENT_ID="" +if command -v jq >/dev/null 2>&1; then + CLIENT_ID=$(echo "$RESP" | jq -r '.client_id // .id') +else + CLIENT_ID=$(echo "$RESP" | sed -n 's/.*"client_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p') +fi + +mkdir -p "$CONF_DIR" +chmod 700 "$CONF_DIR" +cat >"$CONF_FILE" </dev/null 2>&1; then + BACKEND=nft +elif command -v ipset >/dev/null 2>&1 && command -v iptables >/dev/null 2>&1; then + BACKEND=ipset +elif command -v iptables >/dev/null 2>&1; then + BACKEND=iptables +else + echo "no supported firewall backend" >&2 + exit 1 +fi +sed -i "s/^KERNEL_BACKEND=.*/KERNEL_BACKEND=${BACKEND}/" "$CONF_FILE" 2>/dev/null || \ + echo "KERNEL_BACKEND=${BACKEND}" >>"$CONF_FILE" + +INTERVAL="${EVOFW_SYNC_INTERVAL:-1min}" +if command -v systemctl >/dev/null 2>&1; then + cat >/etc/systemd/system/evofw-firewall.service <<'UNIT' +[Unit] +Description=EvoFirewall sync +After=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/evofw-firewall.sh +UNIT + cat >/etc/systemd/system/evofw-firewall.timer </dev/null | grep -v evofw-firewall; echo "*/1 * * * * $SYNC_SCRIPT") | crontab - +fi + +echo "Installed. Client id=${CLIENT_ID}. Approve in EvoFirewall UI, then: $SYNC_SCRIPT" diff --git a/apps/api/src/agent-scripts/mikrotik-install.rsc b/apps/api/src/agent-scripts/mikrotik-install.rsc new file mode 100644 index 0000000..a9aeea4 --- /dev/null +++ b/apps/api/src/agent-scripts/mikrotik-install.rsc @@ -0,0 +1,46 @@ +# EvoFirewall MikroTik install (RouterOS 7+) +# Usage: import after setting globals, or paste into terminal. +# Required globals before import (or edit below): +# :global EvofwCpUrl "https://fw.example.com" +# :global EvofwSeed "YOUR_SEED" +# :global EvofwName "mt-01" + +:global EvofwCpUrl +:global EvofwSeed +:global EvofwName + +:if ([:typeof $EvofwCpUrl] = "nothing") do={ :error "EvofwCpUrl required" } +:if ([:typeof $EvofwSeed] = "nothing") do={ :error "EvofwSeed required" } +:if ([:typeof $EvofwName] = "nothing") do={ :set EvofwName [/system identity get name] } + +:local token ("evofw_" . [/certificate scep-server nonce generate]) +:if ([:len $token] < 20) do={ + :set token ("evofw_" . [:tostr [/system clock get time]] . [:tostr [/system resource get cpu-load]]) +} + +:local body ("{\"name\":\"" . $EvofwName . "\",\"hostname\":\"" . [/system identity get name] . "\",\"platform\":\"mikrotik\",\"token\":\"" . $token . "\",\"client_version\":\"rsc/1\"}") + +/tool fetch url=($EvofwCpUrl . "/v1/agent/enroll") http-method=post http-header-field=("Content-Type: application/json,X-EvoFW-Seed: " . $EvofwSeed) http-data=$body keep-result=no + +# Persist credentials for scheduler script +/system script remove [find name="evofw-env"] +/system script add name=evofw-env source=(" :global EvofwCpUrl \"" . $EvofwCpUrl . "\"; :global EvofwToken \"" . $token . "\" ") + +/system script remove [find name="evofw-sync"] +/system script add name=evofw-sync policy=read,write,policy,test source={ + :global EvofwCpUrl + :global EvofwToken + :if ([:typeof $EvofwCpUrl] = "nothing" || [:typeof $EvofwToken] = "nothing") do={ /system script run evofw-env } + :local tmp [/file get [find name="evofw-policy.json"] name] + /tool fetch url=($EvofwCpUrl . "/v1/agent/policy") http-header-field=("Authorization: Bearer " . $EvofwToken) dst-path=evofw-policy.json + # Address-lists: EVOFW_DENY / EVOFW_ALLOW — operator should map filter rules once: + # /ip firewall filter add chain=input src-address-list=EVOFW_DENY action=drop comment=evofw + # whitelist: policy drop + accept EVOFW_ALLOW + :log info "evofw: policy fetched — apply address-lists via controller export or manual parse" + /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 +} + +/system scheduler remove [find name="evofw-sync"] +/system scheduler add name=evofw-sync interval=1m on-event=evofw-sync + +:put ("EvoFirewall enrolled as " . $EvofwName . " — approve in UI, ensure filter rules for EVOFW_* lists") diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts new file mode 100644 index 0000000..70d1697 --- /dev/null +++ b/apps/api/src/app.ts @@ -0,0 +1,96 @@ +import { resolve } from 'node:path' +import Fastify from 'fastify' +import { + serializerCompiler, + validatorCompiler, + type ZodTypeProvider, +} from '@fastify/type-provider-zod' +import { AsyncTask, CronJob } from 'toad-scheduler' +import type { AppConfig } from './config.js' +import { loadConfig } from './config.js' +import authPlugin from './plugins/auth.js' +import corsPlugin from './plugins/cors.js' +import dbPlugin from './plugins/db.js' +import errorHandlerPlugin from './plugins/error-handler.js' +import { healthRoutes } from './routes/health.js' +import { controlRoutes } from './routes/control.js' +import { agentRoutes } from './routes/agent.js' +import { refreshAllLists } from './services/lists/refresh.js' +import { repos } from '@evofw/db' + +export interface BuildAppOptions { + config?: AppConfig + memory?: boolean +} + +export async function buildApp(opts: BuildAppOptions = {}) { + const config = opts.config ?? loadConfig() + + const app = Fastify({ + logger: { level: config.logLevel }, + }).withTypeProvider() + + app.setValidatorCompiler(validatorCompiler) + app.setSerializerCompiler(serializerCompiler) + + await app.register(import('@fastify/sensible')) + await app.register(import('@fastify/helmet'), { + contentSecurityPolicy: false, + }) + await app.register(import('@fastify/rate-limit'), { + max: 300, + timeWindow: '1 minute', + }) + await app.register(corsPlugin) + await app.register(errorHandlerPlugin) + await app.register(dbPlugin, { config, memory: opts.memory }) + await app.register(authPlugin, { config }) + + // Seed enroll_seed into settings if empty + if (!repos.getSetting(app.db, 'enroll_seed')) { + repos.setSetting(app.db, 'enroll_seed', config.enrollSeed) + } + + await app.register(healthRoutes) + await app.register(agentRoutes, { config }) + + await app.register( + async (protectedApi) => { + protectedApi.addHook('onRequest', app.requireAuth) + await protectedApi.register(controlRoutes, { config }) + }, + { prefix: '/api/v1' }, + ) + + const staticDir = config.staticDir ?? resolve(process.cwd(), 'static') + if (config.staticDir !== null) { + await app.register(import('@fastify/static'), { + root: staticDir, + wildcard: false, + }) + app.setNotFoundHandler(async (_request, reply) => { + return reply.sendFile('index.html') + }) + } + + if (!opts.memory) { + await app.register(import('@fastify/schedule')) + const task = new AsyncTask( + 'list-refresh', + async () => { + await refreshAllLists(app.db) + app.log.info('list refresh completed') + }, + (err) => { + app.log.warn({ err }, 'list refresh failed') + }, + ) + app.scheduler.addCronJob( + new CronJob({ cronExpression: '0 */5 * * * *' }, task, { + preventOverrun: true, + }), + ) + } + + return app +} diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts new file mode 100644 index 0000000..6bed92d --- /dev/null +++ b/apps/api/src/config.ts @@ -0,0 +1,55 @@ +import { resolve } from 'node:path' + +export interface AppConfig { + databaseUrl: string + jwtSecret: string + jwtTtlHours: number + serverPort: number + staticDir: string | null + logLevel: string + authRequired: boolean + authIssuer: string + authPortalUrl: string + publicBaseUrl: string + enrollSeed: string +} + +function boolEnv(v: string | undefined, fallback: boolean): boolean { + if (v === undefined || v === '') return fallback + return v === '1' || v.toLowerCase() === 'true' +} + +export function loadConfig(): AppConfig { + const isProd = process.env.NODE_ENV === 'production' + const jwtSecret = + process.env.AUTH_JWT_SECRET ?? + process.env.JWT_SECRET ?? + (isProd ? '' : 'dev-secret-change-me') + + return { + databaseUrl: process.env.DATABASE_URL ?? 'sqlite:data/app.db', + jwtSecret: jwtSecret || 'dev-secret-change-me', + jwtTtlHours: Number(process.env.JWT_TTL_HOURS ?? '24') || 24, + serverPort: Number(process.env.SERVER_PORT ?? '8080') || 8080, + staticDir: process.env.STATIC_DIR + ? resolve(process.env.STATIC_DIR) + : null, + logLevel: process.env.LOG_LEVEL ?? 'info', + authRequired: boolEnv(process.env.AUTH_REQUIRED, false), + authIssuer: + process.env.AUTH_ISSUER ?? process.env.ISSUER ?? 'https://auth.shnt.top', + authPortalUrl: ( + process.env.AUTH_PORTAL_URL ?? + process.env.VITE_AUTH_PORTAL_URL ?? + 'http://localhost:5175' + ).replace(/\/$/, ''), + publicBaseUrl: ( + process.env.PUBLIC_BASE_URL ?? + `http://localhost:${process.env.SERVER_PORT ?? '8080'}` + ).replace(/\/$/, ''), + enrollSeed: + process.env.EVOFW_ENROLL_SEED ?? + process.env.BUNDLE_SEED_HEX ?? + 'dev-enroll-seed-change-me', + } +} diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts new file mode 100644 index 0000000..19cffa5 --- /dev/null +++ b/apps/api/src/plugins/auth.ts @@ -0,0 +1,217 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' +import fp from 'fastify-plugin' +import { + hasPermission, + permissionForRequest, + type AuthUser, +} from '@evofw/shared' +import type { AppConfig } from '../config.js' +import { createHash } from 'node:crypto' +import { repos } from '@evofw/db' + +declare module 'fastify' { + interface FastifyRequest { + authUser?: AuthUser + agentId?: string + } +} + +declare module '@fastify/jwt' { + interface FastifyJWT { + payload: { + sub: string + email?: string + name?: string + apps?: string[] + permissions?: string[] + is_admin?: boolean + iss?: string + } + user: { + sub: string + email?: string + name?: string + apps?: string[] + permissions?: string[] + is_admin?: boolean + iss?: string + } + } +} + +function hashToken(token: string): string { + return createHash('sha256').update(token).digest('hex') +} + +function isPublicPath(url: string): boolean { + const path = url.split('?')[0] ?? url + if (path === '/health' || path === '/ready') return true + if (path === '/api/auth/config' || path === '/api/v1/auth/config') return true + if (path.startsWith('/v1/agent/enroll')) return true + if (path.startsWith('/v1/agent/install')) return true + if (path.startsWith('/v1/agent/sync-script')) return true + if (path.startsWith('/v1/agent/mikrotik')) return true + return false +} + +function isAgentPath(url: string): boolean { + const path = url.split('?')[0] ?? url + return ( + path === '/v1/agent/policy' || + path === '/v1/agent/apply-report' || + path === '/v1/agent/heartbeat' + ) +} + +async function authPlugin( + app: FastifyInstance, + opts: { config: AppConfig }, +) { + const { config } = opts + + app.get('/api/auth/config', async () => ({ + required: config.authRequired, + portal_url: config.authPortalUrl, + })) + app.get('/api/v1/auth/config', async () => ({ + required: config.authRequired, + portal_url: config.authPortalUrl, + })) + + if (config.authRequired) { + if (!config.jwtSecret || config.jwtSecret.length < 8) { + throw new Error( + 'AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true', + ) + } + await app.register(import('@fastify/jwt'), { + secret: config.jwtSecret, + verify: { allowedIss: [config.authIssuer] }, + }) + } else { + await app.register(import('@fastify/jwt'), { + secret: config.jwtSecret || 'dev-secret-change-me', + }) + } + + app.decorate( + 'requireAuth', + async (request: FastifyRequest, reply: FastifyReply) => { + if (!config.authRequired) { + request.authUser = { + id: 'dev', + email: 'dev@local', + name: 'Dev', + apps: ['fw'], + permissions: [ + 'fw:dashboard:read', + 'fw:agents:write', + 'fw:lists:write', + 'fw:policies:write', + 'fw:stats:read', + 'fw:settings:admin', + ], + isAdmin: true, + } + return + } + + try { + await request.jwtVerify() + } catch { + return reply.code(401).send({ + error: { code: 'UNAUTHORIZED', message: 'Требуется авторизация' }, + }) + } + + const payload = request.user + const apps = Array.isArray(payload.apps) + ? payload.apps.map(String) + : [] + const permissions = Array.isArray(payload.permissions) + ? payload.permissions.map(String) + : [] + + if (!apps.includes('fw') && !payload.is_admin) { + return reply.code(403).send({ + error: { + code: 'FORBIDDEN', + message: 'Нет доступа к приложению EvoFirewall', + }, + }) + } + + request.authUser = { + id: String(payload.sub), + email: String(payload.email ?? ''), + name: String(payload.name ?? ''), + apps, + permissions: payload.is_admin + ? [ + 'fw:dashboard:read', + 'fw:agents:write', + 'fw:lists:write', + 'fw:policies:write', + 'fw:stats:read', + 'fw:settings:admin', + ] + : permissions, + isAdmin: Boolean(payload.is_admin), + } + + const required = permissionForRequest(request.method, request.url) + if ( + required && + !request.authUser.isAdmin && + !hasPermission(request.authUser.permissions, required) + ) { + return reply.code(403).send({ + error: { + code: 'FORBIDDEN', + message: `Недостаточно прав: ${required}`, + }, + }) + } + }, + ) + + app.addHook('onRequest', async (request, reply) => { + if (isPublicPath(request.url)) return + + if (isAgentPath(request.url)) { + const auth = request.headers.authorization + if (!auth?.startsWith('Bearer ')) { + return reply.code(401).send({ + error: { code: 'UNAUTHORIZED', message: 'Agent token required' }, + }) + } + const token = auth.slice('Bearer '.length).trim() + const agent = repos.getAgentByTokenHash(app.db, hashToken(token)) + if (!agent || agent.status !== 'approved') { + return reply.code(agent?.status === 'pending' ? 403 : 401).send({ + error: { + code: agent?.status === 'pending' ? 'FORBIDDEN' : 'UNAUTHORIZED', + message: + agent?.status === 'pending' + ? 'Agent pending approval' + : 'Invalid agent token', + }, + }) + } + request.agentId = agent.id + return + } + }) +} + +declare module 'fastify' { + interface FastifyInstance { + requireAuth: ( + request: FastifyRequest, + reply: FastifyReply, + ) => Promise + } +} + +export default fp(authPlugin, { name: 'auth' }) +export { hashToken } diff --git a/apps/api/src/plugins/cors.ts b/apps/api/src/plugins/cors.ts new file mode 100644 index 0000000..6293316 --- /dev/null +++ b/apps/api/src/plugins/cors.ts @@ -0,0 +1,8 @@ +import type { FastifyInstance } from 'fastify' +import fp from 'fastify-plugin' + +async function corsPlugin(app: FastifyInstance) { + await app.register(import('@fastify/cors'), { origin: true }) +} + +export default fp(corsPlugin, { name: 'cors' }) diff --git a/apps/api/src/plugins/db.ts b/apps/api/src/plugins/db.ts new file mode 100644 index 0000000..ddef868 --- /dev/null +++ b/apps/api/src/plugins/db.ts @@ -0,0 +1,38 @@ +import type { FastifyInstance } from 'fastify' +import fp from 'fastify-plugin' +import { + createDb, + createMemoryDb, + runMigrations, + type Db, + type Sqlite, +} from '@evofw/db' +import type { AppConfig } from '../config.js' + +declare module 'fastify' { + interface FastifyInstance { + db: Db + sqlite: Sqlite + } +} + +export interface DbPluginOptions { + config?: AppConfig + memory?: boolean +} + +async function dbPlugin(app: FastifyInstance, opts: DbPluginOptions) { + const { db, sqlite } = opts.memory + ? createMemoryDb() + : createDb(opts.config!.databaseUrl) + + runMigrations(sqlite) + app.decorate('db', db) + app.decorate('sqlite', sqlite) + + app.addHook('onClose', async () => { + sqlite.close() + }) +} + +export default fp(dbPlugin, { name: 'db' }) diff --git a/apps/api/src/plugins/error-handler.ts b/apps/api/src/plugins/error-handler.ts new file mode 100644 index 0000000..8341ed0 --- /dev/null +++ b/apps/api/src/plugins/error-handler.ts @@ -0,0 +1,38 @@ +import type { FastifyInstance } from 'fastify' +import fp from 'fastify-plugin' + +export class AppError extends Error { + constructor( + public code: string, + message: string, + public statusCode = 400, + ) { + super(message) + this.name = 'AppError' + } +} + +async function errorHandlerPlugin(app: FastifyInstance) { + app.setErrorHandler((err, _req, reply) => { + if (err instanceof AppError) { + return reply.code(err.statusCode).send({ + error: { code: err.code, message: err.message }, + }) + } + const e = err as { statusCode?: number; message?: string } + const status = e.statusCode ?? 500 + const message = + status >= 500 + ? 'Внутренняя ошибка сервера' + : e.message || 'Ошибка запроса' + app.log.error(err) + return reply.code(status).send({ + error: { + code: status >= 500 ? 'INTERNAL_ERROR' : 'VALIDATION_ERROR', + message, + }, + }) + }) +} + +export default fp(errorHandlerPlugin, { name: 'error-handler' }) diff --git a/apps/api/src/routes/agent.ts b/apps/api/src/routes/agent.ts new file mode 100644 index 0000000..00b3c94 --- /dev/null +++ b/apps/api/src/routes/agent.ts @@ -0,0 +1,136 @@ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { FastifyPluginAsync } from 'fastify' +import { repos } from '@evofw/db' +import { enrollBodySchema, applyReportBodySchema } from '@evofw/shared' +import type { AppConfig } from '../config.js' +import { hashToken } from '../plugins/auth.js' +import { evaluateAgentPolicy } from '../services/policy/evaluate.js' +import { AppError } from '../plugins/error-handler.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const scriptsDir = join(__dirname, '../agent-scripts') + +export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( + app, + opts, +) => { + const { config } = opts + + app.get('/v1/agent/install.sh', async (_req, reply) => { + const body = readFileSync(join(scriptsDir, 'install.sh'), 'utf-8') + return reply.type('text/x-shellscript').send(body) + }) + + app.get('/v1/agent/sync-script', async (_req, reply) => { + const body = readFileSync(join(scriptsDir, 'evofw-firewall.sh'), 'utf-8') + return reply.type('text/x-shellscript').send(body) + }) + + app.get('/v1/agent/mikrotik-install.rsc', async (_req, reply) => { + const body = readFileSync( + join(scriptsDir, 'mikrotik-install.rsc'), + 'utf-8', + ) + return reply.type('text/plain').send(body) + }) + + app.post('/v1/agent/enroll', async (req, reply) => { + const seed = req.headers['x-evofw-seed'] + const expected = + repos.getSetting(app.db, 'enroll_seed') || config.enrollSeed + if (!seed || String(seed) !== expected) { + throw new AppError('UNAUTHORIZED', 'Invalid enroll seed', 401) + } + const body = enrollBodySchema.parse(req.body) + const id = crypto.randomUUID() + const tokenHash = hashToken(body.token) + const existing = repos.getAgentByTokenHash(app.db, tokenHash) + if (existing) { + throw new AppError('CONFLICT', 'Token already enrolled', 409) + } + const agent = repos.insertAgent(app.db, { + id, + name: body.name, + hostname: body.hostname ?? null, + platform: body.platform ?? 'linux', + tokenPrefix: body.token.slice(0, 12), + tokenHash, + status: 'pending', + policyMode: 'blacklist', + policyGeneration: 1, + clientVersion: body.client_version ?? null, + settingsJson: '{}', + createdAt: new Date().toISOString(), + }) + return reply.code(201).send({ + client_id: agent!.id, + id: agent!.id, + status: agent!.status, + name: agent!.name, + }) + }) + + app.get('/v1/agent/policy', async (req) => { + const agentId = req.agentId! + const policy = evaluateAgentPolicy(app.db, agentId) + repos.updateAgent(app.db, agentId, { + lastSeenAt: new Date().toISOString(), + lastSeenIp: req.ip, + }) + return { + generation: policy.generation, + hash: policy.hash, + policy_mode: policy.policyMode, + deny_cidrs: policy.denyCidrs, + allow_cidrs: policy.allowCidrs, + sync_interval_sec: policy.syncIntervalSec, + // compat aliases for simple clients + prefixes: + policy.policyMode === 'blacklist' + ? policy.denyCidrs + : policy.allowCidrs, + total: + policy.policyMode === 'blacklist' + ? policy.denyCidrs.length + : policy.allowCidrs.length, + } + }) + + app.post('/v1/agent/apply-report', async (req) => { + const agentId = req.agentId! + const body = applyReportBodySchema.parse(req.body) + const now = new Date().toISOString() + repos.updateAgent(app.db, agentId, { + lastApplyAt: now, + lastApplyStatus: body.status, + lastApplyError: body.error ?? null, + lastApplyPrefixCount: body.prefix_count ?? 0, + lastApplyPacketsDropped: body.packets_dropped ?? 0, + lastApplyPacketsAccepted: body.packets_accepted ?? 0, + lastApplyKernelMethod: body.kernel_method ?? null, + lastSeenAt: now, + lastSeenIp: req.ip, + }) + repos.insertStatsSample(app.db, { + id: crypto.randomUUID(), + agentId, + packetsDropped: body.packets_dropped ?? 0, + packetsAccepted: body.packets_accepted ?? 0, + prefixCount: body.prefix_count ?? 0, + kernelMethod: body.kernel_method ?? null, + recordedAt: now, + }) + return { ok: true } + }) + + app.post('/v1/agent/heartbeat', async (req) => { + const agentId = req.agentId! + repos.updateAgent(app.db, agentId, { + lastSeenAt: new Date().toISOString(), + lastSeenIp: req.ip, + }) + return { ok: true } + }) +} diff --git a/apps/api/src/routes/control.ts b/apps/api/src/routes/control.ts new file mode 100644 index 0000000..960dfc3 --- /dev/null +++ b/apps/api/src/routes/control.ts @@ -0,0 +1,413 @@ +import type { FastifyPluginAsync } from 'fastify' +import { repos } from '@evofw/db' +import { + createOverrideBodySchema, + createIpListBodySchema, + createPolicyRuleBodySchema, + patchAgentBodySchema, + cloneFromBodySchema, +} from '@evofw/shared' +import { AppError } from '../plugins/error-handler.js' +import { refreshIpList } from '../services/lists/refresh.js' +import { evaluateAgentPolicy } from '../services/policy/evaluate.js' +import type { AppConfig } from '../config.js' + +function mapAgent(a: NonNullable>) { + return { + id: a.id, + name: a.name, + hostname: a.hostname, + platform: a.platform, + token_prefix: a.tokenPrefix, + status: a.status, + policy_mode: a.policyMode, + policy_generation: a.policyGeneration, + last_seen_at: a.lastSeenAt, + last_seen_ip: a.lastSeenIp, + last_apply_at: a.lastApplyAt, + last_apply_status: a.lastApplyStatus, + last_apply_error: a.lastApplyError, + last_apply_prefix_count: a.lastApplyPrefixCount, + last_apply_packets_dropped: a.lastApplyPacketsDropped, + last_apply_packets_accepted: a.lastApplyPacketsAccepted, + last_apply_kernel_method: a.lastApplyKernelMethod, + client_version: a.clientVersion, + created_at: a.createdAt, + approved_at: a.approvedAt, + revoked_at: a.revokedAt, + } +} + +export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async ( + app, + opts, +) => { + const { config } = opts + + app.get('/dashboard', async () => { + const all = repos.listAgents(app.db) + const now = Date.now() + const online = all.filter((a) => { + if (!a.lastSeenAt || a.status !== 'approved') return false + return now - Date.parse(a.lastSeenAt) < 5 * 60_000 + }) + return { + agents_total: all.length, + agents_approved: all.filter((a) => a.status === 'approved').length, + agents_online: online.length, + agents_pending: all.filter((a) => a.status === 'pending').length, + packets_dropped: all.reduce( + (s, a) => s + (a.lastApplyPacketsDropped ?? 0), + 0, + ), + packets_accepted: all.reduce( + (s, a) => s + (a.lastApplyPacketsAccepted ?? 0), + 0, + ), + lists_total: repos.listIpLists(app.db).length, + } + }) + + app.get('/install-context', async () => { + const seed = + repos.getSetting(app.db, 'enroll_seed') || config.enrollSeed + return { + suggested_cp_url: config.publicBaseUrl, + enroll_seed: seed, + install_sh_url: `${config.publicBaseUrl}/v1/agent/install.sh`, + mikrotik_url: `${config.publicBaseUrl}/v1/agent/mikrotik-install.rsc`, + sync_interval_sec: Number( + repos.getSetting(app.db, 'agent_sync_interval_sec') || '60', + ), + } + }) + + // Agents + app.get('/agents', async () => ({ + items: repos.listAgents(app.db).map(mapAgent), + })) + + app.get<{ Params: { id: string } }>('/agents/:id', async (req) => { + const a = repos.getAgent(app.db, req.params.id) + if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404) + return mapAgent(a) + }) + + app.get<{ Params: { id: string } }>('/agents/:id/preview', async (req) => { + const a = repos.getAgent(app.db, req.params.id) + if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404) + const policy = evaluateAgentPolicy(app.db, a.id) + return { + ...policy, + deny_cidrs: policy.denyCidrs, + allow_cidrs: policy.allowCidrs, + policy_mode: policy.policyMode, + sync_interval_sec: policy.syncIntervalSec, + } + }) + + app.patch<{ Params: { id: string } }>('/agents/:id', async (req) => { + const body = patchAgentBodySchema.parse(req.body) + const a = repos.getAgent(app.db, req.params.id) + if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404) + const updated = repos.updateAgent(app.db, a.id, { + name: body.name, + policyMode: body.policy_mode, + settingsJson: body.settings + ? JSON.stringify(body.settings) + : undefined, + policyGeneration: + body.policy_mode && body.policy_mode !== a.policyMode + ? a.policyGeneration + 1 + : a.policyGeneration, + }) + return mapAgent(updated!) + }) + + app.post<{ Params: { id: string } }>('/agents/:id/approve', async (req) => { + const a = repos.getAgent(app.db, req.params.id) + if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404) + const updated = repos.updateAgent(app.db, a.id, { + status: 'approved', + approvedAt: new Date().toISOString(), + }) + return mapAgent(updated!) + }) + + app.post<{ Params: { id: string } }>('/agents/:id/revoke', async (req) => { + const a = repos.getAgent(app.db, req.params.id) + if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404) + const updated = repos.updateAgent(app.db, a.id, { + status: 'revoked', + revokedAt: new Date().toISOString(), + }) + return mapAgent(updated!) + }) + + app.delete<{ Params: { id: string } }>('/agents/:id', async (req) => { + repos.deleteAgent(app.db, req.params.id) + return { ok: true } + }) + + app.post<{ Params: { id: string; sourceId: string } }>( + '/agents/:id/clone-from/:sourceId', + async (req) => { + const body = cloneFromBodySchema.parse(req.body ?? {}) + const updated = repos.cloneRulesFrom( + app.db, + req.params.sourceId, + req.params.id, + body.include_overrides ?? false, + ) + if (!updated) throw new AppError('NOT_FOUND', 'Agent not found', 404) + return mapAgent(updated) + }, + ) + + // Overrides + app.get<{ Params: { id: string } }>( + '/agents/:id/overrides', + async (req) => ({ + items: repos.listOverrides(app.db, req.params.id).map((o) => ({ + id: o.id, + agent_id: o.agentId, + cidr: o.cidr, + action: o.action, + comment: o.comment, + created_at: o.createdAt, + })), + }), + ) + + app.post<{ Params: { id: string } }>( + '/agents/:id/overrides', + async (req) => { + const body = createOverrideBodySchema.parse(req.body) + const a = repos.getAgent(app.db, req.params.id) + if (!a) throw new AppError('NOT_FOUND', 'Agent not found', 404) + const row = repos.insertOverride(app.db, { + id: crypto.randomUUID(), + agentId: a.id, + cidr: body.cidr, + action: body.action, + comment: body.comment ?? null, + createdByUserId: req.authUser?.id, + createdAt: new Date().toISOString(), + }) + repos.bumpAgentGeneration(app.db, a.id) + return { + id: row!.id, + agent_id: row!.agentId, + cidr: row!.cidr, + action: row!.action, + comment: row!.comment, + created_at: row!.createdAt, + } + }, + ) + + app.delete<{ Params: { id: string; overrideId: string } }>( + '/agents/:id/overrides/:overrideId', + async (req) => { + repos.deleteOverride(app.db, req.params.overrideId) + repos.bumpAgentGeneration(app.db, req.params.id) + return { ok: true } + }, + ) + + // Lists + app.get('/lists', async () => { + const items = repos.listIpLists(app.db).map((l) => ({ + id: l.id, + name: l.name, + type: l.type, + config_json: l.configJson, + content_hash: l.contentHash, + refreshed_at: l.refreshedAt, + last_error: l.lastError, + entry_count: repos.listIpListEntries(app.db, l.id).length, + created_at: l.createdAt, + updated_at: l.updatedAt, + })) + return { items } + }) + + app.post('/lists', async (req) => { + const body = createIpListBodySchema.parse(req.body) + const id = crypto.randomUUID() + const list = repos.insertIpList(app.db, { + id, + name: body.name, + type: body.type, + configJson: JSON.stringify(body.config ?? {}), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }) + if (body.entries?.length) { + repos.replaceIpListEntries(app.db, id, body.entries) + } + if (body.type !== 'static') { + await refreshIpList(app.db, id) + } + return { + id: list!.id, + name: list!.name, + type: list!.type, + config_json: list!.configJson, + created_at: list!.createdAt, + updated_at: list!.updatedAt, + } + }) + + app.get<{ Params: { id: string } }>('/lists/:id', async (req) => { + const l = repos.getIpList(app.db, req.params.id) + if (!l) throw new AppError('NOT_FOUND', 'List not found', 404) + return { + id: l.id, + name: l.name, + type: l.type, + config_json: l.configJson, + content_hash: l.contentHash, + refreshed_at: l.refreshedAt, + last_error: l.lastError, + entries: repos.listIpListEntries(app.db, l.id).map((e) => e.cidr), + created_at: l.createdAt, + updated_at: l.updatedAt, + } + }) + + app.post<{ Params: { id: string } }>('/lists/:id/refresh', async (req) => { + await refreshIpList(app.db, req.params.id) + const l = repos.getIpList(app.db, req.params.id) + if (!l) throw new AppError('NOT_FOUND', 'List not found', 404) + return { + id: l.id, + content_hash: l.contentHash, + refreshed_at: l.refreshedAt, + last_error: l.lastError, + entry_count: repos.listIpListEntries(app.db, l.id).length, + } + }) + + app.delete<{ Params: { id: string } }>('/lists/:id', async (req) => { + repos.deleteIpList(app.db, req.params.id) + return { ok: true } + }) + + // Rules + app.get<{ Querystring: { agent_id?: string } }>('/rules', async (req) => { + const agentId = + req.query.agent_id === 'tenant' || req.query.agent_id === '' + ? null + : req.query.agent_id + const items = ( + agentId === undefined + ? repos.listPolicyRules(app.db) + : repos.listPolicyRules(app.db, agentId) + ).map((r) => ({ + id: r.id, + agent_id: r.agentId, + priority: r.priority, + action: r.action, + list_id: r.listId, + cidr: r.cidr, + comment: r.comment, + created_at: r.createdAt, + updated_at: r.updatedAt, + })) + return { items } + }) + + app.post('/rules', async (req) => { + const body = createPolicyRuleBodySchema.parse(req.body) + if (!body.list_id && !body.cidr) { + throw new AppError('VALIDATION_ERROR', 'list_id or cidr required') + } + const row = repos.insertPolicyRule(app.db, { + id: crypto.randomUUID(), + agentId: body.agent_id ?? null, + priority: body.priority, + action: body.action, + listId: body.list_id ?? null, + cidr: body.cidr ?? null, + comment: body.comment ?? null, + createdByUserId: req.authUser?.id, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }) + if (body.agent_id) repos.bumpAgentGeneration(app.db, body.agent_id) + else { + for (const a of repos.listAgents(app.db)) { + if (a.status === 'approved') repos.bumpAgentGeneration(app.db, a.id) + } + } + return { + id: row!.id, + agent_id: row!.agentId, + priority: row!.priority, + action: row!.action, + list_id: row!.listId, + cidr: row!.cidr, + comment: row!.comment, + created_at: row!.createdAt, + updated_at: row!.updatedAt, + } + }) + + app.delete<{ Params: { id: string } }>('/rules/:id', async (req) => { + const rule = repos.getPolicyRule(app.db, req.params.id) + repos.deletePolicyRule(app.db, req.params.id) + if (rule?.agentId) repos.bumpAgentGeneration(app.db, rule.agentId) + return { ok: true } + }) + + // Stats + app.get<{ Params: { id: string } }>('/agents/:id/stats', async (req) => ({ + items: repos.listStatsSamples(app.db, req.params.id).map((s) => ({ + id: s.id, + agent_id: s.agentId, + packets_dropped: s.packetsDropped, + packets_accepted: s.packetsAccepted, + prefix_count: s.prefixCount, + kernel_method: s.kernelMethod, + recorded_at: s.recordedAt, + })), + })) + + app.get('/stats/recent', async () => ({ + items: repos.listRecentStats(app.db).map((s) => ({ + id: s.id, + agent_id: s.agentId, + packets_dropped: s.packetsDropped, + packets_accepted: s.packetsAccepted, + prefix_count: s.prefixCount, + kernel_method: s.kernelMethod, + recorded_at: s.recordedAt, + })), + })) + + // Settings + app.get('/settings', async () => { + const rows = repos.listSettings(app.db) + const map: Record = {} + for (const r of rows) { + if (r.key === 'evobgp_api_token' && r.value) { + map[r.key] = '********' + } else { + map[r.key] = r.value + } + } + if (!map.enroll_seed) map.enroll_seed = config.enrollSeed + return map + }) + + app.put('/settings', async (req) => { + const body = req.body as Record + for (const [k, v] of Object.entries(body)) { + if (typeof v !== 'string') continue + if (k === 'evobgp_api_token' && v === '********') continue + repos.setSetting(app.db, k, v) + } + return { ok: true } + }) +} diff --git a/apps/api/src/routes/health.ts b/apps/api/src/routes/health.ts new file mode 100644 index 0000000..62d8ce4 --- /dev/null +++ b/apps/api/src/routes/health.ts @@ -0,0 +1,14 @@ +import type { FastifyPluginAsync } from 'fastify' +import { healthCheck } from '@evofw/db' + +export const healthRoutes: FastifyPluginAsync = async (app) => { + app.get('/health', async () => ({ status: 'ok', service: 'evofirewall' })) + app.get('/ready', async (_req, reply) => { + try { + healthCheck(app.sqlite) + return { status: 'ready' } + } catch { + return reply.code(503).send({ status: 'not_ready' }) + } + }) +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts new file mode 100644 index 0000000..4303450 --- /dev/null +++ b/apps/api/src/server.ts @@ -0,0 +1,40 @@ +import { readFileSync, existsSync } from 'node:fs' +import { resolve } from 'node:path' +import { buildApp } from './app.js' +import { loadConfig } from './config.js' + +for (const path of [ + resolve(import.meta.dirname, '../../../.env'), + '.env', + '../.env', +]) { + if (!existsSync(path)) continue + const content = readFileSync(path, 'utf-8') + for (const line of content.split('\n')) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) continue + const eq = trimmed.indexOf('=') + if (eq === -1) continue + const key = trimmed.slice(0, eq).trim() + let value = trimmed.slice(eq + 1).trim() + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1) + } + if (!(key in process.env)) process.env[key] = value + } + break +} + +const config = loadConfig() +const app = await buildApp({ config }) + +try { + await app.listen({ port: config.serverPort, host: '0.0.0.0' }) + app.log.info(`listening on ${config.serverPort}`) +} catch (err) { + app.log.error(err) + process.exit(1) +} diff --git a/apps/api/src/services/lists/refresh.ts b/apps/api/src/services/lists/refresh.ts new file mode 100644 index 0000000..32662b7 --- /dev/null +++ b/apps/api/src/services/lists/refresh.ts @@ -0,0 +1,174 @@ +import { createHash } from 'node:crypto' +import { resolve4, resolve6 } from 'node:dns/promises' +import type { Db } from '@evofw/db' +import { repos } from '@evofw/db' + +function uniq(cidrs: string[]): string[] { + return [...new Set(cidrs.map((c) => c.trim()).filter(Boolean))].sort() +} + +function hashCidrs(cidrs: string[]): string { + return `sha256:${createHash('sha256').update(cidrs.join('\n')).digest('hex')}` +} + +async function fetchJsonUrl(url: string): Promise { + const res = await fetch(url, { + headers: { Accept: 'application/json' }, + signal: AbortSignal.timeout(30_000), + }) + if (!res.ok) throw new Error(`JSON URL HTTP ${res.status}`) + const data = (await res.json()) as unknown + const out: string[] = [] + const push = (v: unknown) => { + if (typeof v === 'string' && v.trim()) out.push(v.trim()) + } + if (Array.isArray(data)) { + for (const item of data) { + if (typeof item === 'string') push(item) + else if (item && typeof item === 'object') { + const o = item as Record + push(o.cidr ?? o.prefix ?? o.ip ?? o.network) + } + } + } else if (data && typeof data === 'object') { + const o = data as Record + const arr = (o.prefixes ?? o.cidrs ?? o.ips ?? o.items) as unknown + if (Array.isArray(arr)) { + for (const item of arr) { + if (typeof item === 'string') push(item) + else if (item && typeof item === 'object') { + const x = item as Record + push(x.cidr ?? x.prefix ?? x.ip) + } + } + } + } + return uniq(out) +} + +async function resolveDomains(domains: string[]): Promise { + const out: string[] = [] + for (const d of domains) { + const host = d.trim().replace(/\.$/, '') + if (!host) continue + try { + const a = await resolve4(host) + out.push(...a.map((ip) => `${ip}/32`)) + } catch { + /* ignore */ + } + try { + const aaaa = await resolve6(host) + out.push(...aaaa.map((ip) => `${ip}/128`)) + } catch { + /* ignore */ + } + } + return uniq(out) +} + +async function fetchEvobgpCommunity( + apiUrl: string, + token: string, + communityId: string, +): Promise { + const base = apiUrl.replace(/\/$/, '') + // Prefer published revision prefixes filtered by community when available. + const url = `${base}/v1/directories/communities/${encodeURIComponent(communityId)}/prefixes` + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + }, + signal: AbortSignal.timeout(45_000), + }) + if (res.ok) { + const data = (await res.json()) as { items?: { prefix?: string }[]; prefixes?: string[] } + if (Array.isArray(data.prefixes)) return uniq(data.prefixes) + if (Array.isArray(data.items)) { + return uniq(data.items.map((i) => i.prefix ?? '').filter(Boolean)) + } + } + // Fallback: modules lookup / openapi-compatible list + const alt = `${base}/v1/lookup?q=${encodeURIComponent(communityId)}` + const res2 = await fetch(alt, { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + }, + signal: AbortSignal.timeout(45_000), + }) + if (!res2.ok) { + throw new Error(`EvoBGP community fetch failed: ${res.status}/${res2.status}`) + } + const data2 = (await res2.json()) as { prefixes?: string[] } + return uniq(data2.prefixes ?? []) +} + +export async function refreshIpList(db: Db, listId: string): Promise { + const list = repos.getIpList(db, listId) + if (!list) return + + let config: Record = {} + try { + config = JSON.parse(list.configJson || '{}') as Record + } catch { + config = {} + } + + try { + let cidrs: string[] = [] + if (list.type === 'static') { + cidrs = repos.listIpListEntries(db, listId).map((e) => e.cidr) + } else if (list.type === 'json_url') { + const url = String(config.url ?? '') + if (!url) throw new Error('config.url required') + cidrs = await fetchJsonUrl(url) + repos.replaceIpListEntries(db, listId, cidrs) + } else if (list.type === 'domains') { + const domains = Array.isArray(config.domains) + ? (config.domains as string[]) + : String(config.domains ?? '') + .split(/[\s,]+/) + .filter(Boolean) + cidrs = await resolveDomains(domains) + repos.replaceIpListEntries(db, listId, cidrs) + } else if (list.type === 'evobgp_community') { + const apiUrl = + String(config.api_url ?? '') || repos.getSetting(db, 'evobgp_api_url') + const token = + String(config.api_token ?? '') || + repos.getSetting(db, 'evobgp_api_token') + const communityId = String(config.community_id ?? '') + if (!apiUrl || !token || !communityId) { + throw new Error('evobgp_api_url, token and community_id required') + } + cidrs = await fetchEvobgpCommunity(apiUrl, token, communityId) + repos.replaceIpListEntries(db, listId, cidrs) + } + + const contentHash = hashCidrs(cidrs) + repos.updateIpList(db, listId, { + contentHash, + refreshedAt: new Date().toISOString(), + lastError: null, + }) + + // Bump all agents so they re-fetch policy + for (const a of repos.listAgents(db)) { + if (a.status === 'approved') repos.bumpAgentGeneration(db, a.id) + } + } catch (err) { + repos.updateIpList(db, listId, { + lastError: err instanceof Error ? err.message : String(err), + refreshedAt: new Date().toISOString(), + }) + } +} + +export async function refreshAllLists(db: Db): Promise { + for (const list of repos.listIpLists(db)) { + if (list.type === 'static') continue + await refreshIpList(db, list.id) + } +} diff --git a/apps/api/src/services/policy/evaluate.ts b/apps/api/src/services/policy/evaluate.ts new file mode 100644 index 0000000..6eb93de --- /dev/null +++ b/apps/api/src/services/policy/evaluate.ts @@ -0,0 +1,85 @@ +import { createHash } from 'node:crypto' +import type { Db } from '@evofw/db' +import { repos } from '@evofw/db' + +export type EvaluatedPolicy = { + generation: number + hash: string + policyMode: 'blacklist' | 'whitelist' + denyCidrs: string[] + allowCidrs: string[] + syncIntervalSec: number +} + +function uniq(cidrs: string[]): string[] { + const seen = new Set() + const out: string[] = [] + for (const c of cidrs) { + const t = c.trim() + if (!t || seen.has(t)) continue + seen.add(t) + out.push(t) + } + return out.sort() +} + +function expandList(db: Db, listId: string | null | undefined): string[] { + if (!listId) return [] + return repos.listIpListEntries(db, listId).map((e) => e.cidr) +} + +/** Evaluate allow/deny sets for an agent. */ +export function evaluateAgentPolicy(db: Db, agentId: string): EvaluatedPolicy { + const agent = repos.getAgent(db, agentId) + if (!agent) { + throw new Error(`agent not found: ${agentId}`) + } + + const agentRules = repos.listPolicyRules(db, agentId) + const tenantRules = repos.listPolicyRules(db, null) + const ordered = [...agentRules, ...tenantRules].sort( + (a, b) => a.priority - b.priority, + ) + + const deny: string[] = [] + const allow: string[] = [] + + for (const rule of ordered) { + const cidrs = rule.cidr + ? [rule.cidr] + : expandList(db, rule.listId) + if (rule.action === 'deny') deny.push(...cidrs) + else allow.push(...cidrs) + } + + for (const o of repos.listOverrides(db, agentId)) { + if (o.action === 'deny') deny.push(o.cidr) + else allow.push(o.cidr) + } + + const denyCidrs = uniq(deny) + const allowCidrs = uniq(allow) + const policyMode = (agent.policyMode === 'whitelist' + ? 'whitelist' + : 'blacklist') as 'blacklist' | 'whitelist' + + const payload = JSON.stringify({ + generation: agent.policyGeneration, + policyMode, + denyCidrs, + allowCidrs, + }) + const hash = `sha256:${createHash('sha256').update(payload).digest('hex')}` + + const syncIntervalSec = + Number(repos.getSetting(db, 'agent_sync_interval_sec') || '60') || 60 + + return { + generation: agent.policyGeneration, + hash, + policyMode, + denyCidrs, + allowCidrs, + syncIntervalSec, + } +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..0826299 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "outDir": "dist", + "rootDir": "src", + "types": ["node"] + }, + "include": ["src"] +} diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..f64aad6 --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,12 @@ + + + + + + EvoFirewall + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json index 64b2f66..b473668 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,12 +1,42 @@ { "name": "@evofw/web", "private": true, - "version": "0.0.0", + "version": "0.1.0", "type": "module", "scripts": { - "dev": "echo \"web: scaffold — Vite app not initialized yet\"", - "build": "echo \"web: scaffold — skip\"", - "lint": "echo \"web: scaffold — skip\"", - "test": "echo \"web: scaffold — skip\"" + "dev": "vite", + "build": "vite build", + "typecheck": "tsc --noEmit", + "preview": "vite preview" + }, + "dependencies": { + "@base-ui/react": "^1.5.0", + "@evofw/shared": "workspace:*", + "@evofw/ui": "workspace:*", + "@hookform/resolvers": "^5.0.0", + "@tailwindcss/vite": "^4.1.0", + "@tanstack/react-query": "^5.80.0", + "@tanstack/react-router": "^1.120.0", + "@tanstack/react-table": "^8.21.0", + "@tanstack/router-plugin": "^1.120.0", + "class-variance-authority": "^0.7.1", + "lucide-react": "^0.468.0", + "next-themes": "^0.4.6", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-hook-form": "^7.56.0", + "recharts": "^2.15.0", + "sonner": "^1.7.0", + "tailwindcss": "^4.1.0", + "zod": "^4.0.0" + }, + "devDependencies": { + "@types/node": "^22.15.0", + "@types/react": "^19.1.0", + "@types/react-dom": "^19.1.0", + "@vitejs/plugin-react": "^4.5.0", + "typescript": "^5.8.3", + "vite": "^6.3.0", + "vitest": "^3.2.4" } } diff --git a/apps/web/src/components/layout/.gitkeep b/apps/web/src/components/layout/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/web/src/components/layout/app-shell.tsx b/apps/web/src/components/layout/app-shell.tsx new file mode 100644 index 0000000..083e261 --- /dev/null +++ b/apps/web/src/components/layout/app-shell.tsx @@ -0,0 +1,112 @@ +import { Link, useRouterState } from '@tanstack/react-router' +import { + LayoutDashboard, + Server, + List, + Shield, + BarChart3, + Settings, + LogOut, +} from 'lucide-react' +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarHeader, + SidebarInset, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarProvider, + SidebarTrigger, +} from '@evofw/ui/components/sidebar' +import { Button } from '@evofw/ui/components/button' +import { Separator } from '@evofw/ui/components/separator' +import { logout, CURRENT_APP_ID } from '@/lib/auth' +import type { ReactNode } from 'react' + +const NAV = [ + { to: '/', label: 'Дашборд', icon: LayoutDashboard }, + { to: '/agents', label: 'Агенты', icon: Server }, + { to: '/lists', label: 'Списки IP', icon: List }, + { to: '/rules', label: 'Правила', icon: Shield }, + { to: '/stats', label: 'Статистика', icon: BarChart3 }, + { to: '/settings', label: 'Настройки', icon: Settings }, +] as const + +export function AppShell({ children }: { children: ReactNode }) { + const pathname = useRouterState({ select: (s) => s.location.pathname }) + + return ( + + + +
+ +
+ EvoFirewall + + {CURRENT_APP_ID} + +
+
+
+ + + + + {NAV.map((item) => { + const Icon = item.icon + const active = + item.to === '/' + ? pathname === '/' + : pathname.startsWith(item.to) + return ( + + } + > + + {item.label} + + + ) + })} + + + + + + + +
+ +
+ + + Control plane +
+
+ {children} +
+
+
+ ) +} diff --git a/apps/web/src/components/reui-kit/.gitkeep b/apps/web/src/components/reui-kit/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/web/src/components/reui-kit/index.tsx b/apps/web/src/components/reui-kit/index.tsx new file mode 100644 index 0000000..420d7f9 --- /dev/null +++ b/apps/web/src/components/reui-kit/index.tsx @@ -0,0 +1,104 @@ +import type { ReactNode } from 'react' +import { Link } from '@tanstack/react-router' +import { Frame } from '@/components/reui/frame' +import { cn } from '@evofw/ui/lib/utils' + +export type KpiItem = { + id: string + label: string + value: string | number + hint?: string + to?: string +} + +/** KPI grid — preview: https://reui.io/preview/base/stats-12 */ +export function KpiStatGrid({ + items, + className, +}: { + items: KpiItem[] + className?: string +}) { + return ( +
+ {items.map((item) => { + const inner = ( + +
+ {item.label} +
+
{item.value}
+ {item.hint ? ( +
{item.hint}
+ ) : null} + + ) + return item.to ? ( + + {inner} + + ) : ( +
{inner}
+ ) + })} +
+ ) +} + +export function PageShell({ + children, + className, +}: { + children: ReactNode + className?: string +}) { + return ( +
{children}
+ ) +} + +export function PageHeader({ + title, + description, + actions, +}: { + title: string + description?: string + actions?: ReactNode +}) { + return ( +
+
+

{title}

+ {description ? ( +

{description}

+ ) : null} +
+ {actions ? ( +
+ {actions} +
+ ) : null} +
+ ) +} + +export function EmptyState({ + title, + description, + action, +}: { + title: string + description?: string + action?: ReactNode +}) { + return ( + +
{title}
+ {description ? ( +

{description}

+ ) : null} + {action} + + ) +} diff --git a/apps/web/src/components/reui/.gitkeep b/apps/web/src/components/reui/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/web/src/components/reui/frame.tsx b/apps/web/src/components/reui/frame.tsx new file mode 100644 index 0000000..4f9a3f0 --- /dev/null +++ b/apps/web/src/components/reui/frame.tsx @@ -0,0 +1,59 @@ +import type { ReactNode } from 'react' +import { cn } from '@evofw/ui/lib/utils' + +/** Minimal Frame surface (ReUI Frame contract) — preview: https://reui.io/docs/components/base/frame */ +export function Frame({ + children, + className, + dense, +}: { + children: ReactNode + className?: string + dense?: boolean +}) { + return ( +
+ {children} +
+ ) +} + +export function FrameHeader({ + children, + className, +}: { + children: ReactNode + className?: string +}) { + return ( +
+ {children} +
+ ) +} + +export function FrameTitle({ + children, + className, +}: { + children: ReactNode + className?: string +}) { + return

{children}

+} + +export function FrameDescription({ + children, + className, +}: { + children: ReactNode + className?: string +}) { + return

{children}

+} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts new file mode 100644 index 0000000..56efee1 --- /dev/null +++ b/apps/web/src/lib/api.ts @@ -0,0 +1,34 @@ +import { getToken, clearToken, redirectToPortalLogin, isAuthEnabled } from './auth' + +const API_BASE = import.meta.env.VITE_API_URL ?? '' + +export async function apiFetch( + path: string, + init: RequestInit = {}, +): Promise { + const headers = new Headers(init.headers) + if (!headers.has('Content-Type') && init.body) { + headers.set('Content-Type', 'application/json') + } + const token = getToken() + if (token) headers.set('Authorization', `Bearer ${token}`) + + const res = await fetch(`${API_BASE}${path}`, { ...init, headers }) + if (res.status === 401 && isAuthEnabled()) { + clearToken() + redirectToPortalLogin(`${window.location.origin}/auth/callback`) + throw new Error('Unauthorized') + } + if (!res.ok) { + let message = res.statusText + try { + const body = (await res.json()) as { error?: { message?: string } } + message = body.error?.message ?? message + } catch { + /* ignore */ + } + throw new Error(message) + } + if (res.status === 204) return undefined as T + return (await res.json()) as T +} diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts new file mode 100644 index 0000000..6797d08 --- /dev/null +++ b/apps/web/src/lib/auth.ts @@ -0,0 +1,126 @@ +/** Portal JWT storage for EvoFirewall (app id `fw`). */ + +const TOKEN_KEY = 'fw_auth_token' +const HANDOFF_AT_KEY = 'fw_portal_handoff_at' +const HANDOFF_COOLDOWN_MS = 12_000 +const API_BASE = import.meta.env.VITE_API_URL ?? '' + +export type AccessClaims = { + sub: string + email: string + name: string + apps: string[] + permissions: string[] + is_admin?: boolean + exp?: number +} + +export type RuntimeAuthConfig = { + required: boolean + portalUrl: string +} + +let runtimeConfig: RuntimeAuthConfig | null = null + +function viteAuthEnabled(): boolean { + return ( + import.meta.env.VITE_AUTH_ENABLED === 'true' || + import.meta.env.VITE_AUTH_ENABLED === '1' + ) +} + +function vitePortalUrl(): string { + return (import.meta.env.VITE_AUTH_PORTAL_URL ?? 'http://localhost:5175').replace( + /\/$/, + '', + ) +} + +export async function ensureAuthConfig(): Promise { + if (runtimeConfig) return runtimeConfig + try { + const res = await fetch(`${API_BASE}/api/auth/config`) + if (res.ok) { + const data = (await res.json()) as { + required?: boolean + portal_url?: string + } + runtimeConfig = { + required: Boolean(data.required) || viteAuthEnabled(), + portalUrl: (data.portal_url || vitePortalUrl()).replace(/\/$/, ''), + } + return runtimeConfig + } + } catch { + /* fallback */ + } + runtimeConfig = { + required: viteAuthEnabled(), + portalUrl: vitePortalUrl(), + } + return runtimeConfig +} + +export function getToken(): string | null { + return localStorage.getItem(TOKEN_KEY) +} + +export function setToken(token: string) { + localStorage.setItem(TOKEN_KEY, token) +} + +export function clearToken() { + localStorage.removeItem(TOKEN_KEY) +} + +export function isAuthEnabled(): boolean { + return runtimeConfig?.required ?? viteAuthEnabled() +} + +export function authPortalUrl(): string { + return runtimeConfig?.portalUrl ?? vitePortalUrl() +} + +export function isPortalHandoffCoolingDown(): boolean { + const raw = sessionStorage.getItem(HANDOFF_AT_KEY) + if (!raw) return false + return Date.now() - Number(raw) < HANDOFF_COOLDOWN_MS +} + +export function markPortalHandoff(): void { + sessionStorage.setItem(HANDOFF_AT_KEY, String(Date.now())) +} + +export function parseClaims(token: string): AccessClaims | null { + try { + const payload = token.split('.')[1] + if (!payload) return null + const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/')) + return JSON.parse(json) as AccessClaims + } catch { + return null + } +} + +export function isTokenValid(): boolean { + const t = getToken() + if (!t) return false + const c = parseClaims(t) + if (!c?.exp) return !!t + return c.exp * 1000 > Date.now() + 5_000 +} + +export function redirectToPortalLogin(returnTo: string) { + if (isPortalHandoffCoolingDown()) return + markPortalHandoff() + const portal = authPortalUrl() + const url = `${portal}/?return_to=${encodeURIComponent(returnTo)}` + window.location.href = url +} + +export function logout() { + clearToken() + window.location.href = `${authPortalUrl()}/logout` +} + +export const CURRENT_APP_ID = 'fw' diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..11a291e --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,39 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { RouterProvider, createRouter } from '@tanstack/react-router' +import { ThemeProvider } from 'next-themes' +import { Toaster } from '@evofw/ui/components/sonner' +import { TooltipProvider } from '@evofw/ui/components/tooltip' +import { routeTree } from './routeTree.gen' +import '@evofw/ui/globals.css' + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { staleTime: 10_000, retry: 1 }, + }, +}) + +const router = createRouter({ + routeTree, + context: { queryClient }, +}) + +declare module '@tanstack/react-router' { + interface Register { + router: typeof router + } +} + +createRoot(document.getElementById('root')!).render( + + + + + + + + + + , +) diff --git a/apps/web/src/queries/index.ts b/apps/web/src/queries/index.ts new file mode 100644 index 0000000..b455e5d --- /dev/null +++ b/apps/web/src/queries/index.ts @@ -0,0 +1,82 @@ +import { queryOptions } from '@tanstack/react-query' +import { apiFetch } from '@/lib/api' +import type { Agent, DashboardStats, IpList, PolicyRule } from '@evofw/shared' + +export const dashboardQueryOptions = () => + queryOptions({ + queryKey: ['dashboard'], + queryFn: () => apiFetch('/api/v1/dashboard'), + }) + +export const agentsQueryOptions = () => + queryOptions({ + queryKey: ['agents'], + queryFn: () => apiFetch<{ items: Agent[] }>('/api/v1/agents'), + }) + +export const agentQueryOptions = (id: string) => + queryOptions({ + queryKey: ['agents', id], + queryFn: () => apiFetch(`/api/v1/agents/${id}`), + }) + +export const listsQueryOptions = () => + queryOptions({ + queryKey: ['lists'], + queryFn: () => apiFetch<{ items: IpList[] }>('/api/v1/lists'), + }) + +export const rulesQueryOptions = (agentId?: string) => + queryOptions({ + queryKey: ['rules', agentId ?? 'all'], + queryFn: () => + apiFetch<{ items: PolicyRule[] }>( + `/api/v1/rules${agentId ? `?agent_id=${encodeURIComponent(agentId)}` : ''}`, + ), + }) + +export const installContextQueryOptions = () => + queryOptions({ + queryKey: ['install-context'], + queryFn: () => + apiFetch<{ + suggested_cp_url: string + enroll_seed: string + install_sh_url: string + mikrotik_url: string + sync_interval_sec: number + }>('/api/v1/install-context'), + }) + +export const settingsQueryOptions = () => + queryOptions({ + queryKey: ['settings'], + queryFn: () => apiFetch>('/api/v1/settings'), + }) + +export const agentStatsQueryOptions = (id: string) => + queryOptions({ + queryKey: ['agent-stats', id], + queryFn: () => + apiFetch<{ + items: { + packets_dropped: number + packets_accepted: number + recorded_at: string + }[] + }>(`/api/v1/agents/${id}/stats`), + }) + +export const recentStatsQueryOptions = () => + queryOptions({ + queryKey: ['stats-recent'], + queryFn: () => + apiFetch<{ + items: { + agent_id: string + packets_dropped: number + packets_accepted: number + recorded_at: string + }[] + }>('/api/v1/stats/recent'), + }) diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts new file mode 100644 index 0000000..231d618 --- /dev/null +++ b/apps/web/src/routeTree.gen.ts @@ -0,0 +1,244 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as AuthRouteImport } from './routes/_auth' +import { Route as AuthIndexRouteImport } from './routes/_auth/index' +import { Route as AuthAgentsRouteImport } from './routes/_auth/agents' +import { Route as AuthListsRouteImport } from './routes/_auth/lists' +import { Route as AuthRulesRouteImport } from './routes/_auth/rules' +import { Route as AuthSettingsRouteImport } from './routes/_auth/settings' +import { Route as AuthStatsRouteImport } from './routes/_auth/stats' +import { Route as AuthCallbackRouteImport } from './routes/auth.callback' +import { Route as AuthAgentsIdRouteImport } from './routes/_auth/agents.$id' + +const AuthRoute = AuthRouteImport.update({ + id: '/_auth', + getParentRoute: () => rootRouteImport, +} as any) +const AuthIndexRoute = AuthIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => AuthRoute, +} as any) +const AuthAgentsRoute = AuthAgentsRouteImport.update({ + id: '/agents', + path: '/agents', + getParentRoute: () => AuthRoute, +} as any) +const AuthListsRoute = AuthListsRouteImport.update({ + id: '/lists', + path: '/lists', + getParentRoute: () => AuthRoute, +} as any) +const AuthRulesRoute = AuthRulesRouteImport.update({ + id: '/rules', + path: '/rules', + getParentRoute: () => AuthRoute, +} as any) +const AuthSettingsRoute = AuthSettingsRouteImport.update({ + id: '/settings', + path: '/settings', + getParentRoute: () => AuthRoute, +} as any) +const AuthStatsRoute = AuthStatsRouteImport.update({ + id: '/stats', + path: '/stats', + getParentRoute: () => AuthRoute, +} as any) +const AuthCallbackRoute = AuthCallbackRouteImport.update({ + id: '/auth/callback', + path: '/auth/callback', + getParentRoute: () => rootRouteImport, +} as any) +const AuthAgentsIdRoute = AuthAgentsIdRouteImport.update({ + id: '/$id', + path: '/$id', + getParentRoute: () => AuthAgentsRoute, +} as any) + +export interface FileRoutesByFullPath { + '/': typeof AuthIndexRoute + '/agents': typeof AuthAgentsRouteWithChildren + '/lists': typeof AuthListsRoute + '/rules': typeof AuthRulesRoute + '/settings': typeof AuthSettingsRoute + '/stats': typeof AuthStatsRoute + '/auth/callback': typeof AuthCallbackRoute + '/agents/$id': typeof AuthAgentsIdRoute +} +export interface FileRoutesByTo { + '/agents': typeof AuthAgentsRouteWithChildren + '/lists': typeof AuthListsRoute + '/rules': typeof AuthRulesRoute + '/settings': typeof AuthSettingsRoute + '/stats': typeof AuthStatsRoute + '/auth/callback': typeof AuthCallbackRoute + '/': typeof AuthIndexRoute + '/agents/$id': typeof AuthAgentsIdRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/_auth': typeof AuthRouteWithChildren + '/_auth/agents': typeof AuthAgentsRouteWithChildren + '/_auth/lists': typeof AuthListsRoute + '/_auth/rules': typeof AuthRulesRoute + '/_auth/settings': typeof AuthSettingsRoute + '/_auth/stats': typeof AuthStatsRoute + '/auth/callback': typeof AuthCallbackRoute + '/_auth/': typeof AuthIndexRoute + '/_auth/agents/$id': typeof AuthAgentsIdRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: + | '/' + | '/agents' + | '/lists' + | '/rules' + | '/settings' + | '/stats' + | '/auth/callback' + | '/agents/$id' + fileRoutesByTo: FileRoutesByTo + to: + | '/agents' + | '/lists' + | '/rules' + | '/settings' + | '/stats' + | '/auth/callback' + | '/' + | '/agents/$id' + id: + | '__root__' + | '/_auth' + | '/_auth/agents' + | '/_auth/lists' + | '/_auth/rules' + | '/_auth/settings' + | '/_auth/stats' + | '/auth/callback' + | '/_auth/' + | '/_auth/agents/$id' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + AuthRoute: typeof AuthRouteWithChildren + AuthCallbackRoute: typeof AuthCallbackRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/_auth': { + id: '/_auth' + path: '' + fullPath: '/' + preLoaderRoute: typeof AuthRouteImport + parentRoute: typeof rootRouteImport + } + '/_auth/': { + id: '/_auth/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof AuthIndexRouteImport + parentRoute: typeof AuthRoute + } + '/_auth/agents': { + id: '/_auth/agents' + path: '/agents' + fullPath: '/agents' + preLoaderRoute: typeof AuthAgentsRouteImport + parentRoute: typeof AuthRoute + } + '/_auth/lists': { + id: '/_auth/lists' + path: '/lists' + fullPath: '/lists' + preLoaderRoute: typeof AuthListsRouteImport + parentRoute: typeof AuthRoute + } + '/_auth/rules': { + id: '/_auth/rules' + path: '/rules' + fullPath: '/rules' + preLoaderRoute: typeof AuthRulesRouteImport + parentRoute: typeof AuthRoute + } + '/_auth/settings': { + id: '/_auth/settings' + path: '/settings' + fullPath: '/settings' + preLoaderRoute: typeof AuthSettingsRouteImport + parentRoute: typeof AuthRoute + } + '/_auth/stats': { + id: '/_auth/stats' + path: '/stats' + fullPath: '/stats' + preLoaderRoute: typeof AuthStatsRouteImport + parentRoute: typeof AuthRoute + } + '/auth/callback': { + id: '/auth/callback' + path: '/auth/callback' + fullPath: '/auth/callback' + preLoaderRoute: typeof AuthCallbackRouteImport + parentRoute: typeof rootRouteImport + } + '/_auth/agents/$id': { + id: '/_auth/agents/$id' + path: '/$id' + fullPath: '/agents/$id' + preLoaderRoute: typeof AuthAgentsIdRouteImport + parentRoute: typeof AuthAgentsRoute + } + } +} + +interface AuthAgentsRouteChildren { + AuthAgentsIdRoute: typeof AuthAgentsIdRoute +} + +const AuthAgentsRouteChildren: AuthAgentsRouteChildren = { + AuthAgentsIdRoute: AuthAgentsIdRoute, +} + +const AuthAgentsRouteWithChildren = AuthAgentsRoute._addFileChildren( + AuthAgentsRouteChildren, +) + +interface AuthRouteChildren { + AuthAgentsRoute: typeof AuthAgentsRouteWithChildren + AuthListsRoute: typeof AuthListsRoute + AuthRulesRoute: typeof AuthRulesRoute + AuthSettingsRoute: typeof AuthSettingsRoute + AuthStatsRoute: typeof AuthStatsRoute + AuthIndexRoute: typeof AuthIndexRoute +} + +const AuthRouteChildren: AuthRouteChildren = { + AuthAgentsRoute: AuthAgentsRouteWithChildren, + AuthListsRoute: AuthListsRoute, + AuthRulesRoute: AuthRulesRoute, + AuthSettingsRoute: AuthSettingsRoute, + AuthStatsRoute: AuthStatsRoute, + AuthIndexRoute: AuthIndexRoute, +} + +const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren) + +const rootRouteChildren: RootRouteChildren = { + AuthRoute: AuthRouteWithChildren, + AuthCallbackRoute: AuthCallbackRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/apps/web/src/routes/.gitkeep b/apps/web/src/routes/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx new file mode 100644 index 0000000..f4c783f --- /dev/null +++ b/apps/web/src/routes/__root.tsx @@ -0,0 +1,25 @@ +import { createRootRouteWithContext, Outlet } from '@tanstack/react-router' +import type { QueryClient } from '@tanstack/react-query' +import { + ensureAuthConfig, + isAuthEnabled, + isTokenValid, + redirectToPortalLogin, +} from '@/lib/auth' + +export type RouterContext = { + queryClient: QueryClient +} + +export const Route = createRootRouteWithContext()({ + beforeLoad: async ({ location }) => { + await ensureAuthConfig() + if (!isAuthEnabled()) return + if (location.pathname.startsWith('/auth/')) return + if (!isTokenValid()) { + redirectToPortalLogin(`${window.location.origin}/auth/callback`) + throw new Error('redirecting to portal') + } + }, + component: () => , +}) diff --git a/apps/web/src/routes/_auth.tsx b/apps/web/src/routes/_auth.tsx new file mode 100644 index 0000000..341f838 --- /dev/null +++ b/apps/web/src/routes/_auth.tsx @@ -0,0 +1,10 @@ +import { createFileRoute, Outlet } from '@tanstack/react-router' +import { AppShell } from '@/components/layout/app-shell' + +export const Route = createFileRoute('/_auth')({ + component: () => ( + + + + ), +}) diff --git a/apps/web/src/routes/_auth/agents.$id.tsx b/apps/web/src/routes/_auth/agents.$id.tsx new file mode 100644 index 0000000..7ce33d8 --- /dev/null +++ b/apps/web/src/routes/_auth/agents.$id.tsx @@ -0,0 +1,236 @@ +import { createFileRoute, Link } from '@tanstack/react-router' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' +import { useState } from 'react' +import { PageHeader, PageShell } from '@/components/reui-kit' +import { Frame, FrameHeader, FrameTitle, FrameDescription } from '@/components/reui/frame' +import { + agentQueryOptions, + agentsQueryOptions, + rulesQueryOptions, +} from '@/queries' +import { apiFetch } from '@/lib/api' +import { Button } from '@evofw/ui/components/button' +import { Input } from '@evofw/ui/components/input' +import { Label } from '@evofw/ui/components/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@evofw/ui/components/select' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@evofw/ui/components/table' + +export const Route = createFileRoute('/_auth/agents/$id')({ + component: AgentDetailPage, +}) + +function AgentDetailPage() { + const { id } = Route.useParams() + const qc = useQueryClient() + const agentQ = useQuery(agentQueryOptions(id)) + const rulesQ = useQuery(rulesQueryOptions(id)) + const agentsQ = useQuery(agentsQueryOptions()) + const [cidr, setCidr] = useState('') + const [action, setAction] = useState<'allow' | 'deny'>('deny') + const [cloneFrom, setCloneFrom] = useState('') + + const patchMode = useMutation({ + mutationFn: (policy_mode: 'blacklist' | 'whitelist') => + apiFetch(`/api/v1/agents/${id}`, { + method: 'PATCH', + body: JSON.stringify({ policy_mode }), + }), + onSuccess: () => { + toast.success('Режим обновлён') + void qc.invalidateQueries({ queryKey: ['agents'] }) + }, + }) + + const addOverride = useMutation({ + mutationFn: () => + apiFetch(`/api/v1/agents/${id}/overrides`, { + method: 'POST', + body: JSON.stringify({ cidr, action }), + }), + onSuccess: () => { + toast.success('Override добавлен — подхватится на следующей итерации sync') + setCidr('') + void qc.invalidateQueries({ queryKey: ['agents', id] }) + }, + onError: (e: Error) => toast.error(e.message), + }) + + const clone = useMutation({ + mutationFn: () => + apiFetch(`/api/v1/agents/${id}/clone-from/${cloneFrom}`, { + method: 'POST', + body: JSON.stringify({ include_overrides: true }), + }), + onSuccess: () => { + toast.success('Правила скопированы') + void qc.invalidateQueries({ queryKey: ['rules'] }) + void qc.invalidateQueries({ queryKey: ['agents'] }) + }, + onError: (e: Error) => toast.error(e.message), + }) + + const a = agentQ.data + if (!a) { + return + } + + return ( + + + + + } + /> + +
+ + + Политика + + blacklist = deny set; whitelist = allow set + default drop + + +
+ + +
+
+
Dropped
+
{a.last_apply_packets_dropped ?? 0}
+
Accepted
+
{a.last_apply_packets_accepted ?? 0}
+
Kernel
+
{a.last_apply_kernel_method ?? '—'}
+
Last apply
+
{a.last_apply_at ?? '—'}
+
+ + + + + Мгновенный IP override + + Обновится на агенте на следующей итерации sync (~1 мин) + + +
+
+ + setCidr(e.target.value)} + /> +
+
+ + +
+ +
+ + + + + Копировать правила + +
+ + +
+ + + + + Правила агента + + + + + Prio + Action + Source + + + + {(rulesQ.data?.items ?? []).map((r) => ( + + {r.priority} + {r.action} + + {r.cidr ?? r.list_id ?? '—'} + + + ))} + +
+ +
+
+ ) +} diff --git a/apps/web/src/routes/_auth/agents.tsx b/apps/web/src/routes/_auth/agents.tsx new file mode 100644 index 0000000..09bec9d --- /dev/null +++ b/apps/web/src/routes/_auth/agents.tsx @@ -0,0 +1,231 @@ +import { createFileRoute, Link } from '@tanstack/react-router' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' +import { Copy, Check } from 'lucide-react' +import { useMemo, useState } from 'react' +import { PageHeader, PageShell, EmptyState } from '@/components/reui-kit' +import { Frame, FrameHeader, FrameTitle, FrameDescription } from '@/components/reui/frame' +import { + agentsQueryOptions, + installContextQueryOptions, +} from '@/queries' +import { apiFetch } from '@/lib/api' +import { Button } from '@evofw/ui/components/button' +import { Input } from '@evofw/ui/components/input' +import { Label } from '@evofw/ui/components/label' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@evofw/ui/components/table' +import { Badge } from '@evofw/ui/components/badge' + +export const Route = createFileRoute('/_auth/agents')({ + component: AgentsPage, +}) + +function AgentsPage() { + const qc = useQueryClient() + const agentsQ = useQuery(agentsQueryOptions()) + const installQ = useQuery(installContextQueryOptions()) + const [name, setName] = useState('web-01') + + const approve = useMutation({ + mutationFn: (id: string) => + apiFetch(`/api/v1/agents/${id}/approve`, { method: 'POST' }), + onSuccess: () => { + toast.success('Агент одобрен') + void qc.invalidateQueries({ queryKey: ['agents'] }) + }, + onError: (e: Error) => toast.error(e.message), + }) + + const revoke = useMutation({ + mutationFn: (id: string) => + apiFetch(`/api/v1/agents/${id}/revoke`, { method: 'POST' }), + onSuccess: () => { + toast.success('Агент отозван') + void qc.invalidateQueries({ queryKey: ['agents'] }) + }, + }) + + const remove = useMutation({ + mutationFn: (id: string) => + apiFetch(`/api/v1/agents/${id}`, { method: 'DELETE' }), + onSuccess: () => { + toast.success('Удалён') + void qc.invalidateQueries({ queryKey: ['agents'] }) + }, + }) + + const installCmd = useMemo(() => { + const cp = installQ.data?.suggested_cp_url ?? 'https://fw.example.com' + const seed = installQ.data?.enroll_seed ?? '' + return `curl -fsSL ${cp}/v1/agent/install.sh | \\\n EVOFW_CP_URL=${cp} \\\n EVOFW_SEED=${seed} \\\n EVOFW_CLIENT_NAME="${name}" \\\n bash` + }, [installQ.data, name]) + + const items = agentsQ.data?.items ?? [] + const pending = items.filter((a) => a.status === 'pending') + + return ( + + + + + +
+ Установка Linux + + One-liner. После enroll одобрите агента ниже. + +
+
+
+ + setName(e.target.value)} + /> +
+
+          {installCmd}
+        
+ + {installQ.data?.mikrotik_url ? ( +

+ MikroTik:{' '} + + mikrotik-install.rsc + +

+ ) : null} + + + {pending.length > 0 ? ( + + + Запросы ({pending.length}) + +
+ {pending.map((a) => ( +
+
+
{a.name}
+
+ {a.platform} · {a.hostname ?? '—'} · {a.token_prefix}… +
+
+
+ + +
+
+ ))} +
+ + ) : null} + + + + Клиенты + + {items.length === 0 ? ( + + ) : ( + + + + Имя + Платформа + Статус + Режим + Seen + Действия + + + + {items.map((a) => ( + + + + {a.name} + + + {a.platform} + + {a.status} + + {a.policy_mode} + + {a.last_seen_at ?? '—'} + + +
+ {a.status === 'approved' ? ( + + ) : null} + +
+
+
+ ))} +
+
+ )} + +
+ ) +} diff --git a/apps/web/src/routes/_auth/index.tsx b/apps/web/src/routes/_auth/index.tsx new file mode 100644 index 0000000..ce6ad55 --- /dev/null +++ b/apps/web/src/routes/_auth/index.tsx @@ -0,0 +1,133 @@ +import { createFileRoute } from '@tanstack/react-router' +import { useQuery } from '@tanstack/react-query' +import { PageHeader, PageShell, KpiStatGrid } from '@/components/reui-kit' +import { Frame, FrameHeader, FrameTitle } from '@/components/reui/frame' +import { dashboardQueryOptions, agentsQueryOptions, recentStatsQueryOptions } from '@/queries' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@evofw/ui/components/table' +import { Skeleton } from '@evofw/ui/components/skeleton' + +export const Route = createFileRoute('/_auth/')({ + component: DashboardPage, +}) + +function DashboardPage() { + const dash = useQuery(dashboardQueryOptions()) + const agents = useQuery(agentsQueryOptions()) + const stats = useQuery(recentStatsQueryOptions()) + + const d = dash.data + const items = [ + { + id: 'agents', + label: 'Агенты', + value: d?.agents_approved ?? '—', + hint: `${d?.agents_online ?? 0} online / ${d?.agents_pending ?? 0} pending`, + to: '/agents', + }, + { + id: 'dropped', + label: 'Dropped', + value: d?.packets_dropped ?? '—', + hint: 'сумма counters', + to: '/stats', + }, + { + id: 'accepted', + label: 'Accepted', + value: d?.packets_accepted ?? '—', + hint: 'сумма counters', + to: '/stats', + }, + { + id: 'lists', + label: 'Списки IP', + value: d?.lists_total ?? '—', + to: '/lists', + }, + ] + + return ( + + + {dash.isLoading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+ ) : ( + + )} + +
+ + + Агенты + + + + + Имя + Статус + Режим + Dropped + + + + {(agents.data?.items ?? []).slice(0, 8).map((a) => ( + + {a.name} + {a.status} + {a.policy_mode} + + {a.last_apply_packets_dropped ?? 0} + + + ))} + +
+ + + + + Последние samples + + + + + Время + Drop + Accept + + + + {(stats.data?.items ?? []).slice(0, 10).map((s, i) => ( + + + {s.recorded_at} + + + {s.packets_dropped} + + + {s.packets_accepted} + + + ))} + +
+ +
+
+ ) +} diff --git a/apps/web/src/routes/_auth/lists.tsx b/apps/web/src/routes/_auth/lists.tsx new file mode 100644 index 0000000..a5f2404 --- /dev/null +++ b/apps/web/src/routes/_auth/lists.tsx @@ -0,0 +1,210 @@ +import { createFileRoute } from '@tanstack/react-router' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' +import { useState } from 'react' +import { PageHeader, PageShell, EmptyState } from '@/components/reui-kit' +import { Frame, FrameHeader, FrameTitle } from '@/components/reui/frame' +import { listsQueryOptions } from '@/queries' +import { apiFetch } from '@/lib/api' +import { Button } from '@evofw/ui/components/button' +import { Input } from '@evofw/ui/components/input' +import { Label } from '@evofw/ui/components/label' +import { Textarea } from '@evofw/ui/components/textarea' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@evofw/ui/components/select' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@evofw/ui/components/table' + +export const Route = createFileRoute('/_auth/lists')({ + component: ListsPage, +}) + +function ListsPage() { + const qc = useQueryClient() + const listsQ = useQuery(listsQueryOptions()) + const [name, setName] = useState('') + const [type, setType] = useState< + 'static' | 'json_url' | 'domains' | 'evobgp_community' + >('static') + const [extra, setExtra] = useState('') + + const create = useMutation({ + mutationFn: async () => { + const config: Record = {} + let entries: string[] | undefined + if (type === 'static') { + entries = extra + .split(/[\s,]+/) + .map((s) => s.trim()) + .filter(Boolean) + } else if (type === 'json_url') { + config.url = extra.trim() + } else if (type === 'domains') { + config.domains = extra + .split(/[\s,]+/) + .map((s) => s.trim()) + .filter(Boolean) + } else { + config.community_id = extra.trim() + } + return apiFetch('/api/v1/lists', { + method: 'POST', + body: JSON.stringify({ name, type, config, entries }), + }) + }, + onSuccess: () => { + toast.success('Список создан') + setName('') + setExtra('') + void qc.invalidateQueries({ queryKey: ['lists'] }) + }, + onError: (e: Error) => toast.error(e.message), + }) + + const refresh = useMutation({ + mutationFn: (id: string) => + apiFetch(`/api/v1/lists/${id}/refresh`, { method: 'POST' }), + onSuccess: () => { + toast.success('Обновлено') + void qc.invalidateQueries({ queryKey: ['lists'] }) + }, + }) + + const remove = useMutation({ + mutationFn: (id: string) => + apiFetch(`/api/v1/lists/${id}`, { method: 'DELETE' }), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ['lists'] }) + }, + }) + + const items = listsQ.data?.items ?? [] + + return ( + + + + + + Новый список + +
+
+ + setName(e.target.value)} /> +
+
+ + +
+
+ +