Init
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@telemt/api",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"build": "tsup src/server.ts --format esm --dts",
|
||||
"start": "node dist/server.js",
|
||||
"bootstrap-admin": "tsx src/scripts/bootstrap-admin.ts",
|
||||
"test": "vitest run --passWithNoTests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.0.1",
|
||||
"@fastify/helmet": "^13.0.1",
|
||||
"@fastify/jwt": "^9.1.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
"@fastify/sensible": "^6.0.3",
|
||||
"@fastify/static": "^8.2.0",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@telemt/db": "workspace:*",
|
||||
"@telemt/shared": "workspace:*",
|
||||
"drizzle-orm": "^0.44.2",
|
||||
"fastify": "^5.4.0",
|
||||
"fastify-plugin": "^5.0.1",
|
||||
"zod": "^3.25.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.32",
|
||||
"tsup": "^8.5.0",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5.9.2",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import Fastify from 'fastify'
|
||||
import cors from '@fastify/cors'
|
||||
import helmet from '@fastify/helmet'
|
||||
import rateLimit from '@fastify/rate-limit'
|
||||
import sensible from '@fastify/sensible'
|
||||
import fjwt from '@fastify/jwt'
|
||||
import fastifyStatic from '@fastify/static'
|
||||
import { existsSync, mkdirSync, readFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { createDb, healthCheck, migrateSchema, type AppDb, type Sqlite } from '@telemt/db'
|
||||
import type { AppConfig } from './config.js'
|
||||
import { TelemtClient } from './services/telemt-client.js'
|
||||
import { authRoutes, ensureBootstrapAdmin } from './routes/auth.js'
|
||||
import { telemtRoutes, fleetRoutes, agentProtocolRoutes } from './routes/telemt.js'
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
config: AppConfig
|
||||
db: AppDb
|
||||
sqlite: Sqlite
|
||||
telemt: TelemtClient
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildApp(opts: {
|
||||
config: AppConfig
|
||||
databaseUrl?: string
|
||||
}): Promise<FastifyInstance> {
|
||||
const config = opts.config
|
||||
const dbUrl = opts.databaseUrl ?? config.databaseUrl
|
||||
|
||||
const path = dbUrl.replace(/^sqlite:/, '')
|
||||
if (path !== ':memory:') {
|
||||
mkdirSync(dirname(resolve(path)), { recursive: true })
|
||||
}
|
||||
|
||||
const { db, sqlite } = createDb(dbUrl)
|
||||
migrateSchema(sqlite)
|
||||
|
||||
const app = Fastify({
|
||||
logger: { level: config.logLevel },
|
||||
trustProxy: true,
|
||||
})
|
||||
|
||||
const telemt = new TelemtClient(config.telemtApiUrl, config.telemtAuthHeader)
|
||||
|
||||
app.decorate('config', config)
|
||||
app.decorate('db', db)
|
||||
app.decorate('sqlite', sqlite)
|
||||
app.decorate('telemt', telemt)
|
||||
|
||||
await app.register(sensible)
|
||||
await app.register(cors, { origin: true, credentials: true })
|
||||
await app.register(helmet, { contentSecurityPolicy: false })
|
||||
await app.register(rateLimit, { max: 300, timeWindow: '1 minute' })
|
||||
await app.register(fjwt, { secret: config.jwtSecret })
|
||||
|
||||
app.get('/health', async () => {
|
||||
healthCheck(sqlite)
|
||||
return { ok: true, panelMode: config.panelMode }
|
||||
})
|
||||
|
||||
app.get('/ready', async () => {
|
||||
healthCheck(sqlite)
|
||||
const telemtOk =
|
||||
config.panelMode === 'standalone' ? await telemt.health() : true
|
||||
return { ok: true, telemt: telemtOk, panelMode: config.panelMode }
|
||||
})
|
||||
|
||||
app.get('/api/v1/health', async () => ({
|
||||
ok: true,
|
||||
service: 'telemt-panel',
|
||||
panelMode: config.panelMode,
|
||||
}))
|
||||
|
||||
if (config.bootstrapPassword) {
|
||||
await ensureBootstrapAdmin(app, config.bootstrapUsername, config.bootstrapPassword)
|
||||
}
|
||||
|
||||
await app.register(authRoutes)
|
||||
await app.register(telemtRoutes)
|
||||
await app.register(fleetRoutes)
|
||||
await app.register(agentProtocolRoutes)
|
||||
|
||||
app.get('/install-agent.sh', async (_request, reply) => {
|
||||
const candidates = [
|
||||
resolve(process.cwd(), 'deploy/install-agent.sh'),
|
||||
resolve(import.meta.dirname, '../../../deploy/install-agent.sh'),
|
||||
'/app/deploy/install-agent.sh',
|
||||
]
|
||||
const scriptPath = candidates.find((p) => existsSync(p))
|
||||
if (!scriptPath) {
|
||||
return reply.code(404).send('install-agent.sh not found')
|
||||
}
|
||||
reply.header('Content-Type', 'text/x-shellscript')
|
||||
return readFileSync(scriptPath, 'utf-8')
|
||||
})
|
||||
|
||||
if (config.staticDir && existsSync(config.staticDir)) {
|
||||
await app.register(fastifyStatic, {
|
||||
root: config.staticDir,
|
||||
wildcard: false,
|
||||
})
|
||||
app.setNotFoundHandler((request, reply) => {
|
||||
if (request.method === 'GET' && !request.url.startsWith('/api')) {
|
||||
return reply.sendFile('index.html')
|
||||
}
|
||||
return reply.code(404).send({ error: { code: 'not_found', message: 'Not found' } })
|
||||
})
|
||||
}
|
||||
|
||||
return app
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { z } from 'zod'
|
||||
import { PANEL_MODES } from '@telemt/shared'
|
||||
|
||||
export const configSchema = z.object({
|
||||
databaseUrl: z.string().default('sqlite:data/app.db'),
|
||||
jwtSecret: z.string().min(8),
|
||||
jwtTtlHours: z.coerce.number().positive().default(24),
|
||||
issuer: z.string().default('telemt-panel'),
|
||||
serverPort: z.coerce.number().int().positive().default(8080),
|
||||
staticDir: z.string().optional(),
|
||||
logLevel: z.string().default('info'),
|
||||
isProd: z.boolean(),
|
||||
panelMode: z.enum(PANEL_MODES).default('standalone'),
|
||||
telemtApiUrl: z.string().default('http://127.0.0.1:9091'),
|
||||
telemtAuthHeader: z.string().default(''),
|
||||
panelEncryptionKey: z.string().default(''),
|
||||
panelPublicUrl: z.string().default('http://127.0.0.1:8080'),
|
||||
bootstrapUsername: z.string().default('admin'),
|
||||
bootstrapPassword: z.string().optional(),
|
||||
})
|
||||
|
||||
export type AppConfig = z.infer<typeof configSchema>
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||
const isProd = env.NODE_ENV === 'production'
|
||||
const jwtSecret = env.JWT_SECRET ?? (isProd ? '' : 'dev-secret-change-me')
|
||||
|
||||
return configSchema.parse({
|
||||
databaseUrl: env.DATABASE_URL ?? 'sqlite:data/app.db',
|
||||
jwtSecret,
|
||||
jwtTtlHours: env.JWT_TTL_HOURS ?? 24,
|
||||
issuer: env.ISSUER ?? 'telemt-panel',
|
||||
serverPort: env.SERVER_PORT ?? 8080,
|
||||
staticDir: env.STATIC_DIR || undefined,
|
||||
logLevel: env.LOG_LEVEL ?? 'info',
|
||||
isProd,
|
||||
panelMode: env.PANEL_MODE ?? 'standalone',
|
||||
telemtApiUrl: env.TELEMT_API_URL ?? 'http://127.0.0.1:9091',
|
||||
telemtAuthHeader: env.TELEMT_AUTH_HEADER ?? '',
|
||||
panelEncryptionKey: env.PANEL_ENCRYPTION_KEY ?? (isProd ? '' : 'dev-encryption-key'),
|
||||
panelPublicUrl: env.PANEL_PUBLIC_URL ?? 'http://127.0.0.1:8080',
|
||||
bootstrapUsername: env.BOOTSTRAP_USERNAME ?? 'admin',
|
||||
bootstrapPassword: env.BOOTSTRAP_PASSWORD,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { operators } from '@telemt/db'
|
||||
|
||||
export interface AuthOperator {
|
||||
id: string
|
||||
username: string
|
||||
role: string
|
||||
}
|
||||
|
||||
declare module '@fastify/jwt' {
|
||||
interface FastifyJWT {
|
||||
payload: { sub: string; username: string; role: string }
|
||||
user: { sub: string; username: string; role: string }
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireAuth(request: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
await request.jwtVerify()
|
||||
} catch {
|
||||
return reply.code(401).send({ error: { code: 'unauthorized', message: 'Требуется вход' } })
|
||||
}
|
||||
|
||||
const row = request.server.db
|
||||
.select()
|
||||
.from(operators)
|
||||
.where(eq(operators.id, request.user.sub))
|
||||
.get()
|
||||
|
||||
if (!row || row.disabled) {
|
||||
return reply.code(401).send({ error: { code: 'unauthorized', message: 'Оператор недоступен' } })
|
||||
}
|
||||
|
||||
;(request as FastifyRequest & { operator: AuthOperator }).operator = {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
role: row.role,
|
||||
}
|
||||
}
|
||||
|
||||
export function getOperator(request: FastifyRequest): AuthOperator {
|
||||
return (request as FastifyRequest & { operator: AuthOperator }).operator
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { hash, verify } from '@node-rs/argon2'
|
||||
import { loginRequestSchema } from '@telemt/shared'
|
||||
import { operators } from '@telemt/db'
|
||||
import { getOperator, requireAuth } from '../plugins/auth-guards.js'
|
||||
|
||||
function sha256(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex')
|
||||
}
|
||||
|
||||
export async function authRoutes(app: FastifyInstance) {
|
||||
app.post('/api/auth/login', async (request, reply) => {
|
||||
const parsed = loginRequestSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: { code: 'bad_request', message: 'Неверные данные' } })
|
||||
}
|
||||
|
||||
const row = app.db
|
||||
.select()
|
||||
.from(operators)
|
||||
.where(eq(operators.username, parsed.data.username))
|
||||
.get()
|
||||
|
||||
if (!row || row.disabled) {
|
||||
return reply.code(401).send({ error: { code: 'invalid_credentials', message: 'Неверный логин или пароль' } })
|
||||
}
|
||||
|
||||
const ok = await verify(row.passwordHash, parsed.data.password)
|
||||
if (!ok) {
|
||||
return reply.code(401).send({ error: { code: 'invalid_credentials', message: 'Неверный логин или пароль' } })
|
||||
}
|
||||
|
||||
const accessToken = await reply.jwtSign(
|
||||
{ sub: row.id, username: row.username, role: row.role },
|
||||
{ expiresIn: `${app.config.jwtTtlHours}h` },
|
||||
)
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
operator: { id: row.id, username: row.username, role: row.role },
|
||||
panelMode: app.config.panelMode,
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/auth/me', { preHandler: requireAuth }, async (request) => {
|
||||
const op = getOperator(request)
|
||||
return { operator: op, panelMode: app.config.panelMode }
|
||||
})
|
||||
|
||||
app.get('/api/auth/config', async () => ({
|
||||
panelMode: app.config.panelMode,
|
||||
issuer: app.config.issuer,
|
||||
}))
|
||||
}
|
||||
|
||||
export async function ensureBootstrapAdmin(
|
||||
app: FastifyInstance,
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
const existing = app.db.select().from(operators).all()
|
||||
if (existing.length > 0) return
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const passwordHash = await hash(password)
|
||||
app.db
|
||||
.insert(operators)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
username,
|
||||
passwordHash,
|
||||
role: 'admin',
|
||||
disabled: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run()
|
||||
app.log.info(`bootstrap admin created: ${username}`)
|
||||
}
|
||||
|
||||
export { sha256, randomBytes }
|
||||
@@ -0,0 +1,378 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { eq, and, asc, isNull } from 'drizzle-orm'
|
||||
import { telemtProxyRequestSchema } from '@telemt/shared'
|
||||
import { agents, jobs, enrollmentTokens, managedClients } from '@telemt/db'
|
||||
import { requireAuth, getOperator } from '../plugins/auth-guards.js'
|
||||
import { sha256, randomBytes } from './auth.js'
|
||||
|
||||
export async function telemtRoutes(app: FastifyInstance) {
|
||||
app.all('/api/telemt/*', { preHandler: requireAuth }, async (request, reply) => {
|
||||
const suffix = (request.params as { '*': string })['*']
|
||||
const path = `/v1/${suffix}`
|
||||
const method = request.method.toUpperCase()
|
||||
|
||||
if (app.config.panelMode === 'standalone') {
|
||||
const ifMatch = request.headers['if-match']
|
||||
try {
|
||||
const { status, envelope } = await app.telemt.request({
|
||||
method,
|
||||
path,
|
||||
body: method === 'GET' || method === 'DELETE' ? undefined : request.body,
|
||||
ifMatch: typeof ifMatch === 'string' ? ifMatch : undefined,
|
||||
})
|
||||
return reply.code(status).send(envelope)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Telemt unreachable'
|
||||
return reply.code(502).send({
|
||||
ok: false,
|
||||
error: { code: 'telemt_unreachable', message },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return reply.code(400).send({
|
||||
error: {
|
||||
code: 'use_server_proxy',
|
||||
message: 'В режиме fleet используйте /api/servers/:id/telemt/*',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/api/telemt/proxy', { preHandler: requireAuth }, async (request, reply) => {
|
||||
const parsed = telemtProxyRequestSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: { code: 'bad_request', message: 'Неверный запрос' } })
|
||||
}
|
||||
|
||||
if (app.config.panelMode !== 'standalone') {
|
||||
return reply.code(400).send({
|
||||
error: { code: 'fleet_mode', message: 'Proxy только для standalone' },
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const { status, envelope } = await app.telemt.request(parsed.data)
|
||||
return reply.code(status).send(envelope)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Telemt unreachable'
|
||||
return reply.code(502).send({
|
||||
ok: false,
|
||||
error: { code: 'telemt_unreachable', message },
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function fleetRoutes(app: FastifyInstance) {
|
||||
app.get('/api/servers', { preHandler: requireAuth }, async () => {
|
||||
if (app.config.panelMode === 'standalone') {
|
||||
const reachable = await app.telemt.health()
|
||||
return [
|
||||
{
|
||||
id: 'local',
|
||||
name: 'Local Telemt',
|
||||
status: reachable ? 'online' : 'offline',
|
||||
mode: 'standalone',
|
||||
},
|
||||
]
|
||||
}
|
||||
return app.db
|
||||
.select()
|
||||
.from(agents)
|
||||
.orderBy(asc(agents.name))
|
||||
.all()
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
status: r.status,
|
||||
lastSeenAt: r.lastSeenAt,
|
||||
hostname: r.hostname,
|
||||
agentVersion: r.agentVersion,
|
||||
mode: 'fleet',
|
||||
}))
|
||||
})
|
||||
|
||||
app.all('/api/servers/:id/telemt/*', { preHandler: requireAuth }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const suffix = (request.params as { id: string; '*': string })['*']
|
||||
const path = `/v1/${suffix}`
|
||||
const method = request.method.toUpperCase()
|
||||
|
||||
if (app.config.panelMode === 'standalone' || id === 'local') {
|
||||
const ifMatch = request.headers['if-match']
|
||||
try {
|
||||
const { status, envelope } = await app.telemt.request({
|
||||
method,
|
||||
path,
|
||||
body: method === 'GET' || method === 'DELETE' ? undefined : request.body,
|
||||
ifMatch: typeof ifMatch === 'string' ? ifMatch : undefined,
|
||||
})
|
||||
return reply.code(status).send(envelope)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Telemt unreachable'
|
||||
return reply.code(502).send({
|
||||
ok: false,
|
||||
error: { code: 'telemt_unreachable', message },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const agent = app.db.select().from(agents).where(eq(agents.id, id)).get()
|
||||
if (!agent) {
|
||||
return reply.code(404).send({ error: { code: 'not_found', message: 'Сервер не найден' } })
|
||||
}
|
||||
|
||||
const now = new Date().toISOString()
|
||||
const jobId = randomUUID()
|
||||
app.db
|
||||
.insert(jobs)
|
||||
.values({
|
||||
id: jobId,
|
||||
agentId: id,
|
||||
type: 'telemt.proxy',
|
||||
payloadJson: JSON.stringify({
|
||||
method,
|
||||
path,
|
||||
body: method === 'GET' || method === 'DELETE' ? undefined : request.body,
|
||||
}),
|
||||
status: 'pending',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run()
|
||||
|
||||
const deadline = Date.now() + 15_000
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 200))
|
||||
const job = app.db.select().from(jobs).where(eq(jobs.id, jobId)).get()
|
||||
if (!job) break
|
||||
if (job.status === 'done' && job.resultJson) {
|
||||
try {
|
||||
return reply.send(JSON.parse(job.resultJson))
|
||||
} catch {
|
||||
return reply.send({ ok: true, data: job.resultJson })
|
||||
}
|
||||
}
|
||||
if (job.status === 'error') {
|
||||
return reply.code(502).send({
|
||||
error: { code: 'agent_error', message: job.error ?? 'Ошибка агента' },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return reply.code(504).send({
|
||||
error: { code: 'agent_timeout', message: 'Агент не ответил вовремя', jobId },
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/api/enrollment-tokens', { preHandler: requireAuth }, async (_request, reply) => {
|
||||
if (app.config.panelMode !== 'fleet') {
|
||||
return reply.code(400).send({ error: { code: 'standalone', message: 'Только в режиме fleet' } })
|
||||
}
|
||||
return app.db
|
||||
.select()
|
||||
.from(enrollmentTokens)
|
||||
.all()
|
||||
.map((t) => ({
|
||||
id: t.id,
|
||||
label: t.label,
|
||||
expiresAt: t.expiresAt,
|
||||
usedAt: t.usedAt,
|
||||
createdAt: t.createdAt,
|
||||
}))
|
||||
})
|
||||
|
||||
app.post('/api/enrollment-tokens', { preHandler: requireAuth }, async (request, reply) => {
|
||||
if (app.config.panelMode !== 'fleet') {
|
||||
return reply.code(400).send({ error: { code: 'standalone', message: 'Только в режиме fleet' } })
|
||||
}
|
||||
const body = (request.body ?? {}) as { label?: string; ttlHours?: number }
|
||||
const token = randomBytes(24).toString('hex')
|
||||
const now = new Date()
|
||||
const expires = new Date(now.getTime() + (body.ttlHours ?? 24) * 3600_000)
|
||||
const id = randomUUID()
|
||||
app.db
|
||||
.insert(enrollmentTokens)
|
||||
.values({
|
||||
id,
|
||||
tokenHash: sha256(token),
|
||||
label: body.label ?? 'default',
|
||||
expiresAt: expires.toISOString(),
|
||||
createdAt: now.toISOString(),
|
||||
})
|
||||
.run()
|
||||
|
||||
const op = getOperator(request)
|
||||
return {
|
||||
id,
|
||||
token,
|
||||
expiresAt: expires.toISOString(),
|
||||
installCommand: `curl -fsSL ${app.config.panelPublicUrl}/install-agent.sh | sudo bash -s -- --panel-url ${app.config.panelPublicUrl} --token ${token}`,
|
||||
createdBy: op.username,
|
||||
}
|
||||
})
|
||||
|
||||
app.get('/api/clients', { preHandler: requireAuth }, async () => {
|
||||
return app.db.select().from(managedClients).all()
|
||||
})
|
||||
|
||||
app.post('/api/clients', { preHandler: requireAuth }, async (request, reply) => {
|
||||
const body = (request.body ?? {}) as { username?: string; secret?: string }
|
||||
if (!body.username) {
|
||||
return reply.code(400).send({ error: { code: 'bad_request', message: 'username обязателен' } })
|
||||
}
|
||||
const now = new Date().toISOString()
|
||||
const id = randomUUID()
|
||||
app.db
|
||||
.insert(managedClients)
|
||||
.values({
|
||||
id,
|
||||
username: body.username,
|
||||
secret: body.secret ?? null,
|
||||
metaJson: '{}',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run()
|
||||
|
||||
if (app.config.panelMode === 'standalone') {
|
||||
const { status, envelope } = await app.telemt.request({
|
||||
method: 'POST',
|
||||
path: '/v1/users',
|
||||
body: { username: body.username, ...(body.secret ? { secret: body.secret } : {}) },
|
||||
})
|
||||
return reply.code(status).send({ local: { id, username: body.username }, telemt: envelope })
|
||||
}
|
||||
|
||||
return { id, username: body.username }
|
||||
})
|
||||
}
|
||||
|
||||
export async function agentProtocolRoutes(app: FastifyInstance) {
|
||||
app.post('/api/agent/enroll', async (request, reply) => {
|
||||
if (app.config.panelMode !== 'fleet') {
|
||||
return reply.code(400).send({ error: { code: 'standalone', message: 'Enrollment только в fleet' } })
|
||||
}
|
||||
const body = (request.body ?? {}) as {
|
||||
token?: string
|
||||
name?: string
|
||||
hostname?: string
|
||||
agentVersion?: string
|
||||
}
|
||||
if (!body.token) {
|
||||
return reply.code(400).send({ error: { code: 'bad_request', message: 'token обязателен' } })
|
||||
}
|
||||
|
||||
const tokenHash = sha256(body.token)
|
||||
const now = new Date().toISOString()
|
||||
const row = app.db
|
||||
.select()
|
||||
.from(enrollmentTokens)
|
||||
.where(and(eq(enrollmentTokens.tokenHash, tokenHash), isNull(enrollmentTokens.usedAt)))
|
||||
.get()
|
||||
|
||||
if (!row || row.expiresAt < now) {
|
||||
return reply.code(401).send({ error: { code: 'invalid_token', message: 'Токен недействителен' } })
|
||||
}
|
||||
|
||||
const agentId = randomUUID()
|
||||
const agentToken = randomBytes(32).toString('hex')
|
||||
app.db
|
||||
.insert(agents)
|
||||
.values({
|
||||
id: agentId,
|
||||
name: body.name ?? body.hostname ?? `agent-${agentId.slice(0, 8)}`,
|
||||
status: 'online',
|
||||
tokenHash: sha256(agentToken),
|
||||
lastSeenAt: now,
|
||||
agentVersion: body.agentVersion ?? null,
|
||||
hostname: body.hostname ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run()
|
||||
|
||||
app.db
|
||||
.update(enrollmentTokens)
|
||||
.set({ usedAt: now })
|
||||
.where(eq(enrollmentTokens.id, row.id))
|
||||
.run()
|
||||
|
||||
return { agentId, agentToken, panelUrl: app.config.panelPublicUrl }
|
||||
})
|
||||
|
||||
app.get('/api/agent/jobs', async (request, reply) => {
|
||||
const auth = request.headers.authorization ?? ''
|
||||
const token = auth.startsWith('Bearer ') ? auth.slice(7) : ''
|
||||
if (!token) return reply.code(401).send({ error: { code: 'unauthorized' } })
|
||||
|
||||
const agent = app.db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(eq(agents.tokenHash, sha256(token)))
|
||||
.get()
|
||||
if (!agent) return reply.code(401).send({ error: { code: 'unauthorized' } })
|
||||
|
||||
const now = new Date().toISOString()
|
||||
app.db
|
||||
.update(agents)
|
||||
.set({ lastSeenAt: now, status: 'online', updatedAt: now })
|
||||
.where(eq(agents.id, agent.id))
|
||||
.run()
|
||||
|
||||
const pending = app.db
|
||||
.select()
|
||||
.from(jobs)
|
||||
.where(and(eq(jobs.agentId, agent.id), eq(jobs.status, 'pending')))
|
||||
.all()
|
||||
|
||||
for (const j of pending) {
|
||||
app.db
|
||||
.update(jobs)
|
||||
.set({ status: 'running', updatedAt: now })
|
||||
.where(eq(jobs.id, j.id))
|
||||
.run()
|
||||
}
|
||||
|
||||
return pending.map((j) => ({
|
||||
id: j.id,
|
||||
type: j.type,
|
||||
payload: JSON.parse(j.payloadJson || '{}'),
|
||||
}))
|
||||
})
|
||||
|
||||
app.post('/api/agent/jobs/:id/result', async (request, reply) => {
|
||||
const auth = request.headers.authorization ?? ''
|
||||
const token = auth.startsWith('Bearer ') ? auth.slice(7) : ''
|
||||
if (!token) return reply.code(401).send({ error: { code: 'unauthorized' } })
|
||||
|
||||
const agent = app.db
|
||||
.select()
|
||||
.from(agents)
|
||||
.where(eq(agents.tokenHash, sha256(token)))
|
||||
.get()
|
||||
if (!agent) return reply.code(401).send({ error: { code: 'unauthorized' } })
|
||||
|
||||
const { id } = request.params as { id: string }
|
||||
const job = app.db.select().from(jobs).where(eq(jobs.id, id)).get()
|
||||
if (!job || job.agentId !== agent.id) {
|
||||
return reply.code(404).send({ error: { code: 'not_found' } })
|
||||
}
|
||||
|
||||
const body = (request.body ?? {}) as { ok?: boolean; result?: unknown; error?: string }
|
||||
const now = new Date().toISOString()
|
||||
app.db
|
||||
.update(jobs)
|
||||
.set({
|
||||
status: body.ok === false ? 'error' : 'done',
|
||||
resultJson: body.result !== undefined ? JSON.stringify(body.result) : null,
|
||||
error: body.error ?? null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(jobs.id, id))
|
||||
.run()
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { hash } from '@node-rs/argon2'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { createDb, migrateSchema, operators } from '@telemt/db'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { loadConfig } from '../config.js'
|
||||
|
||||
for (const path of [resolve(import.meta.dirname, '../../../../.env'), '.env']) {
|
||||
if (!existsSync(path)) continue
|
||||
const content = readFileSync(path, 'utf-8')
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith('#')) continue
|
||||
const eqIdx = trimmed.indexOf('=')
|
||||
if (eqIdx === -1) continue
|
||||
const key = trimmed.slice(0, eqIdx).trim()
|
||||
let value = trimmed.slice(eqIdx + 1).trim()
|
||||
if (!(key in process.env)) process.env[key] = value
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
function flag(name: string): string | undefined {
|
||||
const i = args.indexOf(`--${name}`)
|
||||
return i >= 0 ? args[i + 1] : undefined
|
||||
}
|
||||
|
||||
const username = flag('username') ?? process.env.BOOTSTRAP_USERNAME ?? 'admin'
|
||||
const password = flag('password') ?? process.env.BOOTSTRAP_PASSWORD
|
||||
if (!password) {
|
||||
console.error('Usage: pnpm --filter api bootstrap-admin -- --username admin --password <pass>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const config = loadConfig()
|
||||
const { db, sqlite } = createDb(config.databaseUrl)
|
||||
migrateSchema(sqlite)
|
||||
|
||||
const existing = db.select().from(operators).where(eq(operators.username, username)).get()
|
||||
const now = new Date().toISOString()
|
||||
const passwordHash = await hash(password)
|
||||
|
||||
if (existing) {
|
||||
db.update(operators)
|
||||
.set({ passwordHash, updatedAt: now, disabled: false })
|
||||
.where(eq(operators.id, existing.id))
|
||||
.run()
|
||||
console.log(`updated operator: ${username}`)
|
||||
} else {
|
||||
db.insert(operators)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
username,
|
||||
passwordHash,
|
||||
role: 'admin',
|
||||
disabled: false,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.run()
|
||||
console.log(`created operator: ${username}`)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { readFileSync, existsSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { buildApp } from './app.js'
|
||||
import { loadConfig } from './config.js'
|
||||
|
||||
for (const path of [
|
||||
resolve(import.meta.dirname, '../../../.env'),
|
||||
resolve(import.meta.dirname, '../../../.env.local'),
|
||||
'.env',
|
||||
'../.env',
|
||||
]) {
|
||||
if (!existsSync(path)) continue
|
||||
const content = readFileSync(path, 'utf-8')
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith('#')) continue
|
||||
const eq = trimmed.indexOf('=')
|
||||
if (eq === -1) continue
|
||||
const key = trimmed.slice(0, eq).trim()
|
||||
let value = trimmed.slice(eq + 1).trim()
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1)
|
||||
}
|
||||
if (!(key in process.env)) process.env[key] = value
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
const config = loadConfig()
|
||||
const app = await buildApp({ config })
|
||||
|
||||
try {
|
||||
await app.listen({ port: config.serverPort, host: '0.0.0.0' })
|
||||
app.log.info(
|
||||
`telemt-panel listening on ${config.serverPort} (mode=${config.panelMode})`,
|
||||
)
|
||||
} catch (err) {
|
||||
app.log.error(err)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
export interface TelemtEnvelope<T = unknown> {
|
||||
ok: boolean
|
||||
data?: T
|
||||
revision?: string
|
||||
error?: { code: string; message: string }
|
||||
request_id?: number
|
||||
}
|
||||
|
||||
export class TelemtClient {
|
||||
constructor(
|
||||
private readonly baseUrl: string,
|
||||
private readonly authHeader: string,
|
||||
) {}
|
||||
|
||||
async request<T = unknown>(opts: {
|
||||
method?: string
|
||||
path: string
|
||||
body?: unknown
|
||||
ifMatch?: string
|
||||
}): Promise<{ status: number; envelope: TelemtEnvelope<T>; raw: string }> {
|
||||
const path = opts.path.startsWith('/') ? opts.path : `/${opts.path}`
|
||||
const url = `${this.baseUrl.replace(/\/$/, '')}${path}`
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
}
|
||||
if (this.authHeader) headers.Authorization = this.authHeader
|
||||
if (opts.body !== undefined) headers['Content-Type'] = 'application/json'
|
||||
if (opts.ifMatch) headers['If-Match'] = opts.ifMatch
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: opts.method ?? 'GET',
|
||||
headers,
|
||||
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
||||
})
|
||||
|
||||
const raw = await res.text()
|
||||
let envelope: TelemtEnvelope<T>
|
||||
try {
|
||||
envelope = JSON.parse(raw) as TelemtEnvelope<T>
|
||||
} catch {
|
||||
envelope = {
|
||||
ok: false,
|
||||
error: { code: 'bad_response', message: raw.slice(0, 200) || res.statusText },
|
||||
}
|
||||
}
|
||||
return { status: res.status, envelope, raw }
|
||||
}
|
||||
|
||||
async health(): Promise<boolean> {
|
||||
try {
|
||||
const { status, envelope } = await this.request({ path: '/v1/health' })
|
||||
return status === 200 && envelope.ok === true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["node"],
|
||||
"lib": ["ES2022"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user