Compare commits

...
10 Commits
Author SHA1 Message Date
Denozordec 15ad53af1f feat(wireguard): implement WireGuard interface management and permissions
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 1m39s
Docker images / frontend-image (push) Successful in 2m56s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 53s
Docker images / publish-release (push) Successful in 10s
Added comprehensive support for managing WireGuard interfaces, including CRUD operations and peer management. Updated permissions to include access control for WireGuard routes. Enhanced the UI components to display and interact with WireGuard configurations, improving user experience and functionality. Introduced new tests for WireGuard-related functionalities to ensure reliability.
2026-09-05 02:10:55 +07:00
Denozordec 883842636b refactor(api): streamline API requests with requestJson and requestBlob functions
Docker images / prepare-release (push) Successful in 6s
Docker images / backend-image (push) Successful in 1m24s
Docker images / frontend-image (push) Successful in 2m6s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 43s
Docker images / publish-release (push) Successful in 7s
Replaced direct fetch calls with requestJson and requestBlob utility functions across multiple components for improved consistency and error handling. This change enhances the maintainability of the codebase by centralizing API request logic and ensuring uniform handling of authentication and response parsing.
2026-09-05 01:42:44 +07:00
Denozordec b9f430de16 refactor(acme-cloudflare): enhance upsertARecord and syncCertificateDomainRecords functions
Docker images / prepare-release (push) Successful in 8s
Docker images / backend-image (push) Successful in 1m32s
Docker images / frontend-image (push) Successful in 2m21s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 41s
Docker images / publish-release (push) Successful in 8s
Updated the upsertARecord function to return status messages ("updated", "created", "skipped_cname") instead of void, improving clarity on record handling. Modified syncCertificateDomainRecords to collect and return skipped CNAME records, enhancing error handling and feedback during DNS operations.
2026-09-05 01:29:58 +07:00
Denozordec 25e040a5dd fix(settings): improve error handling and success notifications in EvoBGP settings
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-image (push) Successful in 1m26s
Docker images / frontend-image (push) Successful in 2m26s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 41s
Docker images / publish-release (push) Successful in 8s
Enhanced the error handling in the settings page by introducing a dedicated error message function. Added success and error toast notifications for better user feedback during settings save operations. Updated API key normalization to ensure consistent handling across the application.
2026-09-05 00:07:58 +07:00
Denozordec f2df990746 chore(docker): enhance Dockerfile for backend to manage nested dependencies
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 1m23s
Docker images / frontend-image (push) Successful in 2m12s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 42s
Docker images / publish-release (push) Successful in 9s
Updated the Dockerfile to create a directory for nested workspace dependencies and adjusted the copy commands to ensure proper handling of node_modules during the build process.
2026-09-04 23:48:03 +07:00
Denozordec 77bc174e43 chore(deps): update package-lock.json and backend dependencies, adjust Dockerfile for leaner backend image
Docker images / prepare-release (push) Successful in 7s
Docker images / backend-image (push) Successful in 1m46s
Docker images / frontend-image (push) Successful in 2m35s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 38s
Docker images / publish-release (push) Successful in 6s
Removed unnecessary frontend dependencies from backend Docker image and updated package-lock.json to include new dev dependencies. Adjusted backend Dockerfile to streamline the build process and ensure proper workspace configuration.
2026-09-04 23:37:23 +07:00
DenozordecandCursor 0e9349e508 feat(auth): интегрировать SSO auth-portal
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 2m52s
Docker images / frontend-image (push) Successful in 2m23s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 38s
Docker images / publish-release (push) Successful in 8s
JWT на backend, handoff/callback на UI, RBAC mm:*, AUTH_* в compose.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 20:38:28 +07:00
DenozordecandCursor 0208aa4d7c chore(deploy): добавить Traefik-стеки и общий compose с CDNManager
Docker images / prepare-release (push) Successful in 5s
Docker images / backend-image (push) Successful in 3m23s
Docker images / frontend-image (push) Successful in 2m1s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 38s
Docker images / publish-release (push) Successful in 6s
Единый стек CDN+MM, вариант рядом с CDN Traefik и CLI-скрипт запуска.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 19:07:49 +07:00
Denozordec 42a2e18047 docs: обновить раздел о прод-развёртывании Docker и изменить ссылки на репозиторий
Docker images / prepare-release (push) Successful in 23s
Docker images / backend-image (push) Failing after 1m30s
Docker images / frontend-image (push) Successful in 3m45s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 50s
Docker images / publish-release (push) Skipped
2026-09-04 18:54:26 +07:00
DenozordecandCursor 9df3971f6c chore(reui): обновить ReUI agent skill до актуальной версии
Docker images / prepare-release (push) Successful in 4s
Docker images / backend-image (push) Successful in 5m58s
Docker images / frontend-image (push) Successful in 2m55s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 53s
Docker images / publish-release (push) Successful in 6s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 20:03:55 +07:00
71 changed files with 5963 additions and 590 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
+48 -10
View File
@@ -1,6 +1,6 @@
# ReUI components
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
@@ -106,22 +106,60 @@ Common mistakes:
## filters
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const [filters, setFilters] = useState<Filter[]>([
createFilter("priority", "is_any_of", ["low"]),
])
const fields: FilterFieldConfig[] = [
{ key: "priority", label: "Priority", type: "multiselect",
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
const fields: FilterField[] = [
{ id: "title", label: "Title", type: "text" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters filters={filters} fields={fields} onChange={setFilters} />
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
## date-selector
+3 -3
View File
@@ -12,9 +12,9 @@
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:__TRACKED_VAR__/dashboard)",
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:3333__TRACKED_VAR__)",
"Bash(curl -s -o /dev/null -w '%{http_code}' http://localhost:59959__TRACKED_VAR__)",
"WebFetch(domain:git.shts.su)",
"Bash(curl -s \"https://git.shts.su/denozord/router-lists-ui/raw/branch/v5/frontend/src/RouteOptimizerPage.jsx\")",
"Bash(curl -s \"https://git.shts.su/denozord/router-lists-ui/raw/branch/v5/frontend/src/OspfToolsPage.jsx\")",
"WebFetch(domain:git.shx.one)",
"Bash(curl -s \"https://git.shx.one/denozord/router-lists-ui/raw/branch/v5/frontend/src/RouteOptimizerPage.jsx\")",
"Bash(curl -s \"https://git.shx.one/denozord/router-lists-ui/raw/branch/v5/frontend/src/OspfToolsPage.jsx\")",
"Bash(node -e ' *)",
"Bash(powershell -Command \"Get-Item 'C:\\\\Users\\\\shats\\\\.claude\\\\projects\\\\C--Users-shats-Dev-MikrotikManager-3\\\\b1dbd554-4665-40c0-bb5b-19d63bb494a0.jsonl'\")",
"Bash(node -e \"const { createRequire } = require\\('module'\\); const r = createRequire\\(__filename\\); const lucide = r\\('lucide-react'\\); ['SlackIcon','WebhookIcon'].forEach\\(n => console.log\\(n, !!lucide[n]\\)\\)\")",
+1 -1
View File
@@ -5,7 +5,7 @@ user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
+48 -10
View File
@@ -1,6 +1,6 @@
# ReUI components
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
@@ -106,22 +106,60 @@ Common mistakes:
## filters
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const [filters, setFilters] = useState<Filter[]>([
createFilter("priority", "is_any_of", ["low"]),
])
const fields: FilterFieldConfig[] = [
{ key: "priority", label: "Priority", type: "multiselect",
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
const fields: FilterField[] = [
{ id: "title", label: "Title", type: "text" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters filters={filters} fields={fields} onChange={setFilters} />
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
## date-selector
+73
View File
@@ -0,0 +1,73 @@
---
description: Use the ReUI registry (blocks, primitives, icons) correctly
globs: ["**/*.tsx","**/*.ts"]
alwaysApply: false
---
---
name: reui
description: Use the ReUI registry from your AI agent - find, install, and correctly use ReUI components (the 20 free building blocks like data-grid, kanban, filters), their free examples, premium blocks, and Motion Icons. Applies in any project using ReUI, the @reui registry, REUI_LICENSE_KEY, or any shadcn project where the user asks for premium blocks, data grids, kanban boards, dashboards, or full pages. Pairs with the free ReUI MCP server for live, scored registry search and inline component APIs.
user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
ReUI is a shadcn-compatible registry. It ships four things you **reuse** - never redesign:
- **components** - the 20 ReUI building blocks with real APIs: `data-grid`, `kanban`, `filters`, `date-selector`, `tree`, `stepper`, ... (free)
- **examples** - free `c-*` single-pattern use-cases of a component (`c-kanban-1`); install one and read it to see exact composition
- **blocks** - premium full-page sections that compose components (`data-grid-2`, `pricing-page-1`); Pro or Ultimate license at install
- **icons** - Motion Icons in 4 styles, static + hover-animated variants; Ultimate license at install
The skill is free and this MCP is free to use; it just needs a ReUI account. On first use your agent opens a browser "Sign in with ReUI" prompt (a free account is created if you don't have one). Free covers components and examples with a daily request allowance; a Pro or Ultimate license unlocks premium blocks and Motion Icons and removes the limit (see [rules/registry.md](./rules/registry.md)). The same account and skill work in every agent and service the MCP connects to - this skill is agent-agnostic.
Skill + MCP are a team: this skill is the workflow (how to find, install, read the API, and adapt by reuse); the MCP is the live data and the hands (search, get_component, install commands). Your job: find the right item, install it with the shadcn CLI, read its real API, and **adapt by reuse** - wire real data and theme it; do not hand-roll or restyle what ReUI already provides. This skill **layers on the shadcn skill**: follow that for generic rules (spacing, `cn()`, semantic colors, forms); follow this for everything ReUI-specific.
## The core loop (MCP-native)
1. **Find** - call the ReUI MCP `search` tool with the user's intent. It returns a ranked, scored list across components/examples/blocks/icons, each with an `install` command, `previewUrl`, `docsUrl`, and `componentsUsed`. Pass hints (`type`, `component`, `category`, `features`, `free`) when you can infer them.
2. **Install** - run the returned command non-interactively (`npx shadcn@latest add @reui/<name> --yes`). The CLI resolves deps, aliases, and the base/style from `components.json`. See [cli.md](./rules/cli.md).
3. **Read the API (on your base)** - first note your base from `components.json` -> `style` (`base-nova` -> Base UI, `radix-nova` -> Radix UI). For each component an item uses, call `get_component(name)` and read its **inline `api`** (no web fetch); then `get_examples(name)` to install a worked example and copy its composition - the installed files are already in your base. Whenever you work with a component's API, also **share its `docsUrl`** (the primitive's API documentation page) with the user so they have the full reference. See [components.md](./rules/components.md).
4. **Adapt (reuse-first)** - swap demo data for real data, fix icon imports, align tokens. Do not redesign. See [adapting.md](./rules/adapting.md).
**Always show the preview.** Every item a tool returns carries a `previewUrl` (a live preview page). Whenever you list, recommend, or present ReUI items to the user - blocks, components, examples, or icons, whether from `search`, `search_icons`, `list_components`, `compose_page`, or any getter - include each item's `previewUrl` so they can SEE it before installing. Blocks and examples open an individual live preview; icons and components link to their live category/component page. Never present an item without its preview link.
If the ReUI MCP is not configured, fall back to `npx shadcn@latest search @reui -q "..."` then `add` - but the MCP gives scored matches + inline APIs; prefer it.
## Commands
Run ReUI as explicit slash commands (via the ReUI MCP) **or** just ask in plain language - both run the same workflow.
| Command | Invoke | Does |
| ----------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| **build** | `/mcp__reui__build <what>` | Compose a page/section/feature from ReUI: plan → install → read API → adapt → craft → audit. |
| **add** | `/mcp__reui__add <item>` | Find & install one component/example/block/icon and wire it in. |
| **fix** | `/mcp__reui__fix [target]` | Diagnose & fix ReUI usage: wrong/undocumented props, base/radix mismatch, missing states, a11y/scroll. |
| **improve** | `/mcp__reui__improve [target]` | Refine + extend existing ReUI UI to a production-exceptional bar (hierarchy, density, states, responsive, motion). |
Invocation differs slightly per agent (`/mcp__reui__build` in Claude Code/Cursor/Windsurf, `/mcp.reui.build` in VS Code). No command surface? Just describe what you want - this skill drives the identical loop.
## When to reach for ReUI vs plain shadcn
| Need | Reach for |
| -------------------------------------------------------------------- | ------------------------------------------------------------------------- |
| A full page or section (dashboard, billing, auth, pricing, settings) | `compose_page` first (plans sections + best blocks), then ReUI **blocks** |
| A data table with sorting/filtering/pagination/virtualization | the **data-grid** component (never hand-roll a `<table>`) |
| A drag-and-drop board | the **kanban** component |
| Advanced column filtering, date range, tree, stepper, ... | the matching ReUI **component** |
| A single generic control already in shadcn (Button, Dialog, Select) | plain **shadcn** |
## Detailed references
- [rules/registry.md](./rules/registry.md) - the four types, the @reui registry, base/radix, free vs premium + license
- [rules/workflow.md](./rules/workflow.md) - the find -> install -> read-API -> adapt loop (most important)
- [rules/components.md](./rules/components.md) - the 20 components, the data-grid contract, base vs radix
- [rules/adapting.md](./rules/adapting.md) - reuse-first: preserve the design (no over-customizing), reuse examples + a block's own elements, real data, don't invent APIs
- [rules/craft.md](./rules/craft.md) - make it exceptional: point of view, hierarchy, density, states, responsive, motion, the bar
- [rules/quality.md](./rules/quality.md) - security, accessibility, and scroll gates (the done gate)
- [rules/styling.md](./rules/styling.md) - ReUI extended tokens, theme adaptation, density
- [rules/icons.md](./rules/icons.md) - portable icons, swapping imports, Motion Icons (static + animated)
- [tools.md](./tools.md) - the ReUI MCP: golden path, the 19 tools, token rules, result shapes, errors
+1 -1
View File
@@ -5,7 +5,7 @@ user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
+48 -10
View File
@@ -1,6 +1,6 @@
# ReUI components
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
@@ -106,22 +106,60 @@ Common mistakes:
## filters
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const [filters, setFilters] = useState<Filter[]>([
createFilter("priority", "is_any_of", ["low"]),
])
const fields: FilterFieldConfig[] = [
{ key: "priority", label: "Priority", type: "multiselect",
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
const fields: FilterField[] = [
{ id: "title", label: "Title", type: "text" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters filters={filters} fields={fields} onChange={setFilters} />
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
## date-selector
+12 -1
View File
@@ -61,7 +61,16 @@ jobs:
STAGING=".ci/docker/backend"
rm -rf "$STAGING"
mkdir -p "$STAGING/packages/contracts" "$STAGING/backend"
cp package.json package-lock.json "$STAGING/"
cp package-lock.json "$STAGING/"
node <<'NODE'
const fs = require("node:fs")
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"))
pkg.workspaces = ["packages/*", "backend"]
pkg.dependencies = {}
pkg.devDependencies = {}
delete pkg.scripts
fs.writeFileSync(".ci/docker/backend/package.json", `${JSON.stringify(pkg, null, 2)}\n`)
NODE
cp packages/contracts/package.json packages/contracts/tsconfig.json "$STAGING/packages/contracts/"
cp -R packages/contracts/src "$STAGING/packages/contracts/"
cp backend/package.json backend/tsconfig.json "$STAGING/backend/"
@@ -70,6 +79,7 @@ jobs:
cp -R backend/drizzle "$STAGING/backend/"
fi
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -177,6 +187,7 @@ jobs:
NEXT_PUBLIC_BACKEND_URL=same-origin
NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
NEXT_PUBLIC_ALLOW_MOCK_DATA=false
NEXT_PUBLIC_AUTH_PORTAL_URL=https://auth.shnt.top
NEXT_PUBLIC_APP_VERSION=${{ needs.prepare-release.outputs.version }}
NEXT_PUBLIC_RELEASE_URL=${{ needs.prepare-release.outputs.release_url }}
tags: |
+1 -1
View File
@@ -5,7 +5,7 @@ user-invocable: false
allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *)
---
> **ReUI skill version `668fb463eb`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
> **ReUI skill version `3bdbad788a`.** If the ReUI MCP's `get_agent_skill` reports a newer `version`, re-run the ReUI installer (see `get_agent_skill` -> `install.recommended`) to update this skill. Cloud/tools-only agents have no local file and always read the latest - they can ignore this.
# ReUI for Agents
+48 -10
View File
@@ -1,6 +1,6 @@
# ReUI components
The 20 ReUI building blocks: `alert`, `autocomplete`, `badge`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
The 21 ReUI building blocks: `alert`, `autocomplete`, `badge`, `cascader`, `data-grid`, `date-selector`, `event-calendar`, `filters`, `frame`, `gantt`, `icon-stack`, `icon-tile`, `kanban`, `number-field`, `phone-input`, `rating`, `scrollspy`, `sortable`, `stepper`, `timeline`, `tree`. Examples and blocks are composed from these.
**Rule one: never guess a component's API. Read it first.** Call **`get_component(name)`** for its inline `api` (props + usage, no web fetch), and **share the result's `docsUrl`** (the component's API documentation page) with the user whenever you work with that component's API, so they have the full reference (the `/llms.txt` index is a further fallback). Then call **`get_examples(name)`** to install a worked example and copy real composition. The contracts below are first-try orientation (required props, composition shape, the one gotcha); the inline `api` is the full reference. No single block fits? Compose: search the components you need, read each `get_component`, install a `get_examples` example per component, and adapt.
@@ -106,22 +106,60 @@ Common mistakes:
## filters
**Required:** `filters` (`Filter[]`), `fields` (`FilterFieldConfig[]`), `onChange`
**Required:** `fields` (`FilterField[]`). The value is ONE `FilterQuery` tree - `query` + `onQueryChange`, or uncontrolled `defaultQuery`.
**Shape:**
```tsx
const [filters, setFilters] = useState<Filter[]>([
createFilter("priority", "is_any_of", ["low"]),
])
const fields: FilterFieldConfig[] = [
{ key: "priority", label: "Priority", type: "multiselect",
options: [{ value: "low", label: "Low" }, { value: "high", label: "High" }] },
const fields: FilterField[] = [
{ id: "title", label: "Title", type: "text" },
{
id: "status",
label: "Status",
type: "select",
options: [
{ value: "active", label: "Active" },
{ value: "archived", label: "Archived" },
],
},
]
const [query, setQuery] = useState<FilterQuery>(() => createFilterQuery())
<Filters filters={filters} fields={fields} onChange={setFilters} />
<Filters fields={fields} query={query} onQueryChange={setQuery} />
```
**Gotcha:** always build initial filters with `createFilter(field, operator, values)` - it generates the required `id`. Never hand-construct a `Filter` object. Pairs naturally with `data-grid`.
**Gotcha:** the state is a TREE, not a list of chips. `FilterQuery` is a group of rules joined by `and`/`or` and a group may hold another group, so `(A and B) or C` is expressible; a rule is `{ id, type: "rule", path: ["status"], operator, value }` and `path` is the whole nested attribute path, root first. The pre-rewrite API is GONE: there is no `filters`/`onChange` prop, no `FilterFieldConfig` (fields are `FilterField`, nested through their own `fields`, keyed `id` not `key`), and no `createFilter()` - it minted ids inside a pure function and broke hydration, so ids now come from `createFilterIdFactory(seed)` seeded off `useId`, and `createFilterQuery()` / `createFilterRule()` take one. Read the query back with `flattenFilterConditions` (`{ path, field, operator, values, negated }` per rule, incomplete rules skipped) and walk the tree yourself when the parentheses carry meaning - the primitive compiles nothing, no SQL, no query string.
`variant` picks the chrome over that one query: `"basic"`, the default, is the flat chip row for a toolbar over a table; `"advanced"` is the condition builder, hung off a trigger or rendered in place with `advancedMode="inline"`. Both read and write the same tree, so a saved view built in one opens in the other. Other props worth knowing before you hand-roll them: `size` is two rungs, `"sm" | "default"`, resolved per style (there is no `lg`); `reorderable` turns on drag and Alt+Arrow row moves in the builder; `onBeforeQueryChange` is the ONE veto point for every write (return `false` to refuse, it cannot rewrite); `editors` registers custom value editors a field selects by `editor` name; `labels` / `operatorLabels` own every rendered string; `pathCollapse` + `maxPathSegments` shorten deep attribute paths; `renderChip` / `renderValue` / `renderEmpty` replace rendered parts. On a field, `loadOptions` supplies async options with paging and `resolveValues` renders a chip restored from a saved view whose option was never loaded. Pairs naturally with `data-grid`.
## cascader
**Required:** `items` (a tree of `{ value, label, children? }`), plus the panel parts inside `CascaderContent`.
**Shape:**
```tsx
<Cascader items={items} value={value} onValueChange={setValue}>
<CascaderTrigger render={<Button variant="outline" />}>
<CascaderValue placeholder="Select an attribute" />
</CascaderTrigger>
<CascaderContent className="w-80">
<CascaderPanel>
<CascaderNav>
<CascaderBreadcrumb />
<CascaderInput />
</CascaderNav>
<CascaderEmpty />
<CascaderList maxHeight={288}>
<CascaderItems />
</CascaderList>
<CascaderStatus />
</CascaderPanel>
</CascaderContent>
</Cascader>
```
**Gotcha:** pressing a branch NAVIGATES, it does not select - only leaves are selectable until you pass `selectable="any"` or a predicate, and once a branch is selectable its chevron becomes the only way to open it. `CascaderInput` must stay inside `CascaderContent` (Base UI refills the query from the selection when the input sits outside the popup). Always include `CascaderStatus`: it is the live region announcing level changes, which the visual breadcrumb does not provide to screen readers. Accepts a flat adjacency list via `getParent` as well as nested `children`. `searchScope="deep"` searches every level and annotates results with their path; `multiple` gives checkbox rows; `inline` + a bare `CascaderPanel` embeds it with no popover.
The shape above is `mode="drill"`, the default. `mode="tree"` keeps the same parts (drop `CascaderBreadcrumb`, pass `showBack={false}`, drive expansion with `expanded`/`onExpandedChange`); `mode="columns"` REPLACES `CascaderList` + `CascaderItems` with a single `CascaderColumns`, and has no breadcrumb. Other props worth knowing before you hand-roll them: `cascade` (multi-select only, parent/child selection with indeterminate branches - pair it with `selectable="any"`, since a leaf-only tree can never cascade), `indicator={false}` to drop the single-select check and its gutter (visual only, no-op with `multiple`), `virtualize`/`virtualizeThreshold` plus `CascaderVirtualItems` for long levels, and `getChildren` for async levels with cursor paging, retry on failure and optional `prefetch`. `CascaderFooter` pins commands below the list (`actions` is the quick path) and `CascaderSubmenu` opens one as a side-anchored flyout with the full menu keyboard model. To head a run of rows use `CascaderGroup` wrapping a `CascaderLabel` - a bare label inside a listbox names nothing and is dropped from the accessibility tree - and `CascaderSeparator` for the rule between runs. Every rendered string comes from `labels`, and the panel is RTL-correct under a `DirectionProvider` or `dir="rtl"`.
## date-selector
+2
View File
@@ -14,12 +14,14 @@ ARG NEXT_PUBLIC_DEFAULT_DATA_SOURCE=live
ARG NEXT_PUBLIC_ALLOW_MOCK_DATA=false
ARG NEXT_PUBLIC_APP_VERSION=dev
ARG NEXT_PUBLIC_RELEASE_URL=
ARG NEXT_PUBLIC_AUTH_PORTAL_URL=https://auth.shnt.top
ENV BACKEND_INTERNAL_URL=$BACKEND_INTERNAL_URL
ENV NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL
ENV NEXT_PUBLIC_DEFAULT_DATA_SOURCE=$NEXT_PUBLIC_DEFAULT_DATA_SOURCE
ENV NEXT_PUBLIC_ALLOW_MOCK_DATA=$NEXT_PUBLIC_ALLOW_MOCK_DATA
ENV NEXT_PUBLIC_APP_VERSION=$NEXT_PUBLIC_APP_VERSION
ENV NEXT_PUBLIC_RELEASE_URL=$NEXT_PUBLIC_RELEASE_URL
ENV NEXT_PUBLIC_AUTH_PORTAL_URL=$NEXT_PUBLIC_AUTH_PORTAL_URL
COPY packages/contracts packages/contracts
COPY next.config.ts tsconfig.json postcss.config.mjs components.json ./
COPY app app
+57 -1
View File
@@ -135,6 +135,7 @@ sequenceDiagram
- **Node.js 22** (как в `Dockerfile.frontend` и `backend/Dockerfile`).
- **npm** с workspaces; установка из корня: `npm ci` или `npm install`.
- Для нативной сборки `better-sqlite3` на Linux может понадобиться toolchain (`python3`, `make`, `g++`); в Docker-образе backend они уже ставятся.
- Backend Docker-образ ставит только workspaces `backend` + `contracts` (без корневых Next/React deps); в production логи — JSON без `pino-pretty`.
### Запуск
@@ -219,7 +220,62 @@ npm --prefix backend run db:studio
## Прод-развёртывание Docker
Эталон: `deploy/docker-compose.yml`. Рабочий каталог для команд compose — `deploy/` (или укажите `-f deploy/docker-compose.yml` из корня репозитория).
Эталон без reverse-proxy: `deploy/docker-compose.yml` (порты `3000` / `8000` на хост).
Стек с Traefik + HTTPS (Let's Encrypt DNS-01 / Cloudflare), по аналогии с CDNManager: [`deploy/docker-compose.traefik.yml`](deploy/docker-compose.traefik.yml) + [`deploy/env.traefik.example`](deploy/env.traefik.example). На сервере публикуются только `:80`/`:443`; frontend получает HTTPS, `/api` и `/health` проксируются на backend внутри сети `mmapp`. Домен по умолчанию: `mm.shnt.top`.
### CDN Manager + MikrotikManager (один Traefik)
Полный стек: Traefik + `cdn.shnt.top` + `mm.shnt.top` в одном Compose.
| Файл | Назначение |
|------|------------|
| [`deploy/docker-compose.cdn-mm.yml`](deploy/docker-compose.cdn-mm.yml) | Traefik + CDN Manager + MM backend/frontend/updater |
| [`deploy/env.cdn-mm.example`](deploy/env.cdn-mm.example) | общий `.env` |
```bash
mkdir -p /opt/cdn-mm/{data/cdn,data/mm,state,updater}
cp deploy/docker-compose.cdn-mm.yml /opt/cdn-mm/docker-compose.yml
cp deploy/env.cdn-mm.example /opt/cdn-mm/.env
cp deploy/updater/targets.json.example /opt/cdn-mm/updater/targets.json
# заполнить CF_DNS_API_TOKEN, CLOUDFLARE_API_TOKEN, AUTH_JWT_SECRET, CORS_ORIGIN, …
docker login git.shx.one
cd /opt/cdn-mm && docker compose pull && docker compose up -d
curl -fsS https://cdn.shnt.top/health
curl -fsS https://mm.shnt.top/health
```
Данные: `./data/cdn` (CDN), `./data/mm` (MM). Сеть Traefik: `edge`. Не запускайте параллельно standalone `docker-compose.traefik.yml` CDNManager или MM на тех же 80/443.
**Если на сервере уже крутится CDNManager Traefik** (`cdnmanager-traefik`, сеть `cdnmanager`) — **не** поднимайте второй Traefik. Варианты:
| Способ | Файл |
|--------|------|
| Compose без своего Traefik | [`deploy/docker-compose.traefik-cdn.yml`](deploy/docker-compose.traefik-cdn.yml) |
| Plain `docker` CLI (скрипт) | [`deploy/run-beside-cdn-traefik.sh`](deploy/run-beside-cdn-traefik.sh) |
Frontend вешается в сеть `cdnmanager` с Traefik-labels; backend/updater остаются в `mmapp`. Сертификат для `MM_DOMAIN` выпускает уже работающий Traefik CDNManager (тот же `letsencrypt` / Cloudflare DNS-01).
```bash
# Compose (рекомендуется)
mkdir -p /opt/mmapp/{data,state,updater}
cp deploy/docker-compose.traefik-cdn.yml /opt/mmapp/docker-compose.yml
cp deploy/env.traefik.example /opt/mmapp/.env # MM_DOMAIN + CORS_ORIGIN
cp deploy/updater/targets.json.example /opt/mmapp/updater/targets.json
cd /opt/mmapp && docker compose pull && docker compose up -d
# Или одной CLI-командой (скрипт сам сделает network/pull/run/connect):
curl -fsSL -o /tmp/run-beside-cdn-traefik.sh \
https://git.shx.one/denozord/MikrotikManager/raw/branch/main/deploy/run-beside-cdn-traefik.sh
chmod +x /tmp/run-beside-cdn-traefik.sh
sudo MM_DOMAIN=mm.shnt.top CORS_ORIGIN=https://mm.shnt.top /tmp/run-beside-cdn-traefik.sh
```
DNS: `A`/`AAAA` для `mm.shnt.top` → IP VPS, Cloudflare **DNS only**. Проверка: `curl -fsS https://mm.shnt.top/health`.
SSO auth-portal: [`docs/integrate-auth-portal.md`](docs/integrate-auth-portal.md) (app id `mm`).
Рабочий каталог для команд compose — `deploy/` (или `-f deploy/docker-compose.yml` / `-f deploy/docker-compose.traefik.yml` / `-f deploy/docker-compose.traefik-cdn.yml` / `-f deploy/docker-compose.cdn-mm.yml` из корня).
### Прод-контейнеры
+2 -2
View File
@@ -28,6 +28,7 @@ import { useDataSource } from "@/lib/data-source"
import { listServers } from "@/shared/api/servers"
import { toFrontendServer } from "@/entities/server/model/mappers"
import { createBackupsAsync, deleteBackup, getBackupJob, getBackupScheduleSettings, listBackups, putBackupScheduleSettings, type BackupItem } from "@/shared/api/backups"
import { requestBlob } from "@/shared/api/http-client"
import { toast } from "sonner"
import {
Stepper,
@@ -276,8 +277,7 @@ export default function BackupsPage() {
}
async function handleDownload(id: string, fallbackFilename: string) {
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/backups/${id}/download`)
if (!res.ok) throw new Error("Не удалось скачать файл")
const res = await requestBlob(backendUrl, `/api/backups/${id}/download`)
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
+2 -5
View File
@@ -23,6 +23,7 @@ import {
XIcon, AlertCircleIcon,
} from "lucide-react"
import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
// ─── types ────────────────────────────────────────────────────────────────────
@@ -621,11 +622,7 @@ export default function BgpPage() {
if (cancelled) return
setLoading(true)
setLiveError(null)
fetch(`${backendUrl}/api/bgp/sessions`)
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`)
return r.json() as Promise<BackendBgpSession[]>
})
void requestJson<BackendBgpSession[]>(backendUrl, "/api/bgp/sessions")
.then(data => {
if (cancelled) return
setLiveSessions(data.map(backendToFrontend))
+20 -17
View File
@@ -1,5 +1,6 @@
import type { CSSProperties, ReactNode } from "react"
import { AppSidebar } from "@/components/app-sidebar"
import { AuthGuard } from "@/components/auth-guard"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"
import { CommandPalette } from "@/components/command-palette"
import { ReleaseNotesModal } from "@/components/release-notes-modal"
@@ -11,22 +12,24 @@ const SKIP_TO_CONTENT_CLASS =
export default function MainLayout({ children }: { children: ReactNode }) {
return (
<DataSourceProvider>
<EvoBGPProvider>
<SidebarProvider
style={{ "--sidebar-width": "240px" } as CSSProperties}
>
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
К содержимому
</a>
<AppSidebar />
<SidebarInset id="main-content" className="h-svh overflow-hidden">
{children}
</SidebarInset>
<CommandPalette />
<ReleaseNotesModal />
</SidebarProvider>
</EvoBGPProvider>
</DataSourceProvider>
<AuthGuard>
<DataSourceProvider>
<EvoBGPProvider>
<SidebarProvider
style={{ "--sidebar-width": "240px" } as CSSProperties}
>
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
К содержимому
</a>
<AppSidebar />
<SidebarInset id="main-content" className="h-svh overflow-hidden">
{children}
</SidebarInset>
<CommandPalette />
<ReleaseNotesModal />
</SidebarProvider>
</EvoBGPProvider>
</DataSourceProvider>
</AuthGuard>
)
}
+10 -9
View File
@@ -16,6 +16,7 @@ import {
} from "lucide-react"
import { cn } from "@/lib/utils"
import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
import { Flag } from "@/components/flag"
import { readStoredRouteOptimizerSettings } from "@/lib/route-optimizer-data"
@@ -733,13 +734,14 @@ function InterfacesTab({
const ra = readStoredRouteOptimizerSettings()
setOptimizing(true)
try {
const r = await fetch(`${backendUrl}/api/servers/${filterServerId}/ospf/optimize`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ pingWeight: ra.pingWeight }),
})
if (!r.ok) throw new Error(`HTTP ${r.status}`)
const data = await r.json() as BackendOspfOptimizeResponse
const data = await requestJson<BackendOspfOptimizeResponse>(
backendUrl,
`/api/servers/${filterServerId}/ospf/optimize`,
{
method: "POST",
body: JSON.stringify({ pingWeight: ra.pingWeight }),
},
)
const byKey: Record<string, number> = {}
data.interfaces.forEach((row) => {
byKey[`${data.serverId}-${row.id}`] = row.optimalCost
@@ -1120,8 +1122,7 @@ export default function OspfPage() {
if (cancelled) return
setLoading(true)
setLiveError(null)
fetch(`${backendUrl}/api/ospf/all`)
.then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() as Promise<BackendOspfAll> })
void requestJson<BackendOspfAll>(backendUrl, "/api/ospf/all")
.then(data => {
if (cancelled) return
setLiveData(data); setFetchedAt(new Date()); setLoading(false)
+4 -1
View File
@@ -875,8 +875,11 @@ export default function SettingsPage() {
await evo.saveSettings(patch)
setEvoKeyDraft("")
markSaved()
toast.success("Настройки EvoBGP сохранены")
} catch (e) {
setEvoSaveErr(e instanceof Error ? e.message : "Ошибка сохранения")
const msg = e instanceof Error ? e.message : "Ошибка сохранения"
setEvoSaveErr(msg)
toast.error(msg)
} finally {
setEvoSaveBusy(false)
}
+14 -12
View File
@@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"
import { servers as mockServers } from "@/lib/data"
import { Flag } from "@/components/flag"
import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
import {
TrashIcon, RefreshCwIcon, CircleIcon, Loader2Icon,
} from "lucide-react"
@@ -259,12 +260,14 @@ function Terminal({
if (isLive && server.backendId !== null) {
setExecuting(true)
try {
const res = await fetch(`${backendUrl}/api/servers/${server.backendId}/exec`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ command: cmd }),
})
const data = await res.json() as { output?: string; error?: string }
const data = await requestJson<{ output?: string; error?: string }>(
backendUrl,
`/api/servers/${server.backendId}/exec`,
{
method: "POST",
body: JSON.stringify({ command: cmd }),
},
)
const text = data.output ?? data.error ?? "(empty response)"
const kind: TermLine["kind"] = text.startsWith("error:") ? "error" : "output"
text.split("\n").forEach(line =>
@@ -427,7 +430,7 @@ interface BackendServer {
}
export default function TerminalPage() {
const { mode, backendUrl } = useDataSource()
const { mode, backendUrl, prefsHydrated } = useDataSource()
const isLive = mode === "live"
// Server list state
@@ -437,14 +440,13 @@ export default function TerminalPage() {
// Load servers from backend when in live mode
useEffect(() => {
if (!isLive) return
if (!isLive || !prefsHydrated) return
let cancelled = false
queueMicrotask(() => {
if (cancelled) return
setServersLoading(true)
fetch(`${backendUrl}/api/servers`)
.then(r => r.json() as Promise<BackendServer[]>)
.then(data => {
void requestJson<BackendServer[]>(backendUrl, "/api/servers")
.then((data) => {
if (cancelled) return
setLiveServers(data.map(s => ({
uid: String(s.id),
@@ -462,7 +464,7 @@ export default function TerminalPage() {
.catch(() => { if (!cancelled) setServersLoading(false) })
})
return () => { cancelled = true }
}, [isLive, backendUrl, refreshKey])
}, [isLive, backendUrl, refreshKey, prefsHydrated])
const termServers: TermServer[] = isLive ? liveServers : mockServersToTermServers()
+455 -167
View File
@@ -1,9 +1,9 @@
"use client"
import { useMemo, useState } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { PageHeader } from "@/components/page-header"
import { servers } from "@/lib/data"
import type { WireGuardInterface } from "@/lib/data"
import { servers as mockServers } from "@/lib/data"
import type { Server } from "@/lib/data"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import {
@@ -14,22 +14,32 @@ import { Button } from "@/components/ui/button"
import { Frame, FramePanel } from "@/components/reui/frame"
import { IconTile } from "@/components/reui/icon-tile"
import { OpsPanel } from "@/components/ops-panel"
import { cn } from "@/lib/utils"
import { useDataSource } from "@/lib/data-source"
import { requestJson } from "@/shared/api/http-client"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
createWireGuardInterface,
createWireGuardPeer,
deleteWireGuardInterface,
deleteWireGuardPeer,
exportWireGuard,
importWireGuard,
listWireGuard,
patchWireGuardInterface,
} from "@/shared/api/wireguard"
import type { WgIfaceDto } from "@mmapp/contracts/wireguard"
import { WgCreateSheet, type WgCreateFormState } from "@/components/wireguard/wg-create-sheet"
import { WgImportSheet } from "@/components/wireguard/wg-import-sheet"
import { WgExportSheet } from "@/components/wireguard/wg-export-sheet"
import { WgPeerSheet, type WgPeerFormState } from "@/components/wireguard/wg-peer-sheet"
import { toast } from "sonner"
import {
ShieldCheckIcon, PlusIcon, KeyRoundIcon,
CodeXmlIcon, UsersIcon, ActivityIcon,
CopyIcon, CheckIcon,
UsersIcon, ActivityIcon, RefreshCwIcon, UploadIcon,
} from "lucide-react"
// ─── collect all WireGuard interfaces from all servers ────────────────────────
function collectInterfaces(): WgIfaceWithServer[] {
function collectMockInterfaces(): WgIfaceWithServer[] {
const result: WgIfaceWithServer[] = []
for (const srv of servers) {
for (const srv of mockServers) {
for (const wg of srv.wireGuardIfaces ?? []) {
result.push({
...wg,
@@ -42,117 +52,342 @@ function collectInterfaces(): WgIfaceWithServer[] {
return result
}
// ─── helpers ──────────────────────────────────────────────────────────────────
// ─── RSC generator ────────────────────────────────────────────────────────────
function generateWgRsc(iface: WgIfaceWithServer): string {
const lines: string[] = []
lines.push(`# WireGuard — ${iface.name} · ${iface.serverName}`)
lines.push(`# RouterOS 7.x`)
lines.push(``)
lines.push(`/interface wireguard add \\`)
lines.push(` name=${iface.name} \\`)
lines.push(` listen-port=${iface.listenPort} \\`)
lines.push(` mtu=${iface.mtu} \\`)
if (iface.comment) lines.push(` comment="${iface.comment}" \\`)
if (!iface.enabled) lines.push(` disabled=yes \\`)
lines.push(``)
for (const p of iface.peers) {
lines.push(`/interface wireguard peers add \\`)
lines.push(` interface=${iface.name} \\`)
lines.push(` public-key="${p.publicKey}" \\`)
lines.push(` allowed-address=${p.allowedIps.join(",")} \\`)
if (p.endpoint) lines.push(` endpoint-address=${p.endpoint.split(":")[0]} \\`)
if (p.endpoint) lines.push(` endpoint-port=${p.endpoint.split(":")[1] ?? "13231"} \\`)
if (p.persistent) lines.push(` persistent-keepalive=25 \\`)
if (p.comment) lines.push(` comment="${p.comment}" \\`)
lines.push(``)
function dtoToRow(d: WgIfaceDto): WgIfaceWithServer {
return {
id: d.id,
rosId: d.rosId,
name: d.name,
listenPort: d.listenPort,
mtu: d.mtu,
publicKey: d.publicKey,
privateKey: d.privateKey,
address: d.address,
peers: d.peers.map((p) => ({
id: p.id,
rosId: p.rosId,
publicKey: p.publicKey,
allowedIps: p.allowedIps,
endpoint: p.endpoint,
latestHandshake: p.latestHandshake,
transferRx: p.transferRx,
transferTx: p.transferTx,
persistentKeepalive: p.persistentKeepalive,
persistent: p.persistent,
comment: p.comment,
disabled: p.disabled,
name: p.name,
clientAddress: p.clientAddress,
clientDns: p.clientDns,
clientEndpoint: p.clientEndpoint,
})),
comment: d.comment,
enabled: d.enabled,
status: d.status,
serverId: d.serverId,
serverName: d.serverName,
serverCountry: d.serverCountry ?? "UN",
}
return lines.join("\n")
}
// ─── Export Sheet ─────────────────────────────────────────────────────────────
interface BackendServer {
id: number
name: string
host: string
country: string
enabled: boolean
}
function ExportSheet({ open, iface, onClose }: {
open: boolean; iface: WgIfaceWithServer | null; onClose: () => void
}) {
const [copied, setCopied] = useState(false)
const code = useMemo(() => iface ? generateWgRsc(iface) : "", [iface])
function mapBackendServer(s: BackendServer): Server {
return {
id: String(s.id),
name: s.name || s.host,
host: s.host,
model: "—",
os: "—",
site: "",
country: s.country || "UN",
asn: "",
type: "exit-node",
enabled: s.enabled,
status: "online",
latency: null,
sessions: 0,
}
}
function handleCopy() {
navigator.clipboard.writeText(code).then(() => {
setCopied(true); setTimeout(() => setCopied(false), 2000)
})
function parseEndpoint(endpoint: string): { address?: string; port?: number } {
const t = endpoint.trim()
if (!t) return {}
const idx = t.lastIndexOf(":")
if (idx <= 0) return { address: t }
return {
address: t.slice(0, idx),
port: Number.parseInt(t.slice(idx + 1), 10) || undefined,
}
return (
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl">
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
<div className="flex items-start justify-between gap-4">
<div>
<SheetTitle>Экспорт WireGuard</SheetTitle>
<SheetDescription>RouterOS 7.x · /interface wireguard + peers</SheetDescription>
</div>
<Button variant="outline" size="sm" className="shrink-0" onClick={handleCopy}>
{copied
? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</>
: <><CopyIcon className="size-3.5" />Копировать</>}
</Button>
</div>
</SheetHeader>
<div className="flex-1 overflow-y-auto">
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
{code.split("\n").map((line, i) => {
const isComment = line.startsWith("#")
const isCmd = line.trimStart().startsWith("/interface")
const isParam = /^\s+[a-z]/.test(line)
return (
<span key={i} className={
isComment ? "text-muted-foreground"
: isCmd ? "text-sky-400"
: isParam ? "text-violet-300"
: "text-foreground"
}>
{line}{"\n"}
</span>
)
})}
</pre>
</div>
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
<Button className="flex-1" onClick={handleCopy}>
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
{copied ? "Скопировано" : "Копировать .rsc"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
// ════════════════════════════════════════════════════════════════════════════
export default function WireGuardPage() {
const allIfaces = useMemo(() => collectInterfaces(), [])
const { mode, backendUrl } = useDataSource()
const isLive = mode === "live"
const [liveIfaces, setLiveIfaces] = useState<WgIfaceWithServer[]>([])
const [liveServers, setLiveServers] = useState<Server[]>([])
const [loading, setLoading] = useState(false)
const [busy, setBusy] = useState(false)
const [search, setSearch] = useState("")
const [createOpen, setCreateOpen] = useState(false)
const [importOpen, setImportOpen] = useState(false)
const [exportIface, setExportIface] = useState<WgIfaceWithServer | null>(null)
const [peerIface, setPeerIface] = useState<WgIfaceWithServer | null>(null)
const [liveExport, setLiveExport] = useState<{
rsc?: string
conf?: string
peerConf?: string
} | null>(null)
const [exportBusy, setExportBusy] = useState(false)
const loadLive = useCallback(async () => {
if (!isLive) return
setLoading(true)
try {
const [wg, servers] = await Promise.all([
listWireGuard(backendUrl),
requestJson<BackendServer[]>(backendUrl, "/api/servers"),
])
setLiveIfaces(wg.interfaces.map(dtoToRow))
setLiveServers(servers.filter((s) => s.enabled).map(mapBackendServer))
if (wg.failures?.length) {
toast.warning(
`Не удалось опросить: ${wg.failures.map((f) => f.serverName ?? f.serverId).join(", ")}`,
)
}
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка загрузки WireGuard")
setLiveIfaces([])
} finally {
setLoading(false)
}
}, [isLive, backendUrl])
useEffect(() => {
if (!isLive) {
queueMicrotask(() => {
setLiveIfaces([])
setLiveServers([])
})
return
}
queueMicrotask(() => {
void loadLive()
})
}, [isLive, loadLive])
const displayIfaces = isLive ? liveIfaces : collectMockInterfaces()
const displayServers = isLive ? liveServers : mockServers.filter((s) => s.enabled)
const filtered = useMemo(() => {
if (!search) return allIfaces
if (!search) return displayIfaces
const q = search.toLowerCase()
return allIfaces.filter((i) =>
i.name.includes(q) ||
i.serverName.toLowerCase().includes(q) ||
i.peers.some((p) => p.allowedIps.some((a) => a.includes(q)) || (p.endpoint ?? "").includes(q))
return displayIfaces.filter(
(i) =>
i.name.toLowerCase().includes(q) ||
i.serverName.toLowerCase().includes(q) ||
i.peers.some(
(p) =>
p.allowedIps.some((a) => a.includes(q)) ||
(p.endpoint ?? "").includes(q),
),
)
}, [allIfaces, search])
}, [displayIfaces, search])
const totalPeers = allIfaces.reduce((s, i) => s + i.peers.length, 0)
const onlinePeers = allIfaces.reduce((s, i) => s + i.peers.filter((p) => !!p.latestHandshake).length, 0)
const upIfaces = allIfaces.filter((i) => i.status === "up").length
const totalPeers = displayIfaces.reduce((s, i) => s + i.peers.length, 0)
const onlinePeers = displayIfaces.reduce(
(s, i) => s + i.peers.filter((p) => !!p.latestHandshake).length,
0,
)
const upIfaces = displayIfaces.filter((i) => i.status === "up").length
const serverOptions = displayServers.map((s) => ({
id: s.id,
name: s.name,
host: s.host,
}))
async function handleCreate(form: WgCreateFormState) {
if (!isLive) {
toast.info("Создание на роутер доступно только в live-режиме")
return
}
setBusy(true)
try {
const ep = parseEndpoint(form.peerEndpoint)
await createWireGuardInterface(backendUrl, {
serverId: form.serverId,
name: form.name.trim(),
listenPort: Number.parseInt(form.listenPort, 10) || 13231,
mtu: Number.parseInt(form.mtu, 10) || 1420,
comment: form.comment || undefined,
address: form.address.trim() || undefined,
disabled: !form.enabled,
peer: form.peerEnabled && form.peerPublicKey.trim()
? {
publicKey: form.peerPublicKey.trim(),
allowedAddresses: form.peerAllowedIps
.split(",")
.map((s) => s.trim())
.filter(Boolean),
endpointAddress: ep.address,
endpointPort: ep.port,
persistentKeepalive: Number.parseInt(form.peerKeepalive, 10) || undefined,
comment: form.peerComment || undefined,
}
: undefined,
})
toast.success(`Интерфейс ${form.name} создан`)
setCreateOpen(false)
await loadLive()
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка создания")
} finally {
setBusy(false)
}
}
async function handleImport(args: {
serverId: string
content: string
format: "auto" | "rsc" | "conf"
dryRun: boolean
}) {
if (!isLive) {
toast.info("Импорт на роутер доступен только в live-режиме")
return
}
setBusy(true)
try {
const res = await importWireGuard(backendUrl, {
serverId: args.serverId,
content: args.content,
format: args.format,
dryRun: args.dryRun,
})
toast.success(
res.applied
? `Импортировано: ${res.applied.interfaceName} (+${res.applied.peersCreated} пиров)`
: "Импорт выполнен",
)
setImportOpen(false)
await loadLive()
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка импорта")
} finally {
setBusy(false)
}
}
async function handleToggle(iface: WgIfaceWithServer) {
if (!isLive || !iface.rosId) {
toast.info("Доступно только в live-режиме")
return
}
try {
await patchWireGuardInterface(backendUrl, iface.serverId, iface.rosId, {
disabled: iface.enabled,
})
toast.success(iface.enabled ? "Отключено" : "Включено")
await loadLive()
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка")
}
}
async function handleDelete(iface: WgIfaceWithServer) {
if (!isLive || !iface.rosId) {
toast.info("Доступно только в live-режиме")
return
}
if (!window.confirm(`Удалить интерфейс ${iface.name} на ${iface.serverName}?`)) return
try {
await deleteWireGuardInterface(backendUrl, iface.serverId, iface.rosId)
toast.success("Удалено")
await loadLive()
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка удаления")
}
}
async function handleAddPeer(form: WgPeerFormState) {
if (!isLive || !peerIface) {
toast.info("Доступно только в live-режиме")
return
}
setBusy(true)
try {
const ep = parseEndpoint(form.endpoint)
await createWireGuardPeer(backendUrl, {
serverId: peerIface.serverId,
interfaceName: peerIface.name,
publicKey: form.publicKey.trim(),
allowedAddresses: form.allowedIps
.split(",")
.map((s) => s.trim())
.filter(Boolean),
endpointAddress: ep.address,
endpointPort: ep.port,
persistentKeepalive: Number.parseInt(form.keepalive, 10) || undefined,
comment: form.comment || undefined,
})
toast.success("Пир добавлен")
setPeerIface(null)
await loadLive()
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка")
} finally {
setBusy(false)
}
}
async function handleDeletePeer(iface: WgIfaceWithServer, peerId: string) {
if (!isLive) {
toast.info("Доступно только в live-режиме")
return
}
if (!window.confirm("Удалить пира?")) return
try {
await deleteWireGuardPeer(backendUrl, iface.serverId, peerId)
toast.success("Пир удалён")
await loadLive()
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка")
}
}
async function handleLiveExport(format: "rsc" | "conf" | "peer-conf") {
if (!exportIface || !isLive) return
setExportBusy(true)
try {
const res = await exportWireGuard(backendUrl, {
serverId: exportIface.serverId,
interfaceName: exportIface.name,
format,
includePrivateKey: format !== "peer-conf",
})
setLiveExport((prev) => ({
...prev,
...(format === "rsc"
? { rsc: res.content }
: format === "conf"
? { conf: res.content }
: { peerConf: res.content }),
}))
toast.success("Конфиг загружен с роутера")
} catch (e) {
toast.error(e instanceof Error ? e.message : "Ошибка экспорта")
} finally {
setExportBusy(false)
}
}
return (
<div className="flex flex-col h-full">
@@ -160,8 +395,24 @@ export default function WireGuardPage() {
crumbs={[{ label: "Управление" }, { label: "WireGuard" }]}
actions={
<>
<Button size="sm">
<PlusIcon className="size-4" />Новый интерфейс
{isLive && (
<Button
size="sm"
variant="outline"
disabled={loading}
onClick={() => void loadLive()}
>
<RefreshCwIcon className={`size-4 ${loading ? "animate-spin" : ""}`} />
Обновить
</Button>
)}
<Button size="sm" variant="outline" onClick={() => setImportOpen(true)}>
<UploadIcon className="size-4" />
Импорт
</Button>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<PlusIcon className="size-4" />
Новый интерфейс
</Button>
</>
}
@@ -169,14 +420,12 @@ export default function WireGuardPage() {
<div className="flex-1 overflow-y-auto p-6">
<div className="flex flex-col gap-5">
{/* KPI */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: "Интерфейсов", value: allIfaces.length, icon: <ShieldCheckIcon className="size-4 text-muted-foreground" /> },
{ label: "Активных (UP)", value: upIfaces, icon: <ActivityIcon className="size-4 text-emerald-500" /> },
{ label: "Всего пиров", value: totalPeers, icon: <UsersIcon className="size-4 text-sky-400" /> },
{ label: "Пиров онлайн", value: `${onlinePeers}/${totalPeers}`, icon: <KeyRoundIcon className="size-4 text-violet-400" /> },
{ label: "Интерфейсов", value: displayIfaces.length, icon: <ShieldCheckIcon className="size-4 text-muted-foreground" /> },
{ label: "Активных (UP)", value: upIfaces, icon: <ActivityIcon className="size-4 text-emerald-500" /> },
{ label: "Всего пиров", value: totalPeers, icon: <UsersIcon className="size-4 text-sky-400" /> },
{ label: "Пиров онлайн", value: `${onlinePeers}/${totalPeers}`, icon: <KeyRoundIcon className="size-4 text-violet-400" /> },
].map((s) => (
<Frame key={s.label} className="h-full">
<FramePanel className="relative isolate flex h-full items-start gap-3">
@@ -192,19 +441,20 @@ export default function WireGuardPage() {
))}
</div>
{/* Info banner */}
<div className="flex items-start gap-3 rounded-lg bg-sky-500/5 border border-sky-500/20 px-4 py-3 text-sm">
<ShieldCheckIcon className="size-5 text-sky-500 shrink-0 mt-0.5" />
<div>
<p className="font-medium text-sky-600 dark:text-sky-400">WireGuard рекомендуемый туннельный протокол в RouterOS 7.x</p>
<p className="font-medium text-sky-600 dark:text-sky-400">
WireGuard live-интеграция RouterOS 7.x
</p>
<p className="text-muted-foreground text-xs mt-0.5">
Доступен с RouterOS 7.1+. Более высокая производительность и безопасность по сравнению с GRE+IPsec.
Ключи генерируются командой <code className="font-mono bg-muted px-1 rounded">/interface/wireguard/print</code>.
{isLive
? "Опрос /interface/wireguard на включённых серверах. Создание, импорт .rsc/.conf и экспорт с роутера."
: "Сейчас mock-режим. Переключитесь в live в настройках, чтобы применять изменения на MikroTik."}
</p>
</div>
</div>
{/* Search + table */}
<DataPageCard>
<DataPageToolbar
search={search}
@@ -214,63 +464,101 @@ export default function WireGuardPage() {
/>
<WireguardDataGrid
interfaces={filtered}
onExport={setExportIface}
onExport={(iface) => {
setLiveExport(null)
setExportIface(iface)
}}
onAddPeer={setPeerIface}
onToggle={handleToggle}
onDelete={handleDelete}
onDeletePeer={handleDeletePeer}
onExportPeer={(iface) => {
setLiveExport(null)
setExportIface(iface)
}}
/>
</DataPageCard>
{/* RouterOS reference */}
<OpsPanel title="RouterOS 7 · /interface wireguard — быстрые команды" contentClassName="px-5 py-4">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
{[
{
title: "Создать интерфейс",
lines: [
"/interface wireguard add \\",
" name=wg0 \\",
" listen-port=13231 \\",
" mtu=1420",
],
},
{
title: "Добавить пира",
lines: [
"/interface wireguard peers add \\",
" interface=wg0 \\",
' public-key="<ключ>" \\',
" allowed-address=10.0.0.2/32 \\",
" endpoint-address=1.2.3.4 \\",
" persistent-keepalive=25",
],
},
{
title: "Назначить IP",
lines: [
"/ip address add \\",
" address=10.210.0.1/30 \\",
" interface=wg0",
"",
"# Статус:",
"/interface wireguard print",
],
},
].map((b) => (
<div key={b.title}>
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">{b.title}</p>
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto">
{b.lines.join("\n")}
</pre>
</div>
))}
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
{[
{
title: "Создать интерфейс",
lines: [
"/interface wireguard add \\",
" name=wg0 \\",
" listen-port=13231 \\",
" mtu=1420",
],
},
{
title: "Добавить пира",
lines: [
"/interface wireguard peers add \\",
" interface=wg0 \\",
' public-key="<ключ>" \\',
" allowed-address=10.0.0.2/32 \\",
" endpoint-address=1.2.3.4 \\",
" persistent-keepalive=25",
],
},
{
title: "Назначить IP",
lines: [
"/ip address add \\",
" address=10.210.0.1/30 \\",
" interface=wg0",
"",
"# Статус:",
"/interface wireguard print",
],
},
].map((b) => (
<div key={b.title}>
<p className="font-sans font-semibold text-foreground/80 mb-1.5 text-[11px] uppercase tracking-wide">
{b.title}
</p>
<pre className="bg-zinc-950 rounded-md p-2.5 text-zinc-300 text-[11px] leading-relaxed overflow-x-auto">
{b.lines.join("\n")}
</pre>
</div>
))}
</div>
</OpsPanel>
</div>
</div>
<ExportSheet
<WgCreateSheet
open={createOpen}
onOpenChange={setCreateOpen}
servers={serverOptions}
busy={busy}
onSubmit={handleCreate}
/>
<WgImportSheet
open={importOpen}
onOpenChange={setImportOpen}
servers={serverOptions}
busy={busy}
onImport={handleImport}
/>
<WgPeerSheet
open={!!peerIface}
iface={peerIface}
busy={busy}
onOpenChange={(v) => { if (!v) setPeerIface(null) }}
onSubmit={handleAddPeer}
/>
<WgExportSheet
open={!!exportIface}
iface={exportIface}
onClose={() => setExportIface(null)}
onClose={() => {
setExportIface(null)
setLiveExport(null)
}}
liveContent={liveExport}
liveBusy={exportBusy}
onRequestLiveExport={isLive ? handleLiveExport : undefined}
/>
</div>
)
+19
View File
@@ -0,0 +1,19 @@
"use client"
export default function AccessDeniedPage() {
return (
<div className="flex min-h-svh flex-col items-center justify-center gap-3 p-6 text-center">
<h1 className="text-lg font-semibold">Нет доступа</h1>
<p className="text-muted-foreground max-w-md text-sm">
У вашей учётной записи нет приложения MikrotikManager (`mm`) или
необходимых прав. Обратитесь к администратору auth-portal.
</p>
<a
href="/auth/callback"
className="text-primary text-sm underline-offset-4 hover:underline"
>
Войти снова
</a>
</div>
)
}
+98
View File
@@ -0,0 +1,98 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import {
clearPortalHandoffFlag,
clearToken,
ensureAuthConfig,
firstAllowedPath,
getClaims,
getToken,
parseHashToken,
redirectToPortalLogin,
redirectToPortalLoginInteractive,
setToken,
} from "@/lib/auth"
export default function AuthCallbackPage() {
const router = useRouter()
const [message, setMessage] = useState("Перенаправление на Auth Portal…")
useEffect(() => {
let cancelled = false
void (async () => {
await ensureAuthConfig()
if (cancelled) return
const params = new URLSearchParams(window.location.search)
const error = params.get("error")
if (error === "sso_loop" || error === "jwt_rejected") {
redirectToPortalLoginInteractive()
return
}
const { accessToken } = parseHashToken(window.location.hash)
if (accessToken) {
setToken(accessToken)
clearPortalHandoffFlag()
const claims = getClaims()
if (!claims) {
clearToken()
redirectToPortalLoginInteractive()
return
}
if (!claims.apps.includes("mm")) {
setMessage("Нет доступа к приложению")
router.replace("/access-denied")
return
}
try {
const res = await fetch("/api/auth/config", {
headers: { Authorization: `Bearer ${accessToken}` },
})
if (res.status === 401) {
clearToken()
redirectToPortalLoginInteractive()
return
}
} catch {
/* ignore network — proceed */
}
const next = firstAllowedPath()
if (next === "/access-denied") {
router.replace("/access-denied")
return
}
router.replace(next)
return
}
if (getToken() && getClaims()) {
clearPortalHandoffFlag()
if (!getClaims()!.apps.includes("mm")) {
router.replace("/access-denied")
return
}
router.replace(firstAllowedPath())
return
}
const ok = redirectToPortalLogin(`${window.location.origin}/auth/callback`)
if (!ok) redirectToPortalLoginInteractive()
})()
return () => {
cancelled = true
}
}, [router])
return (
<div className="text-muted-foreground flex min-h-svh items-center justify-center p-6 text-sm">
{message}
</div>
)
}
+7
View File
@@ -6,3 +6,10 @@ PORT=8000
# Allowed CORS origin (Next.js frontend)
CORS_ORIGIN=http://localhost:3000
# Portal SSO (false = open API for local/dev)
AUTH_REQUIRED=false
# Same HS256 secret as auth-portal JWT_SECRET when AUTH_REQUIRED=true
AUTH_JWT_SECRET=dev-secret-change-me
AUTH_ISSUER=https://auth.shnt.top
AUTH_PORTAL_URL=http://localhost:5175
+19 -2
View File
@@ -5,10 +5,23 @@ RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Do not set NODE_ENV=production here — npm would omit typescript needed for the build stage.
COPY package.json package-lock.json ./
COPY packages/contracts/package.json packages/contracts/
COPY backend/package.json backend/
RUN npm ci --workspace=@mmapp/contracts --workspace=mikrotik-manager-backend --include-workspace-root --ignore-scripts \
# Drop root frontend deps (Next/React/UI) so backend image stays lean.
RUN node -e "\
const fs=require('fs');\
const p=JSON.parse(fs.readFileSync('package.json','utf8'));\
p.dependencies={};\
p.devDependencies={};\
delete p.scripts;\
p.workspaces=['packages/*','backend'];\
fs.writeFileSync('package.json', JSON.stringify(p,null,2)+'\\n');\
"
# Prefer npm ci; if lockfile rejects stripped root package.json, fall back to install.
RUN (npm ci --workspace=@mmapp/contracts --workspace=mikrotik-manager-backend --ignore-scripts \
|| npm install --workspace=@mmapp/contracts --workspace=mikrotik-manager-backend --ignore-scripts) \
&& npm rebuild better-sqlite3
FROM deps AS build
@@ -18,7 +31,9 @@ COPY packages/contracts packages/contracts
COPY backend backend
RUN npm run build -w @mmapp/contracts \
&& npm run build -w mikrotik-manager-backend \
&& npm prune --omit=dev
&& npm prune --omit=dev \
# npm may nest workspace deps (e.g. dotenv) under backend/node_modules — keep dir for COPY
&& mkdir -p backend/node_modules
FROM node:22-bookworm-slim AS runner
WORKDIR /app
@@ -32,6 +47,8 @@ COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/packages/contracts ./packages/contracts
COPY --from=build /app/backend/dist ./backend/dist
COPY --from=build /app/backend/package.json ./backend/package.json
# Nested install from lockfile (dotenv etc.) — ESM resolves from /app/backend/dist → ../node_modules
COPY --from=build /app/backend/node_modules ./backend/node_modules
RUN mkdir -p /app/data
EXPOSE 8000
CMD ["node", "backend/dist/index.js"]
+7 -2
View File
@@ -10,10 +10,13 @@
"start": "node dist/index.js",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio"
"db:studio": "drizzle-kit studio",
"test:auth": "tsx src/lib/permissions.test.ts && tsx src/plugins/auth.smoke.test.ts",
"test:wireguard": "npx tsx src/services/wireguard-config.test.ts"
},
"dependencies": {
"@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.2.2",
"@fastify/type-provider-zod": "^1.0.0",
"@mmapp/contracts": "1.0.0",
"acme-client": "^5.4.0",
@@ -21,7 +24,7 @@
"dotenv": "^16.4.7",
"drizzle-orm": "^0.45.2",
"fastify": "^5.8.5",
"pino-pretty": "^13.1.3",
"fastify-plugin": "^5.1.0",
"undici": "^8.1.0",
"zod": "^4.4.1"
},
@@ -29,6 +32,8 @@
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.15.3",
"drizzle-kit": "^0.31.10",
"jose": "^6.2.11",
"pino-pretty": "^13.1.3",
"tsx": "^4.19.3",
"typescript": "^5.8.3"
}
+46 -2
View File
@@ -3,17 +3,61 @@ import { z } from "zod"
config()
function boolEnv(v: string | undefined, fallback: boolean): boolean {
if (v === undefined || v === "") return fallback
return v === "1" || v.toLowerCase() === "true"
}
const isProd = process.env.NODE_ENV === "production"
const envSchema = z.object({
DATABASE_PATH: z.string().default("./mikrotik.db"),
PORT: z.coerce.number().int().positive().default(8000),
CORS_ORIGIN: z.string().default("http://localhost:3000"),
AUTH_REQUIRED: z.boolean().default(false),
AUTH_JWT_SECRET: z.string().default(""),
AUTH_ISSUER: z.string().default("https://auth.shnt.top"),
AUTH_PORTAL_URL: z.string().default("http://localhost:5175"),
})
const parsed = envSchema.safeParse(process.env)
const raw = {
DATABASE_PATH: process.env.DATABASE_PATH,
PORT: process.env.PORT,
CORS_ORIGIN: process.env.CORS_ORIGIN,
AUTH_REQUIRED: boolEnv(process.env.AUTH_REQUIRED, false),
AUTH_JWT_SECRET:
process.env.AUTH_JWT_SECRET?.trim() ||
process.env.JWT_SECRET?.trim() ||
(isProd ? "" : "dev-secret-change-me"),
AUTH_ISSUER:
process.env.AUTH_ISSUER?.trim() ||
process.env.ISSUER?.trim() ||
"https://auth.shnt.top",
AUTH_PORTAL_URL: (
process.env.AUTH_PORTAL_URL ??
process.env.NEXT_PUBLIC_AUTH_PORTAL_URL ??
"http://localhost:5175"
).replace(/\/$/, ""),
}
const parsed = envSchema.safeParse(raw)
if (!parsed.success) {
console.error("❌ Invalid environment variables:", parsed.error.flatten().fieldErrors)
process.exit(1)
}
export const env = parsed.data
if (parsed.data.AUTH_REQUIRED && parsed.data.AUTH_JWT_SECRET.length < 8) {
console.error("❌ AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true")
process.exit(1)
}
export const env = {
DATABASE_PATH: parsed.data.DATABASE_PATH,
PORT: parsed.data.PORT,
CORS_ORIGIN: parsed.data.CORS_ORIGIN,
authRequired: parsed.data.AUTH_REQUIRED,
jwtSecret: parsed.data.AUTH_JWT_SECRET || "dev-secret-change-me",
authIssuer: parsed.data.AUTH_ISSUER,
authPortalUrl: parsed.data.AUTH_PORTAL_URL,
}
+103 -62
View File
@@ -1,11 +1,12 @@
import Fastify from "fastify"
import Fastify, { type FastifyInstance } from "fastify"
import cors from "@fastify/cors"
import { serializerCompiler, validatorCompiler } from "@fastify/type-provider-zod"
import { env } from "./config.js"
import authPlugin, { requireAuth } from "./plugins/auth.js"
import serversRoutes from "./routes/servers.js"
import bgpRoutes from "./routes/bgp.js"
import ospfRoutes from "./routes/ospf.js"
import execRoutes from "./routes/exec.js"
import bgpRoutes from "./routes/bgp.js"
import ospfRoutes from "./routes/ospf.js"
import execRoutes from "./routes/exec.js"
import filtersRoutes from "./routes/filters.js"
import recursiveRoutes from "./routes/recursive-routes.js"
import trafficRoutes from "./routes/traffic.js"
@@ -22,73 +23,113 @@ import backupsRoutes from "./routes/backups.js"
import certificatesRoutes from "./routes/certificates.js"
import systemDatabaseRoutes from "./routes/system-database.js"
import eventsRoutes from "./routes/events.js"
import wireguardRoutes from "./routes/wireguard.js"
import { refreshScheduler, stopScheduler } from "./services/scheduler.js"
// ── app factory ────────────────────────────────────────────────────────────────
export async function buildApp(opts?: {
logger?: boolean
startScheduler?: boolean
}): Promise<FastifyInstance> {
const usePrettyLogger =
opts?.logger !== false && process.env.NODE_ENV !== "production"
const app = Fastify({
bodyLimit: 512 * 1024 * 1024,
requestTimeout: 10 * 60 * 1000,
logger:
opts?.logger === false
? false
: usePrettyLogger
? {
transport: {
target: "pino-pretty",
options: {
colorize: true,
translateTime: "HH:MM:ss",
ignore: "pid,hostname",
},
},
}
: true,
})
const app = Fastify({
bodyLimit: 512 * 1024 * 1024,
requestTimeout: 10 * 60 * 1000,
logger: {
transport: {
target: "pino-pretty",
options: { colorize: true, translateTime: "HH:MM:ss", ignore: "pid,hostname" },
},
},
})
app.setValidatorCompiler(validatorCompiler)
app.setSerializerCompiler(serializerCompiler)
// Use Zod for request validation and response serialization
app.setValidatorCompiler(validatorCompiler)
app.setSerializerCompiler(serializerCompiler)
await app.register(cors, {
origin: env.CORS_ORIGIN,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
})
// CORS — allow Next.js frontend
await app.register(cors, {
origin: env.CORS_ORIGIN,
/** PATCH — для /api/uptime/probes/:id (звезда на дашборде); без этого браузер режет preflight */
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
})
await app.register(authPlugin)
// ── routes ─────────────────────────────────────────────────────────────────────
app.get("/health", async () => ({
status: "ok",
timestamp: new Date().toISOString(),
version: process.env.APP_VERSION ?? "dev",
}))
app.get("/health", async () => ({
status: "ok",
timestamp: new Date().toISOString(),
version: process.env.APP_VERSION ?? "dev",
}))
app.get("/api/auth/config", async () => ({
required: env.authRequired,
portal_url: env.authPortalUrl,
issuer: env.authIssuer,
}))
await app.register(serversRoutes, { prefix: "/api/servers" })
await app.register(bgpRoutes, { prefix: "/api" })
await app.register(ospfRoutes, { prefix: "/api" })
await app.register(execRoutes, { prefix: "/api" })
await app.register(filtersRoutes, { prefix: "/api" })
await app.register(recursiveRoutes, { prefix: "/api" })
await app.register(trafficRoutes, { prefix: "/api" })
await app.register(serversApiPingRoutes, { prefix: "/api" })
await app.register(uptimeRoutes, { prefix: "/api" })
await app.register(networkRoutes, { prefix: "/api" })
await app.register(internetPathRoutes, { prefix: "/api" })
await app.register(evobgpRoutes, { prefix: "/api" })
await app.register(probesRoutes, { prefix: "/api" })
await app.register(schedulerRoutes, { prefix: "/api" })
await app.register(sidebarCountsRoutes, { prefix: "/api" })
await app.register(alertsRoutes, { prefix: "/api" })
await app.register(backupsRoutes, { prefix: "/api" })
await app.register(certificatesRoutes, { prefix: "/api" })
await app.register(systemDatabaseRoutes, { prefix: "/api" })
await app.register(eventsRoutes, { prefix: "/api" })
if (env.authRequired) {
app.addHook("preHandler", async (request, reply) => {
const pathname = request.url.split("?")[0] ?? request.url
if (!pathname.startsWith("/api/")) return
if (pathname === "/api/auth/config") return
await requireAuth(request, reply)
if (reply.sent) return
})
}
refreshScheduler()
app.addHook("onClose", async () => {
stopScheduler()
})
await app.register(serversRoutes, { prefix: "/api/servers" })
await app.register(bgpRoutes, { prefix: "/api" })
await app.register(ospfRoutes, { prefix: "/api" })
await app.register(execRoutes, { prefix: "/api" })
await app.register(filtersRoutes, { prefix: "/api" })
await app.register(recursiveRoutes, { prefix: "/api" })
await app.register(trafficRoutes, { prefix: "/api" })
await app.register(serversApiPingRoutes, { prefix: "/api" })
await app.register(uptimeRoutes, { prefix: "/api" })
await app.register(networkRoutes, { prefix: "/api" })
await app.register(internetPathRoutes, { prefix: "/api" })
await app.register(evobgpRoutes, { prefix: "/api" })
await app.register(probesRoutes, { prefix: "/api" })
await app.register(schedulerRoutes, { prefix: "/api" })
await app.register(sidebarCountsRoutes, { prefix: "/api" })
await app.register(alertsRoutes, { prefix: "/api" })
await app.register(backupsRoutes, { prefix: "/api" })
await app.register(certificatesRoutes, { prefix: "/api" })
await app.register(systemDatabaseRoutes, { prefix: "/api" })
await app.register(eventsRoutes, { prefix: "/api" })
await app.register(wireguardRoutes, { prefix: "/api" })
// ── start ──────────────────────────────────────────────────────────────────────
if (opts?.startScheduler !== false) {
refreshScheduler()
app.addHook("onClose", async () => {
stopScheduler()
})
}
try {
await app.listen({ port: env.PORT, host: "0.0.0.0" })
console.log(`\n🚀 MikroTik Manager Backend running at http://localhost:${env.PORT}`)
console.log(` Docs / test: http://localhost:${env.PORT}/health`)
} catch (err) {
app.log.error(err)
process.exit(1)
return app
}
const isMain =
process.argv[1] &&
(process.argv[1].endsWith("index.ts") || process.argv[1].endsWith("index.js"))
if (isMain) {
try {
const app = await buildApp()
await app.listen({ port: env.PORT, host: "0.0.0.0" })
console.log(
`\n🚀 MikroTik Manager Backend running at http://localhost:${env.PORT}`,
)
console.log(` Docs / test: http://localhost:${env.PORT}/health`)
} catch (err) {
console.error(err)
process.exit(1)
}
}
+33
View File
@@ -0,0 +1,33 @@
import assert from "node:assert/strict"
import { hasPermission, permissionForRequest } from "./permissions.js"
assert.equal(hasPermission(["mm:servers:write"], "mm:servers:read"), true)
assert.equal(hasPermission(["mm:servers:admin"], "mm:servers:write"), true)
assert.equal(hasPermission(["mm:servers:read"], "mm:servers:write"), false)
assert.equal(
permissionForRequest("GET", "/api/servers"),
"mm:servers:read",
)
assert.equal(
permissionForRequest("POST", "/api/servers"),
"mm:servers:write",
)
assert.equal(
permissionForRequest("GET", "/api/system/database/backup"),
"mm:settings:admin",
)
assert.equal(
permissionForRequest("GET", "/api/unknown-thing"),
"mm:dashboard:read",
)
assert.equal(
permissionForRequest("GET", "/api/wireguard"),
"mm:network:read",
)
assert.equal(
permissionForRequest("POST", "/api/wireguard/interfaces"),
"mm:network:write",
)
console.log("permissions.test.ts: ok")
+175
View File
@@ -0,0 +1,175 @@
/**
* Portal JWT RBAC helpers (mirrors @authportal/shared hasPermission).
* Format: mm:<section>:<read|write|admin>
*/
export type AuthUser = {
id: string
email: string
name: string
apps: string[]
permissions: string[]
isAdmin?: boolean
}
export function hasPermission(
granted: readonly string[],
required: string,
): boolean {
if (granted.includes(required)) return true
const parts = required.split(":")
if (parts.length !== 3) return false
const [app, section, action] = parts
if (action === "read") {
return (
granted.includes(`${app}:${section}:write`) ||
granted.includes(`${app}:${section}:admin`)
)
}
if (action === "write") {
return granted.includes(`${app}:${section}:admin`)
}
return false
}
type Rule = {
methods: string[]
match: (path: string) => boolean
permission: string
}
const RULES: Rule[] = [
{
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
match: (p) =>
p.startsWith("/api/system") ||
p.startsWith("/api/scheduler") ||
p.startsWith("/api/evobgp"),
permission: "mm:settings:admin",
},
{
methods: ["GET"],
match: (p) =>
p.startsWith("/api/sidebar-counts") || p.startsWith("/api/events"),
permission: "mm:dashboard:read",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/servers"),
permission: "mm:servers:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/servers"),
permission: "mm:servers:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/filters"),
permission: "mm:filters:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/filters"),
permission: "mm:filters:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/bgp"),
permission: "mm:bgp:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/bgp"),
permission: "mm:bgp:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/uptime"),
permission: "mm:uptime:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/uptime"),
permission: "mm:uptime:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/traffic"),
permission: "mm:traffic:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/traffic"),
permission: "mm:traffic:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/alerts"),
permission: "mm:alerts:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/alerts"),
permission: "mm:alerts:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/backups"),
permission: "mm:backups:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/backups"),
permission: "mm:backups:write",
},
{
methods: ["GET"],
match: (p) => p.startsWith("/api/certificates"),
permission: "mm:certificates:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) => p.startsWith("/api/certificates"),
permission: "mm:certificates:write",
},
{
methods: ["GET"],
match: (p) =>
p.startsWith("/api/network") ||
p.startsWith("/api/ospf") ||
p.startsWith("/api/recursive") ||
p.startsWith("/api/probes") ||
p.startsWith("/api/internet-path") ||
p.startsWith("/api/exec") ||
p.startsWith("/api/wireguard"),
permission: "mm:network:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: (p) =>
p.startsWith("/api/network") ||
p.startsWith("/api/ospf") ||
p.startsWith("/api/recursive") ||
p.startsWith("/api/probes") ||
p.startsWith("/api/internet-path") ||
p.startsWith("/api/exec") ||
p.startsWith("/api/wireguard"),
permission: "mm:network:write",
},
]
/** Resolve required permission for method+path, or null if public / unknown. */
export function permissionForRequest(
method: string,
path: string,
): string | null {
const m = method.toUpperCase()
const pathname = path.split("?")[0] ?? path
for (const rule of RULES) {
if (!rule.methods.includes(m)) continue
if (rule.match(pathname)) return rule.permission
}
if (pathname.startsWith("/api/")) return "mm:dashboard:read"
return null
}
+72
View File
@@ -0,0 +1,72 @@
/**
* Smoke: AUTH_REQUIRED gate via Fastify inject.
* Run: AUTH_REQUIRED=true AUTH_JWT_SECRET=test-secret-at-least-8 tsx src/plugins/auth.smoke.test.ts
*/
import assert from "node:assert/strict"
import { SignJWT } from "jose"
process.env.AUTH_REQUIRED = "true"
process.env.AUTH_JWT_SECRET = "test-secret-at-least-8"
process.env.AUTH_ISSUER = "https://auth.test.local"
process.env.AUTH_PORTAL_URL = "http://localhost:5175"
process.env.CORS_ORIGIN = "http://localhost:3000"
process.env.DATABASE_PATH = ":memory:"
process.env.NODE_ENV = "test"
// Dynamic import after env is set
const { buildApp } = await import("../index.js")
const secret = new TextEncoder().encode("test-secret-at-least-8")
async function mint(payload: Record<string, unknown>): Promise<string> {
return new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setIssuer("https://auth.test.local")
.setExpirationTime("1h")
.sign(secret)
}
const app = await buildApp({ logger: false, startScheduler: false })
const health = await app.inject({ method: "GET", url: "/health" })
assert.equal(health.statusCode, 200)
const cfg = await app.inject({ method: "GET", url: "/api/auth/config" })
assert.equal(cfg.statusCode, 200)
assert.equal(cfg.json().required, true)
const noToken = await app.inject({ method: "GET", url: "/api/sidebar-counts" })
assert.equal(noToken.statusCode, 401)
const badApp = await mint({
sub: "u1",
email: "a@b.c",
name: "A",
apps: ["cdn"],
permissions: ["cdn:dashboard:read"],
})
const forbiddenApp = await app.inject({
method: "GET",
url: "/api/sidebar-counts",
headers: { authorization: `Bearer ${badApp}` },
})
assert.equal(forbiddenApp.statusCode, 403)
const okToken = await mint({
sub: "u1",
email: "a@b.c",
name: "A",
apps: ["mm"],
permissions: ["mm:dashboard:read"],
})
const ok = await app.inject({
method: "GET",
url: "/api/sidebar-counts",
headers: { authorization: `Bearer ${okToken}` },
})
// May be 200 or 500 if DB missing — must not be 401/403
assert.notEqual(ok.statusCode, 401)
assert.notEqual(ok.statusCode, 403)
await app.close()
console.log("auth.smoke.test.ts: ok")
+119
View File
@@ -0,0 +1,119 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"
import fp from "fastify-plugin"
import { env } from "../config.js"
import {
hasPermission,
permissionForRequest,
type AuthUser,
} from "../lib/permissions.js"
declare module "fastify" {
interface FastifyRequest {
authUser?: AuthUser
}
}
declare module "@fastify/jwt" {
interface FastifyJWT {
payload: {
sub: string
email?: string
name?: string
apps?: string[]
permissions?: string[]
is_admin?: boolean
iss?: string
exp?: number
}
user: {
sub: string
email?: string
name?: string
apps?: string[]
permissions?: string[]
is_admin?: boolean
iss?: string
exp?: number
}
}
}
async function authPlugin(app: FastifyInstance) {
if (env.authRequired && env.jwtSecret.length < 8) {
throw new Error("AUTH_JWT_SECRET / JWT_SECRET required when AUTH_REQUIRED=true")
}
await app.register(import("@fastify/jwt"), {
secret: env.jwtSecret,
...(env.authRequired
? {
verify: {
allowedIss: [env.authIssuer],
},
}
: {}),
})
if (env.authRequired) {
app.log.info(
{ issuer: env.authIssuer, portal: env.authPortalUrl },
"AUTH_REQUIRED=true — portal JWT middleware enabled",
)
} else {
app.log.info("AUTH_REQUIRED=false — /api/* open without JWT")
}
}
/**
* Protect /api/* when AUTH_REQUIRED=true.
* Public: /health, /api/auth/config
*/
export async function requireAuth(
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
if (!env.authRequired) return
const pathname = (request.url.split("?")[0] ?? request.url)
if (pathname === "/api/auth/config") return
const authHeader = request.headers.authorization ?? ""
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : ""
if (!token) {
return reply.code(401).send({ error: "Unauthorized" })
}
try {
await request.jwtVerify()
} catch {
return reply.code(401).send({ error: "Unauthorized" })
}
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("mm")) {
return reply
.code(403)
.send({ error: "Нет доступа к приложению MikrotikManager" })
}
request.authUser = {
id: String(payload.sub),
email: String(payload.email ?? ""),
name: String(payload.name ?? ""),
apps,
permissions,
isAdmin: Boolean(payload.is_admin),
}
const required = permissionForRequest(request.method, pathname)
if (required && !hasPermission(permissions, required)) {
return reply.code(403).send({ error: `Недостаточно прав: ${required}` })
}
}
export default fp(authPlugin, { name: "auth" })
+19 -8
View File
@@ -37,6 +37,12 @@ function normalizeBaseUrl(raw: string): string {
}
}
/** Сырой API-ключ без префикса Bearer (иначе EvoBGP получит `Bearer Bearer …`). */
function normalizeApiKey(raw: string): string {
const trimmed = raw.trim()
return trimmed.replace(/^Bearer\s+/i, "").trim()
}
interface EvoCatalogRaw {
modules: { items: Array<{ id: string; name: string; type: string }> }
domains: {
@@ -158,7 +164,7 @@ async function fetchEvoJson<T>(root: string, path: string, token: string): Promi
function credentialsFromDb(): { root: string; apiKey: string } | null {
const row = ensureEvobgpRow()
const root = normalizeBaseUrl(row.baseUrl)
const apiKey = row.apiKey.trim()
const apiKey = normalizeApiKey(row.apiKey)
if (!root || !apiKey) return null
return { root, apiKey }
}
@@ -168,8 +174,8 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
const row = ensureEvobgpRow()
return reply.send({
baseUrl: row.baseUrl ?? "",
enabled: row.enabled ?? false,
secretConfigured: Boolean(row.apiKey?.trim()),
enabled: Boolean(row.enabled),
secretConfigured: Boolean(normalizeApiKey(row.apiKey ?? "")),
})
})
@@ -183,10 +189,15 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
let nextEnabled = cur.enabled
let nextKey = cur.apiKey
if (parsed.data.baseUrl !== undefined) nextBase = parsed.data.baseUrl.trim()
if (parsed.data.baseUrl !== undefined) {
nextBase = normalizeBaseUrl(parsed.data.baseUrl)
}
if (parsed.data.enabled !== undefined) nextEnabled = parsed.data.enabled
if (parsed.data.apiKey !== undefined) {
nextKey = parsed.data.apiKey === null || parsed.data.apiKey === "" ? "" : parsed.data.apiKey.trim()
nextKey =
parsed.data.apiKey === null || parsed.data.apiKey === ""
? ""
: normalizeApiKey(parsed.data.apiKey)
}
db.update(evobgpSettings)
@@ -202,8 +213,8 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
const row = ensureEvobgpRow()
return reply.send({
baseUrl: row.baseUrl ?? "",
enabled: row.enabled ?? false,
secretConfigured: Boolean(row.apiKey?.trim()),
enabled: Boolean(row.enabled),
secretConfigured: Boolean(normalizeApiKey(row.apiKey ?? "")),
})
})
@@ -223,7 +234,7 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
const keyRaw =
d.apiKey !== undefined && d.apiKey.trim() !== "" ? d.apiKey : row.apiKey
const root = normalizeBaseUrl(urlRaw.trim())
const token = keyRaw.trim()
const token = normalizeApiKey(keyRaw)
if (!root || !token) {
return reply.status(400).send({
error: "Нужны базовый URL и API-ключ (в форме или уже сохранённые в БД)",
+4
View File
@@ -1,5 +1,6 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import { listCertificatesFromServers } from "../services/certificates-service.js"
import { countWireGuardInterfaces } from "../services/wireguard-live.js"
import { db } from "../db/index.js"
import {
filterRules,
@@ -18,6 +19,7 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
uptimeSpeedProbesTotal,
recursiveRoutesTotal,
certificatesTotal,
wireguardTotal,
] = await Promise.all([
Promise.resolve(db.select().from(servers).all().length),
Promise.resolve(db.select().from(filterRules).all().length),
@@ -25,6 +27,7 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
Promise.resolve(db.select().from(uptimeSpeedProbes).all().length),
Promise.resolve(db.select().from(recursiveRoutes).all().length),
listCertificatesFromServers().then((res) => res.certificates.length),
countWireGuardInterfaces().catch(() => 0),
])
return reply.send({
@@ -35,6 +38,7 @@ const sidebarCountsRoutes: FastifyPluginAsyncZod = async (app) => {
monitoringItems: uptimeProbesTotal + uptimeSpeedProbesTotal,
recursiveRoutes: recursiveRoutesTotal,
certificates: certificatesTotal,
wireguard: wireguardTotal,
})
})
}
+456
View File
@@ -0,0 +1,456 @@
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
import {
wgCreateInterfaceSchema,
wgCreatePeerRequestSchema,
wgExportRequestSchema,
wgImportRequestSchema,
wgPatchInterfaceSchema,
wgPatchPeerSchema,
type WgCreatePeerRequest,
type WgIfaceDto,
} from "@mmapp/contracts/wireguard"
import { MikrotikClient, MikrotikError } from "../services/mikrotik.js"
import {
generateMikrotikRsc,
generateNativeConf,
generatePeerClientConf,
parseWgConfig,
type WgParsedConfig,
} from "../services/wireguard-config.js"
import {
getEnabledServerById,
listWireGuardInterfaces,
} from "../services/wireguard-live.js"
function serverIdParam(v: string): string {
return decodeURIComponent(v)
}
function rosIdParam(v: string): string {
return decodeURIComponent(v)
}
function toRosBody(obj: Record<string, string | undefined>): Record<string, string> {
const out: Record<string, string> = {}
for (const [k, v] of Object.entries(obj)) {
if (v !== undefined && v !== "") out[k] = v
}
return out
}
function peerToRosBody(p: Omit<WgCreatePeerRequest, "serverId" | "interfaceName"> & { interfaceName: string }) {
return toRosBody({
interface: p.interfaceName,
"public-key": p.publicKey,
"allowed-address": p.allowedAddresses.join(","),
"endpoint-address": p.endpointAddress,
"endpoint-port": p.endpointPort != null ? String(p.endpointPort) : undefined,
"persistent-keepalive":
p.persistentKeepalive != null ? String(p.persistentKeepalive) : undefined,
comment: p.comment,
name: p.name,
"private-key": typeof p.privateKey === "string" ? p.privateKey : undefined,
"client-address": p.clientAddress,
"client-dns": p.clientDns,
"client-endpoint": p.clientEndpoint,
disabled: p.disabled === true ? "yes" : p.disabled === false ? "no" : undefined,
})
}
function previewFromParsed(parsed: WgParsedConfig) {
return {
format: parsed.format,
interface: {
name: parsed.interface.name,
listenPort: parsed.interface.listenPort,
mtu: parsed.interface.mtu,
privateKey: parsed.interface.privateKey,
comment: parsed.interface.comment,
address: parsed.interface.address,
disabled: parsed.interface.disabled,
},
peers: parsed.peers.map((p) => ({
publicKey: p.publicKey,
allowedAddresses: p.allowedAddresses,
endpointAddress: p.endpointAddress,
endpointPort: p.endpointPort,
persistentKeepalive: p.persistentKeepalive,
comment: p.comment,
name: p.name,
privateKey: p.privateKey,
clientAddress: p.clientAddress,
clientDns: p.clientDns,
clientEndpoint: p.clientEndpoint,
disabled: p.disabled,
})),
}
}
async function applyParsedConfig(
client: MikrotikClient,
parsed: WgParsedConfig,
): Promise<{ interfaceName: string; peersCreated: number }> {
const name = parsed.interface.name
const ifaceBody = toRosBody({
name,
"listen-port": String(parsed.interface.listenPort ?? 13231),
mtu: String(parsed.interface.mtu ?? 1420),
"private-key": parsed.interface.privateKey,
comment: parsed.interface.comment,
disabled: parsed.interface.disabled ? "yes" : undefined,
})
await client.put("/interface/wireguard", ifaceBody)
if (parsed.interface.address) {
await client.put("/ip/address", {
address: parsed.interface.address,
interface: name,
})
}
let peersCreated = 0
for (const p of parsed.peers) {
if (!p.publicKey) continue
await client.put(
"/interface/wireguard/peers",
peerToRosBody({
interfaceName: name,
publicKey: p.publicKey,
allowedAddresses: p.allowedAddresses.length ? p.allowedAddresses : ["0.0.0.0/0"],
endpointAddress: p.endpointAddress,
endpointPort: p.endpointPort,
persistentKeepalive: p.persistentKeepalive,
comment: p.comment,
name: p.name,
privateKey: p.privateKey,
clientAddress: p.clientAddress,
clientDns: p.clientDns,
clientEndpoint: p.clientEndpoint,
disabled: p.disabled,
}),
)
peersCreated += 1
}
return { interfaceName: name, peersCreated }
}
function findIface(
list: WgIfaceDto[],
serverId: string,
interfaceName: string,
): WgIfaceDto | undefined {
return list.find((i) => i.serverId === serverId && i.name === interfaceName)
}
const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
app.get("/wireguard", async (req, reply) => {
const q = req.query as { serverId?: string; includePrivateKey?: string }
const includePrivateKey = q.includePrivateKey === "1" || q.includePrivateKey === "true"
const result = await listWireGuardInterfaces({
serverId: q.serverId,
includePrivateKey,
})
return reply.send(result)
})
app.post("/wireguard/interfaces", async (req, reply) => {
const parsed = wgCreateInterfaceSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = getEnabledServerById(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
try {
await client.put(
"/interface/wireguard",
toRosBody({
name: body.name,
"listen-port": String(body.listenPort),
mtu: String(body.mtu),
comment: body.comment,
"private-key": body.privateKey,
disabled: body.disabled ? "yes" : undefined,
}),
)
if (body.address) {
await client.put("/ip/address", {
address: body.address,
interface: body.name,
})
}
if (body.peer) {
await client.put(
"/interface/wireguard/peers",
peerToRosBody({ ...body.peer, interfaceName: body.name }),
)
}
const list = await listWireGuardInterfaces({
serverId: String(server.id),
includePrivateKey: true,
})
const created = list.interfaces.find((i) => i.name === body.name)
return reply.status(201).send(created ?? { ok: true, name: body.name })
} catch (e) {
const msg = e instanceof MikrotikError ? e.message : e instanceof Error ? e.message : String(e)
return reply.status(502).send({ error: `RouterOS: ${msg}` })
}
})
app.patch("/wireguard/interfaces/:serverId/:rosId", async (req, reply) => {
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
const parsed = wgPatchInterfaceSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const server = getEnabledServerById(serverIdParam(serverId))
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
const d = parsed.data
try {
await client.patch(
`/interface/wireguard/${encodeURIComponent(rosIdParam(rosId))}`,
toRosBody({
name: d.name,
"listen-port": d.listenPort != null ? String(d.listenPort) : undefined,
mtu: d.mtu != null ? String(d.mtu) : undefined,
comment: d.comment,
"private-key": d.privateKey,
disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined,
}),
)
return reply.send({ ok: true })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
return reply.status(502).send({ error: `RouterOS: ${msg}` })
}
})
app.delete("/wireguard/interfaces/:serverId/:rosId", async (req, reply) => {
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
const server = getEnabledServerById(serverIdParam(serverId))
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
try {
await client.delete(`/interface/wireguard/${encodeURIComponent(rosIdParam(rosId))}`)
return reply.send({ ok: true })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
return reply.status(502).send({ error: `RouterOS: ${msg}` })
}
})
app.post("/wireguard/peers", async (req, reply) => {
const parsed = wgCreatePeerRequestSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = getEnabledServerById(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
try {
await client.put("/interface/wireguard/peers", peerToRosBody(body))
return reply.status(201).send({ ok: true })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
return reply.status(502).send({ error: `RouterOS: ${msg}` })
}
})
app.patch("/wireguard/peers/:serverId/:rosId", async (req, reply) => {
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
const parsed = wgPatchPeerSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const server = getEnabledServerById(serverIdParam(serverId))
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const d = parsed.data
const client = MikrotikClient.fromServer(server)
try {
await client.patch(
`/interface/wireguard/peers/${encodeURIComponent(rosIdParam(rosId))}`,
toRosBody({
"public-key": d.publicKey,
"allowed-address": d.allowedAddresses?.join(","),
"endpoint-address": d.endpointAddress,
"endpoint-port": d.endpointPort != null ? String(d.endpointPort) : undefined,
"persistent-keepalive":
d.persistentKeepalive != null ? String(d.persistentKeepalive) : undefined,
comment: d.comment,
name: d.name,
"client-address": d.clientAddress,
"client-dns": d.clientDns,
"client-endpoint": d.clientEndpoint,
disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined,
}),
)
return reply.send({ ok: true })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
return reply.status(502).send({ error: `RouterOS: ${msg}` })
}
})
app.delete("/wireguard/peers/:serverId/:rosId", async (req, reply) => {
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
const server = getEnabledServerById(serverIdParam(serverId))
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
try {
await client.delete(`/interface/wireguard/peers/${encodeURIComponent(rosIdParam(rosId))}`)
return reply.send({ ok: true })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
return reply.status(502).send({ error: `RouterOS: ${msg}` })
}
})
app.post("/wireguard/import", async (req, reply) => {
const parsed = wgImportRequestSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
let config: WgParsedConfig
try {
config = parseWgConfig(body.content, body.format)
} catch (e) {
return reply.status(400).send({ error: e instanceof Error ? e.message : "Ошибка разбора конфига" })
}
const preview = previewFromParsed(config)
if (body.dryRun) {
return reply.send({ dryRun: true, preview })
}
const server = getEnabledServerById(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
try {
const applied = await applyParsedConfig(client, config)
return reply.send({ dryRun: false, preview, applied })
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
return reply.status(502).send({ error: `RouterOS: ${msg}`, preview })
}
})
app.post("/wireguard/export", async (req, reply) => {
const parsed = wgExportRequestSchema.safeParse(req.body ?? {})
if (!parsed.success) {
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
}
const body = parsed.data
const server = getEnabledServerById(body.serverId)
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const list = await listWireGuardInterfaces({
serverId: String(server.id),
includePrivateKey: body.includePrivateKey === true,
})
const iface = findIface(list.interfaces, String(server.id), body.interfaceName)
if (!iface) return reply.status(404).send({ error: "Интерфейс не найден" })
if (body.format === "rsc") {
const content = generateMikrotikRsc({
name: iface.name,
listenPort: iface.listenPort,
mtu: iface.mtu,
comment: iface.comment,
enabled: iface.enabled,
privateKey: body.includePrivateKey ? iface.privateKey : undefined,
publicKey: iface.publicKey,
address: iface.address,
serverName: iface.serverName,
peers: iface.peers.map((p) => ({
publicKey: p.publicKey,
allowedIps: p.allowedIps,
endpoint: p.endpoint,
persistentKeepalive: p.persistentKeepalive,
persistent: p.persistent,
comment: p.comment,
name: p.name,
clientAddress: p.clientAddress,
clientDns: p.clientDns,
clientEndpoint: p.clientEndpoint,
})),
})
return reply.send({
format: "rsc",
filename: `${iface.name}.rsc`,
content,
})
}
if (body.format === "conf") {
const content = generateNativeConf(
{
name: iface.name,
listenPort: iface.listenPort,
mtu: iface.mtu,
comment: iface.comment,
enabled: iface.enabled,
privateKey: iface.privateKey,
publicKey: iface.publicKey,
address: iface.address,
serverName: iface.serverName,
peers: iface.peers.map((p) => ({
publicKey: p.publicKey,
allowedIps: p.allowedIps,
endpoint: p.endpoint,
persistentKeepalive: p.persistentKeepalive,
persistent: p.persistent,
comment: p.comment,
})),
},
{ includePrivateKey: body.includePrivateKey === true },
)
return reply.send({
format: "conf",
filename: `${iface.name}.conf`,
content,
})
}
// peer-conf
const peer = body.peerId
? iface.peers.find((p) => p.id === body.peerId || p.rosId === body.peerId)
: iface.peers[0]
if (!peer) return reply.status(404).send({ error: "Пир не найден" })
if (!iface.publicKey) {
return reply.status(400).send({ error: "У интерфейса нет public-key" })
}
const endpoint =
peer.clientEndpoint ||
(peer.endpoint
? peer.endpoint
: undefined)
const content = generatePeerClientConf({
peerAddress: peer.clientAddress,
peerDns: peer.clientDns,
serverPublicKey: iface.publicKey,
allowedIps: peer.allowedIps.length ? peer.allowedIps : ["0.0.0.0/0"],
endpoint:
endpoint ||
(peer.clientEndpoint
? peer.clientEndpoint.includes(":")
? peer.clientEndpoint
: `${peer.clientEndpoint}:${iface.listenPort}`
: undefined),
persistentKeepalive: peer.persistentKeepalive ?? 25,
})
return reply.send({
format: "peer-conf",
filename: `${iface.name}-peer.conf`,
content,
})
})
}
export default wireguardRoutes
+30 -8
View File
@@ -161,11 +161,11 @@ async function listDnsRecordsByName(token: string, zoneId: string, fqdn: string)
)
}
async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: string): Promise<void> {
async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: string): Promise<"updated" | "created" | "skipped_cname"> {
const records = await listDnsRecordsByName(token, zoneId, fqdn)
const existingA = records.find((record) => record.type === "A")
if (existingA) {
if (existingA.content === ip) return
if (existingA.content === ip) return "updated"
await cloudflareRequest<CfDnsRecord>(token, `/zones/${zoneId}/dns_records/${existingA.id}`, {
method: "PATCH",
body: JSON.stringify({
@@ -176,11 +176,12 @@ async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: st
proxied: false,
}),
})
return
return "updated"
}
// CNAME на CN/SAN (алиас на канонический хост) — норма; A конфликтует с CNAME и для DNS-01 не нужен
if (records.some((record) => record.type === "CNAME")) {
throw new Error(`Для ${fqdn} уже есть CNAME в Cloudflare — A-запись не создана`)
return "skipped_cname"
}
await cloudflareRequest<{ id: string }>(token, `/zones/${zoneId}/dns_records`, {
@@ -193,6 +194,7 @@ async function upsertARecord(token: string, zoneId: string, fqdn: string, ip: st
proxied: false,
}),
})
return "created"
}
async function syncCertificateDomainRecords(
@@ -200,11 +202,14 @@ async function syncCertificateDomainRecords(
domains: string[],
serverIp: string,
defaultZoneId?: string,
): Promise<void> {
): Promise<{ skippedCname: string[] }> {
const skippedCname: string[] = []
for (const domain of domains) {
const zoneId = await resolveZoneId(token, domain, defaultZoneId)
await upsertARecord(token, zoneId, domain, serverIp)
const result = await upsertARecord(token, zoneId, domain, serverIp)
if (result === "skipped_cname") skippedCname.push(domain)
}
return { skippedCname }
}
async function sleep(ms: number) {
@@ -296,9 +301,26 @@ export async function issueCertificateWithCloudflareDns(params: {
const finalized = await client.finalizeOrder(order, csr)
const certPem = await client.getCertificate(finalized)
// A-sync опционален: DNS-01 уже завершён. CNAME на CN (msk2 → msk-gw02) не должен валить импорт.
const clientRos = MikrotikClient.fromServer(params.server)
const serverIp = await resolveServerPublicIp(params.server, clientRos)
await syncCertificateDomainRecords(token, domains, serverIp, settings.defaultZoneId)
try {
params.onStep?.("dns_a_sync")
const serverIp = await resolveServerPublicIp(params.server, clientRos)
const { skippedCname } = await syncCertificateDomainRecords(
token,
domains,
serverIp,
settings.defaultZoneId,
)
if (skippedCname.length > 0) {
params.onStep?.(
`dns_a_sync_skip_cname:${skippedCname.join(",")}`,
)
}
} catch (e) {
const msg = e instanceof Error ? e.message : "ошибка DNS A-sync"
params.onStep?.(`dns_a_sync_warn:${msg}`)
}
const trustStores = params.trustStore.filter(Boolean)
const effectiveTrustStores = trustStores.length > 0 ? trustStores : ["www", "api"]
@@ -0,0 +1,100 @@
import assert from "node:assert/strict"
import {
detectWgConfigFormat,
generateMikrotikRsc,
generateNativeConf,
parseMikrotikRsc,
parseNativeConf,
parseWgConfig,
} from "./wireguard-config.js"
const sampleConf = `[Interface]
PrivateKey = aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa=
Address = 10.210.0.1/30
ListenPort = 13231
MTU = 1420
[Peer]
PublicKey = bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb=
AllowedIPs = 10.210.0.2/32, 192.168.20.0/24
Endpoint = 10.0.1.1:13231
PersistentKeepalive = 25
`
const parsedConf = parseNativeConf(sampleConf)
assert.equal(parsedConf.format, "conf")
assert.equal(parsedConf.interface.listenPort, 13231)
assert.equal(parsedConf.interface.address, "10.210.0.1/30")
assert.equal(parsedConf.peers.length, 1)
assert.equal(parsedConf.peers[0]?.endpointAddress, "10.0.1.1")
assert.equal(parsedConf.peers[0]?.endpointPort, 13231)
assert.deepEqual(parsedConf.peers[0]?.allowedAddresses, ["10.210.0.2/32", "192.168.20.0/24"])
const roundConf = generateNativeConf({
name: "wg0",
listenPort: parsedConf.interface.listenPort ?? 13231,
mtu: parsedConf.interface.mtu ?? 1420,
privateKey: parsedConf.interface.privateKey,
address: parsedConf.interface.address,
peers: parsedConf.peers.map((p) => ({
publicKey: p.publicKey,
allowedIps: p.allowedAddresses,
endpoint: p.endpointAddress
? `${p.endpointAddress}:${p.endpointPort ?? 13231}`
: undefined,
persistentKeepalive: p.persistentKeepalive,
})),
})
const reparsed = parseNativeConf(roundConf)
assert.equal(reparsed.interface.privateKey, parsedConf.interface.privateKey)
assert.equal(reparsed.peers[0]?.publicKey, parsedConf.peers[0]?.publicKey)
const sampleRsc = `# WireGuard
/interface wireguard add \\
name=wg-msk-spb \\
listen-port=13231 \\
mtu=1420 \\
comment="MSK → SPB"
/ip address add \\
address=10.210.0.1/30 \\
interface=wg-msk-spb
/interface wireguard peers add \\
interface=wg-msk-spb \\
public-key="SPBPublicKeyBase64AAAAAAAAAAAAAAAAAAAAAA=" \\
allowed-address=10.210.0.2/32,192.168.20.0/24 \\
endpoint-address=10.0.1.1 \\
endpoint-port=13231 \\
persistent-keepalive=25
`
assert.equal(detectWgConfigFormat(sampleRsc), "rsc")
assert.equal(detectWgConfigFormat(sampleConf), "conf")
const parsedRsc = parseMikrotikRsc(sampleRsc)
assert.equal(parsedRsc.interface.name, "wg-msk-spb")
assert.equal(parsedRsc.interface.address, "10.210.0.1/30")
assert.equal(parsedRsc.peers.length, 1)
assert.equal(parsedRsc.peers[0]?.endpointPort, 13231)
const generatedRsc = generateMikrotikRsc({
name: parsedRsc.interface.name,
listenPort: parsedRsc.interface.listenPort ?? 13231,
mtu: parsedRsc.interface.mtu ?? 1420,
comment: parsedRsc.interface.comment,
address: parsedRsc.interface.address,
peers: parsedRsc.peers.map((p) => ({
publicKey: p.publicKey,
allowedIps: p.allowedAddresses,
endpoint: p.endpointAddress
? `${p.endpointAddress}:${p.endpointPort ?? 13231}`
: undefined,
persistentKeepalive: p.persistentKeepalive,
})),
})
const rscAgain = parseWgConfig(generatedRsc, "rsc")
assert.equal(rscAgain.interface.name, "wg-msk-spb")
assert.equal(rscAgain.peers[0]?.publicKey, parsedRsc.peers[0]?.publicKey)
console.log("wireguard-config tests ok")
+359
View File
@@ -0,0 +1,359 @@
/**
* WireGuard config codecs: native .conf MikroTik .rsc
*/
export type WgParsedPeer = {
publicKey: string
allowedAddresses: string[]
endpointAddress?: string
endpointPort?: number
persistentKeepalive?: number
comment?: string
name?: string
privateKey?: "auto" | "none" | string
clientAddress?: string
clientDns?: string
clientEndpoint?: string
disabled?: boolean
}
export type WgParsedInterface = {
name: string
listenPort?: number
mtu?: number
privateKey?: string
comment?: string
address?: string
disabled?: boolean
}
export type WgParsedConfig = {
format: "rsc" | "conf"
interface: WgParsedInterface
peers: WgParsedPeer[]
}
export type WgExportIface = {
name: string
listenPort: number
mtu: number
comment?: string
enabled?: boolean
privateKey?: string
publicKey?: string
address?: string
serverName?: string
peers: Array<{
publicKey: string
allowedIps: string[]
endpoint?: string
persistentKeepalive?: number
persistent?: boolean
comment?: string
name?: string
clientAddress?: string
clientDns?: string
clientEndpoint?: string
}>
}
function stripQuotes(v: string): string {
const t = v.trim()
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
return t.slice(1, -1)
}
return t
}
function parseKvLine(line: string): Record<string, string> {
const out: Record<string, string> = {}
// Match key=value pairs; values may be quoted
const re = /([a-zA-Z0-9_-]+)=("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^\s\\]+)/g
let m: RegExpExecArray | null
while ((m = re.exec(line)) !== null) {
out[m[1]] = stripQuotes(m[2])
}
return out
}
function joinContinuedLines(text: string): string[] {
const raw = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n")
const lines: string[] = []
let buf = ""
for (const line of raw) {
const trimmedEnd = line.replace(/\s+$/, "")
if (trimmedEnd.endsWith("\\")) {
buf += trimmedEnd.slice(0, -1).trimEnd() + " "
continue
}
buf += trimmedEnd
if (buf.trim()) lines.push(buf.trim())
buf = ""
}
if (buf.trim()) lines.push(buf.trim())
return lines
}
export function detectWgConfigFormat(content: string): "rsc" | "conf" {
const t = content.trim()
if (/\[Interface\]/i.test(t) || /\[Peer\]/i.test(t)) return "conf"
if (/\/interface\s+wireguard/i.test(t) || /\/interface\/wireguard/i.test(t)) return "rsc"
if (/PrivateKey\s*=/i.test(t) || /PublicKey\s*=/i.test(t)) return "conf"
return "rsc"
}
export function parseNativeConf(content: string): WgParsedConfig {
const lines = content.replace(/\r\n/g, "\n").split("\n")
let section: "interface" | "peer" | null = null
const iface: WgParsedInterface = { name: "wg0" }
const peers: WgParsedPeer[] = []
let currentPeer: WgParsedPeer | null = null
const flushPeer = () => {
if (currentPeer?.publicKey) peers.push(currentPeer)
currentPeer = null
}
for (const raw of lines) {
const line = raw.trim()
if (!line || line.startsWith("#") || line.startsWith(";")) continue
if (/^\[Interface\]$/i.test(line)) {
flushPeer()
section = "interface"
continue
}
if (/^\[Peer\]$/i.test(line)) {
flushPeer()
section = "peer"
currentPeer = { publicKey: "", allowedAddresses: [] }
continue
}
const eq = line.indexOf("=")
if (eq < 0) continue
const key = line.slice(0, eq).trim().toLowerCase()
const value = line.slice(eq + 1).trim()
if (section === "interface") {
if (key === "privatekey") iface.privateKey = value
else if (key === "address") iface.address = value.split(",")[0]?.trim()
else if (key === "listenport") iface.listenPort = Number.parseInt(value, 10) || undefined
else if (key === "mtu") iface.mtu = Number.parseInt(value, 10) || undefined
else if (key === "name") iface.name = value || iface.name
} else if (section === "peer" && currentPeer) {
if (key === "publickey") currentPeer.publicKey = value
else if (key === "allowedips") {
currentPeer.allowedAddresses = value
.split(",")
.map((s) => s.trim())
.filter(Boolean)
} else if (key === "endpoint") {
const lastColon = value.lastIndexOf(":")
if (lastColon > 0 && !value.includes("]:")) {
currentPeer.endpointAddress = value.slice(0, lastColon)
currentPeer.endpointPort = Number.parseInt(value.slice(lastColon + 1), 10) || undefined
} else if (value.startsWith("[") && value.includes("]:")) {
const idx = value.indexOf("]:")
currentPeer.endpointAddress = value.slice(1, idx)
currentPeer.endpointPort = Number.parseInt(value.slice(idx + 2), 10) || undefined
} else {
currentPeer.endpointAddress = value
}
} else if (key === "persistentkeepalive") {
currentPeer.persistentKeepalive = Number.parseInt(value, 10) || undefined
} else if (key === "presharedkey") {
// ignore PSK for ROS import for now
}
}
}
flushPeer()
if (!iface.name) iface.name = "wg0"
return { format: "conf", interface: iface, peers }
}
export function parseMikrotikRsc(content: string): WgParsedConfig {
const lines = joinContinuedLines(content)
const iface: WgParsedInterface = { name: "wg0" }
const peers: WgParsedPeer[] = []
let foundIface = false
for (const line of lines) {
if (line.startsWith("#")) continue
const lower = line.toLowerCase()
if (
lower.startsWith("/interface wireguard add") ||
lower.startsWith("/interface/wireguard add")
) {
const kv = parseKvLine(line)
if (kv.name) iface.name = kv.name
if (kv["listen-port"]) iface.listenPort = Number.parseInt(kv["listen-port"], 10) || undefined
if (kv.mtu) iface.mtu = Number.parseInt(kv.mtu, 10) || undefined
if (kv["private-key"]) iface.privateKey = kv["private-key"]
if (kv.comment) iface.comment = kv.comment
if (kv.disabled === "yes") iface.disabled = true
foundIface = true
continue
}
if (
lower.startsWith("/interface wireguard peers add") ||
lower.startsWith("/interface/wireguard/peers add")
) {
const kv = parseKvLine(line)
const allowed = (kv["allowed-address"] ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean)
peers.push({
publicKey: kv["public-key"] ?? "",
allowedAddresses: allowed.length ? allowed : ["0.0.0.0/0"],
endpointAddress: kv["endpoint-address"],
endpointPort: kv["endpoint-port"]
? Number.parseInt(kv["endpoint-port"], 10) || undefined
: undefined,
persistentKeepalive: kv["persistent-keepalive"]
? Number.parseInt(kv["persistent-keepalive"], 10) || undefined
: undefined,
comment: kv.comment,
name: kv.name,
clientAddress: kv["client-address"],
clientDns: kv["client-dns"],
clientEndpoint: kv["client-endpoint"],
disabled: kv.disabled === "yes",
})
continue
}
if (lower.startsWith("/ip address add") || lower.startsWith("/ip/address add")) {
const kv = parseKvLine(line)
if (kv.address) iface.address = kv.address
continue
}
}
if (!foundIface && peers.length === 0) {
throw new Error("Не удалось распознать RouterOS WireGuard .rsc")
}
return { format: "rsc", interface: iface, peers }
}
export function parseWgConfig(
content: string,
format: "auto" | "rsc" | "conf" = "auto",
): WgParsedConfig {
const detected = format === "auto" ? detectWgConfigFormat(content) : format
if (detected === "conf") return parseNativeConf(content)
return parseMikrotikRsc(content)
}
export function generateNativeConf(iface: WgExportIface, opts?: { includePrivateKey?: boolean }): string {
const lines: string[] = []
lines.push(`[Interface]`)
if (opts?.includePrivateKey && iface.privateKey) {
lines.push(`PrivateKey = ${iface.privateKey}`)
} else if (iface.privateKey) {
lines.push(`PrivateKey = ${iface.privateKey}`)
} else {
lines.push(`# PrivateKey = <заполните приватный ключ с роутера>`)
}
if (iface.address) lines.push(`Address = ${iface.address}`)
lines.push(`ListenPort = ${iface.listenPort}`)
if (iface.mtu) lines.push(`MTU = ${iface.mtu}`)
lines.push(``)
for (const p of iface.peers) {
lines.push(`[Peer]`)
lines.push(`PublicKey = ${p.publicKey}`)
lines.push(`AllowedIPs = ${p.allowedIps.join(", ")}`)
if (p.endpoint) lines.push(`Endpoint = ${p.endpoint}`)
const ka = p.persistentKeepalive ?? (p.persistent ? 25 : undefined)
if (ka != null && ka > 0) lines.push(`PersistentKeepalive = ${ka}`)
if (p.comment) lines.push(`# ${p.comment}`)
lines.push(``)
}
return lines.join("\n").trimEnd() + "\n"
}
export function generatePeerClientConf(args: {
peerPrivateKey?: string
peerAddress?: string
peerDns?: string
serverPublicKey: string
allowedIps?: string[]
endpoint?: string
persistentKeepalive?: number
}): string {
const lines: string[] = []
lines.push(`[Interface]`)
lines.push(
args.peerPrivateKey
? `PrivateKey = ${args.peerPrivateKey}`
: `# PrivateKey = <ключ клиента>`,
)
if (args.peerAddress) lines.push(`Address = ${args.peerAddress}`)
if (args.peerDns) lines.push(`DNS = ${args.peerDns}`)
lines.push(``)
lines.push(`[Peer]`)
lines.push(`PublicKey = ${args.serverPublicKey}`)
lines.push(`AllowedIPs = ${(args.allowedIps?.length ? args.allowedIps : ["0.0.0.0/0"]).join(", ")}`)
if (args.endpoint) lines.push(`Endpoint = ${args.endpoint}`)
if (args.persistentKeepalive != null && args.persistentKeepalive > 0) {
lines.push(`PersistentKeepalive = ${args.persistentKeepalive}`)
}
lines.push(``)
return lines.join("\n")
}
export function generateMikrotikRsc(iface: WgExportIface): string {
const lines: string[] = []
lines.push(`# WireGuard — ${iface.name}${iface.serverName ? ` · ${iface.serverName}` : ""}`)
lines.push(`# RouterOS 7.x · MikrotikManager`)
lines.push(``)
lines.push(`/interface wireguard add \\`)
lines.push(` name=${iface.name} \\`)
lines.push(` listen-port=${iface.listenPort} \\`)
lines.push(` mtu=${iface.mtu} \\`)
if (iface.privateKey) lines.push(` private-key="${iface.privateKey}" \\`)
if (iface.comment) lines.push(` comment="${iface.comment.replace(/"/g, '\\"')}" \\`)
if (iface.enabled === false) lines.push(` disabled=yes \\`)
// remove trailing backslash on last iface param by rewriting last line
if (lines[lines.length - 1]?.endsWith(" \\")) {
lines[lines.length - 1] = lines[lines.length - 1]!.slice(0, -2)
}
lines.push(``)
if (iface.address) {
lines.push(`/ip address add \\`)
lines.push(` address=${iface.address} \\`)
lines.push(` interface=${iface.name}`)
lines.push(``)
}
for (const p of iface.peers) {
lines.push(`/interface wireguard peers add \\`)
lines.push(` interface=${iface.name} \\`)
lines.push(` public-key="${p.publicKey}" \\`)
lines.push(` allowed-address=${p.allowedIps.join(",")} \\`)
if (p.endpoint) {
const host = p.endpoint.includes(":") ? p.endpoint.slice(0, p.endpoint.lastIndexOf(":")) : p.endpoint
const port = p.endpoint.includes(":")
? p.endpoint.slice(p.endpoint.lastIndexOf(":") + 1)
: "13231"
lines.push(` endpoint-address=${host} \\`)
lines.push(` endpoint-port=${port} \\`)
}
const ka = p.persistentKeepalive ?? (p.persistent ? 25 : undefined)
if (ka != null && ka > 0) lines.push(` persistent-keepalive=${ka} \\`)
if (p.name) lines.push(` name=${p.name} \\`)
if (p.clientAddress) lines.push(` client-address=${p.clientAddress} \\`)
if (p.clientDns) lines.push(` client-dns=${p.clientDns} \\`)
if (p.clientEndpoint) lines.push(` client-endpoint=${p.clientEndpoint} \\`)
if (p.comment) lines.push(` comment="${p.comment.replace(/"/g, '\\"')}" \\`)
if (lines[lines.length - 1]?.endsWith(" \\")) {
lines[lines.length - 1] = lines[lines.length - 1]!.slice(0, -2)
}
lines.push(``)
}
return lines.join("\n")
}
+214
View File
@@ -0,0 +1,214 @@
import { eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { MikrotikClient } from "./mikrotik.js"
import type { WgIfaceDto, WgPeerDto } from "@mmapp/contracts/wireguard"
type ServerRow = typeof servers.$inferSelect
interface RosWireGuard {
".id"?: string
name?: string
"listen-port"?: string
mtu?: string
"public-key"?: string
"private-key"?: string
running?: string
disabled?: string
comment?: string
}
interface RosWireGuardPeer {
".id"?: string
interface?: string
name?: string
"public-key"?: string
"endpoint-address"?: string
"endpoint-port"?: string
"allowed-address"?: string
"last-handshake"?: string
rx?: string
tx?: string
disabled?: string
comment?: string
"persistent-keepalive"?: string
"client-address"?: string
"client-dns"?: string
"client-endpoint"?: string
}
interface RosIpAddress {
".id"?: string
address?: string
interface?: string
disabled?: string
}
function parseBytes(v: string | undefined): number | undefined {
if (v == null || v === "") return undefined
const n = Number.parseInt(v, 10)
return Number.isFinite(n) ? n : undefined
}
function mapPeer(p: RosWireGuardPeer, idx: number): WgPeerDto {
const rosId = String(p[".id"] ?? `peer-${idx}`)
const allowed = (p["allowed-address"] ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean)
const epAddr = (p["endpoint-address"] ?? "").trim()
const epPort = (p["endpoint-port"] ?? "").trim()
const endpoint = epAddr ? (epPort ? `${epAddr}:${epPort}` : epAddr) : undefined
const ka = p["persistent-keepalive"]
? Number.parseInt(p["persistent-keepalive"], 10)
: undefined
return {
id: rosId,
rosId,
publicKey: p["public-key"] ?? "",
allowedIps: allowed,
endpoint,
latestHandshake: p["last-handshake"]?.trim() || undefined,
transferRx: parseBytes(p.rx),
transferTx: parseBytes(p.tx),
persistentKeepalive: Number.isFinite(ka) ? ka : undefined,
persistent: Number.isFinite(ka) && (ka as number) > 0,
comment: p.comment ?? undefined,
disabled: p.disabled === "true" || p.disabled === "yes",
name: p.name,
clientAddress: p["client-address"],
clientDns: p["client-dns"],
clientEndpoint: p["client-endpoint"],
}
}
function mapIface(
server: ServerRow,
w: RosWireGuard,
peers: WgPeerDto[],
address: string | undefined,
includePrivateKey: boolean,
): WgIfaceDto {
const rosId = String(w[".id"] ?? w.name ?? "wg")
const name = (w.name ?? "").trim() || rosId
const disabled = w.disabled === "true" || w.disabled === "yes"
const running = w.running === "true" || w.running === "yes"
return {
id: `${server.id}:${rosId}`,
rosId,
name,
serverId: String(server.id),
serverName: String(server.name ?? "").trim() || String(server.host ?? server.id),
serverCountry: server.country ?? undefined,
listenPort: Number.parseInt(w["listen-port"] ?? "13231", 10) || 13231,
mtu: Number.parseInt(w.mtu ?? "1420", 10) || 1420,
publicKey: w["public-key"] || undefined,
privateKey: includePrivateKey ? w["private-key"] || undefined : undefined,
address,
peers,
comment: w.comment ?? "",
enabled: !disabled,
status: disabled ? "down" : running ? "up" : "down",
}
}
async function fetchForServer(
server: ServerRow,
includePrivateKey: boolean,
): Promise<WgIfaceDto[]> {
const client = MikrotikClient.fromServer(server)
const [ifacesRaw, peersRaw, addrsRaw] = await Promise.all([
client.get<RosWireGuard[]>("/interface/wireguard"),
client.get<RosWireGuardPeer[]>("/interface/wireguard/peers"),
client.get<RosIpAddress[]>("/ip/address").catch(() => [] as RosIpAddress[]),
])
const peersByIface = new Map<string, WgPeerDto[]>()
peersRaw.forEach((p, idx) => {
const ifaceName = (p.interface ?? "").trim()
if (!ifaceName) return
const list = peersByIface.get(ifaceName) ?? []
list.push(mapPeer(p, idx))
peersByIface.set(ifaceName, list)
})
const addrByIface = new Map<string, string>()
for (const a of addrsRaw) {
if (a.disabled === "true" || a.disabled === "yes") continue
const iface = (a.interface ?? "").trim()
const addr = (a.address ?? "").trim()
if (iface && addr && !addrByIface.has(iface)) addrByIface.set(iface, addr)
}
return ifacesRaw.map((w) => {
const name = (w.name ?? "").trim()
return mapIface(
server,
w,
peersByIface.get(name) ?? [],
addrByIface.get(name),
includePrivateKey,
)
})
}
export type WgListResult = {
interfaces: WgIfaceDto[]
failures: Array<{ serverId: string; serverName?: string; error: string }>
}
export async function listWireGuardInterfaces(opts?: {
serverId?: string
includePrivateKey?: boolean
}): Promise<WgListResult> {
const includePrivateKey = opts?.includePrivateKey === true
let serverRows: ServerRow[]
if (opts?.serverId) {
const id = Number.parseInt(String(opts.serverId), 10)
if (!Number.isFinite(id)) {
return { interfaces: [], failures: [{ serverId: String(opts.serverId), error: "Некорректный serverId" }] }
}
const row = db.select().from(servers).where(eq(servers.id, id)).limit(1).all()[0]
serverRows = row ? [row] : []
} else {
serverRows = db.select().from(servers).where(eq(servers.enabled, true)).all()
}
const failures: WgListResult["failures"] = []
const results = await Promise.all(
serverRows.map(async (server) => {
try {
return await fetchForServer(server, includePrivateKey)
} catch (e) {
failures.push({
serverId: String(server.id),
serverName: server.name ?? undefined,
error: e instanceof Error ? e.message : String(e),
})
return [] as WgIfaceDto[]
}
}),
)
return { interfaces: results.flat(), failures }
}
export async function countWireGuardInterfaces(): Promise<number> {
try {
const result = await Promise.race([
listWireGuardInterfaces({ includePrivateKey: false }),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 8_000)),
])
if (!result) return 0
return result.interfaces.length
} catch {
return 0
}
}
export function getEnabledServerById(serverId: string | number): ServerRow | null {
const id = typeof serverId === "number" ? serverId : Number.parseInt(String(serverId), 10)
if (!Number.isFinite(id)) return null
return db.select().from(servers).where(eq(servers.id, id)).limit(1).all()[0] ?? null
}
export { type RosWireGuard, type RosWireGuardPeer }
+14 -21
View File
@@ -38,6 +38,7 @@ import {
} from "lucide-react"
import { useDataSource } from "@/lib/data-source"
import { useEvoBGP } from "@/lib/evobgp-context"
import { requestJson } from "@/shared/api/http-client"
import {
formatSidebarBadgeCount,
mockSidebarBadgesByUrl,
@@ -101,10 +102,10 @@ const navStructure: { label: string; items: NavItemBase[] }[] = [
},
]
type LiveSidebarCounts = SidebarCountsDto & { greTunnels?: number; certificates?: number }
type LiveSidebarCounts = SidebarCountsDto & { greTunnels?: number; certificates?: number; wireguard?: number }
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { mode, backendUrl } = useDataSource()
const { mode, backendUrl, prefsHydrated } = useDataSource()
const evo = useEvoBGP()
const [mounted, setMounted] = React.useState(false)
const [liveCounts, setLiveCounts] = React.useState<LiveSidebarCounts | null>(null)
@@ -118,30 +119,21 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
}, [])
React.useEffect(() => {
if (mode !== "live") {
setLiveCounts(null)
if (!prefsHydrated || mode !== "live") {
if (mode !== "live") setLiveCounts(null)
return
}
let cancelled = false
const load = async () => {
try {
const base = backendUrl.replace(/\/$/, "")
const [cRes, gRes] = await Promise.all([
fetch(`${base}/api/sidebar-counts`),
fetch(`${base}/api/filters/gre-tunnels`),
const [cJson, gJson] = await Promise.all([
requestJson<SidebarCountsDto>(backendUrl, "/api/sidebar-counts"),
requestJson<{ tunnels?: unknown[] }>(backendUrl, "/api/filters/gre-tunnels").catch(
() => ({ tunnels: [] as unknown[] }),
),
])
if (cancelled) return
if (!cRes.ok) {
setLiveCounts(null)
return
}
const cJson = (await cRes.json()) as SidebarCountsDto
let greN = 0
if (gRes.ok) {
const gJson = (await gRes.json()) as { tunnels?: unknown[] }
greN = (gJson.tunnels ?? []).length
}
setLiveCounts({ ...cJson, greTunnels: greN })
setLiveCounts({ ...cJson, greTunnels: (gJson.tunnels ?? []).length })
} catch {
if (!cancelled) setLiveCounts(null)
}
@@ -152,7 +144,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
cancelled = true
window.clearInterval(id)
}
}, [mode, backendUrl])
}, [mode, backendUrl, prefsHydrated])
const navGroups = React.useMemo((): NavGroup[] => {
function badgeFor(url: string): string | undefined {
@@ -173,8 +165,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
if (url === "/uptime") return formatSidebarBadgeCount(liveCounts.monitoringItems)
if (url === "/gre") return formatSidebarBadgeCount(liveCounts.greTunnels ?? 0)
if (url === "/certificates") return formatSidebarBadgeCount(liveCounts.certificates ?? 0)
if (url === "/wireguard") return formatSidebarBadgeCount(liveCounts.wireguard ?? 0)
if (url === "/wireguard" || url === "/containers" || url === "/bgp") {
if (url === "/containers" || url === "/bgp") {
return undefined
}
+51
View File
@@ -0,0 +1,51 @@
"use client"
import { useEffect, useState, type ReactNode } from "react"
import {
ensureAuthConfig,
getClaims,
getToken,
isAuthEnabled,
redirectToPortalLogin,
redirectToPortalLoginInteractive,
} from "@/lib/auth"
export function AuthGuard({ children }: { children: ReactNode }) {
const [ready, setReady] = useState(false)
useEffect(() => {
let cancelled = false
void (async () => {
await ensureAuthConfig()
if (cancelled) return
if (!isAuthEnabled()) {
setReady(true)
return
}
const claims = getClaims()
if (!getToken() || !claims) {
const ok = redirectToPortalLogin()
if (!ok) redirectToPortalLoginInteractive()
return
}
if (!claims.apps.includes("mm")) {
window.location.assign("/access-denied")
return
}
setReady(true)
})()
return () => {
cancelled = true
}
}, [])
if (!ready) {
return (
<div className="text-muted-foreground flex min-h-svh items-center justify-center p-6 text-sm">
Проверка сессии
</div>
)
}
return children
}
+46 -24
View File
@@ -33,7 +33,6 @@ import {
ChevronRightIcon,
CodeXmlIcon,
MoreHorizontalIcon,
PencilIcon,
PlusIcon,
PowerIcon,
ShieldCheckIcon,
@@ -49,9 +48,22 @@ export interface WgIfaceWithServer extends WireGuardInterface {
interface WireguardDataGridProps {
interfaces: WgIfaceWithServer[]
onExport: (iface: WgIfaceWithServer) => void
onAddPeer?: (iface: WgIfaceWithServer) => void
onToggle?: (iface: WgIfaceWithServer) => void
onDelete?: (iface: WgIfaceWithServer) => void
onDeletePeer?: (iface: WgIfaceWithServer, peerId: string) => void
onExportPeer?: (iface: WgIfaceWithServer, peerId: string) => void
}
function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) {
function WireguardDataGrid({
interfaces,
onExport,
onAddPeer,
onToggle,
onDelete,
onDeletePeer,
onExportPeer,
}: WireguardDataGridProps) {
const columns = useMemo<ColumnDef<WgIfaceWithServer>[]>(
() => [
{
@@ -82,7 +94,7 @@ function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) {
<span className="font-mono font-semibold text-sm">{iface.name}</span>
</div>
<div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground font-mono">
<Flag code={iface.serverCountry} size={12} />
<Flag code={iface.serverCountry || "UN"} size={12} />
{iface.serverName}
</div>
<p className="sr-only">
@@ -97,7 +109,11 @@ function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) {
headerClassName: DATA_GRID_CELL_PAD_FIRST,
cellClassName: DATA_GRID_CELL_PAD_FIRST,
expandedContent: (row: WgIfaceWithServer) => (
<WireGuardPeersDetail peers={row.peers} />
<WireGuardPeersDetail
peers={row.peers}
onDeletePeer={onDeletePeer ? (peerId) => onDeletePeer(row, peerId) : undefined}
onExportPeer={onExportPeer ? (peerId) => onExportPeer(row, peerId) : undefined}
/>
),
},
},
@@ -203,26 +219,32 @@ function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) {
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem onClick={() => onExport(iface)}>
<CodeXmlIcon className="size-4" />
Экспорт .rsc
</DropdownMenuItem>
<DropdownMenuItem>
<PencilIcon className="size-4" />
Редактировать
</DropdownMenuItem>
<DropdownMenuItem>
<PlusIcon className="size-4" />
Добавить пира
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<PowerIcon className="size-4" />
{iface.enabled ? "Отключить" : "Включить"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<Trash2Icon className="size-4" />
Удалить
Экспорт
</DropdownMenuItem>
{onAddPeer && (
<DropdownMenuItem onClick={() => onAddPeer(iface)}>
<PlusIcon className="size-4" />
Добавить пира
</DropdownMenuItem>
)}
{onToggle && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onToggle(iface)}>
<PowerIcon className="size-4" />
{iface.enabled ? "Отключить" : "Включить"}
</DropdownMenuItem>
</>
)}
{onDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={() => onDelete(iface)}>
<Trash2Icon className="size-4" />
Удалить
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -236,7 +258,7 @@ function WireguardDataGrid({ interfaces, onExport }: WireguardDataGridProps) {
},
},
],
[onExport],
[onExport, onAddPeer, onToggle, onDelete, onDeletePeer, onExportPeer],
)
const table = useReactTable({
@@ -2,10 +2,13 @@
import type { WireGuardPeer } from "@/lib/data"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
ArrowDownIcon,
ArrowUpIcon,
CodeXmlIcon,
KeyRoundIcon,
Trash2Icon,
} from "lucide-react"
function fmtBytes(n: number | undefined): string {
@@ -21,7 +24,19 @@ function truncKey(key: string): string {
return `${key.slice(0, 8)}${key.slice(-8)}`
}
function WireGuardPeersDetail({ peers }: { peers: WireGuardPeer[] }) {
function peerKey(peer: WireGuardPeer, index: number): string {
return peer.id ?? peer.rosId ?? peer.publicKey ?? String(index)
}
function WireGuardPeersDetail({
peers,
onDeletePeer,
onExportPeer,
}: {
peers: WireGuardPeer[]
onDeletePeer?: (peerId: string) => void
onExportPeer?: (peerId: string) => void
}) {
if (peers.length === 0) {
return (
<div className="px-5 py-4 text-xs text-muted-foreground text-center border-t border-border/50">
@@ -32,48 +47,84 @@ function WireGuardPeersDetail({ peers }: { peers: WireGuardPeer[] }) {
return (
<div className="border-t border-border/50">
<div className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-3 px-5 py-1.5 bg-muted/10 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
<div className="grid grid-cols-[1fr_1fr_auto_auto_auto_auto] gap-3 px-5 py-1.5 bg-muted/10 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
<span>Public Key</span>
<span>Allowed IPs</span>
<span>Последнее рукопожатие</span>
<span>RX / TX</span>
<span>Endpoint</span>
<span className="sr-only">Действия</span>
</div>
{peers.map((peer) => (
<div
key={peer.publicKey}
className="grid grid-cols-[1fr_1fr_auto_auto_auto] gap-3 px-5 py-2.5 items-center text-xs border-t border-border/50 bg-muted/20"
>
<div className="flex items-center gap-1.5 min-w-0">
<KeyRoundIcon className="size-3 text-muted-foreground shrink-0" />
<span className="font-mono text-muted-foreground truncate" title={peer.publicKey}>
{truncKey(peer.publicKey)}
</span>
</div>
<div className="font-mono text-muted-foreground truncate">
{peer.allowedIps.join(", ")}
</div>
<span
className={cn(
"font-mono text-[11px] whitespace-nowrap",
peer.latestHandshake ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground",
)}
{peers.map((peer, index) => {
const id = peerKey(peer, index)
return (
<div
key={id}
className="grid grid-cols-[1fr_1fr_auto_auto_auto_auto] gap-3 px-5 py-2.5 items-center text-xs border-t border-border/50 bg-muted/20"
>
{peer.latestHandshake ?? "нет рукопожатия"}
</span>
<div className="flex items-center gap-2 text-muted-foreground whitespace-nowrap">
<span className="flex items-center gap-0.5">
<ArrowDownIcon className="size-3 text-emerald-500" />
{fmtBytes(peer.transferRx)}
</span>
<span className="flex items-center gap-0.5">
<ArrowUpIcon className="size-3 text-blue-400" />
{fmtBytes(peer.transferTx)}
<div className="flex items-center gap-1.5 min-w-0">
<KeyRoundIcon className="size-3 text-muted-foreground shrink-0" />
<span className="font-mono text-muted-foreground truncate" title={peer.publicKey}>
{truncKey(peer.publicKey)}
</span>
</div>
<div className="font-mono text-muted-foreground truncate">
{peer.allowedIps.join(", ")}
</div>
<span
className={cn(
"font-mono text-[11px] whitespace-nowrap",
peer.latestHandshake ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground",
)}
>
{peer.latestHandshake ?? "нет рукопожатия"}
</span>
<div className="flex items-center gap-2 text-muted-foreground whitespace-nowrap">
<span className="flex items-center gap-0.5">
<ArrowDownIcon className="size-3 text-emerald-500" />
{fmtBytes(peer.transferRx)}
</span>
<span className="flex items-center gap-0.5">
<ArrowUpIcon className="size-3 text-blue-400" />
{fmtBytes(peer.transferTx)}
</span>
</div>
<span className="font-mono text-muted-foreground/60 text-[11px]">{peer.endpoint ?? "—"}</span>
<div className="flex items-center gap-1 justify-end">
{onExportPeer && (
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
aria-label="Экспорт peer .conf"
onClick={(e) => {
e.stopPropagation()
onExportPeer(id)
}}
>
<CodeXmlIcon className="size-3.5" />
</Button>
)}
{onDeletePeer && (
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 text-destructive"
aria-label="Удалить пира"
onClick={(e) => {
e.stopPropagation()
onDeletePeer(id)
}}
>
<Trash2Icon className="size-3.5" />
</Button>
)}
</div>
</div>
<span className="font-mono text-muted-foreground/60 text-[11px]">{peer.endpoint ?? "—"}</span>
</div>
))}
)
})}
</div>
)
}
+42 -3
View File
@@ -5,6 +5,7 @@ import Link from "next/link"
import { useTheme } from "@/components/theme-provider"
import {
ChevronsUpDownIcon,
LogOutIcon,
MonitorIcon,
MoonIcon,
PaletteIcon,
@@ -13,6 +14,12 @@ import {
} from "lucide-react"
import { cn } from "@/lib/utils"
import {
ensureAuthConfig,
getClaims,
isAuthEnabled,
redirectToPortalLogout,
} from "@/lib/auth"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import {
@@ -96,11 +103,37 @@ function ThemeSegmentedToggle() {
)
}
function initials(name: string, email: string): string {
const base = (name || email || "?").trim()
const parts = base.split(/\s+/).filter(Boolean)
if (parts.length >= 2) {
return (parts[0]![0]! + parts[1]![0]!).toUpperCase()
}
return base.slice(0, 2).toUpperCase()
}
export function NavUser() {
const { isMobile } = useSidebar()
const name = "Оператор"
const email = "локальный доступ"
const fallback = "ОП"
const [name, setName] = useState("Оператор")
const [email, setEmail] = useState("локальный доступ")
const [showLogout, setShowLogout] = useState(false)
useEffect(() => {
void ensureAuthConfig().then(() => {
const claims = getClaims()
if (claims) {
setName(claims.name || claims.email || "Пользователь")
setEmail(claims.email || "")
setShowLogout(isAuthEnabled())
} else if (isAuthEnabled()) {
setName("Сессия")
setEmail("требуется вход")
setShowLogout(true)
}
})
}, [])
const fallback = initials(name, email)
return (
<SidebarMenu>
@@ -155,6 +188,12 @@ export function NavUser() {
<ThemeSegmentedToggle />
</div>
</DropdownMenuItem>
{showLogout ? (
<DropdownMenuItem onClick={() => redirectToPortalLogout()}>
<LogOutIcon aria-hidden />
Выйти
</DropdownMenuItem>
) : null}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
+8 -11
View File
@@ -9,6 +9,7 @@ import { cn } from "@/lib/utils"
import { useDataSource } from "@/lib/data-source"
import { filters, pingProbes, servers } from "@/lib/data"
import type { SidebarCountsDto } from "@/lib/sidebar-badges"
import { resolveApiUrl, requestJson } from "@/shared/api/http-client"
type MonitorMetric = {
id: string
@@ -78,11 +79,12 @@ function MetricCell({ metric }: { metric: MonitorMetric }) {
/** Live system monitor popover — app-shell-7. @see https://reui.io/preview/base/app-shell-7 */
export function SystemMonitorPopover() {
const { mode, backendUrl } = useDataSource()
const { mode, backendUrl, prefsHydrated } = useDataSource()
const [healthOk, setHealthOk] = useState<boolean | null>(null)
const [counts, setCounts] = useState<SidebarCountsDto | null>(null)
useEffect(() => {
if (!prefsHydrated) return
if (mode !== "live") {
setHealthOk(true)
setCounts({
@@ -98,11 +100,10 @@ export function SystemMonitorPopover() {
let cancelled = false
const load = async () => {
const base = backendUrl.replace(/\/$/, "")
try {
const [hRes, cRes] = await Promise.all([
fetch(`${base}/health`),
fetch(`${base}/api/sidebar-counts`),
const [hRes, counts] = await Promise.all([
fetch(resolveApiUrl(backendUrl, "/health"), { signal: AbortSignal.timeout(3000) }),
requestJson<SidebarCountsDto>(backendUrl, "/api/sidebar-counts"),
])
if (cancelled) return
if (hRes.ok) {
@@ -111,11 +112,7 @@ export function SystemMonitorPopover() {
} else {
setHealthOk(false)
}
if (cRes.ok) {
setCounts((await cRes.json()) as SidebarCountsDto)
} else {
setCounts(null)
}
setCounts(counts)
} catch {
if (!cancelled) {
setHealthOk(false)
@@ -129,7 +126,7 @@ export function SystemMonitorPopover() {
cancelled = true
window.clearInterval(id)
}
}, [mode, backendUrl])
}, [mode, backendUrl, prefsHydrated])
const serversCount = counts?.servers ?? 0
const filtersCount = counts?.filterRules ?? 0
+232
View File
@@ -0,0 +1,232 @@
"use client"
import { useMemo, useState } from "react"
import { FormField, FormToggle, SectionTitle } from "@/components/form-kit"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"
export type WgCreateFormState = {
serverId: string
name: string
listenPort: string
mtu: string
comment: string
address: string
enabled: boolean
showAdvanced: boolean
peerEnabled: boolean
peerPublicKey: string
peerAllowedIps: string
peerEndpoint: string
peerKeepalive: string
peerComment: string
}
export const defaultWgCreateForm = (): WgCreateFormState => ({
serverId: "",
name: "",
listenPort: "13231",
mtu: "1420",
comment: "",
address: "",
enabled: true,
showAdvanced: false,
peerEnabled: false,
peerPublicKey: "",
peerAllowedIps: "",
peerEndpoint: "",
peerKeepalive: "25",
peerComment: "",
})
type ServerOption = { id: string; name: string; host: string }
function WgCreateSheet({
open,
onOpenChange,
servers,
busy,
onSubmit,
}: {
open: boolean
onOpenChange: (v: boolean) => void
servers: ServerOption[]
busy?: boolean
onSubmit: (form: WgCreateFormState) => void | Promise<void>
}) {
const [form, setForm] = useState<WgCreateFormState>(defaultWgCreateForm)
const set = <K extends keyof WgCreateFormState>(k: K, v: WgCreateFormState[K]) =>
setForm((f) => ({ ...f, [k]: v }))
const canSubmit = useMemo(() => {
return Boolean(form.serverId && form.name.trim() && form.listenPort)
}, [form.serverId, form.name, form.listenPort])
return (
<Sheet
open={open}
onOpenChange={(v) => {
if (v) setForm(defaultWgCreateForm())
onOpenChange(v)
}}
>
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>Быстрый туннель WireGuard</SheetTitle>
<SheetDescription>
Создать интерфейс на выбранном MikroTik (ключи сгенерирует RouterOS)
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
<div className="flex flex-col gap-4">
<SectionTitle>Основные</SectionTitle>
<FormField label="Сервер" required>
<select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
value={form.serverId}
onChange={(e) => set("serverId", e.target.value)}
>
<option value="">Выберите сервер</option>
{servers.map((s) => (
<option key={s.id} value={s.id}>
{s.name} ({s.host})
</option>
))}
</select>
</FormField>
<FormField label="Имя интерфейса" required hint="Например wg-msk-spb">
<Input
className="font-mono"
placeholder="wg0"
value={form.name}
onChange={(e) => set("name", e.target.value)}
/>
</FormField>
<div className="grid grid-cols-2 gap-3">
<FormField label="Listen port" required>
<Input
className="font-mono"
value={form.listenPort}
onChange={(e) => set("listenPort", e.target.value)}
/>
</FormField>
<FormField label="MTU">
<Input
className="font-mono"
value={form.mtu}
onChange={(e) => set("mtu", e.target.value)}
/>
</FormField>
</div>
<FormField label="Комментарий">
<Input
value={form.comment}
onChange={(e) => set("comment", e.target.value)}
placeholder="MSK → SPB overlay"
/>
</FormField>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Включён</p>
<p className="text-xs text-muted-foreground">disabled=no на роутере</p>
</div>
<FormToggle checked={form.enabled} onChange={(v) => set("enabled", v)} />
</div>
</div>
<button
type="button"
className="flex items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground"
onClick={() => set("showAdvanced", !form.showAdvanced)}
>
{form.showAdvanced ? <ChevronDownIcon className="size-4" /> : <ChevronRightIcon className="size-4" />}
Дополнительно
</button>
{form.showAdvanced && (
<div className="flex flex-col gap-4">
<FormField label="IP на интерфейсе" hint="/ip address add, например 10.210.0.1/30">
<Input
className="font-mono"
placeholder="10.210.0.1/30"
value={form.address}
onChange={(e) => set("address", e.target.value)}
/>
</FormField>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">Добавить первого пира</p>
<p className="text-xs text-muted-foreground">Сразу после создания интерфейса</p>
</div>
<FormToggle checked={form.peerEnabled} onChange={(v) => set("peerEnabled", v)} />
</div>
{form.peerEnabled && (
<div className="flex flex-col gap-3 rounded-lg border border-border p-3">
<FormField label="Public key пира" required>
<Input
className="font-mono text-xs"
value={form.peerPublicKey}
onChange={(e) => set("peerPublicKey", e.target.value)}
/>
</FormField>
<FormField label="Allowed IPs" required hint="Через запятую">
<Input
className="font-mono"
placeholder="10.210.0.2/32"
value={form.peerAllowedIps}
onChange={(e) => set("peerAllowedIps", e.target.value)}
/>
</FormField>
<FormField label="Endpoint" hint="host:port">
<Input
className="font-mono"
placeholder="1.2.3.4:13231"
value={form.peerEndpoint}
onChange={(e) => set("peerEndpoint", e.target.value)}
/>
</FormField>
<FormField label="Keepalive (сек)">
<Input
className="font-mono"
value={form.peerKeepalive}
onChange={(e) => set("peerKeepalive", e.target.value)}
/>
</FormField>
<FormField label="Комментарий пира">
<Input
value={form.peerComment}
onChange={(e) => set("peerComment", e.target.value)}
/>
</FormField>
</div>
)}
</div>
)}
</div>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" disabled={busy} />}>
Отмена
</SheetClose>
<Button
className="flex-1"
disabled={!canSubmit || busy}
onClick={() => void onSubmit(form)}
>
{busy ? "Создание…" : "Создать туннель"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
export { WgCreateSheet }
+219
View File
@@ -0,0 +1,219 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import type { WgIfaceWithServer } from "@/components/data-grids/wireguard-data-grid"
import { Button } from "@/components/ui/button"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import {
generateMikrotikRsc,
generateNativeConf,
generatePeerClientConf,
} from "@/lib/wg-config"
import { CheckIcon, CopyIcon, DownloadIcon } from "lucide-react"
function downloadText(filename: string, content: string) {
const blob = new Blob([content], { type: "text/plain;charset=utf-8" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = filename
a.click()
URL.revokeObjectURL(url)
}
function WgExportSheet({
open,
iface,
onClose,
liveContent,
liveBusy,
onRequestLiveExport,
}: {
open: boolean
iface: WgIfaceWithServer | null
onClose: () => void
/** Optional server-fetched content (with private key) keyed by format */
liveContent?: { rsc?: string; conf?: string; peerConf?: string } | null
liveBusy?: boolean
onRequestLiveExport?: (format: "rsc" | "conf" | "peer-conf") => void
}) {
const [tab, setTab] = useState<"rsc" | "conf" | "peer">("rsc")
const [copied, setCopied] = useState(false)
useEffect(() => {
if (open) {
setTab("rsc")
setCopied(false)
}
}, [open, iface?.id])
const local = useMemo(() => {
if (!iface) return { rsc: "", conf: "", peerConf: "" }
const base = {
name: iface.name,
listenPort: iface.listenPort,
mtu: iface.mtu,
comment: iface.comment,
enabled: iface.enabled,
privateKey: iface.privateKey,
publicKey: iface.publicKey,
address: iface.address,
serverName: iface.serverName,
peers: iface.peers.map((p) => ({
publicKey: p.publicKey,
allowedIps: p.allowedIps,
endpoint: p.endpoint,
persistentKeepalive: p.persistentKeepalive,
persistent: p.persistent,
comment: p.comment,
name: p.name,
clientAddress: p.clientAddress,
clientDns: p.clientDns,
clientEndpoint: p.clientEndpoint,
})),
}
const peer = iface.peers[0]
return {
rsc: generateMikrotikRsc(base),
conf: generateNativeConf(base),
peerConf:
iface.publicKey && peer
? generatePeerClientConf({
peerAddress: peer.clientAddress,
peerDns: peer.clientDns,
serverPublicKey: iface.publicKey,
allowedIps: peer.allowedIps,
endpoint:
peer.clientEndpoint ||
peer.endpoint ||
undefined,
persistentKeepalive: peer.persistentKeepalive ?? 25,
})
: "# Нет public-key интерфейса или пиров для клиентского .conf\n",
}
}, [iface])
const code =
tab === "rsc"
? (liveContent?.rsc ?? local.rsc)
: tab === "conf"
? (liveContent?.conf ?? local.conf)
: (liveContent?.peerConf ?? local.peerConf)
const filename =
tab === "rsc"
? `${iface?.name ?? "wg"}.rsc`
: tab === "conf"
? `${iface?.name ?? "wg"}.conf`
: `${iface?.name ?? "wg"}-peer.conf`
function handleCopy() {
void navigator.clipboard.writeText(code).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}
return (
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<SheetContent className="flex flex-col overflow-hidden p-0 gap-0 sm:max-w-2xl">
<SheetHeader className="shrink-0 px-6 pt-5 pb-4 border-b">
<div className="flex items-start justify-between gap-4">
<div>
<SheetTitle>Экспорт WireGuard</SheetTitle>
<SheetDescription>
{iface ? `${iface.name} · ${iface.serverName}` : "—"}
</SheetDescription>
</div>
<div className="flex gap-2 shrink-0">
<Button variant="outline" size="sm" onClick={handleCopy}>
{copied
? <><CheckIcon className="size-3.5 text-emerald-500" />Скопировано</>
: <><CopyIcon className="size-3.5" />Копировать</>}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => downloadText(filename, code)}
>
<DownloadIcon className="size-3.5" />
Файл
</Button>
</div>
</div>
</SheetHeader>
<div className="px-6 pt-3 shrink-0">
<Tabs value={tab} onValueChange={(v) => setTab(v as typeof tab)}>
<TabsList>
<TabsTrigger value="rsc">MikroTik .rsc</TabsTrigger>
<TabsTrigger value="conf">Native .conf</TabsTrigger>
<TabsTrigger value="peer">Peer .conf</TabsTrigger>
</TabsList>
{onRequestLiveExport && (
<div className="mt-2">
<Button
type="button"
variant="ghost"
size="sm"
disabled={liveBusy}
onClick={() =>
onRequestLiveExport(
tab === "peer" ? "peer-conf" : tab === "conf" ? "conf" : "rsc",
)
}
>
{liveBusy ? "Загрузка с роутера…" : "Подтянуть с роутера (с private-key)"}
</Button>
</div>
)}
<TabsContent value="rsc" className="mt-0" />
<TabsContent value="conf" className="mt-0" />
<TabsContent value="peer" className="mt-0" />
</Tabs>
</div>
<div className="flex-1 overflow-y-auto">
<pre className="px-6 py-5 text-[12px] font-mono leading-relaxed text-foreground/85 whitespace-pre select-all">
{code.split("\n").map((line, i) => {
const isComment = line.startsWith("#")
const isCmd = line.trimStart().startsWith("/interface") || line.trimStart().startsWith("/ip")
const isSection = line.startsWith("[")
const isParam = /^\s+[a-z]/.test(line) || /^[A-Za-z]+=/.test(line)
return (
<span
key={i}
className={
isComment
? "text-muted-foreground"
: isCmd || isSection
? "text-sky-400"
: isParam
? "text-violet-300"
: "text-foreground"
}
>
{line}{"\n"}
</span>
)
})}
</pre>
</div>
<SheetFooter className="shrink-0 px-6 py-4 border-t flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" />}>Закрыть</SheetClose>
<Button className="flex-1" onClick={handleCopy}>
{copied ? <CheckIcon className="size-4" /> : <CopyIcon className="size-4" />}
{copied ? "Скопировано" : "Копировать"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
export { WgExportSheet }
+182
View File
@@ -0,0 +1,182 @@
"use client"
import { useMemo, useState } from "react"
import { FormField, SectionTitle } from "@/components/form-kit"
import { Button } from "@/components/ui/button"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
import { detectWgConfigFormat, parseWgConfig, type WgParsedConfig } from "@/lib/wg-config"
import { UploadIcon } from "lucide-react"
type ServerOption = { id: string; name: string; host: string }
function WgImportSheet({
open,
onOpenChange,
servers,
busy,
onImport,
}: {
open: boolean
onOpenChange: (v: boolean) => void
servers: ServerOption[]
busy?: boolean
onImport: (args: {
serverId: string
content: string
format: "auto" | "rsc" | "conf"
dryRun: boolean
}) => Promise<void>
}) {
const [serverId, setServerId] = useState("")
const [content, setContent] = useState("")
const [format, setFormat] = useState<"auto" | "rsc" | "conf">("auto")
const [preview, setPreview] = useState<WgParsedConfig | null>(null)
const [parseError, setParseError] = useState<string | null>(null)
const detected = useMemo(
() => (content.trim() ? detectWgConfigFormat(content) : null),
[content],
)
function runPreview() {
setParseError(null)
setPreview(null)
try {
setPreview(parseWgConfig(content, format))
} catch (e) {
setParseError(e instanceof Error ? e.message : "Ошибка разбора")
}
}
function onFile(file: File | null) {
if (!file) return
const reader = new FileReader()
reader.onload = () => {
setContent(String(reader.result ?? ""))
setPreview(null)
setParseError(null)
}
reader.readAsText(file)
}
return (
<Sheet
open={open}
onOpenChange={(v) => {
if (!v) {
setContent("")
setPreview(null)
setParseError(null)
setServerId("")
}
onOpenChange(v)
}}
>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>Импорт конфига WireGuard</SheetTitle>
<SheetDescription>
Native .conf или MikroTik .rsc применить на выбранный роутер
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-5">
<FormField label="Сервер" required>
<select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
value={serverId}
onChange={(e) => setServerId(e.target.value)}
>
<option value="">Выберите сервер</option>
{servers.map((s) => (
<option key={s.id} value={s.id}>
{s.name} ({s.host})
</option>
))}
</select>
</FormField>
<FormField label="Формат">
<select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm"
value={format}
onChange={(e) => setFormat(e.target.value as "auto" | "rsc" | "conf")}
>
<option value="auto">Авто{detected ? ` (${detected})` : ""}</option>
<option value="conf">Native WireGuard (.conf)</option>
<option value="rsc">MikroTik (.rsc)</option>
</select>
</FormField>
<div className="flex flex-col gap-2">
<SectionTitle>Содержимое</SectionTitle>
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer w-fit">
<UploadIcon className="size-4" />
Загрузить файл
<input
type="file"
accept=".conf,.rsc,.txt,text/plain"
className="sr-only"
onChange={(e) => onFile(e.target.files?.[0] ?? null)}
/>
</label>
<textarea
className="min-h-40 w-full rounded-md border border-input bg-transparent px-3 py-2 font-mono text-xs leading-relaxed outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]"
placeholder={"[Interface]\nPrivateKey = …\n…\n\nили\n\n/interface wireguard add …"}
value={content}
onChange={(e) => {
setContent(e.target.value)
setPreview(null)
setParseError(null)
}}
/>
</div>
<Button type="button" variant="outline" size="sm" onClick={runPreview} disabled={!content.trim()}>
Предпросмотр
</Button>
{parseError && <p className="text-sm text-destructive">{parseError}</p>}
{preview && (
<div className="rounded-lg border border-border bg-muted/20 px-4 py-3 text-sm flex flex-col gap-2">
<p className="font-medium">
{preview.format.toUpperCase()} · {preview.interface.name}
</p>
<p className="text-xs text-muted-foreground font-mono">
port={preview.interface.listenPort ?? "—"} · mtu={preview.interface.mtu ?? "—"}
{preview.interface.address ? ` · ${preview.interface.address}` : ""}
</p>
<p className="text-xs text-muted-foreground">Пиров: {preview.peers.length}</p>
{preview.peers.slice(0, 5).map((p, i) => (
<p key={i} className="text-[11px] font-mono text-muted-foreground truncate">
{p.publicKey.slice(0, 16)} {p.allowedAddresses.join(", ")}
</p>
))}
</div>
)}
</div>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-col gap-2 sm:flex-col">
<div className="flex w-full gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" disabled={busy} />}>
Отмена
</SheetClose>
<Button
className="flex-1"
disabled={!serverId || !content.trim() || busy}
onClick={() => void onImport({ serverId, content, format, dryRun: false })}
>
{busy ? "Импорт…" : "Применить на роутер"}
</Button>
</div>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
export { WgImportSheet }
+114
View File
@@ -0,0 +1,114 @@
"use client"
import { useState } from "react"
import { FormField, SectionTitle } from "@/components/form-kit"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
SheetDescription, SheetFooter, SheetClose,
} from "@/components/ui/sheet"
import type { WgIfaceWithServer } from "@/components/data-grids/wireguard-data-grid"
export type WgPeerFormState = {
publicKey: string
allowedIps: string
endpoint: string
keepalive: string
comment: string
}
const emptyPeerForm = (): WgPeerFormState => ({
publicKey: "",
allowedIps: "",
endpoint: "",
keepalive: "25",
comment: "",
})
function WgPeerSheet({
open,
iface,
busy,
onOpenChange,
onSubmit,
}: {
open: boolean
iface: WgIfaceWithServer | null
busy?: boolean
onOpenChange: (v: boolean) => void
onSubmit: (form: WgPeerFormState) => void | Promise<void>
}) {
const [form, setForm] = useState<WgPeerFormState>(emptyPeerForm)
const set = <K extends keyof WgPeerFormState>(k: K, v: WgPeerFormState[K]) =>
setForm((f) => ({ ...f, [k]: v }))
return (
<Sheet
open={open}
onOpenChange={(v) => {
if (v) setForm(emptyPeerForm())
onOpenChange(v)
}}
>
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col gap-0 p-0">
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
<SheetTitle>Добавить пира</SheetTitle>
<SheetDescription>
{iface ? `${iface.name} · ${iface.serverName}` : "WireGuard peer"}
</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-y-auto px-6 py-5 flex flex-col gap-4">
<SectionTitle>Параметры пира</SectionTitle>
<FormField label="Public key" required>
<Input
className="font-mono text-xs"
value={form.publicKey}
onChange={(e) => set("publicKey", e.target.value)}
/>
</FormField>
<FormField label="Allowed IPs" required hint="Через запятую">
<Input
className="font-mono"
placeholder="10.210.0.2/32"
value={form.allowedIps}
onChange={(e) => set("allowedIps", e.target.value)}
/>
</FormField>
<FormField label="Endpoint" hint="host:port">
<Input
className="font-mono"
placeholder="1.2.3.4:13231"
value={form.endpoint}
onChange={(e) => set("endpoint", e.target.value)}
/>
</FormField>
<FormField label="Keepalive (сек)">
<Input
className="font-mono"
value={form.keepalive}
onChange={(e) => set("keepalive", e.target.value)}
/>
</FormField>
<FormField label="Комментарий">
<Input value={form.comment} onChange={(e) => set("comment", e.target.value)} />
</FormField>
</div>
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
<SheetClose render={<Button variant="outline" className="flex-1" disabled={busy} />}>
Отмена
</SheetClose>
<Button
className="flex-1"
disabled={busy || !form.publicKey.trim() || !form.allowedIps.trim()}
onClick={() => void onSubmit(form)}
>
{busy ? "Сохранение…" : "Добавить"}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
export { WgPeerSheet }
+242
View File
@@ -0,0 +1,242 @@
# CDN Manager + MikrotikManager + one Traefik (production).
#
# Hosts:
# https://cdn.shnt.top → cdnmanager:8080
# https://mm.shnt.top → mmapp-frontend:3000 → backend:8000 (internal rewrite)
#
# On server:
# mkdir -p /opt/cdn-mm/{data/cdn,data/mm,state,updater}
# cp deploy/docker-compose.cdn-mm.yml /opt/cdn-mm/docker-compose.yml
# cp deploy/env.cdn-mm.example /opt/cdn-mm/.env # fill secrets
# # targets.json:
# # cp deploy/updater/targets.json.example /opt/cdn-mm/updater/targets.json
# # (в CDNManager-репо скачайте тот же файл из MikrotikManager)
# docker login git.shx.one
# cd /opt/cdn-mm && docker compose pull && docker compose up -d
#
# DNS (Cloudflare DNS only, grey cloud):
# A/AAAA cdn.shnt.top → VPS
# A/AAAA mm.shnt.top → VPS
#
# Do not run a second Traefik (standalone CDNManager or MikrotikManager compose)
# on the same host ports while this stack is up.
services:
traefik:
image: traefik:${TRAEFIK_IMAGE_TAG:-v3.7}
container_name: cdn-mm-traefik
restart: unless-stopped
security_opt:
- no-new-privileges:true
ports:
- "${TRAEFIK_HTTP_PORT:-80}:80"
- "${TRAEFIK_HTTPS_PORT:-443}:443"
environment:
CF_DNS_API_TOKEN: ${CF_DNS_API_TOKEN:?set CF_DNS_API_TOKEN in .env}
# Optional if DNS token lacks Zone:Read:
# CF_ZONE_API_TOKEN: ${CF_ZONE_API_TOKEN:-}
command:
- --log.level=${TRAEFIK_LOG_LEVEL:-INFO}
- --api.dashboard=false
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --providers.docker.network=edge
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
- --entrypoints.web.http.redirections.entrypoint.to=websecure
- --entrypoints.web.http.redirections.entrypoint.scheme=https
- --certificatesresolvers.letsencrypt.acme.email=${LETSENCRYPT_EMAIL:?set LETSENCRYPT_EMAIL in .env}
- --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
- --certificatesresolvers.letsencrypt.acme.dnschallenge=true
- --certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare
- --certificatesresolvers.letsencrypt.acme.dnschallenge.delaybeforecheck=15
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- traefik_letsencrypt:/letsencrypt
networks:
- edge
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
# --- CDN Manager -----------------------------------------------------------
cdnmanager:
# cdnmanager и cdn-manager — один образ (алиас для drop-in).
image: git.shx.one/denozord/cdnmanager:${CDN_IMAGE_TAG:-latest}
pull_policy: always
container_name: cdnmanager
restart: unless-stopped
depends_on:
- traefik
env_file:
- .env
environment:
DATABASE_URL: sqlite:/data/app.db
STATIC_DIR: /app/static
SERVER_PORT: "8080"
NODE_ENV: production
LOG_LEVEL: ${LOG_LEVEL:-info}
CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN:?set CLOUDFLARE_API_TOKEN in .env}
JWT_SECRET: ${JWT_SECRET:-}
AUTH_REQUIRED: ${AUTH_REQUIRED:-true}
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET in .env}
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-https://auth.shnt.top}
AUTH_AUDIT_INGEST_SECRET: ${AUTH_AUDIT_INGEST_SECRET:-}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD_HASH: ${ADMIN_PASSWORD_HASH:-}
volumes:
- ./data/cdn:/data
networks:
- edge
labels:
- traefik.enable=true
- traefik.docker.network=edge
- traefik.http.routers.cdnmanager.rule=Host(`${CDN_DOMAIN:-cdn.shnt.top}`)
- traefik.http.routers.cdnmanager.entrypoints=websecure
- traefik.http.routers.cdnmanager.tls=true
- traefik.http.routers.cdnmanager.tls.certresolver=letsencrypt
- traefik.http.services.cdnmanager.loadbalancer.server.port=8080
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:8080/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
# --- MikrotikManager -------------------------------------------------------
backend:
image: git.shx.one/denozord/mikrotikmanager-backend:${MM_BACKEND_IMAGE_TAG:-latest}
pull_policy: always
container_name: mmapp-backend
restart: unless-stopped
depends_on:
- traefik
environment:
NODE_ENV: production
PORT: "8000"
DATABASE_PATH: /app/data/mikrotik.db
CORS_ORIGIN: ${CORS_ORIGIN:-https://mm.shnt.top}
AUTH_REQUIRED: ${AUTH_REQUIRED:-true}
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET in .env}
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-https://auth.shnt.top}
volumes:
- ./data/mm:/app/data
networks:
mmapp:
aliases:
- backend
labels:
mmapp.updater.managed: "true"
mmapp.updater.target: backend
mmapp.updater.image: git.shx.one/denozord/mikrotikmanager-backend:${MM_BACKEND_IMAGE_TAG:-latest}
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:8000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
frontend:
image: git.shx.one/denozord/mikrotikmanager-frontend:${MM_FRONTEND_IMAGE_TAG:-latest}
pull_policy: always
container_name: mmapp-frontend
restart: unless-stopped
depends_on:
- backend
environment:
BACKEND_INTERNAL_URL: http://backend:8000
networks:
mmapp:
aliases:
- frontend
edge: {}
labels:
- mmapp.updater.managed=true
- mmapp.updater.target=frontend
- mmapp.updater.image=git.shx.one/denozord/mikrotikmanager-frontend:${MM_FRONTEND_IMAGE_TAG:-latest}
- traefik.enable=true
- traefik.docker.network=edge
- traefik.http.routers.mmapp.rule=Host(`${MM_DOMAIN:-mm.shnt.top}`)
- traefik.http.routers.mmapp.entrypoints=websecure
- traefik.http.routers.mmapp.tls=true
- traefik.http.routers.mmapp.tls.certresolver=letsencrypt
- traefik.http.services.mmapp.loadbalancer.server.port=3000
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:3000/dashboard').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
interval: 30s
timeout: 5s
retries: 3
start_period: 25s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
updater:
image: git.shx.one/denozord/mikrotikmanager-updater:${MM_UPDATER_IMAGE_TAG:-latest}
pull_policy: always
container_name: mmapp-updater
restart: unless-stopped
depends_on:
- frontend
environment:
REGISTRY: git.shx.one
REGISTRY_USERNAME: ${REGISTRY_USERNAME:-}
REGISTRY_PASSWORD: ${REGISTRY_PASSWORD:-}
POLL_INTERVAL_SECONDS: ${POLL_INTERVAL_SECONDS:-300}
HEALTH_TIMEOUT_SECONDS: ${HEALTH_TIMEOUT_SECONDS:-120}
STOP_TIMEOUT_SECONDS: ${STOP_TIMEOUT_SECONDS:-30}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./state:/state
- ./updater/targets.json:/etc/updater/targets.json:ro
networks:
- mmapp
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
traefik_letsencrypt:
name: cdn_mm_traefik_letsencrypt
networks:
edge:
name: edge
mmapp:
name: mmapp
+138
View File
@@ -0,0 +1,138 @@
# MikrotikManager behind an existing CDNManager Traefik (no second Traefik).
#
# Prerequisite: CDNManager stack is up — network `cdnmanager` and container
# `cdnmanager-traefik` already publish :80/:443 and watch Docker labels.
#
# On server:
# mkdir -p /opt/mmapp/data /opt/mmapp/state /opt/mmapp/updater
# cp deploy/docker-compose.traefik-cdn.yml /opt/mmapp/docker-compose.yml
# cp deploy/env.traefik.example /opt/mmapp/.env
# # set MM_DOMAIN / CORS_ORIGIN; CF_* / LETSENCRYPT_* not required here
# cp deploy/updater/targets.json.example /opt/mmapp/updater/targets.json
# docker login git.shx.one
# cd /opt/mmapp && docker compose pull && docker compose up -d
#
# Equivalent plain CLI: deploy/run-beside-cdn-traefik.sh
#
# Traffic:
# Internet → CDNManager Traefik (:80/:443) → mmapp-frontend:3000 (network cdnmanager)
# └─ rewrite /api,/health → backend:8000 (network mmapp)
services:
backend:
image: git.shx.one/denozord/mikrotikmanager-backend:${MM_BACKEND_IMAGE_TAG:-latest}
pull_policy: always
container_name: mmapp-backend
restart: unless-stopped
environment:
NODE_ENV: production
PORT: "8000"
DATABASE_PATH: /app/data/mikrotik.db
CORS_ORIGIN: ${CORS_ORIGIN:-https://mm.shnt.top}
AUTH_REQUIRED: ${AUTH_REQUIRED:-true}
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET in .env}
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-https://auth.shnt.top}
volumes:
- ./data:/app/data
networks:
mmapp:
aliases:
- backend
labels:
mmapp.updater.managed: "true"
mmapp.updater.target: backend
mmapp.updater.image: git.shx.one/denozord/mikrotikmanager-backend:${MM_BACKEND_IMAGE_TAG:-latest}
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:8000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
frontend:
image: git.shx.one/denozord/mikrotikmanager-frontend:${MM_FRONTEND_IMAGE_TAG:-latest}
pull_policy: always
container_name: mmapp-frontend
restart: unless-stopped
depends_on:
- backend
environment:
BACKEND_INTERNAL_URL: http://backend:8000
networks:
mmapp:
aliases:
- frontend
cdnmanager: {}
labels:
- mmapp.updater.managed=true
- mmapp.updater.target=frontend
- mmapp.updater.image=git.shx.one/denozord/mikrotikmanager-frontend:${MM_FRONTEND_IMAGE_TAG:-latest}
- traefik.enable=true
- traefik.docker.network=cdnmanager
- traefik.http.routers.mmapp.rule=Host(`${MM_DOMAIN:-mm.shnt.top}`)
- traefik.http.routers.mmapp.entrypoints=websecure
- traefik.http.routers.mmapp.tls=true
- traefik.http.routers.mmapp.tls.certresolver=letsencrypt
- traefik.http.services.mmapp.loadbalancer.server.port=3000
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:3000/dashboard').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
interval: 30s
timeout: 5s
retries: 3
start_period: 25s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
updater:
image: git.shx.one/denozord/mikrotikmanager-updater:${MM_UPDATER_IMAGE_TAG:-latest}
pull_policy: always
container_name: mmapp-updater
restart: unless-stopped
depends_on:
- frontend
environment:
REGISTRY: git.shx.one
REGISTRY_USERNAME: ${REGISTRY_USERNAME:-}
REGISTRY_PASSWORD: ${REGISTRY_PASSWORD:-}
POLL_INTERVAL_SECONDS: ${POLL_INTERVAL_SECONDS:-300}
HEALTH_TIMEOUT_SECONDS: ${HEALTH_TIMEOUT_SECONDS:-120}
STOP_TIMEOUT_SECONDS: ${STOP_TIMEOUT_SECONDS:-30}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./state:/state
- ./updater/targets.json:/etc/updater/targets.json:ro
networks:
- mmapp
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
mmapp:
name: mmapp
cdnmanager:
external: true
name: cdnmanager
+173
View File
@@ -0,0 +1,173 @@
# MikrotikManager + Traefik in one Compose stack (production).
# Docs: README.md (раздел «Прод-развёртывание Docker»)
#
# On server:
# mkdir -p /opt/mmapp/data /opt/mmapp/state
# cp deploy/docker-compose.traefik.yml /opt/mmapp/docker-compose.yml
# cp deploy/env.traefik.example /opt/mmapp/.env # fill secrets
# cp deploy/updater/targets.json.example /opt/mmapp/updater/targets.json
# docker login git.shx.one
# cd /opt/mmapp && docker compose pull && docker compose up -d
#
# Traffic:
# Internet → :80/:443 (Traefik) → frontend:3000
# └─ rewrite /api,/health → backend:8000 (internal)
# Backend is not published on the host — only Traefik exposes 80/443.
services:
traefik:
image: traefik:${TRAEFIK_IMAGE_TAG:-v3.7}
container_name: mmapp-traefik
restart: unless-stopped
security_opt:
- no-new-privileges:true
ports:
- "${TRAEFIK_HTTP_PORT:-80}:80"
- "${TRAEFIK_HTTPS_PORT:-443}:443"
environment:
CF_DNS_API_TOKEN: ${CF_DNS_API_TOKEN:?set CF_DNS_API_TOKEN in .env}
# Optional if DNS token lacks Zone:Read:
# CF_ZONE_API_TOKEN: ${CF_ZONE_API_TOKEN:-}
command:
- --log.level=${TRAEFIK_LOG_LEVEL:-INFO}
- --api.dashboard=false
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --providers.docker.network=mmapp
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
- --entrypoints.web.http.redirections.entrypoint.to=websecure
- --entrypoints.web.http.redirections.entrypoint.scheme=https
- --certificatesresolvers.letsencrypt.acme.email=${LETSENCRYPT_EMAIL:?set LETSENCRYPT_EMAIL in .env}
- --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json
- --certificatesresolvers.letsencrypt.acme.dnschallenge=true
- --certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare
- --certificatesresolvers.letsencrypt.acme.dnschallenge.delaybeforecheck=15
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- traefik_letsencrypt:/letsencrypt
networks:
- mmapp
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
backend:
image: git.shx.one/denozord/mikrotikmanager-backend:${MM_BACKEND_IMAGE_TAG:-latest}
pull_policy: always
container_name: mmapp-backend
restart: unless-stopped
depends_on:
- traefik
# No host ports — frontend reaches backend on the Compose network.
environment:
NODE_ENV: production
PORT: "8000"
DATABASE_PATH: /app/data/mikrotik.db
CORS_ORIGIN: ${CORS_ORIGIN:-https://mm.shnt.top}
AUTH_REQUIRED: ${AUTH_REQUIRED:-true}
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET in .env}
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-https://auth.shnt.top}
volumes:
- ./data:/app/data
networks:
- mmapp
labels:
mmapp.updater.managed: "true"
mmapp.updater.target: backend
mmapp.updater.image: git.shx.one/denozord/mikrotikmanager-backend:${MM_BACKEND_IMAGE_TAG:-latest}
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:8000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
frontend:
image: git.shx.one/denozord/mikrotikmanager-frontend:${MM_FRONTEND_IMAGE_TAG:-latest}
pull_policy: always
container_name: mmapp-frontend
restart: unless-stopped
depends_on:
- backend
# No host ports — only Traefik publishes 80/443.
environment:
BACKEND_INTERNAL_URL: http://backend:8000
networks:
- mmapp
labels:
- mmapp.updater.managed=true
- mmapp.updater.target=frontend
- mmapp.updater.image=git.shx.one/denozord/mikrotikmanager-frontend:${MM_FRONTEND_IMAGE_TAG:-latest}
- traefik.enable=true
- traefik.docker.network=mmapp
- traefik.http.routers.mmapp.rule=Host(`${MM_DOMAIN:-mm.shnt.top}`)
- traefik.http.routers.mmapp.entrypoints=websecure
- traefik.http.routers.mmapp.tls=true
- traefik.http.routers.mmapp.tls.certresolver=letsencrypt
- traefik.http.services.mmapp.loadbalancer.server.port=3000
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://127.0.0.1:3000/dashboard').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
interval: 30s
timeout: 5s
retries: 3
start_period: 25s
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
updater:
image: git.shx.one/denozord/mikrotikmanager-updater:${MM_UPDATER_IMAGE_TAG:-latest}
pull_policy: always
container_name: mmapp-updater
restart: unless-stopped
depends_on:
- frontend
environment:
REGISTRY: git.shx.one
REGISTRY_USERNAME: ${REGISTRY_USERNAME:-}
REGISTRY_PASSWORD: ${REGISTRY_PASSWORD:-}
POLL_INTERVAL_SECONDS: ${POLL_INTERVAL_SECONDS:-300}
HEALTH_TIMEOUT_SECONDS: ${HEALTH_TIMEOUT_SECONDS:-120}
STOP_TIMEOUT_SECONDS: ${STOP_TIMEOUT_SECONDS:-30}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./state:/state
- ./updater/targets.json:/etc/updater/targets.json:ro
networks:
- mmapp
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
traefik_letsencrypt:
name: mmapp_traefik_letsencrypt
networks:
mmapp:
name: mmapp
+52
View File
@@ -0,0 +1,52 @@
# Production .env for deploy/docker-compose.cdn-mm.yml
# (CDN Manager + MikrotikManager + one Traefik).
# Copy to /opt/cdn-mm/.env and fill secrets. Do not commit.
# --- Traefik / Let's Encrypt (Cloudflare DNS-01) ---
# Token for ACME only (Zone DNS Edit). Separate from CLOUDFLARE_API_TOKEN below.
CF_DNS_API_TOKEN=
LETSENCRYPT_EMAIL=admin@shnt.top
# TRAEFIK_IMAGE_TAG=v3.7
# TRAEFIK_HTTP_PORT=80
# TRAEFIK_HTTPS_PORT=443
# TRAEFIK_LOG_LEVEL=INFO
# --- Public hosts ---
CDN_DOMAIN=cdn.shnt.top
MM_DOMAIN=mm.shnt.top
# Must match MM UI origin (https:// + MM_DOMAIN).
CORS_ORIGIN=https://mm.shnt.top
# --- Images ---
CDN_IMAGE_TAG=latest
# drop-in alias (same manifest): git.shx.one/denozord/cdn-manager
MM_BACKEND_IMAGE_TAG=latest
MM_FRONTEND_IMAGE_TAG=latest
MM_UPDATER_IMAGE_TAG=latest
# --- CDN Manager ---
CLOUDFLARE_API_TOKEN=
LOG_LEVEL=info
NODE_ENV=production
# Portal SSO — used by CDN Manager and MikrotikManager backend
AUTH_REQUIRED=true
# Same HS256 secret as auth-portal JWT_SECRET (required)
AUTH_JWT_SECRET=
# Optional alias — CDN Manager also reads JWT_SECRET
JWT_SECRET=
AUTH_ISSUER=https://auth.shnt.top
AUTH_PORTAL_URL=https://auth.shnt.top
# Shared with auth-portal AUDIT_INGEST_SECRET (optional, CDN Manager)
AUTH_AUDIT_INGEST_SECRET=
# Legacy local admin (CDN) — only when AUTH_REQUIRED=false
ADMIN_USERNAME=admin
ADMIN_PASSWORD_HASH=
# --- MikrotikManager updater (optional; private registry pull) ---
REGISTRY_USERNAME=
REGISTRY_PASSWORD=
# POLL_INTERVAL_SECONDS=300
# HEALTH_TIMEOUT_SECONDS=120
# STOP_TIMEOUT_SECONDS=30
+37
View File
@@ -0,0 +1,37 @@
# Production .env for MikrotikManager Traefik deploys. Do not commit.
# Use with:
# deploy/docker-compose.traefik.yml — own Traefik (standalone)
# deploy/docker-compose.traefik-cdn.yml — reuse CDNManager Traefik (network cdnmanager)
# deploy/run-beside-cdn-traefik.sh — plain docker CLI beside CDNManager
# --- Public host ---
MM_DOMAIN=mm.shnt.top
# Must match the public HTTPS origin of the UI (same as MM_DOMAIN with https://).
CORS_ORIGIN=https://mm.shnt.top
# --- Portal SSO (MM backend) ---
AUTH_REQUIRED=true
AUTH_JWT_SECRET=
AUTH_ISSUER=https://auth.shnt.top
AUTH_PORTAL_URL=https://auth.shnt.top
# --- Traefik / Let's Encrypt (only for docker-compose.traefik.yml standalone) ---
# Not required when attaching to CDNManager Traefik (traefik-cdn / run-beside script).
CF_DNS_API_TOKEN=
LETSENCRYPT_EMAIL=admin@shnt.top
# TRAEFIK_IMAGE_TAG=v3.7
# TRAEFIK_HTTP_PORT=80
# TRAEFIK_HTTPS_PORT=443
# TRAEFIK_LOG_LEVEL=INFO
# --- Images ---
MM_BACKEND_IMAGE_TAG=latest
MM_FRONTEND_IMAGE_TAG=latest
MM_UPDATER_IMAGE_TAG=latest
# --- Updater (optional; needed for private registry pull) ---
REGISTRY_USERNAME=
REGISTRY_PASSWORD=
# POLL_INTERVAL_SECONDS=300
# HEALTH_TIMEOUT_SECONDS=120
# STOP_TIMEOUT_SECONDS=30
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env bash
# Run MikrotikManager beside an already-running CDNManager Traefik stack.
# Does NOT start a second Traefik — attaches frontend to network `cdnmanager`.
#
# Usage (on the VPS):
# curl -fsSL -o /tmp/run-beside-cdn-traefik.sh \
# https://git.shx.one/denozord/MikrotikManager/raw/branch/main/deploy/run-beside-cdn-traefik.sh
# chmod +x /tmp/run-beside-cdn-traefik.sh
# sudo MM_DOMAIN=mm.shnt.top /tmp/run-beside-cdn-traefik.sh
#
# Or copy this file to the server and run it.
#
# Env overrides:
# MM_DOMAIN=mm.shnt.top
# CORS_ORIGIN=https://mm.shnt.top
# MM_ROOT=/opt/mmapp
# MM_BACKEND_IMAGE_TAG=latest
# MM_FRONTEND_IMAGE_TAG=latest
# MM_UPDATER_IMAGE_TAG=latest
# REGISTRY_USERNAME=… REGISTRY_PASSWORD=… # optional private pull
set -euo pipefail
MM_DOMAIN="${MM_DOMAIN:-mm.shnt.top}"
CORS_ORIGIN="${CORS_ORIGIN:-https://${MM_DOMAIN}}"
MM_ROOT="${MM_ROOT:-/opt/mmapp}"
MM_BACKEND_IMAGE_TAG="${MM_BACKEND_IMAGE_TAG:-latest}"
MM_FRONTEND_IMAGE_TAG="${MM_FRONTEND_IMAGE_TAG:-latest}"
MM_UPDATER_IMAGE_TAG="${MM_UPDATER_IMAGE_TAG:-latest}"
REGISTRY="${REGISTRY:-git.shx.one}"
BACKEND_IMAGE="${REGISTRY}/denozord/mikrotikmanager-backend:${MM_BACKEND_IMAGE_TAG}"
FRONTEND_IMAGE="${REGISTRY}/denozord/mikrotikmanager-frontend:${MM_FRONTEND_IMAGE_TAG}"
UPDATER_IMAGE="${REGISTRY}/denozord/mikrotikmanager-updater:${MM_UPDATER_IMAGE_TAG}"
echo "==> Check CDNManager Traefik network"
if ! docker network inspect cdnmanager >/dev/null 2>&1; then
echo "ERROR: Docker network 'cdnmanager' not found." >&2
echo "Start CDNManager Traefik stack first (deploy/docker-compose.traefik.yml)." >&2
exit 1
fi
if ! docker inspect cdnmanager-traefik >/dev/null 2>&1; then
echo "WARN: container 'cdnmanager-traefik' not found — labels may not be routed." >&2
fi
echo "==> Prepare dirs under ${MM_ROOT}"
mkdir -p "${MM_ROOT}/data" "${MM_ROOT}/state" "${MM_ROOT}/updater"
TARGETS="${MM_ROOT}/updater/targets.json"
if [[ ! -f "${TARGETS}" ]]; then
cat >"${TARGETS}" <<'EOF'
{
"targets": [
{
"id": "backend",
"container_name": "mmapp-backend",
"image": "git.shx.one/denozord/mikrotikmanager-backend:latest",
"health": {
"type": "http",
"url": "http://backend:8000/health",
"expect_status": 200
}
},
{
"id": "frontend",
"container_name": "mmapp-frontend",
"image": "git.shx.one/denozord/mikrotikmanager-frontend:latest",
"health": {
"type": "http",
"url": "http://frontend:3000/dashboard",
"expect_status": 200
}
}
]
}
EOF
echo " wrote ${TARGETS}"
fi
echo "==> Ensure internal network mmapp"
docker network inspect mmapp >/dev/null 2>&1 || docker network create mmapp >/dev/null
if [[ -n "${REGISTRY_USERNAME:-}" && -n "${REGISTRY_PASSWORD:-}" ]]; then
echo "==> docker login ${REGISTRY}"
echo "${REGISTRY_PASSWORD}" | docker login "${REGISTRY}" -u "${REGISTRY_USERNAME}" --password-stdin
fi
echo "==> Pull images"
docker pull "${BACKEND_IMAGE}"
docker pull "${FRONTEND_IMAGE}"
docker pull "${UPDATER_IMAGE}"
stop_rm() {
local name="$1"
if docker inspect "${name}" >/dev/null 2>&1; then
docker stop "${name}" >/dev/null || true
docker rm "${name}" >/dev/null || true
fi
}
echo "==> Recreate mmapp-backend"
stop_rm mmapp-backend
docker run -d \
--name mmapp-backend \
--restart unless-stopped \
--network mmapp \
--network-alias backend \
-e NODE_ENV=production \
-e PORT=8000 \
-e DATABASE_PATH=/app/data/mikrotik.db \
-e "CORS_ORIGIN=${CORS_ORIGIN}" \
-e "AUTH_REQUIRED=${AUTH_REQUIRED:-true}" \
-e "AUTH_JWT_SECRET=${AUTH_JWT_SECRET:?set AUTH_JWT_SECRET}" \
-e "AUTH_ISSUER=${AUTH_ISSUER:-https://auth.shnt.top}" \
-e "AUTH_PORTAL_URL=${AUTH_PORTAL_URL:-https://auth.shnt.top}" \
-v "${MM_ROOT}/data:/app/data" \
--label mmapp.updater.managed=true \
--label mmapp.updater.target=backend \
--label "mmapp.updater.image=${BACKEND_IMAGE}" \
--health-cmd="node -e \"fetch('http://127.0.0.1:8000/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"" \
--health-interval=30s \
--health-timeout=5s \
--health-retries=3 \
--health-start-period=15s \
--log-driver json-file \
--log-opt max-size=10m \
--log-opt max-file=3 \
"${BACKEND_IMAGE}"
echo "==> Recreate mmapp-frontend (mmapp + cdnmanager)"
stop_rm mmapp-frontend
docker run -d \
--name mmapp-frontend \
--restart unless-stopped \
--network mmapp \
--network-alias frontend \
-e BACKEND_INTERNAL_URL=http://backend:8000 \
--label mmapp.updater.managed=true \
--label mmapp.updater.target=frontend \
--label "mmapp.updater.image=${FRONTEND_IMAGE}" \
--label traefik.enable=true \
--label traefik.docker.network=cdnmanager \
--label "traefik.http.routers.mmapp.rule=Host(\`${MM_DOMAIN}\`)" \
--label traefik.http.routers.mmapp.entrypoints=websecure \
--label traefik.http.routers.mmapp.tls=true \
--label traefik.http.routers.mmapp.tls.certresolver=letsencrypt \
--label traefik.http.services.mmapp.loadbalancer.server.port=3000 \
--health-cmd="node -e \"fetch('http://127.0.0.1:3000/dashboard').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"" \
--health-interval=30s \
--health-timeout=5s \
--health-retries=3 \
--health-start-period=25s \
--log-driver json-file \
--log-opt max-size=10m \
--log-opt max-file=3 \
"${FRONTEND_IMAGE}"
docker network connect cdnmanager mmapp-frontend
echo "==> Recreate mmapp-updater"
stop_rm mmapp-updater
docker run -d \
--name mmapp-updater \
--restart unless-stopped \
--network mmapp \
-e "REGISTRY=${REGISTRY}" \
-e "REGISTRY_USERNAME=${REGISTRY_USERNAME:-}" \
-e "REGISTRY_PASSWORD=${REGISTRY_PASSWORD:-}" \
-e "POLL_INTERVAL_SECONDS=${POLL_INTERVAL_SECONDS:-300}" \
-e "HEALTH_TIMEOUT_SECONDS=${HEALTH_TIMEOUT_SECONDS:-120}" \
-e "STOP_TIMEOUT_SECONDS=${STOP_TIMEOUT_SECONDS:-30}" \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "${MM_ROOT}/state:/state" \
-v "${TARGETS}:/etc/updater/targets.json:ro" \
--log-driver json-file \
--log-opt max-size=10m \
--log-opt max-file=3 \
"${UPDATER_IMAGE}"
echo
echo "OK. UI: https://${MM_DOMAIN}"
echo "DNS: A/AAAA for ${MM_DOMAIN} → this VPS, Cloudflare proxy OFF (DNS only)."
echo "Check: curl -fsS https://${MM_DOMAIN}/health"
echo " docker ps --filter name=mmapp-"
echo " docker network inspect cdnmanager --format '{{range .Containers}}{{.Name}} {{end}}'"
+60
View File
@@ -0,0 +1,60 @@
# Интеграция auth-portal ↔ MikrotikManager
App id: **`mm`**. Зеркало на стороне портала: [`auth-portal/docs/integrate-mikrotikmanager.md`](https://git.shx.one/denozord/auth-portal/src/branch/main/docs/integrate-mikrotikmanager.md).
## Flow
```
Browser → MikrotikManager UI (нет token)
→ redirect AUTH_PORTAL_URL/?return_to=…/auth/callback
→ login
→ redirect return_to#access_token=…
→ /auth/callback сохраняет token (localStorage: mmapp_token)
→ API Authorization: Bearer <JWT>
```
## Permissions
| Permission | UI / API |
|------------|----------|
| `mm:dashboard:read` | `/dashboard`, sidebar-counts |
| `mm:servers:read` / `write` | `/servers` |
| `mm:filters:read` / `write` | filters / GRE |
| `mm:bgp:read` / `write` | BGP |
| `mm:uptime:read` / `write` | `/uptime` |
| `mm:traffic:read` / `write` | `/traffic` |
| `mm:alerts:read` / `write` | `/alerts` |
| `mm:backups:read` / `write` | `/backups` |
| `mm:certificates:read` / `write` | certificates |
| `mm:network:read` / `write` | network / OSPF |
| `mm:settings:admin` | `/settings`, system DB, scheduler |
## Env
```env
# backend
AUTH_REQUIRED=true
AUTH_JWT_SECRET=<тот же JWT_SECRET портала>
AUTH_ISSUER=https://auth.shnt.top
AUTH_PORTAL_URL=https://auth.shnt.top
CORS_ORIGIN=https://mm.shnt.top
```
```env
# frontend build (Docker)
NEXT_PUBLIC_AUTH_PORTAL_URL=https://auth.shnt.top
```
UI читает `GET /api/auth/config` (через Next rewrite) для `required` / `portal_url`.
В portal Admin → Apps выдайте app `mm` и нужные `mm:*`. URL в App Switcher: `https://mm.shnt.top`.
## Checklist
1. Общий `JWT_SECRET` / `AUTH_JWT_SECRET` и одинаковый `AUTH_ISSUER`
2. Origin MM в `RETURN_TO_ALLOWLIST` портала
3. Пользователю выдан app `mm`
4. `AUTH_REQUIRED=true` на backend
5. Logout → `{AUTH_PORTAL_URL}/logout`
Prod: [`deploy/docker-compose.cdn-mm.yml`](../deploy/docker-compose.cdn-mm.yml) или standalone Traefik compose.
+46 -19
View File
@@ -8,6 +8,21 @@ import {
parseAppSwitcherConfig,
type AppSwitcherConfig,
} from "@/lib/app-switcher-config"
import {
ensureAuthConfig,
getAuthConfigSync,
getClaims,
} from "@/lib/auth"
function filterByJwtApps(config: AppSwitcherConfig): AppSwitcherConfig {
const claims = getClaims()
if (!claims?.apps?.length) return config
const allowed = new Set(claims.apps)
const apps = config.apps.filter(
(a) => a.id === "mm" || allowed.has(a.id),
)
return { ...config, apps: apps.length > 0 ? apps : config.apps }
}
export function useAppSwitcherConfig(): {
config: AppSwitcherConfig
@@ -16,31 +31,43 @@ export function useAppSwitcherConfig(): {
const [config, setConfig] = useState<AppSwitcherConfig>(() =>
mergeWithLocalApp(DEFAULT_APP_SWITCHER_CONFIG),
)
const [isLoading, setIsLoading] = useState(Boolean(authPortalBaseUrl()))
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
const base = authPortalBaseUrl()
if (!base) {
setConfig(mergeWithLocalApp(DEFAULT_APP_SWITCHER_CONFIG))
setIsLoading(false)
return
}
let cancelled = false
setIsLoading(true)
fetch(`${base}/api/v1/app-switcher`)
.then((res) => (res.ok ? res.json() : Promise.reject()))
.then((raw: unknown) => {
void (async () => {
await ensureAuthConfig()
if (cancelled) return
const portal =
getAuthConfigSync()?.portalUrl || authPortalBaseUrl() || null
if (!portal) {
setConfig(filterByJwtApps(mergeWithLocalApp(DEFAULT_APP_SWITCHER_CONFIG)))
setIsLoading(false)
return
}
setIsLoading(true)
try {
const res = await fetch(`${portal}/api/v1/app-switcher`)
if (!res.ok) throw new Error("switcher fetch failed")
const raw: unknown = await res.json()
if (cancelled) return
const parsed = parseAppSwitcherConfig(raw)
setConfig(mergeWithLocalApp(parsed ?? DEFAULT_APP_SWITCHER_CONFIG))
})
.catch(() => {
if (!cancelled) setConfig(mergeWithLocalApp(DEFAULT_APP_SWITCHER_CONFIG))
})
.finally(() => {
setConfig(
filterByJwtApps(mergeWithLocalApp(parsed ?? DEFAULT_APP_SWITCHER_CONFIG)),
)
} catch {
if (!cancelled) {
setConfig(
filterByJwtApps(mergeWithLocalApp(DEFAULT_APP_SWITCHER_CONFIG)),
)
}
} finally {
if (!cancelled) setIsLoading(false)
})
}
})()
return () => {
cancelled = true
+264
View File
@@ -0,0 +1,264 @@
/** Portal JWT storage + claims helpers for MikrotikManager. */
const TOKEN_KEY = "mmapp_token"
const HANDOFF_KEY = "mmapp_auth_401_handoff"
const HANDOFF_AT_KEY = "mmapp_portal_handoff_at"
const HANDOFF_COOLDOWN_MS = 12_000
export type AccessClaims = {
sub: string
email: string
name: string
apps: string[]
permissions: string[]
is_admin?: boolean
iss?: string
exp?: number
}
export type RuntimeAuthConfig = {
required: boolean
portalUrl: string
}
let runtimeConfig: RuntimeAuthConfig | null = null
let runtimeConfigPromise: Promise<RuntimeAuthConfig> | null = null
function envPortalUrl(): string {
return (
process.env.NEXT_PUBLIC_AUTH_PORTAL_URL?.trim() || "http://localhost:5175"
).replace(/\/$/, "")
}
function envAuthEnabled(): boolean {
const v = process.env.NEXT_PUBLIC_AUTH_ENABLED?.trim().toLowerCase()
return v === "true" || v === "1"
}
/** Load auth mode from API (Docker-friendly). Falls back to NEXT_PUBLIC_*. */
export async function ensureAuthConfig(): Promise<RuntimeAuthConfig> {
if (runtimeConfig) return runtimeConfig
if (runtimeConfigPromise) return runtimeConfigPromise
runtimeConfigPromise = (async () => {
try {
const res = await fetch("/api/auth/config")
if (res.ok) {
const data = (await res.json()) as {
required?: boolean
portal_url?: string
}
runtimeConfig = {
required: Boolean(data.required) || envAuthEnabled(),
portalUrl: (data.portal_url || envPortalUrl()).replace(/\/$/, ""),
}
return runtimeConfig
}
} catch {
/* use env defaults */
}
runtimeConfig = {
required: envAuthEnabled(),
portalUrl: envPortalUrl(),
}
return runtimeConfig
})().finally(() => {
runtimeConfigPromise = null
})
return runtimeConfigPromise
}
export function getAuthConfigSync(): RuntimeAuthConfig | null {
return runtimeConfig
}
export function getToken(): string | null {
if (typeof window === "undefined") return 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 {
if (runtimeConfig) return runtimeConfig.required
return envAuthEnabled()
}
export function authPortalUrl(): string {
if (runtimeConfig?.portalUrl) return runtimeConfig.portalUrl
return envPortalUrl()
}
export function isPortalHandoffCoolingDown(): boolean {
if (typeof window === "undefined") return false
const raw = sessionStorage.getItem(HANDOFF_AT_KEY)
if (!raw) return false
const at = Number(raw)
if (!Number.isFinite(at)) return false
return Date.now() - at < HANDOFF_COOLDOWN_MS
}
export function markPortalHandoff(): void {
sessionStorage.setItem(HANDOFF_KEY, "1")
sessionStorage.setItem(HANDOFF_AT_KEY, String(Date.now()))
}
export function clearPortalHandoffFlag(): void {
sessionStorage.removeItem(HANDOFF_KEY)
}
export function resetPortalHandoff(): void {
sessionStorage.removeItem(HANDOFF_KEY)
sessionStorage.removeItem(HANDOFF_AT_KEY)
}
export function redirectToPortalLogin(returnTo?: string): boolean {
if (isPortalHandoffCoolingDown()) {
clearToken()
return false
}
markPortalHandoff()
const callback = returnTo ?? `${window.location.origin}/auth/callback`
const url = new URL(authPortalUrl())
url.searchParams.set("return_to", callback)
window.location.assign(url.toString())
return true
}
export function redirectToPortalLoginInteractive(): void {
clearToken()
resetPortalHandoff()
window.location.assign(authPortalUrl())
}
export function redirectToPortalLogout(): void {
clearToken()
resetPortalHandoff()
window.location.assign(`${authPortalUrl()}/logout`)
}
export function parseHashToken(hash: string): {
accessToken: string | null
expiresAt: string | null
} {
const raw = hash.startsWith("#") ? hash.slice(1) : hash
const params = new URLSearchParams(raw)
return {
accessToken: params.get("access_token"),
expiresAt: params.get("expires_at"),
}
}
export function decodeClaims(token: string): AccessClaims | null {
try {
const parts = token.split(".")
if (parts.length < 2) return null
const json = atob(parts[1]!.replace(/-/g, "+").replace(/_/g, "/"))
const payload = JSON.parse(json) as Record<string, unknown>
return {
sub: String(payload.sub ?? ""),
email: String(payload.email ?? ""),
name: String(payload.name ?? ""),
apps: Array.isArray(payload.apps) ? payload.apps.map(String) : [],
permissions: Array.isArray(payload.permissions)
? payload.permissions.map(String)
: [],
is_admin: Boolean(payload.is_admin),
iss: payload.iss ? String(payload.iss) : undefined,
exp: typeof payload.exp === "number" ? payload.exp : undefined,
}
} catch {
return null
}
}
export function getClaims(): AccessClaims | null {
const token = getToken()
if (!token) return null
const claims = decodeClaims(token)
if (!claims) return null
if (claims.exp && claims.exp * 1000 < Date.now()) {
clearToken()
resetPortalHandoff()
return null
}
return claims
}
export function hasPermission(
granted: readonly string[],
required: string,
): boolean {
if (granted.includes(required)) return true
const parts = required.split(":")
if (parts.length !== 3) return false
const [app, section, action] = parts
if (action === "read") {
return (
granted.includes(`${app}:${section}:write`) ||
granted.includes(`${app}:${section}:admin`)
)
}
if (action === "write") {
return granted.includes(`${app}:${section}:admin`)
}
return false
}
export function can(required: string): boolean {
if (!isAuthEnabled()) return true
const claims = getClaims()
if (!claims) return false
if (!claims.apps.includes("mm")) return false
return hasPermission(claims.permissions, required)
}
export function permissionForPath(pathname: string): string | null {
if (pathname === "/" || pathname.startsWith("/dashboard")) {
return "mm:dashboard:read"
}
if (pathname.startsWith("/servers")) return "mm:servers:read"
if (pathname.startsWith("/filters") || pathname.startsWith("/gre")) {
return "mm:filters:read"
}
if (pathname.startsWith("/bgp")) return "mm:bgp:read"
if (pathname.startsWith("/uptime")) return "mm:uptime:read"
if (pathname.startsWith("/traffic")) return "mm:traffic:read"
if (pathname.startsWith("/alerts")) return "mm:alerts:read"
if (pathname.startsWith("/backups")) return "mm:backups:read"
if (pathname.startsWith("/certificates")) return "mm:certificates:read"
if (
pathname.startsWith("/network") ||
pathname.startsWith("/ospf") ||
pathname.startsWith("/route-optimizer")
) {
return "mm:network:read"
}
if (pathname.startsWith("/settings")) return "mm:settings:admin"
return "mm:dashboard:read"
}
export function firstAllowedPath(): string {
const candidates = [
"/dashboard",
"/servers",
"/filters",
"/uptime",
"/traffic",
"/alerts",
"/backups",
"/settings",
]
for (const path of candidates) {
const perm = permissionForPath(path)
if (!perm || can(perm)) return path
}
return "/access-denied"
}
+15 -1
View File
@@ -26,12 +26,26 @@ export function isBackendUrlLocked(): boolean {
return cfg.kind === "same-origin" || cfg.kind === "fixed"
}
function isLoopbackHost(hostname: string): boolean {
return hostname === "localhost" || hostname === "127.0.0.1"
}
/** Prefer same-origin when the UI is not on loopback — never point the browser at localhost. */
export function resolveStoredBackendUrl(stored: string | null): string {
const cfg = configuredBackendUrl()
if (cfg.kind === "fixed") return cfg.url
if (cfg.kind === "same-origin" && typeof window !== "undefined") {
if (cfg.kind === "same-origin") {
if (typeof window !== "undefined") return window.location.origin
return ""
}
if (typeof window !== "undefined" && !isLoopbackHost(window.location.hostname)) {
return window.location.origin
}
const trimmed = stored?.trim().replace(/\/$/, "")
if (trimmed && /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(trimmed)) {
if (typeof window !== "undefined" && !isLoopbackHost(window.location.hostname)) {
return window.location.origin
}
}
return trimmed || LOCAL_DEFAULT_BACKEND_URL
}
+11 -11
View File
@@ -9,6 +9,7 @@ import {
LOCAL_DEFAULT_BACKEND_URL,
resolveStoredBackendUrl,
} from "@/lib/backend-url"
import { resolveApiUrl } from "@/shared/api/http-client"
// ── types ─────────────────────────────────────────────────────────────────────
@@ -49,8 +50,13 @@ function readStoredMode(): DataSourceMode {
return defaultDataSourceMode()
}
function readStoredBackendUrl(): string {
if (typeof window === "undefined") return LOCAL_DEFAULT_BACKEND_URL
function initialBackendUrl(): string {
if (typeof window === "undefined") {
const cfg = configuredBackendUrl()
if (cfg.kind === "same-origin") return ""
if (cfg.kind === "fixed") return cfg.url
return LOCAL_DEFAULT_BACKEND_URL
}
return resolveStoredBackendUrl(localStorage.getItem(LS_BACKEND))
}
@@ -60,7 +66,7 @@ function normalizeBackendUrl(url: string): string {
export function DataSourceProvider({ children }: { children: React.ReactNode }) {
const [mode, setModeState] = useState<DataSourceMode>(defaultDataSourceMode)
const [backendUrl, setBackendUrlState] = useState(LOCAL_DEFAULT_BACKEND_URL)
const [backendUrl, setBackendUrlState] = useState(initialBackendUrl)
const [prefsHydrated, setPrefsHydrated] = useState(false)
const [backendStatus, setBackendStatus] = useState<boolean | undefined>(undefined)
const backendUrlLocked = isBackendUrlLocked()
@@ -68,10 +74,7 @@ export function DataSourceProvider({ children }: { children: React.ReactNode })
useEffect(() => {
const storedMode = readStoredMode()
let url = readStoredBackendUrl()
if (configuredBackendUrl().kind === "same-origin") {
url = window.location.origin
}
const url = resolveStoredBackendUrl(localStorage.getItem(LS_BACKEND))
setModeState(storedMode)
setBackendUrlState(url)
setPrefsHydrated(true)
@@ -91,10 +94,7 @@ export function DataSourceProvider({ children }: { children: React.ReactNode })
}, [backendUrlLocked])
const checkBackend = useCallback(async () => {
const healthUrl =
configuredBackendUrl().kind === "same-origin"
? "/health"
: `${normalizeBackendUrl(backendUrl)}/health`
const healthUrl = resolveApiUrl(backendUrl, "/health")
try {
const res = await fetch(healthUrl, { signal: AbortSignal.timeout(3000) })
setBackendStatus(res.ok)
+12
View File
@@ -14,21 +14,33 @@ export interface WanUplink {
// ─── WireGuard ───────────────────────────────────────────────────────────────
export interface WireGuardPeer {
id?: string
rosId?: string
publicKey: string
allowedIps: string[]
endpoint?: string // "1.2.3.4:13231"
latestHandshake?: string // "2 минуты назад"
transferRx?: number // bytes
transferTx?: number // bytes
persistentKeepalive?: number
persistent?: boolean
comment?: string
disabled?: boolean
name?: string
clientAddress?: string
clientDns?: string
clientEndpoint?: string
}
export interface WireGuardInterface {
id: string
rosId?: string
name: string // e.g. "wg-msk-spb"
listenPort: number // default 13231
mtu: number // 1420 default in ROS 7.x
publicKey?: string
privateKey?: string
address?: string
peers: WireGuardPeer[]
comment: string
enabled: boolean
+56 -57
View File
@@ -10,6 +10,7 @@ import {
} from "react"
import { useDataSource } from "@/lib/data-source"
import type { Domain, IpRange, Asn } from "@/lib/data"
import { ApiClientError, requestJson } from "@/shared/api/http-client"
export interface EvoBgpCommunityRow {
id: string
@@ -66,6 +67,12 @@ interface EvoBgpContextValue {
const EvoBgpContext = createContext<EvoBgpContextValue | null>(null)
function errorMessage(e: unknown, fallback: string): string {
if (e instanceof ApiClientError) return e.message || fallback
if (e instanceof Error) return e.message || fallback
return fallback
}
export function EvoBGPProvider({ children }: { children: React.ReactNode }) {
const { mode, backendUrl, backendStatus } = useDataSource()
const [baseUrl, setBaseUrlState] = useState("")
@@ -86,24 +93,15 @@ export function EvoBGPProvider({ children }: { children: React.ReactNode }) {
setLoading(true)
setError(null)
try {
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/evobgp/catalog`, {
method: "POST",
})
const text = await res.text()
if (!res.ok) {
let msg = res.statusText
try {
const j = JSON.parse(text) as { error?: string; detail?: string }
msg = j.error ?? j.detail ?? msg
} catch {
if (text) msg = text
}
throw new Error(msg || "Ошибка EvoBGP")
}
setSnapshot(JSON.parse(text) as EvoBgpCatalogSnapshot)
const data = await requestJson<EvoBgpCatalogSnapshot>(
backendUrl,
"/api/evobgp/catalog",
{ method: "POST" },
)
setSnapshot(data)
} catch (e) {
setSnapshot(null)
setError(e instanceof Error ? e.message : "Ошибка загрузки")
setError(errorMessage(e, "Ошибка загрузки"))
} finally {
setLoading(false)
}
@@ -119,16 +117,19 @@ export function EvoBGPProvider({ children }: { children: React.ReactNode }) {
return
}
try {
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/evobgp/settings`)
if (!res.ok) throw new Error(await res.text())
const data = (await res.json()) as EvoBgpSettingsDto
const data = await requestJson<EvoBgpSettingsDto>(
backendUrl,
"/api/evobgp/settings",
)
setBaseUrlState(data.baseUrl ?? "")
setEnabledState(data.enabled ?? false)
setSecretConfigured(data.secretConfigured ?? false)
setEnabledState(Boolean(data.enabled))
setSecretConfigured(Boolean(data.secretConfigured))
setSettingsLoaded(true)
await pullCatalog(data.enabled ?? false)
} catch {
setError(null)
await pullCatalog(Boolean(data.enabled))
} catch (e) {
setSettingsLoaded(true)
setError(errorMessage(e, "Не удалось загрузить настройки EvoBGP"))
}
}, [mode, backendStatus, backendUrl, pullCatalog])
@@ -140,27 +141,25 @@ export function EvoBGPProvider({ children }: { children: React.ReactNode }) {
const saveSettings = useCallback(
async (patch: EvoBgpSavePayload) => {
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/evobgp/settings`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
})
const text = await res.text()
if (!res.ok) {
let msg = res.statusText
try {
const j = JSON.parse(text) as { error?: string }
msg = j.error ?? msg
} catch {
if (text) msg = text
}
throw new Error(msg || "Не удалось сохранить")
}
const data = JSON.parse(text) as EvoBgpSettingsDto
const data = await requestJson<EvoBgpSettingsDto>(
backendUrl,
"/api/evobgp/settings",
{
method: "PUT",
body: JSON.stringify(patch),
},
)
const nextEnabled = Boolean(data.enabled)
setBaseUrlState(data.baseUrl ?? "")
setEnabledState(data.enabled ?? false)
setSecretConfigured(data.secretConfigured ?? false)
await pullCatalog(data.enabled ?? false)
setEnabledState(nextEnabled)
setSecretConfigured(Boolean(data.secretConfigured))
setError(null)
// Каталог не должен ронять успех сохранения (401/502 на catalog ≠ «настройки не сохранились»)
try {
await pullCatalog(nextEnabled)
} catch {
/* pullCatalog already sets error state */
}
},
[backendUrl, pullCatalog],
)
@@ -169,20 +168,20 @@ export function EvoBGPProvider({ children }: { children: React.ReactNode }) {
await pullCatalog(enabled)
}, [enabled, pullCatalog])
const testConnection = useCallback(async (draft?: EvoBgpTestDraft) => {
try {
const res = await fetch(`${backendUrl.replace(/\/$/, "")}/api/evobgp/test`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(draft ?? {}),
})
const data = (await res.json().catch(() => ({}))) as { ok?: boolean; error?: string }
if (!res.ok) throw new Error(data.error ?? res.statusText)
return { ok: true, message: "Соединение с EvoBGP установлено" }
} catch (e) {
return { ok: false, message: e instanceof Error ? e.message : "Ошибка" }
}
}, [backendUrl])
const testConnection = useCallback(
async (draft?: EvoBgpTestDraft) => {
try {
await requestJson<{ ok?: boolean }>(backendUrl, "/api/evobgp/test", {
method: "POST",
body: JSON.stringify(draft ?? {}),
})
return { ok: true, message: "Соединение с EvoBGP установлено" }
} catch (e) {
return { ok: false, message: errorMessage(e, "Ошибка") }
}
},
[backendUrl],
)
const value = useMemo(
() => ({
+2
View File
@@ -52,4 +52,6 @@ export interface SidebarCountsDto {
uptimeSpeedProbes: number
monitoringItems: number
recursiveRoutes: number
certificates?: number
wireguard?: number
}
+339
View File
@@ -0,0 +1,339 @@
/**
* Client-side WireGuard config codecs (mirror of backend wireguard-config).
* Used for mock preview / offline export without hitting the API.
*/
export type WgParsedPeer = {
publicKey: string
allowedAddresses: string[]
endpointAddress?: string
endpointPort?: number
persistentKeepalive?: number
comment?: string
name?: string
privateKey?: string
clientAddress?: string
clientDns?: string
clientEndpoint?: string
disabled?: boolean
}
export type WgParsedInterface = {
name: string
listenPort?: number
mtu?: number
privateKey?: string
comment?: string
address?: string
disabled?: boolean
}
export type WgParsedConfig = {
format: "rsc" | "conf"
interface: WgParsedInterface
peers: WgParsedPeer[]
}
export type WgExportIface = {
name: string
listenPort: number
mtu: number
comment?: string
enabled?: boolean
privateKey?: string
publicKey?: string
address?: string
serverName?: string
peers: Array<{
publicKey: string
allowedIps: string[]
endpoint?: string
persistentKeepalive?: number
persistent?: boolean
comment?: string
name?: string
clientAddress?: string
clientDns?: string
clientEndpoint?: string
}>
}
function stripQuotes(v: string): string {
const t = v.trim()
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
return t.slice(1, -1)
}
return t
}
function parseKvLine(line: string): Record<string, string> {
const out: Record<string, string> = {}
const re = /([a-zA-Z0-9_-]+)=("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^\s\\]+)/g
let m: RegExpExecArray | null
while ((m = re.exec(line)) !== null) {
out[m[1]] = stripQuotes(m[2])
}
return out
}
function joinContinuedLines(text: string): string[] {
const raw = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n")
const lines: string[] = []
let buf = ""
for (const line of raw) {
const trimmedEnd = line.replace(/\s+$/, "")
if (trimmedEnd.endsWith("\\")) {
buf += trimmedEnd.slice(0, -1).trimEnd() + " "
continue
}
buf += trimmedEnd
if (buf.trim()) lines.push(buf.trim())
buf = ""
}
if (buf.trim()) lines.push(buf.trim())
return lines
}
export function detectWgConfigFormat(content: string): "rsc" | "conf" {
const t = content.trim()
if (/\[Interface\]/i.test(t) || /\[Peer\]/i.test(t)) return "conf"
if (/\/interface\s+wireguard/i.test(t) || /\/interface\/wireguard/i.test(t)) return "rsc"
if (/PrivateKey\s*=/i.test(t) || /PublicKey\s*=/i.test(t)) return "conf"
return "rsc"
}
export function parseNativeConf(content: string): WgParsedConfig {
const lines = content.replace(/\r\n/g, "\n").split("\n")
let section: "interface" | "peer" | null = null
const iface: WgParsedInterface = { name: "wg0" }
const peers: WgParsedPeer[] = []
let currentPeer: WgParsedPeer | null = null
const flushPeer = () => {
if (currentPeer?.publicKey) peers.push(currentPeer)
currentPeer = null
}
for (const raw of lines) {
const line = raw.trim()
if (!line || line.startsWith("#") || line.startsWith(";")) continue
if (/^\[Interface\]$/i.test(line)) {
flushPeer()
section = "interface"
continue
}
if (/^\[Peer\]$/i.test(line)) {
flushPeer()
section = "peer"
currentPeer = { publicKey: "", allowedAddresses: [] }
continue
}
const eq = line.indexOf("=")
if (eq < 0) continue
const key = line.slice(0, eq).trim().toLowerCase()
const value = line.slice(eq + 1).trim()
if (section === "interface") {
if (key === "privatekey") iface.privateKey = value
else if (key === "address") iface.address = value.split(",")[0]?.trim()
else if (key === "listenport") iface.listenPort = Number.parseInt(value, 10) || undefined
else if (key === "mtu") iface.mtu = Number.parseInt(value, 10) || undefined
else if (key === "name") iface.name = value || iface.name
} else if (section === "peer" && currentPeer) {
if (key === "publickey") currentPeer.publicKey = value
else if (key === "allowedips") {
currentPeer.allowedAddresses = value
.split(",")
.map((s) => s.trim())
.filter(Boolean)
} else if (key === "endpoint") {
const lastColon = value.lastIndexOf(":")
if (lastColon > 0 && !value.includes("]:")) {
currentPeer.endpointAddress = value.slice(0, lastColon)
currentPeer.endpointPort = Number.parseInt(value.slice(lastColon + 1), 10) || undefined
} else {
currentPeer.endpointAddress = value
}
} else if (key === "persistentkeepalive") {
currentPeer.persistentKeepalive = Number.parseInt(value, 10) || undefined
}
}
}
flushPeer()
return { format: "conf", interface: iface, peers }
}
export function parseMikrotikRsc(content: string): WgParsedConfig {
const lines = joinContinuedLines(content)
const iface: WgParsedInterface = { name: "wg0" }
const peers: WgParsedPeer[] = []
let foundIface = false
for (const line of lines) {
if (line.startsWith("#")) continue
const lower = line.toLowerCase()
if (
lower.startsWith("/interface wireguard add") ||
lower.startsWith("/interface/wireguard add")
) {
const kv = parseKvLine(line)
if (kv.name) iface.name = kv.name
if (kv["listen-port"]) iface.listenPort = Number.parseInt(kv["listen-port"], 10) || undefined
if (kv.mtu) iface.mtu = Number.parseInt(kv.mtu, 10) || undefined
if (kv["private-key"]) iface.privateKey = kv["private-key"]
if (kv.comment) iface.comment = kv.comment
if (kv.disabled === "yes") iface.disabled = true
foundIface = true
continue
}
if (
lower.startsWith("/interface wireguard peers add") ||
lower.startsWith("/interface/wireguard/peers add")
) {
const kv = parseKvLine(line)
const allowed = (kv["allowed-address"] ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean)
peers.push({
publicKey: kv["public-key"] ?? "",
allowedAddresses: allowed.length ? allowed : ["0.0.0.0/0"],
endpointAddress: kv["endpoint-address"],
endpointPort: kv["endpoint-port"]
? Number.parseInt(kv["endpoint-port"], 10) || undefined
: undefined,
persistentKeepalive: kv["persistent-keepalive"]
? Number.parseInt(kv["persistent-keepalive"], 10) || undefined
: undefined,
comment: kv.comment,
name: kv.name,
clientAddress: kv["client-address"],
clientDns: kv["client-dns"],
clientEndpoint: kv["client-endpoint"],
disabled: kv.disabled === "yes",
})
continue
}
if (lower.startsWith("/ip address add") || lower.startsWith("/ip/address add")) {
const kv = parseKvLine(line)
if (kv.address) iface.address = kv.address
}
}
if (!foundIface && peers.length === 0) {
throw new Error("Не удалось распознать RouterOS WireGuard .rsc")
}
return { format: "rsc", interface: iface, peers }
}
export function parseWgConfig(
content: string,
format: "auto" | "rsc" | "conf" = "auto",
): WgParsedConfig {
const detected = format === "auto" ? detectWgConfigFormat(content) : format
if (detected === "conf") return parseNativeConf(content)
return parseMikrotikRsc(content)
}
export function generateNativeConf(iface: WgExportIface): string {
const lines: string[] = []
lines.push(`[Interface]`)
if (iface.privateKey) lines.push(`PrivateKey = ${iface.privateKey}`)
else lines.push(`# PrivateKey = <заполните приватный ключ с роутера>`)
if (iface.address) lines.push(`Address = ${iface.address}`)
lines.push(`ListenPort = ${iface.listenPort}`)
if (iface.mtu) lines.push(`MTU = ${iface.mtu}`)
lines.push(``)
for (const p of iface.peers) {
lines.push(`[Peer]`)
lines.push(`PublicKey = ${p.publicKey}`)
lines.push(`AllowedIPs = ${p.allowedIps.join(", ")}`)
if (p.endpoint) lines.push(`Endpoint = ${p.endpoint}`)
const ka = p.persistentKeepalive ?? (p.persistent ? 25 : undefined)
if (ka != null && ka > 0) lines.push(`PersistentKeepalive = ${ka}`)
if (p.comment) lines.push(`# ${p.comment}`)
lines.push(``)
}
return lines.join("\n").trimEnd() + "\n"
}
export function generateMikrotikRsc(iface: WgExportIface): string {
const lines: string[] = []
lines.push(`# WireGuard — ${iface.name}${iface.serverName ? ` · ${iface.serverName}` : ""}`)
lines.push(`# RouterOS 7.x · MikrotikManager`)
lines.push(``)
lines.push(`/interface wireguard add \\`)
lines.push(` name=${iface.name} \\`)
lines.push(` listen-port=${iface.listenPort} \\`)
lines.push(` mtu=${iface.mtu} \\`)
if (iface.privateKey) lines.push(` private-key="${iface.privateKey}" \\`)
if (iface.comment) lines.push(` comment="${iface.comment.replace(/"/g, '\\"')}" \\`)
if (iface.enabled === false) lines.push(` disabled=yes \\`)
if (lines[lines.length - 1]?.endsWith(" \\")) {
lines[lines.length - 1] = lines[lines.length - 1]!.slice(0, -2)
}
lines.push(``)
if (iface.address) {
lines.push(`/ip address add \\`)
lines.push(` address=${iface.address} \\`)
lines.push(` interface=${iface.name}`)
lines.push(``)
}
for (const p of iface.peers) {
lines.push(`/interface wireguard peers add \\`)
lines.push(` interface=${iface.name} \\`)
lines.push(` public-key="${p.publicKey}" \\`)
lines.push(` allowed-address=${p.allowedIps.join(",")} \\`)
if (p.endpoint) {
const host = p.endpoint.includes(":") ? p.endpoint.slice(0, p.endpoint.lastIndexOf(":")) : p.endpoint
const port = p.endpoint.includes(":")
? p.endpoint.slice(p.endpoint.lastIndexOf(":") + 1)
: "13231"
lines.push(` endpoint-address=${host} \\`)
lines.push(` endpoint-port=${port} \\`)
}
const ka = p.persistentKeepalive ?? (p.persistent ? 25 : undefined)
if (ka != null && ka > 0) lines.push(` persistent-keepalive=${ka} \\`)
if (p.name) lines.push(` name=${p.name} \\`)
if (p.clientAddress) lines.push(` client-address=${p.clientAddress} \\`)
if (p.clientDns) lines.push(` client-dns=${p.clientDns} \\`)
if (p.clientEndpoint) lines.push(` client-endpoint=${p.clientEndpoint} \\`)
if (p.comment) lines.push(` comment="${p.comment.replace(/"/g, '\\"')}" \\`)
if (lines[lines.length - 1]?.endsWith(" \\")) {
lines[lines.length - 1] = lines[lines.length - 1]!.slice(0, -2)
}
lines.push(``)
}
return lines.join("\n")
}
export function generatePeerClientConf(args: {
peerAddress?: string
peerDns?: string
serverPublicKey: string
allowedIps?: string[]
endpoint?: string
persistentKeepalive?: number
}): string {
const lines: string[] = []
lines.push(`[Interface]`)
lines.push(`# PrivateKey = <ключ клиента>`)
if (args.peerAddress) lines.push(`Address = ${args.peerAddress}`)
if (args.peerDns) lines.push(`DNS = ${args.peerDns}`)
lines.push(``)
lines.push(`[Peer]`)
lines.push(`PublicKey = ${args.serverPublicKey}`)
lines.push(`AllowedIPs = ${(args.allowedIps?.length ? args.allowedIps : ["0.0.0.0/0"]).join(", ")}`)
if (args.endpoint) lines.push(`Endpoint = ${args.endpoint}`)
if (args.persistentKeepalive != null && args.persistentKeepalive > 0) {
lines.push(`PersistentKeepalive = ${args.persistentKeepalive}`)
}
lines.push(``)
return lines.join("\n")
}
+245 -19
View File
@@ -49,6 +49,7 @@
"version": "1.0.0",
"dependencies": {
"@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.2.2",
"@fastify/type-provider-zod": "^1.0.0",
"@mmapp/contracts": "1.0.0",
"acme-client": "^5.4.0",
@@ -56,7 +57,7 @@
"dotenv": "^16.4.7",
"drizzle-orm": "^0.45.2",
"fastify": "^5.8.5",
"pino-pretty": "^13.1.3",
"fastify-plugin": "^5.1.0",
"undici": "^8.1.0",
"zod": "^4.4.1"
},
@@ -64,6 +65,8 @@
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.15.3",
"drizzle-kit": "^0.31.10",
"jose": "^6.2.11",
"pino-pretty": "^13.1.3",
"tsx": "^4.19.3",
"typescript": "^5.8.3"
}
@@ -2018,6 +2021,45 @@
],
"license": "MIT"
},
"node_modules/@fastify/jwt": {
"version": "10.2.2",
"resolved": "https://registry.npmjs.org/@fastify/jwt/-/jwt-10.2.2.tgz",
"integrity": "sha512-UOYY5db2ttuWk2FcN5L6rawE0OFa4+QRJdsYEiHCBmf1GFLC9/k73f/mmv2dxIhS0b/02/62T0Hs6sk+w18Tyg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/error": "^4.2.0",
"@lukeed/ms": "^2.0.2",
"fast-jwt": "^6.2.4",
"fastify-plugin": "^6.0.0",
"steed": "^1.1.3"
}
},
"node_modules/@fastify/jwt/node_modules/fastify-plugin": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz",
"integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/@fastify/merge-json-schemas": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz",
@@ -2814,6 +2856,15 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lukeed/ms": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@lukeed/ms/-/ms-2.0.2.tgz",
"integrity": "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/@mmapp/contracts": {
"resolved": "packages/contracts",
"link": true
@@ -3900,6 +3951,70 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.8.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.8.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.2.4",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz",
@@ -5077,6 +5192,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/asn1js": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz",
@@ -5275,6 +5402,12 @@
"readable-stream": "^3.4.0"
}
},
"node_modules/bn.js": {
"version": "4.12.5",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
"integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
@@ -5672,6 +5805,7 @@
"version": "2.0.20",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
"dev": true,
"license": "MIT"
},
"node_modules/combined-stream": {
@@ -5898,6 +6032,7 @@
"version": "4.6.3",
"resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz",
"integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "*"
@@ -6281,6 +6416,15 @@
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/eciesjs": {
"version": "0.4.18",
"resolved": "https://registry.npmjs.org/eciesjs/-/eciesjs-0.4.18.tgz",
@@ -7159,6 +7303,7 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-4.0.3.tgz",
"integrity": "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-decode-uri-component": {
@@ -7256,6 +7401,22 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/fast-jwt": {
"version": "6.3.3",
"resolved": "https://registry.npmjs.org/fast-jwt/-/fast-jwt-6.3.3.tgz",
"integrity": "sha512-pQDXx7IHeZT4jSmpE9o80RrBqfrG4fPrl8anazSM5vErIdK1iCc13z/EWX+H0j7liWSRnwTpHswIKMeLYGAckw==",
"license": "Apache-2.0",
"dependencies": {
"@lukeed/ms": "^2.0.2",
"asn1.js": "^5.4.1",
"ecdsa-sig-formatter": "^1.0.11",
"mnemonist": "^0.40.0",
"safe-regex2": "^5.1.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/fast-levenshtein": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
@@ -7276,6 +7437,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
"integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==",
"dev": true,
"license": "MIT"
},
"node_modules/fast-string-truncated-width": {
@@ -7318,6 +7480,18 @@
"fast-string-width": "^3.0.2"
}
},
"node_modules/fastfall": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/fastfall/-/fastfall-1.5.1.tgz",
"integrity": "sha512-KH6p+Z8AKPXnmA7+Iz2Lh8ARCMr+8WNPVludm1LGkZoD2MjY6LVnRMtTKhkdzI+jr0RzQWXKzKyBJm1zoHEL4Q==",
"license": "MIT",
"dependencies": {
"reusify": "^1.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/fastify": {
"version": "5.8.5",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz",
@@ -7379,6 +7553,16 @@
"node": ">=10"
}
},
"node_modules/fastparallel": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/fastparallel/-/fastparallel-2.4.1.tgz",
"integrity": "sha512-qUmhxPgNHmvRjZKBFUNI0oZuuH9OlSIOXmJ98lhKPxMZZ7zS/Fi0wRHOihDSz0R1YiIOjxzOY4bq65YTcdBi2Q==",
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4",
"xtend": "^4.0.2"
}
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
@@ -7388,6 +7572,16 @@
"reusify": "^1.0.4"
}
},
"node_modules/fastseries": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/fastseries/-/fastseries-1.7.2.tgz",
"integrity": "sha512-dTPFrPGS8SNSzAt7u/CbMKCJ3s01N04s4JFbORHcmyvVfVKmbhMD1VtRbh5enGHxkaQDqWyLefiKOGGmohGDDQ==",
"license": "ISC",
"dependencies": {
"reusify": "^1.0.0",
"xtend": "^4.0.0"
}
},
"node_modules/fetch-blob": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
@@ -8040,6 +8234,7 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz",
"integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==",
"dev": true,
"license": "MIT"
},
"node_modules/hermes-estree": {
@@ -8839,9 +9034,9 @@
}
},
"node_modules/jose": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
"integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
"version": "6.2.11",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.11.tgz",
"integrity": "sha512-A5NPn7g8EAzGU3IzRs+Yiq8K5n3ypYS75M5+KKiVHdUexfpWK1kP4ZMq7QnTGDoMj6TJ1dtcEJjW60yZDXS4hg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
@@ -8851,6 +9046,7 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
"integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -9596,6 +9792,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -9624,6 +9826,15 @@
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/mnemonist": {
"version": "0.40.4",
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.4.tgz",
"integrity": "sha512-ZAv+KNavneRVzu4tUeOgzkScI3W5BGwZ3rkxIpKtzzVgfTtWQFN1CgX0U72cyvyh3iTuHL3SiSmrQxTlryEIcw==",
"license": "MIT",
"dependencies": {
"obliterator": "^2.0.4"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -10103,6 +10314,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/obliterator": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
"integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==",
"license": "MIT"
},
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
@@ -10426,6 +10643,7 @@
"version": "13.1.3",
"resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.1.3.tgz",
"integrity": "sha512-ttXRkkOz6WWC95KeY9+xxWL6AtImwbyMHrL1mSwqwW9u+vLp/WIElvHvCSDg0xO/Dzrggz1zv3rN5ovTRVowKg==",
"dev": true,
"license": "MIT",
"dependencies": {
"colorette": "^2.0.7",
@@ -10450,6 +10668,7 @@
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz",
"integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.16"
@@ -11792,6 +12011,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/steed": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/steed/-/steed-1.1.3.tgz",
"integrity": "sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==",
"license": "MIT",
"dependencies": {
"fastfall": "^1.5.0",
"fastparallel": "^2.2.0",
"fastq": "^1.3.0",
"fastseries": "^1.7.0",
"reusify": "^1.0.0"
}
},
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
@@ -13458,6 +13690,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
@@ -13634,21 +13875,6 @@
"dependencies": {
"zod": "^4.4.1"
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.2.4",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz",
"integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
}
}
}
+4
View File
@@ -33,6 +33,10 @@
"./backups": {
"types": "./dist/backups.d.ts",
"default": "./dist/backups.js"
},
"./wireguard": {
"types": "./dist/wireguard.d.ts",
"default": "./dist/wireguard.js"
}
},
"dependencies": {
+1
View File
@@ -3,3 +3,4 @@ export * from "./alerts.js"
export * from "./events.js"
export * from "./certificates.js"
export * from "./backups.js"
export * from "./wireguard.js"
+171
View File
@@ -0,0 +1,171 @@
import { z } from "zod"
export const wgStatusSchema = z.enum(["up", "down"])
export const wgPeerDtoSchema = z.object({
id: z.string().min(1),
rosId: z.string().min(1),
publicKey: z.string(),
allowedIps: z.array(z.string()),
endpoint: z.string().optional(),
latestHandshake: z.string().optional(),
transferRx: z.number().nonnegative().optional(),
transferTx: z.number().nonnegative().optional(),
persistentKeepalive: z.number().int().nonnegative().optional(),
persistent: z.boolean().optional(),
comment: z.string().optional(),
disabled: z.boolean().optional(),
name: z.string().optional(),
clientAddress: z.string().optional(),
clientDns: z.string().optional(),
clientEndpoint: z.string().optional(),
})
export const wgIfaceDtoSchema = z.object({
id: z.string().min(1),
rosId: z.string().min(1),
name: z.string().min(1),
serverId: z.string().min(1),
serverName: z.string(),
serverCountry: z.string().optional(),
listenPort: z.number().int().positive(),
mtu: z.number().int().positive(),
publicKey: z.string().optional(),
privateKey: z.string().optional(),
address: z.string().optional(),
peers: z.array(wgPeerDtoSchema),
comment: z.string(),
enabled: z.boolean(),
status: wgStatusSchema,
})
export const wgListResponseSchema = z.object({
interfaces: z.array(wgIfaceDtoSchema),
failures: z
.array(
z.object({
serverId: z.string(),
serverName: z.string().optional(),
error: z.string(),
}),
)
.optional(),
})
export const wgCreatePeerSchema = z.object({
publicKey: z.string().min(1),
allowedAddresses: z.array(z.string().min(1)).min(1),
endpointAddress: z.string().optional(),
endpointPort: z.number().int().positive().optional(),
persistentKeepalive: z.number().int().nonnegative().optional(),
comment: z.string().optional(),
name: z.string().optional(),
privateKey: z.enum(["auto", "none"]).or(z.string().min(1)).optional(),
clientAddress: z.string().optional(),
clientDns: z.string().optional(),
clientEndpoint: z.string().optional(),
disabled: z.boolean().optional(),
})
export const wgCreateInterfaceSchema = z.object({
serverId: z.union([z.string(), z.number()]),
name: z.string().min(1).max(64),
listenPort: z.number().int().positive().default(13231),
mtu: z.number().int().positive().default(1420),
comment: z.string().optional(),
privateKey: z.string().min(1).optional(),
address: z.string().optional(),
disabled: z.boolean().optional(),
peer: wgCreatePeerSchema.optional(),
})
export const wgPatchInterfaceSchema = z.object({
name: z.string().min(1).max(64).optional(),
listenPort: z.number().int().positive().optional(),
mtu: z.number().int().positive().optional(),
comment: z.string().optional(),
disabled: z.boolean().optional(),
privateKey: z.string().min(1).optional(),
})
export const wgCreatePeerRequestSchema = wgCreatePeerSchema.extend({
serverId: z.union([z.string(), z.number()]),
interfaceName: z.string().min(1),
})
export const wgPatchPeerSchema = z.object({
publicKey: z.string().min(1).optional(),
allowedAddresses: z.array(z.string().min(1)).min(1).optional(),
endpointAddress: z.string().optional(),
endpointPort: z.number().int().positive().optional(),
persistentKeepalive: z.number().int().nonnegative().optional(),
comment: z.string().optional(),
name: z.string().optional(),
disabled: z.boolean().optional(),
clientAddress: z.string().optional(),
clientDns: z.string().optional(),
clientEndpoint: z.string().optional(),
})
export const wgImportFormatSchema = z.enum(["auto", "rsc", "conf"])
export const wgImportRequestSchema = z.object({
serverId: z.union([z.string(), z.number()]),
content: z.string().min(1),
format: wgImportFormatSchema.optional().default("auto"),
dryRun: z.boolean().optional().default(false),
})
export const wgExportFormatSchema = z.enum(["rsc", "conf", "peer-conf"])
export const wgExportRequestSchema = z.object({
serverId: z.union([z.string(), z.number()]),
interfaceName: z.string().min(1),
format: wgExportFormatSchema,
peerId: z.string().optional(),
includePrivateKey: z.boolean().optional().default(false),
})
export const wgImportPreviewSchema = z.object({
format: z.enum(["rsc", "conf"]),
interface: z.object({
name: z.string(),
listenPort: z.number().int().positive().optional(),
mtu: z.number().int().positive().optional(),
privateKey: z.string().optional(),
comment: z.string().optional(),
address: z.string().optional(),
disabled: z.boolean().optional(),
}),
peers: z.array(wgCreatePeerSchema),
})
export const wgImportResponseSchema = z.object({
dryRun: z.boolean(),
preview: wgImportPreviewSchema,
applied: z
.object({
interfaceName: z.string(),
peersCreated: z.number().int().nonnegative(),
})
.optional(),
})
export const wgExportResponseSchema = z.object({
format: wgExportFormatSchema,
filename: z.string(),
content: z.string(),
})
export type WgPeerDto = z.infer<typeof wgPeerDtoSchema>
export type WgIfaceDto = z.infer<typeof wgIfaceDtoSchema>
export type WgListResponse = z.infer<typeof wgListResponseSchema>
export type WgCreateInterface = z.infer<typeof wgCreateInterfaceSchema>
export type WgPatchInterface = z.infer<typeof wgPatchInterfaceSchema>
export type WgCreatePeerRequest = z.infer<typeof wgCreatePeerRequestSchema>
export type WgPatchPeer = z.infer<typeof wgPatchPeerSchema>
export type WgImportRequest = z.infer<typeof wgImportRequestSchema>
export type WgExportRequest = z.infer<typeof wgExportRequestSchema>
export type WgImportPreview = z.infer<typeof wgImportPreviewSchema>
export type WgImportResponse = z.infer<typeof wgImportResponseSchema>
export type WgExportResponse = z.infer<typeof wgExportResponseSchema>
+76 -6
View File
@@ -1,4 +1,10 @@
import { configuredBackendUrl } from "@/lib/backend-url"
import {
getToken,
isAuthEnabled,
redirectToPortalLogin,
redirectToPortalLoginInteractive,
} from "@/lib/auth"
export class ApiClientError extends Error {
constructor(
@@ -15,27 +21,73 @@ function trimBaseUrl(baseUrl: string): string {
return baseUrl.replace(/\/$/, "")
}
function resolveRequestUrl(baseUrl: string, path: string): string {
/** Absolute or same-origin-relative URL for backend API paths. */
export function resolveApiUrl(baseUrl: string, path: string): string {
if (path.startsWith("/") && configuredBackendUrl().kind === "same-origin") {
return path
}
// Safety: never call browser localhost when the UI is served from a remote host
if (typeof window !== "undefined") {
const host = window.location.hostname
const remoteUi = host !== "localhost" && host !== "127.0.0.1"
const baseIsLocal =
/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(trimBaseUrl(baseUrl))
if (remoteUi && (baseIsLocal || !baseUrl.trim())) {
return path.startsWith("/") ? path : `/${path}`
}
}
return trimBaseUrl(baseUrl) + path
}
/** Attach portal JWT when present. */
export function withAuthHeaders(init?: HeadersInit): Headers {
const headers = new Headers(init)
const token = typeof window !== "undefined" ? getToken() : null
if (token && !headers.has("Authorization")) {
headers.set("Authorization", `Bearer ${token}`)
}
return headers
}
function handleUnauthorized(): never {
if (typeof window !== "undefined" && isAuthEnabled()) {
const ok = redirectToPortalLogin()
if (!ok) redirectToPortalLoginInteractive()
}
throw new ApiClientError("Unauthorized", 401)
}
async function parseErrorMessage(res: Response): Promise<string> {
const payload = await res.json().catch(() => undefined)
if (
typeof payload === "object" &&
payload !== null &&
"error" in payload &&
typeof (payload as { error?: unknown }).error === "string"
) {
return (payload as { error: string }).error
}
return res.statusText || `HTTP ${res.status}`
}
export async function requestJson<T>(
baseUrl: string,
path: string,
init?: RequestInit,
): Promise<T> {
const hasBody = init?.body != null
const res = await fetch(resolveRequestUrl(baseUrl, path), {
const headers = withAuthHeaders(init?.headers)
if (hasBody && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json")
}
const res = await fetch(resolveApiUrl(baseUrl, path), {
...init,
headers: {
...(hasBody ? { "Content-Type": "application/json" } : {}),
...(init?.headers ?? {}),
},
headers,
})
if (res.status === 401) handleUnauthorized()
if (res.status === 204) return undefined as T
const payload = await res.json().catch(() => undefined)
@@ -52,3 +104,21 @@ export async function requestJson<T>(
return payload as T
}
/** Binary/download endpoints (backup, backup file) with the same auth + URL rules. */
export async function requestBlob(
baseUrl: string,
path: string,
init?: RequestInit,
): Promise<Response> {
const headers = withAuthHeaders(init?.headers)
const res = await fetch(resolveApiUrl(baseUrl, path), {
...init,
headers,
})
if (res.status === 401) handleUnauthorized()
if (!res.ok) {
throw new ApiClientError(await parseErrorMessage(res), res.status)
}
return res
}
+3 -37
View File
@@ -1,19 +1,7 @@
import { ApiClientError } from "@/shared/api/http-client"
import { configuredBackendUrl } from "@/lib/backend-url"
import { ApiClientError, requestBlob } from "@/shared/api/http-client"
const MAX_RESTORE_BYTES = 512 * 1024 * 1024
function trimBaseUrl(baseUrl: string): string {
return baseUrl.replace(/\/$/, "")
}
function resolveDatabaseApiUrl(baseUrl: string, path: string): string {
if (configuredBackendUrl().kind === "same-origin") {
return path
}
return `${trimBaseUrl(baseUrl)}${path}`
}
function parseFilename(contentDisposition: string | null, fallback: string): string {
if (!contentDisposition) return fallback
const utfMatch = /filename\*=UTF-8''([^;]+)/i.exec(contentDisposition)
@@ -32,18 +20,7 @@ function parseFilename(contentDisposition: string | null, fallback: string): str
export async function downloadSystemDatabaseBackup(
baseUrl: string,
): Promise<{ blob: Blob; filename: string }> {
const res = await fetch(resolveDatabaseApiUrl(baseUrl, "/api/system/database/backup"))
if (!res.ok) {
const payload = await res.json().catch(() => undefined)
const msg =
typeof payload === "object" &&
payload !== null &&
"error" in payload &&
typeof (payload as { error?: unknown }).error === "string"
? (payload as { error: string }).error
: res.statusText
throw new ApiClientError(msg, res.status, payload)
}
const res = await requestBlob(baseUrl, "/api/system/database/backup")
const blob = await res.blob()
const filename = parseFilename(res.headers.get("Content-Disposition"), "mikrotik-manager.db")
return { blob, filename }
@@ -56,20 +33,9 @@ export async function restoreSystemDatabaseBackup(baseUrl: string, file: File):
413,
)
}
const res = await fetch(resolveDatabaseApiUrl(baseUrl, "/api/system/database/restore"), {
await requestBlob(baseUrl, "/api/system/database/restore", {
method: "POST",
headers: { "Content-Type": "application/octet-stream" },
body: file,
})
if (!res.ok) {
const payload = await res.json().catch(() => undefined)
const msg =
typeof payload === "object" &&
payload !== null &&
"error" in payload &&
typeof (payload as { error?: unknown }).error === "string"
? (payload as { error: string }).error
: res.statusText
throw new ApiClientError(msg, res.status, payload)
}
}
+107
View File
@@ -0,0 +1,107 @@
import type {
WgCreateInterface,
WgCreatePeerRequest,
WgExportRequest,
WgExportResponse,
WgImportRequest,
WgImportResponse,
WgListResponse,
WgPatchInterface,
WgPatchPeer,
} from "@mmapp/contracts/wireguard"
import { requestJson } from "@/shared/api/http-client"
export async function listWireGuard(
baseUrl: string,
opts?: { serverId?: string; includePrivateKey?: boolean },
): Promise<WgListResponse> {
const q = new URLSearchParams()
if (opts?.serverId) q.set("serverId", opts.serverId)
if (opts?.includePrivateKey) q.set("includePrivateKey", "1")
const qs = q.toString()
return requestJson<WgListResponse>(baseUrl, `/api/wireguard${qs ? `?${qs}` : ""}`)
}
export async function createWireGuardInterface(
baseUrl: string,
payload: WgCreateInterface,
): Promise<unknown> {
return requestJson(baseUrl, "/api/wireguard/interfaces", {
method: "POST",
body: JSON.stringify(payload),
})
}
export async function patchWireGuardInterface(
baseUrl: string,
serverId: string,
rosId: string,
payload: WgPatchInterface,
): Promise<{ ok: boolean }> {
return requestJson(baseUrl, `/api/wireguard/interfaces/${encodeURIComponent(serverId)}/${encodeURIComponent(rosId)}`, {
method: "PATCH",
body: JSON.stringify(payload),
})
}
export async function deleteWireGuardInterface(
baseUrl: string,
serverId: string,
rosId: string,
): Promise<{ ok: boolean }> {
return requestJson(baseUrl, `/api/wireguard/interfaces/${encodeURIComponent(serverId)}/${encodeURIComponent(rosId)}`, {
method: "DELETE",
})
}
export async function createWireGuardPeer(
baseUrl: string,
payload: WgCreatePeerRequest,
): Promise<{ ok: boolean }> {
return requestJson(baseUrl, "/api/wireguard/peers", {
method: "POST",
body: JSON.stringify(payload),
})
}
export async function patchWireGuardPeer(
baseUrl: string,
serverId: string,
rosId: string,
payload: WgPatchPeer,
): Promise<{ ok: boolean }> {
return requestJson(baseUrl, `/api/wireguard/peers/${encodeURIComponent(serverId)}/${encodeURIComponent(rosId)}`, {
method: "PATCH",
body: JSON.stringify(payload),
})
}
export async function deleteWireGuardPeer(
baseUrl: string,
serverId: string,
rosId: string,
): Promise<{ ok: boolean }> {
return requestJson(baseUrl, `/api/wireguard/peers/${encodeURIComponent(serverId)}/${encodeURIComponent(rosId)}`, {
method: "DELETE",
})
}
export async function importWireGuard(
baseUrl: string,
payload: WgImportRequest,
): Promise<WgImportResponse> {
return requestJson(baseUrl, "/api/wireguard/import", {
method: "POST",
body: JSON.stringify(payload),
})
}
export async function exportWireGuard(
baseUrl: string,
payload: WgExportRequest,
): Promise<WgExportResponse> {
return requestJson(baseUrl, "/api/wireguard/export", {
method: "POST",
body: JSON.stringify(payload),
})
}
+1 -1
View File
File diff suppressed because one or more lines are too long