Initial LisgloSIPS V2 implementation

This commit is contained in:
hectorzhao
2026-06-22 10:56:38 +08:00
commit 5fa1bd35e9
303 changed files with 35644 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@lisglosips/worker-recording",
"version": "0.2.0",
"private": true,
"type": "module",
"main": "dist/main.js",
"scripts": {
"dev": "cross-env LISGLOSIPS_SERVICE_NAME=worker-recording tsx watch src/main.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/main.js"
},
"dependencies": {
"@lisglosips/database": "workspace:*",
"@lisglosips/observability": "workspace:*"
},
"devDependencies": {
"cross-env": "10.1.0"
}
}
+70
View File
@@ -0,0 +1,70 @@
import { createLogger } from '@lisglosips/observability';
import { PrismaClient } from '@lisglosips/database';
import { PrismaRecordingStore, RecordingTransferService, SshRecordingClient } from './transfer.js';
const serviceName = process.env.LISGLOSIPS_SERVICE_NAME ?? 'worker-recording';
const logger = createLogger(serviceName, process.env.LISGLOSIPS_LOG_LEVEL ?? 'info');
const remoteHost = process.env.RECORDING_REMOTE_HOST ?? 'lisglosips-a';
const sshConfig = process.env.RECORDING_SSH_CONFIG;
const remoteReadyDir = process.env.RECORDING_REMOTE_READY_DIR ?? '/dev/shm/voip_rec/ready';
const localRoot = process.env.RECORDING_LOCAL_ROOT ?? '/data/recordings';
const scanIntervalMs = Number.parseInt(process.env.RECORDING_SCAN_INTERVAL_MS ?? '10000', 10);
const maxFilesPerScan = Number.parseInt(process.env.RECORDING_MAX_FILES_PER_SCAN ?? '50', 10);
const deleteSourceAfterCopy = process.env.RECORDING_DELETE_SOURCE_AFTER_COPY === 'true';
let shuttingDown = false;
async function main(): Promise<void> {
const prisma = new PrismaClient();
const remote = new SshRecordingClient({ host: remoteHost, sshConfig, remoteReadyDir });
const service = new RecordingTransferService(remote, new PrismaRecordingStore(prisma), {
localRoot,
deleteSourceAfterCopy,
maxFilesPerScan
});
await prisma.$connect();
logger.info(
{
remoteHost,
remoteReadyDir,
localRoot,
scanIntervalMs,
maxFilesPerScan,
deleteSourceAfterCopy
},
'Recording worker started'
);
while (!shuttingDown) {
const result = await service.scanOnce();
if (result.moved || result.failed || result.skipped) {
logger.info(result, 'Recording scan completed');
}
await sleep(scanIntervalMs);
}
await prisma.$disconnect();
}
process.on('SIGTERM', () => {
logger.info('Recording worker stopping');
shuttingDown = true;
});
process.on('SIGINT', () => {
logger.info('Recording worker stopping');
shuttingDown = true;
});
main().catch((error: unknown) => {
logger.error({ error }, 'Recording worker failed');
process.exit(1);
});
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
+115
View File
@@ -0,0 +1,115 @@
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import {
callIdFromStorageKey,
normalizeRemoteRelativePath,
type PulledRecordingFile,
type RecordingStore,
RecordingTransferService,
type RemoteRecordingClient,
type RemoteRecordingFile,
storageKeyFromReadyPath
} from './transfer.js';
class MemoryRemote implements RemoteRecordingClient {
readonly deleted: string[] = [];
constructor(private readonly files: Map<string, Buffer>) {}
async listReadyFiles(): Promise<RemoteRecordingFile[]> {
return Array.from(this.files.entries()).map(([relativePath, content]) => ({
relativePath,
bytes: content.length
}));
}
async pullFile(file: RemoteRecordingFile, localPath: string): Promise<void> {
const content = this.files.get(file.relativePath);
if (!content) {
throw new Error('missing remote file');
}
await writeFile(localPath, content);
}
async deleteFile(file: RemoteRecordingFile): Promise<void> {
this.deleted.push(file.relativePath);
this.files.delete(file.relativePath);
}
}
class MemoryStore implements RecordingStore {
readonly ready: PulledRecordingFile[] = [];
async markReady(input: { storageKey: string; storagePath: string; sourceKey: string; sha256: string; bytes: bigint }): Promise<void> {
this.ready.push({
relativePath: input.sourceKey,
bytes: Number(input.bytes),
localPath: input.storagePath,
sha256: input.sha256
});
}
}
describe('recording transfer', () => {
it('normalizes ready paths safely', () => {
expect(normalizeRemoteRelativePath('/2026/06/21/call.wav.ready')).toBe('2026/06/21/call.wav.ready');
expect(storageKeyFromReadyPath('2026/06/21/call.wav.ready')).toBe('2026/06/21/call.wav');
expect(() => normalizeRemoteRelativePath('../call.wav.ready')).toThrow(/Unsafe/);
expect(() => storageKeyFromReadyPath('2026/06/21/call.wav')).toThrow(/ready/);
});
it('derives call IDs from RTPEngine recording names', () => {
expect(callIdFromStorageKey('2026/06/22/s28-1782091361841-kntoq1y9%40lisglosips-t-c68d7ba1de875f62-mix.wav')).toBe(
's28-1782091361841-kntoq1y9@lisglosips-t'
);
});
it('copies, hashes, marks ready, and deletes source only after verification', async () => {
const root = await mkdtemp(path.join(os.tmpdir(), 'lisglosips-rec-'));
const remote = new MemoryRemote(new Map([['2026/06/21/call.wav.ready', Buffer.from('audio-bytes')]]));
const store = new MemoryStore();
const service = new RecordingTransferService(remote, store, {
localRoot: root,
deleteSourceAfterCopy: true,
maxFilesPerScan: 10
});
try {
const result = await service.scanOnce();
expect(result).toEqual({ moved: 1, skipped: 0, failed: 0 });
expect(store.ready).toHaveLength(1);
expect(store.ready[0]?.sha256).toMatch(/^[a-f0-9]{64}$/);
expect(remote.deleted).toEqual(['2026/06/21/call.wav.ready']);
expect(await readFile(path.join(root, '2026/06/21/call.wav'), 'utf8')).toBe('audio-bytes');
} finally {
await rm(root, { recursive: true, force: true });
}
});
it('keeps source file when local size verification fails', async () => {
const root = await mkdtemp(path.join(os.tmpdir(), 'lisglosips-rec-'));
const remote = new MemoryRemote(new Map([['bad.wav.ready', Buffer.from('abc')]]));
const originalList = remote.listReadyFiles.bind(remote);
remote.listReadyFiles = async () => (await originalList()).map((file) => ({ ...file, bytes: file.bytes + 1 }));
const store = new MemoryStore();
const service = new RecordingTransferService(remote, store, {
localRoot: root,
deleteSourceAfterCopy: true,
maxFilesPerScan: 10
});
try {
const result = await service.scanOnce();
expect(result).toEqual({ moved: 0, skipped: 0, failed: 1 });
expect(store.ready).toHaveLength(0);
expect(remote.deleted).toHaveLength(0);
await expect(stat(path.join(root, 'bad.wav'))).rejects.toThrow();
} finally {
await rm(root, { recursive: true, force: true });
}
});
});
+274
View File
@@ -0,0 +1,274 @@
import { createHash, randomUUID } from 'node:crypto';
import { createReadStream, createWriteStream } from 'node:fs';
import { mkdir, rename, rm, stat } from 'node:fs/promises';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { pipeline } from 'node:stream/promises';
import { PrismaClient } from '@lisglosips/database';
export interface RemoteRecordingFile {
relativePath: string;
bytes: number;
}
export interface PulledRecordingFile extends RemoteRecordingFile {
localPath: string;
sha256: string;
}
export interface RemoteRecordingClient {
listReadyFiles(): Promise<RemoteRecordingFile[]>;
pullFile(file: RemoteRecordingFile, localPath: string): Promise<void>;
deleteFile(file: RemoteRecordingFile): Promise<void>;
}
export interface RecordingStore {
markReady(input: {
storageKey: string;
storagePath: string;
sourceKey: string;
sha256: string;
bytes: bigint;
}): Promise<void>;
}
export interface RecordingTransferOptions {
localRoot: string;
deleteSourceAfterCopy: boolean;
maxFilesPerScan: number;
}
export interface RecordingTransferResult {
moved: number;
skipped: number;
failed: number;
}
export class RecordingTransferService {
constructor(
private readonly remote: RemoteRecordingClient,
private readonly store: RecordingStore,
private readonly options: RecordingTransferOptions
) {}
async scanOnce(): Promise<RecordingTransferResult> {
const files = (await this.remote.listReadyFiles()).slice(0, this.options.maxFilesPerScan);
const result: RecordingTransferResult = { moved: 0, skipped: 0, failed: 0 };
for (const file of files) {
try {
await this.moveOne(file);
result.moved += 1;
} catch {
result.failed += 1;
}
}
return result;
}
private async moveOne(file: RemoteRecordingFile): Promise<void> {
const sourceKey = normalizeRemoteRelativePath(file.relativePath);
const storageKey = storageKeyFromReadyPath(sourceKey);
const finalPath = safeJoin(this.options.localRoot, storageKey);
const tmpPath = `${finalPath}.part-${randomUUID()}`;
await mkdir(path.dirname(finalPath), { recursive: true });
await this.remote.pullFile({ ...file, relativePath: sourceKey }, tmpPath);
const localStat = await stat(tmpPath);
if (localStat.size !== file.bytes) {
await rm(tmpPath, { force: true });
throw new Error(`Recording size mismatch for ${sourceKey}`);
}
const sha256 = await sha256File(tmpPath);
await rename(tmpPath, finalPath);
await this.store.markReady({
storageKey,
storagePath: finalPath,
sourceKey,
sha256,
bytes: BigInt(localStat.size)
});
if (this.options.deleteSourceAfterCopy) {
await this.remote.deleteFile({ ...file, relativePath: sourceKey });
}
}
}
export class PrismaRecordingStore implements RecordingStore {
constructor(private readonly prisma: PrismaClient) {}
async markReady(input: {
storageKey: string;
storagePath: string;
sourceKey: string;
sha256: string;
bytes: bigint;
}): Promise<void> {
const callId = callIdFromStorageKey(input.storageKey);
const rawCdr = await this.prisma.rawCdr.findFirst({
where: {
OR: [{ recordingKey: input.storageKey }, { recordingKey: input.sourceKey }, ...(callId ? [{ callId }] : [])]
},
orderBy: { createdAt: 'desc' },
select: { id: true }
});
await this.prisma.recording.upsert({
where: { storageKey: input.storageKey },
create: {
id: prefixedId('rec'),
rawCdrId: rawCdr?.id,
storageKey: input.storageKey,
storagePath: input.storagePath,
sha256: input.sha256,
bytes: input.bytes,
durationSec: 0,
status: 'READY',
movedAt: new Date()
},
update: {
rawCdrId: rawCdr?.id,
storagePath: input.storagePath,
sha256: input.sha256,
bytes: input.bytes,
status: 'READY',
movedAt: new Date()
}
});
}
}
export interface SshRecordingClientOptions {
host: string;
sshConfig?: string;
remoteReadyDir: string;
}
export class SshRecordingClient implements RemoteRecordingClient {
constructor(private readonly options: SshRecordingClientOptions) {}
async listReadyFiles(): Promise<RemoteRecordingFile[]> {
const command = `find ${shellQuote(this.options.remoteReadyDir)} -type f -name '*.ready' -printf '%P\\t%s\\n'`;
const stdout = await runCapture(this.sshArgs(command));
return stdout
.split('\n')
.filter(Boolean)
.map((line) => {
const [relativePath, sizeText] = line.split('\t');
return {
relativePath: normalizeRemoteRelativePath(relativePath),
bytes: Number.parseInt(sizeText, 10)
};
})
.filter((file) => Number.isSafeInteger(file.bytes) && file.bytes >= 0);
}
async pullFile(file: RemoteRecordingFile, localPath: string): Promise<void> {
const remotePath = remotePathFor(this.options.remoteReadyDir, file.relativePath);
const command = `cat -- ${shellQuote(remotePath)}`;
await pipeline(runStream(this.sshArgs(command)), createWriteStream(localPath, { mode: 0o640 }));
}
async deleteFile(file: RemoteRecordingFile): Promise<void> {
const remotePath = remotePathFor(this.options.remoteReadyDir, file.relativePath);
await runCapture(this.sshArgs(`rm -- ${shellQuote(remotePath)}`));
}
private sshArgs(command: string): string[] {
const args = [];
if (this.options.sshConfig) {
args.push('-F', this.options.sshConfig);
}
args.push(this.options.host, command);
return args;
}
}
export function storageKeyFromReadyPath(relativePath: string): string {
const normalized = normalizeRemoteRelativePath(relativePath);
if (!normalized.endsWith('.ready')) {
throw new Error('Recording source must end with .ready');
}
return normalized.slice(0, -'.ready'.length);
}
export function callIdFromStorageKey(storageKey: string): string | null {
const filename = normalizeRemoteRelativePath(storageKey).split('/').at(-1);
if (!filename) {
return null;
}
const decoded = decodeURIComponent(filename);
const match = /^(?<callId>.+)-[a-f0-9]{16}-(?:mix|tag-\d+)\.(?:wav|mp3)$/i.exec(decoded);
return match?.groups?.callId ?? null;
}
export function normalizeRemoteRelativePath(relativePath: string): string {
const normalized = relativePath.replaceAll('\\', '/').replace(/^\/+/, '');
const parts = normalized.split('/').filter(Boolean);
if (parts.length === 0 || parts.some((part) => part === '.' || part === '..' || part.includes('\0'))) {
throw new Error('Unsafe recording relative path');
}
return parts.join('/');
}
function safeJoin(root: string, relativePath: string): string {
const resolvedRoot = path.resolve(root);
const resolvedPath = path.resolve(resolvedRoot, relativePath);
if (resolvedPath !== resolvedRoot && !resolvedPath.startsWith(`${resolvedRoot}${path.sep}`)) {
throw new Error('Recording path escapes local root');
}
return resolvedPath;
}
function remotePathFor(baseDir: string, relativePath: string): string {
return `${baseDir.replace(/\/+$/, '')}/${normalizeRemoteRelativePath(relativePath)}`;
}
async function sha256File(filePath: string): Promise<string> {
const hash = createHash('sha256');
await pipeline(createReadStream(filePath), hash);
return hash.digest('hex');
}
function runCapture(args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn('ssh', args, { stdio: ['ignore', 'pipe', 'pipe'] });
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk));
child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk));
child.on('error', reject);
child.on('close', (code) => {
if (code === 0) {
resolve(Buffer.concat(stdout).toString('utf8'));
return;
}
reject(new Error(Buffer.concat(stderr).toString('utf8') || `ssh exited with ${code}`));
});
});
}
function runStream(args: string[]): NodeJS.ReadableStream {
const child = spawn('ssh', args, { stdio: ['ignore', 'pipe', 'pipe'] });
const stderr: Buffer[] = [];
child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk));
child.on('close', (code) => {
if (code !== 0) {
child.stdout.destroy(new Error(Buffer.concat(stderr).toString('utf8') || `ssh exited with ${code}`));
}
});
return child.stdout;
}
function shellQuote(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`;
}
function prefixedId(prefix: string): string {
return `${prefix}_${randomUUID().replaceAll('-', '').slice(0, 32)}`;
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "dist/tsconfig.tsbuildinfo"
},
"references": [
{ "path": "../../packages/database" },
{ "path": "../../packages/observability" }
],
"include": ["src/**/*.ts"]
}