feat(api, web): implement short install link functionality for agents
- Added new API endpoints for creating, retrieving, and revoking install links for agents. - Enhanced the agent installation process with short links accessible via `/agent-install/:id` and `/:slug`. - Updated the README and documentation to reflect the new installation method and usage instructions. - Refactored relevant components in the web application to support the new install link feature. - Improved error handling and validation for install link operations. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -22,7 +22,15 @@ pnpm --filter @evofw/api dev # :8080
|
|||||||
pnpm --filter @evofw/web dev # :5177
|
pnpm --filter @evofw/web dev # :5177
|
||||||
```
|
```
|
||||||
|
|
||||||
Linux agent:
|
Linux agent (short link из UI `/agents` → Добавить агента):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL http://localhost:8080/agent-install/<id> | bash
|
||||||
|
# или:
|
||||||
|
curl -fsSL http://localhost:8080/<slug> | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
Legacy one-liner:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL http://localhost:8080/v1/agent/install.sh | \
|
curl -fsSL http://localhost:8080/v1/agent/install.sh | \
|
||||||
|
|||||||
+35
-3
@@ -17,6 +17,10 @@ import { controlRoutes } from './routes/control.js'
|
|||||||
import { agentRoutes } from './routes/agent.js'
|
import { agentRoutes } from './routes/agent.js'
|
||||||
import { refreshAllLists } from './services/lists/refresh.js'
|
import { refreshAllLists } from './services/lists/refresh.js'
|
||||||
import { repos } from '@evofw/db'
|
import { repos } from '@evofw/db'
|
||||||
|
import {
|
||||||
|
isValidInstallSlug,
|
||||||
|
resolveAndRenderInstallScript,
|
||||||
|
} from './services/install-links.js'
|
||||||
|
|
||||||
export interface BuildAppOptions {
|
export interface BuildAppOptions {
|
||||||
config?: AppConfig
|
config?: AppConfig
|
||||||
@@ -68,11 +72,39 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
|||||||
root: staticDir,
|
root: staticDir,
|
||||||
wildcard: false,
|
wildcard: false,
|
||||||
})
|
})
|
||||||
app.setNotFoundHandler(async (_request, reply) => {
|
|
||||||
return reply.sendFile('index.html')
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.setNotFoundHandler(async (request, reply) => {
|
||||||
|
const path = request.url.split('?')[0] ?? ''
|
||||||
|
const segment = path.startsWith('/') ? path.slice(1) : path
|
||||||
|
if (
|
||||||
|
request.method === 'GET' &&
|
||||||
|
segment &&
|
||||||
|
!segment.includes('/') &&
|
||||||
|
isValidInstallSlug(segment)
|
||||||
|
) {
|
||||||
|
const link = repos.getInstallLinkBySlug(app.db, segment)
|
||||||
|
if (link) {
|
||||||
|
const script = resolveAndRenderInstallScript(
|
||||||
|
app.db,
|
||||||
|
link,
|
||||||
|
config.publicBaseUrl,
|
||||||
|
config.enrollSeed,
|
||||||
|
)
|
||||||
|
return reply.type('text/x-shellscript').send(script)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.staticDir !== null) {
|
||||||
|
return reply.sendFile('index.html')
|
||||||
|
}
|
||||||
|
return reply.code(404).send({
|
||||||
|
type: 'about:blank',
|
||||||
|
title: 'Not Found',
|
||||||
|
status: 404,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
if (!opts.memory) {
|
if (!opts.memory) {
|
||||||
await app.register(import('@fastify/schedule'))
|
await app.register(import('@fastify/schedule'))
|
||||||
const task = new AsyncTask(
|
const task = new AsyncTask(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type { AppConfig } from '../config.js'
|
|||||||
import { hashToken } from '../plugins/auth.js'
|
import { hashToken } from '../plugins/auth.js'
|
||||||
import { evaluateAgentPolicy } from '../services/policy/evaluate.js'
|
import { evaluateAgentPolicy } from '../services/policy/evaluate.js'
|
||||||
import { AppError } from '../plugins/error-handler.js'
|
import { AppError } from '../plugins/error-handler.js'
|
||||||
|
import { resolveAndRenderInstallScript } from '../services/install-links.js'
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
const scriptsDir = join(__dirname, '../agent-scripts')
|
const scriptsDir = join(__dirname, '../agent-scripts')
|
||||||
@@ -23,6 +24,21 @@ export const agentRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
return reply.type('text/x-shellscript').send(body)
|
return reply.type('text/x-shellscript').send(body)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string } }>(
|
||||||
|
'/agent-install/:id',
|
||||||
|
async (req, reply) => {
|
||||||
|
const link = repos.getInstallLink(app.db, req.params.id)
|
||||||
|
if (!link) throw new AppError('NOT_FOUND', 'Install link not found', 404)
|
||||||
|
const script = resolveAndRenderInstallScript(
|
||||||
|
app.db,
|
||||||
|
link,
|
||||||
|
config.publicBaseUrl,
|
||||||
|
config.enrollSeed,
|
||||||
|
)
|
||||||
|
return reply.type('text/x-shellscript').send(script)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
app.get('/v1/agent/sync-script', async (_req, reply) => {
|
app.get('/v1/agent/sync-script', async (_req, reply) => {
|
||||||
const body = readFileSync(join(scriptsDir, 'evofw-firewall.sh'), 'utf-8')
|
const body = readFileSync(join(scriptsDir, 'evofw-firewall.sh'), 'utf-8')
|
||||||
return reply.type('text/x-shellscript').send(body)
|
return reply.type('text/x-shellscript').send(body)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
createIpListBodySchema,
|
createIpListBodySchema,
|
||||||
createPolicyRuleBodySchema,
|
createPolicyRuleBodySchema,
|
||||||
createPolicySetBodySchema,
|
createPolicySetBodySchema,
|
||||||
|
createInstallLinkBodySchema,
|
||||||
patchPolicySetBodySchema,
|
patchPolicySetBodySchema,
|
||||||
putAgentPolicySetsBodySchema,
|
putAgentPolicySetsBodySchema,
|
||||||
patchAgentBodySchema,
|
patchAgentBodySchema,
|
||||||
@@ -25,6 +26,10 @@ import {
|
|||||||
resolveAndStoreHostnameRule,
|
resolveAndStoreHostnameRule,
|
||||||
resolveHostnameToCidrs,
|
resolveHostnameToCidrs,
|
||||||
} from '../services/policy/resolve-hostname.js'
|
} from '../services/policy/resolve-hostname.js'
|
||||||
|
import {
|
||||||
|
mapInstallLink,
|
||||||
|
randomToken,
|
||||||
|
} from '../services/install-links.js'
|
||||||
import type { AppConfig } from '../config.js'
|
import type { AppConfig } from '../config.js'
|
||||||
|
|
||||||
function mapAgent(a: NonNullable<ReturnType<typeof repos.getAgent>>) {
|
function mapAgent(a: NonNullable<ReturnType<typeof repos.getAgent>>) {
|
||||||
@@ -134,6 +139,41 @@ export const controlRoutes: FastifyPluginAsync<{ config: AppConfig }> = async (
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Install short-links
|
||||||
|
app.get('/install-links', async () => ({
|
||||||
|
items: repos
|
||||||
|
.listInstallLinks(app.db)
|
||||||
|
.map((row) => mapInstallLink(row, config.publicBaseUrl)),
|
||||||
|
}))
|
||||||
|
|
||||||
|
app.post('/install-links', async (req, reply) => {
|
||||||
|
const body = createInstallLinkBodySchema.parse(req.body)
|
||||||
|
const id = randomToken(10)
|
||||||
|
const slug = randomToken(16)
|
||||||
|
if (repos.getInstallLink(app.db, id) || repos.getInstallLinkBySlug(app.db, slug)) {
|
||||||
|
throw new AppError('CONFLICT', 'Retry create (id collision)', 409)
|
||||||
|
}
|
||||||
|
const row = repos.insertInstallLink(app.db, {
|
||||||
|
id,
|
||||||
|
slug,
|
||||||
|
clientName: body.name.trim(),
|
||||||
|
platform: body.platform ?? 'linux',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
useCount: 0,
|
||||||
|
})
|
||||||
|
return reply.code(201).send(mapInstallLink(row!, config.publicBaseUrl))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>(
|
||||||
|
'/install-links/:id',
|
||||||
|
async (req) => {
|
||||||
|
const row = repos.getInstallLink(app.db, req.params.id)
|
||||||
|
if (!row) throw new AppError('NOT_FOUND', 'Install link not found', 404)
|
||||||
|
const updated = repos.revokeInstallLink(app.db, row.id)
|
||||||
|
return mapInstallLink(updated!, config.publicBaseUrl)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// Agents
|
// Agents
|
||||||
app.get('/agents', async () => ({
|
app.get('/agents', async () => ({
|
||||||
items: repos.listAgents(app.db).map(mapAgent),
|
items: repos.listAgents(app.db).map(mapAgent),
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, it, expect, afterAll } from 'vitest'
|
||||||
|
import { buildApp } from '../app.js'
|
||||||
|
import type { AppConfig } from '../config.js'
|
||||||
|
|
||||||
|
const testConfig: AppConfig = {
|
||||||
|
databaseUrl: 'sqlite::memory:',
|
||||||
|
jwtSecret: 'test',
|
||||||
|
jwtTtlHours: 24,
|
||||||
|
serverPort: 8080,
|
||||||
|
staticDir: null,
|
||||||
|
logLevel: 'error',
|
||||||
|
authRequired: false,
|
||||||
|
authIssuer: 'https://auth.test',
|
||||||
|
authPortalUrl: 'http://localhost:5175',
|
||||||
|
publicBaseUrl: 'https://fw.example.com',
|
||||||
|
enrollSeed: 'test-seed',
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('install-links', () => {
|
||||||
|
const appPromise = buildApp({ memory: true, config: testConfig })
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
const app = await appPromise
|
||||||
|
await app.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates link and serves scripts by id and slug', async () => {
|
||||||
|
const app = await appPromise
|
||||||
|
await app.ready()
|
||||||
|
|
||||||
|
const created = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/install-links',
|
||||||
|
payload: { name: 'web-01', platform: 'linux' },
|
||||||
|
})
|
||||||
|
expect(created.statusCode).toBe(201)
|
||||||
|
const body = created.json() as {
|
||||||
|
id: string
|
||||||
|
slug: string
|
||||||
|
curl: { by_id: string; by_slug: string }
|
||||||
|
}
|
||||||
|
expect(body.id).toBeTruthy()
|
||||||
|
expect(body.slug).toBeTruthy()
|
||||||
|
expect(body.curl.by_id).toContain(`/agent-install/${body.id}`)
|
||||||
|
|
||||||
|
const byId = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/agent-install/${body.id}`,
|
||||||
|
})
|
||||||
|
expect(byId.statusCode).toBe(200)
|
||||||
|
expect(byId.headers['content-type']).toContain('text/x-shellscript')
|
||||||
|
expect(byId.body).toContain("EVOFW_CLIENT_NAME='web-01'")
|
||||||
|
expect(byId.body).toContain("EVOFW_SEED='test-seed'")
|
||||||
|
|
||||||
|
const bySlug = await app.inject({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/${body.slug}`,
|
||||||
|
})
|
||||||
|
expect(bySlug.statusCode).toBe(200)
|
||||||
|
expect(bySlug.body).toContain("EVOFW_CP_URL='https://fw.example.com'")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import type { Db } from '@evofw/db'
|
||||||
|
import { repos } from '@evofw/db'
|
||||||
|
import { AppError } from '../plugins/error-handler.js'
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
|
const scriptsDir = join(__dirname, '../agent-scripts')
|
||||||
|
|
||||||
|
const ID_ALPHABET =
|
||||||
|
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||||
|
|
||||||
|
export function randomToken(length: number): string {
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(length))
|
||||||
|
let out = ''
|
||||||
|
for (const b of bytes) {
|
||||||
|
out += ID_ALPHABET[b % ID_ALPHABET.length]
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Root path segments that must never be treated as install slugs. */
|
||||||
|
export const RESERVED_ROOT_SEGMENTS = new Set([
|
||||||
|
'api',
|
||||||
|
'v1',
|
||||||
|
'assets',
|
||||||
|
'health',
|
||||||
|
'ready',
|
||||||
|
'favicon.ico',
|
||||||
|
'index.html',
|
||||||
|
'robots.txt',
|
||||||
|
'agent-install',
|
||||||
|
'static',
|
||||||
|
])
|
||||||
|
|
||||||
|
const SLUG_RE = /^[A-Za-z0-9_-]{8,64}$/
|
||||||
|
|
||||||
|
export function isValidInstallSlug(segment: string): boolean {
|
||||||
|
if (!segment || segment.includes('/') || segment.includes('.')) return false
|
||||||
|
if (RESERVED_ROOT_SEGMENTS.has(segment.toLowerCase())) return false
|
||||||
|
return SLUG_RE.test(segment)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildInstallUrls(baseUrl: string, id: string, slug: string) {
|
||||||
|
const base = baseUrl.replace(/\/$/, '')
|
||||||
|
const byId = `${base}/agent-install/${id}`
|
||||||
|
const bySlug = `${base}/${slug}`
|
||||||
|
return {
|
||||||
|
by_id: byId,
|
||||||
|
by_slug: bySlug,
|
||||||
|
curl: {
|
||||||
|
by_id: `curl -fsSL ${byId} | bash`,
|
||||||
|
by_slug: `curl -fsSL ${bySlug} | bash`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapInstallLink(
|
||||||
|
row: NonNullable<ReturnType<typeof repos.getInstallLink>>,
|
||||||
|
baseUrl: string,
|
||||||
|
) {
|
||||||
|
const urls = buildInstallUrls(baseUrl, row.id, row.slug)
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
slug: row.slug,
|
||||||
|
client_name: row.clientName,
|
||||||
|
platform: row.platform as 'linux' | 'mikrotik',
|
||||||
|
created_at: row.createdAt,
|
||||||
|
revoked_at: row.revokedAt,
|
||||||
|
last_used_at: row.lastUsedAt,
|
||||||
|
use_count: row.useCount,
|
||||||
|
urls: { by_id: urls.by_id, by_slug: urls.by_slug },
|
||||||
|
curl: urls.curl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadInstallSh(): string {
|
||||||
|
return readFileSync(join(scriptsDir, 'install.sh'), 'utf-8')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Self-contained install script: env exports + full install.sh body.
|
||||||
|
*/
|
||||||
|
export function renderInstallScript(opts: {
|
||||||
|
cpUrl: string
|
||||||
|
seed: string
|
||||||
|
clientName: string
|
||||||
|
platform: string
|
||||||
|
}): string {
|
||||||
|
const cp = opts.cpUrl.replace(/\/$/, '')
|
||||||
|
const escape = (s: string) => s.replace(/'/g, `'\\''`)
|
||||||
|
const header = [
|
||||||
|
'#!/usr/bin/env bash',
|
||||||
|
'# EvoFirewall short install link — env pre-set',
|
||||||
|
'set -euo pipefail',
|
||||||
|
`export EVOFW_CP_URL='${escape(cp)}'`,
|
||||||
|
`export EVOFW_SEED='${escape(opts.seed)}'`,
|
||||||
|
`export EVOFW_CLIENT_NAME='${escape(opts.clientName)}'`,
|
||||||
|
`export EVOFW_PLATFORM='${escape(opts.platform)}'`,
|
||||||
|
'',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
// Drop the shebang from install.sh to avoid double shebang.
|
||||||
|
const body = loadInstallSh().replace(/^#!\/usr\/bin\/env bash\r?\n/, '')
|
||||||
|
return `${header}${body}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAndRenderInstallScript(
|
||||||
|
db: Db,
|
||||||
|
link: NonNullable<ReturnType<typeof repos.getInstallLink>>,
|
||||||
|
publicBaseUrl: string,
|
||||||
|
enrollSeedFallback: string,
|
||||||
|
): string {
|
||||||
|
if (link.revokedAt) {
|
||||||
|
throw new AppError('GONE', 'Install link revoked', 410)
|
||||||
|
}
|
||||||
|
const seed =
|
||||||
|
repos.getSetting(db, 'enroll_seed') || enrollSeedFallback
|
||||||
|
repos.touchInstallLink(db, link.id)
|
||||||
|
return renderInstallScript({
|
||||||
|
cpUrl: publicBaseUrl,
|
||||||
|
seed,
|
||||||
|
clientName: link.clientName,
|
||||||
|
platform: link.platform,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useMutation } from '@tanstack/react-query'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
import { Copy } from 'lucide-react'
|
||||||
|
import { apiFetch } from '@/lib/api'
|
||||||
|
import type { InstallLink } from '@evofw/shared'
|
||||||
|
import { Button } from '@evofw/ui/components/button'
|
||||||
|
import { Field, FieldLabel } from '@evofw/ui/components/field'
|
||||||
|
import { Input } from '@evofw/ui/components/input'
|
||||||
|
import { ScrollArea } from '@evofw/ui/components/scroll-area'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@evofw/ui/components/select'
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetFooter,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from '@evofw/ui/components/sheet'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create agent install invite — Sheet.
|
||||||
|
* Preview: https://reui.io/preview/base/sheet-1 · https://reui.io/preview/base/sheet-8
|
||||||
|
* Hub: https://reui.io/components/sheet · https://reui.io/preview/base/components/c-sheet-1
|
||||||
|
* Copy pattern: https://reui.io/preview/base/settings-14
|
||||||
|
* Primitive API: https://ui.shadcn.com/docs/components/base/sheet
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Platform = 'linux' | 'mikrotik'
|
||||||
|
|
||||||
|
const PLATFORM_ITEMS = [
|
||||||
|
{ value: 'linux', label: 'Linux' },
|
||||||
|
{ value: 'mikrotik', label: 'MikroTik' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
interface AddAgentSheetProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyText(text: string) {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
toast.success('Скопировано')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddAgentSheet({ open, onOpenChange }: AddAgentSheetProps) {
|
||||||
|
const [name, setName] = useState('web-01')
|
||||||
|
const [platform, setPlatform] = useState<Platform>('linux')
|
||||||
|
const [created, setCreated] = useState<InstallLink | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setCreated(null)
|
||||||
|
setName('web-01')
|
||||||
|
setPlatform('linux')
|
||||||
|
}
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const create = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
apiFetch<InstallLink>('/api/v1/install-links', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name: name.trim(), platform }),
|
||||||
|
}),
|
||||||
|
onSuccess: (link) => {
|
||||||
|
setCreated(link)
|
||||||
|
toast.success('Ссылка создана')
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const canCreate = Boolean(name.trim()) && !create.isPending
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent className="flex flex-col gap-0 overflow-hidden sm:max-w-md">
|
||||||
|
<SheetHeader className="shrink-0">
|
||||||
|
<SheetTitle>
|
||||||
|
{created ? 'Команда установки' : 'Добавить агента'}
|
||||||
|
</SheetTitle>
|
||||||
|
<SheetDescription>
|
||||||
|
{created
|
||||||
|
? 'Скопируйте one-liner и выполните на хосте. Затем одобрите агента в списке.'
|
||||||
|
: 'Создайте короткую install-ссылку с именем клиента.'}
|
||||||
|
</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1 px-4">
|
||||||
|
<div className="grid auto-rows-min gap-4 py-2 pb-4">
|
||||||
|
{!created ? (
|
||||||
|
<>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="agent-name">Имя клиента</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="agent-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="web-01"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Платформа</FieldLabel>
|
||||||
|
<Select
|
||||||
|
items={[...PLATFORM_ITEMS]}
|
||||||
|
value={platform}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (v === 'linux' || v === 'mikrotik') setPlatform(v)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PLATFORM_ITEMS.map((item) => (
|
||||||
|
<SelectItem key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>По id</FieldLabel>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<pre className="bg-muted overflow-x-auto rounded-lg p-3 text-xs whitespace-pre-wrap break-all">
|
||||||
|
{created.curl?.by_id}
|
||||||
|
</pre>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="self-start"
|
||||||
|
onClick={() =>
|
||||||
|
void copyText(created.curl?.by_id ?? '')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Copy data-icon="inline-start" />
|
||||||
|
Копировать
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>Короткий slug</FieldLabel>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<pre className="bg-muted overflow-x-auto rounded-lg p-3 text-xs whitespace-pre-wrap break-all">
|
||||||
|
{created.curl?.by_slug}
|
||||||
|
</pre>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="self-start"
|
||||||
|
onClick={() =>
|
||||||
|
void copyText(created.curl?.by_slug ?? '')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Copy data-icon="inline-start" />
|
||||||
|
Копировать
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<SheetFooter className="mt-0 shrink-0 flex-row flex-wrap gap-2 border-t">
|
||||||
|
{created ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setCreated(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ещё ссылка
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => onOpenChange(false)}>Готово</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={!canCreate}
|
||||||
|
onClick={() => create.mutate()}
|
||||||
|
>
|
||||||
|
Создать ссылку
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Copy, Check, Trash2 } from 'lucide-react'
|
import { Check, Plus, Trash2 } from 'lucide-react'
|
||||||
import { useCallback, useMemo, useState } from 'react'
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
import type { ColumnDef } from '@tanstack/react-table'
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
|
||||||
@@ -10,13 +10,6 @@ import {
|
|||||||
PageShell,
|
PageShell,
|
||||||
ResourcePage,
|
ResourcePage,
|
||||||
} from '@/components/reui-kit'
|
} from '@/components/reui-kit'
|
||||||
import {
|
|
||||||
Frame,
|
|
||||||
FrameDescription,
|
|
||||||
FrameHeader,
|
|
||||||
FramePanel,
|
|
||||||
FrameTitle,
|
|
||||||
} from '@/components/reui/frame'
|
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import {
|
import {
|
||||||
DataGridMutedCell,
|
DataGridMutedCell,
|
||||||
@@ -24,13 +17,19 @@ import {
|
|||||||
} from '@/components/data-grid-cell'
|
} from '@/components/data-grid-cell'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
||||||
import { agentsQueryOptions, installContextQueryOptions } from '@/queries'
|
import { AddAgentSheet } from '@/components/agents/add-agent-sheet'
|
||||||
|
import { agentsQueryOptions } from '@/queries'
|
||||||
import { apiFetch } from '@/lib/api'
|
import { apiFetch } from '@/lib/api'
|
||||||
import { Button } from '@evofw/ui/components/button'
|
import { Button } from '@evofw/ui/components/button'
|
||||||
import { Input } from '@evofw/ui/components/input'
|
|
||||||
import { Label } from '@evofw/ui/components/label'
|
|
||||||
import type { Agent } from '@evofw/shared'
|
import type { Agent } from '@evofw/shared'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agents list — ResourcePage (Frame + tabs + Filters + DataGrid).
|
||||||
|
* Preview: https://reui.io/preview/base/data-grid-filtering-2
|
||||||
|
* Empty: https://reui.io/preview/base/empty-state-7
|
||||||
|
* Create Sheet: https://reui.io/preview/base/sheet-1 · sheet-8
|
||||||
|
* Docs: https://reui.io/blocks · https://reui.io/components/sheet
|
||||||
|
*/
|
||||||
export const Route = createFileRoute('/_auth/agents/')({
|
export const Route = createFileRoute('/_auth/agents/')({
|
||||||
component: AgentsPage,
|
component: AgentsPage,
|
||||||
})
|
})
|
||||||
@@ -38,8 +37,7 @@ export const Route = createFileRoute('/_auth/agents/')({
|
|||||||
function AgentsPage() {
|
function AgentsPage() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const agentsQ = useQuery(agentsQueryOptions())
|
const agentsQ = useQuery(agentsQueryOptions())
|
||||||
const installQ = useQuery(installContextQueryOptions())
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
const [name, setName] = useState('web-01')
|
|
||||||
const [filters, setFilters] = useState<Filter[]>([])
|
const [filters, setFilters] = useState<Filter[]>([])
|
||||||
const [activeTab, setActiveTab] = useState('all')
|
const [activeTab, setActiveTab] = useState('all')
|
||||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||||
@@ -61,6 +59,7 @@ function AgentsPage() {
|
|||||||
toast.success('Агент отозван')
|
toast.success('Агент отозван')
|
||||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||||
},
|
},
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
})
|
})
|
||||||
|
|
||||||
const remove = useMutation({
|
const remove = useMutation({
|
||||||
@@ -71,16 +70,10 @@ function AgentsPage() {
|
|||||||
setDeleteId(null)
|
setDeleteId(null)
|
||||||
void qc.invalidateQueries({ queryKey: ['agents'] })
|
void qc.invalidateQueries({ queryKey: ['agents'] })
|
||||||
},
|
},
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
})
|
})
|
||||||
|
|
||||||
const installCmd = useMemo(() => {
|
|
||||||
const cp = installQ.data?.suggested_cp_url ?? 'https://fw.example.com'
|
|
||||||
const seed = installQ.data?.enroll_seed ?? '<seed>'
|
|
||||||
return `curl -fsSL ${cp}/v1/agent/install.sh | \\\n EVOFW_CP_URL=${cp} \\\n EVOFW_SEED=${seed} \\\n EVOFW_CLIENT_NAME="${name}" \\\n bash`
|
|
||||||
}, [installQ.data, name])
|
|
||||||
|
|
||||||
const items = agentsQ.data?.items ?? []
|
const items = agentsQ.data?.items ?? []
|
||||||
const pending = items.filter((a) => a.status === 'pending')
|
|
||||||
|
|
||||||
const filterFields: FilterFieldConfig[] = useMemo(
|
const filterFields: FilterFieldConfig[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -184,6 +177,16 @@ function AgentsPage() {
|
|||||||
const a = row.original
|
const a = row.original
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-end gap-1">
|
<div className="flex justify-end gap-1">
|
||||||
|
{a.status === 'pending' ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => approve.mutate(a.id)}
|
||||||
|
disabled={approve.isPending}
|
||||||
|
>
|
||||||
|
<Check data-icon="inline-start" />
|
||||||
|
Approve
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -214,105 +217,24 @@ function AgentsPage() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[revoke],
|
[approve, revoke],
|
||||||
|
)
|
||||||
|
|
||||||
|
const addButton = (
|
||||||
|
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus data-icon="inline-start" />
|
||||||
|
Добавить агента
|
||||||
|
</Button>
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell>
|
<PageShell>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Агенты"
|
title="Агенты"
|
||||||
description="Linux / MikroTik — enroll, approve, policy mode"
|
description="Linux / MikroTik — short install, approve, policy mode"
|
||||||
|
actions={addButton}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Frame dense spacing="sm">
|
|
||||||
<FrameHeader>
|
|
||||||
<div>
|
|
||||||
<FrameTitle>Установка Linux</FrameTitle>
|
|
||||||
<FrameDescription>
|
|
||||||
One-liner. После enroll одобрите агента ниже.
|
|
||||||
</FrameDescription>
|
|
||||||
</div>
|
|
||||||
</FrameHeader>
|
|
||||||
<FramePanel>
|
|
||||||
<div className="mb-3 flex max-w-sm flex-col gap-2">
|
|
||||||
<Label htmlFor="cname">Имя клиента</Label>
|
|
||||||
<Input
|
|
||||||
id="cname"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<pre className="bg-muted overflow-x-auto rounded-lg p-3 text-xs">
|
|
||||||
{installCmd}
|
|
||||||
</pre>
|
|
||||||
<Button
|
|
||||||
className="mt-2"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={async () => {
|
|
||||||
await navigator.clipboard.writeText(
|
|
||||||
installCmd.replace(/\\\n\s*/g, ' '),
|
|
||||||
)
|
|
||||||
toast.success('Скопировано')
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Copy data-icon="inline-start" />
|
|
||||||
Копировать
|
|
||||||
</Button>
|
|
||||||
{installQ.data?.mikrotik_url ? (
|
|
||||||
<p className="text-muted-foreground mt-3 text-sm">
|
|
||||||
MikroTik:{' '}
|
|
||||||
<a className="underline" href={installQ.data.mikrotik_url}>
|
|
||||||
mikrotik-install.rsc
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</FramePanel>
|
|
||||||
</Frame>
|
|
||||||
|
|
||||||
{pending.length > 0 ? (
|
|
||||||
<Frame dense spacing="sm">
|
|
||||||
<FrameHeader>
|
|
||||||
<FrameTitle>Запросы ({pending.length})</FrameTitle>
|
|
||||||
</FrameHeader>
|
|
||||||
<FramePanel>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{pending.map((a) => (
|
|
||||||
<div
|
|
||||||
key={a.id}
|
|
||||||
className="flex flex-wrap items-center justify-between gap-2 border-b py-2 last:border-0"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<DataGridPrimaryCell
|
|
||||||
accent="primary"
|
|
||||||
title={a.name}
|
|
||||||
subtitle={`${a.platform} · ${a.hostname ?? '—'} · ${a.token_prefix}…`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => approve.mutate(a.id)}
|
|
||||||
disabled={approve.isPending}
|
|
||||||
>
|
|
||||||
<Check data-icon="inline-start" />
|
|
||||||
Approve
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setDeleteId(a.id)}
|
|
||||||
>
|
|
||||||
Удалить
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</FramePanel>
|
|
||||||
</Frame>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<ResourcePage
|
<ResourcePage
|
||||||
title="Клиенты"
|
title="Клиенты"
|
||||||
hideHeader
|
hideHeader
|
||||||
@@ -339,10 +261,14 @@ function AgentsPage() {
|
|||||||
onRetry={() => void agentsQ.refetch()}
|
onRetry={() => void agentsQ.refetch()}
|
||||||
emptyState={{
|
emptyState={{
|
||||||
title: 'Нет агентов',
|
title: 'Нет агентов',
|
||||||
description: 'Установите agent на сервер и одобрите запрос.',
|
description:
|
||||||
|
'Создайте install-ссылку, выполните curl на хосте и одобрите запрос.',
|
||||||
|
action: addButton,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AddAgentSheet open={createOpen} onOpenChange={setCreateOpen} />
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={deleteId !== null}
|
open={deleteId !== null}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
|
|||||||
+15
-1
@@ -1,6 +1,20 @@
|
|||||||
# Agents
|
# Agents
|
||||||
|
|
||||||
## Linux
|
## Short install (рекомендуется)
|
||||||
|
|
||||||
|
В UI `/agents` → **Добавить агента** создаёт install-ссылку. На хосте:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://<cp>/agent-install/<id> | bash
|
||||||
|
# или короткий slug:
|
||||||
|
curl -fsSL https://<cp>/<slug> | bash
|
||||||
|
```
|
||||||
|
|
||||||
|
Скрипт уже содержит `EVOFW_CP_URL`, `EVOFW_SEED`, `EVOFW_CLIENT_NAME`. После enroll одобрите агента во вкладке Pending.
|
||||||
|
|
||||||
|
API (auth): `POST /api/v1/install-links` `{ "name": "web-01", "platform": "linux" }`.
|
||||||
|
|
||||||
|
## Linux (legacy one-liner)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://<cp>/v1/agent/install.sh | \
|
curl -fsSL https://<cp>/v1/agent/install.sh | \
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Agent install short-links (one-liner invites)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS agent_install_links (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
slug TEXT NOT NULL UNIQUE,
|
||||||
|
client_name TEXT NOT NULL,
|
||||||
|
platform TEXT NOT NULL DEFAULT 'linux',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
|
revoked_at TEXT,
|
||||||
|
last_used_at TEXT,
|
||||||
|
use_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
CHECK (platform IN ('linux', 'mikrotik')),
|
||||||
|
CHECK (length(trim(client_name)) > 0),
|
||||||
|
CHECK (length(trim(id)) > 0),
|
||||||
|
CHECK (length(trim(slug)) > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_install_links_slug
|
||||||
|
ON agent_install_links (slug);
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
policyRuleResolved,
|
policyRuleResolved,
|
||||||
ipOverrides,
|
ipOverrides,
|
||||||
agentStatsSamples,
|
agentStatsSamples,
|
||||||
|
agentInstallLinks,
|
||||||
settings,
|
settings,
|
||||||
SHARED_POLICY_SET_ID,
|
SHARED_POLICY_SET_ID,
|
||||||
} from '../schema.js'
|
} from '../schema.js'
|
||||||
@@ -434,6 +435,59 @@ export function cloneRulesFrom(
|
|||||||
return getAgent(db, targetAgentId)
|
return getAgent(db, targetAgentId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listInstallLinks(db: Db) {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(agentInstallLinks)
|
||||||
|
.orderBy(desc(agentInstallLinks.createdAt))
|
||||||
|
.all()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInstallLink(db: Db, id: string) {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(agentInstallLinks)
|
||||||
|
.where(eq(agentInstallLinks.id, id))
|
||||||
|
.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInstallLinkBySlug(db: Db, slug: string) {
|
||||||
|
return db
|
||||||
|
.select()
|
||||||
|
.from(agentInstallLinks)
|
||||||
|
.where(eq(agentInstallLinks.slug, slug))
|
||||||
|
.get()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertInstallLink(
|
||||||
|
db: Db,
|
||||||
|
row: typeof agentInstallLinks.$inferInsert,
|
||||||
|
) {
|
||||||
|
db.insert(agentInstallLinks).values(row).run()
|
||||||
|
return getInstallLink(db, row.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function revokeInstallLink(db: Db, id: string) {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
db.update(agentInstallLinks)
|
||||||
|
.set({ revokedAt: now })
|
||||||
|
.where(eq(agentInstallLinks.id, id))
|
||||||
|
.run()
|
||||||
|
return getInstallLink(db, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function touchInstallLink(db: Db, id: string) {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
db.update(agentInstallLinks)
|
||||||
|
.set({
|
||||||
|
lastUsedAt: now,
|
||||||
|
useCount: sql`${agentInstallLinks.useCount} + 1`,
|
||||||
|
})
|
||||||
|
.where(eq(agentInstallLinks.id, id))
|
||||||
|
.run()
|
||||||
|
return getInstallLink(db, id)
|
||||||
|
}
|
||||||
|
|
||||||
export const repos = {
|
export const repos = {
|
||||||
listAgents,
|
listAgents,
|
||||||
getAgent,
|
getAgent,
|
||||||
@@ -481,6 +535,12 @@ export const repos = {
|
|||||||
setSetting,
|
setSetting,
|
||||||
listSettings,
|
listSettings,
|
||||||
cloneRulesFrom,
|
cloneRulesFrom,
|
||||||
|
listInstallLinks,
|
||||||
|
getInstallLink,
|
||||||
|
getInstallLinkBySlug,
|
||||||
|
insertInstallLink,
|
||||||
|
revokeInstallLink,
|
||||||
|
touchInstallLink,
|
||||||
}
|
}
|
||||||
|
|
||||||
export { SHARED_POLICY_SET_ID }
|
export { SHARED_POLICY_SET_ID }
|
||||||
|
|||||||
@@ -194,6 +194,26 @@ export const agentStatsSamples = sqliteTable(
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** Short install invite links (`/agent-install/:id` and `/:slug`). */
|
||||||
|
export const agentInstallLinks = sqliteTable(
|
||||||
|
'agent_install_links',
|
||||||
|
{
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
slug: text('slug').notNull(),
|
||||||
|
clientName: text('client_name').notNull(),
|
||||||
|
platform: text('platform').notNull().default('linux'), // linux | mikrotik
|
||||||
|
createdAt: text('created_at')
|
||||||
|
.notNull()
|
||||||
|
.default(sql`(strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`),
|
||||||
|
revokedAt: text('revoked_at'),
|
||||||
|
lastUsedAt: text('last_used_at'),
|
||||||
|
useCount: integer('use_count').notNull().default(0),
|
||||||
|
},
|
||||||
|
(t) => ({
|
||||||
|
slugIdx: uniqueIndex('idx_agent_install_links_slug').on(t.slug),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
export const SHARED_POLICY_SET_ID = 'set-shared-default'
|
export const SHARED_POLICY_SET_ID = 'set-shared-default'
|
||||||
|
|
||||||
export const schema = {
|
export const schema = {
|
||||||
@@ -207,4 +227,5 @@ export const schema = {
|
|||||||
policyRuleResolved,
|
policyRuleResolved,
|
||||||
ipOverrides,
|
ipOverrides,
|
||||||
agentStatsSamples,
|
agentStatsSamples,
|
||||||
|
agentInstallLinks,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,6 +182,34 @@ export const dashboardStatsSchema = z.object({
|
|||||||
lists_total: z.number().int(),
|
lists_total: z.number().int(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const createInstallLinkBodySchema = z.object({
|
||||||
|
name: z.string().min(1),
|
||||||
|
platform: agentPlatformSchema.optional().default('linux'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const installLinkSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
slug: z.string(),
|
||||||
|
client_name: z.string(),
|
||||||
|
platform: agentPlatformSchema,
|
||||||
|
created_at: z.string(),
|
||||||
|
revoked_at: z.string().nullable().optional(),
|
||||||
|
last_used_at: z.string().nullable().optional(),
|
||||||
|
use_count: z.number().int(),
|
||||||
|
urls: z
|
||||||
|
.object({
|
||||||
|
by_id: z.string(),
|
||||||
|
by_slug: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
curl: z
|
||||||
|
.object({
|
||||||
|
by_id: z.string(),
|
||||||
|
by_slug: z.string(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
})
|
||||||
|
|
||||||
export type Agent = z.infer<typeof agentSchema>
|
export type Agent = z.infer<typeof agentSchema>
|
||||||
export type IpList = z.infer<typeof ipListSchema>
|
export type IpList = z.infer<typeof ipListSchema>
|
||||||
export type PolicyRule = z.infer<typeof policyRuleSchema>
|
export type PolicyRule = z.infer<typeof policyRuleSchema>
|
||||||
@@ -189,3 +217,4 @@ export type PolicySet = z.infer<typeof policySetSchema>
|
|||||||
export type IpOverride = z.infer<typeof ipOverrideSchema>
|
export type IpOverride = z.infer<typeof ipOverrideSchema>
|
||||||
export type AgentPolicy = z.infer<typeof agentPolicySchema>
|
export type AgentPolicy = z.infer<typeof agentPolicySchema>
|
||||||
export type DashboardStats = z.infer<typeof dashboardStatsSchema>
|
export type DashboardStats = z.infer<typeof dashboardStatsSchema>
|
||||||
|
export type InstallLink = z.infer<typeof installLinkSchema>
|
||||||
|
|||||||
Reference in New Issue
Block a user