Init
Build and Push Telemt Panel Docker Image / build-and-push (push) Failing after 38s
Build and Push Telemt Panel Docker Image / create-release (push) Skipped

This commit is contained in:
Denozordec
2026-08-04 18:33:48 +07:00
commit b5f31c1083
227 changed files with 31375 additions and 0 deletions
+35
View File
@@ -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"
}
}
+114
View File
@@ -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
}
+45
View File
@@ -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,
})
}
+44
View File
@@ -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
}
+83
View File
@@ -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 }
+378
View File
@@ -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 }
})
}
+64
View File
@@ -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}`)
}
+43
View File
@@ -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)
}
+57
View File
@@ -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
}
}
}
+14
View File
@@ -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"]
}