From ef56da4d915891d547640a25244ee34461a42af2 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 21 Jul 2026 01:25:05 +0700 Subject: [PATCH] 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 --- README.md | 10 +- apps/api/src/app.ts | 38 +++- apps/api/src/routes/agent.ts | 16 ++ apps/api/src/routes/control.ts | 40 ++++ apps/api/src/services/install-links.test.ts | 62 ++++++ apps/api/src/services/install-links.ts | 127 +++++++++++ .../src/components/agents/add-agent-sheet.tsx | 206 ++++++++++++++++++ apps/web/src/routes/_auth/agents/index.tsx | 150 ++++--------- docs/agents.md | 16 +- packages/db/migrations/003_install_links.sql | 19 ++ packages/db/src/repositories/index.ts | 60 +++++ packages/db/src/schema.ts | 21 ++ packages/shared/src/contracts.ts | 29 +++ 13 files changed, 677 insertions(+), 117 deletions(-) create mode 100644 apps/api/src/services/install-links.test.ts create mode 100644 apps/api/src/services/install-links.ts create mode 100644 apps/web/src/components/agents/add-agent-sheet.tsx create mode 100644 packages/db/migrations/003_install_links.sql diff --git a/README.md b/README.md index a4a9975..9b2e2e5 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,15 @@ pnpm --filter @evofw/api dev # :8080 pnpm --filter @evofw/web dev # :5177 ``` -Linux agent: +Linux agent (short link из UI `/agents` → Добавить агента): + +```bash +curl -fsSL http://localhost:8080/agent-install/ | bash +# или: +curl -fsSL http://localhost:8080/ | bash +``` + +Legacy one-liner: ```bash curl -fsSL http://localhost:8080/v1/agent/install.sh | \ diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 70d1697..265eded 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -17,6 +17,10 @@ import { controlRoutes } from './routes/control.js' import { agentRoutes } from './routes/agent.js' import { refreshAllLists } from './services/lists/refresh.js' import { repos } from '@evofw/db' +import { + isValidInstallSlug, + resolveAndRenderInstallScript, +} from './services/install-links.js' export interface BuildAppOptions { config?: AppConfig @@ -68,11 +72,39 @@ export async function buildApp(opts: BuildAppOptions = {}) { root: staticDir, 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) { await app.register(import('@fastify/schedule')) const task = new AsyncTask( diff --git a/apps/api/src/routes/agent.ts b/apps/api/src/routes/agent.ts index 00b3c94..2dc463a 100644 --- a/apps/api/src/routes/agent.ts +++ b/apps/api/src/routes/agent.ts @@ -8,6 +8,7 @@ import type { AppConfig } from '../config.js' import { hashToken } from '../plugins/auth.js' import { evaluateAgentPolicy } from '../services/policy/evaluate.js' import { AppError } from '../plugins/error-handler.js' +import { resolveAndRenderInstallScript } from '../services/install-links.js' const __dirname = dirname(fileURLToPath(import.meta.url)) 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) }) + 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) => { const body = readFileSync(join(scriptsDir, 'evofw-firewall.sh'), 'utf-8') return reply.type('text/x-shellscript').send(body) diff --git a/apps/api/src/routes/control.ts b/apps/api/src/routes/control.ts index 4d3defb..48540c6 100644 --- a/apps/api/src/routes/control.ts +++ b/apps/api/src/routes/control.ts @@ -5,6 +5,7 @@ import { createIpListBodySchema, createPolicyRuleBodySchema, createPolicySetBodySchema, + createInstallLinkBodySchema, patchPolicySetBodySchema, putAgentPolicySetsBodySchema, patchAgentBodySchema, @@ -25,6 +26,10 @@ import { resolveAndStoreHostnameRule, resolveHostnameToCidrs, } from '../services/policy/resolve-hostname.js' +import { + mapInstallLink, + randomToken, +} from '../services/install-links.js' import type { AppConfig } from '../config.js' function mapAgent(a: NonNullable>) { @@ -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 app.get('/agents', async () => ({ items: repos.listAgents(app.db).map(mapAgent), diff --git a/apps/api/src/services/install-links.test.ts b/apps/api/src/services/install-links.test.ts new file mode 100644 index 0000000..7e8aa7b --- /dev/null +++ b/apps/api/src/services/install-links.test.ts @@ -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'") + }) +}) diff --git a/apps/api/src/services/install-links.ts b/apps/api/src/services/install-links.ts new file mode 100644 index 0000000..0c115f1 --- /dev/null +++ b/apps/api/src/services/install-links.ts @@ -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>, + 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>, + 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, + }) +} diff --git a/apps/web/src/components/agents/add-agent-sheet.tsx b/apps/web/src/components/agents/add-agent-sheet.tsx new file mode 100644 index 0000000..9e3f14f --- /dev/null +++ b/apps/web/src/components/agents/add-agent-sheet.tsx @@ -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('linux') + const [created, setCreated] = useState(null) + + useEffect(() => { + if (!open) { + setCreated(null) + setName('web-01') + setPlatform('linux') + } + }, [open]) + + const create = useMutation({ + mutationFn: () => + apiFetch('/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 ( + + + + + {created ? 'Команда установки' : 'Добавить агента'} + + + {created + ? 'Скопируйте one-liner и выполните на хосте. Затем одобрите агента в списке.' + : 'Создайте короткую install-ссылку с именем клиента.'} + + + + +
+ {!created ? ( + <> + + Имя клиента + setName(e.target.value)} + placeholder="web-01" + /> + + + Платформа + + + + ) : ( + <> + + По id +
+
+                      {created.curl?.by_id}
+                    
+ +
+
+ + Короткий slug +
+
+                      {created.curl?.by_slug}
+                    
+ +
+
+ + )} +
+
+ + + {created ? ( + <> + + + + ) : ( + <> + + + + )} + +
+
+ ) +} diff --git a/apps/web/src/routes/_auth/agents/index.tsx b/apps/web/src/routes/_auth/agents/index.tsx index 2316365..a7bccd0 100644 --- a/apps/web/src/routes/_auth/agents/index.tsx +++ b/apps/web/src/routes/_auth/agents/index.tsx @@ -1,7 +1,7 @@ import { createFileRoute, Link } from '@tanstack/react-router' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' 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 type { ColumnDef } from '@tanstack/react-table' import type { Filter, FilterFieldConfig } from '@/components/reui/filters' @@ -10,13 +10,6 @@ import { PageShell, ResourcePage, } 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 { DataGridMutedCell, @@ -24,13 +17,19 @@ import { } from '@/components/data-grid-cell' import { StatusBadge } from '@/components/status-badge' 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 { 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' +/** + * 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/')({ component: AgentsPage, }) @@ -38,8 +37,7 @@ export const Route = createFileRoute('/_auth/agents/')({ function AgentsPage() { const qc = useQueryClient() const agentsQ = useQuery(agentsQueryOptions()) - const installQ = useQuery(installContextQueryOptions()) - const [name, setName] = useState('web-01') + const [createOpen, setCreateOpen] = useState(false) const [filters, setFilters] = useState([]) const [activeTab, setActiveTab] = useState('all') const [deleteId, setDeleteId] = useState(null) @@ -61,6 +59,7 @@ function AgentsPage() { toast.success('Агент отозван') void qc.invalidateQueries({ queryKey: ['agents'] }) }, + onError: (e: Error) => toast.error(e.message), }) const remove = useMutation({ @@ -71,16 +70,10 @@ function AgentsPage() { setDeleteId(null) 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 ?? '' - return `curl -fsSL ${cp}/v1/agent/install.sh | \\\n EVOFW_CP_URL=${cp} \\\n EVOFW_SEED=${seed} \\\n EVOFW_CLIENT_NAME="${name}" \\\n bash` - }, [installQ.data, name]) - const items = agentsQ.data?.items ?? [] - const pending = items.filter((a) => a.status === 'pending') const filterFields: FilterFieldConfig[] = useMemo( () => [ @@ -184,6 +177,16 @@ function AgentsPage() { const a = row.original return (
+ {a.status === 'pending' ? ( + + ) : null} ) return ( - - -
- Установка Linux - - One-liner. После enroll одобрите агента ниже. - -
-
- -
- - setName(e.target.value)} - /> -
-
-            {installCmd}
-          
- - {installQ.data?.mikrotik_url ? ( -

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

- ) : null} -
- - - {pending.length > 0 ? ( - - - Запросы ({pending.length}) - - -
- {pending.map((a) => ( -
-
- -
-
- - -
-
- ))} -
-
- - ) : null} - void agentsQ.refetch()} emptyState={{ title: 'Нет агентов', - description: 'Установите agent на сервер и одобрите запрос.', + description: + 'Создайте install-ссылку, выполните curl на хосте и одобрите запрос.', + action: addButton, }} /> + + { diff --git a/docs/agents.md b/docs/agents.md index 42bbffc..e6dd005 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -1,6 +1,20 @@ # Agents -## Linux +## Short install (рекомендуется) + +В UI `/agents` → **Добавить агента** создаёт install-ссылку. На хосте: + +```bash +curl -fsSL https:///agent-install/ | bash +# или короткий slug: +curl -fsSL https:/// | 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 curl -fsSL https:///v1/agent/install.sh | \ diff --git a/packages/db/migrations/003_install_links.sql b/packages/db/migrations/003_install_links.sql new file mode 100644 index 0000000..3276d04 --- /dev/null +++ b/packages/db/migrations/003_install_links.sql @@ -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); diff --git a/packages/db/src/repositories/index.ts b/packages/db/src/repositories/index.ts index 5625859..c5670c7 100644 --- a/packages/db/src/repositories/index.ts +++ b/packages/db/src/repositories/index.ts @@ -10,6 +10,7 @@ import { policyRuleResolved, ipOverrides, agentStatsSamples, + agentInstallLinks, settings, SHARED_POLICY_SET_ID, } from '../schema.js' @@ -434,6 +435,59 @@ export function cloneRulesFrom( 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 = { listAgents, getAgent, @@ -481,6 +535,12 @@ export const repos = { setSetting, listSettings, cloneRulesFrom, + listInstallLinks, + getInstallLink, + getInstallLinkBySlug, + insertInstallLink, + revokeInstallLink, + touchInstallLink, } export { SHARED_POLICY_SET_ID } diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index d749d6d..5594575 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -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 schema = { @@ -207,4 +227,5 @@ export const schema = { policyRuleResolved, ipOverrides, agentStatsSamples, + agentInstallLinks, } diff --git a/packages/shared/src/contracts.ts b/packages/shared/src/contracts.ts index 6ca410c..fbf2b49 100644 --- a/packages/shared/src/contracts.ts +++ b/packages/shared/src/contracts.ts @@ -182,6 +182,34 @@ export const dashboardStatsSchema = z.object({ 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 export type IpList = z.infer export type PolicyRule = z.infer @@ -189,3 +217,4 @@ export type PolicySet = z.infer export type IpOverride = z.infer export type AgentPolicy = z.infer export type DashboardStats = z.infer +export type InstallLink = z.infer