Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15ad53af1f | ||
|
|
883842636b | ||
|
|
b9f430de16 | ||
|
|
25e040a5dd | ||
|
|
f2df990746 | ||
|
|
77bc174e43 | ||
|
|
0e9349e508 | ||
|
|
0208aa4d7c | ||
|
|
42a2e18047 | ||
|
|
9df3971f6c |
@@ -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
|
||||
|
||||
|
||||
@@ -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,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]\\)\\)\")",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` из корня).
|
||||
|
||||
### Прод-контейнеры
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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" })
|
||||
@@ -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-ключ (в форме или уже сохранённые в БД)",
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}}'"
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(
|
||||
() => ({
|
||||
|
||||
@@ -52,4 +52,6 @@ export interface SidebarCountsDto {
|
||||
uptimeSpeedProbes: number
|
||||
monitoringItems: number
|
||||
recursiveRoutes: number
|
||||
certificates?: number
|
||||
wireguard?: number
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
Generated
+245
-19
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -3,3 +3,4 @@ export * from "./alerts.js"
|
||||
export * from "./events.js"
|
||||
export * from "./certificates.js"
|
||||
export * from "./backups.js"
|
||||
export * from "./wireguard.js"
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user