tenant-backend-service.jsโข24 kB
#!/usr/bin/env node
// Tenant Backend Service - Auto-starting persistent bash service for tenants
import { createServer } from 'http';
import { spawn } from 'child_process';
import { mkdir, readFile } from 'fs/promises';
import { join } from 'path';
console.error('๐ Tenant Backend Service starting...');
// GitHub Gist Upload Service for Network-Resilient File Sharing
class GitHubGistUploader {
constructor(githubPAT) {
this.githubPAT = githubPAT;
this.apiBase = 'https://api.github.com/gists';
}
async uploadFile(filename, content, description = 'File uploaded via MCP Tenant Backend') {
try {
const response = await fetch(this.apiBase, {
method: 'POST',
headers: {
'Authorization': `token ${this.githubPAT}`,
'Content-Type': 'application/json',
'User-Agent': 'MCP-Tenant-Backend/1.0'
},
body: JSON.stringify({
description,
public: false,
files: {
[filename]: {
content: content
}
}
})
});
if (!response.ok) {
const errorData = await response.text();
throw new Error(`GitHub API Error (${response.status}): ${errorData}`);
}
const gist = await response.json();
return {
success: true,
service: 'GitHub Gist',
url: gist.html_url,
raw_url: gist.files[filename].raw_url,
id: gist.id,
message: `โ
File uploaded to GitHub Gist: ${gist.html_url}`
};
} catch (error) {
console.error('โ GitHub Gist upload failed:', error.message);
return {
success: false,
service: 'GitHub Gist',
error: error.message
};
}
}
async testConnectivity() {
try {
const response = await fetch('https://api.github.com/users/octocat', {
method: 'GET',
headers: {
'Authorization': `token ${this.githubPAT}`,
'User-Agent': 'MCP-Tenant-Backend/1.0'
}
});
return response.ok;
} catch {
return false;
}
}
}
// Network Detection and Resilient Upload Manager
class ResilientUploadManager {
constructor(githubPAT) {
this.githubPAT = githubPAT;
this.gistUploader = new GitHubGistUploader(githubPAT);
}
async testServiceConnectivity(service) {
switch (service) {
case '0x0.st':
try {
const response = await fetch('https://0x0.st', { method: 'HEAD', timeout: 5000 });
return response.ok;
} catch {
return false;
}
case 'github_gist':
return await this.gistUploader.testConnectivity();
default:
return false;
}
}
async uploadFromCommand(command, workspace, sessionId) {
// Parse curl command to extract file information
const fileMatch = command.match(/curl\s+-F\s+['"]file=([^'"]+)['"]\s+https?:\/\/([^\s]+)/);
if (!fileMatch) {
return { success: false, error: 'Could not parse upload command' };
}
const [, filePath, targetService] = fileMatch;
const filename = filePath.replace(/^@\.\//, '').replace(/^@/, '');
try {
// Read the file content
const fullPath = join(workspace, filePath.replace(/^@/, ''));
const content = await readFile(fullPath, 'utf8');
// Try 0x0.st first, then fallback to GitHub Gist
const zeroXZeroAvailable = await this.testServiceConnectivity('0x0.st');
if (targetService.includes('0x0.st') && zeroXZeroAvailable) {
return await this.tryZeroXZero(command, workspace, filename, content);
} else {
// Fallback to GitHub Gist
console.error(`๐ 0x0.st not available, using GitHub Gist fallback for ${sessionId.substring(0, 8)}...`);
return await this.gistUploader.uploadFile(filename, content);
}
} catch (error) {
// If file reading fails, try original command
console.error(`โ ๏ธ File read failed, trying original command for ${sessionId.substring(0, 8)}...:`, error.message);
return await this.tryOriginalCommand(command, workspace);
}
}
async tryZeroXZero(command, workspace, filename, content) {
try {
// Execute original 0x0.st command
const result = await this.executeCommand(command, workspace);
if (result.exit_code === 0) {
return {
success: true,
service: '0x0.st',
url: result.stdout.trim(),
message: `โ
File uploaded to 0x0.st: ${result.stdout.trim()}`
};
}
throw new Error(result.stderr || 'Upload failed');
} catch (error) {
console.error('โ 0x0.st upload failed, trying GitHub Gist fallback:', error.message);
// Fallback to GitHub Gist
return await this.gistUploader.uploadFile(filename, content);
}
}
async tryOriginalCommand(command, workspace) {
const result = await this.executeCommand(command, workspace);
return {
success: result.exit_code === 0,
service: 'original',
stdout: result.stdout,
stderr: result.stderr,
exit_code: result.exit_code
};
}
async executeCommand(command, cwd) {
return new Promise((resolve) => {
const { spawn } = require('child_process');
const shell = process.platform === 'win32' ? 'cmd.exe' : '/bin/bash';
const process = spawn(command, [], {
cwd,
shell: true,
env: { ...process.env }
});
let stdout = '';
let stderr = '';
process.stdout.on('data', (data) => {
stdout += data.toString();
});
process.stderr.on('data', (data) => {
stderr += data.toString();
});
process.on('close', (code) => {
resolve({ stdout, stderr, exit_code: code || 0 });
});
process.on('error', (error) => {
resolve({ stdout, stderr: error.message, exit_code: -1 });
});
// Timeout after 30 seconds
setTimeout(() => {
if (!process.killed) {
process.kill('SIGTERM');
resolve({ stdout, stderr: 'Command timed out', exit_code: -1 });
}
}, 30000);
});
}
}
class TenantBackendService {
constructor() {
this.tenantProcesses = new Map(); // sessionId -> { process, workspace, port, lastActivity, timeoutTimer }
this.portCounter = 4000; // Start tenant services from port 4000
this.server = null;
// Configuration for session timeout
this.defaultTimeout = parseInt(process.env.TENANT_SESSION_TIMEOUT) || (30 * 60 * 1000); // 30 minutes default
this.enableTimeout = process.env.TENANT_TIMEOUT_ENABLED !== 'false'; // Enabled by default
this.activityTracking = process.env.TENANT_ACTIVITY_TRACKING !== 'false'; // Track activity to reset timeout
// Initialize resilient upload manager
const githubPAT = process.env.GITHUB_PAT;
this.uploadManager = githubPAT ? new ResilientUploadManager(githubPAT) : null;
if (this.uploadManager) {
console.error('๐ Resilient Upload Manager initialized with GitHub Gist fallback');
} else {
console.error('โ ๏ธ GitHub PAT not found - upload fallback disabled');
}
}
async initialize() {
console.error('๐ง Initializing Tenant Backend Service...');
// Start the main HTTP service
this.server = createServer(async (req, res) => {
// Set CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.setHeader('Access-Control-Max-Age', '86400');
// Handle OPTIONS requests for CORS preflight
if (req.method === 'OPTIONS') {
res.writeHead(200);
res.end();
return;
}
try {
const url = new URL(req.url || '/', `http://${req.headers.host}`);
if (req.method === 'POST' && url.pathname === '/tenant-bash') {
await this.handleTenantBash(req, res);
} else if (req.method === 'POST' && url.pathname === '/start-tenant') {
await this.handleStartTenant(req, res);
} else if (req.method === 'POST' && url.pathname === '/stop-tenant') {
await this.handleStopTenant(req, res);
} else if (req.method === 'GET' && url.pathname === '/status') {
await this.handleStatus(req, res);
} else {
res.writeHead(404);
res.end(JSON.stringify({ error: 'Not found' }));
}
} catch (error) {
console.error('โ Tenant service error:', error.message);
res.writeHead(500);
res.end(JSON.stringify({ error: error.message }));
}
});
const PORT = process.env.TENANT_SERVICE_PORT || 3100;
this.server.listen(PORT, () => {
console.error(`โ
Tenant Backend Service ready - listening on port ${PORT}`);
console.error(`๐ก Tenant endpoint: http://localhost:${PORT}/tenant-bash`);
console.error(`๐ฏ Status endpoint: http://localhost:${PORT}/status`);
});
// Auto-cleanup on exit
process.on('SIGINT', () => this.cleanup());
process.on('SIGTERM', () => this.cleanup());
}
async handleStartTenant(req, res) {
let body = '';
for await (const chunk of req) {
body += chunk;
}
const { sessionId, workspace } = JSON.parse(body);
if (!sessionId) {
res.writeHead(400);
res.end(JSON.stringify({ error: 'Session ID required' }));
return;
}
// Create isolated workspace for tenant
const tenantWorkspace = workspace || `/tmp/tenant-${sessionId}`;
await mkdir(tenantWorkspace, { recursive: true });
// Start persistent bash process for tenant
const bashShell = process.platform === 'win32' ? 'bash.exe' : '/bin/bash';
const bashProcess = spawn(bashShell, [], {
cwd: tenantWorkspace,
stdio: ['pipe', 'pipe', 'pipe'],
env: {
...process.env,
PS1: `[tenant-${sessionId.substring(0, 8)}]\$ `,
TERM: 'xterm-256color',
GIT_TERMINAL_PROMPT: '0', // Prevent interactive prompts
GIT_ASKPASS: 'echo', // No password prompts
PWD: tenantWorkspace // Set working directory
// Removed GIT_DIR and GIT_WORK_TREE to prevent conflicts with git clone operations
}
});
const port = this.portCounter++;
this.tenantProcesses.set(sessionId, {
process: bashProcess,
workspace: tenantWorkspace,
port: port,
startTime: Date.now(),
lastActivity: Date.now(),
timeoutTimer: null
});
// Setup activity timeout if enabled
if (this.enableTimeout) {
this.setupActivityTimeout(sessionId);
}
console.error(`๐๏ธ Started tenant backend: ${sessionId.substring(0, 8)}... in ${tenantWorkspace}`);
// Handle bash process lifecycle
bashProcess.on('exit', (code) => {
console.error(`๐ Tenant process exited: ${sessionId.substring(0, 8)}... (code: ${code})`);
this.tenantProcesses.delete(sessionId);
});
bashProcess.on('error', (error) => {
console.error(`โ Tenant process error: ${sessionId.substring(0, 8)}... - ${error.message}`);
});
res.writeHead(200);
res.end(JSON.stringify({
success: true,
sessionId,
workspace: tenantWorkspace,
port,
message: `Tenant backend started for ${sessionId.substring(0, 8)}...`
}));
}
async handleTenantBash(req, res) {
let body = '';
for await (const chunk of req) {
body += chunk;
}
const { sessionId, command } = JSON.parse(body);
if (!sessionId || !command) {
res.writeHead(400);
res.end(JSON.stringify({ error: 'Session ID and command required' }));
return;
}
const tenant = this.tenantProcesses.get(sessionId);
if (!tenant) {
res.writeHead(404);
res.end(JSON.stringify({ error: 'Tenant session not found' }));
return;
}
try {
// Update activity timestamp and reset timeout if tracking enabled
if (this.activityTracking && this.enableTimeout) {
tenant.lastActivity = Date.now();
this.resetActivityTimeout(sessionId);
}
// Check if this is an upload command and we have upload manager
if (this.uploadManager && command.includes('curl -F') && command.includes('0x0.st')) {
console.error(`๐ Upload command detected, using resilient upload manager for ${sessionId.substring(0, 8)}...`);
const uploadResult = await this.uploadManager.uploadFromCommand(command, tenant.workspace, sessionId);
if (uploadResult.success) {
res.writeHead(200);
res.end(JSON.stringify({
success: true,
sessionId,
command,
stdout: uploadResult.message || uploadResult.url,
stderr: '',
exit_code: 0,
workspace: tenant.workspace,
isolation: 'persistent_tenant_backend_with_resilient_upload',
timeout_settings: {
enabled: this.enableTimeout,
timeout_ms: this.defaultTimeout,
activity_tracking: this.activityTracking
},
upload_service: uploadResult.service,
upload_url: uploadResult.url,
upload_raw_url: uploadResult.raw_url,
fallback_used: uploadResult.service !== '0x0.st',
pat_used: 'Not applicable for upload commands'
}));
} else {
res.writeHead(500);
res.end(JSON.stringify({
success: false,
error: uploadResult.error,
sessionId,
command,
workspace: tenant.workspace
}));
}
} else {
// Execute command in tenant's persistent bash process
const result = await this.executeInTenantBash(tenant.process, command, tenant.workspace, sessionId);
res.writeHead(200);
res.end(JSON.stringify({
success: true,
sessionId,
command,
stdout: result.stdout,
stderr: result.stderr,
exit_code: result.exit_code,
workspace: tenant.workspace,
isolation: 'persistent_tenant_backend',
timeout_settings: {
enabled: this.enableTimeout,
timeout_ms: this.defaultTimeout,
activity_tracking: this.activityTracking
},
pat_used: result.pat_used
}));
}
} catch (error) {
res.writeHead(500);
res.end(JSON.stringify({
success: false,
error: error.message,
sessionId,
command
}));
}
}
async executeInTenantBash(bashProcess, command, workspace, sessionId) {
return new Promise(async (resolve) => {
let stdout = '';
let stderr = '';
// Enhanced git command handling with PAT support (ported from server-universal.js)
let enhancedCommand = command;
const githubPAT = process.env.GITHUB_PAT;
const hasPAT = !!githubPAT;
// Automatically configure git for GitHub operations if PAT is available
if (hasPAT && command.startsWith('git')) {
// For git clone, embed PAT in URL
if (command.includes('git clone https://github.com/')) {
enhancedCommand = command.replace(
'https://github.com/',
`https://${githubPAT}@github.com/`
);
console.error('๐ Auto-configured git clone with PAT [HIDDEN]');
}
// For git push, configure remote URL with PAT
else if (command.includes('git push')) {
// First configure the remote URL with PAT, then push
enhancedCommand = `
git remote set-url origin https://${githubPAT}@github.com/$(git config --get remote.origin.url | sed 's/.*github\\.com\\///')
git push origin
`;
console.error('๐ Auto-configured git push with PAT [HIDDEN]');
}
// For other git operations that might need authentication
else if (command.includes('git pull') || command.includes('git fetch')) {
enhancedCommand = command.replace(
'origin',
`https://${githubPAT}@github.com/$(git config --get remote.origin.url | sed 's/.*github\\.com\\///')`
);
console.error('๐ Auto-configured git pull/fetch with PAT [HIDDEN]');
}
}
// Use a proper command execution approach instead of fixed timeout
const { spawn } = await import('child_process');
// Use cross-platform shell detection
const shell = process.platform === 'win32' ? 'cmd.exe' : '/bin/bash';
console.error(`๐ง Using shell: ${shell} on platform: ${process.platform}`);
// Spawn a temporary process for this specific command
const commandProcess = spawn(enhancedCommand, [], {
cwd: workspace,
shell: true,
env: {
...process.env,
GIT_TERMINAL_PROMPT: '0', // Prevent interactive prompts
GIT_ASKPASS: 'echo', // No password prompts
PWD: workspace // Set working directory
// Removed GIT_DIR and GIT_WORK_TREE to prevent conflicts with git clone operations
}
});
let outputBuffer = '';
let errorBuffer = '';
commandProcess.stdout.on('data', (data) => {
const output = data.toString();
stdout += output;
outputBuffer += output;
});
commandProcess.stderr.on('data', (data) => {
const error = data.toString();
stderr += error;
errorBuffer += error;
});
commandProcess.on('close', (code) => {
console.error(`๐ Tenant command completed: ${sessionId.substring(0, 8)}... (exit code: ${code})`);
resolve({
stdout: stdout,
stderr: stderr,
exit_code: code || 0,
pat_used: hasPAT && command.startsWith('git') ? 'Auto-configured with stored PAT' : undefined,
command: command,
enhanced_command: enhancedCommand !== command ? enhancedCommand : undefined
});
});
commandProcess.on('error', (error) => {
console.error(`โ Tenant command error: ${sessionId.substring(0, 8)}... - ${error.message}`);
resolve({
stdout: stdout,
stderr: stderr + error.message,
exit_code: -1,
pat_used: hasPAT && command.startsWith('git') ? 'Auto-configured with stored PAT' : undefined,
command: command,
enhanced_command: enhancedCommand !== command ? enhancedCommand : undefined
});
});
// Add timeout safety net (longer than before)
const timeout = setTimeout(() => {
if (!commandProcess.killed) {
console.error(`โฐ Tenant command timeout: ${sessionId.substring(0, 8)}... - terminating`);
commandProcess.kill('SIGTERM');
resolve({
stdout: stdout,
stderr: stderr + '\nCommand timed out after 30 seconds',
exit_code: -1,
pat_used: hasPAT && command.startsWith('git') ? 'Auto-configured with stored PAT' : undefined,
command: command,
enhanced_command: enhancedCommand !== command ? enhancedCommand : undefined,
timeout: true
});
}
}, 30000); // 30 seconds timeout instead of 1 second
commandProcess.on('close', () => {
clearTimeout(timeout);
});
});
}
async handleStopTenant(req, res) {
let body = '';
for await (const chunk of req) {
body += chunk;
}
const { sessionId } = JSON.parse(body);
if (!sessionId) {
res.writeHead(400);
res.end(JSON.stringify({ error: 'Session ID required' }));
return;
}
const tenant = this.tenantProcesses.get(sessionId);
if (!tenant) {
res.writeHead(404);
res.end(JSON.stringify({ error: 'Tenant session not found' }));
return;
}
// Terminate tenant process
tenant.process.kill('SIGTERM');
this.tenantProcesses.delete(sessionId);
console.error(`๐ Stopped tenant backend: ${sessionId.substring(0, 8)}...`);
res.writeHead(200);
res.end(JSON.stringify({
success: true,
sessionId,
message: `Tenant backend stopped for ${sessionId.substring(0, 8)}...`
}));
}
async handleStatus(req, res) {
const status = Array.from(this.tenantProcesses.entries()).map(([sessionId, tenant]) => {
const lastActivityAgo = Date.now() - tenant.lastActivity;
const timeUntilTimeout = this.enableTimeout ? this.defaultTimeout - lastActivityAgo : -1;
return {
sessionId: sessionId.substring(0, 8) + '...',
workspace: tenant.workspace,
port: tenant.port,
uptime: Date.now() - tenant.startTime,
last_activity: lastActivityAgo,
time_until_timeout: timeUntilTimeout > 0 ? timeUntilTimeout : 0,
timeout_enabled: this.enableTimeout,
timeout_minutes: this.defaultTimeout / 1000 / 60,
active: true
};
});
res.writeHead(200);
res.end(JSON.stringify({
service: 'Tenant Backend Service',
uptime: process.uptime(),
activeTenants: this.tenantProcesses.size,
configuration: {
timeout_enabled: this.enableTimeout,
timeout_minutes: this.defaultTimeout / 1000 / 60,
activity_tracking: this.activityTracking
},
tenants: status
}));
}
setupActivityTimeout(sessionId) {
const tenant = this.tenantProcesses.get(sessionId);
if (!tenant || !this.enableTimeout) return;
// Clear existing timer
if (tenant.timeoutTimer) {
clearTimeout(tenant.timeoutTimer);
}
// Set new timeout timer
tenant.timeoutTimer = setTimeout(() => {
console.error(`โฐ Session timeout for tenant: ${sessionId.substring(0, 8)}... (${this.defaultTimeout / 1000 / 60} minutes inactivity)`);
this.terminateTenantSession(sessionId);
}, this.defaultTimeout);
}
resetActivityTimeout(sessionId) {
if (!this.activityTracking || !this.enableTimeout) return;
const tenant = this.tenantProcesses.get(sessionId);
if (tenant) {
console.error(`๐ Activity detected for tenant: ${sessionId.substring(0, 8)}... - resetting timeout`);
this.setupActivityTimeout(sessionId);
}
}
terminateTenantSession(sessionId) {
const tenant = this.tenantProcesses.get(sessionId);
if (!tenant) return;
console.error(`๐ Terminating inactive tenant session: ${sessionId.substring(0, 8)}...`);
// Clear timeout timer
if (tenant.timeoutTimer) {
clearTimeout(tenant.timeoutTimer);
}
// Terminate bash process gracefully
tenant.process.kill('SIGTERM');
this.tenantProcesses.delete(sessionId);
console.error(`โ
Inactive tenant session terminated: ${sessionId.substring(0, 8)}...`);
}
cleanup() {
console.error('๐งน Cleaning up tenant processes...');
for (const [sessionId, tenant] of this.tenantProcesses) {
console.error(`๐ Stopping tenant: ${sessionId.substring(0, 8)}...`);
// Clear timeout timer
if (tenant.timeoutTimer) {
clearTimeout(tenant.timeoutTimer);
}
tenant.process.kill('SIGTERM');
}
this.tenantProcesses.clear();
if (this.server) {
this.server.close();
}
process.exit(0);
}
}
// Auto-start the service
const tenantService = new TenantBackendService();
tenantService.initialize().catch(console.error);
// Auto-restart on crash
process.on('uncaughtException', (error) => {
console.error('๐ฅ Uncaught exception, restarting service:', error.message);
tenantService.cleanup();
});
process.on('unhandledRejection', (reason) => {
console.error('๐ฅ Unhandled rejection, restarting service:', reason);
tenantService.cleanup();
});